diff --git a/packages/simulator/README.md b/packages/simulator/README.md index c846d79..f289632 100644 --- a/packages/simulator/README.md +++ b/packages/simulator/README.md @@ -83,37 +83,49 @@ This is required because the simulator API expects a factory function for consis ### 2. Extending the Base Simulator -Create your simulator class with a user-friendly API: +Create your simulator class with a user-friendly API by overriding the static +`create`: ```typescript export class MyContractSimulator extends MyContractSimulatorBase { - constructor( + // Override `create` and return your own type. The base `create` returns the + // base `Simulator` (it can't be generic without breaking these overrides), so + // without this a `MyContractSimulator.create(...)` call would resolve to + // `Simulator` and lose the methods below. Delegate to `super._create`, which is + // typed against the contract's constructor args, so a wrong tuple is caught. + static async create( arg1: bigint, arg2: string, - options: BaseSimulatorOptions< + options: SimulatorOptions< MyContractPrivateState, ReturnType - > = {} - ) { - // Bundle args into tuple for parent class - super([arg1, arg2], options); + > = {}, + ): Promise { + return super._create([arg1, arg2], options) as Promise; } - // Wrap contract's circuits with callable methods - public getValue(): bigint { + // Wrap the contract's circuits with callable methods. Every circuit call is + // async. It returns a promise, so callers `await` it. + public getValue(): Promise { return this.circuits.impure.getValue(); } - public setValue(val: bigint): void { - this.circuits.impure.setValue(val); + public setValue(val: bigint): Promise<[]> { + return this.circuits.impure.setValue(val); } - public transfer(to: Uint8Array, amount: bigint): void { - this.circuits.impure.transfer(to, amount); + public transfer(to: Uint8Array, amount: bigint): Promise<[]> { + return this.circuits.impure.transfer(to, amount); } } ``` +> **Every subclass must override the static `create`** and return its own type +> (e.g. `Promise`), delegating to +> `super._create([...args], options)`. This applies even to subclasses that only +> add circuit methods without the override, `MyContractSimulator.create(...)` +> resolves to the base `Simulator` type and callers lose the subclass's methods. + ### 3. Circuit Types #### Pure Circuits diff --git a/packages/simulator/package.json b/packages/simulator/package.json index f7fc6b8..4d9329c 100644 --- a/packages/simulator/package.json +++ b/packages/simulator/package.json @@ -35,7 +35,7 @@ }, "scripts": { "build": "tsc -p .", - "types": "tsc -p tsconfig.json --noEmit", + "types": "tsc -p tsconfig.types.json", "test": "MIDNIGHT_BACKEND=dry vitest run", "test:live": "MIDNIGHT_BACKEND=live vitest run", "clean": "git clean -fXd" diff --git a/packages/simulator/src/create-contract.type-test.ts b/packages/simulator/src/create-contract.type-test.ts new file mode 100644 index 0000000..5fc138a --- /dev/null +++ b/packages/simulator/src/create-contract.type-test.ts @@ -0,0 +1,71 @@ +/** + * Type-level regression guard for the static `create` / `_create` contract. + * + * `yarn types` compiles `src/**` but NOT `test/**` (the sample simulators import + * generated, gitignored contract artifacts), and `vitest` strips types without + * checking them — so a regression in the `create` / `_create` typing would + * otherwise pass CI. This file reproduces the subclass-override pattern against a + * synthetic contract type, so `yarn types` fails on a regression. + * + * It exports nothing and is imported by nothing (inert at runtime). The build + * config (`tsconfig.json`) excludes `*.type-test.ts` from emit, so it never + * reaches `dist`; the type check runs it via `tsconfig.types.json`. + * + * Typechecking the real test simulators (which need the generated artifacts) is a + * separate, larger effort; this is the minimal guard for the contract that + * actually regressed. + */ +import { createSimulator } from './factory/createSimulator.js'; +import type { SimulatorConfig } from './factory/SimulatorConfig.js'; +import type { IMinimalContract } from './types/Contract.js'; + +type GuardPrivateState = { readonly value: number }; +type GuardArgs = readonly [a: number, b: string]; + +// A synthetic config — never executed, only used to instantiate the factory's +// generics for the type-level checks below. +const config = {} as unknown as SimulatorConfig< + GuardPrivateState, + unknown, + unknown, + IMinimalContract, + GuardArgs +>; + +class Guard extends createSimulator(config) { + // A concrete `Promise` return must stay assignable to the base static + // side. If `create`'s return goes generic again, this fails the static-side + // `extends` check (TS2417). + static async create(a: number, b: string, options = {}): Promise { + // biome-ignore lint/complexity/noThisInStatic: super._create must keep the subclass `this` + return super._create([a, b], options) as Promise; + } + + // `_create` must type its args tuple against `GuardArgs`. If that check + // regresses (e.g. `_create` widens to `...args: unknown[]`), the wrong-arity + // call below stops erroring and the `@ts-expect-error` becomes unused → CI red. + static async _argCheck(): Promise { + // Call via the class name (not `super`) so this negative assertion needs + // only the `@ts-expect-error` below — no `noThisInStatic` ignore to stack. + // @ts-expect-error a 1-element tuple must not satisfy `[number, string]`. + return Guard._create([1], {}); + } + + // A subclass-only member. Without it, `Guard`'s instance type would equal the + // base `Simulator`'s and the call-site check below would pass even if `create` + // stopped narrowing to the subclass. This mirrors real subclasses, which add + // circuit methods that a base-typed return would hide. + public marker(): string { + return 'guard'; + } +} + +// The override must narrow the call-site return to the subclass, not widen to +// the base `Simulator` (which lacks `marker`). +async function _callSiteSubtype(): Promise { + const instance: Guard = await Guard.create(1, 'x'); + void instance.marker(); +} + +void Guard; +void _callSiteSubtype; diff --git a/packages/simulator/src/factory/createSimulator.ts b/packages/simulator/src/factory/createSimulator.ts index 415a739..ee83bc9 100644 --- a/packages/simulator/src/factory/createSimulator.ts +++ b/packages/simulator/src/factory/createSimulator.ts @@ -191,21 +191,52 @@ export function createSimulator< } /** - * Constructs a simulator. In dry, deploys from `contractArgs` to fresh - * in-memory state. In live, the caller already deployed; the args seed only - * the local pure-eval context, never an on-chain deploy. + * Public entry point. Permissive params so subclasses can override `create` + * with contract-specific signatures without tripping TS's static-side + * `extends` check. Subclass overrides should assemble the typed args tuple + * and call {@link _create} (`super._create([...args], options)`) so the + * tuple is checked against `TArgs` — delegating back to this permissive + * `create` would silently skip that check. + * + * @param args - `[contractArgs?, options?]`, validated by `_create`. + * @returns The constructed simulator, typed as the base `Simulator`; subclass + * `create` overrides narrow the return to their own type. + */ + static async create( + this: new ( + deps: BackendDeps, + ) => Simulator, + ...args: unknown[] + ): Promise { + const [contractArgs, options] = args as [TArgs?, SimulatorOptions?]; + // biome-ignore lint/complexity/noThisInStatic: keep the caller's `this` so a non-overriding subclass constructs its own type, not the base `Simulator` (the autofix rewrites `this`->`Simulator`, breaking that at runtime). + return (this as unknown as typeof Simulator)._create( + contractArgs, + options, + ); + } + + /** + * Typed construction path. In dry, deploys from `contractArgs` to fresh + * in-memory state; in live the caller already deployed and the args seed + * only the local pure-eval context. Subclass `create` overrides call this + * (`super._create([...typedArgs], options)`) so their args tuple is checked + * against `TArgs`. Underscore-public to match the `_backend`/`_signers` + * convention for declaration emit. * * @param contractArgs - Constructor args for the contract. * @param options - Backend selection, witnesses, private state, live world. - * @returns The constructed simulator (subclass-aware via `this`). + * @returns The constructed simulator, typed as the base `Simulator`; subclass + * `create` overrides narrow the return to their own type. The runtime + * instance is the caller's class (constructed via `this`). */ - static async create( + static async _create( this: new ( deps: BackendDeps, - ) => T, + ) => Simulator, contractArgs: TArgs = [] as unknown as TArgs, options: SimulatorOptions = {}, - ): Promise { + ): Promise { const deps = await prepareBackend(contractArgs, options); return new this(deps); } diff --git a/packages/simulator/test/fixtures/sample-contracts/witnesses/WitnessWitnesses.ts b/packages/simulator/test/fixtures/sample-contracts/witnesses/WitnessWitnesses.ts index 47c8009..1527c9c 100644 --- a/packages/simulator/test/fixtures/sample-contracts/witnesses/WitnessWitnesses.ts +++ b/packages/simulator/test/fixtures/sample-contracts/witnesses/WitnessWitnesses.ts @@ -70,3 +70,12 @@ export const WitnessWitnesses = (): IWitnessWitnesses< ]; }, }); + +/** + * The witness set with the ledger type param `L` erased to `unknown` which is exactly + * what `ReturnType` yields, and what a simulator built + * from this factory uses as its witnesses type. Prefer this alias over spelling + * out `IWitnessWitnesses`, so the `unknown` reads + * as intentional (the erased `L`) rather than arbitrary. + */ +export type WitnessWitnessSet = ReturnType; diff --git a/packages/simulator/test/integration/SampleZOwnableSimulator.ts b/packages/simulator/test/integration/SampleZOwnableSimulator.ts index 94be4e0..8cf2589 100644 --- a/packages/simulator/test/integration/SampleZOwnableSimulator.ts +++ b/packages/simulator/test/integration/SampleZOwnableSimulator.ts @@ -52,8 +52,8 @@ export class SampleZOwnableSimulator extends SampleZOwnableSimulatorBase { ReturnType > = {}, ): Promise { - // biome-ignore lint/complexity/noThisInStatic: super.create must keep the subclass `this` - return super.create( + // biome-ignore lint/complexity/noThisInStatic: super._create must keep the subclass `this` + return super._create( [ownerId, instanceSalt], options, ) as Promise; diff --git a/packages/simulator/test/integration/SimpleSimulator.ts b/packages/simulator/test/integration/SimpleSimulator.ts index 6a2bf4c..5c464f1 100644 --- a/packages/simulator/test/integration/SimpleSimulator.ts +++ b/packages/simulator/test/integration/SimpleSimulator.ts @@ -35,8 +35,8 @@ export class SimpleSimulator extends SimpleSimulatorBase { ReturnType > = {}, ): Promise { - // biome-ignore lint/complexity/noThisInStatic: super.create must keep the subclass `this` - return super.create([], options) as Promise; + // biome-ignore lint/complexity/noThisInStatic: super._create must keep the subclass `this` + return super._create([], options) as Promise; } public setVal(n: bigint): Promise<[]> { diff --git a/packages/simulator/test/integration/Witness.test.ts b/packages/simulator/test/integration/Witness.test.ts index 7696a2f..5f9a1cd 100644 --- a/packages/simulator/test/integration/Witness.test.ts +++ b/packages/simulator/test/integration/Witness.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it } from 'vitest'; import type { - IWitnessWitnesses, WitnessPrivateState, + WitnessWitnessSet, } from '../fixtures/sample-contracts/witnesses/WitnessWitnesses'; import { WitnessSimulator } from './WitnessSimulator'; @@ -11,7 +11,7 @@ const BYTES_OVERRIDE = new Uint8Array(32).fill(1); const FIELD_OVERRIDE = 222n; const UINT_OVERRIDE = 333n; -const overrideWitnesses = (): IWitnessWitnesses => ({ +const overrideWitnesses = (): WitnessWitnessSet => ({ wit_secretBytes(ctx) { return [ctx.privateState, BYTES_OVERRIDE]; }, diff --git a/packages/simulator/test/integration/WitnessSimulator.ts b/packages/simulator/test/integration/WitnessSimulator.ts index 914fbcd..f45b38d 100644 --- a/packages/simulator/test/integration/WitnessSimulator.ts +++ b/packages/simulator/test/integration/WitnessSimulator.ts @@ -44,8 +44,8 @@ export class WitnessSimulator extends WitnessSimulatorBase { ReturnType > = {}, ): Promise { - // biome-ignore lint/complexity/noThisInStatic: super.create must keep the subclass `this` - return super.create([], options) as Promise; + // biome-ignore lint/complexity/noThisInStatic: super._create must keep the subclass `this` + return super._create([], options) as Promise; } public setBytes(): Promise<[]> { diff --git a/packages/simulator/tsconfig.json b/packages/simulator/tsconfig.json index 6439b36..dc6a948 100644 --- a/packages/simulator/tsconfig.json +++ b/packages/simulator/tsconfig.json @@ -16,6 +16,7 @@ "exclude": [ "node_modules", "dist", - "tests" + "tests", + "src/**/*.type-test.ts" ] -} \ No newline at end of file +} diff --git a/packages/simulator/tsconfig.types.json b/packages/simulator/tsconfig.types.json new file mode 100644 index 0000000..2728f6e --- /dev/null +++ b/packages/simulator/tsconfig.types.json @@ -0,0 +1,14 @@ +{ + // Typecheck-only project used by `yarn types`. + // + // Identical to the build config (`tsconfig.json`) except it (a) never emits and + // (b) re-includes the `*.type-test.ts` regression guards, which the build config + // excludes from emit so they don't ship in `dist`. Keeping them in the type + // check is the whole point: they fail CI if the guarded contracts regress. + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "tests"] +} diff --git a/turbo.json b/turbo.json index 08dd369..aac4177 100644 --- a/turbo.json +++ b/turbo.json @@ -19,6 +19,7 @@ "inputs": [ "src/**/*.ts", "!src/**/*.test.ts", + "!src/**/*.type-test.ts", "tsconfig.json", "tsconfig.build.json", ".env" @@ -27,7 +28,7 @@ }, "types": { "dependsOn": ["^build"], - "inputs": ["src/**/*.ts", "tsconfig.json"], + "inputs": ["src/**/*.ts", "tsconfig.json", "tsconfig.types.json"], "outputs": [] }, "lint": {},