diff --git a/src/app/api/fast-tx-status/[hash]/route.ts b/src/app/api/fast-tx-status/[hash]/route.ts index 1b31e86f..3427e646 100644 --- a/src/app/api/fast-tx-status/[hash]/route.ts +++ b/src/app/api/fast-tx-status/[hash]/route.ts @@ -16,19 +16,22 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ const client = getAnalyticsClient() const rows = await client.executeRaw( - `SELECT status FROM mctransactions WHERE lower(hash) = lower(:hash) LIMIT 1`, + `SELECT status, details FROM mctransactions WHERE lower(hash) = lower(:hash) LIMIT 1`, { hash }, { catalog: "fastrpc", timeout: 5000 } ) if (rows.length === 0) { - return NextResponse.json({ status: null }) + return NextResponse.json({ status: null, details: null }) } - const status = rows[0][0] as string - return NextResponse.json({ status }) + const [status, details] = rows[0] as [string | null, string | null] + return NextResponse.json({ status: status ?? null, details: details ?? null }) } catch (error) { console.error("[fast-tx-status] Query failed:", error) - return NextResponse.json({ status: null, error: "Query failed" }, { status: 500 }) + return NextResponse.json( + { status: null, details: 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 3a6726a4..ed85065f 100644 --- a/src/hooks/use-wait-for-tx-confirmation.ts +++ b/src/hooks/use-wait-for-tx-confirmation.ts @@ -8,6 +8,41 @@ import { fetchCommitmentStatus } from "@/lib/fast-rpc-status" import { getTxConfirmationTimeoutMs } from "@/lib/tx-config" import { RPCError, buildRevertMessage } from "@/lib/transaction-errors" +/** Standard user-facing copy for swap failures. Detail belongs in logs, not the toast. */ +const SWAP_FAILED_MESSAGE = "Swap was dropped by the network, please try again" + +/** + * Returns an Error with a friendly user-facing message that still carries the + * underlying error's diagnostic fields (cause, viem `shortMessage`/`details`/ + * `metaMessages`/`walk`, original stack) so `reportClientError` forwards them + * verbatim to Vercel — and so the toast UI never has to show raw RPC strings. + */ +function buildSwapFailedError(cause: unknown, fallbackDetails?: string | null): Error { + const e = new Error(SWAP_FAILED_MESSAGE) + if (cause != null) { + ;(e as { cause?: unknown }).cause = cause + if (typeof cause === "object") { + const c = cause as { + shortMessage?: unknown + details?: unknown + metaMessages?: unknown + walk?: unknown + stack?: unknown + } + const decorated = e as unknown as Record + if (typeof c.shortMessage === "string") decorated.shortMessage = c.shortMessage + if (typeof c.details === "string") decorated.details = c.details + if (Array.isArray(c.metaMessages)) decorated.metaMessages = c.metaMessages + if (typeof c.walk === "function") decorated.walk = (c.walk as Function).bind(c) + if (typeof c.stack === "string") decorated.stack = c.stack + } + } + if (fallbackDetails && !(e as { details?: unknown }).details) { + ;(e as { details?: unknown }).details = fallbackDetails + } + return e +} + /** * Adaptive polling: starts fast to catch sub-second preconfirmations, * then backs off. First 5 polls at 100ms (~500ms window), then 500ms. @@ -142,16 +177,10 @@ export function useWaitForTxConfirmation({ hasConfirmedRef.current = true if (abortRef.current) abortRef.current.abort() - // If wagmi reports a dropped/replaced transaction, surface the full hash - // so users can look it up. Wagmi's own message doesn't always include it. - const raw = receiptError instanceof Error ? receiptError.message : String(receiptError) - const mentionsDropped = /drop|replac/i.test(raw) - const e = - mentionsDropped && hash - ? new Error(`Transaction ${hash} was dropped by the network.`) - : receiptError instanceof Error - ? receiptError - : new Error(String(receiptError)) + // Wrap the wagmi/viem error in a friendly user-facing message while + // preserving viem fields (shortMessage/details/metaMessages/walk) and the + // original stack so Vercel logs retain the full diagnostic. + const e = buildSwapFailedError(receiptError) setError(e) onErrorRef.current?.(e) }, [hash, receiptError]) @@ -192,16 +221,16 @@ export function useWaitForTxConfirmation({ const dbPollInterval = setInterval(async () => { if (abortController.signal.aborted || hasConfirmedRef.current) return try { - const mcStatus = await fetchFastTxStatus(hash, abortController.signal) + const mc = await fetchFastTxStatus(hash, abortController.signal) if (abortController.signal.aborted || hasConfirmedRef.current) return - if (mcStatus === "failed") { + if (mc.status === "failed") { hasConfirmedRef.current = true abortController.abort() - const e = new Error(`Transaction ${hash} was dropped by the network.`) + const e = buildSwapFailedError(null, mc.details) setError(e) onErrorRef.current?.(e) - } else if (mcStatus === "confirmed") { + } else if (mc.status === "confirmed") { // DB caught up — fire confirmed if we haven't already if (!hasConfirmedRef.current) { hasConfirmedRef.current = true @@ -214,7 +243,7 @@ export function useWaitForTxConfirmation({ firePreConfirmed() onConfirmedRef.current(result) } - } else if (mcStatus === "preconfirmed") { + } else if (mc.status === "preconfirmed") { firePreConfirmed() } } catch { @@ -284,11 +313,11 @@ export function useWaitForTxConfirmation({ return } - const mcStatus = await fetchFastTxStatus(hash, abortController.signal) + const mc = await fetchFastTxStatus(hash, abortController.signal) if (abortController.signal.aborted || hasConfirmedRef.current) break - if (mcStatus === "confirmed") { + if (mc.status === "confirmed") { hasConfirmedRef.current = true abortController.abort() clearInterval(dbPollInterval) @@ -301,11 +330,11 @@ export function useWaitForTxConfirmation({ return } - if (mcStatus === "failed") { + if (mc.status === "failed") { hasConfirmedRef.current = true abortController.abort() clearInterval(dbPollInterval) - const e = new Error(`Transaction ${hash} was dropped by the network.`) + const e = buildSwapFailedError(null, mc.details) setError(e) onErrorRef.current?.(e) return diff --git a/src/lib/fast-tx-status.ts b/src/lib/fast-tx-status.ts index e84b251c..87bf1496 100644 --- a/src/lib/fast-tx-status.ts +++ b/src/lib/fast-tx-status.ts @@ -1,9 +1,15 @@ export type FastTxStatus = "preconfirmed" | "confirmed" | "failed" | null +export type FastTxStatusResult = { + status: FastTxStatus + /** Raw `mctransactions.details` for the row (e.g. simulation revert reason). */ + details: string | null +} + const REQUEST_TIMEOUT_MS = 5000 /** Normalize DB status values (e.g. "pre-confirmed") to frontend values ("preconfirmed"). */ -function normalizeStatus(raw: string): FastTxStatus { +function normalizeStatus(raw: string | null | undefined): FastTxStatus { if (raw === "pre-confirmed" || raw === "preconfirmed") return "preconfirmed" if (raw === "confirmed") return "confirmed" if (raw === "failed") return "failed" @@ -11,21 +17,23 @@ function normalizeStatus(raw: string): FastTxStatus { } /** - * Fetches the mctransactions status for a swap tx hash. - * Returns "preconfirmed" | "confirmed" | "failed" | null (not found yet). + * Fetches the mctransactions status (and raw details) for a swap tx hash. + * `details` is forwarded to Vercel error logs so we can categorize the + * underlying simulation-failure reason instead of logging a generic message. */ export async function fetchFastTxStatus( txHash: string, abortSignal?: AbortSignal -): Promise { +): Promise { + const empty: FastTxStatusResult = { status: null, details: null } + const controller = new AbortController() const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS) - // Link parent abort signal so in-flight requests cancel immediately if (abortSignal) { if (abortSignal.aborted) { clearTimeout(timeoutId) - return null + return empty } abortSignal.addEventListener("abort", () => controller.abort(), { once: true }) } @@ -37,14 +45,20 @@ export async function fetchFastTxStatus( clearTimeout(timeoutId) - if (abortSignal?.aborted) return null + if (abortSignal?.aborted) return empty + if (!response.ok) return empty - if (!response.ok) return null + const data = (await response.json()) as { + status?: string | null + details?: string | null + } - const data = await response.json() - return data.status ? normalizeStatus(data.status) : null + return { + status: normalizeStatus(data.status), + details: typeof data.details === "string" && data.details.length > 0 ? data.details : null, + } } catch { clearTimeout(timeoutId) - return null + return empty } }