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));