pivx-wallet 0.1.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.
package/dist/wallet.js ADDED
@@ -0,0 +1,662 @@
1
+ import { RpcError } from 'pivx-rpc';
2
+ import { loadShield } from './shield-bindings.js';
3
+ /** PIVX BIP44 coin types. */
4
+ const COIN_TYPE = { mainnet: 119, testnet: 1 };
5
+ /** Heights at/after which a block must carry a sapling root. Below these,
6
+ * the node legitimately has none. */
7
+ const SAPLING_ACTIVATION = { mainnet: 2_700_000, testnet: 43_200 };
8
+ /** A note worth no more than its own input fee (sapling input 384 bytes ×
9
+ * 1000 sats/byte) never helps cover amount+fee, so it is never spent. */
10
+ const DUST_NOTE_SATS = 384_000;
11
+ /**
12
+ * Mirror of the Rust/WASM note selection and fee model: consume notes
13
+ * smallest-first, growing the fee per sapling input, and report whether the
14
+ * inputs cover amount + fee. Used to refuse a send that would otherwise have
15
+ * the fee silently taken from the recipient.
16
+ */
17
+ function estimateShieldSelection(notes, amount, toIsShield) {
18
+ const tOut = toIsShield ? 0 : 1;
19
+ const feeFor = (sIn) => 1000 * (2 * 948 + sIn * 384 + tOut * 34 + 85);
20
+ const sorted = [...notes].sort((a, b) => a.note.value - b.note.value);
21
+ let total = 0;
22
+ let sIn = 0;
23
+ let fee = feeFor(0);
24
+ for (const n of sorted) {
25
+ sIn++;
26
+ fee = feeFor(sIn);
27
+ total += n.note.value;
28
+ if (total >= amount + fee)
29
+ break;
30
+ }
31
+ return { fee, sufficient: total >= amount + fee };
32
+ }
33
+ /**
34
+ * Same model for the transparent-input (shielding) path: consume UTXOs
35
+ * smallest-first with a per-transparent-input fee and a transparent change
36
+ * output, and report whether they cover amount + fee. The WASM's utxo
37
+ * selection has the same silent-underpay behavior as its note path.
38
+ */
39
+ function estimateTransparentSelection(utxos, amount, toIsShield) {
40
+ // Recipient (shield → 0 transparent outputs, else 1) plus a transparent change output.
41
+ const tOut = (toIsShield ? 0 : 1) + 1;
42
+ const feeFor = (tIn) => 1000 * (2 * 948 + tIn * 150 + tOut * 34 + 85);
43
+ const sorted = [...utxos].sort((a, b) => a.amount - b.amount);
44
+ let total = 0;
45
+ let tIn = 0;
46
+ let fee = feeFor(0);
47
+ for (const u of sorted) {
48
+ tIn++;
49
+ fee = feeFor(tIn);
50
+ total += u.amount;
51
+ if (total >= amount + fee)
52
+ break;
53
+ }
54
+ return { fee, sufficient: total >= amount + fee };
55
+ }
56
+ /** Thrown when a watch-only wallet is asked to spend. */
57
+ export class NoSpendAuthorityError extends Error {
58
+ constructor() {
59
+ super('wallet is watch-only (viewing key): load a spending key to spend');
60
+ this.name = 'NoSpendAuthorityError';
61
+ }
62
+ }
63
+ /** Thrown when the local commitment tree diverges from the node's sapling root. */
64
+ export class ScanDivergedError extends Error {
65
+ height;
66
+ localRoot;
67
+ nodeRoot;
68
+ constructor(height, localRoot, nodeRoot) {
69
+ super(`sapling root mismatch at height ${height}: wallet state is corrupt or the node is on another chain; ` +
70
+ 'recreate the wallet from its keys and resync');
71
+ this.height = height;
72
+ this.localRoot = localRoot;
73
+ this.nodeRoot = nodeRoot;
74
+ this.name = 'ScanDivergedError';
75
+ }
76
+ }
77
+ /** Reverse hex byte order (node displays sapling roots like txids, byte-reversed). */
78
+ const reverseHex = (hex) => (hex.match(/../g) ?? []).reverse().join('');
79
+ /** Guard the money path against non-numeric note values from tampered state. */
80
+ const assertSats = (v) => {
81
+ if (!Number.isSafeInteger(v))
82
+ throw new Error(`note value is not a valid satoshi amount: ${v}`);
83
+ return v;
84
+ };
85
+ // Only sapling-capable (version 3) transactions carry shield outputs. Feeding
86
+ // anything else to the scanner is wasted work, and the parser rejects some
87
+ // legacy transactions outright. PIVX sapling txs serialize with a 4-byte
88
+ // little-endian version of 3, so the hex starts with "03". This assumes
89
+ // version 3 is the only shielded version; revisit if that ever changes.
90
+ const isSaplingTx = (hex) => hex.startsWith('03');
91
+ /**
92
+ * Standalone PIVX wallet: owns keys, scans blocks, tracks shielded notes,
93
+ * and builds fully-proved transactions locally. A node (via `pivx-rpc`) is
94
+ * only used as a chain-data source and broadcast endpoint.
95
+ *
96
+ * Capabilities follow the key material: constructed from a seed or spending
97
+ * key the wallet can spend; from a viewing key it can scan, derive receive
98
+ * addresses, and track balance (watch-only) — and can be upgraded in place
99
+ * with {@link loadSpendingKey}.
100
+ */
101
+ export class PivxWallet {
102
+ shield;
103
+ network;
104
+ extfvk;
105
+ commitmentTree;
106
+ lastProcessedBlock;
107
+ extsk;
108
+ notes = [];
109
+ nullifierMap = new Map();
110
+ /** txid → nullifiers awaiting broadcast confirmation. Persisted, so a
111
+ * crash between broadcast and finalize can't resurrect spent notes. */
112
+ pendingSpends = new Map();
113
+ diversifierIndex;
114
+ /** One writer at a time: block state-mutating operations from racing. */
115
+ busy = false;
116
+ /** Whether the starting checkpoint has been confirmed against the node. */
117
+ startValidated = false;
118
+ constructor(shield, network, extfvk, commitmentTree, lastProcessedBlock, diversifierIndex, extsk) {
119
+ this.shield = shield;
120
+ this.network = network;
121
+ this.extfvk = extfvk;
122
+ this.commitmentTree = commitmentTree;
123
+ this.lastProcessedBlock = lastProcessedBlock;
124
+ this.extsk = extsk;
125
+ this.diversifierIndex = diversifierIndex;
126
+ }
127
+ get isTestnet() {
128
+ return this.network === 'testnet';
129
+ }
130
+ /** True when the wallet holds spend authority. */
131
+ get canSpend() {
132
+ return this.extsk !== undefined;
133
+ }
134
+ static async create(opts) {
135
+ const network = opts.network ?? 'mainnet';
136
+ const provided = [opts.seed, opts.spendingKey, opts.viewingKey].filter((k) => k !== undefined);
137
+ if (provided.length !== 1) {
138
+ throw new Error('provide exactly one of: seed, spendingKey, viewingKey');
139
+ }
140
+ const shield = await loadShield(opts.proving);
141
+ const isTestnet = network === 'testnet';
142
+ let extsk;
143
+ if (opts.seed) {
144
+ if (opts.seed.length !== 32)
145
+ throw new Error('seed must be 32 bytes');
146
+ extsk = shield.generate_extended_spending_key_from_seed({
147
+ seed: Array.from(opts.seed),
148
+ coin_type: COIN_TYPE[network],
149
+ account_index: opts.accountIndex ?? 0,
150
+ });
151
+ }
152
+ else if (opts.spendingKey) {
153
+ extsk = opts.spendingKey;
154
+ }
155
+ const extfvk = extsk
156
+ ? shield.generate_extended_full_viewing_key(extsk, isTestnet)
157
+ : opts.viewingKey;
158
+ // Resume from the checkpoint's own height, not birthHeight: the loaded
159
+ // tree is the committed state AT the checkpoint, so scanning must start
160
+ // at checkpointHeight + 1. Starting higher would leave the tree missing
161
+ // every shield output in the gap and diverge on the first real block.
162
+ const [checkpointHeight, checkpointTree] = shield.get_closest_checkpoint(opts.birthHeight, isTestnet);
163
+ const { diversifier_index } = shield.generate_default_payment_address(extfvk, isTestnet);
164
+ return new PivxWallet(shield, network, extfvk, checkpointTree, checkpointHeight, diversifier_index, extsk);
165
+ }
166
+ /** Upgrade a watch-only wallet. The key must match the stored viewing key. */
167
+ loadSpendingKey(spendingKey) {
168
+ if (this.extsk)
169
+ throw new Error('wallet already has a spending key');
170
+ const derived = this.shield.generate_extended_full_viewing_key(spendingKey, this.isTestnet);
171
+ if (derived !== this.extfvk) {
172
+ throw new Error('spending key does not match this wallet\'s viewing key');
173
+ }
174
+ this.extsk = spendingKey;
175
+ }
176
+ // ── Addresses & balance ───────────────────────────────────────────────────
177
+ /** Next diversified shield receive address. */
178
+ getNewAddress() {
179
+ const { address, diversifier_index } = this.shield.generate_next_shielding_payment_address(this.extfvk, new Uint8Array(this.diversifierIndex), this.isTestnet);
180
+ this.diversifierIndex = diversifier_index;
181
+ return address;
182
+ }
183
+ /** Confirmed shielded balance in satoshis (scanned notes, minus pending spends). */
184
+ getBalance() {
185
+ const pending = new Set([...this.pendingSpends.values()].flat());
186
+ return this.notes
187
+ .filter((n) => !pending.has(n.nullifier))
188
+ .reduce((sum, n) => sum + assertSats(n.note.value), 0);
189
+ }
190
+ /** Whether `address` is a shield (Sapling) address on this wallet's network. */
191
+ isShieldAddress(address) {
192
+ return address.startsWith(this.isTestnet ? 'ptestsapling1' : 'ps1');
193
+ }
194
+ /** Currently tracked unspent notes. */
195
+ getNotes() {
196
+ return this.notes;
197
+ }
198
+ getLastSyncedBlock() {
199
+ return this.lastProcessedBlock;
200
+ }
201
+ /** Look up a note by its on-chain nullifier (payment attribution for spends). */
202
+ getNoteFromNullifier(nullifier) {
203
+ return this.nullifierMap.get(nullifier);
204
+ }
205
+ // ── Scanning ──────────────────────────────────────────────────────────────
206
+ /**
207
+ * Scan blocks (strictly ascending heights, all above the last synced
208
+ * block). Returns the raw hexes of transactions relevant to this wallet.
209
+ * Use this directly when you have your own block feed; otherwise see
210
+ * {@link sync}.
211
+ */
212
+ handleBlocks(blocks) {
213
+ if (this.busy)
214
+ throw new Error('wallet is busy: another sync or spend is in progress');
215
+ return this.applyBlocks(blocks);
216
+ }
217
+ /** handleBlocks without the busy guard, for internal use by sync (which
218
+ * already holds the guard). */
219
+ applyBlocks(blocks) {
220
+ let prev = this.lastProcessedBlock;
221
+ for (const b of blocks) {
222
+ if (b.height <= prev) {
223
+ throw new Error(`blocks must be strictly ascending and above ${this.lastProcessedBlock}`);
224
+ }
225
+ prev = b.height;
226
+ }
227
+ if (blocks.length === 0)
228
+ return [];
229
+ const result = this.shield.handle_blocks(this.commitmentTree, blocks.map((b) => ({ txs: b.txs.map((t) => t.hex).filter(isSaplingTx) })), this.extfvk, this.isTestnet, this.notes);
230
+ this.commitmentTree = result.commitment_tree;
231
+ const spent = new Set(result.nullifiers);
232
+ // Do not retain sub-dust notes: they are never spendable (below their own
233
+ // input fee), so keeping them would let a dust flood grow state and
234
+ // per-block scan cost without bound. Their commitment is still in the tree
235
+ // (appended for every output during the scan), so the root stays correct.
236
+ this.notes = [...result.decrypted_notes, ...result.decrypted_new_notes].filter((n) => !spent.has(n.nullifier) && n.note.value > DUST_NOTE_SATS);
237
+ for (const { note, nullifier } of result.decrypted_new_notes) {
238
+ if (note.value > DUST_NOTE_SATS) {
239
+ this.nullifierMap.set(nullifier, {
240
+ recipient: this.encodeRecipient(note.recipient),
241
+ value: note.value,
242
+ });
243
+ }
244
+ }
245
+ // Drop pending-spend entries whose notes are now gone (the transaction
246
+ // confirmed and its notes were scanned out), so pendingSpends can't leak.
247
+ const tracked = new Set(this.notes.map((n) => n.nullifier));
248
+ for (const [txid, nulls] of this.pendingSpends) {
249
+ if (!nulls.some((n) => tracked.has(n)))
250
+ this.pendingSpends.delete(txid);
251
+ }
252
+ this.lastProcessedBlock = blocks[blocks.length - 1].height;
253
+ return result.wallet_transactions;
254
+ }
255
+ /**
256
+ * Decrypt a single transaction's outputs for this wallet without touching
257
+ * wallet state — a hint for 0-conf payment detection from the mempool.
258
+ *
259
+ * This only trial-decrypts; it does NOT validate the transaction (proof,
260
+ * double-spend, or whether it will ever confirm). Do not credit funds
261
+ * from a preview: dedupe on the caller's own txid and credit only from
262
+ * confirmed notes returned by {@link getNotes} after {@link sync}.
263
+ */
264
+ previewTransaction(hex) {
265
+ if (!isSaplingTx(hex))
266
+ return []; // non-sapling tx has no shield outputs (and would panic the scanner)
267
+ const result = this.shield.handle_blocks(this.commitmentTree, [{ txs: [hex] }], this.extfvk, this.isTestnet, []);
268
+ return result.decrypted_new_notes.map(({ note, memo }) => ({
269
+ recipient: this.encodeRecipient(note.recipient),
270
+ value: note.value,
271
+ memo,
272
+ }));
273
+ }
274
+ /**
275
+ * Sync from the node up to its current tip.
276
+ *
277
+ * Each batch checks the locally-built tree against the node's own
278
+ * `finalsaplingroot`. That catches malformed or mis-ordered data from the
279
+ * node, but it is a self-consistency check, not chain authentication: the
280
+ * SDK does not validate proof-of-stake, so a dishonest node can still serve
281
+ * a self-consistent fabricated chain. Point this at a node you trust. See
282
+ * SECURITY.md.
283
+ */
284
+ async sync(client, opts = {}) {
285
+ if (this.busy)
286
+ throw new Error('wallet is busy: another sync or spend is in progress');
287
+ this.busy = true;
288
+ try {
289
+ // NaN/0/fractional → sane integer; 0 would loop forever.
290
+ const batchSize = Math.max(1, Math.floor(opts.batchSize ?? 100) || 1);
291
+ // getblock verbosity 2 is heavy. A default node has 4 RPC threads and a
292
+ // work queue of 16, so firing a whole batch at once gets 500s. Keep the
293
+ // concurrent fetches well under that.
294
+ const concurrency = Math.max(1, opts.rpcConcurrency ?? 8);
295
+ const tip = await client.getBlockCount();
296
+ await this.ensureValidCheckpoint(client);
297
+ const fetchBlock = async (h) => {
298
+ const hash = await client.getBlockHash(h);
299
+ const block = (await client.getBlock(hash, 2));
300
+ // Trust the height we asked for, not the one the node echoes, and
301
+ // reject a mismatch outright — otherwise a lying node can
302
+ // fast-forward lastProcessedBlock past real deposits.
303
+ if (block.height !== h) {
304
+ throw new Error(`node returned block height ${block.height} for requested height ${h}`);
305
+ }
306
+ return block;
307
+ };
308
+ while (this.lastProcessedBlock < tip) {
309
+ const from = this.lastProcessedBlock + 1;
310
+ const to = Math.min(from + batchSize - 1, tip);
311
+ const heights = Array.from({ length: to - from + 1 }, (_, i) => from + i);
312
+ const blocks = [];
313
+ for (let i = 0; i < heights.length; i += concurrency) {
314
+ blocks.push(...(await Promise.all(heights.slice(i, i + concurrency).map(fetchBlock))));
315
+ }
316
+ // Snapshot so a failed root check can't leave partial state behind.
317
+ // Includes pendingSpends because applyBlocks reconciles it.
318
+ const snapshot = {
319
+ tree: this.commitmentTree,
320
+ last: this.lastProcessedBlock,
321
+ notes: this.notes,
322
+ nmap: new Map(this.nullifierMap),
323
+ pending: new Map(this.pendingSpends),
324
+ };
325
+ try {
326
+ this.applyBlocks(heights.map((h, i) => ({
327
+ height: h,
328
+ txs: blocks[i].tx.map(({ hex, txid }) => ({ hex, txid })),
329
+ })));
330
+ const nodeRoot = blocks[blocks.length - 1].finalsaplingroot;
331
+ // A shielded chain always has a sapling root; a missing one means
332
+ // the node is pre-activation or lying. Either way, refuse to
333
+ // advance unverified.
334
+ if (!nodeRoot)
335
+ throw new Error(`node omitted finalsaplingroot at height ${to}`);
336
+ const localRoot = reverseHex(this.shield.get_sapling_root(this.commitmentTree));
337
+ if (localRoot !== nodeRoot)
338
+ throw new ScanDivergedError(to, localRoot, nodeRoot);
339
+ }
340
+ catch (err) {
341
+ this.commitmentTree = snapshot.tree;
342
+ this.lastProcessedBlock = snapshot.last;
343
+ this.notes = snapshot.notes;
344
+ this.nullifierMap = snapshot.nmap;
345
+ this.pendingSpends = snapshot.pending;
346
+ throw err;
347
+ }
348
+ opts.onProgress?.(to, tip);
349
+ }
350
+ }
351
+ finally {
352
+ this.busy = false;
353
+ }
354
+ }
355
+ /**
356
+ * Confirm the starting commitment tree against the node before scanning
357
+ * forward. A fresh wallet begins at a bundled checkpoint; if that
358
+ * checkpoint's tree does not match the node's sapling root at that height
359
+ * (some near-tip checkpoints in the shield library are captured on stale
360
+ * blocks), walk back to the newest checkpoint the node does confirm. A
361
+ * wallet that already holds scanned notes and no longer matches is treated
362
+ * as diverged rather than silently rewound.
363
+ */
364
+ async ensureValidCheckpoint(client) {
365
+ if (this.startValidated)
366
+ return;
367
+ const activation = SAPLING_ACTIVATION[this.network];
368
+ // Sapling root at height h. Above activation the node must report one;
369
+ // treating an omitted root as "no root" would let a node suppress this
370
+ // check or force an all-the-way rewind by simply withholding the field.
371
+ const rootAt = async (h) => {
372
+ if (h < activation)
373
+ return null;
374
+ const block = (await client.getBlock(await client.getBlockHash(h), 1));
375
+ if (!block.finalsaplingroot) {
376
+ throw new Error(`node omitted finalsaplingroot at height ${h} (past sapling activation)`);
377
+ }
378
+ return block.finalsaplingroot;
379
+ };
380
+ const localRoot = () => reverseHex(this.shield.get_sapling_root(this.commitmentTree));
381
+ const node = await rootAt(this.lastProcessedBlock);
382
+ if (node === null || localRoot() === node) {
383
+ this.startValidated = true;
384
+ return;
385
+ }
386
+ // A rewind is only appropriate for a fresh wallet still sitting on a
387
+ // bundled checkpoint. A wallet that has scanned forward (past a
388
+ // checkpoint, or holding notes) and no longer matches is diverged —
389
+ // rewinding would silently discard correct progress.
390
+ const [nearest] = this.shield.get_closest_checkpoint(this.lastProcessedBlock, this.isTestnet);
391
+ const atCheckpoint = nearest === this.lastProcessedBlock;
392
+ if (this.notes.length > 0 || this.pendingSpends.size > 0 || !atCheckpoint) {
393
+ throw new ScanDivergedError(this.lastProcessedBlock, localRoot(), node);
394
+ }
395
+ let probe = this.lastProcessedBlock - 1;
396
+ let lastCp = this.lastProcessedBlock;
397
+ let adopted = false;
398
+ while (probe > 0) {
399
+ const [cpHeight, cpTree] = this.shield.get_closest_checkpoint(probe, this.isTestnet);
400
+ if (cpHeight >= lastCp)
401
+ break; // no older checkpoint available
402
+ lastCp = cpHeight;
403
+ const nodeRoot = await rootAt(cpHeight);
404
+ const cpRoot = reverseHex(this.shield.get_sapling_root(cpTree));
405
+ if (nodeRoot === null || cpRoot === nodeRoot) {
406
+ this.commitmentTree = cpTree;
407
+ this.lastProcessedBlock = cpHeight;
408
+ adopted = true;
409
+ break;
410
+ }
411
+ probe = cpHeight - 1;
412
+ }
413
+ // No bundled checkpoint matched the node: do not proceed on an unconfirmed
414
+ // tree. Surface it rather than silently "validating".
415
+ if (!adopted)
416
+ throw new ScanDivergedError(this.lastProcessedBlock, localRoot(), node);
417
+ this.startValidated = true;
418
+ }
419
+ /**
420
+ * Reset scan state to the checkpoint at or below `height` and drop all
421
+ * tracked notes. This is the recovery path after a divergence error: call
422
+ * it, then sync again. It needs no keys.
423
+ */
424
+ reloadFromCheckpoint(height) {
425
+ if (this.busy)
426
+ throw new Error('wallet is busy');
427
+ const [cpHeight, cpTree] = this.shield.get_closest_checkpoint(height, this.isTestnet);
428
+ this.commitmentTree = cpTree;
429
+ this.lastProcessedBlock = cpHeight;
430
+ this.notes = [];
431
+ this.nullifierMap = new Map();
432
+ this.pendingSpends = new Map();
433
+ this.startValidated = false; // re-confirm the checkpoint on the next sync
434
+ }
435
+ // ── Spending ──────────────────────────────────────────────────────────────
436
+ /** Load sapling proving parameters (required once before building transactions). */
437
+ async loadProver(source) {
438
+ let ok;
439
+ if ('path' in source) {
440
+ const { readFile } = await import('node:fs/promises');
441
+ const { join } = await import('node:path');
442
+ const [output, spend] = await Promise.all([
443
+ readFile(join(source.path, 'sapling-output.params')),
444
+ readFile(join(source.path, 'sapling-spend.params')),
445
+ ]);
446
+ ok = await this.shield.load_prover_with_bytes(output, spend);
447
+ }
448
+ else if ('url' in source) {
449
+ ok = await this.shield.load_prover_with_url(source.url);
450
+ }
451
+ else {
452
+ ok = await this.shield.load_prover_with_bytes(source.output, source.spend);
453
+ }
454
+ if (!ok)
455
+ throw new Error('failed to load sapling proving parameters');
456
+ }
457
+ /**
458
+ * Build and prove a transaction locally. Nothing is broadcast; the spent
459
+ * notes are held as pending until {@link finalizeTransaction} or
460
+ * {@link discardTransaction}.
461
+ */
462
+ async createTransaction(opts) {
463
+ if (!this.extsk)
464
+ throw new NoSpendAuthorityError();
465
+ if (!Number.isSafeInteger(opts.amount) || opts.amount <= 0) {
466
+ throw new Error('amount must be a positive integer number of satoshis');
467
+ }
468
+ if (opts.memo !== undefined && new TextEncoder().encode(opts.memo).length > 512) {
469
+ throw new Error('memo must be at most 512 bytes');
470
+ }
471
+ const useShield = opts.inputs === undefined || opts.inputs === 'shield';
472
+ if (!useShield && !opts.transparentChangeAddress) {
473
+ throw new Error('transparentChangeAddress is required when spending transparent inputs');
474
+ }
475
+ // Validate transparent inputs. Amounts are satoshis here; a caller wiring
476
+ // pivx-rpc's PIV-float listUnspent straight in (a natural mistake) would
477
+ // otherwise donate the difference to fees.
478
+ if (!useShield) {
479
+ for (const u of opts.inputs) {
480
+ if (!Number.isSafeInteger(u.amount) || u.amount < 0) {
481
+ throw new Error('transparent input amount must be a non-negative integer (satoshis)');
482
+ }
483
+ }
484
+ }
485
+ // Spendable notes, minus pending spends and dust. Dust notes (worth no
486
+ // more than their own input fee) can never help cover amount+fee and only
487
+ // let an attacker inflate the fee, so they are excluded from spending.
488
+ const pending = new Set([...this.pendingSpends.values()].flat());
489
+ const spendable = this.notes.filter((n) => !pending.has(n.nullifier) && n.note.value > DUST_NOTE_SATS);
490
+ // Refuse a send the inputs can't cover including the fee, unless the
491
+ // caller opts into sweep. Both branches mirror the Rust selection so the
492
+ // WASM can't silently pay the fee out of the recipient's amount — the
493
+ // WASM shares the same fee model but has no such guard.
494
+ const sufficient = useShield
495
+ ? estimateShieldSelection(spendable, opts.amount, this.isShieldAddress(opts.to)).sufficient
496
+ : estimateTransparentSelection(opts.inputs, opts.amount, this.isShieldAddress(opts.to)).sufficient;
497
+ if (!sufficient && !opts.sweep) {
498
+ throw new Error('insufficient input value to cover amount + fee; lower the amount, ' +
499
+ 'add inputs, or pass sweep:true to deduct the fee from the recipient');
500
+ }
501
+ // Prover is only needed to build; check it after the cheap validations
502
+ // so callers get input errors without loading ~50MB of parameters.
503
+ if (!(await this.shield.prover_is_loaded())) {
504
+ throw new Error('sapling prover not loaded: call loadProver() first');
505
+ }
506
+ if (this.busy)
507
+ throw new Error('wallet is busy: another sync or spend is in progress');
508
+ this.busy = true;
509
+ try {
510
+ const changeAddress = useShield ? this.getNewAddress() : opts.transparentChangeAddress;
511
+ const result = (await this.shield.create_transaction({
512
+ notes: useShield ? spendable : null,
513
+ utxos: useShield ? null : opts.inputs,
514
+ extsk: this.extsk,
515
+ to_address: opts.to,
516
+ change_address: changeAddress,
517
+ amount: opts.amount,
518
+ block_height: this.lastProcessedBlock + 1,
519
+ is_testnet: this.isTestnet,
520
+ memo: opts.memo ?? '',
521
+ }));
522
+ if (useShield)
523
+ this.pendingSpends.set(result.txid, result.nullifiers);
524
+ return { txid: result.txid, hex: result.txhex, nullifiers: result.nullifiers };
525
+ }
526
+ finally {
527
+ this.busy = false;
528
+ }
529
+ }
530
+ /** Build, broadcast, and finalize in one step. */
531
+ async send(client, opts) {
532
+ const tx = await this.createTransaction(opts);
533
+ try {
534
+ const txid = await client.sendRawTransaction(tx.hex);
535
+ this.finalizeTransaction(tx.txid);
536
+ return txid;
537
+ }
538
+ catch (err) {
539
+ // Only release the notes when the node definitively rejected the
540
+ // transaction. On a transport/timeout error the node may have accepted
541
+ // it, so keep the spend pending — discarding here could let a retry (or
542
+ // an operator reacting to a false "failed") double-spend or double-pay.
543
+ // Recover per docs/deployment.md: wait for the txid to confirm or
544
+ // clearly disappear, then resume.
545
+ if (err instanceof RpcError) {
546
+ this.discardTransaction(tx.txid);
547
+ }
548
+ else if (err && typeof err === 'object') {
549
+ // Ambiguous failure: the notes stay pending. Attach the txid so the
550
+ // operator can reconcile (confirm on-chain, then finalize/discard).
551
+ err.txid = tx.txid;
552
+ }
553
+ throw err;
554
+ }
555
+ }
556
+ /** Mark a broadcast transaction's notes as spent. */
557
+ finalizeTransaction(txid) {
558
+ const nullifiers = this.pendingSpends.get(txid);
559
+ if (!nullifiers)
560
+ return;
561
+ const spent = new Set(nullifiers);
562
+ this.notes = this.notes.filter((n) => !spent.has(n.nullifier));
563
+ this.pendingSpends.delete(txid);
564
+ }
565
+ /** Release a failed transaction's notes back to the spendable set. */
566
+ discardTransaction(txid) {
567
+ this.pendingSpends.delete(txid);
568
+ }
569
+ /**
570
+ * Transactions built and broadcast but not yet finalized or discarded
571
+ * (txid → the nullifiers they spend). After a broadcast error left a spend
572
+ * ambiguous, use this to find the txid, confirm it on-chain, then
573
+ * {@link finalizeTransaction} or {@link discardTransaction}.
574
+ */
575
+ pendingTransactions() {
576
+ return Object.fromEntries(this.pendingSpends);
577
+ }
578
+ // ── Persistence ───────────────────────────────────────────────────────────
579
+ /**
580
+ * Serialize wallet state to JSON. The spending key is deliberately
581
+ * excluded — persist it separately (encrypted) and restore with
582
+ * {@link loadSpendingKey}.
583
+ */
584
+ save() {
585
+ const state = {
586
+ version: 1,
587
+ network: this.network,
588
+ extfvk: this.extfvk,
589
+ lastProcessedBlock: this.lastProcessedBlock,
590
+ commitmentTree: this.commitmentTree,
591
+ diversifierIndex: this.diversifierIndex,
592
+ notes: this.notes,
593
+ nullifierMap: Object.fromEntries(this.nullifierMap),
594
+ pendingSpends: Object.fromEntries(this.pendingSpends),
595
+ };
596
+ return JSON.stringify(state);
597
+ }
598
+ /**
599
+ * Restore a wallet from {@link save} output.
600
+ *
601
+ * For a watch-only deposit scanner, pass `opts.expectedViewingKey` (the key
602
+ * you know this wallet should have): a tampered state file that swapped in
603
+ * an attacker's viewing key would otherwise silently repoint deposit
604
+ * addresses to the attacker. Saved-state integrity is theft-critical here.
605
+ */
606
+ static async load(json, opts = {}) {
607
+ let state;
608
+ try {
609
+ state = JSON.parse(json);
610
+ }
611
+ catch {
612
+ throw new Error('wallet state is not valid JSON');
613
+ }
614
+ if (state === null || typeof state !== 'object')
615
+ throw new Error('wallet state is not an object');
616
+ if (state.version !== 1)
617
+ throw new Error(`unsupported wallet state version ${state.version}`);
618
+ if (state.network !== 'mainnet' && state.network !== 'testnet') {
619
+ throw new Error('wallet state has an invalid network');
620
+ }
621
+ if (typeof state.extfvk !== 'string' || typeof state.commitmentTree !== 'string') {
622
+ throw new Error('wallet state is missing keys or commitment tree');
623
+ }
624
+ if (opts.expectedViewingKey !== undefined && opts.expectedViewingKey !== state.extfvk) {
625
+ throw new Error('wallet state viewing key does not match the expected key');
626
+ }
627
+ if (!Number.isSafeInteger(state.lastProcessedBlock) || !Array.isArray(state.notes)) {
628
+ throw new Error('wallet state has an invalid sync position or notes');
629
+ }
630
+ for (const n of state.notes) {
631
+ if (typeof n?.nullifier !== 'string' || !Number.isSafeInteger(n?.note?.value) || n.note.value < 0) {
632
+ throw new Error('wallet state contains a malformed note');
633
+ }
634
+ }
635
+ if (!Array.isArray(state.diversifierIndex) ||
636
+ state.diversifierIndex.length !== 11 ||
637
+ !state.diversifierIndex.every((b) => Number.isInteger(b) && b >= 0 && b <= 255)) {
638
+ throw new Error('wallet state has an invalid diversifier index');
639
+ }
640
+ if (state.pendingSpends !== undefined && (typeof state.pendingSpends !== 'object' || state.pendingSpends === null)) {
641
+ throw new Error('wallet state has an invalid pending-spends map');
642
+ }
643
+ const shield = await loadShield(opts.proving);
644
+ // Confirm the viewing key decodes for this network before trusting it to
645
+ // derive receive addresses (a tampered state could otherwise repoint
646
+ // deposits to an attacker's key).
647
+ try {
648
+ shield.generate_default_payment_address(state.extfvk, state.network === 'testnet');
649
+ }
650
+ catch {
651
+ throw new Error('wallet state has an invalid viewing key for its network');
652
+ }
653
+ const wallet = new PivxWallet(shield, state.network, state.extfvk, state.commitmentTree, state.lastProcessedBlock, state.diversifierIndex);
654
+ wallet.notes = state.notes;
655
+ wallet.nullifierMap = new Map(Object.entries(state.nullifierMap ?? {}));
656
+ wallet.pendingSpends = new Map(Object.entries(state.pendingSpends ?? {}));
657
+ return wallet;
658
+ }
659
+ encodeRecipient(recipient) {
660
+ return this.shield.encode_payment_address(this.isTestnet, new Uint8Array(recipient));
661
+ }
662
+ }