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.
- package/dist/transparent-wallet.d.ts +67 -4
- package/dist/transparent-wallet.js +260 -31
- package/dist/wallet.js +2 -2
- package/package.json +1 -1
|
@@ -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
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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,87 @@ 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
210
|
async sync(client, { fromHeight = 0, batchSize = 100, onProgress } = {}) {
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
const
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
155
|
-
|
|
241
|
+
}
|
|
242
|
+
finally {
|
|
243
|
+
this.busy = false;
|
|
156
244
|
}
|
|
157
245
|
}
|
|
158
|
-
/**
|
|
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
|
|
192
|
-
//
|
|
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(
|
|
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
|
-
|
|
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(`
|
|
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.
|
|
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": {
|