diff --git a/RELEASING.md b/RELEASING.md index 535200f..0d3ac3a 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -13,6 +13,19 @@ The dist-tag is derived from the version string, so a prerelease can never take over `latest`. The bump strategies are pinned to their branch: `pre*` runs only from `beta`, `patch`/`minor`/`major` only from `main`. +## Changelog + +Each published package keeps its own `CHANGELOG.md`, following +[Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Any PR with a +consumer-visible change adds an entry under that package's `## Unreleased` +heading, with the PR number in parentheses. Mark API breaks with a +`**Breaking:**` prefix. + +Beta bumps leave the entries under `## Unreleased`. When a cycle graduates to +stable, rename the heading to `## (YYYY-MM-DD)` and land that before +running the workflow from `main`, so the published tarball and the GitHub +release notes agree. + ## Running the workflow 1. Go to "Release Package" in Actions. diff --git a/packages/simulator/CHANGELOG.md b/packages/simulator/CHANGELOG.md new file mode 100644 index 0000000..92d41f3 --- /dev/null +++ b/packages/simulator/CHANGELOG.md @@ -0,0 +1,24 @@ +# Changelog + +All notable changes to `@openzeppelin/compact-simulator` are documented in this +file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +Releases before `0.4.0` predate this file; see the `compact-simulator/v*` tags. + +## Unreleased + +### Added + +- `BaseSimulatorOptions.time` sets the block time the kernel's time operations observe, in seconds since the epoch. Defaults to `0` for reproducible runs (#147) + +### Changed + +- **Breaking:** `@midnight-ntwrk/compact-runtime` moves to `0.19.0`, which made `initialState` and every circuit async. The `@midnight-ntwrk/midnight-js-contracts` and `@midnight-ntwrk/midnight-js-types` peers move from `^4.1.0` to `^5.0.0-beta.7`. Both peers stay optional, so a dry-only consumer needs neither (#152) +- **Breaking:** construction is async. Replace `new MySimulator(args, options)` with `await MySimulator.create(args, options)`. Subclass `create` overrides return `Promise` and delegate to `super._create([...args], options)` (#145, #147) +- **Breaking:** circuit proxies return promises. `ContextlessCircuits` maps to `Promise`, so an un-awaited call that used to pass as a truthy promise, such as `if (sim.isOwner(x))`, no longer typechecks (#145, #154) +- **Breaking:** `getPrivateState()`, `getPublicState()`, and `getContractState()` return promises (#145) +- **Breaking:** `CircuitContextManager`'s constructor takes `time`, as `(contract, privateState, coinPK, contractAddress, time, ...contractArgs)`, and `init()` must be awaited once before any circuit call. Direct users of `CircuitContextManager` or `AbstractSimulator` are affected; the `createSimulator` path awaits `init()` internally (#147) +- **Breaking:** `CircuitContext` nests its runtime state under `callContext`, e.g. `ctx.callContext.currentPrivateState` and `ctx.callContext.currentQueryContext.state.state` (#145) +- **Breaking:** live pure circuits bind to the deployed address. An explicit `contractAddress` that differs from the deployed one is rejected rather than silently used for local pure evaluation (#154) diff --git a/packages/simulator/README.md b/packages/simulator/README.md index f289632..77faad1 100644 --- a/packages/simulator/README.md +++ b/packages/simulator/README.md @@ -9,22 +9,27 @@ allowing you to simulate contract behavior locally without blockchain deployment - 🔧 **Witness Overrides** - Mock and spy on witness functions. - 📊 **State Inspection** - Access private and contract state. - 🚀 **Type-Safe** - Full TypeScript support with generics. +- 🔀 **Two Backends** - The same spec runs in memory (`dry`) or against a node (`live`), selected by `MIDNIGHT_BACKEND`. + +> **Upgrading from 0.3.x?** Construction and every circuit call are now +> asynchronous. See the [changelog](https://github.com/OpenZeppelin/compact-tools/blob/main/packages/simulator/CHANGELOG.md). ## Quick Start ```typescript -import { createSimulator } from '@openzeppelin/compact-simulator'; +import { createSimulator, type SimulatorOptions } from '@openzeppelin/compact-simulator'; import { Contract, ledger } from './artifacts/MyContract/contract/index.js'; -// 1. Define your contract arguments type +// 1. Define your contract's constructor arguments as a tuple type type MyContractArgs = readonly [owner: Uint8Array, value: bigint]; -// 2. Create the simulator -const MySimulator = createSimulator< - MyPrivateState, - ReturnType, - ReturnType, - MyContractArgs +// 2. Create the simulator class +const MySimulatorBase = createSimulator< + MyPrivateState, // Private state + ReturnType, // Ledger state + ReturnType, // Witnesses + Contract, // Contract + MyContractArgs // Constructor args >({ contractFactory: (witnesses) => new Contract(witnesses), defaultPrivateState: () => MyPrivateState.generate(), @@ -34,7 +39,7 @@ const MySimulator = createSimulator< }); // 3. Use it! -const sim = new MySimulator([ownerAddress, 100n], { coinPK: deployerPK }); +const sim = await MySimulatorBase.create([ownerAddress, 100n], { coinPK: deployerPK }); ``` ## Core Concepts @@ -53,10 +58,11 @@ type MyContractArgs = readonly [arg1: bigint, arg2: string]; // Create the base simulator with full type information const MyContractSimulatorBase = createSimulator< - MyContractPrivateState, // Private state type - ReturnType, // Ledger state type - ReturnType, // Witnesses type - MyContractArgs // Constructor args type + MyContractPrivateState, // Private state type + ReturnType, // Ledger state type + ReturnType, // Witnesses type + MyContract, // Contract type + MyContractArgs // Constructor args type >({ contractFactory: (witnesses) => new MyContract(witnesses), defaultPrivateState: () => MyContractPrivateState.generate(), @@ -66,6 +72,10 @@ const MyContractSimulatorBase = createSimulator< }); ``` +The fourth type parameter is the **contract**, not the args tuple. The args +tuple is the fifth and defaults to `readonly any[]`; pass it explicitly so a +wrong tuple is caught at the `create` call. + ### ⚠️ Witness Factory Pattern The simulator requires `witnessesFactory` to be a function that returns witnesses, even for empty witnesses. @@ -126,41 +136,72 @@ export class MyContractSimulator extends MyContractSimulatorBase { > add circuit methods without the override, `MyContractSimulator.create(...)` > resolves to the base `Simulator` type and callers lose the subclass's methods. +There is no public constructor. `create` resolves the backend (including the +live adapter's dynamic import) and runs the contract constructor, both of which +are async, so `new MyContractSimulator(...)` is not a supported entry point. + ### 3. Circuit Types +Every circuit proxy returns a promise on both backends, so a single spec runs +against either with uniform `await`. + #### Pure Circuits -Compute outputs from inputs without reading or modifying state: +Compute outputs from inputs without reading or modifying state. They run +locally on the JS artifact in both modes: ```typescript -public add(a: bigint, b: bigint): bigint { +public add(a: bigint, b: bigint): Promise { return this.circuits.pure.add(a, b); } -public calculateFee(amount: bigint): bigint { +public calculateFee(amount: bigint): Promise { return this.circuits.pure.calculateFee(amount); } ``` #### Impure Circuits -Read and/or modify the contract state: +Read and/or modify the contract state. In live mode they submit a transaction, +so a read implemented as an impure circuit still hits the node: ```typescript -public deposit(amount: bigint): void { - this.circuits.impure.deposit(amount); +public deposit(amount: bigint): Promise<[]> { + return this.circuits.impure.deposit(amount); } -public getBalance(): bigint { +public getBalance(): Promise { return this.circuits.impure.getBalance(); } ``` ## Advanced Features +### 👤 Callers and Signers + +Callers are named by alias. Dry derives a deterministic key per alias; live +resolves the alias against the harness's prefunded wallet pool. + +```typescript +// Next call only, then reverts to the default signer +await simulator.as('OWNER').transferOwnership(newOwnerId); + +// Until changed +simulator.setPersistentCaller('ALICE'); +simulator.resetCaller(); + +// Resolve an alias for use as a circuit argument +const owner = await simulator.signers.eitherFor('OWNER'); +``` + +> `ownPublicKey()` is a witness value and MUST NOT be used as an authentication +> mechanism. These helpers exist for circuits that take the caller as an input +> to other computations (e.g. commitment derivation). + ### 🔧 Witness Overrides -Perfect for testing edge cases and tracking witness usage: +Perfect for testing edge cases and tracking witness usage. Dry only: the live +backend throws, because witnesses bind at deploy. ```typescript // Override with fixed value for deterministic testing @@ -176,7 +217,7 @@ simulator.overrideWitness('secretValue', (context) => { return [context.privateState, context.privateState.secretValue]; }); -simulator.someOperation(); +await simulator.someOperation(); console.log(`Witness called ${callCount} times`); // Test error conditions @@ -187,23 +228,31 @@ simulator.overrideWitness('requiredValue', (context) => { ### 📊 State Inspection -Access various levels of contract state: +Every getter is async, so dry reads in memory and live reads through the +indexer behind the same call: ```typescript // Get private state -const privateState = simulator.getPrivateState(); +const privateState = await simulator.getPrivateState(); console.log('Secret value:', privateState.secretValue); // Get public ledger state -const ledgerState = simulator.getPublicState(); +const ledgerState = await simulator.getPublicState(); console.log('Public state:', ledgerState); -// Get full contract state -const contractState = simulator.getContractState(); +// Get the raw contract state value +const contractState = await simulator.getContractState(); + +// The deployed address, e.g. to rebuild a digest bound to `kernel.self()` +console.log('Address:', simulator.contractAddress); +``` + +Private state can also be replaced or patched: -// Access complete circuit context -const context = simulator.circuitContext; -console.log('Zswap inputs', context.currentZswapLocalState.inputs); +```typescript +await simulator.setPrivateState(nextState); +await simulator.updatePrivateState({ secretNonce }); +await simulator.updatePrivateState((prev) => ({ ...prev, counter: prev.counter + 1n })); ``` ## Testing Examples @@ -211,22 +260,21 @@ console.log('Zswap inputs', context.currentZswapLocalState.inputs); ### Basic Test Structure ```typescript -import { encodeCoinPublicKey } from '@midnight-ntwrk/compact-runtime'; import { describe, it, expect, beforeEach } from 'vitest'; -import { MyContractSimulator } from './MyContractSimulator'; +import { MyContractSimulator } from './MyContractSimulator.js'; let simulator: MyContractSimulator; -let val = 123n; -let newVal = 456n; +const val = 123n; +const newVal = 456n; describe('MyContract', () => { - beforeEach(() => { - simulator = new MyContractSimulator(val); + beforeEach(async () => { + simulator = await MyContractSimulator.create(val); }); - it('should set new value', () => { - simulator.setVal(newVal); - expect(simulator.getPublicState()._val).toEqual(newVal); + it('sets new value', async () => { + await simulator.setVal(newVal); + expect((await simulator.getPublicState())._val).toEqual(newVal); }); }); ``` @@ -234,7 +282,7 @@ describe('MyContract', () => { ### Testing with Witness Overrides ```typescript -it('should handle custom witness behavior', () => { +it('handles custom witness behavior', async () => { const customValue = new Uint8Array(32).fill(99); let wasCalled = false; @@ -243,7 +291,7 @@ it('should handle custom witness behavior', () => { return [context.privateState, customValue]; }); - simulator.performOperation(); + await simulator.performOperation(); expect(wasCalled).toBe(true); }); @@ -261,6 +309,7 @@ const SimpleSimulatorBase = createSimulator< SimplePrivateState, ReturnType, ReturnType, + SimpleContract, NoArgs // Empty tuple for no arguments >({ contractFactory: (witnesses) => new SimpleContract(witnesses), @@ -271,37 +320,65 @@ const SimpleSimulatorBase = createSimulator< }); export class SimpleSimulator extends SimpleSimulatorBase { - constructor(options: BaseSimulatorOptions<...> = {}) { - super([], options); // Pass empty array + static async create( + options: SimulatorOptions< + SimplePrivateState, + ReturnType + > = {}, + ): Promise { + return super._create([], options) as Promise; // Pass empty array } } ``` ## API Reference -### BaseSimulatorOptions +### SimulatorOptions ```typescript interface BaseSimulatorOptions { privateState?: P; // Initial private state witnesses?: W; // Custom witness implementations coinPK?: CoinPublicKey; // Coin public key (default: '0'.repeat(64)) - contractAddress?: ContractAddress; // Contract address (default: sampleContractAddress()) + contractAddress?: ContractAddress; // Contract address (dry default: dummyContractAddress()) + time?: number; // Block time the kernel observes, in seconds (default: 0) +} + +interface SimulatorOptions extends BaseSimulatorOptions { + backend?: BackendKind; // Pin 'dry' | 'live' instead of reading MIDNIGHT_BACKEND + live?: LiveContext

