diff --git a/app/src/app/api/bridge/history/route.ts b/app/src/app/api/bridge/history/route.ts new file mode 100644 index 00000000..7868f2a6 --- /dev/null +++ b/app/src/app/api/bridge/history/route.ts @@ -0,0 +1,17 @@ +import { rateLimited } from "@/lib/launchpad/editServer"; +import { BRIDGE_PRIVATE_HEADERS, bridgeErrorResponse, getBridgeHistory } from "@/lib/bridge/relay"; +import { BridgeApiError } from "@/lib/bridge/validation"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +/** One wallet's bridge transfers from Relay, or `{ relay: "off" }` while no Relay API key is configured. */ +export async function GET(req: Request) { + const ip = (req.headers.get("fly-client-ip") || req.headers.get("x-forwarded-for") || "").split(",")[0].trim() || "0.0.0.0"; + if (rateLimited(`bridge:history:ip:${ip}`, 30)) return bridgeErrorResponse(new BridgeApiError("Too many history requests. Please wait a moment.", 429)); + try { + const params = new URL(req.url).searchParams; + if (params.size !== 1 || !params.has("address")) throw new BridgeApiError("Invalid wallet address.", 400); + return Response.json(await getBridgeHistory(params.get("address")!), { headers: BRIDGE_PRIVATE_HEADERS }); + } catch (error) { return bridgeErrorResponse(error); } +} diff --git a/app/src/app/bridge/page.tsx b/app/src/app/bridge/page.tsx index f93d02df..8954436d 100644 --- a/app/src/app/bridge/page.tsx +++ b/app/src/app/bridge/page.tsx @@ -4,6 +4,7 @@ import { pageMetadata } from "@/lib/seo"; import SectionIntro from "@/components/sections/SectionIntro"; import shell from "@/components/sections/SectionShell.module.css"; import BridgePageSlot from "@/components/bridge/BridgePageSlot"; +import BridgeHistory from "@/components/bridge/BridgeHistory"; import { parseBridgeLink } from "@/lib/bridge/link"; import { BRIDGE_ASSETS, BRIDGE_CHAINS, BRIDGE_DESTINATION_IDS, BRIDGE_FUNDING_IDS } from "@/lib/bridge/types"; import styles from "./bridge.module.css"; @@ -67,10 +68,12 @@ export default async function BridgePage({ searchParams }: { searchParams: Promi
  • Funds go from your wallet to Relay's deposit contract. They never pass through an openlaunch wallet or contract.
  • Relay is a third-party protocol. openlaunch does not operate it 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.
  • +
  • Finished transfers stay on the board below. It never asks your wallet for anything.
  • + ); } diff --git a/app/src/app/ui-review-bridge/BridgeReview.tsx b/app/src/app/ui-review-bridge/BridgeReview.tsx index 28e3ca2b..bdde88c4 100644 --- a/app/src/app/ui-review-bridge/BridgeReview.tsx +++ b/app/src/app/ui-review-bridge/BridgeReview.tsx @@ -12,13 +12,31 @@ import type { TrackedApproval } from "@/lib/bridge/approval"; import { describePendingApproval } from "@/lib/bridge/approval-health"; 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"; const wallet = "0x03508bB71268BBA25ECaCC8F620e01866650532c" as const; const requestId = `0x${"1".repeat(64)}` as const; type Scene = "idle" | "disconnected" | "quote" | "quoting" | "high_fee" | "fee_boundary" | "impact_limit" | "expired" | "error" | "pending" | "success" | "uncertain" | "refund" | "approval_pending" | "approval_uncertain" | "approval_confirmed" | "approval_queued" | "approval_missing" | "approval_fee"; const scenes: Scene[] = ["disconnected", "quote", "quoting", "high_fee", "fee_boundary", "impact_limit", "expired", "error", "pending", "success", "uncertain", "refund", "approval_pending", "approval_uncertain", "approval_confirmed", "approval_queued", "approval_missing", "approval_fee"]; +const hash = (n: number) => `0x${n.toString(16).padStart(64, "0")}` as const; +const minutesAgo = (minutes: number) => Date.UTC(2026, 10, 10, 12, 0) - minutes * 60_000; +// One row per thing the board can say, on real routes and in real units. +const HISTORY_ROWS: BridgeHistoryEntry[] = [ + { requestId: hash(11), originChainId: 8453, destinationChainId: 4663, originAsset: "ETH", destinationAsset: "ETH", amountIn: "20000000000000000", inDecimals: 18, amountOut: "19962000000000000", outDecimals: 18, outKind: "quoted", status: "pending", reverted: false, createdAt: minutesAgo(1), sourceHash: hash(12), destinationHashes: [], recipient: null }, + { requestId: hash(21), originChainId: 42161, destinationChainId: 8453, originAsset: "USDC", destinationAsset: "USDC", amountIn: "25000000", inDecimals: 6, amountOut: "24975000", outDecimals: 6, outKind: "received", status: "success", reverted: false, createdAt: minutesAgo(48), sourceHash: hash(22), destinationHashes: [hash(23)], recipient: null }, + { 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(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" }, +]; +type HistoryScene = "rows" | "empty" | "loading" | "no wallet" | "unsaved"; +const HISTORY_SCENES: HistoryScene[] = ["rows", "empty", "loading", "no wallet", "unsaved"]; + export default function BridgeReview() { + const [historyScene, setHistoryScene] = useState("rows"); const [open, setOpen] = useState(false); const [scene, setScene] = useState("disconnected"); const [route, setRoute] = useState({ originChainId: 8453, destinationChainId: 5042, originAsset: "USDC", destinationAsset: "USDC", amount: "25" }); @@ -74,6 +92,7 @@ export default function BridgeReview() { canDiscard: scene === "uncertain", discard: async () => setScene("idle"), discarding: false, }; return ( + <>

    Bridge visual review

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

    @@ -87,6 +106,11 @@ export default function BridgeReview() {
    Powered by Relay0 Openlaunch fee
    +
    {HISTORY_SCENES.map((value) => )}
    +
    + {}} /> +
    + ); } diff --git a/app/src/components/bridge/BridgeDialog.tsx b/app/src/components/bridge/BridgeDialog.tsx index e12872ee..1af56fcb 100644 --- a/app/src/components/bridge/BridgeDialog.tsx +++ b/app/src/components/bridge/BridgeDialog.tsx @@ -205,7 +205,7 @@ export default function BridgeDialog({ open, onOpenChange, restoreFocus, slot = const footer = (
    Powered by Relay - {onPage ? null : onOpenChange(false)}>Open full page} + {onPage ? null : onOpenChange(false)}>Your transfers} 0 Openlaunch fee
    ); diff --git a/app/src/components/bridge/BridgeHistory.tsx b/app/src/components/bridge/BridgeHistory.tsx new file mode 100644 index 00000000..8932eb48 --- /dev/null +++ b/app/src/components/bridge/BridgeHistory.tsx @@ -0,0 +1,128 @@ +"use client"; + +import { useEffect, useState, useSyncExternalStore } from "react"; +import { useAccount } from "wagmi"; +import { ArrowUpRight, RotateCw } from "lucide-react"; +import { BRIDGE_CHAINS, type BridgeChainId } from "@/lib/bridge/types"; +import { SIGNAL_CODES, SIGNAL_COLORS } from "@/lib/bridge/signal"; +import { BRIDGE_HISTORY_LIMIT, formatHistoryAmount, historyReceived, historySettled, historyStatus, mergeHistory, validateHistoryResponse, type BridgeHistoryEntry } from "@/lib/bridge/history"; +import { bridgeHistoryKey } from "@/lib/bridge/history-store"; +import { shortAddr } from "@/lib/chainPublic"; +import { bridgeLedger } from "./history-ledger"; +import { dotFont } from "./dot-font"; +import styles from "./History.module.css"; + +type Remote = { wallet: string; key: string; state: "off" | "on" | "error"; transfers: BridgeHistoryEntry[] }; +const NO_ROWS: BridgeHistoryEntry[] = []; +// A board shows the time large and the day under it, in the reader's own locale and a 24-hour clock. +const CLOCK = new Intl.DateTimeFormat(undefined, { hour: "2-digit", minute: "2-digit", hourCycle: "h23" }); +const DAY = new Intl.DateTimeFormat(undefined, { day: "numeric", month: "short" }); +const codeColor = (chain: BridgeChainId) => ({ "--code": SIGNAL_COLORS[chain].join(" ") }) as React.CSSProperties; + +/** + * "Your transfers" on /bridge: a departures board for the connected wallet. It reads this browser's ledger (written by + * the bridge controller) and, when the server has a Relay key, Relay's own list for the wallet. It never sends or + * changes anything, so it can sit beside the panel without a second bridge controller. + */ +export default function BridgeHistory() { + const { address } = useAccount(); + const wallet = address?.toLowerCase() ?? ""; + const snapshot = useSyncExternalStore(bridgeLedger.subscribe, bridgeLedger.getSnapshot, bridgeLedger.getServerSnapshot); + const local = snapshot.entries[wallet] ?? NO_ROWS; + const unsaved = !!snapshot.unsaved[wallet]; + const [revision, setRevision] = useState(0); + const [remote, setRemote] = useState(null); + // Ask Relay again whenever one of this browser's transfers settles, so its row picks up the delivered amount. + const settled = local.filter((row) => historySettled(row.status)).length; + const key = wallet ? `${wallet}:${revision}:${settled}` : ""; + + useEffect(() => { + if (!address) return; + bridgeLedger.read(address); + const onStorage = (event: StorageEvent) => { if (event.key === null || event.key === bridgeHistoryKey(address)) bridgeLedger.read(address); }; + window.addEventListener("storage", onStorage); + return () => window.removeEventListener("storage", onStorage); + }, [address, revision]); + + useEffect(() => { + if (!key) return; + const controller = new AbortController(); + fetch(`/api/bridge/history?address=${wallet}`, { cache: "no-store", signal: controller.signal }) + .then(async (response) => { + if (!response.ok) throw new Error("history"); + return validateHistoryResponse(await response.json()); + }) + .then((body) => setRemote({ wallet, key, state: body.relay, transfers: body.relay === "on" ? body.transfers : [] })) + .catch(() => { if (!controller.signal.aborted) setRemote((prior) => ({ wallet, key, state: "error", transfers: prior?.wallet === wallet ? prior.transfers : [] })); }); + return () => controller.abort(); + }, [key, wallet]); + + const current = remote?.wallet === wallet ? remote : null; + const loading = !!wallet && current?.key !== key; + const rows = mergeHistory(local, current?.transfers ?? NO_ROWS); + const note = !wallet ? "Transfers from the connected wallet appear here." + : current?.state === "on" ? `From Relay for this wallet, plus what this browser saved. The latest ${BRIDGE_HISTORY_LIMIT} are shown.` + : current?.state === "error" ? "Relay's list did not load, so this shows what this browser saved. Try again in a moment." + : current?.state === "off" ? "Saved in this browser. Transfers sent from another device or browser are not listed." + : "Loading your transfers…"; + + return setRevision((value) => value + 1)} />; +} + +/** The board itself, from plain props, so the development fixture can show every state without a wallet. */ +export function HistoryBoard({ wallet, rows, loading, note, unsaved, onRefresh }: { wallet: string; rows: readonly BridgeHistoryEntry[]; loading: boolean; note: string; unsaved: boolean; onRefresh: () => void }) { + return ( +
    +
    +
    +

    Your transfers

    +

    {note}

    +
    + +
    +
    + {!wallet ? + : rows.length === 0 ? (loading ? : ) + : <> +
    TimeRouteSentReceivedStatus
    +
      {rows.map((entry) => )}
    + } + {wallet && unsaved ?

    This browser is not saving bridge history, so this list clears when you leave the page.

    : null} +
    +
    + ); +} + +function Empty({ title, body }: { title: string; body: string }) { + return
    {title}{body}
    ; +} + +function Row({ entry }: { entry: BridgeHistoryEntry }) { + const origin = BRIDGE_CHAINS[entry.originChainId]; + const destination = BRIDGE_CHAINS[entry.destinationChainId]; + const status = historyStatus(entry); + const received = historyReceived(entry); + const when = new Date(entry.createdAt); + return ( +
  • + + + {SIGNAL_CODES[entry.originChainId]} + + {SIGNAL_CODES[entry.destinationChainId]} + {origin.name} to {destination.name} + + Sent{formatHistoryAmount(entry.amountIn, entry.inDecimals)} {entry.originAsset} + {received.label}{received.amount ?? received.note} + {status.label} + + Relay + {entry.sourceHash ? Source : null} + {entry.status === "success" && entry.destinationHashes[0] ? Destination : null} + + {entry.recipient ? Sent to {shortAddr(entry.recipient)}, not this wallet : null} +
  • + ); +} diff --git a/app/src/components/bridge/History.module.css b/app/src/components/bridge/History.module.css new file mode 100644 index 00000000..49cafe56 --- /dev/null +++ b/app/src/components/bridge/History.module.css @@ -0,0 +1,79 @@ +/* "Your transfers": a departures board built from the Signal board's materials. A dark plate in both themes, dot + lettering for what a station board shows (time, route, amounts, status) and plain text for links and notes. */ +.history { margin-top: 64px; scroll-margin-top: 104px; } +.head { display: flex; flex-wrap: wrap; align-items: flex-end; justify-content: space-between; gap: 12px 24px; margin-bottom: 14px; } +.head h2 { margin: 0; color: var(--color-ink); font-size: 17px; font-weight: 650; letter-spacing: -.02em; line-height: 1.3; text-wrap: balance; } +.head p { margin: 4px 0 0; max-width: 64ch; color: var(--color-muted); font-size: 13px; line-height: 1.5; } +.refresh { display: inline-flex; align-items: center; gap: 7px; min-height: 34px; padding: 0 14px; border: 1px solid var(--color-line-strong); border-radius: 999px; background: transparent; color: var(--color-ink); font: inherit; font-size: 13px; font-weight: 600; cursor: pointer; } +.refresh:hover:not(:disabled) { border-color: var(--color-ink); } +.refresh:disabled { opacity: .55; cursor: default; } +.refresh:focus-visible { outline: 2px solid var(--color-brand); outline-offset: 2px; } +.refresh[data-busy] svg { animation: history-spin 900ms linear infinite; } +@keyframes history-spin { to { transform: rotate(360deg); } } + +.board { border: 1px solid #1c1f27; border-radius: 18px; background: #050608; color: #c9d1dd; overflow: hidden; } +.columns, .row { display: grid; grid-template-columns: 76px minmax(170px, 1.2fr) minmax(0, 1fr) minmax(0, 1fr) 132px 236px; align-items: center; column-gap: 20px; padding-inline: 22px; } +.columns { padding-block: 13px 11px; color: #555e6e; 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; } +.list { margin: 0; padding: 0; list-style: none; } +.row { padding-block: 16px; border-top: 1px dashed #20242d; } + +.time { display: grid; gap: 3px; color: #c3cad6; font-family: var(--font-dots), var(--font-mono); font-size: 16px; font-weight: 800; font-variation-settings: "ROND" 100; font-variant-numeric: tabular-nums; white-space: nowrap; } +.time small { color: #5f6878; font-family: var(--font-sans); font-size: 11px; font-weight: 500; } +.route { display: flex; align-items: center; gap: 10px; min-width: 0; } +.code { color: rgb(var(--code)); font-family: var(--font-dots), var(--font-mono); font-size: 19px; font-weight: 800; font-variation-settings: "ROND" 100; letter-spacing: .06em; line-height: 1; text-shadow: 0 0 12px rgb(var(--code) / .45); } +/* the route between the two codes, drawn as a row of lamps in the row's status colour */ +.track { flex: 0 1 44px; min-width: 18px; height: 4px; color: #2c323d; background: radial-gradient(circle at center, currentColor 1.4px, transparent 1.9px) 0 50% / 6px 4px repeat-x; } +.row[data-tone="route"] .track { color: #6d9dff; animation: history-chase 900ms linear infinite; } +.row[data-tone="ok"] .track { color: #3ddc97; } +.row[data-tone="warn"] .track { color: #fbbf24; opacity: .75; } +@keyframes history-chase { to { background-position: 6px 50%; } } + +.amount { display: grid; gap: 3px; min-width: 0; color: #e7ecf3; font-family: var(--font-dots), var(--font-mono); font-size: 15px; font-weight: 800; font-variation-settings: "ROND" 100; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; } +.amount small { color: #5f6878; font-family: var(--font-sans); font-size: 11px; font-weight: 500; letter-spacing: 0; } +.amount[data-note] { color: #8b95a7; } +/* the column heading names these cells on wide screens; a quoted amount still says so, since it is not a delivery */ +@media (min-width: 861px) { + .sent small, .received:not([data-kind]) small { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; } + .received[data-kind] small { color: #6d9dff; } +} + +.status { display: inline-flex; align-items: center; gap: 8px; color: #8b95a7; font-family: var(--font-dots), var(--font-mono); font-size: 15px; font-weight: 800; font-variation-settings: "ROND" 100; letter-spacing: .08em; text-transform: uppercase; white-space: nowrap; } +.status::before { content: ""; flex: none; width: 7px; height: 7px; border-radius: 50%; background: currentColor; box-shadow: 0 0 10px currentColor; } +.row[data-tone="ok"] .status { color: #3ddc97; } +.row[data-tone="route"] .status { color: #6d9dff; } +.row[data-tone="route"] .status::before { animation: history-blink 1.1s steps(1, end) infinite; } +.row[data-tone="warn"] .status { color: #fbbf24; } +@keyframes history-blink { 50% { opacity: .2; } } + +.links { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 6px 14px; font-size: 12.5px; } +.links a { display: inline-flex; align-items: center; gap: 3px; color: #8b95a7; text-decoration: none; } +.links a:hover { color: #e7ecf3; } +.links a:focus-visible { border-radius: 4px; outline: 2px solid #6d9dff; outline-offset: 2px; } +.recipient { grid-column: 2 / -1; margin-top: 8px; color: #fbbf24; font-size: 12px; } + +.empty { display: grid; justify-items: center; gap: 8px; padding: 46px 20px 44px; text-align: center; } +.empty strong { color: #6d7a90; font-family: var(--font-dots), var(--font-mono); font-size: 24px; font-weight: 800; font-variation-settings: "ROND" 100; letter-spacing: .1em; text-transform: uppercase; } +.empty span { max-width: 46ch; color: #8b95a7; font-size: 13px; line-height: 1.5; } +.notice { margin: 0; padding: 11px 22px; border-top: 1px dashed #20242d; color: #fbbf24; font-size: 12.5px; line-height: 1.5; } + +@media (max-width: 860px) { + .history { margin-top: 48px; } + .columns { display: none; } + /* two equal halves, so a long link list or amount on one side never squeezes the other */ + .row { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); grid-template-areas: "route status" "sent received" "time links"; gap: 12px 16px; padding: 16px 18px; } + .row:first-child { border-top: 0; } + .route { grid-area: route; } + .status { grid-area: status; justify-self: end; } + .sent { grid-area: sent; } + .received { grid-area: received; justify-items: end; text-align: right; } + .time { grid-area: time; grid-auto-flow: column; justify-content: start; align-items: baseline; gap: 8px; font-size: 14px; } + .links { grid-area: links; } + .recipient { grid-column: 1 / -1; margin-top: 0; } +} +@media (max-width: 400px) { + .code { font-size: 17px; } + .status { font-size: 13px; } +} +@media (prefers-reduced-motion: reduce) { + .refresh[data-busy] svg, .row[data-tone="route"] .track, .row[data-tone="route"] .status::before { animation: none; } +} diff --git a/app/src/components/bridge/bridge-ui.test.ts b/app/src/components/bridge/bridge-ui.test.ts index e005bc1f..982f5188 100644 --- a/app/src/components/bridge/bridge-ui.test.ts +++ b/app/src/components/bridge/bridge-ui.test.ts @@ -210,6 +210,18 @@ test("stuck records have a bounded discard path, and the hook avoids APIs missin assert.match(hook, /const messageOf = bridgeErrorMessage;/); }); +test("the history board only reads: no second bridge, no recovery record, and the ledger is written by the controller alone", () => { + const board = read("./BridgeHistory.tsx"); + const hook = read("./useBridge.ts"); + const page = read("../../app/bridge/page.tsx"); + assert.doesNotMatch(board, /useBridge|client-storage|sendTransaction|writeContract|\.record\(|\.forget\(/); + assert.ok(board.includes("bridgeLedger.read(address)")); + assert.ok(page.includes("")); + 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"`)); +}); + 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/bridge/history-ledger.ts b/app/src/components/bridge/history-ledger.ts new file mode 100644 index 00000000..42051db1 --- /dev/null +++ b/app/src/components/bridge/history-ledger.ts @@ -0,0 +1,4 @@ +import { createBridgeHistoryStore } from "@/lib/bridge/history-store"; + +/** The one history ledger, shared by the bridge controller (which writes it) and the history board (which reads it). */ +export const bridgeLedger = createBridgeHistoryStore(() => window.localStorage); diff --git a/app/src/components/bridge/useBridge.ts b/app/src/components/bridge/useBridge.ts index 981e5e92..5ef4b25b 100644 --- a/app/src/components/bridge/useBridge.ts +++ b/app/src/components/bridge/useBridge.ts @@ -11,6 +11,7 @@ import { parseBridgeQuoteRejection } from "@/lib/bridge/client"; import { createBridgeQuoteSession } from "@/lib/bridge/quote-session"; import { activityAfterWalletChange, anchorQuoteExpiry, bridgeErrorMessage, bridgeGasBudget, bridgeRequest, bridgeRequestKey, canReverseBridgeRoute, changeBridgeRoute, hasMatchingDepositEvent, isHash, isMatchingSourceDeposit, linkedTimeoutSignal, mergeBridgeStatus, nativeSourceAmount, RELAY_DEPOSITORY, replacementSourceHash, submitBridgeDeposit, transferCanDiscard, transferIsTerminal, transferPhase, validateBridgeQuote, validateBridgeStatus, type BridgeActivity, type BridgePhase, type BridgeRouteChange, type BridgeRouteInputs, type ProviderObservation, type TrackedBridgeTransfer } from "@/lib/bridge/client"; import { BRIDGE_STORAGE_PREFIX, createBridgeTransferStore } from "@/lib/bridge/client-storage"; +import { bridgeLedger } from "./history-ledger"; import { APPROVAL_STORAGE_PREFIX, approvalBlocksSubmission, approvalCanDiscard, canApplyApprovalPoll, createApprovalStore, reconcileApproval, recoverApprovalFromEvidence, submitExactApproval, validateApprovalMetadata, type ApprovalReceiptObservation } from "@/lib/bridge/approval"; import { APPROVAL_HEALTH_UNAVAILABLE, readPendingApprovalHealth, type ApprovalHealth } from "@/lib/bridge/approval-health"; import { assertBridgeWalletQueueClear } from "@/lib/bridge/transaction-preflight"; @@ -65,6 +66,8 @@ export function useBridge(open: boolean) { const [now, setNow] = useState(0); const [discarding, setDiscarding] = useState(false); const actionLock = useRef(false); + // The quote a deposit was sent from, so the history can show what it expected to deliver. + const sentQuote = useRef<{ requestId: string; amountOut: string } | null>(null); const [quoteSession] = useState(createBridgeQuoteSession); const inputs = useRef({ originChainId: 8453, destinationChainId: 4663, originAsset: "ETH", destinationAsset: "ETH", amount: "" }); const snapshot = useSyncExternalStore(transfers.subscribe, transfers.getSnapshot, transfers.getServerSnapshot); @@ -99,6 +102,14 @@ export function useBridge(open: boolean) { return () => window.removeEventListener("storage", onStorage); }, [address]); + // Remember every transfer that leaves the wallet, for the history board. Separate from the recovery record and best + // effort: nothing here can block, change or clear a transfer. + useEffect(() => { + if (!tracked) return; + const quoted = sentQuote.current?.requestId === tracked.requestId ? sentQuote.current.amountOut : null; + try { bridgeLedger.record(tracked, quoted); } catch { /* history only */ } + }, [tracked]); + // Existing quotes are hidden immediately by their wallet-chain key. Abort // in-flight responses as well; an old account's response cannot reappear. // The aborted request skips its own idle reset, so clear a quoting phase here @@ -481,6 +492,7 @@ export function useBridge(open: boolean) { setIssue(null); const reviewed = quote; const reviewedAt = envelope?.requestedAt ?? 0; + sentQuote.current = { requestId: reviewed.requestId, amountOut: reviewed.amountOut }; const operationKey = bridgeRequestKey(reviewed); setActivity({ key: operationKey, phase: "switching" }); // Never reuse this review after an interrupted/rejected confirmation. @@ -616,6 +628,7 @@ export function useBridge(open: boolean) { setObservation(fresh); if (!transferCanDiscard(current, fresh, Date.now())) throw new Error("This transfer now shows activity, so it was kept. Tracking continues."); transfers.remove(address); + try { bridgeLedger.forget(address, requestId); } catch { /* history only */ } setObservation(null); invalidateQuote(); setStatusIssue(null); diff --git a/app/src/lib/bridge/history-store.ts b/app/src/lib/bridge/history-store.ts new file mode 100644 index 00000000..ade9be33 --- /dev/null +++ b/app/src/lib/bridge/history-store.ts @@ -0,0 +1,71 @@ +import type { Address, Hex } from "viem"; +import type { TrackedBridgeTransfer } from "./client"; +import { historyFromTransfer, parseHistoryLedger, serializeHistoryLedger, upsertHistory, type BridgeHistoryEntry } from "./history"; + +/** + * This browser's bridge history: a ledger kept beside the recovery record, never instead of it. The recovery record + * (client-storage.ts) guards against sending twice and is cleared when a transfer is done; this one only remembers. + * It is best effort by design: a storage failure here is reported, never thrown into a transfer. + */ +export const BRIDGE_HISTORY_PREFIX = "openlaunch.bridge.history.v1:"; +export const bridgeHistoryKey = (address: Address | string) => `${BRIDGE_HISTORY_PREFIX}${address.toLowerCase()}`; +type StorageAdapter = Pick; +export type HistorySnapshot = { entries: Record; unsaved: Record }; +const EMPTY: HistorySnapshot = { entries: {}, unsaved: {} }; + +export function createBridgeHistoryStore(storage: () => StorageAdapter) { + let snapshot = EMPTY; + const listeners = new Set<() => void>(); + function update(wallet: string, entries: BridgeHistoryEntry[], unsaved: boolean) { + const current = snapshot.entries[wallet]; + if (current && snapshot.unsaved[wallet] === unsaved && serializeHistoryLedger(current) === serializeHistoryLedger(entries)) return; + snapshot = { entries: { ...snapshot.entries, [wallet]: entries }, unsaved: { ...snapshot.unsaved, [wallet]: unsaved } }; + for (const notify of listeners) notify(); + } + function load(wallet: string): BridgeHistoryEntry[] { + const raw = storage().getItem(bridgeHistoryKey(wallet)); + if (raw === null) return []; + try { return parseHistoryLedger(raw); } catch { return []; } + } + function write(wallet: string, entries: BridgeHistoryEntry[]) { + try { + storage().setItem(bridgeHistoryKey(wallet), serializeHistoryLedger(entries)); + update(wallet, entries, false); + } catch { update(wallet, entries, true); } + } + return { + subscribe(notify: () => void) { listeners.add(notify); return () => { listeners.delete(notify); }; }, + getSnapshot: () => snapshot, + getServerSnapshot: () => EMPTY, + /** Re-read from storage, keeping rows only memory has (a write that failed) so nothing seen is lost. */ + read(address: Address): BridgeHistoryEntry[] { + const wallet = address.toLowerCase(); + let stored: BridgeHistoryEntry[]; + try { stored = load(wallet); } catch { update(wallet, snapshot.entries[wallet] ?? [], true); return snapshot.entries[wallet] ?? []; } + let rows = stored; + for (const entry of snapshot.entries[wallet] ?? []) rows = upsertHistory(rows, entry); + update(wallet, rows, snapshot.unsaved[wallet] ?? false); + return rows; + }, + /** Remember a tracked transfer once it has left the wallet; a no-op until then and when nothing changed. */ + record(transfer: TrackedBridgeTransfer, quotedOut: string | null = null) { + const entry = historyFromTransfer(transfer, quotedOut); + if (!entry) return; + const wallet = transfer.address.toLowerCase(); + let base: BridgeHistoryEntry[]; + try { base = load(wallet); } catch { base = []; } + for (const row of snapshot.entries[wallet] ?? []) base = upsertHistory(base, row); + const next = upsertHistory(base, entry); + if (snapshot.entries[wallet] && !snapshot.unsaved[wallet] && serializeHistoryLedger(next) === serializeHistoryLedger(snapshot.entries[wallet])) return; + write(wallet, next); + }, + /** Drop a row for a transfer that was discarded because it never left the wallet. */ + forget(address: Address, requestId: Hex) { + const wallet = address.toLowerCase(); + let base: BridgeHistoryEntry[]; + try { base = load(wallet); } catch { base = snapshot.entries[wallet] ?? []; } + const next = base.filter((row) => row.requestId.toLowerCase() !== requestId.toLowerCase()); + if (next.length !== base.length || (snapshot.entries[wallet] ?? []).some((row) => row.requestId.toLowerCase() === requestId.toLowerCase())) write(wallet, next); + }, + }; +} diff --git a/app/src/lib/bridge/history.test.ts b/app/src/lib/bridge/history.test.ts new file mode 100644 index 00000000..671dc53d --- /dev/null +++ b/app/src/lib/bridge/history.test.ts @@ -0,0 +1,280 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import type { Address, Hex } from "viem"; +import type { TrackedBridgeTransfer } from "./client.ts"; +import { BRIDGE_HISTORY_LIMIT, combineHistory, formatHistoryAmount, historyFromTransfer, historyReceived, historyStatus, mergeHistory, parseHistoryEntry, parseHistoryLedger, parseRelayHistory, relayHistoryEntry, serializeHistoryLedger, upsertHistory, validateHistoryResponse, type BridgeHistoryEntry } from "./history.ts"; +import { bridgeHistoryKey, createBridgeHistoryStore } from "./history-store.ts"; +import { BRIDGE_HISTORY_TTL_MS, BRIDGE_HISTORY_UPSTREAM_PER_MINUTE, getBridgeHistory } from "./relay.ts"; +import { ARBITRUM_USDC, ARC_USDC, BASE_USDC, ROBINHOOD_USDG, bridgeCurrencyAsset } from "./types.ts"; +import { GET } from "../../app/api/bridge/history/route.ts"; + +const WALLET = "0x1111111111111111111111111111111111111111" as Address; +const OTHER = "0x2222222222222222222222222222222222222222" as Address; +const hash = (n: number) => `0x${n.toString(16).padStart(64, "0")}` as Hex; +const NATIVE = "0x0000000000000000000000000000000000000000"; + +function tracked(overrides: Partial = {}): TrackedBridgeTransfer { + return { address: WALLET, originChainId: 42161, destinationChainId: 8453, originAsset: "USDC", destinationAsset: "USDC", amount: "25000000", requestId: hash(1), sourceHash: hash(2), destinationHashes: [], status: "pending", createdAt: 1_790_000_000_000, ...overrides }; +} + +function row(overrides: Partial = {}): BridgeHistoryEntry { + return { requestId: hash(1), originChainId: 42161, destinationChainId: 8453, originAsset: "USDC", destinationAsset: "USDC", amountIn: "25000000", inDecimals: 6, amountOut: "24970000", outDecimals: 6, outKind: "quoted", status: "pending", reverted: false, createdAt: 1_790_000_000_000, sourceHash: hash(2), destinationHashes: [], recipient: null, ...overrides }; +} + +const currency = (chainId: number, address: string, decimals: number, amount: string) => ({ currency: { chainId, address, symbol: "X", name: "X", decimals, metadata: {} }, amount, amountFormatted: "1", amountUsd: "1", minimumAmount: amount }); + +/** A GET /requests/v3 request in Relay's published shape, Arbitrum USDC to Base USDC unless overridden. */ +function relayRequest(overrides: Record = {}, route: { from?: unknown; to?: unknown; actualTo?: unknown } = {}) { + return { + id: hash(1), status: "success", user: WALLET, sender: WALLET, recipient: WALLET, refundTo: null, depositAddress: null, requestType: "Bridge", + data: { + slippageTolerance: "50", failReason: null, inTxs: [{ txHash: hash(2), chainId: 42161, status: "success" }], outTxs: [{ txHash: hash(3), chainId: 8453, status: "success" }], + route: { + quoted: { origin: { inputCurrency: route.from ?? currency(42161, ARBITRUM_USDC, 6, "25000000") }, destination: { outputCurrency: route.to ?? currency(8453, BASE_USDC, 6, "24970000") } }, + actual: { destination: { outputCurrency: route.actualTo ?? currency(8453, BASE_USDC, 6, "24975000") } }, + }, + }, + createdAt: "2026-11-10T09:30:00.000Z", updatedAt: "2026-11-10T09:30:20.000Z", + ...overrides, + }; +} + +function memoryStorage(fail = false) { + const items = new Map(); + return { items, adapter: { getItem: (key: string) => items.get(key) ?? null, setItem: (key: string, value: string) => { if (fail) throw new Error("quota"); items.set(key, value); } } }; +} + +test("a tracked transfer enters the history only once it has left the wallet", () => { + assert.equal(historyFromTransfer(tracked({ sourceHash: undefined, status: "waiting" })), null, "a cancelled or unsent wallet prompt is not a transfer"); + assert.equal(historyFromTransfer(tracked({ sourceHash: undefined, status: "uncertain" })), null); + const sent = historyFromTransfer(tracked(), "24970000")!; + assert.deepEqual(sent, row()); + assert.equal(historyFromTransfer(tracked(), "not a number")!.amountOut, null); + // settled by Relay without a wallet hash (recovered on another path) still counts + assert.equal(historyFromTransfer(tracked({ sourceHash: undefined, status: "success" }))!.status, "success"); + const reverted = historyFromTransfer(tracked({ status: "failure", failureReason: "source-reverted" }))!; + assert.equal(reverted.reverted, true); + // Arc's first journal entries were native 18-decimal USDC, and arrive on Arc as the 18-decimal native balance + const legacyArc = historyFromTransfer(tracked({ originChainId: 5042, destinationChainId: 8453, originAsset: undefined, destinationAsset: undefined, amount: "5000000000000000000" }))!; + assert.equal(legacyArc.inDecimals, 18); + assert.equal(legacyArc.originAsset, "USDC"); + assert.equal(legacyArc.destinationAsset, "ETH"); + assert.equal(historyFromTransfer(tracked({ originChainId: 8453, destinationChainId: 5042, originAsset: "USDC", destinationAsset: "USDC" }))!.outDecimals, 18); +}); + +test("a stored row is checked field by field and copied; anything off drops that row only", () => { + assert.deepEqual(parseHistoryEntry({ ...row(), extra: "