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..869c3e6 --- /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. + * 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 { + /** 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; + + /** + * 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; + + /** + * 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

; + + /** 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. + * 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; +} diff --git a/packages/simulator/src/backend/DryBackend.ts b/packages/simulator/src/backend/DryBackend.ts new file mode 100644 index 0000000..772758e --- /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, 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 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 (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; + } +} 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..5e65dd5 --- /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. 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..379408b 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. + */ +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. `create` is async and + * circuits return promises ({@link AsyncCircuits}) so a single spec file runs on + * both backends with uniform `await`. * - * 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: 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). + * @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. + 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. + 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.', ); } - return this._pureCircuitProxy; + + // 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. */ + 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. */ + 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. * - * @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. + * + * @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. * - * @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. */ + getPublicState(): Promise { + return this._backend.getPublicState(); + } + + /** The private state (read parity across backends). */ + 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 (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. + * 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. * - * @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. * - * @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..8f02c5f 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: the live adapter `LiveBackend` +// is type-only here and reached at runtime only via the dynamic import inside +// `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'; 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..7014391 --- /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. */ +export const WITNESS_OVERRIDE_UNSUPPORTED = + 'witness override unsupported on live backend'; + +/** 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'; + +/** + * 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). + * 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. */ + signers: Signers; + /** 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. + * + * It imports no midnight-js — all node wiring lives behind the + * {@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. + * @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. */ + private persistentAlias: string | null = null; + /** Caller active for the next call only, then reverts. */ + 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. + */ + 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}"`); + } + // 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; + } + + /** Same extractor as dry, applied to indexer-sourced state. */ + async getPublicState(): Promise { + return this.ledgerExtractor(await this.ctx.queryLedger()); + } + + /** 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. + * 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 and records it. + * `'single'` applies to the next call then reverts; `'persistent'` holds until + * changed. 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. */ + overrideWitness(_key: PropertyKey, _fn: unknown): void { + throw new Error(WITNESS_OVERRIDE_UNSUPPORTED); + } + + /** Witnesses bind at deploy and cannot be swapped mid-test. */ + 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..c16e45c --- /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`. 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. + * 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). 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. */ + 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`. 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 (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 0000000..1132dbf --- /dev/null +++ b/packages/simulator/src/live/createLiveContext.ts @@ -0,0 +1,183 @@ +import type { StateValue } from '@midnight-ntwrk/compact-runtime'; +// Type-only imports — erased at build, so they create no runtime edge to +// midnight-js. The only runtime midnight-js edge in this file is the +// lazy dynamic import inside `loadFindDeployedContract`. +import type { + PrivateStateProvider, + PublicDataProvider, +} from '@midnight-ntwrk/midnight-js-types'; +import type { DeployedTxHandle, LiveContext } from './LiveContext.js'; + +/** + * Bounded retry policy for absorbing indexer block-lag on public-state reads. + * Always finite: a genuinely missing write fails the suite rather than + * hanging it. Defaults concretize OQ2 and should be tuned against a real node. + */ +export interface IndexerLagPolicy { + /** Max poll attempts before giving up. */ + retries: number; + /** Initial backoff between attempts, in ms. */ + baseDelayMs: number; + /** Backoff ceiling, in ms. */ + maxDelayMs: number; +} + +/** Default indexer-lag policy (OQ2 — provisional, tune against a live node). */ +export const DEFAULT_INDEXER_LAG: IndexerLagPolicy = { + retries: 8, + baseDelayMs: 150, + maxDelayMs: 2000, +}; + +/** + * Options for {@link createLiveContext}. + * + * The package only assembles already-provided pieces; deploy, provider + * construction, and wallet funding are the caller's harness. Provider + * and contract specifics are threaded through opaquely — the harness owns their + * construction and types. + * + * @template P - Private state type. + */ +export interface CreateLiveContextOptions

{ + /** The address of the already-deployed contract. */ + contractAddress: string; + /** + * Per-alias `ContractProviders`, supplied by the harness. The wallet/signing + * differs per alias; `null` is the default signer. Threaded into + * `findDeployedContract`. + */ + providersFor: (alias: string | null) => unknown; + /** The compiled contract, from the harness. Threaded into `findDeployedContract`. */ + compiledContract: unknown; + /** Identifier under which the contract's private state is stored. */ + privateStateId: string; + /** Provider for reading on-chain public state. */ + publicDataProvider: PublicDataProvider; + /** Provider for reading private state (read parity). */ + privateStateProvider: PrivateStateProvider; + /** Optional override of the indexer-lag policy. */ + indexerLag?: Partial; +} + +type FindDeployedContractFn = ( + providers: unknown, + options: unknown, +) => Promise<{ callTx: DeployedTxHandle['callTx'] }>; + +let cachedFindDeployedContract: FindDeployedContractFn | undefined; + +/** + * Lazily loads `findDeployedContract`. The dynamic import is the sole runtime + * edge to midnight-js in the package's graph; a failure to resolve it (the + * optional peers are absent) is rewrapped into an actionable message + * rather than a raw `ERR_MODULE_NOT_FOUND`. + */ +const loadFindDeployedContract = async (): Promise => { + if (cachedFindDeployedContract) return cachedFindDeployedContract; + try { + const mod = await import('@midnight-ntwrk/midnight-js-contracts'); + cachedFindDeployedContract = + mod.findDeployedContract as unknown as FindDeployedContractFn; + } catch (cause) { + throw new Error( + 'install @midnight-ntwrk/midnight-js-contracts (and the midnight-js peers) ' + + 'to use live mode', + { cause }, + ); + } + return cachedFindDeployedContract; +}; + +const sleep = (ms: number): Promise => + new Promise((resolve) => { + setTimeout(resolve, ms); + }); + +/** + * Assembles a {@link LiveContext} from harness-provided pieces. + * + * Provides three things the harness would otherwise hand-roll: a per-alias + * deployed-handle cache (via `findDeployedContract`), a public-state reader that + * absorbs bounded indexer lag, and a private-state reader. The + * adapter ({@link LiveContext}) stays thin; this helper is separate and + * imported only by live consumers (who already depend on midnight-js). + * + * @param options - Harness-provided providers, contract, and address. + * @returns A {@link LiveContext} ready to pass to `create(args, { live })`. + */ +export function createLiveContext

