Skip to content
Merged
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-selectors.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@perfect-abstractions/compose-cli": patch
---

Add `compose selectors` to list and decode a deployed diamond's selectors by facet.
8 changes: 7 additions & 1 deletion cli/src/comander.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,12 @@ export function buildProgram(): Command {
.argument("<address>", "Diamond contract address")
.option("--chain <chain-key>", "Chain key from compose.json", "local")

program
.command("selectors")
.description("List a deployed diamond's selectors by facet")
.argument("<address>", "Diamond contract address")
.option("--chain <chain-key>", "Chain key from compose.json", "local")

return program;
}

Expand Down Expand Up @@ -94,7 +100,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" && !flags.address) {
if ((command === "inspect" || command === "selectors") && !flags.address) {
flags.address = positionalArgs[0];
} else if (!flags.projectName) {
flags.projectName = positionalArgs[0];
Expand Down
26 changes: 3 additions & 23 deletions cli/src/modules/inspect/module.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,10 @@
import path from "node:path";
import { isAddress, type Address } from "viem";
import { ComposeContext } from "../../context/types";
import type { IDiamondAdapter } from "../../adapters/IDiamondAdapter/interface";
import type { ResolvedChainConfig } from "../../utils/chainConfig";
import { findFileAncestor } from "../../utils/files";
import { RPCAdapterError } from "../../adapters/IRPCAdapter/errors";
import { showInspect } from "./output";
import { toFacetInfo } from "./facetFormatter";
import { mergeProjectSignatures } from "./selectorDecoder";
import type { InspectResult, FacetInfo } from "./types";
import { loadDiamondSelectors } from "./selectorLoader";

/** Validate the command address before resolving chain dependencies. */
export function inspectAddress(value: unknown): Address {
Expand All @@ -34,28 +30,12 @@ export const InspectModule = {
* @param diamond - Resolved Diamond adapter for the selected chain.
* @param chain - Resolved chain identity for the result.
* @returns The updated context with inspect result stored in
* `ctx.state.inspect` as {@link ModuleState}\<{@link InspectResult}\>.
* `ctx.state.inspect`.
* @throws {RPCAdapterError} If the address is invalid or no contract code is
* found.
*/
async inspect(ctx: ComposeContext, diamond: IDiamondAdapter, chain: ResolvedChainConfig): Promise<ComposeContext> {
const diamondAddress = inspectAddress(ctx.param.address);
const rawFacets = await diamond.facets(diamondAddress);

const composePath = await findFileAncestor(process.cwd(), "compose.json");
const projectRoot = composePath ? path.dirname(composePath) : null;
if (projectRoot) {
await mergeProjectSignatures(projectRoot);
}

const facets: FacetInfo[] = rawFacets.map(toFacetInfo);

const result: InspectResult = {
diamond: diamondAddress,
chainKey: chain.chainKey,
chainId: chain.chainId,
facets,
};
const result = await loadDiamondSelectors(inspectAddress(ctx.param.address), diamond, chain);

ctx.state.inspect = { success: true, result, error: null };
showInspect(result);
Expand Down
24 changes: 24 additions & 0 deletions cli/src/modules/inspect/selectorLoader.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import path from "node:path";
import type { Address } from "viem";
import type { IDiamondAdapter } from "../../adapters/IDiamondAdapter/interface";
import type { ResolvedChainConfig } from "../../utils/chainConfig";
import { findFileAncestor } from "../../utils/files";
import { toFacetInfo } from "./facetFormatter";
import { mergeProjectSignatures } from "./selectorDecoder";
import type { FacetInfo, InspectResult } from "./types";

/** Loads and decodes a diamond's selectors for on-chain read commands. */
export async function loadDiamondSelectors(
address: Address,
diamond: IDiamondAdapter,
chain: ResolvedChainConfig,
): Promise<InspectResult> {
const rawFacets = await diamond.facets(address);
const composePath = await findFileAncestor(process.cwd(), "compose.json");
if (composePath) {
await mergeProjectSignatures(path.dirname(composePath));
}

const facets: FacetInfo[] = rawFacets.map(toFacetInfo);
return { diamond: address, chainKey: chain.chainKey, chainId: chain.chainId, facets };
}
8 changes: 8 additions & 0 deletions cli/src/modules/pipelineBuilder/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { CatalogPipeline } from "../../pipelines/catalogPipeline";
import { BuildPipeline } from "../../pipelines/buildPipeline";
import { RPCPipeline } from "../../pipelines/rpcPipeline";
import { InspectPipeline } from "../../pipelines/inspectPipeline";
import { SelectorsPipeline } from "../../pipelines/selectorsPipeline";
import { ValidatePipeline } from "../../pipelines/validatePipeline";

/**
Expand Down Expand Up @@ -76,6 +77,13 @@ export const PipelineBuilderModule = {
error: null,
};
return InspectPipeline.execute(ctx);
case "selectors":
ctx.state.commandSelected = {
success: true,
result: { command: ctx.param.command, address: ctx.param.address, chain: ctx.param.chain },
error: null,
};
return SelectorsPipeline.execute(ctx);
default:
ctx.state.commandRouting = {
success: false,
Expand Down
15 changes: 15 additions & 0 deletions cli/src/modules/selectors/module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import type { IDiamondAdapter } from "../../adapters/IDiamondAdapter/interface";
import type { ComposeContext } from "../../context/types";
import { inspectAddress } from "../inspect/module";
import { loadDiamondSelectors } from "../inspect/selectorLoader";
import type { ResolvedChainConfig } from "../../utils/chainConfig";
import { showSelectors } from "./output";

export const SelectorsModule = {
async list(ctx: ComposeContext, diamond: IDiamondAdapter, chain: ResolvedChainConfig): Promise<ComposeContext> {
const result = await loadDiamondSelectors(inspectAddress(ctx.param.address), diamond, chain);
ctx.state.selectors = { success: true, result, error: null };
showSelectors(result);
return ctx;
},
};
23 changes: 23 additions & 0 deletions cli/src/modules/selectors/output.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { cyan, dim, green, yellow } from "../../utils/terminal";
import type { InspectResult } from "../inspect/types";

/** Prints a selector-first view grouped by facet address. */
export function showSelectors(result: InspectResult): void {
console.log(`\n${cyan("Diamond Selectors")}\n`);
console.log(` Diamond: ${result.diamond}`);
console.log(` Chain: ${result.chainKey} (${result.chainId})`);
console.log();

for (const facet of result.facets) {
console.log(` ${facet.address}`);
if (facet.selectors.length === 0) {
console.log(` ${dim("No selectors")}`);
}
for (const { selector, signature } of facet.selectors) {
const unknown = signature.toLowerCase() === selector.toLowerCase();
console.log(` ${dim(selector)} ${unknown ? yellow("Unknown signature") : green(signature)}`);
}
console.log();
}
if (result.facets.length === 0) console.log(` ${dim("No facets found")}\n`);
}
22 changes: 22 additions & 0 deletions cli/src/pipelines/selectorsPipeline.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { ComposeContext } from "../context/types";
import { inspectAddress } from "../modules/inspect/module";
import { SelectorsModule } from "../modules/selectors/module";
import { DependencyKey } from "../resolver/dependencyKey";
import { DependencyResolver } from "../resolver/dependencyResolver";
import { resolveChainConfig } from "../utils/chainConfig";

/** Focused on-chain selector listing pipeline. */
export const SelectorsPipeline = {
async execute(ctx: ComposeContext): Promise<ComposeContext> {
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.Diamond,
params: { chainKey: chain.chainKey },
}]);
const diamond = dependencies[DependencyKey.Diamond];
if (!diamond) throw new Error("Diamond dependency was not resolved");
return SelectorsModule.list(ctx, diamond, chain);
},
};
16 changes: 16 additions & 0 deletions cli/test/comander.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,3 +105,19 @@ describe("inspect command", () => {
});

});

