From b54bac380fb412791e8a988f69f6367c9f6b2a0d Mon Sep 17 00:00:00 2001 From: 0xisk <0xisk@proton.me> Date: Mon, 22 Jun 2026 23:18:30 +0200 Subject: [PATCH 1/4] feat(simulator): backend-aware createSimulator Make the per-module simulator runnable against a live Midnight node as well as the in-memory path, selected by MIDNIGHT_BACKEND=dry|live at construction. One factory (createSimulator) now returns an async, backend-aware class; circuits return promises so a single spec file runs on both backends. * core: a Backend seam with DryBackend (a thin async facade over the existing synchronous engine, now exposed internally as createDrySimulator) and a dynamically-imported LiveBackend adapter over an injected LiveContext; an AsyncCircuits<> mapped type for the proxies. * live: a createLiveContext assembler (per-alias handle cache + bounded indexer-lag reads) and a registerLiveBackend/isLiveBackend registry, so a test:live setup wires the harness once and specs stay backend-agnostic. * signers: alias resolver with deterministic dry keys and a 4-signer live cap. * dependency wall: every @midnight-ntwrk/midnight-js import is confined to src/live/ and reached only via dynamic import, so a dry import resolves zero midnight-js (guarded by a test); they are declared optional peers. BREAKING CHANGE: createSimulator is now async. Construct with `await Sim.create(args, options)` and await circuits and state getters. The previous synchronous surface is replaced; the in-memory engine remains available internally as createDrySimulator. --- .../simulator/docs/code/live-backend-code.md | 234 +++++++ .../docs/design/live-backend-invariants.md | 646 ++++++++++++++++++ .../simulator/docs/design/live-backend.md | 335 +++++++++ packages/simulator/package.json | 17 +- packages/simulator/src/backend/Backend.ts | 117 ++++ packages/simulator/src/backend/DryBackend.ts | 129 ++++ .../simulator/src/factory/SimulatorConfig.ts | 7 + .../src/factory/createDrySimulator.ts | 199 ++++++ .../simulator/src/factory/createSimulator.ts | 363 ++++++---- packages/simulator/src/index.ts | 58 +- packages/simulator/src/live/LiveBackend.ts | 173 +++++ packages/simulator/src/live/LiveContext.ts | 69 ++ .../simulator/src/live/createLiveContext.ts | Bin 0 -> 6651 bytes packages/simulator/src/live/registry.ts | 68 ++ packages/simulator/src/signers/Signers.ts | 173 +++++ packages/simulator/src/types/Circuit.ts | 19 + packages/simulator/src/types/Options.ts | 29 + packages/simulator/src/types/index.ts | 1 + .../test/integration/SampleZOwnable.test.ts | 189 ++--- .../integration/SampleZOwnableSimulator.ts | 52 +- .../simulator/test/integration/Simple.test.ts | 10 +- .../test/integration/SimpleSimulator.ts | 17 +- .../test/integration/Witness.test.ts | 157 +++-- .../test/integration/WitnessSimulator.ts | 48 +- .../test/unit/LiveBackendAdapter.test.ts | 137 ++++ packages/simulator/test/unit/Signers.test.ts | 66 ++ .../test/unit/dependency-wall.test.ts | 54 ++ 27 files changed, 3010 insertions(+), 357 deletions(-) create mode 100644 packages/simulator/docs/code/live-backend-code.md create mode 100644 packages/simulator/docs/design/live-backend-invariants.md create mode 100644 packages/simulator/docs/design/live-backend.md create mode 100644 packages/simulator/src/backend/Backend.ts create mode 100644 packages/simulator/src/backend/DryBackend.ts create mode 100644 packages/simulator/src/factory/createDrySimulator.ts create mode 100644 packages/simulator/src/live/LiveBackend.ts create mode 100644 packages/simulator/src/live/LiveContext.ts create mode 100644 packages/simulator/src/live/createLiveContext.ts create mode 100644 packages/simulator/src/live/registry.ts create mode 100644 packages/simulator/src/signers/Signers.ts create mode 100644 packages/simulator/test/unit/LiveBackendAdapter.test.ts create mode 100644 packages/simulator/test/unit/Signers.test.ts create mode 100644 packages/simulator/test/unit/dependency-wall.test.ts diff --git a/packages/simulator/docs/code/live-backend-code.md b/packages/simulator/docs/code/live-backend-code.md new file mode 100644 index 0000000..44b98d4 --- /dev/null +++ b/packages/simulator/docs/code/live-backend-code.md @@ -0,0 +1,234 @@ +--- +stage: code +project: simulator-live-backend +mode: extension +extends: packages/simulator +status: draft +timestamp: 2026-06-22 +author: 0xisk +previous_stage: packages/simulator/docs/design/live-backend-invariants.md +tags: [simulator, testing, live-backend, midnight-js, async, tooling, parity] +--- + +# Simulator Live-Mode Backend — Code Draft + +> Tooling code draft (a TypeScript test harness, not a Compact contract). The +> skill's contract-centric review (disclosure sites, commitment hygiene, `pure` +> discipline) is re-mapped to tooling concerns: the **Parity Mechanisms** table +> replaces Disclosure Sites, and the dependency wall replaces commitment hygiene. + +## Revision: unified single-factory API (supersedes the dual-factory design below) + +After reviewing the first draft, the dev rejected the dual-factory / duplicate-file +ergonomics. The API was unified per their direction; the engine below is unchanged, +only its surface: + +- **One factory.** `createSimulator` IS the async, backend-aware factory (no separate + `createBackendSimulator`). This is a **breaking change** to the old synchronous + `createSimulator` — every consumer migrates to `await Sim.create()` + `await`ed + circuits. The dev accepted this ("change the current unit tests into async/await"). + The old synchronous logic survives as an internal primitive, `createDrySimulator` + (not exported), which the dry backend wraps and the live backend uses for local + pure-circuit eval. +- **No twin files.** Per module there is exactly one simulator file and one test file, + migrated in place to async. `*Backend.ts` twins were removed. +- **Backend selection by env var, two commands.** `MIDNIGHT_BACKEND=dry|live` read once + at `create()` (INV-8). `test` = dry, `test:live` = live (boots infra + registers the + harness). Specs guard the documented asymmetries with `isLiveBackend()` + (e.g. `it.skipIf(isLiveBackend())(...)`). +- **Global live registration.** `registerLiveBackend(factory)` (called once in the + `test:live` setup) lets `await Sim.create()` stay byte-identical on both backends; + the live world is resolved from the registry (or an explicit `{ live }`). New file: + `src/live/registry.ts` (`registerLiveBackend` / `getRegisteredLiveBackend` / + `clearLiveBackend` / `isLiveBackend`, `LiveBackendFactory` / `LiveBackendRequest`). +- **Private-state mutation + witness read** added to the seam: `setPrivateState` (dry + mutates, live throws — INV-18), `getWitnesses` + a `witnesses` get/set on the class + (so `sim.witnesses = {...}` keeps working). Options type renamed + `BackendSimulatorOptions` → `SimulatorOptions`. + +**Validation of the unified API (dry, this revision):** +- Simulator package own suite: **73/73 green** (Simple, Witness, SampleZOwnable, plus + LiveBackend-adapter / Signers / dependency-wall unit tests), all migrated to async. +- Dependency wall re-verified at build output: `dist/index.js` has **0** real + `@midnight-ntwrk/midnight-js` imports. +- Real consumer (`compact-contracts`, fresh copy): swapped the built simulator in and + ran its suite — **1154/1154** previously-passing unit tests unchanged (the 53 reds are + pre-existing WIP-branch failures, identical with the stock simulator). Migrated the + `security` module **in place** (Pausable + Initializable, same files, async): + **19/19 dry green**, matching the pre-migration baseline exactly. `MIDNIGHT_BACKEND=live` + confirmed to flip the backend and emit the actionable "register a live backend" error + (no node available here). + +The sections below describe the original dual-factory draft and remain accurate for the +engine (Backend seam, Dry/Live backends, Signers, createLiveContext, parity invariants); +read "`createBackendSimulator`" as "`createSimulator`" and "additive / INV-19 sync path +preserved" as "superseded by the unified breaking API" throughout. + +## Summary + +Adds a backend-aware path alongside the existing synchronous `createSimulator`, +selected by `MIDNIGHT_BACKEND=dry|live` at construction. The new +`createBackendSimulator` builds async circuit proxies over a small `Backend` +seam with two implementations: `DryBackend` (a thin async facade over the +unchanged synchronous simulator) and `LiveBackend` (a pure adapter over an +injected `LiveContext`). midnight-js is confined to `src/live/` and reached only +through dynamic imports, so a dry import pulls zero midnight-js — verified at the +build output (`dist/index.js` has no midnight-js references; `createLiveContext.js` +reaches it solely via `await import(...)`). All existing files keep working +byte-for-byte; the change is additive (the full pre-existing dry suite passes as +the INV-19 regression gate). + +The two invariants flagged as most likely wrong (INV-13 result shape, INV-17 +alias resolution) were **pinned against the installed midnight-js 4.1.0 types**, +not assumptions: the default `callTx[name](...)` returns `FinalizedCallTxData` +whose circuit result is at `.private.result`, and the dry alias derivation +reproduces the existing harness's `toHexPadded(label)`. + +## Files + +| File | Purpose | Lines | Status | +|------|---------|-------|--------| +| `src/backend/Backend.ts` | The `Backend` seam (the operations that differ dry vs live) | ~110 | New | +| `src/backend/DryBackend.ts` | Async facade over the existing synchronous simulator | ~110 | New | +| `src/factory/createBackendSimulator.ts` | Backend-aware factory: env resolve, async proxies, dynamic live import | ~280 | New | +| `src/live/LiveContext.ts` | Structural injection seam (`LiveContext`, `DeployedTxHandle`) | ~75 | New | +| `src/live/LiveBackend.ts` | Pure live adapter (no midnight-js); result/message normalization | ~165 | New | +| `src/live/createLiveContext.ts` | Optional assembler: per-alias handle cache + bounded indexer-lag reads | ~190 | New | +| `src/signers/Signers.ts` | Alias resolver (dry derivation, live cap) | ~190 | New | +| `src/types/Circuit.ts` | Added `AsyncCircuits<>` type (additive) | +20 | Modified | +| `src/types/index.ts` | Re-export `AsyncCircuits` (additive) | +1 | Modified | +| `src/index.ts` | Additive barrel exports (type-only `LiveBackend`) | +30 | Modified | +| `package.json` | Optional peer deps, `test:live` script, dev deps | +20 | Modified | +| `test/integration/SimpleBackend.ts` | Backend-aware simulator (migration template, OQ5) | ~60 | New | +| `test/integration/SimpleBackend.test.ts` | Dry + fake-live parity spec | ~150 | New | +| `test/unit/LiveBackendAdapter.test.ts` | Focused live-adapter unit tests (no node) | ~135 | New | +| `test/unit/Signers.test.ts` | Dry derivation + live cap | ~75 | New | +| `test/unit/dependency-wall.test.ts` | INV-1/INV-2 source guard (OQ6) | ~55 | New | + +Verification: `tsc --noEmit` clean, `biome check` clean, `tsc -p .` (build) clean, +**82/82 tests pass** (existing suite + new). + +## Invariant Enforcement Map + +| Invariant | Enforcement location | Mechanism | Tested | +|-----------|----------------------|-----------|--------| +| INV-1 type-only `LiveBackend` | `src/index.ts`; `createBackendSimulator` dynamic import | `export type { LiveBackend }` + `await import('../live/LiveBackend.js')` | `dependency-wall.test.ts` | +| INV-2 dry graph midnight-js-free | whole graph; `src/live/` confinement | type-only + dynamic imports; verified `dist/index.js` 0 refs | `dependency-wall.test.ts` (source scan) | +| INV-3 optional peer deps | `package.json` | `peerDependenciesMeta.*.optional = true` | — (install-matrix not yet) | +| INV-4 async transform | `AsyncCircuits<>`; `DryBackend.call` | mapped type returns `Promise`; dry wraps in `Promise.resolve` | `SimpleBackend.test.ts` | +| INV-5 shared `SimulatorConfig` | `createBackendSimulator` | consumes the same config; no forked struct | `SimpleBackend.ts` reuses config | +| INV-6 friendly missing-deps error | `createLiveContext.loadFindDeployedContract` | `try/catch` around dynamic import → wrapped message | — (cannot uninstall peers in suite) | +| INV-7 witness override rejected (live) | `LiveBackend.overrideWitness/setWitnesses` | explicit throw `"witness override unsupported on live backend"` | adapter + integration | +| INV-8 backend fixed at construction | `resolveBackendKind` (read once); `backendKind` readonly | no runtime toggle/setter | `SimpleBackend.test.ts` | +| INV-9 isolation (dry fresh / live shared) | convention; migration template comment | documented; specs authored order-independent | — (convention, not code) | +| INV-10 live attaches; dry deploys | `LiveBackend` (no deploy); `LiveContext.contractAddress` | live uses injected address; local sim is in-memory pure-eval only | implicit | +| INV-11 bounded indexer lag | `createLiveContext.queryLedger` | finite retry + capped backoff; throws on exhaustion | — (needs live node; defaults provisional) | +| INV-12 observable-outcome parity | sum of INV-13..18 + lifecycle | dry + fake-live produce same outcomes | `SimpleBackend.test.ts` (both backends) | +| INV-13 result-shape normalization | `LiveBackend.call` (impure) | returns `.private.result` (pinned vs midnight-js 4.1.0) | adapter + integration | +| INV-14 assertion-message parity | `LiveBackend.call` (impure) | awaits directly; never catches/rewrites the rejection | adapter (substring match) | +| INV-15 public-state read parity | `DryBackend`/`LiveBackend.getPublicState` | same `config.ledgerExtractor` over both sources | adapter + integration | +| INV-16 pure-circuit locality | `LiveBackend.call` (pure) → local `pureSim` | pure runs on the JS artifact in both modes | adapter (no handle requested) | +| INV-17 caller-identity parity | `Signers` + `setCaller` lifecycle (both backends) | alias derivation; `single`/`persistent` mirror dry | `Signers.test.ts` + adapter + integration | +| INV-18 private-state read parity | `LiveBackend.getPrivateState` via provider | read parity; mutation documented best-effort/dry-only | adapter (read) | +| INV-19 existing sync path unchanged | all existing `core/*`, `factory/createSimulator`, etc. | additive-only; no runtime logic touched | full existing suite (regression gate) | +| INV-20 public API additive | `src/index.ts` | only new exports; nothing removed/renamed | existing suite + barrel diff | +| INV-21 live signer cap | `Signers` (cap 4) + `assertLiveAliasAllowed` | throws on overflow / out-of-pool; never reuses | `Signers.test.ts` + adapter + integration | +| INV-22 caller owns live infra | `src/live/` boundary; `createLiveContext` inputs | helper only assembles provided pieces; ships no infra | by construction | +| INV-23 parity CI-gated | `package.json` `test:live` script | script present; CI job wiring is a follow-up | — (CI not wired) | + +## Parity Mechanisms (tooling analog of Disclosure Sites) + +| Surface | Dry | Live | Made-parity-by | +|---------|-----|------|----------------| +| Impure result | proxy returns `R` | `callTx(...).private.result` | INV-13 normalization in `LiveBackend.call` | +| Assert failure | sync `throw "msg"` | `callTx` rejects, message preserved | INV-14: await without catch/rewrite | +| Public state | `ledgerExtractor(ctx state)` | `ledgerExtractor(await queryLedger())` | INV-15: single shared extractor | +| Pure circuit | local JS | local JS (`pureSim`) | INV-16: same local path both modes | +| Caller `as('OWNER')` | deterministic key | pooled wallet (capped) | INV-17 derivation + INV-21 cap | +| Witness override | recreate contract | **hard error** | INV-7 (documented asymmetry) | +| Private-state mutation | context update | best-effort / dry-only | INV-18 (documented asymmetry) | + +## Implementation Notes + +- **Dependency wall is stronger than the design assumed.** By making `LiveContext` + a structural injection seam, the live **adapter** (`LiveBackend`) needs no + midnight-js at all — only the optional **assembler** (`createLiveContext`) does, + and it reaches it via a single lazy `await import('@midnight-ntwrk/midnight-js-contracts')`. + Result: every static graph in the package is midnight-js-free; the only runtime + edge is that one dynamic import, fired only when `createLiveContext()` is called. +- **OQ1 resolved (INV-13).** Verified against installed midnight-js 4.1.0: + `CircuitCallTxInterface[name](...args): Promise`, and + `FinalizedCallTxData = CallResult & { private: UnsubmittedTxData } & { public: FinalizedTxData }`, + so the circuit return value is at `.private.result` and `.public` is tx framing + (not contract state — hence public state must come from the indexer). +- **OQ4 partially resolved (INV-17).** Dry derivation reproduces the existing + harness's `toHexPadded(label)` so migrated specs resolve identical keys. Live + alias→wallet resolution is caller-supplied (`resolveLiveKey`); alignment with + prefunded seeds remains the harness's job (INV-22). +- **Pure-eval in live.** `createBackendSimulator` always builds the synchronous + simulator (`localSim`); dry uses it for everything, live uses it only for pure + circuits. In live this runs `initialState` **in memory only** — never an + on-chain deploy (INV-10). Pure circuits are state/caller-independent, so the + seed is irrelevant to results. +- **`super.create` binding.** Per-module subclasses override the static `create` + and call `super.create(...)` to keep `this` bound to the subclass. Biome's + `noThisInStatic` would rewrite this to the base-class name and silently break + it; suppressed with a targeted `biome-ignore` and a comment. + +## Deviations from upstream (propose sync — Y/N) + +These deviate from the design/invariants prose. None applied to those docs yet: + +1. **`./live` subpath dropped; `createLiveContext` exported from the main barrel.** + Per dev instruction (no sub-directory barrels). Wall is preserved (verified). + → Propose updating INV-1/INV-20 and the design's Package Layout to drop the + `./live` subpath. **[Y/N]** +2. **INV-6 friendly error lives in `createLiveContext`, not the `LiveBackend` + dynamic-import site.** In this architecture `LiveBackend` has no heavy deps, so + the genuine missing-deps boundary is `createLiveContext`'s dynamic import. + → Propose noting this enforcement-site refinement in INV-6. **[Y/N]** +3. **`LiveContext` simplified to `LiveContext

`.** `C` and `L` are erased + into structural types (`DeployedTxHandle`) and the shared `ledgerExtractor`, so + a harness implements it without midnight-js generics. + → Propose updating the design's `LiveContext` signature. **[Y/N]** + +## Out of Scope + +- **INV-23 CI job.** The `test:live` script exists; wiring the dedicated live CI + job (node infra) is a follow-up, not in this code draft. +- **INV-3 install matrix.** No automated "install without optional peers, run dry + green" test yet. +- **INV-6 error test.** Not exercised (can't uninstall the peers within the suite). +- **Real-node validation.** INV-11/12/13/14/15/17/18 are covered by dry + a + deterministic fake `LiveContext`; none are validated against a live node. +- **INV-15 StateValue nesting on live.** `queryLedger` assumes `ContractState.data` + is the `StateValue` the extractor consumes; needs confirmation against a node. +- **OQ3 private-state mutation on live.** Not implemented; the backend path exposes + no private-state setter. Mutation/injection tests stay dry-only (existing + per-module `privateState` helpers). +- **Codemod (OQ5).** Hand-migrated `SimpleBackend` as the template; no codemod. +- **SampleZOwnable migration.** Left dry-only (its `injectSecretNonce` is the + INV-18 mutation asymmetry); not migrated in this pass. + +## Dev Notes + +- The fake-`LiveContext` tests (`SimpleBackend.test.ts` live block, + `LiveBackendAdapter.test.ts`) deterministically exercise the live adapter's + parity logic — result normalization, assert-message propagation, caller + lifecycle, signer cap, witness-override rejection — without a node. They are the + fast floor under INV-12; the live CI job (INV-23) is the real ceiling. +- `package.json` `test` now sets `MIDNIGHT_BACKEND=dry` explicitly (equivalent to + unset). `test:live` flips it. Same spec files, per the design. + +## Open Questions + +1. **OQ2 indexer-lag budget** — defaults are `retries: 8, baseDelayMs: 150, + maxDelayMs: 2000`; tune against a real node. +2. **OQ3 private-state mutation policy on live** — left unimplemented; confirm the + dry-only policy and whether any provider-`set` path is wanted. +3. **OQ6 CI guard** — implemented as a source-scan test; decide whether to also add + bundle/dependency-graph analysis. +4. **INV-15 live StateValue** — confirm `ContractState.data` is the right input to + `ledgerExtractor` against a node. +5. **Live signer wiring** — `resolveLiveKey` + `providersFor` are caller callbacks; + confirm the harness shape against `OpenZeppelin/compact-contracts#489`. diff --git a/packages/simulator/docs/design/live-backend-invariants.md b/packages/simulator/docs/design/live-backend-invariants.md new file mode 100644 index 0000000..39f4795 --- /dev/null +++ b/packages/simulator/docs/design/live-backend-invariants.md @@ -0,0 +1,646 @@ +--- +stage: invariants +project: simulator-live-backend +mode: extension +extends: packages/simulator +status: draft +timestamp: 2026-06-22 +author: 0xisk +previous_stage: packages/simulator/docs/design/live-backend.md +tags: [simulator, testing, live-backend, midnight-js, async, tooling, parity] +--- + +# Simulator Live-Mode Backend — Invariants + +> This is a **tooling** invariants pass (a TypeScript test harness), not a Compact +> contract. As the design re-mapped the contract-centric skill sections to tooling +> concerns, the invariant categories are re-mapped too (see table below). The +> distinctive surface here is **dry↔live parity**: it replaces Privacy & Disclosure +> as the central, largest category. The tooling analog of a privacy leak is a +> **false test result** — a green test on one backend that does not mean what a +> green test on the other backend means. + +## Summary + +The invariants protect three promises the design makes: (1) the **dependency wall** — +a dry import pulls zero midnight-js; (2) **dry↔live parity** — the same spec produces +the same pass/fail outcome on both backends, modulo a small set of explicitly-listed +asymmetries; (3) **additive compatibility** — every un-migrated module and the sync +`createSimulator` path keep working byte-for-byte. The central invariant is INV-12 +(observable-outcome parity); most other parity invariants exist to make it true, and +the four asymmetries that cannot be made parity (witness override, test isolation, +private-state mutation, signer cap) are pinned as hard guards or documented exceptions +rather than left implicit. INV-23 keeps parity honest over time by gating it in CI. + +Category re-map (mirrors the design's section table): + +| Skill category (contracts) | Re-mapped to (tooling) | +|---|---| +| Type-level / circuit-shape | Compile-time / type & dependency-graph (TS, lint, CI guards) | +| Runtime (`assert`) | Runtime guards (throws, dynamic-import errors) | +| State transition | State & test-lifecycle (deploy-once-shared, indexer lag) | +| Privacy & disclosure | **Dry↔Live parity** (central, largest) | +| Authorization & replay | Isolation & compatibility (dep wall, additive API, signer cap) | + +"Violation scenario" reframing for this domain: where a contract invariant asks "what +becomes publicly deducible," a parity invariant asks **"what false test result becomes +possible"** — a passing test that verifies nothing, or a failing test that flags +nothing real. + +## Compile-Time / Type & Dependency-Graph Invariants + +### INV-1: Type-only `LiveBackend` re-export + +**Category:** Compile-time / type & dependency-graph + +**Statement:** `src/index.ts` exposes `LiveBackend` only via `export type { LiveBackend }`. +The constructable *value* is reachable solely through `createBackendSimulator`'s runtime +`await import('./live/LiveBackend.js')`. The barrel never contains a value re-export +(`export { LiveBackend }`). + +**Applies to:** `src/index.ts`. + +**Enforcement mechanism:** +- Compiler: `export type` erases at build — no runtime edge from the barrel to `src/live/`. +- Test: lint rule forbidding a value re-export of `LiveBackend` from the barrel; the + INV-2 dep-graph guard catches any regression that reintroduces the edge. + +**Violation scenario:** One stray `export { LiveBackend }` statically links `src/live/` — +and therefore the entire midnight-js stack — into every dry import. Leanness is silently +lost; no test fails unless the INV-2 guard exists. + +**Severity:** Critical + +--- + +### INV-2: Dry import graph free of midnight-js + +**Category:** Compile-time / type & dependency-graph + +**Statement:** Any import reachable without `MIDNIGHT_BACKEND=live` (the barrel, +`createBackendSimulator`, `Backend`, `DryBackend`, `Signers`, `createSimulator`) resolves +zero `@midnight-ntwrk/midnight-js-*` modules. midnight-js loads only via the runtime +dynamic import inside `createBackendSimulator` when `MIDNIGHT_BACKEND=live`. All +midnight-js imports are physically confined to `src/live/`. + +**Applies to:** whole-package dependency graph; `src/live/*` is the sole midnight-js importer. + +**Enforcement mechanism:** +- Test: CI guard — a dependency-graph test or bundle analysis asserting a dry entry point + resolves no midnight-js module (exact mechanism is Open Question 6). + +**Violation scenario:** Dry-only consumers pay the install, bundle, and cold-start cost of +a heavy stack they never use. The core value proposition of the design breaks silently — +nothing surfaces until a consumer notices the bloat. + +**Severity:** Critical + +--- + +### INV-3: midnight-js declared as optional peer dependencies + +**Category:** Compile-time / type & dependency-graph + +**Statement:** Every `@midnight-ntwrk/midnight-js-*` dependency is an optional peer +dependency (`peerDependencies` + `peerDependenciesMeta.*.optional = true`). Dry-only +consumers install and run the package without them present. + +**Applies to:** `package.json`. + +**Enforcement mechanism:** +- Test: CI install matrix — install without the optional peers, run the dry suite green. + +**Violation scenario:** Dry consumers are forced to install the heavy stack (or install +fails), defeating leanness at the install boundary even if the runtime graph (INV-2) is clean. + +**Severity:** High + +--- + +### INV-4: Async circuit signature transform + +**Category:** Compile-time / type & dependency-graph + +**Statement:** `createBackendSimulator` exposes every circuit `K` as +`(...args: Args) => Promise>` (the `AsyncCircuits<>` mapped type). `DryBackend` +wraps its synchronous result in `Promise.resolve`; live awaits the network. Spec code is +uniform `await` across both backends. + +**Applies to:** `AsyncCircuits<>`, `createBackendSimulator` proxies, `DryBackend.call`. + +**Enforcement mechanism:** +- Compiler: the `AsyncCircuits<>` mapped type forces `Promise`-returning signatures. +- Runtime check: `DryBackend.call` returns `Promise.resolve(syncResult)` so a circuit never + returns a bare value on one backend and a `Promise` on the other. + +**Violation scenario:** A circuit returning a bare value on dry but a `Promise` on live makes +`await` behave differently per backend — parity broken at the type level before any test runs. + +**Severity:** High + +--- + +### INV-5: Shared `SimulatorConfig` across both factories + +**Category:** Compile-time / type & dependency-graph + +**Statement:** `createSimulator` and `createBackendSimulator` consume the identical +`SimulatorConfig` (same `contractFactory`, `defaultPrivateState`, +`contractArgs`, `ledgerExtractor`, `witnessesFactory`). The live path reuses `ledgerExtractor` +to turn indexer state into `L`. + +**Applies to:** `src/factory/SimulatorConfig.ts` (unchanged), both factories. + +**Enforcement mechanism:** +- Compiler: single shared type; no forked config struct. + +**Violation scenario:** Config drift between factories means a module's config describes a +different contract in dry vs live — a maintenance and parity hazard that compounds per module. + +**Severity:** Medium + +## Runtime Guard Invariants + +### INV-6: Clear missing-deps error in live mode + +**Category:** Runtime guard + +**Statement:** `MIDNIGHT_BACKEND=live` with midnight-js not installed → the dynamic import +fails with a message naming the missing package and the fix (`"install @midnight-ntwrk/… to +use live mode"`), not a raw `ERR_MODULE_NOT_FOUND`. + +**Applies to:** `createBackendSimulator` dynamic-import site. + +**Enforcement mechanism:** +- Runtime check: `try/catch` around `await import('./live/LiveBackend.js')`, rethrowing a wrapped error. +- Test: stub a resolution failure; assert the friendly message. + +**Violation scenario:** A cryptic module-not-found leads devs to think the package is broken +rather than that they opted into live without the optional peers. DX only, not a safety break. + +**Severity:** Medium + +--- + +### INV-7: Witness override rejected on live + +**Category:** Runtime guard + +**Statement:** On the live backend, `overrideWitness(...)` and the `witnesses` setter throw +`"witness override unsupported on live backend"`. Witnesses bind at deploy and cannot be +swapped mid-test. Dry continues to support both. + +**Applies to:** `overrideWitness`, `set witnesses` (live backend path). + +**Enforcement mechanism:** +- Runtime check: explicit throw in the live path before any state mutation. +- Test: call `overrideWitness` on a live sim → rejects with the exact message substring. + +**Violation scenario:** A silent no-op lets a witness-injection test that *intends* to swap a +witness run against the unchanged deployed witness — a false green on a privacy/auth-critical +path. A developer reads "witness-rejection test passes" and ships a contract whose witness check +was never actually exercised. The throw is the entire mitigation, so the consequence is Critical; +the design's dry-only routing reduces likelihood, not impact. + +**Severity:** Critical + +--- + +### INV-8: Backend selection is construction-time and fixed + +**Category:** Runtime guard + +**Statement:** `MIDNIGHT_BACKEND` is read once at fixture/simulator construction. A constructed +simulator does not change backends over its lifetime; there is no runtime backend toggle. + +**Applies to:** `createBackendSimulator` / `Module.create`. + +**Enforcement mechanism:** +- Runtime check: the backend is resolved at `create()`; no setter is exposed. + +**Violation scenario:** A mid-life backend switch would split state across two worlds (in-memory +vs on-chain), producing an incoherent test whose result means nothing. + +**Severity:** Medium + +## State & Test-Lifecycle Invariants + +### INV-9: Test-isolation model (dry fresh vs live shared) + +**Category:** State & test-lifecycle + +**Statement:** Dry yields pristine state per `create()` (`beforeEach`). Live's default is +deploy-once-shared (`beforeAll`), with state accumulating across tests; redeploy-per-test is +opt-in at minutes-per-suite cost (D3). A spec meant to run on both must not assume +`beforeEach`-fresh state unless it uses redeploy-per-test. + +**Applies to:** `create()`, suite lifecycle, D3. + +**Enforcement mechanism:** +- Convention + the opt-in redeploy switch; the isolation mode is documented per migrated module. +- Test: migrated specs are authored order-independent (or marked redeploy-per-test). + +**Violation scenario:** A spec relying on fresh-per-test state passes on dry but sees accumulated +state on live (order-dependent flake or false result). This is the defining lifecycle parity hazard +and the one most likely to bite a mechanical migration. + +**Severity:** High + +--- + +### INV-10: Live attaches; dry deploys + +**Category:** State & test-lifecycle + +**Statement:** In dry, `create(args)` deploys from `contractArgs` → fresh state. In live, the +caller deploys (via `LiveContext`); `create()` attaches to that instance and never deploys from args. + +**Applies to:** `create()`, `DryBackend` vs `LiveBackend` construction (D3). + +**Enforcement mechanism:** +- Runtime: `DryBackend` runs `initialState`; `LiveBackend.contractAddress` comes from `LiveContext`; + `LiveBackend` performs no deploy. + +**Violation scenario:** A double-deploy or arg-driven deploy on live diverges the address/state from +what the harness set up — subsequent reads target the wrong instance. + +**Severity:** Medium + +--- + +### INV-11: Bounded indexer-lag absorption + +**Category:** State & test-lifecycle + +**Statement:** The live public-state read path (`queryLedger`) polls/retries within a **bounded** +budget (finite count + backoff + ceiling) to absorb indexer block-lag after a `callTx` resolves, so +a read-after-write assertion is stable. The budget is never an unbounded wait. + +**Applies to:** `createLiveContext.queryLedger`, `LiveBackend.getPublicState`. + +**Enforcement mechanism:** +- Runtime: retry loop with explicit count/backoff/ceiling (concrete numbers are Open Question 2). + +**Violation scenario:** Too short → read-after-write flakes (false failure). Unbounded → a genuinely +missing write hangs the suite instead of failing it. Both destroy the test's meaning in opposite directions. + +**Severity:** High + +## Dry↔Live Parity Invariants + +> The central category — the tooling analog of Privacy & Disclosure. INV-12 is the umbrella +> promise; INV-13..INV-18 are the mechanisms that make it true or the documented asymmetries +> that bound it. + +### INV-12: Observable-outcome parity (umbrella) + +**Category:** Dry↔Live parity + +**Statement:** For any spec authored against `createBackendSimulator`, each assertion's pass/fail +outcome is the same under `MIDNIGHT_BACKEND=dry` and `=live`, **modulo the four explicitly-listed +asymmetries**: (1) witness override (INV-7, hard-errors on live); (2) test isolation and +constructor-arg effect (INV-9 + INV-10, live shares one deploy); (3) private-state mutation (INV-18, +best-effort on live); (4) signer cap (INV-21, live caps at 4 aliases while dry is unlimited). Outside +those four, a green dry test means the same thing as a green live test. **This list is closed** — any +divergence not on it is a bug, not a new asymmetry to document after the fact. + +**Applies to:** every public operation; the design's central promise. + +**Enforcement mechanism:** +- The sum of INV-13..INV-18 plus the lifecycle invariants makes outcomes identical. +- Gated by INV-23: the same spec runs on both backends in CI; divergence outside the four asymmetries is a failure. + +**Violation scenario:** Divergence makes a passing test meaningless — the tooling analog of a privacy +leak. A dev trusts a dry-green suite and ships a contract that behaves differently against a real node +(or abandons a correct change because live falsely reds). + +**Severity:** Critical + +--- + +### INV-13: Result-shape parity (normalization to bare `R`) + +**Category:** Dry↔Live parity + +**Statement:** `LiveBackend.call('impure', name, args)` normalizes `callTx`'s +`{ public, private: { result } }` to the bare `R` that `DryBackend` returns. Impure reads (e.g. +`owner()`) surface the same `R` shape in both modes. After normalization, an assertion on the return +value is identical across backends. + +**Applies to:** `LiveBackend.call` (impure path), impure reads. + +**Enforcement mechanism:** +- Runtime: normalization in `LiveBackend.call`, pinned against the installed + `@midnight-ntwrk/midnight-js-contracts` `callTx` return type (Open Question 1 — must be verified + before this is final). +- Test: a parity assertion comparing the dry and live return values for the same call. + +**Violation scenario:** A spec must branch on backend to read a result (parity broken), or an +assertion silently reads the wrong nested field and produces a false pass/fail. + +**Severity:** Critical + +--- + +### INV-14: Assertion-message parity + +**Category:** Dry↔Live parity + +**Statement:** A contract `assert` failure surfaces on both backends so that +`await expect(p).rejects.toThrow('Foo: msg')` matches by **substring**. `LiveBackend` must not swallow +or rewrite the contract assert message when normalizing the rejection; live may add surrounding +proof/tx framing, so specs match on a substring, never an exact full string. + +**Applies to:** failure path of every impure call; `LiveBackend` rejection normalization. + +**Enforcement mechanism:** +- Runtime: `LiveBackend` preserves the underlying message in the thrown/rejected error. +- Convention + test: specs use substring matching; a negative test runs green on both backends. + +**Violation scenario:** A swallowed/rewritten message makes an expect-revert test pass on dry but not +match on live (false failure); a match-anything fallback makes any rejection satisfy the assertion (false pass). + +**Severity:** Critical + +--- + +### INV-15: Public-state read parity + +**Category:** Dry↔Live parity + +**Statement:** `getPublicState()` / `getContractState()` apply the same `ledgerExtractor` in both modes — +over the in-memory `CircuitContext` (dry) and over the indexer-sourced `StateValue` (live). The extracted +`L` is structurally identical for equivalent state. + +**Applies to:** `getPublicState`, `getContractState`; the shared `ledgerExtractor`. + +**Enforcement mechanism:** +- Runtime: the single `ledgerExtractor` from `SimulatorConfig` (INV-5) is the only extraction path for both backends. + +**Violation scenario:** Divergent extraction makes state assertions mean different things per backend, even +when the underlying contract state is the same. + +**Severity:** High + +--- + +### INV-16: Pure-circuit locality parity + +**Category:** Dry↔Live parity + +**Statement:** Pure circuits run locally on the JS artifact in **both** modes (no tx in live); only impure +circuits hit the node in live. `LiveBackend` retains the JS contract for local pure evaluation. Locality +follows pure/impure, **not** read/write: reads implemented as impure circuits (e.g. `owner()`) still go to +the node in live (D2). + +**Applies to:** `LiveBackend.call` routing (`'pure'` → local JS, `'impure'` → `handle.callTx`). + +**Enforcement mechanism:** +- Runtime: `call('pure', …)` evaluates locally; `call('impure', …)` submits a tx. + +**Violation scenario:** Submitting a tx for a pure circuit burns one of the 4 wallets and can diverge results; +treating an impure read as local skips the node and reads stale local state → false parity. + +**Severity:** High + +--- + +### INV-17: Caller-identity parity + +**Category:** Dry↔Live parity + +**Statement:** `as('OWNER')` denotes the same logical actor in both modes, and +`signers.eitherFor('OWNER')` / `keyFor('OWNER')` resolve to a value consistent with that actor. Dry derives +a deterministic key from the alias label; live resolves the alias to a fixed prefunded wallet — and the +alias→actor mapping is aligned so "OWNER" is the same party across backends (D1). The +`setCaller(alias, mode)` **mode semantics also match across backends**: `'single'` applies the caller to the +next call then reverts to the default signer; `'persistent'` keeps it until changed. The revert-after-one-call +lifecycle of `'single'` behaves identically on dry and live. + +**Applies to:** `as`, `setPersistentCaller`, `Backend.setCaller(alias, mode)`, `signers.eitherFor`, `signers.keyFor`. + +**Enforcement mechanism:** +- Runtime: the `Signers` resolver; the dry derivation and live seed assignment share one alias→actor mapping + (Open Question 4 pins the resolver and the alignment). +- Runtime: both backends implement the same `'single'`/`'persistent'` lifecycle in `setCaller`. +- Test: a `'single'`-mode call followed by a default-signer call asserts the same active caller on both backends. + +**Violation scenario:** If `'OWNER'` maps to different keys/actors across backends, an authorization test +passes on one backend and fails on the other — a false result that looks like a flaky auth check. + +**Severity:** High + +--- + +### INV-18: Private-state read parity (with documented mutation asymmetry) + +**Category:** Dry↔Live parity + +**Statement:** `getPrivateState()` returns the contract's private state `P` in both modes — from the +`CircuitContext` (dry) and from `levelPrivateStateProvider` keyed by `privateStateId` (live). **Read parity +holds.** **Mutation/injection parity does NOT:** live `privateState.*` mutation is best-effort via +`privateStateProvider.set`, and mid-test secret/witness injection (e.g. +`SampleZOwnable.privateState.injectSecretNonce`) may not faithfully reproduce on live, so such tests may +remain dry-only (Open Question 3). + +**Applies to:** `getPrivateState` (read parity), `privateState.*` mutation (asymmetry). + +**Enforcement mechanism:** +- Runtime: `LiveBackend.getPrivateState` via the provider; mutation documented as best-effort / dry-only. +- Test: read parity asserted on both backends; injection tests tagged dry-only until the policy is decided. + +**Violation scenario:** Assuming mutation parity makes a private-state-injection test green on dry while the +injection never takes effect on live — false confidence in a privacy-critical path. Read divergence yields +wrong private-state assertions. + +**Severity:** High + +## Isolation & Compatibility Invariants + +### INV-19: Existing sync path unchanged byte-for-byte + +**Category:** Isolation & compatibility + +**Statement:** `createSimulator`, `AbstractSimulator`, `ContractSimulator`, `CircuitContextManager`, +`SimulatorConfig`, all existing `types/*`, and every existing synchronous per-module simulator + spec keep +working unchanged. The new work is strictly additive; no existing file's runtime behavior changes. + +**Applies to:** all existing `src/core/*`, `src/factory/createSimulator.ts`, `src/factory/SimulatorConfig.ts`, +`src/types/*`, `src/proxies/*`, and every un-migrated module. + +**Enforcement mechanism:** +- Test: the full existing dry suite passes unchanged as a regression gate; additive-only diff to existing files. + +**Violation scenario:** A regression in the sync path breaks every un-migrated module at once — the per-module +opt-in promise (no flag day) fails. + +**Severity:** Critical + +--- + +### INV-20: Public API purely additive + +**Category:** Isolation & compatibility + +**Statement:** The barrel only gains exports — values `createBackendSimulator`, `DryBackend`, `Backend`, +`LiveContext`, `Signers`; type-only `LiveBackend` (INV-1). No existing export is removed, renamed, or changed +in signature. `createSimulator` consumers see no breaking change. + +**Applies to:** `src/index.ts`. + +**Enforcement mechanism:** +- Test: additive barrel diff; optionally an API-extractor / export-snapshot test. + +**Violation scenario:** A breaking barrel change forces every consumer to migrate, violating the no-flag-day goal. + +**Severity:** High + +--- + +### INV-21: Live signer cap enforced, not silently exceeded + +**Category:** Isolation & compatibility + +**Statement:** Live supports exactly the prefunded set — deployer + 3 named aliases = 4 on the dev-preset node +(D1). Requesting an alias beyond the funded pool fails with a clear error pointing at the deferred +derive-and-fund flow. It never silently reuses a wallet or proceeds with an unfunded one. + +**Applies to:** `Signers` / `WalletPool` live resolution. + +**Enforcement mechanism:** +- Runtime: a bounds-check in the live alias resolver that throws on overflow. +- Test: requesting a 5th distinct alias on live → rejects with the cap message. + +**Violation scenario:** Silent wallet reuse collapses two aliases into one actor → authorization tests pass +spuriously. An unfunded wallet → opaque tx failure that looks like a contract bug. + +**Severity:** Medium + +--- + +### INV-22: Caller harness owns all live infra + +**Category:** Isolation & compatibility + +**Statement:** The package ships no docker-compose, no endpoint provisioning, no deploy. All live infra +(`EnvironmentConfiguration` from `MIDNIGHT_*` env, providers, `WalletPool`, deploy, `LiveContext` impl) lives +in the consuming harness. The optional `createLiveContext` helper only assembles already-provided pieces +(providers + `WalletPool` + `CompiledContract` + `contractAddress`). + +**Applies to:** `src/live/*` boundary; `createLiveContext`. + +**Enforcement mechanism:** +- Boundary by construction: `src/live/` contains only the adapter + optional assembler, no infra; reviewed at code stage. + +**Violation scenario:** Leaking infra into the package re-introduces heavy deps / environment assumptions into the +dependency graph (threatening INV-2) and couples the simulator to one topology. + +**Severity:** Medium + +--- + +### INV-23: Parity is CI-gated per migrated module + +**Category:** Isolation & compatibility + +**Statement:** A module counts as *migrated* only if its single spec runs green on **both** backends in CI — +`MIDNIGHT_BACKEND=dry vitest run` and `MIDNIGHT_BACKEND=live vitest run`. Live is run in a dedicated CI job +with the node infra available. A migrated module that is green on dry but not exercised (or not green) on live +is not actually migrated, and the gate must block the merge. Un-migrated modules run dry-only and are exempt. + +**Applies to:** CI configuration; the migrated-module set. + +**Enforcement mechanism:** +- Test/CI: a `test:live` job over the migrated set; a migrated module missing from it (or red on it) fails the gate. +- This is what operationalizes INV-12 — parity is verified continuously, not asserted once at migration time. + +**Violation scenario:** Without the gate, a module migrated months ago silently drifts: a later dry-only change +breaks live parity and no one notices until a real-node run surprises someone. Parity rot is the failure mode +this invariant exists to prevent. + +**Severity:** High + +## Existing Invariants (Extension Mode) + +### Preserved (must not break — formalized by INV-19, INV-20) + +- `createSimulator` stays **synchronous**: returns a class extending `ContractSimulator`; constructor is sync + (`src/factory/createSimulator.ts:42`). +- `getPublicState(): L` is **synchronous** in the existing path via `config.ledgerExtractor(...)` + (`createSimulator.ts:144`). +- Circuit proxies are `ContextlessCircuits` = `(...args) => R` (sync) in the existing path + (`src/proxies/CircuitProxies.ts`, `src/types/Circuit.ts`). +- `overrideWitness` / `set witnesses` recreate the contract and reset proxies — dry behavior preserved + (`createSimulator.ts:165-182`). Live diverges deliberately (INV-7). +- Constructor defaults preserved: `coinPK = '0'.repeat(64)`, `contractAddress = dummyContractAddress()` + (`createSimulator.ts:48-53`). +- Barrel exports both values and types as today (`src/index.ts`); new exports are additive (INV-20), with the + type-only `LiveBackend` rule (INV-1). + +### Modified + +- **None.** The "simulator class is the single test-facing API" goal is delivered by a *new* additive factory + (`createBackendSimulator`), not by modifying the existing class or its proxies. No existing invariant changes. + +### New + +- INV-1 through INV-22 are all new, introduced by the backend-aware path. + +## Invariant Coverage Matrix + +| Operation / surface | Invariants | Enforcement | +|---|---|---| +| `createBackendSimulator` / `Module.create` | INV-4, INV-5, INV-8, INV-9, INV-10, INV-19, INV-20 | TS types + construction-time backend resolve + additive diff | +| Impure call | INV-4, INV-12, INV-13, INV-14, INV-16 | Async proxy + result/message normalization + pure/impure routing | +| Pure call | INV-4, INV-12, INV-16 | Local JS eval in both modes | +| `getPublicState` / `getContractState` | INV-11, INV-12, INV-15 | Shared `ledgerExtractor` + bounded indexer-lag retry | +| `getPrivateState` | INV-12, INV-18 | Provider read (live) / context (dry); mutation asymmetry documented | +| `as` / `setPersistentCaller` | INV-12, INV-17, INV-21 | `Signers` resolver + signer-cap bounds-check | +| `signers.eitherFor` / `keyFor` | INV-17, INV-21 | Alias→actor mapping aligned across backends | +| `overrideWitness` / `set witnesses` | INV-7, INV-19 | Live throws; dry preserved | +| `privateState.*` mutation | INV-18 | Best-effort provider set; injection tests may stay dry-only | +| Barrel / dry import | INV-1, INV-2, INV-3, INV-20 | Type-only re-export + dep-graph CI guard + optional peers | +| Live module load | INV-2, INV-6 | Dynamic import + wrapped missing-deps error | +| Failure path (`rejects.toThrow`) | INV-14 | Message preserved as substring | +| CI parity gate (migrated module) | INV-12, INV-23 | `test:dry` + `test:live` both green to count as migrated | +| Existing sync simulators + specs | INV-19 | Full existing dry suite as regression gate | + +## Out of Scope + +- **Performance / timing parity.** Live is slower by physics; no invariant claims equal wall-clock or per-op latency. +- **Gas / fee accounting parity.** Not modeled by the simulator on either backend. +- **Private-state mutation/injection parity on live.** Best-effort only (INV-18); faithful mid-test injection is not + guaranteed, and such tests may stay dry-only. Reason: provider semantics differ from in-memory context; policy is Open Question 3. +- **Mid-test witness-implementation swapping on live.** Hard-errors (INV-7); witnesses bind at deploy. Reason: no on-chain mechanism to rebind. +- **More than 4 concurrent live signers.** Fails clearly (INV-21); derive-and-fund flow deferred. Reason: dev-preset node prefunds 4 wallets. +- **Deployment, provider construction, wallet funding, infra provisioning.** Caller-harness responsibility (INV-22); the package ships none. Reason: dev scoped strictly to the simulator. +- **`@openzeppelin/compact-deployer` integration.** Branch-only as of this design; not a dependency. +- **Preprod / testnet parity.** Local node assumed; endpoints from `MIDNIGHT_*` env. +- **First-party fuzzing.** `fast-check` stays hand-rolled. + +## Dev Notes + +- **Parity is the load-bearing property.** Read every parity invariant (INV-12..INV-18) as either "this is made + identical across backends" or "this is a documented asymmetry with a hard guard." There is no third state — an + undocumented divergence is the bug class this whole document exists to prevent. +- INV-13 (result shape) and INV-17 (alias resolution) are the two parity invariants most likely to be wrong in the + first code draft, because both depend on midnight-js details not yet pinned (Open Questions 1 and 4). Treat their + enforcement as provisional until verified against the installed packages. +- Reference prior art for the live harness shape: `OpenZeppelin/compact-contracts#489` + (`test/cma-upgradability/_harness/*`, `fixtures/testTokenV1.ts`). `createLiveContext` generalizes that fixture's kit. + +## Open Questions + +1. **Result-extraction shape (blocks INV-13).** Verify `.private.result` against the installed + `@midnight-ntwrk/midnight-js-contracts` `callTx` return type, including how impure-typed reads (e.g. `owner()`) + surface their value. INV-13's normalization is provisional until pinned. +2. **Indexer-lag budget (concretizes INV-11).** Decide the retry count, backoff, and ceiling for `queryLedger` so + read-after-write is stable without slowing the suite. +3. **Private-state mutation parity (bounds INV-18).** How faithfully can live reproduce mid-test private-state + injection via `privateStateProvider.set`? Decide which tests stay dry-only and codify the policy in code/tests. +4. **Dry alias→key resolver + live alignment (concretizes INV-17).** Deterministic derivation vs caller-supplied + alias→key map, and how aliases line up with live prefunded seeds so "the same alias = the same actor" in both modes. +5. **Codemod vs hand-migrate (process, not an invariant).** Build a real codemod for the async migration, or hand-migrate + the first module and template from it? +6. **CI guard mechanism (concretizes INV-1/INV-2 enforcement).** Dependency-graph test vs bundle analysis to assert a + dry-only import pulls no midnight-js. diff --git a/packages/simulator/docs/design/live-backend.md b/packages/simulator/docs/design/live-backend.md new file mode 100644 index 0000000..52ee249 --- /dev/null +++ b/packages/simulator/docs/design/live-backend.md @@ -0,0 +1,335 @@ +--- +stage: design +project: simulator-live-backend +mode: extension +extends: packages/simulator +status: draft +timestamp: 2026-06-22 +author: 0xisk +previous_stage: null +tags: [simulator, testing, live-backend, midnight-js, async, tooling] +--- + +# Simulator Live-Mode Backend — Design Document + +> Tracks OpenZeppelin/compact-tools#75. This is a **tooling** design (a TypeScript +> test harness), not a Compact contract. The `midnight-design` skill's +> contract-centric sections (ledger schema, disclosure boundary, witnesses) were +> re-mapped to tooling concerns; see the table in the Summary. + +## Summary + +Make the per-module simulator class the single test-facing API for a contract, runnable +against either the existing in-memory path (`DryBackend`) or a live local Midnight node +(`LiveBackend`), selected by `MIDNIGHT_BACKEND=dry|live` at fixture-construction time. +A new `createBackendSimulator(...)` factory lives alongside the existing synchronous +`createSimulator(...)`; modules opt in one at a time. The core trade-off: live infra is +async by physics, so a single spec file can run on both backends only if it is written +async. Opted-in modules take a one-time, mostly-mechanical async migration; un-migrated +modules are untouched. + +Skill-section re-mapping used in this design: + +| Skill section (contracts) | Re-mapped to (tooling) | +|---|---| +| Contract Layout | Package & dependency layout | +| Ledger Schema / Types | Core types + the `Backend` seam + sync→async transform | +| Circuits / Witnesses | Public API surface | +| State Partitioning & Disclosure | Execution-model parity (dry vs live) — the central decision | +| Integration Patterns | Opt-in, backend selection, caller harness | +| Error Handling | Assertion / error-message parity | +| Indexer-visible fields | State-read parity | + +## Package & Dependency Layout + +Single package, single barrel. Users only ever +`import { … } from '@openzeppelin/compact-simulator'`. No `/live` subpath in user code. + +- **Leanness via dynamic import, not subpath export.** Every `@midnight-ntwrk/midnight-js-*` + import lives *inside* the live module (`src/live/`). `createBackendSimulator` reaches it + through a runtime `await import('./live/LiveBackend.js')`, fired **only** when + `MIDNIGHT_BACKEND=live`. A static `import { createBackendSimulator }` never pulls the + midnight-js stack into the dependency graph. +- **Barrel exports `LiveBackend` as a `type` only**, plus the value-level + `createBackendSimulator`, `Backend`, `DryBackend`, `LiveContext`, and the `signers` + helper types. The constructable live *value* is reached solely through the factory's + dynamic import. +- **Hard constraint:** the barrel must never statically re-export the `LiveBackend` + *value* (`export { LiveBackend }`). That one line would re-couple the heavy deps to every + dry import. Type-only re-export keeps the wall up. This must be enforced (lint rule or a + bundle-size/dep test in CI). +- **Dependency declaration:** the midnight-js packages are `optionalpeerDependencies`. + Dry-only consumers never install them. `MIDNIGHT_BACKEND=live` without them installed → + the dynamic import fails with a clear `"install @midnight-ntwrk/… to use live mode"` + error, not a cryptic module-not-found. + +Proposed source layout: + +``` +packages/simulator/src/ +├── core/ # unchanged: AbstractSimulator, ContractSimulator, CircuitContextManager +├── factory/ +│ ├── createSimulator.ts # unchanged (sync, dry-only) +│ ├── createBackendSimulator.ts # NEW — async, backend-aware +│ └── SimulatorConfig.ts # unchanged (reused as-is by both factories) +├── backend/ +│ ├── Backend.ts # NEW — the Backend interface +│ └── DryBackend.ts # NEW — wraps the existing CircuitContext path +├── live/ # NEW — isolated; only this dir imports midnight-js +│ ├── LiveBackend.ts # thin adapter over an injected LiveContext +│ ├── LiveContext.ts # the injection seam (interface + types) +│ └── createLiveContext.ts # OPTIONAL convenience builder (findDeployedContract + queries) +├── signers/ +│ └── Signers.ts # NEW — alias→key (dry) / alias→wallet (live) resolution +└── index.ts # barrel (type-only re-export of LiveBackend) +``` + +## Core Types & the `Backend` Seam + +`SimulatorConfig` is reused unchanged. Both factories consume the +same `contractFactory`, `defaultPrivateState`, `contractArgs`, `ledgerExtractor`, +`witnessesFactory`. The live path reuses `ledgerExtractor` to turn indexer state into `L`. + +**The `Backend` interface** abstracts only the operations that genuinely differ: + +```ts +interface Backend { + call(kind: 'pure' | 'impure', name: string, args: unknown[]): Promise; + getPublicState(): Promise; + getPrivateState(): Promise

