From 47516ca74cd223c7254da31c31e52db28ff04c09 Mon Sep 17 00:00:00 2001 From: Valreb001 Date: Thu, 28 May 2026 15:29:26 +0100 Subject: [PATCH] feat: reimplement feeEstimation service with Horizon fee_stats, TTL cache, and surge pricing --- .../src/services/feeEstimation.ts | 327 ++++-------------- 1 file changed, 73 insertions(+), 254 deletions(-) diff --git a/wata-board-frontend/src/services/feeEstimation.ts b/wata-board-frontend/src/services/feeEstimation.ts index 57bc8f36..66d472b6 100644 --- a/wata-board-frontend/src/services/feeEstimation.ts +++ b/wata-board-frontend/src/services/feeEstimation.ts @@ -1,303 +1,122 @@ /** * Fee Estimation Service for Stellar Transactions - * Provides accurate fee estimation for Stellar network transactions + * Queries Horizon fee_stats endpoint for accurate, real-time fee data. */ -import { Horizon, Networks, TransactionBuilder, Operation, Asset, BASE_FEE } from '@stellar/stellar-sdk'; -import { requestAccess } from '../utils/wallet-bridge'; +import { Horizon, BASE_FEE } from '@stellar/stellar-sdk'; import { getCurrentNetworkConfig } from '../utils/network-config'; +const STROOPS_PER_XLM = 10_000_000; +const CACHE_TTL_MS = 10_000; // 10 second TTL +const SURGE_MULTIPLIER = 1.5; // Applied when network is congested + +export interface FeeTiers { + min: number; // Minimum fee in XLM + recommended: number; // Recommended fee in XLM (p50) + max: number; // High-priority fee in XLM (p90) +} + export interface FeeEstimate { - baseFee: number; // Base fee in stroops - totalFee: number; // Total fee in XLM - minFee: number; // Minimum recommended fee in XLM - recommendedFee: number; // Recommended fee for current network conditions + tiers: FeeTiers; + totalFee: number; // recommended total fee in XLM for given op count operationCount: number; - estimatedTime: number; // Estimated confirmation time in seconds + isSurge: boolean; // true when surge pricing is active + estimatedTimeSeconds: number; } -export interface TransactionDetails { - amount: string; - destination: string; - asset?: Asset; - memo?: string; +interface FeeCache { + tiers: FeeTiers; + isSurge: boolean; + timestamp: number; } +// Utility conversions +export const stroopsToXLM = (stroops: number): number => stroops / STROOPS_PER_XLM; +export const xlmToStroops = (xlm: number): number => Math.floor(xlm * STROOPS_PER_XLM); + export class FeeEstimationService { private server: Horizon.Server; - private networkConfig: any; + private cache: FeeCache | null = null; constructor() { - this.networkConfig = getCurrentNetworkConfig(); - const horizonUrl = this.networkConfig.rpcUrl.replace('soroban', 'horizon'); + const config = getCurrentNetworkConfig(); + const horizonUrl = config.rpcUrl.replace('soroban', 'horizon'); this.server = new Horizon.Server(horizonUrl); } /** - * Get current network fee statistics + * Fetch fee tiers from Horizon fee_stats, with TTL caching. */ - async getNetworkFees(): Promise<{ - minFee: number; - recommendedFee: number; - p50Fee: number; - p90Fee: number; - }> { - try { - // Get recent ledgers to analyze fee trends - // FOR TESTS: Bypass ledgers if mock is provided - if ((window as any).__MOCK_STELLAR_LEDGER__) { - console.log('[FeeEstimationService] Using mock stellar ledger'); - return (window as any).__MOCK_STELLAR_LEDGER__; - } - - const latestLedger = await this.server.ledgers() - .limit(1) - .order('desc') - .call(); - - // For now, use Stellar's base fee as minimum - // In a production environment, you'd analyze recent transactions - const minFee = parseInt(BASE_FEE); - const recommendedFee = Math.max(parseInt(BASE_FEE), 100); // At least 100 stroops - const p50Fee = Math.max(parseInt(BASE_FEE), 200); // 50th percentile - const p90Fee = Math.max(parseInt(BASE_FEE), 500); // 90th percentile - - return { - minFee, - recommendedFee, - p50Fee, - p90Fee - }; - } catch (error) { - console.error('Failed to get network fees:', error); - // Fallback to base fee - return { - minFee: parseInt(BASE_FEE), - recommendedFee: parseInt(BASE_FEE) * 2, - p50Fee: parseInt(BASE_FEE) * 3, - p90Fee: parseInt(BASE_FEE) * 5 - }; + async getFeeTiers(): Promise<{ tiers: FeeTiers; isSurge: boolean }> { + if (this.cache && Date.now() - this.cache.timestamp < CACHE_TTL_MS) { + return { tiers: this.cache.tiers, isSurge: this.cache.isSurge }; } - } - /** - * Estimate fees for a simple payment transaction - */ - async estimatePaymentFee( - amount: string, - destination: string = "GDOPTS553GBKXNF3X4YCQ7NPZUQ644QAN4SV7JEZHAVOVROAUQTSKEHO" // Valid mock destination account - ): Promise { - console.log('[FeeEstimationService] estimatePaymentFee called for amount:', amount); try { - // Get the public key from Freighter - console.log('[FeeEstimationService] Requesting access...'); - const accessResult = await requestAccess(); - console.log('[FeeEstimationService] Access result:', JSON.stringify(accessResult)); - if (accessResult.error || !accessResult.address) { - throw new Error(accessResult.error || 'Could not get public key from wallet'); - } + const feeStats = await this.server.feeStats(); - const pubKeyString = accessResult.address; + const p10 = parseInt(feeStats.fee_charged.p10); + const p50 = parseInt(feeStats.fee_charged.p50); + const p90 = parseInt(feeStats.fee_charged.p90); + const ledgerCapacityUsage = parseFloat(feeStats.ledger_capacity_usage); - // Get account details - // FOR TESTS: Bypass loadAccount if mock is provided - let account; - if ((window as any).__MOCK_STELLAR_ACCOUNT__) { - console.log('[FeeEstimationService] Using mock stellar account'); - account = (window as any).__MOCK_STELLAR_ACCOUNT__(pubKeyString); - } else { - console.log('[FeeEstimationService] Loading account from server...'); - account = await this.server.loadAccount(pubKeyString); - } - - console.log('[FeeEstimationService] Getting network fees...'); - // Get network fee statistics - const networkFees = await this.getNetworkFees(); - console.log('[FeeEstimationService] Network fees:', JSON.stringify(networkFees)); + const isSurge = ledgerCapacityUsage > 0.8; + const surgeMultiplier = isSurge ? SURGE_MULTIPLIER : 1; - // Create a sample transaction to estimate fees - let transaction; - if ((window as any).__MOCK_STELLAR_TRANSACTION__) { - console.log('[FeeEstimationService] Using mock stellar transaction'); - transaction = (window as any).__MOCK_STELLAR_TRANSACTION__(account, amount); - } else { - transaction = new TransactionBuilder(account, { - fee: networkFees.recommendedFee.toString(), - networkPassphrase: this.networkConfig.networkPassphrase, - }) - .addOperation(Operation.payment({ - destination, - asset: Asset.native(), - amount, - })) - .setTimeout(30) - .build(); - } - - // Calculate fees - const operationCount = transaction.operations.length; - const baseFee = parseInt(transaction.fee); - const totalFeeStroops = baseFee * operationCount; - const totalFeeXLM = totalFeeStroops / 10000000; // Convert from stroops to XLM - - return { - baseFee, - totalFee: totalFeeXLM, - minFee: networkFees.minFee / 10000000, - recommendedFee: networkFees.recommendedFee / 10000000, - operationCount, - estimatedTime: this.estimateConfirmationTime(networkFees.recommendedFee) - }; - } catch (error) { - console.error('Fee estimation failed:', error); - // Return fallback estimate - return { - baseFee: parseInt(BASE_FEE), - totalFee: parseInt(BASE_FEE) / 10000000, - minFee: parseInt(BASE_FEE) / 10000000, - recommendedFee: (parseInt(BASE_FEE) * 2) / 10000000, - operationCount: 1, - estimatedTime: 5 + const tiers: FeeTiers = { + min: stroopsToXLM(Math.max(p10, parseInt(BASE_FEE))), + recommended: stroopsToXLM(Math.ceil(p50 * surgeMultiplier)), + max: stroopsToXLM(Math.ceil(p90 * surgeMultiplier)), }; - } - } - - /** - * Estimate fees for complex transactions with multiple operations - */ - async estimateComplexTransactionFee( - operations: Operation[], - fee: number = parseInt(BASE_FEE) * 2 - ): Promise { - try { - const accessResult = await requestAccess(); - if (accessResult.error || !accessResult.address) { - throw new Error(accessResult.error || 'Could not get public key from wallet'); - } - - const pubKeyString = accessResult.address; - - const account = await this.server.loadAccount(pubKeyString); - const networkFees = await this.getNetworkFees(); - - const transactionBuilder = new TransactionBuilder(account, { - fee: fee.toString(), - networkPassphrase: this.networkConfig.networkPassphrase, - }); - - // Add all operations - operations.forEach(op => transactionBuilder.addOperation(op)); - - const transaction = transactionBuilder.setTimeout(30).build(); - const operationCount = transaction.operations.length; - const baseFee = parseInt(transaction.fee); - const totalFeeStroops = baseFee * operationCount; - const totalFeeXLM = totalFeeStroops / 10000000; - - return { - baseFee, - totalFee: totalFeeXLM, - minFee: networkFees.minFee / 10000000, - recommendedFee: networkFees.recommendedFee / 10000000, - operationCount, - estimatedTime: this.estimateConfirmationTime(fee) - }; - } catch (error) { - console.error('Complex fee estimation failed:', error); - return { - baseFee: fee, - totalFee: (fee * operations.length) / 10000000, - minFee: parseInt(BASE_FEE) / 10000000, - recommendedFee: (parseInt(BASE_FEE) * 2) / 10000000, - operationCount: operations.length, - estimatedTime: 5 + this.cache = { tiers, isSurge, timestamp: Date.now() }; + return { tiers, isSurge }; + } catch { + // Fallback to BASE_FEE if Horizon is unreachable + const base = parseInt(BASE_FEE); + const tiers: FeeTiers = { + min: stroopsToXLM(base), + recommended: stroopsToXLM(base * 2), + max: stroopsToXLM(base * 5), }; + return { tiers, isSurge: false }; } } /** - * Estimate confirmation time based on fee + * Estimate fee for a transaction with the given number of operations. */ - private estimateConfirmationTime(feeStroops: number): number { - // Simple heuristic based on fee amount - if (feeStroops >= 500) return 3; // High priority - if (feeStroops >= 200) return 5; // Medium priority - if (feeStroops >= 100) return 10; // Low priority - return 15; // Very low priority - } + async estimateFee(operationCount: number = 1): Promise { + const { tiers, isSurge } = await this.getFeeTiers(); - /** - * Get fee recommendations for different priority levels - */ - async getFeeRecommendations(): Promise<{ - economy: { fee: number; time: number }; - standard: { fee: number; time: number }; - priority: { fee: number; time: number }; - }> { - const networkFees = await this.getNetworkFees(); + const totalFee = tiers.recommended * operationCount; + const estimatedTimeSeconds = isSurge ? 10 : 5; return { - economy: { - fee: networkFees.minFee / 10000000, - time: this.estimateConfirmationTime(networkFees.minFee) - }, - standard: { - fee: networkFees.recommendedFee / 10000000, - time: this.estimateConfirmationTime(networkFees.recommendedFee) - }, - priority: { - fee: networkFees.p90Fee / 10000000, - time: this.estimateConfirmationTime(networkFees.p90Fee) - } + tiers, + totalFee, + operationCount, + isSurge, + estimatedTimeSeconds, }; } - /** - * Format fee for display - */ + /** Format a fee value in XLM for display. */ formatFee(feeXLM: number, decimals: number = 7): string { - return feeXLM.toFixed(decimals) + ' XLM'; + return `${feeXLM.toFixed(decimals)} XLM`; } - /** - * Calculate total cost including fees - */ - calculateTotalCost(amount: number, fee: number): number { - return amount + fee; + /** Total cost of a payment including the recommended fee. */ + async totalCost(amountXLM: number, operationCount: number = 1): Promise { + const estimate = await this.estimateFee(operationCount); + return amountXLM + estimate.totalFee; } -} -// Create singleton instance -export const feeEstimationService = new FeeEstimationService(); - -// Utility functions -export const feeUtils = { - /** - * Convert stroops to XLM - */ - stroopsToXLM: (stroops: number): number => stroops / 10000000, - - /** - * Convert XLM to stroops - */ - xlmToStroops: (xlm: number): number => Math.floor(xlm * 10000000), - - /** - * Check if fee is sufficient for current network conditions - */ - isFeeSufficient: (feeStroops: number, networkFees: any): boolean => { - return feeStroops >= networkFees.minFee; - }, - - /** - * Get fee priority level - */ - getFeePriority: (feeStroops: number, networkFees: any): 'economy' | 'standard' | 'priority' => { - if (feeStroops >= networkFees.p90Fee) return 'priority'; - if (feeStroops >= networkFees.recommendedFee) return 'standard'; - return 'economy'; + /** Invalidate the cache (useful for testing). */ + clearCache(): void { + this.cache = null; } -}; +} +export const feeEstimationService = new FeeEstimationService(); export default feeEstimationService;