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

Filter by extension

Filter by extension


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

Add deployment script generation from compose.json with selected-diamond validation, local and package facets, framework-native contract references, and typed deployment artifacts.
10 changes: 9 additions & 1 deletion cli/src/adapters/IFrameworkAdapter/foundryAdapter/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
IFrameworkAdapter,
SolidityAstSource,
} from "../interface";
import { writeFileIfMissing } from "../../../utils/files";
import { writeFileIfMissing, toPosixPath } from "../../../utils/files";
import { runCommand } from "../../../utils/exec";
import {
composePackageSubpath,
Expand Down Expand Up @@ -38,6 +38,14 @@ const adapter: IFrameworkAdapter = {
await runCommand("forge", ["build"], { cwd: projectRoot });
},

async getDeploymentToolbox(): Promise<null> {
return null;
},

async getDeploymentContractReference(projectRoot: string, sourcePath: string, contractName: string): Promise<string> {
return `${toPosixPath(path.relative(projectRoot, sourcePath))}:${contractName}`;
},

async resolveSoliditySourcePath(ctx: ComposeContext, sourcePath: string): Promise<string> {
if (path.isAbsolute(sourcePath)) return sourcePath;

Expand Down
18 changes: 17 additions & 1 deletion cli/src/adapters/IFrameworkAdapter/hardhatAdapter/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {
import { ScaffoldingModule } from "../../../modules/scaffolding/module";
import { CLI_ROOT } from "../../../utils/cliRoot";
import { isSourceUnitAst, listJsonFiles, uniqueAstSources } from "../../../utils/solidityAst";
import { resolveHardhatAstSourcePath } from "./helpers";
import { resolveHardhatAstSourcePath, resolveDeploymentContractReference } from "./helpers";

/** Framework adapter for Hardhat-based Diamond projects. */
const adapter: IFrameworkAdapter = {
Expand All @@ -39,6 +39,22 @@ const adapter: IFrameworkAdapter = {
await runCommand("npx", ["hardhat", "compile"], { cwd: projectRoot });
},

async getDeploymentToolbox(projectRoot: string): Promise<"ethers" | "viem"> {
const manifest = JSON.parse(await fs.readFile(path.join(projectRoot, "package.json"), "utf8")) as {
dependencies?: Record<string, string>;
devDependencies?: Record<string, string>;
};
const packages = { ...manifest.dependencies, ...manifest.devDependencies };
const ethers = Boolean(packages["@nomicfoundation/hardhat-ethers"] || packages["@nomicfoundation/hardhat-toolbox-mocha-ethers"] || packages["@nomicfoundation/hardhat-toolbox"]);
const viem = Boolean(packages["@nomicfoundation/hardhat-viem"] || packages["@nomicfoundation/hardhat-toolbox-viem"]);
if (ethers === viem) throw new Error("Cannot determine the Hardhat deployment toolbox. Specify ethers or viem explicitly.");
return viem ? "viem" : "ethers";
},

async getDeploymentContractReference(projectRoot: string, sourcePath: string, contractName: string): Promise<string> {
return resolveDeploymentContractReference(projectRoot, adapter.getArtifactDir(projectRoot), sourcePath, contractName);
},

async resolveSoliditySourcePath(ctx: ComposeContext, sourcePath: string): Promise<string> {
if (path.isAbsolute(sourcePath)) return sourcePath;

Expand Down
19 changes: 19 additions & 0 deletions cli/src/adapters/IFrameworkAdapter/hardhatAdapter/helpers.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,23 @@
import path from "node:path";
import fs from "node:fs/promises";
import { listJsonFiles } from "../../../utils/solidityAst";

/** Resolve an artifact by its compiler source identity, including npm source aliases. */
export async function resolveDeploymentContractReference(projectRoot: string, artifactsRoot: string, sourcePath: string, contractName: string): Promise<string> {
const normalize = (value: string): string => process.platform === "win32" ? path.resolve(value).toLowerCase() : path.resolve(value);
const matches = new Set<string>();
for (const file of await listJsonFiles(artifactsRoot)) {
if (path.basename(file) !== `${contractName}.json`) continue;
const artifact = JSON.parse(await fs.readFile(file, "utf8")) as { sourceName?: string; inputSourceName?: string; contractName?: string };
if (artifact.contractName !== contractName || !artifact.sourceName) continue;
const compilerSource = artifact.inputSourceName ?? artifact.sourceName;
if (normalize(resolveHardhatAstSourcePath(projectRoot, compilerSource)) === normalize(sourcePath)) {
matches.add(`${artifact.sourceName}:${contractName}`);
}
}
if (matches.size !== 1) throw new Error(`Deployment artifact not found or ambiguous: ${sourcePath}:${contractName}`);
return [...matches][0];
}

/** Converts a Hardhat compiler source name into its readable filesystem path. */
export function resolveHardhatAstSourcePath(projectRoot: string, sourceName: string): string {
Expand Down
6 changes: 6 additions & 0 deletions cli/src/adapters/IFrameworkAdapter/interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,12 @@ export interface IFrameworkAdapter {
/** Compile explicit Solidity entrypoints and return every unique source unit AST. */
compileAst(ctx: ComposeContext, sourcePaths: string[]): Promise<SolidityAstSource[]>;

/** Detect the deployment API installed in a Hardhat project, or null for Foundry. */
getDeploymentToolbox(projectRoot: string): Promise<"ethers" | "viem" | null>;

/** Resolve the compiled contract identity used by the framework's deployment API. */
getDeploymentContractReference(projectRoot: string, sourcePath: string, contractName: string): Promise<string>;

/**
* Resolve a catalog Solidity path to a readable local file.
*
Expand Down
3 changes: 2 additions & 1 deletion cli/src/modules/deployGeneration/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,6 @@ export function resolveDeployGenerationModel(
facetName: facet.name,
contractName: scaffoldEntry.contractName,
importPath,
source: facet.source,
origin: scaffoldEntry.origin,
group: classifyDeployGroup(facet.source),
};
Expand All @@ -92,6 +91,8 @@ export function resolveDeployGenerationModel(

return {
outputPath: path.join(scriptRoot, outputFileName),
projectRoot,
diamond: { contractName: "Diamond", importPath: `${ctx.param.framework === "hardhat" ? "contracts" : "src"}/Diamond.sol` },
facets,
};
}
22 changes: 17 additions & 5 deletions cli/src/modules/deployGeneration/renderers.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,16 @@
import { DeployFacetEntry, DeployGenerationModel } from "./types";
import { toCamelCase } from "../../utils/strings";
import { loadTemplate } from "../../utils/codegen";
import path from "node:path";
import { toPosixPath, toRelativeImport } from "../../utils/files";
import { renderFacetSections } from "../facetSections";

function scriptImport(model: DeployGenerationModel, importPath: string): string {
return toRelativeImport(toPosixPath(path.relative(
path.dirname(model.outputPath), path.resolve(model.projectRoot, importPath),
)));
}

/**
* Renders a Foundry (Solidity) deploy script that deploys a Diamond proxy
* with all facets in base-first, library-second order.
Expand All @@ -15,8 +23,8 @@ export async function renderFoundryDeployScript(model: DeployGenerationModel): P

const imports = model.facets
.map((facet) => {
const importPath = facet.origin === "local"
? `../${facet.importPath}`
const importPath = facet.origin === "local" || !facet.importPath.startsWith("@")
? scriptImport(model, facet.importPath)
: facet.importPath;
return `import {${facet.contractName}} from "${importPath}";`;
})
Expand All @@ -33,10 +41,12 @@ export async function renderFoundryDeployScript(model: DeployGenerationModel): P
const { baseLines, libraryLines } = renderFacetSections(model.facets, renderDeployLine, "\n");

return template
.replaceAll("{{DIAMOND_NAME}}", model.diamond.contractName)
.replace("{{DIAMOND_IMPORT}}", scriptImport(model, model.diamond.importPath))
.replace("{{IMPORTS}}", imports)
.replace("{{FACET_COUNT}}", String(model.facets.length))
.replace("{{BASE_LINES}}", baseLines)
.replace("{{LIBRARY_LINES}}", libraryLines);
.replace("{{LIBRARY_SECTION}}", libraryLines ? `\n\n /* Library facet generation. */\n${libraryLines}` : "");
}

/**
Expand All @@ -47,7 +57,7 @@ export async function renderFoundryDeployScript(model: DeployGenerationModel): P
*/
function renderHardhatEthersFacetDeploy(facet: DeployFacetEntry): string {
const variableName = toCamelCase(facet.contractName) || "facet";
return ` const ${variableName} = await ethers.deployContract("${facet.contractName}");
return ` const ${variableName} = await ethers.deployContract("${facet.deploymentReference ?? `${facet.importPath}:${facet.contractName}`}");
await ${variableName}.waitForDeployment();
console.log("${facet.contractName}:", await ${variableName}.getAddress());
facets.push(await ${variableName}.getAddress());`;
Expand All @@ -61,7 +71,7 @@ function renderHardhatEthersFacetDeploy(facet: DeployFacetEntry): string {
*/
function renderHardhatViemFacetDeploy(facet: DeployFacetEntry): string {
const variableName = toCamelCase(facet.contractName) || "facet";
return ` const ${variableName} = await viem.deployContract("${facet.contractName}");
return ` const ${variableName} = await viem.deployContract("${facet.deploymentReference ?? `${facet.importPath}:${facet.contractName}`}");
console.log("${facet.contractName}:", ${variableName}.address);
facets.push(${variableName}.address);`;
}
Expand All @@ -79,6 +89,7 @@ export async function renderHardhatEthersDeployScript(model: DeployGenerationMod
]);

return template
.replace("{{DIAMOND_REFERENCE}}", model.diamond.deploymentReference ?? `${toPosixPath(model.diamond.importPath)}:${model.diamond.contractName}`)
.replace("{{BASE_LINES}}", baseLines)
.replace("{{LIBRARY_LINES}}", libraryLines);
}
Expand All @@ -96,6 +107,7 @@ export async function renderHardhatViemDeployScript(model: DeployGenerationModel
]);

return template
.replace("{{DIAMOND_REFERENCE}}", model.diamond.deploymentReference ?? `${toPosixPath(model.diamond.importPath)}:${model.diamond.contractName}`)
.replace("{{BASE_LINES}}", baseLines)
.replace("{{LIBRARY_LINES}}", libraryLines);
}
6 changes: 4 additions & 2 deletions cli/src/modules/deployGeneration/types.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,19 @@
import { FacetOrigin, SelectedFacetSource } from "../scaffolding/types";
import { FacetOrigin } from "../scaffolding/types";

export type DeployFacetGroup = "base" | "library";

export type DeployFacetEntry = {
facetName: string;
contractName: string;
importPath: string;
source: SelectedFacetSource;
origin: FacetOrigin;
group: DeployFacetGroup;
deploymentReference?: string;
};

export type DeployGenerationModel = {
outputPath: string;
projectRoot: string;
diamond: { contractName: string; importPath: string; deploymentReference?: string };
facets: DeployFacetEntry[];
};
19 changes: 19 additions & 0 deletions cli/src/modules/deployScript/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Deployment Script Generation

`DeployScriptPipeline.execute(ctx)` generates a deployment script from the selected diamond in `compose.json`. It is the generation workflow for the future `compose deploy` command, not an execution or confirmation workflow.

Inputs in `ctx.param`:

- `projectRoot`: search for `compose.json` here or in an ancestor directory.
- `diamond`: diamond key, required when several diamonds exist unless selected interactively.
- `chain`: chain key, defaults to `local`.
- `toolbox`: optional Hardhat `ethers` or `viem` override. Otherwise the framework adapter detects the installed deployment plugin.
- `yes`: disable interactive diamond selection.

The pipeline loads the selected local/package facets, runs `ValidatePipeline` in a scoped child context, checks compiled constructor signatures, and uses the shared deploy renderers. Validation includes the existing on-chain checks when `compose.lock` contains a deployment for the selected diamond. The parent retains the child result under `ctx.state.deployValidation`.

Scripts are written exclusively under `compose-scripts/<chain>/` with timestamped, unique filenames. Foundry imports are relative to the generated script. Hardhat contract references come from compiled artifacts, including package source names.

`ctx.state.deployScript` and `ctx.state.deployScriptPipeline` return a `DeployScriptArtifact`: script path, selected diamond, source and artifact contract identities, facet identities, chain, framework/toolbox, and source-AST expected selectors. Missing export evidence is represented as `null`. This artifact describes intended deployment and is not a transaction receipt or proof that runtime selectors match.

The generator does not change Solidity source, `compose.json`, or `compose.lock`, and does not broadcast transactions. Constructor initialization already in the diamond source is preserved and runs when the script is executed. Supported constructors take no arguments for facets and one `address[]` argument for the diamond. Other constructor shapes and explicit `init` configuration are rejected until their configuration schema is defined.
28 changes: 28 additions & 0 deletions cli/src/modules/deployScript/contracts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import type { SolidityAstSource } from "../../adapters/IFrameworkAdapter/interface";
import { matchesAstSource } from "../validation/astIdentity";
import type { DeployContract, DeployProject } from "./types";

type Node = Record<string, unknown>;

/** Check supported constructor shapes before emitting a script with implicit arguments. */
export function validateDeployConstructors(project: DeployProject, sources: SolidityAstSource[]): void {
const check = (identity: DeployContract, diamond: boolean): void => {
const matches = sources.filter((source) => matchesAstSource(source.sourceName, identity.sourcePath))
.flatMap((source) => source.ast.nodes ?? [])
.filter((node): node is Node => Boolean(node) && typeof node === "object")
.filter((node) => node.nodeType === "ContractDefinition" && node.name === identity.contractName);
if (matches.length !== 1) throw new Error(`Contract not found or ambiguous in compiler AST: ${identity.reference}`);
const definition = matches[0];
if (definition.abstract === true || definition.contractKind !== "contract") throw new Error(`Contract is not deployable: ${identity.reference}`);
const nodes = (definition.nodes ?? []) as Node[];
const constructor = nodes.find((node) => node.nodeType === "FunctionDefinition" && node.kind === "constructor");
const parameters = (constructor?.parameters as { parameters?: Node[] } | undefined)?.parameters ?? [];
const type = parameters[0]?.typeDescriptions as { typeString?: string } | undefined;
const supported = diamond
? parameters.length === 1 && /^address\[\]( memory)?$/.test(type?.typeString ?? "")
: parameters.length === 0;
if (!supported) throw new Error(`Unsupported constructor for ${identity.reference}. Expected ${diamond ? "one address[] facet argument" : "no arguments"}.`);
};
check(project.diamond, true);
project.facets.forEach((facet) => check(facet, false));
}
78 changes: 78 additions & 0 deletions cli/src/modules/deployScript/model.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import path from "node:path";
import { randomUUID } from "node:crypto";
import type { IHashingAdapter } from "../../adapters/IHashingAdapter/interface";
import { toPosixPath } from "../../utils/files";
import type { DeployGenerationModel } from "../deployGeneration/types";
import { matchesAstSource } from "../validation/astIdentity";
import type { FacetScanResult } from "../validation/types";
import type { DeployProject, DeployScriptArtifact } from "./types";

export type DeploymentReferences = { diamond: string; facets: string[] };

/** Build the renderer model from the selected composition and framework identities. */
export function buildDeployScriptModel(
project: DeployProject,
references: DeploymentReferences,
generatedAt: string,
): DeployGenerationModel {
const safeName = project.diamondName.replace(/[^A-Za-z0-9_-]/g, "_") || "diamond";
const extension = project.framework === "foundry" ? "s.sol" : "ts";
const filename = `${generatedAt.replace(/[-:.]/g, "")}-deploy-${safeName}-${randomUUID().slice(0, 8)}.${extension}`;
return {
outputPath: path.join(project.projectRoot, "compose-scripts", project.chain.key, filename),
projectRoot: project.projectRoot,
diamond: {
contractName: project.diamond.contractName,
importPath: project.diamond.reference.slice(0, project.diamond.reference.lastIndexOf(":")),
deploymentReference: references.diamond,
},
facets: project.facets.map((facet, index) => ({
facetName: facet.name,
contractName: facet.contractName,
origin: facet.origin,
group: "base",
importPath: toPosixPath(path.relative(project.projectRoot, facet.sourcePath)),
deploymentReference: references.facets[index],
})),
};
}

/** Attach source-side selector evidence without implying that the script has executed. */
export function buildDeployScriptArtifact(
project: DeployProject,
references: DeploymentReferences,
model: DeployGenerationModel,
generatedAt: string,
toolbox: DeployScriptArtifact["toolbox"],
scans: FacetScanResult[],
hashing: IHashingAdapter,
): DeployScriptArtifact {
return {
kind: "deployment-script",
outputPath: model.outputPath,
generatedAt,
composeJsonPath: project.composeJsonPath,
diamondName: project.diamondName,
diamond: { ...project.diamond, artifactReference: references.diamond },
chain: project.chain,
framework: project.framework,
toolbox,
selectorEvidence: "source-ast",
facets: project.facets.map((facet, index) => {
const scan = scans.find((candidate) =>
candidate.facetName === facet.contractName && matchesAstSource(candidate.path, facet.sourcePath),
);
if (!scan) throw new Error(`Missing selector evidence for ${facet.reference}.`);
return {
name: facet.name,
reference: facet.reference,
sourcePath: facet.sourcePath,
contractName: facet.contractName,
artifactReference: references.facets[index],
expectedSelectors: scan.hasExportSelectorsFunction
? scan.exportedSelectors.map((signature) => hashing.keccak256(signature).slice(0, 10))
: null,
};
}),
};
}
Loading
Loading