From 3f0b6cec8bdcaedb9c41d6f229ebd3edeab050f9 Mon Sep 17 00:00:00 2001 From: Vasanth T Date: Tue, 10 Nov 2026 18:11:29 +0530 Subject: [PATCH] feat(bridge): bridge, then buy from the token page When a buyer is short of what a buy pays with, the trade panel shows a two-step card: bridge first, then buy. It reads the wallet's bridge assets on every other network and suggests the largest source worth moving that can pay its own gas. It opens the header bridge on that route (no amount). It then follows the transfer from this browser's ledger and reads the pay balance again as soon as the funds arrive. - lib/bridge/fund.ts: pay-asset mapping, holdings reader (each network read stands alone), source pick by USD value or same kind, two-hour follow window, step derivation - BridgeProvider: open on a route from inside the app, wake the one controller closed for background tracking, re-apply a repeated route, and drop a stale request once the /bridge page takes over - BridgeToBuy + Fund.module.css: Signal-plate card, never signs or sends - TradePanel: shortfall, balance refetch on arrival, card on the buy side - /ui-review-bridge fund scenes, unit and guard tests, docs --- app/src/app/ui-review-bridge/BridgeReview.tsx | 20 ++ app/src/components/bridge/BridgeDialog.tsx | 6 +- app/src/components/bridge/BridgeProvider.tsx | 37 +++- app/src/components/bridge/BridgeToBuy.tsx | 179 ++++++++++++++++++ app/src/components/bridge/Fund.module.css | 33 ++++ app/src/components/bridge/bridge-ui.test.ts | 17 +- app/src/components/launchpad/TradePanel.tsx | 11 +- app/src/lib/bridge/fund.test.ts | 88 +++++++++ app/src/lib/bridge/fund.ts | 96 ++++++++++ docs/bridge.md | 11 ++ 10 files changed, 485 insertions(+), 13 deletions(-) create mode 100644 app/src/components/bridge/BridgeToBuy.tsx create mode 100644 app/src/components/bridge/Fund.module.css create mode 100644 app/src/lib/bridge/fund.test.ts create mode 100644 app/src/lib/bridge/fund.ts diff --git a/app/src/app/ui-review-bridge/BridgeReview.tsx b/app/src/app/ui-review-bridge/BridgeReview.tsx index bdde88c4..8605f785 100644 --- a/app/src/app/ui-review-bridge/BridgeReview.tsx +++ b/app/src/app/ui-review-bridge/BridgeReview.tsx @@ -14,6 +14,9 @@ import { BRIDGE_QUOTE_DEBOUNCE_MS } from "@/lib/bridge/quote-session"; import styles from "@/components/bridge/BridgeDialog.module.css"; import { HistoryBoard } from "@/components/bridge/BridgeHistory"; import type { BridgeHistoryEntry } from "@/lib/bridge/history"; +import { FundCard } from "@/components/bridge/BridgeToBuy"; +import type { BridgeHolding, FundStep } from "@/lib/bridge/fund"; +import type { BridgeAsset, BridgeDestinationId } from "@/lib/bridge/types"; const wallet = "0x03508bB71268BBA25ECaCC8F620e01866650532c" as const; const requestId = `0x${"1".repeat(64)}` as const; @@ -35,6 +38,18 @@ const HISTORY_ROWS: BridgeHistoryEntry[] = [ type HistoryScene = "rows" | "empty" | "loading" | "no wallet" | "unsaved"; const HISTORY_SCENES: HistoryScene[] = ["rows", "empty", "loading", "no wallet", "unsaved"]; +// "Bridge, then buy" in the trade panel, one card per step it can show. +type FundScene = { name: string; step: FundStep; destination: BridgeDestinationId; asset: BridgeAsset; symbol: string; row: BridgeHistoryEntry | null; shortBy: string | null; suggestion: BridgeHolding | null; searching: boolean }; +const FUND_SCENES: FundScene[] = [ + { name: "short, source found", step: "short", destination: 4663, asset: "ETH", symbol: "ORBIT", row: null, shortBy: "0.0125 ETH", suggestion: { chainId: 8453, asset: "USDC", amount: 120_500_000n, decimals: 6 }, searching: false }, + { name: "short, checking", step: "short", destination: 4663, asset: "ETH", symbol: "ORBIT", row: null, shortBy: "0.0125 ETH", suggestion: null, searching: true }, + { name: "empty, nothing elsewhere", step: "short", destination: 5042, asset: "USDC", symbol: "ARCO", row: null, shortBy: null, suggestion: null, searching: false }, + { name: "moving", step: "moving", destination: 4663, asset: "ETH", symbol: "ORBIT", row: { ...HISTORY_ROWS[0], originChainId: 42161, createdAt: minutesAgo(1) }, shortBy: null, suggestion: null, searching: false }, + { 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 }, +]; + export default function BridgeReview() { const [historyScene, setHistoryScene] = useState("rows"); const [open, setOpen] = useState(false); @@ -110,6 +125,11 @@ export default function BridgeReview() {
{}} /> +

