diff --git a/tools/sync-web-artifacts.mjs b/tools/sync-web-artifacts.mjs index 5cd871e..912e686 100644 --- a/tools/sync-web-artifacts.mjs +++ b/tools/sync-web-artifacts.mjs @@ -3,11 +3,21 @@ 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: [] }; +const bankRequirements = [ + functionSignature("contractVersion", [], ["uint256"], "pure"), + functionSignature("paused", [], ["bool"], "view"), + functionSignature("asset", [], ["address"], "view"), + functionSignature("owner", [], ["address"], "view"), + functionSignature("totalLiabilities", [], ["uint256"], "view"), + functionSignature("balanceOf", [parameter("account", "address")], ["uint256"], "view"), + eventSignature("Deposited", [parameter("account", "address", true), parameter("amount", "uint256", false)]), + eventSignature("Withdrawn", [parameter("account", "address", true), parameter("amount", "uint256", false)]), + eventSignature("Paused", [parameter("account", "address", false)]), + eventSignature("Unpaused", [parameter("account", "address", false)]), + eventSignature("OwnershipTransferred", [parameter("previousOwner", "address", true), parameter("newOwner", "address", true)]), + eventSignature("Upgraded", [parameter("implementation", "address", true)]), +]; +const tokenRequirements = [functionSignature("balanceOf", [parameter("account", "address")], ["uint256"], "view")]; export function extractAbi(artifact, contractName) { if (!artifact || typeof artifact !== "object" || !Array.isArray(artifact.abi)) { @@ -15,16 +25,34 @@ export function extractAbi(artifact, contractName) { } 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}`); - } + for (const requirement of requirements) { + const entries = artifact.abi.filter((entry) => entry && entry.type === requirement.type && entry.name === requirement.name); + if (entries.length === 0) throw new Error(`${contractName} ABI is missing required ${requirement.type} ${requirement.name}`); + if (!entries.some((entry) => matchesSignature(entry, requirement))) { + throw new Error(`${contractName} ABI has an invalid signature for ${requirement.name}`); } } return artifact.abi; } +function parameter(name, type, indexed) { return { name, type, ...(indexed === undefined ? {} : { indexed }) }; } +function functionSignature(name, inputs, outputs, stateMutability) { + return { type: "function", name, inputs, outputs: outputs.map((type) => parameter("", type)), stateMutability }; +} +function eventSignature(name, inputs) { return { type: "event", name, inputs, anonymous: false }; } +function matchesSignature(entry, requirement) { + return entry.stateMutability === requirement.stateMutability + && entry.anonymous === requirement.anonymous + && matchesParameters(entry.inputs, requirement.inputs) + && (requirement.outputs === undefined || matchesParameters(entry.outputs, requirement.outputs)); +} +function matchesParameters(actual, expected) { + return Array.isArray(actual) && actual.length === expected.length && actual.every((parameter, index) => { + const required = expected[index]; + return parameter && parameter.name === required.name && parameter.type === required.type && parameter.indexed === required.indexed; + }); +} + 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`; } diff --git a/tools/test-sync-web-artifacts.mjs b/tools/test-sync-web-artifacts.mjs index 16ee7dd..a5fdc4a 100644 --- a/tools/test-sync-web-artifacts.mjs +++ b/tools/test-sync-web-artifacts.mjs @@ -8,10 +8,20 @@ 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 })), + { type: "function", name: "contractVersion", inputs: [], outputs: [{ name: "", type: "uint256" }], stateMutability: "pure" }, + { type: "function", name: "paused", inputs: [], outputs: [{ name: "", type: "bool" }], stateMutability: "view" }, + { type: "function", name: "asset", inputs: [], outputs: [{ name: "", type: "address" }], stateMutability: "view" }, + { type: "function", name: "owner", inputs: [], outputs: [{ name: "", type: "address" }], stateMutability: "view" }, + { type: "function", name: "totalLiabilities", inputs: [], outputs: [{ name: "", type: "uint256" }], stateMutability: "view" }, + { type: "function", name: "balanceOf", inputs: [{ name: "account", type: "address" }], outputs: [{ name: "", type: "uint256" }], stateMutability: "view" }, + { type: "event", name: "Deposited", inputs: [{ name: "account", type: "address", indexed: true }, { name: "amount", type: "uint256", indexed: false }], anonymous: false }, + { type: "event", name: "Withdrawn", inputs: [{ name: "account", type: "address", indexed: true }, { name: "amount", type: "uint256", indexed: false }], anonymous: false }, + { type: "event", name: "Paused", inputs: [{ name: "account", type: "address", indexed: false }], anonymous: false }, + { type: "event", name: "Unpaused", inputs: [{ name: "account", type: "address", indexed: false }], anonymous: false }, + { type: "event", name: "OwnershipTransferred", inputs: [{ name: "previousOwner", type: "address", indexed: true }, { name: "newOwner", type: "address", indexed: true }], anonymous: false }, + { type: "event", name: "Upgraded", inputs: [{ name: "implementation", type: "address", indexed: true }], anonymous: false }, ]; -const tokenAbi = [{ type: "function", name: "balanceOf", inputs: [{ name: "account", type: "address" }], outputs: [{ type: "uint256" }], stateMutability: "view" }]; +const tokenAbi = [{ type: "function", name: "balanceOf", inputs: [{ name: "account", type: "address" }], outputs: [{ name: "", 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, @@ -41,6 +51,13 @@ await withFixture(async (root) => { // 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/); + // Catches generated calls/log decoders accepting ABI entries with the right name but wrong wire signature. + const wrongFunction = structuredClone(bankAbi); + wrongFunction.find((entry) => entry.name === "balanceOf").outputs = []; + assert.throws(() => extractAbi({ abi: wrongFunction }, "BankV1"), /signature.*balanceOf/i); + const wrongEvent = structuredClone(bankAbi); + wrongEvent.find((entry) => entry.name === "Deposited").inputs[0].indexed = false; + assert.throws(() => extractAbi({ abi: wrongEvent }, "BankV1"), /signature.*Deposited/i); 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); diff --git a/web/src/data/bankClient.test.ts b/web/src/data/bankClient.test.ts index 101805e..ed664bb 100644 --- a/web/src/data/bankClient.test.ts +++ b/web/src/data/bankClient.test.ts @@ -135,6 +135,17 @@ describe("loadDashboardSnapshot", () => { expect(snapshot.diagnostics).toEqual([expect.objectContaining({ blockNumber: 4n, logIndex: 2, message: expect.any(String) })]); }); + it("diagnoses proxy logs with malformed topics or payload data", async () => { + // Catches payload-shape validation skipping malformed proxy logs before the diagnostic boundary. + const reader = new ReaderDouble(); + const valid = depositedLog(4n, 1, owner, 7n); + reader.logs = [valid, { ...valid, logIndex: 2, topics: "not-topics" }, { ...valid, logIndex: 3, data: "not-hex" }, { ...valid, logIndex: 4, data: undefined }]; + const snapshot = await loadDashboardSnapshot(reader, manifest); + + expect(snapshot.activity).toHaveLength(1); + expect(snapshot.diagnostics.map((diagnostic) => diagnostic.logIndex)).toEqual([2, 3, 4]); + }); + 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"); diff --git a/web/src/data/bankClient.ts b/web/src/data/bankClient.ts index df07d4b..8b3673c 100644 --- a/web/src/data/bankClient.ts +++ b/web/src/data/bankClient.ts @@ -106,9 +106,10 @@ function decodeActivity(logs: readonly unknown[], proxy: Address): { activity: r const activity: Activity[] = []; const diagnostics: DecodeDiagnostic[] = []; for (const log of logs) { - if (!isLog(log) || !sameAddress(log.address, proxy)) continue; + if (!isLogIdentity(log) || !sameAddress(log.address, proxy)) continue; const identity = { blockNumber: log.blockNumber, logIndex: log.logIndex, transactionHash: log.transactionHash }; try { + if (!isLogPayload(log)) throw new Error("proxy event payload is malformed"); 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); @@ -121,14 +122,18 @@ function decodeActivity(logs: readonly unknown[], proxy: Address): { activity: r return { activity, diagnostics }; } -function isLog(value: unknown): value is Readonly<{ address: Address; blockNumber: bigint; logIndex: number; transactionHash: Hex; data: Hex; topics: readonly Hex[] }> { +function isLogIdentity(value: unknown): value is Readonly<{ address: Address; blockNumber: bigint; logIndex: number; transactionHash: Hex; data?: unknown; topics?: unknown }> { 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); + && typeof (value as { transactionHash?: unknown }).transactionHash === "string"; +} + +function isLogPayload(value: Readonly<{ data?: unknown; topics?: unknown }>): value is Readonly<{ data: Hex; topics: readonly Hex[] }> { + return typeof value.data === "string" && /^0x[0-9a-fA-F]*$/.test(value.data) + && Array.isArray(value.topics) && value.topics.length > 0 + && value.topics.every((topic) => typeof topic === "string" && /^0x[0-9a-fA-F]*$/.test(topic)); } function decodedActivity(eventName: string, args: unknown, identity: Readonly<{ blockNumber: bigint; logIndex: number; transactionHash: Hex }>): Activity | undefined {