Skip to content

Commit b54bac3

Browse files
committed
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<P,L> 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.
1 parent 22048e3 commit b54bac3

27 files changed

Lines changed: 3010 additions & 357 deletions

packages/simulator/docs/code/live-backend-code.md

Lines changed: 234 additions & 0 deletions
Large diffs are not rendered by default.

packages/simulator/docs/design/live-backend-invariants.md

Lines changed: 646 additions & 0 deletions
Large diffs are not rendered by default.

packages/simulator/docs/design/live-backend.md

Lines changed: 335 additions & 0 deletions
Large diffs are not rendered by default.

packages/simulator/package.json

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,10 +36,13 @@
3636
"scripts": {
3737
"build": "tsc -p .",
3838
"types": "tsc -p tsconfig.json --noEmit",
39-
"test": "yarn vitest run",
39+
"test": "MIDNIGHT_BACKEND=dry vitest run",
40+
"test:live": "MIDNIGHT_BACKEND=live vitest run",
4041
"clean": "git clean -fXd"
4142
},
4243
"devDependencies": {
44+
"@midnight-ntwrk/midnight-js-contracts": "^4.1.0",
45+
"@midnight-ntwrk/midnight-js-types": "^4.1.0",
4346
"@tsconfig/node24": "^24.0.3",
4447
"@types/node": "25.9.1",
4548
"fast-check": "^4.5.2",
@@ -49,5 +52,17 @@
4952
"dependencies": {
5053
"@midnight-ntwrk/compact-runtime": "0.16.0",
5154
"@midnight-ntwrk/ledger-v8": "8.1.0"
55+
},
56+
"peerDependencies": {
57+
"@midnight-ntwrk/midnight-js-contracts": "^4.1.0",
58+
"@midnight-ntwrk/midnight-js-types": "^4.1.0"
59+
},
60+
"peerDependenciesMeta": {
61+
"@midnight-ntwrk/midnight-js-contracts": {
62+
"optional": true
63+
},
64+
"@midnight-ntwrk/midnight-js-types": {
65+
"optional": true
66+
}
5267
}
5368
}
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import type { StateValue } from '@midnight-ntwrk/compact-runtime';
2+
3+
/**
4+
* Identifies which execution backend a simulator is bound to.
5+
*
6+
* Resolved once at construction time and fixed for the simulator's lifetime
7+
* (INV-8). There is no runtime toggle: a `'dry'` simulator never becomes
8+
* `'live'` or vice versa.
9+
*/
10+
export type BackendKind = 'dry' | 'live';
11+
12+
/**
13+
* Whether a circuit runs locally on the JS artifact (`'pure'`) or, in live mode,
14+
* is submitted as a transaction to the node (`'impure'`).
15+
*
16+
* Locality follows the pure/impure distinction, NOT read/write (D2, INV-16):
17+
* a read implemented as an impure circuit (e.g. `owner()`) still hits the node
18+
* in live mode.
19+
*/
20+
export type CircuitKind = 'pure' | 'impure';
21+
22+
/**
23+
* The execution seam that genuinely differs between the in-memory simulator and
24+
* a live Midnight node.
25+
*
26+
* `createBackendSimulator` builds the async circuit proxies, caller helpers, and
27+
* state getters on top of this interface; the backend itself stays dumb. Every
28+
* operation is async so spec code is uniform `await` across both backends
29+
* (INV-4): {@link DryBackend} wraps its synchronous results in `Promise.resolve`,
30+
* the live adapter awaits the network.
31+
*
32+
* @template P - Private state type.
33+
* @template L - Public ledger state type.
34+
*/
35+
export interface Backend<P, L> {
36+
/** The backend this instance is bound to (INV-8). */
37+
readonly kind: BackendKind;
38+
39+
/** The deployed contract's address. */
40+
readonly contractAddress: string;
41+
42+
/**
43+
* Invokes a circuit. Pure circuits run locally on the JS artifact in both
44+
* modes; impure circuits run locally in dry and submit a tx in live (D2,
45+
* INV-16). The live adapter normalizes the result to the bare `R` that dry
46+
* returns (INV-13), so an assertion on the return value is identical across
47+
* backends.
48+
*
49+
* @param kind - Whether the circuit is pure or impure.
50+
* @param name - The circuit name.
51+
* @param args - The circuit arguments.
52+
* @returns The bare circuit result `R`, normalized to match dry.
53+
*/
54+
call(kind: CircuitKind, name: string, args: unknown[]): Promise<unknown>;
55+
56+
/**
57+
* Extracts the public ledger state. Both backends apply the same
58+
* `ledgerExtractor` (INV-15) — over the in-memory context in dry, over the
59+
* indexer-sourced state in live.
60+
*/
61+
getPublicState(): Promise<L>;
62+
63+
/**
64+
* Reads the private state `P`. Read parity holds across backends (INV-18);
65+
* mutation parity does not (see {@link overrideWitness} and the private-state
66+
* mutation asymmetry documented on the live adapter).
67+
*/
68+
getPrivateState(): Promise<P>;
69+
70+
/** Returns the raw contract `StateValue` (the input to `ledgerExtractor`). */
71+
getContractState(): Promise<StateValue>;
72+
73+
/**
74+
* Replaces the private state. Dry mutates the in-memory context (used by
75+
* per-module helpers like secret/nonce injection); live throws, because
76+
* mid-test private-state mutation is the documented dry↔live asymmetry
77+
* (INV-18). Guard such specs with `isLiveBackend()`.
78+
*
79+
* @param privateState - The new private state `P`.
80+
*/
81+
setPrivateState(privateState: P): void;
82+
83+
/**
84+
* Sets the caller identity for subsequent circuit calls.
85+
*
86+
* The mode lifecycle matches across backends (INV-17): `'single'` applies the
87+
* caller to the next call then reverts to the default signer; `'persistent'`
88+
* keeps it until changed. `null` clears the override (default signer).
89+
*
90+
* @param alias - The caller alias (e.g. `'OWNER'`), or `null` for the default signer.
91+
* @param mode - `'single'` (one call) or `'persistent'` (until changed).
92+
*/
93+
setCaller(alias: string | null, mode: 'single' | 'persistent'): void;
94+
95+
/**
96+
* Replaces a single witness implementation.
97+
*
98+
* Dry recreates the contract with the new witness; the live adapter throws
99+
* `"witness override unsupported on live backend"` because witnesses bind at
100+
* deploy and cannot be swapped mid-test (INV-7).
101+
*
102+
* @param key - The witness key to override.
103+
* @param fn - The new witness implementation.
104+
*/
105+
overrideWitness(key: PropertyKey, fn: unknown): void;
106+
107+
/**
108+
* Replaces the whole witness set. Dry recreates the contract; the live adapter
109+
* throws the same INV-7 message as {@link overrideWitness}.
110+
*
111+
* @param witnesses - The new witness set.
112+
*/
113+
setWitnesses(witnesses: unknown): void;
114+
115+
/** Returns the current witness set (read parity; live reads the local set). */
116+
getWitnesses(): unknown;
117+
}
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import type {
2+
CoinPublicKey,
3+
StateValue,
4+
} from '@midnight-ntwrk/compact-runtime';
5+
import type { Signers } from '../signers/Signers.js';
6+
import type { Backend, BackendKind, CircuitKind } from './Backend.js';
7+
8+
/**
9+
* The slice of a synchronous `createSimulator` instance that {@link DryBackend}
10+
* drives. Kept structural so the dry backend reuses the existing simulator
11+
* machinery without coupling to its concrete (anonymous) class.
12+
*
13+
* @template P - Private state type.
14+
* @template L - Public ledger state type.
15+
*/
16+
export interface SyncSimulator<P, L> {
17+
readonly contractAddress: string;
18+
callerOverride: CoinPublicKey | null;
19+
persistentCallerOverride: CoinPublicKey | null;
20+
readonly circuits: {
21+
pure: Record<string, (...args: unknown[]) => unknown>;
22+
impure: Record<string, (...args: unknown[]) => unknown>;
23+
};
24+
getPublicState(): L;
25+
getPrivateState(): P;
26+
getContractState(): StateValue;
27+
overrideWitness(key: PropertyKey, fn: unknown): void;
28+
witnesses: unknown;
29+
readonly circuitContextManager: { updatePrivateState(privateState: P): void };
30+
}
31+
32+
/**
33+
* The in-memory backend: a thin async facade over the existing synchronous
34+
* `createSimulator` instance.
35+
*
36+
* Every operation delegates to the wrapped simulator and wraps the synchronous
37+
* result in a resolved promise (INV-4), so a circuit never returns a bare value
38+
* on dry but a `Promise` on live. Because all real work routes through the
39+
* unchanged synchronous path, dry behavior is preserved byte-for-byte (INV-19)
40+
* and is the parity reference the live backend is measured against (INV-12).
41+
*
42+
* @template P - Private state type.
43+
* @template L - Public ledger state type.
44+
*/
45+
export class DryBackend<P, L> implements Backend<P, L> {
46+
readonly kind: BackendKind = 'dry';
47+
48+
private readonly sim: SyncSimulator<P, L>;
49+
private readonly signers: Signers;
50+
51+
/**
52+
* @param sim - The wrapped synchronous simulator instance.
53+
* @param signers - Resolver used to turn caller aliases into deterministic keys.
54+
*/
55+
constructor(sim: SyncSimulator<P, L>, signers: Signers) {
56+
this.sim = sim;
57+
this.signers = signers;
58+
}
59+
60+
get contractAddress(): string {
61+
return this.sim.contractAddress;
62+
}
63+
64+
/**
65+
* Runs a circuit on the in-memory contract. Accessing `circuits.{pure,impure}`
66+
* fresh on each call means a witness override (which rebuilds the wrapped
67+
* simulator's proxies) is picked up transparently.
68+
*/
69+
async call(
70+
kind: CircuitKind,
71+
name: string,
72+
args: unknown[],
73+
): Promise<unknown> {
74+
const proxy =
75+
kind === 'pure' ? this.sim.circuits.pure : this.sim.circuits.impure;
76+
const fn = proxy[name];
77+
if (typeof fn !== 'function') {
78+
throw new Error(`unknown ${kind} circuit "${name}"`);
79+
}
80+
return fn(...args);
81+
}
82+
83+
async getPublicState(): Promise<L> {
84+
return this.sim.getPublicState();
85+
}
86+
87+
async getPrivateState(): Promise<P> {
88+
return this.sim.getPrivateState();
89+
}
90+
91+
async getContractState(): Promise<StateValue> {
92+
return this.sim.getContractState();
93+
}
94+
95+
/** Mutates the in-memory private state (INV-18: dry supports mid-test mutation). */
96+
setPrivateState(privateState: P): void {
97+
this.sim.circuitContextManager.updatePrivateState(privateState);
98+
}
99+
100+
/**
101+
* Resolves the alias to a deterministic key and applies it to the wrapped
102+
* simulator's override fields. `'single'` uses `callerOverride` (the existing
103+
* proxy auto-resets it after one call); `'persistent'` uses
104+
* `persistentCallerOverride` (INV-17).
105+
*/
106+
setCaller(alias: string | null, mode: 'single' | 'persistent'): void {
107+
const key = alias === null ? null : this.signers.resolveDryKey(alias);
108+
if (mode === 'persistent') {
109+
this.sim.persistentCallerOverride = key;
110+
} else {
111+
this.sim.callerOverride = key;
112+
}
113+
}
114+
115+
/** Delegates to the wrapped simulator, which recreates the contract (dry supports this). */
116+
overrideWitness(key: PropertyKey, fn: unknown): void {
117+
this.sim.overrideWitness(key, fn);
118+
}
119+
120+
/** Delegates to the wrapped simulator's witness setter (dry supports this). */
121+
setWitnesses(witnesses: unknown): void {
122+
this.sim.witnesses = witnesses;
123+
}
124+
125+
/** Returns the wrapped simulator's current witnesses. */
126+
getWitnesses(): unknown {
127+
return this.sim.witnesses;
128+
}
129+
}

packages/simulator/src/factory/SimulatorConfig.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,4 +36,11 @@ export interface SimulatorConfig<
3636
ledgerExtractor: (state: StateValue) => L;
3737
/** Factory function to create default witnesses */
3838
witnessesFactory: () => W;
39+
/**
40+
* Optional artifact name (the `artifacts/<name>/` directory) for the compiled
41+
* contract. Dry ignores it; the live backend's registered harness uses it to
42+
* locate the compiled assets + ZK keys and to build the deployable contract.
43+
* Only needed for modules that run on the live backend.
44+
*/
45+
artifactName?: string;
3946
}

0 commit comments

Comments
 (0)