diff --git a/apps/explorer/src/components/UserConsole/FundsSection/components/DirectSquidDepositDialog.test.tsx b/apps/explorer/src/components/UserConsole/FundsSection/components/DirectSquidDepositDialog.test.tsx index 9474489c..5bdcffe9 100644 --- a/apps/explorer/src/components/UserConsole/FundsSection/components/DirectSquidDepositDialog.test.tsx +++ b/apps/explorer/src/components/UserConsole/FundsSection/components/DirectSquidDepositDialog.test.tsx @@ -73,10 +73,14 @@ const query = vi.hoisted(() => ({ // Per-payer inventories, keyed by lowercase address; payers without one read `inventory`. inventories: {} as Record>, nativeBalance: 10n ** 18n, - recipientFil: 0n, + recipientFil: 0n as bigint | undefined, recipientFilIsError: false, recipientFilIsFetching: false, + recipientFilQueryKey: [] as unknown[], + recipientFilSettledKeys: new Set(), quoteEnabled: undefined as boolean | undefined, + quoteIsFetching: false, + quoteQueryKey: [] as unknown[], filGasTopUp: { deadline: 1_700_604_800n, minimumFil: 50_000_000_000_000_000n, @@ -134,7 +138,9 @@ vi.mock("@privy-io/react-auth", () => ({ })); vi.mock("wagmi", () => ({ useAccount: () => ({ address: state.liveRecipient }), - usePublicClient: ({ chainId }: { chainId: number }) => ({ chain: { id: chainId } }), + usePublicClient: ({ chainId }: { chainId: number }) => ({ + chain: { id: chainId }, + }), })); vi.mock("wagmi/actions", () => ({ getAccount: () => ({ address: state.liveRecipient, chainId: state.consoleChainId }), @@ -176,19 +182,25 @@ vi.mock("@tanstack/react-query", () => { }; } if (queryKey[0] === "direct-squid-destination-fil") { + query.recipientFilQueryKey = [...queryKey]; + // As in TanStack Query, a key stays pending until an enabled read of it settles; later reads are background refetches. + const key = JSON.stringify(queryKey); + if (enabled !== false && !query.recipientFilIsFetching) query.recipientFilSettledKeys.add(key); + const isPending = !query.recipientFilSettledKeys.has(key); return { - data: query.recipientFil, - isError: query.recipientFilIsError, + data: isPending ? undefined : query.recipientFil, + isError: !isPending && query.recipientFilIsError, isFetching: query.recipientFilIsFetching, - isPending: false, + isPending, }; } if (queryKey[0] === "direct-squid-deposit-quote") { query.quoteEnabled = enabled; + query.quoteQueryKey = [...queryKey]; return { data: queryKey.at(-1) ? { ...query.quote, filGasTopUp: query.filGasTopUp } : query.quote, error: null, - isFetching: false, + isFetching: query.quoteIsFetching, }; } return { data: query.quote, error: null, isFetching: false }; @@ -329,6 +341,10 @@ describe("DirectSquidDepositDialog safety integration", () => { query.recipientFil = 0n; query.recipientFilIsError = false; query.recipientFilIsFetching = false; + query.recipientFilQueryKey = []; + query.recipientFilSettledKeys.clear(); + query.quoteIsFetching = false; + query.quoteQueryKey = []; query.tokenBalance = 200_000_000n; wallet.getEthereumProvider.mockClear(); wallet.switchChain.mockReset().mockImplementation(async (chainId: number) => { @@ -653,11 +669,10 @@ describe("DirectSquidDepositDialog safety integration", () => { }); it.each([ - [0n, false, "Your wallet does not have enough FIL for fees."], - [1n, false, "Your wallet does not have enough FIL for fees."], - [250_000_000_000_000_000n, false, "You already have FIL for fees."], - [0n, true, "Your wallet does not have enough FIL for fees."], - ])("defaults the FIL option on for destination balance %s (error: %s)", async (balance, isError, hint) => { + [0n, false, "Your wallet does not have enough FIL for fees.", "Your FIL balance could not be loaded."], + [1n, false, "Your wallet does not have enough FIL for fees.", "Your FIL balance could not be loaded."], + [undefined, true, "Your FIL balance could not be loaded.", "Your wallet does not have enough FIL for fees."], + ])("defaults the FIL option on for destination balance %s (error: %s)", async (balance, isError, hint, absent) => { query.recipientFil = balance; query.recipientFilIsError = isError; let renderer!: ReactTestRenderer; @@ -667,54 +682,308 @@ describe("DirectSquidDepositDialog safety integration", () => { const option = renderer.root.findByProps({ id: "direct-squid-fil-gas" }); expect(option.props.checked).toBe(true); + expect(option.props["aria-describedby"]).toBe("direct-squid-fil-gas-description"); + expect(renderer.root.findByProps({ id: "direct-squid-fil-gas-description" })).toBeTruthy(); const text = JSON.stringify(renderer.toJSON()); expect(text).toContain("Include 0.05 FIL for transaction fees"); expect(text).toContain(hint); + expect(text).not.toContain(absent); + expect(text.includes("this covers about a month of typical activity.")).toBe(!isError); expect(text).toContain("The FIL goes to your wallet to pay network fees, not to your Filecoin Pay balance."); expect(text).toContain("+ 0.05 FIL for network fees"); }); - it("keeps a funded wallet funded while the FIL balance refetches in the background", async () => { + it("hides the FIL option and quotes without a top-up for a funded wallet", async () => { query.recipientFil = 250_000_000_000_000_000n; - query.recipientFilIsFetching = true; let renderer!: ReactTestRenderer; await act(async () => { renderer = create(); }); - + await act(async () => { + amountInput(renderer).props.onChange({ target: { value: "100" } }); + }); const text = JSON.stringify(renderer.toJSON()); - expect(text).toContain("You already have FIL for fees."); - expect(text).not.toContain("does not have enough FIL"); + expect(text).not.toContain("Include 0.05 FIL for transaction fees"); + expect(text).not.toContain("+ 0.05 FIL for network fees"); + expect(renderer.root.findAllByProps({ id: "direct-squid-fil-gas" })).toHaveLength(0); + expect(query.quoteEnabled).toBe(true); + await act(async () => { + button(renderer, "Review")?.props.onClick(); + }); + await act(async () => { + button(renderer, "Pay 100 USDC")?.props.onClick(); + await vi.waitFor(() => expect(state.execute).toHaveBeenCalledOnce()); + }); + expect(state.requestRoute).toHaveBeenCalledWith( + expect.not.objectContaining({ filGasTopUp: expect.anything() }), + expect.anything(), + { quoteOnly: false }, + ); }); - it("does not wait for a fresh destination balance to enable the FIL option", async () => { + it("waits for the fresh destination balance before choosing FIL and fetching a quote", async () => { query.recipientFil = 0n; query.recipientFilIsFetching = true; + query.quoteIsFetching = true; + let renderer!: ReactTestRenderer; + await act(async () => { + renderer = create(); + }); + + await act(async () => { + amountInput(renderer).props.onChange({ target: { value: "100" } }); + }); + expect(query.quoteEnabled).toBe(false); + expect(renderer.root.findAllByProps({ id: "direct-squid-fil-gas" })).toHaveLength(0); + expect(JSON.stringify(renderer.toJSON())).not.toContain("Receive at least:"); + expect(JSON.stringify(renderer.toJSON())).not.toContain("Getting a quote"); + expect(JSON.stringify(renderer.toJSON())).toContain("Checking your FIL balance…"); + expect(button(renderer, "Review")?.props.disabled).toBe(true); + }); + + it("disables Review while a quote is fetching", async () => { + query.quoteIsFetching = true; let renderer!: ReactTestRenderer; await act(async () => { renderer = create(); }); + await act(async () => { + amountInput(renderer).props.onChange({ target: { value: "100" } }); + }); + expect(query.quoteEnabled).toBe(true); + expect(button(renderer, "Review")?.props.disabled).toBe(true); + }); + it("does not requote during a funded balance refresh", async () => { + query.recipientFil = 250_000_000_000_000_000n; + let renderer!: ReactTestRenderer; + await act(async () => { + renderer = create(); + }); await act(async () => { amountInput(renderer).props.onChange({ target: { value: "100" } }); }); + const quoteKey = query.quoteQueryKey; + query.recipientFilIsFetching = true; + await act(async () => { + renderer.update(); + }); expect(query.quoteEnabled).toBe(true); + expect(query.quoteQueryKey).toEqual(quoteKey); + }); + + it("keeps a funded quote after a background balance error", async () => { + query.recipientFil = 250_000_000_000_000_000n; + let renderer!: ReactTestRenderer; + await act(async () => { + renderer = create(); + }); + await act(async () => { + amountInput(renderer).props.onChange({ target: { value: "100" } }); + }); + const quoteKey = query.quoteQueryKey; + query.recipientFilIsError = true; + await act(async () => { + renderer.update(); + }); + expect(renderer.root.findAllByProps({ id: "direct-squid-fil-gas" })).toHaveLength(0); + expect(query.quoteQueryKey).toEqual(quoteKey); + }); + + it("keeps a manual FIL opt-out through a background balance refresh", async () => { + let renderer!: ReactTestRenderer; + await act(async () => { + renderer = create(); + }); + await act(async () => { + renderer.root.findByProps({ id: "direct-squid-fil-gas" }).props.onChange(false); + }); + query.recipientFilIsFetching = true; + await act(async () => { + renderer.update(); + }); + expect(renderer.root.findByProps({ id: "direct-squid-fil-gas" }).props.checked).toBe(false); + }); + + it("drops a manual FIL opt-in once the recipient becomes funded", async () => { + let renderer!: ReactTestRenderer; + await act(async () => { + renderer = create(); + }); + await act(async () => { + renderer.root.findByProps({ id: "direct-squid-fil-gas" }).props.onChange(false); + }); + await act(async () => { + renderer.root.findByProps({ id: "direct-squid-fil-gas" }).props.onChange(true); + }); + query.recipientFil = 250_000_000_000_000_000n; + await act(async () => { + renderer.update(); + }); + expect(renderer.root.findAllByProps({ id: "direct-squid-fil-gas" })).toHaveLength(0); + expect(query.quoteQueryKey.at(-1)).toBe(false); + + await reachExecution(renderer); + expect(state.requestRoute).toHaveBeenCalledWith( + expect.not.objectContaining({ filGasTopUp: expect.anything() }), + expect.anything(), + { quoteOnly: false }, + ); + }); + + it("does not requote when a poll changes the balance but not whether FIL is needed", async () => { + let renderer!: ReactTestRenderer; + const poll = async (balance: bigint) => { + query.recipientFil = balance; + await act(async () => { + renderer.update(); + }); + }; + await act(async () => { + renderer = create(); + }); + await act(async () => { + amountInput(renderer).props.onChange({ target: { value: "100" } }); + }); + const insufficientKey = query.quoteQueryKey; + await poll(10_000_000_000_000_000n); + expect(query.quoteQueryKey).toEqual(insufficientKey); expect(renderer.root.findByProps({ id: "direct-squid-fil-gas" }).props.checked).toBe(true); + await poll(250_000_000_000_000_000n); + const fundedKey = query.quoteQueryKey; + expect(fundedKey).not.toEqual(insufficientKey); + await poll(300_000_000_000_000_000n); + expect(query.quoteQueryKey).toEqual(fundedKey); + expect(renderer.root.findAllByProps({ id: "direct-squid-fil-gas" })).toHaveLength(0); + }); + + it("drops a reviewed FIL plan when the recipient becomes funded", async () => { + let renderer!: ReactTestRenderer; + await act(async () => { + renderer = create(); + }); + await act(async () => { + amountInput(renderer).props.onChange({ target: { value: "100" } }); + }); + await act(async () => { + button(renderer, "Review")?.props.onClick(); + }); + expect(button(renderer, "Pay 100 USDC for USDFC + FIL")).toBeDefined(); + query.recipientFil = 250_000_000_000_000_000n; + await act(async () => { + renderer.update(); + }); + expect(button(renderer, "Pay 100 USDC for USDFC + FIL")).toBeUndefined(); + expect(renderer.root.findAllByProps({ id: "direct-squid-fil-gas" })).toHaveLength(0); + expect(renderer.root.findByProps({ role: "status" }).children.join("")).toBe( + "FIL top-up removed: This wallet now has FIL for fees. Review the updated quote without a FIL top-up.", + ); + + // The notice would contradict the FIL option once the wallet falls below the reserve again. + query.recipientFil = 0n; + await act(async () => { + renderer.update(); + }); + expect(renderer.root.findAllByProps({ role: "status" })).toHaveLength(0); + expect(renderer.root.findByProps({ id: "direct-squid-fil-gas" }).props.checked).toBe(true); + }); + + it("keeps the reviewed plan while a deposit is in progress", async () => { + let finishExecution: (() => void) | undefined; + state.execute.mockImplementationOnce( + () => + new Promise((resolve) => { + finishExecution = () => + resolve({ depositedAmount: 95n, destinationTransactionHash: ROUTE_HASH, transactionHash: ROUTE_HASH }); + }), + ); + let renderer!: ReactTestRenderer; + await act(async () => { + renderer = create(); + }); + await reachExecution(renderer); + query.recipientFil = 250_000_000_000_000_000n; - query.recipientFilIsFetching = false; await act(async () => { renderer.update(); }); + expect(JSON.stringify(renderer.toJSON())).toContain("Review Squid deposit"); + expect(JSON.stringify(renderer.toJSON())).not.toContain("Pay with another token"); + + await act(async () => finishExecution?.()); + }); + it("requires a fresh balance decision on each open", async () => { + let renderer!: ReactTestRenderer; + const render = (open: boolean) => ( + + ); + await act(async () => { + renderer = create(render(true)); + }); + await act(async () => { + amountInput(renderer).props.onChange({ target: { value: "100" } }); + }); expect(renderer.root.findByProps({ id: "direct-squid-fil-gas" }).props.checked).toBe(true); + const firstQueryKey = query.recipientFilQueryKey; + await act(async () => { + renderer.update(render(false)); + }); + query.recipientFilIsFetching = true; + await act(async () => { + renderer.update(render(true)); + }); + expect(query.recipientFilQueryKey).not.toEqual(firstQueryKey); + expect(renderer.root.findAllByProps({ id: "direct-squid-fil-gas" })).toHaveLength(0); + expect(query.quoteEnabled).toBe(false); + query.recipientFil = 250_000_000_000_000_000n; + query.recipientFilIsFetching = false; + await act(async () => { + renderer.update(render(true)); + }); + expect(renderer.root.findAllByProps({ id: "direct-squid-fil-gas" })).toHaveLength(0); expect(query.quoteEnabled).toBe(true); + }); + it("waits for a balance read when the recipient changes mid-dialog", async () => { + let renderer!: ReactTestRenderer; + await act(async () => { + renderer = create(); + }); + await act(async () => { + amountInput(renderer).props.onChange({ target: { value: "100" } }); + }); + state.liveRecipient = OTHER; query.recipientFilIsFetching = true; await act(async () => { renderer.update(); }); + expect(query.quoteEnabled).toBe(false); + expect(renderer.root.findAllByProps({ id: "direct-squid-fil-gas" })).toHaveLength(0); + // A cached quote for the earlier choice must not be shown or reviewed before the new balance decides. + expect(JSON.stringify(renderer.toJSON())).not.toContain("Receive at least:"); + expect(button(renderer, "Review")?.props.disabled).toBe(true); + query.recipientFil = 250_000_000_000_000_000n; + query.recipientFilIsFetching = false; + await act(async () => { + renderer.update(); + }); expect(query.quoteEnabled).toBe(true); + expect(renderer.root.findAllByProps({ id: "direct-squid-fil-gas" })).toHaveLength(0); + }); + + it("offers FIL by default when a funded recipient becomes insufficient", async () => { + query.recipientFil = 250_000_000_000_000_000n; + let renderer!: ReactTestRenderer; + await act(async () => { + renderer = create(); + }); + query.recipientFil = 0n; + await act(async () => { + renderer.update(); + }); + expect(renderer.root.findByProps({ id: "direct-squid-fil-gas" }).props.checked).toBe(true); }); it("preserves the reviewed FIL plan through executable route construction", async () => { @@ -910,6 +1179,46 @@ describe("DirectSquidDepositDialog safety integration", () => { expect(renderer.root.findAllByProps({ role: "status" })).toHaveLength(0); }); + it("keeps a gas-maximum notice off the form when another tab's deposit drops the review", async () => { + query.allowance = 0n; + state.execute.mockRejectedValueOnce( + new SquidDepositBudgetError({ completed: ["approve"], remaining: ["route"], requiredFee: 10_000_000_000_000n }), + ); + let renderer!: ReactTestRenderer; + await act(async () => { + renderer = create(); + }); + await reachExecution(renderer); + expect(renderer.root.findByProps({ role: "status" }).children.join("")).toContain("Review the updated gas maximum"); + + const key = getPendingSquidDepositKey(OWNER); + storage.setItem( + key, + JSON.stringify({ + executionStage: "swap-requested", + fundsBefore: "5", + minimumDestinationAmount: "92", + owner: OWNER, + quoteId: "quote-1", + recipient: RECIPIENT, + sourceAmount: "100000000", + sourceChainId: 8453, + sourceToken: USDC, + startedAt: 1_700_000_000_000, + }), + ); + await act(async () => { + for (const listener of listeners.storage ?? []) listener({ key }); + }); + storage.removeItem(key); + await act(async () => { + for (const listener of listeners.storage ?? []) listener({ key }); + }); + + expect(amountInput(renderer)).toBeDefined(); + expect(renderer.root.findAllByProps({ role: "status" })).toHaveLength(0); + }); + it("keeps the dead end when the re-review itself cannot price the remaining transactions", async () => { state.execute.mockRejectedValueOnce( new SquidDepositBudgetError({ diff --git a/apps/explorer/src/components/UserConsole/FundsSection/components/DirectSquidDepositDialog.tsx b/apps/explorer/src/components/UserConsole/FundsSection/components/DirectSquidDepositDialog.tsx index 6f200750..66ffdb98 100644 --- a/apps/explorer/src/components/UserConsole/FundsSection/components/DirectSquidDepositDialog.tsx +++ b/apps/explorer/src/components/UserConsole/FundsSection/components/DirectSquidDepositDialog.tsx @@ -31,7 +31,7 @@ import { formatAddress } from "@/utils/formatter"; import { trackDepositConfirmed } from "@/utils/funnel-analytics"; import { ensureWalletChain } from "@/utils/wallet-chain"; import { isPrivyEmbeddedWallet } from "../../console-wallet"; -import { getFilecoinGasBalanceStatus } from "../data/filecoin-gas-balance"; +import { FIL_TRANSACTION_FEE_RESERVE } from "../data/filecoin-gas-balance"; import { invalidateTopUpQueries } from "../data/guided-top-up"; import { PaymasterReviewError } from "../data/paymaster-deposit-execution"; import { @@ -107,6 +107,11 @@ import { SquidDepositProgress } from "./SquidDepositProgress"; const DEFAULT_SOURCE_CHAIN = 8453; const FIL_GAS_TOP_UP_LABEL = `${formatUnits(FIL_GAS_TOP_UP_AMOUNT, 18)} FIL`; +const FIL_FUNDED_NOTICE = { + kind: "fil", + title: "FIL top-up removed", + description: "This wallet now has FIL for fees. Review the updated quote without a FIL top-up.", +} as const; const DEPOSIT_TARGET = { payments: mainnet.contracts.payments.address, usdfc: mainnet.contracts.usdfc.address, @@ -204,7 +209,9 @@ export function DirectSquidDepositDialog({ const [sourceChainId, setSourceChainId] = useState(DEFAULT_SOURCE_CHAIN); const [sourceTokenAddress, setSourceTokenAddress] = useState(""); const [amount, setAmount] = useState(""); - const [isFilGasTopUpEnabled, setFilGasTopUpEnabled] = useState(true); + const [openCount, setOpenCount] = useState(0); + // The user's FIL checkbox choice, kept only for the open and recipient it was made for. + const [filChoice, setFilChoice] = useState<{ scope: string; enabled: boolean } | null>(null); const [reviewed, setReviewed] = useState(null); const [stage, setStage] = useState(null); // The signature execution is waiting for, so the instruction reads as one of this run's signatures. @@ -212,8 +219,9 @@ export function DirectSquidDepositDialog({ const [transactionHash, setTransactionHash] = useState(null); const [pending, setPending] = useState(null); const [error, setError] = useState(null); - // Why the review card came back after a confirmation: a fresh gas maximum or quote to accept. - const [notice, setNotice] = useState<{ title: string; description: string } | null>(null); + // Why the dialog changed after a confirmation: a fresh gas maximum or quote to accept on the review card, + // or a FIL top-up dropped from the form because the recipient now holds the fee reserve. + const [notice, setNotice] = useState<{ kind: "review" | "fil"; title: string; description: string } | null>(null); const isSubmitting = useRef(false); const isMounted = useRef(true); const initializedSelectionScope = useRef(""); @@ -225,6 +233,7 @@ export function DirectSquidDepositDialog({ }); const recipient = connectedRecipient ? getAddress(connectedRecipient) : undefined; + const filChoiceScope = `${openCount}:${recipient ?? ""}`; const initialSourceAmount = initialSource?.amount; const initialSourceChainId = initialSource?.chainId; const initialSourceDecimals = initialSource?.decimals; @@ -353,19 +362,19 @@ export function DirectSquidDepositDialog({ if (!recipient || !destinationClient) throw new Error("Filecoin balance is unavailable"); return destinationClient.getBalance({ address: recipient }); }, - queryKey: ["direct-squid-destination-fil", recipient], + queryKey: ["direct-squid-destination-fil", recipient, openCount], + // A remounted dialog must not reuse a prior mount's balance when its fresh read fails. + gcTime: 0, refetchInterval: 30_000, refetchOnMount: "always", retry: 1, }); - // Only drives the explanatory copy under the FIL option; the default itself is set below. - // A background refetch of a known balance is not loading, or the hint would tell a - // funded wallet it has no FIL every 30 s. - const recipientFilStatus = getFilecoinGasBalanceStatus({ - balance: recipientFilQuery.data, - isError: recipientFilQuery.isError, - isLoading: recipientFilQuery.isFetching && recipientFilQuery.data === undefined, - }); + // Each open and recipient has its own cache entry, so pending means this open's first read has not settled. + const isFilBalancePending = recipientFilQuery.isPending; + // Keep a successfully observed funded balance through a failed background refetch. + const hasRecipientFil = (recipientFilQuery.data ?? 0n) >= FIL_TRANSACTION_FEE_RESERVE; + const includeFilGasTopUp = + !isFilBalancePending && !hasRecipientFil && (filChoice?.scope === filChoiceScope ? filChoice.enabled : true); const quoteQuery = useQuery({ enabled: open && @@ -374,6 +383,7 @@ export function DirectSquidDepositDialog({ !!recipient && !!owner && !!sourceToken && + !isFilBalancePending && parsedAmount !== null && !balancesQuery.isError && (balancesQuery.data?.token ?? 0n) >= parsedAmount + feeReserve, @@ -387,7 +397,7 @@ export function DirectSquidDepositDialog({ sourceChainId, sourceToken: sourceToken.token, }; - if (!isFilGasTopUpEnabled) return requestSquidDepositRoute(request, squid, { quoteOnly: true }); + if (!includeFilGasTopUp) return requestSquidDepositRoute(request, squid, { quoteOnly: true }); if (!destinationClient) throw new Error("Filecoin network is unavailable"); const filGasTopUp = await planFilGasTopUp(destinationClient, request.usdfc, squid.now ?? Date.now); return requestSquidDepositRoute({ ...request, filGasTopUp }, squid, { quoteOnly: true }); @@ -399,12 +409,12 @@ export function DirectSquidDepositDialog({ sourceChainId, sourceToken?.token, parsedAmount?.toString(), - isFilGasTopUpEnabled, + includeFilGasTopUp, ], retry: false, }); - const quote = quoteQuery.data; + const quote = isFilBalancePending ? undefined : quoteQuery.data; const budgetQuery = useQuery({ // Only the form needs a live budget; a review keeps the figure it showed and the run prices itself. enabled: @@ -453,13 +463,8 @@ export function DirectSquidDepositDialog({ amount: parsedAmount ?? undefined, }; - // Decision (#444 over #377): the FIL top-up is on for every fresh open, including a wallet - // that already holds the fee reserve. Costs accepted for now: a funded wallet buys 0.05 FIL it - // may not need, and a deposit under about 0.5 USDFC cannot be quoted while the FIL leg exceeds - // Squid's 10% cap; both are one untick away. Still open for discussion on PR #430. - // The reset runs while the dialog is closed, so a reopen never shows the previous opt-out. useEffect(() => { - if (!open) setFilGasTopUpEnabled(true); + if (!open) setOpenCount((count) => count + 1); }, [open]); useEffect(() => { @@ -578,6 +583,12 @@ export function DirectSquidDepositDialog({ } }, [owner, parsedAmount, recipient, reviewed, sourceChainId, sourceToken]); + useEffect(() => { + if (stage !== null || isSubmitting.current || !hasRecipientFil || !reviewed?.quote.filGasTopUp) return; + setReviewed(null); + setNotice(FIL_FUNDED_NOTICE); + }, [hasRecipientFil, reviewed, stage]); + useEffect(() => { isMounted.current = true; return () => { @@ -851,6 +862,7 @@ export function DirectSquidDepositDialog({ }); const transactions = listTransactionLabels(budget.transactions.map(({ kind }) => kind)); setNotice({ + kind: "review", title: isGasBreach ? "Review the updated gas maximum" : "Review the updated quote", description: `${change.message} Check the updated ${isGasBreach ? "maximum" : "quote"} and confirm to send the ${transactions}.`, }); @@ -1015,13 +1027,15 @@ export function DirectSquidDepositDialog({ balancesFailed: balancesQuery.isError, feesFailed: budgetQuery.isError, feesLoading: budgetQuery.isFetching, + filBalanceLoading: isFilBalancePending && recipientFilQuery.isFetching, isNativeSource: isSourceNative, nativeSymbol: sourceChain?.nativeCurrency.symbol ?? "native token", parsedAmount, payerLabel: owner ? formatAddress(owner) : "The paying wallet", - quoteFailed: !!quoteQuery.error, + quoteFailed: !isFilBalancePending && !!quoteQuery.error, quoteLoading: quoteQuery.isFetching, - quoteReady: !!quote, + // A refetching quote may be stale, and a FIL quote for a recipient that now holds FIL needs replacing. + quoteReady: !!quote && !quoteQuery.isFetching && !(hasRecipientFil && !!quote.filGasTopUp), requiredNative, sourceSymbol: sourceToken?.symbol, usdcGas: usdcGas.check, @@ -1051,7 +1065,6 @@ export function DirectSquidDepositDialog({ }; }); const isBusy = stage !== null; - const hasRecipientFil = recipientFilStatus === "funded"; const explorerUrl = sourceChain?.blockExplorers?.default.url; const reviewedSourceChain = reviewed ? SQUID_SOURCE_CHAINS.find((chain) => chain.id === reviewed.context.sourceChainId) @@ -1144,7 +1157,7 @@ export function DirectSquidDepositDialog({ ) : null} {!stage && !pending && reviewed && reviewedSourceChain ? (
- {notice ? : null} + {notice?.kind === "review" ? : null}

Spend: {reviewed.amount} {reviewed.sourceSymbol}

@@ -1212,6 +1225,10 @@ export function DirectSquidDepositDialog({ ) : null} {!stage && !pending && (!reviewed || !reviewedSourceChain) ? ( <> + {/* Gated on the live balance, so the notice cannot sit beside a FIL option that came back. */} + {notice?.kind === "fil" && hasRecipientFil ? ( + + ) : null} ) : null} -
- { - setFilGasTopUpEnabled(checked); - setReviewed(null); - }} - /> -
- -

- {hasRecipientFil - ? "You already have FIL for fees. " - : "Your wallet does not have enough FIL for fees. Filecoin transactions (like depositing USDFC) need a small amount of FIL, and this covers about a month of typical activity. "} - The FIL goes to your wallet to pay network fees, not to your Filecoin Pay balance. -

- {quote?.filGasTopUp ? ( -

- {`+ ${formatUnits(quote.filGasTopUp.minimumFil, 18)} FIL for network fees, using ${formatUnits(quote.filGasTopUp.spendUsdfc, 18)} USDFC from the amount received.`} + {!isFilBalancePending && !hasRecipientFil ? ( +

+ { + setFilChoice({ scope: filChoiceScope, enabled: checked }); + setReviewed(null); + }} + /> +
+ +

+ {recipientFilQuery.data === undefined + ? "Your FIL balance could not be loaded. " + : "Your wallet does not have enough FIL for fees. Filecoin transactions (like depositing USDFC) need a small amount of FIL, and this covers about a month of typical activity. "} + The FIL goes to your wallet to pay network fees, not to your Filecoin Pay balance.

- ) : null} + {quote?.filGasTopUp ? ( +

+ {`+ ${formatUnits(quote.filGasTopUp.minimumFil, 18)} FIL for network fees, using ${formatUnits(quote.filGasTopUp.spendUsdfc, 18)} USDFC from the amount received.`} +

+ ) : null} +
-
+ ) : null} {quote && rate && sourceToken && !quoteQuery.isFetching ? (

@@ -1352,7 +1372,7 @@ export function DirectSquidDepositDialog({

) : null} - {quoteQuery.error ? ( + {!isFilBalancePending && quoteQuery.error ? (

{walletErrorMessage(quoteQuery.error, "Squid could not quote this amount.")}

diff --git a/apps/explorer/src/components/UserConsole/FundsSection/data/squid-deposit-execution.test.ts b/apps/explorer/src/components/UserConsole/FundsSection/data/squid-deposit-execution.test.ts index 952f8b4a..baa703a4 100644 --- a/apps/explorer/src/components/UserConsole/FundsSection/data/squid-deposit-execution.test.ts +++ b/apps/explorer/src/components/UserConsole/FundsSection/data/squid-deposit-execution.test.ts @@ -668,7 +668,7 @@ describe("executeSquidDeposit", () => { // The node answering the first read after the receipt is a block behind it. const source = fakeSource({ readNodeHeadSequence: [41n], receiptBlock: 42n }); const sleep = vi.fn(async () => undefined); - await executeSquidDeposit({ + const result = await executeSquidDeposit({ destinationClient: fakeDestination(), ...signingChecks, quote, @@ -682,6 +682,11 @@ describe("executeSquidDeposit", () => { expect(wallet.sendTransaction).toHaveBeenCalledTimes(2); expect(sleep).toHaveBeenCalledOnce(); expect(sleep).toHaveBeenCalledWith(2_000); + expect(result).toEqual({ + depositedAmount: 95n, + destinationTransactionHash: DESTINATION_HASH, + transactionHash: ROUTE_HASH, + }); // The refused read, its retry, then the read before the route is signed. expect(pinnedBlocks(source)).toEqual([undefined, 42n, 42n, 42n]); }); @@ -690,7 +695,7 @@ describe("executeSquidDeposit", () => { const wallet = fakeWallet(); const source = fakeSource({ headSequence: [41n, 43n], receiptBlock: 42n }); const sleep = vi.fn(async () => undefined); - await executeSquidDeposit({ + const result = await executeSquidDeposit({ destinationClient: fakeDestination(), ...signingChecks, quote, @@ -702,6 +707,11 @@ describe("executeSquidDeposit", () => { }); expect(sleep).toHaveBeenCalledOnce(); + expect(result).toEqual({ + depositedAmount: 95n, + destinationTransactionHash: DESTINATION_HASH, + transactionHash: ROUTE_HASH, + }); expect(pinnedBlocks(source)).toEqual([undefined, 43n, 42n]); }); @@ -783,53 +793,83 @@ describe("executeSquidDeposit", () => { expect(wallet.sendTransaction).toHaveBeenCalledTimes(1); }); - it.each([ - [ - "account", - "Wallet account changed before signing", - (wallet: ReturnType) => ({ - getCurrentOwner: vi.fn(async () => (wallet.sendTransaction.mock.calls.length ? RECIPIENT : OWNER)), - }), - ], - [ - "network", - "Source network changed before signing", - (wallet: ReturnType) => ({ - walletClient: { - ...wallet, - getChainId: vi.fn(async () => (wallet.sendTransaction.mock.calls.length ? 1 : 8453)), - }, + // Only the read right before the route signature lags, so no later read would catch a switch during its retry. + it("checks the account after the pre-route read waits for a lagging node", async () => { + const wallet = fakeWallet(); + const source = fakeSource({ headSequence: [42n, 41n, 42n], receiptBlock: 42n }); + let currentOwner: typeof OWNER | typeof RECIPIENT = OWNER; + const sleep = vi.fn(async () => { + currentOwner = RECIPIENT; + }); + await expect( + executeSquidDeposit({ + destinationClient: fakeDestination(), + ...signingChecks, + getCurrentOwner: vi.fn(async () => currentOwner), + quote, + request, + sleep, + sourceClient: source, + squid: { integratorId: "id", fetch: vi.fn(async () => statusResponse("success")) }, + walletClient: wallet, }), - ], - [ - "reviewed context", - "Reviewed screen changed", - (wallet: ReturnType) => ({ - assertCurrentContext: vi.fn(() => { - if (wallet.sendTransaction.mock.calls.length) throw new Error("Reviewed screen changed"); - }), + ).rejects.toThrow("Wallet account changed before signing"); + expect(wallet.sendTransaction).toHaveBeenCalledTimes(1); + expect(sleep).toHaveBeenCalledOnce(); + }); + + it("checks the wallet network after the pre-route read waits for a lagging node", async () => { + const wallet = fakeWallet(); + const source = fakeSource({ headSequence: [42n, 41n, 42n], receiptBlock: 42n }); + let walletChainId = 8453; + const sleep = vi.fn(async () => { + walletChainId = 1; + }); + await expect( + executeSquidDeposit({ + destinationClient: fakeDestination(), + ...signingChecks, + quote, + request, + sleep, + sourceClient: source, + squid: { integratorId: "id", fetch: vi.fn(async () => statusResponse("success")) }, + walletClient: { ...wallet, getChainId: vi.fn(async () => walletChainId) }, }), - ], - ])("fails at once when the %s changes after the approval, without re-reading", async (_case, message, overrides) => { + ).rejects.toThrow("Source network changed before signing"); + expect(wallet.sendTransaction).toHaveBeenCalledTimes(1); + expect(sleep).toHaveBeenCalledOnce(); + }); + + it("stops when the reviewed context changes at the approval receipt", async () => { const wallet = fakeWallet(); const source = fakeSource({ receiptBlock: 42n }); + let current = true; + source.waitForTransactionReceipt = vi.fn(async () => { + current = false; + return { status: "success" as const, blockNumber: 42n } as unknown as Awaited< + ReturnType + >; + }); const sleep = vi.fn(async () => undefined); await expect( executeSquidDeposit({ destinationClient: fakeDestination(), ...signingChecks, + assertCurrentContext: () => { + if (!current) throw new Error("Reviewed screen changed"); + }, quote, request, sleep, sourceClient: source, squid: { integratorId: "id" }, walletClient: wallet, - ...overrides(wallet), }), - ).rejects.toThrow(message); + ).rejects.toThrow("Reviewed screen changed"); expect(wallet.sendTransaction).toHaveBeenCalledTimes(1); expect(sleep).not.toHaveBeenCalled(); - expect(pinnedBlocks(source).filter((block) => block !== undefined).length).toBeLessThanOrEqual(1); + expect(pinnedBlocks(source)).toEqual([undefined]); }); it("resets a nonzero insufficient allowance before approving the payment amount", async () => { diff --git a/apps/explorer/src/components/UserConsole/FundsSection/data/squid-deposit-execution.ts b/apps/explorer/src/components/UserConsole/FundsSection/data/squid-deposit-execution.ts index 41022abc..59257a34 100644 --- a/apps/explorer/src/components/UserConsole/FundsSection/data/squid-deposit-execution.ts +++ b/apps/explorer/src/components/UserConsole/FundsSection/data/squid-deposit-execution.ts @@ -244,18 +244,19 @@ async function assertFreshSigningState({ }): Promise<{ allowance: bigint; nativeBalance: bigint }> { assertCurrentContext(); const isNativeSource = isNativeToken(request.sourceToken); - const [providerOwner, walletChainId, rpcChainId, state] = await Promise.all([ + const state = afterApproval + ? await readSourceTokenStateAfterApproval({ quote, request, sourceClient }, afterApproval) + : await readSourceTokenState( + sourceClient, + request.owner, + request.sourceToken, + quote.transaction.approvalSpender ?? quote.transaction.target, + ); + assertCurrentContext(); + const [providerOwner, walletChainId, rpcChainId] = await Promise.all([ getCurrentOwner(), walletClient.getChainId(), sourceClient.getChainId(), - afterApproval - ? readSourceTokenStateAfterApproval({ quote, request, sourceClient }, afterApproval) - : readSourceTokenState( - sourceClient, - request.owner, - request.sourceToken, - quote.transaction.approvalSpender ?? quote.transaction.target, - ), ]); const { native: nativeBalance, token: tokenBalance } = state; // A native payment needs no approval, so it counts as already allowed. diff --git a/apps/explorer/src/components/UserConsole/FundsSection/data/squid-review-blocker.test.ts b/apps/explorer/src/components/UserConsole/FundsSection/data/squid-review-blocker.test.ts index 20c8f792..3980b21b 100644 --- a/apps/explorer/src/components/UserConsole/FundsSection/data/squid-review-blocker.test.ts +++ b/apps/explorer/src/components/UserConsole/FundsSection/data/squid-review-blocker.test.ts @@ -8,6 +8,7 @@ const ready = { balancesFailed: false, feesFailed: false, feesLoading: false, + filBalanceLoading: false, isNativeSource: false, nativeSymbol: "ETH", parsedAmount: 100n, @@ -91,6 +92,11 @@ describe("getSquidReviewBlocker", () => { { balances: { native: 10n, token: 99n }, quoteReady: false }, { kind: "funds", message: "0x1111...1111 doesn't have enough USDC." }, ], + [ + "a loading FIL balance", + { filBalanceLoading: true, quoteReady: false }, + { kind: "waiting", message: "Checking your FIL balance…" }, + ], ["a failed quote", { quoteFailed: true }, { kind: "failed", message: "Squid could not quote this amount." }], ["a loading quote", { quoteReady: false, quoteLoading: true }, { kind: "waiting", message: "Getting a quote…" }], [ diff --git a/apps/explorer/src/components/UserConsole/FundsSection/data/squid-review-blocker.ts b/apps/explorer/src/components/UserConsole/FundsSection/data/squid-review-blocker.ts index e95222dd..038b2bae 100644 --- a/apps/explorer/src/components/UserConsole/FundsSection/data/squid-review-blocker.ts +++ b/apps/explorer/src/components/UserConsole/FundsSection/data/squid-review-blocker.ts @@ -18,6 +18,7 @@ export function getSquidReviewBlocker({ balancesFailed, feesFailed, feesLoading, + filBalanceLoading, isNativeSource, nativeSymbol, parsedAmount, @@ -34,6 +35,8 @@ export function getSquidReviewBlocker({ balancesFailed: boolean; feesFailed: boolean; feesLoading: boolean; + /** The recipient's FIL balance decides whether the quote includes a FIL top-up. */ + filBalanceLoading: boolean; isNativeSource: boolean; nativeSymbol: string; parsedAmount: bigint | null; @@ -71,6 +74,7 @@ export function getSquidReviewBlocker({ }; } } + if (filBalanceLoading) return { kind: "waiting", message: "Checking your FIL balance…" }; if (quoteFailed) return { kind: "failed", message: "Squid could not quote this amount." }; if (!quoteReady) { return quoteLoading