Bridge, then buy

+

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

+
+ {FUND_SCENES.map(({ name, ...scene }) =>
{name}
{}} onOpen={() => {}} onDismiss={() => {}} />
)} +
); diff --git a/app/src/components/bridge/BridgeDialog.tsx b/app/src/components/bridge/BridgeDialog.tsx index 1af56fcb..df5d72c6 100644 --- a/app/src/components/bridge/BridgeDialog.tsx +++ b/app/src/components/bridge/BridgeDialog.tsx @@ -171,19 +171,21 @@ function FeeLimitNotice({ rejection, inputSymbol, gasSymbol }: { rejection: Brid * slot (bridge-slot.ts), inside that page instead: the same hook either way, so a transfer is quoted, sent and tracked * once. `link` is the page's shareable route (lib/bridge/link.ts); it only preselects networks and tokens. */ -export default function BridgeDialog({ open, onOpenChange, restoreFocus, slot = null, link = null }: { +export default function BridgeDialog({ open, onOpenChange, restoreFocus, slot = null, link = null, linkCount = 0 }: { open: boolean; onOpenChange: (open: boolean) => void; restoreFocus: () => HTMLElement | null; slot?: HTMLElement | null; link?: BridgeLink | null; + /** Bumped by each request for a route, so asking for the same route again re-applies it. */ + linkCount?: number; }) { 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 = bridgeLinkKey(link); + const linkKey = link ? `${linkCount}:${bridgeLinkKey(link)}` : ""; const appliedLink = useRef(""); // Each link is applied once, through the same guarded setters the pickers use: they refuse while an approval or a diff --git a/app/src/components/bridge/BridgeProvider.tsx b/app/src/components/bridge/BridgeProvider.tsx index 68381a9b..69deedcf 100644 --- a/app/src/components/bridge/BridgeProvider.tsx +++ b/app/src/components/bridge/BridgeProvider.tsx @@ -1,23 +1,29 @@ "use client"; import dynamic from "next/dynamic"; -import { createContext, useCallback, useContext, useRef, useState, useSyncExternalStore } from "react"; +import { createContext, useCallback, useContext, useMemo, useRef, useState, useSyncExternalStore } from "react"; import { ArrowLeftRight } from "lucide-react"; import { bridgeSlot } from "./bridge-slot"; +import type { BridgeLink } from "@/lib/bridge/link"; import styles from "./BridgeDialog.module.css"; const BridgeDialog = dynamic(() => import("./BridgeDialog"), { ssr: false }); -const BridgeContext = createContext<(() => void) | null>(null); +type OpenBridge = (link?: BridgeLink) => void; +const BridgeContext = createContext<{ show: OpenBridge; wake: () => void } | null>(null); /** Mounted once for both navigation variants and for the /bridge page; closing the panel does not stop tracking. */ export function BridgeProvider({ children }: { children: React.ReactNode }) { const [activated, setActivated] = useState(false); const [open, setOpen] = useState(false); + // a route asked for by the caller (the token page's "bridge, then buy"); the count makes a repeat request apply again + const [requested, setRequested] = useState<{ link: BridgeLink; count: number } | null>(null); const trigger = useRef(null); // the /bridge page's slot: while it exists the panel lives in the page, and the dialog stays shut const slot = useSyncExternalStore(bridgeSlot.subscribe, bridgeSlot.getSnapshot, bridgeSlot.getServerSnapshot); if (slot.node && open) setOpen(false); - const show = useCallback(() => { + // the page's own route takes over; leaving the page must not bring back an older request + if (slot.node && requested) setRequested(null); + const show = useCallback((link?: BridgeLink) => { const page = bridgeSlot.getSnapshot().node; if (page) { page.scrollIntoView({ block: "nearest" }); @@ -25,29 +31,42 @@ export function BridgeProvider({ children }: { children: React.ReactNode }) { return; } trigger.current = document.activeElement instanceof HTMLElement ? document.activeElement : null; + if (link) setRequested((prior) => ({ link, count: (prior?.count ?? 0) + 1 })); setActivated(true); setOpen(true); }, []); + // Mount the controller without opening anything, so a transfer saved in this browser keeps being tracked while the + // buyer stays on a token page. Tracking never asks the wallet for anything. + const wake = useCallback(() => setActivated(true), []); + const value = useMemo(() => ({ show, wake }), [show, wake]); const restoreFocus = useCallback(() => trigger.current?.isConnected ? trigger.current : document.querySelector('[aria-controls="mobile-menu"]'), []); return ( - + {children} - {activated || slot.node ? : null} + {activated || slot.node ? : null} ); } -/** Opens the bridge panel from anywhere inside the provider (the ⌘K search uses it). Null outside it. */ -export function useOpenBridge(): (() => void) | null { - return useContext(BridgeContext); +/** + * Opens the bridge panel from anywhere inside the provider (the ⌘K search uses it), optionally on a route. The route + * goes through the panel's guarded pickers, so it never changes a transfer or approval in progress. Null outside it. + */ +export function useOpenBridge(): OpenBridge | null { + return useContext(BridgeContext)?.show ?? null; +} + +/** Starts background tracking of a saved transfer without opening the panel. Null outside the provider. */ +export function useWakeBridge(): (() => void) | null { + return useContext(BridgeContext)?.wake ?? null; } /** `compact` drops the label (icon only) where the header has no room: the whole desktop strip, resting or floating. */ export function BridgeButton({ block = false, compact = false, onOpen }: { block?: boolean; compact?: boolean; onOpen?: () => void }) { - const show = useContext(BridgeContext); + const show = useContext(BridgeContext)?.show; return ( : null} + +
    +
  1. + {step === "arrived" ? : "1"} +
    + {step === "arrived" ? `${row!.destinationAsset} arrived on ${to}` : `Bridge ${asset} to ${to}`} + {step === "short" ? <> + {shortBy ?

    This buy needs {shortBy} more on {to}.

    :

    You have no {asset} on {to} yet.

    } +

    {searching ? "Checking your other networks…" : suggestion ? `You have ${formatFundAmount(suggestion.amount, suggestion.decimals)} ${suggestion.asset} on ${BRIDGE_CHAINS[suggestion.chainId].name}.` : "No funds found on your other networks. You can still pick any route in the bridge."}

    + + : step === "moving" ? <> +

    {formatHistoryAmount(row!.amountIn, row!.inDecimals)} {row!.originAsset} from {BRIDGE_CHAINS[row!.originChainId].name} is {row!.status === "delayed" ? "taking longer than usual" : "on its way"}.

    + + : step === "attention" ? <> +

    {historyStatus(row!).label}. Check this transfer in the bridge before sending again.

    + + :

    {received ? `${received} delivered. ` : ""}Your balance here updates in a moment.

    } +
    +
  2. +
  3. + 2 +
    + Buy {symbol} +

    {step === "arrived" ? "Use the buy button above. It asks your wallet separately." : "Once the funds land, buy above. Bridging never buys for you."}

    +
    +
  4. +
+ + ); +} diff --git a/app/src/components/bridge/Fund.module.css b/app/src/components/bridge/Fund.module.css new file mode 100644 index 00000000..33865a52 --- /dev/null +++ b/app/src/components/bridge/Fund.module.css @@ -0,0 +1,33 @@ +/* "Bridge, then buy" in the trade panel: a short strip of the Signal board (dark plate in both themes) over two + numbered steps, because they are a real sequence: the bridge lands first, the buy is its own wallet request. */ +.fund { display: grid; gap: 12px; padding: 12px; border: 1px solid var(--color-line); border-radius: 16px; background: var(--color-paper); } +.head { display: flex; align-items: stretch; gap: 8px; min-width: 0; } +.plate { flex: 1; display: flex; align-items: center; gap: 8px; min-width: 0; padding: 9px 12px; border: 1px solid #1c1f27; border-radius: 10px; background: #050608; } +.code { flex: none; color: rgb(var(--code)); font-family: var(--font-dots), var(--font-mono); font-size: 15px; font-weight: 800; font-variation-settings: "ROND" 100; letter-spacing: .06em; line-height: 1; text-shadow: 0 0 10px rgb(var(--code) / .45); } +.code[data-unknown] { color: #4a5262; text-shadow: none; } +.track { flex: 1 1 auto; min-width: 16px; height: 4px; color: #2c323d; background: radial-gradient(circle at center, currentColor 1.4px, transparent 1.9px) 0 50% / 6px 4px repeat-x; } +.word { flex: none; margin-left: 4px; color: #8b95a7; font-family: var(--font-dots), var(--font-mono); font-size: 12px; font-weight: 800; font-variation-settings: "ROND" 100; letter-spacing: .08em; text-transform: uppercase; } +.fund[data-step="short"] .word { color: #fbbf24; } +.fund[data-step="moving"] .track { color: #6d9dff; animation: fund-chase 900ms linear infinite; } +.fund[data-step="moving"] .word { color: #6d9dff; } +.fund[data-step="arrived"] .track, .fund[data-step="arrived"] .word { color: #3ddc97; } +.fund[data-step="attention"] .track { color: #fbbf24; opacity: .75; } +.fund[data-step="attention"] .word { color: #fbbf24; } +@keyframes fund-chase { to { background-position: 6px 50%; } } + +.steps { display: grid; gap: 10px; margin: 0; padding: 0 2px; list-style: none; } +.steps li { display: grid; grid-template-columns: 22px minmax(0, 1fr); column-gap: 10px; } +.mark { display: grid; place-items: center; width: 22px; height: 22px; border: 1px solid var(--color-line-strong); border-radius: 50%; color: var(--color-muted); font-size: 11px; font-weight: 650; font-variant-numeric: tabular-nums; } +.steps li[data-state="active"] .mark { border-color: var(--color-ink); color: var(--color-ink); } +.steps li[data-state="done"] .mark { border-color: var(--color-up); background: var(--color-up); color: var(--color-inverse); } +.steps li[data-state="warn"] .mark { border-color: var(--color-warm-ink); color: var(--color-warm-ink); } +.steps li[data-state="waiting"] { opacity: .62; } +.steps strong { display: block; color: var(--color-ink); font-size: 13px; font-weight: 650; line-height: 22px; } +.steps p { margin: 2px 0 0; color: var(--color-body); font-size: 12px; line-height: 1.5; text-wrap: pretty; } +.action { display: inline-flex; align-items: center; justify-content: center; gap: 7px; width: 100%; min-height: 40px; margin-top: 10px; padding: 0 14px; border: 1px solid var(--color-ink); border-radius: 999px; background: var(--color-ink); color: var(--color-inverse); font: inherit; font-size: 13px; font-weight: 650; cursor: pointer; } +.action:hover { filter: brightness(1.12); } +.link { margin-top: 6px; padding: 0; border: 0; background: none; color: var(--color-ink); font: inherit; font-size: 12px; font-weight: 600; text-decoration: underline; text-decoration-color: var(--color-line-strong); text-underline-offset: 3px; cursor: pointer; } +.action:focus-visible, .link:focus-visible, .dismiss:focus-visible { outline: 2px solid var(--color-brand); outline-offset: 2px; } +.dismiss { flex: none; display: grid; place-items: center; width: 38px; border: 1px solid var(--color-line); border-radius: 10px; background: var(--color-card); color: var(--color-muted); cursor: pointer; } +.dismiss:hover { color: var(--color-ink); } +@media (prefers-reduced-motion: reduce) { .fund[data-step="moving"] .track { animation: none; } } diff --git a/app/src/components/bridge/bridge-ui.test.ts b/app/src/components/bridge/bridge-ui.test.ts index 982f5188..e77641bd 100644 --- a/app/src/components/bridge/bridge-ui.test.ts +++ b/app/src/components/bridge/bridge-ui.test.ts @@ -30,7 +30,7 @@ test("bridge keeps one lazily loaded controller across desktop/mobile and dismis assert.match(header, / setOpen\(false\)\} \/>/); assert.match(provider, /dynamic\(\(\) => import\("\.\/BridgeDialog"\)/); // mounted on first use or when the /bridge page registers its slot, and kept mounted after either - assert.match(provider, /activated \|\| slot\.node \? /); + assert.match(provider, /activated \|\| slot\.node \? /); assert.doesNotMatch(provider, /open \? { + const card = read("./BridgeToBuy.tsx"); + const trade = read("../launchpad/TradePanel.tsx"); + assert.doesNotMatch(card, /useBridge\b|client-storage|sendTransaction|writeContract|useWriteContract|\.record\(|\.forget\(/); + assert.ok(card.includes("useOpenBridge()") && card.includes("useWakeBridge()")); + assert.ok(card.includes("bridgeLedger.read(address)")); + assert.ok(trade.includes(" setActivated(true), []);"), "waking mounts the controller closed so tracking continues"); + assert.ok(provider.includes("export function useWakeBridge")); + assert.ok(provider.includes("if (slot.node && requested) setRequested(null);"), "the /bridge page's route is never overwritten later by an older request"); + assert.ok(panel.includes("const linkKey = link ? `${linkCount}:${bridgeLinkKey(link)}` : \"\";"), "asking for the same route again re-applies it"); +}); + test("quotes update without locking typing or automatically submitting wallet actions", () => { const hook = read("./useBridge.ts"); assert.match(panel, /useBridge\(open \|\| onPage\)/); diff --git a/app/src/components/launchpad/TradePanel.tsx b/app/src/components/launchpad/TradePanel.tsx index d5b2dbbd..56a6a31d 100644 --- a/app/src/components/launchpad/TradePanel.tsx +++ b/app/src/components/launchpad/TradePanel.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; import { useRouter } from "next/navigation"; import { useBalance, useConfig, useReadContract, useSwitchChain } from "wagmi"; import { useHydratedAccount } from "@/lib/useHydratedAccount"; @@ -27,6 +27,7 @@ import { friendlyError } from "@/lib/errors"; import { Spinner } from "@/components/Skeleton"; import ConnectWallet from "@/components/ConnectWallet"; import { ChainLogo } from "./ChainLogo"; +import BridgeToBuy from "@/components/bridge/BridgeToBuy"; /** * In-page buy / sell straight against the token's Uniswap v4 pool via the @@ -113,6 +114,11 @@ export default function TradePanel({ chain, token, symbol, poolKey, poolId, feeR const busy = phase.k === "preparing" || phase.k === "approving" || phase.k === "signing" || phase.k === "sent"; const balance = side === "buy" ? (pay.native ? eth.data?.value : (qbal.data as bigint | undefined)) : (tok.data as bigint | undefined); const insufficient = amountIn !== null && balance !== undefined && amountIn + (side === "buy" ? buyReserve : 0n) > balance; + // Bridge, then buy: how far short a buy is, and a way to read the balance at once when bridged funds land + const shortBy = side === "buy" && insufficient && amountIn !== null && balance !== undefined ? amountIn + buyReserve - balance : null; + const refetchEth = eth.refetch; + const refetchQuote = qbal.refetch; + const refetchPay = useCallback(() => { void refetchEth(); void refetchQuote(); }, [refetchEth, refetchQuote]); // the pay asset is part of the key: a TWIG quote never prices an ETH buy, or the other way round const quoteKey = `${tradeQuoteKey(chain, token, side, amount)}${viaEth ? ":eth" : ""}`; @@ -409,6 +415,9 @@ export default function TradePanel({ chain, token, symbol, poolKey, poolId, feeR )} + {side === "buy" && isConnected && tradable ? ( + + ) : null} {side === "buy" && quote.key === "twig" && !viaEth && (balance === undefined || balance === 0n || insufficient) ? (

Need TWIG? Wrap GITLAWB 1:1 or buy TWIG at{" "} diff --git a/app/src/lib/bridge/fund.test.ts b/app/src/lib/bridge/fund.test.ts new file mode 100644 index 00000000..7aab256c --- /dev/null +++ b/app/src/lib/bridge/fund.test.ts @@ -0,0 +1,88 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import type { Address, Hex } from "viem"; +import { FUND_FOLLOW_MS, bridgeAssetForPay, followedBridge, formatFundAmount, fundStep, pickBridgeSource, readBridgeHoldings, type BridgeHolding } from "./fund.ts"; +import type { BridgeHistoryEntry } from "./history.ts"; +import { ARBITRUM_USDC, ARC_USDC, BASE_USDC, BRIDGE_ASSETS, BRIDGE_CHAIN_IDS, ETHEREUM_USDC, OPTIMISM_USDC, ROBINHOOD_USDG, type BridgeChainId } from "./types.ts"; + +const WALLET = "0x1111111111111111111111111111111111111111" as Address; +const NATIVE = "0x0000000000000000000000000000000000000000" as Address; +const hash = (n: number) => `0x${n.toString(16).padStart(64, "0")}` as Hex; +const eth = (chainId: BridgeChainId, value: number): BridgeHolding => ({ chainId, asset: "ETH", amount: BigInt(Math.round(value * 1e6)) * 10n ** 12n, decimals: 18 }); +const usd = (chainId: BridgeChainId, value: number, asset: "USDC" | "USDG" = "USDC"): BridgeHolding => ({ chainId, asset, amount: BigInt(Math.round(value * 1e6)), decimals: 6 }); + +test("a buy can be bridged for only when it pays with something the bridge delivers", () => { + assert.equal(bridgeAssetForPay(8453, { address: NATIVE, native: true }), "ETH"); + assert.equal(bridgeAssetForPay(4663, { address: NATIVE, native: true }), "ETH"); + assert.equal(bridgeAssetForPay(5042, { address: NATIVE, native: true }), "USDC", "Arc's gas is USDC"); + assert.equal(bridgeAssetForPay(5042, { address: ARC_USDC, native: false }), "USDC", "and its ERC-20 face is the same balance"); + assert.equal(bridgeAssetForPay(8453, { address: BASE_USDC.toLowerCase() as Address, native: false }), "USDC"); + assert.equal(bridgeAssetForPay(4663, { address: ROBINHOOD_USDG, native: false }), "USDG"); + assert.equal(bridgeAssetForPay(4663, { address: BASE_USDC, native: false }), null, "Base's USDC address means nothing on Robinhood"); + assert.equal(bridgeAssetForPay(8453, { address: "0x4200000000000000000000000000000000000006", native: false }), null, "WETH is not a bridge asset"); + assert.equal(bridgeAssetForPay(8453, { address: "0x9999999999999999999999999999999999999999", native: false }), null, "a pair token or stock is not either"); +}); + +test("holdings are read on every other network, each read standing alone", async () => { + const asked: string[] = []; + const holdings = await readBridgeHoldings({ + native: async (chainId) => { asked.push(`${chainId}:native`); if (chainId === 10) throw new Error("rpc down"); return 7n; }, + token: async (chainId, token) => { asked.push(`${chainId}:${token}`); return chainId === 1 ? (-1n as bigint) : 5n; }, + }, WALLET, 4663); + assert.ok(!asked.some((call) => call.startsWith("4663:")), "never the destination itself"); + const expected = BRIDGE_CHAIN_IDS.filter((id) => id !== 4663).reduce((sum, id) => sum + BRIDGE_ASSETS[id].length, 0); + assert.equal(asked.length, expected); + for (const token of [BASE_USDC, ARC_USDC, ETHEREUM_USDC, ARBITRUM_USDC, OPTIMISM_USDC]) assert.ok(asked.some((call) => call.endsWith(token)), token); + assert.ok(!holdings.some((h) => h.chainId === 10 && h.asset === "ETH"), "a network that fails is left out, not shown as empty"); + assert.ok(!holdings.some((h) => h.chainId === 1 && h.asset === "USDC"), "a nonsense answer is dropped"); + assert.deepEqual(holdings.find((h) => h.chainId === 5042), { chainId: 5042, asset: "USDC", amount: 5n, decimals: 6 }); + assert.deepEqual(holdings.find((h) => h.chainId === 42161 && h.asset === "ETH"), { chainId: 42161, asset: "ETH", amount: 7n, decimals: 18 }); +}); + +test("the suggested source is the largest one worth moving that can actually be sent", () => { + const destination = { chainId: 4663, asset: "ETH" } as const; + // with a price: dollars and ETH compare by value + assert.deepEqual(pickBridgeSource([eth(42161, 0.02), usd(8453, 120), eth(8453, 0.001)], destination, 3000), usd(8453, 120)); + assert.deepEqual(pickBridgeSource([eth(42161, 0.05), usd(8453, 120), eth(8453, 0.001)], destination, 3000), eth(42161, 0.05)); + // a token needs gas beside it; Arc's USDC is its own gas + assert.deepEqual(pickBridgeSource([usd(10, 500), eth(10, 0), eth(1, 0.01)], destination, 3000), eth(1, 0.01), "USDC on Optimism with no ETH there cannot be sent"); + assert.deepEqual(pickBridgeSource([usd(5042, 40), eth(1, 0.001)], destination, 3000), usd(5042, 40)); + // dust and the destination itself never count + assert.equal(pickBridgeSource([eth(42161, 0.0002), usd(5042, 0.5)], destination, 3000), null); + assert.equal(pickBridgeSource([eth(4663, 3)], destination, 3000), null); + assert.equal(pickBridgeSource([], destination, 3000), null); + // no price: like with like, the buy's own kind first + assert.deepEqual(pickBridgeSource([usd(8453, 900), eth(42161, 0.01), eth(1, 0.2)], destination, null), eth(1, 0.2)); + assert.deepEqual(pickBridgeSource([usd(8453, 900), eth(8453, 0.0001), eth(42161, 0.0001)], destination, null), usd(8453, 900), "with no ETH worth moving, dollars are still a route"); + assert.deepEqual(pickBridgeSource([usd(8453, 30), usd(5042, 80), eth(8453, 1)], { chainId: 4663, asset: "USDG" }, Number.NaN), usd(5042, 80), "a broken price is no price"); + // equal values: the lower chain id, so the answer never flips between renders + assert.equal(pickBridgeSource([usd(42161, 50), usd(8453, 50), eth(42161, 1), eth(8453, 1)], { chainId: 5042, asset: "USDC" }, null)!.chainId, 8453); +}); + +test("hint amounts keep four decimals at most and never show dust as zero", () => { + assert.equal(formatFundAmount(123456789n, 6), "123.4567"); + assert.equal(formatFundAmount(1_234_000_000n, 6), "1,234"); + assert.equal(formatFundAmount(10n ** 13n, 18), "<0.0001"); + assert.equal(formatFundAmount(0n, 18), "0"); + assert.equal(formatFundAmount(5n * 10n ** 16n, 18), "0.05"); +}); + +function row(overrides: Partial = {}): BridgeHistoryEntry { + return { requestId: hash(1), originChainId: 42161, destinationChainId: 4663, originAsset: "ETH", destinationAsset: "ETH", amountIn: "50000000000000000", inDecimals: 18, amountOut: "49800000000000000", outDecimals: 18, outKind: "quoted", status: "pending", reverted: false, createdAt: 1_000_000, sourceHash: hash(2), destinationHashes: [], recipient: null, ...overrides }; +} + +test("the trade panel follows the newest recent transfer into its network, until dismissed", () => { + const now = 1_000_000 + 60_000; + const rows = [row(), row({ requestId: hash(3), createdAt: 900_000 }), row({ requestId: hash(4), destinationChainId: 8453, createdAt: 1_050_000 })]; + assert.equal(followedBridge(rows, 4663, now, [])!.requestId, hash(1)); + assert.equal(followedBridge(rows, 4663, now, [hash(1).toUpperCase().replace("0X", "0x")])!.requestId, hash(3), "a dismissed one gives way to the next"); + assert.equal(followedBridge(rows, 5042, now, []), null); + assert.equal(followedBridge([row({ createdAt: now - FUND_FOLLOW_MS - 1 })], 4663, now, []), null, "an old transfer is history, not a step"); + assert.equal(followedBridge([row({ createdAt: now + 120_000 })], 4663, now, []), null, "a clock far ahead is not trusted"); + assert.equal(followedBridge([row({ recipient: "0x2222222222222222222222222222222222222222" })], 4663, now, []), null, "funds sent elsewhere are no help to this wallet's buy"); + + assert.equal(fundStep(null), "short"); + for (const status of ["waiting", "depositing", "pending", "submitted", "delayed"] as const) assert.equal(fundStep({ status }), "moving", status); + assert.equal(fundStep({ status: "success" }), "arrived"); + for (const status of ["refund", "failure", "uncertain"] as const) assert.equal(fundStep({ status }), "attention", status); +}); diff --git a/app/src/lib/bridge/fund.ts b/app/src/lib/bridge/fund.ts new file mode 100644 index 00000000..1c653998 --- /dev/null +++ b/app/src/lib/bridge/fund.ts @@ -0,0 +1,96 @@ +import { formatUnits, type Address } from "viem"; +import type { BridgeHistoryEntry } from "./history"; +import { BRIDGE_ASSETS, BRIDGE_CHAIN_IDS, bridgeCurrency, bridgeCurrencyAsset, isDollarAsset, type BridgeAsset, type BridgeChainId, type BridgeDestinationId } from "./types"; + +/** + * Bridge, then buy: the token page's way to fund a purchase from another network. Two separate steps by design (a + * Relay destination call runs from Relay's contract, not the buyer's wallet, so an atomic bridge-and-buy would need its + * own review). This file is the pure part: which bridge asset a buy pays with, where the wallet already holds funds, + * which of those to suggest, and which recent transfer the trade panel should follow. + */ + +/** The bridge asset a buy pays with on `chainId`, or null when the bridge cannot deliver it (TWIG, GITLAWB, pairs, stocks). */ +export function bridgeAssetForPay(chainId: BridgeDestinationId, pay: { address: Address; native: boolean }): BridgeAsset | null { + // the native asset is ETH on Base and Robinhood, and USDC on Arc + if (pay.native) return chainId === 5042 ? "USDC" : "ETH"; + return bridgeCurrencyAsset(chainId, pay.address)?.asset ?? null; +} + +export type BridgeHolding = { chainId: BridgeChainId; asset: BridgeAsset; amount: bigint; decimals: 6 | 18 }; +export type HoldingReader = { + native: (chainId: BridgeChainId, wallet: Address) => Promise; + token: (chainId: BridgeChainId, token: Address, wallet: Address) => Promise; +}; + +/** + * What the wallet holds of every bridge asset on every network but `destination`. Each read stands alone: a network + * that does not answer is left out, never counted as empty and never allowed to fail the others. + */ +export async function readBridgeHoldings(read: HoldingReader, wallet: Address, destination: BridgeDestinationId): Promise { + const jobs: Promise[] = []; + for (const chainId of BRIDGE_CHAIN_IDS) { + if (chainId === destination) continue; + for (const asset of BRIDGE_ASSETS[chainId]) { + const currency = bridgeCurrency(chainId, asset, "input"); + const native = /^0x0{40}$/.test(currency.address); + jobs.push((native ? read.native(chainId, wallet) : read.token(chainId, currency.address, wallet)).then( + (amount) => typeof amount === "bigint" && amount >= 0n ? { chainId, asset, amount, decimals: currency.decimals } : null, + () => null, + )); + } + } + return (await Promise.all(jobs)).filter((holding): holding is BridgeHolding => holding !== null); +} + +/** Below this, a holding is dust: not worth a bridge fee, so never suggested. */ +export const FUND_MIN_USD = 1; +/** The same floor for ETH when no ETH price is known. */ +export const FUND_MIN_ETH = 0.0005; +const units = (holding: BridgeHolding) => Number(formatUnits(holding.amount, holding.decimals)); + +/** + * The holding to suggest bridging from: the largest by dollar value, or, with no ETH price, the largest in the same kind + * of asset as the buy (ETH for ETH, dollars for dollars). A token can only be sent where the wallet also has gas, so a + * USDC holding counts only beside some ETH on its network; on Arc, USDC is the gas. Null when nothing is worth moving. + */ +export function pickBridgeSource(holdings: readonly BridgeHolding[], destination: { chainId: BridgeDestinationId; asset: BridgeAsset }, ethUsd: number | null): BridgeHolding | null { + const gas = new Map(holdings.filter((h) => h.asset === "ETH").map((h) => [h.chainId, h.amount])); + const sendable = holdings.filter((h) => h.chainId !== destination.chainId && h.amount > 0n && (h.asset === "ETH" || h.chainId === 5042 || (gas.get(h.chainId) ?? 0n) > 0n)); + const usd = (h: BridgeHolding) => isDollarAsset(h.asset) ? units(h) : ethUsd !== null && Number.isFinite(ethUsd) && ethUsd > 0 ? units(h) * ethUsd : null; + const priced = sendable.map((h) => ({ h, usd: usd(h) })); + if (ethUsd !== null && Number.isFinite(ethUsd) && ethUsd > 0) { + return priced.filter((p) => p.usd !== null && p.usd >= FUND_MIN_USD).sort((a, b) => b.usd! - a.usd! || a.h.chainId - b.h.chainId)[0]?.h ?? null; + } + // no ETH price: compare like with like only, the buy's own kind first + const worth = sendable.filter((h) => units(h) >= (isDollarAsset(h.asset) ? FUND_MIN_USD : FUND_MIN_ETH)); + const sameKind = worth.filter((h) => isDollarAsset(h.asset) === isDollarAsset(destination.asset)); + return (sameKind.length ? sameKind : worth).sort((a, b) => units(b) - units(a) || a.chainId - b.chainId)[0] ?? null; +} + +/** Four decimals at most for a hint; dust reads as "<0.0001", never as 0. */ +export function formatFundAmount(amount: bigint, decimals: number): string { + const [whole, fraction = ""] = formatUnits(amount, decimals).split("."); + const cut = fraction.slice(0, 4).replace(/0+$/, ""); + return whole === "0" && !cut && amount > 0n ? "<0.0001" : `${Number(whole).toLocaleString("en-US")}${cut ? `.${cut}` : ""}`; +} + +/** How long a bridge to this network stays on the token page: long enough to come back to, short enough to stay relevant. */ +export const FUND_FOLLOW_MS = 2 * 60 * 60_000; + +/** The newest transfer into `destination` from the last two hours that the buyer has not dismissed, or null. */ +export function followedBridge(rows: readonly BridgeHistoryEntry[], destination: BridgeDestinationId, now: number, dismissed: readonly string[]): BridgeHistoryEntry | null { + const skip = new Set(dismissed.map((id) => id.toLowerCase())); + return rows + .filter((row) => row.destinationChainId === destination && !row.recipient && !skip.has(row.requestId.toLowerCase()) && row.createdAt <= now + 60_000 && now - row.createdAt <= FUND_FOLLOW_MS) + .sort((a, b) => b.createdAt - a.createdAt)[0] ?? null; +} + +export type FundStep = "short" | "moving" | "arrived" | "attention"; + +/** Where the two steps stand: no bridge yet, one on its way, one delivered (buy next), or one that needs a look. */ +export function fundStep(row: Pick | null): FundStep { + if (!row) return "short"; + if (row.status === "success") return "arrived"; + if (row.status === "refund" || row.status === "failure" || row.status === "uncertain") return "attention"; + return "moving"; +} diff --git a/docs/bridge.md b/docs/bridge.md index 32347d19..be7add60 100644 --- a/docs/bridge.md +++ b/docs/bridge.md @@ -55,6 +55,17 @@ These tests never connect a wallet or submit a transaction. `/ui-review-bridge` is a development-only visual fixture with disconnected, quote, quoting, high-fee, expired, error, pending, success, uncertain and refund states, plus approval/recovery states. Its synthetic data cannot execute a transaction, and the route returns 404 outside development. The real header action uses the actual integration. +### Bridge, then buy (2026-11-10) + +A buyer on a token page who is short of what the buy pays with is offered the bridge, then the buy. These are two separate steps with two separate wallet prompts. An atomic bridge-and-buy is not built: a Relay destination call runs from Relay's contract, not the buyer's wallet, so it would need its own reviewed design. + +- **When it shows.** The trade panel's buy side, with a wallet connected and the token tradable, when the wallet holds none of the pay asset on this chain or less than the buy needs (the gas reserve included). Only buys paid with something the bridge delivers qualify: ETH on Base and Robinhood, USDC on Arc, USDC on Base and USDG on Robinhood (`bridgeAssetForPay` in `lib/bridge/fund.ts`). A token priced in TWIG, GITLAWB or another quote token shows nothing. +- **Where the funds are.** The card reads the wallet's balance of every bridge asset on every other bridge network, including Ethereum, Arbitrum and Optimism, through the wallet config's transports, once per wallet and page while the buyer is short. Each read stands alone, so a network that does not answer is left out rather than counted as empty. +- **What it suggests.** It suggests the largest holding worth at least $1, comparing ETH and dollars with the page's ETH price. With no price, it picks the largest holding of the buy's own kind, with an ETH floor of 0.0005. A token holding counts only where the wallet also has ETH for gas; on Arc, USDC is the gas. The button opens the header's bridge on that route (network and asset at both ends) through its guarded pickers. The route carries no amount; the card states how much the buy is short. With nothing found, it opens the default route and says no funds were found. +- **Following the transfer.** The card follows the newest transfer into this network from this browser's history ledger. It must be from the last two hours, sent to this wallet and not hidden. While that transfer is unsettled, the card wakes the one bridge controller closed, so tracking continues without opening the panel. When it arrives, the trade panel reads its balances once, straight away, and step two says to buy with the button above. A refund, failure or unknown status sends the buyer to the bridge before sending again. A finished transfer can be hidden for the session. +- **What it never does.** It never makes a wallet call, sends anything, starts a second bridge controller or buys. The provider now takes a route from inside the app (`useOpenBridge()(link)`), and asking for the same route twice applies it again. +- Checked with `/ui-review-bridge`'s fund scenes (short with and without a source, checking, nothing elsewhere, moving, delayed, arrived, refunded) on desktop and phone widths in both themes. Tests cover the asset mapping, the holdings reader, the source choice, the follow window and the guards. The real trade panel with a connected wallet has not been exercised. No wallet signed anything. + ### Transfer history (2026-11-10) `/bridge` ends with **Your transfers**, a departures board for the connected wallet: time, route (the Signal board's network codes), amount sent, amount received, status, and links to Relay and both explorers. The header panel's footer links to it. It reads two sources and sends nothing.