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.
- package/dist/transparent-wallet.d.ts +76 -5
- package/dist/transparent-wallet.js +267 -32
- package/dist/types.d.ts +8 -0
- package/dist/wallet.d.ts +9 -0
- package/dist/wallet.js +31 -2
- package/package.json +2 -2
|
@@ -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
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
15
|
-
|
|
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 =
|
|
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.
|
|
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
|
|
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.
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
const
|
|
148
|
-
const
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
155
|
-
|
|
247
|
+
}
|
|
248
|
+
finally {
|
|
249
|
+
this.busy = false;
|
|
156
250
|
}
|
|
157
251
|
}
|
|
158
|
-
/**
|
|
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
|
|
192
|
-
//
|
|
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(
|
|
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
|
-
|
|
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(`
|
|
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.
|
|
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.
|
|
47
|
+
"pivx-rpc": "^0.2.0",
|
|
48
48
|
"pivx-shield-rust": "^1.4.0"
|
|
49
49
|
},
|
|
50
50
|
"optionalDependencies": {
|