From 6aba54c390eb964b3f46d0e07e0e7c0f8de4b303 Mon Sep 17 00:00:00 2001 From: Vasanth T Date: Tue, 10 Nov 2026 19:32:30 +0530 Subject: [PATCH 1/3] feat(bridge): Elysium routes for USDC Elysium (99901) is now a bridge network like the other launch chains. Its USDC moves through Relay from every other network and to every launch chain. HYPE, its gas token, is not carried. - Registry: Elysium USDC 0xb47c...0B65 (symbol USDC, 6 decimals, checked on-chain) both ways. A chain's default asset is now its first bridge asset, so Elysium defaults to USDC and every other chain keeps its gas token. - Chain check: Relay lists HYPE as not bridgeable. For Elysium alone, the check requires Relay to list exactly this USDC as bridgeable instead. Every other chain still needs its native currency listed as bridgeable, and the quote checks are unchanged, with gas paid in HYPE when sending from Elysium. - Dialog: Elysium's mark, an ELYS board code (new L and Y glyphs), and a route notice that HYPE is not bridged and is needed on Elysium for gas. The /bridge page lists Elysium. - Bridge, then buy: reads HYPE on Elysium and suggests Elysium USDC as a source only beside some. A buy paid in HYPE gets no bridge offer. - Tests cover the registry, Relay's Elysium listing and each way it can be wrong, every Elysium route, HYPE refunds and outputs (refused), HYPE gas, approvals, history, links, the board and the source rule. The opt-in live test quotes all sixteen Elysium routes through the server validator. --- app/src/app/bridge/page.tsx | 5 +- app/src/app/ui-review-bridge/BridgeReview.tsx | 6 +- .../components/bridge/BridgeDialog.module.css | 3 +- app/src/components/bridge/BridgeDialog.tsx | 30 ++++---- app/src/components/bridge/bridge-ui.test.ts | 15 ++-- app/src/lib/bridge/approval.test.ts | 7 +- app/src/lib/bridge/cctp-server.ts | 2 +- app/src/lib/bridge/chains.ts | 3 +- app/src/lib/bridge/client-chains.test.ts | 9 ++- app/src/lib/bridge/client.test.ts | 8 ++- app/src/lib/bridge/fund.test.ts | 28 +++++++- app/src/lib/bridge/fund.ts | 29 +++++--- app/src/lib/bridge/history.test.ts | 10 ++- app/src/lib/bridge/relay.fixture.ts | 6 +- app/src/lib/bridge/relay.live.test.ts | 2 +- app/src/lib/bridge/relay.test.ts | 72 ++++++++++++++++++- app/src/lib/bridge/signal.test.ts | 4 ++ app/src/lib/bridge/signal.ts | 5 +- .../lib/bridge/transaction-preflight.test.ts | 6 +- app/src/lib/bridge/types.test.ts | 28 ++++++-- app/src/lib/bridge/types.ts | 34 +++++++-- app/src/lib/bridge/validation.ts | 14 ++-- docs/bridge.md | 35 +++++++-- 23 files changed, 290 insertions(+), 71 deletions(-) diff --git a/app/src/app/bridge/page.tsx b/app/src/app/bridge/page.tsx index 1112c28c..26753353 100644 --- a/app/src/app/bridge/page.tsx +++ b/app/src/app/bridge/page.tsx @@ -17,7 +17,7 @@ import styles from "./bridge.module.css"; export const metadata: Metadata = pageMetadata({ path: "/bridge", title: "Bridge", - description: "Move ETH and dollars between Base, Robinhood Chain and Arc, or fund them from Ethereum, Arbitrum and Optimism. Same wallet, no openlaunch fee.", + 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.", }); const STEPS = [ @@ -35,7 +35,7 @@ export default async function BridgePage({ searchParams }: { searchParams: Promi eyebrow="Bridge" icon={} title="Move funds to where you launch" - description="Send ETH and dollars between Base, Robinhood Chain and Arc, or bring them in from Ethereum, Arbitrum and Optimism. They arrive at the same wallet address. openlaunch charges nothing for it." + 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." />
@@ -66,6 +66,7 @@ export default async function BridgePage({ searchParams }: { searchParams: Promi

Good to know

    +
  • 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.
  • 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.
  • diff --git a/app/src/app/ui-review-bridge/BridgeReview.tsx b/app/src/app/ui-review-bridge/BridgeReview.tsx index ca1faee8..082decee 100644 --- a/app/src/app/ui-review-bridge/BridgeReview.tsx +++ b/app/src/app/ui-review-bridge/BridgeReview.tsx @@ -34,6 +34,7 @@ const HISTORY_ROWS: BridgeHistoryEntry[] = [ { requestId: hash(31), originChainId: 8453, destinationChainId: 4663, originAsset: "USDC", destinationAsset: "USDG", amountIn: "150000000", inDecimals: 6, amountOut: "149810000", outDecimals: 6, outKind: "received", status: "success", reverted: false, createdAt: minutesAgo(60 * 5), sourceHash: hash(32), destinationHashes: [hash(33)], recipient: null }, { 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(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 }, @@ -51,6 +52,7 @@ const FUND_SCENES: FundScene[] = [ { name: "delayed", step: "moving", destination: 5042, asset: "USDC", symbol: "ARCO", row: HISTORY_ROWS[3], shortBy: null, suggestion: null, searching: false }, { 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 }, ]; export default function BridgeReview() { @@ -91,7 +93,7 @@ export default function BridgeReview() { transaction: { to: wallet, data: "0x", value: "0", chainId: origin }, // deliberately non-executable fixture ...(erc20Input ? { approval: { token: inputCurrency.address, spender: viaCircle ? CCTP_TOKEN_MESSENGER : RELAY, amount: inputAmount.toString() } } : {}), }; - const approval: TrackedApproval | null = erc20Input && (origin === 5042 || origin === 8453) && (scene.startsWith("approval_") || approved) ? { version: 1, chainId: origin, address: wallet, token: inputCurrency.address, spender: viaCircle ? CCTP_TOKEN_MESSENGER.toLowerCase() as typeof RELAY : RELAY, amount: inputAmount.toString(), createdAt: clock, status: scene === "approval_uncertain" ? "uncertain" : scene === "approval_confirmed" || approved ? "confirmed" : "pending", ...(scene === "approval_uncertain" ? {} : { approvalHash: requestId }) } : null; + const approval: TrackedApproval | null = erc20Input && (origin === 5042 || origin === 8453 || origin === 99901) && (scene.startsWith("approval_") || approved) ? { version: 1, chainId: origin, address: wallet, token: inputCurrency.address, spender: viaCircle ? CCTP_TOKEN_MESSENGER.toLowerCase() as typeof RELAY : RELAY, amount: inputAmount.toString(), createdAt: clock, status: scene === "approval_uncertain" ? "uncertain" : scene === "approval_confirmed" || approved ? "confirmed" : "pending", ...(scene === "approval_uncertain" ? {} : { approvalHash: requestId }) } : null; const tracking = ["pending", "success", "uncertain", "refund"].includes(scene); const feeWarning = ["high_fee", "fee_boundary", "impact_limit"].includes(scene); const rejectedFee = scene === "fee_boundary" ? inputAmount / 20n + 1n : scene === "impact_limit" ? inputAmount / 400n : inputAmount * 55548n / 1_000_000n; @@ -120,7 +122,7 @@ export default function BridgeReview() {

    Bridge visual review

    Development-only synthetic data. No wallet requests, API calls, or funds.

    -

    Base offers ETH and USDC. Arc uses USDC. Robinhood offers ETH; its USDC routes currently fail our safety checks. USDC sends use an exact-amount approval.

    +

    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.

    {scenes.map((value) => )}
    diff --git a/app/src/components/bridge/BridgeDialog.module.css b/app/src/components/bridge/BridgeDialog.module.css index aabd1476..ed1c2978 100644 --- a/app/src/components/bridge/BridgeDialog.module.css +++ b/app/src/components/bridge/BridgeDialog.module.css @@ -32,7 +32,6 @@ .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; } @@ -47,6 +46,8 @@ .networkMark { display: inline-flex; flex: 0 0 30px; align-items: center; justify-content: center; height: 30px; width: 30px; } .networkMark img { display: block; flex-shrink: 0; object-fit: contain; } .arcMark { background: var(--color-scrim); border-radius: 7px; } +.arbitrumMark { background: #f1f5ff; border-radius: 7px; } +.elysiumMark img { border-radius: 7px; } .networkMark .darkLogo { display: none; } :global(.dark) .networkMark .lightLogo { display: none; } :global(.dark) .networkMark .darkLogo { display: block; } diff --git a/app/src/components/bridge/BridgeDialog.tsx b/app/src/components/bridge/BridgeDialog.tsx index 25207826..0b5dd1a7 100644 --- a/app/src/components/bridge/BridgeDialog.tsx +++ b/app/src/components/bridge/BridgeDialog.tsx @@ -28,11 +28,6 @@ type Bridge = ReturnType; // 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; @@ -42,9 +37,11 @@ function nativeAmount(value: string | bigint): string { } function NetworkMark({ chain }: { chain: BridgeChainId }) { - const letters = LETTERMARKS[chain]; - if (letters) return {letters.text}; + if (chain === 42161) return ; + if (chain === 10) return ; if (chain === 1) return ; + // a black disc on white, its own tile on both themes (ChainLogo.tsx) + if (chain === 99901) return ; return {chain === 8453 ? : chain === 5042 ? @@ -140,7 +137,7 @@ function Recipient({ address }: { address: string }) { ); } -function FeeLimitNotice({ rejection, inputSymbol, gasSymbol }: { rejection: BridgeQuoteRejection; inputSymbol: BridgeAsset; gasSymbol: BridgeAsset }) { +function FeeLimitNotice({ rejection, inputSymbol, gasSymbol }: { rejection: BridgeQuoteRejection; inputSymbol: BridgeAsset; gasSymbol: string }) { const feeTooHigh = rejection.reason === "relay-fee"; const exactPercent = feeTooHigh ? rejection.relayFeePercent : rejection.totalImpactPercent.replace(/^-/, ""); return
    @@ -266,16 +263,20 @@ export function BridgeForm({ bridge: b, connect }: { bridge: Bridge; connect: () const mode = signalMode({ phase: b.phase, tracked: false, approvalActive: b.approvalBusy, quoteLoading: b.quoteLoading, reviewing, quoteExpired: needsRefresh, blocked: !!b.quoteRejection || !!b.quoteError }); // the deposit, and only the deposit, can also be confirmed by sliding: never an approval, a quote or a refresh const slideReady = !!b.address && reviewing && !b.approvalRequired && !loading && !b.allowanceLoading; + const actionDisabled = loading || b.allowanceLoading || (!!b.address && !b.canQuote); - // the button and the slide share this one path, with the same guards - function submit() { + function runPrimaryAction() { if (!b.address) { connect(); return; } - if (loading || b.allowanceLoading || !b.canQuote) return; + if (actionDisabled) return; void (reviewing ? b.approvalRequired ? b.approve() : b.confirm() : b.requestQuote()); } return ( -
    { event.preventDefault(); submit(); }}> + { + event.preventDefault(); + // Enter from the amount field can fetch a quote. Wallet actions use the button or slider. + if (b.address && !actionDisabled && !reviewing) void b.requestQuote(); + }}>
    @@ -285,6 +286,7 @@ 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} + {b.originChainId === 99901 || b.destinationChainId === 99901 ?

    Only USDC moves to and from Elysium. Gas there is HYPE, which this bridge does not carry, so keep some HYPE on Elysium to use or send your USDC.

    : 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}
    @@ -322,8 +324,8 @@ export function BridgeForm({ bridge: b, connect }: { bridge: Bridge; connect: () {b.address ? : null} {error ?

    {error}

    : null} - {slideReady ? } back={convertsAsset ? : null} onConfirm={submit} /> : 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} - {b.originChainId === 99901 || b.destinationChainId === 99901 ?

    Only USDC moves to and from Elysium. Gas there is HYPE, which this bridge does not carry, so keep some HYPE on Elysium to use or send your USDC.

    : 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}
    @@ -324,8 +329,13 @@ export function BridgeForm({ bridge: b, connect }: { bridge: Bridge; connect: () {b.address ? : null} {error ?

    {error}

    : null} - {slideReady ? } back={convertsAsset ? : null} onConfirm={runPrimaryAction} /> : null} - -
    {scenes.map((value) => )}
    - - -
    Bridge
    - Preview only · {scene} · no funds move - {tracking ? : setScene("quote")} />} -
    {viaCircle ? "Powered by Circle CCTP" : "Powered by Relay"}0 Openlaunch fee
    -
    -
    -
    {HISTORY_SCENES.map((value) => )}
    -
    -
    - {}} /> -

    Bridge, then buy

    -

    The trade panel card, at trade panel width. Buttons do nothing here.

    -
    - {FUND_SCENES.map(({ name, ...scene }) =>
    {name}
    {}} onOpen={() => {}} onDismiss={() => {}} />
    )} -
    -
    + + Trade panel + Live bridge + } + controls={ +
    +
    +

    Development preview

    +

    Sample data. No wallet requests, API calls, or funds.

    +
    +
    + + + +
    +
    + } + panel={ +
    + {open ?

    The bridge preview is open in a dialog.

    : content} + +
    + } + history={<> +
    + + {}} /> +
    +
    +

    Bridge, then buy

    +

    Preview how the funding card looks inside the token trade panel.

    +
    + {FUND_SCENES.map(({ name, ...fixture }) => ( +
    +
    {name}
    + {}} onOpen={() => {}} onDismiss={() => {}} /> +
    + ))} +
    +
    + } + /> + setOpen(false)} />} + > + {open ? content : null} + ); } diff --git a/app/src/app/ui-review-bridge/page.tsx b/app/src/app/ui-review-bridge/page.tsx index 631c9a80..7ab8f5b0 100644 --- a/app/src/app/ui-review-bridge/page.tsx +++ b/app/src/app/ui-review-bridge/page.tsx @@ -1,7 +1,9 @@ +import type { Metadata } from "next"; import { notFound } from "next/navigation"; import BridgeReview from "./BridgeReview"; export const dynamic = "force-dynamic"; +export const metadata: Metadata = { title: "Bridge review" }; /** Local visual fixture. Never exposes a mock wallet or transfer on production. */ export default function BridgeReviewPage() { diff --git a/app/src/app/ui-review-bridge/review.module.css b/app/src/app/ui-review-bridge/review.module.css new file mode 100644 index 00000000..d1394667 --- /dev/null +++ b/app/src/app/ui-review-bridge/review.module.css @@ -0,0 +1,78 @@ +.toolbar { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + justify-content: space-between; + gap: 16px 24px; + margin-bottom: 24px; + padding: 16px 18px; + border: 1px solid var(--color-line); + border-radius: 16px; + background: var(--color-card); +} +.toolbar h2, .trade h2 { margin: 0; color: var(--color-ink); font-weight: 650; } +.toolbar h2 { font-size: 14px; } +.toolbarCopy p, .trade > p { margin: 4px 0 0; color: var(--color-muted); font-size: 13px; line-height: 1.5; } +.toolbarActions { display: flex; flex-wrap: wrap; align-items: flex-end; gap: 10px; min-width: 0; } +.field { display: grid; min-width: 0; gap: 4px; } +.label { color: var(--color-muted); font-size: 12px; font-weight: 500; } +.select { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + min-height: 44px; + padding: 0 12px; + border: 1px solid var(--color-line); + border-radius: 10px; + background: var(--color-paper); + color: var(--color-ink); + font-size: 13px; + font-weight: 550; + text-align: left; + cursor: pointer; +} +.select:hover, .select[data-popup-open] { border-color: var(--color-line-strong); } +.select svg { flex-shrink: 0; color: var(--color-muted); } +.positioner { z-index: 90; } +.menu { + max-width: var(--available-width); + min-width: var(--anchor-width); + max-height: var(--available-height); + overflow-y: auto; + overscroll-behavior: contain; + padding: 5px; + border: 1px solid var(--color-line-strong); + border-radius: 12px; + background: var(--color-raised); + box-shadow: var(--shadow-card-hover); +} +.option { + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; + min-height: 36px; + padding: 7px 9px; + border-radius: 7px; + color: var(--color-body); + font-size: 13px; + cursor: pointer; +} +.option[data-highlighted] { background: var(--color-brand-soft); color: var(--color-ink); } +.option svg { flex-shrink: 0; color: var(--color-brand); } +.dialogNotice { min-height: 320px; margin: 0; color: var(--color-muted); font-size: 13px; } +.historyPreview { display: grid; justify-items: stretch; gap: 14px; margin-top: 64px; } +.historyPreview > .field { justify-self: end; } +.historyPreview > section { margin-top: 0; scroll-margin-top: 180px; } +.trade { margin-top: 56px; scroll-margin-top: 104px; } +.trade h2 { font-size: 17px; } +.tradeGrid { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 340px), 1fr)); gap: 24px; margin-top: 18px; } +.tradeGrid figure { display: grid; min-width: 0; align-content: start; gap: 8px; margin: 0; } +.tradeGrid figcaption { color: var(--color-muted); font-size: 12px; } +@media (max-width: 600px) { + .toolbar { padding: 14px; } + .toolbarActions { display: grid; width: 100%; grid-template-columns: repeat(2, minmax(0, 1fr)); } + .toolbarActions > button { width: 100%; padding-inline: 10px; } + .toolbarActions .field:first-child { grid-column: 1 / -1; } +} diff --git a/app/src/components/bridge/BridgeDialog.module.css b/app/src/components/bridge/BridgeDialog.module.css index 929cd503..21fc5aa3 100644 --- a/app/src/components/bridge/BridgeDialog.module.css +++ b/app/src/components/bridge/BridgeDialog.module.css @@ -6,20 +6,68 @@ .triggerHint { margin-left: auto; font-size: 12px; color: var(--color-muted); } .backdrop { position: fixed; inset: 0; z-index: 80; background: color-mix(in srgb, var(--color-scrim) 45%, transparent); opacity: 1; transition: opacity 180ms ease-out; } .backdrop[data-starting-style], .backdrop[data-ending-style] { opacity: 0; } -.panel { position: fixed; z-index: 81; top: 50%; left: 50%; transform: translate(-50%, -50%); width: min(480px, calc(100vw - 32px)); max-height: calc(100dvh - 32px); overflow-y: auto; overscroll-behavior: contain; padding: 26px 28px 20px; border: 1px solid var(--color-line); border-radius: 24px; background: var(--color-raised); color: var(--color-ink); box-shadow: var(--shadow-dialog); outline: none; scrollbar-width: thin; scrollbar-color: var(--color-line-strong) transparent; transition: opacity 180ms ease-out, transform 220ms cubic-bezier(.16, 1, .3, 1); } +.panel { + --dialog-gutter: 22px; + position: fixed; + z-index: 81; + top: 50%; + left: 50%; + display: grid; + grid-template-rows: auto minmax(0, 1fr) auto; + width: min(496px, calc(100vw - 32px)); + max-height: calc(100dvh - 32px); + overflow: hidden; + border: 1px solid var(--color-line); + border-radius: 24px; + background: var(--color-raised); + color: var(--color-ink); + box-shadow: var(--shadow-dialog); + outline: none; + transform: translate(-50%, -50%); + transition: opacity 180ms ease-out, transform 220ms cubic-bezier(.16, 1, .3, 1); +} .panel[data-starting-style], .panel[data-ending-style] { opacity: 0; transform: translate(-50%, calc(-50% + 8px)) scale(.98); } .panel ::selection { background: var(--color-brand-soft); color: var(--color-brand); } -.heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; } -.title { margin: 0; font-size: 24px; line-height: 1.25; letter-spacing: -.035em; font-weight: 650; } -.close { display: inline-flex; align-items: center; justify-content: center; width: 36px; height: 36px; margin: -4px -8px -4px 0; border-radius: 50%; color: var(--color-muted); cursor: pointer; } -.description { margin: 6px 0 26px; color: var(--color-muted); font-size: 14px; line-height: 1.5; } -.route { display: grid; grid-template-columns: minmax(0, 1fr) 36px minmax(0, 1fr); align-items: center; gap: 8px; padding-bottom: 20px; border-bottom: 1px solid var(--color-line); } +.title { margin: 0; font-size: 19px; line-height: 1.2; letter-spacing: -.025em; font-weight: 650; } +.close { display: inline-flex; flex: 0 0 36px; align-items: center; justify-content: center; height: 36px; margin-right: -4px; border-radius: 50%; color: var(--color-muted); cursor: pointer; } +.description { margin: 3px 0 0; color: var(--color-muted); font-size: 12px; line-height: 1.5; } +.dialogHeader { + display: flex; + align-items: center; + gap: 11px; + min-width: 0; + padding: 14px var(--dialog-gutter); + border-bottom: 1px solid var(--color-line); +} +.headerMark { + display: grid; + flex: 0 0 38px; + height: 38px; + place-items: center; + border: 1px solid color-mix(in srgb, var(--color-brand) 15%, var(--color-line)); + border-radius: 11px; + background: linear-gradient(155deg, var(--color-card), var(--color-brand-soft)); + color: var(--color-brand); +} +.headingCopy { flex: 1; min-width: 0; } +.dialogBody { + min-height: 0; + min-width: 0; + overflow-y: auto; + overscroll-behavior-y: contain; + padding: 14px var(--dialog-gutter) 12px; + scroll-padding-block: 12px; + scrollbar-width: thin; + scrollbar-color: var(--color-line-strong) transparent; +} +.dialogBody:has(.formActions[data-pin]) { scroll-padding-bottom: 66px; } +.route { display: grid; grid-template-columns: minmax(0, 1fr) 34px minmax(0, 1fr); align-items: end; gap: 8px; } .network { min-width: 0; } .smallLabel { display: block; font-size: 12px; font-weight: 500; color: var(--color-muted); line-height: 1.5; } -.networkTrigger { display: grid; grid-template-columns: 30px minmax(0, 1fr) 13px; align-items: center; gap: 8px; width: calc(100% + 16px); min-height: 58px; margin: 5px -8px 0; padding: 8px 7px; border: 1px solid transparent; border-radius: 12px; color: var(--color-ink); text-align: left; cursor: pointer; transition: background 160ms ease, border-color 160ms ease, transform 120ms ease-out; } -.networkTrigger[data-popup-open] { background: var(--color-paper); border-color: var(--color-line-strong); } +.networkTrigger { display: grid; grid-template-columns: 30px minmax(0, 1fr) 13px; align-items: center; gap: 8px; width: 100%; min-height: 52px; margin-top: 4px; padding: 6px 9px; border: 1px solid var(--color-line); border-radius: 12px; background: var(--color-paper); color: var(--color-ink); text-align: left; cursor: pointer; transition: background 160ms ease, border-color 160ms ease, transform 120ms ease-out; } +.networkTrigger[data-popup-open] { background: var(--color-card); border-color: var(--color-line-strong); } .networkTrigger[data-disabled] { cursor: not-allowed; opacity: .55; } -.networkCopy { display: grid; min-width: 0; gap: 3px; } +.networkCopy { display: grid; min-width: 0; gap: 2px; } .networkName { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 14px; line-height: 1.4; font-weight: 600; } .networkChevron { display: inline-flex; color: var(--color-muted); } .networkTrigger[data-popup-open] .networkChevron { transform: rotate(180deg); } @@ -51,27 +99,50 @@ .networkMark .darkLogo { display: none; } :global(.dark) .networkMark .lightLogo { display: none; } :global(.dark) .networkMark .darkLogo { display: block; } -.reverse { display: inline-flex; align-items: center; justify-content: center; height: 36px; width: 36px; margin-top: 4px; color: var(--color-muted); border: 1px solid var(--color-line); border-radius: 50%; cursor: pointer; transition: transform 180ms cubic-bezier(.16, 1, .3, 1), background 160ms, color 160ms; } -.amountSection { padding: 23px 0 20px; } -.amountRow { display: flex; align-items: center; gap: 16px; margin-top: 4px; } -.amountRow input { display: block; min-width: 0; width: 100%; border: 0; border-radius: 4px; background: transparent; color: var(--color-ink); caret-color: var(--color-brand); font-family: var(--font-dots), var(--font-mono); font-variation-settings: "ROND" 100; font-size: 40px; line-height: 1.3; letter-spacing: 0; font-variant-numeric: tabular-nums; font-weight: 800; } +.reverse { display: inline-flex; align-items: center; justify-content: center; height: 34px; width: 34px; margin-bottom: 9px; color: var(--color-muted); border: 1px solid var(--color-line); border-radius: 50%; cursor: pointer; transition: transform 180ms cubic-bezier(.16, 1, .3, 1), background 160ms, color 160ms; } +.amountStack { + min-width: 0; + margin-top: 12px; + border: 1px solid var(--color-line); + border-radius: 15px; + background: var(--color-paper); +} +.amountSection { padding: 12px 13px 10px; } +.amountRow { display: flex; align-items: center; gap: 10px; margin-top: 3px; } +.amountRow input { display: block; min-width: 0; width: 100%; border: 0; border-radius: 4px; background: transparent; color: var(--color-ink); caret-color: var(--color-brand); font-family: var(--font-mono); font-size: 36px; line-height: 1.2; letter-spacing: -.05em; font-variant-numeric: tabular-nums; font-weight: 600; } .amountRow input::placeholder { color: var(--color-muted); } .currency { display: inline-flex; flex-shrink: 0; align-items: center; gap: 5px; font-size: 18px; font-weight: 550; } .fixedAsset { display: grid; flex-shrink: 0; justify-items: end; gap: 3px; } .assetRequirement { color: var(--color-body); font-size: 12px; line-height: 1.5; } -.assetTrigger { display: inline-flex; flex-shrink: 0; align-items: center; justify-content: center; gap: 7px; min-height: 44px; padding: 6px 9px; border: 1px solid var(--color-line-strong); border-radius: 12px; background: var(--color-paper); color: var(--color-ink); font-size: 16px; font-weight: 550; cursor: pointer; transition: background 160ms ease, border-color 160ms ease, transform 120ms ease-out; } +.assetTrigger { display: inline-flex; flex-shrink: 0; align-items: center; justify-content: center; gap: 6px; min-height: 40px; padding: 5px 8px; border: 1px solid var(--color-line-strong); border-radius: 10px; background: var(--color-card); color: var(--color-ink); font-size: 14px; font-weight: 600; cursor: pointer; transition: background 160ms ease, border-color 160ms ease, transform 120ms ease-out; } .assetTrigger[data-popup-open] { border-color: var(--color-brand); } .assetTrigger[data-popup-open] .networkChevron { transform: rotate(180deg); } .assetTrigger:not(:disabled):active:not(:focus-visible) { transform: scale(.98); } .assetTrigger:focus-visible { transition: none; } -.destinationAsset { display: flex; justify-content: space-between; align-items: center; gap: 12px; min-height: 64px; padding-block: 10px; border-top: 1px solid var(--color-line); } +.destinationAsset { display: flex; justify-content: space-between; align-items: center; gap: 10px; min-height: 52px; padding: 6px 13px; border-top: 1px solid var(--color-line); } .destinationAsset .currency { font-size: 16px; } -.routeNotice { margin-top: 12px; color: var(--color-muted); font-size: 11px; line-height: 1.6; } -.balance { margin-top: 8px; color: var(--color-muted); font-size: 12px; font-variant-numeric: tabular-nums; } -.previewNote { display: flex; align-items: flex-start; gap: 10px; padding: 17px 0; border-top: 1px solid var(--color-line); color: var(--color-body); font-size: 12px; line-height: 1.65; } -.previewNote svg { flex-shrink: 0; margin-top: 2px; color: var(--color-brand); } -.previewNote span { color: var(--color-muted); } -.feeLimit { padding-top: 20px; border-top: 1px solid var(--color-line); font-size: 12px; font-weight: 400; line-height: 1.5; } +.receiveCopy { min-width: 0; } +.quoteHint { display: flex; align-items: flex-start; gap: 5px; color: var(--color-body); font-size: 11px; line-height: 1.4; } +.quoteHint:not(:empty) { margin-top: 2px; } +.quoteHint svg { flex-shrink: 0; margin-top: 2px; color: var(--color-brand); } +.routeAvailability { margin-top: 6px; color: var(--color-muted); font-size: 12px; line-height: 1.5; } +.routeAvailability summary { + display: flex; + align-items: center; + gap: 6px; + min-height: 30px; + border-radius: 6px; + color: var(--color-body); + list-style: none; + cursor: pointer; +} +.routeAvailability summary::-webkit-details-marker { display: none; } +.routeAvailability summary svg { flex-shrink: 0; color: var(--color-muted); } +.routeAvailability summary svg:last-child { margin-left: auto; } +.routeAvailability[open] summary svg:last-child { transform: rotate(180deg); } +.routeAvailability p { margin: 2px 0 5px; overflow-wrap: anywhere; } +.balance { margin-top: 5px; color: var(--color-muted); font-size: 12px; font-variant-numeric: tabular-nums; } +.feeLimit { margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--color-line); font-size: 12px; font-weight: 400; line-height: 1.5; } .feeLimitHeading { display: grid; grid-template-columns: 18px minmax(0, 1fr) auto; align-items: start; gap: 10px; } .feeLimitIcon { margin-top: 3px; color: var(--color-down-ink); } .feeLimitCopy h3 { margin: 0; color: var(--color-ink); font-size: 14px; font-weight: 600; line-height: 1.5; text-wrap: balance; } @@ -93,10 +164,10 @@ .feeLimitReason[open] summary svg { transform: rotate(180deg); } .feeLimitReason p { margin: 0 0 12px; color: var(--color-body); font-size: 12px; font-weight: 400; line-height: 1.65; overflow-wrap: anywhere; } .feeLimit .feeLimitHint { margin: 0; color: var(--color-muted); font-size: 11px; font-weight: 400; line-height: 1.65; text-wrap: pretty; } -.quote { padding: 18px 0 0; border-top: 1px solid var(--color-line); } +.quote { margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--color-line); } .receiveLabel { display: flex; justify-content: space-between; align-items: baseline; gap: 10px; font-size: 12px; color: var(--color-body); } .estimate { font-size: 11px; color: var(--color-muted); } -.receiveAmount { margin: 6px 0 18px; font-family: var(--font-dots), var(--font-mono); font-variation-settings: "ROND" 100; font-size: 30px; font-weight: 800; line-height: 1.3; letter-spacing: 0; font-variant-numeric: tabular-nums; } +.receiveAmount { margin: 6px 0 16px; font-family: var(--font-mono); font-size: 30px; font-weight: 600; line-height: 1.3; letter-spacing: -.04em; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; } .receiveAmount span { display: inline-flex; align-items: center; gap: 4px; margin-left: 8px; vertical-align: middle; font-family: var(--font-sans); font-size: 16px; color: var(--color-muted); font-weight: 500; } .fees { display: grid; gap: 9px; font-size: 12px; } .fees > div, .transferDetails > div { display: flex; align-items: baseline; justify-content: space-between; gap: 16px; } @@ -116,15 +187,102 @@ .recipient[open] summary > svg { transform: rotate(180deg); } .fullAddress { display: block; padding: 0 0 16px; color: var(--color-ink); font-family: var(--font-mono); font-size: 11px; line-height: 1.7; overflow-wrap: anywhere; user-select: all; } .transfer .recipient { margin: 0; border-top: 0; border-bottom: 1px solid var(--color-line); } +.safety { margin-top: 5px; color: var(--color-body); font-size: 12px; line-height: 1.5; } +.safety summary { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 4px 12px; + min-height: 40px; + padding-block: 6px; + border-radius: 6px; + list-style: none; + cursor: pointer; +} +.safety summary::-webkit-details-marker { display: none; } +.gasLabel, .safetyToggle { display: inline-flex; align-items: center; gap: 6px; } +.gasLabel svg, .safetyToggle { flex-shrink: 0; color: var(--color-muted); } +.safetyToggle { margin-left: auto; font-weight: 500; } +.safety[open] .safetyToggle svg { transform: rotate(180deg); } +.safetyCopy { display: grid; gap: 6px; padding: 2px 0 6px 11px; border-left: 1px solid var(--color-line-strong); } +.safetyCopy p { margin: 0; } +.formActions { margin-top: 8px; } +/* Keep the entry action in reach. Review and confirmation remain after the quote details. */ +.dialogBody .formActions[data-pin] { + position: sticky; + z-index: 1; + bottom: -12px; + margin-inline: calc(-1 * var(--dialog-gutter)); + padding: 6px var(--dialog-gutter) 14px; + background: var(--color-raised); +} .primary { display: flex; align-items: center; justify-content: center; gap: 10px; min-height: 48px; width: 100%; padding: 12px 16px; border: 1px solid transparent; border-radius: 12px; background: var(--color-brand); color: var(--color-inverse); font-size: 14px; font-weight: 600; cursor: pointer; transition: background 160ms, transform 160ms cubic-bezier(.16, 1, .3, 1); } +.primary[data-connect] { + display: grid; + grid-template-columns: 30px minmax(0, 1fr) 18px; + min-height: 52px; + padding: 8px 12px 8px 10px; + text-align: left; +} +.primary[data-connect]:not(:disabled) { + border-color: color-mix(in srgb, var(--color-brand-strong) 72%, var(--color-brand)); + box-shadow: inset 0 1px 0 color-mix(in srgb, white 19%, transparent), 0 2px 4px color-mix(in srgb, var(--color-brand) 15%, transparent); +} +.actionMark { + display: grid; + width: 30px; + height: 30px; + place-items: center; + border-radius: 7px; + background: color-mix(in srgb, currentColor 13%, transparent); +} +.actionArrow { justify-self: end; opacity: .84; } .panel button:disabled { opacity: .55; cursor: not-allowed; } .primary:disabled { opacity: 1; background: var(--color-paper); border-color: var(--color-line); color: var(--color-muted); } /* beside the slide-to-confirm rail the ordinary button steps back, but stays a full-size, plainly labelled control */ .secondary { display: flex; align-items: center; justify-content: center; gap: 10px; min-height: 44px; width: 100%; margin-top: 8px; padding: 10px 16px; border: 1px solid var(--color-line-strong); border-radius: 12px; background: transparent; color: var(--color-ink); font-size: 13px; font-weight: 600; cursor: pointer; transition: background 160ms ease, transform 160ms cubic-bezier(.16, 1, .3, 1); } .secondary:disabled { color: var(--color-muted); cursor: default; } .disclaimer { margin-top: 12px; color: var(--color-muted); font-size: 11px; line-height: 1.65; } -.footer { display: flex; justify-content: space-between; gap: 12px; padding-top: 16px; margin-top: 20px; border-top: 1px solid var(--color-line); font-size: 11px; color: var(--color-muted); } -.footer a, .explorerLinks a { display: inline-flex; align-items: center; gap: 3px; text-underline-offset: 3px; } +.footer { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 2px 10px; + margin-top: 16px; + padding-top: 6px; + border-top: 1px solid var(--color-line); + font-size: 12px; + line-height: 1.4; +} +.panel > .footer { + margin: 0; + padding: 4px var(--dialog-gutter); + background: var(--color-card); +} +.footerMeta { display: flex; flex-wrap: wrap; align-items: center; gap: 0 9px; } +.providerLink, .historyLink { + display: inline-flex; + align-items: center; + gap: 5px; + min-height: 40px; + border-radius: 7px; + color: var(--color-body); + font-weight: 500; + text-underline-offset: 3px; +} +.providerLink svg, .historyLink svg { flex-shrink: 0; color: var(--color-muted); } +.feeLabel { color: var(--color-muted); font-size: 11px; } +.feeLabel strong { color: var(--color-body); font-weight: 600; } +.historyLink { + margin-left: auto; + padding-inline: 9px; + margin-inline-end: -9px; + color: var(--color-ink); + transition: background 160ms ease; +} +.explorerLinks a { display: inline-flex; align-items: center; gap: 3px; text-underline-offset: 3px; } .error { display: flex; align-items: flex-start; gap: 8px; min-width: 0; margin: 12px 0; padding: 12px; color: var(--color-down-ink); background: var(--color-down-soft); border-radius: 8px; font-size: 12px; line-height: 1.6; overflow-wrap: anywhere; } .error > span { flex: 1; min-width: 0; } .error > svg { flex-shrink: 0; margin-top: 2px; } @@ -152,21 +310,43 @@ .spinner { animation: bridge-spin 1s linear infinite; } @keyframes bridge-spin { to { transform: rotate(360deg); } } .trigger:focus-visible, .panel :is(button, a, input, select, summary):focus-visible { outline: 2px solid var(--color-brand); outline-offset: 4px; } +.safety summary:focus-visible, .footer a:focus-visible { outline: 2px solid var(--color-brand); outline-offset: -2px; } @media (hover: hover) and (pointer: fine) { - .networkTrigger:not(:disabled):hover { background: var(--color-paper); border-color: var(--color-line); } + .networkTrigger:not(:disabled):hover { background: var(--color-card); border-color: var(--color-line-strong); } .assetTrigger:not(:disabled):hover { border-color: var(--color-brand); } .close:hover, .reverse:hover, .externalAction:hover { color: var(--color-ink); background: var(--color-paper); } .trigger:hover { color: var(--color-ink); background: var(--color-line); } .primary:not(:disabled):hover { background: var(--color-brand-strong); } - .footer a:hover, .explorerLinks a:hover { text-decoration: underline; } + .safety summary:hover .safetyToggle, .providerLink:hover, .explorerLinks a:hover { text-decoration: underline; } + .historyLink:hover { background: var(--color-paper); } } .primary:not(:disabled):active, .reverse:not(:disabled):active, .trigger:active { transform: scale(.97); } +.primary[data-connect]:not(:disabled):active { transform: scale(.985); } .networkTrigger:not(:disabled):active:not(:focus-visible) { transform: scale(.98); } .networkTrigger:focus-visible { transition: none; } +@media (min-width: 521px) and (max-height: 700px) { + .panel { max-height: calc(100dvh - 20px); } + .dialogHeader { padding-block: 10px; } + .dialogBody { padding-top: 8px; } + .dialogBody .routeAvailability { margin-top: 2px; } + .dialogBody .amountStack { margin-top: 8px; } + .dialogBody .amountSection { padding-block: 9px 8px; } + .dialogBody .safety { margin-top: 2px; } + .dialogBody .safety summary { min-height: 36px; } + .panel > .footer { padding-block: 2px; } +} @media (max-width: 520px) { - .panel { width: 100%; top: auto; bottom: 0; left: 0; max-height: calc(100dvh - 16px); transform: none; padding: 24px 22px calc(20px + env(safe-area-inset-bottom)); border-radius: 22px 22px 0 0; border-bottom: 0; box-shadow: var(--shadow-sheet); } + .panel { --dialog-gutter: 17px; width: 100%; top: auto; bottom: 0; left: 0; max-height: calc(100dvh - 16px); transform: none; border-radius: 22px 22px 0 0; border-bottom: 0; box-shadow: var(--shadow-sheet); } .panel[data-starting-style], .panel[data-ending-style] { transform: translateY(16px); } - .amountRow input { font-size: 34px; } + .dialogHeader { padding-block: 12px; } + .dialogBody { padding-top: 12px; } + .panel > .footer { padding-bottom: calc(4px + env(safe-area-inset-bottom)); } + .route { grid-template-columns: minmax(0, 1fr) 32px minmax(0, 1fr); gap: 6px; } + .networkTrigger { grid-template-columns: 24px minmax(0, 1fr) 11px; gap: 5px; padding-inline: 7px; } + .networkTrigger .networkMark { width: 24px; height: 24px; } + .networkTrigger .networkMark img { max-width: 24px; max-height: 24px; } + .networkTrigger .networkName { font-size: 13px; } + .amountRow input { font-size: 32px; } } -@media (max-width: 360px) { .networkTrigger { grid-template-columns: 24px minmax(0, 1fr) 13px; gap: 5px; padding-inline: 4px; } .networkTrigger .networkMark { width: 24px; height: 24px; } .networkTrigger .networkMark img { max-width: 24px; max-height: 24px; } .networkTrigger .networkName { font-size: 13px; } } -@media (prefers-reduced-motion: reduce) { .panel, .backdrop, .primary, .secondary, .trigger, .reverse, .networkTrigger, .networkPopup, .assetTrigger { transition: none; } .spinner { animation: none; } } +@media (max-width: 360px) { .panel { --dialog-gutter: 13px; } .networkTrigger { padding-inline: 5px; } } +@media (prefers-reduced-motion: reduce) { .panel, .backdrop, .primary, .secondary, .trigger, .reverse, .networkTrigger, .networkPopup, .assetTrigger, .historyLink { transition: none; } .primary[data-connect]:not(:disabled):active { transform: none; } .spinner { animation: none; } } diff --git a/app/src/components/bridge/BridgeDialog.tsx b/app/src/components/bridge/BridgeDialog.tsx index 8b63876c..9c1d38fa 100644 --- a/app/src/components/bridge/BridgeDialog.tsx +++ b/app/src/components/bridge/BridgeDialog.tsx @@ -1,12 +1,12 @@ "use client"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useRef, useState, type ReactNode } from "react"; import { createPortal } from "react-dom"; 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, ArrowRight, ArrowUpRight, Check, ChevronDown, ChevronRight, CircleAlert, CircleDollarSign, Clock3, Fuel, History, Info, 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 { DISCARD_AFTER_MS } from "@/lib/bridge/client"; @@ -185,7 +185,6 @@ export default function BridgeDialog({ open, onOpenChange, restoreFocus, slot = }) { const onPage = slot !== null; const bridge = useBridge(open || onPage); - const popup = useRef(null); const [connecting, setConnecting] = useState(false); const transferring = bridge.tracked !== null; const linkKey = link ? `${linkCount}:${bridgeLinkKey(link)}` : ""; @@ -208,41 +207,82 @@ export default function BridgeDialog({ open, onOpenChange, restoreFocus, slot = } const circle = (bridge.tracked?.provider ?? bridge.quote?.provider) === "cctp"; - const footer = ( -
    - {circle - ? Powered by Circle CCTP - : Powered by Relay } - {onPage ? null : onOpenChange(false)}>Your transfers} - 0 Openlaunch fee -
    - ); + const footer = onOpenChange(false)} />; const content = transferring ? : ; return ( <> {onPage ? createPortal(
    {content}{footer}
    , slot) : null} - - - - { if (event.key === "Escape") event.stopPropagation(); }}> -
    - Bridge - -
    - {transferring ? "Your transfer, from departure to arrival." : "Same wallet. A new network."} - {content} - {footer} -
    -
    -
    + + {content} + {connecting ? { setConnecting(false); if (!onPage) onOpenChange(true); }} /> : null} ); } -export function BridgeForm({ bridge: b, connect }: { bridge: Bridge; connect: () => void }) { +export function BridgeModal({ open, onOpenChange, description, children, footer, finalFocus, closeLabel = "Close bridge" }: { + open: boolean; + onOpenChange: (open: boolean) => void; + description: string; + children: ReactNode; + footer: ReactNode; + finalFocus?: false | (() => HTMLElement | null); + closeLabel?: string; +}) { + const popup = useRef(null); + return ( + + + + { if (event.key === "Escape") event.stopPropagation(); }}> +
    + +
    + Bridge + {description} +
    + +
    +
    {children}
    + {footer} +
    +
    +
    + ); +} + +export function BridgeFooter({ circle, hideHistory = false, historyHref = "/bridge#transfers", onHistory }: { + circle: boolean; + hideHistory?: boolean; + historyHref?: string; + onHistory?: () => void; +}) { + const provider = circle ? "Circle CCTP" : "Relay"; + return ( +
    +
    + + Via {provider} + + 0% app fee +
    + {hideHistory ? null : Your transfers} +
    + ); +} + +export function BridgeForm({ bridge: b, connect, idPrefix = "bridge" }: { bridge: Bridge; connect: () => void; idPrefix?: string }) { if (b.approval && (b.approval.status === "pending" || b.approval.status === "uncertain")) return ; + const amountId = `${idPrefix}-amount`; + const balanceId = `${idPrefix}-balance`; + const gasNoteId = `${idPrefix}-gas-note`; const locked = b.busy || b.approvalBusy; const loading = locked || b.quoteLoading; const reviewing = !!b.quote && !b.quoteExpired; @@ -265,6 +305,8 @@ export function BridgeForm({ bridge: b, connect }: { bridge: Bridge; connect: () : reviewing && b.approvalRequired ? `Approve ${nativeAmount(b.amount)} ${inputCurrency.symbol}` : reviewing ? `Bridge to ${destination.name}` : b.quoteError ? "Retry quote" : !b.canQuote ? "Enter an amount" : "Get quote"; const error = (!b.quoteRejection && b.quoteError) || b.error || b.storageError || b.approvalError; + const quoteHint = b.quoteLoading ? "Getting quote…" : b.quoteError ? "Quote unavailable" + : b.address ? "Enter amount for a quote" : "Connect to see a quote"; const mode = signalMode({ phase: b.phase, tracked: false, approvalActive: b.approvalBusy, quoteLoading: b.quoteLoading, reviewing, quoteExpired: needsRefresh, blocked: !!b.quoteRejection || !!b.quoteError }); // the deposit, and only the deposit, can also be confirmed by sliding: never an approval, a quote or a refresh const slideReady = !!b.address && reviewing && !b.approvalRequired && !loading && !b.allowanceLoading; @@ -291,21 +333,38 @@ 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} + {b.originChainId === 4663 || b.destinationChainId === 4663 ? ( +
+ Robinhood supports ETH and USDG +

USDC routes on Robinhood are unavailable because they don’t pass our current safety checks.

+
+ ) : 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} -
- -
- b.setAmount(event.target.value)} disabled={locked} aria-describedby="bridge-balance bridge-gas-note" /> - +
+
+ +
+ b.setAmount(event.target.value)} disabled={locked} aria-describedby={`${balanceId} ${gasNoteId}`} /> + +
+

