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: "