From 8d57664bde9479c98692dc69a89aa2b0bca855cf Mon Sep 17 00:00:00 2001 From: Vasanth T Date: Tue, 10 Nov 2026 16:21:57 +0530 Subject: [PATCH 01/17] fix(bridge): accept Relay's v3.2 router so quotes verify again Relay made its v3.2 router the default on 2026-10-26: Get Chains reports it as contracts.erc20Router and every order names it in extraData. Only the v3 router was pinned, so since then every quote has been refused as unverifiable and the bridge could not be used. Nothing was sent; the check fails closed. Both routers are allowed now, as Relay asks of integrators who pin them. The chain catalogue, the order's output and both refunds must each name one of the two. The depository, the deposit calldata and every other check are unchanged. --- app/src/lib/bridge/validation.test.ts | 50 ++++++++++++++++++++++++++- app/src/lib/bridge/validation.ts | 17 ++++++--- docs/bridge.md | 7 ++++ 3 files changed, 68 insertions(+), 6 deletions(-) diff --git a/app/src/lib/bridge/validation.test.ts b/app/src/lib/bridge/validation.test.ts index 2c2b029a..b26512d2 100644 --- a/app/src/lib/bridge/validation.test.ts +++ b/app/src/lib/bridge/validation.test.ts @@ -2,7 +2,7 @@ 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 { BridgeApiError, DEPOSIT_ABI, QUOTE_TTL_MS, RELAY_ROUTERS, 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()); @@ -94,6 +94,54 @@ test("canonical metadata rejects a changed depository, missing or disabled chain assert.throws(() => validateRelayChains({ chains: [relayChainsFixture().chains[0]] }), BridgeApiError); }); +// Relay made its v3.2 router the default on 2026-10-26 and keeps v3 live through the transition. While only v3 was +// pinned, every live quote was refused; the captured fixture above still names v3, so both are covered here. +const routerWord = (router: string) => `0x${router.slice(2).padStart(64, "0")}`; +function quoteWithRouters(output: string, refunds: [string, string] = [output, output]) { + const fixture = relayQuoteFixture(); + const order = fixture.protocol.v2.orderData; + order.output.extraData = routerWord(output); + order.inputs[0].refunds.forEach((refund, i) => { refund.extraData = routerWord(refunds[i]); }); + rebind(fixture); + return fixture; +} +function chainsWithRouter(router: string, only?: number) { + const fixture = relayChainsFixture(); + fixture.chains.forEach((chain, i) => { if (only === undefined || only === i) chain.contracts.erc20Router = router; }); + return fixture; +} + +test("Relay's v3.2 router is accepted in the chain catalogue and in the order, and v3 still is", () => { + const [v32, v3] = RELAY_ROUTERS; + assert.equal(v32, "0x0729e2a4716d9f7dd17858d5d504426d228697f7"); + assert.equal(v3, "0xb92fe925dc43a0ecde6c8b1a2709c170ec4fff4f"); + for (const router of RELAY_ROUTERS) { + const chains = validateRelayChains(chainsWithRouter(router)); + assert.equal(validateRelayQuote(quoteWithRouters(router), FIXTURE_INPUT, chains, FIXTURE_NOW).amountOut, "9987669548275956", router); + // checksummed or upper-case spellings of the same address are the same router + assert.doesNotThrow(() => validateRelayChains(chainsWithRouter(`0x${router.slice(2).toUpperCase()}`)), router); + } + // mid-transition a catalogue or an order may name both: each one is Relay's, so neither is refused + assert.doesNotThrow(() => validateRelayChains(chainsWithRouter(v3, 1))); + assert.doesNotThrow(() => validate(quoteWithRouters(v32, [v3, v32]))); +}); + +test("a router Relay never published is refused wherever it appears", () => { + const unknown = "0x1111111111111111111111111111111111111111"; + for (const index of [0, 1]) assert.throws(() => validateRelayChains(chainsWithRouter(unknown, index)), BridgeApiError, `chain ${index}`); + const [v32] = RELAY_ROUTERS; + assert.throws(() => validate(quoteWithRouters(unknown)), BridgeApiError, "output"); + assert.throws(() => validate(quoteWithRouters(v32, [unknown, v32])), BridgeApiError, "source refund"); + assert.throws(() => validate(quoteWithRouters(v32, [v32, unknown])), BridgeApiError, "destination refund"); + // the address alone is not the word an order carries, and extra bytes around it do not pass either + for (const extraData of [v32, `${routerWord(v32)}00`, `0x${"0".repeat(22)}11${v32.slice(2)}`]) { + const fixture = relayQuoteFixture(); + fixture.protocol.v2.orderData.output.extraData = extraData; + rebind(fixture); + assert.throws(() => validate(fixture), BridgeApiError, extraData); + } +}); + 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"); diff --git a/app/src/lib/bridge/validation.ts b/app/src/lib/bridge/validation.ts index a974c32f..7491af64 100644 --- a/app/src/lib/bridge/validation.ts +++ b/app/src/lib/bridge/validation.ts @@ -6,7 +6,15 @@ import { BRIDGE_CHAINS, bridgeCurrency, bridgeFeePercent, isBridgeAssetSupported // 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"; +// Relay's execution routers on Base, Robinhood Chain and Arc (the "Cancun" column of its contract-versions table, +// docs.relay.link/references/api/api_resources/contract-addresses): v3.2 first, then v3. Relay made v3.2 the default on +// 2026-10-26 and keeps both live through the transition, telling integrators who pin addresses to allow both. A router +// Relay publishes later is rejected until it is added here: every quote then fails closed, which the opt-in live tests +// (relay.live.test.ts) show at once. +export const RELAY_ROUTERS = ["0x0729e2a4716d9f7dd17858d5d504426d228697f7", "0xb92fe925dc43a0ecde6c8b1a2709c170ec4fff4f"] as const; +const isRelayRouter = (value: unknown) => RELAY_ROUTERS.some((router) => sameAddress(value, router)); +/** An order names its router as one ABI word in extraData: twelve zero bytes, then the address. */ +const isRelayRouterWord = (value: unknown) => RELAY_ROUTERS.some((router) => sameAddress(value, `0x${router.slice(2).padStart(64, "0")}`)); 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)"]); @@ -82,7 +90,7 @@ export function validateRelayChains(value: unknown, route: Pick chain.id === 8453)!.solverAddresses); @@ -111,8 +119,7 @@ export function validateRelayQuote(value: unknown, input: BridgeQuoteRequest, ch 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(isRelayRouterWord(output.extraData)); 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); @@ -129,7 +136,7 @@ export function validateRelayQuote(value: unknown, input: BridgeQuoteRequest, ch 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)); + ensure(sameAddress(refund.currency, refundCurrency) && uint(refund.minimumAmount) === "0" && refund.deadline === output.deadline && isRelayRouterWord(refund.extraData)); } const paymentDetails = object(protocol.paymentDetails); ensure(paymentDetails.chainId === source && sameAddress(paymentDetails.depository, RELAY_DEPOSITORY) && sameAddress(paymentDetails.currency, inputCurrency.address) && uint(paymentDetails.amount) === input.amount); diff --git a/docs/bridge.md b/docs/bridge.md index b56bef1a..0bd25f99 100644 --- a/docs/bridge.md +++ b/docs/bridge.md @@ -55,6 +55,13 @@ These tests never connect a wallet or submit a transaction. `/ui-review-bridge` is a development-only visual fixture with disconnected, quote, quoting, high-fee, expired, error, pending, success, uncertain and refund states, plus approval/recovery states. Its synthetic data cannot execute a transaction, and the route returns 404 outside development. The real header action uses the actual integration. +### Relay router v3.2 (2026-11-10) + +- Relay made its v3.2 router (`0x0729e2a4716d9f7dd17858d5d504426d228697f7`) the default on 2026-10-26: Get Chains reports it as `contracts.erc20Router` and every order names it in `extraData`. Only v3 (`0xb92fe925dc43a0ecde6c8b1a2709c170ec4fff4f`) was pinned, so from that day every quote was refused as unverifiable and the bridge could not be used. Nothing was sent: the check fails closed. +- Both routers are now allowed, as Relay's [contract addresses](https://docs.relay.link/references/api/api_resources/contract-addresses) page asks of integrators who pin them: the chain catalogue, the order's output and both refunds must each name one of the two. The depository, the deposit calldata and every other check are unchanged. +- After the change all ten directed routes passed the read-only live tests again. No wallet signed anything. +- The live tests are opt-in, so nothing noticed the outage for two weeks. A scheduled run of `relay.live.test.ts` would have; that is a workflow change, which belongs to the maintainers. + ### Wallet test findings (2026-09-16) - Approval recovery now distinguishes a transaction missing from the queried node, an earlier nonce blocking it, a fee cap below the current base fee, and an RPC outage. These are advisory observations, never permission to resubmit or proof that a missing transaction failed. A fresh matching receipt and allowance are still required. From 5f8852b611909e85ed9a058bf8923061c8cb88ba Mon Sep 17 00:00:00 2001 From: Vasanth T Date: Tue, 10 Nov 2026 16:43:39 +0530 Subject: [PATCH 02/17] feat(bridge): USDG on Robinhood Chain, both directions Robinhood Chain's dollar is USDG (Global Dollar, Paxos, 6 decimals, 0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168), and the launch form already prices Robinhood tokens in it. The bridge now offers it as a send and receive asset on Robinhood Chain next to ETH. - types: per-chain asset table; USDG added for 4663; asset names, bridgeTokenAsset() for the chain's one ERC-20, isDollarAsset() - validation: symbols come from the chain table; dollar-to-dollar routes (USDC <-> USDG) must quote within 95%..105% of the amount sent, on top of the existing fee and impact limits - approval, client, useBridge, BridgeDialog: token-generic wording, dollar asset is kept when switching chains (USDC <-> USDG) - tests: USDG routes both ways, token substitution refused, dollar bound enforced; live suite sizes amounts by decimals (16/16 routes) - docs/bridge.md: USDG section --- app/src/components/bridge/BridgeDialog.tsx | 28 ++++--- app/src/components/bridge/bridge-ui.test.ts | 11 ++- app/src/components/bridge/useBridge.ts | 17 ++-- app/src/lib/bridge/approval.test.ts | 9 +- app/src/lib/bridge/approval.ts | 26 +++--- app/src/lib/bridge/client.test.ts | 25 +++++- app/src/lib/bridge/client.ts | 16 ++-- app/src/lib/bridge/relay.fixture.ts | 13 +-- app/src/lib/bridge/relay.live.test.ts | 3 +- app/src/lib/bridge/relay.test.ts | 84 ++++++++++++++++++- .../lib/bridge/transaction-preflight.test.ts | 6 +- app/src/lib/bridge/types.test.ts | 41 ++++++++- app/src/lib/bridge/types.ts | 46 +++++++--- app/src/lib/bridge/validation.ts | 15 ++-- docs/bridge.md | 17 +++- 15 files changed, 281 insertions(+), 76 deletions(-) diff --git a/app/src/components/bridge/BridgeDialog.tsx b/app/src/components/bridge/BridgeDialog.tsx index d04eaacf..9daa0717 100644 --- a/app/src/components/bridge/BridgeDialog.tsx +++ b/app/src/components/bridge/BridgeDialog.tsx @@ -4,9 +4,9 @@ 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 { ArrowLeftRight, ArrowRight, ArrowUpRight, Check, ChevronDown, ChevronRight, CircleAlert, CircleDollarSign, 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, type BridgeQuoteRejection } from "@/lib/bridge/types"; +import { BRIDGE_ASSETS, BRIDGE_ASSET_NAMES, BRIDGE_CHAINS, BRIDGE_CHAIN_IDS, bridgeCurrency, bridgeTokenAsset, bridgeTransferInputCurrency, isBridgeAssetSupported, isBridgeChainId, type BridgeAsset, type BridgeChainId, type BridgeQuoteRejection } from "@/lib/bridge/types"; import { DISCARD_AFTER_MS } from "@/lib/bridge/client"; import { formatFeeWarningPercent } from "@/lib/bridge/fee-display"; import { shortAddr } from "@/lib/chainPublic"; @@ -36,6 +36,8 @@ function NetworkMark({ chain }: { chain: BridgeChainId }) { } function AssetMark({ asset, size = 24 }: { asset: BridgeAsset; size?: number }) { + // USDG has no bundled logo: it takes the plain dollar glyph the launch form gives it + if (asset === "USDG") return ; return ; } @@ -56,7 +58,7 @@ function AssetSelect({ label, chain, asset, disabled, onChange }: { label: "Send {choices.map((value) => - {value}{value === "ETH" ? "Ether · Gas token" : "USD Coin · Gas in ETH"} + {value}{BRIDGE_ASSET_NAMES[value]} · {value === BRIDGE_CHAINS[chain].symbol ? "Gas token" : `Gas in ${BRIDGE_CHAINS[chain].symbol}`} )} @@ -190,11 +192,11 @@ export function BridgeForm({ bridge: b, connect }: { bridge: Bridge; connect: () 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.quoteLoading ? "Getting your quote…" + const label = b.approvalBusy ? `Confirm ${inputCurrency.symbol} approval…` : b.allowanceLoading ? `Checking ${inputCurrency.symbol} permission…` : b.quoteLoading ? "Getting your quote…" : 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 && b.approvalRequired ? `Approve ${nativeAmount(b.amount)} ${inputCurrency.symbol}` : reviewing ? `Bridge to ${destination.name}` : b.quoteError ? "Retry quote" : !b.canQuote ? "Enter an amount" : "Get quote"; const error = (!b.quoteRejection && b.quoteError) || b.error || b.storageError || b.approvalError; @@ -211,8 +213,8 @@ export function BridgeForm({ bridge: b, connect }: { bridge: Bridge; 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.originChainId === 4663 || b.destinationChainId === 4663 ?

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

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

{inputCurrency.symbol} approval confirmed. Review a fresh quote before bridging. No funds have been bridged yet.

: null}
@@ -238,7 +240,7 @@ export function BridgeForm({ bridge: b, connect }: { bridge: Bridge; connect: ()
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} + {b.approvalRequired ?

First, approve only {nativeAmount(b.amount)} {inputCurrency.symbol} 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}
) : b.quoteRejection ? :
{b.quoteLoading ? : } @@ -253,7 +255,7 @@ export function BridgeForm({ bridge: b, connect }: { bridge: Bridge; connect: () {b.address ? label : "Connect wallet"} {!loading && b.address ? : 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."}

+

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 ${inputCurrency.symbol} approval remains until used or revoked.` : "No token approvals required."}

); } @@ -261,19 +263,21 @@ export function BridgeForm({ bridge: b, connect }: { bridge: Bridge; connect: () function ApprovalProgress({ bridge: b }: { bridge: Bridge }) { const approval = b.approval!; const approvalChain = BRIDGE_CHAINS[approval.chainId]; + // the chain's one ERC-20: USDC on Base and Arc, USDG on Robinhood Chain + const approvalSymbol = bridgeTokenAsset(approval.chainId) ?? "token"; const uncertain = approval.status === "uncertain"; const health = b.approvalHealth; const needsAttention = uncertain || (!!health && health.kind !== "waiting"); const [hash, setHash] = useState(""); const [verifying, setVerifying] = useState(false); return
-
USDC approval on {approvalChain.name}
+
{approvalSymbol} approval on {approvalChain.name}
{needsAttention ? : }

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

-

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

+

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

-
Approval limit
{nativeAmount(formatUnits(BigInt(approval.amount), 6))} USDC
+
Approval limit
{nativeAmount(formatUnits(BigInt(approval.amount), 6))} {approvalSymbol}
{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} diff --git a/app/src/components/bridge/bridge-ui.test.ts b/app/src/components/bridge/bridge-ui.test.ts index 046aba44..b8d03b29 100644 --- a/app/src/components/bridge/bridge-ui.test.ts +++ b/app/src/components/bridge/bridge-ui.test.ts @@ -95,7 +95,11 @@ test("synthetic bridge review is development-only and cannot execute a transacti }); test("USDC approval is visibly separate from the bridge and has chain-aware recovery", () => { - assert.match(panel, /Approve \$\{nativeAmount\(b.amount\)\} USDC/); + // the approval names the token being approved: USDC on Base and Arc, USDG on Robinhood Chain + assert.match(panel, /Approve \$\{nativeAmount\(b.amount\)\} \$\{inputCurrency\.symbol\}/); + assert.match(panel, /const approvalSymbol = bridgeTokenAsset\(approval\.chainId\) \?\? "token";/); + assert.match(panel, /\{approvalSymbol\} approval on \{approvalChain\.name\}<\/span>/); + assert.doesNotMatch(panel, /USDC approval|exact USDC amount|\} USDC[<`"]/, "no approval copy assumes the token is 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/); @@ -117,7 +121,10 @@ test("USDC selectors separate selected token units from native gas and disallow 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, /USDC on Robinhood is unavailable: its routes do not pass our current safety checks\. ETH and USDG are available\./); + // each asset is named in full, and says which token pays its chain's gas + assert.match(panel, /\{BRIDGE_ASSET_NAMES\[value\]\} · \{value === BRIDGE_CHAINS\[chain\]\.symbol \? "Gas token" : `Gas in \$\{BRIDGE_CHAINS\[chain\]\.symbol\}`\}/); + assert.match(panel, /if \(asset === "USDG"\) return { - if (!stopped) setAllowanceResult({ key: quote.requestId, value: null, error: messageOf(error, "Could not read USDC allowance. Request a new quote.") }); + if (!stopped) setAllowanceResult({ key: quote.requestId, value: null, error: messageOf(error, "Could not read the token allowance. Request a new quote.") }); }); return () => { stopped = true; }; }, [quote, config]); @@ -406,11 +406,12 @@ export function useBridge(open: boolean) { 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."); + const tokenSymbol = bridgeCurrency(reviewed.originChainId, reviewed.originAsset, "input").symbol; + if (selectedBalance < BigInt(owner.amount)) throw new Error(`Not enough ${tokenSymbol} for this approval amount. Reduce the amount and request a new quote.`); + if (allowance >= BigInt(owner.amount)) throw new Error(`${tokenSymbol} 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. + // Arc's token shares its gas balance; Base USDC and Robinhood USDG have 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 }; }, @@ -428,7 +429,7 @@ export function useBridge(open: boolean) { 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) }); + setApprovalIssue({ key: reviewed.address.toLowerCase(), message: messageOf(error, "Could not prepare the approval. Request a new quote.", BRIDGE_CHAINS[reviewed.originChainId].symbol) }); } finally { actionLock.current = false; setApprovalSending(false); @@ -517,8 +518,8 @@ export function useBridge(open: boolean) { 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 (q.approval && (allowance === null || allowance < BigInt(q.amount))) throw new Error(`${bridgeCurrency(q.originChainId, q.originAsset, "input").symbol} 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 ${bridgeCurrency(q.originChainId, q.originAsset, "input").symbol} 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 }; diff --git a/app/src/lib/bridge/approval.test.ts b/app/src/lib/bridge/approval.test.ts index be59865f..cc198cfb 100644 --- a/app/src/lib/bridge/approval.test.ts +++ b/app/src/lib/bridge/approval.test.ts @@ -2,7 +2,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import { decodeFunctionData, encodeAbiParameters, encodeEventTopics, type Address, type Hex } from "viem"; import { APPROVAL_DISCARD_AFTER_MS, APPROVAL_STORAGE_PREFIX, ARC_APPROVAL_CHAIN_ID, ARC_USDC, EXACT_APPROVAL_ABI, RELAY_APPROVAL_SPENDER, USDC_APPROVAL_EVENT, approvalBlocksSubmission, approvalCanDiscard, approvalRequestKey, approvalStorageKey, createApprovalStore, exactApprovalTransaction, hasMatchingApprovalEvent, isMatchingApprovalTransaction, parseStoredApproval, reconcileApproval, serializeApproval, submitExactApproval, validateApprovalMetadata, type ApprovalDependencies, type ApprovalRequest, type TrackedApproval } from "./approval"; -import { BASE_USDC } from "./types"; +import { BASE_USDC, ROBINHOOD_USDG } from "./types"; import { canApplyApprovalPoll, recoverApprovalFromEvidence } from "./approval"; const ADDRESS = "0x1111111111111111111111111111111111111111" as Address; @@ -84,7 +84,10 @@ test("Base approvals pin Base USDC and retain independent chain identity", () => 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 })); + // Robinhood Chain approves its own token, USDG, never Base's or Arc's + assert.equal(exactApprovalTransaction({ ...request, chainId: 4663 }).to, ROBINHOOD_USDG); + assert.throws(() => validateApprovalMetadata({ token: BASE_USDC, spender: RELAY_APPROVAL_SPENDER, amount: REQUEST.amount }, ADDRESS, 4663)); + assert.throws(() => validateApprovalMetadata({ token: ROBINHOOD_USDG, spender: RELAY_APPROVAL_SPENDER, amount: REQUEST.amount }, ADDRESS, 8453)); 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)); @@ -356,7 +359,7 @@ test("a confirmed exact replacement recovers a pending hash without resubmitting assert.throws(() => recoverApprovalFromEvidence(approval, { ...evidence, blockTimestamp: evidence.blockTimestamp + 31n }), /clock/); assert.throws(() => recoverApprovalFromEvidence(approval, { ...evidence, transaction: { ...evidence.transaction, hash: HASH } }), /network/); for (const changes of [{ to: ADDRESS, input: "0x" as Hex }, { from: OTHER }, { input: exactApprovalTransaction({ ...REQUEST, amount: "1" }).data }, { value: 1n }]) { - assert.throws(() => recoverApprovalFromEvidence(approval, { ...evidence, transaction: { ...evidence.transaction, ...changes } }), /exact USDC approval/); + assert.throws(() => recoverApprovalFromEvidence(approval, { ...evidence, transaction: { ...evidence.transaction, ...changes } }), /exact token approval/); } assert.throws(() => recoverApprovalFromEvidence({ ...approval, status: "confirmed" }, evidence), /already resolved/); }); diff --git a/app/src/lib/bridge/approval.ts b/app/src/lib/bridge/approval.ts index 720dd455..5ac5c1f5 100644 --- a/app/src/lib/bridge/approval.ts +++ b/app/src/lib/bridge/approval.ts @@ -1,5 +1,5 @@ import { decodeEventLog, encodeFunctionData, isAddress, type Address, type Hex } from "viem"; -import { bridgeCurrency, isBridgeChainId, type BridgeChainId } from "./types"; +import { bridgeCurrency, bridgeTokenAsset, isBridgeChainId, type BridgeChainId } from "./types"; // Independently pinned: provider calldata never chooses the token or spender. // https://docs.arc.io/arc/references/contract-addresses @@ -16,7 +16,7 @@ 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 ApprovalRequest = { address: Address; amount: string; chainId?: BridgeChainId }; // Omitted chain is legacy Arc; amounts are token units, 6 decimals. export type ApprovalStatus = typeof STATUSES[number]; export type TrackedApproval = ApprovalRequest & { version: 1; @@ -39,13 +39,15 @@ function validAmount(value: unknown): value is string { } 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; + // the chain's one ERC-20 (types.ts): USDC on Base and Arc, USDG on Robinhood Chain + const asset = isBridgeChainId(chainId) ? bridgeTokenAsset(chainId) : null; + if (!asset) throw new Error("Token approvals are not supported on this network."); + return bridgeCurrency(chainId, asset, "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."); + throw new Error("The token approval does not match a valid wallet and exact amount."); } const chainId = value.chainId ?? ARC_APPROVAL_CHAIN_ID; approvalToken(chainId); @@ -108,7 +110,7 @@ export function isMatchingApprovalTransaction(approval: TrackedApproval, transac } catch { return false; } } -/** Smart-wallet recovery uses the canonical USDC event, never a router's log. */ +/** Smart-wallet recovery uses the token's own Approval 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 { @@ -129,7 +131,7 @@ export function parseStoredApproval(raw: string, address: Address): TrackedAppro 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."); } + } catch { throw new Error("Saved token approval recovery data could not be read. Check the approval before trying again."); } } export function serializeApproval(approval: TrackedApproval): string { @@ -172,7 +174,7 @@ export function recoverApprovalFromEvidence(approval: TrackedApproval, evidence: const blockTime = Number(evidence.blockTimestamp) * 1000; // Approvals have no order ID. Never adopt an old approval from before this attempt. if (!Number.isSafeInteger(blockTime) || !Number.isSafeInteger(evidence.now) || blockTime < current.createdAt - 30_000 || blockTime > evidence.now + 30_000) throw new Error("This transaction predates the saved approval or your clock differs from the network. Check the hash and device clock."); - if (!isMatchingApprovalTransaction(current, evidence.transaction) && !hasMatchingApprovalEvent(current, evidence.receipt)) throw new Error("That transaction does not match this wallet's exact USDC approval."); + if (!isMatchingApprovalTransaction(current, evidence.transaction) && !hasMatchingApprovalEvent(current, evidence.receipt)) throw new Error("That transaction does not match this wallet's exact token approval."); return reconcileApproval({ ...current, approvalHash: hash }, evidence); } @@ -206,7 +208,7 @@ export function createApprovalStore(storage: () => StorageAdapter) { update(address, approval, null); return approval; } catch { - const message = "USDC approval recovery storage is unavailable or unreadable. Enable site storage and reload before approving."; + const message = "Token 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); } @@ -220,7 +222,7 @@ export function createApprovalStore(storage: () => StorageAdapter) { 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."; + const message = "Could not save token approval recovery details. Keep this page open and check the approval before trying again."; update(approval.address, approval, message); throw new Error(message); } @@ -228,7 +230,7 @@ export function createApprovalStore(storage: () => StorageAdapter) { 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."; + const message = "Could not clear the saved token approval. Enable site storage and reload before trying again."; update(address, snapshot.approvals[address.toLowerCase()] ?? null, message); throw new Error(message); } @@ -265,7 +267,7 @@ function walletRejected(error: unknown): boolean { /** * 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; + * This only approves the source chain's one bridge token. 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 { diff --git a/app/src/lib/bridge/client.test.ts b/app/src/lib/bridge/client.test.ts index 767e8be5..dbfdea57 100644 --- a/app/src/lib/bridge/client.test.ts +++ b/app/src/lib/bridge/client.test.ts @@ -118,8 +118,29 @@ test("asset selectors clear reinterpreted amounts and preserve supported tokens 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: "" }); + // a dollar stays a dollar across chains: Base USDC becomes Robinhood USDG, and the amount is still cleared + assert.deepEqual(changeBridgeRoute(funded, { side: "origin", chainId: 4663 }), { ...funded, originChainId: 4663, originAsset: "USDG", amount: "" }); + assert.deepEqual(changeBridgeRoute(funded, { side: "destination", chainId: 4663 }), { ...funded, destinationChainId: 4663, destinationAsset: "USDG" }); + const fromRobinhood = { originChainId: 4663, destinationChainId: 8453, originAsset: "USDG", destinationAsset: "ETH", amount: "25" } as const; + assert.deepEqual(changeBridgeRoute(fromRobinhood, { side: "origin", chainId: 5042 }), { ...fromRobinhood, originChainId: 5042, originAsset: "USDC", amount: "" }); + assert.deepEqual(changeBridgeRoute(fromRobinhood, { side: "reverse" }), { originChainId: 8453, destinationChainId: 4663, originAsset: "ETH", destinationAsset: "USDG", amount: "" }); + // Ether has no dollar to map to on Arc, so it falls back to the chain's own asset as before + assert.deepEqual(changeBridgeRoute({ ...fromRobinhood, originAsset: "ETH" }, { side: "origin", chainId: 5042 }), { ...fromRobinhood, originChainId: 5042, originAsset: "USDC", amount: "" }); assert.deepEqual(changeBridgeRoute(route, { side: "destination-asset", asset: "ETH" }), route); + assert.deepEqual(changeBridgeRoute(fromRobinhood, { side: "origin-asset", asset: "USDC" }), fromRobinhood, "USDC is still not offered on Robinhood Chain"); +}); + +test("Robinhood USDG is a six-decimal token of its own, never USDC and never Ether", () => { + const usdg = bridgeRequest(ADDRESS, 4663, "25.000001", 8453, "USDG", "USDC"); + assert.equal(usdg?.amount, "25000001"); + assert.equal(usdg?.originAsset, "USDG"); + assert.equal(bridgeRequest(ADDRESS, 4663, "1.0000001", 8453, "USDG", "USDC"), null, "a seventh decimal place is refused, not rounded"); + assert.equal(bridgeRequest(ADDRESS, 8453, "1", 4663, "ETH", "USDG")?.amount, "1000000000000000000"); + assert.equal(bridgeRequest(ADDRESS, 8453, "1", 5042, "USDG", "USDC"), null, "USDG exists on Robinhood Chain only"); + assert.equal(bridgeRequest(ADDRESS, 8453, "1", 5042, "USDC", "USDG"), null); + // a USDG source has a separate ETH gas balance, like Base USDC and unlike Arc + assert.equal(nativeSourceAmount(usdg!), 0n); + assert.notEqual(bridgeRequestKey(usdg), bridgeRequestKey({ ...usdg!, originAsset: "ETH" })); }); test("Base USDC uses exact pinned ERC20 deposits while reserving separate ETH gas", () => { @@ -138,7 +159,7 @@ test("Base USDC uses exact pinned ERC20 deposits while reserving separate ETH ga ]) 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/); + assert.throws(() => validateBridgeQuote(unlimitedQuote, unlimitedQuote, NOW), /Unlimited token approvals/); }); test("asset changes across awaited preflight cancel a reviewed deposit", async () => { diff --git a/app/src/lib/bridge/client.ts b/app/src/lib/bridge/client.ts index 33418c5f..41c81894 100644 --- a/app/src/lib/bridge/client.ts +++ b/app/src/lib/bridge/client.ts @@ -1,6 +1,6 @@ import { BaseError, decodeEventLog, decodeFunctionData, encodeFunctionData, InsufficientFundsError, isAddress, parseUnits, type Address, type Hex } from "viem"; import { friendlyError } from "../errors"; -import { bridgeCurrency, bridgeFeePercent, defaultBridgeAsset, isBridgeAssetSupported, isBridgeChainId, type BridgeAsset, type BridgeChainId, type BridgeQuote, type BridgeQuoteRejection, type BridgeQuoteRequest, type BridgeStatus, type BridgeStatusResponse } from "./types"; +import { BRIDGE_ASSETS, bridgeCurrency, bridgeFeePercent, defaultBridgeAsset, isBridgeAssetSupported, isBridgeChainId, isDollarAsset, type BridgeAsset, type BridgeChainId, type BridgeQuote, type BridgeQuoteRejection, 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 & { @@ -80,8 +80,12 @@ export function changeBridgeRoute(current: BridgeRouteInputs, change: BridgeRout destinationChainId = change.chainId; } } - if (!isBridgeAssetSupported(originChainId, originAsset)) originAsset = defaultBridgeAsset(originChainId); - if (!isBridgeAssetSupported(destinationChainId, destinationAsset)) destinationAsset = defaultBridgeAsset(destinationChainId); + // An asset the new chain lacks falls back to that chain's default, except that a dollar stays a dollar: USDC on + // Base or Arc becomes USDG on Robinhood Chain, and back. + const kept = (chainId: BridgeChainId, asset: BridgeAsset): BridgeAsset => isBridgeAssetSupported(chainId, asset) ? asset + : (isDollarAsset(asset) ? BRIDGE_ASSETS[chainId].find(isDollarAsset) : undefined) ?? defaultBridgeAsset(chainId); + originAsset = kept(originChainId, originAsset); + destinationAsset = kept(destinationChainId, destinationAsset); const amount = previousOriginAsset === originAsset ? current.amount : ""; return { originChainId, destinationChainId, amount, ...(explicitAssets ? { originAsset, destinationAsset } : {}) }; } @@ -150,9 +154,9 @@ export function validateBridgeQuote(value: unknown, request: BridgeQuoteRequest, } 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 && BigInt(request.amount) === MAX_UINT) throw new Error("Unlimited token 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."); + throw new Error("The approval did not match the exact token 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."); @@ -406,7 +410,7 @@ export function parseStoredTransfer(raw: string, address: Address): TrackedBridg 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."); } + } catch { throw new Error("Saved token 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]; diff --git a/app/src/lib/bridge/relay.fixture.ts b/app/src/lib/bridge/relay.fixture.ts index 9cbed244..9e0a4f03 100644 --- a/app/src/lib/bridge/relay.fixture.ts +++ b/app/src/lib/bridge/relay.fixture.ts @@ -1,6 +1,6 @@ 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 { BRIDGE_CHAINS, BRIDGE_INPUT_CURRENCIES, bridgeCurrency, isDollarAsset, 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. @@ -59,11 +59,14 @@ export function relayRouteFixture(originChainId: BridgeChainId, destinationChain 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 + // USDC and USDG are both dollars: a dollar route delivers the input less its fee, in the output's own decimals + const dollarIn = isDollarAsset(inputCurrency.symbol); + const dollarOut = isDollarAsset(outputCurrency.symbol); + const amount = dollarIn ? "25000000" : FIXTURE_INPUT.amount; + const feeAmount = dollarIn ? "60000" : dollarOut ? "24736726855049" : "12330451724044"; + const amountOut = inputCurrency.symbol === outputCurrency.symbol || (dollarIn && dollarOut) ? ((BigInt(amount) - BigInt(feeAmount)) * 10n ** BigInt(outputCurrency.decimals) / 10n ** BigInt(inputCurrency.decimals)).toString() - : outputCurrency.symbol === "USDC" ? (238n * 10n ** BigInt(outputCurrency.decimals) / 10n).toString() : "10400000000000000"; + : dollarOut ? (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(); diff --git a/app/src/lib/bridge/relay.live.test.ts b/app/src/lib/bridge/relay.live.test.ts index a99caa7c..292f3c8a 100644 --- a/app/src/lib/bridge/relay.live.test.ts +++ b/app/src/lib/bridge/relay.live.test.ts @@ -11,7 +11,8 @@ 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"; + // 25 units of a six-decimal token (USDC, USDG), or 0.01 ETH + const amount = bridgeCurrency(originChainId, originAsset, "input").decimals === 6 ? "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); diff --git a/app/src/lib/bridge/relay.test.ts b/app/src/lib/bridge/relay.test.ts index 8e05281d..097b5c71 100644 --- a/app/src/lib/bridge/relay.test.ts +++ b/app/src/lib/bridge/relay.test.ts @@ -3,7 +3,7 @@ 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 { ARC_USDC, BASE_USDC, BRIDGE_INPUT_CURRENCIES, ROBINHOOD_USDG, 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"; import { parseBridgeQuoteRejection } from "./client.ts"; @@ -418,6 +418,88 @@ test("Base USDC approval cannot borrow Arc's token, use unlimited amount or redi } }); +const USDG_ROUTES: [BridgeChainId, BridgeChainId, BridgeAsset, BridgeAsset][] = [ + [8453, 4663, "USDC", "USDG"], [8453, 4663, "ETH", "USDG"], [5042, 4663, "USDC", "USDG"], + [4663, 8453, "USDG", "USDC"], [4663, 8453, "USDG", "ETH"], [4663, 5042, "USDG", "USDC"], +]; + +test("Robinhood USDG routes verify in both directions with an exact approval of the right token", () => { + for (const [origin, destination, originAsset, destinationAsset] of USDG_ROUTES) { + const route = `${origin}:${originAsset}→${destination}:${destinationAsset}`; + const { input, quote } = relayRouteFixture(origin, destination, originAsset, destinationAsset); + const validated = validateRelayQuote(quote, input, validateRelayChains(relayChainsFixture(), input), FIXTURE_NOW); + assert.equal(validated.transaction.to, RELAY_DEPOSITORY, route); + const sent = bridgeCurrency(origin, originAsset, "input"); + if (sent.address === zeroAddress) { + assert.equal(validated.approval, undefined, route); + assert.equal(validated.transaction.value, input.amount, route); + } else { + // a token source approves exactly the amount, of its own chain's token, to the pinned depository + assert.deepEqual(validated.approval, { token: sent.address, spender: RELAY_DEPOSITORY, amount: input.amount }, route); + assert.equal(validated.transaction.value, "0", route); + } + if (originAsset === "USDG") assert.equal(validated.approval?.token, ROBINHOOD_USDG, route); + } +}); + +test("USDG cannot be swapped for another token, another chain's dollar or other decimals, even when rehashed", () => { + const rebound = (origin: BridgeChainId, destination: BridgeChainId, originAsset: BridgeAsset, destinationAsset: BridgeAsset, mutate: (quote: ReturnType) => void) => { + const { input, quote } = relayRouteFixture(origin, destination, originAsset, destinationAsset); + mutate(quote); + const metadata = validateRelayChains(relayChainsFixture(), input); + const orderId = getOrderId(quote.protocol.v2.orderData as Order, metadata.vmTypes); + quote.protocol.v2.orderId = orderId; + const sent = bridgeCurrency(origin, originAsset, "input"); + if (sent.address !== zeroAddress) quote.steps[0].items[0].data.data = encodeFunctionData({ abi: ERC20_DEPOSIT_ABI, functionName: "depositErc20", args: [input.address, sent.address, BigInt(input.amount), orderId] }); + return () => validateRelayQuote(quote, input, metadata, FIXTURE_NOW); + }; + // receiving USDG: the output and the destination refund must be the pinned USDG contract + for (const mutate of [ + (quote: ReturnType) => { quote.protocol.v2.orderData.output.payments[0].currency = BASE_USDC; }, + (quote: ReturnType) => { quote.protocol.v2.orderData.output.payments[0].currency = zeroAddress; }, + (quote: ReturnType) => { quote.protocol.v2.orderData.inputs[0].refunds[1].currency = zeroAddress; }, + (quote: ReturnType) => { quote.details.currencyOut.currency.symbol = "USDC"; }, + (quote: ReturnType) => { quote.details.currencyOut.currency.decimals = 18; }, + ]) assert.throws(rebound(8453, 4663, "USDC", "USDG", mutate), BridgeApiError); + // sending USDG: the payment, the source refund and the fee stay in USDG + for (const mutate of [ + (quote: ReturnType) => { quote.protocol.v2.orderData.inputs[0].payment.currency = BASE_USDC; }, + (quote: ReturnType) => { quote.protocol.v2.orderData.inputs[0].refunds[0].currency = zeroAddress; }, + (quote: ReturnType) => { quote.fees.relayer.currency.symbol = "USDC"; }, + (quote: ReturnType) => { quote.fees.gas.currency.address = ROBINHOOD_USDG; quote.fees.gas.currency.symbol = "USDG"; quote.fees.gas.currency.decimals = 6; }, + ]) assert.throws(rebound(4663, 8453, "USDG", "USDC", mutate), BridgeApiError); + assert.doesNotThrow(rebound(8453, 4663, "USDC", "USDG", () => {})); + assert.doesNotThrow(rebound(4663, 8453, "USDG", "USDC", () => {})); +}); + +test("a dollar is counted against a dollar: USDC and USDG routes never guarantee under 95% or claim over 105%", () => { + // the provider's own impact estimate stays a mild -0.12% throughout, so only the unit comparison can refuse these + const delivering = (origin: BridgeChainId, destination: BridgeChainId, originAsset: BridgeAsset, destinationAsset: BridgeAsset, percent: bigint) => { + const { input, quote } = relayRouteFixture(origin, destination, originAsset, destinationAsset); + const received = bridgeCurrency(destination, destinationAsset, "output"); + const expected = BigInt(input.amount) * 10n ** BigInt(received.decimals - 6) * percent / 100n; + const minimum = expected * 9950n / 10000n; + const payment = quote.protocol.v2.orderData.output.payments[0]; + payment.expectedAmount = expected.toString(); + payment.minimumAmount = minimum.toString(); + quote.details.currencyOut.amount = expected.toString(); + quote.details.currencyOut.minimumAmount = minimum.toString(); + quote.details.totalImpact.percent = "-0.12"; + 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, originAsset, "input").address, BigInt(input.amount), orderId] }); + return () => validateRelayQuote(quote, input, metadata, FIXTURE_NOW); + }; + // six decimals on both sides, and six against Arc's eighteen + for (const [origin, destination, originAsset, destinationAsset] of [[8453, 4663, "USDC", "USDG"], [4663, 8453, "USDG", "USDC"], [4663, 5042, "USDG", "USDC"], [5042, 4663, "USDC", "USDG"]] as [BridgeChainId, BridgeChainId, BridgeAsset, BridgeAsset][]) { + const route = `${origin}:${originAsset}→${destination}:${destinationAsset}`; + for (const percent of [96n, 100n, 104n]) assert.doesNotThrow(delivering(origin, destination, originAsset, destinationAsset, percent), `${route} at ${percent}%`); + // 95% expected leaves a guaranteed minimum under 95% + for (const percent of [50n, 90n, 95n, 106n, 1000n]) assert.throws(delivering(origin, destination, originAsset, destinationAsset, percent), BridgeApiError, `${route} at ${percent}%`); + } +}); + 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(); diff --git a/app/src/lib/bridge/transaction-preflight.test.ts b/app/src/lib/bridge/transaction-preflight.test.ts index 881fbdb8..351dd90e 100644 --- a/app/src/lib/bridge/transaction-preflight.test.ts +++ b/app/src/lib/bridge/transaction-preflight.test.ts @@ -124,7 +124,7 @@ test("USDC approval preflight blocks both source networks before journaling or o function quote(originChainId: BridgeChainId, destinationChainId: BridgeChainId, originAsset: BridgeAsset, destinationAsset: BridgeAsset): BridgeQuote { const currency = bridgeCurrency(originChainId, originAsset, "input"); const amount = (10n ** BigInt(currency.decimals)).toString(); - const erc20 = originAsset === "USDC"; + const erc20 = !/^0x0{40}$/.test(currency.address); return { address: ADDRESS, originChainId, destinationChainId, originAsset, destinationAsset, amount, requestId: HASH, amountOut: "990000", minimumAmountOut: "980000", @@ -159,7 +159,7 @@ function depositScenario(q: BridgeQuote, pending: number, latest: number) { return { deps, events }; } -test("all ten supported deposit routes block queued nonces before journaling or sending", async () => { +test("all sixteen supported deposit routes block queued nonces before journaling or sending", async () => { let routes = 0; for (const origin of BRIDGE_CHAIN_IDS) for (const destination of BRIDGE_CHAIN_IDS) { if (origin === destination) continue; @@ -171,7 +171,7 @@ test("all ten supported deposit routes block queued nonces before journaling or routes++; } } - assert.equal(routes, 10); + assert.equal(routes, 16); }); test("clear queues preserve explicit approval and deposit submission order", async () => { diff --git a/app/src/lib/bridge/types.test.ts b/app/src/lib/bridge/types.test.ts index 080028e8..199e7a0e 100644 --- a/app/src/lib/bridge/types.test.ts +++ b/app/src/lib/bridge/types.test.ts @@ -1,11 +1,12 @@ import assert from "node:assert/strict"; import test from "node:test"; import { formatUnits, zeroAddress } from "viem"; -import { ARC_USDC, BASE_USDC, BRIDGE_ASSETS, bridgeCurrency, bridgeFeePercent, bridgeTransferInputCurrency, isBridgeAssetSupported } from "./types"; +import { ARC_USDC, BASE_USDC, BRIDGE_ASSETS, BRIDGE_ASSET_NAMES, BRIDGE_CHAIN_IDS, BRIDGE_CHAINS, ROBINHOOD_USDG, bridgeCurrency, bridgeFeePercent, bridgeTokenAsset, bridgeTransferInputCurrency, defaultBridgeAsset, isBridgeAssetSupported, isDollarAsset } from "./types"; +import { launchpad } from "../launchpad/config"; 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[4663], ["ETH", "USDG"]); assert.deepEqual(BRIDGE_ASSETS[5042], ["USDC"]); assert.equal(isBridgeAssetSupported(4663, "USDC"), false); assert.equal(isBridgeAssetSupported(8453, BASE_USDC), false); @@ -13,6 +14,42 @@ test("bridge assets are chain-scoped and never inferred from a symbol alone", () assert.throws(() => bridgeCurrency(4663, "USDC", "input")); assert.throws(() => bridgeCurrency(4663, "USDC", "output")); assert.throws(() => bridgeCurrency(5042, "ETH", "input")); + // USDG is Robinhood Chain's alone, and USDC is still not offered there + for (const chain of [8453, 5042] as const) { + assert.equal(isBridgeAssetSupported(chain, "USDG"), false); + assert.throws(() => bridgeCurrency(chain, "USDG", "output")); + } + assert.equal(isBridgeAssetSupported(4663, "usdg"), false); + assert.equal(isBridgeAssetSupported(4663, ROBINHOOD_USDG), false); +}); + +test("Robinhood USDG is the launch form's own six-decimal contract in both directions", () => { + for (const side of ["input", "output"] as const) { + assert.deepEqual(bridgeCurrency(4663, "USDG", side), { address: ROBINHOOD_USDG, symbol: "USDG", decimals: 6 }); + } + assert.equal(ROBINHOOD_USDG, "0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168"); + // one contract, named once more in the launch config: the bridge must deliver the token the form prices in + const usdg = launchpad("robinhood").quotes.find((quote) => quote.key === "usdg"); + assert.equal(usdg?.address.toLowerCase(), ROBINHOOD_USDG.toLowerCase()); + assert.equal(usdg?.decimals, 6); + // a returned currency is a copy: nothing a caller does to it changes the table + const copy = bridgeCurrency(4663, "USDG", "input"); + copy.decimals = 18; + assert.equal(bridgeCurrency(4663, "USDG", "input").decimals, 6); +}); + +test("every chain keeps its gas token as the default and has at most one ERC-20", () => { + assert.deepEqual(BRIDGE_CHAIN_IDS.map(bridgeTokenAsset), ["USDC", "USDG", "USDC"]); + for (const chain of BRIDGE_CHAIN_IDS) { + assert.equal(defaultBridgeAsset(chain), BRIDGE_CHAINS[chain].symbol); + assert.ok(isBridgeAssetSupported(chain, defaultBridgeAsset(chain)), String(chain)); + // approvals are journaled per chain (approval.ts), so a second ERC-20 on one chain would be ambiguous there + const tokens = BRIDGE_ASSETS[chain].filter((asset) => bridgeCurrency(chain, asset, "input").address !== zeroAddress); + assert.ok(tokens.length <= 1, `${chain}: ${tokens.join(", ")}`); + for (const asset of BRIDGE_ASSETS[chain]) assert.ok(BRIDGE_ASSET_NAMES[asset].length > 0, asset); + } + assert.deepEqual(["ETH", "USDC", "USDG"].map((asset) => isDollarAsset(asset as "ETH")), [false, true, true]); + assert.equal(BRIDGE_ASSET_NAMES.USDG, "Global Dollar"); }); test("Base USDC uses the same six-decimal contract in both directions", () => { diff --git a/app/src/lib/bridge/types.ts b/app/src/lib/bridge/types.ts index 00d99c89..68c9e6e1 100644 --- a/app/src/lib/bridge/types.ts +++ b/app/src/lib/bridge/types.ts @@ -12,15 +12,35 @@ export const BRIDGE_CHAIN_IDS = [8453, 4663, 5042] as const; // 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"; +// Global Dollar (USDG) on Robinhood Chain: issued by Paxos, six decimals, the chain's main stablecoin and the launch +// form's default quote there (lib/launchpad/config.ts names the same contract). Robinhood USDC is still not offered. +// https://docs.paxos.com/guides/stablecoin/usdg/mainnet +export const ROBINHOOD_USDG = "0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168" as const; +export type BridgeAsset = "ETH" | "USDC" | "USDG"; 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. +const native = (symbol: BridgeAsset): BridgeCurrency => ({ address: NATIVE_ADDRESS, symbol, decimals: 18 }); +const token = (address: Address, symbol: BridgeAsset): BridgeCurrency => ({ address, symbol, decimals: 6 }); +const bothWays = (currency: BridgeCurrency) => ({ input: currency, output: currency }); +/** + * Every asset the bridge moves, per chain, as the wallet sends it (input) and as it arrives (output). Only assets + * whose identity has been independently verified belong here; a chain's first entry is listed first in its picker. + * The two sides differ on Arc alone, where USDC is sent through its ERC-20 interface and arrives as the native balance. + * A chain has at most one ERC-20 here: approvals are journaled per chain (approval.ts), so a second one needs that + * journal to carry the token first. + */ +const CURRENCIES: Record>> = { + 8453: { ETH: bothWays(native("ETH")), USDC: bothWays(token(BASE_USDC, "USDC")) }, + 4663: { ETH: bothWays(native("ETH")), USDG: bothWays(token(ROBINHOOD_USDG, "USDG")) }, + 5042: { USDC: { input: token(ARC_USDC, "USDC"), output: native("USDC") } }, +}; export const BRIDGE_ASSETS: Record = { - 8453: ["ETH", "USDC"], - 4663: ["ETH"], - 5042: ["USDC"], + 8453: Object.keys(CURRENCIES[8453]) as BridgeAsset[], + 4663: Object.keys(CURRENCIES[4663]) as BridgeAsset[], + 5042: Object.keys(CURRENCIES[5042]) as BridgeAsset[], }; +/** What each asset is called in full, for pickers and screen readers. */ +export const BRIDGE_ASSET_NAMES: Record = { ETH: "Ether", USDC: "USD Coin", USDG: "Global Dollar" }; export function defaultBridgeAsset(chainId: BridgeChainId): BridgeAsset { return BRIDGE_CHAINS[chainId].symbol; } @@ -29,11 +49,17 @@ export function isBridgeAssetSupported(chainId: BridgeChainId, asset: unknown): } 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."); + const entry = isBridgeAssetSupported(chainId, symbol) ? CURRENCIES[chainId][symbol] : undefined; + if (!entry) throw new Error("This token is not supported on this bridge network."); + return { ...entry[side] }; +} +/** The ERC-20 a chain's bridge can send (it needs an exact approval first), or null where every asset is native. */ +export function bridgeTokenAsset(chainId: BridgeChainId): BridgeAsset | null { + return BRIDGE_ASSETS[chainId].find((asset) => CURRENCIES[chainId][asset]!.input.address !== NATIVE_ADDRESS) ?? null; +} +/** USDC and USDG are both dollars: one unit of either is compared with one unit of the other. */ +export function isDollarAsset(asset: BridgeAsset): boolean { + return asset === "USDC" || asset === "USDG"; } /** The original Arc journal recorded native (18-decimal) deposits, not ERC-20 units. */ diff --git a/app/src/lib/bridge/validation.ts b/app/src/lib/bridge/validation.ts index 7491af64..67e541d6 100644 --- a/app/src/lib/bridge/validation.ts +++ b/app/src/lib/bridge/validation.ts @@ -1,7 +1,7 @@ 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, bridgeFeePercent, isBridgeAssetSupported, isBridgeChainId, type BridgeCurrency, type BridgeChainId, type BridgeQuote, type BridgeQuoteRejection, type BridgeQuoteRequest, type BridgeStatus, type BridgeStatusResponse } from "./types"; +import { BRIDGE_CHAINS, bridgeCurrency, bridgeFeePercent, isBridgeAssetSupported, isBridgeChainId, isDollarAsset, type BridgeCurrency, type BridgeChainId, type BridgeQuote, type BridgeQuoteRejection, 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. @@ -22,7 +22,6 @@ 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; @@ -89,7 +88,7 @@ export function validateRelayChains(value: unknown, route: Pick [BRIDGE_CHAINS[id].key, "ethereum-vm" as const])), solverAddresses: solvers as string[] }; } -/** Implements Relay's protocol.v2 input validation for allowlisted ETH / USDC. */ +/** Implements Relay's protocol.v2 input validation for the allowlisted assets (types.ts): ETH, USDC and USDG. */ export function validateRelayQuote(value: unknown, input: BridgeQuoteRequest, chains: RelayChainMetadata, now = Date.now()): BridgeQuote { const quote = object(value); const protocol = object(object(quote.protocol).v2); @@ -184,7 +183,7 @@ export function validateRelayQuote(value: unknown, input: BridgeQuoteRequest, ch 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] }); + checkCurrency(fees.gas, input.originChainId, gasAmount, { address: zeroAddress, decimals: 18, symbol: BRIDGE_CHAINS[input.originChainId].symbol }); ensure(BigInt(relayerAmount) < BigInt(input.amount)); // Same-symbol USDC still has 6/18-decimal interfaces. Cross multiplication // preserves exact fee equality without truncating either amount. ETH↔USDC @@ -212,6 +211,12 @@ export function validateRelayQuote(value: unknown, input: BridgeQuoteRequest, ch ? "This quote charges more than 5% in bridge fees. Try a different amount or wait for a better quote." : "This quote loses more than 5% in fees and price impact. Try a different amount or wait for a better quote.", 422, quoteRejection); } + // USDC and USDG are both dollars, so their units are compared directly, without the provider's own price estimate: + // a dollar-to-dollar route never guarantees less than 95% of what is sent, nor claims to deliver more than 105%. + if (!sameAsset && isDollarAsset(inputCurrency.symbol) && isDollarAsset(outputCurrency.symbol)) { + const sent = BigInt(input.amount) * outputScale; + ensure(BigInt(minimumAmountOut) * inputScale * 100n >= sent * 95n && BigInt(amountOut) * inputScale * 100n <= sent * 105n); + } return { ...input, requestId: step.requestId, amountOut, minimumAmountOut, relayFee, sourceGas, totalImpactPercent, diff --git a/docs/bridge.md b/docs/bridge.md index 0bd25f99..63c9e947 100644 --- a/docs/bridge.md +++ b/docs/bridge.md @@ -1,11 +1,11 @@ # 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. +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 or USDG (Global Dollar, the chain's main stablecoin and the launch form's default quote there). Arc receives native USDC and sends USDC through its ERC-20 interface. Relay converts between ETH, USDC and USDG 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. +- `POST /api/bridge/quote` accepts `{ address, originChainId, destinationChainId, originAsset, destinationAsset, amount }`. Asset choices are allowlisted `ETH`, `USDC` or `USDG` 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 and USDG inputs**. Outputs use 6 decimals for Base USDC and Robinhood USDG, 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. @@ -32,8 +32,8 @@ An interrupted wallet response is **uncertain**, not failed. The app must not si ## 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. +- Three mainnet networks, same-wallet recipient only. ETH on Base/Robinhood, USDC on Base/Arc and USDG on Robinhood. 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 or loosen safeguards without fresh route validation and separately reviewed support. USDG is offered as its own named asset, never as a silent stand-in for USDC. - 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. @@ -55,6 +55,15 @@ These tests never connect a wallet or submit a transaction. `/ui-review-bridge` is a development-only visual fixture with disconnected, quote, quoting, high-fee, expired, error, pending, success, uncertain and refund states, plus approval/recovery states. Its synthetic data cannot execute a transaction, and the route returns 404 outside development. The real header action uses the actual integration. +### USDG on Robinhood Chain (2026-11-10) + +- USDG is `0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168` on Robinhood Chain: read on-chain as symbol `USDG`, name `Global Dollar`, 6 decimals, and the same contract the launch form already prices in (`lib/launchpad/config.ts`; a test keeps the two equal). It is issued by Paxos ([USDG mainnet addresses](https://docs.paxos.com/guides/stablecoin/usdg/mainnet)) and is one of the two currencies Relay's solver holds on that chain. +- Six routes are new, sixteen in all: USDG to and from Base ETH, Base USDC and Arc USDC. Each returns the same direct-deposit order as the existing routes (`depositNative`, or an exact approval then `depositErc20`), so no rule was loosened to admit them. +- A USDG source works like Base USDC: an exact approval of USDG to the pinned depository as its own wallet action, a fresh quote, then the deposit, with ETH kept separately for gas. Approvals are journaled per chain and each chain has one ERC-20 (USDC on Base and Arc, USDG on Robinhood); a test fails if a chain ever gets a second. +- USDC and USDG are both dollars, so a dollar-to-dollar route is also checked in units, without the provider's price estimate: the guaranteed minimum is never under 95% of the amount sent and the expected output never over 105%. +- Changing a network keeps a dollar a dollar where it can: Base or Arc USDC becomes Robinhood USDG, and back. The amount is still cleared. +- All sixteen directed routes passed the read-only live tests through the unchanged validator. Browser check: the Robinhood token menu lists ETH and USDG, and picking USDG shows the conversion note. No wallet signed anything; a small real transfer in each direction is still needed before this is described as exercised. + ### Relay router v3.2 (2026-11-10) - Relay made its v3.2 router (`0x0729e2a4716d9f7dd17858d5d504426d228697f7`) the default on 2026-10-26: Get Chains reports it as `contracts.erc20Router` and every order names it in `extraData`. Only v3 (`0xb92fe925dc43a0ecde6c8b1a2709c170ec4fff4f`) was pinned, so from that day every quote was refused as unverifiable and the bridge could not be used. Nothing was sent: the check fails closed. From c16db8b96862564eb7648660ce206739c9390771 Mon Sep 17 00:00:00 2001 From: Vasanth T Date: Tue, 10 Nov 2026 16:59:39 +0530 Subject: [PATCH 03/17] feat(bridge): fund the launch chains from Ethereum, Arbitrum and Optimism The bridge now takes money in from Ethereum (1), Arbitrum (42161) and Optimism (10) to Base, Robinhood Chain and Arc. The three are funding sources only: nothing is ever bridged to them. - types: funding networks with ETH and Circle's native USDC (checked on-chain, never USDC.e); BRIDGE_DESTINATION_IDS, BRIDGE_FUNDING_IDS, isBridgeDestination(), isBridgeFundingChain() - validation and client: a request, quote, fee rejection, saved transfer or status naming a funding network as destination is refused - route changes: no reverse from a funding network; picking the destination as the source moves the destination to another launch chain instead of swapping a funding network into it - wallet config adds the three chains; their reads go through /api/rpc?chain=ethereum|arbitrum|optimism (ETHEREUM_RPC_URL etc., else public nodes) with the same read-only allowlist and per-IP budget - panel: "Fund from" group in the source picker, launch chains only in the destination picker, a "Send from" shortcut when the wallet sits on a funding network; wallet menu shows no unsupported warning for them - tests: 30 new routes verify (46 in all) with exact approvals of each network's own USDC, token substitution refused, catalogue drift fails closed, proxy upstreams and unknown keys; live suite 46/46 - docs/bridge.md and .env.example --- app/.env.example | 5 ++ app/src/app/api/rpc/route.test.ts | 34 +++++++++++ app/src/app/api/rpc/route.ts | 8 ++- app/src/app/ui-review-bridge/BridgeReview.tsx | 4 +- app/src/components/WalletMenu.tsx | 9 +-- .../components/bridge/BridgeDialog.module.css | 5 ++ app/src/components/bridge/BridgeDialog.tsx | 45 ++++++++++---- app/src/components/bridge/BridgeProvider.tsx | 2 +- app/src/components/bridge/bridge-ui.test.ts | 11 +++- app/src/components/bridge/useBridge.ts | 3 +- app/src/components/wallet-menu.test.ts | 5 +- app/src/lib/bridge/chains.ts | 16 ++++- app/src/lib/bridge/client-chains.test.ts | 18 ++++-- app/src/lib/bridge/client.test.ts | 33 +++++++++-- app/src/lib/bridge/client.ts | 24 ++++++-- app/src/lib/bridge/relay.fixture.ts | 8 +-- app/src/lib/bridge/relay.live.test.ts | 6 +- app/src/lib/bridge/relay.test.ts | 58 ++++++++++++++++++- app/src/lib/bridge/rpc.ts | 20 +++++++ .../lib/bridge/transaction-preflight.test.ts | 9 +-- app/src/lib/bridge/types.test.ts | 2 +- app/src/lib/bridge/types.ts | 46 ++++++++++++--- app/src/lib/bridge/validation.test.ts | 5 +- app/src/lib/bridge/validation.ts | 12 ++-- app/src/lib/wagmi.ts | 14 +++-- docs/bridge.md | 16 +++-- 26 files changed, 334 insertions(+), 84 deletions(-) create mode 100644 app/src/lib/bridge/rpc.ts diff --git a/app/.env.example b/app/.env.example index 5c2f0c4b..97d4d716 100644 --- a/app/.env.example +++ b/app/.env.example @@ -22,6 +22,11 @@ ROBINHOOD_RPC_URL= # (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 +# Bridge funding networks, read through /api/rpc only (balances, allowances, gas before a deposit). Unset, they use +# ethereum-rpc.publicnode.com, arb1.arbitrum.io/rpc and mainnet.optimism.io; the same Alchemy key covers all three. +# ETHEREUM_RPC_URL= +# ARBITRUM_RPC_URL= +# OPTIMISM_RPC_URL= # Postgres (schema in db/schema.sql; `npm run migrate` applies it idempotently) DATABASE_URL=postgres://localhost/openlaunch_dev diff --git a/app/src/app/api/rpc/route.test.ts b/app/src/app/api/rpc/route.test.ts index 4a50b677..34a1f193 100644 --- a/app/src/app/api/rpc/route.test.ts +++ b/app/src/app/api/rpc/route.test.ts @@ -203,6 +203,40 @@ test("Arc routing uses its configured server upstream with the same read-only al assert.equal(body[1].error.code, -32601); }); +test("bridge funding networks read through public nodes by default, a keyed URL when set, with the same allowlist", async (t) => { + const envs = ["ETHEREUM_RPC_URL", "ARBITRUM_RPC_URL", "OPTIMISM_RPC_URL"] as const; + const previous = Object.fromEntries(envs.map((name) => [name, process.env[name]])); + t.after(() => { for (const name of envs) { if (previous[name] === undefined) delete process.env[name]; else process.env[name] = previous[name]; } }); + for (const name of envs) delete process.env[name]; + const seen: unknown[] = []; + t.mock.method(globalThis, "fetch", async (input: unknown, init?: RequestInit) => { + seen.push(input); + const calls = upstreamBody(init); + assert.deepEqual(calls.map((r) => r.method), ["eth_getBalance"]); + return Response.json(calls.map((r) => result(r.id, "0x1"))); + }); + for (const chain of ["ethereum", "arbitrum", "optimism"]) { + const response = await POST(request([call(1, "eth_getBalance"), call(2, "eth_sendRawTransaction")], chain)); + assert.equal(response.status, 200, chain); + const body = await response.json(); + assert.deepEqual(body[0], result(1, "0x1")); + assert.equal(body[1].error.code, -32601, chain); + } + assert.deepEqual(seen, ["https://ethereum-rpc.publicnode.com", "https://arb1.arbitrum.io/rpc", "https://mainnet.optimism.io"]); + process.env.ARBITRUM_RPC_URL = "https://arb-rpc.example.test/private-test-key"; + await POST(request([call(1, "eth_getBalance")], "arbitrum")); + assert.equal(seen.at(-1), "https://arb-rpc.example.test/private-test-key"); +}); + +test("an unknown chain key is refused before any upstream call", async (t) => { + const upstream = t.mock.method(globalThis, "fetch", async () => Response.json(result(1))); + for (const chain of ["polygon", "mainnet", "__proto__", "constructor", "1", "ETHEREUM"]) { + const response = await POST(request(call(1, "eth_chainId"), chain)); + assert.equal(response.status, 400, chain); + } + assert.equal(upstream.mock.callCount(), 0); +}); + 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"))); diff --git a/app/src/app/api/rpc/route.ts b/app/src/app/api/rpc/route.ts index 547bd818..3a9ef8aa 100644 --- a/app/src/app/api/rpc/route.ts +++ b/app/src/app/api/rpc/route.ts @@ -3,6 +3,7 @@ import { b20RpcUrl, rpcUrl } from "@/lib/chain"; import { responseHasB20Error } from "@/lib/launchpad/baseStocks"; import { CHAINS, DEFAULT_CHAIN, isChainKey } from "@/lib/chainPublic"; import { upstreamStatus } from "@/lib/rpc-proxy"; +import { bridgeFundingUpstream } from "@/lib/bridge/rpc"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -64,9 +65,10 @@ function safeJson(text: string): unknown { export async function POST(req: Request) { const c = new URL(req.url).searchParams.get("chain") ?? DEFAULT_CHAIN; // every chain: the same read allowlist and per-IP bucket, its own upstream (the keyed *_RPC_URL, else the chain's - // official public node) so browsers never hit a public RPC directly and the API key stays server-side - if (!isChainKey(c)) return NextResponse.json({ error: "bad chain" }, { status: 400 }); - const upstream = rpcUrl(c) ?? CHAINS[c].rpcUrls.default.http[0]; + // official public node) so browsers never hit a public RPC directly and the API key stays server-side. The bridge's + // funding networks (ethereum, arbitrum, optimism) are read here too: balances, allowances and gas before a deposit. + const upstream = isChainKey(c) ? (rpcUrl(c) ?? CHAINS[c].rpcUrls.default.http[0]) : bridgeFundingUpstream(c); + if (upstream === null) return NextResponse.json({ error: "bad chain" }, { status: 400 }); 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" } }); diff --git a/app/src/app/ui-review-bridge/BridgeReview.tsx b/app/src/app/ui-review-bridge/BridgeReview.tsx index 419241a0..28e3ca2b 100644 --- a/app/src/app/ui-review-bridge/BridgeReview.tsx +++ b/app/src/app/ui-review-bridge/BridgeReview.tsx @@ -6,7 +6,7 @@ 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 { canReverseBridgeRoute, changeBridgeRoute, parseBridgeAmount, type BridgeRouteChange, type BridgeRouteInputs } from "@/lib/bridge/client"; import { bridgeCurrency, bridgeFeePercent, defaultBridgeAsset, type BridgeQuote } from "@/lib/bridge/types"; import type { TrackedApproval } from "@/lib/bridge/approval"; import { describePendingApproval } from "@/lib/bridge/approval-health"; @@ -55,7 +55,7 @@ export default function BridgeReview() { const rejectedFee = scene === "fee_boundary" ? inputAmount / 20n + 1n : scene === "impact_limit" ? inputAmount / 400n : inputAmount * 55548n / 1_000_000n; 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, + originChainId: origin, destinationChainId: destination, setOriginChainId: (chainId) => changeRoute({ side: "origin", chainId }), setDestinationChainId: (chainId) => changeRoute({ side: "destination", chainId }), reverseRoute: () => changeRoute({ side: "reverse" }), canReverse: canReverseBridgeRoute({ originChainId: origin }), 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, diff --git a/app/src/components/WalletMenu.tsx b/app/src/components/WalletMenu.tsx index c4a8a230..87c6f7db 100644 --- a/app/src/components/WalletMenu.tsx +++ b/app/src/components/WalletMenu.tsx @@ -5,7 +5,7 @@ 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_LABELS, CHAIN_SHORT, chainKeyOf, explorerAddress, explorerName, shortAddr, type ChainKey } from "@/lib/chainPublic"; +import { CHAIN_LABELS, CHAIN_SHORT, chainKeyOf, explorerAddress, explorerName, isChainKey, shortAddr, type ChainKey } from "@/lib/chainPublic"; import { BRIDGE_CHAINS, isBridgeChainId } from "@/lib/bridge/types"; import { VISIBLE_CHAINS } from "@/lib/launchpad/config"; import WalletAvatar from "./WalletAvatar"; @@ -36,8 +36,9 @@ function NetworkLogo({ chain }: { chain: ChainKey }) { * network the site doesn't support. The chip's name for it pushed the Launch button out of the header, so only the * mobile row spells it out. */ -function NetworkBadge({ chain }: { chain: ChainKey | null }) { - if (!chain) return !; +/** A launch chain shows its logo; a bridge funding network (Ethereum, Arbitrum, Optimism) shows none; anything else warns. */ +function NetworkBadge({ chain, known = false }: { chain: ChainKey | null; known?: boolean }) { + if (!chain) return known ? null : !; return ; } @@ -114,7 +115,7 @@ function AccountMenu({ address, chainId, connectorName, block = false, switching title={block ? undefined : key || bridgeNetwork ? `On ${network}` : "Unsupported network. Open to switch"} data-unsupported={(!key && !bridgeNetwork) || undefined} > - {block ? : } + {block ? : } {shortAddr(address)} {block ? {key || bridgeNetwork ? network : "Switch"} : null} diff --git a/app/src/components/bridge/BridgeDialog.module.css b/app/src/components/bridge/BridgeDialog.module.css index 69a78d6a..24f2156d 100644 --- a/app/src/components/bridge/BridgeDialog.module.css +++ b/app/src/components/bridge/BridgeDialog.module.css @@ -31,6 +31,11 @@ .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; } +.networkGroupLabel { margin: 0; padding: 12px 10px 6px; border-top: 1px solid var(--color-line); color: var(--color-muted); font-size: 12px; font-weight: 500; } +.letterMark { display: grid; place-items: center; width: 28px; height: 28px; border-radius: 50%; background: var(--mark-fill); box-shadow: 0 0 0 1.5px var(--mark-ring); color: #fff; font-size: 9px; font-weight: 700; letter-spacing: .02em; } +.walletHint { display: block; width: 100%; margin-top: 12px; padding: 9px 12px; border: 1px dashed var(--color-line-strong); border-radius: 10px; color: var(--color-body); font-size: 12px; text-align: left; cursor: pointer; transition: color 160ms, background 160ms; } +.walletHint:not(:disabled):hover { color: var(--color-ink); background: var(--color-paper); } +.walletHint:disabled { cursor: default; opacity: .6; } .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); } diff --git a/app/src/components/bridge/BridgeDialog.tsx b/app/src/components/bridge/BridgeDialog.tsx index 9daa0717..aa742a77 100644 --- a/app/src/components/bridge/BridgeDialog.tsx +++ b/app/src/components/bridge/BridgeDialog.tsx @@ -6,7 +6,7 @@ import { Dialog } from "@base-ui/react/dialog"; import { Select } from "@base-ui/react/select"; import { ArrowLeftRight, ArrowRight, ArrowUpRight, Check, ChevronDown, ChevronRight, CircleAlert, CircleDollarSign, Clock3, LoaderCircle, Wallet, X } from "lucide-react"; import { formatEther, formatUnits, zeroAddress } from "viem"; -import { BRIDGE_ASSETS, BRIDGE_ASSET_NAMES, BRIDGE_CHAINS, BRIDGE_CHAIN_IDS, bridgeCurrency, bridgeTokenAsset, bridgeTransferInputCurrency, isBridgeAssetSupported, isBridgeChainId, type BridgeAsset, type BridgeChainId, type BridgeQuoteRejection } from "@/lib/bridge/types"; +import { BRIDGE_ASSETS, BRIDGE_ASSET_NAMES, BRIDGE_CHAINS, BRIDGE_CHAIN_IDS, BRIDGE_DESTINATION_IDS, bridgeCurrency, bridgeTokenAsset, bridgeTransferInputCurrency, isBridgeAssetSupported, isBridgeChainId, isBridgeFundingChain, type BridgeAsset, type BridgeChainId, type BridgeQuoteRejection } from "@/lib/bridge/types"; import { DISCARD_AFTER_MS } from "@/lib/bridge/client"; import { formatFeeWarningPercent } from "@/lib/bridge/fee-display"; import { shortAddr } from "@/lib/chainPublic"; @@ -16,7 +16,14 @@ 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 })); +// Money leaves from any network, funding ones included, but arrives only on a launch chain. +const SOURCE_CHAINS: readonly BridgeChainId[] = BRIDGE_CHAIN_IDS; +const DESTINATION_CHAINS: readonly BridgeChainId[] = BRIDGE_DESTINATION_IDS; +// Funding networks without a bundled logo get a lettermark in the network's own colours. +const LETTERMARKS: Partial> = { + 42161: { text: "ARB", fill: "#213147", ring: "#12aaff" }, + 10: { text: "OP", fill: "#ff0420", ring: "#ff0420" }, +}; function nativeAmount(value: string | bigint): string { const text = typeof value === "bigint" ? formatEther(value) : value; @@ -26,6 +33,9 @@ function nativeAmount(value: string | bigint): string { } function NetworkMark({ chain }: { chain: BridgeChainId }) { + const letters = LETTERMARKS[chain]; + if (letters) return {letters.text}; + if (chain === 1) return ; return {chain === 8453 ? : chain === 5042 ? @@ -70,8 +80,19 @@ function AssetSelect({ label, chain, asset, disabled, onChange }: { label: "Send function NetworkSelect({ label, chain, disabled, onChange }: { label: "From" | "To"; chain: BridgeChainId; disabled: boolean; onChange: (chain: BridgeChainId) => void }) { const [instant, setInstant] = useState(false); + const chains = label === "From" ? SOURCE_CHAINS : DESTINATION_CHAINS; + const launch = chains.filter((id) => !isBridgeFundingChain(id)); + const funding = chains.filter(isBridgeFundingChain); + const option = (id: BridgeChainId) => + + + {BRIDGE_CHAINS[id].name} + {BRIDGE_CHAINS[id].symbol} · Gas token + + + ; return
- ({ value: id, label: BRIDGE_CHAINS[id].name }))} disabled={disabled} onValueChange={(value) => { if (isBridgeChainId(value)) onChange(value); }} onOpenChange={(_, details) => setInstant(details.event.type.startsWith("key"))}> {label} network @@ -88,14 +109,11 @@ function NetworkSelect({ label, chain, disabled, onChange }: { label: "From" | "

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

- {BRIDGE_CHAIN_IDS.map((id) => - - - {BRIDGE_CHAINS[id].name} - {BRIDGE_CHAINS[id].symbol} · Gas token - - - )} + {launch.map(option)} + {funding.length > 0 ? + Fund from + {funding.map(option)} + : null}
@@ -191,6 +209,8 @@ export function BridgeForm({ bridge: b, connect }: { bridge: Bridge; connect: () const outputCurrency = bridgeCurrency(b.destinationChainId, b.destinationAsset, "output"); const erc20Input = inputCurrency.address !== zeroAddress; const convertsAsset = inputCurrency.symbol !== outputCurrency.symbol; + // a wallet sitting on a funding network most likely wants to send from it + const walletFunding = b.address && isBridgeFundingChain(b.walletChainId) && b.walletChainId !== b.originChainId ? b.walletChainId : null; const outputAmount = (value: string) => nativeAmount(formatUnits(BigInt(value), outputCurrency.decimals)); const label = b.approvalBusy ? `Confirm ${inputCurrency.symbol} approval…` : b.allowanceLoading ? `Checking ${inputCurrency.symbol} permission…` : b.quoteLoading ? "Getting your quote…" : b.phase === "switching" ? "Confirm network switch…" @@ -209,9 +229,10 @@ export function BridgeForm({ bridge: b, connect }: { bridge: Bridge; connect: () }}>
- +
+ {walletFunding !== null ? : null} {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 and USDG are available.

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

{inputCurrency.symbol} approval confirmed. Review a fresh quote before bridging. No funds have been bridged yet.

: null} diff --git a/app/src/components/bridge/BridgeProvider.tsx b/app/src/components/bridge/BridgeProvider.tsx index 7228a71c..5e35c041 100644 --- a/app/src/components/bridge/BridgeProvider.tsx +++ b/app/src/components/bridge/BridgeProvider.tsx @@ -39,7 +39,7 @@ export function useOpenBridge(): (() => void) | null { export function BridgeButton({ block = false, compact = false, onOpen }: { block?: boolean; compact?: boolean; onOpen?: () => void }) { const show = useContext(BridgeContext); return ( -
{transferring ? "Your transfer, from departure to arrival." : "Same wallet. A new network."} - {transferring ? : } -
- Powered by Relay - 0 Openlaunch fee -
+ {content} + {footer} - {connecting ? { setConnecting(false); onOpenChange(true); }} /> : null} + {connecting ? { setConnecting(false); if (!onPage) onOpenChange(true); }} /> : null} ); } @@ -219,14 +255,20 @@ export function BridgeForm({ bridge: b, connect }: { bridge: Bridge; connect: () : reviewing && b.approvalRequired ? `Approve ${nativeAmount(b.amount)} ${inputCurrency.symbol}` : reviewing ? `Bridge to ${destination.name}` : b.quoteError ? "Retry quote" : !b.canQuote ? "Enter an amount" : "Get quote"; const error = (!b.quoteRejection && b.quoteError) || b.error || b.storageError || b.approvalError; + const mode = signalMode({ phase: b.phase, tracked: false, approvalActive: b.approvalBusy, quoteLoading: b.quoteLoading, reviewing, quoteExpired: needsRefresh, blocked: !!b.quoteRejection || !!b.quoteError }); + // the deposit, and only the deposit, can also be confirmed by sliding: never an approval, a quote or a refresh + const slideReady = !!b.address && reviewing && !b.approvalRequired && !loading && !b.allowanceLoading; + + // the button and the slide share this one path, with the same guards + function submit() { + if (!b.address) { connect(); return; } + if (loading || b.allowanceLoading || !b.canQuote) return; + void (reviewing ? b.approvalRequired ? b.approve() : b.confirm() : b.requestQuote()); + } return ( -
{ - event.preventDefault(); - if (!b.address) { connect(); return; } - if (loading || b.allowanceLoading || !b.canQuote) return; - void (reviewing ? b.approvalRequired ? b.approve() : b.confirm() : b.requestQuote()); - }}> + { event.preventDefault(); submit(); }}> +
@@ -271,7 +313,8 @@ export function BridgeForm({ bridge: b, connect }: { bridge: Bridge; connect: () {b.address ? : null} {error ?

{error}

: null} -
+ ); } diff --git a/app/src/app/ui-review-bridge/BridgeReview.tsx b/app/src/app/ui-review-bridge/BridgeReview.tsx index 28e3ca2b..bdde88c4 100644 --- a/app/src/app/ui-review-bridge/BridgeReview.tsx +++ b/app/src/app/ui-review-bridge/BridgeReview.tsx @@ -12,13 +12,31 @@ import type { TrackedApproval } from "@/lib/bridge/approval"; import { describePendingApproval } from "@/lib/bridge/approval-health"; import { BRIDGE_QUOTE_DEBOUNCE_MS } from "@/lib/bridge/quote-session"; import styles from "@/components/bridge/BridgeDialog.module.css"; +import { HistoryBoard } from "@/components/bridge/BridgeHistory"; +import type { BridgeHistoryEntry } from "@/lib/bridge/history"; const wallet = "0x03508bB71268BBA25ECaCC8F620e01866650532c" as const; const requestId = `0x${"1".repeat(64)}` as const; type Scene = "idle" | "disconnected" | "quote" | "quoting" | "high_fee" | "fee_boundary" | "impact_limit" | "expired" | "error" | "pending" | "success" | "uncertain" | "refund" | "approval_pending" | "approval_uncertain" | "approval_confirmed" | "approval_queued" | "approval_missing" | "approval_fee"; const scenes: Scene[] = ["disconnected", "quote", "quoting", "high_fee", "fee_boundary", "impact_limit", "expired", "error", "pending", "success", "uncertain", "refund", "approval_pending", "approval_uncertain", "approval_confirmed", "approval_queued", "approval_missing", "approval_fee"]; +const hash = (n: number) => `0x${n.toString(16).padStart(64, "0")}` as const; +const minutesAgo = (minutes: number) => Date.UTC(2026, 10, 10, 12, 0) - minutes * 60_000; +// One row per thing the board can say, on real routes and in real units. +const HISTORY_ROWS: BridgeHistoryEntry[] = [ + { requestId: hash(11), originChainId: 8453, destinationChainId: 4663, originAsset: "ETH", destinationAsset: "ETH", amountIn: "20000000000000000", inDecimals: 18, amountOut: "19962000000000000", outDecimals: 18, outKind: "quoted", status: "pending", reverted: false, createdAt: minutesAgo(1), sourceHash: hash(12), destinationHashes: [], recipient: null }, + { requestId: hash(21), originChainId: 42161, destinationChainId: 8453, originAsset: "USDC", destinationAsset: "USDC", amountIn: "25000000", inDecimals: 6, amountOut: "24975000", outDecimals: 6, outKind: "received", status: "success", reverted: false, createdAt: minutesAgo(48), sourceHash: hash(22), destinationHashes: [hash(23)], recipient: null }, + { requestId: hash(31), originChainId: 8453, destinationChainId: 4663, originAsset: "USDC", destinationAsset: "USDG", amountIn: "150000000", inDecimals: 6, amountOut: "149810000", outDecimals: 6, outKind: "received", status: "success", reverted: false, createdAt: minutesAgo(60 * 5), sourceHash: hash(32), destinationHashes: [hash(33)], recipient: null }, + { requestId: hash(41), originChainId: 8453, destinationChainId: 5042, originAsset: "USDC", destinationAsset: "USDC", amountIn: "60000000", inDecimals: 6, amountOut: "59940000000000000000", outDecimals: 18, outKind: "quoted", status: "delayed", reverted: false, createdAt: minutesAgo(60 * 9), sourceHash: hash(42), destinationHashes: [], recipient: null }, + { requestId: hash(51), originChainId: 1, destinationChainId: 5042, originAsset: "ETH", destinationAsset: "USDC", amountIn: "50000000000000000", inDecimals: 18, amountOut: "155300000000000000000", outDecimals: 18, outKind: "quoted", status: "refund", reverted: false, createdAt: minutesAgo(60 * 26), sourceHash: hash(52), destinationHashes: [], recipient: null }, + { requestId: hash(61), originChainId: 10, destinationChainId: 8453, originAsset: "ETH", destinationAsset: "ETH", amountIn: "8000000000000000", inDecimals: 18, amountOut: null, outDecimals: 18, outKind: null, status: "failure", reverted: true, createdAt: minutesAgo(60 * 50), sourceHash: hash(62), destinationHashes: [], recipient: null }, + { requestId: hash(71), originChainId: 4663, destinationChainId: 8453, originAsset: "ETH", destinationAsset: "ETH", amountIn: "4000000000000000", inDecimals: 18, amountOut: "3991000000000000", outDecimals: 18, outKind: "received", status: "success", reverted: false, createdAt: minutesAgo(60 * 80), sourceHash: hash(72), destinationHashes: [hash(73)], recipient: "0x9f3a2bE0c1D4e5F6a7b8C9d0E1f2A3b4C5d6E7F8" }, +]; +type HistoryScene = "rows" | "empty" | "loading" | "no wallet" | "unsaved"; +const HISTORY_SCENES: HistoryScene[] = ["rows", "empty", "loading", "no wallet", "unsaved"]; + export default function BridgeReview() { + const [historyScene, setHistoryScene] = useState("rows"); const [open, setOpen] = useState(false); const [scene, setScene] = useState("disconnected"); const [route, setRoute] = useState({ originChainId: 8453, destinationChainId: 5042, originAsset: "USDC", destinationAsset: "USDC", amount: "25" }); @@ -74,6 +92,7 @@ export default function BridgeReview() { canDiscard: scene === "uncertain", discard: async () => setScene("idle"), discarding: false, }; return ( + <>

Bridge visual review

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

@@ -87,6 +106,11 @@ export default function BridgeReview() {
Powered by Relay0 Openlaunch fee
+
{HISTORY_SCENES.map((value) => )}
+
+ {}} /> +
+ ); } diff --git a/app/src/components/bridge/BridgeDialog.tsx b/app/src/components/bridge/BridgeDialog.tsx index e12872ee..1af56fcb 100644 --- a/app/src/components/bridge/BridgeDialog.tsx +++ b/app/src/components/bridge/BridgeDialog.tsx @@ -205,7 +205,7 @@ export default function BridgeDialog({ open, onOpenChange, restoreFocus, slot = const footer = (
Powered by Relay - {onPage ? null : onOpenChange(false)}>Open full page} + {onPage ? null : onOpenChange(false)}>Your transfers} 0 Openlaunch fee
); diff --git a/app/src/components/bridge/BridgeHistory.tsx b/app/src/components/bridge/BridgeHistory.tsx new file mode 100644 index 00000000..8932eb48 --- /dev/null +++ b/app/src/components/bridge/BridgeHistory.tsx @@ -0,0 +1,128 @@ +"use client"; + +import { useEffect, useState, useSyncExternalStore } from "react"; +import { useAccount } from "wagmi"; +import { ArrowUpRight, RotateCw } from "lucide-react"; +import { BRIDGE_CHAINS, type BridgeChainId } from "@/lib/bridge/types"; +import { SIGNAL_CODES, SIGNAL_COLORS } from "@/lib/bridge/signal"; +import { BRIDGE_HISTORY_LIMIT, formatHistoryAmount, historyReceived, historySettled, historyStatus, mergeHistory, validateHistoryResponse, type BridgeHistoryEntry } from "@/lib/bridge/history"; +import { bridgeHistoryKey } from "@/lib/bridge/history-store"; +import { shortAddr } from "@/lib/chainPublic"; +import { bridgeLedger } from "./history-ledger"; +import { dotFont } from "./dot-font"; +import styles from "./History.module.css"; + +type Remote = { wallet: string; key: string; state: "off" | "on" | "error"; transfers: BridgeHistoryEntry[] }; +const NO_ROWS: BridgeHistoryEntry[] = []; +// A board shows the time large and the day under it, in the reader's own locale and a 24-hour clock. +const CLOCK = new Intl.DateTimeFormat(undefined, { hour: "2-digit", minute: "2-digit", hourCycle: "h23" }); +const DAY = new Intl.DateTimeFormat(undefined, { day: "numeric", month: "short" }); +const codeColor = (chain: BridgeChainId) => ({ "--code": SIGNAL_COLORS[chain].join(" ") }) as React.CSSProperties; + +/** + * "Your transfers" on /bridge: a departures board for the connected wallet. It reads this browser's ledger (written by + * the bridge controller) and, when the server has a Relay key, Relay's own list for the wallet. It never sends or + * changes anything, so it can sit beside the panel without a second bridge controller. + */ +export default function BridgeHistory() { + const { address } = useAccount(); + const wallet = address?.toLowerCase() ?? ""; + const snapshot = useSyncExternalStore(bridgeLedger.subscribe, bridgeLedger.getSnapshot, bridgeLedger.getServerSnapshot); + const local = snapshot.entries[wallet] ?? NO_ROWS; + const unsaved = !!snapshot.unsaved[wallet]; + const [revision, setRevision] = useState(0); + const [remote, setRemote] = useState(null); + // Ask Relay again whenever one of this browser's transfers settles, so its row picks up the delivered amount. + const settled = local.filter((row) => historySettled(row.status)).length; + const key = wallet ? `${wallet}:${revision}:${settled}` : ""; + + useEffect(() => { + if (!address) return; + bridgeLedger.read(address); + const onStorage = (event: StorageEvent) => { if (event.key === null || event.key === bridgeHistoryKey(address)) bridgeLedger.read(address); }; + window.addEventListener("storage", onStorage); + return () => window.removeEventListener("storage", onStorage); + }, [address, revision]); + + useEffect(() => { + if (!key) return; + const controller = new AbortController(); + fetch(`/api/bridge/history?address=${wallet}`, { cache: "no-store", signal: controller.signal }) + .then(async (response) => { + if (!response.ok) throw new Error("history"); + return validateHistoryResponse(await response.json()); + }) + .then((body) => setRemote({ wallet, key, state: body.relay, transfers: body.relay === "on" ? body.transfers : [] })) + .catch(() => { if (!controller.signal.aborted) setRemote((prior) => ({ wallet, key, state: "error", transfers: prior?.wallet === wallet ? prior.transfers : [] })); }); + return () => controller.abort(); + }, [key, wallet]); + + const current = remote?.wallet === wallet ? remote : null; + const loading = !!wallet && current?.key !== key; + const rows = mergeHistory(local, current?.transfers ?? NO_ROWS); + const note = !wallet ? "Transfers from the connected wallet appear here." + : current?.state === "on" ? `From Relay for this wallet, plus what this browser saved. The latest ${BRIDGE_HISTORY_LIMIT} are shown.` + : current?.state === "error" ? "Relay's list did not load, so this shows what this browser saved. Try again in a moment." + : current?.state === "off" ? "Saved in this browser. Transfers sent from another device or browser are not listed." + : "Loading your transfers…"; + + return setRevision((value) => value + 1)} />; +} + +/** The board itself, from plain props, so the development fixture can show every state without a wallet. */ +export function HistoryBoard({ wallet, rows, loading, note, unsaved, onRefresh }: { wallet: string; rows: readonly BridgeHistoryEntry[]; loading: boolean; note: string; unsaved: boolean; onRefresh: () => void }) { + return ( +
+
+
+

Your transfers

+

{note}

+
+ +
+
+ {!wallet ? + : rows.length === 0 ? (loading ? : ) + : <> +
TimeRouteSentReceivedStatus
+
    {rows.map((entry) => )}
+ } + {wallet && unsaved ?

This browser is not saving bridge history, so this list clears when you leave the page.

: null} +
+
+ ); +} + +function Empty({ title, body }: { title: string; body: string }) { + return
{title}{body}
; +} + +function Row({ entry }: { entry: BridgeHistoryEntry }) { + const origin = BRIDGE_CHAINS[entry.originChainId]; + const destination = BRIDGE_CHAINS[entry.destinationChainId]; + const status = historyStatus(entry); + const received = historyReceived(entry); + const when = new Date(entry.createdAt); + return ( +
  • + + + {SIGNAL_CODES[entry.originChainId]} + + {SIGNAL_CODES[entry.destinationChainId]} + {origin.name} to {destination.name} + + Sent{formatHistoryAmount(entry.amountIn, entry.inDecimals)} {entry.originAsset} + {received.label}{received.amount ?? received.note} + {status.label} + + Relay + {entry.sourceHash ? Source : null} + {entry.status === "success" && entry.destinationHashes[0] ? Destination : null} + + {entry.recipient ? Sent to {shortAddr(entry.recipient)}, not this wallet : null} +
  • + ); +} diff --git a/app/src/components/bridge/History.module.css b/app/src/components/bridge/History.module.css new file mode 100644 index 00000000..49cafe56 --- /dev/null +++ b/app/src/components/bridge/History.module.css @@ -0,0 +1,79 @@ +/* "Your transfers": a departures board built from the Signal board's materials. A dark plate in both themes, dot + lettering for what a station board shows (time, route, amounts, status) and plain text for links and notes. */ +.history { margin-top: 64px; scroll-margin-top: 104px; } +.head { display: flex; flex-wrap: wrap; align-items: flex-end; justify-content: space-between; gap: 12px 24px; margin-bottom: 14px; } +.head h2 { margin: 0; color: var(--color-ink); font-size: 17px; font-weight: 650; letter-spacing: -.02em; line-height: 1.3; text-wrap: balance; } +.head p { margin: 4px 0 0; max-width: 64ch; color: var(--color-muted); font-size: 13px; line-height: 1.5; } +.refresh { display: inline-flex; align-items: center; gap: 7px; min-height: 34px; padding: 0 14px; border: 1px solid var(--color-line-strong); border-radius: 999px; background: transparent; color: var(--color-ink); font: inherit; font-size: 13px; font-weight: 600; cursor: pointer; } +.refresh:hover:not(:disabled) { border-color: var(--color-ink); } +.refresh:disabled { opacity: .55; cursor: default; } +.refresh:focus-visible { outline: 2px solid var(--color-brand); outline-offset: 2px; } +.refresh[data-busy] svg { animation: history-spin 900ms linear infinite; } +@keyframes history-spin { to { transform: rotate(360deg); } } + +.board { border: 1px solid #1c1f27; border-radius: 18px; background: #050608; color: #c9d1dd; overflow: hidden; } +.columns, .row { display: grid; grid-template-columns: 76px minmax(170px, 1.2fr) minmax(0, 1fr) minmax(0, 1fr) 132px 236px; align-items: center; column-gap: 20px; padding-inline: 22px; } +.columns { padding-block: 13px 11px; color: #555e6e; font-family: var(--font-dots), var(--font-mono); font-size: 12px; font-weight: 800; font-variation-settings: "ROND" 100; letter-spacing: .08em; text-transform: uppercase; } +.list { margin: 0; padding: 0; list-style: none; } +.row { padding-block: 16px; border-top: 1px dashed #20242d; } + +.time { display: grid; gap: 3px; color: #c3cad6; font-family: var(--font-dots), var(--font-mono); font-size: 16px; font-weight: 800; font-variation-settings: "ROND" 100; font-variant-numeric: tabular-nums; white-space: nowrap; } +.time small { color: #5f6878; font-family: var(--font-sans); font-size: 11px; font-weight: 500; } +.route { display: flex; align-items: center; gap: 10px; min-width: 0; } +.code { color: rgb(var(--code)); font-family: var(--font-dots), var(--font-mono); font-size: 19px; font-weight: 800; font-variation-settings: "ROND" 100; letter-spacing: .06em; line-height: 1; text-shadow: 0 0 12px rgb(var(--code) / .45); } +/* the route between the two codes, drawn as a row of lamps in the row's status colour */ +.track { flex: 0 1 44px; min-width: 18px; height: 4px; color: #2c323d; background: radial-gradient(circle at center, currentColor 1.4px, transparent 1.9px) 0 50% / 6px 4px repeat-x; } +.row[data-tone="route"] .track { color: #6d9dff; animation: history-chase 900ms linear infinite; } +.row[data-tone="ok"] .track { color: #3ddc97; } +.row[data-tone="warn"] .track { color: #fbbf24; opacity: .75; } +@keyframes history-chase { to { background-position: 6px 50%; } } + +.amount { display: grid; gap: 3px; min-width: 0; color: #e7ecf3; font-family: var(--font-dots), var(--font-mono); font-size: 15px; font-weight: 800; font-variation-settings: "ROND" 100; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; } +.amount small { color: #5f6878; font-family: var(--font-sans); font-size: 11px; font-weight: 500; letter-spacing: 0; } +.amount[data-note] { color: #8b95a7; } +/* the column heading names these cells on wide screens; a quoted amount still says so, since it is not a delivery */ +@media (min-width: 861px) { + .sent small, .received:not([data-kind]) small { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; } + .received[data-kind] small { color: #6d9dff; } +} + +.status { display: inline-flex; align-items: center; gap: 8px; color: #8b95a7; font-family: var(--font-dots), var(--font-mono); font-size: 15px; font-weight: 800; font-variation-settings: "ROND" 100; letter-spacing: .08em; text-transform: uppercase; white-space: nowrap; } +.status::before { content: ""; flex: none; width: 7px; height: 7px; border-radius: 50%; background: currentColor; box-shadow: 0 0 10px currentColor; } +.row[data-tone="ok"] .status { color: #3ddc97; } +.row[data-tone="route"] .status { color: #6d9dff; } +.row[data-tone="route"] .status::before { animation: history-blink 1.1s steps(1, end) infinite; } +.row[data-tone="warn"] .status { color: #fbbf24; } +@keyframes history-blink { 50% { opacity: .2; } } + +.links { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 6px 14px; font-size: 12.5px; } +.links a { display: inline-flex; align-items: center; gap: 3px; color: #8b95a7; text-decoration: none; } +.links a:hover { color: #e7ecf3; } +.links a:focus-visible { border-radius: 4px; outline: 2px solid #6d9dff; outline-offset: 2px; } +.recipient { grid-column: 2 / -1; margin-top: 8px; color: #fbbf24; font-size: 12px; } + +.empty { display: grid; justify-items: center; gap: 8px; padding: 46px 20px 44px; text-align: center; } +.empty strong { color: #6d7a90; font-family: var(--font-dots), var(--font-mono); font-size: 24px; font-weight: 800; font-variation-settings: "ROND" 100; letter-spacing: .1em; text-transform: uppercase; } +.empty span { max-width: 46ch; color: #8b95a7; font-size: 13px; line-height: 1.5; } +.notice { margin: 0; padding: 11px 22px; border-top: 1px dashed #20242d; color: #fbbf24; font-size: 12.5px; line-height: 1.5; } + +@media (max-width: 860px) { + .history { margin-top: 48px; } + .columns { display: none; } + /* two equal halves, so a long link list or amount on one side never squeezes the other */ + .row { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); grid-template-areas: "route status" "sent received" "time links"; gap: 12px 16px; padding: 16px 18px; } + .row:first-child { border-top: 0; } + .route { grid-area: route; } + .status { grid-area: status; justify-self: end; } + .sent { grid-area: sent; } + .received { grid-area: received; justify-items: end; text-align: right; } + .time { grid-area: time; grid-auto-flow: column; justify-content: start; align-items: baseline; gap: 8px; font-size: 14px; } + .links { grid-area: links; } + .recipient { grid-column: 1 / -1; margin-top: 0; } +} +@media (max-width: 400px) { + .code { font-size: 17px; } + .status { font-size: 13px; } +} +@media (prefers-reduced-motion: reduce) { + .refresh[data-busy] svg, .row[data-tone="route"] .track, .row[data-tone="route"] .status::before { animation: none; } +} diff --git a/app/src/components/bridge/bridge-ui.test.ts b/app/src/components/bridge/bridge-ui.test.ts index e005bc1f..982f5188 100644 --- a/app/src/components/bridge/bridge-ui.test.ts +++ b/app/src/components/bridge/bridge-ui.test.ts @@ -210,6 +210,18 @@ test("stuck records have a bounded discard path, and the hook avoids APIs missin assert.match(hook, /const messageOf = bridgeErrorMessage;/); }); +test("the history board only reads: no second bridge, no recovery record, and the ledger is written by the controller alone", () => { + const board = read("./BridgeHistory.tsx"); + const hook = read("./useBridge.ts"); + const page = read("../../app/bridge/page.tsx"); + assert.doesNotMatch(board, /useBridge|client-storage|sendTransaction|writeContract|\.record\(|\.forget\(/); + assert.ok(board.includes("bridgeLedger.read(address)")); + assert.ok(page.includes("")); + assert.ok(hook.includes("try { bridgeLedger.record(tracked, quoted); } catch")); + assert.match(hook, /transfers\.remove\(address\);\s+try \{ bridgeLedger\.forget\(address, requestId\); \} catch/); + assert.ok(panel.includes(`href="/bridge#transfers"`)); +}); + test("quotes update without locking typing or automatically submitting wallet actions", () => { const hook = read("./useBridge.ts"); assert.match(panel, /useBridge\(open \|\| onPage\)/); diff --git a/app/src/components/bridge/history-ledger.ts b/app/src/components/bridge/history-ledger.ts new file mode 100644 index 00000000..42051db1 --- /dev/null +++ b/app/src/components/bridge/history-ledger.ts @@ -0,0 +1,4 @@ +import { createBridgeHistoryStore } from "@/lib/bridge/history-store"; + +/** The one history ledger, shared by the bridge controller (which writes it) and the history board (which reads it). */ +export const bridgeLedger = createBridgeHistoryStore(() => window.localStorage); diff --git a/app/src/components/bridge/useBridge.ts b/app/src/components/bridge/useBridge.ts index 981e5e92..5ef4b25b 100644 --- a/app/src/components/bridge/useBridge.ts +++ b/app/src/components/bridge/useBridge.ts @@ -11,6 +11,7 @@ import { parseBridgeQuoteRejection } from "@/lib/bridge/client"; import { createBridgeQuoteSession } from "@/lib/bridge/quote-session"; import { activityAfterWalletChange, anchorQuoteExpiry, bridgeErrorMessage, bridgeGasBudget, bridgeRequest, bridgeRequestKey, canReverseBridgeRoute, 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 { bridgeLedger } from "./history-ledger"; import { APPROVAL_STORAGE_PREFIX, approvalBlocksSubmission, approvalCanDiscard, canApplyApprovalPoll, createApprovalStore, reconcileApproval, recoverApprovalFromEvidence, submitExactApproval, validateApprovalMetadata, type ApprovalReceiptObservation } from "@/lib/bridge/approval"; import { APPROVAL_HEALTH_UNAVAILABLE, readPendingApprovalHealth, type ApprovalHealth } from "@/lib/bridge/approval-health"; import { assertBridgeWalletQueueClear } from "@/lib/bridge/transaction-preflight"; @@ -65,6 +66,8 @@ export function useBridge(open: boolean) { const [now, setNow] = useState(0); const [discarding, setDiscarding] = useState(false); const actionLock = useRef(false); + // The quote a deposit was sent from, so the history can show what it expected to deliver. + const sentQuote = useRef<{ requestId: string; amountOut: string } | null>(null); const [quoteSession] = useState(createBridgeQuoteSession); const inputs = useRef({ originChainId: 8453, destinationChainId: 4663, originAsset: "ETH", destinationAsset: "ETH", amount: "" }); const snapshot = useSyncExternalStore(transfers.subscribe, transfers.getSnapshot, transfers.getServerSnapshot); @@ -99,6 +102,14 @@ export function useBridge(open: boolean) { return () => window.removeEventListener("storage", onStorage); }, [address]); + // Remember every transfer that leaves the wallet, for the history board. Separate from the recovery record and best + // effort: nothing here can block, change or clear a transfer. + useEffect(() => { + if (!tracked) return; + const quoted = sentQuote.current?.requestId === tracked.requestId ? sentQuote.current.amountOut : null; + try { bridgeLedger.record(tracked, quoted); } catch { /* history only */ } + }, [tracked]); + // 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 @@ -481,6 +492,7 @@ export function useBridge(open: boolean) { setIssue(null); const reviewed = quote; const reviewedAt = envelope?.requestedAt ?? 0; + sentQuote.current = { requestId: reviewed.requestId, amountOut: reviewed.amountOut }; const operationKey = bridgeRequestKey(reviewed); setActivity({ key: operationKey, phase: "switching" }); // Never reuse this review after an interrupted/rejected confirmation. @@ -616,6 +628,7 @@ export function useBridge(open: boolean) { 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); + try { bridgeLedger.forget(address, requestId); } catch { /* history only */ } setObservation(null); invalidateQuote(); setStatusIssue(null); diff --git a/app/src/lib/bridge/history-store.ts b/app/src/lib/bridge/history-store.ts new file mode 100644 index 00000000..ade9be33 --- /dev/null +++ b/app/src/lib/bridge/history-store.ts @@ -0,0 +1,71 @@ +import type { Address, Hex } from "viem"; +import type { TrackedBridgeTransfer } from "./client"; +import { historyFromTransfer, parseHistoryLedger, serializeHistoryLedger, upsertHistory, type BridgeHistoryEntry } from "./history"; + +/** + * This browser's bridge history: a ledger kept beside the recovery record, never instead of it. The recovery record + * (client-storage.ts) guards against sending twice and is cleared when a transfer is done; this one only remembers. + * It is best effort by design: a storage failure here is reported, never thrown into a transfer. + */ +export const BRIDGE_HISTORY_PREFIX = "openlaunch.bridge.history.v1:"; +export const bridgeHistoryKey = (address: Address | string) => `${BRIDGE_HISTORY_PREFIX}${address.toLowerCase()}`; +type StorageAdapter = Pick; +export type HistorySnapshot = { entries: Record; unsaved: Record }; +const EMPTY: HistorySnapshot = { entries: {}, unsaved: {} }; + +export function createBridgeHistoryStore(storage: () => StorageAdapter) { + let snapshot = EMPTY; + const listeners = new Set<() => void>(); + function update(wallet: string, entries: BridgeHistoryEntry[], unsaved: boolean) { + const current = snapshot.entries[wallet]; + if (current && snapshot.unsaved[wallet] === unsaved && serializeHistoryLedger(current) === serializeHistoryLedger(entries)) return; + snapshot = { entries: { ...snapshot.entries, [wallet]: entries }, unsaved: { ...snapshot.unsaved, [wallet]: unsaved } }; + for (const notify of listeners) notify(); + } + function load(wallet: string): BridgeHistoryEntry[] { + const raw = storage().getItem(bridgeHistoryKey(wallet)); + if (raw === null) return []; + try { return parseHistoryLedger(raw); } catch { return []; } + } + function write(wallet: string, entries: BridgeHistoryEntry[]) { + try { + storage().setItem(bridgeHistoryKey(wallet), serializeHistoryLedger(entries)); + update(wallet, entries, false); + } catch { update(wallet, entries, true); } + } + return { + subscribe(notify: () => void) { listeners.add(notify); return () => { listeners.delete(notify); }; }, + getSnapshot: () => snapshot, + getServerSnapshot: () => EMPTY, + /** Re-read from storage, keeping rows only memory has (a write that failed) so nothing seen is lost. */ + read(address: Address): BridgeHistoryEntry[] { + const wallet = address.toLowerCase(); + let stored: BridgeHistoryEntry[]; + try { stored = load(wallet); } catch { update(wallet, snapshot.entries[wallet] ?? [], true); return snapshot.entries[wallet] ?? []; } + let rows = stored; + for (const entry of snapshot.entries[wallet] ?? []) rows = upsertHistory(rows, entry); + update(wallet, rows, snapshot.unsaved[wallet] ?? false); + return rows; + }, + /** Remember a tracked transfer once it has left the wallet; a no-op until then and when nothing changed. */ + record(transfer: TrackedBridgeTransfer, quotedOut: string | null = null) { + const entry = historyFromTransfer(transfer, quotedOut); + if (!entry) return; + const wallet = transfer.address.toLowerCase(); + let base: BridgeHistoryEntry[]; + try { base = load(wallet); } catch { base = []; } + for (const row of snapshot.entries[wallet] ?? []) base = upsertHistory(base, row); + const next = upsertHistory(base, entry); + if (snapshot.entries[wallet] && !snapshot.unsaved[wallet] && serializeHistoryLedger(next) === serializeHistoryLedger(snapshot.entries[wallet])) return; + write(wallet, next); + }, + /** Drop a row for a transfer that was discarded because it never left the wallet. */ + forget(address: Address, requestId: Hex) { + const wallet = address.toLowerCase(); + let base: BridgeHistoryEntry[]; + try { base = load(wallet); } catch { base = snapshot.entries[wallet] ?? []; } + const next = base.filter((row) => row.requestId.toLowerCase() !== requestId.toLowerCase()); + if (next.length !== base.length || (snapshot.entries[wallet] ?? []).some((row) => row.requestId.toLowerCase() === requestId.toLowerCase())) write(wallet, next); + }, + }; +} diff --git a/app/src/lib/bridge/history.test.ts b/app/src/lib/bridge/history.test.ts new file mode 100644 index 00000000..671dc53d --- /dev/null +++ b/app/src/lib/bridge/history.test.ts @@ -0,0 +1,280 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import type { Address, Hex } from "viem"; +import type { TrackedBridgeTransfer } from "./client.ts"; +import { BRIDGE_HISTORY_LIMIT, combineHistory, formatHistoryAmount, historyFromTransfer, historyReceived, historyStatus, mergeHistory, parseHistoryEntry, parseHistoryLedger, parseRelayHistory, relayHistoryEntry, serializeHistoryLedger, upsertHistory, validateHistoryResponse, type BridgeHistoryEntry } from "./history.ts"; +import { bridgeHistoryKey, createBridgeHistoryStore } from "./history-store.ts"; +import { BRIDGE_HISTORY_TTL_MS, BRIDGE_HISTORY_UPSTREAM_PER_MINUTE, getBridgeHistory } from "./relay.ts"; +import { ARBITRUM_USDC, ARC_USDC, BASE_USDC, ROBINHOOD_USDG, bridgeCurrencyAsset } from "./types.ts"; +import { GET } from "../../app/api/bridge/history/route.ts"; + +const WALLET = "0x1111111111111111111111111111111111111111" as Address; +const OTHER = "0x2222222222222222222222222222222222222222" as Address; +const hash = (n: number) => `0x${n.toString(16).padStart(64, "0")}` as Hex; +const NATIVE = "0x0000000000000000000000000000000000000000"; + +function tracked(overrides: Partial = {}): TrackedBridgeTransfer { + return { address: WALLET, originChainId: 42161, destinationChainId: 8453, originAsset: "USDC", destinationAsset: "USDC", amount: "25000000", requestId: hash(1), sourceHash: hash(2), destinationHashes: [], status: "pending", createdAt: 1_790_000_000_000, ...overrides }; +} + +function row(overrides: Partial = {}): BridgeHistoryEntry { + return { requestId: hash(1), originChainId: 42161, destinationChainId: 8453, originAsset: "USDC", destinationAsset: "USDC", amountIn: "25000000", inDecimals: 6, amountOut: "24970000", outDecimals: 6, outKind: "quoted", status: "pending", reverted: false, createdAt: 1_790_000_000_000, sourceHash: hash(2), destinationHashes: [], recipient: null, ...overrides }; +} + +const currency = (chainId: number, address: string, decimals: number, amount: string) => ({ currency: { chainId, address, symbol: "X", name: "X", decimals, metadata: {} }, amount, amountFormatted: "1", amountUsd: "1", minimumAmount: amount }); + +/** A GET /requests/v3 request in Relay's published shape, Arbitrum USDC to Base USDC unless overridden. */ +function relayRequest(overrides: Record = {}, route: { from?: unknown; to?: unknown; actualTo?: unknown } = {}) { + return { + id: hash(1), status: "success", user: WALLET, sender: WALLET, recipient: WALLET, refundTo: null, depositAddress: null, requestType: "Bridge", + data: { + slippageTolerance: "50", failReason: null, inTxs: [{ txHash: hash(2), chainId: 42161, status: "success" }], outTxs: [{ txHash: hash(3), chainId: 8453, status: "success" }], + route: { + quoted: { origin: { inputCurrency: route.from ?? currency(42161, ARBITRUM_USDC, 6, "25000000") }, destination: { outputCurrency: route.to ?? currency(8453, BASE_USDC, 6, "24970000") } }, + actual: { destination: { outputCurrency: route.actualTo ?? currency(8453, BASE_USDC, 6, "24975000") } }, + }, + }, + createdAt: "2026-11-10T09:30:00.000Z", updatedAt: "2026-11-10T09:30:20.000Z", + ...overrides, + }; +} + +function memoryStorage(fail = false) { + const items = new Map(); + return { items, adapter: { getItem: (key: string) => items.get(key) ?? null, setItem: (key: string, value: string) => { if (fail) throw new Error("quota"); items.set(key, value); } } }; +} + +test("a tracked transfer enters the history only once it has left the wallet", () => { + assert.equal(historyFromTransfer(tracked({ sourceHash: undefined, status: "waiting" })), null, "a cancelled or unsent wallet prompt is not a transfer"); + assert.equal(historyFromTransfer(tracked({ sourceHash: undefined, status: "uncertain" })), null); + const sent = historyFromTransfer(tracked(), "24970000")!; + assert.deepEqual(sent, row()); + assert.equal(historyFromTransfer(tracked(), "not a number")!.amountOut, null); + // settled by Relay without a wallet hash (recovered on another path) still counts + assert.equal(historyFromTransfer(tracked({ sourceHash: undefined, status: "success" }))!.status, "success"); + const reverted = historyFromTransfer(tracked({ status: "failure", failureReason: "source-reverted" }))!; + assert.equal(reverted.reverted, true); + // Arc's first journal entries were native 18-decimal USDC, and arrive on Arc as the 18-decimal native balance + const legacyArc = historyFromTransfer(tracked({ originChainId: 5042, destinationChainId: 8453, originAsset: undefined, destinationAsset: undefined, amount: "5000000000000000000" }))!; + assert.equal(legacyArc.inDecimals, 18); + assert.equal(legacyArc.originAsset, "USDC"); + assert.equal(legacyArc.destinationAsset, "ETH"); + assert.equal(historyFromTransfer(tracked({ originChainId: 8453, destinationChainId: 5042, originAsset: "USDC", destinationAsset: "USDC" }))!.outDecimals, 18); +}); + +test("a stored row is checked field by field and copied; anything off drops that row only", () => { + assert.deepEqual(parseHistoryEntry({ ...row(), extra: "