From 1fa55c22abcb58322b3d65ec01bed550d4d609ed Mon Sep 17 00:00:00 2001 From: Roberto Cano <3525807+robercano@users.noreply.github.com> Date: Wed, 29 Jul 2026 09:22:16 +0200 Subject: [PATCH] feat(core): add declarative PREFLIGHT phase to deploy() (issue #156) Adds an opt-in pre-broadcast safety phase: expectedChainId, minBalanceWei, and maxGasPriceWei checks run against the live RPC/account BEFORE any transaction is sent, aborting with a typed DeployError("PREFLIGHT_FAILED") on failure. Effective policy is a per-field merge of DeploymentSpec.preflight and DeployOptions.preflight; the phase is fully skipped (zero extra RPC calls) when no policy is configured, keeping the happy path unaffected. Co-Authored-By: Claude Sonnet 5 --- packages/core/src/deploy/deploy.ts | 73 +++++++- packages/core/src/deploy/errors.ts | 32 +++- packages/core/src/deploy/preflight.ts | 180 +++++++++++++++++++ packages/core/src/index.ts | 9 + packages/core/src/spec/index.ts | 2 + packages/core/src/spec/schema.ts | 42 +++++ packages/core/src/spec/types.ts | 46 +++++ packages/core/test/deploy.test.ts | 153 ++++++++++++++++ packages/core/test/preflight.test.ts | 247 ++++++++++++++++++++++++++ packages/core/test/spec.test.ts | 163 +++++++++++++++++ 10 files changed, 938 insertions(+), 9 deletions(-) create mode 100644 packages/core/src/deploy/preflight.ts create mode 100644 packages/core/test/preflight.test.ts diff --git a/packages/core/src/deploy/deploy.ts b/packages/core/src/deploy/deploy.ts index 5688316..1ade843 100644 --- a/packages/core/src/deploy/deploy.ts +++ b/packages/core/src/deploy/deploy.ts @@ -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. */ @@ -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 // --------------------------------------------------------------------------- @@ -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; } /** @@ -205,6 +230,14 @@ 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`. @@ -212,6 +245,10 @@ async function loadResolvedAddressesFromJournal( * 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 @@ -296,7 +333,35 @@ export async function deploy(options: DeployOptions): Promise { ); } - // --- 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 // `/journal.jsonl`. Futures already recorded as complete are @@ -314,7 +379,7 @@ export async function deploy(options: DeployOptions): Promise { defaultSender, }); - // --- 5. Build our result wrapper ------------------------------------------- + // --- 6. 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 425da2a..09ca773 100644 --- a/packages/core/src/deploy/errors.ts +++ b/packages/core/src/deploy/errors.ts @@ -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 = @@ -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; @@ -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; } } diff --git a/packages/core/src/deploy/preflight.ts b/packages/core/src/deploy/preflight.ts new file mode 100644 index 0000000..00641fd --- /dev/null +++ b/packages/core/src/deploy/preflight.ts @@ -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 { + 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 + ); +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 6778f01..6a9fbcf 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -12,6 +12,7 @@ export type { DeploymentSpec, UpgradeableConfig, UpgradeableInitializer, + PreflightPolicy, } from "./spec/types.js"; export { @@ -20,6 +21,7 @@ export { deploymentSpecSchema, resolverArgSchema, upgradeableConfigSchema, + preflightPolicySchema, } from "./spec/schema.js"; export type { SpecError, SpecErrorCode, ValidateResult } from "./spec/validate.js"; @@ -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. diff --git a/packages/core/src/spec/index.ts b/packages/core/src/spec/index.ts index 8a6d464..181a63e 100644 --- a/packages/core/src/spec/index.ts +++ b/packages/core/src/spec/index.ts @@ -14,6 +14,7 @@ export type { ContractArg, ContractEntry, DeploymentSpec, + PreflightPolicy, } from "./types.js"; export { @@ -21,6 +22,7 @@ export { contractEntrySchema, deploymentSpecSchema, resolverArgSchema, + preflightPolicySchema, } from "./schema.js"; export type { SpecError, SpecErrorCode, ValidateResult } from "./validate.js"; diff --git a/packages/core/src/spec/schema.ts b/packages/core/src/spec/schema.ts index 96e748a..6e27487 100644 --- a/packages/core/src/spec/schema.ts +++ b/packages/core/src/spec/schema.ts @@ -11,6 +11,7 @@ import type { ContractEntry, DeploymentSpec, LiteralValue, + PreflightPolicy, UpgradeableConfig, UpgradeableInitializer, } from "./types.js"; @@ -201,6 +202,46 @@ export const contractEntrySchema: z.ZodType = z.object({ upgradeable: upgradeableConfigSchema.optional(), }); +// --------------------------------------------------------------------------- +// PREFLIGHT policy — pre-broadcast safety checks (see deploy/preflight.ts) +// --------------------------------------------------------------------------- + +/** + * Matches a non-negative decimal (base-10) integer string with no leading + * zeros (other than the literal "0" itself), e.g. "0", "42", "1000000000000000000". + * Used for `minBalanceWei` / `maxGasPriceWei`, which are bigint-as-string + * encoded wei amounts (see `PreflightPolicy`'s docs in spec/types.ts). + */ +export const DECIMAL_BIGINT_RE = /^(0|[1-9][0-9]*)$/; + +/** + * `{ expectedChainId?: , minBalanceWei?: , + * maxGasPriceWei?: }` + * + * Shape validation only. All three fields are optional and independent — see + * `PreflightPolicy`'s docs in spec/types.ts and `runPreflight`'s check + * semantics in deploy/preflight.ts. + */ +export const preflightPolicySchema: z.ZodType = z.object({ + expectedChainId: z + .number() + .int({ message: "preflight.expectedChainId must be a positive integer" }) + .positive({ message: "preflight.expectedChainId must be a positive integer" }) + .optional(), + minBalanceWei: z + .string() + .regex(DECIMAL_BIGINT_RE, { + message: "preflight.minBalanceWei must be a non-negative decimal integer string", + }) + .optional(), + maxGasPriceWei: z + .string() + .regex(DECIMAL_BIGINT_RE, { + message: "preflight.maxGasPriceWei must be a non-negative decimal integer string", + }) + .optional(), +}); + // --------------------------------------------------------------------------- // Top-level deployment spec // --------------------------------------------------------------------------- @@ -209,4 +250,5 @@ export const deploymentSpecSchema: z.ZodType = z.object({ version: z.literal(1), contracts: z.array(contractEntrySchema), parameters: z.record(z.string(), literalValueSchema).optional(), + preflight: preflightPolicySchema.optional(), }); diff --git a/packages/core/src/spec/types.ts b/packages/core/src/spec/types.ts index 5051cd5..f6811f4 100644 --- a/packages/core/src/spec/types.ts +++ b/packages/core/src/spec/types.ts @@ -288,4 +288,50 @@ export interface DeploymentSpec { * a different spec — see ParamArg's docs for the full resolution story. */ readonly parameters?: Record; + /** + * Declarative PREFLIGHT policy: pre-broadcast safety checks that run + * BEFORE any transaction is sent (see `deploy/preflight.ts` and + * `deploy/deploy.ts`'s phase ordering). Optional — specs without it are + * unchanged and fully backward compatible (no preflight checks run, zero + * extra RPC calls). + * + * This is the DECLARATIVE base policy, checked in against the spec. The + * EFFECTIVE policy used by a given `deploy()` call is a per-field merge of + * this value and `DeployOptions.preflight` (the per-call override wins per + * field) — see `deploy/preflight.ts`'s `mergePreflightPolicies`. This lets + * a spec declare a sane default (e.g. `expectedChainId` for the network it + * targets) while callers such as `@redeploy/deploy-server` still apply + * per-network overrides (e.g. a stricter `maxGasPriceWei`) without editing + * the spec. + */ + readonly preflight?: PreflightPolicy; +} + +/** + * A declarative pre-broadcast safety policy: up to three independent, + * optional checks (`expectedChainId`, `minBalanceWei`, `maxGasPriceWei`) run + * BEFORE any transaction is broadcast. Declared here (a spec-level shape used + * by both the schema/validate layer and `DeploymentSpec.preflight`) and + * re-exported as the canonical public type from `deploy/preflight.ts`, which + * owns the actual `runPreflight` check logic and the merge-with-DeployOptions + * semantics — see that module's docs for the full design. + */ +export interface PreflightPolicy { + /** + * The chain id the RPC endpoint is expected to report via `eth_chainId`. + * Must be a positive integer. A mismatch is a `"chainId"` failure — the + * classic "pointed the deploy at the wrong network" mistake. + */ + readonly expectedChainId?: number; + /** + * Minimum required balance (in wei) for the deployer account, as a decimal + * bigint-encoded string (e.g. `"1000000000000000000"` for 1 ETH). The + * deployer account is `DeployOptions.defaultSender ?? accounts[0]`. + */ + readonly minBalanceWei?: string; + /** + * Maximum acceptable current network gas price (in wei), as a decimal + * bigint-encoded string. Guards against deploying during a gas spike. + */ + readonly maxGasPriceWei?: string; } diff --git a/packages/core/test/deploy.test.ts b/packages/core/test/deploy.test.ts index 0406072..ff3bb17 100644 --- a/packages/core/test/deploy.test.ts +++ b/packages/core/test/deploy.test.ts @@ -1584,3 +1584,156 @@ describe("deploy() — ResolverArg resolution end-to-end", () => { expect(result.success).toBe(true); }, 30_000); }); + +// --------------------------------------------------------------------------- +// PREFLIGHT phase — declarative pre-broadcast safety checks (issue #156) +// --------------------------------------------------------------------------- +// +// deploy.ts's `makeFakeProvider` does not implement `eth_getBalance` (the +// normal deploy flow never calls it), which makes it a perfect discriminator +// for the "zero extra RPC calls when no preflight is configured" contract: if +// deploy() ever called eth_getBalance without a `minBalanceWei` policy, these +// tests would fail with "unhandled method" instead of silently passing. + +describe("deploy() — PREFLIGHT phase", () => { + let tmpDir: string; + afterEach(() => { + if (tmpDir) rmTmpDir(tmpDir); + }); + + it("throws DeployError(PREFLIGHT_FAILED) with populated preflightFailures for a wrong-chainId policy, before any tx is sent", async () => { + tmpDir = makeTmpDir(); + const state = makeProviderState(); + + // makeFakeProvider's eth_chainId always returns "0x7a69" (31337) — declare + // a policy that expects a different chain to force a mismatch. + await expect( + deploy({ + spec: { version: 1, contracts: [{ id: "reg", contract: "Registry" }] }, + provider: makeFakeProvider(state), + accounts: ACCOUNTS, + deploymentDir: tmpDir, + artifactResolver: makeFakeArtifactResolver({ Registry: 0 }), + preflight: { expectedChainId: 1 }, // mainnet — provider reports 31337 + }), + ).rejects.toThrow(DeployError); + + // No transaction was ever attempted — the failure happens strictly + // before ignitionDeploy() is called. + expect(state.sendTxCount).toBe(0); + + // Preflight fails BEFORE ignitionDeploy() ever runs, so no journal was + // written — safe to re-run against the same deploymentDir. + try { + await deploy({ + spec: { version: 1, contracts: [{ id: "reg", contract: "Registry" }] }, + provider: makeFakeProvider(makeProviderState()), + accounts: ACCOUNTS, + deploymentDir: tmpDir, + artifactResolver: makeFakeArtifactResolver({ Registry: 0 }), + preflight: { expectedChainId: 1 }, + }); + expect.fail("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(DeployError); + const deployErr = err as DeployError; + expect(deployErr.code).toBe("PREFLIGHT_FAILED"); + expect(deployErr.preflightFailures).toBeDefined(); + expect(deployErr.preflightFailures).toHaveLength(1); + expect(deployErr.preflightFailures![0]).toMatchObject({ + check: "chainId", + expected: "1", + actual: "31337", + }); + // specErrors is only populated for INVALID_SPEC — must be undefined here. + expect(deployErr.specErrors).toBeUndefined(); + } + }, 30_000); + + it("behaves EXACTLY as before (no extra RPC calls, deploy succeeds) for a spec with no preflight configured", async () => { + tmpDir = makeTmpDir(); + const state = makeProviderState(); + + // Wrap the fake provider so any eth_getBalance call (which the normal + // deploy flow never makes) throws — proving the preflight phase truly + // makes ZERO extra RPC calls when no policy is configured anywhere. + const guardedProvider: EIP1193Provider = { + async request(args: { method: string; params?: readonly unknown[] | object }): Promise { + if (args.method === "eth_getBalance") { + throw new Error("PREFLIGHT REGRESSION: eth_getBalance called with no minBalanceWei policy set"); + } + return makeFakeProvider(state).request(args); + }, + }; + + const result = await deploy({ + spec: { version: 1, contracts: [{ id: "reg", contract: "Registry" }] }, + provider: guardedProvider, + accounts: ACCOUNTS, + deploymentDir: tmpDir, + artifactResolver: makeFakeArtifactResolver({ Registry: 0 }), + // no `preflight` option, no `spec.preflight` — fully backward compatible + }); + + expect(result.success).toBe(true); + expect(state.sendTxCount).toBe(1); + }, 30_000); + + it("merges spec.preflight (declarative base) with DeployOptions.preflight (per-call override), per field", async () => { + tmpDir = makeTmpDir(); + const state = makeProviderState(); + + // spec declares expectedChainId (satisfied by the fake provider's 31337) + // AND a minBalanceWei the account can never satisfy (fake provider has no + // eth_getBalance handler, so route it through a stub that reports 0). + const balanceStubProvider: EIP1193Provider = { + async request(args: { method: string; params?: readonly unknown[] | object }): Promise { + if (args.method === "eth_getBalance") { + return "0x0"; + } + return makeFakeProvider(state).request(args); + }, + }; + + await expect( + deploy({ + spec: { + version: 1, + contracts: [{ id: "reg", contract: "Registry" }], + preflight: { expectedChainId: 31337 }, // satisfied — no failure from this field + }, + provider: balanceStubProvider, + accounts: ACCOUNTS, + deploymentDir: tmpDir, + artifactResolver: makeFakeArtifactResolver({ Registry: 0 }), + // per-call override ADDS a balance check the spec didn't declare + preflight: { minBalanceWei: "1000000000000000000" }, + }), + ).rejects.toThrow(DeployError); + + expect(state.sendTxCount).toBe(0); + + try { + await deploy({ + spec: { + version: 1, + contracts: [{ id: "reg", contract: "Registry" }], + preflight: { expectedChainId: 31337 }, + }, + provider: balanceStubProvider, + accounts: ACCOUNTS, + deploymentDir: tmpDir, + artifactResolver: makeFakeArtifactResolver({ Registry: 0 }), + preflight: { minBalanceWei: "1000000000000000000" }, + }); + expect.fail("should have thrown"); + } catch (err) { + const deployErr = err as DeployError; + expect(deployErr.code).toBe("PREFLIGHT_FAILED"); + // Only the balance check fails — the merged chainId check (from spec) + // passed, proving both sources of the effective policy were honored. + expect(deployErr.preflightFailures).toHaveLength(1); + expect(deployErr.preflightFailures![0].check).toBe("balance"); + } + }, 30_000); +}); diff --git a/packages/core/test/preflight.test.ts b/packages/core/test/preflight.test.ts new file mode 100644 index 0000000..964462d --- /dev/null +++ b/packages/core/test/preflight.test.ts @@ -0,0 +1,247 @@ +/** + * Unit tests for the PREFLIGHT phase (deploy/preflight.ts). + * + * `runPreflight` is tested in isolation against a stub EIP-1193 provider that + * returns canned `eth_chainId` / `eth_getBalance` / `eth_gasPrice` responses. + * We assert: + * - all-pass (no failures) when every configured check is satisfied. + * - each individual check failing on its own. + * - multiple simultaneous failures are ALL collected (not fail-fast). + * - a policy with no fields set makes ZERO provider calls (the "no-op" + * contract that lets deploy() skip the whole phase for free). + * + * `mergePreflightPolicies` and `isEmptyPreflightPolicy` (the small helpers + * deploy() uses to compute the effective policy) are also covered directly. + */ + +import { describe, it, expect, vi } from "vitest"; +import type { EIP1193Provider } from "@nomicfoundation/ignition-core"; +import { + runPreflight, + mergePreflightPolicies, + isEmptyPreflightPolicy, +} from "../src/deploy/preflight.js"; +import type { PreflightPolicy } from "../src/deploy/preflight.js"; + +// --------------------------------------------------------------------------- +// Helpers — stub EIP-1193 provider +// --------------------------------------------------------------------------- + +const DEFAULT_ACCOUNT = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"; + +interface StubRpcResponses { + eth_chainId?: string; + eth_getBalance?: string; + eth_gasPrice?: string; +} + +/** + * Builds a minimal stub EIP1193Provider that answers only the three RPC + * methods runPreflight can call, each with a canned hex-quantity response. + * Any other method throws — runPreflight must never call anything else. + */ +function makeStubProvider(responses: StubRpcResponses): { + provider: EIP1193Provider; + calls: string[]; +} { + const calls: string[] = []; + const provider: EIP1193Provider = { + async request({ method }: { method: string; params?: readonly unknown[] | object }): Promise { + calls.push(method); + switch (method) { + case "eth_chainId": + if (responses.eth_chainId === undefined) throw new Error("unexpected eth_chainId call"); + return responses.eth_chainId; + case "eth_getBalance": + if (responses.eth_getBalance === undefined) throw new Error("unexpected eth_getBalance call"); + return responses.eth_getBalance; + case "eth_gasPrice": + if (responses.eth_gasPrice === undefined) throw new Error("unexpected eth_gasPrice call"); + return responses.eth_gasPrice; + default: + throw new Error(`StubProvider: unhandled method "${method}"`); + } + }, + }; + return { provider, calls }; +} + +// --------------------------------------------------------------------------- +// All-pass +// --------------------------------------------------------------------------- + +describe("runPreflight — all checks pass", () => { + it("returns an empty array when chainId, balance, and gasPrice all satisfy the policy", async () => { + const { provider, calls } = makeStubProvider({ + eth_chainId: "0x1", // 1 + eth_getBalance: "0xde0b6b3a7640000", // 1e18 + eth_gasPrice: "0x3b9aca00", // 1 gwei + }); + + const policy: PreflightPolicy = { + expectedChainId: 1, + minBalanceWei: "1000000000000000000", // exactly 1e18 — >= passes + maxGasPriceWei: "1000000000", // exactly 1 gwei — <= passes + }; + + const failures = await runPreflight(provider, policy, DEFAULT_ACCOUNT); + + expect(failures).toEqual([]); + expect(calls).toEqual(["eth_chainId", "eth_getBalance", "eth_gasPrice"]); + }); +}); + +// --------------------------------------------------------------------------- +// Individual failures +// --------------------------------------------------------------------------- + +describe("runPreflight — individual check failures", () => { + it("reports a chainId failure on mismatch", async () => { + const { provider } = makeStubProvider({ eth_chainId: "0x2a" }); // 42 + const failures = await runPreflight(provider, { expectedChainId: 1 }, DEFAULT_ACCOUNT); + + expect(failures).toHaveLength(1); + expect(failures[0].check).toBe("chainId"); + expect(failures[0].expected).toBe("1"); + expect(failures[0].actual).toBe("42"); + expect(failures[0].message).toContain("42"); + expect(failures[0].message).toContain("1"); + }); + + it("reports a balance failure when the account balance is below minBalanceWei", async () => { + const { provider } = makeStubProvider({ eth_getBalance: "0x1" }); // 1 wei + const failures = await runPreflight( + provider, + { minBalanceWei: "1000000000000000000" }, // 1 ETH + DEFAULT_ACCOUNT, + ); + + expect(failures).toHaveLength(1); + expect(failures[0].check).toBe("balance"); + expect(failures[0].expected).toBe("1000000000000000000"); + expect(failures[0].actual).toBe("1"); + expect(failures[0].message).toContain(DEFAULT_ACCOUNT); + }); + + it("reports a gasPrice failure when the current gas price exceeds maxGasPriceWei", async () => { + const { provider } = makeStubProvider({ eth_gasPrice: "0x174876e800" }); // 100 gwei + const failures = await runPreflight( + provider, + { maxGasPriceWei: "1000000000" }, // 1 gwei ceiling + DEFAULT_ACCOUNT, + ); + + expect(failures).toHaveLength(1); + expect(failures[0].check).toBe("gasPrice"); + expect(failures[0].expected).toBe("1000000000"); + expect(failures[0].actual).toBe("100000000000"); + }); + + it("does not report a balance failure when balance exactly equals minBalanceWei (boundary)", async () => { + const { provider } = makeStubProvider({ eth_getBalance: "0x64" }); // 100 + const failures = await runPreflight(provider, { minBalanceWei: "100" }, DEFAULT_ACCOUNT); + expect(failures).toEqual([]); + }); + + it("does not report a gasPrice failure when gas price exactly equals maxGasPriceWei (boundary)", async () => { + const { provider } = makeStubProvider({ eth_gasPrice: "0x64" }); // 100 + const failures = await runPreflight(provider, { maxGasPriceWei: "100" }, DEFAULT_ACCOUNT); + expect(failures).toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- +// Multiple simultaneous failures +// --------------------------------------------------------------------------- + +describe("runPreflight — multiple simultaneous failures", () => { + it("collects ALL failing checks, not just the first (fail-together, not fail-fast)", async () => { + const { provider, calls } = makeStubProvider({ + eth_chainId: "0x2a", // 42, expected 1 — fails + eth_getBalance: "0x0", // 0 wei — fails minBalanceWei + eth_gasPrice: "0x174876e800", // 100 gwei — fails maxGasPriceWei of 1 gwei + }); + + const policy: PreflightPolicy = { + expectedChainId: 1, + minBalanceWei: "1000000000000000000", + maxGasPriceWei: "1000000000", + }; + + const failures = await runPreflight(provider, policy, DEFAULT_ACCOUNT); + + expect(failures).toHaveLength(3); + expect(failures.map((f) => f.check).sort()).toEqual(["balance", "chainId", "gasPrice"].sort()); + // All three RPC methods were still called even though the first failed. + expect(calls).toEqual(["eth_chainId", "eth_getBalance", "eth_gasPrice"]); + }); +}); + +// --------------------------------------------------------------------------- +// No-op: empty policy makes ZERO provider calls +// --------------------------------------------------------------------------- + +describe("runPreflight — empty policy is a true no-op", () => { + it("makes ZERO provider.request calls when no policy fields are set", async () => { + const requestSpy = vi.fn(async () => { + throw new Error("provider.request should never be called for an empty policy"); + }); + const provider: EIP1193Provider = { request: requestSpy as EIP1193Provider["request"] }; + + const failures = await runPreflight(provider, {}, DEFAULT_ACCOUNT); + + expect(failures).toEqual([]); + expect(requestSpy).not.toHaveBeenCalled(); + }); +}); + +// --------------------------------------------------------------------------- +// mergePreflightPolicies +// --------------------------------------------------------------------------- + +describe("mergePreflightPolicies", () => { + it("returns an empty object when both base and override are undefined", () => { + expect(mergePreflightPolicies(undefined, undefined)).toEqual({ + expectedChainId: undefined, + minBalanceWei: undefined, + maxGasPriceWei: undefined, + }); + }); + + it("falls back to base fields when override is undefined", () => { + const base: PreflightPolicy = { expectedChainId: 1, minBalanceWei: "5" }; + expect(mergePreflightPolicies(base, undefined)).toEqual({ + expectedChainId: 1, + minBalanceWei: "5", + maxGasPriceWei: undefined, + }); + }); + + it("prefers override fields over base fields, per field", () => { + const base: PreflightPolicy = { expectedChainId: 1, minBalanceWei: "5", maxGasPriceWei: "10" }; + const override: PreflightPolicy = { expectedChainId: 42 }; + expect(mergePreflightPolicies(base, override)).toEqual({ + expectedChainId: 42, // override wins + minBalanceWei: "5", // falls back to base + maxGasPriceWei: "10", // falls back to base + }); + }); +}); + +// --------------------------------------------------------------------------- +// isEmptyPreflightPolicy +// --------------------------------------------------------------------------- + +describe("isEmptyPreflightPolicy", () => { + it("is true for an empty object", () => { + expect(isEmptyPreflightPolicy({})).toBe(true); + }); + + it.each<[string, PreflightPolicy]>([ + ["expectedChainId", { expectedChainId: 1 }], + ["minBalanceWei", { minBalanceWei: "1" }], + ["maxGasPriceWei", { maxGasPriceWei: "1" }], + ])("is false when %s is set", (_name, policy) => { + expect(isEmptyPreflightPolicy(policy)).toBe(false); + }); +}); diff --git a/packages/core/test/spec.test.ts b/packages/core/test/spec.test.ts index 8c59e0f..a7e1852 100644 --- a/packages/core/test/spec.test.ts +++ b/packages/core/test/spec.test.ts @@ -1293,3 +1293,166 @@ describe("validateSpec — upgradeable: schema-level shape checks", () => { } }); }); + +// --------------------------------------------------------------------------- +// PREFLIGHT policy — declarative pre-broadcast safety checks (issue #156) +// --------------------------------------------------------------------------- + +describe("validateSpec — preflight: happy paths", () => { + it("accepts a spec with no preflight block at all (backward compatible)", () => { + const result = validateSpec(happySpec); + expect(result.ok).toBe(true); + }); + + it("accepts an empty preflight object", () => { + const result = validateSpec({ version: 1, contracts: [], preflight: {} }); + expect(result.ok).toBe(true); + }); + + it("accepts a preflight block with only expectedChainId set", () => { + const result = validateSpec({ + version: 1, + contracts: [], + preflight: { expectedChainId: 1 }, + }); + expect(result.ok).toBe(true); + }); + + it("accepts a preflight block with only minBalanceWei set", () => { + const result = validateSpec({ + version: 1, + contracts: [], + preflight: { minBalanceWei: "1000000000000000000" }, + }); + expect(result.ok).toBe(true); + }); + + it("accepts a preflight block with only maxGasPriceWei set", () => { + const result = validateSpec({ + version: 1, + contracts: [], + preflight: { maxGasPriceWei: "50000000000" }, + }); + expect(result.ok).toBe(true); + }); + + it("accepts a preflight block with all three fields set", () => { + const result = validateSpec({ + version: 1, + contracts: [{ id: "registry", contract: "Registry" }], + preflight: { + expectedChainId: 1, + minBalanceWei: "1000000000000000000", + maxGasPriceWei: "50000000000", + }, + }); + expect(result.ok).toBe(true); + }); + + it('accepts minBalanceWei / maxGasPriceWei of "0" (boundary — zero is a valid non-negative bigint string)', () => { + const result = validateSpec({ + version: 1, + contracts: [], + preflight: { minBalanceWei: "0", maxGasPriceWei: "0" }, + }); + expect(result.ok).toBe(true); + }); +}); + +describe("validateSpec — preflight: INVALID_SHAPE rejections", () => { + it("rejects a non-positive expectedChainId (zero)", () => { + const result = validateSpec({ + version: 1, + contracts: [], + preflight: { expectedChainId: 0 }, + }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.errors.some((e) => e.code === "INVALID_SHAPE")).toBe(true); + } + }); + + it("rejects a negative expectedChainId", () => { + const result = validateSpec({ + version: 1, + contracts: [], + preflight: { expectedChainId: -1 }, + }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.errors.some((e) => e.code === "INVALID_SHAPE")).toBe(true); + } + }); + + it("rejects a non-integer expectedChainId", () => { + const result = validateSpec({ + version: 1, + contracts: [], + preflight: { expectedChainId: 1.5 }, + }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.errors.some((e) => e.code === "INVALID_SHAPE")).toBe(true); + } + }); + + it("rejects a minBalanceWei that is not a decimal digit string (hex-prefixed)", () => { + const result = validateSpec({ + version: 1, + contracts: [], + preflight: { minBalanceWei: "0x10" }, + }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.errors.some((e) => e.code === "INVALID_SHAPE")).toBe(true); + } + }); + + it("rejects a minBalanceWei with a leading zero (e.g. '007')", () => { + const result = validateSpec({ + version: 1, + contracts: [], + preflight: { minBalanceWei: "007" }, + }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.errors.some((e) => e.code === "INVALID_SHAPE")).toBe(true); + } + }); + + it("rejects a negative minBalanceWei string", () => { + const result = validateSpec({ + version: 1, + contracts: [], + preflight: { minBalanceWei: "-1" }, + }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.errors.some((e) => e.code === "INVALID_SHAPE")).toBe(true); + } + }); + + it("rejects a maxGasPriceWei that is not numeric at all", () => { + const result = validateSpec({ + version: 1, + contracts: [], + preflight: { maxGasPriceWei: "not-a-number" }, + }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.errors.some((e) => e.code === "INVALID_SHAPE")).toBe(true); + } + }); + + it("rejects a preflight value where minBalanceWei is a number instead of a string", () => { + const result = validateSpec({ + version: 1, + contracts: [], + preflight: { minBalanceWei: 1000000000000000000 }, + }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.errors.some((e) => e.code === "INVALID_SHAPE")).toBe(true); + } + }); +});