Overview
src/hooks/useSorobanQuery.ts's useLockAssetsFeePreview keys its query directly on the raw, un-debounced amount string:
export const useLockAssetsFeePreview = (args: { publicKey?: string | null; poolContractId?: string | null; amount?: string; }) => {
const amount = args.amount?.trim() ?? '';
const numericAmount = Number(amount);
return useQuery({
queryKey: ['lockAssetsFeePreview', args.publicKey, args.poolContractId, amount],
queryFn: () => simulateLockAssets({ publicKey: args.publicKey!, poolContractId: args.poolContractId!, amount }),
enabled: !!args.publicKey && !!args.poolContractId && !!amount && Number.isFinite(numericAmount) && numericAmount > 0,
staleTime: 10000,
retry: 1,
});
};
src/app/farm/page.tsx's DepositModal wires this straight to the amount <Input>'s live value: amount: amountValid ? trimmedAmount : "" inside feePreview = useLockAssetsFeePreview({ ..., amount: ... }) (farm/page.tsx:122-126), where amount is useState-backed and updated on every onChange (farm/page.tsx:97,288). Because the queryKey array includes the literal amount string, React Query treats every keystroke that changes a still-valid amount (e.g. typing "1" → "12" → "123" → "123." → "123.5") as a new, distinct query — each one triggers simulateLockAssets, which builds a fresh TransactionBuilder, calls server.getAccount(...), then server.simulateTransaction(...) (soroban.ts:281-298) — a real, non-trivial round trip to the Soroban RPC endpoint. A user typing a 5-digit amount fires up to 5 separate simulate-transaction RPC calls in the span of a second or two, none of which are cancelled when superseded (React Query doesn't abort in-flight queryFns for a since-changed key by default here), so slow-typing users on a slow connection can have multiple stale simulate responses racing to resolve, and every user pays the RPC cost of every intermediate, never-submitted amount they typed on the way to their final value.
Requirements
- Debounce the
amount value fed into useLockAssetsFeePreview's queryKey/queryFn (e.g. 300-500ms after the user stops typing), matching the debounce pattern already used elsewhere in this codebase for a structurally identical problem (useLeaderboard's SEARCH_DEBOUNCE_MS = 300, useLeaderboard.ts:15,35-38).
- Ensure the fee-preview UI (
feePreview.isFetching → "Simulating..." text, farm/page.tsx:266-271) still communicates "a simulation is pending" during the debounce window, not just during the network request itself, so debouncing doesn't read as an unresponsive input.
- Confirm superseded in-flight simulate calls don't race and overwrite a fresher result (React Query generally resolves this by query-key identity, but verify explicitly given the amount-per-keystroke key churn this hook currently produces).
Acceptance Criteria
Additional Notes
More precise references
src/hooks/useSorobanQuery.ts:82-107 (useLockAssetsFeePreview) — confirmed queryKey includes the raw amount string with no debouncing anywhere in this hook.
src/app/farm/page.tsx:97 (const [amount, setAmount] = useState("")), :288 (onChange={(event) => setAmount(event.target.value)}), :122-126 (feePreview = useLockAssetsFeePreview({ ..., amount: amountValid ? trimmedAmount : "" })) — confirmed the amount field is wired directly, live, with no intermediate debounce state.
src/lib/soroban.ts:281-298 (simulateLockAssets) — confirmed each call performs server.getAccount(args.publicKey) followed by server.simulateTransaction(transaction), i.e. two real RPC round trips per invocation, not a cheap local computation.
src/hooks/useLeaderboard.ts:15,35-38 — confirmed the existing, working debounce pattern (SEARCH_DEBOUNCE_MS = 300, a useEffect + setTimeout gate between searchInput and the value actually used) that this hook should mirror.
Additional edge cases
PoolDetailClient.tsx's deposit modal (src/app/farm/[poolId]/PoolDetailClient.tsx) does not use useLockAssetsFeePreview at all — it has no fee-preview UI, so this specific over-fetching bug is scoped to the Farm page's DepositModal only; worth noting so the fix isn't assumed to also need to touch PoolDetailClient.
- Because
staleTime: 10000 is already set, a user who pauses mid-typing for 10+ seconds and then resumes typing the same eventual amount from a fresh digit onward will still re-trigger the full per-keystroke storm from scratch — the debounce fix should be the primary mitigation, with staleTime remaining a secondary cache-freshness setting, not a substitute for debouncing input changes.
- The
canSubmit gate on the Farm page's submit button (farm/page.tsx:139-152) depends on !feePreview.isLoading && !feePreview.isFetching && !!feePreview.data — with debouncing, verify the button correctly stays disabled through the debounce window (not just the network-fetch window), so a user can't click "Deposit" using a stale fee preview computed for a previously-typed, different amount.
Implementation sketch
// in DepositModal (farm/page.tsx), alongside the existing `amount` state:
const [debouncedAmount, setDebouncedAmount] = useState("");
useEffect(() => {
const id = setTimeout(() => setDebouncedAmount(trimmedAmount), 350);
return () => clearTimeout(id);
}, [trimmedAmount]);
const feePreview = useLockAssetsFeePreview({
publicKey,
poolContractId: selectedContractAddress,
amount: amountValid ? debouncedAmount : "",
});
(Alternatively, move the debounce inside useLockAssetsFeePreview itself via an internal debounced-value hook, so every future caller gets the fix automatically.)
Test/reproduction plan
- Fake-timers test: mock
simulateLockAssets; render DepositModal; fire onChange five times in quick succession with progressively longer amount strings (no act-flushed delay between them); advance timers past the debounce window; assert simulateLockAssets was called exactly once, with the final amount value.
- Regression: assert a single, non-rapid amount entry (type, then wait) still produces a fee preview as it does today.
Cross-references
Overview
src/hooks/useSorobanQuery.ts'suseLockAssetsFeePreviewkeys its query directly on the raw, un-debounced amount string:src/app/farm/page.tsx'sDepositModalwires this straight to the amount<Input>'s live value:amount: amountValid ? trimmedAmount : ""insidefeePreview = useLockAssetsFeePreview({ ..., amount: ... })(farm/page.tsx:122-126), whereamountisuseState-backed and updated on everyonChange(farm/page.tsx:97,288). Because thequeryKeyarray includes the literalamountstring, React Query treats every keystroke that changes a still-valid amount (e.g. typing"1"→"12"→"123"→"123."→"123.5") as a new, distinct query — each one triggerssimulateLockAssets, which builds a freshTransactionBuilder, callsserver.getAccount(...), thenserver.simulateTransaction(...)(soroban.ts:281-298) — a real, non-trivial round trip to the Soroban RPC endpoint. A user typing a 5-digit amount fires up to 5 separate simulate-transaction RPC calls in the span of a second or two, none of which are cancelled when superseded (React Query doesn't abort in-flightqueryFns for a since-changed key by default here), so slow-typing users on a slow connection can have multiple stale simulate responses racing to resolve, and every user pays the RPC cost of every intermediate, never-submitted amount they typed on the way to their final value.Requirements
amountvalue fed intouseLockAssetsFeePreview'squeryKey/queryFn(e.g. 300-500ms after the user stops typing), matching the debounce pattern already used elsewhere in this codebase for a structurally identical problem (useLeaderboard'sSEARCH_DEBOUNCE_MS = 300,useLeaderboard.ts:15,35-38).feePreview.isFetching→ "Simulating..." text,farm/page.tsx:266-271) still communicates "a simulation is pending" during the debounce window, not just during the network request itself, so debouncing doesn't read as an unresponsive input.Acceptance Criteria
simulateLockAssets/RPC call (after the debounce window elapses), not one per keystroke.amountchanges within the debounce window produce exactly onequeryFninvocation, with the value from the last change.Additional Notes
More precise references
src/hooks/useSorobanQuery.ts:82-107(useLockAssetsFeePreview) — confirmedqueryKeyincludes the rawamountstring with no debouncing anywhere in this hook.src/app/farm/page.tsx:97(const [amount, setAmount] = useState("")),:288(onChange={(event) => setAmount(event.target.value)}),:122-126(feePreview = useLockAssetsFeePreview({ ..., amount: amountValid ? trimmedAmount : "" })) — confirmed the amount field is wired directly, live, with no intermediate debounce state.src/lib/soroban.ts:281-298(simulateLockAssets) — confirmed each call performsserver.getAccount(args.publicKey)followed byserver.simulateTransaction(transaction), i.e. two real RPC round trips per invocation, not a cheap local computation.src/hooks/useLeaderboard.ts:15,35-38— confirmed the existing, working debounce pattern (SEARCH_DEBOUNCE_MS = 300, auseEffect+setTimeoutgate betweensearchInputand the value actually used) that this hook should mirror.Additional edge cases
PoolDetailClient.tsx's deposit modal (src/app/farm/[poolId]/PoolDetailClient.tsx) does not useuseLockAssetsFeePreviewat all — it has no fee-preview UI, so this specific over-fetching bug is scoped to the Farm page'sDepositModalonly; worth noting so the fix isn't assumed to also need to touchPoolDetailClient.staleTime: 10000is already set, a user who pauses mid-typing for 10+ seconds and then resumes typing the same eventual amount from a fresh digit onward will still re-trigger the full per-keystroke storm from scratch — the debounce fix should be the primary mitigation, withstaleTimeremaining a secondary cache-freshness setting, not a substitute for debouncing input changes.canSubmitgate on the Farm page's submit button (farm/page.tsx:139-152) depends on!feePreview.isLoading && !feePreview.isFetching && !!feePreview.data— with debouncing, verify the button correctly stays disabled through the debounce window (not just the network-fetch window), so a user can't click "Deposit" using a stale fee preview computed for a previously-typed, different amount.Implementation sketch
(Alternatively, move the debounce inside
useLockAssetsFeePreviewitself via an internal debounced-value hook, so every future caller gets the fix automatically.)Test/reproduction plan
simulateLockAssets; renderDepositModal; fireonChangefive times in quick succession with progressively longer amount strings (noact-flushed delay between them); advance timers past the debounce window; assertsimulateLockAssetswas called exactly once, with the final amount value.Cross-references
SorobanServiceissues a freshsimulateTransactionper call with no de-duplication across concurrently-mounted components) — that issue is about de-duplicating concurrent calls across components; this issue is about eliminating redundant sequential calls from a single input field's own keystroke stream, a different mechanism with a different fix (debouncing vs. request coalescing).