// lib/blockchain-providers.cjs - Blockchain API providers
require('bare-node-runtime/global')
/**
* Ethereum providers configuration
* Supports multiple providers with automatic fallback
*/
const ETHEREUM_PROVIDERS = {
blockscout: {
name: 'Blockscout',
needsApiKey: false,
testnetUrl: 'https://eth-sepolia.blockscout.com/api',
mainnetUrl: 'https://eth.blockscout.com/api',
testnet4Url: null, // Blockscout doesn't have testnet4
getTxListUrl (address, network, apiKey) {
const baseUrl = network === 'testnet' ? this.testnetUrl : this.mainnetUrl
return `${baseUrl}?module=account&action=txlist&address=${address}&startblock=0&endblock=latest&page=1&offset=100&sort=desc`
},
// NEW: Get ERC-20 token transaction list
getTokenTxListUrl (address, network, contractAddress = null) {
const baseUrl = network === 'testnet' ? this.testnetUrl : this.mainnetUrl
let url = `${baseUrl}?module=account&action=tokentx&address=${address}&startblock=0&endblock=latest&page=1&offset=100&sort=desc`
if (contractAddress) {
url += `&contractaddress=${contractAddress}`
}
return url
},
parseResponse (data) {
if (data.status === '1' && Array.isArray(data.result)) {
return data.result.map(tx => ({
hash: tx.hash,
from: tx.from,
to: tx.to,
value: tx.value,
fee: tx.gasUsed && tx.gasPrice ? (BigInt(tx.gasUsed) * BigInt(tx.gasPrice)).toString() : '0',
blockNumber: parseInt(tx.blockNumber) || 0,
timestamp: parseInt(tx.timeStamp) || 0,
status: tx.txreceipt_status === '1' ? 'confirmed' : 'failed'
}))
}
throw new Error('Invalid response format')
},
// NEW: Parse token transaction response
parseTokenResponse (data) {
if (data.status === '1' && Array.isArray(data.result)) {
return data.result.map(tx => ({
hash: tx.hash,
from: tx.from,
to: tx.to,
value: tx.value, // Token amount in smallest unit
tokenSymbol: tx.tokenSymbol,
tokenName: tx.tokenName,
tokenDecimal: parseInt(tx.tokenDecimal) || 0,
contractAddress: tx.contractAddress,
fee: tx.gasUsed && tx.gasPrice ? (BigInt(tx.gasUsed) * BigInt(tx.gasPrice)).toString() : '0',
blockNumber: parseInt(tx.blockNumber) || 0,
timestamp: parseInt(tx.timeStamp) || 0,
// Token transactions: Blockscout doesn't return txreceipt_status for tokentx API
// If transaction is in the response, it was mined. Check for explicit failure indicators.
status: tx.txreceipt_status === '0' || tx.isError === '1' ? 'failed' : 'confirmed'
}))
}
throw new Error('Invalid response format')
}
},
etherscan: {
name: 'Etherscan',
needsApiKey: true,
testnetUrl: 'https://api.etherscan.io/v2/api',
mainnetUrl: 'https://api.etherscan.io/api',
testnet4Url: null,
getTxListUrl (address, network, apiKey) {
if (!apiKey) throw new Error('Etherscan requires API key')
const baseUrl = network === 'testnet' ? this.testnetUrl : this.mainnetUrl
const chainId = network === 'testnet' ? '11155111' : '1' // Sepolia : Mainnet
if (network === 'testnet') {
// Etherscan v2 API for testnet
return `${baseUrl}?chainid=${chainId}&module=account&action=txlist&address=${address}&startblock=0&endblock=latest&page=1&offset=100&sort=desc&apikey=${apiKey}`
} else {
// Regular API for mainnet
return `${baseUrl}?module=account&action=txlist&address=${address}&startblock=0&endblock=latest&page=1&offset=100&sort=desc&apikey=${apiKey}`
}
},
getTokenTxListUrl (address, network, contractAddress = null) {
// Note: Etherscan token tx endpoint requires API key (checked by caller)
const baseUrl = network === 'testnet' ? this.testnetUrl : this.mainnetUrl
const chainId = network === 'testnet' ? '11155111' : '1' // Sepolia : Mainnet
let url
if (network === 'testnet') {
// Etherscan v2 API for testnet
url = `${baseUrl}?chainid=${chainId}&module=account&action=tokentx&address=${address}&startblock=0&endblock=latest&page=1&offset=100&sort=desc`
} else {
// Regular API for mainnet
url = `${baseUrl}?module=account&action=tokentx&address=${address}&startblock=0&endblock=latest&page=1&offset=100&sort=desc`
}
if (contractAddress) {
url += `&contractaddress=${contractAddress}`
}
// Note: API key must be added by the caller since it's passed separately
return url
},
parseResponse (data) {
// Same format as Blockscout
return ETHEREUM_PROVIDERS.blockscout.parseResponse(data)
},
parseTokenResponse (data) {
// Same format as Blockscout for token transactions
return ETHEREUM_PROVIDERS.blockscout.parseTokenResponse(data)
}
},
alchemy: {
name: 'Alchemy',
needsApiKey: true,
testnetUrl: 'https://eth-sepolia.g.alchemy.com/v2',
mainnetUrl: 'https://eth-mainnet.g.alchemy.com/v2',
testnet4Url: null,
getTxListUrl (address, network, apiKey) {
if (!apiKey) throw new Error('Alchemy requires API key')
const baseUrl = network === 'testnet' ? this.testnetUrl : this.mainnetUrl
// Alchemy uses a different API format
return `${baseUrl}/${apiKey}`
},
// Signal to use fetchTokenTransactions instead of URL-based fetch
getTokenTxListUrl (address, network, contractAddress = null) {
return null
},
async fetchTransactions (address, network, apiKey) {
// Alchemy uses JSON-RPC, not REST
const baseUrl = network === 'testnet' ? this.testnetUrl : this.mainnetUrl
const url = `${baseUrl}/${apiKey}`
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
jsonrpc: '2.0',
method: 'alchemy_getAssetTransfers',
params: [{
fromBlock: '0x0',
toBlock: 'latest',
fromAddress: address,
category: ['external', 'internal', 'erc20'],
maxCount: '0x64' // 100 in hex
}],
id: 1
})
})
return response.json()
},
async fetchTokenTransactions (address, network, apiKey, contractAddress = null) {
const baseUrl = network === 'testnet' ? this.testnetUrl : this.mainnetUrl
const url = `${baseUrl}/${apiKey}`
const params = {
fromBlock: '0x0',
toBlock: 'latest',
toAddress: address,
category: ['erc20'],
maxCount: '0x64'
}
if (contractAddress) {
params.contractAddresses = [contractAddress]
}
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
jsonrpc: '2.0',
method: 'alchemy_getAssetTransfers',
params: [params],
id: 1
})
})
return response.json()
},
parseResponse (data) {
// Alchemy has different format
if (data.result && Array.isArray(data.result.transfers)) {
return data.result.transfers.map(tx => ({
hash: tx.hash,
from: tx.from,
to: tx.to,
value: tx.value ? (parseFloat(tx.value) * 1e18).toString() : '0',
fee: '0', // Not provided by Alchemy
blockNumber: tx.blockNum ? parseInt(tx.blockNum, 16) : 0,
timestamp: 0, // Not provided
status: 'confirmed'
}))
}
throw new Error('Invalid Alchemy response format')
},
parseTokenResponse (data) {
if (data.result && Array.isArray(data.result.transfers)) {
return data.result.transfers.map(tx => ({
hash: tx.hash,
from: tx.from,
to: tx.to,
value: tx.rawContract?.value || '0',
tokenSymbol: tx.asset || 'TOKEN',
contractAddress: tx.rawContract?.address,
fee: '0',
blockNumber: tx.blockNum ? parseInt(tx.blockNum, 16) : 0,
timestamp: 0,
status: 'confirmed'
}))
}
throw new Error('Invalid Alchemy response format')
}
}
}
/**
* Bitcoin providers configuration
*/
const BITCOIN_PROVIDERS = {
electrum: {
name: 'Electrum',
needsApiKey: false,
usesNativeWDK: true // Uses WDK's built-in getTransfers() method
},
blockstream: {
name: 'Blockstream',
needsApiKey: false,
testnetUrl: 'https://blockstream.info/testnet/api',
mainnetUrl: 'https://blockstream.info/api',
testnet4Url: 'https://blockstream.info/testnet4/api',
getTxListUrl (address, network) {
let baseUrl
if (network === 'testnet4') {
baseUrl = this.testnet4Url
} else if (network === 'testnet') {
baseUrl = this.testnetUrl
} else {
baseUrl = this.mainnetUrl
}
return `${baseUrl}/address/${address}/txs`
},
parseResponse (data) {
if (!Array.isArray(data)) throw new Error('Invalid response')
return data.map(tx => ({
txid: tx.txid,
hash: tx.txid,
value: tx.vout.reduce((sum, out) => sum + out.value, 0),
fee: tx.fee || 0,
blockHeight: tx.status?.block_height || 0,
timestamp: tx.status?.block_time || 0,
status: tx.status?.confirmed ? 'confirmed' : 'pending'
}))
}
},
mempool: {
name: 'Mempool.space',
needsApiKey: false,
testnetUrl: 'https://mempool.space/testnet/api',
mainnetUrl: 'https://mempool.space/api',
testnet4Url: 'https://mempool.space/testnet4/api',
getTxListUrl (address, network) {
let baseUrl
if (network === 'testnet4') {
baseUrl = this.testnet4Url
} else if (network === 'testnet') {
baseUrl = this.testnetUrl
} else {
baseUrl = this.mainnetUrl
}
return `${baseUrl}/address/${address}/txs`
},
parseResponse (data) {
// Same format as Blockstream
return BITCOIN_PROVIDERS.blockstream.parseResponse(data)
}
}
}
/**
* Fetch transactions using configured provider with automatic fallback
* @param {string} chain - 'bitcoin' or 'ethereum'
* @param {string} address - Blockchain address
* @param {string} network - 'testnet', 'testnet4', or 'mainnet'
* @param {Object} config - Configuration object from getConfig()
* @returns {Promise<Array|Object>} - Array of transactions or { useNativeWDK: true }
*/
async function fetchTransactions (chain, address, network, config) {
const providers = chain === 'ethereum' ? ETHEREUM_PROVIDERS : BITCOIN_PROVIDERS
// Get primary provider from config
const primaryProviderName = config.blockchain[chain].provider
const apiKey = config.blockchain[chain].apiKey
// Build provider list (primary first, then fallbacks)
const providerList = [
primaryProviderName,
...Object.keys(providers).filter(p => p !== primaryProviderName)
]
let lastError = null
for (const providerName of providerList) {
const provider = providers[providerName]
if (!provider) {
console.log(` ⚠️ Unknown provider: ${providerName}`)
continue
}
try {
// Skip if provider needs API key but we don't have one
if (provider.needsApiKey && !apiKey) {
console.log(` ⚠️ Skipping ${provider.name} (API key required)`)
continue
}
// If provider uses native WDK method, return signal
if (provider.usesNativeWDK) {
return { useNativeWDK: true, provider: provider.name }
}
console.log(` 📡 Fetching from ${provider.name}...`)
// Handle Alchemy's special API
if (providerName === 'alchemy' && provider.fetchTransactions) {
const data = await provider.fetchTransactions(address, network, apiKey)
const transactions = provider.parseResponse(data)
console.log(` ✅ ${provider.name}: ${transactions.length} transactions`)
return transactions
}
// Standard HTTP API
const url = provider.getTxListUrl(address, network, apiKey)
const response = await fetch(url)
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
const data = await response.json()
const transactions = provider.parseResponse(data)
console.log(` ✅ ${provider.name}: ${transactions.length} transactions`)
return transactions
} catch (err) {
console.log(` ⚠️ ${provider.name} failed: ${err.message}`)
lastError = err
continue
}
}
// All providers failed
throw new Error(`All providers failed. Last error: ${lastError?.message}`)
}
/**
* Fetch ERC-20 token transactions for an Ethereum address
* @param {string} address - Ethereum address
* @param {string} network - 'testnet' or 'mainnet'
* @param {Object} config - Configuration object from getConfig()
* @param {string} contractAddress - Optional: filter by token contract address
* @returns {Promise<Array>} - Array of token transactions
*/
async function fetchTokenTransactions (address, network, config, contractAddress = null) {
const providers = ETHEREUM_PROVIDERS
// Get primary provider from config
const primaryProviderName = config.blockchain.ethereum.provider
const apiKey = config.blockchain.ethereum.apiKey
// Build provider list (primary first, then fallbacks)
const providerList = [
primaryProviderName,
...Object.keys(providers).filter(p => p !== primaryProviderName)
]
let lastError = null
for (const providerName of providerList) {
const provider = providers[providerName]
if (!provider) {
console.log(` ⚠️ Unknown provider: ${providerName}`)
continue
}
// Check if provider supports token transactions (via URL or custom method)
if (!provider.getTokenTxListUrl && !provider.fetchTokenTransactions) {
console.log(` ⚠️ ${providerName} doesn't support token transactions`)
continue
}
try {
// Skip if provider needs API key but we don't have one
if (provider.needsApiKey && !apiKey) {
console.log(` ⚠️ Skipping ${provider.name} (API key required)`)
continue
}
console.log(` 📡 Fetching token txs from ${provider.name}...`)
// Get token transaction list URL (null = use custom fetchTokenTransactions)
let url = provider.getTokenTxListUrl ? provider.getTokenTxListUrl(address, network, contractAddress) : null
// Handle providers with custom fetchTokenTransactions method (e.g., Alchemy)
if (url === null && provider.fetchTokenTransactions) {
const data = await provider.fetchTokenTransactions(address, network, apiKey, contractAddress)
const transactions = provider.parseTokenResponse(data)
console.log(` ✅ ${provider.name}: ${transactions.length} token transactions`)
return transactions
}
// Standard URL-based fetch
if (!url) {
console.log(` ⚠️ ${provider.name} returned no URL for token transactions`)
continue
}
// Add API key to URL if needed
if (provider.needsApiKey && apiKey) {
url += `&apikey=${apiKey}`
}
const response = await fetch(url)
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
const data = await response.json()
const transactions = provider.parseTokenResponse(data)
console.log(` ✅ ${provider.name}: ${transactions.length} token transactions`)
return transactions
} catch (err) {
console.log(` ⚠️ ${provider.name} failed: ${err.message}`)
lastError = err
continue
}
}
// All providers failed - throw error instead of silently returning empty array
console.error(` ❌ All token tx providers failed: ${lastError?.message}`)
throw new Error(`Failed to fetch token transactions: ${lastError?.message || 'All providers failed'}`)
}
/**
* Get list of available providers for a chain
* @param {string} chain - 'bitcoin' or 'ethereum'
* @returns {Array} - Array of provider names
*/
function getAvailableProviders (chain) {
const providers = chain === 'ethereum' ? ETHEREUM_PROVIDERS : BITCOIN_PROVIDERS
return Object.keys(providers)
}
/**
* Get provider info
* @param {string} chain - 'bitcoin' or 'ethereum'
* @param {string} providerName - Provider name
* @returns {Object} - Provider configuration
*/
function getProviderInfo (chain, providerName) {
const providers = chain === 'ethereum' ? ETHEREUM_PROVIDERS : BITCOIN_PROVIDERS
return providers[providerName] || null
}
module.exports = {
ETHEREUM_PROVIDERS,
BITCOIN_PROVIDERS,
fetchTransactions,
fetchTokenTransactions,
getAvailableProviders,
getProviderInfo
}