Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
328 changes: 78 additions & 250 deletions wata-board-frontend/src/services/feeEstimation.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,49 @@
/**
* 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, BASE_FEE } from '@stellar/stellar-sdk';
import { getCurrentNetworkConfig } from '../utils/network-config';
import { Horizon, Networks, TransactionBuilder, Operation, Asset, BASE_FEE } from '@stellar/stellar-sdk';
import { requestAccess } from '../utils/wallet-bridge';
import { getCurrentNetworkConfig, NETWORK_CHANGE_EVENT } 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 cache: FeeCache | null = null;

constructor() {
const config = getCurrentNetworkConfig();
const horizonUrl = config.rpcUrl.replace('soroban', 'horizon');
private networkConfig: any;
private networkChangeHandler: (() => void) | null = null;

Expand All @@ -48,270 +67,79 @@ export class FeeEstimationService {
}

/**
* 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<FeeEstimate> {
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 pubKeyString = accessResult.address;

// 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 feeStats = await this.server.feeStats();

// 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();
}
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);

// 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
const isSurge = ledgerCapacityUsage > 0.8;
const surgeMultiplier = isSurge ? SURGE_MULTIPLIER : 1;

return {
baseFee,
totalFee: totalFeeXLM,
minFee: networkFees.minFee / 10000000,
recommendedFee: networkFees.recommendedFee / 10000000,
operationCount,
estimatedTime: this.estimateConfirmationTime(networkFees.recommendedFee)
const tiers: FeeTiers = {
min: stroopsToXLM(Math.max(p10, parseInt(BASE_FEE))),
recommended: stroopsToXLM(Math.ceil(p50 * surgeMultiplier)),
max: stroopsToXLM(Math.ceil(p90 * surgeMultiplier)),
};
} 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
};
}
}

/**
* Estimate fees for complex transactions with multiple operations
*/
async estimateComplexTransactionFee(
operations: Operation[],
fee: number = parseInt(BASE_FEE) * 2
): Promise<FeeEstimate> {
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<FeeEstimate> {
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<number> {
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 default feeEstimationService;
export const feeEstimationService = new FeeEstimationService();
export default feeEstimationService;
export default feeEstimationService;
Loading