Skip to content
Open
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
73 changes: 69 additions & 4 deletions packages/core/src/deploy/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,16 @@
* 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. Thread `deploymentDir` through to Ignition's `deploy()` so the journal
* 4. 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
* persists across calls — do NOT reinvent journaling here.
* 5. Wrap the raw DeploymentResult with enough accessors to let callers
* 6. Wrap the raw DeploymentResult with enough accessors to let callers
* check success and read deployed addresses.
*/

Expand Down Expand Up @@ -51,11 +58,16 @@ import {
specHasResolverArgs,
} from "../resolve/resolveSpec.js";
import { ResolveError } from "../resolve/errors.js";
import type { PreflightPolicy } from "./preflight.js";
import { isEmptyPreflightPolicy, mergePreflightPolicies, runPreflight } from "./preflight.js";

// Re-export error types for public API surface
export type { DeployErrorCode } from "./errors.js";
export { DeployError } from "./errors.js";

// Re-export the PREFLIGHT phase's public API surface
export type { PreflightPolicy, PreflightFailure, PreflightCheck } from "./preflight.js";

// ---------------------------------------------------------------------------
// Public types
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -116,6 +128,19 @@ export interface DeployOptions {
* (resolvers are trusted, in-repo code — never loaded dynamically).
*/
resolvers?: ResolverRegistry;
/**
* Per-call PREFLIGHT policy override. The EFFECTIVE policy used by this
* `deploy()` call is a per-field merge of `spec.preflight` (the
* declarative base) and this value — fields set here win over the spec's,
* `undefined` fields fall back to the spec. This is how a caller such as
* `@redeploy/deploy-server` applies a per-network policy (e.g. a stricter
* `maxGasPriceWei` for mainnet) without editing the spec itself.
*
* If the effective (merged) policy has no fields set at all, the entire
* PREFLIGHT phase is skipped — zero extra RPC calls, no behavior change
* for callers who don't opt in. See deploy/preflight.ts.
*/
preflight?: PreflightPolicy;
}

/**
Expand Down Expand Up @@ -205,13 +230,25 @@ async function loadResolvedAddressesFromJournal(
* `deploymentDir` will deploy ONLY the contracts whose futures are not yet
* recorded as complete in the journal.
*
* PREFLIGHT: before any on-chain transaction is broadcast, the effective
* PREFLIGHT policy (a per-field merge of `spec.preflight` and
* `DeployOptions.preflight` — see deploy/preflight.ts) is checked against the
* live RPC and deployer account. This catches mistakes like pointing a
* mainnet deploy at the wrong chain, an underfunded deployer, or a gas-price
* spike — BEFORE any transaction is sent. Skipped entirely (zero extra RPC
* calls) when the effective policy has no fields set.
*
* @throws DeployError with code "INVALID_SPEC" if the spec fails validation.
* @throws DeployError with code "UNKNOWN_RESOLVER" if a `{ kind: "resolver" }`
* arg names a resolver absent from `DeployOptions.resolvers`.
* @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 "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
* is broadcast — check DeployError.preflightFailures for the full list of
* failed checks.
*
* On-chain execution errors (e.g. reverted transactions, gas errors) are NOT
* thrown — they are returned in `result.ignitionResult` with a non-SUCCESSFUL
Expand Down Expand Up @@ -296,7 +333,35 @@ export async function deploy(options: DeployOptions): Promise<DeployResult> {
);
}

// --- 4. Run Ignition deploy — idempotency/resume live here ------------------
// --- 4. 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
// effective policy is a per-field merge of the spec's declarative base
// (`spec.preflight`) and this call's override (`options.preflight`), with
// the override winning per field — see deploy/preflight.ts's
// mergePreflightPolicies. If the merged policy has NO fields set (the
// common case — no preflight configured anywhere), we skip runPreflight()
// entirely: zero extra RPC calls, identical behavior to before this
// feature existed.
const effectivePreflightPolicy = mergePreflightPolicies(
validateResult.spec.preflight,
options.preflight,
);
if (!isEmptyPreflightPolicy(effectivePreflightPolicy)) {
const preflightAccount = defaultSender ?? accounts[0];
const preflightFailures = await runPreflight(provider, effectivePreflightPolicy, preflightAccount);
if (preflightFailures.length > 0) {
throw new DeployError(
"PREFLIGHT_FAILED",
`Preflight checks failed with ${preflightFailures.length} failure(s): ${preflightFailures.map((f) => f.message).join("; ")}`,
undefined,
preflightFailures,
);
}
}

// --- 5. 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 @@ -314,7 +379,7 @@ export async function deploy(options: DeployOptions): Promise<DeployResult> {
defaultSender,
});

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

const deployedAddresses: Record<string, string> = {};
Expand Down
32 changes: 27 additions & 5 deletions packages/core/src/deploy/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
*/

import type { SpecError } from "../spec/validate.js";
import type { PreflightFailure } from "./preflight.js";

