diff --git a/AGENTS.md b/AGENTS.md index 76ac6776..b7ba3589 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -157,6 +157,7 @@ When touching transaction and position flows, validation MUST include all releva 22. **Chain-scoped identity integrity**: all market/token/route identity checks must be chain-scoped and use canonical identifiers (`chainId + market.uniqueKey` or `chainId + address`), including matching, dedupe keys, routing, and trust/allowlist gates. 23. **Bundler residual-asset integrity**: any flash-loan transaction path that routes assets through Bundler/adapter balances (especially ERC4626 unwind paths) must end with explicit trailing asset sweeps to the intended recipient and must keep execute-time slippage bounds consistent with quote-time slippage settings. + ### REQUIRED: Regression Rule Capture After fixing any user-reported bug in a high-impact flow: diff --git a/src/components/Input/Input.tsx b/src/components/Input/Input.tsx index 58505688..7e53ca79 100644 --- a/src/components/Input/Input.tsx +++ b/src/components/Input/Input.tsx @@ -1,7 +1,7 @@ -import { useCallback, useState, useEffect } from 'react'; +import { useCallback, useState, useEffect, useRef } from 'react'; import { formatUnits, parseUnits } from 'viem'; -import { isValidDecimalInput, sanitizeDecimalInput, toParseableDecimalInput } from '@/utils/decimal-input'; +import { hasExcessFractionDigits, isValidDecimalInput, sanitizeDecimalInput, toParseableDecimalInput } from '@/utils/decimal-input'; type InputProps = { decimals: number; @@ -15,6 +15,7 @@ type InputProps = { error?: string | null; // optional error message to render below the input endAdornment?: React.ReactNode; inputClassName?: string; + debounceSetValueMs?: number; }; const formatInputAmount = (value: bigint, decimals: number): string => { @@ -36,10 +37,45 @@ export default function Input({ error, endAdornment, inputClassName, + debounceSetValueMs = 0, }: InputProps): JSX.Element { // State for the input text const [inputAmount, setInputAmount] = useState(value ? formatInputAmount(value, decimals) : '0'); const [isFocused, setIsFocused] = useState(false); + const setValueDebounceTimerRef = useRef | null>(null); + const pendingSetValueRef = useRef(null); + const setValueRef = useRef(setValue); + + const clearSetValueDebounce = useCallback(() => { + if (setValueDebounceTimerRef.current == null) return; + clearTimeout(setValueDebounceTimerRef.current); + setValueDebounceTimerRef.current = null; + }, []); + + const flushPendingSetValue = useCallback(() => { + clearSetValueDebounce(); + const pendingValue = pendingSetValueRef.current; + if (pendingValue == null) return; + pendingSetValueRef.current = null; + setValueRef.current(pendingValue); + }, [clearSetValueDebounce]); + + const scheduleSetValue = useCallback( + (nextValue: bigint) => { + pendingSetValueRef.current = nextValue; + clearSetValueDebounce(); + + if (debounceSetValueMs <= 0) { + flushPendingSetValue(); + return; + } + + setValueDebounceTimerRef.current = setTimeout(() => { + flushPendingSetValue(); + }, debounceSetValueMs); + }, + [clearSetValueDebounce, debounceSetValueMs, flushPendingSetValue], + ); // Update input text when value prop changes useEffect(() => { @@ -49,6 +85,17 @@ export default function Input({ } }, [value, decimals, isFocused]); + useEffect(() => { + setValueRef.current = setValue; + }, [setValue]); + + useEffect( + () => () => { + clearSetValueDebounce(); + }, + [clearSetValueDebounce], + ); + const onInputChange = useCallback( (e: React.ChangeEvent) => { // update the shown input text regardless @@ -56,11 +103,14 @@ export default function Input({ if (!isValidDecimalInput(normalizedInput)) { return; } + if (hasExcessFractionDigits(normalizedInput, decimals)) { + return; + } setInputAmount(normalizedInput); const parseableInput = toParseableDecimalInput(normalizedInput); if (!parseableInput) { - setValue(BigInt(0)); + scheduleSetValue(BigInt(0)); if (setError) setError(null); return; } @@ -71,36 +121,40 @@ export default function Input({ if (max !== undefined && inputBigInt > max) { if (setError) setError(exceedMaxErrMessage ?? 'Input exceeds max'); if (allowExceedMax) { - setValue(inputBigInt); + scheduleSetValue(inputBigInt); } return; } - setValue(inputBigInt); + scheduleSetValue(inputBigInt); if (setError) setError(null); } catch { if (setError) setError('Invalid input'); } }, - [decimals, setError, setInputAmount, setValue, max, exceedMaxErrMessage, allowExceedMax], + [decimals, setError, setInputAmount, max, exceedMaxErrMessage, allowExceedMax, scheduleSetValue], ); // if max is clicked, set the input to the max value const handleMax = useCallback(() => { + clearSetValueDebounce(); + pendingSetValueRef.current = null; if (max) { setValue(max); // set readable input setInputAmount(formatInputAmount(max, decimals)); + if (setError) setError(null); } if (onMaxClick) onMaxClick(); - }, [max, decimals, setInputAmount, setValue, onMaxClick]); + }, [clearSetValueDebounce, max, decimals, setInputAmount, setValue, setError, onMaxClick]); const handleBlur = useCallback(() => { + flushPendingSetValue(); setIsFocused(false); if (value !== undefined) { setInputAmount(formatInputAmount(value, decimals)); } - }, [value, decimals]); + }, [flushPendingSetValue, value, decimals]); return (
diff --git a/src/features/market-detail/components/pull-liquidity-modal.tsx b/src/features/market-detail/components/pull-liquidity-modal.tsx index 9449a9e8..fc2956a7 100644 --- a/src/features/market-detail/components/pull-liquidity-modal.tsx +++ b/src/features/market-detail/components/pull-liquidity-modal.tsx @@ -1,9 +1,10 @@ 'use client'; import { useState, useMemo, useCallback } from 'react'; -import { type Address, formatUnits, parseUnits } from 'viem'; +import { type Address, formatUnits } from 'viem'; import { BsArrowRepeat } from 'react-icons/bs'; import { Modal, ModalHeader, ModalBody, ModalFooter } from '@/components/common/Modal'; +import Input from '@/components/Input/Input'; import { Button } from '@/components/ui/button'; import { Spinner } from '@/components/ui/spinner'; import { TokenIcon } from '@/components/shared/token-icon'; @@ -41,7 +42,7 @@ type PullLiquidityModalProps = { */ export function PullLiquidityModal({ market, network, onOpenChange, onSuccess }: PullLiquidityModalProps) { const [selectedVaultAddress, setSelectedVaultAddress] = useState(null); - const [pullAmount, setPullAmount] = useState(''); + const [pullAmount, setPullAmount] = useState(0n); const supplyingVaults = market.supplyingVaults ?? []; const supplyingVaultAddresses = useMemo(() => supplyingVaults.map((v) => v.address), [supplyingVaults]); @@ -88,15 +89,7 @@ export function PullLiquidityModal({ market, network, onOpenChange, onSuccess }: const maxPullable = liveMaxPullable ?? apiMaxPullable; - // Parse pull amount - const parsedAmount = useMemo(() => { - if (!pullAmount || pullAmount === '' || pullAmount === '0') return 0n; - try { - return parseUnits(pullAmount, decimals); - } catch { - return 0n; - } - }, [pullAmount, decimals]); + const parsedAmount = pullAmount; // Auto-compute withdrawals using live data when available const autoWithdrawals = useMemo(() => { @@ -131,15 +124,9 @@ export function PullLiquidityModal({ market, network, onOpenChange, onSuccess }: const handleVaultSelect = useCallback((address: string) => { setSelectedVaultAddress(address); - setPullAmount(''); + setPullAmount(0n); }, []); - const handleSetMax = useCallback(() => { - if (maxPullable > 0n) { - setPullAmount(formatUnits(maxPullable, decimals)); - } - }, [maxPullable, decimals]); - const handlePullLiquidity = useCallback(async () => { if (!market.collateralAsset || !selectedVault || autoWithdrawals.length === 0) return; @@ -260,7 +247,7 @@ export function PullLiquidityModal({ market, network, onOpenChange, onSuccess }:

Pull Amount

- setPullAmount(e.target.value)} - onKeyDown={(e) => { - if (e.key === '-' || e.key === 'e') e.preventDefault(); - }} - className="bg-hovered h-10 w-full rounded p-2 focus:border-primary focus:outline-none" + inputClassName="h-10 rounded bg-hovered px-3 py-2 text-base font-medium tabular-nums" /> - {maxPullable > 0n && ( - - )}
{validationError && parsedAmount > 0n &&

{validationError}

} diff --git a/src/modals/borrow/components/add-collateral-and-borrow.tsx b/src/modals/borrow/components/add-collateral-and-borrow.tsx index 4ca363c4..d65c0b87 100644 --- a/src/modals/borrow/components/add-collateral-and-borrow.tsx +++ b/src/modals/borrow/components/add-collateral-and-borrow.tsx @@ -21,11 +21,9 @@ import { clampTargetLtv, computeLtv, computeTargetCollateralAmount, - formatEditableLtvPercent, formatLtvPercent, getCollateralValueInLoan, ltvWadToPercent, - normalizeEditablePercentInput, percentToLtvWad, } from './helpers'; @@ -54,8 +52,6 @@ export function AddCollateralAndBorrow({ const [borrowAmount, setBorrowAmount] = useState(0n); const [showLtvInput, setShowLtvInput] = useState(false); const [lastEditedField, setLastEditedField] = useState<'collateral' | 'borrow'>('borrow'); - const [ltvInput, setLtvInput] = useState('0'); - const [isEditingLtvInput, setIsEditingLtvInput] = useState(false); const [ltvBorrowHint, setLtvBorrowHint] = useState(null); const [collateralInputError, setCollateralInputError] = useState(null); const [borrowInputError, setBorrowInputError] = useState(null); @@ -112,11 +108,6 @@ export function AddCollateralAndBorrow({ const maxTargetLtvPercent = useMemo(() => Math.min(100, ltvWadToPercent(clampTargetLtv(lltv, lltv))), [lltv]); - useEffect(() => { - if (isEditingLtvInput) return; - setLtvInput(formatEditableLtvPercent(ltvWadToPercent(projectedLTV), maxTargetLtvPercent)); - }, [projectedLTV, maxTargetLtvPercent, isEditingLtvInput]); - useEffect(() => { if (!showLtvInput) { setLtvBorrowHint(null); @@ -156,19 +147,9 @@ export function AddCollateralAndBorrow({ setBorrowAmount(value); }, []); - const handleLtvInputChange = useCallback( - (value: string) => { - const normalizedInput = normalizeEditablePercentInput(value); - if (normalizedInput == null) return; - setLtvInput(normalizedInput); + const applyTargetLtv = useCallback( + (clampedTargetLtv: bigint) => { setLtvBorrowHint(null); - if (normalizedInput === '') return; - - const parsedPercent = Number.parseFloat(normalizedInput); - if (!Number.isFinite(parsedPercent)) return; - const clampedPercent = clampEditablePercent(parsedPercent, maxTargetLtvPercent); - - const clampedTargetLtv = clampTargetLtv(percentToLtvWad(clampedPercent), lltv); if (clampedTargetLtv <= 0n) return; if (lastEditedField === 'borrow') { @@ -197,13 +178,9 @@ export function AddCollateralAndBorrow({ } setBorrowAmount(signedBorrowDelta); setBorrowInputError(signedBorrowDelta > effectiveAvailableLiquidity ? 'Exceeds available liquidity' : null); - return; } - - setLtvBorrowHint(null); }, [ - lltv, lastEditedField, currentBorrowAssets, borrowAmount, @@ -214,21 +191,8 @@ export function AddCollateralAndBorrow({ collateralTokenBalance, projectedCollateralAssets, effectiveAvailableLiquidity, - maxTargetLtvPercent, ], ); - - const handleLtvInputBlur = useCallback(() => { - setIsEditingLtvInput(false); - const parsedPercent = Number.parseFloat(ltvInput.replace(',', '.')); - if (!Number.isFinite(parsedPercent)) { - setLtvInput(formatEditableLtvPercent(ltvWadToPercent(projectedLTV), maxTargetLtvPercent)); - return; - } - const clampedPercent = clampEditablePercent(parsedPercent, maxTargetLtvPercent); - const clampedTargetLtv = clampTargetLtv(percentToLtvWad(clampedPercent), lltv); - setLtvInput(formatEditableLtvPercent(ltvWadToPercent(clampedTargetLtv), maxTargetLtvPercent)); - }, [ltvInput, projectedLTV, lltv, maxTargetLtvPercent]); const amountInputClassName = 'h-10 rounded bg-surface px-3 py-2 text-base font-medium tabular-nums'; const ltvInputClassName = 'h-10 w-full rounded bg-hovered p-2 pr-8 text-base font-medium tabular-nums focus:border-primary focus:outline-none'; @@ -374,27 +338,23 @@ export function AddCollateralAndBorrow({

Target LTV

- setIsEditingLtvInput(true)} - onChange={(event) => handleLtvInputChange(event.target.value)} - onBlur={handleLtvInputBlur} - className={ltvInputClassName} + { + setLtvBorrowHint(null); + const clampedPercent = clampEditablePercent(Number(nextTargetLtvBps) / 100, maxTargetLtvPercent); + applyTargetLtv(clampTargetLtv(percentToLtvWad(clampedPercent), lltv)); + }} + value={BigInt(Math.round(clampEditablePercent(ltvWadToPercent(projectedLTV), maxTargetLtvPercent) * 100))} + inputClassName={ltvInputClassName} + endAdornment={%} /> - %
{ltvBorrowHint && lastEditedField === 'collateral' && (