From d247ecbd8a78a65549d5d3b6095cbeba0013e005 Mon Sep 17 00:00:00 2001 From: Roberto Cano <3525807+robercano@users.noreply.github.com> Date: Fri, 24 Jul 2026 16:54:07 +0200 Subject: [PATCH] =?UTF-8?q?feat(core):=20first-class=20upgradeable=20contr?= =?UTF-8?q?acts=20(UUPS=20/=20Transparent=20proxies)=20=E2=80=94=20#155?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a declarative `upgradeable` field to `ContractEntry` (spec/types.ts, spec/schema.ts) supporting UUPS and Transparent proxy patterns, with cross-field validation (spec/validate.ts): initializer/proxyAdminOwner refs participate in ordering/cycle checks exactly like constructor args, initializer.function is validated as a plain Solidity identifier (INVALID_IDENTIFIER), and proxyAdminOwner is required for transparent proxies / rejected for uups (MISSING_PROXY_ADMIN_OWNER / UNUSED_PROXY_ADMIN_OWNER) — no unsafe defaults. compile/compile.ts composes the proxy by hand from Ignition primitives (ignition-core 0.15.15 has no m.proxy): impl deployment, an optional m.encodeFunctionCall initializer, the ERC1967Proxy/TransparentUpgradeableProxy future (artifact names overridable via CompileOptions.proxyArtifacts), and a final m.contractAt handle stored under the entry's own id so every downstream ref (and the compiled result) resolves to the PROXY, not the implementation. Also adds compileUpgrade() — a standalone v1 upgrade-flow compiler (UUPS: upgradeToAndCall; Transparent: ProxyAdmin.upgradeAndCall) — and deploy/proxyHistory.ts, a typed proxy-implementation-history data model with pure derive/append helpers (not wired into reader — future work). simulate.ts surfaces an `upgradeable` marker on PlannedStep and includes initializer/proxyAdminOwner refs in dependsOn, without changing output for non-upgradeable entries. Storage-layout safety is explicitly out of scope (v1) — documented in spec/types.ts, compile.ts, and the new packages/core/README.md. Co-Authored-By: Claude Sonnet 5 --- packages/core/README.md | 109 ++++ packages/core/src/compile/compile.ts | 660 +++++++++++++++++------ packages/core/src/compile/errors.ts | 10 +- packages/core/src/deploy/proxyHistory.ts | 161 ++++++ packages/core/src/index.ts | 20 +- packages/core/src/simulate/simulate.ts | 49 +- packages/core/src/spec/schema.ts | 42 +- packages/core/src/spec/types.ts | 89 +++ packages/core/src/spec/validate.ts | 177 ++++-- packages/core/test/compile.test.ts | 498 ++++++++++++++++- packages/core/test/proxyHistory.test.ts | 200 +++++++ packages/core/test/simulate.test.ts | 102 ++++ packages/core/test/spec.test.ts | 384 +++++++++++++ 13 files changed, 2302 insertions(+), 199 deletions(-) create mode 100644 packages/core/README.md create mode 100644 packages/core/src/deploy/proxyHistory.ts create mode 100644 packages/core/test/proxyHistory.test.ts diff --git a/packages/core/README.md b/packages/core/README.md new file mode 100644 index 0000000..0d53414 --- /dev/null +++ b/packages/core/README.md @@ -0,0 +1,109 @@ +# @redeploy/core + +Deployment engine on top of Hardhat Ignition: declarative deployment spec, dependency +resolution/ordering, idempotent journal-based resume. + +See the repo-level `CLAUDE.md` for the overall project context. This README covers one +feature in depth: **upgradeable proxies**. + +## Upgradeable proxies (UUPS / Transparent) + +A `ContractEntry` can declare `upgradeable` to be deployed behind a proxy instead of +directly: + +```ts +const spec: DeploymentSpec = { + version: 1, + contracts: [ + { + id: "vault", + contract: "Vault", + args: [{ kind: "literal", value: "0x..." }], + upgradeable: { + kind: "uups", + initializer: { + function: "initialize", + args: [{ kind: "literal", value: "0x..." }], + }, + }, + }, + ], +}; +``` + +`compileSpec()` (`src/compile/compile.ts`) has no built-in `m.proxy()` helper to lean on +(ignition-core 0.15.15 doesn't ship one), so it composes the standard OpenZeppelin proxy +pattern by hand from Ignition primitives: + +1. `impl = m.contract(entry.contract, ctorArgs, { id: "_impl" })` — the implementation. +2. `data = m.encodeFunctionCall(impl, initializer.function, initializerArgs)` (or `"0x"` + if no `initializer` is declared). +3. The proxy itself: + - **uups**: `m.contract("ERC1967Proxy", [impl, data], { id: "_proxy" })` + - **transparent**: `m.contract("TransparentUpgradeableProxy", [impl, proxyAdminOwner, data], { id: "_proxy" })` + — OZ v5's `TransparentUpgradeableProxy` deploys its own `ProxyAdmin` internally, + owned by `proxyAdminOwner`. +4. `handle = m.contractAt(entry.contract, proxy, { id: entry.id })` — this is what gets + stored under the entry's `id`. + +**Refs always resolve to the proxy.** Any `{ kind: "ref", contract: "vault" }` elsewhere +in the spec — and `DeployResult.deployedAddresses["vault"]` — resolves to the PROXY's +address, exposed with the IMPLEMENTATION's ABI. That's the entire point of a proxy: +callers always talk to the one stable address, across every future upgrade. + +### uups vs. transparent + +| | `kind: "uups"` | `kind: "transparent"` | +|---|---|---| +| Proxy artifact | `ERC1967Proxy` | `TransparentUpgradeableProxy` | +| Who can upgrade | The implementation itself (must inherit OZ's `UUPSUpgradeable` and implement `_authorizeUpgrade`) | A separate `ProxyAdmin` contract, deployed internally by the proxy | +| `proxyAdminOwner` | Not used — rejected by `validateSpec` (`UNUSED_PROXY_ADMIN_OWNER`) if set | **Required** — rejected by `validateSpec` (`MISSING_PROXY_ADMIN_OWNER`) if absent | + +The proxy artifact names are overridable per-project via +`CompileOptions.proxyArtifacts` (`{ uups?: string; transparent?: string }`), for +projects that vendor OpenZeppelin under different artifact names. Both artifacts must be +compiled/available to the `ArtifactResolver` passed to `deploy()` — they ship as part of +`openzeppelin-contracts` v5; reDeploy does not vendor OZ's Solidity sources itself. + +### Initializer + +The optional `initializer` runs atomically with proxy deployment, via `delegatecall` +through the proxy — so it initializes PROXY storage, not the implementation's. This is +typically an OZ `Initializable` contract's `initialize(...)` function. `function` must be +a plain Solidity identifier (`validateSpec`'s `INVALID_IDENTIFIER` rejects anything else +— it flows directly into on-chain calldata), and `args` go through the exact same +validated ref/literal/param/expr/resolver arg-mapping as constructor `args`. + +### Proxy-admin ownership (security) + +For `kind: "transparent"`, `proxyAdminOwner` is the INITIAL owner of the proxy's +`ProxyAdmin` — only that address may later call `ProxyAdmin.upgradeAndCall`. There is +**no fallback default** (no `address(0)`, no implicit deployer account): an unset +proxy-admin owner is an access-control footgun, not a convenience default, so +`validateSpec` rejects it outright (`MISSING_PROXY_ADMIN_OWNER`). + +### Upgrading + +`compile/compile.ts` also exports `compileUpgrade()`, a standalone compiler for an +explicit upgrade step: it deploys a NEW implementation (under a caller-supplied, +fresh future `id` — reusing the original `_impl` id would be replayed from +Ignition's journal and skipped, since the journal keys by future id, not bytecode) and +calls the appropriate on-chain upgrade function — `upgradeToAndCall` directly on the +proxy for `"uups"`, or `ProxyAdmin.upgradeAndCall` for `"transparent"` (which requires +the caller to supply the `ProxyAdmin`'s address explicitly — this library does not +compute or guess it). See `compileUpgrade`'s doc comment for the full API. + +`src/deploy/proxyHistory.ts` provides a typed data model — +`ProxyImplementationHistory` / `ProxyImplementationRecord` — plus pure helpers +(`deriveProxyHistoriesFromSpec`, `appendProxyImplementationRecord`) for tracking which +implementation a proxy currently points at and its upgrade history. This is a v1, +core-only data model: it is **not** wired into `@redeploy/reader` yet — that is +follow-up work built on this stable shape. + +### Storage-layout safety is OUT OF SCOPE (v1) + +reDeploy does **not** validate that a new implementation's storage layout is compatible +with the previous one — deploying an incompatible implementation will corrupt proxy +storage. Run your own storage-layout check before upgrading, e.g. the +[`@openzeppelin/upgrades-core`](https://github.com/OpenZeppelin/openzeppelin-upgrades) +/ `hardhat-upgrades` plugin's validator, or `forge inspect --pretty storage-layout`. diff --git a/packages/core/src/compile/compile.ts b/packages/core/src/compile/compile.ts index 25c31cc..5f61724 100644 --- a/packages/core/src/compile/compile.ts +++ b/packages/core/src/compile/compile.ts @@ -25,9 +25,13 @@ * * Map vs. plain object for id→future lookup * ------------------------------------------ - * We use Map> rather - * than a plain object for the id→future index. Spec-derived strings must - * never index a plain object (prototype-pollution risk). + * We use Map> rather than a plain object for + * the id→future index. Spec-derived strings must never index a plain object + * (prototype-pollution risk). `ContractFuture` (rather than the + * narrower `NamedArtifactContractDeploymentFuture`) is the value type + * because upgradeable entries store a `contractAt` future (the proxy exposed + * with the implementation's ABI) here instead of a plain deployment future — + * see "Upgradeable proxies" below. * * Literal mapping * --------------- @@ -37,17 +41,57 @@ * `{ __bigint: "..." }` encoding mentioned in some doc comments is a future * extension. If a runtime value falls outside these shapes, CompileError * UNSUPPORTED_LITERAL is thrown. + * + * Upgradeable proxies (UUPS / Transparent) + * ----------------------------------------- + * ignition-core 0.15.15 has NO built-in `m.proxy` helper, so an + * `entry.upgradeable` entry is compiled by hand-composing the standard + * OpenZeppelin proxy pattern from Ignition primitives: + * + * 1. `impl = m.contract(entry.contract, ctorArgs, { id: "_impl" })` + * 2. `data = m.encodeFunctionCall(impl, initializer.function, initArgs)` + * (or `"0x"` if no initializer is declared) + * 3. `proxy = m.contract("ERC1967Proxy", [impl, data], { id: "_proxy" })` + * (uups) or + * `m.contract("TransparentUpgradeableProxy", [impl, proxyAdminOwner, data], { id: "_proxy" })` + * (transparent — OZ v5's TransparentUpgradeableProxy deploys its own + * ProxyAdmin internally, owned by `proxyAdminOwner`) + * 4. `handle = m.contractAt(entry.contract, proxy, { id: entry.id })` + * + * `handle` (NOT `impl` or `proxy` directly) is what gets stored in + * `futureByEntryId` under `entry.id` — so every downstream `ref` to this + * entry, and the final `IgnitionModuleResult`, resolve to the PROXY's + * address with the IMPLEMENTATION's ABI. That is the entire point of a + * proxy: callers always talk to the stable proxy address. + * + * The proxy artifact names (`"ERC1967Proxy"` / `"TransparentUpgradeableProxy"`) + * are overridable via `CompileOptions.proxyArtifacts` for consumers whose + * Foundry/Hardhat project vendors OpenZeppelin under different artifact + * names; both must be resolvable by the `ArtifactResolver` passed to + * `deploy()` (they exist in `openzeppelin-contracts` v5 and must be + * compiled/available in the consuming project — reDeploy does not vendor + * OZ's Solidity sources itself). + * + * STORAGE-LAYOUT SAFETY IS OUT OF SCOPE (v1) — see `UpgradeableConfig`'s + * doc comment in spec/types.ts. `compileUpgrade()` (below) documents the + * same caveat for the upgrade-call path. */ import { buildModule } from "@nomicfoundation/ignition-core"; import type { ArgumentType, + ContractFuture, IgnitionModule, + IgnitionModuleBuilder, IgnitionModuleResult, ModuleParameterType, - NamedArtifactContractDeploymentFuture, } from "@nomicfoundation/ignition-core"; -import type { DeploymentSpec, LiteralValue, ContractEntry } from "../spec/types.js"; +import type { + ContractArg, + ContractEntry, + DeploymentSpec, + LiteralValue, +} from "../spec/types.js"; import { evaluateExpression, EvaluationError, @@ -70,6 +114,22 @@ export interface CompileOptions { * Must be a non-empty string (Ignition constraint). */ moduleId?: string; + /** + * Override the default artifact names used for the proxy contracts + * deployed for `upgradeable` entries. Both artifacts must be + * compiled/available to the `ArtifactResolver` passed to `deploy()` — they + * ship as part of `openzeppelin-contracts` v5 + * (`@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol` and + * `@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol`). + * reDeploy does not vendor these sources itself — the consuming + * Foundry/Hardhat project must have them available. + */ + proxyArtifacts?: { + /** Defaults to `"ERC1967Proxy"`. */ + uups?: string; + /** Defaults to `"TransparentUpgradeableProxy"`. */ + transparent?: string; + }; } /** @@ -132,8 +192,16 @@ export function buildCreationOrder(entries: readonly ContractEntry[]): ContractE // special-casing needed. for (const entry of entries) { const deps = new Set(); - // Ref args - for (const arg of entry.args ?? []) { + // Ref args — from constructor `args` AND (identically) from + // `upgradeable.initializer.args` / `upgradeable.proxyAdminOwner`, since + // both flow into real Ignition future dependencies once compiled (see + // the impl/proxy/contractAt futures built in compileSpec below). + const argSources: ContractArg[] = [ + ...(entry.args ?? []), + ...(entry.upgradeable?.initializer?.args ?? []), + ...(entry.upgradeable?.proxyAdminOwner !== undefined ? [entry.upgradeable.proxyAdminOwner] : []), + ]; + for (const arg of argSources) { if (arg.kind === "ref") { deps.add(arg.contract); } else if (arg.kind === "expr") { @@ -253,6 +321,160 @@ function mapLiteralValue(value: LiteralValue, path: string): ArgumentType { ); } +// --------------------------------------------------------------------------- +// Contract argument mapping (ref/literal/param/expr/resolver) +// --------------------------------------------------------------------------- + +/** + * Maps a single `ContractArg` to an Ignition `ArgumentType`, handling every + * arg kind (ref/literal/param/expr/resolver) identically to how constructor + * `args` have always been mapped. Shared by constructor `args`, + * `upgradeable.initializer.args`, and `upgradeable.proxyAdminOwner` so all + * three go through the SAME validated arg-mapping — no bespoke handling, no + * string interpolation into calldata. + * + * @param m The Ignition module builder (for `m.getParameter`). + * @param arg The arg to map. + * @param argPath JSON-pointer-ish path for error messages. + * @param spec The full spec (for `spec.parameters` defaults/expr context). + * @param futureByEntryId Build-time id→future index (for `ref` resolution). + */ +function mapContractArg( + m: IgnitionModuleBuilder, + arg: ContractArg, + argPath: string, + spec: DeploymentSpec, + futureByEntryId: ReadonlyMap>, +): ArgumentType { + if (arg.kind === "ref") { + // Resolve the referenced future. Build-time ordering guarantees the + // target was already created. If not, validateSpec was bypassed. + const refFuture = futureByEntryId.get(arg.contract); + if (refFuture === undefined) { + throw new CompileError( + "INTERNAL_INVARIANT", + `ref target "${arg.contract}" has not been registered as a future — was validateSpec called?`, + argPath, + ); + } + // ContractFuture is assignable to ArgumentType, so passing + // the future here creates a real Ignition dependency edge. + return refFuture; + } + if (arg.kind === "param") { + // Resolve via Ignition's m.getParameter(). This is the seam that + // lets the SAME compiled module receive different values per + // network/environment: the caller supplies the actual value at + // deploy time via DeployOptions.deploymentParameters (keyed by this + // module's id and this parameter's name; see deploy/deploy.ts). We + // do NOT bake a fixed value into the module here — that would + // defeat the whole point of per-network overrides. + // + // If the spec declares a value for this parameter under + // `spec.parameters[arg.name]`, we pass it as Ignition's + // `defaultValue` — used only when deploymentParameters does NOT + // supply an override for this name. validateSpec guarantees + // `arg.name` is a declared key of `spec.parameters` before compile + // is ever reached (SpecErrorCode "UNKNOWN_PARAM"). + const declaredDefault = spec.parameters?.[arg.name]; + const defaultValue = + declaredDefault !== undefined + ? // Ignition's ModuleParameterType (like ArgumentType) does not + // include `null` in its TypeScript union, but null passes + // through at runtime — same rationale as mapLiteralValue's + // null handling above. + (mapLiteralValue(declaredDefault, argPath) as unknown as ModuleParameterType) + : undefined; + return m.getParameter(arg.name, defaultValue); + } + if (arg.kind === "expr") { + // Evaluate the expression with parameter values and contract references + try { + const context: EvaluationContext = { + params: {}, + contractAddresses: {}, + }; + + // Populate parameter values (convert LiteralValue to bigint for parameters) + if (spec.parameters) { + for (const [paramName, paramValue] of Object.entries(spec.parameters)) { + // For now, we only support bigint parameters in expressions + // Parse literal values as bigint if possible + if (typeof paramValue === "number" || typeof paramValue === "string") { + try { + context.params[paramName] = BigInt(paramValue); + } catch { + throw new CompileError( + "EXPRESSION_EVAL_ERROR", + `Parameter "${paramName}" value is not convertible to BigInt for expressions`, + argPath, + ); + } + } else if (paramValue === null) { + context.params[paramName] = 0n; + } else { + throw new CompileError( + "EXPRESSION_EVAL_ERROR", + `Parameter "${paramName}" has unsupported type for expressions: ${typeof paramValue}`, + argPath, + ); + } + } + } + + // Populate contract addresses/futures for ${contractId} references + for (const [contractId, future] of futureByEntryId) { + context.contractAddresses[contractId] = future; + } + + const result = evaluateExpression(arg.expression, context); + + // If the result is a Future (from a contract reference), return it directly + if (typeof result === "object" && result !== null && "address" in result) { + // This is a Future-like object from Ignition + return result as ArgumentType; + } + + // Otherwise, map the result as a literal value + // BigInt results are mapped as numbers (or strings for large values) + if (typeof result === "bigint") { + return result as unknown as ArgumentType; + } + + // String results (hex hashes, addresses, etc.) + return result as ArgumentType; + } catch (err) { + if (err instanceof EvaluationError) { + throw new CompileError( + "EXPRESSION_EVAL_ERROR", + `Expression evaluation failed: ${err.message}`, + argPath, + ); + } + throw err; + } + } + if (arg.kind === "resolver") { + // compileSpec() never invokes resolvers itself — see + // resolve/registry.ts and this error code's doc comment + // (compile/errors.ts). deploy() always pre-resolves resolver args + // via resolve/resolveSpec.ts BEFORE calling compileSpec(), so this + // should be unreachable through the normal deploy() pipeline. It + // fires only if compileSpec() is called directly with a spec that + // still contains unresolved resolver args. + throw new CompileError( + "UNRESOLVED_RESOLVER_ARG", + `Resolver arg "${arg.name}" was not pre-resolved before compileSpec() was called. ` + + `Resolver args must be resolved to concrete literals first — deploy() does this ` + + `automatically via its pre-resolution pass (resolve/resolveSpec.ts). If you are ` + + `calling compileSpec() directly, call resolveSpecResolverArgs() on the spec first.`, + argPath, + ); + } + // arg.kind === "literal" + return mapLiteralValue(arg.value, argPath); +} + // --------------------------------------------------------------------------- // Main public API // --------------------------------------------------------------------------- @@ -286,152 +508,27 @@ export function compileSpec( // Map from spec entry id to the Ignition future created for it. // We use Map (not a plain object) for prototype-pollution safety. - const futureByEntryId = new Map< - string, - NamedArtifactContractDeploymentFuture - >(); + // ContractFuture (not the narrower NamedArtifactContractDeploymentFuture) + // because upgradeable entries store a contractAt future here — see this + // file's module doc, "Upgradeable proxies". + const futureByEntryId = new Map>(); + + const uupsProxyArtifact = options?.proxyArtifacts?.uups ?? "ERC1967Proxy"; + const transparentProxyArtifact = + options?.proxyArtifacts?.transparent ?? "TransparentUpgradeableProxy"; // IgnitionModuleResult is the correct result type when we return a - // record of named-artifact contract deployment futures. + // record of contract futures. const module = buildModule(moduleId, (m) => { for (const entry of orderedEntries) { const entryPath = `contracts[id=${entry.id}]`; - // Map constructor arguments - const mappedArgs: ArgumentType[] = ((entry.args ?? []).map((arg, argIdx) => { - const argPath = `${entryPath}.args[${argIdx}]`; - if (arg.kind === "ref") { - // Resolve the referenced future. Build-time ordering guarantees the - // target was already created. If not, validateSpec was bypassed. - const refFuture = futureByEntryId.get(arg.contract); - if (refFuture === undefined) { - throw new CompileError( - "INTERNAL_INVARIANT", - `ref target "${arg.contract}" has not been registered as a future — was validateSpec called?`, - argPath, - ); - } - // ContractFuture is assignable to ArgumentType, so passing - // the future here creates a real Ignition dependency edge. - return refFuture; - } - if (arg.kind === "param") { - // Resolve via Ignition's m.getParameter(). This is the seam that - // lets the SAME compiled module receive different values per - // network/environment: the caller supplies the actual value at - // deploy time via DeployOptions.deploymentParameters (keyed by this - // module's id and this parameter's name; see deploy/deploy.ts). We - // do NOT bake a fixed value into the module here — that would - // defeat the whole point of per-network overrides. - // - // If the spec declares a value for this parameter under - // `spec.parameters[arg.name]`, we pass it as Ignition's - // `defaultValue` — used only when deploymentParameters does NOT - // supply an override for this name. validateSpec guarantees - // `arg.name` is a declared key of `spec.parameters` before compile - // is ever reached (SpecErrorCode "UNKNOWN_PARAM"). - const declaredDefault = spec.parameters?.[arg.name]; - const defaultValue = - declaredDefault !== undefined - ? // Ignition's ModuleParameterType (like ArgumentType) does not - // include `null` in its TypeScript union, but null passes - // through at runtime — same rationale as mapLiteralValue's - // null handling above. - (mapLiteralValue(declaredDefault, argPath) as unknown as ModuleParameterType) - : undefined; - return m.getParameter(arg.name, defaultValue); - } - if (arg.kind === "expr") { - // Evaluate the expression with parameter values and contract references - try { - const context: EvaluationContext = { - params: {}, - contractAddresses: {}, - }; - - // Populate parameter values (convert LiteralValue to bigint for parameters) - if (spec.parameters) { - for (const [paramName, paramValue] of Object.entries(spec.parameters)) { - // For now, we only support bigint parameters in expressions - // Parse literal values as bigint if possible - if (typeof paramValue === "number" || typeof paramValue === "string") { - try { - context.params[paramName] = BigInt(paramValue); - } catch { - throw new CompileError( - "EXPRESSION_EVAL_ERROR", - `Parameter "${paramName}" value is not convertible to BigInt for expressions`, - argPath, - ); - } - } else if (paramValue === null) { - context.params[paramName] = 0n; - } else { - throw new CompileError( - "EXPRESSION_EVAL_ERROR", - `Parameter "${paramName}" has unsupported type for expressions: ${typeof paramValue}`, - argPath, - ); - } - } - } - - // Populate contract addresses/futures for ${contractId} references - for (const [contractId, future] of futureByEntryId) { - context.contractAddresses[contractId] = future; - } - - const result = evaluateExpression(arg.expression, context); - - // If the result is a Future (from a contract reference), return it directly - if ( - typeof result === "object" && - result !== null && - "address" in result - ) { - // This is a Future-like object from Ignition - return result as ArgumentType; - } - - // Otherwise, map the result as a literal value - // BigInt results are mapped as numbers (or strings for large values) - if (typeof result === "bigint") { - return result as unknown as ArgumentType; - } - - // String results (hex hashes, addresses, etc.) - return result; - } catch (err) { - if (err instanceof EvaluationError) { - throw new CompileError( - "EXPRESSION_EVAL_ERROR", - `Expression evaluation failed: ${err.message}`, - argPath, - ); - } - throw err; - } - } - if (arg.kind === "resolver") { - // compileSpec() never invokes resolvers itself — see - // resolve/registry.ts and this error code's doc comment - // (compile/errors.ts). deploy() always pre-resolves resolver args - // via resolve/resolveSpec.ts BEFORE calling compileSpec(), so this - // should be unreachable through the normal deploy() pipeline. It - // fires only if compileSpec() is called directly with a spec that - // still contains unresolved resolver args. - throw new CompileError( - "UNRESOLVED_RESOLVER_ARG", - `Resolver arg "${arg.name}" was not pre-resolved before compileSpec() was called. ` + - `Resolver args must be resolved to concrete literals first — deploy() does this ` + - `automatically via its pre-resolution pass (resolve/resolveSpec.ts). If you are ` + - `calling compileSpec() directly, call resolveSpecResolverArgs() on the spec first.`, - argPath, - ); - } - // arg.kind === "literal" - return mapLiteralValue(arg.value, argPath); - }) as unknown[]) as ArgumentType[]; + // Map constructor arguments — shared helper so ctorArgs, initializer + // args, and proxyAdminOwner (below) all go through identical + // ref/literal/param/expr/resolver handling. + const mappedArgs: ArgumentType[] = (entry.args ?? []).map((arg, argIdx) => + mapContractArg(m, arg, `${entryPath}.args[${argIdx}]`, spec, futureByEntryId), + ); // Map `after` constraints to futures const afterFutures = (entry.after ?? []).map((afterId, afterIdx) => { @@ -447,17 +544,90 @@ export function compileSpec( return afterFuture; }); - // Create the future. We use the overload: - // m.contract(contractName, args?, options?) - // where contractName is the Solidity artifact name (entry.contract) and - // the `id` option lets us use entry.id as the Ignition future id (to - // avoid clashes when the same artifact is deployed multiple times). - const future = m.contract(entry.contract, mappedArgs, { - id: entry.id, + if (entry.upgradeable === undefined) { + // Non-upgradeable path — unchanged from pre-#155 behavior. + // Create the future. We use the overload: + // m.contract(contractName, args?, options?) + // where contractName is the Solidity artifact name (entry.contract) and + // the `id` option lets us use entry.id as the Ignition future id (to + // avoid clashes when the same artifact is deployed multiple times). + const future = m.contract(entry.contract, mappedArgs, { + id: entry.id, + ...(afterFutures.length > 0 ? { after: afterFutures } : {}), + }); + + futureByEntryId.set(entry.id, future); + continue; + } + + // Upgradeable path (UUPS / Transparent) — see this file's module doc, + // "Upgradeable proxies", for the full design rationale. + const { kind, initializer, proxyAdminOwner } = entry.upgradeable; + const upgradeablePath = `${entryPath}.upgradeable`; + + // 1. Deploy the implementation contract. `after` applies here — every + // future built below (proxy, contractAt) transitively depends on this + // one, so the after-ordering constraint holds for the whole entry. + const implFuture = m.contract(entry.contract, mappedArgs, { + id: `${entry.id}_impl`, ...(afterFutures.length > 0 ? { after: afterFutures } : {}), }); - futureByEntryId.set(entry.id, future); + // 2. Encode the initializer call (or "0x" — no call) for the proxy's + // constructor `_data` argument. Executed via delegatecall through the + // proxy, so it initializes PROXY storage, not the implementation's. + let initData: ArgumentType = "0x"; + if (initializer !== undefined) { + const initArgs = (initializer.args ?? []).map((arg, argIdx) => + mapContractArg( + m, + arg, + `${upgradeablePath}.initializer.args[${argIdx}]`, + spec, + futureByEntryId, + ), + ); + initData = m.encodeFunctionCall(implFuture, initializer.function, initArgs, { + id: `${entry.id}_initData`, + }); + } + + // 3. Deploy the proxy itself. + let proxyFuture; + if (kind === "uups") { + proxyFuture = m.contract(uupsProxyArtifact, [implFuture, initData], { + id: `${entry.id}_proxy`, + }); + } else { + // kind === "transparent" + if (proxyAdminOwner === undefined) { + // validateSpec's "MISSING_PROXY_ADMIN_OWNER" rejects this before + // compileSpec() is ever reached in the normal pipeline — this is a + // defensive last resort for direct callers who bypass validateSpec. + throw new CompileError( + "INTERNAL_INVARIANT", + `transparent proxy entry "${entry.id}" has no proxyAdminOwner — was validateSpec called?`, + `${upgradeablePath}.proxyAdminOwner`, + ); + } + const ownerArg = mapContractArg( + m, + proxyAdminOwner, + `${upgradeablePath}.proxyAdminOwner`, + spec, + futureByEntryId, + ); + proxyFuture = m.contract(transparentProxyArtifact, [implFuture, ownerArg, initData], { + id: `${entry.id}_proxy`, + }); + } + + // 4. Expose the entry at the proxy address, using the IMPLEMENTATION's + // ABI. This — not implFuture or proxyFuture — is what downstream refs + // (and the final IgnitionModuleResult) resolve to under entry.id. + const handle = m.contractAt(entry.contract, proxyFuture, { id: entry.id }); + + futureByEntryId.set(entry.id, handle); } // Return all futures keyed by entry.id for Ignition's result tracking. @@ -468,3 +638,183 @@ export function compileSpec( return module; } + +// --------------------------------------------------------------------------- +// Upgrade flow — compiling an upgrade call for an already-deployed proxy +// --------------------------------------------------------------------------- +// +// DESIGN (issue #155, v1 scope) +// ============================== +// +// Automatic bytecode-diff-driven upgrade detection is EXPLICITLY out of +// scope (it would require the compiler to fetch on-chain bytecode and diff +// artifacts — heavy, and orthogonal to the declarative-spec model). v1 +// upgrades are represented as an EXPLICIT, separate compile step: +// `compileUpgrade()` builds a standalone Ignition module — run against the +// SAME `deploymentDir`/journal as the original deployment, but under its OWN +// module id — that deploys a NEW implementation and calls the appropriate +// on-chain upgrade function: +// +// - UUPS: `proxy.upgradeToAndCall(newImpl, initData)` — the +// implementation contract's own ABI (inherited from OZ's +// `UUPSUpgradeable`) already exposes this, so we simply `m.contractAt` +// the existing proxy address using the (new) implementation's contract +// name and `m.call` it directly. +// - Transparent: `ProxyAdmin.upgradeAndCall(proxy, newImpl, initData)` — +// OZ v5's `TransparentUpgradeableProxy` deploys its OWN `ProxyAdmin` +// internally, so its address is NOT derivable from the proxy address +// alone. The caller MUST supply `proxyAdminAddress` explicitly (e.g. +// read from chain state, or — once built — a `@redeploy/reader` +// integration on top of `ProxyImplementationHistory`, see +// `deploy/proxyHistory.ts`). `compileUpgrade()` refuses to guess. +// +// Reusing the SAME implementation future id as the original deploy (e.g. +// `${id}_impl`) would NOT trigger a redeploy on a subsequent run against the +// same journal — Ignition's journal keys futures by id, not by bytecode +// content, so an unchanged id is replayed from the journal and skipped +// regardless of source changes. `compileUpgrade()` therefore requires the +// CALLER to supply a fresh, distinct `id` for the new implementation (e.g. +// include a version suffix: "token_impl_v2") — this is how "a changed +// implementation artifact naturally produces a new impl future/deployment" +// in practice: a NEW id, not a mutated old one. +// +// `compileUpgrade()`'s constructor/initializer args are intentionally typed +// as `LiteralValue[]` (not the full `ContractArg` union) — there is no +// sibling multi-contract spec in this standalone context, so `ref` args +// (which resolve against a spec's other entries) don't make sense here. +// Args still go through `mapLiteralValue` — the SAME validated literal +// mapping used everywhere else, never raw string interpolation. +// +// STORAGE-LAYOUT SAFETY IS OUT OF SCOPE (v1) — see spec/types.ts's +// `UpgradeableConfig` doc comment. Run your own storage-layout check (e.g. +// the `@openzeppelin/upgrades-core` / `hardhat-upgrades` plugin's validator) +// before compiling an upgrade for a new implementation. + +/** An initializer/reinitializer call to run atomically with an upgrade. */ +export interface UpgradeInitializer { + /** The function name (e.g. "reinitialize"). Should be a plain Solidity identifier. */ + readonly function: string; + /** Positional arguments — literal values only (see module doc above). */ + readonly args?: LiteralValue[]; +} + +/** Options for `compileUpgrade()`. */ +export interface UpgradeOptions { + /** + * The future id to use for the NEW implementation deployment. MUST be + * different from any future id already recorded in the target + * `deploymentDir`'s journal (e.g. append a version suffix) — reusing an + * existing id causes Ignition to treat it as already-complete and skip + * deploying it. See this section's module doc for the full rationale. + */ + readonly id: string; + /** The Solidity artifact/contract name of the NEW implementation. */ + readonly contract: string; + /** Constructor arguments for the new implementation (literal values only). */ + readonly args?: LiteralValue[]; + /** + * The address of the ALREADY-DEPLOYED proxy being upgraded (e.g. from a + * previous `DeployResult.deployedAddresses[proxyId]`, or + * `ProxyImplementationHistory.proxyAddress` — see `deploy/proxyHistory.ts`). + */ + readonly proxyAddress: string; + /** + * Must match how the proxy was originally deployed (`UpgradeableConfig.kind`). + * A mismatch will fail on-chain (wrong function selector / wrong caller). + */ + readonly kind: "uups" | "transparent"; + /** Optional re-initializer call, executed atomically with the upgrade. */ + readonly initializer?: UpgradeInitializer; + /** + * REQUIRED for `kind: "transparent"` — the address of the `ProxyAdmin` + * contract that owns upgrade rights over the proxy. `compileUpgrade()` + * throws `CompileError("MISSING_PROXY_ADMIN_ADDRESS")` if this is absent; + * there is deliberately no fallback (see this section's module doc). + */ + readonly proxyAdminAddress?: string; + /** + * The artifact name for the `ProxyAdmin` contract (transparent only). + * Defaults to `"ProxyAdmin"` (OpenZeppelin v5's artifact name). + */ + readonly proxyAdminArtifact?: string; + /** + * The Ignition module id for this upgrade run. Defaults to `${id}Upgrade`. + * Must be consistent across repeated runs of the SAME upgrade (for + * idempotency/resume, exactly like `DeployOptions.moduleId`). + */ + readonly moduleId?: string; +} + +/** + * Compiles a standalone Ignition module that upgrades an already-deployed + * proxy to a new implementation. Intended to be run (via `deploy()` or + * directly via Ignition's own `deploy()`) against the SAME `deploymentDir` + * as the original deployment, but as a SEPARATE module/run — see this + * section's module doc for the full v1 upgrade-flow design and its + * scope boundary (no automatic bytecode-diff detection, no storage-layout + * validation). + * + * @throws CompileError with code MISSING_PROXY_ADMIN_ADDRESS if + * `kind: "transparent"` and `proxyAdminAddress` is not supplied. + * @throws CompileError with code UNSUPPORTED_LITERAL if an arg value falls + * outside the supported LiteralValue shape. + */ +export function compileUpgrade(options: UpgradeOptions): CompiledModule { + const moduleId = options.moduleId ?? `${options.id}Upgrade`; + const upgradePath = `upgrade[id=${options.id}]`; + + if (options.kind === "transparent" && options.proxyAdminAddress === undefined) { + throw new CompileError( + "MISSING_PROXY_ADMIN_ADDRESS", + `Upgrade for transparent proxy "${options.id}" requires proxyAdminAddress ` + + `(the ProxyAdmin contract that owns upgrade rights) — refusing to guess an owner.`, + `${upgradePath}.proxyAdminAddress`, + ); + } + + const module = buildModule(moduleId, (m) => { + const mappedArgs: ArgumentType[] = (options.args ?? []).map((value, i) => + mapLiteralValue(value, `${upgradePath}.args[${i}]`), + ); + + const newImpl = m.contract(options.contract, mappedArgs, { id: options.id }); + + let initData: ArgumentType = "0x"; + if (options.initializer !== undefined) { + const initArgs = (options.initializer.args ?? []).map((value, i) => + mapLiteralValue(value, `${upgradePath}.initializer.args[${i}]`), + ); + initData = m.encodeFunctionCall(newImpl, options.initializer.function, initArgs, { + id: `${options.id}_initData`, + }); + } + + if (options.kind === "uups") { + // UUPSUpgradeable exposes upgradeToAndCall(address,bytes) — the new + // implementation contract's own ABI (inherited from UUPSUpgradeable) + // must include it for this call to succeed on-chain. + const proxyAsImpl = m.contractAt(options.contract, options.proxyAddress, { + id: `${options.id}_proxyAt`, + }); + m.call(proxyAsImpl, "upgradeToAndCall", [newImpl, initData], { + id: `${options.id}_upgrade`, + }); + } else { + // kind === "transparent" — must go through the ProxyAdmin; the proxy + // itself rejects admin-only calls from anything but its linked + // ProxyAdmin (OZ's TransparentUpgradeableProxy access-control design). + const proxyAdminArtifactName = options.proxyAdminArtifact ?? "ProxyAdmin"; + // options.proxyAdminAddress is guaranteed defined here (checked above). + const proxyAdmin = m.contractAt(proxyAdminArtifactName, options.proxyAdminAddress as string, { + id: `${options.id}_admin`, + }); + m.call(proxyAdmin, "upgradeAndCall", [options.proxyAddress, newImpl, initData], { + id: `${options.id}_upgrade`, + }); + } + + return { [options.id]: newImpl }; + }); + + return module as CompiledModule; +} diff --git a/packages/core/src/compile/errors.ts b/packages/core/src/compile/errors.ts index 7fb5232..96a6675 100644 --- a/packages/core/src/compile/errors.ts +++ b/packages/core/src/compile/errors.ts @@ -26,7 +26,15 @@ export type CompileErrorCode = * An internal invariant was violated — e.g. a ref whose target id was not * registered as a future (which implies the caller bypassed validateSpec). */ - | "INTERNAL_INVARIANT"; + | "INTERNAL_INVARIANT" + /** + * `compileUpgrade()` was called with `kind: "transparent"` but no + * `proxyAdminAddress`. Unlike `proxyAdminOwner` on the INITIAL deployment + * (validated by `validateSpec`'s "MISSING_PROXY_ADMIN_OWNER"), an upgrade + * call is not spec-validated — `compileUpgrade()` enforces this itself, + * since silently guessing a ProxyAdmin address would be unsafe. + */ + | "MISSING_PROXY_ADMIN_ADDRESS"; /** * Thrown by compileSpec when an unrecoverable condition is detected at diff --git a/packages/core/src/deploy/proxyHistory.ts b/packages/core/src/deploy/proxyHistory.ts new file mode 100644 index 0000000..49dec3a --- /dev/null +++ b/packages/core/src/deploy/proxyHistory.ts @@ -0,0 +1,161 @@ +/** + * Data model for tracking an upgradeable proxy's implementation history. + * + * SCOPE (v1, issue #155): this module provides TYPES + PURE HELPERS only. + * It does NOT read chain state, does NOT persist anything, and is NOT wired + * into `@redeploy/reader` — that is explicit follow-up work. It exists so a + * future reader-side "implementation history" feature can be built on a + * stable, typed shape without redesigning the data model later. + * + * The helpers here consume the SAME address-book shape `deploy()` already + * produces — `DeployResult.deployedAddresses` (deploy/deploy.ts): a + * `Record` mapping a spec entry id (and, for upgradeable + * entries, its internal `${id}_impl` future id) to a deployed address. That + * is deliberate: callers can pass `deploy()`'s own output straight into + * `deriveProxyHistoriesFromSpec()` with no extra plumbing. + * + * STORAGE-LAYOUT SAFETY IS OUT OF SCOPE (v1) — see spec/types.ts's + * `UpgradeableConfig` doc comment. This module tracks WHICH implementation + * is/was active; it does not validate that a new implementation's storage + * layout is compatible with the previous one. Run your own storage-layout + * check (e.g. the `@openzeppelin/upgrades-core` / `hardhat-upgrades` + * plugin's validator) before upgrading. + */ + +import type { ContractEntry, DeploymentSpec } from "../spec/types.js"; + +// --------------------------------------------------------------------------- +// Public types +// --------------------------------------------------------------------------- + +/** + * A single implementation that was (or is) set for a proxy, and what + * deployment/upgrade run set it. + */ +export interface ProxyImplementationRecord { + /** The address of the implementation contract. */ + readonly implementationAddress: string; + /** The Solidity contract/artifact name of that implementation. */ + readonly implementationContract: string; + /** + * The Ignition future/module id that produced this implementation + * deployment — e.g. `"${proxyId}_impl"` for the entry's initial deploy + * (see compile/compile.ts), or an upgrade module's + * `UpgradeOptions.id` for a subsequent upgrade (see + * `compile/compile.ts`'s `compileUpgrade()`). + */ + readonly setBy: string; +} + +/** + * The full implementation history of a single upgradeable proxy. + */ +export interface ProxyImplementationHistory { + /** The spec entry id of the proxy (`ContractEntry.id` — refs resolve here). */ + readonly proxyId: string; + /** The proxy pattern — see `UpgradeableConfig.kind`'s doc comment. */ + readonly kind: "uups" | "transparent"; + /** The deployed proxy contract's (stable, never-changing) address. */ + readonly proxyAddress: string; + /** + * The currently active implementation. Always equal to + * `history[history.length - 1]`. + */ + readonly currentImplementation: ProxyImplementationRecord; + /** Ordered oldest → newest implementation records for this proxy. */ + readonly history: readonly ProxyImplementationRecord[]; +} + +// --------------------------------------------------------------------------- +// Pure helpers +// --------------------------------------------------------------------------- + +/** + * Derive the INITIAL `ProxyImplementationHistory` for a single upgradeable + * entry, from its spec definition and a resolved address book (as produced + * by `DeployResult.deployedAddresses`, or an equivalent map from any other + * source — e.g. a reader snapshot). + * + * The address book must contain: + * - `entry.id` → the proxy address (compile.ts always registers + * the proxy-as-implementation `contractAt` handle under the entry's own + * id — see compile/compile.ts's module doc, "Upgradeable proxies"). + * - `${entry.id}_impl` → the implementation address. + * + * Returns `undefined` if `entry.upgradeable` is absent, or if either address + * is missing from the address book (e.g. the deployment failed, or this + * particular entry wasn't part of the run that produced `addresses`). + */ +export function deriveInitialProxyHistory( + entry: ContractEntry, + addresses: Readonly>, +): ProxyImplementationHistory | undefined { + if (entry.upgradeable === undefined) { + return undefined; + } + + const proxyAddress = addresses[entry.id]; + const implementationAddress = addresses[`${entry.id}_impl`]; + if (proxyAddress === undefined || implementationAddress === undefined) { + return undefined; + } + + const initialRecord: ProxyImplementationRecord = { + implementationAddress, + implementationContract: entry.contract, + setBy: `${entry.id}_impl`, + }; + + return { + proxyId: entry.id, + kind: entry.upgradeable.kind, + proxyAddress, + currentImplementation: initialRecord, + history: [initialRecord], + }; +} + +/** + * Derive the initial `ProxyImplementationHistory` for every upgradeable + * entry in a spec, given a resolved address book (e.g. + * `DeployResult.deployedAddresses`). Non-upgradeable entries and entries + * missing from the address book are silently skipped — mirroring how + * `DeployResult.deployedAddresses` itself is simply absent/incomplete for a + * failed or partial deployment, rather than an error condition here. + */ +export function deriveProxyHistoriesFromSpec( + spec: DeploymentSpec, + addresses: Readonly>, +): ProxyImplementationHistory[] { + const histories: ProxyImplementationHistory[] = []; + for (const entry of spec.contracts) { + const history = deriveInitialProxyHistory(entry, addresses); + if (history !== undefined) { + histories.push(history); + } + } + return histories; +} + +/** + * Append a new implementation record to an existing history — the pure, + * composable primitive an upgrade flow (e.g. after running + * `compile/compile.ts`'s `compileUpgrade()` and a successful on-chain + * upgrade call) or a future reader integration uses to extend history across + * multiple deploy/upgrade runs. + * + * Never mutates `existing` — returns a new object. The proxy's address and + * `kind` never change across an upgrade (only `currentImplementation` and + * `history` do), so this deliberately does not accept a `proxyAddress`/`kind` + * override. + */ +export function appendProxyImplementationRecord( + existing: ProxyImplementationHistory, + next: ProxyImplementationRecord, +): ProxyImplementationHistory { + return { + ...existing, + currentImplementation: next, + history: [...existing.history, next], + }; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 8a50455..6778f01 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -10,6 +10,8 @@ export type { ContractArg, ContractEntry, DeploymentSpec, + UpgradeableConfig, + UpgradeableInitializer, } from "./spec/types.js"; export { @@ -17,14 +19,15 @@ export { contractEntrySchema, deploymentSpecSchema, resolverArgSchema, + upgradeableConfigSchema, } from "./spec/schema.js"; export type { SpecError, SpecErrorCode, ValidateResult } from "./spec/validate.js"; export { validateSpec } from "./spec/validate.js"; // Spec compiler — converts a validated DeploymentSpec into an Ignition module -export type { CompileOptions, CompiledModule } from "./compile/compile.js"; -export { compileSpec, buildCreationOrder } from "./compile/compile.js"; +export type { CompileOptions, CompiledModule, UpgradeOptions, UpgradeInitializer } from "./compile/compile.js"; +export { compileSpec, buildCreationOrder, compileUpgrade } from "./compile/compile.js"; export type { CompileErrorCode } from "./compile/errors.js"; export { CompileError } from "./compile/errors.js"; @@ -34,6 +37,19 @@ export { deploy } from "./deploy/deploy.js"; export type { DeployErrorCode } from "./deploy/errors.js"; export { DeployError } from "./deploy/errors.js"; +// Upgradeable proxy implementation-history data model — types + pure helpers +// only (v1 scope; see deploy/proxyHistory.ts's module doc). NOT wired into +// any reader/verify package — that is explicit follow-up work. +export type { + ProxyImplementationRecord, + ProxyImplementationHistory, +} from "./deploy/proxyHistory.js"; +export { + deriveInitialProxyHistory, + deriveProxyHistoriesFromSpec, + appendProxyImplementationRecord, +} from "./deploy/proxyHistory.js"; + // Dry-run / plan-only simulation — no chain, no provider, no journal export type { SimulateErrorCode, diff --git a/packages/core/src/simulate/simulate.ts b/packages/core/src/simulate/simulate.ts index 3854ad9..89109f7 100644 --- a/packages/core/src/simulate/simulate.ts +++ b/packages/core/src/simulate/simulate.ts @@ -98,12 +98,32 @@ export interface PlannedStep { readonly after?: string[]; /** * Combined set of dependency ids for this step: the union of all ref-arg - * targets and all after-constraint ids. This mirrors the set of ids that - * Ignition would receive as dependency edges when building the module. + * targets (from `args`, `upgradeable.initializer.args`, and + * `upgradeable.proxyAdminOwner`) and all after-constraint ids. This mirrors + * the set of ids that Ignition would receive as dependency edges when + * building the module. * - * Listed in the order they appear in the spec (refs first, then after). + * Listed in the order they appear in the spec (args refs, then upgradeable + * refs, then after). */ readonly dependsOn: string[]; + /** + * Present iff this entry declares `ContractEntry.upgradeable` — surfaces + * the proxy shape WITHOUT compiling a real Ignition module (this stays a + * byte-for-byte-compatible superset: entries with no `upgradeable` produce + * NO `upgradeable` key at all, exactly as before #155). + * + * `compileSpec()` (compile/compile.ts) expands a step like this into THREE + * Ignition futures (impl/proxy/contractAt) under this one spec id — this + * marker just tells a plan consumer "this id resolves to a proxy", not the + * literal future breakdown. + */ + readonly upgradeable?: { + /** The proxy pattern — see `UpgradeableConfig.kind`'s doc comment. */ + readonly kind: "uups" | "transparent"; + /** True iff the entry declares an `initializer` call. */ + readonly hasInitializer: boolean; + }; } /** @@ -187,11 +207,24 @@ export function simulate(spec: unknown): SimulateResult { const seenDeps = new Set(); const dependsOn: string[] = []; - for (const arg of entry.args ?? []) { + const addRefDep = (arg: ContractArg): void => { if (arg.kind === "ref" && !seenDeps.has(arg.contract)) { seenDeps.add(arg.contract); dependsOn.push(arg.contract); } + }; + + for (const arg of entry.args ?? []) { + addRefDep(arg); + } + // upgradeable refs participate in dependsOn identically to constructor + // args — see compile.ts's buildCreationOrder (same treatment applied + // there for real Ignition dependency edges). + for (const arg of entry.upgradeable?.initializer?.args ?? []) { + addRefDep(arg); + } + if (entry.upgradeable?.proxyAdminOwner !== undefined) { + addRefDep(entry.upgradeable.proxyAdminOwner); } for (const afterId of entry.after ?? []) { if (!seenDeps.has(afterId)) { @@ -206,6 +239,14 @@ export function simulate(spec: unknown): SimulateResult { ...(entry.args !== undefined ? { args: entry.args } : {}), ...(entry.after !== undefined ? { after: entry.after } : {}), dependsOn, + ...(entry.upgradeable !== undefined + ? { + upgradeable: { + kind: entry.upgradeable.kind, + hasInitializer: entry.upgradeable.initializer !== undefined, + }, + } + : {}), }; }); diff --git a/packages/core/src/spec/schema.ts b/packages/core/src/spec/schema.ts index c979eab..96e748a 100644 --- a/packages/core/src/spec/schema.ts +++ b/packages/core/src/spec/schema.ts @@ -6,7 +6,14 @@ */ import { z } from "zod"; -import type { ContractArg, ContractEntry, DeploymentSpec, LiteralValue } from "./types.js"; +import type { + ContractArg, + ContractEntry, + DeploymentSpec, + LiteralValue, + UpgradeableConfig, + UpgradeableInitializer, +} from "./types.js"; // --------------------------------------------------------------------------- // Literal value (recursive JSON-serializable scalar / array) @@ -149,6 +156,38 @@ export const contractArgSchema: z.ZodType = z.discriminatedUnion("k */ export const VALID_CONTRACT_NAME_RE = /^[A-Za-z_$][A-Za-z0-9_$]*$/; +// --------------------------------------------------------------------------- +// Upgradeable proxy configuration +// --------------------------------------------------------------------------- + +/** + * `{ function: "", args?: ContractArg[] }` + * + * Shape validation only. `function` non-emptiness is checked here; whether it + * is a VALID Solidity identifier (it flows into on-chain calldata via + * `m.encodeFunctionCall`) is a cross-field rule enforced by validate.ts's + * `SpecErrorCode` "INVALID_IDENTIFIER" — kept out of this zod layer so + * validateSpec can attach a precise, dedicated error code instead of a + * generic INVALID_SHAPE one. + */ +const upgradeableInitializerSchema: z.ZodType = z.object({ + function: z.string().min(1, { message: "upgradeable.initializer.function must be a non-empty string" }), + args: z.array(contractArgSchema).optional(), +}); + +/** + * `{ kind: "uups" | "transparent", initializer?: {...}, proxyAdminOwner?: ContractArg }` + * + * Shape validation only. Cross-field rules (proxyAdminOwner REQUIRED for + * "transparent", rejected for "uups"; ref/param resolution inside + * initializer.args/proxyAdminOwner) are enforced by validate.ts. + */ +export const upgradeableConfigSchema: z.ZodType = z.object({ + kind: z.enum(["uups", "transparent"]), + initializer: upgradeableInitializerSchema.optional(), + proxyAdminOwner: contractArgSchema.optional(), +}); + export const contractEntrySchema: z.ZodType = z.object({ id: z.string().min(1, { message: "contract entry id must be a non-empty string" }), contract: z @@ -159,6 +198,7 @@ export const contractEntrySchema: z.ZodType = z.object({ }), args: z.array(contractArgSchema).optional(), after: z.array(z.string().min(1, { message: "after entry must be a non-empty string" })).optional(), + upgradeable: upgradeableConfigSchema.optional(), }); // --------------------------------------------------------------------------- diff --git a/packages/core/src/spec/types.ts b/packages/core/src/spec/types.ts index 154bb96..5051cd5 100644 --- a/packages/core/src/spec/types.ts +++ b/packages/core/src/spec/types.ts @@ -129,6 +129,87 @@ export interface ResolverArg { */ export type ContractArg = RefArg | LiteralArg | ParamArg | ExprArg | ResolverArg; +/** + * Configuration for an upgrade-time "initializer" call — the function invoked + * atomically with proxy deployment (UUPS: as `ERC1967Proxy`'s constructor + * `_data` argument; Transparent: as `TransparentUpgradeableProxy`'s + * constructor `_data` argument). This is typically an OZ `Initializable` + * contract's `initialize(...)` function, called via `delegatecall` through + * the proxy so storage is set on the PROXY, not the implementation. + * + * SECURITY: `function` and `args` flow directly into on-chain calldata + * (`compile/compile.ts` uses Ignition's `m.encodeFunctionCall`). `function` + * is validated as a plain Solidity identifier (`validateSpec` — see + * `SpecErrorCode` "INVALID_IDENTIFIER") and `args` go through the SAME + * validated arg-mapping as constructor `args` (ref/literal/param/expr/ + * resolver) — never raw string interpolation. + */ +export interface UpgradeableInitializer { + /** The initializer function name (e.g. "initialize"). Must be a plain Solidity identifier. */ + readonly function: string; + /** Arguments to the initializer function, in order. */ + readonly args?: ContractArg[]; +} + +/** + * Marks a `ContractEntry` as deployed behind an upgradeable proxy instead of + * directly. See `compile/compile.ts`'s module doc for the exact Ignition + * primitives used to build the proxy (ignition-core 0.15.15 has no built-in + * `m.proxy` helper, so we compose `m.contract` + `m.encodeFunctionCall` + + * `m.contractAt` by hand, following the standard OpenZeppelin proxy pattern). + * + * Regardless of `kind`, the entry's `id` always resolves (for downstream + * `ref`s AND in `DeployResult.deployedAddresses`) to the PROXY's address, + * never the implementation's — that's the whole point of a proxy: callers + * interact with the stable proxy address across upgrades. The implementation + * is deployed under future id `${id}_impl` and the proxy under `${id}_proxy`. + * + * STORAGE-LAYOUT SAFETY IS OUT OF SCOPE (v1): this library does NOT validate + * that a new implementation's storage layout is compatible with the previous + * one. Run your own storage-layout check before deploying a new + * implementation for an existing proxy (e.g. the `@openzeppelin/upgrades-core` + * / `hardhat-upgrades` plugin's validator, or `forge inspect --pretty + * storage-layout`). Deploying an incompatible implementation WILL corrupt + * proxy storage — reDeploy does not (and cannot, without a storage-layout + * database) protect you from this. + */ +export interface UpgradeableConfig { + /** + * The proxy pattern to use. + * - `"uups"`: the implementation itself controls upgrades (must inherit + * OZ's `UUPSUpgradeable` and implement `_authorizeUpgrade`). Proxy + * artifact: `ERC1967Proxy`. + * - `"transparent"`: a separate `ProxyAdmin` contract (deployed + * internally by OZ v5's `TransparentUpgradeableProxy`) controls + * upgrades. Proxy artifact: `TransparentUpgradeableProxy`. + */ + readonly kind: "uups" | "transparent"; + /** + * Optional initializer call, executed atomically with proxy deployment. + * If omitted, the proxy's constructor `_data` argument is `"0x"` (no call) + * — the implementation must then be initialized separately (e.g. via a + * later `config` step) or not require initialization at all. + */ + readonly initializer?: UpgradeableInitializer; + /** + * The INITIAL owner of the proxy's `ProxyAdmin` (transparent proxies + * only — OZ v5's `TransparentUpgradeableProxy` deploys its own + * `ProxyAdmin`, owned by this address; only that owner may later call + * `ProxyAdmin.upgradeAndCall`). NOT used for `"uups"` (uups proxies have + * no separate ProxyAdmin — upgrade rights live in the implementation's + * `_authorizeUpgrade` override). + * + * SECURITY: REQUIRED for `kind: "transparent"` — `validateSpec` reports a + * `MISSING_PROXY_ADMIN_OWNER` error if it is absent. There is deliberately + * NO fallback default (e.g. `address(0)` or the deployer account) — an + * unset proxy-admin owner is an access-control footgun, not a convenience + * default. Declaring `proxyAdminOwner` on a `"uups"` entry is flagged as + * `UNUSED_PROXY_ADMIN_OWNER` (rejected, not silently ignored) since it + * signals likely author confusion about which proxy pattern is in use. + */ + readonly proxyAdminOwner?: ContractArg; +} + /** * A single contract entry in a deployment spec. */ @@ -156,6 +237,14 @@ export interface ContractEntry { * by `args` refs. */ readonly after?: string[]; + /** + * Deploy this entry behind an upgradeable proxy (UUPS or Transparent) + * instead of directly. Optional — omitting it is fully backward compatible + * with pre-upgradeable specs (the entry deploys exactly as before). See + * `UpgradeableConfig`'s docs for the full behavior, including which + * address `id` resolves to and the storage-layout-safety scope boundary. + */ + readonly upgradeable?: UpgradeableConfig; } /** diff --git a/packages/core/src/spec/validate.ts b/packages/core/src/spec/validate.ts index ec4ff9f..9253050 100644 --- a/packages/core/src/spec/validate.ts +++ b/packages/core/src/spec/validate.ts @@ -8,8 +8,8 @@ * All errors are COLLECTED and returned together (never fail-fast). */ -import type { DeploymentSpec } from "./types.js"; -import { deploymentSpecSchema } from "./schema.js"; +import type { ContractArg, ContractEntry, DeploymentSpec } from "./types.js"; +import { deploymentSpecSchema, VALID_CONTRACT_NAME_RE } from "./schema.js"; // --------------------------------------------------------------------------- // SpecError — structured validation error @@ -22,7 +22,13 @@ export type SpecErrorCode = | "MISSING_REF" | "SELF_REFERENCE" | "CYCLE" - | "UNKNOWN_PARAM"; + | "UNKNOWN_PARAM" + /** `upgradeable.initializer.function` is not a valid Solidity identifier. */ + | "INVALID_IDENTIFIER" + /** `upgradeable.kind === "transparent"` but `proxyAdminOwner` is absent. */ + | "MISSING_PROXY_ADMIN_OWNER" + /** `upgradeable.kind === "uups"` but `proxyAdminOwner` was set anyway. */ + | "UNUSED_PROXY_ADMIN_OWNER"; /** * A single structured validation error. @@ -65,6 +71,55 @@ function zodPathToString(path: (string | number)[]): string { // Cross-field validation // --------------------------------------------------------------------------- +/** + * Check a single `ContractArg` for a "ref" self-reference / missing target, + * or a "param" reference to an undeclared parameter. Shared by constructor + * `args`, `upgradeable.initializer.args`, and `upgradeable.proxyAdminOwner` + * so all three are held to EXACTLY the same ref/param rules. + * + * @param argBasePath Path to the arg itself (WITHOUT a trailing `.contract`/ + * `.name` — that suffix is appended here per error kind), e.g. + * `contracts[2].args[0]` or `contracts[2].upgradeable.proxyAdminOwner`. + * @param locationDescription Human-readable phrase describing where the arg + * lives, used in the error message, e.g. `args[0]` or + * `upgradeable.proxyAdminOwner`. + */ +function checkArgRefAndParam( + arg: ContractArg, + argBasePath: string, + locationDescription: string, + entry: ContractEntry, + allIds: ReadonlySet, + declaredParams: ReadonlySet, + errors: SpecError[], +): void { + if (arg.kind === "ref") { + const argPath = `${argBasePath}.contract`; + if (arg.contract === entry.id) { + errors.push({ + path: argPath, + code: "SELF_REFERENCE", + message: `Contract "${entry.id}" references itself in ${locationDescription}`, + }); + } else if (!allIds.has(arg.contract)) { + // Missing ref — Set.has() is pollution-safe; no extra own-key guard needed. + errors.push({ + path: argPath, + code: "MISSING_REF", + message: `Contract "${entry.id}" ${locationDescription} references unknown id "${arg.contract}"`, + }); + } + } else if (arg.kind === "param" && !declaredParams.has(arg.name)) { + // Unknown parameter — Set.has() is pollution-safe. + const argPath = `${argBasePath}.name`; + errors.push({ + path: argPath, + code: "UNKNOWN_PARAM", + message: `Contract "${entry.id}" ${locationDescription} references undeclared parameter "${arg.name}" — add it to DeploymentSpec.parameters`, + }); + } +} + function collectCrossFieldErrors(spec: DeploymentSpec): SpecError[] { const errors: SpecError[] = []; @@ -105,33 +160,78 @@ function collectCrossFieldErrors(spec: DeploymentSpec): SpecError[] { // Check args refs if (entry.args) { for (let j = 0; j < entry.args.length; j++) { - const arg = entry.args[j]; - if (arg.kind === "ref") { - const argPath = `${basePath}.args[${j}].contract`; - // Self-reference - if (arg.contract === entry.id) { - errors.push({ - path: argPath, - code: "SELF_REFERENCE", - message: `Contract "${entry.id}" references itself in args`, - }); - } else if (!allIds.has(arg.contract)) { - // Missing ref — Set.has() is pollution-safe; no extra own-key guard needed. - errors.push({ - path: argPath, - code: "MISSING_REF", - message: `Contract "${entry.id}" args[${j}] references unknown id "${arg.contract}"`, - }); - } - } else if (arg.kind === "param" && !declaredParams.has(arg.name)) { - // Unknown parameter — Set.has() is pollution-safe. - const argPath = `${basePath}.args[${j}].name`; + checkArgRefAndParam( + entry.args[j], + `${basePath}.args[${j}]`, + `args[${j}]`, + entry, + allIds, + declaredParams, + errors, + ); + } + } + + // Check upgradeable proxy configuration ---------------------------------- + if (entry.upgradeable) { + const upgradeable = entry.upgradeable; + const upgradeablePath = `${basePath}.upgradeable`; + + // proxyAdminOwner is REQUIRED for transparent proxies (never defaulted — + // see UpgradeableConfig's docs for the access-control rationale) and + // must be ABSENT for uups proxies (flagged rather than silently + // ignored, since setting it signals likely author confusion about + // which proxy pattern is in use). + if (upgradeable.kind === "transparent" && upgradeable.proxyAdminOwner === undefined) { + errors.push({ + path: `${upgradeablePath}.proxyAdminOwner`, + code: "MISSING_PROXY_ADMIN_OWNER", + message: `Contract "${entry.id}" declares a transparent proxy but has no proxyAdminOwner — the initial ProxyAdmin owner must be explicit (no default is applied for access-control safety)`, + }); + } else if (upgradeable.kind === "uups" && upgradeable.proxyAdminOwner !== undefined) { + errors.push({ + path: `${upgradeablePath}.proxyAdminOwner`, + code: "UNUSED_PROXY_ADMIN_OWNER", + message: `Contract "${entry.id}" declares a uups proxy but also sets proxyAdminOwner, which only applies to transparent proxies — remove it or change kind to "transparent"`, + }); + } + + if (upgradeable.proxyAdminOwner !== undefined) { + checkArgRefAndParam( + upgradeable.proxyAdminOwner, + `${upgradeablePath}.proxyAdminOwner`, + "upgradeable.proxyAdminOwner", + entry, + allIds, + declaredParams, + errors, + ); + } + + if (upgradeable.initializer !== undefined) { + // initializer.function flows directly into on-chain calldata + // (compile.ts's m.encodeFunctionCall) — must be a plain Solidity + // identifier, never validated by string interpolation. + if (!VALID_CONTRACT_NAME_RE.test(upgradeable.initializer.function)) { errors.push({ - path: argPath, - code: "UNKNOWN_PARAM", - message: `Contract "${entry.id}" args[${j}] references undeclared parameter "${arg.name}" — add it to DeploymentSpec.parameters`, + path: `${upgradeablePath}.initializer.function`, + code: "INVALID_IDENTIFIER", + message: `Contract "${entry.id}" upgradeable.initializer.function "${upgradeable.initializer.function}" is not a valid Solidity identifier`, }); } + + const initArgs = upgradeable.initializer.args ?? []; + for (let j = 0; j < initArgs.length; j++) { + checkArgRefAndParam( + initArgs[j], + `${upgradeablePath}.initializer.args[${j}]`, + `upgradeable.initializer.args[${j}]`, + entry, + allIds, + declaredParams, + errors, + ); + } } } @@ -219,14 +319,21 @@ function detectCycles(spec: DeploymentSpec): SpecError[] { // Collect all unique dependency ids for this entry const deps = new Set(); - if (entry.args) { - for (const arg of entry.args) { - if (arg.kind === "ref") { - const toId = arg.contract; - // Skip unknown ids and self-refs - if (!allIds.has(toId) || toId === fromId) continue; - deps.add(toId); - } + // Ref args participate in ordering/cycle checks — from constructor + // `args` AND (identically) from `upgradeable.initializer.args` / + // `upgradeable.proxyAdminOwner`, since both flow into real Ignition + // future dependencies once compiled (see compile.ts's buildCreationOrder). + const refSources: ContractArg[] = [ + ...(entry.args ?? []), + ...(entry.upgradeable?.initializer?.args ?? []), + ...(entry.upgradeable?.proxyAdminOwner !== undefined ? [entry.upgradeable.proxyAdminOwner] : []), + ]; + for (const arg of refSources) { + if (arg.kind === "ref") { + const toId = arg.contract; + // Skip unknown ids and self-refs + if (!allIds.has(toId) || toId === fromId) continue; + deps.add(toId); } } diff --git a/packages/core/test/compile.test.ts b/packages/core/test/compile.test.ts index 1c0be68..746f58f 100644 --- a/packages/core/test/compile.test.ts +++ b/packages/core/test/compile.test.ts @@ -4,8 +4,11 @@ import { RuntimeValueType, type ModuleParameterRuntimeValue, type NamedArtifactContractDeploymentFuture, + type NamedArtifactContractAtFuture, + type EncodeFunctionCallFuture, + type ContractCallFuture, } from "@nomicfoundation/ignition-core"; -import { compileSpec, CompileError, buildCreationOrder } from "../src/index.js"; +import { compileSpec, compileUpgrade, CompileError, buildCreationOrder } from "../src/index.js"; import type { DeploymentSpec } from "../src/index.js"; // --------------------------------------------------------------------------- @@ -34,6 +37,38 @@ function asModuleParameterRuntimeValue( return v; } +/** Cast a future to the named-artifact contractAt shape for assertions. */ +function asContractAtFuture(future: unknown): NamedArtifactContractAtFuture { + const f = future as NamedArtifactContractAtFuture; + if (f.type !== FutureType.NAMED_ARTIFACT_CONTRACT_AT) { + throw new Error(`Expected NAMED_ARTIFACT_CONTRACT_AT, got ${f.type}`); + } + return f; +} + +/** Cast a future to the encode-function-call shape for assertions. */ +function asEncodeFunctionCallFuture(future: unknown): EncodeFunctionCallFuture { + const f = future as EncodeFunctionCallFuture; + if (f.type !== FutureType.ENCODE_FUNCTION_CALL) { + throw new Error(`Expected ENCODE_FUNCTION_CALL, got ${f.type}`); + } + return f; +} + +/** Cast a future to the contract-call shape for assertions. */ +function asContractCallFuture(future: unknown): ContractCallFuture { + const f = future as ContractCallFuture; + if (f.type !== FutureType.CONTRACT_CALL) { + throw new Error(`Expected CONTRACT_CALL, got ${f.type}`); + } + return f; +} + +/** Find a future by its exact bare (moduleId-stripped) id, e.g. "vault" or "vault_impl". */ +function findFutureByBareId(mod: { futures: Set<{ id: string }> }, bareId: string) { + return [...mod.futures].find((f) => f.id.endsWith(`#${bareId}`)); +} + // --------------------------------------------------------------------------- // 1. Basic module creation // --------------------------------------------------------------------------- @@ -1077,3 +1112,464 @@ describe("compileSpec — UNRESOLVED_RESOLVER_ARG error (unresolved ResolverArg) expect(ordered.map((e) => e.id)).toEqual(["vault", "registry"]); }); }); + +// --------------------------------------------------------------------------- +// 15. Upgradeable proxies — UUPS (issue #155) +// --------------------------------------------------------------------------- + +const OWNER_ADDR = "0x0000000000000000000000000000000000000002"; +// Valid EIP-55 checksummed addresses — required by m.contractAt()'s address +// validation (used in compileUpgrade() below); a non-checksummed / arbitrary +// hex string is rejected by Ignition at module-construction time. +const PROXY_ADDR = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"; +const PROXY_ADMIN_ADDR = "0x70997970C51812dc3A010C7d01b50e0d17dc79C8"; + +describe("compileSpec — upgradeable: uups (no initializer)", () => { + const spec: DeploymentSpec = { + version: 1, + contracts: [{ id: "vault", contract: "Vault", upgradeable: { kind: "uups" } }], + }; + + it("creates exactly 3 futures (impl, proxy, contractAt)", () => { + const mod = compileSpec(spec); + expect(mod.futures.size).toBe(3); + }); + + it("impl future is a NAMED_ARTIFACT_CONTRACT_DEPLOYMENT for the entry's contract", () => { + const mod = compileSpec(spec); + const impl = asContractFuture(findFutureByBareId(mod, "vault_impl")); + expect(impl.contractName).toBe("Vault"); + expect(impl.constructorArgs).toEqual([]); + }); + + it("proxy future deploys ERC1967Proxy with [impl, \"0x\"]", () => { + const mod = compileSpec(spec); + const impl = findFutureByBareId(mod, "vault_impl"); + const proxy = asContractFuture(findFutureByBareId(mod, "vault_proxy")); + expect(proxy.contractName).toBe("ERC1967Proxy"); + expect(proxy.constructorArgs[0]).toBe(impl); + expect(proxy.constructorArgs[1]).toBe("0x"); + }); + + it("the entry's own id resolves to a contractAt future at the proxy, with the impl's ABI", () => { + const mod = compileSpec(spec); + const proxy = findFutureByBareId(mod, "vault_proxy"); + const handle = asContractAtFuture(findFutureByBareId(mod, "vault")); + expect(handle.contractName).toBe("Vault"); + expect(handle.address).toBe(proxy); + }); +}); + +describe("compileSpec — upgradeable: uups (with initializer)", () => { + const spec: DeploymentSpec = { + version: 1, + contracts: [ + { id: "registry", contract: "Registry" }, + { + id: "vault", + contract: "Vault", + upgradeable: { + kind: "uups", + initializer: { + function: "initialize", + args: [ + { kind: "ref", contract: "registry" }, + { kind: "literal", value: 42 }, + ], + }, + }, + }, + ], + }; + + it("creates 4 futures (registry, impl, initData, proxy) + the contractAt handle = 5 total", () => { + const mod = compileSpec(spec); + // registry(1) + vault_impl(1) + vault_initData(1) + vault_proxy(1) + vault handle(1) + expect(mod.futures.size).toBe(5); + }); + + it("encodes the initializer call against the impl future with mapped args", () => { + const mod = compileSpec(spec); + const impl = findFutureByBareId(mod, "vault_impl"); + const registryFuture = findFutureByBareId(mod, "registry"); + const initData = asEncodeFunctionCallFuture(findFutureByBareId(mod, "vault_initData")); + expect(initData.contract).toBe(impl); + expect(initData.functionName).toBe("initialize"); + expect(initData.args[0]).toBe(registryFuture); + expect(initData.args[1]).toBe(42); + }); + + it("the proxy's constructorArgs[1] is the initData future (not a literal \"0x\")", () => { + const mod = compileSpec(spec); + const initData = findFutureByBareId(mod, "vault_initData"); + const proxy = asContractFuture(findFutureByBareId(mod, "vault_proxy")); + expect(proxy.constructorArgs[1]).toBe(initData); + }); + + it("a downstream ref to the upgradeable entry resolves to the proxy handle, not impl/proxy", () => { + const specWithConsumer: DeploymentSpec = { + version: 1, + contracts: [ + ...spec.contracts, + { id: "consumer", contract: "Consumer", args: [{ kind: "ref", contract: "vault" }] }, + ], + }; + const mod = compileSpec(specWithConsumer); + const handle = findFutureByBareId(mod, "vault"); + const impl = findFutureByBareId(mod, "vault_impl"); + const proxy = findFutureByBareId(mod, "vault_proxy"); + const consumer = asContractFuture(findFutureByBareId(mod, "consumer")); + expect(consumer.constructorArgs[0]).toBe(handle); + expect(consumer.constructorArgs[0]).not.toBe(impl); + expect(consumer.constructorArgs[0]).not.toBe(proxy); + }); +}); + +// --------------------------------------------------------------------------- +// 16. Upgradeable proxies — Transparent +// --------------------------------------------------------------------------- + +describe("compileSpec — upgradeable: transparent", () => { + const spec: DeploymentSpec = { + version: 1, + contracts: [ + { + id: "vault", + contract: "Vault", + upgradeable: { + kind: "transparent", + proxyAdminOwner: { kind: "literal", value: OWNER_ADDR }, + initializer: { function: "initialize" }, + }, + }, + ], + }; + + it("proxy future deploys TransparentUpgradeableProxy with [impl, proxyAdminOwner, initData]", () => { + const mod = compileSpec(spec); + const impl = findFutureByBareId(mod, "vault_impl"); + const initData = findFutureByBareId(mod, "vault_initData"); + const proxy = asContractFuture(findFutureByBareId(mod, "vault_proxy")); + expect(proxy.contractName).toBe("TransparentUpgradeableProxy"); + expect(proxy.constructorArgs[0]).toBe(impl); + expect(proxy.constructorArgs[1]).toBe(OWNER_ADDR); + expect(proxy.constructorArgs[2]).toBe(initData); + }); + + it("initData defaults to \"0x\" when initializer has no args", () => { + const mod = compileSpec(spec); + const initData = asEncodeFunctionCallFuture(findFutureByBareId(mod, "vault_initData")); + expect(initData.args).toEqual([]); + }); + + it("the entry resolves to a contractAt handle at the proxy", () => { + const mod = compileSpec(spec); + const proxy = findFutureByBareId(mod, "vault_proxy"); + const handle = asContractAtFuture(findFutureByBareId(mod, "vault")); + expect(handle.address).toBe(proxy); + }); + + it("proxyAdminOwner can itself be a ref (mapped through the same arg-mapping helper)", () => { + const specWithRefOwner: DeploymentSpec = { + version: 1, + contracts: [ + { id: "admin", contract: "Admin" }, + { + id: "vault", + contract: "Vault", + upgradeable: { kind: "transparent", proxyAdminOwner: { kind: "ref", contract: "admin" } }, + }, + ], + }; + const mod = compileSpec(specWithRefOwner); + const adminFuture = findFutureByBareId(mod, "admin"); + const proxy = asContractFuture(findFutureByBareId(mod, "vault_proxy")); + expect(proxy.constructorArgs[1]).toBe(adminFuture); + }); +}); + +// --------------------------------------------------------------------------- +// 17. Upgradeable proxies — custom proxy artifact names +// --------------------------------------------------------------------------- + +describe("compileSpec — upgradeable: custom proxyArtifacts override", () => { + it("uses a custom uups proxy artifact name when provided", () => { + const spec: DeploymentSpec = { + version: 1, + contracts: [{ id: "vault", contract: "Vault", upgradeable: { kind: "uups" } }], + }; + const mod = compileSpec(spec, { proxyArtifacts: { uups: "MyERC1967Proxy" } }); + const proxy = asContractFuture(findFutureByBareId(mod, "vault_proxy")); + expect(proxy.contractName).toBe("MyERC1967Proxy"); + }); + + it("uses a custom transparent proxy artifact name when provided", () => { + const spec: DeploymentSpec = { + version: 1, + contracts: [ + { + id: "vault", + contract: "Vault", + upgradeable: { + kind: "transparent", + proxyAdminOwner: { kind: "literal", value: OWNER_ADDR }, + }, + }, + ], + }; + const mod = compileSpec(spec, { proxyArtifacts: { transparent: "MyTransparentProxy" } }); + const proxy = asContractFuture(findFutureByBareId(mod, "vault_proxy")); + expect(proxy.contractName).toBe("MyTransparentProxy"); + }); +}); + +// --------------------------------------------------------------------------- +// 18. Upgradeable proxies — ordering via initializer.args / proxyAdminOwner +// --------------------------------------------------------------------------- + +describe("compileSpec — upgradeable: build-time ordering through initializer/proxyAdminOwner refs", () => { + it("compiles when initializer.args refs a contract declared LATER in the array", () => { + const spec: DeploymentSpec = { + version: 1, + contracts: [ + { + id: "vault", + contract: "Vault", + upgradeable: { + kind: "uups", + initializer: { function: "initialize", args: [{ kind: "ref", contract: "registry" }] }, + }, + }, + { id: "registry", contract: "Registry" }, + ], + }; + const mod = compileSpec(spec); + const registryFuture = findFutureByBareId(mod, "registry"); + const initData = asEncodeFunctionCallFuture(findFutureByBareId(mod, "vault_initData")); + expect(initData.args[0]).toBe(registryFuture); + }); + + it("compiles when proxyAdminOwner refs a contract declared LATER in the array", () => { + const spec: DeploymentSpec = { + version: 1, + contracts: [ + { + id: "vault", + contract: "Vault", + upgradeable: { kind: "transparent", proxyAdminOwner: { kind: "ref", contract: "admin" } }, + }, + { id: "admin", contract: "Admin" }, + ], + }; + const mod = compileSpec(spec); + const adminFuture = findFutureByBareId(mod, "admin"); + const proxy = asContractFuture(findFutureByBareId(mod, "vault_proxy")); + expect(proxy.constructorArgs[1]).toBe(adminFuture); + }); + + it("buildCreationOrder places a proxyAdminOwner ref target before the dependent entry", () => { + const entries: DeploymentSpec["contracts"] = [ + { + id: "vault", + contract: "Vault", + upgradeable: { kind: "transparent", proxyAdminOwner: { kind: "ref", contract: "admin" } }, + }, + { id: "admin", contract: "Admin" }, + ]; + const ordered = buildCreationOrder(entries); + expect(ordered.map((e) => e.id)).toEqual(["admin", "vault"]); + }); + + it("buildCreationOrder places an initializer.args ref target before the dependent entry", () => { + const entries: DeploymentSpec["contracts"] = [ + { + id: "vault", + contract: "Vault", + upgradeable: { + kind: "uups", + initializer: { function: "initialize", args: [{ kind: "ref", contract: "registry" }] }, + }, + }, + { id: "registry", contract: "Registry" }, + ]; + const ordered = buildCreationOrder(entries); + expect(ordered.map((e) => e.id)).toEqual(["registry", "vault"]); + }); +}); + +// --------------------------------------------------------------------------- +// 19. Upgradeable proxies — backward compatibility +// --------------------------------------------------------------------------- + +describe("compileSpec — upgradeable: non-upgradeable entries are byte-for-byte unaffected", () => { + it("an entry with no upgradeable field compiles to exactly one NAMED_ARTIFACT_CONTRACT_DEPLOYMENT future", () => { + const spec: DeploymentSpec = { + version: 1, + contracts: [{ id: "token", contract: "Token" }], + }; + const mod = compileSpec(spec); + expect(mod.futures.size).toBe(1); + const f = asContractFuture([...mod.futures][0]); + expect(f.type).toBe(FutureType.NAMED_ARTIFACT_CONTRACT_DEPLOYMENT); + }); +}); + +// --------------------------------------------------------------------------- +// 20. compileUpgrade() — the v1 upgrade-flow compiler (issue #155) +// --------------------------------------------------------------------------- + +describe("compileUpgrade — uups", () => { + it("defaults moduleId to 'Upgrade'", () => { + const mod = compileUpgrade({ + id: "vault_impl_v2", + contract: "VaultV2", + proxyAddress: PROXY_ADDR, + kind: "uups", + }); + expect(mod.id).toBe("vault_impl_v2Upgrade"); + }); + + it("respects a custom moduleId", () => { + const mod = compileUpgrade({ + id: "vault_impl_v2", + contract: "VaultV2", + proxyAddress: PROXY_ADDR, + kind: "uups", + moduleId: "CustomUpgrade", + }); + expect(mod.id).toBe("CustomUpgrade"); + }); + + it("creates the new implementation future under the given id", () => { + const mod = compileUpgrade({ + id: "vault_impl_v2", + contract: "VaultV2", + args: ["ctor-arg"], + proxyAddress: PROXY_ADDR, + kind: "uups", + }); + const newImpl = asContractFuture(findFutureByBareId(mod, "vault_impl_v2")); + expect(newImpl.contractName).toBe("VaultV2"); + expect(newImpl.constructorArgs[0]).toBe("ctor-arg"); + }); + + it("calls upgradeToAndCall on a contractAt-wrapped proxy, with data \"0x\" when no initializer", () => { + const mod = compileUpgrade({ + id: "vault_impl_v2", + contract: "VaultV2", + proxyAddress: PROXY_ADDR, + kind: "uups", + }); + const newImpl = findFutureByBareId(mod, "vault_impl_v2"); + const proxyAt = asContractAtFuture(findFutureByBareId(mod, "vault_impl_v2_proxyAt")); + expect(proxyAt.address).toBe(PROXY_ADDR); + const upgradeCall = asContractCallFuture(findFutureByBareId(mod, "vault_impl_v2_upgrade")); + expect(upgradeCall.functionName).toBe("upgradeToAndCall"); + expect(upgradeCall.args[0]).toBe(newImpl); + expect(upgradeCall.args[1]).toBe("0x"); + }); + + it("encodes an initializer call and passes it as upgradeToAndCall's data arg", () => { + const mod = compileUpgrade({ + id: "vault_impl_v2", + contract: "VaultV2", + proxyAddress: PROXY_ADDR, + kind: "uups", + initializer: { function: "reinitialize", args: [2] }, + }); + const initData = asEncodeFunctionCallFuture(findFutureByBareId(mod, "vault_impl_v2_initData")); + expect(initData.functionName).toBe("reinitialize"); + expect(initData.args[0]).toBe(2); + const upgradeCall = asContractCallFuture(findFutureByBareId(mod, "vault_impl_v2_upgrade")); + expect(upgradeCall.args[1]).toBe(initData); + }); + + it("exports the new implementation future keyed by the given id", () => { + const mod = compileUpgrade({ + id: "vault_impl_v2", + contract: "VaultV2", + proxyAddress: PROXY_ADDR, + kind: "uups", + }); + const newImpl = findFutureByBareId(mod, "vault_impl_v2"); + expect(mod.results["vault_impl_v2"]).toBe(newImpl); + }); +}); + +describe("compileSpec — upgradeable: defensive INTERNAL_INVARIANT (validateSpec bypassed)", () => { + it("throws CompileError(INTERNAL_INVARIANT) for a transparent entry with no proxyAdminOwner", () => { + // validateSpec's MISSING_PROXY_ADMIN_OWNER rejects this in the normal + // pipeline — this exercises compileSpec()'s own defensive last-resort + // guard for direct callers who bypass validateSpec (compileSpec's + // documented contract: it does NOT re-validate). + const spec: DeploymentSpec = { + version: 1, + contracts: [ + { id: "vault", contract: "Vault", upgradeable: { kind: "transparent" } }, + ], + }; + expect(() => compileSpec(spec)).toThrowError(CompileError); + try { + compileSpec(spec); + } catch (err) { + expect(err).toBeInstanceOf(CompileError); + expect((err as CompileError).code).toBe("INTERNAL_INVARIANT"); + expect((err as CompileError).path).toBe("contracts[id=vault].upgradeable.proxyAdminOwner"); + } + }); +}); + +describe("compileUpgrade — transparent", () => { + it("throws CompileError(MISSING_PROXY_ADMIN_ADDRESS) when proxyAdminAddress is absent", () => { + expect(() => + compileUpgrade({ + id: "vault_impl_v2", + contract: "VaultV2", + proxyAddress: PROXY_ADDR, + kind: "transparent", + }), + ).toThrowError(CompileError); + try { + compileUpgrade({ + id: "vault_impl_v2", + contract: "VaultV2", + proxyAddress: PROXY_ADDR, + kind: "transparent", + }); + } catch (err) { + expect(err).toBeInstanceOf(CompileError); + expect((err as CompileError).code).toBe("MISSING_PROXY_ADMIN_ADDRESS"); + } + }); + + it("calls ProxyAdmin.upgradeAndCall with [proxyAddress, newImpl, initData]", () => { + const mod = compileUpgrade({ + id: "vault_impl_v2", + contract: "VaultV2", + proxyAddress: PROXY_ADDR, + kind: "transparent", + proxyAdminAddress: PROXY_ADMIN_ADDR, + }); + const newImpl = findFutureByBareId(mod, "vault_impl_v2"); + const admin = asContractAtFuture(findFutureByBareId(mod, "vault_impl_v2_admin")); + expect(admin.contractName).toBe("ProxyAdmin"); + expect(admin.address).toBe(PROXY_ADMIN_ADDR); + const upgradeCall = asContractCallFuture(findFutureByBareId(mod, "vault_impl_v2_upgrade")); + expect(upgradeCall.functionName).toBe("upgradeAndCall"); + expect(upgradeCall.args[0]).toBe(PROXY_ADDR); + expect(upgradeCall.args[1]).toBe(newImpl); + }); + + it("uses a custom proxyAdminArtifact name when provided", () => { + const mod = compileUpgrade({ + id: "vault_impl_v2", + contract: "VaultV2", + proxyAddress: PROXY_ADDR, + kind: "transparent", + proxyAdminAddress: PROXY_ADMIN_ADDR, + proxyAdminArtifact: "MyProxyAdmin", + }); + const admin = asContractAtFuture(findFutureByBareId(mod, "vault_impl_v2_admin")); + expect(admin.contractName).toBe("MyProxyAdmin"); + }); +}); diff --git a/packages/core/test/proxyHistory.test.ts b/packages/core/test/proxyHistory.test.ts new file mode 100644 index 0000000..2b2a6fd --- /dev/null +++ b/packages/core/test/proxyHistory.test.ts @@ -0,0 +1,200 @@ +/** + * Tests for the proxy implementation-history data model (issue #155, + * deploy/proxyHistory.ts). + * + * SCOPE: pure, typed helpers only — no chain/provider/journal involved, so + * these are plain structural/unit tests over in-memory Record + * address books (mirroring the shape of DeployResult.deployedAddresses). + */ + +import { describe, it, expect } from "vitest"; +import { + deriveInitialProxyHistory, + deriveProxyHistoriesFromSpec, + appendProxyImplementationRecord, +} from "../src/index.js"; +import type { DeploymentSpec, ContractEntry, ProxyImplementationHistory } from "../src/index.js"; + +const IMPL_ADDR = "0x0000000000000000000000000000000000000010"; +const PROXY_ADDR = "0x0000000000000000000000000000000000000011"; + +describe("deriveInitialProxyHistory", () => { + it("returns undefined for a non-upgradeable entry", () => { + const entry: ContractEntry = { id: "token", contract: "Token" }; + const result = deriveInitialProxyHistory(entry, { token: "0xabc" }); + expect(result).toBeUndefined(); + }); + + it("returns undefined when the proxy address is missing from the address book", () => { + const entry: ContractEntry = { + id: "vault", + contract: "Vault", + upgradeable: { kind: "uups" }, + }; + const result = deriveInitialProxyHistory(entry, { vault_impl: IMPL_ADDR }); + expect(result).toBeUndefined(); + }); + + it("returns undefined when the implementation address is missing from the address book", () => { + const entry: ContractEntry = { + id: "vault", + contract: "Vault", + upgradeable: { kind: "uups" }, + }; + const result = deriveInitialProxyHistory(entry, { vault: PROXY_ADDR }); + expect(result).toBeUndefined(); + }); + + it("derives a full history for a valid uups entry with both addresses present", () => { + const entry: ContractEntry = { + id: "vault", + contract: "Vault", + upgradeable: { kind: "uups" }, + }; + const history = deriveInitialProxyHistory(entry, { + vault: PROXY_ADDR, + vault_impl: IMPL_ADDR, + }); + expect(history).toBeDefined(); + expect(history?.proxyId).toBe("vault"); + expect(history?.kind).toBe("uups"); + expect(history?.proxyAddress).toBe(PROXY_ADDR); + expect(history?.currentImplementation).toEqual({ + implementationAddress: IMPL_ADDR, + implementationContract: "Vault", + setBy: "vault_impl", + }); + expect(history?.history).toEqual([history?.currentImplementation]); + }); + + it("derives a full history for a transparent entry", () => { + const entry: ContractEntry = { + id: "vault", + contract: "Vault", + upgradeable: { + kind: "transparent", + proxyAdminOwner: { kind: "literal", value: "0x0000000000000000000000000000000000000002" }, + }, + }; + const history = deriveInitialProxyHistory(entry, { + vault: PROXY_ADDR, + vault_impl: IMPL_ADDR, + }); + expect(history?.kind).toBe("transparent"); + }); +}); + +describe("deriveProxyHistoriesFromSpec", () => { + it("returns an empty array for a spec with no upgradeable entries", () => { + const spec: DeploymentSpec = { + version: 1, + contracts: [{ id: "token", contract: "Token" }], + }; + const histories = deriveProxyHistoriesFromSpec(spec, { token: "0xabc" }); + expect(histories).toEqual([]); + }); + + it("derives a history entry per upgradeable contract, skipping non-upgradeable ones", () => { + const spec: DeploymentSpec = { + version: 1, + contracts: [ + { id: "token", contract: "Token" }, + { id: "vaultA", contract: "Vault", upgradeable: { kind: "uups" } }, + { id: "vaultB", contract: "Vault", upgradeable: { kind: "uups" } }, + ], + }; + const addresses = { + token: "0xabc", + vaultA: "0x0000000000000000000000000000000000000021", + vaultA_impl: "0x0000000000000000000000000000000000000022", + vaultB: "0x0000000000000000000000000000000000000031", + vaultB_impl: "0x0000000000000000000000000000000000000032", + }; + const histories = deriveProxyHistoriesFromSpec(spec, addresses); + expect(histories).toHaveLength(2); + expect(histories.map((h) => h.proxyId).sort()).toEqual(["vaultA", "vaultB"]); + }); + + it("skips an upgradeable entry missing from a partial/failed-deploy address book", () => { + const spec: DeploymentSpec = { + version: 1, + contracts: [{ id: "vault", contract: "Vault", upgradeable: { kind: "uups" } }], + }; + const histories = deriveProxyHistoriesFromSpec(spec, {}); + expect(histories).toEqual([]); + }); +}); + +describe("appendProxyImplementationRecord", () => { + it("returns a NEW object, never mutating the input", () => { + const original: ProxyImplementationHistory = { + proxyId: "vault", + kind: "uups", + proxyAddress: PROXY_ADDR, + currentImplementation: { + implementationAddress: IMPL_ADDR, + implementationContract: "Vault", + setBy: "vault_impl", + }, + history: [ + { + implementationAddress: IMPL_ADDR, + implementationContract: "Vault", + setBy: "vault_impl", + }, + ], + }; + const frozen = JSON.parse(JSON.stringify(original)); + + const upgraded = appendProxyImplementationRecord(original, { + implementationAddress: "0x0000000000000000000000000000000000000099", + implementationContract: "VaultV2", + setBy: "vault_impl_v2Upgrade", + }); + + // Original is untouched. + expect(original).toEqual(frozen); + // The new object reflects the upgrade. + expect(upgraded).not.toBe(original); + expect(upgraded.currentImplementation.implementationContract).toBe("VaultV2"); + expect(upgraded.history).toHaveLength(2); + expect(upgraded.history[0]).toEqual(original.currentImplementation); + expect(upgraded.history[1]).toEqual(upgraded.currentImplementation); + // proxyId/kind/proxyAddress carry over unchanged. + expect(upgraded.proxyId).toBe(original.proxyId); + expect(upgraded.kind).toBe(original.kind); + expect(upgraded.proxyAddress).toBe(original.proxyAddress); + }); + + it("supports chaining multiple upgrades", () => { + let history: ProxyImplementationHistory = { + proxyId: "vault", + kind: "uups", + proxyAddress: PROXY_ADDR, + currentImplementation: { + implementationAddress: "0x01", + implementationContract: "VaultV1", + setBy: "vault_impl", + }, + history: [{ implementationAddress: "0x01", implementationContract: "VaultV1", setBy: "vault_impl" }], + }; + + history = appendProxyImplementationRecord(history, { + implementationAddress: "0x02", + implementationContract: "VaultV2", + setBy: "vault_impl_v2Upgrade", + }); + history = appendProxyImplementationRecord(history, { + implementationAddress: "0x03", + implementationContract: "VaultV3", + setBy: "vault_impl_v3Upgrade", + }); + + expect(history.history.map((r) => r.implementationContract)).toEqual([ + "VaultV1", + "VaultV2", + "VaultV3", + ]); + expect(history.currentImplementation.implementationContract).toBe("VaultV3"); + }); +}); diff --git a/packages/core/test/simulate.test.ts b/packages/core/test/simulate.test.ts index b8a2acf..f5bd021 100644 --- a/packages/core/test/simulate.test.ts +++ b/packages/core/test/simulate.test.ts @@ -598,3 +598,105 @@ describe("simulate — ResolverArg pass-through", () => { expect(vault.dependsOn).toEqual(["registry"]); }); }); + +// --------------------------------------------------------------------------- +// Upgradeable proxies — PlannedStep.upgradeable marker (issue #155) +// --------------------------------------------------------------------------- + +describe("simulate — upgradeable marker", () => { + it("does NOT include an `upgradeable` key for a non-upgradeable entry (byte-for-byte compatible)", () => { + const result = simulate({ + version: 1, + contracts: [{ id: "token", contract: "Token" }], + }); + const steps = assertOk(result); + expect(steps[0]).not.toHaveProperty("upgradeable"); + }); + + it("surfaces kind:\"uups\" and hasInitializer:false when no initializer is declared", () => { + const result = simulate({ + version: 1, + contracts: [{ id: "vault", contract: "Vault", upgradeable: { kind: "uups" } }], + }); + const steps = assertOk(result); + const vault = steps.find((s) => s.id === "vault")!; + expect(vault.upgradeable).toEqual({ kind: "uups", hasInitializer: false }); + }); + + it("surfaces hasInitializer:true when an initializer is declared", () => { + const result = simulate({ + version: 1, + contracts: [ + { + id: "vault", + contract: "Vault", + upgradeable: { kind: "uups", initializer: { function: "initialize" } }, + }, + ], + }); + const steps = assertOk(result); + const vault = steps.find((s) => s.id === "vault")!; + expect(vault.upgradeable).toEqual({ kind: "uups", hasInitializer: true }); + }); + + it("surfaces kind:\"transparent\" for a transparent entry", () => { + const result = simulate({ + version: 1, + contracts: [ + { + id: "vault", + contract: "Vault", + upgradeable: { + kind: "transparent", + proxyAdminOwner: { kind: "literal", value: "0x0000000000000000000000000000000000000002" }, + }, + }, + ], + }); + const steps = assertOk(result); + const vault = steps.find((s) => s.id === "vault")!; + expect(vault.upgradeable).toEqual({ kind: "transparent", hasInitializer: false }); + }); + + it("includes refs from initializer.args and proxyAdminOwner in dependsOn", () => { + const result = simulate({ + version: 1, + contracts: [ + { id: "registry", contract: "Registry" }, + { id: "admin", contract: "Admin" }, + { + id: "vault", + contract: "Vault", + upgradeable: { + kind: "transparent", + proxyAdminOwner: { kind: "ref", contract: "admin" }, + initializer: { function: "initialize", args: [{ kind: "ref", contract: "registry" }] }, + }, + }, + ], + }); + const steps = assertOk(result); + const vault = steps.find((s) => s.id === "vault")!; + expect(vault.dependsOn).toEqual(["registry", "admin"]); + }); + + it("places dependencies contributed only via initializer/proxyAdminOwner refs before the dependent step", () => { + const result = simulate({ + version: 1, + contracts: [ + { + id: "vault", + contract: "Vault", + upgradeable: { + kind: "uups", + initializer: { function: "initialize", args: [{ kind: "ref", contract: "registry" }] }, + }, + }, + { id: "registry", contract: "Registry" }, + ], + }); + const steps = assertOk(result); + const ids = steps.map((s) => s.id); + expect(ids.indexOf("registry")).toBeLessThan(ids.indexOf("vault")); + }); +}); diff --git a/packages/core/test/spec.test.ts b/packages/core/test/spec.test.ts index b3f0b44..8c59e0f 100644 --- a/packages/core/test/spec.test.ts +++ b/packages/core/test/spec.test.ts @@ -909,3 +909,387 @@ describe("validateSpec — ResolverArg shape rejection", () => { } }); }); + +// --------------------------------------------------------------------------- +// Upgradeable proxies (issue #155) +// --------------------------------------------------------------------------- + +const OWNER_ADDR = "0x0000000000000000000000000000000000000002"; + +describe("validateSpec — upgradeable: happy paths", () => { + it("accepts a uups entry with no initializer and no proxyAdminOwner", () => { + const result = validateSpec({ + version: 1, + contracts: [ + { id: "vault", contract: "Vault", upgradeable: { kind: "uups" } }, + ], + }); + expect(result.ok).toBe(true); + }); + + it("accepts a uups entry with an initializer", () => { + const result = validateSpec({ + version: 1, + contracts: [ + { + id: "vault", + contract: "Vault", + upgradeable: { + kind: "uups", + initializer: { function: "initialize", args: [{ kind: "literal", value: 1 }] }, + }, + }, + ], + }); + expect(result.ok).toBe(true); + }); + + it("accepts a transparent entry with proxyAdminOwner", () => { + const result = validateSpec({ + version: 1, + contracts: [ + { + id: "vault", + contract: "Vault", + upgradeable: { + kind: "transparent", + proxyAdminOwner: { kind: "literal", value: OWNER_ADDR }, + }, + }, + ], + }); + expect(result.ok).toBe(true); + }); + + it("accepts a ref inside initializer.args that resolves to a known id", () => { + const result = validateSpec({ + version: 1, + contracts: [ + { id: "registry", contract: "Registry" }, + { + id: "vault", + contract: "Vault", + upgradeable: { + kind: "uups", + initializer: { function: "initialize", args: [{ kind: "ref", contract: "registry" }] }, + }, + }, + ], + }); + expect(result.ok).toBe(true); + }); + + it("accepts a ref inside proxyAdminOwner that resolves to a known id", () => { + const result = validateSpec({ + version: 1, + contracts: [ + { id: "admin", contract: "Admin" }, + { + id: "vault", + contract: "Vault", + upgradeable: { + kind: "transparent", + proxyAdminOwner: { kind: "ref", contract: "admin" }, + }, + }, + ], + }); + expect(result.ok).toBe(true); + }); + + it("accepts a param inside initializer.args that is declared in parameters", () => { + const result = validateSpec({ + version: 1, + parameters: { initialOwner: OWNER_ADDR }, + contracts: [ + { + id: "vault", + contract: "Vault", + upgradeable: { + kind: "uups", + initializer: { function: "initialize", args: [{ kind: "param", name: "initialOwner" }] }, + }, + }, + ], + }); + expect(result.ok).toBe(true); + }); + + it("leaves non-upgradeable specs fully unaffected (backward compatibility)", () => { + const result = validateSpec(happySpec); + expect(result.ok).toBe(true); + }); +}); + +describe("validateSpec — upgradeable: MISSING_PROXY_ADMIN_OWNER", () => { + it("rejects a transparent entry with no proxyAdminOwner", () => { + const result = validateSpec({ + version: 1, + contracts: [{ id: "vault", contract: "Vault", upgradeable: { kind: "transparent" } }], + }); + expect(result.ok).toBe(false); + if (!result.ok) { + const e = result.errors.find((e) => e.code === "MISSING_PROXY_ADMIN_OWNER"); + expect(e).toBeDefined(); + expect(e?.message).toContain("vault"); + expect(e?.path).toBe("contracts[0].upgradeable.proxyAdminOwner"); + } + }); +}); + +describe("validateSpec — upgradeable: UNUSED_PROXY_ADMIN_OWNER", () => { + it("rejects a uups entry that also sets proxyAdminOwner", () => { + const result = validateSpec({ + version: 1, + contracts: [ + { + id: "vault", + contract: "Vault", + upgradeable: { + kind: "uups", + proxyAdminOwner: { kind: "literal", value: OWNER_ADDR }, + }, + }, + ], + }); + expect(result.ok).toBe(false); + if (!result.ok) { + const e = result.errors.find((e) => e.code === "UNUSED_PROXY_ADMIN_OWNER"); + expect(e).toBeDefined(); + expect(e?.message).toContain("vault"); + } + }); +}); + +describe("validateSpec — upgradeable: INVALID_IDENTIFIER", () => { + it("rejects an initializer.function that is not a valid Solidity identifier", () => { + const result = validateSpec({ + version: 1, + contracts: [ + { + id: "vault", + contract: "Vault", + upgradeable: { + kind: "uups", + initializer: { function: "not a function()" }, + }, + }, + ], + }); + expect(result.ok).toBe(false); + if (!result.ok) { + const e = result.errors.find((e) => e.code === "INVALID_IDENTIFIER"); + expect(e).toBeDefined(); + expect(e?.path).toBe("contracts[0].upgradeable.initializer.function"); + } + }); + + it("accepts a valid identifier with underscores and digits", () => { + const result = validateSpec({ + version: 1, + contracts: [ + { + id: "vault", + contract: "Vault", + upgradeable: { kind: "uups", initializer: { function: "_initialize_v2" } }, + }, + ], + }); + expect(result.ok).toBe(true); + }); +}); + +describe("validateSpec — upgradeable: MISSING_REF / SELF_REFERENCE in initializer.args", () => { + it("rejects a ref inside initializer.args pointing at an unknown id", () => { + const result = validateSpec({ + version: 1, + contracts: [ + { + id: "vault", + contract: "Vault", + upgradeable: { + kind: "uups", + initializer: { function: "initialize", args: [{ kind: "ref", contract: "ghost" }] }, + }, + }, + ], + }); + expect(result.ok).toBe(false); + if (!result.ok) { + const e = result.errors.find((e) => e.code === "MISSING_REF"); + expect(e).toBeDefined(); + expect(e?.message).toContain("ghost"); + expect(e?.path).toBe("contracts[0].upgradeable.initializer.args[0].contract"); + } + }); + + it("rejects a self-reference inside initializer.args", () => { + const result = validateSpec({ + version: 1, + contracts: [ + { + id: "vault", + contract: "Vault", + upgradeable: { + kind: "uups", + initializer: { function: "initialize", args: [{ kind: "ref", contract: "vault" }] }, + }, + }, + ], + }); + expect(result.ok).toBe(false); + if (!result.ok) { + const e = result.errors.find((e) => e.code === "SELF_REFERENCE"); + expect(e).toBeDefined(); + } + }); + + it("rejects a ref inside proxyAdminOwner pointing at an unknown id", () => { + const result = validateSpec({ + version: 1, + contracts: [ + { + id: "vault", + contract: "Vault", + upgradeable: { + kind: "transparent", + proxyAdminOwner: { kind: "ref", contract: "ghostAdmin" }, + }, + }, + ], + }); + expect(result.ok).toBe(false); + if (!result.ok) { + const e = result.errors.find((e) => e.code === "MISSING_REF"); + expect(e).toBeDefined(); + expect(e?.message).toContain("ghostAdmin"); + expect(e?.path).toBe("contracts[0].upgradeable.proxyAdminOwner.contract"); + } + }); + + it("rejects an undeclared param inside initializer.args", () => { + const result = validateSpec({ + version: 1, + contracts: [ + { + id: "vault", + contract: "Vault", + upgradeable: { + kind: "uups", + initializer: { function: "initialize", args: [{ kind: "param", name: "unknownParam" }] }, + }, + }, + ], + }); + expect(result.ok).toBe(false); + if (!result.ok) { + const e = result.errors.find((e) => e.code === "UNKNOWN_PARAM"); + expect(e).toBeDefined(); + expect(e?.message).toContain("unknownParam"); + expect(e?.path).toBe("contracts[0].upgradeable.initializer.args[0].name"); + } + }); +}); + +describe("validateSpec — upgradeable: ordering and cycles", () => { + it("accepts a ref inside initializer.args to an id declared LATER in the array", () => { + const result = validateSpec({ + version: 1, + contracts: [ + { + id: "vault", + contract: "Vault", + upgradeable: { + kind: "uups", + initializer: { function: "initialize", args: [{ kind: "ref", contract: "registry" }] }, + }, + }, + { id: "registry", contract: "Registry" }, + ], + }); + expect(result.ok).toBe(true); + }); + + it("detects a cycle formed through initializer.args", () => { + const result = validateSpec({ + version: 1, + contracts: [ + { + id: "a", + contract: "A", + upgradeable: { + kind: "uups", + initializer: { function: "initialize", args: [{ kind: "ref", contract: "b" }] }, + }, + }, + { id: "b", contract: "B", args: [{ kind: "ref", contract: "a" }] }, + ], + }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.errors.some((e) => e.code === "CYCLE")).toBe(true); + } + }); + + it("detects a cycle formed through proxyAdminOwner", () => { + const result = validateSpec({ + version: 1, + contracts: [ + { + id: "a", + contract: "A", + upgradeable: { + kind: "transparent", + proxyAdminOwner: { kind: "ref", contract: "b" }, + }, + }, + { id: "b", contract: "B", args: [{ kind: "ref", contract: "a" }] }, + ], + }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.errors.some((e) => e.code === "CYCLE")).toBe(true); + } + }); +}); + +describe("validateSpec — upgradeable: schema-level shape checks", () => { + it("rejects an unknown upgradeable.kind value", () => { + const result = validateSpec({ + version: 1, + contracts: [{ id: "vault", contract: "Vault", upgradeable: { kind: "bogus" } }], + }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.errors.some((e) => e.code === "INVALID_SHAPE")).toBe(true); + } + }); + + it("rejects an upgradeable entry missing the kind field", () => { + const result = validateSpec({ + version: 1, + contracts: [{ id: "vault", contract: "Vault", upgradeable: {} }], + }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.errors.some((e) => e.code === "INVALID_SHAPE")).toBe(true); + } + }); + + it("rejects an initializer with an empty function name", () => { + const result = validateSpec({ + version: 1, + contracts: [ + { + id: "vault", + contract: "Vault", + upgradeable: { kind: "uups", initializer: { function: "" } }, + }, + ], + }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.errors.some((e) => e.code === "INVALID_SHAPE")).toBe(true); + } + }); +});