openzeppelin_relayer/domain/relayer/stellar/
utils.rs

1//! Stellar relayer utility functions.
2//!
3//! Generic helpers for ledger math, fee slippage, and other reusable logic
4//! shared across gas abstraction and related code.
5
6use crate::constants::STELLAR_LEDGER_TIME_SECONDS;
7use crate::models::RelayerError;
8use crate::services::provider::StellarProviderTrait;
9
10/// Default slippage tolerance for max_fee_amount in basis points (500 = 5%).
11/// Allows fee fluctuation between quote and execution time.
12pub const DEFAULT_SOROBAN_MAX_FEE_SLIPPAGE_BPS: u64 = 500;
13
14/// Apply slippage tolerance to max_fee_amount for FeeForwarder.
15///
16/// The FeeForwarder contract has separate `fee_amount` (what relayer charges at execution)
17/// and `max_fee_amount` (user's authorized ceiling). Setting them equal means no room for
18/// fee fluctuation between quote and execution. This function applies a slippage buffer
19/// to allow for price movement.
20///
21/// # Arguments
22/// * `fee_in_token` - The calculated fee amount in token units
23/// * `slippage_bps` - Slippage in basis points (default: [`DEFAULT_SOROBAN_MAX_FEE_SLIPPAGE_BPS`])
24///
25/// # Returns
26/// The max_fee_amount with slippage buffer applied as i128
27pub fn apply_max_fee_slippage_with_bps(fee_in_token: u64, slippage_bps: u64) -> i128 {
28    let fee_with_slippage = (fee_in_token as u128) * (10000 + slippage_bps as u128) / 10000;
29    fee_with_slippage as i128
30}
31
32/// Apply default slippage to max_fee_amount (uses [`DEFAULT_SOROBAN_MAX_FEE_SLIPPAGE_BPS`]).
33pub fn apply_max_fee_slippage(fee_in_token: u64) -> i128 {
34    apply_max_fee_slippage_with_bps(fee_in_token, DEFAULT_SOROBAN_MAX_FEE_SLIPPAGE_BPS)
35}
36
37/// Calculate the expiration ledger for authorization.
38///
39/// Uses the provider to get the current ledger sequence and adds the
40/// specified validity duration (in seconds) converted to ledger count.
41pub async fn get_expiration_ledger<P>(
42    provider: &P,
43    validity_seconds: u64,
44) -> Result<u32, RelayerError>
45where
46    P: StellarProviderTrait + Send + Sync,
47{
48    let current_ledger = provider
49        .get_latest_ledger()
50        .await
51        .map_err(|e| RelayerError::Internal(format!("Failed to get latest ledger: {e}")))?;
52
53    let mut ledgers_to_add = validity_seconds.div_ceil(STELLAR_LEDGER_TIME_SECONDS);
54    if ledgers_to_add == 0 {
55        ledgers_to_add = 1;
56    }
57    Ok(current_ledger
58        .sequence
59        .saturating_add(ledgers_to_add as u32))
60}
61
62#[cfg(test)]
63mod tests {
64    use super::*;
65    use std::future::ready;
66
67    use crate::services::provider::MockStellarProviderTrait;
68
69    // ============================================================================
70    // Tests for apply_max_fee_slippage
71    // ============================================================================
72
73    #[test]
74    fn test_apply_max_fee_slippage_basic() {
75        // 5% slippage on 10000 should give 10500
76        let result = apply_max_fee_slippage(10000);
77        assert_eq!(result, 10500);
78    }
79
80    #[test]
81    fn test_apply_max_fee_slippage_zero() {
82        let result = apply_max_fee_slippage(0);
83        assert_eq!(result, 0);
84    }
85
86    #[test]
87    fn test_apply_max_fee_slippage_large_value() {
88        let large_fee: u64 = 1_000_000_000_000;
89        let result = apply_max_fee_slippage(large_fee);
90        assert_eq!(result, 1_050_000_000_000i128);
91    }
92
93    #[test]
94    fn test_apply_max_fee_slippage_small_value() {
95        let result = apply_max_fee_slippage(100);
96        assert_eq!(result, 105);
97    }
98
99    // ============================================================================
100    // Tests for apply_max_fee_slippage_with_bps (direct)
101    // ============================================================================
102
103    #[test]
104    fn test_apply_max_fee_slippage_with_bps_zero_slippage() {
105        // 0 BPS = no slippage
106        let result = apply_max_fee_slippage_with_bps(10000, 0);
107        assert_eq!(result, 10000);
108    }
109
110    #[test]
111    fn test_apply_max_fee_slippage_with_bps_one_percent() {
112        // 100 BPS = 1%
113        let result = apply_max_fee_slippage_with_bps(10000, 100);
114        assert_eq!(result, 10100);
115    }
116
117    #[test]
118    fn test_apply_max_fee_slippage_with_bps_ten_percent() {
119        // 1000 BPS = 10%
120        let result = apply_max_fee_slippage_with_bps(10000, 1000);
121        assert_eq!(result, 11000);
122    }
123
124    #[test]
125    fn test_apply_max_fee_slippage_with_bps_hundred_percent() {
126        // 10000 BPS = 100% (double)
127        let result = apply_max_fee_slippage_with_bps(10000, 10000);
128        assert_eq!(result, 20000);
129    }
130
131    #[test]
132    fn test_apply_max_fee_slippage_with_bps_zero_fee() {
133        let result = apply_max_fee_slippage_with_bps(0, 500);
134        assert_eq!(result, 0);
135    }
136
137    #[test]
138    fn test_apply_max_fee_slippage_with_bps_large_fee() {
139        let large_fee: u64 = 1_000_000_000_000;
140        // 5% slippage
141        let result = apply_max_fee_slippage_with_bps(large_fee, 500);
142        assert_eq!(result, 1_050_000_000_000i128);
143    }
144
145    #[test]
146    fn test_apply_max_fee_slippage_with_bps_small_fee_rounds_down() {
147        // 1 unit with 1 BPS (0.01%) — should round down to 1
148        let result = apply_max_fee_slippage_with_bps(1, 1);
149        // (1 * 10001) / 10000 = 1 (integer division)
150        assert_eq!(result, 1);
151    }
152
153    // ============================================================================
154    // Tests for get_expiration_ledger
155    // ============================================================================
156
157    #[tokio::test]
158    async fn test_get_expiration_ledger_success() {
159        let mut provider = MockStellarProviderTrait::new();
160        provider.expect_get_latest_ledger().returning(|| {
161            Box::pin(ready(Ok(
162                soroban_rs::stellar_rpc_client::GetLatestLedgerResponse {
163                    id: "test".to_string(),
164                    protocol_version: 20,
165                    sequence: 1000,
166                },
167            )))
168        });
169
170        let result = get_expiration_ledger(&provider, 300).await;
171        assert!(result.is_ok());
172        let expiration = result.unwrap();
173        assert_eq!(expiration, 1060); // 1000 + 60
174    }
175
176    #[tokio::test]
177    async fn test_get_expiration_ledger_zero_seconds() {
178        let mut provider = MockStellarProviderTrait::new();
179        provider.expect_get_latest_ledger().returning(|| {
180            Box::pin(ready(Ok(
181                soroban_rs::stellar_rpc_client::GetLatestLedgerResponse {
182                    id: "test".to_string(),
183                    protocol_version: 20,
184                    sequence: 1000,
185                },
186            )))
187        });
188
189        let result = get_expiration_ledger(&provider, 0).await;
190        assert!(result.is_ok());
191        let expiration = result.unwrap();
192        assert_eq!(expiration, 1001); // 1000 + 1 (minimum)
193    }
194
195    #[tokio::test]
196    async fn test_get_expiration_ledger_provider_error() {
197        use crate::services::provider::ProviderError;
198
199        let mut provider = MockStellarProviderTrait::new();
200        provider.expect_get_latest_ledger().returning(|| {
201            Box::pin(ready(Err(ProviderError::Other(
202                "network error".to_string(),
203            ))))
204        });
205
206        let result = get_expiration_ledger(&provider, 300).await;
207        assert!(result.is_err());
208        match result.unwrap_err() {
209            RelayerError::Internal(msg) => {
210                assert!(msg.contains("Failed to get latest ledger"));
211            }
212            _ => panic!("Expected Internal error"),
213        }
214    }
215
216    #[tokio::test]
217    async fn test_get_expiration_ledger_non_divisible_seconds() {
218        // 7 seconds / 5 seconds per ledger = 2 ledgers (div_ceil)
219        let mut provider = MockStellarProviderTrait::new();
220        provider.expect_get_latest_ledger().returning(|| {
221            Box::pin(ready(Ok(
222                soroban_rs::stellar_rpc_client::GetLatestLedgerResponse {
223                    id: "test".to_string(),
224                    protocol_version: 20,
225                    sequence: 1000,
226                },
227            )))
228        });
229
230        let result = get_expiration_ledger(&provider, 7).await;
231        assert!(result.is_ok());
232        let expiration = result.unwrap();
233        assert_eq!(expiration, 1002); // 1000 + ceil(7/5) = 1000 + 2
234    }
235
236    #[tokio::test]
237    async fn test_get_expiration_ledger_one_second() {
238        let mut provider = MockStellarProviderTrait::new();
239        provider.expect_get_latest_ledger().returning(|| {
240            Box::pin(ready(Ok(
241                soroban_rs::stellar_rpc_client::GetLatestLedgerResponse {
242                    id: "test".to_string(),
243                    protocol_version: 20,
244                    sequence: 500,
245                },
246            )))
247        });
248
249        let result = get_expiration_ledger(&provider, 1).await;
250        assert!(result.is_ok());
251        let expiration = result.unwrap();
252        assert_eq!(expiration, 501); // 500 + ceil(1/5) = 500 + 1
253    }
254
255    #[tokio::test]
256    async fn test_get_expiration_ledger_sequence_near_max() {
257        // Test saturating_add behavior near u32::MAX
258        let mut provider = MockStellarProviderTrait::new();
259        provider.expect_get_latest_ledger().returning(|| {
260            Box::pin(ready(Ok(
261                soroban_rs::stellar_rpc_client::GetLatestLedgerResponse {
262                    id: "test".to_string(),
263                    protocol_version: 20,
264                    sequence: u32::MAX - 1,
265                },
266            )))
267        });
268
269        let result = get_expiration_ledger(&provider, 300).await;
270        assert!(result.is_ok());
271        let expiration = result.unwrap();
272        // saturating_add should cap at u32::MAX
273        assert_eq!(expiration, u32::MAX);
274    }
275
276    #[tokio::test]
277    async fn test_get_expiration_ledger_exact_ledger_time() {
278        // Exactly STELLAR_LEDGER_TIME_SECONDS (5 seconds) = 1 ledger
279        let mut provider = MockStellarProviderTrait::new();
280        provider.expect_get_latest_ledger().returning(|| {
281            Box::pin(ready(Ok(
282                soroban_rs::stellar_rpc_client::GetLatestLedgerResponse {
283                    id: "test".to_string(),
284                    protocol_version: 20,
285                    sequence: 2000,
286                },
287            )))
288        });
289
290        let result = get_expiration_ledger(&provider, STELLAR_LEDGER_TIME_SECONDS).await;
291        assert!(result.is_ok());
292        let expiration = result.unwrap();
293        assert_eq!(expiration, 2001); // 2000 + 1
294    }
295}