Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/simulator/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
23 changes: 23 additions & 0 deletions packages/simulator/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
59 changes: 39 additions & 20 deletions packages/simulator/src/live/createLiveContext.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -65,37 +66,34 @@ export interface CreateLiveContextOptions<P> {
* is the harness's responsibility (already required for `get`).
*/
privateStateProvider: PrivateStateProvider<string, P>;
/** Runs every live call in a midnight-js scoped transaction with these options. */
scopedTransactionOptions?: ScopedTransactionOptions;
/** Optional override of the indexer-lag policy. */
indexerLag?: Partial<IndexerLagPolicy>;
}

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<FindDeployedContractFn> => {
if (cachedFindDeployedContract) return cachedFindDeployedContract;
const loadContracts = async (): Promise<Contracts> => {
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) ' +
'to use live mode',
{ cause },
);
}
return cachedFindDeployedContract;
return cachedContracts;
};

const sleep = (ms: number): Promise<void> =>
Expand Down Expand Up @@ -128,13 +126,34 @@ export function createLiveContext<P>(
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;
};
Expand Down
79 changes: 75 additions & 4 deletions packages/simulator/test/unit/createLiveContext.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, PS> = {}) => {
const store = new Map<string, PS>(Object.entries(initial));
Expand Down Expand Up @@ -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<PS>({
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<void>) => {
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);
}
});
});
Loading