@midnight-ntwrk/wallet-sdk-capabilities 3.2.0 → 3.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,453 @@
1
+ // This file is part of MIDNIGHT-WALLET-SDK.
2
+ // Copyright (C) Midnight Foundation
3
+ // SPDX-License-Identifier: Apache-2.0
4
+ // Licensed under the Apache License, Version 2.0 (the "License");
5
+ // You may not use this file except in compliance with the License.
6
+ // You may obtain a copy of the License at
7
+ // http://www.apache.org/licenses/LICENSE-2.0
8
+ // Unless required by applicable law or agreed to in writing, software
9
+ // distributed under the License is distributed on an "AS IS" BASIS,
10
+ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11
+ // See the License for the specific language governing permissions and
12
+ // limitations under the License.
13
+ /**
14
+ * Unified Simulator for wallet testing.
15
+ *
16
+ * This module provides a simulated ledger environment for testing wallet functionality without requiring a real
17
+ * blockchain node. It supports:
18
+ *
19
+ * - Optional genesis mints for pre-funded accounts (shielded, unshielded, or Night tokens)
20
+ * - Transaction submission with configurable strictness
21
+ * - Night token rewards via rewardNight()
22
+ * - Time advancement for TTL and time-sensitive tests
23
+ */
24
+ import { Effect, Either, pipe, Stream, SubscriptionRef } from 'effect';
25
+ import { addressFromKey, ClaimRewardsTransaction, createShieldedCoinInfo, Intent, LedgerState, nativeToken, SignatureErased, Transaction, TransactionContext, UnshieldedOffer, ZswapOffer, ZswapOutput, } from '@midnight-ntwrk/ledger-v8';
26
+ import { DateOps, LedgerOps } from '@midnight-ntwrk/wallet-sdk-utilities';
27
+ import { NetworkId } from '@midnight-ntwrk/wallet-sdk-abstractions';
28
+ import { addToMempool, allMempoolTransactions, blankState, blockHash, createBlock, createEmptyBlock, createStrictness, defaultStrictness, genesisStrictness, getLastBlock, hasPendingTransactions, nextBlockContextFromBlock, processTransactions, removeFromMempool, resolveFullness, } from './SimulatorState.js';
29
+ // =============================================================================
30
+ // Type Guards and Helpers for Genesis Mints
31
+ // =============================================================================
32
+ /** Type guard for shielded genesis mint. */
33
+ const isShieldedMint = (mint) => mint.type === 'shielded';
34
+ /** Type guard for unshielded genesis mint. */
35
+ const isUnshieldedMint = (mint) => mint.type === 'unshielded';
36
+ /**
37
+ * Check if an unshielded mint is for the native Night token. Night is auto-detected by comparing tokenType with
38
+ * ledger.nativeToken().raw.
39
+ */
40
+ const isNightToken = (tokenType) => tokenType === nativeToken().raw;
41
+ // Re-export state accessors for backward compatibility
42
+ export { getLastBlock, getCurrentBlockNumber, getBlockByNumber, getLastBlockResults, getLastBlockEvents, hasPendingTransactions, getCurrentTime, applyTransaction, defaultStrictness, genesisStrictness, createStrictness, } from './SimulatorState.js';
43
+ // =============================================================================
44
+ // Block Producers
45
+ // =============================================================================
46
+ /**
47
+ * Default block producer: produces a block for each state change with non-empty mempool.
48
+ *
49
+ * By default, uses post-genesis strictness (balancing, signatures, limits enforced). This ensures realistic simulation
50
+ * where transactions must be properly balanced (pay fees).
51
+ *
52
+ * @param fullness - Static fullness (0-1) or callback based on state
53
+ * @param strictness - Strictness config (defaults to defaultStrictness)
54
+ */
55
+ export const immediateBlockProducer = (fullness = 0.5, strictness = defaultStrictness) => (states) => states.pipe(Stream.filter(hasPendingTransactions), Stream.map((s) => allMempoolTransactions(s, resolveFullness(fullness, s), createStrictness(strictness))));
56
+ // =============================================================================
57
+ // Simulator Class
58
+ // =============================================================================
59
+ /**
60
+ * Unified simulator for wallet testing.
61
+ *
62
+ * Provides a simulated ledger environment for testing wallet functionality without a real blockchain. Optionally
63
+ * pre-funds accounts via genesis mints.
64
+ *
65
+ * @example
66
+ * ```typescript
67
+ * // Empty ledger (useful for dust/Night token testing via rewardNight)
68
+ * const simulator = yield* Simulator.init({});
69
+ *
70
+ * // Pre-funded accounts (useful for token transfer testing)
71
+ * const simulator = yield* Simulator.init({
72
+ * genesisMints: [{ amount: 1000n, tokenType, shieldedRecipient: secretKeys }],
73
+ * });
74
+ * ```;
75
+ */
76
+ export class Simulator {
77
+ // ===========================================================================
78
+ // Static Methods
79
+ // ===========================================================================
80
+ /**
81
+ * Initialize a new simulator.
82
+ *
83
+ * @example
84
+ * ```typescript
85
+ * // Empty ledger - use rewardNight() for Night tokens
86
+ * const simulator = yield* Simulator.init({});
87
+ *
88
+ * // Pre-funded accounts for token transfer testing
89
+ * const simulator = yield* Simulator.init({
90
+ * genesisMints: [{ amount: 1000n, tokenType, shieldedRecipient: secretKeys }],
91
+ * });
92
+ *
93
+ * // With custom network ID
94
+ * const simulator = yield* Simulator.init({
95
+ * networkId: NetworkId.Preview,
96
+ * genesisMints: [...],
97
+ * });
98
+ * ```;
99
+ *
100
+ * @param config - Configuration options (all optional)
101
+ * @returns Effect that produces a Simulator instance
102
+ */
103
+ static init(config = {}) {
104
+ const networkId = config.networkId ?? NetworkId.NetworkId.Undeployed;
105
+ return config.genesisMints !== undefined && config.genesisMints.length > 0
106
+ ? Simulator.initWithGenesis(config.genesisMints, networkId, config.blockProducer)
107
+ : Simulator.initBlank(networkId, config.blockProducer);
108
+ }
109
+ /** Initialize simulator with blank ledger state. */
110
+ static initBlank(networkId, blockProducer) {
111
+ return pipe(Effect.promise(() => blankState(networkId)), Effect.flatMap((state) => Simulator.fromState(state, blockProducer)));
112
+ }
113
+ /**
114
+ * Initialize simulator with genesis mints (pre-funded accounts). Supports shielded and unshielded token mints. Night
115
+ * tokens are auto-detected by comparing tokenType with nativeToken().raw.
116
+ */
117
+ static initWithGenesis(genesisMints, networkId = NetworkId.NetworkId.Undeployed, blockProducer) {
118
+ const noStrictness = createStrictness();
119
+ // Pure function to extract shielded mint data (returns array for flatMap)
120
+ const toShieldedMint = (mint) => isShieldedMint(mint) ? [{ tokenType: mint.tokenType, amount: mint.amount, keys: mint.recipient }] : [];
121
+ // Pure function to extract non-Night unshielded mint data (returns array for flatMap)
122
+ // Night is auto-detected by tokenType and excluded from regular unshielded minting
123
+ const toCustomUnshieldedMint = (mint) => isUnshieldedMint(mint) && !isNightToken(mint.tokenType)
124
+ ? [{ tokenType: mint.tokenType, amount: mint.amount, recipient: mint.recipient }]
125
+ : [];
126
+ // Pure function to extract Night mint data from unshielded mints (returns array for flatMap)
127
+ // Night is auto-detected by comparing tokenType with nativeToken().raw
128
+ const toNightMint = (mint) => isUnshieldedMint(mint) && isNightToken(mint.tokenType) && mint.verifyingKey !== undefined
129
+ ? [{ amount: mint.amount, recipient: mint.recipient, verifyingKey: mint.verifyingKey }]
130
+ : [];
131
+ // Pure function to create a ZswapOffer from shielded mint data
132
+ const createShieldedOffer = (transfer) => {
133
+ const coin = createShieldedCoinInfo(transfer.tokenType, transfer.amount);
134
+ const output = ZswapOutput.new(coin, 0, transfer.keys.coinPublicKey, transfer.keys.encryptionPublicKey);
135
+ return ZswapOffer.fromOutput(output, transfer.tokenType, transfer.amount);
136
+ };
137
+ // Pure function to create an Intent with UnshieldedOffer
138
+ // Note: Intent API requires mutation, isolated here
139
+ const createUnshieldedIntent = (mints, ttl) => {
140
+ const outputs = mints.map((mint) => ({ type: mint.tokenType, value: mint.amount, owner: mint.recipient }));
141
+ const intent = Intent.new(ttl);
142
+ intent.guaranteedUnshieldedOffer = UnshieldedOffer.new([], outputs, []);
143
+ return intent;
144
+ };
145
+ // Process shielded and custom unshielded mints in initial block (Night handled separately)
146
+ const makeInitialTransactions = (ledgerState, context, blockTime) => {
147
+ const verificationTime = blockTime;
148
+ // Separate mints by type using flatMap (pure, no mutation)
149
+ // Night tokens are excluded and handled separately via reward/claim mechanism
150
+ const shieldedMints = genesisMints.flatMap(toShieldedMint);
151
+ const customUnshieldedMints = genesisMints.flatMap(toCustomUnshieldedMint);
152
+ // If no shielded or custom unshielded mints, return empty result
153
+ if (shieldedMints.length === 0 && customUnshieldedMints.length === 0) {
154
+ return {
155
+ initialState: ledgerState,
156
+ transactions: [],
157
+ };
158
+ }
159
+ // Create ZswapOffer for shielded mints (if any)
160
+ const zswapOffer = shieldedMints.length > 0
161
+ ? shieldedMints.map(createShieldedOffer).reduce((acc, offer) => acc.merge(offer))
162
+ : undefined;
163
+ // Create Intent with UnshieldedOffer for custom unshielded mints (if any)
164
+ const ttl = new Date(blockTime.getTime() + 3600 * 1000); // 1 hour TTL from block time
165
+ const intent = customUnshieldedMints.length > 0 ? createUnshieldedIntent(customUnshieldedMints, ttl) : undefined;
166
+ // Build transaction from parts
167
+ const proofErasedTx = Transaction.fromParts(networkId, zswapOffer, undefined, intent).eraseProofs();
168
+ const verifiedTx = proofErasedTx.wellFormed(ledgerState, noStrictness, verificationTime);
169
+ const [newState, result] = ledgerState.apply(verifiedTx, new TransactionContext(ledgerState, context));
170
+ return {
171
+ initialState: newState,
172
+ transactions: [{ tx: proofErasedTx, result }],
173
+ };
174
+ };
175
+ // Process a single Night mint by distributing Night and creating claim transaction
176
+ const processNightMint = (ledgerState, mint, blockTime, context) => {
177
+ // Distribute Night tokens (makes them claimable)
178
+ const ledgerWithReward = ledgerState.testingDistributeNight(mint.recipient, mint.amount, blockTime);
179
+ // Create claim transaction
180
+ const signature = new SignatureErased();
181
+ const claimTx = new ClaimRewardsTransaction(signature.instance, networkId, mint.amount, mint.verifyingKey, LedgerOps.randomNonce(), signature);
182
+ const proofErasedTx = Transaction.fromRewards(claimTx).eraseProofs();
183
+ const verifiedTx = proofErasedTx.wellFormed(ledgerWithReward, noStrictness, blockTime);
184
+ const [newState, result] = ledgerWithReward.apply(verifiedTx, new TransactionContext(ledgerWithReward, context));
185
+ return { ledgerState: newState, tx: proofErasedTx, result };
186
+ };
187
+ return Effect.gen(function* () {
188
+ const genesisTime = new Date(0);
189
+ const emptyState = LedgerState.blank(networkId);
190
+ const context = yield* Effect.promise(() => nextBlockContextFromBlock(undefined, genesisTime));
191
+ // Process shielded and unshielded mints first
192
+ const init = makeInitialTransactions(emptyState, context, genesisTime);
193
+ // Apply post-block update before processing Night mints
194
+ // Night distribution requires the ledger to be in a consistent post-block state
195
+ const postBlockState = init.initialState.postBlockUpdate(genesisTime);
196
+ // Process Night mints sequentially using reduce (pure functional fold)
197
+ const nightMints = genesisMints.flatMap(toNightMint);
198
+ const nightResults = nightMints.reduce((acc, mint) => {
199
+ const result = processNightMint(acc.ledgerState, mint, genesisTime, context);
200
+ return {
201
+ ledgerState: result.ledgerState,
202
+ transactions: [...acc.transactions, { tx: result.tx, result: result.result }],
203
+ };
204
+ }, {
205
+ ledgerState: postBlockState,
206
+ transactions: [],
207
+ });
208
+ // Apply final post-block update
209
+ const finalLedger = nightResults.ledgerState.postBlockUpdate(genesisTime);
210
+ // Combine all transactions
211
+ const allTransactions = [...init.transactions, ...nightResults.transactions];
212
+ // Create genesis block with all transactions
213
+ const genesisBlock = {
214
+ number: 0n,
215
+ hash: context.parentBlockHash,
216
+ timestamp: genesisTime,
217
+ transactions: allTransactions,
218
+ };
219
+ const initialState = {
220
+ networkId,
221
+ ledger: finalLedger,
222
+ blocks: [genesisBlock],
223
+ mempool: [],
224
+ currentTime: genesisTime, // Time stays at genesis; next block will advance it
225
+ };
226
+ return yield* Simulator.fromState(initialState, blockProducer);
227
+ });
228
+ }
229
+ /** Create a Simulator from an initial state with proper stream setup. */
230
+ static fromState(initialState, blockProducer) {
231
+ return Effect.gen(function* () {
232
+ const stateRef = yield* SubscriptionRef.make(initialState);
233
+ // Create a shared stream of state changes.
234
+ // Note: SubscriptionRef.changes only emits on updates, not the initial value.
235
+ // Consumers (sync services) should get the initial state via getLatestState() if needed.
236
+ const stateChangesStream = yield* Stream.share(stateRef.changes, {
237
+ capacity: 'unbounded',
238
+ replay: 1,
239
+ });
240
+ // Create instance first so we can use instance method in the stream
241
+ const simulator = new Simulator(stateRef, stateChangesStream);
242
+ // Set up block production stream
243
+ const effectiveProducer = blockProducer ?? immediateBlockProducer();
244
+ const statesForProducer = Stream.concat(Stream.succeed(initialState), stateRef.changes);
245
+ const productionRequests = effectiveProducer(statesForProducer);
246
+ yield* Effect.forkScoped(productionRequests.pipe(Stream.runForEach((request) => simulator.#produceBlock(request))));
247
+ return simulator;
248
+ });
249
+ }
250
+ // ===========================================================================
251
+ // Instance Properties
252
+ // ===========================================================================
253
+ #stateRef;
254
+ /** Observable stream of simulator state changes. */
255
+ state$;
256
+ constructor(stateRef, state$) {
257
+ this.#stateRef = stateRef;
258
+ this.state$ = state$;
259
+ }
260
+ // ===========================================================================
261
+ // Instance Methods
262
+ // ===========================================================================
263
+ /** Get the current simulator state. */
264
+ getLatestState() {
265
+ return SubscriptionRef.get(this.#stateRef);
266
+ }
267
+ /**
268
+ * Distribute Night tokens to a recipient and submit claim transaction to mempool. Used for testing dust token
269
+ * generation.
270
+ *
271
+ * This method:
272
+ *
273
+ * 1. Modifies the ledger to make Night tokens claimable
274
+ * 2. Creates and submits a ClaimRewardsTransaction to the mempool
275
+ * 3. The block producer will process the transaction
276
+ *
277
+ * @param verifyingKey - Signature verifying key (recipient address is derived from it)
278
+ * @param amount - Amount of Night tokens to distribute
279
+ */
280
+ rewardNight(verifyingKey, amount) {
281
+ const stateRef = this.#stateRef;
282
+ // eslint-disable-next-line @typescript-eslint/no-this-alias
283
+ const simulator = this;
284
+ const recipient = addressFromKey(verifyingKey);
285
+ return Effect.gen(function* () {
286
+ // First, modify ledger state to make Night tokens claimable
287
+ yield* SubscriptionRef.updateEffect(stateRef, (simulatorState) => pipe(LedgerOps.ledgerTry(() => simulatorState.ledger.testingDistributeNight(recipient, amount, simulatorState.currentTime)), Effect.map((newLedgerState) => ({
288
+ ...simulatorState,
289
+ ledger: newLedgerState,
290
+ }))));
291
+ // Create and submit the claim transaction through submitTransaction
292
+ const currentState = yield* SubscriptionRef.get(stateRef);
293
+ const signature = new SignatureErased();
294
+ const claimRewardsTransaction = new ClaimRewardsTransaction(signature.instance, currentState.networkId, amount, verifyingKey, LedgerOps.randomNonce(), signature);
295
+ const tx = Transaction.fromRewards(claimRewardsTransaction).eraseProofs();
296
+ // Submit transaction with genesisStrictness - reward claims are not balanced transactions
297
+ return yield* simulator.submitTransaction(tx, { strictness: genesisStrictness });
298
+ });
299
+ }
300
+ /**
301
+ * Submit a transaction and wait for it to be included in a block.
302
+ *
303
+ * This method adds the transaction to the mempool and blocks until the block producer includes it in a block. Use
304
+ * this when you need confirmation that the transaction was processed.
305
+ *
306
+ * For fire-and-forget scenarios where you don't need to wait for block inclusion, use `submitAndForget` instead.
307
+ *
308
+ * @param tx - Transaction to submit (proofs erased)
309
+ * @param options - Optional submission options
310
+ * @param options.strictness - Override well-formedness strictness
311
+ * @returns The block containing the transaction
312
+ */
313
+ submitTransaction(tx, options) {
314
+ // Only set strictness if explicitly provided; otherwise let block producer assign default
315
+ const pendingTx = options?.strictness !== undefined ? { tx, strictness: createStrictness(options.strictness) } : { tx };
316
+ const stateRef = this.#stateRef;
317
+ return Effect.gen(function* () {
318
+ // Add to mempool
319
+ yield* SubscriptionRef.update(stateRef, (s) => addToMempool(s, pendingTx));
320
+ // Wait for the transaction to be processed (removed from mempool)
321
+ // by watching state changes
322
+ const finalState = yield* pipe(stateRef.changes, Stream.filter((s) => !s.mempool.includes(pendingTx)), Stream.take(1), Stream.runHead);
323
+ if (finalState._tag === 'None') {
324
+ return yield* Effect.die(new Error('State stream ended unexpectedly'));
325
+ }
326
+ // Find the block containing our transaction
327
+ const block = finalState.value.blocks.find((b) => b.transactions.some((bt) => bt.tx === tx));
328
+ if (!block) {
329
+ // Transaction was removed from mempool but not in any block - it failed
330
+ return yield* Effect.fail({
331
+ _tag: 'LedgerError',
332
+ message: 'Transaction was discarded',
333
+ });
334
+ }
335
+ return block;
336
+ });
337
+ }
338
+ /**
339
+ * Submit a transaction without waiting for block inclusion.
340
+ *
341
+ * This method adds the transaction to the mempool and returns immediately. The block producer will process it
342
+ * asynchronously. Use this for fire-and-forget scenarios or when testing custom block producers with batched
343
+ * transactions.
344
+ *
345
+ * To wait for block inclusion, use `submitTransaction` instead.
346
+ *
347
+ * @param tx - Transaction to submit (proofs erased)
348
+ * @param options - Optional options
349
+ * @param options.strictness - Override well-formedness strictness
350
+ */
351
+ submitAndForget(tx, options) {
352
+ // Only set strictness if explicitly provided; otherwise let block producer assign default
353
+ const pendingTx = options?.strictness !== undefined ? { tx, strictness: createStrictness(options.strictness) } : { tx };
354
+ return SubscriptionRef.update(this.#stateRef, (s) => addToMempool(s, pendingTx));
355
+ }
356
+ /**
357
+ * Fast-forward the simulator time by the given number of seconds. Does not produce a block - only advances the
358
+ * internal clock. Useful for testing time-sensitive functionality like TTL.
359
+ *
360
+ * @param seconds - Number of seconds to advance (must be positive)
361
+ */
362
+ fastForward(seconds) {
363
+ return SubscriptionRef.update(this.#stateRef, (simulatorState) => ({
364
+ ...simulatorState,
365
+ currentTime: DateOps.addSeconds(simulatorState.currentTime, seconds),
366
+ }));
367
+ }
368
+ // ===========================================================================
369
+ // Internal Block Production
370
+ // ===========================================================================
371
+ /**
372
+ * Produce a block from the given block production request. Internal method used by the block producer stream.
373
+ *
374
+ * This method orchestrates block production by:
375
+ *
376
+ * 1. Computing the block hash (async)
377
+ * 2. Processing transactions using pure functions from SimulatorState
378
+ * 3. Creating the block and updating state atomically
379
+ *
380
+ * Each transaction in the request has its own strictness assigned by the block producer.
381
+ *
382
+ * @param request - Block production request with transactions and fullness
383
+ */
384
+ #produceBlock(request) {
385
+ const stateRef = this.#stateRef;
386
+ const { transactions, fullness } = request;
387
+ return Effect.gen(function* () {
388
+ const blockResult = yield* SubscriptionRef.modifyEffect(stateRef, (simulatorState) => Effect.gen(function* () {
389
+ // Advance time first, then use it for the block
390
+ const blockTime = DateOps.addSeconds(simulatorState.currentTime, 1);
391
+ const previousBlock = getLastBlock(simulatorState);
392
+ const nextBlockNumber = previousBlock !== undefined ? previousBlock.number + 1n : 0n;
393
+ const hash = yield* Effect.promise(() => blockHash(nextBlockNumber));
394
+ if (transactions.length === 0) {
395
+ // No transactions to process, return empty block using pure function
396
+ const [emptyBlock, newState] = createEmptyBlock(simulatorState, hash, blockTime, transactions);
397
+ return [Either.right(emptyBlock), newState];
398
+ }
399
+ const context = yield* Effect.promise(() => nextBlockContextFromBlock(previousBlock, blockTime));
400
+ // Process all transactions - each has its own strictness assigned by block producer
401
+ const processingResult = processTransactions(simulatorState.ledger, transactions, blockTime, context, fullness);
402
+ if (Either.isLeft(processingResult)) {
403
+ // Transaction failed, remove from mempool and return error
404
+ const newState = removeFromMempool(simulatorState, transactions);
405
+ return [Either.left(processingResult.left), newState];
406
+ }
407
+ const { blockTransactions, finalLedger } = processingResult.right;
408
+ // Create block using pure function
409
+ const [block, newState] = createBlock(simulatorState, blockTransactions, hash, blockTime, finalLedger, transactions);
410
+ return [Either.right(block), newState];
411
+ }));
412
+ // Return the result
413
+ if (Either.isLeft(blockResult)) {
414
+ return yield* Effect.fail(blockResult.left);
415
+ }
416
+ return blockResult.right;
417
+ });
418
+ }
419
+ // ===========================================================================
420
+ // Query Method
421
+ // ===========================================================================
422
+ /**
423
+ * Query the simulator state with a custom function. This is a generic query mechanism that allows extracting any
424
+ * information from the current state without modifying it.
425
+ *
426
+ * @example
427
+ * ```typescript
428
+ * // Query fee prices
429
+ * const feePrices = yield* simulator.query(state => state.ledger.parameters.feePrices);
430
+ *
431
+ * // Use composable state accessors
432
+ * const blockNumber = yield* simulator.query(getCurrentBlockNumber);
433
+ * const lastBlock = yield* simulator.query(getLastBlock);
434
+ * const events = yield* simulator.query(getLastBlockEvents);
435
+ *
436
+ * // Query UTXOs for an address
437
+ * const utxos = yield* simulator.query(state => Array.from(state.ledger.utxo.filter(address)));
438
+ *
439
+ * // Complex query returning multiple values
440
+ * const info = yield* simulator.query(state => ({
441
+ * networkId: state.networkId,
442
+ * blockNumber: getCurrentBlockNumber(state),
443
+ * feePrices: state.ledger.parameters.feePrices,
444
+ * }));
445
+ * ```;
446
+ *
447
+ * @param fn - Function that receives the current state and returns a result
448
+ * @returns The result of applying the function to the current state
449
+ */
450
+ query(fn) {
451
+ return Effect.map(SubscriptionRef.get(this.#stateRef), fn);
452
+ }
453
+ }