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
6 changes: 6 additions & 0 deletions .changeset/tiny-animals-agree.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@perfect-abstractions/compose-cli": patch
---

add rpc adapter using viem, add diamond inspect command querying deployed diamonds

1 change: 1 addition & 0 deletions cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
"@inquirer/select": "5.2.1",
"@perfect-abstractions/compose": "0.0.4",
"commander": "^13.1.0",
"dotenv": "^16.4.7",
"fs-extra": "^11.3.3",
"picocolors": "^1.1.1",
"viem": "^2.52.2"
Expand Down
20 changes: 20 additions & 0 deletions cli/src/adapters/interface/IRPCAdapter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import type { Address, Abi, Hex } from "viem";
import type { ReadContractParameters } from "viem";

/** Optional behavior for a contract read. */
export type RPCReadContractOptions = {
/** Verify that bytecode exists at the target before reading. */
verifyCode?: boolean;
};

/** Generic read-only RPC boundary used by CLI modules. */
export interface IRPCAdapter {
/** Read a view or pure contract function and return its decoded value. */
readContract<T>(
parameters: ReadContractParameters<Abi>,
options?: RPCReadContractOptions,
): Promise<T>;

/** Return deployed bytecode, or undefined when the account has no code. */
getCode(address: Address): Promise<Hex | undefined>;
}
111 changes: 111 additions & 0 deletions cli/src/adapters/rpc/adapter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import {
createPublicClient,
defineChain,
getAddress,
http,
type Address,
type Chain,
type Hex,
type ReadContractParameters,
} from "viem";
import type { IRPCAdapter, RPCReadContractOptions } from "../interface/IRPCAdapter";
import { requestError, RPCAdapterError } from "./errors";
import { retryRPC } from "./retry";
import type { RPCAdapterOptions } from "./types";
import { validateOptions } from "./validation";

/**
* Creates the minimal viem chain definition needed by a custom RPC endpoint.
* @param chainId Expected EVM chain ID.
* @param rpcUrl HTTP or HTTPS JSON-RPC endpoint.
* @returns A viem chain definition bound to the endpoint.
*/
function chainFor(chainId: number, rpcUrl: string): Chain {
return defineChain({
id: chainId,
name: `Compose chain ${chainId}`,
nativeCurrency: { name: "Native", symbol: "NATIVE", decimals: 18 },
rpcUrls: { default: { http: [rpcUrl] } },
});
}

/**
* Creates an isolated, chain-specific read-only RPC adapter.
*
* Construction performs a chain-ID probe, so a successfully created adapter
* has already verified that its endpoint points at the requested network.
* Requests use the adapter retry policy rather than viem transport retries.
* @param options RPC endpoint and expected chain configuration.
* @returns A configured read-only RPC adapter.
* @throws {RPCAdapterError} If configuration is invalid, the endpoint is
* unreachable, unauthorized, or reports a different chain ID.
*/
export async function createRPCAdapter(options: RPCAdapterOptions): Promise<IRPCAdapter> {
validateOptions(options);
const chain = chainFor(options.chainId, options.rpcUrl);
const client = createPublicClient({ chain, transport: http(options.rpcUrl, { retryCount: 0 }) });

let endpointChainId: number;
try {
endpointChainId = await retryRPC(() => client.getChainId());
} catch (error) {
throw error instanceof RPCAdapterError ? error : requestError("getChainId", options.chainId, error);
}
if (endpointChainId !== options.chainId) {
throw new RPCAdapterError(
"RPC_CHAIN_ID_MISMATCH",
`RPC endpoint reports chain ${endpointChainId}, expected chain ${options.chainId}`,
{ operation: "getChainId", chainId: options.chainId },
);
}

/**
* Returns deployed bytecode at an address, or no code for an EOA/empty account.
* @param address Address to inspect.
* @returns Deployed bytecode, or `undefined` when no bytecode exists.
* @throws {RPCAdapterError} If the RPC request fails.
*/
async function getCode(address: Address): Promise<Hex | undefined> {
try {
return await retryRPC(() => client.getCode({ address }));
} catch (error) {
throw requestError("getCode", options.chainId, error);
}
}

/**
* Reads and decodes a view/pure contract function through the configured RPC.
* @param parameters Contract address, ABI, function name, and arguments.
* @param readOptions Optional contract-code verification settings.
* @returns The decoded contract result.
* @throws {RPCAdapterError} If verification or the RPC request fails.
*/
async function readContract<T>(parameters: ReadContractParameters, readOptions?: RPCReadContractOptions): Promise<T> {
try {
if (readOptions?.verifyCode) {
if (!parameters.address) {
throw new RPCAdapterError("RPC_INVALID_CONFIGURATION", "Contract address is required when verifyCode is enabled", {
operation: "readContract",
chainId: options.chainId,
});
}
const code = await getCode(getAddress(parameters.address));
if (!code || code === "0x") {
throw new RPCAdapterError("RPC_CONTRACT_NOT_FOUND", `No contract code found at ${parameters.address}`, {
operation: "readContract",
chainId: options.chainId,
});
}
}
return await retryRPC(() => client.readContract(parameters)) as T;
} catch (error) {
if (error instanceof RPCAdapterError) throw error;
throw requestError("readContract", options.chainId, error);
}
}

return {
readContract,
getCode,
};
}
55 changes: 55 additions & 0 deletions cli/src/adapters/rpc/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { statusCode } from "./utils";

/** Stable error categories emitted by the RPC adapter. */
export type RPCErrorCode =
| "RPC_INVALID_CONFIGURATION"
| "RPC_ENV_VAR_MISSING"
| "RPC_CHAIN_NOT_FOUND"
| "RPC_CHAIN_ID_MISMATCH"
| "RPC_UNAUTHORIZED"
| "RPC_CONTRACT_NOT_FOUND"
| "RPC_REQUEST_FAILED"
| "RPC_INVALID_ADDRESS";

/**
* Diagnostic error thrown at the RPC/configuration boundary.
*
* The stable code is intended for programmatic handling; operation and chain
* ID provide context, while the original failure remains available as cause.
* @param code Stable adapter error category.
* @param message Human-readable diagnostic message.
* @param options Operation context and optional chain ID or original cause.
*/
export class RPCAdapterError extends Error {
/** Stable category used by callers and CLI error handling. */
readonly code: RPCErrorCode;
/** RPC or configuration operation that produced the error. */
readonly operation: string;
/** Chain involved in the operation, when one was resolved. */
readonly chainId?: number;

constructor(
code: RPCErrorCode,
message: string,
options: { operation: string; chainId?: number; cause?: unknown },
) {
super(message, { cause: options.cause });
this.name = "RPCAdapterError";
this.code = code;
this.operation = options.operation;
this.chainId = options.chainId;
}
}

/**
* Converts an unknown transport failure into a stable adapter error.
* @param operation RPC operation that failed.
* @param chainId Chain on which the operation was attempted.
* @param error Original transport or client failure.
* @returns A normalized adapter error with a stable error code.
*/
export function requestError(operation: string, chainId: number, error: unknown): RPCAdapterError {
const status = statusCode(error);
const code = status === 401 ? "RPC_UNAUTHORIZED" : "RPC_REQUEST_FAILED";
return new RPCAdapterError(code, `RPC ${operation} failed on chain ${chainId}`, { operation, chainId, cause: error });
}
82 changes: 82 additions & 0 deletions cli/src/adapters/rpc/retry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { withRetry } from "viem";
import { errorChain, errorText, statusCode } from "./utils";

/** Maximum number of retries after the initial RPC request. */
const RETRY_COUNT = 2;
/** Fallback delays, in milliseconds, for successive retries. */
const RETRY_DELAYS = [100, 200] as const;
/** Maximum delay accepted from a server Retry-After header. */
const RETRY_AFTER_CAP = 2_000;

/**
* Reads a Retry-After header and converts it to a bounded delay.
* @param error Error that may contain response headers.
* @returns Retry delay in milliseconds, or `undefined` when absent or invalid.
*/
function retryAfterMs(error: unknown): number | undefined {
for (const item of errorChain(error)) {
if (!item || typeof item !== "object") continue;
const headers = (item as { headers?: Headers; response?: { headers?: Headers } }).headers
?? (item as { response?: { headers?: Headers } }).response?.headers;
const value = headers?.get("retry-after");
if (!value) continue;

const seconds = Number(value);
if (Number.isFinite(seconds)) return Math.min(Math.max(seconds * 1_000, 0), RETRY_AFTER_CAP);

const timestamp = Date.parse(value);
if (!Number.isNaN(timestamp)) return Math.min(Math.max(timestamp - Date.now(), 0), RETRY_AFTER_CAP);
}
return undefined;
}

/**
* Identifies failures that are safe to retry without changing chain state.
* @param error Error produced by the failed RPC request.
* @returns `true` when the failure is considered transient.
*/
function isTransientError(error: unknown): boolean {
const status = statusCode(error);
if (status === 401) return false;
if (status === 429 || status === 502 || status === 503) return true;

for (const item of errorChain(error)) {
if (!item || typeof item !== "object") continue;
const code = (item as { code?: unknown }).code;
if (code === 429 || code === "ECONNRESET" || code === "ETIMEDOUT") return true;
}

const text = errorText(error);
return [
"econnreset",
"connection reset",
"fetch failed",
"network error",
"etimedout",
"econnrefused",
"socket hang up",
].some((term) => text.includes(term));
}

/**
* Selects the server-provided delay or the adapter's backoff schedule.
* @param input Retry attempt count and failure information.
* @returns Delay before the next attempt, in milliseconds.
*/
function retryDelay({ count, error }: { count: number; error: Error }): number {
return retryAfterMs(error) ?? RETRY_DELAYS[Math.min(count, RETRY_DELAYS.length - 1)] ?? RETRY_DELAYS.at(-1)!;
}

/**
* Executes an RPC request with bounded retries for transient failures only.
* @param request Asynchronous RPC operation to execute.
* @returns The successful request result.
* @throws The final request error when retries are exhausted or the failure is permanent.
*/
export async function retryRPC<T>(request: () => Promise<T>): Promise<T> {
return withRetry(request, {
retryCount: RETRY_COUNT,
delay: retryDelay,
shouldRetry: ({ error }) => isTransientError(error),
});
}
7 changes: 7 additions & 0 deletions cli/src/adapters/rpc/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/** Configuration used to bind an adapter to one RPC endpoint and chain. */
export type RPCAdapterOptions = {
/** HTTP or HTTPS JSON-RPC endpoint. */
rpcUrl: string;
/** Expected EVM chain ID reported by the endpoint. */
chainId: number;
};
41 changes: 41 additions & 0 deletions cli/src/adapters/rpc/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/**
* Returns an error and each distinct cause in its cause chain.
* @param error Error whose causes should be traversed.
* @returns The error and its reachable cause values in traversal order.
*/
export function errorChain(error: unknown): unknown[] {
const errors: unknown[] = [];
let current = error;
while (current && typeof current === "object" && !errors.includes(current)) {
errors.push(current);
current = "cause" in current ? (current as { cause?: unknown }).cause : undefined;
}
return errors;
}

/**
* Combines error messages across an error's cause chain for matching.
* @param error Error whose messages should be combined.
* @returns Lowercase text containing all chained error messages.
*/
export function errorText(error: unknown): string {
return errorChain(error)
.map((item) => (item instanceof Error ? item.message : String(item)))
.join(" ")
.toLowerCase();
}

/**
* Finds an HTTP/status code in an error or one of its causes.
* @param error Error that may contain a status code.
* @returns The first numeric status code found, or `undefined`.
*/
export function statusCode(error: unknown): number | undefined {
for (const item of errorChain(error)) {
if (!item || typeof item !== "object") continue;
const value = item as { status?: unknown; statusCode?: unknown; response?: { status?: unknown } };
const status = value.status ?? value.statusCode ?? value.response?.status;
if (typeof status === "number") return status;
}
return undefined;
}
21 changes: 21 additions & 0 deletions cli/src/adapters/rpc/validation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { RPCAdapterError } from "./errors";
import type { RPCAdapterOptions } from "./types";

/**
* Validates an RPC endpoint and expected chain before client construction.
* @param options RPC endpoint and expected chain configuration.
* @returns Nothing when the configuration is valid.
* @throws {RPCAdapterError} If the URL or chain ID is invalid.
*/
export function validateOptions({ rpcUrl, chainId }: RPCAdapterOptions): void {
if (!rpcUrl) throw new RPCAdapterError("RPC_INVALID_CONFIGURATION", "RPC URL is required", { operation: "create" });
try {
const url = new URL(rpcUrl);
if (url.protocol !== "http:" && url.protocol !== "https:") throw new Error("RPC URL must use HTTP or HTTPS");
} catch (error) {
throw new RPCAdapterError("RPC_INVALID_CONFIGURATION", "RPC URL must be a valid HTTP or HTTPS URL", { operation: "create", cause: error });
}
if (!Number.isSafeInteger(chainId) || chainId <= 0) {
throw new RPCAdapterError("RPC_INVALID_CONFIGURATION", "chainId must be a positive integer", { operation: "create", cause: chainId });
}
}
20 changes: 18 additions & 2 deletions cli/src/comander.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,18 @@ export function buildProgram(): Command {
.command("build")
.description("Compile the project if artifacts are stale or missing")

program
.command("rpc")
.description("Check RPC connectivity and optionally inspect contract bytecode")
.option("--chain <chain-key>", "Chain key from compose.json", "local")
.option("--address <address>", "Contract address to check or inspect")

program
.command("inspect")
.description("Inspect a deployed diamond's facets and selectors via Loupe")
.argument("<address>", "Diamond contract address")
.option("--chain <chain-key>", "Chain key from compose.json", "local")

return program;
}

Expand Down Expand Up @@ -75,8 +87,12 @@ export function parseArgs(argv: string[]): { command: string; flags: Record<stri
const positionalArgs = commandInstance.args.filter(
(arg): arg is string => typeof arg === "string" && arg !== command,
);
if (positionalArgs.length > 0 && !flags.projectName) {
flags.projectName = positionalArgs[0];
if (positionalArgs.length > 0) {
if (command === "inspect" && !flags.address) {
flags.address = positionalArgs[0];
} else if (!flags.projectName) {
flags.projectName = positionalArgs[0];
}
}

return { command, flags };
Expand Down
Loading
Loading