pivx-wallet 0.1.0 → 0.2.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,19 +70,37 @@ 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.
@@ -78,14 +110,23 @@ export declare class TransparentWallet {
78
110
  batchSize?: number;
79
111
  onProgress?: (height: number, tip: number) => void;
80
112
  }): Promise<void>;
81
- /** Total tracked transparent balance in satoshis. */
113
+ /**
114
+ * Total spendable transparent balance in satoshis. Outputs reserved by
115
+ * {@link buildSend} are excluded (like the shield wallet's pending-note
116
+ * exclusion); {@link getUtxos} still lists them.
117
+ */
82
118
  balance(): number;
119
+ /** All tracked UTXOs, including ones reserved by {@link buildSend} (unlike {@link balance}). */
83
120
  getUtxos(): readonly OwnedUtxo[];
84
121
  private static estSize;
85
122
  /**
86
123
  * Build and sign a transparent send of `amount` sats to `to`, selecting
87
124
  * UTXOs largest-first with change to a fresh change address. `feePerByte`
88
125
  * defaults to 100 sats/byte. Returns the raw tx hex and the spent inputs.
126
+ *
127
+ * The selected inputs are reserved: a later buildSend will not select them
128
+ * again until {@link markSpent} (broadcast succeeded) or {@link release}
129
+ * (broadcast definitively rejected) resolves them.
89
130
  */
90
131
  buildSend(to: string, amount: number, feePerByte?: number): {
91
132
  hex: string;
@@ -94,10 +135,32 @@ export declare class TransparentWallet {
94
135
  vout: number;
95
136
  }[];
96
137
  };
97
- /** Mark inputs spent after a successful broadcast. */
138
+ /** Mark inputs spent after a successful broadcast (drops them and their reservation). */
98
139
  markSpent(spent: {
99
140
  txid: string;
100
141
  vout: number;
101
142
  }[]): void;
143
+ /**
144
+ * Release inputs reserved by {@link buildSend} after a definitively
145
+ * rejected broadcast: they become selectable again. On an ambiguous failure
146
+ * (timeout), keep them reserved until the transaction confirms or clearly
147
+ * disappears.
148
+ */
149
+ release(spent: {
150
+ txid: string;
151
+ vout: number;
152
+ }[]): void;
153
+ /**
154
+ * Serialize wallet state to JSON (cross-SDK state format, version 1). No
155
+ * key material is included — restore with {@link load} and the seed.
156
+ */
157
+ save(): string;
158
+ /**
159
+ * Restore a wallet from {@link save} output: re-derives keys from `seed`
160
+ * (same network/account/gap as saved) and restores scan position, UTXOs,
161
+ * and reservations. Throws if the state is malformed or does not belong to
162
+ * this seed.
163
+ */
164
+ static load(seed: Uint8Array, state: string): TransparentWallet;
102
165
  }
103
166
  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,87 @@ 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
210
  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);
211
+ if (this.busy)
212
+ throw new Error('wallet is busy: another sync is in progress');
213
+ this.busy = true;
214
+ try {
215
+ const concurrency = 8;
216
+ const tip = await client.getBlockCount();
217
+ const fetchBlock = async (h) => client.getBlock(await client.getBlockHash(h), 2);
218
+ // NaN/0/fractional sane integer: 0 would loop forever and fractional
219
+ // heights would skip blocks (matches Rust batch.max(1)).
220
+ const batch = Math.max(1, Math.floor(batchSize) || 1);
221
+ let from = Math.max(fromHeight, this.lastScanned + 1);
222
+ while (from <= tip) {
223
+ const to = Math.min(from + batch - 1, tip);
224
+ const heights = Array.from({ length: to - from + 1 }, (_, i) => from + i);
225
+ for (let i = 0; i < heights.length; i += concurrency) {
226
+ const blocks = await Promise.all(heights.slice(i, i + concurrency).map(fetchBlock));
227
+ for (const b of blocks) {
228
+ // getblock verbosity 2 always carries these; a block without them
229
+ // would silently disable the reorg continuity check, so treat it
230
+ // as a malformed node response rather than scanning past it.
231
+ const block = b;
232
+ if (typeof block?.hash !== 'string' || typeof block?.previousblockhash !== 'string') {
233
+ throw new Error(`node returned a block without hash/previousblockhash at height ${block?.height}`);
234
+ }
235
+ this.scanBlock(block);
236
+ }
237
+ }
238
+ onProgress?.(to, tip);
239
+ from = to + 1;
153
240
  }
154
- onProgress?.(to, tip);
155
- from = to + 1;
241
+ }
242
+ finally {
243
+ this.busy = false;
156
244
  }
157
245
  }
158
- /** Total tracked transparent balance in satoshis. */
246
+ /**
247
+ * Total spendable transparent balance in satoshis. Outputs reserved by
248
+ * {@link buildSend} are excluded (like the shield wallet's pending-note
249
+ * exclusion); {@link getUtxos} still lists them.
250
+ */
159
251
  balance() {
160
- return [...this.utxos.values()].reduce((s, u) => s + u.amount, 0);
252
+ return [...this.utxos.values()].reduce((s, u) => (this.pending.has(`${u.txid}:${u.vout}`) ? s : s + u.amount), 0);
161
253
  }
