Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions app/src/app/api/bridge/history/route.ts
Original file line number Diff line number Diff line change
@@ -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); }
}
3 changes: 3 additions & 0 deletions app/src/app/bridge/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -67,10 +68,12 @@ export default async function BridgePage({ searchParams }: { searchParams: Promi
<li>Funds go from your wallet to Relay&apos;s deposit contract. They never pass through an openlaunch wallet or contract.</li>
<li>Relay is a third-party protocol. openlaunch does not operate it and cannot reverse a transfer.</li>
<li>A transfer is saved in this browser while it is in progress. Reopen the bridge with the same wallet to pick it up.</li>
<li>Finished transfers stay on the board below. It never asks your wallet for anything.</li>
</ul>
</section>
</div>
</div>
<BridgeHistory />
</main>
);
}
24 changes: 24 additions & 0 deletions app/src/app/ui-review-bridge/BridgeReview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<HistoryScene>("rows");
const [open, setOpen] = useState(false);
const [scene, setScene] = useState<Scene>("disconnected");
const [route, setRoute] = useState<BridgeRouteInputs>({ originChainId: 8453, destinationChainId: 5042, originAsset: "USDC", destinationAsset: "USDC", amount: "25" });
Expand Down Expand Up @@ -74,6 +92,7 @@ export default function BridgeReview() {
canDiscard: scene === "uncertain", discard: async () => setScene("idle"), discarding: false,
};
return (
<>
<main className="mx-auto max-w-3xl px-5 py-20">
<h1 className="text-2xl font-semibold">Bridge visual review</h1>
<p className="mt-3 text-sm text-body">Development-only synthetic data. No wallet requests, API calls, or funds.</p>
Expand All @@ -87,6 +106,11 @@ export default function BridgeReview() {
<div className={styles.footer}><span>Powered by Relay</span><span>0 Openlaunch fee</span></div>
</Dialog.Popup></Dialog.Portal>
</Dialog.Root>
<div className="mt-16 flex flex-wrap gap-2">{HISTORY_SCENES.map((value) => <button key={value} type="button" aria-pressed={historyScene === value} className="min-h-11 rounded-lg border border-line-strong px-4 text-sm aria-pressed:border-ink" onClick={() => setHistoryScene(value)}>history: {value}</button>)}</div>
</main>
<div className="mx-auto max-w-6xl px-5 pb-24">
<HistoryBoard wallet={historyScene === "no wallet" ? "" : wallet.toLowerCase()} rows={historyScene === "rows" || historyScene === "unsaved" ? HISTORY_ROWS : []} loading={historyScene === "loading"} unsaved={historyScene === "unsaved"} note="Preview only. Synthetic transfers, no wallet and no Relay call." onRefresh={() => {}} />
</div>
</>
);
}
2 changes: 1 addition & 1 deletion app/src/components/bridge/BridgeDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,7 @@ export default function BridgeDialog({ open, onOpenChange, restoreFocus, slot =
const footer = (
<div className={styles.footer}>
<a href="https://relay.link" target="_blank" rel="noreferrer">Powered by Relay <ArrowUpRight size={12} aria-hidden /></a>
{onPage ? null : <Link href="/bridge" onClick={() => onOpenChange(false)}>Open full page</Link>}
{onPage ? null : <Link href="/bridge#transfers" onClick={() => onOpenChange(false)}>Your transfers</Link>}
<span>0 Openlaunch fee</span>
</div>
);
Expand Down
128 changes: 128 additions & 0 deletions app/src/components/bridge/BridgeHistory.tsx
Original file line number Diff line number Diff line change
@@ -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<Remote | null>(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 <HistoryBoard wallet={wallet} rows={rows} loading={loading} note={note} unsaved={unsaved} onRefresh={() => 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 (
<section id="transfers" className={`${styles.history} ${dotFont.variable}`} aria-labelledby="bridge-transfers">
<div className={styles.head}>
<div>
<h2 id="bridge-transfers">Your transfers</h2>
<p>{note}</p>
</div>
<button type="button" className={styles.refresh} disabled={!wallet || loading} data-busy={loading || undefined} onClick={onRefresh}>
<RotateCw size={14} aria-hidden />{loading ? "Checking" : "Refresh"}
</button>
</div>
<div className={styles.board}>
{!wallet ? <Empty title="No wallet" body="Connect a wallet in the panel above to see its transfers." />
: rows.length === 0 ? (loading ? <Empty title="Loading" body="Reading this wallet's transfers." /> : <Empty title="No transfers" body="Transfers you send from this wallet will show up on this board." />)
: <>
<div className={styles.columns} aria-hidden><span>Time</span><span>Route</span><span>Sent</span><span>Received</span><span>Status</span><span /></div>
<ol className={styles.list} aria-label="Transfers, newest first">{rows.map((entry) => <Row key={entry.requestId} entry={entry} />)}</ol>
</>}
{wallet && unsaved ? <p className={styles.notice} role="status">This browser is not saving bridge history, so this list clears when you leave the page.</p> : null}
</div>
</section>
);
}

function Empty({ title, body }: { title: string; body: string }) {
return <div className={styles.empty}><strong>{title}</strong><span>{body}</span></div>;
}

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 (
<li className={styles.row} data-tone={status.tone}>
<time className={styles.time} dateTime={when.toISOString()}>{CLOCK.format(when)}<small>{DAY.format(when)}</small></time>
<span className={styles.route}>
<span className={styles.code} style={codeColor(entry.originChainId)} aria-hidden>{SIGNAL_CODES[entry.originChainId]}</span>
<span className={styles.track} aria-hidden />
<span className={styles.code} style={codeColor(entry.destinationChainId)} aria-hidden>{SIGNAL_CODES[entry.destinationChainId]}</span>
<span className="sr-only">{origin.name} to {destination.name}</span>
</span>
<span className={`${styles.amount} ${styles.sent}`}><small>Sent</small>{formatHistoryAmount(entry.amountIn, entry.inDecimals)} {entry.originAsset}</span>
<span className={`${styles.amount} ${styles.received}`} data-note={received.note ? "" : undefined} data-kind={received.label === "Quoted" ? "quoted" : undefined}><small>{received.label}</small>{received.amount ?? received.note}</span>
<span className={styles.status}>{status.label}</span>
<span className={styles.links}>
<a href={`https://relay.link/transaction/${entry.requestId}`} target="_blank" rel="noreferrer">Relay<ArrowUpRight size={12} aria-hidden /></a>
{entry.sourceHash ? <a href={`${origin.explorer}/tx/${entry.sourceHash}`} target="_blank" rel="noreferrer">Source<ArrowUpRight size={12} aria-hidden /></a> : null}
{entry.status === "success" && entry.destinationHashes[0] ? <a href={`${destination.explorer}/tx/${entry.destinationHashes[0]}`} target="_blank" rel="noreferrer">Destination<ArrowUpRight size={12} aria-hidden /></a> : null}
</span>
{entry.recipient ? <span className={styles.recipient}>Sent to {shortAddr(entry.recipient)}, not this wallet</span> : null}
</li>
);
}
Loading
Loading