; + getContractState(): Promise; + setCaller(alias: string | null, mode: 'single' | 'persistent'): void; + readonly contractAddress: string; +} +``` + +**Sync→async transform.** Today `ContextlessCircuits` maps each circuit to +`(...args) => R`. Add an async sibling used by `createBackendSimulator`: + +```ts +type AsyncCircuits = { + [K in keyof Circuits]: (...args: Args) => Promise>; +}; +``` + +`createBackendSimulator` owns the async `pure` / `impure` proxies (the backend stays dumb — +it exposes `call(...)`, the factory builds the proxies on top). **Dry wraps its synchronous +result in `Promise.resolve`; live awaits the network.** Spec code is uniform `await`. + +## Public API Surface + +**Construction is async** (live must await deploy/handle/query; dry wraps sync): + +```ts +// dry (MIDNIGHT_BACKEND unset|dry) +const sim = await SampleZOwnableSimulator.create(args, options); + +// live (MIDNIGHT_BACKEND=live) — caller injects the live world +const sim = await SampleZOwnableSimulator.create(args, { live: liveCtx }); +``` + +**The injection seam — `LiveContext`, defined by the package, implemented by the +caller's harness** (thin: the caller hands over fully-built per-alias handles + readers): + +```ts +interface LiveContext { + handleFor(alias: string | null): Promise>; // null = default signer; cached per alias + queryLedger(): Promise; // → ledgerExtractor → L + queryPrivateState(): Promise

; + readonly contractAddress: string; +} +``` + +`LiveBackend` is then a pure adapter: route `call('impure', name, args)` to +`handleFor(activeAlias).callTx[name](...)` and normalize the result; route +`call('pure', …)` to local JS evaluation (D2); route `getPublicState()` through +`queryLedger()` + `ledgerExtractor`. All midnight-js wiring stays in the caller's harness +or the optional `createLiveContext` helper, never in the package's runtime deps beyond the +type imports inside `src/live/`. + +**Per-module authoring is unchanged except async** — swap the factory, add `return`, widen +the return type: + +```ts +// before (dry-only): transferOwnership(id) { this.circuits.impure.transferOwnership(id); } +// after (both modes): transferOwnership(id) { return this.circuits.impure.transferOwnership(id); } // Promise +``` + +**`Signers` helper** (alias is the common currency for caller identity and circuit args): + +```ts +sim.as('OWNER').transferOwnership(newId); // caller identity +await sim.signers.eitherFor('OWNER'); // Either for circuit args +await sim.signers.keyFor('OWNER'); // raw key +``` + +Dry resolves an alias to a deterministic key from its label (same trick as the existing +`makeUser('OWNER')` / `generatePubKeyPair('OWNER')`); live resolves it to a pooled, +prefunded wallet. + +## Execution-Model Parity (Dry vs Live) + +The central design surface. For every operation: + +| Operation | Dry (`DryBackend`) | Live (`LiveBackend`) | Seam decision | +|---|---|---|---| +| **Construct** | `create()` runs `initialState` → fresh state per call | attaches to caller-deployed instance via `LiveContext` | args drive deploy in dry; in live the caller deployed already (D3) | +| **Impure call** | run JS circuit, update `CircuitContext` | `handle.callTx[name](...)`, submit tx | normalize live `{public, private:{result}}` → bare `R` | +| **Pure call** | run JS pure circuit, return `R` | **run locally** on the JS artifact, no tx (D2) | identical local path; live keeps the JS contract for this | +| **getPublicState** | `ledgerExtractor(ctx.state)` | `ledgerExtractor(await queryLedger())` | same extractor, async source | +| **getPrivateState** | `ctxManager.currentPrivateState` | `await queryPrivateState()` (provider) | — | +| **as / setPersistentCaller** | alias→deterministic key→`emptyZswapLocalState` | alias→pooled wallet→cached handle | D1 | +| **overrideWitness / set witnesses** | recreate contract with new witnesses | bound at deploy; cannot swap mid-test | **dry-only**; live throws `"witness override unsupported on live backend"` | +| **privateState.* mutation** | `ctxManager.updatePrivateState` | `privateStateProvider.set(id, …)` (best-effort) | asymmetry flagged; witness-injection tests may stay dry-only | +| **Failure** | sync `throw "Foo: msg"` | `callTx` rejects, assert msg propagates as substring | both → `await expect(...).rejects.toThrow(msg)` | + +### Decisions + +- **D1 — Caller identity unifies on alias *strings*.** Live can sign only with prefunded + wallets, so `as(OWNER /*CoinPublicKey*/)` becomes `as('OWNER')`. Migrated caller-override + tests change refs to aliases (a bit more than await-noise, scoped to those tests). Circuit + args that need a key use `sim.signers.eitherFor('OWNER')` in both modes. **Live cap:** 4 + prefunded wallets (deployer + 3 named aliases) on the dev-preset node; more requires a + derive-and-fund flow (deferred). +- **D2 — Pure circuits run locally in both modes; only impure circuits hit the node in + live.** A `pure circuit` is deterministic JS with no ledger/witness, so submitting a tx + for it would be absurd and would burn one of the 4 wallets. `LiveBackend` keeps the JS + artifact for local pure eval. Note: some "reads" in these simulators are *impure* + circuits (e.g. `owner()` in `SampleZOwnableSimulator`) and still go to the node. +- **D3 — Test isolation: deploy-once-shared (default) vs redeploy-per-test (opt-in).** Dry + gives pristine state every `beforeEach: create()`. Live attaching to one deployed contract + accumulates state across tests, so the default is deploy-once in `beforeAll` with tests + tolerating shared state (fast; the CMA branch's approach). Redeploy-per-test gives true + isolation at minutes-per-suite cost. Consequence: some `beforeEach`-fresh-state + assumptions in existing tests will not survive live unchanged. + +## Integration: Opt-in, Backend Selection, Caller Harness + +- **Backend selection** at fixture-construction via `MIDNIGHT_BACKEND`. `package.json`: + ```json + "test": "MIDNIGHT_BACKEND=dry vitest run", + "test:live": "MIDNIGHT_BACKEND=live vitest run" + ``` + Same spec files, flip the env var. +- **Opt-in per module** = swap `createSimulator` → `createBackendSimulator`, add `return` + to delegating methods, widen return types to `Promise`, and async-migrate that module's + spec (`await`, `.rejects.toThrow`, alias caller refs). Codemod-assisted (~90% mechanical). +- **Caller harness owns all live infra** — the package ships no docker-compose and no + endpoint provisioning. The harness (in the consuming repo) builds the + `EnvironmentConfiguration` from `MIDNIGHT_*` env (localhost defaults, infra assumed + running), constructs providers, a `WalletPool`, deploys once, and implements `LiveContext`. + The branch `OpenZeppelin/compact-contracts#489` `_harness/` + fixture is the reference + shape. +- **Optional `createLiveContext({...})` convenience** (exported from `src/live/`): given + providers + a `WalletPool` + a `CompiledContract` + `contractAddress`, it builds the + per-alias `findDeployedContract` handle cache and the `queryLedger` / `queryPrivateState` + readers — so callers don't re-derive the kit by hand. `LiveBackend` itself stays thin; + this helper is separate. + +## Error & Assertion Parity + +- One failure mechanism on both sides surfaces the same way after async migration: + `await expect(promise).rejects.toThrow(message)`. +- Live wraps the assert message in additional context (proof / tx-failure framing), so + tests must match on a **substring** (`.toThrow('Foo: msg')`), never assert an exact full + error string. Confirmed by the branch: `.rejects.toThrow('AccessControl: unauthorized account')` + passes against the live node. +- Backend responsibility: `LiveBackend` must not swallow or rewrite the contract assert + message when normalizing the rejection. + +## State-Read Parity + +- `getPublicState()` / `getContractState()` read the indexer in live + (`publicDataProvider.queryContractState(address)` → `ledger(state.data)`), the in-memory + context in dry. Same `ledgerExtractor` over both. +- **Indexer lag:** `callTx` resolves on tx confirmation, but a subsequent indexer read can + trail by a block. `createLiveContext.queryLedger` should poll/retry briefly to absorb the + lag before returning, so a `read-after-write` assertion does not flake. +- `getPrivateState()` reads the `levelPrivateStateProvider` (keyed by `privateStateId`) in + live, the context in dry. + +## Change Plan (Extension Mode) + +**New:** +- `src/backend/Backend.ts`, `src/backend/DryBackend.ts` +- `src/factory/createBackendSimulator.ts` +- `src/live/{LiveBackend,LiveContext,createLiveContext}.ts` +- `src/signers/Signers.ts` +- `AsyncCircuits<>` type (alongside `ContextlessCircuits`) +- New barrel exports (value: `createBackendSimulator`, `DryBackend`, `Backend`, + `LiveContext`, `Signers`; type-only: `LiveBackend`) + +**Modified:** +- `src/index.ts` — additive exports only; the type-only `LiveBackend` re-export rule. +- `package.json` — add midnight-js packages as `optionalpeerDependencies`; add a CI + guard that a dry-only import resolves no midnight-js. + +**Unchanged (must keep working byte-for-byte for un-migrated modules):** +- `createSimulator`, `AbstractSimulator`, `ContractSimulator`, `CircuitContextManager`, + `SimulatorConfig`, all existing `types/*`, and every existing synchronous per-module + simulator + its spec. + +**API compatibility / publishing impact:** +- Purely additive to the public API; no breaking change to `createSimulator` consumers. +- Dry-only install footprint is unchanged (heavy deps are optional + dynamically imported). +- No CMA / verifier-key implications for the simulator package itself; those live in the + contracts under test and the caller's deploy harness. + +## Design Decisions Log + +- **Single barrel + dynamic import + type-only `LiveBackend` re-export + optional peer + deps** — the only combination that delivers "no `/live` in user code", "no flag day", and + "dry path stays lean" simultaneously. +- **`createBackendSimulator` alongside `createSimulator`** (not a replacement) — gradual, + per-module opt-in; un-migrated modules untouched. +- **Async everywhere on the backend-aware path** — forced by live-infra physics; accepted + as a one-time, codemod-assisted migration per opted-in module. +- **Thin `LiveBackend` + injected `LiveContext`** — deployment, provider construction, and + wallet funding are the caller's responsibility (the package ships no infra). Optional + `createLiveContext` helper reduces caller boilerplate without thickening the backend. +- **D1 alias-string caller identity**, **D2 pure-local-in-both-modes**, **D3 + deploy-once-shared default**. +- **Result normalization** — `LiveBackend` unwraps `callTx`'s `{ public, private:{result} }` + to the bare `R` that dry returns, so spec assertions are identical across backends. + +## Out of Scope + +- **Deployment, provider construction, wallet funding, infra provisioning.** The package + ships no docker-compose and no endpoint setup; the caller's harness owns it. Reason: the + dev scoped this strictly to the simulator; the deployer is on a branch and not depended + upon. +- **The `@openzeppelin/compact-deployer` package.** Not a dependency (branch-only as of this + design). +- **Preprod / testnet targets.** Local node assumed; endpoints come from `MIDNIGHT_*` env. +- **Mid-test witness-implementation swapping on live.** Witnesses bind at deploy; live + `overrideWitness` is unsupported. +- **More than 4 concurrent live signers** (derive-and-fund flow). Deferred. +- **A first-party fuzzing harness.** Out of scope; `fast-check` remains hand-rolled. + +## Dev Notes + +- Reference prior art: `OpenZeppelin/compact-contracts#489` (`test/cma-upgradability`) + `_harness/{network,providers,wallet,walletPool,deploy}.ts` and + `fixtures/testTokenV1.ts`. Our `createLiveContext` generalizes that fixture's kit. +- The issue names the package `@openzeppelin-compact/contracts-simulator`; the actual name + is `@openzeppelin/compact-simulator`. Using the real name. + +## Open Questions + +1. **Result extraction shape** — verify `.private.result` against the installed + `@midnight-ntwrk/midnight-js-contracts` `callTx` return type, including how + impure-typed reads (e.g. `owner()`) surface their value. Pin the exact field before + coding `LiveBackend.call`. +2. **Indexer-lag policy** — concrete retry/poll budget for `queryLedger` (count, backoff, + ceiling) so live reads are reliable without slowing the suite. +3. **Private-state parity for witness-heavy contracts** — how faithfully can live reproduce + mid-test private-state injection (e.g. `SampleZOwnable.privateState.injectSecretNonce`) + via `privateStateProvider.set`? Some such tests may stay dry-only initially; decide the + policy in invariants/code. +4. **Dry alias→key resolver** — default deterministic derivation vs a caller-supplied + alias→key map; and how aliases line up with live prefunded seeds so the same alias means + "the same actor" in both modes. +5. **Codemod** — build a real codemod for the async migration, or hand-migrate the first + module and template from it? +6. **CI guard** — exact mechanism to assert a dry-only import pulls no midnight-js (bundle + analysis vs a dependency-graph test). diff --git a/packages/simulator/package.json b/packages/simulator/package.json index 822fb3f..2a53117 100644 --- a/packages/simulator/package.json +++ b/packages/simulator/package.json @@ -36,10 +36,13 @@ "scripts": { "build": "tsc -p .", "types": "tsc -p tsconfig.json --noEmit", - "test": "yarn vitest run", + "test": "MIDNIGHT_BACKEND=dry vitest run", + "test:live": "MIDNIGHT_BACKEND=live vitest run", "clean": "git clean -fXd" }, "devDependencies": { + "@midnight-ntwrk/midnight-js-contracts": "^4.1.0", + "@midnight-ntwrk/midnight-js-types": "^4.1.0", "@tsconfig/node24": "^24.0.3", "@types/node": "25.9.1", "fast-check": "^4.5.2", @@ -49,5 +52,17 @@ "dependencies": { "@midnight-ntwrk/compact-runtime": "0.16.0", "@midnight-ntwrk/ledger-v8": "8.1.0" + }, + "peerDependencies": { + "@midnight-ntwrk/midnight-js-contracts": "^4.1.0", + "@midnight-ntwrk/midnight-js-types": "^4.1.0" + }, + "peerDependenciesMeta": { + "@midnight-ntwrk/midnight-js-contracts": { + "optional": true + }, + "@midnight-ntwrk/midnight-js-types": { + "optional": true + } } } diff --git a/packages/simulator/src/backend/Backend.ts b/packages/simulator/src/backend/Backend.ts new file mode 100644 index 0000000..1d201b7 --- /dev/null +++ b/packages/simulator/src/backend/Backend.ts @@ -0,0 +1,117 @@ +import type { StateValue } from '@midnight-ntwrk/compact-runtime'; + +/** + * Identifies which execution backend a simulator is bound to. + * + * Resolved once at construction time and fixed for the simulator's lifetime + * (INV-8). There is no runtime toggle: a `'dry'` simulator never becomes + * `'live'` or vice versa. + */ +export type BackendKind = 'dry' | 'live'; + +/** + * Whether a circuit runs locally on the JS artifact (`'pure'`) or, in live mode, + * is submitted as a transaction to the node (`'impure'`). + * + * Locality follows the pure/impure distinction, NOT read/write (D2, INV-16): + * a read implemented as an impure circuit (e.g. `owner()`) still hits the node + * in live mode. + */ +export type CircuitKind = 'pure' | 'impure'; + +/** + * The execution seam that genuinely differs between the in-memory simulator and + * a live Midnight node. + * + * `createBackendSimulator` builds the async circuit proxies, caller helpers, and + * state getters on top of this interface; the backend itself stays dumb. Every + * operation is async so spec code is uniform `await` across both backends + * (INV-4): {@link DryBackend} wraps its synchronous results in `Promise.resolve`, + * the live adapter awaits the network. + * + * @template P - Private state type. + * @template L - Public ledger state type. + */ +export interface Backend { + /** The backend this instance is bound to (INV-8). */ + readonly kind: BackendKind; + + /** The deployed contract's address. */ + readonly contractAddress: string; + + /** + * Invokes a circuit. Pure circuits run locally on the JS artifact in both + * modes; impure circuits run locally in dry and submit a tx in live (D2, + * INV-16). The live adapter normalizes the result to the bare `R` that dry + * returns (INV-13), so an assertion on the return value is identical across + * backends. + * + * @param kind - Whether the circuit is pure or impure. + * @param name - The circuit name. + * @param args - The circuit arguments. + * @returns The bare circuit result `R`, normalized to match dry. + */ + call(kind: CircuitKind, name: string, args: unknown[]): Promise; + + /** + * Extracts the public ledger state. Both backends apply the same + * `ledgerExtractor` (INV-15) — over the in-memory context in dry, over the + * indexer-sourced state in live. + */ + getPublicState(): Promise; + + /** + * Reads the private state `P`. Read parity holds across backends (INV-18); + * mutation parity does not (see {@link overrideWitness} and the private-state + * mutation asymmetry documented on the live adapter). + */ + getPrivateState(): Promise

