Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

update: bitcoin balance and transaction status fetcher #23

Merged
merged 13 commits into from
Nov 13, 2024
7 changes: 7 additions & 0 deletions example-next/src/components/Tokens.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
111 changes: 111 additions & 0 deletions packages/react/src/actions/bitcoin/balance.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import {
BalanceApiResponse,
BlockchainInfoBalanceResponse,
BlockcypherBalanceResponse,
BtcScanBalanceResponse,
} from '../../types/bitcoin.js';
import { tryAPI } from '../../utils/index.js';
import { APIs, CACHE_EXPIRATION_TIME } from './bitcoinApiConfig.js';

export const getBalance = async (address: string): Promise<BalanceApiResponse> => {
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[apiName];
if (api) {
const apiUrl = api.url.balance(address);
let response: BalanceApiResponse;
switch (apiName) {
case 'btcscan': {
const apiResponse = await tryAPI<BtcScanBalanceResponse>(apiName, apiUrl);
response = { source: 'btcscan', data: apiResponse.data };
break;
}
case 'blockchain.info': {
const apiResponse = await tryAPI<BlockchainInfoBalanceResponse>(apiName, apiUrl);
response = { source: 'blockchain.info', data: apiResponse.data };
break;
}
case 'blockcypher': {
const apiResponse = await tryAPI<BlockcypherBalanceResponse>(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 Object.values(APIs)) {
const apiUrl = api.url.balance(address);
try {
let response: BalanceApiResponse;
switch (api.name) {
case 'btcscan': {
const apiResponse = await tryAPI<BtcScanBalanceResponse>(api.name, apiUrl);
response = { source: 'btcscan', data: apiResponse.data };
break;
}
case 'blockchain.info': {
const apiResponse = await tryAPI<BlockchainInfoBalanceResponse>(api.name, apiUrl);
response = { source: 'blockchain.info', data: apiResponse.data };
break;
}
case 'blockcypher': {
const apiResponse = await tryAPI<BlockcypherBalanceResponse>(api.name, apiUrl);
response = { source: 'blockcypher', data: apiResponse.data };
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 balance. Last error: ${lastError?.message}`);
};

export const getFormattedBalance = async (address: string): Promise<number> => {
const response = await getBalance(address);

switch (response.source) {
case 'btcscan': {
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': {
const data = response.data as BlockchainInfoBalanceResponse;
return data.confirmed + data.unconfirmed;
}

case 'blockcypher': {
const data = response.data as BlockcypherBalanceResponse;
return data.balance + data.unconfirmed_balance;
}
}
};
58 changes: 34 additions & 24 deletions packages/react/src/actions/bitcoin/bitcoinApiConfig.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,41 @@
export interface BitcoinApiConfig {
mainnet: string;
testnet: string;
name: string;
}
import { ApiConfig } from '../../types/bitcoin.js';

export const bitcoinApiConfig: Record<string, BitcoinApiConfig> = {
blockstream: {
mainnet: 'https://blockstream.info',
testnet: 'https://blockstream.info/testnet',
name: 'blockstream',
export const APIs: Record<string, ApiConfig> = {
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}`,
},
},
mempool: {
mainnet: 'https://mempool.space',
testnet: 'https://mempool.space/testnet',
name: 'mempool',
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 interface BitcoinApiConfigResult {
baseUrl: string;
name: string;
}
export const CACHE_EXPIRATION_TIME = 30 * 60 * 1000; // 30 minutes
export const REQUEST_TIMEOUT = 5000; // 5 seconds
export const MAX_RETRIES = 3;
export const RETRY_DELAY = 1000;

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 class FetchError extends Error {
constructor(
message: string,
public readonly status?: number,
public readonly statusText?: string,
) {
super(message);
this.name = 'FetchError';
}
}
Loading
Loading