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
package/src/RavenSafe.js
ADDED
|
@@ -0,0 +1,753 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const { Command } = require('commander');
|
|
5
|
+
const fs = require('fs/promises');
|
|
6
|
+
const bitcoin = require('bitcoinjs-lib');
|
|
7
|
+
const readline = require('readline/promises');
|
|
8
|
+
const config = require('./config');
|
|
9
|
+
const {
|
|
10
|
+
ADDRESS_CHAIN_MODES,
|
|
11
|
+
ADDRESS_CHAINS,
|
|
12
|
+
EXPECTED_CONTEXTS,
|
|
13
|
+
addressPathForIndex,
|
|
14
|
+
listLedgerAddressRequests,
|
|
15
|
+
listLedgerAddresses,
|
|
16
|
+
} = require('./ledger');
|
|
17
|
+
const {
|
|
18
|
+
broadcastRawTx,
|
|
19
|
+
createExplorer,
|
|
20
|
+
describeBroadcastProvider,
|
|
21
|
+
} = require('./explorer/zelcore');
|
|
22
|
+
const { formatRvn, scanLedgerAddresses } = require('./core/scan');
|
|
23
|
+
const { signDryRunPlan } = require('./tx/signer');
|
|
24
|
+
const {
|
|
25
|
+
buildDryRunSendPlan,
|
|
26
|
+
parseNonNegativeInteger,
|
|
27
|
+
parsePositiveInteger,
|
|
28
|
+
parseRvnAmountToSats,
|
|
29
|
+
validateRvnAddress,
|
|
30
|
+
} = require('./tx/builder');
|
|
31
|
+
|
|
32
|
+
function printError(error, write = console.error) {
|
|
33
|
+
write(`Error: ${error.message}`);
|
|
34
|
+
if (error.statusCode) {
|
|
35
|
+
write(`Status: ${error.statusCode}`);
|
|
36
|
+
}
|
|
37
|
+
if (error.endpoint) {
|
|
38
|
+
write(`Endpoint: ${error.endpoint}`);
|
|
39
|
+
}
|
|
40
|
+
if (error.responseBody) {
|
|
41
|
+
write(`Response body: ${error.responseBody}`);
|
|
42
|
+
}
|
|
43
|
+
if (error.hint) {
|
|
44
|
+
write(`Hint: ${error.hint}`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function parseWholeNumber(value, name) {
|
|
49
|
+
const text = String(value);
|
|
50
|
+
if (text.startsWith('-')) {
|
|
51
|
+
throw new Error(`${name} must be >= 0`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (!/^(0|[1-9]\d*)$/.test(text)) {
|
|
55
|
+
throw new Error(`${name} must be a whole number`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return Number(text);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function parseLedgerRangeOptions(options, maxCount) {
|
|
62
|
+
const start = parseWholeNumber(options.start, 'start');
|
|
63
|
+
const count = parseWholeNumber(options.count, 'count');
|
|
64
|
+
|
|
65
|
+
if (start < 0) {
|
|
66
|
+
throw new Error('start must be >= 0');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (count < 1 || count > maxCount) {
|
|
70
|
+
throw new Error(`count must be between 1 and ${maxCount}`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (!EXPECTED_CONTEXTS[options.app]) {
|
|
74
|
+
throw new Error('app must be one of: current, ravencoin, bitcoin');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
start,
|
|
79
|
+
count,
|
|
80
|
+
expectedContext: options.app,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function parseAddressOptions(options) {
|
|
85
|
+
return parseLedgerRangeOptions(options, 100);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function parseScanOptions(options) {
|
|
89
|
+
const parsed = parseLedgerRangeOptions(options, 200);
|
|
90
|
+
if (!ADDRESS_CHAIN_MODES.includes(options.chain)) {
|
|
91
|
+
throw new Error('chain must be one of: receiving, change, both');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
...parsed,
|
|
96
|
+
chain: options.chain,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function parseSingleAddressChain(value, name) {
|
|
101
|
+
if (!ADDRESS_CHAINS[value]) {
|
|
102
|
+
throw new Error(`${name} must be one of: receiving, change`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return value;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function parseSendOptions(options) {
|
|
109
|
+
if (!EXPECTED_CONTEXTS[options.app]) {
|
|
110
|
+
throw new Error('app must be one of: current, ravencoin, bitcoin');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const fromChain = parseSingleAddressChain(options.fromChain, 'from-chain');
|
|
114
|
+
const changeChain = parseSingleAddressChain(options.changeChain, 'change-chain');
|
|
115
|
+
const fromIndex = parseWholeNumber(options.fromIndex, 'from-index');
|
|
116
|
+
const changeIndexDefault = String(config.ravencoin.defaultChangeIndex);
|
|
117
|
+
const changeIndex = parseWholeNumber(options.changeIndex ?? changeIndexDefault, 'change-index');
|
|
118
|
+
const feeRateDefault = String(config.ravencoin.feeRateSatPerByte);
|
|
119
|
+
const dustDefault = String(config.ravencoin.dustSats);
|
|
120
|
+
|
|
121
|
+
const destination = validateRvnAddress(options.to).address;
|
|
122
|
+
const amountSats = parseRvnAmountToSats(options.amount);
|
|
123
|
+
const feeRateSatsPerByte = parsePositiveInteger(options.feeRate ?? feeRateDefault, 'fee-rate');
|
|
124
|
+
const dustSats = BigInt(parseNonNegativeInteger(dustDefault, 'config.ravencoin.dustSats'));
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
expectedContext: options.app,
|
|
128
|
+
fromChain,
|
|
129
|
+
fromIndex,
|
|
130
|
+
destination,
|
|
131
|
+
amountSats,
|
|
132
|
+
feeRateSatsPerByte,
|
|
133
|
+
dustSats,
|
|
134
|
+
changeChain,
|
|
135
|
+
changeIndex,
|
|
136
|
+
sign: Boolean(options.sign),
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function parseBroadcastOptions(options) {
|
|
141
|
+
const hasRawTx = options.rawtx !== undefined && options.rawtx !== null;
|
|
142
|
+
const hasFile = options.file !== undefined && options.file !== null;
|
|
143
|
+
|
|
144
|
+
if (hasRawTx && hasFile) {
|
|
145
|
+
throw new Error('use either --rawtx or --file, not both');
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (!hasRawTx && !hasFile) {
|
|
149
|
+
throw new Error('either --rawtx or --file is required');
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return {
|
|
153
|
+
rawtx: hasRawTx ? String(options.rawtx) : null,
|
|
154
|
+
file: hasFile ? String(options.file) : null,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function normalizeRawTx(rawTx) {
|
|
159
|
+
const text = String(rawTx || '').trim();
|
|
160
|
+
|
|
161
|
+
if (text.length === 0) {
|
|
162
|
+
throw new Error('rawtx must be a non-empty hex string');
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (text.length % 2 !== 0) {
|
|
166
|
+
throw new Error('rawtx must be even-length hex');
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (!/^[0-9a-fA-F]+$/.test(text)) {
|
|
170
|
+
throw new Error('rawtx must contain only hex characters');
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return text.toLowerCase();
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function loadBroadcastRawTx(options) {
|
|
177
|
+
if (options.rawtx !== null) {
|
|
178
|
+
return normalizeRawTx(options.rawtx);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
let contents;
|
|
182
|
+
try {
|
|
183
|
+
contents = await fs.readFile(options.file, 'utf8');
|
|
184
|
+
} catch (error) {
|
|
185
|
+
throw new Error(`could not read raw transaction file: ${error.message}`);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return normalizeRawTx(contents);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function inspectRawTransaction(rawTx) {
|
|
192
|
+
const bytes = Buffer.byteLength(rawTx, 'hex');
|
|
193
|
+
|
|
194
|
+
try {
|
|
195
|
+
const transaction = bitcoin.Transaction.fromHex(rawTx);
|
|
196
|
+
return {
|
|
197
|
+
txid: transaction.getId(),
|
|
198
|
+
bytes,
|
|
199
|
+
inputCount: transaction.ins.length,
|
|
200
|
+
outputCount: transaction.outs.length,
|
|
201
|
+
};
|
|
202
|
+
} catch (error) {
|
|
203
|
+
throw new Error(`rawtx is hex but could not be decoded as a transaction: ${error.message}`);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function formatTable(rows, columns) {
|
|
208
|
+
const widths = columns.map(column => {
|
|
209
|
+
return Math.max(
|
|
210
|
+
column.label.length,
|
|
211
|
+
...rows.map(row => String(row[column.key]).length),
|
|
212
|
+
);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
const line = columns
|
|
216
|
+
.map((column, index) => column.label.padEnd(widths[index]))
|
|
217
|
+
.join(' ');
|
|
218
|
+
const divider = widths.map(width => '-'.repeat(width)).join(' ');
|
|
219
|
+
const body = rows.map(row => {
|
|
220
|
+
return columns
|
|
221
|
+
.map((column, index) => String(row[column.key]).padEnd(widths[index]))
|
|
222
|
+
.join(' ');
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
return [line, divider, ...body].join('\n');
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function printAddressResult(result) {
|
|
229
|
+
console.log('RVN Ledger address listing');
|
|
230
|
+
console.log('Reads public keys only. Does not sign, send, or broadcast.');
|
|
231
|
+
console.log('');
|
|
232
|
+
console.log(`Test context: ${result.expectedContext.label} (${result.expectedContext.purpose})`);
|
|
233
|
+
console.log(`HID detected: ${result.hidDetected ? 'yes' : 'no'}`);
|
|
234
|
+
console.log(`HID device count: ${result.hidDeviceCount}`);
|
|
235
|
+
|
|
236
|
+
if (result.app && result.app.name) {
|
|
237
|
+
console.log(`Open Ledger app: ${result.app.name} ${result.app.version}`);
|
|
238
|
+
console.log(`Detected context: ${result.app.context.label} (${result.app.context.purpose})`);
|
|
239
|
+
} else if (result.app && result.app.error) {
|
|
240
|
+
console.log('Open Ledger app: could not detect');
|
|
241
|
+
printError(result.app.error, console.log);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
for (const warning of result.warnings) {
|
|
245
|
+
console.log(`Warning: ${warning}`);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (result.error) {
|
|
249
|
+
console.log('');
|
|
250
|
+
printError(result.error, console.log);
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const rows = result.addressResults.map(item => ({
|
|
255
|
+
index: item.index,
|
|
256
|
+
path: item.path,
|
|
257
|
+
rvnAddress: item.rvnAddress || '-',
|
|
258
|
+
ledgerAddress: item.ledgerAddress || '-',
|
|
259
|
+
match: item.ok ? (item.matchesLedger ? 'yes' : 'no') : 'error',
|
|
260
|
+
}));
|
|
261
|
+
|
|
262
|
+
console.log('');
|
|
263
|
+
console.log(formatTable(rows, [
|
|
264
|
+
{ key: 'index', label: 'index' },
|
|
265
|
+
{ key: 'path', label: 'derivation path' },
|
|
266
|
+
{ key: 'rvnAddress', label: 'RVN address' },
|
|
267
|
+
{ key: 'ledgerAddress', label: 'Ledger-returned address' },
|
|
268
|
+
{ key: 'match', label: 'match' },
|
|
269
|
+
]));
|
|
270
|
+
|
|
271
|
+
const failedRows = result.addressResults.filter(item => !item.ok);
|
|
272
|
+
for (const item of failedRows) {
|
|
273
|
+
console.log('');
|
|
274
|
+
console.log(`Path ${item.path} failed:`);
|
|
275
|
+
printError(item.error, console.log);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function printScanResult(result) {
|
|
280
|
+
console.log('RVN Ledger balance scan');
|
|
281
|
+
console.log('Reads public keys and public blockchain data only. Does not sign, send, or broadcast.');
|
|
282
|
+
console.log('');
|
|
283
|
+
|
|
284
|
+
if (!result.expectedContext) {
|
|
285
|
+
printError(result.error, console.log);
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
console.log(`Test context: ${result.expectedContext.label} (${result.expectedContext.purpose})`);
|
|
290
|
+
console.log(`HID detected: ${result.hidDetected ? 'yes' : 'no'}`);
|
|
291
|
+
console.log(`HID device count: ${result.hidDeviceCount}`);
|
|
292
|
+
|
|
293
|
+
if (result.app && result.app.name) {
|
|
294
|
+
console.log(`Open Ledger app: ${result.app.name} ${result.app.version}`);
|
|
295
|
+
console.log(`Detected context: ${result.app.context.label} (${result.app.context.purpose})`);
|
|
296
|
+
} else if (result.app && result.app.error) {
|
|
297
|
+
console.log('Open Ledger app: could not detect');
|
|
298
|
+
printError(result.app.error, console.log);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
for (const warning of result.warnings) {
|
|
302
|
+
console.log(`Warning: ${warning}`);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if (result.error) {
|
|
306
|
+
console.log('');
|
|
307
|
+
printError(result.error, console.log);
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const rows = result.scanResults.map(item => ({
|
|
312
|
+
chain: item.chain || '-',
|
|
313
|
+
index: item.index,
|
|
314
|
+
path: item.path,
|
|
315
|
+
rvnAddress: item.rvnAddress || '-',
|
|
316
|
+
confirmed: item.ok ? formatRvn(item.confirmedSats) : '-',
|
|
317
|
+
unconfirmed: item.ok ? formatRvn(item.unconfirmedSats) : '-',
|
|
318
|
+
utxos: item.ok ? item.utxoCount : '-',
|
|
319
|
+
match: item.matchesLedger ? 'yes' : 'no',
|
|
320
|
+
}));
|
|
321
|
+
|
|
322
|
+
console.log('');
|
|
323
|
+
console.log(formatTable(rows, [
|
|
324
|
+
{ key: 'chain', label: 'chain' },
|
|
325
|
+
{ key: 'index', label: 'index' },
|
|
326
|
+
{ key: 'path', label: 'derivation path' },
|
|
327
|
+
{ key: 'rvnAddress', label: 'RVN address' },
|
|
328
|
+
{ key: 'confirmed', label: 'confirmed balance' },
|
|
329
|
+
{ key: 'unconfirmed', label: 'unconfirmed balance' },
|
|
330
|
+
{ key: 'utxos', label: 'UTXOs' },
|
|
331
|
+
{ key: 'match', label: 'match' },
|
|
332
|
+
]));
|
|
333
|
+
|
|
334
|
+
const failedRows = result.scanResults.filter(item => !item.ok);
|
|
335
|
+
for (const item of failedRows) {
|
|
336
|
+
console.log('');
|
|
337
|
+
console.log(`Path ${item.path} failed:`);
|
|
338
|
+
printError(item.error, console.log);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
console.log('');
|
|
342
|
+
console.log(`Receiving confirmed total: ${formatRvn(result.receivingConfirmedSats)}`);
|
|
343
|
+
console.log(`Change confirmed total: ${formatRvn(result.changeConfirmedSats)}`);
|
|
344
|
+
console.log(`Grand total confirmed: ${formatRvn(result.grandTotalConfirmedSats)}`);
|
|
345
|
+
console.log(`Total UTXOs: ${result.totalUtxoCount}`);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
async function createDryRunSendPlan(options) {
|
|
349
|
+
let explorer;
|
|
350
|
+
try {
|
|
351
|
+
explorer = createExplorer();
|
|
352
|
+
} catch (error) {
|
|
353
|
+
return {
|
|
354
|
+
ledgerResult: null,
|
|
355
|
+
plan: null,
|
|
356
|
+
error,
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const requests = [
|
|
361
|
+
{
|
|
362
|
+
role: 'source',
|
|
363
|
+
chain: options.fromChain,
|
|
364
|
+
index: options.fromIndex,
|
|
365
|
+
path: addressPathForIndex(options.fromIndex, options.fromChain),
|
|
366
|
+
},
|
|
367
|
+
{
|
|
368
|
+
role: 'change',
|
|
369
|
+
chain: options.changeChain,
|
|
370
|
+
index: options.changeIndex,
|
|
371
|
+
path: addressPathForIndex(options.changeIndex, options.changeChain),
|
|
372
|
+
},
|
|
373
|
+
];
|
|
374
|
+
|
|
375
|
+
const ledgerResult = await listLedgerAddressRequests({
|
|
376
|
+
expectedContext: options.expectedContext,
|
|
377
|
+
requests,
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
const result = {
|
|
381
|
+
ledgerResult,
|
|
382
|
+
plan: null,
|
|
383
|
+
error: ledgerResult.error,
|
|
384
|
+
};
|
|
385
|
+
|
|
386
|
+
if (ledgerResult.error) {
|
|
387
|
+
return result;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const sourceAddress = ledgerResult.addressResults.find(item => item.role === 'source');
|
|
391
|
+
const changeAddress = ledgerResult.addressResults.find(item => item.role === 'change');
|
|
392
|
+
|
|
393
|
+
if (!sourceAddress || !sourceAddress.ok) {
|
|
394
|
+
result.error = sourceAddress && sourceAddress.error
|
|
395
|
+
? sourceAddress.error
|
|
396
|
+
: new Error('Could not derive source address from Ledger.');
|
|
397
|
+
return result;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
if (!sourceAddress.matchesLedger) {
|
|
401
|
+
result.error = new Error('Source address mismatch: locally derived RVN address does not match Ledger-returned address.');
|
|
402
|
+
return result;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
if (!changeAddress || !changeAddress.ok) {
|
|
406
|
+
result.error = changeAddress && changeAddress.error
|
|
407
|
+
? changeAddress.error
|
|
408
|
+
: new Error('Could not derive change address from Ledger.');
|
|
409
|
+
return result;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
if (!changeAddress.matchesLedger) {
|
|
413
|
+
result.error = new Error('Change address mismatch: locally derived RVN address does not match Ledger-returned address.');
|
|
414
|
+
return result;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
try {
|
|
418
|
+
const utxos = await explorer.getUtxos(sourceAddress.rvnAddress);
|
|
419
|
+
result.plan = buildDryRunSendPlan({
|
|
420
|
+
sourceChain: sourceAddress.chain,
|
|
421
|
+
sourceIndex: sourceAddress.index,
|
|
422
|
+
sourcePath: sourceAddress.path,
|
|
423
|
+
sourceAddress: sourceAddress.rvnAddress,
|
|
424
|
+
destinationAddress: options.destination,
|
|
425
|
+
amountSats: options.amountSats,
|
|
426
|
+
utxos,
|
|
427
|
+
feeRateSatsPerByte: options.feeRateSatsPerByte,
|
|
428
|
+
dustSats: options.dustSats,
|
|
429
|
+
changeChain: changeAddress.chain,
|
|
430
|
+
changeIndex: changeAddress.index,
|
|
431
|
+
changePath: changeAddress.path,
|
|
432
|
+
changeAddress: changeAddress.rvnAddress,
|
|
433
|
+
});
|
|
434
|
+
} catch (error) {
|
|
435
|
+
result.error = error;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
return result;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function printSendPlanResult(result, options = {}) {
|
|
442
|
+
const signingRequested = options.signingRequested || false;
|
|
443
|
+
console.log('RVN Ledger send planner');
|
|
444
|
+
if (signingRequested) {
|
|
445
|
+
console.log('SIGNING REQUESTED');
|
|
446
|
+
console.log('No transaction has been signed yet. Nothing will be broadcast.');
|
|
447
|
+
} else {
|
|
448
|
+
console.log('DRY RUN ONLY');
|
|
449
|
+
console.log('No transaction was signed or broadcast.');
|
|
450
|
+
}
|
|
451
|
+
console.log('');
|
|
452
|
+
|
|
453
|
+
const ledgerResult = result.ledgerResult;
|
|
454
|
+
if (ledgerResult) {
|
|
455
|
+
console.log(`Test context: ${ledgerResult.expectedContext.label} (${ledgerResult.expectedContext.purpose})`);
|
|
456
|
+
console.log(`HID detected: ${ledgerResult.hidDetected ? 'yes' : 'no'}`);
|
|
457
|
+
console.log(`HID device count: ${ledgerResult.hidDeviceCount}`);
|
|
458
|
+
|
|
459
|
+
if (ledgerResult.app && ledgerResult.app.name) {
|
|
460
|
+
console.log(`Open Ledger app: ${ledgerResult.app.name} ${ledgerResult.app.version}`);
|
|
461
|
+
console.log(`Detected context: ${ledgerResult.app.context.label} (${ledgerResult.app.context.purpose})`);
|
|
462
|
+
} else if (ledgerResult.app && ledgerResult.app.error) {
|
|
463
|
+
console.log('Open Ledger app: could not detect');
|
|
464
|
+
printError(ledgerResult.app.error, console.log);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
for (const warning of ledgerResult.warnings) {
|
|
468
|
+
console.log(`Warning: ${warning}`);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
if (result.error) {
|
|
473
|
+
console.log('');
|
|
474
|
+
printError(result.error, console.log);
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
const plan = result.plan;
|
|
479
|
+
console.log('');
|
|
480
|
+
console.log(`Source chain: ${plan.sourceChain}`);
|
|
481
|
+
console.log(`Source index: ${plan.sourceIndex}`);
|
|
482
|
+
console.log(`Source path: ${plan.sourcePath}`);
|
|
483
|
+
console.log(`Source address: ${plan.sourceAddress}`);
|
|
484
|
+
console.log(`Destination address: ${plan.destinationAddress}`);
|
|
485
|
+
console.log(`Amount: ${formatRvn(plan.amountSats)}`);
|
|
486
|
+
|
|
487
|
+
console.log('');
|
|
488
|
+
console.log('Selected UTXOs:');
|
|
489
|
+
console.log(formatTable(plan.selectedUtxos.map(utxo => ({
|
|
490
|
+
txid: utxo.txid,
|
|
491
|
+
vout: utxo.vout,
|
|
492
|
+
value: formatRvn(utxo.valueSats),
|
|
493
|
+
confirmations: utxo.confirmations,
|
|
494
|
+
})), [
|
|
495
|
+
{ key: 'txid', label: 'txid' },
|
|
496
|
+
{ key: 'vout', label: 'vout' },
|
|
497
|
+
{ key: 'value', label: 'value' },
|
|
498
|
+
{ key: 'confirmations', label: 'confirmations' },
|
|
499
|
+
]));
|
|
500
|
+
|
|
501
|
+
console.log('');
|
|
502
|
+
console.log(`Fee rate: ${plan.feeRateSatsPerByte.toString()} sat/byte`);
|
|
503
|
+
console.log(`Estimated tx bytes: ${plan.estimatedBytes}`);
|
|
504
|
+
console.log(`Estimated fee: ${formatRvn(plan.feeSats)}`);
|
|
505
|
+
if (plan.dustRemainderAddedToFeeSats > 0n) {
|
|
506
|
+
console.log(`Dust/remainder added to fee: ${formatRvn(plan.dustRemainderAddedToFeeSats)}`);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
console.log('');
|
|
510
|
+
console.log(`Change chain: ${plan.changeChain}`);
|
|
511
|
+
console.log(`Change index: ${plan.changeIndex}`);
|
|
512
|
+
console.log(`Change path: ${plan.changePath}`);
|
|
513
|
+
console.log(`Change address: ${plan.changeAddress}`);
|
|
514
|
+
console.log(`Change amount: ${formatRvn(plan.changeSats)}`);
|
|
515
|
+
|
|
516
|
+
console.log('');
|
|
517
|
+
console.log('Final outputs:');
|
|
518
|
+
console.log(formatTable(plan.outputs.map(output => ({
|
|
519
|
+
type: output.type,
|
|
520
|
+
address: output.address,
|
|
521
|
+
value: formatRvn(output.valueSats),
|
|
522
|
+
})), [
|
|
523
|
+
{ key: 'type', label: 'type' },
|
|
524
|
+
{ key: 'address', label: 'address' },
|
|
525
|
+
{ key: 'value', label: 'value' },
|
|
526
|
+
]));
|
|
527
|
+
|
|
528
|
+
console.log('');
|
|
529
|
+
if (signingRequested) {
|
|
530
|
+
console.log('No transaction has been signed yet. Nothing will be broadcast.');
|
|
531
|
+
} else {
|
|
532
|
+
console.log('No transaction was signed or broadcast.');
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
async function confirmLedgerSigning() {
|
|
537
|
+
const rl = readline.createInterface({
|
|
538
|
+
input: process.stdin,
|
|
539
|
+
output: process.stdout,
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
try {
|
|
543
|
+
const answer = await rl.question('Type SIGN to call Ledger signing: ');
|
|
544
|
+
return answer === 'SIGN';
|
|
545
|
+
} finally {
|
|
546
|
+
rl.close();
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
async function confirmBroadcast() {
|
|
551
|
+
const rl = readline.createInterface({
|
|
552
|
+
input: process.stdin,
|
|
553
|
+
output: process.stdout,
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
try {
|
|
557
|
+
const answer = await rl.question('Type BROADCAST to send this transaction to the Ravencoin network: ');
|
|
558
|
+
return answer === 'BROADCAST';
|
|
559
|
+
} finally {
|
|
560
|
+
rl.close();
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
function printSignedTransaction(result) {
|
|
565
|
+
console.log('');
|
|
566
|
+
console.log('Signed raw transaction hex:');
|
|
567
|
+
console.log(result.signedRawTx);
|
|
568
|
+
console.log('');
|
|
569
|
+
console.log(`TXID: ${result.txid}`);
|
|
570
|
+
console.log('');
|
|
571
|
+
console.log('Signed transaction generated. Nothing was broadcast.');
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
function printBroadcastPreview(rawTx, inspection, provider) {
|
|
575
|
+
console.log('RVN manual broadcast');
|
|
576
|
+
console.log('WARNING: broadcasting is irreversible once the Ravencoin network accepts the transaction.');
|
|
577
|
+
console.log('');
|
|
578
|
+
console.log(`Broadcast provider: ${provider.label}`);
|
|
579
|
+
if (provider.endpoint) {
|
|
580
|
+
console.log(`Broadcast endpoint: ${provider.endpoint}`);
|
|
581
|
+
}
|
|
582
|
+
console.log(`TXID: ${inspection.txid}`);
|
|
583
|
+
console.log(`Estimated bytes: ${inspection.bytes}`);
|
|
584
|
+
console.log(`Inputs: ${inspection.inputCount}`);
|
|
585
|
+
console.log(`Outputs: ${inspection.outputCount}`);
|
|
586
|
+
console.log('');
|
|
587
|
+
console.log('No transaction has been broadcast yet.');
|
|
588
|
+
console.log(`Raw transaction hex length: ${rawTx.length}`);
|
|
589
|
+
console.log('');
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
async function main() {
|
|
593
|
+
if (process.argv.length === 2) {
|
|
594
|
+
const { runInteractiveCli } = require('./interactive');
|
|
595
|
+
await runInteractiveCli();
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
const program = new Command();
|
|
600
|
+
|
|
601
|
+
program
|
|
602
|
+
.name('RavenSafe')
|
|
603
|
+
.description('RavenSafe CLI. Run without a command for the guided Ravencoin Ledger wallet flow.');
|
|
604
|
+
|
|
605
|
+
program
|
|
606
|
+
.command('addresses')
|
|
607
|
+
.description('List Ledger-derived RVN receive addresses')
|
|
608
|
+
.option('--start <number>', 'first address index', '0')
|
|
609
|
+
.option('--count <number>', 'number of addresses to list, 1 through 100', '10')
|
|
610
|
+
.option('--app <context>', 'expected Ledger app context: current, ravencoin, or bitcoin', 'ravencoin')
|
|
611
|
+
.action(async options => {
|
|
612
|
+
let parsed;
|
|
613
|
+
try {
|
|
614
|
+
parsed = parseAddressOptions(options);
|
|
615
|
+
} catch (error) {
|
|
616
|
+
console.error(`Error: ${error.message}`);
|
|
617
|
+
process.exitCode = 1;
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
const result = await listLedgerAddresses(parsed);
|
|
622
|
+
printAddressResult(result);
|
|
623
|
+
process.exitCode = result.allMatch ? 0 : 2;
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
program
|
|
627
|
+
.command('send')
|
|
628
|
+
.description('Prepare a dry-run RVN send plan; with --sign, sign only and never broadcast')
|
|
629
|
+
.option('--from-chain <receiving|change>', 'source address chain', 'receiving')
|
|
630
|
+
.requiredOption('--from-index <number>', 'source address index')
|
|
631
|
+
.requiredOption('--to <RVN_ADDRESS>', 'destination RVN address')
|
|
632
|
+
.requiredOption('--amount <RVN_AMOUNT>', 'amount to send in RVN')
|
|
633
|
+
.option('--fee-rate <sat_per_byte>', 'fee rate in sat/byte; defaults to config.ravencoin.feeRateSatPerByte')
|
|
634
|
+
.option('--change-chain <receiving|change>', 'change address chain', 'change')
|
|
635
|
+
.option('--change-index <number>', 'change address index; defaults to config.ravencoin.defaultChangeIndex')
|
|
636
|
+
.option('--app <context>', 'expected Ledger app context: current, ravencoin, or bitcoin', 'ravencoin')
|
|
637
|
+
.option('--dry-run', 'dry-run only; no signing or broadcasting', true)
|
|
638
|
+
.option('--sign', 'after showing the summary, ask for SIGN and call Ledger signing')
|
|
639
|
+
.action(async options => {
|
|
640
|
+
let parsed;
|
|
641
|
+
try {
|
|
642
|
+
parsed = parseSendOptions(options);
|
|
643
|
+
} catch (error) {
|
|
644
|
+
console.error(`Error: ${error.message}`);
|
|
645
|
+
process.exitCode = 1;
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
const result = await createDryRunSendPlan(parsed);
|
|
650
|
+
printSendPlanResult(result, {
|
|
651
|
+
signingRequested: parsed.sign && Boolean(result.plan),
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
if (!result.plan) {
|
|
655
|
+
process.exitCode = 2;
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
if (!parsed.sign) {
|
|
660
|
+
process.exitCode = 0;
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
const confirmed = await confirmLedgerSigning();
|
|
665
|
+
if (!confirmed) {
|
|
666
|
+
console.log('Signing aborted. Nothing was signed or broadcast.');
|
|
667
|
+
process.exitCode = 1;
|
|
668
|
+
return;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
try {
|
|
672
|
+
const signed = await signDryRunPlan(result.plan);
|
|
673
|
+
printSignedTransaction(signed);
|
|
674
|
+
process.exitCode = 0;
|
|
675
|
+
} catch (error) {
|
|
676
|
+
console.log('');
|
|
677
|
+
printError(error, console.log);
|
|
678
|
+
console.log('Nothing was broadcast.');
|
|
679
|
+
process.exitCode = 2;
|
|
680
|
+
}
|
|
681
|
+
});
|
|
682
|
+
|
|
683
|
+
program
|
|
684
|
+
.command('broadcast')
|
|
685
|
+
.description('Manually broadcast a signed raw RVN transaction after BROADCAST confirmation')
|
|
686
|
+
.option('--rawtx <SIGNED_RAW_TX_HEX>', 'signed raw transaction hex')
|
|
687
|
+
.option('--file <path>', 'file containing signed raw transaction hex')
|
|
688
|
+
.action(async options => {
|
|
689
|
+
let parsed;
|
|
690
|
+
let rawTx;
|
|
691
|
+
let inspection;
|
|
692
|
+
const provider = describeBroadcastProvider();
|
|
693
|
+
try {
|
|
694
|
+
parsed = parseBroadcastOptions(options);
|
|
695
|
+
rawTx = await loadBroadcastRawTx(parsed);
|
|
696
|
+
inspection = inspectRawTransaction(rawTx);
|
|
697
|
+
} catch (error) {
|
|
698
|
+
console.error(`Error: ${error.message}`);
|
|
699
|
+
process.exitCode = 1;
|
|
700
|
+
return;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
printBroadcastPreview(rawTx, inspection, provider);
|
|
704
|
+
|
|
705
|
+
const confirmed = await confirmBroadcast();
|
|
706
|
+
if (!confirmed) {
|
|
707
|
+
console.log('Broadcast aborted. Nothing was broadcast.');
|
|
708
|
+
process.exitCode = 1;
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
try {
|
|
713
|
+
const txid = await broadcastRawTx(rawTx);
|
|
714
|
+
console.log('');
|
|
715
|
+
console.log(`Broadcast provider: ${provider.label}`);
|
|
716
|
+
console.log(`Broadcast TXID: ${txid}`);
|
|
717
|
+
} catch (error) {
|
|
718
|
+
console.log('');
|
|
719
|
+
printError(error, console.log);
|
|
720
|
+
console.log('Broadcast failed.');
|
|
721
|
+
process.exitCode = 2;
|
|
722
|
+
}
|
|
723
|
+
});
|
|
724
|
+
|
|
725
|
+
program
|
|
726
|
+
.command('scan')
|
|
727
|
+
.description('Scan Ledger-derived RVN addresses for balances')
|
|
728
|
+
.option('--start <number>', 'first address index', '0')
|
|
729
|
+
.option('--count <number>', 'number of addresses to scan per selected chain, 1 through 200', '10')
|
|
730
|
+
.option('--chain <chain>', 'address chain to scan: receiving, change, or both', 'receiving')
|
|
731
|
+
.option('--app <context>', 'expected Ledger app context: current, ravencoin, or bitcoin', 'ravencoin')
|
|
732
|
+
.action(async options => {
|
|
733
|
+
let parsed;
|
|
734
|
+
try {
|
|
735
|
+
parsed = parseScanOptions(options);
|
|
736
|
+
} catch (error) {
|
|
737
|
+
console.error(`Error: ${error.message}`);
|
|
738
|
+
process.exitCode = 1;
|
|
739
|
+
return;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
const result = await scanLedgerAddresses(parsed);
|
|
743
|
+
printScanResult(result);
|
|
744
|
+
process.exitCode = result.allMatch ? 0 : 2;
|
|
745
|
+
});
|
|
746
|
+
|
|
747
|
+
await program.parseAsync(process.argv);
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
main().catch(error => {
|
|
751
|
+
console.error(error && error.stack ? error.stack : error);
|
|
752
|
+
process.exitCode = 1;
|
|
753
|
+
});
|