From 2b48b897ec15bb7b53bd86be950cf2808550f4c7 Mon Sep 17 00:00:00 2001 From: soumyaRouterP Date: Fri, 8 Nov 2024 23:26:36 +0530 Subject: [PATCH 01/13] add: bitcoin balance fetch --- packages/react/src/actions/bitcoin/balance.ts | 138 ++++++++++++++++++ .../src/actions/bitcoin/bitcoinApiConfig.ts | 17 +++ packages/react/src/actions/getToken.ts | 11 +- packages/react/src/types/bitcoin.ts | 63 ++++++++ packages/react/src/utils/index.ts | 10 ++ 5 files changed, 230 insertions(+), 9 deletions(-) create mode 100644 packages/react/src/actions/bitcoin/balance.ts diff --git a/packages/react/src/actions/bitcoin/balance.ts b/packages/react/src/actions/bitcoin/balance.ts new file mode 100644 index 00000000..7a9aa855 --- /dev/null +++ b/packages/react/src/actions/bitcoin/balance.ts @@ -0,0 +1,138 @@ +import { + ApiConfig, + ApiResponse, + BlockchainInfoResponse, + BlockcypherResponse, + BtcScanResponse, +} from '../../types/bitcoin.js'; +import { getFromLocalStorage, setInLocalStorage } from '../../utils/index.js'; +import { APIs } from './bitcoinApiConfig.js'; + +const CACHE_EXPIRATION_TIME = 30 * 60 * 1000; // 30 minutes +const REQUEST_TIMEOUT = 5000; // 5 seconds +const MAX_RETRIES = 3; +const RETRY_DELAY = 1000; + +class FetchError extends Error { + constructor( + message: string, + public readonly status?: number, + public readonly statusText?: string, + ) { + super(message); + this.name = 'FetchError'; + } +} + +const fetchWithTimeout = async (url: string, timeout: number): Promise => { + const controller = new AbortController(); + const id = setTimeout(() => controller.abort(), timeout); + + try { + const response = await fetch(url, { + signal: controller.signal, + }); + clearTimeout(id); + return response; + } catch (error) { + clearTimeout(id); + throw error; + } +}; + +const fetchWithRetry = async ( + url: string, + attempt: number = 0, + retryDelay: number = RETRY_DELAY, + maxRetries: number = MAX_RETRIES, + requestTimeout: number = REQUEST_TIMEOUT, +): Promise => { + try { + const response = await fetchWithTimeout(url, requestTimeout); + + if (!response.ok) { + // Avoiding retries on client errors except rate limits + if (response.status !== 429 && response.status < 500) { + throw new FetchError(`HTTP error! status: ${response.status}`, response.status, response.statusText); + } + throw new FetchError(`HTTP error! status: ${response.status}`); + } + + return await response.json(); + } catch (error) { + if (attempt >= maxRetries) throw error; + + // If server error, retry + if (error instanceof FetchError && error.status && error.status < 500 && error.status !== 429) { + throw error; + } + + await new Promise((resolve) => setTimeout(resolve, retryDelay * Math.pow(2, attempt))); + return fetchWithRetry(url, attempt + 1, retryDelay, maxRetries, requestTimeout); + } +}; + +const tryAPI = async (name: ApiConfig['name'], url: string): Promise => { + try { + const data = await fetchWithRetry(url); + return { source: name, data } as ApiResponse; + } catch (error) { + console.warn(`Failed to fetch from ${name}: ${error instanceof Error ? error.message : 'Unknown error'}`); + throw error; + } +}; + +export const getBalance = async (address: string): Promise => { + const cachedBalance = getFromLocalStorage(`balance-${address}`); + if (cachedBalance && cachedBalance.timestamp + CACHE_EXPIRATION_TIME > Date.now()) { + return cachedBalance.data; + } + + let lastError: Error | null = null; + for (const api of APIs) { + try { + let response: ApiResponse; + switch (api.name) { + case 'btcscan': + response = await tryAPI(api.name, api.url(address)); + break; + case 'blockchain.info': + response = await tryAPI(api.name, api.url(address)); + break; + case 'blockcypher': + response = await tryAPI(api.name, api.url(address)); + break; + } + setInLocalStorage(`balance-${address}`, { + data: response, + timestamp: Date.now(), + }); + return response; + } catch (error) { + lastError = error as Error; + continue; + } + } + + throw new Error(`All APIs failed to fetch balance. Last error: ${lastError?.message}`); +}; + +export const getFormattedBalance = async (address: string): Promise => { + const response = await getBalance(address); + + switch (response.source) { + case 'btcscan': { + const confirmedBalance = response.data.chain_stats.funded_txo_sum - response.data.chain_stats.spent_txo_sum; + const unconfirmedBalance = response.data.mempool_stats.funded_txo_sum - response.data.mempool_stats.spent_txo_sum; + return confirmedBalance + unconfirmedBalance; + } + + case 'blockchain.info': { + return response.data.confirmed + response.data.unconfirmed; + } + + case 'blockcypher': { + return response.data.balance + response.data.unconfirmed_balance; + } + } +}; diff --git a/packages/react/src/actions/bitcoin/bitcoinApiConfig.ts b/packages/react/src/actions/bitcoin/bitcoinApiConfig.ts index 9506760e..d3cf8f58 100644 --- a/packages/react/src/actions/bitcoin/bitcoinApiConfig.ts +++ b/packages/react/src/actions/bitcoin/bitcoinApiConfig.ts @@ -1,3 +1,5 @@ +import { ApiConfig } from '../../types/bitcoin.js'; + export interface BitcoinApiConfig { mainnet: string; testnet: string; @@ -29,3 +31,18 @@ export function getBitcoinApiConfig(isTestnet: boolean, apiName: string = 'block name: config.name, }; } + +export const APIs: ApiConfig[] = [ + { + name: 'btcscan', + url: (address: string) => `https://btcscan.org/api/address/${address}`, + }, + { + name: 'blockchain.info', + url: (address: string) => `https://api.blockchain.info/haskoin-store/btc/address/${address}/balance`, + }, + { + name: 'blockcypher', + url: (address: string) => `https://api.blockcypher.com/v1/btc/main/addrs/${address}/balance`, + }, +]; diff --git a/packages/react/src/actions/getToken.ts b/packages/react/src/actions/getToken.ts index 9ee45850..d609a8bc 100644 --- a/packages/react/src/actions/getToken.ts +++ b/packages/react/src/actions/getToken.ts @@ -13,8 +13,7 @@ import { OtherChainData, } from '../types/index.js'; import { areTokensEqual } from '../utils/index.js'; -import { getBitcoinApiConfig } from './bitcoin/bitcoinApiConfig.js'; -import { fetchBalance as fetchBitcoinBalance } from './bitcoin/transaction.js'; +import { getFormattedBalance as getBitcoinBalance } from './bitcoin/balance.js'; import { getCosmosTokenBalanceAndAllowance, getCosmosTokenMetadata } from './cosmos/getCosmosToken.js'; import { getEVMTokenBalanceAndAllowance, getEVMTokenMetadata } from './evm/getEVMToken.js'; import { viewMethodOnNear } from './near/readCalls.js'; @@ -238,13 +237,7 @@ export const getTokenBalanceAndAllowance = (async (params) => { } if (chain.type === 'bitcoin') { - const balance = - (await fetchBitcoinBalance(getBitcoinApiConfig(chain.id !== 'bitcoin', 'blockstream'), account)) || - (await fetchBitcoinBalance(getBitcoinApiConfig(chain.id !== 'bitcoin', 'mempool'), account)); - - if (balance === null) { - throw new Error('Failed to fetch bitcoin balance'); - } + const balance = await getBitcoinBalance(account); return { balance, allowance: 0n }; } diff --git a/packages/react/src/types/bitcoin.ts b/packages/react/src/types/bitcoin.ts index a47df776..1a7c4875 100644 --- a/packages/react/src/types/bitcoin.ts +++ b/packages/react/src/types/bitcoin.ts @@ -94,3 +94,66 @@ export type BitcoinTransferRequest = { }; memo: string; }; + +export interface BtcScanResponse { + address: string; + chain_stats: { + funded_txo_count: number; + funded_txo_sum: number; + spent_txo_count: number; + spent_txo_sum: number; + tx_count: number; + }; + mempool_stats: { + funded_txo_count: number; + funded_txo_sum: number; + spent_txo_count: number; + spent_txo_sum: number; + tx_count: number; + }; +} + +export interface BlockchainInfoResponse { + address: string; + confirmed: number; + unconfirmed: number; + utxo: number; + txs: number; + received: number; +} + +export interface BlockcypherResponse { + address: string; + total_received: number; + total_sent: number; + balance: number; + unconfirmed_balance: number; + final_balance: number; + n_tx: number; + unconfirmed_n_tx: number; + final_n_tx: number; +} + +export type ApiResponse = + | { + source: 'btcscan'; + data: BtcScanResponse; + } + | { + source: 'blockchain.info'; + data: BlockchainInfoResponse; + } + | { + source: 'blockcypher'; + data: BlockcypherResponse; + }; + +export type CachedData = { + data: T; + timestamp: number; +}; + +export interface ApiConfig { + name: 'btcscan' | 'blockchain.info' | 'blockcypher'; + url: (address: string) => string; +} diff --git a/packages/react/src/utils/index.ts b/packages/react/src/utils/index.ts index 338cc0f9..cd813a0a 100644 --- a/packages/react/src/utils/index.ts +++ b/packages/react/src/utils/index.ts @@ -1,5 +1,6 @@ import { detect } from 'detect-browser'; import { SLF_TOKEN } from '../constants/index.js'; +import { CachedData } from '../types/bitcoin.js'; /** * This is a workaround for the issue with BigInt serialization in JSON.stringify @@ -126,3 +127,12 @@ export function isNativeOrFactoryToken(token: string): boolean { const lowerToken = token.toLowerCase(); return lowerToken.startsWith('ibc') || lowerToken.startsWith('factory') || lowerToken === SLF_TOKEN; } + +export const getFromLocalStorage = (key: string): CachedData | null => { + const value = localStorage.getItem(key); + return value ? JSON.parse(value) : null; +}; + +export const setInLocalStorage = (key: string, value: CachedData): void => { + localStorage.setItem(key, JSON.stringify(value)); +}; From 724e2ef37b7f437c4cd0e10b0b4135b236d2966b Mon Sep 17 00:00:00 2001 From: soumyaRouterP Date: Sat, 9 Nov 2024 20:53:55 +0530 Subject: [PATCH 02/13] add: bitcoin transaction status --- packages/react/src/actions/bitcoin/balance.ts | 54 +++++--- .../src/actions/bitcoin/bitcoinApiConfig.ts | 15 ++- .../react/src/actions/bitcoin/transaction.ts | 98 +++++++------- .../src/actions/getTransactionReceipt.ts | 8 +- .../react/src/actions/waitForTransaction.ts | 7 +- packages/react/src/types/bitcoin.ts | 121 ++++++++++++++++-- packages/react/src/utils/index.ts | 5 +- 7 files changed, 212 insertions(+), 96 deletions(-) diff --git a/packages/react/src/actions/bitcoin/balance.ts b/packages/react/src/actions/bitcoin/balance.ts index 7a9aa855..c568a924 100644 --- a/packages/react/src/actions/bitcoin/balance.ts +++ b/packages/react/src/actions/bitcoin/balance.ts @@ -1,9 +1,11 @@ import { ApiConfig, ApiResponse, - BlockchainInfoResponse, - BlockcypherResponse, - BtcScanResponse, + BalanceApiResponse, + BlockchainInfoBalanceResponse, + BlockcypherBalanceResponse, + BtcScanBalanceResponse, + CachedBalanceData, } from '../../types/bitcoin.js'; import { getFromLocalStorage, setInLocalStorage } from '../../utils/index.js'; import { APIs } from './bitcoinApiConfig.js'; @@ -72,18 +74,18 @@ const fetchWithRetry = async ( } }; -const tryAPI = async (name: ApiConfig['name'], url: string): Promise => { +export const tryAPI = async (name: ApiConfig['name'], url: string): Promise> => { try { const data = await fetchWithRetry(url); - return { source: name, data } as ApiResponse; + return { source: name, data }; } catch (error) { console.warn(`Failed to fetch from ${name}: ${error instanceof Error ? error.message : 'Unknown error'}`); throw error; } }; -export const getBalance = async (address: string): Promise => { - const cachedBalance = getFromLocalStorage(`balance-${address}`); +export const getBalance = async (address: string): Promise => { + const cachedBalance = getFromLocalStorage(`balance-${address}`); if (cachedBalance && cachedBalance.timestamp + CACHE_EXPIRATION_TIME > Date.now()) { return cachedBalance.data; } @@ -91,22 +93,31 @@ export const getBalance = async (address: string): Promise => { let lastError: Error | null = null; for (const api of APIs) { try { - let response: ApiResponse; + let response: BalanceApiResponse; switch (api.name) { - case 'btcscan': - response = await tryAPI(api.name, api.url(address)); + case 'btcscan': { + const apiResponse = await tryAPI(api.name, api.url.balance(address)); + response = { source: 'btcscan', data: apiResponse.data }; break; - case 'blockchain.info': - response = await tryAPI(api.name, api.url(address)); + } + case 'blockchain.info': { + const apiResponse = await tryAPI(api.name, api.url.balance(address)); + response = { source: 'blockchain.info', data: apiResponse.data }; break; - case 'blockcypher': - response = await tryAPI(api.name, api.url(address)); + } + case 'blockcypher': { + const apiResponse = await tryAPI(api.name, api.url.balance(address)); + response = { source: 'blockcypher', data: apiResponse.data }; break; + } } - setInLocalStorage(`balance-${address}`, { + + const cacheData: CachedBalanceData = { data: response, timestamp: Date.now(), - }); + }; + + setInLocalStorage(`balance-${address}`, cacheData); return response; } catch (error) { lastError = error as Error; @@ -122,17 +133,20 @@ export const getFormattedBalance = async (address: string): Promise => { switch (response.source) { case 'btcscan': { - const confirmedBalance = response.data.chain_stats.funded_txo_sum - response.data.chain_stats.spent_txo_sum; - const unconfirmedBalance = response.data.mempool_stats.funded_txo_sum - response.data.mempool_stats.spent_txo_sum; + const data = response.data as BtcScanBalanceResponse; + const confirmedBalance = data.chain_stats.funded_txo_sum - data.chain_stats.spent_txo_sum; + const unconfirmedBalance = data.mempool_stats.funded_txo_sum - data.mempool_stats.spent_txo_sum; return confirmedBalance + unconfirmedBalance; } case 'blockchain.info': { - return response.data.confirmed + response.data.unconfirmed; + const data = response.data as BlockchainInfoBalanceResponse; + return data.confirmed + data.unconfirmed; } case 'blockcypher': { - return response.data.balance + response.data.unconfirmed_balance; + const data = response.data as BlockcypherBalanceResponse; + return data.balance + data.unconfirmed_balance; } } }; diff --git a/packages/react/src/actions/bitcoin/bitcoinApiConfig.ts b/packages/react/src/actions/bitcoin/bitcoinApiConfig.ts index d3cf8f58..52c90128 100644 --- a/packages/react/src/actions/bitcoin/bitcoinApiConfig.ts +++ b/packages/react/src/actions/bitcoin/bitcoinApiConfig.ts @@ -35,14 +35,23 @@ export function getBitcoinApiConfig(isTestnet: boolean, apiName: string = 'block export const APIs: ApiConfig[] = [ { name: 'btcscan', - url: (address: string) => `https://btcscan.org/api/address/${address}`, + url: { + balance: (address: string) => `https://btcscan.org/api/address/${address}`, + transaction: (txHash: string) => `https://btcscan.org/api//tx/${txHash}/status`, + }, }, { name: 'blockchain.info', - url: (address: string) => `https://api.blockchain.info/haskoin-store/btc/address/${address}/balance`, + url: { + balance: (address: string) => `https://api.blockchain.info/haskoin-store/btc/address/${address}/balance`, + transaction: (txHash: string) => `https://api.blockchain.info/haskoin-store/btc/transaction/${txHash}`, + }, }, { name: 'blockcypher', - url: (address: string) => `https://api.blockcypher.com/v1/btc/main/addrs/${address}/balance`, + url: { + balance: (address: string) => `https://api.blockcypher.com/v1/btc/main/addrs/${address}/balance`, + transaction: (txHash: string) => `https://api.blockcypher.com/v1/btc/main/txs/${txHash}`, + }, }, ]; diff --git a/packages/react/src/actions/bitcoin/transaction.ts b/packages/react/src/actions/bitcoin/transaction.ts index 6a46009b..b071dc8f 100644 --- a/packages/react/src/actions/bitcoin/transaction.ts +++ b/packages/react/src/actions/bitcoin/transaction.ts @@ -1,14 +1,17 @@ import { - BitcoinBalanceResponse, BitcoinTransactionStatus, BitcoinTransferRequest, + BlockchainInfoTransactionResponse, + BlockcypherTransactionResponse, BlockstreamGasFeeResponse, + BtcScanTransactionResponse, MempoolSpaceBitcoinGasFeeResponse, XfiBitcoinConnector, } from '../../types/bitcoin.js'; import { ConnectionOrConfig, OtherChainData, OtherChainTypes } from '../../types/index.js'; import { removeHexPrefix } from '../../utils/index.js'; -import { BitcoinApiConfigResult, getBitcoinApiConfig } from './bitcoinApiConfig.js'; +import { tryAPI } from './balance.js'; +import { APIs, BitcoinApiConfigResult, getBitcoinApiConfig } from './bitcoinApiConfig.js'; export async function getBitcoinGasFee(apiConfig: BitcoinApiConfigResult): Promise { try { @@ -170,56 +173,49 @@ export async function signBitcoinTransaction({ } } -export const fetchBalance = async (apiConfig: BitcoinApiConfigResult, account: string): Promise => { - try { - const apiUrl = `${apiConfig.baseUrl}/api/address/${account}`; - - const response = await fetch(apiUrl); - if (!response.ok) { - throw new Error(`Failed to fetch balance from ${apiConfig.name}: ${response.status}`); +/** + * Fetches the transaction status for a given transaction hash. + * + * @param {string} txHash - The transaction hash to fetch the status for. + * @returns {Promise} A promise that resolves to the transaction status. + * @throws {Error} If all APIs fail to fetch the transaction status. + */ +export const getTransactionStatus = async (txHash: string): Promise => { + for (const api of APIs) { + try { + switch (api.name) { + case 'btcscan': { + const response = await tryAPI(api.name, api.url.transaction(txHash)); + return { + confirmed: response.data.confirmed, + block_height: response.data.block_height, + block_hash: response.data.block_hash, + block_time: response.data.block_time, + }; + } + case 'blockchain.info': { + const response = await tryAPI(api.name, api.url.transaction(txHash)); + return { + confirmed: response.data.block?.height !== undefined, + block_height: response.data.block?.height ?? 0, + block_hash: 'unknown', + block_time: response.data.time * 1000, + }; + } + case 'blockcypher': { + const response = await tryAPI(api.name, api.url.transaction(txHash)); + return { + confirmed: response.data.confirmations > 0, + block_height: response.data.block_height, + block_hash: response.data.block_hash, + block_time: new Date(response.data.received).getTime(), + }; + } + } + } catch (error) { + continue; } - - const rawData = await response.json(); - const totalBalanceSatoshis = calculateTotalBalance(rawData); - - return BigInt(totalBalanceSatoshis); - } catch (error) { - console.error(`Failed to fetch bitcoin balance from ${apiConfig.name} - `, error); - throw error; } -}; - -const calculateTotalBalance = (rawData: BitcoinBalanceResponse): number => { - const confirmedBalance = rawData.chain_stats.funded_txo_sum - rawData.chain_stats.spent_txo_sum; - const mempoolBalance = rawData.mempool_stats.funded_txo_sum - rawData.mempool_stats.spent_txo_sum; - - return confirmedBalance + mempoolBalance; -}; - -export const fetchTransaction = async ( - txHash: string, - apiConfig: BitcoinApiConfigResult, -): Promise => { - const apiUrl = `${apiConfig.baseUrl}/api/tx/${txHash}/status`; - - try { - const response = await fetch(apiUrl); - if (!response.ok) { - console.error(`Failed to fetch transaction status from ${apiConfig.name}: ${response.status}`); - return undefined; - } - const rawData = await response.json(); - const transactionStatus: BitcoinTransactionStatus = { - confirmed: rawData.confirmed, - block_height: rawData.block_height, - block_hash: rawData.block_hash, - block_time: rawData.block_time, - }; - - return transactionStatus.confirmed ? transactionStatus : undefined; - } catch (error) { - console.error(`Error fetching Bitcoin transaction status from ${apiConfig.name}: ${error}`); - return undefined; - } + throw new Error(`All APIs failed to fetch transaction status for ${txHash}`); }; diff --git a/packages/react/src/actions/getTransactionReceipt.ts b/packages/react/src/actions/getTransactionReceipt.ts index 6b45377e..918900fa 100644 --- a/packages/react/src/actions/getTransactionReceipt.ts +++ b/packages/react/src/actions/getTransactionReceipt.ts @@ -1,7 +1,7 @@ import { getTransactionReceipt as getEVMTransactionReceipt } from '@wagmi/core'; import { ChainData, ChainType, ConnectionOrConfig, OtherChainData, TransactionReceipt } from '../types/index.js'; -import { getBitcoinApiConfig } from './bitcoin/bitcoinApiConfig.js'; -import { fetchTransaction } from './bitcoin/transaction.js'; + +import { getTransactionStatus as getBitcoinTransactionStatus } from './bitcoin/transaction.js'; import { getNearProvider } from './near/readCalls.js'; import { TransactionParams } from './waitForTransaction.js'; @@ -99,9 +99,7 @@ export const getTransactionReceipt = (async ({ if (chain.type === 'bitcoin') { const { txHash } = transactionParams as TransactionParams<'bitcoin'>; - const result = - (await fetchTransaction(txHash, getBitcoinApiConfig(chain.id !== 'bitcoin', 'blockstream'))) || - (await fetchTransaction(txHash, getBitcoinApiConfig(chain.id !== 'bitcoin', 'mempool'))); + const result = getBitcoinTransactionStatus(txHash); return result; } diff --git a/packages/react/src/actions/waitForTransaction.ts b/packages/react/src/actions/waitForTransaction.ts index 2c667ef3..e1c2122a 100644 --- a/packages/react/src/actions/waitForTransaction.ts +++ b/packages/react/src/actions/waitForTransaction.ts @@ -3,8 +3,7 @@ import { waitForTransactionReceipt } from '@wagmi/core'; import { ReplacementReturnType } from 'viem'; import { ChainData, ChainType, ConnectionOrConfig, OtherChainData, TransactionReceipt } from '../types/index.js'; import { pollCallback } from '../utils/index.js'; -import { getBitcoinApiConfig } from './bitcoin/bitcoinApiConfig.js'; -import { fetchTransaction as fetchBitcoinTransaction } from './bitcoin/transaction.js'; +import { getTransactionStatus as getBitcoinTransactionStatus } from './bitcoin/transaction.js'; import { getNearProvider } from './near/readCalls.js'; export type DefaultOverrides = { @@ -199,9 +198,7 @@ export const waitForTransaction = (async ({ chain, config, overrides, transactio const receipt = await pollCallback( async () => { - const result = - (await fetchBitcoinTransaction(txHash, getBitcoinApiConfig(chain.id !== 'bitcoin', 'blockstream'))) || - (await fetchBitcoinTransaction(txHash, getBitcoinApiConfig(chain.id !== 'bitcoin', 'mempool'))); + const result = getBitcoinTransactionStatus(txHash); return result; }, { diff --git a/packages/react/src/types/bitcoin.ts b/packages/react/src/types/bitcoin.ts index 1a7c4875..05dc7698 100644 --- a/packages/react/src/types/bitcoin.ts +++ b/packages/react/src/types/bitcoin.ts @@ -95,7 +95,7 @@ export type BitcoinTransferRequest = { memo: string; }; -export interface BtcScanResponse { +export interface BtcScanBalanceResponse { address: string; chain_stats: { funded_txo_count: number; @@ -113,7 +113,7 @@ export interface BtcScanResponse { }; } -export interface BlockchainInfoResponse { +export interface BlockchainInfoBalanceResponse { address: string; confirmed: number; unconfirmed: number; @@ -122,7 +122,7 @@ export interface BlockchainInfoResponse { received: number; } -export interface BlockcypherResponse { +export interface BlockcypherBalanceResponse { address: string; total_received: number; total_sent: number; @@ -134,26 +134,129 @@ export interface BlockcypherResponse { final_n_tx: number; } -export type ApiResponse = +export type BalanceApiResponse = | { source: 'btcscan'; - data: BtcScanResponse; + data: BtcScanBalanceResponse; } | { source: 'blockchain.info'; - data: BlockchainInfoResponse; + data: BlockchainInfoBalanceResponse; } | { source: 'blockcypher'; - data: BlockcypherResponse; + data: BlockcypherBalanceResponse; }; -export type CachedData = { +export type TransactionApiResponse = + | { + source: 'btcscan'; + data: BtcScanTransactionResponse; + } + | { + source: 'blockchain.info'; + data: BlockchainInfoTransactionResponse; + } + | { + source: 'blockcypher'; + data: BlockcypherTransactionResponse; + }; + +export type ApiResponse = { + source: ApiConfig['name']; data: T; +}; + +export type CachedBalanceData = { + data: BalanceApiResponse; timestamp: number; }; export interface ApiConfig { name: 'btcscan' | 'blockchain.info' | 'blockcypher'; - url: (address: string) => string; + url: { + balance: (address: string) => string; + transaction: (txHash: string) => string; + }; } + +export type BtcScanTransactionResponse = { + confirmed: boolean; + block_height: number; + block_hash: string; + block_time: number; +}; + +export type BlockcypherTransactionResponse = { + block_hash: string; + block_height: number; + block_index: number; + hash: string; + addresses: string[]; + total: number; + fees: number; + size: number; + vsize: number; + preference: string; + relayed_by: string; + confirmed: string; + received: string; + ver: number; + double_spend: boolean; + vin_sz: number; + vout_sz: number; + data_protocol: string; + confirmations: number; + confidence: number; + inputs: Array<{ + prev_hash: string; + output_index: number; + output_value: number; + sequence: number; + addresses: string[]; + script_type: string; + age: number; + witness: string[]; + }>; + outputs: Array<{ + value: number; + script: string; + addresses: string[] | null; + script_type: string; + data_hex?: string; + }>; +}; + +export type BlockchainInfoTransactionResponse = { + txid: string; + size: number; + version: number; + locktime: number; + fee: number; + inputs: Array<{ + coinbase: boolean; + txid: string; + output: number; + sigscript: string; + sequence: number; + pkscript: string; + value: number; + address: string; + witness: string[]; + }>; + outputs: Array<{ + address: string | null; + pkscript: string; + value: number; + spent: boolean; + spender: string | null; + }>; + block: { + height: number; + position: number; + }; + deleted: boolean; + time: number; + rbf: boolean; + weight: number; +}; diff --git a/packages/react/src/utils/index.ts b/packages/react/src/utils/index.ts index cd813a0a..64a1bdf5 100644 --- a/packages/react/src/utils/index.ts +++ b/packages/react/src/utils/index.ts @@ -1,6 +1,5 @@ import { detect } from 'detect-browser'; import { SLF_TOKEN } from '../constants/index.js'; -import { CachedData } from '../types/bitcoin.js'; /** * This is a workaround for the issue with BigInt serialization in JSON.stringify @@ -128,11 +127,11 @@ export function isNativeOrFactoryToken(token: string): boolean { return lowerToken.startsWith('ibc') || lowerToken.startsWith('factory') || lowerToken === SLF_TOKEN; } -export const getFromLocalStorage = (key: string): CachedData | null => { +export const getFromLocalStorage = (key: string): T | null => { const value = localStorage.getItem(key); return value ? JSON.parse(value) : null; }; -export const setInLocalStorage = (key: string, value: CachedData): void => { +export const setInLocalStorage = (key: string, value: T): void => { localStorage.setItem(key, JSON.stringify(value)); }; From d5339e919187e127504937af2d4615b70646114d Mon Sep 17 00:00:00 2001 From: soumyaRouterP Date: Sat, 9 Nov 2024 21:28:48 +0530 Subject: [PATCH 03/13] update: bitcoin gas fees from mempool.space --- .../src/actions/bitcoin/bitcoinApiConfig.ts | 32 ------------ .../react/src/actions/bitcoin/transaction.ts | 50 ++++--------------- 2 files changed, 10 insertions(+), 72 deletions(-) diff --git a/packages/react/src/actions/bitcoin/bitcoinApiConfig.ts b/packages/react/src/actions/bitcoin/bitcoinApiConfig.ts index 52c90128..f26126af 100644 --- a/packages/react/src/actions/bitcoin/bitcoinApiConfig.ts +++ b/packages/react/src/actions/bitcoin/bitcoinApiConfig.ts @@ -1,37 +1,5 @@ import { ApiConfig } from '../../types/bitcoin.js'; -export interface BitcoinApiConfig { - mainnet: string; - testnet: string; - name: string; -} - -export const bitcoinApiConfig: Record = { - blockstream: { - mainnet: 'https://blockstream.info', - testnet: 'https://blockstream.info/testnet', - name: 'blockstream', - }, - mempool: { - mainnet: 'https://mempool.space', - testnet: 'https://mempool.space/testnet', - name: 'mempool', - }, -}; - -export interface BitcoinApiConfigResult { - baseUrl: string; - name: string; -} - -export function getBitcoinApiConfig(isTestnet: boolean, apiName: string = 'blockstream'): BitcoinApiConfigResult { - const config = bitcoinApiConfig[apiName] || bitcoinApiConfig.blockstream; - return { - baseUrl: isTestnet ? config.testnet : config.mainnet, - name: config.name, - }; -} - export const APIs: ApiConfig[] = [ { name: 'btcscan', diff --git a/packages/react/src/actions/bitcoin/transaction.ts b/packages/react/src/actions/bitcoin/transaction.ts index b071dc8f..2611c7e7 100644 --- a/packages/react/src/actions/bitcoin/transaction.ts +++ b/packages/react/src/actions/bitcoin/transaction.ts @@ -3,7 +3,6 @@ import { BitcoinTransferRequest, BlockchainInfoTransactionResponse, BlockcypherTransactionResponse, - BlockstreamGasFeeResponse, BtcScanTransactionResponse, MempoolSpaceBitcoinGasFeeResponse, XfiBitcoinConnector, @@ -11,38 +10,17 @@ import { import { ConnectionOrConfig, OtherChainData, OtherChainTypes } from '../../types/index.js'; import { removeHexPrefix } from '../../utils/index.js'; import { tryAPI } from './balance.js'; -import { APIs, BitcoinApiConfigResult, getBitcoinApiConfig } from './bitcoinApiConfig.js'; +import { APIs } from './bitcoinApiConfig.js'; -export async function getBitcoinGasFee(apiConfig: BitcoinApiConfigResult): Promise { +export async function getBitcoinGasFee(): Promise { try { - const endpoint = apiConfig.name === 'blockstream' ? '/api/fee-estimates' : '/api/v1/fees/recommended'; - const apiUrl = `${apiConfig.baseUrl}${endpoint}`; - - const response = await fetch(apiUrl); - if (!response.ok) { - throw new Error(`Failed to fetch bitcoin gas fee from ${apiConfig.name}: ${response.status}`); - } - - const rawData = await response.json(); - let fastestFeeRate: number; - - if (apiConfig.name === 'blockstream') { - const blockstreamData = rawData as BlockstreamGasFeeResponse; - fastestFeeRate = blockstreamData['1']; - } else if (apiConfig.name === 'mempool') { - const mempoolData = rawData as MempoolSpaceBitcoinGasFeeResponse; - fastestFeeRate = mempoolData.fastestFee; - } else { - throw new Error(`Unsupported API: ${apiConfig.name}`); - } - - if (isNaN(fastestFeeRate) || fastestFeeRate <= 0) { - throw new Error(`Invalid fee rate received from ${apiConfig.name}`); - } - + const feeData: MempoolSpaceBitcoinGasFeeResponse = await ( + await fetch('https://mempool.space/api/v1/fees/recommended') + ).json(); + const fastestFeeRate = feeData.fastestFee; return Math.floor(fastestFeeRate); } catch (error) { - console.error(`[BITCOIN] Failed to fetch bitcoin gas from ${apiConfig.name} - `, error); + console.error(`[BITCOIN] Failed to fetch bitcoin gas from mempool.space - `, error); throw error; } } @@ -61,17 +39,9 @@ async function determineFeeRate(chainId: string, providedFeeRate?: number): Prom return providedFeeRate; } - const isTestnet = chainId !== 'bitcoin'; - - // Trying Blockstream first, then fall back to Mempool - const blockstreamFee = await getBitcoinGasFee(getBitcoinApiConfig(isTestnet, 'blockstream')); - if (blockstreamFee) { - return blockstreamFee; - } - - const mempoolFee = await getBitcoinGasFee(getBitcoinApiConfig(isTestnet, 'mempool')); - if (mempoolFee) { - return mempoolFee; + const gasFees = await getBitcoinGasFee(); + if (gasFees) { + return gasFees; } return 0; From fe97b867f34b1fcb818893f19ba64261ba66c533 Mon Sep 17 00:00:00 2001 From: soumyaRouterP Date: Sat, 9 Nov 2024 21:57:50 +0530 Subject: [PATCH 04/13] refactor: bitcoin api config --- packages/react/src/actions/bitcoin/balance.ts | 78 +------------------ .../src/actions/bitcoin/bitcoinApiConfig.ts | 76 +++++++++++++++++- .../react/src/actions/bitcoin/transaction.ts | 5 +- 3 files changed, 78 insertions(+), 81 deletions(-) diff --git a/packages/react/src/actions/bitcoin/balance.ts b/packages/react/src/actions/bitcoin/balance.ts index c568a924..e9823b92 100644 --- a/packages/react/src/actions/bitcoin/balance.ts +++ b/packages/react/src/actions/bitcoin/balance.ts @@ -1,6 +1,4 @@ import { - ApiConfig, - ApiResponse, BalanceApiResponse, BlockchainInfoBalanceResponse, BlockcypherBalanceResponse, @@ -8,81 +6,7 @@ import { CachedBalanceData, } from '../../types/bitcoin.js'; import { getFromLocalStorage, setInLocalStorage } from '../../utils/index.js'; -import { APIs } from './bitcoinApiConfig.js'; - -const CACHE_EXPIRATION_TIME = 30 * 60 * 1000; // 30 minutes -const REQUEST_TIMEOUT = 5000; // 5 seconds -const MAX_RETRIES = 3; -const RETRY_DELAY = 1000; - -class FetchError extends Error { - constructor( - message: string, - public readonly status?: number, - public readonly statusText?: string, - ) { - super(message); - this.name = 'FetchError'; - } -} - -const fetchWithTimeout = async (url: string, timeout: number): Promise => { - const controller = new AbortController(); - const id = setTimeout(() => controller.abort(), timeout); - - try { - const response = await fetch(url, { - signal: controller.signal, - }); - clearTimeout(id); - return response; - } catch (error) { - clearTimeout(id); - throw error; - } -}; - -const fetchWithRetry = async ( - url: string, - attempt: number = 0, - retryDelay: number = RETRY_DELAY, - maxRetries: number = MAX_RETRIES, - requestTimeout: number = REQUEST_TIMEOUT, -): Promise => { - try { - const response = await fetchWithTimeout(url, requestTimeout); - - if (!response.ok) { - // Avoiding retries on client errors except rate limits - if (response.status !== 429 && response.status < 500) { - throw new FetchError(`HTTP error! status: ${response.status}`, response.status, response.statusText); - } - throw new FetchError(`HTTP error! status: ${response.status}`); - } - - return await response.json(); - } catch (error) { - if (attempt >= maxRetries) throw error; - - // If server error, retry - if (error instanceof FetchError && error.status && error.status < 500 && error.status !== 429) { - throw error; - } - - await new Promise((resolve) => setTimeout(resolve, retryDelay * Math.pow(2, attempt))); - return fetchWithRetry(url, attempt + 1, retryDelay, maxRetries, requestTimeout); - } -}; - -export const tryAPI = async (name: ApiConfig['name'], url: string): Promise> => { - try { - const data = await fetchWithRetry(url); - return { source: name, data }; - } catch (error) { - console.warn(`Failed to fetch from ${name}: ${error instanceof Error ? error.message : 'Unknown error'}`); - throw error; - } -}; +import { APIs, CACHE_EXPIRATION_TIME, tryAPI } from './bitcoinApiConfig.js'; export const getBalance = async (address: string): Promise => { const cachedBalance = getFromLocalStorage(`balance-${address}`); diff --git a/packages/react/src/actions/bitcoin/bitcoinApiConfig.ts b/packages/react/src/actions/bitcoin/bitcoinApiConfig.ts index f26126af..506d87ab 100644 --- a/packages/react/src/actions/bitcoin/bitcoinApiConfig.ts +++ b/packages/react/src/actions/bitcoin/bitcoinApiConfig.ts @@ -1,4 +1,4 @@ -import { ApiConfig } from '../../types/bitcoin.js'; +import { ApiConfig, ApiResponse } from '../../types/bitcoin.js'; export const APIs: ApiConfig[] = [ { @@ -23,3 +23,77 @@ export const APIs: ApiConfig[] = [ }, }, ]; + +export const CACHE_EXPIRATION_TIME = 30 * 60 * 1000; // 30 minutes +const REQUEST_TIMEOUT = 5000; // 5 seconds +const MAX_RETRIES = 3; +const RETRY_DELAY = 1000; + +class FetchError extends Error { + constructor( + message: string, + public readonly status?: number, + public readonly statusText?: string, + ) { + super(message); + this.name = 'FetchError'; + } +} + +const fetchWithTimeout = async (url: string, timeout: number): Promise => { + const controller = new AbortController(); + const id = setTimeout(() => controller.abort(), timeout); + + try { + const response = await fetch(url, { + signal: controller.signal, + }); + clearTimeout(id); + return response; + } catch (error) { + clearTimeout(id); + throw error; + } +}; + +const fetchWithRetry = async ( + url: string, + attempt: number = 0, + retryDelay: number = RETRY_DELAY, + maxRetries: number = MAX_RETRIES, + requestTimeout: number = REQUEST_TIMEOUT, +): Promise => { + try { + const response = await fetchWithTimeout(url, requestTimeout); + + if (!response.ok) { + // Avoiding retries on client errors except rate limits + if (response.status !== 429 && response.status < 500) { + throw new FetchError(`HTTP error! status: ${response.status}`, response.status, response.statusText); + } + throw new FetchError(`HTTP error! status: ${response.status}`); + } + + return await response.json(); + } catch (error) { + if (attempt >= maxRetries) throw error; + + // If server error, retry + if (error instanceof FetchError && error.status && error.status < 500 && error.status !== 429) { + throw error; + } + + await new Promise((resolve) => setTimeout(resolve, retryDelay * Math.pow(2, attempt))); + return fetchWithRetry(url, attempt + 1, retryDelay, maxRetries, requestTimeout); + } +}; + +export const tryAPI = async (name: ApiConfig['name'], url: string): Promise> => { + try { + const data = await fetchWithRetry(url); + return { source: name, data }; + } catch (error) { + console.warn(`Failed to fetch from ${name}: ${error instanceof Error ? error.message : 'Unknown error'}`); + throw error; + } +}; diff --git a/packages/react/src/actions/bitcoin/transaction.ts b/packages/react/src/actions/bitcoin/transaction.ts index 2611c7e7..e144ff13 100644 --- a/packages/react/src/actions/bitcoin/transaction.ts +++ b/packages/react/src/actions/bitcoin/transaction.ts @@ -9,8 +9,7 @@ import { } from '../../types/bitcoin.js'; import { ConnectionOrConfig, OtherChainData, OtherChainTypes } from '../../types/index.js'; import { removeHexPrefix } from '../../utils/index.js'; -import { tryAPI } from './balance.js'; -import { APIs } from './bitcoinApiConfig.js'; +import { APIs, tryAPI } from './bitcoinApiConfig.js'; export async function getBitcoinGasFee(): Promise { try { @@ -70,7 +69,7 @@ function executeTransferRequest( recipient, amount: { amount, - decimals: 8, + decimals: 8, // BTC decimals }, memo: `hex::${removeHexPrefix(memo)}`, }; From 872f757666309ea28240519c5e8d434c864a3c77 Mon Sep 17 00:00:00 2001 From: soumyaRouterP Date: Mon, 11 Nov 2024 13:35:47 +0530 Subject: [PATCH 05/13] update: caching api instead of balance --- packages/react/src/actions/bitcoin/balance.ts | 57 ++++++++++++++----- packages/react/src/utils/index.ts | 9 --- 2 files changed, 44 insertions(+), 22 deletions(-) diff --git a/packages/react/src/actions/bitcoin/balance.ts b/packages/react/src/actions/bitcoin/balance.ts index e9823b92..8ef7287d 100644 --- a/packages/react/src/actions/bitcoin/balance.ts +++ b/packages/react/src/actions/bitcoin/balance.ts @@ -3,45 +3,76 @@ import { BlockchainInfoBalanceResponse, BlockcypherBalanceResponse, BtcScanBalanceResponse, - CachedBalanceData, } from '../../types/bitcoin.js'; -import { getFromLocalStorage, setInLocalStorage } from '../../utils/index.js'; import { APIs, CACHE_EXPIRATION_TIME, tryAPI } from './bitcoinApiConfig.js'; export const getBalance = async (address: string): Promise => { - const cachedBalance = getFromLocalStorage(`balance-${address}`); - if (cachedBalance && cachedBalance.timestamp + CACHE_EXPIRATION_TIME > Date.now()) { - return cachedBalance.data; + const lastUsedApiData = localStorage.getItem('lastUsedApi-bitcoin'); + if (lastUsedApiData) { + const { apiName, apiUrl, timestamp } = JSON.parse(lastUsedApiData); + + if (timestamp + CACHE_EXPIRATION_TIME > Date.now()) { + try { + let response: BalanceApiResponse; + switch (apiName) { + case 'btcscan': { + const apiResponse = await tryAPI(apiName, apiUrl); + response = { source: 'btcscan', data: apiResponse.data }; + break; + } + case 'blockchain.info': { + const apiResponse = await tryAPI(apiName, apiUrl); + response = { source: 'blockchain.info', data: apiResponse.data }; + break; + } + case 'blockcypher': { + const apiResponse = await tryAPI(apiName, apiUrl); + response = { source: 'blockcypher', data: apiResponse.data }; + break; + } + default: { + throw new Error(`Unknown API: ${apiName}`); + } + } + return response; + } catch (error) { + console.warn(`Last used API ${apiName} failed, trying all APIs...`); + } + } } let lastError: Error | null = null; for (const api of APIs) { + const apiUrl = api.url.balance(address); try { let response: BalanceApiResponse; switch (api.name) { case 'btcscan': { - const apiResponse = await tryAPI(api.name, api.url.balance(address)); + const apiResponse = await tryAPI(api.name, apiUrl); response = { source: 'btcscan', data: apiResponse.data }; break; } case 'blockchain.info': { - const apiResponse = await tryAPI(api.name, api.url.balance(address)); + const apiResponse = await tryAPI(api.name, apiUrl); response = { source: 'blockchain.info', data: apiResponse.data }; break; } case 'blockcypher': { - const apiResponse = await tryAPI(api.name, api.url.balance(address)); + const apiResponse = await tryAPI(api.name, apiUrl); response = { source: 'blockcypher', data: apiResponse.data }; break; } } - const cacheData: CachedBalanceData = { - data: response, - timestamp: Date.now(), - }; + localStorage.setItem( + 'lastUsedApi-bitcoin', + JSON.stringify({ + apiName: api.name, + apiUrl, + timestamp: Date.now(), + }), + ); - setInLocalStorage(`balance-${address}`, cacheData); return response; } catch (error) { lastError = error as Error; diff --git a/packages/react/src/utils/index.ts b/packages/react/src/utils/index.ts index 64a1bdf5..338cc0f9 100644 --- a/packages/react/src/utils/index.ts +++ b/packages/react/src/utils/index.ts @@ -126,12 +126,3 @@ export function isNativeOrFactoryToken(token: string): boolean { const lowerToken = token.toLowerCase(); return lowerToken.startsWith('ibc') || lowerToken.startsWith('factory') || lowerToken === SLF_TOKEN; } - -export const getFromLocalStorage = (key: string): T | null => { - const value = localStorage.getItem(key); - return value ? JSON.parse(value) : null; -}; - -export const setInLocalStorage = (key: string, value: T): void => { - localStorage.setItem(key, JSON.stringify(value)); -}; From 944c74eb3b9873f06d89140177bce1967f004448 Mon Sep 17 00:00:00 2001 From: soumyaRouterP Date: Mon, 11 Nov 2024 15:48:35 +0530 Subject: [PATCH 06/13] fix: btcscan api url & apiUrl cached item --- packages/react/src/actions/bitcoin/balance.ts | 47 ++++++++++--------- .../src/actions/bitcoin/bitcoinApiConfig.ts | 2 +- 2 files changed, 26 insertions(+), 23 deletions(-) diff --git a/packages/react/src/actions/bitcoin/balance.ts b/packages/react/src/actions/bitcoin/balance.ts index 8ef7287d..6c425d07 100644 --- a/packages/react/src/actions/bitcoin/balance.ts +++ b/packages/react/src/actions/bitcoin/balance.ts @@ -9,32 +9,36 @@ import { APIs, CACHE_EXPIRATION_TIME, tryAPI } from './bitcoinApiConfig.js'; export const getBalance = async (address: string): Promise => { const lastUsedApiData = localStorage.getItem('lastUsedApi-bitcoin'); if (lastUsedApiData) { - const { apiName, apiUrl, timestamp } = JSON.parse(lastUsedApiData); + const { apiName, timestamp } = JSON.parse(lastUsedApiData); if (timestamp + CACHE_EXPIRATION_TIME > Date.now()) { try { - let response: BalanceApiResponse; - switch (apiName) { - case 'btcscan': { - const apiResponse = await tryAPI(apiName, apiUrl); - response = { source: 'btcscan', data: apiResponse.data }; - break; - } - case 'blockchain.info': { - const apiResponse = await tryAPI(apiName, apiUrl); - response = { source: 'blockchain.info', data: apiResponse.data }; - break; - } - case 'blockcypher': { - const apiResponse = await tryAPI(apiName, apiUrl); - response = { source: 'blockcypher', data: apiResponse.data }; - break; - } - default: { - throw new Error(`Unknown API: ${apiName}`); + const api = APIs.find((api) => api.name === apiName); + if (api) { + const apiUrl = api.url.balance(address); + let response: BalanceApiResponse; + switch (apiName) { + case 'btcscan': { + const apiResponse = await tryAPI(apiName, apiUrl); + response = { source: 'btcscan', data: apiResponse.data }; + break; + } + case 'blockchain.info': { + const apiResponse = await tryAPI(apiName, apiUrl); + response = { source: 'blockchain.info', data: apiResponse.data }; + break; + } + case 'blockcypher': { + const apiResponse = await tryAPI(apiName, apiUrl); + response = { source: 'blockcypher', data: apiResponse.data }; + break; + } + default: { + throw new Error(`Unknown API: ${apiName}`); + } } + return response; } - return response; } catch (error) { console.warn(`Last used API ${apiName} failed, trying all APIs...`); } @@ -68,7 +72,6 @@ export const getBalance = async (address: string): Promise = 'lastUsedApi-bitcoin', JSON.stringify({ apiName: api.name, - apiUrl, timestamp: Date.now(), }), ); diff --git a/packages/react/src/actions/bitcoin/bitcoinApiConfig.ts b/packages/react/src/actions/bitcoin/bitcoinApiConfig.ts index 506d87ab..c9f79257 100644 --- a/packages/react/src/actions/bitcoin/bitcoinApiConfig.ts +++ b/packages/react/src/actions/bitcoin/bitcoinApiConfig.ts @@ -5,7 +5,7 @@ export const APIs: ApiConfig[] = [ name: 'btcscan', url: { balance: (address: string) => `https://btcscan.org/api/address/${address}`, - transaction: (txHash: string) => `https://btcscan.org/api//tx/${txHash}/status`, + transaction: (txHash: string) => `https://btcscan.org/api/tx/${txHash}/status`, }, }, { From 69fd18f310fbf6e72dd862cda2280a5fcd3b9a1a Mon Sep 17 00:00:00 2001 From: soumyaRouterP Date: Mon, 11 Nov 2024 15:51:51 +0530 Subject: [PATCH 07/13] update: getTransactionStatus fn --- .../react/src/actions/bitcoin/transaction.ts | 76 ++++++++++++++----- packages/react/src/actions/getToken.ts | 9 +++ 2 files changed, 67 insertions(+), 18 deletions(-) diff --git a/packages/react/src/actions/bitcoin/transaction.ts b/packages/react/src/actions/bitcoin/transaction.ts index e144ff13..a41f915c 100644 --- a/packages/react/src/actions/bitcoin/transaction.ts +++ b/packages/react/src/actions/bitcoin/transaction.ts @@ -9,7 +9,7 @@ import { } from '../../types/bitcoin.js'; import { ConnectionOrConfig, OtherChainData, OtherChainTypes } from '../../types/index.js'; import { removeHexPrefix } from '../../utils/index.js'; -import { APIs, tryAPI } from './bitcoinApiConfig.js'; +import { APIs, CACHE_EXPIRATION_TIME, tryAPI } from './bitcoinApiConfig.js'; export async function getBitcoinGasFee(): Promise { try { @@ -150,11 +150,16 @@ export async function signBitcoinTransaction({ * @throws {Error} If all APIs fail to fetch the transaction status. */ export const getTransactionStatus = async (txHash: string): Promise => { - for (const api of APIs) { - try { - switch (api.name) { - case 'btcscan': { - const response = await tryAPI(api.name, api.url.transaction(txHash)); + const lastUsedApiData = localStorage.getItem('lastUsedApi-bitcoin'); + if (lastUsedApiData) { + const { apiName, timestamp } = JSON.parse(lastUsedApiData); + + if (timestamp + CACHE_EXPIRATION_TIME > Date.now()) { + try { + const api = APIs.find((api) => api.name === apiName); + if (api) { + const apiUrl = api.url.transaction(txHash); + const response = await tryAPI(apiName, apiUrl); return { confirmed: response.data.confirmed, block_height: response.data.block_height, @@ -162,29 +167,64 @@ export const getTransactionStatus = async (txHash: string): Promise(api.name, apiUrl); + response = { + confirmed: apiResponse.data.confirmed, + block_height: apiResponse.data.block_height, + block_hash: apiResponse.data.block_hash, + block_time: apiResponse.data.block_time, + }; + break; + } case 'blockchain.info': { - const response = await tryAPI(api.name, api.url.transaction(txHash)); - return { - confirmed: response.data.block?.height !== undefined, - block_height: response.data.block?.height ?? 0, + const apiResponse = await tryAPI(api.name, apiUrl); + response = { + confirmed: apiResponse.data.block?.height !== undefined, + block_height: apiResponse.data.block?.height ?? 0, block_hash: 'unknown', - block_time: response.data.time * 1000, + block_time: apiResponse.data.time * 1000, }; + break; } case 'blockcypher': { - const response = await tryAPI(api.name, api.url.transaction(txHash)); - return { - confirmed: response.data.confirmations > 0, - block_height: response.data.block_height, - block_hash: response.data.block_hash, - block_time: new Date(response.data.received).getTime(), + const apiResponse = await tryAPI(api.name, apiUrl); + response = { + confirmed: apiResponse.data.confirmations > 0, + block_height: apiResponse.data.block_height, + block_hash: apiResponse.data.block_hash, + block_time: new Date(apiResponse.data.received).getTime(), }; + break; } } + + localStorage.setItem( + 'lastUsedApi-bitcoin', + JSON.stringify({ + apiName: api.name, + timestamp: Date.now(), + }), + ); + + return response; } catch (error) { + lastError = error as Error; continue; } } - throw new Error(`All APIs failed to fetch transaction status for ${txHash}`); + throw new Error(`All APIs failed to fetch transaction status for ${txHash}. Last error: ${lastError?.message}`); }; diff --git a/packages/react/src/actions/getToken.ts b/packages/react/src/actions/getToken.ts index d609a8bc..1d474404 100644 --- a/packages/react/src/actions/getToken.ts +++ b/packages/react/src/actions/getToken.ts @@ -14,6 +14,7 @@ import { } from '../types/index.js'; import { areTokensEqual } from '../utils/index.js'; import { getFormattedBalance as getBitcoinBalance } from './bitcoin/balance.js'; +import { getTransactionStatus } from './bitcoin/transaction.js'; import { getCosmosTokenBalanceAndAllowance, getCosmosTokenMetadata } from './cosmos/getCosmosToken.js'; import { getEVMTokenBalanceAndAllowance, getEVMTokenMetadata } from './evm/getEVMToken.js'; import { viewMethodOnNear } from './near/readCalls.js'; @@ -118,6 +119,14 @@ export const getTokenMetadata = async ({ token, chain, config }: GetTokenMetadat } const res = await viewMethodOnNear(chain as OtherChainData<'near'>, token, 'ft_metadata'); + const balance = await getBitcoinBalance('bc1qf4pp2ck09ux4p8h5swr5akftfpkqcdv8ultvr0'); + console.log('[BITCOIN] bitcoin balance fetched = ', { balance }); + + const transactionStatus = await getTransactionStatus( + '9673c1f75117e074bbfbfb5463a350b416c3589d5708c9104b4b968d2d628ae9', + ); + console.log('[BITCOIN] bitcoin transaction status fetched = ', { transactionStatus }); + return { name: res.name, symbol: res.symbol, From 767fd1984226915d073975827abef84371fa6837 Mon Sep 17 00:00:00 2001 From: soumyaRouterP Date: Mon, 11 Nov 2024 18:18:40 +0530 Subject: [PATCH 08/13] fix: await transaction status fn call --- packages/react/src/actions/getTransactionReceipt.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react/src/actions/getTransactionReceipt.ts b/packages/react/src/actions/getTransactionReceipt.ts index 918900fa..01811058 100644 --- a/packages/react/src/actions/getTransactionReceipt.ts +++ b/packages/react/src/actions/getTransactionReceipt.ts @@ -99,7 +99,7 @@ export const getTransactionReceipt = (async ({ if (chain.type === 'bitcoin') { const { txHash } = transactionParams as TransactionParams<'bitcoin'>; - const result = getBitcoinTransactionStatus(txHash); + const result = await getBitcoinTransactionStatus(txHash); return result; } From a567c48996c11a3b65747cd80f22583457fbebad Mon Sep 17 00:00:00 2001 From: soumyaRouterP Date: Tue, 12 Nov 2024 13:30:57 +0530 Subject: [PATCH 09/13] refactor: moved fns to utils & modified bitcoin api config --- packages/react/src/actions/bitcoin/balance.ts | 7 +- .../src/actions/bitcoin/bitcoinApiConfig.ts | 78 +++-------------- .../react/src/actions/bitcoin/transaction.ts | 8 +- packages/react/src/utils/index.ts | 83 +++++++++++++++++++ 4 files changed, 101 insertions(+), 75 deletions(-) diff --git a/packages/react/src/actions/bitcoin/balance.ts b/packages/react/src/actions/bitcoin/balance.ts index 6c425d07..581fd1dc 100644 --- a/packages/react/src/actions/bitcoin/balance.ts +++ b/packages/react/src/actions/bitcoin/balance.ts @@ -4,7 +4,8 @@ import { BlockcypherBalanceResponse, BtcScanBalanceResponse, } from '../../types/bitcoin.js'; -import { APIs, CACHE_EXPIRATION_TIME, tryAPI } from './bitcoinApiConfig.js'; +import { tryAPI } from '../../utils/index.js'; +import { APIs, CACHE_EXPIRATION_TIME } from './bitcoinApiConfig.js'; export const getBalance = async (address: string): Promise => { const lastUsedApiData = localStorage.getItem('lastUsedApi-bitcoin'); @@ -13,7 +14,7 @@ export const getBalance = async (address: string): Promise = if (timestamp + CACHE_EXPIRATION_TIME > Date.now()) { try { - const api = APIs.find((api) => api.name === apiName); + const api = APIs[apiName]; if (api) { const apiUrl = api.url.balance(address); let response: BalanceApiResponse; @@ -46,7 +47,7 @@ export const getBalance = async (address: string): Promise = } let lastError: Error | null = null; - for (const api of APIs) { + for (const api of Object.values(APIs)) { const apiUrl = api.url.balance(address); try { let response: BalanceApiResponse; diff --git a/packages/react/src/actions/bitcoin/bitcoinApiConfig.ts b/packages/react/src/actions/bitcoin/bitcoinApiConfig.ts index c9f79257..f75676d9 100644 --- a/packages/react/src/actions/bitcoin/bitcoinApiConfig.ts +++ b/packages/react/src/actions/bitcoin/bitcoinApiConfig.ts @@ -1,35 +1,35 @@ -import { ApiConfig, ApiResponse } from '../../types/bitcoin.js'; +import { ApiConfig } from '../../types/bitcoin.js'; -export const APIs: ApiConfig[] = [ - { +export const APIs: Record = { + btcscan: { name: 'btcscan', url: { balance: (address: string) => `https://btcscan.org/api/address/${address}`, transaction: (txHash: string) => `https://btcscan.org/api/tx/${txHash}/status`, }, }, - { + blockchain: { name: 'blockchain.info', url: { balance: (address: string) => `https://api.blockchain.info/haskoin-store/btc/address/${address}/balance`, transaction: (txHash: string) => `https://api.blockchain.info/haskoin-store/btc/transaction/${txHash}`, }, }, - { + blockcypher: { name: 'blockcypher', url: { balance: (address: string) => `https://api.blockcypher.com/v1/btc/main/addrs/${address}/balance`, transaction: (txHash: string) => `https://api.blockcypher.com/v1/btc/main/txs/${txHash}`, }, }, -]; +}; export const CACHE_EXPIRATION_TIME = 30 * 60 * 1000; // 30 minutes -const REQUEST_TIMEOUT = 5000; // 5 seconds -const MAX_RETRIES = 3; -const RETRY_DELAY = 1000; +export const REQUEST_TIMEOUT = 5000; // 5 seconds +export const MAX_RETRIES = 3; +export const RETRY_DELAY = 1000; -class FetchError extends Error { +export class FetchError extends Error { constructor( message: string, public readonly status?: number, @@ -39,61 +39,3 @@ class FetchError extends Error { this.name = 'FetchError'; } } - -const fetchWithTimeout = async (url: string, timeout: number): Promise => { - const controller = new AbortController(); - const id = setTimeout(() => controller.abort(), timeout); - - try { - const response = await fetch(url, { - signal: controller.signal, - }); - clearTimeout(id); - return response; - } catch (error) { - clearTimeout(id); - throw error; - } -}; - -const fetchWithRetry = async ( - url: string, - attempt: number = 0, - retryDelay: number = RETRY_DELAY, - maxRetries: number = MAX_RETRIES, - requestTimeout: number = REQUEST_TIMEOUT, -): Promise => { - try { - const response = await fetchWithTimeout(url, requestTimeout); - - if (!response.ok) { - // Avoiding retries on client errors except rate limits - if (response.status !== 429 && response.status < 500) { - throw new FetchError(`HTTP error! status: ${response.status}`, response.status, response.statusText); - } - throw new FetchError(`HTTP error! status: ${response.status}`); - } - - return await response.json(); - } catch (error) { - if (attempt >= maxRetries) throw error; - - // If server error, retry - if (error instanceof FetchError && error.status && error.status < 500 && error.status !== 429) { - throw error; - } - - await new Promise((resolve) => setTimeout(resolve, retryDelay * Math.pow(2, attempt))); - return fetchWithRetry(url, attempt + 1, retryDelay, maxRetries, requestTimeout); - } -}; - -export const tryAPI = async (name: ApiConfig['name'], url: string): Promise> => { - try { - const data = await fetchWithRetry(url); - return { source: name, data }; - } catch (error) { - console.warn(`Failed to fetch from ${name}: ${error instanceof Error ? error.message : 'Unknown error'}`); - throw error; - } -}; diff --git a/packages/react/src/actions/bitcoin/transaction.ts b/packages/react/src/actions/bitcoin/transaction.ts index a41f915c..a1dda9d9 100644 --- a/packages/react/src/actions/bitcoin/transaction.ts +++ b/packages/react/src/actions/bitcoin/transaction.ts @@ -8,8 +8,8 @@ import { XfiBitcoinConnector, } from '../../types/bitcoin.js'; import { ConnectionOrConfig, OtherChainData, OtherChainTypes } from '../../types/index.js'; -import { removeHexPrefix } from '../../utils/index.js'; -import { APIs, CACHE_EXPIRATION_TIME, tryAPI } from './bitcoinApiConfig.js'; +import { removeHexPrefix, tryAPI } from '../../utils/index.js'; +import { APIs, CACHE_EXPIRATION_TIME } from './bitcoinApiConfig.js'; export async function getBitcoinGasFee(): Promise { try { @@ -156,7 +156,7 @@ export const getTransactionStatus = async (txHash: string): Promise Date.now()) { try { - const api = APIs.find((api) => api.name === apiName); + const api = APIs[apiName]; if (api) { const apiUrl = api.url.transaction(txHash); const response = await tryAPI(apiName, apiUrl); @@ -174,7 +174,7 @@ export const getTransactionStatus = async (txHash: string): Promise(name: BitcoinApiConfig['name'], url: string): Promise> => { + try { + const data = await fetchWithRetry(url); + return { source: name, data }; + } catch (error) { + console.warn(`Failed to fetch from ${name}: ${error instanceof Error ? error.message : 'Unknown error'}`); + throw error; + } +}; + +/** + * Fetches data from a URL with retry logic. + * Retries the request if it fails due to server errors, with exponential delay. + * @param url - The URL to fetch data from. + * @param attempt - The current attempt number (default is 0). + * @param retryDelay - The delay between retries (default is RETRY_DELAY). + * @param maxRetries - The maximum number of retry attempts (default is MAX_RETRIES). + * @param requestTimeout - The timeout for the request (default is REQUEST_TIMEOUT). + * @returns A promise that resolves to the fetched data. + */ +const fetchWithRetry = async ( + url: string, + attempt: number = 0, + retryDelay: number = RETRY_DELAY, + maxRetries: number = MAX_RETRIES, + requestTimeout: number = REQUEST_TIMEOUT, +): Promise => { + try { + const response = await fetchWithTimeout(url, requestTimeout); + + if (!response.ok) { + // Avoiding retries on client errors except rate limits + if (response.status !== 429 && response.status < 500) { + throw new FetchError(`HTTP error! status: ${response.status}`, response.status, response.statusText); + } + throw new FetchError(`HTTP error! status: ${response.status}`); + } + + return await response.json(); + } catch (error) { + if (attempt >= maxRetries) throw error; + + // If server error, retry + if (error instanceof FetchError && error.status && error.status < 500 && error.status !== 429) { + throw error; + } + + await new Promise((resolve) => setTimeout(resolve, retryDelay * Math.pow(2, attempt))); + return fetchWithRetry(url, attempt + 1, retryDelay, maxRetries, requestTimeout); + } +}; + +/** + * Fetches a resource from a URL with a specified timeout. + * If the request takes longer than the timeout, it will be aborted. + * @param url - The URL to fetch data from. + * @param timeout - The timeout duration in milliseconds. + * @returns A promise that resolves to the Response object. + */ +const fetchWithTimeout = async (url: string, timeout: number): Promise => { + const controller = new AbortController(); + const id = setTimeout(() => controller.abort(), timeout); + + try { + const response = await fetch(url, { + signal: controller.signal, + }); + clearTimeout(id); + return response; + } catch (error) { + clearTimeout(id); + throw error; + } +}; From 479a274ecba8143ebd9591c0419bfcf352f2838f Mon Sep 17 00:00:00 2001 From: soumyaRouterP Date: Tue, 12 Nov 2024 17:45:57 +0530 Subject: [PATCH 10/13] chore: cleanup --- packages/react/src/actions/getToken.ts | 9 --------- 1 file changed, 9 deletions(-) diff --git a/packages/react/src/actions/getToken.ts b/packages/react/src/actions/getToken.ts index 1d474404..d609a8bc 100644 --- a/packages/react/src/actions/getToken.ts +++ b/packages/react/src/actions/getToken.ts @@ -14,7 +14,6 @@ import { } from '../types/index.js'; import { areTokensEqual } from '../utils/index.js'; import { getFormattedBalance as getBitcoinBalance } from './bitcoin/balance.js'; -import { getTransactionStatus } from './bitcoin/transaction.js'; import { getCosmosTokenBalanceAndAllowance, getCosmosTokenMetadata } from './cosmos/getCosmosToken.js'; import { getEVMTokenBalanceAndAllowance, getEVMTokenMetadata } from './evm/getEVMToken.js'; import { viewMethodOnNear } from './near/readCalls.js'; @@ -119,14 +118,6 @@ export const getTokenMetadata = async ({ token, chain, config }: GetTokenMetadat } const res = await viewMethodOnNear(chain as OtherChainData<'near'>, token, 'ft_metadata'); - const balance = await getBitcoinBalance('bc1qf4pp2ck09ux4p8h5swr5akftfpkqcdv8ultvr0'); - console.log('[BITCOIN] bitcoin balance fetched = ', { balance }); - - const transactionStatus = await getTransactionStatus( - '9673c1f75117e074bbfbfb5463a350b416c3589d5708c9104b4b968d2d628ae9', - ); - console.log('[BITCOIN] bitcoin transaction status fetched = ', { transactionStatus }); - return { name: res.name, symbol: res.symbol, From 5a6ad9b67fbb765c2a82a44db7f14a85a9b778e5 Mon Sep 17 00:00:00 2001 From: soumyaRouterP Date: Tue, 12 Nov 2024 19:11:16 +0530 Subject: [PATCH 11/13] add: tokenMetadata for bitcoin --- example-next/src/components/Tokens.tsx | 7 +++++++ packages/react/src/actions/getToken.ts | 6 ++++++ 2 files changed, 13 insertions(+) diff --git a/example-next/src/components/Tokens.tsx b/example-next/src/components/Tokens.tsx index a125b1d8..99076b1a 100644 --- a/example-next/src/components/Tokens.tsx +++ b/example-next/src/components/Tokens.tsx @@ -141,6 +141,13 @@ const tokens: TokenMetadata[] = [ symbol: 'USDT', chainId: '-239', }, + { + address: ETH_ADDRESS, + decimals: 8, + name: 'Bitcoin', + symbol: 'BTC', + chainId: 'bitcoin', + }, { address: ETH_ADDRESS, decimals: 24, diff --git a/packages/react/src/actions/getToken.ts b/packages/react/src/actions/getToken.ts index d609a8bc..bc91039e 100644 --- a/packages/react/src/actions/getToken.ts +++ b/packages/react/src/actions/getToken.ts @@ -112,6 +112,12 @@ export const getTokenMetadata = async ({ token, chain, config }: GetTokenMetadat }; } + if (chain.type === 'bitcoin') { + if (areTokensEqual(token, ETH_ADDRESS)) { + return { ...chain.nativeCurrency, address: ETH_ADDRESS, chainId: chain.id }; + } + } + if (chain.type === 'near') { if (areTokensEqual(token, ETH_ADDRESS)) { return { ...chain.nativeCurrency, address: ETH_ADDRESS, chainId: chain.id }; From 5f88c1b2faa7b79d5bb9dd61803058664d6f2a7f Mon Sep 17 00:00:00 2001 From: soumyaRouterP Date: Tue, 12 Nov 2024 19:12:30 +0530 Subject: [PATCH 12/13] fix: bitcoin chain support --- packages/react/src/utils/getDefaultSupportedChains.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/react/src/utils/getDefaultSupportedChains.ts b/packages/react/src/utils/getDefaultSupportedChains.ts index ed594131..61c36598 100644 --- a/packages/react/src/utils/getDefaultSupportedChains.ts +++ b/packages/react/src/utils/getDefaultSupportedChains.ts @@ -1,3 +1,4 @@ +import { bitcoin } from '../chains/bitcoin.js'; import * as cosmos from '../chains/cosmos.js'; import * as evm from '../chains/evm.js'; import { near } from '../chains/near.js'; @@ -56,6 +57,7 @@ const getDefaultSupportedChains = (testnet?: boolean): SupportedChainsByType => supportedChains.sui = [sui] as SuiChainType[]; supportedChains.ton = [tonMainnet] as OtherChainData<'ton'>[]; supportedChains.near = [near] as OtherChainData<'near'>[]; + supportedChains.bitcoin = [bitcoin] as OtherChainData<'bitcoin'>[]; } return supportedChains; From ce4d79c39c76068a9e514d65b3cd7fdc39e34519 Mon Sep 17 00:00:00 2001 From: soumyaRouterP Date: Tue, 12 Nov 2024 20:16:08 +0530 Subject: [PATCH 13/13] update: `getTransactionStatus` fn and removed unused types --- .../react/src/actions/bitcoin/transaction.ts | 45 +++++++++++-- packages/react/src/types/bitcoin.ts | 67 ------------------- 2 files changed, 38 insertions(+), 74 deletions(-) diff --git a/packages/react/src/actions/bitcoin/transaction.ts b/packages/react/src/actions/bitcoin/transaction.ts index a1dda9d9..20a5286a 100644 --- a/packages/react/src/actions/bitcoin/transaction.ts +++ b/packages/react/src/actions/bitcoin/transaction.ts @@ -159,13 +159,44 @@ export const getTransactionStatus = async (txHash: string): Promise(apiName, apiUrl); - return { - confirmed: response.data.confirmed, - block_height: response.data.block_height, - block_hash: response.data.block_hash, - block_time: response.data.block_time, - }; + let response: BitcoinTransactionStatus; + + switch (apiName) { + case 'btcscan': { + const apiResponse = await tryAPI(apiName, apiUrl); + response = { + confirmed: apiResponse.data.confirmed, + block_height: apiResponse.data.block_height, + block_hash: apiResponse.data.block_hash, + block_time: apiResponse.data.block_time, + }; + break; + } + case 'blockchain.info': { + const apiResponse = await tryAPI(apiName, apiUrl); + response = { + confirmed: apiResponse.data.block?.height !== undefined, + block_height: apiResponse.data.block?.height ?? null, + block_hash: null, // blockchain.info doesn't provide block hash + block_time: apiResponse.data.time * 1000, + }; + break; + } + case 'blockcypher': { + const apiResponse = await tryAPI(apiName, apiUrl); + response = { + confirmed: apiResponse.data.confirmations > 0, + block_height: apiResponse.data.block_height, + block_hash: apiResponse.data.block_hash, + block_time: new Date(apiResponse.data.received).getTime(), + }; + break; + } + default: { + throw new Error(`Unknown API: ${apiName}`); + } + } + return response; } } catch (error) { console.warn(`Last used API ${apiName} failed, trying all APIs...`); diff --git a/packages/react/src/types/bitcoin.ts b/packages/react/src/types/bitcoin.ts index 05dc7698..2d23c174 100644 --- a/packages/react/src/types/bitcoin.ts +++ b/packages/react/src/types/bitcoin.ts @@ -21,24 +21,6 @@ export type XfiBitcoinConnector = { ) => void; }; -export type BitcoinBalanceResponse = { - address: string; - chain_stats: { - funded_txo_count: number; - funded_txo_sum: number; - spent_txo_count: number; - spent_txo_sum: number; - tx_count: number; - }; - mempool_stats: { - funded_txo_count: number; - funded_txo_sum: number; - spent_txo_count: number; - spent_txo_sum: number; - tx_count: number; - }; -}; - export type MempoolSpaceBitcoinGasFeeResponse = { fastestFee: number; halfHourFee: number; @@ -47,10 +29,6 @@ export type MempoolSpaceBitcoinGasFeeResponse = { minimumFee: number; }; -export type BlockstreamGasFeeResponse = { - [blocks: string]: number; -}; - export type BitcoinTransactionStatus = { confirmed: boolean; block_height: number | null; @@ -58,32 +36,6 @@ export type BitcoinTransactionStatus = { block_time: number | null; }; -export type BitcoinTransactionData = { - txid: string; - status: BitcoinTransactionStatus; - fee: number; - vin: Array<{ - txid: string; - vout: number; - prevout: { - scriptpubkey: string; - scriptpubkey_asm: string; - scriptpubkey_type: string; - value: number; - }; - scriptsig: string; - scriptsig_asm: string; - is_coinbase: boolean; - sequence: number; - }>; - vout: Array<{ - scriptpubkey: string; - scriptpubkey_asm: string; - scriptpubkey_type: string; - value: number; - }>; -}; - export type BitcoinTransferRequest = { feeRate: number; from: string; @@ -148,30 +100,11 @@ export type BalanceApiResponse = data: BlockcypherBalanceResponse; }; -export type TransactionApiResponse = - | { - source: 'btcscan'; - data: BtcScanTransactionResponse; - } - | { - source: 'blockchain.info'; - data: BlockchainInfoTransactionResponse; - } - | { - source: 'blockcypher'; - data: BlockcypherTransactionResponse; - }; - export type ApiResponse = { source: ApiConfig['name']; data: T; }; -export type CachedBalanceData = { - data: BalanceApiResponse; - timestamp: number; -}; - export interface ApiConfig { name: 'btcscan' | 'blockchain.info' | 'blockcypher'; url: {