Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
Expand Up @@ -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
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 @@ 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")));
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
4 changes: 2 additions & 2 deletions app/src/app/ui-review-bridge/BridgeReview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<typeof useBridge> = {
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,
Expand Down
9 changes: 5 additions & 4 deletions app/src/components/WalletMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 <span className={styles.networkWarn} aria-hidden>!</span>;
/** 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 : <span className={styles.networkWarn} aria-hidden>!</span>;
return <ChainLogo chain={chain} size={14} className="absolute -right-1 -bottom-1 ring-2 ring-card" />;
}

Expand Down Expand Up @@ -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}
>
<span className={styles.triggerMark}><WalletAvatar address={address} />{block ? <span className={styles.connectionDot} /> : <NetworkBadge chain={key ?? bridgeNetwork?.key ?? null} />}</span>
<span className={styles.triggerMark}><WalletAvatar address={address} />{block ? <span className={styles.connectionDot} /> : <NetworkBadge chain={key ?? (bridgeNetwork && isChainKey(bridgeNetwork.key) ? bridgeNetwork.key : null)} known={!!bridgeNetwork} />}</span>
<span className={styles.triggerAddress}>{shortAddr(address)}</span>
{block ? <span className={styles.triggerNetwork}>{key || bridgeNetwork ? network : "Switch"}</span> : null}
<ChevronDown size={13} className={styles.chevron} aria-hidden />
Expand Down
5 changes: 5 additions & 0 deletions app/src/components/bridge/BridgeDialog.module.css

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

45 changes: 33 additions & 12 deletions app/src/components/bridge/BridgeDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -16,7 +16,14 @@ import useBridge from "./useBridge";
import styles from "./BridgeDialog.module.css";

type Bridge = ReturnType<typeof useBridge>;
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<Record<BridgeChainId, { text: string; fill: string; ring: string }>> = {
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;
Expand All @@ -26,6 +33,9 @@ function nativeAmount(value: string | bigint): string {
}

function NetworkMark({ chain }: { chain: BridgeChainId }) {
const letters = LETTERMARKS[chain];
if (letters) return <span className={styles.networkMark} aria-hidden><span className={styles.letterMark} style={{ "--mark-fill": letters.fill, "--mark-ring": letters.ring } as React.CSSProperties}>{letters.text}</span></span>;
if (chain === 1) return <span className={styles.networkMark} aria-hidden><Image src="/brand/ethereum.svg" alt="" width={28} height={28} draggable={false} /></span>;
return <span className={`${styles.networkMark} ${chain === 5042 ? styles.arcMark : ""}`} aria-hidden>{chain === 8453
? <Image src="/brand/base.svg" alt="" width={30} height={30} draggable={false} />
: chain === 5042 ? <Image src="/brand/arc.svg" alt="" width={23} height={24} draggable={false} />
Expand Down Expand Up @@ -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) => <Select.Item key={id} value={id} label={BRIDGE_CHAINS[id].name} className={styles.networkOption}>
<NetworkMark chain={id} />
<span className={styles.networkCopy}>
<Select.ItemText className={styles.networkName}>{BRIDGE_CHAINS[id].name}</Select.ItemText>
<span className={styles.gasCurrency}>{BRIDGE_CHAINS[id].symbol} · Gas token</span>
</span>
<Select.ItemIndicator className={styles.networkCheck}><Check size={17} strokeWidth={2.5} aria-hidden /></Select.ItemIndicator>
</Select.Item>;
return <div className={styles.network}>
<Select.Root value={chain} items={networkItems} disabled={disabled}
<Select.Root value={chain} items={chains.map((id) => ({ value: id, label: BRIDGE_CHAINS[id].name }))} disabled={disabled}
onValueChange={(value) => { if (isBridgeChainId(value)) onChange(value); }}
onOpenChange={(_, details) => setInstant(details.event.type.startsWith("key"))}>
<Select.Label className={styles.smallLabel}>{label}<span className="sr-only"> network</span></Select.Label>
Expand All @@ -88,14 +109,11 @@ function NetworkSelect({ label, chain, disabled, onChange }: { label: "From" | "
<Select.Popup className={styles.networkPopup} data-instant={instant || undefined}>
<p className={styles.networkMenuTitle}>{label === "From" ? "Send from" : "Receive on"}</p>
<Select.List className={styles.networkList}>
{BRIDGE_CHAIN_IDS.map((id) => <Select.Item key={id} value={id} label={BRIDGE_CHAINS[id].name} className={styles.networkOption}>
<NetworkMark chain={id} />
<span className={styles.networkCopy}>
<Select.ItemText className={styles.networkName}>{BRIDGE_CHAINS[id].name}</Select.ItemText>
<span className={styles.gasCurrency}>{BRIDGE_CHAINS[id].symbol} · Gas token</span>
</span>
<Select.ItemIndicator className={styles.networkCheck}><Check size={17} strokeWidth={2.5} aria-hidden /></Select.ItemIndicator>
</Select.Item>)}
{launch.map(option)}
{funding.length > 0 ? <Select.Group className={styles.networkList}>
<Select.GroupLabel className={styles.networkGroupLabel}>Fund from</Select.GroupLabel>
{funding.map(option)}
</Select.Group> : null}
</Select.List>
</Select.Popup>
</Select.Positioner>
Expand Down Expand Up @@ -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…"
Expand All @@ -209,9 +229,10 @@ export function BridgeForm({ bridge: b, connect }: { bridge: Bridge; connect: ()
}}>
<div className={styles.route}>
<NetworkSelect label="From" chain={b.originChainId} disabled={locked} onChange={b.setOriginChainId} />
<button type="button" className={styles.reverse} aria-label={`Reverse route: ${destination.name} to ${origin.name}`} disabled={locked} onClick={b.reverseRoute}><ArrowLeftRight size={18} aria-hidden /></button>
<button type="button" className={styles.reverse} aria-label={b.canReverse ? `Reverse route: ${destination.name} to ${origin.name}` : `${origin.name} only sends funds here`} title={b.canReverse ? undefined : `${origin.name} only sends funds here`} disabled={locked || !b.canReverse} onClick={b.reverseRoute}><ArrowLeftRight size={18} aria-hidden /></button>
<NetworkSelect label="To" chain={b.destinationChainId} disabled={locked} onChange={b.setDestinationChainId} />
</div>
{walletFunding !== null ? <button type="button" className={styles.walletHint} disabled={locked} onClick={() => b.setOriginChainId(walletFunding)}>Your wallet is on {BRIDGE_CHAINS[walletFunding].name}. Send from {BRIDGE_CHAINS[walletFunding].name}</button> : null}
{convertsAsset ? <p className={styles.conversionNote}>{inputCurrency.symbol} <ArrowRight size={12} aria-hidden /> {outputCurrency.symbol}<span>Converted by Relay</span></p> : null}
{b.originChainId === 4663 || b.destinationChainId === 4663 ? <p className={styles.routeNotice}>USDC on Robinhood is unavailable: its routes do not pass our current safety checks. ETH and USDG are available.</p> : null}
{erc20Input && b.approval?.chainId === b.originChainId && b.approval.status === "confirmed" && !b.quote ? <p className={styles.approvalNotice} role="status">{inputCurrency.symbol} approval confirmed. Review a fresh quote before bridging. No funds have been bridged yet.</p> : null}
Expand Down
Loading
Loading