@pioneer-platform/eth-network 8.13.13 → 8.13.15

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/lib/index.js ADDED
@@ -0,0 +1,1097 @@
1
+ "use strict";
2
+ /*
3
+ ETH Network tools - Cleaned up for pioneer-sdk
4
+
5
+ Essential features only:
6
+ - Token transfers (ETH + ERC20)
7
+ - Balance queries
8
+ - Fee estimation
9
+ - Memo encoding (THORChain/Maya)
10
+ - Multi-network support
11
+ */
12
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
13
+ if (k2 === undefined) k2 = k;
14
+ var desc = Object.getOwnPropertyDescriptor(m, k);
15
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
16
+ desc = { enumerable: true, get: function() { return m[k]; } };
17
+ }
18
+ Object.defineProperty(o, k2, desc);
19
+ }) : (function(o, m, k, k2) {
20
+ if (k2 === undefined) k2 = k;
21
+ o[k2] = m[k];
22
+ }));
23
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
24
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
25
+ }) : function(o, v) {
26
+ o["default"] = v;
27
+ });
28
+ var __importStar = (this && this.__importStar) || (function () {
29
+ var ownKeys = function(o) {
30
+ ownKeys = Object.getOwnPropertyNames || function (o) {
31
+ var ar = [];
32
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
33
+ return ar;
34
+ };
35
+ return ownKeys(o);
36
+ };
37
+ return function (mod) {
38
+ if (mod && mod.__esModule) return mod;
39
+ var result = {};
40
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
41
+ __setModuleDefault(result, mod);
42
+ return result;
43
+ };
44
+ })();
45
+ Object.defineProperty(exports, "__esModule", { value: true });
46
+ exports.getFees = exports.getBalanceAddress = exports.getTransactionsByNetwork = exports.getTransactionByNetwork = exports.broadcastByNetwork = exports.estimateGasByNetwork = exports.getTokenMetadataByNetwork = exports.getTokenMetadata = exports.getTokenDecimalsByNetwork = exports.getBalanceTokensByNetwork = exports.getBalanceTokenByNetwork = exports.getBalanceAddressByNetwork = exports.getMemoEncoded = exports.getTransferData = exports.broadcast = exports.getTransaction = exports.getSymbolFromContract = exports.getAllowance = exports.getBalanceToken = exports.getBalances = exports.getBalance = exports.estimateFee = exports.getGasPriceByNetwork = exports.getGasPrice = exports.getNonceByNetwork = exports.getNonce = exports.addNodes = exports.addNode = exports.getNodes = exports.init = void 0;
47
+ const TAG = " | eth-network | ";
48
+ const ethers = __importStar(require("ethers"));
49
+ const BigNumber = require('bignumber.js');
50
+ const axiosLib = require('axios');
51
+ const Axios = axiosLib.default || axiosLib;
52
+ const https = require('https');
53
+ const axios = Axios.create({
54
+ httpsAgent: new https.Agent({
55
+ rejectUnauthorized: false
56
+ })
57
+ });
58
+ let blockbook = require("@pioneer-platform/blockbook");
59
+ const providers_1 = require("@ethersproject/providers");
60
+ const utils_1 = require("./utils");
61
+ const xchain_util_1 = require("@xchainjs/xchain-util");
62
+ const nodes_1 = require("@pioneer-platform/nodes");
63
+ const NodeHealth = __importStar(require("./node-health"));
64
+ const log = require('@pioneer-platform/loggerdog')();
65
+ let wait = require('wait-promise');
66
+ let sleep = wait.sleep;
67
+ // Provider instances
68
+ let ETHERSCAN;
69
+ let PROVIDER;
70
+ let PROVIDER_BASE;
71
+ let NODE_URL;
72
+ let BASE = 1000000000000000000;
73
+ // Timeout configuration
74
+ const RPC_TIMEOUT_MS = 10000; // 10 second timeout for RPC calls
75
+ /**
76
+ * Wrap a promise with a timeout
77
+ * @param promise Promise to wrap
78
+ * @param timeoutMs Timeout in milliseconds
79
+ * @param errorMessage Error message if timeout occurs
80
+ */
81
+ const withTimeout = (promise, timeoutMs, errorMessage) => {
82
+ return Promise.race([
83
+ promise,
84
+ new Promise((_, reject) => setTimeout(() => reject(new Error(errorMessage)), timeoutMs))
85
+ ]);
86
+ };
87
+ // ERC20 ABI (minimal for transfers and balances)
88
+ const ERC20ABI = [
89
+ "function balanceOf(address owner) view returns (uint256)",
90
+ "function transfer(address to, uint256 amount) returns (bool)",
91
+ "function allowance(address owner, address spender) view returns (uint256)",
92
+ "function approve(address spender, uint256 amount) returns (bool)",
93
+ "function decimals() view returns (uint8)",
94
+ "function symbol() view returns (string)",
95
+ "function name() view returns (string)"
96
+ ];
97
+ // Node registry
98
+ let NODES = [];
99
+ // Dead node tracker - nodes that failed recently (service URL -> timestamp)
100
+ const DEAD_NODES = new Map();
101
+ const DEAD_NODE_TTL_MS = 5 * 60 * 1000; // 5 minutes before retry
102
+ const RACE_BATCH_SIZE = 3; // Try 3 premium/reliable nodes first (reduced from 6)
103
+ // Node tier priority (lower = higher priority)
104
+ const TIER_PRIORITY = {
105
+ premium: 1,
106
+ reliable: 2,
107
+ public: 3,
108
+ untrusted: 4,
109
+ };
110
+ // Startup validation configuration
111
+ // Environment variables:
112
+ // - VALIDATE_NODES_ON_STARTUP: Set to 'false' to disable background validation (default: enabled)
113
+ // - STARTUP_NODE_TIMEOUT: Timeout per node in ms (default: 3000)
114
+ // - STARTUP_NODE_CONCURRENCY: Number of parallel validation workers (default: 20)
115
+ // - STARTUP_VALIDATION_TIER: Comma-separated tiers to validate on startup (default: 'premium,reliable')
116
+ // - STARTUP_NODE_SAMPLE: Number of random nodes to test per tier (default: 0 = all)
117
+ //
118
+ // Note: Validation runs in background and does NOT block server startup
119
+ const STARTUP_VALIDATION_ENABLED = process.env.VALIDATE_NODES_ON_STARTUP !== 'false'; // Default true
120
+ const STARTUP_VALIDATION_TIMEOUT = parseInt(process.env.STARTUP_NODE_TIMEOUT || '3000'); // 3s timeout
121
+ const STARTUP_VALIDATION_CONCURRENCY = parseInt(process.env.STARTUP_NODE_CONCURRENCY || '20'); // 20 parallel
122
+ const STARTUP_VALIDATION_TIERS = (process.env.STARTUP_VALIDATION_TIER || 'premium,reliable').split(',');
123
+ const STARTUP_VALIDATION_SAMPLE_SIZE = parseInt(process.env.STARTUP_NODE_SAMPLE || '0'); // 0 = all nodes in tier
124
+ /**
125
+ * Check if a node is marked as dead
126
+ */
127
+ const isNodeDead = (nodeUrl) => {
128
+ const deathTime = DEAD_NODES.get(nodeUrl);
129
+ if (!deathTime)
130
+ return false;
131
+ // Check if TTL expired
132
+ if (Date.now() - deathTime > DEAD_NODE_TTL_MS) {
133
+ DEAD_NODES.delete(nodeUrl);
134
+ return false;
135
+ }
136
+ return true;
137
+ };
138
+ /**
139
+ * Mark a node as dead
140
+ * Only logs at WARN level for premium/reliable nodes, DEBUG for others
141
+ */
142
+ const markNodeDead = (nodeUrl, tier) => {
143
+ DEAD_NODES.set(nodeUrl, Date.now());
144
+ // Only log failures for premium/reliable nodes (reduces spam)
145
+ if (tier === 'premium' || tier === 'reliable') {
146
+ log.warn(TAG + ' | markNodeDead | ', `⚠️ ${tier} node failed: ${nodeUrl.substring(0, 50)}...`);
147
+ }
148
+ else {
149
+ log.debug(TAG + ' | markNodeDead | ', `Public/untrusted node failed: ${nodeUrl.substring(0, 50)}...`);
150
+ }
151
+ };
152
+ /**
153
+ * Quick health check for a single node
154
+ * @param node Node to test
155
+ * @param timeout Timeout in ms
156
+ * @returns true if healthy, false otherwise
157
+ */
158
+ const quickNodeHealthCheck = async (node, timeout = STARTUP_VALIDATION_TIMEOUT) => {
159
+ try {
160
+ // Extract chain ID from networkId (e.g., "eip155:1" -> 1)
161
+ let chainId;
162
+ if (node.networkId.includes(':')) {
163
+ chainId = parseInt(node.networkId.split(':')[1]);
164
+ }
165
+ // Create provider with timeout
166
+ const provider = new ethers.providers.JsonRpcProvider({
167
+ url: node.service,
168
+ timeout,
169
+ });
170
+ // Race between chainId check and timeout
171
+ const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error('Timeout')), timeout));
172
+ const network = await Promise.race([
173
+ provider.getNetwork(),
174
+ timeoutPromise,
175
+ ]);
176
+ // Verify chain ID matches if expected
177
+ if (chainId && network.chainId !== chainId) {
178
+ return false;
179
+ }
180
+ return true;
181
+ }
182
+ catch (e) {
183
+ return false;
184
+ }
185
+ };
186
+ /**
187
+ * Validate nodes on startup
188
+ * Tests nodes in parallel, respecting tier configuration
189
+ * Only validates premium/reliable nodes by default (configurable via STARTUP_VALIDATION_TIER)
190
+ */
191
+ const validateNodesOnStartup = async () => {
192
+ const tag = TAG + ' | validateNodesOnStartup | ';
193
+ if (!STARTUP_VALIDATION_ENABLED) {
194
+ log.info(tag, 'Startup node validation disabled (VALIDATE_NODES_ON_STARTUP=false)');
195
+ return;
196
+ }
197
+ if (NODES.length === 0) {
198
+ log.warn(tag, 'No nodes to validate');
199
+ return;
200
+ }
201
+ // Filter nodes by tier
202
+ let nodesToTest = NODES.filter(node => {
203
+ const tier = node.tier || 'public';
204
+ return STARTUP_VALIDATION_TIERS.includes(tier);
205
+ });
206
+ log.info(tag, `Filtering ${NODES.length} total nodes to ${nodesToTest.length} nodes in tiers: ${STARTUP_VALIDATION_TIERS.join(', ')}`);
207
+ if (nodesToTest.length === 0) {
208
+ log.warn(tag, `No nodes found matching tiers: ${STARTUP_VALIDATION_TIERS.join(', ')}`);
209
+ return;
210
+ }
211
+ // Sample if configured
212
+ if (STARTUP_VALIDATION_SAMPLE_SIZE > 0 && STARTUP_VALIDATION_SAMPLE_SIZE < nodesToTest.length) {
213
+ nodesToTest = nodesToTest
214
+ .sort(() => Math.random() - 0.5)
215
+ .slice(0, STARTUP_VALIDATION_SAMPLE_SIZE);
216
+ log.info(tag, `Sampling ${STARTUP_VALIDATION_SAMPLE_SIZE} random nodes from filtered list`);
217
+ }
218
+ const startTime = Date.now();
219
+ let completed = 0;
220
+ let healthy = 0;
221
+ let dead = 0;
222
+ const tierStats = {};
223
+ // Process nodes in batches with concurrency limit
224
+ const queue = [...nodesToTest];
225
+ const workers = [];
226
+ for (let i = 0; i < STARTUP_VALIDATION_CONCURRENCY; i++) {
227
+ workers.push((async () => {
228
+ while (queue.length > 0) {
229
+ const node = queue.shift();
230
+ if (!node)
231
+ break;
232
+ const tier = node.tier || 'public';
233
+ if (!tierStats[tier]) {
234
+ tierStats[tier] = { healthy: 0, dead: 0 };
235
+ }
236
+ const isHealthy = await quickNodeHealthCheck(node, STARTUP_VALIDATION_TIMEOUT);
237
+ completed++;
238
+ if (!isHealthy) {
239
+ markNodeDead(node.service, node.tier);
240
+ dead++;
241
+ tierStats[tier].dead++;
242
+ }
243
+ else {
244
+ healthy++;
245
+ tierStats[tier].healthy++;
246
+ }
247
+ }
248
+ })());
249
+ }
250
+ await Promise.all(workers);
251
+ const elapsed = Date.now() - startTime;
252
+ const healthyPercent = ((healthy / nodesToTest.length) * 100).toFixed(1);
253
+ // Log tier breakdown
254
+ const tierSummary = Object.entries(tierStats)
255
+ .map(([tier, stats]) => `${tier}: ${stats.healthy}/${stats.healthy + stats.dead}`)
256
+ .join(', ');
257
+ log.info(tag, `✅ Validation complete: ${healthy}/${nodesToTest.length} healthy (${healthyPercent}%) in ${elapsed}ms | ${tierSummary}`);
258
+ if (dead > 0) {
259
+ log.info(tag, `Marked ${dead} nodes as dead - will skip for ${DEAD_NODE_TTL_MS / 1000 / 60}min`);
260
+ }
261
+ if (healthy === 0) {
262
+ log.error(tag, '❌ No healthy nodes found! Network operations may fail');
263
+ }
264
+ };
265
+ /**
266
+ * Initialize the ETH network module
267
+ */
268
+ const init = async function (settings, redis) {
269
+ const tag = TAG + ' | init | ';
270
+ // NOTE: Blockbook is initialized globally by the server with all networks
271
+ // Do NOT call blockbook.init() here as it would overwrite the server's initialization
272
+ log.debug(tag, 'Blockbook should already be initialized by server for transaction history');
273
+ // Initialize node health tracking (with optional Redis)
274
+ await NodeHealth.initNodeHealth(redis);
275
+ // Load web3 nodes from @pioneer-platform/nodes
276
+ let web3nodes = (0, nodes_1.getWeb3Nodes)();
277
+ for (let i = 0; i < web3nodes.length; i++) {
278
+ let node = web3nodes[i];
279
+ if (!node.networkId)
280
+ throw Error('missing networkId');
281
+ if (!node.service)
282
+ throw Error('missing service');
283
+ // Cast to TieredNode (tier will be 'premium' | 'reliable' | 'public' | 'untrusted' or undefined)
284
+ NODES.push(node);
285
+ }
286
+ log.info(tag, `Loaded ${NODES.length} Web3 nodes (${NODES.filter(n => n.tier === 'premium').length} premium, ${NODES.filter(n => n.tier === 'reliable').length} reliable)`);
287
+ // Validate nodes in background to avoid blocking server startup
288
+ if (STARTUP_VALIDATION_ENABLED) {
289
+ log.info(tag, '🔄 Starting background node validation...');
290
+ validateNodesOnStartup().catch(err => {
291
+ log.error(tag, 'Background node validation failed:', err);
292
+ });
293
+ }
294
+ if (!settings) {
295
+ // Use default mainnet
296
+ PROVIDER = new ethers.providers.JsonRpcProvider(process.env['PARITY_ARCHIVE_NODE'] || 'https://eth.llamarpc.com');
297
+ PROVIDER_BASE = new ethers.providers.JsonRpcProvider(process.env['BASE_NODE'] || 'https://base.llamarpc.com');
298
+ ETHERSCAN = new providers_1.EtherscanProvider('mainnet', process.env['ETHERSCAN_API_KEY']);
299
+ NODE_URL = process.env['PARITY_ARCHIVE_NODE'] || 'https://eth.llamarpc.com';
300
+ }
301
+ else if (settings.testnet) {
302
+ if (!process.env['INFURA_TESTNET_ROPSTEN'])
303
+ throw Error("Missing INFURA_TESTNET_ROPSTEN");
304
+ if (!process.env['ETHERSCAN_API_KEY'])
305
+ throw Error("Missing ETHERSCAN_API_KEY");
306
+ PROVIDER = new ethers.providers.JsonRpcProvider(process.env['INFURA_TESTNET_ROPSTEN']);
307
+ NODE_URL = process.env['INFURA_TESTNET_ROPSTEN'];
308
+ ETHERSCAN = new providers_1.EtherscanProvider('ropsten', process.env['ETHERSCAN_API_KEY']);
309
+ }
310
+ else {
311
+ PROVIDER = new ethers.providers.JsonRpcProvider(process.env['PARITY_ARCHIVE_NODE'] || 'https://eth.llamarpc.com');
312
+ }
313
+ log.info(tag, 'ETH network module initialized successfully');
314
+ };
315
+ exports.init = init;
316
+ /**
317
+ * Get registered nodes
318
+ */
319
+ const getNodes = function () {
320
+ return NODES;
321
+ };
322
+ exports.getNodes = getNodes;
323
+ /**
324
+ * Add a single node
325
+ */
326
+ const addNode = function (node) {
327
+ if (!node.networkId)
328
+ throw Error('missing networkId');
329
+ if (!node.service)
330
+ throw Error('missing service');
331
+ return NODES.push(node);
332
+ };
333
+ exports.addNode = addNode;
334
+ /**
335
+ * Add multiple nodes
336
+ */
337
+ const addNodes = function (nodeList) {
338
+ for (let i = 0; i < nodeList.length; i++) {
339
+ let node = nodeList[i];
340
+ if (!node.networkId)
341
+ throw Error('missing networkId');
342
+ if (!node.service)
343
+ throw Error('missing service');
344
+ NODES.push(node);
345
+ }
346
+ };
347
+ exports.addNodes = addNodes;
348
+ /**
349
+ * Get nonce for address
350
+ */
351
+ const getNonce = async function (address) {
352
+ return await PROVIDER.getTransactionCount(address, 'pending');
353
+ };
354
+ exports.getNonce = getNonce;
355
+ /**
356
+ * Get transaction count by network
357
+ */
358
+ const getNonceByNetwork = async function (networkId, address) {
359
+ let tag = TAG + ' | getNonceByNetwork | ';
360
+ try {
361
+ let node = NODES.find((n) => n.networkId === networkId);
362
+ if (!node)
363
+ throw Error(`Node not found for networkId: ${networkId}`);
364
+ const provider = new ethers.providers.JsonRpcProvider(node.service);
365
+ return await withTimeout(provider.getTransactionCount(address, 'pending'), RPC_TIMEOUT_MS, `Nonce fetch timeout after ${RPC_TIMEOUT_MS}ms`);
366
+ }
367
+ catch (e) {
368
+ log.error(tag, e);
369
+ throw e;
370
+ }
371
+ };
372
+ exports.getNonceByNetwork = getNonceByNetwork;
373
+ /**
374
+ * Get current gas price
375
+ */
376
+ const getGasPrice = async function () {
377
+ return await PROVIDER.getGasPrice();
378
+ };
379
+ exports.getGasPrice = getGasPrice;
380
+ /**
381
+ * Get gas price by network - with retry logic across multiple nodes
382
+ */
383
+ const getGasPriceByNetwork = async function (networkId) {
384
+ let tag = TAG + ' | getGasPriceByNetwork | ';
385
+ // Get ALL nodes for this network
386
+ let nodes = NODES.filter((n) => n.networkId === networkId);
387
+ if (!nodes || nodes.length === 0) {
388
+ throw Error(`No nodes found for networkId: ${networkId}`);
389
+ }
390
+ // Extract chain ID from networkId (e.g., "eip155:1" -> 1)
391
+ let chainId;
392
+ if (networkId.includes(':')) {
393
+ chainId = parseInt(networkId.split(':')[1]);
394
+ }
395
+ // Try each node until one succeeds
396
+ let lastError = null;
397
+ for (let i = 0; i < nodes.length; i++) {
398
+ const node = nodes[i];
399
+ try {
400
+ // Use StaticJsonRpcProvider to skip network auto-detection
401
+ const provider = new ethers.providers.StaticJsonRpcProvider(node.service, chainId ? { chainId, name: node.networkId } : undefined);
402
+ const gasPrice = await withTimeout(provider.getGasPrice(), RPC_TIMEOUT_MS, `Gas price fetch timeout after ${RPC_TIMEOUT_MS}ms for ${node.service.substring(0, 50)}`);
403
+ // Success! Log which node worked (only if not the first one)
404
+ if (i > 0) {
405
+ log.info(tag, `Successfully fetched gas price from node ${i + 1}/${nodes.length}: ${node.service.substring(0, 50)}`);
406
+ }
407
+ return gasPrice;
408
+ }
409
+ catch (e) {
410
+ lastError = e;
411
+ // Log the failure and try next node
412
+ log.warn(tag, `Node ${i + 1}/${nodes.length} failed (${node.service.substring(0, 50)}): ${e.message || e}`);
413
+ // Continue to next node
414
+ }
415
+ }
416
+ // All nodes failed
417
+ log.error(tag, `All ${nodes.length} nodes failed for ${networkId}`);
418
+ throw lastError || Error(`Failed to fetch gas price from all nodes for ${networkId}`);
419
+ };
420
+ exports.getGasPriceByNetwork = getGasPriceByNetwork;
421
+ /**
422
+ * Estimate fee for transaction
423
+ */
424
+ const estimateFee = async function (asset, params) {
425
+ if (!params)
426
+ throw Error("params required");
427
+ if (!params.asset && !asset)
428
+ throw Error("Asset or params.asset required");
429
+ let assetForEstimate = asset ? asset : params.asset;
430
+ const { average: averageGP, fast: fastGP, fastest: fastestGP } = (0, utils_1.getDefaultGasPrices)();
431
+ let assetAddress;
432
+ if (assetForEstimate && (0, xchain_util_1.assetToString)(assetForEstimate) !== (0, xchain_util_1.assetToString)(xchain_util_1.AssetETH)) {
433
+ assetAddress = (0, utils_1.getTokenAddress)(assetForEstimate);
434
+ }
435
+ let gasLimit;
436
+ if (assetAddress && assetAddress !== utils_1.ETHAddress) {
437
+ // ERC20 token transfer
438
+ if (!params.sender || !params.recipient)
439
+ throw Error("missing params! need sender and recipient on token tx!");
440
+ let contract = new ethers.Contract(assetAddress, ERC20ABI, PROVIDER);
441
+ gasLimit = params.data
442
+ ? await contract.estimateGas.transfer(params.recipient, params.data, { from: params.sender })
443
+ : await contract.estimateGas.transfer(params.recipient, 1, { from: params.sender });
444
+ }
445
+ else {
446
+ // ETH transfer
447
+ if (!params.sender || !params.recipient)
448
+ throw Error("missing params! need sender and recipient");
449
+ const gasEstimate = await PROVIDER.estimateGas({
450
+ from: params.sender,
451
+ to: params.recipient,
452
+ value: ethers.utils.parseEther('0.000001')
453
+ });
454
+ gasLimit = gasEstimate.add(0);
455
+ }
456
+ return {
457
+ gasPrices: {
458
+ average: averageGP,
459
+ fast: fastGP,
460
+ fastest: fastestGP
461
+ },
462
+ gasLimit,
463
+ fees: {
464
+ type: 'byte',
465
+ average: (0, utils_1.getFee)({ gasPrice: averageGP, gasLimit }),
466
+ fast: (0, utils_1.getFee)({ gasPrice: fastGP, gasLimit }),
467
+ fastest: (0, utils_1.getFee)({ gasPrice: fastestGP, gasLimit })
468
+ }
469
+ };
470
+ };
471
+ exports.estimateFee = estimateFee;
472
+ /**
473
+ * Get ETH balance for address
474
+ */
475
+ const getBalance = async function (address) {
476
+ let tag = TAG + ' | getBalance | ';
477
+ try {
478
+ const balance = await PROVIDER.getBalance(address);
479
+ return ethers.utils.formatEther(balance);
480
+ }
481
+ catch (e) {
482
+ log.error(tag, e);
483
+ throw e;
484
+ }
485
+ };
486
+ exports.getBalance = getBalance;
487
+ /**
488
+ * Get balances for multiple addresses
489
+ */
490
+ const getBalances = async function (addresses) {
491
+ let tag = TAG + ' | getBalances | ';
492
+ try {
493
+ const promises = addresses.map(addr => PROVIDER.getBalance(addr));
494
+ const balances = await Promise.all(promises);
495
+ return balances.map(b => ethers.utils.formatEther(b));
496
+ }
497
+ catch (e) {
498
+ log.error(tag, e);
499
+ throw e;
500
+ }
501
+ };
502
+ exports.getBalances = getBalances;
503
+ /**
504
+ * Get ERC20 token balance
505
+ */
506
+ const getBalanceToken = async function (address, tokenAddress) {
507
+ let tag = TAG + ' | getBalanceToken | ';
508
+ try {
509
+ const contract = new ethers.Contract(tokenAddress, ERC20ABI, PROVIDER);
510
+ const balance = await contract.balanceOf(address);
511
+ const decimals = await contract.decimals();
512
+ return ethers.utils.formatUnits(balance, decimals);
513
+ }
514
+ catch (e) {
515
+ log.error(tag, e);
516
+ throw e;
517
+ }
518
+ };
519
+ exports.getBalanceToken = getBalanceToken;
520
+ /**
521
+ * Get ERC20 allowance
522
+ */
523
+ const getAllowance = async function (tokenAddress, spender, sender) {
524
+ let tag = TAG + ' | getAllowance | ';
525
+ try {
526
+ const contract = new ethers.Contract(tokenAddress, ERC20ABI, PROVIDER);
527
+ const allowance = await contract.allowance(sender, spender);
528
+ return allowance.toString();
529
+ }
530
+ catch (e) {
531
+ log.error(tag, e);
532
+ throw e;
533
+ }
534
+ };
535
+ exports.getAllowance = getAllowance;
536
+ /**
537
+ * Get symbol from contract address
538
+ */
539
+ const getSymbolFromContract = async function (contractAddress) {
540
+ let tag = TAG + ' | getSymbolFromContract | ';
541
+ try {
542
+ const contract = new ethers.Contract(contractAddress, ERC20ABI, PROVIDER);
543
+ return await contract.symbol();
544
+ }
545
+ catch (e) {
546
+ log.error(tag, e);
547
+ throw e;
548
+ }
549
+ };
550
+ exports.getSymbolFromContract = getSymbolFromContract;
551
+ /**
552
+ * Get transaction by hash
553
+ */
554
+ const getTransaction = async function (txid) {
555
+ let tag = TAG + ' | getTransaction | ';
556
+ try {
557
+ const tx = await PROVIDER.getTransaction(txid);
558
+ const receipt = await PROVIDER.getTransactionReceipt(txid);
559
+ return { tx, receipt };
560
+ }
561
+ catch (e) {
562
+ log.error(tag, e);
563
+ throw e;
564
+ }
565
+ };
566
+ exports.getTransaction = getTransaction;
567
+ /**
568
+ * Broadcast signed transaction
569
+ */
570
+ const broadcast = async function (tx) {
571
+ let tag = TAG + ' | broadcast | ';
572
+ try {
573
+ const result = await PROVIDER.sendTransaction(tx);
574
+ return result;
575
+ }
576
+ catch (e) {
577
+ log.error(tag, e);
578
+ throw e;
579
+ }
580
+ };
581
+ exports.broadcast = broadcast;
582
+ /**
583
+ * Get ERC20 transfer data (for building unsigned transactions)
584
+ */
585
+ const getTransferData = function (toAddress, amount, contractAddress) {
586
+ const iface = new ethers.utils.Interface(ERC20ABI);
587
+ return iface.encodeFunctionData("transfer", [toAddress, amount]);
588
+ };
589
+ exports.getTransferData = getTransferData;
590
+ /**
591
+ * Encode memo data for THORChain/Maya swaps
592
+ */
593
+ const getMemoEncoded = async function (swap) {
594
+ let tag = TAG + ' | getMemoEncoded | ';
595
+ try {
596
+ const provider = new ethers.providers.JsonRpcProvider('https://eth.llamarpc.com');
597
+ const abiEncoder = new ethers.utils.AbiCoder();
598
+ const encoded = abiEncoder.encode(["address", "address", "uint256", "uint256", "string"], [
599
+ swap.vault || "0x0000000000000000000000000000000000000000",
600
+ swap.asset || "0x0000000000000000000000000000000000000000",
601
+ swap.amount || "0",
602
+ swap.amountOutMin || "0",
603
+ swap.memo || ""
604
+ ]);
605
+ return encoded;
606
+ }
607
+ catch (e) {
608
+ log.error(tag, "Error encoding memo:", e);
609
+ throw e;
610
+ }
611
+ };
612
+ exports.getMemoEncoded = getMemoEncoded;
613
+ /**
614
+ * Get ETH balance by network - with parallel racing logic
615
+ */
616
+ const getBalanceAddressByNetwork = async function (networkId, address) {
617
+ let tag = TAG + ' | getBalanceAddressByNetwork | ';
618
+ // CRITICAL FIX: Normalize/checksum address before using with ethers
619
+ let checksummedAddress;
620
+ try {
621
+ checksummedAddress = ethers.utils.getAddress(address);
622
+ }
623
+ catch (e) {
624
+ log.error(tag, `Invalid Ethereum address format: ${address} - ${e.message}`);
625
+ throw new Error(`Invalid Ethereum address: ${address}`);
626
+ }
627
+ // Get ALL nodes for this network, excluding dead ones
628
+ let allNodes = NODES.filter((n) => n.networkId === networkId);
629
+ if (!allNodes || allNodes.length === 0) {
630
+ throw Error(`No nodes found for networkId: ${networkId}`);
631
+ }
632
+ // Filter out dead nodes and sort by health score (tier + metrics)
633
+ const nodesWithHealth = await Promise.all(allNodes
634
+ .filter((n) => !isNodeDead(n.service))
635
+ .map(async (node) => {
636
+ const metrics = await NodeHealth.getNodeHealth(node.service, node.networkId);
637
+ const score = NodeHealth.getHealthScore(node, metrics);
638
+ return { node, score, metrics };
639
+ }));
640
+ let nodes = nodesWithHealth
641
+ .sort((a, b) => b.score - a.score) // Higher score first
642
+ .map((item) => item.node);
643
+ if (nodes.length === 0) {
644
+ log.error(tag, `All ${allNodes.length} nodes are marked dead for ${networkId} - failing fast`);
645
+ throw Error(`No healthy nodes available for ${networkId} (all ${allNodes.length} nodes are dead)`);
646
+ }
647
+ const topTier = nodes[0].tier || 'public';
648
+ log.info(tag, `Racing top ${Math.min(RACE_BATCH_SIZE, nodes.length)} nodes (${nodes.length} alive of ${allNodes.length} total, starting with ${topTier})`);
649
+ // Extract chain ID from networkId (e.g., "eip155:1" -> 1)
650
+ let chainId;
651
+ if (networkId.includes(':')) {
652
+ chainId = parseInt(networkId.split(':')[1]);
653
+ }
654
+ // Race nodes in batches (now sorted by tier, so best nodes first)
655
+ let batchStartIndex = 0;
656
+ while (batchStartIndex < nodes.length) {
657
+ const batch = nodes.slice(batchStartIndex, batchStartIndex + RACE_BATCH_SIZE);
658
+ // Create racing promises for this batch
659
+ const racePromises = batch.map(async (node, index) => {
660
+ const startTime = Date.now();
661
+ try {
662
+ const provider = new ethers.providers.StaticJsonRpcProvider(node.service, chainId ? { chainId, name: node.networkId } : undefined);
663
+ const balance = await withTimeout(provider.getBalance(checksummedAddress), // USE CHECKSUMMED ADDRESS
664
+ RPC_TIMEOUT_MS, `Balance fetch timeout after ${RPC_TIMEOUT_MS}ms`);
665
+ const result = ethers.utils.formatEther(balance);
666
+ // Record success with response time
667
+ const responseTime = Date.now() - startTime;
668
+ await NodeHealth.recordSuccess(node.service, node.networkId, responseTime);
669
+ log.info(tag, `✅ Node ${batchStartIndex + index + 1}/${nodes.length} succeeded: ${node.service.substring(0, 50)} (${responseTime}ms)`);
670
+ return { success: true, result, nodeUrl: node.service };
671
+ }
672
+ catch (e) {
673
+ log.warn(tag, `❌ Node ${batchStartIndex + index + 1}/${nodes.length} failed: ${node.service.substring(0, 50)} - ${e.message}`);
674
+ // CRITICAL FIX: Don't mark nodes dead for validation errors
675
+ // Only mark dead for connectivity/timeout issues
676
+ const isValidationError = e.message && (e.message.includes('invalid address') ||
677
+ e.message.includes('INVALID_ARGUMENT') ||
678
+ e.message.includes('bad address checksum'));
679
+ if (!isValidationError) {
680
+ markNodeDead(node.service, node.tier);
681
+ await NodeHealth.recordFailure(node.service, node.networkId);
682
+ }
683
+ else {
684
+ log.warn(tag, `Skipping node death marking - validation error (not node's fault)`);
685
+ }
686
+ throw e;
687
+ }
688
+ });
689
+ // Race this batch - return first successful result
690
+ try {
691
+ const winner = await Promise.race(racePromises.map(p => p.catch(e => Promise.reject(e))));
692
+ return winner.result;
693
+ }
694
+ catch (e) {
695
+ log.warn(tag, `Batch ${Math.floor(batchStartIndex / RACE_BATCH_SIZE) + 1} failed, trying next batch...`);
696
+ }
697
+ batchStartIndex += RACE_BATCH_SIZE;
698
+ }
699
+ // All batches failed
700
+ const deadCount = DEAD_NODES.size;
701
+ log.error(tag, `All ${nodes.length} nodes failed for ${networkId} (${deadCount} marked dead)`);
702
+ throw Error(`Failed to fetch balance from all nodes for ${networkId}`);
703
+ };
704
+ exports.getBalanceAddressByNetwork = getBalanceAddressByNetwork;
705
+ /**
706
+ * Get ERC20 token balance by network
707
+ */
708
+ const getBalanceTokenByNetwork = async function (networkId, address, tokenAddress) {
709
+ let tag = TAG + ' | getBalanceTokenByNetwork | ';
710
+ // Get ALL nodes for this network, excluding dead ones
711
+ let allNodes = NODES.filter((n) => n.networkId === networkId);
712
+ if (!allNodes || allNodes.length === 0) {
713
+ throw Error(`No nodes found for networkId: ${networkId}`);
714
+ }
715
+ // Filter out dead nodes and sort by tier priority
716
+ let nodes = allNodes
717
+ .filter((n) => !isNodeDead(n.service))
718
+ .sort((a, b) => {
719
+ const tierA = a.tier || 'public';
720
+ const tierB = b.tier || 'public';
721
+ return TIER_PRIORITY[tierA] - TIER_PRIORITY[tierB];
722
+ });
723
+ if (nodes.length === 0) {
724
+ log.error(tag, `All ${allNodes.length} nodes are marked dead for ${networkId} token ${tokenAddress} - failing fast`);
725
+ throw Error(`No healthy nodes available for ${networkId} token ${tokenAddress} (all ${allNodes.length} nodes are dead)`);
726
+ }
727
+ const topTier = nodes[0].tier || 'public';
728
+ log.info(tag, `Racing top ${Math.min(RACE_BATCH_SIZE, nodes.length)} nodes (${nodes.length} alive of ${allNodes.length} total, starting with ${topTier})`);
729
+ // Extract chain ID from networkId (e.g., "eip155:1" -> 1)
730
+ let chainId;
731
+ if (networkId.includes(':')) {
732
+ chainId = parseInt(networkId.split(':')[1]);
733
+ }
734
+ // Race nodes in batches
735
+ let batchStartIndex = 0;
736
+ while (batchStartIndex < nodes.length) {
737
+ const batch = nodes.slice(batchStartIndex, batchStartIndex + RACE_BATCH_SIZE);
738
+ // Create racing promises for this batch
739
+ const racePromises = batch.map(async (node, index) => {
740
+ try {
741
+ const provider = new ethers.providers.StaticJsonRpcProvider(node.service, chainId ? { chainId, name: node.networkId } : undefined);
742
+ const contract = new ethers.Contract(tokenAddress, ERC20ABI, provider);
743
+ // Wrap both contract calls with timeout
744
+ const balance = await withTimeout(contract.balanceOf(address), RPC_TIMEOUT_MS, `Token balance fetch timeout after ${RPC_TIMEOUT_MS}ms`);
745
+ const decimals = await withTimeout(contract.decimals(), RPC_TIMEOUT_MS, `Token decimals fetch timeout after ${RPC_TIMEOUT_MS}ms`);
746
+ const result = ethers.utils.formatUnits(balance, decimals);
747
+ // Success!
748
+ log.info(tag, `✅ Node ${batchStartIndex + index + 1}/${nodes.length} succeeded: ${node.service.substring(0, 50)}`);
749
+ return { success: true, result, nodeUrl: node.service };
750
+ }
751
+ catch (e) {
752
+ // Mark node as dead and continue
753
+ log.warn(tag, `❌ Node ${batchStartIndex + index + 1}/${nodes.length} failed: ${node.service.substring(0, 50)} - ${e.message}`);
754
+ markNodeDead(node.service, node.tier);
755
+ throw e;
756
+ }
757
+ });
758
+ // Race this batch - return first successful result
759
+ try {
760
+ const winner = await Promise.race(racePromises.map(p => p.catch(e => Promise.reject(e))));
761
+ return winner.result;
762
+ }
763
+ catch (e) {
764
+ // All nodes in this batch failed, try next batch
765
+ log.warn(tag, `Batch ${Math.floor(batchStartIndex / RACE_BATCH_SIZE) + 1} failed, trying next batch...`);
766
+ }
767
+ batchStartIndex += RACE_BATCH_SIZE;
768
+ }
769
+ // All batches failed
770
+ const deadCount = DEAD_NODES.size;
771
+ log.error(tag, `All ${nodes.length} nodes failed for token balance ${networkId} (${deadCount} marked dead)`);
772
+ throw Error(`Failed to fetch token balance from all nodes for ${networkId}`);
773
+ };
774
+ exports.getBalanceTokenByNetwork = getBalanceTokenByNetwork;
775
+ /**
776
+ * Get all ERC20 token balances by network (returns empty array for now - requires token list)
777
+ */
778
+ const getBalanceTokensByNetwork = async function (networkId, address) {
779
+ let tag = TAG + ' | getBalanceTokensByNetwork | ';
780
+ try {
781
+ log.warn(tag, 'Token discovery not yet implemented - return empty array');
782
+ // TODO: Implement token discovery via Alchemy/Covalent/etc
783
+ return [];
784
+ }
785
+ catch (e) {
786
+ log.error(tag, e);
787
+ throw e;
788
+ }
789
+ };
790
+ exports.getBalanceTokensByNetwork = getBalanceTokensByNetwork;
791
+ /**
792
+ * Get ERC20 token decimals by network - CRITICAL for correct amount calculations
793
+ */
794
+ const getTokenDecimalsByNetwork = async function (networkId, tokenAddress) {
795
+ let tag = TAG + ' | getTokenDecimalsByNetwork | ';
796
+ try {
797
+ // Find the node for this network
798
+ let node = NODES.find((n) => n.networkId === networkId);
799
+ if (!node)
800
+ throw Error(`Node not found for networkId: ${networkId}`);
801
+ // Extract chain ID from networkId (e.g., "eip155:1" -> 1)
802
+ let chainId;
803
+ if (networkId.includes(':')) {
804
+ chainId = parseInt(networkId.split(':')[1]);
805
+ }
806
+ // Use StaticJsonRpcProvider to skip network auto-detection
807
+ const provider = new ethers.providers.StaticJsonRpcProvider(node.service, chainId ? { chainId, name: node.networkId } : undefined);
808
+ const contract = new ethers.Contract(tokenAddress, ERC20ABI, provider);
809
+ const decimals = await contract.decimals();
810
+ // Return as number for easy use
811
+ return Number(decimals);
812
+ }
813
+ catch (e) {
814
+ log.error(tag, 'Failed to fetch decimals for', tokenAddress, 'on', networkId, ':', e);
815
+ throw e;
816
+ }
817
+ };
818
+ exports.getTokenDecimalsByNetwork = getTokenDecimalsByNetwork;
819
+ /**
820
+ * Get comprehensive ERC20 token metadata from contract address
821
+ * Fetches name, symbol, decimals, and can check balance for a user address
822
+ */
823
+ const getTokenMetadata = async function (networkId, contractAddress, userAddress) {
824
+ let tag = TAG + ' | getTokenMetadata | ';
825
+ // Get ALL nodes for this network
826
+ let allNodes = NODES.filter((n) => n.networkId === networkId);
827
+ if (!allNodes || allNodes.length === 0) {
828
+ throw Error(`No nodes found for networkId: ${networkId}`);
829
+ }
830
+ // Filter out dead nodes and sort by tier priority
831
+ let nodes = allNodes
832
+ .filter((n) => !isNodeDead(n.service))
833
+ .sort((a, b) => {
834
+ const tierA = a.tier || 'public';
835
+ const tierB = b.tier || 'public';
836
+ return TIER_PRIORITY[tierA] - TIER_PRIORITY[tierB];
837
+ });
838
+ if (nodes.length === 0) {
839
+ log.error(tag, `All ${allNodes.length} nodes are marked dead for ${networkId} contract ${contractAddress} - failing fast`);
840
+ throw Error(`No healthy nodes available for ${networkId} contract ${contractAddress} (all ${allNodes.length} nodes are dead)`);
841
+ }
842
+ const topTier = nodes[0].tier || 'public';
843
+ log.info(tag, `Racing top ${Math.min(RACE_BATCH_SIZE, nodes.length)} nodes for token metadata: ${contractAddress} (${nodes.length} alive of ${allNodes.length} total, starting with ${topTier})`);
844
+ // Extract chain ID from networkId (e.g., "eip155:1" -> 1)
845
+ let chainId;
846
+ if (networkId.includes(':')) {
847
+ chainId = parseInt(networkId.split(':')[1]);
848
+ }
849
+ // Race nodes in batches
850
+ let batchStartIndex = 0;
851
+ while (batchStartIndex < nodes.length) {
852
+ const batch = nodes.slice(batchStartIndex, batchStartIndex + RACE_BATCH_SIZE);
853
+ // Create racing promises for this batch
854
+ const racePromises = batch.map(async (node, index) => {
855
+ try {
856
+ // Use StaticJsonRpcProvider to skip network auto-detection
857
+ const provider = new ethers.providers.StaticJsonRpcProvider(node.service, chainId ? { chainId, name: node.networkId } : undefined);
858
+ // Create contract instance
859
+ const contract = new ethers.Contract(contractAddress, ERC20ABI, provider);
860
+ // Fetch token metadata (name, symbol, decimals in parallel)
861
+ const [name, symbol, decimals] = await Promise.all([
862
+ withTimeout(contract.name(), RPC_TIMEOUT_MS, `Token name fetch timeout after ${RPC_TIMEOUT_MS}ms`),
863
+ withTimeout(contract.symbol(), RPC_TIMEOUT_MS, `Token symbol fetch timeout after ${RPC_TIMEOUT_MS}ms`),
864
+ withTimeout(contract.decimals(), RPC_TIMEOUT_MS, `Token decimals fetch timeout after ${RPC_TIMEOUT_MS}ms`)
865
+ ]);
866
+ // Build metadata object
867
+ const metadata = {
868
+ name: name || 'Unknown Token',
869
+ symbol: symbol || 'UNKNOWN',
870
+ decimals: Number(decimals) || 18,
871
+ contractAddress: contractAddress.toLowerCase(),
872
+ networkId,
873
+ chainId
874
+ };
875
+ // If user address provided, fetch balance
876
+ if (userAddress) {
877
+ try {
878
+ const balance = await withTimeout(contract.balanceOf(userAddress), RPC_TIMEOUT_MS, `Token balance fetch timeout after ${RPC_TIMEOUT_MS}ms`);
879
+ const formattedBalance = ethers.utils.formatUnits(balance, metadata.decimals);
880
+ metadata.balance = formattedBalance;
881
+ metadata.balanceRaw = balance.toString();
882
+ }
883
+ catch (balanceError) {
884
+ log.warn(tag, `Failed to fetch balance for ${userAddress}:`, balanceError);
885
+ metadata.balance = '0';
886
+ metadata.balanceRaw = '0';
887
+ }
888
+ }
889
+ log.info(tag, `✅ Node ${batchStartIndex + index + 1}/${nodes.length} succeeded: ${node.service.substring(0, 50)}`);
890
+ return { success: true, result: metadata, nodeUrl: node.service };
891
+ }
892
+ catch (e) {
893
+ log.warn(tag, `❌ Node ${batchStartIndex + index + 1}/${nodes.length} failed: ${node.service.substring(0, 50)} - ${e.message}`);
894
+ markNodeDead(node.service, node.tier);
895
+ throw e;
896
+ }
897
+ });
898
+ // Race this batch - return first successful result
899
+ try {
900
+ const winner = await Promise.race(racePromises.map(p => p.catch(e => Promise.reject(e))));
901
+ log.info(tag, `Token metadata fetched successfully:`, {
902
+ name: winner.result.name,
903
+ symbol: winner.result.symbol,
904
+ decimals: winner.result.decimals,
905
+ balance: winner.result.balance
906
+ });
907
+ return winner.result;
908
+ }
909
+ catch (e) {
910
+ log.warn(tag, `Batch ${Math.floor(batchStartIndex / RACE_BATCH_SIZE) + 1} failed, trying next batch...`);
911
+ }
912
+ batchStartIndex += RACE_BATCH_SIZE;
913
+ }
914
+ // All batches failed
915
+ const deadCount = DEAD_NODES.size;
916
+ log.error(tag, `All ${nodes.length} nodes failed for token ${contractAddress} on ${networkId} (${deadCount} marked dead)`);
917
+ throw new Error(`Failed to fetch token metadata from all nodes for ${contractAddress} on ${networkId}`);
918
+ };
919
+ exports.getTokenMetadata = getTokenMetadata;
920
+ /**
921
+ * Get token metadata by network with balance check
922
+ * Convenience wrapper for getTokenMetadata
923
+ */
924
+ const getTokenMetadataByNetwork = async function (networkId, contractAddress, userAddress) {
925
+ return (0, exports.getTokenMetadata)(networkId, contractAddress, userAddress);
926
+ };
927
+ exports.getTokenMetadataByNetwork = getTokenMetadataByNetwork;
928
+ /**
929
+ * Estimate gas by network
930
+ */
931
+ const estimateGasByNetwork = async function (networkId, transaction) {
932
+ let tag = TAG + ' | estimateGasByNetwork | ';
933
+ try {
934
+ let node = NODES.find((n) => n.networkId === networkId);
935
+ if (!node)
936
+ throw Error(`Node not found for networkId: ${networkId}`);
937
+ // Extract chain ID from networkId
938
+ let chainId;
939
+ if (networkId.includes(':')) {
940
+ chainId = parseInt(networkId.split(':')[1]);
941
+ }
942
+ const provider = new ethers.providers.StaticJsonRpcProvider(node.service, chainId ? { chainId, name: node.networkId } : undefined);
943
+ const gasEstimate = await provider.estimateGas(transaction);
944
+ return gasEstimate;
945
+ }
946
+ catch (e) {
947
+ log.error(tag, e);
948
+ throw e;
949
+ }
950
+ };
951
+ exports.estimateGasByNetwork = estimateGasByNetwork;
952
+ /**
953
+ * Broadcast transaction by network
954
+ */
955
+ const broadcastByNetwork = async function (networkId, signedTx) {
956
+ let tag = TAG + ' | broadcastByNetwork | ';
957
+ try {
958
+ let node = NODES.find((n) => n.networkId === networkId);
959
+ if (!node)
960
+ throw Error(`Node not found for networkId: ${networkId}`);
961
+ // Extract chain ID from networkId
962
+ let chainId;
963
+ if (networkId.includes(':')) {
964
+ chainId = parseInt(networkId.split(':')[1]);
965
+ }
966
+ const provider = new ethers.providers.StaticJsonRpcProvider(node.service, chainId ? { chainId, name: node.networkId } : undefined);
967
+ const result = await provider.sendTransaction(signedTx);
968
+ // Return in expected format for pioneer-server
969
+ return {
970
+ success: true,
971
+ txid: result.hash,
972
+ transactionHash: result.hash,
973
+ from: result.from,
974
+ to: result.to,
975
+ nonce: result.nonce,
976
+ gasLimit: result.gasLimit?.toString(),
977
+ gasPrice: result.gasPrice?.toString(),
978
+ chainId: result.chainId
979
+ };
980
+ }
981
+ catch (e) {
982
+ log.error(tag, e);
983
+ // Return error format expected by server
984
+ return {
985
+ success: false,
986
+ error: e.message || e.toString()
987
+ };
988
+ }
989
+ };
990
+ exports.broadcastByNetwork = broadcastByNetwork;
991
+ /**
992
+ * Get transaction by network and txid
993
+ */
994
+ const getTransactionByNetwork = async function (networkId, txid) {
995
+ let tag = TAG + ' | getTransactionByNetwork | ';
996
+ try {
997
+ let node = NODES.find((n) => n.networkId === networkId);
998
+ if (!node)
999
+ throw Error(`Node not found for networkId: ${networkId}`);
1000
+ // Extract chain ID from networkId
1001
+ let chainId;
1002
+ if (networkId.includes(':')) {
1003
+ chainId = parseInt(networkId.split(':')[1]);
1004
+ }
1005
+ const provider = new ethers.providers.StaticJsonRpcProvider(node.service, chainId ? { chainId, name: node.networkId } : undefined);
1006
+ log.info(tag, `Fetching transaction ${txid} on ${networkId}`);
1007
+ const tx = await provider.getTransaction(txid);
1008
+ const receipt = await provider.getTransactionReceipt(txid);
1009
+ return { tx, receipt };
1010
+ }
1011
+ catch (e) {
1012
+ log.error(tag, e);
1013
+ throw e;
1014
+ }
1015
+ };
1016
+ exports.getTransactionByNetwork = getTransactionByNetwork;
1017
+ /**
1018
+ * Get transaction history by network
1019
+ */
1020
+ const getTransactionsByNetwork = async function (networkId, address, options = {}) {
1021
+ let tag = TAG + ' | getTransactionsByNetwork | ';
1022
+ try {
1023
+ log.info(tag, `Fetching transactions for ${address} on ${networkId}`);
1024
+ // Normalize address to checksummed format
1025
+ let checksummedAddress;
1026
+ try {
1027
+ checksummedAddress = ethers.utils.getAddress(address);
1028
+ }
1029
+ catch (e) {
1030
+ log.error(tag, `Invalid Ethereum address format: ${address} - ${e.message}`);
1031
+ throw new Error(`Invalid Ethereum address: ${address}`);
1032
+ }
1033
+ // Determine which coin symbol to use for Blockbook
1034
+ // ETH mainnet uses "ETH", other networks would need mapping
1035
+ let coinSymbol = 'ETH'; // Default to ETH mainnet
1036
+ // Map network ID to Blockbook coin symbol
1037
+ const networkToCoin = {
1038
+ 'eip155:1': 'ETH', // Ethereum Mainnet
1039
+ 'eip155:137': 'MATIC', // Polygon (if supported)
1040
+ 'eip155:56': 'BSC', // BSC (if supported)
1041
+ // Add more as needed
1042
+ };
1043
+ if (networkToCoin[networkId]) {
1044
+ coinSymbol = networkToCoin[networkId];
1045
+ }
1046
+ else {
1047
+ log.warn(tag, `Unknown networkId ${networkId}, defaulting to ETH`);
1048
+ }
1049
+ // Fetch address info from Blockbook with transaction details
1050
+ const page = options?.page || 1;
1051
+ const pageSize = options?.pageSize || 1000; // Max transactions per page
1052
+ log.info(tag, `Fetching from Blockbook: ${coinSymbol} address ${checksummedAddress} page ${page} pageSize ${pageSize}`);
1053
+ // Use Blockbook to get address info with transactions
1054
+ const addressInfo = await blockbook.getAddressInfo(coinSymbol, checksummedAddress, 'txs', { page, pageSize });
1055
+ if (!addressInfo || !addressInfo.transactions) {
1056
+ log.warn(tag, 'No transactions found for address');
1057
+ return [];
1058
+ }
1059
+ // Parse and normalize transactions
1060
+ const transactions = addressInfo.transactions.map((tx) => ({
1061
+ hash: tx.txid,
1062
+ from: tx.vin && tx.vin[0] && tx.vin[0].addresses && tx.vin[0].addresses[0] ? tx.vin[0].addresses[0] : '',
1063
+ to: tx.vout && tx.vout[0] && tx.vout[0].addresses && tx.vout[0].addresses[0] ? tx.vout[0].addresses[0] : '',
1064
+ value: tx.value || '0',
1065
+ blockNumber: tx.blockHeight || 0,
1066
+ timestamp: tx.blockTime ? new Date(tx.blockTime * 1000).toISOString() : '',
1067
+ gasUsed: tx.ethereumSpecific?.gasUsed?.toString() || '0',
1068
+ gasPrice: tx.ethereumSpecific?.gasPrice?.toString() || '0',
1069
+ status: tx.ethereumSpecific?.status === 1 || tx.confirmations > 0 ? 'success' : 'failed',
1070
+ confirmations: tx.confirmations || 0,
1071
+ // Include raw tx for detailed analysis
1072
+ _raw: tx
1073
+ }));
1074
+ // Apply date range filter if specified
1075
+ if (options?.dateRange) {
1076
+ const from = options.dateRange.from ? new Date(options.dateRange.from).getTime() : 0;
1077
+ const to = options.dateRange.to ? new Date(options.dateRange.to).getTime() : Date.now();
1078
+ return transactions.filter((tx) => {
1079
+ const txTime = new Date(tx.timestamp).getTime();
1080
+ return txTime >= from && txTime <= to;
1081
+ });
1082
+ }
1083
+ log.info(tag, `Fetched ${transactions.length} transactions for ${checksummedAddress}`);
1084
+ return transactions;
1085
+ }
1086
+ catch (e) {
1087
+ log.error(tag, 'Error fetching transactions:', e.message);
1088
+ log.error(tag, 'Full error:', e);
1089
+ log.error(tag, 'Stack:', e.stack);
1090
+ // Don't throw - return empty array to allow graceful degradation
1091
+ return [];
1092
+ }
1093
+ };
1094
+ exports.getTransactionsByNetwork = getTransactionsByNetwork;
1095
+ // Legacy compatibility exports
1096
+ exports.getBalanceAddress = exports.getBalance;
1097
+ exports.getFees = exports.estimateFee;