diff --git a/app/.env.example b/app/.env.example index 2472366e..afe4dacf 100644 --- a/app/.env.example +++ b/app/.env.example @@ -25,6 +25,11 @@ ARC_RPC_URL= # Elysium: the public node (https://rpc.elysium.kinetiq.xyz, the chain default when unset) is shared; use Alchemy elysium-mainnet in production. ELYSIUM_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 2fe71b0c..1d1c63dc 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"; @@ -37,8 +37,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 ; } @@ -115,7 +116,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 ( -