Skip to content

Commit cf56a47

Browse files
Fix create (#134)
* fix create method * move emit to build.json, fold test sims into the types check * revert typecheck refactor * add _create type sibling * improve doc on overriding create * fix return doc in create * add named alias (so it looks intentional) * add regression guard --------- Co-authored-by: 0xisk <iskander.andrews@openzeppelin.com>
1 parent e5ae90b commit cf56a47

12 files changed

Lines changed: 171 additions & 32 deletions

File tree

packages/simulator/README.md

Lines changed: 25 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -83,37 +83,49 @@ This is required because the simulator API expects a factory function for consis
8383

8484
### 2. Extending the Base Simulator
8585

86-
Create your simulator class with a user-friendly API:
86+
Create your simulator class with a user-friendly API by overriding the static
87+
`create`:
8788

8889
```typescript
8990
export class MyContractSimulator extends MyContractSimulatorBase {
90-
constructor(
91+
// Override `create` and return your own type. The base `create` returns the
92+
// base `Simulator` (it can't be generic without breaking these overrides), so
93+
// without this a `MyContractSimulator.create(...)` call would resolve to
94+
// `Simulator` and lose the methods below. Delegate to `super._create`, which is
95+
// typed against the contract's constructor args, so a wrong tuple is caught.
96+
static async create(
9197
arg1: bigint,
9298
arg2: string,
93-
options: BaseSimulatorOptions<
99+
options: SimulatorOptions<
94100
MyContractPrivateState,
95101
ReturnType<typeof MyContractWitnesses>
96-
> = {}
97-
) {
98-
// Bundle args into tuple for parent class
99-
super([arg1, arg2], options);
102+
> = {},
103+
): Promise<MyContractSimulator> {
104+
return super._create([arg1, arg2], options) as Promise<MyContractSimulator>;
100105
}
101106

102-
// Wrap contract's circuits with callable methods
103-
public getValue(): bigint {
107+
// Wrap the contract's circuits with callable methods. Every circuit call is
108+
// async. It returns a promise, so callers `await` it.
109+
public getValue(): Promise<bigint> {
104110
return this.circuits.impure.getValue();
105111
}
106112

107-
public setValue(val: bigint): void {
108-
this.circuits.impure.setValue(val);
113+
public setValue(val: bigint): Promise<[]> {
114+
return this.circuits.impure.setValue(val);
109115
}
110116

111-
public transfer(to: Uint8Array, amount: bigint): void {
112-
this.circuits.impure.transfer(to, amount);
117+
public transfer(to: Uint8Array, amount: bigint): Promise<[]> {
118+
return this.circuits.impure.transfer(to, amount);
113119
}
114120
}
115121
```
116122

123+
> **Every subclass must override the static `create`** and return its own type
124+
> (e.g. `Promise<MyContractSimulator>`), delegating to
125+
> `super._create([...args], options)`. This applies even to subclasses that only
126+
> add circuit methods without the override, `MyContractSimulator.create(...)`
127+
> resolves to the base `Simulator` type and callers lose the subclass's methods.
128+
117129
### 3. Circuit Types
118130

119131
#### Pure Circuits

packages/simulator/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@
3535
},
3636
"scripts": {
3737
"build": "tsc -p .",
38-
"types": "tsc -p tsconfig.json --noEmit",
38+
"types": "tsc -p tsconfig.types.json",
3939
"test": "MIDNIGHT_BACKEND=dry vitest run",
4040
"test:live": "MIDNIGHT_BACKEND=live vitest run",
4141
"clean": "git clean -fXd"
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
/**
2+
* Type-level regression guard for the static `create` / `_create` contract.
3+
*
4+
* `yarn types` compiles `src/**` but NOT `test/**` (the sample simulators import
5+
* generated, gitignored contract artifacts), and `vitest` strips types without
6+
* checking them — so a regression in the `create` / `_create` typing would
7+
* otherwise pass CI. This file reproduces the subclass-override pattern against a
8+
* synthetic contract type, so `yarn types` fails on a regression.
9+
*
10+
* It exports nothing and is imported by nothing (inert at runtime). The build
11+
* config (`tsconfig.json`) excludes `*.type-test.ts` from emit, so it never
12+
* reaches `dist`; the type check runs it via `tsconfig.types.json`.
13+
*
14+
* Typechecking the real test simulators (which need the generated artifacts) is a
15+
* separate, larger effort; this is the minimal guard for the contract that
16+
* actually regressed.
17+
*/
18+
import { createSimulator } from './factory/createSimulator.js';
19+
import type { SimulatorConfig } from './factory/SimulatorConfig.js';
20+
import type { IMinimalContract } from './types/Contract.js';
21+
22+
type GuardPrivateState = { readonly value: number };
23+
type GuardArgs = readonly [a: number, b: string];
24+
25+
// A synthetic config — never executed, only used to instantiate the factory's
26+
// generics for the type-level checks below.
27+
const config = {} as unknown as SimulatorConfig<
28+
GuardPrivateState,
29+
unknown,
30+
unknown,
31+
IMinimalContract,
32+
GuardArgs
33+
>;
34+
35+
class Guard extends createSimulator(config) {
36+
// A concrete `Promise<Guard>` return must stay assignable to the base static
37+
// side. If `create`'s return goes generic again, this fails the static-side
38+
// `extends` check (TS2417).
39+
static async create(a: number, b: string, options = {}): Promise<Guard> {
40+
// biome-ignore lint/complexity/noThisInStatic: super._create must keep the subclass `this`
41+
return super._create([a, b], options) as Promise<Guard>;
42+
}
43+
44+
// `_create` must type its args tuple against `GuardArgs`. If that check
45+
// regresses (e.g. `_create` widens to `...args: unknown[]`), the wrong-arity
46+
// call below stops erroring and the `@ts-expect-error` becomes unused → CI red.
47+
static async _argCheck(): Promise<unknown> {
48+
// Call via the class name (not `super`) so this negative assertion needs
49+
// only the `@ts-expect-error` below — no `noThisInStatic` ignore to stack.
50+
// @ts-expect-error a 1-element tuple must not satisfy `[number, string]`.
51+
return Guard._create([1], {});
52+
}
53+
54+
// A subclass-only member. Without it, `Guard`'s instance type would equal the
55+
// base `Simulator`'s and the call-site check below would pass even if `create`
56+
// stopped narrowing to the subclass. This mirrors real subclasses, which add
57+
// circuit methods that a base-typed return would hide.
58+
public marker(): string {
59+
return 'guard';
60+
}
61+
}
62+
63+
// The override must narrow the call-site return to the subclass, not widen to
64+
// the base `Simulator` (which lacks `marker`).
65+
async function _callSiteSubtype(): Promise<void> {
66+
const instance: Guard = await Guard.create(1, 'x');
67+
void instance.marker();
68+
}
69+
70+
void Guard;
71+
void _callSiteSubtype;

packages/simulator/src/factory/createSimulator.ts

Lines changed: 38 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -191,21 +191,52 @@ export function createSimulator<
191191
}
192192

