@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/CHANGELOG.md +16 -0
- package/lib/constant.d.ts +208 -0
- package/lib/constant.js +553 -0
- package/lib/etherscan-api.d.ts +58 -0
- package/lib/etherscan-api.js +103 -0
- package/lib/index.d.ts +146 -0
- package/lib/index.js +1097 -0
- package/lib/node-health.d.ts +44 -0
- package/lib/node-health.js +257 -0
- package/lib/types/client-types.d.ts +61 -0
- package/lib/types/client-types.js +8 -0
- package/lib/types/etherscan-api-types.d.ts +55 -0
- package/lib/types/etherscan-api-types.js +2 -0
- package/lib/types/index.d.ts +2 -0
- package/lib/types/index.js +18 -0
- package/lib/utils.d.ts +135 -0
- package/lib/utils.js +470 -0
- package/package.json +2 -2
- package/tsconfig.json +9 -16
- package/tsconfig.tsbuildinfo +1 -1
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;
|