diff --git a/web/index.html b/web/index.html index d91f1f3..d5df951 100644 --- a/web/index.html +++ b/web/index.html @@ -7,5 +7,6 @@
+ diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx new file mode 100644 index 0000000..6e5af60 --- /dev/null +++ b/web/src/App.test.tsx @@ -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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + expect(screen.queryAllByRole("link")).toHaveLength(0); + }); + + it("shows No deposits instead of dividing zero liabilities", () => { + render(); + expect(screen.getByText("No deposits")).toBeTruthy(); + }); + + it("distinguishes stale and disconnected conditions without hiding the persistent warnings", () => { + const { rerender } = render(); + expect(screen.getByText("Stale")).toBeTruthy(); + expect(screen.getByText(/RPC timeout/)).toBeTruthy(); + expect(screen.getByText("1,500.00 mUSDC")).toBeTruthy(); + + rerender(); + 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(); + expect(screen.getAllByText("Invalid manifest")).toHaveLength(2); + expect(screen.getByText(/missing required field proxy/)).toBeTruthy(); + + rerender(); + 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(); + 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); + }); +}); diff --git a/web/src/App.tsx b/web/src/App.tsx new file mode 100644 index 0000000..30534df --- /dev/null +++ b/web/src/App.tsx @@ -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 ( +
+ +
+ + {hasSnapshot ? ( +
+ + + + +
+ ) : ( +
+

State unavailable

+

+ {dashboard.status === "loading" ? "Synchronizing deployment" : dashboard.status === "invalid-manifest" ? "Invalid manifest" : dashboard.status === "chain-mismatch" ? "Chain mismatch" : "Disconnected"} +

+

{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."}

+
+ )} +
+ +
+ ); +} diff --git a/web/src/app.css b/web/src/app.css new file mode 100644 index 0000000..c036fe7 --- /dev/null +++ b/web/src/app.css @@ -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; } +} diff --git a/web/src/components/AccountTable.tsx b/web/src/components/AccountTable.tsx new file mode 100644 index 0000000..8c15894 --- /dev/null +++ b/web/src/components/AccountTable.tsx @@ -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 ( +
+
+

Internal ledger

Tracked accounts

+

Balances are bank liabilities, not wallet token balances.

+
+
+ + + + {snapshot.actors.map((actor) => ( + + + + + + ))} + +
AccountAddressBalance
{manifest.network === "anvil" ? actor.label : shortenAddress(actor.address)}{shortenAddress(actor.address)}{formatAmount(actor.balance)}
+
+
+ ); +} diff --git a/web/src/components/AccountingGrid.tsx b/web/src/components/AccountingGrid.tsx new file mode 100644 index 0000000..84b83ac --- /dev/null +++ b/web/src/components/AccountingGrid.tsx @@ -0,0 +1,19 @@ +import type { DashboardSnapshot } from "../types/dashboard"; +import { formatAmount, formatReserveRatio, formatSurplus } from "./format"; + +export function AccountingGrid({ snapshot }: { snapshot: DashboardSnapshot }) { + return ( +
+
+

Custody invariant

Accounting

+

Solvent when reserves are greater than or equal to liabilities.

+
+
+
Reserves
{formatAmount(snapshot.reserves)}
+
Liabilities
{formatAmount(snapshot.liabilities)}
+
Surplus
{formatSurplus(snapshot.surplus)}
+
Reserve ratio
{formatReserveRatio(snapshot.reserves, snapshot.liabilities)}
+
+
+ ); +} diff --git a/web/src/components/ActivityTimeline.tsx b/web/src/components/ActivityTimeline.tsx new file mode 100644 index 0000000..1e05f21 --- /dev/null +++ b/web/src/components/ActivityTimeline.tsx @@ -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 ( +
+
+

Proxy-only log stream

Activity

+

Newest first. Direct reads reconcile the accounting shown above.

+
+ {items.length === 0 ?

No proxy activity found from the deployment block.

: ( +
    + {items.map((item) => { + const key = `${item.transactionHash}-${item.logIndex}`; + const url = transactionUrl(manifest, item.transactionHash); + const transaction = {shortenAddress(item.transactionHash)}; + return ( +
  1. +
  2. + ); + })} +
+ )} +
+ ); +} diff --git a/web/src/components/ContractIdentity.tsx b/web/src/components/ContractIdentity.tsx new file mode 100644 index 0000000..f5c8543 --- /dev/null +++ b/web/src/components/ContractIdentity.tsx @@ -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 = {manifest.network === "baseSepolia" ? shortenAddress(address) : address}; + const url = addressUrl(manifest, address); + return ( +
+ {url ? {value} : value} +
+ ); +} + +export function ContractIdentity({ manifest, snapshot }: { manifest: DeploymentManifest; snapshot: DashboardSnapshot }) { + return ( +
+
+

Upgrade boundary

Contract identity

+

Reads target the proxy. The implementation address identifies its current logic.

+
+
+
Proxy
+
Implementation
+
Token
+
Owner
+
Deployment block
{manifest.deploymentBlock.toString()}
+
+
+ ); +} diff --git a/web/src/components/StatusHeader.tsx b/web/src/components/StatusHeader.tsx new file mode 100644 index 0000000..7107bc1 --- /dev/null +++ b/web/src/components/StatusHeader.tsx @@ -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 ( +
+
+

UUPS custody lab / read-only telemetry

+

Bank operations console

+

Reconciled proxy state and event history. State changes happen only through the project’s Foundry scripts.

+
+
+
Network
{networkLabel(state)}
+
Sync
+
Height
{snapshot ? `Block ${snapshot.blockNumber}` : "—"}
+
Bank
{snapshot ? (snapshot.paused ? "Paused" : "Active") : "—"}
+
Logic
{snapshot ? `Version ${snapshot.version}` : "—"}
+
+ {snapshot &&

Last synchronized

} + {(state.status === "stale" || state.status === "disconnected") && ( +

{state.error} Failed at .

+ )} + {(state.status === "invalid-manifest" || state.status === "chain-mismatch") &&

{state.error}

} +
+ ); +} diff --git a/web/src/components/TrustDisclosure.tsx b/web/src/components/TrustDisclosure.tsx new file mode 100644 index 0000000..953dff9 --- /dev/null +++ b/web/src/components/TrustDisclosure.tsx @@ -0,0 +1,14 @@ +export function TrustDisclosure() { + return ( + + ); +} diff --git a/web/src/components/WarningBanner.tsx b/web/src/components/WarningBanner.tsx new file mode 100644 index 0000000..cc3431b --- /dev/null +++ b/web/src/components/WarningBanner.tsx @@ -0,0 +1,8 @@ +export function WarningBanner() { + return ( + + ); +} diff --git a/web/src/components/format.test.ts b/web/src/components/format.test.ts new file mode 100644 index 0000000..d54de01 --- /dev/null +++ b/web/src/components/format.test.ts @@ -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("—"); + }); +}); diff --git a/web/src/components/format.ts b/web/src/components/format.ts new file mode 100644 index 0000000..27ec567 --- /dev/null +++ b/web/src/components/format.ts @@ -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); +} diff --git a/web/src/hooks/useBankDashboard.test.tsx b/web/src/hooks/useBankDashboard.test.tsx new file mode 100644 index 0000000..624c1c0 --- /dev/null +++ b/web/src/hooks/useBankDashboard.test.tsx @@ -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() { + let resolve!: (value: T) => void; + let reject!: (reason: unknown) => void; + const promise = new Promise((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 {children}; + }; +} + +describe("useBankDashboard", () => { + beforeEach(() => { + notifyBlock = undefined; + }); + + it("starts in loading while the manifest and first reconciled snapshot are pending", () => { + const pendingManifest = deferred(); + 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); + }); + }); +}); diff --git a/web/src/hooks/useBankDashboard.ts b/web/src/hooks/useBankDashboard.ts new file mode 100644 index 0000000..9f79d2c --- /dev/null +++ b/web/src/hooks/useBankDashboard.ts @@ -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; + loader?: (manifest: DeploymentManifest) => Promise; + now?: () => Date; +}>; + +const manifestQueryKey = ["deployment-manifest"] as const; +const defaultNow = () => new Date(); + +async function fetchManifest(): Promise { + 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; +} + +async function fetchSnapshot(manifest: DeploymentManifest): Promise { + 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(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(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" }; +} diff --git a/web/src/main.tsx b/web/src/main.tsx new file mode 100644 index 0000000..d4fbf88 --- /dev/null +++ b/web/src/main.tsx @@ -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 ; +} + +createRoot(document.getElementById("root")!).render( + + + + + + + , +); diff --git a/web/src/test/setup.ts b/web/src/test/setup.ts index cb0ff5c..b37ae6c 100644 --- a/web/src/test/setup.ts +++ b/web/src/test/setup.ts @@ -1 +1,4 @@ -export {}; +import { cleanup } from "@testing-library/react"; +import { afterEach } from "vitest"; + +afterEach(cleanup); diff --git a/web/src/vite-env.d.ts b/web/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/web/src/vite-env.d.ts @@ -0,0 +1 @@ +///