pivx-wallet 0.3.2 → 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.
- package/dist/transparent-wallet.d.ts +1 -0
- package/dist/transparent-wallet.js +68 -1
- package/dist/wallet.js +45 -21
- package/package.json +2 -2
|
@@ -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
|
-
//
|
|
515
|
-
//
|
|
516
|
-
//
|
|
517
|
-
|
|
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
|
+
"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": {
|
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
"@noble/hashes": "^2.2.0",
|
|
45
45
|
"@scure/base": "^2.2.0",
|
|
46
46
|
"@scure/bip32": "^2.2.0",
|
|
47
|
-
"pivx-rpc": "^0.
|
|
47
|
+
"pivx-rpc": "^0.5.0",
|
|
48
48
|
"pivx-shield-rust": "^1.4.0"
|
|
49
49
|
},
|
|
50
50
|
"optionalDependencies": {
|