OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-wallet-lib / blockchain-providers.cjs

503 lines · javascript ref:
// 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
}