/** Discriminated error codes for DeployError. */
export type DeployErrorCode =
Expand All @@ -35,12 +36,22 @@ export type DeployErrorCode =
* coerced into the bigint shape ResolverContext.params requires. See
* resolve/resolveSpec.ts.
*/
| "RESOLVER_ERROR";
| "RESOLVER_ERROR"
/**
* The effective PREFLIGHT policy (per-field merge of
* `DeploymentSpec.preflight` and `DeployOptions.preflight`) failed one or
* more checks — wrong chain id, insufficient deployer balance, or gas
* price above the configured ceiling. Thrown BEFORE any on-chain
* transaction is broadcast. Check DeployError.preflightFailures for the
* full list of failed checks. See deploy/preflight.ts.
*/
| "PREFLIGHT_FAILED";

/**
* Thrown by deploy() when the DeploymentSpec is invalid or cannot be compiled.
* Does NOT represent on-chain execution failures — those are returned as a
* DeployResult with type !== SUCCESSFUL_DEPLOYMENT.
* Thrown by deploy() when the DeploymentSpec is invalid, cannot be compiled,
* or fails the PREFLIGHT phase. Does NOT represent on-chain execution
* failures — those are returned as a DeployResult with type !==
* SUCCESSFUL_DEPLOYMENT.
*/
export class DeployError extends Error {
readonly code: DeployErrorCode;
Expand All @@ -49,11 +60,22 @@ export class DeployError extends Error {
* Undefined for other error codes.
*/
readonly specErrors?: SpecError[];
/**
* The list of failed preflight checks when code === "PREFLIGHT_FAILED".
* Undefined for other error codes. See deploy/preflight.ts.
*/
readonly preflightFailures?: PreflightFailure[];

constructor(code: DeployErrorCode, message: string, specErrors?: SpecError[]) {
constructor(
code: DeployErrorCode,
message: string,
specErrors?: SpecError[],
preflightFailures?: PreflightFailure[],
) {
super(message);
this.name = "DeployError";
this.code = code;
this.specErrors = specErrors;
this.preflightFailures = preflightFailures;
}
}
180 changes: 180 additions & 0 deletions packages/core/src/deploy/preflight.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
/**
* PREFLIGHT phase — declarative, pre-broadcast safety checks for deploy().
*
* DESIGN
* ======
*
* A `PreflightPolicy` declares up to three optional checks:
* - `expectedChainId` — the RPC's `eth_chainId` must match exactly.
* - `minBalanceWei` — the deployer account's balance must be >= this.
* - `maxGasPriceWei` — the network's current gas price must be <= this.
*
* Every field is OPTIONAL. `runPreflight` performs ONLY the checks whose
* corresponding policy field is set — this is what lets deploy() skip the
* whole phase (zero extra RPC calls) when no policy is configured anywhere
* (see deploy/deploy.ts's step 3.5 for the merge + skip logic).
*
* These checks run strictly BEFORE any transaction is broadcast (deploy.ts
* runs this after spec validation/compile but before ignitionDeploy()), so a
* misconfigured mainnet deploy (wrong chain, near-empty deployer, gas spike)
* is caught pre-broadcast with a typed `DeployError("PREFLIGHT_FAILED", ...)`
* — never after a transaction has already been sent.
*
* `runPreflight` itself never throws for a failing check — it COLLECTS every
* failure and returns them all (mirroring spec/validate.ts's
* collect-don't-fail-fast style). deploy() is responsible for turning a
* non-empty failure list into the thrown DeployError.
*/

import type { EIP1193Provider } from "@nomicfoundation/ignition-core";
import type { PreflightPolicy } from "../spec/types.js";

// ---------------------------------------------------------------------------
// Public types
// ---------------------------------------------------------------------------

// PreflightPolicy is canonically declared in spec/types.ts (a spec-level
// shape shared with the schema/validate layer) and re-exported here as the
// public entry point for the deploy-time preflight API. Every field is
// optional and independent — set only the checks you want enforced. This
// shape is used both as the spec-declared base policy
// (`DeploymentSpec.preflight`) and as the per-call override
// (`DeployOptions.preflight`); the effective policy for a given `deploy()`
// call is a per-field merge of the two (see `mergePreflightPolicies` below
// and deploy/deploy.ts).
export type { PreflightPolicy };

/** Which of the three PreflightPolicy checks failed. */
export type PreflightCheck = "chainId" | "balance" | "gasPrice";

/**
* A single failed preflight check, as returned by `runPreflight`.
*/
export interface PreflightFailure {
/** Which check failed. */
readonly check: PreflightCheck;
/** The expected value, stringified for display (e.g. a chain id or wei amount). */
readonly expected: string;
/** The actual value observed from the RPC, stringified for display. */
readonly actual: string;
/** Human-readable description of the failure. */
readonly message: string;
}

// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------

/**
* Parse a hex-quantity JSON-RPC result (e.g. `"0x7a69"`) into a bigint.
* Ignition's EIP1193Provider contract guarantees these RPC methods return
* hex-quantity strings, so this does not need to handle decimal input.
*/
function hexToBigInt(hex: unknown): bigint {
if (typeof hex !== "string") {
throw new Error(`Expected a hex string RPC result, got ${typeof hex}`);
}
return BigInt(hex);
}

// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------

/**
* Run the preflight checks declared by `policy` against `provider`, for the
* deployer account `account`. Performs ONLY the checks whose corresponding
* policy field is set — a `policy` with no fields set makes ZERO RPC calls.
*
* Never sends a transaction. Never logs secrets (the account address is not
* a secret; no private key ever passes through this module).
*
* @returns The list of failures, in check order (chainId, balance, gasPrice).
* An empty array means every configured check passed.
*/
export async function runPreflight(
provider: EIP1193Provider,
policy: PreflightPolicy,
account: string,
): Promise<PreflightFailure[]> {
const failures: PreflightFailure[] = [];

if (policy.expectedChainId !== undefined) {
const rawChainId = await provider.request({ method: "eth_chainId" });
const actualChainId = hexToBigInt(rawChainId);
const expectedChainId = BigInt(policy.expectedChainId);
if (actualChainId !== expectedChainId) {
failures.push({
check: "chainId",
expected: expectedChainId.toString(),
actual: actualChainId.toString(),
message: `Expected chain id ${expectedChainId.toString()} but the RPC reports chain id ${actualChainId.toString()} — refusing to deploy to the wrong network`,
});
}
}

if (policy.minBalanceWei !== undefined) {
const rawBalance = await provider.request({
method: "eth_getBalance",
params: [account, "latest"],
});
const actualBalance = hexToBigInt(rawBalance);
const minBalance = BigInt(policy.minBalanceWei);
if (actualBalance < minBalance) {
failures.push({
check: "balance",
expected: minBalance.toString(),
actual: actualBalance.toString(),
message: `Deployer account ${account} has balance ${actualBalance.toString()} wei, below the required minimum of ${minBalance.toString()} wei`,
});
}
}

if (policy.maxGasPriceWei !== undefined) {
const rawGasPrice = await provider.request({ method: "eth_gasPrice" });
const actualGasPrice = hexToBigInt(rawGasPrice);
const maxGasPrice = BigInt(policy.maxGasPriceWei);
if (actualGasPrice > maxGasPrice) {
failures.push({
check: "gasPrice",
expected: maxGasPrice.toString(),
actual: actualGasPrice.toString(),
message: `Current network gas price ${actualGasPrice.toString()} wei exceeds the configured ceiling of ${maxGasPrice.toString()} wei`,
});
}
}

return failures;
}

/**
* Per-field merge of two PreflightPolicy objects: `override` wins per field
* over `base`, `undefined` fields in `override` fall back to `base`.
*
* Used by deploy() to combine `DeploymentSpec.preflight` (the declarative
* base policy) with `DeployOptions.preflight` (the per-call override, e.g.
* a deploy-server per-network policy) into the single effective policy.
*/
export function mergePreflightPolicies(
base: PreflightPolicy | undefined,
override: PreflightPolicy | undefined,
): PreflightPolicy {
return {
expectedChainId: override?.expectedChainId ?? base?.expectedChainId,
minBalanceWei: override?.minBalanceWei ?? base?.minBalanceWei,
maxGasPriceWei: override?.maxGasPriceWei ?? base?.maxGasPriceWei,
};
}

/**
* True iff `policy` has no fields set — i.e. the effective preflight policy
* is a no-op. deploy() uses this to skip runPreflight() entirely (zero extra
* RPC calls) for the common case of no preflight configuration anywhere.
*/
export function isEmptyPreflightPolicy(policy: PreflightPolicy): boolean {
return (
policy.expectedChainId === undefined &&
policy.minBalanceWei === undefined &&
policy.maxGasPriceWei === undefined
);
}
9 changes: 9 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export type {
DeploymentSpec,
UpgradeableConfig,
UpgradeableInitializer,
PreflightPolicy,
} from "./spec/types.js";

export {
Expand All @@ -20,6 +21,7 @@ export {
deploymentSpecSchema,
resolverArgSchema,
upgradeableConfigSchema,
preflightPolicySchema,
} from "./spec/schema.js";

export type { SpecError, SpecErrorCode, ValidateResult } from "./spec/validate.js";
Expand All @@ -37,6 +39,13 @@ export { deploy } from "./deploy/deploy.js";
export type { DeployErrorCode } from "./deploy/errors.js";
export { DeployError } from "./deploy/errors.js";

// PREFLIGHT phase — declarative pre-broadcast safety checks (chain id,
// deployer balance, gas price ceiling), run BEFORE any transaction is
// broadcast. See deploy/preflight.ts for the full design; DeployOptions.preflight
// and DeploymentSpec.preflight (spec/types.js) feed the effective merged policy.
export type { PreflightFailure, PreflightCheck } from "./deploy/preflight.js";
export { runPreflight } from "./deploy/preflight.js";

// Upgradeable proxy implementation-history data model — types + pure helpers
// only (v1 scope; see deploy/proxyHistory.ts's module doc). NOT wired into
// any reader/verify package — that is explicit follow-up work.
Expand Down
Loading
Loading