diff --git a/.changeset/compose-selectors.md b/.changeset/compose-selectors.md
new file mode 100644
index 00000000..83444612
--- /dev/null
+++ b/.changeset/compose-selectors.md
@@ -0,0 +1,5 @@
+---
+"@perfect-abstractions/compose-cli": patch
+---
+
+Add `compose selectors` to list and decode a deployed diamond's selectors by facet.
diff --git a/cli/src/comander.ts b/cli/src/comander.ts
index 899fecd4..cdfcd681 100644
--- a/cli/src/comander.ts
+++ b/cli/src/comander.ts
@@ -61,6 +61,12 @@ export function buildProgram(): Command {
.argument("
", "Diamond contract address")
.option("--chain ", "Chain key from compose.json", "local")
+ program
+ .command("selectors")
+ .description("List a deployed diamond's selectors by facet")
+ .argument("", "Diamond contract address")
+ .option("--chain ", "Chain key from compose.json", "local")
+
return program;
}
@@ -94,7 +100,7 @@ export function parseArgs(argv: string[]): { command: string; flags: Record 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];
diff --git a/cli/src/modules/inspect/module.ts b/cli/src/modules/inspect/module.ts
index 962d075f..57040b54 100644
--- a/cli/src/modules/inspect/module.ts
+++ b/cli/src/modules/inspect/module.ts
@@ -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 {
@@ -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 {
- 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);
diff --git a/cli/src/modules/inspect/selectorLoader.ts b/cli/src/modules/inspect/selectorLoader.ts
new file mode 100644
index 00000000..b9e0b28c
--- /dev/null
+++ b/cli/src/modules/inspect/selectorLoader.ts
@@ -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 {
+ 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 };
+}
diff --git a/cli/src/modules/pipelineBuilder/module.ts b/cli/src/modules/pipelineBuilder/module.ts
index df1788e9..f065bf81 100644
--- a/cli/src/modules/pipelineBuilder/module.ts
+++ b/cli/src/modules/pipelineBuilder/module.ts
@@ -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";
/**
@@ -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,
diff --git a/cli/src/modules/selectors/module.ts b/cli/src/modules/selectors/module.ts
new file mode 100644
index 00000000..211be623
--- /dev/null
+++ b/cli/src/modules/selectors/module.ts
@@ -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 {
+ const result = await loadDiamondSelectors(inspectAddress(ctx.param.address), diamond, chain);
+ ctx.state.selectors = { success: true, result, error: null };
+ showSelectors(result);
+ return ctx;
+ },
+};
diff --git a/cli/src/modules/selectors/output.ts b/cli/src/modules/selectors/output.ts
new file mode 100644
index 00000000..0c0703b0
--- /dev/null
+++ b/cli/src/modules/selectors/output.ts
@@ -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`);
+}
diff --git a/cli/src/pipelines/selectorsPipeline.ts b/cli/src/pipelines/selectorsPipeline.ts
new file mode 100644
index 00000000..c81b162b
--- /dev/null
+++ b/cli/src/pipelines/selectorsPipeline.ts
@@ -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 {
+ 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);
+ },
+};
diff --git a/cli/test/comander.test.ts b/cli/test/comander.test.ts
index 985ab631..facd8af8 100644
--- a/cli/test/comander.test.ts
+++ b/cli/test/comander.test.ts
@@ -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" },
+ });
+ });
+});
diff --git a/cli/test/modules/pipelineBuilder/pipelineBuilder.test.ts b/cli/test/modules/pipelineBuilder/pipelineBuilder.test.ts
index 5a957103..72ed2dd4 100644
--- a/cli/test/modules/pipelineBuilder/pipelineBuilder.test.ts
+++ b/cli/test/modules/pipelineBuilder/pipelineBuilder.test.ts
@@ -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 () => {
@@ -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();
+ }
+ });
});
diff --git a/cli/test/modules/selectors/output.test.ts b/cli/test/modules/selectors/output.test.ts
new file mode 100644
index 00000000..6b797141
--- /dev/null
+++ b/cli/test/modules/selectors/output.test.ts
@@ -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();
+ }
+ });
+});
diff --git a/cli/test/pipelines/selectorsPipeline/selectorsPipeline.test.ts b/cli/test/pipelines/selectorsPipeline/selectorsPipeline.test.ts
new file mode 100644
index 00000000..dd202064
--- /dev/null
+++ b/cli/test/pipelines/selectorsPipeline/selectorsPipeline.test.ts
@@ -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");
+ });
+});