254
+ /** All tracked UTXOs, including ones reserved by {@link buildSend} (unlike {@link balance}). */
162
255
  getUtxos() {
163
256
  return [...this.utxos.values()];
164
257
  }
@@ -169,6 +262,10 @@ export class TransparentWallet {
169
262
  * Build and sign a transparent send of `amount` sats to `to`, selecting
170
263
  * UTXOs largest-first with change to a fresh change address. `feePerByte`
171
264
  * defaults to 100 sats/byte. Returns the raw tx hex and the spent inputs.
265
+ *
266
+ * The selected inputs are reserved: a later buildSend will not select them
267
+ * again until {@link markSpent} (broadcast succeeded) or {@link release}
268
+ * (broadcast definitively rejected) resolves them.
172
269
  */
173
270
  buildSend(to, amount, feePerByte = 100) {
174
271
  if (!Number.isSafeInteger(amount) || amount <= 0)
@@ -188,10 +285,12 @@ export class TransparentWallet {
188
285
  if (amount < dustThreshold(toScript.length))
189
286
  throw new Error('amount is below the dust threshold');
190
287
  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).
288
+ // Exclude reserved outpoints (awaiting markSpent/release) and immature
289
+ // coinbase/coinstake outputs: the node rejects a spend of one before
290
+ // nCoinbaseMaturity confirmations (depth vs. last scanned block).
193
291
  const maturity = coinbaseMaturity(this.network);
194
292
  const avail = [...this.utxos.values()]
293
+ .filter((u) => !this.pending.has(`${u.txid}:${u.vout}`))
195
294
  .filter((u) => !(u.coinbase && this.lastScanned - u.height + 1 < maturity))
196
295
  .sort((a, b) => b.amount - a.amount);
197
296
  const selected = [];
@@ -211,7 +310,7 @@ export class TransparentWallet {
211
310
  // the tx is rejected as dust) and the fee to later spend the change input.
212
311
  // Change is always P2PKH (25-byte script).
213
312
  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');
313
+ const chAddr = encodeAddress(fromHex(this.nextChangeHash()), this.network, 'p2pkh');
215
314
  outputs.push({ address: chAddr, amount: changeVal });
216
315
  }
217
316
  const inputs = selected.map((u) => ({
@@ -222,12 +321,142 @@ export class TransparentWallet {
222
321
  privateKey: this.keys.get(u.keyHash),
223
322
  }));
224
323
  const spent = selected.map((u) => ({ txid: u.txid, vout: u.vout }));
225
- return { hex: buildTransparentTx(inputs, outputs, 0), spent };
324
+ const rawHex = buildTransparentTx(inputs, outputs, 0);
325
+ for (const s of spent)
326
+ this.pending.add(`${s.txid}:${s.vout}`);
327
+ return { hex: rawHex, spent };
226
328
  }
227
- /** Mark inputs spent after a successful broadcast. */
329
+ /** Mark inputs spent after a successful broadcast (drops them and their reservation). */
228
330
  markSpent(spent) {
229
- for (const s of spent)
331
+ for (const s of spent) {
230
332
  this.utxos.delete(`${s.txid}:${s.vout}`);
333
+ this.pending.delete(`${s.txid}:${s.vout}`);
334
+ }
335
+ }
336
+ /**
337
+ * Release inputs reserved by {@link buildSend} after a definitively
338
+ * rejected broadcast: they become selectable again. On an ambiguous failure
339
+ * (timeout), keep them reserved until the transaction confirms or clearly
340
+ * disappears.
341
+ */
342
+ release(spent) {
343
+ for (const s of spent)
344
+ this.pending.delete(`${s.txid}:${s.vout}`);
345
+ }
346
+ // ── Persistence ───────────────────────────────────────────────────────────
347
+ /**
348
+ * Serialize wallet state to JSON (cross-SDK state format, version 1). No
349
+ * key material is included — restore with {@link load} and the seed.
350
+ */
351
+ save() {
352
+ return JSON.stringify({
353
+ version: 1,
354
+ network: this.network,
355
+ account: this.account,
356
+ gap: this.gap,
357
+ nextExternal: this.nextExternal,
358
+ nextChange: this.nextChange,
359
+ lastScanned: this.lastScanned,
360
+ lastScannedHash: this.lastScannedHash,
361
+ // Sorted by (txid, vout) so save() output is deterministic and
362
+ // byte-comparable with the Rust SDK's.
363
+ utxos: [...this.utxos.values()]
364
+ .sort((a, b) => (a.txid < b.txid ? -1 : a.txid > b.txid ? 1 : a.vout - b.vout))
365
+ .map((u) => ({
366
+ txid: u.txid,
367
+ vout: u.vout,
368
+ amount: u.amount,
369
+ scriptPubKey: hex(u.scriptPubKey),
370
+ keyHash: u.keyHash,
371
+ coinbase: u.coinbase,
372
+ height: u.height,
373
+ })),
374
+ pending: [...this.pending]
375
+ .map((k) => {
376
+ const [txid, vout] = k.split(':');
377
+ return { txid, vout: Number(vout) };
378
+ })
379
+ .sort((a, b) => (a.txid < b.txid ? -1 : a.txid > b.txid ? 1 : a.vout - b.vout)),
380
+ });
381
+ }
382
+ /**
383
+ * Restore a wallet from {@link save} output: re-derives keys from `seed`
384
+ * (same network/account/gap as saved) and restores scan position, UTXOs,
385
+ * and reservations. Throws if the state is malformed or does not belong to
386
+ * this seed.
387
+ */
388
+ static load(seed, state) {
389
+ let s;
390
+ try {
391
+ s = JSON.parse(state);
392
+ }
393
+ catch {
394
+ throw new Error('wallet state is not valid JSON');
395
+ }
396
+ if (s === null || typeof s !== 'object')
397
+ throw new Error('wallet state is not an object');
398
+ if (s.version !== 1)
399
+ throw new Error(`unsupported wallet state version ${s.version}`);
400
+ if (s.network !== 'mainnet' && s.network !== 'testnet') {
401
+ throw new Error('wallet state has an invalid network');
402
+ }
403
+ const isCount = (v) => Number.isSafeInteger(v) && v >= 0;
404
+ if (!isCount(s.account) || !isCount(s.gap) || !isCount(s.nextExternal) || !isCount(s.nextChange) || !isCount(s.lastScanned)) {
405
+ throw new Error('wallet state has invalid counters');
406
+ }
407
+ // Bound attacker-controlled derivation work: load() re-derives 2*gap keys,
408
+ // so an oversized gap in a hostile state file is a hang-on-load DoS.
409
+ // account must fit a hardened BIP32 index.
410
+ if (s.gap > 10_000)
411
+ throw new Error('wallet state gap exceeds the supported maximum (10000)');
412
+ if (s.account >= 0x80000000)
413
+ throw new Error('wallet state account exceeds the BIP32 hardened range');
414
+ if (s.lastScannedHash !== null && typeof s.lastScannedHash !== 'string') {
415
+ throw new Error('wallet state has an invalid last-scanned hash');
416
+ }
417
+ if (!Array.isArray(s.utxos) || !Array.isArray(s.pending)) {
418
+ throw new Error('wallet state has invalid utxo or pending lists');
419
+ }
420
+ const w = TransparentWallet.create(seed, s.network, s.account, s.gap);
421
+ w.nextExternal = s.nextExternal;
422
+ w.nextChange = s.nextChange;
423
+ w.lastScanned = s.lastScanned;
424
+ w.lastScannedHash = s.lastScannedHash;
425
+ const isTxid = (v) => typeof v === 'string' && /^[0-9a-fA-F]{64}$/.test(v);
426
+ for (const u of s.utxos) {
427
+ if (!isTxid(u?.txid) || !isCount(u.vout) ||
428
+ !Number.isSafeInteger(u.amount) || u.amount < 0 ||
429
+ typeof u.scriptPubKey !== 'string' || !isHex(u.scriptPubKey) ||
430
+ typeof u.keyHash !== 'string' || typeof u.coinbase !== 'boolean' || !isCount(u.height)) {
431
+ throw new Error('wallet state contains a malformed utxo');
432
+ }
433
+ if (!w.keys.has(u.keyHash)) {
434
+ throw new Error('wallet state does not match seed: utxo key hash is not derived from it');
435
+ }
436
+ const script = fromHex(u.scriptPubKey);
437
+ // The scriptPubKey must actually pay the claimed key: otherwise a
438
+ // hostile state file could make buildSend sign an arbitrary foreign
439
+ // script (used verbatim as the sighash scriptCode) with our key.
440
+ if (ownedScriptHash(script) !== u.keyHash) {
441
+ throw new Error('wallet state contains a utxo whose script does not pay its key hash');
442
+ }
443
+ w.utxos.set(`${u.txid}:${u.vout}`, {
444
+ txid: u.txid,
445
+ vout: u.vout,
446
+ amount: u.amount,
447
+ scriptPubKey: script,
448
+ keyHash: u.keyHash,
449
+ coinbase: u.coinbase,
450
+ height: u.height,
451
+ });
452
+ }
453
+ for (const p of s.pending) {
454
+ if (!isTxid(p?.txid) || !isCount(p.vout)) {
455
+ throw new Error('wallet state contains a malformed pending entry');
456
+ }
457
+ w.pending.add(`${p.txid}:${p.vout}`);
458
+ }
459
+ return w;
231
460
  }
232
461
  }
233
462
  export { scriptPubKeyForAddress };
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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pivx-wallet",
3
- "version": "0.1.0",
3
+ "version": "0.2.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": {