pivx-wallet 0.3.3 → 0.3.5

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.
@@ -39,6 +39,7 @@ export declare class TransparentWallet {
39
39
  private utxos;
40
40
  private lastScanned;
41
41
  private lastScannedHash;
42
+ private scannedHashes;
42
43
  private pending;
43
44
  /** One sync at a time (mirrors the shield wallet's busy guard). */
44
45
  private busy;
@@ -14,6 +14,14 @@ const isHex = (s) => /^(?:[0-9a-fA-F]{2})+$/.test(s);
14
14
  const dustThreshold = (scriptLen) => Math.floor((30_000 * (8 + 1 + scriptLen + 148)) / 1000);
15
15
  /** Coinbase/coinstake maturity in blocks (nCoinbaseMaturity): mainnet 100, testnet 15. */
16
16
  const coinbaseMaturity = (network) => (network === 'mainnet' ? 100 : 15);
17
+ /**
18
+ * Depth of the rolling (height, hash) window kept for reorg recovery. On a
19
+ * detected same-height tip reorg, sync walks this window newest→oldest to find
20
+ * the true fork and resets there; a reorg deeper than the window cannot be
21
+ * located safely and fails loud instead of silently retaining orphaned UTXOs.
22
+ * Identical in the Rust SDK.
23
+ */
24
+ const REORG_WINDOW = 100;
17
25
  /**
18
26
  * hash160 of a scriptPubKey we know how to spend, if it is one: a standard
19
27
  * 25-byte P2PKH (76a914<20>88ac) or the 26-byte exchange form with an
@@ -43,6 +51,7 @@ export class TransparentWallet {
43
51
  utxos = new Map(); // "txid:vout" → utxo
44
52
  lastScanned = 0; // height of the last block passed to scanBlock
45
53
  lastScannedHash = null; // hash of that block, for reorg detection
54
+ scannedHashes = []; // rolling window of recent (height, hash) for the reorg walk-back
46
55
  pending = new Set(); // "txid:vout" reserved by buildSend until markSpent/release
47
56
  /** One sync at a time (mirrors the shield wallet's busy guard). */
48
57
  busy = false;
@@ -175,6 +184,14 @@ export class TransparentWallet {
175
184
  }
176
185
  }
177
186
  this.lastScannedHash = typeof block.hash === 'string' ? block.hash : null;
187
+ // Record this block into the rolling reorg window (same usable-hash guard as
188
+ // lastScannedHash), keeping only the last REORG_WINDOW entries.
189
+ if (typeof block.hash === 'string') {
190
+ this.scannedHashes.push({ height, hash: block.hash });
191
+ if (this.scannedHashes.length > REORG_WINDOW) {
192
+ this.scannedHashes.splice(0, this.scannedHashes.length - REORG_WINDOW);
193
+ }
194
+ }
178
195
  }
179
196
  /** Height of the last block passed to {@link scanBlock} (0 if none). */
180
197
  lastScannedBlock() {
@@ -193,8 +210,13 @@ export class TransparentWallet {
193
210
  this.pending.delete(k);
194
211
  }
195
212
  }
213
+ // Trim the reorg window to the retained span; if the reset height is itself a
214
+ // known window entry (a true fork), restore its hash so scan continuity is
215
+ // preserved — otherwise (a manual reset to an unknown height) leave it null.
216
+ this.scannedHashes = this.scannedHashes.filter((e) => e.height <= height);
217
+ const retained = this.scannedHashes.find((e) => e.height === height);
196
218
  this.lastScanned = height;
197
- this.lastScannedHash = null;
219
+ this.lastScannedHash = retained ? retained.hash : null;
198
220
  }
