Skip to main content

sdkt_core/
fee.rs

1//! Fee estimation engine for sdkt-core.
2//!
3//! Produces base-fee estimates from recent ledger data and applies
4//! network-specific multipliers so CLI and wallet users can preview the
5//! cost of a transaction before broadcasting.
6//!
7//! Fee values are expressed in **stroops** (1 XLM = 10_000_000 stroops).
8//! Conversions to XLM strings use integer math to avoid float drift.
9
10use serde::{Deserialize, Serialize};
11use std::fmt;
12use std::str::FromStr;
13
14/// One whole XLM expressed in stroops.
15pub const STROOPS_PER_XLM: u64 = 10_000_000;
16
17/// Soroban networks the DevKit can target.
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
19pub enum NetworkKind {
20    #[default]
21    Testnet,
22    Mainnet,
23    Standalone,
24}
25
26impl fmt::Display for NetworkKind {
27    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
28        let s = match self {
29            NetworkKind::Testnet => "testnet",
30            NetworkKind::Mainnet => "mainnet",
31            NetworkKind::Standalone => "standalone",
32        };
33        write!(f, "{s}")
34    }
35}
36
37impl FromStr for NetworkKind {
38    type Err = FeeError;
39    fn from_str(s: &str) -> Result<Self, Self::Err> {
40        match s.to_ascii_lowercase().as_str() {
41            "testnet" => Ok(NetworkKind::Testnet),
42            "mainnet" => Ok(NetworkKind::Mainnet),
43            "standalone" => Ok(NetworkKind::Standalone),
44            other => Err(FeeError::UnknownNetwork(other.to_string())),
45        }
46    }
47}
48
49impl NetworkKind {
50    /// Platform-default fee multiplier applied on top of the ledger base fee.
51    pub fn fee_multiplier(self) -> f64 {
52        match self {
53            NetworkKind::Testnet => 1.0,
54            NetworkKind::Mainnet => 1.25,
55            NetworkKind::Standalone => 0.10,
56        }
57    }
58}
59
60/// Error type returned by the fee engine.
61#[derive(Debug, PartialEq)]
62pub enum FeeError {
63    EmptyLedger,
64    UnknownNetwork(String),
65    Conversion,
66}
67
68impl fmt::Display for FeeError {
69    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
70        match self {
71            FeeError::EmptyLedger => {
72                write!(f, "no ledger data supplied for fee estimation")
73            }
74            FeeError::UnknownNetwork(n) => {
75                write!(f, "unknown Soroban network: {n}")
76            }
77            FeeError::Conversion => {
78                write!(f, "integer conversion failure")
79            }
80        }
81    }
82}
83
84impl std::error::Error for FeeError {}
85
86/// Fee engine configuration.
87#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
88pub struct FeeConfig {
89    pub network: NetworkKind,
90    pub multiplier_override: Option<f64>,
91}
92
93impl FeeConfig {
94    pub fn testnet() -> Self {
95        Self {
96            network: NetworkKind::Testnet,
97            multiplier_override: None,
98        }
99    }
100    pub fn mainnet() -> Self {
101        Self {
102            network: NetworkKind::Mainnet,
103            multiplier_override: None,
104        }
105    }
106    pub fn standalone() -> Self {
107        Self {
108            network: NetworkKind::Standalone,
109            multiplier_override: None,
110        }
111    }
112
113    /// Effective multiplier (override or network default).
114    pub fn multiplier(&self) -> f64 {
115        self.multiplier_override
116            .unwrap_or_else(|| self.network.fee_multiplier())
117    }
118}
119
120/// Raw fee reading pulled from a recent ledger.
121#[derive(Debug, Clone, PartialEq)]
122pub struct LedgerFeeSample {
123    pub base_fee: u32, // stroops, as reported by the ledger
124}
125
126/// Fee estimator — stateless, fed samples from recent ledgers.
127pub struct FeeEstimator {
128    config: FeeConfig,
129}
130
131impl FeeEstimator {
132    pub fn new(config: FeeConfig) -> Self {
133        Self { config }
134    }
135
136    /// Median base fee from a slice of ledger samples.
137    pub fn estimate_base_fee(samples: &[LedgerFeeSample]) -> Result<u32, FeeError> {
138        if samples.is_empty() {
139            return Err(FeeError::EmptyLedger);
140        }
141        let mut sorted: Vec<u32> = samples.iter().map(|s| s.base_fee).collect();
142        sorted.sort_unstable();
143        // lower median for even-length samples
144        let mid = (sorted.len() - 1) / 2;
145        Ok(sorted[mid])
146    }
147
148    /// Final estimated fee in stroops = base_fee * multiplier.
149    pub fn estimate_stroops(&self, samples: &[LedgerFeeSample]) -> Result<u64, FeeError> {
150        let base = Self::estimate_base_fee(samples)? as u64;
151        let mult = self.config.multiplier();
152        let fee = base as f64 * mult;
153        if fee > u64::MAX as f64 {
154            return Err(FeeError::Conversion);
155        }
156        Ok(fee.round() as u64)
157    }
158
159    /// Final estimated fee in XLM string (exact integer fraction).
160    pub fn estimate_xlm(&self, samples: &[LedgerFeeSample]) -> Result<String, FeeError> {
161        let stroops = self.estimate_stroops(samples)?;
162        let whole = stroops / STROOPS_PER_XLM;
163        let frac = stroops % STROOPS_PER_XLM;
164        if frac == 0 {
165            Ok(format!("{whole}"))
166        } else {
167            let s = format!("{whole}.{frac:07}");
168            Ok(s.trim_end_matches('0').trim_end_matches('.').to_string())
169        }
170    }
171
172    /// Single-call convenience returning both units.
173    pub fn estimate(&self, samples: &[LedgerFeeSample]) -> Result<(u64, String), FeeError> {
174        let stroops = self.estimate_stroops(samples)?;
175        let xlm = self.estimate_xlm(samples)?;
176        Ok((stroops, xlm))
177    }
178}
179
180impl Default for FeeEstimator {
181    fn default() -> Self {
182        Self::new(FeeConfig::default())
183    }
184}
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189
190    fn sample(s: u32) -> LedgerFeeSample {
191        LedgerFeeSample { base_fee: s }
192    }
193
194    #[test]
195    fn test_stroops_per_xlm() {
196        assert_eq!(STROOPS_PER_XLM, 10_000_000);
197    }
198
199    #[test]
200    fn test_network_display() {
201        assert_eq!(NetworkKind::Testnet.to_string(), "testnet");
202        assert_eq!(NetworkKind::Mainnet.to_string(), "mainnet");
203        assert_eq!(NetworkKind::Standalone.to_string(), "standalone");
204    }
205
206    #[test]
207    fn test_network_from_str() {
208        assert_eq!(
209            "testnet".parse::<NetworkKind>().unwrap(),
210            NetworkKind::Testnet
211        );
212        assert_eq!(
213            "MAINNET".parse::<NetworkKind>().unwrap(),
214            NetworkKind::Mainnet
215        );
216        assert!("fakenet".parse::<NetworkKind>().is_err());
217    }
218
219    #[test]
220    fn test_network_multipliers() {
221        assert_eq!(NetworkKind::Testnet.fee_multiplier(), 1.0);
222        assert_eq!(NetworkKind::Mainnet.fee_multiplier(), 1.25);
223        assert_eq!(NetworkKind::Standalone.fee_multiplier(), 0.10);
224    }
225
226    #[test]
227    fn test_estimate_base_fee_median() {
228        let samples = vec![sample(100), sample(200), sample(300)];
229        assert_eq!(FeeEstimator::estimate_base_fee(&samples).unwrap(), 200);
230    }
231
232    #[test]
233    fn test_estimate_base_fee_even_count() {
234        let samples = vec![sample(100), sample(200), sample(300), sample(400)];
235        assert_eq!(FeeEstimator::estimate_base_fee(&samples).unwrap(), 200);
236    }
237
238    #[test]
239    fn test_estimate_base_fee_empty() {
240        let empty: [LedgerFeeSample; 0] = [];
241        let err = FeeEstimator::estimate_base_fee(&empty).unwrap_err();
242        assert_eq!(err, FeeError::EmptyLedger);
243    }
244
245    #[test]
246    fn test_testnet_fee_stroops() {
247        let est = FeeEstimator::new(FeeConfig::testnet());
248        let samples = vec![sample(100)];
249        assert_eq!(est.estimate_stroops(&samples).unwrap(), 100);
250    }
251
252    #[test]
253    fn test_mainnet_fee_stroops() {
254        let est = FeeEstimator::new(FeeConfig::mainnet());
255        let samples = vec![sample(100)];
256        assert_eq!(est.estimate_stroops(&samples).unwrap(), 125);
257    }
258
259    #[test]
260    fn test_standalone_fee_stroops() {
261        let est = FeeEstimator::new(FeeConfig::standalone());
262        let samples = vec![sample(100)];
263        assert_eq!(est.estimate_stroops(&samples).unwrap(), 10);
264    }
265
266    #[test]
267    fn test_override_multiplier() {
268        let cfg = FeeConfig {
269            network: NetworkKind::Testnet,
270            multiplier_override: Some(2.5),
271        };
272        let est = FeeEstimator::new(cfg);
273        let samples = vec![sample(100)];
274        assert_eq!(est.estimate_stroops(&samples).unwrap(), 250);
275    }
276
277    #[test]
278    fn test_estimate_xlm_exact() {
279        let est = FeeEstimator::new(FeeConfig::standalone());
280        let samples = vec![sample(100)];
281        assert_eq!(est.estimate_xlm(&samples).unwrap(), "0.000001");
282    }
283
284    #[test]
285    fn test_estimate_xlm_whole() {
286        let est = FeeEstimator::new(FeeConfig::mainnet());
287        let samples = vec![sample(8_000_000)];
288        assert_eq!(est.estimate_xlm(&samples).unwrap(), "1");
289    }
290
291    #[test]
292    fn test_estimate_combined() {
293        let est = FeeEstimator::new(FeeConfig::mainnet());
294        let samples = vec![sample(100)];
295        let (stroops, xlm) = est.estimate(&samples).unwrap();
296        assert_eq!(stroops, 125);
297        assert_eq!(xlm, "0.0000125");
298    }
299
300    #[test]
301    fn test_real_testnet_sample() {
302        let samples = vec![sample(100)];
303        let est = FeeEstimator::new(FeeConfig::testnet());
304        let (stroops, xlm) = est.estimate(&samples).unwrap();
305        assert_eq!(stroops, 100);
306        assert_eq!(xlm, "0.00001");
307    }
308
309    #[test]
310    fn test_multiple_samples() {
311        let samples = vec![sample(100), sample(120), sample(90), sample(110)];
312        let est = FeeEstimator::new(FeeConfig::testnet());
313        let (stroops, _xlm) = est.estimate(&samples).unwrap();
314        assert_eq!(stroops, 100); // sorted [90,100,110,120] -> idx1=100
315    }
316}