diff --git a/app/.env.example b/app/.env.example index 5e0af6ec..1694a488 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/public/brand/arc.svg b/app/public/brand/arc.svg new file mode 100644 index 00000000..6612c097 --- /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 00000000..27302762 --- /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 00000000..fcdd5833 --- /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 00000000..01dcc0e2 --- /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 00000000..e8a576a5 --- /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 00000000..3cc54a92 --- /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 00000000..a004575a --- /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 00000000..118caaae --- /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/api/rpc/route.test.ts b/app/src/app/api/rpc/route.test.ts new file mode 100644 index 00000000..4a50b677 --- /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 da3ca43e..3afcce23 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" } }); @@ -73,15 +78,23 @@ 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 === 0) return NextResponse.json({ error: "empty batch" }, { status: 400 }); 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; @@ -94,8 +107,22 @@ export async function POST(req: Request) { status = alt.status; } } - return new NextResponse(text, { status, headers: { "content-type": "application/json", "cache-control": "no-store" } }); - } catch (err) { - return NextResponse.json({ error: err instanceof Error ? err.message : "upstream failed" }, { status: 502 }); + // 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, 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 { + return NextResponse.json({ error: "The network RPC is temporarily unavailable. Try again." }, { status: 502, headers: { "cache-control": "no-store" } }); } } 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 00000000..e0892f31 --- /dev/null +++ b/app/src/app/ui-review-bridge/BridgeReview.tsx @@ -0,0 +1,80 @@ +"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, 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() } } : {}), + }; + 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"); }, + approvalCanBeDiscarded: scene === "approval_uncertain", discardApproval: async () => { setApproved(false); setScene("idle"); }, + canDiscard: scene === "uncertain", discard: async () => setScene("idle"), discarding: false, + }; + 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 00000000..631c9a80 --- /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 8c6cb8aa..6b07a44a 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.module.css b/app/src/components/WalletMenu.module.css index 69b51e4b..41c23f38 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 b8eccd78..f7101708 100644 --- a/app/src/components/WalletMenu.tsx +++ b/app/src/components/WalletMenu.tsx @@ -1,10 +1,12 @@ "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"; 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 +36,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 +78,11 @@ function AccountMenu({ address, chainId, connectorName, block = false, switching {shortAddr(address)} - {key ? CHAIN_SHORT[key] : "Switch"} + {key || bridgeNetwork ? network : "Switch"} @@ -112,25 +116,30 @@ 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 00000000..8e2356e2 --- /dev/null +++ b/app/src/components/bridge/BridgeDialog.module.css @@ -0,0 +1,136 @@ +.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; 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; } +.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 00000000..df9a6ea3 --- /dev/null +++ b/app/src/components/bridge/BridgeDialog.tsx @@ -0,0 +1,306 @@ +"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 { DISCARD_AFTER_MS } from "@/lib/bridge/client"; +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 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."}

