Skip to content

Commit 0233b36

Browse files
authored
Merge pull request #50 from Gitlawb/codex/bridge-approval-recovery
fix(bridge): recover stuck approvals safely
2 parents c6fcf07 + 92172b7 commit 0233b36

11 files changed

Lines changed: 457 additions & 24 deletions

File tree

‎app/src/app/ui-review-bridge/BridgeReview.tsx‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,13 @@ import { formatUnits, parseEther, parseUnits, zeroAddress } from "viem";
99
import { changeBridgeRoute, parseBridgeAmount, type BridgeRouteChange, type BridgeRouteInputs } from "@/lib/bridge/client";
1010
import { bridgeCurrency, defaultBridgeAsset, type BridgeQuote } from "@/lib/bridge/types";
1111
import type { TrackedApproval } from "@/lib/bridge/approval";
12+
import { describePendingApproval } from "@/lib/bridge/approval-health";
1213
import styles from "@/components/bridge/BridgeDialog.module.css";
1314

1415
const wallet = "0x03508bB71268BBA25ECaCC8F620e01866650532c" as const;
1516
const requestId = `0x${"1".repeat(64)}` as const;
16-
type Scene = "idle" | "disconnected" | "quote" | "expired" | "error" | "pending" | "success" | "uncertain" | "refund" | "approval_pending" | "approval_uncertain" | "approval_confirmed";
17-
const scenes: Scene[] = ["disconnected", "quote", "expired", "error", "pending", "success", "uncertain", "refund", "approval_pending", "approval_uncertain", "approval_confirmed"];
17+
type Scene = "idle" | "disconnected" | "quote" | "expired" | "error" | "pending" | "success" | "uncertain" | "refund" | "approval_pending" | "approval_uncertain" | "approval_confirmed" | "approval_queued" | "approval_missing" | "approval_fee";
18+
const scenes: Scene[] = ["disconnected", "quote", "expired", "error", "pending", "success", "uncertain", "refund", "approval_pending", "approval_uncertain", "approval_confirmed", "approval_queued", "approval_missing", "approval_fee"];
1819

