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..c61bb766 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. 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
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..4f2e7133
--- /dev/null
+++ b/cli/src/modules/history/module.ts
@@ -0,0 +1,26 @@
+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 { loadHistorySelectors } from "./selectors";
+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),
+ };
+ 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
new file mode 100644
index 00000000..4fc854c2
--- /dev/null
+++ b/cli/src/modules/history/output.ts
@@ -0,0 +1,41 @@
+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)}`);
+ 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
new file mode 100644
index 00000000..b66bc3c1
--- /dev/null
+++ b/cli/src/modules/history/types.ts
@@ -0,0 +1,25 @@
+import type { Address, Hex } from "viem";
+
+export type HistoryParameter = {
+ name: string;
+ type: string;
+ value: unknown;
+ selectors?: Hex[] | null;
+};
+
+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..23fd4d75
--- /dev/null
+++ b/cli/test/modules/history/module.test.ts
@@ -0,0 +1,146 @@
+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);
+ vi.mocked(adapter.readContract).mockResolvedValue("0x12345678abcdef01" as never);
+ 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(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();
+ }
+ });
+
+ 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("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) => {
+ 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..ec92a9b1
--- /dev/null
+++ b/cli/test/pipelines/historyPipeline/historyPipelineE2E.test.ts
@@ -0,0 +1,77 @@
+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");
+ 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();
+ 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) => {