+ + ); +} + +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} + {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.

+
; +} + +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} + {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/BridgeProvider.tsx b/app/src/components/bridge/BridgeProvider.tsx new file mode 100644 index 00000000..9f110bc7 --- /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/bridge-ui.test.ts b/app/src/components/bridge/bridge-ui.test.ts new file mode 100644 index 00000000..b3f8278b --- /dev/null +++ b/app/src/components/bridge/bridge-ui.test.ts @@ -0,0 +1,142 @@ +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("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, //); + 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\)/); +}); + +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 \?
/); + assert.match(panel, /b\.approvalCanBeDiscarded \?
/); + 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)/); + 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, /const messageOf = bridgeErrorMessage;/); +}); diff --git a/app/src/components/bridge/useBridge.ts b/app/src/components/bridge/useBridge.ts new file mode 100644 index 00000000..89463282 --- /dev/null +++ b/app/src/components/bridge/useBridge.ts @@ -0,0 +1,651 @@ +"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 { 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"; + +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 = bridgeErrorMessage; +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: "", 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 [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); + 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. + // 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; + 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); + 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); }); + 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: 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) => { + 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; + // 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 })); + 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); + setObservation({ requestId: current.requestId, status, sourceMined: receipt.status === "rejected" || (receipt.status === "fulfilled" && receipt.value !== null), observedAt: Date.now() }); + } + 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: linkedTimeoutSignal(abort.signal, 20_000), + }); + 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 }); + } 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 (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.", BRIDGE_CHAINS[reviewed.originChainId].symbol) }); + } 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.", BRIDGE_CHAINS[reviewed.originChainId].symbol) }); + } 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); } + } + + // 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); + + 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; + 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 || 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 (error) { + setStatusIssue({ key: requestId, message: messageOf(error, "Could not verify the transfer before discarding it. Try again.") }); + } finally { + setDiscarding(false); + } + } + + async function discardApproval() { + if (actionLock.current || approvalSending || discarding || !address || !approval || !approvalCanBeDiscarded || !navigator.locks) return; + const createdAt = approval.createdAt; + 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 || !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 (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), []); + 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, approvalCanBeDiscarded, discardApproval, + 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)), + }; +} + +export default useBridge; diff --git a/app/src/components/launchpad/launch-machine.test.ts b/app/src/components/launchpad/launch-machine.test.ts index eb9fc787..91f23926 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 eb8ae6d9..db66841b 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\) =>/); @@ -54,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\./); diff --git a/app/src/components/wallet-picker.test.ts b/app/src/components/wallet-picker.test.ts index e37fc772..c22d8c3f 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); +}); + +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 new file mode 100644 index 00000000..9f31d308 --- /dev/null +++ b/app/src/lib/bridge/approval.ts @@ -0,0 +1,291 @@ +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 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"); +} + +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 00000000..d9878c73 --- /dev/null +++ b/app/src/lib/bridge/chains.ts @@ -0,0 +1,28 @@ +import { defineChain, type Chain } from "viem"; +import { CHAINS, SITE_URL } 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, +}; + +/** + * 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 new file mode 100644 index 00000000..ecffb48a --- /dev/null +++ b/app/src/lib/bridge/client-chains.test.ts @@ -0,0 +1,41 @@ +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\(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/); +}); + +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 00000000..41f07424 --- /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 00000000..bae93d7d --- /dev/null +++ b/app/src/lib/bridge/client.test.ts @@ -0,0 +1,595 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +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"; + +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, ttlMs: 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); +}); + +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); + } +}); + +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.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."); + 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 new file mode 100644 index 00000000..a8713d1c --- /dev/null +++ b/app/src/lib/bridge/client.ts @@ -0,0 +1,401 @@ +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"; +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; +} + +/** + * 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; + 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 }; +} + +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."; + 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}`; + } + return error instanceof Error ? error.message : fallback; +} + +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 00000000..c042792a --- /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 00000000..c831fb61 --- /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 00000000..13107d61 --- /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 00000000..4df05d3f --- /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 00000000..dff36439 --- /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 00000000..9cbed244 --- /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 00000000..a99caa7c --- /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 00000000..d3ad811a --- /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 00000000..00b40884 --- /dev/null +++ b/app/src/lib/bridge/relay.test.ts @@ -0,0 +1,395 @@ +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); + } +}); + +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 new file mode 100644 index 00000000..5d0ded51 --- /dev/null +++ b/app/src/lib/bridge/relay.ts @@ -0,0 +1,105 @@ +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); + } +} + +// 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([ + 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, + destinationCurrency: bridgeCurrency(input.destinationChainId, input.destinationAsset, "output").address, amount: input.amount, tradeType: "EXACT_INPUT", + explicitDeposit: true, includeProtocolData: true, slippageTolerance: "50", + }) }, 128_000, fetcher), + ]); + try { + 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."); + } +} + +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 00000000..ce21381f --- /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 00000000..6cac6b53 --- /dev/null +++ b/app/src/lib/bridge/types.ts @@ -0,0 +1,86 @@ +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 + ttlMs: number; // remaining validity when issued; the browser anchors this on its own clock + 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; +} + diff --git a/app/src/lib/bridge/validation.test.ts b/app/src/lib/bridge/validation.test.ts new file mode 100644 index 00000000..2c2b029a --- /dev/null +++ b/app/src/lib/bridge/validation.test.ts @@ -0,0 +1,103 @@ +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.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 }); +}); + +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 00000000..b1b7f71c --- /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, ttlMs: expiresAt - now, + 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/rpc-proxy.test.ts b/app/src/lib/rpc-proxy.test.ts new file mode 100644 index 00000000..8e131a6f --- /dev/null +++ b/app/src/lib/rpc-proxy.test.ts @@ -0,0 +1,42 @@ +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); +}); + +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 new file mode 100644 index 00000000..c12a703a --- /dev/null +++ b/app/src/lib/rpc-proxy.ts @@ -0,0 +1,36 @@ +/** + * 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, expectedIds?: readonly unknown[]): 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; + // 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; +} diff --git a/app/src/lib/security-headers.test.ts b/app/src/lib/security-headers.test.ts index 6df1fe30..3d938ab4 100644 --- a/app/src/lib/security-headers.test.ts +++ b/app/src/lib/security-headers.test.ts @@ -35,10 +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}`); - assert.ok(connect.includes(" http://127.0.0.1:8545")); - assert.ok(connect.includes(" https://openlaunch.lol")); + for (const origin of WALLET_CONNECT_SRC) assert.ok(connectSources.has(origin), `missing ${origin}`); + 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")); 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 2c2d0e94..18bfbfbc 100644 --- a/app/src/lib/security-headers.ts +++ b/app/src/lib/security-headers.ts @@ -79,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 0bf5ac04..0610185d 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, arcBrowserRpc } 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. +// 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], + 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(arcBrowserRpc(), { batch: true }), }, ssr: true, }); diff --git a/docs/bridge.md b/docs/bridge.md new file mode 100644 index 00000000..c21fc12b --- /dev/null +++ b/docs/bridge.md @@ -0,0 +1,98 @@ +# 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. 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. + +## 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. +- 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. +- 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 + +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. + +### 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. +- 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/)