@btc-stamps/tx-builder
Version:
Transaction builder for Bitcoin Stamps and SRC-20 tokens with advanced UTXO selection
248 lines (219 loc) • 6.48 kB
text/typescript
/**
* Base UTXO Provider
* Common functionality for all provider implementations
* Includes fee normalization for consistency with BTCStampsExplorer
*/
import * as bitcoin from 'bitcoinjs-lib';
import type { Network } from 'bitcoinjs-lib';
import type {
AddressHistory,
AddressHistoryOptions,
Balance,
IUTXOProvider,
ProviderOptions,
Transaction,
UTXO,
} from '../interfaces/provider.interface.ts';
import {
createNormalizedFeeRate,
FeeNormalizer,
type FeeSource,
type NormalizedFeeRate,
validateAndNormalizeFee,
} from '../utils/fee-normalizer.ts';
export abstract class BaseProvider implements IUTXOProvider {
protected network: Network;
protected timeout: number;
protected retries: number;
protected retryDelay: number;
protected maxRetryDelay: number;
constructor(options: ProviderOptions) {
this.network = options.network;
this.timeout = options.timeout ?? 30000;
this.retries = options.retries ?? 3;
this.retryDelay = options.retryDelay ?? 1000;
this.maxRetryDelay = options.maxRetryDelay ?? 10000;
}
abstract getUTXOs(address: string): Promise<UTXO[]>;
abstract getBalance(address: string): Promise<Balance>;
abstract getTransaction(txid: string): Promise<Transaction>;
abstract broadcastTransaction(hexTx: string): Promise<string>;
abstract getFeeRate(priority?: 'low' | 'medium' | 'high'): Promise<number>;
abstract getBlockHeight(): Promise<number>;
abstract isConnected(): Promise<boolean>;
abstract getAddressHistory(
address: string,
options?: AddressHistoryOptions,
): Promise<AddressHistory[]>;
getNetwork(): Network {
return this.network;
}
/**
* Execute request with retry logic
*/
protected async executeWithRetry<T>(
fn: () => Promise<T>,
retries = this.retries,
): Promise<T> {
let lastError: Error | undefined;
let delay = this.retryDelay;
for (let i = 0; i <= retries; i++) {
try {
return await this.executeWithTimeout(fn);
} catch (error) {
lastError = error as Error;
if (i < retries) {
await this.sleep(delay);
delay = Math.min(delay * 2, this.maxRetryDelay); // Exponential backoff
}
}
}
throw lastError || new Error('Request failed after retries');
}
/**
* Execute request with timeout
*/
protected executeWithTimeout<T>(
fn: () => Promise<T>,
timeout = this.timeout,
): Promise<T> {
return Promise.race([
fn(),
new Promise<T>((_, reject) =>
setTimeout(() => reject(new Error('Request timeout')), timeout)
),
]);
}
/**
* Sleep for specified milliseconds
*/
protected sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* Validate Bitcoin address format
*/
protected isValidAddress(address: string): boolean {
try {
// Use bitcoinjs-lib for proper address validation
bitcoin.address.toOutputScript(address, this.network);
return true;
} catch {
return false;
}
}
/**
* Validate transaction ID format
*/
protected isValidTxid(txid: string): boolean {
return /^[a-fA-F0-9]{64}$/.test(txid);
}
/**
* Convert satoshis to BTC
*/
protected satoshisToBTC(satoshis: number): number {
return satoshis / 100000000;
}
/**
* Convert BTC to satoshis
*/
protected btcToSatoshis(btc: number): number {
return Math.round(btc * 100000000);
}
/**
* Normalize fee rate from provider response to consistent satsPerVB
*/
protected normalizeFeeRate(
rate: any,
source: FeeSource,
): NormalizedFeeRate | null {
return validateAndNormalizeFee(rate, source);
}
/**
* Get normalized fee rate ensuring it's within acceptable bounds
*/
protected async getNormalizedFeeRate(
priority: 'low' | 'medium' | 'high' = 'medium',
): Promise<NormalizedFeeRate> {
try {
const rawFeeRate = await this.getFeeRate(priority);
const normalized = this.normalizeFeeRate(
rawFeeRate,
this.getProviderSource(),
);
if (normalized && FeeNormalizer.validateFeeRate(normalized.satsPerVB)) {
return normalized;
}
} catch (error) {
console.warn('Failed to get normalized fee rate from provider:', error);
}
// Fallback to standard rate
return FeeNormalizer.getStandardFeeLevel(priority as any);
}
/**
* Convert legacy fee rate response to normalized format
*/
protected normalizeLegacyFeeResponse(
feeResponse: any,
source: FeeSource,
): NormalizedFeeRate | null {
try {
// Handle different response formats from various providers
if (typeof feeResponse === 'number') {
return createNormalizedFeeRate(feeResponse, 'sat/vB', source);
}
if (feeResponse && typeof feeResponse.feeRate === 'number') {
return createNormalizedFeeRate(feeResponse.feeRate, 'sat/vB', source);
}
if (feeResponse && typeof feeResponse.fee_per_kb === 'number') {
// Convert from sat/kB to sat/vB
return createNormalizedFeeRate(
feeResponse.fee_per_kb,
'btc/kb',
source,
);
}
return null;
} catch (error) {
console.warn('Failed to normalize legacy fee response:', error);
return null;
}
}
/**
* Get the fee source identifier for this provider
* Override in child classes to specify the correct source
*/
protected getProviderSource(): FeeSource {
return 'explorer'; // Default, should be overridden
}
/**
* Validate that fee rate is reasonable for the current network conditions
*/
protected validateFeeRate(satsPerVB: number): boolean {
return FeeNormalizer.validateFeeRate(satsPerVB);
}
/**
* Get all priority levels with normalized rates
*/
async getNormalizedFeeRates(): Promise<{
low: NormalizedFeeRate;
medium: NormalizedFeeRate;
high: NormalizedFeeRate;
urgent?: NormalizedFeeRate;
}> {
try {
const [low, medium, high] = await Promise.all([
this.getNormalizedFeeRate('low'),
this.getNormalizedFeeRate('medium'),
this.getNormalizedFeeRate('high'),
]);
return { low, medium, high };
} catch (error) {
console.warn(
'Failed to get normalized fee rates, using standards:',
error,
);
return FeeNormalizer.getAllStandardFeeLevels();
}
}
}