ravensafe-cli 1.0.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/Docs.md +262 -0
- package/LICENSE +21 -0
- package/README.md +171 -0
- package/RavenSafe.js +4 -0
- package/assets/ravensafe-cli-brand.png +0 -0
- package/cli.js +4 -0
- package/package.json +55 -0
- package/src/RavenSafe.js +753 -0
- package/src/config/index.js +25 -0
- package/src/core/address.js +67 -0
- package/src/core/network.js +13 -0
- package/src/core/scan.js +218 -0
- package/src/core/session-cache.js +158 -0
- package/src/explorer/zelcore.js +526 -0
- package/src/interactive/index.js +972 -0
- package/src/interactive/ui.js +441 -0
- package/src/ledger/index.js +405 -0
- package/src/tx/builder.js +448 -0
- package/src/tx/signer.js +205 -0
- package/tools/probe-ledger.js +90 -0
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const bitcoin = require('bitcoinjs-lib');
|
|
4
|
+
const { ravencoinMainnet } = require('../core/network');
|
|
5
|
+
|
|
6
|
+
const SATS_PER_RVN = 100000000n;
|
|
7
|
+
const LEGACY_P2PKH_INPUT_BYTES = 148;
|
|
8
|
+
const STANDARD_OUTPUT_BYTES = 34;
|
|
9
|
+
const TX_OVERHEAD_BYTES = 10;
|
|
10
|
+
|
|
11
|
+
class TxPlanError extends Error {
|
|
12
|
+
constructor(message, hint) {
|
|
13
|
+
super(message);
|
|
14
|
+
this.name = 'TxPlanError';
|
|
15
|
+
this.hint = hint;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function parseRvnAmountToSats(value) {
|
|
20
|
+
const text = String(value || '').trim();
|
|
21
|
+
if (!/^(0|[1-9]\d*)(\.\d{1,8})?$/.test(text)) {
|
|
22
|
+
throw new TxPlanError('amount must be a positive RVN value with up to 8 decimal places.');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const [wholePart, fractionalPart = ''] = text.split('.');
|
|
26
|
+
const sats = BigInt(wholePart) * SATS_PER_RVN +
|
|
27
|
+
BigInt(fractionalPart.padEnd(8, '0'));
|
|
28
|
+
|
|
29
|
+
if (sats <= 0n) {
|
|
30
|
+
throw new TxPlanError('amount must be greater than 0.');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return sats;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function parsePositiveInteger(value, name) {
|
|
37
|
+
const text = String(value || '').trim();
|
|
38
|
+
if (!/^(0|[1-9]\d*)$/.test(text)) {
|
|
39
|
+
throw new TxPlanError(`${name} must be a whole number.`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const parsed = BigInt(text);
|
|
43
|
+
if (parsed <= 0n) {
|
|
44
|
+
throw new TxPlanError(`${name} must be greater than 0.`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (parsed > BigInt(Number.MAX_SAFE_INTEGER)) {
|
|
48
|
+
throw new TxPlanError(`${name} is too large.`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return parsed;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function parseNonNegativeInteger(value, name) {
|
|
55
|
+
const text = String(value || '').trim();
|
|
56
|
+
if (!/^(0|[1-9]\d*)$/.test(text)) {
|
|
57
|
+
throw new TxPlanError(`${name} must be a non-negative whole number.`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const parsed = Number(text);
|
|
61
|
+
if (!Number.isSafeInteger(parsed)) {
|
|
62
|
+
throw new TxPlanError(`${name} is too large.`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return parsed;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function validateRvnAddress(address) {
|
|
69
|
+
if (typeof address !== 'string' || address.trim() === '') {
|
|
70
|
+
throw new TxPlanError('destination address is required.');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
let decoded;
|
|
74
|
+
try {
|
|
75
|
+
decoded = bitcoin.address.fromBase58Check(address.trim());
|
|
76
|
+
} catch {
|
|
77
|
+
throw new TxPlanError('destination address is not a valid Ravencoin mainnet P2PKH/P2SH address.');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (decoded.version === ravencoinMainnet.pubKeyHash) {
|
|
81
|
+
return {
|
|
82
|
+
address: address.trim(),
|
|
83
|
+
type: 'p2pkh',
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (decoded.version === ravencoinMainnet.scriptHash) {
|
|
88
|
+
return {
|
|
89
|
+
address: address.trim(),
|
|
90
|
+
type: 'p2sh',
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
throw new TxPlanError('destination address is not a valid Ravencoin mainnet P2PKH/P2SH address.');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function estimateLegacyP2pkhTxBytes(inputCount, outputCount) {
|
|
98
|
+
if (!Number.isSafeInteger(inputCount) || inputCount < 1) {
|
|
99
|
+
throw new TxPlanError('transaction plan requires at least one input.');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (!Number.isSafeInteger(outputCount) || outputCount < 1) {
|
|
103
|
+
throw new TxPlanError('transaction plan requires at least one output.');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return TX_OVERHEAD_BYTES +
|
|
107
|
+
inputCount * LEGACY_P2PKH_INPUT_BYTES +
|
|
108
|
+
outputCount * STANDARD_OUTPUT_BYTES;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function normalizeUtxo(utxo) {
|
|
112
|
+
if (!utxo || typeof utxo !== 'object') {
|
|
113
|
+
throw new TxPlanError('explorer returned an invalid UTXO.');
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (typeof utxo.txid !== 'string' || utxo.txid.length === 0) {
|
|
117
|
+
throw new TxPlanError('explorer returned a UTXO without txid.');
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (!Number.isSafeInteger(utxo.vout) || utxo.vout < 0) {
|
|
121
|
+
throw new TxPlanError('explorer returned a UTXO with invalid vout.');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (typeof utxo.valueSats !== 'bigint' || utxo.valueSats <= 0n) {
|
|
125
|
+
throw new TxPlanError('explorer returned a UTXO with invalid value.');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
txid: utxo.txid,
|
|
130
|
+
vout: utxo.vout,
|
|
131
|
+
valueSats: utxo.valueSats,
|
|
132
|
+
confirmations: Number.isSafeInteger(utxo.confirmations) ? utxo.confirmations : 0,
|
|
133
|
+
height: Number.isSafeInteger(utxo.height) ? utxo.height : null,
|
|
134
|
+
coinbase: Boolean(utxo.coinbase),
|
|
135
|
+
sourceChain: utxo.sourceChain || utxo.chain || null,
|
|
136
|
+
sourceIndex: Number.isSafeInteger(utxo.sourceIndex)
|
|
137
|
+
? utxo.sourceIndex
|
|
138
|
+
: (Number.isSafeInteger(utxo.index) ? utxo.index : null),
|
|
139
|
+
sourcePath: utxo.sourcePath || utxo.path || null,
|
|
140
|
+
sourceAddress: utxo.sourceAddress || utxo.address || null,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function evaluateSelection(selectedUtxos, amountSats, feeRateSatsPerByte, dustSats) {
|
|
145
|
+
const inputTotalSats = selectedUtxos.reduce((total, utxo) => total + utxo.valueSats, 0n);
|
|
146
|
+
const withChangeBytes = estimateLegacyP2pkhTxBytes(selectedUtxos.length, 2);
|
|
147
|
+
const withChangeFeeSats = BigInt(withChangeBytes) * feeRateSatsPerByte;
|
|
148
|
+
const changeSats = inputTotalSats - amountSats - withChangeFeeSats;
|
|
149
|
+
|
|
150
|
+
if (changeSats >= dustSats) {
|
|
151
|
+
return {
|
|
152
|
+
selectedUtxos,
|
|
153
|
+
inputTotalSats,
|
|
154
|
+
estimatedBytes: withChangeBytes,
|
|
155
|
+
feeSats: withChangeFeeSats,
|
|
156
|
+
changeSats,
|
|
157
|
+
dustRemainderAddedToFeeSats: 0n,
|
|
158
|
+
hasChangeOutput: true,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const withoutChangeBytes = estimateLegacyP2pkhTxBytes(selectedUtxos.length, 1);
|
|
163
|
+
const minimumNoChangeFeeSats = BigInt(withoutChangeBytes) * feeRateSatsPerByte;
|
|
164
|
+
const noChangeRemainderSats = inputTotalSats - amountSats - minimumNoChangeFeeSats;
|
|
165
|
+
|
|
166
|
+
if (noChangeRemainderSats >= 0n) {
|
|
167
|
+
return {
|
|
168
|
+
selectedUtxos,
|
|
169
|
+
inputTotalSats,
|
|
170
|
+
estimatedBytes: withoutChangeBytes,
|
|
171
|
+
feeSats: inputTotalSats - amountSats,
|
|
172
|
+
changeSats: 0n,
|
|
173
|
+
dustRemainderAddedToFeeSats: noChangeRemainderSats,
|
|
174
|
+
hasChangeOutput: false,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function selectUtxosForAmount(utxos, amountSats, feeRateSatsPerByte, dustSats) {
|
|
182
|
+
const candidates = utxos
|
|
183
|
+
.map(normalizeUtxo)
|
|
184
|
+
.sort((left, right) => {
|
|
185
|
+
if (right.confirmations !== left.confirmations) {
|
|
186
|
+
return right.confirmations - left.confirmations;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (right.valueSats > left.valueSats) return 1;
|
|
190
|
+
if (right.valueSats < left.valueSats) return -1;
|
|
191
|
+
return 0;
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
const selected = [];
|
|
195
|
+
let inputTotalSats = 0n;
|
|
196
|
+
|
|
197
|
+
for (const utxo of candidates) {
|
|
198
|
+
selected.push(utxo);
|
|
199
|
+
inputTotalSats += utxo.valueSats;
|
|
200
|
+
|
|
201
|
+
const withChangeBytes = estimateLegacyP2pkhTxBytes(selected.length, 2);
|
|
202
|
+
const withChangeFeeSats = BigInt(withChangeBytes) * feeRateSatsPerByte;
|
|
203
|
+
const changeSats = inputTotalSats - amountSats - withChangeFeeSats;
|
|
204
|
+
|
|
205
|
+
if (changeSats >= dustSats) {
|
|
206
|
+
return {
|
|
207
|
+
selectedUtxos: selected,
|
|
208
|
+
inputTotalSats,
|
|
209
|
+
estimatedBytes: withChangeBytes,
|
|
210
|
+
feeSats: withChangeFeeSats,
|
|
211
|
+
changeSats,
|
|
212
|
+
dustRemainderAddedToFeeSats: 0n,
|
|
213
|
+
hasChangeOutput: true,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const withoutChangeBytes = estimateLegacyP2pkhTxBytes(selected.length, 1);
|
|
218
|
+
const minimumNoChangeFeeSats = BigInt(withoutChangeBytes) * feeRateSatsPerByte;
|
|
219
|
+
const noChangeRemainderSats = inputTotalSats - amountSats - minimumNoChangeFeeSats;
|
|
220
|
+
|
|
221
|
+
if (noChangeRemainderSats >= 0n) {
|
|
222
|
+
return {
|
|
223
|
+
selectedUtxos: selected,
|
|
224
|
+
inputTotalSats,
|
|
225
|
+
estimatedBytes: withoutChangeBytes,
|
|
226
|
+
feeSats: inputTotalSats - amountSats,
|
|
227
|
+
changeSats: 0n,
|
|
228
|
+
dustRemainderAddedToFeeSats: noChangeRemainderSats,
|
|
229
|
+
hasChangeOutput: false,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const availableSats = candidates.reduce((total, utxo) => total + utxo.valueSats, 0n);
|
|
235
|
+
throw new TxPlanError('insufficient funds for amount plus estimated fee.', `Available from selected source address: ${availableSats.toString()} sats.`);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function selectWalletUtxosForAmount(utxos, amountSats, feeRateSatsPerByte, dustSats) {
|
|
239
|
+
const candidates = utxos
|
|
240
|
+
.map(normalizeUtxo)
|
|
241
|
+
.filter(utxo => utxo.confirmations > 0);
|
|
242
|
+
|
|
243
|
+
const singleCandidates = [...candidates].sort((left, right) => {
|
|
244
|
+
if (left.valueSats < right.valueSats) return -1;
|
|
245
|
+
if (left.valueSats > right.valueSats) return 1;
|
|
246
|
+
return right.confirmations - left.confirmations;
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
for (const utxo of singleCandidates) {
|
|
250
|
+
const selection = evaluateSelection([utxo], amountSats, feeRateSatsPerByte, dustSats);
|
|
251
|
+
if (selection) {
|
|
252
|
+
return selection;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const selected = [];
|
|
257
|
+
const largestFirst = [...candidates].sort((left, right) => {
|
|
258
|
+
if (right.valueSats > left.valueSats) return 1;
|
|
259
|
+
if (right.valueSats < left.valueSats) return -1;
|
|
260
|
+
return right.confirmations - left.confirmations;
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
for (const utxo of largestFirst) {
|
|
264
|
+
selected.push(utxo);
|
|
265
|
+
const selection = evaluateSelection([...selected], amountSats, feeRateSatsPerByte, dustSats);
|
|
266
|
+
if (selection) {
|
|
267
|
+
return selection;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const availableSats = candidates.reduce((total, utxo) => total + utxo.valueSats, 0n);
|
|
272
|
+
throw new TxPlanError('insufficient funds for amount plus estimated fee.', `Available confirmed balance: ${availableSats.toString()} sats.`);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function buildDryRunSendPlan(options) {
|
|
276
|
+
const destination = validateRvnAddress(options.destinationAddress);
|
|
277
|
+
const amountSats = options.amountSats;
|
|
278
|
+
const feeRateSatsPerByte = options.feeRateSatsPerByte;
|
|
279
|
+
const dustSats = options.dustSats;
|
|
280
|
+
|
|
281
|
+
if (typeof amountSats !== 'bigint' || amountSats <= 0n) {
|
|
282
|
+
throw new TxPlanError('amount must be greater than 0.');
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (typeof feeRateSatsPerByte !== 'bigint' || feeRateSatsPerByte <= 0n) {
|
|
286
|
+
throw new TxPlanError('fee rate must be greater than 0.');
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if (typeof dustSats !== 'bigint' || dustSats < 0n) {
|
|
290
|
+
throw new TxPlanError('dust threshold must be a non-negative integer.');
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (!options.sourceAddress || !options.changeAddress) {
|
|
294
|
+
throw new TxPlanError('source and change addresses are required.');
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const selection = selectUtxosForAmount(
|
|
298
|
+
options.utxos || [],
|
|
299
|
+
amountSats,
|
|
300
|
+
feeRateSatsPerByte,
|
|
301
|
+
dustSats,
|
|
302
|
+
);
|
|
303
|
+
|
|
304
|
+
const outputs = [
|
|
305
|
+
{
|
|
306
|
+
type: 'destination',
|
|
307
|
+
address: destination.address,
|
|
308
|
+
valueSats: amountSats,
|
|
309
|
+
},
|
|
310
|
+
];
|
|
311
|
+
|
|
312
|
+
if (selection.hasChangeOutput) {
|
|
313
|
+
outputs.push({
|
|
314
|
+
type: 'change',
|
|
315
|
+
address: options.changeAddress,
|
|
316
|
+
valueSats: selection.changeSats,
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
return {
|
|
321
|
+
dryRun: true,
|
|
322
|
+
sourceChain: options.sourceChain,
|
|
323
|
+
sourceIndex: options.sourceIndex,
|
|
324
|
+
sourcePath: options.sourcePath,
|
|
325
|
+
sourceAddress: options.sourceAddress,
|
|
326
|
+
destinationAddress: destination.address,
|
|
327
|
+
destinationType: destination.type,
|
|
328
|
+
amountSats,
|
|
329
|
+
selectedUtxos: selection.selectedUtxos,
|
|
330
|
+
inputTotalSats: selection.inputTotalSats,
|
|
331
|
+
feeRateSatsPerByte,
|
|
332
|
+
estimatedBytes: selection.estimatedBytes,
|
|
333
|
+
feeSats: selection.feeSats,
|
|
334
|
+
changeChain: options.changeChain,
|
|
335
|
+
changeIndex: options.changeIndex,
|
|
336
|
+
changePath: options.changePath,
|
|
337
|
+
changeAddress: options.changeAddress,
|
|
338
|
+
changeSats: selection.changeSats,
|
|
339
|
+
dustRemainderAddedToFeeSats: selection.dustRemainderAddedToFeeSats,
|
|
340
|
+
outputs,
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function uniqueSourceAddresses(selectedUtxos) {
|
|
345
|
+
const sources = new Map();
|
|
346
|
+
|
|
347
|
+
for (const utxo of selectedUtxos) {
|
|
348
|
+
const key = `${utxo.sourceChain || 'unknown'}:${utxo.sourceIndex ?? 'unknown'}:${utxo.sourceAddress || 'unknown'}`;
|
|
349
|
+
if (!sources.has(key)) {
|
|
350
|
+
sources.set(key, {
|
|
351
|
+
chain: utxo.sourceChain,
|
|
352
|
+
index: utxo.sourceIndex,
|
|
353
|
+
path: utxo.sourcePath,
|
|
354
|
+
address: utxo.sourceAddress,
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
return [...sources.values()];
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function buildWalletSendPlan(options) {
|
|
363
|
+
const destination = validateRvnAddress(options.destinationAddress);
|
|
364
|
+
const amountSats = options.amountSats;
|
|
365
|
+
const feeRateSatsPerByte = options.feeRateSatsPerByte;
|
|
366
|
+
const dustSats = options.dustSats;
|
|
367
|
+
|
|
368
|
+
if (typeof amountSats !== 'bigint' || amountSats <= 0n) {
|
|
369
|
+
throw new TxPlanError('amount must be greater than 0.');
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
if (typeof feeRateSatsPerByte !== 'bigint' || feeRateSatsPerByte <= 0n) {
|
|
373
|
+
throw new TxPlanError('fee rate must be greater than 0.');
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
if (typeof dustSats !== 'bigint' || dustSats < 0n) {
|
|
377
|
+
throw new TxPlanError('dust threshold must be a non-negative integer.');
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
if (!options.changeAddress || !options.changePath) {
|
|
381
|
+
throw new TxPlanError('change address and path are required.');
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
if (destination.address === options.changeAddress) {
|
|
385
|
+
throw new TxPlanError('destination address matches the selected change address. Choose a different destination before signing.');
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const selection = selectWalletUtxosForAmount(
|
|
389
|
+
options.utxos || [],
|
|
390
|
+
amountSats,
|
|
391
|
+
feeRateSatsPerByte,
|
|
392
|
+
dustSats,
|
|
393
|
+
);
|
|
394
|
+
|
|
395
|
+
const outputs = [
|
|
396
|
+
{
|
|
397
|
+
type: 'destination',
|
|
398
|
+
address: destination.address,
|
|
399
|
+
valueSats: amountSats,
|
|
400
|
+
},
|
|
401
|
+
];
|
|
402
|
+
|
|
403
|
+
if (selection.hasChangeOutput) {
|
|
404
|
+
outputs.push({
|
|
405
|
+
type: 'change',
|
|
406
|
+
address: options.changeAddress,
|
|
407
|
+
valueSats: selection.changeSats,
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const sourceAddresses = uniqueSourceAddresses(selection.selectedUtxos);
|
|
412
|
+
|
|
413
|
+
return {
|
|
414
|
+
dryRun: true,
|
|
415
|
+
walletPlan: true,
|
|
416
|
+
sourceChain: sourceAddresses.length === 1 ? sourceAddresses[0].chain : 'multiple',
|
|
417
|
+
sourceIndex: sourceAddresses.length === 1 ? sourceAddresses[0].index : null,
|
|
418
|
+
sourcePath: sourceAddresses.length === 1 ? sourceAddresses[0].path : null,
|
|
419
|
+
sourceAddress: sourceAddresses.length === 1 ? sourceAddresses[0].address : null,
|
|
420
|
+
sourceAddresses,
|
|
421
|
+
destinationAddress: destination.address,
|
|
422
|
+
destinationType: destination.type,
|
|
423
|
+
amountSats,
|
|
424
|
+
selectedUtxos: selection.selectedUtxos,
|
|
425
|
+
inputTotalSats: selection.inputTotalSats,
|
|
426
|
+
feeRateSatsPerByte,
|
|
427
|
+
estimatedBytes: selection.estimatedBytes,
|
|
428
|
+
feeSats: selection.feeSats,
|
|
429
|
+
changeChain: options.changeChain,
|
|
430
|
+
changeIndex: options.changeIndex,
|
|
431
|
+
changePath: options.changePath,
|
|
432
|
+
changeAddress: options.changeAddress,
|
|
433
|
+
changeSats: selection.changeSats,
|
|
434
|
+
dustRemainderAddedToFeeSats: selection.dustRemainderAddedToFeeSats,
|
|
435
|
+
outputs,
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
module.exports = {
|
|
440
|
+
TxPlanError,
|
|
441
|
+
buildDryRunSendPlan,
|
|
442
|
+
buildWalletSendPlan,
|
|
443
|
+
estimateLegacyP2pkhTxBytes,
|
|
444
|
+
parseNonNegativeInteger,
|
|
445
|
+
parsePositiveInteger,
|
|
446
|
+
parseRvnAmountToSats,
|
|
447
|
+
validateRvnAddress,
|
|
448
|
+
};
|
package/src/tx/signer.js
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const bitcoin = require('bitcoinjs-lib');
|
|
4
|
+
const TransportNodeHid = require('@ledgerhq/hw-transport-node-hid').default;
|
|
5
|
+
const Btc = require('@ledgerhq/hw-app-btc').default;
|
|
6
|
+
const { createExplorer } = require('../explorer/zelcore');
|
|
7
|
+
const { describeLedgerError } = require('../ledger');
|
|
8
|
+
const { ravencoinMainnet } = require('../core/network');
|
|
9
|
+
|
|
10
|
+
const SIGHASH_ALL = 1;
|
|
11
|
+
|
|
12
|
+
class SigningError extends Error {
|
|
13
|
+
constructor(message, hint, options = {}) {
|
|
14
|
+
super(message);
|
|
15
|
+
this.name = 'SigningError';
|
|
16
|
+
this.hint = hint;
|
|
17
|
+
this.statusCode = options.statusCode || null;
|
|
18
|
+
this.endpoint = options.endpoint || null;
|
|
19
|
+
this.responseBody = options.responseBody || null;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function writeVarInt(number) {
|
|
24
|
+
if (!Number.isSafeInteger(number) || number < 0) {
|
|
25
|
+
throw new SigningError('Cannot encode invalid varint.');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (number < 0xfd) {
|
|
29
|
+
return Buffer.from([number]);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (number <= 0xffff) {
|
|
33
|
+
const buffer = Buffer.alloc(3);
|
|
34
|
+
buffer[0] = 0xfd;
|
|
35
|
+
buffer.writeUInt16LE(number, 1);
|
|
36
|
+
return buffer;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (number <= 0xffffffff) {
|
|
40
|
+
const buffer = Buffer.alloc(5);
|
|
41
|
+
buffer[0] = 0xfe;
|
|
42
|
+
buffer.writeUInt32LE(number, 1);
|
|
43
|
+
return buffer;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
throw new SigningError('Varint is too large.');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function writeUInt64LE(value) {
|
|
50
|
+
if (typeof value !== 'bigint' || value < 0n) {
|
|
51
|
+
throw new SigningError('Cannot encode invalid satoshi value.');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const buffer = Buffer.alloc(8);
|
|
55
|
+
buffer.writeBigUInt64LE(value, 0);
|
|
56
|
+
return buffer;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function serializeOutputs(outputs) {
|
|
60
|
+
const chunks = [writeVarInt(outputs.length)];
|
|
61
|
+
|
|
62
|
+
for (const output of outputs) {
|
|
63
|
+
const script = Buffer.from(bitcoin.address.toOutputScript(output.address, ravencoinMainnet));
|
|
64
|
+
chunks.push(writeUInt64LE(output.valueSats));
|
|
65
|
+
chunks.push(writeVarInt(script.length));
|
|
66
|
+
chunks.push(script);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return Buffer.concat(chunks).toString('hex');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function validatePreviousTransaction(rawTxHex, utxo) {
|
|
73
|
+
let tx;
|
|
74
|
+
try {
|
|
75
|
+
tx = bitcoin.Transaction.fromHex(rawTxHex);
|
|
76
|
+
} catch (error) {
|
|
77
|
+
throw new SigningError(`Could not parse previous transaction ${utxo.txid}: ${error.message}`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (tx.getId() !== utxo.txid) {
|
|
81
|
+
throw new SigningError(`Explorer raw transaction txid mismatch for ${utxo.txid}.`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const prevout = tx.outs[utxo.vout];
|
|
85
|
+
if (!prevout) {
|
|
86
|
+
throw new SigningError(`Previous transaction ${utxo.txid} does not contain vout ${utxo.vout}.`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (prevout.value !== utxo.valueSats) {
|
|
90
|
+
throw new SigningError(`Previous transaction ${utxo.txid}:${utxo.vout} value does not match explorer UTXO value.`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function signingPathForUtxo(utxo, plan) {
|
|
95
|
+
const path = utxo.sourcePath || utxo.path || plan.sourcePath;
|
|
96
|
+
if (!path) {
|
|
97
|
+
throw new SigningError(`Selected UTXO ${utxo.txid}:${utxo.vout} is missing a Ledger derivation path.`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return path;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function closeTransport(transport) {
|
|
104
|
+
if (!transport) {
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
try {
|
|
109
|
+
await transport.close();
|
|
110
|
+
} catch {
|
|
111
|
+
// Signing already succeeded or failed; close errors should not hide that result.
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function openLedgerTransport() {
|
|
116
|
+
const devicePaths = await TransportNodeHid.list();
|
|
117
|
+
if (devicePaths.length === 0) {
|
|
118
|
+
throw new SigningError(
|
|
119
|
+
'No Ledger HID device detected.',
|
|
120
|
+
'Connect the Ledger over USB, unlock it, close Ledger Live, and open the Ravencoin app.',
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return TransportNodeHid.open(devicePaths[0]);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function signDryRunPlan(plan, options = {}) {
|
|
128
|
+
const explorer = options.explorer || createExplorer();
|
|
129
|
+
const rawTransactions = [];
|
|
130
|
+
|
|
131
|
+
for (const utxo of plan.selectedUtxos) {
|
|
132
|
+
let rawTxHex;
|
|
133
|
+
try {
|
|
134
|
+
rawTxHex = await explorer.getRawTransaction(utxo.txid);
|
|
135
|
+
} catch (error) {
|
|
136
|
+
const hint = error.hint
|
|
137
|
+
? `${error.message} ${error.hint}`
|
|
138
|
+
: error.message;
|
|
139
|
+
throw new SigningError(
|
|
140
|
+
`Could not fetch raw previous transaction for ${utxo.txid}.`,
|
|
141
|
+
hint,
|
|
142
|
+
{
|
|
143
|
+
statusCode: error.statusCode,
|
|
144
|
+
endpoint: error.endpoint,
|
|
145
|
+
responseBody: error.responseBody,
|
|
146
|
+
},
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
validatePreviousTransaction(rawTxHex, utxo);
|
|
151
|
+
rawTransactions.push(rawTxHex);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const outputScriptHex = serializeOutputs(plan.outputs);
|
|
155
|
+
let transport;
|
|
156
|
+
|
|
157
|
+
try {
|
|
158
|
+
transport = await openLedgerTransport();
|
|
159
|
+
const btc = new Btc({
|
|
160
|
+
transport,
|
|
161
|
+
currency: 'ravencoin',
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
const inputs = rawTransactions.map((rawTxHex, index) => {
|
|
165
|
+
const splitTx = btc.splitTransaction(rawTxHex, false, false);
|
|
166
|
+
return [splitTx, plan.selectedUtxos[index].vout, null, undefined];
|
|
167
|
+
});
|
|
168
|
+
const associatedKeysets = plan.selectedUtxos.map(utxo => signingPathForUtxo(utxo, plan));
|
|
169
|
+
const signedRawTx = await btc.createPaymentTransaction({
|
|
170
|
+
inputs,
|
|
171
|
+
associatedKeysets,
|
|
172
|
+
changePath: plan.changeSats > 0n ? plan.changePath : undefined,
|
|
173
|
+
outputScriptHex,
|
|
174
|
+
lockTime: 0,
|
|
175
|
+
sigHashType: SIGHASH_ALL,
|
|
176
|
+
segwit: false,
|
|
177
|
+
additionals: [],
|
|
178
|
+
useTrustedInputForSegwit: false,
|
|
179
|
+
});
|
|
180
|
+
const txid = bitcoin.Transaction.fromHex(signedRawTx).getId();
|
|
181
|
+
|
|
182
|
+
return {
|
|
183
|
+
signedRawTx,
|
|
184
|
+
txid,
|
|
185
|
+
};
|
|
186
|
+
} catch (error) {
|
|
187
|
+
const ledgerError = describeLedgerError(error);
|
|
188
|
+
if (error instanceof SigningError) {
|
|
189
|
+
throw error;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const message = ledgerError.statusCode
|
|
193
|
+
? `${ledgerError.message} (${ledgerError.statusCode})`
|
|
194
|
+
: ledgerError.message;
|
|
195
|
+
throw new SigningError(message, ledgerError.hint);
|
|
196
|
+
} finally {
|
|
197
|
+
await closeTransport(transport);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
module.exports = {
|
|
202
|
+
SigningError,
|
|
203
|
+
serializeOutputs,
|
|
204
|
+
signDryRunPlan,
|
|
205
|
+
};
|