From 0cd84c5db5b28959f27ca337e845f918be2cfe3e Mon Sep 17 00:00:00 2001 From: 0xisk <0xisk@proton.me> Date: Mon, 31 Aug 2026 11:48:06 +0200 Subject: [PATCH 1/3] 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` and any truthiness check passed vacuously. The cast on the Proxy return masked the mismatch from tsc. * Circuit.ts: map ContextlessCircuits to `Promise`; 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. --- .../simulator/src/core/AbstractSimulator.ts | 2 + .../src/create-contract.type-test.ts | 38 +++++++++++++++++++ .../src/factory/createDrySimulator.ts | 4 +- packages/simulator/src/types/Circuit.ts | 33 +++++++--------- 4 files changed, 56 insertions(+), 21 deletions(-) diff --git a/packages/simulator/src/core/AbstractSimulator.ts b/packages/simulator/src/core/AbstractSimulator.ts index c179fe8..7721224 100644 --- a/packages/simulator/src/core/AbstractSimulator.ts +++ b/packages/simulator/src/core/AbstractSimulator.ts @@ -93,6 +93,8 @@ export abstract class AbstractSimulator * @returns The current private state of type P */ public getPrivateState(): P { + // The runtime types this `P | undefined`; the simulator always seeds a + // private state, so it is set after init. return this.circuitContext.callContext.currentPrivateState as P; } diff --git a/packages/simulator/src/create-contract.type-test.ts b/packages/simulator/src/create-contract.type-test.ts index 0b40d5d..00d49ab 100644 --- a/packages/simulator/src/create-contract.type-test.ts +++ b/packages/simulator/src/create-contract.type-test.ts @@ -8,8 +8,10 @@ * Exports nothing and is imported by nothing. The build config excludes * `*.type-test.ts` from emit so it never reaches `dist`; `yarn types` compiles it. */ +import type { CircuitContext } from '@midnight-ntwrk/compact-runtime'; import { createSimulator } from './factory/createSimulator.js'; import type { SimulatorConfig } from './factory/SimulatorConfig.js'; +import type { AsyncCircuits, ContextlessCircuits } from './types/Circuit.js'; import type { IMinimalContract } from './types/Contract.js'; type GuardPrivateState = { readonly value: number }; @@ -60,5 +62,41 @@ async function _callSiteSubtype(): Promise { void instance.marker(); } +// --- Circuit proxies resolve to promises ------------------------------------ +// The wrapping proxies are async regardless of artifact era, so the mapped +// type must yield `Promise` for the 0.18 promise shape and the 0.16 sync +// shape alike. A bare-`R` mapping reintroduces the un-awaited-result footgun. + +type GuardCircuits = { + modern: ( + ctx: CircuitContext, + x: number, + ) => Promise<{ result: boolean; context: CircuitContext }>; + legacy: ( + ctx: CircuitContext, + x: number, + ) => { result: boolean; context: CircuitContext }; +}; + +declare const contextless: ContextlessCircuits< + GuardCircuits, + GuardPrivateState +>; + +function _circuitsResolveToPromises(): void { + const modern: Promise = contextless.modern(1); + const legacy: Promise = contextless.legacy(1); + // @ts-expect-error the mapped circuit yields a promise, not a bare boolean. + const sync: boolean = contextless.modern(1); + void modern; + void legacy; + void sync; +} + +// `AsyncCircuits` must stay interchangeable with `ContextlessCircuits`. +const _alias: AsyncCircuits = contextless; + void Guard; void _callSiteSubtype; +void _circuitsResolveToPromises; +void _alias; diff --git a/packages/simulator/src/factory/createDrySimulator.ts b/packages/simulator/src/factory/createDrySimulator.ts index 844a71f..f1db530 100644 --- a/packages/simulator/src/factory/createDrySimulator.ts +++ b/packages/simulator/src/factory/createDrySimulator.ts @@ -44,7 +44,7 @@ export function createDrySimulator< * before {@link init} has been awaited. */ get contractAddress(): string { - if (!this._contractAddress) { + if (this._contractAddress === undefined) { throw new Error('Simulator: await init() before use'); } return this._contractAddress; @@ -218,6 +218,8 @@ export function createDrySimulator< const circuitCtx = this.circuitContext; return { ledger: this.getPublicState(), + // Runtime-typed `P | undefined`; always set after init (see + // `AbstractSimulator.getPrivateState`). privateState: circuitCtx.callContext.currentPrivateState as P, contractAddress: circuitCtx.callContext.currentQueryContext.address, }; diff --git a/packages/simulator/src/types/Circuit.ts b/packages/simulator/src/types/Circuit.ts index b3f961d..17444b7 100644 --- a/packages/simulator/src/types/Circuit.ts +++ b/packages/simulator/src/types/Circuit.ts @@ -30,35 +30,28 @@ type CircuitResult = Awaited extends { result: infer R } ? R : never; /** * Transforms circuit functions by removing the explicit `CircuitContext` parameter. * - * Each original circuit function has signature: - * `(ctx: CircuitContext, ...args) => Promise<{ result: R; context: CircuitContext }>` - * - * The transformed function takes the same parameters except the context, - * and returns the `result` directly. + * Each transformed function takes the original parameters minus the context and + * resolves to the `result`. Always a `Promise`: the wrapping proxies are async + * whether or not the underlying artifact is. */ export type ContextlessCircuits = { [K in keyof Circuits]: Circuits[K] extends ( ctx: CircuitContext, ...args: infer P ) => infer Ret - ? (...args: P) => CircuitResult + ? (...args: P) => Promise> : never; }; /** - * Async sibling of {@link ContextlessCircuits}, used by `createBackendSimulator`. + * Alias of {@link ContextlessCircuits}, used by `createBackendSimulator`. * - * Identical to {@link ContextlessCircuits} except every circuit returns - * `Promise` instead of `R`. This is the type-level half of dry↔live parity: - * the dry backend resolves in memory, the live backend awaits the network, and - * spec code is uniform `await` across both. A circuit can never return a bare - * value on one backend and a `Promise` on the other. + * The name states the dry↔live parity contract: the dry backend resolves in + * memory, the live backend awaits the network, and spec code is uniform + * `await` across both. A circuit can never return a bare value on one backend + * and a `Promise` on the other. */ -export type AsyncCircuits = { - [K in keyof Circuits]: Circuits[K] extends ( - ctx: CircuitContext, - ...args: infer P - ) => infer Ret - ? (...args: P) => Promise> - : never; -}; +export type AsyncCircuits = ContextlessCircuits< + Circuits, + TState +>; From 7494f5a21d205a97602f638211577dbfedb2d699 Mon Sep 17 00:00:00 2001 From: 0xisk <0xisk@proton.me> Date: Mon, 31 Aug 2026 11:48:13 +0200 Subject: [PATCH 2/3] 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(). --- .../simulator/src/factory/createSimulator.ts | 44 +++++++++++--- .../test/integration/LiveAddress.test.ts | 60 +++++++++++++++++++ .../test/integration/LiveMutation.test.ts | 9 ++- 3 files changed, 103 insertions(+), 10 deletions(-) create mode 100644 packages/simulator/test/integration/LiveAddress.test.ts diff --git a/packages/simulator/src/factory/createSimulator.ts b/packages/simulator/src/factory/createSimulator.ts index 5403b23..a31f1a6 100644 --- a/packages/simulator/src/factory/createSimulator.ts +++ b/packages/simulator/src/factory/createSimulator.ts @@ -89,16 +89,25 @@ export function createSimulator< // The local in-memory simulator: the whole dry path, and the pure-circuit // evaluator in live (D2). In live this runs `initialState` in memory only — // it is never deployed on-chain. - const localSim = new DrySimClass(contractArgs, options); // 0.18: `initialState` is async, so the constructor defers the constructor // run to `init()`. Await it before deriving names / wiring any backend. - await localSim.init(); - const contract = localSim.contract; - const impureNames = Object.keys(contract.impureCircuits); - const impureSet = new Set(impureNames); - const pureNames = Object.keys(contract.circuits).filter( - (name) => !impureSet.has(name), - ); + const buildLocalSim = async (contractAddress?: string) => { + const sim = new DrySimClass( + contractArgs, + contractAddress ? { ...options, contractAddress } : options, + ); + await sim.init(); + return sim; + }; + + const circuitNames = (contract: IMinimalContract) => { + const impureNames = Object.keys(contract.impureCircuits); + const impureSet = new Set(impureNames); + const pureNames = Object.keys(contract.circuits).filter( + (name) => !impureSet.has(name), + ); + return { pureNames, impureNames }; + }; if (kind === 'live') { const signers = new Signers({ @@ -127,6 +136,23 @@ export function createSimulator< ); } + // The deployed address is the contract's identity; an explicit override + // that disagrees with it can only produce wrong results. + if ( + options.contractAddress && + options.contractAddress !== liveCtx.contractAddress + ) { + throw new Error( + `contractAddress ${options.contractAddress} does not match the ` + + `deployed contract at ${liveCtx.contractAddress}`, + ); + } + + // Bind the local evaluator to the deployed address so pure circuits + // never observe a dummy address. + const localSim = await buildLocalSim(liveCtx.contractAddress); + const { pureNames, impureNames } = circuitNames(localSim.contract); + // The live adapter value is reached only via dynamic import, // so a dry import never statically links it (and any future heavy deps). const { LiveBackend } = await import('../live/LiveBackend.js'); @@ -139,6 +165,8 @@ export function createSimulator< return { backend, signers, pureNames, impureNames }; } + const localSim = await buildLocalSim(); + const { pureNames, impureNames } = circuitNames(localSim.contract); const signers = new Signers({ mode: 'dry', dryKeys: options.signerKeys }); const backend = new DryBackend( localSim as unknown as SyncSimulator, diff --git a/packages/simulator/test/integration/LiveAddress.test.ts b/packages/simulator/test/integration/LiveAddress.test.ts new file mode 100644 index 0000000..d84e737 --- /dev/null +++ b/packages/simulator/test/integration/LiveAddress.test.ts @@ -0,0 +1,60 @@ +import { + dummyContractAddress, + type StateValue, +} from '@midnight-ntwrk/compact-runtime'; +import { describe, expect, it } from 'vitest'; +import type { + DeployedTxHandle, + LiveContext, +} from '../../src/live/LiveContext.js'; +import { WitnessPrivateState } from '../fixtures/sample-contracts/witnesses/WitnessWitnesses.js'; +import { WitnessSimulator } from './WitnessSimulator.js'; + +// Runtime-parseable: the deployed address now seeds the local evaluator's +// circuit context, so a made-up string no longer works here. +const DEPLOYED = dummyContractAddress(); + +/** An inert live world; only the address-binding paths are exercised. */ +const makeWorld = (): LiveContext => ({ + contractAddress: DEPLOYED, + async handleFor(): Promise { + return { callTx: {} }; + }, + async queryLedger(): Promise { + return {} as unknown as StateValue; + }, + async queryPrivateState(): Promise { + return WitnessPrivateState.generate(); + }, +}); + +describe('live contract-address binding', () => { + it('exposes the deployed address', async () => { + const sim = await WitnessSimulator.create({ + backend: 'live', + live: makeWorld(), + }); + + expect(sim.contractAddress).toBe(DEPLOYED); + }); + + it('accepts an explicit contractAddress equal to the deployed one', async () => { + const sim = await WitnessSimulator.create({ + backend: 'live', + live: makeWorld(), + contractAddress: DEPLOYED, + }); + + expect(sim.contractAddress).toBe(DEPLOYED); + }); + + it('rejects an explicit contractAddress that differs from the deployed one', async () => { + await expect( + WitnessSimulator.create({ + backend: 'live', + live: makeWorld(), + contractAddress: '0200aaaa', + }), + ).rejects.toThrow(/does not match the deployed contract/); + }); +}); diff --git a/packages/simulator/test/integration/LiveMutation.test.ts b/packages/simulator/test/integration/LiveMutation.test.ts index d6b200e..5ccadd7 100644 --- a/packages/simulator/test/integration/LiveMutation.test.ts +++ b/packages/simulator/test/integration/LiveMutation.test.ts @@ -1,4 +1,7 @@ -import type { StateValue } from '@midnight-ntwrk/compact-runtime'; +import { + dummyContractAddress, + type StateValue, +} from '@midnight-ntwrk/compact-runtime'; import { describe, expect, it } from 'vitest'; import { PRIVATE_STATE_MUTATION_UNSUPPORTED } from '../../src/index.js'; import type { @@ -20,7 +23,9 @@ const makeWorld = ( ): LiveContext => { let stored = initial; const world: LiveContext = { - contractAddress: '0200deadbeef', + // Runtime-parseable: the deployed address seeds the local evaluator's + // circuit context. + contractAddress: dummyContractAddress(), async handleFor(): Promise { return { callTx: {} }; }, From f0a031a73e52ea816bc59533c97625716a640706 Mon Sep 17 00:00:00 2001 From: 0xisk <0xisk@proton.me> Date: Mon, 31 Aug 2026 11:48:13 +0200 Subject: [PATCH 3/3] docs(release): hyphenate and align prerelease wording --- RELEASING.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/RELEASING.md b/RELEASING.md index e97f7fa..535200f 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -21,7 +21,7 @@ runs only from `beta`, `patch`/`minor`/`major` only from `main`. 4. Choose the package to release and the version bump type. Following [SemVer](https://semver.org/): - **Patch** - Backward-compatible bug fixes. - - **Minor** - New functionality in a backward compatible way. + - **Minor** - New functionality in a backward-compatible way. - **Major** - Breaking API changes. - **Prepatch / preminor / premajor** - Open a new beta cycle at the 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`. - Create a git tag. - Publish the package to npm under the channel's dist-tag. 7. Once published, go to "Releases" and create a GitHub release using the - generated tag. Mark beta tags as pre-releases. + generated tag. Mark beta tags as prereleases. ## Graduating a beta to stable