{!b.address ? `${inputCurrency.symbol} on ${origin.name}` : b.balanceLoading ? "Checking your balance…" : b.balance !== undefined ? `Available: ${nativeAmount(formatUnits(b.balance, inputCurrency.decimals))} ${inputCurrency.symbol}` : b.balanceError ?? "Balance unavailable"}

+ {b.address && erc20Input && b.originChainId !== 5042 && b.nativeBalance !== undefined ?

For gas: {nativeAmount(b.nativeBalance)} {origin.symbol}

: null} +
+
+
+ Receive on {destination.name} + + {!b.quote && !b.quoteRejection ? <> + {b.quoteLoading ? : null} + {quoteHint} + : null} + +
+
-

{!b.address ? `${inputCurrency.symbol} on ${origin.name}` : b.balanceLoading ? "Checking your balance…" : b.balance !== undefined ? `Available: ${nativeAmount(formatUnits(b.balance, inputCurrency.decimals))} ${inputCurrency.symbol}` : b.balanceError ?? "Balance unavailable"}

- {b.address && erc20Input && b.originChainId !== 5042 && b.nativeBalance !== undefined ?

For gas: {nativeAmount(b.nativeBalance)} {origin.symbol}

: null}
-
Receive on {destination.name}
- {b.quote ? (
Estimated output{needsRefresh ? "Quote expired" : "Live quote"}
@@ -321,26 +380,40 @@ export function BridgeForm({ bridge: b, connect }: { bridge: Bridge; connect: ()

{needsRefresh ? "This quote expired. Refresh and review the new amounts." : circle ? "Circle takes one fixed fee, so this is the exact amount you receive. Arrival time and gas can change." : "0.5% slippage limit. Arrival time and gas can change."}

{b.approvalRequired ?

First, approve only {nativeAmount(b.amount)} {inputCurrency.symbol} for {circle ? "Circle’s transfer contract" : "Relay’s deposit contract"}. Then review a fresh quote and confirm the bridge separately. Approval alone does not move your funds and uses additional {origin.symbol} for gas.

: null}
- ) : b.quoteRejection ? :
- {b.quoteLoading ? : } -

{b.quoteLoading ? "Updating your quote…" : b.quoteError ? "No quote available yet." : b.address ? "Enter an amount. We’ll find your route." : "See your route before you commit."}
- {b.quoteLoading ? "Keep typing. We’ll use your latest amount." : "Quotes update automatically. No wallet request until you confirm."}

-
} + ) : b.quoteRejection ? : null} {b.address ? : null} {error ?

{error}

: null} - {slideReady ? } back={convertsAsset ? : null} onConfirm={runWalletAction} /> : null} - -

