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,526 @@
1
+ 'use strict';
2
+
3
+ const axios = require('axios');
4
+ const config = require('../config');
5
+
6
+ const SATS_PER_RVN = 100000000n;
7
+ const RAW_TX_REQUIRED_MESSAGE = 'Zelcore rawtx endpoint did not return valid previous raw transaction hex required for Ledger signing.';
8
+ const ZELCORE_BROADCAST_PATH = '/tx/send';
9
+
10
+ class ExplorerError extends Error {
11
+ constructor(message, options = {}) {
12
+ super(message);
13
+ this.name = 'ExplorerError';
14
+ this.statusCode = options.statusCode || null;
15
+ this.endpoint = options.endpoint || null;
16
+ this.responseBody = options.responseBody || null;
17
+ this.provider = options.provider || null;
18
+ this.hint = options.hint || 'Check src/config/index.js and confirm the Zelcore explorer is online.';
19
+ }
20
+ }
21
+
22
+ function trimTrailingSlash(value) {
23
+ return value.replace(/\/+$/, '');
24
+ }
25
+
26
+ function joinUrl(baseUrl, path) {
27
+ return `${trimTrailingSlash(baseUrl)}/${path.replace(/^\/+/, '')}`;
28
+ }
29
+
30
+ function malformed(message) {
31
+ return new ExplorerError(`Malformed explorer response: ${message}`, {
32
+ hint: 'The Zelcore explorer returned data that does not match the expected format.',
33
+ });
34
+ }
35
+
36
+ function extractExplorerErrorDetail(data) {
37
+ if (!data) {
38
+ return null;
39
+ }
40
+
41
+ if (typeof data === 'string') {
42
+ return data.slice(0, 500);
43
+ }
44
+
45
+ if (typeof data === 'object') {
46
+ for (const key of ['error', 'message', 'rawtx', 'txid']) {
47
+ if (typeof data[key] === 'string' && data[key].length > 0) {
48
+ return data[key].slice(0, 500);
49
+ }
50
+ }
51
+ }
52
+
53
+ return null;
54
+ }
55
+
56
+ function formatResponseBody(data) {
57
+ if (data === undefined || data === null) {
58
+ return null;
59
+ }
60
+
61
+ if (typeof data === 'string') {
62
+ return data.slice(0, 1000);
63
+ }
64
+
65
+ try {
66
+ return JSON.stringify(data).slice(0, 1000);
67
+ } catch {
68
+ return String(data).slice(0, 1000);
69
+ }
70
+ }
71
+
72
+ function describeBroadcastProvider() {
73
+ return {
74
+ key: 'zelcore',
75
+ label: 'Zelcore broadcast endpoint',
76
+ endpoint: joinUrl(config.ravencoin.explorerBaseUrl, ZELCORE_BROADCAST_PATH),
77
+ };
78
+ }
79
+
80
+ function parseSats(value, fieldName, options = {}) {
81
+ if (typeof value === 'number') {
82
+ if (!Number.isSafeInteger(value)) {
83
+ throw malformed(`${fieldName} must be a safe integer number in satoshis.`);
84
+ }
85
+
86
+ if (!options.allowNegative && value < 0) {
87
+ throw malformed(`${fieldName} cannot be negative.`);
88
+ }
89
+
90
+ return BigInt(value);
91
+ }
92
+
93
+ if (typeof value !== 'string') {
94
+ throw malformed(`${fieldName} must be a string or safe integer number in satoshis.`);
95
+ }
96
+
97
+ const text = value.trim();
98
+ if (!/^-?\d+$/.test(text)) {
99
+ throw malformed(`${fieldName} must be an integer in satoshis.`);
100
+ }
101
+
102
+ if (!options.allowNegative && text.startsWith('-')) {
103
+ throw malformed(`${fieldName} cannot be negative.`);
104
+ }
105
+
106
+ return BigInt(text);
107
+ }
108
+
109
+ function parseRvnDecimalToSats(value, fieldName, options = {}) {
110
+ const text = String(value).trim();
111
+ if (!/^-?(0|[1-9]\d*)(\.\d{1,8})?$/.test(text)) {
112
+ throw malformed(`${fieldName} must be an RVN decimal value with up to 8 decimal places.`);
113
+ }
114
+
115
+ if (!options.allowNegative && text.startsWith('-')) {
116
+ throw malformed(`${fieldName} cannot be negative.`);
117
+ }
118
+
119
+ const negative = text.startsWith('-');
120
+ const unsigned = negative ? text.slice(1) : text;
121
+ const [wholePart, fractionalPart = ''] = unsigned.split('.');
122
+ const sats = BigInt(wholePart) * SATS_PER_RVN +
123
+ BigInt(fractionalPart.padEnd(8, '0'));
124
+
125
+ return negative ? -sats : sats;
126
+ }
127
+
128
+ function parseZelcoreSats(data, satField, decimalField, options = {}) {
129
+ if (data[satField] !== undefined && data[satField] !== null && data[satField] !== '') {
130
+ return parseSats(data[satField], satField, options);
131
+ }
132
+
133
+ if (data[decimalField] !== undefined && data[decimalField] !== null && data[decimalField] !== '') {
134
+ return parseRvnDecimalToSats(data[decimalField], decimalField, options);
135
+ }
136
+
137
+ throw malformed(`Zelcore response must include ${satField} or ${decimalField}.`);
138
+ }
139
+
140
+ function parseOptionalZelcoreSats(data, satField, decimalField, fallback, options = {}) {
141
+ if (data[satField] === undefined || data[satField] === null || data[satField] === '') {
142
+ if (data[decimalField] === undefined || data[decimalField] === null || data[decimalField] === '') {
143
+ return fallback;
144
+ }
145
+ }
146
+
147
+ return parseZelcoreSats(data, satField, decimalField, options);
148
+ }
149
+
150
+ function parseNonNegativeInteger(value, fieldName, fallback) {
151
+ if (value === undefined || value === null || value === '') {
152
+ if (arguments.length >= 3) {
153
+ return fallback;
154
+ }
155
+
156
+ throw malformed(`${fieldName} is required.`);
157
+ }
158
+
159
+ const number = typeof value === 'number' ? value : Number(value);
160
+ if (!Number.isSafeInteger(number) || number < 0) {
161
+ throw malformed(`${fieldName} must be a non-negative integer.`);
162
+ }
163
+
164
+ return number;
165
+ }
166
+
167
+ function parseOptionalNonNegativeInteger(value, fallback) {
168
+ if (value === undefined || value === null || value === '') {
169
+ return fallback;
170
+ }
171
+
172
+ const number = typeof value === 'number' ? value : Number(value);
173
+ if (!Number.isSafeInteger(number) || number < 0) {
174
+ return fallback;
175
+ }
176
+
177
+ return number;
178
+ }
179
+
180
+ function parseOptionalAddressCount(data, fields) {
181
+ for (const field of fields) {
182
+ if (data[field] !== undefined && data[field] !== null && data[field] !== '') {
183
+ return parseOptionalNonNegativeInteger(data[field], null);
184
+ }
185
+ }
186
+
187
+ return null;
188
+ }
189
+
190
+ function normalizeZelcoreTxPage(data) {
191
+ if (!data || typeof data !== 'object') {
192
+ throw malformed('Zelcore txs response must be an object.');
193
+ }
194
+
195
+ if (!Array.isArray(data.txs)) {
196
+ throw malformed('Zelcore txs response must include txs array.');
197
+ }
198
+
199
+ return {
200
+ txs: data.txs,
201
+ pagesTotal: parseNonNegativeInteger(data.pagesTotal, 'pagesTotal', 1),
202
+ };
203
+ }
204
+
205
+ function outputAddresses(vout) {
206
+ const scriptPubKey = vout && vout.scriptPubKey;
207
+ if (scriptPubKey === undefined || scriptPubKey === null) {
208
+ return [];
209
+ }
210
+
211
+ if (!scriptPubKey || typeof scriptPubKey !== 'object') {
212
+ throw malformed('vout.scriptPubKey must be an object when present.');
213
+ }
214
+
215
+ if (scriptPubKey.addresses !== undefined && scriptPubKey.addresses !== null) {
216
+ if (!Array.isArray(scriptPubKey.addresses) ||
217
+ !scriptPubKey.addresses.every(address => typeof address === 'string')) {
218
+ throw malformed('vout.scriptPubKey.addresses must be an array of strings.');
219
+ }
220
+
221
+ return scriptPubKey.addresses;
222
+ }
223
+
224
+ if (scriptPubKey.address !== undefined && scriptPubKey.address !== null) {
225
+ if (typeof scriptPubKey.address !== 'string') {
226
+ throw malformed('vout.scriptPubKey.address must be a string.');
227
+ }
228
+
229
+ return [scriptPubKey.address];
230
+ }
231
+
232
+ return [];
233
+ }
234
+
235
+ function isUnspentZelcoreOutput(vout) {
236
+ return vout.spentTxId === undefined ||
237
+ vout.spentTxId === null ||
238
+ vout.spentTxId === '';
239
+ }
240
+
241
+ function parseZelcoreVoutValue(vout) {
242
+ if (vout.valueSat !== undefined && vout.valueSat !== null && vout.valueSat !== '') {
243
+ return parseSats(vout.valueSat, 'vout.valueSat');
244
+ }
245
+
246
+ if (vout.valueSats !== undefined && vout.valueSats !== null && vout.valueSats !== '') {
247
+ return parseSats(vout.valueSats, 'vout.valueSats');
248
+ }
249
+
250
+ if (vout.value !== undefined && vout.value !== null && vout.value !== '') {
251
+ return parseRvnDecimalToSats(vout.value, 'vout.value');
252
+ }
253
+
254
+ throw malformed('Zelcore vout must include valueSat or value.');
255
+ }
256
+
257
+ function normalizeZelcoreUtxos(address, txs) {
258
+ const seen = new Set();
259
+ const utxos = [];
260
+
261
+ for (const tx of txs) {
262
+ if (!tx || typeof tx !== 'object') {
263
+ throw malformed('Zelcore tx entry must be an object.');
264
+ }
265
+
266
+ if (typeof tx.txid !== 'string' || tx.txid.length === 0) {
267
+ throw malformed('Zelcore tx entry must include txid.');
268
+ }
269
+
270
+ if (!Array.isArray(tx.vout)) {
271
+ continue;
272
+ }
273
+
274
+ const confirmations = parseOptionalNonNegativeInteger(tx.confirmations, 0);
275
+ const height = confirmations > 0
276
+ ? parseOptionalNonNegativeInteger(tx.blockheight, null)
277
+ : null;
278
+
279
+ for (const vout of tx.vout) {
280
+ if (!vout || typeof vout !== 'object') {
281
+ throw malformed('Zelcore vout entry must be an object.');
282
+ }
283
+
284
+ if (!outputAddresses(vout).includes(address) || !isUnspentZelcoreOutput(vout)) {
285
+ continue;
286
+ }
287
+
288
+ const voutIndex = parseNonNegativeInteger(vout.n, 'vout.n');
289
+ const key = `${tx.txid}:${voutIndex}`;
290
+ if (seen.has(key)) {
291
+ continue;
292
+ }
293
+
294
+ seen.add(key);
295
+ utxos.push({
296
+ txid: tx.txid,
297
+ vout: voutIndex,
298
+ valueSats: parseZelcoreVoutValue(vout),
299
+ height,
300
+ confirmations,
301
+ coinbase: Boolean(tx.isCoinBase || tx.coinbase),
302
+ });
303
+ }
304
+ }
305
+
306
+ return utxos;
307
+ }
308
+
309
+ function validateRawTxHex(rawTx, message = 'Raw transaction hex must be a non-empty even-length hex string.') {
310
+ if (typeof rawTx !== 'string' || rawTx.length === 0 || rawTx.length % 2 !== 0 || !/^[0-9a-fA-F]+$/.test(rawTx)) {
311
+ throw new ExplorerError(message);
312
+ }
313
+ }
314
+
315
+ class ZelcoreExplorer {
316
+ constructor(options = {}) {
317
+ this.baseUrl = trimTrailingSlash(options.baseUrl || config.ravencoin.explorerBaseUrl);
318
+ this.timeoutMs = options.timeoutMs || 15000;
319
+ this.addressCache = new Map();
320
+ this.utxoCache = new Map();
321
+ this.rawTxCache = new Map();
322
+ }
323
+
324
+ async requestJson(method, endpoint, data, options = {}) {
325
+ const headers = {
326
+ accept: 'application/json',
327
+ ...(options.headers || {}),
328
+ };
329
+
330
+ if (method === 'post') {
331
+ headers['content-type'] = 'application/json';
332
+ }
333
+
334
+ try {
335
+ const response = await axios({
336
+ method,
337
+ url: endpoint,
338
+ data,
339
+ timeout: this.timeoutMs,
340
+ headers,
341
+ });
342
+
343
+ return response.data;
344
+ } catch (error) {
345
+ const statusCode = error && error.response ? error.response.status : null;
346
+ const responseData = error && error.response ? error.response.data : null;
347
+ const detail = extractExplorerErrorDetail(responseData);
348
+ const responseBody = formatResponseBody(responseData);
349
+ const provider = options.provider || 'Zelcore explorer';
350
+ const defaultHint = options.hint || 'Check src/config/index.js and confirm the Zelcore explorer is online.';
351
+ let message = `${provider} unavailable: ${endpoint}`;
352
+
353
+ if (statusCode) {
354
+ message = detail
355
+ ? `${provider} request failed with HTTP ${statusCode}: ${endpoint}: ${detail}`
356
+ : `${provider} request failed with HTTP ${statusCode}: ${endpoint}`;
357
+ }
358
+
359
+ throw new ExplorerError(message, {
360
+ statusCode,
361
+ endpoint,
362
+ responseBody,
363
+ provider,
364
+ hint: defaultHint,
365
+ });
366
+ }
367
+ }
368
+
369
+ requestExplorerJson(method, path, data, options = {}) {
370
+ return this.requestJson(method, joinUrl(this.baseUrl, path), data, {
371
+ provider: options.provider || 'Zelcore explorer',
372
+ hint: options.hint || 'Check src/config/index.js and confirm the Zelcore explorer is online.',
373
+ });
374
+ }
375
+
376
+ async getUtxos(address) {
377
+ if (this.utxoCache.has(address)) {
378
+ return this.utxoCache.get(address);
379
+ }
380
+
381
+ const promise = (async () => {
382
+ const firstPage = normalizeZelcoreTxPage(await this.requestExplorerJson(
383
+ 'get',
384
+ `/txs?address=${encodeURIComponent(address)}&pageNum=0`,
385
+ ));
386
+ const txs = [...firstPage.txs];
387
+
388
+ for (let pageNum = 1; pageNum < firstPage.pagesTotal; pageNum += 1) {
389
+ const page = normalizeZelcoreTxPage(await this.requestExplorerJson(
390
+ 'get',
391
+ `/txs?address=${encodeURIComponent(address)}&pageNum=${pageNum}`,
392
+ ));
393
+ txs.push(...page.txs);
394
+ }
395
+
396
+ const utxos = normalizeZelcoreUtxos(address, txs);
397
+ this.utxoCache.set(address, utxos);
398
+ return utxos;
399
+ })().catch(error => {
400
+ this.utxoCache.delete(address);
401
+ throw error;
402
+ });
403
+
404
+ this.utxoCache.set(address, promise);
405
+ return promise;
406
+ }
407
+
408
+ async getAddressBalance(address) {
409
+ if (this.addressCache.has(address)) {
410
+ return this.addressCache.get(address);
411
+ }
412
+
413
+ const promise = this.requestExplorerJson(
414
+ 'get',
415
+ `/addr/${encodeURIComponent(address)}/?noTxList=1`,
416
+ )
417
+ .then(data => {
418
+ if (!data || typeof data !== 'object') {
419
+ throw malformed('Zelcore address response must be an object.');
420
+ }
421
+
422
+ const balance = {
423
+ confirmedSats: parseZelcoreSats(data, 'balanceSat', 'balance'),
424
+ unconfirmedSats: parseOptionalZelcoreSats(
425
+ data,
426
+ 'unconfirmedBalanceSat',
427
+ 'unconfirmedBalance',
428
+ 0n,
429
+ { allowNegative: true },
430
+ ),
431
+ txAppearances: parseOptionalAddressCount(data, ['txApperances', 'txAppearances']),
432
+ unconfirmedTxAppearances: parseOptionalAddressCount(data, [
433
+ 'unconfirmedTxApperances',
434
+ 'unconfirmedTxAppearances',
435
+ ]),
436
+ };
437
+
438
+ this.addressCache.set(address, balance);
439
+ return balance;
440
+ })
441
+ .catch(error => {
442
+ this.addressCache.delete(address);
443
+ throw error;
444
+ });
445
+
446
+ this.addressCache.set(address, promise);
447
+ return promise;
448
+ }
449
+
450
+ async getRawTransaction(txid) {
451
+ if (this.rawTxCache.has(txid)) {
452
+ return this.rawTxCache.get(txid);
453
+ }
454
+
455
+ const promise = this.requestExplorerJson(
456
+ 'get',
457
+ `/rawtx/${encodeURIComponent(txid)}`,
458
+ null,
459
+ {
460
+ provider: 'Zelcore rawtx endpoint',
461
+ hint: 'Previous raw transaction hex is required for Ledger signing.',
462
+ },
463
+ )
464
+ .then(data => {
465
+ if (!data || typeof data !== 'object' || typeof data.rawtx !== 'string') {
466
+ throw new ExplorerError(RAW_TX_REQUIRED_MESSAGE);
467
+ }
468
+
469
+ validateRawTxHex(data.rawtx, RAW_TX_REQUIRED_MESSAGE);
470
+ this.rawTxCache.set(txid, data.rawtx);
471
+ return data.rawtx;
472
+ })
473
+ .catch(error => {
474
+ this.rawTxCache.delete(txid);
475
+ throw error;
476
+ });
477
+
478
+ this.rawTxCache.set(txid, promise);
479
+ return promise;
480
+ }
481
+
482
+ async broadcastRawTx(rawTx) {
483
+ validateRawTxHex(rawTx);
484
+ const endpoint = joinUrl(this.baseUrl, ZELCORE_BROADCAST_PATH);
485
+
486
+ const data = await this.requestJson('post', endpoint, {
487
+ rawtx: rawTx,
488
+ }, {
489
+ provider: 'Zelcore broadcast endpoint',
490
+ hint: 'The signed transaction was not accepted by Zelcore or the Ravencoin network.',
491
+ });
492
+
493
+ if (!data || typeof data !== 'object' || typeof data.txid !== 'string' || data.txid.length === 0) {
494
+ throw malformed('Zelcore broadcast response must include txid string.');
495
+ }
496
+
497
+ return data.txid;
498
+ }
499
+
500
+ clearCache() {
501
+ this.addressCache.clear();
502
+ this.utxoCache.clear();
503
+ this.rawTxCache.clear();
504
+ }
505
+ }
506
+
507
+ function createExplorer(options = {}) {
508
+ return new ZelcoreExplorer(options);
509
+ }
510
+
511
+ async function broadcastRawTx(rawTx, options = {}) {
512
+ const explorer = options.explorer || createExplorer();
513
+ return explorer.broadcastRawTx(rawTx);
514
+ }
515
+
516
+ module.exports = {
517
+ ExplorerError,
518
+ ZelcoreExplorer,
519
+ broadcastRawTx,
520
+ createExplorer,
521
+ describeBroadcastProvider,
522
+ formatResponseBody,
523
+ normalizeZelcoreUtxos,
524
+ parseRvnDecimalToSats,
525
+ parseSats,
526
+ };