feat: add read-only bank operations console

This commit is contained in:
golem
2026-08-21 03:35:45 -06:00
parent 9ce8843279
commit 10507e769d
18 changed files with 967 additions and 1 deletions
+1
View File
@@ -7,5 +7,6 @@
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+191
View File
@@ -0,0 +1,191 @@
import { render, screen } from "@testing-library/react";
import type { Address, Hex } from "viem";
import { describe, expect, it } from "vitest";
import shellHtml from "../index.html?raw";
import { App } from "./App";
import type { DashboardState } from "./hooks/useBankDashboard";
import type { DashboardSnapshot, DeploymentManifest } from "./types/dashboard";
const address = (digit: string) => `0x${digit.repeat(40)}` as Address;
const transactionHash = (digit: string) => `0x${digit.repeat(64)}` as Hex;
const localManifest: DeploymentManifest = {
schemaVersion: 1,
network: "anvil",
chainId: 31337,
deploymentBlock: 3n,
rpcUrl: "http://127.0.0.1:8545",
token: address("1"),
proxy: address("2"),
implementation: address("3"),
owner: address("4"),
actors: [
{ label: "Alice", address: address("5") },
{ label: "Bob", address: address("6") },
],
};
const snapshot: DashboardSnapshot = {
blockNumber: 18n,
synchronizedAt: new Date("2026-08-21T10:00:00.000Z"),
version: 1,
paused: true,
asset: localManifest.token,
owner: localManifest.owner,
proxy: localManifest.proxy,
implementation: localManifest.implementation,
reserves: 1_500_000_000n,
liabilities: 1_400_000_000n,
surplus: 100_000_000n,
actors: [
{ label: "Alice", address: address("5"), balance: 900_000_000n },
{ label: "Bob", address: address("6"), balance: 500_000_000n },
],
activity: [
{ kind: "deposit", account: address("5"), amount: 1_000_000_000n, blockNumber: 4n, logIndex: 0, transactionHash: transactionHash("a") },
{ kind: "withdrawal", account: address("5"), amount: 100_000_000n, blockNumber: 5n, logIndex: 0, transactionHash: transactionHash("b") },
{ kind: "paused", account: address("4"), blockNumber: 6n, logIndex: 0, transactionHash: transactionHash("c") },
{ kind: "ownershipTransferred", previousOwner: address("7"), newOwner: address("4"), blockNumber: 3n, logIndex: 1, transactionHash: transactionHash("d") },
{ kind: "upgraded", implementation: address("3"), blockNumber: 3n, logIndex: 0, transactionHash: transactionHash("d") },
],
diagnostics: [{ blockNumber: 7n, logIndex: 2, transactionHash: transactionHash("e"), message: "Could not decode a proxy event log." }],
};
function ready(manifest = localManifest, value = snapshot): DashboardState {
return { status: "ready", manifest, snapshot: value };
}
describe("read-only operations console", () => {
it("loads the console entry point from the browser document", () => {
const document = new DOMParser().parseFromString(shellHtml, "text/html");
expect(document.querySelector('script[type="module"]')?.getAttribute("src")).toBe("/src/main.tsx");
});
it("keeps the exact educational funds warning and complete trust disclosure visible", () => {
render(<App dashboard={ready()} />);
expect(screen.getByText("Educational demo — mock token — never use real funds.")).toBeTruthy();
expect(screen.getByText(/educational and unaudited/i)).toBeTruthy();
expect(screen.getByText(/owner can pause customer actions and install arbitrary future logic/i)).toBeTruthy();
expect(screen.getByText(/UUPS mistakes can corrupt state or permanently brick upgradeability/i)).toBeTruthy();
expect(screen.getByText(/professional audits, operational key controls, multisig or timelocked governance, incident procedures, legal advice, and jurisdiction-specific compliance/i)).toBeTruthy();
});
it("renders network, synchronization, lifecycle, pause, and V1 status", () => {
render(<App dashboard={ready()} />);
expect(screen.getByText("Local Anvil")).toBeTruthy();
expect(screen.getByText("Ready")).toBeTruthy();
expect(screen.getByText("Block 18")).toBeTruthy();
expect(screen.getByText("Paused")).toBeTruthy();
expect(screen.getByText("Version 1")).toBeTruthy();
expect(screen.getByText(/Aug 21, 2026/)).toBeTruthy();
});
it("renders accounting, contract identity, and local tracked accounts", () => {
render(<App dashboard={ready()} />);
expect(screen.getByText("1,500.00 mUSDC")).toBeTruthy();
expect(screen.getByText("1,400.00 mUSDC")).toBeTruthy();
expect(screen.getByText("100.00 mUSDC")).toBeTruthy();
expect(screen.getByText("107.14%")).toBeTruthy();
expect(screen.getByText("Proxy")).toBeTruthy();
expect(screen.getByText("Implementation")).toBeTruthy();
expect(screen.getByText("Token")).toBeTruthy();
expect(screen.getByText("Owner")).toBeTruthy();
expect(screen.getByText("Deployment block")).toBeTruthy();
expect(screen.getByText("Alice")).toBeTruthy();
expect(screen.getByText("Bob")).toBeTruthy();
expect(screen.getByText("900.00 mUSDC")).toBeTruthy();
expect(screen.getByText("500.00 mUSDC")).toBeTruthy();
});
it("renders valid V1 activity newest first beside an isolated decode warning", () => {
render(<App dashboard={ready()} />);
const timeline = screen.getByLabelText("Proxy activity");
expect(timeline.textContent).toContain("Deposited");
expect(timeline.textContent).toContain("Withdrawn");
expect(timeline.textContent).toContain("Paused by");
expect(timeline.textContent).toContain("Ownership transferred");
expect(timeline.textContent).toContain("Implementation upgraded");
expect(timeline.textContent).toContain("Block 7");
expect(timeline.textContent).toContain("Could not decode a proxy event log.");
expect(timeline.textContent?.indexOf("Paused by")).toBeLessThan(timeline.textContent?.indexOf("Withdrawn") ?? 0);
expect(timeline.textContent).toContain("0xcccc…cccc");
});
it("uses validated Base Sepolia explorer links and public shortened account labels", () => {
const baseManifest: DeploymentManifest = {
...localManifest,
network: "baseSepolia",
chainId: 84532,
rpcUrl: "https://sepolia.base.org",
explorerBaseUrl: "https://sepolia.basescan.org",
};
render(<App dashboard={ready(baseManifest)} />);
expect(screen.getByText("Base Sepolia")).toBeTruthy();
expect(screen.queryByText("Alice")).toBeNull();
expect(screen.getAllByText("0x5555…5555").length).toBeGreaterThan(0);
const proxyLink = screen.getByRole("link", { name: /proxy.*0x2222…2222/i });
expect(proxyLink.getAttribute("href")).toBe(`https://sepolia.basescan.org/address/${snapshot.proxy}`);
const transactionLink = screen.getByRole("link", { name: /transaction 0xcccc…cccc/i });
expect(transactionLink.getAttribute("href")).toBe(`https://sepolia.basescan.org/tx/${transactionHash("c")}`);
});
it("does not create explorer links for local Anvil", () => {
render(<App dashboard={ready()} />);
expect(screen.queryAllByRole("link")).toHaveLength(0);
});
it("shows No deposits instead of dividing zero liabilities", () => {
render(<App dashboard={ready(localManifest, { ...snapshot, reserves: 0n, liabilities: 0n, surplus: 0n })} />);
expect(screen.getByText("No deposits")).toBeTruthy();
});
it("distinguishes stale and disconnected conditions without hiding the persistent warnings", () => {
const { rerender } = render(<App dashboard={{
status: "stale",
manifest: localManifest,
snapshot,
failedAt: new Date("2026-08-21T10:05:00.000Z"),
error: "RPC timeout",
}} />);
expect(screen.getByText("Stale")).toBeTruthy();
expect(screen.getByText(/RPC timeout/)).toBeTruthy();
expect(screen.getByText("1,500.00 mUSDC")).toBeTruthy();
rerender(<App dashboard={{
status: "disconnected",
manifest: localManifest,
failedAt: new Date("2026-08-21T10:06:00.000Z"),
error: "connection refused",
}} />);
expect(screen.getAllByText("Disconnected")).toHaveLength(2);
expect(screen.getByText(/connection refused/)).toBeTruthy();
expect(screen.getByText("Educational demo — mock token — never use real funds.")).toBeTruthy();
expect(screen.getByText(/educational and unaudited/i)).toBeTruthy();
});
it("renders explanatory invalid-manifest and chain-mismatch terminal screens", () => {
const { rerender } = render(<App dashboard={{ status: "invalid-manifest", error: "manifest is missing required field proxy" }} />);
expect(screen.getAllByText("Invalid manifest")).toHaveLength(2);
expect(screen.getByText(/missing required field proxy/)).toBeTruthy();
rerender(<App dashboard={{
status: "chain-mismatch",
manifest: localManifest,
error: "endpoint chain ID 84532 does not match manifest chain ID 31337",
}} />);
expect(screen.getAllByText("Chain mismatch")).toHaveLength(2);
expect(screen.getByText(/84532.*31337/)).toBeTruthy();
});
it("exposes no forms, buttons, wallet connection, signing, or transaction controls", () => {
const { container } = render(<App dashboard={ready()} />);
expect(container.querySelector("button")).toBeNull();
expect(container.querySelector("form")).toBeNull();
expect(container.textContent).not.toMatch(/connect wallet|sign transaction|submit transaction|deposit funds|withdraw funds/i);
});
});
+37
View File
@@ -0,0 +1,37 @@
import { AccountingGrid } from "./components/AccountingGrid";
import { AccountTable } from "./components/AccountTable";
import { ActivityTimeline } from "./components/ActivityTimeline";
import { ContractIdentity } from "./components/ContractIdentity";
import { StatusHeader } from "./components/StatusHeader";
import { TrustDisclosure } from "./components/TrustDisclosure";
import { WarningBanner } from "./components/WarningBanner";
import type { DashboardState } from "./hooks/useBankDashboard";
export function App({ dashboard }: { dashboard: DashboardState }) {
const hasSnapshot = dashboard.status === "ready" || dashboard.status === "stale";
return (
<div className="app-shell">
<WarningBanner />
<main>
<StatusHeader state={dashboard} />
{hasSnapshot ? (
<div className="console-grid">
<AccountingGrid snapshot={dashboard.snapshot} />
<ContractIdentity manifest={dashboard.manifest} snapshot={dashboard.snapshot} />
<AccountTable manifest={dashboard.manifest} snapshot={dashboard.snapshot} />
<ActivityTimeline manifest={dashboard.manifest} snapshot={dashboard.snapshot} />
</div>
) : (
<section className="panel unavailable-panel" aria-labelledby="unavailable-heading">
<p className="eyebrow">State unavailable</p>
<h2 id="unavailable-heading">
{dashboard.status === "loading" ? "Synchronizing deployment" : dashboard.status === "invalid-manifest" ? "Invalid manifest" : dashboard.status === "chain-mismatch" ? "Chain mismatch" : "Disconnected"}
</h2>
<p>{dashboard.status === "loading" ? "Validating the public deployment manifest and reconciling a block-consistent snapshot." : "Contract values remain unknown until the configuration and RPC endpoint can be verified."}</p>
</section>
)}
</main>
<TrustDisclosure />
</div>
);
}
+109
View File
@@ -0,0 +1,109 @@
:root {
color: #f5f7f3;
background: #0b0d0c;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-synthesis: none;
--canvas: #0b0d0c;
--surface: #121513;
--surface-raised: #181c19;
--line: #303630;
--muted: #aab3aa;
--green: #5ff59b;
--amber: #ffcf5a;
--red: #ff716c;
--white: #f5f7f3;
}
* { box-sizing: border-box; }
html { background: var(--canvas); }
body { margin: 0; min-width: 320px; min-height: 100vh; background: radial-gradient(circle at 75% 0%, #18251d 0, transparent 32rem), var(--canvas); }
body::before { content: ""; position: fixed; inset: 0; pointer-events: none; opacity: .2; background-image: linear-gradient(rgba(255,255,255,.025) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,.025) 1px, transparent 1px); background-size: 32px 32px; }
a { color: var(--green); text-underline-offset: .2em; }
a:hover { color: var(--white); }
a:focus-visible, abbr:focus-visible { outline: 3px solid var(--amber); outline-offset: 4px; border-radius: 2px; }
abbr[title] { text-decoration-color: #758078; text-underline-offset: .2em; cursor: help; }
code { font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; }
.app-shell { position: relative; width: min(1440px, 100%); margin: 0 auto; padding: 1rem clamp(1rem, 3vw, 3rem) 3rem; }
.warning-banner { position: sticky; z-index: 10; top: .75rem; display: flex; align-items: center; justify-content: center; gap: .65rem; margin-bottom: clamp(2rem, 5vw, 4.5rem); padding: .8rem 1rem; border: 1px solid #806e32; border-radius: 4px; color: #fff3c5; background: rgba(66, 52, 10, .96); box-shadow: 0 12px 40px rgba(0,0,0,.32); font-size: .88rem; letter-spacing: .02em; }
main { display: grid; gap: 1.25rem; }
.status-header { display: grid; grid-template-columns: minmax(0, 1.3fr) minmax(34rem, 1fr); column-gap: 3rem; align-items: end; padding: 0 0 1.6rem; border-bottom: 1px solid var(--line); }
.eyebrow { margin: 0 0 .55rem; color: var(--green); font: 700 .7rem/1.2 "SFMono-Regular", Consolas, monospace; letter-spacing: .16em; text-transform: uppercase; }
h1, h2, p { margin-top: 0; }
h1 { margin-bottom: .75rem; max-width: 13ch; font-size: clamp(2.25rem, 6vw, 5rem); line-height: .92; letter-spacing: -.065em; }
h2 { margin-bottom: 0; font-size: 1.3rem; letter-spacing: -.025em; }
.lede { max-width: 62ch; margin-bottom: 0; color: var(--muted); line-height: 1.6; }
.status-strip { display: grid; grid-template-columns: repeat(5, auto); gap: .5rem; margin: 0; }
.status-strip > div { min-width: 0; padding: .7rem .8rem; border-left: 1px solid var(--line); }
dt { color: var(--muted); font-size: .72rem; letter-spacing: .08em; text-transform: uppercase; }
dd { margin: .35rem 0 0; font-weight: 680; }
.status-strip dd { white-space: nowrap; font-size: .85rem; }
.status-dot { display: inline-block; width: .55rem; height: .55rem; margin-right: .45rem; border-radius: 50%; background: var(--muted); box-shadow: 0 0 12px currentColor; }
.status-dot.good { color: var(--green); background: var(--green); }
.status-dot.warn { color: var(--amber); background: var(--amber); }
.status-dot.bad { color: var(--red); background: var(--red); }
.sync-time { grid-column: 2; margin: .7rem 0 0; color: var(--muted); font-size: .78rem; text-align: right; }
.state-message { grid-column: 1 / -1; margin: 1.2rem 0 0; padding: .8rem 1rem; border-left: 3px solid var(--amber); color: #ffe8a6; background: #28220f; }
.console-grid { display: grid; grid-template-columns: minmax(0, 1.45fr) minmax(24rem, .85fr); gap: 1.25rem; align-items: start; }
.panel, .trust-disclosure { border: 1px solid var(--line); border-radius: 5px; background: linear-gradient(145deg, rgba(24, 28, 25, .97), rgba(16, 19, 17, .97)); box-shadow: 0 18px 50px rgba(0,0,0,.17); }
.panel { padding: clamp(1.2rem, 3vw, 2rem); }
.section-heading { display: flex; justify-content: space-between; align-items: end; gap: 2rem; margin-bottom: 1.7rem; }
.helper { max-width: 38ch; margin: 0; color: var(--muted); font-size: .8rem; line-height: 1.5; text-align: right; }
.accounting-panel { grid-column: 1; }
.accounting-grid { display: grid; grid-template-columns: 1fr 1fr; margin: 0; }
.accounting-grid > div { padding: 1rem 0; border-top: 1px solid var(--line); }
.accounting-grid > div:nth-child(even) { padding-left: 1.5rem; border-left: 1px solid var(--line); }
.accounting-grid dd { font-family: "SFMono-Regular", Consolas, monospace; font-size: 1.15rem; }
.accounting-grid .hero-figure { padding-top: 1.25rem; padding-bottom: 2.2rem; }
.accounting-grid .hero-figure dd { color: var(--white); font-size: clamp(1.75rem, 3.4vw, 3.25rem); line-height: 1; letter-spacing: -.07em; }
.identity-panel { grid-column: 2; grid-row: 1 / span 2; }
.identity-list { display: grid; gap: 0; margin: 0; }
.identity-list > div { padding: 1rem 0; border-top: 1px solid var(--line); }
.identity-list code { display: block; overflow-wrap: anywhere; color: var(--muted); font-size: .78rem; line-height: 1.6; }
.identity-list .proxy-row { margin: 0 -.75rem; padding: 1.25rem .75rem; border: 1px solid #3d5d49; background: #142119; }
.identity-list .primary-address code { color: var(--green); font-size: .93rem; }
.accounts-panel { grid-column: 1; }
.table-scroll { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; }
th, td { padding: .9rem .8rem; border-top: 1px solid var(--line); text-align: left; }
thead th { color: var(--muted); font-size: .68rem; letter-spacing: .1em; text-transform: uppercase; }
tbody th { color: var(--white); }
td:last-child, th:last-child { text-align: right; }
td code { color: var(--muted); font-size: .82rem; }
.activity-panel { grid-column: 1 / -1; }
.timeline { display: grid; grid-template-columns: 1fr 1fr; gap: 0 2rem; margin: 0; padding: 0; list-style: none; }
.timeline li { position: relative; display: grid; grid-template-columns: 1rem 1fr; gap: .8rem; min-width: 0; padding: 1rem 0; border-top: 1px solid var(--line); }
.timeline-marker { width: .5rem; height: .5rem; margin-top: .35rem; border: 1px solid var(--green); border-radius: 50%; background: #163522; box-shadow: 0 0 10px rgba(95,245,155,.28); }
.timeline p { margin-bottom: .4rem; line-height: 1.45; }
.timeline .event-context { margin: 0; color: var(--muted); font-size: .76rem; }
.timeline .diagnostic { color: #ffe3a0; }
.timeline .diagnostic .timeline-marker { border-color: var(--amber); background: #3a2e0d; box-shadow: 0 0 10px rgba(255,207,90,.25); }
.empty-state { margin: 0; color: var(--muted); }
.unavailable-panel { min-height: 15rem; display: grid; align-content: center; justify-items: start; }
.unavailable-panel p:last-child { max-width: 60ch; margin-bottom: 0; color: var(--muted); line-height: 1.6; }
.trust-disclosure { margin-top: 1.25rem; padding: clamp(1.3rem, 3vw, 2.2rem); border-color: #704542; background: linear-gradient(145deg, #211515, #171313); }
.trust-disclosure .eyebrow { color: var(--red); }
.trust-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1.5rem; margin-top: 1.5rem; }
.trust-grid p { margin-bottom: 0; color: #d3c4c2; line-height: 1.55; }
.professional-note { margin: 1.5rem 0 0; padding-top: 1.25rem; border-top: 1px solid #533632; color: var(--white); line-height: 1.6; }
@media (max-width: 980px) {
.status-header { grid-template-columns: 1fr; align-items: start; }
.status-strip { grid-template-columns: repeat(5, 1fr); margin-top: 2rem; }
.sync-time { grid-column: 1; text-align: left; }
.console-grid { grid-template-columns: 1fr; }
.accounting-panel, .identity-panel, .accounts-panel, .activity-panel { grid-column: 1; grid-row: auto; }
.timeline { grid-template-columns: 1fr; }
}
@media (max-width: 700px) {
.app-shell { padding-inline: .75rem; }
.warning-banner { top: .35rem; margin-bottom: 2.5rem; }
.status-strip { grid-template-columns: repeat(2, 1fr); }
.section-heading { display: block; }
.helper { margin-top: .75rem; text-align: left; }
.accounting-grid { grid-template-columns: 1fr; }
.accounting-grid > div:nth-child(even) { padding-left: 0; border-left: 0; }
.accounting-grid .hero-figure { padding-bottom: 1.35rem; }
.trust-grid { grid-template-columns: 1fr; gap: .7rem; }
}
+27
View File
@@ -0,0 +1,27 @@
import type { DashboardSnapshot, DeploymentManifest } from "../types/dashboard";
import { formatAmount, shortenAddress } from "./format";
export function AccountTable({ manifest, snapshot }: { manifest: DeploymentManifest; snapshot: DashboardSnapshot }) {
return (
<section className="panel accounts-panel" aria-labelledby="accounts-heading">
<div className="section-heading">
<div><p className="eyebrow">Internal ledger</p><h2 id="accounts-heading">Tracked accounts</h2></div>
<p className="helper">Balances are bank liabilities, not wallet token balances.</p>
</div>
<div className="table-scroll">
<table>
<thead><tr><th scope="col">Account</th><th scope="col">Address</th><th scope="col">Balance</th></tr></thead>
<tbody>
{snapshot.actors.map((actor) => (
<tr key={actor.address}>
<th scope="row">{manifest.network === "anvil" ? actor.label : shortenAddress(actor.address)}</th>
<td><code title={actor.address}>{shortenAddress(actor.address)}</code></td>
<td>{formatAmount(actor.balance)}</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
);
}
+19
View File
@@ -0,0 +1,19 @@
import type { DashboardSnapshot } from "../types/dashboard";
import { formatAmount, formatReserveRatio, formatSurplus } from "./format";
export function AccountingGrid({ snapshot }: { snapshot: DashboardSnapshot }) {
return (
<section className="panel accounting-panel" aria-labelledby="accounting-heading">
<div className="section-heading">
<div><p className="eyebrow">Custody invariant</p><h2 id="accounting-heading">Accounting</h2></div>
<p className="helper">Solvent when reserves are greater than or equal to liabilities.</p>
</div>
<dl className="accounting-grid">
<div className="hero-figure"><dt><abbr title="MockUSDC held by the stable proxy address">Reserves</abbr></dt><dd>{formatAmount(snapshot.reserves)}</dd></div>
<div className="hero-figure"><dt><abbr title="The sum of all balances recorded in the bank ledger">Liabilities</abbr></dt><dd>{formatAmount(snapshot.liabilities)}</dd></div>
<div><dt>Surplus</dt><dd>{formatSurplus(snapshot.surplus)}</dd></div>
<div><dt>Reserve ratio</dt><dd>{formatReserveRatio(snapshot.reserves, snapshot.liabilities)}</dd></div>
</dl>
</section>
);
}
+69
View File
@@ -0,0 +1,69 @@
import type { Address, Hex } from "viem";
import type { Activity, DashboardSnapshot, DecodeDiagnostic, DeploymentManifest } from "../types/dashboard";
import { formatAmount, shortenAddress } from "./format";
type TimelineItem = (Activity & { diagnostic?: false }) | (DecodeDiagnostic & { diagnostic: true });
function actorLabel(address: Address, manifest: DeploymentManifest): string {
if (manifest.network === "baseSepolia") return shortenAddress(address);
return manifest.actors.find((actor) => actor.address.toLowerCase() === address.toLowerCase())?.label ?? shortenAddress(address);
}
function description(activity: Activity, manifest: DeploymentManifest): string {
switch (activity.kind) {
case "deposit": return `Deposited ${formatAmount(activity.amount)} for ${actorLabel(activity.account, manifest)}`;
case "withdrawal": return `Withdrawn ${formatAmount(activity.amount)} for ${actorLabel(activity.account, manifest)}`;
case "paused": return `Paused by ${actorLabel(activity.account, manifest)}`;
case "unpaused": return `Unpaused by ${actorLabel(activity.account, manifest)}`;
case "ownershipTransferred": return `Ownership transferred from ${actorLabel(activity.previousOwner, manifest)} to ${actorLabel(activity.newOwner, manifest)}`;
case "upgraded": return `Implementation upgraded to ${shortenAddress(activity.implementation)}`;
}
}
function transactionUrl(manifest: DeploymentManifest, transactionHash: Hex): string | undefined {
if (!manifest.explorerBaseUrl) return undefined;
return `${manifest.explorerBaseUrl.replace(/\/$/, "")}/tx/${transactionHash}`;
}
function newestFirst(first: TimelineItem, second: TimelineItem): number {
if (first.blockNumber === second.blockNumber) return second.logIndex - first.logIndex;
return first.blockNumber > second.blockNumber ? -1 : 1;
}
export function ActivityTimeline({ manifest, snapshot }: { manifest: DeploymentManifest; snapshot: DashboardSnapshot }) {
const items: TimelineItem[] = [
...snapshot.activity.map((activity) => ({ ...activity, diagnostic: false as const })),
...snapshot.diagnostics.map((diagnostic) => ({ ...diagnostic, diagnostic: true as const })),
].sort(newestFirst);
return (
<section className="panel activity-panel" aria-labelledby="activity-heading">
<div className="section-heading">
<div><p className="eyebrow">Proxy-only log stream</p><h2 id="activity-heading">Activity</h2></div>
<p className="helper">Newest first. Direct reads reconcile the accounting shown above.</p>
</div>
{items.length === 0 ? <p className="empty-state">No proxy activity found from the deployment block.</p> : (
<ol className="timeline" aria-label="Proxy activity">
{items.map((item) => {
const key = `${item.transactionHash}-${item.logIndex}`;
const url = transactionUrl(manifest, item.transactionHash);
const transaction = <code>{shortenAddress(item.transactionHash)}</code>;
return (
<li key={key} className={item.diagnostic ? "diagnostic" : undefined}>
<div className="timeline-marker" aria-hidden="true" />
<div>
<p>{item.diagnostic ? item.message : description(item, manifest)}</p>
<p className="event-context">
Block {item.blockNumber.toString()} · {url
? <a href={url} aria-label={`Transaction ${shortenAddress(item.transactionHash)}`} target="_blank" rel="noreferrer">{transaction}</a>
: transaction}
</p>
</div>
</li>
);
})}
</ol>
)}
</section>
);
}
+41
View File
@@ -0,0 +1,41 @@
import type { Address } from "viem";
import type { DashboardSnapshot, DeploymentManifest } from "../types/dashboard";
import { shortenAddress } from "./format";
function addressUrl(manifest: DeploymentManifest, address: Address): string | undefined {
if (!manifest.explorerBaseUrl) return undefined;
return `${manifest.explorerBaseUrl.replace(/\/$/, "")}/address/${address}`;
}
function AddressValue({ label, address, manifest, primary = false }: {
label: string;
address: Address;
manifest: DeploymentManifest;
primary?: boolean;
}) {
const value = <code title={address}>{manifest.network === "baseSepolia" ? shortenAddress(address) : address}</code>;
const url = addressUrl(manifest, address);
return (
<dd className={primary ? "primary-address" : undefined}>
{url ? <a href={url} aria-label={`${label} ${shortenAddress(address)}`} target="_blank" rel="noreferrer">{value}</a> : value}
</dd>
);
}
export function ContractIdentity({ manifest, snapshot }: { manifest: DeploymentManifest; snapshot: DashboardSnapshot }) {
return (
<section className="panel identity-panel" aria-labelledby="identity-heading">
<div className="section-heading">
<div><p className="eyebrow">Upgrade boundary</p><h2 id="identity-heading">Contract identity</h2></div>
<p className="helper">Reads target the proxy. The implementation address identifies its current logic.</p>
</div>
<dl className="identity-list">
<div className="proxy-row"><dt><abbr title="The stable application address that holds storage and reserves">Proxy</abbr></dt><AddressValue label="Proxy" address={snapshot.proxy} manifest={manifest} primary /></div>
<div><dt><abbr title="The replaceable contract containing the current executable logic">Implementation</abbr></dt><AddressValue label="Implementation" address={snapshot.implementation} manifest={manifest} /></div>
<div><dt>Token</dt><AddressValue label="Token" address={snapshot.asset} manifest={manifest} /></div>
<div><dt><abbr title="The account authorized to pause and upgrade the proxy">Owner</abbr></dt><AddressValue label="Owner" address={snapshot.owner} manifest={manifest} /></div>
<div><dt>Deployment block</dt><dd>{manifest.deploymentBlock.toString()}</dd></div>
</dl>
</section>
);
}
+42
View File
@@ -0,0 +1,42 @@
import type { DashboardState } from "../hooks/useBankDashboard";
import { formatDate } from "./format";
const networkLabel = (state: DashboardState) => {
if (!("manifest" in state)) return "Network unavailable";
return state.manifest.network === "anvil" ? "Local Anvil" : "Base Sepolia";
};
const statusLabel = (status: DashboardState["status"]) => ({
loading: "Loading",
ready: "Ready",
stale: "Stale",
disconnected: "Disconnected",
"invalid-manifest": "Invalid manifest",
"chain-mismatch": "Chain mismatch",
})[status];
export function StatusHeader({ state }: { state: DashboardState }) {
const snapshot = "snapshot" in state ? state.snapshot : undefined;
const statusTone = state.status === "ready" ? "good" : state.status === "loading" ? "neutral" : state.status === "stale" ? "warn" : "bad";
return (
<header className="status-header" aria-labelledby="console-heading">
<div className="title-lockup">
<p className="eyebrow">UUPS custody lab / read-only telemetry</p>
<h1 id="console-heading">Bank operations console</h1>
<p className="lede">Reconciled proxy state and event history. State changes happen only through the projects Foundry scripts.</p>
</div>
<dl className="status-strip">
<div><dt>Network</dt><dd>{networkLabel(state)}</dd></div>
<div><dt>Sync</dt><dd><span className={`status-dot ${statusTone}`} aria-hidden="true" />{statusLabel(state.status)}</dd></div>
<div><dt>Height</dt><dd>{snapshot ? `Block ${snapshot.blockNumber}` : "—"}</dd></div>
<div><dt>Bank</dt><dd>{snapshot ? (snapshot.paused ? "Paused" : "Active") : "—"}</dd></div>
<div><dt>Logic</dt><dd>{snapshot ? `Version ${snapshot.version}` : "—"}</dd></div>
</dl>
{snapshot && <p className="sync-time">Last synchronized <time dateTime={snapshot.synchronizedAt.toISOString()}>{formatDate(snapshot.synchronizedAt)}</time></p>}
{(state.status === "stale" || state.status === "disconnected") && (
<p className="state-message" role="status">{state.error} Failed at <time dateTime={state.failedAt.toISOString()}>{formatDate(state.failedAt)}</time>.</p>
)}
{(state.status === "invalid-manifest" || state.status === "chain-mismatch") && <p className="state-message" role="alert">{state.error}</p>}
</header>
);
}
+14
View File
@@ -0,0 +1,14 @@
export function TrustDisclosure() {
return (
<aside className="trust-disclosure" aria-labelledby="trust-heading">
<p className="eyebrow">Trust boundary</p>
<h2 id="trust-heading">What this demo does not guarantee</h2>
<div className="trust-grid">
<p><strong>Unaudited code.</strong> MockUSDC has no value. These contracts are educational and unaudited; real deposits must never be sent here.</p>
<p><strong>Central upgrade authority.</strong> The owner can pause customer actions and install arbitrary future logic.</p>
<p><strong>Upgrade risk.</strong> UUPS mistakes can corrupt state or permanently brick upgradeability.</p>
</div>
<p className="professional-note">A real custody product requires professional audits, operational key controls, multisig or timelocked governance, incident procedures, legal advice, and jurisdiction-specific compliance work.</p>
</aside>
);
}
+8
View File
@@ -0,0 +1,8 @@
export function WarningBanner() {
return (
<aside className="warning-banner" aria-label="Demo warning">
<span aria-hidden="true"></span>
<strong>Educational demo mock token never use real funds.</strong>
</aside>
);
}
+35
View File
@@ -0,0 +1,35 @@
import { describe, expect, it } from "vitest";
import { formatAmount, formatReserveRatio, formatSurplus, shortenAddress } from "./format";
describe("console formatters", () => {
it("preserves six-decimal token precision without showing raw base units", () => {
expect(formatAmount(1_234_567_890n)).toBe("1,234.56789 mUSDC");
});
it("shortens addresses while preserving recognizable ends", () => {
expect(shortenAddress("0x1234567890abcdef1234567890abcdef1234cDEF")).toBe("0x1234…cDEF");
});
it("formats reserve surplus as a token amount", () => {
expect(formatSurplus(25_500_000n)).toBe("25.50 mUSDC");
});
it("reports an exact fully reserved ratio when reserves equal liabilities", () => {
expect(formatReserveRatio(1_400_000_000n, 1_400_000_000n)).toBe("100.00%");
});
it("reports an overcollateralized reserve ratio", () => {
expect(formatReserveRatio(1_750_000_000n, 1_400_000_000n)).toBe("125.00%");
});
it("explains that a zero-liability ratio has no deposits", () => {
expect(formatReserveRatio(0n, 0n)).toBe("No deposits");
});
it("renders failed or unknown values as an em dash rather than fabricated zero", () => {
expect(formatAmount(undefined)).toBe("—");
expect(formatSurplus(null)).toBe("—");
expect(formatReserveRatio(undefined, 1n)).toBe("—");
expect(formatReserveRatio(1n, undefined)).toBe("—");
});
});
+44
View File
@@ -0,0 +1,44 @@
import type { Address, Hex } from "viem";
const UNKNOWN = "—";
export function formatAmount(value: bigint | null | undefined): string {
if (value === null || value === undefined) return UNKNOWN;
const negative = value < 0n;
const absolute = negative ? -value : value;
const whole = absolute / 1_000_000n;
const fraction = absolute % 1_000_000n;
const groupedWhole = new Intl.NumberFormat("en-US").format(whole);
let fractionText = fraction.toString().padStart(6, "0").replace(/0+$/, "");
if (fractionText.length < 2) fractionText = fractionText.padEnd(2, "0");
return `${negative ? "" : ""}${groupedWhole}.${fractionText} mUSDC`;
}
export function formatSurplus(value: bigint | null | undefined): string {
return formatAmount(value);
}
export function formatReserveRatio(
reserves: bigint | null | undefined,
liabilities: bigint | null | undefined,
): string {
if (reserves === null || reserves === undefined || liabilities === null || liabilities === undefined) return UNKNOWN;
if (liabilities === 0n) return "No deposits";
const hundredthsOfPercent = (reserves * 10_000n + liabilities / 2n) / liabilities;
const whole = hundredthsOfPercent / 100n;
const fraction = (hundredthsOfPercent % 100n).toString().padStart(2, "0");
return `${whole}.${fraction}%`;
}
export function shortenAddress(value: Address | Hex): string {
return `${value.slice(0, 6)}${value.slice(-4)}`;
}
export function formatDate(value: Date | undefined): string {
if (!value) return UNKNOWN;
return new Intl.DateTimeFormat("en-US", {
dateStyle: "medium",
timeStyle: "medium",
timeZone: "UTC",
}).format(value);
}
+173
View File
@@ -0,0 +1,173 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { act, renderHook, waitFor } from "@testing-library/react";
import type { ReactNode } from "react";
import type { Address } from "viem";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { DashboardSnapshot, DeploymentManifest } from "../types/dashboard";
import { useBankDashboard } from "./useBankDashboard";
let notifyBlock: ((blockNumber: bigint) => void) | undefined;
vi.mock("wagmi", () => ({
useWatchBlockNumber: (options: { onBlockNumber?: (blockNumber: bigint) => void }) => {
notifyBlock = options.onBlockNumber;
},
}));
const address = (digit: string) => `0x${digit.repeat(40)}` as Address;
const manifest: DeploymentManifest = {
schemaVersion: 1,
network: "anvil",
chainId: 31337,
deploymentBlock: 3n,
rpcUrl: "http://127.0.0.1:8545",
token: address("1"),
proxy: address("2"),
implementation: address("3"),
owner: address("4"),
actors: [{ label: "Alice", address: address("5") }],
};
const manifestJson = { ...manifest, deploymentBlock: 3 };
const snapshot: DashboardSnapshot = {
blockNumber: 12n,
synchronizedAt: new Date("2026-08-21T10:00:00.000Z"),
version: 1,
paused: false,
asset: manifest.token,
owner: manifest.owner,
proxy: manifest.proxy,
implementation: manifest.implementation,
reserves: 900_000_000n,
liabilities: 900_000_000n,
surplus: 0n,
actors: [{ label: "Alice", address: address("5"), balance: 900_000_000n }],
activity: [],
diagnostics: [],
};
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason: unknown) => void;
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
return { promise, resolve, reject };
}
function createWrapper() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } });
return function Wrapper({ children }: { children: ReactNode }) {
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
};
}
describe("useBankDashboard", () => {
beforeEach(() => {
notifyBlock = undefined;
});
it("starts in loading while the manifest and first reconciled snapshot are pending", () => {
const pendingManifest = deferred<unknown>();
const { result } = renderHook(() => useBankDashboard({
loadManifest: () => pendingManifest.promise,
loader: vi.fn(),
}), { wrapper: createWrapper() });
expect(result.current.status).toBe("loading");
});
it("becomes ready with the synchronized block and time from the successful snapshot", async () => {
const { result } = renderHook(() => useBankDashboard({
loadManifest: async () => manifestJson,
loader: async () => snapshot,
}), { wrapper: createWrapper() });
await waitFor(() => expect(result.current.status).toBe("ready"));
if (result.current.status !== "ready") throw new Error("expected ready dashboard");
expect(result.current.snapshot.blockNumber).toBe(12n);
expect(result.current.snapshot.synchronizedAt.toISOString()).toBe("2026-08-21T10:00:00.000Z");
});
it("retains the last successful snapshot as stale when a later reconciliation fails", async () => {
const loader = vi.fn()
.mockResolvedValueOnce(snapshot)
.mockRejectedValueOnce(new Error("RPC timeout"));
const clock = vi.fn(() => new Date("2026-08-21T10:05:00.000Z"));
const { result } = renderHook(() => useBankDashboard({
loadManifest: async () => manifestJson,
loader,
now: clock,
}), { wrapper: createWrapper() });
await waitFor(() => expect(result.current.status).toBe("ready"));
act(() => notifyBlock?.(13n));
await waitFor(() => expect(result.current.status).toBe("stale"));
if (result.current.status !== "stale") throw new Error("expected stale dashboard");
expect(result.current.snapshot).toBe(snapshot);
expect(result.current.failedAt.toISOString()).toBe("2026-08-21T10:05:00.000Z");
expect(result.current.error).toContain("RPC timeout");
});
it("reports an initial RPC failure as disconnected without inventing a snapshot", async () => {
const { result } = renderHook(() => useBankDashboard({
loadManifest: async () => manifestJson,
loader: async () => { throw new Error("connection refused"); },
now: () => new Date("2026-08-21T10:06:00.000Z"),
}), { wrapper: createWrapper() });
await waitFor(() => expect(result.current.status).toBe("disconnected"));
if (result.current.status !== "disconnected") throw new Error("expected disconnected dashboard");
expect(result.current.snapshot).toBeUndefined();
expect(result.current.error).toContain("connection refused");
});
it("makes an invalid manifest terminal and never calls the snapshot loader", async () => {
const loader = vi.fn();
const { result } = renderHook(() => useBankDashboard({
loadManifest: async () => ({ network: "anvil" }),
loader,
}), { wrapper: createWrapper() });
await waitFor(() => expect(result.current.status).toBe("invalid-manifest"));
expect(loader).not.toHaveBeenCalled();
act(() => notifyBlock?.(14n));
expect(loader).not.toHaveBeenCalled();
});
it("makes a manifest/network mismatch terminal and stops block reconciliation", async () => {
const loader = vi.fn().mockRejectedValue(new Error(
"endpoint chain ID 84532 does not match manifest chain ID 31337",
));
const { result } = renderHook(() => useBankDashboard({
loadManifest: async () => manifestJson,
loader,
}), { wrapper: createWrapper() });
await waitFor(() => expect(result.current.status).toBe("chain-mismatch"));
act(() => notifyBlock?.(14n));
await Promise.resolve();
expect(loader).toHaveBeenCalledTimes(1);
});
it("reconciles exactly once when a new watched block arrives", async () => {
const nextSnapshot = { ...snapshot, blockNumber: 13n, synchronizedAt: new Date("2026-08-21T10:01:00.000Z") };
const loader = vi.fn().mockResolvedValueOnce(snapshot).mockResolvedValueOnce(nextSnapshot);
const { result } = renderHook(() => useBankDashboard({
loadManifest: async () => manifestJson,
loader,
}), { wrapper: createWrapper() });
await waitFor(() => expect(result.current.status).toBe("ready"));
act(() => {
notifyBlock?.(13n);
notifyBlock?.(13n);
});
await waitFor(() => {
expect(loader).toHaveBeenCalledTimes(2);
expect(result.current.status === "ready" && result.current.snapshot.blockNumber).toBe(13n);
});
});
});
+118
View File
@@ -0,0 +1,118 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useCallback, useEffect, useMemo, useRef } from "react";
import type { PublicClient } from "viem";
import { useWatchBlockNumber } from "wagmi";
import { createManifestPublicClient } from "../config/chains";
import { parseDeploymentManifest } from "../config/manifest";
import { loadDashboardSnapshot, ViemBankReader } from "../data/bankClient";
import type { DashboardSnapshot, DeploymentManifest } from "../types/dashboard";
export type DashboardState =
| Readonly<{ status: "loading" }>
| Readonly<{ status: "ready"; manifest: DeploymentManifest; snapshot: DashboardSnapshot }>
| Readonly<{ status: "stale"; manifest: DeploymentManifest; snapshot: DashboardSnapshot; failedAt: Date; error: string }>
| Readonly<{ status: "disconnected"; manifest: DeploymentManifest; snapshot?: undefined; failedAt: Date; error: string }>
| Readonly<{ status: "invalid-manifest"; error: string }>
| Readonly<{ status: "chain-mismatch"; manifest: DeploymentManifest; error: string }>;
export type BankDashboardOptions = Readonly<{
loadManifest?: () => Promise<unknown>;
loader?: (manifest: DeploymentManifest) => Promise<DashboardSnapshot>;
now?: () => Date;
}>;
const manifestQueryKey = ["deployment-manifest"] as const;
const defaultNow = () => new Date();
async function fetchManifest(): Promise<unknown> {
const response = await fetch("/deployment.json", { headers: { Accept: "application/json" } });
if (!response.ok) throw new Error(`deployment manifest request failed with HTTP ${response.status}`);
return response.json() as Promise<unknown>;
}
async function fetchSnapshot(manifest: DeploymentManifest): Promise<DashboardSnapshot> {
const client = createManifestPublicClient(manifest);
return loadDashboardSnapshot(new ViemBankReader(client as PublicClient), manifest);
}
function message(error: unknown): string {
return error instanceof Error ? error.message : "Unknown dashboard error";
}
function isChainMismatch(error: unknown): boolean {
return /endpoint chain ID \d+ does not match manifest chain ID \d+/i.test(message(error));
}
export function useBankDashboard(options: BankDashboardOptions = {}): DashboardState {
const loadManifest = options.loadManifest ?? fetchManifest;
const loader = options.loader ?? fetchSnapshot;
const now = options.now ?? defaultNow;
const queryClient = useQueryClient();
const lastWatchedBlock = useRef<bigint | undefined>(undefined);
const manifestQuery = useQuery({
queryKey: manifestQueryKey,
queryFn: loadManifest,
retry: false,
staleTime: Number.POSITIVE_INFINITY,
});
const parsedManifest = useMemo(() => {
if (manifestQuery.data === undefined) return undefined;
try {
return { manifest: parseDeploymentManifest(manifestQuery.data) } as const;
} catch (error) {
return { error: message(error) } as const;
}
}, [manifestQuery.data]);
const manifest = parsedManifest && "manifest" in parsedManifest ? parsedManifest.manifest : undefined;
const snapshotQueryKey = useMemo(
() => ["bank-dashboard", manifest?.chainId, manifest?.proxy] as const,
[manifest?.chainId, manifest?.proxy],
);
const snapshotQuery = useQuery({
queryKey: snapshotQueryKey,
queryFn: () => {
if (!manifest) throw new Error("deployment manifest is unavailable");
return loader(manifest);
},
enabled: manifest !== undefined,
retry: false,
});
const mismatch = snapshotQuery.error !== null && isChainMismatch(snapshotQuery.error);
const latestSnapshot = useRef<DashboardSnapshot | undefined>(undefined);
useEffect(() => {
latestSnapshot.current = snapshotQuery.data;
}, [snapshotQuery.data]);
const reconcileBlock = useCallback((blockNumber: bigint) => {
if (!manifest || mismatch || latestSnapshot.current?.blockNumber === blockNumber || lastWatchedBlock.current === blockNumber) return;
lastWatchedBlock.current = blockNumber;
void queryClient.invalidateQueries({ queryKey: snapshotQueryKey, exact: true });
}, [manifest, mismatch, queryClient, snapshotQueryKey]);
useWatchBlockNumber({
chainId: manifest?.chainId,
enabled: manifest !== undefined && !mismatch,
onBlockNumber: reconcileBlock,
});
const failedAt = useMemo(
() => snapshotQuery.error === null ? undefined : now(),
[now, snapshotQuery.error],
);
if (manifestQuery.isPending) return { status: "loading" };
if (manifestQuery.error !== null) return { status: "invalid-manifest", error: message(manifestQuery.error) };
if (parsedManifest && "error" in parsedManifest) return { status: "invalid-manifest", error: parsedManifest.error ?? "Deployment manifest is invalid" };
if (!manifest) return { status: "loading" };
if (mismatch) return { status: "chain-mismatch", manifest, error: message(snapshotQuery.error) };
if (snapshotQuery.error !== null) {
const error = message(snapshotQuery.error);
if (snapshotQuery.data) return { status: "stale", manifest, snapshot: snapshotQuery.data, failedAt: failedAt ?? now(), error };
return { status: "disconnected", manifest, failedAt: failedAt ?? now(), error };
}
if (snapshotQuery.data) return { status: "ready", manifest, snapshot: snapshotQuery.data };
return { status: "loading" };
}
+34
View File
@@ -0,0 +1,34 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { createConfig, http, WagmiProvider } from "wagmi";
import { baseSepolia } from "wagmi/chains";
import { App } from "./App";
import "./app.css";
import { anvil } from "./config/chains";
import { useBankDashboard } from "./hooks/useBankDashboard";
const wagmiConfig = createConfig({
chains: [anvil, baseSepolia],
connectors: [],
transports: {
[anvil.id]: http("http://127.0.0.1:8545"),
[baseSepolia.id]: http("https://sepolia.base.org"),
},
});
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
export function LiveConsole() {
const dashboard = useBankDashboard();
return <App dashboard={dashboard} />;
}
createRoot(document.getElementById("root")!).render(
<StrictMode>
<WagmiProvider config={wagmiConfig} reconnectOnMount={false}>
<QueryClientProvider client={queryClient}>
<LiveConsole />
</QueryClientProvider>
</WagmiProvider>
</StrictMode>,
);
+4 -1
View File
@@ -1 +1,4 @@
export {};
import { cleanup } from "@testing-library/react";
import { afterEach } from "vitest";
afterEach(cleanup);
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />