diff --git a/.changeset/bright-blocks-validate.md b/.changeset/bright-blocks-validate.md new file mode 100644 index 00000000..bb23a1ae --- /dev/null +++ b/.changeset/bright-blocks-validate.md @@ -0,0 +1,5 @@ +--- +"@perfect-abstractions/compose-cli": minor +--- + +Validate deployed diamond facet bytecode against the source-derived virtual storage layout. diff --git a/cli/package.json b/cli/package.json index c0a2da10..6f59d5c2 100644 --- a/cli/package.json +++ b/cli/package.json @@ -36,6 +36,7 @@ "@inquirer/select": "5.2.1", "@perfect-abstractions/compose": "0.0.6", "commander": "^13.1.0", + "compose-bytecode-validator": "0.1.2", "dotenv": "^16.4.7", "fs-extra": "^11.3.3", "picocolors": "^1.1.1", diff --git a/cli/src/adapters/IBytecodeValidatorAdapter/adapter.ts b/cli/src/adapters/IBytecodeValidatorAdapter/adapter.ts new file mode 100644 index 00000000..946932ef --- /dev/null +++ b/cli/src/adapters/IBytecodeValidatorAdapter/adapter.ts @@ -0,0 +1,54 @@ +import { validateStorage } from "compose-bytecode-validator"; +import type { + BytecodeValidationInput, + BytecodeValidationReport, + BytecodeStorageLocation, + IBytecodeValidatorAdapter, +} from "./interface"; + +const PLAIN_BYTE_ARRAY = /Plain\(\[([\d,\s]+)\]\)/g; + +export function formatSymbolicStoragePath(path: string): string { + return path.replace(PLAIN_BYTE_ARRAY, (match, values: string) => { + const bytes = values.split(",").map((value) => Number(value.trim())); + if (bytes.length !== 32 || bytes.some((byte) => !Number.isInteger(byte) || byte < 0 || byte > 255)) { + return match; + } + return `Plain(0x${bytes.map((byte) => byte.toString(16).padStart(2, "0")).join("")})`; + }); +} + +function normalizeLocation(location: BytecodeStorageLocation): BytecodeStorageLocation { + return { + ...location, + symbolicPath: formatSymbolicStoragePath(location.symbolicPath), + }; +} + +function normalizeReport(report: BytecodeValidationReport): BytecodeValidationReport { + return { + collisions: report.collisions.map((collision) => ({ + ...collision, + location: normalizeLocation(collision.location), + })), + validatedVariables: report.validatedVariables.map((variable) => ({ + ...variable, + location: normalizeLocation(variable.location), + })), + uncertainScopes: report.uncertainScopes.map((scope) => ({ + ...scope, + location: normalizeLocation(scope.location), + })), + diagnostics: report.diagnostics.map((diagnostic) => ({ + ...diagnostic, + symbolicPath: formatSymbolicStoragePath(diagnostic.symbolicPath), + })), + delegatecallWarnings: report.delegatecallWarnings, + }; +} + +export const BytecodeValidatorAdapter: IBytecodeValidatorAdapter = { + validate(input: BytecodeValidationInput): BytecodeValidationReport { + return normalizeReport(validateStorage(input) as BytecodeValidationReport); + }, +}; diff --git a/cli/src/adapters/IBytecodeValidatorAdapter/interface.ts b/cli/src/adapters/IBytecodeValidatorAdapter/interface.ts new file mode 100644 index 00000000..f30d9965 --- /dev/null +++ b/cli/src/adapters/IBytecodeValidatorAdapter/interface.ts @@ -0,0 +1,82 @@ +import type { Hex } from "viem"; + +export type BytecodeStorageLocation = { + slot: string; + offset: number; + selector: string; + pc?: number; + symbolicPath: string; +}; + +export type BytecodeStorageCollision = { + location: BytecodeStorageLocation; + virtualPath: string; + sourceNames?: string[]; + expectedType: string; + observedType: string; + reason: string; +}; + +export type BytecodeValidatedVariable = { + location: BytecodeStorageLocation; + virtualPath: string; + sourceNames?: string[]; + expectedType: string; + observedType: string; +}; + +export type BytecodeUncertainScope = { + location: BytecodeStorageLocation; + virtualPath?: string; + sourceNames?: string[]; + reason: string; +}; + +export type BytecodeStorageDiagnostic = { + selector: string; + pc?: number; + symbolicPath: string; + message: string; +}; + +export type BytecodeDelegateCallWarning = { + callerSelector: string; + pc: number; + target: string; + selector?: string; + reason: string; +}; + +export type BytecodeValidationReport = { + collisions: BytecodeStorageCollision[]; + validatedVariables: BytecodeValidatedVariable[]; + uncertainScopes: BytecodeUncertainScope[]; + diagnostics: BytecodeStorageDiagnostic[]; + delegatecallWarnings: BytecodeDelegateCallWarning[]; +}; + +export type BytecodeVirtualStorageRecord = { + id: string; + virtualPath: string; + parentVirtualPath?: string; + kind: "normal" | "immutable"; + codeWidth: number; + layout: string[]; + serializedLayout: string[]; + slots: number[][]; + source: "erc8042" | "erc7201" | "slot-assignment" | "implicit-state"; + sourceName: string; + contractName: string; + structName?: string; + diamondName?: string; +}; + +export type BytecodeValidationInput = { + bytecode: Hex; + virtualStorageLayout: { records: BytecodeVirtualStorageRecord[] }; +}; + +/** Stable CLI boundary around the external WASM validator package. */ +export interface IBytecodeValidatorAdapter { + validate(input: BytecodeValidationInput): BytecodeValidationReport; +} diff --git a/cli/src/adapters/IBytecodeValidatorAdapter/package.d.ts b/cli/src/adapters/IBytecodeValidatorAdapter/package.d.ts new file mode 100644 index 00000000..ab551e9a --- /dev/null +++ b/cli/src/adapters/IBytecodeValidatorAdapter/package.d.ts @@ -0,0 +1,3 @@ +declare module "compose-bytecode-validator" { + export function validateStorage(input: unknown): unknown; +} diff --git a/cli/src/adapters/IRPCAdapter/adapter.ts b/cli/src/adapters/IRPCAdapter/adapter.ts index 519c6404..4e3980bd 100644 --- a/cli/src/adapters/IRPCAdapter/adapter.ts +++ b/cli/src/adapters/IRPCAdapter/adapter.ts @@ -65,9 +65,17 @@ export async function createRPCAdapter(options: RPCAdapterOptions): Promise { + async function getBlockNumber(): Promise { try { - return await retryRPC(() => client.getCode({ address })); + return await retryRPC(() => client.getBlockNumber()); + } catch (error) { + throw requestError("getBlockNumber", options.chainId, error); + } + } + + async function getCode(address: Address, blockNumber?: bigint): Promise { + try { + return await retryRPC(() => client.getCode({ address, blockNumber })); } catch (error) { throw requestError("getCode", options.chainId, error); } @@ -89,7 +97,7 @@ export async function createRPCAdapter(options: RPCAdapterOptions): Promise; + /** Read a view or pure contract function and return its decoded value. */ readContract( parameters: ReadContractParameters, @@ -16,5 +19,5 @@ export interface IRPCAdapter { ): Promise; /** Return deployed bytecode, or undefined when the account has no code. */ - getCode(address: Address): Promise; + getCode(address: Address, blockNumber?: bigint): Promise; } diff --git a/cli/src/comander.ts b/cli/src/comander.ts index f7f73773..899fecd4 100644 --- a/cli/src/comander.ts +++ b/cli/src/comander.ts @@ -57,7 +57,7 @@ export function buildProgram(): Command { program .command("inspect") - .description("Inspect a deployed diamond's facets and selectors via Loupe") + .description("Inspect a deployed diamond's facets and selectors") .argument("
", "Diamond contract address") .option("--chain ", "Chain key from compose.json", "local") diff --git a/cli/src/modules/bytecodeValidation/module.ts b/cli/src/modules/bytecodeValidation/module.ts new file mode 100644 index 00000000..4c2b0667 --- /dev/null +++ b/cli/src/modules/bytecodeValidation/module.ts @@ -0,0 +1,207 @@ +import { getAddress, type Address } from "viem"; +import type { + BytecodeValidationReport, + IBytecodeValidatorAdapter, +} from "../../adapters/IBytecodeValidatorAdapter/interface"; +import type { IRPCAdapter } from "../../adapters/IRPCAdapter/interface"; +import type { ComposeContext, ModuleState } from "../../context/types"; +import { DIAMOND_INSPECT_ABI } from "../inspect/diamondInspectAbi"; +import type { VirtualStorageLayoutRecord } from "../validation/types"; +import type { + BytecodeValidationSummary, + DeploymentBytecodeValidationResult, +} from "./types"; + +type DeploymentDependencies = { + rpc: IRPCAdapter; + validator: IBytecodeValidatorAdapter; +}; + +type InspectedFacet = { + facet: Address; + functionSelectors: `0x${string}`[]; +}; + +function storageRecords(ctx: ComposeContext): VirtualStorageLayoutRecord[] { + return (ctx.param.virtualStorageRecords as VirtualStorageLayoutRecord[] | undefined) ?? []; +} + +function validatorRecords(records: VirtualStorageLayoutRecord[]) { + return records.map((record) => ({ + ...record, + parentVirtualPath: record.parentVirtualPath ?? undefined, + structName: record.structName ?? undefined, + })); +} + +function sourceNamesForPath( + virtualPath: string | undefined, + records: VirtualStorageLayoutRecord[], +): string[] { + if (!virtualPath) return []; + const matches = records.filter((record) => + virtualPath === record.virtualPath || virtualPath.startsWith(`${record.virtualPath}.`)); + const longestPath = Math.max(0, ...matches.map((record) => record.virtualPath.length)); + return [...new Set( + matches + .filter((record) => record.virtualPath.length === longestPath) + .map((record) => record.sourceName), + )]; +} + +function addVslSourceNames( + report: BytecodeValidationReport, + records: VirtualStorageLayoutRecord[], +): BytecodeValidationReport { + return { + ...report, + collisions: report.collisions.map((collision) => ({ + ...collision, + sourceNames: sourceNamesForPath(collision.virtualPath, records), + })), + validatedVariables: report.validatedVariables.map((variable) => ({ + ...variable, + sourceNames: sourceNamesForPath(variable.virtualPath, records), + })), + uncertainScopes: report.uncertainScopes.map((scope) => ({ + ...scope, + sourceNames: sourceNamesForPath(scope.virtualPath, records), + })), + }; +} + +/** Validates all current facets of one deployed diamond at one pinned block. */ +export const BytecodeValidationModule = { + async validateDeployment( + ctx: ComposeContext, + dependencies: DeploymentDependencies, + ): Promise { + const diamondName = String(ctx.param.diamondName); + const chainKey = String(ctx.param.chainKey); + const diamondAddress = getAddress(String(ctx.param.diamondAddress)); + const blockNumber = await dependencies.rpc.getBlockNumber(); + let rawFacets: InspectedFacet[]; + try { + rawFacets = await dependencies.rpc.readContract({ + address: diamondAddress, + abi: DIAMOND_INSPECT_ABI, + functionName: "facets", + blockNumber, + }, { verifyCode: true }); + } catch (error) { + const message = error instanceof Error ? error.message : "Diamond introspection failed."; + ctx.state.bytecodeDeploymentValidation = { + success: true, + result: { + diamondName, + chainKey, + diamondAddress, + blockNumber, + complete: false, + introspectionError: message, + facets: [], + }, + error: null, + }; + return ctx; + } + const facetAddresses = [...new Set(rawFacets.map((facet) => getAddress(facet.facet)))]; + const records = validatorRecords(storageRecords(ctx)); + const facets = []; + + for (const address of facetAddresses) { + try { + const bytecode = await dependencies.rpc.getCode(address, blockNumber); + if (!bytecode || bytecode === "0x") { + facets.push({ + address, + report: null, + uncertain: `Runtime bytecode was unavailable at pinned block ${blockNumber}; this facet was not validated.`, + error: null, + }); + continue; + } + + facets.push({ + address, + report: addVslSourceNames( + dependencies.validator.validate({ + bytecode, + virtualStorageLayout: { records }, + }), + storageRecords(ctx), + ), + uncertain: null, + error: null, + }); + } catch (error) { + facets.push({ + address, + report: null, + uncertain: null, + error: error instanceof Error ? error.message : "Facet bytecode validation failed.", + }); + } + } + + const result: DeploymentBytecodeValidationResult = { + diamondName, + chainKey, + diamondAddress, + blockNumber, + complete: facets.every((facet) => facet.uncertain === null), + introspectionError: null, + facets, + }; + const collisions = facets.flatMap((facet) => facet.report?.collisions ?? []); + const facetErrors = facets.filter((facet) => facet.error); + const success = collisions.length === 0 && facetErrors.length === 0; + ctx.state.bytecodeDeploymentValidation = { + success, + result, + error: success + ? null + : { + code: collisions.length > 0 + ? "BYTECODE_STORAGE_COLLISION_DETECTED" + : "BYTECODE_FACET_VALIDATION_FAILED", + message: collisions.length > 0 + ? "Deployed facet bytecode contradicts the diamond storage layout." + : "One or more deployed facets could not be validated.", + nativeError: null, + }, + }; + return ctx; + }, + + mergeDeployments( + ctx: ComposeContext, + children: ComposeContext[], + skipped = false, + ): ComposeContext { + const deployments = children.map((child) => + child.state.bytecodeDeploymentValidation as ModuleState); + const failed = deployments.find((deployment) => !deployment.success); + const result: BytecodeValidationSummary = { + skipped, + complete: deployments.every((deployment) => deployment.result?.complete !== false), + deployments: deployments.flatMap((deployment) => deployment.result ? [deployment.result] : []), + failures: children.flatMap((child, index) => { + const deployment = deployments[index]; + if (deployment.success || deployment.result) return []; + return [{ + diamondName: String(child.param.diamondName), + chainKey: String(child.param.chainKey), + diamondAddress: String(child.param.diamondAddress), + message: deployment.error?.message ?? "Bytecode validation failed.", + }]; + }), + }; + ctx.state.bytecodeValidation = { + success: !failed, + result, + error: failed?.error ?? null, + }; + return ctx; + }, +}; diff --git a/cli/src/modules/bytecodeValidation/output.ts b/cli/src/modules/bytecodeValidation/output.ts new file mode 100644 index 00000000..1539441d --- /dev/null +++ b/cli/src/modules/bytecodeValidation/output.ts @@ -0,0 +1,86 @@ +import type { ComposeContext, ModuleState } from "../../context/types"; +import { red, yellow } from "../../utils/terminal"; +import type { BytecodeValidationSummary } from "./types"; + +type OutputWriter = (message: string) => void; + +function printAffectedSources(sourceNames: string[] | undefined, write: OutputWriter): void { + if (!sourceNames?.length) return; + write(" Affected:"); + for (const sourceName of sourceNames) write(` - ${sourceName}`); +} + +/** Prints deployed-bytecode collisions and scoped warnings. */ +export function showBytecodeValidationReport(ctx: ComposeContext): void { + const state = ctx.state.bytecodeValidation as ModuleState | undefined; + if (!state || state.result?.skipped) return; + + if (!state.success && !state.result) { + console.error(red("\nBytecode validation failed")); + console.error(red(state.error?.message ?? "Bytecode validation failed.")); + return; + } + + for (const failure of state.result?.failures ?? []) { + console.error(red("\nBytecode validation failed")); + console.error(red(` ${failure.message}`)); + console.error(`${failure.diamondName} / ${failure.chainKey}`); + console.error(` ${failure.diamondAddress}`); + } + + for (const deployment of state.result?.deployments ?? []) { + const scope = `${deployment.diamondName} / ${deployment.chainKey}`; + if (deployment.introspectionError) { + console.error(red("\nDiamond introspection error")); + console.error(red(` ${deployment.introspectionError}`)); + console.error(`${scope} / ${deployment.diamondAddress}`); + continue; + } + for (const facet of deployment.facets) { + if (facet.error) { + console.error(red("\nBytecode validation failed")); + console.error(red(` ${facet.error}`)); + console.error(`${scope} / ${facet.address}`); + continue; + } + if (facet.uncertain) { + console.warn(yellow(`\nBytecode validation incomplete`)); + console.warn(yellow(` ${facet.uncertain}`)); + console.warn(`${scope} / ${facet.address}`); + continue; + } + if (!facet.report) continue; + + for (const collision of facet.report.collisions) { + console.error(red("\nBytecode validation failed")); + console.error(red(` ${collision.reason}`)); + console.error(`${scope} / ${facet.address}`); + console.error(` ${collision.virtualPath}`); + printAffectedSources(collision.sourceNames, (message) => console.error(message)); + console.error(` Expected: ${collision.expectedType}`); + console.error(` Observed: ${collision.observedType}`); + console.error(` Selector: ${collision.location.selector}`); + if (collision.location.pc !== undefined) console.error(` PC: ${collision.location.pc}`); + } + + for (const uncertain of facet.report.uncertainScopes) { + console.warn(yellow("\nBytecode validation warning")); + console.warn(yellow(` ${uncertain.reason}`)); + console.warn(`${scope} / ${facet.address}`); + console.warn(` ${uncertain.virtualPath ?? uncertain.location.symbolicPath}`); + printAffectedSources(uncertain.sourceNames, (message) => console.warn(message)); + } + for (const diagnostic of facet.report.diagnostics) { + console.warn(yellow("\nBytecode validation warning")); + console.warn(yellow(` ${diagnostic.message}`)); + console.warn(`${scope} / ${facet.address}`); + console.warn(` ${diagnostic.symbolicPath}`); + } + for (const warning of facet.report.delegatecallWarnings) { + console.warn(yellow("\nBytecode validation warning")); + console.warn(yellow(` delegatecall at PC ${warning.pc}: ${warning.reason}`)); + console.warn(`${scope} / ${facet.address}`); + } + } + } +} diff --git a/cli/src/modules/bytecodeValidation/types.ts b/cli/src/modules/bytecodeValidation/types.ts new file mode 100644 index 00000000..a36b8637 --- /dev/null +++ b/cli/src/modules/bytecodeValidation/types.ts @@ -0,0 +1,31 @@ +import type { Address } from "viem"; +import type { BytecodeValidationReport } from "../../adapters/IBytecodeValidatorAdapter/interface"; + +export type FacetBytecodeValidationResult = { + address: Address; + report: BytecodeValidationReport | null; + uncertain: string | null; + error: string | null; +}; + +export type DeploymentBytecodeValidationResult = { + diamondName: string; + chainKey: string; + diamondAddress: Address; + blockNumber: bigint; + complete: boolean; + introspectionError: string | null; + facets: FacetBytecodeValidationResult[]; +}; + +export type BytecodeValidationSummary = { + skipped: boolean; + complete: boolean; + deployments: DeploymentBytecodeValidationResult[]; + failures: Array<{ + diamondName: string; + chainKey: string; + diamondAddress: string; + message: string; + }>; +}; diff --git a/cli/src/modules/inspect/commonSignatures.ts b/cli/src/modules/inspect/commonSignatures.ts index ba946b16..7601f0b9 100644 --- a/cli/src/modules/inspect/commonSignatures.ts +++ b/cli/src/modules/inspect/commonSignatures.ts @@ -1,7 +1,7 @@ /** * Standard 4-byte function signatures for common ERC and Diamond interfaces. * - * Covers ERC-20, ERC-721, ERC-1155, Diamond Loupe/Cut, and Compose-specific + * Covers ERC-20, ERC-721, ERC-1155, Diamond introspection/cut, and Compose-specific * functions. Used to build the initial selector-to-signature lookup map. */ export const COMMON_SIGNATURES: readonly string[] = [ diff --git a/cli/src/modules/inspect/diamondLoupeAbi.ts b/cli/src/modules/inspect/diamondInspectAbi.ts similarity index 64% rename from cli/src/modules/inspect/diamondLoupeAbi.ts rename to cli/src/modules/inspect/diamondInspectAbi.ts index b8f15b3c..5a514a7a 100644 --- a/cli/src/modules/inspect/diamondLoupeAbi.ts +++ b/cli/src/modules/inspect/diamondInspectAbi.ts @@ -1,9 +1,9 @@ /** - * ABI for the EIP-2535 Diamond Loupe `facets()` view function. + * ABI for the Diamond `facets()` introspection function. * - * Used to query on-chain diamonds for their registered facets and selectors. + * ERC-8153 and ERC-2535 expose the same response shape at this boundary. */ -export const DIAMOND_LOUPE_ABI = [ +export const DIAMOND_INSPECT_ABI = [ { name: "facets", type: "function", diff --git a/cli/src/modules/inspect/facetFormatter.ts b/cli/src/modules/inspect/facetFormatter.ts index 490cfecc..4bfbbd52 100644 --- a/cli/src/modules/inspect/facetFormatter.ts +++ b/cli/src/modules/inspect/facetFormatter.ts @@ -6,10 +6,10 @@ import type { FacetInfo } from "./types"; * Converts raw on-chain facet data into a {@link FacetInfo} object with decoded * selector signatures. * - * @param raw - The raw facet returned by the Diamond Loupe. + * @param raw - The raw facet returned by Diamond introspection. * @param raw.facet - The facet contract address. * @param raw.functionSelectors - The 4-byte selectors registered on the facet. - * @param index - The zero-based index of the facet in the Loupe response. + * @param index - The zero-based index of the facet in the introspection response. * @returns The facet info with decoded selectors. */ export function toFacetInfo(raw: { facet: Address; functionSelectors: Hex[] }, index: number): FacetInfo { diff --git a/cli/src/modules/inspect/module.ts b/cli/src/modules/inspect/module.ts index 3168862a..73517c5e 100644 --- a/cli/src/modules/inspect/module.ts +++ b/cli/src/modules/inspect/module.ts @@ -7,7 +7,7 @@ import { resolveChainConfig } from "../../utils/chainConfig"; import { findFileAncestor } from "../../utils/files"; import { RPCAdapterError } from "../../adapters/IRPCAdapter/errors"; import { showInspect } from "./output"; -import { DIAMOND_LOUPE_ABI } from "./diamondLoupeAbi"; +import { DIAMOND_INSPECT_ABI } from "./diamondInspectAbi"; import { toFacetInfo } from "./facetFormatter"; import { mergeProjectSignatures } from "./selectorDecoder"; import type { InspectResult, FacetInfo } from "./types"; @@ -17,7 +17,7 @@ export const InspectModule = { * Inspects an on-chain Diamond and displays its facets and selectors. * * Validates the diamond address, resolves the RPC adapter for the target - * chain, fetches facets via the Diamond Loupe, and decodes each selector + * chain, fetches facets via Diamond introspection, and decodes each selector * using a combination of common signatures and project ABI files. * * @param ctx - The compose context with `address` and optional `chain` params. @@ -60,7 +60,7 @@ export const InspectModule = { { facet: Address; functionSelectors: Hex[] }[] >({ address: diamondAddress, - abi: DIAMOND_LOUPE_ABI, + abi: DIAMOND_INSPECT_ABI, functionName: "facets", }); diff --git a/cli/src/modules/validation/module.ts b/cli/src/modules/validation/module.ts index 8566dc28..c8b87f95 100644 --- a/cli/src/modules/validation/module.ts +++ b/cli/src/modules/validation/module.ts @@ -20,7 +20,7 @@ import { getSelectorExportValidationState, getVirtualStorageLayoutValidationState, } from "./state"; -import { showReport, showSuccess } from "./output"; +import { showIncomplete, showReport, showSuccess } from "./output"; import { getResolvedFacetSources, resolveFacetSources } from "./sourceResolution"; import { IFrameworkAdapter } from "../../adapters/IFrameworkAdapter/interface"; @@ -34,6 +34,7 @@ import { IFrameworkAdapter } from "../../adapters/IFrameworkAdapter/interface"; export const ValidationModule = { showReport, showSuccess, + showIncomplete, getFacetScanState, getSelectorExportValidationState, getSelectorCollisionValidationState, diff --git a/cli/src/modules/validation/output.ts b/cli/src/modules/validation/output.ts index ffc8923f..40367ea0 100644 --- a/cli/src/modules/validation/output.ts +++ b/cli/src/modules/validation/output.ts @@ -169,6 +169,11 @@ export function showSuccess(): void { console.log(green("\nValidation passed.\n")); } +/** Prints a non-blocking command result when some evidence could not be validated. */ +export function showIncomplete(): void { + console.warn(yellow("\nValidation completed with warnings.\n")); +} + function printStorageVariable(variable: StorageVariableReference): void { const name = variable.structName ? `${variable.structName}.${variable.variableName}` diff --git a/cli/src/modules/validation/types.ts b/cli/src/modules/validation/types.ts index 10c3c94c..2841ab0f 100644 --- a/cli/src/modules/validation/types.ts +++ b/cli/src/modules/validation/types.ts @@ -122,6 +122,7 @@ export type VirtualStorageLayoutSource = export type VirtualStorageLayoutRecord = { id: string; virtualPath: string; + parentVirtualPath: string | null; kind: VirtualStorageLayoutKind; codeWidth: 1; layout: string[]; diff --git a/cli/src/modules/validation/virtualStorageLayout.ts b/cli/src/modules/validation/virtualStorageLayout.ts index d0da09e8..4ff2546d 100644 --- a/cli/src/modules/validation/virtualStorageLayout.ts +++ b/cli/src/modules/validation/virtualStorageLayout.ts @@ -172,6 +172,7 @@ export function buildVirtualStorageLayout( const emitted = emitRecord({ id: deriveStorageRootId(root.id, root.source), virtualPath: root.id, + parentVirtualPath: null, kind: "normal", fields: root.fields ?? structMembers(root.structId, index), root, @@ -264,6 +265,7 @@ function compareVirtualStorageLayouts( function emitRecord(options: { id: string; virtualPath: string; + parentVirtualPath: string | null; kind: VirtualStorageLayoutRecord["kind"]; fields: AstNode[]; root: StorageRoot; @@ -281,6 +283,7 @@ function emitRecord(options: { const record: VirtualStorageLayoutRecord = { id: options.id, virtualPath: options.virtualPath, + parentVirtualPath: options.parentVirtualPath, kind: options.kind, codeWidth: 1, layout: analysis.layout, @@ -309,6 +312,7 @@ function emitRecord(options: { const emitted = emitRecord({ id: childId, virtualPath: childPath, + parentVirtualPath: options.virtualPath, kind: child.containerKind === "mapping" ? "normal" : "immutable", fields: structMembers(child.structId, options.index), root: options.root, diff --git a/cli/src/pipelines/bytecodeValidationPipeline.ts b/cli/src/pipelines/bytecodeValidationPipeline.ts new file mode 100644 index 00000000..7176c542 --- /dev/null +++ b/cli/src/pipelines/bytecodeValidationPipeline.ts @@ -0,0 +1,63 @@ +import type { ComposeContext, ModuleState } from "../context/types"; +import type { IBytecodeValidatorAdapter } from "../adapters/IBytecodeValidatorAdapter/interface"; +import type { IRPCAdapter } from "../adapters/IRPCAdapter/interface"; +import { BytecodeValidationModule } from "../modules/bytecodeValidation/module"; +import { DependencyKey } from "../resolver/dependencyKey"; +import { DependencyResolver } from "../resolver/dependencyResolver"; + +function composeError(error: unknown) { + return { + code: typeof error === "object" && error && "code" in error + ? String(error.code) + : "BYTECODE_VALIDATION_FAILED", + message: error instanceof Error ? error.message : "Bytecode validation failed.", + nativeError: error, + }; +} + +/** Child pipeline for one diamond deployment on one chain. */ +export const BytecodeValidationPipeline = { + async execute(ctx: ComposeContext): Promise { + try { + const dependencies = await DependencyResolver.resolve([ + { + key: DependencyKey.RPC, + params: { + chainKey: ctx.param.chainKey, + projectRoot: String(ctx.param.projectRoot), + }, + }, + { key: DependencyKey.BytecodeValidator }, + ]); + const rpc = dependencies[DependencyKey.RPC] as IRPCAdapter | undefined; + const validator = dependencies[DependencyKey.BytecodeValidator] as IBytecodeValidatorAdapter | undefined; + if (!rpc || !validator) throw new Error("Bytecode validation dependencies were not resolved."); + + ctx = await BytecodeValidationModule.validateDeployment(ctx, { rpc, validator }); + const state = ctx.state.bytecodeDeploymentValidation as ModuleState; + if (!state.success) { + ctx.status = { + success: false, + stopped: true, + failedAt: "bytecodeDeploymentValidation", + error: state.error, + }; + } + return ctx; + } catch (error) { + const resolvedError = composeError(error); + ctx.state.bytecodeDeploymentValidation = { + success: false, + result: null, + error: resolvedError, + }; + ctx.status = { + success: false, + stopped: true, + failedAt: "bytecodeDeploymentValidation", + error: resolvedError, + }; + return ctx; + } + }, +}; diff --git a/cli/src/pipelines/inspectPipeline.ts b/cli/src/pipelines/inspectPipeline.ts index 88ca5a80..b86afcbc 100644 --- a/cli/src/pipelines/inspectPipeline.ts +++ b/cli/src/pipelines/inspectPipeline.ts @@ -1,7 +1,7 @@ import { ComposeContext } from "../context/types"; import { InspectModule } from "../modules/inspect/module"; -/** Diamond inspect pipeline for querying on-chain facets via Loupe. */ +/** Diamond inspect pipeline for querying on-chain facets and selectors. */ export const InspectPipeline = { async execute(ctx: ComposeContext): Promise { return InspectModule.inspect(ctx); diff --git a/cli/src/pipelines/validatePipeline.ts b/cli/src/pipelines/validatePipeline.ts index 308214e9..1fe9244a 100644 --- a/cli/src/pipelines/validatePipeline.ts +++ b/cli/src/pipelines/validatePipeline.ts @@ -4,6 +4,15 @@ import { ValidationModule } from "../modules/validation/module"; import { DependencyKey } from "../resolver/dependencyKey"; import { DependencyResolver } from "../resolver/dependencyResolver"; import { loadValidationProject } from "../modules/validation/project"; +import { Context } from "../context/context"; +import type { ModuleState } from "../context/types"; +import { LockFileModule } from "../modules/lockFile/module"; +import type { LockFileState } from "../modules/lockFile/types"; +import { BytecodeValidationPipeline } from "./bytecodeValidationPipeline"; +import { BytecodeValidationModule } from "../modules/bytecodeValidation/module"; +import { showBytecodeValidationReport } from "../modules/bytecodeValidation/output"; +import type { BytecodeValidationSummary } from "../modules/bytecodeValidation/types"; +import type { VirtualStorageLayoutResult } from "../modules/validation/types"; /** Runs source-side validation directly from compiler AST output. */ export const ValidatePipeline = { @@ -47,9 +56,59 @@ export const ValidatePipeline = { const selectorCollisions = ValidationModule.getSelectorCollisionValidationState(ctx); const virtualStorageLayout = ValidationModule.getVirtualStorageLayoutValidationState(ctx); - const pipelineError = selectorCollisions?.error ?? virtualStorageLayout?.error ?? null; + const sourceSuccess = selectorCollisions?.success === true && virtualStorageLayout?.success === true; + + if (sourceSuccess) { + ctx = await LockFileModule.readLockFile(ctx); + const lockState = ctx.state.lockFile as ModuleState | undefined; + if (!lockState?.success) { + ctx.state.bytecodeValidation = { + success: false, + result: null, + error: lockState?.error ?? { + code: "LOCK_FILE_INVALID", + message: "Unable to read compose.lock.", + nativeError: null, + }, + }; + } else if (!lockState.result) { + ctx = BytecodeValidationModule.mergeDeployments(ctx, [], true); + } else { + const records = (virtualStorageLayout.result as VirtualStorageLayoutResult).records; + const childContexts: ComposeContext[] = []; + for (const diamond of project.diamonds) { + const chainDeployments = lockState.result.lock.deployments[diamond.name] ?? {}; + for (const [chainKey, deployment] of Object.entries(chainDeployments)) { + const child = Context.create(); + child.param = { + projectRoot: ctx.param.projectRoot, + diamondName: diamond.name, + chainKey, + diamondAddress: deployment.diamond, + virtualStorageRecords: records.filter((record) => record.diamondName === diamond.name), + }; + childContexts.push(await BytecodeValidationPipeline.execute(child)); + } + } + ctx = BytecodeValidationModule.mergeDeployments( + ctx, + childContexts, + childContexts.length === 0, + ); + } + } else { + ctx = BytecodeValidationModule.mergeDeployments(ctx, [], true); + } + + const bytecodeValidation = ctx.state.bytecodeValidation as + | ModuleState + | undefined; + const pipelineError = selectorCollisions?.error + ?? virtualStorageLayout?.error + ?? bytecodeValidation?.error + ?? null; ctx.state.validatePipeline = { - success: selectorCollisions?.success === true && virtualStorageLayout?.success === true, + success: sourceSuccess && bytecodeValidation?.success === true, result: { checkedFacets: project.facetSources.length, }, @@ -66,9 +125,14 @@ export const ValidatePipeline = { } ctx = await ValidationModule.showReport(ctx); + showBytecodeValidationReport(ctx); if (ctx.state.validatePipeline.success) { - ValidationModule.showSuccess(); + if (bytecodeValidation?.result?.complete === false) { + ValidationModule.showIncomplete(); + } else { + ValidationModule.showSuccess(); + } } return ctx; diff --git a/cli/src/resolver/dependencyKey.ts b/cli/src/resolver/dependencyKey.ts index 37e77ec0..818c6000 100644 --- a/cli/src/resolver/dependencyKey.ts +++ b/cli/src/resolver/dependencyKey.ts @@ -9,4 +9,5 @@ export enum DependencyKey { RPC = "rpc", Foundry = "foundry", Hardhat = "hardhat", + BytecodeValidator = "bytecodeValidator", } diff --git a/cli/src/resolver/dependencyRegistry.ts b/cli/src/resolver/dependencyRegistry.ts index 4962cfa5..27a812c4 100644 --- a/cli/src/resolver/dependencyRegistry.ts +++ b/cli/src/resolver/dependencyRegistry.ts @@ -9,6 +9,8 @@ import type { IRPCAdapter } from "../adapters/IRPCAdapter/interface"; import { createRPCAdapter } from "../adapters/IRPCAdapter/adapter"; import { resolveChainConfig } from "../utils/chainConfig"; import { DependencyKey } from "./dependencyKey"; +import { BytecodeValidatorAdapter } from "../adapters/IBytecodeValidatorAdapter/adapter"; +import type { IBytecodeValidatorAdapter } from "../adapters/IBytecodeValidatorAdapter/interface"; /** Optional parameters passed to a dependency factory. */ export type DependencyParams = Record; @@ -29,6 +31,7 @@ export type DependencyMap = { [DependencyKey.RPC]: IRPCAdapter; [DependencyKey.Foundry]: IFrameworkAdapter; [DependencyKey.Hardhat]: IFrameworkAdapter; + [DependencyKey.BytecodeValidator]: IBytecodeValidatorAdapter; }; /** @@ -51,4 +54,5 @@ export const DependencyRegistry: { }, [DependencyKey.Foundry]: () => foundryAdapter, [DependencyKey.Hardhat]: () => hardhatAdapter, + [DependencyKey.BytecodeValidator]: () => BytecodeValidatorAdapter, }; diff --git a/cli/src/utils/metadata.ts b/cli/src/utils/metadata.ts index 4695160d..9598333a 100644 --- a/cli/src/utils/metadata.ts +++ b/cli/src/utils/metadata.ts @@ -1,12 +1,9 @@ import fs from "node:fs"; import path from "node:path"; -import { fileURLToPath } from "node:url"; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); +import { CLI_ROOT } from "./cliRoot"; function readVersion(): string { - const pkgPath = path.resolve(__dirname, "..", "..", "package.json"); + const pkgPath = path.join(CLI_ROOT, "package.json"); const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8")); return pkg.version; } @@ -24,4 +21,3 @@ export const COMPOSE_HEADER = ` \\_____\\____/|_| |_|_| \\____/|_____/|______| \\_____|______|_____| `; - diff --git a/cli/test/adapters/IBytecodeValidatorAdapter/adapter.test.ts b/cli/test/adapters/IBytecodeValidatorAdapter/adapter.test.ts new file mode 100644 index 00000000..59ddb592 --- /dev/null +++ b/cli/test/adapters/IBytecodeValidatorAdapter/adapter.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { + BytecodeValidatorAdapter, + formatSymbolicStoragePath, +} from "../../../src/adapters/IBytecodeValidatorAdapter/adapter"; + +describe("BytecodeValidatorAdapter", () => { + it("runs the packaged WASM validator through the CLI boundary", () => { + const report = BytecodeValidatorAdapter.validate({ + bytecode: "0x6000600055", + virtualStorageLayout: { records: [] }, + }); + + expect(report).toEqual({ + collisions: expect.any(Array), + validatedVariables: expect.any(Array), + uncertainScopes: expect.any(Array), + diagnostics: expect.any(Array), + delegatecallWarnings: expect.any(Array), + }); + }); + + it("formats plain byte-array slots as readable hex", () => { + expect(formatSymbolicStoragePath( + "Plain([208, 192, 207, 159, 68, 4, 30, 38, 148, 81, 176, 16, 155, 218, 207, 239, 134, 70, 39, 202, 210, 195, 121, 156, 43, 94, 12, 108, 34, 175, 0, 119])", + )).toBe("Plain(0xd0c0cf9f44041e269451b0109bdacfef864627cad2c3799c2b5e0c6c22af0077)"); + }); +}); diff --git a/cli/test/adapters/IRPCAdapter/adapter.test.ts b/cli/test/adapters/IRPCAdapter/adapter.test.ts index b0fb823b..bb8c6d5e 100644 --- a/cli/test/adapters/IRPCAdapter/adapter.test.ts +++ b/cli/test/adapters/IRPCAdapter/adapter.test.ts @@ -35,6 +35,7 @@ import { createRPCAdapter } from "../../../src/adapters/IRPCAdapter/adapter"; type MockClient = { chain: { id: number }; getChainId: ReturnType; + getBlockNumber: ReturnType; getCode: ReturnType; readContract: ReturnType; }; @@ -46,6 +47,7 @@ function useClient(overrides: Record = {}): void { mocks.activeClient = { chain: { id: 11155111 }, getChainId: vi.fn().mockResolvedValue(11155111), + getBlockNumber: vi.fn().mockResolvedValue(123n), getCode: vi.fn().mockResolvedValue("0x6000"), readContract: vi.fn().mockResolvedValue("result"), ...overrides, @@ -84,6 +86,29 @@ describe("createRPCAdapter", () => { expect((mocks.activeClient as MockClient).getCode).toHaveBeenCalledOnce(); }); + it("pins block and code reads to an explicit block number", async () => { + useClient(); + const adapter = await createRPCAdapter({ rpcUrl: "https://rpc.example", chainId: 11155111 }); + + await expect(adapter.getBlockNumber()).resolves.toBe(123n); + await adapter.getCode(address, 120n); + await adapter.readContract({ + address, + abi: [], + functionName: "example", + blockNumber: 120n, + } as never, { verifyCode: true }); + + expect((mocks.activeClient as MockClient).getCode).toHaveBeenNthCalledWith(1, { + address, + blockNumber: 120n, + }); + expect((mocks.activeClient as MockClient).getCode).toHaveBeenNthCalledWith(2, { + address, + blockNumber: 120n, + }); + }); + 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/modules/bytecodeValidation/module.test.ts b/cli/test/modules/bytecodeValidation/module.test.ts new file mode 100644 index 00000000..3dbadc62 --- /dev/null +++ b/cli/test/modules/bytecodeValidation/module.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it, vi } from "vitest"; +import type { Address, Hex } from "viem"; +import { Context } from "../../../src/context/context"; +import type { IBytecodeValidatorAdapter } from "../../../src/adapters/IBytecodeValidatorAdapter/interface"; +import type { IRPCAdapter } from "../../../src/adapters/IRPCAdapter/interface"; +import { BytecodeValidationModule } from "../../../src/modules/bytecodeValidation/module"; + +const diamond = "0x0000000000000000000000000000000000000001" as Address; +const facet = "0x0000000000000000000000000000000000000002" as Address; + +function report(collisions: unknown[] = []) { + return { + collisions, + validatedVariables: [], + uncertainScopes: [], + diagnostics: [], + delegatecallWarnings: [], + } as ReturnType; +} + +function setup() { + const ctx = Context.create(); + ctx.param = { + diamondName: "ExampleDiamond", + chainKey: "local", + diamondAddress: diamond, + virtualStorageRecords: [], + }; + const rpc: IRPCAdapter = { + getBlockNumber: vi.fn().mockResolvedValue(100n), + readContract: vi.fn().mockResolvedValue([{ + facet, + functionSelectors: ["0x12345678"], + }]), + getCode: vi.fn().mockResolvedValue("0x6000" as Hex), + }; + const validator: IBytecodeValidatorAdapter = { + validate: vi.fn().mockReturnValue(report()), + }; + return { ctx, rpc, validator }; +} + +describe("BytecodeValidationModule", () => { + it("uses Diamond introspection and one pinned block", async () => { + const { ctx, rpc, validator } = setup(); + const result = await BytecodeValidationModule.validateDeployment(ctx, { rpc, validator }); + + expect(rpc.readContract).toHaveBeenCalledWith(expect.objectContaining({ + address: diamond, + blockNumber: 100n, + functionName: "facets", + }), { verifyCode: true }); + expect(rpc.getCode).toHaveBeenCalledWith(facet, 100n); + expect(validator.validate).toHaveBeenCalledOnce(); + expect(result.state.bytecodeDeploymentValidation.success).toBe(true); + }); + + it("reports introspection errors without blocking validation", async () => { + const { ctx, rpc, validator } = setup(); + vi.mocked(rpc.readContract).mockRejectedValue(new Error("packed selectors are invalid")); + + const result = await BytecodeValidationModule.validateDeployment(ctx, { rpc, validator }); + const state = result.state.bytecodeDeploymentValidation; + + expect(state.success).toBe(true); + expect(state.result).toMatchObject({ + complete: false, + introspectionError: "packed selectors are invalid", + facets: [], + }); + expect(validator.validate).not.toHaveBeenCalled(); + }); + + it("blocks only when the validator proves a collision", async () => { + const { ctx, rpc, validator } = setup(); + ctx.param.virtualStorageRecords = [{ + id: "0x01", + virtualPath: "example.storage", + parentVirtualPath: null, + kind: "normal", + codeWidth: 1, + layout: ["0x2f"], + serializedLayout: ["0x01", "0x2f"], + slots: [[256]], + source: "erc8042", + sourceName: "src/ExampleFacet.sol", + contractName: "ExampleFacet", + structName: "Storage", + }]; + vi.mocked(validator.validate).mockReturnValue(report([{ + location: { slot: "0", offset: 0, selector: "12345678", symbolicPath: "slot(0)" }, + virtualPath: "example.storage", + expectedType: "uint256", + observedType: "address", + reason: "type mismatch", + }])); + + const result = await BytecodeValidationModule.validateDeployment(ctx, { rpc, validator }); + + expect(result.state.bytecodeDeploymentValidation.success).toBe(false); + expect(result.state.bytecodeDeploymentValidation.error?.code).toBe( + "BYTECODE_STORAGE_COLLISION_DETECTED", + ); + expect(result.state.bytecodeDeploymentValidation.result).toMatchObject({ + facets: [{ report: { collisions: [{ sourceNames: ["src/ExampleFacet.sol"] }] } }], + }); + }); + + it.each([ + ["an undefined response", undefined], + ["empty runtime bytecode", "0x" as Hex], + ])("marks validation incomplete without failing for %s", async (_case, bytecode) => { + const { ctx, rpc, validator } = setup(); + vi.mocked(rpc.getCode).mockResolvedValue(bytecode); + + const result = await BytecodeValidationModule.validateDeployment(ctx, { rpc, validator }); + const state = result.state.bytecodeDeploymentValidation; + + expect(state.success).toBe(true); + expect(state.result).toMatchObject({ complete: false }); + expect((state.result as { facets: Array<{ uncertain: string }> }).facets[0].uncertain) + .toContain("this facet was not validated"); + expect(validator.validate).not.toHaveBeenCalled(); + }); + + it("continues after an individual facet cannot be analyzed", async () => { + const { ctx, rpc, validator } = setup(); + const secondFacet = "0x0000000000000000000000000000000000000003" as Address; + vi.mocked(rpc.readContract).mockResolvedValue([ + { facet, functionSelectors: [] }, + { facet: secondFacet, functionSelectors: [] }, + ] as never); + vi.mocked(rpc.getCode) + .mockRejectedValueOnce(new Error("code unavailable")) + .mockResolvedValueOnce("0x6000"); + + const result = await BytecodeValidationModule.validateDeployment(ctx, { rpc, validator }); + const state = result.state.bytecodeDeploymentValidation; + + expect(state.success).toBe(false); + expect((state.result as { facets: Array<{ error: string | null }> }).facets[0].error) + .toBe("code unavailable"); + expect(validator.validate).toHaveBeenCalledOnce(); + }); +}); diff --git a/cli/test/modules/inspect/module.test.ts b/cli/test/modules/inspect/module.test.ts index dfd7d3c4..13c7c9a0 100644 --- a/cli/test/modules/inspect/module.test.ts +++ b/cli/test/modules/inspect/module.test.ts @@ -116,7 +116,7 @@ describe("InspectModule", () => { }); describe("decodeSelector", () => { - it("decodes Diamond Loupe selectors", () => { + it("decodes Diamond introspection selectors", () => { expect(decodeSelector("0x7a0ed627")).toBe("facets()"); expect(decodeSelector("0x52ef6b2c")).toBe("facetAddresses()"); expect(decodeSelector("0xadfca15e")).toBe("facetFunctionSelectors(address)"); diff --git a/cli/test/modules/validation/virtualStorageLayout.test.ts b/cli/test/modules/validation/virtualStorageLayout.test.ts index 3ba221f0..a49beb0d 100644 --- a/cli/test/modules/validation/virtualStorageLayout.test.ts +++ b/cli/test/modules/validation/virtualStorageLayout.test.ts @@ -370,6 +370,7 @@ function record(layout: string[]): VirtualStorageLayoutRecord { return { id: hashVirtualPath("shared.storage"), virtualPath: "shared.storage", + parentVirtualPath: null, kind: "normal", codeWidth: 1, layout, @@ -456,21 +457,25 @@ describe("virtual storage layout", () => { id: record.id, kind: record.kind, path: record.virtualPath, + parentPath: record.parentVirtualPath, }))).toEqual([ { id: hashVirtualPath("erc20"), kind: "normal", path: "erc20", + parentPath: null, }, { id: hashVirtualPath("erc20.5"), kind: "normal", path: "erc20.5", + parentPath: "erc20", }, { id: hashVirtualPath("erc20.5.3"), kind: "immutable", path: "erc20.5.3", + parentPath: "erc20.5", }, ]); }); @@ -563,6 +568,7 @@ describe("virtual storage layout", () => { expect(result.records[0]).toEqual({ id: "0xb4df32537f6767405c9db7d67260e5375218aecdea91f4240ad14000623cbdff", virtualPath: "evmole.normal", + parentVirtualPath: null, kind: "normal", codeWidth: 1, layout: [ diff --git a/cli/test/pipelines/validatePipeline/bytecodeHarness.ts b/cli/test/pipelines/validatePipeline/bytecodeHarness.ts new file mode 100644 index 00000000..0f2b7b5c --- /dev/null +++ b/cli/test/pipelines/validatePipeline/bytecodeHarness.ts @@ -0,0 +1,290 @@ +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import fs from "node:fs/promises"; +import net from "node:net"; +import os from "node:os"; +import path from "node:path"; + +const ANVIL_PRIVATE_KEY = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; +const CHAIN_ID = 31337; +const COMPOSE_ROOT = path.resolve(__dirname, "../../../.."); +const FIXTURE_ROOT = path.join(__dirname, "fixtures", "bytecode"); +const CLI_ENTRY = process.env.COMPOSE_E2E_CLI_ENTRY + ?? path.join(COMPOSE_ROOT, "cli", "dist", "index.js"); + +type ProcessResult = { + code: number; + stdout: string; + stderr: string; +}; + +type BroadcastTransaction = { + contractName?: string; + contractAddress?: string; + hash?: string; +}; + +export type BytecodeE2EHarness = { + canonicalFacetAddress: string; + diamondAddress: string; + rpcUrl: string; + createValidationProject(variant: "compatible" | "incompatible"): Promise; + runValidate(projectRoot: string): Promise; + cleanup(): Promise; +}; + +function runProcess( + command: string, + args: string[], + options: { cwd: string; env?: NodeJS.ProcessEnv }, +): Promise { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { + cwd: options.cwd, + env: options.env ?? process.env, + windowsHide: true, + }); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk: Buffer) => { stdout += chunk.toString(); }); + child.stderr.on("data", (chunk: Buffer) => { stderr += chunk.toString(); }); + child.once("error", reject); + child.once("close", (code) => resolve({ code: code ?? 1, stdout, stderr })); + }); +} + +async function availablePort(): 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(); + const port = typeof address === "object" && address ? address.port : 0; + server.close((error) => error ? reject(error) : resolve(port)); + }); + }); +} + +async function waitForRPC(rpcUrl: string, process: ChildProcessWithoutNullStreams): Promise { + for (let attempt = 0; attempt < 100; attempt += 1) { + if (process.exitCode !== null) throw new Error("Anvil exited before its RPC endpoint was ready."); + try { + const response = await fetch(rpcUrl, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "eth_chainId", params: [] }), + }); + if (response.ok) return; + } catch { + // The endpoint is not listening yet. + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + throw new Error("Timed out waiting for Anvil RPC."); +} + +async function linkFoundryLibraries(projectRoot: string): Promise { + const libraryRoot = path.join(projectRoot, "lib"); + await fs.mkdir(libraryRoot, { recursive: true }); + const directoryType = process.platform === "win32" ? "junction" : "dir"; + await Promise.all([ + fs.symlink(COMPOSE_ROOT, path.join(libraryRoot, "Compose"), directoryType), + fs.symlink( + path.join(COMPOSE_ROOT, "lib", "forge-std"), + path.join(libraryRoot, "forge-std"), + directoryType, + ), + ]); +} + +async function writeFoundryConfig(projectRoot: string): Promise { + await Promise.all([ + fs.writeFile( + path.join(projectRoot, "foundry.toml"), + [ + "[profile.default]", + 'src = "src"', + 'script = "script"', + 'out = "out"', + 'libs = ["lib"]', + 'solc = "0.8.30"', + "optimizer = true", + "", + ].join("\n"), + "utf8", + ), + fs.writeFile( + path.join(projectRoot, "remappings.txt"), + "@perfect-abstractions/compose/=lib/Compose/src/\nforge-std/=lib/forge-std/src/\n", + "utf8", + ), + ]); +} + +async function createDeploymentProject(root: string): Promise { + const projectRoot = path.join(root, "deployment"); + await Promise.all([ + fs.mkdir(path.join(projectRoot, "src"), { recursive: true }), + fs.mkdir(path.join(projectRoot, "script"), { recursive: true }), + ]); + await linkFoundryLibraries(projectRoot); + await writeFoundryConfig(projectRoot); + await Promise.all([ + fs.copyFile(path.join(FIXTURE_ROOT, "Diamond.sol"), path.join(projectRoot, "src", "Diamond.sol")), + fs.copyFile( + path.join(FIXTURE_ROOT, "canonical", "CanonicalStorageFacet.sol"), + path.join(projectRoot, "src", "CanonicalStorageFacet.sol"), + ), + fs.copyFile( + path.join(FIXTURE_ROOT, "script", "Deploy.s.sol"), + path.join(projectRoot, "script", "Deploy.s.sol"), + ), + ]); + return projectRoot; +} + +function packageFacet(contractName: string) { + return { + source: "package", + contract: contractName, + package: "@perfect-abstractions/compose", + }; +} + +/** Starts Anvil and deploys the canonical ERC-20 diamond used by bytecode E2E tests. */ +export async function createBytecodeE2EHarness(): Promise { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "compose-bytecode-e2e-")); + const port = await availablePort(); + const rpcUrl = `http://127.0.0.1:${port}`; + const anvil = spawn("anvil", ["--silent", "--port", String(port), "--chain-id", String(CHAIN_ID)], { + cwd: root, + windowsHide: true, + }); + + try { + await waitForRPC(rpcUrl, anvil); + const deploymentRoot = await createDeploymentProject(root); + const deployment = await runProcess("forge", [ + "script", + "script/Deploy.s.sol:DeployScript", + "--rpc-url", + rpcUrl, + "--private-key", + ANVIL_PRIVATE_KEY, + "--broadcast", + "--non-interactive", + ], { cwd: deploymentRoot }); + if (deployment.code !== 0) { + throw new Error(`Diamond deployment failed.\n${deployment.stdout}\n${deployment.stderr}`); + } + + const broadcastPath = path.join( + deploymentRoot, + "broadcast", + "Deploy.s.sol", + String(CHAIN_ID), + "run-latest.json", + ); + const broadcast = JSON.parse(await fs.readFile(broadcastPath, "utf8")) as { + transactions: BroadcastTransaction[]; + }; + const deployments = new Map( + broadcast.transactions + .filter((transaction) => transaction.contractName && transaction.contractAddress) + .map((transaction) => [transaction.contractName!, transaction]), + ); + const diamondTransaction = deployments.get("Diamond"); + if (!diamondTransaction?.contractAddress) throw new Error("Diamond address missing from Foundry broadcast."); + const diamondAddress = diamondTransaction.contractAddress; + const canonicalFacetAddress = deployments.get("CanonicalStorageFacet")?.contractAddress; + if (!canonicalFacetAddress) { + throw new Error("Canonical storage facet address missing from Foundry broadcast."); + } + + return { + canonicalFacetAddress, + diamondAddress, + rpcUrl, + async createValidationProject(variant) { + const contractName = variant === "compatible" + ? "CompatibleStorageFacet" + : "IncompatibleStorageFacet"; + const projectRoot = path.join(root, variant); + await fs.mkdir(path.join(projectRoot, "src"), { recursive: true }); + await linkFoundryLibraries(projectRoot); + await writeFoundryConfig(projectRoot); + await Promise.all([ + fs.copyFile(path.join(FIXTURE_ROOT, "Diamond.sol"), path.join(projectRoot, "src", "Diamond.sol")), + fs.copyFile( + path.join(FIXTURE_ROOT, variant, `${contractName}.sol`), + path.join(projectRoot, "src", `${contractName}.sol`), + ), + ]); + + const facets = { + DiamondInspectFacet: packageFacet("DiamondInspectFacet"), + ERC20DataFacet: packageFacet("ERC20DataFacet"), + ERC20ApproveFacet: packageFacet("ERC20ApproveFacet"), + ERC20TransferFacet: packageFacet("ERC20TransferFacet"), + [contractName]: { + source: "local", + contract: `src/${contractName}.sol:${contractName}`, + }, + }; + await fs.writeFile(path.join(projectRoot, "compose.json"), JSON.stringify({ + project: `bytecode-${variant}`, + compose: "0.0.6", + framework: "foundry", + diamonds: { + ERC20Diamond: { + contract: "src/Diamond.sol:Diamond", + facets, + }, + }, + chains: { + local: { rpc: rpcUrl, chainId: CHAIN_ID }, + }, + }, null, 2), "utf8"); + + const deployedFacets = Object.fromEntries( + [...deployments.entries()] + .filter(([name]) => name !== "Diamond") + .map(([name, transaction]) => [name, transaction.contractAddress]), + ); + await fs.writeFile(path.join(projectRoot, "compose.lock"), JSON.stringify({ + compose: "0.0.6", + deployments: { + ERC20Diamond: { + local: { + diamond: diamondAddress, + facets: deployedFacets, + facetHash: "0x00", + lastSync: new Date().toISOString(), + txHash: diamondTransaction.hash ?? "0x00", + }, + }, + }, + }, null, 2), "utf8"); + return projectRoot; + }, + runValidate: (projectRoot) => runProcess(process.execPath, [ + CLI_ENTRY, + "validate", + "--project-root", + projectRoot, + ], { cwd: projectRoot }), + async cleanup() { + anvil.kill(); + await new Promise((resolve) => { + if (anvil.exitCode !== null) return resolve(); + anvil.once("close", () => resolve()); + setTimeout(resolve, 2_000); + }); + await fs.rm(root, { recursive: true, force: true }); + }, + }; + } catch (error) { + anvil.kill(); + await fs.rm(root, { recursive: true, force: true }); + throw error; + } +} diff --git a/cli/test/pipelines/validatePipeline/bytecodeUnavailableHarness.ts b/cli/test/pipelines/validatePipeline/bytecodeUnavailableHarness.ts new file mode 100644 index 00000000..13b11968 --- /dev/null +++ b/cli/test/pipelines/validatePipeline/bytecodeUnavailableHarness.ts @@ -0,0 +1,54 @@ +import { createBytecodeE2EHarness } from "./bytecodeHarness"; + +type RpcResponse = { + error?: { code: number; message: string }; + result?: T; +}; + +export type BytecodeUnavailableHarness = { + canonicalFacetAddress: string; + projectRoot: string; + removeCanonicalFacetCode(): Promise; + runValidate(): ReturnType>["runValidate"]>; + cleanup(): Promise; +}; + +async function rpc(rpcUrl: string, method: string, params: unknown[]): Promise { + const response = await fetch(rpcUrl, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }), + }); + const payload = await response.json() as RpcResponse; + if (!response.ok || payload.error) { + throw new Error(payload.error?.message ?? `${method} failed with HTTP ${response.status}.`); + } + return payload.result as T; +} + +/** Creates a deployed Diamond whose declared canonical facet can be made unavailable. */ +export async function createBytecodeUnavailableHarness(): Promise { + const harness = await createBytecodeE2EHarness(); + try { + const projectRoot = await harness.createValidationProject("compatible"); + return { + canonicalFacetAddress: harness.canonicalFacetAddress, + projectRoot, + async removeCanonicalFacetCode() { + await rpc(harness.rpcUrl, "anvil_setCode", [harness.canonicalFacetAddress, "0x"]); + await rpc(harness.rpcUrl, "evm_mine", []); + const code = await rpc( + harness.rpcUrl, + "eth_getCode", + [harness.canonicalFacetAddress, "latest"], + ); + if (code !== "0x") throw new Error("Anvil did not clear the canonical facet bytecode."); + }, + runValidate: () => harness.runValidate(projectRoot), + cleanup: () => harness.cleanup(), + }; + } catch (error) { + await harness.cleanup(); + throw error; + } +} diff --git a/cli/test/pipelines/validatePipeline/fixtures/bytecode/Diamond.sol b/cli/test/pipelines/validatePipeline/fixtures/bytecode/Diamond.sol new file mode 100644 index 00000000..c3a5a5a5 --- /dev/null +++ b/cli/test/pipelines/validatePipeline/fixtures/bytecode/Diamond.sol @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +import "@perfect-abstractions/compose/diamond/DiamondMod.sol" as DiamondMod; +import "@perfect-abstractions/compose/interfaceDetection/ERC165/ERC165Mod.sol" as ERC165Mod; +import {IERC20} from "@perfect-abstractions/compose/interfaces/IERC20.sol"; + +contract Diamond { + constructor(address[] memory facets) { + DiamondMod.addFacets(facets); + ERC165Mod.registerInterface(type(IERC20).interfaceId); + } + + fallback() external payable { + DiamondMod.diamondFallback(); + } + + receive() external payable {} +} diff --git a/cli/test/pipelines/validatePipeline/fixtures/bytecode/canonical/CanonicalStorageFacet.sol b/cli/test/pipelines/validatePipeline/fixtures/bytecode/canonical/CanonicalStorageFacet.sol new file mode 100644 index 00000000..5c67f807 --- /dev/null +++ b/cli/test/pipelines/validatePipeline/fixtures/bytecode/canonical/CanonicalStorageFacet.sol @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +contract CanonicalStorageFacet { + bytes32 constant STORAGE_POSITION = keccak256("compose.e2e.storage"); + + struct Node { + uint256 amount; + uint256 score; + uint256 nonce; + } + + /** + * @custom:storage-location erc8042:compose.e2e.storage + */ + struct Storage { + uint256 value; + address account; + bool enabled; + uint8 small; + mapping(address owner => uint256 balance) balances; + uint256[] values; + uint8[5] fixedValues; + mapping(bytes4 id => Node node) nodes; + Node[] children; + } + + function getStorage() internal pure returns (Storage storage s) { + bytes32 position = STORAGE_POSITION; + assembly { + s.slot := position + } + } + + function writeScalars(uint256 value, address account, bool enabled, uint8 small) external { + Storage storage s = getStorage(); + s.value = value; + s.account = account; + s.enabled = enabled; + s.small = small; + } + + function writeBalance(address owner, uint256 balance) external { + getStorage().balances[owner] = balance; + } + + function pushValue(uint256 value) external { + getStorage().values.push(value); + } + + function writeFixed(uint256 index, uint8 value) external { + getStorage().fixedValues[index] = value; + } + + function writeNode(bytes4 id, uint256 amount, uint256 score, uint256 nonce) external { + Node storage node = getStorage().nodes[id]; + node.amount = amount; + node.score = score; + node.nonce = nonce; + } + + function writeChild(uint256 index, uint256 amount, uint256 score, uint256 nonce) external { + Node storage node = getStorage().children[index]; + node.amount = amount; + node.score = score; + node.nonce = nonce; + } + + function exportSelectors() external pure returns (bytes memory) { + return bytes.concat( + this.writeScalars.selector, + this.writeBalance.selector, + this.pushValue.selector, + this.writeFixed.selector, + this.writeNode.selector, + this.writeChild.selector + ); + } +} diff --git a/cli/test/pipelines/validatePipeline/fixtures/bytecode/compatible/CompatibleStorageFacet.sol b/cli/test/pipelines/validatePipeline/fixtures/bytecode/compatible/CompatibleStorageFacet.sol new file mode 100644 index 00000000..950ec1a6 --- /dev/null +++ b/cli/test/pipelines/validatePipeline/fixtures/bytecode/compatible/CompatibleStorageFacet.sol @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +contract CompatibleStorageFacet { + bytes32 constant STORAGE_POSITION = keccak256("compose.e2e.storage"); + + struct Node { + uint256 amount; + uint256 score; + uint256 nonce; + } + + /** + * @custom:storage-location erc8042:compose.e2e.storage + */ + struct Storage { + uint256 value; + address account; + bool enabled; + uint8 small; + mapping(address owner => uint256 balance) balances; + uint256[] values; + uint8[5] fixedValues; + mapping(bytes4 id => Node node) nodes; + Node[] children; + bytes32 appended; + } + + function getStorage() internal pure returns (Storage storage s) { + bytes32 position = STORAGE_POSITION; + assembly { + s.slot := position + } + } + + function writeScalars(uint256 value, address account, bool enabled, uint8 small) external { + Storage storage s = getStorage(); + s.value = value; + s.account = account; + s.enabled = enabled; + s.small = small; + } + + function writeBalance(address owner, uint256 balance) external { + getStorage().balances[owner] = balance; + } + + function pushValue(uint256 value) external { + getStorage().values.push(value); + } + + function writeFixed(uint256 index, uint8 value) external { + getStorage().fixedValues[index] = value; + } + + function writeNode(bytes4 id, uint256 amount, uint256 score, uint256 nonce) external { + Node storage node = getStorage().nodes[id]; + node.amount = amount; + node.score = score; + node.nonce = nonce; + } + + function writeChild(uint256 index, uint256 amount, uint256 score, uint256 nonce) external { + Node storage node = getStorage().children[index]; + node.amount = amount; + node.score = score; + node.nonce = nonce; + } + + function writeAppended(bytes32 value) external { + getStorage().appended = value; + } + + function exportSelectors() external pure returns (bytes memory) { + return bytes.concat( + this.writeScalars.selector, + this.writeBalance.selector, + this.pushValue.selector, + this.writeFixed.selector, + this.writeNode.selector, + this.writeChild.selector, + this.writeAppended.selector + ); + } +} diff --git a/cli/test/pipelines/validatePipeline/fixtures/bytecode/incompatible/IncompatibleStorageFacet.sol b/cli/test/pipelines/validatePipeline/fixtures/bytecode/incompatible/IncompatibleStorageFacet.sol new file mode 100644 index 00000000..0d2333d9 --- /dev/null +++ b/cli/test/pipelines/validatePipeline/fixtures/bytecode/incompatible/IncompatibleStorageFacet.sol @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +contract IncompatibleStorageFacet { + bytes32 constant STORAGE_POSITION = keccak256("compose.e2e.storage"); + + struct Node { + address amount; + address score; + address nonce; + } + + /** + * @custom:storage-location erc8042:compose.e2e.storage + */ + struct Storage { + address value; + uint256 account; + uint128 enabled; + uint128 small; + mapping(address owner => address balance) balances; + address[] values; + address[5] fixedValues; + mapping(bytes4 id => Node node) nodes; + Node[] children; + } + + function getStorage() internal pure returns (Storage storage s) { + bytes32 position = STORAGE_POSITION; + assembly { + s.slot := position + } + } + + function writeScalars(uint256 value, address account, bool enabled, uint8 small) external { + Storage storage s = getStorage(); + s.value = address(uint160(value)); + s.account = uint256(uint160(account)); + s.enabled = enabled ? 1 : 0; + s.small = small; + } + + function writeBalance(address owner, uint256 balance) external { + getStorage().balances[owner] = address(uint160(balance)); + } + + function pushValue(uint256 value) external { + getStorage().values.push(address(uint160(value))); + } + + function writeFixed(uint256 index, uint8 value) external { + getStorage().fixedValues[index] = address(uint160(value)); + } + + function writeNode(bytes4 id, uint256 amount, uint256 score, uint256 nonce) external { + Node storage node = getStorage().nodes[id]; + node.amount = address(uint160(amount)); + node.score = address(uint160(score)); + node.nonce = address(uint160(nonce)); + } + + function writeChild(uint256 index, uint256 amount, uint256 score, uint256 nonce) external { + Node storage node = getStorage().children[index]; + node.amount = address(uint160(amount)); + node.score = address(uint160(score)); + node.nonce = address(uint160(nonce)); + } + + function exportSelectors() external pure returns (bytes memory) { + return bytes.concat( + this.writeScalars.selector, + this.writeBalance.selector, + this.pushValue.selector, + this.writeFixed.selector, + this.writeNode.selector, + this.writeChild.selector + ); + } +} diff --git a/cli/test/pipelines/validatePipeline/fixtures/bytecode/script/Deploy.s.sol b/cli/test/pipelines/validatePipeline/fixtures/bytecode/script/Deploy.s.sol new file mode 100644 index 00000000..56772bfe --- /dev/null +++ b/cli/test/pipelines/validatePipeline/fixtures/bytecode/script/Deploy.s.sol @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +import {Script} from "forge-std/Script.sol"; +import {Diamond} from "../src/Diamond.sol"; +import {CanonicalStorageFacet} from "../src/CanonicalStorageFacet.sol"; +import {DiamondInspectFacet} from "@perfect-abstractions/compose/diamond/DiamondInspectFacet.sol"; +import {ERC20DataFacet} from "@perfect-abstractions/compose/token/ERC20/Data/ERC20DataFacet.sol"; +import {ERC20ApproveFacet} from "@perfect-abstractions/compose/token/ERC20/Approve/ERC20ApproveFacet.sol"; +import {ERC20TransferFacet} from "@perfect-abstractions/compose/token/ERC20/Transfer/ERC20TransferFacet.sol"; + +contract DeployScript is Script { + function run() public returns (Diamond diamond) { + vm.startBroadcast(); + + address[] memory facets = new address[](5); + facets[0] = address(new DiamondInspectFacet()); + facets[1] = address(new ERC20DataFacet()); + facets[2] = address(new ERC20ApproveFacet()); + facets[3] = address(new ERC20TransferFacet()); + facets[4] = address(new CanonicalStorageFacet()); + + diamond = new Diamond(facets); + vm.stopBroadcast(); + } +} diff --git a/cli/test/pipelines/validatePipeline/harness.ts b/cli/test/pipelines/validatePipeline/harness.ts index e7b9924e..d028b3d8 100644 --- a/cli/test/pipelines/validatePipeline/harness.ts +++ b/cli/test/pipelines/validatePipeline/harness.ts @@ -7,17 +7,20 @@ import { Context } from "../../../src/context/context"; export type ValidatePipelineHarness = { ctx: ComposeContext; projectRoot: string; + writeLock(deployments: Record): Promise; cleanup(): Promise; }; -const facets = [ +const defaultFacets = [ "FullStorageFacet", "CompatibleStorageFacet", "IncompatibleStorageFacet", ]; /** Creates a Foundry project containing compatible and incompatible storage facets. */ -export async function createValidatePipelineHarness(): Promise { +export async function createValidatePipelineHarness( + facets: string[] = defaultFacets, +): Promise { const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), "compose-validate-pipeline-")); const sourceRoot = path.join(projectRoot, "src"); const fixtureRoot = path.join(__dirname, "fixtures"); @@ -55,6 +58,11 @@ export async function createValidatePipelineHarness(): Promise fs.writeFile( + path.join(projectRoot, "compose.lock"), + JSON.stringify({ compose: "0.0.6", deployments }, null, 2), + "utf8", + ), cleanup: () => fs.rm(projectRoot, { recursive: true, force: true }), }; } diff --git a/cli/test/pipelines/validatePipeline/validatePipeline.test.ts b/cli/test/pipelines/validatePipeline/validatePipeline.test.ts deleted file mode 100644 index ad094c00..00000000 --- a/cli/test/pipelines/validatePipeline/validatePipeline.test.ts +++ /dev/null @@ -1,102 +0,0 @@ -import fs from "node:fs/promises"; -import path from "node:path"; -import { describe, expect, it, vi } from "vitest"; -import { ValidatePipeline } from "../../../src/pipelines/validatePipeline"; -import { findVirtualStorageLayoutCollisions } from "../../../src/modules/validation/virtualStorageLayout"; -import { ValidationModule } from "../../../src/modules/validation/module"; -import { createValidatePipelineHarness } from "./harness"; - -describe("validate pipeline", () => { - it("compiles Solidity and reports only the incompatible storage variables", async () => { - const harness = await createValidatePipelineHarness(); - const errors = vi.spyOn(console, "error").mockImplementation(() => undefined); - const warnings = vi.spyOn(console, "warn").mockImplementation(() => undefined); - - try { - const result = await ValidatePipeline.execute(harness.ctx); - const validation = ValidationModule.getVirtualStorageLayoutValidationState(result); - const collision = validation?.result?.collisions[0]; - const mismatchSummary = validation?.result?.collisions.flatMap((item) => - item.mismatches.map((mismatch) => [ - item.virtualPath, - `${mismatch.left.structName}.${mismatch.left.variableName}: ${mismatch.left.typeName}`, - `${mismatch.right.structName}.${mismatch.right.variableName}: ${mismatch.right.typeName}`, - ].join(" | ")) - ) ?? []; - const rootRecords = validation?.result?.records.filter( - (record) => record.virtualPath === "compose.fixture.virtual-storage", - ) ?? []; - const recordFor = (contractName: string) => rootRecords.find( - (record) => record.contractName === contractName, - )!; - - expect(result.state.validatePipeline?.success).toBe(false); - expect(validation?.success).toBe(false); - expect(collision?.diamondName).toBe("StorageDiamond"); - expect(collision?.virtualPath).toBe("compose.fixture.virtual-storage"); - expect(collision?.records.map((record) => record.contractName).sort()).toEqual([ - "CompatibleStorageFacet", - "FullStorageFacet", - "IncompatibleStorageFacet", - ]); - expect(mismatchSummary).toEqual([ - "compose.fixture.virtual-storage | InlineChild.facetCount: uint32 | InlineChild.facetCount: uint64", - "compose.fixture.virtual-storage | Storage.mapToDynamicArray: mapping(uint256 => uint256[]) | Storage.mapToDynamicArray: mapping(uint256 => address[])", - "compose.fixture.virtual-storage | Storage.dynamicValues: uint256[] | Storage.dynamicValues: address[]", - "compose.fixture.virtual-storage | Storage.packedDynamicValues: uint8[] | Storage.packedDynamicValues: uint16[]", - "compose.fixture.virtual-storage | Storage.fixedFive: uint256[5] | Storage.fixedFive: address[5]", - "compose.fixture.virtual-storage | Storage.fixedThreeHundred: uint256[300] | Storage.fixedThreeHundred: address[300]", - "compose.fixture.virtual-storage | Storage.nestedFixed: uint256[5][10] | Storage.nestedFixed: address[5][10]", - "compose.fixture.virtual-storage | Storage.internalFn: function (uint256) returns (uint256) | Storage.internalFn: uint256", - "compose.fixture.virtual-storage.367 | ContainerChild.amount: uint256 | ContainerChild.amount: address", - "compose.fixture.virtual-storage.368 | ContainerChild.amount: uint256 | ContainerChild.amount: address", - "compose.fixture.virtual-storage.369 | ContainerChild.amount: uint256 | ContainerChild.amount: address", - "compose.fixture.virtual-storage.373 | ContainerChild.amount: uint256 | ContainerChild.amount: address", - ]); - expect(findVirtualStorageLayoutCollisions([ - recordFor("FullStorageFacet"), - recordFor("CompatibleStorageFacet"), - ])).toEqual([]); - expect(findVirtualStorageLayoutCollisions([ - recordFor("FullStorageFacet"), - recordFor("IncompatibleStorageFacet"), - ])).toHaveLength(1); - await expect(fs.access(path.join( - harness.projectRoot, - "out", - "FullStorageFacet.sol", - "FullStorageFacet.json", - ))).resolves.toBeUndefined(); - - const output = errors.mock.calls.flat().map(String); - expect(output).toContain(" FullStorageFacet: InlineChild.facetCount"); - expect(output).toContain(" IncompatibleStorageFacet: InlineChild.facetCount"); - expect(output).toContain(" Storage path: Storage.inlineStruct.child.facetCount"); - expect(output).toContain(" FullStorageFacet: Storage.fixedThreeHundred"); - expect(output).toContain(" IncompatibleStorageFacet: Storage.fixedThreeHundred"); - expect(output).toContain(" Storage path: Storage.fixedThreeHundred"); - expect(output).toContain(" Storage path: Storage.childByAddress[key].amount"); - expect(output).toContain(" Storage path: Storage.childList[index].amount"); - expect(output).toContain(" Storage path: Storage.fixedChildren[index].amount"); - expect(output).toContain(" Storage path: Storage.nestedChildren[key][index].amount"); - expect(output.filter((message) => message === "")).toHaveLength(12); - expect(output.filter((message) => message === ` ${"─".repeat(48)}`)).toHaveLength(7); - expect(output.filter((message) => message.includes("ContainerChild.amount"))).toHaveLength(8); - expect(output.some((message) => message.includes("CompatibleStorageFacet:"))).toBe(false); - expect(output.some((message) => message.includes("0xf1"))).toBe(false); - expect(output.some((message) => message.includes("0x2f"))).toBe(false); - - const warningOutput = warnings.mock.calls.flat().map(String); - expect(warningOutput.some( - (message) => message.includes("FullStorageFacet: Storage.internalFn"), - )).toBe(true); - expect(warningOutput.some( - (message) => message.includes("internal function storage type uses compiler-specific representation"), - )).toBe(true); - } finally { - errors.mockRestore(); - warnings.mockRestore(); - await harness.cleanup(); - } - }, 30_000); -}); diff --git a/cli/test/pipelines/validatePipeline/validatePipelineBytecode.test.ts b/cli/test/pipelines/validatePipeline/validatePipelineBytecode.test.ts new file mode 100644 index 00000000..6b940fbe --- /dev/null +++ b/cli/test/pipelines/validatePipeline/validatePipelineBytecode.test.ts @@ -0,0 +1,39 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { + createBytecodeE2EHarness, + type BytecodeE2EHarness, +} from "./bytecodeHarness"; + +describe.sequential("validate pipeline bytecode E2E", () => { + let harness: BytecodeE2EHarness; + + beforeAll(async () => { + harness = await createBytecodeE2EHarness(); + }, 120_000); + + afterAll(async () => { + await harness?.cleanup(); + }); + + it("accepts deployed bytecode when the local VSL is a compatible append", async () => { + const projectRoot = await harness.createValidationProject("compatible"); + const result = await harness.runValidate(projectRoot); + const output = `${result.stdout}\n${result.stderr}`; + + expect(result.code, output).toBe(0); + expect(output).toContain("Validation passed."); + expect(output).not.toContain("Bytecode validation failed"); + }, 120_000); + + it("rejects deployed bytecode when the local VSL contradicts every canonical field", async () => { + const projectRoot = await harness.createValidationProject("incompatible"); + const result = await harness.runValidate(projectRoot); + const output = `${result.stdout}\n${result.stderr}`; + + expect(result.code, output).not.toBe(0); + expect(output).toContain("Bytecode validation failed"); + expect(output).toContain("compose.e2e.storage"); + expect(output).toContain("Expected:"); + expect(output).toContain("Observed:"); + }, 120_000); +}); diff --git a/cli/test/pipelines/validatePipeline/validatePipelineBytecodeUnavailable.test.ts b/cli/test/pipelines/validatePipeline/validatePipelineBytecodeUnavailable.test.ts new file mode 100644 index 00000000..db0ce901 --- /dev/null +++ b/cli/test/pipelines/validatePipeline/validatePipelineBytecodeUnavailable.test.ts @@ -0,0 +1,30 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { + createBytecodeUnavailableHarness, + type BytecodeUnavailableHarness, +} from "./bytecodeUnavailableHarness"; + +describe.sequential("validate pipeline unavailable bytecode E2E", () => { + let harness: BytecodeUnavailableHarness; + + beforeAll(async () => { + harness = await createBytecodeUnavailableHarness(); + await harness.removeCanonicalFacetCode(); + }, 120_000); + + afterAll(async () => { + await harness?.cleanup(); + }); + + it("reports an introspection error without blocking when packed selectors cannot be read", async () => { + const result = await harness.runValidate(); + const output = `${result.stdout}\n${result.stderr}`; + + expect(result.code, output).toBe(0); + expect(output).toContain("Diamond introspection error"); + expect(output).toContain("RPC readContract failed"); + expect(output).toContain("Validation completed with warnings."); + expect(output).not.toContain("Validation passed."); + expect(output).not.toContain("Bytecode validation failed"); + }, 120_000); +}); diff --git a/cli/test/pipelines/validatePipeline/validatePipelineVSL.test.ts b/cli/test/pipelines/validatePipeline/validatePipelineVSL.test.ts new file mode 100644 index 00000000..e8fc1b19 --- /dev/null +++ b/cli/test/pipelines/validatePipeline/validatePipelineVSL.test.ts @@ -0,0 +1,425 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { describe, expect, it, vi } from "vitest"; +import { ValidatePipeline } from "../../../src/pipelines/validatePipeline"; +import { findVirtualStorageLayoutCollisions } from "../../../src/modules/validation/virtualStorageLayout"; +import { ValidationModule } from "../../../src/modules/validation/module"; +import { createValidatePipelineHarness } from "./harness"; +import { DependencyResolver } from "../../../src/resolver/dependencyResolver"; +import { DependencyKey } from "../../../src/resolver/dependencyKey"; +import type { IRPCAdapter } from "../../../src/adapters/IRPCAdapter/interface"; +import type { IBytecodeValidatorAdapter } from "../../../src/adapters/IBytecodeValidatorAdapter/interface"; +import type { Address, Hex } from "viem"; +import { BytecodeValidatorAdapter } from "../../../src/adapters/IBytecodeValidatorAdapter/adapter"; + +const diamondAddress = "0x0000000000000000000000000000000000000001" as Address; +const facetAddress = "0x0000000000000000000000000000000000000002" as Address; + +function deployment() { + return { + diamond: diamondAddress, + facets: { ExampleFacet: facetAddress }, + facetHash: "0x01", + lastSync: "2026-09-20T00:00:00.000Z", + txHash: "0x02", + }; +} + +function bytecodeDependencies(report: ReturnType) { + const rpc: IRPCAdapter = { + getBlockNumber: vi.fn().mockResolvedValue(100n), + readContract: vi.fn().mockResolvedValue([{ + facet: facetAddress, + functionSelectors: ["0x12345678"], + }]), + getCode: vi.fn().mockResolvedValue("0x6000" as Hex), + }; + const validator: IBytecodeValidatorAdapter = { + validate: vi.fn().mockReturnValue(report), + }; + return { rpc, validator }; +} + +function emptyReport() { + return { + collisions: [], + validatedVariables: [], + uncertainScopes: [], + diagnostics: [], + delegatecallWarnings: [], + }; +} + +describe("validate pipeline", () => { + it("passes source validation and skips bytecode validation when compose.lock is absent", async () => { + const harness = await createValidatePipelineHarness([ + "FullStorageFacet", + "CompatibleStorageFacet", + ]); + const logs = vi.spyOn(console, "log").mockImplementation(() => undefined); + + try { + const result = await ValidatePipeline.execute(harness.ctx); + expect(result.state.validatePipeline?.success).toBe(true); + expect(result.state.bytecodeValidation).toMatchObject({ + success: true, + result: { skipped: true, deployments: [], failures: [] }, + }); + expect(logs.mock.calls.flat().map(String)).toContain("\u001b[32m\nValidation passed.\n\u001b[39m"); + } finally { + logs.mockRestore(); + await harness.cleanup(); + } + }, 30_000); + + it("validates introspected facets against the diamond VSL without blocking warnings", async () => { + const harness = await createValidatePipelineHarness([ + "FullStorageFacet", + "CompatibleStorageFacet", + ]); + await harness.writeLock({ StorageDiamond: { local: deployment() } }); + const report = { + ...emptyReport(), + uncertainScopes: [{ + location: { slot: "0", offset: 0, selector: "12345678", symbolicPath: "slot(0)" }, + virtualPath: "compose.fixture.virtual-storage", + reason: "Scoped evidence is incomplete.", + }], + }; + const { rpc, validator } = bytecodeDependencies(report); + const originalResolve = DependencyResolver.resolve.bind(DependencyResolver); + const resolver = vi.spyOn(DependencyResolver, "resolve").mockImplementation(async (requests) => { + if (requests.some((request) => request.key === DependencyKey.BytecodeValidator)) { + return { + [DependencyKey.RPC]: rpc, + [DependencyKey.BytecodeValidator]: validator, + }; + } + return originalResolve(requests); + }); + const warnings = vi.spyOn(console, "warn").mockImplementation(() => undefined); + const logs = vi.spyOn(console, "log").mockImplementation(() => undefined); + + try { + const result = await ValidatePipeline.execute(harness.ctx); + expect(result.state.validatePipeline?.success).toBe(true); + expect(validator.validate).toHaveBeenCalledWith(expect.objectContaining({ + virtualStorageLayout: { + records: expect.arrayContaining([ + expect.objectContaining({ diamondName: "StorageDiamond" }), + ]), + }, + })); + expect(rpc.getCode).toHaveBeenCalledWith(facetAddress, 100n); + const warningOutput = warnings.mock.calls.flat().map(String); + expect(warningOutput).toContain( + "\u001b[33m Scoped evidence is incomplete.\u001b[39m", + ); + expect(warningOutput).toContain(" compose.fixture.virtual-storage"); + const reasonIndex = warningOutput.findIndex((line) => line.includes("Scoped evidence is incomplete.")); + const pathIndex = warningOutput.findIndex((line) => line.includes("compose.fixture.virtual-storage")); + expect(reasonIndex).toBeGreaterThan(-1); + expect(reasonIndex).toBeLessThan(pathIndex); + } finally { + resolver.mockRestore(); + warnings.mockRestore(); + logs.mockRestore(); + await harness.cleanup(); + } + }, 30_000); + + it("completes with warnings when an introspected facet has no runtime bytecode", async () => { + const harness = await createValidatePipelineHarness([ + "FullStorageFacet", + "CompatibleStorageFacet", + ]); + await harness.writeLock({ StorageDiamond: { local: deployment() } }); + const { rpc, validator } = bytecodeDependencies(emptyReport()); + vi.mocked(rpc.getCode).mockResolvedValue("0x"); + const originalResolve = DependencyResolver.resolve.bind(DependencyResolver); + const resolver = vi.spyOn(DependencyResolver, "resolve").mockImplementation(async (requests) => { + if (requests.some((request) => request.key === DependencyKey.BytecodeValidator)) { + return { + [DependencyKey.RPC]: rpc, + [DependencyKey.BytecodeValidator]: validator, + }; + } + return originalResolve(requests); + }); + const warnings = vi.spyOn(console, "warn").mockImplementation(() => undefined); + const logs = vi.spyOn(console, "log").mockImplementation(() => undefined); + + try { + const result = await ValidatePipeline.execute(harness.ctx); + expect(result.state.validatePipeline?.success).toBe(true); + expect(result.state.bytecodeValidation).toMatchObject({ + success: true, + result: { complete: false }, + }); + expect(validator.validate).not.toHaveBeenCalled(); + + const warningOutput = warnings.mock.calls.flat().map(String); + expect(warningOutput.some((message) => message.includes("Bytecode validation incomplete"))) + .toBe(true); + expect(warningOutput).toContain("\u001b[33m\nValidation completed with warnings.\n\u001b[39m"); + expect(logs.mock.calls.flat().map(String)).not.toContain( + "\u001b[32m\nValidation passed.\n\u001b[39m", + ); + } finally { + resolver.mockRestore(); + warnings.mockRestore(); + logs.mockRestore(); + await harness.cleanup(); + } + }, 30_000); + + it("fails validation when deployed bytecode contradicts the diamond VSL", async () => { + const harness = await createValidatePipelineHarness([ + "FullStorageFacet", + "CompatibleStorageFacet", + ]); + await harness.writeLock({ StorageDiamond: { local: deployment() } }); + const collision = { + location: { slot: "0", offset: 0, selector: "12345678", pc: 42, symbolicPath: "slot(0)" }, + virtualPath: "compose.fixture.virtual-storage", + expectedType: "uint256", + observedType: "address", + reason: "type mismatch", + }; + const { rpc, validator } = bytecodeDependencies({ + ...emptyReport(), + collisions: [collision], + }); + const originalResolve = DependencyResolver.resolve.bind(DependencyResolver); + const resolver = vi.spyOn(DependencyResolver, "resolve").mockImplementation(async (requests) => { + if (requests.some((request) => request.key === DependencyKey.BytecodeValidator)) { + return { + [DependencyKey.RPC]: rpc, + [DependencyKey.BytecodeValidator]: validator, + }; + } + return originalResolve(requests); + }); + const errors = vi.spyOn(console, "error").mockImplementation(() => undefined); + + try { + const result = await ValidatePipeline.execute(harness.ctx); + expect(result.state.validatePipeline?.success).toBe(false); + expect(result.status.failedAt).toBe("validatePipeline"); + const output = errors.mock.calls.flat().map(String); + expect(output).toEqual(expect.arrayContaining([ + " compose.fixture.virtual-storage", + " Expected: uint256", + " Observed: address", + " PC: 42", + ])); + expect(output).toContain(" Affected:"); + expect(output.some((line) => line.startsWith(" - ") && line.includes("FullStorageFacet.sol"))) + .toBe(true); + expect(output.some((line) => line.startsWith(" - ") && line.includes("CompatibleStorageFacet.sol"))) + .toBe(true); + const reasonIndex = output.findIndex((line) => line.includes("type mismatch")); + const pathIndex = output.indexOf(" compose.fixture.virtual-storage"); + expect(reasonIndex).toBeGreaterThan(-1); + expect(reasonIndex).toBeLessThan(pathIndex); + } finally { + resolver.mockRestore(); + errors.mockRestore(); + await harness.cleanup(); + } + }, 30_000); + + it("continues validating other deployments after one chain fails", async () => { + const harness = await createValidatePipelineHarness([ + "FullStorageFacet", + "CompatibleStorageFacet", + ]); + await harness.writeLock({ + StorageDiamond: { + broken: deployment(), + healthy: deployment(), + }, + }); + const { rpc, validator } = bytecodeDependencies(emptyReport()); + const originalResolve = DependencyResolver.resolve.bind(DependencyResolver); + const resolver = vi.spyOn(DependencyResolver, "resolve").mockImplementation(async (requests) => { + if (requests.some((request) => request.key === DependencyKey.BytecodeValidator)) { + const chainKey = requests.find((request) => request.key === DependencyKey.RPC) + ?.params?.chainKey; + if (chainKey === "broken") throw new Error("RPC unavailable"); + return { + [DependencyKey.RPC]: rpc, + [DependencyKey.BytecodeValidator]: validator, + }; + } + return originalResolve(requests); + }); + const errors = vi.spyOn(console, "error").mockImplementation(() => undefined); + + try { + const result = await ValidatePipeline.execute(harness.ctx); + expect(result.state.validatePipeline?.success).toBe(false); + expect(result.state.bytecodeValidation).toMatchObject({ + success: false, + result: { + deployments: [expect.objectContaining({ chainKey: "healthy" })], + failures: [expect.objectContaining({ + chainKey: "broken", + message: "RPC unavailable", + })], + }, + }); + expect(validator.validate).toHaveBeenCalledOnce(); + } finally { + resolver.mockRestore(); + errors.mockRestore(); + await harness.cleanup(); + } + }, 30_000); + + it("runs compiled facet bytecode through the packaged validator", async () => { + const harness = await createValidatePipelineHarness(["FullStorageFacet"]); + await harness.writeLock({ StorageDiamond: { local: deployment() } }); + const rpc: IRPCAdapter = { + getBlockNumber: vi.fn().mockResolvedValue(100n), + readContract: vi.fn().mockResolvedValue([{ + facet: facetAddress, + functionSelectors: ["0x12345678"], + }]), + getCode: vi.fn(async () => { + const artifact = JSON.parse(await fs.readFile(path.join( + harness.projectRoot, + "out", + "FullStorageFacet.sol", + "FullStorageFacet.json", + ), "utf8")) as { deployedBytecode: { object: Hex } }; + return artifact.deployedBytecode.object; + }), + }; + const originalResolve = DependencyResolver.resolve.bind(DependencyResolver); + const resolver = vi.spyOn(DependencyResolver, "resolve").mockImplementation(async (requests) => { + if (requests.some((request) => request.key === DependencyKey.BytecodeValidator)) { + return { + [DependencyKey.RPC]: rpc, + [DependencyKey.BytecodeValidator]: BytecodeValidatorAdapter, + }; + } + return originalResolve(requests); + }); + const warnings = vi.spyOn(console, "warn").mockImplementation(() => undefined); + const logs = vi.spyOn(console, "log").mockImplementation(() => undefined); + + try { + const result = await ValidatePipeline.execute(harness.ctx); + expect(result.state.validatePipeline?.success).toBe(true); + expect(result.state.bytecodeValidation).toMatchObject({ + success: true, + result: { + deployments: [{ + facets: [{ + report: { collisions: [] }, + }], + }], + }, + }); + } finally { + resolver.mockRestore(); + warnings.mockRestore(); + logs.mockRestore(); + await harness.cleanup(); + } + }, 30_000); + + it("compiles Solidity and reports only the incompatible storage variables", async () => { + const harness = await createValidatePipelineHarness(); + const errors = vi.spyOn(console, "error").mockImplementation(() => undefined); + const warnings = vi.spyOn(console, "warn").mockImplementation(() => undefined); + + try { + const result = await ValidatePipeline.execute(harness.ctx); + const validation = ValidationModule.getVirtualStorageLayoutValidationState(result); + const collision = validation?.result?.collisions[0]; + const mismatchSummary = validation?.result?.collisions.flatMap((item) => + item.mismatches.map((mismatch) => [ + item.virtualPath, + `${mismatch.left.structName}.${mismatch.left.variableName}: ${mismatch.left.typeName}`, + `${mismatch.right.structName}.${mismatch.right.variableName}: ${mismatch.right.typeName}`, + ].join(" | ")) + ) ?? []; + const rootRecords = validation?.result?.records.filter( + (record) => record.virtualPath === "compose.fixture.virtual-storage", + ) ?? []; + const recordFor = (contractName: string) => rootRecords.find( + (record) => record.contractName === contractName, + )!; + + expect(result.state.validatePipeline?.success).toBe(false); + expect(validation?.success).toBe(false); + expect(collision?.diamondName).toBe("StorageDiamond"); + expect(collision?.virtualPath).toBe("compose.fixture.virtual-storage"); + expect(collision?.records.map((record) => record.contractName).sort()).toEqual([ + "CompatibleStorageFacet", + "FullStorageFacet", + "IncompatibleStorageFacet", + ]); + expect(mismatchSummary).toEqual([ + "compose.fixture.virtual-storage | InlineChild.facetCount: uint32 | InlineChild.facetCount: uint64", + "compose.fixture.virtual-storage | Storage.mapToDynamicArray: mapping(uint256 => uint256[]) | Storage.mapToDynamicArray: mapping(uint256 => address[])", + "compose.fixture.virtual-storage | Storage.dynamicValues: uint256[] | Storage.dynamicValues: address[]", + "compose.fixture.virtual-storage | Storage.packedDynamicValues: uint8[] | Storage.packedDynamicValues: uint16[]", + "compose.fixture.virtual-storage | Storage.fixedFive: uint256[5] | Storage.fixedFive: address[5]", + "compose.fixture.virtual-storage | Storage.fixedThreeHundred: uint256[300] | Storage.fixedThreeHundred: address[300]", + "compose.fixture.virtual-storage | Storage.nestedFixed: uint256[5][10] | Storage.nestedFixed: address[5][10]", + "compose.fixture.virtual-storage | Storage.internalFn: function (uint256) returns (uint256) | Storage.internalFn: uint256", + "compose.fixture.virtual-storage.367 | ContainerChild.amount: uint256 | ContainerChild.amount: address", + "compose.fixture.virtual-storage.368 | ContainerChild.amount: uint256 | ContainerChild.amount: address", + "compose.fixture.virtual-storage.369 | ContainerChild.amount: uint256 | ContainerChild.amount: address", + "compose.fixture.virtual-storage.373 | ContainerChild.amount: uint256 | ContainerChild.amount: address", + ]); + expect(findVirtualStorageLayoutCollisions([ + recordFor("FullStorageFacet"), + recordFor("CompatibleStorageFacet"), + ])).toEqual([]); + expect(findVirtualStorageLayoutCollisions([ + recordFor("FullStorageFacet"), + recordFor("IncompatibleStorageFacet"), + ])).toHaveLength(1); + await expect(fs.access(path.join( + harness.projectRoot, + "out", + "FullStorageFacet.sol", + "FullStorageFacet.json", + ))).resolves.toBeUndefined(); + + const output = errors.mock.calls.flat().map(String); + expect(output).toContain(" FullStorageFacet: InlineChild.facetCount"); + expect(output).toContain(" IncompatibleStorageFacet: InlineChild.facetCount"); + expect(output).toContain(" Storage path: Storage.inlineStruct.child.facetCount"); + expect(output).toContain(" FullStorageFacet: Storage.fixedThreeHundred"); + expect(output).toContain(" IncompatibleStorageFacet: Storage.fixedThreeHundred"); + expect(output).toContain(" Storage path: Storage.fixedThreeHundred"); + expect(output).toContain(" Storage path: Storage.childByAddress[key].amount"); + expect(output).toContain(" Storage path: Storage.childList[index].amount"); + expect(output).toContain(" Storage path: Storage.fixedChildren[index].amount"); + expect(output).toContain(" Storage path: Storage.nestedChildren[key][index].amount"); + expect(output.filter((message) => message === "")).toHaveLength(12); + expect(output.filter((message) => message === ` ${"─".repeat(48)}`)).toHaveLength(7); + expect(output.filter((message) => message.includes("ContainerChild.amount"))).toHaveLength(8); + expect(output.some((message) => message.includes("CompatibleStorageFacet:"))).toBe(false); + expect(output.some((message) => message.includes("0xf1"))).toBe(false); + expect(output.some((message) => message.includes("0x2f"))).toBe(false); + + const warningOutput = warnings.mock.calls.flat().map(String); + expect(warningOutput.some( + (message) => message.includes("FullStorageFacet: Storage.internalFn"), + )).toBe(true); + expect(warningOutput.some( + (message) => message.includes("internal function storage type uses compiler-specific representation"), + )).toBe(true); + } finally { + errors.mockRestore(); + warnings.mockRestore(); + await harness.cleanup(); + } + }, 30_000); +}); diff --git a/package-lock.json b/package-lock.json index f5683871..54f252cd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,6 +32,7 @@ "@inquirer/select": "5.2.1", "@perfect-abstractions/compose": "0.0.6", "commander": "^13.1.0", + "compose-bytecode-validator": "0.1.2", "dotenv": "^16.4.7", "fs-extra": "^11.3.3", "picocolors": "^1.1.1", @@ -11525,6 +11526,15 @@ "integrity": "sha512-QE33hToZseCH3jS0qN96O/bSh3kaw/h+Tq7ngyY9eWDUnTlTNUyqfqvCXioLe5Na5jFsL78ra/wuBU4iuEgd4w==", "license": "ISC" }, + "node_modules/compose-bytecode-validator": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/compose-bytecode-validator/-/compose-bytecode-validator-0.1.2.tgz", + "integrity": "sha512-nsJJxe3+uFeSLbVCZgY/qTC5rE7xHzW5v12D67C5kgxBJ1m59Xz79VqMMqtdte0jsGiEoX3xvyVL4JxTQCZ73w==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, "node_modules/compose-documentation": { "resolved": "website", "link": true