@effectstream/midnight-contracts 0.100.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,995 @@
1
+ import dotenv from "dotenv";
2
+ import { parseArgs } from "node:util";
3
+ // graphql-ws has a dangling promise in its subscribe flow: when a subscription ends,
4
+ // the socket closes normally (code 1000) but the internal throwOnClose promise rejects
5
+ // with no handler. In Bun, unhandled rejections crash the process (exit 1).
6
+ if (globalThis.process?.versions?.bun) {
7
+ process.on('unhandledRejection', (reason: unknown) => {
8
+ if (reason && typeof reason === 'object' && 'type' in reason && (reason as any).type === 'close') {
9
+ return;
10
+ }
11
+ console.error('Unhandled rejection:', reason);
12
+ process.exit(1);
13
+ });
14
+ }
15
+ import { ShieldedWallet } from "@midnight-ntwrk/wallet-sdk-shielded";
16
+ import {
17
+ DustSecretKey,
18
+ LedgerParameters,
19
+ ZswapSecretKeys,
20
+ shieldedToken,
21
+ type UnprovenTransaction,
22
+ } from "@midnight-ntwrk/ledger-v8";
23
+
24
+ const log = console;
25
+ import { setNetworkId } from "@midnight-ntwrk/midnight-js-network-id";
26
+ import { Buffer } from "node:buffer";
27
+ import * as Rx from "rxjs";
28
+ import { HDWallet, Roles } from "@midnight-ntwrk/wallet-sdk-hd";
29
+ import { type DefaultConfiguration, WalletFacade } from "@midnight-ntwrk/wallet-sdk-facade";
30
+ import { DustWallet } from "@midnight-ntwrk/wallet-sdk-dust-wallet";
31
+ import {
32
+ UnshieldedWallet,
33
+ createKeystore,
34
+ PublicKey,
35
+ InMemoryTransactionHistoryStorage,
36
+ type UnshieldedKeystore,
37
+ } from "@midnight-ntwrk/wallet-sdk-unshielded-wallet";
38
+ import { type ShieldedWalletState } from "@midnight-ntwrk/wallet-sdk-shielded";
39
+ import { NetworkId } from "@midnight-ntwrk/wallet-sdk-abstractions";
40
+ import { MidnightBech32m } from "@midnight-ntwrk/wallet-sdk-address-format";
41
+ import * as path from "node:path";
42
+ import { mkdirSync, writeFileSync, readFileSync } from "node:fs";
43
+ import { CONSTANTS } from "./constants.ts";
44
+ import type { NetworkUrls, WalletResult } from "./types.ts";
45
+ import { midnightNetworkConfig } from "./midnight-env.ts";
46
+ import { getEnv, args as getArgs, exit, isNotFoundError } from "@effectstream/utils/runtime";
47
+
48
+ // ============================================================================
49
+ // Dust State Persistence
50
+ // ============================================================================
51
+
52
+ const DEFAULT_DUST_STATE_DIR = "dust-state";
53
+
54
+ /**
55
+ * Build the dust state file path from network + seed.
56
+ * Uses first 16 hex chars of the seed as a stable identifier — the seed
57
+ * deterministically maps to a dust address, so this is a unique key per wallet
58
+ * that is available before building the facade.
59
+ */
60
+ function getDustStatePath(baseDir: string, networkId: string, seed: string): string {
61
+ const seedKey = seed.slice(0, 16);
62
+ return path.join(baseDir, `${networkId}-${seedKey}.json`);
63
+ }
64
+
65
+ function isUndeployedNetwork(networkId: string): boolean {
66
+ return networkId.toLowerCase() === "undeployed";
67
+ }
68
+
69
+ /**
70
+ * Save serialized dust wallet state to disk.
71
+ * No-ops for "undeployed" networks (chain resets make cached state invalid).
72
+ * @param seed - Wallet seed hex string (used to derive stable file path)
73
+ */
74
+ export function saveDustState(
75
+ baseDir: string,
76
+ networkId: string,
77
+ seed: string,
78
+ serializedState: string,
79
+ ): string | null {
80
+ if (isUndeployedNetwork(networkId)) return null;
81
+ const filePath = getDustStatePath(baseDir, networkId, seed);
82
+ try {
83
+ mkdirSync(path.dirname(filePath), { recursive: true });
84
+ writeFileSync(filePath, serializedState, "utf-8");
85
+ log.info(`Dust state saved to ${filePath}`);
86
+ return filePath;
87
+ } catch (e) {
88
+ log.warn(`Failed to save dust state to ${filePath}: ${e instanceof Error ? e.message : String(e)}`);
89
+ return null;
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Load previously saved dust wallet state from disk.
95
+ * Always returns null for "undeployed" networks.
96
+ * @param seed - Wallet seed hex string (used to derive stable file path)
97
+ */
98
+ export function loadDustState(
99
+ baseDir: string,
100
+ networkId: string,
101
+ seed: string,
102
+ ): string | null {
103
+ if (isUndeployedNetwork(networkId)) return null;
104
+ const filePath = getDustStatePath(baseDir, networkId, seed);
105
+ try {
106
+ return readFileSync(filePath, "utf-8");
107
+ } catch {
108
+ return null;
109
+ }
110
+ }
111
+
112
+ // ============================================================================
113
+ // Key Derivation
114
+ // ============================================================================
115
+
116
+ type DerivationRole = typeof Roles.Zswap | typeof Roles.Dust | typeof Roles.NightExternal;
117
+
118
+ function deriveSeedForRole(seed: string, role: DerivationRole): Uint8Array {
119
+ const seedBuffer = Buffer.from(seed, "hex");
120
+ const hdWalletResult = HDWallet.fromSeed(seedBuffer);
121
+
122
+ if (hdWalletResult.type !== "seedOk") {
123
+ throw new Error(`Failed to create HD wallet: ${hdWalletResult.type}`);
124
+ }
125
+
126
+ const derivationResult = hdWalletResult.hdWallet
127
+ .selectAccount(0)
128
+ .selectRole(role)
129
+ .deriveKeyAt(0);
130
+
131
+ if (derivationResult.type === "keyOutOfBounds") {
132
+ throw new Error(`Key derivation out of bounds for role: ${role}`);
133
+ }
134
+
135
+ return Buffer.from(derivationResult.key);
136
+ }
137
+
138
+ // ============================================================================
139
+ // Wallet Configuration
140
+ // ============================================================================
141
+
142
+ /**
143
+ * Resolve sync timeout from env or default.
144
+ */
145
+ export function resolveWalletSyncTimeoutMs(): number {
146
+ const envValue = getEnv("MIDNIGHT_WALLET_SYNC_TIMEOUT_MS");
147
+ if (!envValue) return CONSTANTS.WALLET_SYNC_TIMEOUT_MS;
148
+ const parsed = Number(envValue);
149
+ if (Number.isFinite(parsed) && parsed > 0) return parsed;
150
+ log.warn(
151
+ `Invalid MIDNIGHT_WALLET_SYNC_TIMEOUT_MS="${envValue}", using default ${CONSTANTS.WALLET_SYNC_TIMEOUT_MS}ms`
152
+ );
153
+ return CONSTANTS.WALLET_SYNC_TIMEOUT_MS;
154
+ }
155
+
156
+ /**
157
+ * Safely stringify progress objects that may contain bigint.
158
+ */
159
+ export function safeStringifyProgress(value: unknown): string {
160
+ try {
161
+ return JSON.stringify(value, (_key, v) =>
162
+ typeof v === "bigint" ? v.toString() : v
163
+ );
164
+ } catch (_err) {
165
+ return String(value);
166
+ }
167
+ }
168
+
169
+ /**
170
+ * Wait for wallet to be synced and funded
171
+ */
172
+ export async function syncAndWaitForFunds(
173
+ wallet: WalletFacade,
174
+ options?: { timeoutMs?: number; waitNonZero?: boolean; skipShielded?: boolean },
175
+ ): Promise<{ shieldedBalance: bigint; unshieldedBalance: bigint; dustBalance: bigint }> {
176
+ const skipShielded = options?.skipShielded ?? false;
177
+ const syncLabel = skipShielded ? "unshielded/dust" : "shielded/unshielded/dust";
178
+ log.info(
179
+ `Waiting for wallet to sync and receive funds (${syncLabel})...`
180
+ );
181
+
182
+ const syncTimeoutMs = options?.timeoutMs ?? resolveWalletSyncTimeoutMs();
183
+ const waitNonZero = options?.waitNonZero ?? false;
184
+
185
+ let latestState: any = null;
186
+ const periodicLogger = setInterval(() => {
187
+ if (!latestState) return;
188
+ const shieldedSynced = latestState.shielded.state.progress.isStrictlyComplete();
189
+ const dustSynced = latestState.dust.state.progress.isStrictlyComplete();
190
+ const unshieldedSynced = latestState.unshielded?.progress?.isStrictlyComplete() ?? false;
191
+ log.info(
192
+ `[wait] shielded=${shieldedSynced}, unshielded=${unshieldedSynced}, dust=${dustSynced}`
193
+ );
194
+ }, CONSTANTS.WALLET_SYNC_THROTTLE_MS);
195
+ const syncStartedAt = Date.now();
196
+
197
+ const state = await Rx.firstValueFrom(
198
+ wallet.state().pipe(
199
+ Rx.throttleTime(CONSTANTS.WALLET_SYNC_THROTTLE_MS),
200
+ Rx.tap((state: any) => {
201
+ const elapsedSec = ((Date.now() - syncStartedAt) / 1000).toFixed(0);
202
+ const shieldedSynced = state.shielded.state.progress.isStrictlyComplete();
203
+ const dustSynced = state.dust.state.progress.isStrictlyComplete();
204
+ const unshieldedSynced = state.unshielded?.progress?.isStrictlyComplete() ?? false;
205
+ log.info(
206
+ `Wallet sync progress (${elapsedSec}s): shielded=${shieldedSynced}, unshielded=${unshieldedSynced}, dust=${dustSynced}`
207
+ );
208
+ }),
209
+ Rx.filter((state: any) => {
210
+ const shieldedSynced = skipShielded || state.shielded.state.progress.isStrictlyComplete();
211
+ const dustSynced = state.dust.state.progress.isStrictlyComplete();
212
+ const unshieldedSynced = state.unshielded?.progress?.isStrictlyComplete() ?? false;
213
+
214
+ if (!shieldedSynced || !dustSynced || !unshieldedSynced) return false;
215
+
216
+ if (waitNonZero && !skipShielded) {
217
+ const tokenId = shieldedToken().tag;
218
+ const shieldedBalance = state.shielded.balances[tokenId] ?? 0n;
219
+ return shieldedBalance > 0n;
220
+ }
221
+
222
+ return true;
223
+ }),
224
+ Rx.tap(() => log.info("Wallet sync complete")),
225
+ Rx.timeout({
226
+ each: syncTimeoutMs,
227
+ with: () =>
228
+ Rx.throwError(
229
+ () => new Error(`Wallet sync timeout after ${syncTimeoutMs}ms`)
230
+ ),
231
+ })
232
+ )
233
+ );
234
+
235
+ const tokenId = shieldedToken().tag;
236
+
237
+ const shieldedBalancesObj = state.shielded.balances || {};
238
+ const availableKeys = Object.keys(shieldedBalancesObj);
239
+ log.info(`Available shielded balance keys: ${availableKeys.length > 0 ? availableKeys.join(', ') : 'none'}`);
240
+ log.info(`Looking for token ID: ${tokenId}`);
241
+
242
+ const shieldedBalance = shieldedBalancesObj[tokenId] ?? 0n;
243
+ log.info(`Shielded balance for token ${tokenId}: ${shieldedBalance}`);
244
+
245
+ const unshieldedBalancesObj = (state.unshielded?.balances ?? {}) as Record<string, bigint>;
246
+ const unshieldedBalance = Object.values(unshieldedBalancesObj).reduce(
247
+ (acc, v) => acc + (v ?? 0n),
248
+ 0n
249
+ );
250
+
251
+ let dustBalance = 0n;
252
+ try {
253
+ dustBalance = state.dust?.balance?.(new Date()) ?? 0n;
254
+ } catch (_err) {
255
+ log.warn("Could not read dust balance from synced state; continuing with dustBalance=0");
256
+ }
257
+
258
+ return { shieldedBalance, unshieldedBalance, dustBalance };
259
+ }
260
+
261
+ /**
262
+ * Wait for dust wallet sync and return dust balance if available.
263
+ */
264
+ export async function waitForDustFunds(
265
+ wallet: WalletFacade,
266
+ optionsOrTimeout?: number | { timeoutMs?: number; waitNonZero?: boolean }
267
+ ): Promise<bigint> {
268
+ log.info("Waiting for dust wallet to sync and receive funds...");
269
+
270
+ const options = typeof optionsOrTimeout === 'number'
271
+ ? { timeoutMs: optionsOrTimeout }
272
+ : optionsOrTimeout;
273
+
274
+ const syncTimeoutMs = options?.timeoutMs ?? resolveWalletSyncTimeoutMs();
275
+ const waitNonZero = options?.waitNonZero ?? false;
276
+ const dustSyncStartedAt = Date.now();
277
+
278
+ const dustWallet = (wallet as any).dust;
279
+ if (!dustWallet || !dustWallet.state) {
280
+ log.warn("Dust wallet state not available; skipping dust balance wait.");
281
+ return 0n;
282
+ }
283
+
284
+ const dustBalance = (await Rx.firstValueFrom(
285
+ (dustWallet.state as Rx.Observable<any>).pipe(
286
+ Rx.throttleTime(CONSTANTS.WALLET_SYNC_THROTTLE_MS),
287
+ Rx.tap((state: any) => {
288
+ try {
289
+ const elapsedSec = ((Date.now() - dustSyncStartedAt) / 1000).toFixed(0);
290
+ const complete = state.state?.progress?.isStrictlyComplete();
291
+ log.info(`Dust wallet sync progress (${elapsedSec}s): complete=${complete ?? "unknown"}`);
292
+ } catch (_err) {
293
+ // ignore logging errors
294
+ }
295
+ }),
296
+ Rx.filter((state: any) => {
297
+ try {
298
+ return state.state?.progress?.isStrictlyComplete() === true;
299
+ } catch (_err) {
300
+ return false;
301
+ }
302
+ }),
303
+ Rx.map((state: any) => {
304
+ try {
305
+ if (typeof state.balance === "function") {
306
+ return state.balance(new Date()) as bigint;
307
+ }
308
+ } catch (_err) {
309
+ // fall through
310
+ }
311
+ return 0n;
312
+ }),
313
+ Rx.timeout({
314
+ each: syncTimeoutMs,
315
+ with: () =>
316
+ Rx.throwError(
317
+ () => new Error(`Dust wallet sync timeout after ${syncTimeoutMs}ms`)
318
+ ),
319
+ }),
320
+ Rx.filter((balance: bigint) => !waitNonZero || balance > 0n),
321
+ Rx.tap((balance: bigint) => {
322
+ if (balance > 0n) log.info(`Dust wallet balance: ${balance}`);
323
+ })
324
+ )
325
+ )) as bigint;
326
+
327
+ return dustBalance;
328
+ }
329
+
330
+ // ============================================================================
331
+ // Dust Sync with Retry
332
+ // ============================================================================
333
+
334
+ const DUST_STALL_TIMEOUT_MS = 60_000;
335
+ const DUST_MAX_RETRIES = 5;
336
+ const DUST_COMPLETION_GAP = 50n;
337
+
338
+ export interface DustSyncWithRetryOptions {
339
+ networkUrls: Required<NetworkUrls>;
340
+ seed: string;
341
+ networkId: NetworkId.NetworkId;
342
+ /** Per-emission stall timeout in ms. Default: 60_000 (1 minute). */
343
+ stallTimeoutMs?: number;
344
+ /** Maximum retry attempts on stall. Default: 5. */
345
+ maxRetries?: number;
346
+ /** Throttle interval for state emissions in ms. Default: 10_000 (10s). */
347
+ throttleMs?: number;
348
+ syncMode?: WalletSyncMode;
349
+ /** Base directory for dust state files (relative to CWD). Default: "dust-state". */
350
+ dustStateDir?: string;
351
+ }
352
+
353
+ /**
354
+ * Build a wallet facade and wait for dust sync with stall-detection retry.
355
+ *
356
+ * On each stall (no new state emission within `stallTimeoutMs`):
357
+ * 1. Serialize the current dust state
358
+ * 2. Stop the wallet facade
359
+ * 3. Rebuild from the saved state (skips already-synced blocks)
360
+ * 4. Resume sync
361
+ *
362
+ * For non-undeployed networks, state is also persisted to disk so subsequent
363
+ * process startups can resume from the last checkpoint.
364
+ */
365
+ export async function waitForDustFundsWithRetry(
366
+ options: DustSyncWithRetryOptions,
367
+ existingWalletResult?: WalletResult,
368
+ ): Promise<{ walletResult: WalletResult; dustBalance: bigint }> {
369
+ const {
370
+ networkUrls,
371
+ seed,
372
+ networkId,
373
+ stallTimeoutMs = DUST_STALL_TIMEOUT_MS,
374
+ maxRetries = DUST_MAX_RETRIES,
375
+ throttleMs = CONSTANTS.WALLET_SYNC_THROTTLE_MS,
376
+ syncMode = 'dust-only',
377
+ dustStateDir = DEFAULT_DUST_STATE_DIR,
378
+ } = options;
379
+
380
+ const networkIdStr = String(networkId);
381
+
382
+ // Pre-load cached state from disk before building any wallet.
383
+ // Uses seed-based path so we don't need the dust address yet.
384
+ const cachedState: string | null = loadDustState(dustStateDir, networkIdStr, seed);
385
+ if (cachedState) {
386
+ log.info(`Loaded cached dust state from disk (${getDustStatePath(dustStateDir, networkIdStr, seed)})`);
387
+ }
388
+ let inMemoryState: string | null = null;
389
+
390
+ // Helper to serialize and save dust state
391
+ async function serializeAndSave(wr: WalletResult): Promise<string | null> {
392
+ try {
393
+
394
+ const serialized: string = await (wr.wallet as any).dust.serializeState();
395
+ inMemoryState = serialized;
396
+ saveDustState(dustStateDir, networkIdStr, seed, serialized);
397
+ return serialized;
398
+ } catch (e) {
399
+ log.warn(`Failed to serialize dust state: ${e instanceof Error ? e.message : String(e)}`);
400
+ return null;
401
+ }
402
+ }
403
+
404
+ // Helper to stop a wallet safely
405
+ async function stopWallet(wr: WalletResult | null): Promise<void> {
406
+ if (!wr) return;
407
+ try {
408
+ await wr.wallet.stop();
409
+ } catch {
410
+ // ignore stop errors
411
+ }
412
+ }
413
+
414
+ // Build or reuse wallet
415
+ let walletResult = existingWalletResult ?? null;
416
+
417
+ let dustSyncedState: any = null;
418
+ let lastAppliedIndex = 0n;
419
+
420
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
421
+ lastAppliedIndex = 0n;
422
+ try {
423
+ if (!walletResult) {
424
+ const stateToRestore = inMemoryState ?? cachedState;
425
+ if (attempt > 1) {
426
+ if (stateToRestore) {
427
+ log.info(`Retry ${attempt}/${maxRetries}: rebuilding wallet from ${inMemoryState ? 'in-memory' : 'cached'} state...`);
428
+ } else {
429
+ log.info(`Retry ${attempt}/${maxRetries}: rebuilding wallet from scratch...`);
430
+ }
431
+ } else if (stateToRestore) {
432
+ log.info("Building wallet from cached dust state...");
433
+ }
434
+ walletResult = await buildWalletFacade(
435
+ networkUrls,
436
+ seed,
437
+ networkId,
438
+ syncMode,
439
+ stateToRestore,
440
+ );
441
+ }
442
+
443
+
444
+ const dustWallet = (walletResult.wallet as any).dust;
445
+ if (!dustWallet || !dustWallet.state) {
446
+ log.warn("Dust wallet state not available; skipping dust sync.");
447
+ return { walletResult, dustBalance: 0n };
448
+ }
449
+
450
+ // First, try the SDK's built-in waitForSyncedState with DUST_COMPLETION_GAP.
451
+ // This handles the common case (mainnet/testnet with blocks to sync).
452
+ // If it throws (timeout or no sync events), fall back to reading the
453
+ // current state directly — on fresh chains with no dust activity,
454
+ // the wallet has nothing to sync and waitForSyncedState would hang forever.
455
+ const syncStartedAt = Date.now();
456
+ try {
457
+ if (typeof dustWallet.waitForSyncedState === "function") {
458
+ dustSyncedState = await Promise.race([
459
+ dustWallet.waitForSyncedState(DUST_COMPLETION_GAP),
460
+ new Promise<never>((_, reject) =>
461
+ setTimeout(() => reject(new Error("stall")), stallTimeoutMs)
462
+ ),
463
+ ]);
464
+ } else {
465
+ // Fallback: RxJS pipeline for SDK versions without waitForSyncedState
466
+
467
+ dustSyncedState = await Rx.firstValueFrom(
468
+
469
+ (dustWallet.state as Rx.Observable<any>).pipe(
470
+ Rx.throttleTime(throttleMs),
471
+ Rx.timeout({
472
+ each: stallTimeoutMs,
473
+ with: () => Rx.throwError(() => new Error("stall")),
474
+ }),
475
+
476
+ Rx.filter((ds: any) => {
477
+ const progress = ds.state?.progress;
478
+ if (!progress) return false;
479
+ if (typeof progress.isCompleteWithin === "function") {
480
+ if (progress.isCompleteWithin(DUST_COMPLETION_GAP)) return true;
481
+ } else if (typeof progress.isStrictlyComplete === "function") {
482
+ if (progress.isStrictlyComplete()) return true;
483
+ }
484
+ const applied = progress.appliedIndex ?? 0n;
485
+ const target = progress.highestRelevantWalletIndex ?? 0n;
486
+ if (applied > 0n && target > 0n && progress.isConnected && applied === lastAppliedIndex) {
487
+ return true;
488
+ }
489
+ lastAppliedIndex = applied;
490
+ return false;
491
+ }),
492
+ ),
493
+ );
494
+ }
495
+ } catch (syncErr) {
496
+ // Sync timed out or stalled. Read the current state to check progress.
497
+
498
+ const currentState = await Rx.firstValueFrom(dustWallet.state as Rx.Observable<any>);
499
+ const progress = currentState?.state?.progress;
500
+ const applied = progress?.appliedIndex ?? 0n;
501
+ const target = progress?.highestRelevantWalletIndex ?? 0n;
502
+ const elapsedSec = ((Date.now() - syncStartedAt) / 1000).toFixed(0);
503
+
504
+ // Update lastAppliedIndex so the stall handler knows progress was made
505
+ // and preserves the in-memory state for the next retry.
506
+ lastAppliedIndex = applied;
507
+
508
+ log.info(
509
+ `Dust sync attempt ${attempt}/${maxRetries} (${elapsedSec}s): ${applied}/${target} connected=${progress?.isConnected ?? "?"}`,
510
+ );
511
+
512
+ if (applied === 0n && target === 0n) {
513
+ // Nothing to sync — the wallet has no dust history on this chain.
514
+ // Accept the current state as "complete".
515
+ log.info("Dust wallet has no events to sync (0/0). Accepting current state.");
516
+ dustSyncedState = currentState;
517
+ } else {
518
+ // There IS data to sync but the wallet stalled. Re-throw for retry.
519
+ throw syncErr;
520
+ }
521
+ }
522
+
523
+ const elapsedSec = ((Date.now() - syncStartedAt) / 1000).toFixed(0);
524
+ const syncProgress = dustSyncedState?.state?.progress;
525
+ log.info(
526
+ `Dust sync attempt ${attempt}/${maxRetries} (${elapsedSec}s): ` +
527
+ `${syncProgress?.appliedIndex ?? "?"}/${syncProgress?.highestRelevantWalletIndex ?? "?"} ` +
528
+ `connected=${syncProgress?.isConnected ?? "?"}`,
529
+ );
530
+
531
+ // Sync complete — extract balance and save state
532
+ let dustBalance = 0n;
533
+ try {
534
+ if (typeof dustSyncedState.balance === "function") {
535
+ dustBalance = dustSyncedState.balance(new Date()) as bigint;
536
+ } else if (typeof dustSyncedState.walletBalance === "function") {
537
+ dustBalance = dustSyncedState.walletBalance(new Date()) as bigint;
538
+ }
539
+ } catch {
540
+ // ignore balance extraction errors
541
+ }
542
+
543
+ log.info(`Dust sync complete (attempt ${attempt}). Balance: ${dustBalance}`);
544
+
545
+ // Save final state to disk
546
+ await serializeAndSave(walletResult);
547
+
548
+ return { walletResult, dustBalance };
549
+ } catch (e) {
550
+ const isStall = e instanceof Error && e.message === "stall";
551
+
552
+ if (isStall && attempt < maxRetries) {
553
+ log.warn(`Dust sync stalled on attempt ${attempt}/${maxRetries}. Stopping wallet and rebuilding from state...`);
554
+ if (walletResult) {
555
+ await serializeAndSave(walletResult);
556
+ await stopWallet(walletResult);
557
+ walletResult = null;
558
+ }
559
+ // If target was 0 on this attempt, the state is likely invalid — don't restore it
560
+ if (lastAppliedIndex === 0n) {
561
+ log.warn("No sync progress was made (appliedIndex=0), discarding in-memory state for clean rebuild");
562
+ inMemoryState = null;
563
+ }
564
+ continue;
565
+ }
566
+
567
+ // Non-stall error or final attempt — save what we have and throw
568
+ if (walletResult) {
569
+ await serializeAndSave(walletResult);
570
+ }
571
+
572
+ if (isStall) {
573
+ throw new Error(
574
+ `Dust wallet sync stalled after ${maxRetries} attempts. ` +
575
+ `Each attempt waited ${stallTimeoutMs}ms for new state emissions.`,
576
+ );
577
+ }
578
+ throw e;
579
+ }
580
+ }
581
+
582
+ // Should not reach here, but just in case
583
+ throw new Error("waitForDustFundsWithRetry: exhausted all retries");
584
+ }
585
+
586
+ export function getInitialDustState(
587
+ dustWallet: any
588
+ ): Promise<any> {
589
+ return Rx.firstValueFrom(dustWallet.state);
590
+ }
591
+
592
+ /**
593
+ * Create wallet configuration for the modular Midnight SDK
594
+ */
595
+ function createWalletConfiguration(
596
+ networkUrls: Required<NetworkUrls>,
597
+ networkId: NetworkId.NetworkId,
598
+ ): DefaultConfiguration {
599
+ return {
600
+ indexerClientConnection: {
601
+ indexerHttpUrl: networkUrls.indexer,
602
+ indexerWsUrl: networkUrls.indexerWS,
603
+ },
604
+ provingServerUrl: new URL(networkUrls.proofServer),
605
+ relayURL: new URL(networkUrls.node.replace("http", "ws")),
606
+ networkId: networkId,
607
+ txHistoryStorage: new InMemoryTransactionHistoryStorage(),
608
+ costParameters: {
609
+ additionalFeeOverhead: CONSTANTS.DUST_FEE_OVERHEAD,
610
+ feeBlocksMargin: CONSTANTS.DUST_FEE_BLOCKS_MARGIN,
611
+ },
612
+ };
613
+ }
614
+
615
+ function buildShieldedWallet(config: DefaultConfiguration, seed: Uint8Array) {
616
+ return ShieldedWallet(config as any).startWithSeed(seed);
617
+ }
618
+
619
+ function buildDustWallet(
620
+ config: DefaultConfiguration,
621
+ seed: Uint8Array,
622
+ serializedState?: string | null,
623
+ ) {
624
+ const dustConfig = {
625
+ ...config,
626
+ costParameters: {
627
+ ledgerParams: LedgerParameters.initialParameters(),
628
+ additionalFeeOverhead: CONSTANTS.DUST_FEE_OVERHEAD,
629
+ feeBlocksMargin: CONSTANTS.DUST_FEE_BLOCKS_MARGIN,
630
+ },
631
+ };
632
+ if (serializedState) {
633
+ log.info("Restoring dust wallet from cached state");
634
+ return DustWallet(dustConfig as any).restore(serializedState);
635
+ }
636
+ const dustParameters = LedgerParameters.initialParameters().dust;
637
+ return DustWallet(dustConfig as any).startWithSeed(seed, dustParameters);
638
+ }
639
+
640
+ function buildUnshieldedWallet(
641
+ config: DefaultConfiguration,
642
+ keystore: UnshieldedKeystore,
643
+ ) {
644
+ return UnshieldedWallet({
645
+ ...config,
646
+ txHistoryStorage: new InMemoryTransactionHistoryStorage(),
647
+ } as any).startWithPublicKey(PublicKey.fromKeyStore(keystore));
648
+ }
649
+
650
+ export type WalletSyncMode = 'all' | 'dust-only';
651
+
652
+ /**
653
+ * Build a wallet facade. When `syncMode` is `'dust-only'`, the shielded and
654
+ * unshielded wallets are stopped immediately after startup so they don't
655
+ * consume indexer connections or CPU. The facade still requires all three
656
+ * at init time (SDK constraint), but the unused ones are torn down.
657
+ */
658
+ export async function buildWalletFacade(
659
+ networkUrls: Required<NetworkUrls>,
660
+ seed: string,
661
+ networkId: NetworkId.NetworkId,
662
+ syncMode: WalletSyncMode = 'all',
663
+ dustSerializedState?: string | null,
664
+ ): Promise<WalletResult> {
665
+ const shieldedSeed = deriveSeedForRole(seed, Roles.Zswap);
666
+ const dustSeed = deriveSeedForRole(seed, Roles.Dust);
667
+ const unshieldedSeed = deriveSeedForRole(seed, Roles.NightExternal);
668
+
669
+ const config = createWalletConfiguration(networkUrls, networkId);
670
+
671
+ const unshieldedKeystore = createKeystore(unshieldedSeed, networkId);
672
+
673
+ const shieldedWallet = buildShieldedWallet(config, shieldedSeed);
674
+ const dustWallet = buildDustWallet(config, dustSeed, dustSerializedState);
675
+ const unshieldedWallet = buildUnshieldedWallet(config, unshieldedKeystore);
676
+
677
+ const zswapSecretKeys = ZswapSecretKeys.fromSeed(shieldedSeed);
678
+ const dustSecretKey = DustSecretKey.fromSeed(dustSeed);
679
+
680
+ const wallet: WalletFacade = await WalletFacade.init({
681
+ configuration: config as any,
682
+ shielded: () => shieldedWallet,
683
+ unshielded: () => unshieldedWallet,
684
+ dust: () => dustWallet,
685
+ });
686
+
687
+ await wallet.start(zswapSecretKeys, dustSecretKey);
688
+
689
+ if (syncMode === 'dust-only') {
690
+ log.info("Stopping shielded and unshielded wallet sync (dust-only mode)");
691
+ await Promise.all([
692
+ (wallet.shielded as any).stop?.(),
693
+ (wallet.unshielded as any).stop?.(),
694
+ ]);
695
+ }
696
+
697
+ const unshieldedAddress = unshieldedKeystore.getBech32Address().asString();
698
+ const dustState = await getInitialDustState(wallet.dust);
699
+ const dustAddress = MidnightBech32m.encode(networkId, dustState.address).asString();
700
+
701
+ return {
702
+ wallet,
703
+ zswapSecretKeys,
704
+ walletZswapSecretKeys: zswapSecretKeys,
705
+ dustSecretKey,
706
+ walletDustSecretKey: dustSecretKey,
707
+ dustAddress,
708
+ unshieldedAddress,
709
+ unshieldedKeystore,
710
+ };
711
+ }
712
+
713
+ export function getInitialShieldedState(
714
+ shieldedWallet: any
715
+ ): Promise<ShieldedWalletState> {
716
+ return Rx.firstValueFrom(shieldedWallet.state);
717
+ }
718
+
719
+ /**
720
+ * Get initial state of unshielded wallet
721
+ */
722
+ function getInitialUnshieldedState(
723
+ unshieldedWallet: any
724
+ ): Promise<any> {
725
+ if (!unshieldedWallet) return Promise.resolve(null);
726
+ if (unshieldedWallet.state && typeof unshieldedWallet.state.pipe === 'function') {
727
+ return Rx.firstValueFrom(unshieldedWallet.state);
728
+ }
729
+ if (typeof unshieldedWallet.state === 'function') {
730
+ return Rx.firstValueFrom(unshieldedWallet.state());
731
+ }
732
+ return Promise.resolve(null);
733
+ }
734
+
735
+ /**
736
+ * Register unshielded Night UTXOs for dust generation
737
+ * This is required before the wallet can pay transaction fees
738
+ */
739
+ export async function registerNightForDust(walletResult: WalletResult): Promise<boolean> {
740
+ log.info("Checking for unshielded Night UTXOs to register for dust generation...");
741
+
742
+ const state = await Rx.firstValueFrom(
743
+ walletResult.wallet.state().pipe(
744
+
745
+ Rx.filter((s: any) => {
746
+ // Only require unshielded+dust sync for dust registration (shielded is not needed)
747
+ const dustSynced = s.dust?.state?.progress?.isStrictlyComplete() ?? false;
748
+ const unshieldedSynced = s.unshielded?.progress?.isStrictlyComplete() ?? false;
749
+ return dustSynced && unshieldedSynced;
750
+ }),
751
+ Rx.timeout({
752
+ each: resolveWalletSyncTimeoutMs(),
753
+ with: () => Rx.throwError(() => new Error("Timeout waiting for unshielded+dust sync for dust registration")),
754
+ })
755
+ )
756
+ );
757
+
758
+ const unregisteredNightUtxos =
759
+ (state as any).unshielded?.availableCoins?.filter((coin: any) => coin.meta.registeredForDustGeneration === false) ?? [];
760
+
761
+ if (unregisteredNightUtxos.length === 0) {
762
+ log.info("No unregistered unshielded Night UTXOs available.");
763
+ const dustBalance = await waitForDustFunds(walletResult.wallet, { timeoutMs: 5000 });
764
+ return dustBalance > 0n;
765
+ }
766
+
767
+ log.info(`Found ${unregisteredNightUtxos.length} unregistered Night UTXOs. Registering for dust...`);
768
+
769
+ try {
770
+ const recipe = await (walletResult.wallet as any).registerNightUtxosForDustGeneration(
771
+ unregisteredNightUtxos,
772
+ walletResult.unshieldedKeystore.getPublicKey(),
773
+ (payload: Uint8Array) => walletResult.unshieldedKeystore.signData(payload)
774
+ );
775
+
776
+ const signedRecipe: UnprovenTransaction = await (walletResult.wallet as any).signUnprovenTransaction(
777
+ recipe.transaction,
778
+ (payload: Uint8Array) => walletResult.unshieldedKeystore.signData(payload),
779
+ );
780
+
781
+ log.info("Submitting dust registration transaction...");
782
+ const txId = await walletResult.wallet.submitTransaction(
783
+ await (walletResult.wallet as any).finalizeTransaction(signedRecipe)
784
+ );
785
+ log.info(`Dust registration submitted with tx id: ${txId}`);
786
+
787
+ log.info("Waiting for dust to be generated...");
788
+ await Rx.firstValueFrom(
789
+ walletResult.wallet.state().pipe(
790
+ Rx.throttleTime(CONSTANTS.WALLET_SYNC_THROTTLE_MS),
791
+ Rx.tap((s: any) => {
792
+ const dustBalance = s.dust?.balance?.(new Date()) ?? 0n;
793
+ log.info(`Current dust balance: ${dustBalance}`);
794
+ }),
795
+ Rx.filter((s: any) => (s.dust?.balance?.(new Date()) ?? 0n) > 0n),
796
+ Rx.timeout({
797
+ each: resolveWalletSyncTimeoutMs(),
798
+ with: () => Rx.throwError(() => new Error("Timeout waiting for dust generation"))
799
+ })
800
+ )
801
+ );
802
+
803
+ log.info("Dust registration complete!");
804
+ return true;
805
+ } catch (e) {
806
+ log.error(`Failed to register Night UTXOs for dust: ${e instanceof Error ? e.message : String(e)}`);
807
+ return false;
808
+ }
809
+ }
810
+
811
+ // ============================================================================
812
+ // Main
813
+ // ============================================================================
814
+
815
+ async function main() {
816
+ const { values: parsedArgs } = parseArgs({
817
+ args: getArgs(),
818
+ options: {
819
+ seed: { type: "string" },
820
+ balance: { type: "boolean", default: false },
821
+ },
822
+ strict: false,
823
+ });
824
+
825
+ const result = dotenv.config({ path: ".env.testnet", override: true });
826
+ if (result.error) {
827
+ if (!isNotFoundError(result.error)) {
828
+ log.warn(`Failed to load .env.testnet: ${result.error}`);
829
+ }
830
+ }
831
+
832
+ const envSeed = getEnv("MIDNIGHT_WALLET_SEED");
833
+ const argSeed = parsedArgs.seed;
834
+ let seed = argSeed || envSeed;
835
+
836
+ if (!seed) {
837
+ log.info("No seed provided. Generating a new random 32-byte hex seed...");
838
+ const randomBytes = new Uint8Array(32);
839
+ crypto.getRandomValues(randomBytes);
840
+ seed = Array.from(randomBytes)
841
+ .map((b) => b.toString(16).padStart(2, "0"))
842
+ .join("");
843
+ log.info("==========================================");
844
+ log.info(`GENERATED SEED: ${seed}`);
845
+ log.info("SAVE THIS SEED! YOU WILL NEED IT TO RESTORE THIS WALLET.");
846
+ log.info("==========================================");
847
+ } else {
848
+ log.info("Using provided seed: " + seed);
849
+ }
850
+
851
+ if (seed?.startsWith("000000000000000000000000000000000000000000000000000000000000000")) {
852
+ log.warn("⚠️ Genesis seeds (0x000...001, 0x000...002, etc.) only have funds on 'undeployed' local networks!");
853
+ log.warn("⚠️ For testnet/preview networks, you need to:");
854
+ log.warn(" 1. Generate a new wallet seed, OR");
855
+ log.warn(" 2. Request funds from a faucet for this wallet");
856
+ }
857
+
858
+ const indexer = getEnv("MIDNIGHT_INDEXER_URL") || midnightNetworkConfig.indexer;
859
+ const indexerWS = getEnv("MIDNIGHT_INDEXER_WS_URL") || midnightNetworkConfig.indexerWS;
860
+ const node = getEnv("MIDNIGHT_NODE_URL") || midnightNetworkConfig.node;
861
+ const proofServer = getEnv("MIDNIGHT_PROOF_SERVER_URL") || midnightNetworkConfig.proofServer;
862
+
863
+ const networkUrls: Required<NetworkUrls> = {
864
+ id: "placeholder-value",
865
+ indexer,
866
+ indexerWS,
867
+ node,
868
+ proofServer
869
+ };
870
+
871
+ const networkIdRaw = getEnv("MIDNIGHT_NETWORK_ID") || "undeployed";
872
+
873
+ let networkId: NetworkId.NetworkId;
874
+ switch (networkIdRaw.toLowerCase()) {
875
+ case "undeployed":
876
+ networkId = NetworkId.NetworkId.Undeployed;
877
+ break;
878
+ case "testnet":
879
+ case "testnet-02":
880
+ networkId = NetworkId.NetworkId.TestNet;
881
+ break;
882
+ case "devnet":
883
+ case "qanet":
884
+ networkId = NetworkId.NetworkId.DevNet;
885
+ break;
886
+ case "preview":
887
+ networkId = "preview" as NetworkId.NetworkId;
888
+ log.info(`Using preview network (addresses will have mn_addr_preview prefix)`);
889
+ break;
890
+ default:
891
+ log.warn(`Unknown network ID "${networkIdRaw}", using as-is. Valid values: undeployed, testnet, devnet, preview`);
892
+ networkId = networkIdRaw as NetworkId.NetworkId;
893
+ }
894
+ networkUrls.id = networkId;
895
+
896
+ log.info(`Using network ID: ${networkId}`);
897
+ log.info(`Indexer: ${indexer}`);
898
+ log.info(`Indexer WS: ${indexerWS}`);
899
+ log.info(`Node: ${node}`);
900
+ setNetworkId(networkId);
901
+
902
+ try {
903
+ log.info("Building wallet...");
904
+ const walletResult = await buildWalletFacade(networkUrls, seed, networkId);
905
+
906
+ const initialState = await getInitialShieldedState(walletResult.wallet.shielded);
907
+ const shieldedAddress = initialState.address.coinPublicKeyString();
908
+
909
+ log.info("==========================================");
910
+ log.info("Wallet Addresses");
911
+ log.info("==========================================");
912
+ log.info(`Shielded Address: ${shieldedAddress}`);
913
+ log.info(`Unshielded Address: ${walletResult.unshieldedAddress}`);
914
+ log.info(`Dust Address: ${walletResult.dustAddress}`);
915
+ log.info("==========================================");
916
+
917
+ if (parsedArgs.balance) {
918
+ log.info("==========================================");
919
+ log.info("Fetching Balances...");
920
+ log.info("==========================================");
921
+
922
+ let shieldedBalance = 0n;
923
+ let unshieldedBalance = 0n;
924
+ let dustBalance = 0n;
925
+
926
+ const tokenId = shieldedToken().tag;
927
+ shieldedBalance = initialState.balances[tokenId] ?? 0n;
928
+
929
+ const syncTimeoutMs = resolveWalletSyncTimeoutMs();
930
+
931
+ if (shieldedBalance === 0n) {
932
+ log.info("Shielded balance is 0. Waiting for wallet sync to confirm funds...");
933
+ try {
934
+ const synced = await syncAndWaitForFunds(walletResult.wallet);
935
+ shieldedBalance = synced.shieldedBalance;
936
+ unshieldedBalance = synced.unshieldedBalance;
937
+ dustBalance = synced.dustBalance;
938
+ } catch (e) {
939
+ log.warn(`Sync timed out or failed: ${e instanceof Error ? e.message : String(e)}`);
940
+ }
941
+ } else {
942
+ try {
943
+ const unshieldedState = await getInitialUnshieldedState((walletResult.wallet as any).unshielded);
944
+ const uBalances = (unshieldedState?.balances ?? {}) as Record<string, bigint>;
945
+ unshieldedBalance = Object.values(uBalances).reduce(
946
+ (acc: bigint, v) => acc + (v ?? 0n),
947
+ 0n
948
+ );
949
+ } catch(_e) { /* ignore */ }
950
+
951
+ try {
952
+ const state = await Rx.firstValueFrom(walletResult.wallet.state());
953
+ dustBalance = (state as any).dust?.balance?.(new Date()) ?? 0n;
954
+
955
+ if (dustBalance === 0n) {
956
+ log.info("Dust balance is 0. Attempting to sync dust wallet...");
957
+ try {
958
+ dustBalance = await waitForDustFunds(walletResult.wallet, { timeoutMs: syncTimeoutMs });
959
+ } catch(_e) {
960
+ log.warn("Dust sync timed out or returned no funds.");
961
+ }
962
+ }
963
+
964
+ if (dustBalance === 0n && unshieldedBalance > 0n) {
965
+ log.info("Dust is 0 but unshielded funds available. Registering for dust generation...");
966
+ const success = await registerNightForDust(walletResult);
967
+ if (success) {
968
+ dustBalance = await waitForDustFunds(walletResult.wallet, { timeoutMs: 30000 });
969
+ }
970
+ }
971
+ } catch (_e) {
972
+ // ignore
973
+ }
974
+ }
975
+
976
+ log.info("==========================================");
977
+ log.info("Balances");
978
+ log.info("==========================================");
979
+ log.info(`Shielded Balance: ${shieldedBalance} NIGHT`);
980
+ log.info(`Dust Balance: ${dustBalance} DUST`);
981
+ log.info(`Unshielded Balance: ${unshieldedBalance} NIGHT`);
982
+ }
983
+
984
+ log.info("==========================================");
985
+
986
+ await walletResult.wallet.stop();
987
+ } catch (error) {
988
+ log.error(`Error: ${error instanceof Error ? error.message : String(error)}`);
989
+ exit(1);
990
+ }
991
+ }
992
+
993
+ if (import.meta.main) {
994
+ main();
995
+ }