Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/compose-history.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@perfect-abstractions/compose-cli": patch
---

Add `compose history` to display a deployed diamond's ERC-8153 event timeline.
5 changes: 5 additions & 0 deletions cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ compose init [options]
compose catalog
compose info
compose validate
compose history <address> --chain <chain>
compose --version | -v
compose --help | -h
```
Expand Down Expand Up @@ -67,6 +68,10 @@ Run static analysis on the local codebase:

Exit code non-zero on failure (CI-friendly).

### `compose history <address> --chain <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:
Expand Down
21 changes: 21 additions & 0 deletions cli/src/adapters/IRPCAdapter/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -83,6 +85,23 @@ export async function createRPCAdapter(options: RPCAdapterOptions): Promise<IRPC
}
}

async function getLogs(address: Address, events: readonly AbiEvent[], fromBlock: bigint, toBlock: bigint): Promise<Log[]> {
try {
return await retryRPC(() => client.getLogs({ address, events, fromBlock, toBlock }));
} catch (error) {
throw requestError("getLogs", options.chainId, error);
}
}

async function getBlockTimestamp(blockNumber: bigint): Promise<bigint> {
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.
Expand Down Expand Up @@ -118,5 +137,7 @@ export async function createRPCAdapter(options: RPCAdapterOptions): Promise<IRPC
getBlockNumber,
readContract,
getCode,
getLogs,
getBlockTimestamp,
};
}
8 changes: 7 additions & 1 deletion cli/src/adapters/IRPCAdapter/interface.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { Address, Abi, Hex } from "viem";
import type { Address, Abi, AbiEvent, Hex, Log } from "viem";
import type { ReadContractParameters } from "viem";

/** Optional behavior for a contract read. */
Expand All @@ -20,4 +20,10 @@ export interface IRPCAdapter {

/** Return deployed bytecode, or undefined when the account has no code. */
getCode(address: Address, blockNumber?: bigint): Promise<Hex | undefined>;

/** 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<Log[]>;

/** Return the timestamp of a historical block, in Unix seconds. */
getBlockTimestamp(blockNumber: bigint): Promise<bigint>;
}
8 changes: 7 additions & 1 deletion cli/src/comander.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,12 @@ export function buildProgram(): Command {
.argument("<address>", "Diamond contract address")
.option("--chain <chain-key>", "Chain key from compose.json", "local")

program
.command("history")
.description("Show a deployed diamond's ERC-8153 event history")
.argument("<address>", "Diamond contract address")
.option("--chain <chain-key>", "Chain key from compose.json", "local")

return program;
}

Expand Down Expand Up @@ -100,7 +106,7 @@ export function parseArgs(argv: string[]): { command: string; flags: Record<stri
(arg): arg is string => 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];
Expand Down
45 changes: 45 additions & 0 deletions cli/src/modules/history/decoder.ts
Original file line number Diff line number Diff line change
@@ -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<bigint, number, false> {
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<HistoryEvent[]> {
const timestampByBlock = new Map<bigint, bigint>();
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<string, unknown>;
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;
});
}
10 changes: 10 additions & 0 deletions cli/src/modules/history/events.ts
Original file line number Diff line number Diff line change
@@ -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)",
]);
35 changes: 35 additions & 0 deletions cli/src/modules/history/logs.ts
Original file line number Diff line number Diff line change
@@ -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<Log[]> {
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<Log[]> {
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;
}
26 changes: 26 additions & 0 deletions cli/src/modules/history/module.ts
Original file line number Diff line number Diff line change
@@ -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<ComposeContext> {
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;
},
};
41 changes: 41 additions & 0 deletions cli/src/modules/history/output.ts
Original file line number Diff line number Diff line change
@@ -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();
}
}
37 changes: 37 additions & 0 deletions cli/src/modules/history/selectors.ts
Original file line number Diff line number Diff line change
@@ -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<Hex[] | null> {
try {
const packed = await rpc.readContract<Hex>({ 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<void> {
const selectorsByFacet = new Map<Address, Promise<Hex[] | null>>();
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;
})));
}
25 changes: 25 additions & 0 deletions cli/src/modules/history/types.ts
Original file line number Diff line number Diff line change
@@ -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[];
};
8 changes: 8 additions & 0 deletions cli/src/modules/pipelineBuilder/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

/**
Expand Down Expand Up @@ -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,
Expand Down
22 changes: 22 additions & 0 deletions cli/src/pipelines/historyPipeline.ts
Original file line number Diff line number Diff line change
@@ -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<ComposeContext> {
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);
},
};
Loading
Loading