From be32146b32df21ef595be76f45f594b306765f0b Mon Sep 17 00:00:00 2001 From: andrew Date: Wed, 22 Jul 2026 17:56:22 -0300 Subject: [PATCH 01/12] add mutatable private state in live backend --- packages/simulator/src/backend/Backend.ts | 14 +++++--- packages/simulator/src/backend/DryBackend.ts | 2 +- .../simulator/src/factory/createSimulator.ts | 36 ++++++++++++++++--- packages/simulator/src/index.ts | 5 ++- packages/simulator/src/live/LiveBackend.ts | 18 ++++++---- packages/simulator/src/live/LiveContext.ts | 21 +++++++++++ .../simulator/src/live/createLiveContext.ts | 20 ++++++++++- 7 files changed, 96 insertions(+), 20 deletions(-) diff --git a/packages/simulator/src/backend/Backend.ts b/packages/simulator/src/backend/Backend.ts index 869c3e6..b99cfd6 100644 --- a/packages/simulator/src/backend/Backend.ts +++ b/packages/simulator/src/backend/Backend.ts @@ -71,14 +71,18 @@ export interface Backend { 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()`. + * Replaces the whole private state `P`. Dry mutates the in-memory context; + * live writes to the harness's private-state provider so the next impure + * `callTx` proves against it — but only if the injected {@link LiveContext} + * implements `setPrivateState`; otherwise it throws + * {@link ../live/LiveBackend.PRIVATE_STATE_MUTATION_UNSUPPORTED}. + * + * Async across backends for uniform `await`: dry resolves synchronously, + * live awaits the provider write. * * @param privateState - The new private state `P`. */ - setPrivateState(privateState: P): void; + setPrivateState(privateState: P): Promise; /** * Sets the caller identity for subsequent circuit calls. diff --git a/packages/simulator/src/backend/DryBackend.ts b/packages/simulator/src/backend/DryBackend.ts index 772758e..b86cae8 100644 --- a/packages/simulator/src/backend/DryBackend.ts +++ b/packages/simulator/src/backend/DryBackend.ts @@ -93,7 +93,7 @@ export class DryBackend implements Backend { } /** Mutates the in-memory private state (dry supports mid-test mutation). */ - setPrivateState(privateState: P): void { + async setPrivateState(privateState: P): Promise { this.sim.circuitContextManager.updatePrivateState(privateState); } diff --git a/packages/simulator/src/factory/createSimulator.ts b/packages/simulator/src/factory/createSimulator.ts index 379408b..6b0d77a 100644 --- a/packages/simulator/src/factory/createSimulator.ts +++ b/packages/simulator/src/factory/createSimulator.ts @@ -241,14 +241,40 @@ export function createSimulator< } /** - * 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()`. + * Replaces the whole private state. Dry mutates the in-memory context; live + * writes to the harness's private-state provider so the next impure call + * proves against it (throws if the `LiveContext` opted out of mutation). * * @param privateState - The new private state. */ - setPrivateState(privateState: P): void { - this._backend.setPrivateState(privateState); + setPrivateState(privateState: P): Promise { + return this._backend.setPrivateState(privateState); + } + + /** + * Ergonomic granular private-state mutation. Replaces the per-module + * `injectSecretKey`/`injectSecretNonce` helpers: a plain object shallow- + * merges onto the current state, a function receives the current state and + * returns the next. + * + * Read-modify-write goes through the backend, so it works on both dry + * (in-memory) and live (provider read then write). On live the current + * state must already exist (it is seeded at deploy). + * + * @example sim.updatePrivateState({ secretKey }); + * @example sim.updatePrivateState((prev) => ({ ...prev, counter: prev.counter + 1n })); + * + * @param updater - A partial patch to merge, or an updater function. + */ + async updatePrivateState( + updater: Partial

| ((prev: P) => P), + ): Promise { + const prev = await this._backend.getPrivateState(); + const next = + typeof updater === 'function' + ? (updater as (p: P) => P)(prev) + : { ...prev, ...updater }; + await this._backend.setPrivateState(next); } /** The raw contract state value. */ diff --git a/packages/simulator/src/index.ts b/packages/simulator/src/index.ts index 8f02c5f..a0a6ba0 100644 --- a/packages/simulator/src/index.ts +++ b/packages/simulator/src/index.ts @@ -25,7 +25,10 @@ export { DEFAULT_INDEXER_LAG, } from './live/createLiveContext.js'; export type { LiveBackend } from './live/LiveBackend.js'; -export { WITNESS_OVERRIDE_UNSUPPORTED } from './live/LiveBackend.js'; +export { + PRIVATE_STATE_MUTATION_UNSUPPORTED, + WITNESS_OVERRIDE_UNSUPPORTED, +} from './live/LiveBackend.js'; export type { DeployedTxHandle, FinalizedCallResult, diff --git a/packages/simulator/src/live/LiveBackend.ts b/packages/simulator/src/live/LiveBackend.ts index 7014391..c27a4a7 100644 --- a/packages/simulator/src/live/LiveBackend.ts +++ b/packages/simulator/src/live/LiveBackend.ts @@ -8,9 +8,9 @@ import type { LiveContext } from './LiveContext.js'; export const WITNESS_OVERRIDE_UNSUPPORTED = 'witness override unsupported on live backend'; -/** The error thrown when private state is mutated on the live backend. */ +/** The error thrown when the live `LiveContext` cannot mutate private state. */ export const PRIVATE_STATE_MUTATION_UNSUPPORTED = - 'private-state mutation unsupported on live backend'; + 'private-state mutation unsupported: this LiveContext does not implement setPrivateState'; /** * Dependencies the live adapter is constructed with by `createBackendSimulator`. @@ -119,12 +119,16 @@ export class LiveBackend implements Backend { } /** - * 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. + * Writes private state through the injected {@link LiveContext} so the next + * impure `callTx` proves against it. If the context opted out of mutation + * (no `setPrivateState`), throws {@link PRIVATE_STATE_MUTATION_UNSUPPORTED} + * so the spec fails loudly rather than proving against stale state. */ - setPrivateState(_privateState: P): void { - throw new Error(PRIVATE_STATE_MUTATION_UNSUPPORTED); + async setPrivateState(privateState: P): Promise { + if (!this.ctx.setPrivateState) { + throw new Error(PRIVATE_STATE_MUTATION_UNSUPPORTED); + } + await this.ctx.setPrivateState(privateState); } /** diff --git a/packages/simulator/src/live/LiveContext.ts b/packages/simulator/src/live/LiveContext.ts index c16e45c..0f9120b 100644 --- a/packages/simulator/src/live/LiveContext.ts +++ b/packages/simulator/src/live/LiveContext.ts @@ -66,4 +66,25 @@ export interface LiveContext

{ * parity). */ queryPrivateState(): Promise

; + + /** + * Optional: writes the whole private state to the harness's private-state + * provider, so the NEXT impure `callTx` proves against it. Each `callTx` + * reads private state fresh from the provider (midnight-js `getStates` → + * `privateStateProvider.get`), so no handle invalidation is needed. + * + * Omit to opt out of mutation — {@link LiveBackend} then throws + * {@link LiveBackend.PRIVATE_STATE_MUTATION_UNSUPPORTED}, so a spec that + * mutates fails loudly rather than silently proving against stale state. + * + * Faithful for any client-controlled field of `P` (secret keys, cached + * plaintexts, seeds, nonces) — injecting a hostile/stale value and asserting + * the resulting rejection or handled behavior mirrors a real client. The one + * thing it cannot do is fabricate on-chain state (`L`): a private state that + * presupposes an on-chain event that never happened will not make a happy + * path succeed on a live node. + * + * @param state - The new private state `P`. + */ + setPrivateState?(state: P): Promise; } diff --git a/packages/simulator/src/live/createLiveContext.ts b/packages/simulator/src/live/createLiveContext.ts index 1132dbf..8f3da06 100644 --- a/packages/simulator/src/live/createLiveContext.ts +++ b/packages/simulator/src/live/createLiveContext.ts @@ -54,7 +54,16 @@ export interface CreateLiveContextOptions

{ privateStateId: string; /** Provider for reading on-chain public state. */ publicDataProvider: PublicDataProvider; - /** Provider for reading private state (read parity). */ + /** + * Provider for reading and (optionally) writing private state. + * + * Invariant: this MUST be the same provider instance wired into the + * `providersFor(alias)` bundle handed to `findDeployedContract`. The + * `setPrivateState` write below targets this provider, and the next + * `callTx` reads private state from the bundle's provider; if they are + * different instances the write is invisible to proving. `setContractAddress` + * is the harness's responsibility (already required for `get`). + */ privateStateProvider: PrivateStateProvider; /** Optional override of the indexer-lag policy. */ indexerLag?: Partial; @@ -179,5 +188,14 @@ export function createLiveContext

( } return state; }, + + /** + * Writes the whole private state to the provider under `privateStateId`. + * The next impure `callTx` reads it fresh (no handle-cache invalidation + * needed). See the invariant on {@link CreateLiveContextOptions.privateStateProvider}. + */ + async setPrivateState(state: P): Promise { + await options.privateStateProvider.set(options.privateStateId, state); + }, }; } From ddcd7c20fdf00f28b962b875f4643a78923216a0 Mon Sep 17 00:00:00 2001 From: andrew Date: Wed, 22 Jul 2026 17:57:02 -0300 Subject: [PATCH 02/12] add tests --- .../integration/SampleZOwnableSimulator.ts | 6 +- .../test/integration/Witness.test.ts | 46 +++++++++++--- .../test/integration/WitnessSimulator.ts | 18 ++---- .../test/unit/LiveBackendAdapter.test.ts | 36 ++++++++++- .../test/unit/createLiveContext.test.ts | 60 +++++++++++++++++++ 5 files changed, 141 insertions(+), 25 deletions(-) create mode 100644 packages/simulator/test/unit/createLiveContext.test.ts diff --git a/packages/simulator/test/integration/SampleZOwnableSimulator.ts b/packages/simulator/test/integration/SampleZOwnableSimulator.ts index 415feaf..eabdf01 100644 --- a/packages/simulator/test/integration/SampleZOwnableSimulator.ts +++ b/packages/simulator/test/integration/SampleZOwnableSimulator.ts @@ -130,10 +130,8 @@ export class SampleZOwnableSimulator extends SampleZOwnableSimulatorBase { injectSecretNonce: async ( newNonce: Buffer, ): Promise => { - const currentState = await this.getPrivateState(); - const updatedState = { ...currentState, secretNonce: newNonce }; - this.setPrivateState(updatedState); - return updatedState; + await this.updatePrivateState({ secretNonce: newNonce }); + return this.getPrivateState(); }, /** diff --git a/packages/simulator/test/integration/Witness.test.ts b/packages/simulator/test/integration/Witness.test.ts index 147ee11..c45b17c 100644 --- a/packages/simulator/test/integration/Witness.test.ts +++ b/packages/simulator/test/integration/Witness.test.ts @@ -200,15 +200,45 @@ describe('witness/private state overrides', () => { }); }); - describe('private state overrides', () => { - it('should match ps ', async () => { - // Private state - const ps = await contract.getPrivateState(); - void ps.secretBytes; - void ps.secretField; - void ps.secretUint; + describe('private state mutation', () => { + it('replaces the whole private state via setPrivateState', async () => { + const next: WitnessPrivateState = { + secretBytes: new Uint8Array(32).fill(5), + secretField: 11n, + secretUint: 22n, + }; + await contract.setPrivateState(next); + expect(await contract.getPrivateState()).toEqual(next); + }); + + it('patch-merges only the given fields, preserving the rest', async () => { + const before = await contract.getPrivateState(); + await contract.updatePrivateState({ secretField: FIELD_OVERRIDE }); + + const after = await contract.getPrivateState(); + expect(after.secretField).toEqual(FIELD_OVERRIDE); + // Untouched fields survive the merge. + expect(after.secretBytes).toEqual(before.secretBytes); + expect(after.secretUint).toEqual(before.secretUint); }); - it('should override the entire private state', () => {}); + it('supports the updater-function form (prev → next)', async () => { + const before = await contract.getPrivateState(); + await contract.updatePrivateState((prev) => ({ + ...prev, + secretUint: prev.secretUint + 100n, + })); + expect((await contract.getPrivateState()).secretUint).toEqual( + before.secretUint + 100n, + ); + }); + + it('feeds mutated private state into subsequent circuit calls', async () => { + // secretBytes is written to public state by setBytes() via the witness. + const injected = new Uint8Array(32).fill(9); + await contract.updatePrivateState({ secretBytes: injected }); + await contract.setBytes(); + expect((await contract.getPublicState())._valBytes).toEqual(injected); + }); }); }); diff --git a/packages/simulator/test/integration/WitnessSimulator.ts b/packages/simulator/test/integration/WitnessSimulator.ts index 04e3026..e299b54 100644 --- a/packages/simulator/test/integration/WitnessSimulator.ts +++ b/packages/simulator/test/integration/WitnessSimulator.ts @@ -64,24 +64,18 @@ export class WitnessSimulator extends WitnessSimulatorBase { injectSecretBytes: async ( newBytes: Buffer, ): Promise => { - const currentState = await this.getPrivateState(); - const updatedState = { ...currentState, secretBytes: newBytes }; - this.setPrivateState(updatedState); - return updatedState; + await this.updatePrivateState({ secretBytes: newBytes }); + return this.getPrivateState(); }, injectSecretField: async ( newField: bigint, ): Promise => { - const currentState = await this.getPrivateState(); - const updatedState = { ...currentState, secretField: newField }; - this.setPrivateState(updatedState); - return updatedState; + await this.updatePrivateState({ secretField: newField }); + return this.getPrivateState(); }, injectSecretUint: async (newUint: bigint): Promise => { - const currentState = await this.getPrivateState(); - const updatedState = { ...currentState, secretUint: newUint }; - this.setPrivateState(updatedState); - return updatedState; + await this.updatePrivateState({ secretUint: newUint }); + return this.getPrivateState(); }, }; } diff --git a/packages/simulator/test/unit/LiveBackendAdapter.test.ts b/packages/simulator/test/unit/LiveBackendAdapter.test.ts index 3951fb1..21b4c04 100644 --- a/packages/simulator/test/unit/LiveBackendAdapter.test.ts +++ b/packages/simulator/test/unit/LiveBackendAdapter.test.ts @@ -1,7 +1,10 @@ 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 { + LiveBackend, + PRIVATE_STATE_MUTATION_UNSUPPORTED, +} from '../../src/live/LiveBackend.js'; import type { DeployedTxHandle, LiveContext, @@ -134,4 +137,35 @@ describe('LiveBackend adapter', () => { const { backend } = makeBackend({}); expect(await backend.getPrivateState()).toEqual({ secret: 7 }); }); + + it('throws when the LiveContext opts out of private-state mutation', async () => { + // FakeWorld implements no setPrivateState. + const { backend } = makeBackend({}); + await expect(backend.setPrivateState({ secret: 9 })).rejects.toThrow( + PRIVATE_STATE_MUTATION_UNSUPPORTED, + ); + }); + + it('delegates private-state mutation to the LiveContext (read-after-write)', async () => { + /** A world that stores private state, so a write is observable by a read. */ + class MutableWorld extends FakeWorld { + private stored = { secret: 7 }; + override async queryPrivateState() { + return this.stored; + } + async setPrivateState(state: { secret: number }) { + this.stored = state; + } + } + const world = new MutableWorld({}); + const backend = new LiveBackend<{ secret: number }, Ledger>({ + ctx: world, + pureSim: fakePureSim({}), + signers: new Signers({ mode: 'live', liveAliases: ['OWNER'] }), + ledgerExtractor: (state) => state as unknown as Ledger, + }); + + await backend.setPrivateState({ secret: 42 }); + expect(await backend.getPrivateState()).toEqual({ secret: 42 }); + }); }); diff --git a/packages/simulator/test/unit/createLiveContext.test.ts b/packages/simulator/test/unit/createLiveContext.test.ts new file mode 100644 index 0000000..6153bfd --- /dev/null +++ b/packages/simulator/test/unit/createLiveContext.test.ts @@ -0,0 +1,60 @@ +import type { PrivateStateProvider } from '@midnight-ntwrk/midnight-js-types'; +import { describe, expect, it } from 'vitest'; +import { createLiveContext } from '../../src/live/createLiveContext.js'; + +type PS = { secretKey: Uint8Array }; + +/** + * An in-memory stand-in for the harness's `PrivateStateProvider`, exercising + * only the `get`/`set` slice `createLiveContext` uses. `findDeployedContract` + * (and thus midnight-js) is never reached, so these tests run without the + * optional live peers installed. + */ +const fakeProvider = (initial: Record = {}) => { + const store = new Map(Object.entries(initial)); + const calls: Array<{ id: string; state: PS }> = []; + const provider = { + async get(id: string) { + return store.get(id) ?? null; + }, + async set(id: string, state: PS) { + calls.push({ id, state }); + store.set(id, state); + }, + } as unknown as PrivateStateProvider; + return { provider, calls }; +}; + +const makeContext = (provider: PrivateStateProvider) => + createLiveContext({ + contractAddress: '0200cafef00d', + providersFor: () => ({}), + compiledContract: {}, + privateStateId: 'my-contract', + publicDataProvider: {} as never, + privateStateProvider: provider, + }); + +describe('createLiveContext private-state write', () => { + it('writes the whole private state to the provider under privateStateId', async () => { + const { provider, calls } = fakeProvider(); + const ctx = makeContext(provider); + + const sk = Uint8Array.of(1, 2, 3); + await ctx.setPrivateState?.({ secretKey: sk }); + + expect(calls).toEqual([{ id: 'my-contract', state: { secretKey: sk } }]); + }); + + it('is observable by queryPrivateState (read-after-write parity)', async () => { + const { provider } = fakeProvider({ + 'my-contract': { secretKey: Uint8Array.of(0) }, + }); + const ctx = makeContext(provider); + + const sk = Uint8Array.of(9, 9, 9); + await ctx.setPrivateState?.({ secretKey: sk }); + + expect(await ctx.queryPrivateState()).toEqual({ secretKey: sk }); + }); +}); From af51044becd617bae445593ecd7d5e5dd0eada7b Mon Sep 17 00:00:00 2001 From: andrew Date: Thu, 23 Jul 2026 01:41:49 -0300 Subject: [PATCH 03/12] fix doc --- packages/simulator/src/backend/Backend.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/simulator/src/backend/Backend.ts b/packages/simulator/src/backend/Backend.ts index b99cfd6..e9e1c80 100644 --- a/packages/simulator/src/backend/Backend.ts +++ b/packages/simulator/src/backend/Backend.ts @@ -73,7 +73,7 @@ export interface Backend { /** * Replaces the whole private state `P`. Dry mutates the in-memory context; * live writes to the harness's private-state provider so the next impure - * `callTx` proves against it — but only if the injected {@link LiveContext} + * `callTx` proves against it but only if the injected {@link LiveContext} * implements `setPrivateState`; otherwise it throws * {@link ../live/LiveBackend.PRIVATE_STATE_MUTATION_UNSUPPORTED}. * From 7a07f8d5f50f58cc7c0e0be06911d34f6904ddee Mon Sep 17 00:00:00 2001 From: andrew Date: Thu, 23 Jul 2026 02:04:30 -0300 Subject: [PATCH 04/12] add live mutation tests --- .../test/integration/LiveMutation.test.ts | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 packages/simulator/test/integration/LiveMutation.test.ts diff --git a/packages/simulator/test/integration/LiveMutation.test.ts b/packages/simulator/test/integration/LiveMutation.test.ts new file mode 100644 index 0000000..64ac467 --- /dev/null +++ b/packages/simulator/test/integration/LiveMutation.test.ts @@ -0,0 +1,96 @@ +import type { StateValue } from '@midnight-ntwrk/compact-runtime'; +import { describe, expect, it } from 'vitest'; +import { PRIVATE_STATE_MUTATION_UNSUPPORTED } from '../../src/index'; +import type { + DeployedTxHandle, + LiveContext, +} from '../../src/live/LiveContext.js'; +import { WitnessPrivateState } from '../fixtures/sample-contracts/witnesses/WitnessWitnesses'; +import { WitnessSimulator } from './WitnessSimulator'; + +/** + * A mutable in-memory live world. Only the private-state read/write paths are + * exercised here, so `handleFor`/`queryLedger` are inert stubs (no node, no + * midnight-js). `backend: 'live'` is forced via options so this runs under the + * dry test runner while still driving the LiveBackend code path. + */ +const makeWorld = ( + initial: WitnessPrivateState, + opts: { mutable?: boolean } = {}, +): LiveContext => { + let stored = initial; + const world: LiveContext = { + contractAddress: '0200deadbeef', + async handleFor(): Promise { + return { callTx: {} }; + }, + async queryLedger(): Promise { + return {} as unknown as StateValue; + }, + async queryPrivateState(): Promise { + return stored; + }, + }; + if (opts.mutable !== false) { + world.setPrivateState = async (state) => { + stored = state; + }; + } + return world; +}; + +describe('private-state mutation over the live backend (mock world)', () => { + it('round-trips updatePrivateState read-modify-write through the provider', async () => { + const world = makeWorld(WitnessPrivateState.generate()); + const sim = await WitnessSimulator.create({ backend: 'live', live: world }); + expect(sim.backendKind).toBe('live'); + + await sim.updatePrivateState({ secretField: 999n }); + + expect((await sim.getPrivateState()).secretField).toEqual(999n); + }); + + it('composes the updater form over live', async () => { + const world = makeWorld({ + ...WitnessPrivateState.generate(), + secretUint: 5n, + }); + const sim = await WitnessSimulator.create({ backend: 'live', live: world }); + + await sim.updatePrivateState((p) => ({ + ...p, + secretUint: p.secretUint + 10n, + })); + + expect((await sim.getPrivateState()).secretUint).toEqual(15n); + }); + + it('preserves untouched fields when patching over live', async () => { + const seed = WitnessPrivateState.generate(); + const world = makeWorld(seed); + const sim = await WitnessSimulator.create({ backend: 'live', live: world }); + + await sim.updatePrivateState({ secretField: 42n }); + + const after = await sim.getPrivateState(); + expect(after.secretField).toEqual(42n); + expect(after.secretBytes).toEqual(seed.secretBytes); + expect(after.secretUint).toEqual(seed.secretUint); + }); + + it('throws through updatePrivateState when the world opts out of mutation', async () => { + const world = makeWorld(WitnessPrivateState.generate(), { mutable: false }); + const sim = await WitnessSimulator.create({ backend: 'live', live: world }); + + await expect(sim.updatePrivateState({ secretField: 1n })).rejects.toThrow( + PRIVATE_STATE_MUTATION_UNSUPPORTED, + ); + // Full-replace path throws too. + await expect( + sim.setPrivateState({ + ...(await sim.getPrivateState()), + secretField: 1n, + }), + ).rejects.toThrow(PRIVATE_STATE_MUTATION_UNSUPPORTED); + }); +}); From a7a8af4fd3dae32e6a92e56851a86e6bf2319544 Mon Sep 17 00:00:00 2001 From: andrew Date: Thu, 23 Jul 2026 04:03:58 -0300 Subject: [PATCH 05/12] serialize rmw sequence --- .../simulator/src/factory/createSimulator.ts | 45 ++++++++++++++----- 1 file changed, 35 insertions(+), 10 deletions(-) diff --git a/packages/simulator/src/factory/createSimulator.ts b/packages/simulator/src/factory/createSimulator.ts index 6b0d77a..fdd09fb 100644 --- a/packages/simulator/src/factory/createSimulator.ts +++ b/packages/simulator/src/factory/createSimulator.ts @@ -147,6 +147,12 @@ export function createSimulator< readonly _backend: Backend; readonly _signers: Signers; + /** + * Tail of the private-state mutation queue. Serializes read-modify-write so + * concurrent mutations can't interleave and lose an update. Internal. + */ + _psMutationChain: Promise = Promise.resolve(); + /** Async circuit proxies; every call returns a promise. */ readonly circuits: { pure: AsyncCircuits, P>; @@ -247,8 +253,27 @@ export function createSimulator< * * @param privateState - The new private state. */ + /** + * Serializes a private-state mutation against this simulator's queue so a + * read-modify-write can't interleave with another mutation and drop an + * update. A rejection propagates to its caller but does not poison the + * queue for subsequent mutations. Internal. + * + * @param op - The mutation to run once the queue drains. + */ + _enqueuePsMutation(op: () => Promise): Promise { + const run = this._psMutationChain.then(op); + this._psMutationChain = run.then( + () => undefined, + () => undefined, + ); + return run; + } + setPrivateState(privateState: P): Promise { - return this._backend.setPrivateState(privateState); + return this._enqueuePsMutation(() => + this._backend.setPrivateState(privateState), + ); } /** @@ -266,15 +291,15 @@ export function createSimulator< * * @param updater - A partial patch to merge, or an updater function. */ - async updatePrivateState( - updater: Partial

| ((prev: P) => P), - ): Promise { - const prev = await this._backend.getPrivateState(); - const next = - typeof updater === 'function' - ? (updater as (p: P) => P)(prev) - : { ...prev, ...updater }; - await this._backend.setPrivateState(next); + updatePrivateState(updater: Partial

| ((prev: P) => P)): Promise { + return this._enqueuePsMutation(async () => { + const prev = await this._backend.getPrivateState(); + const next = + typeof updater === 'function' + ? (updater as (p: P) => P)(prev) + : { ...prev, ...updater }; + await this._backend.setPrivateState(next); + }); } /** The raw contract state value. */ From bc494719e96dc3aaae365819b7b3ddbaa602a36a Mon Sep 17 00:00:00 2001 From: andrew Date: Thu, 23 Jul 2026 04:05:38 -0300 Subject: [PATCH 06/12] use buffer in tests --- .../test/integration/Witness.test.ts | 23 ++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/packages/simulator/test/integration/Witness.test.ts b/packages/simulator/test/integration/Witness.test.ts index c45b17c..7696a2f 100644 --- a/packages/simulator/test/integration/Witness.test.ts +++ b/packages/simulator/test/integration/Witness.test.ts @@ -203,7 +203,7 @@ describe('witness/private state overrides', () => { describe('private state mutation', () => { it('replaces the whole private state via setPrivateState', async () => { const next: WitnessPrivateState = { - secretBytes: new Uint8Array(32).fill(5), + secretBytes: Buffer.alloc(32, 5), secretField: 11n, secretUint: 22n, }; @@ -233,12 +233,29 @@ describe('witness/private state overrides', () => { ); }); + it('serializes concurrent updates so neither patch is lost', async () => { + const before = await contract.getPrivateState(); + // Both read the same prev if unserialized; the last write would then + // clobber the other field. Serialization must land both. + await Promise.all([ + contract.updatePrivateState({ secretField: 77n }), + contract.updatePrivateState({ secretUint: 88n }), + ]); + + const after = await contract.getPrivateState(); + expect(after.secretField).toEqual(77n); + expect(after.secretUint).toEqual(88n); + expect(after.secretBytes).toEqual(before.secretBytes); + }); + it('feeds mutated private state into subsequent circuit calls', async () => { // secretBytes is written to public state by setBytes() via the witness. - const injected = new Uint8Array(32).fill(9); + const injected = Buffer.alloc(32, 9); await contract.updatePrivateState({ secretBytes: injected }); await contract.setBytes(); - expect((await contract.getPublicState())._valBytes).toEqual(injected); + expect((await contract.getPublicState())._valBytes).toEqual( + new Uint8Array(injected), + ); }); }); }); From c52c246f2798219aafb573fd9c70353e4dc9e97b Mon Sep 17 00:00:00 2001 From: andrew Date: Thu, 23 Jul 2026 04:06:21 -0300 Subject: [PATCH 07/12] fix tsdoc links --- packages/simulator/src/backend/Backend.ts | 4 ++-- packages/simulator/src/live/LiveContext.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/simulator/src/backend/Backend.ts b/packages/simulator/src/backend/Backend.ts index e9e1c80..39f0d1d 100644 --- a/packages/simulator/src/backend/Backend.ts +++ b/packages/simulator/src/backend/Backend.ts @@ -73,9 +73,9 @@ export interface Backend { /** * Replaces the whole private state `P`. Dry mutates the in-memory context; * live writes to the harness's private-state provider so the next impure - * `callTx` proves against it but only if the injected {@link LiveContext} + * `callTx` proves against it but only if the injected `LiveContext` * implements `setPrivateState`; otherwise it throws - * {@link ../live/LiveBackend.PRIVATE_STATE_MUTATION_UNSUPPORTED}. + * `PRIVATE_STATE_MUTATION_UNSUPPORTED` (from the live backend). * * Async across backends for uniform `await`: dry resolves synchronously, * live awaits the provider write. diff --git a/packages/simulator/src/live/LiveContext.ts b/packages/simulator/src/live/LiveContext.ts index 0f9120b..b2a0ff7 100644 --- a/packages/simulator/src/live/LiveContext.ts +++ b/packages/simulator/src/live/LiveContext.ts @@ -74,8 +74,8 @@ export interface LiveContext

{ * `privateStateProvider.get`), so no handle invalidation is needed. * * Omit to opt out of mutation — {@link LiveBackend} then throws - * {@link LiveBackend.PRIVATE_STATE_MUTATION_UNSUPPORTED}, so a spec that - * mutates fails loudly rather than silently proving against stale state. + * `PRIVATE_STATE_MUTATION_UNSUPPORTED`, so a spec that mutates fails loudly + * rather than silently proving against stale state. * * Faithful for any client-controlled field of `P` (secret keys, cached * plaintexts, seeds, nonces) — injecting a hostile/stale value and asserting From 2027cc3b187a17d8b424161ce199b00b46927db3 Mon Sep 17 00:00:00 2001 From: andrew Date: Thu, 23 Jul 2026 14:01:15 -0300 Subject: [PATCH 08/12] resolve updatePrivateState to the written state --- .../simulator/src/factory/createSimulator.ts | 11 ++++++++-- .../integration/SampleZOwnableSimulator.ts | 8 +++---- .../test/integration/WitnessSimulator.ts | 22 ++++++------------- 3 files changed, 19 insertions(+), 22 deletions(-) diff --git a/packages/simulator/src/factory/createSimulator.ts b/packages/simulator/src/factory/createSimulator.ts index fdd09fb..3a8d6ff 100644 --- a/packages/simulator/src/factory/createSimulator.ts +++ b/packages/simulator/src/factory/createSimulator.ts @@ -260,8 +260,9 @@ export function createSimulator< * queue for subsequent mutations. Internal. * * @param op - The mutation to run once the queue drains. + * @returns `op`'s result. */ - _enqueuePsMutation(op: () => Promise): Promise { + _enqueuePsMutation(op: () => Promise): Promise { const run = this._psMutationChain.then(op); this._psMutationChain = run.then( () => undefined, @@ -286,12 +287,17 @@ export function createSimulator< * (in-memory) and live (provider read then write). On live the current * state must already exist (it is seeded at deploy). * + * Resolves to the state that was written, so callers can + * `return sim.updatePrivateState(...)` without a follow-up `getPrivateState()` + * (which would otherwise race the queued write). + * * @example sim.updatePrivateState({ secretKey }); * @example sim.updatePrivateState((prev) => ({ ...prev, counter: prev.counter + 1n })); * * @param updater - A partial patch to merge, or an updater function. + * @returns The private state that was written. */ - updatePrivateState(updater: Partial

| ((prev: P) => P)): Promise { + updatePrivateState(updater: Partial

| ((prev: P) => P)): Promise

{ return this._enqueuePsMutation(async () => { const prev = await this._backend.getPrivateState(); const next = @@ -299,6 +305,7 @@ export function createSimulator< ? (updater as (p: P) => P)(prev) : { ...prev, ...updater }; await this._backend.setPrivateState(next); + return next; }); } diff --git a/packages/simulator/test/integration/SampleZOwnableSimulator.ts b/packages/simulator/test/integration/SampleZOwnableSimulator.ts index eabdf01..94be4e0 100644 --- a/packages/simulator/test/integration/SampleZOwnableSimulator.ts +++ b/packages/simulator/test/integration/SampleZOwnableSimulator.ts @@ -127,12 +127,10 @@ export class SampleZOwnableSimulator extends SampleZOwnableSimulatorBase { * @param newNonce The secret nonce. * @returns The SampleZOwnable private state after setting the new nonce. */ - injectSecretNonce: async ( + injectSecretNonce: ( newNonce: Buffer, - ): Promise => { - await this.updatePrivateState({ secretNonce: newNonce }); - return this.getPrivateState(); - }, + ): Promise => + this.updatePrivateState({ secretNonce: newNonce }), /** * @description Returns the secret nonce given the context. diff --git a/packages/simulator/test/integration/WitnessSimulator.ts b/packages/simulator/test/integration/WitnessSimulator.ts index e299b54..914fbcd 100644 --- a/packages/simulator/test/integration/WitnessSimulator.ts +++ b/packages/simulator/test/integration/WitnessSimulator.ts @@ -61,21 +61,13 @@ export class WitnessSimulator extends WitnessSimulatorBase { } public readonly privateState = { - injectSecretBytes: async ( + injectSecretBytes: ( newBytes: Buffer, - ): Promise => { - await this.updatePrivateState({ secretBytes: newBytes }); - return this.getPrivateState(); - }, - injectSecretField: async ( - newField: bigint, - ): Promise => { - await this.updatePrivateState({ secretField: newField }); - return this.getPrivateState(); - }, - injectSecretUint: async (newUint: bigint): Promise => { - await this.updatePrivateState({ secretUint: newUint }); - return this.getPrivateState(); - }, + ): Promise => + this.updatePrivateState({ secretBytes: newBytes }), + injectSecretField: (newField: bigint): Promise => + this.updatePrivateState({ secretField: newField }), + injectSecretUint: (newUint: bigint): Promise => + this.updatePrivateState({ secretUint: newUint }), }; } From ef87666178924de7cdf2299b98637c97018a438d Mon Sep 17 00:00:00 2001 From: andrew Date: Thu, 23 Jul 2026 14:06:06 -0300 Subject: [PATCH 09/12] fix docs --- packages/simulator/src/factory/createSimulator.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/simulator/src/factory/createSimulator.ts b/packages/simulator/src/factory/createSimulator.ts index 3a8d6ff..1dfce28 100644 --- a/packages/simulator/src/factory/createSimulator.ts +++ b/packages/simulator/src/factory/createSimulator.ts @@ -246,13 +246,6 @@ export function createSimulator< return this._backend.getPrivateState(); } - /** - * Replaces the whole private state. Dry mutates the in-memory context; live - * writes to the harness's private-state provider so the next impure call - * proves against it (throws if the `LiveContext` opted out of mutation). - * - * @param privateState - The new private state. - */ /** * Serializes a private-state mutation against this simulator's queue so a * read-modify-write can't interleave with another mutation and drop an @@ -271,6 +264,13 @@ export function createSimulator< return run; } + /** + * Replaces the whole private state. Dry mutates the in-memory context; live + * writes to the harness's private-state provider so the next impure call + * proves against it (throws if the `LiveContext` opted out of mutation). + * + * @param privateState - The new private state. + */ setPrivateState(privateState: P): Promise { return this._enqueuePsMutation(() => this._backend.setPrivateState(privateState), From 9cc0735566d68b28a70b26af67d20acd73a6519e Mon Sep 17 00:00:00 2001 From: andrew Date: Thu, 23 Jul 2026 14:08:38 -0300 Subject: [PATCH 10/12] document per-instance scope of queue --- packages/simulator/src/factory/createSimulator.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/simulator/src/factory/createSimulator.ts b/packages/simulator/src/factory/createSimulator.ts index 1dfce28..98eec6d 100644 --- a/packages/simulator/src/factory/createSimulator.ts +++ b/packages/simulator/src/factory/createSimulator.ts @@ -150,6 +150,14 @@ export function createSimulator< /** * Tail of the private-state mutation queue. Serializes read-modify-write so * concurrent mutations can't interleave and lose an update. Internal. + * + * The guarantee is scoped to this simulator instance. On live, two + * simulators (or two processes) sharing the same `privateStateProvider` and + * `privateStateId` still race a read-modify-write — both read the provider, + * last write wins — since this queue is local. Cross-instance atomicity + * would need provider-side compare-and-set/versioning and is out of scope; + * tests drive one simulator per private state, so the per-instance queue is + * sufficient in practice. */ _psMutationChain: Promise = Promise.resolve(); From 965688c60c6e66940af6bdc590fe81d81261f11b Mon Sep 17 00:00:00 2001 From: andrew Date: Thu, 23 Jul 2026 14:13:53 -0300 Subject: [PATCH 11/12] extract PrivateStateMutator --- .../simulator/src/core/PrivateStateMutator.ts | 81 +++++++++++++++++++ .../simulator/src/factory/createSimulator.ts | 66 +++++---------- packages/simulator/src/index.ts | 1 + 3 files changed, 100 insertions(+), 48 deletions(-) create mode 100644 packages/simulator/src/core/PrivateStateMutator.ts diff --git a/packages/simulator/src/core/PrivateStateMutator.ts b/packages/simulator/src/core/PrivateStateMutator.ts new file mode 100644 index 0000000..13d7a39 --- /dev/null +++ b/packages/simulator/src/core/PrivateStateMutator.ts @@ -0,0 +1,81 @@ +/** + * Serializes read-modify-write access to a contract's private state. + * + * Built from a backend's `getPrivateState`/`setPrivateState` pair, it queues + * every mutation so a read-modify-write (`update`) can't interleave with another + * mutation and lose an update (a lost-update race). It is backend-agnostic: the + * dry, live, and future `sim` backends all reuse it by supplying their own + * read/write closures. + * + * The guarantee is scoped to this instance. On live, two `PrivateStateMutator`s + * (or two processes) targeting the same `privateStateProvider` + `privateStateId` + * still race — both read the provider, last write wins — since the queue is + * local. Cross-instance atomicity would need provider-side + * compare-and-set/versioning and is out of scope; tests drive one simulator per + * private state, so the per-instance queue is sufficient in practice. + * + * @template P - Private state type. + */ +export class PrivateStateMutator

{ + /** Tail of the mutation queue; each op runs after the previous drains. */ + #chain: Promise = Promise.resolve(); + readonly #read: () => Promise

; + readonly #write: (next: P) => Promise; + + /** + * @param read - Reads the current private state (backend `getPrivateState`). + * @param write - Replaces the whole private state (backend `setPrivateState`). + */ + constructor(read: () => Promise

, write: (next: P) => Promise) { + this.#read = read; + this.#write = write; + } + + /** + * Runs `op` once the queue drains, serialized against every other mutation. + * The returned promise settles with `op`'s result (or rejection) for the + * caller; the queue tail deliberately swallows rejections so a failed op does + * not poison subsequent mutations. + * + * @param op - The operation to run once the queue drains. + * @returns `op`'s result. + */ + enqueue(op: () => Promise): Promise { + const run = this.#chain.then(op); + this.#chain = run.then( + () => undefined, + () => undefined, + ); + return run; + } + + /** + * Replaces the whole private state, serialized against other mutations. + * + * @param next - The new private state. + */ + set(next: P): Promise { + return this.enqueue(() => this.#write(next)); + } + + /** + * Read-modify-write, serialized end-to-end so the read and write are atomic + * against other mutations on this instance. Resolves to the state that was + * written, so callers need no follow-up read. + * + * @param updater - A partial patch to shallow-merge, or a function that + * receives the current state and returns the next. + * @returns The private state that was written. + */ + update(updater: Partial

| ((prev: P) => P)): Promise

{ + return this.enqueue(async () => { + const prev = await this.#read(); + const next = + typeof updater === 'function' + ? (updater as (p: P) => P)(prev) + : { ...prev, ...updater }; + await this.#write(next); + return next; + }); + } +} diff --git a/packages/simulator/src/factory/createSimulator.ts b/packages/simulator/src/factory/createSimulator.ts index 98eec6d..415a739 100644 --- a/packages/simulator/src/factory/createSimulator.ts +++ b/packages/simulator/src/factory/createSimulator.ts @@ -1,5 +1,6 @@ import type { Backend, BackendKind, CircuitKind } from '../backend/Backend.js'; import { DryBackend, type SyncSimulator } from '../backend/DryBackend.js'; +import { PrivateStateMutator } from '../core/PrivateStateMutator.js'; import type { LiveContext } from '../live/LiveContext.js'; import { getRegisteredLiveBackend } from '../live/registry.js'; import { Signers } from '../signers/Signers.js'; @@ -148,18 +149,10 @@ export function createSimulator< readonly _signers: Signers; /** - * Tail of the private-state mutation queue. Serializes read-modify-write so - * concurrent mutations can't interleave and lose an update. Internal. - * - * The guarantee is scoped to this simulator instance. On live, two - * simulators (or two processes) sharing the same `privateStateProvider` and - * `privateStateId` still race a read-modify-write — both read the provider, - * last write wins — since this queue is local. Cross-instance atomicity - * would need provider-side compare-and-set/versioning and is out of scope; - * tests drive one simulator per private state, so the per-instance queue is - * sufficient in practice. + * Serializes private-state read-modify-write for this instance. Internal; + * see {@link PrivateStateMutator} for the scope of the guarantee. */ - _psMutationChain: Promise = Promise.resolve(); + readonly _mutator: PrivateStateMutator

; /** Async circuit proxies; every call returns a promise. */ readonly circuits: { @@ -177,6 +170,12 @@ export function createSimulator< this._backend = deps.backend; this.backendKind = deps.backend.kind; this._signers = deps.signers; + // Constructed here (not as a field initializer) so the closures capture + // the assigned `_backend` rather than an undefined one. + this._mutator = new PrivateStateMutator

( + () => this._backend.getPrivateState(), + (next) => this._backend.setPrivateState(next), + ); this.circuits = { pure: buildProxy( this._backend, @@ -254,35 +253,16 @@ export function createSimulator< return this._backend.getPrivateState(); } - /** - * Serializes a private-state mutation against this simulator's queue so a - * read-modify-write can't interleave with another mutation and drop an - * update. A rejection propagates to its caller but does not poison the - * queue for subsequent mutations. Internal. - * - * @param op - The mutation to run once the queue drains. - * @returns `op`'s result. - */ - _enqueuePsMutation(op: () => Promise): Promise { - const run = this._psMutationChain.then(op); - this._psMutationChain = run.then( - () => undefined, - () => undefined, - ); - return run; - } - /** * Replaces the whole private state. Dry mutates the in-memory context; live * writes to the harness's private-state provider so the next impure call * proves against it (throws if the `LiveContext` opted out of mutation). + * Serialized against other mutations via {@link _mutator}. * * @param privateState - The new private state. */ setPrivateState(privateState: P): Promise { - return this._enqueuePsMutation(() => - this._backend.setPrivateState(privateState), - ); + return this._mutator.set(privateState); } /** @@ -291,13 +271,11 @@ export function createSimulator< * merges onto the current state, a function receives the current state and * returns the next. * - * Read-modify-write goes through the backend, so it works on both dry - * (in-memory) and live (provider read then write). On live the current - * state must already exist (it is seeded at deploy). - * - * Resolves to the state that was written, so callers can - * `return sim.updatePrivateState(...)` without a follow-up `getPrivateState()` - * (which would otherwise race the queued write). + * The read-modify-write is serialized (see {@link _mutator}) and resolves to + * the state that was written, so callers can `return sim.updatePrivateState(...)` + * without a follow-up `getPrivateState()`. Works on both dry (in-memory) and + * live (provider read then write); on live the current state must already + * exist (it is seeded at deploy). * * @example sim.updatePrivateState({ secretKey }); * @example sim.updatePrivateState((prev) => ({ ...prev, counter: prev.counter + 1n })); @@ -306,15 +284,7 @@ export function createSimulator< * @returns The private state that was written. */ updatePrivateState(updater: Partial

| ((prev: P) => P)): Promise

{ - return this._enqueuePsMutation(async () => { - const prev = await this._backend.getPrivateState(); - const next = - typeof updater === 'function' - ? (updater as (p: P) => P)(prev) - : { ...prev, ...updater }; - await this._backend.setPrivateState(next); - return next; - }); + return this._mutator.update(updater); } /** The raw contract state value. */ diff --git a/packages/simulator/src/index.ts b/packages/simulator/src/index.ts index a0a6ba0..5a6ef21 100644 --- a/packages/simulator/src/index.ts +++ b/packages/simulator/src/index.ts @@ -7,6 +7,7 @@ export { DryBackend } from './backend/DryBackend.js'; export { AbstractSimulator } from './core/AbstractSimulator.js'; export { CircuitContextManager } from './core/CircuitContextManager.js'; export { ContractSimulator } from './core/ContractSimulator.js'; +export { PrivateStateMutator } from './core/PrivateStateMutator.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 From 029c7d1481963737b90c3eb87f483f486ec9365a Mon Sep 17 00:00:00 2001 From: andrew Date: Thu, 23 Jul 2026 14:16:21 -0300 Subject: [PATCH 12/12] add PrivateStateMutator tests --- .../unit/core/PrivateStateMutator.test.ts | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 packages/simulator/test/unit/core/PrivateStateMutator.test.ts diff --git a/packages/simulator/test/unit/core/PrivateStateMutator.test.ts b/packages/simulator/test/unit/core/PrivateStateMutator.test.ts new file mode 100644 index 0000000..461c061 --- /dev/null +++ b/packages/simulator/test/unit/core/PrivateStateMutator.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest'; +import { PrivateStateMutator } from '../../../src/core/PrivateStateMutator.js'; + +type PS = { a: number; b: number }; + +/** A mutator over an in-memory state, for exercising the queue in isolation. */ +const makeMutator = (initial: PS) => { + let state = initial; + const mutator = new PrivateStateMutator( + async () => state, + async (next) => { + state = next; + }, + ); + return { mutator, get: () => state }; +}; + +describe('PrivateStateMutator', () => { + it('set replaces the whole state', async () => { + const { mutator, get } = makeMutator({ a: 1, b: 2 }); + await mutator.set({ a: 9, b: 9 }); + expect(get()).toEqual({ a: 9, b: 9 }); + }); + + it('update shallow-merges a patch and resolves to the written state', async () => { + const { mutator, get } = makeMutator({ a: 1, b: 2 }); + const next = await mutator.update({ b: 5 }); + expect(next).toEqual({ a: 1, b: 5 }); + expect(get()).toEqual({ a: 1, b: 5 }); + }); + + it('update supports the updater-function form', async () => { + const { mutator } = makeMutator({ a: 1, b: 2 }); + const next = await mutator.update((prev) => ({ ...prev, a: prev.a + 10 })); + expect(next).toEqual({ a: 11, b: 2 }); + }); + + it('serializes concurrent updates so neither patch is lost', async () => { + const { mutator, get } = makeMutator({ a: 0, b: 0 }); + await Promise.all([mutator.update({ a: 1 }), mutator.update({ b: 2 })]); + expect(get()).toEqual({ a: 1, b: 2 }); + }); + + it('propagates a rejection to its caller', async () => { + const { mutator } = makeMutator({ a: 1, b: 2 }); + await expect( + mutator.enqueue(async () => { + throw new Error('boom'); + }), + ).rejects.toThrow('boom'); + }); + + it('runs a mutation queued after a rejected one (queue not poisoned)', async () => { + const { mutator, get } = makeMutator({ a: 0, b: 0 }); + // Enqueue a failing op and a following op back-to-back: the following op is + // chained onto the tail while the first is still pending. + const failing = mutator.enqueue(async () => { + throw new Error('nope'); + }); + const following = mutator.set({ a: 7, b: 7 }); + + await expect(failing).rejects.toThrow('nope'); + await expect(following).resolves.toBeUndefined(); + expect(get()).toEqual({ a: 7, b: 7 }); + }); +});