feat: add read-only bank operations console
This commit is contained in:
@@ -7,5 +7,6 @@
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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; }
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 project’s 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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("—");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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" };
|
||||
}
|
||||
@@ -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>,
|
||||
);
|
||||
@@ -1 +1,4 @@
|
||||
export {};
|
||||
import { cleanup } from "@testing-library/react";
|
||||
import { afterEach } from "vitest";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user