From 5f8852b611909e85ed9a058bf8923061c8cb88ba Mon Sep 17 00:00:00 2001 From: Vasanth T Date: Tue, 10 Nov 2026 16:43:39 +0530 Subject: [PATCH] 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.