pivx-wallet 0.1.0 → 0.3.0

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.
@@ -38,6 +38,12 @@ export declare class TransparentWallet {
38
38
  private nextChange;
39
39
  private utxos;
40
40
  private lastScanned;
41
+ private lastScannedHash;
42
+ private pending;
43
+ /** One sync at a time (mirrors the shield wallet's busy guard). */
44
+ private busy;
45
+ private account;
46
+ private gap;
41
47
  private constructor();
42
48
  /**
43
49
  * Derive `gap` external and `gap` change addresses from `seed` under
@@ -46,6 +52,14 @@ export declare class TransparentWallet {
46
52
  static create(seed: Uint8Array, network: Network, account?: number, gap?: number): TransparentWallet;
47
53
  /** Next unused external receive address. */
48
54
  newAddress(): string;
55
+ /**
56
+ * Next unused external receive address, encoded as an exchange (EXM)
57
+ * address. Shares the address cursor with {@link newAddress}: it hands out
58
+ * the same underlying key as the next {@link newAddress} would, so the same
59
+ * index's P2PKH encoding also pays this wallet — the two forms differ only
60
+ * in their scriptPubKey encoding.
61
+ */
62
+ newExchangeAddress(): string;
49
63
  private nextChangeHash;
50
64
  /**
51
65
  * Add a caller-supplied UTXO if it pays one of our addresses. Returns true if
@@ -56,36 +70,71 @@ export declare class TransparentWallet {
56
70
  private insertUtxo;
57
71
  /** Apply a scanned block's transparent outputs (added if ours) and spent inputs (removed). */
58
72
  scan(outputs: ScannedOutput[], spent: ScannedInput[]): void;
73
+ /** Drop a UTXO and any reservation on it (spent on-chain — nothing left to reserve). */
74
+ private removeUtxo;
59
75
  /**
60
76
  * Scan one decoded block (`getblock <hash> 2`): credit every output that
61
77
  * pays us and remove every tracked UTXO the block spends. Coinbase vins (no
62
- * prevout `txid`) are skipped. Records the block's height as last scanned.
78
+ * prevout `txid`) are skipped. Records the block's height and hash as last
79
+ * scanned.
80
+ *
81
+ * Throws {@link ScanDivergedError} — before mutating any state — when the
82
+ * block claims to extend the last scanned block (height + 1) but its
83
+ * `previousblockhash` does not match the last scanned hash: the chain
84
+ * reorganized under us. Recover with {@link resetScan} below the fork point
85
+ * and re-sync.
63
86
  */
64
87
  scanBlock(block: any): void;
65
88
  /** Height of the last block passed to {@link scanBlock} (0 if none). */
66
89
  lastScannedBlock(): number;
90
+ /**
91
+ * Recovery path after {@link ScanDivergedError}: reset the scan position to
92
+ * `height` (choose one below the fork point) and re-sync. Every scanned UTXO
93
+ * above that height is dropped, along with its reservation; caller-supplied
94
+ * UTXOs (tracked at height 0) are kept.
95
+ */
96
+ resetScan(height: number): void;
67
97
  /**
68
98
  * Sync from the node into the wallet, from `max(fromHeight, lastScanned+1)`
69
99
  * up to the current tip, fetching each block with getBlockHash +
70
100
  * getBlock(hash, 2) and feeding it to {@link scanBlock}. Blocks are fetched
71
- * with bounded concurrency but scanned in ascending order.
101
+ * with bounded concurrency but scanned in ascending order. Only one sync may
102
+ * run at a time; a concurrent call throws. A {@link ScanDivergedError} from
103
+ * {@link scanBlock} (reorg) propagates to the caller.
72
104
  *
73
105
  * Like the shield wallet's sync this is a chain-data pull, not chain
74
106
  * authentication: point it at a node you trust. See SECURITY.md.
75
107
  */
76
- sync(client: PivxClient, { fromHeight, batchSize, onProgress }?: {
108
+ sync(client: PivxClient, { fromHeight, batchSize, onProgress, signal }?: {
77
109
  fromHeight?: number;
78
110
  batchSize?: number;
79
111
  onProgress?: (height: number, tip: number) => void;
112
+ /**
113
+ * Abort the sync. Checked at every batch and concurrency-chunk
114
+ * boundary, before the next round of RPCs is issued; when set, sync
115
+ * throws `signal.reason` (an `AbortError` DOMException by default).
116
+ * Fully scanned blocks are kept and the busy guard is released, so a
117
+ * follow-up sync resumes where this one stopped.
118
+ */
119
+ signal?: AbortSignal;
80
120
  }): Promise<void>;
81
- /** Total tracked transparent balance in satoshis. */
121
+ /**
122
+ * Total spendable transparent balance in satoshis. Outputs reserved by
123
+ * {@link buildSend} are excluded (like the shield wallet's pending-note
124
+ * exclusion); {@link getUtxos} still lists them.
125
+ */
82
126
  balance(): number;
127
+ /** All tracked UTXOs, including ones reserved by {@link buildSend} (unlike {@link balance}). */
83
128
  getUtxos(): readonly OwnedUtxo[];
84
129
  private static estSize;
85
130
  /**
86
131
  * Build and sign a transparent send of `amount` sats to `to`, selecting
87
132
  * UTXOs largest-first with change to a fresh change address. `feePerByte`
88
133
  * defaults to 100 sats/byte. Returns the raw tx hex and the spent inputs.
134
+ *
135
+ * The selected inputs are reserved: a later buildSend will not select them
136
+ * again until {@link markSpent} (broadcast succeeded) or {@link release}
137
+ * (broadcast definitively rejected) resolves them.
89
138
  */
90
139
  buildSend(to: string, amount: number, feePerByte?: number): {
91
140
  hex: string;
@@ -94,10 +143,32 @@ export declare class TransparentWallet {
94
143
  vout: number;
95
144
  }[];
96
145
  };
97
- /** Mark inputs spent after a successful broadcast. */
146
+ /** Mark inputs spent after a successful broadcast (drops them and their reservation). */
98
147
  markSpent(spent: {
99
148
  txid: string;
100
149
  vout: number;
101
150
  }[]): void;
151
+ /**
152
+ * Release inputs reserved by {@link buildSend} after a definitively
153
+ * rejected broadcast: they become selectable again. On an ambiguous failure
154
+ * (timeout), keep them reserved until the transaction confirms or clearly
155
+ * disappears.
156
+ */
157
+ release(spent: {
158
+ txid: string;
159
+ vout: number;
160
+ }[]): void;
161
+ /**
162
+ * Serialize wallet state to JSON (cross-SDK state format, version 1). No
163
+ * key material is included — restore with {@link load} and the seed.
164
+ */
165
+ save(): string;
166
+ /**
167
+ * Restore a wallet from {@link save} output: re-derives keys from `seed`
168
+ * (same network/account/gap as saved) and restores scan position, UTXOs,
169
+ * and reservations. Throws if the state is malformed or does not belong to
170
+ * this seed.
171
+ */
172
+ static load(seed: Uint8Array, state: string): TransparentWallet;
102
173
  }
103
174
  export { scriptPubKeyForAddress };
@@ -1,6 +1,9 @@
1
1
  import { decodeAddress, deriveKey, encodeAddress, hash160 } from './transparent.js';
2
2
  import { buildTransparentTx, scriptPubKeyForAddress } from './transparent-tx.js';
3
+ import { ScanDivergedError } from './wallet.js';
3
4
  const hex = (b) => [...b].map((x) => x.toString(16).padStart(2, '0')).join('');
5
+ const fromHex = (s) => Uint8Array.from((s.match(/../g) ?? []).map((b) => parseInt(b, 16)));
6
+ const isHex = (s) => /^(?:[0-9a-fA-F]{2})+$/.test(s);
4
7
  /**
5
8
  * PIVX dust threshold (sats) for an output whose scriptPubKey is `scriptLen`
6
9
  * bytes. Matches `GetDustThreshold` in src/policy/policy.cpp: the output plus
@@ -11,13 +14,23 @@ const hex = (b) => [...b].map((x) => x.toString(16).padStart(2, '0')).join('');
11
14
  const dustThreshold = (scriptLen) => Math.floor((30_000 * (8 + 1 + scriptLen + 148)) / 1000);
12
15
  /** Coinbase/coinstake maturity in blocks (nCoinbaseMaturity): mainnet 100, testnet 15. */
13
16
  const coinbaseMaturity = (network) => (network === 'mainnet' ? 100 : 15);
14
- /** hash160 of a standard P2PKH scriptPubKey (76a914<20>88ac), if it is one. */
15
- function p2pkhHash(script) {
17
+ /**
18
+ * hash160 of a scriptPubKey we know how to spend, if it is one: a standard
19
+ * 25-byte P2PKH (76a914<20>88ac) or the 26-byte exchange form with an
20
+ * OP_EXCHANGEADDR prefix (e076a914<20>88ac) — per Solver's TX_EXCHANGEADDR in
21
+ * PIVX src/script/standard.cpp. Both encodings pay the same key.
22
+ */
23
+ function ownedScriptHash(script) {
16
24
  if (script.length === 25 &&
17
25
  script[0] === 0x76 && script[1] === 0xa9 && script[2] === 0x14 &&
18
26
  script[23] === 0x88 && script[24] === 0xac) {
19
27
  return hex(script.slice(3, 23));
20
28
  }
29
+ if (script.length === 26 && script[0] === 0xe0 &&
30
+ script[1] === 0x76 && script[2] === 0xa9 && script[3] === 0x14 &&
31
+ script[24] === 0x88 && script[25] === 0xac) {
32
+ return hex(script.slice(4, 24));
33
+ }
21
34
  return undefined;
22
35
  }
23
36
  export class TransparentWallet {
@@ -29,6 +42,12 @@ export class TransparentWallet {
29
42
  nextChange = 0;
30
43
  utxos = new Map(); // "txid:vout" → utxo
31
44
  lastScanned = 0; // height of the last block passed to scanBlock
45
+ lastScannedHash = null; // hash of that block, for reorg detection
46
+ pending = new Set(); // "txid:vout" reserved by buildSend until markSpent/release
47
+ /** One sync at a time (mirrors the shield wallet's busy guard). */
48
+ busy = false;
49
+ account = 0;
50
+ gap = 0;
32
51
  constructor(network) {
33
52
  this.network = network;
34
53
  }
@@ -38,6 +57,8 @@ export class TransparentWallet {
38
57
  */
39
58
  static create(seed, network, account = 0, gap = 100) {
40
59
  const w = new TransparentWallet(network);
60
+ w.account = account;
61
+ w.gap = gap;
41
62
  for (let i = 0; i < gap; i++) {
42
63
  const ext = deriveKey(seed, network, account, 0, i);
43
64
  const eh = hex(hash160(ext.publicKey));
@@ -58,6 +79,20 @@ export class TransparentWallet {
58
79
  this.nextExternal++;
59
80
  return e.address;
60
81
  }
82
+ /**
83
+ * Next unused external receive address, encoded as an exchange (EXM)
84
+ * address. Shares the address cursor with {@link newAddress}: it hands out
85
+ * the same underlying key as the next {@link newAddress} would, so the same
86
+ * index's P2PKH encoding also pays this wallet — the two forms differ only
87
+ * in their scriptPubKey encoding.
88
+ */
89
+ newExchangeAddress() {
90
+ const e = this.external[this.nextExternal];
91
+ if (!e)
92
+ throw new Error('address gap limit reached; increase gap');
93
+ this.nextExternal++;
94
+ return encodeAddress(fromHex(e.hash), this.network, 'exchange');
95
+ }
61
96
  nextChangeHash() {
62
97
  const h = this.change[this.nextChange];
63
98
  if (!h)
@@ -74,7 +109,7 @@ export class TransparentWallet {
74
109
  return this.insertUtxo(txid, vout, amount, scriptPubKey, false, 0);
75
110
  }
76
111
  insertUtxo(txid, vout, amount, scriptPubKey, coinbase, height) {
77
- const h = p2pkhHash(scriptPubKey);
112
+ const h = ownedScriptHash(scriptPubKey);
78
113
  if (h && this.keys.has(h)) {
79
114
  this.utxos.set(`${txid}:${vout}`, { txid, vout, amount, scriptPubKey, keyHash: h, coinbase, height });
80
115
  return true;
@@ -86,14 +121,32 @@ export class TransparentWallet {
86
121
  for (const o of outputs)
87
122
  this.addUtxo(o.txid, o.vout, o.amount, o.scriptPubKey);
88
123
  for (const s of spent)
89
- this.utxos.delete(`${s.txid}:${s.vout}`);
124
+ this.removeUtxo(`${s.txid}:${s.vout}`);
125
+ }
126
+ /** Drop a UTXO and any reservation on it (spent on-chain — nothing left to reserve). */
127
+ removeUtxo(key) {
128
+ this.utxos.delete(key);
129
+ this.pending.delete(key);
90
130
  }
91
131
  /**
92
132
  * Scan one decoded block (`getblock <hash> 2`): credit every output that
93
133
  * pays us and remove every tracked UTXO the block spends. Coinbase vins (no
94
- * prevout `txid`) are skipped. Records the block's height as last scanned.
134
+ * prevout `txid`) are skipped. Records the block's height and hash as last
135
+ * scanned.
136
+ *
137
+ * Throws {@link ScanDivergedError} — before mutating any state — when the
138
+ * block claims to extend the last scanned block (height + 1) but its
139
+ * `previousblockhash` does not match the last scanned hash: the chain
140
+ * reorganized under us. Recover with {@link resetScan} below the fork point
141
+ * and re-sync.
95
142
  */
96
143
  scanBlock(block) {
144
+ if (this.lastScannedHash !== null &&
145
+ block.height === this.lastScanned + 1 &&
146
+ typeof block.previousblockhash === 'string' &&
147
+ block.previousblockhash !== this.lastScannedHash) {
148
+ throw new ScanDivergedError(block.height, this.lastScannedHash, block.previousblockhash);
149
+ }
97
150
  if (typeof block.height === 'number')
98
151
  this.lastScanned = block.height;
99
152
  const height = this.lastScanned;
@@ -118,47 +171,93 @@ export class TransparentWallet {
118
171
  }
119
172
  for (const i of tx.vin ?? []) {
120
173
  if (i.txid !== undefined)
121
- this.utxos.delete(`${i.txid}:${i.vout}`);
174
+ this.removeUtxo(`${i.txid}:${i.vout}`);
122
175
  }
123
176
  }
177
+ this.lastScannedHash = typeof block.hash === 'string' ? block.hash : null;
124
178
  }
125
179
  /** Height of the last block passed to {@link scanBlock} (0 if none). */
126
180
  lastScannedBlock() {
127
181
  return this.lastScanned;
128
182
  }
183
+ /**
184
+ * Recovery path after {@link ScanDivergedError}: reset the scan position to
185
+ * `height` (choose one below the fork point) and re-sync. Every scanned UTXO
186
+ * above that height is dropped, along with its reservation; caller-supplied
187
+ * UTXOs (tracked at height 0) are kept.
188
+ */
189
+ resetScan(height) {
190
+ for (const [k, u] of this.utxos) {
191
+ if (u.height > height && u.height > 0) {
192
+ this.utxos.delete(k);
193
+ this.pending.delete(k);
194
+ }
195
+ }
196
+ this.lastScanned = height;
197
+ this.lastScannedHash = null;
198
+ }
129
199
  /**
130
200
  * Sync from the node into the wallet, from `max(fromHeight, lastScanned+1)`
131
201
  * up to the current tip, fetching each block with getBlockHash +
132
202
  * getBlock(hash, 2) and feeding it to {@link scanBlock}. Blocks are fetched
133
- * with bounded concurrency but scanned in ascending order.
203
+ * with bounded concurrency but scanned in ascending order. Only one sync may
204
+ * run at a time; a concurrent call throws. A {@link ScanDivergedError} from
205
+ * {@link scanBlock} (reorg) propagates to the caller.
134
206
  *
135
207
  * Like the shield wallet's sync this is a chain-data pull, not chain
136
208
  * authentication: point it at a node you trust. See SECURITY.md.
137
209
  */
138
- async sync(client, { fromHeight = 0, batchSize = 100, onProgress } = {}) {
139
- const concurrency = 8;
140
- const tip = await client.getBlockCount();
141
- const fetchBlock = async (h) => client.getBlock(await client.getBlockHash(h), 2);
142
- // NaN/0/fractional → sane integer: 0 would loop forever and fractional
143
- // heights would skip blocks (matches Rust batch.max(1)).
144
- const batch = Math.max(1, Math.floor(batchSize) || 1);
145
- let from = Math.max(fromHeight, this.lastScanned + 1);
146
- while (from <= tip) {
147
- const to = Math.min(from + batch - 1, tip);
148
- const heights = Array.from({ length: to - from + 1 }, (_, i) => from + i);
149
- for (let i = 0; i < heights.length; i += concurrency) {
150
- const blocks = await Promise.all(heights.slice(i, i + concurrency).map(fetchBlock));
151
- for (const block of blocks)
152
- this.scanBlock(block);
210
+ async sync(client, { fromHeight = 0, batchSize = 100, onProgress, signal } = {}) {
211
+ if (this.busy)
212
+ throw new Error('wallet is busy: another sync is in progress');
213
+ this.busy = true;
214
+ try {
215
+ const throwIfAborted = () => {
216
+ if (signal?.aborted)
217
+ throw signal.reason ?? new DOMException('sync aborted', 'AbortError');
218
+ };
219
+ const concurrency = 8;
220
+ const tip = await client.getBlockCount();
221
+ const fetchBlock = async (h) => client.getBlock(await client.getBlockHash(h), 2);
222
+ // NaN/0/fractional sane integer: 0 would loop forever and fractional
223
+ // heights would skip blocks (matches Rust batch.max(1)).
224
+ const batch = Math.max(1, Math.floor(batchSize) || 1);
225
+ let from = Math.max(fromHeight, this.lastScanned + 1);
226
+ while (from <= tip) {
227
+ throwIfAborted(); // batch boundary: before issuing the next round of RPCs
228
+ const to = Math.min(from + batch - 1, tip);
229
+ const heights = Array.from({ length: to - from + 1 }, (_, i) => from + i);
230
+ for (let i = 0; i < heights.length; i += concurrency) {
231
+ throwIfAborted(); // chunk boundary: previous chunk fully scanned
232
+ const blocks = await Promise.all(heights.slice(i, i + concurrency).map(fetchBlock));
233
+ for (const b of blocks) {
234
+ // getblock verbosity 2 always carries these; a block without them
235
+ // would silently disable the reorg continuity check, so treat it
236
+ // as a malformed node response rather than scanning past it.
237
+ const block = b;
238
+ if (typeof block?.hash !== 'string' || typeof block?.previousblockhash !== 'string') {
239
+ throw new Error(`node returned a block without hash/previousblockhash at height ${block?.height}`);
240
+ }
241
+ this.scanBlock(block);
242
+ }
243
+ }
244
+ onProgress?.(to, tip);
245
+ from = to + 1;
153
246
  }
154
- onProgress?.(to, tip);
155
- from = to + 1;
247
+ }
248
+ finally {
249
+ this.busy = false;
156
250
  }
157
251
  }
158
- /** Total tracked transparent balance in satoshis. */
252
+ /**
253
+ * Total spendable transparent balance in satoshis. Outputs reserved by
254
+ * {@link buildSend} are excluded (like the shield wallet's pending-note
255
+ * exclusion); {@link getUtxos} still lists them.
256
+ */
159
257
  balance() {
160
- return [...this.utxos.values()].reduce((s, u) => s + u.amount, 0);
258
+ return [...this.utxos.values()].reduce((s, u) => (this.pending.has(`${u.txid}:${u.vout}`) ? s : s + u.amount), 0);
161
259
  }
260
+ /** All tracked UTXOs, including ones reserved by {@link buildSend} (unlike {@link balance}). */
162
261
  getUtxos() {
163
262
  return [...this.utxos.values()];
164
263
  }
@@ -169,6 +268,10 @@ export class TransparentWallet {
169
268
  * Build and sign a transparent send of `amount` sats to `to`, selecting
170
269
  * UTXOs largest-first with change to a fresh change address. `feePerByte`
171
270
  * defaults to 100 sats/byte. Returns the raw tx hex and the spent inputs.
271
+ *
272
+ * The selected inputs are reserved: a later buildSend will not select them
273
+ * again until {@link markSpent} (broadcast succeeded) or {@link release}
274
+ * (broadcast definitively rejected) resolves them.
172
275
  */
173
276
  buildSend(to, amount, feePerByte = 100) {
174
277
  if (!Number.isSafeInteger(amount) || amount <= 0)
@@ -188,10 +291,12 @@ export class TransparentWallet {
188
291
  if (amount < dustThreshold(toScript.length))
189
292
  throw new Error('amount is below the dust threshold');
190
293
  const feerate = feePerByte;
191
- // Exclude immature coinbase/coinstake outputs: the node rejects a spend of
192
- // one before nCoinbaseMaturity confirmations (depth vs. last scanned block).
294
+ // Exclude reserved outpoints (awaiting markSpent/release) and immature
295
+ // coinbase/coinstake outputs: the node rejects a spend of one before
296
+ // nCoinbaseMaturity confirmations (depth vs. last scanned block).
193
297
  const maturity = coinbaseMaturity(this.network);
194
298
  const avail = [...this.utxos.values()]
299
+ .filter((u) => !this.pending.has(`${u.txid}:${u.vout}`))
195
300
  .filter((u) => !(u.coinbase && this.lastScanned - u.height + 1 < maturity))
196
301
  .sort((a, b) => b.amount - a.amount);
197
302
  const selected = [];
@@ -211,7 +316,7 @@ export class TransparentWallet {
211
316
  // the tx is rejected as dust) and the fee to later spend the change input.
212
317
  // Change is always P2PKH (25-byte script).
213
318
  if (changeVal > Math.max(feerate * 148, dustThreshold(25))) {
214
- const chAddr = encodeAddress(Uint8Array.from(this.nextChangeHash().match(/../g).map((b) => parseInt(b, 16))), this.network, 'p2pkh');
319
+ const chAddr = encodeAddress(fromHex(this.nextChangeHash()), this.network, 'p2pkh');
215
320
  outputs.push({ address: chAddr, amount: changeVal });
216
321
  }
217
322
  const inputs = selected.map((u) => ({
@@ -222,12 +327,142 @@ export class TransparentWallet {
222
327
  privateKey: this.keys.get(u.keyHash),
223
328
  }));
224
329
  const spent = selected.map((u) => ({ txid: u.txid, vout: u.vout }));
225
- return { hex: buildTransparentTx(inputs, outputs, 0), spent };
330
+ const rawHex = buildTransparentTx(inputs, outputs, 0);
331
+ for (const s of spent)
332
+ this.pending.add(`${s.txid}:${s.vout}`);
333
+ return { hex: rawHex, spent };
226
334
  }
227
- /** Mark inputs spent after a successful broadcast. */
335
+ /** Mark inputs spent after a successful broadcast (drops them and their reservation). */
228
336
  markSpent(spent) {
229
- for (const s of spent)
337
+ for (const s of spent) {
230
338
  this.utxos.delete(`${s.txid}:${s.vout}`);
339
+ this.pending.delete(`${s.txid}:${s.vout}`);
340
+ }
341
+ }
342
+ /**
343
+ * Release inputs reserved by {@link buildSend} after a definitively
344
+ * rejected broadcast: they become selectable again. On an ambiguous failure
345
+ * (timeout), keep them reserved until the transaction confirms or clearly
346
+ * disappears.
347
+ */
348
+ release(spent) {
349
+ for (const s of spent)
350
+ this.pending.delete(`${s.txid}:${s.vout}`);
351
+ }
352
+ // ── Persistence ───────────────────────────────────────────────────────────
353
+ /**
354
+ * Serialize wallet state to JSON (cross-SDK state format, version 1). No
355
+ * key material is included — restore with {@link load} and the seed.
356
+ */
357
+ save() {
358
+ return JSON.stringify({
359
+ version: 1,
360
+ network: this.network,
361
+ account: this.account,
362
+ gap: this.gap,
363
+ nextExternal: this.nextExternal,
364
+ nextChange: this.nextChange,
365
+ lastScanned: this.lastScanned,
366
+ lastScannedHash: this.lastScannedHash,
367
+ // Sorted by (txid, vout) so save() output is deterministic and
368
+ // byte-comparable with the Rust SDK's.
369
+ utxos: [...this.utxos.values()]
370
+ .sort((a, b) => (a.txid < b.txid ? -1 : a.txid > b.txid ? 1 : a.vout - b.vout))
371
+ .map((u) => ({
372
+ txid: u.txid,
373
+ vout: u.vout,
374
+ amount: u.amount,
375
+ scriptPubKey: hex(u.scriptPubKey),
376
+ keyHash: u.keyHash,
377
+ coinbase: u.coinbase,
378
+ height: u.height,
379
+ })),
380
+ pending: [...this.pending]
381
+ .map((k) => {
382
+ const [txid, vout] = k.split(':');
383
+ return { txid, vout: Number(vout) };
384
+ })
385
+ .sort((a, b) => (a.txid < b.txid ? -1 : a.txid > b.txid ? 1 : a.vout - b.vout)),
386
+ });
387
+ }
388
+ /**
389
+ * Restore a wallet from {@link save} output: re-derives keys from `seed`
390
+ * (same network/account/gap as saved) and restores scan position, UTXOs,
391
+ * and reservations. Throws if the state is malformed or does not belong to
392
+ * this seed.
393
+ */
394
+ static load(seed, state) {
395
+ let s;
396
+ try {
397
+ s = JSON.parse(state);
398
+ }
399
+ catch {
400
+ throw new Error('wallet state is not valid JSON');
401
+ }
402
+ if (s === null || typeof s !== 'object')
403
+ throw new Error('wallet state is not an object');
404
+ if (s.version !== 1)
405
+ throw new Error(`unsupported wallet state version ${s.version}`);
406
+ if (s.network !== 'mainnet' && s.network !== 'testnet') {
407
+ throw new Error('wallet state has an invalid network');
408
+ }
409
+ const isCount = (v) => Number.isSafeInteger(v) && v >= 0;
410
+ if (!isCount(s.account) || !isCount(s.gap) || !isCount(s.nextExternal) || !isCount(s.nextChange) || !isCount(s.lastScanned)) {
411
+ throw new Error('wallet state has invalid counters');
412
+ }
413
+ // Bound attacker-controlled derivation work: load() re-derives 2*gap keys,
414
+ // so an oversized gap in a hostile state file is a hang-on-load DoS.
415
+ // account must fit a hardened BIP32 index.
416
+ if (s.gap > 10_000)
417
+ throw new Error('wallet state gap exceeds the supported maximum (10000)');
418
+ if (s.account >= 0x80000000)
419
+ throw new Error('wallet state account exceeds the BIP32 hardened range');
420
+ if (s.lastScannedHash !== null && typeof s.lastScannedHash !== 'string') {
421
+ throw new Error('wallet state has an invalid last-scanned hash');
422
+ }
423
+ if (!Array.isArray(s.utxos) || !Array.isArray(s.pending)) {
424
+ throw new Error('wallet state has invalid utxo or pending lists');
425
+ }
426
+ const w = TransparentWallet.create(seed, s.network, s.account, s.gap);
427
+ w.nextExternal = s.nextExternal;
428
+ w.nextChange = s.nextChange;
429
+ w.lastScanned = s.lastScanned;
430
+ w.lastScannedHash = s.lastScannedHash;
431
+ const isTxid = (v) => typeof v === 'string' && /^[0-9a-fA-F]{64}$/.test(v);
432
+ for (const u of s.utxos) {
433
+ if (!isTxid(u?.txid) || !isCount(u.vout) ||
434
+ !Number.isSafeInteger(u.amount) || u.amount < 0 ||
435
+ typeof u.scriptPubKey !== 'string' || !isHex(u.scriptPubKey) ||
436
+ typeof u.keyHash !== 'string' || typeof u.coinbase !== 'boolean' || !isCount(u.height)) {
437
+ throw new Error('wallet state contains a malformed utxo');
438
+ }
439
+ if (!w.keys.has(u.keyHash)) {
440
+ throw new Error('wallet state does not match seed: utxo key hash is not derived from it');
441
+ }
442
+ const script = fromHex(u.scriptPubKey);
443
+ // The scriptPubKey must actually pay the claimed key: otherwise a
444
+ // hostile state file could make buildSend sign an arbitrary foreign
445
+ // script (used verbatim as the sighash scriptCode) with our key.
446
+ if (ownedScriptHash(script) !== u.keyHash) {
447
+ throw new Error('wallet state contains a utxo whose script does not pay its key hash');
448
+ }
449
+ w.utxos.set(`${u.txid}:${u.vout}`, {
450
+ txid: u.txid,
451
+ vout: u.vout,
452
+ amount: u.amount,
453
+ scriptPubKey: script,
454
+ keyHash: u.keyHash,
455
+ coinbase: u.coinbase,
456
+ height: u.height,
457
+ });
458
+ }
459
+ for (const p of s.pending) {
460
+ if (!isTxid(p?.txid) || !isCount(p.vout)) {
461
+ throw new Error('wallet state contains a malformed pending entry');
462
+ }
463
+ w.pending.add(`${p.txid}:${p.vout}`);
464
+ }
465
+ return w;
231
466
  }
232
467
  }
233
468
  export { scriptPubKeyForAddress };
package/dist/types.d.ts CHANGED
@@ -90,6 +90,14 @@ export interface SyncOptions {
90
90
  */
91
91
  rpcConcurrency?: number;
92
92
  onProgress?: (height: number, tip: number) => void;
93
+ /**
94
+ * Abort the sync. Checked at every batch and concurrency-chunk boundary,
95
+ * before the next round of RPCs is issued; when set, sync throws
96
+ * `signal.reason` (an `AbortError` DOMException by default). State stays
97
+ * consistent: only fully applied, root-verified batches are kept, and the
98
+ * busy guard is released so a follow-up sync can resume where it stopped.
99
+ */
100
+ signal?: AbortSignal;
93
101
  }
94
102
  /** Serialized wallet state (spending key deliberately excluded). */
95
103
  export interface WalletState {
package/dist/wallet.d.ts CHANGED
@@ -60,6 +60,15 @@ export declare class PivxWallet {
60
60
  recipient: string;
61
61
  value: number;
62
62
  } | undefined;
63
+ /**
64
+ * Remove every nullifier-map entry that is no longer referenced by a
65
+ * currently tracked unspent note or by a pending spend, and return the
66
+ * number removed. Explicit and opt-in: the map is what powers
67
+ * {@link getNoteFromNullifier}, so callers using nullifier → note
68
+ * attribution should call this only after reconciling the spends they
69
+ * care about. Deterministic; the save/load format is unchanged.
70
+ */
71
+ pruneNullifiers(): number;
63
72
  /**
64
73
  * Scan blocks (strictly ascending heights, all above the last synced
65
74
  * block). Returns the raw hexes of transactions relevant to this wallet.
package/dist/wallet.js CHANGED
@@ -66,8 +66,8 @@ export class ScanDivergedError extends Error {
66
66
  localRoot;
67
67
  nodeRoot;
68
68
  constructor(height, localRoot, nodeRoot) {
69
- super(`sapling root mismatch at height ${height}: wallet state is corrupt or the node is on another chain; ` +
70
- 'recreate the wallet from its keys and resync');
69
+ super(`scan diverged at height ${height}: wallet state is corrupt or the node is on another chain; ` +
70
+ 'recreate the wallet from its keys (or resetScan for a transparent wallet) and resync');
71
71
  this.height = height;
72
72
  this.localRoot = localRoot;
73
73
  this.nodeRoot = nodeRoot;
@@ -202,6 +202,28 @@ export class PivxWallet {
202
202
  getNoteFromNullifier(nullifier) {
203
203
  return this.nullifierMap.get(nullifier);
204
204
  }
205
+ /**
206
+ * Remove every nullifier-map entry that is no longer referenced by a
207
+ * currently tracked unspent note or by a pending spend, and return the
208
+ * number removed. Explicit and opt-in: the map is what powers
209
+ * {@link getNoteFromNullifier}, so callers using nullifier → note
210
+ * attribution should call this only after reconciling the spends they
211
+ * care about. Deterministic; the save/load format is unchanged.
212
+ */
213
+ pruneNullifiers() {
214
+ const live = new Set(this.notes.map((n) => n.nullifier));
215
+ for (const nulls of this.pendingSpends.values())
216
+ for (const n of nulls)
217
+ live.add(n);
218
+ let removed = 0;
219
+ for (const nullifier of this.nullifierMap.keys()) {
220
+ if (!live.has(nullifier)) {
221
+ this.nullifierMap.delete(nullifier);
222
+ removed++;
223
+ }
224
+ }
225
+ return removed;
226
+ }
205
227
  // ── Scanning ──────────────────────────────────────────────────────────────
206
228
  /**
207
229
  * Scan blocks (strictly ascending heights, all above the last synced
@@ -286,6 +308,11 @@ export class PivxWallet {
286
308
  throw new Error('wallet is busy: another sync or spend is in progress');
287
309
  this.busy = true;
288
310
  try {
311
+ const throwIfAborted = () => {
312
+ if (opts.signal?.aborted) {
313
+ throw opts.signal.reason ?? new DOMException('sync aborted', 'AbortError');
314
+ }
315
+ };
289
316
  // NaN/0/fractional → sane integer; 0 would loop forever.
290
317
  const batchSize = Math.max(1, Math.floor(opts.batchSize ?? 100) || 1);
291
318
  // getblock verbosity 2 is heavy. A default node has 4 RPC threads and a
@@ -306,11 +333,13 @@ export class PivxWallet {
306
333
  return block;
307
334
  };
308
335
  while (this.lastProcessedBlock < tip) {
336
+ throwIfAborted(); // batch boundary: nothing applied yet, state consistent
309
337
  const from = this.lastProcessedBlock + 1;
310
338
  const to = Math.min(from + batchSize - 1, tip);
311
339
  const heights = Array.from({ length: to - from + 1 }, (_, i) => from + i);
312
340
  const blocks = [];
313
341
  for (let i = 0; i < heights.length; i += concurrency) {
342
+ throwIfAborted(); // chunk boundary: before issuing the next RPCs
314
343
  blocks.push(...(await Promise.all(heights.slice(i, i + concurrency).map(fetchBlock))));
315
344
  }
316
345
  // Snapshot so a failed root check can't leave partial state behind.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pivx-wallet",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
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.1.0",
47
+ "pivx-rpc": "^0.2.0",
48
48
  "pivx-shield-rust": "^1.4.0"
49
49
  },
50
50
  "optionalDependencies": {