Skip to content

Commit 9e513de

Browse files
Add PS mutation (#131)
* add mutatable private state in live backend * add tests * fix doc * add live mutation tests * serialize rmw sequence * use buffer in tests * fix tsdoc links * resolve updatePrivateState to the written state * fix docs * document per-instance scope of queue * extract PrivateStateMutator * add PrivateStateMutator tests
1 parent 60308ab commit 9e513de

15 files changed

Lines changed: 510 additions & 57 deletions

packages/simulator/src/backend/Backend.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -71,14 +71,18 @@ export interface Backend<P, L> {
7171
getContractState(): Promise<StateValue>;
7272

7373
/**
74-
* Replaces the private state. Dry mutates the in-memory context (used by
75-
* per-module helpers like secret/nonce injection); live throws, because
76-
* mid-test private-state mutation is the documented dry↔live asymmetry.
77-
* Guard such specs with `isLiveBackend()`.
74+
* Replaces the whole private state `P`. Dry mutates the in-memory context;
75+
* live writes to the harness's private-state provider so the next impure
76+
* `callTx` proves against it but only if the injected `LiveContext`
77+
* implements `setPrivateState`; otherwise it throws
78+
* `PRIVATE_STATE_MUTATION_UNSUPPORTED` (from the live backend).
79+
*
80+
* Async across backends for uniform `await`: dry resolves synchronously,
81+
* live awaits the provider write.
7882
*
7983
* @param privateState - The new private state `P`.
8084
*/
81-
setPrivateState(privateState: P): void;
85+
setPrivateState(privateState: P): Promise<void>;
8286

8387
/**
8488
* Sets the caller identity for subsequent circuit calls.

packages/simulator/src/backend/DryBackend.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ export class DryBackend<P, L> implements Backend<P, L> {
9393
}
9494

9595
/** Mutates the in-memory private state (dry supports mid-test mutation). */
96-
setPrivateState(privateState: P): void {
96+
async setPrivateState(privateState: P): Promise<void> {
9797
this.sim.circuitContextManager.updatePrivateState(privateState);
9898
}
9999

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
/**
2+
* Serializes read-modify-write access to a contract's private state.
3+
*
4+
* Built from a backend's `getPrivateState`/`setPrivateState` pair, it queues
5+
* every mutation so a read-modify-write (`update`) can't interleave with another
6+
* mutation and lose an update (a lost-update race). It is backend-agnostic: the
7+
* dry, live, and future `sim` backends all reuse it by supplying their own
8+
* read/write closures.
9+
*
10+
* The guarantee is scoped to this instance. On live, two `PrivateStateMutator`s
11+
* (or two processes) targeting the same `privateStateProvider` + `privateStateId`
12+
* still race — both read the provider, last write wins — since the queue is
13+
* local. Cross-instance atomicity would need provider-side
14+
* compare-and-set/versioning and is out of scope; tests drive one simulator per
15+
* private state, so the per-instance queue is sufficient in practice.
16+
*
17+
* @template P - Private state type.
18+
*/
19+
export class PrivateStateMutator<P> {
20+
/** Tail of the mutation queue; each op runs after the previous drains. */
21+
#chain: Promise<unknown> = Promise.resolve();
22+
readonly #read: () => Promise<P>;
23+
readonly #write: (next: P) => Promise<void>;
24+
25+
/**
26+
* @param read - Reads the current private state (backend `getPrivateState`).
27+
* @param write - Replaces the whole private state (backend `setPrivateState`).
28+
*/
29+
constructor(read: () => Promise<P>, write: (next: P) => Promise<void>) {
30+
this.#read = read;
31+
this.#write = write;
32+
}
33+
34+
/**
35+
* Runs `op` once the queue drains, serialized against every other mutation.
36+
* The returned promise settles with `op`'s result (or rejection) for the
37+
* caller; the queue tail deliberately swallows rejections so a failed op does
38+
* not poison subsequent mutations.
39+
*
40+
* @param op - The operation to run once the queue drains.
41+
* @returns `op`'s result.
42+
*/
43+
enqueue<T>(op: () => Promise<T>): Promise<T> {
44+
const run = this.#chain.then(op);
45+
this.#chain = run.then(
46+
() => undefined,
47+
() => undefined,
48+
);
49+
return run;
50+
}
51+
52+
/**
53+
* Replaces the whole private state, serialized against other mutations.
54+
*
55+
* @param next - The new private state.
56+
*/
57+
set(next: P): Promise<void> {
58+
return this.enqueue(() => this.#write(next));
59+
}
60+
61+
/**
62+
* Read-modify-write, serialized end-to-end so the read and write are atomic
63+
* against other mutations on this instance. Resolves to the state that was
64+
* written, so callers need no follow-up read.
65+
*
66+
* @param updater - A partial patch to shallow-merge, or a function that
67+
* receives the current state and returns the next.
68+
* @returns The private state that was written.
69+
*/
70+
update(updater: Partial<P> | ((prev: P) => P)): Promise<P> {
71+
return this.enqueue(async () => {
72+
const prev = await this.#read();
73+
const next =
74+
typeof updater === 'function'
75+
? (updater as (p: P) => P)(prev)
76+
: { ...prev, ...updater };
77+
await this.#write(next);
78+
return next;
79+
});
80+
}
81+
}

packages/simulator/src/factory/createSimulator.ts

Lines changed: 41 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { Backend, BackendKind, CircuitKind } from '../backend/Backend.js';
22
import { DryBackend, type SyncSimulator } from '../backend/DryBackend.js';
3+
import { PrivateStateMutator } from '../core/PrivateStateMutator.js';
34
import type { LiveContext } from '../live/LiveContext.js';
45
import { getRegisteredLiveBackend } from '../live/registry.js';
56
import { Signers } from '../signers/Signers.js';
@@ -147,6 +148,12 @@ export function createSimulator<
147148
readonly _backend: Backend<P, L>;
148149
readonly _signers: Signers;
149150

151+
/**
152+
* Serializes private-state read-modify-write for this instance. Internal;
153+
* see {@link PrivateStateMutator} for the scope of the guarantee.
154+
*/
155+
readonly _mutator: PrivateStateMutator<P>;
156+
150157
/** Async circuit proxies; every call returns a promise. */
151158
readonly circuits: {
152159
pure: AsyncCircuits<ExtractPureCircuits<TContract>, P>;
@@ -163,6 +170,12 @@ export function createSimulator<
163170
this._backend = deps.backend;
164171
this.backendKind = deps.backend.kind;
165172
this._signers = deps.signers;
173+
// Constructed here (not as a field initializer) so the closures capture
174+
// the assigned `_backend` rather than an undefined one.
175+
this._mutator = new PrivateStateMutator<P>(
176+
() => this._backend.getPrivateState(),
177+
(next) => this._backend.setPrivateState(next),
178+
);
166179
this.circuits = {
167180
pure: buildProxy(
168181
this._backend,
@@ -241,14 +254,37 @@ export function createSimulator<
241254
}
242255

243256
/**
244-
* Replaces the private state (for per-module secret/nonce injection helpers).
245-
* Dry mutates the in-memory context; live throws (mutation asymmetry) —
246-
* guard such specs with `isLiveBackend()`.
257+
* Replaces the whole private state. Dry mutates the in-memory context; live
258+
* writes to the harness's private-state provider so the next impure call
259+
* proves against it (throws if the `LiveContext` opted out of mutation).
260+
* Serialized against other mutations via {@link _mutator}.
247261
*
248262
* @param privateState - The new private state.
249263
*/
250-
setPrivateState(privateState: P): void {
251-
this._backend.setPrivateState(privateState);
264+
setPrivateState(privateState: P): Promise<void> {
265+
return this._mutator.set(privateState);
266+
}
267+
268+
/**
269+
* Ergonomic granular private-state mutation. Replaces the per-module
270+
* `injectSecretKey`/`injectSecretNonce` helpers: a plain object shallow-
271+
* merges onto the current state, a function receives the current state and
272+
* returns the next.
273+
*
274+
* The read-modify-write is serialized (see {@link _mutator}) and resolves to
275+
* the state that was written, so callers can `return sim.updatePrivateState(...)`
276+
* without a follow-up `getPrivateState()`. Works on both dry (in-memory) and
277+
* live (provider read then write); on live the current state must already
278+
* exist (it is seeded at deploy).
279+
*
280+
* @example sim.updatePrivateState({ secretKey });
281+
* @example sim.updatePrivateState((prev) => ({ ...prev, counter: prev.counter + 1n }));
282+
*
283+
* @param updater - A partial patch to merge, or an updater function.
284+
* @returns The private state that was written.
285+
*/
286+
updatePrivateState(updater: Partial<P> | ((prev: P) => P)): Promise<P> {
287+
return this._mutator.update(updater);
252288
}
253289

254290
/** The raw contract state value. */

packages/simulator/src/index.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export { DryBackend } from './backend/DryBackend.js';
77
export { AbstractSimulator } from './core/AbstractSimulator.js';
88
export { CircuitContextManager } from './core/CircuitContextManager.js';
99
export { ContractSimulator } from './core/ContractSimulator.js';
10+
export { PrivateStateMutator } from './core/PrivateStateMutator.js';
1011
// --- Core simulator (one factory, two backends) ----------------------------
1112
// A dry import pulls zero midnight-js: the live adapter `LiveBackend`
1213
// is type-only here and reached at runtime only via the dynamic import inside
@@ -25,7 +26,10 @@ export {
2526
DEFAULT_INDEXER_LAG,
2627
} from './live/createLiveContext.js';
2728
export type { LiveBackend } from './live/LiveBackend.js';
28-
export { WITNESS_OVERRIDE_UNSUPPORTED } from './live/LiveBackend.js';
29+
export {
30+
PRIVATE_STATE_MUTATION_UNSUPPORTED,
31+
WITNESS_OVERRIDE_UNSUPPORTED,
32+
} from './live/LiveBackend.js';
2933
export type {
3034
DeployedTxHandle,
3135
FinalizedCallResult,

packages/simulator/src/live/LiveBackend.ts

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,9 @@ import type { LiveContext } from './LiveContext.js';
88
export const WITNESS_OVERRIDE_UNSUPPORTED =
99
'witness override unsupported on live backend';
1010

11-
/** The error thrown when private state is mutated on the live backend. */
11+
/** The error thrown when the live `LiveContext` cannot mutate private state. */
1212
export const PRIVATE_STATE_MUTATION_UNSUPPORTED =
13-
'private-state mutation unsupported on live backend';
13+
'private-state mutation unsupported: this LiveContext does not implement setPrivateState';
1414

1515
/**
1616
* Dependencies the live adapter is constructed with by `createBackendSimulator`.
@@ -119,12 +119,16 @@ export class LiveBackend<P, L> implements Backend<P, L> {
119119
}
120120

121121
/**
122-
* Mid-test private-state mutation does not faithfully reproduce on live.
123-
* Throws so such specs are explicitly guarded with `isLiveBackend()`
124-
* rather than silently passing against unchanged state.
122+
* Writes private state through the injected {@link LiveContext} so the next
123+
* impure `callTx` proves against it. If the context opted out of mutation
124+
* (no `setPrivateState`), throws {@link PRIVATE_STATE_MUTATION_UNSUPPORTED}
125+
* so the spec fails loudly rather than proving against stale state.
125126
*/
126-
setPrivateState(_privateState: P): void {
127-
throw new Error(PRIVATE_STATE_MUTATION_UNSUPPORTED);
127+
async setPrivateState(privateState: P): Promise<void> {
128+
if (!this.ctx.setPrivateState) {
129+
throw new Error(PRIVATE_STATE_MUTATION_UNSUPPORTED);
130+
}
131+
await this.ctx.setPrivateState(privateState);
128132
}
129133

130134
/**

packages/simulator/src/live/LiveContext.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,4 +66,25 @@ export interface LiveContext<P> {
6666
* parity).
6767
*/
6868
queryPrivateState(): Promise<P>;
69+
70+
/**
71+
* Optional: writes the whole private state to the harness's private-state
72+
* provider, so the NEXT impure `callTx` proves against it. Each `callTx`
73+
* reads private state fresh from the provider (midnight-js `getStates` →
74+
* `privateStateProvider.get`), so no handle invalidation is needed.
75+
*
76+
* Omit to opt out of mutation — {@link LiveBackend} then throws
77+
* `PRIVATE_STATE_MUTATION_UNSUPPORTED`, so a spec that mutates fails loudly
78+
* rather than silently proving against stale state.
79+
*
80+
* Faithful for any client-controlled field of `P` (secret keys, cached
81+
* plaintexts, seeds, nonces) — injecting a hostile/stale value and asserting
82+
* the resulting rejection or handled behavior mirrors a real client. The one
83+
* thing it cannot do is fabricate on-chain state (`L`): a private state that
84+
* presupposes an on-chain event that never happened will not make a happy
85+
* path succeed on a live node.
86+
*
87+
* @param state - The new private state `P`.
88+
*/
89+
setPrivateState?(state: P): Promise<void>;
6990
}

packages/simulator/src/live/createLiveContext.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,16 @@ export interface CreateLiveContextOptions<P> {
5454
privateStateId: string;
5555
/** Provider for reading on-chain public state. */
5656
publicDataProvider: PublicDataProvider;
57-
/** Provider for reading private state (read parity). */
57+
/**
58+
* Provider for reading and (optionally) writing private state.
59+
*
60+
* Invariant: this MUST be the same provider instance wired into the
61+
* `providersFor(alias)` bundle handed to `findDeployedContract`. The
62+
* `setPrivateState` write below targets this provider, and the next
63+
* `callTx` reads private state from the bundle's provider; if they are
64+
* different instances the write is invisible to proving. `setContractAddress`
65+
* is the harness's responsibility (already required for `get`).
66+
*/
5867
privateStateProvider: PrivateStateProvider<string, P>;
5968
/** Optional override of the indexer-lag policy. */
6069
indexerLag?: Partial<IndexerLagPolicy>;
@@ -179,5 +188,14 @@ export function createLiveContext<P>(
179188
}
180189
return state;
181190
},
191+
192+
/**
193+
* Writes the whole private state to the provider under `privateStateId`.
194+
* The next impure `callTx` reads it fresh (no handle-cache invalidation
195+
* needed). See the invariant on {@link CreateLiveContextOptions.privateStateProvider}.
196+
*/
197+
async setPrivateState(state: P): Promise<void> {
198+
await options.privateStateProvider.set(options.privateStateId, state);
199+
},
182200
};
183201
}

0 commit comments

Comments
 (0)