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 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/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/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 +>; 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: {} }; },