openzeppelin_relayer/models/rpc/stellar/
mod.rs

1use serde::{Deserialize, Serialize};
2use utoipa::ToSchema;
3
4use crate::domain::transaction::stellar::StellarTransactionValidator;
5use crate::models::ApiError;
6use crate::{
7    domain::stellar::validation::validate_operations, models::transaction::stellar::OperationSpec,
8};
9#[derive(Debug, Deserialize, Serialize, PartialEq, ToSchema)]
10#[serde(deny_unknown_fields)]
11#[derive(Clone)]
12#[schema(as = StellarFeeEstimateRequestParams)]
13pub struct FeeEstimateRequestParams {
14    /// Pre-built transaction XDR (base64 encoded, signed or unsigned)
15    /// Mutually exclusive with operations field
16    #[schema(nullable = true)]
17    pub transaction_xdr: Option<String>,
18    /// Source account address (required when operations are provided)
19    /// For sponsored transactions, this should be the user's account address
20    #[schema(nullable = true)]
21    pub source_account: Option<String>,
22    /// Operations array to build transaction from
23    /// Mutually exclusive with transaction_xdr field
24    #[schema(nullable = true)]
25    pub operations: Option<Vec<OperationSpec>>,
26    /// Asset identifier for fee token (e.g., "native" or "USDC:GA5Z...")
27    pub fee_token: String,
28}
29
30impl FeeEstimateRequestParams {
31    /// Validate the fee estimate request according to the rules:
32    /// - Only one input type allowed (operations XOR transaction_xdr)
33    /// - fee_token must be in valid format
34    pub fn validate(&self) -> Result<(), crate::models::ApiError> {
35        // Validate fee_token structure
36        StellarTransactionValidator::validate_fee_token_structure(&self.fee_token)
37            .map_err(|e| ApiError::BadRequest(format!("Invalid fee_token structure: {e}")))?;
38
39        // Check that exactly one input type is provided
40        let has_operations = self
41            .operations
42            .as_ref()
43            .map(|ops| !ops.is_empty())
44            .unwrap_or(false);
45        let has_xdr = self.transaction_xdr.is_some();
46
47        if has_operations {
48            validate_operations(self.operations.as_ref().unwrap())
49                .map_err(|e| ApiError::BadRequest(format!("Invalid operations: {e}")))?;
50            if self.source_account.is_none() || self.source_account.as_ref().unwrap().is_empty() {
51                return Err(ApiError::BadRequest(
52                    "source_account is required when providing operations".to_string(),
53                ));
54            }
55        }
56
57        match (has_operations, has_xdr) {
58            (true, true) => {
59                return Err(ApiError::BadRequest(
60                    "Cannot provide both transaction_xdr and operations".to_string(),
61                ));
62            }
63            (false, false) => {
64                return Err(ApiError::BadRequest(
65                    "Must provide either transaction_xdr or operations".to_string(),
66                ));
67            }
68            _ => {}
69        }
70
71        Ok(())
72    }
73}
74
75#[derive(Debug, Deserialize, Serialize, PartialEq, Clone, ToSchema)]
76#[schema(as = StellarFeeEstimateResult)]
77pub struct FeeEstimateResult {
78    /// Estimated fee in token amount (decimal UI representation as string)
79    pub fee_in_token_ui: String,
80    /// Estimated fee in token amount (raw units as string)
81    pub fee_in_token: String,
82    /// Conversion rate from XLM to token (as string)
83    pub conversion_rate: String,
84}
85
86// prepareTransaction
87#[derive(Debug, Deserialize, Serialize, PartialEq, ToSchema)]
88#[serde(deny_unknown_fields)]
89#[derive(Clone)]
90#[schema(as = StellarPrepareTransactionRequestParams)]
91pub struct PrepareTransactionRequestParams {
92    /// Pre-built transaction XDR (base64 encoded, signed or unsigned)
93    /// Mutually exclusive with operations field
94    #[schema(nullable = true)]
95    pub transaction_xdr: Option<String>,
96    /// Operations array to build transaction from
97    /// Mutually exclusive with transaction_xdr field
98    #[schema(nullable = true)]
99    pub operations: Option<Vec<OperationSpec>>,
100    /// Source account address (required when operations are provided)
101    /// For gasless transactions, this should be the user's account address
102    #[schema(nullable = true)]
103    pub source_account: Option<String>,
104    /// Asset identifier for fee token
105    pub fee_token: String,
106}
107
108impl PrepareTransactionRequestParams {
109    /// Validate the prepare transaction request according to the rules:
110    /// - Only one input type allowed (operations XOR transaction_xdr)
111    /// - fee_token must be in valid format
112    /// - source_account is required when operations are provided
113    pub fn validate(&self) -> Result<(), crate::models::ApiError> {
114        // Validate fee_token structure
115        StellarTransactionValidator::validate_fee_token_structure(&self.fee_token)
116            .map_err(|e| ApiError::BadRequest(format!("Invalid fee_token structure: {e}")))?;
117
118        // Check that exactly one input type is provided
119        let has_operations = self
120            .operations
121            .as_ref()
122            .map(|ops| !ops.is_empty())
123            .unwrap_or(false);
124        let has_xdr = self.transaction_xdr.is_some();
125
126        match (has_operations, has_xdr) {
127            (true, true) => {
128                return Err(ApiError::BadRequest(
129                    "Cannot provide both transaction_xdr and operations".to_string(),
130                ));
131            }
132            (false, false) => {
133                return Err(ApiError::BadRequest(
134                    "Must provide either transaction_xdr or operations".to_string(),
135                ));
136            }
137            _ => {}
138        }
139
140        // Validate source_account is provided when operations are used
141        if has_operations {
142            validate_operations(self.operations.as_ref().unwrap())
143                .map_err(|e| ApiError::BadRequest(format!("Invalid operations: {e}")))?;
144            if self.source_account.is_none() || self.source_account.as_ref().unwrap().is_empty() {
145                return Err(ApiError::BadRequest(
146                    "source_account is required when providing operations".to_string(),
147                ));
148            }
149        }
150
151        Ok(())
152    }
153}
154
155#[derive(Debug, Deserialize, Serialize, PartialEq, Clone, ToSchema)]
156#[schema(as = StellarPrepareTransactionResult)]
157pub struct PrepareTransactionResult {
158    /// Extended transaction XDR (base64 encoded)
159    pub transaction: String,
160    /// Fee amount in token (raw units as string)
161    pub fee_in_token: String,
162    /// Fee amount in token (decimal UI representation as string)
163    pub fee_in_token_ui: String,
164    /// Fee amount in stroops (as string)
165    pub fee_in_stroops: String,
166    /// Asset identifier for fee token
167    pub fee_token: String,
168    /// Transaction validity timestamp (ISO 8601 format)
169    pub valid_until: String,
170}
171
172/// Stellar RPC method enum
173pub enum StellarRpcMethod {
174    Generic(String),
175}
176
177#[derive(Debug, Serialize, Deserialize, ToSchema, PartialEq, Clone)]
178#[serde(untagged)]
179#[schema(as = StellarRpcRequest)]
180pub enum StellarRpcRequest {
181    #[serde(rename = "rawRpcRequest")]
182    #[schema(example = "rawRpcRequest")]
183    RawRpcRequest {
184        method: String,
185        params: serde_json::Value,
186    },
187}
188
189#[derive(Debug, Serialize, Deserialize, ToSchema, PartialEq)]
190#[serde(untagged)]
191pub enum StellarRpcResult {
192    /// Raw JSON-RPC response value. Covers string or structured JSON values.
193    RawRpcResult(serde_json::Value),
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199    use crate::models::transaction::stellar::{asset::AssetSpec, OperationSpec};
200
201    const TEST_PK: &str = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF";
202    const VALID_FEE_TOKEN_NATIVE: &str = "native";
203    const VALID_FEE_TOKEN_USDC: &str =
204        "USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5";
205    const INVALID_FEE_TOKEN: &str = "invalid-token";
206
207    // FeeEstimateRequestParams tests
208
209    #[test]
210    fn test_fee_estimate_validate_with_xdr_success() {
211        let params = FeeEstimateRequestParams {
212            transaction_xdr: Some("AAAAAgAAAAA=".to_string()),
213            operations: None,
214            source_account: None,
215            fee_token: VALID_FEE_TOKEN_USDC.to_string(),
216        };
217        assert!(params.validate().is_ok());
218    }
219
220    #[test]
221    fn test_fee_estimate_validate_with_operations_success() {
222        let params = FeeEstimateRequestParams {
223            transaction_xdr: None,
224            operations: Some(vec![OperationSpec::Payment {
225                destination: TEST_PK.to_string(),
226                amount: 1000000,
227                asset: AssetSpec::Native,
228            }]),
229            source_account: Some(TEST_PK.to_string()),
230            fee_token: VALID_FEE_TOKEN_USDC.to_string(),
231        };
232        assert!(params.validate().is_ok());
233    }
234
235    #[test]
236    fn test_fee_estimate_validate_with_usdc_token_success() {
237        let params = FeeEstimateRequestParams {
238            transaction_xdr: Some("AAAAAgAAAAA=".to_string()),
239            operations: None,
240            source_account: None,
241            fee_token: VALID_FEE_TOKEN_USDC.to_string(),
242        };
243        assert!(params.validate().is_ok());
244    }
245
246    #[test]
247    fn test_fee_estimate_validate_invalid_fee_token() {
248        let params = FeeEstimateRequestParams {
249            transaction_xdr: Some("AAAAAgAAAAA=".to_string()),
250            operations: None,
251            source_account: None,
252            fee_token: INVALID_FEE_TOKEN.to_string(),
253        };
254        let result = params.validate();
255        assert!(result.is_err());
256        if let Err(ApiError::BadRequest(msg)) = result {
257            assert!(msg.contains("Invalid fee_token structure"));
258        } else {
259            panic!("Expected BadRequest error for invalid fee_token");
260        }
261    }
262
263    #[test]
264    fn test_fee_estimate_validate_both_xdr_and_operations() {
265        let params = FeeEstimateRequestParams {
266            transaction_xdr: Some("AAAAAgAAAAA=".to_string()),
267            operations: Some(vec![OperationSpec::Payment {
268                destination: TEST_PK.to_string(),
269                amount: 1000000,
270                asset: AssetSpec::Native,
271            }]),
272            source_account: Some(TEST_PK.to_string()),
273            fee_token: VALID_FEE_TOKEN_NATIVE.to_string(),
274        };
275        let result = params.validate();
276        assert!(result.is_err());
277        if let Err(ApiError::BadRequest(msg)) = result {
278            assert!(msg.contains("Cannot provide both transaction_xdr and operations"));
279        } else {
280            panic!("Expected BadRequest error for both xdr and operations");
281        }
282    }
283
284    #[test]
285    fn test_fee_estimate_validate_neither_xdr_nor_operations() {
286        let params = FeeEstimateRequestParams {
287            transaction_xdr: None,
288            operations: None,
289            source_account: None,
290            fee_token: VALID_FEE_TOKEN_NATIVE.to_string(),
291        };
292        let result = params.validate();
293        assert!(result.is_err());
294        if let Err(ApiError::BadRequest(msg)) = result {
295            assert!(msg.contains("Must provide either transaction_xdr or operations"));
296        } else {
297            panic!("Expected BadRequest error for missing both xdr and operations");
298        }
299    }
300
301    #[test]
302    fn test_fee_estimate_validate_operations_without_source_account() {
303        let params = FeeEstimateRequestParams {
304            transaction_xdr: None,
305            operations: Some(vec![OperationSpec::Payment {
306                destination: TEST_PK.to_string(),
307                amount: 1000000,
308                asset: AssetSpec::Native,
309            }]),
310            source_account: None,
311            fee_token: VALID_FEE_TOKEN_NATIVE.to_string(),
312        };
313        let result = params.validate();
314        assert!(result.is_err());
315        if let Err(ApiError::BadRequest(msg)) = result {
316            assert!(msg.contains("source_account is required when providing operations"));
317        } else {
318            panic!("Expected BadRequest error for missing source_account");
319        }
320    }
321
322    #[test]
323    fn test_fee_estimate_validate_operations_with_empty_source_account() {
324        let params = FeeEstimateRequestParams {
325            transaction_xdr: None,
326            operations: Some(vec![OperationSpec::Payment {
327                destination: TEST_PK.to_string(),
328                amount: 1000000,
329                asset: AssetSpec::Native,
330            }]),
331            source_account: Some("".to_string()),
332            fee_token: VALID_FEE_TOKEN_NATIVE.to_string(),
333        };
334        let result = params.validate();
335        assert!(result.is_err());
336        if let Err(ApiError::BadRequest(msg)) = result {
337            assert!(msg.contains("source_account is required when providing operations"));
338        } else {
339            panic!("Expected BadRequest error for empty source_account");
340        }
341    }
342
343    #[test]
344    fn test_fee_estimate_validate_empty_operations() {
345        let params = FeeEstimateRequestParams {
346            transaction_xdr: None,
347            operations: Some(vec![]),
348            source_account: Some(TEST_PK.to_string()),
349            fee_token: VALID_FEE_TOKEN_NATIVE.to_string(),
350        };
351        let result = params.validate();
352        assert!(result.is_err());
353        if let Err(ApiError::BadRequest(msg)) = result {
354            assert!(msg.contains("Must provide either transaction_xdr or operations"));
355        } else {
356            panic!("Expected BadRequest error for empty operations");
357        }
358    }
359
360    // PrepareTransactionRequestParams tests
361
362    #[test]
363    fn test_prepare_transaction_validate_with_xdr_success() {
364        let params = PrepareTransactionRequestParams {
365            transaction_xdr: Some("AAAAAgAAAAA=".to_string()),
366            operations: None,
367            source_account: None,
368            fee_token: VALID_FEE_TOKEN_USDC.to_string(),
369        };
370        assert!(params.validate().is_ok());
371    }
372
373    #[test]
374    fn test_prepare_transaction_validate_with_operations_success() {
375        let params = PrepareTransactionRequestParams {
376            transaction_xdr: None,
377            operations: Some(vec![OperationSpec::Payment {
378                destination: TEST_PK.to_string(),
379                amount: 1000000,
380                asset: AssetSpec::Native,
381            }]),
382            source_account: Some(TEST_PK.to_string()),
383            fee_token: VALID_FEE_TOKEN_USDC.to_string(),
384        };
385        assert!(params.validate().is_ok());
386    }
387
388    #[test]
389    fn test_prepare_transaction_validate_with_usdc_token_success() {
390        let params = PrepareTransactionRequestParams {
391            transaction_xdr: Some("AAAAAgAAAAA=".to_string()),
392            operations: None,
393            source_account: None,
394            fee_token: VALID_FEE_TOKEN_USDC.to_string(),
395        };
396        assert!(params.validate().is_ok());
397    }
398
399    #[test]
400    fn test_prepare_transaction_validate_invalid_fee_token() {
401        let params = PrepareTransactionRequestParams {
402            transaction_xdr: Some("AAAAAgAAAAA=".to_string()),
403            operations: None,
404            source_account: None,
405            fee_token: INVALID_FEE_TOKEN.to_string(),
406        };
407        let result = params.validate();
408        assert!(result.is_err());
409        if let Err(ApiError::BadRequest(msg)) = result {
410            assert!(msg.contains("Invalid fee_token structure"));
411        } else {
412            panic!("Expected BadRequest error for invalid fee_token");
413        }
414    }
415
416    #[test]
417    fn test_prepare_transaction_validate_both_xdr_and_operations() {
418        let params = PrepareTransactionRequestParams {
419            transaction_xdr: Some("AAAAAgAAAAA=".to_string()),
420            operations: Some(vec![OperationSpec::Payment {
421                destination: TEST_PK.to_string(),
422                amount: 1000000,
423                asset: AssetSpec::Native,
424            }]),
425            source_account: Some(TEST_PK.to_string()),
426            fee_token: VALID_FEE_TOKEN_NATIVE.to_string(),
427        };
428        let result = params.validate();
429        assert!(result.is_err());
430        if let Err(ApiError::BadRequest(msg)) = result {
431            assert!(msg.contains("Cannot provide both transaction_xdr and operations"));
432        } else {
433            panic!("Expected BadRequest error for both xdr and operations");
434        }
435    }
436
437    #[test]
438    fn test_prepare_transaction_validate_neither_xdr_nor_operations() {
439        let params = PrepareTransactionRequestParams {
440            transaction_xdr: None,
441            operations: None,
442            source_account: None,
443            fee_token: VALID_FEE_TOKEN_NATIVE.to_string(),
444        };
445        let result = params.validate();
446        assert!(result.is_err());
447        if let Err(ApiError::BadRequest(msg)) = result {
448            assert!(msg.contains("Must provide either transaction_xdr or operations"));
449        } else {
450            panic!("Expected BadRequest error for missing both xdr and operations");
451        }
452    }
453
454    #[test]
455    fn test_prepare_transaction_validate_operations_without_source_account() {
456        let params = PrepareTransactionRequestParams {
457            transaction_xdr: None,
458            operations: Some(vec![OperationSpec::Payment {
459                destination: TEST_PK.to_string(),
460                amount: 1000000,
461                asset: AssetSpec::Native,
462            }]),
463            source_account: None,
464            fee_token: VALID_FEE_TOKEN_NATIVE.to_string(),
465        };
466        let result = params.validate();
467        assert!(result.is_err());
468        if let Err(ApiError::BadRequest(msg)) = result {
469            assert!(msg.contains("source_account is required when providing operations"));
470        } else {
471            panic!("Expected BadRequest error for missing source_account");
472        }
473    }
474
475    #[test]
476    fn test_prepare_transaction_validate_operations_with_empty_source_account() {
477        let params = PrepareTransactionRequestParams {
478            transaction_xdr: None,
479            operations: Some(vec![OperationSpec::Payment {
480                destination: TEST_PK.to_string(),
481                amount: 1000000,
482                asset: AssetSpec::Native,
483            }]),
484            source_account: Some("".to_string()),
485            fee_token: VALID_FEE_TOKEN_NATIVE.to_string(),
486        };
487        let result = params.validate();
488        assert!(result.is_err());
489        if let Err(ApiError::BadRequest(msg)) = result {
490            assert!(msg.contains("source_account is required when providing operations"));
491        } else {
492            panic!("Expected BadRequest error for empty source_account");
493        }
494    }
495
496    #[test]
497    fn test_prepare_transaction_validate_empty_operations() {
498        let params = PrepareTransactionRequestParams {
499            transaction_xdr: None,
500            operations: Some(vec![]),
501            source_account: Some(TEST_PK.to_string()),
502            fee_token: VALID_FEE_TOKEN_NATIVE.to_string(),
503        };
504        let result = params.validate();
505        assert!(result.is_err());
506        // Empty operations array is treated as "no operations provided"
507        // so it falls into the "neither xdr nor operations" case
508        if let Err(ApiError::BadRequest(msg)) = result {
509            assert!(msg.contains("Must provide either transaction_xdr or operations"));
510        } else {
511            panic!("Expected BadRequest error for empty operations");
512        }
513    }
514}