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
1 change: 1 addition & 0 deletions app/src/app/bridge/bridge.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
.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; }

Expand Down
18 changes: 14 additions & 4 deletions app/src/app/bridge/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import shell from "@/components/sections/SectionShell.module.css";
import BridgePageSlot from "@/components/bridge/BridgePageSlot";
import BridgeHistory from "@/components/bridge/BridgeHistory";
import { parseBridgeLink } from "@/lib/bridge/link";
import { BRIDGE_ASSETS, BRIDGE_CHAINS, BRIDGE_DESTINATION_IDS, BRIDGE_FUNDING_IDS } from "@/lib/bridge/types";
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";

/**
Expand All @@ -20,6 +20,16 @@ export const metadata: Metadata = pageMetadata({
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.",
});

/** 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."],
Expand All @@ -45,8 +55,8 @@ export default async function BridgePage({ searchParams }: { searchParams: Promi
<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><dd>{BRIDGE_ASSETS[id].join(", ")}<span>Send and receive</span></dd></div>)}
{BRIDGE_FUNDING_IDS.map((id) => <div key={id}><dt>{BRIDGE_CHAINS[id].name}</dt><dd>{BRIDGE_ASSETS[id].join(", ")}<span>Send only</span></dd></div>)}
{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">
Expand All @@ -68,7 +78,7 @@ export default async function BridgePage({ searchParams }: { searchParams: Promi
<section aria-labelledby="bridge-know">
<h2 id="bridge-know">Good to know</h2>
<ul className={styles.points}>
<li>On Elysium the bridge carries USDC only. Its gas token, HYPE, is not bridged, so keep some HYPE there to use or send what arrives.</li>
<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 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>
Expand Down
9 changes: 7 additions & 2 deletions app/src/app/ui-review-bridge/BridgeReview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ const HISTORY_ROWS: BridgeHistoryEntry[] = [
{ requestId: hash(41), originChainId: 8453, destinationChainId: 5042, originAsset: "USDC", destinationAsset: "USDC", amountIn: "60000000", inDecimals: 6, amountOut: "59940000000000000000", outDecimals: 18, outKind: "quoted", status: "delayed", reverted: false, createdAt: minutesAgo(60 * 9), sourceHash: hash(42), destinationHashes: [], recipient: null },
{ requestId: hash(51), originChainId: 1, destinationChainId: 5042, originAsset: "ETH", destinationAsset: "USDC", amountIn: "50000000000000000", inDecimals: 18, amountOut: "155300000000000000000", outDecimals: 18, outKind: "quoted", status: "refund", reverted: false, createdAt: minutesAgo(60 * 26), sourceHash: hash(52), destinationHashes: [], recipient: null },
{ requestId: hash(91), originChainId: 99901, destinationChainId: 8453, originAsset: "USDC", destinationAsset: "USDC", amountIn: "25000000", inDecimals: 6, amountOut: "24664192", outDecimals: 6, outKind: "received", status: "success", reverted: false, createdAt: minutesAgo(60 * 30), sourceHash: hash(92), destinationHashes: [hash(93)], recipient: null },
{ requestId: hash(95), originChainId: 8453, destinationChainId: 99901, originAsset: "USDC", destinationAsset: "HYPE", amountIn: "25000000", inDecimals: 6, amountOut: "343478554577755790", outDecimals: 18, outKind: "received", status: "success", reverted: false, createdAt: minutesAgo(60 * 40), sourceHash: hash(96), destinationHashes: [hash(97)], recipient: null },
{ requestId: hash(61), originChainId: 10, destinationChainId: 8453, originAsset: "ETH", destinationAsset: "ETH", amountIn: "8000000000000000", inDecimals: 18, amountOut: null, outDecimals: 18, outKind: null, status: "failure", reverted: true, createdAt: minutesAgo(60 * 50), sourceHash: hash(62), destinationHashes: [], recipient: null },
{ requestId: hash(71), originChainId: 4663, destinationChainId: 8453, originAsset: "ETH", destinationAsset: "ETH", amountIn: "4000000000000000", inDecimals: 18, amountOut: "3991000000000000", outDecimals: 18, outKind: "received", status: "success", reverted: false, createdAt: minutesAgo(60 * 80), sourceHash: hash(72), destinationHashes: [hash(73)], recipient: "0x9f3a2bE0c1D4e5F6a7b8C9d0E1f2A3b4C5d6E7F8" },
{ provider: "cctp", requestId: hash(81), originChainId: 42161, destinationChainId: 5042, originAsset: "USDC", destinationAsset: "USDC", amountIn: "40000000", inDecimals: 6, amountOut: "39983361000000000000", outDecimals: 18, outKind: "received", status: "success", reverted: false, createdAt: minutesAgo(60 * 96), sourceHash: hash(82), destinationHashes: [hash(83)], recipient: null },
Expand All @@ -53,6 +54,8 @@ const FUND_SCENES: FundScene[] = [
{ name: "arrived", step: "arrived", destination: 8453, asset: "USDC", symbol: "BASED", row: HISTORY_ROWS[1], shortBy: null, suggestion: null, searching: false },
{ name: "refunded", step: "attention", destination: 5042, asset: "USDC", symbol: "ARCO", row: HISTORY_ROWS[4], shortBy: null, suggestion: null, searching: false },
{ name: "Elysium, source found", step: "short", destination: 99901, asset: "USDC", symbol: "KINO", row: null, shortBy: "18.5 USDC", suggestion: { chainId: 42161, asset: "USDC", amount: 64_200_000n, decimals: 6 }, searching: false },
{ name: "Elysium HYPE, source found", step: "short", destination: 99901, asset: "HYPE", symbol: "KINO", row: null, shortBy: "0.25 HYPE", suggestion: { chainId: 8453, asset: "ETH", amount: 40_000_000_000_000_000n, decimals: 18 }, searching: false },
{ name: "Elysium HYPE, arrived", step: "arrived", destination: 99901, asset: "HYPE", symbol: "KINO", row: HISTORY_ROWS[6], shortBy: null, suggestion: null, searching: false },
];

export default function BridgeReview() {
Expand Down Expand Up @@ -82,7 +85,9 @@ export default function BridgeReview() {
}, [open, scene, inputAmount, amount, origin, destination, originAsset, destinationAsset]);
const normalizedInput = inputAmount * 10n ** BigInt(18 - inputCurrency.decimals);
const netAmount = normalizedInput * 9975n / 10000n;
const converted = originAsset === destinationAsset ? netAmount : originAsset === "USDC" ? netAmount / 2400n : netAmount * 2400n;
// fixed synthetic dollar prices, not market data
const PRICE: Record<BridgeAsset, bigint> = { ETH: 2400n, USDC: 1n, USDG: 1n, HYPE: 72n };
const converted = originAsset === destinationAsset ? netAmount : netAmount * PRICE[originAsset] / PRICE[destinationAsset];
// Circle: one synthetic fixed fee (the size of the live Fast plus forward fee), delivered exactly
const circleFee = destination === 5042 ? 16_577n : 54_447n;
const outputAmount = viaCircle ? (inputAmount > circleFee ? inputAmount - circleFee : 0n) * 10n ** BigInt(outputCurrency.decimals - 6) : converted / 10n ** BigInt(18 - outputCurrency.decimals);
Expand Down Expand Up @@ -122,7 +127,7 @@ export default function BridgeReview() {
<main className="mx-auto max-w-3xl px-5 py-20">
<h1 className="text-2xl font-semibold">Bridge visual review</h1>
<p className="mt-3 text-sm text-body">Development-only synthetic data. No wallet requests, API calls, or funds.</p>
<p className="mt-2 text-sm text-muted">Base offers ETH and USDC. Arc uses USDC. Robinhood offers ETH; its USDC routes currently fail our safety checks. Elysium sends and receives USDC only. USDC sends use an exact-amount approval.</p>
<p className="mt-2 text-sm text-muted">Base offers ETH and USDC. Arc uses USDC. Robinhood offers ETH; its USDC routes currently fail our safety checks. Elysium sends USDC only, and receives USDC or HYPE, its gas token. USDC sends use an exact-amount approval.</p>
<button type="button" aria-pressed={circle} className="mt-6 min-h-11 rounded-lg border border-line-strong px-4 text-sm aria-pressed:border-ink" onClick={() => { setCircle((value) => !value); if (!cctpRoute({ ...route, originAsset, destinationAsset })) setRoute({ originChainId: 8453, destinationChainId: 5042, originAsset: "USDC", destinationAsset: "USDC", amount: "25" }); }}>quote from: {circle ? "Circle" : "Relay"}</button>
<div className="my-6 flex flex-wrap gap-2">{scenes.map((value) => <button key={value} type="button" className="min-h-11 rounded-lg border border-line-strong px-4 text-sm" onClick={() => { if (value.startsWith("approval_")) { if (!erc20Input) setRoute({ originChainId: 8453, destinationChainId: 5042, originAsset: "USDC", destinationAsset: "USDC", amount: "25" }); setApproved(value === "approval_confirmed"); } else if (!parseBridgeAmount(amount, inputCurrency.decimals)) setRoute((current) => ({ ...current, amount: originAsset === "USDC" ? "25" : "0.01" })); setScene(value); setOpen(true); }}>{value.replaceAll("_", " ")}</button>)}</div>
<Dialog.Root open={open} onOpenChange={setOpen}>
Expand Down
12 changes: 8 additions & 4 deletions app/src/components/bridge/BridgeDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@ import Image from "next/image";
import Link from "next/link";
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 { ArrowLeftRight, Coins, 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, BRIDGE_DESTINATION_IDS, bridgeCurrency, bridgeProviderName, bridgeTokenAsset, bridgeTransferInputCurrency, isBridgeAssetSupported, isBridgeChainId, isBridgeFundingChain, type BridgeAsset, type BridgeChainId, type BridgeQuoteRejection } from "@/lib/bridge/types";
import { BRIDGE_ASSET_NAMES, BRIDGE_CHAINS, BRIDGE_CHAIN_IDS, BRIDGE_DESTINATION_IDS, bridgeCurrency, bridgeAssets, bridgeProviderName, bridgeTokenAsset, bridgeTransferInputCurrency, isBridgeAssetSupported, isBridgeChainId, isBridgeFundingChain, type BridgeAsset, type BridgeChainId, type BridgeQuoteRejection } from "@/lib/bridge/types";
import { DISCARD_AFTER_MS } from "@/lib/bridge/client";
import { CCTP_APPROVAL_SPENDER } from "@/lib/bridge/approval";
import { bridgeLinkKey, type BridgeLink } from "@/lib/bridge/link";
Expand Down Expand Up @@ -54,20 +54,23 @@ function NetworkMark({ chain }: { chain: BridgeChainId }) {
function AssetMark({ asset, size = 24 }: { asset: BridgeAsset; size?: number }) {
// USDG has no bundled logo: it takes the plain dollar glyph the launch form gives it
if (asset === "USDG") return <CircleDollarSign size={size} strokeWidth={1.75} aria-hidden />;
// nor does HYPE: the launch form's coin glyph again
if (asset === "HYPE") return <Coins size={size} strokeWidth={1.75} aria-hidden />;
return <Image src={asset === "USDC" ? "/brand/usdc.svg" : "/brand/ethereum.svg"} alt="" width={size} height={size} draggable={false} aria-hidden />;
}

function AssetSelect({ label, chain, asset, disabled, onChange }: { label: "Send" | "Receive"; chain: BridgeChainId; asset: BridgeAsset; disabled: boolean; onChange: (asset: BridgeAsset) => void }) {
const [instant, setInstant] = useState(false);
const choices = BRIDGE_ASSETS[chain];
const side = label === "Send" ? "input" : "output";
const choices = bridgeAssets(chain, side);
if (choices.length === 1) return (
<span className={styles.fixedAsset}>
<span className={styles.currency} aria-label={`${label} token: ${asset}`}><AssetMark asset={asset} size={28} />{asset}</span>
{chain === 99901 ? <span className={styles.assetRequirement}>USDC only · Gas in HYPE</span> : null}
</span>
);
return <Select.Root value={asset} items={choices.map((value) => ({ value, label: value }))} disabled={disabled}
onValueChange={(value) => { if (isBridgeAssetSupported(chain, value)) onChange(value); }}
onValueChange={(value) => { if (isBridgeAssetSupported(chain, value, side)) onChange(value); }}
onOpenChange={(_, details) => setInstant(details.event.type.startsWith("key"))}>
<Select.Trigger type="button" aria-label={`${label} token`} className={styles.assetTrigger}>
<AssetMark asset={asset} size={28} /><Select.Value /><Select.Icon className={styles.networkChevron}><ChevronDown size={14} aria-hidden /></Select.Icon>
Expand Down Expand Up @@ -292,6 +295,7 @@ export function BridgeForm({ bridge: b, connect }: { bridge: Bridge; connect: ()
{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}
{b.destinationChainId === 99901 && b.destinationAsset !== "HYPE" ? <button type="button" className={styles.walletHint} disabled={locked} onClick={() => b.setDestinationAsset("HYPE")}>Gas on Elysium is paid in HYPE. With none there yet, receive HYPE instead</button> : 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}

<div className={styles.amountSection}>
Expand Down
2 changes: 1 addition & 1 deletion app/src/components/bridge/BridgeToBuy.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ function Follow({ destination, asset, symbol, pay, short, shortBy, ethUsd, onArr
const fallbackOrigin: BridgeChainId = destination === 8453 ? 1 : 8453;
const link: BridgeLink = suggestion
? { originChainId: suggestion.chainId, destinationChainId: destination, originAsset: suggestion.asset, destinationAsset: asset }
: { originChainId: fallbackOrigin, destinationChainId: destination, ...(isBridgeAssetSupported(fallbackOrigin, asset) ? { originAsset: asset } : {}), destinationAsset: asset };
: { originChainId: fallbackOrigin, destinationChainId: destination, ...(isBridgeAssetSupported(fallbackOrigin, asset, "input") ? { originAsset: asset } : {}), destinationAsset: asset };
const dismiss = () => {
if (!row) return;
const next = [...dismissed, row.requestId].slice(-20);
Expand Down
10 changes: 8 additions & 2 deletions app/src/components/bridge/bridge-ui.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,13 +181,19 @@ test("USDC approval is visibly separate from the bridge and has chain-aware reco
test("USDC selectors separate selected token units from native gas and disallow unsafe Robinhood routes", () => {
assert.match(panel, /AssetSelect label="Send"[^>]+onChange=\{b.setOriginAsset\}/);
assert.match(panel, /AssetSelect label="Receive"[^>]+onChange=\{b.setDestinationAsset\}/);
assert.match(panel, /BRIDGE_ASSETS\[chain\]/);
assert.match(panel, /isBridgeAssetSupported\(chain, value\)/);
// each picker lists its own side: Elysium's Receive offers HYPE, its Send never does
assert.ok(panel.includes(`const side = label === "Send" ? "input" : "output";`));
assert.match(panel, /bridgeAssets\(chain, side\)/);
assert.match(panel, /isBridgeAssetSupported\(chain, value, side\)/);
assert.doesNotMatch(panel, /BRIDGE_ASSETS\b/);
assert.match(panel, /formatUnits\(b.balance, inputCurrency.decimals\)/);
assert.match(panel, /formatUnits\(BigInt\(value\), outputCurrency.decimals\)/);
assert.match(panel, /For gas: \{nativeAmount\(b.nativeBalance\)\} \{origin.symbol\}/);
assert.match(panel, /USDC on Robinhood is unavailable: its routes do not pass our current safety checks\. ETH and USDG are available\./);
assert.match(panel, /chain === 99901 \? <span className=\{styles\.assetRequirement\}>USDC only · Gas in HYPE<\/span>/);
// into Elysium, a one-tap switch to HYPE for a wallet with no gas there
assert.ok(panel.includes(`{b.destinationChainId === 99901 && b.destinationAsset !== "HYPE" ? <button type="button" className={styles.walletHint} disabled={locked} onClick={() => b.setDestinationAsset("HYPE")}>Gas on Elysium is paid in HYPE. With none there yet, receive HYPE instead</button> : null}`));
assert.match(panel, /if \(asset === "HYPE"\) return <Coins/, "HYPE has no bundled mark, so it takes the launch form's coin glyph");
assert.ok(panel.includes('if (chain === 99901) return <span className={`${styles.networkMark} ${styles.elysiumMark}`} aria-hidden><Image src="/brand/elysium.png"'));
assert.ok(css.includes(".elysiumMark img { border-radius: 7px; }"));
// each asset is named in full, and says which token pays its chain's gas
Expand Down
4 changes: 2 additions & 2 deletions app/src/components/bridge/useBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -304,11 +304,11 @@ export function useBridge(open: boolean) {
}

function setOriginAsset(asset: BridgeAsset) {
if (isBridgeAssetSupported(inputs.current.originChainId, asset)) updateRoute({ side: "origin-asset", asset });
if (isBridgeAssetSupported(inputs.current.originChainId, asset, "input")) updateRoute({ side: "origin-asset", asset });
}

function setDestinationAsset(asset: BridgeAsset) {
if (isBridgeAssetSupported(inputs.current.destinationChainId, asset)) updateRoute({ side: "destination-asset", asset });
if (isBridgeAssetSupported(inputs.current.destinationChainId, asset, "output")) updateRoute({ side: "destination-asset", asset });
}

function reverseRoute() { updateRoute({ side: "reverse" }); }
Expand Down
Loading
Loading