Keep some {origin.symbol} on {origin.name} for gas. {convertsAsset ? "Relay converts the asset at the quoted rate. " : ""}Bridging uses {provider}, a third-party protocol, and carries risk. Openlaunch does not operate {provider}, holds no funds in transit, and is not responsible for delays, refunds or losses. {erc20Input ? `An unspent ${inputCurrency.symbol} approval remains until used or revoked.` : "No token approvals required."}

+
+ + Gas in {origin.symbol} on {origin.name} + Details + +
+

Keep some {origin.symbol} on {origin.name} for gas. {convertsAsset ? "Relay converts the asset at the quoted rate. " : ""}{erc20Input ? `An unused ${inputCurrency.symbol} approval stays active until you use or revoke it.` : "No token approvals required."}

+

Bridging uses {provider}, a third-party protocol, and carries risk. Openlaunch does not operate {provider}, holds no funds in transit, and is not responsible for delays, refunds or losses.

+
+
+
+ {slideReady ? } back={convertsAsset ? : null} onConfirm={runWalletAction} /> : null} + +
); } diff --git a/app/src/components/bridge/Signal.module.css b/app/src/components/bridge/Signal.module.css index de2da574..cf55dc15 100644 --- a/app/src/components/bridge/Signal.module.css +++ b/app/src/components/bridge/Signal.module.css @@ -2,14 +2,14 @@ The board is a dark object in both themes (an LED strip is not a light-mode surface), so its plate and lights are literal colours; everything around it follows the site tokens. */ -.board { margin-bottom: 18px; padding: 10px 10px 8px; border: 1px solid #1c1f27; border-radius: 14px; background: #050608; } +.board { margin-bottom: 14px; padding: 8px 10px 7px; border: 1px solid #232832; border-radius: 14px; background: linear-gradient(165deg, #111722, #050608 62%); box-shadow: inset 0 1px 0 rgb(255 255 255 / .07); } .boardRuler { display: block; height: 0; } .boardCanvas { display: block; width: 100%; } -.caption { margin: 6px 2px 0; min-height: 20px; overflow: hidden; color: #6d9dff; font-family: var(--font-dots), var(--font-mono); font-variation-settings: "ROND" 100; font-size: 13px; font-weight: 800; line-height: 20px; text-align: center; white-space: nowrap; } +.caption { margin: 3px 2px 0; min-height: 18px; overflow: hidden; color: #6d9dff; font-family: var(--font-dots), var(--font-mono); font-variation-settings: "ROND" 100; font-size: 12px; font-weight: 800; line-height: 18px; text-align: center; white-space: nowrap; } .caption[data-mode="idle"], .caption[data-mode="expired"] { color: #7b8494; } .caption[data-mode="arrived"] { color: #3ddc97; } .caption[data-mode="refund"], .caption[data-mode="attention"], .caption[data-mode="blocked"] { color: #fbbf24; } -.caption span { display: inline-block; animation: roll 360ms cubic-bezier(.16, 1, .3, 1) both; } +.caption span { display: inline-block; animation: roll 220ms cubic-bezier(.16, 1, .3, 1) both; } @keyframes roll { from { opacity: 0; transform: translateY(105%); } to { opacity: 1; transform: none; } } .slide { margin-top: 4px; } diff --git a/app/src/components/bridge/bridge-ui.test.ts b/app/src/components/bridge/bridge-ui.test.ts index f726c478..0d4e56ba 100644 --- a/app/src/components/bridge/bridge-ui.test.ts +++ b/app/src/components/bridge/bridge-ui.test.ts @@ -47,7 +47,7 @@ test("the /bridge page shows the same controller's panel instead of starting a s assert.equal((panel.match(/useBridge\(/g) ?? []).length, 1, "one hook call serves the dialog and the page"); assert.match(panel, /const bridge = useBridge\(open \|\| onPage\);/); assert.match(panel, /onPage \? createPortal\(
\{content\}\{footer\}<\/section>, slot\) : null/); - assert.match(panel, /\("#bridge-amount"\)\?\.focus/); // a link preselects through the pickers' guarded setters, once, and carries no amount @@ -94,7 +94,7 @@ test("the signal board is decorative and sliding confirms the deposit only", () }); test("bridge review names minimum, separate gas, recipient, risk and expiry", () => { - for (const content of ["Minimum received", "Source gas", "Receiving wallet", "0.5% slippage", "carries risk", "This quote expired", "Refresh quote", "0 Openlaunch fee"]) assert.ok(panel.includes(content), content); + for (const content of ["Minimum received", "Source gas", "Receiving wallet", "0.5% slippage", "carries risk", "This quote expired", "Refresh quote", "0% Openlaunch fee"]) assert.ok(panel.includes(content), content); assert.match(panel, /if \(reviewing\) void \(b\.approvalRequired \? b\.approve\(\) : b\.confirm\(\)\);/); assert.doesNotMatch(panel, /dangerouslySetInnerHTML|setInterval|sendTransaction/); }); @@ -102,8 +102,9 @@ test("bridge review names minimum, separate gas, recipient, risk and expiry", () test("bridge dialog has accessible focus, mobile layout and reduced motion", () => { assert.match(panel, /Dialog.Popup[^>]+initialFocus=\{popup\}/); assert.match(panel, /finalFocus=\{connecting \? false : restoreFocus\}/); - assert.match(panel, /Dialog.Close[^>]+aria-label="Close bridge"/); - assert.match(panel, /htmlFor="bridge-amount"/); + assert.match(panel, /Dialog.Close[^>]+aria-label=\{closeLabel\}/); + assert.match(panel, /closeLabel = "Close bridge"/); + assert.match(panel, /htmlFor=\{amountId\}/); assert.match(css, /prefers-reduced-motion: reduce/); assert.match(css, /max-height: calc\(100dvh - 16px\)/); assert.match(css, /:focus-visible/); @@ -186,7 +187,7 @@ test("USDC selectors separate selected token units from native gas and disallow 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, /
[\s\S]*Robinhood supports ETH and USDG[\s\S]*USDC routes on Robinhood are unavailable because they don’t pass our current safety checks\./); assert.match(panel, /chain === 99901 \? USDC only · Gas in HYPE<\/span>/); assert.ok(panel.includes('if (chain === 99901) return ")); assert.ok(hook.includes("try { bridgeLedger.record(tracked, quoted); } catch")); assert.match(hook, /transfers\.remove\(address\);\s+try \{ bridgeLedger\.forget\(address, requestId\); \} catch/); - assert.ok(panel.includes(`href="/bridge#transfers"`)); + assert.match(panel, /historyHref = "\/bridge#transfers"/); + assert.match(panel, /href=\{historyHref\}/); }); test("bridge, then buy stays two steps: the trade panel card opens and follows the one controller and never sends", () => { @@ -256,7 +258,7 @@ test("quotes update without locking typing or automatically submitting wallet ac assert.match(hook, /next\.originChainId === inputs\.current\.originChainId[^\n]+next\.amount === inputs\.current\.amount\) return/); assert.match(hook, /busy: sending \|\| approvalSending \|\| approvalPending,/); assert.match(panel, /const loading = locked \|\| b.quoteLoading/); - assert.match(panel, /input id="bridge-amount"[^\n]+disabled=\{locked\}/); + assert.match(panel, /input id=\{amountId\}[^\n]+disabled=\{locked\}/); assert.match(panel, /const actionDisabled = loading \|\| b\.allowanceLoading \|\| \(!!b\.address && !b\.canQuote\)/); assert.match(panel, /if \(actionDisabled\) return/); assert.match(panel, /Your quote updates automatically/); @@ -302,7 +304,8 @@ test("a Circle transfer is named as Circle's, reads allowance for Circle's spend assert.match(hook, /const statusUrl = observed \? bridgeStatusUrl\(observed\)/); assert.match(hook, /findCctpBurn\(pub, current\)/); assert.match(panel, /const circle = \(bridge\.tracked\?\.provider \?\? bridge\.quote\?\.provider\) === "cctp";/); - assert.match(panel, /Powered by Circle CCTP/); + assert.match(panel, /const provider = circle \? "Circle CCTP" : "Relay"/); + assert.match(panel, /href=\{circle \? "https:\/\/developers\.circle\.com\/cctp" : "https:\/\/relay\.link"\}/); assert.match(panel, /\{circle \? null : Transfer ID/); assert.match(panel, /Circle takes one fixed fee, so this is the exact amount you receive\./);