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 1/2] 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); + } + }); +}); From f272f576bbbf0be9ebf5020f205fd86b7678c21b Mon Sep 17 00:00:00 2001 From: Roberto Cano <3525807+robercano@users.noreply.github.com> Date: Wed, 29 Jul 2026 09:45:14 +0200 Subject: [PATCH 2/2] feat(deploy-server): simulate-first interlock + preflight SSE surfacing (#156) Add requireSimulate and preflight to NetworkConfig so a network can refuse POST /api/deploy until an identical spec (by stable hash) has been successfully simulated, and so DeployError("PREFLIGHT_FAILED", ...) from @redeploy/core's new preflight phase surfaces as a structured SSE error the studio can render. Co-Authored-By: Claude Sonnet 5 --- apps/deploy-server/src/networks.ts | 94 ++++- apps/deploy-server/src/server.ts | 111 +++++- apps/deploy-server/src/simulate-state.ts | 128 +++++++ apps/deploy-server/src/spec-hash.ts | 65 ++++ apps/deploy-server/test/deploy.test.ts | 348 ++++++++++++++++++ apps/deploy-server/test/networks.test.ts | 136 +++++++ .../deploy-server/test/simulate-state.test.ts | 137 +++++++ apps/deploy-server/test/spec-hash.test.ts | 70 ++++ 8 files changed, 1079 insertions(+), 10 deletions(-) create mode 100644 apps/deploy-server/src/simulate-state.ts create mode 100644 apps/deploy-server/src/spec-hash.ts create mode 100644 apps/deploy-server/test/simulate-state.test.ts create mode 100644 apps/deploy-server/test/spec-hash.test.ts diff --git a/apps/deploy-server/src/networks.ts b/apps/deploy-server/src/networks.ts index de61c3e..a21f850 100644 --- a/apps/deploy-server/src/networks.ts +++ b/apps/deploy-server/src/networks.ts @@ -53,7 +53,13 @@ * "moduleId": "Deployment", // optional; else "Deployment" * "deploymentParameters": { // optional; flat paramName -> value, * "someParam": 123 // wrapped under `moduleId` before - * } // being passed to core.deploy() + * }, // being passed to core.deploy() + * "requireSimulate": true, // optional; default false — see below + * "preflight": { // optional PreflightPolicy (see @redeploy/core) + * "expectedChainId": 11155111, + * "minBalanceWei": "1000000000000000000", + * "maxGasPriceWei": "50000000000" + * } * } * } * } @@ -92,6 +98,16 @@ * time) — defense in depth for the derived-deploymentDir path segment, * even though names only ever originate from trusted server config, never * from client input. + * - `requireSimulate` (default `false`): when `true`, `POST /api/deploy` + * for this network refuses to run unless a `POST /api/simulate` has + * already succeeded for the EXACT SAME spec on this same network — see + * `simulate-state.ts` and `server.ts`'s `handleDeploy`. + * - `preflight` (optional): a `PreflightPolicy` (from `@redeploy/core`) + * passed as `core.deploy()`'s per-call `preflight` option for this + * network — merged (per field) with any `spec.preflight` the request + * carries. See `@redeploy/core`'s preflight docs for the check + * semantics; a failure surfaces as a structured `PREFLIGHT_FAILED` SSE + * error from `POST /api/deploy` (never broadcasting a transaction). * * WIRE SHAPE * ========== @@ -106,7 +122,7 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; -import type { DeployOptions } from "@redeploy/core"; +import type { DeployOptions, PreflightPolicy } from "@redeploy/core"; // --------------------------------------------------------------------------- // Types derived from @redeploy/core's public surface (avoids a direct @@ -148,6 +164,29 @@ export interface NetworkConfig { readonly deploymentParameters?: ModuleParameters; /** Ignition module id override for this network. Defaults to `"Deployment"`. */ readonly moduleId?: string; + /** + * When `true`, `POST /api/deploy` for this network REFUSES to run unless a + * `POST /api/simulate` has already succeeded for the EXACT SAME spec (by + * stable hash — see `spec-hash.ts`) on this same network. See + * `simulate-state.ts` and `server.ts`'s `handleDeploy` for the interlock + * check. Defaults to `false`/undefined — omitting this field preserves + * pre-existing behavior exactly (no interlock). + */ + readonly requireSimulate?: boolean; + /** + * Optional per-network `PreflightPolicy` (from `@redeploy/core`), passed + * as `core.deploy()`'s `preflight` option for this network. Per-field + * merged by `core.deploy()` with any `spec.preflight` the request body + * carries (the network's policy is the "per-call override" — see + * `@redeploy/core`'s `mergePreflightPolicies`). Every field is optional and + * independent (`expectedChainId`, `minBalanceWei`, `maxGasPriceWei`) — the + * deep numeric/shape validation of these values is delegated to + * `@redeploy/core` at deploy time; this module only shape-checks that + * `preflight` (when present) is an object with the expected primitive + * field types (mirroring the light shape-checking already done for + * `chainId`/`deploymentParameters` below). + */ + readonly preflight?: PreflightPolicy; } /** The full set of configured networks plus which one is the default. */ @@ -205,6 +244,14 @@ interface RawNetworkConfig { deploymentDir?: unknown; deploymentParameters?: unknown; moduleId?: unknown; + requireSimulate?: unknown; + preflight?: unknown; +} + +interface RawPreflightPolicy { + expectedChainId?: unknown; + minBalanceWei?: unknown; + maxGasPriceWei?: unknown; } interface RawNetworksConfigFile { @@ -314,6 +361,47 @@ function readNetworksConfigFile( `NETWORKS_CONFIG file at "${resolvedPath}": network "${name}" has an invalid "deploymentParameters" (must be an object)`, ); } + if (entry.requireSimulate !== undefined && typeof entry.requireSimulate !== "boolean") { + throw new NetworksConfigError( + `NETWORKS_CONFIG file at "${resolvedPath}": network "${name}" has a non-boolean "requireSimulate"`, + ); + } + + let preflight: PreflightPolicy | undefined; + if (entry.preflight !== undefined) { + if (typeof entry.preflight !== "object" || entry.preflight === null || Array.isArray(entry.preflight)) { + throw new NetworksConfigError( + `NETWORKS_CONFIG file at "${resolvedPath}": network "${name}" has an invalid "preflight" (must be an object)`, + ); + } + const rawPreflight = entry.preflight as RawPreflightPolicy; + if (rawPreflight.expectedChainId !== undefined && typeof rawPreflight.expectedChainId !== "number") { + throw new NetworksConfigError( + `NETWORKS_CONFIG file at "${resolvedPath}": network "${name}" has a non-numeric "preflight.expectedChainId"`, + ); + } + if (rawPreflight.minBalanceWei !== undefined && typeof rawPreflight.minBalanceWei !== "string") { + throw new NetworksConfigError( + `NETWORKS_CONFIG file at "${resolvedPath}": network "${name}" has a non-string "preflight.minBalanceWei"`, + ); + } + if (rawPreflight.maxGasPriceWei !== undefined && typeof rawPreflight.maxGasPriceWei !== "string") { + throw new NetworksConfigError( + `NETWORKS_CONFIG file at "${resolvedPath}": network "${name}" has a non-string "preflight.maxGasPriceWei"`, + ); + } + preflight = { + ...(rawPreflight.expectedChainId !== undefined + ? { expectedChainId: rawPreflight.expectedChainId as number } + : {}), + ...(rawPreflight.minBalanceWei !== undefined + ? { minBalanceWei: rawPreflight.minBalanceWei as string } + : {}), + ...(rawPreflight.maxGasPriceWei !== undefined + ? { maxGasPriceWei: rawPreflight.maxGasPriceWei as string } + : {}), + }; + } networks[name] = { rpcUrl: entry.rpcUrl, @@ -324,6 +412,8 @@ function readNetworksConfigFile( ? { deploymentParameters: entry.deploymentParameters as ModuleParameters } : {}), ...(entry.moduleId !== undefined ? { moduleId: entry.moduleId as string } : {}), + ...(entry.requireSimulate !== undefined ? { requireSimulate: entry.requireSimulate as boolean } : {}), + ...(preflight !== undefined ? { preflight } : {}), }; } diff --git a/apps/deploy-server/src/server.ts b/apps/deploy-server/src/server.ts index 9c79f47..c32a5c6 100644 --- a/apps/deploy-server/src/server.ts +++ b/apps/deploy-server/src/server.ts @@ -21,6 +21,8 @@ import { createRpcChainReader } from "./verify/chain-reader.js"; import { loadNetworksRegistry, resolveNetwork, listNetworks, DEFAULT_MODULE_ID } from "./networks.js"; import type { NetworkConfig } from "./networks.js"; import { buildAddressBook, buildChainConfigExecutor } from "./apply-config/chain-executor.js"; +import { specHash } from "./spec-hash.js"; +import { recordSimulated, hasSimulated } from "./simulate-state.js"; /** Maximum body size for POST requests (1 MiB). */ const MAX_BODY_BYTES = 1024 * 1024; @@ -297,6 +299,15 @@ async function readAndParseBody( * resolved config. Once core's simulate() gains a * deploymentParameters/moduleId option, wire `network.config.deploymentParameters` * (keyed under `network.config.moduleId ?? DEFAULT_MODULE_ID`) through here. + * + * Simulate-first interlock recording (issue #156): on a SUCCESSFUL simulate + * (`ok:true`), the request body's stable spec hash (see `spec-hash.ts`) is + * recorded via `recordSimulated()` against the resolved network's + * `deploymentDir` — this is what `POST /api/deploy` later checks for + * networks configured with `requireSimulate: true` (see `networks.ts` and + * `handleDeploy`). Recording is best-effort (mirrors `recordSimulated`'s own + * doc comment): a failure here never turns a successful simulate into an + * error response, and never affects the SSE frames emitted to the client. */ async function handleSimulate(req: IncomingMessage, res: ServerResponse): Promise { const result = await readAndParseBody(req, res); @@ -305,11 +316,10 @@ async function handleSimulate(req: IncomingMessage, res: ServerResponse): Promis // Resolve + validate the target network (allowlist lookup only — see // resolveNetworkForRequest doc). See this function's LIMITATION note: the - // resolved config is not yet consumed by simulate() itself. + // resolved config is not yet consumed by simulate() itself (beyond the + // simulate-first interlock recording below). const network = resolveNetworkForRequest(req, res); if (network === undefined) return; - // `network` is intentionally unused beyond validation — see LIMITATION - // above: core's simulate() has no options parameter to pass it through to. // --- Simulate ------------------------------------------------------------ const simResult = simulate(spec); @@ -327,6 +337,16 @@ async function handleSimulate(req: IncomingMessage, res: ServerResponse): Promis writeSseEvent(res, "step", stepData); } writeSseEvent(res, "done", { success: true }); + + // --- Record simulate-first interlock state (best-effort) --------------- + // See recordSimulated()'s doc comment: failures here are swallowed (a + // generic stderr line only) and never affect the response already sent. + try { + fs.mkdirSync(network.config.deploymentDir, { recursive: true }); + recordSimulated(network.config.deploymentDir, specHash(spec)); + } catch { + process.stderr.write("[deploy-server] failed to record simulate-first interlock state\n"); + } } else { const errors: SimulateError[] = [...simResult.errors]; writeSseEvent(res, "done", { success: false, errors }); @@ -389,6 +409,27 @@ async function handleSimulate(req: IncomingMessage, res: ServerResponse): Promis * by the provider's internal viem account, returning [account.address]. This * avoids importing viem directly into deploy-server. * + * Simulate-first interlock (issue #156): when the resolved network's + * `requireSimulate` is `true`, this handler computes the request body's + * stable spec hash (see `spec-hash.ts`) and requires a prior successful + * `POST /api/simulate` for that EXACT hash on this same network (see + * `simulate-state.ts` / `handleSimulate`'s recording step). This check runs + * BEFORE any provider/tx construction, right after the SSE stream opens; a + * miss yields a terminal `done{success:false, errors:[{code: + * "SIMULATE_REQUIRED", message}]}` frame and `core.deploy()` is never + * called. + * + * Preflight (issue #156): the resolved network's optional `preflight` policy + * (see `networks.ts`'s `NetworkConfig.preflight`) is passed as + * `core.deploy()`'s `preflight` option, merged (per field, core-side) with + * any `spec.preflight` the request body carries. A failing check throws + * `DeployError("PREFLIGHT_FAILED", ..., preflightFailures)` BEFORE any + * transaction is broadcast; this handler maps each `PreflightFailure` into + * the `done{success:false}` errors array as `{code:"PREFLIGHT_FAILED", + * message, check, expected, actual}` (see the `DeployError` catch branch + * below) so the studio can render exactly which check(s) failed. These + * values (chain ids, wei amounts, an address) are non-secret. + * * ReadError handling: on success:true, we call readDeployment() synchronously. * If it throws ReadError we still emit done{success:true, deployment:null, * warning:"could not read journal"} — a successful deploy must not become a 500. @@ -429,6 +470,31 @@ async function handleDeploy(req: IncomingMessage, res: ServerResponse): Promise< Connection: "keep-alive", }); + // --- Simulate-first interlock (issue #156) -------------------------------- + // For networks configured with requireSimulate: true, refuse to deploy + // unless a POST /api/simulate has already succeeded for this EXACT spec + // (by stable hash — see spec-hash.ts) on this same network (see + // simulate-state.ts and handleSimulate's recording step above). This check + // runs BEFORE any provider/tx construction — caught pre-broadcast, never an + // SSE error after a transaction could have been sent. SECURITY: the spec + // itself is never echoed back — only a fixed, structured error. + if (network.config.requireSimulate === true) { + const hash = specHash(body); + if (!hasSimulated(network.config.deploymentDir, hash)) { + writeSseEvent(res, "done", { + success: false, + errors: [ + { + code: "SIMULATE_REQUIRED", + message: "This network requires a successful simulate for this exact spec before deploying.", + }, + ], + }); + res.end(); + return; + } + } + // --- Validate private key presence BEFORE building the provider ---------- // SECURITY: never include the key value in any message or log. const rawPrivateKey = network.config.deployerPrivateKey; @@ -522,18 +588,47 @@ async function handleDeploy(req: IncomingMessage, res: ServerResponse): Promise< artifactResolver, moduleId, ...(deploymentParameters !== undefined ? { deploymentParameters } : {}), + // Per-network preflight policy (issue #156) — merged by core.deploy() + // (per field) with any spec.preflight the request body carries. See + // networks.ts's NetworkConfig.preflight doc comment. + ...(network.config.preflight !== undefined ? { preflight: network.config.preflight } : {}), }); } catch (caughtErr) { - // deploy() throws DeployError for INVALID_SPEC and COMPILE_ERROR. - // All other thrown values are unexpected errors. + // deploy() throws DeployError for INVALID_SPEC, COMPILE_ERROR, and + // PREFLIGHT_FAILED. All other thrown values are unexpected errors. if (caughtErr instanceof DeployError) { const deployErr = caughtErr; - const errors: Array<{ code?: string; message: string }> = + let errors: Array<{ + code?: string; + message: string; + check?: string; + expected?: string; + actual?: string; + }>; + if ( deployErr.code === "INVALID_SPEC" && deployErr.specErrors != null && deployErr.specErrors.length > 0 - ? deployErr.specErrors.map((se) => ({ code: deployErr.code, message: se.message })) - : [{ code: deployErr.code, message: deployErr.message }]; + ) { + errors = deployErr.specErrors.map((se) => ({ code: deployErr.code, message: se.message })); + } else if ( + deployErr.code === "PREFLIGHT_FAILED" && + deployErr.preflightFailures != null && + deployErr.preflightFailures.length > 0 + ) { + // Structured preflight failures (issue #156) — non-secret (chain + // ids, wei amounts, the deployer address), safe to forward so the + // studio can render exactly which check(s) failed. + errors = deployErr.preflightFailures.map((f) => ({ + code: "PREFLIGHT_FAILED", + message: f.message, + check: f.check, + expected: f.expected, + actual: f.actual, + })); + } else { + errors = [{ code: deployErr.code, message: deployErr.message }]; + } writeSseEvent(res, "done", { success: false, errors }); res.end(); return; diff --git a/apps/deploy-server/src/simulate-state.ts b/apps/deploy-server/src/simulate-state.ts new file mode 100644 index 0000000..1d59d24 --- /dev/null +++ b/apps/deploy-server/src/simulate-state.ts @@ -0,0 +1,128 @@ +/** + * Persisted "has this spec been simulated?" state, per network — backs the + * simulate-first interlock for networks configured with + * `NetworkConfig.requireSimulate === true` (see `networks.ts` and + * `server.ts`'s `handleSimulate`/`handleDeploy`). + * + * DESIGN + * ====== + * + * A successful `POST /api/simulate` for a given network records the + * requested spec's stable hash (see `spec-hash.ts`) into a small JSON file + * living alongside that network's Ignition journal: + * `/.redeploy-simulated.json`. `deploymentDir` is already a + * per-network, server-controlled directory (see `networks.ts`'s module + * doc), so this file is deliberately co-located there rather than in some + * new, separately-configured path — no new server-side path input, no new + * attack surface. + * + * The file holds a bounded, most-recently-used array of hex hash strings + * (capped at `MAX_RECORDED_HASHES`) so a long-running server with many + * distinct simulated specs never grows this file unboundedly. `recordSimulated` + * evicts the oldest entries first when the cap is exceeded. + * + * FAILURE POSTURE + * ================ + * + * `hasSimulated` is used by `handleDeploy`'s pre-broadcast interlock CHECK: + * it must fail CLOSED — any inability to positively confirm a prior + * simulate (file missing, corrupt, unreadable) is treated as "not + * simulated", so `requireSimulate` still refuses the deploy. This is why + * `hasSimulated` swallows all errors and returns `false` rather than + * throwing. + * + * `recordSimulated`, by contrast, is invoked on the SUCCESS path of + * `POST /api/simulate` and is explicitly best-effort: a write failure here + * must never turn a successful simulate into an error response (see + * `handleSimulate`'s doc comment). Failures are logged as a single generic + * stderr line (no path/error detail — consistent with this codebase's + * existing "never leak server-side paths" discipline) and otherwise + * swallowed. + */ + +import * as fs from "node:fs"; +import * as path from "node:path"; + +/** Filename of the per-network simulated-spec-hash record, under `deploymentDir`. */ +const SIMULATED_STATE_FILENAME = ".redeploy-simulated.json"; + +/** Maximum number of distinct spec hashes retained per network (most-recent-first). */ +const MAX_RECORDED_HASHES = 50; + +interface SimulatedStateFile { + hashes?: unknown; +} + +function stateFilePath(deploymentDir: string): string { + return path.join(deploymentDir, SIMULATED_STATE_FILENAME); +} + +/** + * Read the recorded hash list from `deploymentDir`'s state file. + * + * Never throws: a missing file, unreadable file, malformed JSON, or a JSON + * shape that isn't `{ hashes: string[] }` all resolve to an empty array — + * "no record" is always a safe, fail-closed default for the interlock check + * this backs (see this module's doc comment). + */ +function readRecordedHashes(deploymentDir: string): string[] { + let raw: string; + try { + raw = fs.readFileSync(stateFilePath(deploymentDir), "utf8"); + } catch { + return []; + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return []; + } + + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + return []; + } + const hashes = (parsed as SimulatedStateFile).hashes; + if (!Array.isArray(hashes)) { + return []; + } + return hashes.filter((h): h is string => typeof h === "string"); +} + +/** + * Record that `hash` has been successfully simulated for the network whose + * Ignition journal lives at `deploymentDir`. + * + * Best-effort: `deploymentDir` is created if missing (`mkdir -p`), and any + * read/parse/write failure is caught, logged as a single generic stderr + * line, and otherwise swallowed — this function must never throw in a way + * that turns a successful simulate into an error response. + * + * Bounded: retains at most `MAX_RECORDED_HASHES` entries, most-recent-first + * (an existing entry being re-recorded is moved to the front rather than + * duplicated). + */ +export function recordSimulated(deploymentDir: string, hash: string): void { + try { + fs.mkdirSync(deploymentDir, { recursive: true }); + const existing = readRecordedHashes(deploymentDir); + const withoutHash = existing.filter((h) => h !== hash); + const updated = [hash, ...withoutHash].slice(0, MAX_RECORDED_HASHES); + const payload: SimulatedStateFile = { hashes: updated }; + fs.writeFileSync(stateFilePath(deploymentDir), JSON.stringify(payload), "utf8"); + } catch { + process.stderr.write("[deploy-server] failed to persist simulated-spec state\n"); + } +} + +/** + * Whether `hash` has previously been recorded as simulated for the network + * whose Ignition journal lives at `deploymentDir`. + * + * Fails CLOSED: any error reading/parsing the state file resolves to + * `false` ("not simulated") — see this module's doc comment. Never throws. + */ +export function hasSimulated(deploymentDir: string, hash: string): boolean { + return readRecordedHashes(deploymentDir).includes(hash); +} diff --git a/apps/deploy-server/src/spec-hash.ts b/apps/deploy-server/src/spec-hash.ts new file mode 100644 index 0000000..2587efd --- /dev/null +++ b/apps/deploy-server/src/spec-hash.ts @@ -0,0 +1,65 @@ +/** + * Stable, deterministic hashing for a `DeploymentSpec` (or any JSON-ish + * value) — used to key the simulate-first interlock (see `simulate-state.ts` + * and `server.ts`'s `handleSimulate`/`handleDeploy`). + * + * DESIGN + * ====== + * + * `JSON.stringify` alone is NOT a stable serialization: two structurally + * identical objects with keys in a different order produce different + * strings, and thus different hashes. Since the studio (or any client) may + * re-serialize the same logical spec with keys in a different order between + * a `POST /api/simulate` call and a subsequent `POST /api/deploy` call, a + * naive `sha256(JSON.stringify(spec))` would spuriously treat them as + * different specs and defeat the "same spec" interlock check. + * + * `specHash` fixes this by recursively sorting object keys (arrays keep + * their original order — element order is semantically meaningful, e.g. + * constructor `args`) before serializing, then hashing the canonical string + * with SHA-256 (`node:crypto`), hex-encoded. + * + * This module has NO knowledge of the `DeploymentSpec` shape — it operates + * on `unknown` so it can hash the raw parsed request body exactly as both + * `handleSimulate` and `handleDeploy` receive it, without importing + * `@redeploy/core`'s spec types. + */ + +import { createHash } from "node:crypto"; + +/** + * Recursively produce a canonical (deterministically ordered) clone of + * `value`, suitable for stable JSON serialization: + * - Plain objects: keys are sorted lexicographically (recursively). + * - Arrays: element order is preserved (order is semantically meaningful), + * but each element is itself canonicalized. + * - Everything else (strings, numbers, booleans, null, undefined): + * returned as-is — `JSON.stringify` already handles these deterministically. + */ +function canonicalize(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map((item) => canonicalize(item)); + } + if (value !== null && typeof value === "object") { + const sortedKeys = Object.keys(value as Record).sort(); + const result: Record = {}; + for (const key of sortedKeys) { + result[key] = canonicalize((value as Record)[key]); + } + return result; + } + return value; +} + +/** + * Produce a deterministic SHA-256 hex digest of `spec`'s canonical JSON + * serialization. Two values that are structurally equal (same keys/values, + * regardless of object key order) always produce the same hash; any + * structural difference (a changed value, an added/removed key, a reordered + * array) produces a different hash. + */ +export function specHash(spec: unknown): string { + const canonical = canonicalize(spec); + const serialized = JSON.stringify(canonical); + return createHash("sha256").update(serialized).digest("hex"); +} diff --git a/apps/deploy-server/test/deploy.test.ts b/apps/deploy-server/test/deploy.test.ts index 796b651..5517510 100644 --- a/apps/deploy-server/test/deploy.test.ts +++ b/apps/deploy-server/test/deploy.test.ts @@ -1268,3 +1268,351 @@ describe("POST /api/deploy — snapshot records the selected network label", () expect(readerMod.buildSnapshot).toHaveBeenCalledWith(expect.objectContaining({ network: "default" })); }); }); + +// --------------------------------------------------------------------------- +// Simulate-first interlock (issue #156) — requireSimulate: true +// --------------------------------------------------------------------------- + +describe("POST /api/deploy — simulate-first interlock (requireSimulate)", () => { + let tmpDir: string; + let journalDir: string; + let savedNetworksConfig: string | undefined; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "redeploy-interlock-test-")); + journalDir = path.join(tmpDir, "alpha-journal"); + savedNetworksConfig = process.env["NETWORKS_CONFIG"]; + process.env["DEPLOYER_PRIVATE_KEY"] = + "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + if (savedNetworksConfig === undefined) { + delete process.env["NETWORKS_CONFIG"]; + } else { + process.env["NETWORKS_CONFIG"] = savedNetworksConfig; + } + }); + + function writeRequireSimulateConfig(): void { + const configPath = path.join(tmpDir, "networks.json"); + fs.writeFileSync( + configPath, + JSON.stringify({ + networks: { + alpha: { + rpcUrl: "http://alpha.example.com", + deployerPrivateKey: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + deploymentDir: journalDir, + requireSimulate: true, + }, + }, + }), + "utf8", + ); + process.env["NETWORKS_CONFIG"] = configPath; + } + + it("refuses to deploy a not-yet-simulated spec: done{success:false, errors:[{code:'SIMULATE_REQUIRED'}]}, core.deploy never called", async () => { + writeRequireSimulateConfig(); + + const coreMod = vi.mocked(await import("@redeploy/core")); + + const res = await doRequest(port, "POST", "/api/deploy?network=alpha", JSON.stringify(VALID_SPEC)); + expect(res.statusCode).toBe(200); + expect(res.headers["content-type"]).toMatch(/text\/event-stream/); + + const events = parseSse(res.body); + const doneEvent = events.find((e) => e.event === "done"); + expect(doneEvent).toBeDefined(); + const done = doneEvent!.data as Record; + expect(done["success"]).toBe(false); + const errors = done["errors"] as Array>; + expect(errors.length).toBeGreaterThan(0); + expect(errors[0]!["code"]).toBe("SIMULATE_REQUIRED"); + + // No deploy must have been attempted — not even provider construction. + expect(coreMod.deploy).not.toHaveBeenCalled(); + expect(coreMod.jsonRpcProvider).not.toHaveBeenCalled(); + }); + + it("allows the deploy to proceed after a successful simulate for the exact same spec", async () => { + writeRequireSimulateConfig(); + + const coreMod = vi.mocked(await import("@redeploy/core")); + + // First, a successful simulate for the exact spec on the same network. + const simRes = await doRequest(port, "POST", "/api/simulate?network=alpha", JSON.stringify(VALID_SPEC)); + expect(simRes.statusCode).toBe(200); + const simEvents = parseSse(simRes.body); + const simDone = simEvents.find((e) => e.event === "done"); + expect((simDone!.data as Record)["success"]).toBe(true); + + // Now the deploy for the SAME spec must proceed past the interlock. + const res = await doRequest(port, "POST", "/api/deploy?network=alpha", JSON.stringify(VALID_SPEC)); + expect(res.statusCode).toBe(200); + + const events = parseSse(res.body); + const doneEvent = events.find((e) => e.event === "done"); + const done = doneEvent!.data as Record; + expect(done["success"]).toBe(true); + + expect(coreMod.deploy).toHaveBeenCalledTimes(1); + }); + + it("a simulate for a DIFFERENT spec does not satisfy the interlock for a not-yet-simulated spec", async () => { + writeRequireSimulateConfig(); + + const coreMod = vi.mocked(await import("@redeploy/core")); + + const otherSpec = { + version: 1, + contracts: [{ id: "different", contract: "Different" }], + }; + const simRes = await doRequest(port, "POST", "/api/simulate?network=alpha", JSON.stringify(otherSpec)); + expect(simRes.statusCode).toBe(200); + + const res = await doRequest(port, "POST", "/api/deploy?network=alpha", JSON.stringify(VALID_SPEC)); + const events = parseSse(res.body); + const doneEvent = events.find((e) => e.event === "done"); + const done = doneEvent!.data as Record; + expect(done["success"]).toBe(false); + const errors = done["errors"] as Array>; + expect(errors[0]!["code"]).toBe("SIMULATE_REQUIRED"); + expect(coreMod.deploy).not.toHaveBeenCalled(); + }); + + it("a spec that is structurally identical but with reordered keys still satisfies the interlock (stable hash)", async () => { + writeRequireSimulateConfig(); + + const coreMod = vi.mocked(await import("@redeploy/core")); + + const reorderedSpec = { + contracts: VALID_SPEC.contracts, + version: VALID_SPEC.version, + }; + + const simRes = await doRequest(port, "POST", "/api/simulate?network=alpha", JSON.stringify(VALID_SPEC)); + expect(simRes.statusCode).toBe(200); + + const res = await doRequest( + port, + "POST", + "/api/deploy?network=alpha", + JSON.stringify(reorderedSpec), + ); + const events = parseSse(res.body); + const doneEvent = events.find((e) => e.event === "done"); + const done = doneEvent!.data as Record; + expect(done["success"]).toBe(true); + expect(coreMod.deploy).toHaveBeenCalledTimes(1); + }); + + it("does not affect deploys when requireSimulate is false", async () => { + const configPath = path.join(tmpDir, "networks.json"); + fs.writeFileSync( + configPath, + JSON.stringify({ + networks: { + alpha: { + rpcUrl: "http://alpha.example.com", + deployerPrivateKey: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + deploymentDir: journalDir, + requireSimulate: false, + }, + }, + }), + "utf8", + ); + process.env["NETWORKS_CONFIG"] = configPath; + + const coreMod = vi.mocked(await import("@redeploy/core")); + + const res = await doRequest(port, "POST", "/api/deploy?network=alpha", JSON.stringify(VALID_SPEC)); + const events = parseSse(res.body); + const doneEvent = events.find((e) => e.event === "done"); + expect((doneEvent!.data as Record)["success"]).toBe(true); + expect(coreMod.deploy).toHaveBeenCalledTimes(1); + }); + + it("does not affect deploys when requireSimulate is absent (backward compat)", async () => { + const configPath = path.join(tmpDir, "networks.json"); + fs.writeFileSync( + configPath, + JSON.stringify({ + networks: { + alpha: { + rpcUrl: "http://alpha.example.com", + deployerPrivateKey: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + deploymentDir: journalDir, + }, + }, + }), + "utf8", + ); + process.env["NETWORKS_CONFIG"] = configPath; + + const coreMod = vi.mocked(await import("@redeploy/core")); + + const res = await doRequest(port, "POST", "/api/deploy?network=alpha", JSON.stringify(VALID_SPEC)); + const events = parseSse(res.body); + const doneEvent = events.find((e) => e.event === "done"); + expect((doneEvent!.data as Record)["success"]).toBe(true); + expect(coreMod.deploy).toHaveBeenCalledTimes(1); + }); +}); + +// --------------------------------------------------------------------------- +// Preflight SSE surfacing (issue #156) +// --------------------------------------------------------------------------- + +describe("POST /api/deploy — preflight failure surfacing (issue #156)", () => { + it("maps a thrown DeployError(PREFLIGHT_FAILED) into structured done{success:false} errors", async () => { + process.env["DEPLOYER_PRIVATE_KEY"] = + "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + + const { DeployError } = await import("@redeploy/core"); + const coreMod = vi.mocked(await import("@redeploy/core")); + + const preflightFailures = [ + { + check: "chainId" as const, + expected: "11155111", + actual: "1", + message: + "Expected chain id 11155111 but the RPC reports chain id 1 — refusing to deploy to the wrong network", + }, + ]; + coreMod.deploy.mockRejectedValueOnce( + new DeployError( + "PREFLIGHT_FAILED", + "Preflight checks failed with 1 failure(s)", + undefined, + preflightFailures, + ), + ); + + const res = await doRequest(port, "POST", "/api/deploy", JSON.stringify(VALID_SPEC)); + expect(res.statusCode).toBe(200); + + const events = parseSse(res.body); + const doneEvent = events.find((e) => e.event === "done"); + expect(doneEvent).toBeDefined(); + const done = doneEvent!.data as Record; + expect(done["success"]).toBe(false); + + const errors = done["errors"] as Array>; + expect(errors).toHaveLength(1); + expect(errors[0]).toEqual({ + code: "PREFLIGHT_FAILED", + message: preflightFailures[0]!.message, + check: "chainId", + expected: "11155111", + actual: "1", + }); + }); + + it("maps multiple preflightFailures into multiple structured errors, one per failed check", async () => { + process.env["DEPLOYER_PRIVATE_KEY"] = + "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + + const { DeployError } = await import("@redeploy/core"); + const coreMod = vi.mocked(await import("@redeploy/core")); + + const preflightFailures = [ + { check: "chainId" as const, expected: "1", actual: "31337", message: "wrong chain" }, + { check: "balance" as const, expected: "1000", actual: "0", message: "insufficient balance" }, + ]; + coreMod.deploy.mockRejectedValueOnce( + new DeployError("PREFLIGHT_FAILED", "Preflight failed", undefined, preflightFailures), + ); + + const res = await doRequest(port, "POST", "/api/deploy", JSON.stringify(VALID_SPEC)); + const events = parseSse(res.body); + const doneEvent = events.find((e) => e.event === "done"); + const done = doneEvent!.data as Record; + expect(done["success"]).toBe(false); + const errors = done["errors"] as Array>; + expect(errors).toHaveLength(2); + expect(errors[0]!["check"]).toBe("chainId"); + expect(errors[1]!["check"]).toBe("balance"); + }); + + it("passes the network's configured preflight policy through to core.deploy()'s preflight option", async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "redeploy-preflight-config-test-")); + const savedNetworksConfig = process.env["NETWORKS_CONFIG"]; + try { + const configPath = path.join(tmpDir, "networks.json"); + fs.writeFileSync( + configPath, + JSON.stringify({ + networks: { + alpha: { + rpcUrl: "http://alpha.example.com", + deployerPrivateKey: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + deploymentDir: path.join(tmpDir, "alpha-journal"), + preflight: { expectedChainId: 11155111 }, + }, + }, + }), + "utf8", + ); + process.env["NETWORKS_CONFIG"] = configPath; + + const coreMod = vi.mocked(await import("@redeploy/core")); + + const res = await doRequest(port, "POST", "/api/deploy?network=alpha", JSON.stringify(VALID_SPEC)); + expect(res.statusCode).toBe(200); + + expect(coreMod.deploy).toHaveBeenCalledWith( + expect.objectContaining({ preflight: { expectedChainId: 11155111 } }), + ); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + if (savedNetworksConfig === undefined) { + delete process.env["NETWORKS_CONFIG"]; + } else { + process.env["NETWORKS_CONFIG"] = savedNetworksConfig; + } + } + }); + + it("omits the preflight option entirely when the network has no configured preflight policy", async () => { + process.env["DEPLOYER_PRIVATE_KEY"] = + "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + + const coreMod = vi.mocked(await import("@redeploy/core")); + + await doRequest(port, "POST", "/api/deploy", JSON.stringify(VALID_SPEC)); + + const callArgs = coreMod.deploy.mock.calls[0]![0] as Record; + expect(callArgs["preflight"]).toBeUndefined(); + }); + + it("SECURITY: preflight error fields (chain id / wei / address) never leak the deployer private key or rpcUrl", async () => { + const SENTINEL_KEY = + "0xfacefacefacefacefacefacefacefacefacefacefacefacefacefacefaceface"; + const SENTINEL_RPC = "http://secret-rpc.preflight-test.example.com"; + process.env["DEPLOYER_PRIVATE_KEY"] = SENTINEL_KEY; + process.env["RPC_URL"] = SENTINEL_RPC; + + const { DeployError } = await import("@redeploy/core"); + const coreMod = vi.mocked(await import("@redeploy/core")); + + coreMod.deploy.mockRejectedValueOnce( + new DeployError("PREFLIGHT_FAILED", "Preflight failed", undefined, [ + { + check: "balance", + expected: "1000000000000000000", + actual: "0", + message: "Deployer account 0xDeployer0000000000000000000000000000000001 has balance 0 wei", + }, + ]), + ); + + const res = await doRequest(port, "POST", "/api/deploy", JSON.stringify(VALID_SPEC)); + expect(res.body).not.toContain(SENTINEL_KEY); + expect(res.body).not.toContain(SENTINEL_RPC); + }); +}); diff --git a/apps/deploy-server/test/networks.test.ts b/apps/deploy-server/test/networks.test.ts index f84c771..612485d 100644 --- a/apps/deploy-server/test/networks.test.ts +++ b/apps/deploy-server/test/networks.test.ts @@ -240,6 +240,142 @@ describe("loadNetworksRegistry — NETWORKS_CONFIG file", () => { }); }); +// --------------------------------------------------------------------------- +// requireSimulate / preflight (issue #156) +// --------------------------------------------------------------------------- + +describe("loadNetworksRegistry — requireSimulate (issue #156)", () => { + it("parses requireSimulate: true", () => { + const configPath = writeConfigFile({ + networks: { alpha: { rpcUrl: "http://alpha.example.com", requireSimulate: true } }, + }); + const registry = loadNetworksRegistry({ env: baseEnv({ NETWORKS_CONFIG: configPath }) }); + expect(registry.networks.get("alpha")?.requireSimulate).toBe(true); + }); + + it("parses requireSimulate: false", () => { + const configPath = writeConfigFile({ + networks: { alpha: { rpcUrl: "http://alpha.example.com", requireSimulate: false } }, + }); + const registry = loadNetworksRegistry({ env: baseEnv({ NETWORKS_CONFIG: configPath }) }); + expect(registry.networks.get("alpha")?.requireSimulate).toBe(false); + }); + + it("backward compat: omitting requireSimulate leaves it undefined", () => { + const configPath = writeConfigFile({ + networks: { alpha: { rpcUrl: "http://alpha.example.com" } }, + }); + const registry = loadNetworksRegistry({ env: baseEnv({ NETWORKS_CONFIG: configPath }) }); + expect(registry.networks.get("alpha")?.requireSimulate).toBeUndefined(); + }); + + it("the legacy synthesized 'default' network never has requireSimulate set", () => { + const registry = loadNetworksRegistry({ env: baseEnv() }); + expect(registry.networks.get("default")?.requireSimulate).toBeUndefined(); + }); + + it("throws NetworksConfigError for a non-boolean requireSimulate", () => { + const configPath = writeConfigFile({ + networks: { alpha: { rpcUrl: "http://alpha.example.com", requireSimulate: "yes" } }, + }); + expect(() => loadNetworksRegistry({ env: baseEnv({ NETWORKS_CONFIG: configPath }) })).toThrow( + NetworksConfigError, + ); + }); +}); + +describe("loadNetworksRegistry — preflight policy (issue #156)", () => { + it("parses a full preflight policy object", () => { + const configPath = writeConfigFile({ + networks: { + alpha: { + rpcUrl: "http://alpha.example.com", + preflight: { + expectedChainId: 11155111, + minBalanceWei: "1000000000000000000", + maxGasPriceWei: "50000000000", + }, + }, + }, + }); + const registry = loadNetworksRegistry({ env: baseEnv({ NETWORKS_CONFIG: configPath }) }); + expect(registry.networks.get("alpha")?.preflight).toEqual({ + expectedChainId: 11155111, + minBalanceWei: "1000000000000000000", + maxGasPriceWei: "50000000000", + }); + }); + + it("parses a partial preflight policy (only one field set)", () => { + const configPath = writeConfigFile({ + networks: { + alpha: { rpcUrl: "http://alpha.example.com", preflight: { expectedChainId: 1 } }, + }, + }); + const registry = loadNetworksRegistry({ env: baseEnv({ NETWORKS_CONFIG: configPath }) }); + expect(registry.networks.get("alpha")?.preflight).toEqual({ expectedChainId: 1 }); + }); + + it("backward compat: omitting preflight leaves it undefined", () => { + const configPath = writeConfigFile({ + networks: { alpha: { rpcUrl: "http://alpha.example.com" } }, + }); + const registry = loadNetworksRegistry({ env: baseEnv({ NETWORKS_CONFIG: configPath }) }); + expect(registry.networks.get("alpha")?.preflight).toBeUndefined(); + }); + + it("throws NetworksConfigError when preflight is not an object", () => { + const configPath = writeConfigFile({ + networks: { alpha: { rpcUrl: "http://alpha.example.com", preflight: "nope" } }, + }); + expect(() => loadNetworksRegistry({ env: baseEnv({ NETWORKS_CONFIG: configPath }) })).toThrow( + NetworksConfigError, + ); + }); + + it("throws NetworksConfigError when preflight is an array", () => { + const configPath = writeConfigFile({ + networks: { alpha: { rpcUrl: "http://alpha.example.com", preflight: [] } }, + }); + expect(() => loadNetworksRegistry({ env: baseEnv({ NETWORKS_CONFIG: configPath }) })).toThrow( + NetworksConfigError, + ); + }); + + it("throws NetworksConfigError for a non-numeric preflight.expectedChainId", () => { + const configPath = writeConfigFile({ + networks: { + alpha: { rpcUrl: "http://alpha.example.com", preflight: { expectedChainId: "1" } }, + }, + }); + expect(() => loadNetworksRegistry({ env: baseEnv({ NETWORKS_CONFIG: configPath }) })).toThrow( + NetworksConfigError, + ); + }); + + it("throws NetworksConfigError for a non-string preflight.minBalanceWei", () => { + const configPath = writeConfigFile({ + networks: { + alpha: { rpcUrl: "http://alpha.example.com", preflight: { minBalanceWei: 123 } }, + }, + }); + expect(() => loadNetworksRegistry({ env: baseEnv({ NETWORKS_CONFIG: configPath }) })).toThrow( + NetworksConfigError, + ); + }); + + it("throws NetworksConfigError for a non-string preflight.maxGasPriceWei", () => { + const configPath = writeConfigFile({ + networks: { + alpha: { rpcUrl: "http://alpha.example.com", preflight: { maxGasPriceWei: 123 } }, + }, + }); + expect(() => loadNetworksRegistry({ env: baseEnv({ NETWORKS_CONFIG: configPath }) })).toThrow( + NetworksConfigError, + ); + }); +}); + // --------------------------------------------------------------------------- // loadNetworksRegistry — schema validation errors // --------------------------------------------------------------------------- diff --git a/apps/deploy-server/test/simulate-state.test.ts b/apps/deploy-server/test/simulate-state.test.ts new file mode 100644 index 0000000..8fbf3ee --- /dev/null +++ b/apps/deploy-server/test/simulate-state.test.ts @@ -0,0 +1,137 @@ +/** + * Tests for src/simulate-state.ts: persisted "has this spec been + * simulated?" state backing the simulate-first interlock (issue #156). + */ + +import { describe, it, expect, afterEach, beforeEach } from "vitest"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { recordSimulated, hasSimulated } from "../src/simulate-state.js"; + +let tmpDir: string; + +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "redeploy-simulate-state-test-")); +}); + +afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +describe("recordSimulated / hasSimulated", () => { + it("hasSimulated returns false for a hash that was never recorded", () => { + expect(hasSimulated(tmpDir, "deadbeef")).toBe(false); + }); + + it("hasSimulated returns false when deploymentDir does not exist at all", () => { + const missingDir = path.join(tmpDir, "does-not-exist"); + expect(hasSimulated(missingDir, "deadbeef")).toBe(false); + }); + + it("after recordSimulated, hasSimulated returns true for that exact hash", () => { + recordSimulated(tmpDir, "hash-a"); + expect(hasSimulated(tmpDir, "hash-a")).toBe(true); + }); + + it("hasSimulated returns false for an unrecorded hash even when others are recorded", () => { + recordSimulated(tmpDir, "hash-a"); + expect(hasSimulated(tmpDir, "hash-b")).toBe(false); + }); + + it("creates deploymentDir (mkdir -p) when it does not yet exist", () => { + const freshDir = path.join(tmpDir, "nested", "journal-dir"); + expect(fs.existsSync(freshDir)).toBe(false); + recordSimulated(freshDir, "hash-a"); + expect(fs.existsSync(freshDir)).toBe(true); + expect(hasSimulated(freshDir, "hash-a")).toBe(true); + }); + + it("recording the same hash twice does not duplicate it (idempotent)", () => { + recordSimulated(tmpDir, "hash-a"); + recordSimulated(tmpDir, "hash-a"); + + const stateFile = path.join(tmpDir, ".redeploy-simulated.json"); + const parsed = JSON.parse(fs.readFileSync(stateFile, "utf8")) as { hashes: string[] }; + expect(parsed.hashes.filter((h) => h === "hash-a")).toHaveLength(1); + }); + + it("a missing state file → hasSimulated is false (treated as no record)", () => { + // tmpDir exists but no .redeploy-simulated.json has ever been written. + expect(hasSimulated(tmpDir, "any-hash")).toBe(false); + }); + + it("a corrupt (non-JSON) state file → hasSimulated is false, never throws", () => { + const stateFile = path.join(tmpDir, ".redeploy-simulated.json"); + fs.writeFileSync(stateFile, "{ not valid json", "utf8"); + expect(() => hasSimulated(tmpDir, "any-hash")).not.toThrow(); + expect(hasSimulated(tmpDir, "any-hash")).toBe(false); + }); + + it("a state file with an unexpected JSON shape → hasSimulated is false, never throws", () => { + const stateFile = path.join(tmpDir, ".redeploy-simulated.json"); + fs.writeFileSync(stateFile, JSON.stringify({ hashes: "not-an-array" }), "utf8"); + expect(hasSimulated(tmpDir, "any-hash")).toBe(false); + + fs.writeFileSync(stateFile, JSON.stringify(["a", "b"]), "utf8"); + expect(hasSimulated(tmpDir, "a")).toBe(false); + + fs.writeFileSync(stateFile, JSON.stringify(null), "utf8"); + expect(hasSimulated(tmpDir, "any-hash")).toBe(false); + }); + + it("recordSimulated never throws even when the target path cannot be created (best-effort)", () => { + // Point deploymentDir at a path whose parent is actually a FILE, so + // mkdirSync(..., {recursive:true}) fails with ENOTDIR — recordSimulated + // must swallow this rather than throw (SUCCESS path of simulate must + // never become an error because recording failed). + const blockerFile = path.join(tmpDir, "blocker-file"); + fs.writeFileSync(blockerFile, "not a directory", "utf8"); + const impossibleDir = path.join(blockerFile, "journal"); + + expect(() => recordSimulated(impossibleDir, "hash-a")).not.toThrow(); + // And the interlock check still fails closed for that path. + expect(hasSimulated(impossibleDir, "hash-a")).toBe(false); + }); + + it("bounds growth: retains at most the most-recent N hashes", () => { + // Record more than the internal cap (50) worth of distinct hashes. + const total = 55; + for (let i = 0; i < total; i++) { + recordSimulated(tmpDir, `hash-${i}`); + } + + const stateFile = path.join(tmpDir, ".redeploy-simulated.json"); + const parsed = JSON.parse(fs.readFileSync(stateFile, "utf8")) as { hashes: string[] }; + expect(parsed.hashes.length).toBeLessThanOrEqual(50); + + // The most recently recorded hash must still be present... + expect(hasSimulated(tmpDir, `hash-${total - 1}`)).toBe(true); + // ...while the oldest ones were evicted. + expect(hasSimulated(tmpDir, "hash-0")).toBe(false); + }); + + it("re-recording an existing hash moves it to the front, protecting it from the next eviction", () => { + // Fill to exactly the cap (50), with hash-0 recorded first (so it is the + // LEAST recently used entry, i.e. next in line for eviction). + recordSimulated(tmpDir, "hash-0"); + for (let i = 1; i < 50; i++) { + recordSimulated(tmpDir, `hash-${i}`); + } + expect(hasSimulated(tmpDir, "hash-0")).toBe(true); + + // Re-record hash-0: this refreshes it to the front WITHOUT growing the + // set (it's a re-record, not a new hash), so nothing is evicted yet. + recordSimulated(tmpDir, "hash-0"); + expect(hasSimulated(tmpDir, "hash-0")).toBe(true); + expect(hasSimulated(tmpDir, "hash-1")).toBe(true); + + // Recording one brand-new hash now must evict the new least-recently-used + // entry (hash-1, which became the oldest once hash-0 was refreshed to the + // front) — NOT hash-0, which was just refreshed. + recordSimulated(tmpDir, "hash-new"); + expect(hasSimulated(tmpDir, "hash-0")).toBe(true); + expect(hasSimulated(tmpDir, "hash-new")).toBe(true); + expect(hasSimulated(tmpDir, "hash-1")).toBe(false); + }); +}); diff --git a/apps/deploy-server/test/spec-hash.test.ts b/apps/deploy-server/test/spec-hash.test.ts new file mode 100644 index 0000000..bedd932 --- /dev/null +++ b/apps/deploy-server/test/spec-hash.test.ts @@ -0,0 +1,70 @@ +/** + * Tests for src/spec-hash.ts: the stable, key-order-independent spec hash + * used to key the simulate-first interlock (see simulate-state.ts). + */ + +import { describe, it, expect } from "vitest"; +import { specHash } from "../src/spec-hash.js"; + +describe("specHash", () => { + it("produces a 64-character hex string (sha256 digest)", () => { + const hash = specHash({ version: 1, contracts: [] }); + expect(hash).toMatch(/^[0-9a-f]{64}$/); + }); + + it("is stable across reordered top-level keys", () => { + const a = { version: 1, contracts: [{ id: "token", contract: "Token" }] }; + const b = { contracts: [{ id: "token", contract: "Token" }], version: 1 }; + expect(specHash(a)).toBe(specHash(b)); + }); + + it("is stable across reordered nested object keys", () => { + const a = { + version: 1, + contracts: [{ id: "token", contract: "Token", args: [] }], + }; + const b = { + version: 1, + contracts: [{ contract: "Token", args: [], id: "token" }], + }; + expect(specHash(a)).toBe(specHash(b)); + }); + + it("is stable regardless of deeply nested key order", () => { + const a = { a: { b: { c: 1, d: 2 }, e: 3 } }; + const b = { a: { e: 3, b: { d: 2, c: 1 } } }; + expect(specHash(a)).toBe(specHash(b)); + }); + + it("preserves array element ORDER as semantically meaningful (different order → different hash)", () => { + const a = { contracts: ["token", "vault"] }; + const b = { contracts: ["vault", "token"] }; + expect(specHash(a)).not.toBe(specHash(b)); + }); + + it("produces different hashes for structurally different specs", () => { + const a = { version: 1, contracts: [{ id: "token", contract: "Token" }] }; + const b = { version: 1, contracts: [{ id: "vault", contract: "Vault" }] }; + expect(specHash(a)).not.toBe(specHash(b)); + }); + + it("produces different hashes for a changed value on the same key", () => { + const a = { version: 1 }; + const b = { version: 2 }; + expect(specHash(a)).not.toBe(specHash(b)); + }); + + it("is deterministic across repeated calls with the same input", () => { + const spec = { version: 1, contracts: [{ id: "token", contract: "Token" }] }; + expect(specHash(spec)).toBe(specHash(spec)); + expect(specHash(spec)).toBe(specHash(JSON.parse(JSON.stringify(spec)))); + }); + + it("handles primitives, null, and arrays of objects", () => { + expect(() => specHash(null)).not.toThrow(); + expect(() => specHash(42)).not.toThrow(); + expect(() => specHash("a string")).not.toThrow(); + expect(() => specHash([{ b: 1, a: 2 }, { d: 3, c: 4 }])).not.toThrow(); + expect(specHash([{ b: 1, a: 2 }])).toBe(specHash([{ a: 2, b: 1 }])); + }); +});