pivx-wallet 0.1.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.
- package/LICENSE +22 -0
- package/README.md +36 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +4 -0
- package/dist/shield-bindings.d.ts +29 -0
- package/dist/shield-bindings.js +65 -0
- package/dist/transparent-tx.d.ts +27 -0
- package/dist/transparent-tx.js +132 -0
- package/dist/transparent-wallet.d.ts +103 -0
- package/dist/transparent-wallet.js +233 -0
- package/dist/transparent.d.ts +31 -0
- package/dist/transparent.js +82 -0
- package/dist/types.d.ts +109 -0
- package/dist/types.js +4 -0
- package/dist/wallet.d.ts +161 -0
- package/dist/wallet.js +662 -0
- package/package.json +57 -0
package/dist/wallet.js
ADDED
|
@@ -0,0 +1,662 @@
|
|
|
1
|
+
import { RpcError } from 'pivx-rpc';
|
|
2
|
+
import { loadShield } from './shield-bindings.js';
|
|
3
|
+
/** PIVX BIP44 coin types. */
|
|
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 };
|
|
8
|
+
/** A note worth no more than its own input fee (sapling input 384 bytes ×
|
|
9
|
+
* 1000 sats/byte) never helps cover amount+fee, so it is never spent. */
|
|
10
|
+
const DUST_NOTE_SATS = 384_000;
|
|
11
|
+
/**
|
|
12
|
+
* Mirror of the Rust/WASM note selection and fee model: consume notes
|
|
13
|
+
* smallest-first, growing the fee per sapling input, and report whether the
|
|
14
|
+
* inputs cover amount + fee. Used to refuse a send that would otherwise have
|
|
15
|
+
* the fee silently taken from the recipient.
|
|
16
|
+
*/
|
|
17
|
+
function estimateShieldSelection(notes, amount, toIsShield) {
|
|
18
|
+
const tOut = toIsShield ? 0 : 1;
|
|
19
|
+
const feeFor = (sIn) => 1000 * (2 * 948 + sIn * 384 + tOut * 34 + 85);
|
|
20
|
+
const sorted = [...notes].sort((a, b) => a.note.value - b.note.value);
|
|
21
|
+
let total = 0;
|
|
22
|
+
let sIn = 0;
|
|
23
|
+
let fee = feeFor(0);
|
|
24
|
+
for (const n of sorted) {
|
|
25
|
+
sIn++;
|
|
26
|
+
fee = feeFor(sIn);
|
|
27
|
+
total += n.note.value;
|
|
28
|
+
if (total >= amount + fee)
|
|
29
|
+
break;
|
|
30
|
+
}
|
|
31
|
+
return { fee, sufficient: total >= amount + fee };
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Same model for the transparent-input (shielding) path: consume UTXOs
|
|
35
|
+
* smallest-first with a per-transparent-input fee and a transparent change
|
|
36
|
+
* output, and report whether they cover amount + fee. The WASM's utxo
|
|
37
|
+
* selection has the same silent-underpay behavior as its note path.
|
|
38
|
+
*/
|
|
39
|
+
function estimateTransparentSelection(utxos, amount, toIsShield) {
|
|
40
|
+
// Recipient (shield → 0 transparent outputs, else 1) plus a transparent change output.
|
|
41
|
+
const tOut = (toIsShield ? 0 : 1) + 1;
|
|
42
|
+
const feeFor = (tIn) => 1000 * (2 * 948 + tIn * 150 + tOut * 34 + 85);
|
|
43
|
+
const sorted = [...utxos].sort((a, b) => a.amount - b.amount);
|
|
44
|
+
let total = 0;
|
|
45
|
+
let tIn = 0;
|
|
46
|
+
let fee = feeFor(0);
|
|
47
|
+
for (const u of sorted) {
|
|
48
|
+
tIn++;
|
|
49
|
+
fee = feeFor(tIn);
|
|
50
|
+
total += u.amount;
|
|
51
|
+
if (total >= amount + fee)
|
|
52
|
+
break;
|
|
53
|
+
}
|
|
54
|
+
return { fee, sufficient: total >= amount + fee };
|
|
55
|
+
}
|
|
56
|
+
/** Thrown when a watch-only wallet is asked to spend. */
|
|
57
|
+
export class NoSpendAuthorityError extends Error {
|
|
58
|
+
constructor() {
|
|
59
|
+
super('wallet is watch-only (viewing key): load a spending key to spend');
|
|
60
|
+
this.name = 'NoSpendAuthorityError';
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/** Thrown when the local commitment tree diverges from the node's sapling root. */
|
|
64
|
+
export class ScanDivergedError extends Error {
|
|
65
|
+
height;
|
|
66
|
+
localRoot;
|
|
67
|
+
nodeRoot;
|
|
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');
|
|
71
|
+
this.height = height;
|
|
72
|
+
this.localRoot = localRoot;
|
|
73
|
+
this.nodeRoot = nodeRoot;
|
|
74
|
+
this.name = 'ScanDivergedError';
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/** Reverse hex byte order (node displays sapling roots like txids, byte-reversed). */
|
|
78
|
+
const reverseHex = (hex) => (hex.match(/../g) ?? []).reverse().join('');
|
|
79
|
+
/** Guard the money path against non-numeric note values from tampered state. */
|
|
80
|
+
const assertSats = (v) => {
|
|
81
|
+
if (!Number.isSafeInteger(v))
|
|
82
|
+
throw new Error(`note value is not a valid satoshi amount: ${v}`);
|
|
83
|
+
return v;
|
|
84
|
+
};
|
|
85
|
+
// Only sapling-capable (version 3) transactions carry shield outputs. Feeding
|
|
86
|
+
// anything else to the scanner is wasted work, and the parser rejects some
|
|
87
|
+
// legacy transactions outright. PIVX sapling txs serialize with a 4-byte
|
|
88
|
+
// little-endian version of 3, so the hex starts with "03". This assumes
|
|
89
|
+
// version 3 is the only shielded version; revisit if that ever changes.
|
|
90
|
+
const isSaplingTx = (hex) => hex.startsWith('03');
|
|
91
|
+
/**
|
|
92
|
+
* Standalone PIVX wallet: owns keys, scans blocks, tracks shielded notes,
|
|
93
|
+
* and builds fully-proved transactions locally. A node (via `pivx-rpc`) is
|
|
94
|
+
* only used as a chain-data source and broadcast endpoint.
|
|
95
|
+
*
|
|
96
|
+
* Capabilities follow the key material: constructed from a seed or spending
|
|
97
|
+
* key the wallet can spend; from a viewing key it can scan, derive receive
|
|
98
|
+
* addresses, and track balance (watch-only) — and can be upgraded in place
|
|
99
|
+
* with {@link loadSpendingKey}.
|
|
100
|
+
*/
|
|
101
|
+
export class PivxWallet {
|
|
102
|
+
shield;
|
|
103
|
+
network;
|
|
104
|
+
extfvk;
|
|
105
|
+
commitmentTree;
|
|
106
|
+
lastProcessedBlock;
|
|
107
|
+
extsk;
|
|
108
|
+
notes = [];
|
|
109
|
+
nullifierMap = new Map();
|
|
110
|
+
/** txid → nullifiers awaiting broadcast confirmation. Persisted, so a
|
|
111
|
+
* crash between broadcast and finalize can't resurrect spent notes. */
|
|
112
|
+
pendingSpends = new Map();
|
|
113
|
+
diversifierIndex;
|
|
114
|
+
/** One writer at a time: block state-mutating operations from racing. */
|
|
115
|
+
busy = false;
|
|
116
|
+
/** Whether the starting checkpoint has been confirmed against the node. */
|
|
117
|
+
startValidated = false;
|
|
118
|
+
constructor(shield, network, extfvk, commitmentTree, lastProcessedBlock, diversifierIndex, extsk) {
|
|
119
|
+
this.shield = shield;
|
|
120
|
+
this.network = network;
|
|
121
|
+
this.extfvk = extfvk;
|
|
122
|
+
this.commitmentTree = commitmentTree;
|
|
123
|
+
this.lastProcessedBlock = lastProcessedBlock;
|
|
124
|
+
this.extsk = extsk;
|
|
125
|
+
this.diversifierIndex = diversifierIndex;
|
|
126
|
+
}
|
|
127
|
+
get isTestnet() {
|
|
128
|
+
return this.network === 'testnet';
|
|
129
|
+
}
|
|
130
|
+
/** True when the wallet holds spend authority. */
|
|
131
|
+
get canSpend() {
|
|
132
|
+
return this.extsk !== undefined;
|
|
133
|
+
}
|
|
134
|
+
static async create(opts) {
|
|
135
|
+
const network = opts.network ?? 'mainnet';
|
|
136
|
+
const provided = [opts.seed, opts.spendingKey, opts.viewingKey].filter((k) => k !== undefined);
|
|
137
|
+
if (provided.length !== 1) {
|
|
138
|
+
throw new Error('provide exactly one of: seed, spendingKey, viewingKey');
|
|
139
|
+
}
|
|
140
|
+
const shield = await loadShield(opts.proving);
|
|
141
|
+
const isTestnet = network === 'testnet';
|
|
142
|
+
let extsk;
|
|
143
|
+
if (opts.seed) {
|
|
144
|
+
if (opts.seed.length !== 32)
|
|
145
|
+
throw new Error('seed must be 32 bytes');
|
|
146
|
+
extsk = shield.generate_extended_spending_key_from_seed({
|
|
147
|
+
seed: Array.from(opts.seed),
|
|
148
|
+
coin_type: COIN_TYPE[network],
|
|
149
|
+
account_index: opts.accountIndex ?? 0,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
else if (opts.spendingKey) {
|
|
153
|
+
extsk = opts.spendingKey;
|
|
154
|
+
}
|
|
155
|
+
const extfvk = extsk
|
|
156
|
+
? shield.generate_extended_full_viewing_key(extsk, isTestnet)
|
|
157
|
+
: opts.viewingKey;
|
|
158
|
+
// Resume from the checkpoint's own height, not birthHeight: the loaded
|
|
159
|
+
// tree is the committed state AT the checkpoint, so scanning must start
|
|
160
|
+
// at checkpointHeight + 1. Starting higher would leave the tree missing
|
|
161
|
+
// every shield output in the gap and diverge on the first real block.
|
|
162
|
+
const [checkpointHeight, checkpointTree] = shield.get_closest_checkpoint(opts.birthHeight, isTestnet);
|
|
163
|
+
const { diversifier_index } = shield.generate_default_payment_address(extfvk, isTestnet);
|
|
164
|
+
return new PivxWallet(shield, network, extfvk, checkpointTree, checkpointHeight, diversifier_index, extsk);
|
|
165
|
+
}
|
|
166
|
+
/** Upgrade a watch-only wallet. The key must match the stored viewing key. */
|
|
167
|
+
loadSpendingKey(spendingKey) {
|
|
168
|
+
if (this.extsk)
|
|
169
|
+
throw new Error('wallet already has a spending key');
|
|
170
|
+
const derived = this.shield.generate_extended_full_viewing_key(spendingKey, this.isTestnet);
|
|
171
|
+
if (derived !== this.extfvk) {
|
|
172
|
+
throw new Error('spending key does not match this wallet\'s viewing key');
|
|
173
|
+
}
|
|
174
|
+
this.extsk = spendingKey;
|
|
175
|
+
}
|
|
176
|
+
// ── Addresses & balance ───────────────────────────────────────────────────
|
|
177
|
+
/** Next diversified shield receive address. */
|
|
178
|
+
getNewAddress() {
|
|
179
|
+
const { address, diversifier_index } = this.shield.generate_next_shielding_payment_address(this.extfvk, new Uint8Array(this.diversifierIndex), this.isTestnet);
|
|
180
|
+
this.diversifierIndex = diversifier_index;
|
|
181
|
+
return address;
|
|
182
|
+
}
|
|
183
|
+
/** Confirmed shielded balance in satoshis (scanned notes, minus pending spends). */
|
|
184
|
+
getBalance() {
|
|
185
|
+
const pending = new Set([...this.pendingSpends.values()].flat());
|
|
186
|
+
return this.notes
|
|
187
|
+
.filter((n) => !pending.has(n.nullifier))
|
|
188
|
+
.reduce((sum, n) => sum + assertSats(n.note.value), 0);
|
|
189
|
+
}
|
|
190
|
+
/** Whether `address` is a shield (Sapling) address on this wallet's network. */
|
|
191
|
+
isShieldAddress(address) {
|
|
192
|
+
return address.startsWith(this.isTestnet ? 'ptestsapling1' : 'ps1');
|
|
193
|
+
}
|
|
194
|
+
/** Currently tracked unspent notes. */
|
|
195
|
+
getNotes() {
|
|
196
|
+
return this.notes;
|
|
197
|
+
}
|
|
198
|
+
getLastSyncedBlock() {
|
|
199
|
+
return this.lastProcessedBlock;
|
|
200
|
+
}
|
|
201
|
+
/** Look up a note by its on-chain nullifier (payment attribution for spends). */
|
|
202
|
+
getNoteFromNullifier(nullifier) {
|
|
203
|
+
return this.nullifierMap.get(nullifier);
|
|
204
|
+
}
|
|
205
|
+
// ── Scanning ──────────────────────────────────────────────────────────────
|
|
206
|
+
/**
|
|
207
|
+
* Scan blocks (strictly ascending heights, all above the last synced
|
|
208
|
+
* block). Returns the raw hexes of transactions relevant to this wallet.
|
|
209
|
+
* Use this directly when you have your own block feed; otherwise see
|
|
210
|
+
* {@link sync}.
|
|
211
|
+
*/
|
|
212
|
+
handleBlocks(blocks) {
|
|
213
|
+
if (this.busy)
|
|
214
|
+
throw new Error('wallet is busy: another sync or spend is in progress');
|
|
215
|
+
return this.applyBlocks(blocks);
|
|
216
|
+
}
|
|
217
|
+
/** handleBlocks without the busy guard, for internal use by sync (which
|
|
218
|
+
* already holds the guard). */
|
|
219
|
+
applyBlocks(blocks) {
|
|
220
|
+
let prev = this.lastProcessedBlock;
|
|
221
|
+
for (const b of blocks) {
|
|
222
|
+
if (b.height <= prev) {
|
|
223
|
+
throw new Error(`blocks must be strictly ascending and above ${this.lastProcessedBlock}`);
|
|
224
|
+
}
|
|
225
|
+
prev = b.height;
|
|
226
|
+
}
|
|
227
|
+
if (blocks.length === 0)
|
|
228
|
+
return [];
|
|
229
|
+
const result = this.shield.handle_blocks(this.commitmentTree, blocks.map((b) => ({ txs: b.txs.map((t) => t.hex).filter(isSaplingTx) })), this.extfvk, this.isTestnet, this.notes);
|
|
230
|
+
this.commitmentTree = result.commitment_tree;
|
|
231
|
+
const spent = new Set(result.nullifiers);
|
|
232
|
+
// Do not retain sub-dust notes: they are never spendable (below their own
|
|
233
|
+
// input fee), so keeping them would let a dust flood grow state and
|
|
234
|
+
// per-block scan cost without bound. Their commitment is still in the tree
|
|
235
|
+
// (appended for every output during the scan), so the root stays correct.
|
|
236
|
+
this.notes = [...result.decrypted_notes, ...result.decrypted_new_notes].filter((n) => !spent.has(n.nullifier) && n.note.value > DUST_NOTE_SATS);
|
|
237
|
+
for (const { note, nullifier } of result.decrypted_new_notes) {
|
|
238
|
+
if (note.value > DUST_NOTE_SATS) {
|
|
239
|
+
this.nullifierMap.set(nullifier, {
|
|
240
|
+
recipient: this.encodeRecipient(note.recipient),
|
|
241
|
+
value: note.value,
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
// Drop pending-spend entries whose notes are now gone (the transaction
|
|
246
|
+
// confirmed and its notes were scanned out), so pendingSpends can't leak.
|
|
247
|
+
const tracked = new Set(this.notes.map((n) => n.nullifier));
|
|
248
|
+
for (const [txid, nulls] of this.pendingSpends) {
|
|
249
|
+
if (!nulls.some((n) => tracked.has(n)))
|
|
250
|
+
this.pendingSpends.delete(txid);
|
|
251
|
+
}
|
|
252
|
+
this.lastProcessedBlock = blocks[blocks.length - 1].height;
|
|
253
|
+
return result.wallet_transactions;
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Decrypt a single transaction's outputs for this wallet without touching
|
|
257
|
+
* wallet state — a hint for 0-conf payment detection from the mempool.
|
|
258
|
+
*
|
|
259
|
+
* This only trial-decrypts; it does NOT validate the transaction (proof,
|
|
260
|
+
* double-spend, or whether it will ever confirm). Do not credit funds
|
|
261
|
+
* from a preview: dedupe on the caller's own txid and credit only from
|
|
262
|
+
* confirmed notes returned by {@link getNotes} after {@link sync}.
|
|
263
|
+
*/
|
|
264
|
+
previewTransaction(hex) {
|
|
265
|
+
if (!isSaplingTx(hex))
|
|
266
|
+
return []; // non-sapling tx has no shield outputs (and would panic the scanner)
|
|
267
|
+
const result = this.shield.handle_blocks(this.commitmentTree, [{ txs: [hex] }], this.extfvk, this.isTestnet, []);
|
|
268
|
+
return result.decrypted_new_notes.map(({ note, memo }) => ({
|
|
269
|
+
recipient: this.encodeRecipient(note.recipient),
|
|
270
|
+
value: note.value,
|
|
271
|
+
memo,
|
|
272
|
+
}));
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* Sync from the node up to its current tip.
|
|
276
|
+
*
|
|
277
|
+
* Each batch checks the locally-built tree against the node's own
|
|
278
|
+
* `finalsaplingroot`. That catches malformed or mis-ordered data from the
|
|
279
|
+
* node, but it is a self-consistency check, not chain authentication: the
|
|
280
|
+
* SDK does not validate proof-of-stake, so a dishonest node can still serve
|
|
281
|
+
* a self-consistent fabricated chain. Point this at a node you trust. See
|
|
282
|
+
* SECURITY.md.
|
|
283
|
+
*/
|
|
284
|
+
async sync(client, opts = {}) {
|
|
285
|
+
if (this.busy)
|
|
286
|
+
throw new Error('wallet is busy: another sync or spend is in progress');
|
|
287
|
+
this.busy = true;
|
|
288
|
+
try {
|
|
289
|
+
// NaN/0/fractional → sane integer; 0 would loop forever.
|
|
290
|
+
const batchSize = Math.max(1, Math.floor(opts.batchSize ?? 100) || 1);
|
|
291
|
+
// getblock verbosity 2 is heavy. A default node has 4 RPC threads and a
|
|
292
|
+
// work queue of 16, so firing a whole batch at once gets 500s. Keep the
|
|
293
|
+
// concurrent fetches well under that.
|
|
294
|
+
const concurrency = Math.max(1, opts.rpcConcurrency ?? 8);
|
|
295
|
+
const tip = await client.getBlockCount();
|
|
296
|
+
await this.ensureValidCheckpoint(client);
|
|
297
|
+
const fetchBlock = async (h) => {
|
|
298
|
+
const hash = await client.getBlockHash(h);
|
|
299
|
+
const block = (await client.getBlock(hash, 2));
|
|
300
|
+
// Trust the height we asked for, not the one the node echoes, and
|
|
301
|
+
// reject a mismatch outright — otherwise a lying node can
|
|
302
|
+
// fast-forward lastProcessedBlock past real deposits.
|
|
303
|
+
if (block.height !== h) {
|
|
304
|
+
throw new Error(`node returned block height ${block.height} for requested height ${h}`);
|
|
305
|
+
}
|
|
306
|
+
return block;
|
|
307
|
+
};
|
|
308
|
+
while (this.lastProcessedBlock < tip) {
|
|
309
|
+
const from = this.lastProcessedBlock + 1;
|
|
310
|
+
const to = Math.min(from + batchSize - 1, tip);
|
|
311
|
+
const heights = Array.from({ length: to - from + 1 }, (_, i) => from + i);
|
|
312
|
+
const blocks = [];
|
|
313
|
+
for (let i = 0; i < heights.length; i += concurrency) {
|
|
314
|
+
blocks.push(...(await Promise.all(heights.slice(i, i + concurrency).map(fetchBlock))));
|
|
315
|
+
}
|
|
316
|
+
// Snapshot so a failed root check can't leave partial state behind.
|
|
317
|
+
// Includes pendingSpends because applyBlocks reconciles it.
|
|
318
|
+
const snapshot = {
|
|
319
|
+
tree: this.commitmentTree,
|
|
320
|
+
last: this.lastProcessedBlock,
|
|
321
|
+
notes: this.notes,
|
|
322
|
+
nmap: new Map(this.nullifierMap),
|
|
323
|
+
pending: new Map(this.pendingSpends),
|
|
324
|
+
};
|
|
325
|
+
try {
|
|
326
|
+
this.applyBlocks(heights.map((h, i) => ({
|
|
327
|
+
height: h,
|
|
328
|
+
txs: blocks[i].tx.map(({ hex, txid }) => ({ hex, txid })),
|
|
329
|
+
})));
|
|
330
|
+
const nodeRoot = blocks[blocks.length - 1].finalsaplingroot;
|
|
331
|
+
// A shielded chain always has a sapling root; a missing one means
|
|
332
|
+
// the node is pre-activation or lying. Either way, refuse to
|
|
333
|
+
// advance unverified.
|
|
334
|
+
if (!nodeRoot)
|
|
335
|
+
throw new Error(`node omitted finalsaplingroot at height ${to}`);
|
|
336
|
+
const localRoot = reverseHex(this.shield.get_sapling_root(this.commitmentTree));
|
|
337
|
+
if (localRoot !== nodeRoot)
|
|
338
|
+
throw new ScanDivergedError(to, localRoot, nodeRoot);
|
|
339
|
+
}
|
|
340
|
+
catch (err) {
|
|
341
|
+
this.commitmentTree = snapshot.tree;
|
|
342
|
+
this.lastProcessedBlock = snapshot.last;
|
|
343
|
+
this.notes = snapshot.notes;
|
|
344
|
+
this.nullifierMap = snapshot.nmap;
|
|
345
|
+
this.pendingSpends = snapshot.pending;
|
|
346
|
+
throw err;
|
|
347
|
+
}
|
|
348
|
+
opts.onProgress?.(to, tip);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
finally {
|
|
352
|
+
this.busy = false;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* Confirm the starting commitment tree against the node before scanning
|
|
357
|
+
* forward. A fresh wallet begins at a bundled checkpoint; if that
|
|
358
|
+
* checkpoint's tree does not match the node's sapling root at that height
|
|
359
|
+
* (some near-tip checkpoints in the shield library are captured on stale
|
|
360
|
+
* blocks), walk back to the newest checkpoint the node does confirm. A
|
|
361
|
+
* wallet that already holds scanned notes and no longer matches is treated
|
|
362
|
+
* as diverged rather than silently rewound.
|
|
363
|
+
*/
|
|
364
|
+
async ensureValidCheckpoint(client) {
|
|
365
|
+
if (this.startValidated)
|
|
366
|
+
return;
|
|
367
|
+
const activation = SAPLING_ACTIVATION[this.network];
|
|
368
|
+
// Sapling root at height h. Above activation the node must report one;
|
|
369
|
+
// treating an omitted root as "no root" would let a node suppress this
|
|
370
|
+
// check or force an all-the-way rewind by simply withholding the field.
|
|
371
|
+
const rootAt = async (h) => {
|
|
372
|
+
if (h < activation)
|
|
373
|
+
return null;
|
|
374
|
+
const block = (await client.getBlock(await client.getBlockHash(h), 1));
|
|
375
|
+
if (!block.finalsaplingroot) {
|
|
376
|
+
throw new Error(`node omitted finalsaplingroot at height ${h} (past sapling activation)`);
|
|
377
|
+
}
|
|
378
|
+
return block.finalsaplingroot;
|
|
379
|
+
};
|
|
380
|
+
const localRoot = () => reverseHex(this.shield.get_sapling_root(this.commitmentTree));
|
|
381
|
+
const node = await rootAt(this.lastProcessedBlock);
|
|
382
|
+
if (node === null || localRoot() === node) {
|
|
383
|
+
this.startValidated = true;
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
// A rewind is only appropriate for a fresh wallet still sitting on a
|
|
387
|
+
// bundled checkpoint. A wallet that has scanned forward (past a
|
|
388
|
+
// checkpoint, or holding notes) and no longer matches is diverged —
|
|
389
|
+
// rewinding would silently discard correct progress.
|
|
390
|
+
const [nearest] = this.shield.get_closest_checkpoint(this.lastProcessedBlock, this.isTestnet);
|
|
391
|
+
const atCheckpoint = nearest === this.lastProcessedBlock;
|
|
392
|
+
if (this.notes.length > 0 || this.pendingSpends.size > 0 || !atCheckpoint) {
|
|
393
|
+
throw new ScanDivergedError(this.lastProcessedBlock, localRoot(), node);
|
|
394
|
+
}
|
|
395
|
+
let probe = this.lastProcessedBlock - 1;
|
|
396
|
+
let lastCp = this.lastProcessedBlock;
|
|
397
|
+
let adopted = false;
|
|
398
|
+
while (probe > 0) {
|
|
399
|
+
const [cpHeight, cpTree] = this.shield.get_closest_checkpoint(probe, this.isTestnet);
|
|
400
|
+
if (cpHeight >= lastCp)
|
|
401
|
+
break; // no older checkpoint available
|
|
402
|
+
lastCp = cpHeight;
|
|
403
|
+
const nodeRoot = await rootAt(cpHeight);
|
|
404
|
+
const cpRoot = reverseHex(this.shield.get_sapling_root(cpTree));
|
|
405
|
+
if (nodeRoot === null || cpRoot === nodeRoot) {
|
|
406
|
+
this.commitmentTree = cpTree;
|
|
407
|
+
this.lastProcessedBlock = cpHeight;
|
|
408
|
+
adopted = true;
|
|
409
|
+
break;
|
|
410
|
+
}
|
|
411
|
+
probe = cpHeight - 1;
|
|
412
|
+
}
|
|
413
|
+
// No bundled checkpoint matched the node: do not proceed on an unconfirmed
|
|
414
|
+
// tree. Surface it rather than silently "validating".
|
|
415
|
+
if (!adopted)
|
|
416
|
+
throw new ScanDivergedError(this.lastProcessedBlock, localRoot(), node);
|
|
417
|
+
this.startValidated = true;
|
|
418
|
+
}
|
|
419
|
+
/**
|
|
420
|
+
* Reset scan state to the checkpoint at or below `height` and drop all
|
|
421
|
+
* tracked notes. This is the recovery path after a divergence error: call
|
|
422
|
+
* it, then sync again. It needs no keys.
|
|
423
|
+
*/
|
|
424
|
+
reloadFromCheckpoint(height) {
|
|
425
|
+
if (this.busy)
|
|
426
|
+
throw new Error('wallet is busy');
|
|
427
|
+
const [cpHeight, cpTree] = this.shield.get_closest_checkpoint(height, this.isTestnet);
|
|
428
|
+
this.commitmentTree = cpTree;
|
|
429
|
+
this.lastProcessedBlock = cpHeight;
|
|
430
|
+
this.notes = [];
|
|
431
|
+
this.nullifierMap = new Map();
|
|
432
|
+
this.pendingSpends = new Map();
|
|
433
|
+
this.startValidated = false; // re-confirm the checkpoint on the next sync
|
|
434
|
+
}
|
|
435
|
+
// ── Spending ──────────────────────────────────────────────────────────────
|
|
436
|
+
/** Load sapling proving parameters (required once before building transactions). */
|
|
437
|
+
async loadProver(source) {
|
|
438
|
+
let ok;
|
|
439
|
+
if ('path' in source) {
|
|
440
|
+
const { readFile } = await import('node:fs/promises');
|
|
441
|
+
const { join } = await import('node:path');
|
|
442
|
+
const [output, spend] = await Promise.all([
|
|
443
|
+
readFile(join(source.path, 'sapling-output.params')),
|
|
444
|
+
readFile(join(source.path, 'sapling-spend.params')),
|
|
445
|
+
]);
|
|
446
|
+
ok = await this.shield.load_prover_with_bytes(output, spend);
|
|
447
|
+
}
|
|
448
|
+
else if ('url' in source) {
|
|
449
|
+
ok = await this.shield.load_prover_with_url(source.url);
|
|
450
|
+
}
|
|
451
|
+
else {
|
|
452
|
+
ok = await this.shield.load_prover_with_bytes(source.output, source.spend);
|
|
453
|
+
}
|
|
454
|
+
if (!ok)
|
|
455
|
+
throw new Error('failed to load sapling proving parameters');
|
|
456
|
+
}
|
|
457
|
+
/**
|
|
458
|
+
* Build and prove a transaction locally. Nothing is broadcast; the spent
|
|
459
|
+
* notes are held as pending until {@link finalizeTransaction} or
|
|
460
|
+
* {@link discardTransaction}.
|
|
461
|
+
*/
|
|
462
|
+
async createTransaction(opts) {
|
|
463
|
+
if (!this.extsk)
|
|
464
|
+
throw new NoSpendAuthorityError();
|
|
465
|
+
if (!Number.isSafeInteger(opts.amount) || opts.amount <= 0) {
|
|
466
|
+
throw new Error('amount must be a positive integer number of satoshis');
|
|
467
|
+
}
|
|
468
|
+
if (opts.memo !== undefined && new TextEncoder().encode(opts.memo).length > 512) {
|
|
469
|
+
throw new Error('memo must be at most 512 bytes');
|
|
470
|
+
}
|
|
471
|
+
const useShield = opts.inputs === undefined || opts.inputs === 'shield';
|
|
472
|
+
if (!useShield && !opts.transparentChangeAddress) {
|
|
473
|
+
throw new Error('transparentChangeAddress is required when spending transparent inputs');
|
|
474
|
+
}
|
|
475
|
+
// Validate transparent inputs. Amounts are satoshis here; a caller wiring
|
|
476
|
+
// pivx-rpc's PIV-float listUnspent straight in (a natural mistake) would
|
|
477
|
+
// otherwise donate the difference to fees.
|
|
478
|
+
if (!useShield) {
|
|
479
|
+
for (const u of opts.inputs) {
|
|
480
|
+
if (!Number.isSafeInteger(u.amount) || u.amount < 0) {
|
|
481
|
+
throw new Error('transparent input amount must be a non-negative integer (satoshis)');
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
// Spendable notes, minus pending spends and dust. Dust notes (worth no
|
|
486
|
+
// more than their own input fee) can never help cover amount+fee and only
|
|
487
|
+
// let an attacker inflate the fee, so they are excluded from spending.
|
|
488
|
+
const pending = new Set([...this.pendingSpends.values()].flat());
|
|
489
|
+
const spendable = this.notes.filter((n) => !pending.has(n.nullifier) && n.note.value > DUST_NOTE_SATS);
|
|
490
|
+
// Refuse a send the inputs can't cover including the fee, unless the
|
|
491
|
+
// caller opts into sweep. Both branches mirror the Rust selection so the
|
|
492
|
+
// WASM can't silently pay the fee out of the recipient's amount — the
|
|
493
|
+
// WASM shares the same fee model but has no such guard.
|
|
494
|
+
const sufficient = useShield
|
|
495
|
+
? estimateShieldSelection(spendable, opts.amount, this.isShieldAddress(opts.to)).sufficient
|
|
496
|
+
: estimateTransparentSelection(opts.inputs, opts.amount, this.isShieldAddress(opts.to)).sufficient;
|
|
497
|
+
if (!sufficient && !opts.sweep) {
|
|
498
|
+
throw new Error('insufficient input value to cover amount + fee; lower the amount, ' +
|
|
499
|
+
'add inputs, or pass sweep:true to deduct the fee from the recipient');
|
|
500
|
+
}
|
|
501
|
+
// Prover is only needed to build; check it after the cheap validations
|
|
502
|
+
// so callers get input errors without loading ~50MB of parameters.
|
|
503
|
+
if (!(await this.shield.prover_is_loaded())) {
|
|
504
|
+
throw new Error('sapling prover not loaded: call loadProver() first');
|
|
505
|
+
}
|
|
506
|
+
if (this.busy)
|
|
507
|
+
throw new Error('wallet is busy: another sync or spend is in progress');
|
|
508
|
+
this.busy = true;
|
|
509
|
+
try {
|
|
510
|
+
const changeAddress = useShield ? this.getNewAddress() : opts.transparentChangeAddress;
|
|
511
|
+
const result = (await this.shield.create_transaction({
|
|
512
|
+
notes: useShield ? spendable : null,
|
|
513
|
+
utxos: useShield ? null : opts.inputs,
|
|
514
|
+
extsk: this.extsk,
|
|
515
|
+
to_address: opts.to,
|
|
516
|
+
change_address: changeAddress,
|
|
517
|
+
amount: opts.amount,
|
|
518
|
+
block_height: this.lastProcessedBlock + 1,
|
|
519
|
+
is_testnet: this.isTestnet,
|
|
520
|
+
memo: opts.memo ?? '',
|
|
521
|
+
}));
|
|
522
|
+
if (useShield)
|
|
523
|
+
this.pendingSpends.set(result.txid, result.nullifiers);
|
|
524
|
+
return { txid: result.txid, hex: result.txhex, nullifiers: result.nullifiers };
|
|
525
|
+
}
|
|
526
|
+
finally {
|
|
527
|
+
this.busy = false;
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
/** Build, broadcast, and finalize in one step. */
|
|
531
|
+
async send(client, opts) {
|
|
532
|
+
const tx = await this.createTransaction(opts);
|
|
533
|
+
try {
|
|
534
|
+
const txid = await client.sendRawTransaction(tx.hex);
|
|
535
|
+
this.finalizeTransaction(tx.txid);
|
|
536
|
+
return txid;
|
|
537
|
+
}
|
|
538
|
+
catch (err) {
|
|
539
|
+
// Only release the notes when the node definitively rejected the
|
|
540
|
+
// transaction. On a transport/timeout error the node may have accepted
|
|
541
|
+
// it, so keep the spend pending — discarding here could let a retry (or
|
|
542
|
+
// an operator reacting to a false "failed") double-spend or double-pay.
|
|
543
|
+
// Recover per docs/deployment.md: wait for the txid to confirm or
|
|
544
|
+
// clearly disappear, then resume.
|
|
545
|
+
if (err instanceof RpcError) {
|
|
546
|
+
this.discardTransaction(tx.txid);
|
|
547
|
+
}
|
|
548
|
+
else if (err && typeof err === 'object') {
|
|
549
|
+
// Ambiguous failure: the notes stay pending. Attach the txid so the
|
|
550
|
+
// operator can reconcile (confirm on-chain, then finalize/discard).
|
|
551
|
+
err.txid = tx.txid;
|
|
552
|
+
}
|
|
553
|
+
throw err;
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
/** Mark a broadcast transaction's notes as spent. */
|
|
557
|
+
finalizeTransaction(txid) {
|
|
558
|
+
const nullifiers = this.pendingSpends.get(txid);
|
|
559
|
+
if (!nullifiers)
|
|
560
|
+
return;
|
|
561
|
+
const spent = new Set(nullifiers);
|
|
562
|
+
this.notes = this.notes.filter((n) => !spent.has(n.nullifier));
|
|
563
|
+
this.pendingSpends.delete(txid);
|
|
564
|
+
}
|
|
565
|
+
/** Release a failed transaction's notes back to the spendable set. */
|
|
566
|
+
discardTransaction(txid) {
|
|
567
|
+
this.pendingSpends.delete(txid);
|
|
568
|
+
}
|
|
569
|
+
/**
|
|
570
|
+
* Transactions built and broadcast but not yet finalized or discarded
|
|
571
|
+
* (txid → the nullifiers they spend). After a broadcast error left a spend
|
|
572
|
+
* ambiguous, use this to find the txid, confirm it on-chain, then
|
|
573
|
+
* {@link finalizeTransaction} or {@link discardTransaction}.
|
|
574
|
+
*/
|
|
575
|
+
pendingTransactions() {
|
|
576
|
+
return Object.fromEntries(this.pendingSpends);
|
|
577
|
+
}
|
|
578
|
+
// ── Persistence ───────────────────────────────────────────────────────────
|
|
579
|
+
/**
|
|
580
|
+
* Serialize wallet state to JSON. The spending key is deliberately
|
|
581
|
+
* excluded — persist it separately (encrypted) and restore with
|
|
582
|
+
* {@link loadSpendingKey}.
|
|
583
|
+
*/
|
|
584
|
+
save() {
|
|
585
|
+
const state = {
|
|
586
|
+
version: 1,
|
|
587
|
+
network: this.network,
|
|
588
|
+
extfvk: this.extfvk,
|
|
589
|
+
lastProcessedBlock: this.lastProcessedBlock,
|
|
590
|
+
commitmentTree: this.commitmentTree,
|
|
591
|
+
diversifierIndex: this.diversifierIndex,
|
|
592
|
+
notes: this.notes,
|
|
593
|
+
nullifierMap: Object.fromEntries(this.nullifierMap),
|
|
594
|
+
pendingSpends: Object.fromEntries(this.pendingSpends),
|
|
595
|
+
};
|
|
596
|
+
return JSON.stringify(state);
|
|
597
|
+
}
|
|
598
|
+
/**
|
|
599
|
+
* Restore a wallet from {@link save} output.
|
|
600
|
+
*
|
|
601
|
+
* For a watch-only deposit scanner, pass `opts.expectedViewingKey` (the key
|
|
602
|
+
* you know this wallet should have): a tampered state file that swapped in
|
|
603
|
+
* an attacker's viewing key would otherwise silently repoint deposit
|
|
604
|
+
* addresses to the attacker. Saved-state integrity is theft-critical here.
|
|
605
|
+
*/
|
|
606
|
+
static async load(json, opts = {}) {
|
|
607
|
+
let state;
|
|
608
|
+
try {
|
|
609
|
+
state = JSON.parse(json);
|
|
610
|
+
}
|
|
611
|
+
catch {
|
|
612
|
+
throw new Error('wallet state is not valid JSON');
|
|
613
|
+
}
|
|
614
|
+
if (state === null || typeof state !== 'object')
|
|
615
|
+
throw new Error('wallet state is not an object');
|
|
616
|
+
if (state.version !== 1)
|
|
617
|
+
throw new Error(`unsupported wallet state version ${state.version}`);
|
|
618
|
+
if (state.network !== 'mainnet' && state.network !== 'testnet') {
|
|
619
|
+
throw new Error('wallet state has an invalid network');
|
|
620
|
+
}
|
|
621
|
+
if (typeof state.extfvk !== 'string' || typeof state.commitmentTree !== 'string') {
|
|
622
|
+
throw new Error('wallet state is missing keys or commitment tree');
|
|
623
|
+
}
|
|
624
|
+
if (opts.expectedViewingKey !== undefined && opts.expectedViewingKey !== state.extfvk) {
|
|
625
|
+
throw new Error('wallet state viewing key does not match the expected key');
|
|
626
|
+
}
|
|
627
|
+
if (!Number.isSafeInteger(state.lastProcessedBlock) || !Array.isArray(state.notes)) {
|
|
628
|
+
throw new Error('wallet state has an invalid sync position or notes');
|
|
629
|
+
}
|
|
630
|
+
for (const n of state.notes) {
|
|
631
|
+
if (typeof n?.nullifier !== 'string' || !Number.isSafeInteger(n?.note?.value) || n.note.value < 0) {
|
|
632
|
+
throw new Error('wallet state contains a malformed note');
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
if (!Array.isArray(state.diversifierIndex) ||
|
|
636
|
+
state.diversifierIndex.length !== 11 ||
|
|
637
|
+
!state.diversifierIndex.every((b) => Number.isInteger(b) && b >= 0 && b <= 255)) {
|
|
638
|
+
throw new Error('wallet state has an invalid diversifier index');
|
|
639
|
+
}
|
|
640
|
+
if (state.pendingSpends !== undefined && (typeof state.pendingSpends !== 'object' || state.pendingSpends === null)) {
|
|
641
|
+
throw new Error('wallet state has an invalid pending-spends map');
|
|
642
|
+
}
|
|
643
|
+
const shield = await loadShield(opts.proving);
|
|
644
|
+
// Confirm the viewing key decodes for this network before trusting it to
|
|
645
|
+
// derive receive addresses (a tampered state could otherwise repoint
|
|
646
|
+
// deposits to an attacker's key).
|
|
647
|
+
try {
|
|
648
|
+
shield.generate_default_payment_address(state.extfvk, state.network === 'testnet');
|
|
649
|
+
}
|
|
650
|
+
catch {
|
|
651
|
+
throw new Error('wallet state has an invalid viewing key for its network');
|
|
652
|
+
}
|
|
653
|
+
const wallet = new PivxWallet(shield, state.network, state.extfvk, state.commitmentTree, state.lastProcessedBlock, state.diversifierIndex);
|
|
654
|
+
wallet.notes = state.notes;
|
|
655
|
+
wallet.nullifierMap = new Map(Object.entries(state.nullifierMap ?? {}));
|
|
656
|
+
wallet.pendingSpends = new Map(Object.entries(state.pendingSpends ?? {}));
|
|
657
|
+
return wallet;
|
|
658
|
+
}
|
|
659
|
+
encodeRecipient(recipient) {
|
|
660
|
+
return this.shield.encode_payment_address(this.isTestnet, new Uint8Array(recipient));
|
|
661
|
+
}
|
|
662
|
+
}
|