🧐 Motivation
Today every simulator in @openzeppelin-compact/contracts-simulator runs purely in-memory against a CircuitContext. That's fast and deterministic, which is the right default for unit tests, but it only exercises circuit logic in isolation — it never touches a real Midnight node, real provers, real wallets, or real ledger state transitions.
Several things we care about cannot be covered by an in-memory simulator alone:
- Contract upgradability. Deploying v1, exercising it, upgrading to v2, and verifying state + behavior across the boundary only makes sense against a live node that actually persists ledger state between transactions.
- End-to-end prover / wallet integration. The in-memory path bypasses proof generation and wallet signing, so regressions in those layers go unnoticed until integration time.
- Multi-caller flows. Scenarios with several funded accounts interacting with the same deployed contract (role grants, transfers, access checks across principals) are awkward to model in memory and trivial on a live node with a real wallet pool.
- Confidence that unit-test assertions reflect on-chain reality. Running the same spec against both backends catches divergences between the simulator's model of a circuit and what the node actually executes.
Right now the only way to get any of this is to write a separate, live-node-flavored test file per module, with its own API surface that mirrors the simulator's. That duplication is the root problem: the public API of each contract ends up encoded in two places, and the two drift.
📝 Details
Make the existing per-module simulator classes the single home for each contract's test-facing API, and let them run against either an in-memory context or a real deployed contract on a local Midnight node — same methods, same spec files, selected at fixture-construction time.
Proposal
Introduce a Backend abstraction in @openzeppelin-compact/contracts-simulator:
DryBackend — wraps the existing in-memory CircuitContext path.
LiveBackend<C> — wraps a DeployedContract<C>, MidnightProviders, and a wallet (plus an optional address-keyed wallet pool for .as(alias) / setPersistentCaller(alias) support).
Each simulator method returns Promise<T> in both modes (dry mode wraps sync logic in async, live mode awaits network calls). Tests write await simulator.foo() uniformly; no conditional types, no runtime branching in spec code. Backend selection happens at fixture-construction time, driven by MIDNIGHT_BACKEND=dry|live.
A new factory createBackendSimulator(...) lives alongside the existing createSimulator(...); modules opt in one at a time by swapping the factory call and wrapping their method return types in Promise<T>. No flag day.
🧐 Motivation
Today every simulator in
@openzeppelin-compact/contracts-simulatorruns purely in-memory against aCircuitContext. That's fast and deterministic, which is the right default for unit tests, but it only exercises circuit logic in isolation — it never touches a real Midnight node, real provers, real wallets, or real ledger state transitions.Several things we care about cannot be covered by an in-memory simulator alone:
Right now the only way to get any of this is to write a separate, live-node-flavored test file per module, with its own API surface that mirrors the simulator's. That duplication is the root problem: the public API of each contract ends up encoded in two places, and the two drift.
📝 Details
Make the existing per-module simulator classes the single home for each contract's test-facing API, and let them run against either an in-memory context or a real deployed contract on a local Midnight node — same methods, same spec files, selected at fixture-construction time.
Proposal
Introduce a
Backendabstraction in@openzeppelin-compact/contracts-simulator:DryBackend— wraps the existing in-memoryCircuitContextpath.LiveBackend<C>— wraps aDeployedContract<C>,MidnightProviders, and a wallet (plus an optional address-keyed wallet pool for.as(alias)/setPersistentCaller(alias)support).Each simulator method returns
Promise<T>in both modes (dry mode wraps sync logic inasync, live mode awaits network calls). Tests writeawait simulator.foo()uniformly; no conditional types, no runtime branching in spec code. Backend selection happens at fixture-construction time, driven byMIDNIGHT_BACKEND=dry|live.A new factory
createBackendSimulator(...)lives alongside the existingcreateSimulator(...); modules opt in one at a time by swapping the factory call and wrapping their method return types inPromise<T>. No flag day.