diff --git a/packages/core/src/compile/compile.ts b/packages/core/src/compile/compile.ts index 5f61724..e9ba97c 100644 --- a/packages/core/src/compile/compile.ts +++ b/packages/core/src/compile/compile.ts @@ -217,7 +217,11 @@ export function buildCreationOrder(entries: readonly ContractEntry[]): ContractE // Otherwise, the validator will catch it as a missing reference } } - // `param` and `resolver` args add no edges (see NOTE above). + // `param` and `resolver` args add no edges (see NOTE above). `crossRef` + // args likewise add no edge here — the referenced contract lives on a + // DIFFERENT network entirely, outside this spec's own dependency + // graph, so a build-order edge would be meaningless. See CrossRefArg's + // doc comment (spec/types.ts) for the full v1 scope boundary. } // After constraints for (const afterId of entry.after ?? []) { @@ -471,6 +475,24 @@ function mapContractArg( argPath, ); } + if (arg.kind === "crossRef") { + // compileSpec() never reads another network's journal itself — see + // resolve/crossRef.ts and this error code's doc comment + // (compile/errors.ts). deploy() always pre-resolves crossRef args via + // resolve/crossRef.ts's resolveCrossRefArgs() 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 crossRef args. + throw new CompileError( + "UNRESOLVED_CROSS_REF_ARG", + `Cross-chain ref to contract "${arg.contract}" on network "${arg.network}" was not ` + + `pre-resolved before compileSpec() was called. crossRef args must be resolved to ` + + `concrete literals first — deploy() does this automatically via its pre-resolution ` + + `pass (resolve/crossRef.ts). If you are calling compileSpec() directly, call ` + + `resolveCrossRefArgs() on the spec first.`, + argPath, + ); + } // arg.kind === "literal" return mapLiteralValue(arg.value, argPath); } diff --git a/packages/core/src/compile/errors.ts b/packages/core/src/compile/errors.ts index 96a6675..fa59d1a 100644 --- a/packages/core/src/compile/errors.ts +++ b/packages/core/src/compile/errors.ts @@ -22,6 +22,16 @@ export type CompileErrorCode = * callback) — see resolve/registry.ts for the full design. */ | "UNRESOLVED_RESOLVER_ARG" + /** + * A `{ kind: "crossRef" }` arg reached compileSpec() unresolved. Cross-chain + * refs must be pre-resolved to concrete literals BEFORE compileSpec() is + * called — deploy() does this automatically via its async pre-resolution + * pass (resolve/crossRef.ts), which runs BEFORE the resolver pre-resolution + * pass. compileSpec() itself never reads another network's journal (that + * would require async I/O inside a synchronous builder callback) — see + * resolve/crossRef.ts for the full design. + */ + | "UNRESOLVED_CROSS_REF_ARG" /** * An internal invariant was violated — e.g. a ref whose target id was not * registered as a future (which implies the caller bypassed validateSpec). diff --git a/packages/core/src/deploy/deploy.ts b/packages/core/src/deploy/deploy.ts index 1ade843..385dfd5 100644 --- a/packages/core/src/deploy/deploy.ts +++ b/packages/core/src/deploy/deploy.ts @@ -15,30 +15,34 @@ * * This module's responsibility is: * 1. Validate the spec (fail fast with a typed DeployError). - * 2. Resolve `{ kind: "resolver" }` args against the injected + * 2. Resolve `{ kind: "crossRef" }` args (cross-chain dependency refs, + * issue #159) against the injected `DeployOptions.crossNetworkJournals` + * map — an async pre-pass that reads ANOTHER network's journal and + * runs BEFORE compilation (see resolve/crossRef.ts for the full design + * and v1 scope boundary). This is a no-op (skipped entirely) for specs + * that don't use crossRef args. + * 3. Resolve `{ kind: "resolver" }` args against the injected * `DeployOptions.resolvers` registry — an async pre-pass that runs - * BEFORE compilation (see resolve/resolveSpec.ts and resolve/registry.ts - * for the full Layer 2 "typed resolver escape-hatch" design). This is a - * no-op (skipped entirely) for specs that don't use resolver args. - * 3. Compile the (now fully-resolved) spec into an Ignition module. - * 4. Run the PREFLIGHT phase (see deploy/preflight.ts): the effective + * AFTER crossRef resolution (step 2) and BEFORE compilation (see + * resolve/resolveSpec.ts and resolve/registry.ts for the full Layer 2 + * "typed resolver escape-hatch" design). This is a no-op (skipped + * entirely) for specs that don't use resolver args. + * 4. Compile the (now fully-resolved) spec into an Ignition module. + * 5. Run the PREFLIGHT phase (see deploy/preflight.ts): the effective * policy — a per-field merge of `spec.preflight` and * `DeployOptions.preflight` — is checked against the live RPC/account * BEFORE any transaction is broadcast. A failing check throws a typed * `DeployError("PREFLIGHT_FAILED", ...)`. Skipped entirely (zero extra * RPC calls) when the effective policy has no fields set — the common * case — so deploy() has zero extra cost for callers who don't opt in. - * 5. Thread `deploymentDir` through to Ignition's `deploy()` so the journal + * 6. Thread `deploymentDir` through to Ignition's `deploy()` so the journal * persists across calls — do NOT reinvent journaling here. - * 6. Wrap the raw DeploymentResult with enough accessors to let callers + * 7. Wrap the raw DeploymentResult with enough accessors to let callers * check success and read deployed addresses. */ -import { existsSync } from "node:fs"; -import { join } from "node:path"; import { deploy as ignitionDeploy, - status as ignitionStatus, DeploymentResultType, } from "@nomicfoundation/ignition-core"; import type { @@ -58,6 +62,10 @@ import { specHasResolverArgs, } from "../resolve/resolveSpec.js"; import { ResolveError } from "../resolve/errors.js"; +import { loadAddressesFromJournal, stripModulePrefix } from "../resolve/journal.js"; +import type { CrossNetworkJournal } from "../resolve/crossRef.js"; +import { resolveCrossRefArgs, specHasCrossRefArgs } from "../resolve/crossRef.js"; +import { CrossRefError } from "../resolve/crossRefErrors.js"; import type { PreflightPolicy } from "./preflight.js"; import { isEmptyPreflightPolicy, mergePreflightPolicies, runPreflight } from "./preflight.js"; @@ -128,6 +136,25 @@ export interface DeployOptions { * (resolvers are trusted, in-repo code — never loaded dynamically). */ resolvers?: ResolverRegistry; + /** + * Injected map of network name -> journal location, for resolving + * `{ kind: "crossRef" }` args (cross-chain dependency refs — see + * spec/types.ts's CrossRefArg). Optional: specs with no crossRef args never + * touch this option. Every network name referenced by a `{ kind: "crossRef" }` + * arg anywhere in `spec.contracts` must appear as a key here, or deploy() + * throws `DeployError("CROSS_REF_ERROR")` before any compilation or + * on-chain activity happens. + * + * Core has NO network registry of its own (single-network spec, single + * `deploymentDir` per call) — this map is entirely caller-supplied. A + * typical caller (e.g. `@redeploy/deploy-server`) derives it from its own + * network config (mapping network name -> that network's deployment + * directory) before calling deploy(). See resolve/crossRef.ts for the full + * `resolveCrossRefArgs`/`CrossNetworkJournal` contract and the v1 scope + * boundary (the referenced network's deployment must already be COMPLETE + * — no cross-chain orchestrator, no automatic ordering across networks). + */ + crossNetworkJournals?: Record; /** * Per-call PREFLIGHT policy override. The EFFECTIVE policy used by this * `deploy()` call is a per-field merge of `spec.preflight` (the @@ -172,49 +199,6 @@ export interface DeployResult { readonly ignitionResult: DeploymentResult; } -// --------------------------------------------------------------------------- -// Internal helpers — resolver pre-resolution pass plumbing -// --------------------------------------------------------------------------- - -/** - * Strips Ignition's "#" future-id prefix so callers can index by - * their spec entry id. Shared between the post-deploy address extraction - * (step 5 below) and the pre-deploy journal read for - * `ResolverContext.resolvedAddresses` (step 2 below) so both paths agree on - * the exact same id shape. - */ -function stripModulePrefix(key: string, moduleId: string): string { - const prefix = `${moduleId}#`; - return key.startsWith(prefix) ? key.slice(prefix.length) : key; -} - -/** - * Best-effort read of addresses already deployed in a PREVIOUS run against - * `deploymentDir`, for `ResolverContext.resolvedAddresses`. Returns an empty - * object for a fresh deployment (no journal yet) — that is the documented v1 - * behavior (see resolve/registry.ts's scope-boundary note), not an error. - * - * We check for `journal.jsonl`'s existence before calling Ignition's - * `status()` because `status()` throws an `IgnitionError` for a - * `deploymentDir` with no journal, and a fresh-deploy resolver run should not - * depend on parsing/matching that error shape. - */ -async function loadResolvedAddressesFromJournal( - deploymentDir: string, - moduleId: string, -): Promise> { - const journalPath = join(deploymentDir, "journal.jsonl"); - if (!existsSync(journalPath)) { - return {}; - } - const statusResult = await ignitionStatus(deploymentDir); - const resolvedAddresses: Record = {}; - for (const [key, contract] of Object.entries(statusResult.contracts)) { - resolvedAddresses[stripModulePrefix(key, moduleId)] = contract.address; - } - return resolvedAddresses; -} - // --------------------------------------------------------------------------- // Public API // --------------------------------------------------------------------------- @@ -244,6 +228,10 @@ async function loadResolvedAddressesFromJournal( * @throws DeployError with code "RESOLVER_ERROR" if a resolver invocation * fails, or a spec parameter cannot be coerced to the bigint shape * `ResolverContext.params` requires. + * @throws DeployError with code "CROSS_REF_ERROR" if a `{ kind: "crossRef" }` + * arg names a network absent from `DeployOptions.crossNetworkJournals`, or + * references a contract id not yet deployed on that network. See + * resolve/crossRef.ts. * @throws DeployError with code "COMPILE_ERROR" if spec compilation fails. * @throws DeployError with code "PREFLIGHT_FAILED" if the effective PREFLIGHT * policy fails one or more checks. Thrown BEFORE any on-chain transaction @@ -276,7 +264,40 @@ export async function deploy(options: DeployOptions): Promise { ); } - // --- 2. Resolve `resolver` args (Layer 2 typed escape-hatch) ---------------- + const effectiveModuleId = moduleId ?? "Deployment"; + let specForCompile: DeploymentSpec = validateResult.spec; + + // --- 2. Resolve `crossRef` args (cross-chain dependency refs, issue #159) --- + // + // MUST run before compileSpec() — Ignition's builder has no concept of + // crossRef args; by the time compileSpec() sees the spec, every crossRef + // arg must already be a concrete literal. Runs BEFORE the resolver + // pre-resolution pass below (step 3): both are pre-compile literal + // substitutions, ordered crossRef -> resolver. See resolve/crossRef.ts for + // the full design and v1 scope boundary (no cross-chain orchestrator; the + // referenced network's deployment must already be complete). + // + // Skipped entirely (no journal read, no journals-map lookup) when the spec + // has no crossRef args — the common case — so deploy() has zero extra cost + // for specs that don't use this feature. + if (specHasCrossRefArgs(specForCompile)) { + try { + specForCompile = await resolveCrossRefArgs(specForCompile, { + journals: options.crossNetworkJournals ?? {}, + }); + } catch (err) { + if (err instanceof CrossRefError) { + throw new DeployError("CROSS_REF_ERROR", err.message); + } + const msg = err instanceof Error ? err.message : String(err); + throw new DeployError( + "CROSS_REF_ERROR", + `Failed to resolve DeploymentSpec crossRef args: ${msg}`, + ); + } + } + + // --- 3. Resolve `resolver` args (Layer 2 typed escape-hatch) ---------------- // // MUST run before compileSpec() — Ignition's builder has no concept of // resolver args; by the time compileSpec() sees the spec, every resolver @@ -286,11 +307,9 @@ export async function deploy(options: DeployOptions): Promise { // Skipped entirely (no journal read, no param build, no registry lookup) // when the spec has no resolver args — the common case — so deploy() has // zero extra cost for specs that don't use this feature. - const effectiveModuleId = moduleId ?? "Deployment"; - let specForCompile: DeploymentSpec = validateResult.spec; if (specHasResolverArgs(specForCompile)) { try { - const resolvedAddresses = await loadResolvedAddressesFromJournal( + const resolvedAddresses = await loadAddressesFromJournal( deploymentDir, effectiveModuleId, ); @@ -320,7 +339,7 @@ export async function deploy(options: DeployOptions): Promise { } } - // --- 3. Compile spec into an Ignition module -------------------------------- + // --- 4. Compile spec into an Ignition module -------------------------------- let ignitionModule; try { ignitionModule = compileSpec(specForCompile, { moduleId }); @@ -333,7 +352,7 @@ export async function deploy(options: DeployOptions): Promise { ); } - // --- 4. PREFLIGHT phase — pre-broadcast safety checks ----------------------- + // --- 5. PREFLIGHT phase — pre-broadcast safety checks ----------------------- // // Runs AFTER spec validation/compile but STRICTLY BEFORE ignitionDeploy() // below, so a failing check aborts before any transaction is broadcast. The @@ -361,7 +380,7 @@ export async function deploy(options: DeployOptions): Promise { } } - // --- 5. Run Ignition deploy — idempotency/resume live here ------------------ + // --- 6. Run Ignition deploy — idempotency/resume live here ------------------ // // Ignition's deploy() creates (or reads) a journal at // `/journal.jsonl`. Futures already recorded as complete are @@ -379,7 +398,7 @@ export async function deploy(options: DeployOptions): Promise { defaultSender, }); - // --- 6. Build our result wrapper ------------------------------------------- + // --- 7. Build our result wrapper ------------------------------------------- const success = ignitionResult.type === DeploymentResultType.SUCCESSFUL_DEPLOYMENT; const deployedAddresses: Record = {}; diff --git a/packages/core/src/deploy/errors.ts b/packages/core/src/deploy/errors.ts index 09ca773..b662c02 100644 --- a/packages/core/src/deploy/errors.ts +++ b/packages/core/src/deploy/errors.ts @@ -37,6 +37,17 @@ export type DeployErrorCode = * resolve/resolveSpec.ts. */ | "RESOLVER_ERROR" + /** + * A `{ kind: "crossRef" }` arg named a network absent from + * `DeployOptions.crossNetworkJournals`, or referenced a contract id not + * present in that network's journal (either the journal doesn't exist yet + * or the id was never deployed there — deploy that network first). See + * resolve/crossRef.ts for the full CrossRefError/CrossRefErrorCode detail + * (CrossRefError.code distinguishes "CROSS_REF_UNKNOWN_NETWORK" from + * "CROSS_REF_NOT_DEPLOYED", but both re-wrap to this single DeployError + * code — check `err.message` for which one occurred). + */ + | "CROSS_REF_ERROR" /** * The effective PREFLIGHT policy (per-field merge of * `DeploymentSpec.preflight` and `DeployOptions.preflight`) failed one or diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 6a9fbcf..fbf2a0f 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -5,6 +5,7 @@ export type { ParamArg, ExprArg, ResolverArg, + CrossRefArg, LiteralScalar, LiteralValue, ContractArg, @@ -20,6 +21,7 @@ export { contractEntrySchema, deploymentSpecSchema, resolverArgSchema, + crossRefArgSchema, upgradeableConfigSchema, preflightPolicySchema, } from "./spec/schema.js"; @@ -87,3 +89,19 @@ export { jsonRpcProvider } from "./provider/jsonRpc.js"; // / RESOLVER_ERROR, exported above via DeployErrorCode), so callers of // deploy() only ever need to catch DeployError. export type { Resolver, ResolverContext, ResolverRegistry } from "./resolve/registry.js"; + +// Cross-chain dependency refs (issue #159) — async pre-deploy resolution of +// `{ kind: "crossRef" }` args against an injected map of network -> journal +// location, wired via DeployOptions.crossNetworkJournals. See +// resolve/crossRef.ts for the full ResolveCrossRefOptions/CrossNetworkJournal +// contract and the v1 scope boundary (core has no network registry; the +// referenced network's deployment must already be complete). +// +// Unlike resolve/errors.ts's ResolveError, CrossRefError IS exported here — +// resolveCrossRefArgs() is intended to be callable directly (e.g. by a +// multi-network orchestration layer outside deploy()), not only through +// deploy()'s own pre-resolution pass. +export type { CrossNetworkJournal, ResolveCrossRefOptions } from "./resolve/crossRef.js"; +export { resolveCrossRefArgs, specHasCrossRefArgs } from "./resolve/crossRef.js"; +export type { CrossRefErrorCode } from "./resolve/crossRefErrors.js"; +export { CrossRefError } from "./resolve/crossRefErrors.js"; diff --git a/packages/core/src/resolve/crossRef.ts b/packages/core/src/resolve/crossRef.ts new file mode 100644 index 0000000..995cfcc --- /dev/null +++ b/packages/core/src/resolve/crossRef.ts @@ -0,0 +1,273 @@ +/** + * Async pre-resolution pass for `{ kind: "crossRef" }` args (cross-chain + * dependency refs — issue #159). + * + * `resolveCrossRefArgs` walks a validated DeploymentSpec, and for every + * crossRef arg it finds, reads the referenced NETWORK's Ignition journal + * (via `resolve/journal.ts`'s `loadAddressesFromJournal` — the SAME + * primitive `deploy()` uses to read its own network's journal for + * `ResolverContext.resolvedAddresses`) and returns a NEW DeploymentSpec where + * every crossRef arg has been replaced by a concrete `{ kind: "literal", + * value:
}` arg. + * + * This is deliberately a pure, standalone transform over DeploymentSpec — + * exactly like `resolve/resolveSpec.ts`'s `resolveSpecResolverArgs` — so it + * can run to completion BEFORE compileSpec() ever constructs Ignition + * futures. See deploy/deploy.ts for where this pass sits in the overall + * deploy() pipeline (BEFORE the resolver pre-resolution pass — both are + * pre-compile literal substitutions, ordered crossRef -> resolver so a + * resolver could, in principle, observe an already-resolved crossRef address + * via `ctx.resolvedAddresses` in a future extension, though v1 does not wire + * that through). + * + * compile/compile.ts's compileSpec() throws + * CompileError("UNRESOLVED_CROSS_REF_ARG") if it ever encounters a + * `{ kind: "crossRef" }` arg — by design, a spec fed to compileSpec() must + * never contain crossRef args; they are pre-resolved here (or, for direct + * compileSpec() callers who don't go through deploy(), must be + * pre-substituted by the caller using this same function). + * + * CORE HAS NO NETWORK REGISTRY (v1 scope boundary — see CrossRefArg's doc + * comment in spec/types.ts): `ResolveCrossRefOptions.journals` is injected by + * the CALLER, mapping an opaque network name to a `CrossNetworkJournal` + * (deploymentDir + optional moduleId). Multi-network orchestration (e.g. + * resolving `journals` from a project's own network config, such as + * `@redeploy/deploy-server`'s `NetworksRegistry`) is explicitly OUT OF SCOPE + * for this package — core only knows how to read a journal it's handed a + * path to. + */ + +import { isAddress } from "viem"; +import type { ContractArg, ContractEntry, DeploymentSpec, LiteralArg } from "../spec/types.js"; +import { loadAddressesFromJournal } from "./journal.js"; +import { CrossRefError } from "./crossRefErrors.js"; + +// --------------------------------------------------------------------------- +// Public types +// --------------------------------------------------------------------------- + +/** + * Location of another network's Ignition deployment, for reading its journal. + */ +export interface CrossNetworkJournal { + /** Directory containing that network's `journal.jsonl`. */ + readonly deploymentDir: string; + /** + * The Ignition module id that network's deployment was run under. + * Defaults to `"Deployment"` (deploy()'s own default) if omitted. + */ + readonly moduleId?: string; +} + +/** Options for resolveCrossRefArgs(). */ +export interface ResolveCrossRefOptions { + /** + * Injected map of network name -> journal location. Every `network` named + * by a `{ kind: "crossRef" }` arg anywhere in the spec must appear as an + * OWN key here, or resolveCrossRefArgs() throws + * `CrossRefError("CROSS_REF_UNKNOWN_NETWORK")`. Core does not own or + * discover this mapping itself — see this file's module doc. + */ + readonly journals: Record; +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * True iff `spec` contains at least one `{ kind: "crossRef" }` arg anywhere + * in its contracts (constructor `args`, `upgradeable.initializer.args`, or + * `upgradeable.proxyAdminOwner`). Used by deploy() to skip this pass's setup + * work entirely for specs that don't use cross-chain refs — the common case + * — while keeping resolveCrossRefArgs() itself safe to call unconditionally + * (it also short-circuits internally). + */ +export function specHasCrossRefArgs(spec: DeploymentSpec): boolean { + return spec.contracts.some((entry) => entryHasCrossRefArg(entry)); +} + +function entryHasCrossRefArg(entry: ContractEntry): boolean { + if ((entry.args ?? []).some((arg) => arg.kind === "crossRef")) return true; + if ((entry.upgradeable?.initializer?.args ?? []).some((arg) => arg.kind === "crossRef")) return true; + if (entry.upgradeable?.proxyAdminOwner?.kind === "crossRef") return true; + return false; +} + +/** + * Resolves every `{ kind: "crossRef" }` arg in `spec` against + * `options.journals` and returns a NEW DeploymentSpec with those args + * replaced by `{ kind: "literal", value:
}`. + * + * Contracts/args that contain no crossRef arg are returned unchanged (same + * object references) — only entries that actually needed resolution are + * rebuilt. If `spec` contains no crossRef args at all, `spec` itself is + * returned unchanged. + * + * Applies uniformly to constructor `args`, `upgradeable.initializer.args`, + * and `upgradeable.proxyAdminOwner` — the same three locations + * `spec/validate.ts`'s `checkArgRefAndParam` and `compile/compile.ts`'s + * `mapContractArg` treat identically for ref/param/expr/resolver args. + * + * @throws CrossRefError with code "CROSS_REF_UNKNOWN_NETWORK" if a crossRef + * arg names a network absent from `options.journals`. + * @throws CrossRefError with code "CROSS_REF_NOT_DEPLOYED" if the referenced + * network's journal has no recorded address for the requested contract id + * (including when the journal doesn't exist at all — nothing deployed + * there yet — or when the requested `contract` id collides with an + * inherited Object.prototype member such as "__proto__"/"constructor", + * which is treated identically to "not present"). Also thrown (same code) + * if the journal DOES have a recorded value for the id but that value is + * not a well-formed address — refuses to substitute it into on-chain + * calldata. The referenced network's deployment must be COMPLETE first + * — see CrossRefArg's v1 scope boundary (spec/types.ts). + */ +export async function resolveCrossRefArgs( + spec: DeploymentSpec, + options: ResolveCrossRefOptions, +): Promise { + if (!specHasCrossRefArgs(spec)) { + return spec; + } + + // Cache journal reads per network — multiple crossRef args (even across + // entries) referencing the same network should only read that network's + // journal.jsonl once. + const journalCache = new Map>(); + + async function resolveAddress( + entryId: string, + locationDescription: string, + network: string, + contract: string, + ): Promise { + // Guard against prototype pollution: only OWN, enumerable keys of the + // injected journals map may be looked up. Mirrors resolve/resolveSpec.ts's + // Object.hasOwn guard for the ResolverRegistry lookup. + if (!Object.hasOwn(options.journals, network)) { + throw new CrossRefError( + "CROSS_REF_UNKNOWN_NETWORK", + `Contract "${entryId}" ${locationDescription} references network "${network}", which is ` + + `not present in the injected journals map — no CrossNetworkJournal is registered for it`, + ); + } + + let addresses = journalCache.get(network); + if (addresses === undefined) { + const journal = options.journals[network]; + addresses = await loadAddressesFromJournal(journal.deploymentDir, journal.moduleId ?? "Deployment"); + journalCache.set(network, addresses); + } + + // Guard against prototype pollution: only OWN, enumerable keys of the + // loaded address map may be looked up — a `contract` id equal to an + // inherited Object.prototype member (e.g. "__proto__", "constructor", + // "toString") must NOT resolve to that inherited member. Mirrors the + // Object.hasOwn guard above for the journals-map lookup. + if (!Object.hasOwn(addresses, contract)) { + throw new CrossRefError( + "CROSS_REF_NOT_DEPLOYED", + `Contract "${entryId}" ${locationDescription} references contract "${contract}" on network ` + + `"${network}", but it is not present in that network's journal — deploy network "${network}" ` + + `first before this one`, + ); + } + + const address = addresses[contract]; + // Defense-in-depth: the resolved value is about to be substituted as a + // `{ kind: "literal" }` constructor arg and fed into on-chain calldata — + // refuse to do that for anything that isn't a well-formed address, even + // though `loadAddressesFromJournal` should only ever produce addresses. + if (!isAddress(address)) { + throw new CrossRefError( + "CROSS_REF_NOT_DEPLOYED", + `Contract "${entryId}" ${locationDescription} references contract "${contract}" on network ` + + `"${network}", but the value recorded in that network's journal ("${address}") is not a valid ` + + `address — refusing to substitute it into on-chain calldata`, + ); + } + return address; + } + + async function resolveArgList( + entryId: string, + args: readonly ContractArg[] | undefined, + locationPrefix: string, + ): Promise<{ changed: boolean; args: ContractArg[] | undefined }> { + if (args === undefined) { + return { changed: false, args: undefined }; + } + let changed = false; + const resolved: ContractArg[] = []; + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (arg.kind !== "crossRef") { + resolved.push(arg); + continue; + } + changed = true; + const address = await resolveAddress(entryId, `${locationPrefix}[${i}]`, arg.network, arg.contract); + const literalArg: LiteralArg = { kind: "literal", value: address }; + resolved.push(literalArg); + } + return { changed, args: resolved }; + } + + const contracts: ContractEntry[] = []; + for (const entry of spec.contracts) { + if (!entryHasCrossRefArg(entry)) { + contracts.push(entry); + continue; + } + + const { changed: argsChanged, args: resolvedArgs } = await resolveArgList( + entry.id, + entry.args, + "args", + ); + + let upgradeable = entry.upgradeable; + if (upgradeable !== undefined) { + let upgradeableChanged = false; + + const { changed: initChanged, args: resolvedInitArgs } = await resolveArgList( + entry.id, + upgradeable.initializer?.args, + "upgradeable.initializer.args", + ); + if (initChanged) { + upgradeableChanged = true; + } + + let proxyAdminOwner = upgradeable.proxyAdminOwner; + if (proxyAdminOwner?.kind === "crossRef") { + upgradeableChanged = true; + const address = await resolveAddress( + entry.id, + "upgradeable.proxyAdminOwner", + proxyAdminOwner.network, + proxyAdminOwner.contract, + ); + proxyAdminOwner = { kind: "literal", value: address }; + } + + if (upgradeableChanged) { + upgradeable = { + ...upgradeable, + ...(upgradeable.initializer !== undefined + ? { initializer: { ...upgradeable.initializer, args: resolvedInitArgs } } + : {}), + ...(proxyAdminOwner !== undefined ? { proxyAdminOwner } : {}), + }; + } + } + + contracts.push({ + ...entry, + ...(argsChanged ? { args: resolvedArgs } : {}), + ...(upgradeable !== entry.upgradeable ? { upgradeable } : {}), + }); + } + + return { ...spec, contracts }; +} diff --git a/packages/core/src/resolve/crossRefErrors.ts b/packages/core/src/resolve/crossRefErrors.ts new file mode 100644 index 0000000..5143efa --- /dev/null +++ b/packages/core/src/resolve/crossRefErrors.ts @@ -0,0 +1,48 @@ +/** + * Error types for the cross-chain dependency ref pre-resolution pass + * (resolve/crossRef.ts). + * + * CrossRefError is thrown by resolveCrossRefArgs() and caught by + * deploy/deploy.ts, which re-wraps it as a DeployError with code + * "CROSS_REF_ERROR" — mirroring how resolve/errors.ts's ResolveError is + * caught and re-wrapped for `{ kind: "resolver" }` args — so callers of + * deploy() only ever need to catch DeployError. Unlike ResolveError, + * CrossRefError IS part of the public API surface (see src/index.ts): + * resolveCrossRefArgs() is also intended to be called directly by consumers + * that manage their own multi-network orchestration outside deploy() + * (e.g. @redeploy/deploy-server wiring per-network journals from its + * NetworksRegistry). + */ + +/** Discriminated error codes for CrossRefError. */ +export type CrossRefErrorCode = + /** + * A `{ kind: "crossRef", network: "..." }` arg names a network that is not + * a key of the injected `ResolveCrossRefOptions.journals` map. Core has no + * network registry of its own (see spec/types.ts's CrossRefArg docs) — the + * caller must supply a journal location for every network referenced by a + * crossRef arg. + */ + | "CROSS_REF_UNKNOWN_NETWORK" + /** + * The referenced network's journal exists (or was looked up) but does not + * contain a deployed address for the requested contract id — either the + * journal is missing entirely (nothing deployed yet on that network) or + * the specific contract id was never deployed there. The referenced + * network's deployment must be COMPLETE before this spec is deployed (see + * CrossRefArg's v1 scope boundary — no cross-chain orchestrator). + */ + | "CROSS_REF_NOT_DEPLOYED"; + +/** + * Thrown by resolveCrossRefArgs() when a crossRef arg cannot be resolved. + */ +export class CrossRefError extends Error { + readonly code: CrossRefErrorCode; + + constructor(code: CrossRefErrorCode, message: string) { + super(message); + this.name = "CrossRefError"; + this.code = code; + } +} diff --git a/packages/core/src/resolve/journal.ts b/packages/core/src/resolve/journal.ts new file mode 100644 index 0000000..5b652f9 --- /dev/null +++ b/packages/core/src/resolve/journal.ts @@ -0,0 +1,72 @@ +/** + * Reusable in-core primitive: read the deployed contract addresses recorded + * in a Hardhat Ignition journal (`/journal.jsonl`). + * + * This is a pure extraction of the journal-read logic that previously lived + * only in `deploy/deploy.ts` (as `loadResolvedAddressesFromJournal`, used to + * populate `ResolverContext.resolvedAddresses` for the SAME network's + * previous run). It is now a standalone, exported helper so it can ALSO back + * `resolve/crossRef.ts`'s cross-NETWORK journal reads — the same primitive, + * applied to a DIFFERENT network's `deploymentDir` rather than the current + * one. + * + * We do NOT reimplement any journal semantics here — Ignition's own + * `status()` (from `@nomicfoundation/ignition-core`) owns parsing + * `journal.jsonl` and computing each future's on-chain status; this module + * only shapes the result into a plain `Record` keyed by + * spec entry id (stripping Ignition's `#` future-id prefix). + */ + +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import { status as ignitionStatus } from "@nomicfoundation/ignition-core"; + +/** + * Strips Ignition's "#" future-id prefix so callers can index by + * their spec entry id (e.g. "Deployment#registry" -> "registry"). + */ +export function stripModulePrefix(key: string, moduleId: string): string { + const prefix = `${moduleId}#`; + return key.startsWith(prefix) ? key.slice(prefix.length) : key; +} + +/** + * Best-effort read of the contract addresses recorded in a deployment + * journal at `/journal.jsonl`, keyed by spec entry id (the + * Ignition `#` prefix is stripped). + * + * Returns an empty object if no journal exists yet at `deploymentDir` (a + * fresh/never-deployed directory) — this is expected, documented behavior, + * not an error. We check `journal.jsonl`'s existence before calling + * Ignition's `status()` because `status()` throws an `IgnitionError` for a + * `deploymentDir` with no journal, and callers of this helper (the + * same-network resolver pre-pass in deploy/deploy.ts, and the cross-network + * pre-pass in resolve/crossRef.ts) should not need to parse/match that error + * shape just to handle "nothing deployed there yet". + * + * @param deploymentDir The directory containing `journal.jsonl` for the + * target network/deployment. + * @param moduleId The Ignition module id used when that deployment was + * run. Defaults to `"Deployment"` (deploy()'s own default — see + * `deploy/deploy.ts`'s `DeployOptions.moduleId`). + */ +export async function loadAddressesFromJournal( + deploymentDir: string, + moduleId = "Deployment", +): Promise> { + const journalPath = join(deploymentDir, "journal.jsonl"); + if (!existsSync(journalPath)) { + return {}; + } + const statusResult = await ignitionStatus(deploymentDir); + // Defense-in-depth against prototype pollution: build the map with no + // prototype so a stripped contract id equal to an Object.prototype member + // (e.g. "__proto__", "constructor") can never resolve to an inherited + // value at any downstream lookup site — even one that forgets the explicit + // Object.hasOwn guard callers are expected to use (see resolve/crossRef.ts). + const addresses: Record = Object.create(null) as Record; + for (const [key, contract] of Object.entries(statusResult.contracts)) { + addresses[stripModulePrefix(key, moduleId)] = contract.address; + } + return addresses; +} diff --git a/packages/core/src/simulate/simulate.ts b/packages/core/src/simulate/simulate.ts index 89109f7..aa23f69 100644 --- a/packages/core/src/simulate/simulate.ts +++ b/packages/core/src/simulate/simulate.ts @@ -107,6 +107,21 @@ export interface PlannedStep { * refs, then after). */ readonly dependsOn: string[]; + /** + * Cross-chain dependency refs declared anywhere on this entry (constructor + * `args`, `upgradeable.initializer.args`, `upgradeable.proxyAdminOwner`) — + * one `{ network, contract }` pair per `{ kind: "crossRef" }` arg found, in + * the same left-to-right order those args were collected for `dependsOn` + * below (args, then upgradeable). Surfaced as a DISTINCT field from + * `dependsOn` because a crossRef targets a contract on a DIFFERENT network + * entirely — it is never a same-run build/deploy-order dependency (see + * CrossRefArg's doc comment in spec/types.ts for the full v1 scope + * boundary). Undefined when the entry declares no crossRef args. + * + * Rendering these as distinct (e.g. cross-network) edges in a visual plan + * is a studio follow-up — this field only surfaces the data plan-only. + */ + readonly crossRefs?: readonly { readonly network: string; readonly contract: string }[]; /** * Present iff this entry declares `ContractEntry.upgradeable` — surfaces * the proxy shape WITHOUT compiling a real Ignition module (this stays a @@ -214,17 +229,32 @@ export function simulate(spec: unknown): SimulateResult { } }; + // crossRef args are collected SEPARATELY from dependsOn — see + // PlannedStep.crossRefs's doc comment. Not deduplicated (unlike + // dependsOn): the same {network, contract} pair could legitimately be + // referenced from multiple positions (e.g. two different constructor + // args), and each occurrence is a distinct plan-relevant fact. + const crossRefs: { network: string; contract: string }[] = []; + const addCrossRefDep = (arg: ContractArg): void => { + if (arg.kind === "crossRef") { + crossRefs.push({ network: arg.network, contract: arg.contract }); + } + }; + for (const arg of entry.args ?? []) { addRefDep(arg); + addCrossRefDep(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); + addCrossRefDep(arg); } if (entry.upgradeable?.proxyAdminOwner !== undefined) { addRefDep(entry.upgradeable.proxyAdminOwner); + addCrossRefDep(entry.upgradeable.proxyAdminOwner); } for (const afterId of entry.after ?? []) { if (!seenDeps.has(afterId)) { @@ -239,6 +269,7 @@ export function simulate(spec: unknown): SimulateResult { ...(entry.args !== undefined ? { args: entry.args } : {}), ...(entry.after !== undefined ? { after: entry.after } : {}), dependsOn, + ...(crossRefs.length > 0 ? { crossRefs } : {}), ...(entry.upgradeable !== undefined ? { upgradeable: { diff --git a/packages/core/src/spec/index.ts b/packages/core/src/spec/index.ts index 181a63e..a572374 100644 --- a/packages/core/src/spec/index.ts +++ b/packages/core/src/spec/index.ts @@ -9,6 +9,7 @@ export type { ParamArg, ExprArg, ResolverArg, + CrossRefArg, LiteralScalar, LiteralValue, ContractArg, @@ -22,6 +23,7 @@ export { contractEntrySchema, deploymentSpecSchema, resolverArgSchema, + crossRefArgSchema, preflightPolicySchema, } from "./schema.js"; diff --git a/packages/core/src/spec/schema.ts b/packages/core/src/spec/schema.ts index 6e27487..44271d1 100644 --- a/packages/core/src/spec/schema.ts +++ b/packages/core/src/spec/schema.ts @@ -135,6 +135,21 @@ export const resolverArgSchema = z.object({ args: z.array(literalValueSchema).optional(), }); +/** + * `{ kind: "crossRef", network: "", contract: "" }` + * + * Shape validation only — both `network` and `contract` are opaque strings to + * this layer. `network` resolution against an injected journals map, and + * whether `contract` was actually deployed there, are checked at deploy time + * by `resolveCrossRefArgs()` (resolve/crossRef.ts) — see `CrossRefArg`'s doc + * comment in spec/types.ts for the full design. + */ +export const crossRefArgSchema = z.object({ + kind: z.literal("crossRef"), + network: z.string().min(1, { message: "crossRef.network must be a non-empty string" }), + contract: z.string().min(1, { message: "crossRef.contract must be a non-empty string" }), +}); + /** * ContractArg discriminated union. * Unknown `kind` values produce a clear parse error. @@ -145,6 +160,7 @@ export const contractArgSchema: z.ZodType = z.discriminatedUnion("k paramArgSchema, exprArgSchema, resolverArgSchema, + crossRefArgSchema, ]); // --------------------------------------------------------------------------- diff --git a/packages/core/src/spec/types.ts b/packages/core/src/spec/types.ts index f6811f4..c172756 100644 --- a/packages/core/src/spec/types.ts +++ b/packages/core/src/spec/types.ts @@ -122,12 +122,68 @@ export interface ResolverArg { readonly args?: readonly LiteralValue[]; } +/** + * A reference to a contract deployed on a DIFFERENT network than the one this + * spec is being deployed to. + * + * Unlike `RefArg` (which references a sibling entry in THIS spec/run and is + * resolved by Ignition into a real build-time future dependency), a + * `CrossRefArg` names a `network` (an opaque string — core has no network + * registry; see `resolve/crossRef.ts`'s `ResolveCrossRefOptions.journals`, + * which the CALLER injects, typically backed by a project's own + * network-to-deployment-directory mapping such as + * `@redeploy/deploy-server`'s `NetworksRegistry`) and a `contract` id that + * must already be deployed and journaled on THAT network. + * + * Resolution happens at deploy time, BEFORE compilation, via + * `resolveCrossRefArgs()` (`resolve/crossRef.ts`): the target network's + * Ignition journal is read (using the SAME journal primitive `deploy()` uses + * for its own deploymentDir — see `resolve/journal.ts`) and the arg is + * replaced with a concrete `{ kind: "literal", value:
}`. A + * compiled spec must NEVER contain a `crossRef` arg — `compileSpec()` throws + * `CompileError("UNRESOLVED_CROSS_REF_ARG")` if it ever sees one (mirrors the + * `ResolverArg` guard). + * + * SCOPE BOUNDARY (v1): the referenced network's deployment must already be + * COMPLETE before this spec is deployed. There is no cross-chain + * orchestrator, no automatic ordering across networks, and no attempt to + * "wait" for the other network — sequencing multi-network deployments is + * entirely the operator's responsibility. `crossRef` args also contribute NO + * build-order/cycle-detection edges (`validate.ts`'s `detectCycles` and + * `compile.ts`'s `buildCreationOrder`) since the target lives outside this + * spec's own dependency graph entirely — checking it here would be + * meaningless (and cross-spec cycles are not a concept this library models). + * + * @example + * ```ts + * { kind: "crossRef", network: "mainnet", contract: "registry" } + * ``` + */ +export interface CrossRefArg { + readonly kind: "crossRef"; + /** + * The name of the OTHER network the referenced contract was deployed to. + * Opaque to core — resolved against `ResolveCrossRefOptions.journals` + * (resolve/crossRef.ts), which the caller injects. + */ + readonly network: string; + /** The `id` of the contract, as deployed on `network`. */ + readonly contract: string; +} + /** * A constructor argument for a contract: a ref to another contract, a literal - * value, a named parameter, a computed expression, or a typed resolver - * escape-hatch. + * value, a named parameter, a computed expression, a typed resolver + * escape-hatch, or a cross-chain reference to a contract deployed on a + * different network. */ -export type ContractArg = RefArg | LiteralArg | ParamArg | ExprArg | ResolverArg; +export type ContractArg = + | RefArg + | LiteralArg + | ParamArg + | ExprArg + | ResolverArg + | CrossRefArg; /** * Configuration for an upgrade-time "initializer" call — the function invoked diff --git a/packages/core/src/spec/validate.ts b/packages/core/src/spec/validate.ts index 9253050..c8c3afe 100644 --- a/packages/core/src/spec/validate.ts +++ b/packages/core/src/spec/validate.ts @@ -117,6 +117,18 @@ function checkArgRefAndParam( code: "UNKNOWN_PARAM", message: `Contract "${entry.id}" ${locationDescription} references undeclared parameter "${arg.name}" — add it to DeploymentSpec.parameters`, }); + } else if (arg.kind === "crossRef") { + // crossRef is intentionally shape-only here: `network` and `contract` + // target a DIFFERENT network's deployment, entirely outside this spec's + // own id space — checking `arg.contract` against `allIds` (this spec's + // ids) or flagging SELF_REFERENCE would be meaningless (and could + // produce false positives if a same-named id happens to exist locally). + // zod's `crossRefArgSchema` already guarantees both fields are + // non-empty strings; real resolution (does `network` exist in the + // injected journals map, was `contract` actually deployed there) happens + // at deploy time in resolve/crossRef.ts's resolveCrossRefArgs(), which + // has visibility into the injected ResolveCrossRefOptions that + // validateSpec does not. } } diff --git a/packages/core/test/compile.test.ts b/packages/core/test/compile.test.ts index 746f58f..76ad27e 100644 --- a/packages/core/test/compile.test.ts +++ b/packages/core/test/compile.test.ts @@ -1113,6 +1113,82 @@ describe("compileSpec — UNRESOLVED_RESOLVER_ARG error (unresolved ResolverArg) }); }); +// --------------------------------------------------------------------------- +// CrossRefArg — unresolved cross-chain refs at compile time (issue #159) +// --------------------------------------------------------------------------- +// +// compileSpec() is called by deploy() only AFTER its crossRef pre-resolution +// pass (resolve/crossRef.ts) has already replaced every `{ kind: "crossRef" }` +// arg with a concrete `{ kind: "literal" }` arg — so compileSpec() itself +// should never see a crossRef arg in the normal deploy() pipeline. These +// tests exercise compileSpec()'s defensive behavior for direct callers who +// pass a spec that still contains unresolved crossRef args (mirrors the +// UNRESOLVED_RESOLVER_ARG tests above). + +describe("compileSpec — UNRESOLVED_CROSS_REF_ARG error (unresolved CrossRefArg)", () => { + it("throws CompileError(UNRESOLVED_CROSS_REF_ARG) for an unresolved crossRef arg", () => { + const spec: DeploymentSpec = { + version: 1, + contracts: [ + { + id: "vault", + contract: "Vault", + args: [{ kind: "crossRef", network: "mainnet", contract: "registry" }], + }, + ], + }; + expect(() => compileSpec(spec)).toThrowError(CompileError); + try { + compileSpec(spec); + } catch (err) { + expect(err).toBeInstanceOf(CompileError); + expect((err as CompileError).code).toBe("UNRESOLVED_CROSS_REF_ARG"); + expect((err as CompileError).message).toContain("registry"); + expect((err as CompileError).message).toContain("mainnet"); + expect((err as CompileError).path).toBe("contracts[id=vault].args[0]"); + } + }); + + it("compiles successfully once the crossRef arg has been pre-substituted with a literal", () => { + // Simulates what deploy()'s crossRef pre-resolution pass does: replace + // the crossRef arg with a concrete literal BEFORE calling compileSpec(). + const spec: DeploymentSpec = { + version: 1, + contracts: [ + { + id: "vault", + contract: "Vault", + args: [{ kind: "literal", value: "0x000000000000000000000000000000000000AA" }], + }, + ], + }; + const mod = compileSpec(spec); + const f = asContractFuture([...mod.futures][0]); + expect(f.constructorArgs[0]).toBe("0x000000000000000000000000000000000000AA"); + }); + + it("crossRef args contribute no build-order dependency edges (buildCreationOrder)", () => { + // A crossRef arg referencing a LOCAL-looking contract id textually (in + // `contract`) must NOT create a build-time dependency edge — the target + // lives on a DIFFERENT network entirely, outside this spec's dependency + // graph. Order here is declared with the "dependent" contract FIRST + // specifically to prove no edge was created. + const spec: DeploymentSpec = { + version: 1, + contracts: [ + { + id: "vault", + contract: "Vault", + args: [{ kind: "crossRef", network: "mainnet", contract: "registry" }], + }, + { id: "registry", contract: "Registry" }, + ], + }; + const ordered = buildCreationOrder(spec.contracts); + expect(ordered.map((e) => e.id)).toEqual(["vault", "registry"]); + }); +}); + // --------------------------------------------------------------------------- // 15. Upgradeable proxies — UUPS (issue #155) // --------------------------------------------------------------------------- diff --git a/packages/core/test/crossRef.test.ts b/packages/core/test/crossRef.test.ts new file mode 100644 index 0000000..2711aca --- /dev/null +++ b/packages/core/test/crossRef.test.ts @@ -0,0 +1,629 @@ +/** + * Tests for the cross-chain dependency ref pre-resolution pass (issue #159): + * resolve/crossRef.ts's resolveCrossRefArgs()/specHasCrossRefArgs(), and the + * extracted journal-read primitive resolve/journal.ts's + * loadAddressesFromJournal(). + * + * "Fixture journal dir" strategy: rather than hand-crafting a + * journal.jsonl (an Ignition-internal format), we run a REAL `deploy()` call + * against the in-memory fake EIP-1193 provider (test/testHelpers) to produce + * a genuine journal on disk — the exact same strategy already used by + * test/deploy.test.ts's "ctx.resolvedAddresses populated from a PREVIOUS + * run's journal" test for the same-network resolver case. This exercises the + * real Ignition `status()` read path, not a mocked one. + */ + +import { describe, it, expect, afterEach, beforeAll, afterAll } from "vitest"; +import { + resolveCrossRefArgs, + specHasCrossRefArgs, +} from "../src/resolve/crossRef.js"; +import { CrossRefError } from "../src/resolve/crossRefErrors.js"; +import { loadAddressesFromJournal } from "../src/resolve/journal.js"; +import type { DeploymentSpec } from "../src/spec/types.js"; +import { deploy } from "../src/deploy/deploy.js"; +import { + makeFakeArtifactResolver, + makeFakeProvider, + makeTmpDir, + rmTmpDir, + FAKE_ACCOUNTS, +} from "./testHelpers/fakeEip1193Provider.js"; + +// --------------------------------------------------------------------------- +// specHasCrossRefArgs +// --------------------------------------------------------------------------- + +describe("specHasCrossRefArgs", () => { + it("returns false for a spec with no args at all", () => { + const spec: DeploymentSpec = { version: 1, contracts: [{ id: "a", contract: "A" }] }; + expect(specHasCrossRefArgs(spec)).toBe(false); + }); + + it("returns false for a spec with only literal/ref/param/expr/resolver args", () => { + const spec: DeploymentSpec = { + version: 1, + contracts: [ + { id: "a", contract: "A" }, + { + id: "b", + contract: "B", + args: [ + { kind: "literal", value: "x" }, + { kind: "ref", contract: "a" }, + { kind: "param", name: "p" }, + { kind: "expr", expression: "1n" }, + { kind: "resolver", name: "r" }, + ], + }, + ], + parameters: { p: 1 }, + }; + expect(specHasCrossRefArgs(spec)).toBe(false); + }); + + it("returns true when a constructor arg is a crossRef", () => { + const spec: DeploymentSpec = { + version: 1, + contracts: [ + { id: "b", contract: "B", args: [{ kind: "crossRef", network: "mainnet", contract: "registry" }] }, + ], + }; + expect(specHasCrossRefArgs(spec)).toBe(true); + }); + + it("returns true when upgradeable.initializer.args contains a crossRef", () => { + const spec: DeploymentSpec = { + version: 1, + contracts: [ + { + id: "b", + contract: "B", + upgradeable: { + kind: "uups", + initializer: { + function: "initialize", + args: [{ kind: "crossRef", network: "mainnet", contract: "registry" }], + }, + }, + }, + ], + }; + expect(specHasCrossRefArgs(spec)).toBe(true); + }); + + it("returns true when upgradeable.proxyAdminOwner is a crossRef", () => { + const spec: DeploymentSpec = { + version: 1, + contracts: [ + { + id: "b", + contract: "B", + upgradeable: { + kind: "transparent", + proxyAdminOwner: { kind: "crossRef", network: "mainnet", contract: "registry" }, + }, + }, + ], + }; + expect(specHasCrossRefArgs(spec)).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// resolveCrossRefArgs — fast paths / pass-through +// --------------------------------------------------------------------------- + +describe("resolveCrossRefArgs — fast paths", () => { + it("returns the SAME spec reference when there are no crossRef args", async () => { + const spec: DeploymentSpec = { version: 1, contracts: [{ id: "a", contract: "A" }] }; + const result = await resolveCrossRefArgs(spec, { journals: {} }); + expect(result).toBe(spec); + }); + + it("leaves entries with no crossRef args untouched (same object reference)", async () => { + const untouchedEntry = { id: "a", contract: "A" }; + const spec: DeploymentSpec = { + version: 1, + contracts: [ + untouchedEntry, + { + id: "b", + contract: "B", + args: [{ kind: "crossRef", network: "net1", contract: "x" }], + }, + ], + }; + // net1's journal is empty (fresh dir) -> resolution will throw + // CROSS_REF_NOT_DEPLOYED, but we only care that entry "a" is untouched up + // to the point of iteration — use a network that DOES resolve instead. + const tmpDir = makeTmpDir(); + try { + const provider = makeFakeProvider(); + const seedResult = await deploy({ + spec: { version: 1, contracts: [{ id: "x", contract: "X" }] }, + provider, + accounts: FAKE_ACCOUNTS, + deploymentDir: tmpDir, + artifactResolver: makeFakeArtifactResolver({ X: 0 }), + }); + expect(seedResult.success).toBe(true); + + const result = await resolveCrossRefArgs(spec, { + journals: { net1: { deploymentDir: tmpDir } }, + }); + expect(result.contracts[0]).toBe(untouchedEntry); + } finally { + rmTmpDir(tmpDir); + } + }); +}); + +// --------------------------------------------------------------------------- +// resolveCrossRefArgs — resolves a real address from a fixture journal dir +// --------------------------------------------------------------------------- + +describe("resolveCrossRefArgs — resolves against a real journal", () => { + let sourceDir: string; + afterEach(() => { + if (sourceDir) rmTmpDir(sourceDir); + }); + + it("substitutes a crossRef arg with the literal address read from the target network's journal", async () => { + sourceDir = makeTmpDir(); + const provider = makeFakeProvider(); + + const sourceResult = await deploy({ + spec: { version: 1, contracts: [{ id: "registry", contract: "Registry" }] }, + provider, + accounts: FAKE_ACCOUNTS, + deploymentDir: sourceDir, + artifactResolver: makeFakeArtifactResolver({ Registry: 0 }), + }); + expect(sourceResult.success).toBe(true); + const registryAddress = sourceResult.deployedAddresses["registry"]; + expect(registryAddress).toMatch(/^0x/); + + const spec: DeploymentSpec = { + version: 1, + contracts: [ + { + id: "vault", + contract: "Vault", + args: [{ kind: "crossRef", network: "mainnet", contract: "registry" }], + }, + ], + }; + + const resolved = await resolveCrossRefArgs(spec, { + journals: { mainnet: { deploymentDir: sourceDir } }, + }); + + expect(resolved.contracts[0].args).toEqual([{ kind: "literal", value: registryAddress }]); + }); + + it("resolves a crossRef arg inside upgradeable.initializer.args", async () => { + sourceDir = makeTmpDir(); + const provider = makeFakeProvider(); + const sourceResult = await deploy({ + spec: { version: 1, contracts: [{ id: "registry", contract: "Registry" }] }, + provider, + accounts: FAKE_ACCOUNTS, + deploymentDir: sourceDir, + artifactResolver: makeFakeArtifactResolver({ Registry: 0 }), + }); + const registryAddress = sourceResult.deployedAddresses["registry"]; + + const spec: DeploymentSpec = { + version: 1, + contracts: [ + { + id: "vault", + contract: "Vault", + upgradeable: { + kind: "uups", + initializer: { + function: "initialize", + args: [{ kind: "crossRef", network: "mainnet", contract: "registry" }], + }, + }, + }, + ], + }; + + const resolved = await resolveCrossRefArgs(spec, { + journals: { mainnet: { deploymentDir: sourceDir } }, + }); + + expect(resolved.contracts[0].upgradeable?.initializer?.args).toEqual([ + { kind: "literal", value: registryAddress }, + ]); + }); + + it("resolves a crossRef arg used as upgradeable.proxyAdminOwner", async () => { + sourceDir = makeTmpDir(); + const provider = makeFakeProvider(); + const sourceResult = await deploy({ + spec: { version: 1, contracts: [{ id: "registry", contract: "Registry" }] }, + provider, + accounts: FAKE_ACCOUNTS, + deploymentDir: sourceDir, + artifactResolver: makeFakeArtifactResolver({ Registry: 0 }), + }); + const registryAddress = sourceResult.deployedAddresses["registry"]; + + const spec: DeploymentSpec = { + version: 1, + contracts: [ + { + id: "vault", + contract: "Vault", + upgradeable: { + kind: "transparent", + proxyAdminOwner: { kind: "crossRef", network: "mainnet", contract: "registry" }, + }, + }, + ], + }; + + const resolved = await resolveCrossRefArgs(spec, { + journals: { mainnet: { deploymentDir: sourceDir } }, + }); + + expect(resolved.contracts[0].upgradeable?.proxyAdminOwner).toEqual({ + kind: "literal", + value: registryAddress, + }); + }); + + it("caches the journal read across multiple crossRef args on the same network", async () => { + sourceDir = makeTmpDir(); + const provider = makeFakeProvider(); + const sourceResult = await deploy({ + spec: { + version: 1, + contracts: [ + { id: "registry", contract: "Registry" }, + { id: "token", contract: "Token" }, + ], + }, + provider, + accounts: FAKE_ACCOUNTS, + deploymentDir: sourceDir, + artifactResolver: makeFakeArtifactResolver({ Registry: 0, Token: 0 }), + }); + expect(sourceResult.success).toBe(true); + + const spec: DeploymentSpec = { + version: 1, + contracts: [ + { + id: "vault", + contract: "Vault", + args: [ + { kind: "crossRef", network: "mainnet", contract: "registry" }, + { kind: "crossRef", network: "mainnet", contract: "token" }, + ], + }, + ], + }; + + const resolved = await resolveCrossRefArgs(spec, { + journals: { mainnet: { deploymentDir: sourceDir } }, + }); + + expect(resolved.contracts[0].args).toEqual([ + { kind: "literal", value: sourceResult.deployedAddresses["registry"] }, + { kind: "literal", value: sourceResult.deployedAddresses["token"] }, + ]); + }); + + it("leaves non-crossRef args in the same entry untouched", async () => { + sourceDir = makeTmpDir(); + const provider = makeFakeProvider(); + const sourceResult = await deploy({ + spec: { version: 1, contracts: [{ id: "registry", contract: "Registry" }] }, + provider, + accounts: FAKE_ACCOUNTS, + deploymentDir: sourceDir, + artifactResolver: makeFakeArtifactResolver({ Registry: 0 }), + }); + const registryAddress = sourceResult.deployedAddresses["registry"]; + + const spec: DeploymentSpec = { + version: 1, + contracts: [ + { + id: "vault", + contract: "Vault", + args: [ + { kind: "literal", value: "unchanged" }, + { kind: "crossRef", network: "mainnet", contract: "registry" }, + ], + }, + ], + }; + + const resolved = await resolveCrossRefArgs(spec, { + journals: { mainnet: { deploymentDir: sourceDir } }, + }); + + expect(resolved.contracts[0].args).toEqual([ + { kind: "literal", value: "unchanged" }, + { kind: "literal", value: registryAddress }, + ]); + }); +}); + +// --------------------------------------------------------------------------- +// resolveCrossRefArgs — errors +// --------------------------------------------------------------------------- + +describe("resolveCrossRefArgs — errors", () => { + it("throws CrossRefError(CROSS_REF_UNKNOWN_NETWORK) when the network is absent from options.journals", async () => { + const spec: DeploymentSpec = { + version: 1, + contracts: [ + { + id: "vault", + contract: "Vault", + args: [{ kind: "crossRef", network: "ghostNetwork", contract: "registry" }], + }, + ], + }; + + await expect(resolveCrossRefArgs(spec, { journals: {} })).rejects.toThrow(CrossRefError); + + try { + await resolveCrossRefArgs(spec, { journals: {} }); + expect.fail("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(CrossRefError); + const crossRefErr = err as CrossRefError; + expect(crossRefErr.code).toBe("CROSS_REF_UNKNOWN_NETWORK"); + expect(crossRefErr.message).toContain("ghostNetwork"); + expect(crossRefErr.message).toContain('"vault"'); + } + }); + + it("throws CrossRefError(CROSS_REF_NOT_DEPLOYED) when the target network's journal doesn't exist yet", async () => { + const freshDir = makeTmpDir(); + try { + const spec: DeploymentSpec = { + version: 1, + contracts: [ + { + id: "vault", + contract: "Vault", + args: [{ kind: "crossRef", network: "mainnet", contract: "registry" }], + }, + ], + }; + + try { + await resolveCrossRefArgs(spec, { journals: { mainnet: { deploymentDir: freshDir } } }); + expect.fail("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(CrossRefError); + expect((err as CrossRefError).code).toBe("CROSS_REF_NOT_DEPLOYED"); + expect((err as CrossRefError).message).toContain("mainnet"); + expect((err as CrossRefError).message).toContain('deploy network "mainnet" first'); + } + } finally { + rmTmpDir(freshDir); + } + }); + + it("throws CrossRefError(CROSS_REF_NOT_DEPLOYED) when the journal exists but lacks the requested contract id", async () => { + const sourceDir = makeTmpDir(); + try { + const provider = makeFakeProvider(); + const sourceResult = await deploy({ + spec: { version: 1, contracts: [{ id: "registry", contract: "Registry" }] }, + provider, + accounts: FAKE_ACCOUNTS, + deploymentDir: sourceDir, + artifactResolver: makeFakeArtifactResolver({ Registry: 0 }), + }); + expect(sourceResult.success).toBe(true); + + const spec: DeploymentSpec = { + version: 1, + contracts: [ + { + id: "vault", + contract: "Vault", + args: [{ kind: "crossRef", network: "mainnet", contract: "neverDeployed" }], + }, + ], + }; + + try { + await resolveCrossRefArgs(spec, { journals: { mainnet: { deploymentDir: sourceDir } } }); + expect.fail("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(CrossRefError); + expect((err as CrossRefError).code).toBe("CROSS_REF_NOT_DEPLOYED"); + expect((err as CrossRefError).message).toContain("neverDeployed"); + } + } finally { + rmTmpDir(sourceDir); + } + }); + + it("respects a custom moduleId when reading the target network's journal", async () => { + const sourceDir = makeTmpDir(); + try { + const provider = makeFakeProvider(); + const sourceResult = await deploy({ + spec: { version: 1, contracts: [{ id: "registry", contract: "Registry" }] }, + provider, + accounts: FAKE_ACCOUNTS, + deploymentDir: sourceDir, + artifactResolver: makeFakeArtifactResolver({ Registry: 0 }), + moduleId: "SourceModule", + }); + expect(sourceResult.success).toBe(true); + const registryAddress = sourceResult.deployedAddresses["registry"]; + + const spec: DeploymentSpec = { + version: 1, + contracts: [ + { + id: "vault", + contract: "Vault", + args: [{ kind: "crossRef", network: "mainnet", contract: "registry" }], + }, + ], + }; + + const resolved = await resolveCrossRefArgs(spec, { + journals: { mainnet: { deploymentDir: sourceDir, moduleId: "SourceModule" } }, + }); + + expect(resolved.contracts[0].args).toEqual([{ kind: "literal", value: registryAddress }]); + } finally { + rmTmpDir(sourceDir); + } + }); +}); + +// --------------------------------------------------------------------------- +// resolveCrossRefArgs — prototype-pollution guard (mirrors resolveSpec.ts's +// guard for the ResolverRegistry lookup) +// --------------------------------------------------------------------------- + +describe("resolveCrossRefArgs — prototype-pollution guard", () => { + it.each(["toString", "constructor", "hasOwnProperty", "valueOf", "__proto__"])( + "throws CrossRefError(CROSS_REF_UNKNOWN_NETWORK) for network name %j when absent from an EMPTY journals map", + async (network) => { + const spec: DeploymentSpec = { + version: 1, + contracts: [{ id: "a", contract: "A", args: [{ kind: "crossRef", network, contract: "x" }] }], + }; + try { + await resolveCrossRefArgs(spec, { journals: {} }); + expect.fail(`should have thrown for network ${network}`); + } catch (err) { + expect(err).toBeInstanceOf(CrossRefError); + expect((err as CrossRefError).code).toBe("CROSS_REF_UNKNOWN_NETWORK"); + } + }, + ); +}); + +// --------------------------------------------------------------------------- +// resolveCrossRefArgs — prototype-pollution guard on the ADDRESS lookup +// (addresses[contract]) — a real fixture journal that does NOT contain a +// prototype-key contract id must still be rejected as CROSS_REF_NOT_DEPLOYED, +// never silently resolving to an inherited Object.prototype member. +// --------------------------------------------------------------------------- + +describe("resolveCrossRefArgs — prototype-pollution guard (contract id)", () => { + let sourceDir: string; + + beforeAll(async () => { + sourceDir = makeTmpDir(); + const provider = makeFakeProvider(); + const sourceResult = await deploy({ + spec: { version: 1, contracts: [{ id: "registry", contract: "Registry" }] }, + provider, + accounts: FAKE_ACCOUNTS, + deploymentDir: sourceDir, + artifactResolver: makeFakeArtifactResolver({ Registry: 0 }), + }); + expect(sourceResult.success).toBe(true); + }); + + afterAll(() => { + rmTmpDir(sourceDir); + }); + + it.each(["toString", "constructor", "hasOwnProperty", "valueOf", "__proto__"])( + "throws CrossRefError(CROSS_REF_NOT_DEPLOYED) for contract id %j when absent from a real journal", + async (contractId) => { + const spec: DeploymentSpec = { + version: 1, + contracts: [ + { + id: "vault", + contract: "Vault", + args: [{ kind: "crossRef", network: "mainnet", contract: contractId }], + }, + ], + }; + + try { + await resolveCrossRefArgs(spec, { journals: { mainnet: { deploymentDir: sourceDir } } }); + expect.fail(`should have thrown for contract id ${contractId}`); + } catch (err) { + expect(err).toBeInstanceOf(CrossRefError); + expect((err as CrossRefError).code).toBe("CROSS_REF_NOT_DEPLOYED"); + expect((err as CrossRefError).message).not.toMatch(/function|\[native code\]/); + } + }, + ); +}); + +// --------------------------------------------------------------------------- +// loadAddressesFromJournal (resolve/journal.ts) — the extracted primitive +// --------------------------------------------------------------------------- + +describe("loadAddressesFromJournal", () => { + it("returns an empty object for a directory with no journal.jsonl yet", async () => { + const freshDir = makeTmpDir(); + try { + const addresses = await loadAddressesFromJournal(freshDir); + expect(addresses).toEqual({}); + } finally { + rmTmpDir(freshDir); + } + }); + + it("reads deployed addresses keyed by spec entry id (default moduleId)", async () => { + const dir = makeTmpDir(); + try { + const provider = makeFakeProvider(); + const result = await deploy({ + spec: { + version: 1, + contracts: [ + { id: "registry", contract: "Registry" }, + { id: "token", contract: "Token" }, + ], + }, + provider, + accounts: FAKE_ACCOUNTS, + deploymentDir: dir, + artifactResolver: makeFakeArtifactResolver({ Registry: 0, Token: 0 }), + }); + expect(result.success).toBe(true); + + const addresses = await loadAddressesFromJournal(dir); + expect(addresses).toEqual(result.deployedAddresses); + } finally { + rmTmpDir(dir); + } + }); + + it("reads deployed addresses keyed correctly for a custom moduleId", async () => { + const dir = makeTmpDir(); + try { + const provider = makeFakeProvider(); + const result = await deploy({ + spec: { version: 1, contracts: [{ id: "registry", contract: "Registry" }] }, + provider, + accounts: FAKE_ACCOUNTS, + deploymentDir: dir, + artifactResolver: makeFakeArtifactResolver({ Registry: 0 }), + moduleId: "CustomModule", + }); + expect(result.success).toBe(true); + + const addresses = await loadAddressesFromJournal(dir, "CustomModule"); + expect(addresses).toEqual({ registry: result.deployedAddresses["registry"] }); + } finally { + rmTmpDir(dir); + } + }); +}); diff --git a/packages/core/test/crossRefAddressGuard.test.ts b/packages/core/test/crossRefAddressGuard.test.ts new file mode 100644 index 0000000..6ae60c1 --- /dev/null +++ b/packages/core/test/crossRefAddressGuard.test.ts @@ -0,0 +1,51 @@ +/** + * Tests for resolve/crossRef.ts's defense-in-depth `isAddress` guard + * (issue #159 hardening follow-up): if a journal somehow yields a non-address + * string for a crossRef'd contract id, resolveCrossRefArgs() must throw + * CrossRefError("CROSS_REF_NOT_DEPLOYED") rather than substituting an + * arbitrary string into on-chain calldata as a `{ kind: "literal" }` arg. + * + * This is a SEPARATE test file (rather than living in crossRef.test.ts) + * because it needs to mock resolve/journal.js's `loadAddressesFromJournal` to + * force a malformed value through — something a real fixture journal + * (produced by an honest `deploy()` call, per crossRef.test.ts's strategy) + * can never produce, since Ignition's `status()` only ever records genuine + * deployed addresses. `vi.mock` here is file-scoped, so it doesn't affect + * crossRef.test.ts's real-journal tests. + */ + +import { describe, it, expect, vi } from "vitest"; + +vi.mock("../src/resolve/journal.js", () => ({ + loadAddressesFromJournal: vi.fn(async () => ({ registry: "not-an-address" })), +})); + +import { resolveCrossRefArgs } from "../src/resolve/crossRef.js"; +import { CrossRefError } from "../src/resolve/crossRefErrors.js"; +import type { DeploymentSpec } from "../src/spec/types.js"; + +describe("resolveCrossRefArgs — invalid-address guard (defense in depth)", () => { + it("throws CrossRefError(CROSS_REF_NOT_DEPLOYED) when the journal's recorded value is not a valid address", async () => { + const spec: DeploymentSpec = { + version: 1, + contracts: [ + { + id: "vault", + contract: "Vault", + args: [{ kind: "crossRef", network: "mainnet", contract: "registry" }], + }, + ], + }; + + try { + await resolveCrossRefArgs(spec, { journals: { mainnet: { deploymentDir: "/irrelevant" } } }); + expect.fail("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(CrossRefError); + const crossRefErr = err as CrossRefError; + expect(crossRefErr.code).toBe("CROSS_REF_NOT_DEPLOYED"); + expect(crossRefErr.message).toContain("not-an-address"); + expect(crossRefErr.message).toContain('"registry"'); + } + }); +}); diff --git a/packages/core/test/deploy.test.ts b/packages/core/test/deploy.test.ts index ff3bb17..17a628d 100644 --- a/packages/core/test/deploy.test.ts +++ b/packages/core/test/deploy.test.ts @@ -1737,3 +1737,232 @@ describe("deploy() — PREFLIGHT phase", () => { } }, 30_000); }); + +// --------------------------------------------------------------------------- +// CrossRefArg + DeployOptions.crossNetworkJournals — cross-chain dependency +// refs, end-to-end (issue #159) +// --------------------------------------------------------------------------- +// +// Mirrors the ResolverArg end-to-end tests above: a crossRef arg's resolved +// value is decoded out of the REAL ABI-encoded constructor args Ignition +// sent on-chain, proving the whole chain works: spec CrossRefArg -> +// resolve/crossRef.ts's resolveCrossRefArgs() -> deploy()'s pre-compile pass +// -> compile.ts -> Ignition -> the actual deploy transaction. Both +// "networks" here are just two independent deploymentDirs deployed via the +// SAME in-memory fake provider (no real multi-chain setup needed — core has +// no network concept of its own; see CrossRefArg's docs). + +describe("deploy() — CrossRefArg resolution end-to-end", () => { + let sourceDir: string; + let targetDir: string; + afterEach(() => { + if (sourceDir) rmTmpDir(sourceDir); + if (targetDir) rmTmpDir(targetDir); + }); + + it("resolves a crossRef arg against another network's journal and passes its address into the real deploy transaction", async () => { + sourceDir = makeTmpDir(); + targetDir = makeTmpDir(); + const sourceState = makeProviderState(); + const targetState = makeProviderState(); + + // "Network A": deploy a Registry normally. + const sourceResult = await deploy({ + spec: { version: 1, contracts: [{ id: "registry", contract: "Registry" }] }, + provider: makeFakeProvider(sourceState), + accounts: ACCOUNTS, + deploymentDir: sourceDir, + artifactResolver: makeFakeArtifactResolver({ Registry: 0 }), + }); + expect(sourceResult.success).toBe(true); + const registryAddress = sourceResult.deployedAddresses["registry"]; + + // "Network B": deploy a Vault whose constructor arg is a crossRef to + // Network A's registry. + const spec: DeploymentSpec = { + version: 1, + contracts: [ + { + id: "vault", + contract: "Vault", + args: [{ kind: "crossRef", network: "networkA", contract: "registry" }], + }, + ], + }; + + const targetResult = await deploy({ + spec, + provider: makeFakeProvider(targetState), + accounts: ACCOUNTS, + deploymentDir: targetDir, + artifactResolver: makeTypedArtifactResolver({ + Vault: buildTypedConstructorAbi(["address"]), + }), + crossNetworkJournals: { networkA: { deploymentDir: sourceDir } }, + }); + + expect(targetResult.success).toBe(true); + const decoded = decodeDeployData({ + abi: buildTypedConstructorAbi(["address"]), + bytecode: FAKE_BYTECODE as `0x${string}`, + data: targetState.sentData[0] as `0x${string}`, + }); + expect((decoded.args?.[0] as string).toLowerCase()).toBe(registryAddress.toLowerCase()); + }, 30_000); + + it("mixes a crossRef arg with a same-run ref arg in the same entry", async () => { + sourceDir = makeTmpDir(); + targetDir = makeTmpDir(); + const sourceState = makeProviderState(); + const targetState = makeProviderState(); + + const sourceResult = await deploy({ + spec: { version: 1, contracts: [{ id: "registry", contract: "Registry" }] }, + provider: makeFakeProvider(sourceState), + accounts: ACCOUNTS, + deploymentDir: sourceDir, + artifactResolver: makeFakeArtifactResolver({ Registry: 0 }), + }); + const registryAddress = sourceResult.deployedAddresses["registry"]; + + const spec: DeploymentSpec = { + version: 1, + contracts: [ + { id: "token", contract: "Token" }, + { + id: "vault", + contract: "Vault", + args: [ + { kind: "ref", contract: "token" }, + { kind: "crossRef", network: "networkA", contract: "registry" }, + ], + }, + ], + }; + + const targetResult = await deploy({ + spec, + provider: makeFakeProvider(targetState), + accounts: ACCOUNTS, + deploymentDir: targetDir, + artifactResolver: makeTypedArtifactResolver({ + Token: [], + Vault: buildTypedConstructorAbi(["address", "address"]), + }), + crossNetworkJournals: { networkA: { deploymentDir: sourceDir } }, + }); + + expect(targetResult.success).toBe(true); + // vault is the second contract deployed (after token, via the ref dependency) + const decoded = decodeDeployData({ + abi: buildTypedConstructorAbi(["address", "address"]), + bytecode: FAKE_BYTECODE as `0x${string}`, + data: targetState.sentData[1] as `0x${string}`, + }); + expect((decoded.args?.[0] as string).toLowerCase()).toBe( + targetResult.deployedAddresses["token"].toLowerCase(), + ); + expect((decoded.args?.[1] as string).toLowerCase()).toBe(registryAddress.toLowerCase()); + }, 30_000); + + it("throws DeployError(CROSS_REF_ERROR) with ZERO tx sent when the network is absent from crossNetworkJournals", async () => { + targetDir = makeTmpDir(); + const state = makeProviderState(); + + const spec: DeploymentSpec = { + version: 1, + contracts: [ + { + id: "vault", + contract: "Vault", + args: [{ kind: "crossRef", network: "ghostNetwork", contract: "registry" }], + }, + ], + }; + + try { + await deploy({ + spec, + provider: makeFakeProvider(state), + accounts: ACCOUNTS, + deploymentDir: targetDir, + artifactResolver: makeTypedArtifactResolver({ + Vault: buildTypedConstructorAbi(["address"]), + }), + // no `crossNetworkJournals` supplied at all + }); + expect.fail("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(DeployError); + const deployErr = err as DeployError; + expect(deployErr.code).toBe("CROSS_REF_ERROR"); + expect(deployErr.message).toContain("ghostNetwork"); + } + + expect(state.sendTxCount).toBe(0); + }, 30_000); + + it("throws DeployError(CROSS_REF_ERROR) with ZERO tx sent when the referenced contract was not yet deployed on the target network", async () => { + sourceDir = makeTmpDir(); + targetDir = makeTmpDir(); + const sourceState = makeProviderState(); + const targetState = makeProviderState(); + + // Network A exists but never deployed "registry". + await deploy({ + spec: { version: 1, contracts: [{ id: "somethingElse", contract: "Registry" }] }, + provider: makeFakeProvider(sourceState), + accounts: ACCOUNTS, + deploymentDir: sourceDir, + artifactResolver: makeFakeArtifactResolver({ Registry: 0 }), + }); + + const spec: DeploymentSpec = { + version: 1, + contracts: [ + { + id: "vault", + contract: "Vault", + args: [{ kind: "crossRef", network: "networkA", contract: "registry" }], + }, + ], + }; + + try { + await deploy({ + spec, + provider: makeFakeProvider(targetState), + accounts: ACCOUNTS, + deploymentDir: targetDir, + artifactResolver: makeTypedArtifactResolver({ + Vault: buildTypedConstructorAbi(["address"]), + }), + crossNetworkJournals: { networkA: { deploymentDir: sourceDir } }, + }); + expect.fail("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(DeployError); + const deployErr = err as DeployError; + expect(deployErr.code).toBe("CROSS_REF_ERROR"); + expect(deployErr.message).toContain("registry"); + } + + expect(targetState.sendTxCount).toBe(0); + }, 30_000); + + it("does not require DeployOptions.crossNetworkJournals for specs with no crossRef args (backward compatible)", async () => { + targetDir = makeTmpDir(); + const state = makeProviderState(); + + const result = await deploy({ + spec: { version: 1, contracts: [{ id: "reg", contract: "Registry" }] }, + provider: makeFakeProvider(state), + accounts: ACCOUNTS, + deploymentDir: targetDir, + artifactResolver: makeFakeArtifactResolver({ Registry: 0 }), + // no `crossNetworkJournals` option — must not be required + }); + + expect(result.success).toBe(true); + }, 30_000); +}); diff --git a/packages/core/test/simulate.test.ts b/packages/core/test/simulate.test.ts index f5bd021..c876c79 100644 --- a/packages/core/test/simulate.test.ts +++ b/packages/core/test/simulate.test.ts @@ -599,6 +599,128 @@ describe("simulate — ResolverArg pass-through", () => { }); }); +// --------------------------------------------------------------------------- +// 12. CrossRefArg — PlannedStep.crossRefs (issue #159) +// --------------------------------------------------------------------------- +// +// simulate() is validate + topo-sort only — a crossRef arg is surfaced in a +// DISTINCT `crossRefs` field (not `dependsOn`), since the referenced contract +// lives on a different network entirely and is never a same-run build/deploy +// order dependency. + +describe("simulate — CrossRefArg surfacing", () => { + it("populates PlannedStep.crossRefs from a constructor crossRef arg", () => { + const result = simulate({ + version: 1, + contracts: [ + { + id: "vault", + contract: "Vault", + args: [{ kind: "crossRef", network: "mainnet", contract: "registry" }], + }, + ], + }); + const steps = assertOk(result); + expect(steps[0].crossRefs).toEqual([{ network: "mainnet", contract: "registry" }]); + }); + + it("does not add a dependsOn entry for a crossRef arg", () => { + const result = simulate({ + version: 1, + contracts: [ + { + id: "vault", + contract: "Vault", + args: [{ kind: "crossRef", network: "mainnet", contract: "registry" }], + }, + ], + }); + const steps = assertOk(result); + expect(steps[0].dependsOn).toEqual([]); + }); + + it("omits `crossRefs` entirely for an entry with no crossRef args (byte-for-byte compatible)", () => { + const result = simulate({ + version: 1, + contracts: [{ id: "token", contract: "Token" }], + }); + const steps = assertOk(result); + expect(steps[0]).not.toHaveProperty("crossRefs"); + }); + + it("collects multiple crossRef args across args/initializer.args/proxyAdminOwner", () => { + const result = simulate({ + version: 1, + contracts: [ + { + id: "vault", + contract: "Vault", + args: [{ kind: "crossRef", network: "mainnet", contract: "registry" }], + upgradeable: { + kind: "transparent", + initializer: { + function: "initialize", + args: [{ kind: "crossRef", network: "polygon", contract: "token" }], + }, + proxyAdminOwner: { kind: "crossRef", network: "arbitrum", contract: "admin" }, + }, + }, + ], + }); + const steps = assertOk(result); + expect(steps[0].crossRefs).toEqual([ + { network: "mainnet", contract: "registry" }, + { network: "polygon", contract: "token" }, + { network: "arbitrum", contract: "admin" }, + ]); + // None of these contribute a same-run dependency edge. + expect(steps[0].dependsOn).toEqual([]); + }); + + it("mixes a crossRef arg with ref/literal/param/expr/resolver args in the same step", () => { + const result = simulate({ + version: 1, + parameters: { threshold: 1 }, + contracts: [ + { id: "registry", contract: "Registry" }, + { + id: "vault", + contract: "Vault", + args: [ + { kind: "ref", contract: "registry" }, + { kind: "literal", value: "Vault Name" }, + { kind: "param", name: "threshold" }, + { kind: "expr", expression: "1n + 1n" }, + { kind: "resolver", name: "readOracle" }, + { kind: "crossRef", network: "mainnet", contract: "registry" }, + ], + }, + ], + }); + const steps = assertOk(result); + const vault = steps.find((s) => s.id === "vault")!; + expect(vault.args).toHaveLength(6); + expect(vault.crossRefs).toEqual([{ network: "mainnet", contract: "registry" }]); + // Only the ref contributes a dependsOn edge — crossRef does not. + expect(vault.dependsOn).toEqual(["registry"]); + }); + + it("never touches a chain/journal — stays synchronous and side-effect-free", () => { + const result = simulate({ + version: 1, + contracts: [ + { + id: "vault", + contract: "Vault", + args: [{ kind: "crossRef", network: "mainnet", contract: "registry" }], + }, + ], + }); + expect(result).not.toBeInstanceOf(Promise); + expect(assertOk(result)).toHaveLength(1); + }); +}); + // --------------------------------------------------------------------------- // Upgradeable proxies — PlannedStep.upgradeable marker (issue #155) // --------------------------------------------------------------------------- diff --git a/packages/core/test/spec.test.ts b/packages/core/test/spec.test.ts index a7e1852..557ce98 100644 --- a/packages/core/test/spec.test.ts +++ b/packages/core/test/spec.test.ts @@ -910,6 +910,215 @@ describe("validateSpec — ResolverArg shape rejection", () => { }); }); +// --------------------------------------------------------------------------- +// CrossRefArg — cross-chain dependency refs (issue #159) +// --------------------------------------------------------------------------- +// +// crossRef is shape-only at this layer: `network`/`contract` reference a +// DIFFERENT network's deployment entirely, so validateSpec must NOT check +// `contract` against this spec's own id set (no MISSING_REF/SELF_REFERENCE), +// and must NOT contribute a cycle/build-order edge (see detectCycles below). +// Real resolution happens at deploy time (resolve/crossRef.ts). + +describe("validateSpec — CrossRefArg happy path", () => { + it("accepts a crossRef arg", () => { + const result = validateSpec({ + version: 1, + contracts: [ + { + id: "vault", + contract: "Vault", + args: [{ kind: "crossRef", network: "mainnet", contract: "registry" }], + }, + ], + }); + expect(result.ok).toBe(true); + }); + + it("accepts a crossRef whose contract id happens to equal the entry's own id (not a SELF_REFERENCE — different network)", () => { + const result = validateSpec({ + version: 1, + contracts: [ + { + id: "vault", + contract: "Vault", + args: [{ kind: "crossRef", network: "mainnet", contract: "vault" }], + }, + ], + }); + expect(result.ok).toBe(true); + }); + + it("accepts a crossRef whose contract id does not exist anywhere in this spec (not a MISSING_REF — different network)", () => { + const result = validateSpec({ + version: 1, + contracts: [ + { + id: "vault", + contract: "Vault", + args: [{ kind: "crossRef", network: "mainnet", contract: "totallyUnknownElsewhere" }], + }, + ], + }); + expect(result.ok).toBe(true); + }); + + it("accepts a crossRef arg inside upgradeable.initializer.args", () => { + const result = validateSpec({ + version: 1, + contracts: [ + { + id: "vault", + contract: "Vault", + upgradeable: { + kind: "uups", + initializer: { + function: "initialize", + args: [{ kind: "crossRef", network: "mainnet", contract: "registry" }], + }, + }, + }, + ], + }); + expect(result.ok).toBe(true); + }); + + it("accepts a crossRef arg as upgradeable.proxyAdminOwner", () => { + const result = validateSpec({ + version: 1, + contracts: [ + { + id: "vault", + contract: "Vault", + upgradeable: { + kind: "transparent", + proxyAdminOwner: { kind: "crossRef", network: "mainnet", contract: "registry" }, + }, + }, + ], + }); + expect(result.ok).toBe(true); + }); + + it("accepts a mix of literal, ref, param, expr, resolver, and crossRef args in the same contract", () => { + const result = validateSpec({ + version: 1, + parameters: { threshold: 3 }, + contracts: [ + { id: "registry", contract: "Registry" }, + { + id: "vault", + contract: "Vault", + args: [ + { kind: "ref", contract: "registry" }, + { kind: "literal", value: "Vault Name" }, + { kind: "param", name: "threshold" }, + { kind: "expr", expression: "1n + 1n" }, + { kind: "resolver", name: "readOracle" }, + { kind: "crossRef", network: "mainnet", contract: "registry" }, + ], + }, + ], + }); + expect(result.ok).toBe(true); + }); + + it("does not require the crossRef network to be declared anywhere in the spec", () => { + const result = validateSpec({ + version: 1, + contracts: [ + { + id: "a", + contract: "A", + args: [{ kind: "crossRef", network: "notDeclaredAnywhere", contract: "b" }], + }, + ], + }); + expect(result.ok).toBe(true); + }); +}); + +describe("validateSpec — CrossRefArg shape rejection", () => { + it("rejects a crossRef arg with an empty network", () => { + const result = validateSpec({ + version: 1, + contracts: [ + { id: "a", contract: "A", args: [{ kind: "crossRef", network: "", contract: "b" }] }, + ], + }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.errors.some((e) => e.code === "INVALID_SHAPE")).toBe(true); + } + }); + + it("rejects a crossRef arg with an empty contract", () => { + const result = validateSpec({ + version: 1, + contracts: [ + { id: "a", contract: "A", args: [{ kind: "crossRef", network: "mainnet", contract: "" }] }, + ], + }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.errors.some((e) => e.code === "INVALID_SHAPE")).toBe(true); + } + }); + + it("rejects a crossRef arg missing the network field", () => { + const result = validateSpec({ + version: 1, + contracts: [{ id: "a", contract: "A", args: [{ kind: "crossRef", contract: "b" }] }], + }); + expect(result.ok).toBe(false); + }); + + it("rejects a crossRef arg missing the contract field", () => { + const result = validateSpec({ + version: 1, + contracts: [{ id: "a", contract: "A", args: [{ kind: "crossRef", network: "mainnet" }] }], + }); + expect(result.ok).toBe(false); + }); +}); + +describe("validateSpec — CrossRefArg does not contribute build-order/cycle edges", () => { + it("a spec whose only 'cyclic-looking' link is via crossRef does not report CYCLE", () => { + // If crossRef were (incorrectly) treated like a ref, "a" -> "b" -> "a" + // would be flagged as a cycle. It must not be, since the crossRef target + // lives on a different network entirely. + const result = validateSpec({ + version: 1, + contracts: [ + { id: "a", contract: "A", args: [{ kind: "ref", contract: "b" }] }, + { id: "b", contract: "B", args: [{ kind: "crossRef", network: "mainnet", contract: "a" }] }, + ], + }); + expect(result.ok).toBe(true); + }); + + it("a crossRef referencing an unknown id anywhere never produces MISSING_REF or SELF_REFERENCE", () => { + const result = validateSpec({ + version: 1, + contracts: [ + { + id: "onlyEntry", + contract: "Only", + args: [{ kind: "crossRef", network: "mainnet", contract: "onlyEntry" }], + }, + ], + }); + expect(result.ok).toBe(true); + if (result.ok) { + // Sanity: confirm no errors were silently swallowed by checking the + // spec round-trips as fully valid with the crossRef arg intact. + expect(result.spec.contracts[0].args).toEqual([ + { kind: "crossRef", network: "mainnet", contract: "onlyEntry" }, + ]); + } + }); +}); + // --------------------------------------------------------------------------- // Upgradeable proxies (issue #155) // --------------------------------------------------------------------------- diff --git a/packages/core/test/testHelpers/fakeEip1193Provider.ts b/packages/core/test/testHelpers/fakeEip1193Provider.ts new file mode 100644 index 0000000..ca8b885 --- /dev/null +++ b/packages/core/test/testHelpers/fakeEip1193Provider.ts @@ -0,0 +1,162 @@ +/** + * Minimal in-memory EIP-1193 provider + artifact resolver, sufficient to run + * a REAL `deploy()` call (against the real Ignition engine) without a live + * chain — used to produce a genuine `journal.jsonl` on disk for tests that + * need to read it back (e.g. resolve/crossRef.ts's cross-network journal + * reads). This is a trimmed-down variant of the fake provider already used + * in test/deploy.test.ts (see that file for the fully-documented version + * with partial-deploy/interruption support) — this copy only supports + * straight-through successful deploys of 0-or-N-arg contracts, which is all + * that's needed to seed a "source network" journal for cross-chain-ref tests. + */ + +import type { ArtifactResolver, Artifact, EIP1193Provider } from "@nomicfoundation/ignition-core"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; + +/** A trivial constructor ABI fragment with `numInputs` address inputs. */ +function buildConstructorAbi(numInputs: number): object[] { + const inputs = Array.from({ length: numInputs }, (_, i) => ({ + name: `arg${i}`, + type: "address", + internalType: "address", + })); + return [{ type: "constructor", inputs, stateMutability: "nonpayable" }]; +} + +/** A fake ArtifactResolver backed by a name -> argCount map. */ +export function makeFakeArtifactResolver(argCounts: Record = {}): ArtifactResolver { + return { + async loadArtifact(contractName: string): Promise { + const numArgs = argCounts[contractName] ?? 0; + return { + contractName, + sourceName: `contracts/${contractName}.sol`, + bytecode: "0x60806040526000805534801561001457600080fd5b50610100806100246000396000f3fe", + abi: buildConstructorAbi(numArgs), + linkReferences: {}, + }; + }, + async getBuildInfo() { + return undefined; + }, + }; +} + +interface ProviderState { + sendTxCount: number; + blockNumber: number; + txReceipts: Map; + nonces: Map; +} + +function makeProviderState(): ProviderState { + return { sendTxCount: 0, blockNumber: 10, txReceipts: new Map(), nonces: new Map() }; +} + +/** Builds a healthy fake EIP-1193 provider sufficient for a basic-strategy deploy. */ +export function makeFakeProvider(): EIP1193Provider { + const state = makeProviderState(); + const CHAIN_ID = "0x7a69"; // 31337 + + return { + async request({ + method, + params, + }: { + method: string; + params?: readonly unknown[] | object; + }): Promise { + const p = Array.isArray(params) ? params : []; + + switch (method) { + case "hardhat_getAutomine": + throw new Error("not supported"); + case "web3_clientVersion": + throw new Error("not supported"); + case "eth_chainId": + return CHAIN_ID; + case "eth_accounts": + return ["0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"]; + case "eth_blockNumber": + return "0x" + state.blockNumber.toString(16); + case "eth_getBlockByNumber": { + state.blockNumber += 1; + return { + number: "0x" + state.blockNumber.toString(16), + hash: "0x" + state.blockNumber.toString(16).padStart(64, "0"), + }; + } + case "eth_getTransactionCount": { + const addr = (p[0] as string).toLowerCase(); + return "0x" + (state.nonces.get(addr) ?? 0).toString(16); + } + case "eth_gasPrice": + return "0x3b9aca00"; + case "eth_estimateGas": + return "0x30d40"; + case "eth_sendTransaction": { + state.sendTxCount += 1; + const txParams = p[0] as Record; + const from = ( + (txParams["from"] as string | undefined) ?? + "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266" + ).toLowerCase(); + const nonce = state.nonces.get(from) ?? 0; + state.nonces.set(from, nonce + 1); + const txHash = `0x${"ab".repeat(31)}${nonce.toString(16).padStart(2, "0")}`; + const contractAddress = `0x${(state.sendTxCount * 17).toString(16).padStart(40, "0")}`; + state.blockNumber += 1; + state.txReceipts.set(txHash, { blockNumber: state.blockNumber, contractAddress }); + return txHash; + } + case "eth_getTransactionByHash": { + const txHash = p[0] as string; + const receipt = state.txReceipts.get(txHash); + if (receipt === undefined) return null; + return { + hash: txHash, + blockHash: "0x" + receipt.blockNumber.toString(16).padStart(64, "0"), + blockNumber: "0x" + receipt.blockNumber.toString(16), + from: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + to: null, + input: "0x", + value: "0x0", + chainId: CHAIN_ID, + nonce: "0x0", + gasPrice: "0x3b9aca00", + }; + } + case "eth_getTransactionReceipt": { + const txHash = p[0] as string; + const receipt = state.txReceipts.get(txHash); + if (receipt === undefined) return null; + return { + blockHash: "0x" + receipt.blockNumber.toString(16).padStart(64, "0"), + blockNumber: "0x" + receipt.blockNumber.toString(16), + status: "0x1", + contractAddress: receipt.contractAddress, + logs: [], + }; + } + case "eth_getCode": + return "0x6001"; + case "eth_call": + return "0x"; + default: + throw new Error(`FakeProvider: unhandled method "${method}"`); + } + }, + }; +} + +export const FAKE_ACCOUNTS = ["0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"]; + +export function makeTmpDir(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), "redeploy-test-")); +} + +export function rmTmpDir(dir: string): void { + fs.rmSync(dir, { recursive: true, force: true }); +}