From 4ae0a6ceffc4b00c088a044c7488c12912cf355c Mon Sep 17 00:00:00 2001 From: Vasanth T Date: Wed, 16 Sep 2026 13:39:13 +0530 Subject: [PATCH 01/12] feat(bridge): add Relay funding across three chains Let users fund the same wallet across Base, Robinhood and Arc without leaving Openlaunch or deploying new contracts. Validate Relay orders and deposits, require exact USDC approvals, and preserve uncertain transfers for recovery instead of resending. Include route, approval, recovery and UI regression tests, official asset provenance, and rollout documentation. Keep Robinhood USDC disabled because current routes fail the deposit and impact safeguards. --- app/public/brand/README.md | 11 + app/public/brand/arc.svg | 7 + app/public/brand/base.svg | 12 + app/public/brand/ethereum.svg | 2 + app/public/brand/robinhood-black.svg | 4 + app/public/brand/robinhood-white.svg | 4 + app/public/brand/usdc.svg | 11 + app/src/app/api/bridge/quote/route.ts | 18 + app/src/app/api/bridge/status/route.ts | 16 + app/src/app/ui-review-bridge/BridgeReview.tsx | 78 +++ app/src/app/ui-review-bridge/page.tsx | 10 + app/src/components/HeaderNav.tsx | 7 +- app/src/components/WalletMenu.tsx | 17 +- .../components/bridge/BridgeDialog.module.css | 135 +++++ app/src/components/bridge/BridgeDialog.tsx | 295 ++++++++++ app/src/components/bridge/BridgeProvider.tsx | 42 ++ app/src/components/bridge/DESIGN.md | 171 ++++++ app/src/components/bridge/bridge-ui.test.ts | 116 ++++ app/src/components/bridge/useBridge.ts | 550 ++++++++++++++++++ .../launchpad/launch-machine.test.ts | 2 +- app/src/components/wallet-menu.test.ts | 6 +- app/src/components/wallet-picker.test.ts | 5 +- app/src/lib/bridge/approval.test.ts | 319 ++++++++++ app/src/lib/bridge/approval.ts | 275 +++++++++ app/src/lib/bridge/chains.ts | 18 + app/src/lib/bridge/client-chains.test.ts | 40 ++ app/src/lib/bridge/client-storage.ts | 69 +++ app/src/lib/bridge/client.test.ts | 500 ++++++++++++++++ app/src/lib/bridge/client.ts | 322 ++++++++++ app/src/lib/bridge/relay-order.NOTICE.md | 72 +++ app/src/lib/bridge/relay-order.golden.ts | 261 +++++++++ app/src/lib/bridge/relay-order.test.ts | 60 ++ app/src/lib/bridge/relay-order.ts | 168 ++++++ app/src/lib/bridge/relay-order.vectors.ts | 47 ++ app/src/lib/bridge/relay.fixture.ts | 198 +++++++ app/src/lib/bridge/relay.live.test.ts | 24 + app/src/lib/bridge/relay.routes.test.ts | 80 +++ app/src/lib/bridge/relay.test.ts | 365 ++++++++++++ app/src/lib/bridge/relay.ts | 84 +++ app/src/lib/bridge/types.test.ts | 48 ++ app/src/lib/bridge/types.ts | 87 +++ app/src/lib/bridge/validation.test.ts | 102 ++++ app/src/lib/bridge/validation.ts | 223 +++++++ app/src/lib/security-headers.test.ts | 5 +- app/src/lib/security-headers.ts | 6 +- app/src/lib/wagmi.ts | 8 +- docs/bridge-surface.md | 13 + docs/bridge.md | 90 +++ 48 files changed, 4984 insertions(+), 19 deletions(-) create mode 100644 app/public/brand/README.md create mode 100644 app/public/brand/arc.svg create mode 100644 app/public/brand/base.svg create mode 100644 app/public/brand/ethereum.svg create mode 100644 app/public/brand/robinhood-black.svg create mode 100644 app/public/brand/robinhood-white.svg create mode 100644 app/public/brand/usdc.svg create mode 100644 app/src/app/api/bridge/quote/route.ts create mode 100644 app/src/app/api/bridge/status/route.ts create mode 100644 app/src/app/ui-review-bridge/BridgeReview.tsx create mode 100644 app/src/app/ui-review-bridge/page.tsx create mode 100644 app/src/components/bridge/BridgeDialog.module.css create mode 100644 app/src/components/bridge/BridgeDialog.tsx create mode 100644 app/src/components/bridge/BridgeProvider.tsx create mode 100644 app/src/components/bridge/DESIGN.md create mode 100644 app/src/components/bridge/bridge-ui.test.ts create mode 100644 app/src/components/bridge/useBridge.ts create mode 100644 app/src/lib/bridge/approval.test.ts create mode 100644 app/src/lib/bridge/approval.ts create mode 100644 app/src/lib/bridge/chains.ts create mode 100644 app/src/lib/bridge/client-chains.test.ts create mode 100644 app/src/lib/bridge/client-storage.ts create mode 100644 app/src/lib/bridge/client.test.ts create mode 100644 app/src/lib/bridge/client.ts create mode 100644 app/src/lib/bridge/relay-order.NOTICE.md create mode 100644 app/src/lib/bridge/relay-order.golden.ts create mode 100644 app/src/lib/bridge/relay-order.test.ts create mode 100644 app/src/lib/bridge/relay-order.ts create mode 100644 app/src/lib/bridge/relay-order.vectors.ts create mode 100644 app/src/lib/bridge/relay.fixture.ts create mode 100644 app/src/lib/bridge/relay.live.test.ts create mode 100644 app/src/lib/bridge/relay.routes.test.ts create mode 100644 app/src/lib/bridge/relay.test.ts create mode 100644 app/src/lib/bridge/relay.ts create mode 100644 app/src/lib/bridge/types.test.ts create mode 100644 app/src/lib/bridge/types.ts create mode 100644 app/src/lib/bridge/validation.test.ts create mode 100644 app/src/lib/bridge/validation.ts create mode 100644 docs/bridge-surface.md create mode 100644 docs/bridge.md diff --git a/app/public/brand/README.md b/app/public/brand/README.md new file mode 100644 index 0000000..90dd68f --- /dev/null +++ b/app/public/brand/README.md @@ -0,0 +1,11 @@ +# Official bridge identity assets + +Retrieved 2026-09-16. Marks retain their upstream shapes and colours; only source comments were added. They identify the token/network, not endorsement of Openlaunch. Trademark rights remain with their respective owners. + +- `base.svg`: [Base brand pack](https://brand.base.org/base-brand.zip), `1_Base Brand Assets/The Square/Base_square_blue.svg`. [Current guidance](https://brand.base.org/core-identifiers). +- `robinhood-black.svg` / `robinhood-white.svg`: official Chain docs [black feather](https://cdn.robinhood.com/assets/generated_assets/hoodchain_docsite/feather-dark.svg) / [white feather](https://cdn.robinhood.com/assets/generated_assets/hoodchain_docsite/feather-light.svg). [Usage guidance](https://docs.robinhood.com/chain/brand-guidelines/). Use the black mark on light surfaces, white on dark, and keep its height at least 20px. +- `ethereum.svg`: [Ethereum's purple diamond](https://ethereum.org/images/assets/svgs/eth-diamond-purple.svg), linked from the [official asset library](https://ethereum.org/assets). +- `arc.svg`: [official Arc icon](https://cdn.prod.website-files.com/685311a976e7c248b5dfde95/699e21e934a48439675361dc_arc-icon.svg), linked by [Arc](https://www.arc.io/). The white mark uses a dark backing; no recolouring. +- `usdc.svg`: `Token Logo/USDC Token.svg` from [Circle's official USDC brand archive](https://6778953.fs1.hubspotusercontent-na1.net/hubfs/6778953/Pressroom/brandkit/logo-downloads/usdc.zip), linked by [Circle's pressroom](https://www.circle.com/pressroom). + +These SVGs are served locally; no third-party image requests or CSS colour filters are needed. Do not replace official marks with letter badges or redraw their geometry. diff --git a/app/public/brand/arc.svg b/app/public/brand/arc.svg new file mode 100644 index 0000000..6612c09 --- /dev/null +++ b/app/public/brand/arc.svg @@ -0,0 +1,7 @@ + + + + diff --git a/app/public/brand/base.svg b/app/public/brand/base.svg new file mode 100644 index 0000000..2730276 --- /dev/null +++ b/app/public/brand/base.svg @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/app/public/brand/ethereum.svg b/app/public/brand/ethereum.svg new file mode 100644 index 0000000..fcdd583 --- /dev/null +++ b/app/public/brand/ethereum.svg @@ -0,0 +1,2 @@ + + diff --git a/app/public/brand/robinhood-black.svg b/app/public/brand/robinhood-black.svg new file mode 100644 index 0000000..01dcc0e --- /dev/null +++ b/app/public/brand/robinhood-black.svg @@ -0,0 +1,4 @@ + + + + diff --git a/app/public/brand/robinhood-white.svg b/app/public/brand/robinhood-white.svg new file mode 100644 index 0000000..e8a576a --- /dev/null +++ b/app/public/brand/robinhood-white.svg @@ -0,0 +1,4 @@ + + + + diff --git a/app/public/brand/usdc.svg b/app/public/brand/usdc.svg new file mode 100644 index 0000000..3cc54a9 --- /dev/null +++ b/app/public/brand/usdc.svg @@ -0,0 +1,11 @@ + + + + + + + diff --git a/app/src/app/api/bridge/quote/route.ts b/app/src/app/api/bridge/quote/route.ts new file mode 100644 index 0000000..a004575 --- /dev/null +++ b/app/src/app/api/bridge/quote/route.ts @@ -0,0 +1,18 @@ +import { rateLimited } from "@/lib/launchpad/editServer"; +import { BRIDGE_PRIVATE_HEADERS, bridgeErrorResponse, getBridgeQuote, readBridgeJson } from "@/lib/bridge/relay"; +import { BridgeApiError, parseBridgeRequest } from "@/lib/bridge/validation"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +export async function POST(req: Request) { + const ip = (req.headers.get("fly-client-ip") || req.headers.get("x-forwarded-for") || "").split(",")[0].trim() || "0.0.0.0"; + if (rateLimited(`bridge:quote:ip:${ip}`, 20)) return bridgeErrorResponse(new BridgeApiError("Too many quotes. Please wait a moment.", 429)); + try { + if (req.headers.get("content-type")?.split(";", 1)[0].trim().toLowerCase() !== "application/json") throw new BridgeApiError("Send a JSON request.", 415); + const length = req.headers.get("content-length"); + if (length !== null && (!/^\d+$/.test(length) || Number(length) > 2048)) throw new BridgeApiError("The request is too large.", 413); + const input = parseBridgeRequest(await readBridgeJson(req.body, 2048)); + return Response.json(await getBridgeQuote(input), { headers: BRIDGE_PRIVATE_HEADERS }); + } catch (error) { return bridgeErrorResponse(error); } +} diff --git a/app/src/app/api/bridge/status/route.ts b/app/src/app/api/bridge/status/route.ts new file mode 100644 index 0000000..118caaa --- /dev/null +++ b/app/src/app/api/bridge/status/route.ts @@ -0,0 +1,16 @@ +import { rateLimited } from "@/lib/launchpad/editServer"; +import { BRIDGE_PRIVATE_HEADERS, bridgeErrorResponse, getBridgeStatus } from "@/lib/bridge/relay"; +import { BridgeApiError } from "@/lib/bridge/validation"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +export async function GET(req: Request) { + const ip = (req.headers.get("fly-client-ip") || req.headers.get("x-forwarded-for") || "").split(",")[0].trim() || "0.0.0.0"; + if (rateLimited(`bridge:status:ip:${ip}`, 100)) return bridgeErrorResponse(new BridgeApiError("Too many status requests. Please wait a moment.", 429)); + try { + const params = new URL(req.url).searchParams; + if (params.size !== 1 || !params.has("requestId")) throw new BridgeApiError("Invalid bridge request ID.", 400); + return Response.json(await getBridgeStatus(params.get("requestId")!), { headers: BRIDGE_PRIVATE_HEADERS }); + } catch (error) { return bridgeErrorResponse(error); } +} diff --git a/app/src/app/ui-review-bridge/BridgeReview.tsx b/app/src/app/ui-review-bridge/BridgeReview.tsx new file mode 100644 index 0000000..f23eba9 --- /dev/null +++ b/app/src/app/ui-review-bridge/BridgeReview.tsx @@ -0,0 +1,78 @@ +"use client"; + +import { useState } from "react"; +import { Dialog } from "@base-ui/react/dialog"; +import { X } from "lucide-react"; +import { BridgeForm, Transfer } from "@/components/bridge/BridgeDialog"; +import type useBridge from "@/components/bridge/useBridge"; +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 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"]; + +export default function BridgeReview() { + const [open, setOpen] = useState(false); + const [scene, setScene] = useState("disconnected"); + const [route, setRoute] = useState({ originChainId: 8453, destinationChainId: 5042, originAsset: "USDC", destinationAsset: "USDC", amount: "25" }); + const [approved, setApproved] = useState(false); + const { originChainId: origin, destinationChainId: destination, amount } = route; + const originAsset = route.originAsset ?? defaultBridgeAsset(origin); + const destinationAsset = route.destinationAsset ?? defaultBridgeAsset(destination); + const inputCurrency = bridgeCurrency(origin, originAsset, "input"); + const outputCurrency = bridgeCurrency(destination, destinationAsset, "output"); + const erc20Input = inputCurrency.address !== zeroAddress; + const changeRoute = (change: BridgeRouteChange) => { setRoute((current) => changeBridgeRoute(current, change)); setApproved(false); setScene((current) => current === "disconnected" ? current : "idle"); }; + const setAmount = (value: string) => { setRoute((current) => ({ ...current, amount: value })); setApproved(false); setScene((current) => current === "disconnected" ? current : "idle"); }; + const [clock] = useState(() => Date.now()); + // Synthetic fixed conversion, not a market quote. This page never calls Relay. + const inputAmount = parseBridgeAmount(amount, inputCurrency.decimals) ?? 0n; + const normalizedInput = inputAmount * 10n ** BigInt(18 - inputCurrency.decimals); + const netAmount = normalizedInput * 9975n / 10000n; + const converted = originAsset === destinationAsset ? netAmount : originAsset === "USDC" ? netAmount / 2400n : netAmount * 2400n; + const outputAmount = converted / 10n ** BigInt(18 - outputCurrency.decimals); + const quote: BridgeQuote = { + address: wallet, originChainId: origin, destinationChainId: destination, originAsset, destinationAsset, amount: inputAmount.toString(), + requestId, amountOut: outputAmount.toString(), minimumAmountOut: (outputAmount * 995n / 1000n).toString(), relayFee: formatUnits(inputAmount * 25n / 10000n, inputCurrency.decimals), sourceGas: origin === 5042 ? "0.001" : "0.0000007", totalImpactPercent: "-0.25", timeEstimate: 2, expiresAt: clock + 45_000, + 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 tracking = ["pending", "success", "uncertain", "refund"].includes(scene); + const bridge: ReturnType = { + address: scene === "disconnected" ? undefined : wallet, walletChainId: origin, + originChainId: origin, destinationChainId: destination, setOriginChainId: (chainId) => changeRoute({ side: "origin", chainId }), setDestinationChainId: (chainId) => changeRoute({ side: "destination", chainId }), reverseRoute: () => changeRoute({ side: "reverse" }), amount, setAmount, + originAsset, destinationAsset, setOriginAsset: (asset) => changeRoute({ side: "origin-asset", asset }), setDestinationAsset: (asset) => changeRoute({ side: "destination-asset", asset }), + balance: parseUnits(originAsset === "USDC" ? "125" : "0.05", inputCurrency.decimals), nativeBalance: parseEther(origin === 5042 ? "125" : "0.05"), balanceLoading: false, balanceError: null, + quote: scene === "quote" || scene === "expired" ? quote : null, + phase: tracking ? scene as "pending" | "success" | "uncertain" | "refund" : scene === "quote" || scene === "expired" ? "review" : "idle", + error: scene === "error" ? "Relay is temporarily unavailable. Try requesting a quote again." : null, quoteError: null, + quoteExpired: scene === "expired", requestQuote: async () => setScene("quote"), confirm: async () => setScene("pending"), reset: () => setScene("quote"), + 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, + approve: async () => setScene("approval_pending"), retryApproval: () => { setApproved(true); setScene("approval_confirmed"); }, + recoverApproval: async () => { setApproved(true); setScene("approval_confirmed"); }, + }; + return ( +
+

Bridge visual review

+

Development-only synthetic data. No wallet requests, API calls, or funds.

+

Base offers ETH and USDC. Arc uses USDC. Robinhood offers ETH; its USDC routes currently fail our safety checks. USDC sends use an exact-amount approval.

+
{scenes.map((value) => )}
+ + +
Bridge
+ Preview only · {scene} · no funds move + {tracking ? : setScene("quote")} />} +
Powered by Relay0 Openlaunch fee
+
+
+
+ ); +} diff --git a/app/src/app/ui-review-bridge/page.tsx b/app/src/app/ui-review-bridge/page.tsx new file mode 100644 index 0000000..631c9a8 --- /dev/null +++ b/app/src/app/ui-review-bridge/page.tsx @@ -0,0 +1,10 @@ +import { notFound } from "next/navigation"; +import BridgeReview from "./BridgeReview"; + +export const dynamic = "force-dynamic"; + +/** Local visual fixture. Never exposes a mock wallet or transfer on production. */ +export default function BridgeReviewPage() { + if (process.env.NODE_ENV !== "development") notFound(); + return ; +} diff --git a/app/src/components/HeaderNav.tsx b/app/src/components/HeaderNav.tsx index 8c6cb8a..6b07a44 100644 --- a/app/src/components/HeaderNav.tsx +++ b/app/src/components/HeaderNav.tsx @@ -13,6 +13,7 @@ import ConnectButton from "./ConnectButton"; import ThemeToggle from "./ThemeToggle"; import NotificationSettings from "./NotificationSettings"; import LivePulse from "./launchpad/LivePulse"; +import { BridgeButton, BridgeProvider } from "./bridge/BridgeProvider"; const NAV = [ { href: "/", label: "Launchpad" }, @@ -38,12 +39,12 @@ export default function HeaderNav({ pulse }: { pulse: Pulse }) { const heroCtaOnScreen = useHeroCtaOnScreen(pathname); return ( - + - + ); } @@ -99,6 +100,7 @@ function Desktop({ visible = false, pulse, isActive, quietCta }: { visible?: boo
+ @@ -261,6 +263,7 @@ function Mobile({ visible = false, pulse, isActive }: { visible?: boolean; pulse
+ setOpen(false)} /> setOpen(false)} /> diff --git a/app/src/components/WalletMenu.tsx b/app/src/components/WalletMenu.tsx index b8eccd7..dc95e90 100644 --- a/app/src/components/WalletMenu.tsx +++ b/app/src/components/WalletMenu.tsx @@ -5,6 +5,7 @@ import { useRef, useState } from "react"; import { Popover } from "@base-ui/react/popover"; import { ArrowDownUp, ArrowRight, ArrowUpRight, Check, ChevronDown, Copy, LayoutDashboard, LogOut, X } from "lucide-react"; import { CHAIN_KEYS, CHAIN_LABELS, CHAIN_SHORT, chainKeyOf, explorerAddress, explorerName, shortAddr, type ChainKey } from "@/lib/chainPublic"; +import { BRIDGE_CHAINS, isBridgeChainId } from "@/lib/bridge/types"; import WalletAvatar from "./WalletAvatar"; import styles from "./WalletMenu.module.css"; @@ -34,8 +35,10 @@ function AccountMenu({ address, chainId, connectorName, block = false, switching const actionLock = useRef(false); const popupRef = useRef(null); const key = chainKeyOf(chainId); + const bridgeNetwork = isBridgeChainId(chainId) ? BRIDGE_CHAINS[chainId] : null; const busy = switching || disconnecting || localBusy !== null; - const network = key ? CHAIN_SHORT[key] : "Unsupported network"; + const network = key ? CHAIN_SHORT[key] : bridgeNetwork?.name ?? "Unsupported network"; + const explorer = key ? explorerAddress(key, address) : bridgeNetwork ? `${bridgeNetwork.explorer}/address/${address}` : null; async function copyAddress() { try { @@ -74,11 +77,11 @@ function AccountMenu({ address, chainId, connectorName, block = false, switching {shortAddr(address)} - {key ? CHAIN_SHORT[key] : "Switch"} + {key || bridgeNetwork ? network : "Switch"} @@ -112,15 +115,15 @@ function AccountMenu({ address, chainId, connectorName, block = false, switching {copied ? : } {copied ? "Copied" : "Copy address"} - {key ? ( - + {explorer ? ( + Explorer ) : null}
Network
- {!key ?

This network isn’t supported. Choose one below.

: null} + {!key && !bridgeNetwork ?

This network isn’t supported. Choose one below.

: null}
{CHAIN_KEYS.map((chain) => ( ))}
-

{switching || (localBusy && localBusy !== "disconnect") ? "Confirm the network in your wallet…" : key ? `Connected to ${CHAIN_LABELS[key]}` : "A network switch needs wallet approval."}

+

{switching || (localBusy && localBusy !== "disconnect") ? "Confirm the network in your wallet…" : key ? `Connected to ${CHAIN_LABELS[key]}` : bridgeNetwork ? `Connected to ${network}. Use Bridge to move funds, or choose a launch network above.` : "A network switch needs wallet approval."}

{ setOpen(false); onNavigate?.(); }}> diff --git a/app/src/components/bridge/BridgeDialog.module.css b/app/src/components/bridge/BridgeDialog.module.css new file mode 100644 index 0000000..d7eb92b --- /dev/null +++ b/app/src/components/bridge/BridgeDialog.module.css @@ -0,0 +1,135 @@ +.trigger { display: inline-flex; align-items: center; justify-content: center; gap: 7px; min-height: 36px; padding: 0 11px; border: 1px solid var(--color-line); border-radius: 999px; color: var(--color-body); background: var(--color-card); font-size: 13px; font-weight: 500; cursor: pointer; transition: background 160ms, color 160ms, transform 160ms; } +.blockTrigger { width: 100%; justify-content: flex-start; min-height: 48px; padding: 0 12px; border: 0; border-radius: 12px; color: var(--color-ink); font-size: 16px; } +.triggerHint { margin-left: auto; font-size: 12px; color: var(--color-muted); } +.backdrop { position: fixed; inset: 0; z-index: 80; background: color-mix(in srgb, var(--color-scrim) 45%, transparent); opacity: 1; transition: opacity 180ms ease-out; } +.backdrop[data-starting-style], .backdrop[data-ending-style] { opacity: 0; } +.panel { position: fixed; z-index: 81; top: 50%; left: 50%; transform: translate(-50%, -50%); width: min(480px, calc(100vw - 32px)); max-height: calc(100dvh - 32px); overflow-y: auto; overscroll-behavior: contain; padding: 26px 28px 20px; border: 1px solid var(--color-line); border-radius: 24px; background: var(--color-card); color: var(--color-ink); box-shadow: var(--shadow-dialog); outline: none; scrollbar-width: thin; scrollbar-color: var(--color-line-strong) transparent; transition: opacity 180ms ease-out, transform 220ms cubic-bezier(.16, 1, .3, 1); } +.panel[data-starting-style], .panel[data-ending-style] { opacity: 0; transform: translate(-50%, calc(-50% + 8px)) scale(.98); } +.panel ::selection { background: var(--color-brand-soft); color: var(--color-brand); } +.heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; } +.title { margin: 0; font-size: 24px; line-height: 1.25; letter-spacing: -.035em; font-weight: 650; } +.close { display: inline-flex; align-items: center; justify-content: center; width: 36px; height: 36px; margin: -4px -8px -4px 0; border-radius: 50%; color: var(--color-muted); cursor: pointer; } +.description { margin: 6px 0 26px; color: var(--color-muted); font-size: 14px; line-height: 1.5; } +.route { display: grid; grid-template-columns: minmax(0, 1fr) 36px minmax(0, 1fr); align-items: center; gap: 8px; padding-bottom: 20px; border-bottom: 1px solid var(--color-line); } +.network { min-width: 0; } +.smallLabel { display: block; font-size: 12px; font-weight: 500; color: var(--color-muted); line-height: 1.5; } +.networkTrigger { display: grid; grid-template-columns: 30px minmax(0, 1fr) 13px; align-items: center; gap: 8px; width: calc(100% + 16px); min-height: 58px; margin: 5px -8px 0; padding: 8px 7px; border: 1px solid transparent; border-radius: 12px; color: var(--color-ink); text-align: left; cursor: pointer; transition: background 160ms ease, border-color 160ms ease, transform 120ms ease-out; } +.networkTrigger[data-popup-open] { background: var(--color-paper); border-color: var(--color-line-strong); } +.networkTrigger[data-disabled] { cursor: not-allowed; opacity: .55; } +.networkCopy { display: grid; min-width: 0; gap: 3px; } +.networkName { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 14px; line-height: 1.4; font-weight: 600; } +.networkChevron { display: inline-flex; color: var(--color-muted); } +.networkTrigger[data-popup-open] .networkChevron { transform: rotate(180deg); } +.gasCurrency { display: block; color: var(--color-muted); font-size: 11px; font-weight: 400; line-height: 1.5; } +.networkPositioner { z-index: 90; } +.networkPopup { width: min(268px, calc(100vw - 32px)); max-width: var(--available-width); max-height: var(--available-height); overflow-y: auto; overscroll-behavior: contain; padding: 6px; border: 1px solid var(--color-line-strong); border-radius: 16px; background: var(--color-card); color: var(--color-ink); box-shadow: var(--shadow-card-hover); outline: none; transform-origin: var(--transform-origin); transition: opacity 150ms ease-out, transform 150ms cubic-bezier(.16, 1, .3, 1); scrollbar-width: thin; scrollbar-color: var(--color-line-strong) transparent; } +.networkPopup[data-starting-style], .networkPopup[data-ending-style] { opacity: 0; transform: translateY(-4px) scale(.98); } +.networkPopup[data-instant] { transition: none; } +.networkPopup ::selection { background: var(--color-brand-soft); color: var(--color-brand); } +.networkMenuTitle { margin: 0; padding: 8px 10px 10px; color: var(--color-body); font-size: 12px; font-weight: 500; } +.networkList { display: grid; gap: 3px; } +.networkOption { display: grid; grid-template-columns: 30px minmax(0, 1fr) 20px; align-items: center; gap: 12px; min-height: 64px; padding: 10px; border-radius: 10px; cursor: pointer; outline: none; user-select: none; } +.networkOption[data-highlighted] { background: var(--color-paper); box-shadow: inset 0 0 0 1px var(--color-line-strong); } +.networkOption[data-selected] { background: var(--color-brand-soft); } +.networkOption[data-selected] .gasCurrency { color: var(--color-body); } +.networkPopup[data-instant] .networkOption[data-highlighted] { outline: 2px solid var(--color-brand); outline-offset: -2px; } +.networkCheck { display: flex; justify-content: center; grid-column: 3; color: var(--color-brand); } +.conversionNote { display: flex; align-items: center; gap: 6px; margin: 14px 0 0; font-size: 12px; color: var(--color-body); } +.conversionNote span { margin-left: auto; color: var(--color-muted); font-size: 11px; } +.networkMark { display: inline-flex; flex: 0 0 30px; align-items: center; justify-content: center; height: 30px; width: 30px; } +.networkMark img { display: block; flex-shrink: 0; object-fit: contain; } +.arcMark { background: var(--color-scrim); border-radius: 7px; } +.networkMark .darkLogo { display: none; } +:global(.dark) .networkMark .lightLogo { display: none; } +:global(.dark) .networkMark .darkLogo { display: block; } +.reverse { display: inline-flex; align-items: center; justify-content: center; height: 36px; width: 36px; margin-top: 4px; color: var(--color-muted); border: 1px solid var(--color-line); border-radius: 50%; cursor: pointer; transition: transform 180ms cubic-bezier(.16, 1, .3, 1), background 160ms, color 160ms; } +.amountSection { padding: 23px 0 20px; } +.amountRow { display: flex; align-items: center; gap: 16px; margin-top: 4px; } +.amountRow input { display: block; min-width: 0; width: 100%; border: 0; border-radius: 4px; background: transparent; color: var(--color-ink); caret-color: var(--color-brand); font-size: 38px; line-height: 1.35; letter-spacing: -.035em; font-variant-numeric: tabular-nums; font-weight: 500; } +.amountRow input::placeholder { color: var(--color-muted); } +.currency { display: inline-flex; flex-shrink: 0; align-items: center; gap: 5px; font-size: 18px; font-weight: 550; } +.assetTrigger { display: inline-flex; flex-shrink: 0; align-items: center; justify-content: center; gap: 7px; min-height: 44px; padding: 6px 9px; border: 1px solid var(--color-line-strong); border-radius: 12px; background: var(--color-paper); color: var(--color-ink); font-size: 16px; font-weight: 550; cursor: pointer; transition: background 160ms ease, border-color 160ms ease, transform 120ms ease-out; } +.assetTrigger[data-popup-open] { border-color: var(--color-brand); } +.assetTrigger[data-popup-open] .networkChevron { transform: rotate(180deg); } +.assetTrigger:not(:disabled):active:not(:focus-visible) { transform: scale(.98); } +.assetTrigger:focus-visible { transition: none; } +.destinationAsset { display: flex; justify-content: space-between; align-items: center; gap: 12px; min-height: 64px; padding-block: 10px; border-top: 1px solid var(--color-line); } +.destinationAsset .currency { font-size: 16px; } +.routeNotice { margin-top: 12px; color: var(--color-muted); font-size: 11px; line-height: 1.6; } +.balance { margin-top: 8px; color: var(--color-muted); font-size: 12px; font-variant-numeric: tabular-nums; } +.previewNote { display: flex; align-items: flex-start; gap: 10px; padding: 17px 0; border-top: 1px solid var(--color-line); color: var(--color-body); font-size: 12px; line-height: 1.65; } +.previewNote svg { flex-shrink: 0; margin-top: 2px; color: var(--color-brand); } +.previewNote span { color: var(--color-muted); } +.quote { padding: 18px 0 0; border-top: 1px solid var(--color-line); } +.receiveLabel { display: flex; justify-content: space-between; align-items: baseline; gap: 10px; font-size: 12px; color: var(--color-body); } +.estimate { font-size: 11px; color: var(--color-muted); } +.receiveAmount { margin: 6px 0 18px; font-size: 27px; font-weight: 600; line-height: 1.3; letter-spacing: -.025em; font-variant-numeric: tabular-nums; } +.receiveAmount span { display: inline-flex; align-items: center; gap: 4px; margin-left: 6px; vertical-align: middle; font-size: 16px; color: var(--color-muted); font-weight: 500; } +.fees { display: grid; gap: 9px; font-size: 12px; } +.fees > div, .transferDetails > div { display: flex; align-items: baseline; justify-content: space-between; gap: 16px; } +.fees dt, .transferDetails dt { color: var(--color-body); } +.fees dt span { color: var(--color-muted); font-size: 11px; } +.fees dd { display: inline-flex; align-items: center; gap: 4px; text-align: right; font-variant-numeric: tabular-nums; } +.quoteNotice { margin-top: 14px; color: var(--color-muted); font-size: 11px; line-height: 1.6; } +.approvalNotice { margin: 14px 0; padding-left: 12px; border-left: 2px solid var(--color-brand); color: var(--color-body); font-size: 12px; line-height: 1.65; } +.approvalRecovery { margin: 16px 0; padding-top: 16px; border-top: 1px solid var(--color-line); font-size: 12px; color: var(--color-body); } +.approvalRecovery summary { cursor: pointer; padding: 6px 0; } +.approvalRecovery p { margin: 8px 0 14px; color: var(--color-muted); line-height: 1.65; } +.approvalRecovery input { margin-top: 6px; width: 100%; min-width: 0; padding: 10px; border: 1px solid var(--color-line-strong); border-radius: 8px; color: var(--color-ink); background: var(--color-paper); font-family: var(--font-mono); font-size: 11px; } +.recipient { margin-top: 16px; border-top: 1px solid var(--color-line); font-size: 12px; color: var(--color-muted); } +.recipient summary { display: flex; align-items: center; gap: 8px; min-height: 56px; list-style: none; cursor: pointer; } +.recipient summary::-webkit-details-marker { display: none; } +.recipient summary > span:nth-last-child(2) { margin-left: auto; color: var(--color-ink); font-variant-numeric: tabular-nums; } +.recipient[open] summary > svg { transform: rotate(180deg); } +.fullAddress { display: block; padding: 0 0 16px; color: var(--color-ink); font-family: var(--font-mono); font-size: 11px; line-height: 1.7; overflow-wrap: anywhere; user-select: all; } +.transfer .recipient { margin: 0; border-top: 0; border-bottom: 1px solid var(--color-line); } +.primary { display: flex; align-items: center; justify-content: center; gap: 10px; min-height: 48px; width: 100%; padding: 12px 16px; border: 1px solid transparent; border-radius: 12px; background: var(--color-brand); color: var(--color-inverse); font-size: 14px; font-weight: 600; cursor: pointer; transition: background 160ms, transform 160ms cubic-bezier(.16, 1, .3, 1); } +.panel button:disabled { opacity: .55; cursor: not-allowed; } +.primary:disabled { opacity: 1; background: var(--color-paper); border-color: var(--color-line); color: var(--color-muted); } +.disclaimer { margin-top: 12px; color: var(--color-muted); font-size: 11px; line-height: 1.65; } +.footer { display: flex; justify-content: space-between; gap: 12px; padding-top: 16px; margin-top: 20px; border-top: 1px solid var(--color-line); font-size: 11px; color: var(--color-muted); } +.footer a, .explorerLinks a { display: inline-flex; align-items: center; gap: 3px; text-underline-offset: 3px; } +.error { display: flex; align-items: flex-start; gap: 8px; margin: 12px 0; padding: 12px; color: var(--color-down-ink); background: var(--color-down-soft); border-radius: 8px; font-size: 12px; line-height: 1.6; } +.error > svg { flex-shrink: 0; margin-top: 2px; } +.inlineButton { display: block; margin-top: 6px; text-decoration: underline; text-underline-offset: 3px; cursor: pointer; } +.transferRoute { display: flex; justify-content: center; align-items: center; flex-wrap: wrap; gap: 9px; padding: 16px 0; border-block: 1px solid var(--color-line); font-size: 13px; font-weight: 500; } +.transferRoute > svg { margin-inline: 7px; color: var(--color-muted); } +.statusHeading { margin: 24px 0; text-align: center; } +.statusIcon { display: inline-flex; align-items: center; justify-content: center; height: 52px; width: 52px; border-radius: 50%; color: var(--color-brand); background: var(--color-brand-soft); } +.success { background: var(--color-up-soft); color: var(--color-up); } +.statusHeading h3 { font-size: 20px; font-weight: 600; letter-spacing: -.025em; margin: 14px 0 8px; } +.statusHeading p { color: var(--color-body); font-size: 13px; line-height: 1.65; text-wrap: pretty; } +.transferDetails { display: grid; gap: 9px; font-size: 12px; padding-bottom: 20px; border-bottom: 1px solid var(--color-line); font-variant-numeric: tabular-nums; } +.transferAmount { display: inline-flex; align-items: center; gap: 6px; } +.steps { margin: 22px 0; display: grid; gap: 22px; list-style: none; padding: 0; } +.steps li { display: flex; gap: 12px; align-items: flex-start; position: relative; } +.steps li:not(:last-child)::after { content: ""; position: absolute; top: 28px; bottom: -17px; left: 12px; width: 1px; background: var(--color-line-strong); } +.steps li > span { display: inline-flex; flex-shrink: 0; align-items: center; justify-content: center; height: 25px; width: 25px; border: 1px solid var(--color-line-strong); border-radius: 50%; font-size: 11px; color: var(--color-muted); } +.steps li[data-complete="true"] > span { background: var(--color-up-soft); border-color: transparent; color: var(--color-up); } +.steps strong { display: block; font-size: 13px; font-weight: 500; } +.steps small { display: block; margin-top: 4px; font-size: 11px; color: var(--color-muted); line-height: 1.5; } +.externalAction { display: flex; align-items: center; justify-content: space-between; min-height: 46px; padding: 0 14px; border: 1px solid var(--color-line-strong); border-radius: 10px; font-size: 13px; font-weight: 500; transition: background 160ms; } +.explorerLinks { display: flex; gap: 14px; flex-wrap: wrap; margin-top: 12px; font-size: 11px; color: var(--color-body); } +.newTransfer { display: flex; align-items: center; justify-content: center; gap: 6px; min-height: 44px; width: 100%; margin-top: 12px; color: var(--color-brand); font-size: 13px; cursor: pointer; } +.requestId { display: block; overflow-wrap: anywhere; font-family: var(--font-mono); font-size: 10px; user-select: all; } +.spinner { animation: bridge-spin 1s linear infinite; } +@keyframes bridge-spin { to { transform: rotate(360deg); } } +.trigger:focus-visible, .panel :is(button, a, input, select, summary):focus-visible { outline: 2px solid var(--color-brand); outline-offset: 4px; } +@media (hover: hover) and (pointer: fine) { + .networkTrigger:not(:disabled):hover { background: var(--color-paper); border-color: var(--color-line); } + .assetTrigger:not(:disabled):hover { border-color: var(--color-brand); } + .trigger:hover, .close:hover, .reverse:hover, .externalAction:hover { color: var(--color-ink); background: var(--color-paper); } + .primary:not(:disabled):hover { background: var(--color-brand-strong); } + .footer a:hover, .explorerLinks a:hover { text-decoration: underline; } +} +.primary:not(:disabled):active, .reverse:not(:disabled):active, .trigger:active { transform: scale(.97); } +.networkTrigger:not(:disabled):active:not(:focus-visible) { transform: scale(.98); } +.networkTrigger:focus-visible { transition: none; } +@media (min-width: 1024px) and (max-width: 1279px) { .trigger:not(.blockTrigger) { width: 36px; padding: 0; } .trigger:not(.blockTrigger) .triggerLabel { display: none; } } +@media (max-width: 520px) { + .panel { width: 100%; top: auto; bottom: 0; left: 0; max-height: calc(100dvh - 16px); transform: none; padding: 24px 22px calc(20px + env(safe-area-inset-bottom)); border-radius: 22px 22px 0 0; border-bottom: 0; box-shadow: var(--shadow-sheet); } + .panel[data-starting-style], .panel[data-ending-style] { transform: translateY(16px); } + .amountRow input { font-size: 34px; } +} +@media (max-width: 360px) { .networkTrigger { grid-template-columns: 24px minmax(0, 1fr) 13px; gap: 5px; padding-inline: 4px; } .networkTrigger .networkMark { width: 24px; height: 24px; } .networkTrigger .networkMark img { max-width: 24px; max-height: 24px; } .networkTrigger .networkName { font-size: 13px; } } +@media (prefers-reduced-motion: reduce) { .panel, .backdrop, .primary, .trigger, .reverse, .networkTrigger, .networkPopup, .assetTrigger { transition: none; } .spinner { animation: none; } } diff --git a/app/src/components/bridge/BridgeDialog.tsx b/app/src/components/bridge/BridgeDialog.tsx new file mode 100644 index 0000000..7a74473 --- /dev/null +++ b/app/src/components/bridge/BridgeDialog.tsx @@ -0,0 +1,295 @@ +"use client"; + +import { useRef, useState } from "react"; +import Image from "next/image"; +import { Dialog } from "@base-ui/react/dialog"; +import { Select } from "@base-ui/react/select"; +import { ArrowLeftRight, ArrowRight, ArrowUpRight, Check, ChevronDown, ChevronRight, CircleAlert, Clock3, LoaderCircle, Wallet, X } from "lucide-react"; +import { formatEther, formatUnits, zeroAddress } from "viem"; +import { BRIDGE_ASSETS, BRIDGE_CHAINS, BRIDGE_CHAIN_IDS, bridgeCurrency, bridgeTransferInputCurrency, isBridgeAssetSupported, isBridgeChainId, type BridgeAsset, type BridgeChainId } from "@/lib/bridge/types"; +import { shortAddr } from "@/lib/chainPublic"; +import WalletPicker from "../WalletPicker"; +import WalletAvatar from "../WalletAvatar"; +import useBridge from "./useBridge"; +import styles from "./BridgeDialog.module.css"; + +type Bridge = ReturnType; +const networkItems = BRIDGE_CHAIN_IDS.map((id) => ({ value: id, label: BRIDGE_CHAINS[id].name })); + +function nativeAmount(value: string | bigint): string { + const text = typeof value === "bigint" ? formatEther(value) : value; + const [whole, decimal = ""] = text.split("."); + const cut = decimal.slice(0, 7).replace(/0+$/, ""); + return whole === "0" && !cut && /[1-9]/.test(decimal) ? "<0.0000001" : `${whole}${cut ? `.${cut}` : ""}`; +} + +function NetworkMark({ chain }: { chain: BridgeChainId }) { + return {chain === 8453 + ? + : chain === 5042 ? + : <> + + + }; +} + +function AssetMark({ asset, size = 24 }: { asset: BridgeAsset; size?: number }) { + return ; +} + +function AssetSelect({ label, chain, asset, disabled, onChange }: { label: "Send" | "Receive"; chain: BridgeChainId; asset: BridgeAsset; disabled: boolean; onChange: (asset: BridgeAsset) => void }) { + const [instant, setInstant] = useState(false); + const choices = BRIDGE_ASSETS[chain]; + if (choices.length === 1) return {asset}; + return ({ value, label: value }))} disabled={disabled} + onValueChange={(value) => { if (isBridgeAssetSupported(chain, value)) onChange(value); }} + onOpenChange={(_, details) => setInstant(details.event.type.startsWith("key"))}> + + + + + + +

{label} on {BRIDGE_CHAINS[chain].name}

+ + {choices.map((value) => + + {value}{value === "ETH" ? "Ether · Gas token" : "USD Coin · Gas in ETH"} + + )} + +
+
+
+
; +} + +function NetworkSelect({ label, chain, disabled, onChange }: { label: "From" | "To"; chain: BridgeChainId; disabled: boolean; onChange: (chain: BridgeChainId) => void }) { + const [instant, setInstant] = useState(false); + return
+ { if (isBridgeChainId(value)) onChange(value); }} + onOpenChange={(_, details) => setInstant(details.event.type.startsWith("key"))}> + {label} network + + + + + Gas in {BRIDGE_CHAINS[chain].symbol} + + + + + + +

{label === "From" ? "Send from" : "Receive on"}

+ + {BRIDGE_CHAIN_IDS.map((id) => + + + {BRIDGE_CHAINS[id].name} + {BRIDGE_CHAINS[id].symbol} · Gas token + + + )} + +
+
+
+
+
; +} + +function Recipient({ address }: { address: string }) { + return ( +
+ Receiving wallet{shortAddr(address)} + {address} +
+ ); +} + +export default function BridgeDialog({ open, onOpenChange, restoreFocus }: { + open: boolean; + onOpenChange: (open: boolean) => void; + restoreFocus: () => HTMLElement | null; +}) { + const bridge = useBridge(); + const popup = useRef(null); + const [connecting, setConnecting] = useState(false); + const transferring = bridge.tracked !== null; + + function connect() { + onOpenChange(false); + setConnecting(true); + } + + return ( + <> + + + + { if (event.key === "Escape") event.stopPropagation(); }}> +
+ Bridge + +
+ {transferring ? "Your transfer, from departure to arrival." : "Same wallet. A new network."} + {transferring ? : } +
+ Powered by Relay + 0 Openlaunch fee +
+
+
+
+ {connecting ? { setConnecting(false); onOpenChange(true); }} /> : null} + + ); +} + +export function BridgeForm({ bridge: b, connect }: { bridge: Bridge; connect: () => void }) { + if (b.approval && (b.approval.status === "pending" || b.approval.status === "uncertain")) return ; + const locked = b.busy || b.approvalBusy; + const reviewing = !!b.quote && !b.quoteExpired; + const needsRefresh = !!b.quote && b.quoteExpired; + const origin = BRIDGE_CHAINS[b.originChainId]; + const destination = BRIDGE_CHAINS[b.destinationChainId]; + const inputCurrency = bridgeCurrency(b.originChainId, b.originAsset, "input"); + const outputCurrency = bridgeCurrency(b.destinationChainId, b.destinationAsset, "output"); + const erc20Input = inputCurrency.address !== zeroAddress; + const convertsAsset = inputCurrency.symbol !== outputCurrency.symbol; + const outputAmount = (value: string) => nativeAmount(formatUnits(BigInt(value), outputCurrency.decimals)); + const label = b.approvalBusy ? "Confirm USDC approval…" : b.allowanceLoading ? "Checking USDC permission…" : b.phase === "quoting" ? "Finding your route…" + : b.phase === "switching" ? "Confirm network switch…" + : b.phase === "confirming" ? "Confirm in your wallet…" + : needsRefresh ? "Refresh quote" + : reviewing && b.approvalRequired ? `Approve ${nativeAmount(b.amount)} USDC` + : reviewing ? `Bridge to ${destination.name}` : "Review bridge"; + + return ( +
{ event.preventDefault(); if (b.address) void (reviewing ? b.approvalRequired ? b.approve() : b.confirm() : b.requestQuote()); else connect(); }}> +
+ + + +
+ {convertsAsset ?

{inputCurrency.symbol} {outputCurrency.symbol}Converted by Relay

: null} + {b.originChainId === 4663 || b.destinationChainId === 4663 ?

USDC on Robinhood is unavailable: its routes do not pass our current safety checks. ETH remains available.

: null} + {erc20Input && b.approval?.chainId === b.originChainId && b.approval.status === "confirmed" && !b.quote ?

USDC approval confirmed. Review a fresh quote before bridging. No funds have been bridged yet.

: null} + +
+ +
+ b.setAmount(event.target.value)} disabled={locked} aria-describedby="bridge-balance bridge-gas-note" /> + +
+

{!b.address ? `${inputCurrency.symbol} on ${origin.name}` : b.balanceLoading ? "Checking your balance…" : b.balance !== undefined ? `Available: ${nativeAmount(formatUnits(b.balance, inputCurrency.decimals))} ${inputCurrency.symbol}` : b.balanceError ?? "Balance unavailable"}

+ {b.address && erc20Input && b.originChainId !== 5042 && b.nativeBalance !== undefined ?

For gas: {nativeAmount(b.nativeBalance)} {origin.symbol}

: null} +
+ +
Receive on {destination.name}
+ + {b.quote ? ( +
+
Estimated output
+

{outputAmount(b.quote.amountOut)}{outputCurrency.symbol}

+
+
Minimum received
{outputAmount(b.quote.minimumAmountOut)} {outputCurrency.symbol}
+
Relay fee (included)
{nativeAmount(b.quote.relayFee)} {inputCurrency.symbol}
+
Source gas (extra, estimated)
{nativeAmount(b.quote.sourceGas)} {origin.symbol}
+ {convertsAsset ?
Value change (conversion + fees)
{Number(b.quote.totalImpactPercent).toFixed(2)}%
: null} +
Estimated arrival
{Math.max(1, Math.ceil(b.quote.timeEstimate))} seconds
+
+

{needsRefresh ? "This quote expired. Refresh and review the new amounts." : "0.5% slippage limit. Arrival time and gas can change."}

+ {b.approvalRequired ?

First, approve only {nativeAmount(b.amount)} USDC for Relay’s deposit contract. Then review a fresh quote and confirm the bridge separately. Approval alone does not move your funds and uses additional {origin.symbol} for gas.

: null} +
+ ) :

Get a live quote before you commit.
Fees and the minimum received shown upfront.

} + + {b.address ? : null} + {b.quoteError || b.error || b.storageError || b.approvalError ?

{b.quoteError || b.error || b.storageError || b.approvalError}

: null} + +

Keep some {origin.symbol} on {origin.name} for gas. {convertsAsset ? "Relay converts the asset at the quoted rate. " : ""}Bridging uses a third-party protocol and carries risk. {erc20Input ? "An unspent USDC approval remains until used or revoked." : "No token approvals required."}

+ + ); +} + +function ApprovalProgress({ bridge: b }: { bridge: Bridge }) { + const approval = b.approval!; + const approvalChain = BRIDGE_CHAINS[approval.chainId]; + const uncertain = approval.status === "uncertain"; + 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.`}

+
+
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.

+
{ 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} +

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.

+
; +} + +export function Transfer({ bridge: b }: { bridge: Bridge }) { + const transfer = b.tracked!; + const success = b.phase === "success"; + const refund = b.phase === "refund"; + const failed = b.phase === "failure"; + const reverted = transfer.failureReason === "source-reverted"; + const uncertain = b.phase === "uncertain"; + const waitingForWallet = b.phase === "confirming" || b.phase === "switching"; + const terminal = success || refund || failed; + const origin = BRIDGE_CHAINS[transfer.originChainId]; + const destination = BRIDGE_CHAINS[transfer.destinationChainId]; + const inputCurrency = bridgeTransferInputCurrency(transfer); + const outputCurrency = bridgeCurrency(transfer.destinationChainId, transfer.destinationAsset, "output"); + const title = success ? `Arrived on ${destination.name}` : refund ? "Relay reports a refund" : reverted ? "The deposit reverted" : failed ? "Transfer needs attention" : uncertain ? "Checking your transfer" : waitingForWallet ? "Check your wallet" : "Your transfer is on its way"; + const detail = success ? `Relay has confirmed ${outputCurrency.symbol} delivery to your receiving wallet.` : refund ? "Check your wallets on both networks and Relay’s transfer details before trying again." : reverted ? `The source network rejected the deposit. Your ${inputCurrency.symbol} was not bridged; network gas was still charged.` : failed ? "Relay couldn’t complete this transfer. Check the transfer details for recovery before sending again." : uncertain ? "The wallet response was interrupted. Don’t send again while we check whether your deposit was submitted." : waitingForWallet ? "Review the network, amount and transaction in your wallet. Nothing moves without your confirmation." : "You can close this panel. Reopen Bridge with this wallet to check its status."; + + return ( +
+
{origin.name}{destination.name}
+
+ {success ? : terminal || uncertain ? : } +

{title}

{detail}

+
+
+
Amount sent
{nativeAmount(formatUnits(BigInt(transfer.amount), inputCurrency.decimals))} {inputCurrency.symbol}
+
+ +
    +
  1. {!reverted && (transfer.sourceHash || success || refund) ? : "1"}
    Deposit on {origin.name}{reverted ? "Reverted. Deposit not made." : transfer.sourceHash || success || refund ? "Submitted to the source network" : waitingForWallet ? "Awaiting wallet confirmation" : "Checking for your deposit"}
  2. +
  3. {success ? : "2"}
    {refund ? "Refund reported by Relay" : "Relay processes the transfer"}{transfer.status === "delayed" ? "Taking longer than expected. Tracking continues." : failed ? "Open transfer details for help" : success ? "Transfer complete" : "Live status from the bridge provider"}
  4. +
  5. {success ? : "3"}
    Receive {outputCurrency.symbol} on {destination.name}{success ? "Delivered to the same wallet address" : "Delivery will be confirmed here"}
  6. +
+ {b.error || b.statusError || b.storageError ?

{b.error || b.statusError || b.storageError}{b.statusError ? : null}

: null} + View transfer on Relay +
+ {transfer.sourceHash ? Source transaction : null} + {success && transfer.destinationHashes[0] ? Destination transaction : null} +
+ {b.canReset ? : null} +

Transfer ID {transfer.requestId}

+
+ ); +} diff --git a/app/src/components/bridge/BridgeProvider.tsx b/app/src/components/bridge/BridgeProvider.tsx new file mode 100644 index 0000000..9f110bc --- /dev/null +++ b/app/src/components/bridge/BridgeProvider.tsx @@ -0,0 +1,42 @@ +"use client"; + +import dynamic from "next/dynamic"; +import { createContext, useCallback, useContext, useRef, useState } from "react"; +import { ArrowLeftRight } from "lucide-react"; +import styles from "./BridgeDialog.module.css"; + +const BridgeDialog = dynamic(() => import("./BridgeDialog"), { ssr: false }); +const BridgeContext = createContext<(() => void) | null>(null); + +/** Mounted once for both navigation variants; closing the panel does not stop tracking. */ +export function BridgeProvider({ children }: { children: React.ReactNode }) { + const [activated, setActivated] = useState(false); + const [open, setOpen] = useState(false); + const trigger = useRef(null); + const show = useCallback(() => { + trigger.current = document.activeElement instanceof HTMLElement ? document.activeElement : null; + setActivated(true); + setOpen(true); + }, []); + const restoreFocus = useCallback(() => trigger.current?.isConnected + ? trigger.current + : document.querySelector('[aria-controls="mobile-menu"]'), []); + + return ( + + {children} + {activated ? : null} + + ); +} + +export function BridgeButton({ block = false, onOpen }: { block?: boolean; onOpen?: () => void }) { + const show = useContext(BridgeContext); + return ( + + ); +} diff --git a/app/src/components/bridge/DESIGN.md b/app/src/components/bridge/DESIGN.md new file mode 100644 index 0000000..e871dd7 --- /dev/null +++ b/app/src/components/bridge/DESIGN.md @@ -0,0 +1,171 @@ +--- +name: openlaunch.lol bridge +description: A scoped Clear Sky extension for reviewing ETH/USDC bridges across Base, Robinhood and Arc. +colors: + # Light-default snapshot of inherited tokens; runtime .dark overrides remain authoritative. + paper: "#fafaf8" + card: "#ffffff" + line: "#e7e5e4" + line-strong: "#d6d3d1" + ink: "#0f172a" + body: "#475569" + muted: "#64748b" + inverse: "#ffffff" + brand: "#0052ff" + brand-strong: "#0041cc" + brand-soft: "#eaf0ff" + up: "#15803d" + up-soft: "#ecfdf3" + down-ink: "#b91c1c" + down-soft: "#fef2f2" + scrim: "#0f172a" +typography: + control: + fontFamily: "var(--font-sans)" + fontSize: "13px" + fontWeight: 500 + detail: + fontFamily: "var(--font-sans)" + fontSize: "12px" + caption: + fontFamily: "var(--font-sans)" + fontSize: "11px" + address: + fontFamily: "var(--font-mono)" +rounded: + control: "8px" + block: "12px" + pill: "999px" +spacing: + "2": "8px" + "3": "12px" + "4": "16px" + "5": "20px" + "6": "24px" +components: + bridge-trigger: + backgroundColor: "{colors.card}" + textColor: "{colors.body}" + typography: "{typography.control}" + rounded: "{rounded.pill}" + padding: "0 11px" + bridge-primary: + backgroundColor: "{colors.brand}" + textColor: "{colors.inverse}" + rounded: "{rounded.block}" + padding: "12px 16px" + width: "100%" + bridge-primary-hover: + backgroundColor: "{colors.brand-strong}" + bridge-primary-disabled: + backgroundColor: "{colors.paper}" + textColor: "{colors.muted}" + bridge-error: + backgroundColor: "{colors.down-soft}" + textColor: "{colors.down-ink}" + rounded: "{rounded.control}" + padding: "12px" +--- + +# Design System: openlaunch.lol bridge + +## Overview + +**Creative North Star: "The Instrument Strip"** + +This component-local record inherits the incumbent Clear Sky world and the global design authority. It describes `BridgeDialog.tsx`, `BridgeDialog.module.css` and `BridgeProvider.tsx`; it does not redefine the site's identity, palette or typography. The direction contract remains in `docs/bridge-surface.md`. + +The bridge is a bounded interaction over the existing page: one clear amount, open cost rows and visible wallet identity. Quiet navigation opens the panel; an explicit action advances review. Transfer feedback preserves the same visual language. + +**Key Characteristics:** + +- Inherited light and dark tokens, with one filled action inside the active panel. +- Hairline-separated content within a single bounded dialog. +- Native recipient disclosure and explicit, readable transfer progress. + +## Colors + +Clear Sky's blue action and slate neutrals carry into the panel. Frontmatter records the default light values; all component CSS binds to the inherited `--color-*` properties, including their existing dark overrides. + +### Primary + +Base Blue identifies the main action, focus, selection and pending-status accent. Strong Blue supplies the filled action's hover state; Soft Blue supports status icons. + +### Neutral + +Paper provides quiet hover and disabled surfaces. Card is the dialog and trigger ground. Ink carries amounts and important labels; Body and Muted distinguish explanation from secondary context. Line separates sections, while Line Strong supports progress connectors and the recovery link. The scrim remains dark in both themes. + +### Semantic + +Up and Up Soft identify completed transfer feedback. Down Ink on Down Soft identifies errors. Written state labels carry the meaning independently of colour. + +**The Panel Action Rule.** Keep the bridge entry neutral; reserve the filled brand treatment inside the active panel for its next action. + +## Typography + +Inter is inherited through `--font-sans`; full addresses and request identifiers use the runtime `--font-mono` binding. This extension does not select a new font. The worktree's inherited mono binding and the global design record may differ; resolve that globally, not in bridge styles. + +The hierarchy is a restrained dialog title, prominent amount, compact control text, then detail and caption text. The implementation uses a 24px title, 38px amount entry (34px on phones), 27px receive amount and 20px transfer heading. These are local component measurements, not a new site-wide display ramp. Current financial figures use tabular numerals; this record does not promote their inherited sans rendering into an exception to global typography guidance. + +## Layout + +A centred panel is at most 480px wide, with 16px viewport clearance and internal scrolling. At 520px and below it becomes a full-width bottom sheet, retains 16px top clearance and adds bottom safe-area padding. Content order and cost visibility remain the same. + +The source and destination flank a route reversal control. Amount entry leads into open estimate and fee rows; labels align left and values right. Repeated 8–24px spacing organizes controls and sections. The desktop trigger becomes icon-only between 1024px and 1279px while retaining its accessible name. + +## Elevation & Depth + +Content inside the dialog stays flat and uses single-pixel hairlines. The overlay alone uses the existing `--shadow-dialog` or mobile `--shadow-sheet`, over a scrim mixed at 45%. No new shadow palette is introduced. + +**The Bounded Interaction Rule.** Use the dialog boundary to contain the transaction; keep route, estimate, recipient and progress sections open within it. + +## Shapes + +Header entry uses a pill; the filled action and mobile menu entry use soft block corners. Close and route-reversal controls are circles. The local dialog has 24px corners, becoming 22px top corners on phones. These dialog measurements do not replace the global shape scale. + +The network menu uses a local 16px outer radius and 10px row radius around a 6px inset, preserving concentric corners. Its network names reuse the existing 14px control text, with 11px gas captions and a 12px menu label. These are component-local measurements, not a new site-wide token scale. + +## Components + +### Entry and action controls + +The neutral header entry is at least 36px tall; its mobile menu variant is at least 48px. The primary action is full-width and at least 48px tall. Disabled primary actions use Paper, Line and Muted at full opacity. Hover is restricted to fine pointers; active controls use the existing restrained press scale. + +### Amount and quote + +The amount field is borderless within its section, with an explicit source-asset label, available-balance context and a brand caret. Base UI network pickers expose Base, Robinhood and Arc with gas currencies. Each trigger combines the official mark, network name and gas token; its portaled menu uses spacious logo-led rows, a selected checkmark, keyboard highlighting and viewport collision handling. The menu stays above the scrolling dialog and inherits light/dark tokens. Pointer opening uses a short origin-aware transition; keyboard opening and reduced-motion preferences skip it. Official locally hosted Base, Robinhood, Arc, Ethereum and USDC marks identify the route and asset. Preserve their upstream geometry and colours; the Robinhood feather switches between official black/white variants, and the white Arc mark keeps a dark backing in either theme. Quotes distinguish estimated output, minimum received, included Relay fee and extra source gas. ETH/USDC routes disclose conversion and estimated value change. Changing the source asset clears the amount and quote. No quote value is implied before a quote exists. + +Base exposes a compact 44px ETH/USDC token picker beside the send amount and in the receive row. It reuses the network menu's Base UI behavior, official marks, focus, collision handling and motion. Networks with only one supported asset show a static token label, not a pretend dropdown. Base USDC's available balance is shown separately from ETH available for gas. Robinhood routes disclose that USDC is unavailable under the current safety checks. + +Base and Arc USDC approval is a separate action, limited to the entered amount. A pending or uncertain approval has its own source-network status and explorer link, never a misleading bridge-delivery progress indicator. Approval confirmation leads to a fresh quote and a separate deposit confirmation. Use plain explanatory text with a restrained accent rule, not another nested card. + +### Receiving wallet + +A native details/summary row combines the existing wallet avatar, receiving-wallet label and short address. It opens to a full, selectable, wrapping address in both the review and tracked-transfer views. Keep the full address available at phone widths. + +**The Inspectable Recipient Rule.** A shortened receiving address must disclose its complete value in the same flow before and after submission. + +### Transfer progress and recovery + +An ordered three-step sequence uses neutral numbered circles, hairline connectors and green check states only when completed. Status text distinguishes waiting, checking, delivery, refund and failure. Recovery uses a neutral outlined Relay link and explicit explorer links; a new-transfer control is secondary. + +### Focus and motion + +Base UI owns the dialog interaction. Opening focuses the popup; closing restores the initiating control, with the mobile menu button as fallback. All panel buttons, links, inputs and summaries receive a 2px brand focus outline with 4px offset. + +Entry uses a short opacity/rise transition; press feedback is finite. A spinner indicates active work. Reduced motion disables panel transitions and spinner rotation. Closing the panel preserves tracking so reopening can show the same transfer. + +## Do's and Don'ts + +### Do: + +- **Do** bind colours, fonts and elevation to the existing runtime theme variables. +- **Do** preserve the complete receiving-wallet disclosure in review and transfer states. +- **Do** keep costs, estimates and recovery actions legible beside their amounts. +- **Do** retain keyboard focus, mobile safe-area clearance and reduced-motion behavior. + +### Don't: + +- **Don't** add nested cards around each cost or progress section. +- **Don't** use colour alone to communicate completion, failure or uncertainty. +- **Don't** replace official network or token marks with letter badges, distort their proportions, or recolour them with CSS filters. diff --git a/app/src/components/bridge/bridge-ui.test.ts b/app/src/components/bridge/bridge-ui.test.ts new file mode 100644 index 0000000..da08594 --- /dev/null +++ b/app/src/components/bridge/bridge-ui.test.ts @@ -0,0 +1,116 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +const read = (path: string) => readFileSync(new URL(path, import.meta.url), "utf8"); +const panel = read("./BridgeDialog.tsx"); +const provider = read("./BridgeProvider.tsx"); +const header = read("../HeaderNav.tsx"); +const css = read("./BridgeDialog.module.css"); + +test("bridge uses bundled official asset marks rather than letter placeholders", () => { + for (const asset of ["base.svg", "robinhood-black.svg", "robinhood-white.svg", "ethereum.svg", "arc.svg", "usdc.svg"]) { + assert.ok(panel.includes(`/brand/${asset}`), asset); + const svg = read(`../../../public/brand/${asset}`); + assert.match(svg, //); + assert.match(panel, //); +}); + +test("bridge keeps one lazily loaded controller across desktop/mobile and dismissal", () => { + assert.match(header, //); + assert.match(header, //); + assert.match(header, / setOpen\(false\)\} \/>/); + assert.match(provider, /dynamic\(\(\) => import\("\.\/BridgeDialog"\)/); + assert.match(provider, /activated \? { + for (const content of ["Minimum received", "Source gas", "Receiving wallet", "0.5% slippage", "carries risk", "This quote expired", "Refresh quote", "0 Openlaunch fee"]) assert.ok(panel.includes(content), content); + assert.match(panel, /reviewing \? b.approvalRequired \? b.approve\(\) : b.confirm\(\) : b.requestQuote\(\)/); + assert.doesNotMatch(panel, /dangerouslySetInnerHTML|setInterval|sendTransaction/); +}); + +test("bridge dialog has accessible focus, mobile layout and reduced motion", () => { + assert.match(panel, /Dialog.Popup[^>]+initialFocus=\{popup\}/); + assert.match(panel, /finalFocus=\{connecting \? false : restoreFocus\}/); + assert.match(panel, /Dialog.Close[^>]+aria-label="Close bridge"/); + assert.match(panel, /htmlFor="bridge-amount"/); + assert.match(css, /prefers-reduced-motion: reduce/); + assert.match(css, /max-height: calc\(100dvh - 16px\)/); + assert.match(css, /:focus-visible/); +}); + +test("both quote and transfer recipients expose the full address without hover", () => { + assert.match(panel, /
[\s\S]*[\s\S]*\{address\}<\/code>/); + assert.match(panel, //); + assert.match(panel, //); + assert.match(css, /input, select, summary\):focus-visible/); +}); + +test("bridge selectors expose three networks and label cross-asset conversion and gas", () => { + assert.match(panel, /BRIDGE_CHAIN_IDS.map/); + assert.match(panel, /Select.Trigger[^>]+aria-label=\{`\$\{label\} network`\}/); + assert.match(panel, /onChange=\{b.setDestinationChainId\}/); + assert.match(panel, /onClick=\{b.reverseRoute\}/); + assert.match(panel, /Gas in \{BRIDGE_CHAINS\[chain\].symbol\}/); + assert.match(panel, /Converted by Relay/); + assert.match(panel, /Value change/); + assert.doesNotMatch(panel + provider, /Bridge ETH|relayFeeEth|sourceGasEth|Your ETH is on its way/); +}); + +test("network pickers use themed Base UI lists without native browser menus", () => { + assert.match(panel, /import \{ Select \} from "@base-ui\/react\/select"/); + assert.doesNotMatch(panel, /]+label=\{BRIDGE_CHAINS\[id\].name\}/); + assert.match(panel, /Select.ItemIndicator/); + assert.match(css, /networkPositioner \{ z-index: 90/); + assert.match(css, /networkPopup\[data-instant\] \{ transition: none/); + assert.match(css, /prefers-reduced-motion: reduce[^\n]+\.networkPopup/); +}); + +test("synthetic bridge review is development-only and cannot execute a transaction", () => { + assert.match(read("../../app/ui-review-bridge/page.tsx"), /process.env.NODE_ENV !== "development"\) notFound\(\)/); + const review = read("../../app/ui-review-bridge/BridgeReview.tsx"); + assert.doesNotMatch(review, /sendTransaction|useBridge\(\)|fetch\(/); + assert.match(review, /No wallet requests, API calls, or funds/); +}); + +test("USDC approval is visibly separate from the bridge and has chain-aware recovery", () => { + assert.match(panel, /Approve \$\{nativeAmount\(b.amount\)\} USDC/); + assert.match(panel, /Approval alone does not move your funds/); + assert.match(panel, /No bridge deposit has been requested/); + assert.match(panel, /Review a fresh quote before bridging/); + assert.match(panel, /approval\.approvalHash/); + assert.match(panel, /b.recoverApproval\(hash.trim\(\)\)/); + assert.match(panel, /unspent allowance remains until used or revoked/); + assert.match(panel, /No wallet request will be made/); + assert.match(panel, /b.approvalError \|\| b.storageError/); + assert.match(panel, /approval\.approvalHash \? ]+onClick=\{b.retryApproval\}/); + assert.match(panel, /BRIDGE_CHAINS\[approval.chainId\]/); + assert.match(panel, /approvalChain.explorer/); +}); + +test("USDC selectors separate selected token units from native gas and disallow unsafe Robinhood routes", () => { + assert.match(panel, /AssetSelect label="Send"[^>]+onChange=\{b.setOriginAsset\}/); + assert.match(panel, /AssetSelect label="Receive"[^>]+onChange=\{b.setDestinationAsset\}/); + assert.match(panel, /BRIDGE_ASSETS\[chain\]/); + assert.match(panel, /isBridgeAssetSupported\(chain, value\)/); + assert.match(panel, /formatUnits\(b.balance, inputCurrency.decimals\)/); + assert.match(panel, /formatUnits\(BigInt\(value\), outputCurrency.decimals\)/); + assert.match(panel, /For gas: \{nativeAmount\(b.nativeBalance\)\} \{origin.symbol\}/); + assert.match(panel, /USDC on Robinhood is unavailable/); + assert.match(panel, /uses additional \{origin.symbol\} for gas/); + assert.match(panel, /const inputCurrency = bridgeTransferInputCurrency\(transfer\)/); +}); diff --git a/app/src/components/bridge/useBridge.ts b/app/src/components/bridge/useBridge.ts new file mode 100644 index 0000000..1d04c7a --- /dev/null +++ b/app/src/components/bridge/useBridge.ts @@ -0,0 +1,550 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react"; +import { useAccount, useBalance, useConfig, useReadContract, useSwitchChain } from "wagmi"; +import { getAccount, getPublicClient, getWalletClient } from "wagmi/actions"; +import { estimateTotalFee } from "viem/op-stack"; +import { erc20Abi, parseEther, TransactionReceiptNotFoundError, type Address } from "viem"; +import { BRIDGE_WALLET_CHAINS } from "@/lib/bridge/chains"; +import { BRIDGE_CHAINS, bridgeCurrency, defaultBridgeAsset, isBridgeAssetSupported, isBridgeChainId, type BridgeAsset, type BridgeChainId, type BridgeQuote } from "@/lib/bridge/types"; +import { bridgeGasBudget, bridgeRequest, bridgeRequestKey, changeBridgeRoute, hasMatchingDepositEvent, isHash, isMatchingSourceDeposit, mergeBridgeStatus, nativeSourceAmount, RELAY_DEPOSITORY, submitBridgeDeposit, transferIsTerminal, transferPhase, validateBridgeQuote, validateBridgeStatus, type BridgePhase, type BridgeRouteChange, type BridgeRouteInputs, type TrackedBridgeTransfer } from "@/lib/bridge/client"; +import { BRIDGE_STORAGE_PREFIX, createBridgeTransferStore } from "@/lib/bridge/client-storage"; +import { APPROVAL_STORAGE_PREFIX, approvalBlocksSubmission, createApprovalStore, hasMatchingApprovalEvent, isMatchingApprovalTransaction, reconcileApproval, submitExactApproval, validateApprovalMetadata } from "@/lib/bridge/approval"; + +export type { BridgePhase, TrackedBridgeTransfer } from "@/lib/bridge/client"; + +// Created without touching window: the server snapshot and first client render +// are neutral. Storage is loaded after hydration and is scoped to the account. +const transfers = createBridgeTransferStore(() => window.localStorage); +const approvals = createApprovalStore(() => window.localStorage); +type QuoteEnvelope = { quote: BridgeQuote; key: string; walletChainId?: number; requestedAt: number }; +type Issue = { key: string; message: string } | null; +const messageOf = (error: unknown, fallback: string) => error instanceof Error ? error.message : fallback; +const transferLockName = (address: Address) => `openlaunch:bridge:${address.toLowerCase()}`; + +async function responseBody(response: Response): Promise { + const body: unknown = await response.json(); + if (!response.ok) { + const message = body && typeof body === "object" && "error" in body && typeof body.error === "string" ? body.error : "The bridge service is temporarily unavailable. Try again."; + throw new Error(message); + } + return body; +} + +/** Mount once above the dialog so closing it never interrupts transfer recovery. */ +export function useBridge() { + const config = useConfig(); + const { address, chainId: walletChainId } = useAccount(); + const { switchChainAsync } = useSwitchChain(); + const [route, setRoute] = useState({ originChainId: 8453, destinationChainId: 4663, originAsset: "ETH", destinationAsset: "ETH", amount: "" }); + const { originChainId, destinationChainId, amount } = route; + const originAsset = route.originAsset ?? defaultBridgeAsset(originChainId); + const destinationAsset = route.destinationAsset ?? defaultBridgeAsset(destinationChainId); + const inputCurrency = bridgeCurrency(originChainId, originAsset, "input"); + const inputIsToken = !/^0x0{40}$/.test(inputCurrency.address); + const [envelope, setEnvelope] = useState(null); + const [activity, setActivity] = useState<{ key: string; phase: "idle" | "quoting" | "switching" | "confirming" }>({ key: "", phase: "idle" }); + const [issue, setIssue] = useState(null); + const [quoteIssue, setQuoteIssue] = useState(null); + const [statusIssue, setStatusIssue] = useState(null); + const [expiredId, setExpiredId] = useState(null); + const [sending, setSending] = useState(false); + const [pollRevision, setPollRevision] = useState(0); + const [approvalPollRevision, setApprovalPollRevision] = useState(0); + const [approvalSending, setApprovalSending] = useState(false); + const [approvalIssue, setApprovalIssue] = useState(null); + const [allowanceResult, setAllowanceResult] = useState<{ key: string; value: bigint | null; error: string | null } | null>(null); + const actionLock = useRef(false); + const quoteSequence = useRef(0); + const quoteAbort = useRef(null); + const inputs = useRef({ originChainId: 8453, destinationChainId: 4663, originAsset: "ETH", destinationAsset: "ETH", amount: "" }); + const snapshot = useSyncExternalStore(transfers.subscribe, transfers.getSnapshot, transfers.getServerSnapshot); + const approvalSnapshot = useSyncExternalStore(approvals.subscribe, approvals.getSnapshot, approvals.getServerSnapshot); + const walletKey = address?.toLowerCase() ?? ""; + const tracked = snapshot.transfers[walletKey] ?? null; + const approval = approvalSnapshot.approvals[walletKey] ?? null; + const approvalPending = approvalBlocksSubmission(approval); + const storageError = snapshot.errors[walletKey] ?? approvalSnapshot.errors[walletKey] ?? null; + const request = bridgeRequest(address, originChainId, amount, destinationChainId, originAsset, destinationAsset); + const requestKey = bridgeRequestKey(request); + const quote = envelope?.key === requestKey && envelope.walletChainId === walletChainId ? envelope.quote : null; + const quoteExpired = !!quote && expiredId === quote.requestId; + const allowanceLoading = !!quote?.approval && allowanceResult?.key !== quote.requestId; + const approvalRequired = !!quote?.approval && (allowanceResult?.key !== quote.requestId || allowanceResult.value === null || allowanceResult.value < BigInt(quote.approval.amount)); + const sourceBalance = useBalance({ address, chainId: originChainId, query: { enabled: !!address, refetchInterval: 15_000 } }); + const tokenBalance = useReadContract({ address: inputCurrency.address, abi: erc20Abi, functionName: "balanceOf", args: address ? [address] : undefined, chainId: originChainId, query: { enabled: !!address && inputIsToken, refetchInterval: 15_000 } }); + const cancelQuote = useCallback(() => { quoteSequence.current++; quoteAbort.current?.abort(); }, []); + + useEffect(() => { + if (!address) return; + const refresh = () => { + try { transfers.read(address); } catch { /* surfaced in the store */ } + try { approvals.read(address); } catch { /* surfaced in the store */ } + }; + refresh(); + const onStorage = (event: StorageEvent) => { + if (event.key === null || event.key === `${BRIDGE_STORAGE_PREFIX}${address.toLowerCase()}` || event.key === `${APPROVAL_STORAGE_PREFIX}${address.toLowerCase()}`) refresh(); + }; + window.addEventListener("storage", onStorage); + return () => window.removeEventListener("storage", onStorage); + }, [address]); + + // Existing quotes are hidden immediately by their wallet-chain key. Abort + // in-flight responses as well; an old account's response cannot reappear. + useEffect(() => cancelQuote, [address, walletChainId, cancelQuote]); + + useEffect(() => { + if (!quote) return; + const timer = window.setTimeout(() => setExpiredId(quote.requestId), Math.max(0, quote.expiresAt - Date.now())); + return () => window.clearTimeout(timer); + }, [quote]); + + useEffect(() => { + if (!quote?.approval) return; + let stopped = false; + const pub = getPublicClient(config, { chainId: quote.originChainId }); + if (!pub) return; + void Promise.all([pub.getChainId(), pub.readContract({ address: quote.approval.token, abi: erc20Abi, functionName: "allowance", args: [quote.address, RELAY_DEPOSITORY] })]).then(([chainId, value]) => { + if (chainId !== quote.originChainId) throw new Error("The approval RPC reported a different network."); + if (!stopped) setAllowanceResult({ key: quote.requestId, value, error: null }); + }).catch((error) => { + if (!stopped) setAllowanceResult({ key: quote.requestId, value: null, error: messageOf(error, "Could not read USDC allowance. Request a new quote.") }); + }); + return () => { stopped = true; }; + }, [quote, config]); + + const approvalId = approval?.createdAt; + const approvalHash = approval?.approvalHash; + useEffect(() => { + // An unknown broadcast cannot be resolved from allowance alone. Wait for + // a wallet-returned or manually verified hash before polling its source chain. + if (!address || !approvalId || !approvalHash) return; + let stopped = false; + let active = false; + let timer: ReturnType | undefined; + const poll = async () => { + if (stopped || active) return; + active = true; + clearTimeout(timer); + try { + const observed = approvals.getSnapshot().approvals[address.toLowerCase()]; + if (!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([ + pub.getChainId(), + pub.readContract({ address: observed.token, abi: erc20Abi, functionName: "allowance", args: [address, RELAY_DEPOSITORY] }), + pub.getTransactionReceipt({ hash: approvalHash }).catch((error) => { + if (error instanceof TransactionReceiptNotFoundError) return null; + throw error; + }), + ]); + const apply = (persist: boolean) => { + const current = approvals.read(address); + if (stopped || !current || current.createdAt !== approvalId || current.approvalHash !== approvalHash || current.amount !== observed.amount) return; + const next = reconcileApproval(current, { chainId, allowance, receipt }); + if (persist) { + try { approvals.save(next); } catch { /* memory retains the receipt result */ } + } else approvals.remember(next); + 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.") }); + } finally { + active = false; + const current = approvals.getSnapshot().approvals[address.toLowerCase()]; + if (!stopped && current?.createdAt === approvalId && current.approvalHash === approvalHash && approvalBlocksSubmission(current)) timer = setTimeout(() => void poll(), 8_000); + } + }; + void poll(); + const focus = () => { void poll(); }; + window.addEventListener("focus", focus); + return () => { stopped = true; clearTimeout(timer); window.removeEventListener("focus", focus); }; + }, [address, approvalId, approvalHash, approvalPollRevision, config]); + + const trackedId = tracked?.requestId; + const settled = transferIsTerminal(tracked); + useEffect(() => { + if (!address || !trackedId || settled) return; + let stopped = false; + let timer: ReturnType | undefined; + let controller: AbortController | undefined; + let active = false; + let failures = 0; + const poll = async () => { + if (stopped || active) return; + active = true; + clearTimeout(timer); + controller = new AbortController(); + try { + const observed = transfers.getSnapshot().transfers[address.toLowerCase()]; + const pub = observed ? getPublicClient(config, { chainId: observed.originChainId }) : undefined; + const [provider, receipt] = await Promise.allSettled([ + fetch(`/api/bridge/status?requestId=${encodeURIComponent(trackedId)}`, { cache: "no-store", signal: AbortSignal.any([controller.signal, AbortSignal.timeout(15_000)]) }).then(responseBody).then(validateBridgeStatus), + observed?.sourceHash && pub ? Promise.all([pub.getChainId(), pub.getTransactionReceipt({ hash: observed.sourceHash })]).then(([chainId, result]) => chainId === observed.originChainId ? result : null) : Promise.resolve(null), + ]); + if (stopped) return; + const applyStatus = async (persist: boolean) => { + let current = transfers.read(address); + if (stopped || !current || current.requestId !== trackedId || transferIsTerminal(current)) return; + let next: TrackedBridgeTransfer; + if (receipt.status === "fulfilled" && receipt.value?.status === "reverted" && receipt.value.transactionHash.toLowerCase() === current.sourceHash?.toLowerCase()) { + next = { ...current, status: "failure", failureReason: "source-reverted" }; + } else { + if (provider.status === "rejected") throw provider.reason; + const status = provider.value; + if (!current.sourceHash && status.inTxHashes[0] && pub) { + const candidate = status.inTxHashes[0]; + const [chainId, transaction] = await Promise.all([pub.getChainId(), pub.getTransaction({ hash: candidate })]); + if (chainId !== current.originChainId) throw new Error("The source RPC reported a different network. Tracking will retry."); + const matched = isMatchingSourceDeposit(current, transaction) || hasMatchingDepositEvent(current, await pub.getTransactionReceipt({ hash: candidate })); + if (!matched) throw new Error("Relay's source transaction could not be matched to this deposit. Tracking will retry."); + current = { ...current, sourceHash: candidate }; + } + next = mergeBridgeStatus(current, status); + } + if (stopped) return; + if (persist) { + try { transfers.save(next); } catch { /* retained in memory; pre-send recovery remains durable */ } + } else transfers.remember(next); + setStatusIssue(null); + failures = 0; + }; + if (navigator.locks) { + await navigator.locks.request(transferLockName(address), { ifAvailable: true }, async (lock) => { if (lock) await applyStatus(true); }); + } else await applyStatus(false); // read-only recovery remains available in older browsers + } catch (error) { + if (stopped) return; + failures++; + setStatusIssue({ key: trackedId, message: messageOf(error, "Could not refresh transfer status. Tracking will retry.") }); + } finally { + active = false; + const current = transfers.getSnapshot().transfers[address.toLowerCase()]; + if (!stopped && current?.requestId === trackedId && !transferIsTerminal(current)) timer = setTimeout(() => void poll(), Math.min(30_000, 6_000 * 2 ** Math.min(failures, 3))); + } + }; + void poll(); + const focus = () => { void poll(); }; + window.addEventListener("focus", focus); + return () => { stopped = true; clearTimeout(timer); controller?.abort(); window.removeEventListener("focus", focus); }; + }, [address, trackedId, settled, pollRevision, config]); + + function invalidateQuote() { + cancelQuote(); + setEnvelope(null); + setExpiredId(null); + setQuoteIssue(null); + setIssue(null); + setActivity({ key: "", phase: "idle" }); + } + + function updateRoute(change: BridgeRouteChange) { + if (actionLock.current || approvalPending || (tracked && !transferIsTerminal(tracked))) return; + const next = changeBridgeRoute(inputs.current, change); + inputs.current = next; + setRoute(next); + invalidateQuote(); + } + + function setOriginChainId(chainId: BridgeChainId) { + if (isBridgeChainId(chainId)) updateRoute({ side: "origin", chainId }); + } + + function setDestinationChainId(chainId: BridgeChainId) { + if (isBridgeChainId(chainId)) updateRoute({ side: "destination", chainId }); + } + + function setOriginAsset(asset: BridgeAsset) { + if (isBridgeAssetSupported(inputs.current.originChainId, asset)) updateRoute({ side: "origin-asset", asset }); + } + + function setDestinationAsset(asset: BridgeAsset) { + if (isBridgeAssetSupported(inputs.current.destinationChainId, asset)) updateRoute({ side: "destination-asset", asset }); + } + + function reverseRoute() { updateRoute({ side: "reverse" }); } + + function setAmount(value: string) { + if (actionLock.current || approvalPending || (tracked && !transferIsTerminal(tracked))) return; + const next = { ...inputs.current, amount: value }; + inputs.current = next; + setRoute(next); + invalidateQuote(); + } + + async function requestQuote() { + if (actionLock.current || approvalPending || (tracked && !transferIsTerminal(tracked))) return; + const connected = getAccount(config); + const current = bridgeRequest(connected.address, inputs.current.originChainId, inputs.current.amount, inputs.current.destinationChainId, inputs.current.originAsset, inputs.current.destinationAsset); + if (!current) { + const currency = bridgeCurrency(inputs.current.originChainId, inputs.current.originAsset, "input"); + setQuoteIssue({ key: requestKey, message: !connected.address ? "Connect your wallet to get a quote." : `Enter a ${currency.symbol} amount greater than zero, with at most ${currency.decimals} decimal places.` }); + return; + } + const key = bridgeRequestKey(current); + const sequence = ++quoteSequence.current; + const requestedAt = Date.now(); + quoteAbort.current?.abort(); + const abort = new AbortController(); + quoteAbort.current = abort; + setEnvelope(null); + setExpiredId(null); + setIssue(null); + setQuoteIssue(null); + setApprovalIssue(null); + setActivity({ key, phase: "quoting" }); + try { + if (approvalBlocksSubmission(approvals.read(current.address))) throw new Error("An approval is still being tracked. Wait for its confirmation before requesting a new quote."); + const response = await fetch("/api/bridge/quote", { + method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(current), cache: "no-store", + signal: AbortSignal.any([abort.signal, AbortSignal.timeout(20_000)]), + }); + const next = validateBridgeQuote(await responseBody(response), current, Date.now()); + const wallet = getAccount(config); + if (sequence !== quoteSequence.current || wallet.address?.toLowerCase() !== current.address.toLowerCase() || wallet.chainId !== connected.chainId) return; + setEnvelope({ quote: next, key, walletChainId: connected.chainId, requestedAt }); + } catch (error) { + if (sequence !== quoteSequence.current || abort.signal.aborted) return; + setQuoteIssue({ key, message: messageOf(error, "Could not get a quote. Try again.") }); + } finally { + if (sequence === quoteSequence.current) setActivity({ key, phase: "idle" }); + } + } + + async function approve() { + if (actionLock.current || !quote?.approval || quoteExpired || approvalPending || (tracked && !transferIsTerminal(tracked))) return; + actionLock.current = true; + setApprovalSending(true); + setApprovalIssue(null); + const reviewed = quote; + const operationKey = bridgeRequestKey(reviewed); + setEnvelope(null); + try { + validateBridgeQuote(reviewed, reviewed, Date.now()); + const request = validateApprovalMetadata(reviewed.approval, reviewed.address, reviewed.originChainId); + if (!navigator.locks) throw new Error("This browser cannot safely coordinate approvals between tabs. Use a current browser."); + await navigator.locks.request(transferLockName(reviewed.address), { ifAvailable: true }, async (lock) => { + if (!lock) throw new Error("A bridge or approval request is open in another tab. Check that tab before continuing."); + const pub = getPublicClient(config, { chainId: reviewed.originChainId }); + if (!pub) throw new Error("Could not connect to the source network. Request a new quote."); + let wallet: Awaited> | undefined; + const currentRequest = () => { + const current = bridgeRequest(getAccount(config).address, inputs.current.originChainId, inputs.current.amount, inputs.current.destinationChainId, inputs.current.originAsset, inputs.current.destinationAsset); + const deposit = transfers.read(reviewed.address); + if (Date.now() >= reviewed.expiresAt || bridgeRequestKey(current) !== operationKey || (deposit && !transferIsTerminal(deposit))) return null; + return request; + }; + const result = await submitExactApproval(request, { + now: Date.now, + currentRequest, + readWallet: async () => { + if (!getAccount(config).address) return {}; + wallet = await getWalletClient(config); + const [accounts, chainId] = await Promise.all([wallet.getAddresses(), wallet.getChainId()]); + return { address: getAccount(config).address?.toLowerCase() === accounts[0]?.toLowerCase() ? accounts[0] : undefined, chainId }; + }, + switchChain: (chainId) => switchChainAsync({ chainId }), + prepare: async (owner, transaction) => { + 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(), + pub.readContract({ address: transaction.to, abi: erc20Abi, functionName: "allowance", args: [owner.address, RELAY_DEPOSITORY] }), + pub.readContract({ address: transaction.to, abi: erc20Abi, functionName: "balanceOf", args: [owner.address] }), + reviewed.originChainId === 8453 ? estimateTotalFee(pub, call) : Promise.resolve(0n), + ]); + if (chainId !== reviewed.originChainId) throw new Error("The source RPC reported a different network."); + if (selectedBalance < BigInt(owner.amount)) throw new Error("Not enough USDC for this approval amount. Reduce the amount and request a new quote."); + if (allowance >= BigInt(owner.amount)) throw new Error("USDC allowance is already sufficient. Request a new quote to review the deposit."); + if (fees.maxFeePerGas === undefined || fees.maxPriorityFeePerGas === undefined) throw new Error("Could not estimate approval fees. Request a new quote."); + // Arc's token shares its gas balance; Base USDC has a separate ETH + // gas balance. Reserve approval and quoted deposit costs in native units. + const budget = bridgeGasBudget(nativeSourceAmount(reviewed), balance, estimate, fees.maxFeePerGas, parseEther(reviewed.sourceGas) + additional, BRIDGE_CHAINS[reviewed.originChainId].symbol); + return { gas: budget.gas, maxFeePerGas: fees.maxFeePerGas, maxPriorityFeePerGas: fees.maxPriorityFeePerGas }; + }, + readApproval: (owner) => approvals.read(owner), + saveApproval: (record) => approvals.save(record), + removeApproval: (owner) => approvals.remove(owner), + send: (owner, transaction, gas) => { + const connected = getAccount(config); + if (!wallet || !currentRequest() || connected.address?.toLowerCase() !== owner.address.toLowerCase() || connected.chainId !== transaction.chainId) throw new Error("Wallet changed before approval submission"); + return wallet.sendTransaction({ account: owner.address, chain: BRIDGE_WALLET_CHAINS[transaction.chainId], to: transaction.to, data: transaction.data, value: 0n, ...gas }); + }, + phase: (phase) => setActivity({ key: operationKey, phase }), + }); + if (result.kind === "rejected") setApprovalIssue({ key: reviewed.address.toLowerCase(), message: "You declined the approval. No bridge deposit was requested. Request a new quote when ready." }); + if (result.kind === "uncertain") setApprovalIssue({ key: reviewed.address.toLowerCase(), message: "The wallet did not return an approval hash. Check this approval before trying again; no bridge deposit was requested." }); + }); + } catch (error) { + setApprovalIssue({ key: reviewed.address.toLowerCase(), message: messageOf(error, "Could not prepare USDC approval. Request a new quote.") }); + } finally { + actionLock.current = false; + setApprovalSending(false); + setActivity({ key: operationKey, phase: "idle" }); + void sourceBalance.refetch(); + if (inputIsToken) void tokenBalance.refetch(); + } + } + + async function recoverApproval(hash: string) { + if (actionLock.current || !address || !approval || approval.approvalHash || approval.status !== "uncertain") 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; + } + actionLock.current = true; + setApprovalSending(true); + setApprovalIssue(null); + try { + if (!navigator.locks) throw new Error("Open this page in a current browser to safely recover the approval."); + 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."); + 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."); + 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 }); + approvals.save(next); + }); + } catch (error) { + setApprovalIssue({ key: address.toLowerCase(), message: messageOf(error, "Could not verify that approval. Nothing was submitted; check again.") }); + } finally { + actionLock.current = false; + setApprovalSending(false); + } + } + + async function confirm() { + if (actionLock.current || !quote || quoteExpired || approvalPending || allowanceLoading || approvalRequired || (tracked && !transferIsTerminal(tracked))) return; + actionLock.current = true; + setSending(true); + setIssue(null); + const reviewed = quote; + const reviewedAt = envelope?.requestedAt ?? 0; + const operationKey = bridgeRequestKey(reviewed); + setActivity({ key: operationKey, phase: "switching" }); + // Never reuse this review after an interrupted/rejected confirmation. + setEnvelope(null); + try { + const execute = async () => { + const approvalRecord = approvals.read(reviewed.address); + if (approvalBlocksSubmission(approvalRecord)) throw new Error("An approval is still being tracked. Check it before depositing."); + if (reviewed.approval && approvalRecord && reviewedAt <= approvalRecord.createdAt) throw new Error("Request and review a new quote after the approval before depositing."); + let wallet: Awaited> | undefined; + const result = await submitBridgeDeposit(reviewed, { + now: Date.now, + currentRequest: () => bridgeRequest(getAccount(config).address, inputs.current.originChainId, inputs.current.amount, inputs.current.destinationChainId, inputs.current.originAsset, inputs.current.destinationAsset), + readWallet: async () => { + const connected = getAccount(config); + if (!connected.address) return {}; + wallet = await getWalletClient(config); + const [accounts, chainId] = await Promise.all([wallet.getAddresses(), wallet.getChainId()]); + // Both Wagmi and the provider must still identify the reviewed account. + const latest = getAccount(config); + return { address: latest.address?.toLowerCase() === accounts[0]?.toLowerCase() ? accounts[0] : undefined, chainId }; + }, + switchChain: (chainId) => switchChainAsync({ chainId }), + prepare: async (q) => { + const pub = getPublicClient(config, { chainId: q.originChainId }); + if (!pub) throw new Error("Could not connect to the source network. Try again."); + 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(), + // Base charges L1 data/operator fees in addition to execution gas. + // Reserving the total estimate here is intentionally conservative. + q.originChainId === 8453 ? estimateTotalFee(pub, transaction) : Promise.resolve(0n), + q.approval ? pub.readContract({ address: q.approval.token, abi: erc20Abi, functionName: "allowance", args: [q.address, RELAY_DEPOSITORY] }) : Promise.resolve(null), + q.approval ? pub.readContract({ address: q.approval.token, abi: erc20Abi, functionName: "balanceOf", args: [q.address] }) : Promise.resolve(null), + ]); + if (rpcChain !== q.originChainId) throw new Error("The source RPC reported a different network. Try again later."); + if (q.approval && (allowance === null || allowance < BigInt(q.amount))) throw new Error("USDC allowance is no longer sufficient. Request a new quote and approve the exact amount."); + if (q.approval && (selectedBalance === null || selectedBalance < BigInt(q.amount))) throw new Error("Not enough USDC for this deposit. Reduce the amount and request a new quote."); + if (fees.maxFeePerGas === undefined || fees.maxPriorityFeePerGas === undefined) throw new Error("Could not estimate network fees. Try again."); + const budget = bridgeGasBudget(nativeSourceAmount(q), balance, estimate, fees.maxFeePerGas, additional, BRIDGE_CHAINS[q.originChainId].symbol); + return { gas: budget.gas, maxFeePerGas: fees.maxFeePerGas, maxPriorityFeePerGas: fees.maxPriorityFeePerGas }; + }, + readTransfer: (owner) => transfers.read(owner), + saveTransfer: (transfer) => transfers.save(transfer), + removeTransfer: (owner) => transfers.remove(owner), + send: (q, gas) => { + const connected = getAccount(config); + if (!wallet || connected.address?.toLowerCase() !== q.address.toLowerCase() || connected.chainId !== q.originChainId) throw new Error("Wallet changed before submission"); + return wallet.sendTransaction({ account: q.address, chain: BRIDGE_WALLET_CHAINS[q.originChainId], to: q.transaction.to, data: q.transaction.data, value: BigInt(q.transaction.value), ...gas }); + }, + phase: (phase) => setActivity({ key: operationKey, phase }), + }); + if (result.kind === "rejected") setIssue({ key: reviewed.address.toLowerCase(), message: "You declined the wallet request. No transfer was submitted. Request a new quote when you're ready." }); + if (result.kind === "uncertain") setIssue({ key: reviewed.address.toLowerCase(), message: "The wallet did not return a transaction hash. The transfer may have been submitted. Keep tracking this request before trying again." }); + }; + // Keep the lock across the wallet prompt: two tabs must never replace + // one another's recovery record or prompt for two deposits. + if (navigator.locks) { + await navigator.locks.request(transferLockName(reviewed.address), { ifAvailable: true }, async (lock) => { + if (!lock) throw new Error("A bridge request is already open in another tab. Check that tab before continuing."); + await execute(); + }); + } else throw new Error("This browser cannot safely coordinate bridge requests between tabs. Open Openlaunch in a current browser to continue."); + } catch (error) { + setIssue({ key: reviewed.address.toLowerCase(), message: messageOf(error, "Could not prepare the transfer. Request a new quote and try again.") }); + } finally { + actionLock.current = false; + setSending(false); + setActivity({ key: operationKey, phase: "idle" }); + void sourceBalance.refetch(); + if (inputIsToken) void tokenBalance.refetch(); + } + } + + function reset() { + if (actionLock.current || approvalPending || (tracked && !transferIsTerminal(tracked))) return; + if (address && tracked) { + const requestId = tracked.requestId; + if (!navigator.locks) return; + void navigator.locks.request(transferLockName(address), { ifAvailable: true }, (lock) => { + if (!lock) return; + try { + const current = transfers.read(address); + if (current?.requestId !== requestId || !transferIsTerminal(current)) return; + transfers.remove(address); + invalidateQuote(); + setStatusIssue(null); + } catch { /* storage warning is exposed in the store */ } + }); + } else { invalidateQuote(); setStatusIssue(null); } + } + + const retryStatus = useCallback(() => setPollRevision((value) => value + 1), []); + const retryApproval = useCallback(() => setApprovalPollRevision((value) => value + 1), []); + const activePhase = activity.key === requestKey ? activity.phase : "idle"; + const phase: BridgePhase = sending && (activePhase === "switching" || activePhase === "confirming") ? activePhase : tracked ? transferPhase(tracked) : activePhase === "quoting" ? "quoting" : quote ? "review" : "idle"; + return { + address, walletChainId, originChainId, destinationChainId, originAsset, destinationAsset, setOriginAsset, setDestinationAsset, setOriginChainId, setDestinationChainId, reverseRoute, amount, setAmount, + balance: inputIsToken ? tokenBalance.data : sourceBalance.data?.value, + nativeBalance: sourceBalance.data?.value, + balanceLoading: !!address && (inputIsToken ? tokenBalance.isPending : sourceBalance.isPending), + balanceError: (inputIsToken ? tokenBalance.isError : sourceBalance.isError) ? "Could not read your source balance. It will be checked again before submission." : null, + quote, phase, error: tracked?.failureReason === "source-reverted" ? "The source transaction reverted on-chain. The deposit was not made; network gas was still charged." : issue?.key === walletKey ? issue.message : null, + quoteError: quoteIssue?.key === requestKey ? quoteIssue.message : null, + quoteExpired, requestQuote, confirm, reset, tracked, + approval, approvalRequired, allowanceLoading, approvalBusy: approvalSending, + approvalError: approvalIssue?.key === walletKey ? approvalIssue.message : quote && allowanceResult?.key === quote.requestId ? allowanceResult.error : null, + approve, retryApproval, recoverApproval, + statusError: statusIssue && statusIssue.key === trackedId ? statusIssue.message : null, + retryStatus, storageError, busy: sending || approvalSending || approvalPending || activePhase === "quoting", + canReset: !sending && !approvalSending && !approvalPending && (!tracked || transferIsTerminal(tracked)), + }; +} + +export default useBridge; diff --git a/app/src/components/launchpad/launch-machine.test.ts b/app/src/components/launchpad/launch-machine.test.ts index eb9fc78..91f2392 100644 --- a/app/src/components/launchpad/launch-machine.test.ts +++ b/app/src/components/launchpad/launch-machine.test.ts @@ -10,7 +10,7 @@ import test from "node:test"; */ const read = (file: string) => readFileSync(new URL(file, import.meta.url), "utf8"); const machine = read("./LaunchMachine.tsx"); -const css = read("./LaunchMachine.module.css"); +const css = read("./LaunchMachine.module.css").replaceAll("\r\n", "\n"); const metrics = read("./LaunchMechanism.tsx"); const hero = read("./LaunchHero.tsx"); diff --git a/app/src/components/wallet-menu.test.ts b/app/src/components/wallet-menu.test.ts index eb8ae6d..58155bd 100644 --- a/app/src/components/wallet-menu.test.ts +++ b/app/src/components/wallet-menu.test.ts @@ -33,8 +33,10 @@ test("wallet adapter preserves hydration, routes connecting through the picker, test("unknown networks stay unknown and never get an inferred explorer destination", () => { assert.match(source, /const key = chainKeyOf\(chainId\)/); - assert.match(source, /key \? CHAIN_SHORT\[key\] : "Unsupported network"/); - assert.match(source, /\{key \? \(\s*]*href=\{explorerAddress\(key, address\)\}/); + assert.match(source, /key \? CHAIN_SHORT\[key\] : bridgeNetwork\?\.name \?\? "Unsupported network"/); + assert.match(source, /isBridgeChainId\(chainId\) \? BRIDGE_CHAINS\[chainId\]/); + assert.match(source, /\{explorer \? \(\s*]*href=\{explorer\}/); + assert.match(source, /const explorer = key \? explorerAddress\(key, address\) : bridgeNetwork \? `[\s\S]*` : null/); assert.match(source, /explorerName\(key\)/); assert.match(source, /This network isn’t supported\. Choose one below\./); assert.match(source, /CHAIN_KEYS.map\(\(chain\) =>/); diff --git a/app/src/components/wallet-picker.test.ts b/app/src/components/wallet-picker.test.ts index e37fc77..c22d8c3 100644 --- a/app/src/components/wallet-picker.test.ts +++ b/app/src/components/wallet-picker.test.ts @@ -1,11 +1,12 @@ import assert from "node:assert/strict"; import { readFileSync, readdirSync, statSync } from "node:fs"; import { join } from "node:path"; +import { fileURLToPath } from "node:url"; import test from "node:test"; const picker = readFileSync(new URL("./WalletPicker.tsx", import.meta.url), "utf8"); const button = readFileSync(new URL("./ConnectWallet.tsx", import.meta.url), "utf8"); -const src = new URL("../", import.meta.url).pathname; +const src = fileURLToPath(new URL("../", import.meta.url)); function walk(dir: string, out: string[] = []): string[] { for (const name of readdirSync(dir)) { @@ -19,7 +20,7 @@ function walk(dir: string, out: string[] = []): string[] { // Source contracts: the picker is the only place that chooses a connector or calls connect(). test("every connect entry point goes through the shared picker; nothing else touches useConnect", () => { const users = walk(src).filter((p) => /useConnect\b|connectors\[0\]|c\.id === "coinbaseWallet"/.test(readFileSync(p, "utf8"))); - assert.deepEqual(users.map((p) => p.slice(src.length)), ["components/WalletPicker.tsx"]); + assert.deepEqual(users.map((p) => p.slice(src.length).replaceAll("\\", "/")), ["components/WalletPicker.tsx"]); for (const file of ["components/ConnectButton.tsx", "components/launchpad/Posts.tsx", "components/launchpad/TradePanel.tsx", "components/launchpad/MeDashboard.tsx"]) { assert.match(readFileSync(join(src, file), "utf8"), / = {}): TrackedApproval { + return { ...REQUEST, version: 1, chainId: ARC_APPROVAL_CHAIN_ID, token: ARC_USDC, spender: RELAY_APPROVAL_SPENDER, createdAt: NOW, status: "uncertain", ...overrides }; +} + +function scenario() { + const events: string[] = []; + const state = { current: { ...REQUEST } as ApprovalRequest | null, approval: null as TrackedApproval | null, chainId: 5042, address: ADDRESS as Address | undefined, sends: 0 }; + const deps: ApprovalDependencies = { + now: () => NOW, + currentRequest: () => state.current, + readWallet: async () => { events.push("wallet"); return { address: state.address, chainId: state.chainId }; }, + switchChain: async (chainId) => { events.push("switch"); state.chainId = chainId; }, + prepare: async () => { events.push("prepare"); return GAS; }, + readApproval: () => state.approval, + saveApproval: (approval) => { events.push(`save:${approval.status}`); state.approval = approval; }, + removeApproval: () => { events.push("remove"); state.approval = null; }, + send: async (request, transaction) => { + events.push("send"); state.sends++; + assert.deepEqual(request, REQUEST); + assert.equal(transaction.to, ARC_USDC); + assert.equal(transaction.chainId, 5042); + assert.equal(transaction.value, 0n); + const [spender, amount] = decodeFunctionData({ abi: EXACT_APPROVAL_ABI, data: transaction.data }).args; + assert.equal(spender.toLowerCase(), RELAY_APPROVAL_SPENDER); + assert.equal(amount, 25_000_000n); + return HASH; + }, + phase: (phase) => events.push(`phase:${phase}`), + }; + return { state, events, deps }; +} + +function storageScenario() { + const values = new Map(); + const flags = { failWrite: false, failRead: false, discardWrite: false }; + const storage = { + getItem(key: string) { if (flags.failRead) throw new Error("blocked"); return values.get(key) ?? null; }, + setItem(key: string, value: string) { if (flags.failWrite) throw new Error("quota"); if (!flags.discardWrite) values.set(key, value); }, + removeItem(key: string) { values.delete(key); }, + }; + return { values, flags, storage, store: createApprovalStore(() => storage) }; +} + +test("approval is pinned to Arc USDC, Relay depository and the exact six-decimal amount", () => { + assert.deepEqual(validateApprovalMetadata({ token: ARC_USDC, spender: RELAY_APPROVAL_SPENDER, amount: REQUEST.amount }, ADDRESS), REQUEST); + const tx = exactApprovalTransaction(REQUEST); + assert.equal(tx.chainId, 5042); + assert.equal(tx.to, ARC_USDC); + assert.equal(tx.value, 0n); + const decoded = decodeFunctionData({ abi: EXACT_APPROVAL_ABI, data: tx.data }); + assert.equal(decoded.functionName, "approve"); + assert.equal(decoded.args[0].toLowerCase(), RELAY_APPROVAL_SPENDER); + assert.equal(decoded.args[1], 25_000_000n); + for (const metadata of [null, {}, { token: OTHER, spender: RELAY_APPROVAL_SPENDER, amount: REQUEST.amount }, { token: ARC_USDC, spender: OTHER, amount: REQUEST.amount }]) assert.throws(() => validateApprovalMetadata(metadata, ADDRESS)); + for (const amount of ["0", "-1", "01", "1.0", "1e6", ((1n << 256n) - 1n).toString(), "9".repeat(90)]) assert.throws(() => exactApprovalTransaction({ ...REQUEST, amount })); + const bound = (((1n << 256n) - 1n) / 10n ** 12n).toString(); + assert.doesNotThrow(() => exactApprovalTransaction({ ...REQUEST, amount: bound })); + assert.throws(() => exactApprovalTransaction({ address: `0x${"0".repeat(40)}`, amount: "1" })); +}); + +test("Base approvals pin Base USDC and retain independent chain identity", () => { + const request = { ...REQUEST, chainId: 8453 as const }; + assert.deepEqual(validateApprovalMetadata({ token: BASE_USDC, spender: RELAY_APPROVAL_SPENDER, amount: REQUEST.amount }, ADDRESS, 8453), request); + const tx = exactApprovalTransaction(request); + assert.equal(tx.chainId, 8453); + assert.equal(tx.to, BASE_USDC); + assert.equal(tx.value, 0n); + assert.equal(decodeFunctionData({ abi: EXACT_APPROVAL_ABI, data: tx.data }).args[1], 25_000_000n); + assert.notEqual(approvalRequestKey(request), approvalRequestKey(REQUEST)); + assert.throws(() => validateApprovalMetadata({ token: ARC_USDC, spender: RELAY_APPROVAL_SPENDER, amount: REQUEST.amount }, ADDRESS, 8453)); + assert.throws(() => validateApprovalMetadata({ token: BASE_USDC, spender: RELAY_APPROVAL_SPENDER, amount: REQUEST.amount }, ADDRESS, 5042)); + assert.throws(() => exactApprovalTransaction({ ...request, chainId: 4663 })); + const unlimited = ((1n << 256n) - 1n).toString(); + assert.throws(() => exactApprovalTransaction({ ...request, amount: unlimited })); + assert.throws(() => validateApprovalMetadata({ token: BASE_USDC, spender: RELAY_APPROVAL_SPENDER, amount: unlimited }, ADDRESS, 8453)); + assert.doesNotThrow(() => exactApprovalTransaction({ ...request, amount: ((1n << 256n) - 2n).toString() })); + const record = tracked({ chainId: 8453, token: BASE_USDC, approvalHash: HASH, status: "pending" }); + assert.deepEqual(parseStoredApproval(serializeApproval(record), ADDRESS), record); + assert.equal(isMatchingApprovalTransaction(record, { from: ADDRESS, to: BASE_USDC, input: tx.data, value: 0n }), true); + assert.equal(isMatchingApprovalTransaction(record, { from: ADDRESS, to: ARC_USDC, input: tx.data, value: 0n }), false); + assert.equal(reconcileApproval(record, { chainId: 8453, allowance: 25_000_000n, receipt: { transactionHash: HASH, status: "success" } }).status, "confirmed"); + assert.throws(() => reconcileApproval(record, { chainId: 5042, allowance: 25_000_000n, receipt: { transactionHash: HASH, status: "success" } })); +}); + +test("Base approval submission rechecks chain and blocks another chain's pending journal", async () => { + const item = scenario(); + const request = { ...REQUEST, chainId: 8453 as const }; + item.state.current = request; + item.deps.send = async (owner, transaction) => { + assert.equal(owner.chainId, 8453); + assert.equal(transaction.chainId, 8453); + assert.equal(transaction.to, BASE_USDC); + item.state.sends++; + return HASH; + }; + assert.equal((await submitExactApproval(request, item.deps)).kind, "sent"); + assert.equal(item.state.chainId, 8453); + assert.equal(item.state.approval?.chainId, 8453); + assert.equal(item.state.approval?.token, BASE_USDC); + item.state.current = REQUEST; + await assert.rejects(submitExactApproval(REQUEST, item.deps), /already being tracked/); + assert.equal(item.state.sends, 1); + const changed = scenario(); changed.state.current = request; + changed.deps.prepare = async () => { changed.state.current = { ...request, chainId: 5042 }; return GAS; }; + await assert.rejects(submitExactApproval(request, changed.deps), /changed/); + assert.equal(changed.state.sends, 0); +}); + +test("Base approval event recovery cannot accept an Arc token event", () => { + const approval = tracked({ chainId: 8453, token: BASE_USDC }); + const log = { address: BASE_USDC as Address, topics: encodeEventTopics({ abi: USDC_APPROVAL_EVENT, eventName: "Approval", args: { owner: ADDRESS, spender: RELAY_APPROVAL_SPENDER } }) as Hex[], data: encodeAbiParameters([{ type: "uint256" }], [25_000_000n]) }; + assert.equal(hasMatchingApprovalEvent(approval, { status: "success", logs: [log] }), true); + assert.equal(hasMatchingApprovalEvent(approval, { status: "success", logs: [{ ...log, address: ARC_USDC }] }), false); +}); + +test("approval records are account scoped and cannot be mistaken for native deposit records", () => { + const record = tracked(); + assert.deepEqual(parseStoredApproval(serializeApproval(record), ADDRESS), record); + assert.equal(approvalStorageKey(ADDRESS), `${APPROVAL_STORAGE_PREFIX}${ADDRESS.toLowerCase()}`); + assert.notEqual(approvalStorageKey(ADDRESS), `openlaunch.bridge.v1:${ADDRESS.toLowerCase()}`); + assert.equal(approvalRequestKey(null), ""); + assert.equal(approvalRequestKey(REQUEST), `${ADDRESS}:5042:25000000`); + for (const mutation of [ + { version: 2 }, { chainId: 8453 }, { token: OTHER }, { spender: OTHER }, { address: OTHER }, + { createdAt: 0 }, { createdAt: 1.2 }, { createdAt: Number.MAX_SAFE_INTEGER + 1 }, + { status: "success" }, { status: "confirmed" }, { status: "pending" }, { status: "reverted" }, { status: "insufficient" }, + { approvalHash: "0x1234" }, { approvalHash: `0x${"0".repeat(64)}` }, { amount: "0" }, + ]) assert.throws(() => parseStoredApproval(JSON.stringify({ ...record, ...mutation }), ADDRESS), /could not be read/); + for (const invalid of ["{", "null", "[]", JSON.stringify({ ...REQUEST, requestId: HASH, sourceHash: HASH })]) assert.throws(() => parseStoredApproval(invalid, ADDRESS)); +}); + +test("submission saves an uncertain journal before requesting the wallet, then preserves its own hash", async () => { + const { deps, state, events } = scenario(); + const result = await submitExactApproval(REQUEST, deps); + assert.equal(result.kind, "sent"); + assert.equal(state.approval?.approvalHash, HASH); + assert.equal(state.approval?.status, "pending"); + assert.ok(events.indexOf("save:uncertain") < events.indexOf("send")); + assert.ok(events.indexOf("send") < events.indexOf("save:pending")); + assert.equal(state.sends, 1); + assert.equal("sourceHash" in state.approval!, false); + assert.equal("requestId" in state.approval!, false); +}); + +test("switches only to Arc and rechecks the wallet, review and latest journal after async preparation", async () => { + const switched = scenario(); + switched.state.chainId = 8453; + await submitExactApproval(REQUEST, switched.deps); + assert.ok(switched.events.includes("switch")); + assert.equal(switched.state.chainId, 5042); + for (const change of [ + (state: ReturnType["state"]) => { state.address = OTHER; }, + (state: ReturnType["state"]) => { state.chainId = 8453; }, + (state: ReturnType["state"]) => { state.current = null; }, + (state: ReturnType["state"]) => { state.current = { ...REQUEST, amount: "1" }; }, + (state: ReturnType["state"]) => { state.approval = tracked(); }, + ]) { + const item = scenario(); + item.deps.prepare = async () => { change(item.state); return GAS; }; + await assert.rejects(submitExactApproval(REQUEST, item.deps)); + assert.equal(item.state.sends, 0); + } +}); + +test("invalid wallet, expired review or invalid gas never opens an approval prompt", async () => { + const invalidWallet = scenario(); invalidWallet.state.address = OTHER; + await assert.rejects(submitExactApproval(REQUEST, invalidWallet.deps)); + assert.equal(invalidWallet.state.sends, 0); + const expired = scenario(); expired.state.current = null; + await assert.rejects(submitExactApproval(REQUEST, expired.deps)); + assert.equal(expired.state.sends, 0); + const invalidGas = scenario(); invalidGas.deps.prepare = async () => ({ ...GAS, gas: 0n }); + await assert.rejects(submitExactApproval(REQUEST, invalidGas.deps), /estimate approval gas/); + assert.equal(invalidGas.state.sends, 0); +}); + +test("storage failure or non-durable writes prevent wallet requests", async () => { + for (const discardWrite of [false, true]) { + const item = scenario(); + const disk = storageScenario(); + disk.flags.failWrite = !discardWrite; disk.flags.discardWrite = discardWrite; + item.deps.readApproval = disk.store.read; + item.deps.saveApproval = disk.store.save; + item.deps.removeApproval = disk.store.remove; + await assert.rejects(submitExactApproval(REQUEST, item.deps), /No transaction was requested/); + assert.equal(item.state.sends, 0); + } +}); + +test("wallet rejection removes an unsent approval record and preserves any previous settled approval", async () => { + for (const previous of [null, tracked({ status: "confirmed", approvalHash: HASH })]) { + const item = scenario(); + item.state.approval = previous; + item.deps.send = async () => { throw { cause: { code: 4001 } }; }; + assert.deepEqual(await submitExactApproval(REQUEST, item.deps), { kind: "rejected" }); + assert.deepEqual(item.state.approval, previous); + } +}); + +test("unknown broadcast and malformed wallet hashes retain an uncertain journal and prohibit resubmission", async () => { + for (const send of [async () => { throw new Error("RPC timeout"); }, async () => "0x1234" as Hex, async () => `0x${"0".repeat(64)}` as Hex]) { + const item = scenario(); item.deps.send = send; + const result = await submitExactApproval(REQUEST, item.deps); + assert.equal(result.kind, "uncertain"); + assert.equal(item.state.approval?.status, "uncertain"); + assert.equal(item.state.approval?.approvalHash, undefined); + await assert.rejects(submitExactApproval(REQUEST, item.deps), /already being tracked/); + } +}); + +test("pending approvals block duplicates even when a different amount is requested", async () => { + const item = scenario(); item.state.approval = tracked({ amount: "1", status: "pending", approvalHash: HASH }); + await assert.rejects(submitExactApproval(REQUEST, item.deps), /already being tracked/); + assert.equal(item.state.sends, 0); + assert.equal(approvalBlocksSubmission(null), false); + for (const status of ["confirmed", "reverted", "insufficient"] as const) assert.equal(approvalBlocksSubmission(tracked({ status, approvalHash: HASH })), false); +}); + +test("only a matching successful receipt and fresh sufficient allowance confirm an approval", () => { + const pending = tracked({ status: "pending", approvalHash: HASH }); + const observation = { chainId: 5042, allowance: 25_000_000n, receipt: { transactionHash: HASH, status: "success" as const } }; + assert.equal(reconcileApproval(pending, observation).status, "confirmed"); + assert.equal(reconcileApproval(pending, { ...observation, allowance: 24_999_999n }).status, "insufficient"); + assert.equal(reconcileApproval(pending, { ...observation, receipt: { transactionHash: HASH, status: "reverted" } }).status, "reverted"); + assert.equal(reconcileApproval(pending, { chainId: 5042, allowance: 25_000_000n }).status, "pending"); + assert.equal(reconcileApproval(tracked({ status: "confirmed", approvalHash: HASH }), { chainId: 5042, allowance: 25_000_000n }).status, "pending"); + assert.equal(reconcileApproval(tracked(), observation).status, "uncertain"); + assert.throws(() => reconcileApproval(pending, { ...observation, chainId: 8453 })); + assert.throws(() => reconcileApproval(pending, { ...observation, allowance: -1n })); + assert.throws(() => reconcileApproval(pending, { ...observation, receipt: { transactionHash: OTHER_HASH, status: "success" } })); +}); + +test("manual hash recovery requires exact owner, USDC target, zero value and canonical approval calldata", () => { + const approval = tracked(); + const transaction = { from: ADDRESS, to: ARC_USDC as Address | null, value: 0n, input: exactApprovalTransaction(REQUEST).data }; + assert.equal(isMatchingApprovalTransaction(approval, transaction), true); + for (const mutation of [ + { from: OTHER }, { to: OTHER }, { to: null }, { value: 1n }, + { input: exactApprovalTransaction({ ...REQUEST, amount: "1" }).data }, + { input: `${transaction.input}00` as Hex }, + { input: transaction.input.replace(RELAY_APPROVAL_SPENDER.slice(2), OTHER.slice(2)) as Hex }, + ]) assert.equal(isMatchingApprovalTransaction(approval, { ...transaction, ...mutation }), false); +}); + +test("smart-wallet recovery binds successful USDC Approval logs to exact owner, spender and six-decimal amount", () => { + const approval = tracked(); + const log = { + address: ARC_USDC as Address, + topics: encodeEventTopics({ abi: USDC_APPROVAL_EVENT, eventName: "Approval", args: { owner: ADDRESS, spender: RELAY_APPROVAL_SPENDER } }) as Hex[], + data: encodeAbiParameters([{ type: "uint256" }], [25_000_000n]), + }; + assert.equal(hasMatchingApprovalEvent(approval, { status: "success", logs: [log] }), true); + assert.equal(hasMatchingApprovalEvent(approval, { status: "reverted", logs: [log] }), false); + for (const mutation of [ + { address: OTHER }, { topics: [] }, { topics: [...log.topics, HASH] }, + { topics: encodeEventTopics({ abi: USDC_APPROVAL_EVENT, eventName: "Approval", args: { owner: OTHER, spender: RELAY_APPROVAL_SPENDER } }) as Hex[] }, + { topics: encodeEventTopics({ abi: USDC_APPROVAL_EVENT, eventName: "Approval", args: { owner: ADDRESS, spender: OTHER } }) as Hex[] }, + { data: encodeAbiParameters([{ type: "uint256" }], [1n]) }, { data: "0x1234" as Hex }, { data: `${log.data}00` as Hex }, + ]) assert.equal(hasMatchingApprovalEvent(approval, { status: "success", logs: [{ ...log, ...mutation }] }), false); +}); + +test("post-send storage failure retains the returned approval hash in memory and unknown recovery on disk", async () => { + const item = scenario(); + const disk = storageScenario(); + item.deps.readApproval = disk.store.read; item.deps.saveApproval = disk.store.save; item.deps.removeApproval = disk.store.remove; + item.deps.send = async () => { disk.flags.failWrite = true; return HASH; }; + const result = await submitExactApproval(REQUEST, item.deps); + assert.equal(result.kind, "sent"); + assert.equal(disk.store.getSnapshot().approvals[ADDRESS]?.approvalHash, HASH); + assert.equal(disk.store.read(ADDRESS)?.approvalHash, HASH); + const reloaded = createApprovalStore(() => disk.storage); + assert.equal(reloaded.read(ADDRESS)?.status, "uncertain"); + assert.equal(reloaded.read(ADDRESS)?.approvalHash, undefined); + assert.equal(approvalBlocksSubmission(reloaded.read(ADDRESS)), true); +}); + +test("store is reactive, retains recovery on read errors, and rejects corrupt records without losing other wallets", () => { + const disk = storageScenario(); + let updates = 0; + const unsubscribe = disk.store.subscribe(() => { updates++; }); + assert.deepEqual(disk.store.getServerSnapshot(), { approvals: {}, errors: {} }); + disk.store.save(tracked()); + disk.store.save(tracked({ address: OTHER })); + assert.equal(updates, 2); + disk.flags.failRead = true; + assert.throws(() => disk.store.read(ADDRESS), /unavailable or unreadable/); + assert.equal(disk.store.getSnapshot().approvals[ADDRESS]?.amount, REQUEST.amount); + assert.ok(disk.store.getSnapshot().errors[ADDRESS]); + disk.flags.failRead = false; + disk.values.set(approvalStorageKey(ADDRESS), "bad JSON"); + assert.throws(() => disk.store.read(ADDRESS)); + assert.equal(disk.store.read(OTHER)?.address, OTHER); + disk.store.remove(ADDRESS); + assert.equal(disk.store.read(ADDRESS), null); + unsubscribe(); +}); + +test("approval workflow requires a new reviewed quote and never dispatches a deposit", async () => { + const item = scenario(); + await submitExactApproval(REQUEST, item.deps); + const confirmed = reconcileApproval(item.state.approval!, { chainId: 5042, allowance: 25_000_000n, receipt: { transactionHash: HASH, status: "success" } }); + assert.equal(confirmed.status, "confirmed"); + assert.equal(item.state.sends, 1); + assert.equal(item.events.filter((event) => event === "send").length, 1); +}); diff --git a/app/src/lib/bridge/approval.ts b/app/src/lib/bridge/approval.ts new file mode 100644 index 0000000..9c8bb28 --- /dev/null +++ b/app/src/lib/bridge/approval.ts @@ -0,0 +1,275 @@ +import { decodeEventLog, encodeFunctionData, isAddress, type Address, type Hex } from "viem"; +import { bridgeCurrency, isBridgeChainId, type BridgeChainId } from "./types"; + +// Independently pinned: provider calldata never chooses the token or spender. +// https://docs.arc.io/arc/references/contract-addresses +// https://docs.relay.link/references/protocol/contracts/evm-depository +export const ARC_APPROVAL_CHAIN_ID = 5042 as const; +export const ARC_USDC = "0x3600000000000000000000000000000000000000" as const; +export const RELAY_APPROVAL_SPENDER = "0x4cd00e387622c35bddb9b4c962c136462338bc31" as const; +export const EXACT_APPROVAL_ABI = [{ type: "function", name: "approve", stateMutability: "nonpayable", inputs: [{ name: "spender", type: "address" }, { name: "amount", type: "uint256" }], outputs: [{ name: "", type: "bool" }] }] as const; +export const USDC_APPROVAL_EVENT = [{ type: "event", name: "Approval", inputs: [{ name: "owner", type: "address", indexed: true }, { name: "spender", type: "address", indexed: true }, { name: "value", type: "uint256", indexed: false }] }] as const; +export const APPROVAL_STORAGE_PREFIX = "openlaunch.bridge.approval.v1:"; +const MAX_UINT = (1n << 256n) - 1n; +// The corresponding native balance uses 18 decimals; never overflow that value. +const MAX_APPROVAL_AMOUNT = MAX_UINT / 10n ** 12n; +const HASH = /^0x[0-9a-fA-F]{64}$/; +const STATUSES = ["uncertain", "pending", "confirmed", "reverted", "insufficient"] as const; + +export type ApprovalRequest = { address: Address; amount: string; chainId?: BridgeChainId }; // Omitted chain is legacy Arc; amounts are USDC units, 6 decimals. +export type ApprovalStatus = typeof STATUSES[number]; +export type TrackedApproval = ApprovalRequest & { + version: 1; + chainId: BridgeChainId; + token: Address; + spender: typeof RELAY_APPROVAL_SPENDER; + createdAt: number; + status: ApprovalStatus; + approvalHash?: Hex; +}; +export type ApprovalTransaction = { chainId: BridgeChainId; to: Address; data: Hex; value: 0n }; +export type ApprovalGas = { gas: bigint; maxFeePerGas: bigint; maxPriorityFeePerGas: bigint }; + +function validHash(value: unknown): value is Hex { + return typeof value === "string" && HASH.test(value) && !/^0x0{64}$/i.test(value); +} + +function validAmount(value: unknown): value is string { + return typeof value === "string" && /^[1-9][0-9]{0,77}$/.test(value) && BigInt(value) < MAX_UINT; +} + +export function approvalToken(chainId: BridgeChainId = ARC_APPROVAL_CHAIN_ID): Address { + if (!isBridgeChainId(chainId) || (chainId !== 8453 && chainId !== 5042)) throw new Error("USDC approvals are not supported on this network."); + return bridgeCurrency(chainId, "USDC", "input").address; +} + +function validateRequest(value: ApprovalRequest): ApprovalRequest { + if (!value || typeof value.address !== "string" || !isAddress(value.address, { strict: false }) || /^0x0{40}$/i.test(value.address) || !validAmount(value.amount)) { + throw new Error("The USDC approval does not match a valid wallet and exact amount."); + } + const chainId = value.chainId ?? ARC_APPROVAL_CHAIN_ID; + approvalToken(chainId); + if (chainId === ARC_APPROVAL_CHAIN_ID && BigInt(value.amount) > MAX_APPROVAL_AMOUNT) throw new Error("The USDC approval amount exceeds Arc's native balance range."); + return { address: value.address, amount: value.amount, ...(value.chainId !== undefined ? { chainId } : {}) }; +} + +/** Validate provider metadata before adapting a quote to this fixed approval flow. */ +export function validateApprovalMetadata(value: unknown, address: Address, chainId?: BridgeChainId): ApprovalRequest { + const metadata = value as { token?: unknown; spender?: unknown; amount?: unknown } | null; + if (!metadata || typeof metadata.token !== "string" || metadata.token.toLowerCase() !== approvalToken(chainId).toLowerCase() || typeof metadata.spender !== "string" || metadata.spender.toLowerCase() !== RELAY_APPROVAL_SPENDER || !validAmount(metadata.amount)) { + throw new Error("The bridge requested an unsupported token, spender, or approval amount."); + } + return validateRequest({ address, amount: metadata.amount, ...(chainId !== undefined ? { chainId } : {}) }); +} + +export function approvalRequestKey(request: ApprovalRequest | null): string { + return request ? `${request.address.toLowerCase()}:${request.chainId ?? ARC_APPROVAL_CHAIN_ID}:${request.amount}` : ""; +} + +export const approvalStorageKey = (address: Address) => `${APPROVAL_STORAGE_PREFIX}${address.toLowerCase()}`; + +export function approvalBlocksSubmission(approval: TrackedApproval | null): boolean { + return !!approval && (approval.status === "uncertain" || approval.status === "pending"); +} + +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)] }) }; +} + +/** Also bind candidate chain and block time to the journal in the RPC caller. */ +export function isMatchingApprovalTransaction(approval: TrackedApproval, transaction: { from: Address; to: Address | null; input: Hex; value: bigint }): boolean { + try { + const checked = parseStoredApproval(serializeApproval(approval), approval.address); + return transaction.from.toLowerCase() === checked.address.toLowerCase() && transaction.to?.toLowerCase() === checked.token.toLowerCase() && transaction.value === 0n && transaction.input.toLowerCase() === exactApprovalTransaction(checked).data.toLowerCase(); + } catch { return false; } +} + +/** Smart-wallet recovery uses the canonical USDC event, never a router's log. */ +export function hasMatchingApprovalEvent(approval: TrackedApproval, receipt: { status: "success" | "reverted"; logs: readonly { address: Address; data: Hex; topics: readonly Hex[] }[] }): boolean { + if (receipt.status !== "success") return false; + try { + const checked = parseStoredApproval(serializeApproval(approval), approval.address); + return receipt.logs.some((log) => { + if (log.address.toLowerCase() !== checked.token.toLowerCase() || log.topics.length !== 3 || !log.topics.every((topic) => HASH.test(topic)) || !/^0x[0-9a-fA-F]{64}$/.test(log.data)) return false; + try { + const event = decodeEventLog({ abi: USDC_APPROVAL_EVENT, data: log.data, topics: [log.topics[0], ...log.topics.slice(1)], strict: true }); + return event.args.owner.toLowerCase() === checked.address.toLowerCase() && event.args.spender.toLowerCase() === RELAY_APPROVAL_SPENDER && event.args.value.toString() === checked.amount; + } catch { return false; } + }); + } catch { return false; } +} + +export function parseStoredApproval(raw: string, address: Address): TrackedApproval { + try { + const entry = JSON.parse(raw) as TrackedApproval; + const request = validateRequest(entry); + if (entry.version !== 1 || request.address.toLowerCase() !== address.toLowerCase() || !isBridgeChainId(entry.chainId) || typeof entry.token !== "string" || entry.token.toLowerCase() !== approvalToken(entry.chainId).toLowerCase() || entry.spender !== RELAY_APPROVAL_SPENDER || !Number.isSafeInteger(entry.createdAt) || entry.createdAt <= 0 || !STATUSES.includes(entry.status) || (entry.approvalHash !== undefined && !validHash(entry.approvalHash)) || (entry.status !== "uncertain" && !entry.approvalHash)) throw new Error("invalid record"); + return { ...request, version: 1, chainId: entry.chainId, token: approvalToken(entry.chainId), spender: RELAY_APPROVAL_SPENDER, createdAt: entry.createdAt, status: entry.status, ...(entry.approvalHash ? { approvalHash: entry.approvalHash } : {}) }; + } catch { throw new Error("Saved USDC approval recovery data could not be read. Check the approval before trying again."); } +} + +export function serializeApproval(approval: TrackedApproval): string { + return JSON.stringify(parseStoredApproval(JSON.stringify(approval), approval.address)); +} + +export type ApprovalObservation = { + chainId: number; + allowance: bigint; + receipt?: { transactionHash: Hex; status: "success" | "reverted" } | null; +}; + +/** + * Call with a freshly read allowance and receipt on the saved chain, including after reload. + * A saved confirmation is never sufficient authority to deposit. An allowance + * alone cannot resolve an unknown broadcast, so it never enables an automatic retry. + */ +export function reconcileApproval(approval: TrackedApproval, observation: ApprovalObservation): TrackedApproval { + const checked = parseStoredApproval(serializeApproval(approval), approval.address); + if (observation.chainId !== checked.chainId || typeof observation.allowance !== "bigint" || observation.allowance < 0n || observation.allowance > MAX_UINT) throw new Error("Could not verify the approval on its source network. Check again before continuing."); + if (!checked.approvalHash) return { ...checked, status: "uncertain" }; + if (!observation.receipt) return { ...checked, status: "pending" }; + if (!validHash(observation.receipt.transactionHash) || observation.receipt.transactionHash.toLowerCase() !== checked.approvalHash.toLowerCase() || !["success", "reverted"].includes(observation.receipt.status)) throw new Error("The approval receipt did not match the saved transaction."); + return { ...checked, status: observation.receipt.status === "reverted" ? "reverted" : observation.allowance >= BigInt(checked.amount) ? "confirmed" : "insufficient" }; +} + +type StorageAdapter = Pick; +export type ApprovalSnapshot = { approvals: Record; errors: Record }; +const EMPTY: ApprovalSnapshot = { approvals: {}, errors: {} }; + +/** Separate from native/deposit recovery. Call mutating operations under the wallet Web Lock. */ +export function createApprovalStore(storage: () => StorageAdapter) { + let snapshot = EMPTY; + const listeners = new Set<() => void>(); + function update(address: Address, approval: TrackedApproval | null, error: string | null) { + const key = address.toLowerCase(); + snapshot = { approvals: { ...snapshot.approvals, [key]: approval }, errors: { ...snapshot.errors, [key]: error } }; + for (const notify of listeners) notify(); + } + return { + subscribe(notify: () => void) { listeners.add(notify); return () => { listeners.delete(notify); }; }, + getSnapshot: () => snapshot, + getServerSnapshot: () => EMPTY, + remember(approval: TrackedApproval) { update(approval.address, parseStoredApproval(serializeApproval(approval), approval.address), snapshot.errors[approval.address.toLowerCase()] ?? null); }, + read(address: Address): TrackedApproval | null { + try { + const raw = storage().getItem(approvalStorageKey(address)); + let approval = raw === null ? null : parseStoredApproval(raw, address); + const memory = snapshot.approvals[address.toLowerCase()]; + if (approval && memory && approval.createdAt === memory.createdAt && approvalRequestKey(approval) === approvalRequestKey(memory)) { + // Retain a wallet-returned hash if the post-send storage write failed. + if (memory.approvalHash && !approval.approvalHash) approval = { ...approval, approvalHash: memory.approvalHash, status: memory.status }; + } + update(address, approval, null); + return approval; + } catch { + const message = "USDC approval recovery storage is unavailable or unreadable. Enable site storage and reload before approving."; + update(address, snapshot.approvals[address.toLowerCase()] ?? null, message); + throw new Error(message); + } + }, + save(approval: TrackedApproval) { + // Validate before changing either persistent or in-memory recovery data. + const raw = serializeApproval(approval); + try { + const adapter = storage(); + adapter.setItem(approvalStorageKey(approval.address), raw); + if (adapter.getItem(approvalStorageKey(approval.address)) !== raw) throw new Error("Storage did not retain approval"); + update(approval.address, approval, null); + } catch { + const message = "Could not save USDC approval recovery details. Keep this page open and check the approval before trying again."; + update(approval.address, approval, message); + throw new Error(message); + } + }, + remove(address: Address) { + try { storage().removeItem(approvalStorageKey(address)); update(address, null, null); } + catch { + const message = "Could not clear the saved USDC approval. Enable site storage and reload before trying again."; + update(address, snapshot.approvals[address.toLowerCase()] ?? null, message); + throw new Error(message); + } + }, + }; +} + +export type ApprovalDependencies = { + now: () => number; + // Return null for expired reviews, changed routes, or any tracked deposit. + currentRequest: () => ApprovalRequest | null; + readWallet: () => Promise<{ address?: Address; chainId?: number }>; + switchChain: (chainId: BridgeChainId) => Promise; + prepare: (request: ApprovalRequest, transaction: ApprovalTransaction) => Promise; + readApproval: (address: Address) => TrackedApproval | null; + saveApproval: (approval: TrackedApproval) => void; + removeApproval: (address: Address) => void; + send: (request: ApprovalRequest, transaction: ApprovalTransaction, gas: ApprovalGas) => Promise; + phase: (phase: "switching" | "confirming") => void; +}; +export type ApprovalResult = { kind: "sent" | "uncertain"; approval: TrackedApproval } | { kind: "rejected" }; + +function walletRejected(error: unknown): boolean { + let current = error; + const seen = new Set(); + for (let depth = 0; current && typeof current === "object" && depth < 8 && !seen.has(current); depth++) { + seen.add(current); + const value = current as { code?: unknown; cause?: unknown }; + if (value.code === 4001 || value.code === "ACTION_REJECTED") return true; + current = value.cause; + } + return false; +} + +/** + * Caller must hold the same per-wallet Web Lock as deposit submission/recovery. + * This only approves USDC. After confirmation, request and review a NEW quote; + * never automatically deposit, and never put this hash in the deposit journal. + */ +export async function submitExactApproval(input: ApprovalRequest, deps: ApprovalDependencies): Promise { + const request = validateRequest(input); + const chainId = request.chainId ?? ARC_APPROVAL_CHAIN_ID; + const transaction = exactApprovalTransaction(request); + const checkRequest = () => { + const current = deps.currentRequest(); + if (!current || approvalRequestKey(validateRequest(current)) !== approvalRequestKey(request)) throw new Error("The wallet, route, amount, or quote changed. Review a new quote before approving."); + }; + checkRequest(); + const existing = deps.readApproval(request.address); + if (approvalBlocksSubmission(existing)) throw new Error("An approval is already being tracked. Check its status before approving again."); + let wallet = await deps.readWallet(); + if (wallet.address?.toLowerCase() !== request.address.toLowerCase()) throw new Error("The connected wallet changed. Review a new quote."); + if (wallet.chainId !== chainId) { deps.phase("switching"); await deps.switchChain(chainId); } + checkRequest(); + const gas = await deps.prepare(request, transaction); + if (typeof gas.gas !== "bigint" || gas.gas <= 0n || typeof gas.maxFeePerGas !== "bigint" || gas.maxFeePerGas <= 0n || typeof gas.maxPriorityFeePerGas !== "bigint" || gas.maxPriorityFeePerGas < 0n || gas.maxPriorityFeePerGas > gas.maxFeePerGas) throw new Error("Could not estimate approval gas. Request a new quote."); + wallet = await deps.readWallet(); + checkRequest(); + if (wallet.address?.toLowerCase() !== request.address.toLowerCase() || wallet.chainId !== chainId) throw new Error("Your wallet account or network changed. Review a new quote."); + const latest = deps.readApproval(request.address); + if (approvalBlocksSubmission(latest)) throw new Error("Another approval is now being tracked. Check it before continuing."); + const createdAt = Math.max(deps.now(), (latest?.createdAt ?? 0) + 1); + const tracked: TrackedApproval = { ...request, version: 1, chainId, token: approvalToken(chainId), spender: RELAY_APPROVAL_SPENDER, createdAt, status: "uncertain" }; + serializeApproval(tracked); + const restore = () => { if (latest) deps.saveApproval(latest); else deps.removeApproval(request.address); }; + try { deps.saveApproval(tracked); } + catch { + try { restore(); } catch { /* retain the storage warning; no wallet call happened */ } + throw new Error("Could not save approval recovery details. No transaction was requested. Enable site storage before trying again."); + } + deps.phase("confirming"); + let hash: Hex; + try { + hash = await deps.send(request, transaction, gas); + if (!validHash(hash)) throw new Error("Wallet returned no approval hash"); + } catch (error) { + if (walletRejected(error)) { + const current = deps.readApproval(request.address); + if (current?.createdAt === tracked.createdAt && approvalRequestKey(current) === approvalRequestKey(tracked)) restore(); + return { kind: "rejected" }; + } + return { kind: "uncertain", approval: tracked }; + } + const sent: TrackedApproval = { ...tracked, approvalHash: hash, status: "pending" }; + try { deps.saveApproval(sent); } catch { /* durable pre-send record prevents duplicate approval */ } + return { kind: "sent", approval: sent }; +} diff --git a/app/src/lib/bridge/chains.ts b/app/src/lib/bridge/chains.ts new file mode 100644 index 0000000..63e7e9a --- /dev/null +++ b/app/src/lib/bridge/chains.ts @@ -0,0 +1,18 @@ +import { defineChain, type Chain } from "viem"; +import { CHAINS } from "../chainPublic"; +import type { BridgeChainId } from "./types"; + +/** Wallet/RPC registration for bridging only; Arc is not a launchpad network. */ +export const arc = defineChain({ + id: 5042, + name: "Arc", + nativeCurrency: { name: "USDC", symbol: "USDC", decimals: 18 }, + rpcUrls: { default: { http: ["https://rpc.mainnet.arc.io"] } }, + blockExplorers: { default: { name: "Arc Explorer", url: "https://explorer.arc.io" } }, +}); + +export const BRIDGE_WALLET_CHAINS: Record = { + 8453: CHAINS.base, + 4663: CHAINS.robinhood, + 5042: arc, +}; diff --git a/app/src/lib/bridge/client-chains.test.ts b/app/src/lib/bridge/client-chains.test.ts new file mode 100644 index 0000000..02c0dda --- /dev/null +++ b/app/src/lib/bridge/client-chains.test.ts @@ -0,0 +1,40 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import { CHAINS, CHAIN_KEYS } from "../chainPublic"; +import { arc, BRIDGE_WALLET_CHAINS } from "./chains"; +import { BRIDGE_CHAIN_IDS, BRIDGE_CHAINS } from "./types"; + +test("every bridge source has a matching wallet chain without extending launch networks", () => { + for (const id of BRIDGE_CHAIN_IDS) { + const walletChain = BRIDGE_WALLET_CHAINS[id]; + assert.equal(walletChain.id, id); + assert.equal(walletChain.nativeCurrency.symbol, BRIDGE_CHAINS[id].symbol); + assert.equal(walletChain.nativeCurrency.decimals, 18); + assert.ok(walletChain.rpcUrls.default.http.length > 0); + } + assert.deepEqual(CHAIN_KEYS, ["base", "robinhood"]); + assert.equal(Object.hasOwn(CHAINS, "arc"), false); + assert.equal(arc.rpcUrls.default.http[0], "https://rpc.mainnet.arc.io"); + assert.equal(arc.blockExplorers.default.url, "https://explorer.arc.io"); + assert.equal(arc.nativeCurrency.symbol, "USDC"); + assert.notEqual(arc.id, 5042002); +}); + +test("wallet config registers Arc for switching and native-balance reads", () => { + const config = readFileSync(new URL("../wagmi.ts", import.meta.url), "utf8"); + assert.match(config, /chains:\s*\[CHAINS\.base,\s*robinhood,\s*arc\]/); + assert.match(config, /\[arc\.id\]:\s*http\(arc\.rpcUrls\.default\.http\[0\]/); + const hook = readFileSync(new URL("../../components/bridge/useBridge.ts", import.meta.url), "utf8"); + assert.match(hook, /chain:\s*BRIDGE_WALLET_CHAINS\[q\.originChainId\]/); + assert.doesNotMatch(hook, /CHAINS\[BRIDGE_CHAINS/); +}); + +test("approval polling waits for a hash and restarts when the same journal gains one", () => { + const hook = readFileSync(new URL("../../components/bridge/useBridge.ts", import.meta.url), "utf8"); + assert.match(hook, /const approvalHash = approval\?\.approvalHash;/); + assert.match(hook, /if \(!address \|\| !approvalId \|\| !approvalHash\) return;/); + assert.match(hook, /\[address, approvalId, approvalHash, approvalPollRevision, config\]/); + assert.match(hook, /observed\.approvalHash !== approvalHash\) return;/); + assert.match(hook, /pub\.getTransactionReceipt\(\{ hash: approvalHash \}\)/); +}); diff --git a/app/src/lib/bridge/client-storage.ts b/app/src/lib/bridge/client-storage.ts new file mode 100644 index 0000000..41f0742 --- /dev/null +++ b/app/src/lib/bridge/client-storage.ts @@ -0,0 +1,69 @@ +import type { Address } from "viem"; +import { parseStoredTransfer, serializeTransfer, transferIsTerminal, type TrackedBridgeTransfer } from "./client"; + +export const BRIDGE_STORAGE_PREFIX = "openlaunch.bridge.v1:"; +type StorageAdapter = Pick; +export type TransferSnapshot = { transfers: Record; errors: Record }; +const EMPTY: TransferSnapshot = { transfers: {}, errors: {} }; +export const bridgeStorageKey = (address: Address) => `${BRIDGE_STORAGE_PREFIX}${address.toLowerCase()}`; + +/** Injectable storage adapter, with stable snapshots for useSyncExternalStore. */ +export function createBridgeTransferStore(storage: () => StorageAdapter) { + let snapshot = EMPTY; + const listeners = new Set<() => void>(); + function update(address: Address, transfer: TrackedBridgeTransfer | null, error: string | null) { + const key = address.toLowerCase(); + snapshot = { transfers: { ...snapshot.transfers, [key]: transfer }, errors: { ...snapshot.errors, [key]: error } }; + for (const notify of listeners) notify(); + } + return { + subscribe(notify: () => void) { listeners.add(notify); return () => { listeners.delete(notify); }; }, + getSnapshot: () => snapshot, + getServerSnapshot: () => EMPTY, + remember(transfer: TrackedBridgeTransfer) { + update(transfer.address, transfer, snapshot.errors[transfer.address.toLowerCase()] ?? null); + }, + read(address: Address): TrackedBridgeTransfer | null { + try { + const raw = storage().getItem(bridgeStorageKey(address)); + let transfer = raw === null ? null : parseStoredTransfer(raw, address); + const memory = snapshot.transfers[address.toLowerCase()]; + if (transfer && memory?.requestId === transfer.requestId) { + // The pre-send record can be older than memory if the subsequent + // source-hash write failed. Do not discard that known transaction. + const advanced = transferIsTerminal(memory) || (!transferIsTerminal(transfer) && memory.sourceHash && !transfer.sourceHash); + transfer = { ...transfer, ...(advanced ? { status: memory.status, ...(memory.failureReason ? { failureReason: memory.failureReason } : {}) } : {}), sourceHash: memory.sourceHash ?? transfer.sourceHash, destinationHashes: [...new Set([...transfer.destinationHashes, ...memory.destinationHashes])] }; + } + update(address, transfer, null); + return transfer; + } catch { + const message = "Bridge recovery storage is unavailable or unreadable. Enable site storage and reload before starting a transfer."; + update(address, snapshot.transfers[address.toLowerCase()] ?? null, message); + throw new Error(message); + } + }, + save(transfer: TrackedBridgeTransfer) { + try { + const raw = serializeTransfer(transfer); + const adapter = storage(); + adapter.setItem(bridgeStorageKey(transfer.address), raw); + if (adapter.getItem(bridgeStorageKey(transfer.address)) !== raw) throw new Error("Storage did not retain transfer"); + update(transfer.address, transfer, null); + } catch { + const message = "Could not save bridge recovery details. Keep this page open and check this transfer before trying again."; + update(transfer.address, transfer, message); + throw new Error(message); + } + }, + remove(address: Address) { + try { + storage().removeItem(bridgeStorageKey(address)); + update(address, null, null); + } catch { + const message = "Could not clear the saved bridge record. Enable site storage and reload before trying again."; + update(address, snapshot.transfers[address.toLowerCase()] ?? null, message); + throw new Error(message); + } + }, + }; +} diff --git a/app/src/lib/bridge/client.test.ts b/app/src/lib/bridge/client.test.ts new file mode 100644 index 0000000..456f617 --- /dev/null +++ b/app/src/lib/bridge/client.test.ts @@ -0,0 +1,500 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { encodeAbiParameters, encodeEventTopics, encodeFunctionData, type Address, type Hex } from "viem"; +import { bridgeGasBudget, bridgeRequest, bridgeRequestKey, changeBridgeRoute, ERC20_DEPOSIT_ABI, ERC20_DEPOSIT_EVENT, hasMatchingDepositEvent, isMatchingSourceDeposit, isWalletRejection, mergeBridgeStatus, nativeSourceAmount, NATIVE_DEPOSIT_ABI, NATIVE_DEPOSIT_EVENT, parseBridgeAmount, parseStoredTransfer, RELAY_DEPOSITORY, serializeTransfer, submitBridgeDeposit, transferIsTerminal, validateBridgeQuote, validateBridgeStatus, type DepositDependencies, type TrackedBridgeTransfer } from "./client"; +import { bridgeStorageKey, createBridgeTransferStore } from "./client-storage"; +import { ARC_USDC, BASE_USDC, BRIDGE_CHAIN_IDS, type BridgeQuote, type BridgeQuoteRequest } from "./types"; + +const ADDRESS = "0x1111111111111111111111111111111111111111" as Address; +const OTHER = "0x2222222222222222222222222222222222222222" as Address; +const REQUEST = `0x${"a".repeat(64)}` as Hex; +const ORDER = `0x${"b".repeat(64)}` as Hex; +const HASH = `0x${"c".repeat(64)}` as Hex; +const DEST_HASH = `0x${"d".repeat(64)}` as Hex; +const NOW = 1_800_000_000_000; +const amount = "10000000000000000"; + +function quote(): BridgeQuote { + return { + address: ADDRESS, originChainId: 8453, destinationChainId: 4663, amount, + requestId: REQUEST, amountOut: "9800000000000000", minimumAmountOut: "9700000000000000", + relayFee: "0.0002", sourceGas: "0.00001", totalImpactPercent: "-2", timeEstimate: 15, expiresAt: NOW + 45_000, + transaction: { to: RELAY_DEPOSITORY, data: encodeFunctionData({ abi: NATIVE_DEPOSIT_ABI, functionName: "depositNative", args: [ADDRESS, ORDER] }), value: amount, chainId: 8453 }, + }; +} + +function tracked(): TrackedBridgeTransfer { + return { address: ADDRESS, originChainId: 8453, destinationChainId: 4663, amount, requestId: REQUEST, destinationHashes: [], status: "uncertain", createdAt: NOW, depositData: quote().transaction.data }; +} + +function arcQuote(destinationChainId: 8453 | 4663 = 8453): BridgeQuote { + const amount = "25000000"; + return { ...quote(), originChainId: 5042, destinationChainId, amount, relayFee: "0.05", sourceGas: "0.001", amountOut: "9000000000000000", minimumAmountOut: "8990000000000000", approval: { token: ARC_USDC, spender: RELAY_DEPOSITORY, amount }, transaction: { chainId: 5042, to: RELAY_DEPOSITORY, value: "0", data: encodeFunctionData({ abi: ERC20_DEPOSIT_ABI, functionName: "depositErc20", args: [ADDRESS, ARC_USDC, BigInt(amount), ORDER] }) } }; +} + +function baseUsdcQuote(): BridgeQuote { + const q = arcQuote(); + return { ...q, originChainId: 8453, destinationChainId: 5042, originAsset: "USDC", destinationAsset: "USDC", amountOut: "24950000000000000000", minimumAmountOut: "24900000000000000000", sourceGas: "0.00001", approval: { ...q.approval!, token: BASE_USDC }, transaction: { ...q.transaction, chainId: 8453, data: encodeFunctionData({ abi: ERC20_DEPOSIT_ABI, functionName: "depositErc20", args: [ADDRESS, BASE_USDC, BigInt(q.amount), ORDER] }) } }; +} + +function scenario(q = quote()) { + const events: string[] = []; + const state = { now: NOW, current: { address: q.address, originChainId: q.originChainId, destinationChainId: q.destinationChainId, amount: q.amount, ...(q.originAsset ? { originAsset: q.originAsset } : {}), ...(q.destinationAsset ? { destinationAsset: q.destinationAsset } : {}) } as BridgeQuoteRequest | null, transfer: null as TrackedBridgeTransfer | null, chainId: 8453, walletAddress: ADDRESS as Address | undefined, sends: 0 }; + const deps: DepositDependencies = { + now: () => state.now, + currentRequest: () => state.current, + readWallet: async () => { events.push("wallet"); return { address: state.walletAddress, chainId: state.chainId }; }, + switchChain: async (id) => { events.push("switch"); state.chainId = id; }, + prepare: async () => { events.push("prepare"); return { gas: 100_000n, maxFeePerGas: 2n, maxPriorityFeePerGas: 1n }; }, + readTransfer: () => state.transfer, + saveTransfer: (next) => { events.push(`save:${next.status}`); state.transfer = next; }, + removeTransfer: () => { events.push("remove"); state.transfer = null; }, + send: async () => { events.push("send"); state.sends++; return HASH; }, + phase: (phase) => events.push(`phase:${phase}`), + }; + return { q, deps, events, state }; +} + +test("amount parsing preserves integer precision and never rounds extra decimal places", () => { + assert.equal(parseBridgeAmount(" 0.010000000000000001 "), 10_000_000_000_000_001n); + assert.equal(parseBridgeAmount(".000000000000000001"), 1n); + assert.equal(parseBridgeAmount("1."), 10n ** 18n); + for (const bad of ["", "0", "0.0", "-1", "+1", "1e-3", "Infinity", "NaN", "1,000", "1.2.3", "0.0000000000000000001", "1.0000000000000000000", "9".repeat(80)]) assert.equal(parseBridgeAmount(bad), null, bad); + assert.equal(bridgeRequest(undefined, 8453, "1", 4663), null); + assert.equal(bridgeRequest(ADDRESS, 4663, "1", 8453)?.destinationChainId, 8453); + assert.equal(bridgeRequestKey(bridgeRequest(ADDRESS, 8453, "0.01", 4663)), bridgeRequestKey(bridgeRequest(ADDRESS, 8453, ".0100", 4663))); +}); + +test("all six routes use the input currency precision, never native decimals for Arc ERC20", () => { + for (const origin of BRIDGE_CHAIN_IDS) for (const destination of BRIDGE_CHAIN_IDS) { + const request = bridgeRequest(ADDRESS, origin, origin === 5042 ? "1.000001" : "1.000000000000000001", destination); + if (origin === destination) assert.equal(request, null); + else { + assert.equal(request?.amount, origin === 5042 ? "1000001" : "1000000000000000001"); + assert.equal(request?.destinationChainId, destination); + } + } + assert.equal(bridgeRequest(ADDRESS, 5042, "1.0000001", 8453), null); + assert.equal(parseBridgeAmount("0.000001", 6), 1n); + assert.equal(parseBridgeAmount("0.0000001", 6), null); + assert.equal(nativeSourceAmount(arcQuote()), 25n * 10n ** 18n); + assert.notEqual(bridgeRequestKey(bridgeRequest(ADDRESS, 8453, "1", 4663)), bridgeRequestKey(bridgeRequest(ADDRESS, 8453, "1", 5042))); +}); + +test("network selections resolve collisions atomically and never reinterpret ETH amounts as USDC", () => { + const initial = { originChainId: 8453, destinationChainId: 4663, amount: "0.01" } as const; + const toArc = changeBridgeRoute(initial, { side: "destination", chainId: 5042 }); + assert.deepEqual(toArc, { ...initial, destinationChainId: 5042 }); + assert.deepEqual(changeBridgeRoute(initial, { side: "reverse" }), { originChainId: 4663, destinationChainId: 8453, amount: "0.01" }); + assert.deepEqual(changeBridgeRoute(initial, { side: "origin", chainId: 4663 }), { originChainId: 4663, destinationChainId: 8453, amount: "0.01" }); + assert.deepEqual(changeBridgeRoute(toArc, { side: "origin", chainId: 5042 }), { originChainId: 5042, destinationChainId: 8453, amount: "" }); + assert.deepEqual(changeBridgeRoute(toArc, { side: "reverse" }), { originChainId: 5042, destinationChainId: 8453, amount: "" }); + const fromArc = { originChainId: 5042, destinationChainId: 8453, amount: "25" } as const; + assert.deepEqual(changeBridgeRoute(fromArc, { side: "destination", chainId: 5042 }), { originChainId: 8453, destinationChainId: 5042, amount: "" }); + assert.deepEqual(changeBridgeRoute(fromArc, { side: "destination", chainId: 4663 }), { ...fromArc, destinationChainId: 4663 }); + assert.deepEqual(changeBridgeRoute(fromArc, { side: "origin", chainId: 4663 }), { originChainId: 4663, destinationChainId: 8453, amount: "" }); +}); + +test("asset-aware requests keep ETH and six-decimal USDC distinct and reject unsupported assets", () => { + const usdc = bridgeRequest(ADDRESS, 8453, "25.000001", 5042, "USDC", "USDC"); + assert.equal(usdc?.amount, "25000001"); + assert.equal(usdc?.originAsset, "USDC"); + assert.equal(bridgeRequest(ADDRESS, 8453, "1.0000001", 5042, "USDC", "USDC"), null); + assert.equal(bridgeRequest(ADDRESS, 8453, "1", 4663, "USDC", "USDC"), null); + assert.equal(bridgeRequest(ADDRESS, 4663, "1", 8453, "USDC", "ETH"), null); + assert.equal(bridgeRequest(ADDRESS, 5042, "1", 8453, "ETH", "USDC"), null); + const legacy = bridgeRequest(ADDRESS, 8453, "25", 5042)!; + assert.equal(legacy.amount, "25000000000000000000"); + assert.equal(bridgeRequestKey(legacy), bridgeRequestKey({ ...legacy, originAsset: "ETH", destinationAsset: "USDC" })); + assert.notEqual(bridgeRequestKey(legacy), bridgeRequestKey({ ...legacy, originAsset: "USDC" })); + const fromRh = bridgeRequest(ADDRESS, 4663, "1", 8453)!; + assert.notEqual(bridgeRequestKey(fromRh), bridgeRequestKey({ ...fromRh, destinationAsset: "USDC" })); +}); + +test("asset selectors clear reinterpreted amounts and preserve supported tokens across chain changes", () => { + const route = { originChainId: 8453, destinationChainId: 5042, originAsset: "ETH", destinationAsset: "USDC", amount: "1" } as const; + const usdc = changeBridgeRoute(route, { side: "origin-asset", asset: "USDC" }); + assert.deepEqual(usdc, { ...route, originAsset: "USDC", amount: "" }); + const funded = { ...usdc, amount: "25" }; + assert.deepEqual(changeBridgeRoute(funded, { side: "reverse" }), { ...funded, originChainId: 5042, destinationChainId: 8453 }); + assert.deepEqual(changeBridgeRoute(funded, { side: "origin", chainId: 4663 }), { ...funded, originChainId: 4663, originAsset: "ETH", amount: "" }); + assert.deepEqual(changeBridgeRoute(route, { side: "destination-asset", asset: "ETH" }), route); +}); + +test("Base USDC uses exact pinned ERC20 deposits while reserving separate ETH gas", () => { + const q = baseUsdcQuote(); + assert.equal(validateBridgeQuote(q, q, NOW), q); + assert.equal(nativeSourceAmount(q), 0n); + assert.equal(nativeSourceAmount(quote()), BigInt(amount)); + assert.equal(nativeSourceAmount(arcQuote()), 25n * 10n ** 18n); + assert.deepEqual(bridgeGasBudget(0n, 300n, 100n, 2n, 50n), { gas: 120n, reserve: 300n }); + assert.throws(() => bridgeGasBudget(0n, 299n, 100n, 2n, 50n), /Not enough ETH/); + for (const changed of [ + { ...q, originAsset: "ETH" as const }, { ...q, approval: undefined }, + { ...q, approval: { ...q.approval!, token: ARC_USDC } }, + { ...q, transaction: { ...q.transaction, value: q.amount } }, + { ...q, transaction: { ...q.transaction, data: arcQuote().transaction.data } }, + ]) assert.throws(() => validateBridgeQuote(changed, q, NOW)); + const unlimited = ((1n << 256n) - 1n).toString(); + const unlimitedQuote: BridgeQuote = { ...q, amount: unlimited, approval: { ...q.approval!, amount: unlimited }, transaction: { ...q.transaction, data: encodeFunctionData({ abi: ERC20_DEPOSIT_ABI, functionName: "depositErc20", args: [ADDRESS, BASE_USDC, BigInt(unlimited), ORDER] }) } }; + assert.throws(() => validateBridgeQuote(unlimitedQuote, unlimitedQuote, NOW), /Unlimited USDC approvals/); +}); + +test("asset changes across awaited preflight cancel a reviewed deposit", async () => { + const s = scenario(baseUsdcQuote()); + s.deps.prepare = async () => { s.state.current = { ...s.state.current!, originAsset: "ETH" }; return { gas: 100_000n, maxFeePerGas: 2n, maxPriorityFeePerGas: 1n }; }; + await assert.rejects(submitBridgeDeposit(s.q, s.deps), /changed/); + assert.equal(s.state.sends, 0); +}); + +test("version-three journals preserve asset choices without reinterpreting legacy native transfers", async () => { + const s = scenario(baseUsdcQuote()); + await submitBridgeDeposit(s.q, s.deps); + const saved = s.state.transfer!; + assert.equal(JSON.parse(serializeTransfer(saved)).version, 3); + assert.deepEqual(parseStoredTransfer(serializeTransfer(saved), ADDRESS), saved); + assert.equal(saved.originAsset, "USDC"); + const oneExplicit = { ...saved, destinationAsset: undefined }; + assert.equal(parseStoredTransfer(serializeTransfer(oneExplicit), ADDRESS).destinationAsset, "USDC"); + for (const mutation of [{ version: 1 }, { version: 2 }, { originAsset: "ETH" }, { originAsset: undefined }, { destinationAsset: undefined }, { depositKind: undefined }, { depositData: arcQuote().transaction.data }]) assert.throws(() => parseStoredTransfer(JSON.stringify({ ...saved, version: 3, ...mutation }), ADDRESS)); + const legacy = tracked(); + const restored = parseStoredTransfer(serializeTransfer(legacy), ADDRESS); + assert.equal(restored.originAsset, undefined); + assert.equal(restored.amount, amount); + assert.throws(() => parseStoredTransfer(JSON.stringify({ ...legacy, version: 1, originAsset: "USDC" }), ADDRESS)); +}); + +test("Base USDC recovery rejects Arc tokens and appended event data", () => { + const q = baseUsdcQuote(); + const transfer: TrackedBridgeTransfer = { ...tracked(), originAsset: "USDC", destinationAsset: "USDC", destinationChainId: 5042, amount: q.amount, depositKind: "erc20", depositData: q.transaction.data }; + const log = { address: RELAY_DEPOSITORY, topics: encodeEventTopics({ abi: ERC20_DEPOSIT_EVENT, eventName: "RelayErc20Deposit" }) as Hex[], data: encodeAbiParameters([{ type: "address" }, { type: "address" }, { type: "uint256" }, { type: "bytes32" }], [ADDRESS, BASE_USDC, BigInt(q.amount), ORDER]) }; + assert.equal(hasMatchingDepositEvent(transfer, { status: "success", logs: [log] }), true); + assert.equal(hasMatchingDepositEvent(transfer, { status: "success", logs: [{ ...log, data: `${log.data}00` }] }), false); + const wrongToken = encodeAbiParameters([{ type: "address" }, { type: "address" }, { type: "uint256" }, { type: "bytes32" }], [ADDRESS, ARC_USDC, BigInt(q.amount), ORDER]); + assert.equal(hasMatchingDepositEvent(transfer, { status: "success", logs: [{ ...log, data: wrongToken }] }), false); +}); + +test("quote permits only native ETH deposit bound to reviewed wallet, amount and chain", () => { + const q = quote(); + assert.equal(validateBridgeQuote(q, q, NOW), q); + // Relay's protocol orderId and status requestId are distinct identifiers. + assert.notEqual(ORDER, REQUEST); + const altered: BridgeQuote[] = [ + { ...q, address: OTHER }, { ...q, amount: "2" }, { ...q, destinationChainId: 8453 }, + { ...q, transaction: { ...q.transaction, chainId: 4663 } }, + { ...q, transaction: { ...q.transaction, value: "2" } }, + { ...q, transaction: { ...q.transaction, to: OTHER } }, + { ...q, transaction: { ...q.transaction, data: "0x" } }, + { ...q, transaction: { ...q.transaction, data: `${q.transaction.data}00` } }, + { ...q, transaction: { ...q.transaction, data: encodeFunctionData({ abi: NATIVE_DEPOSIT_ABI, functionName: "depositNative", args: [OTHER, ORDER] }) } }, + { ...q, transaction: { ...q.transaction, data: encodeFunctionData({ abi: NATIVE_DEPOSIT_ABI, functionName: "depositNative", args: [ADDRESS, `0x${"0".repeat(64)}`] }) } }, + { ...q, expiresAt: NOW }, { ...q, expiresAt: NOW + 121_000 }, + { ...q, minimumAmountOut: "999999999999999999" }, { ...q, amountOut: "-1" }, + ]; + for (const changed of altered) assert.throws(() => validateBridgeQuote(changed, q, NOW)); +}); + +test("ETH/USDC quote amounts are not compared as if they were the same currency", () => { + const base = quote(); + const toArc: BridgeQuote = { ...base, destinationChainId: 5042, amountOut: "24000000000000000000", minimumAmountOut: "23900000000000000000" }; + assert.equal(validateBridgeQuote(toArc, toArc, NOW), toArc); + const fromArc = arcQuote(4663); + assert.equal(validateBridgeQuote(fromArc, fromArc, NOW), fromArc); + assert.throws(() => validateBridgeQuote({ ...toArc, destinationChainId: 5042002 }, toArc, NOW), /route/); +}); + +test("Arc only accepts pinned six-decimal USDC with exact approval and four-argument deposit", () => { + for (const destination of [8453, 4663] as const) { + const q = arcQuote(destination); + assert.equal(validateBridgeQuote(q, q, NOW), q); + for (const changed of [ + { ...q, approval: undefined }, + { ...q, approval: { ...q.approval!, token: OTHER } }, + { ...q, approval: { ...q.approval!, spender: OTHER } }, + { ...q, approval: { ...q.approval!, amount: "999999999999999999999999" } }, + { ...q, relayFee: "0.0000001" }, + { ...q, transaction: { ...q.transaction, value: q.amount } }, + { ...q, transaction: { ...q.transaction, to: OTHER } }, + { ...q, transaction: { ...q.transaction, data: quote().transaction.data } }, + { ...q, transaction: { ...q.transaction, data: `${q.transaction.data}00` } }, + { ...q, transaction: { ...q.transaction, data: encodeFunctionData({ abi: ERC20_DEPOSIT_ABI, functionName: "depositErc20", args: [OTHER, ARC_USDC, BigInt(q.amount), ORDER] }) } }, + { ...q, transaction: { ...q.transaction, data: encodeFunctionData({ abi: ERC20_DEPOSIT_ABI, functionName: "depositErc20", args: [ADDRESS, OTHER, BigInt(q.amount), ORDER] }) } }, + { ...q, transaction: { ...q.transaction, data: encodeFunctionData({ abi: ERC20_DEPOSIT_ABI, functionName: "depositErc20", args: [ADDRESS, ARC_USDC, 1n, ORDER] }) } }, + ]) assert.throws(() => validateBridgeQuote(changed, q, NOW)); + } + assert.throws(() => validateBridgeQuote({ ...quote(), approval: arcQuote().approval }, quote(), NOW), /approval/); +}); + +test("Arc deposits journal version two separately from approval and preserve exact calldata", async () => { + const s = scenario(arcQuote()); + const result = await submitBridgeDeposit(s.q, s.deps); + assert.equal(result.kind, "sent"); + const transfer = s.state.transfer!; + assert.equal(transfer.depositKind, "erc20"); + assert.equal(transfer.amount, "25000000"); + assert.equal(JSON.parse(serializeTransfer(transfer)).version, 2); + assert.deepEqual(parseStoredTransfer(serializeTransfer(transfer), ADDRESS), transfer); + assert.equal(isMatchingSourceDeposit(transfer, { from: ADDRESS, to: RELAY_DEPOSITORY, input: s.q.transaction.data, value: 0n }), true); + assert.equal(isMatchingSourceDeposit(transfer, { from: ADDRESS, to: RELAY_DEPOSITORY, input: s.q.transaction.data, value: BigInt(s.q.amount) }), false); + assert.throws(() => parseStoredTransfer(JSON.stringify({ ...transfer, version: 1 }), ADDRESS)); + assert.throws(() => parseStoredTransfer(serializeTransfer({ ...transfer, amount: "1" }), ADDRESS)); +}); + +test("Arc smart-wallet recovery requires pinned ERC20 event amount, wallet and order", () => { + const q = arcQuote(); + const transfer: TrackedBridgeTransfer = { ...tracked(), originChainId: 5042, destinationChainId: 8453, amount: q.amount, depositKind: "erc20", depositData: q.transaction.data }; + const log = { address: RELAY_DEPOSITORY, topics: encodeEventTopics({ abi: ERC20_DEPOSIT_EVENT, eventName: "RelayErc20Deposit" }) as Hex[], data: encodeAbiParameters([{ type: "address" }, { type: "address" }, { type: "uint256" }, { type: "bytes32" }], [ADDRESS, ARC_USDC, BigInt(q.amount), ORDER]) }; + assert.equal(hasMatchingDepositEvent(transfer, { status: "success", logs: [log] }), true); + for (const args of [[OTHER, ARC_USDC, BigInt(q.amount), ORDER], [ADDRESS, OTHER, BigInt(q.amount), ORDER], [ADDRESS, ARC_USDC, 1n, ORDER], [ADDRESS, ARC_USDC, BigInt(q.amount), REQUEST]] as const) { + const changed = { ...log, data: encodeAbiParameters([{ type: "address" }, { type: "address" }, { type: "uint256" }, { type: "bytes32" }], args) }; + assert.equal(hasMatchingDepositEvent(transfer, { status: "success", logs: [changed] }), false); + } + assert.equal(hasMatchingDepositEvent(transfer, { status: "reverted", logs: [log] }), false); +}); + +test("fee and total-impact guards reject excessive loss, nonnumeric amounts and relayer fees above 5%", () => { + const q = quote(); + for (const impact of ["-5", "0", "1.25", "100"]) assert.doesNotThrow(() => validateBridgeQuote({ ...q, totalImpactPercent: impact }, q, NOW)); + for (const impact of ["-5.000000000000000001", "-6", "101", "NaN", "Infinity", "1e2", "", "--1"]) assert.throws(() => validateBridgeQuote({ ...q, totalImpactPercent: impact }, q, NOW), /impact/); + assert.doesNotThrow(() => validateBridgeQuote({ ...q, relayFee: "0.0005" }, q, NOW)); + for (const relayFee of ["0.000500000000000001", "0.01", "0.02", "-1", "1e-3", "Infinity", "9".repeat(80)]) assert.throws(() => validateBridgeQuote({ ...q, relayFee }, q, NOW), /fee/); + assert.throws(() => validateBridgeQuote({ ...q, sourceGas: "9".repeat(80) }, q, NOW), /fee/); + assert.throws(() => validateBridgeQuote({ ...q, totalImpactPercent: undefined }, q, NOW), /impact/); +}); + +test("gas preflight reserves fresh fees and rejects spending the full native balance", () => { + const budget = bridgeGasBudget(1000n, 10_000n, 100n, 2n, 50n); + assert.deepEqual(budget, { gas: 120n, reserve: 300n }); + assert.throws(() => bridgeGasBudget(1000n, 1299n, 100n, 2n, 50n), /Not enough ETH/); + assert.throws(() => bridgeGasBudget(1000n, 1000n, 100n, 2n), /Not enough ETH/); + assert.throws(() => bridgeGasBudget(1000n, 10000n, 0n, 2n), /estimate/); + assert.throws(() => bridgeGasBudget(1000n, 1000n, 100n, 2n, 0n, "USDC"), /Not enough USDC/); +}); + +test("submission persists recovery before wallet send and binds the returned source hash", async () => { + const { q, deps, events, state } = scenario(); + const result = await submitBridgeDeposit(q, deps); + assert.equal(result.kind, "sent"); + assert.equal(state.sends, 1); + assert.ok(events.indexOf("save:uncertain") < events.indexOf("send")); + assert.equal(state.transfer?.sourceHash, HASH); + assert.equal(state.transfer?.status, "pending"); + assert.deepEqual(state.transfer?.destinationHashes, []); +}); + +test("chain switching is explicit and wallet account/network are rechecked after preflight", async () => { + const switched = scenario(); + switched.state.chainId = 4663; + await submitBridgeDeposit(switched.q, switched.deps); + assert.ok(switched.events.indexOf("switch") < switched.events.indexOf("prepare")); + assert.equal(switched.events.filter((event) => event === "wallet").length, 2); + + for (const change of ["account", "chain"] as const) { + const s = scenario(); + s.deps.prepare = async () => { + if (change === "account") s.state.walletAddress = OTHER; + else s.state.chainId = 4663; + return { gas: 1n, maxFeePerGas: 2n, maxPriorityFeePerGas: 1n }; + }; + await assert.rejects(submitBridgeDeposit(s.q, s.deps), /account or network changed/); + assert.equal(s.state.sends, 0); + assert.equal(s.state.transfer, null); + } +}); + +test("account/input edits and quote expiration during awaits cancel before any wallet prompt", async () => { + for (const change of ["account", "amount", "destination", "expiry"] as const) { + const s = scenario(); + s.deps.prepare = async () => { + if (change === "account") s.state.current = bridgeRequest(OTHER, 8453, "0.01", 4663); + else if (change === "amount") s.state.current = bridgeRequest(ADDRESS, 8453, "0.02", 4663); + else if (change === "destination") s.state.current = bridgeRequest(ADDRESS, 8453, "0.01", 5042); + else s.state.now = s.q.expiresAt; + return { gas: 1n, maxFeePerGas: 2n, maxPriorityFeePerGas: 1n }; + }; + await assert.rejects(submitBridgeDeposit(s.q, s.deps), /changed|expired/); + assert.equal(s.state.sends, 0); + assert.equal(s.state.transfer, null); + } +}); + +test("existing and newly observed pending records prevent a second deposit", async () => { + const existing = scenario(); + existing.state.transfer = tracked(); + await assert.rejects(submitBridgeDeposit(existing.q, existing.deps), /already being tracked/); + assert.deepEqual(existing.events, []); + const during = scenario(); + during.deps.prepare = async () => { + during.state.transfer = tracked(); + return { gas: 1n, maxFeePerGas: 2n, maxPriorityFeePerGas: 1n }; + }; + await assert.rejects(submitBridgeDeposit(during.q, during.deps), /now being tracked/); + assert.equal(during.state.sends, 0); +}); + +test("failed preflight or durable storage never requests a signature", async () => { + const preflight = scenario(); + preflight.deps.prepare = async () => { throw new Error("RPC unavailable"); }; + await assert.rejects(submitBridgeDeposit(preflight.q, preflight.deps), /RPC unavailable/); + assert.equal(preflight.state.sends, 0); + const storage = scenario(); + storage.deps.saveTransfer = () => { throw new Error("quota exceeded"); }; + await assert.rejects(submitBridgeDeposit(storage.q, storage.deps), /No transaction was requested/); + assert.equal(storage.state.sends, 0); + assert.equal(storage.state.transfer, null); +}); + +test("only explicit wallet rejection clears the unsent record", async () => { + const s = scenario(); + s.deps.send = async () => { throw { cause: { code: 4001 } }; }; + const result = await submitBridgeDeposit(s.q, s.deps); + assert.equal(result.kind, "rejected"); + assert.equal(s.state.transfer, null); + assert.equal(isWalletRejection({ code: "ACTION_REJECTED" }), true); + assert.equal(isWalletRejection(new Error("User rejected? Network unavailable")), false); + const cyclic: { cause?: unknown } = {}; + cyclic.cause = cyclic; + assert.equal(isWalletRejection(cyclic), false); +}); + +test("ambiguous submission remains recoverable without hash and cannot be resubmitted", async () => { + const s = scenario(); + s.deps.send = async () => { s.state.sends++; throw new Error("RPC timeout after broadcast"); }; + const result = await submitBridgeDeposit(s.q, s.deps); + assert.equal(result.kind, "uncertain"); + assert.equal(s.state.transfer?.status, "uncertain"); + assert.equal(s.state.transfer?.sourceHash, undefined); + await assert.rejects(submitBridgeDeposit(s.q, s.deps), /already being tracked/); + assert.equal(s.state.sends, 1); + assert.equal(parseStoredTransfer(serializeTransfer(s.state.transfer!), ADDRESS).requestId, REQUEST); +}); + +test("a missing wallet hash is uncertain, and a post-send storage failure does not initiate another send", async () => { + const missing = scenario(); + missing.deps.send = async () => "0x"; + assert.equal((await submitBridgeDeposit(missing.q, missing.deps)).kind, "uncertain"); + const after = scenario(); + const save = after.deps.saveTransfer; + after.deps.saveTransfer = (next) => { + if (next.sourceHash) throw new Error("storage became unavailable"); + save(next); + }; + assert.equal((await submitBridgeDeposit(after.q, after.deps)).kind, "sent"); + assert.equal(after.state.sends, 1); + assert.equal(after.state.transfer?.status, "uncertain"); +}); + +test("provider status alone settles success/refund, while waiting cannot resolve an ambiguous send", () => { + const unknown = tracked(); + assert.equal(mergeBridgeStatus(unknown, { status: "waiting", inTxHashes: [], txHashes: [] }).status, "uncertain"); + const pending = mergeBridgeStatus({ ...unknown, sourceHash: HASH }, { status: "pending", inTxHashes: [HASH], txHashes: [] }); + assert.equal(pending.sourceHash, HASH); + assert.equal(transferIsTerminal(pending), false); + for (const status of ["success", "refund", "failure"] as const) { + const final = mergeBridgeStatus(pending, { status, inTxHashes: [HASH], txHashes: [DEST_HASH] }); + assert.equal(transferIsTerminal(final), true); + assert.deepEqual(final.destinationHashes, [DEST_HASH]); + assert.equal(mergeBridgeStatus(final, { status: "pending", inTxHashes: [], txHashes: [] }), final); + } + assert.throws(() => validateBridgeStatus({ status: "failed-network-request", inTxHashes: [], txHashes: [] })); + assert.throws(() => validateBridgeStatus({ status: "success", inTxHashes: ["not-a-hash"], txHashes: [] })); + assert.throws(() => mergeBridgeStatus(pending, { status: "success", inTxHashes: [DEST_HASH], txHashes: [HASH] }), /not yet been matched/); + assert.throws(() => mergeBridgeStatus(unknown, { status: "success", inTxHashes: [HASH], txHashes: [DEST_HASH] }), /not yet been matched/); + assert.throws(() => mergeBridgeStatus(pending, { status: "refund", inTxHashes: [], txHashes: [] }), /not yet been matched/); +}); + +test("recovering an unknown source hash requires the exact recorded native deposit on-chain", () => { + const transfer = tracked(); + const transaction = { from: ADDRESS, to: RELAY_DEPOSITORY, value: BigInt(amount), input: quote().transaction.data }; + assert.equal(isMatchingSourceDeposit(transfer, transaction), true); + assert.equal(isMatchingSourceDeposit(transfer, { ...transaction, from: OTHER }), false); + assert.equal(isMatchingSourceDeposit(transfer, { ...transaction, to: OTHER }), false); + assert.equal(isMatchingSourceDeposit(transfer, { ...transaction, value: 1n }), false); + assert.equal(isMatchingSourceDeposit(transfer, { ...transaction, input: "0x" }), false); + assert.equal(isMatchingSourceDeposit({ ...transfer, depositData: undefined }, transaction), false); +}); + +test("smart-wallet recovery binds the canonical deposit event to wallet, amount and protocol order", () => { + const transfer = tracked(); + const log = { + address: RELAY_DEPOSITORY, + topics: encodeEventTopics({ abi: NATIVE_DEPOSIT_EVENT, eventName: "RelayNativeDeposit" }) as Hex[], + data: encodeAbiParameters([{ type: "address" }, { type: "uint256" }, { type: "bytes32" }], [ADDRESS, BigInt(amount), ORDER]), + }; + assert.equal(hasMatchingDepositEvent(transfer, { status: "success", logs: [log] }), true); + assert.equal(hasMatchingDepositEvent(transfer, { status: "reverted", logs: [log] }), false); + assert.equal(hasMatchingDepositEvent(transfer, { status: "success", logs: [{ ...log, address: OTHER }] }), false); + assert.equal(hasMatchingDepositEvent({ ...transfer, address: OTHER }, { status: "success", logs: [log] }), false); + assert.equal(hasMatchingDepositEvent({ ...transfer, amount: "1" }, { status: "success", logs: [log] }), false); + const wrongOrder = { ...log, data: encodeAbiParameters([{ type: "address" }, { type: "uint256" }, { type: "bytes32" }], [ADDRESS, BigInt(amount), REQUEST]) }; + assert.equal(hasMatchingDepositEvent(transfer, { status: "success", logs: [wrongOrder] }), false); +}); + +function memoryStorage() { + const values = new Map(); + return { values, getItem: (key: string) => values.get(key) ?? null, setItem: (key: string, value: string) => { values.set(key, value); }, removeItem: (key: string) => { values.delete(key); } }; +} + +test("transfer storage restores per wallet including an unknown transaction hash", () => { + const storage = memoryStorage(); + const store = createBridgeTransferStore(() => storage); + const transfer = tracked(); + store.save(transfer); + const restored = createBridgeTransferStore(() => storage); + assert.deepEqual(restored.read(ADDRESS), { ...transfer, sourceHash: undefined }); + assert.equal(restored.read(OTHER), null); + assert.equal(restored.getServerSnapshot().transfers[ADDRESS.toLowerCase()], undefined); + store.remove(ADDRESS); + assert.equal(restored.read(ADDRESS), null); +}); + +test("version-one recovery keeps old Base/Robinhood records and accepts every distinct Arc route", () => { + for (const originChainId of BRIDGE_CHAIN_IDS) for (const destinationChainId of BRIDGE_CHAIN_IDS) { + if (originChainId === destinationChainId) continue; + const transfer = { ...tracked(), originChainId, destinationChainId }; + assert.deepEqual(parseStoredTransfer(serializeTransfer(transfer), ADDRESS), { ...transfer, sourceHash: undefined }); + } +}); + +test("corrupt or wallet-mismatched records fail closed, never silently resetting pending funds", () => { + const storage = memoryStorage(); + const store = createBridgeTransferStore(() => storage); + storage.setItem(bridgeStorageKey(ADDRESS), "not-json"); + assert.throws(() => store.read(ADDRESS), /storage is unavailable or unreadable/); + storage.setItem(bridgeStorageKey(ADDRESS), serializeTransfer({ ...tracked(), address: OTHER })); + assert.throws(() => store.read(ADDRESS)); + assert.throws(() => parseStoredTransfer(serializeTransfer({ ...tracked(), amount: "-1" }), ADDRESS)); + assert.throws(() => parseStoredTransfer(serializeTransfer({ ...tracked(), destinationChainId: 8453 }), ADDRESS)); +}); + +test("storage writes must be durable; a later failure retains recovery details in memory", () => { + const noop = createBridgeTransferStore(() => ({ getItem: () => null, setItem: () => {}, removeItem: () => {} })); + assert.throws(() => noop.save(tracked()), /Could not save/); + assert.equal(noop.getSnapshot().transfers[ADDRESS.toLowerCase()]?.requestId, REQUEST); + const blocked = createBridgeTransferStore(() => { throw new Error("blocked"); }); + assert.throws(() => blocked.read(ADDRESS)); + assert.throws(() => blocked.save({ ...tracked(), sourceHash: HASH })); + assert.equal(blocked.getSnapshot().transfers[ADDRESS.toLowerCase()]?.sourceHash, HASH); + assert.match(blocked.getSnapshot().errors[ADDRESS.toLowerCase()] ?? "", /Keep this page open/); +}); + +test("fresh storage reads preserve an in-memory source hash without overwriting a newer request", () => { + const storage = memoryStorage(); + const store = createBridgeTransferStore(() => storage); + const initial = tracked(); + store.save(initial); + store.remember({ ...initial, sourceHash: HASH, status: "pending" }); + assert.equal(store.read(ADDRESS)?.sourceHash, HASH); + assert.equal(store.read(ADDRESS)?.status, "pending"); + storage.setItem(bridgeStorageKey(ADDRESS), serializeTransfer({ ...initial, requestId: ORDER })); + assert.equal(store.read(ADDRESS)?.requestId, ORDER); + assert.equal(store.read(ADDRESS)?.sourceHash, undefined); +}); + +test("confirmed source-revert recovery survives a reload without claiming a provider refund", () => { + const transfer: TrackedBridgeTransfer = { ...tracked(), sourceHash: HASH, status: "failure", failureReason: "source-reverted" }; + const restored = parseStoredTransfer(serializeTransfer(transfer), ADDRESS); + assert.equal(restored.failureReason, "source-reverted"); + assert.equal(restored.status, "failure"); + assert.equal(transferIsTerminal(restored), true); +}); diff --git a/app/src/lib/bridge/client.ts b/app/src/lib/bridge/client.ts new file mode 100644 index 0000000..fc772c3 --- /dev/null +++ b/app/src/lib/bridge/client.ts @@ -0,0 +1,322 @@ +import { decodeEventLog, decodeFunctionData, encodeFunctionData, isAddress, parseUnits, type Address, type Hex } from "viem"; +import { bridgeCurrency, defaultBridgeAsset, isBridgeAssetSupported, isBridgeChainId, type BridgeAsset, type BridgeChainId, type BridgeQuote, type BridgeQuoteRequest, type BridgeStatus, type BridgeStatusResponse } from "./types"; + +export type BridgePhase = "idle" | "quoting" | "review" | "switching" | "confirming" | "pending" | "success" | "refund" | "failure" | "uncertain"; +export type TrackedBridgeTransfer = BridgeQuoteRequest & { + requestId: Hex; + sourceHash?: Hex; + destinationHashes: Hex[]; + status: BridgeStatus | "uncertain"; + createdAt: number; + depositData?: Hex; + depositKind?: "erc20"; + failureReason?: "source-reverted"; +}; + +// Relay's explicit native deposit contract. This is deliberately independent of +// the server adapter: a response cannot turn the wallet request into an approval. +export const RELAY_DEPOSITORY = "0x4cd00e387622c35bddb9b4c962c136462338bc31" as const; +export const NATIVE_DEPOSIT_ABI = [{ type: "function", name: "depositNative", stateMutability: "payable", inputs: [{ name: "depositor", type: "address" }, { name: "orderId", type: "bytes32" }], outputs: [] }] as const; +// Only the exact-amount overload is supported, never the full-allowance overload. +export const ERC20_DEPOSIT_ABI = [{ type: "function", name: "depositErc20", stateMutability: "nonpayable", inputs: [{ name: "depositor", type: "address" }, { name: "token", type: "address" }, { name: "amount", type: "uint256" }, { name: "orderId", type: "bytes32" }], outputs: [] }] as const; +// https://docs.relay.link/references/protocol/contracts/evm-depository +export const NATIVE_DEPOSIT_EVENT = [{ type: "event", name: "RelayNativeDeposit", inputs: [{ name: "from", type: "address", indexed: false }, { name: "amount", type: "uint256", indexed: false }, { name: "id", type: "bytes32", indexed: false }] }] as const; +export const ERC20_DEPOSIT_EVENT = [{ type: "event", name: "RelayErc20Deposit", inputs: [{ name: "from", type: "address", indexed: false }, { name: "token", type: "address", indexed: false }, { name: "amount", type: "uint256", indexed: false }, { name: "id", type: "bytes32", indexed: false }] }] as const; +const HASH = /^0x[0-9a-fA-F]{64}$/; +const UINT = /^(0|[1-9]\d*)$/; +const MAX_UINT = (1n << 256n) - 1n; +const STATUSES: readonly BridgeStatus[] = ["waiting", "depositing", "pending", "submitted", "delayed", "success", "refund", "failure"]; + +export function isHash(value: unknown): value is Hex { + return typeof value === "string" && HASH.test(value); +} + +function isWei(value: unknown, positive = false): value is string { + return typeof value === "string" && value.length <= 78 && UINT.test(value) && BigInt(value) <= MAX_UINT && (!positive || BigInt(value) > 0n); +} + +/** Strict decimal parsing: never round sub-wei digits or accept exponent syntax. */ +export function parseBridgeAmount(value: string, decimals: 6 | 18 = 18): bigint | null { + const text = value.trim(); + if (text.length > 100 || !new RegExp(`^(?:\\d+(?:\\.\\d{0,${decimals}})?|\\.\\d{1,${decimals}})$`).test(text)) return null; + try { + const amount = parseUnits(text, decimals); + return amount > 0n && amount <= MAX_UINT ? amount : null; + } catch { return null; } +} + +export function bridgeRequest(address: Address | undefined, originChainId: BridgeChainId, amount: string, destinationChainId: BridgeChainId, originAsset?: BridgeAsset, destinationAsset?: BridgeAsset): BridgeQuoteRequest | null { + if (!address || !isAddress(address) || !isBridgeChainId(originChainId) || !isBridgeChainId(destinationChainId) || originChainId === destinationChainId || !isBridgeAssetSupported(originChainId, originAsset ?? defaultBridgeAsset(originChainId)) || !isBridgeAssetSupported(destinationChainId, destinationAsset ?? defaultBridgeAsset(destinationChainId))) return null; + const units = parseBridgeAmount(amount, bridgeCurrency(originChainId, originAsset, "input").decimals); + return units === null ? null : { address, originChainId, destinationChainId, amount: units.toString(), ...(originAsset ? { originAsset } : {}), ...(destinationAsset ? { destinationAsset } : {}) }; +} + +export type BridgeRouteInputs = { originChainId: BridgeChainId; destinationChainId: BridgeChainId; amount: string; originAsset?: BridgeAsset; destinationAsset?: BridgeAsset }; +export type BridgeRouteChange = { side: "origin" | "destination"; chainId: BridgeChainId } | { side: "origin-asset" | "destination-asset"; asset: BridgeAsset } | { side: "reverse" }; + +/** Select/swap one distinct pair, clearing currency amounts when the source asset changes. */ +export function changeBridgeRoute(current: BridgeRouteInputs, change: BridgeRouteChange): BridgeRouteInputs { + let { originChainId, destinationChainId } = current; + let originAsset = current.originAsset ?? defaultBridgeAsset(originChainId); + let destinationAsset = current.destinationAsset ?? defaultBridgeAsset(destinationChainId); + const previousOriginAsset = originAsset; + const explicitAssets = current.originAsset !== undefined || current.destinationAsset !== undefined || change.side === "origin-asset" || change.side === "destination-asset"; + if (change.side === "reverse") { + [originChainId, destinationChainId] = [destinationChainId, originChainId]; + [originAsset, destinationAsset] = [destinationAsset, originAsset]; + } else if (change.side === "origin-asset" || change.side === "destination-asset") { + const chainId = change.side === "origin-asset" ? originChainId : destinationChainId; + if (!isBridgeAssetSupported(chainId, change.asset)) return current; + if (change.side === "origin-asset") originAsset = change.asset; + else destinationAsset = change.asset; + } else if ("chainId" in change) { + if (!isBridgeChainId(change.chainId)) return current; + if (change.side === "origin") { + if (change.chainId === destinationChainId) { destinationChainId = originChainId; [originAsset, destinationAsset] = [destinationAsset, originAsset]; } + originChainId = change.chainId; + } else { + if (change.chainId === originChainId) { originChainId = destinationChainId; [originAsset, destinationAsset] = [destinationAsset, originAsset]; } + destinationChainId = change.chainId; + } + } + if (!isBridgeAssetSupported(originChainId, originAsset)) originAsset = defaultBridgeAsset(originChainId); + if (!isBridgeAssetSupported(destinationChainId, destinationAsset)) destinationAsset = defaultBridgeAsset(destinationChainId); + const amount = previousOriginAsset === originAsset ? current.amount : ""; + return { originChainId, destinationChainId, amount, ...(explicitAssets ? { originAsset, destinationAsset } : {}) }; +} + +export function bridgeRequestKey(request: BridgeQuoteRequest | null): string { + return request ? `${request.address.toLowerCase()}:${request.originChainId}:${request.originAsset ?? defaultBridgeAsset(request.originChainId)}:${request.destinationChainId}:${request.destinationAsset ?? defaultBridgeAsset(request.destinationChainId)}:${request.amount}` : ""; +} + +/** Arc's ERC-20 interface and native gas asset share one USDC balance. */ +export function nativeSourceAmount(request: BridgeQuoteRequest): bigint { + const currency = bridgeCurrency(request.originChainId, request.originAsset, "input"); + return request.originChainId === 5042 ? BigInt(request.amount) * 10n ** 12n : /^0x0{40}$/.test(currency.address) ? BigInt(request.amount) : 0n; +} + +function validImpactPercent(value: unknown): boolean { + if (typeof value !== "string" || value.length > 32 || !/^-?\d+(?:\.\d+)?$/.test(value)) return false; + const negative = value.startsWith("-"); + const [whole, fractional = ""] = (negative ? value.slice(1) : value).split("."); + const scaled = BigInt(whole + fractional); + const scale = 10n ** BigInt(fractional.length); + return scaled <= (negative ? 5n : 100n) * scale; +} + +export function validateBridgeQuote(value: unknown, request: BridgeQuoteRequest, now: number): BridgeQuote { + if (!value || typeof value !== "object") throw new Error("The bridge returned an invalid quote. Request a new quote."); + const quote = value as BridgeQuote; + if (!isAddress(quote.address ?? "") || !isBridgeChainId(quote.originChainId) || !isBridgeChainId(quote.destinationChainId) || !isBridgeAssetSupported(quote.originChainId, quote.originAsset ?? defaultBridgeAsset(quote.originChainId)) || !isBridgeAssetSupported(quote.destinationChainId, quote.destinationAsset ?? defaultBridgeAsset(quote.destinationChainId)) || bridgeRequestKey(quote) !== bridgeRequestKey(request) || quote.destinationChainId === quote.originChainId) { + throw new Error("The quote no longer matches this wallet, amount, or route. Review a new quote."); + } + if (!isHash(quote.requestId) || !isWei(quote.amount, true) || !isWei(quote.amountOut, true) || !isWei(quote.minimumAmountOut, true) || BigInt(quote.minimumAmountOut) > BigInt(quote.amountOut)) { + throw new Error("The bridge returned invalid transfer amounts."); + } + if (!Number.isFinite(quote.expiresAt) || quote.expiresAt <= now || quote.expiresAt > now + 120_000) throw new Error("This quote expired. Request a new quote and review it before continuing."); + const inputCurrency = bridgeCurrency(request.originChainId, request.originAsset, "input"); + const inputDecimals = inputCurrency.decimals; + if (!Number.isFinite(quote.timeEstimate) || quote.timeEstimate < 0 || ![[quote.relayFee, inputDecimals], [quote.sourceGas, 18]].every(([fee, decimals]) => typeof fee === "string" && new RegExp(`^\\d+(?:\\.\\d{1,${decimals}})?$`).test(fee) && fee.length <= 100 && parseUnits(fee, Number(decimals)) <= MAX_UINT)) { + throw new Error("The bridge returned invalid fee details."); + } + if (parseUnits(quote.relayFee, inputDecimals) * 100n > BigInt(quote.amount) * 5n) { + throw new Error("The relay fee exceeds the 5% limit. Try a different amount."); + } + if (!validImpactPercent(quote.totalImpactPercent)) { + throw new Error("The quote's total impact is unavailable or exceeds the 5% loss limit. Try a different amount."); + } + const tx = quote.transaction; + const erc20 = !/^0x0{40}$/.test(inputCurrency.address); + if (erc20 && BigInt(request.amount) === MAX_UINT) throw new Error("Unlimited USDC approvals are not supported. Enter an exact transfer amount."); + if (erc20 ? !quote.approval || quote.approval.token?.toLowerCase() !== inputCurrency.address.toLowerCase() || quote.approval.spender?.toLowerCase() !== RELAY_DEPOSITORY || quote.approval.amount !== request.amount : quote.approval !== undefined) { + throw new Error("The approval did not match the exact USDC deposit on this network."); + } + if (!tx || tx.chainId !== request.originChainId || typeof tx.to !== "string" || tx.to.toLowerCase() !== RELAY_DEPOSITORY || tx.value !== (erc20 ? "0" : request.amount) || typeof tx.data !== "string" || !new RegExp(`^0x[0-9a-fA-F]{${erc20 ? 264 : 136}}$`).test(tx.data)) { + throw new Error("The bridge transaction did not pass the deposit safety check."); + } + try { + const orderId = erc20 ? decodeFunctionData({ abi: ERC20_DEPOSIT_ABI, data: tx.data }).args[3] : decodeFunctionData({ abi: NATIVE_DEPOSIT_ABI, data: tx.data }).args[1]; + const canonical = erc20 + ? encodeFunctionData({ abi: ERC20_DEPOSIT_ABI, functionName: "depositErc20", args: [request.address, inputCurrency.address, BigInt(request.amount), orderId] }) + : encodeFunctionData({ abi: NATIVE_DEPOSIT_ABI, functionName: "depositNative", args: [request.address, orderId] }); + if (/^0x0+$/.test(orderId) || canonical.toLowerCase() !== tx.data.toLowerCase()) throw new Error("binding"); + } catch { throw new Error("The bridge transaction did not match this wallet's exact deposit."); } + return quote; +} + +export function validateBridgeStatus(value: unknown): BridgeStatusResponse { + if (!value || typeof value !== "object") throw new Error("Transfer status is temporarily unavailable."); + const status = value as BridgeStatusResponse; + if (!STATUSES.includes(status.status) || !Array.isArray(status.inTxHashes) || !Array.isArray(status.txHashes) || status.inTxHashes.length > 50 || status.txHashes.length > 50 || ![...status.inTxHashes, ...status.txHashes].every(isHash)) { + throw new Error("Transfer status is temporarily unavailable."); + } + return status; +} + +export function transferIsTerminal(transfer: TrackedBridgeTransfer | null): boolean { + return !!transfer && ["success", "refund", "failure"].includes(transfer.status); +} + +export function transferPhase(transfer: TrackedBridgeTransfer): BridgePhase { + return transferIsTerminal(transfer) ? transfer.status as "success" | "refund" | "failure" : transfer.status === "uncertain" ? "uncertain" : "pending"; +} + +export function mergeBridgeStatus(transfer: TrackedBridgeTransfer, status: BridgeStatusResponse): TrackedBridgeTransfer { + // A delayed response must not roll an already settled transfer backwards. + if (transferIsTerminal(transfer)) return transfer; + const sourceHash = transfer.sourceHash; + const matchingHash = sourceHash && status.inTxHashes.some((hash) => hash.toLowerCase() === sourceHash.toLowerCase()); + if ((["success", "refund", "failure"].includes(status.status) && !matchingHash) || (sourceHash && status.inTxHashes.length > 0 && !matchingHash)) { + throw new Error("Relay's update has not yet been matched to your source transaction. Tracking will retry."); + } + return { + ...transfer, + sourceHash, + destinationHashes: matchingHash ? [...new Set([...transfer.destinationHashes, ...status.txHashes])] : transfer.destinationHashes, + // "waiting" alone cannot prove whether a wallet broadcast an unknown send. + status: transfer.status === "uncertain" && !sourceHash ? "uncertain" : status.status, + }; +} + +/** Recovery without a wallet-returned hash must bind the provider's hash on-chain. */ +export function isMatchingSourceDeposit(transfer: TrackedBridgeTransfer, transaction: { from: Address; to: Address | null; input: Hex; value: bigint }): boolean { + return !!transfer.depositData && transaction.from.toLowerCase() === transfer.address.toLowerCase() && transaction.to?.toLowerCase() === RELAY_DEPOSITORY && transaction.value.toString() === (transfer.depositKind === "erc20" ? "0" : transfer.amount) && transaction.input.toLowerCase() === transfer.depositData.toLowerCase(); +} + +/** Smart wallets may deposit through an internal call rather than the outer tx. */ +export function hasMatchingDepositEvent(transfer: TrackedBridgeTransfer, receipt: { status: "success" | "reverted"; logs: readonly { address: Address; data: Hex; topics: readonly Hex[] }[] }): boolean { + if (!transfer.depositData || receipt.status !== "success") return false; + try { + const erc20 = transfer.depositKind === "erc20"; + const orderId = erc20 ? decodeFunctionData({ abi: ERC20_DEPOSIT_ABI, data: transfer.depositData }).args[3] : decodeFunctionData({ abi: NATIVE_DEPOSIT_ABI, data: transfer.depositData }).args[1]; + return receipt.logs.some((log) => { + if (log.address.toLowerCase() !== RELAY_DEPOSITORY || log.topics.length !== 1 || !new RegExp(`^0x[0-9a-fA-F]{${erc20 ? 256 : 192}}$`).test(log.data)) return false; + try { + const event = decodeEventLog({ abi: erc20 ? ERC20_DEPOSIT_EVENT : NATIVE_DEPOSIT_EVENT, data: log.data, topics: [log.topics[0]], strict: true }); + if (!event.args) return false; + if (erc20 && (!("token" in event.args) || event.args.token.toLowerCase() !== bridgeCurrency(transfer.originChainId, transfer.originAsset, "input").address.toLowerCase())) return false; + return event.args.from.toLowerCase() === transfer.address.toLowerCase() && event.args.amount.toString() === transfer.amount && event.args.id.toLowerCase() === orderId.toLowerCase(); + } catch { return false; } + }); + } catch { return false; } +} + +/** An estimate is only a preflight, with room for changing gas prices. */ +export function bridgeGasBudget(value: bigint, balance: bigint, gasEstimate: bigint, maxFeePerGas: bigint, additionalFeeReserve = 0n, symbol = "ETH"): { gas: bigint; reserve: bigint } { + if (value < 0n || balance < 0n || gasEstimate <= 0n || maxFeePerGas <= 0n || additionalFeeReserve < 0n) throw new Error("Could not estimate network fees. Try again."); + const gas = (gasEstimate * 120n + 99n) / 100n; + const reserve = gas * maxFeePerGas + (additionalFeeReserve * 120n + 99n) / 100n; + if (balance < value + reserve) throw new Error(`Not enough ${symbol} for this amount and network gas. Reduce the amount and request a new quote.`); + return { gas, reserve }; +} + +export function isWalletRejection(error: unknown): boolean { + let current = error; + const seen = new Set(); + for (let i = 0; current && typeof current === "object" && i < 8 && !seen.has(current); i++) { + seen.add(current); + const node = current as { code?: unknown; cause?: unknown }; + if (node.code === 4001 || node.code === "ACTION_REJECTED") return true; + current = node.cause; + } + return false; +} + +export type PreparedBridgeGas = { gas: bigint; maxFeePerGas: bigint; maxPriorityFeePerGas: bigint }; +export type DepositDependencies = { + now: () => number; + currentRequest: () => BridgeQuoteRequest | null; + readWallet: () => Promise<{ address?: Address; chainId?: number }>; + switchChain: (chainId: BridgeChainId) => Promise; + prepare: (quote: BridgeQuote) => Promise; + readTransfer: (address: Address) => TrackedBridgeTransfer | null; + saveTransfer: (transfer: TrackedBridgeTransfer) => void; + removeTransfer: (address: Address) => void; + send: (quote: BridgeQuote, gas: PreparedBridgeGas) => Promise; + phase: (phase: "switching" | "confirming") => void; +}; +export type DepositResult = { kind: "sent"; transfer: TrackedBridgeTransfer } | { kind: "rejected" } | { kind: "uncertain"; transfer: TrackedBridgeTransfer }; + +/** The actual send workflow, dependency-injected so tests exercise its async boundaries. */ +export async function submitBridgeDeposit(quote: BridgeQuote, deps: DepositDependencies): Promise { + const request = deps.currentRequest(); + if (!request) throw new Error("Reconnect this wallet and review a new quote."); + const checkRequest = () => { + if (bridgeRequestKey(deps.currentRequest()) !== bridgeRequestKey(request)) throw new Error("The wallet or bridge inputs changed. Review a new quote."); + validateBridgeQuote(quote, request, deps.now()); + }; + checkRequest(); + const existing = deps.readTransfer(request.address); + if (existing && !transferIsTerminal(existing)) throw new Error("A transfer is already being tracked for this wallet. Check its status before starting another."); + let wallet = await deps.readWallet(); + if (wallet.address?.toLowerCase() !== request.address.toLowerCase()) throw new Error("The connected wallet changed. Review a new quote."); + if (wallet.chainId !== request.originChainId) { + deps.phase("switching"); + await deps.switchChain(request.originChainId); + } + checkRequest(); + const gas = await deps.prepare(quote); + wallet = await deps.readWallet(); + checkRequest(); + if (wallet.address?.toLowerCase() !== request.address.toLowerCase() || wallet.chainId !== request.originChainId) throw new Error("Your wallet account or network changed. Review a new quote."); + const latest = deps.readTransfer(request.address); + if (latest && !transferIsTerminal(latest)) throw new Error("Another transfer is now being tracked for this wallet. Check its status before continuing."); + const tracked: TrackedBridgeTransfer = { ...request, requestId: quote.requestId, destinationHashes: [], status: "uncertain", createdAt: deps.now(), depositData: quote.transaction.data, ...(quote.approval ? { depositKind: "erc20" as const } : {}) }; + // This write must succeed before invoking the wallet. A reload during its + // prompt resumes read-only tracking by requestId, even without a tx hash. + try { deps.saveTransfer(tracked); } catch { + // No wallet call happened. Clear a partially written record when storage + // permits it; never continue signing without durable recovery details. + try { deps.removeTransfer(request.address); } catch { /* retain recovery warning */ } + throw new Error("Could not save bridge recovery details. No transaction was requested. Enable site storage before trying again."); + } + deps.phase("confirming"); + let hash: Hex; + try { + hash = await deps.send(quote, gas); + if (!isHash(hash)) throw new Error("Wallet returned no transaction hash"); + } catch (error) { + if (isWalletRejection(error)) { + const current = deps.readTransfer(request.address); + if (current?.requestId === tracked.requestId) deps.removeTransfer(request.address); + return { kind: "rejected" }; + } + // An RPC timeout is not proof of failure. Never automatically resubmit. + return { kind: "uncertain", transfer: tracked }; + } + const sent: TrackedBridgeTransfer = { ...tracked, sourceHash: hash, status: "pending" }; + // If this second write fails, the durable pre-send record still prevents a + // duplicate and lets Relay recover the hash. The store retains the hash in memory. + try { deps.saveTransfer(sent); } catch { /* recovered using the first write */ } + return { kind: "sent", transfer: sent }; +} + +export function parseStoredTransfer(raw: string, address: Address): TrackedBridgeTransfer { + const entry = JSON.parse(raw) as TrackedBridgeTransfer & { version?: unknown }; + const erc20 = entry?.depositKind === "erc20"; + const assetsValid = isBridgeChainId(entry?.originChainId) && isBridgeChainId(entry?.destinationChainId) && isBridgeAssetSupported(entry.originChainId, entry.originAsset) && isBridgeAssetSupported(entry.destinationChainId, entry.destinationAsset); + const legacyAssets = entry?.originAsset === undefined && entry?.destinationAsset === undefined; + const validVersion = (entry?.version === 1 && legacyAssets && entry.depositKind === undefined) || (entry?.version === 2 && legacyAssets && erc20 && entry.originChainId === 5042 && typeof entry.depositData === "string") || (entry?.version === 3 && assetsValid && typeof entry.depositData === "string" && (entry.depositKind === undefined || erc20) && erc20 === !/^0x0{40}$/.test(bridgeCurrency(entry.originChainId, entry.originAsset, "input").address)); + if (!validVersion || typeof entry.address !== "string" || entry.address.toLowerCase() !== address.toLowerCase() || !isAddress(entry.address) || !isBridgeChainId(entry.originChainId) || !isBridgeChainId(entry.destinationChainId) || entry.destinationChainId === entry.originChainId || !isHash(entry.requestId) || !isWei(entry.amount, true) || !Number.isFinite(entry.createdAt) || entry.createdAt <= 0 || (entry.sourceHash !== undefined && !isHash(entry.sourceHash)) || !Array.isArray(entry.destinationHashes) || entry.destinationHashes.length > 50 || !entry.destinationHashes.every(isHash) || (entry.status !== "uncertain" && !STATUSES.includes(entry.status)) || (entry.depositData !== undefined && !new RegExp(`^0x[0-9a-fA-F]{${erc20 ? 264 : 136}}$`).test(entry.depositData)) || (entry.failureReason !== undefined && entry.failureReason !== "source-reverted")) { + throw new Error("Saved bridge recovery data could not be read. Check your transfer on Relay before trying again."); + } + if (erc20) { + try { + const order = decodeFunctionData({ abi: ERC20_DEPOSIT_ABI, data: entry.depositData! }).args[3]; + const canonical = encodeFunctionData({ abi: ERC20_DEPOSIT_ABI, functionName: "depositErc20", args: [entry.address, bridgeCurrency(entry.originChainId, entry.originAsset, "input").address, BigInt(entry.amount), order] }); + if (/^0x0+$/.test(order) || canonical.toLowerCase() !== entry.depositData!.toLowerCase()) throw new Error("binding"); + } catch { throw new Error("Saved USDC deposit details could not be verified. Check Relay before trying again."); } + } else if (entry.depositData) { + try { + const order = decodeFunctionData({ abi: NATIVE_DEPOSIT_ABI, data: entry.depositData }).args[1]; + const canonical = encodeFunctionData({ abi: NATIVE_DEPOSIT_ABI, functionName: "depositNative", args: [entry.address, order] }); + if (/^0x0+$/.test(order) || canonical.toLowerCase() !== entry.depositData.toLowerCase()) throw new Error("binding"); + } catch { throw new Error("Saved native deposit details could not be verified. Check Relay before trying again."); } + } + return { address: entry.address, originChainId: entry.originChainId, destinationChainId: entry.destinationChainId, ...(entry.originAsset ? { originAsset: entry.originAsset } : {}), ...(entry.destinationAsset ? { destinationAsset: entry.destinationAsset } : {}), amount: entry.amount, requestId: entry.requestId, sourceHash: entry.sourceHash, destinationHashes: entry.destinationHashes, status: entry.status, createdAt: entry.createdAt, ...(entry.depositData ? { depositData: entry.depositData } : {}), ...(erc20 ? { depositKind: "erc20" as const } : {}), ...(entry.failureReason ? { failureReason: entry.failureReason } : {}) }; +} + +export function serializeTransfer(transfer: TrackedBridgeTransfer): string { + const explicitAssets = transfer.originAsset !== undefined || transfer.destinationAsset !== undefined; + return JSON.stringify({ ...transfer, ...(explicitAssets ? { originAsset: transfer.originAsset ?? defaultBridgeAsset(transfer.originChainId), destinationAsset: transfer.destinationAsset ?? defaultBridgeAsset(transfer.destinationChainId) } : {}), version: explicitAssets ? 3 : transfer.depositKind === "erc20" ? 2 : 1 }); +} diff --git a/app/src/lib/bridge/relay-order.NOTICE.md b/app/src/lib/bridge/relay-order.NOTICE.md new file mode 100644 index 0000000..c042792 --- /dev/null +++ b/app/src/lib/bridge/relay-order.NOTICE.md @@ -0,0 +1,72 @@ +# Relay EVM order hashing: provenance and license + +`relay-order.ts` contains the EVM/v1-only order type, EIP-712 struct schema, +normalizer, and order-ID hash algorithm extracted from the published +`@relay-protocol/settlement-sdk` **0.0.143** package by **Uneven Labs**. +The npm package metadata declares the **MIT** license and that author; no +copyright year is supplied here. The published tarball contains no separate +LICENSE file. The MIT notice below is retained with the extracted code. + +## Exact upstream reference + +- Package: [@relay-protocol/settlement-sdk 0.0.143](https://www.npmjs.com/package/@relay-protocol/settlement-sdk/v/0.0.143) +- Tarball: [settlement-sdk-0.0.143.tgz](https://registry.npmjs.org/@relay-protocol/settlement-sdk/-/settlement-sdk-0.0.143.tgz) +- Repository recorded in package metadata: `https://github.com/relayprotocol/settlement-protocol/packages/sdk` +- npm `gitHead`: `04d245b3701d5fe70baccaec0c6cc3a0a0827b96` +- npm tarball SHA-1: `44e2a9ab8c54754916614aba443b1aecb39b523f` +- npm tarball integrity: `sha512-+4oUYKmYA4SJ9CBRpfnrBFajGRx+sFuIBQZEkwjt/Dgte2fyL5qw0/5DQ1qGujqbzdCJC0oik/Ycjbg6y7z9MA==` +- `dist/order/index.js` SHA-256: `3dd837935b235da6b4f81322f3118362e630025af0e6edaebc6726c97a00a31b` +- `dist/utils.js` SHA-256: `004df8fef2733f9e5654b54c5fa4dadba9791386c2c2da26a2dcdc09f0a62d80` +- Type source: `dist/order/index.d.ts`, `Order` only. + +The upstream repository was not publicly readable when checked. The immutable +versioned npm tarball is the retrievable source reference, not an assumed Git tag. + +## Extraction boundary and verification + +The schema fields and their order, all v1 normalization fields, the 20-byte EVM +address encoding, byte normalization, and `hashStruct` call with primary type +`Order` are unchanged. In particular, addresses are not padded to 32 bytes and +the order ID does not use a typed-data domain hash. CommonJS compiler wrappers +were removed, TypeScript annotations restored, EVM helpers inlined, and a +`server-only` boundary added. Local guards explicitly reject versions other than +v1, non-EVM chains (including the solver and empty-output cases), and inherited +chain-map properties. No signing, settlement, or non-EVM code was copied. + +Before removing the SDK dependency, the full schema, normalized objects, and +hashes were compared against the installed official 0.0.143 implementation for +256 deterministic EVM/v1 vectors. These cover multiple inputs, refunds, +payments, fees and calls; mixed-case hex; variable-length bytes; zero values; +uint256 boundaries; uint32 deadline boundaries; and three chain identifiers. +`relay-order.golden.ts` records all 256 **official SDK** order IDs, indexed by +the generator in `relay-order.vectors.ts`. The persisted suite also checks the +independently captured provider order ID in `relay.fixture.ts` and the added +fail-closed guards. This is compatibility verification, not a security audit. + +The narrow extraction avoids bringing unrelated multi-chain dependencies and +their known vulnerabilities into the server. It uses the application's existing +Viem dependency. Do not update the protocol schema without reviewing upstream +source, regenerating reference vectors against that exact upstream version, and +rechecking read-only live quotes across all supported asset routes. + +## MIT License + +Copyright (c) Uneven Labs + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/app/src/lib/bridge/relay-order.golden.ts b/app/src/lib/bridge/relay-order.golden.ts new file mode 100644 index 0000000..c831fb6 --- /dev/null +++ b/app/src/lib/bridge/relay-order.golden.ts @@ -0,0 +1,261 @@ +// Golden getOrderId outputs captured from the installed official +// @relay-protocol/settlement-sdk@0.0.143 BEFORE removal. Each index maps to +// evmOrderVector(index). Schema, normalized data, and hashes were also compared. +export const SDK_0_0_143_ORDER_IDS = [ + "0x24ca60233d4af032dedfbb868008bc8a6d8fdd13514412fc9769dbfe72abbe46", + "0x7528b2d8313dbc9bb0b47b22461fda2248500b5109fad1f74e90f1fba92b1477", + "0x61d41fb4cf59bfb8dd3ced3482830197f5917ad334a8fb7c4c3de09958ae5568", + "0x7a3a4afbee2f7c9a46cb2737c7e330dbf7f0f2461122d6ece797f9d5fef254f4", + "0x648e84b10913cca0d117bcf87b4ff988081c4c939c1e9901c47d17b0435ffd91", + "0xbd473396e549d0045a773f703d9afe56819a3392ae7b88d1829f244e48277177", + "0xb76a42def70e4b16b0ce71ca9809793689cc94671b0e8979dc4042f49df685ed", + "0x4b272b86994258c66b4569735fcd5cb60d61f56ebf6bd77f80d7ff68ddb476b1", + "0x9d8639a5061c507d2f1008cf7ad279401c17e16d0079a8d941194df144a6bfab", + "0x4707a1d66f173e9611fbd3b38d01cb8dec98ba86cb9bf1ba094d83e51681f249", + "0xbd7755598d3697fd7e86b3fb16e8b3fb067af5afd3f5317fdac35254a0e2aec6", + "0x7bbd25a93349a18f1458c20ddb2496e4edcd263643c19eb911c9d13bc113beab", + "0xb421c6a809c414fe04905889055dc426bdd64fef54a5fc50926ae72573dc8ce6", + "0x940798ebb78978f55de5674fa44cd9923c36ea1cb0c014e945f6532a95dc31b9", + "0xda525e65219c4bb294e91ebcbfb985925a7c340b9dfbe3fbd935ff6f80f09223", + "0x07130853fd760eea48e5390be59905ed06b2bd7b3fe76aef8a9944fef03d1d4e", + "0xc1342afa0937af511bf9815da4503ef76fab1fd0002f5216714b37895d13d3fa", + "0x9e561df40949b59bf89c5aa4be3d208f4af589dea84ae60e09fdced1a733ee8f", + "0x1596032d3f93de405826d0f0f965501e31fb60457d80a99eedba3fdd181977cd", + "0x3f62f6351fd0e93f67f1fb9a1c0966b7b3a776350a5e2740514b74d919586edf", + "0x91d381a694e12ee57e841a952f4e02abbdf45fb463490a09a793c72fed40456c", + "0xba8e9949a62bd67e8e225a49b2ea7b35c63df46250788912a5a092a402fcead9", + "0x192dd50ad28016ee38e0ce6a6dda0ca310d83a8a5f223dc411051ff8cb52b66a", + "0xea3c2b43bc6272c4c423f470ce37c38e8ef632b85f4aa18015b539725e30d34b", + "0xbe6a5a588d2c039beee19002a1efb2d2849e993430b79a2131bcc56782f12cde", + "0xa282ca2c8915fff317a801895dd441781c2c1a94690f473fa849bdc4dcb62d81", + "0x61c2bd973ccef0972b303a418af9573fccd5a83429604644ca8905eaefe0837d", + "0x800e981785a81e092e16c418080f2e365e91da760735999eca0d7c534c9baf70", + "0x80e341742311557dcae40693a0e96dd5fce378438c872b51dfdfdf158a894550", + "0x8b9f42b01d56130a402011229b7cf4917649a4aac43babbadd641b0e03b58ada", + "0x7f98e764ceb6d564b015a5ba657d299ad8799a75ba7bb957d6e7e476cc875c8b", + "0x81c514fabb7f81535ac1e5d4987221849e43218343033e547625dfae26f57761", + "0xb372a39a990e1e6e58ef4181c7fffeacc796adb0deaa1f0b3c3c48c86edbf35e", + "0xa0a4a3944d831ca00680e375e1d1ce3d03e72db36573f4c8e9698c39dced307d", + "0x0622412e2063be85bd2ce85af94275cf7312acb4911b44fb4ba3837752ff40d5", + "0x0f44dddedf773232edb737b161077137a1c46256e443a1dd60d2ba38ea3dd1d1", + "0xbbc62f1c6bbf80f111cd4114d7e053b736e860a81ace2ea0ce71c98fa4b8b49f", + "0x8821722f2c0d8340a5cea0209478aad820b5130a73117fcdf99dc8ceeda3e228", + "0xd82815e16e982aa9fb2674868fd4fe593a59568373866d8f2df501f42155a24e", + "0x063ed3920996ff025a44499824735aca32af2f19200dba7f93d77863780415b2", + "0xad8d6c671439d746cbd0f5bd243b13991f1c720243b5a90c735742257ba66f3b", + "0x702c30dba147e7dd1ae39d8bcf24b68a0f390e6ca38bfa220f5be4deed49e6e3", + "0x490644bb3a70c8ea0095dabb78a9f8171ecaaae5946cab10c28ea25997a6e18b", + "0x1a39c6b15f812844ded19963ab496625380245a648b70200de5d2f7ce003ee02", + "0x695c323915c5ba8897b74ccb27169759df5e81871d75a6bc49586929954ffd27", + "0x59ad8289017febf365fc7fcc07fa28314ce9f39d7f8d474a551aa6a0a41e0034", + "0x9f676b6585f9b8326df94e32d22fef259e16e719252b1097ddb443657173a0d2", + "0xa4ae151f6708e027e62928bd8e869582f6aa4d5afd1c0e8104ccb0386fb1d2eb", + "0x18380a905c398089e1faff58afae6914011b02fefd6958aa5fb122ccd60518c5", + "0x5575ebc94e23d13a1e8ee8cd0a740f3c45fd58a02fb9822ae58e8bc0a5148fbb", + "0x06c719ede045db061f3ee497e4b0f55d9dc5671b6968abf2c4ee6ef6ef9b784c", + "0xd0b2ab519515879a3295742e9d217a8c01a7565f598d6a9731afe9b5be324546", + "0x21e9a348391777e90621d1f9cb7af56725621326a76f1987333999b5c90c9df0", + "0x69eaab93d5c46ae534e972cc638f9d7184b0edc0a60e535bbbd627679d19f910", + "0xe166e69bab6c2335f0c29658b4d6789ff455ccca85a6f5e25c44b35a144f4453", + "0x4c23f14e1b89578a81ffb886260cf3509ba60a6b90c67d615842df6361d77225", + "0xdb098daa9d92960fdfab3c75d8ab6b1f0aed7c56a7afd60058fec34d76049119", + "0x5cee207a1b9740da00c0f1ef76cd64ead364277ba566c84f2d6185b730439550", + "0x7f2e4a3d3292f6673e3f55a831e22da5304b5a1438f7b302536667a9d08ceb53", + "0x4c171c4879fadb5b86c465823570f448123f948ccaf2020bf4208d8f0e68c38e", + "0x7fba8b83946ff559acd5778cbf5a6cd6d7e3016f04b3cad16bfc3db86c9525f5", + "0xa69b6d1d55176a0a17908ff578740fd8ddc90761404e258931fbd576bed4cf1b", + "0x2f713229643ee771dfb317f44c5b44b99323f0db479c894105db5453ab116e1a", + "0x041cbf85c69d88438418a0f3dcd501cfa8a8f04de7287561016759d6402603eb", + "0x5d6b6aa1501e028960cfcafe283fa5cfba62d26517897a4c19ef39d21c8c5821", + "0x29ca38cad1034de5bc5859488b6fccf739c2e168530fef59b809b867653028bb", + "0x205471178c5e67fd610c1208741ce7e372cec9d35aaf9886ec554192d684c021", + "0x1bd22cbc4811e6c25bf284e7aa49644995bf83e2dd32f51807db1eb8a4ade084", + "0x6dd08deb9db8718b97379f225b72e954109a6e13e44822fa680833965c288f20", + "0x97aebd7a8b3cdf3d112e438d5017c363ef5f63b5bfd361a77b93718081778aaa", + "0x71265fe76154174c3a507d00298adde0049d939bce2d3ea68ebde13420817ac0", + "0xb36bb5555c892a7425679e7bc9d64b8322c04e3d456b038ada5206c7b18a76ac", + "0x3af06d31b0b265521111328aba2789c8c34f176a1c19a23efe57ee7d184bf611", + "0x3857e15ff92f915b8c373ddd8f8848857cedd2624e28cad8c83ae7c2dc8ae0aa", + "0x22eef8fe7614eac65ac5158e4436ef742638c6b3dc449411dc2f4830ed01a375", + "0x7cfccf55295f787bdbe21f4f99138c04fb951f379d8a0fde54571631b2314d45", + "0xb38c9a6b8fb60c2a4aeb9cd4b6f5085d39b06f105a442ed744426f9570996fb1", + "0x14316aea4f611fef950714b9e7900db4fe91da9266e0e92092dc90565b88fae8", + "0xd7511ee6a48945dd5c405e8158bdf5f800542ecac3f0d6ebea45983b48a922ca", + "0x23b6e16c13725e9680c6afe451854c4e75ab6a653d0dc820462be8bc887c32b5", + "0x33106c76bbf34feca5575d446bae66f83865372c413c0e1d00363584cfb0aeb7", + "0x5c380493750d5b83900a6ef4e66a6578a4b02e83ccc86d068bb82a6d09c3e16c", + "0xdfff6f842388bcf1b831afa204574ccb651212f9ea132f0b096518aa5276ff5c", + "0xbf5a2d65912349a451e355b279f5dfcd3bebf4360e9eb6d357d2882c96a4e705", + "0xc1c0154eb2426e813aebc5c588481010f78553fc288658e553fdd59c8e112351", + "0x5f850c4eecd2e346b691f4c432b82c19ae9efefcaf66d637abe00bddd549966a", + "0x044ded0d2c362ea88fd803f8b20a9dc027259d9bed8f32491d18126ae76a9ca3", + "0xf35707cf90cf04970498b9a8875ecb89a0fb7d0b79b55ac969ef7ae1fae0d922", + "0xb98fc4fdbc8a5e7d8d7279f1cfd4bf5ce61ac21139100b23f896faeea1adef3f", + "0x0313dfe78b89e17abef9a1e0a9e882b97e43b387feb49256e9beb48cc9c87275", + "0xa3d76921bc27029a4e5b3b02452e137b537d1cf1f5b7ffe0d8ea3f616bad51f7", + "0x5705398b2bf48829d0f7d951acf143a40f02d07a077eb0b7fd37682e7823a914", + "0x113e207cc8789c1646c57bfc85040be283d5106bc6b8647d65bfdb52b2d8fdbd", + "0x3d5bdf45168763f93c069fe5c78df83a249bb3e4e6fdb74bac23ee6d88c48213", + "0x5606699541d1d7142ab89d26124750ea4b1f13951e6714d289e1e60b9a6324c8", + "0xf36663c46fc438bbaad8697db963cde8e8a8eada5ee12fb41d846f9f5ff6fc24", + "0x9ad962e99308bf55c31ef1764745feb4051b040a8e04f4d441b1378f01b36c34", + "0x76ea0f2f9cfd859a9826ffad65c9f064b04bc9650880027d4f305851928ef240", + "0xac35918a005518d5e06c7a82a17c4728e2bbabf7956d3609c588c5dc6d87a9d9", + "0x51ab852f5540a0c245c8d8da508c31cd847d1eee2507e4a8dea429e21066f502", + "0xd7030676c5e559e586ec31a9aef08d62238af5cb285763d10983ccaf4af86581", + "0x1713b267e21b4e8c917d9f22affabbd8b88a81f6dd8bf0387ef24f0ffa7e602d", + "0xc1d7367168ee8d25d3e417a49d2687d248e8c53d1dae9d41f7b59b884e902fc9", + "0x6a2d4e235f5de2b4e9dbcb43f7f6b58e3a97eb414a26776a208e2c888d25d878", + "0x3fd5671407a6dc262f898af0b10285805e3a27579499eeda901540daf3083067", + "0xc645026a7e7319065c1cb0e7f4f25a4c62d34f6dc0ac56c08bc359715f3e5bc7", + "0xe97f85e19e373c546825115c7dccf7f59902d356281774ae1b4ed11285aee14e", + "0xc9f63ee61226ae9fa1a326419e354ceb15f56c7e99d80d1b821040e8503b56e7", + "0xb99ec198dffc5950d75982dee9f277699fa944207aea8f61ac26317b160281a0", + "0x49b9d98cf968f90cf0065cdb390b279810062e36212af4aed36d6ef2cbbc302d", + "0x2f78b1e43cf1d0b14eb34c3a2d92ea32dc1870ee91e4bb43c12a247e2d267d77", + "0x085a4784ababcfb8aa011797877e255fbf706beb52cd8c9fa87698f9f2c0f3de", + "0xcd39a953539a459b3346737f5e022767dc67a86bdc49df2875f0a415309e1a15", + "0x07e2a995f5cd13681c230a71359b8964e2a039d284eec60eb58a7270c8705a2c", + "0x13a7ac684fbe8ea60530a89e4c1a05660092139474b943de9e8897b9366a7563", + "0x9308cfd70bfbb3cf867590dba3c16d2bf5d3ceb5ab940f1f0d6d9e4b4304e0ee", + "0x150852dce26a17f7a26bb3db3c11a5f189ece9ee968ff16aba50c0807c92496e", + "0x5a76770ee5bbb628c4df4a19ad27197cca8a4dbbe7d9329e56ad2ddee3f76e6e", + "0x9c9260ae93863ea0258a1d25fd3be59df247177d2a67bc86ed4bb6dfd14b297f", + "0xcfdd28fb1602dccaa54b788963de4cc1edd27064b1a87fd7d4f014bd69a1ab30", + "0x0d5e58cf87e750910d7382421c11d6dc98c6ad38d5fde9c0c40dfd5b142f10f7", + "0x12b7e47b4ffc6b010b05cf4b281a5c1cd9b0f21d1034b5dec0a45ddc40ab3fe5", + "0x51be1e19c8902708fba44c0157141f53e0ceb5e5410d92603d4593735ecf5cd5", + "0x237eaa2edce3c220df76f9a76714b539d6f35c7ad050c7a8865e3248139ea3e6", + "0x9ab3d877aa0ba1fda77b9b27d568bd66d6b23d08040b8d26fa7dd1d589c75e86", + "0x3fdac95930a8d648e0f8794811dad579d65d6b89b8576ba51c145e52cf1839f0", + "0x8451a5c0df020df756f6e0789a4f7de6ea9a3f775b255bc0676a2bee22f196e4", + "0x7c33c8d1dac0c74827d096486dba0ad138d06b1a5dc6e9997a1385353a6d174b", + "0x480dc076cfc09a70af2abdcb19855efe4cf69653e521dd0a05f0869667b3cb6d", + "0x5fbb51ad2786667111b9e50816f1d3adc7a3e0f8b4acdaf055768010a347b2f1", + "0x69937b78f33179bbb155f454091beea250bc10e87dbec8ee1f83afacf8c4ceea", + "0x958389e5e4fafa1a13165075be48b38b8853c22fab318db813fcfe8a40225ae5", + "0xe05d3689557920e3bee3c7720e5c92d0691b8fc96ce4b14cf7c5ac677451da5d", + "0xe4e5a96c9cc560c0c7c6b800a027612b58e27538ee6a30e3ff94646c6e03ae52", + "0xa788a8be19f041c6370c0bba69581b300472b3a3da92262a9096e80cdc50f0a3", + "0x8826e046ecac9ca38aa7963fe4cce55af5341a8455f25f336c3f80016e71fdbc", + "0x673e9db6833ec04905fe6129a98ea5c204b88e3c47ae17860bf1c8a304c8cc29", + "0x702e26ef3f46cfa3d9c8a61209fa364a2cdd0bdb3e95a6ea8d9eebc6c70d5870", + "0x8b349c5d15d25f462f426447d5aaeedf4c4c5c55b5c163d1a4c9ccd62a0eadaf", + "0x8b09c440aff15eba1467aa9f7e2752dcb239d2f993bb3a07e7e8bbb6c2be59b6", + "0x5ffd38d0dee494473d9c87b52356edef97d489f9a97382a4f45f4b3073910e99", + "0x8024fdcad5533e6bfa35c6b4a953bfbc2e1caaa5229658f737683b3120986ea0", + "0xf36d6c0cc3117d0b7824f0aa7570813214786338c9be6e44781f343e0921f052", + "0xfcc8c67d76f49f676c8c4320db044e35204161d39e241089117bb3ba0102d8fc", + "0xe8143a08509b66bacf25a148395cbeb8741cf54f9fee7896b8df8903ab024520", + "0xa834908678e8db487c1fe23c36655bbdcda88cc7b7ab3e7d5f9ec4a1e0d0e7a3", + "0xa5efe42266a4ef9736daec3f6081563704e674ec74bb1d494d086445acc3c0ff", + "0x6d1e1c5bb2fbb09bfa316949dbab24646796bc13fae55ba20708eba2d5a9e210", + "0xb198632d33ba58887d93dadc5a19c9e18205cac732b1e2159b49492cdb8a2b15", + "0xd6bff844c9da519bd613ced59f0ebe1ad2b4889ba0b5a82001f18d0413e57bda", + "0x5ed3da2ca50d4c6d2377e41bc6f977cb065bca89415e6aa7f9c0296a8e4f2e80", + "0x625b0b4afdb9b5aa042304fdd1c81c775ec0cb092f7c2abc89857a9e223d12c2", + "0xeafada0b3f47daea447d88a9afac8c3c386061cf98aaf19fb312628295efebd2", + "0x16c0ca0ed2bee2712b010ccd7f5922ccb4fb787fbae679c80fa8e4009533b34e", + "0xb9975e06ece0ed96066b9b03a21e91cc0747458a505badfdf4ae4c0ebb47e4f0", + "0x3e0af73b6e1e3cb3782951b0882ed2ade89b46ee9bec6a4d309c942f0b7f2acc", + "0x03799222e4de651ca6edd72872bb9161ac41a860cefae1c802f47115871fd123", + "0x411ae1e35b16e0eb2502b556feb79d0fd0cb31a2d8439da9548c80710949ac8d", + "0xa5c70d096986f323e8b28991e30d631cc0f6d2f3c162c08dcc5d2f7402fdeb8f", + "0x3c58c6404ab0ba24622cb5b50f2976fa88ef013afadf7e5c33a67450da67f8bb", + "0x10ffef9dc45150fe810456e24f46e8a6058b722272c45795cd6128a57ff55475", + "0xa12c826d7f51bed7d34252a15fe24d82982e34d1b261bc5d440d634365aedcf4", + "0xae238b9418a0f3ca2944fd5dc98d733a214f5509d1cc403e6810f02bd813d06d", + "0xd95c8da9a0fcdf86f1864d1917579561c15c0e6a56d54aad203dbb2f0b36c50e", + "0x65e835eb4a957d02c832cbbbda38ece3de969b2c8b46b427e9bd69e928bfb600", + "0x078d77628aef12b380f79e28928e408a0d24befa62b87747c942d46584dd6c07", + "0xae44e981b574a9aad658fef2ec51e573d6580bcf7ebb87433020b8f1303d704a", + "0x3e4d6d49f2d5cb3da6ae76fac95b6712363e3732dd6430ad1ce680e2f67098df", + "0xcdf89ba8ae13806d7eb62f8748284cfcfc3ede5ae66df1b27e760bcdb5d38159", + "0x4225383b62b2611661f1f5a46ac7b59a7be02e2f86261297049f119304204efb", + "0x9ada277037f06f930c1405e244df6ba5b1622ccc64d5f4b68f4eb1457cc1fdb3", + "0x5dccf00a9983f44d010db8f8ca3e453366303c583f2bedcfe3f5d2d819f5b6f4", + "0x59a3cb21aa27a4691d36232bddfea4abfcd7b1ecf76eb731232ddc239207ec6c", + "0xbcc446ffb7d7e037596932a48a9cfb67e42f8f3b956d0afda5ee98d2ecb35bcc", + "0x41016d052e0fd4f30903f705080105911c9c5b98c994e34c6f629f706dc460e4", + "0xeba8640954f50f3326cba89899fa27b3402a651095ff4f78ad7f26c04c5997bf", + "0x03eb41fabdeb0bd84b7d2b81bbced3ce6542a8e5653838e63ef3ac28235ffe09", + "0xb9d06ef32d7eb011c18a63f701b7aff9dcb1b5cd669f1eb3a1e839c0c0b105f9", + "0x66b1230a31660e63b362357b37bc92be6e4500778dd8536b276ae56de1eb3e0b", + "0xd1573dc3a90e91bc7333c11280248e26e95dee033cf0870cb75427cff8c8d209", + "0xc727bc080bf63fa39a753c124d789f8fe55b5a725a951c3dddaa29b186d64750", + "0x3c1d1cc4f2627d83427a60381cbf8c7a3188feca45e00fa3efdb1b1537111d9a", + "0xe84da000e0b413f189ef3f5c115bc12007368b014c5a3f7300a8f614f6027634", + "0x8c32b741fe4dadfd8b4edc37295cad25422765ae7ed1d0a39954d3107a3286e2", + "0xf0f76afc9699228ec96022246a58ab3d43e11e8c497e17e1b15db3c205d62798", + "0x417011154354adc6d4b51e2c2b2b870d733a7861b32f99aa12a50912dcbade4e", + "0xbdefeef341905a6905cc837f519773e8c89d98c975b2284c3ca0b847b78966d7", + "0xf418b7a5e3a71c046f6e30cc1a39124053a27e62dfc94b261299358e0d07c791", + "0x23cb5e84a42e5971a274e3f9c929a6ccf90f97b8742f982c55250bddb1e373bb", + "0xed2111ec35d033f8e14726085651152da400ecc625d16db37898228b6fde02eb", + "0x1759a0b9e124da182352ae9d6c0e4a3a7a650e570bb9cfb303dc546e5f84897a", + "0x48d3f7c2788b13f1831ec039393c76d15adb80ca6fe30031ac066ff6e40a5750", + "0x60dfba653b1edaf1cbf60501667d74e66dba8f0c3148e5a516ac0a65063b365a", + "0x925224a4eaaee94243af6095c1803be44490279461e355a0ae93904faddb7263", + "0xa703bd655719795d727dad338b69c6163633aa291d7515394d31b3395c607fb5", + "0x1ea2f50dcd96221e2c3b392fe642f834eb73895403f6eacb1dcf96ffbc32bbdc", + "0xf5f820b3f4e63e8b7df347ceb5eb5f4342bf8a269f2c29fe664bda4980a2853f", + "0x41872b565e3972ac6434fbbefcb450f7b555ec0ca624d655c7ddd07156908aa4", + "0x957debb2fded95596920ad16780b724cfde44956ab0552c66041b653ca844336", + "0xd5350901eca0755f532a56fe7787714f0663a8ec222bff7ccd4f4955a0d94bf8", + "0xec5c48ad9faf62769146ca31147b0a88a5c3f90dd789d178097037c6ab8ab93b", + "0x1b248e30eb833ba7ad3533f4784ce8ec38c87c7185668ebadab45e86fa5c6951", + "0xfc873f9b3b8499f400647a7b5b7cc19e02461be384ba1d68dad612b6e31970d3", + "0x407b8d3470a6e05fa28bbd61e52c01ff0cd0fef34abdf21fa79e6bf6d832f53f", + "0xf365f3a172e6f1d6a6bb55ae31d24c3489f2df65d36e7c45ea7693635716acbd", + "0xbecc619bb6df65c58ec6491b7b03eb4d18b0d883cd17b360473448b8e9cef0de", + "0x763e5cfc1ce6c280e0ca0fc33b0783fa2f039e4e4fbf464995468af1aca8efe2", + "0x20e05e0d38723ee1cde1a80df3bbd0c22246235ffb5af2782b68d0e16d14b779", + "0x1bfeda92ed8ea578d9801385ae81d1d540146ee96eb17f2eccd8c95652b4106e", + "0x3c1730caa715cc601704e675f4c1cb449bed4555fc39b24de50ab5a6b40c4ac8", + "0xc80f0d708272867f7364bddfe203503c5d071aa9d219f1fefe74521e4bbca535", + "0x56fa7a09539e73892be33ad459039e24ffbb584750193fbe4992985809310a21", + "0x2cfbbb9e885c9889dbacb7de759863e94c412e795a73ab1c60f9e3c8b32317cf", + "0x86f81161f118607024e20c46488680a7f1d77f1b6045557f529138f0b7d2fd52", + "0xa692ed87ce53d521f4610253b693cd904bdd7785b31e6ba26bb57de5af9ff9e9", + "0x0d2f1327b70c1f72f68ae5c50a72faaa2ca1b177fdbff495b1238bedd4c8d50a", + "0x4782aebaa0d2299c38b84f86c98389a718e41f647c23fb86a59d8296b6102f82", + "0x6e10af450f5be65718a5dc512ccf72835f44fbb53a18acb3a8fcccc5c5cd73ff", + "0xc3a17b8ad66888dba72cd72578f3a55806106a87f3843eccb3b5d634159e8409", + "0xa1083a17a4d5f512293c95c10d46ea84051c57fa3765510599b95a5ae596e549", + "0x9ccbc0ad2a923957ded71259f7bbf951b9808329a0417a375a0b30a2558e81d2", + "0x88698f46439f4c6c52a856b47b6d9f8230100c83b06db25b37c33beb81eb0304", + "0x20477be44659f621fe49710bffa5f2caaf64c4a238f64f088817a0710f11b1d5", + "0x2eddeacab651d7832fe85a8678229fd300dfa13cef3a23528c5e2ccbaa5ff31f", + "0xcbbcaa4a0145694df40818fe9dda2febd47dab792c8263110b74d37956beda6f", + "0xcf81375152312e319a259d4d48db35b47997aa3ffe5bebb8dc1cd4805391649d", + "0xd12beec31e572b7dafaab8b8dd63df768acbffd0f8b5bdc0908b5aa92a1d3519", + "0xcea7837fcfdc1d6c1c443668933ad85142287ff365e9e97803206fb5561c3a82", + "0xe2e89f5306b073e634183a8f3a86fe66a8291fd2e3b674b01ceec82c2e68e753", + "0x3ca8062b11be20b6e8e80f6a06049d4737718f98faa1771f5601440dc747c5e5", + "0x8d815ce60a849876fe2e085d895fad9ed29a9b6d285b82db0c2c0c00b4dc27c5", + "0x8c38277d6cff9e2633b78d0e25a3d7e27ded59ca96c9538d48bf9b0859700e69", + "0x21f0fd85f58332bbad3b5104a95f9c960dd85ddc753e57e52bbed1eed05d242b", + "0xa06b2c4e8516b5868b906f6837700e4acedea0d7704f616d26f0ae6c62f80a03", + "0x66428e1d73f021f38c8a92334ace13c1b0ed223b6d70461f998e3ee768474700", + "0x5cd12d28f8c346be527d990ae7eecdd391d836696379173c5c2917a1b503a6d8", + "0x3a484cae80b2f9c8ecdab7444da085123d85c5c5e33e986e1f83298475dad15a", + "0x5248aa279437eda57dec4500568098b95108cbb69f6be765ada766e88f70a953", + "0xa798dadf1cea662fff3f37ad8cbbbda89322f6bd2684dcc4e6ef74fb83e5e7e1", + "0xaafc4c3a621729d79bbe73e7a11c76c1f6b03300025d2478124462396e87f208", + "0xc1d7b40010a6a5ba3f961f0d87b33069021d80b22ebd04efc8476ff207c3c31b", + "0xf99c590139e2592d6a9c66d64fe1d365e38d4c6eca2f7743b7ee25274e381dcd", + "0xc84f436837b544f512ae13b743484e63d4adf55609376cc6e8d3bfac68c47fa9", + "0xf5487cbd75a6497fea246825f236a90cbb98f67c5fe0a7ae1392be635737ce4e", + "0x516daac226b98f89ac4d7d44ced9c4a38a9eefd420b6c726428955261a4cfd34", + "0x64eed5d9ab4b87c08362557d571f67ecf6f66f894fafc32a832125f966515546", + "0x23c1f849ad51176786c6877029810cbbe0e99e45f9b12896e0e8d6530e3f53bf", + "0xc582c6a24a0cb541f74bf0ec4cff15c4464ecf3ff569b17fd411f1164235f020", + "0x56df83fb391d9618ab8b38369d7ae9c58e45a1f550c49fecdd5ee1ff00fbe6d9", + "0xf4cf8c0f6261c3d7b6e5c46cfe44b90911f14f08c07c7e4d8f44aac0d7d1e861", + "0xf0d14120ce0f2f9d787a6387436ec6ea4e4862ca48261461b34179120f0e26c3", + "0x24e9aa3c7c31b46cc6c826fb3842ed2d05885ba930d67c5cc75e6d367b2ef656", + "0x6554547359f63f2532cbbc2de1f2a8b0dbe8583de83fd8f034f0061b7f9564a9", + "0x7f8b7766c94bb4df23d14c3fe311568045f27fca0d93e648129aa8b63cc84f86", + "0xf9671b44be911fc0c3083e4d408f4a1ded079d8562379b169dedca70e86d14d7", + "0x91c1aafc49ef008faba350e48b3c20b0d5f83df49e7e43ff42acd9a480555a67", +] as const; diff --git a/app/src/lib/bridge/relay-order.test.ts b/app/src/lib/bridge/relay-order.test.ts new file mode 100644 index 0000000..13107d6 --- /dev/null +++ b/app/src/lib/bridge/relay-order.test.ts @@ -0,0 +1,60 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { getOrderId, normalizeOrder, type Order } from "./relay-order.ts"; +import { SDK_0_0_143_ORDER_IDS } from "./relay-order.golden.ts"; +import { evmOrderVector, VECTOR_VM_TYPES } from "./relay-order.vectors.ts"; +import { FIXTURE_ORDER_ID, relayQuoteFixture } from "./relay.fixture.ts"; + +test("EVM v1 hashes match 256 golden outputs captured from official settlement-sdk 0.0.143", () => { + assert.equal(SDK_0_0_143_ORDER_IDS.length, 256); + SDK_0_0_143_ORDER_IDS.forEach((expected, index) => { + assert.equal(getOrderId(evmOrderVector(index), VECTOR_VM_TYPES), expected, `SDK vector ${index}`); + }); +}); + +test("captured real native ETH order retains its provider order ID", () => { + assert.equal(getOrderId(relayQuoteFixture().protocol.v2.orderData as Order, VECTOR_VM_TYPES), FIXTURE_ORDER_ID); +}); + +test("EVM address normalization remains 20 bytes with no padding or endian changes", () => { + const order = evmOrderVector(1); + order.output.payments[0].recipient = "0x0123456789AbCdEf0123456789AbCdEf01234567"; + order.output.extraData = "0xAbCdEf01"; + const result = normalizeOrder(order, VECTOR_VM_TYPES); + assert.equal(result.output.payments[0].recipient, "0x0123456789abcdef0123456789abcdef01234567"); + assert.equal(result.output.payments[0].recipient.length, 42); + assert.equal(result.output.extraData, "0xabcdef01"); + assert.equal(result.solver, order.solver); + assert.equal(result.solverChainId, order.solverChainId); +}); + +for (const length of [0, 19, 21, 32]) { + test(`rejects a ${length}-byte EVM address`, () => { + const order = evmOrderVector(1); + order.output.payments[0].recipient = `0x${"ab".repeat(length)}`; + assert.throws(() => getOrderId(order, VECTOR_VM_TYPES), /Invalid ethereum-vm address byte length/); + }); +} + +test("rejects unsupported versions instead of silently hashing a new schema", () => { + for (const version of ["v0", "v2", "", undefined]) { + const order = { ...evmOrderVector(1), version } as Order; + assert.throws(() => getOrderId(order, VECTOR_VM_TYPES), /Unsupported Relay order version/); + } +}); + +test("rejects non-EVM, unknown, and inherited chain configurations", () => { + const order = evmOrderVector(2); + for (const chain of Object.keys(VECTOR_VM_TYPES)) { + assert.throws(() => getOrderId(order, { ...VECTOR_VM_TYPES, [chain]: "solana-vm" }), /Unsupported EVM order chain/); + assert.throws(() => getOrderId(order, { ...VECTOR_VM_TYPES, [chain]: "gateway-vm" }), /Unsupported EVM order chain/); + } + assert.throws(() => getOrderId(order, {}), /Unsupported EVM order chain/); + assert.throws(() => getOrderId(order, Object.create(VECTOR_VM_TYPES)), /Unsupported EVM order chain/); +}); + +test("non-EVM output is rejected even when no payments reference it", () => { + const order = evmOrderVector(0); + order.output.payments = []; + assert.throws(() => getOrderId(order, { ...VECTOR_VM_TYPES, robinhood: "solana-vm" }), /Unsupported EVM order chain/); +}); diff --git a/app/src/lib/bridge/relay-order.ts b/app/src/lib/bridge/relay-order.ts new file mode 100644 index 0000000..4df05d3 --- /dev/null +++ b/app/src/lib/bridge/relay-order.ts @@ -0,0 +1,168 @@ +import "server-only"; +import { hashStruct, hexToBytes, type Hex } from "viem"; + +// EVM/v1-only extraction of @relay-protocol/settlement-sdk 0.0.143. +// Copyright (c) Uneven Labs. MIT license and exact provenance: relay-order.NOTICE.md. +// Keep the upstream schema, normalization, and hashStruct algorithm in sync as a +// reviewed unit. This is NOT hashTypedData: Relay order IDs have no domain hash. +export type Order = { + version: "v1"; + solverChainId: string; + solver: string; + salt: string; + inputs: { + payment: { chainId: string; currency: string; amount: string; weight: string }; + refunds: { + chainId: string; + recipient: string; + currency: string; + minimumAmount: string; + deadline: number; + extraData: string; + }[]; + }[]; + output: { + chainId: string; + payments: { + recipient: string; + currency: string; + minimumAmount: string; + expectedAmount: string; + }[]; + calls: string[]; + deadline: number; + extraData: string; + }; + fees: { + recipientChainId: string; + recipient: string; + currencyChainId: string; + currency: string; + amount: string; + }[]; +}; + +export const ORDER_EIP712_TYPES = { + Order: [ + { name: "version", type: "string" }, + { name: "solverChainId", type: "string" }, + { name: "solver", type: "address" }, + { name: "salt", type: "uint256" }, + { name: "inputs", type: "Input[]" }, + { name: "output", type: "Output" }, + { name: "fees", type: "Fee[]" }, + ], + Input: [ + { name: "payment", type: "InputPayment" }, + { name: "refunds", type: "InputRefund[]" }, + ], + InputPayment: [ + { name: "chainId", type: "string" }, + { name: "currency", type: "bytes" }, + { name: "amount", type: "uint256" }, + { name: "weight", type: "uint256" }, + ], + InputRefund: [ + { name: "chainId", type: "string" }, + { name: "recipient", type: "bytes" }, + { name: "currency", type: "bytes" }, + { name: "minimumAmount", type: "uint256" }, + { name: "deadline", type: "uint32" }, + { name: "extraData", type: "bytes" }, + ], + Output: [ + { name: "chainId", type: "string" }, + { name: "payments", type: "OutputPayment[]" }, + { name: "deadline", type: "uint32" }, + { name: "calls", type: "bytes[]" }, + { name: "extraData", type: "bytes" }, + ], + OutputPayment: [ + { name: "recipient", type: "bytes" }, + { name: "currency", type: "bytes" }, + { name: "minimumAmount", type: "uint256" }, + { name: "expectedAmount", type: "uint256" }, + ], + Fee: [ + { name: "recipientChainId", type: "string" }, + { name: "recipient", type: "bytes" }, + { name: "currencyChainId", type: "string" }, + { name: "currency", type: "bytes" }, + { name: "amount", type: "uint256" }, + ], +}; + +type ChainIdToVmType = Record; +const getChainVmType = (chainId: string, chainsConfig: ChainIdToVmType) => { + if (!Object.hasOwn(chainsConfig, chainId) || chainsConfig[chainId] !== "ethereum-vm") { + throw new Error(`Unsupported EVM order chain ${chainId}`); + } + return chainsConfig[chainId]; +}; +const _toHexString = (arr: Uint8Array): Hex => `0x${Buffer.from(arr).toString("hex")}`; +const encodeBytesToHex = (bytes: string) => _toHexString(hexToBytes(bytes as Hex)); +const encodeAddressToHex = (address: string, vmType: string) => { + if (vmType !== "ethereum-vm") throw new Error("Unsupported vm type"); + const encoded = hexToBytes(address as Hex); + if (encoded.length !== 20) { + throw new Error(`Invalid ethereum-vm address byte length ${encoded.length}; expected 20`); + } + return _toHexString(encoded); +}; + +export const normalizeOrder = (order: Order, chainsConfig: ChainIdToVmType) => { + // Local scope guards only; the normalization below is the upstream v1 algorithm. + if (order.version !== "v1") throw new Error("Unsupported Relay order version"); + const vmType = (chainId: string) => getChainVmType(chainId, chainsConfig); + vmType(order.solverChainId); + vmType(order.output.chainId); + return { + version: order.version, + solverChainId: order.solverChainId, + solver: order.solver, + salt: order.salt, + inputs: order.inputs.map((input) => ({ + payment: { + chainId: input.payment.chainId, + currency: encodeAddressToHex(input.payment.currency, vmType(input.payment.chainId)), + amount: input.payment.amount, + weight: input.payment.weight, + }, + refunds: input.refunds.map((refund) => ({ + chainId: refund.chainId, + recipient: encodeAddressToHex(refund.recipient, vmType(refund.chainId)), + currency: encodeAddressToHex(refund.currency, vmType(refund.chainId)), + minimumAmount: refund.minimumAmount, + deadline: refund.deadline, + extraData: encodeBytesToHex(refund.extraData), + })), + })), + output: { + chainId: order.output.chainId, + payments: order.output.payments.map((payment) => ({ + recipient: encodeAddressToHex(payment.recipient, vmType(order.output.chainId)), + currency: encodeAddressToHex(payment.currency, vmType(order.output.chainId)), + minimumAmount: payment.minimumAmount, + expectedAmount: payment.expectedAmount, + })), + calls: order.output.calls.map(encodeBytesToHex), + deadline: order.output.deadline, + extraData: encodeBytesToHex(order.output.extraData), + }, + fees: order.fees.map((fee) => ({ + recipientChainId: fee.recipientChainId, + recipient: encodeAddressToHex(fee.recipient, vmType(fee.recipientChainId)), + currencyChainId: fee.currencyChainId, + currency: encodeAddressToHex(fee.currency, vmType(fee.currencyChainId)), + amount: fee.amount, + })), + }; +}; + +export const getOrderId = (order: Order, config: ChainIdToVmType) => { + return hashStruct({ + types: ORDER_EIP712_TYPES, + primaryType: "Order", + data: normalizeOrder(order, config), + }); +}; diff --git a/app/src/lib/bridge/relay-order.vectors.ts b/app/src/lib/bridge/relay-order.vectors.ts new file mode 100644 index 0000000..dff3643 --- /dev/null +++ b/app/src/lib/bridge/relay-order.vectors.ts @@ -0,0 +1,47 @@ +import type { Order } from "./relay-order"; + +export const VECTOR_VM_TYPES = { + base: "ethereum-vm", robinhood: "ethereum-vm", ethereum: "ethereum-vm", +}; + +// Deterministic protocol-level vectors intentionally include non-native currencies, +// calls, fees, and multiple payments. The bridge validator rejects those features; +// the hashing primitive must nevertheless preserve every upstream field exactly. +export function evmOrderVector(index: number): Order { + const chains = ["base", "robinhood", "ethereum"]; + const address = (offset: number) => { + const hex = ((BigInt(index + 1) << 120n) + BigInt(offset + 1) * 0xabcdef12345n).toString(16).padStart(40, "0"); + return `0x${index % 2 ? hex.toUpperCase() : hex}`; + }; + const bytes = (offset: number) => `0x${"Ab12cdEF".repeat((index + offset) % 9)}`; + const amount = (offset: number) => ( + index % 16 === 0 ? (1n << 256n) - 1n - BigInt(offset) : + index % 16 === 1 ? BigInt(offset) : (BigInt(index + 1) << BigInt(index % 192)) + BigInt(offset) + ).toString(); + const deadline = index % 4 === 0 ? 0 : index % 4 === 1 ? 0xffffffff : 1_790_141_517 + index; + return { + version: "v1", + solverChainId: chains[index % 3], + solver: address(0).toLowerCase(), + salt: `0x${BigInt(index + 1).toString(16).padStart(64, "0")}`, + inputs: Array.from({ length: 1 + index % 3 }, (_, input) => ({ + payment: { chainId: chains[(index + input) % 3], currency: address(10 + input), amount: amount(input), weight: String(input + 1) }, + refunds: Array.from({ length: index % 4 }, (_, refund) => ({ + chainId: chains[(index + refund) % 3], recipient: address(20 + refund), currency: address(30 + refund), + minimumAmount: amount(refund + 1), deadline, extraData: bytes(refund), + })), + })), + output: { + chainId: chains[(index + 1) % 3], + payments: Array.from({ length: 1 + index % 4 }, (_, payment) => ({ + recipient: address(40 + payment), currency: address(50 + payment), + minimumAmount: amount(payment + 2), expectedAmount: amount(payment + 1), + })), + calls: Array.from({ length: index % 3 }, (_, call) => bytes(call)), deadline, extraData: bytes(3), + }, + fees: Array.from({ length: index % 3 }, (_, fee) => ({ + recipientChainId: chains[(index + fee) % 3], recipient: address(60 + fee), + currencyChainId: chains[(index + fee + 1) % 3], currency: address(70 + fee), amount: amount(fee + 3), + })), + }; +} diff --git a/app/src/lib/bridge/relay.fixture.ts b/app/src/lib/bridge/relay.fixture.ts new file mode 100644 index 0000000..9cbed24 --- /dev/null +++ b/app/src/lib/bridge/relay.fixture.ts @@ -0,0 +1,198 @@ +import { encodeFunctionData, zeroAddress } from "viem"; +import { getOrderId, type Order } from "./relay-order"; +import { BRIDGE_CHAINS, BRIDGE_INPUT_CURRENCIES, bridgeCurrency, type BridgeAsset, type BridgeChainId, type BridgeQuoteRequest } from "./types"; +import { APPROVAL_ABI, DEPOSIT_ABI, ERC20_DEPOSIT_ABI, RELAY_DEPOSITORY } from "./validation"; + +// Compact, read-only mainnet quote captured with Relay's public example account. +// Never executed. The fixed order hash provides an independent encoding vector. +export const FIXTURE_NOW = 1790141417000; +export const FIXTURE_ADDRESS = "0x03508bb71268bba25ecacc8f620e01866650532c"; +export const FIXTURE_ORDER_ID = "0x821340c60739e51c1b86f8bd0b1a106b70be74cc79a7d38a69e859dd7f37c4b0"; +export const FIXTURE_REQUEST_ID = "0x1789536717c461dc89176ca26a7699a6a97a50b64265140f4d11a52159491da8"; +const router = "0xb92fe925dc43a0ecde6c8b1a2709c170ec4fff4f"; +const extraData = `0x${router.slice(2).padStart(64, "0")}`; +export const FIXTURE_INPUT: BridgeQuoteRequest = { address: FIXTURE_ADDRESS, originChainId: 8453, destinationChainId: 4663, amount: "10000000000000000" }; + +export function relayChainsFixture() { + return { chains: ([8453, 4663, 5042] as BridgeChainId[]).map((id) => ({ + id, vmType: "evm", disabled: false, depositEnabled: true, blockProductionLagging: false, + protocol: { v2: { chainId: BRIDGE_CHAINS[id].key, depository: RELAY_DEPOSITORY } }, + currency: { address: zeroAddress, symbol: id === 5042 ? "USDC" : "ETH", decimals: 18, supportsBridging: true }, + contracts: { erc20Router: router }, solverAddresses: ["0xf70da97812cb96acdf810712aa562db8dfa3dbef"], + })) }; +} + +export function relayQuoteFixture() { + const amountOut = "9987669548275956"; + const minimumAmount = "9937731200534577"; + const money = (chainId: number, amount: string) => ({ currency: { chainId, address: zeroAddress as string, symbol: chainId === 5042 ? "USDC" : "ETH", decimals: 18 }, amount }); + return { + protocol: { v2: { + orderId: FIXTURE_ORDER_ID, hubType: "onchain", + orderData: { + version: "v1", solverChainId: "base", solver: "0xf70da97812cb96acdf810712aa562db8dfa3dbef", + salt: "0x63f4beb696db6675f6a0678edb29aa8e209fef9137317c5f73a2f8d3819f2e22", + inputs: [{ payment: { chainId: "base", currency: zeroAddress as string, amount: FIXTURE_INPUT.amount, weight: "1" }, + refunds: ["base", "robinhood"].map((chainId) => ({ chainId, recipient: FIXTURE_ADDRESS, currency: zeroAddress as string, minimumAmount: "0", deadline: 1790141517, extraData })), + }], + output: { chainId: "robinhood", payments: [{ recipient: FIXTURE_ADDRESS, currency: zeroAddress as string, minimumAmount, expectedAmount: amountOut }], calls: [], deadline: 1790141517, extraData }, + fees: [], + }, + paymentDetails: { chainId: "base", depository: RELAY_DEPOSITORY, currency: zeroAddress as string, amount: FIXTURE_INPUT.amount }, + } }, + steps: [{ id: "deposit", kind: "transaction", requestId: FIXTURE_REQUEST_ID, depositAddress: "", items: [{ + status: "incomplete", data: { + from: FIXTURE_ADDRESS, to: RELAY_DEPOSITORY, + data: `0x49290c1c${FIXTURE_ADDRESS.slice(2).padStart(64, "0")}${FIXTURE_ORDER_ID.slice(2)}`, + value: FIXTURE_INPUT.amount, chainId: 8453, + }, check: { endpoint: `/intents/status/v3?requestId=${FIXTURE_REQUEST_ID}`, method: "GET" }, + }] }], + details: { sender: FIXTURE_ADDRESS, recipient: FIXTURE_ADDRESS, currencyIn: money(8453, FIXTURE_INPUT.amount), currencyOut: { ...money(4663, amountOut), minimumAmount }, totalImpact: { percent: "-0.12" }, timeEstimate: 1 }, + fees: { relayer: money(8453, "12330451724044"), gas: money(8453, "3000000000000"), app: money(8453, "0"), subsidized: money(8453, "0") }, + }; +} + +/** Synthetic routes retain the complete native / exact ERC-20 deposit structure. */ +export function relayRouteFixture(originChainId: BridgeChainId, destinationChainId: BridgeChainId, originAsset?: BridgeAsset, destinationAsset?: BridgeAsset) { + const source = BRIDGE_CHAINS[originChainId].key; + const destination = BRIDGE_CHAINS[destinationChainId].key; + const inputCurrency = bridgeCurrency(originChainId, originAsset, "input"); + const outputCurrency = bridgeCurrency(destinationChainId, destinationAsset, "output"); + const erc20 = inputCurrency.address !== zeroAddress; + const amount = inputCurrency.symbol === "USDC" ? "25000000" : FIXTURE_INPUT.amount; + const feeAmount = inputCurrency.symbol === "USDC" ? "60000" : outputCurrency.symbol === "USDC" ? "24736726855049" : "12330451724044"; + const amountOut = inputCurrency.symbol === outputCurrency.symbol + ? ((BigInt(amount) - BigInt(feeAmount)) * 10n ** BigInt(outputCurrency.decimals) / 10n ** BigInt(inputCurrency.decimals)).toString() + : outputCurrency.symbol === "USDC" ? (238n * 10n ** BigInt(outputCurrency.decimals) / 10n).toString() : "10400000000000000"; + const minimumAmount = (BigInt(amountOut) * 9950n / 10000n).toString(); + const input: BridgeQuoteRequest = { address: FIXTURE_ADDRESS, originChainId, destinationChainId, amount, ...(originAsset === undefined ? {} : { originAsset }), ...(destinationAsset === undefined ? {} : { destinationAsset }) }; + const quote = relayQuoteFixture(); + const order = quote.protocol.v2.orderData; + order.inputs[0].payment = { chainId: source, currency: inputCurrency.address, amount, weight: "1" }; + order.inputs[0].refunds = [source, destination].map((chainId) => ({ chainId, recipient: input.address, currency: chainId === source ? inputCurrency.address : outputCurrency.address, minimumAmount: "0", deadline: order.output.deadline, extraData })); + order.output.chainId = destination; + order.output.payments[0] = { recipient: input.address, currency: outputCurrency.address, minimumAmount, expectedAmount: amountOut }; + quote.protocol.v2.paymentDetails.chainId = source; + quote.protocol.v2.paymentDetails.currency = inputCurrency.address; + quote.protocol.v2.paymentDetails.amount = amount; + quote.steps[0].items[0].data.chainId = originChainId; + quote.steps[0].items[0].data.value = erc20 ? "0" : amount; + quote.details.currencyIn.currency.chainId = originChainId; + quote.details.currencyIn.currency.symbol = inputCurrency.symbol; + quote.details.currencyIn.currency.address = inputCurrency.address; + quote.details.currencyIn.currency.decimals = inputCurrency.decimals; + quote.details.currencyIn.amount = amount; + quote.details.currencyOut.currency.chainId = destinationChainId; + quote.details.currencyOut.currency.symbol = outputCurrency.symbol; + quote.details.currencyOut.currency.address = outputCurrency.address; + quote.details.currencyOut.currency.decimals = outputCurrency.decimals; + quote.details.currencyOut.amount = amountOut; + quote.details.currencyOut.minimumAmount = minimumAmount; + quote.details.totalImpact.percent = originChainId === 5042 || destinationChainId === 5042 ? "-0.68" : "-0.12"; + for (const fee of Object.values(quote.fees)) { + fee.currency.chainId = originChainId; + fee.currency.symbol = inputCurrency.symbol; + fee.currency.address = inputCurrency.address; + fee.currency.decimals = inputCurrency.decimals; + } + quote.fees.gas.currency.address = zeroAddress; + quote.fees.gas.currency.decimals = 18; + quote.fees.gas.currency.symbol = BRIDGE_CHAINS[originChainId].symbol; + quote.fees.relayer.amount = feeAmount; + quote.fees.gas.amount = originChainId === 5042 ? "3000000000000000" : "671181819574"; + const orderId = getOrderId(order as Order, { base: "ethereum-vm", robinhood: "ethereum-vm", arc: "ethereum-vm" }); + quote.protocol.v2.orderId = orderId; + quote.steps[0].items[0].data.data = erc20 + ? encodeFunctionData({ abi: ERC20_DEPOSIT_ABI, functionName: "depositErc20", args: [input.address, inputCurrency.address, BigInt(amount), orderId] }) + : encodeFunctionData({ abi: DEPOSIT_ABI, functionName: "depositNative", args: [input.address, orderId] }); + return { input, quote }; +} + +export function addApprovalFixture(quote: ReturnType, input: BridgeQuoteRequest) { + const approval = structuredClone(quote.steps[0]); + approval.id = "approve"; + approval.items[0].data.to = bridgeCurrency(input.originChainId, input.originAsset, "input").address; + approval.items[0].data.value = "0"; + approval.items[0].data.data = encodeFunctionData({ abi: APPROVAL_ABI, functionName: "approve", args: [RELAY_DEPOSITORY, BigInt(input.amount)] }); + quote.steps.unshift(approval); +} + +// Independent unsigned Base→Arc capture: the order ID came directly from Relay. +export const ARC_FIXTURE_NOW = 1790145902000; +export const ARC_FIXTURE_ORDER_ID = "0x3d2084d45de6c676747a09dc5e303c772b4300e1d27f3a8bfbd2a269192023b0"; +export function relayArcQuoteFixture() { + const { input, quote } = relayRouteFixture(8453, 5042); + const address = "0x1111111111111111111111111111111111111111"; + input.address = address; + const order = quote.protocol.v2.orderData; + order.salt = "0xed669f2a03277b9e00687ed0e243e5cb195d2b86c0327ba6bb88cd479b5b4f54"; + order.output.deadline = 1790146002; + order.output.payments[0] = { recipient: address, currency: zeroAddress, minimumAmount: "23686807729608496144", expectedAmount: "23805836914179393109" }; + for (const refund of order.inputs[0].refunds) { refund.recipient = address; refund.deadline = order.output.deadline; } + quote.protocol.v2.orderId = ARC_FIXTURE_ORDER_ID; + quote.steps[0].requestId = "0x17895412021a6804d2f81161357b456285dc32fddbb5691a414b174584cfe949"; + quote.steps[0].items[0].data.from = address; + quote.steps[0].items[0].data.data = encodeFunctionData({ abi: DEPOSIT_ABI, functionName: "depositNative", args: [address, ARC_FIXTURE_ORDER_ID] }); + quote.steps[0].items[0].check.endpoint = `/intents/status/v3?requestId=${quote.steps[0].requestId}`; + quote.details.sender = address; + quote.details.recipient = address; + quote.details.currencyOut.amount = order.output.payments[0].expectedAmount; + quote.details.currencyOut.minimumAmount = order.output.payments[0].minimumAmount; + return { input, quote }; +} + +// Independent unsigned Arc ERC-20→Robinhood capture, including exact approval. +export const ARC_OUTBOUND_FIXTURE_NOW = 1790146668000; +export const ARC_OUTBOUND_ORDER_ID = "0xd2ca55b2630a781450fc62ab5b2b81df6582302a9367ddc98c1c477c6d34e6df"; +export function relayArcOutboundFixture() { + const { input, quote } = relayRouteFixture(5042, 4663); + const address = "0x1111111111111111111111111111111111111111"; + input.address = address; + const order = quote.protocol.v2.orderData; + order.salt = "0x1a2c24137231b17e3c2a87b07ecc7c69a4d76b14c96db341891ec4d338eb9387"; + order.output.deadline = 1790146768; + order.output.payments[0] = { recipient: address, currency: zeroAddress, minimumAmount: "10328979931861476", expectedAmount: "10380884353629624" }; + for (const refund of order.inputs[0].refunds) { refund.recipient = address; refund.deadline = order.output.deadline; } + quote.protocol.v2.orderId = ARC_OUTBOUND_ORDER_ID; + quote.steps[0].requestId = "0x1789541968421d7d023d7da7b4c7d60e90ac1ec9df50ef9c4690920a660929a8"; + quote.steps[0].items[0].data.from = address; + quote.steps[0].items[0].data.data = encodeFunctionData({ abi: ERC20_DEPOSIT_ABI, functionName: "depositErc20", args: [address, BRIDGE_INPUT_CURRENCIES[5042].address, BigInt(input.amount), ARC_OUTBOUND_ORDER_ID] }); + quote.steps[0].items[0].check.endpoint = `/intents/status/v3?requestId=${quote.steps[0].requestId}`; + quote.details.sender = address; + quote.details.recipient = address; + quote.details.currencyOut.amount = order.output.payments[0].expectedAmount; + quote.details.currencyOut.minimumAmount = order.output.payments[0].minimumAmount; + quote.details.totalImpact.percent = "-0.36"; + quote.fees.relayer.amount = "54943"; + quote.fees.gas.amount = "3294270000000000"; + addApprovalFixture(quote, input); + return { input, quote }; +} + +// Independent unsigned Base USDC6→Arc USDC18 capture. Never executed. +export const BASE_USDC_FIXTURE_NOW = 1790149219000; +export const BASE_USDC_ORDER_ID = "0xa920a0c66c66329f3391358ac14496a8b2a576f1072abb0df4279a80f15080c7"; +export function relayBaseUsdcFixture() { + const { input, quote } = relayRouteFixture(8453, 5042, "USDC", "USDC"); + const address = "0x1111111111111111111111111111111111111111"; + input.address = address; + const order = quote.protocol.v2.orderData; + order.salt = "0x95de789cfc639ead6fae81c33d5b3e269d76b825a6df03b1abd9f1d6e4c1c56d"; + order.output.deadline = 1790149319; + order.output.payments[0] = { recipient: address, currency: zeroAddress, minimumAmount: "24814370670000000000", expectedAmount: "24939066000000000000" }; + for (const refund of order.inputs[0].refunds) { refund.recipient = address; refund.deadline = order.output.deadline; } + quote.protocol.v2.orderId = BASE_USDC_ORDER_ID; + quote.steps[0].requestId = "0x17895445192847f7ef9504f2228cf0fcf6c2c6aa4b11d659cd3ea1667e7cb439"; + quote.steps[0].items[0].data.from = address; + quote.steps[0].items[0].data.data = encodeFunctionData({ abi: ERC20_DEPOSIT_ABI, functionName: "depositErc20", args: [address, bridgeCurrency(8453, "USDC", "input").address, BigInt(input.amount), BASE_USDC_ORDER_ID] }); + quote.steps[0].items[0].check.endpoint = `/intents/status/v3?requestId=${quote.steps[0].requestId}`; + quote.details.sender = address; + quote.details.recipient = address; + quote.details.currencyOut.amount = order.output.payments[0].expectedAmount; + quote.details.currencyOut.minimumAmount = order.output.payments[0].minimumAmount; + quote.details.totalImpact.percent = "-0.24"; + quote.fees.relayer.amount = "60934"; + quote.fees.gas.amount = "1868564641196"; + addApprovalFixture(quote, input); + return { input, quote }; +} diff --git a/app/src/lib/bridge/relay.live.test.ts b/app/src/lib/bridge/relay.live.test.ts new file mode 100644 index 0000000..a99caa7 --- /dev/null +++ b/app/src/lib/bridge/relay.live.test.ts @@ -0,0 +1,24 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { getBridgeQuote, getBridgeStatus } from "./relay.ts"; +import { formatUnits } from "viem"; +import { BRIDGE_ASSETS, BRIDGE_CHAINS, bridgeCurrency, type BridgeChainId } from "./types.ts"; + +// Explicit opt-in. This only requests quotes/status using a public dummy address; +// it never connects a wallet, signs, or submits a transaction. +const chains = [8453, 4663, 5042] as BridgeChainId[]; +for (const originChainId of chains) for (const destinationChainId of chains) { + if (originChainId === destinationChainId) continue; + for (const originAsset of BRIDGE_ASSETS[originChainId]) for (const destinationAsset of BRIDGE_ASSETS[destinationChainId]) { + test(`read-only live Relay ${originChainId}:${originAsset}→${destinationChainId}:${destinationAsset}`, { skip: process.env.RUN_BRIDGE_LIVE_TESTS !== "1" }, async (t) => { + const amount = originAsset === "USDC" ? "25000000" : "10000000000000000"; + const quote = await getBridgeQuote({ address: "0x1111111111111111111111111111111111111111", originChainId, destinationChainId, originAsset, destinationAsset, amount }); + assert.ok(BigInt(quote.amountOut) > 0n); + assert.equal(quote.transaction.chainId, originChainId); + assert.ok(quote.expiresAt > Date.now()); + const status = await getBridgeStatus(quote.requestId); + assert.equal(status.status, "waiting"); + t.diagnostic(JSON.stringify({ route: `${BRIDGE_CHAINS[originChainId].name}:${originAsset}→${BRIDGE_CHAINS[destinationChainId].name}:${destinationAsset}`, input: formatUnits(BigInt(amount), bridgeCurrency(originChainId, originAsset, "input").decimals), output: formatUnits(BigInt(quote.amountOut), bridgeCurrency(destinationChainId, destinationAsset, "output").decimals), minimumOutput: formatUnits(BigInt(quote.minimumAmountOut), bridgeCurrency(destinationChainId, destinationAsset, "output").decimals), sourceInputRelayFee: quote.relayFee, sourceNativeGas: quote.sourceGas, totalImpactPercent: quote.totalImpactPercent, approval: quote.approval ?? null, status: status.status })); + }); + } +} diff --git a/app/src/lib/bridge/relay.routes.test.ts b/app/src/lib/bridge/relay.routes.test.ts new file mode 100644 index 0000000..d3ad811 --- /dev/null +++ b/app/src/lib/bridge/relay.routes.test.ts @@ -0,0 +1,80 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { POST } from "../../app/api/bridge/quote/route.ts"; +import { GET } from "../../app/api/bridge/status/route.ts"; +import { BASE_USDC } from "./types.ts"; +import { FIXTURE_INPUT, FIXTURE_NOW, addApprovalFixture, relayChainsFixture, relayRouteFixture } from "./relay.fixture.ts"; + +function quoteRequest(body: string, ip: string, headers: Record = {}) { + return new Request("https://openlaunch.example/api/bridge/quote", { method: "POST", body, headers: { "Content-Type": "application/json", "fly-client-ip": ip, ...headers } }); +} + +test("HTTP quote boundary rejects declared and streamed oversized bodies and unsupported content types", async () => { + assert.equal((await POST(quoteRequest("{}", "bridge-limit-header", { "Content-Length": "5000" }))).status, 413); + assert.equal((await POST(quoteRequest(" ".repeat(3000), "bridge-limit-stream"))).status, 413); + assert.equal((await POST(quoteRequest("{}", "bridge-content-type", { "Content-Type": "text/plain" }))).status, 415); + const invalid = await POST(quoteRequest("null", "bridge-null-json")); + assert.equal(invalid.status, 400); + assert.match(invalid.headers.get("cache-control")!, /private, no-store/); +}); + +test("HTTP quote rate limit runs before malformed body parsing", async () => { + for (let i = 0; i < 20; i++) assert.equal((await POST(quoteRequest("{", "bridge-rate-test"))).status, 400); + const limited = await POST(quoteRequest("{", "bridge-rate-test")); + assert.equal(limited.status, 429); + assert.equal(limited.headers.get("retry-after"), "5"); + assert.match(limited.headers.get("cache-control")!, /no-store/); +}); + +test("HTTP status boundary rejects malformed and duplicate IDs with private responses", async () => { + for (const query of ["", "requestId=https%3A%2F%2Fattacker.example", "requestId=bad&requestId=bad", "url=https%3A%2F%2Fattacker.example"]) { + const response = await GET(new Request(`https://openlaunch.example/api/bridge/status?${query}`, { headers: { "fly-client-ip": "bridge-status-invalid" } })); + assert.equal(response.status, 400); + assert.match(response.headers.get("cache-control")!, /private, no-store/); + } +}); + +test("HTTP quote accepts explicit Base USDC and preserves exact asset selection", async (t) => { + const { input, quote } = relayRouteFixture(8453, 5042, "USDC", "USDC"); + addApprovalFixture(quote, input); + t.mock.method(Date, "now", () => FIXTURE_NOW); + t.mock.method(globalThis, "fetch", async (url: string, init: RequestInit) => { + if (url.endsWith("/chains")) return Response.json(relayChainsFixture()); + const request = JSON.parse(String(init.body)); + assert.equal(request.originCurrency, BASE_USDC); + assert.equal(request.amount, "25000000"); + return Response.json(quote); + }); + const response = await POST(quoteRequest(JSON.stringify(input), "bridge-base-usdc-valid")); + assert.equal(response.status, 200); + assert.match(response.headers.get("cache-control")!, /private, no-store/); + const body = await response.json(); + assert.equal(body.originAsset, "USDC"); + assert.equal(body.destinationAsset, "USDC"); + assert.equal(body.approval.token, BASE_USDC); + assert.equal(body.transaction.value, "0"); +}); + +test("HTTP quote rejects unsupported assets and injected fields before contacting Relay", async (t) => { + let requests = 0; + t.mock.method(globalThis, "fetch", async () => { requests++; return Response.json({}); }); + for (const [index, mutation] of [ + { destinationAsset: "USDC" }, { originAsset: "USDT" }, { originAsset: null }, + { originChainId: 5042, originAsset: "ETH" }, { originCurrency: BASE_USDC }, + ].entries()) { + const response = await POST(quoteRequest(JSON.stringify({ ...FIXTURE_INPUT, ...mutation }), `bridge-assets-invalid-${index}`)); + assert.equal(response.status, 400); + } + assert.equal(requests, 0); +}); + +test("HTTP quote rejects ERC20 unlimited-approval amounts before contacting Relay", async (t) => { + let requests = 0; + t.mock.method(globalThis, "fetch", async () => { requests++; return Response.json({}); }); + for (const originChainId of [8453, 5042]) { + const input = { ...FIXTURE_INPUT, originChainId, originAsset: "USDC", amount: ((1n << 256n) - 1n).toString() }; + const response = await POST(quoteRequest(JSON.stringify(input), `bridge-unlimited-${originChainId}`)); + assert.equal(response.status, 400); + } + assert.equal(requests, 0); +}); diff --git a/app/src/lib/bridge/relay.test.ts b/app/src/lib/bridge/relay.test.ts new file mode 100644 index 0000000..b4c03f7 --- /dev/null +++ b/app/src/lib/bridge/relay.test.ts @@ -0,0 +1,365 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { encodeFunctionData, parseAbi, zeroAddress } from "viem"; +import { APPROVAL_ABI, BridgeApiError, ERC20_DEPOSIT_ABI, RELAY_DEPOSITORY, parseBridgeRequest, validateRelayChains, validateRelayQuote } from "./validation.ts"; +import { getOrderId, type Order } from "./relay-order.ts"; +import { ARC_USDC, BASE_USDC, BRIDGE_INPUT_CURRENCIES, bridgeCurrency, type BridgeAsset, type BridgeChainId } from "./types.ts"; +import { BRIDGE_PRIVATE_HEADERS, bridgeErrorResponse, getBridgeQuote, getBridgeStatus, readBridgeJson } from "./relay.ts"; +import { ARC_FIXTURE_NOW, ARC_FIXTURE_ORDER_ID, ARC_OUTBOUND_FIXTURE_NOW, ARC_OUTBOUND_ORDER_ID, BASE_USDC_FIXTURE_NOW, BASE_USDC_ORDER_ID, FIXTURE_INPUT, FIXTURE_NOW, FIXTURE_REQUEST_ID, addApprovalFixture, relayArcOutboundFixture, relayArcQuoteFixture, relayBaseUsdcFixture, relayChainsFixture, relayQuoteFixture, relayRouteFixture } from "./relay.fixture.ts"; + +test("quote adapter uses only fixed provider endpoints and requests native verification data without app fees", async () => { + const seen: { url: string; init: RequestInit }[] = []; + const quote = await getBridgeQuote(FIXTURE_INPUT, async (url, init) => { + seen.push({ url, init }); + return Response.json(url.endsWith("/chains") ? relayChainsFixture() : relayQuoteFixture()); + }, () => FIXTURE_NOW); + assert.equal(quote.requestId, FIXTURE_REQUEST_ID); + assert.deepEqual(seen.map((call) => call.url).sort(), ["https://api.relay.link/chains", "https://api.relay.link/quote/v2"]); + const payload = JSON.parse(String(seen.find((call) => call.init.method === "POST")!.init.body)); + assert.equal(payload.recipient, FIXTURE_INPUT.address); + assert.equal(payload.refundTo, FIXTURE_INPUT.address); + assert.equal(payload.explicitDeposit, true); + assert.equal(payload.includeProtocolData, true); + assert.equal(payload.slippageTolerance, "50"); + assert.equal(payload.appFees, undefined); + for (const call of seen) { + assert.equal(call.init.cache, "no-store"); assert.equal(call.init.redirect, "error"); + assert.ok(call.init.signal instanceof AbortSignal); + } +}); + +test("status rejects arbitrary identifiers before any upstream request", async () => { + let requests = 0; + await assert.rejects(getBridgeStatus("https://attacker.example", async () => { requests++; return Response.json({}); }), BridgeApiError); + assert.equal(requests, 0); + await getBridgeStatus(FIXTURE_REQUEST_ID, async (url) => { + assert.equal(url, `https://api.relay.link/intents/status/v3?requestId=${FIXTURE_REQUEST_ID}`); + return Response.json({ status: "waiting" }); + }); +}); + +test("upstream failure messages never expose provider data or keys and responses remain private", async () => { + const error: unknown = await getBridgeStatus(FIXTURE_REQUEST_ID, async () => new Response("secret upstream diagnostic", { status: 500 })).then(() => null, (error: unknown) => error); + assert.ok(error instanceof BridgeApiError); + const response = bridgeErrorResponse(error); + assert.equal(response.status, 503); + assert.ok(!(await response.text()).includes("secret")); + assert.equal(response.headers.get("cache-control"), BRIDGE_PRIVATE_HEADERS["Cache-Control"]); + const rate = bridgeErrorResponse(new BridgeApiError("Try later.", 429)); + assert.equal(rate.headers.get("retry-after"), "5"); +}); + +test("bounded JSON rejects chunked oversized, malformed, and stalled bodies", async () => { + const large = new ReadableStream({ start(controller) { controller.enqueue(new TextEncoder().encode(" ".repeat(3000))); controller.close(); } }); + await assert.rejects(readBridgeJson(large, 2048), (e) => e instanceof BridgeApiError && e.status === 413); + await assert.rejects(readBridgeJson(new Response("{").body, 2048), (e) => e instanceof BridgeApiError && e.status === 400); + const stalled = new ReadableStream({}); + await assert.rejects(readBridgeJson(stalled, 2048, 10), (e) => e instanceof BridgeApiError && e.status === 504); +}); + +test("rejects oversized upstream JSON even when Content-Length is absent", async () => { + await assert.rejects(getBridgeStatus(FIXTURE_REQUEST_ID, async () => new Response(" ".repeat(40_000))), BridgeApiError); +}); + +const supported = [8453, 4663, 5042] as BridgeChainId[]; +for (const origin of supported) for (const destination of supported) { + if (origin === destination) continue; + test(`validates pinned-asset route ${origin}→${destination} with fees in source currency`, async () => { + const { input, quote: fixture } = relayRouteFixture(origin, destination); + const result = await getBridgeQuote(input, async (url, init) => { + if (url.endsWith("/chains")) return Response.json(relayChainsFixture()); + const body = JSON.parse(String(init.body)); + assert.equal(body.originChainId, origin); + assert.equal(body.destinationChainId, destination); + assert.equal(body.refundTo, input.address); + assert.equal(body.amount, input.amount); + assert.equal(body.originCurrency, BRIDGE_INPUT_CURRENCIES[origin].address); + assert.equal(body.destinationCurrency, zeroAddress); + return Response.json(fixture); + }, () => FIXTURE_NOW); + assert.equal(result.transaction.value, origin === 5042 ? "0" : input.amount); + assert.equal(result.transaction.chainId, origin); + assert.equal(result.amountOut, fixture.details.currencyOut.amount); + assert.equal(result.totalImpactPercent, fixture.details.totalImpact.percent); + assert.equal(result.relayFee, origin === 5042 ? "0.06" : destination === 5042 ? "0.000024736726855049" : "0.000012330451724044"); + assert.deepEqual(result.approval, origin === 5042 ? { token: ARC_USDC, spender: RELAY_DEPOSITORY, amount: input.amount } : undefined); + }); +} + +test("captured Arc quote binds the independent protocol order ID and 18-decimal native USDC", () => { + const { input, quote } = relayArcQuoteFixture(); + const metadata = validateRelayChains(relayChainsFixture(), input); + assert.equal(getOrderId(quote.protocol.v2.orderData as Order, metadata.vmTypes), ARC_FIXTURE_ORDER_ID); + const result = validateRelayQuote(quote, input, metadata, ARC_FIXTURE_NOW); + assert.equal(result.amountOut, "23805836914179393109"); + assert.equal(result.minimumAmountOut, "23686807729608496144"); + assert.equal(result.totalImpactPercent, "-0.68"); + assert.equal(result.sourceGas, "0.000000671181819574"); + assert.ok(BigInt(result.amountOut) > BigInt(input.amount), "different asset units must not be compared"); +}); + +test("chain metadata validates only the route plus the Base solver hub", () => { + const baseRobinhood = relayChainsFixture(); + baseRobinhood.chains[2].disabled = true; + assert.doesNotThrow(() => validateRelayChains(baseRobinhood, FIXTURE_INPUT)); + const baseArc = relayRouteFixture(8453, 5042).input; + assert.throws(() => validateRelayChains(baseRobinhood, baseArc), BridgeApiError); + const unrelatedRobinhood = relayChainsFixture(); + unrelatedRobinhood.chains[1].disabled = true; + assert.doesNotThrow(() => validateRelayChains(unrelatedRobinhood, baseArc)); + const robinhoodArc = relayRouteFixture(4663, 5042).input; + const missingHub = relayChainsFixture(); + missingHub.chains = missingHub.chains.filter((chain) => chain.id !== 8453); + assert.throws(() => validateRelayChains(missingHub, robinhoodArc), BridgeApiError); + const differingSolvers = relayChainsFixture(); + differingSolvers.chains[1].solverAddresses = ["0x1111111111111111111111111111111111111111"]; + differingSolvers.chains[2].solverAddresses = ["0x2222222222222222222222222222222222222222"]; + assert.deepEqual(validateRelayChains(differingSolvers, robinhoodArc).solverAddresses, differingSolvers.chains[0].solverAddresses); +}); + +test("rejects wrong Arc native symbols, 6-decimal ERC20 assumptions, assets and testnet IDs", () => { + const { input } = relayRouteFixture(8453, 5042); + for (const mutation of [ + (chains: ReturnType) => { chains.chains[2].currency.symbol = "ETH"; }, + (chains: ReturnType) => { chains.chains[2].currency.decimals = 6; }, + (chains: ReturnType) => { chains.chains[2].currency.address = "0x1111111111111111111111111111111111111111"; }, + ]) { + const chains = relayChainsFixture(); mutation(chains); + assert.throws(() => validateRelayChains(chains, input), BridgeApiError); + } + for (const field of ["symbol", "decimals", "address"] as const) { + const { quote } = relayRouteFixture(8453, 5042); + const currency = quote.details.currencyOut.currency as Record; + currency[field] = field === "symbol" ? "ETH" : field === "decimals" ? 6 : "0x1111111111111111111111111111111111111111"; + assert.throws(() => validateRelayQuote(quote, input, validateRelayChains(relayChainsFixture(), input), FIXTURE_NOW), BridgeApiError); + } + const { input: arcSource, quote } = relayRouteFixture(5042, 8453); + quote.fees.relayer.currency.symbol = "ETH"; + assert.throws(() => validateRelayQuote(quote, arcSource, validateRelayChains(relayChainsFixture(), arcSource), FIXTURE_NOW), BridgeApiError); + assert.throws(() => parseBridgeRequest({ ...input, destinationChainId: 5042002 }), (error) => error instanceof BridgeApiError && error.status === 400); +}); + +test("rejects missing, malformed, nonfinite and excessive total impact without changing raw amount units", async () => { + for (const percent of [undefined, "NaN", "Infinity", "1e3", "+0", "+1", "-100.01", "100.01", "100.000000000000000001", "-5.01", "-5.000000000000000001", "0".repeat(33), 0]) { + const { input, quote } = relayRouteFixture(8453, 5042); + (quote.details.totalImpact as { percent: unknown }).percent = percent; + assert.throws(() => validateRelayQuote(quote, input, validateRelayChains(relayChainsFixture(), input), FIXTURE_NOW), BridgeApiError, String(percent)); + } + const { input, quote } = relayRouteFixture(8453, 5042); + for (const percent of ["-5", "-5.000000000000000000", "-0", "0", "100", "100.000000000000000000"]) { + quote.details.totalImpact.percent = percent; + assert.doesNotThrow(() => validateRelayQuote(quote, input, validateRelayChains(relayChainsFixture(), input), FIXTURE_NOW)); + } + quote.details.totalImpact.percent = "-5.01"; + await assert.rejects(getBridgeQuote(input, async (url) => Response.json(url.endsWith("/chains") ? relayChainsFixture() : quote), () => FIXTURE_NOW), (error) => error instanceof BridgeApiError && error.status === 422 && /more than 5%/.test(error.message)); +}); + +test("rejects excessive source fees even when provider impact claims no loss", () => { + const { input, quote } = relayRouteFixture(5042, 8453); + quote.fees.relayer.amount = (BigInt(input.amount) * 5n / 100n + 1n).toString(); + quote.details.totalImpact.percent = "0"; + assert.throws(() => validateRelayQuote(quote, input, validateRelayChains(relayChainsFixture(), input), FIXTURE_NOW), (error) => error instanceof BridgeApiError && error.status === 422); +}); + +test("captured Arc ERC-20 quote binds the independent order hash and exact approval", () => { + const { input, quote } = relayArcOutboundFixture(); + const metadata = validateRelayChains(relayChainsFixture(), input); + assert.equal(getOrderId(quote.protocol.v2.orderData as Order, metadata.vmTypes), ARC_OUTBOUND_ORDER_ID); + const result = validateRelayQuote(quote, input, metadata, ARC_OUTBOUND_FIXTURE_NOW); + assert.equal(result.amount, "25000000"); + assert.equal(result.transaction.value, "0"); + assert.equal(result.relayFee, "0.054943"); + assert.equal(result.sourceGas, "0.00329427"); + assert.deepEqual(result.approval, { token: ARC_USDC, spender: RELAY_DEPOSITORY, amount: "25000000" }); + quote.steps.shift(); // Relay may omit approval when allowance is already enough. + assert.deepEqual(validateRelayQuote(quote, input, metadata, ARC_OUTBOUND_FIXTURE_NOW), result); +}); + +test("rejects wrong Arc approval target, spender, amount, chain, value and encoding", () => { + const other = "0x2222222222222222222222222222222222222222"; + const approval = (spender: typeof RELAY_DEPOSITORY | typeof other, amount: bigint) => encodeFunctionData({ abi: APPROVAL_ABI, functionName: "approve", args: [spender, amount] }); + const mutations: Record["steps"][number]["items"][number]["data"]) => void> = { + "wrong token": (tx) => { tx.to = other; }, + "native alias": (tx) => { tx.to = zeroAddress; }, + "wrong owner": (tx) => { tx.from = other; }, + "wrong chain": (tx) => { tx.chainId = 8453; }, + "nonzero value": (tx) => { tx.value = "1"; }, + "wrong spender": (tx) => { tx.data = approval(other, 25000000n); }, + "unlimited amount": (tx) => { tx.data = approval(RELAY_DEPOSITORY, (1n << 256n) - 1n); }, + "zero amount": (tx) => { tx.data = approval(RELAY_DEPOSITORY, 0n); }, + "partial amount": (tx) => { tx.data = approval(RELAY_DEPOSITORY, 24999999n); }, + "trailing bytes": (tx) => { tx.data += "00"; }, + }; + for (const [name, mutate] of Object.entries(mutations)) { + const { input, quote } = relayArcOutboundFixture(); + mutate(quote.steps[0].items[0].data); + assert.throws(() => validateRelayQuote(quote, input, validateRelayChains(relayChainsFixture(), input), ARC_OUTBOUND_FIXTURE_NOW), BridgeApiError, name); + } +}); + +test("rejects extra, reordered, mismatched or signature approval steps", () => { + for (const mutate of [ + (quote: ReturnType) => { quote.steps.push(structuredClone(quote.steps[0])); }, + (quote: ReturnType) => { quote.steps.reverse(); }, + (quote: ReturnType) => { quote.steps[0].kind = "signature"; }, + (quote: ReturnType) => { quote.steps[0].requestId = FIXTURE_REQUEST_ID; }, + (quote: ReturnType) => { quote.steps[0].items.push(structuredClone(quote.steps[0].items[0])); }, + ]) { + const { input, quote } = relayArcOutboundFixture(); mutate(quote); + assert.throws(() => validateRelayQuote(quote, input, validateRelayChains(relayChainsFixture(), input), ARC_OUTBOUND_FIXTURE_NOW), BridgeApiError); + } + const quote = relayQuoteFixture(); + addApprovalFixture(quote, FIXTURE_INPUT); + assert.throws(() => validateRelayQuote(quote, FIXTURE_INPUT, validateRelayChains(relayChainsFixture()), FIXTURE_NOW), BridgeApiError); +}); + +test("rejects Arc deposit full-allowance overload, swapped arguments and native value", () => { + for (const variant of ["full allowance", "swapped arguments", "wrong amount", "wrong token", "native value", "trailing data"]) { + const { input, quote } = relayArcOutboundFixture(); + const tx = quote.steps[1].items[0].data; + if (variant === "full allowance") tx.data = encodeFunctionData({ abi: parseAbi(["function depositErc20(address depositor,address token,bytes32 id)"]), functionName: "depositErc20", args: [input.address, ARC_USDC, ARC_OUTBOUND_ORDER_ID] }); + if (variant === "swapped arguments") tx.data = encodeFunctionData({ abi: ERC20_DEPOSIT_ABI, functionName: "depositErc20", args: [ARC_USDC, input.address, BigInt(input.amount), ARC_OUTBOUND_ORDER_ID] }); + if (variant === "wrong amount") tx.data = encodeFunctionData({ abi: ERC20_DEPOSIT_ABI, functionName: "depositErc20", args: [input.address, ARC_USDC, BigInt(input.amount) + 1n, ARC_OUTBOUND_ORDER_ID] }); + if (variant === "wrong token") tx.data = encodeFunctionData({ abi: ERC20_DEPOSIT_ABI, functionName: "depositErc20", args: [input.address, zeroAddress, BigInt(input.amount), ARC_OUTBOUND_ORDER_ID] }); + if (variant === "native value") tx.value = input.amount; + if (variant === "trailing data") tx.data += "00"; + assert.throws(() => validateRelayQuote(quote, input, validateRelayChains(relayChainsFixture(), input), ARC_OUTBOUND_FIXTURE_NOW), BridgeApiError, variant); + } +}); + +test("Arc input, relay fee and refund use USDC6 while gas and output retain native18", () => { + for (const mutate of [ + (quote: ReturnType) => { quote.protocol.v2.orderData.inputs[0].payment.currency = zeroAddress; }, + (quote: ReturnType) => { quote.protocol.v2.orderData.inputs[0].refunds[0].currency = zeroAddress; }, + (quote: ReturnType) => { quote.protocol.v2.orderData.inputs[0].refunds[1].currency = ARC_USDC; }, + (quote: ReturnType) => { quote.protocol.v2.paymentDetails.currency = zeroAddress; }, + (quote: ReturnType) => { quote.details.currencyIn.currency.decimals = 18; }, + (quote: ReturnType) => { quote.details.currencyIn.currency.address = zeroAddress; }, + (quote: ReturnType) => { quote.fees.relayer.currency.decimals = 18; }, + (quote: ReturnType) => { quote.fees.gas.currency.decimals = 6; }, + (quote: ReturnType) => { quote.fees.gas.currency.address = ARC_USDC; }, + ]) { + const { input, quote } = relayArcOutboundFixture(); mutate(quote); + const metadata = validateRelayChains(relayChainsFixture(), input); + const orderId = getOrderId(quote.protocol.v2.orderData as Order, metadata.vmTypes); + quote.protocol.v2.orderId = orderId; + quote.steps[1].items[0].data.data = encodeFunctionData({ abi: ERC20_DEPOSIT_ABI, functionName: "depositErc20", args: [input.address, ARC_USDC, BigInt(input.amount), orderId] }); + assert.throws(() => validateRelayQuote(quote, input, metadata, ARC_OUTBOUND_FIXTURE_NOW), BridgeApiError); + } +}); + +const usdcRoutes: [BridgeChainId, BridgeChainId, BridgeAsset, BridgeAsset][] = [ + [8453, 4663, "USDC", "ETH"], [4663, 8453, "ETH", "USDC"], + [8453, 5042, "USDC", "USDC"], [5042, 8453, "USDC", "USDC"], +]; +for (const [origin, destination, originAsset, destinationAsset] of usdcRoutes) { + test(`selected assets ${origin}:${originAsset}→${destination}:${destinationAsset} bind provider request, fee units and approval`, async () => { + const { input, quote } = relayRouteFixture(origin, destination, originAsset, destinationAsset); + const inputCurrency = bridgeCurrency(origin, originAsset, "input"); + const outputCurrency = bridgeCurrency(destination, destinationAsset, "output"); + if (inputCurrency.address !== zeroAddress) addApprovalFixture(quote, input); + const result = await getBridgeQuote(input, async (url, init) => { + if (url.endsWith("/chains")) return Response.json(relayChainsFixture()); + const body = JSON.parse(String(init.body)); + assert.equal(body.originCurrency, inputCurrency.address); + assert.equal(body.destinationCurrency, outputCurrency.address); + assert.equal(body.amount, input.amount); + return Response.json(quote); + }, () => FIXTURE_NOW); + assert.equal(result.originAsset, originAsset); + assert.equal(result.destinationAsset, destinationAsset); + assert.equal(result.transaction.value, originAsset === "USDC" ? "0" : input.amount); + assert.equal(result.relayFee, originAsset === "USDC" ? "0.06" : "0.000024736726855049"); + assert.deepEqual(result.approval, originAsset === "USDC" ? { token: inputCurrency.address, spender: RELAY_DEPOSITORY, amount: input.amount } : undefined); + }); +} + +test("request selections preserve legacy defaults but reject unsupported or injected asset fields", () => { + assert.deepEqual(parseBridgeRequest(FIXTURE_INPUT), FIXTURE_INPUT); + const selected = { ...FIXTURE_INPUT, amount: "25000000", originAsset: "USDC" as const, destinationAsset: "ETH" as const }; + assert.deepEqual(parseBridgeRequest(selected), selected); + assert.deepEqual(parseBridgeRequest({ ...FIXTURE_INPUT, originAsset: "ETH" }), { ...FIXTURE_INPUT, originAsset: "ETH" }); + for (const mutation of [ + { originAsset: null }, { originAsset: "usdc" }, { originAsset: "USDT" }, { originAsset: BASE_USDC }, + { destinationAsset: "USDC" }, { destinationAsset: 1 }, { originChainId: 5042, originAsset: "ETH" }, + { token: BASE_USDC }, { approval: { token: BASE_USDC } }, + ]) assert.throws(() => parseBridgeRequest({ ...FIXTURE_INPUT, ...mutation }), (error) => error instanceof BridgeApiError && error.status === 400); +}); + +test("captured Base USDC6→Arc USDC18 quote preserves exact normalized fee equality and independent hash", () => { + const { input, quote } = relayBaseUsdcFixture(); + const metadata = validateRelayChains(relayChainsFixture(), input); + assert.equal(getOrderId(quote.protocol.v2.orderData as Order, metadata.vmTypes), BASE_USDC_ORDER_ID); + const result = validateRelayQuote(quote, input, metadata, BASE_USDC_FIXTURE_NOW); + assert.equal(result.amountOut, "24939066000000000000"); + assert.equal(result.relayFee, "0.060934"); + assert.equal(result.sourceGas, "0.000001868564641196"); + assert.equal(result.approval?.token, BASE_USDC); + assert.equal(BigInt(input.amount) - 60934n, BigInt(result.amountOut) / 10n ** 12n); +}); + +test("request amount maximum remains valid for native ETH but never an ERC20 approval", async () => { + const maximum = (1n << 256n) - 1n; + const native = { ...FIXTURE_INPUT, amount: maximum.toString() }; + assert.deepEqual(parseBridgeRequest(native), native); + const baseUsdc = { ...native, originAsset: "USDC" as const }; + assert.equal(parseBridgeRequest({ ...baseUsdc, amount: (maximum - 1n).toString() }).amount, (maximum - 1n).toString()); + let requests = 0; + for (const input of [baseUsdc, { ...native, originChainId: 5042 as const }, { ...native, originChainId: 5042 as const, originAsset: "USDC" as const }]) { + assert.throws(() => parseBridgeRequest(input), (error) => error instanceof BridgeApiError && error.status === 400); + await assert.rejects(getBridgeQuote(input, async () => { requests++; return Response.json({}); }), (error) => error instanceof BridgeApiError && error.status === 400); + } + assert.equal(requests, 0); +}); + +test("quote validation cannot normalize a matching ERC20 maximum into unlimited approval", () => { + for (const origin of [8453, 5042] as const) { + const { input, quote } = relayRouteFixture(origin, 4663, "USDC", "ETH"); + input.amount = ((1n << 256n) - 1n).toString(); + quote.protocol.v2.orderData.inputs[0].payment.amount = input.amount; + quote.protocol.v2.paymentDetails.amount = input.amount; + quote.details.currencyIn.amount = input.amount; + const metadata = validateRelayChains(relayChainsFixture(), input); + const orderId = getOrderId(quote.protocol.v2.orderData as Order, metadata.vmTypes); + quote.protocol.v2.orderId = orderId; + quote.steps[0].items[0].data.data = encodeFunctionData({ abi: ERC20_DEPOSIT_ABI, functionName: "depositErc20", args: [input.address, bridgeCurrency(origin, "USDC", "input").address, BigInt(input.amount), orderId] }); + addApprovalFixture(quote, input); + assert.throws(() => validateRelayQuote(quote, input, metadata, FIXTURE_NOW), BridgeApiError); + } +}); + +test("selected USDC assets reject rehashed output/refund substitutions and decimal rounding attacks", () => { + for (const mutate of [ + (quote: ReturnType) => { quote.protocol.v2.orderData.inputs[0].payment.currency = ARC_USDC; }, + (quote: ReturnType) => { quote.protocol.v2.orderData.inputs[0].refunds[0].currency = zeroAddress; }, + (quote: ReturnType) => { quote.protocol.v2.orderData.inputs[0].refunds[1].currency = ARC_USDC; }, + (quote: ReturnType) => { quote.protocol.v2.orderData.output.payments[0].currency = ARC_USDC; }, + (quote: ReturnType) => { quote.protocol.v2.orderData.output.payments[0].expectedAmount = (BigInt(quote.details.currencyOut.amount) + 1n).toString(); quote.details.currencyOut.amount = quote.protocol.v2.orderData.output.payments[0].expectedAmount; }, + (quote: ReturnType) => { quote.fees.relayer.amount = "60935"; }, + (quote: ReturnType) => { quote.fees.relayer.currency.decimals = 18; }, + (quote: ReturnType) => { quote.fees.gas.currency.address = BASE_USDC; quote.fees.gas.currency.symbol = "USDC"; quote.fees.gas.currency.decimals = 6; }, + ]) { + const { input, quote } = relayBaseUsdcFixture(); mutate(quote); + const metadata = validateRelayChains(relayChainsFixture(), input); + const orderId = getOrderId(quote.protocol.v2.orderData as Order, metadata.vmTypes); + quote.protocol.v2.orderId = orderId; + quote.steps[1].items[0].data.data = encodeFunctionData({ abi: ERC20_DEPOSIT_ABI, functionName: "depositErc20", args: [input.address, BASE_USDC, BigInt(input.amount), orderId] }); + assert.throws(() => validateRelayQuote(quote, input, metadata, BASE_USDC_FIXTURE_NOW), BridgeApiError); + } + const { input, quote } = relayRouteFixture(4663, 8453, "ETH", "USDC"); + quote.details.currencyOut.currency.decimals = 18; + assert.throws(() => validateRelayQuote(quote, input, validateRelayChains(relayChainsFixture(), input), FIXTURE_NOW), BridgeApiError); +}); + +test("Base USDC approval cannot borrow Arc's token, use unlimited amount or redirect spender", () => { + for (const variant of ["Arc token", "native token", "unlimited", "wrong spender", "wrong chain"]) { + const { input, quote } = relayBaseUsdcFixture(); + const tx = quote.steps[0].items[0].data; + if (variant === "Arc token") tx.to = ARC_USDC; + if (variant === "native token") tx.to = zeroAddress; + if (variant === "wrong chain") tx.chainId = 5042; + if (variant === "unlimited") tx.data = encodeFunctionData({ abi: APPROVAL_ABI, functionName: "approve", args: [RELAY_DEPOSITORY, (1n << 256n) - 1n] }); + if (variant === "wrong spender") tx.data = encodeFunctionData({ abi: APPROVAL_ABI, functionName: "approve", args: [input.address, BigInt(input.amount)] }); + assert.throws(() => validateRelayQuote(quote, input, validateRelayChains(relayChainsFixture(), input), BASE_USDC_FIXTURE_NOW), BridgeApiError, variant); + } +}); diff --git a/app/src/lib/bridge/relay.ts b/app/src/lib/bridge/relay.ts new file mode 100644 index 0000000..2703f2c --- /dev/null +++ b/app/src/lib/bridge/relay.ts @@ -0,0 +1,84 @@ +import "server-only"; +import { BridgeApiError, isRequestId, parseBridgeRequest, parseRelayStatus, validateRelayChains, validateRelayQuote } from "./validation"; +import { bridgeCurrency, type BridgeQuote, type BridgeQuoteRequest, type BridgeStatusResponse } from "./types"; + +const RELAY_API = "https://api.relay.link"; +const UPSTREAM_TIMEOUT_MS = 12_000; +export const BRIDGE_PRIVATE_HEADERS = { "Cache-Control": "private, no-store, max-age=0", "CDN-Cache-Control": "no-store" }; +type Fetcher = (url: string, init: RequestInit) => Promise; + +/** Count streamed bytes, not Content-Length alone; cap reads even for chunked bodies. */ +export async function readBridgeJson(body: ReadableStream | null, maxBytes: number, timeoutMs = 8_000): Promise { + if (!body) throw new BridgeApiError("Invalid JSON body.", 400); + const reader = body.getReader(); + const decoder = new TextDecoder("utf-8", { fatal: true }); + let bytes = 0; + let text = ""; + let timer: ReturnType | undefined; + const timeout = new Promise((_, reject) => { timer = setTimeout(() => reject(new BridgeApiError("The bridge request timed out. Please try again.", 504)), timeoutMs); }); + try { + while (true) { + const chunk = await Promise.race([reader.read(), timeout]); + if (chunk.done) break; + bytes += chunk.value.byteLength; + if (bytes > maxBytes) throw new BridgeApiError("The request is too large.", 413); + text += decoder.decode(chunk.value, { stream: true }); + } + text += decoder.decode(); + return JSON.parse(text); + } catch (error) { + void reader.cancel().catch(() => {}); + if (error instanceof BridgeApiError) throw error; + throw new BridgeApiError("Invalid JSON body.", 400); + } finally { + clearTimeout(timer); + reader.releaseLock(); + } +} + +async function relayJson(path: string, init: RequestInit, maxBytes: number, fetcher: Fetcher): Promise { + try { + const response = await fetcher(`${RELAY_API}${path}`, { + ...init, cache: "no-store", redirect: "error", signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS), + headers: { "Content-Type": "application/json", ...(process.env.RELAY_API_KEY ? { "x-api-key": process.env.RELAY_API_KEY } : {}) }, + }); + if (response.status === 429) throw new BridgeApiError("The bridge is busy. Please try again shortly.", 429); + if (response.status === 404) throw new BridgeApiError("Bridge status is not available yet. Please try again.", 404); + if (!response.ok) throw new BridgeApiError("No bridge quote is available for this amount. Please try again.", response.status >= 500 ? 503 : 422); + return await readBridgeJson(response.body, maxBytes, UPSTREAM_TIMEOUT_MS); + } catch (error) { + if (error instanceof BridgeApiError && [429, 404, 422, 503].includes(error.status)) throw error; + throw new BridgeApiError("The bridge service is unavailable. Please try again.", 502); + } +} + +export async function getBridgeQuote(request: BridgeQuoteRequest, fetcher: Fetcher = fetch, now: () => number = Date.now): Promise { + const input = parseBridgeRequest(request); + const [chains, quote] = await Promise.all([ + relayJson("/chains", { method: "GET" }, 2_000_000, fetcher), + relayJson("/quote/v2", { method: "POST", body: JSON.stringify({ + user: input.address, recipient: input.address, refundTo: input.address, originChainId: input.originChainId, + destinationChainId: input.destinationChainId, originCurrency: bridgeCurrency(input.originChainId, input.originAsset, "input").address, + destinationCurrency: bridgeCurrency(input.destinationChainId, input.destinationAsset, "output").address, amount: input.amount, tradeType: "EXACT_INPUT", + explicitDeposit: true, includeProtocolData: true, slippageTolerance: "50", + }) }, 128_000, fetcher), + ]); + try { + return validateRelayQuote(quote, input, validateRelayChains(chains, input), now()); + } catch (error) { + if (error instanceof BridgeApiError && error.status === 422) throw error; + throw new BridgeApiError("The bridge returned an unverifiable quote. Please try again."); + } +} + +export async function getBridgeStatus(requestId: string, fetcher: Fetcher = fetch): Promise { + if (!isRequestId(requestId)) throw new BridgeApiError("Invalid bridge request ID.", 400); + const response = await relayJson(`/intents/status/v3?requestId=${encodeURIComponent(requestId)}`, { method: "GET" }, 32_000, fetcher); + try { return parseRelayStatus(response); } + catch { throw new BridgeApiError("The bridge returned an unreadable status. Please try again."); } +} + +export function bridgeErrorResponse(error: unknown): Response { + const known = error instanceof BridgeApiError ? error : new BridgeApiError("The bridge service is unavailable. Please try again."); + return Response.json({ error: known.message }, { status: known.status, headers: { ...BRIDGE_PRIVATE_HEADERS, ...(known.status === 429 ? { "Retry-After": "5" } : {}) } }); +} diff --git a/app/src/lib/bridge/types.test.ts b/app/src/lib/bridge/types.test.ts new file mode 100644 index 0000000..ce21381 --- /dev/null +++ b/app/src/lib/bridge/types.test.ts @@ -0,0 +1,48 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { formatUnits, zeroAddress } from "viem"; +import { ARC_USDC, BASE_USDC, BRIDGE_ASSETS, bridgeCurrency, bridgeTransferInputCurrency, isBridgeAssetSupported } from "./types"; + +test("bridge assets are chain-scoped and never inferred from a symbol alone", () => { + assert.deepEqual(BRIDGE_ASSETS[8453], ["ETH", "USDC"]); + assert.deepEqual(BRIDGE_ASSETS[4663], ["ETH"]); + assert.deepEqual(BRIDGE_ASSETS[5042], ["USDC"]); + assert.equal(isBridgeAssetSupported(4663, "USDC"), false); + assert.equal(isBridgeAssetSupported(8453, BASE_USDC), false); + assert.equal(isBridgeAssetSupported(8453, "usdc"), false); + assert.throws(() => bridgeCurrency(4663, "USDC", "input")); + assert.throws(() => bridgeCurrency(4663, "USDC", "output")); + assert.throws(() => bridgeCurrency(5042, "ETH", "input")); +}); + +test("Base USDC uses the same six-decimal contract in both directions", () => { + for (const side of ["input", "output"] as const) { + assert.deepEqual(bridgeCurrency(8453, "USDC", side), { address: BASE_USDC, symbol: "USDC", decimals: 6 }); + } +}); + +test("Arc keeps distinct ERC20 input and native output representations", () => { + assert.deepEqual(bridgeCurrency(5042, "USDC", "input"), { address: ARC_USDC, symbol: "USDC", decimals: 6 }); + assert.deepEqual(bridgeCurrency(5042, "USDC", "output"), { address: zeroAddress, symbol: "USDC", decimals: 18 }); +}); + +test("omitted asset fields preserve legacy route semantics", () => { + for (const chain of [8453, 4663] as const) { + for (const side of ["input", "output"] as const) assert.deepEqual(bridgeCurrency(chain, undefined, side), { address: zeroAddress, symbol: "ETH", decimals: 18 }); + } + assert.equal(bridgeCurrency(5042, undefined, "input").address, ARC_USDC); + assert.equal(bridgeCurrency(5042, undefined, "output").address, zeroAddress); +}); + +test("recovered Arc native and ERC20 journals both display the original USDC amount", () => { + const native = bridgeTransferInputCurrency({ originChainId: 5042 }); + assert.equal(native.address, zeroAddress); + assert.equal(formatUnits(25n * 10n ** 18n, native.decimals), "25"); + const erc20 = bridgeTransferInputCurrency({ originChainId: 5042, depositKind: "erc20" }); + assert.equal(erc20.address, ARC_USDC); + assert.equal(formatUnits(25_000_000n, erc20.decimals), "25"); + const modern = bridgeTransferInputCurrency({ originChainId: 5042, originAsset: "USDC", depositKind: "erc20" }); + assert.deepEqual(modern, erc20); + assert.equal(bridgeTransferInputCurrency({ originChainId: 8453 }).decimals, 18); + assert.equal(bridgeTransferInputCurrency({ originChainId: 8453, originAsset: "USDC", depositKind: "erc20" }).decimals, 6); +}); diff --git a/app/src/lib/bridge/types.ts b/app/src/lib/bridge/types.ts new file mode 100644 index 0000000..8d736bd --- /dev/null +++ b/app/src/lib/bridge/types.ts @@ -0,0 +1,87 @@ +import type { Address, Hex } from "viem"; + +/** Bridge networks do not extend the launch registry. */ +export type BridgeChainId = 8453 | 4663 | 5042; +export const BRIDGE_CHAINS = { + 8453: { name: "Base", key: "base", explorer: "https://basescan.org", symbol: "ETH", decimals: 18 }, + 4663: { name: "Robinhood", key: "robinhood", explorer: "https://robinhoodchain.blockscout.com", symbol: "ETH", decimals: 18 }, + 5042: { name: "Arc", key: "arc", explorer: "https://explorer.arc.io", symbol: "USDC", decimals: 18 }, +} as const; +export const BRIDGE_CHAIN_IDS = [8453, 4663, 5042] as const; +// Arc's ERC-20 USDC interface shares the native balance, but has its own +// transfer semantics and SIX decimals. Never alias it to native zero-address USDC. +export const ARC_USDC = "0x3600000000000000000000000000000000000000" as const; +export const BASE_USDC = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" as const; +export type BridgeAsset = "ETH" | "USDC"; +export type BridgeCurrency = { address: Address; symbol: BridgeAsset; decimals: 6 | 18 }; +const NATIVE_ADDRESS = "0x0000000000000000000000000000000000000000" as const; +// Only assets whose identity has been independently verified belong here. +export const BRIDGE_ASSETS: Record = { + 8453: ["ETH", "USDC"], + 4663: ["ETH"], + 5042: ["USDC"], +}; +export function defaultBridgeAsset(chainId: BridgeChainId): BridgeAsset { + return BRIDGE_CHAINS[chainId].symbol; +} +export function isBridgeAssetSupported(chainId: BridgeChainId, asset: unknown): asset is BridgeAsset { + return typeof asset === "string" && BRIDGE_ASSETS[chainId].includes(asset as BridgeAsset); +} +export function bridgeCurrency(chainId: BridgeChainId, asset: BridgeAsset | undefined, side: "input" | "output"): BridgeCurrency { + const symbol = asset ?? defaultBridgeAsset(chainId); + if (!isBridgeAssetSupported(chainId, symbol)) throw new Error("This token is not supported on this bridge network."); + if (symbol === "ETH") return { address: NATIVE_ADDRESS, symbol, decimals: 18 }; + if (chainId === 8453) return { address: BASE_USDC, symbol, decimals: 6 }; + if (chainId === 5042) return { address: side === "input" ? ARC_USDC : NATIVE_ADDRESS, symbol, decimals: side === "input" ? 6 : 18 }; + throw new Error("USDC is not verified for this bridge network."); +} + +/** The original Arc journal recorded native (18-decimal) deposits, not ERC-20 units. */ +export function bridgeTransferInputCurrency(transfer: { originChainId: BridgeChainId; originAsset?: BridgeAsset; depositKind?: "erc20" }): BridgeCurrency { + if (transfer.originChainId === 5042 && transfer.originAsset === undefined && transfer.depositKind === undefined) { + return { address: NATIVE_ADDRESS, symbol: "USDC", decimals: 18 }; + } + return bridgeCurrency(transfer.originChainId, transfer.originAsset, "input"); +} +export const BRIDGE_INPUT_CURRENCIES = { + 8453: { address: "0x0000000000000000000000000000000000000000", symbol: "ETH", decimals: 18 }, + 4663: { address: "0x0000000000000000000000000000000000000000", symbol: "ETH", decimals: 18 }, + 5042: { address: ARC_USDC, symbol: "USDC", decimals: 6 }, +} as const; +export type BridgeApproval = { token: Address; spender: Address; amount: string }; + +export type BridgeQuoteRequest = { + address: Address; + originChainId: BridgeChainId; + destinationChainId: BridgeChainId; + originAsset?: BridgeAsset; // omitted only for legacy native/default requests + destinationAsset?: BridgeAsset; + amount: string; // input currency base units, never native-gas units for ERC-20s +}; + +export type BridgeQuote = BridgeQuoteRequest & { + requestId: Hex; + amountOut: string; + minimumAmountOut: string; + relayFee: string; // formatted in the source input currency + sourceGas: string; // formatted in the source chain's native currency + totalImpactPercent: string; + approval?: BridgeApproval; // allowlisted USDC -> pinned Relay depository, exact amount only + timeEstimate: number; + expiresAt: number; // milliseconds, shortened from the provider's order deadline + transaction: { to: Address; data: Hex; value: string; chainId: BridgeChainId }; +}; + +export type BridgeStatus = "waiting" | "depositing" | "pending" | "submitted" | "delayed" | "success" | "refund" | "failure"; +export type BridgeStatusResponse = { + status: BridgeStatus; + inTxHashes: Hex[]; + txHashes: Hex[]; +}; + +export function isBridgeChainId(value: unknown): value is BridgeChainId { + return value === 8453 || value === 4663 || value === 5042; +} + +/** Default destination only; users can select either of the other networks. */ +export const otherBridgeChain = (id: BridgeChainId): BridgeChainId => id === 8453 ? 4663 : 8453; diff --git a/app/src/lib/bridge/validation.test.ts b/app/src/lib/bridge/validation.test.ts new file mode 100644 index 0000000..313741f --- /dev/null +++ b/app/src/lib/bridge/validation.test.ts @@ -0,0 +1,102 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { getOrderId, type Order } from "./relay-order.ts"; +import { encodeFunctionData, zeroAddress } from "viem"; +import { BridgeApiError, DEPOSIT_ABI, QUOTE_TTL_MS, parseBridgeRequest, parseRelayStatus, validateRelayChains, validateRelayQuote } from "./validation.ts"; +import { FIXTURE_ADDRESS, FIXTURE_INPUT, FIXTURE_NOW, FIXTURE_ORDER_ID, FIXTURE_REQUEST_ID, relayChainsFixture, relayQuoteFixture } from "./relay.fixture.ts"; + +const metadata = () => validateRelayChains(relayChainsFixture()); +const validate = (quote: unknown) => validateRelayQuote(quote, FIXTURE_INPUT, metadata(), FIXTURE_NOW); +function setPath(value: unknown, path: string, replacement: unknown) { + const parts = path.split("."); + let target = value as Record; + for (const part of parts.slice(0, -1)) target = target[part] as Record; + target[parts.at(-1)!] = replacement; +} +function rebind(quote: ReturnType) { + const orderId = getOrderId(quote.protocol.v2.orderData as unknown as Order, metadata().vmTypes); + quote.protocol.v2.orderId = orderId; + quote.steps[0].items[0].data.data = encodeFunctionData({ abi: DEPOSIT_ABI, functionName: "depositNative", args: [FIXTURE_ADDRESS, orderId] }); +} + +test("captured native quote hashes to the independently captured order ID and normalizes ETH fees", () => { + const fixture = relayQuoteFixture(); + assert.equal(getOrderId(fixture.protocol.v2.orderData as unknown as Order, metadata().vmTypes), FIXTURE_ORDER_ID); + const quote = validate(fixture); + assert.equal(quote.requestId, FIXTURE_REQUEST_ID); + assert.equal(quote.amountOut, "9987669548275956"); + assert.equal(quote.minimumAmountOut, "9937731200534577"); + assert.equal(quote.relayFee, "0.000012330451724044"); + assert.equal(quote.sourceGas, "0.000003"); + assert.equal(quote.expiresAt, FIXTURE_NOW + QUOTE_TTL_MS); + assert.deepEqual(quote.transaction, { to: fixture.steps[0].items[0].data.to, data: fixture.steps[0].items[0].data.data, value: FIXTURE_INPUT.amount, chainId: 8453 }); +}); + +for (const [name, value] of [ + ["zero amount", { ...FIXTURE_INPUT, amount: "0" }], ["negative", { ...FIXTURE_INPUT, amount: "-1" }], + ["fraction", { ...FIXTURE_INPUT, amount: "0.1" }], ["exponent", { ...FIXTURE_INPUT, amount: "1e18" }], + ["uint256 overflow", { ...FIXTURE_INPUT, amount: (1n << 256n).toString() }], + ["same chain", { ...FIXTURE_INPUT, destinationChainId: 8453 }], ["testnet", { ...FIXTURE_INPUT, destinationChainId: 46630 }], + ["zero recipient", { ...FIXTURE_INPUT, address: zeroAddress }], ["injected recipient", { ...FIXTURE_INPUT, recipient: FIXTURE_ADDRESS }], ["null", null], +] as const) { + test(`rejects request: ${name}`, () => assert.throws(() => parseBridgeRequest(value), (e) => e instanceof BridgeApiError && e.status === 400)); +} + +const orderAttacks: [string, unknown][] = [ + ["output.payments.0.recipient", "0x1111111111111111111111111111111111111111"], + ["output.payments.0.currency", "0x1111111111111111111111111111111111111111"], + ["output.chainId", "base"], ["inputs.0.payment.chainId", "robinhood"], + ["inputs.0.payment.amount", "20000000000000000"], ["inputs.0.payment.currency", "0x1111111111111111111111111111111111111111"], + ["output.payments.0.minimumAmount", "1"], ["output.deadline", Math.floor(FIXTURE_NOW / 1000)], + ["inputs.0.refunds.0.recipient", "0x1111111111111111111111111111111111111111"], + ["inputs.0.refunds.0.chainId", "robinhood"], ["output.extraData", "0x"], + ["solver", "0x1111111111111111111111111111111111111111"], +]; +for (const [path, value] of orderAttacks) { + test(`rejects malicious order even when its hash and calldata are rebound: ${path}`, () => { + const fixture = relayQuoteFixture(); + setPath(fixture.protocol.v2.orderData, path, value); + rebind(fixture); + assert.throws(() => validate(fixture), BridgeApiError); + }); +} + +for (const [path, value] of [ + ["protocol.v2.orderId", `0x${"1".repeat(64)}`], + ["protocol.v2.paymentDetails.depository", FIXTURE_ADDRESS], + ["steps.0.items.0.data.to", FIXTURE_ADDRESS], ["steps.0.items.0.data.from", zeroAddress], + ["steps.0.items.0.data.value", "10000000000000001"], ["steps.0.items.0.data.chainId", 4663], + ["steps.0.items.0.data.data", "0x"], ["steps.0.items.0.check.endpoint", "https://attacker.example/execute"], + ["steps.0.kind", "signature"], ["steps.0.depositAddress", FIXTURE_ADDRESS], + ["details.recipient", zeroAddress], ["details.currencyOut.amount", "10000000000000000"], + ["fees.app.amount", "1"], ["fees.relayer.amount", "0"], ["fees.gas.currency.chainId", 4663], +] as [string, unknown][]) { + test(`rejects tampered quote: ${path}`, () => { + const fixture = relayQuoteFixture(); setPath(fixture, path, value); + assert.throws(() => validate(fixture), BridgeApiError); + }); +} + +test("rejects additional transactions, output calls, input payments, and protocol fees", () => { + for (const path of ["steps", "steps.0.items", "protocol.v2.orderData.inputs", "protocol.v2.orderData.output.calls", "protocol.v2.orderData.fees"]) { + const fixture = relayQuoteFixture(); + setPath(fixture, path, [{}, {}]); + assert.throws(() => validate(fixture), BridgeApiError, path); + } +}); + +test("canonical metadata rejects a changed depository, missing or disabled chain", () => { + for (const [path, value] of [["chains.0.protocol.v2.depository", FIXTURE_ADDRESS], ["chains.1.disabled", true], ["chains.1.currency.supportsBridging", false], ["chains.1.protocol.v2.chainId", "robinhood-testnet"]] as [string, unknown][]) { + const fixture = relayChainsFixture(); setPath(fixture, path, value); + assert.throws(() => validateRelayChains(fixture), BridgeApiError); + } + assert.throws(() => validateRelayChains({ chains: [relayChainsFixture().chains[0]] }), BridgeApiError); +}); + +test("status distinguishes refunds and delays and accepts waiting before hashes exist", () => { + assert.deepEqual(parseRelayStatus({ status: "waiting" }), { status: "waiting", inTxHashes: [], txHashes: [] }); + assert.equal(parseRelayStatus({ status: "delayed" }).status, "delayed"); + assert.equal(parseRelayStatus({ status: "refund", txHashes: [FIXTURE_REQUEST_ID] }).status, "refund"); + assert.throws(() => parseRelayStatus({ status: "success", txHashes: ["javascript:alert(1)"] }), BridgeApiError); + assert.throws(() => parseRelayStatus({ status: "success", destinationChainId: 1 }), BridgeApiError); +}); diff --git a/app/src/lib/bridge/validation.ts b/app/src/lib/bridge/validation.ts new file mode 100644 index 0000000..3333506 --- /dev/null +++ b/app/src/lib/bridge/validation.ts @@ -0,0 +1,223 @@ +import "server-only"; +import { getOrderId, type Order } from "./relay-order"; +import { encodeFunctionData, formatEther, formatUnits, isAddress, parseAbi, zeroAddress, type Hex } from "viem"; +import { BRIDGE_CHAINS, bridgeCurrency, isBridgeAssetSupported, isBridgeChainId, type BridgeCurrency, type BridgeChainId, type BridgeQuote, type BridgeQuoteRequest, type BridgeStatus, type BridgeStatusResponse } from "./types"; + +// Independently pinned, then checked against GET /chains. Never trust a quote to +// supply the address against which that same quote is validated. +export const RELAY_DEPOSITORY = "0x4cd00e387622c35bddb9b4c962c136462338bc31"; +const RELAY_ROUTER = "0xb92fe925dc43a0ecde6c8b1a2709c170ec4fff4f"; +export const DEPOSIT_ABI = parseAbi(["function depositNative(address depositor, bytes32 id) payable"]); +export const ERC20_DEPOSIT_ABI = parseAbi(["function depositErc20(address depositor, address token, uint256 amount, bytes32 id)"]); +export const APPROVAL_ABI = parseAbi(["function approve(address spender, uint256 amount) returns (bool)"]); +export const QUOTE_TTL_MS = 45_000; +const DEADLINE_MARGIN_MS = 15_000; +const UINT256_MAX = (1n << 256n) - 1n; +const HASH = /^0x[0-9a-fA-F]{64}$/; +const NATIVE_SYMBOLS = { 8453: "ETH", 4663: "ETH", 5042: "USDC" } as const; +const MAX_TOTAL_LOSS_PERCENT = 5; +type ObjectValue = Record; + +export class BridgeApiError extends Error { + constructor(message: string, public readonly status = 502) { super(message); } +} + +function ensure(condition: unknown): asserts condition { + if (!condition) throw new BridgeApiError("The bridge returned an unverifiable quote. Please try again."); +} +function object(value: unknown): ObjectValue { + ensure(value && typeof value === "object" && !Array.isArray(value)); + return value as ObjectValue; +} +function list(value: unknown, length?: number): unknown[] { + ensure(Array.isArray(value) && (length === undefined || value.length === length)); + return value; +} +function sameAddress(value: unknown, expected: string): boolean { + return typeof value === "string" && value.toLowerCase() === expected.toLowerCase(); +} +function uint(value: unknown, positive = false): string { + ensure(typeof value === "string" && /^(0|[1-9][0-9]{0,77})$/.test(value)); + ensure(BigInt(value) <= UINT256_MAX && (!positive || BigInt(value) > 0n)); + return value; +} +export function isRequestId(value: unknown): value is Hex { + return typeof value === "string" && HASH.test(value) && !/^0x0{64}$/.test(value); +} + +export function parseBridgeRequest(value: unknown): BridgeQuoteRequest { + try { + const b = object(value); + const required = ["address", "originChainId", "destinationChainId", "amount"]; + ensure(required.every((key) => Object.hasOwn(b, key)) && Object.keys(b).every((key) => [...required, "originAsset", "destinationAsset"].includes(key))); + ensure(typeof b.address === "string" && isAddress(b.address, { strict: false }) && !sameAddress(b.address, zeroAddress)); + ensure(isBridgeChainId(b.originChainId) && isBridgeChainId(b.destinationChainId) && b.originChainId !== b.destinationChainId); + ensure(b.originAsset === undefined || isBridgeAssetSupported(b.originChainId, b.originAsset)); + ensure(b.destinationAsset === undefined || isBridgeAssetSupported(b.destinationChainId, b.destinationAsset)); + const amount = uint(b.amount, true); + // ERC20 max is an unlimited-allowance sentinel, never an exact approval. + ensure(sameAddress(bridgeCurrency(b.originChainId, b.originAsset, "input").address, zeroAddress) || BigInt(amount) < UINT256_MAX); + return { address: b.address as BridgeQuoteRequest["address"], originChainId: b.originChainId, destinationChainId: b.destinationChainId, amount, ...(b.originAsset === undefined ? {} : { originAsset: b.originAsset }), ...(b.destinationAsset === undefined ? {} : { destinationAsset: b.destinationAsset }) }; + } catch { + throw new BridgeApiError("Enter a valid wallet, amount, and supported bridge route.", 400); + } +} + +export type RelayChainMetadata = { + vmTypes: Record; + solverAddresses: string[]; +}; + +export function validateRelayChains(value: unknown, route: Pick = { originChainId: 8453, destinationChainId: 4663 }): RelayChainMetadata { + const chains = list(object(value).chains); + // Base hosts the solver hub even for Robinhood↔Arc. Unrelated route outages + // must not disable otherwise valid routes, so only inspect these chains. + const required = [...new Set([8453, route.originChainId, route.destinationChainId])]; + const selected = required.map((id) => { + const matches = chains.filter((chain) => object(chain).id === id); + const chain = object(list(matches, 1)[0]); + const v2 = object(object(chain.protocol).v2); + const currency = object(chain.currency); + const contracts = object(chain.contracts); + ensure(chain.vmType === "evm" && chain.disabled === false && chain.depositEnabled === true && chain.blockProductionLagging !== true); + ensure(v2.chainId === BRIDGE_CHAINS[id].key && sameAddress(v2.depository, RELAY_DEPOSITORY)); + ensure(sameAddress(currency.address, zeroAddress) && currency.decimals === 18 && currency.symbol === NATIVE_SYMBOLS[id] && currency.supportsBridging === true); + ensure(sameAddress(contracts.erc20Router, RELAY_ROUTER)); + return chain; + }); + const solvers = list(selected.find((chain) => chain.id === 8453)!.solverAddresses); + ensure(solvers.length > 0 && solvers.every((v) => typeof v === "string" && isAddress(v, { strict: false }))); + return { vmTypes: Object.fromEntries(required.map((id) => [BRIDGE_CHAINS[id].key, "ethereum-vm" as const])), solverAddresses: solvers as string[] }; +} + +/** Implements Relay's protocol.v2 input validation for allowlisted ETH / USDC. */ +export function validateRelayQuote(value: unknown, input: BridgeQuoteRequest, chains: RelayChainMetadata, now = Date.now()): BridgeQuote { + const quote = object(value); + const protocol = object(object(quote.protocol).v2); + const order = object(protocol.orderData); + const source = BRIDGE_CHAINS[input.originChainId].key; + const destination = BRIDGE_CHAINS[input.destinationChainId].key; + const inputCurrency = bridgeCurrency(input.originChainId, input.originAsset, "input"); + const outputCurrency = bridgeCurrency(input.destinationChainId, input.destinationAsset, "output"); + const erc20Input = !sameAddress(inputCurrency.address, zeroAddress); + ensure(!erc20Input || BigInt(uint(input.amount, true)) < UINT256_MAX); + const sameAsset = inputCurrency.symbol === outputCurrency.symbol; + const inputScale = 10n ** BigInt(inputCurrency.decimals); + const outputScale = 10n ** BigInt(outputCurrency.decimals); + ensure(protocol.hubType === "onchain" && order.version === "v1" && order.solverChainId === "base"); + ensure(chains.solverAddresses.some((solver) => sameAddress(order.solver, solver)) && isRequestId(order.salt)); + const orderInput = object(list(order.inputs, 1)[0]); + const payment = object(orderInput.payment); + ensure(payment.chainId === source && sameAddress(payment.currency, inputCurrency.address) && uint(payment.amount, true) === input.amount && payment.weight === "1"); + const output = object(order.output); + ensure(output.chainId === destination && list(output.calls, 0) && list(order.fees, 0)); + const routerData = `0x${RELAY_ROUTER.slice(2).padStart(64, "0")}`; + ensure(sameAddress(output.extraData, routerData)); + ensure(typeof output.deadline === "number" && Number.isSafeInteger(output.deadline)); + const expiresAt = Math.min(now + QUOTE_TTL_MS, output.deadline * 1000 - DEADLINE_MARGIN_MS); + ensure(expiresAt >= now + 5_000); + const outputPayment = object(list(output.payments, 1)[0]); + ensure(sameAddress(outputPayment.recipient, input.address) && sameAddress(outputPayment.currency, outputCurrency.address)); + const amountOut = uint(outputPayment.expectedAmount, true); + const minimumAmountOut = uint(outputPayment.minimumAmount, true); + ensure(BigInt(minimumAmountOut) <= BigInt(amountOut)); + if (sameAsset) ensure(BigInt(amountOut) * inputScale <= BigInt(input.amount) * outputScale); + ensure(BigInt(minimumAmountOut) >= BigInt(amountOut) * 9950n / 10000n); + // A failed fill may refund on either chain, but can never redirect the refund. + const refunds = list(orderInput.refunds, 2).map(object); + ensure(new Set(refunds.map((refund) => refund.chainId)).size === 2); + for (const refund of refunds) { + ensure((refund.chainId === source || refund.chainId === destination) && sameAddress(refund.recipient, input.address)); + const refundCurrency = refund.chainId === source ? inputCurrency.address : outputCurrency.address; + ensure(sameAddress(refund.currency, refundCurrency) && uint(refund.minimumAmount) === "0" && refund.deadline === output.deadline && sameAddress(refund.extraData, routerData)); + } + const paymentDetails = object(protocol.paymentDetails); + ensure(paymentDetails.chainId === source && sameAddress(paymentDetails.depository, RELAY_DEPOSITORY) && sameAddress(paymentDetails.currency, inputCurrency.address) && uint(paymentDetails.amount) === input.amount); + ensure(isRequestId(protocol.orderId)); + // Use the provider's maintained encoding, never a locally invented order hash. + const computedOrderId = getOrderId(order as unknown as Order, chains.vmTypes); + ensure(sameAddress(computedOrderId, protocol.orderId)); + + const steps = list(quote.steps); + ensure(steps.length === 1 || (erc20Input && steps.length === 2)); + const step = object(steps[steps.length - 1]); + ensure(step.id === "deposit" && step.kind === "transaction" && isRequestId(step.requestId) && !step.depositAddress); + if (steps.length === 2) { + const approvalStep = object(steps[0]); + ensure(approvalStep.id === "approve" && approvalStep.kind === "transaction" && approvalStep.requestId === step.requestId && !approvalStep.depositAddress); + const approvalItem = object(list(approvalStep.items, 1)[0]); + ensure(approvalItem.status === "incomplete"); + const approvalTx = object(approvalItem.data); + const approvalData = encodeFunctionData({ abi: APPROVAL_ABI, functionName: "approve", args: [RELAY_DEPOSITORY, BigInt(input.amount)] }); + ensure(sameAddress(approvalTx.from, input.address) && sameAddress(approvalTx.to, inputCurrency.address) && approvalTx.chainId === input.originChainId && uint(approvalTx.value) === "0" && sameAddress(approvalTx.data, approvalData)); + } + const item = object(list(step.items, 1)[0]); + ensure(item.status === "incomplete"); + const tx = object(item.data); + const depositValue = erc20Input ? "0" : input.amount; + ensure(sameAddress(tx.from, input.address) && sameAddress(tx.to, RELAY_DEPOSITORY) && tx.chainId === input.originChainId && uint(tx.value) === depositValue); + const calldata = erc20Input + ? encodeFunctionData({ abi: ERC20_DEPOSIT_ABI, functionName: "depositErc20", args: [input.address, inputCurrency.address, BigInt(input.amount), computedOrderId] }) + : encodeFunctionData({ abi: DEPOSIT_ABI, functionName: "depositNative", args: [input.address, computedOrderId] }); + ensure(sameAddress(tx.data, calldata)); // exact encoding also rejects trailing bytes + const check = object(item.check); + ensure(check.method === "GET" && check.endpoint === `/intents/status/v3?requestId=${step.requestId}`); + + const details = object(quote.details); + ensure(sameAddress(details.sender, input.address) && sameAddress(details.recipient, input.address)); + const checkCurrency = (value: unknown, chainId: BridgeChainId, amount: string, expected: BridgeCurrency) => { + const money = object(value); + const currency = object(money.currency); + ensure(currency.chainId === chainId && sameAddress(currency.address, expected.address) && currency.decimals === expected.decimals && currency.symbol === expected.symbol && uint(money.amount) === amount); + return money; + }; + checkCurrency(details.currencyIn, input.originChainId, input.amount, inputCurrency); + ensure(checkCurrency(details.currencyOut, input.destinationChainId, amountOut, outputCurrency).minimumAmount === minimumAmountOut); + const fees = object(quote.fees); + const relayerAmount = uint(object(fees.relayer).amount); + const gasAmount = uint(object(fees.gas).amount); + checkCurrency(fees.relayer, input.originChainId, relayerAmount, inputCurrency); + checkCurrency(fees.gas, input.originChainId, gasAmount, { address: zeroAddress, decimals: 18, symbol: NATIVE_SYMBOLS[input.originChainId] }); + ensure(BigInt(relayerAmount) < BigInt(input.amount)); + if (BigInt(relayerAmount) * 100n > BigInt(input.amount) * BigInt(MAX_TOTAL_LOSS_PERCENT)) { + throw new BridgeApiError("This quote charges more than 5% in bridge fees. Try a different amount or wait for a better quote.", 422); + } + // Same-symbol USDC still has 6/18-decimal interfaces. Cross multiplication + // preserves exact fee equality without truncating either amount. ETH↔USDC + // instead uses the bounded source fee and Relay's market-impact estimate. + if (sameAsset) ensure(BigInt(relayerAmount) * outputScale === BigInt(input.amount) * outputScale - BigInt(amountOut) * inputScale); + ensure(uint(object(fees.app).amount) === "0"); + if (fees.subsidized !== undefined) ensure(uint(object(fees.subsidized).amount) === "0"); + const totalImpactPercent = object(details.totalImpact).percent; + ensure(typeof totalImpactPercent === "string" && totalImpactPercent.length <= 32 && /^-?\d+(?:\.\d+)?$/.test(totalImpactPercent)); + const negativeImpact = totalImpactPercent.startsWith("-"); + const [wholeImpact, fractionalImpact = ""] = (negativeImpact ? totalImpactPercent.slice(1) : totalImpactPercent).split("."); + const impactMagnitude = BigInt(wholeImpact + fractionalImpact); + const impactScale = 10n ** BigInt(fractionalImpact.length); + ensure(impactMagnitude <= 100n * impactScale); + if (negativeImpact && impactMagnitude > BigInt(MAX_TOTAL_LOSS_PERCENT) * impactScale) throw new BridgeApiError("This quote loses more than 5% in fees and price impact. Try a different amount or wait for a better quote.", 422); + ensure(typeof details.timeEstimate === "number" && Number.isFinite(details.timeEstimate) && details.timeEstimate >= 0 && details.timeEstimate <= 86400); + return { + ...input, requestId: step.requestId, amountOut, minimumAmountOut, + relayFee: formatUnits(BigInt(relayerAmount), inputCurrency.decimals), sourceGas: formatEther(BigInt(gasAmount)), totalImpactPercent, + ...(erc20Input ? { approval: { token: inputCurrency.address, spender: RELAY_DEPOSITORY, amount: input.amount } } : {}), + timeEstimate: details.timeEstimate, expiresAt, + transaction: { to: RELAY_DEPOSITORY, data: calldata, value: depositValue, chainId: input.originChainId }, + }; +} + +export function parseRelayStatus(value: unknown): BridgeStatusResponse { + const response = object(value); + const statuses: BridgeStatus[] = ["waiting", "depositing", "pending", "submitted", "delayed", "success", "refund", "failure"]; + ensure(statuses.includes(response.status as BridgeStatus)); + for (const key of ["originChainId", "destinationChainId"]) { + if (response[key] !== undefined) ensure(isBridgeChainId(response[key])); + } + const hashes = (value: unknown): Hex[] => { + if (value === undefined) return []; + const values = list(value); + ensure(values.length <= 20 && values.every(isRequestId)); + return values as Hex[]; + }; + return { status: response.status as BridgeStatus, inTxHashes: hashes(response.inTxHashes), txHashes: hashes(response.txHashes) }; +} diff --git a/app/src/lib/security-headers.test.ts b/app/src/lib/security-headers.test.ts index 6df1fe3..bcabdec 100644 --- a/app/src/lib/security-headers.test.ts +++ b/app/src/lib/security-headers.test.ts @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import test from "node:test"; -import { SECURITY_HEADERS, WALLET_CONNECT_SRC, buildCsp, cspNonce, extraConnectOrigins, isOrigin } from "./security-headers.ts"; +import { SECURITY_HEADERS, WALLET_CONNECT_SRC, BRIDGE_CONNECT_SRC, buildCsp, cspNonce, extraConnectOrigins, isOrigin } from "./security-headers.ts"; const NONCE = "AAAAAAAAAAAAAAAAAAAAAA=="; @@ -37,6 +37,9 @@ test("connect-src covers the site and the wallet SDK, plus vetted extra origins const connect = directive(csp, "connect-src"); assert.ok(connect.startsWith("connect-src 'self' ")); for (const origin of WALLET_CONNECT_SRC) assert.ok(connect.includes(` ${origin}`), `missing ${origin}`); + assert.deepEqual(BRIDGE_CONNECT_SRC, ["https://rpc.mainnet.arc.io"]); + assert.ok(connect.split(" ").includes("https://rpc.mainnet.arc.io")); + assert.ok(!connect.split(" ").includes("https:")); assert.ok(connect.includes(" http://127.0.0.1:8545")); assert.ok(connect.includes(" https://openlaunch.lol")); assert.doesNotMatch(connect, /evil|javascript|not a url/); diff --git a/app/src/lib/security-headers.ts b/app/src/lib/security-headers.ts index 2c2d0e9..088bdf6 100644 --- a/app/src/lib/security-headers.ts +++ b/app/src/lib/security-headers.ts @@ -19,6 +19,10 @@ export const WALLET_CONNECT_SRC = [ "wss://www.walletlink.org", ] as const; +// Arc is registered for bridge balance/gas/receipt reads only. Quote and status +// requests remain same-origin. Pin this one official RPC, never an arbitrary URL. +export const BRIDGE_CONNECT_SRC = ["https://rpc.mainnet.arc.io"] as const; + const NONCE_RE = /^[A-Za-z0-9+/]{16,}={0,2}$/; /** Base64 of at least 16 random bytes. Runs in Node and the browser runtime alike. */ @@ -54,7 +58,7 @@ export function isOrigin(value: string): boolean { */ export function buildCsp(nonce: string, { dev = false, connectSrc = [] }: CspOptions = {}): string { if (!NONCE_RE.test(nonce)) throw new Error("csp nonce must be base64"); - const connect = new Set(["'self'", ...WALLET_CONNECT_SRC, ...connectSrc.filter(isOrigin)]); + const connect = new Set(["'self'", ...WALLET_CONNECT_SRC, ...BRIDGE_CONNECT_SRC, ...connectSrc.filter(isOrigin)]); if (dev) for (const s of ["ws:", "http://localhost:*", "http://127.0.0.1:*"]) connect.add(s); const directives = [ "default-src 'self'", diff --git a/app/src/lib/wagmi.ts b/app/src/lib/wagmi.ts index 0bf5ac0..d3e8182 100644 --- a/app/src/lib/wagmi.ts +++ b/app/src/lib/wagmi.ts @@ -2,15 +2,17 @@ import { createConfig, http } from "wagmi"; import { injected, coinbaseWallet } from "wagmi/connectors"; import { CHAINS, robinhood } from "./chainPublic"; import { browserRpc } from "./launchpad/config"; +import { arc } from "./bridge/chains"; -// Two chains, one config. Reads/simulations go through our RPC proxy per chain -// (or a dev override); wallets send transactions through their own provider. +// Launch chains use our read-only RPC proxy. Arc is registered for bridging +// only, using its official RPC; wallet transactions use the wallet's provider. export const wagmiConfig = createConfig({ - chains: [CHAINS.base, robinhood], + chains: [CHAINS.base, robinhood, arc], connectors: [injected(), coinbaseWallet({ appName: "openlaunch.lol", preference: { options: "all", telemetry: false } })], transports: { [CHAINS.base.id]: http(browserRpc("base"), { batch: true }), [robinhood.id]: http(browserRpc("robinhood"), { batch: true }), + [arc.id]: http(arc.rpcUrls.default.http[0], { batch: true }), }, ssr: true, }); diff --git a/docs/bridge-surface.md b/docs/bridge-surface.md new file mode 100644 index 0000000..397b7f8 --- /dev/null +++ b/docs/bridge-surface.md @@ -0,0 +1,13 @@ +# Bridge panel direction + +THESIS: Fund Base, Robinhood and Arc without losing your place in Openlaunch, with explicit ETH/USDC conversion and approval boundaries. + +OWN-WORLD: Inherit Clear Sky's blue, flat surfaces, existing wallet identity, and light/dark tokens. No new brand, chain registry, or asset imagery. + +STORY: Choose source and destination, enter the source asset, review output and costs, approve exact USDC if required, review again, confirm the deposit, follow delivery. Keep the destination, gas currency and recipient visible. + +FIRST VIEWPORT: A restrained Bridge heading leads into two selectors offering three networks, a generous amount input, and one clear action. Fine separators organize costs; no nested dashboard cards. + +FORM: Local extension of the navigation/wallet surface, using a protected-focus dialog for a financial action. Its mobile version preserves the same hierarchy. No concept seed required. + +FINISH: unreviewed and undocumented is unfinished; this build ends with the finish review, the verdict, DESIGN.md, and every shipping raster carrying its provenance diff --git a/docs/bridge.md b/docs/bridge.md new file mode 100644 index 0000000..83b38bd --- /dev/null +++ b/docs/bridge.md @@ -0,0 +1,90 @@ +# Base, Robinhood and Arc bridge + +The header's **Bridge** action opens a focused funding panel without leaving the current token or launch page. It supports Base (8453), Robinhood Chain (4663), and Arc mainnet (5042), to the same connected wallet address. Base sends/receives ETH or official USDC; Robinhood sends/receives ETH. Arc receives native USDC and sends USDC through its ERC-20 interface. Relay converts ETH and USDC at the reviewed quote. This does not add launched tokens or GITLAWB, change the launch-chain registry, or deploy contracts. + +## Integration + +- Relay supplies quotes and transfer status. Openlaunch charges no application fee; Relay and source-network gas still cost money. +- `POST /api/bridge/quote` accepts `{ address, originChainId, destinationChainId, originAsset, destinationAsset, amount }`. Asset choices are allowlisted `ETH` or `USDC` for that chain, never arbitrary addresses. Omitted asset fields retain legacy defaults: Base/Robinhood ETH, Arc USDC. The integer amount uses 18 decimals for ETH inputs and **6 decimals for USDC inputs**. Outputs use 6 decimals for Base USDC, 18 for ETH or Arc native USDC. `GET /api/bridge/status?requestId=…` returns normalized provider state and transaction hashes. +- Base USDC is Circle's `0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913`. Its ERC-20 balance and allowance are checked separately from native ETH gas. An approval and deposit both require an ETH reserve; a USDC balance cannot pay Base gas. The picker, fee labels, quote identity and recovery journal all retain the selected asset. +- Requests use a fixed upstream, timeout and streamed body-size bounds, existing per-IP limits, and private/no-store caching. The API does not need a wallet signature. Relay quotes/status stay server-side. CSP permits only the pinned official `https://rpc.mainnet.arc.io` for Arc wallet balance, allowance, gas and receipt reads; script restrictions are unchanged. +- Arc's native USDC (18 decimals) and ERC-20 interface `0x3600000000000000000000000000000000000000` (6 decimals) share one balance but are **not interchangeable transfer interfaces**. Input parsing, deposit verification and recovery distinguish them. Gas budgeting converts the USDC input into native units and leaves a reserve for network fees. +- `RELAY_API_KEY` is optional, server-only, and forwarded as `x-api-key` if configured. Read-only smoke tests passed without a key. Higher production traffic may require a Relay key or adjusted provider limits. Do not expose the key through a `NEXT_PUBLIC_` variable. +- The server reconstructs the order hash using a narrow EVM/v1-only extraction of the MIT-licensed `@relay-protocol/settlement-sdk` 0.0.143 schema, normalizer and hashing algorithm, with the existing Viem dependency. This avoids shipping the SDK's unrelated multi-chain dependency subtree and its known vulnerabilities. The exact npm tarball, gitHead, upstream source SHA-256 hashes, MIT notice and 256 official-SDK compatibility vectors are recorded in [the provenance notice](../app/src/lib/bridge/relay-order.NOTICE.md); unsupported protocol versions and non-EVM orders fail closed. + +## Transfer safety + +Quotes request `explicitDeposit`, `includeProtocolData`, and an explicit `refundTo` matching the connected wallet. The server verifies the order hash, route-specific chain metadata, independently pinned depository/router, exact input/output currencies, recipient/depositor, refund destinations, minimum output, deadline, and canonical calldata. ETH sources accept only `depositNative`. USDC sources accept only an exact-amount approval to the pinned Relay depository and the four-argument `depositErc20(depositor, currency, amount, orderId)`. Unlimited approvals, generic router/multicall transactions, extra steps, output calls and application fees are rejected. An unavailable route fails closed rather than silently changing assets or providers. + +The client binds the quote to the displayed account, direction and amount, rejects changed/expired quotes, then rechecks the wallet and source balance with fresh gas estimates before asking the wallet to send. Quotes are available for at most 45 seconds, with 0.5% output slippage. It rejects Relay fees above 5% of input and provider-reported total value loss above 5%. The latter is Relay's market estimate, not an independent fair-price oracle. ETH and USDC raw amounts are never subtracted from one another. No wallet request happens automatically. Inputs must leave room for source gas. + +Base and Arc USDC sends check the exact allowance. If it is insufficient, approval is a separate user action with its own durable recovery record and transaction hash. Approval alone does not create a bridge transfer. Once approval confirms, the user reviews a **fresh quote** and explicitly confirms the deposit. An interrupted approval response must be resolved before another approval is requested. An unspent approval remains on-chain until used or revoked; rejection of a later deposit does not revoke it. + +An approval without a returned hash can be recovered using the actual mined transaction hash from the wallet. Recovery verifies the saved source chain, exact token/spender/amount, transaction or canonical approval event, receipt, fresh allowance, and a block timestamp no earlier than 30 seconds before the saved attempt. That clock check is a bounded heuristic, not unique nonce proof. Allowance alone cannot resolve an unknown broadcast; replaced or cancelled transactions without a matching recoverable hash remain blocked for manual investigation. Approval gas is additional to the later deposit gas estimate. + +Before asking the wallet to send, the app stores a versioned, per-wallet recovery record locally. Pending transfers resume when Bridge is reopened with that wallet. Local storage and Web Locks are required to protect against duplicate submissions in multiple tabs. Private browsing or restrictive browser policies can prevent a transfer from starting. + +An interrupted wallet response is **uncertain**, not failed. The app must not silently resend. A provider outage or missing receipt is not evidence that funds were lost. Relay's transfer page and verified explorer links remain available for recovery. Provider-reported success and refund states are labeled accordingly; a refund can arrive on either route chain. + +## Limits and rollout + +- Three mainnet networks, same-wallet recipient only. ETH on Base/Robinhood and USDC on Base/Arc. No arbitrary tokens, recipients, unlimited allowances, deposits from exchanges, custom swap calls, or extra networks. +- Robinhood USDC remains unavailable. On 2026-09-16, the official canonical gateway mapped Ethereum USDC to bridged `0x80e0e24718dbFcad49ECAA6F1e6C89A190586cA8` (6 decimals); this is not Circle-native issuance. Read-only Relay probes at 25 USDC returned roughly 21% loss outbound, while inbound routes were rejected for roughly 92% swap impact. Outbound responses required an approval-proxy/swap flow, not our validated direct deposit. Do not enable this asset, substitute USDG, or loosen safeguards without fresh route validation and separately reviewed support. +- Relay is a third-party bridge protocol. Its availability, liquidity, estimates and settlement are not guaranteed by Openlaunch. Provider response changes fail closed until reviewed. +- Funds never pass through an Openlaunch wallet or new Openlaunch contract. +- A user can keep a wallet approval prompt open beyond a quote's validity. The app cannot retract that wallet prompt; the protocol deadline and provider recovery process still apply. +- Unknown broadcasts and manually replaced/cancelled transactions may need manual investigation via Relay and the source explorer. Never clear an unresolved recovery record merely because a timer elapsed. +- This implementation has read-only live quote verification and mocked execution tests, **not a real-money end-to-end transfer test**. Before production rollout, an authorized maintainer should use a small amount to verify all supported directions, Arc approval and rejection, a normal delivery, and refresh recovery with their own wallet. Do not describe the feature as independently audited or risk-free. + +## Development and verification + +From `app/`, run `npm test`, `npm run lint`, `npm run typecheck`, and `npm run build`. The optional live tests use Relay's public documentation example address and only request quotes/status: + +```powershell +$env:RUN_BRIDGE_LIVE_TESTS='1' +npx tsx --conditions=react-server --tsconfig tsconfig.test.json --test src/lib/bridge/relay.live.test.ts +``` + +These tests never connect a wallet or submit a transaction. + +`/ui-review-bridge` is a development-only visual fixture with disconnected, quote, expired, error, pending, success, uncertain and refund states, plus pending/uncertain/confirmed approval states. Its synthetic data cannot execute a transaction, and the route returns 404 outside development. The real header action uses the actual integration. + +### Base USDC extension verification (2026-09-16) + +- All ten supported directed asset routes passed live unsigned Relay quote/status checks: the six original routes plus Base USDC in both directions with Arc USDC and Robinhood ETH. +- The actual development HTTP API returned validated, `no-store` quotes for all four new directions and rejected Robinhood USDC with HTTP 400. +- Full regression suite: 612 tests, 602 passed, 10 opt-in live tests skipped. TypeScript and the local production build passed. Lint retained only the two existing token OG-image warnings; existing image-store tracing warnings remain. +- Browser checks covered Base's send and receive token menus, clearing stale amounts when the input asset changes, reversing routes, six-decimal Base output, separate ETH gas, nested Escape focus, and phone layouts in both themes. The UI keeps the existing themed Base UI menu treatment rather than native token selects. +- Recovery retains legacy native/v2 Arc records; explicit-asset records are version 3. Tests distinguish old Arc native amounts (18 decimals) from ERC-20 amounts (6 decimals) without changing stored amounts. Maximum-integer approvals are explicitly rejected. +- A separate code review checked token/native balance separation, approval and deposit bindings, cross-tab locks, stale quote handling and legacy recovery. No wallet signing or real settlement was tested; a maintainer-controlled small-value transfer remains necessary before rollout. + +### Arc extension verification (2026-09-16) + +- Full suite: 579 tests, 573 passed, 6 opt-in live tests skipped. The separate live provider run passed all six directed routes across Base, Robinhood and Arc. +- Typecheck and production build passed. Lint: no errors, the two existing token OG-image warnings. The existing image-store tracing warnings remain in the build. +- Local production-build HTTP smoke: all six routes returned HTTP 200 validated quotes and `waiting` status. Arc-source quotes carried exact-amount approval metadata; ETH-source quotes did not. Responses were `no-store`; the synthetic review route returned 404. +- Browser checks covered route collision/reversal, clearing amounts across asset changes, Arc-to-Robinhood approval/review/deposit states, manual-hash recovery UI, six-decimal USDC display, keyboard dismissal, and the narrow mobile sheet in light and dark themes. Synthetic previews were used; they did not exercise a real wallet broadcast. +- A separate code review checked quote/call validation, shared-balance gas math, approval and deposit journals, manual hash recovery, cross-tab locking and stale reviews. No blocking findings; this is not a protocol audit. No funds were sent or wallet transactions signed. + +### Native-only baseline verification (2026-09-16, before Arc extension) + +- Full suite: 530 tests, 528 passed, 2 opt-in live tests skipped. The separate provider run passed both live directions. +- Typecheck and production build passed. Lint had no errors and only the two existing token OG-image warnings; the build retained the existing image-store tracing warnings. +- Production dependency audit: zero reported vulnerabilities. The bridge adds no runtime dependency. +- Local production-build HTTP smoke: both 0.01 ETH quote directions returned validated quotes and `waiting` status; the synthetic review route returned 404. +- Browser checks covered desktop/mobile, inherited light/dark themes, route reversal, shared wallet-picker return, accessible full-recipient disclosure, and pending/recovery UI. Wallet signing and actual settlement remain untested. +- A separate code review covered quote validation, transaction recovery, cross-tab locking, and the vendored encoder. This is not an independent protocol audit. + +## Primary references + +- [Relay input validation](https://docs.relay.link/references/api/api_core_concepts/input-validation) +- [Relay quote API](https://docs.relay.link/references/api/get-quote-v2) +- [Relay refunds](https://docs.relay.link/references/api/api_core_concepts/refunds) +- [Relay status API](https://docs.relay.link/references/api/get-intents-status-v3) +- [Relay transfer recovery](https://support.relay.link/en/articles/9260724-how-do-i-find-my-transaction) +- [Relay depository and explicit-amount ERC-20 deposit](https://docs.relay.link/references/protocol/contracts/evm-depository) +- [Arc mainnet connection details](https://docs.arc.io/arc/references/connect-to-arc) +- [Arc native USDC and ERC-20 semantics](https://docs.arc.io/arc/concepts/stablecoin-native-model) +- [Circle USDC contract registry](https://developers.circle.com/stablecoins/usdc-contract-addresses) +- [Robinhood canonical bridge and token mapping](https://docs.robinhood.com/chain/bridging/) +- [Robinhood official gateway contracts](https://docs.robinhood.com/chain/protocol-contracts/) From 718c8f35560ac09786a9baa46bb48cfaaadf0cbb Mon Sep 17 00:00:00 2001 From: Vasanth T Date: Wed, 16 Sep 2026 13:54:49 +0530 Subject: [PATCH 02/12] test(csp): assert exact connect-source membership CodeQL mistook the split-array includes assertion for substring URL validation. Use Set membership for every expected origin so the test's token boundary is explicit without changing production CSP behavior. --- app/src/lib/security-headers.test.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/app/src/lib/security-headers.test.ts b/app/src/lib/security-headers.test.ts index bcabdec..3f87da2 100644 --- a/app/src/lib/security-headers.test.ts +++ b/app/src/lib/security-headers.test.ts @@ -35,13 +35,14 @@ test("production policy: nonce-only scripts, no framing, same-origin by default" test("connect-src covers the site and the wallet SDK, plus vetted extra origins only", () => { const csp = buildCsp(NONCE, { connectSrc: ["http://127.0.0.1:8545", "https://openlaunch.lol", "https://evil.example/path", "javascript:alert(1)", "not a url"] }); const connect = directive(csp, "connect-src"); + const connectSources = new Set(connect.split(/\s+/).slice(1)); assert.ok(connect.startsWith("connect-src 'self' ")); - for (const origin of WALLET_CONNECT_SRC) assert.ok(connect.includes(` ${origin}`), `missing ${origin}`); + for (const origin of WALLET_CONNECT_SRC) assert.ok(connectSources.has(origin), `missing ${origin}`); assert.deepEqual(BRIDGE_CONNECT_SRC, ["https://rpc.mainnet.arc.io"]); - assert.ok(connect.split(" ").includes("https://rpc.mainnet.arc.io")); - assert.ok(!connect.split(" ").includes("https:")); - assert.ok(connect.includes(" http://127.0.0.1:8545")); - assert.ok(connect.includes(" https://openlaunch.lol")); + assert.ok(connectSources.has("https://rpc.mainnet.arc.io")); + assert.ok(!connectSources.has("https:")); + assert.ok(connectSources.has("http://127.0.0.1:8545")); + assert.ok(connectSources.has("https://openlaunch.lol")); assert.doesNotMatch(connect, /evil|javascript|not a url/); }); From 81278787f3bdccf6586cbd3a8051bd841bdae12c Mon Sep 17 00:00:00 2001 From: Vasanth T Date: Wed, 16 Sep 2026 14:07:56 +0530 Subject: [PATCH 03/12] fix(wallet): use official network logos Replace letter placeholders with the existing official Base and Robinhood assets. Switch the Robinhood mark through theme CSS to preserve contrast without adding hydration state or changing wallet actions. --- app/src/components/WalletMenu.module.css | 7 +++++-- app/src/components/WalletMenu.tsx | 8 +++++++- app/src/components/wallet-menu.test.ts | 13 +++++++++++++ 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/app/src/components/WalletMenu.module.css b/app/src/components/WalletMenu.module.css index 69b51e4..41c23f3 100644 --- a/app/src/components/WalletMenu.module.css +++ b/app/src/components/WalletMenu.module.css @@ -71,8 +71,11 @@ .networks { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-top: 10px; } .networkButton { display: flex; min-width: 0; align-items: center; gap: 7px; min-height: 44px; padding: 8px; border: 1px solid var(--color-line); border-radius: 10px; color: var(--color-body); font-size: 12px; font-weight: 500; cursor: pointer; } .networkButton:hover, .networkButton[aria-pressed="true"] { border-color: var(--color-line-strong); color: var(--color-ink); background: var(--color-card); } -.networkGlyph { display: grid; place-items: center; flex-shrink: 0; width: 22px; height: 22px; border-radius: 50%; background: var(--color-brand-soft); color: var(--color-brand); font: 700 10px var(--font-mono); } -.robinhood { background: var(--color-up-soft); color: var(--color-up); } +.networkLogo { display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0; width: 22px; height: 22px; } +.networkLogo img { display: block; flex-shrink: 0; object-fit: contain; } +.networkLogo .darkLogo { display: none; } +:global(.dark) .networkLogo .lightLogo { display: none; } +:global(.dark) .networkLogo .darkLogo { display: block; } .networkCheck { margin-left: auto; color: var(--color-ink); flex-shrink: 0; } .pendingDot { width: 5px; height: 5px; border-radius: 50%; background: var(--color-muted); margin-left: auto; } .networkNote, .unsupported { margin-top: 10px; color: var(--color-muted); font-size: 11px; line-height: 1.6; } diff --git a/app/src/components/WalletMenu.tsx b/app/src/components/WalletMenu.tsx index dc95e90..f710170 100644 --- a/app/src/components/WalletMenu.tsx +++ b/app/src/components/WalletMenu.tsx @@ -1,6 +1,7 @@ "use client"; import Link from "next/link"; +import Image from "next/image"; import { useRef, useState } from "react"; import { Popover } from "@base-ui/react/popover"; import { ArrowDownUp, ArrowRight, ArrowUpRight, Check, ChevronDown, Copy, LayoutDashboard, LogOut, X } from "lucide-react"; @@ -127,7 +128,12 @@ function AccountMenu({ address, chainId, connectorName, block = false, switching
{CHAIN_KEYS.map((chain) => ( diff --git a/app/src/components/wallet-menu.test.ts b/app/src/components/wallet-menu.test.ts index 58155bd..db66841 100644 --- a/app/src/components/wallet-menu.test.ts +++ b/app/src/components/wallet-menu.test.ts @@ -56,6 +56,19 @@ test("pending actions are locked against duplicate requests and rejection is rec assert.match(source, /role="status" aria-live="polite"/); }); +test("network switcher uses official local logos with theme-correct Robinhood marks", () => { + for (const asset of ["base", "robinhood-black", "robinhood-white"]) { + assert.ok(source.includes(`src="/brand/${asset}.svg" alt=""`)); + assert.match(readFileSync(new URL(`../../public/brand/${asset}.svg`, import.meta.url), "utf8"), / { assert.match(source, /await navigator.clipboard.writeText\(address\)/); assert.match(source, /Address copied\./); From 7ec59657a5299b76410f0a54b6360ad43a66a014 Mon Sep 17 00:00:00 2001 From: Kevin Codex Date: Wed, 16 Sep 2026 18:35:55 +0800 Subject: [PATCH 04/12] bridge: unstick stranded records, anchor quote expiry, drop AbortSignal.any MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review fixes on the Relay bridge: - A sped-up or cancelled-and-replaced deposit no longer blocks tracking forever. When the wallet's hash is unmined and Relay reports a different source hash, that hash is verified as this wallet's exact deposit for the same order and adopted. - A record with no deposit anywhere can be discarded after 15 minutes, only while Relay still reports "waiting" with no hashes and the source chain has no receipt, observed within the last minute. Unmined approvals get the same bounded discard; a late-mined approval only grants the exact allowance every deposit re-reads. - A wallet chain change during a quote no longer leaves the form locked on "Finding your route…". - Quote validity travels as ttlMs and is anchored on the browser clock at request time, so device clock skew cannot expire or extend a quote. - Fetch timeouts use a hand-linked signal instead of AbortSignal.any, which older in-app wallet browsers lack. - Relay's chain catalogue is cached per fetcher for a minute instead of re-downloaded on every quote; an unverifiable copy is dropped. - The USDC approval is no longer gated on the 45 s quote TTL, since a fresh quote is required after it anyway. - Wallet and RPC errors go through friendlyError; the dialog states that Openlaunch does not operate Relay or hold funds in transit. Unit tests cover the hash adoption rule, the quoting-lock reset, expiry anchoring, the linked timeout signal, both discard gates and the catalogue cache. 621 tests, lint, typecheck, build and the read-only live route suite pass. --- app/src/app/ui-review-bridge/BridgeReview.tsx | 4 +- app/src/components/bridge/BridgeDialog.tsx | 13 ++- app/src/components/bridge/bridge-ui.test.ts | 17 +++ app/src/components/bridge/useBridge.ts | 101 +++++++++++++++--- app/src/lib/bridge/approval.test.ts | 19 +++- app/src/lib/bridge/approval.ts | 16 +++ app/src/lib/bridge/client.test.ts | 85 ++++++++++++++- app/src/lib/bridge/client.ts | 57 ++++++++++ app/src/lib/bridge/relay.test.ts | 30 ++++++ app/src/lib/bridge/relay.ts | 25 ++++- app/src/lib/bridge/types.ts | 1 + app/src/lib/bridge/validation.test.ts | 1 + app/src/lib/bridge/validation.ts | 2 +- docs/bridge.md | 4 +- 14 files changed, 350 insertions(+), 25 deletions(-) diff --git a/app/src/app/ui-review-bridge/BridgeReview.tsx b/app/src/app/ui-review-bridge/BridgeReview.tsx index f23eba9..908c476 100644 --- a/app/src/app/ui-review-bridge/BridgeReview.tsx +++ b/app/src/app/ui-review-bridge/BridgeReview.tsx @@ -38,7 +38,7 @@ export default function BridgeReview() { const outputAmount = converted / 10n ** BigInt(18 - outputCurrency.decimals); const quote: BridgeQuote = { address: wallet, originChainId: origin, destinationChainId: destination, originAsset, destinationAsset, amount: inputAmount.toString(), - requestId, amountOut: outputAmount.toString(), minimumAmountOut: (outputAmount * 995n / 1000n).toString(), relayFee: formatUnits(inputAmount * 25n / 10000n, inputCurrency.decimals), sourceGas: origin === 5042 ? "0.001" : "0.0000007", totalImpactPercent: "-0.25", timeEstimate: 2, expiresAt: clock + 45_000, + requestId, amountOut: outputAmount.toString(), minimumAmountOut: (outputAmount * 995n / 1000n).toString(), relayFee: formatUnits(inputAmount * 25n / 10000n, inputCurrency.decimals), sourceGas: origin === 5042 ? "0.001" : "0.0000007", totalImpactPercent: "-0.25", timeEstimate: 2, expiresAt: clock + 45_000, ttlMs: 45_000, 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() } } : {}), }; @@ -58,6 +58,8 @@ export default function BridgeReview() { approval, approvalRequired: erc20Input && !approved, allowanceLoading: false, approvalBusy: false, approvalError: null, approve: async () => setScene("approval_pending"), retryApproval: () => { setApproved(true); setScene("approval_confirmed"); }, recoverApproval: async () => { setApproved(true); setScene("approval_confirmed"); }, + approvalCanBeDiscarded: scene === "approval_uncertain", discardApproval: () => { setApproved(false); setScene("idle"); }, + canDiscard: scene === "uncertain", discard: () => setScene("idle"), }; return (
diff --git a/app/src/components/bridge/BridgeDialog.tsx b/app/src/components/bridge/BridgeDialog.tsx index 7a74473..31b99bf 100644 --- a/app/src/components/bridge/BridgeDialog.tsx +++ b/app/src/components/bridge/BridgeDialog.tsx @@ -7,6 +7,7 @@ import { Select } from "@base-ui/react/select"; import { ArrowLeftRight, ArrowRight, ArrowUpRight, Check, ChevronDown, ChevronRight, CircleAlert, Clock3, LoaderCircle, Wallet, X } from "lucide-react"; import { formatEther, formatUnits, zeroAddress } from "viem"; import { BRIDGE_ASSETS, BRIDGE_CHAINS, BRIDGE_CHAIN_IDS, bridgeCurrency, bridgeTransferInputCurrency, isBridgeAssetSupported, isBridgeChainId, type BridgeAsset, type BridgeChainId } from "@/lib/bridge/types"; +import { DISCARD_AFTER_MS } from "@/lib/bridge/client"; import { shortAddr } from "@/lib/chainPublic"; import WalletPicker from "../WalletPicker"; import WalletAvatar from "../WalletAvatar"; @@ -213,7 +214,7 @@ export function BridgeForm({ bridge: b, connect }: { bridge: Bridge; connect: () {b.address ? label : "Connect wallet"} {!locked && b.address ? : null} -

Keep some {origin.symbol} on {origin.name} for gas. {convertsAsset ? "Relay converts the asset at the quoted rate. " : ""}Bridging uses a third-party protocol and carries risk. {erc20Input ? "An unspent USDC approval remains until used or revoked." : "No token approvals required."}

+

Keep some {origin.symbol} on {origin.name} for gas. {convertsAsset ? "Relay converts the asset at the quoted rate. " : ""}Bridging uses Relay, a third-party protocol, and carries risk. Openlaunch does not operate Relay, holds no funds in transit, and is not responsible for delays, refunds or losses. {erc20Input ? "An unspent USDC approval remains until used or revoked." : "No token approvals required."}

); } @@ -246,6 +247,11 @@ function ApprovalProgress({ bridge: b }: { bridge: Bridge }) {
: 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.

+ +
: 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.

; } @@ -289,6 +295,11 @@ export function Transfer({ bridge: b }: { bridge: Bridge }) { {success && transfer.destinationHashes[0] ? Destination transaction : null} {b.canReset ? : null} + {b.canDiscard ?
+ No deposit after {DISCARD_AFTER_MS / 60_000} minutes? +

Relay has not seen a deposit for this transfer and nothing is confirmed on {origin.name}. If your wallet shows the transaction as dropped or cancelled, you can discard this record and start over. Keep the Transfer ID below in case you need Relay support.

+ +
: null}

Transfer ID {transfer.requestId}

); diff --git a/app/src/components/bridge/bridge-ui.test.ts b/app/src/components/bridge/bridge-ui.test.ts index da08594..392ea47 100644 --- a/app/src/components/bridge/bridge-ui.test.ts +++ b/app/src/components/bridge/bridge-ui.test.ts @@ -114,3 +114,20 @@ test("USDC selectors separate selected token units from native gas and disallow assert.match(panel, /uses additional \{origin.symbol\} for gas/); assert.match(panel, /const inputCurrency = bridgeTransferInputCurrency\(transfer\)/); }); + +test("stuck records have a bounded discard path, and the hook avoids APIs missing in older wallet browsers", () => { + const hook = read("./useBridge.ts"); + assert.match(panel, /b\.canDiscard \?
Discard this transfer/); + assert.match(panel, /b\.approvalCanBeDiscarded \?
Discard this approval/); + assert.match(panel, /Keep the Transfer ID below/); + assert.match(panel, /Openlaunch does not operate Relay, holds no funds in transit, and is not responsible for delays, refunds or losses/); + assert.doesNotMatch(hook, /AbortSignal\.(any|timeout)/); + assert.match(hook, /linkedTimeoutSignal\(abort\.signal, 20_000\)/); + assert.match(hook, /anchorQuoteExpiry\(await responseBody\(response\), requestedAt\)/); + assert.match(hook, /setActivity\(activityAfterWalletChange\)/); + assert.match(hook, /error instanceof TransactionReceiptNotFoundError\) return null/); + assert.match(hook, /replacementSourceHash\(current, status, receipt\.status === "fulfilled" && receipt\.value === null\)/); + assert.match(hook, /friendlyError\(error\)/); +}); diff --git a/app/src/components/bridge/useBridge.ts b/app/src/components/bridge/useBridge.ts index 1d04c7a..a70e917 100644 --- a/app/src/components/bridge/useBridge.ts +++ b/app/src/components/bridge/useBridge.ts @@ -4,12 +4,13 @@ import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from " import { useAccount, useBalance, useConfig, useReadContract, useSwitchChain } from "wagmi"; import { getAccount, getPublicClient, getWalletClient } from "wagmi/actions"; import { estimateTotalFee } from "viem/op-stack"; -import { erc20Abi, parseEther, TransactionReceiptNotFoundError, type Address } from "viem"; +import { BaseError, erc20Abi, parseEther, TransactionReceiptNotFoundError, type Address } from "viem"; +import { friendlyError } from "@/lib/errors"; import { BRIDGE_WALLET_CHAINS } from "@/lib/bridge/chains"; import { BRIDGE_CHAINS, bridgeCurrency, defaultBridgeAsset, isBridgeAssetSupported, isBridgeChainId, type BridgeAsset, type BridgeChainId, type BridgeQuote } from "@/lib/bridge/types"; -import { bridgeGasBudget, bridgeRequest, bridgeRequestKey, changeBridgeRoute, hasMatchingDepositEvent, isHash, isMatchingSourceDeposit, mergeBridgeStatus, nativeSourceAmount, RELAY_DEPOSITORY, submitBridgeDeposit, transferIsTerminal, transferPhase, validateBridgeQuote, validateBridgeStatus, type BridgePhase, type BridgeRouteChange, type BridgeRouteInputs, type TrackedBridgeTransfer } from "@/lib/bridge/client"; +import { activityAfterWalletChange, anchorQuoteExpiry, bridgeGasBudget, bridgeRequest, bridgeRequestKey, changeBridgeRoute, hasMatchingDepositEvent, isHash, isMatchingSourceDeposit, linkedTimeoutSignal, mergeBridgeStatus, nativeSourceAmount, RELAY_DEPOSITORY, replacementSourceHash, submitBridgeDeposit, transferCanDiscard, transferIsTerminal, transferPhase, validateBridgeQuote, validateBridgeStatus, type BridgeActivity, type BridgePhase, type BridgeRouteChange, type BridgeRouteInputs, type ProviderObservation, type TrackedBridgeTransfer } from "@/lib/bridge/client"; import { BRIDGE_STORAGE_PREFIX, createBridgeTransferStore } from "@/lib/bridge/client-storage"; -import { APPROVAL_STORAGE_PREFIX, approvalBlocksSubmission, createApprovalStore, hasMatchingApprovalEvent, isMatchingApprovalTransaction, reconcileApproval, submitExactApproval, validateApprovalMetadata } from "@/lib/bridge/approval"; +import { APPROVAL_STORAGE_PREFIX, approvalBlocksSubmission, approvalCanDiscard, createApprovalStore, hasMatchingApprovalEvent, isMatchingApprovalTransaction, reconcileApproval, submitExactApproval, validateApprovalMetadata, type ApprovalReceiptObservation } from "@/lib/bridge/approval"; export type { BridgePhase, TrackedBridgeTransfer } from "@/lib/bridge/client"; @@ -19,7 +20,14 @@ const transfers = createBridgeTransferStore(() => window.localStorage); const approvals = createApprovalStore(() => window.localStorage); type QuoteEnvelope = { quote: BridgeQuote; key: string; walletChainId?: number; requestedAt: number }; type Issue = { key: string; message: string } | null; -const messageOf = (error: unknown, fallback: string) => error instanceof Error ? error.message : fallback; +/** Wallet and RPC errors go through the app's shared copy instead of raw viem dumps. */ +const messageOf = (error: unknown, fallback: string, gasSymbol = "ETH") => { + if (error instanceof BaseError) { + const message = friendlyError(error); + return /insufficient funds/i.test(error.shortMessage || error.message) ? `Not enough ${gasSymbol} for this transaction plus gas.` : message; + } + return error instanceof Error ? error.message : fallback; +}; const transferLockName = (address: Address) => `openlaunch:bridge:${address.toLowerCase()}`; async function responseBody(response: Response): Promise { @@ -43,7 +51,7 @@ export function useBridge() { const inputCurrency = bridgeCurrency(originChainId, originAsset, "input"); const inputIsToken = !/^0x0{40}$/.test(inputCurrency.address); const [envelope, setEnvelope] = useState(null); - const [activity, setActivity] = useState<{ key: string; phase: "idle" | "quoting" | "switching" | "confirming" }>({ key: "", phase: "idle" }); + const [activity, setActivity] = useState({ key: "", phase: "idle" }); const [issue, setIssue] = useState(null); const [quoteIssue, setQuoteIssue] = useState(null); const [statusIssue, setStatusIssue] = useState(null); @@ -54,6 +62,9 @@ export function useBridge() { const [approvalSending, setApprovalSending] = useState(false); const [approvalIssue, setApprovalIssue] = useState(null); const [allowanceResult, setAllowanceResult] = useState<{ key: string; value: bigint | null; error: string | null } | null>(null); + const [observation, setObservation] = useState(null); + const [approvalObservation, setApprovalObservation] = useState(null); + const [now, setNow] = useState(0); const actionLock = useRef(false); const quoteSequence = useRef(0); const quoteAbort = useRef(null); @@ -91,7 +102,12 @@ export function useBridge() { // Existing quotes are hidden immediately by their wallet-chain key. Abort // in-flight responses as well; an old account's response cannot reappear. - useEffect(() => cancelQuote, [address, walletChainId, cancelQuote]); + // The aborted request skips its own idle reset, so clear a quoting phase here + // or the form stays locked on "Finding your route…". + useEffect(() => () => { + cancelQuote(); + setActivity(activityAfterWalletChange); + }, [address, walletChainId, cancelQuote]); useEffect(() => { if (!quote) return; @@ -146,6 +162,7 @@ export function useBridge() { 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() }); setApprovalIssue(null); }; if (navigator.locks) await navigator.locks.request(transferLockName(address), { ifAvailable: true }, (lock) => { if (lock) apply(true); }); @@ -182,8 +199,12 @@ export function useBridge() { const observed = transfers.getSnapshot().transfers[address.toLowerCase()]; const pub = observed ? getPublicClient(config, { chainId: observed.originChainId }) : undefined; const [provider, receipt] = await Promise.allSettled([ - fetch(`/api/bridge/status?requestId=${encodeURIComponent(trackedId)}`, { cache: "no-store", signal: AbortSignal.any([controller.signal, AbortSignal.timeout(15_000)]) }).then(responseBody).then(validateBridgeStatus), - observed?.sourceHash && pub ? Promise.all([pub.getChainId(), pub.getTransactionReceipt({ hash: observed.sourceHash })]).then(([chainId, result]) => chainId === observed.originChainId ? result : null) : Promise.resolve(null), + fetch(`/api/bridge/status?requestId=${encodeURIComponent(trackedId)}`, { cache: "no-store", signal: linkedTimeoutSignal(controller.signal, 15_000) }).then(responseBody).then(validateBridgeStatus), + // "Not found" is a real answer (unmined, dropped or replaced); other RPC failures stay unknown. + observed?.sourceHash && pub ? Promise.all([pub.getChainId(), pub.getTransactionReceipt({ hash: observed.sourceHash }).catch((error) => { + if (error instanceof TransactionReceiptNotFoundError) return null; + throw error; + })]).then(([chainId, result]) => chainId === observed.originChainId ? result : null) : Promise.resolve(null), ]); if (stopped) return; const applyStatus = async (persist: boolean) => { @@ -195,8 +216,9 @@ export function useBridge() { } else { if (provider.status === "rejected") throw provider.reason; const status = provider.value; - if (!current.sourceHash && status.inTxHashes[0] && pub) { - const candidate = status.inTxHashes[0]; + // The candidate must still be this wallet's exact deposit for this order. + const candidate = pub ? replacementSourceHash(current, status, receipt.status === "fulfilled" && receipt.value === null) : null; + if (candidate && pub) { const [chainId, transaction] = await Promise.all([pub.getChainId(), pub.getTransaction({ hash: candidate })]); if (chainId !== current.originChainId) throw new Error("The source RPC reported a different network. Tracking will retry."); const matched = isMatchingSourceDeposit(current, transaction) || hasMatchingDepositEvent(current, await pub.getTransactionReceipt({ hash: candidate })); @@ -204,6 +226,7 @@ export function useBridge() { current = { ...current, sourceHash: candidate }; } next = mergeBridgeStatus(current, status); + setObservation({ requestId: current.requestId, status, sourceMined: receipt.status === "rejected" || (receipt.status === "fulfilled" && receipt.value !== null), observedAt: Date.now() }); } if (stopped) return; if (persist) { @@ -299,9 +322,9 @@ export function useBridge() { if (approvalBlocksSubmission(approvals.read(current.address))) throw new Error("An approval is still being tracked. Wait for its confirmation before requesting a new quote."); const response = await fetch("/api/bridge/quote", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(current), cache: "no-store", - signal: AbortSignal.any([abort.signal, AbortSignal.timeout(20_000)]), + signal: linkedTimeoutSignal(abort.signal, 20_000), }); - const next = validateBridgeQuote(await responseBody(response), current, Date.now()); + const next = validateBridgeQuote(anchorQuoteExpiry(await responseBody(response), requestedAt), current, Date.now()); const wallet = getAccount(config); if (sequence !== quoteSequence.current || wallet.address?.toLowerCase() !== current.address.toLowerCase() || wallet.chainId !== connected.chainId) return; setEnvelope({ quote: next, key, walletChainId: connected.chainId, requestedAt }); @@ -333,7 +356,7 @@ export function useBridge() { const currentRequest = () => { const current = bridgeRequest(getAccount(config).address, inputs.current.originChainId, inputs.current.amount, inputs.current.destinationChainId, inputs.current.originAsset, inputs.current.destinationAsset); const deposit = transfers.read(reviewed.address); - if (Date.now() >= reviewed.expiresAt || bridgeRequestKey(current) !== operationKey || (deposit && !transferIsTerminal(deposit))) return null; + if (bridgeRequestKey(current) !== operationKey || (deposit && !transferIsTerminal(deposit))) return null; return request; }; const result = await submitExactApproval(request, { @@ -377,7 +400,7 @@ export function useBridge() { if (result.kind === "uncertain") setApprovalIssue({ key: reviewed.address.toLowerCase(), message: "The wallet did not return an approval hash. Check this approval before trying again; no bridge deposit was requested." }); }); } catch (error) { - setApprovalIssue({ key: reviewed.address.toLowerCase(), message: messageOf(error, "Could not prepare USDC approval. Request a new quote.") }); + setApprovalIssue({ key: reviewed.address.toLowerCase(), message: messageOf(error, "Could not prepare USDC approval. Request a new quote.", BRIDGE_CHAINS[reviewed.originChainId].symbol) }); } finally { actionLock.current = false; setApprovalSending(false); @@ -497,7 +520,7 @@ export function useBridge() { }); } else throw new Error("This browser cannot safely coordinate bridge requests between tabs. Open Openlaunch in a current browser to continue."); } catch (error) { - setIssue({ key: reviewed.address.toLowerCase(), message: messageOf(error, "Could not prepare the transfer. Request a new quote and try again.") }); + setIssue({ key: reviewed.address.toLowerCase(), message: messageOf(error, "Could not prepare the transfer. Request a new quote and try again.", BRIDGE_CHAINS[reviewed.originChainId].symbol) }); } finally { actionLock.current = false; setSending(false); @@ -525,6 +548,51 @@ export function useBridge() { } else { invalidateQuote(); setStatusIssue(null); } } + // Discard eligibility depends on wall-clock age. Render stays pure: the clock + // is state, refreshed after every observation and once a minute while a record is open. + const blocked = (tracked && !transferIsTerminal(tracked)) || approvalPending; + useEffect(() => { + if (!blocked) return; + const tick = () => setNow(Date.now()); + tick(); + const timer = window.setInterval(tick, 60_000); + return () => window.clearInterval(timer); + }, [blocked, observation, approvalObservation]); + const canDiscard = transferCanDiscard(tracked, observation, now); + const approvalCanBeDiscarded = approvalCanDiscard(approval, approvalObservation, now); + + function discard() { + if (actionLock.current || sending || !address || !tracked || !canDiscard || !navigator.locks) return; + const requestId = tracked.requestId; + void navigator.locks.request(transferLockName(address), { ifAvailable: true }, (lock) => { + if (!lock) return; + try { + const current = transfers.read(address); + if (current?.requestId !== requestId || !transferCanDiscard(current, observation, Date.now())) return; + transfers.remove(address); + setObservation(null); + invalidateQuote(); + setStatusIssue(null); + } catch { /* storage warning is exposed in the store */ } + }); + } + + function discardApproval() { + if (actionLock.current || approvalSending || !address || !approval || !approvalCanBeDiscarded || !navigator.locks) return; + const createdAt = approval.createdAt; + void navigator.locks.request(transferLockName(address), { ifAvailable: true }, (lock) => { + if (!lock) return; + try { + const current = approvals.read(address); + if (current?.createdAt !== createdAt || !approvalCanDiscard(current, approvalObservation, Date.now())) return; + approvals.remove(address); + setApprovalObservation(null); + setApprovalIssue(null); + invalidateQuote(); + } catch { /* storage warning is exposed in the store */ } + }); + } + const retryStatus = useCallback(() => setPollRevision((value) => value + 1), []); const retryApproval = useCallback(() => setApprovalPollRevision((value) => value + 1), []); const activePhase = activity.key === requestKey ? activity.phase : "idle"; @@ -540,7 +608,8 @@ export function useBridge() { quoteExpired, requestQuote, confirm, reset, tracked, approval, approvalRequired, allowanceLoading, approvalBusy: approvalSending, approvalError: approvalIssue?.key === walletKey ? approvalIssue.message : quote && allowanceResult?.key === quote.requestId ? allowanceResult.error : null, - approve, retryApproval, recoverApproval, + approve, retryApproval, recoverApproval, approvalCanBeDiscarded, discardApproval, + canDiscard, discard, statusError: statusIssue && statusIssue.key === trackedId ? statusIssue.message : null, retryStatus, storageError, busy: sending || approvalSending || approvalPending || activePhase === "quoting", canReset: !sending && !approvalSending && !approvalPending && (!tracked || transferIsTerminal(tracked)), diff --git a/app/src/lib/bridge/approval.test.ts b/app/src/lib/bridge/approval.test.ts index f3455b1..2ba5f2f 100644 --- a/app/src/lib/bridge/approval.test.ts +++ b/app/src/lib/bridge/approval.test.ts @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import { decodeFunctionData, encodeAbiParameters, encodeEventTopics, type Address, type Hex } from "viem"; -import { APPROVAL_STORAGE_PREFIX, ARC_APPROVAL_CHAIN_ID, ARC_USDC, EXACT_APPROVAL_ABI, RELAY_APPROVAL_SPENDER, USDC_APPROVAL_EVENT, approvalBlocksSubmission, approvalRequestKey, approvalStorageKey, createApprovalStore, exactApprovalTransaction, hasMatchingApprovalEvent, isMatchingApprovalTransaction, parseStoredApproval, reconcileApproval, serializeApproval, submitExactApproval, validateApprovalMetadata, type ApprovalDependencies, type ApprovalRequest, type TrackedApproval } from "./approval"; +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"; const ADDRESS = "0x1111111111111111111111111111111111111111" as Address; @@ -317,3 +317,20 @@ test("approval workflow requires a new reviewed quote and never dispatches a dep assert.equal(item.state.sends, 1); assert.equal(item.events.filter((event) => event === "send").length, 1); }); + +test("an unmined approval can be discarded after the wait; a hash needs a fresh missing-receipt observation", () => { + const later = NOW + APPROVAL_DISCARD_AFTER_MS; + const uncertain = tracked({ status: "uncertain" }); + const pending = tracked({ status: "pending", approvalHash: HASH }); + const missing = { createdAt: NOW, receiptFound: false, observedAt: later }; + assert.equal(approvalCanDiscard(uncertain, null, later), true); + assert.equal(approvalCanDiscard(uncertain, null, later - 1), false); + assert.equal(approvalCanDiscard(pending, missing, later), true); + assert.equal(approvalCanDiscard(pending, null, later), false); // never observed on-chain + assert.equal(approvalCanDiscard(pending, { ...missing, receiptFound: true }, later), false); + assert.equal(approvalCanDiscard(pending, { ...missing, observedAt: later - 61_000 }, later), false); + assert.equal(approvalCanDiscard(pending, { ...missing, observedAt: later + 1 }, later), false); + assert.equal(approvalCanDiscard(pending, { ...missing, createdAt: NOW + 1 }, later), false); // another attempt's observation + 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); +}); diff --git a/app/src/lib/bridge/approval.ts b/app/src/lib/bridge/approval.ts index 9c8bb28..9f31d30 100644 --- a/app/src/lib/bridge/approval.ts +++ b/app/src/lib/bridge/approval.ts @@ -68,6 +68,22 @@ export function approvalRequestKey(request: ApprovalRequest | null): string { export const approvalStorageKey = (address: Address) => `${APPROVAL_STORAGE_PREFIX}${address.toLowerCase()}`; +export const APPROVAL_DISCARD_AFTER_MS = 15 * 60_000; +export type ApprovalReceiptObservation = { createdAt: number; receiptFound: boolean; observedAt: number }; + +/** + * An approval that is still unmined after a long wait can be dropped: if it + * mines later it only grants the exact allowance to the pinned depository, and + * every deposit re-reads the allowance before asking the wallet. A record with + * a hash needs a fresh "no receipt" observation; one without a hash cannot be + * checked on-chain, so only the wait applies. + */ +export function approvalCanDiscard(approval: TrackedApproval | null, observation: ApprovalReceiptObservation | null, now: number): boolean { + if (!approvalBlocksSubmission(approval) || now - approval!.createdAt < APPROVAL_DISCARD_AFTER_MS) return false; + if (!approval!.approvalHash) return true; + return !!observation && observation.createdAt === approval!.createdAt && !observation.receiptFound && now - observation.observedAt <= 60_000 && now >= observation.observedAt; +} + export function approvalBlocksSubmission(approval: TrackedApproval | null): boolean { return !!approval && (approval.status === "uncertain" || approval.status === "pending"); } diff --git a/app/src/lib/bridge/client.test.ts b/app/src/lib/bridge/client.test.ts index 456f617..aacd5f4 100644 --- a/app/src/lib/bridge/client.test.ts +++ b/app/src/lib/bridge/client.test.ts @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import { encodeAbiParameters, encodeEventTopics, encodeFunctionData, type Address, type Hex } from "viem"; -import { bridgeGasBudget, bridgeRequest, bridgeRequestKey, changeBridgeRoute, ERC20_DEPOSIT_ABI, ERC20_DEPOSIT_EVENT, hasMatchingDepositEvent, isMatchingSourceDeposit, isWalletRejection, mergeBridgeStatus, nativeSourceAmount, NATIVE_DEPOSIT_ABI, NATIVE_DEPOSIT_EVENT, parseBridgeAmount, parseStoredTransfer, RELAY_DEPOSITORY, serializeTransfer, submitBridgeDeposit, transferIsTerminal, validateBridgeQuote, validateBridgeStatus, type DepositDependencies, type TrackedBridgeTransfer } from "./client"; +import { activityAfterWalletChange, anchorQuoteExpiry, DISCARD_AFTER_MS, linkedTimeoutSignal, replacementSourceHash, transferCanDiscard, bridgeGasBudget, bridgeRequest, bridgeRequestKey, changeBridgeRoute, ERC20_DEPOSIT_ABI, ERC20_DEPOSIT_EVENT, hasMatchingDepositEvent, isMatchingSourceDeposit, isWalletRejection, mergeBridgeStatus, nativeSourceAmount, NATIVE_DEPOSIT_ABI, NATIVE_DEPOSIT_EVENT, parseBridgeAmount, parseStoredTransfer, RELAY_DEPOSITORY, serializeTransfer, submitBridgeDeposit, transferIsTerminal, validateBridgeQuote, validateBridgeStatus, type DepositDependencies, type TrackedBridgeTransfer } from "./client"; import { bridgeStorageKey, createBridgeTransferStore } from "./client-storage"; import { ARC_USDC, BASE_USDC, BRIDGE_CHAIN_IDS, type BridgeQuote, type BridgeQuoteRequest } from "./types"; @@ -18,7 +18,7 @@ function quote(): BridgeQuote { return { address: ADDRESS, originChainId: 8453, destinationChainId: 4663, amount, requestId: REQUEST, amountOut: "9800000000000000", minimumAmountOut: "9700000000000000", - relayFee: "0.0002", sourceGas: "0.00001", totalImpactPercent: "-2", timeEstimate: 15, expiresAt: NOW + 45_000, + relayFee: "0.0002", sourceGas: "0.00001", totalImpactPercent: "-2", timeEstimate: 15, expiresAt: NOW + 45_000, ttlMs: 45_000, transaction: { to: RELAY_DEPOSITORY, data: encodeFunctionData({ abi: NATIVE_DEPOSIT_ABI, functionName: "depositNative", args: [ADDRESS, ORDER] }), value: amount, chainId: 8453 }, }; } @@ -498,3 +498,84 @@ test("confirmed source-revert recovery survives a reload without claiming a prov assert.equal(restored.status, "failure"); assert.equal(transferIsTerminal(restored), true); }); + +test("quote validity is anchored on this device's clock at request time, never on the server epoch", () => { + const skewed = { ...quote(), expiresAt: NOW - 600_000, ttlMs: 45_000 }; // a server 10 minutes "behind" this phone + const anchored = anchorQuoteExpiry(skewed, NOW) as BridgeQuote; + assert.equal(anchored.expiresAt, NOW + 45_000); + assert.equal(validateBridgeQuote(anchored, quote(), NOW).requestId, REQUEST); + assert.throws(() => validateBridgeQuote(skewed, quote(), NOW), /expired/); + for (const ttlMs of [undefined, "45000", -1, 120_001, Number.NaN, Number.POSITIVE_INFINITY]) { + assert.throws(() => anchorQuoteExpiry({ ...quote(), ttlMs }, NOW), /quote validity/); + } + assert.equal(anchorQuoteExpiry(null, NOW), null); // non-objects fall through to the quote validator +}); + +test("linked timeout signal aborts on the parent, on the timer, and never needs AbortSignal.any", async () => { + const parent = new AbortController(); + const signal = linkedTimeoutSignal(parent.signal, 60_000); + assert.equal(signal.aborted, false); + parent.abort(new Error("gone")); + assert.equal(signal.aborted, true); + assert.equal((signal.reason as Error).message, "gone"); + const timed = linkedTimeoutSignal(new AbortController().signal, 5); + await new Promise((resolve) => setTimeout(resolve, 20)); + assert.equal(timed.aborted, true); + assert.equal((timed.reason as DOMException).name, "TimeoutError"); + const already = new AbortController(); + already.abort(); + assert.equal(linkedTimeoutSignal(already.signal, 60_000).aborted, true); + assert.equal(linkedTimeoutSignal(undefined, 60_000).aborted, false); +}); + +test("a deposit that never happened can be discarded only after the wait, with fresh provider and chain evidence", () => { + const unsent: TrackedBridgeTransfer = { address: ADDRESS, originChainId: 8453, destinationChainId: 4663, amount, requestId: REQUEST, destinationHashes: [], status: "uncertain", createdAt: NOW }; + const waiting = { status: "waiting" as const, inTxHashes: [], txHashes: [] }; + const later = NOW + DISCARD_AFTER_MS; + const fresh = { requestId: REQUEST, status: waiting, sourceMined: false, observedAt: later }; + assert.equal(transferCanDiscard(unsent, fresh, later), true); + assert.equal(transferCanDiscard({ ...unsent, sourceHash: HASH, status: "pending" }, fresh, later), true); // wallet hash dropped, never mined + assert.equal(transferCanDiscard(unsent, fresh, later - 1), false); // too early + assert.equal(transferCanDiscard(unsent, { ...fresh, observedAt: later - 61_000 }, later), false); // stale observation + assert.equal(transferCanDiscard(unsent, { ...fresh, observedAt: later + 1 }, later), false); // observation from the future + assert.equal(transferCanDiscard(unsent, { ...fresh, sourceMined: true }, later), false); // receipt exists or RPC unknown + assert.equal(transferCanDiscard(unsent, { ...fresh, requestId: ORDER }, later), false); // another transfer's status + assert.equal(transferCanDiscard(unsent, null, later), false); + for (const status of ["depositing", "pending", "submitted", "delayed", "success", "refund", "failure"] as const) { + assert.equal(transferCanDiscard(unsent, { ...fresh, status: { ...waiting, status } }, later), false, status); + } + assert.equal(transferCanDiscard(unsent, { ...fresh, status: { ...waiting, inTxHashes: [HASH] } }, later), false); // provider saw a deposit + assert.equal(transferCanDiscard(unsent, { ...fresh, status: { ...waiting, txHashes: [DEST_HASH] } }, later), false); + assert.equal(transferCanDiscard({ ...unsent, status: "success" }, fresh, later), false); // settled records use reset instead + assert.equal(transferCanDiscard(null, fresh, later), false); +}); + +test("a sped-up or cancelled deposit adopts Relay's hash only when the wallet's own hash is unmined and no longer reported", () => { + const base: TrackedBridgeTransfer = { address: ADDRESS, originChainId: 8453, destinationChainId: 4663, amount, requestId: REQUEST, destinationHashes: [], status: "pending", createdAt: NOW, sourceHash: HASH }; + const status = (inTxHashes: Hex[]) => ({ status: "success" as const, inTxHashes, txHashes: [DEST_HASH] }); + const REPLACEMENT = `0x${"e".repeat(64)}` as Hex; + // Wallet returned no hash: adopt whatever Relay reports, then verify it on-chain. + assert.equal(replacementSourceHash({ ...base, sourceHash: undefined, status: "uncertain" }, status([REPLACEMENT]), false), REPLACEMENT); + assert.equal(replacementSourceHash({ ...base, sourceHash: undefined, status: "uncertain" }, status([]), false), null); + // Speed-up: wallet hash dropped, Relay saw the replacement. + assert.equal(replacementSourceHash(base, status([REPLACEMENT]), true), REPLACEMENT); + assert.equal(replacementSourceHash(base, status([REPLACEMENT.toUpperCase().replace("0X", "0x") as Hex]), true), REPLACEMENT.toUpperCase().replace("0X", "0x")); + // Never swap a mined wallet hash, a wallet hash Relay still reports, or when Relay reports nothing else. + assert.equal(replacementSourceHash(base, status([REPLACEMENT]), false), null); + assert.equal(replacementSourceHash(base, status([HASH, REPLACEMENT]), true), null); + assert.equal(replacementSourceHash(base, status([HASH.toUpperCase().replace("0X", "0x") as Hex]), true), null); + assert.equal(replacementSourceHash(base, status([]), true), null); + // Once adopted, the provider's settled state applies to the same order. + const adopted = { ...base, sourceHash: REPLACEMENT }; + assert.equal(mergeBridgeStatus(adopted, status([REPLACEMENT])).status, "success"); + assert.deepEqual(mergeBridgeStatus(adopted, status([REPLACEMENT])).destinationHashes, [DEST_HASH]); + assert.throws(() => mergeBridgeStatus(base, status([REPLACEMENT])), /not yet been matched/); // without adoption it stays blocked +}); + +test("a wallet change clears an in-flight quote lock but leaves wallet prompts untouched", () => { + assert.deepEqual(activityAfterWalletChange({ key: "k", phase: "quoting" }), { key: "", phase: "idle" }); + for (const phase of ["idle", "switching", "confirming"] as const) { + const activity = { key: "k", phase }; + assert.equal(activityAfterWalletChange(activity), activity); + } +}); diff --git a/app/src/lib/bridge/client.ts b/app/src/lib/bridge/client.ts index fc772c3..bda9a1d 100644 --- a/app/src/lib/bridge/client.ts +++ b/app/src/lib/bridge/client.ts @@ -144,6 +144,63 @@ export function validateBridgeQuote(value: unknown, request: BridgeQuoteRequest, return quote; } +/** + * The server states how long a quote stays valid; only this device's clock + * decides when that runs out. Anchoring on the request time also absorbs the + * round trip, so a skewed phone clock cannot expire (or extend) a fresh quote. + */ +export function anchorQuoteExpiry(value: unknown, requestedAt: number): unknown { + if (!value || typeof value !== "object") return value; + const { ttlMs } = value as { ttlMs?: unknown }; + if (typeof ttlMs !== "number" || !Number.isFinite(ttlMs) || ttlMs < 0 || ttlMs > 120_000) throw new Error("The bridge returned an invalid quote validity. Request a new quote."); + return { ...value, expiresAt: requestedAt + ttlMs }; +} + +/** AbortSignal.any/timeout are missing in older in-app wallet browsers; link the signals by hand. */ +export function linkedTimeoutSignal(parent: AbortSignal | undefined, timeoutMs: number): AbortSignal { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(new DOMException("The request timed out.", "TimeoutError")), timeoutMs); + controller.signal.addEventListener("abort", () => clearTimeout(timer), { once: true }); + if (parent?.aborted) controller.abort(parent.reason); + else parent?.addEventListener("abort", () => controller.abort(parent.reason), { once: true }); + return controller.signal; +} + +/** + * The provider hash to verify and adopt as this transfer's source hash: any + * reported hash when the wallet returned none, or a different hash when the + * wallet's own was replaced (speed-up/cancel) and is not mined. A mined or + * still-reported wallet hash is never swapped; a mismatch then keeps retrying. + */ +export function replacementSourceHash(transfer: TrackedBridgeTransfer, status: BridgeStatusResponse, walletHashUnmined: boolean): Hex | null { + const known = transfer.sourceHash?.toLowerCase(); + const candidate = status.inTxHashes.find((hash) => hash.toLowerCase() !== known) ?? null; + if (!candidate || !transfer.sourceHash) return candidate; + const knownReported = status.inTxHashes.some((hash) => hash.toLowerCase() === known); + return walletHashUnmined && !knownReported ? candidate : null; +} + +export type BridgeActivity = { key: string; phase: "idle" | "quoting" | "switching" | "confirming" }; +/** An aborted quote never runs its own idle reset; wallet changes clear it here and leave wallet prompts alone. */ +export function activityAfterWalletChange(activity: BridgeActivity): BridgeActivity { + return activity.phase === "quoting" ? { key: "", phase: "idle" } : activity; +} + +export const DISCARD_AFTER_MS = 15 * 60_000; +export type ProviderObservation = { requestId: Hex; status: BridgeStatusResponse; sourceMined: boolean; observedAt: number }; + +/** + * Bounded escape hatch for a deposit that never happened: the provider still + * reports "waiting" with no deposit, no wallet hash is mined, and the order + * deadline (about a minute) is long past. A record with any on-chain evidence, + * or a stale observation, keeps blocking until it settles. + */ +export function transferCanDiscard(transfer: TrackedBridgeTransfer | null, observation: ProviderObservation | null, now: number): boolean { + if (!transfer || transferIsTerminal(transfer) || !observation || observation.requestId !== transfer.requestId) return false; + if (observation.status.status !== "waiting" || observation.status.inTxHashes.length > 0 || observation.status.txHashes.length > 0 || observation.sourceMined) return false; + return now - transfer.createdAt >= DISCARD_AFTER_MS && now - observation.observedAt <= 60_000 && now >= observation.observedAt; +} + export function validateBridgeStatus(value: unknown): BridgeStatusResponse { if (!value || typeof value !== "object") throw new Error("Transfer status is temporarily unavailable."); const status = value as BridgeStatusResponse; diff --git a/app/src/lib/bridge/relay.test.ts b/app/src/lib/bridge/relay.test.ts index b4c03f7..00b4088 100644 --- a/app/src/lib/bridge/relay.test.ts +++ b/app/src/lib/bridge/relay.test.ts @@ -363,3 +363,33 @@ test("Base USDC approval cannot borrow Arc's token, use unlimited amount or redi assert.throws(() => validateRelayQuote(quote, input, validateRelayChains(relayChainsFixture(), input), BASE_USDC_FIXTURE_NOW), BridgeApiError, variant); } }); + +test("the chain catalogue is reused per fetcher within its TTL and refetched after it or after a failure", async () => { + const calls: string[] = []; + let chainsBody: unknown = relayChainsFixture(); + const fetcher = async (url: string) => { + calls.push(url); + return Response.json(url.endsWith("/chains") ? chainsBody : relayQuoteFixture()); + }; + const chainsCalls = () => calls.filter((url) => url.endsWith("/chains")).length; + let now = FIXTURE_NOW - 100_000; // the fixture order deadline is FIXTURE_NOW + 100 s; keep every step inside it + await Promise.all([getBridgeQuote(FIXTURE_INPUT, fetcher, () => now), getBridgeQuote(FIXTURE_INPUT, fetcher, () => now)]); + assert.equal(chainsCalls(), 1); // concurrent quotes share one in-flight catalogue + now += 59_000; + await getBridgeQuote(FIXTURE_INPUT, fetcher, () => now); + assert.equal(chainsCalls(), 1); + now += 2_000; + await getBridgeQuote(FIXTURE_INPUT, fetcher, () => now); + assert.equal(chainsCalls(), 2); + // A copy that fails verification is dropped, not served again for a minute. + chainsBody = { chains: [] }; + now += 61_000; + await assert.rejects(getBridgeQuote(FIXTURE_INPUT, fetcher, () => now), (error) => error instanceof BridgeApiError); + assert.equal(chainsCalls(), 3); + chainsBody = relayChainsFixture(); + await getBridgeQuote(FIXTURE_INPUT, fetcher, () => now); + assert.equal(chainsCalls(), 4); + // Another fetcher never sees this fetcher's copy. + await getBridgeQuote(FIXTURE_INPUT, async (url) => { calls.push(`other:${url}`); return Response.json(url.endsWith("/chains") ? relayChainsFixture() : relayQuoteFixture()); }, () => now); + assert.equal(calls.filter((url) => url.startsWith("other:") && url.endsWith("/chains")).length, 1); +}); diff --git a/app/src/lib/bridge/relay.ts b/app/src/lib/bridge/relay.ts index 2703f2c..5d0ded5 100644 --- a/app/src/lib/bridge/relay.ts +++ b/app/src/lib/bridge/relay.ts @@ -52,10 +52,28 @@ async function relayJson(path: string, init: RequestInit, maxBytes: number, fetc } } +// The chain catalogue changes rarely and is large. One copy per fetcher for a +// minute serves concurrent quotes; a failed or unverifiable copy is dropped so +// the next quote refetches rather than repeating the same rejection. +export const RELAY_CHAINS_TTL_MS = 60_000; +const chainsCache = new WeakMap }>(); +function relayChains(fetcher: Fetcher, now: number): Promise { + const cached = chainsCache.get(fetcher); + if (cached && now - cached.at < RELAY_CHAINS_TTL_MS && now >= cached.at) return cached.value; + const value = relayJson("/chains", { method: "GET" }, 2_000_000, fetcher); + chainsCache.set(fetcher, { at: now, value }); + value.catch(() => forgetChains(fetcher, value)); + return value; +} +function forgetChains(fetcher: Fetcher, value: Promise) { + if (chainsCache.get(fetcher)?.value === value) chainsCache.delete(fetcher); +} + export async function getBridgeQuote(request: BridgeQuoteRequest, fetcher: Fetcher = fetch, now: () => number = Date.now): Promise { const input = parseBridgeRequest(request); + const chainsPromise = relayChains(fetcher, now()); const [chains, quote] = await Promise.all([ - relayJson("/chains", { method: "GET" }, 2_000_000, fetcher), + chainsPromise, relayJson("/quote/v2", { method: "POST", body: JSON.stringify({ user: input.address, recipient: input.address, refundTo: input.address, originChainId: input.originChainId, destinationChainId: input.destinationChainId, originCurrency: bridgeCurrency(input.originChainId, input.originAsset, "input").address, @@ -64,7 +82,10 @@ export async function getBridgeQuote(request: BridgeQuoteRequest, fetcher: Fetch }) }, 128_000, fetcher), ]); try { - return validateRelayQuote(quote, input, validateRelayChains(chains, input), now()); + let metadata; + try { metadata = validateRelayChains(chains, input); } + catch (error) { forgetChains(fetcher, chainsPromise); throw error; } + return validateRelayQuote(quote, input, metadata, now()); } catch (error) { if (error instanceof BridgeApiError && error.status === 422) throw error; throw new BridgeApiError("The bridge returned an unverifiable quote. Please try again."); diff --git a/app/src/lib/bridge/types.ts b/app/src/lib/bridge/types.ts index 8d736bd..ed54c39 100644 --- a/app/src/lib/bridge/types.ts +++ b/app/src/lib/bridge/types.ts @@ -69,6 +69,7 @@ export type BridgeQuote = BridgeQuoteRequest & { approval?: BridgeApproval; // allowlisted USDC -> pinned Relay depository, exact amount only timeEstimate: number; expiresAt: number; // milliseconds, shortened from the provider's order deadline + ttlMs: number; // remaining validity when issued; the browser anchors this on its own clock transaction: { to: Address; data: Hex; value: string; chainId: BridgeChainId }; }; diff --git a/app/src/lib/bridge/validation.test.ts b/app/src/lib/bridge/validation.test.ts index 313741f..2c2b029 100644 --- a/app/src/lib/bridge/validation.test.ts +++ b/app/src/lib/bridge/validation.test.ts @@ -29,6 +29,7 @@ test("captured native quote hashes to the independently captured order ID and no assert.equal(quote.relayFee, "0.000012330451724044"); assert.equal(quote.sourceGas, "0.000003"); assert.equal(quote.expiresAt, FIXTURE_NOW + QUOTE_TTL_MS); + assert.equal(quote.ttlMs, QUOTE_TTL_MS); assert.deepEqual(quote.transaction, { to: fixture.steps[0].items[0].data.to, data: fixture.steps[0].items[0].data.data, value: FIXTURE_INPUT.amount, chainId: 8453 }); }); diff --git a/app/src/lib/bridge/validation.ts b/app/src/lib/bridge/validation.ts index 3333506..b1b7f71 100644 --- a/app/src/lib/bridge/validation.ts +++ b/app/src/lib/bridge/validation.ts @@ -201,7 +201,7 @@ export function validateRelayQuote(value: unknown, input: BridgeQuoteRequest, ch ...input, requestId: step.requestId, amountOut, minimumAmountOut, relayFee: formatUnits(BigInt(relayerAmount), inputCurrency.decimals), sourceGas: formatEther(BigInt(gasAmount)), totalImpactPercent, ...(erc20Input ? { approval: { token: inputCurrency.address, spender: RELAY_DEPOSITORY, amount: input.amount } } : {}), - timeEstimate: details.timeEstimate, expiresAt, + timeEstimate: details.timeEstimate, expiresAt, ttlMs: expiresAt - now, transaction: { to: RELAY_DEPOSITORY, data: calldata, value: depositValue, chainId: input.originChainId }, }; } diff --git a/docs/bridge.md b/docs/bridge.md index 83b38bd..3ecb84e 100644 --- a/docs/bridge.md +++ b/docs/bridge.md @@ -33,7 +33,9 @@ An interrupted wallet response is **uncertain**, not failed. The app must not si - Relay is a third-party bridge protocol. Its availability, liquidity, estimates and settlement are not guaranteed by Openlaunch. Provider response changes fail closed until reviewed. - Funds never pass through an Openlaunch wallet or new Openlaunch contract. - A user can keep a wallet approval prompt open beyond a quote's validity. The app cannot retract that wallet prompt; the protocol deadline and provider recovery process still apply. -- Unknown broadcasts and manually replaced/cancelled transactions may need manual investigation via Relay and the source explorer. Never clear an unresolved recovery record merely because a timer elapsed. +- A sped-up or cancelled-and-replaced deposit is adopted automatically: when the wallet's hash is unmined and Relay reports a different source hash, the app verifies that hash is this wallet's exact deposit for the same order before tracking it. +- A record with no deposit anywhere can be discarded by the user after 15 minutes, and only while Relay still reports `waiting` with no transaction hashes and the source chain has no receipt for the wallet's hash, checked within the last minute. The same wait applies to an unmined approval, which if mined later only grants the exact allowance the next deposit re-reads. Any on-chain evidence keeps the record until it settles. Unknown broadcasts and replaced transactions that Relay cannot match still need manual investigation via Relay and the source explorer. +- Quote validity travels as a remaining duration (`ttlMs`) and is anchored on the browser's own clock at request time, so device clock skew cannot expire or extend a quote. - This implementation has read-only live quote verification and mocked execution tests, **not a real-money end-to-end transfer test**. Before production rollout, an authorized maintainer should use a small amount to verify all supported directions, Arc approval and rejection, a normal delivery, and refresh recovery with their own wallet. Do not describe the feature as independently audited or risk-free. ## Development and verification From 1cb3c26eeba4f39085a42a8513857303f3136388 Mon Sep 17 00:00:00 2001 From: Kevin Codex Date: Wed, 16 Sep 2026 18:49:58 +0800 Subject: [PATCH 05/12] bridge: drop the design-tool artifacts (DESIGN.md, bridge-surface.md) Neither file is read by code or tests. They are output templates from the contributor's design tooling and duplicate theme tokens that already live in the stylesheet. docs/bridge.md remains the bridge documentation. --- app/src/components/bridge/DESIGN.md | 171 ---------------------------- docs/bridge-surface.md | 13 --- 2 files changed, 184 deletions(-) delete mode 100644 app/src/components/bridge/DESIGN.md delete mode 100644 docs/bridge-surface.md diff --git a/app/src/components/bridge/DESIGN.md b/app/src/components/bridge/DESIGN.md deleted file mode 100644 index e871dd7..0000000 --- a/app/src/components/bridge/DESIGN.md +++ /dev/null @@ -1,171 +0,0 @@ ---- -name: openlaunch.lol bridge -description: A scoped Clear Sky extension for reviewing ETH/USDC bridges across Base, Robinhood and Arc. -colors: - # Light-default snapshot of inherited tokens; runtime .dark overrides remain authoritative. - paper: "#fafaf8" - card: "#ffffff" - line: "#e7e5e4" - line-strong: "#d6d3d1" - ink: "#0f172a" - body: "#475569" - muted: "#64748b" - inverse: "#ffffff" - brand: "#0052ff" - brand-strong: "#0041cc" - brand-soft: "#eaf0ff" - up: "#15803d" - up-soft: "#ecfdf3" - down-ink: "#b91c1c" - down-soft: "#fef2f2" - scrim: "#0f172a" -typography: - control: - fontFamily: "var(--font-sans)" - fontSize: "13px" - fontWeight: 500 - detail: - fontFamily: "var(--font-sans)" - fontSize: "12px" - caption: - fontFamily: "var(--font-sans)" - fontSize: "11px" - address: - fontFamily: "var(--font-mono)" -rounded: - control: "8px" - block: "12px" - pill: "999px" -spacing: - "2": "8px" - "3": "12px" - "4": "16px" - "5": "20px" - "6": "24px" -components: - bridge-trigger: - backgroundColor: "{colors.card}" - textColor: "{colors.body}" - typography: "{typography.control}" - rounded: "{rounded.pill}" - padding: "0 11px" - bridge-primary: - backgroundColor: "{colors.brand}" - textColor: "{colors.inverse}" - rounded: "{rounded.block}" - padding: "12px 16px" - width: "100%" - bridge-primary-hover: - backgroundColor: "{colors.brand-strong}" - bridge-primary-disabled: - backgroundColor: "{colors.paper}" - textColor: "{colors.muted}" - bridge-error: - backgroundColor: "{colors.down-soft}" - textColor: "{colors.down-ink}" - rounded: "{rounded.control}" - padding: "12px" ---- - -# Design System: openlaunch.lol bridge - -## Overview - -**Creative North Star: "The Instrument Strip"** - -This component-local record inherits the incumbent Clear Sky world and the global design authority. It describes `BridgeDialog.tsx`, `BridgeDialog.module.css` and `BridgeProvider.tsx`; it does not redefine the site's identity, palette or typography. The direction contract remains in `docs/bridge-surface.md`. - -The bridge is a bounded interaction over the existing page: one clear amount, open cost rows and visible wallet identity. Quiet navigation opens the panel; an explicit action advances review. Transfer feedback preserves the same visual language. - -**Key Characteristics:** - -- Inherited light and dark tokens, with one filled action inside the active panel. -- Hairline-separated content within a single bounded dialog. -- Native recipient disclosure and explicit, readable transfer progress. - -## Colors - -Clear Sky's blue action and slate neutrals carry into the panel. Frontmatter records the default light values; all component CSS binds to the inherited `--color-*` properties, including their existing dark overrides. - -### Primary - -Base Blue identifies the main action, focus, selection and pending-status accent. Strong Blue supplies the filled action's hover state; Soft Blue supports status icons. - -### Neutral - -Paper provides quiet hover and disabled surfaces. Card is the dialog and trigger ground. Ink carries amounts and important labels; Body and Muted distinguish explanation from secondary context. Line separates sections, while Line Strong supports progress connectors and the recovery link. The scrim remains dark in both themes. - -### Semantic - -Up and Up Soft identify completed transfer feedback. Down Ink on Down Soft identifies errors. Written state labels carry the meaning independently of colour. - -**The Panel Action Rule.** Keep the bridge entry neutral; reserve the filled brand treatment inside the active panel for its next action. - -## Typography - -Inter is inherited through `--font-sans`; full addresses and request identifiers use the runtime `--font-mono` binding. This extension does not select a new font. The worktree's inherited mono binding and the global design record may differ; resolve that globally, not in bridge styles. - -The hierarchy is a restrained dialog title, prominent amount, compact control text, then detail and caption text. The implementation uses a 24px title, 38px amount entry (34px on phones), 27px receive amount and 20px transfer heading. These are local component measurements, not a new site-wide display ramp. Current financial figures use tabular numerals; this record does not promote their inherited sans rendering into an exception to global typography guidance. - -## Layout - -A centred panel is at most 480px wide, with 16px viewport clearance and internal scrolling. At 520px and below it becomes a full-width bottom sheet, retains 16px top clearance and adds bottom safe-area padding. Content order and cost visibility remain the same. - -The source and destination flank a route reversal control. Amount entry leads into open estimate and fee rows; labels align left and values right. Repeated 8–24px spacing organizes controls and sections. The desktop trigger becomes icon-only between 1024px and 1279px while retaining its accessible name. - -## Elevation & Depth - -Content inside the dialog stays flat and uses single-pixel hairlines. The overlay alone uses the existing `--shadow-dialog` or mobile `--shadow-sheet`, over a scrim mixed at 45%. No new shadow palette is introduced. - -**The Bounded Interaction Rule.** Use the dialog boundary to contain the transaction; keep route, estimate, recipient and progress sections open within it. - -## Shapes - -Header entry uses a pill; the filled action and mobile menu entry use soft block corners. Close and route-reversal controls are circles. The local dialog has 24px corners, becoming 22px top corners on phones. These dialog measurements do not replace the global shape scale. - -The network menu uses a local 16px outer radius and 10px row radius around a 6px inset, preserving concentric corners. Its network names reuse the existing 14px control text, with 11px gas captions and a 12px menu label. These are component-local measurements, not a new site-wide token scale. - -## Components - -### Entry and action controls - -The neutral header entry is at least 36px tall; its mobile menu variant is at least 48px. The primary action is full-width and at least 48px tall. Disabled primary actions use Paper, Line and Muted at full opacity. Hover is restricted to fine pointers; active controls use the existing restrained press scale. - -### Amount and quote - -The amount field is borderless within its section, with an explicit source-asset label, available-balance context and a brand caret. Base UI network pickers expose Base, Robinhood and Arc with gas currencies. Each trigger combines the official mark, network name and gas token; its portaled menu uses spacious logo-led rows, a selected checkmark, keyboard highlighting and viewport collision handling. The menu stays above the scrolling dialog and inherits light/dark tokens. Pointer opening uses a short origin-aware transition; keyboard opening and reduced-motion preferences skip it. Official locally hosted Base, Robinhood, Arc, Ethereum and USDC marks identify the route and asset. Preserve their upstream geometry and colours; the Robinhood feather switches between official black/white variants, and the white Arc mark keeps a dark backing in either theme. Quotes distinguish estimated output, minimum received, included Relay fee and extra source gas. ETH/USDC routes disclose conversion and estimated value change. Changing the source asset clears the amount and quote. No quote value is implied before a quote exists. - -Base exposes a compact 44px ETH/USDC token picker beside the send amount and in the receive row. It reuses the network menu's Base UI behavior, official marks, focus, collision handling and motion. Networks with only one supported asset show a static token label, not a pretend dropdown. Base USDC's available balance is shown separately from ETH available for gas. Robinhood routes disclose that USDC is unavailable under the current safety checks. - -Base and Arc USDC approval is a separate action, limited to the entered amount. A pending or uncertain approval has its own source-network status and explorer link, never a misleading bridge-delivery progress indicator. Approval confirmation leads to a fresh quote and a separate deposit confirmation. Use plain explanatory text with a restrained accent rule, not another nested card. - -### Receiving wallet - -A native details/summary row combines the existing wallet avatar, receiving-wallet label and short address. It opens to a full, selectable, wrapping address in both the review and tracked-transfer views. Keep the full address available at phone widths. - -**The Inspectable Recipient Rule.** A shortened receiving address must disclose its complete value in the same flow before and after submission. - -### Transfer progress and recovery - -An ordered three-step sequence uses neutral numbered circles, hairline connectors and green check states only when completed. Status text distinguishes waiting, checking, delivery, refund and failure. Recovery uses a neutral outlined Relay link and explicit explorer links; a new-transfer control is secondary. - -### Focus and motion - -Base UI owns the dialog interaction. Opening focuses the popup; closing restores the initiating control, with the mobile menu button as fallback. All panel buttons, links, inputs and summaries receive a 2px brand focus outline with 4px offset. - -Entry uses a short opacity/rise transition; press feedback is finite. A spinner indicates active work. Reduced motion disables panel transitions and spinner rotation. Closing the panel preserves tracking so reopening can show the same transfer. - -## Do's and Don'ts - -### Do: - -- **Do** bind colours, fonts and elevation to the existing runtime theme variables. -- **Do** preserve the complete receiving-wallet disclosure in review and transfer states. -- **Do** keep costs, estimates and recovery actions legible beside their amounts. -- **Do** retain keyboard focus, mobile safe-area clearance and reduced-motion behavior. - -### Don't: - -- **Don't** add nested cards around each cost or progress section. -- **Don't** use colour alone to communicate completion, failure or uncertainty. -- **Don't** replace official network or token marks with letter badges, distort their proportions, or recolour them with CSS filters. diff --git a/docs/bridge-surface.md b/docs/bridge-surface.md deleted file mode 100644 index 397b7f8..0000000 --- a/docs/bridge-surface.md +++ /dev/null @@ -1,13 +0,0 @@ -# Bridge panel direction - -THESIS: Fund Base, Robinhood and Arc without losing your place in Openlaunch, with explicit ETH/USDC conversion and approval boundaries. - -OWN-WORLD: Inherit Clear Sky's blue, flat surfaces, existing wallet identity, and light/dark tokens. No new brand, chain registry, or asset imagery. - -STORY: Choose source and destination, enter the source asset, review output and costs, approve exact USDC if required, review again, confirm the deposit, follow delivery. Keep the destination, gas currency and recipient visible. - -FIRST VIEWPORT: A restrained Bridge heading leads into two selectors offering three networks, a generous amount input, and one clear action. Fine separators organize costs; no nested dashboard cards. - -FORM: Local extension of the navigation/wallet surface, using a protected-focus dialog for a financial action. Its mobile version preserves the same hierarchy. No concept seed required. - -FINISH: unreviewed and undocumented is unfinished; this build ends with the finish review, the verdict, DESIGN.md, and every shipping raster carrying its provenance From a0f59f4d93e0cb6ccc7f76833546e25aa101b1b8 Mon Sep 17 00:00:00 2001 From: Kevin Codex Date: Wed, 16 Sep 2026 19:03:09 +0800 Subject: [PATCH 06/12] bridge: discard re-verifies Relay status and the receipt under the lock The observation that shows the discard control only decides visibility. Removal now fetches a fresh provider status and, when a hash exists, the source-chain receipt under the per-wallet lock, and keeps the record if either shows activity. The button is disabled while that check runs. --- app/src/app/ui-review-bridge/BridgeReview.tsx | 4 +- app/src/components/bridge/BridgeDialog.tsx | 4 +- app/src/components/bridge/bridge-ui.test.ts | 7 +- app/src/components/bridge/useBridge.ts | 74 ++++++++++++++----- 4 files changed, 66 insertions(+), 23 deletions(-) diff --git a/app/src/app/ui-review-bridge/BridgeReview.tsx b/app/src/app/ui-review-bridge/BridgeReview.tsx index 908c476..e0892f3 100644 --- a/app/src/app/ui-review-bridge/BridgeReview.tsx +++ b/app/src/app/ui-review-bridge/BridgeReview.tsx @@ -58,8 +58,8 @@ export default function BridgeReview() { approval, approvalRequired: erc20Input && !approved, allowanceLoading: false, approvalBusy: false, approvalError: null, approve: async () => setScene("approval_pending"), retryApproval: () => { setApproved(true); setScene("approval_confirmed"); }, recoverApproval: async () => { setApproved(true); setScene("approval_confirmed"); }, - approvalCanBeDiscarded: scene === "approval_uncertain", discardApproval: () => { setApproved(false); setScene("idle"); }, - canDiscard: scene === "uncertain", discard: () => setScene("idle"), + approvalCanBeDiscarded: scene === "approval_uncertain", discardApproval: async () => { setApproved(false); setScene("idle"); }, + canDiscard: scene === "uncertain", discard: async () => setScene("idle"), discarding: false, }; return (
diff --git a/app/src/components/bridge/BridgeDialog.tsx b/app/src/components/bridge/BridgeDialog.tsx index 31b99bf..df9a6ea 100644 --- a/app/src/components/bridge/BridgeDialog.tsx +++ b/app/src/components/bridge/BridgeDialog.tsx @@ -250,7 +250,7 @@ function ApprovalProgress({ bridge: b }: { bridge: Bridge }) { {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.

- +
: 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.

; @@ -298,7 +298,7 @@ export function Transfer({ bridge: b }: { bridge: Bridge }) { {b.canDiscard ?
No deposit after {DISCARD_AFTER_MS / 60_000} minutes?

Relay has not seen a deposit for this transfer and nothing is confirmed on {origin.name}. If your wallet shows the transaction as dropped or cancelled, you can discard this record and start over. Keep the Transfer ID below in case you need Relay support.

- +
: null}

Transfer ID {transfer.requestId}

diff --git a/app/src/components/bridge/bridge-ui.test.ts b/app/src/components/bridge/bridge-ui.test.ts index 392ea47..96535f7 100644 --- a/app/src/components/bridge/bridge-ui.test.ts +++ b/app/src/components/bridge/bridge-ui.test.ts @@ -118,9 +118,12 @@ test("USDC selectors separate selected token units from native gas and disallow test("stuck records have a bounded discard path, and the hook avoids APIs missing in older wallet browsers", () => { const hook = read("./useBridge.ts"); assert.match(panel, /b\.canDiscard \?
Discard this transfer/); + assert.match(panel, /disabled=\{b\.discarding\} onClick=\{b\.discard\}>/); assert.match(panel, /b\.approvalCanBeDiscarded \?
Discard this approval/); + assert.match(panel, /onClick=\{b\.discardApproval\}>/); + assert.match(hook, /const fresh = \{ requestId, status, sourceMined, observedAt: Date\.now\(\) \};/); // removal re-reads evidence under the lock + assert.match(hook, /if \(!transferCanDiscard\(current, fresh, Date\.now\(\)\)\) throw/); + assert.match(hook, /if \(!approvalCanDiscard\(current, fresh, Date\.now\(\)\)\) throw/); assert.match(panel, /Keep the Transfer ID below/); assert.match(panel, /Openlaunch does not operate Relay, holds no funds in transit, and is not responsible for delays, refunds or losses/); assert.doesNotMatch(hook, /AbortSignal\.(any|timeout)/); diff --git a/app/src/components/bridge/useBridge.ts b/app/src/components/bridge/useBridge.ts index a70e917..e1a8396 100644 --- a/app/src/components/bridge/useBridge.ts +++ b/app/src/components/bridge/useBridge.ts @@ -65,6 +65,7 @@ export function useBridge() { const [observation, setObservation] = useState(null); const [approvalObservation, setApprovalObservation] = useState(null); const [now, setNow] = useState(0); + const [discarding, setDiscarding] = useState(false); const actionLock = useRef(false); const quoteSequence = useRef(0); const quoteAbort = useRef(null); @@ -561,36 +562,75 @@ export function useBridge() { const canDiscard = transferCanDiscard(tracked, observation, now); const approvalCanBeDiscarded = approvalCanDiscard(approval, approvalObservation, now); - function discard() { - if (actionLock.current || sending || !address || !tracked || !canDiscard || !navigator.locks) return; + const receiptOrNull = (pub: NonNullable>, hash: `0x${string}`) => pub.getTransactionReceipt({ hash }).catch((error: unknown) => { + if (error instanceof TransactionReceiptNotFoundError) return null; + throw error; + }); + + // Both discards re-read Relay and the source chain under the wallet lock. The + // observation that showed the button only decides visibility, never removal. + async function discard() { + if (actionLock.current || sending || discarding || !address || !tracked || !canDiscard || !navigator.locks) return; const requestId = tracked.requestId; - void navigator.locks.request(transferLockName(address), { ifAvailable: true }, (lock) => { - if (!lock) return; - try { + setDiscarding(true); + try { + await navigator.locks.request(transferLockName(address), { ifAvailable: true }, async (lock) => { + if (!lock) throw new Error("Another bridge action is in progress. Try again in a moment."); const current = transfers.read(address); - if (current?.requestId !== requestId || !transferCanDiscard(current, observation, Date.now())) return; + if (current?.requestId !== requestId || transferIsTerminal(current)) return; + const status = await fetch(`/api/bridge/status?requestId=${encodeURIComponent(requestId)}`, { cache: "no-store", signal: linkedTimeoutSignal(undefined, 15_000) }).then(responseBody).then(validateBridgeStatus); + let sourceMined = false; + if (current.sourceHash) { + const pub = getPublicClient(config, { chainId: current.originChainId }); + if (!pub) throw new Error("Could not connect to the source network. Try again."); + const [chainId, receipt] = await Promise.all([pub.getChainId(), receiptOrNull(pub, current.sourceHash)]); + if (chainId !== current.originChainId) throw new Error("The source RPC reported a different network. Try again."); + sourceMined = receipt !== null; + } + const fresh = { requestId, status, sourceMined, observedAt: Date.now() }; + setObservation(fresh); + if (!transferCanDiscard(current, fresh, Date.now())) throw new Error("This transfer now shows activity, so it was kept. Tracking continues."); transfers.remove(address); setObservation(null); invalidateQuote(); setStatusIssue(null); - } catch { /* storage warning is exposed in the store */ } - }); + }); + } catch (error) { + setStatusIssue({ key: requestId, message: messageOf(error, "Could not verify the transfer before discarding it. Try again.") }); + } finally { + setDiscarding(false); + } } - function discardApproval() { - if (actionLock.current || approvalSending || !address || !approval || !approvalCanBeDiscarded || !navigator.locks) return; + async function discardApproval() { + if (actionLock.current || approvalSending || discarding || !address || !approval || !approvalCanBeDiscarded || !navigator.locks) return; const createdAt = approval.createdAt; - void navigator.locks.request(transferLockName(address), { ifAvailable: true }, (lock) => { - if (!lock) return; - try { + setDiscarding(true); + try { + await navigator.locks.request(transferLockName(address), { ifAvailable: true }, async (lock) => { + if (!lock) throw new Error("Another bridge action is in progress. Try again in a moment."); const current = approvals.read(address); - if (current?.createdAt !== createdAt || !approvalCanDiscard(current, approvalObservation, Date.now())) return; + if (current?.createdAt !== createdAt || !approvalBlocksSubmission(current)) return; + let fresh: ApprovalReceiptObservation | null = null; + if (current.approvalHash) { + const pub = getPublicClient(config, { chainId: current.chainId }); + if (!pub) throw new Error("Could not connect to the approval's network. Try again."); + const [chainId, receipt] = await Promise.all([pub.getChainId(), receiptOrNull(pub, current.approvalHash)]); + if (chainId !== current.chainId) throw new Error("The source RPC reported a different network. Try again."); + fresh = { createdAt, receiptFound: receipt !== null, observedAt: Date.now() }; + setApprovalObservation(fresh); + } + if (!approvalCanDiscard(current, fresh, Date.now())) throw new Error("This approval now shows activity, so it was kept. Checking continues."); approvals.remove(address); setApprovalObservation(null); setApprovalIssue(null); invalidateQuote(); - } catch { /* storage warning is exposed in the store */ } - }); + }); + } catch (error) { + setApprovalIssue({ key: address.toLowerCase(), message: messageOf(error, "Could not verify the approval before discarding it. Try again.") }); + } finally { + setDiscarding(false); + } } const retryStatus = useCallback(() => setPollRevision((value) => value + 1), []); @@ -609,7 +649,7 @@ export function useBridge() { approval, approvalRequired, allowanceLoading, approvalBusy: approvalSending, approvalError: approvalIssue?.key === walletKey ? approvalIssue.message : quote && allowanceResult?.key === quote.requestId ? allowanceResult.error : null, approve, retryApproval, recoverApproval, approvalCanBeDiscarded, discardApproval, - canDiscard, discard, + canDiscard, discard, discarding, statusError: statusIssue && statusIssue.key === trackedId ? statusIssue.message : null, retryStatus, storageError, busy: sending || approvalSending || approvalPending || activePhase === "quoting", canReset: !sending && !approvalSending && !approvalPending && (!tracked || transferIsTerminal(tracked)), From cc5428e847765e93f37aed259a0b4d70ee7ba91d Mon Sep 17 00:00:00 2001 From: Kevin Codex Date: Wed, 16 Sep 2026 19:21:29 +0800 Subject: [PATCH 07/12] bridge: show the provider's own reason behind generic RPC errors viem wraps unrecognised wallet/RPC error codes as "An unknown RPC error occurred." and keeps the provider text in details. bridgeErrorMessage now appends that text, maps the wallet's "unrecognized chain" reply to an actionable hint, and treats any insufficient-funds cause uniformly. --- app/src/components/bridge/bridge-ui.test.ts | 2 +- app/src/components/bridge/useBridge.ts | 14 +++---------- app/src/lib/bridge/client.test.ts | 17 +++++++++++++-- app/src/lib/bridge/client.ts | 23 ++++++++++++++++++++- 4 files changed, 41 insertions(+), 15 deletions(-) diff --git a/app/src/components/bridge/bridge-ui.test.ts b/app/src/components/bridge/bridge-ui.test.ts index 96535f7..8c43e5e 100644 --- a/app/src/components/bridge/bridge-ui.test.ts +++ b/app/src/components/bridge/bridge-ui.test.ts @@ -132,5 +132,5 @@ test("stuck records have a bounded discard path, and the hook avoids APIs missin assert.match(hook, /setActivity\(activityAfterWalletChange\)/); assert.match(hook, /error instanceof TransactionReceiptNotFoundError\) return null/); assert.match(hook, /replacementSourceHash\(current, status, receipt\.status === "fulfilled" && receipt\.value === null\)/); - assert.match(hook, /friendlyError\(error\)/); + assert.match(hook, /const messageOf = bridgeErrorMessage;/); }); diff --git a/app/src/components/bridge/useBridge.ts b/app/src/components/bridge/useBridge.ts index e1a8396..8946328 100644 --- a/app/src/components/bridge/useBridge.ts +++ b/app/src/components/bridge/useBridge.ts @@ -4,11 +4,10 @@ import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from " import { useAccount, useBalance, useConfig, useReadContract, useSwitchChain } from "wagmi"; import { getAccount, getPublicClient, getWalletClient } from "wagmi/actions"; import { estimateTotalFee } from "viem/op-stack"; -import { BaseError, erc20Abi, parseEther, TransactionReceiptNotFoundError, type Address } from "viem"; -import { friendlyError } from "@/lib/errors"; +import { erc20Abi, parseEther, TransactionReceiptNotFoundError, type Address } from "viem"; import { BRIDGE_WALLET_CHAINS } from "@/lib/bridge/chains"; import { BRIDGE_CHAINS, bridgeCurrency, defaultBridgeAsset, isBridgeAssetSupported, isBridgeChainId, type BridgeAsset, type BridgeChainId, type BridgeQuote } from "@/lib/bridge/types"; -import { activityAfterWalletChange, anchorQuoteExpiry, bridgeGasBudget, bridgeRequest, bridgeRequestKey, changeBridgeRoute, hasMatchingDepositEvent, isHash, isMatchingSourceDeposit, linkedTimeoutSignal, mergeBridgeStatus, nativeSourceAmount, RELAY_DEPOSITORY, replacementSourceHash, submitBridgeDeposit, transferCanDiscard, transferIsTerminal, transferPhase, validateBridgeQuote, validateBridgeStatus, type BridgeActivity, type BridgePhase, type BridgeRouteChange, type BridgeRouteInputs, type ProviderObservation, type TrackedBridgeTransfer } from "@/lib/bridge/client"; +import { activityAfterWalletChange, anchorQuoteExpiry, bridgeErrorMessage, bridgeGasBudget, bridgeRequest, bridgeRequestKey, changeBridgeRoute, hasMatchingDepositEvent, isHash, isMatchingSourceDeposit, linkedTimeoutSignal, mergeBridgeStatus, nativeSourceAmount, RELAY_DEPOSITORY, replacementSourceHash, submitBridgeDeposit, transferCanDiscard, transferIsTerminal, transferPhase, validateBridgeQuote, validateBridgeStatus, type BridgeActivity, type BridgePhase, type BridgeRouteChange, type BridgeRouteInputs, type ProviderObservation, type TrackedBridgeTransfer } from "@/lib/bridge/client"; import { BRIDGE_STORAGE_PREFIX, createBridgeTransferStore } from "@/lib/bridge/client-storage"; import { APPROVAL_STORAGE_PREFIX, approvalBlocksSubmission, approvalCanDiscard, createApprovalStore, hasMatchingApprovalEvent, isMatchingApprovalTransaction, reconcileApproval, submitExactApproval, validateApprovalMetadata, type ApprovalReceiptObservation } from "@/lib/bridge/approval"; @@ -20,14 +19,7 @@ const transfers = createBridgeTransferStore(() => window.localStorage); const approvals = createApprovalStore(() => window.localStorage); type QuoteEnvelope = { quote: BridgeQuote; key: string; walletChainId?: number; requestedAt: number }; type Issue = { key: string; message: string } | null; -/** Wallet and RPC errors go through the app's shared copy instead of raw viem dumps. */ -const messageOf = (error: unknown, fallback: string, gasSymbol = "ETH") => { - if (error instanceof BaseError) { - const message = friendlyError(error); - return /insufficient funds/i.test(error.shortMessage || error.message) ? `Not enough ${gasSymbol} for this transaction plus gas.` : message; - } - return error instanceof Error ? error.message : fallback; -}; +const messageOf = bridgeErrorMessage; const transferLockName = (address: Address) => `openlaunch:bridge:${address.toLowerCase()}`; async function responseBody(response: Response): Promise { diff --git a/app/src/lib/bridge/client.test.ts b/app/src/lib/bridge/client.test.ts index aacd5f4..517fcf5 100644 --- a/app/src/lib/bridge/client.test.ts +++ b/app/src/lib/bridge/client.test.ts @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { encodeAbiParameters, encodeEventTopics, encodeFunctionData, type Address, type Hex } from "viem"; -import { activityAfterWalletChange, anchorQuoteExpiry, DISCARD_AFTER_MS, linkedTimeoutSignal, replacementSourceHash, transferCanDiscard, bridgeGasBudget, bridgeRequest, bridgeRequestKey, changeBridgeRoute, ERC20_DEPOSIT_ABI, ERC20_DEPOSIT_EVENT, hasMatchingDepositEvent, isMatchingSourceDeposit, isWalletRejection, mergeBridgeStatus, nativeSourceAmount, NATIVE_DEPOSIT_ABI, NATIVE_DEPOSIT_EVENT, parseBridgeAmount, parseStoredTransfer, RELAY_DEPOSITORY, serializeTransfer, submitBridgeDeposit, transferIsTerminal, validateBridgeQuote, validateBridgeStatus, type DepositDependencies, type TrackedBridgeTransfer } from "./client"; +import { encodeAbiParameters, encodeEventTopics, encodeFunctionData, HttpRequestError, InsufficientFundsError, RpcRequestError, SwitchChainError, UnknownRpcError, UserRejectedRequestError, type Address, type Hex } from "viem"; +import { activityAfterWalletChange, anchorQuoteExpiry, bridgeErrorMessage, DISCARD_AFTER_MS, linkedTimeoutSignal, replacementSourceHash, transferCanDiscard, bridgeGasBudget, bridgeRequest, bridgeRequestKey, changeBridgeRoute, ERC20_DEPOSIT_ABI, ERC20_DEPOSIT_EVENT, hasMatchingDepositEvent, isMatchingSourceDeposit, isWalletRejection, mergeBridgeStatus, nativeSourceAmount, NATIVE_DEPOSIT_ABI, NATIVE_DEPOSIT_EVENT, parseBridgeAmount, parseStoredTransfer, RELAY_DEPOSITORY, serializeTransfer, submitBridgeDeposit, transferIsTerminal, validateBridgeQuote, validateBridgeStatus, type DepositDependencies, type TrackedBridgeTransfer } from "./client"; import { bridgeStorageKey, createBridgeTransferStore } from "./client-storage"; import { ARC_USDC, BASE_USDC, BRIDGE_CHAIN_IDS, type BridgeQuote, type BridgeQuoteRequest } from "./types"; @@ -579,3 +579,16 @@ test("a wallet change clears an in-flight quote lock but leaves wallet prompts u assert.equal(activityAfterWalletChange(activity), activity); } }); + +test("bridge error copy surfaces the provider's own text behind viem's generic wrappers", () => { + const rpc = (code: number, message: string) => new RpcRequestError({ body: {}, error: { code, message }, url: "http://wallet" }); + assert.equal(bridgeErrorMessage(new UnknownRpcError(rpc(4902, "Unrecognized chain ID \"0x13b2\". Try adding the chain using wallet_addEthereumChain first.")), "x"), "Your wallet doesn't have this network yet. Add it in the wallet, then try again."); + assert.equal(bridgeErrorMessage(new UnknownRpcError(rpc(-32099, "node is syncing")), "x"), "An unknown RPC error occurred. node is syncing"); + assert.match(bridgeErrorMessage(new UnknownRpcError(rpc(-32099, "y".repeat(300))), "x"), /^An unknown RPC error occurred\. y{160}…$/); + assert.equal(bridgeErrorMessage(new HttpRequestError({ url: "http://127.0.0.1:8545", details: "fetch failed" }), "x"), "HTTP request failed. fetch failed"); + assert.equal(bridgeErrorMessage(new UserRejectedRequestError(new Error("User rejected the request.")), "x"), "You cancelled in your wallet."); + assert.equal(bridgeErrorMessage(new SwitchChainError(new UserRejectedRequestError(new Error("User rejected the request."))), "x"), "You cancelled in your wallet."); + assert.equal(bridgeErrorMessage(new InsufficientFundsError({ cause: new Error("insufficient funds for gas * price + value") }), "x", "USDC"), "Not enough USDC for this transaction plus gas."); + assert.equal(bridgeErrorMessage(new Error("plain"), "fallback"), "plain"); + assert.equal(bridgeErrorMessage("string", "fallback"), "fallback"); +}); diff --git a/app/src/lib/bridge/client.ts b/app/src/lib/bridge/client.ts index bda9a1d..b88ecdc 100644 --- a/app/src/lib/bridge/client.ts +++ b/app/src/lib/bridge/client.ts @@ -1,4 +1,5 @@ -import { decodeEventLog, decodeFunctionData, encodeFunctionData, isAddress, parseUnits, type Address, type Hex } from "viem"; +import { BaseError, decodeEventLog, decodeFunctionData, encodeFunctionData, InsufficientFundsError, isAddress, parseUnits, type Address, type Hex } from "viem"; +import { friendlyError } from "../errors"; import { bridgeCurrency, defaultBridgeAsset, isBridgeAssetSupported, isBridgeChainId, type BridgeAsset, type BridgeChainId, type BridgeQuote, type BridgeQuoteRequest, type BridgeStatus, type BridgeStatusResponse } from "./types"; export type BridgePhase = "idle" | "quoting" | "review" | "switching" | "confirming" | "pending" | "success" | "refund" | "failure" | "uncertain"; @@ -267,6 +268,26 @@ export function bridgeGasBudget(value: bigint, balance: bigint, gasEstimate: big return { gas, reserve }; } +const GENERIC_RPC_MESSAGE = /^(An unknown RPC error occurred\.|HTTP request failed\.|An internal error was received\.|The request took too long to respond\.)$/; + +/** + * Wallet and RPC errors go through the app's shared copy. viem's generic + * wrappers hide the provider's own text in `details`; surface it so a user + * (and support) can see "Unrecognized chain ID" rather than "unknown error". + */ +export function bridgeErrorMessage(error: unknown, fallback: string, gasSymbol = "ETH"): string { + if (error instanceof BaseError) { + const short = error.shortMessage || error.message; + const details = typeof error.details === "string" ? error.details.replace(/\s+/g, " ").trim() : ""; + if (error.walk((e) => e instanceof InsufficientFundsError) || /insufficient funds/i.test(`${short} ${details}`)) return `Not enough ${gasSymbol} for this transaction plus gas.`; + if (/unrecognized chain|wallet_addEthereumChain|chain .* not (?:been )?added/i.test(details)) return "Your wallet doesn't have this network yet. Add it in the wallet, then try again."; + const message = friendlyError(error); + if (!GENERIC_RPC_MESSAGE.test(message) || !details) return message; + return `${message} ${details.length > 160 ? `${details.slice(0, 160)}…` : details}`; + } + return error instanceof Error ? error.message : fallback; +} + export function isWalletRejection(error: unknown): boolean { let current = error; const seen = new Set(); From 945d57134ca89c29474dc7bbc8b899d2800982da Mon Sep 17 00:00:00 2001 From: Kevin Codex Date: Wed, 16 Sep 2026 19:37:35 +0800 Subject: [PATCH 08/12] bridge: proxy Arc reads and make the RPC proxy retry-friendly Public Base and Arc nodes rate-limit a single quote's burst of reads. They answer inside a 200 body, sometimes with one object where a batch array was due, which viem cannot use ("unknown RPC error" or a TypeError in its batch scheduler). The proxy now maps rate-limit and malformed replies to HTTP 429/502 so viem retries with backoff, and Arc reads go through the proxy (ARC_RPC_URL upstream, else the official node) instead of straight from every browser. The Arc origin leaves the CSP; a dev override NEXT_PUBLIC_RPC_URL_ARC is allowed like the other chains. --- app/.env.example | 4 ++++ app/src/app/api/rpc/route.ts | 14 ++++++++++--- app/src/lib/bridge/chains.ts | 12 +++++++++++- app/src/lib/bridge/client-chains.test.ts | 3 ++- app/src/lib/bridge/client.test.ts | 1 + app/src/lib/bridge/client.ts | 1 + app/src/lib/rpc-proxy.test.ts | 25 ++++++++++++++++++++++++ app/src/lib/rpc-proxy.ts | 21 ++++++++++++++++++++ app/src/lib/security-headers.test.ts | 6 +++--- app/src/lib/security-headers.ts | 8 ++------ app/src/lib/wagmi.ts | 8 ++++---- docs/bridge.md | 2 +- 12 files changed, 86 insertions(+), 19 deletions(-) create mode 100644 app/src/lib/rpc-proxy.test.ts create mode 100644 app/src/lib/rpc-proxy.ts diff --git a/app/.env.example b/app/.env.example index 5e0af6e..1694a48 100644 --- a/app/.env.example +++ b/app/.env.example @@ -6,11 +6,15 @@ NEXT_PUBLIC_SITE_URL=http://localhost:3000 # Browser RPCs (the site proxies mainnet reads through /api/rpc; these are for local anvil forks) NEXT_PUBLIC_RPC_URL_BASE=http://127.0.0.1:8545 NEXT_PUBLIC_RPC_URL_ROBINHOOD=http://127.0.0.1:8546 +# NEXT_PUBLIC_RPC_URL_ARC=http://127.0.0.1:8547 # Server RPCs (indexer + read proxy). Alchemy works for both chains, but cannot execute Base's B20 # stock tokens — BASE_B20_RPC_URL is the fallback node for those (default: base-rpc.publicnode.com). BASE_RPC_URL= ROBINHOOD_RPC_URL= +# Arc (bridge-only). The official public node rate-limits bursts; use a keyed provider in production +# (Alchemy arc-mainnet, dRPC rpc.drpc.mainnet.arc.io, Blockdaemon rpc.blockdaemon.mainnet.arc.io, QuickNode). +ARC_RPC_URL= # BASE_B20_RPC_URL=https://base-rpc.publicnode.com # Postgres (schema in db/schema.sql; `npm run migrate` applies it idempotently) diff --git a/app/src/app/api/rpc/route.ts b/app/src/app/api/rpc/route.ts index da3ca43..64fae86 100644 --- a/app/src/app/api/rpc/route.ts +++ b/app/src/app/api/rpc/route.ts @@ -2,6 +2,8 @@ import { NextResponse } from "next/server"; import { b20RpcUrl, rpcUrl } from "@/lib/chain"; import { responseHasB20Error } from "@/lib/launchpad/baseStocks"; import { CHAINS, isChainKey } from "@/lib/chainPublic"; +import { arc } from "@/lib/bridge/chains"; +import { upstreamStatus } from "@/lib/rpc-proxy"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -62,8 +64,11 @@ function safeJson(text: string): unknown { export async function POST(req: Request) { const c = new URL(req.url).searchParams.get("chain") ?? "base"; - if (!isChainKey(c)) return NextResponse.json({ error: "bad chain" }, { status: 400 }); - const upstream = rpcUrl(c) ?? CHAINS[c].rpcUrls.default.http[0]; + // Arc is a bridge-only network: same read allowlist and per-IP bucket, its own + // upstream (ARC_RPC_URL, else the official public node) so browsers never hit + // a public RPC directly and the API key stays server-side. + if (!isChainKey(c) && c !== "arc") return NextResponse.json({ error: "bad chain" }, { status: 400 }); + const upstream = c === "arc" ? process.env.ARC_RPC_URL?.trim() || arc.rpcUrls.default.http[0] : rpcUrl(c) ?? CHAINS[c].rpcUrls.default.http[0]; if (!upstream) return NextResponse.json({ error: "rpc unconfigured" }, { status: 503 }); const ip = (req.headers.get("fly-client-ip") || req.headers.get("x-forwarded-for") || "").split(",")[0].trim() || "0.0.0.0"; if (!take(ip)) return NextResponse.json({ error: "rate limited" }, { status: 429, headers: { "retry-after": "2" } }); @@ -94,7 +99,10 @@ export async function POST(req: Request) { status = alt.status; } } - return new NextResponse(text, { status, headers: { "content-type": "application/json", "cache-control": "no-store" } }); + // Rate limiting and malformed bodies become 429/502 so viem retries with backoff + // instead of surfacing "unknown RPC error" or throwing inside its batch scheduler. + status = upstreamStatus(text, Array.isArray(body), list.length, status); + return new NextResponse(text, { status, headers: { "content-type": "application/json", "cache-control": "no-store", ...(status === 429 ? { "retry-after": "1" } : {}) } }); } catch (err) { return NextResponse.json({ error: err instanceof Error ? err.message : "upstream failed" }, { status: 502 }); } diff --git a/app/src/lib/bridge/chains.ts b/app/src/lib/bridge/chains.ts index 63e7e9a..d9878c7 100644 --- a/app/src/lib/bridge/chains.ts +++ b/app/src/lib/bridge/chains.ts @@ -1,5 +1,5 @@ import { defineChain, type Chain } from "viem"; -import { CHAINS } from "../chainPublic"; +import { CHAINS, SITE_URL } from "../chainPublic"; import type { BridgeChainId } from "./types"; /** Wallet/RPC registration for bridging only; Arc is not a launchpad network. */ @@ -16,3 +16,13 @@ export const BRIDGE_WALLET_CHAINS: Record = { 4663: CHAINS.robinhood, 5042: arc, }; + +/** + * Browser reads for Arc go through the same-origin proxy (or a dev override), + * never straight to a public node: public Arc/Base RPCs rate-limit a single + * quote's burst of reads. The chain's own rpcUrls stay official so wallets + * add the network correctly. + */ +export function arcBrowserRpc(): string { + return process.env.NEXT_PUBLIC_RPC_URL_ARC?.trim() || `${SITE_URL}/api/rpc?chain=arc`; +} diff --git a/app/src/lib/bridge/client-chains.test.ts b/app/src/lib/bridge/client-chains.test.ts index 02c0dda..ecffb48 100644 --- a/app/src/lib/bridge/client-chains.test.ts +++ b/app/src/lib/bridge/client-chains.test.ts @@ -24,7 +24,8 @@ test("every bridge source has a matching wallet chain without extending launch n test("wallet config registers Arc for switching and native-balance reads", () => { const config = readFileSync(new URL("../wagmi.ts", import.meta.url), "utf8"); assert.match(config, /chains:\s*\[CHAINS\.base,\s*robinhood,\s*arc\]/); - assert.match(config, /\[arc\.id\]:\s*http\(arc\.rpcUrls\.default\.http\[0\]/); + assert.match(config, /\[arc\.id\]:\s*http\(arcBrowserRpc\(\)/); // proxied reads; the chain's own rpcUrls stay official for wallet_addEthereumChain + assert.doesNotMatch(config, /rpc\.mainnet\.arc\.io/); const hook = readFileSync(new URL("../../components/bridge/useBridge.ts", import.meta.url), "utf8"); assert.match(hook, /chain:\s*BRIDGE_WALLET_CHAINS\[q\.originChainId\]/); assert.doesNotMatch(hook, /CHAINS\[BRIDGE_CHAINS/); diff --git a/app/src/lib/bridge/client.test.ts b/app/src/lib/bridge/client.test.ts index 517fcf5..bae93d7 100644 --- a/app/src/lib/bridge/client.test.ts +++ b/app/src/lib/bridge/client.test.ts @@ -584,6 +584,7 @@ test("bridge error copy surfaces the provider's own text behind viem's generic w const rpc = (code: number, message: string) => new RpcRequestError({ body: {}, error: { code, message }, url: "http://wallet" }); assert.equal(bridgeErrorMessage(new UnknownRpcError(rpc(4902, "Unrecognized chain ID \"0x13b2\". Try adding the chain using wallet_addEthereumChain first.")), "x"), "Your wallet doesn't have this network yet. Add it in the wallet, then try again."); assert.equal(bridgeErrorMessage(new UnknownRpcError(rpc(-32099, "node is syncing")), "x"), "An unknown RPC error occurred. node is syncing"); + assert.equal(bridgeErrorMessage(new UnknownRpcError(rpc(-32016, "over rate limit")), "x"), "The network is busy right now. Try again in a moment."); assert.match(bridgeErrorMessage(new UnknownRpcError(rpc(-32099, "y".repeat(300))), "x"), /^An unknown RPC error occurred\. y{160}…$/); assert.equal(bridgeErrorMessage(new HttpRequestError({ url: "http://127.0.0.1:8545", details: "fetch failed" }), "x"), "HTTP request failed. fetch failed"); assert.equal(bridgeErrorMessage(new UserRejectedRequestError(new Error("User rejected the request.")), "x"), "You cancelled in your wallet."); diff --git a/app/src/lib/bridge/client.ts b/app/src/lib/bridge/client.ts index b88ecdc..a8713d1 100644 --- a/app/src/lib/bridge/client.ts +++ b/app/src/lib/bridge/client.ts @@ -281,6 +281,7 @@ export function bridgeErrorMessage(error: unknown, fallback: string, gasSymbol = const details = typeof error.details === "string" ? error.details.replace(/\s+/g, " ").trim() : ""; if (error.walk((e) => e instanceof InsufficientFundsError) || /insufficient funds/i.test(`${short} ${details}`)) return `Not enough ${gasSymbol} for this transaction plus gas.`; if (/unrecognized chain|wallet_addEthereumChain|chain .* not (?:been )?added/i.test(details)) return "Your wallet doesn't have this network yet. Add it in the wallet, then try again."; + if (/rate limit|too many requests/i.test(`${short} ${details}`)) return "The network is busy right now. Try again in a moment."; const message = friendlyError(error); if (!GENERIC_RPC_MESSAGE.test(message) || !details) return message; return `${message} ${details.length > 160 ? `${details.slice(0, 160)}…` : details}`; diff --git a/app/src/lib/rpc-proxy.test.ts b/app/src/lib/rpc-proxy.test.ts new file mode 100644 index 0000000..04dda79 --- /dev/null +++ b/app/src/lib/rpc-proxy.test.ts @@ -0,0 +1,25 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { upstreamStatus } from "./rpc-proxy.ts"; + +const ok = (id: number) => ({ jsonrpc: "2.0", id, result: "0x1" }); +const err = (id: number, code: number, message: string) => ({ jsonrpc: "2.0", id, error: { code, message } }); + +test("upstream replies clients cannot use become retryable statuses", () => { + assert.equal(upstreamStatus(JSON.stringify([ok(1), ok(2)]), true, 2, 200), 200); + assert.equal(upstreamStatus(JSON.stringify(ok(1)), false, 1, 200), 200); + assert.equal(upstreamStatus(JSON.stringify([ok(1), err(2, 3, "execution reverted")]), true, 2, 200), 200); // real errors pass through + // rate limiting inside a 200 body, by message or by code + assert.equal(upstreamStatus(JSON.stringify([ok(1), err(2, -32016, "over rate limit")]), true, 2, 200), 429); + assert.equal(upstreamStatus(JSON.stringify(err(1, -32000, "rate limit exceeded")), false, 1, 200), 429); + assert.equal(upstreamStatus(JSON.stringify(err(1, -32005, "limit")), false, 1, 200), 429); + assert.equal(upstreamStatus(JSON.stringify(err(1, 429, "Too Many Requests")), false, 1, 200), 429); + // shape mismatches that would throw inside viem's batch scheduler + assert.equal(upstreamStatus(JSON.stringify(err(1, -32016, "over rate limit")), true, 2, 200), 502); // single object for a batch + assert.equal(upstreamStatus(JSON.stringify([ok(1)]), true, 2, 200), 502); // short batch + assert.equal(upstreamStatus(JSON.stringify([ok(1)]), false, 1, 200), 502); // array for a single request + assert.equal(upstreamStatus("gateway timeout", true, 1, 200), 502); + assert.equal(upstreamStatus(JSON.stringify([null]), true, 1, 200), 502); + // non-200 statuses are passed through untouched + for (const status of [400, 403, 429, 500, 503]) assert.equal(upstreamStatus("{}", false, 1, status), status); +}); diff --git a/app/src/lib/rpc-proxy.ts b/app/src/lib/rpc-proxy.ts new file mode 100644 index 0000000..fe1c129 --- /dev/null +++ b/app/src/lib/rpc-proxy.ts @@ -0,0 +1,21 @@ +/** + * Public RPC nodes answer rate limiting and outages inside a 200 body, and + * sometimes with a single object where a batch array was due. viem retries + * HTTP 429/502 with backoff but cannot use those bodies (a short batch even + * throws a TypeError in its scheduler). Map them to statuses clients can retry. + */ +const RATE_LIMITED = /rate limit|too many requests|exceeded (?:the )?(?:quota|capacity|throughput)/i; +const RATE_LIMIT_CODES = new Set([-32005, -32016, 429]); + +export function upstreamStatus(text: string, batch: boolean, expected: number, status: number): number { + if (status !== 200) return status; + let parsed: unknown; + try { parsed = JSON.parse(text); } catch { return 502; } + if (Array.isArray(parsed) !== batch) return 502; + const items = Array.isArray(parsed) ? parsed : [parsed]; + if (Array.isArray(parsed) && parsed.length !== expected) return 502; + if (items.some((item) => !item || typeof item !== "object")) return 502; + const errors = items.map((item) => (item as { error?: { code?: unknown; message?: unknown } }).error).filter((error): error is { code?: unknown; message?: unknown } => !!error && typeof error === "object"); + if (errors.some((error) => (typeof error.code === "number" && RATE_LIMIT_CODES.has(error.code)) || RATE_LIMITED.test(String(error.message ?? "")))) return 429; + return 200; +} diff --git a/app/src/lib/security-headers.test.ts b/app/src/lib/security-headers.test.ts index 3f87da2..3d938ab 100644 --- a/app/src/lib/security-headers.test.ts +++ b/app/src/lib/security-headers.test.ts @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import test from "node:test"; -import { SECURITY_HEADERS, WALLET_CONNECT_SRC, BRIDGE_CONNECT_SRC, buildCsp, cspNonce, extraConnectOrigins, isOrigin } from "./security-headers.ts"; +import { SECURITY_HEADERS, WALLET_CONNECT_SRC, buildCsp, cspNonce, extraConnectOrigins, isOrigin } from "./security-headers.ts"; const NONCE = "AAAAAAAAAAAAAAAAAAAAAA=="; @@ -38,8 +38,8 @@ test("connect-src covers the site and the wallet SDK, plus vetted extra origins const connectSources = new Set(connect.split(/\s+/).slice(1)); assert.ok(connect.startsWith("connect-src 'self' ")); for (const origin of WALLET_CONNECT_SRC) assert.ok(connectSources.has(origin), `missing ${origin}`); - assert.deepEqual(BRIDGE_CONNECT_SRC, ["https://rpc.mainnet.arc.io"]); - assert.ok(connectSources.has("https://rpc.mainnet.arc.io")); + assert.ok(!connectSources.has("https://rpc.mainnet.arc.io")); // Arc reads go through /api/rpc, never a third-party origin + assert.deepEqual(extraConnectOrigins({ NEXT_PUBLIC_RPC_URL_ARC: "http://127.0.0.1:8547/" }), ["http://127.0.0.1:8547"]); assert.ok(!connectSources.has("https:")); assert.ok(connectSources.has("http://127.0.0.1:8545")); assert.ok(connectSources.has("https://openlaunch.lol")); diff --git a/app/src/lib/security-headers.ts b/app/src/lib/security-headers.ts index 088bdf6..18bfbfb 100644 --- a/app/src/lib/security-headers.ts +++ b/app/src/lib/security-headers.ts @@ -19,10 +19,6 @@ export const WALLET_CONNECT_SRC = [ "wss://www.walletlink.org", ] as const; -// Arc is registered for bridge balance/gas/receipt reads only. Quote and status -// requests remain same-origin. Pin this one official RPC, never an arbitrary URL. -export const BRIDGE_CONNECT_SRC = ["https://rpc.mainnet.arc.io"] as const; - const NONCE_RE = /^[A-Za-z0-9+/]{16,}={0,2}$/; /** Base64 of at least 16 random bytes. Runs in Node and the browser runtime alike. */ @@ -58,7 +54,7 @@ export function isOrigin(value: string): boolean { */ export function buildCsp(nonce: string, { dev = false, connectSrc = [] }: CspOptions = {}): string { if (!NONCE_RE.test(nonce)) throw new Error("csp nonce must be base64"); - const connect = new Set(["'self'", ...WALLET_CONNECT_SRC, ...BRIDGE_CONNECT_SRC, ...connectSrc.filter(isOrigin)]); + const connect = new Set(["'self'", ...WALLET_CONNECT_SRC, ...connectSrc.filter(isOrigin)]); if (dev) for (const s of ["ws:", "http://localhost:*", "http://127.0.0.1:*"]) connect.add(s); const directives = [ "default-src 'self'", @@ -83,7 +79,7 @@ export function buildCsp(nonce: string, { dev = false, connectSrc = [] }: CspOpt /** Origins the browser must reach besides the page itself: the configured site URL and dev RPC overrides. */ export function extraConnectOrigins(env: Record): string[] { const out = new Set(); - for (const key of ["NEXT_PUBLIC_SITE_URL", "NEXT_PUBLIC_RPC_URL_BASE", "NEXT_PUBLIC_RPC_URL_ROBINHOOD"]) { + for (const key of ["NEXT_PUBLIC_SITE_URL", "NEXT_PUBLIC_RPC_URL_BASE", "NEXT_PUBLIC_RPC_URL_ROBINHOOD", "NEXT_PUBLIC_RPC_URL_ARC"]) { const value = env[key]?.trim(); if (!value) continue; try { diff --git a/app/src/lib/wagmi.ts b/app/src/lib/wagmi.ts index d3e8182..0610185 100644 --- a/app/src/lib/wagmi.ts +++ b/app/src/lib/wagmi.ts @@ -2,17 +2,17 @@ import { createConfig, http } from "wagmi"; import { injected, coinbaseWallet } from "wagmi/connectors"; import { CHAINS, robinhood } from "./chainPublic"; import { browserRpc } from "./launchpad/config"; -import { arc } from "./bridge/chains"; +import { arc, arcBrowserRpc } from "./bridge/chains"; -// Launch chains use our read-only RPC proxy. Arc is registered for bridging -// only, using its official RPC; wallet transactions use the wallet's provider. +// Every chain reads through our read-only RPC proxy (or a dev override). Arc is +// registered for bridging only; wallet transactions use the wallet's provider. export const wagmiConfig = createConfig({ chains: [CHAINS.base, robinhood, arc], connectors: [injected(), coinbaseWallet({ appName: "openlaunch.lol", preference: { options: "all", telemetry: false } })], transports: { [CHAINS.base.id]: http(browserRpc("base"), { batch: true }), [robinhood.id]: http(browserRpc("robinhood"), { batch: true }), - [arc.id]: http(arc.rpcUrls.default.http[0], { batch: true }), + [arc.id]: http(arcBrowserRpc(), { batch: true }), }, ssr: true, }); diff --git a/docs/bridge.md b/docs/bridge.md index 3ecb84e..d072714 100644 --- a/docs/bridge.md +++ b/docs/bridge.md @@ -7,7 +7,7 @@ The header's **Bridge** action opens a focused funding panel without leaving the - Relay supplies quotes and transfer status. Openlaunch charges no application fee; Relay and source-network gas still cost money. - `POST /api/bridge/quote` accepts `{ address, originChainId, destinationChainId, originAsset, destinationAsset, amount }`. Asset choices are allowlisted `ETH` or `USDC` for that chain, never arbitrary addresses. Omitted asset fields retain legacy defaults: Base/Robinhood ETH, Arc USDC. The integer amount uses 18 decimals for ETH inputs and **6 decimals for USDC inputs**. Outputs use 6 decimals for Base USDC, 18 for ETH or Arc native USDC. `GET /api/bridge/status?requestId=…` returns normalized provider state and transaction hashes. - Base USDC is Circle's `0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913`. Its ERC-20 balance and allowance are checked separately from native ETH gas. An approval and deposit both require an ETH reserve; a USDC balance cannot pay Base gas. The picker, fee labels, quote identity and recovery journal all retain the selected asset. -- Requests use a fixed upstream, timeout and streamed body-size bounds, existing per-IP limits, and private/no-store caching. The API does not need a wallet signature. Relay quotes/status stay server-side. CSP permits only the pinned official `https://rpc.mainnet.arc.io` for Arc wallet balance, allowance, gas and receipt reads; script restrictions are unchanged. +- Requests use a fixed upstream, timeout and streamed body-size bounds, existing per-IP limits, and private/no-store caching. The API does not need a wallet signature. Relay quotes/status stay server-side. Arc balance, allowance, gas and receipt reads go through the same-origin `/api/rpc?chain=arc` proxy (upstream `ARC_RPC_URL`, else the official public node), so the CSP gains no third-party origin and script restrictions are unchanged. Public Base and Arc nodes rate-limit a single quote's burst of reads; the proxy turns rate-limit and malformed replies into HTTP 429/502 so viem retries them, and production should point `BASE_RPC_URL` and `ARC_RPC_URL` at keyed providers. - Arc's native USDC (18 decimals) and ERC-20 interface `0x3600000000000000000000000000000000000000` (6 decimals) share one balance but are **not interchangeable transfer interfaces**. Input parsing, deposit verification and recovery distinguish them. Gas budgeting converts the USDC input into native units and leaves a reserve for network fees. - `RELAY_API_KEY` is optional, server-only, and forwarded as `x-api-key` if configured. Read-only smoke tests passed without a key. Higher production traffic may require a Relay key or adjusted provider limits. Do not expose the key through a `NEXT_PUBLIC_` variable. - The server reconstructs the order hash using a narrow EVM/v1-only extraction of the MIT-licensed `@relay-protocol/settlement-sdk` 0.0.143 schema, normalizer and hashing algorithm, with the existing Viem dependency. This avoids shipping the SDK's unrelated multi-chain dependency subtree and its known vulnerabilities. The exact npm tarball, gitHead, upstream source SHA-256 hashes, MIT notice and 256 official-SDK compatibility vectors are recorded in [the provenance notice](../app/src/lib/bridge/relay-order.NOTICE.md); unsupported protocol versions and non-EVM orders fail closed. From ed258b71cf7cf6c785b5b5af6b4588da67087351 Mon Sep 17 00:00:00 2001 From: Kevin Codex Date: Wed, 16 Sep 2026 19:45:05 +0800 Subject: [PATCH 09/12] rpc proxy: answer disallowed methods in place instead of failing the batch viem probes eth_fillTransaction when estimating Base fees. The proxy replied HTTP 403 for the whole batch, so every read in it failed with "unknown RPC error" and viem never took its fallback path. Disallowed methods now get a JSON-RPC -32601 error in their own slot while the rest of the batch is forwarded; the allowlist itself is unchanged. --- app/src/app/api/rpc/route.ts | 28 +++++++++++++++++++--------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/app/src/app/api/rpc/route.ts b/app/src/app/api/rpc/route.ts index 64fae86..527bbc0 100644 --- a/app/src/app/api/rpc/route.ts +++ b/app/src/app/api/rpc/route.ts @@ -78,15 +78,22 @@ export async function POST(req: Request) { } catch { return NextResponse.json({ error: "bad json" }, { status: 400 }); } - const list = Array.isArray(body) ? body : [body]; + const batch = Array.isArray(body); + const list: Req[] = Array.isArray(body) ? body : [body]; if (list.length > 20) return NextResponse.json({ error: "batch too large" }, { status: 400 }); - for (const r of list) { - if (typeof r?.method !== "string" || !ALLOWED.has(r.method)) { - return NextResponse.json({ jsonrpc: "2.0", id: r?.id ?? null, error: { code: -32601, message: `method not allowed: ${String(r?.method)}` } }, { status: 403 }); - } - } + // A method outside the allowlist gets a JSON-RPC "method not found" in its + // slot, never an HTTP error for the whole batch: viem then falls back (it + // probes eth_fillTransaction for fee estimates) and the other reads succeed. + const denied = new Map(); + const forward: Req[] = []; + list.forEach((r, i) => { + if (typeof r?.method !== "string" || !ALLOWED.has(r.method)) denied.set(i, { jsonrpc: "2.0", id: r?.id ?? null, error: { code: -32601, message: `method not allowed: ${String(r?.method)}` } }); + else forward.push(r); + }); + const reply = (items: unknown[]) => NextResponse.json(batch ? items : items[0], { headers: { "cache-control": "no-store" } }); + if (forward.length === 0) return reply(list.map((_, i) => denied.get(i))); try { - const payload = JSON.stringify(body); + const payload = JSON.stringify(batch ? forward : forward[0]); const res = await fetch(upstream, { method: "POST", headers: { "content-type": "application/json" }, body: payload, signal: AbortSignal.timeout(15_000) }); let text = await res.text(); let status = res.status; @@ -101,8 +108,11 @@ export async function POST(req: Request) { } // Rate limiting and malformed bodies become 429/502 so viem retries with backoff // instead of surfacing "unknown RPC error" or throwing inside its batch scheduler. - status = upstreamStatus(text, Array.isArray(body), list.length, status); - return new NextResponse(text, { status, headers: { "content-type": "application/json", "cache-control": "no-store", ...(status === 429 ? { "retry-after": "1" } : {}) } }); + status = upstreamStatus(text, batch, forward.length, status); + if (status !== 200 || denied.size === 0) return new NextResponse(text, { status, headers: { "content-type": "application/json", "cache-control": "no-store", ...(status === 429 ? { "retry-after": "1" } : {}) } }); + const upstreamItems = JSON.parse(text) as unknown[]; // upstreamStatus verified an array of forward.length + let next = 0; + return reply(list.map((_, i) => denied.get(i) ?? upstreamItems[next++])); } catch (err) { return NextResponse.json({ error: err instanceof Error ? err.message : "upstream failed" }, { status: 502 }); } From 2eaa192c13797c223196079706045ded0a05f53f Mon Sep 17 00:00:00 2001 From: Kevin Codex Date: Wed, 16 Sep 2026 19:56:59 +0800 Subject: [PATCH 10/12] bridge: drop an unused export; record the wallet test and its findings otherBridgeChain had no callers. docs/bridge.md now records the maintainer's Base USDC to Arc transfer on 2026-09-16 and what the wallet test surfaced: Phantom cannot add Arc or Robinhood, the proxy's whole-batch 403 on eth_fillTransaction, public-node rate limits, and why publicnode is unsuitable as a proxy upstream. --- app/src/lib/bridge/types.ts | 2 -- docs/bridge.md | 8 +++++++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/app/src/lib/bridge/types.ts b/app/src/lib/bridge/types.ts index ed54c39..6cac6b5 100644 --- a/app/src/lib/bridge/types.ts +++ b/app/src/lib/bridge/types.ts @@ -84,5 +84,3 @@ export function isBridgeChainId(value: unknown): value is BridgeChainId { return value === 8453 || value === 4663 || value === 5042; } -/** Default destination only; users can select either of the other networks. */ -export const otherBridgeChain = (id: BridgeChainId): BridgeChainId => id === 8453 ? 4663 : 8453; diff --git a/docs/bridge.md b/docs/bridge.md index d072714..c21fc12 100644 --- a/docs/bridge.md +++ b/docs/bridge.md @@ -36,7 +36,7 @@ An interrupted wallet response is **uncertain**, not failed. The app must not si - A sped-up or cancelled-and-replaced deposit is adopted automatically: when the wallet's hash is unmined and Relay reports a different source hash, the app verifies that hash is this wallet's exact deposit for the same order before tracking it. - A record with no deposit anywhere can be discarded by the user after 15 minutes, and only while Relay still reports `waiting` with no transaction hashes and the source chain has no receipt for the wallet's hash, checked within the last minute. The same wait applies to an unmined approval, which if mined later only grants the exact allowance the next deposit re-reads. Any on-chain evidence keeps the record until it settles. Unknown broadcasts and replaced transactions that Relay cannot match still need manual investigation via Relay and the source explorer. - Quote validity travels as a remaining duration (`ttlMs`) and is anchored on the browser's own clock at request time, so device clock skew cannot expire or extend a quote. -- This implementation has read-only live quote verification and mocked execution tests, **not a real-money end-to-end transfer test**. Before production rollout, an authorized maintainer should use a small amount to verify all supported directions, Arc approval and rejection, a normal delivery, and refresh recovery with their own wallet. Do not describe the feature as independently audited or risk-free. +- Real-money coverage so far: on 2026-09-16 a maintainer completed Base USDC → Arc with their own wallet (exact 9 USDC approval, fresh quote, deposit, delivery). Other directions, a deliberate wallet rejection, and refresh recovery still need the same small-amount check before this is described as fully exercised. Do not describe the feature as independently audited or risk-free. ## Development and verification @@ -51,6 +51,12 @@ These tests never connect a wallet or submit a transaction. `/ui-review-bridge` is a development-only visual fixture with disconnected, quote, expired, error, pending, success, uncertain and refund states, plus pending/uncertain/confirmed approval states. Its synthetic data cannot execute a transaction, and the route returns 404 outside development. The real header action uses the actual integration. +### Wallet test findings (2026-09-16) + +- 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. + ### Base USDC extension verification (2026-09-16) - All ten supported directed asset routes passed live unsigned Relay quote/status checks: the six original routes plus Base USDC in both directions with Arc USDC and Robinhood ETH. From f5c940047332eb55dd52fe75db5e57c563a50e55 Mon Sep 17 00:00:00 2001 From: Vasanth T Date: Wed, 16 Sep 2026 17:33:01 +0530 Subject: [PATCH 11/12] fix(rpc): keep malformed batch errors retryable Viem accepts JSON-RPC error envelopes even on HTTP failures, so a single upstream error object can still crash a batched gas preflight. Return non-RPC HTTP errors for retryable failures and reject mismatched response IDs or malformed envelopes. Preserve Kevin's Arc proxy, method denials, recovery flow, and actionable error copy. Cover real viem batching and retry behavior, keep bridge alerts within the panel, and leave unrelated local changes out. Verified 627 unit tests, 10 read-only live routes, Base/Arc approval preflights, lint, typecheck, and production build. --- app/src/app/api/rpc/route.test.ts | 213 ++++++++++++++++++ app/src/app/api/rpc/route.ts | 17 +- .../components/bridge/BridgeDialog.module.css | 3 +- app/src/components/bridge/bridge-ui.test.ts | 6 + app/src/lib/rpc-proxy.test.ts | 17 ++ app/src/lib/rpc-proxy.ts | 17 +- 6 files changed, 267 insertions(+), 6 deletions(-) create mode 100644 app/src/app/api/rpc/route.test.ts diff --git a/app/src/app/api/rpc/route.test.ts b/app/src/app/api/rpc/route.test.ts new file mode 100644 index 0000000..4a50b67 --- /dev/null +++ b/app/src/app/api/rpc/route.test.ts @@ -0,0 +1,213 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createPublicClient, encodeFunctionData, HttpRequestError, http, parseAbi } from "viem"; +import { base } from "viem/chains"; +import { POST } from "./route.ts"; + +type RpcCall = { jsonrpc: string; id: number | string; method: string; params?: unknown[] }; +const call = (id: number | string, method: string): RpcCall => ({ jsonrpc: "2.0", id, method, params: [] }); +const result = (id: number | string, value = "0x2105") => ({ jsonrpc: "2.0", id, result: value }); +const batchError = { jsonrpc: "2.0", id: null, error: { code: -32000, message: "batch unsupported: private upstream diagnostic" } }; +const approval = { + account: "0x1111111111111111111111111111111111111111" as const, + to: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" as const, + value: 0n, + data: encodeFunctionData({ abi: parseAbi(["function approve(address spender, uint256 amount) returns (bool)"]), functionName: "approve", args: ["0x4cd00e387622c35bddb9b4c962c136462338bc31", 1_000_000n] }), +}; +let sequence = 0; +function request(body: unknown, chain = "base") { + return new Request(`http://localhost/api/rpc?chain=${chain}`, { + method: "POST", headers: { "content-type": "application/json", "x-forwarded-for": `rpc-test-${++sequence}` }, body: JSON.stringify(body), + }); +} +function upstreamBody(init?: RequestInit): RpcCall[] { + const body = JSON.parse(String(init?.body)); + return Array.isArray(body) ? body : [body]; +} + +test("mixed batches reject optional/write methods without poisoning allowed reads", async (t) => { + const forwarded: RpcCall[][] = []; + t.mock.method(globalThis, "fetch", async (_input: unknown, init?: RequestInit) => { + const calls = upstreamBody(init); + forwarded.push(calls); + return Response.json(calls.map((r) => result(r.id)).reverse()); + }); + const response = await POST(request([call(1, "eth_chainId"), call(2, "eth_fillTransaction"), call(3, "eth_estimateGas"), call(4, "eth_sendRawTransaction")])); + assert.equal(response.status, 200); + assert.equal(response.headers.get("cache-control"), "no-store"); + const body = await response.json(); + assert.equal(body.length, 4); + assert.deepEqual(forwarded[0].map((r) => r.method), ["eth_chainId", "eth_estimateGas"]); + for (const id of [1, 3]) assert.deepEqual(body.find((r: { id: number }) => r.id === id), result(id)); + for (const id of [2, 4]) assert.equal(body.find((r: { id: number }) => r.id === id).error.code, -32601); +}); + +test("actual viem batches the unsupported probe with a one-USDC approval gas estimate", async (t) => { + const batches: RpcCall[][] = []; + t.mock.method(globalThis, "fetch", async (_input: unknown, init?: RequestInit) => Response.json( + upstreamBody(init).map((r) => result(r.id, r.method === "eth_chainId" ? "0x2105" : "0x10000")).reverse(), + )); + const transport = http("http://localhost/api/rpc?chain=base", { + batch: true, retryCount: 0, fetchFn: (input, init) => { + batches.push(upstreamBody(init)); + return POST(new Request(input, init)); + }, + }); + const client = createPublicClient({ chain: base, transport }); + const probe = transport({ chain: base, retryCount: 0 }).request({ method: "eth_fillTransaction", params: [{}] }); + const results = await Promise.allSettled([probe, client.getChainId(), client.estimateGas(approval)]); + assert.equal(batches.length, 1); + assert.deepEqual(batches[0].map((r) => r.method).sort(), ["eth_chainId", "eth_estimateGas", "eth_fillTransaction"]); + assert.equal(results[0].status, "rejected"); + if (results[0].status === "rejected") { + assert.equal(results[0].reason.code, -32601); + assert.doesNotMatch(results[0].reason.message, /undefined|Cannot read properties/); + } + assert.deepEqual(results[1], { status: "fulfilled", value: 8453 }); + assert.deepEqual(results[2], { status: "fulfilled", value: 65536n }); +}); + +test("a malformed top-level batch error becomes an HTTP error and viem retries successfully", async (t) => { + let attempts = 0; + const statuses: number[] = []; + t.mock.method(globalThis, "fetch", async (_input: unknown, init?: RequestInit) => { + attempts++; + if (attempts === 1) return Response.json(batchError); + return Response.json(upstreamBody(init).map((r) => result(r.id, "0x10000"))); + }); + const client = createPublicClient({ chain: base, transport: http("http://localhost/api/rpc?chain=base", { + batch: true, retryCount: 1, retryDelay: 0, + fetchFn: async (input, init) => { + const response = await POST(new Request(input, init)); + statuses.push(response.status); + if (response.status !== 200) { + const body = await response.clone().json(); + assert.equal(typeof body.error, "string"); + assert.equal(body.jsonrpc, undefined); + assert.doesNotMatch(JSON.stringify(body), /private upstream diagnostic/); + } + return response; + }, + }) }); + assert.equal(await client.estimateGas(approval), 65536n); + assert.equal(attempts, 2); + assert.deepEqual(statuses, [502, 200]); +}); + +test("persistent malformed batch errors yield HTTP errors, never viem's undefined-error crash", async (t) => { + t.mock.method(globalThis, "fetch", async () => Response.json(batchError)); + const client = createPublicClient({ chain: base, transport: http("http://localhost/api/rpc?chain=base", { + batch: true, retryCount: 0, fetchFn: (input, init) => POST(new Request(input, init)), + }) }); + const outcomes = await Promise.allSettled([client.getChainId(), client.estimateGas(approval)]); + for (const outcome of outcomes) { + assert.equal(outcome.status, "rejected"); + if (outcome.status !== "rejected") continue; + const cause = outcome.reason.walk((error: unknown) => error instanceof HttpRequestError); + assert.ok(cause instanceof HttpRequestError); + assert.equal(cause.status, 502); + assert.doesNotMatch(outcome.reason.message, /Cannot read properties|reading 'error'|private upstream diagnostic/); + } +}); + +test("all-denied requests never reach the upstream and retain Kevin's normal JSON-RPC response", async (t) => { + const upstream = t.mock.method(globalThis, "fetch", async () => { throw new Error("must not forward"); }); + const response = await POST(request([call(1, "eth_sendTransaction"), call(2, "personal_sign")])); + assert.equal(response.status, 200); + assert.deepEqual((await response.json()).map((r: { id: number; error: { code: number } }) => [r.id, r.error.code]), [[1, -32601], [2, -32601]]); + const single = await POST(request(call(3, "eth_fillTransaction"))); + assert.equal(single.status, 200); + assert.equal((await single.json()).error.code, -32601); + assert.equal(upstream.mock.callCount(), 0); +}); + +test("out-of-order valid responses retain each request's ID and value", async (t) => { + const replies = [result("second", "0x2"), result("first", "0x1")]; + t.mock.method(globalThis, "fetch", async () => Response.json(replies)); + const response = await POST(request([call("first", "eth_chainId"), call("second", "eth_blockNumber")])); + assert.equal(response.status, 200); + assert.deepEqual(await response.json(), replies); +}); + +test("wrong, duplicate, missing and malformed response items fail closed with non-RPC 502 bodies", async (t) => { + for (const value of [ + batchError, [], [result(2)], [result(1), result(99)], [result(1), result(1)], + [{ jsonrpc: "2.0", id: 1 }, result(2)], + [{ ...result(1), error: { code: -32603, message: "contradictory" } }, result(2)], + [{ jsonrpc: "2.0", id: 1, error: { code: "-32603", message: "wrong type" } }, result(2)], + [{ ...result(1), jsonrpc: "1.0" }, result(2)], + ]) { + const upstream = t.mock.method(globalThis, "fetch", async () => Response.json(value)); + const response = await POST(request([call(1, "eth_estimateGas"), call(2, "eth_getBalance")])); + assert.equal(response.status, 502); + const body = await response.json(); + assert.equal(typeof body.error, "string"); + assert.equal(body.jsonrpc, undefined); + assert.doesNotMatch(JSON.stringify(body), /private upstream diagnostic/); + upstream.mock.restore(); + } +}); + +test("upstream 429 and non-200 JSON-RPC objects keep retryable HTTP semantics", async (t) => { + for (const status of [429, 502, 503]) { + const upstream = t.mock.method(globalThis, "fetch", async () => Response.json(batchError, { status })); + const response = await POST(request([call(1, "eth_chainId"), call(2, "eth_fillTransaction")])); + assert.equal(response.status, status); + assert.equal(typeof (await response.json()).error, "string"); + if (status === 429) assert.equal(response.headers.get("retry-after"), "1"); + upstream.mock.restore(); + } + t.mock.method(globalThis, "fetch", async () => Response.json([{ jsonrpc: "2.0", id: 1, error: { code: -32016, message: "over rate limit" } }])); + const limited = await POST(request([call(1, "eth_chainId")])); + assert.equal(limited.status, 429); + assert.equal(limited.headers.get("retry-after"), "1"); + assert.equal(typeof (await limited.json()).error, "string"); +}); + +test("upstream fetch failures remain retryable non-RPC HTTP errors", async (t) => { + t.mock.method(globalThis, "fetch", async () => { throw new Error("RPC unavailable"); }); + const response = await POST(request([call(1, "eth_chainId"), call(2, "eth_fillTransaction")])); + assert.equal(response.status, 502); + const body = await response.json(); + assert.equal(typeof body.error, "string"); + assert.equal(body.jsonrpc, undefined); +}); + +test("Base B20 fallback still receives only allowed reads", async (t) => { + const forwarded: RpcCall[][] = []; + t.mock.method(globalThis, "fetch", async (_input: unknown, init?: RequestInit) => { + const calls = upstreamBody(init); + forwarded.push(calls); + return Response.json(forwarded.length === 1 ? [{ jsonrpc: "2.0", id: 1, error: { code: -32603, message: "EVM error OpcodeNotFound" } }] : [result(1, "0x42")]); + }); + const response = await POST(request([call(1, "eth_call"), call(2, "eth_fillTransaction")])); + assert.equal(forwarded.length, 2); + for (const calls of forwarded) assert.deepEqual(calls.map((r) => r.method), ["eth_call"]); + assert.deepEqual((await response.json()).find((r: { id: number }) => r.id === 1), result(1, "0x42")); +}); + +test("Arc routing uses its configured server upstream with the same read-only allowlist", async (t) => { + const previous = process.env.ARC_RPC_URL; + process.env.ARC_RPC_URL = "https://arc-rpc.example.test/private-test-key"; + t.after(() => { if (previous === undefined) delete process.env.ARC_RPC_URL; else process.env.ARC_RPC_URL = previous; }); + t.mock.method(globalThis, "fetch", async (input: unknown, init?: RequestInit) => { + assert.equal(input, process.env.ARC_RPC_URL); + const calls = upstreamBody(init); + assert.deepEqual(calls.map((r) => r.method), ["eth_chainId"]); + return Response.json(calls.map((r) => result(r.id, "0x13b2"))); + }); + const response = await POST(request([call(1, "eth_chainId"), call(2, "eth_sendRawTransaction")], "arc")); + assert.equal(response.status, 200); + const body = await response.json(); + assert.deepEqual(body[0], result(1, "0x13b2")); + assert.equal(body[1].error.code, -32601); +}); + +test("single allowed reads keep their response shape; empty and oversized batches are rejected", async (t) => { + const upstream = t.mock.method(globalThis, "fetch", async () => Response.json(result(1))); + const single = await POST(request(call(1, "eth_chainId"))); + assert.deepEqual(await single.json(), result(1)); + assert.equal((await POST(request([]))).status, 400); + assert.equal((await POST(request(Array.from({ length: 21 }, (_, id) => call(id, "eth_chainId"))))).status, 400); + assert.equal(upstream.mock.callCount(), 1); +}); diff --git a/app/src/app/api/rpc/route.ts b/app/src/app/api/rpc/route.ts index 527bbc0..3afcce2 100644 --- a/app/src/app/api/rpc/route.ts +++ b/app/src/app/api/rpc/route.ts @@ -80,6 +80,7 @@ export async function POST(req: Request) { } const batch = Array.isArray(body); const list: Req[] = Array.isArray(body) ? body : [body]; + if (list.length === 0) return NextResponse.json({ error: "empty batch" }, { status: 400 }); if (list.length > 20) return NextResponse.json({ error: "batch too large" }, { status: 400 }); // A method outside the allowlist gets a JSON-RPC "method not found" in its // slot, never an HTTP error for the whole batch: viem then falls back (it @@ -108,12 +109,20 @@ export async function POST(req: Request) { } // Rate limiting and malformed bodies become 429/502 so viem retries with backoff // instead of surfacing "unknown RPC error" or throwing inside its batch scheduler. - status = upstreamStatus(text, batch, forward.length, status); - if (status !== 200 || denied.size === 0) return new NextResponse(text, { status, headers: { "content-type": "application/json", "cache-control": "no-store", ...(status === 429 ? { "retry-after": "1" } : {}) } }); + status = upstreamStatus(text, batch, forward.length, status, forward.map((r) => r.id)); + if (status !== 200) { + // viem accepts a JSON-RPC error envelope even on HTTP 429/502. Returning + // an upstream single error object for a batch would still crash its + // scheduler. A non-RPC body forces the transport's HTTP retry path. + return NextResponse.json({ error: status === 429 ? "The network is busy. Try again in a moment." : "The network returned an unavailable or invalid RPC response. Try again." }, { + status, headers: { "cache-control": "no-store", ...(status === 429 ? { "retry-after": "1" } : {}) }, + }); + } + if (denied.size === 0) return new NextResponse(text, { status, headers: { "content-type": "application/json", "cache-control": "no-store" } }); const upstreamItems = JSON.parse(text) as unknown[]; // upstreamStatus verified an array of forward.length let next = 0; return reply(list.map((_, i) => denied.get(i) ?? upstreamItems[next++])); - } catch (err) { - return NextResponse.json({ error: err instanceof Error ? err.message : "upstream failed" }, { status: 502 }); + } catch { + return NextResponse.json({ error: "The network RPC is temporarily unavailable. Try again." }, { status: 502, headers: { "cache-control": "no-store" } }); } } diff --git a/app/src/components/bridge/BridgeDialog.module.css b/app/src/components/bridge/BridgeDialog.module.css index d7eb92b..8e2356e 100644 --- a/app/src/components/bridge/BridgeDialog.module.css +++ b/app/src/components/bridge/BridgeDialog.module.css @@ -89,7 +89,8 @@ .disclaimer { margin-top: 12px; color: var(--color-muted); font-size: 11px; line-height: 1.65; } .footer { display: flex; justify-content: space-between; gap: 12px; padding-top: 16px; margin-top: 20px; border-top: 1px solid var(--color-line); font-size: 11px; color: var(--color-muted); } .footer a, .explorerLinks a { display: inline-flex; align-items: center; gap: 3px; text-underline-offset: 3px; } -.error { display: flex; align-items: flex-start; gap: 8px; margin: 12px 0; padding: 12px; color: var(--color-down-ink); background: var(--color-down-soft); border-radius: 8px; font-size: 12px; line-height: 1.6; } +.error { display: flex; align-items: flex-start; gap: 8px; min-width: 0; margin: 12px 0; padding: 12px; color: var(--color-down-ink); background: var(--color-down-soft); border-radius: 8px; font-size: 12px; line-height: 1.6; overflow-wrap: anywhere; } +.error > span { flex: 1; min-width: 0; } .error > svg { flex-shrink: 0; margin-top: 2px; } .inlineButton { display: block; margin-top: 6px; text-decoration: underline; text-underline-offset: 3px; cursor: pointer; } .transferRoute { display: flex; justify-content: center; align-items: center; flex-wrap: wrap; gap: 9px; padding: 16px 0; border-block: 1px solid var(--color-line); font-size: 13px; font-weight: 500; } diff --git a/app/src/components/bridge/bridge-ui.test.ts b/app/src/components/bridge/bridge-ui.test.ts index 8c43e5e..b3f8278 100644 --- a/app/src/components/bridge/bridge-ui.test.ts +++ b/app/src/components/bridge/bridge-ui.test.ts @@ -48,6 +48,12 @@ test("bridge dialog has accessible focus, mobile layout and reduced motion", () assert.match(css, /:focus-visible/); }); +test("bridge alerts wrap long provider reasons without discarding actionable errors", () => { + assert.match(css, /\.error \{[^}]*min-width: 0[^}]*overflow-wrap: anywhere/); + assert.match(css, /\.error > span \{[^}]*min-width: 0/); + assert.match(read("./useBridge.ts"), /const messageOf = bridgeErrorMessage;/); +}); + test("both quote and transfer recipients expose the full address without hover", () => { assert.match(panel, /
[\s\S]*[\s\S]*\{address\}<\/code>/); assert.match(panel, //); diff --git a/app/src/lib/rpc-proxy.test.ts b/app/src/lib/rpc-proxy.test.ts index 04dda79..8e131a6 100644 --- a/app/src/lib/rpc-proxy.test.ts +++ b/app/src/lib/rpc-proxy.test.ts @@ -23,3 +23,20 @@ test("upstream replies clients cannot use become retryable statuses", () => { // non-200 statuses are passed through untouched for (const status of [400, 403, 429, 500, 503]) assert.equal(upstreamStatus("{}", false, 1, status), status); }); + +test("reply IDs and envelopes must match the forwarded requests", () => { + assert.equal(upstreamStatus(JSON.stringify([ok(2), ok(1)]), true, 2, 200, [1, 2]), 200); + assert.equal(upstreamStatus(JSON.stringify([ok(1), ok(1)]), true, 2, 200, [1, 2]), 502); + assert.equal(upstreamStatus(JSON.stringify([ok(1), ok(3)]), true, 2, 200, [1, 2]), 502); + assert.equal(upstreamStatus(JSON.stringify(ok(2)), false, 1, 200, [1]), 502); + assert.equal(upstreamStatus(JSON.stringify([{ ...ok(1), id: "1" }]), true, 1, 200, [1]), 502); + for (const value of [ + { jsonrpc: "2.0", id: 1 }, + { ...ok(1), error: { code: -32603, message: "contradictory" } }, + { jsonrpc: "2.0", id: 1, error: { code: "bad", message: "malformed" } }, + { id: 1, result: "0x1" }, + ]) assert.equal(upstreamStatus(JSON.stringify([value]), true, 1, 200, [1]), 502); + // Valid null results (e.g. an unmined receipt) and execution errors survive. + assert.equal(upstreamStatus(JSON.stringify([{ ...ok(1), result: null }]), true, 1, 200, [1]), 200); + assert.equal(upstreamStatus(JSON.stringify([err(1, 3, "execution reverted")]), true, 1, 200, [1]), 200); +}); diff --git a/app/src/lib/rpc-proxy.ts b/app/src/lib/rpc-proxy.ts index fe1c129..c12a703 100644 --- a/app/src/lib/rpc-proxy.ts +++ b/app/src/lib/rpc-proxy.ts @@ -7,7 +7,7 @@ const RATE_LIMITED = /rate limit|too many requests|exceeded (?:the )?(?:quota|capacity|throughput)/i; const RATE_LIMIT_CODES = new Set([-32005, -32016, 429]); -export function upstreamStatus(text: string, batch: boolean, expected: number, status: number): number { +export function upstreamStatus(text: string, batch: boolean, expected: number, status: number, expectedIds?: readonly unknown[]): number { if (status !== 200) return status; let parsed: unknown; try { parsed = JSON.parse(text); } catch { return 502; } @@ -17,5 +17,20 @@ export function upstreamStatus(text: string, batch: boolean, expected: number, s if (items.some((item) => !item || typeof item !== "object")) return 502; const errors = items.map((item) => (item as { error?: { code?: unknown; message?: unknown } }).error).filter((error): error is { code?: unknown; message?: unknown } => !!error && typeof error === "object"); if (errors.some((error) => (typeof error.code === "number" && RATE_LIMIT_CODES.has(error.code)) || RATE_LIMITED.test(String(error.message ?? "")))) return 429; + // Length alone is not enough: duplicate/missing IDs can assign a balance or + // gas result to the wrong call in a client's batch scheduler. + if (items.some((item) => { + const response = item as Record; + const hasResult = Object.hasOwn(response, "result"); + const hasError = Object.hasOwn(response, "error"); + const error = response.error as { code?: unknown; message?: unknown } | null; + return response.jsonrpc !== "2.0" || hasResult === hasError || + (hasError && (!error || typeof error.code !== "number" || typeof error.message !== "string")); + })) return 502; + if (expectedIds) { + const ids = items.map((item) => (item as { id?: unknown }).id); + if (expectedIds.length !== items.length || new Set(ids).size !== ids.length || + expectedIds.some((id) => !ids.includes(id))) return 502; + } return 200; } From e6e5e2cadc216554f7ead9f9a8b1985830fe6e50 Mon Sep 17 00:00:00 2001 From: Kevin Codex Date: Wed, 16 Sep 2026 20:11:49 +0800 Subject: [PATCH 12/12] brand: drop the assets README; each SVG carries its own source comment The note was served publicly from /brand and referenced by nothing. The provenance it recorded already lives in a Source comment inside every SVG, which the UI tests assert. --- app/public/brand/README.md | 11 ----------- 1 file changed, 11 deletions(-) delete mode 100644 app/public/brand/README.md diff --git a/app/public/brand/README.md b/app/public/brand/README.md deleted file mode 100644 index 90dd68f..0000000 --- a/app/public/brand/README.md +++ /dev/null @@ -1,11 +0,0 @@ -# Official bridge identity assets - -Retrieved 2026-09-16. Marks retain their upstream shapes and colours; only source comments were added. They identify the token/network, not endorsement of Openlaunch. Trademark rights remain with their respective owners. - -- `base.svg`: [Base brand pack](https://brand.base.org/base-brand.zip), `1_Base Brand Assets/The Square/Base_square_blue.svg`. [Current guidance](https://brand.base.org/core-identifiers). -- `robinhood-black.svg` / `robinhood-white.svg`: official Chain docs [black feather](https://cdn.robinhood.com/assets/generated_assets/hoodchain_docsite/feather-dark.svg) / [white feather](https://cdn.robinhood.com/assets/generated_assets/hoodchain_docsite/feather-light.svg). [Usage guidance](https://docs.robinhood.com/chain/brand-guidelines/). Use the black mark on light surfaces, white on dark, and keep its height at least 20px. -- `ethereum.svg`: [Ethereum's purple diamond](https://ethereum.org/images/assets/svgs/eth-diamond-purple.svg), linked from the [official asset library](https://ethereum.org/assets). -- `arc.svg`: [official Arc icon](https://cdn.prod.website-files.com/685311a976e7c248b5dfde95/699e21e934a48439675361dc_arc-icon.svg), linked by [Arc](https://www.arc.io/). The white mark uses a dark backing; no recolouring. -- `usdc.svg`: `Token Logo/USDC Token.svg` from [Circle's official USDC brand archive](https://6778953.fs1.hubspotusercontent-na1.net/hubfs/6778953/Pressroom/brandkit/logo-downloads/usdc.zip), linked by [Circle's pressroom](https://www.circle.com/pressroom). - -These SVGs are served locally; no third-party image requests or CSS colour filters are needed. Do not replace official marks with letter badges or redraw their geometry.