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);
+ }
});
});