describe("selectors command", () => {
it("parses address and chain", () => {
expect(parseArgs(["node", "compose", "selectors", "0x0000000000000000000000000000000000000001", "--chain", "sepolia"])).toEqual({
command: "selectors",
flags: { address: "0x0000000000000000000000000000000000000001", chain: "sepolia" },
});
});

it("defaults to local", () => {
expect(parseArgs(["node", "compose", "selectors", "0x0000000000000000000000000000000000000001"])).toEqual({
command: "selectors",
flags: { address: "0x0000000000000000000000000000000000000001", chain: "local" },
});
});
});
16 changes: 16 additions & 0 deletions cli/test/modules/pipelineBuilder/pipelineBuilder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest";
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";

describe("PipelineBuilderModule", () => {
it("routes the validate command to ValidatePipeline", async () => {
Expand All @@ -19,4 +20,19 @@ describe("PipelineBuilderModule", () => {
execute.mockRestore();
}
});

it("routes the selectors command to SelectorsPipeline", async () => {
const ctx = Context.create();
ctx.param = { command: "selectors", address: "0x0000000000000000000000000000000000000001", chain: "local" };
const execute = vi.spyOn(SelectorsPipeline, "execute").mockResolvedValue(ctx);

try {
const result = await PipelineBuilderModule.route(ctx);
expect(result).toBe(ctx);
expect(execute).toHaveBeenCalledWith(ctx);
expect(ctx.state.commandSelected).toMatchObject({ success: true, result: ctx.param });
} finally {
execute.mockRestore();
}
});
});
29 changes: 29 additions & 0 deletions cli/test/modules/selectors/output.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { describe, expect, it, vi } from "vitest";
import { showSelectors } from "../../../src/modules/selectors/output";

describe("showSelectors", () => {
it("groups signatures by facet and marks unknown selectors", () => {
const log = vi.spyOn(console, "log").mockImplementation(() => {});
try {
showSelectors({
diamond: "0x0000000000000000000000000000000000000001",
chainKey: "local",
chainId: 31337,
facets: [{
address: "0x0000000000000000000000000000000000000002",
index: 0,
selectors: [
{ selector: "0x7a0ed627", signature: "facets()" },
{ selector: "0xdeadbeef", signature: "0xdeadbeef" },
],
}],
});
const output = log.mock.calls.map(([line]) => line).join("\n");
expect(output).toContain("facets()");
expect(output).toContain("Unknown signature");
expect(output).toContain("0xdeadbeef");
} finally {
log.mockRestore();
}
});
});
66 changes: 66 additions & 0 deletions cli/test/pipelines/selectorsPipeline/selectorsPipeline.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
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(),
showSelectors: vi.fn(),
}));