; + + /** Returns the raw contract `StateValue` (the input to `ledgerExtractor`). */ + getContractState(): Promise; + + /** + * Replaces the private state. Dry mutates the in-memory context (used by + * per-module helpers like secret/nonce injection); live throws, because + * mid-test private-state mutation is the documented dry↔live asymmetry + * (INV-18). Guard such specs with `isLiveBackend()`. + * + * @param privateState - The new private state `P`. + */ + setPrivateState(privateState: P): void; + + /** + * Sets the caller identity for subsequent circuit calls. + * + * The mode lifecycle matches across backends (INV-17): `'single'` applies the + * caller to the next call then reverts to the default signer; `'persistent'` + * keeps it until changed. `null` clears the override (default signer). + * + * @param alias - The caller alias (e.g. `'OWNER'`), or `null` for the default signer. + * @param mode - `'single'` (one call) or `'persistent'` (until changed). + */ + setCaller(alias: string | null, mode: 'single' | 'persistent'): void; + + /** + * Replaces a single witness implementation. + * + * Dry recreates the contract with the new witness; the live adapter throws + * `"witness override unsupported on live backend"` because witnesses bind at + * deploy and cannot be swapped mid-test (INV-7). + * + * @param key - The witness key to override. + * @param fn - The new witness implementation. + */ + overrideWitness(key: PropertyKey, fn: unknown): void; + + /** + * Replaces the whole witness set. Dry recreates the contract; the live adapter + * throws the same INV-7 message as {@link overrideWitness}. + * + * @param witnesses - The new witness set. + */ + setWitnesses(witnesses: unknown): void; + + /** Returns the current witness set (read parity; live reads the local set). */ + getWitnesses(): unknown; +} diff --git a/packages/simulator/src/backend/DryBackend.ts b/packages/simulator/src/backend/DryBackend.ts new file mode 100644 index 0000000..8feb242 --- /dev/null +++ b/packages/simulator/src/backend/DryBackend.ts @@ -0,0 +1,129 @@ +import type { + CoinPublicKey, + StateValue, +} from '@midnight-ntwrk/compact-runtime'; +import type { Signers } from '../signers/Signers.js'; +import type { Backend, BackendKind, CircuitKind } from './Backend.js'; + +/** + * The slice of a synchronous `createSimulator` instance that {@link DryBackend} + * drives. Kept structural so the dry backend reuses the existing simulator + * machinery without coupling to its concrete (anonymous) class. + * + * @template P - Private state type. + * @template L - Public ledger state type. + */ +export interface SyncSimulator { + readonly contractAddress: string; + callerOverride: CoinPublicKey | null; + persistentCallerOverride: CoinPublicKey | null; + readonly circuits: { + pure: Record unknown>; + impure: Record unknown>; + }; + getPublicState(): L; + getPrivateState(): P; + getContractState(): StateValue; + overrideWitness(key: PropertyKey, fn: unknown): void; + witnesses: unknown; + readonly circuitContextManager: { updatePrivateState(privateState: P): void }; +} + +/** + * The in-memory backend: a thin async facade over the existing synchronous + * `createSimulator` instance. + * + * Every operation delegates to the wrapped simulator and wraps the synchronous + * result in a resolved promise (INV-4), so a circuit never returns a bare value + * on dry but a `Promise` on live. Because all real work routes through the + * unchanged synchronous path, dry behavior is preserved byte-for-byte (INV-19) + * and is the parity reference the live backend is measured against (INV-12). + * + * @template P - Private state type. + * @template L - Public ledger state type. + */ +export class DryBackend implements Backend { + readonly kind: BackendKind = 'dry'; + + private readonly sim: SyncSimulator; + private readonly signers: Signers; + + /** + * @param sim - The wrapped synchronous simulator instance. + * @param signers - Resolver used to turn caller aliases into deterministic keys. + */ + constructor(sim: SyncSimulator, signers: Signers) { + this.sim = sim; + this.signers = signers; + } + + get contractAddress(): string { + return this.sim.contractAddress; + } + + /** + * Runs a circuit on the in-memory contract. Accessing `circuits.{pure,impure}` + * fresh on each call means a witness override (which rebuilds the wrapped + * simulator's proxies) is picked up transparently. + */ + async call( + kind: CircuitKind, + name: string, + args: unknown[], + ): Promise { + const proxy = + kind === 'pure' ? this.sim.circuits.pure : this.sim.circuits.impure; + const fn = proxy[name]; + if (typeof fn !== 'function') { + throw new Error(`unknown ${kind} circuit "${name}"`); + } + return fn(...args); + } + + async getPublicState(): Promise { + return this.sim.getPublicState(); + } + + async getPrivateState(): Promise

{ + return this.sim.getPrivateState(); + } + + async getContractState(): Promise { + return this.sim.getContractState(); + } + + /** Mutates the in-memory private state (INV-18: dry supports mid-test mutation). */ + setPrivateState(privateState: P): void { + this.sim.circuitContextManager.updatePrivateState(privateState); + } + + /** + * Resolves the alias to a deterministic key and applies it to the wrapped + * simulator's override fields. `'single'` uses `callerOverride` (the existing + * proxy auto-resets it after one call); `'persistent'` uses + * `persistentCallerOverride` (INV-17). + */ + setCaller(alias: string | null, mode: 'single' | 'persistent'): void { + const key = alias === null ? null : this.signers.resolveDryKey(alias); + if (mode === 'persistent') { + this.sim.persistentCallerOverride = key; + } else { + this.sim.callerOverride = key; + } + } + + /** Delegates to the wrapped simulator, which recreates the contract (dry supports this). */ + overrideWitness(key: PropertyKey, fn: unknown): void { + this.sim.overrideWitness(key, fn); + } + + /** Delegates to the wrapped simulator's witness setter (dry supports this). */ + setWitnesses(witnesses: unknown): void { + this.sim.witnesses = witnesses; + } + + /** Returns the wrapped simulator's current witnesses. */ + getWitnesses(): unknown { + return this.sim.witnesses; + } +} diff --git a/packages/simulator/src/factory/SimulatorConfig.ts b/packages/simulator/src/factory/SimulatorConfig.ts index bd11aaa..6186ee4 100644 --- a/packages/simulator/src/factory/SimulatorConfig.ts +++ b/packages/simulator/src/factory/SimulatorConfig.ts @@ -36,4 +36,11 @@ export interface SimulatorConfig< ledgerExtractor: (state: StateValue) => L; /** Factory function to create default witnesses */ witnessesFactory: () => W; + /** + * Optional artifact name (the `artifacts//` directory) for the compiled + * contract. Dry ignores it; the live backend's registered harness uses it to + * locate the compiled assets + ZK keys and to build the deployable contract. + * Only needed for modules that run on the live backend. + */ + artifactName?: string; } diff --git a/packages/simulator/src/factory/createDrySimulator.ts b/packages/simulator/src/factory/createDrySimulator.ts new file mode 100644 index 0000000..f0c7a39 --- /dev/null +++ b/packages/simulator/src/factory/createDrySimulator.ts @@ -0,0 +1,199 @@ +import type { WitnessContext } from '@midnight-ntwrk/compact-runtime'; +import { dummyContractAddress } from '@midnight-ntwrk/compact-runtime'; +import { CircuitContextManager } from '../core/CircuitContextManager.js'; +import { ContractSimulator } from '../core/ContractSimulator.js'; +import type { IMinimalContract } from '../types/Contract.js'; +import type { + ContextlessCircuits, + ExtractImpureCircuits, + ExtractPureCircuits, +} from '../types/index.js'; +import type { BaseSimulatorOptions } from '../types/Options.js'; +import type { SimulatorConfig } from './SimulatorConfig.js'; + +/** + * Internal synchronous simulator primitive. + * + * This is the in-memory engine the public async {@link createSimulator} builds on: + * the dry backend wraps an instance of this class, and the live backend uses one + * locally to evaluate pure circuits (INV-16). It is not the public testing API — + * use {@link createSimulator} instead. + * + * Creates a class extending ContractSimulator with witness management, state + * management, circuit proxy creation, and options handling. + * + * @param config - Configuration object defining how to create and manage the simulator + * @returns A class constructor that can be extended to create specific simulators + */ +export function createDrySimulator< + P, + L, + W, + TContract extends IMinimalContract, + TArgs extends readonly any[] = readonly any[], +>(config: SimulatorConfig) { + return class GeneratedSimulator extends ContractSimulator { + contract: TContract; + readonly contractAddress: string; + public _witnesses: W; + + /** + * Creates a new simulator instance with explicit contract args and options + */ + constructor( + contractArgs: TArgs = [] as any, + options: BaseSimulatorOptions = {}, + ) { + super(); + + const { + privateState = config.defaultPrivateState(), + witnesses = config.witnessesFactory(), + coinPK = '0'.repeat(64), + contractAddress = dummyContractAddress(), + } = options; + + this._witnesses = witnesses; + this.contract = config.contractFactory(this._witnesses); + + const processedArgs = config.contractArgs(...contractArgs); + + this.circuitContextManager = new CircuitContextManager( + this.contract, + privateState, + coinPK, + contractAddress, + ...processedArgs, + ); + + this.contractAddress = this.circuitContext.currentQueryContext.address; + } + + public _pureCircuitProxy?: ContextlessCircuits< + ExtractPureCircuits, + P + >; + public _impureCircuitProxy?: ContextlessCircuits< + ExtractImpureCircuits, + P + >; + + /** + * Gets the pure circuit proxy, creating it lazily if it doesn't exist. + * + * @returns The pure circuit proxy for executing read-only contract methods + */ + public get pureCircuit(): ContextlessCircuits< + ExtractPureCircuits, + P + > { + if (!this._pureCircuitProxy) { + this._pureCircuitProxy = this.createPureCircuitProxy( + this.contract.circuits as ExtractPureCircuits, + () => this.circuitContext, + ); + } + return this._pureCircuitProxy; + } + + /** + * Gets the impure circuit proxy, creating it lazily if it doesn't exist. + * + * @returns The impure circuit proxy for executing state-modifying contract methods + */ + public get impureCircuit(): ContextlessCircuits< + ExtractImpureCircuits, + P + > { + if (!this._impureCircuitProxy) { + this._impureCircuitProxy = this.createImpureCircuitProxy( + this.contract.impureCircuits as ExtractImpureCircuits, + () => this.getCallerContext(), + (ctx) => { + this.circuitContext = ctx; + }, + ); + } + return this._impureCircuitProxy; + } + + /** + * Gets both pure and impure circuit proxies. + * + * @returns Object containing both pure and impure circuit proxies + */ + public get circuits() { + return { + pure: this.pureCircuit, + impure: this.impureCircuit, + }; + } + + /** + * Resets cached circuit proxies, forcing re-initialization on next access. + */ + public resetCircuitProxies(): void { + this._pureCircuitProxy = undefined; + this._impureCircuitProxy = undefined; + } + + /** + * Extracts the public ledger state from the current contract state. + * + * @returns The current public state of the contract + */ + getPublicState(): L { + return config.ledgerExtractor( + this.circuitContext.currentQueryContext.state.state, + ); + } + + // Common witness management methods + /** + * Gets the current witness functions. + * + * @returns The current witness function implementations + */ + public get witnesses(): W { + return this._witnesses; + } + + /** + * Sets new witness functions and recreates the contract with them. + * + * @param newWitnesses - The new witness function implementations to use + */ + public set witnesses(newWitnesses: W) { + this._witnesses = newWitnesses; + this.contract = config.contractFactory(this._witnesses); + this.resetCircuitProxies(); + } + + /** + * Overrides a specific witness function while keeping others unchanged. + * + * @param key - The key of the witness function to override + * @param fn - The new implementation for the witness function + */ + public overrideWitness(key: K, fn: W[K]) { + this.witnesses = { + ...this._witnesses, + [key]: fn, + } as W; + } + + /** + * Gets the current witness context with the proper structure for witness function calls. + * + * @returns The current witness context that can be passed to witness functions + */ + public getWitnessContext(): WitnessContext { + const circuitCtx = this.circuitContext; + return { + ledger: this.getPublicState(), + privateState: circuitCtx.currentPrivateState, + contractAddress: circuitCtx.currentQueryContext.address, + }; + } + }; +} diff --git a/packages/simulator/src/factory/createSimulator.ts b/packages/simulator/src/factory/createSimulator.ts index 98c83f0..d4f286f 100644 --- a/packages/simulator/src/factory/createSimulator.ts +++ b/packages/simulator/src/factory/createSimulator.ts @@ -1,28 +1,50 @@ -import type { WitnessContext } from '@midnight-ntwrk/compact-runtime'; -import { dummyContractAddress } from '@midnight-ntwrk/compact-runtime'; -import { CircuitContextManager } from '../core/CircuitContextManager.js'; -import { ContractSimulator } from '../core/ContractSimulator.js'; +import type { Backend, BackendKind, CircuitKind } from '../backend/Backend.js'; +import { DryBackend, type SyncSimulator } from '../backend/DryBackend.js'; +import type { LiveContext } from '../live/LiveContext.js'; +import { getRegisteredLiveBackend } from '../live/registry.js'; +import { Signers } from '../signers/Signers.js'; import type { IMinimalContract } from '../types/Contract.js'; import type { - ContextlessCircuits, + AsyncCircuits, ExtractImpureCircuits, ExtractPureCircuits, } from '../types/index.js'; -import type { BaseSimulatorOptions } from '../types/Options.js'; +import type { SimulatorOptions } from '../types/Options.js'; +import { createDrySimulator } from './createDrySimulator.js'; import type { SimulatorConfig } from './SimulatorConfig.js'; +/** Prepared backend wiring handed to the simulator constructor. */ +interface BackendDeps { + backend: Backend; + signers: Signers; + pureNames: string[]; + impureNames: string[]; +} + +/** + * Resolves the backend kind once: an explicit override wins, otherwise + * `MIDNIGHT_BACKEND=live` selects live and anything else (unset or `dry`) + * selects dry (INV-8). + */ +const resolveBackendKind = (override?: BackendKind): BackendKind => + override ?? (process.env.MIDNIGHT_BACKEND === 'live' ? 'live' : 'dry'); + /** - * Factory function to create simulator classes with consistent boilerplate elimination. + * Creates a backend-aware simulator class for a contract. + * + * One factory, two backends: the produced class runs against the in-memory path + * ({@link DryBackend}) or a live Midnight node (`LiveBackend`), selected by + * `MIDNIGHT_BACKEND=dry|live` at construction (INV-8). `create` is async and + * circuits return promises ({@link AsyncCircuits}) so a single spec file runs on + * both backends with uniform `await` (INV-4). * - * This factory creates a class that extends ContractSimulator with all the common - * functionality needed for contract simulation, including: - * - Witness management - * - State management - * - Circuit proxy creation - * - Options handling + * The live adapter is reached only through a runtime dynamic import (INV-1, + * INV-2): a static `import { createSimulator }` never pulls midnight-js into the + * dependency graph. In live mode the {@link LiveContext} comes from `options.live` + * or the globally registered live backend (`registerLiveBackend`). * - * @param config - Configuration object defining how to create and manage the simulator - * @returns A class constructor that can be extended to create specific simulators + * @param config - The shared simulator configuration (same shape both backends, INV-5). + * @returns A class to extend with per-circuit delegating methods. */ export function createSimulator< P, @@ -31,168 +53,239 @@ export function createSimulator< TContract extends IMinimalContract, TArgs extends readonly any[] = readonly any[], >(config: SimulatorConfig) { - return class GeneratedSimulator extends ContractSimulator { - contract: TContract; - readonly contractAddress: string; - public _witnesses: W; + // Built once per factory; instances per `create()`. The synchronous primitive + // is the whole dry path and the local JS artifact for pure-circuit eval (INV-16). + const DrySimClass = createDrySimulator(config); - /** - * Creates a new simulator instance with explicit contract args and options - */ - constructor( - contractArgs: TArgs = [] as any, - options: BaseSimulatorOptions = {}, - ) { - super(); - - const { - privateState = config.defaultPrivateState(), - witnesses = config.witnessesFactory(), - coinPK = '0'.repeat(64), - contractAddress = dummyContractAddress(), - } = options; - - this._witnesses = witnesses; - this.contract = config.contractFactory(this._witnesses); - - const processedArgs = config.contractArgs(...contractArgs); - - this.circuitContextManager = new CircuitContextManager( - this.contract, - privateState, - coinPK, - contractAddress, - ...processedArgs, - ); - - this.contractAddress = this.circuitContext.currentQueryContext.address; - } - - public _pureCircuitProxy?: ContextlessCircuits< - ExtractPureCircuits, - P - >; - public _impureCircuitProxy?: ContextlessCircuits< - ExtractImpureCircuits, - P - >; + /** + * Builds an async circuit proxy: each name becomes a function that routes to + * `backend.call(kind, name, args)`, returning the bare `R` as a promise. + */ + const buildProxy = ( + backend: Backend, + kind: CircuitKind, + names: string[], + ): Record Promise> => { + const proxy: Record Promise> = {}; + for (const name of names) { + proxy[name] = (...args: unknown[]) => backend.call(kind, name, args); + } + return proxy; + }; - /** - * Gets the pure circuit proxy, creating it lazily if it doesn't exist. - * - * @returns The pure circuit proxy for executing read-only contract methods - */ - public get pureCircuit(): ContextlessCircuits< - ExtractPureCircuits, - P - > { - if (!this._pureCircuitProxy) { - this._pureCircuitProxy = this.createPureCircuitProxy( - this.contract.circuits as ExtractPureCircuits, - () => this.circuitContext, + /** Resolves backend selection, builds the backend, and derives circuit names. */ + const prepareBackend = async ( + contractArgs: TArgs, + options: SimulatorOptions, + ): Promise> => { + const kind = resolveBackendKind(options.backend); + + // The local synchronous simulator: the whole dry path, and the pure-circuit + // evaluator in live (D2). In live this runs `initialState` in memory only — + // it is never deployed on-chain (INV-10). + const localSim = new DrySimClass(contractArgs, options); + const contract = localSim.contract; + const impureNames = Object.keys(contract.impureCircuits); + const impureSet = new Set(impureNames); + const pureNames = Object.keys(contract.circuits).filter( + (name) => !impureSet.has(name), + ); + + if (kind === 'live') { + const signers = new Signers({ + mode: 'live', + liveAliases: options.liveAliases, + resolveLiveKey: options.resolveLiveKey, + }); + + // Prefer an explicit ctx; otherwise the globally registered live backend. + let liveCtx = options.live; + if (!liveCtx) { + const factory = getRegisteredLiveBackend(); + if (factory) { + liveCtx = (await factory({ + config, + contractArgs, + options, + })) as LiveContext

; + } + } + if (!liveCtx) { + throw new Error( + 'live backend selected (MIDNIGHT_BACKEND=live) but no LiveContext available. ' + + 'Pass `{ live }` to create(), or call registerLiveBackend(...) in your ' + + 'test:live setup. The harness owns deploy/providers/wallets (INV-22).', ); } - return this._pureCircuitProxy; + + // INV-1/INV-2: the live adapter value is reached only via dynamic import, + // so a dry import never statically links it (and any future heavy deps). + const { LiveBackend } = await import('../live/LiveBackend.js'); + const backend = new LiveBackend({ + ctx: liveCtx, + pureSim: localSim as unknown as SyncSimulator, + signers, + ledgerExtractor: config.ledgerExtractor, + }); + return { backend, signers, pureNames, impureNames }; } + const signers = new Signers({ mode: 'dry', dryKeys: options.signerKeys }); + const backend = new DryBackend( + localSim as unknown as SyncSimulator, + signers, + ); + return { backend, signers, pureNames, impureNames }; + }; + + return class Simulator { + /** The backend this instance resolved to at construction (INV-8). */ + readonly backendKind: BackendKind; + + // Public (underscore-prefixed) to satisfy declaration emit for the returned + // anonymous class; treat as internal. + readonly _backend: Backend; + readonly _signers: Signers; + + /** Async circuit proxies; every call returns a promise (INV-4). */ + readonly circuits: { + pure: AsyncCircuits, P>; + impure: AsyncCircuits, P>; + }; + /** - * Gets the impure circuit proxy, creating it lazily if it doesn't exist. + * Internal constructor. Use the async static {@link create} instead — it + * resolves the backend (including the live dynamic import) before construction. * - * @returns The impure circuit proxy for executing state-modifying contract methods + * @param deps - Prepared backend wiring from {@link prepareBackend}. */ - public get impureCircuit(): ContextlessCircuits< - ExtractImpureCircuits, - P - > { - if (!this._impureCircuitProxy) { - this._impureCircuitProxy = this.createImpureCircuitProxy( - this.contract.impureCircuits as ExtractImpureCircuits, - () => this.getCallerContext(), - (ctx) => { - this.circuitContext = ctx; - }, - ); - } - return this._impureCircuitProxy; + constructor(deps: BackendDeps) { + this._backend = deps.backend; + this.backendKind = deps.backend.kind; + this._signers = deps.signers; + this.circuits = { + pure: buildProxy( + this._backend, + 'pure', + deps.pureNames, + ) as unknown as AsyncCircuits, P>, + impure: buildProxy( + this._backend, + 'impure', + deps.impureNames, + ) as unknown as AsyncCircuits, P>, + }; } /** - * Gets both pure and impure circuit proxies. + * Constructs a simulator. In dry, deploys from `contractArgs` to fresh + * in-memory state. In live, the caller already deployed; the args seed only + * the local pure-eval context, never an on-chain deploy (INV-10). * - * @returns Object containing both pure and impure circuit proxies + * @param contractArgs - Constructor args for the contract. + * @param options - Backend selection, witnesses, private state, live world. + * @returns The constructed simulator (subclass-aware via `this`). */ - public get circuits() { - return { - pure: this.pureCircuit, - impure: this.impureCircuit, - }; + static async create( + this: new ( + deps: BackendDeps, + ) => T, + contractArgs: TArgs = [] as unknown as TArgs, + options: SimulatorOptions = {}, + ): Promise { + const deps = await prepareBackend(contractArgs, options); + return new this(deps); + } + + /** The alias resolver for circuit-arg keys (`signers.eitherFor('OWNER')`). */ + get signers(): Signers { + return this._signers; } /** - * Resets cached circuit proxies, forcing re-initialization on next access. + * Sets the caller for the next call only, then reverts (INV-17). + * + * @param alias - The caller alias, or `null` for the default signer. + * @returns This instance, for chaining (`sim.as('OWNER').transfer(...)`). */ - public resetCircuitProxies(): void { - this._pureCircuitProxy = undefined; - this._impureCircuitProxy = undefined; + as(alias: string | null): this { + this._backend.setCaller(alias, 'single'); + return this; } /** - * Extracts the public ledger state from the current contract state. + * Sets a persistent caller for all subsequent calls until changed (INV-17). * - * @returns The current public state of the contract + * @param alias - The caller alias, or `null` to clear. + * @returns This instance, for chaining. */ - getPublicState(): L { - return config.ledgerExtractor( - this.circuitContext.currentQueryContext.state.state, - ); + setPersistentCaller(alias: string | null): this { + this._backend.setCaller(alias, 'persistent'); + return this; + } + + /** Clears the persistent caller (the single-shot caller self-resets per call). */ + resetCaller(): this { + this._backend.setCaller(null, 'persistent'); + return this; + } + + /** The public ledger state, via the shared extractor (INV-15). */ + getPublicState(): Promise { + return this._backend.getPublicState(); + } + + /** The private state (read parity across backends, INV-18). */ + getPrivateState(): Promise

{ + return this._backend.getPrivateState(); } - // Common witness management methods /** - * Gets the current witness functions. + * Replaces the private state (for per-module secret/nonce injection helpers). + * Dry mutates the in-memory context; live throws (INV-18 mutation asymmetry) — + * guard such specs with `isLiveBackend()`. * - * @returns The current witness function implementations + * @param privateState - The new private state. */ - public get witnesses(): W { - return this._witnesses; + setPrivateState(privateState: P): void { + this._backend.setPrivateState(privateState); + } + + /** The raw contract state value. */ + getContractState() { + return this._backend.getContractState(); + } + + /** The current witness set. */ + get witnesses(): W { + return this._backend.getWitnesses() as W; } /** - * Sets new witness functions and recreates the contract with them. - * - * @param newWitnesses - The new witness function implementations to use + * Replaces the whole witness set. Dry recreates the contract; live throws + * (INV-7). Equivalent to {@link setWitnesses}; kept for API compatibility. */ - public set witnesses(newWitnesses: W) { - this._witnesses = newWitnesses; - this.contract = config.contractFactory(this._witnesses); - this.resetCircuitProxies(); + set witnesses(newWitnesses: W) { + this._backend.setWitnesses(newWitnesses); } /** - * Overrides a specific witness function while keeping others unchanged. + * Overrides a single witness. Dry recreates the contract; live throws (INV-7). * - * @param key - The key of the witness function to override - * @param fn - The new implementation for the witness function + * @param key - The witness key. + * @param fn - The replacement implementation. */ - public overrideWitness(key: K, fn: W[K]) { - this.witnesses = { - ...this._witnesses, - [key]: fn, - } as W; + overrideWitness(key: K, fn: W[K]): void { + this._backend.overrideWitness(key as PropertyKey, fn); } /** - * Gets the current witness context with the proper structure for witness function calls. + * Replaces the whole witness set. Dry recreates the contract; live throws (INV-7). * - * @returns The current witness context that can be passed to witness functions + * @param witnesses - The new witness set. */ - public getWitnessContext(): WitnessContext { - const circuitCtx = this.circuitContext; - return { - ledger: this.getPublicState(), - privateState: circuitCtx.currentPrivateState, - contractAddress: circuitCtx.currentQueryContext.address, - }; + setWitnesses(witnesses: W): void { + this._backend.setWitnesses(witnesses); } }; } diff --git a/packages/simulator/src/index.ts b/packages/simulator/src/index.ts index 59523ed..b9e0642 100644 --- a/packages/simulator/src/index.ts +++ b/packages/simulator/src/index.ts @@ -1,14 +1,68 @@ -// biome-ignore lint/performance/noBarrelFile: entrypoint module +// biome-ignore-all lint/performance/noBarrelFile: package entrypoint + +// --- Backend seam ---------------------------------------------------------- +export type { Backend, BackendKind, CircuitKind } from './backend/Backend.js'; +export type { SyncSimulator } from './backend/DryBackend.js'; +export { DryBackend } from './backend/DryBackend.js'; export { AbstractSimulator } from './core/AbstractSimulator.js'; export { CircuitContextManager } from './core/CircuitContextManager.js'; export { ContractSimulator } from './core/ContractSimulator.js'; +// --- Core simulator (one factory, two backends) ---------------------------- +// A dry import pulls zero midnight-js (INV-2): the live adapter `LiveBackend` +// is type-only here and reached at runtime only via the dynamic import inside +// `createSimulator` (INV-1). `createLiveContext`/`registerLiveBackend` are +// values, but their static graph is midnight-js-free (type-only + dynamic +// import), so exporting them from the main barrel keeps the wall up. export { createSimulator } from './factory/createSimulator.js'; export type { SimulatorConfig } from './factory/SimulatorConfig.js'; export type { + CreateLiveContextOptions, + IndexerLagPolicy, +} from './live/createLiveContext.js'; +// --- Live wiring (harness-facing) ------------------------------------------ +export { + createLiveContext, + DEFAULT_INDEXER_LAG, +} from './live/createLiveContext.js'; +export type { LiveBackend } from './live/LiveBackend.js'; +export { WITNESS_OVERRIDE_UNSUPPORTED } from './live/LiveBackend.js'; +export type { + DeployedTxHandle, + FinalizedCallResult, + LiveContext, +} from './live/LiveContext.js'; +export type { + LiveBackendFactory, + LiveBackendRequest, +} from './live/registry.js'; +export { + clearLiveBackend, + getRegisteredLiveBackend, + isLiveBackend, + registerLiveBackend, +} from './live/registry.js'; +export type { + ContractAddress, + Either, + ZswapCoinPublicKey, +} from './signers/Signers.js'; +// --- Signers --------------------------------------------------------------- +export { + MAX_LIVE_SIGNERS, + Signers, + type SignersOptions, +} from './signers/Signers.js'; + +// --- Types ----------------------------------------------------------------- +export type { + AsyncCircuits, ContextlessCircuits, ExtractImpureCircuits, ExtractPureCircuits, IContractSimulator, IMinimalContract, } from './types/index.js'; -export type { BaseSimulatorOptions } from './types/Options.js'; +export type { + BaseSimulatorOptions, + SimulatorOptions, +} from './types/Options.js'; diff --git a/packages/simulator/src/live/LiveBackend.ts b/packages/simulator/src/live/LiveBackend.ts new file mode 100644 index 0000000..8ab5e69 --- /dev/null +++ b/packages/simulator/src/live/LiveBackend.ts @@ -0,0 +1,173 @@ +import type { StateValue } from '@midnight-ntwrk/compact-runtime'; +import type { Backend, BackendKind, CircuitKind } from '../backend/Backend.js'; +import type { SyncSimulator } from '../backend/DryBackend.js'; +import type { Signers } from '../signers/Signers.js'; +import type { LiveContext } from './LiveContext.js'; + +/** The error thrown when witnesses are swapped on the live backend (INV-7). */ +export const WITNESS_OVERRIDE_UNSUPPORTED = + 'witness override unsupported on live backend'; + +/** The error thrown when private state is mutated on the live backend (INV-18). */ +export const PRIVATE_STATE_MUTATION_UNSUPPORTED = + 'private-state mutation unsupported on live backend'; + +/** + * Dependencies the live adapter is constructed with by `createBackendSimulator`. + * + * @template P - Private state type. + * @template L - Public ledger state type. + */ +export interface LiveBackendDeps { + /** The caller-supplied live world (handles + readers). */ + ctx: LiveContext

