From 2a534c6f2e69b57044287c19f33f40b0c192811b Mon Sep 17 00:00:00 2001 From: 0xisk <0xisk@proton.me> Date: Fri, 25 Sep 2026 04:28:28 +0200 Subject: [PATCH 1/4] feat(simulator): add live encryption-key mappings midnight-js encrypts each shielded coin a circuit creates to its recipient's encryption key and only knows the calling wallet's, so a live circuit that sent a coin to another wallet's coin public key failed to build. The deployed handle's callTx leaves the additionalCoinEncPublicKeyMappings slot empty, so with the option set each call is rebuilt on createCallTxOptions + submitCallTx, the pair callTx itself runs. --- packages/simulator/CHANGELOG.md | 1 + packages/simulator/README.md | 15 +++ .../simulator/src/live/createLiveContext.ts | 94 +++++++++++++++---- .../test/unit/createLiveContext.test.ts | 72 +++++++++++++- 4 files changed, 163 insertions(+), 19 deletions(-) diff --git a/packages/simulator/CHANGELOG.md b/packages/simulator/CHANGELOG.md index 92d41f3..b337f8b 100644 --- a/packages/simulator/CHANGELOG.md +++ b/packages/simulator/CHANGELOG.md @@ -12,6 +12,7 @@ Releases before `0.4.0` predate this file; see the `compact-simulator/v*` tags. ### Added - `BaseSimulatorOptions.time` sets the block time the kernel's time operations observe, in seconds since the epoch. Defaults to `0` for reproducible runs (#147) +- `CreateLiveContextOptions.additionalCoinEncPublicKeyMappings` maps coin public keys to encryption public keys for every live call, so a circuit can send a shielded coin to a wallet other than the caller ### Changed diff --git a/packages/simulator/README.md b/packages/simulator/README.md index 77faad1..e87817b 100644 --- a/packages/simulator/README.md +++ b/packages/simulator/README.md @@ -198,6 +198,21 @@ const owner = await simulator.signers.eitherFor('OWNER'); > mechanism. These helpers exist for circuits that take the caller as an input > to other computations (e.g. commitment derivation). +In live mode, midnight-js encrypts every shielded coin a circuit creates to its +recipient's encryption key, and on its own knows only the calling wallet's. +When a circuit sends a coin to another wallet's coin public key, pass that +wallet's key pair to `createLiveContext`, or the call fails to build with +`Unable to resolve encryption public key`: + +```typescript +createLiveContext({ + // ... + additionalCoinEncPublicKeyMappings: new Map([ + [bob.coinPublicKey, bob.encryptionPublicKey], + ]), +}); +``` + ### 🔧 Witness Overrides Perfect for testing edge cases and tracking witness usage. Dry only: the live diff --git a/packages/simulator/src/live/createLiveContext.ts b/packages/simulator/src/live/createLiveContext.ts index 8f3da06..84c9e1e 100644 --- a/packages/simulator/src/live/createLiveContext.ts +++ b/packages/simulator/src/live/createLiveContext.ts @@ -1,12 +1,16 @@ 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 `loadFindDeployedContract`. +// lazy dynamic import inside `loadContracts`. import type { PrivateStateProvider, PublicDataProvider, } from '@midnight-ntwrk/midnight-js-types'; -import type { DeployedTxHandle, LiveContext } from './LiveContext.js'; +import type { + DeployedTxHandle, + FinalizedCallResult, + LiveContext, +} from './LiveContext.js'; /** * Bounded retry policy for absorbing indexer block-lag on public-state reads. @@ -65,29 +69,50 @@ export interface CreateLiveContextOptions

