Skip to main content

sdkt_rpc/
fee.rs

1//! Network fee information retrieval from Soroban RPC.
2//!
3//! Provides `get_fee_stats` to fetch current network fee metrics, mapping them
4//! to the `sdkt-core` fee estimators.
5
6use crate::{RpcError, SorobanRpcClient};
7use sdkt_core::{FeeConfig, FeeEstimator, LedgerFeeSample};
8use serde::Deserialize;
9
10/// Query the current network fee stats.
11pub async fn get_fee_stats(client: &SorobanRpcClient) -> Result<FeeStats, RpcError> {
12    client.request("getFeeStats", ()).await
13}
14
15/// Extract valid fee samples from a percentile distribution.
16/// Returns up to 7 valid numeric samples (min, p10..p50, mode).
17fn parse_distr_to_samples(d: &FeeDistribution) -> Vec<LedgerFeeSample> {
18    let keys = [&d.min, &d.p10, &d.p20, &d.p30, &d.p40, &d.p50, &d.mode];
19    let mut samples = Vec::with_capacity(keys.len());
20    for s in keys {
21        if let Ok(val) = s.parse::<u32>() {
22            samples.push(LedgerFeeSample { base_fee: val });
23        }
24    }
25    samples
26}
27
28/// Parse FeeStats into estimation samples.
29/// Priority 1: soroban_inclusion_fee percentiles.
30/// Priority 2: inclusion_fee percentiles (fallback).
31/// Returns None if no valid data.
32fn parse_fee_stats(stats: &FeeStats) -> Option<Vec<LedgerFeeSample>> {
33    let samples = parse_distr_to_samples(&stats.soroban_inclusion_fee);
34    if !samples.is_empty() && samples.iter().any(|s| s.base_fee > 0) {
35        return Some(samples);
36    }
37
38    let fallback = parse_distr_to_samples(&stats.inclusion_fee);
39    if !fallback.is_empty() {
40        return Some(fallback);
41    }
42
43    None
44}
45
46/// Estimate fees dynamically based on the current network state via RPC.
47///
48/// This uses `sdkt-core`'s `FeeEstimator` internally, preventing duplication of
49/// fee calculation logic. It translates RPC network fees into `LedgerFeeSample`s.
50pub async fn estimate_dynamic_fee(
51    client: &SorobanRpcClient,
52    config: FeeConfig,
53) -> Result<(u64, String), RpcError> {
54    let stats = get_fee_stats(client).await?;
55
56    let samples = match parse_fee_stats(&stats) {
57        Some(s) => s,
58        None => {
59            return Err(RpcError::Rpc(
60                "No valid fee data available from network stats".to_string(),
61            ))
62        }
63    };
64
65    let estimator = FeeEstimator::new(config);
66    let (stroops, xlm) = estimator
67        .estimate(&samples)
68        .map_err(|e| RpcError::Config(format!("Fee estimation failed: {e}")))?;
69
70    Ok((stroops, xlm))
71}
72
73/// Soroban fee statistics response.
74#[derive(Debug, Deserialize, PartialEq)]
75#[serde(rename_all = "camelCase")]
76pub struct FeeStats {
77    pub soroban_inclusion_fee: FeeDistribution,
78    pub inclusion_fee: FeeDistribution,
79    pub latest_ledger: u32,
80}
81
82/// Distribution of fees across the network.
83#[derive(Debug, Clone, Deserialize, PartialEq)]
84#[serde(rename_all = "camelCase")]
85pub struct FeeDistribution {
86    pub max: String,
87    pub min: String,
88    pub mode: String,
89    pub p10: String,
90    pub p20: String,
91    pub p30: String,
92    pub p40: String,
93    pub p50: String,
94    pub p60: String,
95    pub p70: String,
96    pub p80: String,
97    pub p90: String,
98    pub p95: String,
99    pub p99: String,
100}
101
102#[cfg(test)]
103mod tests {
104    use super::*;
105
106    fn mock_valid_distribution() -> FeeDistribution {
107        FeeDistribution {
108            max: "1000".to_string(),
109            min: "100".to_string(),
110            mode: "150".to_string(),
111            p10: "110".to_string(),
112            p20: "120".to_string(),
113            p30: "130".to_string(),
114            p40: "140".to_string(),
115            p50: "150".to_string(),
116            p60: "160".to_string(),
117            p70: "170".to_string(),
118            p80: "180".to_string(),
119            p90: "190".to_string(),
120            p95: "195".to_string(),
121            p99: "199".to_string(),
122        }
123    }
124
125    #[test]
126    fn test_valid_parse() {
127        let stats = FeeStats {
128            soroban_inclusion_fee: mock_valid_distribution(),
129            inclusion_fee: mock_valid_distribution(),
130            latest_ledger: 100,
131        };
132        let samples = parse_fee_stats(&stats).unwrap();
133        assert_eq!(samples.len(), 7);
134        assert_eq!(samples[0].base_fee, 100); // min
135        assert_eq!(samples[5].base_fee, 150); // p50
136    }
137
138    #[test]
139    fn test_malformed_values_are_skipped() {
140        let mut valid = mock_valid_distribution();
141        valid.p50 = "bad_num".to_string();
142        let stats = FeeStats {
143            soroban_inclusion_fee: valid,
144            inclusion_fee: mock_valid_distribution(),
145            latest_ledger: 100,
146        };
147        let samples = parse_fee_stats(&stats).unwrap();
148        assert_eq!(samples.len(), 6);
149    }
150
151    #[test]
152    fn test_all_zero_primary_uses_fallback() {
153        let zero_distr = FeeDistribution {
154            max: "0".to_string(),
155            min: "0".to_string(),
156            mode: "0".to_string(),
157            p10: "0".to_string(),
158            p20: "0".to_string(),
159            p30: "0".to_string(),
160            p40: "0".to_string(),
161            p50: "0".to_string(),
162            p60: "0".to_string(),
163            p70: "0".to_string(),
164            p80: "0".to_string(),
165            p90: "0".to_string(),
166            p95: "0".to_string(),
167            p99: "0".to_string(),
168        };
169        let stats = FeeStats {
170            soroban_inclusion_fee: zero_distr,
171            inclusion_fee: mock_valid_distribution(),
172            latest_ledger: 100,
173        };
174        let samples = parse_fee_stats(&stats).unwrap();
175        assert_eq!(samples.len(), 7);
176        // fallback fee distribution got parsed, meaning some are > 0
177        assert!(samples.iter().any(|s| s.base_fee > 0));
178    }
179
180    #[test]
181    fn test_both_all_zero_gives_some_zero_samples() {
182        let zero_distr = FeeDistribution {
183            max: "0".to_string(),
184            min: "0".to_string(),
185            mode: "0".to_string(),
186            p10: "0".to_string(),
187            p20: "0".to_string(),
188            p30: "0".to_string(),
189            p40: "0".to_string(),
190            p50: "0".to_string(),
191            p60: "0".to_string(),
192            p70: "0".to_string(),
193            p80: "0".to_string(),
194            p90: "0".to_string(),
195            p95: "0".to_string(),
196            p99: "0".to_string(),
197        };
198        let stats = FeeStats {
199            soroban_inclusion_fee: zero_distr.clone(),
200            inclusion_fee: zero_distr,
201            latest_ledger: 100,
202        };
203        let samples = parse_fee_stats(&stats).unwrap();
204        assert!(samples.iter().all(|s| s.base_fee == 0));
205    }
206
207    #[test]
208    fn test_all_malformed_gives_none() {
209        let bad_distr = FeeDistribution {
210            max: "aaa".to_string(),
211            min: "bbb".to_string(),
212            mode: "ccc".to_string(),
213            p10: "".to_string(),
214            p20: "".to_string(),
215            p30: "".to_string(),
216            p40: "".to_string(),
217            p50: "".to_string(),
218            p60: "".to_string(),
219            p70: "".to_string(),
220            p80: "".to_string(),
221            p90: "".to_string(),
222            p95: "".to_string(),
223            p99: "zzz".to_string(),
224        };
225        let stats = FeeStats {
226            soroban_inclusion_fee: bad_distr.clone(),
227            inclusion_fee: bad_distr,
228            latest_ledger: 100,
229        };
230        assert!(parse_fee_stats(&stats).is_none());
231    }
232}