; + /** + * A local in-memory simulator used solely to evaluate pure circuits (D2, + * INV-16). Never deployed on-chain (INV-10); pure circuits are state- and + * caller-independent, so its seed state does not affect results. + */ + pureSim: SyncSimulator; + /** Alias resolver, used here for the live signer cap (INV-21). */ + signers: Signers; + /** The shared ledger extractor, applied to indexer state for parity (INV-15). */ + ledgerExtractor: (state: StateValue) => L; +} + +/** + * The live backend: a thin adapter that routes operations to an injected + * {@link LiveContext} and normalizes results to match dry (INV-12, INV-13). + * + * It imports no midnight-js — all node wiring lives behind the + * {@link LiveContext} seam (INV-22). Routing follows pure/impure, not read/write + * (D2, INV-16): pure circuits run locally on the JS artifact, impure circuits + * submit a tx. + * + * @template P - Private state type. + * @template L - Public ledger state type. + */ +export class LiveBackend implements Backend { + readonly kind: BackendKind = 'live'; + + private readonly ctx: LiveContext

; + private readonly pureSim: SyncSimulator; + private readonly signers: Signers; + private readonly ledgerExtractor: (state: StateValue) => L; + + /** Caller active for all subsequent calls until changed (INV-17). */ + private persistentAlias: string | null = null; + /** Caller active for the next call only, then reverts (INV-17). */ + private singleAlias: string | null = null; + private hasSingle = false; + + constructor(deps: LiveBackendDeps) { + this.ctx = deps.ctx; + this.pureSim = deps.pureSim; + this.signers = deps.signers; + this.ledgerExtractor = deps.ledgerExtractor; + } + + get contractAddress(): string { + return this.ctx.contractAddress; + } + + /** + * Pure circuits evaluate locally on the JS artifact (no tx); impure circuits + * submit a tx via the per-alias handle and the result is normalized from + * `FinalizedCallTxData.private.result` to the bare `R` dry returns (INV-13). + */ + async call( + kind: CircuitKind, + name: string, + args: unknown[], + ): Promise { + if (kind === 'pure') { + const fn = this.pureSim.circuits.pure[name]; + if (typeof fn !== 'function') { + throw new Error(`unknown pure circuit "${name}"`); + } + const result = fn(...args); + this.consumeSingle(); + return result; + } + + const alias = this.activeAlias(); + const handle = await this.ctx.handleFor(alias); + const txFn = handle.callTx[name]; + if (typeof txFn !== 'function') { + throw new Error(`unknown impure circuit "${name}"`); + } + // INV-13: unwrap callTx's { public, private: { result } } to the bare R. + // INV-14: the assert message inside any rejection is preserved verbatim — + // we await directly and never catch/rewrite it. + const finalized = await txFn(...args); + this.consumeSingle(); + return finalized.private.result; + } + + /** INV-15: same extractor as dry, applied to indexer-sourced state. */ + async getPublicState(): Promise { + return this.ledgerExtractor(await this.ctx.queryLedger()); + } + + /** INV-18: read parity via the private-state provider. */ + async getPrivateState(): Promise

{ + return this.ctx.queryPrivateState(); + } + + async getContractState(): Promise { + return this.ctx.queryLedger(); + } + + /** + * Mid-test private-state mutation does not faithfully reproduce on live + * (INV-18). Throws so such specs are explicitly guarded with `isLiveBackend()` + * rather than silently passing against unchanged state. + */ + setPrivateState(_privateState: P): void { + throw new Error(PRIVATE_STATE_MUTATION_UNSUPPORTED); + } + + /** + * Validates the alias against the prefunded pool (INV-21) and records it. + * `'single'` applies to the next call then reverts; `'persistent'` holds until + * changed (INV-17). The lifecycle mirrors dry's `callerOverride` / + * `persistentCallerOverride`. + */ + setCaller(alias: string | null, mode: 'single' | 'persistent'): void { + if (alias !== null) this.signers.assertLiveAliasAllowed(alias); + if (mode === 'persistent') { + this.persistentAlias = alias; + } else { + this.singleAlias = alias; + this.hasSingle = true; + } + } + + /** Witnesses bind at deploy and cannot be swapped mid-test (INV-7). */ + overrideWitness(_key: PropertyKey, _fn: unknown): void { + throw new Error(WITNESS_OVERRIDE_UNSUPPORTED); + } + + /** Witnesses bind at deploy and cannot be swapped mid-test (INV-7). */ + setWitnesses(_witnesses: unknown): void { + throw new Error(WITNESS_OVERRIDE_UNSUPPORTED); + } + + /** Reads the local witness set (used for pure-circuit evaluation). */ + getWitnesses(): unknown { + return this.pureSim.witnesses; + } + + /** The alias active for the next call: single-shot takes priority over persistent. */ + private activeAlias(): string | null { + return this.hasSingle ? this.singleAlias : this.persistentAlias; + } + + /** Clears the single-shot caller after a call, reverting to persistent/default. */ + private consumeSingle(): void { + if (this.hasSingle) { + this.hasSingle = false; + this.singleAlias = null; + } + } +} diff --git a/packages/simulator/src/live/LiveContext.ts b/packages/simulator/src/live/LiveContext.ts new file mode 100644 index 0000000..2d4187e --- /dev/null +++ b/packages/simulator/src/live/LiveContext.ts @@ -0,0 +1,69 @@ +import type { StateValue } from '@midnight-ntwrk/compact-runtime'; + +/** + * The result shape the live adapter reads from a `callTx` invocation. + * + * Pinned against `@midnight-ntwrk/midnight-js-contracts`: the default + * `handle.callTx[name](...args)` resolves to a `FinalizedCallTxData` whose + * circuit return value lives at `.private.result` (INV-13). A harness's + * `FoundContract['callTx']` is structurally assignable to this. + */ +export interface FinalizedCallResult { + readonly private: { readonly result: unknown }; +} + +/** + * The minimal slice of a deployed-contract handle the live adapter needs: + * a `callTx` map from circuit name to an async tx submission. + * + * Kept structural so the package's runtime graph never imports midnight-js + * (INV-2). A harness's `FoundContract` satisfies this without any cast. + */ +export interface DeployedTxHandle { + readonly callTx: Record< + string, + (...args: unknown[]) => Promise + >; +} + +/** + * The injection seam between the package and a live Midnight node. + * + * Defined by the package, implemented by the caller's harness, which owns all + * live infra (deploy, providers, wallet pool) per INV-22. The package's adapter + * ({@link LiveBackend}) is a pure consumer of this interface and imports no + * midnight-js itself. + * + * Parameterized only by `P` (private state): the contract type `C` and ledger + * type `L` are erased into structural types ({@link DeployedTxHandle}) and the + * shared `ledgerExtractor`, so a harness can implement this without fighting + * midnight-js generics. + * + * @template P - Private state type. + */ +export interface LiveContext

{ + /** The address of the contract the harness already deployed (INV-10). */ + readonly contractAddress: string; + + /** + * Resolves a per-alias deployed-contract handle, signing with that alias's + * prefunded wallet. `null` selects the default signer. Implementations should + * cache handles per alias. + * + * @param alias - The caller alias, or `null` for the default signer. + */ + handleFor(alias: string | null): Promise; + + /** + * Reads the current public contract state from the indexer as a `StateValue`, + * ready to feed the shared `ledgerExtractor` (INV-15). Implementations should + * absorb bounded indexer lag so read-after-write is stable (INV-11). + */ + queryLedger(): Promise; + + /** + * Reads the contract's private state from the private-state provider (INV-18, + * read parity). + */ + queryPrivateState(): Promise

; +} diff --git a/packages/simulator/src/live/createLiveContext.ts b/packages/simulator/src/live/createLiveContext.ts new file mode 100644 index 0000000000000000000000000000000000000000..79876844bac52a803c796dfb89af64f43ec4479b GIT binary patch literal 6651 zcma)AZExGi5$2QCKT-CGD9ac}vo=;uFvx z(O=kK(r0FODe5h#Kg15no!y;z=9y>4%!=GrR4ock&-C|7Rr*(z)|!?yw)u<>-_1;t znaQ;3W!2n19L4#pP;u3>byk^~K0H1+I-)x)@8wy#AS3ol`tQI0L)xlRC!{JG)h124 zROVDo^@3unu@7ZAc`KxoiKZ%NTg`sIl@win`nC6_)2F+sCb1tDw7-(YCWCEL(%7V$ z@EaSkwN!muP_oF>%*6h1WU|sEnuckvlJ_P{E_IRS3!Pl#S!HpzVgJBg!@K2x=+>G? z99jHxYx75}w_Sc-k5Us~s!FY%?(lL*XEIPajm4q`FHt=27#p^i$PD zq<_?U+W(Es92~uPaX>HVm%PprxZLW>E~v%E}!TpZE2L|@k1|F z6Zn-1xkj(#@MEz`O1386fBhQnKTqds!M@E*r3a*FqO;m$8Ude~vShn+i&r#OCKcaP zS@Zv`LL4lDp)$6JUlTb*)yHMWs!pqt;yeQ&D)UX#=fAxn+?>j3UZ)9-fQdR|2u&1% zR$*ftE(LHB-G{xS1N|iMHSmxft60;uyWWkO+`8L8BTDcNeN<2EkQ8vN^sK#Q-?g=cA2dLtts|5B%F z@o~%0FWx|`O^S`z*Ui+^*0QC9H~1?AP(tS;9I@Xx;|hjmY4S{^U2$E@b19%CoMPL8)il4PF@#fVl zJX@|H`njlZK41x`e0~QHJa{^2fqKcoWxR0OgKZuZ*g?`?m8G7IQeBct8K;Y$T$wRn z7#-{Km=ek6uJZ-blxg1sC?YuG z9b-1-?7XF3U?Xz*${+5@h6|jf(k;(=@1NYBNh~=r*vBeKEZ;UC%L#*Tohxyd=B|zs zUO5O+^ZeYcAK*k5?({hWD8N^@+V)gxR7t}N@3P>^a)_Ex7e#8Y$7s>^j-z}RrK8eJ zGMIJt5;GoaTT%gD5xd7U%<429de#R3gjk4IY0HLuM8FccvoQpd?V^1?Fmr$hl`Y@r zc0iGA6D~)8fEm)XL#Jm{XAc>|7&wAm$@LK>Xyvo6E2OQ+ZybsCoW>m`*bBZJY;Jm; zXjI~{fjgM|EzPGUo{BF+*nsE46)+DO)W@;{`6ZsN{97q+*Gaf(xK;?424}n4X)hExwTX#=`Ed}wuU$w|8fhu*;_W@&X};Q1fz*In_(RHT@@|t|uxwhGN;>sXrLCD60v~j>$p72@b3T|^MG}_Yo zZN(b6q&)1fIhffCBRZ82AO!NHzY7;|L0~cdbNBUmmnC+oKxVjpUFwd%09V#IWqM9m z*5;PehJOxOA_DES*|O}s23Q?MoLRZ`+To=`hxF1Lg;_uZo0I4#+(;jCM)D7*9lu$+ z2jZ6{-u)l(U_s=7Ql?rLocyy!DYlUDn45%g0IPgo511(Om=_jsQ7v_Khkh)ttMIZ5 zIqJ@Ye?y0Gz7kMDezoL*Z(Y1X+0<4+jQD!MX>2v?Ce#C$(?QzIMZV|ouAPIRSobGA z3-!&t0}8hj1e{wzV)XbEq@SRQqDQ0lP)Y~-ZM$D_Jx0YAU8@6m?|UIn&*%gvO-JK2 zAndr}&}w{O$k!N@l{?2KDp3Xc3yQp6Z9A;Pg%7O}JK>oBg>=*@YZI=q8WoB~8}7zO zb9n~d5iJYBBbZ_w)HCL(Xg&oUIbqA5(LgA=vlc)*AqMZ*@+=evXu=Opu(o36+tjk# z$TWX#C)U^!y>nPC>3k;|xY$FM<*2k!hxk2ot$U>G1a_a@1(qF8eZCums1`om7WUAc zoUKhc`;I>C9v}@`o(_`L)2o{7ZBU5%{r--Q#$_qq`R)6w!p*Ocl7Sl?7B86KkeQr# zA62mu4W6|x{n#LM>t|;XCt5J#Q}lzwH4=QMid+?eEpvhXgTV((PQchArQg1#!#{bU zi~gV4$T`NbGQMlwp+C`8gw;X@?Q*hFI@)%4VpGA)zB(3l`pE7qigxik(~_Ktcx138 zw4)G!B0v-}+qnnX?dZ(i$Vur7-!=>aaQd5~I54t!LAtW0BME!KJ?$5(6`R^OELeXY zzF&6SU*4B(FN1;E%UzjvRsI8C3&fr5Unc&hLb$7LoUDDzjU$Us-ImgrtU;{HhGo_v zzmeRSfcRld;6QG=x#Wc{ooZ(_4SB$-UNyAorXNtj!5UBdN}8K$3X&G}#Oa=K^U)fM zu%41xQ)|Ga#>}277{cabV`rd2H#2g4%VVZ5B4k`C8A>xEv&f;GxkPc%pi%yLf#DtN zMSJxmLCx(@@DCa-!E5pf_i^OK78-E7xRDiY-L=}n2`yg03OV7)4TE1z`mF(n%MbTS zs>%xLqfzAH9eX%p8-C0LE3cqe-OPzjoK1E&MkvkmmoI~~;S;Qs8vidOHp&bNNlW=IfLcm5?hPj_c zLZYo}dl_MLA%suyYPEQ^lyBZdPQc&;w{FL2Xwh+zAZN{e0ZSio=l;xO?h_#RO;=2= zhGnf~;SAyT9>q(TeB>L+46MMQAO>+eC()`KQWUSv1m z#Q!%Fyxr=z)7%fc7!LqFdLIYptz6nMDUj0HU@3TYcWG$ix5^k?a5H1}hSCMooAyU^ kLmCe053882@4ozDxJupT2mv@gljOn<;^IeCq2 { + /** The simulator config (contract factory, witnesses, ledger extractor, …). */ + config: SimulatorConfig; + /** The constructor args the test passed to `create`. */ + contractArgs: TArgs; + /** The options the test passed to `create`. */ + options: SimulatorOptions; +} + +/** + * Produces a {@link LiveContext} for a given request. Registered once by the + * consuming harness (typically in a `test:live` setup file). + */ +export type LiveBackendFactory = ( + // The registry is contract-agnostic; each call is concretely typed at the create() site. + req: LiveBackendRequest, +) => Promise>; + +let registeredFactory: LiveBackendFactory | undefined; + +/** + * Registers the live backend the simulator attaches to when + * `MIDNIGHT_BACKEND=live` and no explicit `{ live }` is passed to `create`. + * + * Call this once from your `test:live` setup. It keeps the per-module test files + * backend-agnostic: `await Sim.create()` works on both backends. + * + * @param factory - Builds a {@link LiveContext} per `create` call. + */ +export function registerLiveBackend(factory: LiveBackendFactory): void { + registeredFactory = factory; +} + +/** Clears the registered live backend (mainly for test teardown). */ +export function clearLiveBackend(): void { + registeredFactory = undefined; +} + +/** Returns the registered live backend factory, if any. */ +export function getRegisteredLiveBackend(): LiveBackendFactory | undefined { + return registeredFactory; +} + +/** + * Whether the live backend is selected via `MIDNIGHT_BACKEND=live`. + * + * Use it in specs to guard the documented dry↔live asymmetries, e.g. + * `it.skipIf(isLiveBackend())('rejects witness override', …)`. + */ +export function isLiveBackend(): boolean { + return process.env.MIDNIGHT_BACKEND === 'live'; +} diff --git a/packages/simulator/src/signers/Signers.ts b/packages/simulator/src/signers/Signers.ts new file mode 100644 index 0000000..6d273c2 --- /dev/null +++ b/packages/simulator/src/signers/Signers.ts @@ -0,0 +1,173 @@ +import { + type CoinPublicKey, + convertFieldToBytes, + encodeCoinPublicKey, +} from '@midnight-ntwrk/compact-runtime'; +import type { BackendKind } from '../backend/Backend.js'; + +/** + * The number of prefunded wallets available on the dev-preset live node: + * the deployer plus three named aliases (D1, INV-21). Requesting more requires + * the deferred derive-and-fund flow. + */ +export const MAX_LIVE_SIGNERS = 4; + +/** Structural mirror of a generated artifact's `ZswapCoinPublicKey`. */ +export type ZswapCoinPublicKey = { bytes: Uint8Array }; + +/** Structural mirror of a generated artifact's `ContractAddress`. */ +export type ContractAddress = { bytes: Uint8Array }; + +/** Structural mirror of a generated artifact's `Either`. */ +export type Either = { + is_left: boolean; + left: L; + right: R; +}; + +/** + * Converts an ASCII alias to a 64-char zero-padded hex string. + * + * This is the exact derivation the existing test harness uses + * (`generatePubKeyPair` / `encodeToPK`), so a backend-aware simulator resolves + * an alias to the same key the current synchronous specs do — preserving dry + * parity for migrated modules (INV-17). + * + * @param alias - The caller alias. + * @returns A 64-char hex `CoinPublicKey`. + */ +const aliasToHex = (alias: string): CoinPublicKey => + Buffer.from(alias, 'ascii').toString('hex').padStart(64, '0'); + +const zeroBytes = (): Uint8Array => convertFieldToBytes(32, 0n, ''); + +/** + * Configuration for {@link Signers}. + */ +export interface SignersOptions { + /** The backend this resolver serves. */ + mode: BackendKind; + /** + * Dry only: override the default deterministic alias derivation with an + * explicit alias→key map. Aliases not present fall back to the default + * derivation (OQ4). + */ + dryKeys?: Readonly>; + /** + * Live only: the aliases backed by a prefunded wallet on the node. Capped at + * {@link MAX_LIVE_SIGNERS} (INV-21). Requesting an alias outside this set + * fails with a clear error rather than silently reusing a wallet. + */ + liveAliases?: readonly string[]; + /** + * Live only: resolve an alias to its wallet's coin public key. Supplied by the + * caller's harness, which owns wallet provisioning (INV-22). + */ + resolveLiveKey?: (alias: string) => CoinPublicKey | Promise; +} + +/** + * Resolves caller-identity aliases to keys, uniformly across backends. + * + * Alias strings are the common currency for caller identity (D1): `as('OWNER')` + * denotes the same logical actor in both modes (INV-17). Dry derives a + * deterministic key from the alias label; live resolves the alias to a pooled, + * prefunded wallet, enforcing the {@link MAX_LIVE_SIGNERS} cap (INV-21). + * + * The public resolvers ({@link keyFor}, {@link eitherFor}) are async so spec + * code is uniform `await` across backends, even though dry resolves + * synchronously. + */ +export class Signers { + readonly mode: BackendKind; + private readonly dryKeys: Readonly>; + private readonly liveAliases: ReadonlySet; + private readonly resolveLiveKey?: ( + alias: string, + ) => CoinPublicKey | Promise; + + constructor(options: SignersOptions) { + this.mode = options.mode; + this.dryKeys = options.dryKeys ?? {}; + this.liveAliases = new Set(options.liveAliases ?? []); + this.resolveLiveKey = options.resolveLiveKey; + + if (this.liveAliases.size > MAX_LIVE_SIGNERS) { + throw new Error( + `live backend supports at most ${MAX_LIVE_SIGNERS} prefunded signers; ` + + `got ${this.liveAliases.size}. The derive-and-fund flow for more is deferred.`, + ); + } + } + + /** + * Synchronous dry-mode alias→key resolution. + * + * Used by the dry backend's `setCaller`, which is synchronous. Not used in + * live mode (live resolution is deferred to the async handle cache). + * + * @param alias - The caller alias. + * @returns The deterministic dry key for the alias. + */ + public resolveDryKey(alias: string): CoinPublicKey { + return this.dryKeys[alias] ?? aliasToHex(alias); + } + + /** + * Asserts an alias is backed by a prefunded wallet on the live node. + * + * Throws the cap error rather than silently reusing a wallet or proceeding + * with an unfunded one (INV-21). A no-op in dry mode. + * + * @param alias - The caller alias to validate. + */ + public assertLiveAliasAllowed(alias: string): void { + if (this.mode !== 'live') return; + if (this.liveAliases.size > 0 && !this.liveAliases.has(alias)) { + throw new Error( + `live signer "${alias}" is not in the prefunded pool ` + + `[${[...this.liveAliases].join(', ')}] (max ${MAX_LIVE_SIGNERS}). ` + + 'Add it to the wallet pool or use the deferred derive-and-fund flow.', + ); + } + } + + /** + * Resolves an alias to a raw {@link CoinPublicKey}, for use as a circuit arg. + * + * @param alias - The caller alias. + * @returns The key for the alias. + */ + public async keyFor(alias: string): Promise { + if (this.mode === 'live') { + this.assertLiveAliasAllowed(alias); + if (!this.resolveLiveKey) { + throw new Error( + `cannot resolve live key for "${alias}": no resolveLiveKey supplied. ` + + 'The caller harness must provide one (INV-22).', + ); + } + return this.resolveLiveKey(alias); + } + return this.resolveDryKey(alias); + } + + /** + * Resolves an alias to an `Either`, + * the shape circuits expect for an owner/user argument. Always the left + * (coin-public-key) variant; contract-address owners are out of scope here. + * + * @param alias - The caller alias. + * @returns The `Either` wrapping the alias's coin public key. + */ + public async eitherFor( + alias: string, + ): Promise> { + const key = await this.keyFor(alias); + return { + is_left: true, + left: { bytes: encodeCoinPublicKey(key) }, + right: { bytes: zeroBytes() }, + }; + } +} diff --git a/packages/simulator/src/types/Circuit.ts b/packages/simulator/src/types/Circuit.ts index 232b28c..1749b8a 100644 --- a/packages/simulator/src/types/Circuit.ts +++ b/packages/simulator/src/types/Circuit.ts @@ -38,3 +38,22 @@ export type ContextlessCircuits = { ? (...args: P) => R : never; }; + +/** + * Async sibling of {@link ContextlessCircuits}, used by `createBackendSimulator`. + * + * Identical to {@link ContextlessCircuits} except every circuit returns + * `Promise` instead of `R`. This is the type-level half of dry↔live parity + * (INV-4): the dry backend wraps its synchronous result in `Promise.resolve`, + * the live backend awaits the network, and spec code is uniform `await` across + * both. A circuit can never return a bare value on one backend and a `Promise` + * on the other. + */ +export type AsyncCircuits = { + [K in keyof Circuits]: Circuits[K] extends ( + ctx: CircuitContext, + ...args: infer P + ) => { result: infer R; context?: CircuitContext } + ? (...args: P) => Promise + : never; +}; diff --git a/packages/simulator/src/types/Options.ts b/packages/simulator/src/types/Options.ts index e876530..00f7a39 100644 --- a/packages/simulator/src/types/Options.ts +++ b/packages/simulator/src/types/Options.ts @@ -2,6 +2,8 @@ import type { CoinPublicKey, ContractAddress, } from '@midnight-ntwrk/compact-runtime'; +import type { BackendKind } from '../backend/Backend.js'; +import type { LiveContext } from '../live/LiveContext.js'; /** * Base configuration options for simulator constructors. @@ -19,3 +21,30 @@ export type BaseSimulatorOptions = { /** Contract deployment address */ contractAddress?: ContractAddress; }; + +/** + * Options for `createSimulator`'s async `create`. Extends the base construction + * options with backend selection and the live-world injection seam. + * + * @template P - Private state type. + * @template W - Witnesses type. + */ +export interface SimulatorOptions extends BaseSimulatorOptions { + /** + * Force a backend instead of reading `MIDNIGHT_BACKEND`. Mainly for tests that + * pin a backend regardless of the environment. + */ + backend?: BackendKind; + /** + * The live world, supplied by the caller's harness (INV-22). In live mode this + * is used if provided; otherwise the globally registered live backend (see + * `registerLiveBackend`) is used. Ignored in dry mode. + */ + live?: LiveContext

; + /** Dry only: override the deterministic alias→key derivation (OQ4). */ + signerKeys?: Readonly>; + /** Live only: the prefunded alias pool (max `MAX_LIVE_SIGNERS`, INV-21). */ + liveAliases?: readonly string[]; + /** Live only: resolve an alias to its wallet's coin public key (INV-22). */ + resolveLiveKey?: (alias: string) => CoinPublicKey | Promise; +} diff --git a/packages/simulator/src/types/index.ts b/packages/simulator/src/types/index.ts index c4fb050..f94da8e 100644 --- a/packages/simulator/src/types/index.ts +++ b/packages/simulator/src/types/index.ts @@ -1,6 +1,7 @@ // Re-export all types from type modules export type { + AsyncCircuits, ContextlessCircuits, ExtractImpureCircuits, ExtractPureCircuits, diff --git a/packages/simulator/test/integration/SampleZOwnable.test.ts b/packages/simulator/test/integration/SampleZOwnable.test.ts index 1a21bb9..02681d2 100644 --- a/packages/simulator/test/integration/SampleZOwnable.test.ts +++ b/packages/simulator/test/integration/SampleZOwnable.test.ts @@ -10,10 +10,10 @@ import { SampleZOwnablePrivateState } from '../fixtures/sample-contracts/witness import * as utils from '../fixtures/utils/address.js'; import { SampleZOwnableSimulator } from './SampleZOwnableSimulator.js'; -// PKs -const [OWNER, Z_OWNER] = utils.generatePubKeyPair('OWNER'); -const [NEW_OWNER, Z_NEW_OWNER] = utils.generatePubKeyPair('NEW_OWNER'); -const [UNAUTHORIZED, _] = utils.generatePubKeyPair('UNAUTHORIZED'); +// PKs — the caller identity now travels as an alias string (`as('OWNER')`); the +// alias resolves to the same deterministic key these encoded PKs are built from. +const [, Z_OWNER] = utils.generatePubKeyPair('OWNER'); +const [, Z_NEW_OWNER] = utils.generatePubKeyPair('NEW_OWNER'); const INSTANCE_SALT = new Uint8Array(32).fill(8675309); const BAD_NONCE = Buffer.from(Buffer.alloc(32, 'BAD_NONCE')); @@ -98,30 +98,30 @@ const buildCommitment = ( describe('SampleZOwnable', () => { describe('before initialize', () => { - it('should fail when setting owner commitment as 0', () => { - expect(() => { - const badId = new Uint8Array(32).fill(0); - new SampleZOwnableSimulator(badId, INSTANCE_SALT); - }).toThrow('SampleZOwnable: invalid id'); + it('should fail when setting owner commitment as 0', async () => { + const badId = new Uint8Array(32).fill(0); + await expect( + SampleZOwnableSimulator.create(badId, INSTANCE_SALT), + ).rejects.toThrow('SampleZOwnable: invalid id'); }); - it('should initialize with non-zero commitment', () => { + it('should initialize with non-zero commitment', async () => { const notZeroPK = utils.encodeToPK('NOT_ZERO'); const notZeroNonce = new Uint8Array(32).fill(1); const nonZeroId = createIdHash(notZeroPK, notZeroNonce); - ownable = new SampleZOwnableSimulator(nonZeroId, INSTANCE_SALT); + ownable = await SampleZOwnableSimulator.create(nonZeroId, INSTANCE_SALT); const nonZeroCommitment = buildCommitmentFromId( nonZeroId, INSTANCE_SALT, INIT_COUNTER, ); - expect(ownable.owner()).toEqual(nonZeroCommitment); + expect(await ownable.owner()).toEqual(nonZeroCommitment); }); }); describe('after initialization', () => { - beforeEach(() => { + beforeEach(async () => { // Create private state object and generate nonce const PS = SampleZOwnablePrivateState.generate(); // Bind nonce for convenience @@ -129,13 +129,13 @@ describe('SampleZOwnable', () => { // Prepare owner ID with gen nonce const ownerId = createIdHash(Z_OWNER, secretNonce); // Deploy contract with derived owner commitment and PS - ownable = new SampleZOwnableSimulator(ownerId, INSTANCE_SALT, { + ownable = await SampleZOwnableSimulator.create(ownerId, INSTANCE_SALT, { privateState: PS, }); }); describe('owner', () => { - it('should return the correct owner commitment', () => { + it('should return the correct owner commitment', async () => { const expCommitment = buildCommitment( Z_OWNER, secretNonce, @@ -143,7 +143,7 @@ describe('SampleZOwnable', () => { INIT_COUNTER, DOMAIN, ); - expect(ownable.owner()).toEqual(expCommitment); + expect(await ownable.owner()).toEqual(expCommitment); }); }); @@ -167,56 +167,58 @@ describe('SampleZOwnable', () => { ); }); - it('should transfer ownership', () => { - ownable.as(OWNER).transferOwnership(newIdHash); - expect(ownable.owner()).toEqual(newOwnerCommitment); + it('should transfer ownership', async () => { + await ownable.as('OWNER').transferOwnership(newIdHash); + expect(await ownable.owner()).toEqual(newOwnerCommitment); // Old owner - expect(() => { - ownable.as(OWNER).assertOnlyOwner(); - }).toThrow('SampleZOwnable: caller is not the owner'); + await expect(ownable.as('OWNER').assertOnlyOwner()).rejects.toThrow( + 'SampleZOwnable: caller is not the owner', + ); // Unauthorized - expect(() => { - ownable.as(UNAUTHORIZED).assertOnlyOwner(); - }).toThrow('SampleZOwnable: caller is not the owner'); + await expect( + ownable.as('UNAUTHORIZED').assertOnlyOwner(), + ).rejects.toThrow('SampleZOwnable: caller is not the owner'); // New owner - ownable.privateState.injectSecretNonce(Buffer.from(newOwnerNonce)); - expect(ownable.as(NEW_OWNER).assertOnlyOwner()).not.to.throw; + await ownable.privateState.injectSecretNonce( + Buffer.from(newOwnerNonce), + ); + await ownable.as('NEW_OWNER').assertOnlyOwner(); }); - it('should fail when transferring to id zero', () => { + it('should fail when transferring to id zero', async () => { const badId = new Uint8Array(32).fill(0); - expect(() => { - ownable.as(OWNER).transferOwnership(badId); - }).toThrow('SampleZOwnable: invalid id'); + await expect( + ownable.as('OWNER').transferOwnership(badId), + ).rejects.toThrow('SampleZOwnable: invalid id'); }); - it('should fail when unauthorized transfers ownership', () => { - expect(() => { - ownable.as(UNAUTHORIZED).transferOwnership(newOwnerCommitment); - }).toThrow('SampleZOwnable: caller is not the owner'); + it('should fail when unauthorized transfers ownership', async () => { + await expect( + ownable.as('UNAUTHORIZED').transferOwnership(newOwnerCommitment), + ).rejects.toThrow('SampleZOwnable: caller is not the owner'); }); /** * @description More thoroughly tested in `_transferOwnership` * */ - it('should bump instance after transfer', () => { - const beforeInstance = ownable.getPublicState()._counter; + it('should bump instance after transfer', async () => { + const beforeInstance = (await ownable.getPublicState())._counter; // Transfer - ownable.as(OWNER).transferOwnership(newOwnerCommitment); + await ownable.as('OWNER').transferOwnership(newOwnerCommitment); // Check counter - const afterInstance = ownable.getPublicState()._counter; + const afterInstance = (await ownable.getPublicState())._counter; expect(afterInstance).toEqual(beforeInstance + 1n); }); - it('should change commitment when transferring ownership to self with same pk + nonce)', () => { + it('should change commitment when transferring ownership to self with same pk + nonce)', async () => { // Confirm current commitment const repeatedId = createIdHash(Z_OWNER, secretNonce); - const initCommitment = ownable.owner(); + const initCommitment = await ownable.owner(); const expInitCommitment = buildCommitmentFromId( repeatedId, INSTANCE_SALT, @@ -225,10 +227,10 @@ describe('SampleZOwnable', () => { expect(initCommitment).toEqual(expInitCommitment); // Transfer ownership to self with the same id -> `H(pk, nonce)` - ownable.as(OWNER).transferOwnership(repeatedId); + await ownable.as('OWNER').transferOwnership(repeatedId); // Check commitments don't match - const newCommitment = ownable.owner(); + const newCommitment = await ownable.owner(); expect(initCommitment).not.toEqual(newCommitment); // Build commitment locally and validate new commitment == expected @@ -241,93 +243,93 @@ describe('SampleZOwnable', () => { expect(newCommitment).toEqual(expNewCommitment); // Check same owner maintains permissions after transfer - expect(ownable.as(OWNER).assertOnlyOwner()).not.to.throw; + await ownable.as('OWNER').assertOnlyOwner(); }); }); describe('renounceOwnership', () => { - it('should renounce ownership', () => { - ownable.as(OWNER).renounceOwnership(); + it('should renounce ownership', async () => { + await ownable.as('OWNER').renounceOwnership(); // Check owner is reset - expect(ownable.owner()).toEqual(new Uint8Array(32).fill(0)); + expect(await ownable.owner()).toEqual(new Uint8Array(32).fill(0)); // Check revoked permissions - expect(() => { - ownable.assertOnlyOwner(); - }).toThrow('SampleZOwnable: caller is not the owner'); + await expect(ownable.assertOnlyOwner()).rejects.toThrow( + 'SampleZOwnable: caller is not the owner', + ); }); - it('should fail when renouncing from unauthorized', () => { - expect(() => { - ownable.as(UNAUTHORIZED).renounceOwnership(); - }).toThrow('SampleZOwnable: caller is not the owner'); + it('should fail when renouncing from unauthorized', async () => { + await expect( + ownable.as('UNAUTHORIZED').renounceOwnership(), + ).rejects.toThrow('SampleZOwnable: caller is not the owner'); }); - it('should fail when renouncing from authorized with bad nonce', () => { - ownable.privateState.injectSecretNonce(BAD_NONCE); - expect(() => { - ownable.as(OWNER).renounceOwnership(); - }).toThrow('SampleZOwnable: caller is not the owner'); + it('should fail when renouncing from authorized with bad nonce', async () => { + await ownable.privateState.injectSecretNonce(BAD_NONCE); + await expect(ownable.as('OWNER').renounceOwnership()).rejects.toThrow( + 'SampleZOwnable: caller is not the owner', + ); }); - it('should fail when renouncing from unauthorized with bad nonce', () => { - ownable.privateState.injectSecretNonce(BAD_NONCE); - expect(() => { - ownable.as(UNAUTHORIZED).renounceOwnership(); - }); + it('should fail when renouncing from unauthorized with bad nonce', async () => { + await ownable.privateState.injectSecretNonce(BAD_NONCE); + await expect( + ownable.as('UNAUTHORIZED').renounceOwnership(), + ).rejects.toThrow('SampleZOwnable: caller is not the owner'); }); }); describe('assertOnlyOwner', () => { - it('should allow authorized caller with correct nonce to call', () => { + it('should allow authorized caller with correct nonce to call', async () => { // Check nonce is correct - expect(ownable.privateState.getCurrentSecretNonce()).toEqual( + expect(await ownable.privateState.getCurrentSecretNonce()).toEqual( secretNonce, ); - expect(ownable.as(OWNER).assertOnlyOwner()).to.not.throw; + await ownable.as('OWNER').assertOnlyOwner(); }); - it('should fail when the authorized caller has the wrong nonce', () => { + it('should fail when the authorized caller has the wrong nonce', async () => { // Inject bad nonce - ownable.privateState.injectSecretNonce(BAD_NONCE); + await ownable.privateState.injectSecretNonce(BAD_NONCE); // Check nonce does not match - expect(ownable.privateState.getCurrentSecretNonce()).not.toEqual( + expect(await ownable.privateState.getCurrentSecretNonce()).not.toEqual( secretNonce, ); // Set caller and call circuit - expect(() => { - ownable.as(OWNER).assertOnlyOwner(); - }).toThrow('SampleZOwnable: caller is not the owner'); + await expect(ownable.as('OWNER').assertOnlyOwner()).rejects.toThrow( + 'SampleZOwnable: caller is not the owner', + ); }); - it('should fail when unauthorized caller has the correct nonce', () => { + it('should fail when unauthorized caller has the correct nonce', async () => { // Check nonce is correct - expect(ownable.privateState.getCurrentSecretNonce()).toEqual( + expect(await ownable.privateState.getCurrentSecretNonce()).toEqual( secretNonce, ); - expect(() => { - ownable.as(UNAUTHORIZED).assertOnlyOwner(); - }).toThrow('SampleZOwnable: caller is not the owner'); + await expect( + ownable.as('UNAUTHORIZED').assertOnlyOwner(), + ).rejects.toThrow('SampleZOwnable: caller is not the owner'); }); - it('should fail when unauthorized caller has the wrong nonce', () => { + it('should fail when unauthorized caller has the wrong nonce', async () => { // Inject bad nonce - ownable.privateState.injectSecretNonce(BAD_NONCE); + await ownable.privateState.injectSecretNonce(BAD_NONCE); // Check nonce does not match - expect(ownable.privateState.getCurrentSecretNonce()).not.toEqual( + expect(await ownable.privateState.getCurrentSecretNonce()).not.toEqual( secretNonce, ); // Set unauthorized caller and call circuit - expect(() => { - ownable.as(UNAUTHORIZED).assertOnlyOwner(); - }).toThrow('SampleZOwnable: caller is not the owner'); + await expect( + ownable.as('UNAUTHORIZED').assertOnlyOwner(), + ).rejects.toThrow('SampleZOwnable: caller is not the owner'); }); }); @@ -352,14 +354,17 @@ describe('SampleZOwnable', () => { ]; it.each( testCases, - )('should match commitment for $label with counter $counter', ({ + )('should match commitment for $label with counter $counter', async ({ ownerPK, counter, }) => { const id = createIdHash(ownerPK, secretNonce); // Check buildCommitmentFromId - const hashFromContract = ownable._computeOwnerCommitment(id, counter); + const hashFromContract = await ownable._computeOwnerCommitment( + id, + counter, + ); const hashFromHelper1 = buildCommitmentFromId( id, INSTANCE_SALT, @@ -400,21 +405,21 @@ describe('SampleZOwnable', () => { it.each( testCases, - )('should match local and contract owner id for $label', ({ + )('should match local and contract owner id for $label', async ({ eitherOwner, nonce, }) => { - const ownerId = ownable._computeOwnerId(eitherOwner, nonce); + const ownerId = await ownable._computeOwnerId(eitherOwner, nonce); const expId = createIdHash(eitherOwner.left, nonce); expect(ownerId).toEqual(expId); }); - it('should fail to compute ContractAddress id', () => { + it('should fail to compute ContractAddress id', async () => { const eitherContract = utils.createEitherTestContractAddress('CONTRACT'); - expect(() => { - ownable._computeOwnerId(eitherContract, secretNonce); - }).toThrow( + await expect( + ownable._computeOwnerId(eitherContract, secretNonce), + ).rejects.toThrow( 'SampleZOwnable: contract address owners are not yet supported', ); }); diff --git a/packages/simulator/test/integration/SampleZOwnableSimulator.ts b/packages/simulator/test/integration/SampleZOwnableSimulator.ts index e2e26f2..415feaf 100644 --- a/packages/simulator/test/integration/SampleZOwnableSimulator.ts +++ b/packages/simulator/test/integration/SampleZOwnableSimulator.ts @@ -1,4 +1,4 @@ -import { type BaseSimulatorOptions, createSimulator } from '../../src/index'; +import { createSimulator, type SimulatorOptions } from '../../src/index'; import { type ContractAddress, type Either, @@ -44,15 +44,19 @@ const SampleZOwnableSimulatorBase = createSimulator< * SampleZOwnable Simulator */ export class SampleZOwnableSimulator extends SampleZOwnableSimulatorBase { - constructor( + static async create( ownerId: Uint8Array, instanceSalt: Uint8Array, - options: BaseSimulatorOptions< + options: SimulatorOptions< SampleZOwnablePrivateState, ReturnType > = {}, - ) { - super([ownerId, instanceSalt], options); + ): Promise { + // biome-ignore lint/complexity/noThisInStatic: super.create must keep the subclass `this` + return super.create( + [ownerId, instanceSalt], + options, + ) as Promise; } /** @@ -60,7 +64,7 @@ export class SampleZOwnableSimulator extends SampleZOwnableSimulatorBase { * The full commitment is: `SHA256(SHA256(pk, nonce), instanceSalt, counter, domain)`. * @returns The current owner's commitment. */ - public owner(): Uint8Array { + public owner(): Promise { return this.circuits.impure.owner(); } @@ -69,8 +73,8 @@ export class SampleZOwnableSimulator extends SampleZOwnableSimulatorBase { * `newOwnerId` must be precalculated and given to the current owner off chain. * @param newOwnerId The new owner's unique identifier (`SHA256(pk, nonce)`). */ - public transferOwnership(newOwnerId: Uint8Array) { - this.circuits.impure.transferOwnership(newOwnerId); + public transferOwnership(newOwnerId: Uint8Array): Promise<[]> { + return this.circuits.impure.transferOwnership(newOwnerId); } /** @@ -78,26 +82,28 @@ export class SampleZOwnableSimulator extends SampleZOwnableSimulatorBase { * It will not be possible to call `assertOnlyOnwer` circuits anymore. * Can only be called by the current owner. */ - public renounceOwnership() { - this.circuits.impure.renounceOwnership(); + public renounceOwnership(): Promise<[]> { + return this.circuits.impure.renounceOwnership(); } /** * @description Throws if called by any account whose id hash `SHA256(pk, nonce)` does not match * the stored owner commitment. Use this to only allow the owner to call specific circuits. */ - public assertOnlyOwner() { - this.circuits.impure.assertOnlyOwner(); + public assertOnlyOwner(): Promise<[]> { + return this.circuits.impure.assertOnlyOwner(); } /** * @description Computes the owner commitment from the given `id` and `counter`. * @param id - The unique identifier of the owner calculated by `SHA256(pk, nonce)`. - * @param counter - The current counter or round. This increments by `1` - * after every transfer to prevent duplicate commitments given the same `id`. + * @param counter - The current counter or round. * @returns The commitment derived from `id` and `counter`. */ - public _computeOwnerCommitment(id: Uint8Array, counter: bigint): Uint8Array { + public _computeOwnerCommitment( + id: Uint8Array, + counter: bigint, + ): Promise { return this.circuits.impure._computeOwnerCommitment(id, counter); } @@ -111,7 +117,7 @@ export class SampleZOwnableSimulator extends SampleZOwnableSimulatorBase { public _computeOwnerId( pk: Either, nonce: Uint8Array, - ): Uint8Array { + ): Promise { return this.circuits.pure._computeOwnerId(pk, nonce); } @@ -121,13 +127,12 @@ export class SampleZOwnableSimulator extends SampleZOwnableSimulatorBase { * @param newNonce The secret nonce. * @returns The SampleZOwnable private state after setting the new nonce. */ - injectSecretNonce: ( + injectSecretNonce: async ( newNonce: Buffer, - ): SampleZOwnablePrivateState => { - const currentState = - this.circuitContextManager.getContext().currentPrivateState; + ): Promise => { + const currentState = await this.getPrivateState(); const updatedState = { ...currentState, secretNonce: newNonce }; - this.circuitContextManager.updatePrivateState(updatedState); + this.setPrivateState(updatedState); return updatedState; }, @@ -135,9 +140,8 @@ export class SampleZOwnableSimulator extends SampleZOwnableSimulatorBase { * @description Returns the secret nonce given the context. * @returns The secret nonce. */ - getCurrentSecretNonce: (): Uint8Array => { - return this.circuitContextManager.getContext().currentPrivateState - .secretNonce; + getCurrentSecretNonce: async (): Promise => { + return (await this.getPrivateState()).secretNonce; }, }; } diff --git a/packages/simulator/test/integration/Simple.test.ts b/packages/simulator/test/integration/Simple.test.ts index d174d45..c1513d3 100644 --- a/packages/simulator/test/integration/Simple.test.ts +++ b/packages/simulator/test/integration/Simple.test.ts @@ -4,17 +4,17 @@ import { SimpleSimulator } from './SimpleSimulator'; let simple: SimpleSimulator; describe('Simple test', () => { - beforeEach(() => { - simple = new SimpleSimulator(); + beforeEach(async () => { + simple = await SimpleSimulator.create(); }); it('sanity check', () => { expect(1).toEqual(1); }); - it('should set val', () => { + it('should set val', async () => { const VAL = 123n; - simple.setVal(VAL); - expect(simple.getVal()).toEqual(VAL); + await simple.setVal(VAL); + expect(await simple.getVal()).toEqual(VAL); }); }); diff --git a/packages/simulator/test/integration/SimpleSimulator.ts b/packages/simulator/test/integration/SimpleSimulator.ts index 411c5e9..6a2bf4c 100644 --- a/packages/simulator/test/integration/SimpleSimulator.ts +++ b/packages/simulator/test/integration/SimpleSimulator.ts @@ -1,4 +1,4 @@ -import { type BaseSimulatorOptions, createSimulator } from '../../src/index'; +import { createSimulator, type SimulatorOptions } from '../../src/index'; import { ledger, Contract as SimpleContract, @@ -29,20 +29,21 @@ const SimpleSimulatorBase = createSimulator< * Simple Simulator */ export class SimpleSimulator extends SimpleSimulatorBase { - constructor( - options: BaseSimulatorOptions< + static async create( + options: SimulatorOptions< SimplePrivateState, ReturnType > = {}, - ) { - super([], options); + ): Promise { + // biome-ignore lint/complexity/noThisInStatic: super.create must keep the subclass `this` + return super.create([], options) as Promise; } - public setVal(n: bigint) { - this.circuits.impure.setVal(n); + public setVal(n: bigint): Promise<[]> { + return this.circuits.impure.setVal(n); } - public getVal(): bigint { + public getVal(): Promise { return this.circuits.impure.getVal(); } } diff --git a/packages/simulator/test/integration/Witness.test.ts b/packages/simulator/test/integration/Witness.test.ts index c2e0519..147ee11 100644 --- a/packages/simulator/test/integration/Witness.test.ts +++ b/packages/simulator/test/integration/Witness.test.ts @@ -26,162 +26,187 @@ const overrideWitnesses = (): IWitnessWitnesses => ({ let contract: WitnessSimulator; describe('witness/private state overrides', () => { - beforeEach(() => { - contract = new WitnessSimulator(); + beforeEach(async () => { + contract = await WitnessSimulator.create(); }); describe('witness overrides', () => { - it('should have default public state values', () => { - expect(contract.getPublicState()._valBytes).toEqual( + it('should have default public state values', async () => { + expect((await contract.getPublicState())._valBytes).toEqual( new Uint8Array(32).fill(0), ); - expect(contract.getPublicState()._valField).toEqual(0n); - expect(contract.getPublicState()._valUint).toEqual(0n); + expect((await contract.getPublicState())._valField).toEqual(0n); + expect((await contract.getPublicState())._valUint).toEqual(0n); }); - it('should set values according to witness logic', () => { + it('should set values according to witness logic', async () => { // Private state - const psBytes = contract.getPrivateState().secretBytes; - const psField = contract.getPrivateState().secretField; - const psUint = contract.getPrivateState().secretUint; + const ps = await contract.getPrivateState(); + const psBytes = ps.secretBytes; + const psField = ps.secretField; + const psUint = ps.secretUint; // Set values - contract.setBytes(); - contract.setField(VAL1); - contract.setUint(VAL1, VAL2); + await contract.setBytes(); + await contract.setField(VAL1); + await contract.setUint(VAL1, VAL2); // Check values - expect(contract.getPublicState()._valBytes).toEqual( + expect((await contract.getPublicState())._valBytes).toEqual( new Uint8Array(psBytes), ); - expect(contract.getPublicState()._valField).toEqual(psField + VAL1); - expect(contract.getPublicState()._valUint).toEqual(psUint + VAL1 + VAL2); + expect((await contract.getPublicState())._valField).toEqual( + psField + VAL1, + ); + expect((await contract.getPublicState())._valUint).toEqual( + psUint + VAL1 + VAL2, + ); }); - it('should override all witnesses', () => { + it('should override all witnesses', async () => { // Private state - const psBytes = contract.getPrivateState().secretBytes; - const psField = contract.getPrivateState().secretField; - const psUint = contract.getPrivateState().secretUint; + const ps = await contract.getPrivateState(); + const psBytes = ps.secretBytes; + const psField = ps.secretField; + const psUint = ps.secretUint; // Override entire object contract.witnesses = overrideWitnesses(); // Set values - contract.setBytes(); - contract.setField(VAL1); - contract.setUint(VAL1, VAL2); + await contract.setBytes(); + await contract.setField(VAL1); + await contract.setUint(VAL1, VAL2); // Check bytes - expect(contract.getPublicState()._valBytes).toEqual(BYTES_OVERRIDE); - expect(contract.getPublicState()._valBytes).not.toEqual( + expect((await contract.getPublicState())._valBytes).toEqual( + BYTES_OVERRIDE, + ); + expect((await contract.getPublicState())._valBytes).not.toEqual( new Uint8Array(psBytes), ); // Check field - expect(contract.getPublicState()._valField).toEqual(FIELD_OVERRIDE); - expect(contract.getPublicState()._valField).not.toEqual(psField + VAL1); + expect((await contract.getPublicState())._valField).toEqual( + FIELD_OVERRIDE, + ); + expect((await contract.getPublicState())._valField).not.toEqual( + psField + VAL1, + ); // Check uint - expect(contract.getPublicState()._valUint).toEqual(UINT_OVERRIDE); - expect(contract.getPublicState()._valUint).not.toEqual( + expect((await contract.getPublicState())._valUint).toEqual(UINT_OVERRIDE); + expect((await contract.getPublicState())._valUint).not.toEqual( psUint + VAL1 + VAL2, ); }); describe('when overriding individual witnesses', () => { - it('should override wit_secretBytes', () => { + it('should override wit_secretBytes', async () => { // Private state - const psBytes = contract.getPrivateState().secretBytes; - const psField = contract.getPrivateState().secretField; - const psUint = contract.getPrivateState().secretUint; + const ps = await contract.getPrivateState(); + const psBytes = ps.secretBytes; + const psField = ps.secretField; + const psUint = ps.secretUint; contract.overrideWitness('wit_secretBytes', (ctx) => { return [ctx.privateState, BYTES_OVERRIDE]; }); // Set all values - contract.setBytes(); - contract.setField(VAL1); - contract.setUint(VAL1, VAL2); + await contract.setBytes(); + await contract.setField(VAL1); + await contract.setUint(VAL1, VAL2); // Check bytes override - expect(contract.getPublicState()._valBytes).toEqual(BYTES_OVERRIDE); - expect(contract.getPublicState()._valBytes).not.toEqual( + expect((await contract.getPublicState())._valBytes).toEqual( + BYTES_OVERRIDE, + ); + expect((await contract.getPublicState())._valBytes).not.toEqual( new Uint8Array(psBytes), ); // Check other witnesses remain unchanged - expect(contract.getPublicState()._valField).toEqual(psField + VAL1); - expect(contract.getPublicState()._valUint).toEqual( + expect((await contract.getPublicState())._valField).toEqual( + psField + VAL1, + ); + expect((await contract.getPublicState())._valUint).toEqual( psUint + VAL1 + VAL2, ); }); - it('should override wit_secretFieldPlusArg', () => { + it('should override wit_secretFieldPlusArg', async () => { // Private state - const psBytes = contract.getPrivateState().secretBytes; - const _psField = contract.getPrivateState().secretField; - const psUint = contract.getPrivateState().secretUint; + const ps = await contract.getPrivateState(); + const psBytes = ps.secretBytes; + const psUint = ps.secretUint; contract.overrideWitness('wit_secretFieldPlusArg', (ctx) => { return [ctx.privateState, FIELD_OVERRIDE]; }); // Set all values - contract.setBytes(); - contract.setField(VAL1); - contract.setUint(VAL1, VAL2); + await contract.setBytes(); + await contract.setField(VAL1); + await contract.setUint(VAL1, VAL2); // Check field override - expect(contract.getPublicState()._valField).toEqual(FIELD_OVERRIDE); - expect(contract.getPublicState()._valField).not.toEqual(VAL1); + expect((await contract.getPublicState())._valField).toEqual( + FIELD_OVERRIDE, + ); + expect((await contract.getPublicState())._valField).not.toEqual(VAL1); // Check other witnesses remain unchanged - expect(contract.getPublicState()._valBytes).toEqual( + expect((await contract.getPublicState())._valBytes).toEqual( new Uint8Array(psBytes), ); - expect(contract.getPublicState()._valUint).toEqual( + expect((await contract.getPublicState())._valUint).toEqual( psUint + VAL1 + VAL2, ); }); - it('should override wit_secretUintPlusArgs', () => { + it('should override wit_secretUintPlusArgs', async () => { // Private state - const psBytes = contract.getPrivateState().secretBytes; - const psField = contract.getPrivateState().secretField; - const psUint = contract.getPrivateState().secretUint; + const ps = await contract.getPrivateState(); + const psBytes = ps.secretBytes; + const psField = ps.secretField; + const psUint = ps.secretUint; contract.overrideWitness('wit_secretUintPlusArgs', (ctx) => { return [ctx.privateState, UINT_OVERRIDE]; }); // Set all values - contract.setBytes(); - contract.setField(VAL1); - contract.setUint(VAL1, VAL2); + await contract.setBytes(); + await contract.setField(VAL1); + await contract.setUint(VAL1, VAL2); // Check uint override - expect(contract.getPublicState()._valUint).toEqual(UINT_OVERRIDE); - expect(contract.getPublicState()._valUint).not.toEqual( + expect((await contract.getPublicState())._valUint).toEqual( + UINT_OVERRIDE, + ); + expect((await contract.getPublicState())._valUint).not.toEqual( psUint + VAL1 + VAL2, ); // Check other witnesses remain unchanged - expect(contract.getPublicState()._valBytes).toEqual( + expect((await contract.getPublicState())._valBytes).toEqual( new Uint8Array(psBytes), ); - expect(contract.getPublicState()._valField).toEqual(psField + VAL1); + expect((await contract.getPublicState())._valField).toEqual( + psField + VAL1, + ); }); }); }); describe('private state overrides', () => { - it('should match ps ', () => { + it('should match ps ', async () => { // Private state - const _psBytes = contract.getPrivateState().secretBytes; - const _psField = contract.getPrivateState().secretField; - const _psUint = contract.getPrivateState().secretUint; + const ps = await contract.getPrivateState(); + void ps.secretBytes; + void ps.secretField; + void ps.secretUint; }); it('should override the entire private state', () => {}); diff --git a/packages/simulator/test/integration/WitnessSimulator.ts b/packages/simulator/test/integration/WitnessSimulator.ts index e03ac46..04e3026 100644 --- a/packages/simulator/test/integration/WitnessSimulator.ts +++ b/packages/simulator/test/integration/WitnessSimulator.ts @@ -1,4 +1,4 @@ -import { type BaseSimulatorOptions, createSimulator } from '../../src/index'; +import { createSimulator, type SimulatorOptions } from '../../src/index'; import { ledger, Contract as WitnessContract, @@ -38,49 +38,49 @@ const WitnessSimulatorBase = createSimulator< * Witness Simulator */ export class WitnessSimulator extends WitnessSimulatorBase { - constructor( - options: BaseSimulatorOptions< + static async create( + options: SimulatorOptions< WitnessPrivateState, ReturnType > = {}, - ) { - super([], options); + ): Promise { + // biome-ignore lint/complexity/noThisInStatic: super.create must keep the subclass `this` + return super.create([], options) as Promise; } - public setBytes() { - this.circuits.impure.setBytes(); + public setBytes(): Promise<[]> { + return this.circuits.impure.setBytes(); } - public setField(arg: bigint) { - this.circuits.impure.setField(arg); + public setField(arg: bigint): Promise<[]> { + return this.circuits.impure.setField(arg); } - public setUint(arg1: bigint, arg2: bigint) { - this.circuits.impure.setUint(arg1, arg2); + public setUint(arg1: bigint, arg2: bigint): Promise<[]> { + return this.circuits.impure.setUint(arg1, arg2); } public readonly privateState = { - injectSecretBytes: ( + injectSecretBytes: async ( newBytes: Buffer, - ): WitnessPrivateState => { - const currentState = - this.circuitContextManager.getContext().currentPrivateState; + ): Promise => { + const currentState = await this.getPrivateState(); const updatedState = { ...currentState, secretBytes: newBytes }; - this.circuitContextManager.updatePrivateState(updatedState); + this.setPrivateState(updatedState); return updatedState; }, - injectSecretField: (newField: bigint): WitnessPrivateState => { - const currentState = - this.circuitContextManager.getContext().currentPrivateState; + injectSecretField: async ( + newField: bigint, + ): Promise => { + const currentState = await this.getPrivateState(); const updatedState = { ...currentState, secretField: newField }; - this.circuitContextManager.updatePrivateState(updatedState); + this.setPrivateState(updatedState); return updatedState; }, - injectSecretUint: (newUint: bigint): WitnessPrivateState => { - const currentState = - this.circuitContextManager.getContext().currentPrivateState; + injectSecretUint: async (newUint: bigint): Promise => { + const currentState = await this.getPrivateState(); const updatedState = { ...currentState, secretUint: newUint }; - this.circuitContextManager.updatePrivateState(updatedState); + this.setPrivateState(updatedState); return updatedState; }, }; diff --git a/packages/simulator/test/unit/LiveBackendAdapter.test.ts b/packages/simulator/test/unit/LiveBackendAdapter.test.ts new file mode 100644 index 0000000..e84dc2a --- /dev/null +++ b/packages/simulator/test/unit/LiveBackendAdapter.test.ts @@ -0,0 +1,137 @@ +import type { StateValue } from '@midnight-ntwrk/compact-runtime'; +import { describe, expect, it } from 'vitest'; +import type { SyncSimulator } from '../../src/backend/DryBackend.js'; +import { LiveBackend } from '../../src/live/LiveBackend.js'; +import type { + DeployedTxHandle, + LiveContext, +} from '../../src/live/LiveContext.js'; +import { Signers } from '../../src/signers/Signers.js'; + +type Ledger = { tag: string }; + +/** Records the alias passed to handleFor and serves scripted callTx results. */ +class FakeWorld implements LiveContext<{ secret: number }> { + readonly contractAddress = '0200cafef00d'; + lastAlias: string | null | undefined; + private readonly callTx: DeployedTxHandle['callTx']; + + constructor(callTx: DeployedTxHandle['callTx']) { + this.callTx = callTx; + } + + async handleFor(alias: string | null): Promise { + this.lastAlias = alias; + return { callTx: this.callTx }; + } + + async queryLedger(): Promise { + return { tag: 'ledger-state' } as unknown as StateValue; + } + + async queryPrivateState() { + return { secret: 7 }; + } +} + +/** Minimal pure-circuit evaluator: only `circuits.pure` is exercised by LiveBackend. */ +const fakePureSim = ( + pure: Record unknown>, +): SyncSimulator<{ secret: number }, Ledger> => + ({ circuits: { pure, impure: {} } }) as unknown as SyncSimulator< + { secret: number }, + Ledger + >; + +const makeBackend = ( + callTx: DeployedTxHandle['callTx'], + pure: Record unknown> = {}, + liveAliases: string[] = ['OWNER', 'ALICE'], +) => { + const world = new FakeWorld(callTx); + const backend = new LiveBackend<{ secret: number }, Ledger>({ + ctx: world, + pureSim: fakePureSim(pure), + signers: new Signers({ mode: 'live', liveAliases }), + ledgerExtractor: (state) => state as unknown as Ledger, + }); + return { backend, world }; +}; + +describe('LiveBackend adapter', () => { + it('runs pure circuits locally without touching the node (INV-16)', async () => { + const { backend, world } = makeBackend( + {}, + { double: (n) => (n as bigint) * 2n }, + ); + expect(await backend.call('pure', 'double', [21n])).toEqual(42n); + // No impure handle was ever requested. + expect(world.lastAlias).toBeUndefined(); + }); + + it('normalizes impure results from .private.result to bare R (INV-13)', async () => { + const { backend } = makeBackend({ + owner: async () => ({ private: { result: 'OWNER_COMMITMENT' } }), + }); + expect(await backend.call('impure', 'owner', [])).toEqual( + 'OWNER_COMMITMENT', + ); + }); + + it('propagates the contract assert message as a substring (INV-14)', async () => { + const { backend } = makeBackend({ + guarded: async () => { + throw new Error( + 'AccessControl: unauthorized account (+ proof/tx framing)', + ); + }, + }); + await expect(backend.call('impure', 'guarded', [])).rejects.toThrow( + 'AccessControl: unauthorized account', + ); + }); + + it('applies single-shot caller for one call, then reverts (INV-17)', async () => { + const { backend, world } = makeBackend({ + noop: async () => ({ private: { result: undefined } }), + }); + backend.setCaller('OWNER', 'single'); + await backend.call('impure', 'noop', []); + expect(world.lastAlias).toBe('OWNER'); + + await backend.call('impure', 'noop', []); + expect(world.lastAlias).toBeNull(); + }); + + it('keeps a persistent caller across calls (INV-17)', async () => { + const { backend, world } = makeBackend({ + noop: async () => ({ private: { result: undefined } }), + }); + backend.setCaller('ALICE', 'persistent'); + await backend.call('impure', 'noop', []); + await backend.call('impure', 'noop', []); + expect(world.lastAlias).toBe('ALICE'); + }); + + it('rejects callers outside the prefunded pool (INV-21)', () => { + const { backend } = makeBackend({}); + expect(() => backend.setCaller('STRANGER', 'single')).toThrow( + 'not in the prefunded pool', + ); + }); + + it('hard-errors on witness override / setWitnesses (INV-7)', () => { + const { backend } = makeBackend({}); + expect(() => backend.overrideWitness('w', () => {})).toThrow( + 'witness override unsupported on live backend', + ); + expect(() => backend.setWitnesses({})).toThrow( + 'witness override unsupported on live backend', + ); + }); + + it('reads private state through the provider (INV-18)', async () => { + const { backend } = makeBackend({}); + expect(await backend.getPrivateState()).toEqual({ secret: 7 }); + }); +}); diff --git a/packages/simulator/test/unit/Signers.test.ts b/packages/simulator/test/unit/Signers.test.ts new file mode 100644 index 0000000..2132bed --- /dev/null +++ b/packages/simulator/test/unit/Signers.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest'; +import { MAX_LIVE_SIGNERS, Signers } from '../../src/signers/Signers.js'; + +/** The existing harness's alias derivation, reproduced for the parity check. */ +const expectedDryKey = (alias: string): string => + Buffer.from(alias, 'ascii').toString('hex').padStart(64, '0'); + +describe('Signers — dry derivation (INV-17)', () => { + const signers = new Signers({ mode: 'dry' }); + + it('derives the same key the existing test harness uses', async () => { + expect(await signers.keyFor('OWNER')).toEqual(expectedDryKey('OWNER')); + expect(signers.resolveDryKey('OWNER')).toEqual(expectedDryKey('OWNER')); + }); + + it('honors an explicit alias→key override', async () => { + const custom = new Signers({ + mode: 'dry', + dryKeys: { OWNER: 'ff'.repeat(32) }, + }); + expect(await custom.keyFor('OWNER')).toEqual('ff'.repeat(32)); + // Unmapped aliases fall back to the default derivation. + expect(await custom.keyFor('ALICE')).toEqual(expectedDryKey('ALICE')); + }); + + it('wraps the key in a left-variant Either for circuit args', async () => { + const either = await signers.eitherFor('OWNER'); + expect(either.is_left).toBe(true); + expect(either.left.bytes).toBeInstanceOf(Uint8Array); + }); +}); + +describe('Signers — live cap (INV-21)', () => { + it(`allows up to ${MAX_LIVE_SIGNERS} prefunded aliases`, () => { + expect( + () => + new Signers({ + mode: 'live', + liveAliases: ['DEPLOYER', 'OWNER', 'ALICE', 'BOB'], + }), + ).not.toThrow(); + }); + + it('rejects a pool larger than the cap at construction', () => { + expect( + () => + new Signers({ + mode: 'live', + liveAliases: ['DEPLOYER', 'OWNER', 'ALICE', 'BOB', 'CAROL'], + }), + ).toThrow(`at most ${MAX_LIVE_SIGNERS}`); + }); + + it('rejects an alias outside the pool, never silently reusing a wallet', () => { + const signers = new Signers({ mode: 'live', liveAliases: ['OWNER'] }); + expect(() => signers.assertLiveAliasAllowed('STRANGER')).toThrow( + 'not in the prefunded pool', + ); + expect(() => signers.assertLiveAliasAllowed('OWNER')).not.toThrow(); + }); + + it('is a no-op in dry mode', () => { + const signers = new Signers({ mode: 'dry' }); + expect(() => signers.assertLiveAliasAllowed('ANYONE')).not.toThrow(); + }); +}); diff --git a/packages/simulator/test/unit/dependency-wall.test.ts b/packages/simulator/test/unit/dependency-wall.test.ts new file mode 100644 index 0000000..9be0255 --- /dev/null +++ b/packages/simulator/test/unit/dependency-wall.test.ts @@ -0,0 +1,54 @@ +import { readdirSync, readFileSync } from 'node:fs'; +import { dirname, join, relative } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +const here = dirname(fileURLToPath(import.meta.url)); +const SRC_DIR = join(here, '..', '..', 'src'); +const LIVE_DIR = join(SRC_DIR, 'live'); + +/** All `.ts` files under a directory, recursively. */ +function tsFiles(dir: string): string[] { + const out: string[] = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name); + if (entry.isDirectory()) { + out.push(...tsFiles(full)); + } else if (entry.name.endsWith('.ts')) { + out.push(full); + } + } + return out; +} + +const MIDNIGHT_JS = '@midnight-ntwrk/midnight-js'; + +/** + * INV-2 / INV-1 enforcement (the CI guard from OQ6). + * + * The dry dependency graph must pull zero midnight-js. We enforce the structural + * precondition: every midnight-js import is physically confined to `src/live/`. + * Any reference elsewhere — even a `type` import — is flagged, since a stray + * value re-export is exactly how the wall silently falls (INV-1). + * + * A stronger bundle/dependency-graph analysis (the other OQ6 option) can layer + * on top; this source-level guard is the fast, deterministic floor. + */ +describe('dependency wall (INV-1, INV-2)', () => { + it('confines every midnight-js import to src/live/', () => { + const offenders = tsFiles(SRC_DIR) + .filter((file) => !file.startsWith(LIVE_DIR)) + .filter((file) => readFileSync(file, 'utf8').includes(MIDNIGHT_JS)) + .map((file) => relative(SRC_DIR, file)); + + expect(offenders).toEqual([]); + }); + + it('keeps midnight-js out of the main barrel (INV-1)', () => { + const barrel = readFileSync(join(SRC_DIR, 'index.ts'), 'utf8'); + expect(barrel.includes(MIDNIGHT_JS)).toBe(false); + // The live adapter must be a type-only re-export, never a value re-export. + expect(barrel).toContain('export type { LiveBackend }'); + expect(barrel).not.toMatch(/export\s*\{\s*LiveBackend\b/); + }); +}); From 0313e9d26a6342f1d70934673b725d3005a70b5b Mon Sep 17 00:00:00 2001 From: 0xisk <0xisk@proton.me> Date: Mon, 22 Jun 2026 23:27:13 +0200 Subject: [PATCH 2/4] chore(simulator): sync yarn.lock, drop pipeline docs from PR Add the midnight-js optional peer/dev deps to yarn.lock so the immutable CI install matches package.json. Remove the design/invariants/code pipeline artifacts under packages/simulator/docs from the PR (kept locally, out of the published package). --- .../simulator/docs/code/live-backend-code.md | 234 ------- .../docs/design/live-backend-invariants.md | 646 ------------------ .../simulator/docs/design/live-backend.md | 335 --------- yarn.lock | 422 +++++++++++- 4 files changed, 417 insertions(+), 1220 deletions(-) delete mode 100644 packages/simulator/docs/code/live-backend-code.md delete mode 100644 packages/simulator/docs/design/live-backend-invariants.md delete mode 100644 packages/simulator/docs/design/live-backend.md diff --git a/packages/simulator/docs/code/live-backend-code.md b/packages/simulator/docs/code/live-backend-code.md deleted file mode 100644 index 44b98d4..0000000 --- a/packages/simulator/docs/code/live-backend-code.md +++ /dev/null @@ -1,234 +0,0 @@ ---- -stage: code -project: simulator-live-backend -mode: extension -extends: packages/simulator -status: draft -timestamp: 2026-06-22 -author: 0xisk -previous_stage: packages/simulator/docs/design/live-backend-invariants.md -tags: [simulator, testing, live-backend, midnight-js, async, tooling, parity] ---- - -# Simulator Live-Mode Backend — Code Draft - -> Tooling code draft (a TypeScript test harness, not a Compact contract). The -> skill's contract-centric review (disclosure sites, commitment hygiene, `pure` -> discipline) is re-mapped to tooling concerns: the **Parity Mechanisms** table -> replaces Disclosure Sites, and the dependency wall replaces commitment hygiene. - -## Revision: unified single-factory API (supersedes the dual-factory design below) - -After reviewing the first draft, the dev rejected the dual-factory / duplicate-file -ergonomics. The API was unified per their direction; the engine below is unchanged, -only its surface: - -- **One factory.** `createSimulator` IS the async, backend-aware factory (no separate - `createBackendSimulator`). This is a **breaking change** to the old synchronous - `createSimulator` — every consumer migrates to `await Sim.create()` + `await`ed - circuits. The dev accepted this ("change the current unit tests into async/await"). - The old synchronous logic survives as an internal primitive, `createDrySimulator` - (not exported), which the dry backend wraps and the live backend uses for local - pure-circuit eval. -- **No twin files.** Per module there is exactly one simulator file and one test file, - migrated in place to async. `*Backend.ts` twins were removed. -- **Backend selection by env var, two commands.** `MIDNIGHT_BACKEND=dry|live` read once - at `create()` (INV-8). `test` = dry, `test:live` = live (boots infra + registers the - harness). Specs guard the documented asymmetries with `isLiveBackend()` - (e.g. `it.skipIf(isLiveBackend())(...)`). -- **Global live registration.** `registerLiveBackend(factory)` (called once in the - `test:live` setup) lets `await Sim.create()` stay byte-identical on both backends; - the live world is resolved from the registry (or an explicit `{ live }`). New file: - `src/live/registry.ts` (`registerLiveBackend` / `getRegisteredLiveBackend` / - `clearLiveBackend` / `isLiveBackend`, `LiveBackendFactory` / `LiveBackendRequest`). -- **Private-state mutation + witness read** added to the seam: `setPrivateState` (dry - mutates, live throws — INV-18), `getWitnesses` + a `witnesses` get/set on the class - (so `sim.witnesses = {...}` keeps working). Options type renamed - `BackendSimulatorOptions` → `SimulatorOptions`. - -**Validation of the unified API (dry, this revision):** -- Simulator package own suite: **73/73 green** (Simple, Witness, SampleZOwnable, plus - LiveBackend-adapter / Signers / dependency-wall unit tests), all migrated to async. -- Dependency wall re-verified at build output: `dist/index.js` has **0** real - `@midnight-ntwrk/midnight-js` imports. -- Real consumer (`compact-contracts`, fresh copy): swapped the built simulator in and - ran its suite — **1154/1154** previously-passing unit tests unchanged (the 53 reds are - pre-existing WIP-branch failures, identical with the stock simulator). Migrated the - `security` module **in place** (Pausable + Initializable, same files, async): - **19/19 dry green**, matching the pre-migration baseline exactly. `MIDNIGHT_BACKEND=live` - confirmed to flip the backend and emit the actionable "register a live backend" error - (no node available here). - -The sections below describe the original dual-factory draft and remain accurate for the -engine (Backend seam, Dry/Live backends, Signers, createLiveContext, parity invariants); -read "`createBackendSimulator`" as "`createSimulator`" and "additive / INV-19 sync path -preserved" as "superseded by the unified breaking API" throughout. - -## Summary - -Adds a backend-aware path alongside the existing synchronous `createSimulator`, -selected by `MIDNIGHT_BACKEND=dry|live` at construction. The new -`createBackendSimulator` builds async circuit proxies over a small `Backend` -seam with two implementations: `DryBackend` (a thin async facade over the -unchanged synchronous simulator) and `LiveBackend` (a pure adapter over an -injected `LiveContext`). midnight-js is confined to `src/live/` and reached only -through dynamic imports, so a dry import pulls zero midnight-js — verified at the -build output (`dist/index.js` has no midnight-js references; `createLiveContext.js` -reaches it solely via `await import(...)`). All existing files keep working -byte-for-byte; the change is additive (the full pre-existing dry suite passes as -the INV-19 regression gate). - -The two invariants flagged as most likely wrong (INV-13 result shape, INV-17 -alias resolution) were **pinned against the installed midnight-js 4.1.0 types**, -not assumptions: the default `callTx[name](...)` returns `FinalizedCallTxData` -whose circuit result is at `.private.result`, and the dry alias derivation -reproduces the existing harness's `toHexPadded(label)`. - -## Files - -| File | Purpose | Lines | Status | -|------|---------|-------|--------| -| `src/backend/Backend.ts` | The `Backend` seam (the operations that differ dry vs live) | ~110 | New | -| `src/backend/DryBackend.ts` | Async facade over the existing synchronous simulator | ~110 | New | -| `src/factory/createBackendSimulator.ts` | Backend-aware factory: env resolve, async proxies, dynamic live import | ~280 | New | -| `src/live/LiveContext.ts` | Structural injection seam (`LiveContext`, `DeployedTxHandle`) | ~75 | New | -| `src/live/LiveBackend.ts` | Pure live adapter (no midnight-js); result/message normalization | ~165 | New | -| `src/live/createLiveContext.ts` | Optional assembler: per-alias handle cache + bounded indexer-lag reads | ~190 | New | -| `src/signers/Signers.ts` | Alias resolver (dry derivation, live cap) | ~190 | New | -| `src/types/Circuit.ts` | Added `AsyncCircuits<>` type (additive) | +20 | Modified | -| `src/types/index.ts` | Re-export `AsyncCircuits` (additive) | +1 | Modified | -| `src/index.ts` | Additive barrel exports (type-only `LiveBackend`) | +30 | Modified | -| `package.json` | Optional peer deps, `test:live` script, dev deps | +20 | Modified | -| `test/integration/SimpleBackend.ts` | Backend-aware simulator (migration template, OQ5) | ~60 | New | -| `test/integration/SimpleBackend.test.ts` | Dry + fake-live parity spec | ~150 | New | -| `test/unit/LiveBackendAdapter.test.ts` | Focused live-adapter unit tests (no node) | ~135 | New | -| `test/unit/Signers.test.ts` | Dry derivation + live cap | ~75 | New | -| `test/unit/dependency-wall.test.ts` | INV-1/INV-2 source guard (OQ6) | ~55 | New | - -Verification: `tsc --noEmit` clean, `biome check` clean, `tsc -p .` (build) clean, -**82/82 tests pass** (existing suite + new). - -## Invariant Enforcement Map - -| Invariant | Enforcement location | Mechanism | Tested | -|-----------|----------------------|-----------|--------| -| INV-1 type-only `LiveBackend` | `src/index.ts`; `createBackendSimulator` dynamic import | `export type { LiveBackend }` + `await import('../live/LiveBackend.js')` | `dependency-wall.test.ts` | -| INV-2 dry graph midnight-js-free | whole graph; `src/live/` confinement | type-only + dynamic imports; verified `dist/index.js` 0 refs | `dependency-wall.test.ts` (source scan) | -| INV-3 optional peer deps | `package.json` | `peerDependenciesMeta.*.optional = true` | — (install-matrix not yet) | -| INV-4 async transform | `AsyncCircuits<>`; `DryBackend.call` | mapped type returns `Promise`; dry wraps in `Promise.resolve` | `SimpleBackend.test.ts` | -| INV-5 shared `SimulatorConfig` | `createBackendSimulator` | consumes the same config; no forked struct | `SimpleBackend.ts` reuses config | -| INV-6 friendly missing-deps error | `createLiveContext.loadFindDeployedContract` | `try/catch` around dynamic import → wrapped message | — (cannot uninstall peers in suite) | -| INV-7 witness override rejected (live) | `LiveBackend.overrideWitness/setWitnesses` | explicit throw `"witness override unsupported on live backend"` | adapter + integration | -| INV-8 backend fixed at construction | `resolveBackendKind` (read once); `backendKind` readonly | no runtime toggle/setter | `SimpleBackend.test.ts` | -| INV-9 isolation (dry fresh / live shared) | convention; migration template comment | documented; specs authored order-independent | — (convention, not code) | -| INV-10 live attaches; dry deploys | `LiveBackend` (no deploy); `LiveContext.contractAddress` | live uses injected address; local sim is in-memory pure-eval only | implicit | -| INV-11 bounded indexer lag | `createLiveContext.queryLedger` | finite retry + capped backoff; throws on exhaustion | — (needs live node; defaults provisional) | -| INV-12 observable-outcome parity | sum of INV-13..18 + lifecycle | dry + fake-live produce same outcomes | `SimpleBackend.test.ts` (both backends) | -| INV-13 result-shape normalization | `LiveBackend.call` (impure) | returns `.private.result` (pinned vs midnight-js 4.1.0) | adapter + integration | -| INV-14 assertion-message parity | `LiveBackend.call` (impure) | awaits directly; never catches/rewrites the rejection | adapter (substring match) | -| INV-15 public-state read parity | `DryBackend`/`LiveBackend.getPublicState` | same `config.ledgerExtractor` over both sources | adapter + integration | -| INV-16 pure-circuit locality | `LiveBackend.call` (pure) → local `pureSim` | pure runs on the JS artifact in both modes | adapter (no handle requested) | -| INV-17 caller-identity parity | `Signers` + `setCaller` lifecycle (both backends) | alias derivation; `single`/`persistent` mirror dry | `Signers.test.ts` + adapter + integration | -| INV-18 private-state read parity | `LiveBackend.getPrivateState` via provider | read parity; mutation documented best-effort/dry-only | adapter (read) | -| INV-19 existing sync path unchanged | all existing `core/*`, `factory/createSimulator`, etc. | additive-only; no runtime logic touched | full existing suite (regression gate) | -| INV-20 public API additive | `src/index.ts` | only new exports; nothing removed/renamed | existing suite + barrel diff | -| INV-21 live signer cap | `Signers` (cap 4) + `assertLiveAliasAllowed` | throws on overflow / out-of-pool; never reuses | `Signers.test.ts` + adapter + integration | -| INV-22 caller owns live infra | `src/live/` boundary; `createLiveContext` inputs | helper only assembles provided pieces; ships no infra | by construction | -| INV-23 parity CI-gated | `package.json` `test:live` script | script present; CI job wiring is a follow-up | — (CI not wired) | - -## Parity Mechanisms (tooling analog of Disclosure Sites) - -| Surface | Dry | Live | Made-parity-by | -|---------|-----|------|----------------| -| Impure result | proxy returns `R` | `callTx(...).private.result` | INV-13 normalization in `LiveBackend.call` | -| Assert failure | sync `throw "msg"` | `callTx` rejects, message preserved | INV-14: await without catch/rewrite | -| Public state | `ledgerExtractor(ctx state)` | `ledgerExtractor(await queryLedger())` | INV-15: single shared extractor | -| Pure circuit | local JS | local JS (`pureSim`) | INV-16: same local path both modes | -| Caller `as('OWNER')` | deterministic key | pooled wallet (capped) | INV-17 derivation + INV-21 cap | -| Witness override | recreate contract | **hard error** | INV-7 (documented asymmetry) | -| Private-state mutation | context update | best-effort / dry-only | INV-18 (documented asymmetry) | - -## Implementation Notes - -- **Dependency wall is stronger than the design assumed.** By making `LiveContext` - a structural injection seam, the live **adapter** (`LiveBackend`) needs no - midnight-js at all — only the optional **assembler** (`createLiveContext`) does, - and it reaches it via a single lazy `await import('@midnight-ntwrk/midnight-js-contracts')`. - Result: every static graph in the package is midnight-js-free; the only runtime - edge is that one dynamic import, fired only when `createLiveContext()` is called. -- **OQ1 resolved (INV-13).** Verified against installed midnight-js 4.1.0: - `CircuitCallTxInterface[name](...args): Promise`, and - `FinalizedCallTxData = CallResult & { private: UnsubmittedTxData } & { public: FinalizedTxData }`, - so the circuit return value is at `.private.result` and `.public` is tx framing - (not contract state — hence public state must come from the indexer). -- **OQ4 partially resolved (INV-17).** Dry derivation reproduces the existing - harness's `toHexPadded(label)` so migrated specs resolve identical keys. Live - alias→wallet resolution is caller-supplied (`resolveLiveKey`); alignment with - prefunded seeds remains the harness's job (INV-22). -- **Pure-eval in live.** `createBackendSimulator` always builds the synchronous - simulator (`localSim`); dry uses it for everything, live uses it only for pure - circuits. In live this runs `initialState` **in memory only** — never an - on-chain deploy (INV-10). Pure circuits are state/caller-independent, so the - seed is irrelevant to results. -- **`super.create` binding.** Per-module subclasses override the static `create` - and call `super.create(...)` to keep `this` bound to the subclass. Biome's - `noThisInStatic` would rewrite this to the base-class name and silently break - it; suppressed with a targeted `biome-ignore` and a comment. - -## Deviations from upstream (propose sync — Y/N) - -These deviate from the design/invariants prose. None applied to those docs yet: - -1. **`./live` subpath dropped; `createLiveContext` exported from the main barrel.** - Per dev instruction (no sub-directory barrels). Wall is preserved (verified). - → Propose updating INV-1/INV-20 and the design's Package Layout to drop the - `./live` subpath. **[Y/N]** -2. **INV-6 friendly error lives in `createLiveContext`, not the `LiveBackend` - dynamic-import site.** In this architecture `LiveBackend` has no heavy deps, so - the genuine missing-deps boundary is `createLiveContext`'s dynamic import. - → Propose noting this enforcement-site refinement in INV-6. **[Y/N]** -3. **`LiveContext` simplified to `LiveContext

`.** `C` and `L` are erased - into structural types (`DeployedTxHandle`) and the shared `ledgerExtractor`, so - a harness implements it without midnight-js generics. - → Propose updating the design's `LiveContext` signature. **[Y/N]** - -## Out of Scope - -- **INV-23 CI job.** The `test:live` script exists; wiring the dedicated live CI - job (node infra) is a follow-up, not in this code draft. -- **INV-3 install matrix.** No automated "install without optional peers, run dry - green" test yet. -- **INV-6 error test.** Not exercised (can't uninstall the peers within the suite). -- **Real-node validation.** INV-11/12/13/14/15/17/18 are covered by dry + a - deterministic fake `LiveContext`; none are validated against a live node. -- **INV-15 StateValue nesting on live.** `queryLedger` assumes `ContractState.data` - is the `StateValue` the extractor consumes; needs confirmation against a node. -- **OQ3 private-state mutation on live.** Not implemented; the backend path exposes - no private-state setter. Mutation/injection tests stay dry-only (existing - per-module `privateState` helpers). -- **Codemod (OQ5).** Hand-migrated `SimpleBackend` as the template; no codemod. -- **SampleZOwnable migration.** Left dry-only (its `injectSecretNonce` is the - INV-18 mutation asymmetry); not migrated in this pass. - -## Dev Notes - -- The fake-`LiveContext` tests (`SimpleBackend.test.ts` live block, - `LiveBackendAdapter.test.ts`) deterministically exercise the live adapter's - parity logic — result normalization, assert-message propagation, caller - lifecycle, signer cap, witness-override rejection — without a node. They are the - fast floor under INV-12; the live CI job (INV-23) is the real ceiling. -- `package.json` `test` now sets `MIDNIGHT_BACKEND=dry` explicitly (equivalent to - unset). `test:live` flips it. Same spec files, per the design. - -## Open Questions - -1. **OQ2 indexer-lag budget** — defaults are `retries: 8, baseDelayMs: 150, - maxDelayMs: 2000`; tune against a real node. -2. **OQ3 private-state mutation policy on live** — left unimplemented; confirm the - dry-only policy and whether any provider-`set` path is wanted. -3. **OQ6 CI guard** — implemented as a source-scan test; decide whether to also add - bundle/dependency-graph analysis. -4. **INV-15 live StateValue** — confirm `ContractState.data` is the right input to - `ledgerExtractor` against a node. -5. **Live signer wiring** — `resolveLiveKey` + `providersFor` are caller callbacks; - confirm the harness shape against `OpenZeppelin/compact-contracts#489`. diff --git a/packages/simulator/docs/design/live-backend-invariants.md b/packages/simulator/docs/design/live-backend-invariants.md deleted file mode 100644 index 39f4795..0000000 --- a/packages/simulator/docs/design/live-backend-invariants.md +++ /dev/null @@ -1,646 +0,0 @@ ---- -stage: invariants -project: simulator-live-backend -mode: extension -extends: packages/simulator -status: draft -timestamp: 2026-06-22 -author: 0xisk -previous_stage: packages/simulator/docs/design/live-backend.md -tags: [simulator, testing, live-backend, midnight-js, async, tooling, parity] ---- - -# Simulator Live-Mode Backend — Invariants - -> This is a **tooling** invariants pass (a TypeScript test harness), not a Compact -> contract. As the design re-mapped the contract-centric skill sections to tooling -> concerns, the invariant categories are re-mapped too (see table below). The -> distinctive surface here is **dry↔live parity**: it replaces Privacy & Disclosure -> as the central, largest category. The tooling analog of a privacy leak is a -> **false test result** — a green test on one backend that does not mean what a -> green test on the other backend means. - -## Summary - -The invariants protect three promises the design makes: (1) the **dependency wall** — -a dry import pulls zero midnight-js; (2) **dry↔live parity** — the same spec produces -the same pass/fail outcome on both backends, modulo a small set of explicitly-listed -asymmetries; (3) **additive compatibility** — every un-migrated module and the sync -`createSimulator` path keep working byte-for-byte. The central invariant is INV-12 -(observable-outcome parity); most other parity invariants exist to make it true, and -the four asymmetries that cannot be made parity (witness override, test isolation, -private-state mutation, signer cap) are pinned as hard guards or documented exceptions -rather than left implicit. INV-23 keeps parity honest over time by gating it in CI. - -Category re-map (mirrors the design's section table): - -| Skill category (contracts) | Re-mapped to (tooling) | -|---|---| -| Type-level / circuit-shape | Compile-time / type & dependency-graph (TS, lint, CI guards) | -| Runtime (`assert`) | Runtime guards (throws, dynamic-import errors) | -| State transition | State & test-lifecycle (deploy-once-shared, indexer lag) | -| Privacy & disclosure | **Dry↔Live parity** (central, largest) | -| Authorization & replay | Isolation & compatibility (dep wall, additive API, signer cap) | - -"Violation scenario" reframing for this domain: where a contract invariant asks "what -becomes publicly deducible," a parity invariant asks **"what false test result becomes -possible"** — a passing test that verifies nothing, or a failing test that flags -nothing real. - -## Compile-Time / Type & Dependency-Graph Invariants - -### INV-1: Type-only `LiveBackend` re-export - -**Category:** Compile-time / type & dependency-graph - -**Statement:** `src/index.ts` exposes `LiveBackend` only via `export type { LiveBackend }`. -The constructable *value* is reachable solely through `createBackendSimulator`'s runtime -`await import('./live/LiveBackend.js')`. The barrel never contains a value re-export -(`export { LiveBackend }`). - -**Applies to:** `src/index.ts`. - -**Enforcement mechanism:** -- Compiler: `export type` erases at build — no runtime edge from the barrel to `src/live/`. -- Test: lint rule forbidding a value re-export of `LiveBackend` from the barrel; the - INV-2 dep-graph guard catches any regression that reintroduces the edge. - -**Violation scenario:** One stray `export { LiveBackend }` statically links `src/live/` — -and therefore the entire midnight-js stack — into every dry import. Leanness is silently -lost; no test fails unless the INV-2 guard exists. - -**Severity:** Critical - ---- - -### INV-2: Dry import graph free of midnight-js - -**Category:** Compile-time / type & dependency-graph - -**Statement:** Any import reachable without `MIDNIGHT_BACKEND=live` (the barrel, -`createBackendSimulator`, `Backend`, `DryBackend`, `Signers`, `createSimulator`) resolves -zero `@midnight-ntwrk/midnight-js-*` modules. midnight-js loads only via the runtime -dynamic import inside `createBackendSimulator` when `MIDNIGHT_BACKEND=live`. All -midnight-js imports are physically confined to `src/live/`. - -**Applies to:** whole-package dependency graph; `src/live/*` is the sole midnight-js importer. - -**Enforcement mechanism:** -- Test: CI guard — a dependency-graph test or bundle analysis asserting a dry entry point - resolves no midnight-js module (exact mechanism is Open Question 6). - -**Violation scenario:** Dry-only consumers pay the install, bundle, and cold-start cost of -a heavy stack they never use. The core value proposition of the design breaks silently — -nothing surfaces until a consumer notices the bloat. - -**Severity:** Critical - ---- - -### INV-3: midnight-js declared as optional peer dependencies - -**Category:** Compile-time / type & dependency-graph - -**Statement:** Every `@midnight-ntwrk/midnight-js-*` dependency is an optional peer -dependency (`peerDependencies` + `peerDependenciesMeta.*.optional = true`). Dry-only -consumers install and run the package without them present. - -**Applies to:** `package.json`. - -**Enforcement mechanism:** -- Test: CI install matrix — install without the optional peers, run the dry suite green. - -**Violation scenario:** Dry consumers are forced to install the heavy stack (or install -fails), defeating leanness at the install boundary even if the runtime graph (INV-2) is clean. - -**Severity:** High - ---- - -### INV-4: Async circuit signature transform - -**Category:** Compile-time / type & dependency-graph - -**Statement:** `createBackendSimulator` exposes every circuit `K` as -`(...args: Args) => Promise>` (the `AsyncCircuits<>` mapped type). `DryBackend` -wraps its synchronous result in `Promise.resolve`; live awaits the network. Spec code is -uniform `await` across both backends. - -**Applies to:** `AsyncCircuits<>`, `createBackendSimulator` proxies, `DryBackend.call`. - -**Enforcement mechanism:** -- Compiler: the `AsyncCircuits<>` mapped type forces `Promise`-returning signatures. -- Runtime check: `DryBackend.call` returns `Promise.resolve(syncResult)` so a circuit never - returns a bare value on one backend and a `Promise` on the other. - -**Violation scenario:** A circuit returning a bare value on dry but a `Promise` on live makes -`await` behave differently per backend — parity broken at the type level before any test runs. - -**Severity:** High - ---- - -### INV-5: Shared `SimulatorConfig` across both factories - -**Category:** Compile-time / type & dependency-graph - -**Statement:** `createSimulator` and `createBackendSimulator` consume the identical -`SimulatorConfig` (same `contractFactory`, `defaultPrivateState`, -`contractArgs`, `ledgerExtractor`, `witnessesFactory`). The live path reuses `ledgerExtractor` -to turn indexer state into `L`. - -**Applies to:** `src/factory/SimulatorConfig.ts` (unchanged), both factories. - -**Enforcement mechanism:** -- Compiler: single shared type; no forked config struct. - -**Violation scenario:** Config drift between factories means a module's config describes a -different contract in dry vs live — a maintenance and parity hazard that compounds per module. - -**Severity:** Medium - -## Runtime Guard Invariants - -### INV-6: Clear missing-deps error in live mode - -**Category:** Runtime guard - -**Statement:** `MIDNIGHT_BACKEND=live` with midnight-js not installed → the dynamic import -fails with a message naming the missing package and the fix (`"install @midnight-ntwrk/… to -use live mode"`), not a raw `ERR_MODULE_NOT_FOUND`. - -**Applies to:** `createBackendSimulator` dynamic-import site. - -**Enforcement mechanism:** -- Runtime check: `try/catch` around `await import('./live/LiveBackend.js')`, rethrowing a wrapped error. -- Test: stub a resolution failure; assert the friendly message. - -**Violation scenario:** A cryptic module-not-found leads devs to think the package is broken -rather than that they opted into live without the optional peers. DX only, not a safety break. - -**Severity:** Medium - ---- - -### INV-7: Witness override rejected on live - -**Category:** Runtime guard - -**Statement:** On the live backend, `overrideWitness(...)` and the `witnesses` setter throw -`"witness override unsupported on live backend"`. Witnesses bind at deploy and cannot be -swapped mid-test. Dry continues to support both. - -**Applies to:** `overrideWitness`, `set witnesses` (live backend path). - -**Enforcement mechanism:** -- Runtime check: explicit throw in the live path before any state mutation. -- Test: call `overrideWitness` on a live sim → rejects with the exact message substring. - -**Violation scenario:** A silent no-op lets a witness-injection test that *intends* to swap a -witness run against the unchanged deployed witness — a false green on a privacy/auth-critical -path. A developer reads "witness-rejection test passes" and ships a contract whose witness check -was never actually exercised. The throw is the entire mitigation, so the consequence is Critical; -the design's dry-only routing reduces likelihood, not impact. - -**Severity:** Critical - ---- - -### INV-8: Backend selection is construction-time and fixed - -**Category:** Runtime guard - -**Statement:** `MIDNIGHT_BACKEND` is read once at fixture/simulator construction. A constructed -simulator does not change backends over its lifetime; there is no runtime backend toggle. - -**Applies to:** `createBackendSimulator` / `Module.create`. - -**Enforcement mechanism:** -- Runtime check: the backend is resolved at `create()`; no setter is exposed. - -**Violation scenario:** A mid-life backend switch would split state across two worlds (in-memory -vs on-chain), producing an incoherent test whose result means nothing. - -**Severity:** Medium - -## State & Test-Lifecycle Invariants - -### INV-9: Test-isolation model (dry fresh vs live shared) - -**Category:** State & test-lifecycle - -**Statement:** Dry yields pristine state per `create()` (`beforeEach`). Live's default is -deploy-once-shared (`beforeAll`), with state accumulating across tests; redeploy-per-test is -opt-in at minutes-per-suite cost (D3). A spec meant to run on both must not assume -`beforeEach`-fresh state unless it uses redeploy-per-test. - -**Applies to:** `create()`, suite lifecycle, D3. - -**Enforcement mechanism:** -- Convention + the opt-in redeploy switch; the isolation mode is documented per migrated module. -- Test: migrated specs are authored order-independent (or marked redeploy-per-test). - -**Violation scenario:** A spec relying on fresh-per-test state passes on dry but sees accumulated -state on live (order-dependent flake or false result). This is the defining lifecycle parity hazard -and the one most likely to bite a mechanical migration. - -**Severity:** High - ---- - -### INV-10: Live attaches; dry deploys - -**Category:** State & test-lifecycle - -**Statement:** In dry, `create(args)` deploys from `contractArgs` → fresh state. In live, the -caller deploys (via `LiveContext`); `create()` attaches to that instance and never deploys from args. - -**Applies to:** `create()`, `DryBackend` vs `LiveBackend` construction (D3). - -**Enforcement mechanism:** -- Runtime: `DryBackend` runs `initialState`; `LiveBackend.contractAddress` comes from `LiveContext`; - `LiveBackend` performs no deploy. - -**Violation scenario:** A double-deploy or arg-driven deploy on live diverges the address/state from -what the harness set up — subsequent reads target the wrong instance. - -**Severity:** Medium - ---- - -### INV-11: Bounded indexer-lag absorption - -**Category:** State & test-lifecycle - -**Statement:** The live public-state read path (`queryLedger`) polls/retries within a **bounded** -budget (finite count + backoff + ceiling) to absorb indexer block-lag after a `callTx` resolves, so -a read-after-write assertion is stable. The budget is never an unbounded wait. - -**Applies to:** `createLiveContext.queryLedger`, `LiveBackend.getPublicState`. - -**Enforcement mechanism:** -- Runtime: retry loop with explicit count/backoff/ceiling (concrete numbers are Open Question 2). - -**Violation scenario:** Too short → read-after-write flakes (false failure). Unbounded → a genuinely -missing write hangs the suite instead of failing it. Both destroy the test's meaning in opposite directions. - -**Severity:** High - -## Dry↔Live Parity Invariants - -> The central category — the tooling analog of Privacy & Disclosure. INV-12 is the umbrella -> promise; INV-13..INV-18 are the mechanisms that make it true or the documented asymmetries -> that bound it. - -### INV-12: Observable-outcome parity (umbrella) - -**Category:** Dry↔Live parity - -**Statement:** For any spec authored against `createBackendSimulator`, each assertion's pass/fail -outcome is the same under `MIDNIGHT_BACKEND=dry` and `=live`, **modulo the four explicitly-listed -asymmetries**: (1) witness override (INV-7, hard-errors on live); (2) test isolation and -constructor-arg effect (INV-9 + INV-10, live shares one deploy); (3) private-state mutation (INV-18, -best-effort on live); (4) signer cap (INV-21, live caps at 4 aliases while dry is unlimited). Outside -those four, a green dry test means the same thing as a green live test. **This list is closed** — any -divergence not on it is a bug, not a new asymmetry to document after the fact. - -**Applies to:** every public operation; the design's central promise. - -**Enforcement mechanism:** -- The sum of INV-13..INV-18 plus the lifecycle invariants makes outcomes identical. -- Gated by INV-23: the same spec runs on both backends in CI; divergence outside the four asymmetries is a failure. - -**Violation scenario:** Divergence makes a passing test meaningless — the tooling analog of a privacy -leak. A dev trusts a dry-green suite and ships a contract that behaves differently against a real node -(or abandons a correct change because live falsely reds). - -**Severity:** Critical - ---- - -### INV-13: Result-shape parity (normalization to bare `R`) - -**Category:** Dry↔Live parity - -**Statement:** `LiveBackend.call('impure', name, args)` normalizes `callTx`'s -`{ public, private: { result } }` to the bare `R` that `DryBackend` returns. Impure reads (e.g. -`owner()`) surface the same `R` shape in both modes. After normalization, an assertion on the return -value is identical across backends. - -**Applies to:** `LiveBackend.call` (impure path), impure reads. - -**Enforcement mechanism:** -- Runtime: normalization in `LiveBackend.call`, pinned against the installed - `@midnight-ntwrk/midnight-js-contracts` `callTx` return type (Open Question 1 — must be verified - before this is final). -- Test: a parity assertion comparing the dry and live return values for the same call. - -**Violation scenario:** A spec must branch on backend to read a result (parity broken), or an -assertion silently reads the wrong nested field and produces a false pass/fail. - -**Severity:** Critical - ---- - -### INV-14: Assertion-message parity - -**Category:** Dry↔Live parity - -**Statement:** A contract `assert` failure surfaces on both backends so that -`await expect(p).rejects.toThrow('Foo: msg')` matches by **substring**. `LiveBackend` must not swallow -or rewrite the contract assert message when normalizing the rejection; live may add surrounding -proof/tx framing, so specs match on a substring, never an exact full string. - -**Applies to:** failure path of every impure call; `LiveBackend` rejection normalization. - -**Enforcement mechanism:** -- Runtime: `LiveBackend` preserves the underlying message in the thrown/rejected error. -- Convention + test: specs use substring matching; a negative test runs green on both backends. - -**Violation scenario:** A swallowed/rewritten message makes an expect-revert test pass on dry but not -match on live (false failure); a match-anything fallback makes any rejection satisfy the assertion (false pass). - -**Severity:** Critical - ---- - -### INV-15: Public-state read parity - -**Category:** Dry↔Live parity - -**Statement:** `getPublicState()` / `getContractState()` apply the same `ledgerExtractor` in both modes — -over the in-memory `CircuitContext` (dry) and over the indexer-sourced `StateValue` (live). The extracted -`L` is structurally identical for equivalent state. - -**Applies to:** `getPublicState`, `getContractState`; the shared `ledgerExtractor`. - -**Enforcement mechanism:** -- Runtime: the single `ledgerExtractor` from `SimulatorConfig` (INV-5) is the only extraction path for both backends. - -**Violation scenario:** Divergent extraction makes state assertions mean different things per backend, even -when the underlying contract state is the same. - -**Severity:** High - ---- - -### INV-16: Pure-circuit locality parity - -**Category:** Dry↔Live parity - -**Statement:** Pure circuits run locally on the JS artifact in **both** modes (no tx in live); only impure -circuits hit the node in live. `LiveBackend` retains the JS contract for local pure evaluation. Locality -follows pure/impure, **not** read/write: reads implemented as impure circuits (e.g. `owner()`) still go to -the node in live (D2). - -**Applies to:** `LiveBackend.call` routing (`'pure'` → local JS, `'impure'` → `handle.callTx`). - -**Enforcement mechanism:** -- Runtime: `call('pure', …)` evaluates locally; `call('impure', …)` submits a tx. - -**Violation scenario:** Submitting a tx for a pure circuit burns one of the 4 wallets and can diverge results; -treating an impure read as local skips the node and reads stale local state → false parity. - -**Severity:** High - ---- - -### INV-17: Caller-identity parity - -**Category:** Dry↔Live parity - -**Statement:** `as('OWNER')` denotes the same logical actor in both modes, and -`signers.eitherFor('OWNER')` / `keyFor('OWNER')` resolve to a value consistent with that actor. Dry derives -a deterministic key from the alias label; live resolves the alias to a fixed prefunded wallet — and the -alias→actor mapping is aligned so "OWNER" is the same party across backends (D1). The -`setCaller(alias, mode)` **mode semantics also match across backends**: `'single'` applies the caller to the -next call then reverts to the default signer; `'persistent'` keeps it until changed. The revert-after-one-call -lifecycle of `'single'` behaves identically on dry and live. - -**Applies to:** `as`, `setPersistentCaller`, `Backend.setCaller(alias, mode)`, `signers.eitherFor`, `signers.keyFor`. - -**Enforcement mechanism:** -- Runtime: the `Signers` resolver; the dry derivation and live seed assignment share one alias→actor mapping - (Open Question 4 pins the resolver and the alignment). -- Runtime: both backends implement the same `'single'`/`'persistent'` lifecycle in `setCaller`. -- Test: a `'single'`-mode call followed by a default-signer call asserts the same active caller on both backends. - -**Violation scenario:** If `'OWNER'` maps to different keys/actors across backends, an authorization test -passes on one backend and fails on the other — a false result that looks like a flaky auth check. - -**Severity:** High - ---- - -### INV-18: Private-state read parity (with documented mutation asymmetry) - -**Category:** Dry↔Live parity - -**Statement:** `getPrivateState()` returns the contract's private state `P` in both modes — from the -`CircuitContext` (dry) and from `levelPrivateStateProvider` keyed by `privateStateId` (live). **Read parity -holds.** **Mutation/injection parity does NOT:** live `privateState.*` mutation is best-effort via -`privateStateProvider.set`, and mid-test secret/witness injection (e.g. -`SampleZOwnable.privateState.injectSecretNonce`) may not faithfully reproduce on live, so such tests may -remain dry-only (Open Question 3). - -**Applies to:** `getPrivateState` (read parity), `privateState.*` mutation (asymmetry). - -**Enforcement mechanism:** -- Runtime: `LiveBackend.getPrivateState` via the provider; mutation documented as best-effort / dry-only. -- Test: read parity asserted on both backends; injection tests tagged dry-only until the policy is decided. - -**Violation scenario:** Assuming mutation parity makes a private-state-injection test green on dry while the -injection never takes effect on live — false confidence in a privacy-critical path. Read divergence yields -wrong private-state assertions. - -**Severity:** High - -## Isolation & Compatibility Invariants - -### INV-19: Existing sync path unchanged byte-for-byte - -**Category:** Isolation & compatibility - -**Statement:** `createSimulator`, `AbstractSimulator`, `ContractSimulator`, `CircuitContextManager`, -`SimulatorConfig`, all existing `types/*`, and every existing synchronous per-module simulator + spec keep -working unchanged. The new work is strictly additive; no existing file's runtime behavior changes. - -**Applies to:** all existing `src/core/*`, `src/factory/createSimulator.ts`, `src/factory/SimulatorConfig.ts`, -`src/types/*`, `src/proxies/*`, and every un-migrated module. - -**Enforcement mechanism:** -- Test: the full existing dry suite passes unchanged as a regression gate; additive-only diff to existing files. - -**Violation scenario:** A regression in the sync path breaks every un-migrated module at once — the per-module -opt-in promise (no flag day) fails. - -**Severity:** Critical - ---- - -### INV-20: Public API purely additive - -**Category:** Isolation & compatibility - -**Statement:** The barrel only gains exports — values `createBackendSimulator`, `DryBackend`, `Backend`, -`LiveContext`, `Signers`; type-only `LiveBackend` (INV-1). No existing export is removed, renamed, or changed -in signature. `createSimulator` consumers see no breaking change. - -**Applies to:** `src/index.ts`. - -**Enforcement mechanism:** -- Test: additive barrel diff; optionally an API-extractor / export-snapshot test. - -**Violation scenario:** A breaking barrel change forces every consumer to migrate, violating the no-flag-day goal. - -**Severity:** High - ---- - -### INV-21: Live signer cap enforced, not silently exceeded - -**Category:** Isolation & compatibility - -**Statement:** Live supports exactly the prefunded set — deployer + 3 named aliases = 4 on the dev-preset node -(D1). Requesting an alias beyond the funded pool fails with a clear error pointing at the deferred -derive-and-fund flow. It never silently reuses a wallet or proceeds with an unfunded one. - -**Applies to:** `Signers` / `WalletPool` live resolution. - -**Enforcement mechanism:** -- Runtime: a bounds-check in the live alias resolver that throws on overflow. -- Test: requesting a 5th distinct alias on live → rejects with the cap message. - -**Violation scenario:** Silent wallet reuse collapses two aliases into one actor → authorization tests pass -spuriously. An unfunded wallet → opaque tx failure that looks like a contract bug. - -**Severity:** Medium - ---- - -### INV-22: Caller harness owns all live infra - -**Category:** Isolation & compatibility - -**Statement:** The package ships no docker-compose, no endpoint provisioning, no deploy. All live infra -(`EnvironmentConfiguration` from `MIDNIGHT_*` env, providers, `WalletPool`, deploy, `LiveContext` impl) lives -in the consuming harness. The optional `createLiveContext` helper only assembles already-provided pieces -(providers + `WalletPool` + `CompiledContract` + `contractAddress`). - -**Applies to:** `src/live/*` boundary; `createLiveContext`. - -**Enforcement mechanism:** -- Boundary by construction: `src/live/` contains only the adapter + optional assembler, no infra; reviewed at code stage. - -**Violation scenario:** Leaking infra into the package re-introduces heavy deps / environment assumptions into the -dependency graph (threatening INV-2) and couples the simulator to one topology. - -**Severity:** Medium - ---- - -### INV-23: Parity is CI-gated per migrated module - -**Category:** Isolation & compatibility - -**Statement:** A module counts as *migrated* only if its single spec runs green on **both** backends in CI — -`MIDNIGHT_BACKEND=dry vitest run` and `MIDNIGHT_BACKEND=live vitest run`. Live is run in a dedicated CI job -with the node infra available. A migrated module that is green on dry but not exercised (or not green) on live -is not actually migrated, and the gate must block the merge. Un-migrated modules run dry-only and are exempt. - -**Applies to:** CI configuration; the migrated-module set. - -**Enforcement mechanism:** -- Test/CI: a `test:live` job over the migrated set; a migrated module missing from it (or red on it) fails the gate. -- This is what operationalizes INV-12 — parity is verified continuously, not asserted once at migration time. - -**Violation scenario:** Without the gate, a module migrated months ago silently drifts: a later dry-only change -breaks live parity and no one notices until a real-node run surprises someone. Parity rot is the failure mode -this invariant exists to prevent. - -**Severity:** High - -## Existing Invariants (Extension Mode) - -### Preserved (must not break — formalized by INV-19, INV-20) - -- `createSimulator` stays **synchronous**: returns a class extending `ContractSimulator`; constructor is sync - (`src/factory/createSimulator.ts:42`). -- `getPublicState(): L` is **synchronous** in the existing path via `config.ledgerExtractor(...)` - (`createSimulator.ts:144`). -- Circuit proxies are `ContextlessCircuits` = `(...args) => R` (sync) in the existing path - (`src/proxies/CircuitProxies.ts`, `src/types/Circuit.ts`). -- `overrideWitness` / `set witnesses` recreate the contract and reset proxies — dry behavior preserved - (`createSimulator.ts:165-182`). Live diverges deliberately (INV-7). -- Constructor defaults preserved: `coinPK = '0'.repeat(64)`, `contractAddress = dummyContractAddress()` - (`createSimulator.ts:48-53`). -- Barrel exports both values and types as today (`src/index.ts`); new exports are additive (INV-20), with the - type-only `LiveBackend` rule (INV-1). - -### Modified - -- **None.** The "simulator class is the single test-facing API" goal is delivered by a *new* additive factory - (`createBackendSimulator`), not by modifying the existing class or its proxies. No existing invariant changes. - -### New - -- INV-1 through INV-22 are all new, introduced by the backend-aware path. - -## Invariant Coverage Matrix - -| Operation / surface | Invariants | Enforcement | -|---|---|---| -| `createBackendSimulator` / `Module.create` | INV-4, INV-5, INV-8, INV-9, INV-10, INV-19, INV-20 | TS types + construction-time backend resolve + additive diff | -| Impure call | INV-4, INV-12, INV-13, INV-14, INV-16 | Async proxy + result/message normalization + pure/impure routing | -| Pure call | INV-4, INV-12, INV-16 | Local JS eval in both modes | -| `getPublicState` / `getContractState` | INV-11, INV-12, INV-15 | Shared `ledgerExtractor` + bounded indexer-lag retry | -| `getPrivateState` | INV-12, INV-18 | Provider read (live) / context (dry); mutation asymmetry documented | -| `as` / `setPersistentCaller` | INV-12, INV-17, INV-21 | `Signers` resolver + signer-cap bounds-check | -| `signers.eitherFor` / `keyFor` | INV-17, INV-21 | Alias→actor mapping aligned across backends | -| `overrideWitness` / `set witnesses` | INV-7, INV-19 | Live throws; dry preserved | -| `privateState.*` mutation | INV-18 | Best-effort provider set; injection tests may stay dry-only | -| Barrel / dry import | INV-1, INV-2, INV-3, INV-20 | Type-only re-export + dep-graph CI guard + optional peers | -| Live module load | INV-2, INV-6 | Dynamic import + wrapped missing-deps error | -| Failure path (`rejects.toThrow`) | INV-14 | Message preserved as substring | -| CI parity gate (migrated module) | INV-12, INV-23 | `test:dry` + `test:live` both green to count as migrated | -| Existing sync simulators + specs | INV-19 | Full existing dry suite as regression gate | - -## Out of Scope - -- **Performance / timing parity.** Live is slower by physics; no invariant claims equal wall-clock or per-op latency. -- **Gas / fee accounting parity.** Not modeled by the simulator on either backend. -- **Private-state mutation/injection parity on live.** Best-effort only (INV-18); faithful mid-test injection is not - guaranteed, and such tests may stay dry-only. Reason: provider semantics differ from in-memory context; policy is Open Question 3. -- **Mid-test witness-implementation swapping on live.** Hard-errors (INV-7); witnesses bind at deploy. Reason: no on-chain mechanism to rebind. -- **More than 4 concurrent live signers.** Fails clearly (INV-21); derive-and-fund flow deferred. Reason: dev-preset node prefunds 4 wallets. -- **Deployment, provider construction, wallet funding, infra provisioning.** Caller-harness responsibility (INV-22); the package ships none. Reason: dev scoped strictly to the simulator. -- **`@openzeppelin/compact-deployer` integration.** Branch-only as of this design; not a dependency. -- **Preprod / testnet parity.** Local node assumed; endpoints from `MIDNIGHT_*` env. -- **First-party fuzzing.** `fast-check` stays hand-rolled. - -## Dev Notes - -- **Parity is the load-bearing property.** Read every parity invariant (INV-12..INV-18) as either "this is made - identical across backends" or "this is a documented asymmetry with a hard guard." There is no third state — an - undocumented divergence is the bug class this whole document exists to prevent. -- INV-13 (result shape) and INV-17 (alias resolution) are the two parity invariants most likely to be wrong in the - first code draft, because both depend on midnight-js details not yet pinned (Open Questions 1 and 4). Treat their - enforcement as provisional until verified against the installed packages. -- Reference prior art for the live harness shape: `OpenZeppelin/compact-contracts#489` - (`test/cma-upgradability/_harness/*`, `fixtures/testTokenV1.ts`). `createLiveContext` generalizes that fixture's kit. - -## Open Questions - -1. **Result-extraction shape (blocks INV-13).** Verify `.private.result` against the installed - `@midnight-ntwrk/midnight-js-contracts` `callTx` return type, including how impure-typed reads (e.g. `owner()`) - surface their value. INV-13's normalization is provisional until pinned. -2. **Indexer-lag budget (concretizes INV-11).** Decide the retry count, backoff, and ceiling for `queryLedger` so - read-after-write is stable without slowing the suite. -3. **Private-state mutation parity (bounds INV-18).** How faithfully can live reproduce mid-test private-state - injection via `privateStateProvider.set`? Decide which tests stay dry-only and codify the policy in code/tests. -4. **Dry alias→key resolver + live alignment (concretizes INV-17).** Deterministic derivation vs caller-supplied - alias→key map, and how aliases line up with live prefunded seeds so "the same alias = the same actor" in both modes. -5. **Codemod vs hand-migrate (process, not an invariant).** Build a real codemod for the async migration, or hand-migrate - the first module and template from it? -6. **CI guard mechanism (concretizes INV-1/INV-2 enforcement).** Dependency-graph test vs bundle analysis to assert a - dry-only import pulls no midnight-js. diff --git a/packages/simulator/docs/design/live-backend.md b/packages/simulator/docs/design/live-backend.md deleted file mode 100644 index 52ee249..0000000 --- a/packages/simulator/docs/design/live-backend.md +++ /dev/null @@ -1,335 +0,0 @@ ---- -stage: design -project: simulator-live-backend -mode: extension -extends: packages/simulator -status: draft -timestamp: 2026-06-22 -author: 0xisk -previous_stage: null -tags: [simulator, testing, live-backend, midnight-js, async, tooling] ---- - -# Simulator Live-Mode Backend — Design Document - -> Tracks OpenZeppelin/compact-tools#75. This is a **tooling** design (a TypeScript -> test harness), not a Compact contract. The `midnight-design` skill's -> contract-centric sections (ledger schema, disclosure boundary, witnesses) were -> re-mapped to tooling concerns; see the table in the Summary. - -## Summary - -Make the per-module simulator class the single test-facing API for a contract, runnable -against either the existing in-memory path (`DryBackend`) or a live local Midnight node -(`LiveBackend`), selected by `MIDNIGHT_BACKEND=dry|live` at fixture-construction time. -A new `createBackendSimulator(...)` factory lives alongside the existing synchronous -`createSimulator(...)`; modules opt in one at a time. The core trade-off: live infra is -async by physics, so a single spec file can run on both backends only if it is written -async. Opted-in modules take a one-time, mostly-mechanical async migration; un-migrated -modules are untouched. - -Skill-section re-mapping used in this design: - -| Skill section (contracts) | Re-mapped to (tooling) | -|---|---| -| Contract Layout | Package & dependency layout | -| Ledger Schema / Types | Core types + the `Backend` seam + sync→async transform | -| Circuits / Witnesses | Public API surface | -| State Partitioning & Disclosure | Execution-model parity (dry vs live) — the central decision | -| Integration Patterns | Opt-in, backend selection, caller harness | -| Error Handling | Assertion / error-message parity | -| Indexer-visible fields | State-read parity | - -## Package & Dependency Layout - -Single package, single barrel. Users only ever -`import { … } from '@openzeppelin/compact-simulator'`. No `/live` subpath in user code. - -- **Leanness via dynamic import, not subpath export.** Every `@midnight-ntwrk/midnight-js-*` - import lives *inside* the live module (`src/live/`). `createBackendSimulator` reaches it - through a runtime `await import('./live/LiveBackend.js')`, fired **only** when - `MIDNIGHT_BACKEND=live`. A static `import { createBackendSimulator }` never pulls the - midnight-js stack into the dependency graph. -- **Barrel exports `LiveBackend` as a `type` only**, plus the value-level - `createBackendSimulator`, `Backend`, `DryBackend`, `LiveContext`, and the `signers` - helper types. The constructable live *value* is reached solely through the factory's - dynamic import. -- **Hard constraint:** the barrel must never statically re-export the `LiveBackend` - *value* (`export { LiveBackend }`). That one line would re-couple the heavy deps to every - dry import. Type-only re-export keeps the wall up. This must be enforced (lint rule or a - bundle-size/dep test in CI). -- **Dependency declaration:** the midnight-js packages are `optionalpeerDependencies`. - Dry-only consumers never install them. `MIDNIGHT_BACKEND=live` without them installed → - the dynamic import fails with a clear `"install @midnight-ntwrk/… to use live mode"` - error, not a cryptic module-not-found. - -Proposed source layout: - -``` -packages/simulator/src/ -├── core/ # unchanged: AbstractSimulator, ContractSimulator, CircuitContextManager -├── factory/ -│ ├── createSimulator.ts # unchanged (sync, dry-only) -│ ├── createBackendSimulator.ts # NEW — async, backend-aware -│ └── SimulatorConfig.ts # unchanged (reused as-is by both factories) -├── backend/ -│ ├── Backend.ts # NEW — the Backend interface -│ └── DryBackend.ts # NEW — wraps the existing CircuitContext path -├── live/ # NEW — isolated; only this dir imports midnight-js -│ ├── LiveBackend.ts # thin adapter over an injected LiveContext -│ ├── LiveContext.ts # the injection seam (interface + types) -│ └── createLiveContext.ts # OPTIONAL convenience builder (findDeployedContract + queries) -├── signers/ -│ └── Signers.ts # NEW — alias→key (dry) / alias→wallet (live) resolution -└── index.ts # barrel (type-only re-export of LiveBackend) -``` - -## Core Types & the `Backend` Seam - -`SimulatorConfig` is reused unchanged. Both factories consume the -same `contractFactory`, `defaultPrivateState`, `contractArgs`, `ledgerExtractor`, -`witnessesFactory`. The live path reuses `ledgerExtractor` to turn indexer state into `L`. - -**The `Backend` interface** abstracts only the operations that genuinely differ: - -```ts -interface Backend { - call(kind: 'pure' | 'impure', name: string, args: unknown[]): Promise; - getPublicState(): Promise; - getPrivateState(): Promise

; - getContractState(): Promise; - setCaller(alias: string | null, mode: 'single' | 'persistent'): void; - readonly contractAddress: string; -} -``` - -**Sync→async transform.** Today `ContextlessCircuits` maps each circuit to -`(...args) => R`. Add an async sibling used by `createBackendSimulator`: - -```ts -type AsyncCircuits = { - [K in keyof Circuits]: (...args: Args) => Promise>; -}; -``` - -`createBackendSimulator` owns the async `pure` / `impure` proxies (the backend stays dumb — -it exposes `call(...)`, the factory builds the proxies on top). **Dry wraps its synchronous -result in `Promise.resolve`; live awaits the network.** Spec code is uniform `await`. - -## Public API Surface - -**Construction is async** (live must await deploy/handle/query; dry wraps sync): - -```ts -// dry (MIDNIGHT_BACKEND unset|dry) -const sim = await SampleZOwnableSimulator.create(args, options); - -// live (MIDNIGHT_BACKEND=live) — caller injects the live world -const sim = await SampleZOwnableSimulator.create(args, { live: liveCtx }); -``` - -**The injection seam — `LiveContext`, defined by the package, implemented by the -caller's harness** (thin: the caller hands over fully-built per-alias handles + readers): - -```ts -interface LiveContext { - handleFor(alias: string | null): Promise>; // null = default signer; cached per alias - queryLedger(): Promise; // → ledgerExtractor → L - queryPrivateState(): Promise