199
221
  /**
200
222
  * Sync from the node into the wallet, from `max(fromHeight, lastScanned+1)`
@@ -218,6 +240,36 @@ export class TransparentWallet {
218
240
  };
219
241
  const concurrency = 8;
220
242
  const tip = await client.getBlockCount();
243
+ // Stale-tip reorg detection: the forward scan only checks parent-hash
244
+ // continuity for blocks above lastScanned, so a same-height reorg (block N
245
+ // replaced while the tip stays N) leaves lastScanned == tip and is missed.
246
+ // Any reorg at/below lastScanned changes that block's hash, so re-verify
247
+ // the node's current hash for it. On a mismatch, walk the recorded window
248
+ // newest→oldest to find the true fork (the highest stored height whose
249
+ // hash the node still confirms) and reset there; the UTXO model self-heals
250
+ // (resetScan drops UTXOs above the fork and the re-scan re-credits the
251
+ // survivors). If the reorg is deeper than the window we cannot locate the
252
+ // fork safely, so fail loud rather than silently retain orphaned UTXOs.
253
+ // Honest chain = 1 getBlockHash (tip matches → skip the walk).
254
+ if (this.lastScanned > 0 && this.lastScannedHash !== null) {
255
+ const nodeTip = await client.getBlockHash(this.lastScanned);
256
+ if (nodeTip !== this.lastScannedHash) {
257
+ let fork;
258
+ for (let i = this.scannedHashes.length - 1; i >= 0; i--) {
259
+ const entry = this.scannedHashes[i];
260
+ if ((await client.getBlockHash(entry.height)) === entry.hash) {
261
+ fork = entry.height;
262
+ break;
263
+ }
264
+ }
265
+ if (fork !== undefined) {
266
+ this.resetScan(fork);
267
+ }
268
+ else {
269
+ throw new ScanDivergedError(this.lastScanned, this.lastScannedHash, nodeTip);
270
+ }
271
+ }
272
+ }
221
273
  const fetchBlock = async (h) => client.getBlock(await client.getBlockHash(h), 2);
222
274
  // NaN/0/fractional → sane integer: 0 would loop forever and fractional
223
275
  // heights would skip blocks (matches Rust batch.max(1)).
@@ -274,6 +326,13 @@ export class TransparentWallet {
274
326
  * (broadcast definitively rejected) resolves them.
275
327
  */
276
328
  buildSend(to, amount, feePerByte = 100) {
329
+ // A sync running in the event-loop gap (suspended on an RPC await) may be
330
+ // mid-reorg reset; selecting/reserving a UTXO now risks spending an output
331
+ // resetScan is about to drop. Refuse until the sync releases the guard.
332
+ // (markSpent/release stay unguarded: removal-only finalization must always
333
+ // complete, or a reservation leaks.)
334
+ if (this.busy)
335
+ throw new Error('wallet is busy: a sync is in progress');
277
336
  if (!Number.isSafeInteger(amount) || amount <= 0)
278
337
  throw new Error('amount must be a positive integer (satoshis)');
279
338
  if (!Number.isInteger(feePerByte) || feePerByte <= 0)
@@ -364,6 +423,9 @@ export class TransparentWallet {
364
423
  nextChange: this.nextChange,
365
424
  lastScanned: this.lastScanned,
366
425
  lastScannedHash: this.lastScannedHash,
426
+ // Rolling reorg window (ascending by height, newest last). Emitted here —
427
+ // after lastScannedHash, before utxos — for byte parity with the Rust SDK.
428
+ scannedHashes: this.scannedHashes.map((e) => ({ height: e.height, hash: e.hash })),
367
429
  // Sorted by (txid, vout) so save() output is deterministic and
368
430
  // byte-comparable with the Rust SDK's.
369
431
  utxos: [...this.utxos.values()]
@@ -423,6 +485,10 @@ export class TransparentWallet {
423
485
  if (!Array.isArray(s.utxos) || !Array.isArray(s.pending)) {
424
486
  throw new Error('wallet state has invalid utxo or pending lists');
425
487
  }
488
+ // Backward-compatible: older states have no window → treat as empty.
489
+ if (s.scannedHashes !== undefined && !Array.isArray(s.scannedHashes)) {
490
+ throw new Error('wallet state has an invalid scanned-hash window');
491
+ }
426
492
  const w = TransparentWallet.create(seed, s.network, s.account, s.gap);
427
493
  w.nextExternal = s.nextExternal;
428
494
  w.nextChange = s.nextChange;
@@ -462,6 +528,22 @@ export class TransparentWallet {
462
528
  }
463
529
  w.pending.add(`${p.txid}:${p.vout}`);
464
530
  }
531
+ for (const e of s.scannedHashes ?? []) {
532
+ // hash is string-checked (not hex-validated), matching lastScannedHash
533
+ // and the Rust SDK so a state loads in both or neither.
534
+ if (!isCount(e?.height) || typeof e.hash !== 'string') {
535
+ throw new Error('wallet state contains a malformed scanned-hash entry');
536
+ }
537
+ w.scannedHashes.push({ height: e.height, hash: e.hash });
538
+ }
539
+ // Honest save() output is ascending, unique, ≤ REORG_WINDOW entries, all
540
+ // heights ≤ lastScanned. The reorg walk-back trusts array order and entry
541
+ // heights, so reject anything else rather than let a hostile or corrupt
542
+ // state mislead it (this also keeps duplicates out of resetScan).
543
+ if (w.scannedHashes.length > REORG_WINDOW ||
544
+ w.scannedHashes.some((e, i) => e.height > w.lastScanned || (i > 0 && e.height <= w.scannedHashes[i - 1].height))) {
545
+ throw new Error('wallet state contains an invalid scanned-hash window');
546
+ }
465
547
  return w;
466
548
  }
467
549
  }
