diff --git a/packages/simulator/CHANGELOG.md b/packages/simulator/CHANGELOG.md index 92d41f3..938998a 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.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 77faad1..5ac565d 100644 --- a/packages/simulator/README.md +++ b/packages/simulator/README.md @@ -198,6 +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 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({ + // ... + 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 8f3da06..6cf943f 100644 --- a/packages/simulator/src/live/createLiveContext.ts +++ b/packages/simulator/src/live/createLiveContext.ts @@ -1,7 +1,8 @@ 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 { ScopedTransactionOptions } from '@midnight-ntwrk/midnight-js-contracts'; import type { PrivateStateProvider, PublicDataProvider, @@ -65,29 +66,26 @@ export interface CreateLiveContextOptions

{ * is the harness's responsibility (already required for `get`). */ privateStateProvider: PrivateStateProvider; + /** Runs every live call in a midnight-js scoped transaction with these options. */ + scopedTransactionOptions?: ScopedTransactionOptions; /** Optional override of the indexer-lag policy. */ indexerLag?: Partial; } -type FindDeployedContractFn = ( - providers: unknown, - options: unknown, -) => Promise<{ callTx: DeployedTxHandle['callTx'] }>; +type Contracts = typeof import('@midnight-ntwrk/midnight-js-contracts'); -let cachedFindDeployedContract: FindDeployedContractFn | undefined; +let cachedContracts: Contracts | 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'); } catch (cause) { throw new Error( 'install @midnight-ntwrk/midnight-js-contracts (and the midnight-js peers) ' + @@ -95,7 +93,7 @@ const loadFindDeployedContract = async (): Promise => { { cause }, ); } - return cachedFindDeployedContract; + return cachedContracts; }; const sleep = (ms: number): Promise => @@ -128,13 +126,34 @@ export function createLiveContext

( const key = alias ?? '\u0000default'; const cached = handleCache.get(key); if (cached) return cached; - const built = loadFindDeployedContract().then((findDeployedContract) => - findDeployedContract(options.providersFor(alias), { - compiledContract: options.compiledContract, - contractAddress: options.contractAddress, - privateStateId: options.privateStateId, - }), - ); + 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, + }); + 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.entries(handle.callTx).map(([circuitId, call]) => [ + circuitId, + (...args: unknown[]) => + contracts.withContractScopedTransaction( + providers, + async (txCtx) => { + await call(txCtx, ...args); + }, + scopedTransactionOptions, + ), + ]), + ); + return { ...handle, callTx }; + }); 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..8b1db2a 100644 --- a/packages/simulator/test/unit/createLiveContext.test.ts +++ b/packages/simulator/test/unit/createLiveContext.test.ts @@ -1,14 +1,21 @@ +import type { ScopedTransactionOptions } from '@midnight-ntwrk/midnight-js-contracts'; 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(), + withContractScopedTransaction: vi.fn(), +})); + +vi.mock('@midnight-ntwrk/midnight-js-contracts', () => contracts); + 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)); @@ -58,3 +65,67 @@ describe('createLiveContext private-state write', () => { expect(await ctx.queryPrivateState()).toEqual({ secretKey: sk }); }); }); + +describe('createLiveContext scoped-transaction options', () => { + const COMPILED = { name: 'compiled' }; + 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 = (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); + contracts.withContractScopedTransaction.mockImplementation( + async (_providers, fn: (txCtx: unknown) => Promise) => { + await fn(TX_CTX); + return FINALIZED; + }, + ); + }); + + it('returns the deployed handle as-is without options', 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('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); + } + }); +});