Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion packages/simulator/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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
}
}
}
117 changes: 117 additions & 0 deletions packages/simulator/src/backend/Backend.ts
Original file line number Diff line number Diff line change
@@ -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.
* 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):
* 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:
* {@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<P, L> {
/** The backend this instance is bound to. */
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).
* 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.
* @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<unknown>;

/**
* Extracts the public ledger state. Both backends apply the same
* `ledgerExtractor` — over the in-memory context in dry, over the
* indexer-sourced state in live.
*/
getPublicState(): Promise<L>;

/**
* 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).
*/
getPrivateState(): Promise<P>;

/** Returns the raw contract `StateValue` (the input to `ledgerExtractor`). */
getContractState(): Promise<StateValue>;

/**
* 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.
* 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: `'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.
*
* @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 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;
}
129 changes: 129 additions & 0 deletions packages/simulator/src/backend/DryBackend.ts
Original file line number Diff line number Diff line change
@@ -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<P, L> {
readonly contractAddress: string;
callerOverride: CoinPublicKey | null;
persistentCallerOverride: CoinPublicKey | null;
readonly circuits: {
pure: Record<string, (...args: unknown[]) => unknown>;
impure: Record<string, (...args: unknown[]) => 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, 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
* and is the parity reference the live backend is measured against.
*
* @template P - Private state type.
* @template L - Public ledger state type.
*/
export class DryBackend<P, L> implements Backend<P, L> {
readonly kind: BackendKind = 'dry';

private readonly sim: SyncSimulator<P, L>;
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<P, L>, 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<unknown> {
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<L> {
return this.sim.getPublicState();
}

async getPrivateState(): Promise<P> {
return this.sim.getPrivateState();
}

async getContractState(): Promise<StateValue> {
return this.sim.getContractState();
}

/** Mutates the in-memory private state (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`.
*/
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;
}
}
7 changes: 7 additions & 0 deletions packages/simulator/src/factory/SimulatorConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,11 @@ export interface SimulatorConfig<
ledgerExtractor: (state: StateValue) => L;
/** Factory function to create default witnesses */
witnessesFactory: () => W;
/**
* Optional artifact name (the `artifacts/<name>/` 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;
}
Loading
Loading