193193
/**
194-
* Constructs a simulator. In dry, deploys from `contractArgs` to fresh
195-
* in-memory state. In live, the caller already deployed; the args seed only
196-
* the local pure-eval context, never an on-chain deploy.
194+
* Public entry point. Permissive params so subclasses can override `create`
195+
* with contract-specific signatures without tripping TS's static-side
196+
* `extends` check. Subclass overrides should assemble the typed args tuple
197+
* and call {@link _create} (`super._create([...args], options)`) so the
198+
* tuple is checked against `TArgs` — delegating back to this permissive
199+
* `create` would silently skip that check.
200+
*
201+
* @param args - `[contractArgs?, options?]`, validated by `_create`.
202+
* @returns The constructed simulator, typed as the base `Simulator`; subclass
203+
* `create` overrides narrow the return to their own type.
204+
*/
205+
static async create(
206+
this: new (
207+
deps: BackendDeps<P, L>,
208+
) => Simulator,
209+
...args: unknown[]
210+
): Promise<Simulator> {
211+
const [contractArgs, options] = args as [TArgs?, SimulatorOptions<P, W>?];
212+
// 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).
213+
return (this as unknown as typeof Simulator)._create(
214+
contractArgs,
215+
options,
216+
);
217+
}
218+
219+
/**
220+
* Typed construction path. In dry, deploys from `contractArgs` to fresh
221+
* in-memory state; in live the caller already deployed and the args seed
222+
* only the local pure-eval context. Subclass `create` overrides call this
223+
* (`super._create([...typedArgs], options)`) so their args tuple is checked
224+
* against `TArgs`. Underscore-public to match the `_backend`/`_signers`
225+
* convention for declaration emit.
197226
*
198227
* @param contractArgs - Constructor args for the contract.
199228
* @param options - Backend selection, witnesses, private state, live world.
200-
* @returns The constructed simulator (subclass-aware via `this`).
229+
* @returns The constructed simulator, typed as the base `Simulator`; subclass
230+
* `create` overrides narrow the return to their own type. The runtime
231+
* instance is the caller's class (constructed via `this`).
201232
*/
202-
static async create<T extends Simulator>(
233+
static async _create(
203234
this: new (
204235
deps: BackendDeps<P, L>,
205-
) => T,
236+
) => Simulator,
206237
contractArgs: TArgs = [] as unknown as TArgs,
207238
options: SimulatorOptions<P, W> = {},
208-
): Promise<T> {
239+
): Promise<Simulator> {
209240
const deps = await prepareBackend(contractArgs, options);
210241
return new this(deps);
211242
}

