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.
- package/dist/transparent-wallet.js +15 -0
- package/dist/wallet.d.ts +9 -0
- package/dist/wallet.js +57 -34
- package/package.json +1 -1
|
@@ -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
|
-
/**
|
|
6
|
-
* the
|
|
7
|
-
|
|
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
|
-
//
|
|
343
|
-
//
|
|
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
|
|
346
|
-
if (
|
|
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
|
-
|
|
380
|
-
//
|
|
381
|
-
// the node
|
|
382
|
-
//
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
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
|
|
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
|
|
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.
|
|
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": {
|