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
23 changes: 21 additions & 2 deletions src/components/swap/SwapToast.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,18 @@ export function SwapToast({ hash }: { hash: string }) {

const toastRef = useRef<HTMLDivElement>(null)
const effectiveHash = hash.startsWith("pending-") ? undefined : hash
const prevEffectiveHashRef = useRef<string | undefined>(undefined)

// Log when real hash arrives (permit path: placeholder → real hash swap)
if (effectiveHash && effectiveHash !== prevEffectiveHashRef.current) {
prevEffectiveHashRef.current = effectiveHash
const elapsed = toast?.createdAt ? ((Date.now() - toast.createdAt) / 1000).toFixed(2) : "?"
console.log(`[SwapToast] Hash ready | +${elapsed}s from submit | now=${Date.now()} | hash=${effectiveHash}`)
}

// Wagmi watches for real on-chain receipt — used for confirmed detection only.
// Our custom polling hook handles preconfirmed detection via FastRPC commitment/receipt polling.
// Wagmi correctly distinguishes real L1 receipts from FastRPC's simulated preconf receipts.
const { data: receipt, error: receiptError } = useWaitForTransactionReceipt({
hash: effectiveHash as `0x${string}` | undefined,
})
Expand All @@ -46,20 +57,28 @@ export function SwapToast({ hash }: { hash: string }) {
receiptError,
mode: "status",
onConfirmed: () => {
if (effectiveHash) setStatus(hash, "confirmed")
const t = useSwapToastStore.getState().toasts.find((x) => x.hash === hash)
const elapsed = t?.createdAt ? ((Date.now() - t.createdAt) / 1000).toFixed(2) : "?"
const sincePreconf = t?.preconfirmedAt ? ((Date.now() - t.preconfirmedAt) / 1000).toFixed(2) : "n/a"
console.log(`[SwapToast] CONFIRMED | +${elapsed}s from submit | +${sincePreconf}s from preconf | hash=${effectiveHash}`)
if (effectiveHash) setStatus(hash, "confirmed")
t?.onConfirm?.()
},
onPreConfirmed: () => {
const currentStatus = useSwapToastStore.getState().toasts.find((t) => t.hash === hash)?.status
const t = useSwapToastStore.getState().toasts.find((x) => x.hash === hash)
const elapsed = t?.createdAt ? ((Date.now() - t.createdAt) / 1000).toFixed(2) : "?"
console.log(`[SwapToast] PRECONFIRMED | +${elapsed}s from submit | status was ${currentStatus} | now=${Date.now()} | hash=${effectiveHash}`)
if (effectiveHash && currentStatus !== "confirmed") {
setStatus(hash, "preconfirmed")
playPreconfirmSound()
const t = useSwapToastStore.getState().toasts.find((x) => x.hash === hash)
t?.onPreConfirm?.()
}
},
onError: (err) => {
const t = useSwapToastStore.getState().toasts.find((x) => x.hash === hash)
const elapsed = t?.createdAt ? ((Date.now() - t.createdAt) / 1000).toFixed(2) : "?"
console.log(`[SwapToast] ERROR | +${elapsed}s from submit | hash=${effectiveHash}`, err.message)
const txReceipt = err instanceof RPCError ? err.receipt : undefined
const rawDbRecord = err instanceof RPCError ? err.rawDbRecord : undefined
const message =
Expand Down
2 changes: 1 addition & 1 deletion src/hooks/use-barter-validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { fetchBarterRoute } from "@/lib/barter-api"
import { ZERO_ADDRESS, WETH_ADDRESS } from "@/lib/swap-constants"
import type { Token } from "@/types/swap"

const DEBOUNCE_MS = 1500
const DEBOUNCE_MS = 300
const MAX_SLIPPAGE_PCT = 2.0

interface UseBarterValidationParams {
Expand Down
126 changes: 64 additions & 62 deletions src/hooks/use-wait-for-tx-confirmation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import { RPCError } from "@/lib/transaction-errors"
const FAST_POLL_INTERVAL_MS = 100
const NORMAL_POLL_INTERVAL_MS = 500
const FAST_POLL_COUNT = 5
/** DB failure detection runs independently on a slower cadence */
const DB_POLL_INTERVAL_MS = 2000

function getPollInterval(pollCount: number): number {
return pollCount < FAST_POLL_COUNT ? FAST_POLL_INTERVAL_MS : NORMAL_POLL_INTERVAL_MS
Expand All @@ -35,7 +37,7 @@ export interface UseWaitForTxConfirmationParams {
receiptError?: Error | null
mode: WaitForTxConfirmationMode
onConfirmed: (result: TxConfirmationResult) => void
/** Called when RPC receipt or mctransactions reports preconfirmed. */
/** Called when RPC receipt or commitments report preconfirmed. */
onPreConfirmed?: (result: TxConfirmationResult) => void
onError?: (error: Error) => void
}
Expand All @@ -47,19 +49,20 @@ export interface UseWaitForTxConfirmationReturn {
}

/**
* Two-phase polling with Wagmi as parallel fallback:
* Two-phase polling with decoupled failure detection:
*
* Phase 1 (pending → preconfirmed):
* Poll BOTH eth_getTransactionReceipt (FastRPC) and mctransactions in parallel.
* First source to show success/preconfirmed fires onPreConfirmed.
* mctransactions "failed" in this phase fires onError immediately.
* Fast loop: poll commitment status + RPC receipt (both hit FastRPC directly).
* Background: poll mctransactions DB every 2s for failure detection only.
* First source to show preconfirmed fires onPreConfirmed.
*
* Phase 2 (preconfirmed → final):
* Stop RPC receipt polling. Poll only mctransactions for confirmed/failed.
* mctransactions "confirmed" → fire onConfirmed (final success).
* mctransactions "failed" → fire onError.
* Poll mctransactions + RPC receipt for confirmed/failed.
*
* Wagmi receipt (on-chain) stays active throughout as a parallel fallback.
*
* Key optimization: the slow DB call (mctransactions via StarRocks) never
* blocks the fast RPC calls. Preconfirmation detection runs at RPC speed.
*/
export function useWaitForTxConfirmation({
hash,
Expand Down Expand Up @@ -144,7 +147,7 @@ export function useWaitForTxConfirmation({
onErrorRef.current?.(e)
}, [hash, receiptError])

// Effect: Two-phase polling
// Effect: Two-phase polling with decoupled DB failure detection
useEffect(() => {
if (!hash || processingHashRef.current === hash) return

Expand Down Expand Up @@ -176,9 +179,44 @@ export function useWaitForTxConfirmation({
const startTime = Date.now()
let pollCount = 0

// ── Phase 1: Poll both RPC receipt and mctransactions ──
// Background DB poll for failure detection (runs independently, never blocks fast path)
const dbPollInterval = setInterval(async () => {
if (abortController.signal.aborted || hasConfirmedRef.current) return
try {
const mcStatus = await fetchFastTxStatus(hash, abortController.signal)
if (abortController.signal.aborted || hasConfirmedRef.current) return

if (mcStatus === "failed") {
hasConfirmedRef.current = true
abortController.abort()
const e = new Error("Transaction was dropped by the network.")
setError(e)
onErrorRef.current?.(e)
} else if (mcStatus === "confirmed") {
// DB caught up — fire confirmed if we haven't already
if (!hasConfirmedRef.current) {
hasConfirmedRef.current = true
abortController.abort()
setIsConfirmed(true)
const result: TxConfirmationResult =
mode === "receipt"
? { source: "db" }
: { source: "db", status: { success: true, hash } }
firePreConfirmed()
onConfirmedRef.current(result)
}
} else if (mcStatus === "preconfirmed") {
firePreConfirmed()
}
} catch {
// Ignore DB poll errors — fast path handles preconfirmation
}
}, DB_POLL_INTERVAL_MS)

// ── Phase 1: Fast RPC polling for preconfirmation ──
while (!abortController.signal.aborted && !hasConfirmedRef.current) {
if (Date.now() - startTime > timeoutMs) {
clearInterval(dbPollInterval)
const e = new Error(
"Transaction confirmation timed out — your swap may have still succeeded. Check your wallet."
)
Expand All @@ -187,69 +225,44 @@ export function useWaitForTxConfirmation({
return
}

// Poll three sources in parallel:
// 1. FastRPC commitment status (fastest — node knows instantly)
// 2. RPC eth_getTransactionReceipt
// 3. mctransactions DB status (slowest — lags ~15s)
const [commitStatus, rpcResult, mcStatus] = await Promise.all([
// Poll only fast sources — both hit FastRPC directly
const [commitStatus, rpcResult] = await Promise.all([
fetchCommitmentStatus(hash, abortController.signal),
fetchTransactionReceiptFromDb(hash, abortController.signal),
fetchFastTxStatus(hash, abortController.signal),
])

if (abortController.signal.aborted || hasConfirmedRef.current) return

// 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
}
if (abortController.signal.aborted || hasConfirmedRef.current) break

// RPC receipt with reverted status → immediate error
if (rpcResult && rpcResult.receipt.status === "reverted") {
hasConfirmedRef.current = true
abortController.abort()
clearInterval(dbPollInterval)
const e = new RPCError("RPC Error", rpcResult.receipt, rpcResult.rawResult)
setError(e)
onErrorRef.current?.(e)
return
}

// Any source signals preconfirmed → fire and move to phase 2
// Either fast source signals preconfirmed → fire and move to phase 2
if (
commitStatus === "preconfirmed" ||
mcStatus === "preconfirmed" ||
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
}

await new Promise((r) => setTimeout(r, getPollInterval(pollCount++)))
}

// ── Phase 2: Poll only mctransactions for confirmed/failed ──
// ── Phase 2: Wait for confirmed/failed ──
// Wagmi handles confirmed detection via real L1 receipt (passed as prop).
// We only poll DB here for failure detection. Wagmi's onConfirmed effect
// fires when a real on-chain receipt arrives — no FastRPC simulated receipt issue.
while (!abortController.signal.aborted && !hasConfirmedRef.current) {
if (Date.now() - startTime > timeoutMs) {
clearInterval(dbPollInterval)
const e = new Error(
"Transaction confirmation timed out — your swap may have still succeeded. Check your wallet."
)
Expand All @@ -260,11 +273,12 @@ export function useWaitForTxConfirmation({

const mcStatus = await fetchFastTxStatus(hash, abortController.signal)

if (abortController.signal.aborted || hasConfirmedRef.current) return
if (abortController.signal.aborted || hasConfirmedRef.current) break

if (mcStatus === "confirmed") {
hasConfirmedRef.current = true
abortController.abort()
clearInterval(dbPollInterval)
setIsConfirmed(true)
const result: TxConfirmationResult =
mode === "receipt"
Expand All @@ -277,29 +291,17 @@ export function useWaitForTxConfirmation({
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.")
clearInterval(dbPollInterval)
const e = new Error("Transaction was dropped by the network.")
setError(e)
onErrorRef.current?.(e)
return
}

await new Promise((r) => setTimeout(r, NORMAL_POLL_INTERVAL_MS))
}

clearInterval(dbPollInterval)
} catch (err) {
if ((err as Error).name !== "AbortError" && !hasConfirmedRef.current) {
const e = err instanceof Error ? err : new Error(String(err))
Expand Down
2 changes: 0 additions & 2 deletions src/lib/transaction-receipt-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,13 +90,11 @@ async function fetchTransactionReceipt(

// No result means transaction not found in DB yet
if (!data.result) {
// This is normal - DB hasn't indexed the transaction yet
return null
}

// Has result but no status means pending/not confirmed
if (!data.result.status) {
console.log(`[fetchTransactionReceipt] Receipt found but no status (pending) for ${txHash}`)
return null
}

Expand Down
Loading