From 6d49bbd55dfe014782b5e42b2ae45246dc1c1da9 Mon Sep 17 00:00:00 2001 From: JspIIV Date: Thu, 20 Aug 2026 15:13:24 +0300 Subject: [PATCH] fix: stop a reset transfer from writing over the next one reset() cleared currentStep, logs and error but left executeTransfer running. retrieveAttestation polls in a `while (true)` loop with no timeout and no cancellation, so the abandoned run kept polling every five seconds and kept calling addLog and setCurrentStep. Pressing Reset hid the transfer rather than ending it: log lines reappeared on the cleared screen and the step advanced on its own to minting and then completed. Starting a second transfer made it worse, because both runs wrote to the same state. Their log lines interleaved and whichever finished first set the step, so the first transfer could display "Bridge completed successfully" while the second was still burning. The hook now owns an AbortController per run. reset() and unmount abort it, executeTransfer abandons any previous run before starting, the signal is threaded through the attestation poll and the mint retry loop, and an aborted run is treated as abandoned rather than failed so it does not paint an error over a screen the user already reset. Two smaller fixes in the same paths: - The attestation wait is bounded. A message that never attests polled forever; it now gives up after three hours and says the burn succeeded so the mint can be completed later. - "Waiting for attestation..." was appended every poll, leaving roughly 700 identical lines after an hour. It is logged once a minute. - The gas line reported a gas limit as a price: formatUnits(gas, 9) printed a 180,000 gas limit as "0.00018 Gwei". It now logs the limit as a number, and the real receipt.gasUsed once the mint lands. --- src/hooks/use-cross-chain-transfer.ts | 97 ++++++++++++++++++++++----- 1 file changed, 82 insertions(+), 15 deletions(-) diff --git a/src/hooks/use-cross-chain-transfer.ts b/src/hooks/use-cross-chain-transfer.ts index 31f9328..a92c586 100644 --- a/src/hooks/use-cross-chain-transfer.ts +++ b/src/hooks/use-cross-chain-transfer.ts @@ -18,7 +18,7 @@ "use client"; -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { http, encodeFunctionData, @@ -78,6 +78,11 @@ const DEFAULT_DECIMALS = 6; const FAST_FINALITY_THRESHOLD = 1000; const STANDARD_FINALITY_THRESHOLD = 2000; const ATTESTATION_POLL_INTERVAL_MS = 5000; +// Standard-finality transfers can take well over an hour, so this ceiling only +// exists to stop a message that will never attest from polling forever. +const ATTESTATION_TIMEOUT_MS = 3 * 60 * 60 * 1000; +// One "still waiting" line per minute instead of one per poll. +const ATTESTATION_LOG_EVERY = 12; const MINT_MAX_RETRIES = 3; const MINT_RETRY_BASE_DELAY_MS = 2000; const GAS_BUFFER_PERCENT = 120n; @@ -91,6 +96,30 @@ export function useCrossChainTransfer() { const [logs, setLogs] = useState([]); const [error, setError] = useState(null); + // Tracks the in-flight transfer so it can be abandoned. Without this, reset() + // only clears the UI: the previous run keeps polling for its attestation and + // later writes logs and step changes over whatever is on screen, including a + // "completed" for a transfer the user already walked away from. + const abortRef = useRef(null); + + // Abandon an in-flight transfer on unmount so it cannot outlive the page. + useEffect(() => () => abortRef.current?.abort(), []); + + /** Sleep that gives up early when the transfer is abandoned. */ + const wait = (ms: number, signal: AbortSignal) => + new Promise((resolve, reject) => { + if (signal.aborted) return reject(signal.reason); + const timer = setTimeout(() => { + signal.removeEventListener("abort", onAbort); + resolve(); + }, ms); + function onAbort() { + clearTimeout(timer); + reject(signal.reason); + } + signal.addEventListener("abort", onAbort, { once: true }); + }); + // --------------------------------------------------------------------------- // CCTP Transfer Flow // The core transfer is a 4-step process: Approve → Burn → Attest → Mint @@ -103,6 +132,13 @@ export function useCrossChainTransfer() { transferType: "fast" | "standard", wallets: WalletConnections, ) => { + // Abandon anything still running before starting a new attempt, so two + // transfers can never write to the same state. + abortRef.current?.abort(); + const controller = new AbortController(); + abortRef.current = controller; + const { signal } = controller; + try { const numericAmount = parseUnits(amount, DEFAULT_DECIMALS); @@ -152,7 +188,11 @@ export function useCrossChainTransfer() { } // Step 3: Retrieve attestation - const attestation = await retrieveAttestation(burnTx, sourceChainId); + const attestation = await retrieveAttestation( + burnTx, + sourceChainId, + signal, + ); // Step 4: Mint if (isDestinationSolana) { @@ -166,11 +206,17 @@ export function useCrossChainTransfer() { destinationChainId, attestation, wallets, + signal, ); } } catch (error) { + // An abandoned transfer is not a failure: the user reset or navigated + // away, and its state is no longer on screen to report into. + if (signal.aborted) return; setCurrentStep("error"); setError(getErrorMessage(error)); + } finally { + if (abortRef.current === controller) abortRef.current = null; } }; @@ -460,18 +506,23 @@ export function useCrossChainTransfer() { const retrieveAttestation = async ( transactionHash: string, sourceChainId: number, + signal: AbortSignal, ): Promise => { setCurrentStep("waiting-attestation"); addLog("Retrieving attestation..."); const url = `${IRIS_API_URL}/v2/messages/${CHAIN_CONFIGS[sourceChainId as SupportedChainId].destinationDomain}?transactionHash=${transactionHash}`; - while (true) { - const response = await fetch(url); + const deadline = Date.now() + ATTESTATION_TIMEOUT_MS; + let polls = 0; + + while (Date.now() < deadline) { + signal.throwIfAborted(); + + const response = await fetch(url, { signal }); if (response.status === 404) { - await new Promise((resolve) => - setTimeout(resolve, ATTESTATION_POLL_INTERVAL_MS), - ); + await wait(ATTESTATION_POLL_INTERVAL_MS, signal); + polls++; continue; } if (!response.ok) { @@ -484,11 +535,19 @@ export function useCrossChainTransfer() { addLog("Attestation retrieved"); return data.messages[0] as AttestationResponse; } - addLog("Waiting for attestation..."); - await new Promise((resolve) => - setTimeout(resolve, ATTESTATION_POLL_INTERVAL_MS), - ); + // Logging every poll grew the transfer log without bound; a slow standard + // transfer produced hundreds of identical lines. + if (polls % ATTESTATION_LOG_EVERY === 0) { + addLog("Waiting for attestation..."); + } + await wait(ATTESTATION_POLL_INTERVAL_MS, signal); + polls++; } + + throw new Error( + `Attestation did not arrive within ${Math.round(ATTESTATION_TIMEOUT_MS / 60000)} minutes. ` + + `The burn succeeded (${transactionHash}); the mint can be completed later.`, + ); }; // --------------------------------------------------------------------------- @@ -500,6 +559,7 @@ export function useCrossChainTransfer() { destinationChainId: number, attestation: AttestationResponse, wallets: WalletConnections, + signal: AbortSignal, ) => { let retries = 0; setCurrentStep("minting"); @@ -507,6 +567,7 @@ export function useCrossChainTransfer() { while (retries < MINT_MAX_RETRIES) { try { + signal.throwIfAborted(); await switchEvmWalletToChain(destinationChainId, wallets); if (!client.account) { throw new Error("Connect an EVM wallet to continue."); @@ -541,7 +602,9 @@ export function useCrossChainTransfer() { }); const gasWithBuffer = (gasEstimate * GAS_BUFFER_PERCENT) / 100n; - addLog(`Gas Used: ${formatUnits(gasWithBuffer, 9)} Gwei`); + // gasWithBuffer is a gas limit, not a price: formatting it as Gwei + // reported a ~180,000 gas limit as "0.00018 Gwei". + addLog(`Gas limit: ${gasWithBuffer.toString()}`); const tx = await client.sendTransaction({ account: client.account, @@ -562,19 +625,19 @@ export function useCrossChainTransfer() { if (receipt.status !== "success") { throw new Error(`Mint transaction reverted: ${tx}`); } + addLog(`Gas used: ${receipt.gasUsed.toString()}`); addLog(`Bridge completed successfully`); setCurrentStep("completed"); break; } catch (err) { + if (signal.aborted) throw err; if ( err instanceof TransactionExecutionError && retries < MINT_MAX_RETRIES - 1 ) { retries++; addLog(`Retry ${retries}/${MINT_MAX_RETRIES}...`); - await new Promise((resolve) => - setTimeout(resolve, MINT_RETRY_BASE_DELAY_MS * retries), - ); + await wait(MINT_RETRY_BASE_DELAY_MS * retries, signal); continue; } throw err; @@ -935,6 +998,10 @@ export function useCrossChainTransfer() { }; const reset = () => { + // Stop the run first: clearing state while it is still executing only hides + // it until the next log line or step change repaints the screen. + abortRef.current?.abort(); + abortRef.current = null; setCurrentStep("idle"); setLogs([]); setError(null);