( + options: CreateLiveContextOptions

, +): LiveContext

{ + const lag: IndexerLagPolicy = { + ...DEFAULT_INDEXER_LAG, + ...options.indexerLag, + }; + const handleCache = new Map>(); + + const resolveHandle = (alias: string | null): Promise => { + const key = alias ?? '\u0000default'; + const cached = handleCache.get(key); + if (cached) return cached; + const built = loadFindDeployedContract().then((findDeployedContract) => + findDeployedContract(options.providersFor(alias), { + compiledContract: options.compiledContract, + contractAddress: options.contractAddress, + privateStateId: options.privateStateId, + }), + ); + handleCache.set(key, built); + return built; + }; + + return { + contractAddress: options.contractAddress, + + handleFor: resolveHandle, + + /** + * Polls the indexer for the contract state, retrying a bounded number of + * times with capped exponential backoff to absorb block-lag after a + * confirmed write. Returns the `StateValue` the shared + * `ledgerExtractor` consumes. + */ + async queryLedger(): Promise { + let delay = lag.baseDelayMs; + let lastErr: unknown; + for (let attempt = 0; attempt <= lag.retries; attempt++) { + try { + const state = await options.publicDataProvider.queryContractState( + options.contractAddress, + ); + if (state != null) { + // `ContractState.data` is the StateValue the dry path also extracts. + return (state as unknown as { data: StateValue }).data; + } + } catch (err) { + lastErr = err; + } + if (attempt < lag.retries) { + await sleep(delay); + delay = Math.min(delay * 2, lag.maxDelayMs); + } + } + throw new Error( + `no contract state at ${options.contractAddress} after ${lag.retries + 1} ` + + 'attempts — the write may be missing, or indexer lag exceeds the budget', + lastErr === undefined ? undefined : { cause: lastErr }, + ); + }, + + async queryPrivateState(): Promise

{ + const state = await options.privateStateProvider.get( + options.privateStateId, + ); + if (state == null) { + throw new Error( + `no private state stored at "${options.privateStateId}"`, + ); + } + return state; + }, + }; +} diff --git a/packages/simulator/src/live/registry.ts b/packages/simulator/src/live/registry.ts new file mode 100644 index 0000000..9cc33ef --- /dev/null +++ b/packages/simulator/src/live/registry.ts @@ -0,0 +1,77 @@ +import type { SimulatorConfig } from '../factory/SimulatorConfig.js'; +import type { IMinimalContract } from '../types/Contract.js'; +import type { SimulatorOptions } from '../types/Options.js'; +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; + * this just hands it the same config + args the test used. + */ +export interface LiveBackendRequest< + P = unknown, + L = unknown, + W = unknown, + TContract extends IMinimalContract = IMinimalContract, + TArgs extends readonly unknown[] = readonly unknown[], +> { + /** 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. + * + * 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; +} + +/** 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..e913340 --- /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). 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. + * + * @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}. 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. + */ + 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. 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. + * + * 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.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.`, + ); + } + } + + /** + * 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. 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.', + ); + } + 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..5a20a03 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: + * 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..66666cf 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. 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`). */ + liveAliases?: readonly string[]; + /** Live only: resolve an alias to its wallet's coin public key. */ + 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..3951fb1 --- /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', 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', 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', 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', 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', 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', () => { + const { backend } = makeBackend({}); + expect(() => backend.setCaller('STRANGER', 'single')).toThrow( + 'not in the prefunded pool', + ); + }); + + it('hard-errors on witness override / setWitnesses', () => { + 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', 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..a774c29 --- /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', () => { + 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', () => { + 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..9d0bb71 --- /dev/null +++ b/packages/simulator/test/unit/dependency-wall.test.ts @@ -0,0 +1,56 @@ +import { readdirSync, readFileSync } from 'node:fs'; +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[] { + 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'; + +/** + * 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. + * + * 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', () => { + it('confines every midnight-js import to src/live/', () => { + const offenders = tsFiles(SRC_DIR) + .filter((file) => !file.startsWith(LIVE_PREFIX)) + .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', () => { + 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/); + }); +}); 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