diff --git a/packages/simulator/src/backend/Backend.ts b/packages/simulator/src/backend/Backend.ts
index 869c3e6..39f0d1d 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 `LiveContext`
+ * implements `setPrivateState`; otherwise it throws
+ * `PRIVATE_STATE_MUTATION_UNSUPPORTED` (from the live backend).
+ *
+ * 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/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 379408b..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';
@@ -147,6 +148,12 @@ export function createSimulator<
readonly _backend: Backend
;
readonly _signers: Signers;
+ /**
+ * Serializes private-state read-modify-write for this instance. Internal;
+ * see {@link PrivateStateMutator} for the scope of the guarantee.
+ */
+ readonly _mutator: PrivateStateMutator
;
+
/** Async circuit proxies; every call returns a promise. */
readonly circuits: {
pure: AsyncCircuits, P>;
@@ -163,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,
@@ -241,14 +254,37 @@ 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).
+ * Serialized against other mutations via {@link _mutator}.
*
* @param privateState - The new private state.
*/
- setPrivateState(privateState: P): void {
- this._backend.setPrivateState(privateState);
+ setPrivateState(privateState: P): Promise {
+ return this._mutator.set(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.
+ *
+ * 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 }));
+ *
+ * @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
{
+ 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 8f02c5f..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
@@ -25,7 +26,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..b2a0ff7 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
+ * `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);
+ },
};
}
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);
+ });
+});
diff --git a/packages/simulator/test/integration/SampleZOwnableSimulator.ts b/packages/simulator/test/integration/SampleZOwnableSimulator.ts
index 415feaf..94be4e0 100644
--- a/packages/simulator/test/integration/SampleZOwnableSimulator.ts
+++ b/packages/simulator/test/integration/SampleZOwnableSimulator.ts
@@ -127,14 +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 => {
- const currentState = await this.getPrivateState();
- const updatedState = { ...currentState, secretNonce: newNonce };
- this.setPrivateState(updatedState);
- return updatedState;
- },
+ ): Promise =>
+ this.updatePrivateState({ secretNonce: newNonce }),
/**
* @description Returns the secret nonce given the context.
diff --git a/packages/simulator/test/integration/Witness.test.ts b/packages/simulator/test/integration/Witness.test.ts
index 147ee11..7696a2f 100644
--- a/packages/simulator/test/integration/Witness.test.ts
+++ b/packages/simulator/test/integration/Witness.test.ts
@@ -200,15 +200,62 @@ 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: Buffer.alloc(32, 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('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('should override the entire private state', () => {});
+ 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 = Buffer.alloc(32, 9);
+ await contract.updatePrivateState({ secretBytes: injected });
+ await contract.setBytes();
+ expect((await contract.getPublicState())._valBytes).toEqual(
+ new Uint8Array(injected),
+ );
+ });
});
});
diff --git a/packages/simulator/test/integration/WitnessSimulator.ts b/packages/simulator/test/integration/WitnessSimulator.ts
index 04e3026..914fbcd 100644
--- a/packages/simulator/test/integration/WitnessSimulator.ts
+++ b/packages/simulator/test/integration/WitnessSimulator.ts
@@ -61,27 +61,13 @@ export class WitnessSimulator extends WitnessSimulatorBase {
}
public readonly privateState = {
- injectSecretBytes: async (
+ injectSecretBytes: (
newBytes: Buffer,
- ): Promise => {
- const currentState = await this.getPrivateState();
- const updatedState = { ...currentState, secretBytes: newBytes };
- this.setPrivateState(updatedState);
- return updatedState;
- },
- injectSecretField: async (
- newField: bigint,
- ): Promise => {
- const currentState = await this.getPrivateState();
- const updatedState = { ...currentState, secretField: newField };
- this.setPrivateState(updatedState);
- return updatedState;
- },
- injectSecretUint: async (newUint: bigint): Promise => {
- const currentState = await this.getPrivateState();
- const updatedState = { ...currentState, secretUint: newUint };
- this.setPrivateState(updatedState);
- return updatedState;
- },
+ ): Promise =>
+ this.updatePrivateState({ secretBytes: newBytes }),
+ injectSecretField: (newField: bigint): Promise =>
+ this.updatePrivateState({ secretField: newField }),
+ injectSecretUint: (newUint: bigint): Promise =>
+ this.updatePrivateState({ secretUint: newUint }),
};
}
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/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 });
+ });
+});
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 });
+ });
+});