From 48a443a150ac370220ab235465dafdce7dde535c Mon Sep 17 00:00:00 2001 From: Vagabond Date: Sat, 10 Oct 2026 00:24:05 +0700 Subject: [PATCH 1/2] feat(cli): add ERC-8153 diamond history command --- .changeset/compose-history.md | 5 + cli/README.md | 5 + cli/src/adapters/IRPCAdapter/adapter.ts | 21 ++ cli/src/adapters/IRPCAdapter/interface.ts | 8 +- cli/src/comander.ts | 8 +- cli/src/modules/history/decoder.ts | 45 ++++ cli/src/modules/history/events.ts | 10 + cli/src/modules/history/logs.ts | 35 ++++ cli/src/modules/history/module.ts | 24 +++ cli/src/modules/history/output.ts | 38 ++++ cli/src/modules/history/types.ts | 24 +++ cli/src/modules/pipelineBuilder/module.ts | 8 + cli/src/pipelines/historyPipeline.ts | 22 ++ cli/test/adapters/IRPCAdapter/adapter.test.ts | 28 +++ cli/test/comander.test.ts | 9 + .../modules/bytecodeValidation/module.test.ts | 2 + cli/test/modules/history/module.test.ts | 120 +++++++++++ .../pipelineBuilder/pipelineBuilder.test.ts | 14 ++ .../historyPipeline/fixtures/Deploy.s.sol | 69 ++++++ .../fixtures/HistoryDiamond.sol | 21 ++ .../fixtures/HistoryFacets.sol | 51 +++++ cli/test/pipelines/historyPipeline/harness.ts | 196 ++++++++++++++++++ .../historyPipeline/historyPipeline.test.ts | 51 +++++ .../historyPipelineE2E.test.ts | 74 +++++++ .../validatePipelineVSL.test.ts | 4 + 25 files changed, 890 insertions(+), 2 deletions(-) create mode 100644 .changeset/compose-history.md create mode 100644 cli/src/modules/history/decoder.ts create mode 100644 cli/src/modules/history/events.ts create mode 100644 cli/src/modules/history/logs.ts create mode 100644 cli/src/modules/history/module.ts create mode 100644 cli/src/modules/history/output.ts create mode 100644 cli/src/modules/history/types.ts create mode 100644 cli/src/pipelines/historyPipeline.ts create mode 100644 cli/test/modules/history/module.test.ts create mode 100644 cli/test/pipelines/historyPipeline/fixtures/Deploy.s.sol create mode 100644 cli/test/pipelines/historyPipeline/fixtures/HistoryDiamond.sol create mode 100644 cli/test/pipelines/historyPipeline/fixtures/HistoryFacets.sol create mode 100644 cli/test/pipelines/historyPipeline/harness.ts create mode 100644 cli/test/pipelines/historyPipeline/historyPipeline.test.ts create mode 100644 cli/test/pipelines/historyPipeline/historyPipelineE2E.test.ts diff --git a/.changeset/compose-history.md b/.changeset/compose-history.md new file mode 100644 index 00000000..231a4a66 --- /dev/null +++ b/.changeset/compose-history.md @@ -0,0 +1,5 @@ +--- +"@perfect-abstractions/compose-cli": patch +--- + +Add `compose history` to display a deployed diamond's ERC-8153 event timeline. diff --git a/cli/README.md b/cli/README.md index 05a773b8..f4892c59 100644 --- a/cli/README.md +++ b/cli/README.md @@ -22,6 +22,7 @@ compose init [options] compose catalog compose info compose validate +compose history
--chain compose --version | -v compose --help | -h ``` @@ -67,6 +68,10 @@ Run static analysis on the local codebase: Exit code non-zero on failure (CI-friendly). +### `compose history
--chain ` + +Show the diamond's ERC-8153 events, newest first, with block, timestamp, transaction hash, and decoded event parameters. Reads `FacetAdded`, `FacetReplaced`, `FacetRemoved`, `DiamondDelegateCall`, and `DiamondMetadata`. ERC-2535 `DiamondCut` history is not supported yet. + ## Base Presets Each base preset provides a starting point for common diamond patterns: diff --git a/cli/src/adapters/IRPCAdapter/adapter.ts b/cli/src/adapters/IRPCAdapter/adapter.ts index dba3ae63..4ba2ac14 100644 --- a/cli/src/adapters/IRPCAdapter/adapter.ts +++ b/cli/src/adapters/IRPCAdapter/adapter.ts @@ -4,8 +4,10 @@ import { getAddress, http, type Address, + type AbiEvent, type Chain, type Hex, + type Log, type ReadContractParameters, } from "viem"; import type { IRPCAdapter, RPCReadContractOptions } from "./interface"; @@ -83,6 +85,23 @@ export async function createRPCAdapter(options: RPCAdapterOptions): Promise { + try { + return await retryRPC(() => client.getLogs({ address, events, fromBlock, toBlock })); + } catch (error) { + throw requestError("getLogs", options.chainId, error); + } + } + + async function getBlockTimestamp(blockNumber: bigint): Promise { + try { + const block = await retryRPC(() => client.getBlock({ blockNumber })); + return block.timestamp; + } catch (error) { + throw requestError("getBlock", options.chainId, error); + } + } + /** * Reads and decodes a view/pure contract function through the configured RPC. * @param parameters Contract address, ABI, function name, and arguments. @@ -118,5 +137,7 @@ export async function createRPCAdapter(options: RPCAdapterOptions): Promise; + + /** Return logs emitted by an address in an inclusive block range, filtered by event ABI. */ + getLogs(address: Address, events: readonly AbiEvent[], fromBlock: bigint, toBlock: bigint): Promise; + + /** Return the timestamp of a historical block, in Unix seconds. */ + getBlockTimestamp(blockNumber: bigint): Promise; } diff --git a/cli/src/comander.ts b/cli/src/comander.ts index cdfcd681..3d987a59 100644 --- a/cli/src/comander.ts +++ b/cli/src/comander.ts @@ -67,6 +67,12 @@ export function buildProgram(): Command { .argument("
", "Diamond contract address") .option("--chain ", "Chain key from compose.json", "local") + program + .command("history") + .description("Show a deployed diamond's ERC-8153 event history") + .argument("
", "Diamond contract address") + .option("--chain ", "Chain key from compose.json", "local") + return program; } @@ -100,7 +106,7 @@ export function parseArgs(argv: string[]): { command: string; flags: Record typeof arg === "string" && arg !== command, ); if (positionalArgs.length > 0) { - if ((command === "inspect" || command === "selectors") && !flags.address) { + if ((command === "inspect" || command === "selectors" || command === "history") && !flags.address) { flags.address = positionalArgs[0]; } else if (!flags.projectName) { flags.projectName = positionalArgs[0]; diff --git a/cli/src/modules/history/decoder.ts b/cli/src/modules/history/decoder.ts new file mode 100644 index 00000000..cca7e664 --- /dev/null +++ b/cli/src/modules/history/decoder.ts @@ -0,0 +1,45 @@ +import { decodeEventLog, type Log } from "viem"; +import type { IRPCAdapter } from "../../adapters/IRPCAdapter/interface"; +import { historyEvents } from "./events"; +import type { HistoryEvent } from "./types"; + +function completeLog(log: Log): asserts log is Log { + if (log.blockNumber === null || log.transactionHash === null || log.transactionIndex === null || log.logIndex === null) { + throw new Error("History log is missing block or transaction position"); + } +} + +/** Decode event parameters from their ABI, preserving their declared order. */ +export async function decodeHistoryLogs(rpc: IRPCAdapter, logs: Log[]): Promise { + const timestampByBlock = new Map(); + const result: HistoryEvent[] = []; + + for (const log of logs) { + completeLog(log); + const decoded = decodeEventLog({ abi: historyEvents, data: log.data, topics: log.topics }); + const event = historyEvents.find((item) => item.name === decoded.eventName); + if (!event) throw new Error(`Unknown ERC-8153 event: ${decoded.eventName}`); + + let timestamp = timestampByBlock.get(log.blockNumber); + if (timestamp === undefined) { + timestamp = await rpc.getBlockTimestamp(log.blockNumber); + timestampByBlock.set(log.blockNumber, timestamp); + } + + const args = decoded.args as Record; + result.push({ + blockNumber: log.blockNumber, + timestamp, + transactionHash: log.transactionHash, + transactionIndex: log.transactionIndex, + logIndex: log.logIndex, + name: decoded.eventName, + parameters: event.inputs.map((input) => ({ name: input.name, type: input.type, value: args[input.name] })), + }); + } + + return result.sort((left, right) => { + if (left.blockNumber !== right.blockNumber) return left.blockNumber < right.blockNumber ? 1 : -1; + return right.transactionIndex - left.transactionIndex || right.logIndex - left.logIndex; + }); +} diff --git a/cli/src/modules/history/events.ts b/cli/src/modules/history/events.ts new file mode 100644 index 00000000..7b569d17 --- /dev/null +++ b/cli/src/modules/history/events.ts @@ -0,0 +1,10 @@ +import { parseAbi } from "viem"; + +/** ERC-8153's three facet changes and two optional upgrade records. */ +export const historyEvents = parseAbi([ + "event FacetAdded(address indexed _facet)", + "event FacetReplaced(address indexed _oldFacet, address indexed _newFacet)", + "event FacetRemoved(address indexed _facet)", + "event DiamondDelegateCall(address indexed _delegate, bytes _delegateCalldata)", + "event DiamondMetadata(bytes32 indexed _tag, bytes _data)", +]); diff --git a/cli/src/modules/history/logs.ts b/cli/src/modules/history/logs.ts new file mode 100644 index 00000000..42ab6048 --- /dev/null +++ b/cli/src/modules/history/logs.ts @@ -0,0 +1,35 @@ +import type { AbiEvent, Address, Log } from "viem"; +import type { IRPCAdapter } from "../../adapters/IRPCAdapter/interface"; +import { errorText, statusCode } from "../../adapters/IRPCAdapter/utils"; + +const BLOCK_WINDOW = 100_000n; +const RANGE_LIMIT = /block range|range of blocks|block span|too many (results|logs)|query returned more than|response size|limit exceeded|exceed.{0,40}(block|result|log)|max.{0,30}(block|range)/; + +async function queryRange( + rpc: IRPCAdapter, + address: Address, + events: readonly AbiEvent[], + fromBlock: bigint, + toBlock: bigint, +): Promise { + try { + return await rpc.getLogs(address, events, fromBlock, toBlock); + } catch (error) { + if (fromBlock === toBlock || (statusCode(error) !== 413 && !RANGE_LIMIT.test(errorText(error)))) throw error; + const middle = (fromBlock + toBlock) / 2n; + const first = await queryRange(rpc, address, events, fromBlock, middle); + const second = await queryRange(rpc, address, events, middle + 1n, toBlock); + return [...first, ...second]; + } +} + +/** Query a stable chain head in bounded windows and split provider-limited ranges. */ +export async function loadHistoryLogs(rpc: IRPCAdapter, address: Address, events: readonly AbiEvent[]): Promise { + const head = await rpc.getBlockNumber(); + const logs: Log[] = []; + for (let fromBlock = 0n; fromBlock <= head; fromBlock += BLOCK_WINDOW) { + const toBlock = fromBlock + BLOCK_WINDOW - 1n > head ? head : fromBlock + BLOCK_WINDOW - 1n; + logs.push(...await queryRange(rpc, address, events, fromBlock, toBlock)); + } + return logs; +} diff --git a/cli/src/modules/history/module.ts b/cli/src/modules/history/module.ts new file mode 100644 index 00000000..0fb3b558 --- /dev/null +++ b/cli/src/modules/history/module.ts @@ -0,0 +1,24 @@ +import type { Address } from "viem"; +import type { IRPCAdapter } from "../../adapters/IRPCAdapter/interface"; +import type { ComposeContext } from "../../context/types"; +import type { ResolvedChainConfig } from "../../utils/chainConfig"; +import { decodeHistoryLogs } from "./decoder"; +import { historyEvents } from "./events"; +import { loadHistoryLogs } from "./logs"; +import { showHistory } from "./output"; +import type { HistoryResult } from "./types"; + +export const HistoryModule = { + async list(ctx: ComposeContext, rpc: IRPCAdapter, chain: ResolvedChainConfig, address: Address): Promise { + const logs = await loadHistoryLogs(rpc, address, historyEvents); + const result: HistoryResult = { + diamond: address, + chainKey: chain.chainKey, + chainId: chain.chainId, + events: await decodeHistoryLogs(rpc, logs), + }; + ctx.state.history = { success: true, result, error: null }; + showHistory(result); + return ctx; + }, +}; diff --git a/cli/src/modules/history/output.ts b/cli/src/modules/history/output.ts new file mode 100644 index 00000000..5d33ca6d --- /dev/null +++ b/cli/src/modules/history/output.ts @@ -0,0 +1,38 @@ +import { cyan, dim } from "../../utils/terminal"; +import type { HistoryParameter, HistoryResult } from "./types"; + +function displayValue(parameter: HistoryParameter): string { + const value = String(parameter.value); + if (parameter.type === "bytes" && value.length > 82) return `${value.slice(0, 82)}...`; + return value; +} + +function parameterLabel(name: string): string { + const words = name.replace(/^_/, "").replace(/([a-z])([A-Z])/g, "$1 $2"); + return `${words.charAt(0).toUpperCase()}${words.slice(1)}`; +} + +/** Shared event frame; event-specific fields come from the decoded ABI. */ +export function showHistory(result: HistoryResult): void { + console.log(`\nDiamond: ${result.diamond}`); + console.log(`Chain: ${result.chainKey} (${result.chainId})`); + console.log(); + console.log(cyan("Upgrade History:")); + console.log(); + + if (result.events.length === 0) { + console.log(dim(" No ERC-8153 events found")); + return; + } + + for (const event of result.events) { + const time = new Date(Number(event.timestamp) * 1_000).toISOString().slice(0, 19).replace("T", " "); + console.log(`Block ${event.blockNumber} (${time} UTC)`); + console.log(` Tx: ${event.transactionHash}`); + console.log(` Event: ${event.name}`); + for (const parameter of event.parameters) { + console.log(` ${parameterLabel(parameter.name)}: ${displayValue(parameter)}`); + } + console.log(); + } +} diff --git a/cli/src/modules/history/types.ts b/cli/src/modules/history/types.ts new file mode 100644 index 00000000..2e32dedb --- /dev/null +++ b/cli/src/modules/history/types.ts @@ -0,0 +1,24 @@ +import type { Address, Hex } from "viem"; + +export type HistoryParameter = { + name: string; + type: string; + value: unknown; +}; + +export type HistoryEvent = { + blockNumber: bigint; + timestamp: bigint; + transactionHash: Hex; + transactionIndex: number; + logIndex: number; + name: string; + parameters: HistoryParameter[]; +}; + +export type HistoryResult = { + diamond: Address; + chainKey: string; + chainId: number; + events: HistoryEvent[]; +}; diff --git a/cli/src/modules/pipelineBuilder/module.ts b/cli/src/modules/pipelineBuilder/module.ts index f065bf81..6d4b6287 100644 --- a/cli/src/modules/pipelineBuilder/module.ts +++ b/cli/src/modules/pipelineBuilder/module.ts @@ -6,6 +6,7 @@ import { BuildPipeline } from "../../pipelines/buildPipeline"; import { RPCPipeline } from "../../pipelines/rpcPipeline"; import { InspectPipeline } from "../../pipelines/inspectPipeline"; import { SelectorsPipeline } from "../../pipelines/selectorsPipeline"; +import { HistoryPipeline } from "../../pipelines/historyPipeline"; import { ValidatePipeline } from "../../pipelines/validatePipeline"; /** @@ -84,6 +85,13 @@ export const PipelineBuilderModule = { error: null, }; return SelectorsPipeline.execute(ctx); + case "history": + ctx.state.commandSelected = { + success: true, + result: { command: ctx.param.command, address: ctx.param.address, chain: ctx.param.chain }, + error: null, + }; + return HistoryPipeline.execute(ctx); default: ctx.state.commandRouting = { success: false, diff --git a/cli/src/pipelines/historyPipeline.ts b/cli/src/pipelines/historyPipeline.ts new file mode 100644 index 00000000..ed4a6d39 --- /dev/null +++ b/cli/src/pipelines/historyPipeline.ts @@ -0,0 +1,22 @@ +import type { ComposeContext } from "../context/types"; +import { inspectAddress } from "../modules/inspect/module"; +import { HistoryModule } from "../modules/history/module"; +import { DependencyKey } from "../resolver/dependencyKey"; +import { DependencyResolver } from "../resolver/dependencyResolver"; +import { resolveChainConfig } from "../utils/chainConfig"; + +/** Read and display the ERC-8153 event history of a deployed diamond. */ +export const HistoryPipeline = { + async execute(ctx: ComposeContext): Promise { + const address = inspectAddress(ctx.param.address); + const chainKey = typeof ctx.param.chain === "string" ? ctx.param.chain : "local"; + const chain = await resolveChainConfig({ chainKey }); + const dependencies = await DependencyResolver.resolve([{ + key: DependencyKey.RPC, + params: { chainKey: chain.chainKey }, + }]); + const rpc = dependencies[DependencyKey.RPC]; + if (!rpc) throw new Error("RPC dependency was not resolved"); + return HistoryModule.list(ctx, rpc, chain, address); + }, +}; diff --git a/cli/test/adapters/IRPCAdapter/adapter.test.ts b/cli/test/adapters/IRPCAdapter/adapter.test.ts index bb8c6d5e..2e0497dd 100644 --- a/cli/test/adapters/IRPCAdapter/adapter.test.ts +++ b/cli/test/adapters/IRPCAdapter/adapter.test.ts @@ -37,6 +37,8 @@ type MockClient = { getChainId: ReturnType; getBlockNumber: ReturnType; getCode: ReturnType; + getLogs: ReturnType; + getBlock: ReturnType; readContract: ReturnType; }; @@ -49,6 +51,8 @@ function useClient(overrides: Record = {}): void { getChainId: vi.fn().mockResolvedValue(11155111), getBlockNumber: vi.fn().mockResolvedValue(123n), getCode: vi.fn().mockResolvedValue("0x6000"), + getLogs: vi.fn().mockResolvedValue([]), + getBlock: vi.fn().mockResolvedValue({ timestamp: 1_000n }), readContract: vi.fn().mockResolvedValue("result"), ...overrides, }; @@ -109,6 +113,30 @@ describe("createRPCAdapter", () => { }); }); + it("filters logs by event ABI and reads historical timestamps", async () => { + useClient(); + const adapter = await createRPCAdapter({ rpcUrl: "https://rpc.example", chainId: 11155111 }); + const events = [{ type: "event", name: "Changed", inputs: [] }] as const; + + await expect(adapter.getLogs(address, events, 10n, 20n)).resolves.toEqual([]); + await expect(adapter.getBlockTimestamp(10n)).resolves.toBe(1_000n); + expect((mocks.activeClient as MockClient).getLogs).toHaveBeenCalledWith({ + address, events, fromBlock: 10n, toBlock: 20n, + }); + expect((mocks.activeClient as MockClient).getBlock).toHaveBeenCalledWith({ blockNumber: 10n }); + }); + + it("retries transient log failures and reports persistent block failures", async () => { + useClient({ + getLogs: vi.fn().mockRejectedValueOnce(Object.assign(new Error("temporary"), { status: 503 })).mockResolvedValue([]), + getBlock: vi.fn().mockRejectedValue(new Error("unavailable")), + }); + const adapter = await createRPCAdapter({ rpcUrl: "https://rpc.example", chainId: 11155111 }); + await expect(adapter.getLogs(address, [], 0n, 1n)).resolves.toEqual([]); + expect((mocks.activeClient as MockClient).getLogs).toHaveBeenCalledTimes(2); + await expect(adapter.getBlockTimestamp(1n)).rejects.toMatchObject({ code: "RPC_REQUEST_FAILED", operation: "getBlock" }); + }); + it("reports missing contract code when verification is enabled", async () => { useClient({ getCode: vi.fn().mockResolvedValue("0x") }); const adapter = await createRPCAdapter({ rpcUrl: "https://rpc.example", chainId: 11155111 }); diff --git a/cli/test/comander.test.ts b/cli/test/comander.test.ts index facd8af8..5aeadaff 100644 --- a/cli/test/comander.test.ts +++ b/cli/test/comander.test.ts @@ -121,3 +121,12 @@ describe("selectors command", () => { }); }); }); + +describe("history command", () => { + it("parses the diamond address and chain", () => { + expect(parseArgs(["node", "compose", "history", "0x0000000000000000000000000000000000000001", "--chain", "sepolia"])).toEqual({ + command: "history", + flags: { address: "0x0000000000000000000000000000000000000001", chain: "sepolia" }, + }); + }); +}); diff --git a/cli/test/modules/bytecodeValidation/module.test.ts b/cli/test/modules/bytecodeValidation/module.test.ts index 9bce9d5a..40722462 100644 --- a/cli/test/modules/bytecodeValidation/module.test.ts +++ b/cli/test/modules/bytecodeValidation/module.test.ts @@ -31,6 +31,8 @@ function setup() { getBlockNumber: vi.fn().mockResolvedValue(100n), readContract: vi.fn(), getCode: vi.fn().mockResolvedValue("0x6000" as Hex), + getLogs: vi.fn(), + getBlockTimestamp: vi.fn(), }; const diamondAdapter = { facets: vi.fn().mockResolvedValue([{ diff --git a/cli/test/modules/history/module.test.ts b/cli/test/modules/history/module.test.ts new file mode 100644 index 00000000..1bb28565 --- /dev/null +++ b/cli/test/modules/history/module.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it, vi } from "vitest"; +import { encodeAbiParameters, encodeEventTopics, type AbiEvent, type Address, type Hex, type Log } from "viem"; +import type { IRPCAdapter } from "../../../src/adapters/IRPCAdapter/interface"; +import { Context } from "../../../src/context/context"; +import { historyEvents } from "../../../src/modules/history/events"; +import { HistoryModule } from "../../../src/modules/history/module"; + +const diamond = "0x0000000000000000000000000000000000000001" as Address; +const oldFacet = "0x0000000000000000000000000000000000000002" as Address; +const newFacet = "0x0000000000000000000000000000000000000003" as Address; +const tx = `0x${"ab".repeat(32)}` as Hex; +const tag = `0x${"cd".repeat(32)}` as Hex; +const chain = { chainKey: "local", chainId: 31337, rpcUrl: "http://localhost:8545" }; + +function eventLog(name: string, args: Record, blockNumber: bigint, logIndex: number): Log { + const found = historyEvents.find((item) => item.name === name); + if (!found) throw new Error(`Missing event ${name}`); + const event: AbiEvent = found; + const indexed = event.inputs.filter((input) => input.indexed); + const nonIndexed = event.inputs.filter((input) => !input.indexed); + const valueOf = (name?: string) => { + if (!name) throw new Error("Unnamed fixture parameter"); + return args[name]; + }; + return { + address: diamond, + blockHash: `0x${"12".repeat(32)}`, + blockNumber, + transactionHash: tx, + transactionIndex: 0, + logIndex, + removed: false, + topics: encodeEventTopics({ abi: [event], eventName: name, args: Object.fromEntries(indexed.map((input) => [input.name, valueOf(input.name)])) }), + data: encodeAbiParameters(nonIndexed, nonIndexed.map((input) => valueOf(input.name))), + } as Log; +} + +function rpc(logs: Log[], head = 10n): IRPCAdapter { + return { + getBlockNumber: vi.fn().mockResolvedValue(head), + getLogs: vi.fn().mockResolvedValue(logs), + getBlockTimestamp: vi.fn().mockResolvedValue(1_700_000_000n), + getCode: vi.fn(), + readContract: vi.fn(), + }; +} + +describe("HistoryModule", () => { + it("decodes all five ERC-8153 events, orders them, and prints their parameters", async () => { + const logs = [ + eventLog("FacetAdded", { _facet: oldFacet }, 8n, 0), + eventLog("FacetReplaced", { _oldFacet: oldFacet, _newFacet: newFacet }, 9n, 0), + eventLog("FacetRemoved", { _facet: oldFacet }, 9n, 1), + eventLog("DiamondDelegateCall", { _delegate: newFacet, _delegateCalldata: "0x12345678" }, 10n, 0), + eventLog("DiamondMetadata", { _tag: tag, _data: "0x1234" }, 10n, 1), + ]; + const adapter = rpc(logs); + const printed = vi.spyOn(console, "log").mockImplementation(() => undefined); + try { + const ctx = Context.create(); + await HistoryModule.list(ctx, adapter, chain, diamond); + const result = ctx.state.history; + expect(result).toMatchObject({ success: true }); + if (!result || !("result" in result) || !result.result) throw new Error("Missing history result"); + const events = (result.result as { events: { name: string; parameters: { name: string; value: unknown }[] }[] }).events; + expect(events.map((event) => event.name)).toEqual([ + "DiamondMetadata", "DiamondDelegateCall", "FacetRemoved", "FacetReplaced", "FacetAdded", + ]); + expect(events[0].parameters).toEqual([ + { name: "_tag", type: "bytes32", value: tag }, + { name: "_data", type: "bytes", value: "0x1234" }, + ]); + expect(events[3].parameters.map((parameter) => parameter.value)).toEqual([oldFacet, newFacet]); + const output = printed.mock.calls.map(([line]) => String(line)).join("\n"); + expect(output).toContain("Upgrade History:"); + expect(output).toContain("Block 10 (2023-11-14 22:13:20 UTC)"); + expect(output).toContain(` Tx: ${tx}`); + expect(output).toContain(" Event: DiamondMetadata"); + expect(output).toContain(` Tag: ${tag}`); + expect(output).toContain(" Data: 0x1234"); + expect(output).toContain(` Old Facet: ${oldFacet}`); + expect(output).toContain(` New Facet: ${newFacet}`); + expect(adapter.getBlockTimestamp).toHaveBeenCalledTimes(3); + } finally { + printed.mockRestore(); + } + }); + + it("includes metadata-only history and reports an empty result without inventing facet changes", async () => { + const printed = vi.spyOn(console, "log").mockImplementation(() => undefined); + try { + const ctx = Context.create(); + await HistoryModule.list(ctx, rpc([eventLog("DiamondMetadata", { _tag: tag, _data: "0x" }, 1n, 0)], 1n), chain, diamond); + expect(ctx.state.history).toMatchObject({ result: { events: [{ name: "DiamondMetadata" }] } }); + + printed.mockClear(); + await HistoryModule.list(Context.create(), rpc([], 0n), chain, diamond); + expect(printed.mock.calls.some(([line]) => String(line).includes("No ERC-8153 events found"))).toBe(true); + } finally { + printed.mockRestore(); + } + }); + + it("splits provider-limited ranges and propagates persistent RPC failures", async () => { + const adapter = rpc([], 3n); + vi.mocked(adapter.getLogs).mockImplementation(async (_address, _events, from, to) => { + if (to - from > 1n) throw new Error("block range too wide"); + return []; + }); + const printed = vi.spyOn(console, "log").mockImplementation(() => undefined); + try { + await HistoryModule.list(Context.create(), adapter, chain, diamond); + expect(adapter.getLogs).toHaveBeenCalledTimes(3); + vi.mocked(adapter.getLogs).mockRejectedValue(new Error("RPC unavailable")); + await expect(HistoryModule.list(Context.create(), adapter, chain, diamond)).rejects.toThrow("RPC unavailable"); + } finally { + printed.mockRestore(); + } + }); +}); diff --git a/cli/test/modules/pipelineBuilder/pipelineBuilder.test.ts b/cli/test/modules/pipelineBuilder/pipelineBuilder.test.ts index 72ed2dd4..95847036 100644 --- a/cli/test/modules/pipelineBuilder/pipelineBuilder.test.ts +++ b/cli/test/modules/pipelineBuilder/pipelineBuilder.test.ts @@ -3,6 +3,7 @@ import { Context } from "../../../src/context/context"; import { PipelineBuilderModule } from "../../../src/modules/pipelineBuilder/module"; import { ValidatePipeline } from "../../../src/pipelines/validatePipeline"; import { SelectorsPipeline } from "../../../src/pipelines/selectorsPipeline"; +import { HistoryPipeline } from "../../../src/pipelines/historyPipeline"; describe("PipelineBuilderModule", () => { it("routes the validate command to ValidatePipeline", async () => { @@ -35,4 +36,17 @@ describe("PipelineBuilderModule", () => { execute.mockRestore(); } }); + + it("routes history to HistoryPipeline", async () => { + const ctx = Context.create(); + ctx.param = { command: "history", address: "0x0000000000000000000000000000000000000001", chain: "local" }; + const execute = vi.spyOn(HistoryPipeline, "execute").mockResolvedValue(ctx); + try { + await PipelineBuilderModule.route(ctx); + expect(execute).toHaveBeenCalledWith(ctx); + expect(ctx.state.commandSelected).toMatchObject({ success: true, result: ctx.param }); + } finally { + execute.mockRestore(); + } + }); }); diff --git a/cli/test/pipelines/historyPipeline/fixtures/Deploy.s.sol b/cli/test/pipelines/historyPipeline/fixtures/Deploy.s.sol new file mode 100644 index 00000000..046ccc01 --- /dev/null +++ b/cli/test/pipelines/historyPipeline/fixtures/Deploy.s.sol @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.30; + +import {Script} from "forge-std/Script.sol"; +import {DiamondUpgradeFacet} from "@perfect-abstractions/compose/diamond/DiamondUpgradeFacet.sol"; +import {HistoryDiamond} from "../src/HistoryDiamond.sol"; +import { + HistoryFacetA, + HistoryFacetB, + HistoryFacetB2, + HistoryFacetC, + HistoryInitializer +} from "../src/HistoryFacets.sol"; + +contract DeployScript is Script { + function run() external { + vm.startBroadcast(); + + DiamondUpgradeFacet upgradeFacet = new DiamondUpgradeFacet(); + HistoryDiamond diamond = new HistoryDiamond(address(upgradeFacet)); + HistoryFacetA facetA = new HistoryFacetA(); + HistoryFacetB facetB = new HistoryFacetB(); + HistoryFacetC facetC = new HistoryFacetC(); + HistoryFacetB2 facetB2 = new HistoryFacetB2(); + HistoryInitializer initializer = new HistoryInitializer(); + + address[] memory additions = new address[](1); + address[] memory noFacets = new address[](0); + DiamondUpgradeFacet.FacetReplacement[] memory noReplacements = new DiamondUpgradeFacet.FacetReplacement[](0); + + additions[0] = address(facetA); + DiamondUpgradeFacet(address(diamond)) + .upgradeDiamond(additions, noReplacements, noFacets, address(0), "", bytes32(0), ""); + + additions[0] = address(facetB); + DiamondUpgradeFacet(address(diamond)) + .upgradeDiamond(additions, noReplacements, noFacets, address(0), "", bytes32(0), ""); + + additions[0] = address(facetC); + DiamondUpgradeFacet(address(diamond)) + .upgradeDiamond(additions, noReplacements, noFacets, address(0), "", bytes32(0), ""); + + DiamondUpgradeFacet.FacetReplacement[] memory replacements = new DiamondUpgradeFacet.FacetReplacement[](1); + replacements[0] = DiamondUpgradeFacet.FacetReplacement(address(facetB), address(facetB2)); + DiamondUpgradeFacet(address(diamond)) + .upgradeDiamond(noFacets, replacements, noFacets, address(0), "", bytes32(0), ""); + + address[] memory removals = new address[](1); + removals[0] = address(facetC); + DiamondUpgradeFacet(address(diamond)) + .upgradeDiamond(noFacets, noReplacements, removals, address(0), "", bytes32(0), ""); + + DiamondUpgradeFacet(address(diamond)) + .upgradeDiamond(noFacets, noReplacements, noFacets, address(0), "", bytes32("history"), hex"1234"); + + DiamondUpgradeFacet(address(diamond)) + .upgradeDiamond( + noFacets, + noReplacements, + noFacets, + address(initializer), + abi.encodeWithSelector(HistoryInitializer.initialize.selector), + bytes32(0), + "" + ); + + vm.stopBroadcast(); + } +} diff --git a/cli/test/pipelines/historyPipeline/fixtures/HistoryDiamond.sol b/cli/test/pipelines/historyPipeline/fixtures/HistoryDiamond.sol new file mode 100644 index 00000000..0e5ea0d1 --- /dev/null +++ b/cli/test/pipelines/historyPipeline/fixtures/HistoryDiamond.sol @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.30; + +import "@perfect-abstractions/compose/diamond/DiamondMod.sol" as DiamondMod; + +contract HistoryDiamond { + constructor(address upgradeFacet) { + bytes32 ownerSlot = keccak256("erc173.owner"); + assembly { + sstore(ownerSlot, caller()) + } + + address[] memory initialFacets = new address[](1); + initialFacets[0] = upgradeFacet; + DiamondMod.addFacets(initialFacets); + } + + fallback() external payable { + DiamondMod.diamondFallback(); + } +} diff --git a/cli/test/pipelines/historyPipeline/fixtures/HistoryFacets.sol b/cli/test/pipelines/historyPipeline/fixtures/HistoryFacets.sol new file mode 100644 index 00000000..4e267c0a --- /dev/null +++ b/cli/test/pipelines/historyPipeline/fixtures/HistoryFacets.sol @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.30; + +contract HistoryFacetA { + function alpha() external pure returns (uint256) { + return 1; + } + + function exportSelectors() external pure returns (bytes memory) { + return bytes.concat(this.alpha.selector); + } +} + +contract HistoryFacetB { + function beta() external pure returns (uint256) { + return 2; + } + + function exportSelectors() external pure returns (bytes memory) { + return bytes.concat(this.beta.selector); + } +} + +contract HistoryFacetB2 { + function beta() external pure returns (uint256) { + return 22; + } + + function exportSelectors() external pure returns (bytes memory) { + return bytes.concat(this.beta.selector); + } +} + +contract HistoryFacetC { + function gamma() external pure returns (uint256) { + return 3; + } + + function exportSelectors() external pure returns (bytes memory) { + return bytes.concat(this.gamma.selector); + } +} + +contract HistoryInitializer { + function initialize() external { + bytes32 position = keccak256("history.initialized"); + assembly { + sstore(position, 1) + } + } +} diff --git a/cli/test/pipelines/historyPipeline/harness.ts b/cli/test/pipelines/historyPipeline/harness.ts new file mode 100644 index 00000000..c2d197df --- /dev/null +++ b/cli/test/pipelines/historyPipeline/harness.ts @@ -0,0 +1,196 @@ +import assert from "node:assert/strict"; +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import fs from "node:fs/promises"; +import { existsSync } from "node:fs"; +import net from "node:net"; +import os from "node:os"; +import path from "node:path"; +import { getAddress } from "viem"; +import type { Address, Hex } from "viem"; + +const CHAIN_ID = 31337; +const PRIVATE_KEY = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; +const COMPOSE_ROOT = path.resolve(__dirname, "../../../.."); +const FIXTURES = path.join(__dirname, "fixtures"); + +type BroadcastTransaction = { + contractName?: string; + contractAddress?: Address; + hash?: Hex; + transaction?: { to?: Address }; +}; + +type Receipt = { + status: Hex; + blockNumber: Hex; + logs: { address: Address; topics: Hex[] }[]; +}; + +export type HistoryHarness = { + projectRoot: string; + diamond: Address; + facetAddresses: Record; + upgradeBlocks: bigint[]; + rpcUrl: string; + rpc(method: string, params?: unknown[]): Promise; + runHistory(): Promise; + cleanup(): Promise; +}; + +function foundryTool(name: string): string { + const executable = `${name}${process.platform === "win32" ? ".exe" : ""}`; + const local = path.join(os.homedir(), ".foundry", "bin", executable); + return existsSync(local) ? local : name; +} + +function runProcess(command: string, args: string[], cwd: string): Promise { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { cwd, windowsHide: true, stdio: ["ignore", "pipe", "pipe"] }); + let output = ""; + child.stdout.on("data", (chunk: Buffer) => { output += chunk.toString(); }); + child.stderr.on("data", (chunk: Buffer) => { output += chunk.toString(); }); + child.once("error", reject); + child.once("close", (code) => code === 0 ? resolve(output) : reject(new Error(`${command} exited ${code}\n${output}`))); + }); +} + +async function freePort(): Promise { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + server.close((error) => error ? reject(error) : resolve(typeof address === "object" && address ? address.port : 0)); + }); + }); +} + +async function rpc(url: string, method: string, params: unknown[] = []): Promise { + const response = await fetch(url, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }), + }); + const body = await response.json() as { result?: unknown; error?: { message: string } }; + if (!response.ok || body.error) throw new Error(body.error?.message ?? response.statusText); + return body.result; +} + +async function startAnvil(port: number, projectRoot: string): Promise<{ child: ChildProcessWithoutNullStreams; url: string }> { + const child = spawn(foundryTool("anvil"), [ + "--host", "127.0.0.1", "--port", String(port), "--chain-id", String(CHAIN_ID), "--silent", + ], { cwd: projectRoot, windowsHide: true }); + let output = ""; + child.stdout.on("data", (chunk: Buffer) => { output += chunk.toString(); }); + child.stderr.on("data", (chunk: Buffer) => { output += chunk.toString(); }); + const errors: Error[] = []; + child.on("error", (error) => errors.push(error)); + const url = `http://127.0.0.1:${port}`; + for (let attempt = 0; attempt < 100; attempt += 1) { + if (errors.length > 0 || child.exitCode !== null) break; + try { + if (await rpc(url, "eth_chainId") === "0x7a69") return { child, url }; + } catch { + await new Promise((resolve) => setTimeout(resolve, 100)); + } + } + child.kill(); + throw errors[0] ?? new Error(`Anvil did not start\n${output}`); +} + +async function prepareProject(projectRoot: string, url: string): Promise { + await Promise.all([ + fs.mkdir(path.join(projectRoot, "src"), { recursive: true }), + fs.mkdir(path.join(projectRoot, "script"), { recursive: true }), + ]); + await Promise.all([ + fs.copyFile(path.join(FIXTURES, "HistoryDiamond.sol"), path.join(projectRoot, "src", "HistoryDiamond.sol")), + fs.copyFile(path.join(FIXTURES, "HistoryFacets.sol"), path.join(projectRoot, "src", "HistoryFacets.sol")), + fs.copyFile(path.join(FIXTURES, "Deploy.s.sol"), path.join(projectRoot, "script", "Deploy.s.sol")), + fs.writeFile(path.join(projectRoot, "foundry.toml"), [ + "[profile.default]", 'src = "src"', 'script = "script"', 'out = "out"', 'libs = []', 'solc = "0.8.30"', "optimizer = true", "", + ].join("\n")), + fs.writeFile(path.join(projectRoot, "remappings.txt"), [ + `@perfect-abstractions/compose/=${COMPOSE_ROOT.replaceAll("\\", "/")}/src/`, + `forge-std/=${COMPOSE_ROOT.replaceAll("\\", "/")}/lib/forge-std/src/`, + "", + ].join("\n")), + fs.writeFile(path.join(projectRoot, "compose.json"), JSON.stringify({ + chains: { local: { chainId: CHAIN_ID, rpc: url } }, + })), + ]); +} + +async function deployment(projectRoot: string, url: string): Promise> { + await runProcess(foundryTool("forge"), [ + "script", "script/Deploy.s.sol:DeployScript", "--broadcast", "--slow", "--non-interactive", + "--rpc-url", url, "--private-key", PRIVATE_KEY, + ], projectRoot); + const broadcast = JSON.parse(await fs.readFile(path.join( + projectRoot, "broadcast", "Deploy.s.sol", String(CHAIN_ID), "run-latest.json", + ), "utf8")) as { transactions: BroadcastTransaction[] }; + + const facetAddresses: Record = {}; + for (const name of ["DiamondUpgradeFacet", "HistoryFacetA", "HistoryFacetB", "HistoryFacetC", "HistoryFacetB2", "HistoryInitializer"]) { + const address = broadcast.transactions.find((item) => item.contractName === name)?.contractAddress; + assert.ok(address, `Missing ${name} deployment`); + facetAddresses[name] = getAddress(address); + } + const diamond = broadcast.transactions.find((item) => item.contractName === "HistoryDiamond")?.contractAddress; + assert.ok(diamond, "Missing HistoryDiamond deployment"); + + const upgrades = broadcast.transactions.filter((item) => item.transaction?.to?.toLowerCase() === diamond.toLowerCase()); + assert.equal(upgrades.length, 7, "Expected three adds, replace, remove, metadata, and delegatecall"); + const upgradeBlocks: bigint[] = []; + for (const upgrade of upgrades) { + const receipt = await rpc(url, "eth_getTransactionReceipt", [upgrade.hash]) as Receipt; + assert.equal(receipt.status, "0x1", `Upgrade reverted: ${upgrade.hash}`); + assert.equal(receipt.logs.length, 1, "Each upgrade should emit one ERC-8153 event"); + assert.equal(receipt.logs[0].address.toLowerCase(), diamond.toLowerCase()); + upgradeBlocks.push(BigInt(receipt.blockNumber)); + } + assert.equal(new Set(upgradeBlocks).size, 7, "Upgrade transactions must land in separate blocks"); + assert.ok(upgradeBlocks.every((block, index) => index === 0 || block > upgradeBlocks[index - 1])); + + return { diamond: getAddress(diamond), facetAddresses, upgradeBlocks }; +} + +export async function createHistoryHarness(): Promise { + const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), "compose-history-e2e-")); + let anvil: ChildProcessWithoutNullStreams | undefined; + const cleanup = async () => { + if (anvil && anvil.exitCode === null) { + anvil.kill(); + await Promise.race([ + new Promise((resolve) => anvil!.once("exit", resolve)), + new Promise((resolve) => setTimeout(resolve, 2_000)), + ]); + } + const parent = path.resolve(os.tmpdir()); + const target = path.resolve(projectRoot); + assert.ok(target.startsWith(`${parent}${path.sep}`) && path.basename(target).startsWith("compose-history-e2e-")); + await fs.rm(target, { recursive: true, force: true }); + }; + + try { + const started = await startAnvil(await freePort(), projectRoot); + anvil = started.child; + await prepareProject(projectRoot, started.url); + const result = await deployment(projectRoot, started.url); + return { + ...result, + projectRoot, + rpcUrl: started.url, + rpc: (method, params) => rpc(started.url, method, params), + runHistory: () => { + const entry = process.env.COMPOSE_E2E_CLI_ENTRY ?? path.join(COMPOSE_ROOT, "cli", "dist", "index.js"); + assert.ok(existsSync(entry), `Compose CLI build not found: ${entry}`); + return runProcess(process.execPath, [entry, "history", result.diamond, "--chain", "local"], projectRoot); + }, + cleanup, + }; + } catch (error) { + await cleanup(); + throw error; + } +} diff --git a/cli/test/pipelines/historyPipeline/historyPipeline.test.ts b/cli/test/pipelines/historyPipeline/historyPipeline.test.ts new file mode 100644 index 00000000..9d24ab5e --- /dev/null +++ b/cli/test/pipelines/historyPipeline/historyPipeline.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it, vi } from "vitest"; +import { Context } from "../../../src/context/context"; +import { DependencyKey } from "../../../src/resolver/dependencyKey"; + +const mocks = vi.hoisted(() => ({ + resolveChainConfig: vi.fn(), + resolve: vi.fn(), + list: vi.fn(), +})); + +vi.mock("../../../src/utils/chainConfig", () => ({ resolveChainConfig: mocks.resolveChainConfig })); +vi.mock("../../../src/resolver/dependencyResolver", () => ({ DependencyResolver: { resolve: mocks.resolve } })); +vi.mock("../../../src/modules/history/module", () => ({ HistoryModule: { list: mocks.list } })); + +import { HistoryPipeline } from "../../../src/pipelines/historyPipeline"; + +const address = "0x0000000000000000000000000000000000000001"; + +describe("HistoryPipeline", () => { + it("validates the address before resolving a chain", async () => { + const ctx = Context.create(); + ctx.param = { address: "invalid" }; + await expect(HistoryPipeline.execute(ctx)).rejects.toMatchObject({ code: "RPC_INVALID_ADDRESS" }); + expect(mocks.resolveChainConfig).not.toHaveBeenCalled(); + }); + + it("resolves the RPC adapter and runs the history module", async () => { + const ctx = Context.create(); + ctx.param = { address, chain: "sepolia" }; + const chain = { chainKey: "sepolia", chainId: 11155111, rpcUrl: "https://rpc.example" }; + const rpc = { getLogs: vi.fn() }; + mocks.resolveChainConfig.mockResolvedValue(chain); + mocks.resolve.mockResolvedValue({ [DependencyKey.RPC]: rpc }); + mocks.list.mockResolvedValue(ctx); + + await expect(HistoryPipeline.execute(ctx)).resolves.toBe(ctx); + expect(mocks.resolve).toHaveBeenCalledWith([{ key: DependencyKey.RPC, params: { chainKey: "sepolia" } }]); + expect(mocks.list).toHaveBeenCalledWith(ctx, rpc, chain, address); + }); + + it("propagates unknown chains and RPC failures", async () => { + const ctx = Context.create(); + ctx.param = { address, chain: "missing" }; + mocks.resolveChainConfig.mockRejectedValueOnce(new Error("Unknown chain")); + await expect(HistoryPipeline.execute(ctx)).rejects.toThrow("Unknown chain"); + + mocks.resolveChainConfig.mockResolvedValue({ chainKey: "local" }); + mocks.resolve.mockRejectedValueOnce(new Error("RPC unavailable")); + await expect(HistoryPipeline.execute(ctx)).rejects.toThrow("RPC unavailable"); + }); +}); diff --git a/cli/test/pipelines/historyPipeline/historyPipelineE2E.test.ts b/cli/test/pipelines/historyPipeline/historyPipelineE2E.test.ts new file mode 100644 index 00000000..1dafa1c3 --- /dev/null +++ b/cli/test/pipelines/historyPipeline/historyPipelineE2E.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it, vi } from "vitest"; +import { keccak256, stringToHex } from "viem"; +import { Context } from "../../../src/context/context"; +import type { HistoryResult } from "../../../src/modules/history/types"; +import { HistoryPipeline } from "../../../src/pipelines/historyPipeline"; +import { createHistoryHarness } from "./harness"; + +describe("compose history over Anvil", () => { + it("reads all five ERC-8153 events from a real diamond across separate upgrade blocks", async () => { + const harness = await createHistoryHarness(); + const previousDirectory = process.cwd(); + const printed = vi.spyOn(console, "log").mockImplementation(() => undefined); + + try { + process.chdir(harness.projectRoot); + const ctx = Context.create(); + ctx.param = { address: harness.diamond, chain: "local" }; + const result = await HistoryPipeline.execute(ctx); + const state = result.state.history; + expect(state).toMatchObject({ success: true }); + if (!state || !("result" in state) || !state.result) throw new Error("Missing history result"); + const history = state.result as HistoryResult; + + expect(history.events.map((event) => event.name)).toEqual([ + "DiamondDelegateCall", + "DiamondMetadata", + "FacetRemoved", + "FacetReplaced", + "FacetAdded", + "FacetAdded", + "FacetAdded", + "FacetAdded", + ]); + expect(history.events.slice(0, 7).map((event) => event.blockNumber)).toEqual([...harness.upgradeBlocks].reverse()); + expect(history.events[7].blockNumber).toBeLessThan(harness.upgradeBlocks[0]); + + const parameterValues = (index: number) => history.events[index].parameters.map((parameter) => parameter.value); + expect(parameterValues(0)[0]).toBe(harness.facetAddresses.HistoryInitializer); + expect(parameterValues(0)[1]).toMatch(/^0x[0-9a-f]{8}$/); + expect(parameterValues(1)[1]).toBe("0x1234"); + expect(parameterValues(2)).toEqual([harness.facetAddresses.HistoryFacetC]); + expect(parameterValues(3)).toEqual([ + harness.facetAddresses.HistoryFacetB, + harness.facetAddresses.HistoryFacetB2, + ]); + expect(history.events.slice(4, 7).map((event) => event.parameters[0].value)).toEqual([ + harness.facetAddresses.HistoryFacetC, + harness.facetAddresses.HistoryFacetB, + harness.facetAddresses.HistoryFacetA, + ]); + expect(parameterValues(7)).toEqual([harness.facetAddresses.DiamondUpgradeFacet]); + + const marker = keccak256(stringToHex("history.initialized")); + const value = await harness.rpc("eth_getStorageAt", [harness.diamond, marker, "latest"]); + expect(BigInt(value as string)).toBe(1n); + + const output = printed.mock.calls.map(([line]) => String(line)).join("\n"); + for (const name of ["FacetAdded", "FacetReplaced", "FacetRemoved", "DiamondMetadata", "DiamondDelegateCall"]) { + expect(output).toContain(`Event: ${name}`); + } + expect(output).toContain("Data: 0x1234"); + + const cliOutput = await harness.runHistory(); + expect((cliOutput.match(/Event: FacetAdded/g) ?? []).length).toBe(4); + for (const name of ["FacetReplaced", "FacetRemoved", "DiamondMetadata", "DiamondDelegateCall"]) { + expect(cliOutput).toContain(`Event: ${name}`); + } + } finally { + process.chdir(previousDirectory); + printed.mockRestore(); + await harness.cleanup(); + } + }, 60_000); +}); diff --git a/cli/test/pipelines/validatePipeline/validatePipelineVSL.test.ts b/cli/test/pipelines/validatePipeline/validatePipelineVSL.test.ts index af89f603..a2cfd0b4 100644 --- a/cli/test/pipelines/validatePipeline/validatePipelineVSL.test.ts +++ b/cli/test/pipelines/validatePipeline/validatePipelineVSL.test.ts @@ -34,6 +34,8 @@ function bytecodeDependencies(report: ReturnType { ), "utf8")) as { deployedBytecode: { object: Hex } }; return artifact.deployedBytecode.object; }), + getLogs: vi.fn(), + getBlockTimestamp: vi.fn(), }; const originalResolve = DependencyResolver.resolve.bind(DependencyResolver); const resolver = vi.spyOn(DependencyResolver, "resolve").mockImplementation(async (requests) => { From f3a0ca9f1edf4b22c8b9faf009f4016a4fc6dc43 Mon Sep 17 00:00:00 2001 From: Vagabond Date: Sat, 10 Oct 2026 00:54:50 +0700 Subject: [PATCH 2/2] feat(cli): show facet selectors in diamond history --- cli/README.md | 2 +- cli/src/modules/history/module.ts | 2 + cli/src/modules/history/output.ts | 3 ++ cli/src/modules/history/selectors.ts | 37 +++++++++++++++++++ cli/src/modules/history/types.ts | 1 + cli/test/modules/history/module.test.ts | 26 +++++++++++++ .../historyPipelineE2E.test.ts | 3 ++ 7 files changed, 73 insertions(+), 1 deletion(-) create mode 100644 cli/src/modules/history/selectors.ts diff --git a/cli/README.md b/cli/README.md index f4892c59..c61bb766 100644 --- a/cli/README.md +++ b/cli/README.md @@ -70,7 +70,7 @@ Exit code non-zero on failure (CI-friendly). ### `compose history
--chain ` -Show the diamond's ERC-8153 events, newest first, with block, timestamp, transaction hash, and decoded event parameters. Reads `FacetAdded`, `FacetReplaced`, `FacetRemoved`, `DiamondDelegateCall`, and `DiamondMetadata`. ERC-2535 `DiamondCut` history is not supported yet. +Show the diamond's ERC-8153 events, newest first, with block, timestamp, transaction hash, and decoded event parameters. For facet events, selectors are read directly from each facet's `exportSelectors()` function; unavailable results are labeled. Reads `FacetAdded`, `FacetReplaced`, `FacetRemoved`, `DiamondDelegateCall`, and `DiamondMetadata`. ERC-2535 `DiamondCut` history is not supported yet. ## Base Presets diff --git a/cli/src/modules/history/module.ts b/cli/src/modules/history/module.ts index 0fb3b558..4f2e7133 100644 --- a/cli/src/modules/history/module.ts +++ b/cli/src/modules/history/module.ts @@ -6,6 +6,7 @@ import { decodeHistoryLogs } from "./decoder"; import { historyEvents } from "./events"; import { loadHistoryLogs } from "./logs"; import { showHistory } from "./output"; +import { loadHistorySelectors } from "./selectors"; import type { HistoryResult } from "./types"; export const HistoryModule = { @@ -17,6 +18,7 @@ export const HistoryModule = { chainId: chain.chainId, events: await decodeHistoryLogs(rpc, logs), }; + await loadHistorySelectors(rpc, result.events); ctx.state.history = { success: true, result, error: null }; showHistory(result); return ctx; diff --git a/cli/src/modules/history/output.ts b/cli/src/modules/history/output.ts index 5d33ca6d..4fc854c2 100644 --- a/cli/src/modules/history/output.ts +++ b/cli/src/modules/history/output.ts @@ -32,6 +32,9 @@ export function showHistory(result: HistoryResult): void { console.log(` Event: ${event.name}`); for (const parameter of event.parameters) { console.log(` ${parameterLabel(parameter.name)}: ${displayValue(parameter)}`); + if (parameter.selectors !== undefined) { + console.log(` Selectors: ${parameter.selectors === null ? "unavailable" : parameter.selectors.join(", ") || "none"}`); + } } console.log(); } diff --git a/cli/src/modules/history/selectors.ts b/cli/src/modules/history/selectors.ts new file mode 100644 index 00000000..26a41896 --- /dev/null +++ b/cli/src/modules/history/selectors.ts @@ -0,0 +1,37 @@ +import { bytesToHex, hexToBytes, parseAbi, type Address, type Hex } from "viem"; +import type { IRPCAdapter } from "../../adapters/IRPCAdapter/interface"; +import type { HistoryEvent } from "./types"; + +const exportSelectorsAbi = parseAbi(["function exportSelectors() pure returns (bytes)"]); +const facetParameters = new Set(["_facet", "_oldFacet", "_newFacet"]); + +async function readFacetSelectors(rpc: IRPCAdapter, facet: Address): Promise { + try { + const packed = await rpc.readContract({ address: facet, abi: exportSelectorsAbi, functionName: "exportSelectors" }); + const bytes = hexToBytes(packed); + if (bytes.length % 4 !== 0) return null; + const selectors: Hex[] = []; + for (let offset = 0; offset < bytes.length; offset += 4) { + selectors.push(bytesToHex(bytes.subarray(offset, offset + 4))); + } + return selectors; + } catch { + return null; + } +} + +/** Enrich facet events from their direct, pure exportSelectors() calls. */ +export async function loadHistorySelectors(rpc: IRPCAdapter, events: HistoryEvent[]): Promise { + const selectorsByFacet = new Map>(); + await Promise.all(events.flatMap((event) => event.parameters + .filter((parameter) => event.name.startsWith("Facet") && facetParameters.has(parameter.name)) + .map(async (parameter) => { + const facet = parameter.value as Address; + let selectors = selectorsByFacet.get(facet); + if (!selectors) { + selectors = readFacetSelectors(rpc, facet); + selectorsByFacet.set(facet, selectors); + } + parameter.selectors = await selectors; + }))); +} diff --git a/cli/src/modules/history/types.ts b/cli/src/modules/history/types.ts index 2e32dedb..b66bc3c1 100644 --- a/cli/src/modules/history/types.ts +++ b/cli/src/modules/history/types.ts @@ -4,6 +4,7 @@ export type HistoryParameter = { name: string; type: string; value: unknown; + selectors?: Hex[] | null; }; export type HistoryEvent = { diff --git a/cli/test/modules/history/module.test.ts b/cli/test/modules/history/module.test.ts index 1bb28565..23fd4d75 100644 --- a/cli/test/modules/history/module.test.ts +++ b/cli/test/modules/history/module.test.ts @@ -55,6 +55,7 @@ describe("HistoryModule", () => { eventLog("DiamondMetadata", { _tag: tag, _data: "0x1234" }, 10n, 1), ]; const adapter = rpc(logs); + vi.mocked(adapter.readContract).mockResolvedValue("0x12345678abcdef01" as never); const printed = vi.spyOn(console, "log").mockImplementation(() => undefined); try { const ctx = Context.create(); @@ -80,6 +81,11 @@ describe("HistoryModule", () => { expect(output).toContain(" Data: 0x1234"); expect(output).toContain(` Old Facet: ${oldFacet}`); expect(output).toContain(` New Facet: ${newFacet}`); + expect(output).toContain(" Selectors: 0x12345678, 0xabcdef01"); + expect(adapter.readContract).toHaveBeenCalledTimes(2); + expect(vi.mocked(adapter.readContract).mock.calls.map(([parameters]) => parameters.address).sort()).toEqual( + [oldFacet, newFacet].sort(), + ); expect(adapter.getBlockTimestamp).toHaveBeenCalledTimes(3); } finally { printed.mockRestore(); @@ -101,6 +107,26 @@ describe("HistoryModule", () => { } }); + it("keeps facet history when exportSelectors is unavailable or malformed", async () => { + const adapter = rpc([ + eventLog("FacetAdded", { _facet: oldFacet }, 2n, 0), + eventLog("FacetAdded", { _facet: newFacet }, 3n, 0), + ]); + vi.mocked(adapter.readContract) + .mockRejectedValueOnce(new Error("no code")) + .mockResolvedValueOnce("0x123456" as never); + const printed = vi.spyOn(console, "log").mockImplementation(() => undefined); + try { + const ctx = Context.create(); + await HistoryModule.list(ctx, adapter, chain, diamond); + expect(ctx.state.history).toMatchObject({ success: true }); + const output = printed.mock.calls.map(([line]) => String(line)).join("\n"); + expect((output.match(/Selectors: unavailable/g) ?? []).length).toBe(2); + } finally { + printed.mockRestore(); + } + }); + it("splits provider-limited ranges and propagates persistent RPC failures", async () => { const adapter = rpc([], 3n); vi.mocked(adapter.getLogs).mockImplementation(async (_address, _events, from, to) => { diff --git a/cli/test/pipelines/historyPipeline/historyPipelineE2E.test.ts b/cli/test/pipelines/historyPipeline/historyPipelineE2E.test.ts index 1dafa1c3..ec92a9b1 100644 --- a/cli/test/pipelines/historyPipeline/historyPipelineE2E.test.ts +++ b/cli/test/pipelines/historyPipeline/historyPipelineE2E.test.ts @@ -59,12 +59,15 @@ describe("compose history over Anvil", () => { expect(output).toContain(`Event: ${name}`); } expect(output).toContain("Data: 0x1234"); + expect(output).toContain("Selectors: 0x"); + expect(output).not.toContain("Selectors: unavailable"); const cliOutput = await harness.runHistory(); expect((cliOutput.match(/Event: FacetAdded/g) ?? []).length).toBe(4); for (const name of ["FacetReplaced", "FacetRemoved", "DiamondMetadata", "DiamondDelegateCall"]) { expect(cliOutput).toContain(`Event: ${name}`); } + expect(cliOutput).toContain("Selectors: 0x"); } finally { process.chdir(previousDirectory); printed.mockRestore();