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