synor/apps/desktop-wallet/src/store/feeAnalytics.ts
2026-02-02 14:30:07 +05:30

227 lines
5.9 KiB
TypeScript

import { create } from 'zustand';
import { invoke } from '@tauri-apps/api/core';
/**
* Mempool statistics from the network
*/
export interface MempoolStats {
txCount: number;
totalSizeBytes: number;
totalFees: number;
minFeeRate: number; // sompi per byte
avgFeeRate: number;
maxFeeRate: number;
percentile10: number; // fee rate at 10th percentile
percentile50: number; // fee rate at 50th percentile (median)
percentile90: number; // fee rate at 90th percentile
lastUpdated: number; // unix timestamp
}
/**
* Fee tier recommendation
*/
export interface FeeRecommendation {
tier: 'economy' | 'standard' | 'priority' | 'instant';
feeRate: number; // sompi per byte
estimatedBlocks: number;
estimatedTimeSecs: number;
description: string;
}
/**
* Historical fee data point for charting
*/
export interface FeeHistoryPoint {
timestamp: number;
avgFeeRate: number;
minFeeRate: number;
maxFeeRate: number;
blockHeight: number;
}
/**
* Full fee analytics data
*/
export interface FeeAnalytics {
mempool: MempoolStats;
recommendations: FeeRecommendation[];
feeHistory: FeeHistoryPoint[];
networkCongestion: 'low' | 'medium' | 'high';
blockTargetTimeSecs: number;
}
interface FeeAnalyticsState {
// State
analytics: FeeAnalytics | null;
selectedTier: FeeRecommendation['tier'];
isLoading: boolean;
error: string | null;
autoRefresh: boolean;
refreshIntervalMs: number;
// Actions
fetchAnalytics: () => Promise<void>;
setSelectedTier: (tier: FeeRecommendation['tier']) => void;
setAutoRefresh: (enabled: boolean) => void;
calculateFee: (txSizeBytes: number) => Promise<number>;
}
// Transform snake_case backend response to camelCase
function transformMempoolStats(data: any): MempoolStats {
return {
txCount: data.tx_count,
totalSizeBytes: data.total_size_bytes,
totalFees: data.total_fees,
minFeeRate: data.min_fee_rate,
avgFeeRate: data.avg_fee_rate,
maxFeeRate: data.max_fee_rate,
percentile10: data.percentile_10,
percentile50: data.percentile_50,
percentile90: data.percentile_90,
lastUpdated: data.last_updated,
};
}
function transformRecommendation(data: any): FeeRecommendation {
return {
tier: data.tier as FeeRecommendation['tier'],
feeRate: data.fee_rate,
estimatedBlocks: data.estimated_blocks,
estimatedTimeSecs: data.estimated_time_secs,
description: data.description,
};
}
function transformFeeHistory(data: any): FeeHistoryPoint {
return {
timestamp: data.timestamp,
avgFeeRate: data.avg_fee_rate,
minFeeRate: data.min_fee_rate,
maxFeeRate: data.max_fee_rate,
blockHeight: data.block_height,
};
}
function transformAnalytics(data: any): FeeAnalytics {
return {
mempool: transformMempoolStats(data.mempool),
recommendations: data.recommendations.map(transformRecommendation),
feeHistory: data.fee_history.map(transformFeeHistory),
networkCongestion: data.network_congestion as FeeAnalytics['networkCongestion'],
blockTargetTimeSecs: data.block_target_time_secs,
};
}
export const useFeeAnalyticsStore = create<FeeAnalyticsState>()((set, get) => ({
// Initial state
analytics: null,
selectedTier: 'standard',
isLoading: false,
error: null,
autoRefresh: true,
refreshIntervalMs: 30000, // 30 seconds
// Fetch all analytics data
fetchAnalytics: async () => {
set({ isLoading: true, error: null });
try {
const data = await invoke<any>('fee_get_analytics');
const analytics = transformAnalytics(data);
set({ analytics, isLoading: false });
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to fetch fee analytics';
set({ error: message, isLoading: false });
}
},
// Set selected fee tier
setSelectedTier: (tier) => {
set({ selectedTier: tier });
},
// Toggle auto-refresh
setAutoRefresh: (enabled) => {
set({ autoRefresh: enabled });
},
// Calculate fee for a transaction
calculateFee: async (txSizeBytes) => {
const { selectedTier } = get();
try {
const fee = await invoke<number>('fee_calculate', {
txSizeBytes,
tier: selectedTier,
});
return fee;
} catch (error) {
// Fallback calculation
const { analytics } = get();
const recommendation = analytics?.recommendations.find((r) => r.tier === selectedTier);
const feeRate = recommendation?.feeRate || 1.0;
return Math.ceil(txSizeBytes * feeRate);
}
},
}));
/**
* Get the selected recommendation
*/
export function useSelectedRecommendation(): FeeRecommendation | null {
const { analytics, selectedTier } = useFeeAnalyticsStore();
return analytics?.recommendations.find((r) => r.tier === selectedTier) || null;
}
/**
* Get congestion color class
*/
export function getCongestionColor(congestion: FeeAnalytics['networkCongestion']): string {
switch (congestion) {
case 'low':
return 'text-green-400';
case 'medium':
return 'text-yellow-400';
case 'high':
return 'text-red-400';
default:
return 'text-gray-400';
}
}
/**
* Get congestion background color
*/
export function getCongestionBgColor(congestion: FeeAnalytics['networkCongestion']): string {
switch (congestion) {
case 'low':
return 'bg-green-500/20 border-green-500/30';
case 'medium':
return 'bg-yellow-500/20 border-yellow-500/30';
case 'high':
return 'bg-red-500/20 border-red-500/30';
default:
return 'bg-gray-500/20 border-gray-500/30';
}
}
/**
* Format time duration in human readable format
*/
export function formatDuration(seconds: number): string {
if (seconds < 60) {
return `~${seconds}s`;
} else if (seconds < 3600) {
const mins = Math.floor(seconds / 60);
return `~${mins}m`;
} else {
const hours = Math.floor(seconds / 3600);
return `~${hours}h`;
}
}
/**
* Format fee rate
*/
export function formatFeeRate(rate: number): string {
return `${rate.toFixed(2)} sompi/byte`;
}