From a70a10f58e247f3bb70193e9b36ff20a20003003 Mon Sep 17 00:00:00 2001 From: Jason Schwarz Date: Tue, 24 Mar 2026 18:11:25 -0300 Subject: [PATCH 1/3] feat: poll mctransactions for swap tx status instead of eth_getTransactionReceipt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dropped txs were invisible to the old polling (eth_getTransactionReceipt returns null forever) causing the spinner to hang for 60s until timeout. Now polls /api/fast-tx-status/[hash] which queries the mctransactions table directly. This table tracks the preconfirmation lifecycle and reflects "failed" status immediately when the RPC drops a tx. - "failed" → fires onError instantly (no more 60s wait) - "pre-confirmed" → fires onPreConfirmed, keeps polling - "confirmed" → fires onConfirmed as final success - Wagmi receipt watching unchanged as parallel fallback --- src/app/api/fast-tx-status/[hash]/route.ts | 33 +++++++ src/hooks/use-wait-for-tx-confirmation.ts | 105 +++++++++++++++------ src/lib/fast-tx-status.ts | 33 +++++++ 3 files changed, 140 insertions(+), 31 deletions(-) create mode 100644 src/app/api/fast-tx-status/[hash]/route.ts create mode 100644 src/lib/fast-tx-status.ts diff --git a/src/app/api/fast-tx-status/[hash]/route.ts b/src/app/api/fast-tx-status/[hash]/route.ts new file mode 100644 index 00000000..0a50ffa0 --- /dev/null +++ b/src/app/api/fast-tx-status/[hash]/route.ts @@ -0,0 +1,33 @@ +import { NextRequest, NextResponse } from "next/server" +import { getAnalyticsClient } from "@/lib/analytics/client" + +/** + * Queries mctransactions for a swap's preconfirmation status. + * Returns: "pre-confirmed" | "confirmed" | "failed" | null (not found yet) + */ +export async function GET(request: NextRequest, { params }: { params: Promise<{ hash: string }> }) { + try { + const { hash } = await params + + if (!hash) { + return NextResponse.json({ status: null, error: "Hash required" }, { status: 400 }) + } + + const client = getAnalyticsClient() + const rows = await client.executeRaw( + `SELECT status FROM mctransactions WHERE lower(hash) = lower(:hash) LIMIT 1`, + { hash }, + { catalog: "fastrpc", timeout: 5000 } + ) + + if (rows.length === 0) { + return NextResponse.json({ status: null }) + } + + const status = rows[0][0] as string + return NextResponse.json({ status }) + } catch (error) { + console.error("[fast-tx-status] Query failed:", error) + return NextResponse.json({ status: null, error: "Query failed" }, { status: 500 }) + } +} diff --git a/src/hooks/use-wait-for-tx-confirmation.ts b/src/hooks/use-wait-for-tx-confirmation.ts index 4ddab13b..2387ab60 100644 --- a/src/hooks/use-wait-for-tx-confirmation.ts +++ b/src/hooks/use-wait-for-tx-confirmation.ts @@ -2,11 +2,12 @@ import { useState, useEffect, useRef, useCallback } from "react" import type { TransactionReceipt } from "viem" +import { fetchFastTxStatus } from "@/lib/fast-tx-status" import { fetchTransactionReceiptFromDb } from "@/lib/transaction-receipt-utils" import { getTxConfirmationTimeoutMs } from "@/lib/tx-config" import { RPCError } from "@/lib/transaction-errors" -const RECEIPT_CHECK_INTERVAL_MS = 500 +const STATUS_CHECK_INTERVAL_MS = 500 export type WaitForTxConfirmationMode = "receipt" | "status" @@ -23,7 +24,7 @@ export interface UseWaitForTxConfirmationParams { receiptError?: Error | null mode: WaitForTxConfirmationMode onConfirmed: (result: TxConfirmationResult) => void - /** Called when DB finds receipt first (before on-chain). Wagmi continues waiting for on-chain confirmation. */ + /** Called when mctransactions reports pre-confirmed or DB has success receipt. */ onPreConfirmed?: (result: TxConfirmationResult) => void onError?: (error: Error) => void } @@ -35,8 +36,15 @@ export interface UseWaitForTxConfirmationReturn { } /** - * Races database polling against Wagmi's on-chain receipt. - * The first to resolve triggers onConfirmed and halts the other process. + * Polls mctransactions for swap lifecycle status (pre-confirmed / failed), + * races against Wagmi's on-chain receipt for final confirmation. + * + * - mctransactions "pre-confirmed" → fire onPreConfirmed, keep polling + * - mctransactions "confirmed" → fire onPreConfirmed + onConfirmed (final, no wagmi wait) + * - mctransactions "failed" → fire onError immediately (dropped tx) + * - Wagmi receipt success → fire onConfirmed (if mctransactions hasn't already) + * - Wagmi receipt reverted → fire onError + * - Timeout → fire onError */ export function useWaitForTxConfirmation({ hash, @@ -78,7 +86,7 @@ export function useWaitForTxConfirmation({ } }, []) - // Effect: Watch for Wagmi (on-chain) receipt to arrive + // Effect: Watch for Wagmi (on-chain) receipt — this is the final authority for success useEffect(() => { if (!hash || !receipt || hasConfirmedRef.current) return @@ -121,9 +129,7 @@ export function useWaitForTxConfirmation({ onErrorRef.current?.(e) }, [hash, receiptError]) - // Effect: Database polling logic (one fetch per iteration to detect status flip 0x1 -> 0x0) - // Start whenever we have a hash and aren't already polling this hash (don't gate on hasConfirmedRef - // so that we still poll when Wagmi receipt isn't ready yet and DB can be first) + // Effect: Poll mctransactions for pre-confirmed/failed status useEffect(() => { if (!hash || processingHashRef.current === hash) return @@ -134,7 +140,7 @@ export function useWaitForTxConfirmation({ setIsConfirmed(false) setError(null) - const dbPoll = async () => { + const poll = async () => { try { const timeoutMs = await getTxConfirmationTimeoutMs() const startTime = Date.now() @@ -149,39 +155,76 @@ export function useWaitForTxConfirmation({ return } - const dbResult = await fetchTransactionReceiptFromDb(hash, abortController.signal) + // Poll mctransactions status + const mcStatus = await fetchFastTxStatus(hash, abortController.signal) - if (dbResult) { - const { receipt: dbReceipt, rawResult } = dbResult - if (dbReceipt.status === "reverted") { - hasConfirmedRef.current = true - abortController.abort() - const e = new RPCError("RPC Error", dbReceipt, rawResult) - setError(e) - onErrorRef.current?.(e) - return + if (abortController.signal.aborted || hasConfirmedRef.current) return + + if (mcStatus === "failed") { + // Dropped/failed tx — try to get receipt for error details + hasConfirmedRef.current = true + abortController.abort() + + let dbReceipt: TransactionReceipt | undefined + let rawResult: unknown + try { + const receiptResult = await fetchTransactionReceiptFromDb(hash) + if (receiptResult) { + dbReceipt = receiptResult.receipt + rawResult = receiptResult.rawResult + } + } catch { + // Receipt fetch is best-effort for error details } - // Success (0x1): fire onPreConfirmed once, then keep polling to detect flip to 0x0 + + const e = dbReceipt + ? new RPCError("Transaction failed", dbReceipt, rawResult) + : new Error("Transaction was dropped by the network.") + setError(e) + onErrorRef.current?.(e) + return + } + + if (mcStatus === "confirmed") { + // mctransactions says confirmed — treat as final success + hasConfirmedRef.current = true + abortController.abort() + setIsConfirmed(true) + const result: TxConfirmationResult = + mode === "receipt" + ? { source: "db" } + : { source: "db", status: { success: true, hash } } + // Fire onPreConfirmed first if it hasn't fired yet if (!preConfirmedFiredRef.current) { preConfirmedFiredRef.current = true - const result: TxConfirmationResult = - mode === "receipt" - ? { source: "db", receipt: dbReceipt } - : { source: "db", status: { success: true, hash } } try { onPreConfirmedRef.current?.(result) - } catch (err) { - const e = err instanceof Error ? err : new Error(String(err)) - setError(e) - onErrorRef.current?.(e) + } catch { + // Best-effort; onConfirmed is the important one } } + onConfirmedRef.current(result) + return + } + + if (mcStatus === "pre-confirmed" && !preConfirmedFiredRef.current) { + preConfirmedFiredRef.current = true + const result: TxConfirmationResult = + mode === "receipt" + ? { source: "db" } + : { source: "db", status: { success: true, hash } } + try { + onPreConfirmedRef.current?.(result) + } catch (err) { + const e = err instanceof Error ? err : new Error(String(err)) + setError(e) + onErrorRef.current?.(e) + } } - await new Promise((r) => setTimeout(r, RECEIPT_CHECK_INTERVAL_MS)) + await new Promise((r) => setTimeout(r, STATUS_CHECK_INTERVAL_MS)) } } catch (err) { - // Ignore deliberate aborts; surface genuine polling errors if ((err as Error).name !== "AbortError" && !hasConfirmedRef.current) { const e = err instanceof Error ? err : new Error(String(err)) setError(e) @@ -190,7 +233,7 @@ export function useWaitForTxConfirmation({ } } - dbPoll() + poll() return () => { abortController.abort() diff --git a/src/lib/fast-tx-status.ts b/src/lib/fast-tx-status.ts new file mode 100644 index 00000000..5e8004a2 --- /dev/null +++ b/src/lib/fast-tx-status.ts @@ -0,0 +1,33 @@ +export type FastTxStatus = "pre-confirmed" | "confirmed" | "failed" | null + +const REQUEST_TIMEOUT_MS = 5000 + +/** + * Fetches the mctransactions status for a swap tx hash. + * Returns "pre-confirmed" | "confirmed" | "failed" | null (not found yet). + */ +export async function fetchFastTxStatus( + txHash: string, + abortSignal?: AbortSignal +): Promise { + const controller = new AbortController() + const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS) + + try { + const response = await fetch(`/api/fast-tx-status/${txHash}`, { + signal: controller.signal, + }) + + clearTimeout(timeoutId) + + if (abortSignal?.aborted) return null + + if (!response.ok) return null + + const data = await response.json() + return data.status as FastTxStatus + } catch { + clearTimeout(timeoutId) + return null + } +} From 9e5fcb9ed8c0114a28011384df72b88ac0d9598a Mon Sep 17 00:00:00 2001 From: Jason Schwarz Date: Tue, 24 Mar 2026 18:15:25 -0300 Subject: [PATCH 2/3] style: format use-add-fast-to-metamask --- src/hooks/use-add-fast-to-metamask.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/hooks/use-add-fast-to-metamask.ts b/src/hooks/use-add-fast-to-metamask.ts index b9c5737f..2182daa3 100644 --- a/src/hooks/use-add-fast-to-metamask.ts +++ b/src/hooks/use-add-fast-to-metamask.ts @@ -53,9 +53,7 @@ export function useAddFastToMetamask(): UseAddFastToMetamaskReturn { if (!provider) { const ethereum = (window as any).ethereum if (ethereum?.providers && Array.isArray(ethereum.providers)) { - provider = ethereum.providers.find( - (p: any) => p && p.isMetaMask === true && !p.isRabby - ) + provider = ethereum.providers.find((p: any) => p && p.isMetaMask === true && !p.isRabby) } else if (ethereum?.isMetaMask === true && !ethereum?.isRabby) { provider = ethereum } From a2887cb9e5c4f0fc491a1d8332922f29838e3cb0 Mon Sep 17 00:00:00 2001 From: Jason Schwarz Date: Tue, 24 Mar 2026 18:55:04 -0300 Subject: [PATCH 3/3] =?UTF-8?q?feat:=20two-phase=20polling=20=E2=80=94=20R?= =?UTF-8?q?PC=20+=20mctransactions=20for=20preconfirm,=20then=20mctransact?= =?UTF-8?q?ions=20only?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1: poll both eth_getTransactionReceipt and mctransactions in parallel until pre-confirmed. Phase 2: stop RPC polling, poll only mctransactions for confirmed/failed. Wagmi stays as parallel fallback. Also fix toast text: "Pre-confirmed" → "Preconfirmed" --- src/components/swap/SwapToast.tsx | 2 +- src/hooks/use-wait-for-tx-confirmation.ts | 155 +++++++++++++++------- 2 files changed, 108 insertions(+), 49 deletions(-) diff --git a/src/components/swap/SwapToast.tsx b/src/components/swap/SwapToast.tsx index 8a395cb6..6c39f217 100644 --- a/src/components/swap/SwapToast.tsx +++ b/src/components/swap/SwapToast.tsx @@ -253,7 +253,7 @@ export function SwapToast({ hash }: { hash: string }) { {isConfirmed ? "Tokens Available" : isPreConfirmed - ? "Tokens Pre-confirmed" + ? "Tokens Preconfirmed" : "Swapping..."} diff --git a/src/hooks/use-wait-for-tx-confirmation.ts b/src/hooks/use-wait-for-tx-confirmation.ts index 2387ab60..815aefe7 100644 --- a/src/hooks/use-wait-for-tx-confirmation.ts +++ b/src/hooks/use-wait-for-tx-confirmation.ts @@ -24,7 +24,7 @@ export interface UseWaitForTxConfirmationParams { receiptError?: Error | null mode: WaitForTxConfirmationMode onConfirmed: (result: TxConfirmationResult) => void - /** Called when mctransactions reports pre-confirmed or DB has success receipt. */ + /** Called when RPC receipt or mctransactions reports pre-confirmed. */ onPreConfirmed?: (result: TxConfirmationResult) => void onError?: (error: Error) => void } @@ -36,15 +36,19 @@ export interface UseWaitForTxConfirmationReturn { } /** - * Polls mctransactions for swap lifecycle status (pre-confirmed / failed), - * races against Wagmi's on-chain receipt for final confirmation. + * Two-phase polling with Wagmi as parallel fallback: * - * - mctransactions "pre-confirmed" → fire onPreConfirmed, keep polling - * - mctransactions "confirmed" → fire onPreConfirmed + onConfirmed (final, no wagmi wait) - * - mctransactions "failed" → fire onError immediately (dropped tx) - * - Wagmi receipt success → fire onConfirmed (if mctransactions hasn't already) - * - Wagmi receipt reverted → fire onError - * - Timeout → fire onError + * Phase 1 (pending → pre-confirmed): + * Poll BOTH eth_getTransactionReceipt (FastRPC) and mctransactions in parallel. + * First source to show success/pre-confirmed fires onPreConfirmed. + * mctransactions "failed" in this phase fires onError immediately. + * + * Phase 2 (pre-confirmed → final): + * Stop RPC receipt polling. Poll only mctransactions for confirmed/failed. + * mctransactions "confirmed" → fire onConfirmed (final success). + * mctransactions "failed" → fire onError. + * + * Wagmi receipt (on-chain) stays active throughout as a parallel fallback. */ export function useWaitForTxConfirmation({ hash, @@ -86,7 +90,7 @@ export function useWaitForTxConfirmation({ } }, []) - // Effect: Watch for Wagmi (on-chain) receipt — this is the final authority for success + // Effect: Watch for Wagmi (on-chain) receipt — parallel fallback throughout useEffect(() => { if (!hash || !receipt || hasConfirmedRef.current) return @@ -129,7 +133,7 @@ export function useWaitForTxConfirmation({ onErrorRef.current?.(e) }, [hash, receiptError]) - // Effect: Poll mctransactions for pre-confirmed/failed status + // Effect: Two-phase polling useEffect(() => { if (!hash || processingHashRef.current === hash) return @@ -140,11 +144,27 @@ export function useWaitForTxConfirmation({ setIsConfirmed(false) setError(null) + /** Fire onPreConfirmed once, from whichever source wins the race. */ + const firePreConfirmed = () => { + if (preConfirmedFiredRef.current) return + preConfirmedFiredRef.current = true + const result: TxConfirmationResult = + mode === "receipt" ? { source: "db" } : { source: "db", status: { success: true, hash } } + try { + onPreConfirmedRef.current?.(result) + } catch (err) { + const e = err instanceof Error ? err : new Error(String(err)) + setError(e) + onErrorRef.current?.(e) + } + } + const poll = async () => { try { const timeoutMs = await getTxConfirmationTimeoutMs() const startTime = Date.now() + // ── Phase 1: Poll both RPC receipt and mctransactions ── while (!abortController.signal.aborted && !hasConfirmedRef.current) { if (Date.now() - startTime > timeoutMs) { const e = new Error( @@ -155,38 +175,77 @@ export function useWaitForTxConfirmation({ return } - // Poll mctransactions status - const mcStatus = await fetchFastTxStatus(hash, abortController.signal) + // Poll both sources in parallel + const [rpcResult, mcStatus] = await Promise.all([ + fetchTransactionReceiptFromDb(hash, abortController.signal), + fetchFastTxStatus(hash, abortController.signal), + ]) if (abortController.signal.aborted || hasConfirmedRef.current) return + // mctransactions "failed" → immediate error (dropped tx) if (mcStatus === "failed") { - // Dropped/failed tx — try to get receipt for error details hasConfirmedRef.current = true abortController.abort() + const e = rpcResult + ? new RPCError("Transaction failed", rpcResult.receipt, rpcResult.rawResult) + : new Error("Transaction was dropped by the network.") + setError(e) + onErrorRef.current?.(e) + return + } - let dbReceipt: TransactionReceipt | undefined - let rawResult: unknown - try { - const receiptResult = await fetchTransactionReceiptFromDb(hash) - if (receiptResult) { - dbReceipt = receiptResult.receipt - rawResult = receiptResult.rawResult - } - } catch { - // Receipt fetch is best-effort for error details + // RPC receipt with reverted status → immediate error + if (rpcResult && rpcResult.receipt.status === "reverted") { + hasConfirmedRef.current = true + abortController.abort() + const e = new RPCError("RPC Error", rpcResult.receipt, rpcResult.rawResult) + setError(e) + onErrorRef.current?.(e) + return + } + + // Either source signals pre-confirmed → fire and move to phase 2 + if ( + mcStatus === "pre-confirmed" || + mcStatus === "confirmed" || + (rpcResult && rpcResult.receipt.status === "success") + ) { + firePreConfirmed() + // If mctransactions already says confirmed, finish now + if (mcStatus === "confirmed") { + hasConfirmedRef.current = true + abortController.abort() + setIsConfirmed(true) + const result: TxConfirmationResult = + mode === "receipt" + ? { source: "db" } + : { source: "db", status: { success: true, hash } } + onConfirmedRef.current(result) + return } + break // → Phase 2 + } - const e = dbReceipt - ? new RPCError("Transaction failed", dbReceipt, rawResult) - : new Error("Transaction was dropped by the network.") + await new Promise((r) => setTimeout(r, STATUS_CHECK_INTERVAL_MS)) + } + + // ── Phase 2: Poll only mctransactions for confirmed/failed ── + while (!abortController.signal.aborted && !hasConfirmedRef.current) { + if (Date.now() - startTime > timeoutMs) { + const e = new Error( + "Transaction confirmation timed out — your swap may have still succeeded. Check your wallet." + ) setError(e) onErrorRef.current?.(e) return } + const mcStatus = await fetchFastTxStatus(hash, abortController.signal) + + if (abortController.signal.aborted || hasConfirmedRef.current) return + if (mcStatus === "confirmed") { - // mctransactions says confirmed — treat as final success hasConfirmedRef.current = true abortController.abort() setIsConfirmed(true) @@ -194,32 +253,32 @@ export function useWaitForTxConfirmation({ mode === "receipt" ? { source: "db" } : { source: "db", status: { success: true, hash } } - // Fire onPreConfirmed first if it hasn't fired yet - if (!preConfirmedFiredRef.current) { - preConfirmedFiredRef.current = true - try { - onPreConfirmedRef.current?.(result) - } catch { - // Best-effort; onConfirmed is the important one - } - } onConfirmedRef.current(result) return } - if (mcStatus === "pre-confirmed" && !preConfirmedFiredRef.current) { - preConfirmedFiredRef.current = true - const result: TxConfirmationResult = - mode === "receipt" - ? { source: "db" } - : { source: "db", status: { success: true, hash } } + if (mcStatus === "failed") { + hasConfirmedRef.current = true + abortController.abort() + + let dbReceipt: TransactionReceipt | undefined + let rawResult: unknown try { - onPreConfirmedRef.current?.(result) - } catch (err) { - const e = err instanceof Error ? err : new Error(String(err)) - setError(e) - onErrorRef.current?.(e) + const receiptResult = await fetchTransactionReceiptFromDb(hash) + if (receiptResult) { + dbReceipt = receiptResult.receipt + rawResult = receiptResult.rawResult + } + } catch { + // Best-effort for error details } + + const e = dbReceipt + ? new RPCError("Transaction failed", dbReceipt, rawResult) + : new Error("Transaction was dropped by the network.") + setError(e) + onErrorRef.current?.(e) + return } await new Promise((r) => setTimeout(r, STATUS_CHECK_INTERVAL_MS))