1use crate::{RpcError, SorobanRpcClient};
7use sdkt_core::{FeeConfig, FeeEstimator, LedgerFeeSample};
8use serde::Deserialize;
9
10pub async fn get_fee_stats(client: &SorobanRpcClient) -> Result<FeeStats, RpcError> {
12 client.request("getFeeStats", ()).await
13}
14
15fn 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
28fn 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
46pub 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#[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#[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); assert_eq!(samples[5].base_fee, 150); }
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 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}