pivx-wallet 0.3.4 → 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.
@@ -326,6 +326,13 @@ export class TransparentWallet {
326
326
  * (broadcast definitively rejected) resolves them.
327
327
  */
328
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');
329
336
  if (!Number.isSafeInteger(amount) || amount <= 0)
330
337
  throw new Error('amount must be a positive integer (satoshis)');
331
338
  if (!Number.isInteger(feePerByte) || feePerByte <= 0)
@@ -529,6 +536,14 @@ export class TransparentWallet {
529
536
  }
530
537
  w.scannedHashes.push({ height: e.height, hash: e.hash });
531
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
+ }
532
547
  return w;
533
548
  }
534
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;
@@ -339,14 +342,20 @@ export class PivxWallet {
339
342
  // rewound, so re-verify the tip's finalsaplingroot against our local
340
343
  // commitment root — the same localRoot-vs-nodeRoot check the batch loop
341
344
  // 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).
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.
344
356
  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}`);
357
+ const nodeRoot = await this.nodeSaplingRoot(client, tip);
358
+ if (nodeRoot !== null) {
350
359
  const localRoot = reverseHex(this.shield.get_sapling_root(this.commitmentTree));
351
360
  if (localRoot !== nodeRoot)
352
361
  throw new ScanDivergedError(tip, localRoot, nodeRoot);
@@ -376,15 +385,25 @@ export class PivxWallet {
376
385
  height: h,
377
386
  txs: blocks[i].tx.map(({ hex, txid }) => ({ hex, txid })),
378
387
  })));
379
- const nodeRoot = blocks[blocks.length - 1].finalsaplingroot;
380
- // A shielded chain always has a sapling root; a missing one means
381
- // the node is pre-activation or lying. Either way, refuse to
382
- // advance unverified.
383
- if (!nodeRoot)
384
- throw new Error(`node omitted finalsaplingroot at height ${to}`);
385
- const localRoot = reverseHex(this.shield.get_sapling_root(this.commitmentTree));
386
- if (localRoot !== nodeRoot)
387
- 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
+ }
388
407
  }
389
408
  catch (err) {
390
409
  this.commitmentTree = snapshot.tree;
@@ -401,6 +420,23 @@ export class PivxWallet {
401
420
  this.busy = false;
402
421
  }
403
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
+ }
404
440
  /**
405
441
  * Confirm the starting commitment tree against the node before scanning
406
442
  * forward. A fresh wallet begins at a bundled checkpoint; if that
@@ -413,21 +449,8 @@ export class PivxWallet {
413
449
  async ensureValidCheckpoint(client) {
414
450
  if (this.startValidated)
415
451
  return;
416
- const activation = SAPLING_ACTIVATION[this.network];
417
- // Sapling root at height h. Above activation the node must report one;
418
- // treating an omitted root as "no root" would let a node suppress this
419
- // check or force an all-the-way rewind by simply withholding the field.
420
- const rootAt = async (h) => {
421
- if (h < activation)
422
- return null;
423
- const block = (await client.getBlock(await client.getBlockHash(h), 1));
424
- if (!block.finalsaplingroot) {
425
- throw new Error(`node omitted finalsaplingroot at height ${h} (past sapling activation)`);
426
- }
427
- return block.finalsaplingroot;
428
- };
429
452
  const localRoot = () => reverseHex(this.shield.get_sapling_root(this.commitmentTree));
430
- const node = await rootAt(this.lastProcessedBlock);
453
+ const node = await this.nodeSaplingRoot(client, this.lastProcessedBlock);
431
454
  if (node === null || localRoot() === node) {
432
455
  this.startValidated = true;
433
456
  return;
@@ -449,7 +472,7 @@ export class PivxWallet {
449
472
  if (cpHeight >= lastCp)
450
473
  break; // no older checkpoint available
451
474
  lastCp = cpHeight;
452
- const nodeRoot = await rootAt(cpHeight);
475
+ const nodeRoot = await this.nodeSaplingRoot(client, cpHeight);
453
476
  const cpRoot = reverseHex(this.shield.get_sapling_root(cpTree));
454
477
  if (nodeRoot === null || cpRoot === nodeRoot) {
455
478
  this.commitmentTree = cpTree;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pivx-wallet",
3
- "version": "0.3.4",
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": {