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
13 changes: 8 additions & 5 deletions src/app/api/fast-tx-status/[hash]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
)
}
}
67 changes: 48 additions & 19 deletions src/hooks/use-wait-for-tx-confirmation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>
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.
Expand Down Expand Up @@ -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])
Expand Down Expand Up @@ -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
Expand All @@ -214,7 +243,7 @@ export function useWaitForTxConfirmation({
firePreConfirmed()
onConfirmedRef.current(result)
}
} else if (mcStatus === "preconfirmed") {
} else if (mc.status === "preconfirmed") {
firePreConfirmed()
}
} catch {
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down
36 changes: 25 additions & 11 deletions src/lib/fast-tx-status.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,39 @@
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"
return null
}

/**
* 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<FastTxStatus> {
): Promise<FastTxStatusResult> {
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 })
}
Expand All @@ -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
}
}
Loading