From 984a224e4352aebb2eceafe5235409757680b3a5 Mon Sep 17 00:00:00 2001 From: Roberto Cano <3525807+robercano@users.noreply.github.com> Date: Thu, 30 Jul 2026 11:34:28 +0200 Subject: [PATCH 1/5] =?UTF-8?q?feat(core):=20add=20CrossRefArg=20spec=20ty?= =?UTF-8?q?pe=20=E2=80=94=20cross-chain=20dependency=20refs,=20part=201=20?= =?UTF-8?q?(issue=20#159)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `{ kind: "crossRef", network, contract }` to the ContractArg union: a reference to a contract deployed on a DIFFERENT network, resolved to a literal address at deploy time (part 2 of this feature). Shape-validated by zod (non-empty network/contract) and deliberately excluded from validateSpec's ref/cycle checks — the target lives outside this spec's own id space, so MISSING_REF/SELF_REFERENCE/CYCLE checks would be meaningless. Co-Authored-By: Claude Sonnet 5 --- packages/core/src/spec/index.ts | 2 + packages/core/src/spec/schema.ts | 16 +++ packages/core/src/spec/types.ts | 62 ++++++++- packages/core/src/spec/validate.ts | 12 ++ packages/core/test/spec.test.ts | 209 +++++++++++++++++++++++++++++ 5 files changed, 298 insertions(+), 3 deletions(-) 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/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) // --------------------------------------------------------------------------- From 6f48a1f2aa82a605cf65b03100ae71cd8e83adfb Mon Sep 17 00:00:00 2001 From: Roberto Cano <3525807+robercano@users.noreply.github.com> Date: Thu, 30 Jul 2026 11:34:33 +0200 Subject: [PATCH 2/5] feat(core): reject unresolved crossRef args at compile time (issue #159) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit compileSpec() must never see a crossRef arg — they are pre-resolved to literals before compilation (see the next commit). Mirrors the existing UNRESOLVED_RESOLVER_ARG guard: mapContractArg() throws CompileError("UNRESOLVED_CROSS_REF_ARG") for a direct caller who bypasses the pre-resolution pass. buildCreationOrder() already excludes crossRef from build-order edges (only "ref"/"expr" are handled there) — added tests proving no phantom dependency is introduced. Co-Authored-By: Claude Sonnet 5 --- packages/core/src/compile/compile.ts | 24 ++++++++- packages/core/src/compile/errors.ts | 10 ++++ packages/core/test/compile.test.ts | 76 ++++++++++++++++++++++++++++ 3 files changed, 109 insertions(+), 1 deletion(-) 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/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) // --------------------------------------------------------------------------- From fcdaacee87a318f16127b9aba18ab8307e5af744 Mon Sep 17 00:00:00 2001 From: Roberto Cano <3525807+robercano@users.noreply.github.com> Date: Thu, 30 Jul 2026 11:34:39 +0200 Subject: [PATCH 3/5] =?UTF-8?q?feat(core):=20add=20resolveCrossRefArgs=20?= =?UTF-8?q?=E2=80=94=20cross-network=20journal=20resolution=20(issue=20#15?= =?UTF-8?q?9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extracts deploy.ts's private journal-address-read helper into a reusable resolve/journal.ts (loadAddressesFromJournal) — a pure extraction, same Ignition status() + stripModulePrefix behavior. Adds resolve/crossRef.ts's resolveCrossRefArgs(), which walks a spec and replaces every `{ kind: "crossRef" }` arg (constructor args, upgradeable.initializer.args, upgradeable.proxyAdminOwner) with a literal address read from the target network's journal (via an injected ResolveCrossRefOptions.journals map — core has no network registry of its own). Throws CrossRefError with CROSS_REF_UNKNOWN_NETWORK or CROSS_REF_NOT_DEPLOYED on failure. Tests build real journals via the in-memory fake EIP-1193 provider (no anvil needed) to exercise the actual Ignition status() read path. Co-Authored-By: Claude Sonnet 5 --- packages/core/src/resolve/crossRef.ts | 249 ++++++++ packages/core/src/resolve/crossRefErrors.ts | 48 ++ packages/core/src/resolve/journal.ts | 67 ++ packages/core/test/crossRef.test.ts | 576 ++++++++++++++++++ .../test/testHelpers/fakeEip1193Provider.ts | 162 +++++ 5 files changed, 1102 insertions(+) create mode 100644 packages/core/src/resolve/crossRef.ts create mode 100644 packages/core/src/resolve/crossRefErrors.ts create mode 100644 packages/core/src/resolve/journal.ts create mode 100644 packages/core/test/crossRef.test.ts create mode 100644 packages/core/test/testHelpers/fakeEip1193Provider.ts diff --git a/packages/core/src/resolve/crossRef.ts b/packages/core/src/resolve/crossRef.ts new file mode 100644 index 0000000..8b93362 --- /dev/null +++ b/packages/core/src/resolve/crossRef.ts @@ -0,0 +1,249 @@ +/** + * 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 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). 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); + } + + const address = addresses[contract]; + if (address === undefined) { + 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`, + ); + } + 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..276d762 --- /dev/null +++ b/packages/core/src/resolve/journal.ts @@ -0,0 +1,67 @@ +/** + * 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); + const addresses: Record = {}; + for (const [key, contract] of Object.entries(statusResult.contracts)) { + addresses[stripModulePrefix(key, moduleId)] = contract.address; + } + return addresses; +} diff --git a/packages/core/test/crossRef.test.ts b/packages/core/test/crossRef.test.ts new file mode 100644 index 0000000..b7eed76 --- /dev/null +++ b/packages/core/test/crossRef.test.ts @@ -0,0 +1,576 @@ +/** + * 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 } 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"); + } + }, + ); +}); + +// --------------------------------------------------------------------------- +// 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/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 }); +} From 1ab76e1c0cf2cb05d02524fa38cdd0804ef8fc79 Mon Sep 17 00:00:00 2001 From: Roberto Cano <3525807+robercano@users.noreply.github.com> Date: Thu, 30 Jul 2026 11:34:47 +0200 Subject: [PATCH 4/5] feat(core): wire crossRef resolution into deploy()/simulate() (issue #159) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit deploy() gains DeployOptions.crossNetworkJournals: an injected map of network name -> journal location. When the spec has crossRef args, deploy() resolves them (via resolveCrossRefArgs) BEFORE the existing resolver pre-resolution pass — both are pre-compile literal substitutions, ordered crossRef -> resolver. CrossRefError is caught and re-wrapped as DeployError("CROSS_REF_ERROR"), mirroring the ResolveError wrapping already in place. Fully backward compatible: specs with no crossRef args touch zero extra I/O. simulate()'s PlannedStep gains a distinct `crossRefs` field (network + contract pairs) — kept separate from `dependsOn` since a crossRef targets a different network's deployment, never a same-run build/deploy-order dependency. Studio rendering of cross-network edges is a follow-up. Exports CrossRefArg, crossRefArgSchema, CrossNetworkJournal, ResolveCrossRefOptions, resolveCrossRefArgs, specHasCrossRefArgs, CrossRefError, and CrossRefErrorCode from the package root. Co-Authored-By: Claude Sonnet 5 --- packages/core/src/deploy/deploy.ts | 143 ++++++++------- packages/core/src/deploy/errors.ts | 11 ++ packages/core/src/index.ts | 18 ++ packages/core/src/simulate/simulate.ts | 31 ++++ packages/core/test/deploy.test.ts | 229 +++++++++++++++++++++++++ packages/core/test/simulate.test.ts | 122 +++++++++++++ 6 files changed, 492 insertions(+), 62 deletions(-) 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/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/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) // --------------------------------------------------------------------------- From 0e9bfbc676f1a64763c1fcb815329499e441e87a Mon Sep 17 00:00:00 2001 From: Roberto Cano <3525807+robercano@users.noreply.github.com> Date: Thu, 30 Jul 2026 11:56:41 +0200 Subject: [PATCH 5/5] fix(core): guard crossRef address lookup against prototype pollution (issue #159) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirror the existing Object.hasOwn guard on options.journals[network] with the same guard on the loaded addresses[contract] map, so a contract id equal to a prototype key (e.g. "__proto__", "constructor", "toString") throws the normal CrossRefError("CROSS_REF_NOT_DEPLOYED") instead of silently resolving to an inherited Object.prototype member. Also builds the address map in loadAddressesFromJournal with Object.create(null) for defense in depth. Additionally validate the resolved value with viem's isAddress before substituting it as a { kind: "literal" } constructor arg, so a journal that somehow yields a non-address string can never be injected into on-chain calldata — refusing with the same CROSS_REF_NOT_DEPLOYED error instead. --- packages/core/src/resolve/crossRef.ts | 30 +++++++++- packages/core/src/resolve/journal.ts | 7 ++- packages/core/test/crossRef.test.ts | 55 ++++++++++++++++++- .../core/test/crossRefAddressGuard.test.ts | 51 +++++++++++++++++ 4 files changed, 138 insertions(+), 5 deletions(-) create mode 100644 packages/core/test/crossRefAddressGuard.test.ts diff --git a/packages/core/src/resolve/crossRef.ts b/packages/core/src/resolve/crossRef.ts index 8b93362..995cfcc 100644 --- a/packages/core/src/resolve/crossRef.ts +++ b/packages/core/src/resolve/crossRef.ts @@ -37,6 +37,7 @@ * 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"; @@ -113,7 +114,12 @@ function entryHasCrossRefArg(entry: ContractEntry): boolean { * @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). The referenced network's deployment must be COMPLETE first + * 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( @@ -153,8 +159,12 @@ export async function resolveCrossRefArgs( journalCache.set(network, addresses); } - const address = addresses[contract]; - if (address === undefined) { + // 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 ` + @@ -162,6 +172,20 @@ export async function resolveCrossRefArgs( `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; } diff --git a/packages/core/src/resolve/journal.ts b/packages/core/src/resolve/journal.ts index 276d762..5b652f9 100644 --- a/packages/core/src/resolve/journal.ts +++ b/packages/core/src/resolve/journal.ts @@ -59,7 +59,12 @@ export async function loadAddressesFromJournal( return {}; } const statusResult = await ignitionStatus(deploymentDir); - const addresses: Record = {}; + // 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; } diff --git a/packages/core/test/crossRef.test.ts b/packages/core/test/crossRef.test.ts index b7eed76..2711aca 100644 --- a/packages/core/test/crossRef.test.ts +++ b/packages/core/test/crossRef.test.ts @@ -13,7 +13,7 @@ * real Ignition `status()` read path, not a mocked one. */ -import { describe, it, expect, afterEach } from "vitest"; +import { describe, it, expect, afterEach, beforeAll, afterAll } from "vitest"; import { resolveCrossRefArgs, specHasCrossRefArgs, @@ -512,6 +512,59 @@ describe("resolveCrossRefArgs — prototype-pollution guard", () => { ); }); +// --------------------------------------------------------------------------- +// 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 // --------------------------------------------------------------------------- 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"'); + } + }); +});