; - readonly contractAddress: string; -} -``` - -`LiveBackend` is then a pure adapter: route `call('impure', name, args)` to -`handleFor(activeAlias).callTx[name](...)` and normalize the result; route -`call('pure', …)` to local JS evaluation (D2); route `getPublicState()` through -`queryLedger()` + `ledgerExtractor`. All midnight-js wiring stays in the caller's harness -or the optional `createLiveContext` helper, never in the package's runtime deps beyond the -type imports inside `src/live/`. - -**Per-module authoring is unchanged except async** — swap the factory, add `return`, widen -the return type: - -```ts -// before (dry-only): transferOwnership(id) { this.circuits.impure.transferOwnership(id); } -// after (both modes): transferOwnership(id) { return this.circuits.impure.transferOwnership(id); } // Promise -``` - -**`Signers` helper** (alias is the common currency for caller identity and circuit args): - -```ts -sim.as('OWNER').transferOwnership(newId); // caller identity -await sim.signers.eitherFor('OWNER'); // Either for circuit args -await sim.signers.keyFor('OWNER'); // raw key -``` - -Dry resolves an alias to a deterministic key from its label (same trick as the existing -`makeUser('OWNER')` / `generatePubKeyPair('OWNER')`); live resolves it to a pooled, -prefunded wallet. - -## Execution-Model Parity (Dry vs Live) - -The central design surface. For every operation: - -| Operation | Dry (`DryBackend`) | Live (`LiveBackend`) | Seam decision | -|---|---|---|---| -| **Construct** | `create()` runs `initialState` → fresh state per call | attaches to caller-deployed instance via `LiveContext` | args drive deploy in dry; in live the caller deployed already (D3) | -| **Impure call** | run JS circuit, update `CircuitContext` | `handle.callTx[name](...)`, submit tx | normalize live `{public, private:{result}}` → bare `R` | -| **Pure call** | run JS pure circuit, return `R` | **run locally** on the JS artifact, no tx (D2) | identical local path; live keeps the JS contract for this | -| **getPublicState** | `ledgerExtractor(ctx.state)` | `ledgerExtractor(await queryLedger())` | same extractor, async source | -| **getPrivateState** | `ctxManager.currentPrivateState` | `await queryPrivateState()` (provider) | — | -| **as / setPersistentCaller** | alias→deterministic key→`emptyZswapLocalState` | alias→pooled wallet→cached handle | D1 | -| **overrideWitness / set witnesses** | recreate contract with new witnesses | bound at deploy; cannot swap mid-test | **dry-only**; live throws `"witness override unsupported on live backend"` | -| **privateState.* mutation** | `ctxManager.updatePrivateState` | `privateStateProvider.set(id, …)` (best-effort) | asymmetry flagged; witness-injection tests may stay dry-only | -| **Failure** | sync `throw "Foo: msg"` | `callTx` rejects, assert msg propagates as substring | both → `await expect(...).rejects.toThrow(msg)` | - -### Decisions - -- **D1 — Caller identity unifies on alias *strings*.** Live can sign only with prefunded - wallets, so `as(OWNER /*CoinPublicKey*/)` becomes `as('OWNER')`. Migrated caller-override - tests change refs to aliases (a bit more than await-noise, scoped to those tests). Circuit - args that need a key use `sim.signers.eitherFor('OWNER')` in both modes. **Live cap:** 4 - prefunded wallets (deployer + 3 named aliases) on the dev-preset node; more requires a - derive-and-fund flow (deferred). -- **D2 — Pure circuits run locally in both modes; only impure circuits hit the node in - live.** A `pure circuit` is deterministic JS with no ledger/witness, so submitting a tx - for it would be absurd and would burn one of the 4 wallets. `LiveBackend` keeps the JS - artifact for local pure eval. Note: some "reads" in these simulators are *impure* - circuits (e.g. `owner()` in `SampleZOwnableSimulator`) and still go to the node. -- **D3 — Test isolation: deploy-once-shared (default) vs redeploy-per-test (opt-in).** Dry - gives pristine state every `beforeEach: create()`. Live attaching to one deployed contract - accumulates state across tests, so the default is deploy-once in `beforeAll` with tests - tolerating shared state (fast; the CMA branch's approach). Redeploy-per-test gives true - isolation at minutes-per-suite cost. Consequence: some `beforeEach`-fresh-state - assumptions in existing tests will not survive live unchanged. - -## Integration: Opt-in, Backend Selection, Caller Harness - -- **Backend selection** at fixture-construction via `MIDNIGHT_BACKEND`. `package.json`: - ```json - "test": "MIDNIGHT_BACKEND=dry vitest run", - "test:live": "MIDNIGHT_BACKEND=live vitest run" - ``` - Same spec files, flip the env var. -- **Opt-in per module** = swap `createSimulator` → `createBackendSimulator`, add `return` - to delegating methods, widen return types to `Promise`, and async-migrate that module's - spec (`await`, `.rejects.toThrow`, alias caller refs). Codemod-assisted (~90% mechanical). -- **Caller harness owns all live infra** — the package ships no docker-compose and no - endpoint provisioning. The harness (in the consuming repo) builds the - `EnvironmentConfiguration` from `MIDNIGHT_*` env (localhost defaults, infra assumed - running), constructs providers, a `WalletPool`, deploys once, and implements `LiveContext`. - The branch `OpenZeppelin/compact-contracts#489` `_harness/` + fixture is the reference - shape. -- **Optional `createLiveContext({...})` convenience** (exported from `src/live/`): given - providers + a `WalletPool` + a `CompiledContract` + `contractAddress`, it builds the - per-alias `findDeployedContract` handle cache and the `queryLedger` / `queryPrivateState` - readers — so callers don't re-derive the kit by hand. `LiveBackend` itself stays thin; - this helper is separate. - -## Error & Assertion Parity - -- One failure mechanism on both sides surfaces the same way after async migration: - `await expect(promise).rejects.toThrow(message)`. -- Live wraps the assert message in additional context (proof / tx-failure framing), so - tests must match on a **substring** (`.toThrow('Foo: msg')`), never assert an exact full - error string. Confirmed by the branch: `.rejects.toThrow('AccessControl: unauthorized account')` - passes against the live node. -- Backend responsibility: `LiveBackend` must not swallow or rewrite the contract assert - message when normalizing the rejection. - -## State-Read Parity - -- `getPublicState()` / `getContractState()` read the indexer in live - (`publicDataProvider.queryContractState(address)` → `ledger(state.data)`), the in-memory - context in dry. Same `ledgerExtractor` over both. -- **Indexer lag:** `callTx` resolves on tx confirmation, but a subsequent indexer read can - trail by a block. `createLiveContext.queryLedger` should poll/retry briefly to absorb the - lag before returning, so a `read-after-write` assertion does not flake. -- `getPrivateState()` reads the `levelPrivateStateProvider` (keyed by `privateStateId`) in - live, the context in dry. - -## Change Plan (Extension Mode) - -**New:** -- `src/backend/Backend.ts`, `src/backend/DryBackend.ts` -- `src/factory/createBackendSimulator.ts` -- `src/live/{LiveBackend,LiveContext,createLiveContext}.ts` -- `src/signers/Signers.ts` -- `AsyncCircuits<>` type (alongside `ContextlessCircuits`) -- New barrel exports (value: `createBackendSimulator`, `DryBackend`, `Backend`, - `LiveContext`, `Signers`; type-only: `LiveBackend`) - -**Modified:** -- `src/index.ts` — additive exports only; the type-only `LiveBackend` re-export rule. -- `package.json` — add midnight-js packages as `optionalpeerDependencies`; add a CI - guard that a dry-only import resolves no midnight-js. - -**Unchanged (must keep working byte-for-byte for un-migrated modules):** -- `createSimulator`, `AbstractSimulator`, `ContractSimulator`, `CircuitContextManager`, - `SimulatorConfig`, all existing `types/*`, and every existing synchronous per-module - simulator + its spec. - -**API compatibility / publishing impact:** -- Purely additive to the public API; no breaking change to `createSimulator` consumers. -- Dry-only install footprint is unchanged (heavy deps are optional + dynamically imported). -- No CMA / verifier-key implications for the simulator package itself; those live in the - contracts under test and the caller's deploy harness. - -## Design Decisions Log - -- **Single barrel + dynamic import + type-only `LiveBackend` re-export + optional peer - deps** — the only combination that delivers "no `/live` in user code", "no flag day", and - "dry path stays lean" simultaneously. -- **`createBackendSimulator` alongside `createSimulator`** (not a replacement) — gradual, - per-module opt-in; un-migrated modules untouched. -- **Async everywhere on the backend-aware path** — forced by live-infra physics; accepted - as a one-time, codemod-assisted migration per opted-in module. -- **Thin `LiveBackend` + injected `LiveContext`** — deployment, provider construction, and - wallet funding are the caller's responsibility (the package ships no infra). Optional - `createLiveContext` helper reduces caller boilerplate without thickening the backend. -- **D1 alias-string caller identity**, **D2 pure-local-in-both-modes**, **D3 - deploy-once-shared default**. -- **Result normalization** — `LiveBackend` unwraps `callTx`'s `{ public, private:{result} }` - to the bare `R` that dry returns, so spec assertions are identical across backends. - -## Out of Scope - -- **Deployment, provider construction, wallet funding, infra provisioning.** The package - ships no docker-compose and no endpoint setup; the caller's harness owns it. Reason: the - dev scoped this strictly to the simulator; the deployer is on a branch and not depended - upon. -- **The `@openzeppelin/compact-deployer` package.** Not a dependency (branch-only as of this - design). -- **Preprod / testnet targets.** Local node assumed; endpoints come from `MIDNIGHT_*` env. -- **Mid-test witness-implementation swapping on live.** Witnesses bind at deploy; live - `overrideWitness` is unsupported. -- **More than 4 concurrent live signers** (derive-and-fund flow). Deferred. -- **A first-party fuzzing harness.** Out of scope; `fast-check` remains hand-rolled. - -## Dev Notes - -- Reference prior art: `OpenZeppelin/compact-contracts#489` (`test/cma-upgradability`) - `_harness/{network,providers,wallet,walletPool,deploy}.ts` and - `fixtures/testTokenV1.ts`. Our `createLiveContext` generalizes that fixture's kit. -- The issue names the package `@openzeppelin-compact/contracts-simulator`; the actual name - is `@openzeppelin/compact-simulator`. Using the real name. - -## Open Questions - -1. **Result extraction shape** — verify `.private.result` against the installed - `@midnight-ntwrk/midnight-js-contracts` `callTx` return type, including how - impure-typed reads (e.g. `owner()`) surface their value. Pin the exact field before - coding `LiveBackend.call`. -2. **Indexer-lag policy** — concrete retry/poll budget for `queryLedger` (count, backoff, - ceiling) so live reads are reliable without slowing the suite. -3. **Private-state parity for witness-heavy contracts** — how faithfully can live reproduce - mid-test private-state injection (e.g. `SampleZOwnable.privateState.injectSecretNonce`) - via `privateStateProvider.set`? Some such tests may stay dry-only initially; decide the - policy in invariants/code. -4. **Dry alias→key resolver** — default deterministic derivation vs a caller-supplied - alias→key map; and how aliases line up with live prefunded seeds so the same alias means - "the same actor" in both modes. -5. **Codemod** — build a real codemod for the async migration, or hand-migrate the first - module and template from it? -6. **CI guard** — exact mechanism to assert a dry-only import pulls no midnight-js (bundle - analysis vs a dependency-graph test). diff --git a/yarn.lock b/yarn.lock index 1777295..5941fc6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -105,6 +105,19 @@ __metadata: languageName: node linkType: hard +"@effect/platform@npm:^0.95.0": + version: 0.95.0 + resolution: "@effect/platform@npm:0.95.0" + dependencies: + find-my-way-ts: "npm:^0.1.6" + msgpackr: "npm:^1.11.4" + multipasta: "npm:^0.2.7" + peerDependencies: + effect: ^3.20.0 + checksum: 10/ae3f3bd441f77bb0f3bb71f954d3a06be2565e4d924eba8c7d5c898da32d893f42c4af0e5c6fee5a1ba087ab7d2d1dae8734a4b1e830baeb654fcccd63c996bb + languageName: node + linkType: hard + "@emnapi/core@npm:1.10.0": version: 1.10.0 resolution: "@emnapi/core@npm:1.10.0" @@ -166,6 +179,20 @@ __metadata: languageName: node linkType: hard +"@midnight-ntwrk/compact-js@npm:2.5.1": + version: 2.5.1 + resolution: "@midnight-ntwrk/compact-js@npm:2.5.1" + dependencies: + "@effect/platform": "npm:^0.95.0" + "@midnight-ntwrk/compact-runtime": "npm:0.16.0" + "@midnight-ntwrk/ledger-v8": "npm:^8.0.3" + "@midnight-ntwrk/platform-js": "npm:^2.2.4" + effect: "npm:^3.20.0" + tslib: "npm:^2.8.1" + checksum: 10/ee041b88d8fd43dc63f8cbb6b02f2eb0d6445921633b032e1dd3e909c75be8ca8f311cee70d16a9d06795bbb94172d2a6797799ee3870f29a8aa42e7e3e153b6 + languageName: node + linkType: hard + "@midnight-ntwrk/compact-runtime@npm:0.16.0": version: 0.16.0 resolution: "@midnight-ntwrk/compact-runtime@npm:0.16.0" @@ -177,20 +204,139 @@ __metadata: languageName: node linkType: hard -"@midnight-ntwrk/ledger-v8@npm:8.1.0": +"@midnight-ntwrk/ledger-v8@npm:8.1.0, @midnight-ntwrk/ledger-v8@npm:^8.0.3, @midnight-ntwrk/ledger-v8@npm:^8.1.0": version: 8.1.0 resolution: "@midnight-ntwrk/ledger-v8@npm:8.1.0" checksum: 10/10d56076b0333a502f157c816f8cfebefc8d50221cb20c6db15abcbf2d0092bdaf7e9bc1bd19a6d9f51455547c713c916cb16d4a7d18e83cba0e172ad6e2a507 languageName: node linkType: hard -"@midnight-ntwrk/onchain-runtime-v3@npm:^3.0.0": +"@midnight-ntwrk/midnight-js-contracts@npm:^4.1.0": + version: 4.1.1 + resolution: "@midnight-ntwrk/midnight-js-contracts@npm:4.1.1" + dependencies: + "@midnight-ntwrk/midnight-js-network-id": "npm:4.1.1" + "@midnight-ntwrk/midnight-js-protocol": "npm:4.1.1" + "@midnight-ntwrk/midnight-js-types": "npm:4.1.1" + "@midnight-ntwrk/midnight-js-utils": "npm:4.1.1" + checksum: 10/93791613419dd914cbd4db6c0bc75cebb093962e4162a391d298e2ae705e53c2e87a40fa923fd9a9a6a31cdb91bede0836673beea14d4a594e15da250a1b4feb + languageName: node + linkType: hard + +"@midnight-ntwrk/midnight-js-network-id@npm:4.1.1": + version: 4.1.1 + resolution: "@midnight-ntwrk/midnight-js-network-id@npm:4.1.1" + checksum: 10/ac2f06da0d3bdec6ee83fe84312d8d012b398dad8e23896727c8de10df51eefeeff8eff2de29079c534e090f7cc8520f6e0763a324b560dfe1a0f1d55f2ca2a3 + languageName: node + linkType: hard + +"@midnight-ntwrk/midnight-js-protocol@npm:4.1.1": + version: 4.1.1 + resolution: "@midnight-ntwrk/midnight-js-protocol@npm:4.1.1" + dependencies: + "@midnight-ntwrk/compact-js": "npm:2.5.1" + "@midnight-ntwrk/compact-runtime": "npm:0.16.0" + "@midnight-ntwrk/ledger-v8": "npm:8.1.0" + "@midnight-ntwrk/onchain-runtime-v3": "npm:3.0.0" + "@midnight-ntwrk/platform-js": "npm:2.2.4" + checksum: 10/bfd6195e90b8c0fbc178b32ff9f8223f377d48a55a4a6fdedba424ede77c2234eac75555263b73a61f3ca14d2cc27ed935b3efae15daa6a475a00db4a696e7a1 + languageName: node + linkType: hard + +"@midnight-ntwrk/midnight-js-types@npm:4.1.1, @midnight-ntwrk/midnight-js-types@npm:^4.1.0": + version: 4.1.1 + resolution: "@midnight-ntwrk/midnight-js-types@npm:4.1.1" + dependencies: + "@midnight-ntwrk/midnight-js-protocol": "npm:4.1.1" + effect: "npm:^3.20.0" + pino: "npm:^10.3.1" + rxjs: "npm:^7.8.2" + checksum: 10/a11a994f0838968954f01146c9f5f65f29c8e30fe305401a3f0cef15b6a75802078d2c8c4abe6ef743585a12a05b4d9d30dbe1bb7177699cd2623302fb2b619e + languageName: node + linkType: hard + +"@midnight-ntwrk/midnight-js-utils@npm:4.1.1": + version: 4.1.1 + resolution: "@midnight-ntwrk/midnight-js-utils@npm:4.1.1" + dependencies: + "@midnight-ntwrk/midnight-js-network-id": "npm:4.1.1" + "@midnight-ntwrk/midnight-js-protocol": "npm:4.1.1" + "@midnight-ntwrk/wallet-sdk-address-format": "npm:^3.1.0" + checksum: 10/73f3ab682bd0ea37b988bc34e8036bc03d748b1df534ea57764c7fc656a235ea3287ca55481d57be956455ff4a8334e805c99d87d95c998f1879b5b7743dc18c + languageName: node + linkType: hard + +"@midnight-ntwrk/onchain-runtime-v3@npm:3.0.0, @midnight-ntwrk/onchain-runtime-v3@npm:^3.0.0": version: 3.0.0 resolution: "@midnight-ntwrk/onchain-runtime-v3@npm:3.0.0" checksum: 10/873aeb9e631c3678373c62b5aef847de454de94427028fb3d3f28bfdc8b2c02a3c770bd79d9bfef183eb9db6fb8c23e6826636f2e512ffd6eacbcf7cc0651c5d languageName: node linkType: hard +"@midnight-ntwrk/platform-js@npm:2.2.4, @midnight-ntwrk/platform-js@npm:^2.2.4": + version: 2.2.4 + resolution: "@midnight-ntwrk/platform-js@npm:2.2.4" + dependencies: + "@effect/platform": "npm:^0.95.0" + effect: "npm:^3.20.0" + tslib: "npm:^2.8.1" + checksum: 10/1650bb7e54a64740aaaf27f7e84b7bffdb08611c994bbf54208db43a0a11d10ea8994f05d82e848d60d6fcee8a9b3a5db770d306262b99547e71185d52614825 + languageName: node + linkType: hard + +"@midnight-ntwrk/wallet-sdk-address-format@npm:^3.1.0": + version: 3.1.2 + resolution: "@midnight-ntwrk/wallet-sdk-address-format@npm:3.1.2" + dependencies: + "@midnight-ntwrk/ledger-v8": "npm:^8.1.0" + "@scure/base": "npm:^2.0.0" + "@subsquid/scale-codec": "npm:^4.0.1" + checksum: 10/f3e2374c1dd8e31310aa464fa2afecca3cca92b923a999bfcba2922225b907e5387b94a70e6a8c06e8fb9d51fd9140a08c827c13f8a9191fd18d597cb5ab7b0c + languageName: node + linkType: hard + +"@msgpackr-extract/msgpackr-extract-darwin-arm64@npm:3.0.4": + version: 3.0.4 + resolution: "@msgpackr-extract/msgpackr-extract-darwin-arm64@npm:3.0.4" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"@msgpackr-extract/msgpackr-extract-darwin-x64@npm:3.0.4": + version: 3.0.4 + resolution: "@msgpackr-extract/msgpackr-extract-darwin-x64@npm:3.0.4" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"@msgpackr-extract/msgpackr-extract-linux-arm64@npm:3.0.4": + version: 3.0.4 + resolution: "@msgpackr-extract/msgpackr-extract-linux-arm64@npm:3.0.4" + conditions: os=linux & cpu=arm64 + languageName: node + linkType: hard + +"@msgpackr-extract/msgpackr-extract-linux-arm@npm:3.0.4": + version: 3.0.4 + resolution: "@msgpackr-extract/msgpackr-extract-linux-arm@npm:3.0.4" + conditions: os=linux & cpu=arm + languageName: node + linkType: hard + +"@msgpackr-extract/msgpackr-extract-linux-x64@npm:3.0.4": + version: 3.0.4 + resolution: "@msgpackr-extract/msgpackr-extract-linux-x64@npm:3.0.4" + conditions: os=linux & cpu=x64 + languageName: node + linkType: hard + +"@msgpackr-extract/msgpackr-extract-win32-x64@npm:3.0.4": + version: 3.0.4 + resolution: "@msgpackr-extract/msgpackr-extract-win32-x64@npm:3.0.4" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + "@napi-rs/wasm-runtime@npm:^1.1.4": version: 1.1.4 resolution: "@napi-rs/wasm-runtime@npm:1.1.4" @@ -242,11 +388,21 @@ __metadata: dependencies: "@midnight-ntwrk/compact-runtime": "npm:0.16.0" "@midnight-ntwrk/ledger-v8": "npm:8.1.0" + "@midnight-ntwrk/midnight-js-contracts": "npm:^4.1.0" + "@midnight-ntwrk/midnight-js-types": "npm:^4.1.0" "@tsconfig/node24": "npm:^24.0.3" "@types/node": "npm:25.9.1" fast-check: "npm:^4.5.2" typescript: "npm:^6.0.3" vitest: "npm:^4.1.6" + peerDependencies: + "@midnight-ntwrk/midnight-js-contracts": ^4.1.0 + "@midnight-ntwrk/midnight-js-types": ^4.1.0 + peerDependenciesMeta: + "@midnight-ntwrk/midnight-js-contracts": + optional: true + "@midnight-ntwrk/midnight-js-types": + optional: true languageName: unknown linkType: soft @@ -257,6 +413,13 @@ __metadata: languageName: node linkType: hard +"@pinojs/redact@npm:^0.4.0": + version: 0.4.0 + resolution: "@pinojs/redact@npm:0.4.0" + checksum: 10/2210ffb6b38357853d47239fd0532cc9edb406325270a81c440a35cece22090127c30c2ead3eefa3e608f2244087485308e515c431f4f69b6bd2e16cbd32812b + languageName: node + linkType: hard + "@rolldown/binding-android-arm64@npm:1.0.2": version: 1.0.2 resolution: "@rolldown/binding-android-arm64@npm:1.0.2" @@ -373,13 +536,46 @@ __metadata: languageName: node linkType: hard -"@standard-schema/spec@npm:^1.1.0": +"@scure/base@npm:^2.0.0": + version: 2.2.0 + resolution: "@scure/base@npm:2.2.0" + checksum: 10/b52ec9cd54bad77e22f881b6924ccab692dc1c6dd10287d1787bf263e9f1e560d6d2bda906538fb9a39615d61a1b5c2f53f57a511667fd10e93b9cdaa6fb5d2a + languageName: node + linkType: hard + +"@standard-schema/spec@npm:^1.0.0, @standard-schema/spec@npm:^1.1.0": version: 1.1.0 resolution: "@standard-schema/spec@npm:1.1.0" checksum: 10/a209615c9e8b2ea535d7db0a5f6aa0f962fd4ab73ee86a46c100fb78116964af1f55a27c1794d4801e534a196794223daa25ff5135021e03c7828aa3d95e1763 languageName: node linkType: hard +"@subsquid/scale-codec@npm:^4.0.1": + version: 4.0.1 + resolution: "@subsquid/scale-codec@npm:4.0.1" + dependencies: + "@subsquid/util-internal-hex": "npm:^1.2.2" + "@subsquid/util-internal-json": "npm:^1.2.2" + checksum: 10/d0c81f43c6c93d6885baa0992dd170c94e8259b2eb500694b62b8ca25624c78bb7e4815b1120bbb7f3ed0e7eda02cd02233e1d8b5bac903322731ff3c9fb42bc + languageName: node + linkType: hard + +"@subsquid/util-internal-hex@npm:^1.2.2": + version: 1.2.3 + resolution: "@subsquid/util-internal-hex@npm:1.2.3" + checksum: 10/d3feeb16e130d7a5281bbd98c0ddc9a44d3c49f2655766d4e97d16407c8466b3b246bbefecfb397580f2402dc62b45065c8e62ce986b14935246b1252e66d347 + languageName: node + linkType: hard + +"@subsquid/util-internal-json@npm:^1.2.2": + version: 1.2.3 + resolution: "@subsquid/util-internal-json@npm:1.2.3" + dependencies: + "@subsquid/util-internal-hex": "npm:^1.2.2" + checksum: 10/9a518c8fc56066778b0535ed243024e17f958d9020d99d5444657fd877d7da3adc1f34b3f0e621cb8365729bc9e10aeb63bb24b91e579eb413ef8cbbab66c81d + languageName: node + linkType: hard + "@tsconfig/node10@npm:^1.0.7": version: 1.0.12 resolution: "@tsconfig/node10@npm:1.0.12" @@ -641,6 +837,13 @@ __metadata: languageName: node linkType: hard +"atomic-sleep@npm:^1.0.0": + version: 1.0.0 + resolution: "atomic-sleep@npm:1.0.0" + checksum: 10/3ab6d2cf46b31394b4607e935ec5c1c3c4f60f3e30f0913d35ea74b51b3585e84f590d09e58067f11762eec71c87d25314ce859030983dc0e4397eed21daa12e + languageName: node + linkType: hard + "chai@npm:^6.2.2": version: 6.2.2 resolution: "chai@npm:6.2.2" @@ -705,7 +908,7 @@ __metadata: languageName: node linkType: hard -"detect-libc@npm:^2.0.3": +"detect-libc@npm:^2.0.1, detect-libc@npm:^2.0.3": version: 2.1.2 resolution: "detect-libc@npm:2.1.2" checksum: 10/b736c8d97d5d46164c0d1bed53eb4e6a3b1d8530d460211e2d52f1c552875e706c58a5376854e4e54f8b828c9cada58c855288c968522eb93ac7696d65970766 @@ -719,6 +922,16 @@ __metadata: languageName: node linkType: hard +"effect@npm:^3.20.0": + version: 3.21.4 + resolution: "effect@npm:3.21.4" + dependencies: + "@standard-schema/spec": "npm:^1.0.0" + fast-check: "npm:^3.23.1" + checksum: 10/c95506043e070662af85963af779b3d4e9ec501e03069532888831cc268e0fd9f448dcd2a4c89809e05471e64dcd270313b196056f878d1c028037870694af11 + languageName: node + linkType: hard + "env-paths@npm:^2.2.0": version: 2.2.1 resolution: "env-paths@npm:2.2.1" @@ -756,6 +969,15 @@ __metadata: languageName: node linkType: hard +"fast-check@npm:^3.23.1": + version: 3.23.2 + resolution: "fast-check@npm:3.23.2" + dependencies: + pure-rand: "npm:^6.1.0" + checksum: 10/dab344146b778e8bc2973366ea55528d1b58d3e3037270262b877c54241e800c4d744957722c24705c787020d702aece11e57c9e3dbd5ea19c3e10926bf1f3fe + languageName: node + linkType: hard + "fast-check@npm:^4.5.2": version: 4.8.0 resolution: "fast-check@npm:4.8.0" @@ -777,6 +999,13 @@ __metadata: languageName: node linkType: hard +"find-my-way-ts@npm:^0.1.6": + version: 0.1.6 + resolution: "find-my-way-ts@npm:0.1.6" + checksum: 10/b95bf644011f0d341e5963aa4cac55b2ee59e2435d3f65ae5cf9ee80e52f0fc7db0cee9a55e7420a62a2cec7d8bec7538399dada45e024c05488daa754451bcc + languageName: node + linkType: hard + "fsevents@npm:~2.3.3": version: 2.3.3 resolution: "fsevents@npm:2.3.3" @@ -1000,6 +1229,56 @@ __metadata: languageName: node linkType: hard +"msgpackr-extract@npm:^3.0.2": + version: 3.0.4 + resolution: "msgpackr-extract@npm:3.0.4" + dependencies: + "@msgpackr-extract/msgpackr-extract-darwin-arm64": "npm:3.0.4" + "@msgpackr-extract/msgpackr-extract-darwin-x64": "npm:3.0.4" + "@msgpackr-extract/msgpackr-extract-linux-arm": "npm:3.0.4" + "@msgpackr-extract/msgpackr-extract-linux-arm64": "npm:3.0.4" + "@msgpackr-extract/msgpackr-extract-linux-x64": "npm:3.0.4" + "@msgpackr-extract/msgpackr-extract-win32-x64": "npm:3.0.4" + node-gyp: "npm:latest" + node-gyp-build-optional-packages: "npm:5.2.2" + dependenciesMeta: + "@msgpackr-extract/msgpackr-extract-darwin-arm64": + optional: true + "@msgpackr-extract/msgpackr-extract-darwin-x64": + optional: true + "@msgpackr-extract/msgpackr-extract-linux-arm": + optional: true + "@msgpackr-extract/msgpackr-extract-linux-arm64": + optional: true + "@msgpackr-extract/msgpackr-extract-linux-x64": + optional: true + "@msgpackr-extract/msgpackr-extract-win32-x64": + optional: true + bin: + download-msgpackr-prebuilds: bin/download-prebuilds.js + checksum: 10/05a66482eca3c7932afef4300abc0cccfbb002185506d85d77fd2cff63870d58ef6903fef879ea09ff76ed0c18c9282a0dceb0d621a58e3c02adc9e0bfb8eb33 + languageName: node + linkType: hard + +"msgpackr@npm:^1.11.4": + version: 1.12.1 + resolution: "msgpackr@npm:1.12.1" + dependencies: + msgpackr-extract: "npm:^3.0.2" + dependenciesMeta: + msgpackr-extract: + optional: true + checksum: 10/90f5eabb2b059f441714ec0b09cfccea589caf36604598ae44a023e6c113f97889ca6c899beb318bc1a6bb7d6b4d6bef75a6fc13a01aa49c3111ae08da2c6db5 + languageName: node + linkType: hard + +"multipasta@npm:^0.2.7": + version: 0.2.7 + resolution: "multipasta@npm:0.2.7" + checksum: 10/244a7194ff508b3c5c1724f11c303f1c446cf6142cdbe82e57d5e59c44abb4942b1b983dd8c0d9c63080e684b2a8fa10f511df70d42dbef4d215ed7d41e76fcc + languageName: node + linkType: hard + "nanoid@npm:^3.3.12": version: 3.3.12 resolution: "nanoid@npm:3.3.12" @@ -1009,6 +1288,19 @@ __metadata: languageName: node linkType: hard +"node-gyp-build-optional-packages@npm:5.2.2": + version: 5.2.2 + resolution: "node-gyp-build-optional-packages@npm:5.2.2" + dependencies: + detect-libc: "npm:^2.0.1" + bin: + node-gyp-build-optional-packages: bin.js + node-gyp-build-optional-packages-optional: optional.js + node-gyp-build-optional-packages-test: build-test.js + checksum: 10/f448a328cf608071dc8cc4426ac5be0daec4788e4e1759e9f7ffcd286822cc799384edce17a8c79e610c4bbfc8e3aff788f3681f1d88290e0ca7aaa5342a090f + languageName: node + linkType: hard + "node-gyp@npm:latest": version: 12.3.0 resolution: "node-gyp@npm:12.3.0" @@ -1054,6 +1346,13 @@ __metadata: languageName: node linkType: hard +"on-exit-leak-free@npm:^2.1.0": + version: 2.1.2 + resolution: "on-exit-leak-free@npm:2.1.2" + checksum: 10/f7b4b7200026a08f6e4a17ba6d72e6c5cbb41789ed9cf7deaf9d9e322872c7dc5a7898549a894651ee0ee9ae635d34a678115bf8acdfba8ebd2ba2af688b563c + languageName: node + linkType: hard + "onetime@npm:^7.0.0": version: 7.0.0 resolution: "onetime@npm:7.0.0" @@ -1100,6 +1399,43 @@ __metadata: languageName: node linkType: hard +"pino-abstract-transport@npm:^3.0.0": + version: 3.0.0 + resolution: "pino-abstract-transport@npm:3.0.0" + dependencies: + split2: "npm:^4.0.0" + checksum: 10/f42b85b2663c8520839124a55b27801e88c89c65e9569384b49bb4c81b022ae24860020c2375b92a03db699113969007cc155e1fb2dfe53754403920c1cbe18c + languageName: node + linkType: hard + +"pino-std-serializers@npm:^7.0.0": + version: 7.1.0 + resolution: "pino-std-serializers@npm:7.1.0" + checksum: 10/6e27f6f885927b6df3b424ddb8a9e0e9854f3b59f4abd51afa74e1c2cf33436a505277b004bb00ce61884a962c8fdfd977391205c7baab885d6afb35fce7396a + languageName: node + linkType: hard + +"pino@npm:^10.3.1": + version: 10.3.1 + resolution: "pino@npm:10.3.1" + dependencies: + "@pinojs/redact": "npm:^0.4.0" + atomic-sleep: "npm:^1.0.0" + on-exit-leak-free: "npm:^2.1.0" + pino-abstract-transport: "npm:^3.0.0" + pino-std-serializers: "npm:^7.0.0" + process-warning: "npm:^5.0.0" + quick-format-unescaped: "npm:^4.0.3" + real-require: "npm:^0.2.0" + safe-stable-stringify: "npm:^2.3.1" + sonic-boom: "npm:^4.0.1" + thread-stream: "npm:^4.0.0" + bin: + pino: bin.js + checksum: 10/46cad7bf1859c83a8a9c43af764e5165f44057ce76d44b1b2b4390f2abccb8a579f42abfe742d88b4d8e1d339213afb46ea50fc39c50095dd1f0f9fe26ea1342 + languageName: node + linkType: hard + "postcss@npm:^8.5.15": version: 8.5.15 resolution: "postcss@npm:8.5.15" @@ -1118,6 +1454,20 @@ __metadata: languageName: node linkType: hard +"process-warning@npm:^5.0.0": + version: 5.0.0 + resolution: "process-warning@npm:5.0.0" + checksum: 10/10f3e00ac9fc1943ec4566ff41fff2b964e660f853c283e622257719839d340b4616e707d62a02d6aa0038761bb1fa7c56bc7308d602d51bd96f05f9cd305dcd + languageName: node + linkType: hard + +"pure-rand@npm:^6.1.0": + version: 6.1.0 + resolution: "pure-rand@npm:6.1.0" + checksum: 10/256aa4bcaf9297256f552914e03cbdb0039c8fe1db11fa1e6d3f80790e16e563eb0a859a1e61082a95e224fc0c608661839439f8ecc6a3db4e48d46d99216ee4 + languageName: node + linkType: hard + "pure-rand@npm:^8.0.0": version: 8.4.0 resolution: "pure-rand@npm:8.4.0" @@ -1125,6 +1475,27 @@ __metadata: languageName: node linkType: hard +"quick-format-unescaped@npm:^4.0.3": + version: 4.0.4 + resolution: "quick-format-unescaped@npm:4.0.4" + checksum: 10/591eca457509a99368b623db05248c1193aa3cedafc9a077d7acab09495db1231017ba3ad1b5386e5633271edd0a03b312d8640a59ee585b8516a42e15438aa7 + languageName: node + linkType: hard + +"real-require@npm:^0.2.0": + version: 0.2.0 + resolution: "real-require@npm:0.2.0" + checksum: 10/ddf44ee76301c774e9c9f2826da8a3c5c9f8fc87310f4a364e803ef003aa1a43c378b4323051ced212097fff1af459070f4499338b36a7469df1d4f7e8c0ba4c + languageName: node + linkType: hard + +"real-require@npm:^1.0.0": + version: 1.0.0 + resolution: "real-require@npm:1.0.0" + checksum: 10/ac2ae7681e20c92be45a5f49110414af1576c7b4512869c2260076a69fc7c336335ef354f466a3be92e779c55b8df0b0043d191797d82d7f18e6310958e5a890 + languageName: node + linkType: hard + "restore-cursor@npm:^5.0.0": version: 5.1.0 resolution: "restore-cursor@npm:5.1.0" @@ -1193,6 +1564,22 @@ __metadata: languageName: node linkType: hard +"rxjs@npm:^7.8.2": + version: 7.8.2 + resolution: "rxjs@npm:7.8.2" + dependencies: + tslib: "npm:^2.1.0" + checksum: 10/03dff09191356b2b87d94fbc1e97c4e9eb3c09d4452399dddd451b09c2f1ba8d56925a40af114282d7bc0c6fe7514a2236ca09f903cf70e4bbf156650dddb49d + languageName: node + linkType: hard + +"safe-stable-stringify@npm:^2.3.1": + version: 2.5.0 + resolution: "safe-stable-stringify@npm:2.5.0" + checksum: 10/2697fa186c17c38c3ca5309637b4ac6de2f1c3d282da27cd5e1e3c88eca0fb1f9aea568a6aabdf284111592c8782b94ee07176f17126031be72ab1313ed46c5c + languageName: node + linkType: hard + "semver@npm:^7.3.5": version: 7.8.0 resolution: "semver@npm:7.8.0" @@ -1223,6 +1610,15 @@ __metadata: languageName: node linkType: hard +"sonic-boom@npm:^4.0.1": + version: 4.2.1 + resolution: "sonic-boom@npm:4.2.1" + dependencies: + atomic-sleep: "npm:^1.0.0" + checksum: 10/161af46b3e6debc4ad3865b0db47f37289741a0b3005b8cf056f93a4e0e1a347e24ca1a2d8ccc864f7f19caa6185a766797f8382cdbfd2f3d046a0323d73a542 + languageName: node + linkType: hard + "source-map-js@npm:^1.2.1": version: 1.2.1 resolution: "source-map-js@npm:1.2.1" @@ -1230,6 +1626,13 @@ __metadata: languageName: node linkType: hard +"split2@npm:^4.0.0": + version: 4.2.0 + resolution: "split2@npm:4.2.0" + checksum: 10/09bbefc11bcf03f044584c9764cd31a252d8e52cea29130950b26161287c11f519807c5e54bd9e5804c713b79c02cefe6a98f4688630993386be353e03f534ab + languageName: node + linkType: hard + "stackback@npm:0.0.2": version: 0.0.2 resolution: "stackback@npm:0.0.2" @@ -1283,6 +1686,15 @@ __metadata: languageName: node linkType: hard +"thread-stream@npm:^4.0.0": + version: 4.2.0 + resolution: "thread-stream@npm:4.2.0" + dependencies: + real-require: "npm:^1.0.0" + checksum: 10/040d1f5284806a28d12ac83fc0a1f0b32547c6773b5fab9494c664798fa7fb14197bede695ec61221c5f93f64a32b0f1850408d0ad5730af03ee1d1efe6c1b05 + languageName: node + linkType: hard + "tinybench@npm:^2.9.0": version: 2.9.0 resolution: "tinybench@npm:2.9.0" @@ -1352,7 +1764,7 @@ __metadata: languageName: node linkType: hard -"tslib@npm:^2.4.0": +"tslib@npm:^2.1.0, tslib@npm:^2.4.0, tslib@npm:^2.8.1": version: 2.8.1 resolution: "tslib@npm:2.8.1" checksum: 10/3e2e043d5c2316461cb54e5c7fe02c30ef6dccb3384717ca22ae5c6b5bc95232a6241df19c622d9c73b809bea33b187f6dbc73030963e29950c2141bc32a79f7 From beb40eefdcde0123b0b89166829e82c847232bc7 Mon Sep 17 00:00:00 2001 From: 0xisk <0xisk@proton.me> Date: Tue, 23 Jun 2026 18:18:14 +0200 Subject: [PATCH 3/4] refactor(simulator): drop INV- invariant cross-refs from source Remove the INV-N citation tags that referenced the live-backend invariants design doc from all simulator source and test comments, reflowing each sentence so it reads naturally on its own. Non-INV design references (D1, D2, OQ2/4/6) are left intact. Also normalize a stray NUL byte in createLiveContext.ts to its unicode escape sequence, so the file is valid UTF-8 text that git diffs normally instead of being treated as binary. The runtime string value is unchanged. --- packages/simulator/src/backend/Backend.ts | 32 ++++++------- packages/simulator/src/backend/DryBackend.ts | 10 ++-- .../src/factory/createDrySimulator.ts | 2 +- .../simulator/src/factory/createSimulator.ts | 44 +++++++++--------- packages/simulator/src/index.ts | 4 +- packages/simulator/src/live/LiveBackend.ts | 44 +++++++++--------- packages/simulator/src/live/LiveContext.ts | 18 +++---- .../simulator/src/live/createLiveContext.ts | Bin 6651 -> 6551 bytes packages/simulator/src/live/registry.ts | 4 +- packages/simulator/src/signers/Signers.ts | 16 +++---- packages/simulator/src/types/Circuit.ts | 4 +- packages/simulator/src/types/Options.ts | 6 +-- .../test/unit/LiveBackendAdapter.test.ts | 16 +++---- packages/simulator/test/unit/Signers.test.ts | 4 +- .../test/unit/dependency-wall.test.ts | 8 ++-- 15 files changed, 106 insertions(+), 106 deletions(-) diff --git a/packages/simulator/src/backend/Backend.ts b/packages/simulator/src/backend/Backend.ts index 1d201b7..869c3e6 100644 --- a/packages/simulator/src/backend/Backend.ts +++ b/packages/simulator/src/backend/Backend.ts @@ -3,8 +3,8 @@ import type { StateValue } from '@midnight-ntwrk/compact-runtime'; /** * Identifies which execution backend a simulator is bound to. * - * Resolved once at construction time and fixed for the simulator's lifetime - * (INV-8). There is no runtime toggle: a `'dry'` simulator never becomes + * Resolved once at construction time and fixed for the simulator's lifetime. + * There is no runtime toggle: a `'dry'` simulator never becomes * `'live'` or vice versa. */ export type BackendKind = 'dry' | 'live'; @@ -13,7 +13,7 @@ export type BackendKind = 'dry' | 'live'; * Whether a circuit runs locally on the JS artifact (`'pure'`) or, in live mode, * is submitted as a transaction to the node (`'impure'`). * - * Locality follows the pure/impure distinction, NOT read/write (D2, INV-16): + * Locality follows the pure/impure distinction, NOT read/write (D2): * a read implemented as an impure circuit (e.g. `owner()`) still hits the node * in live mode. */ @@ -25,15 +25,15 @@ export type CircuitKind = 'pure' | 'impure'; * * `createBackendSimulator` builds the async circuit proxies, caller helpers, and * state getters on top of this interface; the backend itself stays dumb. Every - * operation is async so spec code is uniform `await` across both backends - * (INV-4): {@link DryBackend} wraps its synchronous results in `Promise.resolve`, + * operation is async so spec code is uniform `await` across both backends: + * {@link DryBackend} wraps its synchronous results in `Promise.resolve`, * the live adapter awaits the network. * * @template P - Private state type. * @template L - Public ledger state type. */ export interface Backend { - /** The backend this instance is bound to (INV-8). */ + /** The backend this instance is bound to. */ readonly kind: BackendKind; /** The deployed contract's address. */ @@ -41,9 +41,9 @@ export interface Backend { /** * Invokes a circuit. Pure circuits run locally on the JS artifact in both - * modes; impure circuits run locally in dry and submit a tx in live (D2, - * INV-16). The live adapter normalizes the result to the bare `R` that dry - * returns (INV-13), so an assertion on the return value is identical across + * modes; impure circuits run locally in dry and submit a tx in live (D2). + * The live adapter normalizes the result to the bare `R` that dry + * returns, so an assertion on the return value is identical across * backends. * * @param kind - Whether the circuit is pure or impure. @@ -55,13 +55,13 @@ export interface Backend { /** * Extracts the public ledger state. Both backends apply the same - * `ledgerExtractor` (INV-15) — over the in-memory context in dry, over the + * `ledgerExtractor` — over the in-memory context in dry, over the * indexer-sourced state in live. */ getPublicState(): Promise; /** - * Reads the private state `P`. Read parity holds across backends (INV-18); + * Reads the private state `P`. Read parity holds across backends; * mutation parity does not (see {@link overrideWitness} and the private-state * mutation asymmetry documented on the live adapter). */ @@ -73,8 +73,8 @@ export interface Backend { /** * Replaces the private state. Dry mutates the in-memory context (used by * per-module helpers like secret/nonce injection); live throws, because - * mid-test private-state mutation is the documented dry↔live asymmetry - * (INV-18). Guard such specs with `isLiveBackend()`. + * mid-test private-state mutation is the documented dry↔live asymmetry. + * Guard such specs with `isLiveBackend()`. * * @param privateState - The new private state `P`. */ @@ -83,7 +83,7 @@ export interface Backend { /** * Sets the caller identity for subsequent circuit calls. * - * The mode lifecycle matches across backends (INV-17): `'single'` applies the + * The mode lifecycle matches across backends: `'single'` applies the * caller to the next call then reverts to the default signer; `'persistent'` * keeps it until changed. `null` clears the override (default signer). * @@ -97,7 +97,7 @@ export interface Backend { * * Dry recreates the contract with the new witness; the live adapter throws * `"witness override unsupported on live backend"` because witnesses bind at - * deploy and cannot be swapped mid-test (INV-7). + * deploy and cannot be swapped mid-test. * * @param key - The witness key to override. * @param fn - The new witness implementation. @@ -106,7 +106,7 @@ export interface Backend { /** * Replaces the whole witness set. Dry recreates the contract; the live adapter - * throws the same INV-7 message as {@link overrideWitness}. + * throws the same message as {@link overrideWitness}. * * @param witnesses - The new witness set. */ diff --git a/packages/simulator/src/backend/DryBackend.ts b/packages/simulator/src/backend/DryBackend.ts index 8feb242..772758e 100644 --- a/packages/simulator/src/backend/DryBackend.ts +++ b/packages/simulator/src/backend/DryBackend.ts @@ -34,10 +34,10 @@ export interface SyncSimulator { * `createSimulator` instance. * * Every operation delegates to the wrapped simulator and wraps the synchronous - * result in a resolved promise (INV-4), so a circuit never returns a bare value + * result in a resolved promise, so a circuit never returns a bare value * on dry but a `Promise` on live. Because all real work routes through the - * unchanged synchronous path, dry behavior is preserved byte-for-byte (INV-19) - * and is the parity reference the live backend is measured against (INV-12). + * unchanged synchronous path, dry behavior is preserved byte-for-byte + * and is the parity reference the live backend is measured against. * * @template P - Private state type. * @template L - Public ledger state type. @@ -92,7 +92,7 @@ export class DryBackend implements Backend { return this.sim.getContractState(); } - /** Mutates the in-memory private state (INV-18: dry supports mid-test mutation). */ + /** Mutates the in-memory private state (dry supports mid-test mutation). */ setPrivateState(privateState: P): void { this.sim.circuitContextManager.updatePrivateState(privateState); } @@ -101,7 +101,7 @@ export class DryBackend implements Backend { * Resolves the alias to a deterministic key and applies it to the wrapped * simulator's override fields. `'single'` uses `callerOverride` (the existing * proxy auto-resets it after one call); `'persistent'` uses - * `persistentCallerOverride` (INV-17). + * `persistentCallerOverride`. */ setCaller(alias: string | null, mode: 'single' | 'persistent'): void { const key = alias === null ? null : this.signers.resolveDryKey(alias); diff --git a/packages/simulator/src/factory/createDrySimulator.ts b/packages/simulator/src/factory/createDrySimulator.ts index f0c7a39..5e65dd5 100644 --- a/packages/simulator/src/factory/createDrySimulator.ts +++ b/packages/simulator/src/factory/createDrySimulator.ts @@ -16,7 +16,7 @@ import type { SimulatorConfig } from './SimulatorConfig.js'; * * This is the in-memory engine the public async {@link createSimulator} builds on: * the dry backend wraps an instance of this class, and the live backend uses one - * locally to evaluate pure circuits (INV-16). It is not the public testing API — + * locally to evaluate pure circuits. It is not the public testing API — * use {@link createSimulator} instead. * * Creates a class extending ContractSimulator with witness management, state diff --git a/packages/simulator/src/factory/createSimulator.ts b/packages/simulator/src/factory/createSimulator.ts index d4f286f..379408b 100644 --- a/packages/simulator/src/factory/createSimulator.ts +++ b/packages/simulator/src/factory/createSimulator.ts @@ -24,7 +24,7 @@ interface BackendDeps { /** * Resolves the backend kind once: an explicit override wins, otherwise * `MIDNIGHT_BACKEND=live` selects live and anything else (unset or `dry`) - * selects dry (INV-8). + * selects dry. */ const resolveBackendKind = (override?: BackendKind): BackendKind => override ?? (process.env.MIDNIGHT_BACKEND === 'live' ? 'live' : 'dry'); @@ -34,16 +34,16 @@ const resolveBackendKind = (override?: BackendKind): BackendKind => * * One factory, two backends: the produced class runs against the in-memory path * ({@link DryBackend}) or a live Midnight node (`LiveBackend`), selected by - * `MIDNIGHT_BACKEND=dry|live` at construction (INV-8). `create` is async and + * `MIDNIGHT_BACKEND=dry|live` at construction. `create` is async and * circuits return promises ({@link AsyncCircuits}) so a single spec file runs on - * both backends with uniform `await` (INV-4). + * both backends with uniform `await`. * - * The live adapter is reached only through a runtime dynamic import (INV-1, - * INV-2): a static `import { createSimulator }` never pulls midnight-js into the + * The live adapter is reached only through a runtime dynamic import: a static + * `import { createSimulator }` never pulls midnight-js into the * dependency graph. In live mode the {@link LiveContext} comes from `options.live` * or the globally registered live backend (`registerLiveBackend`). * - * @param config - The shared simulator configuration (same shape both backends, INV-5). + * @param config - The shared simulator configuration (same shape both backends). * @returns A class to extend with per-circuit delegating methods. */ export function createSimulator< @@ -54,7 +54,7 @@ export function createSimulator< TArgs extends readonly any[] = readonly any[], >(config: SimulatorConfig) { // Built once per factory; instances per `create()`. The synchronous primitive - // is the whole dry path and the local JS artifact for pure-circuit eval (INV-16). + // is the whole dry path and the local JS artifact for pure-circuit eval. const DrySimClass = createDrySimulator(config); /** @@ -82,7 +82,7 @@ export function createSimulator< // The local synchronous simulator: the whole dry path, and the pure-circuit // evaluator in live (D2). In live this runs `initialState` in memory only — - // it is never deployed on-chain (INV-10). + // it is never deployed on-chain. const localSim = new DrySimClass(contractArgs, options); const contract = localSim.contract; const impureNames = Object.keys(contract.impureCircuits); @@ -114,11 +114,11 @@ export function createSimulator< throw new Error( 'live backend selected (MIDNIGHT_BACKEND=live) but no LiveContext available. ' + 'Pass `{ live }` to create(), or call registerLiveBackend(...) in your ' + - 'test:live setup. The harness owns deploy/providers/wallets (INV-22).', + 'test:live setup. The harness owns deploy/providers/wallets.', ); } - // INV-1/INV-2: the live adapter value is reached only via dynamic import, + // The live adapter value is reached only via dynamic import, // so a dry import never statically links it (and any future heavy deps). const { LiveBackend } = await import('../live/LiveBackend.js'); const backend = new LiveBackend({ @@ -139,7 +139,7 @@ export function createSimulator< }; return class Simulator { - /** The backend this instance resolved to at construction (INV-8). */ + /** The backend this instance resolved to at construction. */ readonly backendKind: BackendKind; // Public (underscore-prefixed) to satisfy declaration emit for the returned @@ -147,7 +147,7 @@ export function createSimulator< readonly _backend: Backend; readonly _signers: Signers; - /** Async circuit proxies; every call returns a promise (INV-4). */ + /** Async circuit proxies; every call returns a promise. */ readonly circuits: { pure: AsyncCircuits, P>; impure: AsyncCircuits, P>; @@ -180,7 +180,7 @@ export function createSimulator< /** * Constructs a simulator. In dry, deploys from `contractArgs` to fresh * in-memory state. In live, the caller already deployed; the args seed only - * the local pure-eval context, never an on-chain deploy (INV-10). + * the local pure-eval context, never an on-chain deploy. * * @param contractArgs - Constructor args for the contract. * @param options - Backend selection, witnesses, private state, live world. @@ -203,7 +203,7 @@ export function createSimulator< } /** - * Sets the caller for the next call only, then reverts (INV-17). + * Sets the caller for the next call only, then reverts. * * @param alias - The caller alias, or `null` for the default signer. * @returns This instance, for chaining (`sim.as('OWNER').transfer(...)`). @@ -214,7 +214,7 @@ export function createSimulator< } /** - * Sets a persistent caller for all subsequent calls until changed (INV-17). + * Sets a persistent caller for all subsequent calls until changed. * * @param alias - The caller alias, or `null` to clear. * @returns This instance, for chaining. @@ -230,19 +230,19 @@ export function createSimulator< return this; } - /** The public ledger state, via the shared extractor (INV-15). */ + /** The public ledger state, via the shared extractor. */ getPublicState(): Promise { return this._backend.getPublicState(); } - /** The private state (read parity across backends, INV-18). */ + /** The private state (read parity across backends). */ getPrivateState(): Promise

