Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
8d57664
fix(bridge): accept Relay's v3.2 router so quotes verify again
Vasanthdev2004 Nov 10, 2026
5f8852b
feat(bridge): USDG on Robinhood Chain, both directions
Vasanthdev2004 Nov 10, 2026
c16db8b
feat(bridge): fund the launch chains from Ethereum, Arbitrum and Opti…
Vasanthdev2004 Nov 10, 2026
7962779
feat(bridge): signal board, slide to confirm and a /bridge page
Vasanthdev2004 Nov 10, 2026
2506bf7
feat(bridge): transfer history on /bridge
Vasanthdev2004 Nov 10, 2026
3f0b6ce
feat(bridge): bridge, then buy from the token page
Vasanthdev2004 Nov 10, 2026
8b76281
feat(bridge): Circle CCTP as a second route for USDC
Vasanthdev2004 Nov 10, 2026
a74fe74
Merge main (Elysium) into fix/bridge-relay-router
Vasanthdev2004 Nov 10, 2026
9de9935
Merge fix/bridge-relay-router into feat/bridge-routes
Vasanthdev2004 Nov 10, 2026
925c4b3
Merge feat/bridge-routes into feat/bridge-funding-chains
Vasanthdev2004 Nov 10, 2026
da90f4d
Merge feat/bridge-funding-chains into feat/bridge-redesign
Vasanthdev2004 Nov 10, 2026
0b3e1cd
Merge feat/bridge-redesign into feat/bridge-history
Vasanthdev2004 Nov 10, 2026
bb84a77
Merge feat/bridge-history into feat/bridge-buy
Vasanthdev2004 Nov 10, 2026
5aae8ea
Merge feat/bridge-buy into feat/bridge-cctp
Vasanthdev2004 Nov 10, 2026
6aba54c
feat(bridge): Elysium routes for USDC
Vasanthdev2004 Nov 10, 2026
cd910df
fix(bridge): finish Elysium route polish
Vasanthdev2004 Nov 10, 2026
f63e890
feat(bridge): receive HYPE on Elysium
Vasanthdev2004 Nov 10, 2026
e1ec824
feat(bridge): guide funds in from an exchange
Vasanthdev2004 Nov 10, 2026
d9865c1
fix(bridge): polish the dialog and refresh the review page
Vasanthdev2004 Nov 10, 2026
5214041
Merge the dialog polish into the HYPE and exchange work
Vasanthdev2004 Nov 10, 2026
2cbf718
fix(bridge): cap Circle bursts and filter Relay history
Vasanthdev2004 Nov 10, 2026
3ff9aa0
fix(bridge): address review findings on recovery records and bridge-t…
Vasanthdev2004 Nov 10, 2026
116376b
fix(bridge): wait for the ledger before the buy card, and keep pollin…
Vasanthdev2004 Nov 10, 2026
a21a671
fix(bridge): mount the bridge provider around every page, not only th…
Vasanthdev2004 Nov 10, 2026
e6a2bda
Merge remote-tracking branch 'origin/main' into codex/bridge-pr135-only
Vasanthdev2004 Nov 12, 2026
f87ef08
fix(bridge): retain verified transfer recovery safeguards
Vasanthdev2004 Nov 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions app/.env.example
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
# Copy to .env.local for local dev. Never commit real values.

# Public site URL (used in metadata, image URLs, share cards)
Expand Down Expand Up @@ -25,6 +25,11 @@
# 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
Expand Down
5 changes: 5 additions & 0 deletions app/public/brand/arbitrum.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 6 additions & 0 deletions app/public/brand/optimism.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
17 changes: 17 additions & 0 deletions app/src/app/api/bridge/history/route.ts
Original file line number Diff line number Diff line change
@@ -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); }
}
4 changes: 2 additions & 2 deletions app/src/app/api/bridge/quote/route.ts
Original file line number Diff line number Diff line change
@@ -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");
Expand Down
7 changes: 5 additions & 2 deletions app/src/app/api/bridge/status/route.ts
Original file line number Diff line number Diff line change
@@ -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); }
Expand Down
34 changes: 34 additions & 0 deletions app/src/app/api/rpc/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,40 @@
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]]));

Check warning on line 208 in app/src/app/api/rpc/route.test.ts

View workflow job for this annotation

GitHub Actions / pr-guard

pr-guard: Code execution, raw HTML or shell

app/src/app/api/rpc/route.test.ts: an environment variable read by a computed name
t.after(() => { for (const name of envs) { if (previous[name] === undefined) delete process.env[name]; else process.env[name] = previous[name]; } });

