diff --git a/app/src/app/ui-review-bridge/BridgeReview.tsx b/app/src/app/ui-review-bridge/BridgeReview.tsx index e0892f31..d82ec12c 100644 --- a/app/src/app/ui-review-bridge/BridgeReview.tsx +++ b/app/src/app/ui-review-bridge/BridgeReview.tsx @@ -9,12 +9,13 @@ import { formatUnits, parseEther, parseUnits, zeroAddress } from "viem"; import { changeBridgeRoute, parseBridgeAmount, type BridgeRouteChange, type BridgeRouteInputs } from "@/lib/bridge/client"; import { bridgeCurrency, defaultBridgeAsset, type BridgeQuote } from "@/lib/bridge/types"; import type { TrackedApproval } from "@/lib/bridge/approval"; +import { describePendingApproval } from "@/lib/bridge/approval-health"; import styles from "@/components/bridge/BridgeDialog.module.css"; const wallet = "0x03508bB71268BBA25ECaCC8F620e01866650532c" as const; const requestId = `0x${"1".repeat(64)}` as const; -type Scene = "idle" | "disconnected" | "quote" | "expired" | "error" | "pending" | "success" | "uncertain" | "refund" | "approval_pending" | "approval_uncertain" | "approval_confirmed"; -const scenes: Scene[] = ["disconnected", "quote", "expired", "error", "pending", "success", "uncertain", "refund", "approval_pending", "approval_uncertain", "approval_confirmed"]; +type Scene = "idle" | "disconnected" | "quote" | "expired" | "error" | "pending" | "success" | "uncertain" | "refund" | "approval_pending" | "approval_uncertain" | "approval_confirmed" | "approval_queued" | "approval_missing" | "approval_fee"; +const scenes: Scene[] = ["disconnected", "quote", "expired", "error", "pending", "success", "uncertain", "refund", "approval_pending", "approval_uncertain", "approval_confirmed", "approval_queued", "approval_missing", "approval_fee"]; export default function BridgeReview() { const [open, setOpen] = useState(false); @@ -42,7 +43,7 @@ export default function BridgeReview() { transaction: { to: wallet, data: "0x", value: "0", chainId: origin }, // deliberately non-executable fixture ...(erc20Input ? { approval: { token: inputCurrency.address, spender: "0x4cd00e387622c35bddb9b4c962c136462338bc31" as const, amount: inputAmount.toString() } } : {}), }; - const approval: TrackedApproval | null = erc20Input && (origin === 5042 || origin === 8453) && (scene.startsWith("approval_") || approved) ? { version: 1, chainId: origin, address: wallet, token: inputCurrency.address, spender: "0x4cd00e387622c35bddb9b4c962c136462338bc31", amount: inputAmount.toString(), createdAt: clock, status: scene === "approval_uncertain" ? "uncertain" : scene === "approval_pending" ? "pending" : "confirmed", ...(scene === "approval_uncertain" ? {} : { approvalHash: requestId }) } : null; + const approval: TrackedApproval | null = erc20Input && (origin === 5042 || origin === 8453) && (scene.startsWith("approval_") || approved) ? { version: 1, chainId: origin, address: wallet, token: inputCurrency.address, spender: "0x4cd00e387622c35bddb9b4c962c136462338bc31", amount: inputAmount.toString(), createdAt: clock, status: scene === "approval_uncertain" ? "uncertain" : scene === "approval_confirmed" || approved ? "confirmed" : "pending", ...(scene === "approval_uncertain" ? {} : { approvalHash: requestId }) } : null; const tracking = ["pending", "success", "uncertain", "refund"].includes(scene); const bridge: ReturnType = { address: scene === "disconnected" ? undefined : wallet, walletChainId: origin, @@ -56,6 +57,7 @@ export default function BridgeReview() { tracked: tracking ? { address: wallet, requestId, amount: quote.amount, originChainId: origin, destinationChainId: destination, originAsset, destinationAsset, destinationHashes: [], status: scene as "pending" | "success" | "uncertain" | "refund", createdAt: clock } : null, statusError: null, retryStatus: () => {}, storageError: null, busy: false, canReset: scene === "success" || scene === "refund", approval, approvalRequired: erc20Input && !approved, allowanceLoading: false, approvalBusy: false, approvalError: null, + approvalHealth: ["approval_queued", "approval_missing", "approval_fee"].includes(scene) ? describePendingApproval({ createdAt: clock - 70_000, now: clock, transaction: scene === "approval_missing" ? null : { nonce: 2, maxFeePerGas: 30_000_000_000n }, latestNonce: scene === "approval_queued" ? 0 : 2, baseFeePerGas: 166_000_000_000n }) : null, approve: async () => setScene("approval_pending"), retryApproval: () => { setApproved(true); setScene("approval_confirmed"); }, recoverApproval: async () => { setApproved(true); setScene("approval_confirmed"); }, approvalCanBeDiscarded: scene === "approval_uncertain", discardApproval: async () => { setApproved(false); setScene("idle"); }, diff --git a/app/src/components/bridge/BridgeDialog.tsx b/app/src/components/bridge/BridgeDialog.tsx index df9a6ea3..89619730 100644 --- a/app/src/components/bridge/BridgeDialog.tsx +++ b/app/src/components/bridge/BridgeDialog.tsx @@ -223,33 +223,35 @@ function ApprovalProgress({ bridge: b }: { bridge: Bridge }) { const approval = b.approval!; const approvalChain = BRIDGE_CHAINS[approval.chainId]; const uncertain = approval.status === "uncertain"; + const health = b.approvalHealth; + const needsAttention = uncertain || (!!health && health.kind !== "waiting"); const [hash, setHash] = useState(""); const [verifying, setVerifying] = useState(false); return
USDC approval on {approvalChain.name}
- {uncertain ? : } -

{verifying ? "Verifying transaction" : b.approvalBusy ? "Check your wallet" : uncertain ? "Check your approval" : "Approval submitted"}

-

{verifying ? `Checking the transaction on ${approvalChain.name}. No wallet request will be made.` : b.approvalBusy ? "Approve the exact USDC amount in your wallet. This is not the bridge deposit." : uncertain ? "The wallet response was interrupted. Check your wallet’s activity and verify the transaction hash below. Do not approve again." : `Waiting for ${approvalChain.name} to confirm. You’ll review a fresh bridge quote next.`}

+ {needsAttention ? : } +

{verifying ? "Verifying transaction" : b.approvalBusy ? "Check your wallet" : uncertain ? "Check your approval" : health?.title ?? "Approval submitted"}

+

{verifying ? `Checking the transaction on ${approvalChain.name}. No wallet request will be made.` : b.approvalBusy ? "Approve the exact USDC amount in your wallet. This is not the bridge deposit." : uncertain ? "The wallet response was interrupted. Check your wallet’s activity and verify the transaction hash below. Do not approve again." : health?.detail ?? `Waiting for ${approvalChain.name} to confirm. You’ll review a fresh bridge quote next.`}

Approval limit
{nativeAmount(formatUnits(BigInt(approval.amount), 6))} USDC
{b.address ? : null}

No bridge deposit has been requested. An approval permits Relay’s deposit contract to use up to this amount; it does not bridge it.

{b.approvalError || b.storageError ?

{b.approvalError || b.storageError}

: null} {approval.approvalHash ? View approval on {approvalChain.name} : null} - {uncertain && !approval.approvalHash && !b.approvalBusy ?
- Have the approval transaction hash? -

Copy it from your wallet’s activity on {approvalChain.name}. We’ll verify the wallet, token, spender and exact amount before resuming.

+ {!b.approvalBusy ?
+ {approval.approvalHash ? "Sped up your approval in the wallet?" : "Have the approval transaction hash?"} +

Copy the confirmed approval hash from your wallet’s activity on {approvalChain.name}. We’ll verify the network, wallet, token, spender, exact amount and confirmation before resuming. A cancellation or an unrelated transaction cannot be used.

{ event.preventDefault(); setVerifying(true); void b.recoverApproval(hash.trim()).finally(() => setVerifying(false)); }}> setHash(event.target.value)} placeholder="0x…" maxLength={66} autoComplete="off" spellCheck={false} /> - +
: null} {approval.approvalHash ? : null} {b.approvalCanBeDiscarded ?
Still not confirmed after {DISCARD_AFTER_MS / 60_000} minutes? -

{approvalChain.name} has no record of this approval. If your wallet shows it as dropped or cancelled, you can discard it and start over. If it confirms later it only grants this exact allowance; the next deposit re-checks it.

+

No confirmation was found for this saved approval hash. This does not prove the transaction was dropped. Check your wallet’s earliest pending transaction before starting over; discarding this record does not cancel it or fix a queued nonce. If it confirms later it only grants this exact allowance; the next deposit re-checks it.

: null}

You can close this panel. Reopen Bridge with this wallet to resume. If you stop after approval, the unspent allowance remains until used or revoked.

diff --git a/app/src/components/bridge/bridge-ui.test.ts b/app/src/components/bridge/bridge-ui.test.ts index b3f8278b..c7ef2738 100644 --- a/app/src/components/bridge/bridge-ui.test.ts +++ b/app/src/components/bridge/bridge-ui.test.ts @@ -140,3 +140,17 @@ test("stuck records have a bounded discard path, and the hook avoids APIs missin assert.match(hook, /replacementSourceHash\(current, status, receipt\.status === "fulfilled" && receipt\.value === null\)/); assert.match(hook, /const messageOf = bridgeErrorMessage;/); }); + +test("pending approvals explain missing/queued transactions and accept a verified replacement without a new wallet call", () => { + const hook = read("./useBridge.ts"); + assert.match(panel, /health\?\.title/); + assert.match(panel, /needsAttention \? (null); const [observation, setObservation] = useState(null); const [approvalObservation, setApprovalObservation] = useState(null); + const [approvalHealthResult, setApprovalHealthResult] = useState<{ wallet: string; createdAt: number; hash: string; value: ApprovalHealth | null } | null>(null); const [now, setNow] = useState(0); const [discarding, setDiscarding] = useState(false); const actionLock = useRef(false); @@ -137,7 +140,7 @@ export function useBridge() { clearTimeout(timer); try { const observed = approvals.getSnapshot().approvals[address.toLowerCase()]; - if (!observed || observed.createdAt !== approvalId || observed.approvalHash !== approvalHash) return; + if (!observed || !approvalBlocksSubmission(observed) || observed.createdAt !== approvalId || observed.approvalHash !== approvalHash) return; const pub = getPublicClient(config, { chainId: observed.chainId }); if (!pub) throw new Error("Could not connect to the source network to check the approval."); const [chainId, allowance, receipt] = await Promise.all([ @@ -148,20 +151,29 @@ export function useBridge() { throw error; }), ]); + if (chainId !== observed.chainId) throw new Error("The approval RPC reported a different network. Checking will retry."); + // Diagnostics never substitute for a receipt or allowance, and failed + // optional reads cannot stop an already-mined approval from resolving. + const health = receipt ? null : await readPendingApprovalHealth(pub, observed, Date.now()); const apply = (persist: boolean) => { const current = approvals.read(address); - if (stopped || !current || current.createdAt !== approvalId || current.approvalHash !== approvalHash || current.amount !== observed.amount) return; + if (stopped || !current || !canApplyApprovalPoll(current, observed)) return; const next = reconcileApproval(current, { chainId, allowance, receipt }); if (persist) { try { approvals.save(next); } catch { /* memory retains the receipt result */ } } else approvals.remember(next); setApprovalObservation({ createdAt: current.createdAt, receiptFound: receipt !== null, observedAt: Date.now() }); + setApprovalHealthResult({ wallet: address.toLowerCase(), createdAt: current.createdAt, hash: approvalHash, value: health }); setApprovalIssue(null); }; if (navigator.locks) await navigator.locks.request(transferLockName(address), { ifAvailable: true }, (lock) => { if (lock) apply(true); }); else apply(false); } catch (error) { - if (!stopped) setApprovalIssue({ key: address.toLowerCase(), message: messageOf(error, "Approval status is temporarily unavailable. Checking will retry.") }); + const current = approvals.getSnapshot().approvals[address.toLowerCase()]; + if (!stopped && current?.createdAt === approvalId && current.approvalHash === approvalHash && approvalBlocksSubmission(current)) { + setApprovalHealthResult({ wallet: address.toLowerCase(), createdAt: approvalId, hash: approvalHash, value: APPROVAL_HEALTH_UNAVAILABLE }); + setApprovalIssue({ key: address.toLowerCase(), message: messageOf(error, "Approval status is temporarily unavailable. Checking will retry.") }); + } } finally { active = false; const current = approvals.getSnapshot().approvals[address.toLowerCase()]; @@ -363,6 +375,7 @@ export function useBridge() { }, switchChain: (chainId) => switchChainAsync({ chainId }), prepare: async (owner, transaction) => { + await assertBridgeWalletQueueClear(pub, owner.address, BRIDGE_CHAINS[transaction.chainId].name); const call = { account: owner.address, to: transaction.to, data: transaction.data, value: transaction.value }; const [chainId, balance, estimate, fees, allowance, selectedBalance, additional] = await Promise.all([ pub.getChainId(), pub.getBalance({ address: owner.address }), pub.estimateGas(call), pub.estimateFeesPerGas(), @@ -404,7 +417,7 @@ export function useBridge() { } async function recoverApproval(hash: string) { - if (actionLock.current || !address || !approval || approval.approvalHash || approval.status !== "uncertain") return; + if (actionLock.current || !address || !approval || !approvalBlocksSubmission(approval)) return; if (!isHash(hash) || /^0x0+$/.test(hash)) { setApprovalIssue({ key: address.toLowerCase(), message: "Enter the approval transaction hash from your wallet or its source explorer." }); return; @@ -417,21 +430,16 @@ export function useBridge() { await navigator.locks.request(transferLockName(address), { ifAvailable: true }, async (lock) => { if (!lock) throw new Error("A bridge request is open in another tab. Check it before recovering this approval."); const current = approvals.read(address); - if (!current || current.createdAt !== approval.createdAt || current.approvalHash) throw new Error("The saved approval changed. Review its current status."); + if (!current || current.createdAt !== approval.createdAt || current.approvalHash !== approval.approvalHash || !approvalBlocksSubmission(current)) throw new Error("The saved approval changed. Review its current status."); const pub = getPublicClient(config, { chainId: current.chainId }); if (!pub) throw new Error("Could not connect to the approval's network. Try checking again."); const [chainId, transaction, receipt, allowance] = await Promise.all([ pub.getChainId(), pub.getTransaction({ hash }), pub.getTransactionReceipt({ hash }), pub.readContract({ address: current.token, abi: erc20Abi, functionName: "allowance", args: [address, RELAY_DEPOSITORY] }), ]); - if (chainId !== current.chainId || receipt.transactionHash.toLowerCase() !== hash.toLowerCase()) throw new Error("The transaction could not be verified on the approval's network."); + if (receipt.transactionHash.toLowerCase() !== hash.toLowerCase()) throw new Error("The transaction receipt did not match the supplied hash."); const block = await pub.getBlock({ blockNumber: receipt.blockNumber }); - const blockTime = Number(block.timestamp) * 1000; - // Approvals have no unique order ID. Do not adopt a historical matching - // approval; allow only a bounded clock difference from this wallet call. - if (blockTime < current.createdAt - 30_000 || blockTime > Date.now() + 30_000) throw new Error("This transaction predates the saved approval or your clock differs from the network. Check the hash and device clock."); - if (!isMatchingApprovalTransaction(current, transaction) && !hasMatchingApprovalEvent(current, receipt)) throw new Error("That transaction does not match this wallet's exact USDC approval."); - const next = reconcileApproval({ ...current, approvalHash: hash }, { chainId, allowance, receipt }); + const next = recoverApprovalFromEvidence(current, { chainId, transaction, receipt, allowance, blockTimestamp: block.timestamp, now: Date.now() }); approvals.save(next); }); } catch (error) { @@ -475,6 +483,7 @@ export function useBridge() { prepare: async (q) => { const pub = getPublicClient(config, { chainId: q.originChainId }); if (!pub) throw new Error("Could not connect to the source network. Try again."); + await assertBridgeWalletQueueClear(pub, q.address, BRIDGE_CHAINS[q.originChainId].name); const transaction = { account: q.address, to: q.transaction.to, data: q.transaction.data, value: BigInt(q.transaction.value) }; const [rpcChain, balance, estimate, fees, additional, allowance, selectedBalance] = await Promise.all([ pub.getChainId(), pub.getBalance({ address: q.address }), pub.estimateGas(transaction), pub.estimateFeesPerGas(), @@ -639,6 +648,7 @@ export function useBridge() { quoteError: quoteIssue?.key === requestKey ? quoteIssue.message : null, quoteExpired, requestQuote, confirm, reset, tracked, approval, approvalRequired, allowanceLoading, approvalBusy: approvalSending, + approvalHealth: approvalPending && approvalHealthResult?.wallet === walletKey && approvalHealthResult.createdAt === approvalId && approvalHealthResult.hash === approvalHash ? approvalHealthResult.value : null, approvalError: approvalIssue?.key === walletKey ? approvalIssue.message : quote && allowanceResult?.key === quote.requestId ? allowanceResult.error : null, approve, retryApproval, recoverApproval, approvalCanBeDiscarded, discardApproval, canDiscard, discard, discarding, diff --git a/app/src/lib/bridge/approval-health.test.ts b/app/src/lib/bridge/approval-health.test.ts new file mode 100644 index 00000000..3f6d1e69 --- /dev/null +++ b/app/src/lib/bridge/approval-health.test.ts @@ -0,0 +1,63 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { TransactionNotFoundError, type Hex } from "viem"; +import { ARC_USDC, RELAY_APPROVAL_SPENDER, exactApprovalTransaction, type TrackedApproval } from "./approval"; +import { describePendingApproval, readPendingApprovalHealth } from "./approval-health"; + +const NOW = 1_800_000_000_000; +const hash = `0x${"1".repeat(64)}` as Hex; +const approval: TrackedApproval = { version: 1, address: "0x1111111111111111111111111111111111111111", chainId: 5042, token: ARC_USDC, spender: RELAY_APPROVAL_SPENDER, amount: "500000", createdAt: NOW - 70_000, status: "pending", approvalHash: hash }; +const transaction = { hash, from: approval.address, to: ARC_USDC, value: 0n, input: exactApprovalTransaction(approval).data, nonce: 2, maxFeePerGas: 30_000_000_000n }; +const observation = { createdAt: approval.createdAt, now: NOW, transaction, latestNonce: 0, baseFeePerGas: 166_000_000_000n }; + +test("nonce 2 cannot be described as normally confirming while source nonce is 0", () => { + const health = describePendingApproval(observation); + assert.equal(health.kind, "queued"); + assert.match(health.detail, /nonce 2/); + assert.match(health.detail, /nonce is 0/); + assert.match(health.detail, /earliest pending/); +}); + +test("underpriced fee, consumed nonce and slow confirmation have distinct guidance", () => { + assert.equal(describePendingApproval({ ...observation, latestNonce: 2 }).kind, "fee-too-low"); + assert.equal(describePendingApproval({ ...observation, latestNonce: 3 }).kind, "nonce-passed"); + assert.equal(describePendingApproval({ ...observation, latestNonce: 2, baseFeePerGas: 20_000_000_000n }).kind, "delayed"); + assert.equal(describePendingApproval({ ...observation, createdAt: NOW, latestNonce: 2, baseFeePerGas: 20_000_000_000n }).kind, "waiting"); + assert.equal(describePendingApproval({ ...observation, latestNonce: 2, transaction: { nonce: 2, gasPrice: 30_000_000_000n } }).kind, "fee-too-low"); +}); + +test("missing hash has a propagation grace period and never asserts failure or cancellation", () => { + assert.equal(describePendingApproval({ ...observation, transaction: null, createdAt: NOW }).kind, "waiting"); + const health = describePendingApproval({ ...observation, transaction: null }); + assert.equal(health.kind, "not-seen"); + assert.match(health.detail, /may still be queued/); + assert.match(health.detail, /do not approve again/); +}); + +test("invalid nonce observations stay unknown rather than falsely identifying a queue", () => { + for (const latestNonce of [undefined, -1, NaN, 1.5, Infinity]) assert.equal(describePendingApproval({ ...observation, latestNonce }).kind, "unavailable"); +}); + +test("read-only diagnostics use matching transaction evidence and preserve the journal", async () => { + const before = JSON.stringify(approval); + const client = { + getTransaction: async () => transaction, + getTransactionCount: async () => 0, + getBlock: async () => ({ baseFeePerGas: 166_000_000_000n }), + }; + assert.equal((await readPendingApprovalHealth(client, approval, NOW)).kind, "queued"); + assert.equal((await readPendingApprovalHealth({ ...client, getTransaction: async () => ({ ...transaction, value: 1n }) }, approval, NOW)).kind, "unavailable"); + assert.equal((await readPendingApprovalHealth({ ...client, getTransaction: async () => ({ ...transaction, hash: `0x${"2".repeat(64)}` as Hex }) }, approval, NOW)).kind, "unavailable"); + assert.equal(JSON.stringify(approval), before); +}); + +test("only typed transaction-not-found becomes missing; rate limits and RPC failures remain unavailable", async () => { + const client = { + getTransaction: async () => { throw new TransactionNotFoundError({ hash }); }, + getTransactionCount: async () => { throw new Error("should not read nonce"); }, + getBlock: async () => { throw new Error("should not read block"); }, + }; + assert.equal((await readPendingApprovalHealth(client, approval, NOW)).kind, "not-seen"); + assert.equal((await readPendingApprovalHealth({ ...client, getTransaction: async () => { throw new Error("rate limited"); } }, approval, NOW)).kind, "unavailable"); + assert.equal((await readPendingApprovalHealth({ ...client, getTransaction: async () => transaction }, approval, NOW)).kind, "unavailable"); +}); diff --git a/app/src/lib/bridge/approval-health.ts b/app/src/lib/bridge/approval-health.ts new file mode 100644 index 00000000..4865c551 --- /dev/null +++ b/app/src/lib/bridge/approval-health.ts @@ -0,0 +1,64 @@ +import { TransactionNotFoundError, type Address, type Hex } from "viem"; +import { isMatchingApprovalTransaction, type TrackedApproval } from "./approval"; + +export type ApprovalHealth = { + kind: "waiting" | "not-seen" | "queued" | "fee-too-low" | "nonce-passed" | "delayed" | "unavailable"; + title: string; + detail: string; +}; + +type PendingTransaction = { hash: Hex; from: Address; to: Address | null; input: Hex; value: bigint; nonce: number; maxFeePerGas?: bigint; gasPrice?: bigint }; +type ApprovalHealthClient = { + getTransaction: (args: { hash: Hex }) => Promise; + getTransactionCount: (args: { address: Address; blockTag: "latest" }) => Promise; + getBlock: (args: { blockTag: "latest" }) => Promise<{ baseFeePerGas: bigint | null }>; +}; + +export const APPROVAL_HEALTH_UNAVAILABLE: ApprovalHealth = { + kind: "unavailable", title: "Approval status unavailable", + detail: "The network could not provide transaction details. Checking will retry. Check your wallet’s activity before taking any action; do not approve again.", +}; + +/** Advice only: never changes the journal, confirms an approval or permits a retry. */ +export function describePendingApproval(input: { createdAt: number; now: number; transaction: Pick | null; latestNonce?: number; baseFeePerGas?: bigint | null }): ApprovalHealth { + const delayed = input.now - input.createdAt >= 60_000; + const waiting: ApprovalHealth = { kind: "waiting", title: "Approval submitted", detail: "Waiting for a confirmation. You’ll review a fresh bridge quote next." }; + if (!input.transaction) return delayed ? { + kind: "not-seen", title: "Approval not found by the network", + detail: "This network node cannot find the saved transaction. It may still be queued in your wallet, dropped, or replaced. Check your wallet’s activity; do not approve again just because it is missing here.", + } : waiting; + const { nonce, maxFeePerGas, gasPrice } = input.transaction; + if (!Number.isSafeInteger(nonce) || nonce < 0 || !Number.isSafeInteger(input.latestNonce) || input.latestNonce! < 0) return APPROVAL_HEALTH_UNAVAILABLE; + if (nonce > input.latestNonce!) return { + kind: "queued", title: "Waiting for an earlier transaction", + detail: `This approval uses nonce ${nonce}, but your wallet’s next confirmed nonce is ${input.latestNonce}. Check the earliest pending transaction in your wallet on this network. Later transactions cannot confirm first.`, + }; + if (nonce < input.latestNonce!) return { + kind: "nonce-passed", title: "Check your wallet’s transaction history", + detail: "This nonce has already been used, but the saved approval has no receipt. It may have been replaced, or the node may be catching up. If you sped it up, verify the confirmed replacement hash below.", + }; + const feeCap = maxFeePerGas ?? gasPrice; + if (feeCap !== undefined && input.baseFeePerGas != null && feeCap < input.baseFeePerGas) return { + kind: "fee-too-low", title: "Approval fee is below the network fee", + detail: "The submitted transaction’s fee cap is below the current base fee. Review its fee in your wallet. If you choose to speed it up, return with the confirmed replacement hash; do not create a second approval.", + }; + return delayed ? { kind: "delayed", title: "Approval is taking longer than expected", detail: "The network can see the transaction, but it has not confirmed. Check its status and fee in your wallet. If you sped it up, verify the confirmed replacement hash below." } : waiting; +} + +/** Optional diagnostics. A failed RPC read is not evidence that a transaction is missing. */ +export async function readPendingApprovalHealth(client: ApprovalHealthClient, approval: TrackedApproval, now: number): Promise { + if (!approval.approvalHash) return APPROVAL_HEALTH_UNAVAILABLE; + try { + const transaction = await client.getTransaction({ hash: approval.approvalHash }).catch((error: unknown) => { + if (error instanceof TransactionNotFoundError) return null; + throw error; + }); + if (!transaction) return describePendingApproval({ createdAt: approval.createdAt, now, transaction }); + if (transaction.hash.toLowerCase() !== approval.approvalHash.toLowerCase() || !isMatchingApprovalTransaction(approval, transaction)) return APPROVAL_HEALTH_UNAVAILABLE; + const [latestNonce, block] = await Promise.all([ + client.getTransactionCount({ address: approval.address, blockTag: "latest" }), + client.getBlock({ blockTag: "latest" }), + ]); + return describePendingApproval({ createdAt: approval.createdAt, now, transaction, latestNonce, baseFeePerGas: block.baseFeePerGas }); + } catch { return APPROVAL_HEALTH_UNAVAILABLE; } +} diff --git a/app/src/lib/bridge/approval.test.ts b/app/src/lib/bridge/approval.test.ts index 2ba5f2f4..be59865f 100644 --- a/app/src/lib/bridge/approval.test.ts +++ b/app/src/lib/bridge/approval.test.ts @@ -3,6 +3,7 @@ import test from "node:test"; import { decodeFunctionData, encodeAbiParameters, encodeEventTopics, type Address, type Hex } from "viem"; import { APPROVAL_DISCARD_AFTER_MS, APPROVAL_STORAGE_PREFIX, ARC_APPROVAL_CHAIN_ID, ARC_USDC, EXACT_APPROVAL_ABI, RELAY_APPROVAL_SPENDER, USDC_APPROVAL_EVENT, approvalBlocksSubmission, approvalCanDiscard, approvalRequestKey, approvalStorageKey, createApprovalStore, exactApprovalTransaction, hasMatchingApprovalEvent, isMatchingApprovalTransaction, parseStoredApproval, reconcileApproval, serializeApproval, submitExactApproval, validateApprovalMetadata, type ApprovalDependencies, type ApprovalRequest, type TrackedApproval } from "./approval"; import { BASE_USDC } from "./types"; +import { canApplyApprovalPoll, recoverApprovalFromEvidence } from "./approval"; const ADDRESS = "0x1111111111111111111111111111111111111111" as Address; const OTHER = "0x2222222222222222222222222222222222222222" as Address; @@ -334,3 +335,38 @@ test("an unmined approval can be discarded after the wait; a hash needs a fresh for (const status of ["confirmed", "reverted", "insufficient"] as const) assert.equal(approvalCanDiscard(tracked({ status, approvalHash: HASH }), missing, later), false, status); assert.equal(approvalCanDiscard(null, missing, later), false); }); + +test("a confirmed exact replacement recovers a pending hash without resubmitting or granting more allowance", () => { + const approval = tracked({ status: "pending", approvalHash: HASH }); + const evidence = { + chainId: 5042, allowance: 25_000_000n, + transaction: { hash: OTHER_HASH, from: ADDRESS, to: ARC_USDC, input: exactApprovalTransaction(REQUEST).data, value: 0n }, + receipt: { transactionHash: OTHER_HASH, status: "success" as const, logs: [] }, + blockTimestamp: BigInt(NOW / 1000), now: NOW, + }; + const recovered = recoverApprovalFromEvidence(approval, evidence); + assert.equal(recovered.approvalHash, OTHER_HASH); + assert.equal(recovered.status, "confirmed"); + assert.equal(recovered.amount, approval.amount); + assert.equal(recovered.createdAt, approval.createdAt); + assert.equal(approval.approvalHash, HASH); // input journal untouched + assert.equal(recoverApprovalFromEvidence(approval, { ...evidence, allowance: 0n }).status, "insufficient"); + assert.throws(() => recoverApprovalFromEvidence(approval, { ...evidence, chainId: 8453 }), /network/); + assert.throws(() => recoverApprovalFromEvidence(approval, { ...evidence, blockTimestamp: evidence.blockTimestamp - 31n }), /predates/); + assert.throws(() => recoverApprovalFromEvidence(approval, { ...evidence, blockTimestamp: evidence.blockTimestamp + 31n }), /clock/); + assert.throws(() => recoverApprovalFromEvidence(approval, { ...evidence, transaction: { ...evidence.transaction, hash: HASH } }), /network/); + for (const changes of [{ to: ADDRESS, input: "0x" as Hex }, { from: OTHER }, { input: exactApprovalTransaction({ ...REQUEST, amount: "1" }).data }, { value: 1n }]) { + assert.throws(() => recoverApprovalFromEvidence(approval, { ...evidence, transaction: { ...evidence.transaction, ...changes } }), /exact USDC approval/); + } + assert.throws(() => recoverApprovalFromEvidence({ ...approval, status: "confirmed" }, evidence), /already resolved/); +}); + +test("a delayed missing-receipt poll cannot undo same-hash confirmation or another recovery", () => { + const observed = tracked({ status: "pending", approvalHash: HASH }); + assert.equal(canApplyApprovalPoll(observed, observed), true); + const confirmed = reconcileApproval(observed, { chainId: 5042, allowance: BigInt(observed.amount), receipt: { transactionHash: HASH, status: "success" } }); + assert.equal(canApplyApprovalPoll(confirmed, observed), false); + for (const status of ["reverted", "insufficient"] as const) assert.equal(canApplyApprovalPoll({ ...observed, status }, observed), false); + for (const change of [{ approvalHash: OTHER_HASH }, { address: OTHER }, { amount: "1" }, { createdAt: NOW + 1 }, { chainId: 8453 as const }]) assert.equal(canApplyApprovalPoll({ ...observed, ...change }, observed), false); + assert.equal(canApplyApprovalPoll(null, observed), false); +}); diff --git a/app/src/lib/bridge/approval.ts b/app/src/lib/bridge/approval.ts index 9f31d308..720dd455 100644 --- a/app/src/lib/bridge/approval.ts +++ b/app/src/lib/bridge/approval.ts @@ -88,6 +88,13 @@ export function approvalBlocksSubmission(approval: TrackedApproval | null): bool return !!approval && (approval.status === "uncertain" || approval.status === "pending"); } +/** A delayed poll must not overwrite a recovery or a different wallet attempt. */ +export function canApplyApprovalPoll(current: TrackedApproval | null, observed: TrackedApproval): boolean { + return !!current && approvalBlocksSubmission(current) && current.createdAt === observed.createdAt && + current.address.toLowerCase() === observed.address.toLowerCase() && current.chainId === observed.chainId && + current.approvalHash === observed.approvalHash && current.amount === observed.amount; +} + export function exactApprovalTransaction(request: ApprovalRequest): ApprovalTransaction { const checked = validateRequest(request); return { chainId: checked.chainId ?? ARC_APPROVAL_CHAIN_ID, to: approvalToken(checked.chainId), value: 0n, data: encodeFunctionData({ abi: EXACT_APPROVAL_ABI, functionName: "approve", args: [RELAY_APPROVAL_SPENDER, BigInt(checked.amount)] }) }; @@ -149,6 +156,26 @@ export function reconcileApproval(approval: TrackedApproval, observation: Approv return { ...checked, status: observation.receipt.status === "reverted" ? "reverted" : observation.allowance >= BigInt(checked.amount) ? "confirmed" : "insufficient" }; } +/** Verify a user-supplied mined hash, including a wallet speed-up replacement. */ +export function recoverApprovalFromEvidence(approval: TrackedApproval, evidence: { + chainId: number; + allowance: bigint; + transaction: { hash: Hex; from: Address; to: Address | null; input: Hex; value: bigint }; + receipt: { transactionHash: Hex; status: "success" | "reverted"; logs: readonly { address: Address; data: Hex; topics: readonly Hex[] }[] }; + blockTimestamp: bigint; + now: number; +}): TrackedApproval { + const current = parseStoredApproval(serializeApproval(approval), approval.address); + if (!approvalBlocksSubmission(current)) throw new Error("The saved approval is already resolved. Review its current status."); + const hash = evidence.receipt.transactionHash; + if (evidence.chainId !== current.chainId || !validHash(hash) || evidence.transaction.hash.toLowerCase() !== hash.toLowerCase()) throw new Error("The transaction could not be verified on the approval's network."); + const blockTime = Number(evidence.blockTimestamp) * 1000; + // Approvals have no order ID. Never adopt an old approval from before this attempt. + if (!Number.isSafeInteger(blockTime) || !Number.isSafeInteger(evidence.now) || blockTime < current.createdAt - 30_000 || blockTime > evidence.now + 30_000) throw new Error("This transaction predates the saved approval or your clock differs from the network. Check the hash and device clock."); + if (!isMatchingApprovalTransaction(current, evidence.transaction) && !hasMatchingApprovalEvent(current, evidence.receipt)) throw new Error("That transaction does not match this wallet's exact USDC approval."); + return reconcileApproval({ ...current, approvalHash: hash }, evidence); +} + type StorageAdapter = Pick; export type ApprovalSnapshot = { approvals: Record; errors: Record }; const EMPTY: ApprovalSnapshot = { approvals: {}, errors: {} }; diff --git a/app/src/lib/bridge/transaction-preflight.test.ts b/app/src/lib/bridge/transaction-preflight.test.ts new file mode 100644 index 00000000..881fbdb8 --- /dev/null +++ b/app/src/lib/bridge/transaction-preflight.test.ts @@ -0,0 +1,186 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { encodeFunctionData, type Address, type Hex } from "viem"; +import { submitExactApproval, type ApprovalDependencies, type ApprovalRequest } from "./approval"; +import { ERC20_DEPOSIT_ABI, NATIVE_DEPOSIT_ABI, RELAY_DEPOSITORY, submitBridgeDeposit, type DepositDependencies } from "./client"; +import { assertBridgeWalletQueueClear } from "./transaction-preflight"; +import { BRIDGE_ASSETS, BRIDGE_CHAINS, BRIDGE_CHAIN_IDS, bridgeCurrency, type BridgeAsset, type BridgeChainId, type BridgeQuote } from "./types"; + +const ADDRESS = "0x1111111111111111111111111111111111111111" as Address; +const HASH = `0x${"a".repeat(64)}` as Hex; +const ORDER = `0x${"b".repeat(64)}` as Hex; +const NOW = 1_800_000_000_000; +const GAS = { gas: 100_000n, maxFeePerGas: 2n, maxPriorityFeePerGas: 1n }; + +function clientFor(pending: number, latest: number, events: string[] = []) { + return { + async getTransactionCount(request: { address: Address; blockTag: "latest" | "pending" }) { + assert.equal(request.address, ADDRESS); + events.push(request.blockTag); + return request.blockTag === "pending" ? pending : latest; + }, + }; +} + +test("a clear source-wallet queue accepts zero and nonzero confirmed nonces on every bridge network", async () => { + for (const { name } of Object.values(BRIDGE_CHAINS)) { + for (const count of [0, 17, Number.MAX_SAFE_INTEGER]) { + const events: string[] = []; + await assertBridgeWalletQueueClear(clientFor(count, count, events), ADDRESS, name); + assert.deepEqual(events, ["pending", "latest"]); + } + } +}); + +test("a visible pending queue blocks with source-network and earliest-transaction guidance", async () => { + for (const { name } of Object.values(BRIDGE_CHAINS)) { + await assert.rejects(assertBridgeWalletQueueClear(clientFor(3, 0), ADDRESS, name), (error: Error) => { + assert.match(error.message, new RegExp(`pending transaction on ${name}`)); + assert.match(error.message, /Open your wallet and resolve the earliest pending transaction before approving or bridging/); + return true; + }); + } +}); + +test("latest is not requested until the pending observation completes", async () => { + const events: string[] = []; + let resolvePending!: (value: number) => void; + const pending = new Promise((resolve) => { resolvePending = resolve; }); + const checking = assertBridgeWalletQueueClear({ + getTransactionCount: async ({ blockTag }) => { + events.push(blockTag); + return blockTag === "pending" ? pending : 7; + }, + }, ADDRESS, "Arc"); + assert.deepEqual(events, ["pending"]); + resolvePending(7); + await checking; + assert.deepEqual(events, ["pending", "latest"]); +}); + +test("a newer confirmed nonce fails closed without claiming a pending wallet transaction", async () => { + await assert.rejects(assertBridgeWalletQueueClear(clientFor(2, 3), ADDRESS, "Arc"), (error: Error) => { + assert.match(error.message, /consistent transaction count on Arc/); + assert.match(error.message, /Wait a moment and try again/); + assert.doesNotMatch(error.message, /wallet has a pending|resolve the earliest/); + return true; + }); +}); + +test("malformed or unsafe transaction counts cannot allow approval or deposit", async () => { + for (const invalid of [-1, 0.5, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1, "0", null, undefined]) { + for (const [pending, latest] of [[invalid, 0], [0, invalid]]) { + await assert.rejects(assertBridgeWalletQueueClear(clientFor(pending as number, latest as number), ADDRESS, "Base"), /consistent transaction count on Base/); + } + } +}); + +test("RPC failures remain the original error and never become a claim about the wallet queue", async () => { + for (const failedRead of ["pending", "latest"] as const) { + const error = new Error(`RPC ${failedRead} unavailable`); + const events: string[] = []; + await assert.rejects(assertBridgeWalletQueueClear({ + getTransactionCount: async ({ blockTag }) => { + events.push(blockTag); + if (blockTag === failedRead) throw error; + return 0; + }, + }, ADDRESS, "Arc"), (received) => received === error); + assert.deepEqual(events, failedRead === "pending" ? ["pending"] : ["pending", "latest"]); + } +}); + +function approvalScenario(chainId: 8453 | 5042, pending: number, latest: number) { + const events: string[] = []; + const request: ApprovalRequest = { address: ADDRESS, chainId, amount: "500000" }; + const deps: ApprovalDependencies = { + now: () => NOW, + currentRequest: () => request, + readWallet: async () => ({ address: ADDRESS, chainId }), + switchChain: async () => assert.fail("already on source network"), + prepare: async () => { + await assertBridgeWalletQueueClear(clientFor(pending, latest, events), ADDRESS, BRIDGE_CHAINS[chainId].name); + return GAS; + }, + readApproval: () => null, + saveApproval: (approval) => { events.push(`save:${approval.status}`); }, + removeApproval: () => { events.push("remove"); }, + send: async () => { events.push("send"); return HASH; }, + phase: (phase) => { events.push(`phase:${phase}`); }, + }; + return { request, deps, events }; +} + +test("USDC approval preflight blocks both source networks before journaling or opening the wallet", async () => { + for (const chainId of [8453, 5042] as const) { + for (const [pending, latest] of [[3, 0], [0, 1], [-1, 0]]) { + const scenario = approvalScenario(chainId, pending, latest); + await assert.rejects(submitExactApproval(scenario.request, scenario.deps), /pending transaction|consistent transaction count/); + assert.deepEqual(scenario.events, ["pending", "latest"]); + } + } +}); + +function quote(originChainId: BridgeChainId, destinationChainId: BridgeChainId, originAsset: BridgeAsset, destinationAsset: BridgeAsset): BridgeQuote { + const currency = bridgeCurrency(originChainId, originAsset, "input"); + const amount = (10n ** BigInt(currency.decimals)).toString(); + const erc20 = originAsset === "USDC"; + return { + address: ADDRESS, originChainId, destinationChainId, originAsset, destinationAsset, amount, + requestId: HASH, amountOut: "990000", minimumAmountOut: "980000", + relayFee: "0.001", sourceGas: "0.00001", totalImpactPercent: "-1", timeEstimate: 15, expiresAt: NOW + 45_000, ttlMs: 45_000, + ...(erc20 ? { approval: { token: currency.address, spender: RELAY_DEPOSITORY, amount } } : {}), + transaction: { + to: RELAY_DEPOSITORY, chainId: originChainId, value: erc20 ? "0" : amount, + data: erc20 + ? encodeFunctionData({ abi: ERC20_DEPOSIT_ABI, functionName: "depositErc20", args: [ADDRESS, currency.address, BigInt(amount), ORDER] }) + : encodeFunctionData({ abi: NATIVE_DEPOSIT_ABI, functionName: "depositNative", args: [ADDRESS, ORDER] }), + }, + }; +} + +function depositScenario(q: BridgeQuote, pending: number, latest: number) { + const events: string[] = []; + const deps: DepositDependencies = { + now: () => NOW, + currentRequest: () => q, + readWallet: async () => ({ address: ADDRESS, chainId: q.originChainId }), + switchChain: async () => assert.fail("already on source network"), + prepare: async () => { + await assertBridgeWalletQueueClear(clientFor(pending, latest, events), ADDRESS, BRIDGE_CHAINS[q.originChainId].name); + return GAS; + }, + readTransfer: () => null, + saveTransfer: (transfer) => { events.push(`save:${transfer.status}`); }, + removeTransfer: () => { events.push("remove"); }, + send: async () => { events.push("send"); return HASH; }, + phase: (phase) => { events.push(`phase:${phase}`); }, + }; + return { deps, events }; +} + +test("all ten supported deposit routes block queued nonces before journaling or sending", async () => { + let routes = 0; + for (const origin of BRIDGE_CHAIN_IDS) for (const destination of BRIDGE_CHAIN_IDS) { + if (origin === destination) continue; + for (const originAsset of BRIDGE_ASSETS[origin]) for (const destinationAsset of BRIDGE_ASSETS[destination]) { + const q = quote(origin, destination, originAsset, destinationAsset); + const scenario = depositScenario(q, 3, 0); + await assert.rejects(submitBridgeDeposit(q, scenario.deps), /pending transaction/); + assert.deepEqual(scenario.events, ["pending", "latest"]); + routes++; + } + } + assert.equal(routes, 10); +}); + +test("clear queues preserve explicit approval and deposit submission order", async () => { + const approval = approvalScenario(5042, 4, 4); + assert.equal((await submitExactApproval(approval.request, approval.deps)).kind, "sent"); + const q = quote(5042, 8453, "USDC", "USDC"); + const deposit = depositScenario(q, 5, 5); + assert.equal((await submitBridgeDeposit(q, deposit.deps)).kind, "sent"); + for (const events of [approval.events, deposit.events]) { + assert.deepEqual(events, ["pending", "latest", "save:uncertain", "phase:confirming", "send", "save:pending"]); + } +}); diff --git a/app/src/lib/bridge/transaction-preflight.ts b/app/src/lib/bridge/transaction-preflight.ts new file mode 100644 index 00000000..86f25d60 --- /dev/null +++ b/app/src/lib/bridge/transaction-preflight.ts @@ -0,0 +1,24 @@ +import type { Address } from "viem"; + +type WalletQueueClient = { + getTransactionCount: (request: { address: Address; blockTag: "latest" | "pending" }) => Promise; +}; + +/** + * Do not stack a bridge transaction behind a queue visible to the source RPC. + * This is a read-only preflight, not a guarantee that every node sees the same + * mempool. Never choose a nonce or replace an existing wallet transaction here. + */ +export async function assertBridgeWalletQueueClear(client: WalletQueueClient, address: Address, networkName: string): Promise { + // Pending first avoids reporting a queue just because a transaction mined + // between a latest read and a newer pending read. A reverse mismatch fails + // closed as an inconsistent observation, not as evidence of a stuck queue. + const pending = await client.getTransactionCount({ address, blockTag: "pending" }); + const latest = await client.getTransactionCount({ address, blockTag: "latest" }); + if (!Number.isSafeInteger(pending) || pending < 0 || !Number.isSafeInteger(latest) || latest < 0 || pending < latest) { + throw new Error(`Could not verify a consistent transaction count on ${networkName}. Wait a moment and try again before approving or bridging.`); + } + if (pending > latest) { + throw new Error(`Your wallet has a pending transaction on ${networkName}. Open your wallet and resolve the earliest pending transaction before approving or bridging.`); + } +} diff --git a/docs/bridge.md b/docs/bridge.md index c21fc12b..cca48cf8 100644 --- a/docs/bridge.md +++ b/docs/bridge.md @@ -53,6 +53,11 @@ These tests never connect a wallet or submit a transaction. ### Wallet test findings (2026-09-16) +- Approval recovery now distinguishes a transaction missing from the queried node, an earlier nonce blocking it, a fee cap below the current base fee, and an RPC outage. These are advisory observations, never permission to resubmit or proof that a missing transaction failed. A fresh matching receipt and allowance are still required. +- Before a new approval or deposit, the source RPC's pending and latest nonce counts are checked. A visible queue or inconsistent read stops submission before journaling or requesting the approval/deposit signature. A wallet network-switch prompt may happen first. This cannot see every node's mempool or wallet-private queue, and it deliberately never overrides a nonce. +- A wallet speed-up can change the approval hash. Both uncertain and pending approvals accept a user-supplied mined replacement after validating chain, hash, recent block time, exact owner/token/spender/amount (or canonical USDC Approval event), receipt and current allowance. Verification sends no transaction. A stale poll cannot overwrite a completed recovery. +- For a stuck approval, inspect the earliest pending transaction in the wallet on the source network. Do not clear wallet history or repeatedly approve. Discarding a saved UI record neither cancels a transaction nor resolves a nonce queue. Any wallet replacement/cancellation remains a user-controlled action. + - Phantom cannot add Arc or Robinhood (fixed EVM network list), so its chain switch fails; MetaMask adds both from the wallet config. The error copy now names this case. - viem probes `eth_fillTransaction` when estimating Base fees. The read proxy used to answer HTTP 403 for the whole batch, which failed every Base read; it now returns a per-item JSON-RPC `-32601` so viem falls back and the other reads succeed. Production ran the same proxy, so Base-origin bridges would have failed there too. - Public Base and Arc nodes rate-limit one quote's burst of reads inside 200 bodies. The proxy maps those and malformed bodies to 429/502 so viem retries; Arc reads are proxied with `ARC_RPC_URL`. base-rpc.publicnode.com is unsuitable as an upstream because it refuses receipt lookups without a token, which stalls approval and transfer tracking.