; // Live only: the caller's live world + signerKeys?: Readonly>; // Dry only: override alias to key derivation + liveAliases?: readonly string[]; // Live only: the prefunded alias pool + resolveLiveKey?: (alias: string) => CoinPublicKey | Promise; } ``` +`time` defaults to `0` so runs are reproducible; the runtime would otherwise +stamp wall-clock time. + +In live mode the address comes from the deployed contract. An explicit +`contractAddress` that does not match it is rejected, so pure circuits reading +`kernel.self()` always observe the deployed address. + ### Core Methods | Method | Description | | ------ | ----------- | -| `overrideWitness(key, fn)` | Override a specific witness function | -| `getPrivateState()` | Get current private state | -| `getPublicState()` | Get current public ledger state | -| `getContractState()` | Get full contract state | +| `static create(...args, options?)` | Construct a simulator (async; overridden per subclass) | +| `contractAddress` | The deployed contract's address | +| `signers` | Alias resolver (`keyFor`, `eitherFor`) for circuit arguments | +| `as(alias)` | Set the caller for the next call only | +| `setPersistentCaller(alias)` / `resetCaller()` | Set or clear the caller for all subsequent calls | +| `getPrivateState()` | Get current private state (async) | +| `setPrivateState(state)` / `updatePrivateState(patch \| fn)` | Replace or patch the private state (async) | +| `getPublicState()` | Get current public ledger state (async) | +| `getContractState()` | Get the raw contract state value (async) | +| `overrideWitness(key, fn)` / `setWitnesses(w)` | Replace witnesses (dry only; live throws) | ## Tips & Best Practices -1. **Type Safety**: Always specify generic parameters for full type safety. -2. **Witness Testing**: Use witness overrides to test edge cases without modifying contract code. -3. **Deterministic Tests**: Override witnesses with fixed values for reproducible tests. -4. **State Validation**: Inspect state after operations to ensure correctness. +1. **Type Safety**: Always specify generic parameters, including the args tuple, for full type safety. +2. **Await Everything**: Construction, circuit calls, and state reads are all async. +3. **Witness Testing**: Use witness overrides to test edge cases without modifying contract code. +4. **Deterministic Tests**: Override witnesses with fixed values and leave `time` at its default for reproducible tests. +5. **State Validation**: Inspect state after operations to ensure correctness. diff --git a/packages/simulator/package.json b/packages/simulator/package.json index 40132e6..6f15a34 100644 --- a/packages/simulator/package.json +++ b/packages/simulator/package.json @@ -28,6 +28,7 @@ "files": [ "dist", "README.md", + "CHANGELOG.md", "LICENSE" ], "engines": {