| … | … | @@ -18,6 +18,16 @@ const ETHEREUM_PROVIDERS = { |
| 18 | 18 | return `${baseUrl}?module=account&action=txlist&address=${address}&startblock=0&endblock=latest&page=1&offset=100&sort=desc` |
| 19 | 19 | }, |
| 20 | 20 | |
| 21 | // NEW: Get ERC-20 token transaction list |
| 22 | getTokenTxListUrl (address, network, contractAddress = null) { |
| 23 | const baseUrl = network === 'testnet' ? this.testnetUrl : this.mainnetUrl |
| 24 | let url = `${baseUrl}?module=account&action=tokentx&address=${address}&startblock=0&endblock=latest&page=1&offset=100&sort=desc` |
| 25 | if (contractAddress) { |
| 26 | url += `&contractaddress=${contractAddress}` |
| 27 | } |
| 28 | return url |
| 29 | }, |
| 30 | |
| 21 | 31 | parseResponse (data) { |
| 22 | 32 | if (data.status === '1' && Array.isArray(data.result)) { |
| 23 | 33 | return data.result.map(tx => ({ |
| … | … | @@ -32,6 +42,27 @@ const ETHEREUM_PROVIDERS = { |
| 32 | 42 | })) |
| 33 | 43 | } |
| 34 | 44 | throw new Error('Invalid response format') |
| 45 | }, |
| 46 | |
| 47 | // NEW: Parse token transaction response |
| 48 | parseTokenResponse (data) { |
| 49 | if (data.status === '1' && Array.isArray(data.result)) { |
| 50 | return data.result.map(tx => ({ |
| 51 | hash: tx.hash, |
| 52 | from: tx.from, |
| 53 | to: tx.to, |
| 54 | value: tx.value, // Token amount in smallest unit |
| 55 | tokenSymbol: tx.tokenSymbol, |
| 56 | tokenName: tx.tokenName, |
| 57 | tokenDecimal: parseInt(tx.tokenDecimal) || 0, |
| 58 | contractAddress: tx.contractAddress, |
| 59 | fee: tx.gasUsed && tx.gasPrice ? (BigInt(tx.gasUsed) * BigInt(tx.gasPrice)).toString() : '0', |
| 60 | blockNumber: parseInt(tx.blockNumber) || 0, |
| 61 | timestamp: parseInt(tx.timeStamp) || 0, |
| 62 | status: tx.txreceipt_status === '1' ? 'confirmed' : 'failed' |
| 63 | })) |
| 64 | } |
| 65 | throw new Error('Invalid response format') |
| 35 | 66 | } |
| 36 | 67 | }, |
| 37 | 68 | |
| … | … | @@ -268,6 +299,70 @@ async function fetchTransactions (chain, address, network, config) { |
| 268 | 299 | throw new Error(`All providers failed. Last error: ${lastError?.message}`) |
| 269 | 300 | } |
| 270 | 301 | |
| 302 | /** |
| 303 | * Fetch ERC-20 token transactions for an Ethereum address |
| 304 | * @param {string} address - Ethereum address |
| 305 | * @param {string} network - 'testnet' or 'mainnet' |
| 306 | * @param {Object} config - Configuration object from getConfig() |
| 307 | * @param {string} contractAddress - Optional: filter by token contract address |
| 308 | * @returns {Promise<Array>} - Array of token transactions |
| 309 | */ |
| 310 | async function fetchTokenTransactions (address, network, config, contractAddress = null) { |
| 311 | const providers = ETHEREUM_PROVIDERS |
| 312 | |
| 313 | // Get primary provider from config |
| 314 | const primaryProviderName = config.blockchain.ethereum.provider |
| 315 | const apiKey = config.blockchain.ethereum.apiKey |
| 316 | |
| 317 | // Build provider list (primary first, then fallbacks) |
| 318 | const providerList = [ |
| 319 | primaryProviderName, |
| 320 | ...Object.keys(providers).filter(p => p !== primaryProviderName) |
| 321 | ] |
| 322 | |
| 323 | let lastError = null |
| 324 | |
| 325 | for (const providerName of providerList) { |
| 326 | const provider = providers[providerName] |
| 327 | if (!provider || !provider.getTokenTxListUrl) { |
| 328 | console.log(` ⚠️ ${providerName} doesn't support token transactions`) |
| 329 | continue |
| 330 | } |
| 331 | |
| 332 | try { |
| 333 | // Skip if provider needs API key but we don't have one |
| 334 | if (provider.needsApiKey && !apiKey) { |
| 335 | console.log(` ⚠️ Skipping ${provider.name} (API key required)`) |
| 336 | continue |
| 337 | } |
| 338 | |
| 339 | console.log(` 📡 Fetching token txs from ${provider.name}...`) |
| 340 | |
| 341 | // Get token transaction list URL |
| 342 | const url = provider.getTokenTxListUrl(address, network, contractAddress) |
| 343 | const response = await fetch(url) |
| 344 | |
| 345 | if (!response.ok) { |
| 346 | throw new Error(`HTTP ${response.status}: ${response.statusText}`) |
| 347 | } |
| 348 | |
| 349 | const data = await response.json() |
| 350 | const transactions = provider.parseTokenResponse(data) |
| 351 | |
| 352 | console.log(` ✅ ${provider.name}: ${transactions.length} token transactions`) |
| 353 | return transactions |
| 354 | } catch (err) { |
| 355 | console.log(` ⚠️ ${provider.name} failed: ${err.message}`) |
| 356 | lastError = err |
| 357 | continue |
| 358 | } |
| 359 | } |
| 360 | |
| 361 | // All providers failed - return empty array instead of throwing |
| 362 | console.log(` ⚠️ All token tx providers failed: ${lastError?.message}`) |
| 363 | return [] |
| 364 | } |
| 365 | |
| 271 | 366 | /** |
| 272 | 367 | * Get list of available providers for a chain |
| 273 | 368 | * @param {string} chain - 'bitcoin' or 'ethereum' |
| … | … | @@ -293,6 +388,7 @@ module.exports = { |
| 293 | 388 | ETHEREUM_PROVIDERS, |
| 294 | 389 | BITCOIN_PROVIDERS, |
| 295 | 390 | fetchTransactions, |
| 391 | fetchTokenTransactions, |
| 296 | 392 | getAvailableProviders, |
| 297 | 393 | getProviderInfo |
| 298 | 394 | } |