package/dist/wallet.d.ts CHANGED
@@ -104,6 +104,15 @@ export declare class PivxWallet {
104
104
  * SECURITY.md.
105
105
  */
106
106
  sync(client: PivxClient, opts?: SyncOptions): Promise<void>;
107
+ /**
108
+ * The node's finalsaplingroot at height h, or null below the SAPLING_ACTIVATION
109
+ * threshold — below real V5_0 the node reports a zero root our non-zero empty
110
+ * tree can't match, so returning null signals "skip the check". At/above the
111
+ * threshold the node must report one; treating an omitted root as "no root"
112
+ * would let a node suppress the check or force an all-the-way rewind by simply
113
+ * withholding the field.
114
+ */
115
+ private nodeSaplingRoot;
107
116
  /**
108
117
  * Confirm the starting commitment tree against the node before scanning
109
118
  * forward. A fresh wallet begins at a bundled checkpoint; if that
package/dist/wallet.js CHANGED
@@ -2,9 +2,12 @@ import { RpcError } from 'pivx-rpc';
2
2
  import { loadShield } from './shield-bindings.js';
3
3
  /** PIVX BIP44 coin types. */
4
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 };
5
+ /** Threshold at/above which the sapling root check runs; below it we skip. These
6
+ * are the exact UPGRADE_V5_0 activation heights (inclusive) from PIVX consensus,
7
+ * so at/above them an honest node always reports a real, matchable
8
+ * finalsaplingroot, and below them (no shielded txs yet) there is nothing to
9
+ * verify. Mainnet V5_0 is 2_700_500; testnet is 201. */
10
+ const SAPLING_ACTIVATION = { mainnet: 2_700_500, testnet: 201 };
8
11
  /** A note worth no more than its own input fee (sapling input 384 bytes ×
9
12
  * 1000 sats/byte) never helps cover amount+fee, so it is never spent. */
10
13
  const DUST_NOTE_SATS = 384_000;
@@ -332,6 +335,32 @@ export class PivxWallet {
332
335
  }
333
336
  return block;
334
337
  };
338
+ // Stale-tip reorg detection: when lastProcessedBlock === tip the batch
339
+ // loop below never runs, so its per-batch root check never fires. A
340
+ // same-height reorg (block N replaced while the tip stays N) changes the
341
+ // shielded set's root but not the height. The tree can't be cheaply
342
+ // rewound, so re-verify the tip's finalsaplingroot against our local
343
+ // commitment root — the same localRoot-vs-nodeRoot check the batch loop
344
+ // does — and diverge on mismatch (caller recovers via reloadFromCheckpoint).
345
+ // Runs even at an exact checkpoint height: a fresh wallet still on its
346
+ // bundled checkpoint matches the node's finalsaplingroot there (that is
347
+ // what a checkpoint is, and ensureValidCheckpoint already confirmed it),
348
+ // so it is a clean no-op — while a wallet that scanned up to a checkpoint
349
+ // height and was then same-height reorged finally gets the check it was
350
+ // missing. Route the fetch through nodeSaplingRoot so it is skipped below
351
+ // the SAPLING_ACTIVATION threshold: below real V5_0 the node reports a
352
+ // zero root our non-zero empty tree can't match, so an honest wallet would
353
+ // false-diverge — the same activation exception ensureValidCheckpoint
354
+ // relies on. nodeSaplingRoot's verbosity-1 getblock returns
355
+ // finalsaplingroot without the full tx list the batch fetchBlock needs.
356
+ if (this.lastProcessedBlock === tip) {
357
+ const nodeRoot = await this.nodeSaplingRoot(client, tip);
358
+ if (nodeRoot !== null) {
359
+ const localRoot = reverseHex(this.shield.get_sapling_root(this.commitmentTree));
360
+ if (localRoot !== nodeRoot)
361
+ throw new ScanDivergedError(tip, localRoot, nodeRoot);
362
+ }
363
+ }
335
364
  while (this.lastProcessedBlock < tip) {
336
365
  throwIfAborted(); // batch boundary: nothing applied yet, state consistent
337
366
  const from = this.lastProcessedBlock + 1;
@@ -356,15 +385,25 @@ export class PivxWallet {
356
385
  height: h,
357
386
  txs: blocks[i].tx.map(({ hex, txid }) => ({ hex, txid })),
358
387
  })));
