diff --git a/Makefile b/Makefile index 1cbaa49..b65219b 100644 --- a/Makefile +++ b/Makefile @@ -4,7 +4,7 @@ SHELL := /bin/bash RPC_LOCAL := http://127.0.0.1:8545 ANVIL_OWNER := 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 -.PHONY: doctor setup verify deploy-v1 seed-v1 check-state test-finalize-manifest +.PHONY: doctor setup verify deploy-v1 seed-v1 check-state test-finalize-manifest sync-abis publish-web-manifest sync-artifacts sync-artifacts-check doctor: @./tools/doctor.sh setup: @@ -23,6 +23,15 @@ verify: @npm --prefix web run build test-finalize-manifest: @node tools/test-finalize-manifest.mjs +sync-abis: + @node tools/sync-web-artifacts.mjs +publish-web-manifest: + @node tools/publish-web-manifest.mjs +sync-artifacts: sync-abis publish-web-manifest +sync-artifacts-check: + @node tools/test-sync-web-artifacts.mjs + @node tools/sync-web-artifacts.mjs + @node tools/sync-web-artifacts.mjs --check deploy-v1: @node tools/finalize-manifest.mjs preflight-deploy anvil @SCRIPT_SENDER=$(ANVIL_OWNER) DEPLOYMENT_MANIFEST_PATH=deployments/pending.json npm_config_offline=true forge script script/DeployV1.s.sol:DeployV1 --rpc-url $(RPC_LOCAL) --sender $(ANVIL_OWNER) --broadcast --force diff --git a/tools/publish-web-manifest.mjs b/tools/publish-web-manifest.mjs new file mode 100644 index 0000000..b79ff5f --- /dev/null +++ b/tools/publish-web-manifest.mjs @@ -0,0 +1,71 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const requiredFields = ["schemaVersion", "network", "chainId", "deploymentBlock", "token", "proxy", "implementation", "owner", "actors"]; +const optionalFields = ["rpcUrl", "explorerBaseUrl"]; +const zeroAddress = "0x0000000000000000000000000000000000000000"; +const secretMarker = /(?:private[_ -]?key|mnemonic|secret|password|credential|api[_ -]?key)|0x[a-fA-F0-9]{64}/i; + +export async function publishManifest({ + activePath = resolve(repositoryRoot, "deployments/active.json"), + outputPath = resolve(repositoryRoot, "web/public/deployment.json"), +} = {}) { + let contents; + try { contents = await readFile(activePath, "utf8"); } catch { throw new Error(`active manifest is missing at ${activePath}`); } + let manifest; + try { manifest = JSON.parse(contents); } catch { throw new Error("active manifest contains invalid JSON"); } + validatePublicManifest(manifest); + await mkdir(dirname(outputPath), { recursive: true }); + await writeFile(outputPath, `${JSON.stringify(manifest, null, 2)}\n`); + return manifest; +} + +export function validatePublicManifest(manifest) { + if (!isRecord(manifest)) throw new Error("manifest must be an object"); + for (const field of requiredFields) if (!Object.hasOwn(manifest, field)) throw new Error(`manifest is missing required field ${field}`); + for (const field of Object.keys(manifest)) if (!requiredFields.includes(field) && !optionalFields.includes(field)) throw new Error(`manifest contains unknown field ${field}`); + rejectSecrets(manifest); + if (manifest.schemaVersion !== 1) throw new Error("manifest schemaVersion must be 1"); + if (manifest.network !== "anvil" && manifest.network !== "baseSepolia") throw new Error("manifest network is unsupported"); + if (manifest.chainId !== (manifest.network === "anvil" ? 31337 : 84532)) throw new Error("manifest chainId does not match network"); + if (!Number.isSafeInteger(manifest.deploymentBlock) || manifest.deploymentBlock < 1) throw new Error("manifest deploymentBlock must be at least 1"); + for (const field of ["token", "proxy", "implementation", "owner"]) assertAddress(manifest[field], field); + assertActors(manifest.actors); + for (const field of optionalFields) if (Object.hasOwn(manifest, field)) assertPublicUrl(manifest[field], field); +} + +function isRecord(value) { return typeof value === "object" && value !== null && !Array.isArray(value); } +function assertAddress(value, field) { + if (typeof value !== "string" || !/^0x[0-9a-fA-F]{40}$/.test(value) || value.toLowerCase() === zeroAddress) throw new Error(`manifest ${field} must be a nonzero address`); +} +function assertActors(value) { + if (!Array.isArray(value) || value.length === 0) throw new Error("manifest actors must be a nonempty array"); + const labels = new Set(); const addresses = new Set(); + for (const [index, actor] of value.entries()) { + if (!isRecord(actor) || Object.keys(actor).length !== 2 || !Object.hasOwn(actor, "label") || !Object.hasOwn(actor, "address")) throw new Error(`manifest actors[${index}] must contain label and address`); + if (typeof actor.label !== "string" || actor.label.trim() === "" || labels.has(actor.label)) throw new Error("manifest actor labels must be unique nonempty strings"); + assertAddress(actor.address, `actors[${index}].address`); + const normalized = actor.address.toLowerCase(); + if (addresses.has(normalized)) throw new Error("manifest actors must be unique"); + labels.add(actor.label); addresses.add(normalized); + } +} +function assertPublicUrl(value, field) { + if (typeof value !== "string") throw new Error(`manifest ${field} must be a public URL`); + let url; try { url = new URL(value); } catch { throw new Error(`manifest ${field} must be a public URL`); } + if ((url.protocol !== "http:" && url.protocol !== "https:") || url.username || url.password) throw new Error(`manifest ${field} must be a public URL without credentials`); + for (const key of url.searchParams.keys()) if (/(key|token|secret)/i.test(key)) throw new Error(`manifest ${field} must not contain credential query parameters`); +} +function rejectSecrets(value, path = "") { + if (typeof value === "string") { if (secretMarker.test(value)) throw new Error(`manifest contains prohibited secret material at ${path}`); return; } + if (Array.isArray(value)) { value.forEach((item, index) => rejectSecrets(item, `${path}[${index}]`)); return; } + if (!isRecord(value)) return; + for (const [key, nested] of Object.entries(value)) { rejectSecrets(key, path); rejectSecrets(nested, path ? `${path}.${key}` : key); } +} + +if (process.argv[1] && pathToFileURL(resolve(process.argv[1])).href === import.meta.url) { + if (process.argv.length !== 2) { console.error("usage: publish-web-manifest.mjs"); process.exitCode = 1; } + else publishManifest().catch((error) => { console.error(error.message); process.exitCode = 1; }); +} diff --git a/tools/sync-web-artifacts.mjs b/tools/sync-web-artifacts.mjs new file mode 100644 index 0000000..5cd871e --- /dev/null +++ b/tools/sync-web-artifacts.mjs @@ -0,0 +1,68 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const bankRequirements = { + functions: ["contractVersion", "paused", "asset", "owner", "totalLiabilities", "balanceOf"], + events: ["Deposited", "Withdrawn", "Paused", "Unpaused", "OwnershipTransferred", "Upgraded"], +}; +const tokenRequirements = { functions: ["balanceOf"], events: [] }; + +export function extractAbi(artifact, contractName) { + if (!artifact || typeof artifact !== "object" || !Array.isArray(artifact.abi)) { + throw new Error(`${contractName} artifact must contain an ABI`); + } + const requirements = contractName === "BankV1" ? bankRequirements : contractName === "MockUSDC" ? tokenRequirements : null; + if (!requirements) throw new Error(`unsupported contract artifact ${contractName}`); + for (const [type, names] of Object.entries(requirements)) { + for (const name of names) { + if (!artifact.abi.some((entry) => entry && entry.type === type.slice(0, -1) && entry.name === name)) { + throw new Error(`${contractName} ABI is missing required ${type.slice(0, -1)} ${name}`); + } + } + } + return artifact.abi; +} + +export function renderContractsModule(bankV1Abi, mockUsdcAbi) { + return `/* This file is generated by tools/sync-web-artifacts.mjs. Do not edit. */\n\nexport const bankV1Abi = ${JSON.stringify(bankV1Abi, null, 2)} as const;\n\nexport const mockUsdcAbi = ${JSON.stringify(mockUsdcAbi, null, 2)} as const;\n`; +} + +export async function syncArtifacts({ + bankArtifactPath = resolve(repositoryRoot, "out/BankV1.sol/BankV1.json"), + tokenArtifactPath = resolve(repositoryRoot, "out/MockUSDC.sol/MockUSDC.json"), + outputPath = resolve(repositoryRoot, "web/src/generated/contracts.ts"), + check = false, +} = {}) { + const [bankArtifact, tokenArtifact] = await Promise.all([ + readArtifact(bankArtifactPath, "BankV1"), + readArtifact(tokenArtifactPath, "MockUSDC"), + ]); + const contents = renderContractsModule(extractAbi(bankArtifact, "BankV1"), extractAbi(tokenArtifact, "MockUSDC")); + if (check) { + let existing; + try { existing = await readFile(outputPath, "utf8"); } catch { throw new Error("generated contracts module is stale or missing"); } + if (existing !== contents) throw new Error("generated contracts module is stale"); + return contents; + } + await mkdir(dirname(outputPath), { recursive: true }); + await writeFile(outputPath, contents); + return contents; +} + +async function readArtifact(path, contractName) { + let contents; + try { contents = await readFile(path, "utf8"); } catch { throw new Error(`${contractName} artifact is missing at ${path}`); } + try { return JSON.parse(contents); } catch { throw new Error(`${contractName} artifact contains invalid JSON`); } +} + +if (process.argv[1] && pathToFileURL(resolve(process.argv[1])).href === import.meta.url) { + const check = process.argv.slice(2).every((argument) => argument === "--check") && process.argv.includes("--check"); + if (process.argv.slice(2).some((argument) => argument !== "--check")) { + console.error("usage: sync-web-artifacts.mjs [--check]"); + process.exitCode = 1; + } else { + syncArtifacts({ check }).catch((error) => { console.error(error.message); process.exitCode = 1; }); + } +} diff --git a/tools/test-sync-web-artifacts.mjs b/tools/test-sync-web-artifacts.mjs new file mode 100644 index 0000000..16ee7dd --- /dev/null +++ b/tools/test-sync-web-artifacts.mjs @@ -0,0 +1,76 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { extractAbi, renderContractsModule, syncArtifacts } from "./sync-web-artifacts.mjs"; +import { publishManifest } from "./publish-web-manifest.mjs"; + +const address = "0x5FbDB2315678afecb367f032d93F642f64180aa3"; +const owner = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"; +const bankAbi = [ + ...["contractVersion", "paused", "asset", "owner", "totalLiabilities", "balanceOf"].map((name) => ({ type: "function", name, inputs: [], outputs: [], stateMutability: "view" })), + ...["Deposited", "Withdrawn", "Paused", "Unpaused", "OwnershipTransferred", "Upgraded"].map((name) => ({ type: "event", name, inputs: [], anonymous: false })), +]; +const tokenAbi = [{ type: "function", name: "balanceOf", inputs: [{ name: "account", type: "address" }], outputs: [{ type: "uint256" }], stateMutability: "view" }]; +const manifest = { + schemaVersion: 1, network: "anvil", chainId: 31337, deploymentBlock: 3, + rpcUrl: "http://127.0.0.1:8545", token: address, proxy: address, + implementation: "0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512", owner, + actors: [{ label: "owner", address: owner }], +}; + +async function withFixture(run) { + const root = await mkdtemp(join(tmpdir(), "uups-artifacts-")); + try { await run(root); } finally { await rm(root, { recursive: true, force: true }); } +} + +async function expectRejects(action, pattern) { + await assert.rejects(action, pattern); +} + +await withFixture(async (root) => { + const bank = join(root, "BankV1.json"); + const token = join(root, "MockUSDC.json"); + const output = join(root, "contracts.ts"); + + // Catches a production bridge that silently produces an ABI module from incomplete artifacts. + await expectRejects(() => syncArtifacts({ bankArtifactPath: bank, tokenArtifactPath: token, outputPath: output }), /BankV1 artifact/i); + await writeFile(bank, JSON.stringify({ abi: bankAbi })); + await expectRejects(() => syncArtifacts({ bankArtifactPath: bank, tokenArtifactPath: token, outputPath: output }), /MockUSDC artifact/i); + await writeFile(token, JSON.stringify({ abi: tokenAbi })); + + // Catches a bridge that exports an ABI missing a V1 contract function or event. + assert.throws(() => extractAbi({ abi: bankAbi.filter((entry) => entry.name !== "Withdrawn") }, "BankV1"), /Withdrawn/); + const rendered = renderContractsModule(extractAbi({ abi: bankAbi }, "BankV1"), extractAbi({ abi: tokenAbi }, "MockUSDC")); + assert.match(rendered, /export const bankV1Abi = .* as const;/s); + assert.match(rendered, /export const mockUsdcAbi = .* as const;/s); + assert.doesNotMatch(rendered, /bankV2Abi|BankV2/); + + // Catches a bridge that requires an active manifest or reads V2 as part of ABI generation. + await syncArtifacts({ bankArtifactPath: bank, tokenArtifactPath: token, outputPath: output }); + assert.equal(await readFile(output, "utf8"), rendered); + await writeFile(output, "stale\n"); + await expectRejects(() => syncArtifacts({ bankArtifactPath: bank, tokenArtifactPath: token, outputPath: output, check: true }), /stale/i); +}); + +await withFixture(async (root) => { + const activePath = join(root, "active.json"); + const outputPath = join(root, "deployment.json"); + await writeFile(activePath, JSON.stringify(manifest)); + + // Catches a publisher that copies pending, secret-bearing, or malformed live state into the browser bundle. + await publishManifest({ activePath, outputPath }); + assert.deepEqual(JSON.parse(await readFile(outputPath, "utf8")), manifest); + for (const invalid of [ + { ...manifest, deploymentBlock: 0 }, + { ...manifest, rpcUrl: "https://token@example.test" }, + { ...manifest, rpcUrl: "https://example.test/?secret=value" }, + { ...manifest, privateKey: "not-public" }, + { ...manifest, proxy: "0x0000000000000000000000000000000000000000" }, + ]) { + await writeFile(activePath, JSON.stringify(invalid)); + await expectRejects(() => publishManifest({ activePath, outputPath }), /manifest|deployment|credential|secret|address/i); + } +}); + +console.log("artifact bridge tests passed"); diff --git a/web/public/.gitkeep b/web/public/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/web/public/.gitkeep @@ -0,0 +1 @@ + diff --git a/web/src/config/chains.ts b/web/src/config/chains.ts new file mode 100644 index 0000000..e3a059f --- /dev/null +++ b/web/src/config/chains.ts @@ -0,0 +1,18 @@ +import { createPublicClient, defineChain, http } from "viem"; +import { baseSepolia } from "viem/chains"; +import type { DeploymentManifest } from "../types/dashboard"; + +export const anvil = defineChain({ + id: 31337, + name: "Anvil", + nativeCurrency: { name: "Ether", symbol: "ETH", decimals: 18 }, + rpcUrls: { default: { http: ["http://127.0.0.1:8545"] } }, +}); + +export const supportedChains = [anvil, baseSepolia] as const; + +export function createManifestPublicClient(manifest: DeploymentManifest, viteRpcUrl = (import.meta as ImportMeta & { env?: { VITE_RPC_URL?: string } }).env?.VITE_RPC_URL) { + const rpcUrl = manifest.rpcUrl ?? viteRpcUrl; + if (!rpcUrl) throw new Error("deployment manifest has no RPC URL and VITE_RPC_URL is not configured"); + return createPublicClient({ chain: manifest.chainId === anvil.id ? anvil : baseSepolia, transport: http(rpcUrl) }); +} diff --git a/web/src/config/manifest.test.ts b/web/src/config/manifest.test.ts new file mode 100644 index 0000000..58fee32 --- /dev/null +++ b/web/src/config/manifest.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; +import { parseDeploymentManifest } from "./manifest"; + +const owner = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"; +const alice = "0x70997970C51812dc3A010C7d01b50e0d17dc79C8"; +const baseManifest = { + schemaVersion: 1, + network: "anvil", + chainId: 31337, + deploymentBlock: 3, + rpcUrl: "http://127.0.0.1:8545", + token: "0x5FbDB2315678afecb367f032d93F642f64180aa3", + proxy: "0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0", + implementation: "0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512", + owner, + actors: [{ label: "owner", address: owner }, { label: "Alice", address: alice }], +}; + +describe("parseDeploymentManifest", () => { + it("normalizes a valid local deployment without inventing optional URLs", () => { + const manifest = parseDeploymentManifest({ ...baseManifest, rpcUrl: undefined }); + + expect(manifest).toMatchObject({ + schemaVersion: 1, + network: "anvil", + chainId: 31337, + deploymentBlock: 3n, + token: "0x5FbDB2315678afecb367f032d93F642f64180aa3", + }); + expect(manifest.rpcUrl).toBeUndefined(); + }); + + it("accepts a valid public Base Sepolia deployment", () => { + const manifest = parseDeploymentManifest({ + ...baseManifest, + network: "baseSepolia", + chainId: 84532, + rpcUrl: "https://sepolia.base.org", + explorerBaseUrl: "https://sepolia.basescan.org", + }); + + expect(manifest).toMatchObject({ network: "baseSepolia", chainId: 84532, deploymentBlock: 3n }); + }); + + it.each([ + ["malformed JSON-shaped input", "not an object"], + ["schema mismatch", { ...baseManifest, schemaVersion: 2 }], + ["chain and network mismatch", { ...baseManifest, chainId: 84532 }], + ["invalid address", { ...baseManifest, token: "not-an-address" }], + ["zero contract address", { ...baseManifest, proxy: "0x0000000000000000000000000000000000000000" }], + ["empty actor label", { ...baseManifest, actors: [{ label: "", address: owner }] }], + ["duplicate actor", { ...baseManifest, actors: [{ label: "owner", address: owner }, { label: "copy", address: owner }] }], + ["deployment block below one", { ...baseManifest, deploymentBlock: 0 }], + ["RPC user info", { ...baseManifest, rpcUrl: "https://key@example.test" }], + ["RPC key query", { ...baseManifest, rpcUrl: "https://example.test/?key=value" }], + ["RPC token query", { ...baseManifest, rpcUrl: "https://example.test/?token=value" }], + ["RPC secret query", { ...baseManifest, rpcUrl: "https://example.test/?secret=value" }], + ])("rejects %s instead of accepting unsafe deployment state", (_name, input) => { + expect(() => parseDeploymentManifest(input)).toThrow(/manifest|address|actor|deployment|rpc/i); + }); +}); diff --git a/web/src/config/manifest.ts b/web/src/config/manifest.ts new file mode 100644 index 0000000..50d70b3 --- /dev/null +++ b/web/src/config/manifest.ts @@ -0,0 +1,101 @@ +import { getAddress, isAddress, type Address } from "viem"; +import type { DeploymentManifest } from "../types/dashboard"; + +const requiredFields = [ + "schemaVersion", "network", "chainId", "deploymentBlock", "token", "proxy", "implementation", "owner", "actors", +] as const; +const optionalFields = ["rpcUrl", "explorerBaseUrl"] as const; +const zeroAddress = "0x0000000000000000000000000000000000000000"; + +export function parseDeploymentManifest(value: unknown): DeploymentManifest { + if (!isRecord(value)) throw new Error("manifest must be an object"); + assertSchema(value); + + if (value.schemaVersion !== 1) throw new Error("manifest schemaVersion must be 1"); + if (value.network !== "anvil" && value.network !== "baseSepolia") throw new Error("manifest network is unsupported"); + const network = value.network; + const chainId = value.chainId; + const deploymentBlock = value.deploymentBlock; + if (typeof chainId !== "number" || !Number.isSafeInteger(chainId) || (network === "anvil" ? chainId !== 31337 : chainId !== 84532)) { + throw new Error("manifest chainId does not match network"); + } + if (typeof deploymentBlock !== "number" || !Number.isSafeInteger(deploymentBlock) || deploymentBlock < 1) { + throw new Error("manifest deploymentBlock must be at least 1"); + } + + const rpcUrl = optionalUrl(value, "rpcUrl"); + const explorerBaseUrl = optionalUrl(value, "explorerBaseUrl"); + return { + schemaVersion: 1, + network, + chainId: chainId as 31337 | 84532, + deploymentBlock: BigInt(deploymentBlock), + ...(rpcUrl === undefined ? {} : { rpcUrl }), + ...(explorerBaseUrl === undefined ? {} : { explorerBaseUrl }), + token: parseAddress(value.token, "token"), + proxy: parseAddress(value.proxy, "proxy"), + implementation: parseAddress(value.implementation, "implementation"), + owner: parseAddress(value.owner, "owner"), + actors: parseActors(value.actors), + }; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function assertSchema(manifest: Record): void { + for (const field of requiredFields) { + if (!Object.hasOwn(manifest, field)) throw new Error(`manifest is missing required field ${field}`); + } + for (const field of Object.keys(manifest)) { + if (!(requiredFields as readonly string[]).includes(field) && !(optionalFields as readonly string[]).includes(field)) { + throw new Error(`manifest contains unknown field ${field}`); + } + } +} + +function parseAddress(value: unknown, field: string): Address { + if (typeof value !== "string" || !isAddress(value) || value.toLowerCase() === zeroAddress) { + throw new Error(`manifest ${field} must be a nonzero address`); + } + return getAddress(value); +} + +function parseActors(value: unknown): readonly Readonly<{ label: string; address: Address }>[] { + if (!Array.isArray(value) || value.length === 0) throw new Error("manifest actors must be a nonempty array"); + const labels = new Set(); + const addresses = new Set(); + return value.map((actor, index) => { + if (!isRecord(actor) || Object.keys(actor).length !== 2 || !Object.hasOwn(actor, "label") || !Object.hasOwn(actor, "address")) { + throw new Error(`manifest actors[${index}] must contain label and address`); + } + if (typeof actor.label !== "string" || actor.label.trim() === "" || labels.has(actor.label)) { + throw new Error("manifest actor labels must be unique nonempty strings"); + } + const address = parseAddress(actor.address, `actors[${index}].address`); + if (addresses.has(address.toLowerCase())) throw new Error("manifest actors must be unique"); + labels.add(actor.label); + addresses.add(address.toLowerCase()); + return { label: actor.label, address }; + }); +} + +function optionalUrl(manifest: Record, field: "rpcUrl" | "explorerBaseUrl"): string | undefined { + const value = manifest[field]; + if (value === undefined) return undefined; + if (typeof value !== "string") throw new Error(`manifest ${field} must be a public URL`); + let url: URL; + try { + url = new URL(value); + } catch { + throw new Error(`manifest ${field} must be a public URL`); + } + if ((url.protocol !== "http:" && url.protocol !== "https:") || url.username || url.password) { + throw new Error(`manifest ${field} must be a public URL without credentials`); + } + for (const key of url.searchParams.keys()) { + if (/(key|token|secret)/i.test(key)) throw new Error(`manifest ${field} must not contain credential query parameters`); + } + return value; +} diff --git a/web/src/data/bankClient.test.ts b/web/src/data/bankClient.test.ts new file mode 100644 index 0000000..101805e --- /dev/null +++ b/web/src/data/bankClient.test.ts @@ -0,0 +1,143 @@ +import { encodeAbiParameters, encodeEventTopics, parseAbiParameters, type Address, type Hex } from "viem"; +import { describe, expect, it } from "vitest"; +import { bankV1Abi } from "../generated/contracts"; +import type { DeploymentManifest } from "../types/dashboard"; +import { EIP1967_IMPLEMENTATION_SLOT, loadDashboardSnapshot, type BankReader } from "./bankClient"; + +const token = "0x5FbDB2315678afecb367f032d93F642f64180aa3" as const; +const proxy = "0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0" as const; +const implementation = "0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512" as const; +const owner = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266" as const; +const alice = "0x70997970C51812dc3A010C7d01b50e0d17dc79C8" as const; +const manifest: DeploymentManifest = { schemaVersion: 1, network: "anvil", chainId: 31337, deploymentBlock: 3n, token, proxy, implementation, owner, actors: [{ label: "owner", address: owner }, { label: "Alice", address: alice }] }; +const wordFor = (address: Address): Hex => `0x${"0".repeat(24)}${address.slice(2)}` as Hex; + +class ReaderDouble implements BankReader { + readonly operations: string[] = []; + readonly contractCalls: Array<{ address: Address; functionName: string; args?: readonly unknown[]; blockNumber: bigint }> = []; + readonly storageCalls: Array<{ address: Address; slot: Hex; blockNumber: bigint }> = []; + readonly logCalls: Array<{ address: Address; fromBlock: bigint; toBlock: bigint }> = []; + chainId = 31337; + blockNumber = 12n; + implementation: Address = implementation; + asset: Address = token; + bankOwner: Address = owner; + reserves = 150n; + liabilities = 100n; + actorBalances = new Map([[owner, 70n], [alice, 30n]]); + logs: readonly unknown[] = []; + error?: Error; + + async getChainId() { this.operations.push("chain"); return this.chainId; } + async getCode({ address }: { address: Address }) { this.operations.push(`code:${address}`); return "0x6000" as Hex; } + async getBlockNumber() { this.operations.push("block"); return this.blockNumber; } + async readContract(call: { address: Address; functionName: string; args?: readonly unknown[]; blockNumber: bigint }) { + this.operations.push(`read:${call.functionName}`); this.contractCalls.push(call); + if (this.error) throw this.error; + if (call.functionName === "contractVersion") return 1n; + if (call.functionName === "paused") return false; + if (call.functionName === "asset") return this.asset; + if (call.functionName === "owner") return this.bankOwner; + if (call.functionName === "totalLiabilities") return this.liabilities; + if (call.functionName === "balanceOf") return call.address === token ? this.reserves : this.actorBalances.get(call.args?.[0] as Address) ?? 0n; + throw new Error(`unexpected function ${call.functionName}`); + } + async getStorageAt(call: { address: Address; slot: Hex; blockNumber: bigint }) { this.operations.push("storage"); this.storageCalls.push(call); return wordFor(this.implementation); } + async getLogs(call: { address: Address; fromBlock: bigint; toBlock: bigint }) { this.operations.push("logs"); this.logCalls.push(call); return this.logs; } +} + +function depositedLog(blockNumber: bigint, logIndex: number, account: Address, amount: bigint) { + return { + address: proxy, + blockNumber, + logIndex, + transactionHash: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + topics: encodeEventTopics({ abi: bankV1Abi, eventName: "Deposited", args: { account } }), + data: encodeAbiParameters(parseAbiParameters("uint256"), [amount]), + }; +} + +describe("loadDashboardSnapshot", () => { + it("pins every state and activity read to one latest block", async () => { + // Catches a dashboard that mixes different blocks into one accounting snapshot. + const reader = new ReaderDouble(); + const snapshot = await loadDashboardSnapshot(reader, manifest); + + expect(snapshot).toMatchObject({ blockNumber: 12n, version: 1, reserves: 150n, liabilities: 100n, surplus: 50n }); + expect(reader.contractCalls.every((call) => call.blockNumber === 12n)).toBe(true); + expect(reader.storageCalls).toEqual([{ address: proxy, slot: EIP1967_IMPLEMENTATION_SLOT, blockNumber: 12n }]); + expect(reader.logCalls).toEqual([{ address: proxy, fromBlock: 3n, toBlock: 12n }]); + }); + + it("checks the endpoint chain and contract bytecode before contract state", async () => { + // Catches reads against a wrong endpoint or empty deployment addresses. + const reader = new ReaderDouble(); + await loadDashboardSnapshot(reader, manifest); + + expect(reader.operations.slice(0, 5)).toEqual([`chain`, `code:${proxy}`, `code:${token}`, `code:${implementation}`, `block`]); + }); + + it("rejects a chain that differs from the active manifest before reading contracts", async () => { + // Catches state being displayed for a different network. + const reader = new ReaderDouble(); reader.chainId = 84532; + await expect(loadDashboardSnapshot(reader, manifest)).rejects.toThrow(/chain/i); + expect(reader.contractCalls).toHaveLength(0); + }); + + it("reads the V1 state from the proxy and token reserve from MockUSDC", async () => { + // Catches bank calls directed at the implementation or native-balance reserve accounting. + const reader = new ReaderDouble(); + const snapshot = await loadDashboardSnapshot(reader, manifest); + + expect(reader.contractCalls.filter((call) => call.functionName !== "balanceOf").every((call) => call.address === proxy)).toBe(true); + expect(reader.contractCalls.find((call) => call.address === token && call.functionName === "balanceOf")?.args).toEqual([proxy]); + expect(snapshot.actors).toEqual([{ label: "owner", address: owner, balance: 70n }, { label: "Alice", address: alice, balance: 30n }]); + }); + + it("rejects a mismatched implementation slot, asset, or owner", async () => { + // Catches a manifest whose identity no longer describes the deployed proxy. + for (const mutate of [ + (reader: ReaderDouble) => { reader.implementation = token; }, + (reader: ReaderDouble) => { reader.asset = implementation; }, + (reader: ReaderDouble) => { reader.bankOwner = alice; }, + ]) { + const reader = new ReaderDouble(); mutate(reader); + await expect(loadDashboardSnapshot(reader, manifest)).rejects.toThrow(/implementation|asset|owner/i); + } + }); + + it("rejects insolvency instead of fabricating a negative surplus", async () => { + // Catches under-collateralized state being presented as a valid bigint surplus. + const reader = new ReaderDouble(); reader.reserves = 99n; + await expect(loadDashboardSnapshot(reader, manifest)).rejects.toThrow(/insolvent/i); + }); + + it("decodes only proxy V1 events in deterministic block and log order", async () => { + // Catches V2/foreign activity or nondeterministic activity ordering in the dashboard. + const reader = new ReaderDouble(); + reader.logs = [depositedLog(5n, 4, alice, 9n), depositedLog(4n, 8, owner, 7n)]; + const snapshot = await loadDashboardSnapshot(reader, manifest); + + expect(snapshot.activity).toEqual([ + expect.objectContaining({ kind: "deposit", blockNumber: 4n, logIndex: 8, account: owner, amount: 7n }), + expect.objectContaining({ kind: "deposit", blockNumber: 5n, logIndex: 4, account: alice, amount: 9n }), + ]); + expect(snapshot.diagnostics).toEqual([]); + }); + + it("keeps valid activity when one proxy log cannot be decoded", async () => { + // Catches a single malformed RPC log blanking the entire timeline. + const reader = new ReaderDouble(); + reader.logs = [depositedLog(4n, 1, owner, 7n), { ...depositedLog(4n, 2, alice, 9n), data: "0x1234" }]; + const snapshot = await loadDashboardSnapshot(reader, manifest); + + expect(snapshot.activity).toHaveLength(1); + expect(snapshot.diagnostics).toEqual([expect.objectContaining({ blockNumber: 4n, logIndex: 2, message: expect.any(String) })]); + }); + + it("preserves a rejected contract read instead of substituting zero", async () => { + // Catches disconnected RPC reads being silently rendered as zero balances. + const reader = new ReaderDouble(); reader.error = new Error("node read failed"); + await expect(loadDashboardSnapshot(reader, manifest)).rejects.toThrow("node read failed"); + }); +}); diff --git a/web/src/data/bankClient.ts b/web/src/data/bankClient.ts new file mode 100644 index 0000000..df07d4b --- /dev/null +++ b/web/src/data/bankClient.ts @@ -0,0 +1,145 @@ +import { decodeEventLog, getAddress, isAddress, type Abi, type Address, type Hex, type PublicClient } from "viem"; +import { bankV1Abi, mockUsdcAbi } from "../generated/contracts"; +import type { Activity, DashboardSnapshot, DecodeDiagnostic, DeploymentManifest } from "../types/dashboard"; + +export const EIP1967_IMPLEMENTATION_SLOT = + "0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc"; + +type ContractCall = Readonly<{ address: Address; abi: Abi; functionName: string; args?: readonly unknown[]; blockNumber: bigint }>; +type StorageCall = Readonly<{ address: Address; slot: Hex; blockNumber: bigint }>; +type LogCall = Readonly<{ address: Address; fromBlock: bigint; toBlock: bigint }>; + +export interface BankReader { + getChainId(): Promise; + getCode(call: Readonly<{ address: Address }>): Promise; + getBlockNumber(): Promise; + readContract(call: ContractCall): Promise; + getStorageAt(call: StorageCall): Promise; + getLogs(call: LogCall): Promise; +} + +export class ViemBankReader implements BankReader { + constructor(private readonly client: PublicClient) {} + + getChainId(): Promise { return this.client.getChainId(); } + getCode({ address }: Readonly<{ address: Address }>): Promise { return this.client.getCode({ address }); } + getBlockNumber(): Promise { return this.client.getBlockNumber(); } + readContract({ address, abi, functionName, args, blockNumber }: ContractCall): Promise { + return this.client.readContract({ address, abi, functionName, args, blockNumber } as never); + } + getStorageAt({ address, slot, blockNumber }: StorageCall): Promise { return this.client.getStorageAt({ address, slot, blockNumber }); } + getLogs({ address, fromBlock, toBlock }: LogCall): Promise { return this.client.getLogs({ address, fromBlock, toBlock }); } +} + +export async function loadDashboardSnapshot(reader: BankReader, manifest: DeploymentManifest): Promise { + const chainId = await reader.getChainId(); + if (chainId !== manifest.chainId) throw new Error(`endpoint chain ID ${chainId} does not match manifest chain ID ${manifest.chainId}`); + for (const [label, address] of [["proxy", manifest.proxy], ["token", manifest.token], ["implementation", manifest.implementation]] as const) { + const code = await reader.getCode({ address }); + if (!code || code === "0x") throw new Error(`${label} has no bytecode`); + } + + const blockNumber = await reader.getBlockNumber(); + const [version, paused, asset, owner, liabilities, reserves, implementationSlot, actorBalances, logs] = await Promise.all([ + read(reader, manifest.proxy, bankV1Abi, "contractVersion", blockNumber), + read(reader, manifest.proxy, bankV1Abi, "paused", blockNumber), + read(reader, manifest.proxy, bankV1Abi, "asset", blockNumber), + read(reader, manifest.proxy, bankV1Abi, "owner", blockNumber), + read(reader, manifest.proxy, bankV1Abi, "totalLiabilities", blockNumber), + read(reader, manifest.token, mockUsdcAbi, "balanceOf", blockNumber, [manifest.proxy]), + reader.getStorageAt({ address: manifest.proxy, slot: EIP1967_IMPLEMENTATION_SLOT, blockNumber }), + Promise.all(manifest.actors.map(async (actor) => ({ ...actor, balance: asBigint(await read(reader, manifest.proxy, bankV1Abi, "balanceOf", blockNumber, [actor.address]), "actor balance") }))), + reader.getLogs({ address: manifest.proxy, fromBlock: manifest.deploymentBlock, toBlock: blockNumber }), + ]); + + if (version !== 1n) throw new Error("proxy is not running BankV1"); + if (typeof paused !== "boolean") throw new Error("proxy pause state is invalid"); + if (sameAddress(asAddress(asset, "asset"), manifest.token) === false) throw new Error("proxy asset does not match manifest token"); + if (sameAddress(asAddress(owner, "owner"), manifest.owner) === false) throw new Error("proxy owner does not match manifest owner"); + const implementation = implementationFromSlot(implementationSlot); + if (!sameAddress(implementation, manifest.implementation)) throw new Error("proxy implementation slot does not match manifest implementation"); + const resolvedLiabilities = asBigint(liabilities, "liabilities"); + const resolvedReserves = asBigint(reserves, "reserves"); + if (resolvedReserves < resolvedLiabilities) throw new Error("proxy is insolvent: reserves are below liabilities"); + + const { activity, diagnostics } = decodeActivity(logs, manifest.proxy); + return { + blockNumber, + synchronizedAt: new Date(), + version: 1, + paused, + asset: manifest.token, + owner: manifest.owner, + proxy: manifest.proxy, + implementation, + reserves: resolvedReserves, + liabilities: resolvedLiabilities, + surplus: resolvedReserves - resolvedLiabilities, + actors: actorBalances, + activity, + diagnostics, + }; +} + +function read(reader: BankReader, address: Address, abi: Abi, functionName: string, blockNumber: bigint, args?: readonly unknown[]): Promise { + return reader.readContract({ address, abi, functionName, ...(args === undefined ? {} : { args }), blockNumber }); +} + +function asBigint(value: unknown, label: string): bigint { + if (typeof value !== "bigint") throw new Error(`${label} read is invalid`); + return value; +} + +function asAddress(value: unknown, label: string): Address { + if (typeof value !== "string" || !isAddress(value)) throw new Error(`${label} read is invalid`); + return getAddress(value); +} + +function sameAddress(first: Address, second: Address): boolean { return first.toLowerCase() === second.toLowerCase(); } + +function implementationFromSlot(value: Hex | undefined): Address { + if (typeof value !== "string" || !/^0x[0-9a-fA-F]{64}$/.test(value)) throw new Error("proxy implementation slot is invalid"); + return asAddress(`0x${value.slice(-40)}`, "proxy implementation slot"); +} + +function decodeActivity(logs: readonly unknown[], proxy: Address): { activity: readonly Activity[]; diagnostics: readonly DecodeDiagnostic[] } { + const activity: Activity[] = []; + const diagnostics: DecodeDiagnostic[] = []; + for (const log of logs) { + if (!isLog(log) || !sameAddress(log.address, proxy)) continue; + const identity = { blockNumber: log.blockNumber, logIndex: log.logIndex, transactionHash: log.transactionHash }; + try { + const decoded = decodeEventLog({ abi: bankV1Abi, data: log.data, topics: log.topics as [Hex, ...Hex[]] }); + const next = decodedActivity(decoded.eventName, decoded.args, identity); + if (next) activity.push(next); + } catch { + diagnostics.push({ ...identity, message: "Could not decode a proxy event log." }); + } + } + activity.sort((first, second) => first.blockNumber === second.blockNumber ? first.logIndex - second.logIndex : first.blockNumber < second.blockNumber ? -1 : 1); + diagnostics.sort((first, second) => first.blockNumber === second.blockNumber ? first.logIndex - second.logIndex : first.blockNumber < second.blockNumber ? -1 : 1); + return { activity, diagnostics }; +} + +function isLog(value: unknown): value is Readonly<{ address: Address; blockNumber: bigint; logIndex: number; transactionHash: Hex; data: Hex; topics: readonly Hex[] }> { + return typeof value === "object" && value !== null + && typeof (value as { address?: unknown }).address === "string" && isAddress((value as { address: string }).address) + && typeof (value as { blockNumber?: unknown }).blockNumber === "bigint" + && typeof (value as { logIndex?: unknown }).logIndex === "number" + && typeof (value as { transactionHash?: unknown }).transactionHash === "string" + && typeof (value as { data?: unknown }).data === "string" + && Array.isArray((value as { topics?: unknown }).topics); +} + +function decodedActivity(eventName: string, args: unknown, identity: Readonly<{ blockNumber: bigint; logIndex: number; transactionHash: Hex }>): Activity | undefined { + const values = args as Record; + switch (eventName) { + case "Deposited": return { ...identity, kind: "deposit", account: asAddress(values.account, "deposit account"), amount: asBigint(values.amount, "deposit amount") }; + case "Withdrawn": return { ...identity, kind: "withdrawal", account: asAddress(values.account, "withdrawal account"), amount: asBigint(values.amount, "withdrawal amount") }; + case "Paused": return { ...identity, kind: "paused", account: asAddress(values.account, "pause account") }; + case "Unpaused": return { ...identity, kind: "unpaused", account: asAddress(values.account, "unpause account") }; + case "OwnershipTransferred": return { ...identity, kind: "ownershipTransferred", previousOwner: asAddress(values.previousOwner, "previous owner"), newOwner: asAddress(values.newOwner, "new owner") }; + case "Upgraded": return { ...identity, kind: "upgraded", implementation: asAddress(values.implementation, "upgraded implementation") }; + default: return undefined; + } +} diff --git a/web/src/generated/.gitkeep b/web/src/generated/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/web/src/generated/.gitkeep @@ -0,0 +1 @@ + diff --git a/web/src/types/dashboard.ts b/web/src/types/dashboard.ts new file mode 100644 index 0000000..928ee19 --- /dev/null +++ b/web/src/types/dashboard.ts @@ -0,0 +1,54 @@ +import type { Address, Hex } from "viem"; + +export type DeploymentManifest = Readonly<{ + schemaVersion: 1; + network: "anvil" | "baseSepolia"; + chainId: 31337 | 84532; + deploymentBlock: bigint; + rpcUrl?: string; + explorerBaseUrl?: string; + token: Address; + proxy: Address; + implementation: Address; + owner: Address; + actors: readonly Readonly<{ label: string; address: Address }>[]; +}>; + +export type ActorBalance = Readonly<{ + label: string; + address: Address; + balance: bigint; +}>; + +type ActivityIdentity = Readonly<{ + blockNumber: bigint; + logIndex: number; + transactionHash: Hex; +}>; + +export type Activity = + | Readonly + | Readonly + | Readonly + | Readonly + | Readonly + | Readonly; + +export type DecodeDiagnostic = Readonly; + +export type DashboardSnapshot = Readonly<{ + blockNumber: bigint; + synchronizedAt: Date; + version: 1; + paused: boolean; + asset: Address; + owner: Address; + proxy: Address; + implementation: Address; + reserves: bigint; + liabilities: bigint; + surplus: bigint; + actors: readonly ActorBalance[]; + activity: readonly Activity[]; + diagnostics: readonly DecodeDiagnostic[]; +}>;