pivx-wallet 0.3.3 → 0.3.4

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)).
@@ -364,6 +416,9 @@ export class TransparentWallet {
364
416
  nextChange: this.nextChange,
365
417
  lastScanned: this.lastScanned,
366
418
  lastScannedHash: this.lastScannedHash,
419
+ // Rolling reorg window (ascending by height, newest last). Emitted here —
420
+ // after lastScannedHash, before utxos — for byte parity with the Rust SDK.
421
+ scannedHashes: this.scannedHashes.map((e) => ({ height: e.height, hash: e.hash })),
367
422
  // Sorted by (txid, vout) so save() output is deterministic and
368
423
  // byte-comparable with the Rust SDK's.
369
424
  utxos: [...this.utxos.values()]
@@ -423,6 +478,10 @@ export class TransparentWallet {
423
478
  if (!Array.isArray(s.utxos) || !Array.isArray(s.pending)) {
424
479
  throw new Error('wallet state has invalid utxo or pending lists');
425
480
  }
481
+ // Backward-compatible: older states have no window → treat as empty.
482
+ if (s.scannedHashes !== undefined && !Array.isArray(s.scannedHashes)) {
483
+ throw new Error('wallet state has an invalid scanned-hash window');
484
+ }
426
485
  const w = TransparentWallet.create(seed, s.network, s.account, s.gap);
427
486
  w.nextExternal = s.nextExternal;
428
487
  w.nextChange = s.nextChange;
@@ -462,6 +521,14 @@ export class TransparentWallet {
462
521
  }
463
522
  w.pending.add(`${p.txid}:${p.vout}`);
464
523
  }
524
+ for (const e of s.scannedHashes ?? []) {
525
+ // hash is string-checked (not hex-validated), matching lastScannedHash
526
+ // and the Rust SDK so a state loads in both or neither.
527
+ if (!isCount(e?.height) || typeof e.hash !== 'string') {
528
+ throw new Error('wallet state contains a malformed scanned-hash entry');
529
+ }
530
+ w.scannedHashes.push({ height: e.height, hash: e.hash });
531
+ }
465
532
  return w;
466
533
  }
467
534
  }
package/dist/wallet.js CHANGED
@@ -332,6 +332,26 @@ export class PivxWallet {
332
332
  }
333
333
  return block;
334
334
  };
335
+ // Stale-tip reorg detection: when lastProcessedBlock === tip the batch
336
+ // loop below never runs, so its per-batch root check never fires. A
337
+ // same-height reorg (block N replaced while the tip stays N) changes the
338
+ // shielded set's root but not the height. The tree can't be cheaply
339
+ // rewound, so re-verify the tip's finalsaplingroot against our local
340
+ // commitment root — the same localRoot-vs-nodeRoot check the batch loop
341
+ // does — and diverge on mismatch (caller recovers via reloadFromCheckpoint).
342
+ // Skip when nothing has been scanned past the checkpoint (a fresh wallet
343
+ // still on its bundled checkpoint has no tree of its own to check).
344
+ if (this.lastProcessedBlock === tip) {
345
+ const [checkpoint] = this.shield.get_closest_checkpoint(this.lastProcessedBlock, this.isTestnet);
346
+ if (this.lastProcessedBlock > checkpoint) {
347
+ const nodeRoot = (await fetchBlock(tip)).finalsaplingroot;
348
+ if (!nodeRoot)
349
+ throw new Error(`node omitted finalsaplingroot at height ${tip}`);
350
+ const localRoot = reverseHex(this.shield.get_sapling_root(this.commitmentTree));
351
+ if (localRoot !== nodeRoot)
352
+ throw new ScanDivergedError(tip, localRoot, nodeRoot);
353
+ }
354
+ }
335
355
  while (this.lastProcessedBlock < tip) {
336
356
  throwIfAborted(); // batch boundary: nothing applied yet, state consistent
337
357
  const from = this.lastProcessedBlock + 1;
@@ -511,31 +531,35 @@ export class PivxWallet {
511
531
  }
512
532
  }
513
533
  }
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
- }
534
+ // Acquire the single-writer guard BEFORE snapshotting spendable notes or
535
+ // awaiting anything. Otherwise two concurrent createTransaction calls could
536
+ // each snapshot the same notes before either set `busy`, then serialize on
537
+ // the guard and both build with the same inputs — a double-spend.
535
538
  if (this.busy)
536
539
  throw new Error('wallet is busy: another sync or spend is in progress');
537
540
  this.busy = true;
538
541
  try {
542
+ // Spendable notes, minus pending spends and dust. Dust notes (worth no
543
+ // more than their own input fee) can never help cover amount+fee and only
544
+ // let an attacker inflate the fee, so they are excluded from spending.
545
+ const pending = new Set([...this.pendingSpends.values()].flat());
546
+ const spendable = this.notes.filter((n) => !pending.has(n.nullifier) && n.note.value > DUST_NOTE_SATS);
547
+ // Refuse a send the inputs can't cover including the fee, unless the
548
+ // caller opts into sweep. Both branches mirror the Rust selection so the
549
+ // WASM can't silently pay the fee out of the recipient's amount — the
550
+ // WASM shares the same fee model but has no such guard.
551
+ const sufficient = useShield
552
+ ? estimateShieldSelection(spendable, opts.amount, this.isShieldAddress(opts.to)).sufficient
553
+ : estimateTransparentSelection(opts.inputs, opts.amount, this.isShieldAddress(opts.to)).sufficient;
554
+ if (!sufficient && !opts.sweep) {
555
+ throw new Error('insufficient input value to cover amount + fee; lower the amount, ' +
556
+ 'add inputs, or pass sweep:true to deduct the fee from the recipient');
557
+ }
558
+ // Prover is only needed to build; check it after the cheap validations
559
+ // so callers get input errors without loading ~50MB of parameters.
560
+ if (!(await this.shield.prover_is_loaded())) {
561
+ throw new Error('sapling prover not loaded: call loadProver() first');
562
+ }
539
563
  const changeAddress = useShield ? this.getNewAddress() : opts.transparentChangeAddress;
540
564
  const result = (await this.shield.create_transaction({
541
565
  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.4",
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": {