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
8 changes: 5 additions & 3 deletions app/src/app/ui-review-bridge/BridgeReview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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<typeof useBridge> = {
address: scene === "disconnected" ? undefined : wallet, walletChainId: origin,
Expand All @@ -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"); },
Expand Down
18 changes: 10 additions & 8 deletions app/src/components/bridge/BridgeDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <div className={styles.transfer}>
<div className={styles.transferRoute}><NetworkMark chain={approval.chainId} /><span>USDC approval on {approvalChain.name}</span></div>
<div className={styles.statusHeading} role="status">
<span className={styles.statusIcon}>{uncertain ? <CircleAlert size={26} aria-hidden /> : <LoaderCircle size={26} className={styles.spinner} aria-hidden />}</span>
<h3>{verifying ? "Verifying transaction" : b.approvalBusy ? "Check your wallet" : uncertain ? "Check your approval" : "Approval submitted"}</h3>
<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>
<span className={styles.statusIcon}>{needsAttention ? <CircleAlert size={26} aria-hidden /> : <LoaderCircle size={26} className={styles.spinner} aria-hidden />}</span>
<h3>{verifying ? "Verifying transaction" : b.approvalBusy ? "Check your wallet" : uncertain ? "Check your approval" : health?.title ?? "Approval submitted"}</h3>
<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>
</div>
<dl className={styles.transferDetails}><div><dt>Approval limit</dt><dd>{nativeAmount(formatUnits(BigInt(approval.amount), 6))} USDC</dd></div></dl>
{b.address ? <Recipient address={b.address} /> : null}
<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>
{b.approvalError || b.storageError ? <p className={styles.error} role="alert"><CircleAlert size={16} aria-hidden /><span>{b.approvalError || b.storageError}</span></p> : null}
{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}
{uncertain && !approval.approvalHash && !b.approvalBusy ? <details className={styles.approvalRecovery}>
<summary>Have the approval transaction hash?</summary>
<p>Copy it from your wallet’s activity on {approvalChain.name}. We’ll verify the wallet, token, spender and exact amount before resuming.</p>
{!b.approvalBusy ? <details className={styles.approvalRecovery}>
<summary>{approval.approvalHash ? "Sped up your approval in the wallet?" : "Have the approval transaction hash?"}</summary>
<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>
<form onSubmit={(event) => { event.preventDefault(); setVerifying(true); void b.recoverApproval(hash.trim()).finally(() => setVerifying(false)); }}>
<label htmlFor="bridge-approval-hash" className={styles.smallLabel}>Approval transaction hash</label>
<input id="bridge-approval-hash" value={hash} onChange={(event) => setHash(event.target.value)} placeholder="0x…" maxLength={66} autoComplete="off" spellCheck={false} />
<button type="submit" className={styles.newTransfer} disabled={!/^0x[0-9a-fA-F]{64}$/.test(hash.trim())}>Verify approval transaction</button>
<button type="submit" className={styles.newTransfer} disabled={verifying || !/^0x[0-9a-fA-F]{64}$/.test(hash.trim())}>Verify approval transaction</button>
</form>
</details> : null}
{approval.approvalHash ? <button type="button" className={styles.newTransfer} disabled={b.approvalBusy} onClick={b.retryApproval}>Check approval status</button> : null}
{b.approvalCanBeDiscarded ? <details className={styles.approvalRecovery}>
<summary>Still not confirmed after {DISCARD_AFTER_MS / 60_000} minutes?</summary>
<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>
<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>
<button type="button" className={styles.newTransfer} disabled={b.approvalBusy || b.discarding} onClick={b.discardApproval}>{b.discarding ? "Checking the approval…" : "Discard this approval"}</button>
</details> : null}
<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>
Expand Down
14 changes: 14 additions & 0 deletions app/src/components/bridge/bridge-ui.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 \? <CircleAlert/);
assert.match(panel, /Sped up your approval in the wallet/);
assert.doesNotMatch(panel, /has no record of this approval/);
assert.match(panel, /discarding this record does not cancel it or fix a queued nonce/);
assert.match(hook, /recoverApprovalFromEvidence\(current/);
assert.match(hook, /!canApplyApprovalPoll\(current, observed\)/);
assert.match(hook, /const health = receipt \? null : await readPendingApprovalHealth/);
assert.match(hook, /value: APPROVAL_HEALTH_UNAVAILABLE/);
assert.equal((hook.match(/await assertBridgeWalletQueueClear\(/g) ?? []).length, 2);
});
Loading
Loading