{ return this._backend.getPrivateState(); } /** * Replaces the private state (for per-module secret/nonce injection helpers). - * Dry mutates the in-memory context; live throws (INV-18 mutation asymmetry) — + * Dry mutates the in-memory context; live throws (mutation asymmetry) — * guard such specs with `isLiveBackend()`. * * @param privateState - The new private state. @@ -262,15 +262,15 @@ export function createSimulator< } /** - * Replaces the whole witness set. Dry recreates the contract; live throws - * (INV-7). Equivalent to {@link setWitnesses}; kept for API compatibility. + * Replaces the whole witness set. Dry recreates the contract; live throws. + * Equivalent to {@link setWitnesses}; kept for API compatibility. */ set witnesses(newWitnesses: W) { this._backend.setWitnesses(newWitnesses); } /** - * Overrides a single witness. Dry recreates the contract; live throws (INV-7). + * Overrides a single witness. Dry recreates the contract; live throws. * * @param key - The witness key. * @param fn - The replacement implementation. @@ -280,7 +280,7 @@ export function createSimulator< } /** - * Replaces the whole witness set. Dry recreates the contract; live throws (INV-7). + * Replaces the whole witness set. Dry recreates the contract; live throws. * * @param witnesses - The new witness set. */ diff --git a/packages/simulator/src/index.ts b/packages/simulator/src/index.ts index b9e0642..8f02c5f 100644 --- a/packages/simulator/src/index.ts +++ b/packages/simulator/src/index.ts @@ -8,9 +8,9 @@ export { AbstractSimulator } from './core/AbstractSimulator.js'; export { CircuitContextManager } from './core/CircuitContextManager.js'; export { ContractSimulator } from './core/ContractSimulator.js'; // --- Core simulator (one factory, two backends) ---------------------------- -// A dry import pulls zero midnight-js (INV-2): the live adapter `LiveBackend` +// A dry import pulls zero midnight-js: the live adapter `LiveBackend` // is type-only here and reached at runtime only via the dynamic import inside -// `createSimulator` (INV-1). `createLiveContext`/`registerLiveBackend` are +// `createSimulator`. `createLiveContext`/`registerLiveBackend` are // values, but their static graph is midnight-js-free (type-only + dynamic // import), so exporting them from the main barrel keeps the wall up. export { createSimulator } from './factory/createSimulator.js'; diff --git a/packages/simulator/src/live/LiveBackend.ts b/packages/simulator/src/live/LiveBackend.ts index 8ab5e69..7014391 100644 --- a/packages/simulator/src/live/LiveBackend.ts +++ b/packages/simulator/src/live/LiveBackend.ts @@ -4,11 +4,11 @@ import type { SyncSimulator } from '../backend/DryBackend.js'; import type { Signers } from '../signers/Signers.js'; import type { LiveContext } from './LiveContext.js'; -/** The error thrown when witnesses are swapped on the live backend (INV-7). */ +/** The error thrown when witnesses are swapped on the live backend. */ export const WITNESS_OVERRIDE_UNSUPPORTED = 'witness override unsupported on live backend'; -/** The error thrown when private state is mutated on the live backend (INV-18). */ +/** The error thrown when private state is mutated on the live backend. */ export const PRIVATE_STATE_MUTATION_UNSUPPORTED = 'private-state mutation unsupported on live backend'; @@ -22,24 +22,24 @@ export interface LiveBackendDeps { /** The caller-supplied live world (handles + readers). */ ctx: LiveContext

