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.
@@ -0,0 +1,405 @@
1
+ 'use strict';
2
+
3
+ const TransportNodeHid = require('@ledgerhq/hw-transport-node-hid').default;
4
+ const Btc = require('@ledgerhq/hw-app-btc').default;
5
+ const { getAppAndVersion } = require('@ledgerhq/hw-app-btc/lib/getAppAndVersion');
6
+ const { deriveRvnP2pkhAddress } = require('../core/address');
7
+
8
+ const PROBE_PATHS = Object.freeze([
9
+ "m/44'/175'/0'/0/0",
10
+ "m/44'/175'/0'/0/1",
11
+ "m/44'/175'/0'/0/2",
12
+ "m/44'/175'/175'/0/0",
13
+ ]);
14
+
15
+ const EXPECTED_CONTEXTS = Object.freeze({
16
+ current: {
17
+ label: 'currently open Ledger app',
18
+ purpose: 'auto-detected context',
19
+ },
20
+ ravencoin: {
21
+ label: 'Ravencoin app',
22
+ purpose: 'preferred/documented target',
23
+ },
24
+ bitcoin: {
25
+ label: 'Bitcoin app',
26
+ purpose: 'fallback/compatibility experiment',
27
+ },
28
+ });
29
+
30
+ const ADDRESS_CHAINS = Object.freeze({
31
+ receiving: {
32
+ label: 'receiving',
33
+ change: 0,
34
+ },
35
+ change: {
36
+ label: 'change',
37
+ change: 1,
38
+ },
39
+ });
40
+
41
+ const ADDRESS_CHAIN_MODES = Object.freeze([
42
+ 'receiving',
43
+ 'change',
44
+ 'both',
45
+ ]);
46
+
47
+ function statusCodeHex(statusCode) {
48
+ if (typeof statusCode !== 'number') {
49
+ return null;
50
+ }
51
+
52
+ return `0x${statusCode.toString(16).padStart(4, '0')}`;
53
+ }
54
+
55
+ function describeLedgerError(error) {
56
+ const statusCode = statusCodeHex(error && error.statusCode);
57
+ const message = error && error.message ? error.message : String(error);
58
+ let hint = 'Close Ledger Live, unlock the Ledger, and open the Ravencoin app first.';
59
+
60
+ if (message.includes('NoDevice') || message.includes('No Ledger device found')) {
61
+ hint = 'Connect the Ledger over USB, unlock it, and close Ledger Live.';
62
+ } else if (message.includes('cannot open device') || message.includes('Cannot open device')) {
63
+ hint = 'Close Ledger Live or any wallet app that may already be using HID.';
64
+ } else if (error && error.statusCode === 0x6985) {
65
+ hint = 'The request was rejected on the Ledger device.';
66
+ } else if (error && error.statusCode === 0x5515) {
67
+ hint = 'Unlock the Ledger and open the Ravencoin app before retrying.';
68
+ } else if (error && [0x6a82, 0x6d00, 0x6e00].includes(error.statusCode)) {
69
+ hint = 'The open Ledger app did not support this public-key request; try the Ravencoin app first, then Bitcoin only as fallback.';
70
+ }
71
+
72
+ return {
73
+ name: (error && error.name) || 'LedgerError',
74
+ statusCode,
75
+ message,
76
+ hint,
77
+ };
78
+ }
79
+
80
+ function classifyAppContext(appName) {
81
+ const normalized = (appName || '').toLowerCase();
82
+
83
+ if (normalized.includes('raven')) {
84
+ return {
85
+ key: 'ravencoin',
86
+ label: 'Ravencoin app',
87
+ purpose: 'preferred/documented target',
88
+ };
89
+ }
90
+
91
+ if (normalized.includes('bitcoin')) {
92
+ return {
93
+ key: 'bitcoin',
94
+ label: 'Bitcoin app',
95
+ purpose: 'fallback/compatibility experiment',
96
+ };
97
+ }
98
+
99
+ return {
100
+ key: 'unknown',
101
+ label: appName || 'unknown app',
102
+ purpose: 'not a known RVN probe context',
103
+ };
104
+ }
105
+
106
+ function buildContextWarnings(expectedContext, detectedContext) {
107
+ const warnings = [];
108
+
109
+ if (expectedContext === 'bitcoin') {
110
+ warnings.push('Bitcoin app mode is a fallback diagnostic only. Ravencoin app remains the primary workflow.');
111
+ }
112
+
113
+ if (expectedContext === 'current' && detectedContext.key === 'bitcoin') {
114
+ warnings.push('Bitcoin app is a fallback diagnostic context for this project, not the primary RVN workflow.');
115
+ }
116
+
117
+ if (expectedContext !== 'current' && detectedContext.key !== 'unknown' && detectedContext.key !== expectedContext) {
118
+ warnings.push(`Expected ${EXPECTED_CONTEXTS[expectedContext].label}, but Ledger reports ${detectedContext.label}.`);
119
+ }
120
+
121
+ if (detectedContext.key === 'unknown') {
122
+ warnings.push('Open the Ravencoin app first. Try the Bitcoin app only as a fallback diagnostic.');
123
+ }
124
+
125
+ return warnings;
126
+ }
127
+
128
+ function addressChainsForMode(mode = 'receiving') {
129
+ if (mode === 'both') {
130
+ return ['receiving', 'change'];
131
+ }
132
+
133
+ if (!ADDRESS_CHAINS[mode]) {
134
+ throw new Error('chain must be one of: receiving, change, both');
135
+ }
136
+
137
+ return [mode];
138
+ }
139
+
140
+ function addressPathForIndex(index, chain = 'receiving') {
141
+ if (!ADDRESS_CHAINS[chain]) {
142
+ throw new Error('chain must be receiving or change');
143
+ }
144
+
145
+ return `m/44'/175'/0'/${ADDRESS_CHAINS[chain].change}/${index}`;
146
+ }
147
+
148
+ async function readPublicKeys(transport, paths = PROBE_PATHS) {
149
+ const btc = new Btc({
150
+ transport,
151
+ currency: 'ravencoin',
152
+ });
153
+
154
+ const results = [];
155
+ for (const path of paths) {
156
+ try {
157
+ const response = await btc.getWalletPublicKey(path, {
158
+ format: 'legacy',
159
+ verify: false,
160
+ });
161
+
162
+ const publicKey = response.publicKey;
163
+ results.push({
164
+ ok: true,
165
+ path,
166
+ publicKey,
167
+ chainCode: response.chainCode || null,
168
+ ledgerAddress: response.bitcoinAddress || null,
169
+ rvnAddress: deriveRvnP2pkhAddress(publicKey),
170
+ });
171
+ } catch (error) {
172
+ results.push({
173
+ ok: false,
174
+ path,
175
+ error: describeLedgerError(error),
176
+ });
177
+ }
178
+ }
179
+
180
+ return results;
181
+ }
182
+
183
+ async function closeTransport(transport) {
184
+ if (!transport) {
185
+ return;
186
+ }
187
+
188
+ try {
189
+ await transport.close();
190
+ } catch {
191
+ // The probe is already done; close failures should not hide the useful result.
192
+ }
193
+ }
194
+
195
+ async function detectOpenApp(transport, result, expectedContext) {
196
+ try {
197
+ const appAndVersion = await getAppAndVersion(transport);
198
+ const detectedContext = classifyAppContext(appAndVersion.name);
199
+ result.app = {
200
+ name: appAndVersion.name,
201
+ version: appAndVersion.version,
202
+ context: detectedContext,
203
+ };
204
+ result.warnings.push(...buildContextWarnings(expectedContext, detectedContext));
205
+ } catch (error) {
206
+ result.app = {
207
+ error: describeLedgerError(error),
208
+ };
209
+ }
210
+ }
211
+
212
+ function buildNoDeviceError() {
213
+ return {
214
+ name: 'NoDevice',
215
+ statusCode: null,
216
+ message: 'No Ledger HID device detected.',
217
+ hint: 'Connect the Ledger over USB, unlock it, close Ledger Live, and open the Ravencoin app.',
218
+ };
219
+ }
220
+
221
+ async function probeLedger(options = {}) {
222
+ const expectedContext = options.expectedContext || 'current';
223
+ if (!EXPECTED_CONTEXTS[expectedContext]) {
224
+ throw new Error(`Unknown Ledger app context: ${expectedContext}`);
225
+ }
226
+
227
+ const devicePaths = await TransportNodeHid.list();
228
+ const result = {
229
+ expectedContext: EXPECTED_CONTEXTS[expectedContext],
230
+ hidDetected: devicePaths.length > 0,
231
+ hidDeviceCount: devicePaths.length,
232
+ app: null,
233
+ warnings: [],
234
+ publicKeyResults: [],
235
+ publicKeyExportWorks: false,
236
+ error: null,
237
+ };
238
+
239
+ if (devicePaths.length === 0) {
240
+ result.error = buildNoDeviceError();
241
+ return result;
242
+ }
243
+
244
+ let transport;
245
+ try {
246
+ transport = await TransportNodeHid.open(devicePaths[0]);
247
+
248
+ await detectOpenApp(transport, result, expectedContext);
249
+ result.publicKeyResults = await readPublicKeys(transport);
250
+ result.publicKeyExportWorks = result.publicKeyResults.some(item => item.ok);
251
+ } catch (error) {
252
+ result.error = describeLedgerError(error);
253
+ } finally {
254
+ await closeTransport(transport);
255
+ }
256
+
257
+ return result;
258
+ }
259
+
260
+ async function listLedgerAddressRequests(options = {}) {
261
+ const expectedContext = options.expectedContext || 'ravencoin';
262
+ if (!EXPECTED_CONTEXTS[expectedContext]) {
263
+ throw new Error(`Unknown Ledger app context: ${expectedContext}`);
264
+ }
265
+
266
+ const addressRequests = options.requests || [];
267
+ if (!Array.isArray(addressRequests) || addressRequests.length === 0) {
268
+ throw new Error('At least one Ledger address request is required.');
269
+ }
270
+
271
+ const devicePaths = await TransportNodeHid.list();
272
+ const result = {
273
+ expectedContext: EXPECTED_CONTEXTS[expectedContext],
274
+ hidDetected: devicePaths.length > 0,
275
+ hidDeviceCount: devicePaths.length,
276
+ app: null,
277
+ warnings: [],
278
+ addressResults: [],
279
+ allMatch: false,
280
+ error: null,
281
+ };
282
+
283
+ if (devicePaths.length === 0) {
284
+ result.error = buildNoDeviceError();
285
+ return result;
286
+ }
287
+
288
+ let transport;
289
+ try {
290
+ transport = await TransportNodeHid.open(devicePaths[0]);
291
+
292
+ await detectOpenApp(transport, result, expectedContext);
293
+
294
+ const publicKeyResults = await readPublicKeys(
295
+ transport,
296
+ addressRequests.map(item => item.path),
297
+ );
298
+
299
+ result.addressResults = publicKeyResults.map((item, offset) => {
300
+ const request = addressRequests[offset];
301
+ if (!item.ok) {
302
+ return {
303
+ ok: false,
304
+ role: request.role || null,
305
+ chain: request.chain,
306
+ index: request.index,
307
+ path: request.path,
308
+ rvnAddress: null,
309
+ ledgerAddress: null,
310
+ matchesLedger: false,
311
+ error: item.error,
312
+ };
313
+ }
314
+
315
+ return {
316
+ ok: true,
317
+ role: request.role || null,
318
+ chain: request.chain,
319
+ index: request.index,
320
+ path: request.path,
321
+ rvnAddress: item.rvnAddress,
322
+ ledgerAddress: item.ledgerAddress,
323
+ matchesLedger: item.rvnAddress === item.ledgerAddress,
324
+ };
325
+ });
326
+
327
+ result.allMatch = result.addressResults.length > 0 &&
328
+ result.addressResults.every(item => item.ok && item.matchesLedger);
329
+ } catch (error) {
330
+ result.error = describeLedgerError(error);
331
+ } finally {
332
+ await closeTransport(transport);
333
+ }
334
+
335
+ return result;
336
+ }
337
+
338
+ async function listLedgerAddresses(options = {}) {
339
+ const start = options.start ?? 0;
340
+ const count = options.count ?? 10;
341
+ const chains = addressChainsForMode(options.chain || 'receiving');
342
+ const requests = chains.flatMap(chain => {
343
+ return Array.from({ length: count }, (_, offset) => {
344
+ const index = start + offset;
345
+ return {
346
+ chain,
347
+ index,
348
+ path: addressPathForIndex(index, chain),
349
+ };
350
+ });
351
+ });
352
+
353
+ return listLedgerAddressRequests({
354
+ ...options,
355
+ requests,
356
+ });
357
+ }
358
+
359
+ async function verifyLedgerAddressOnDevice(path, expectedAddress) {
360
+ const devicePaths = await TransportNodeHid.list();
361
+ if (devicePaths.length === 0) {
362
+ throw buildNoDeviceError();
363
+ }
364
+
365
+ let transport;
366
+ try {
367
+ transport = await TransportNodeHid.open(devicePaths[0]);
368
+ const btc = new Btc({
369
+ transport,
370
+ currency: 'ravencoin',
371
+ });
372
+ const response = await btc.getWalletPublicKey(path, {
373
+ format: 'legacy',
374
+ verify: true,
375
+ });
376
+ const rvnAddress = deriveRvnP2pkhAddress(response.publicKey);
377
+
378
+ return {
379
+ path,
380
+ rvnAddress,
381
+ ledgerAddress: response.bitcoinAddress || null,
382
+ matchesExpected: rvnAddress === expectedAddress,
383
+ matchesLedger: rvnAddress === response.bitcoinAddress,
384
+ };
385
+ } catch (error) {
386
+ throw describeLedgerError(error);
387
+ } finally {
388
+ await closeTransport(transport);
389
+ }
390
+ }
391
+
392
+ module.exports = {
393
+ ADDRESS_CHAINS,
394
+ ADDRESS_CHAIN_MODES,
395
+ EXPECTED_CONTEXTS,
396
+ PROBE_PATHS,
397
+ addressChainsForMode,
398
+ addressPathForIndex,
399
+ classifyAppContext,
400
+ describeLedgerError,
401
+ listLedgerAddressRequests,
402
+ listLedgerAddresses,
403
+ probeLedger,
404
+ verifyLedgerAddressOnDevice,
405
+ };