Check warning on line 209 in app/src/app/api/rpc/route.test.ts

View workflow job for this annotation

GitHub Actions / pr-guard

pr-guard: Code execution, raw HTML or shell

app/src/app/api/rpc/route.test.ts: an environment variable read by a computed name
for (const name of envs) delete process.env[name];

Check warning on line 210 in app/src/app/api/rpc/route.test.ts

View workflow job for this annotation

GitHub Actions / pr-guard

pr-guard: Code execution, raw HTML or shell

app/src/app/api/rpc/route.test.ts: an environment variable read by a computed 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"]);

Check warning on line 225 in app/src/app/api/rpc/route.test.ts

View workflow job for this annotation

GitHub Actions / pr-guard

pr-guard: A host the repository has not used before

app/src/app/api/rpc/route.test.ts: ethereum-rpc.publicnode.com
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")));
Expand Down
8 changes: 5 additions & 3 deletions app/src/app/api/rpc/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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" } });
Expand Down
85 changes: 85 additions & 0 deletions app/src/app/bridge/BridgePageLayout.tsx
Original file line number Diff line number Diff line change
@@ -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 <>
<dd>{both.join(", ")}<span>Send and receive</span></dd>
{receiveOnly.length > 0 ? <dd>{receiveOnly.join(", ")}<span>Receive only</span></dd> : 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 (
<main className={shell.page}>
<SectionIntro
eyebrow="Bridge"
icon={<ArrowLeftRight />}
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."
>
<a href="#transfers" className={shell.textLink}>Your transfers <ArrowDown size={14} aria-hidden="true" /></a>
{introActions}
</SectionIntro>
{controls}
<div className={styles.layout}>
{panel}
<div className={styles.facts}>
<section aria-labelledby="bridge-networks">
<h2 id="bridge-networks">Networks and tokens</h2>
<dl className={styles.networks}>
{BRIDGE_DESTINATION_IDS.map((id) => <div key={id}><dt>{BRIDGE_CHAINS[id].name}</dt>{launchAssets(id)}</div>)}
{BRIDGE_FUNDING_IDS.map((id) => <div key={id}><dt>{BRIDGE_CHAINS[id].name}</dt><dd>{BRIDGE_SEND_ASSETS[id].join(", ")}<span>Send only</span></dd></div>)}
</dl>
</section>
<section aria-labelledby="bridge-steps">
<h2 id="bridge-steps">How a transfer goes</h2>
<ol className={styles.steps}>
{STEPS.map(([title, body]) => <li key={title}><strong>{title}</strong><span>{body}</span></li>)}
</ol>
</section>
<section aria-labelledby="bridge-costs">
<h2 id="bridge-costs">What it costs</h2>
<ul className={styles.points}>
<li>openlaunch takes no fee on a transfer.</li>
<li>The bridge provider charges a fee that is shown before you confirm and is already taken out of the amount you receive.</li>
<li>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.</li>
<li>Gas on the network you send from is extra.</li>
<li>A quote that would cost more than 5% in fees, or lose more than 5% in conversion, is blocked.</li>
</ul>
</section>
<section aria-labelledby="bridge-know">
<h2 id="bridge-know">Good to know</h2>
<ul className={styles.points}>
<li>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.</li>
<li>Funds on an exchange? The panel&apos;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.</li>
<li>Funds go from your wallet to Relay&apos;s deposit contract, or to Circle&apos;s transfer contract on a Circle route. They never pass through an openlaunch wallet or contract.</li>
<li>Relay and Circle are third parties. openlaunch operates neither and cannot reverse a transfer.</li>
<li>A transfer is saved in this browser while it is in progress. Reopen the bridge with the same wallet to pick it up.</li>
<li>Finished transfers stay on the board below. It never asks your wallet for anything.</li>
</ul>
</section>
</div>
</div>
{history}
</main>
);
}
28 changes: 28 additions & 0 deletions app/src/app/bridge/bridge.module.css
Original file line number Diff line number Diff line change
@@ -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; }
}
27 changes: 27 additions & 0 deletions app/src/app/bridge/page.tsx
Original file line number Diff line number Diff line change
@@ -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<Record<string, string | string[] | undefined>> }) {
const link = parseBridgeLink(await searchParams);
return (
<BridgePageLayout
panel={<BridgePageSlot link={link} />}
history={<BridgeHistory />}
/>
);
}
Loading
Loading