From 96a4b80a34cc8b53f25e4b88ed3f8a49feb853e5 Mon Sep 17 00:00:00 2001 From: 0xisk <0xisk@proton.me> Date: Fri, 25 Sep 2026 05:04:06 +0200 Subject: [PATCH 1/2] refactor(simulator)!: use midnight-js live options createLiveContext takes FindDeployedContractOptionsExistingPrivateState and ContractProviders instead of hand-rolled fields, so the contract type is inferred and new midnight-js options need no simulator change. The default signer's providers now serve the public-data and private-state reads. BREAKING CHANGE: createLiveContext options move to findOptions and providersFor; contractAddress, compiledContract, privateStateId, publicDataProvider and privateStateProvider are removed. --- packages/simulator/CHANGELOG.md | 1 + packages/simulator/README.md | 3 +- packages/simulator/package.json | 5 + .../simulator/src/live/createLiveContext.ts | 106 +++++------ .../test/unit/createLiveContext.test.ts | 179 ++++++++++-------- yarn.lock | 4 + 6 files changed, 158 insertions(+), 140 deletions(-) diff --git a/packages/simulator/CHANGELOG.md b/packages/simulator/CHANGELOG.md index 938998a..2227082 100644 --- a/packages/simulator/CHANGELOG.md +++ b/packages/simulator/CHANGELOG.md @@ -16,6 +16,7 @@ Releases before `0.4.0` predate this file; see the `compact-simulator/v*` tags. ### Changed +- **Breaking:** `createLiveContext` takes midnight-js types. `findOptions` replaces `contractAddress`, `compiledContract` and `privateStateId`. `providersFor` returns `ContractProviders`, and `providersFor(null)` replaces `publicDataProvider` and `privateStateProvider`. `@midnight-ntwrk/compact-js` joins the optional peers - **Breaking:** `@midnight-ntwrk/compact-runtime` moves to `0.19.0`, which made `initialState` and every circuit async. The `@midnight-ntwrk/midnight-js-contracts` and `@midnight-ntwrk/midnight-js-types` peers move from `^4.1.0` to `^5.0.0-beta.7`. Both peers stay optional, so a dry-only consumer needs neither (#152) - **Breaking:** construction is async. Replace `new MySimulator(args, options)` with `await MySimulator.create(args, options)`. Subclass `create` overrides return `Promise` and delegate to `super._create([...args], options)` (#145, #147) - **Breaking:** circuit proxies return promises. `ContextlessCircuits` maps to `Promise`, so an un-awaited call that used to pass as a truthy promise, such as `if (sim.isOwner(x))`, no longer typechecks (#145, #154) diff --git a/packages/simulator/README.md b/packages/simulator/README.md index 5ac565d..8e6a16e 100644 --- a/packages/simulator/README.md +++ b/packages/simulator/README.md @@ -208,7 +208,8 @@ wallet's coin and encryption public keys (its `WalletProvider`'s ```typescript createLiveContext({ - // ... + providersFor, // alias => ContractProviders + findOptions: { compiledContract, contractAddress, privateStateId }, scopedTransactionOptions: { additionalCoinEncPublicKeyMappings: new Map([ [bob.getCoinPublicKey(), bob.getEncryptionPublicKey()], diff --git a/packages/simulator/package.json b/packages/simulator/package.json index dfca761..44b04a5 100644 --- a/packages/simulator/package.json +++ b/packages/simulator/package.json @@ -43,6 +43,7 @@ "clean": "git clean -fXd" }, "devDependencies": { + "@midnight-ntwrk/compact-js": "2.5.5-rc.8", "@midnight-ntwrk/midnight-js-contracts": "5.0.0-beta.7", "@midnight-ntwrk/midnight-js-types": "5.0.0-beta.7", "@noble/curves": "^2.2.0", @@ -57,10 +58,14 @@ "@midnight-ntwrk/compact-runtime": "0.19.0" }, "peerDependencies": { + "@midnight-ntwrk/compact-js": "^2.5.5-rc.8", "@midnight-ntwrk/midnight-js-contracts": "^5.0.0-beta.7", "@midnight-ntwrk/midnight-js-types": "^5.0.0-beta.7" }, "peerDependenciesMeta": { + "@midnight-ntwrk/compact-js": { + "optional": true + }, "@midnight-ntwrk/midnight-js-contracts": { "optional": true }, diff --git a/packages/simulator/src/live/createLiveContext.ts b/packages/simulator/src/live/createLiveContext.ts index 6cf943f..e7bead9 100644 --- a/packages/simulator/src/live/createLiveContext.ts +++ b/packages/simulator/src/live/createLiveContext.ts @@ -1,12 +1,13 @@ +import type { Contract } from '@midnight-ntwrk/compact-js'; import type { StateValue } from '@midnight-ntwrk/compact-runtime'; // Type-only imports — erased at build, so they create no runtime edge to // midnight-js. The only runtime midnight-js edge in this file is the // lazy dynamic import inside `loadContracts`. -import type { ScopedTransactionOptions } from '@midnight-ntwrk/midnight-js-contracts'; import type { - PrivateStateProvider, - PublicDataProvider, -} from '@midnight-ntwrk/midnight-js-types'; + ContractProviders, + FindDeployedContractOptionsExistingPrivateState, + ScopedTransactionOptions, +} from '@midnight-ntwrk/midnight-js-contracts'; import type { DeployedTxHandle, LiveContext } from './LiveContext.js'; /** @@ -34,38 +35,21 @@ export const DEFAULT_INDEXER_LAG: IndexerLagPolicy = { * Options for {@link createLiveContext}. * * The package only assembles already-provided pieces; deploy, provider - * construction, and wallet funding are the caller's harness. Provider - * and contract specifics are threaded through opaquely — the harness owns their - * construction and types. + * construction, and wallet funding are the caller's harness. * - * @template P - Private state type. + * @template C - Contract type. */ -export interface CreateLiveContextOptions

{ - /** The address of the already-deployed contract. */ - contractAddress: string; +export interface CreateLiveContextOptions< + C extends Contract.Any = Contract.Any, +> { /** - * Per-alias `ContractProviders`, supplied by the harness. The wallet/signing - * differs per alias; `null` is the default signer. Threaded into - * `findDeployedContract`. + * Per-alias providers; `null` is the default signer. Public and private + * state are read through `providersFor(null)`, so aliases should share its + * private-state provider, and the harness calls its `setContractAddress`. */ - providersFor: (alias: string | null) => unknown; - /** The compiled contract, from the harness. Threaded into `findDeployedContract`. */ - compiledContract: unknown; - /** Identifier under which the contract's private state is stored. */ - privateStateId: string; - /** Provider for reading on-chain public state. */ - publicDataProvider: PublicDataProvider; - /** - * Provider for reading and (optionally) writing private state. - * - * Invariant: this MUST be the same provider instance wired into the - * `providersFor(alias)` bundle handed to `findDeployedContract`. The - * `setPrivateState` write below targets this provider, and the next - * `callTx` reads private state from the bundle's provider; if they are - * different instances the write is invisible to proving. `setContractAddress` - * is the harness's responsibility (already required for `get`). - */ - privateStateProvider: PrivateStateProvider; + providersFor: (alias: string | null) => ContractProviders; + /** Passed to `findDeployedContract` for every alias. */ + findOptions: FindDeployedContractOptionsExistingPrivateState; /** Runs every live call in a midnight-js scoped transaction with these options. */ scopedTransactionOptions?: ScopedTransactionOptions; /** Optional override of the indexer-lag policy. */ @@ -110,16 +94,18 @@ const sleep = (ms: number): Promise => * adapter ({@link LiveContext}) stays thin; this helper is separate and * imported only by live consumers (who already depend on midnight-js). * - * @param options - Harness-provided providers, contract, and address. + * @param options - Harness-provided providers and `findDeployedContract` options. * @returns A {@link LiveContext} ready to pass to `create(args, { live })`. */ -export function createLiveContext

( - options: CreateLiveContextOptions

, -): LiveContext

{ +export function createLiveContext( + options: CreateLiveContextOptions, +): LiveContext> { const lag: IndexerLagPolicy = { ...DEFAULT_INDEXER_LAG, ...options.indexerLag, }; + const { findOptions, scopedTransactionOptions } = options; + const { contractAddress, privateStateId } = findOptions; const handleCache = new Map>(); const resolveHandle = (alias: string | null): Promise => { @@ -127,15 +113,14 @@ export function createLiveContext

( const cached = handleCache.get(key); if (cached) return cached; const built = loadContracts().then(async (contracts) => { - const providers = options.providersFor(alias) as never; - const compiledContract = options.compiledContract as never; - const { contractAddress, privateStateId, scopedTransactionOptions } = - options; - const handle = await contracts.findDeployedContract(providers, { - compiledContract, - contractAddress, - privateStateId, - }); + const providers = options.providersFor(alias); + const found = await contracts.findDeployedContract( + providers, + findOptions, + ); + // Erase `FoundContract` to the structural handle `LiveBackend` reads; + // its typed `callTx` overloads are not assignable to `DeployedTxHandle`. + const handle = found as unknown as DeployedTxHandle; if (scopedTransactionOptions === undefined) return handle; // A scoped transaction is the only midnight-js entry point that takes // these options, so each call becomes a scope holding just that call. @@ -159,7 +144,7 @@ export function createLiveContext

( }; return { - contractAddress: options.contractAddress, + contractAddress, handleFor: resolveHandle, @@ -170,13 +155,13 @@ export function createLiveContext

( * `ledgerExtractor` consumes. */ async queryLedger(): Promise { + const { publicDataProvider } = options.providersFor(null); let delay = lag.baseDelayMs; let lastErr: unknown; for (let attempt = 0; attempt <= lag.retries; attempt++) { try { - const state = await options.publicDataProvider.queryContractState( - options.contractAddress, - ); + const state = + await publicDataProvider.queryContractState(contractAddress); if (state != null) { // `ContractState.data` is the StateValue the dry path also extracts. return (state as unknown as { data: StateValue }).data; @@ -190,31 +175,28 @@ export function createLiveContext

( } } throw new Error( - `no contract state at ${options.contractAddress} after ${lag.retries + 1} ` + + `no contract state at ${contractAddress} after ${lag.retries + 1} ` + 'attempts — the write may be missing, or indexer lag exceeds the budget', lastErr === undefined ? undefined : { cause: lastErr }, ); }, - async queryPrivateState(): Promise

{ - const state = await options.privateStateProvider.get( - options.privateStateId, - ); + async queryPrivateState(): Promise> { + const { privateStateProvider } = options.providersFor(null); + const state = await privateStateProvider.get(privateStateId); if (state == null) { - throw new Error( - `no private state stored at "${options.privateStateId}"`, - ); + throw new Error(`no private state stored at "${privateStateId}"`); } return state; }, /** - * Writes the whole private state to the provider under `privateStateId`. - * The next impure `callTx` reads it fresh (no handle-cache invalidation - * needed). See the invariant on {@link CreateLiveContextOptions.privateStateProvider}. + * Writes the whole private state under `privateStateId`. The next impure + * `callTx` reads it fresh (no handle-cache invalidation needed). */ - async setPrivateState(state: P): Promise { - await options.privateStateProvider.set(options.privateStateId, state); + async setPrivateState(state: Contract.PrivateState): Promise { + const { privateStateProvider } = options.providersFor(null); + await privateStateProvider.set(privateStateId, state); }, }; } diff --git a/packages/simulator/test/unit/createLiveContext.test.ts b/packages/simulator/test/unit/createLiveContext.test.ts index 8b1db2a..a78f27d 100644 --- a/packages/simulator/test/unit/createLiveContext.test.ts +++ b/packages/simulator/test/unit/createLiveContext.test.ts @@ -1,5 +1,9 @@ -import type { ScopedTransactionOptions } from '@midnight-ntwrk/midnight-js-contracts'; -import type { PrivateStateProvider } from '@midnight-ntwrk/midnight-js-types'; +import type { Contract } from '@midnight-ntwrk/compact-js'; +import type { + ContractProviders, + FindDeployedContractOptionsExistingPrivateState, + ScopedTransactionOptions, +} from '@midnight-ntwrk/midnight-js-contracts'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { createLiveContext } from '../../src/live/createLiveContext.js'; @@ -11,63 +15,50 @@ const contracts = vi.hoisted(() => ({ vi.mock('@midnight-ntwrk/midnight-js-contracts', () => contracts); type PS = { secretKey: Uint8Array }; +type C = Contract; -/** - * An in-memory stand-in for the harness's `PrivateStateProvider`, exercising - * only the `get`/`set` slice `createLiveContext` uses. midnight-js-contracts is - * mocked; the private-state tests never reach it. - */ -const fakeProvider = (initial: Record = {}) => { - const store = new Map(Object.entries(initial)); - const calls: Array<{ id: string; state: PS }> = []; - const provider = { - async get(id: string) { - return store.get(id) ?? null; - }, - async set(id: string, state: PS) { - calls.push({ id, state }); - store.set(id, state); - }, - } as unknown as PrivateStateProvider; - return { provider, calls }; -}; - -const makeContext = (provider: PrivateStateProvider) => - createLiveContext({ - contractAddress: '0200cafef00d', - providersFor: () => ({}), - compiledContract: {}, - privateStateId: 'my-contract', - publicDataProvider: {} as never, - privateStateProvider: provider, - }); - -describe('createLiveContext private-state write', () => { - it('writes the whole private state to the provider under privateStateId', async () => { - const { provider, calls } = fakeProvider(); - const ctx = makeContext(provider); - - const sk = Uint8Array.of(1, 2, 3); - await ctx.setPrivateState?.({ secretKey: sk }); - - expect(calls).toEqual([{ id: 'my-contract', state: { secretKey: sk } }]); - }); - - it('is observable by queryPrivateState (read-after-write parity)', async () => { - const { provider } = fakeProvider({ - 'my-contract': { secretKey: Uint8Array.of(0) }, - }); - const ctx = makeContext(provider); +const CONTRACT_ADDRESS = '0200cafef00d'; +const PRIVATE_STATE_ID = 'my-contract'; - const sk = Uint8Array.of(9, 9, 9); - await ctx.setPrivateState?.({ secretKey: sk }); +const findOptions = { + compiledContract: { name: 'compiled' }, + contractAddress: CONTRACT_ADDRESS, + privateStateId: PRIVATE_STATE_ID, +} as unknown as FindDeployedContractOptionsExistingPrivateState; - expect(await ctx.queryPrivateState()).toEqual({ secretKey: sk }); +/** + * Per-alias provider bundles holding only the slices `createLiveContext` uses: + * an in-memory private-state store and a public-data reader. midnight-js-contracts + * is mocked, so no real provider is built. + */ +const fakeProviders = (contractState: unknown = null) => { + const store = new Map(); + const bundles = new Map>(); + const providersFor = vi.fn((alias: string | null) => { + let bundle = bundles.get(alias); + if (!bundle) { + bundle = { + alias, + privateStateProvider: { + async get(id: string) { + return store.get(id) ?? null; + }, + async set(id: string, state: PS) { + store.set(id, state); + }, + }, + publicDataProvider: { + queryContractState: vi.fn(async () => contractState), + }, + } as unknown as ContractProviders; + bundles.set(alias, bundle); + } + return bundle; }); -}); + return { providersFor, store }; +}; -describe('createLiveContext scoped-transaction options', () => { - const COMPILED = { name: 'compiled' }; +describe('createLiveContext', () => { const TX_CTX = { txCtx: true }; const FINALIZED = { private: { result: [] } }; const OPTIONS: ScopedTransactionOptions = { @@ -77,17 +68,6 @@ describe('createLiveContext scoped-transaction options', () => { }; const deployed = { callTx: { deposit: vi.fn(), getParent: vi.fn() } }; - const liveContext = (scopedTransactionOptions?: ScopedTransactionOptions) => - createLiveContext({ - contractAddress: '0200cafef00d', - providersFor: (alias) => ({ alias }), - compiledContract: COMPILED, - privateStateId: 'my-contract', - publicDataProvider: {} as never, - privateStateProvider: fakeProvider().provider, - scopedTransactionOptions, - }); - beforeEach(() => { vi.clearAllMocks(); contracts.findDeployedContract.mockResolvedValue(deployed); @@ -99,22 +79,35 @@ describe('createLiveContext scoped-transaction options', () => { ); }); - it('returns the deployed handle as-is without options', async () => { - const handle = await liveContext().handleFor(null); + it('exposes the contract address from findOptions', () => { + const { providersFor } = fakeProviders(); + const ctx = createLiveContext({ providersFor, findOptions }); + + expect(ctx.contractAddress).toBe(CONTRACT_ADDRESS); + }); + + it('finds the contract with findOptions and the alias providers', async () => { + const { providersFor } = fakeProviders(); + const handle = await createLiveContext({ + providersFor, + findOptions, + }).handleFor('ALICE'); expect(handle).toBe(deployed); - expect(contracts.findDeployedContract).toHaveBeenCalledWith( - { alias: null }, - { - compiledContract: COMPILED, - contractAddress: '0200cafef00d', - privateStateId: 'my-contract', - }, - ); + expect(contracts.findDeployedContract).toHaveBeenCalledOnce(); + const [providers, options] = + contracts.findDeployedContract.mock.calls[0] ?? []; + expect(providers).toBe(providersFor('ALICE')); + expect(options).toBe(findOptions); }); it('runs every circuit call in its own scoped transaction with the options', async () => { - const handle = await liveContext(OPTIONS).handleFor('ALICE'); + const { providersFor } = fakeProviders(); + const handle = await createLiveContext({ + providersFor, + findOptions, + scopedTransactionOptions: OPTIONS, + }).handleFor('ALICE'); expect(await handle.callTx.deposit?.('coin')).toBe(FINALIZED); expect(await handle.callTx.getParent?.()).toBe(FINALIZED); @@ -124,8 +117,40 @@ describe('createLiveContext scoped-transaction options', () => { const scopes = contracts.withContractScopedTransaction.mock.calls; expect(scopes).toHaveLength(2); for (const [providers, , options] of scopes) { - expect(providers).toBe(contracts.findDeployedContract.mock.calls[0]?.[0]); + expect(providers).toBe(providersFor('ALICE')); expect(options).toBe(OPTIONS); } }); + + it("reads the ledger through the default signer's public-data provider", async () => { + const data = { ledger: true }; + const { providersFor } = fakeProviders({ data }); + const ctx = createLiveContext({ providersFor, findOptions }); + + expect(await ctx.queryLedger()).toBe(data); + expect( + providersFor(null).publicDataProvider.queryContractState, + ).toHaveBeenCalledWith(CONTRACT_ADDRESS); + }); + + it("writes and reads private state through the default signer's provider", async () => { + const { providersFor, store } = fakeProviders(); + const ctx = createLiveContext({ providersFor, findOptions }); + const state = { secretKey: Uint8Array.of(9, 9, 9) }; + + await ctx.setPrivateState?.(state); + + expect(store.get(PRIVATE_STATE_ID)).toBe(state); + expect(await ctx.queryPrivateState()).toBe(state); + expect(providersFor.mock.calls).toStrictEqual([[null], [null]]); + }); + + it('rejects a private-state read when nothing is stored', async () => { + const { providersFor } = fakeProviders(); + const ctx = createLiveContext({ providersFor, findOptions }); + + await expect(ctx.queryPrivateState()).rejects.toThrow( + `no private state stored at "${PRIVATE_STATE_ID}"`, + ); + }); }); diff --git a/yarn.lock b/yarn.lock index d19e5cb..a92b62b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1025,6 +1025,7 @@ __metadata: version: 0.0.0-use.local resolution: "@openzeppelin/compact-simulator@workspace:packages/simulator" dependencies: + "@midnight-ntwrk/compact-js": "npm:2.5.5-rc.8" "@midnight-ntwrk/compact-runtime": "npm:0.19.0" "@midnight-ntwrk/midnight-js-contracts": "npm:5.0.0-beta.7" "@midnight-ntwrk/midnight-js-types": "npm:5.0.0-beta.7" @@ -1036,9 +1037,12 @@ __metadata: typescript: "npm:^6.0.3" vitest: "npm:^4.1.9" peerDependencies: + "@midnight-ntwrk/compact-js": ^2.5.5-rc.8 "@midnight-ntwrk/midnight-js-contracts": ^5.0.0-beta.7 "@midnight-ntwrk/midnight-js-types": ^5.0.0-beta.7 peerDependenciesMeta: + "@midnight-ntwrk/compact-js": + optional: true "@midnight-ntwrk/midnight-js-contracts": optional: true "@midnight-ntwrk/midnight-js-types": From 487509cc54c115bdb73a844c5059157ab91ebe9f Mon Sep 17 00:00:00 2001 From: 0xisk <0xisk@proton.me> Date: Fri, 25 Sep 2026 05:04:27 +0200 Subject: [PATCH 2/2] docs(simulator): link the changelog entry to #196 --- packages/simulator/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/simulator/CHANGELOG.md b/packages/simulator/CHANGELOG.md index 2227082..ddf7031 100644 --- a/packages/simulator/CHANGELOG.md +++ b/packages/simulator/CHANGELOG.md @@ -16,7 +16,7 @@ Releases before `0.4.0` predate this file; see the `compact-simulator/v*` tags. ### Changed -- **Breaking:** `createLiveContext` takes midnight-js types. `findOptions` replaces `contractAddress`, `compiledContract` and `privateStateId`. `providersFor` returns `ContractProviders`, and `providersFor(null)` replaces `publicDataProvider` and `privateStateProvider`. `@midnight-ntwrk/compact-js` joins the optional peers +- **Breaking:** `createLiveContext` takes midnight-js types. `findOptions` replaces `contractAddress`, `compiledContract` and `privateStateId`. `providersFor` returns `ContractProviders`, and `providersFor(null)` replaces `publicDataProvider` and `privateStateProvider`. `@midnight-ntwrk/compact-js` joins the optional peers (#196) - **Breaking:** `@midnight-ntwrk/compact-runtime` moves to `0.19.0`, which made `initialState` and every circuit async. The `@midnight-ntwrk/midnight-js-contracts` and `@midnight-ntwrk/midnight-js-types` peers move from `^4.1.0` to `^5.0.0-beta.7`. Both peers stay optional, so a dry-only consumer needs neither (#152) - **Breaking:** construction is async. Replace `new MySimulator(args, options)` with `await MySimulator.create(args, options)`. Subclass `create` overrides return `Promise` and delegate to `super._create([...args], options)` (#145, #147) - **Breaking:** circuit proxies return promises. `ContextlessCircuits` maps to `Promise`, so an un-awaited call that used to pass as a truthy promise, such as `if (sim.isOwner(x))`, no longer typechecks (#145, #154)