359
- const nodeRoot = blocks[blocks.length - 1].finalsaplingroot;
360
- // A shielded chain always has a sapling root; a missing one means
361
- // the node is pre-activation or lying. Either way, refuse to
362
- // advance unverified.
363
- if (!nodeRoot)
364
- throw new Error(`node omitted finalsaplingroot at height ${to}`);
365
- const localRoot = reverseHex(this.shield.get_sapling_root(this.commitmentTree));
366
- if (localRoot !== nodeRoot)
367
- throw new ScanDivergedError(to, localRoot, nodeRoot);
388
+ // Verify the locally-built tree against the node's finalsaplingroot,
389
+ // except below the SAPLING_ACTIVATION threshold, where we skip: below
390
+ // real V5_0 the node reports a zero root our non-zero empty tree can't
391
+ // match, and no shielded txs exist below activation anyway, so there
392
+ // is nothing to verify. Both networks can scan into the skip window: a
393
+ // testnet wallet with a below-activation birth height resumes from the
394
+ // height-0 empty-tree checkpoint, and a mainnet wallet resumes from
395
+ // checkpoint 2_700_000 and scans the empty [2_700_001, 2_700_500) gap
396
+ // below its real activation. Same exception nodeSaplingRoot encodes.
397
+ if (to >= SAPLING_ACTIVATION[this.network]) {
398
+ const nodeRoot = blocks[blocks.length - 1].finalsaplingroot;
399
+ // A shielded chain always has a sapling root; a missing one past
400
+ // activation means the node is lying. Refuse to advance unverified.
401
+ if (!nodeRoot)
402
+ throw new Error(`node omitted finalsaplingroot at height ${to}`);
403
+ const localRoot = reverseHex(this.shield.get_sapling_root(this.commitmentTree));
404
+ if (localRoot !== nodeRoot)
405
+ throw new ScanDivergedError(to, localRoot, nodeRoot);
406
+ }
368
407
  }
369
408
  catch (err) {
370
409
  this.commitmentTree = snapshot.tree;
@@ -381,6 +420,23 @@ export class PivxWallet {
381
420
  this.busy = false;
382
421
  }
383
422
  }