vi.mock("../../../src/utils/chainConfig", () => ({ resolveChainConfig: mocks.resolveChainConfig }));
vi.mock("../../../src/resolver/dependencyResolver", () => ({ DependencyResolver: { resolve: mocks.resolve } }));
vi.mock("../../../src/modules/selectors/output", () => ({ showSelectors: mocks.showSelectors }));

import { SelectorsPipeline } from "../../../src/pipelines/selectorsPipeline";

const address = "0x0000000000000000000000000000000000000001";

describe("SelectorsPipeline", () => {
it("rejects an invalid address before resolving the chain", async () => {
const ctx = Context.create();
ctx.param = { address: "invalid" };
await expect(SelectorsPipeline.execute(ctx)).rejects.toMatchObject({ code: "RPC_INVALID_ADDRESS" });
expect(mocks.resolveChainConfig).not.toHaveBeenCalled();
});

it("propagates an unknown chain error before resolving the adapter", async () => {
mocks.resolveChainConfig.mockRejectedValueOnce(new Error("Unknown chain: missing"));
const ctx = Context.create();
ctx.param = { address, chain: "missing" };

await expect(SelectorsPipeline.execute(ctx)).rejects.toThrow("Unknown chain: missing");
expect(mocks.resolve).not.toHaveBeenCalled();
});

it("queries facets and decodes selectors", async () => {
const facets = vi.fn().mockResolvedValue([
{ facet: "0x0000000000000000000000000000000000000002", functionSelectors: ["0x7a0ed627", "0xdeadbeef"] },
]);
mocks.resolveChainConfig.mockResolvedValue({ chainKey: "sepolia", rpcUrl: "https://rpc.example", chainId: 11155111 });
mocks.resolve.mockResolvedValue({ [DependencyKey.Diamond]: { facets } });

const ctx = Context.create();
ctx.param = { address, chain: "sepolia" };
const result = await SelectorsPipeline.execute(ctx);

expect(mocks.resolve).toHaveBeenCalledWith([{ key: DependencyKey.Diamond, params: { chainKey: "sepolia" } }]);
expect(facets).toHaveBeenCalledWith(address);
expect(result.state.selectors).toMatchObject({
success: true,
result: { facets: [{ selectors: [
{ selector: "0x7a0ed627", signature: "facets()" },
{ selector: "0xdeadbeef", signature: "0xdeadbeef" },
] }] },
});
expect(mocks.showSelectors).toHaveBeenCalledOnce();
});

it("propagates RPC failures", async () => {
mocks.resolveChainConfig.mockResolvedValue({ chainKey: "local", rpcUrl: "http://localhost", chainId: 31337 });
mocks.resolve.mockResolvedValue({ [DependencyKey.Diamond]: { facets: vi.fn().mockRejectedValue(new Error("RPC unavailable")) } });
const ctx = Context.create();
ctx.param = { address };
await expect(SelectorsPipeline.execute(ctx)).rejects.toThrow("RPC unavailable");
});
});
Loading