; /** - * A local in-memory simulator used solely to evaluate pure circuits (D2, - * INV-16). Never deployed on-chain (INV-10); pure circuits are state- and + * A local in-memory simulator used solely to evaluate pure circuits (D2). + * Never deployed on-chain; pure circuits are state- and * caller-independent, so its seed state does not affect results. */ pureSim: SyncSimulator; - /** Alias resolver, used here for the live signer cap (INV-21). */ + /** Alias resolver, used here for the live signer cap. */ signers: Signers; - /** The shared ledger extractor, applied to indexer state for parity (INV-15). */ + /** The shared ledger extractor, applied to indexer state for parity. */ ledgerExtractor: (state: StateValue) => L; } /** * The live backend: a thin adapter that routes operations to an injected - * {@link LiveContext} and normalizes results to match dry (INV-12, INV-13). + * {@link LiveContext} and normalizes results to match dry. * * It imports no midnight-js — all node wiring lives behind the - * {@link LiveContext} seam (INV-22). Routing follows pure/impure, not read/write - * (D2, INV-16): pure circuits run locally on the JS artifact, impure circuits + * {@link LiveContext} seam. Routing follows pure/impure, not read/write + * (D2): pure circuits run locally on the JS artifact, impure circuits * submit a tx. * * @template P - Private state type. @@ -53,9 +53,9 @@ export class LiveBackend implements Backend { private readonly signers: Signers; private readonly ledgerExtractor: (state: StateValue) => L; - /** Caller active for all subsequent calls until changed (INV-17). */ + /** Caller active for all subsequent calls until changed. */ private persistentAlias: string | null = null; - /** Caller active for the next call only, then reverts (INV-17). */ + /** Caller active for the next call only, then reverts. */ private singleAlias: string | null = null; private hasSingle = false; @@ -73,7 +73,7 @@ export class LiveBackend implements Backend { /** * Pure circuits evaluate locally on the JS artifact (no tx); impure circuits * submit a tx via the per-alias handle and the result is normalized from - * `FinalizedCallTxData.private.result` to the bare `R` dry returns (INV-13). + * `FinalizedCallTxData.private.result` to the bare `R` dry returns. */ async call( kind: CircuitKind, @@ -96,20 +96,20 @@ export class LiveBackend implements Backend { if (typeof txFn !== 'function') { throw new Error(`unknown impure circuit "${name}"`); } - // INV-13: unwrap callTx's { public, private: { result } } to the bare R. - // INV-14: the assert message inside any rejection is preserved verbatim — + // Unwrap callTx's { public, private: { result } } to the bare R. + // The assert message inside any rejection is preserved verbatim — // we await directly and never catch/rewrite it. const finalized = await txFn(...args); this.consumeSingle(); return finalized.private.result; } - /** INV-15: same extractor as dry, applied to indexer-sourced state. */ + /** Same extractor as dry, applied to indexer-sourced state. */ async getPublicState(): Promise { return this.ledgerExtractor(await this.ctx.queryLedger()); } - /** INV-18: read parity via the private-state provider. */ + /** Read parity via the private-state provider. */ async getPrivateState(): Promise

{ return this.ctx.queryPrivateState(); } @@ -119,8 +119,8 @@ export class LiveBackend implements Backend { } /** - * Mid-test private-state mutation does not faithfully reproduce on live - * (INV-18). Throws so such specs are explicitly guarded with `isLiveBackend()` + * Mid-test private-state mutation does not faithfully reproduce on live. + * Throws so such specs are explicitly guarded with `isLiveBackend()` * rather than silently passing against unchanged state. */ setPrivateState(_privateState: P): void { @@ -128,9 +128,9 @@ export class LiveBackend implements Backend { } /** - * Validates the alias against the prefunded pool (INV-21) and records it. + * Validates the alias against the prefunded pool and records it. * `'single'` applies to the next call then reverts; `'persistent'` holds until - * changed (INV-17). The lifecycle mirrors dry's `callerOverride` / + * changed. The lifecycle mirrors dry's `callerOverride` / * `persistentCallerOverride`. */ setCaller(alias: string | null, mode: 'single' | 'persistent'): void { @@ -143,12 +143,12 @@ export class LiveBackend implements Backend { } } - /** Witnesses bind at deploy and cannot be swapped mid-test (INV-7). */ + /** Witnesses bind at deploy and cannot be swapped mid-test. */ overrideWitness(_key: PropertyKey, _fn: unknown): void { throw new Error(WITNESS_OVERRIDE_UNSUPPORTED); } - /** Witnesses bind at deploy and cannot be swapped mid-test (INV-7). */ + /** Witnesses bind at deploy and cannot be swapped mid-test. */ setWitnesses(_witnesses: unknown): void { throw new Error(WITNESS_OVERRIDE_UNSUPPORTED); } diff --git a/packages/simulator/src/live/LiveContext.ts b/packages/simulator/src/live/LiveContext.ts index 2d4187e..c16e45c 100644 --- a/packages/simulator/src/live/LiveContext.ts +++ b/packages/simulator/src/live/LiveContext.ts @@ -5,7 +5,7 @@ import type { StateValue } from '@midnight-ntwrk/compact-runtime'; * * Pinned against `@midnight-ntwrk/midnight-js-contracts`: the default * `handle.callTx[name](...args)` resolves to a `FinalizedCallTxData` whose - * circuit return value lives at `.private.result` (INV-13). A harness's + * circuit return value lives at `.private.result`. A harness's * `FoundContract['callTx']` is structurally assignable to this. */ export interface FinalizedCallResult { @@ -16,8 +16,8 @@ export interface FinalizedCallResult { * The minimal slice of a deployed-contract handle the live adapter needs: * a `callTx` map from circuit name to an async tx submission. * - * Kept structural so the package's runtime graph never imports midnight-js - * (INV-2). A harness's `FoundContract` satisfies this without any cast. + * Kept structural so the package's runtime graph never imports midnight-js. + * A harness's `FoundContract` satisfies this without any cast. */ export interface DeployedTxHandle { readonly callTx: Record< @@ -30,7 +30,7 @@ export interface DeployedTxHandle { * The injection seam between the package and a live Midnight node. * * Defined by the package, implemented by the caller's harness, which owns all - * live infra (deploy, providers, wallet pool) per INV-22. The package's adapter + * live infra (deploy, providers, wallet pool). The package's adapter * ({@link LiveBackend}) is a pure consumer of this interface and imports no * midnight-js itself. * @@ -42,7 +42,7 @@ export interface DeployedTxHandle { * @template P - Private state type. */ export interface LiveContext

{ - /** The address of the contract the harness already deployed (INV-10). */ + /** The address of the contract the harness already deployed. */ readonly contractAddress: string; /** @@ -56,14 +56,14 @@ export interface LiveContext

{ /** * Reads the current public contract state from the indexer as a `StateValue`, - * ready to feed the shared `ledgerExtractor` (INV-15). Implementations should - * absorb bounded indexer lag so read-after-write is stable (INV-11). + * ready to feed the shared `ledgerExtractor`. Implementations should + * absorb bounded indexer lag so read-after-write is stable. */ queryLedger(): Promise; /** - * Reads the contract's private state from the private-state provider (INV-18, - * read parity). + * Reads the contract's private state from the private-state provider (read + * parity). */ queryPrivateState(): Promise

; } diff --git a/packages/simulator/src/live/createLiveContext.ts b/packages/simulator/src/live/createLiveContext.ts index 79876844bac52a803c796dfb89af64f43ec4479b..1132dbfdf76ac44d52d1d40e95ebb0f3241427e5 100644 GIT binary patch delta 114 zcmexuJl%N0+>J|%8CmqW6tp(0GW}xOtij60$f~EHrO!3FQCN7hKihxC$>AK4lXq}b zZ?@sQ!?;<2`z*7JjzVHyibA47K~ZK|Vo9oQaS4!AC`wICNiEte&A)(&EvD1}2sZB# RJjS%STjVP9=1hr6i~wikCN2N~ delta 190 zcmbPk{M&fKTn+^dProo-Bh8I-iy3*j6tp0GLqkoy%_2;{m^h*GMw*-XSlJk%TtfrR z%_3~#Fj+%W&B@vvksKhC3@vmNCbw`@!&Dk-ZvM}4hY@Ozndau(TxXdPl9N~P$-sCP gnwyXCEns3~*qkMJ3~n~i00WV$NLn_tN=#w|01i+vY5)KL diff --git a/packages/simulator/src/live/registry.ts b/packages/simulator/src/live/registry.ts index 8893ca9..c6c4ee3 100644 --- a/packages/simulator/src/live/registry.ts +++ b/packages/simulator/src/live/registry.ts @@ -5,8 +5,8 @@ import type { LiveContext } from './LiveContext.js'; /** * What the registered live backend receives in order to deploy/attach the right - * contract and return a {@link LiveContext} for it. The harness owns all infra - * (INV-22); this just hands it the same config + args the test used. + * contract and return a {@link LiveContext} for it. The harness owns all infra; + * this just hands it the same config + args the test used. */ export interface LiveBackendRequest< P = unknown, diff --git a/packages/simulator/src/signers/Signers.ts b/packages/simulator/src/signers/Signers.ts index 6d273c2..989be07 100644 --- a/packages/simulator/src/signers/Signers.ts +++ b/packages/simulator/src/signers/Signers.ts @@ -7,7 +7,7 @@ import type { BackendKind } from '../backend/Backend.js'; /** * The number of prefunded wallets available on the dev-preset live node: - * the deployer plus three named aliases (D1, INV-21). Requesting more requires + * the deployer plus three named aliases (D1). Requesting more requires * the deferred derive-and-fund flow. */ export const MAX_LIVE_SIGNERS = 4; @@ -31,7 +31,7 @@ export type Either = { * This is the exact derivation the existing test harness uses * (`generatePubKeyPair` / `encodeToPK`), so a backend-aware simulator resolves * an alias to the same key the current synchronous specs do — preserving dry - * parity for migrated modules (INV-17). + * parity for migrated modules. * * @param alias - The caller alias. * @returns A 64-char hex `CoinPublicKey`. @@ -55,13 +55,13 @@ export interface SignersOptions { dryKeys?: Readonly>; /** * Live only: the aliases backed by a prefunded wallet on the node. Capped at - * {@link MAX_LIVE_SIGNERS} (INV-21). Requesting an alias outside this set + * {@link MAX_LIVE_SIGNERS}. Requesting an alias outside this set * fails with a clear error rather than silently reusing a wallet. */ liveAliases?: readonly string[]; /** * Live only: resolve an alias to its wallet's coin public key. Supplied by the - * caller's harness, which owns wallet provisioning (INV-22). + * caller's harness, which owns wallet provisioning. */ resolveLiveKey?: (alias: string) => CoinPublicKey | Promise; } @@ -70,9 +70,9 @@ export interface SignersOptions { * Resolves caller-identity aliases to keys, uniformly across backends. * * Alias strings are the common currency for caller identity (D1): `as('OWNER')` - * denotes the same logical actor in both modes (INV-17). Dry derives a + * denotes the same logical actor in both modes. Dry derives a * deterministic key from the alias label; live resolves the alias to a pooled, - * prefunded wallet, enforcing the {@link MAX_LIVE_SIGNERS} cap (INV-21). + * prefunded wallet, enforcing the {@link MAX_LIVE_SIGNERS} cap. * * The public resolvers ({@link keyFor}, {@link eitherFor}) are async so spec * code is uniform `await` across backends, even though dry resolves @@ -117,7 +117,7 @@ export class Signers { * Asserts an alias is backed by a prefunded wallet on the live node. * * Throws the cap error rather than silently reusing a wallet or proceeding - * with an unfunded one (INV-21). A no-op in dry mode. + * with an unfunded one. A no-op in dry mode. * * @param alias - The caller alias to validate. */ @@ -144,7 +144,7 @@ export class Signers { if (!this.resolveLiveKey) { throw new Error( `cannot resolve live key for "${alias}": no resolveLiveKey supplied. ` + - 'The caller harness must provide one (INV-22).', + 'The caller harness must provide one.', ); } return this.resolveLiveKey(alias); diff --git a/packages/simulator/src/types/Circuit.ts b/packages/simulator/src/types/Circuit.ts index 1749b8a..5a20a03 100644 --- a/packages/simulator/src/types/Circuit.ts +++ b/packages/simulator/src/types/Circuit.ts @@ -43,8 +43,8 @@ export type ContextlessCircuits = { * Async sibling of {@link ContextlessCircuits}, used by `createBackendSimulator`. * * Identical to {@link ContextlessCircuits} except every circuit returns - * `Promise` instead of `R`. This is the type-level half of dry↔live parity - * (INV-4): the dry backend wraps its synchronous result in `Promise.resolve`, + * `Promise` instead of `R`. This is the type-level half of dry↔live parity: + * the dry backend wraps its synchronous result in `Promise.resolve`, * the live backend awaits the network, and spec code is uniform `await` across * both. A circuit can never return a bare value on one backend and a `Promise` * on the other. diff --git a/packages/simulator/src/types/Options.ts b/packages/simulator/src/types/Options.ts index 00f7a39..66666cf 100644 --- a/packages/simulator/src/types/Options.ts +++ b/packages/simulator/src/types/Options.ts @@ -36,15 +36,15 @@ export interface SimulatorOptions extends BaseSimulatorOptions { */ backend?: BackendKind; /** - * The live world, supplied by the caller's harness (INV-22). In live mode this + * The live world, supplied by the caller's harness. In live mode this * is used if provided; otherwise the globally registered live backend (see * `registerLiveBackend`) is used. Ignored in dry mode. */ live?: LiveContext

; /** Dry only: override the deterministic alias→key derivation (OQ4). */ signerKeys?: Readonly>; - /** Live only: the prefunded alias pool (max `MAX_LIVE_SIGNERS`, INV-21). */ + /** Live only: the prefunded alias pool (max `MAX_LIVE_SIGNERS`). */ liveAliases?: readonly string[]; - /** Live only: resolve an alias to its wallet's coin public key (INV-22). */ + /** Live only: resolve an alias to its wallet's coin public key. */ resolveLiveKey?: (alias: string) => CoinPublicKey | Promise; } diff --git a/packages/simulator/test/unit/LiveBackendAdapter.test.ts b/packages/simulator/test/unit/LiveBackendAdapter.test.ts index e84dc2a..3951fb1 100644 --- a/packages/simulator/test/unit/LiveBackendAdapter.test.ts +++ b/packages/simulator/test/unit/LiveBackendAdapter.test.ts @@ -59,7 +59,7 @@ const makeBackend = ( }; describe('LiveBackend adapter', () => { - it('runs pure circuits locally without touching the node (INV-16)', async () => { + it('runs pure circuits locally without touching the node', async () => { const { backend, world } = makeBackend( {}, { double: (n) => (n as bigint) * 2n }, @@ -69,7 +69,7 @@ describe('LiveBackend adapter', () => { expect(world.lastAlias).toBeUndefined(); }); - it('normalizes impure results from .private.result to bare R (INV-13)', async () => { + it('normalizes impure results from .private.result to bare R', async () => { const { backend } = makeBackend({ owner: async () => ({ private: { result: 'OWNER_COMMITMENT' } }), }); @@ -78,7 +78,7 @@ describe('LiveBackend adapter', () => { ); }); - it('propagates the contract assert message as a substring (INV-14)', async () => { + it('propagates the contract assert message as a substring', async () => { const { backend } = makeBackend({ guarded: async () => { throw new Error( @@ -91,7 +91,7 @@ describe('LiveBackend adapter', () => { ); }); - it('applies single-shot caller for one call, then reverts (INV-17)', async () => { + it('applies single-shot caller for one call, then reverts', async () => { const { backend, world } = makeBackend({ noop: async () => ({ private: { result: undefined } }), }); @@ -103,7 +103,7 @@ describe('LiveBackend adapter', () => { expect(world.lastAlias).toBeNull(); }); - it('keeps a persistent caller across calls (INV-17)', async () => { + it('keeps a persistent caller across calls', async () => { const { backend, world } = makeBackend({ noop: async () => ({ private: { result: undefined } }), }); @@ -113,14 +113,14 @@ describe('LiveBackend adapter', () => { expect(world.lastAlias).toBe('ALICE'); }); - it('rejects callers outside the prefunded pool (INV-21)', () => { + it('rejects callers outside the prefunded pool', () => { const { backend } = makeBackend({}); expect(() => backend.setCaller('STRANGER', 'single')).toThrow( 'not in the prefunded pool', ); }); - it('hard-errors on witness override / setWitnesses (INV-7)', () => { + it('hard-errors on witness override / setWitnesses', () => { const { backend } = makeBackend({}); expect(() => backend.overrideWitness('w', () => {})).toThrow( 'witness override unsupported on live backend', @@ -130,7 +130,7 @@ describe('LiveBackend adapter', () => { ); }); - it('reads private state through the provider (INV-18)', async () => { + it('reads private state through the provider', async () => { const { backend } = makeBackend({}); expect(await backend.getPrivateState()).toEqual({ secret: 7 }); }); diff --git a/packages/simulator/test/unit/Signers.test.ts b/packages/simulator/test/unit/Signers.test.ts index 2132bed..a774c29 100644 --- a/packages/simulator/test/unit/Signers.test.ts +++ b/packages/simulator/test/unit/Signers.test.ts @@ -5,7 +5,7 @@ import { MAX_LIVE_SIGNERS, Signers } from '../../src/signers/Signers.js'; const expectedDryKey = (alias: string): string => Buffer.from(alias, 'ascii').toString('hex').padStart(64, '0'); -describe('Signers — dry derivation (INV-17)', () => { +describe('Signers — dry derivation', () => { const signers = new Signers({ mode: 'dry' }); it('derives the same key the existing test harness uses', async () => { @@ -30,7 +30,7 @@ describe('Signers — dry derivation (INV-17)', () => { }); }); -describe('Signers — live cap (INV-21)', () => { +describe('Signers — live cap', () => { it(`allows up to ${MAX_LIVE_SIGNERS} prefunded aliases`, () => { expect( () => diff --git a/packages/simulator/test/unit/dependency-wall.test.ts b/packages/simulator/test/unit/dependency-wall.test.ts index 9be0255..1d18896 100644 --- a/packages/simulator/test/unit/dependency-wall.test.ts +++ b/packages/simulator/test/unit/dependency-wall.test.ts @@ -24,17 +24,17 @@ function tsFiles(dir: string): string[] { const MIDNIGHT_JS = '@midnight-ntwrk/midnight-js'; /** - * INV-2 / INV-1 enforcement (the CI guard from OQ6). + * Dependency-wall enforcement (the CI guard from OQ6). * * The dry dependency graph must pull zero midnight-js. We enforce the structural * precondition: every midnight-js import is physically confined to `src/live/`. * Any reference elsewhere — even a `type` import — is flagged, since a stray - * value re-export is exactly how the wall silently falls (INV-1). + * value re-export is exactly how the wall silently falls. * * A stronger bundle/dependency-graph analysis (the other OQ6 option) can layer * on top; this source-level guard is the fast, deterministic floor. */ -describe('dependency wall (INV-1, INV-2)', () => { +describe('dependency wall', () => { it('confines every midnight-js import to src/live/', () => { const offenders = tsFiles(SRC_DIR) .filter((file) => !file.startsWith(LIVE_DIR)) @@ -44,7 +44,7 @@ describe('dependency wall (INV-1, INV-2)', () => { expect(offenders).toEqual([]); }); - it('keeps midnight-js out of the main barrel (INV-1)', () => { + it('keeps midnight-js out of the main barrel', () => { const barrel = readFileSync(join(SRC_DIR, 'index.ts'), 'utf8'); expect(barrel.includes(MIDNIGHT_JS)).toBe(false); // The live adapter must be a type-only re-export, never a value re-export. From fc1b8afacbeaea86db9cc420332c760195e3bc0e Mon Sep 17 00:00:00 2001 From: 0xisk <0xisk@proton.me> Date: Tue, 23 Jun 2026 18:19:20 +0200 Subject: [PATCH 4/4] fix(simulator): address coderabbit review findings * signers: gate the MAX_LIVE_SIGNERS cap check on live mode, so a shared options object carrying an oversized `liveAliases` no longer throws at construction in dry mode (the field is documented as live-only). * registry: make `registerLiveBackend` throw when a different factory is already registered instead of silently replacing it; a silent swap in a shared test process switches harness context and yields non-deterministic live runs. Re-registering the same factory is a no-op; call `clearLiveBackend` to replace. * dependency-wall test: match the live directory with a trailing path separator so the prefix check can't treat a sibling like `src/live2/` as inside `src/live/`. --- packages/simulator/src/live/registry.ts | 9 +++++++++ packages/simulator/src/signers/Signers.ts | 2 +- packages/simulator/test/unit/dependency-wall.test.ts | 6 ++++-- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/packages/simulator/src/live/registry.ts b/packages/simulator/src/live/registry.ts index c6c4ee3..9cc33ef 100644 --- a/packages/simulator/src/live/registry.ts +++ b/packages/simulator/src/live/registry.ts @@ -41,9 +41,18 @@ let registeredFactory: LiveBackendFactory | undefined; * Call this once from your `test:live` setup. It keeps the per-module test files * backend-agnostic: `await Sim.create()` works on both backends. * + * Throws if a different factory is already registered: in a shared test process + * a silent replacement would switch harness context and cause non-deterministic + * live behavior. Call {@link clearLiveBackend} before re-registering. + * * @param factory - Builds a {@link LiveContext} per `create` call. */ export function registerLiveBackend(factory: LiveBackendFactory): void { + if (registeredFactory && registeredFactory !== factory) { + throw new Error( + 'live backend is already registered. Call clearLiveBackend() before replacing it.', + ); + } registeredFactory = factory; } diff --git a/packages/simulator/src/signers/Signers.ts b/packages/simulator/src/signers/Signers.ts index 989be07..e913340 100644 --- a/packages/simulator/src/signers/Signers.ts +++ b/packages/simulator/src/signers/Signers.ts @@ -92,7 +92,7 @@ export class Signers { this.liveAliases = new Set(options.liveAliases ?? []); this.resolveLiveKey = options.resolveLiveKey; - if (this.liveAliases.size > MAX_LIVE_SIGNERS) { + if (this.mode === 'live' && this.liveAliases.size > MAX_LIVE_SIGNERS) { throw new Error( `live backend supports at most ${MAX_LIVE_SIGNERS} prefunded signers; ` + `got ${this.liveAliases.size}. The derive-and-fund flow for more is deferred.`, diff --git a/packages/simulator/test/unit/dependency-wall.test.ts b/packages/simulator/test/unit/dependency-wall.test.ts index 1d18896..9d0bb71 100644 --- a/packages/simulator/test/unit/dependency-wall.test.ts +++ b/packages/simulator/test/unit/dependency-wall.test.ts @@ -1,11 +1,13 @@ import { readdirSync, readFileSync } from 'node:fs'; -import { dirname, join, relative } from 'node:path'; +import { dirname, join, relative, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; const here = dirname(fileURLToPath(import.meta.url)); const SRC_DIR = join(here, '..', '..', 'src'); const LIVE_DIR = join(SRC_DIR, 'live'); +// Trailing separator so the prefix match can't leak to a sibling like `live2/`. +const LIVE_PREFIX = `${LIVE_DIR}${sep}`; /** All `.ts` files under a directory, recursively. */ function tsFiles(dir: string): string[] { @@ -37,7 +39,7 @@ const MIDNIGHT_JS = '@midnight-ntwrk/midnight-js'; describe('dependency wall', () => { it('confines every midnight-js import to src/live/', () => { const offenders = tsFiles(SRC_DIR) - .filter((file) => !file.startsWith(LIVE_DIR)) + .filter((file) => !file.startsWith(LIVE_PREFIX)) .filter((file) => readFileSync(file, 'utf8').includes(MIDNIGHT_JS)) .map((file) => relative(SRC_DIR, file));