423
+ /**
424
+ * The node's finalsaplingroot at height h, or null below the SAPLING_ACTIVATION
425
+ * threshold — below real V5_0 the node reports a zero root our non-zero empty
426
+ * tree can't match, so returning null signals "skip the check". At/above the
427
+ * threshold the node must report one; treating an omitted root as "no root"
428
+ * would let a node suppress the check or force an all-the-way rewind by simply
429
+ * withholding the field.
430
+ */
431
+ async nodeSaplingRoot(client, h) {
432
+ if (h < SAPLING_ACTIVATION[this.network])
433
+ return null;
434
+ const block = (await client.getBlock(await client.getBlockHash(h), 1));
435
+ if (!block.finalsaplingroot) {
436
+ throw new Error(`node omitted finalsaplingroot at height ${h} (past sapling activation)`);
437
+ }
438
+ return block.finalsaplingroot;
439
+ }
384
440
  /**
385
441
  * Confirm the starting commitment tree against the node before scanning
386
442
  * forward. A fresh wallet begins at a bundled checkpoint; if that
@@ -393,21 +449,8 @@ export class PivxWallet {
393
449
  async ensureValidCheckpoint(client) {
394
450
  if (this.startValidated)
395
451
  return;
396
- const activation = SAPLING_ACTIVATION[this.network];
397
- // Sapling root at height h. Above activation the node must report one;
398
- // treating an omitted root as "no root" would let a node suppress this
399
- // check or force an all-the-way rewind by simply withholding the field.
400
- const rootAt = async (h) => {
401
- if (h < activation)
402
- return null;
403
- const block = (await client.getBlock(await client.getBlockHash(h), 1));
404
- if (!block.finalsaplingroot) {
405
- throw new Error(`node omitted finalsaplingroot at height ${h} (past sapling activation)`);
406
- }
407
- return block.finalsaplingroot;
408
- };
409
452
  const localRoot = () => reverseHex(this.shield.get_sapling_root(this.commitmentTree));
410
- const node = await rootAt(this.lastProcessedBlock);
453
+ const node = await this.nodeSaplingRoot(client, this.lastProcessedBlock);
411
454
  if (node === null || localRoot() === node) {
412
455
  this.startValidated = true;
413
456
  return;
@@ -429,7 +472,7 @@ export class PivxWallet {
429
472
  if (cpHeight >= lastCp)
430
473
  break; // no older checkpoint available
431
474
  lastCp = cpHeight;
432
- const nodeRoot = await rootAt(cpHeight);
475
+ const nodeRoot = await this.nodeSaplingRoot(client, cpHeight);
433
476
  const cpRoot = reverseHex(this.shield.get_sapling_root(cpTree));
434
477
  if (nodeRoot === null || cpRoot === nodeRoot) {
435
478
  this.commitmentTree = cpTree;
@@ -511,31 +554,35 @@ export class PivxWallet {
511
554
  }
512
555
  }
513
556
  }
514
- // Spendable notes, minus pending spends and dust. Dust notes (worth no
515
- // more than their own input fee) can never help cover amount+fee and only
516
- // let an attacker inflate the fee, so they are excluded from spending.
517
- const pending = new Set([...this.pendingSpends.values()].flat());
518
- const spendable = this.notes.filter((n) => !pending.has(n.nullifier) && n.note.value > DUST_NOTE_SATS);
519
- // Refuse a send the inputs can't cover including the fee, unless the
520
- // caller opts into sweep. Both branches mirror the Rust selection so the
521
- // WASM can't silently pay the fee out of the recipient's amount — the
522
- // WASM shares the same fee model but has no such guard.
523
- const sufficient = useShield
524
- ? estimateShieldSelection(spendable, opts.amount, this.isShieldAddress(opts.to)).sufficient
525
- : estimateTransparentSelection(opts.inputs, opts.amount, this.isShieldAddress(opts.to)).sufficient;
526
- if (!sufficient && !opts.sweep) {
527
- throw new Error('insufficient input value to cover amount + fee; lower the amount, ' +
528
- 'add inputs, or pass sweep:true to deduct the fee from the recipient');
529
- }
530
- // Prover is only needed to build; check it after the cheap validations
531
- // so callers get input errors without loading ~50MB of parameters.
532
- if (!(await this.shield.prover_is_loaded())) {
533
- throw new Error('sapling prover not loaded: call loadProver() first');
534
- }
557
+ // Acquire the single-writer guard BEFORE snapshotting spendable notes or
558
+ // awaiting anything. Otherwise two concurrent createTransaction calls could
559
+ // each snapshot the same notes before either set `busy`, then serialize on
560
+ // the guard and both build with the same inputs — a double-spend.
535
561
  if (this.busy)
536
562
  throw new Error('wallet is busy: another sync or spend is in progress');
537
563
  this.busy = true;
538
564
  try {
565
+ // Spendable notes, minus pending spends and dust. Dust notes (worth no
566
+ // more than their own input fee) can never help cover amount+fee and only
567
+ // let an attacker inflate the fee, so they are excluded from spending.
568
+ const pending = new Set([...this.pendingSpends.values()].flat());
569
+ const spendable = this.notes.filter((n) => !pending.has(n.nullifier) && n.note.value > DUST_NOTE_SATS);
570
+ // Refuse a send the inputs can't cover including the fee, unless the
571
+ // caller opts into sweep. Both branches mirror the Rust selection so the
572
+ // WASM can't silently pay the fee out of the recipient's amount — the
573
+ // WASM shares the same fee model but has no such guard.
574
+ const sufficient = useShield
575
+ ? estimateShieldSelection(spendable, opts.amount, this.isShieldAddress(opts.to)).sufficient
576
+ : estimateTransparentSelection(opts.inputs, opts.amount, this.isShieldAddress(opts.to)).sufficient;
577
+ if (!sufficient && !opts.sweep) {
578
+ throw new Error('insufficient input value to cover amount + fee; lower the amount, ' +
579
+ 'add inputs, or pass sweep:true to deduct the fee from the recipient');
580
+ }
581
+ // Prover is only needed to build; check it after the cheap validations
582
+ // so callers get input errors without loading ~50MB of parameters.
583
+ if (!(await this.shield.prover_is_loaded())) {
584
+ throw new Error('sapling prover not loaded: call loadProver() first');
585
+ }
539
586
  const changeAddress = useShield ? this.getNewAddress() : opts.transparentChangeAddress;
540
587
  const result = (await this.shield.create_transaction({
541
588
  notes: useShield ? spendable : null,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pivx-wallet",
3
- "version": "0.3.3",
3
+ "version": "0.3.5",
4
4
  "description": "Standalone PIVX wallet SDK: local key management, shielded (SHIELD/Sapling) scanning, balances, and transaction building. The node is only a chain-data source.",
5
5
  "license": "MIT",
6
6
  "repository": {