Skip to content

useLockAssetsFeePreview fires a fresh Soroban simulateTransaction RPC call on every keystroke in the deposit amount field, with no debounce #134

Description

@prodbycorne

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

  • Typing a multi-character amount in one continuous keystroke burst results in at most one simulateLockAssets/RPC call (after the debounce window elapses), not one per keystroke.
  • The fee-preview UI shows a "pending/simulating" state throughout the debounce window and the subsequent network request, so the field doesn't look inert while debouncing.
  • A test using fake timers asserts that N rapid amount changes within the debounce window produce exactly one queryFn invocation, with the value from the last change.
  • Existing behavior (fee preview shown for a valid, settled amount; hidden/errored for invalid amounts) is unchanged once debounced.

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

Metadata

Metadata

Labels

GrantFox OSSIssue tracked in GrantFox OSSMaybe RewardedIssue may be eligible for a GrantFox rewardOfficial CampaignCampaign: Official CampaignOfficial Campaign | FWC26Campaign: Official Campaign | FWC26Third CampaignCampaign: Third CampaignfarmFarming/staking flow — deposit, lock, unlock, creditsperformanceRendering performance, caching, or bundle sizevery hardExtremely hard — deep expertise, careful design, and significant time required

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions