Skip to content
Open
38 changes: 25 additions & 13 deletions packages/simulator/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof MyContractWitnesses>
> = {}
) {
// Bundle args into tuple for parent class
super([arg1, arg2], options);
> = {},
): Promise<MyContractSimulator> {
return super._create([arg1, arg2], options) as Promise<MyContractSimulator>;
}

// 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<bigint> {
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<MyContractSimulator>`), 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
Expand Down
2 changes: 1 addition & 1 deletion packages/simulator/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
71 changes: 71 additions & 0 deletions packages/simulator/src/create-contract.type-test.ts
Original file line number Diff line number Diff line change
@@ -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<Guard>` 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<Guard> {
// biome-ignore lint/complexity/noThisInStatic: super._create must keep the subclass `this`
return super._create([a, b], options) as Promise<Guard>;
}

// `_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<unknown> {
// 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<void> {
const instance: Guard = await Guard.create(1, 'x');
void instance.marker();
}

void Guard;
void _callSiteSubtype;
45 changes: 38 additions & 7 deletions packages/simulator/src/factory/createSimulator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 followupsuper.create(tuple, options) is no longer checked against TArgs.

Every subclass has exactly one call site that assembles the tuple, and it is now unchecked:

type Args = readonly [owner: Uint8Array, salt: Uint8Array];

static async create(owner: Uint8Array, salt: Uint8Array, options = {}) {
  return super.create([owner], options) as Promise<Sub>;  // forgot `salt`
}
  • on main: TS2345: Source has 1 element(s) but target requires 2
  • with this PR: compiles, then fails at runtime inside config.contractArgs(...contractArgs)

Direct calls on the base class lose the same protection. Base.create({ backend: 'dry' }) and five-argument calls both type-check now. main rejected both.

A typed sibling keeps override compatibility and the arg checking. I compiled this against all three sample simulators (clean), and the bad call above errors again:

static async create(
  this: new (deps: BackendDeps<P, L>) => Simulator,
  ...args: unknown[]
): Promise<Simulator> {
  const [contractArgs, options] = args as [TArgs?, SimulatorOptions<P, W>?];
  return (this as unknown as typeof Simulator)._create.call(this, contractArgs, options);
}

/** Typed construction path; subclass `create` overrides should call this. */
static async _create(
  this: new (deps: BackendDeps<P, L>) => Simulator,
  contractArgs: TArgs = [] as unknown as TArgs,
  options: SimulatorOptions<P, W> = {},
): Promise<Simulator> {
  const deps = await prepareBackend(contractArgs, options);
  return new this(deps);
}

Subclasses then call super._create([owner, salt], options). Underscore-public matches the existing _backend / _signers convention for declaration emit. Purely additive, so it can also land after this PR.

added by claude (dev3-code-review)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, this is better 👍

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this: new (
deps: BackendDeps<P, L>,
) => Simulator,
...args: unknown[]
): Promise<Simulator> {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 followup — a subclass that does not override create loses its own instance type.

class NoOverride extends Base {
  bump() { return this.circuits.impure.setVal(1n); }
}
await (await NoOverride.create([1n])).bump();
// this PR: TS2339: Property 'bump' does not exist on type '...Simulator'
// main:    fine, `T` was inferred from `this`

Overriding create becomes mandatory for every subclass, including ones that only add circuit methods. Blast radius is small today (composedTokens.ts and sharedInitCollision.ts are the only non-overriders in compact-contracts), but it narrows the API contract. Worth a README line so people meet it as documentation rather than as a type error.

added by claude (dev3-code-review)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Indirectly addressed above. Improved readme doc d9fea4f

const [contractArgs, options] = args as [TArgs?, SimulatorOptions<P, W>?];
// 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<T extends Simulator>(
static async _create(
this: new (
deps: BackendDeps<P, L>,
) => T,
) => Simulator,
contractArgs: TArgs = [] as unknown as TArgs,
options: SimulatorOptions<P, W> = {},
): Promise<T> {
): Promise<Simulator> {
const deps = await prepareBackend(contractArgs, options);
return new this(deps);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,3 +70,12 @@ export const WitnessWitnesses = <L>(): IWitnessWitnesses<
];
},
});

/**
* The witness set with the ledger type param `L` erased to `unknown` which is exactly
* what `ReturnType<typeof WitnessWitnesses>` yields, and what a simulator built
* from this factory uses as its witnesses type. Prefer this alias over spelling
* out `IWitnessWitnesses<unknown, WitnessPrivateState>`, so the `unknown` reads
* as intentional (the erased `L`) rather than arbitrary.
*/
export type WitnessWitnessSet = ReturnType<typeof WitnessWitnesses>;
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,8 @@ export class SampleZOwnableSimulator extends SampleZOwnableSimulatorBase {
ReturnType<typeof SampleZOwnableWitnesses>
> = {},
): Promise<SampleZOwnableSimulator> {
// 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<SampleZOwnableSimulator>;
Expand Down
4 changes: 2 additions & 2 deletions packages/simulator/test/integration/SimpleSimulator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,8 @@ export class SimpleSimulator extends SimpleSimulatorBase {
ReturnType<typeof SimpleWitnesses>
> = {},
): Promise<SimpleSimulator> {
// biome-ignore lint/complexity/noThisInStatic: super.create must keep the subclass `this`
return super.create([], options) as Promise<SimpleSimulator>;
// biome-ignore lint/complexity/noThisInStatic: super._create must keep the subclass `this`
return super._create([], options) as Promise<SimpleSimulator>;
}

public setVal(n: bigint): Promise<[]> {
Expand Down
4 changes: 2 additions & 2 deletions packages/simulator/test/integration/Witness.test.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -11,7 +11,7 @@ const BYTES_OVERRIDE = new Uint8Array(32).fill(1);
const FIELD_OVERRIDE = 222n;
const UINT_OVERRIDE = 333n;

const overrideWitnesses = (): IWitnessWitnesses<WitnessPrivateState> => ({
const overrideWitnesses = (): WitnessWitnessSet => ({
wit_secretBytes(ctx) {
return [ctx.privateState, BYTES_OVERRIDE];
},
Expand Down
4 changes: 2 additions & 2 deletions packages/simulator/test/integration/WitnessSimulator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,8 @@ export class WitnessSimulator extends WitnessSimulatorBase {
ReturnType<typeof WitnessWitnesses>
> = {},
): Promise<WitnessSimulator> {
// biome-ignore lint/complexity/noThisInStatic: super.create must keep the subclass `this`
return super.create([], options) as Promise<WitnessSimulator>;
// biome-ignore lint/complexity/noThisInStatic: super._create must keep the subclass `this`
return super._create([], options) as Promise<WitnessSimulator>;
}

public setBytes(): Promise<[]> {
Expand Down
5 changes: 3 additions & 2 deletions packages/simulator/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"exclude": [
"node_modules",
"dist",
"tests"
"tests",
"src/**/*.type-test.ts"
]
}
}
14 changes: 14 additions & 0 deletions packages/simulator/tsconfig.types.json
Original file line number Diff line number Diff line change
@@ -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"]
}
3 changes: 2 additions & 1 deletion turbo.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"inputs": [
"src/**/*.ts",
"!src/**/*.test.ts",
"!src/**/*.type-test.ts",
"tsconfig.json",
"tsconfig.build.json",
".env"
Expand All @@ -27,7 +28,7 @@
},
"types": {
"dependsOn": ["^build"],
"inputs": ["src/**/*.ts", "tsconfig.json"],
"inputs": ["src/**/*.ts", "tsconfig.json", "tsconfig.types.json"],
"outputs": []
},
"lint": {},
Expand Down
Loading