Skip to content

Commit 512b7b4

Browse files
authored
fix(simulator): async proxy types and live address binding (#154)
* fix(simulator): type circuit proxies as promise-returning The 0.18 migration made every circuit proxy async but left ContextlessCircuits mapping to the bare result, so a consumer typed `boolean` received `Promise<boolean>` and any truthiness check passed vacuously. The cast on the Proxy return masked the mismatch from tsc. * Circuit.ts: map ContextlessCircuits to `Promise<R>`; AsyncCircuits becomes an alias so the two cannot drift. * create-contract.type-test.ts: pin the promise mapping for both the 0.18 promise shape and the 0.16 sync shape; fails on the old mapping. * createDrySimulator.ts: narrow the init guard to `=== undefined` and note why the private-state cast is sound. * AbstractSimulator.ts: same cast note. * fix(simulator): bind live pure evaluation to deployed address In live mode the local evaluator was built before the LiveContext resolved, so it kept the dummy address in its circuit context while the public simulator reported the deployed one. Resolve the context first, seed the evaluator with the deployed address, and reject an explicit contractAddress that disagrees with it. The deployed address now reaches the runtime, so a live harness must supply a parseable one; the mock worlds in the tests move from a made-up string to dummyContractAddress(). * docs(release): hyphenate and align prerelease wording
1 parent fd08aa2 commit 512b7b4

8 files changed

Lines changed: 161 additions & 33 deletions

File tree

RELEASING.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ runs only from `beta`, `patch`/`minor`/`major` only from `main`.
2121
4. Choose the package to release and the version bump type.
2222
Following [SemVer](https://semver.org/):
2323
- **Patch** - Backward-compatible bug fixes.
24-
- **Minor** - New functionality in a backward compatible way.
24+
- **Minor** - New functionality in a backward-compatible way.
2525
- **Major** - Breaking API changes.
2626
- **Prepatch / preminor / premajor** - Open a new beta cycle at the
2727
corresponding bump (`0.3.1` + preminor -> `0.4.0-beta.0`).
@@ -36,7 +36,7 @@ runs only from `beta`, `patch`/`minor`/`major` only from `main`.
3636
- Create a git tag.
3737
- Publish the package to npm under the channel's dist-tag.
3838
7. Once published, go to "Releases" and create a GitHub release using the
39-
generated tag. Mark beta tags as pre-releases.
39+
generated tag. Mark beta tags as prereleases.
4040

4141
## Graduating a beta to stable
4242

packages/simulator/src/core/AbstractSimulator.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,8 @@ export abstract class AbstractSimulator<P, L>
9393
* @returns The current private state of type P
9494
*/
9595
public getPrivateState(): P {
96+
// The runtime types this `P | undefined`; the simulator always seeds a
97+
// private state, so it is set after init.
9698
return this.circuitContext.callContext.currentPrivateState as P;
9799
}
98100

packages/simulator/src/create-contract.type-test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,10 @@
88
* Exports nothing and is imported by nothing. The build config excludes
99
* `*.type-test.ts` from emit so it never reaches `dist`; `yarn types` compiles it.
1010
*/
11+
import type { CircuitContext } from '@midnight-ntwrk/compact-runtime';
1112
import { createSimulator } from './factory/createSimulator.js';
1213
import type { SimulatorConfig } from './factory/SimulatorConfig.js';
14+
import type { AsyncCircuits, ContextlessCircuits } from './types/Circuit.js';
1315
import type { IMinimalContract } from './types/Contract.js';
1416

1517
type GuardPrivateState = { readonly value: number };
@@ -60,5 +62,41 @@ async function _callSiteSubtype(): Promise<void> {
6062
void instance.marker();
6163
}
6264

65+
// --- Circuit proxies resolve to promises ------------------------------------
66+
// The wrapping proxies are async regardless of artifact era, so the mapped
67+
// type must yield `Promise<R>` for the 0.18 promise shape and the 0.16 sync
68+
// shape alike. A bare-`R` mapping reintroduces the un-awaited-result footgun.
69+
70+
type GuardCircuits = {
71+
modern: (
72+
ctx: CircuitContext<GuardPrivateState>,
73+
x: number,
74+
) => Promise<{ result: boolean; context: CircuitContext<GuardPrivateState> }>;
75+
legacy: (
76+
ctx: CircuitContext<GuardPrivateState>,
77+
x: number,
78+
) => { result: boolean; context: CircuitContext<GuardPrivateState> };
79+
};
80+
81+
declare const contextless: ContextlessCircuits<
82+
GuardCircuits,
83+
GuardPrivateState
84+
>;
85+
86+
function _circuitsResolveToPromises(): void {
87+
const modern: Promise<boolean> = contextless.modern(1);
88+
const legacy: Promise<boolean> = contextless.legacy(1);
89+
// @ts-expect-error the mapped circuit yields a promise, not a bare boolean.
90+
const sync: boolean = contextless.modern(1);
91+
void modern;
92+
void legacy;
93+
void sync;
94+
}
95+
96+
// `AsyncCircuits` must stay interchangeable with `ContextlessCircuits`.
97+
const _alias: AsyncCircuits<GuardCircuits, GuardPrivateState> = contextless;
98+
6399
void Guard;
64100
void _callSiteSubtype;
101+
void _circuitsResolveToPromises;
102+
void _alias;

packages/simulator/src/factory/createDrySimulator.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ export function createDrySimulator<
4444
* before {@link init} has been awaited.
4545
*/
4646
get contractAddress(): string {
47-
if (!this._contractAddress) {
47+
if (this._contractAddress === undefined) {
4848
throw new Error('Simulator: await init() before use');
4949
}
5050
return this._contractAddress;
@@ -218,6 +218,8 @@ export function createDrySimulator<
218218
const circuitCtx = this.circuitContext;
219219
return {
220220
ledger: this.getPublicState(),
221+
// Runtime-typed `P | undefined`; always set after init (see
222+
// `AbstractSimulator.getPrivateState`).
221223
privateState: circuitCtx.callContext.currentPrivateState as P,
222224
contractAddress: circuitCtx.callContext.currentQueryContext.address,
223225
};

packages/simulator/src/factory/createSimulator.ts

Lines changed: 36 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -89,16 +89,25 @@ export function createSimulator<
8989
// The local in-memory simulator: the whole dry path, and the pure-circuit
9090
// evaluator in live (D2). In live this runs `initialState` in memory only —
9191
// it is never deployed on-chain.
92-
const localSim = new DrySimClass(contractArgs, options);
9392
// 0.18: `initialState` is async, so the constructor defers the constructor
9493
// run to `init()`. Await it before deriving names / wiring any backend.
95-
await localSim.init();
96-
const contract = localSim.contract;
97-
const impureNames = Object.keys(contract.impureCircuits);
98-
const impureSet = new Set(impureNames);
99-
const pureNames = Object.keys(contract.circuits).filter(
100-
(name) => !impureSet.has(name),
101-
);
94+
const buildLocalSim = async (contractAddress?: string) => {
95+
const sim = new DrySimClass(
96+
contractArgs,
97+
contractAddress ? { ...options, contractAddress } : options,
98+
);
99+
await sim.init();
100+
return sim;
101+
};
102+
103+
const circuitNames = (contract: IMinimalContract) => {
104+
const impureNames = Object.keys(contract.impureCircuits);
105+
const impureSet = new Set(impureNames);
106+
const pureNames = Object.keys(contract.circuits).filter(
107+
(name) => !impureSet.has(name),
108+
);
109+
return { pureNames, impureNames };
110+
};
102111

103112
if (kind === 'live') {
104113
const signers = new Signers({
@@ -127,6 +136,23 @@ export function createSimulator<
127136
);
128137
}
129138

139+
// The deployed address is the contract's identity; an explicit override
140+
// that disagrees with it can only produce wrong results.
141+
if (
142+
options.contractAddress &&
143+
options.contractAddress !== liveCtx.contractAddress
144+
) {
145+
throw new Error(
146+
`contractAddress ${options.contractAddress} does not match the ` +
147+
`deployed contract at ${liveCtx.contractAddress}`,
148+
);
149+
}
150+
151+
// Bind the local evaluator to the deployed address so pure circuits
152+
// never observe a dummy address.
153+
const localSim = await buildLocalSim(liveCtx.contractAddress);
154+
const { pureNames, impureNames } = circuitNames(localSim.contract);
155+
130156
// The live adapter value is reached only via dynamic import,
131157
// so a dry import never statically links it (and any future heavy deps).
132158
const { LiveBackend } = await import('../live/LiveBackend.js');
@@ -139,6 +165,8 @@ export function createSimulator<
139165
return { backend, signers, pureNames, impureNames };
140166
}
141167

168+
const localSim = await buildLocalSim();
169+
const { pureNames, impureNames } = circuitNames(localSim.contract);
142170
const signers = new Signers({ mode: 'dry', dryKeys: options.signerKeys });
143171
const backend = new DryBackend<P, L>(
144172
localSim as unknown as SyncSimulator<P, L>,

packages/simulator/src/types/Circuit.ts

Lines changed: 13 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -30,35 +30,28 @@ type CircuitResult<Ret> = Awaited<Ret> extends { result: infer R } ? R : never;
3030
/**
3131
* Transforms circuit functions by removing the explicit `CircuitContext` parameter.
3232
*
33-
* Each original circuit function has signature:
34-
* `(ctx: CircuitContext<TState>, ...args) => Promise<{ result: R; context: CircuitContext<TState> }>`
35-
*
36-
* The transformed function takes the same parameters except the context,
37-
* and returns the `result` directly.
33+
* Each transformed function takes the original parameters minus the context and
34+
* resolves to the `result`. Always a `Promise`: the wrapping proxies are async
35+
* whether or not the underlying artifact is.
3836
*/
3937
export type ContextlessCircuits<Circuits, TState> = {
4038
[K in keyof Circuits]: Circuits[K] extends (
4139
ctx: CircuitContext<TState>,
4240
...args: infer P
4341
) => infer Ret
44-
? (...args: P) => CircuitResult<Ret>
42+
? (...args: P) => Promise<CircuitResult<Ret>>
4543
: never;
4644
};
4745

4846
/**
49-
* Async sibling of {@link ContextlessCircuits}, used by `createBackendSimulator`.
47+
* Alias of {@link ContextlessCircuits}, used by `createBackendSimulator`.
5048
*
51-
* Identical to {@link ContextlessCircuits} except every circuit returns
52-
* `Promise<R>` instead of `R`. This is the type-level half of dry↔live parity:
53-
* the dry backend resolves in memory, the live backend awaits the network, and
54-
* spec code is uniform `await` across both. A circuit can never return a bare
55-
* value on one backend and a `Promise` on the other.
49+
* The name states the dry↔live parity contract: the dry backend resolves in
50+
* memory, the live backend awaits the network, and spec code is uniform
51+
* `await` across both. A circuit can never return a bare value on one backend
52+
* and a `Promise` on the other.
5653
*/
57-
export type AsyncCircuits<Circuits, TState> = {
58-
[K in keyof Circuits]: Circuits[K] extends (
59-
ctx: CircuitContext<TState>,
60-
...args: infer P
61-
) => infer Ret
62-
? (...args: P) => Promise<CircuitResult<Ret>>
63-
: never;
64-
};
54+
export type AsyncCircuits<Circuits, TState> = ContextlessCircuits<
55+
Circuits,
56+
TState
57+
>;
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import {
2+
dummyContractAddress,
3+
type StateValue,
4+
} from '@midnight-ntwrk/compact-runtime';
5+
import { describe, expect, it } from 'vitest';
6+
import type {
7+
DeployedTxHandle,
8+
LiveContext,
9+
} from '../../src/live/LiveContext.js';
10+
import { WitnessPrivateState } from '../fixtures/sample-contracts/witnesses/WitnessWitnesses.js';
11+
import { WitnessSimulator } from './WitnessSimulator.js';
12+
13+
// Runtime-parseable: the deployed address now seeds the local evaluator's
14+
// circuit context, so a made-up string no longer works here.
15+
const DEPLOYED = dummyContractAddress();
16+
17+
/** An inert live world; only the address-binding paths are exercised. */
18+
const makeWorld = (): LiveContext<WitnessPrivateState> => ({
19+
contractAddress: DEPLOYED,
20+
async handleFor(): Promise<DeployedTxHandle> {
21+
return { callTx: {} };
22+
},
23+
async queryLedger(): Promise<StateValue> {
24+
return {} as unknown as StateValue;
25+
},
26+
async queryPrivateState(): Promise<WitnessPrivateState> {
27+
return WitnessPrivateState.generate();
28+
},
29+
});
30+
31+
describe('live contract-address binding', () => {
32+
it('exposes the deployed address', async () => {
33+
const sim = await WitnessSimulator.create({
34+
backend: 'live',
35+
live: makeWorld(),
36+
});
37+
38+
expect(sim.contractAddress).toBe(DEPLOYED);
39+
});
40+
41+
it('accepts an explicit contractAddress equal to the deployed one', async () => {
42+
const sim = await WitnessSimulator.create({
43+
backend: 'live',
44+
live: makeWorld(),
45+
contractAddress: DEPLOYED,
46+
});
47+
48+
expect(sim.contractAddress).toBe(DEPLOYED);
49+
});
50+
51+
it('rejects an explicit contractAddress that differs from the deployed one', async () => {
52+
await expect(
53+
WitnessSimulator.create({
54+
backend: 'live',
55+
live: makeWorld(),
56+
contractAddress: '0200aaaa',
57+
}),
58+
).rejects.toThrow(/does not match the deployed contract/);
59+
});
60+
});

packages/simulator/test/integration/LiveMutation.test.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1-
import type { StateValue } from '@midnight-ntwrk/compact-runtime';
1+
import {
2+
dummyContractAddress,
3+
type StateValue,
4+
} from '@midnight-ntwrk/compact-runtime';
25
import { describe, expect, it } from 'vitest';
36
import { PRIVATE_STATE_MUTATION_UNSUPPORTED } from '../../src/index.js';
47
import type {
@@ -20,7 +23,9 @@ const makeWorld = (
2023
): LiveContext<WitnessPrivateState> => {
2124
let stored = initial;
2225
const world: LiveContext<WitnessPrivateState> = {
23-
contractAddress: '0200deadbeef',
26+
// Runtime-parseable: the deployed address seeds the local evaluator's
27+
// circuit context.
28+
contractAddress: dummyContractAddress(),
2429
async handleFor(): Promise<DeployedTxHandle> {
2530
return { callTx: {} };
2631
},

0 commit comments

Comments
 (0)