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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion packages/core/src/compile/compile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? []) {
Expand Down Expand Up @@ -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);
}
Expand Down
10 changes: 10 additions & 0 deletions packages/core/src/compile/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
143 changes: 81 additions & 62 deletions packages/core/src/deploy/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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";

Expand Down Expand Up @@ -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<string, CrossNetworkJournal>;
/**
* Per-call PREFLIGHT policy override. The EFFECTIVE policy used by this
* `deploy()` call is a per-field merge of `spec.preflight` (the
Expand Down Expand Up @@ -172,49 +199,6 @@ export interface DeployResult {
readonly ignitionResult: DeploymentResult;
}

// ---------------------------------------------------------------------------
// Internal helpers — resolver pre-resolution pass plumbing
// ---------------------------------------------------------------------------

/**
* Strips Ignition's "<moduleId>#" 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<Record<string, string>> {
const journalPath = join(deploymentDir, "journal.jsonl");
if (!existsSync(journalPath)) {
return {};
}
const statusResult = await ignitionStatus(deploymentDir);
const resolvedAddresses: Record<string, string> = {};
for (const [key, contract] of Object.entries(statusResult.contracts)) {
resolvedAddresses[stripModulePrefix(key, moduleId)] = contract.address;
}
return resolvedAddresses;
}

// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -276,7 +264,40 @@ export async function deploy(options: DeployOptions): Promise<DeployResult> {
);
}

// --- 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
Expand All @@ -286,11 +307,9 @@ export async function deploy(options: DeployOptions): Promise<DeployResult> {
// 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,
);
Expand Down Expand Up @@ -320,7 +339,7 @@ export async function deploy(options: DeployOptions): Promise<DeployResult> {
}
}

// --- 3. Compile spec into an Ignition module --------------------------------
// --- 4. Compile spec into an Ignition module --------------------------------
let ignitionModule;
try {
ignitionModule = compileSpec(specForCompile, { moduleId });
Expand All @@ -333,7 +352,7 @@ export async function deploy(options: DeployOptions): Promise<DeployResult> {
);
}

// --- 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
Expand Down Expand Up @@ -361,7 +380,7 @@ export async function deploy(options: DeployOptions): Promise<DeployResult> {
}
}

// --- 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
// `<deploymentDir>/journal.jsonl`. Futures already recorded as complete are
Expand All @@ -379,7 +398,7 @@ export async function deploy(options: DeployOptions): Promise<DeployResult> {
defaultSender,
});

// --- 6. Build our result wrapper -------------------------------------------
// --- 7. Build our result wrapper -------------------------------------------
const success = ignitionResult.type === DeploymentResultType.SUCCESSFUL_DEPLOYMENT;

const deployedAddresses: Record<string, string> = {};
Expand Down
11 changes: 11 additions & 0 deletions packages/core/src/deploy/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 18 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export type {
ParamArg,
ExprArg,
ResolverArg,
CrossRefArg,
LiteralScalar,
LiteralValue,
ContractArg,
Expand All @@ -20,6 +21,7 @@ export {
contractEntrySchema,
deploymentSpecSchema,
resolverArgSchema,
crossRefArgSchema,
upgradeableConfigSchema,
preflightPolicySchema,
} from "./spec/schema.js";
Expand Down Expand Up @@ -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";
Loading
Loading