packages/simulator/test/fixtures/sample-contracts/witnesses/WitnessWitnesses.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,3 +70,12 @@ export const WitnessWitnesses = <L>(): IWitnessWitnesses<
7070
];
7171
},
7272
});
73+
74+
/**
75+
* The witness set with the ledger type param `L` erased to `unknown` which is exactly
76+
* what `ReturnType<typeof WitnessWitnesses>` yields, and what a simulator built
77+
* from this factory uses as its witnesses type. Prefer this alias over spelling
78+
* out `IWitnessWitnesses<unknown, WitnessPrivateState>`, so the `unknown` reads
79+
* as intentional (the erased `L`) rather than arbitrary.
80+
*/
81+
export type WitnessWitnessSet = ReturnType<typeof WitnessWitnesses>;

packages/simulator/test/integration/SampleZOwnableSimulator.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,8 @@ export class SampleZOwnableSimulator extends SampleZOwnableSimulatorBase {
5252
ReturnType<typeof SampleZOwnableWitnesses>
5353
> = {},
5454
): Promise<SampleZOwnableSimulator> {
55-
// biome-ignore lint/complexity/noThisInStatic: super.create must keep the subclass `this`
56-
return super.create(
55+
// biome-ignore lint/complexity/noThisInStatic: super._create must keep the subclass `this`
56+
return super._create(
5757
[ownerId, instanceSalt],
5858
options,
5959
) as Promise<SampleZOwnableSimulator>;

packages/simulator/test/integration/SimpleSimulator.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,8 @@ export class SimpleSimulator extends SimpleSimulatorBase {
3535
ReturnType<typeof SimpleWitnesses>
3636
> = {},
3737
): Promise<SimpleSimulator> {
38-
// biome-ignore lint/complexity/noThisInStatic: super.create must keep the subclass `this`
39-
return super.create([], options) as Promise<SimpleSimulator>;
38+
// biome-ignore lint/complexity/noThisInStatic: super._create must keep the subclass `this`
39+
return super._create([], options) as Promise<SimpleSimulator>;
4040
}
4141

4242
public setVal(n: bigint): Promise<[]> {

packages/simulator/test/integration/Witness.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { beforeEach, describe, expect, it } from 'vitest';
22
import type {
3-
IWitnessWitnesses,
43
WitnessPrivateState,
4+
WitnessWitnessSet,
55
} from '../fixtures/sample-contracts/witnesses/WitnessWitnesses';
66
import { WitnessSimulator } from './WitnessSimulator';
77

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

14-
const overrideWitnesses = (): IWitnessWitnesses<WitnessPrivateState> => ({
14+
const overrideWitnesses = (): WitnessWitnessSet => ({
1515
wit_secretBytes(ctx) {
1616
return [ctx.privateState, BYTES_OVERRIDE];
1717
},

packages/simulator/test/integration/WitnessSimulator.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,8 @@ export class WitnessSimulator extends WitnessSimulatorBase {
4444
ReturnType<typeof WitnessWitnesses>
4545
> = {},
4646
): Promise<WitnessSimulator> {
47-
// biome-ignore lint/complexity/noThisInStatic: super.create must keep the subclass `this`
48-
return super.create([], options) as Promise<WitnessSimulator>;
47+
// biome-ignore lint/complexity/noThisInStatic: super._create must keep the subclass `this`
48+
return super._create([], options) as Promise<WitnessSimulator>;
4949
}
5050

5151
public setBytes(): Promise<[]> {

packages/simulator/tsconfig.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
"exclude": [
1717
"node_modules",
1818
"dist",
19-
"tests"
19+
"tests",
20+
"src/**/*.type-test.ts"
2021
]
21-
}
22+
}

0 commit comments

Comments
 (0)