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/public/brand/arbitrum.svg b/app/public/brand/arbitrum.svg new file mode 100644 index 00000000..00f30b9d --- /dev/null +++ b/app/public/brand/arbitrum.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/app/public/brand/optimism.svg b/app/public/brand/optimism.svg new file mode 100644 index 00000000..c809fd13 --- /dev/null +++ b/app/public/brand/optimism.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/app/api/bridge/history/route.ts b/app/src/app/api/bridge/history/route.ts new file mode 100644 index 00000000..917e4df1 --- /dev/null +++ b/app/src/app/api/bridge/history/route.ts @@ -0,0 +1,17 @@ +import { rateLimited } from "@/lib/launchpad/editServer"; +import { BRIDGE_PRIVATE_HEADERS, bridgeErrorResponse, getBridgeHistory } from "@/lib/bridge/relay"; +import { BridgeApiError } from "@/lib/bridge/validation"; +import { clientIp } from "@/lib/profiles/http"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +/** One wallet's bridge transfers from Relay, or `{ relay: "off" }` while no Relay API key is configured. */ +export async function GET(req: Request) { + if (rateLimited(`bridge:history:ip:${clientIp(req)}`, 30)) return bridgeErrorResponse(new BridgeApiError("Too many history requests. Please wait a moment.", 429)); + try { + const params = new URL(req.url).searchParams; + if (params.size !== 1 || !params.has("address")) throw new BridgeApiError("Invalid wallet address.", 400); + return Response.json(await getBridgeHistory(params.get("address")!), { headers: BRIDGE_PRIVATE_HEADERS }); + } catch (error) { return bridgeErrorResponse(error); } +} diff --git a/app/src/app/api/bridge/quote/route.ts b/app/src/app/api/bridge/quote/route.ts index a004575a..9823fc01 100644 --- a/app/src/app/api/bridge/quote/route.ts +++ b/app/src/app/api/bridge/quote/route.ts @@ -1,13 +1,13 @@ import { rateLimited } from "@/lib/launchpad/editServer"; import { BRIDGE_PRIVATE_HEADERS, bridgeErrorResponse, getBridgeQuote, readBridgeJson } from "@/lib/bridge/relay"; import { BridgeApiError, parseBridgeRequest } from "@/lib/bridge/validation"; +import { clientIp } from "@/lib/profiles/http"; export const dynamic = "force-dynamic"; export const runtime = "nodejs"; export async function POST(req: Request) { - const ip = (req.headers.get("fly-client-ip") || req.headers.get("x-forwarded-for") || "").split(",")[0].trim() || "0.0.0.0"; - if (rateLimited(`bridge:quote:ip:${ip}`, 20)) return bridgeErrorResponse(new BridgeApiError("Too many quotes. Please wait a moment.", 429)); + if (rateLimited(`bridge:quote:ip:${clientIp(req)}`, 20)) return bridgeErrorResponse(new BridgeApiError("Too many quotes. Please wait a moment.", 429)); try { if (req.headers.get("content-type")?.split(";", 1)[0].trim().toLowerCase() !== "application/json") throw new BridgeApiError("Send a JSON request.", 415); const length = req.headers.get("content-length"); diff --git a/app/src/app/api/bridge/status/route.ts b/app/src/app/api/bridge/status/route.ts index 118caaae..85466cb9 100644 --- a/app/src/app/api/bridge/status/route.ts +++ b/app/src/app/api/bridge/status/route.ts @@ -1,15 +1,18 @@ import { rateLimited } from "@/lib/launchpad/editServer"; import { BRIDGE_PRIVATE_HEADERS, bridgeErrorResponse, getBridgeStatus } from "@/lib/bridge/relay"; import { BridgeApiError } from "@/lib/bridge/validation"; +import { getCctpStatus, parseCctpStatusRequest } from "@/lib/bridge/cctp-server"; +import { clientIp } from "@/lib/profiles/http"; export const dynamic = "force-dynamic"; export const runtime = "nodejs"; export async function GET(req: Request) { - const ip = (req.headers.get("fly-client-ip") || req.headers.get("x-forwarded-for") || "").split(",")[0].trim() || "0.0.0.0"; - if (rateLimited(`bridge:status:ip:${ip}`, 100)) return bridgeErrorResponse(new BridgeApiError("Too many status requests. Please wait a moment.", 429)); + if (rateLimited(`bridge:status:ip:${clientIp(req)}`, 100)) return bridgeErrorResponse(new BridgeApiError("Too many status requests. Please wait a moment.", 429)); try { const params = new URL(req.url).searchParams; + // A Circle burn is found by its source transaction and checked against the wallet, amount and route it was for. + if (params.has("provider")) return Response.json(await getCctpStatus(parseCctpStatusRequest(params)), { headers: BRIDGE_PRIVATE_HEADERS }); if (params.size !== 1 || !params.has("requestId")) throw new BridgeApiError("Invalid bridge request ID.", 400); return Response.json(await getBridgeStatus(params.get("requestId")!), { headers: BRIDGE_PRIVATE_HEADERS }); } catch (error) { return bridgeErrorResponse(error); } diff --git a/app/src/app/api/rpc/route.test.ts b/app/src/app/api/rpc/route.test.ts 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/bridge/BridgePageLayout.tsx b/app/src/app/bridge/BridgePageLayout.tsx new file mode 100644 index 00000000..2dd1769e --- /dev/null +++ b/app/src/app/bridge/BridgePageLayout.tsx @@ -0,0 +1,85 @@ +import type { ReactNode } from "react"; +import { ArrowDown, ArrowLeftRight } from "lucide-react"; +import SectionIntro from "@/components/sections/SectionIntro"; +import shell from "@/components/sections/SectionShell.module.css"; +import { BRIDGE_CHAINS, BRIDGE_DESTINATION_IDS, BRIDGE_FUNDING_IDS, BRIDGE_RECEIVE_ASSETS, BRIDGE_SEND_ASSETS, type BridgeChainId } from "@/lib/bridge/types"; +import styles from "./bridge.module.css"; + +/** A launch chain's assets: what it sends and receives, then what it only receives (HYPE on Elysium). */ +function launchAssets(id: BridgeChainId) { + const both = BRIDGE_SEND_ASSETS[id].filter((asset) => BRIDGE_RECEIVE_ASSETS[id].includes(asset)); + const receiveOnly = BRIDGE_RECEIVE_ASSETS[id].filter((asset) => !BRIDGE_SEND_ASSETS[id].includes(asset)); + return <> +
{both.join(", ")}Send and receive
+ {receiveOnly.length > 0 ?
{receiveOnly.join(", ")}Receive only
: null} + ; +} + +const STEPS = [ + ["Pick a route and an amount", "A live quote appears with the fee, the gas and the least you will receive. Nothing is asked of your wallet yet."], + ["Approve, for tokens only", "USDC and USDG need an approval for the exact amount first. It is its own wallet step and moves nothing. ETH skips it."], + ["Confirm the deposit", "Slide the coin across or press the button, then confirm in your wallet. This is the one step that sends funds."], + ["Watch it arrive", "The board follows the transfer until the provider confirms delivery to the same address on the other network."], +] as const; + +export default function BridgePageLayout({ panel, history, controls, introActions }: { + panel: ReactNode; + history: ReactNode; + controls?: ReactNode; + introActions?: ReactNode; +}) { + return ( +
+ } + title="Move funds to where you launch" + description="Send ETH and dollars between Base, Robinhood Chain, Arc and Elysium, or bring them in from Ethereum, Arbitrum and Optimism. They arrive at the same wallet address. openlaunch charges nothing for it." + > + Your transfers + {introActions} + + {controls} +
+ {panel} +
+
+

Networks and tokens

+
+ {BRIDGE_DESTINATION_IDS.map((id) =>
{BRIDGE_CHAINS[id].name}
{launchAssets(id)}
)} + {BRIDGE_FUNDING_IDS.map((id) =>
{BRIDGE_CHAINS[id].name}
{BRIDGE_SEND_ASSETS[id].join(", ")}Send only
)} +
+
+
+

How a transfer goes

+
    + {STEPS.map(([title, body]) =>
  1. {title}{body}
  2. )} +
+
+
+

What it costs

+
    +
  • openlaunch takes no fee on a transfer.
  • +
  • The bridge provider charges a fee that is shown before you confirm and is already taken out of the amount you receive.
  • +
  • Relay quotes every route. For USDC to USDC into Base or Arc, Circle is quoted too, and you get whichever delivers more. The quote names which one.
  • +
  • Gas on the network you send from is extra.
  • +
  • A quote that would cost more than 5% in fees, or lose more than 5% in conversion, is blocked.
  • +
+
+
+

Good to know

+
    +
  • Elysium sends USDC only. Its gas token, HYPE, can be received there but not sent, so receive a little HYPE first if you have none to pay gas on Elysium.
  • +
  • Funds on an exchange? The panel's exchange guide shows where to withdraw them (Base, Ethereum, Arbitrum or Optimism), watches for them to land, then opens the bridge on that route.
  • +
  • Funds go from your wallet to Relay's deposit contract, or to Circle's transfer contract on a Circle route. They never pass through an openlaunch wallet or contract.
  • +
  • Relay and Circle are third parties. openlaunch operates neither and cannot reverse a transfer.
  • +
  • A transfer is saved in this browser while it is in progress. Reopen the bridge with the same wallet to pick it up.
  • +
  • Finished transfers stay on the board below. It never asks your wallet for anything.
  • +
+
+
+
+ {history} +
+ ); +} diff --git a/app/src/app/bridge/bridge.module.css b/app/src/app/bridge/bridge.module.css new file mode 100644 index 00000000..3dfd6d1e --- /dev/null +++ b/app/src/app/bridge/bridge.module.css @@ -0,0 +1,28 @@ +/* /bridge: the panel on the left at a comfortable reading width, the facts beside it; one column on small screens with the panel first. */ +.layout { display: grid; grid-template-columns: minmax(0, 520px) minmax(0, 1fr); align-items: start; gap: 40px 56px; } +.facts { display: grid; gap: 32px; min-width: 0; padding-top: 6px; } +.facts h2 { margin: 0 0 12px; color: var(--color-ink); font-size: 17px; font-weight: 650; letter-spacing: -.02em; line-height: 1.3; } + +.networks { display: grid; margin: 0; } +.networks div { display: grid; grid-template-columns: minmax(96px, 132px) minmax(0, 1fr); gap: 12px; padding: 10px 0; border-top: 1px solid var(--color-line); font-size: 14px; line-height: 1.5; } +.networks div:last-child { border-bottom: 1px solid var(--color-line); } +.networks dt { color: var(--color-ink); font-weight: 600; } +.networks dd + dd { grid-column: 2; } +.networks dd { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 4px 12px; margin: 0; color: var(--color-body); } +.networks dd span { color: var(--color-muted); font-size: 13px; } + +/* a real sequence: each step happens after the one before it */ +.steps { display: grid; gap: 14px; margin: 0; padding: 0; list-style: none; counter-reset: step; } +.steps li { display: grid; grid-template-columns: 26px minmax(0, 1fr); column-gap: 12px; counter-increment: step; font-size: 14px; line-height: 1.6; } +.steps li::before { content: counter(step); grid-row: span 2; display: grid; place-items: center; width: 26px; height: 26px; border: 1px solid var(--color-line-strong); border-radius: 50%; color: var(--color-ink); font-size: 12px; font-weight: 600; font-variant-numeric: tabular-nums; } +.steps strong { color: var(--color-ink); font-weight: 600; } +.steps span { color: var(--color-body); } + +.points { display: grid; gap: 8px; margin: 0; padding: 0; list-style: none; color: var(--color-body); font-size: 14px; line-height: 1.6; } +.points li { position: relative; padding-left: 16px; } +.points li::before { content: ""; position: absolute; left: 0; top: .68em; width: 5px; height: 5px; border-radius: 50%; background: var(--color-line-strong); } + +@media (max-width: 960px) { + .layout { grid-template-columns: minmax(0, 1fr); } + .layout > :first-child { max-width: 560px; } +} diff --git a/app/src/app/bridge/page.tsx b/app/src/app/bridge/page.tsx new file mode 100644 index 00000000..ff7a2863 --- /dev/null +++ b/app/src/app/bridge/page.tsx @@ -0,0 +1,27 @@ +import type { Metadata } from "next"; +import { pageMetadata } from "@/lib/seo"; +import BridgePageSlot from "@/components/bridge/BridgePageSlot"; +import BridgeHistory from "@/components/bridge/BridgeHistory"; +import { parseBridgeLink } from "@/lib/bridge/link"; +import BridgePageLayout from "./BridgePageLayout"; + +/** + * The bridge as a page: the same panel the header opens, with room for the facts beside it. The panel is rendered by + * the app's one bridge controller into the slot below (components/bridge/bridge-slot.ts), so this page starts no + * second copy. `?from=…&to=…&asset=…&toAsset=…` preselects a route (lib/bridge/link.ts) and nothing more. + */ +export const metadata: Metadata = pageMetadata({ + path: "/bridge", + title: "Bridge", + description: "Move ETH and dollars between Base, Robinhood Chain, Arc and Elysium, or fund them from Ethereum, Arbitrum and Optimism. Same wallet, no openlaunch fee.", +}); + +export default async function BridgePage({ searchParams }: { searchParams: Promise> }) { + const link = parseBridgeLink(await searchParams); + return ( + } + history={} + /> + ); +} diff --git a/app/src/app/layout.tsx b/app/src/app/layout.tsx index 5f927e71..09cb124a 100644 --- a/app/src/app/layout.tsx +++ b/app/src/app/layout.tsx @@ -5,6 +5,7 @@ import Web3Provider from "@/components/Web3Provider"; import Header from "@/components/Header"; import TxToasts from "@/components/launchpad/TxToasts"; import LiveProvider from "@/components/launchpad/LiveProvider"; +import { BridgeProvider } from "@/components/bridge/BridgeProvider"; import { getLaunchFeed, getLaunchTotalsForRequest } from "@/lib/launchpad/queries"; import { ethUsd } from "@/lib/launchpad/ethPrice"; import { boostEnabled } from "@/lib/launchpad/boostSwitch"; @@ -54,10 +55,13 @@ export default async function RootLayout({ children }: { children: React.ReactNo + {/* one bridge controller for the header, the search and every page (the token page's "bridge, then buy") */} +
{children}