Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions src/app/api/fast-tx-status/[hash]/route.ts
Original file line number Diff line number Diff line change
@@ -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 })
}
}
2 changes: 1 addition & 1 deletion src/components/swap/SwapToast.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -253,7 +253,7 @@ export function SwapToast({ hash }: { hash: string }) {
{isConfirmed
? "Tokens Available"
: isPreConfirmed
? "Tokens Pre-confirmed"
? "Tokens Preconfirmed"
: "Swapping..."}
</span>

Expand Down
4 changes: 1 addition & 3 deletions src/hooks/use-add-fast-to-metamask.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
164 changes: 133 additions & 31 deletions src/hooks/use-wait-for-tx-confirmation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand All @@ -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 RPC receipt or mctransactions reports pre-confirmed. */
onPreConfirmed?: (result: TxConfirmationResult) => void
onError?: (error: Error) => void
}
Expand All @@ -35,8 +36,19 @@ export interface UseWaitForTxConfirmationReturn {
}

/**
* Races database polling against Wagmi's on-chain receipt.
* The first to resolve triggers onConfirmed and halts the other process.
* Two-phase polling with Wagmi as parallel fallback:
*
* 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,
Expand Down Expand Up @@ -78,7 +90,7 @@ export function useWaitForTxConfirmation({
}
}, [])

// Effect: Watch for Wagmi (on-chain) receipt to arrive
// Effect: Watch for Wagmi (on-chain) receipt — parallel fallback throughout
useEffect(() => {
if (!hash || !receipt || hasConfirmedRef.current) return

Expand Down Expand Up @@ -121,9 +133,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: Two-phase polling
useEffect(() => {
if (!hash || processingHashRef.current === hash) return

Expand All @@ -134,11 +144,27 @@ export function useWaitForTxConfirmation({
setIsConfirmed(false)
setError(null)

const dbPoll = async () => {
/** 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(
Expand All @@ -149,39 +175,115 @@ export function useWaitForTxConfirmation({
return
}

const dbResult = await fetchTransactionReceiptFromDb(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

if (dbResult) {
const { receipt: dbReceipt, rawResult } = dbResult
if (dbReceipt.status === "reverted") {
// mctransactions "failed" → immediate error (dropped tx)
if (mcStatus === "failed") {
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
}

// 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()
const e = new RPCError("RPC Error", dbReceipt, rawResult)
setError(e)
onErrorRef.current?.(e)
return
}
// Success (0x1): fire onPreConfirmed once, then keep polling to detect flip to 0x0
if (!preConfirmedFiredRef.current) {
preConfirmedFiredRef.current = true
setIsConfirmed(true)
const result: TxConfirmationResult =
mode === "receipt"
? { source: "db", receipt: dbReceipt }
? { 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)
onConfirmedRef.current(result)
return
}
break // → Phase 2
}

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") {
hasConfirmedRef.current = true
abortController.abort()
setIsConfirmed(true)
const result: TxConfirmationResult =
mode === "receipt"
? { source: "db" }
: { source: "db", status: { success: true, hash } }
onConfirmedRef.current(result)
return
}

if (mcStatus === "failed") {
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 {
// 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, 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)
Expand All @@ -190,7 +292,7 @@ export function useWaitForTxConfirmation({
}
}

dbPoll()
poll()

return () => {
abortController.abort()
Expand Down
33 changes: 33 additions & 0 deletions src/lib/fast-tx-status.ts
Original file line number Diff line number Diff line change
@@ -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<FastTxStatus> {
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
}
}
Loading