{ * is the harness's responsibility (already required for `get`). */ privateStateProvider: PrivateStateProvider; + /** + * Encryption public keys for shielded recipients other than the calling + * wallet, keyed by coin public key. midnight-js refuses to build a call that + * creates a shielded coin for a coin public key it cannot map to one. + */ + additionalCoinEncPublicKeyMappings?: ReadonlyMap; /** Optional override of the indexer-lag policy. */ indexerLag?: Partial; } -type FindDeployedContractFn = ( - providers: unknown, - options: unknown, -) => Promise<{ callTx: DeployedTxHandle['callTx'] }>; +/** The `@midnight-ntwrk/midnight-js-contracts` exports the live path calls. */ +interface ContractsModule { + findDeployedContract( + providers: unknown, + options: unknown, + ): Promise; + createCallTxOptions( + compiledContract: unknown, + circuitId: string, + contractAddress: string, + privateStateId: string, + additionalCoinEncPublicKeyMappings: ReadonlyMap, + args: unknown[], + ): unknown; + submitCallTx( + providers: unknown, + options: unknown, + ): Promise; +} -let cachedFindDeployedContract: FindDeployedContractFn | undefined; +let cachedContracts: ContractsModule | undefined; /** - * Lazily loads `findDeployedContract`. The dynamic import is the sole runtime + * Lazily loads midnight-js-contracts. The dynamic import is the sole runtime * edge to midnight-js in the package's graph; a failure to resolve it (the * optional peers are absent) is rewrapped into an actionable message * rather than a raw `ERR_MODULE_NOT_FOUND`. */ -const loadFindDeployedContract = async (): Promise => { - if (cachedFindDeployedContract) return cachedFindDeployedContract; +const loadContracts = async (): Promise => { + if (cachedContracts) return cachedContracts; try { - const mod = await import('@midnight-ntwrk/midnight-js-contracts'); - cachedFindDeployedContract = - mod.findDeployedContract as unknown as FindDeployedContractFn; + cachedContracts = (await import( + '@midnight-ntwrk/midnight-js-contracts' + )) as unknown as ContractsModule; } catch (cause) { throw new Error( 'install @midnight-ntwrk/midnight-js-contracts (and the midnight-js peers) ' + @@ -95,7 +120,7 @@ const loadFindDeployedContract = async (): Promise => { { cause }, ); } - return cachedFindDeployedContract; + return cachedContracts; }; const sleep = (ms: number): Promise => @@ -123,18 +148,51 @@ export function createLiveContext

( ...options.indexerLag, }; const handleCache = new Map>(); + const encKeyMappings = options.additionalCoinEncPublicKeyMappings; + + // The handle's own `callTx` runs `submitCallTx(createCallTxOptions(...))` + // with the mappings slot left empty; rebuild each entry with it filled. + const withEncKeyMappings = ( + handle: DeployedTxHandle, + contracts: ContractsModule, + providers: unknown, + mappings: ReadonlyMap, + ): DeployedTxHandle => ({ + ...handle, + callTx: Object.fromEntries( + Object.keys(handle.callTx).map((circuitId) => [ + circuitId, + (...args: unknown[]) => + contracts.submitCallTx( + providers, + contracts.createCallTxOptions( + options.compiledContract, + circuitId, + options.contractAddress, + options.privateStateId, + mappings, + args, + ), + ), + ]), + ), + }); const resolveHandle = (alias: string | null): Promise => { const key = alias ?? '\u0000default'; const cached = handleCache.get(key); if (cached) return cached; - const built = loadFindDeployedContract().then((findDeployedContract) => - findDeployedContract(options.providersFor(alias), { + const built = loadContracts().then(async (contracts) => { + const providers = options.providersFor(alias); + const handle = await contracts.findDeployedContract(providers, { compiledContract: options.compiledContract, contractAddress: options.contractAddress, privateStateId: options.privateStateId, - }), - ); + }); + return encKeyMappings === undefined + ? handle + : withEncKeyMappings(handle, contracts, providers, encKeyMappings); + }); handleCache.set(key, built); return built; }; diff --git a/packages/simulator/test/unit/createLiveContext.test.ts b/packages/simulator/test/unit/createLiveContext.test.ts index 6153bfd..c4b4aa5 100644 --- a/packages/simulator/test/unit/createLiveContext.test.ts +++ b/packages/simulator/test/unit/createLiveContext.test.ts @@ -1,7 +1,15 @@ import type { PrivateStateProvider } from '@midnight-ntwrk/midnight-js-types'; -import { describe, expect, it } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { createLiveContext } from '../../src/live/createLiveContext.js'; +const contracts = vi.hoisted(() => ({ + findDeployedContract: vi.fn(), + createCallTxOptions: vi.fn(), + submitCallTx: vi.fn(), +})); + +vi.mock('@midnight-ntwrk/midnight-js-contracts', () => contracts); + type PS = { secretKey: Uint8Array }; /** @@ -58,3 +66,65 @@ describe('createLiveContext private-state write', () => { expect(await ctx.queryPrivateState()).toEqual({ secretKey: sk }); }); }); + +describe('createLiveContext encryption-key mappings', () => { + const COMPILED = { name: 'compiled' }; + const MAPPINGS = new Map([['bob-coin-key', 'bob-enc-key']]); + const deployed = { callTx: { deposit: vi.fn(), getParent: vi.fn() } }; + + const liveContext = (mappings?: ReadonlyMap) => + createLiveContext({ + contractAddress: '0200cafef00d', + providersFor: (alias) => ({ alias }), + compiledContract: COMPILED, + privateStateId: 'my-contract', + publicDataProvider: {} as never, + privateStateProvider: fakeProvider().provider, + additionalCoinEncPublicKeyMappings: mappings, + }); + + beforeEach(() => { + vi.clearAllMocks(); + contracts.findDeployedContract.mockResolvedValue(deployed); + }); + + it('returns the deployed handle as-is without mappings', async () => { + const handle = await liveContext().handleFor(null); + + expect(handle).toBe(deployed); + expect(contracts.findDeployedContract).toHaveBeenCalledWith( + { alias: null }, + { + compiledContract: COMPILED, + contractAddress: '0200cafef00d', + privateStateId: 'my-contract', + }, + ); + }); + + it('submits every circuit call with the mappings through the alias providers', async () => { + const callOptions = { circuitId: 'deposit' }; + const finalized = { private: { result: [] } }; + contracts.createCallTxOptions.mockReturnValue(callOptions); + contracts.submitCallTx.mockResolvedValue(finalized); + + const handle = await liveContext(MAPPINGS).handleFor('ALICE'); + const result = await handle.callTx.deposit?.('coin'); + + expect(Object.keys(handle.callTx)).toStrictEqual(['deposit', 'getParent']); + expect(contracts.createCallTxOptions).toHaveBeenCalledWith( + COMPILED, + 'deposit', + '0200cafef00d', + 'my-contract', + MAPPINGS, + ['coin'], + ); + expect(contracts.submitCallTx).toHaveBeenCalledWith( + { alias: 'ALICE' }, + callOptions, + ); + expect(result).toBe(finalized); + expect(deployed.callTx.deposit).not.toHaveBeenCalled(); + }); +}); From 486cfbf91e10777ae62e068420580447ba5e36ab Mon Sep 17 00:00:00 2001 From: 0xisk <0xisk@proton.me> Date: Fri, 25 Sep 2026 04:28:55 +0200 Subject: [PATCH 2/4] docs(simulator): link the changelog entry to #195 --- 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 b337f8b..7305f85 100644 --- a/packages/simulator/CHANGELOG.md +++ b/packages/simulator/CHANGELOG.md @@ -12,7 +12,7 @@ Releases before `0.4.0` predate this file; see the `compact-simulator/v*` tags. ### Added - `BaseSimulatorOptions.time` sets the block time the kernel's time operations observe, in seconds since the epoch. Defaults to `0` for reproducible runs (#147) -- `CreateLiveContextOptions.additionalCoinEncPublicKeyMappings` maps coin public keys to encryption public keys for every live call, so a circuit can send a shielded coin to a wallet other than the caller +- `CreateLiveContextOptions.additionalCoinEncPublicKeyMappings` maps coin public keys to encryption public keys for every live call, so a circuit can send a shielded coin to a wallet other than the caller (#195) ### Changed From bcd465f5bb852ccd7cc34ff51b66f9d07b348595 Mon Sep 17 00:00:00 2001 From: 0xisk <0xisk@proton.me> Date: Fri, 25 Sep 2026 04:39:09 +0200 Subject: [PATCH 3/4] refactor(simulator): import midnight-js call types The lazy module and the mappings option take their types from midnight-js-contracts instead of local copies. The imports are type-only, so the dependency wall is unchanged. --- .../simulator/src/live/createLiveContext.ts | 110 +++++++----------- 1 file changed, 39 insertions(+), 71 deletions(-) diff --git a/packages/simulator/src/live/createLiveContext.ts b/packages/simulator/src/live/createLiveContext.ts index 84c9e1e..f003158 100644 --- a/packages/simulator/src/live/createLiveContext.ts +++ b/packages/simulator/src/live/createLiveContext.ts @@ -2,15 +2,12 @@ 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 { CallOptionsBase } from '@midnight-ntwrk/midnight-js-contracts'; import type { PrivateStateProvider, PublicDataProvider, } from '@midnight-ntwrk/midnight-js-types'; -import type { - DeployedTxHandle, - FinalizedCallResult, - LiveContext, -} from './LiveContext.js'; +import type { DeployedTxHandle, LiveContext } from './LiveContext.js'; /** * Bounded retry policy for absorbing indexer block-lag on public-state reads. @@ -69,37 +66,18 @@ export interface CreateLiveContextOptions

{ * is the harness's responsibility (already required for `get`). */ privateStateProvider: PrivateStateProvider; - /** - * Encryption public keys for shielded recipients other than the calling - * wallet, keyed by coin public key. midnight-js refuses to build a call that - * creates a shielded coin for a coin public key it cannot map to one. - */ - additionalCoinEncPublicKeyMappings?: ReadonlyMap; + /** Passed to midnight-js on every live call. */ + additionalCoinEncPublicKeyMappings?: CallOptionsBase< + never, + never + >['additionalCoinEncPublicKeyMappings']; /** Optional override of the indexer-lag policy. */ indexerLag?: Partial; } -/** The `@midnight-ntwrk/midnight-js-contracts` exports the live path calls. */ -interface ContractsModule { - findDeployedContract( - providers: unknown, - options: unknown, - ): Promise; - createCallTxOptions( - compiledContract: unknown, - circuitId: string, - contractAddress: string, - privateStateId: string, - additionalCoinEncPublicKeyMappings: ReadonlyMap, - args: unknown[], - ): unknown; - submitCallTx( - providers: unknown, - options: unknown, - ): Promise; -} +type Contracts = typeof import('@midnight-ntwrk/midnight-js-contracts'); -let cachedContracts: ContractsModule | undefined; +let cachedContracts: Contracts | undefined; /** * Lazily loads midnight-js-contracts. The dynamic import is the sole runtime @@ -107,12 +85,10 @@ let cachedContracts: ContractsModule | undefined; * optional peers are absent) is rewrapped into an actionable message * rather than a raw `ERR_MODULE_NOT_FOUND`. */ -const loadContracts = async (): Promise => { +const loadContracts = async (): Promise => { if (cachedContracts) return cachedContracts; try { - cachedContracts = (await import( - '@midnight-ntwrk/midnight-js-contracts' - )) as unknown as ContractsModule; + cachedContracts = await import('@midnight-ntwrk/midnight-js-contracts'); } catch (cause) { throw new Error( 'install @midnight-ntwrk/midnight-js-contracts (and the midnight-js peers) ' + @@ -148,50 +124,42 @@ export function createLiveContext

( ...options.indexerLag, }; const handleCache = new Map>(); - const encKeyMappings = options.additionalCoinEncPublicKeyMappings; - - // The handle's own `callTx` runs `submitCallTx(createCallTxOptions(...))` - // with the mappings slot left empty; rebuild each entry with it filled. - const withEncKeyMappings = ( - handle: DeployedTxHandle, - contracts: ContractsModule, - providers: unknown, - mappings: ReadonlyMap, - ): DeployedTxHandle => ({ - ...handle, - callTx: Object.fromEntries( - Object.keys(handle.callTx).map((circuitId) => [ - circuitId, - (...args: unknown[]) => - contracts.submitCallTx( - providers, - contracts.createCallTxOptions( - options.compiledContract, - circuitId, - options.contractAddress, - options.privateStateId, - mappings, - args, - ), - ), - ]), - ), - }); const resolveHandle = (alias: string | null): Promise => { const key = alias ?? '\u0000default'; const cached = handleCache.get(key); if (cached) return cached; const built = loadContracts().then(async (contracts) => { - const providers = options.providersFor(alias); + const providers = options.providersFor(alias) as never; + const compiledContract = options.compiledContract as never; + const { contractAddress, privateStateId } = options; const handle = await contracts.findDeployedContract(providers, { - compiledContract: options.compiledContract, - contractAddress: options.contractAddress, - privateStateId: options.privateStateId, + compiledContract, + contractAddress, + privateStateId, }); - return encKeyMappings === undefined - ? handle - : withEncKeyMappings(handle, contracts, providers, encKeyMappings); + const mappings = options.additionalCoinEncPublicKeyMappings; + if (mappings === undefined) return handle; + // `callTx` is `submitCallTx(createCallTxOptions(...))` with the mappings + // slot left empty; rebuild each entry with it filled. + const callTx = Object.fromEntries( + Object.keys(handle.callTx).map((circuitId) => [ + circuitId, + (...args: unknown[]) => + contracts.submitCallTx( + providers, + contracts.createCallTxOptions( + compiledContract, + circuitId, + contractAddress, + privateStateId, + mappings, + args, + ), + ), + ]), + ); + return { ...handle, callTx }; }); handleCache.set(key, built); return built; From 1d17d69c0602b7c05c8731afce8a8c53c4975d33 Mon Sep 17 00:00:00 2001 From: 0xisk <0xisk@proton.me> Date: Fri, 25 Sep 2026 04:48:15 +0200 Subject: [PATCH 4/4] refactor(simulator): take ScopedTransactionOptions Each call runs through the handle's own callTx inside withContractScopedTransaction, the midnight-js entry point that takes these options, instead of a callTx rebuilt from createCallTxOptions and submitCallTx. New scope options pass through without a simulator change. --- packages/simulator/CHANGELOG.md | 2 +- packages/simulator/README.md | 22 ++++-- .../simulator/src/live/createLiveContext.ts | 35 ++++------ .../test/unit/createLiveContext.test.ts | 69 ++++++++++--------- 4 files changed, 65 insertions(+), 63 deletions(-) diff --git a/packages/simulator/CHANGELOG.md b/packages/simulator/CHANGELOG.md index 7305f85..938998a 100644 --- a/packages/simulator/CHANGELOG.md +++ b/packages/simulator/CHANGELOG.md @@ -12,7 +12,7 @@ Releases before `0.4.0` predate this file; see the `compact-simulator/v*` tags. ### Added - `BaseSimulatorOptions.time` sets the block time the kernel's time operations observe, in seconds since the epoch. Defaults to `0` for reproducible runs (#147) -- `CreateLiveContextOptions.additionalCoinEncPublicKeyMappings` maps coin public keys to encryption public keys for every live call, so a circuit can send a shielded coin to a wallet other than the caller (#195) +- `CreateLiveContextOptions.scopedTransactionOptions` runs every live call in a midnight-js scoped transaction with those options. With `additionalCoinEncPublicKeyMappings`, a circuit can send a shielded coin to a wallet other than the caller (#195) ### Changed diff --git a/packages/simulator/README.md b/packages/simulator/README.md index e87817b..5ac565d 100644 --- a/packages/simulator/README.md +++ b/packages/simulator/README.md @@ -198,21 +198,29 @@ const owner = await simulator.signers.eitherFor('OWNER'); > mechanism. These helpers exist for circuits that take the caller as an input > to other computations (e.g. commitment derivation). -In live mode, midnight-js encrypts every shielded coin a circuit creates to its -recipient's encryption key, and on its own knows only the calling wallet's. -When a circuit sends a coin to another wallet's coin public key, pass that -wallet's key pair to `createLiveContext`, or the call fails to build with +In live mode, midnight-js encrypts each shielded output sent to a coin public +key with the recipient's encryption public key, and on its own knows only the +calling wallet's. When a circuit sends a coin to another wallet, pass that +wallet's coin and encryption public keys (its `WalletProvider`'s +`getCoinPublicKey()` and `getEncryptionPublicKey()`) through +`scopedTransactionOptions`. Otherwise the call fails to build with `Unable to resolve encryption public key`: ```typescript createLiveContext({ // ... - additionalCoinEncPublicKeyMappings: new Map([ - [bob.coinPublicKey, bob.encryptionPublicKey], - ]), + scopedTransactionOptions: { + additionalCoinEncPublicKeyMappings: new Map([ + [bob.getCoinPublicKey(), bob.getEncryptionPublicKey()], + ]), + }, }); ``` +With the option set, each call runs as its own midnight-js scoped transaction. +midnight-js then wraps circuit and submission errors (`Unexpected error +executing scoped transaction ...`), keeping the original message inside. + ### 🔧 Witness Overrides Perfect for testing edge cases and tracking witness usage. Dry only: the live diff --git a/packages/simulator/src/live/createLiveContext.ts b/packages/simulator/src/live/createLiveContext.ts index f003158..6cf943f 100644 --- a/packages/simulator/src/live/createLiveContext.ts +++ b/packages/simulator/src/live/createLiveContext.ts @@ -2,7 +2,7 @@ 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 { CallOptionsBase } from '@midnight-ntwrk/midnight-js-contracts'; +import type { ScopedTransactionOptions } from '@midnight-ntwrk/midnight-js-contracts'; import type { PrivateStateProvider, PublicDataProvider, @@ -66,11 +66,8 @@ export interface CreateLiveContextOptions

{ * is the harness's responsibility (already required for `get`). */ privateStateProvider: PrivateStateProvider; - /** Passed to midnight-js on every live call. */ - additionalCoinEncPublicKeyMappings?: CallOptionsBase< - never, - never - >['additionalCoinEncPublicKeyMappings']; + /** Runs every live call in a midnight-js scoped transaction with these options. */ + scopedTransactionOptions?: ScopedTransactionOptions; /** Optional override of the indexer-lag policy. */ indexerLag?: Partial; } @@ -132,30 +129,26 @@ export function createLiveContext

( const built = loadContracts().then(async (contracts) => { const providers = options.providersFor(alias) as never; const compiledContract = options.compiledContract as never; - const { contractAddress, privateStateId } = options; + const { contractAddress, privateStateId, scopedTransactionOptions } = + options; const handle = await contracts.findDeployedContract(providers, { compiledContract, contractAddress, privateStateId, }); - const mappings = options.additionalCoinEncPublicKeyMappings; - if (mappings === undefined) return handle; - // `callTx` is `submitCallTx(createCallTxOptions(...))` with the mappings - // slot left empty; rebuild each entry with it filled. + 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. const callTx = Object.fromEntries( - Object.keys(handle.callTx).map((circuitId) => [ + Object.entries(handle.callTx).map(([circuitId, call]) => [ circuitId, (...args: unknown[]) => - contracts.submitCallTx( + contracts.withContractScopedTransaction( providers, - contracts.createCallTxOptions( - compiledContract, - circuitId, - contractAddress, - privateStateId, - mappings, - args, - ), + async (txCtx) => { + await call(txCtx, ...args); + }, + scopedTransactionOptions, ), ]), ); diff --git a/packages/simulator/test/unit/createLiveContext.test.ts b/packages/simulator/test/unit/createLiveContext.test.ts index c4b4aa5..8b1db2a 100644 --- a/packages/simulator/test/unit/createLiveContext.test.ts +++ b/packages/simulator/test/unit/createLiveContext.test.ts @@ -1,11 +1,11 @@ +import type { ScopedTransactionOptions } from '@midnight-ntwrk/midnight-js-contracts'; import type { PrivateStateProvider } from '@midnight-ntwrk/midnight-js-types'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { createLiveContext } from '../../src/live/createLiveContext.js'; const contracts = vi.hoisted(() => ({ findDeployedContract: vi.fn(), - createCallTxOptions: vi.fn(), - submitCallTx: vi.fn(), + withContractScopedTransaction: vi.fn(), })); vi.mock('@midnight-ntwrk/midnight-js-contracts', () => contracts); @@ -14,9 +14,8 @@ type PS = { secretKey: Uint8Array }; /** * An in-memory stand-in for the harness's `PrivateStateProvider`, exercising - * only the `get`/`set` slice `createLiveContext` uses. `findDeployedContract` - * (and thus midnight-js) is never reached, so these tests run without the - * optional live peers installed. + * 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)); @@ -67,12 +66,18 @@ describe('createLiveContext private-state write', () => { }); }); -describe('createLiveContext encryption-key mappings', () => { +describe('createLiveContext scoped-transaction options', () => { const COMPILED = { name: 'compiled' }; - const MAPPINGS = new Map([['bob-coin-key', 'bob-enc-key']]); + const TX_CTX = { txCtx: true }; + const FINALIZED = { private: { result: [] } }; + const OPTIONS: ScopedTransactionOptions = { + additionalCoinEncPublicKeyMappings: new Map([ + ['bob-coin-key', 'bob-enc-key'], + ]), + }; const deployed = { callTx: { deposit: vi.fn(), getParent: vi.fn() } }; - const liveContext = (mappings?: ReadonlyMap) => + const liveContext = (scopedTransactionOptions?: ScopedTransactionOptions) => createLiveContext({ contractAddress: '0200cafef00d', providersFor: (alias) => ({ alias }), @@ -80,15 +85,21 @@ describe('createLiveContext encryption-key mappings', () => { privateStateId: 'my-contract', publicDataProvider: {} as never, privateStateProvider: fakeProvider().provider, - additionalCoinEncPublicKeyMappings: mappings, + scopedTransactionOptions, }); beforeEach(() => { vi.clearAllMocks(); contracts.findDeployedContract.mockResolvedValue(deployed); + contracts.withContractScopedTransaction.mockImplementation( + async (_providers, fn: (txCtx: unknown) => Promise) => { + await fn(TX_CTX); + return FINALIZED; + }, + ); }); - it('returns the deployed handle as-is without mappings', async () => { + it('returns the deployed handle as-is without options', async () => { const handle = await liveContext().handleFor(null); expect(handle).toBe(deployed); @@ -102,29 +113,19 @@ describe('createLiveContext encryption-key mappings', () => { ); }); - it('submits every circuit call with the mappings through the alias providers', async () => { - const callOptions = { circuitId: 'deposit' }; - const finalized = { private: { result: [] } }; - contracts.createCallTxOptions.mockReturnValue(callOptions); - contracts.submitCallTx.mockResolvedValue(finalized); - - const handle = await liveContext(MAPPINGS).handleFor('ALICE'); - const result = await handle.callTx.deposit?.('coin'); - - expect(Object.keys(handle.callTx)).toStrictEqual(['deposit', 'getParent']); - expect(contracts.createCallTxOptions).toHaveBeenCalledWith( - COMPILED, - 'deposit', - '0200cafef00d', - 'my-contract', - MAPPINGS, - ['coin'], - ); - expect(contracts.submitCallTx).toHaveBeenCalledWith( - { alias: 'ALICE' }, - callOptions, - ); - expect(result).toBe(finalized); - expect(deployed.callTx.deposit).not.toHaveBeenCalled(); + it('runs every circuit call in its own scoped transaction with the options', async () => { + const handle = await liveContext(OPTIONS).handleFor('ALICE'); + + expect(await handle.callTx.deposit?.('coin')).toBe(FINALIZED); + expect(await handle.callTx.getParent?.()).toBe(FINALIZED); + + expect(deployed.callTx.deposit).toHaveBeenCalledWith(TX_CTX, 'coin'); + expect(deployed.callTx.getParent).toHaveBeenCalledWith(TX_CTX); + 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(options).toBe(OPTIONS); + } }); });