1920
export default function BridgeReview() {
2021
const [open, setOpen] = useState(false);
@@ -42,7 +43,7 @@ export default function BridgeReview() {
4243
transaction: { to: wallet, data: "0x", value: "0", chainId: origin }, // deliberately non-executable fixture
4344
...(erc20Input ? { approval: { token: inputCurrency.address, spender: "0x4cd00e387622c35bddb9b4c962c136462338bc31" as const, amount: inputAmount.toString() } } : {}),
4445
};
45-
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;
46+
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;
4647
const tracking = ["pending", "success", "uncertain", "refund"].includes(scene);
4748
const bridge: ReturnType<typeof useBridge> = {
4849
address: scene === "disconnected" ? undefined : wallet, walletChainId: origin,
@@ -56,6 +57,7 @@ export default function BridgeReview() {
5657
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,
5758
statusError: null, retryStatus: () => {}, storageError: null, busy: false, canReset: scene === "success" || scene === "refund",
5859
approval, approvalRequired: erc20Input && !approved, allowanceLoading: false, approvalBusy: false, approvalError: null,
60+
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,
5961
approve: async () => setScene("approval_pending"), retryApproval: () => { setApproved(true); setScene("approval_confirmed"); },
6062
recoverApproval: async () => { setApproved(true); setScene("approval_confirmed"); },
6163
approvalCanBeDiscarded: scene === "approval_uncertain", discardApproval: async () => { setApproved(false); setScene("idle"); },

‎app/src/components/bridge/BridgeDialog.tsx‎

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -223,33 +223,35 @@ function ApprovalProgress({ bridge: b }: { bridge: Bridge }) {
223223
const approval = b.approval!;
224224
const approvalChain = BRIDGE_CHAINS[approval.chainId];
225225
const uncertain = approval.status === "uncertain";
226+
const health = b.approvalHealth;
227+
const needsAttention = uncertain || (!!health && health.kind !== "waiting");
226228
const [hash, setHash] = useState("");
227229
const [verifying, setVerifying] = useState(false);
228230
return <div className={styles.transfer}>
229231
<div className={styles.transferRoute}><NetworkMark chain={approval.chainId} /><span>USDC approval on {approvalChain.name}</span></div>
230232
<div className={styles.statusHeading} role="status">
231-
<span className={styles.statusIcon}>{uncertain ? <CircleAlert size={26} aria-hidden /> : <LoaderCircle size={26} className={styles.spinner} aria-hidden />}</span>
232-
<h3>{verifying ? "Verifying transaction" : b.approvalBusy ? "Check your wallet" : uncertain ? "Check your approval" : "Approval submitted"}</h3>
233-
<p>{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.`}</p>
233+
<span className={styles.statusIcon}>{needsAttention ? <CircleAlert size={26} aria-hidden /> : <LoaderCircle size={26} className={styles.spinner} aria-hidden />}</span>
234+
<h3>{verifying ? "Verifying transaction" : b.approvalBusy ? "Check your wallet" : uncertain ? "Check your approval" : health?.title ?? "Approval submitted"}</h3>
235+
<p>{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.`}</p>
234236
</div>
235237
<dl className={styles.transferDetails}><div><dt>Approval limit</dt><dd>{nativeAmount(formatUnits(BigInt(approval.amount), 6))} USDC</dd></div></dl>
236238
{b.address ? <Recipient address={b.address} /> : null}
237239
<p className={styles.approvalNotice}>No bridge deposit has been requested. An approval permits Relay’s deposit contract to use up to this amount; it does not bridge it.</p>
238240
{b.approvalError || b.storageError ? <p className={styles.error} role="alert"><CircleAlert size={16} aria-hidden /><span>{b.approvalError || b.storageError}</span></p> : null}
239241
{approval.approvalHash ? <a className={styles.externalAction} href={`${approvalChain.explorer}/tx/${approval.approvalHash}`} target="_blank" rel="noreferrer">View approval on {approvalChain.name} <ArrowUpRight size={17} aria-hidden /></a> : null}
240-
{uncertain && !approval.approvalHash && !b.approvalBusy ? <details className={styles.approvalRecovery}>
241-
<summary>Have the approval transaction hash?</summary>
242-
<p>Copy it from your wallet’s activity on {approvalChain.name}. We’ll verify the wallet, token, spender and exact amount before resuming.</p>
242+
{!b.approvalBusy ? <details className={styles.approvalRecovery}>
243+
<summary>{approval.approvalHash ? "Sped up your approval in the wallet?" : "Have the approval transaction hash?"}</summary>
244+
<p>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.</p>
243245
<form onSubmit={(event) => { event.preventDefault(); setVerifying(true); void b.recoverApproval(hash.trim()).finally(() => setVerifying(false)); }}>
244246
<label htmlFor="bridge-approval-hash" className={styles.smallLabel}>Approval transaction hash</label>
245247
<input id="bridge-approval-hash" value={hash} onChange={(event) => setHash(event.target.value)} placeholder="0x…" maxLength={66} autoComplete="off" spellCheck={false} />
246-
<button type="submit" className={styles.newTransfer} disabled={!/^0x[0-9a-fA-F]{64}$/.test(hash.trim())}>Verify approval transaction</button>
248+
<button type="submit" className={styles.newTransfer} disabled={verifying || !/^0x[0-9a-fA-F]{64}$/.test(hash.trim())}>Verify approval transaction</button>
247249
</form>
248250
</details> : null}
249251
{approval.approvalHash ? <button type="button" className={styles.newTransfer} disabled={b.approvalBusy} onClick={b.retryApproval}>Check approval status</button> : null}
250252
{b.approvalCanBeDiscarded ? <details className={styles.approvalRecovery}>
251253
<summary>Still not confirmed after {DISCARD_AFTER_MS / 60_000} minutes?</summary>
252-
<p>{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.</p>
254+
<p>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.</p>
253255
<button type="button" className={styles.newTransfer} disabled={b.approvalBusy || b.discarding} onClick={b.discardApproval}>{b.discarding ? "Checking the approval…" : "Discard this approval"}</button>
254256
</details> : null}
255257
<p className={styles.disclaimer}>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.</p>

‎app/src/components/bridge/bridge-ui.test.ts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,3 +140,17 @@ test("stuck records have a bounded discard path, and the hook avoids APIs missin
140140
assert.match(hook, /replacementSourceHash\(current, status, receipt\.status === "fulfilled" && receipt\.value === null\)/);
141141
assert.match(hook, /const messageOf = bridgeErrorMessage;/);
142142
});
143+
144+
test("pending approvals explain missing/queued transactions and accept a verified replacement without a new wallet call", () => {
145+
const hook = read("./useBridge.ts");
146+
assert.match(panel, /health\?\.title/);
147+
assert.match(panel, /needsAttention \? <CircleAlert/);
148+
assert.match(panel, /Sped up your approval in the wallet/);
149+
assert.doesNotMatch(panel, /has no record of this approval/);
150+
assert.match(panel, /discarding this record does not cancel it or fix a queued nonce/);
151+
assert.match(hook, /recoverApprovalFromEvidence\(current/);
152+
assert.match(hook, /!canApplyApprovalPoll\(current, observed\)/);
153+
assert.match(hook, /const health = receipt \? null : await readPendingApprovalHealth/);
154+
assert.match(hook, /value: APPROVAL_HEALTH_UNAVAILABLE/);
155+
assert.equal((hook.match(/await assertBridgeWalletQueueClear\(/g) ?? []).length, 2);
156+
});

0 commit comments

Comments
 (0)