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 }); + }); +});