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,972 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const readline = require('readline/promises');
|
|
4
|
+
const config = require('../config');
|
|
5
|
+
const ui = require('./ui');
|
|
6
|
+
const {
|
|
7
|
+
broadcastRawTx,
|
|
8
|
+
createExplorer,
|
|
9
|
+
} = require('../explorer/zelcore');
|
|
10
|
+
const {
|
|
11
|
+
addressPathForIndex,
|
|
12
|
+
listLedgerAddressRequests,
|
|
13
|
+
verifyLedgerAddressOnDevice,
|
|
14
|
+
} = require('../ledger');
|
|
15
|
+
const {
|
|
16
|
+
formatRvn,
|
|
17
|
+
readAddressBalance,
|
|
18
|
+
readAddressUsage,
|
|
19
|
+
scanLedgerAddressRequests,
|
|
20
|
+
} = require('../core/scan');
|
|
21
|
+
const { createSessionCache } = require('../core/session-cache');
|
|
22
|
+
const { signDryRunPlan } = require('../tx/signer');
|
|
23
|
+
const {
|
|
24
|
+
buildWalletSendPlan,
|
|
25
|
+
parsePositiveInteger,
|
|
26
|
+
parseRvnAmountToSats,
|
|
27
|
+
validateRvnAddress,
|
|
28
|
+
} = require('../tx/builder');
|
|
29
|
+
|
|
30
|
+
class UserCancelledError extends Error {
|
|
31
|
+
constructor() {
|
|
32
|
+
super('Cancelled by user. Nothing was signed or broadcast.');
|
|
33
|
+
this.name = 'UserCancelledError';
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const MAX_CUSTOM_RANGE_SIZE = 200;
|
|
38
|
+
const SCAN_PRESETS = Object.freeze({
|
|
39
|
+
quick: {
|
|
40
|
+
label: 'Quick scan',
|
|
41
|
+
description: 'first 20 addresses',
|
|
42
|
+
receiving: { start: 0, end: 15 },
|
|
43
|
+
change: { start: 0, end: 5 },
|
|
44
|
+
},
|
|
45
|
+
standard: {
|
|
46
|
+
label: 'Standard scan',
|
|
47
|
+
description: 'first 50 addresses',
|
|
48
|
+
receiving: { start: 0, end: 40 },
|
|
49
|
+
change: { start: 0, end: 10 },
|
|
50
|
+
},
|
|
51
|
+
deep: {
|
|
52
|
+
label: 'Deep scan',
|
|
53
|
+
description: 'first 100 addresses',
|
|
54
|
+
receiving: { start: 0, end: 70 },
|
|
55
|
+
change: { start: 0, end: 30 },
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const RAVENCOIN_CONTEXT = Object.freeze({
|
|
60
|
+
label: 'Ravencoin app',
|
|
61
|
+
purpose: 'preferred/documented target',
|
|
62
|
+
});
|
|
63
|
+
const EXPLORER_TX_BASE_URL = 'https://explorer.rvn.zelcore.io/tx';
|
|
64
|
+
|
|
65
|
+
function errorLines(error) {
|
|
66
|
+
const lines = [`Error: ${error.message || String(error)}`];
|
|
67
|
+
if (error.statusCode) {
|
|
68
|
+
lines.push(`Status: ${error.statusCode}`);
|
|
69
|
+
}
|
|
70
|
+
if (error.endpoint) {
|
|
71
|
+
lines.push(`Endpoint: ${error.endpoint}`);
|
|
72
|
+
}
|
|
73
|
+
if (error.responseBody) {
|
|
74
|
+
lines.push(`Response body: ${error.responseBody}`);
|
|
75
|
+
}
|
|
76
|
+
if (error.hint) {
|
|
77
|
+
lines.push(`Hint: ${error.hint}`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return lines;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function printError(error, write = console.log) {
|
|
84
|
+
const lines = errorLines(error);
|
|
85
|
+
if (write === console.log) {
|
|
86
|
+
ui.errorBox('Error', lines);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
for (const line of lines) {
|
|
91
|
+
write(line);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function buildIndexRangeRequests(chain, startIndex, endIndex) {
|
|
96
|
+
return Array.from({ length: endIndex - startIndex + 1 }, (_, offset) => {
|
|
97
|
+
const index = startIndex + offset;
|
|
98
|
+
return {
|
|
99
|
+
role: `${chain}:${index}`,
|
|
100
|
+
chain,
|
|
101
|
+
index,
|
|
102
|
+
path: addressPathForIndex(index, chain),
|
|
103
|
+
};
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function scanPlanRequests(scanPlan) {
|
|
108
|
+
return [
|
|
109
|
+
...buildIndexRangeRequests('receiving', scanPlan.receiving.start, scanPlan.receiving.end),
|
|
110
|
+
...buildIndexRangeRequests('change', scanPlan.change.start, scanPlan.change.end),
|
|
111
|
+
];
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function quickScanRequests() {
|
|
115
|
+
return scanPlanRequests(SCAN_PRESETS.quick);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function singleAddressRequest(chain, index) {
|
|
119
|
+
return {
|
|
120
|
+
role: `${chain}:${index}`,
|
|
121
|
+
chain,
|
|
122
|
+
index,
|
|
123
|
+
path: addressPathForIndex(index, chain),
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function balanceScanRequests(scanPlan = SCAN_PRESETS.quick) {
|
|
128
|
+
return scanPlanRequests(scanPlan);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function assertRavencoinApp(scanResult) {
|
|
132
|
+
if (scanResult.error) {
|
|
133
|
+
throw scanResult.error;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const detected = scanResult.app && scanResult.app.context;
|
|
137
|
+
if (!detected) {
|
|
138
|
+
throw new Error('Could not confirm that the Ledger Ravencoin app is open. Open the Ravencoin app and try again.');
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (detected && detected.key !== 'ravencoin') {
|
|
142
|
+
throw new Error(`Open the Ledger Ravencoin app before continuing. Detected: ${detected.label}.`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function fundedRows(scanResults) {
|
|
147
|
+
return scanResults.filter(item => {
|
|
148
|
+
return item.ok && (item.confirmedSats > 0n || item.unconfirmedSats > 0n || item.utxoCount > 0);
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function printFundedAddresses(scanResult) {
|
|
153
|
+
const funded = fundedRows(scanResult.scanResults);
|
|
154
|
+
|
|
155
|
+
if (funded.length === 0) {
|
|
156
|
+
ui.infoBox('Funded Addresses', [
|
|
157
|
+
'No funded addresses were found in this scan range.',
|
|
158
|
+
'Try a larger scan size if you expect funds on later indexes.',
|
|
159
|
+
]);
|
|
160
|
+
} else {
|
|
161
|
+
const lines = [];
|
|
162
|
+
funded.forEach((item, index) => {
|
|
163
|
+
if (index > 0) {
|
|
164
|
+
lines.push('');
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
lines.push(`${ui.style.bold(`${item.chain} #${item.index}`)} ${formatRvn(item.confirmedSats)} confirmed ${formatRvn(item.unconfirmedSats)} unconfirmed`);
|
|
168
|
+
lines.push(`${item.utxoCount} UTXO${item.utxoCount === 1 ? '' : 's'} ${ui.style.dim(item.rvnAddress)}`);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
ui.printBox(lines, {
|
|
172
|
+
title: 'Funded Addresses',
|
|
173
|
+
color: 'green',
|
|
174
|
+
width: 84,
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
console.log('');
|
|
179
|
+
ui.keyValueBox('Balance Summary', [
|
|
180
|
+
{ label: 'Receiving confirmed', value: formatRvn(scanResult.receivingConfirmedSats) },
|
|
181
|
+
{ label: 'Change confirmed', value: formatRvn(scanResult.changeConfirmedSats) },
|
|
182
|
+
{ label: 'Grand total', value: formatRvn(scanResult.grandTotalConfirmedSats) },
|
|
183
|
+
{ label: 'Total UTXOs', value: String(scanResult.totalUtxoCount) },
|
|
184
|
+
], {
|
|
185
|
+
color: 'cyan',
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function summarizeScanResults(scanResults) {
|
|
190
|
+
const okResults = scanResults.filter(item => item.ok);
|
|
191
|
+
const receivingConfirmedSats = okResults
|
|
192
|
+
.filter(item => item.chain === 'receiving')
|
|
193
|
+
.reduce((total, item) => total + item.confirmedSats, 0n);
|
|
194
|
+
const changeConfirmedSats = okResults
|
|
195
|
+
.filter(item => item.chain === 'change')
|
|
196
|
+
.reduce((total, item) => total + item.confirmedSats, 0n);
|
|
197
|
+
|
|
198
|
+
return {
|
|
199
|
+
expectedContext: RAVENCOIN_CONTEXT,
|
|
200
|
+
hidDetected: true,
|
|
201
|
+
hidDeviceCount: null,
|
|
202
|
+
app: null,
|
|
203
|
+
warnings: [],
|
|
204
|
+
scanResults,
|
|
205
|
+
receivingConfirmedSats,
|
|
206
|
+
changeConfirmedSats,
|
|
207
|
+
grandTotalConfirmedSats: receivingConfirmedSats + changeConfirmedSats,
|
|
208
|
+
totalUtxoCount: okResults.reduce((total, item) => total + item.utxoCount, 0),
|
|
209
|
+
allMatch: scanResults.length > 0 &&
|
|
210
|
+
scanResults.every(item => item.ok && item.matchesLedger),
|
|
211
|
+
error: null,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async function refreshCachedEntry(explorer, sessionCache, entry) {
|
|
216
|
+
const balance = await readAddressBalance(explorer, entry.address);
|
|
217
|
+
return sessionCache.updateBalance(entry.chain, entry.index, balance);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async function refreshCachedEntries(explorer, sessionCache, entries) {
|
|
221
|
+
for (const entry of entries) {
|
|
222
|
+
await refreshCachedEntry(explorer, sessionCache, entry);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async function scanAddressRequestsWithCache(options) {
|
|
227
|
+
const {
|
|
228
|
+
explorer,
|
|
229
|
+
sessionCache,
|
|
230
|
+
requests,
|
|
231
|
+
refreshCached = true,
|
|
232
|
+
} = options;
|
|
233
|
+
const cachedBefore = [];
|
|
234
|
+
const missingRequests = [];
|
|
235
|
+
|
|
236
|
+
for (const request of requests) {
|
|
237
|
+
const cached = sessionCache.get(request.chain, request.index);
|
|
238
|
+
if (cached && cached.ok && cached.matchesLedger) {
|
|
239
|
+
cachedBefore.push(cached);
|
|
240
|
+
} else {
|
|
241
|
+
missingRequests.push(request);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const failedScans = new Map();
|
|
246
|
+
if (missingRequests.length > 0) {
|
|
247
|
+
const scanResult = await scanLedgerAddressRequests({
|
|
248
|
+
expectedContext: 'ravencoin',
|
|
249
|
+
requests: missingRequests,
|
|
250
|
+
explorer,
|
|
251
|
+
});
|
|
252
|
+
assertRavencoinApp(scanResult);
|
|
253
|
+
|
|
254
|
+
for (const item of scanResult.scanResults) {
|
|
255
|
+
if (!sessionCache.upsertScanItem(item)) {
|
|
256
|
+
failedScans.set(sessionCache.key(item.chain, item.index), item);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (refreshCached) {
|
|
262
|
+
await refreshCachedEntries(explorer, sessionCache, cachedBefore);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const scanResults = requests.map(request => {
|
|
266
|
+
const cached = sessionCache.get(request.chain, request.index);
|
|
267
|
+
if (cached && cached.ok && cached.matchesLedger) {
|
|
268
|
+
return sessionCache.toScanItem(cached);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return failedScans.get(sessionCache.key(request.chain, request.index)) || {
|
|
272
|
+
chain: request.chain,
|
|
273
|
+
index: request.index,
|
|
274
|
+
path: request.path,
|
|
275
|
+
rvnAddress: null,
|
|
276
|
+
ledgerAddress: null,
|
|
277
|
+
matchesLedger: false,
|
|
278
|
+
confirmedSats: 0n,
|
|
279
|
+
unconfirmedSats: 0n,
|
|
280
|
+
utxos: [],
|
|
281
|
+
utxoCount: 0,
|
|
282
|
+
txAppearances: null,
|
|
283
|
+
unconfirmedTxAppearances: null,
|
|
284
|
+
ok: false,
|
|
285
|
+
error: new Error('Address was not available in the session cache.'),
|
|
286
|
+
};
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
return summarizeScanResults(scanResults);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
async function scanWalletBalances(explorer, sessionCache, scanPlan) {
|
|
293
|
+
return scanAddressRequestsWithCache({
|
|
294
|
+
explorer,
|
|
295
|
+
sessionCache,
|
|
296
|
+
requests: balanceScanRequests(scanPlan),
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function isUnusedReceiveAddress(item) {
|
|
301
|
+
if (!item.ok || !item.matchesLedger) {
|
|
302
|
+
return false;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const hasBalance = item.confirmedSats !== 0n || item.unconfirmedSats !== 0n;
|
|
306
|
+
const hasAddressUsage = Number.isSafeInteger(item.txAppearances);
|
|
307
|
+
|
|
308
|
+
if (hasAddressUsage) {
|
|
309
|
+
const hasConfirmedUse = Number.isSafeInteger(item.txAppearances) && item.txAppearances > 0;
|
|
310
|
+
const hasUnconfirmedUse = Number.isSafeInteger(item.unconfirmedTxAppearances) &&
|
|
311
|
+
item.unconfirmedTxAppearances > 0;
|
|
312
|
+
|
|
313
|
+
return !hasBalance && !hasConfirmedUse && !hasUnconfirmedUse;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
if (hasBalance || item.utxoCount !== 0) {
|
|
317
|
+
return false;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
return item.txAppearances === null || item.txAppearances === 0;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
async function refreshCachedReceiveUsage(explorer, sessionCache, entry) {
|
|
324
|
+
const usage = await readAddressUsage(explorer, entry.address);
|
|
325
|
+
return sessionCache.updateUsage(entry.chain, entry.index, usage);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
async function deriveReceiveAddressUsage(explorer, sessionCache, index) {
|
|
329
|
+
const request = singleAddressRequest('receiving', index);
|
|
330
|
+
const ledgerResult = await listLedgerAddressRequests({
|
|
331
|
+
expectedContext: 'ravencoin',
|
|
332
|
+
requests: [request],
|
|
333
|
+
});
|
|
334
|
+
assertRavencoinApp(ledgerResult);
|
|
335
|
+
|
|
336
|
+
const item = ledgerResult.addressResults[0];
|
|
337
|
+
if (!item || !item.ok) {
|
|
338
|
+
throw item && item.error
|
|
339
|
+
? item.error
|
|
340
|
+
: new Error(`Could not derive receiving address ${index}.`);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
if (!item.matchesLedger) {
|
|
344
|
+
throw new Error('Locally derived receive address does not match Ledger-returned address.');
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const usage = await readAddressUsage(explorer, item.rvnAddress);
|
|
348
|
+
return sessionCache.set({
|
|
349
|
+
...item,
|
|
350
|
+
confirmedSats: usage.confirmedSats,
|
|
351
|
+
unconfirmedSats: usage.unconfirmedSats,
|
|
352
|
+
utxos: [],
|
|
353
|
+
utxoCount: 0,
|
|
354
|
+
txAppearances: usage.txAppearances,
|
|
355
|
+
unconfirmedTxAppearances: usage.unconfirmedTxAppearances,
|
|
356
|
+
balanceSource: usage.balanceSource,
|
|
357
|
+
balanceError: usage.balanceError,
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
async function ensureReceiveAddressUsage(explorer, sessionCache, index) {
|
|
362
|
+
const cached = sessionCache.get('receiving', index);
|
|
363
|
+
if (cached && cached.ok && cached.matchesLedger) {
|
|
364
|
+
return refreshCachedReceiveUsage(explorer, sessionCache, cached);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
return deriveReceiveAddressUsage(explorer, sessionCache, index);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function hasAddressUsageFields(item) {
|
|
371
|
+
return Number.isSafeInteger(item.txAppearances);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
async function ensureReceiveAddressForUnusedCheck(explorer, sessionCache, index) {
|
|
375
|
+
const usageItem = await ensureReceiveAddressUsage(explorer, sessionCache, index);
|
|
376
|
+
if (hasAddressUsageFields(usageItem)) {
|
|
377
|
+
return usageItem;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const result = await scanAddressRequestsWithCache({
|
|
381
|
+
explorer,
|
|
382
|
+
sessionCache,
|
|
383
|
+
requests: [singleAddressRequest('receiving', index)],
|
|
384
|
+
});
|
|
385
|
+
const item = result.scanResults[0];
|
|
386
|
+
if (!item || !item.ok || !item.matchesLedger) {
|
|
387
|
+
throw item && item.error
|
|
388
|
+
? item.error
|
|
389
|
+
: new Error(`Could not check receiving address ${index}.`);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
return sessionCache.get('receiving', index) || item;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function collectSpendableUtxosFromCache(sessionCache) {
|
|
396
|
+
const spendable = [];
|
|
397
|
+
|
|
398
|
+
for (const item of sessionCache.values()) {
|
|
399
|
+
if (!item.ok || !item.matchesLedger || item.utxoCount === 0) {
|
|
400
|
+
continue;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
for (const utxo of item.utxos) {
|
|
404
|
+
if (utxo.confirmations <= 0) {
|
|
405
|
+
continue;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
spendable.push({
|
|
409
|
+
...utxo,
|
|
410
|
+
sourceChain: item.chain,
|
|
411
|
+
sourceIndex: item.index,
|
|
412
|
+
sourcePath: item.path,
|
|
413
|
+
sourceAddress: item.address,
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
return spendable;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function findChangeAddress(sessionCache) {
|
|
422
|
+
const changeIndex = config.ravencoin.defaultChangeIndex;
|
|
423
|
+
const item = sessionCache.get('change', changeIndex);
|
|
424
|
+
|
|
425
|
+
if (!item || !item.ok || !item.matchesLedger) {
|
|
426
|
+
throw new Error(`Could not derive verified change address at change index ${changeIndex}.`);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
return item;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
async function ask(rl, prompt) {
|
|
433
|
+
const answer = await rl.question(prompt);
|
|
434
|
+
if (!process.stdout.isTTY) {
|
|
435
|
+
console.log('');
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
const text = answer.trim();
|
|
439
|
+
if (text.toLowerCase() === 'cancel') {
|
|
440
|
+
throw new UserCancelledError();
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
return text;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function parseScanRange(value, label) {
|
|
447
|
+
const text = String(value || '').trim();
|
|
448
|
+
const match = text.match(/^(\d+)-(\d+)$/);
|
|
449
|
+
if (!match) {
|
|
450
|
+
throw new Error(`${label} range must look like 0-30.`);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
const start = Number(match[1]);
|
|
454
|
+
const end = Number(match[2]);
|
|
455
|
+
if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end)) {
|
|
456
|
+
throw new Error(`${label} range is too large.`);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
if (end < start) {
|
|
460
|
+
throw new Error(`${label} range must start before it ends.`);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
if (end - start + 1 > MAX_CUSTOM_RANGE_SIZE) {
|
|
464
|
+
throw new Error(`${label} range can include at most ${MAX_CUSTOM_RANGE_SIZE} addresses.`);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
return { start, end };
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
async function askCustomScanRange(rl, label) {
|
|
471
|
+
while (true) {
|
|
472
|
+
const answer = await ask(rl, ui.prompt(`${label} index range`, '(example 0-30)'));
|
|
473
|
+
try {
|
|
474
|
+
return parseScanRange(answer, label);
|
|
475
|
+
} catch (error) {
|
|
476
|
+
ui.warning(error.message);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
async function askScanPlan(rl) {
|
|
482
|
+
ui.section('Scan Wallet Balances', 'Reads public keys and public blockchain data only.');
|
|
483
|
+
ui.menu('Scan Size', [
|
|
484
|
+
{ value: 1, label: SCAN_PRESETS.quick.label, description: `${SCAN_PRESETS.quick.description}, recommended` },
|
|
485
|
+
{ value: 2, label: SCAN_PRESETS.standard.label, description: SCAN_PRESETS.standard.description },
|
|
486
|
+
{ value: 3, label: SCAN_PRESETS.deep.label, description: SCAN_PRESETS.deep.description },
|
|
487
|
+
{ value: 4, label: 'Custom scan', description: 'choose receiving and change ranges' },
|
|
488
|
+
]);
|
|
489
|
+
|
|
490
|
+
while (true) {
|
|
491
|
+
const answer = await ask(rl, ui.prompt('Choose scan size', '[1]'));
|
|
492
|
+
if (answer === '' || answer === '1') {
|
|
493
|
+
return SCAN_PRESETS.quick;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
if (answer === '2') {
|
|
497
|
+
return SCAN_PRESETS.standard;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
if (answer === '3') {
|
|
501
|
+
return SCAN_PRESETS.deep;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
if (answer === '4') {
|
|
505
|
+
return {
|
|
506
|
+
label: 'Custom scan',
|
|
507
|
+
description: 'custom ranges',
|
|
508
|
+
receiving: await askCustomScanRange(rl, 'Receiving'),
|
|
509
|
+
change: await askCustomScanRange(rl, 'Change'),
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
ui.warning('Choose 1, 2, 3, or 4.');
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
async function askDestination(rl) {
|
|
518
|
+
while (true) {
|
|
519
|
+
const answer = await ask(rl, ui.prompt('Destination RVN address', '(or cancel)'));
|
|
520
|
+
try {
|
|
521
|
+
return validateRvnAddress(answer).address;
|
|
522
|
+
} catch (error) {
|
|
523
|
+
printError(error);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
async function askAmount(rl) {
|
|
529
|
+
while (true) {
|
|
530
|
+
const answer = await ask(rl, ui.prompt('Amount to send in RVN', '(or cancel)'));
|
|
531
|
+
try {
|
|
532
|
+
return parseRvnAmountToSats(answer);
|
|
533
|
+
} catch (error) {
|
|
534
|
+
printError(error);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
async function askFeeRate(rl) {
|
|
540
|
+
const recommended = config.ravencoin.feeRateSatPerByte;
|
|
541
|
+
|
|
542
|
+
console.log('');
|
|
543
|
+
ui.menu('Fee Option', [
|
|
544
|
+
{ value: 1, label: 'Recommended', description: `safe default, ${recommended} sat/byte` },
|
|
545
|
+
{ value: 2, label: 'Custom fee rate', description: 'enter sat/byte manually' },
|
|
546
|
+
]);
|
|
547
|
+
|
|
548
|
+
while (true) {
|
|
549
|
+
const answer = await ask(rl, ui.prompt('Choose fee option', '[1]'));
|
|
550
|
+
if (answer === '' || answer === '1') {
|
|
551
|
+
return BigInt(recommended);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
if (answer === '2') {
|
|
555
|
+
while (true) {
|
|
556
|
+
const custom = await ask(rl, ui.prompt('Custom fee rate in sat/byte'));
|
|
557
|
+
try {
|
|
558
|
+
return parsePositiveInteger(custom, 'fee rate');
|
|
559
|
+
} catch (error) {
|
|
560
|
+
printError(error);
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
ui.warning('Choose 1 or 2.');
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
function printTransactionSummary(plan) {
|
|
570
|
+
const sourceLabel = plan.sourceAddresses.length === 1
|
|
571
|
+
? plan.sourceAddresses[0].address
|
|
572
|
+
: `multiple wallet addresses (${plan.sourceAddresses.length})`;
|
|
573
|
+
|
|
574
|
+
const entries = [
|
|
575
|
+
{ label: 'Amount', value: formatRvn(plan.amountSats) },
|
|
576
|
+
{ label: 'Destination', value: plan.destinationAddress },
|
|
577
|
+
{ label: 'Source', value: sourceLabel },
|
|
578
|
+
{ label: 'Estimated fee', value: formatRvn(plan.feeSats) },
|
|
579
|
+
];
|
|
580
|
+
|
|
581
|
+
if (plan.changeSats > 0n) {
|
|
582
|
+
entries.push({ label: 'Change', value: `${formatRvn(plan.changeSats)} to ${plan.changeAddress}` });
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
console.log('');
|
|
586
|
+
ui.keyValueBox('Transaction Summary', entries, {
|
|
587
|
+
color: 'yellow',
|
|
588
|
+
width: 84,
|
|
589
|
+
});
|
|
590
|
+
ui.warningBox('Ledger Approval Required', [
|
|
591
|
+
'Review the amount and destination on the Ledger screen before approving.',
|
|
592
|
+
'Typing SIGN only starts device signing. The Ledger still must approve it.',
|
|
593
|
+
]);
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
function scanPlanLabel(scanPlan) {
|
|
597
|
+
return `receiving ${scanPlan.receiving.start}-${scanPlan.receiving.end}, change ${scanPlan.change.start}-${scanPlan.change.end}`;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
function hasRequestCoverage(sessionCache, requests) {
|
|
601
|
+
return requests.every(request => {
|
|
602
|
+
const cached = sessionCache.get(request.chain, request.index);
|
|
603
|
+
return cached && cached.ok && cached.matchesLedger;
|
|
604
|
+
});
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
async function ensureAddressCached(explorer, sessionCache, chain, index) {
|
|
608
|
+
const cached = sessionCache.get(chain, index);
|
|
609
|
+
if (cached && cached.ok && cached.matchesLedger) {
|
|
610
|
+
await refreshCachedEntry(explorer, sessionCache, cached);
|
|
611
|
+
return sessionCache.get(chain, index);
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
const result = await scanAddressRequestsWithCache({
|
|
615
|
+
explorer,
|
|
616
|
+
sessionCache,
|
|
617
|
+
requests: [singleAddressRequest(chain, index)],
|
|
618
|
+
refreshCached: false,
|
|
619
|
+
});
|
|
620
|
+
const item = result.scanResults[0];
|
|
621
|
+
if (!item || !item.ok || !item.matchesLedger) {
|
|
622
|
+
throw item && item.error
|
|
623
|
+
? item.error
|
|
624
|
+
: new Error(`Could not derive ${chain} address ${index}.`);
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
return sessionCache.get(chain, index);
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
async function refreshAllCachedBalances(explorer, sessionCache) {
|
|
631
|
+
await refreshCachedEntries(explorer, sessionCache, sessionCache.values());
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
function buildSendPlanFromCache(sessionCache, options) {
|
|
635
|
+
const changeAddress = findChangeAddress(sessionCache);
|
|
636
|
+
const spendableUtxos = collectSpendableUtxosFromCache(sessionCache);
|
|
637
|
+
|
|
638
|
+
return buildWalletSendPlan({
|
|
639
|
+
destinationAddress: options.destinationAddress,
|
|
640
|
+
amountSats: options.amountSats,
|
|
641
|
+
utxos: spendableUtxos,
|
|
642
|
+
feeRateSatsPerByte: options.feeRateSatsPerByte,
|
|
643
|
+
dustSats: BigInt(config.ravencoin.dustSats),
|
|
644
|
+
changeChain: changeAddress.chain,
|
|
645
|
+
changeIndex: changeAddress.index,
|
|
646
|
+
changePath: changeAddress.path,
|
|
647
|
+
changeAddress: changeAddress.address,
|
|
648
|
+
});
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
function isInsufficientFundsError(error) {
|
|
652
|
+
return Boolean(error && error.message && error.message.includes('insufficient funds'));
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
async function ensureQuickScanCoverage(explorer, sessionCache) {
|
|
656
|
+
await scanAddressRequestsWithCache({
|
|
657
|
+
explorer,
|
|
658
|
+
sessionCache,
|
|
659
|
+
requests: quickScanRequests(),
|
|
660
|
+
});
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
async function buildSendPlanWithCache(explorer, sessionCache, options) {
|
|
664
|
+
const onStatus = options.onStatus || ui.info;
|
|
665
|
+
|
|
666
|
+
if (!sessionCache.hasAny()) {
|
|
667
|
+
onStatus('Checking wallet funds...');
|
|
668
|
+
await ensureQuickScanCoverage(explorer, sessionCache);
|
|
669
|
+
} else {
|
|
670
|
+
onStatus('Refreshing wallet funds...');
|
|
671
|
+
await refreshAllCachedBalances(explorer, sessionCache);
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
await ensureAddressCached(explorer, sessionCache, 'change', config.ravencoin.defaultChangeIndex);
|
|
675
|
+
|
|
676
|
+
try {
|
|
677
|
+
return buildSendPlanFromCache(sessionCache, options);
|
|
678
|
+
} catch (error) {
|
|
679
|
+
if (!isInsufficientFundsError(error) || hasRequestCoverage(sessionCache, quickScanRequests())) {
|
|
680
|
+
if (isInsufficientFundsError(error) && !error.hint) {
|
|
681
|
+
error.hint = 'Run Scan wallet balances with a larger scan size if funds are on later addresses.';
|
|
682
|
+
}
|
|
683
|
+
throw error;
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
onStatus('Checking the quick wallet range...');
|
|
688
|
+
await ensureQuickScanCoverage(explorer, sessionCache);
|
|
689
|
+
try {
|
|
690
|
+
return buildSendPlanFromCache(sessionCache, options);
|
|
691
|
+
} catch (error) {
|
|
692
|
+
if (isInsufficientFundsError(error) && !error.hint) {
|
|
693
|
+
error.hint = 'Run Scan wallet balances with a larger scan size if funds are on later addresses.';
|
|
694
|
+
}
|
|
695
|
+
throw error;
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
function printSendFailure(heading, error, fallbackNextStep) {
|
|
700
|
+
const lines = [error && error.message ? error.message : String(error)];
|
|
701
|
+
if (error && error.hint) {
|
|
702
|
+
lines.push(`Next step: ${error.hint}`);
|
|
703
|
+
}
|
|
704
|
+
if (fallbackNextStep && !(error && error.hint)) {
|
|
705
|
+
lines.push(`Next step: ${fallbackNextStep}`);
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
console.log('');
|
|
709
|
+
ui.errorBox(heading, lines);
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
function invalidateSessionAfterBroadcast(sessionCache, explorer) {
|
|
713
|
+
sessionCache.clear();
|
|
714
|
+
if (explorer && typeof explorer.clearCache === 'function') {
|
|
715
|
+
explorer.clearCache();
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
function printBroadcastSuccess(txid) {
|
|
720
|
+
const explorerUrl = `${EXPLORER_TX_BASE_URL}/${txid}`;
|
|
721
|
+
|
|
722
|
+
console.log('');
|
|
723
|
+
ui.successBox('Success', [
|
|
724
|
+
'Status: Broadcast accepted',
|
|
725
|
+
`TXID: ${txid}`,
|
|
726
|
+
]);
|
|
727
|
+
console.log('');
|
|
728
|
+
console.log(ui.style.bold('Explorer:'));
|
|
729
|
+
console.log(explorerUrl);
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
async function handleScanBalances(rl, sessionCache) {
|
|
733
|
+
const scanPlan = await askScanPlan(rl);
|
|
734
|
+
const explorer = createExplorer();
|
|
735
|
+
console.log('');
|
|
736
|
+
ui.info(`Range: ${scanPlanLabel(scanPlan)}`);
|
|
737
|
+
const result = await ui.withSpinner('Checking wallet balances...', () => {
|
|
738
|
+
return scanWalletBalances(explorer, sessionCache, scanPlan);
|
|
739
|
+
});
|
|
740
|
+
printFundedAddresses(result);
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
async function handleSendRvn(rl, sessionCache) {
|
|
744
|
+
ui.section('Send RVN', 'Guided send flow with explicit Ledger approval.');
|
|
745
|
+
ui.info('Type cancel at any prompt to stop before signing.');
|
|
746
|
+
console.log('');
|
|
747
|
+
|
|
748
|
+
const amountSats = await askAmount(rl);
|
|
749
|
+
const destinationAddress = await askDestination(rl);
|
|
750
|
+
const feeRateSatsPerByte = await askFeeRate(rl);
|
|
751
|
+
const explorer = createExplorer();
|
|
752
|
+
let plan;
|
|
753
|
+
|
|
754
|
+
try {
|
|
755
|
+
console.log('');
|
|
756
|
+
plan = await buildSendPlanWithCache(explorer, sessionCache, {
|
|
757
|
+
destinationAddress,
|
|
758
|
+
amountSats,
|
|
759
|
+
feeRateSatsPerByte,
|
|
760
|
+
onStatus: ui.info,
|
|
761
|
+
});
|
|
762
|
+
} catch (error) {
|
|
763
|
+
printSendFailure(
|
|
764
|
+
'Send failed. Nothing was signed or broadcast.',
|
|
765
|
+
error,
|
|
766
|
+
'Check the Ledger connection and try again.',
|
|
767
|
+
);
|
|
768
|
+
return;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
printTransactionSummary(plan);
|
|
772
|
+
console.log('');
|
|
773
|
+
const signAnswer = await ask(rl, ui.prompt('Type SIGN to sign on the Ledger', '(Enter cancels)'));
|
|
774
|
+
if (signAnswer !== 'SIGN') {
|
|
775
|
+
ui.warningBox('Signing Cancelled', [
|
|
776
|
+
'Nothing was signed or broadcast.',
|
|
777
|
+
]);
|
|
778
|
+
return;
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
let signed;
|
|
782
|
+
try {
|
|
783
|
+
signed = await signDryRunPlan(plan, {
|
|
784
|
+
explorer,
|
|
785
|
+
});
|
|
786
|
+
} catch (error) {
|
|
787
|
+
printSendFailure(
|
|
788
|
+
'Signing failed. Nothing was broadcast.',
|
|
789
|
+
error,
|
|
790
|
+
'Check the Ledger screen and try again.',
|
|
791
|
+
);
|
|
792
|
+
return;
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
try {
|
|
796
|
+
const broadcastTxid = await broadcastRawTx(signed.signedRawTx, {
|
|
797
|
+
explorer,
|
|
798
|
+
});
|
|
799
|
+
invalidateSessionAfterBroadcast(sessionCache, explorer);
|
|
800
|
+
printBroadcastSuccess(broadcastTxid);
|
|
801
|
+
} catch (error) {
|
|
802
|
+
printSendFailure(
|
|
803
|
+
'Broadcast failed. No successful broadcast confirmation was received.',
|
|
804
|
+
error,
|
|
805
|
+
'Check the explorer link later or try sending again when the explorer is available.',
|
|
806
|
+
);
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
async function findUnusedReceiveAddress(explorer, sessionCache) {
|
|
811
|
+
for (let index = 0; index <= config.ravencoin.scan.receiveMaxIndex; index += 1) {
|
|
812
|
+
const current = await ensureReceiveAddressForUnusedCheck(explorer, sessionCache, index);
|
|
813
|
+
if (current && isUnusedReceiveAddress(current)) {
|
|
814
|
+
return current;
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
return null;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
async function handleReceiveRvn(rl, sessionCache) {
|
|
822
|
+
ui.section('Receive RVN', 'Finds the first unused receiving address.');
|
|
823
|
+
|
|
824
|
+
const explorer = createExplorer();
|
|
825
|
+
const receiveAddress = await ui.withSpinner('Finding an unused receiving address...', () => {
|
|
826
|
+
return findUnusedReceiveAddress(explorer, sessionCache);
|
|
827
|
+
});
|
|
828
|
+
|
|
829
|
+
if (!receiveAddress) {
|
|
830
|
+
ui.warningBox('No Address Found', [
|
|
831
|
+
`No unused receiving address was found in indexes 0-${config.ravencoin.scan.receiveMaxIndex}.`,
|
|
832
|
+
]);
|
|
833
|
+
return;
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
console.log('');
|
|
837
|
+
ui.keyValueBox('Unused Receiving Address', [
|
|
838
|
+
{ label: 'Address', value: receiveAddress.address },
|
|
839
|
+
{ label: 'Index', value: String(receiveAddress.index) },
|
|
840
|
+
], {
|
|
841
|
+
color: 'green',
|
|
842
|
+
});
|
|
843
|
+
|
|
844
|
+
const verify = await ask(rl, ui.prompt('Verify on the Ledger device?', '[y/N]'));
|
|
845
|
+
if (!['y', 'yes'].includes(verify.toLowerCase())) {
|
|
846
|
+
return;
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
const result = await verifyLedgerAddressOnDevice(receiveAddress.path, receiveAddress.address);
|
|
850
|
+
if (!result.matchesExpected || !result.matchesLedger) {
|
|
851
|
+
throw new Error('Ledger on-device verification did not match the locally derived receive address.');
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
ui.success('Ledger verification matched this RVN receiving address.');
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
function printSafetyNotes() {
|
|
858
|
+
console.log('');
|
|
859
|
+
ui.infoBox('Help / Safety Notes', [
|
|
860
|
+
`${ui.symbols.bullet} Keep the Ledger unlocked with the Ravencoin app open.`,
|
|
861
|
+
`${ui.symbols.bullet} Never enter your recovery phrase anywhere.`,
|
|
862
|
+
`${ui.symbols.bullet} Verify transaction details on the Ledger before approving.`,
|
|
863
|
+
`${ui.symbols.bullet} Use the explorer link after a successful broadcast.`,
|
|
864
|
+
`${ui.symbols.bullet} Advanced commands remain available through node RavenSafe.js --help.`,
|
|
865
|
+
]);
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
function printSupportDonate() {
|
|
869
|
+
const rvnDonation = config.branding.donations.rvn;
|
|
870
|
+
|
|
871
|
+
console.log('');
|
|
872
|
+
ui.printBox([
|
|
873
|
+
'Thank you for supporting RavenSafe CLI.',
|
|
874
|
+
'',
|
|
875
|
+
`RVN donation address: ${rvnDonation.address}`,
|
|
876
|
+
`Explorer: ${rvnDonation.explorerUrl}`,
|
|
877
|
+
], {
|
|
878
|
+
title: 'Support / Donate',
|
|
879
|
+
color: 'blue',
|
|
880
|
+
width: 92,
|
|
881
|
+
});
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
function printMenu() {
|
|
885
|
+
console.log('');
|
|
886
|
+
ui.menu('RavenSafe CLI', [
|
|
887
|
+
{ value: 1, label: 'Scan wallet balances' },
|
|
888
|
+
{ value: 2, label: 'Send RVN' },
|
|
889
|
+
{ value: 3, label: 'Receive RVN' },
|
|
890
|
+
{ value: 4, label: 'Help / safety notes' },
|
|
891
|
+
{ value: 5, label: 'Support / Donate' },
|
|
892
|
+
{ value: 6, label: 'Exit' },
|
|
893
|
+
], 'Ledger signs. Your seed never leaves the device.');
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
async function handleMenuChoice(rl, sessionCache, choice) {
|
|
897
|
+
if (choice === '1') {
|
|
898
|
+
await handleScanBalances(rl, sessionCache);
|
|
899
|
+
return false;
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
if (choice === '2') {
|
|
903
|
+
await handleSendRvn(rl, sessionCache);
|
|
904
|
+
return false;
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
if (choice === '3') {
|
|
908
|
+
await handleReceiveRvn(rl, sessionCache);
|
|
909
|
+
return false;
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
if (choice === '4') {
|
|
913
|
+
printSafetyNotes();
|
|
914
|
+
return false;
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
if (choice === '5') {
|
|
918
|
+
printSupportDonate();
|
|
919
|
+
return false;
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
if (choice === '6') {
|
|
923
|
+
ui.success('Session closed.');
|
|
924
|
+
return true;
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
ui.warning('Choose 1, 2, 3, 4, 5, or 6.');
|
|
928
|
+
return false;
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
async function runInteractiveCli() {
|
|
932
|
+
const rl = readline.createInterface({
|
|
933
|
+
input: process.stdin,
|
|
934
|
+
output: process.stdout,
|
|
935
|
+
});
|
|
936
|
+
const sessionCache = createSessionCache();
|
|
937
|
+
|
|
938
|
+
try {
|
|
939
|
+
await ui.showStartup();
|
|
940
|
+
|
|
941
|
+
while (true) {
|
|
942
|
+
try {
|
|
943
|
+
printMenu();
|
|
944
|
+
const choice = await ask(rl, ui.prompt('Choose an option'));
|
|
945
|
+
const shouldExit = await handleMenuChoice(rl, sessionCache, choice);
|
|
946
|
+
if (shouldExit) {
|
|
947
|
+
return;
|
|
948
|
+
}
|
|
949
|
+
} catch (error) {
|
|
950
|
+
if (error instanceof UserCancelledError) {
|
|
951
|
+
ui.warningBox('Cancelled', [
|
|
952
|
+
error.message,
|
|
953
|
+
]);
|
|
954
|
+
return;
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
printError(error);
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
} finally {
|
|
961
|
+
sessionCache.clear();
|
|
962
|
+
rl.close();
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
module.exports = {
|
|
967
|
+
balanceScanRequests,
|
|
968
|
+
buildIndexRangeRequests,
|
|
969
|
+
invalidateSessionAfterBroadcast,
|
|
970
|
+
printBroadcastSuccess,
|
|
971
|
+
runInteractiveCli,
|
|
972
|
+
};
|