openzeppelin_relayer/models/transaction/request/
stellar.rs

1use serde::{Deserialize, Serialize};
2use utoipa::ToSchema;
3
4use crate::models::transaction::stellar::{MemoSpec, OperationSpec};
5
6#[derive(Deserialize, Serialize, ToSchema)]
7pub struct StellarTransactionRequest {
8    #[schema(nullable = true)]
9    pub source_account: Option<String>,
10    pub network: String,
11    #[schema(max_length = 100, nullable = true)]
12    pub operations: Option<Vec<OperationSpec>>,
13    #[schema(nullable = true)]
14    pub memo: Option<MemoSpec>,
15    #[schema(nullable = true)]
16    pub valid_until: Option<String>,
17    /// Pre-built transaction XDR (base64 encoded, signed or unsigned)
18    /// Mutually exclusive with operations field
19    #[schema(nullable = true)]
20    pub transaction_xdr: Option<String>,
21    /// Explicitly request fee-bump wrapper
22    /// Only valid when transaction_xdr contains a signed transaction
23    #[schema(nullable = true)]
24    pub fee_bump: Option<bool>,
25    /// Maximum fee in stroops (defaults to 0.1 XLM = 1,000,000 stroops)
26    #[schema(nullable = true)]
27    pub max_fee: Option<i64>,
28}
29
30impl StellarTransactionRequest {
31    /// Validate the transaction request according to the rules:
32    /// - Only one input type allowed (operations XOR transaction_xdr)
33    /// - If fee_bump is true, transaction_xdr must be provided
34    /// - Operations mode cannot use fee_bump
35    pub fn validate(&self) -> Result<(), crate::models::ApiError> {
36        use crate::models::ApiError;
37
38        // Check that exactly one input type is provided
39        let has_operations = self
40            .operations
41            .as_ref()
42            .map(|ops| !ops.is_empty())
43            .unwrap_or(false);
44        let has_xdr = self.transaction_xdr.is_some();
45
46        match (has_operations, has_xdr) {
47            (true, true) => {
48                return Err(ApiError::BadRequest(
49                    "Cannot provide both operations and transaction_xdr".to_string(),
50                ));
51            }
52            (false, false) => {
53                return Err(ApiError::BadRequest(
54                    "Must provide either operations or transaction_xdr".to_string(),
55                ));
56            }
57            _ => {}
58        }
59
60        // Validate fee_bump flag usage
61        if self.fee_bump == Some(true) && has_operations {
62            return Err(ApiError::BadRequest(
63                "Cannot request fee_bump with operations mode".to_string(),
64            ));
65        }
66
67        Ok(())
68    }
69}
70
71#[cfg(test)]
72mod tests {
73    use super::*;
74    use serde_json;
75
76    #[test]
77    fn test_serde_operations_mode() {
78        let json = r#"{
79            "source_account": "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF",
80            "network": "testnet",
81            "operations": []
82        }"#;
83
84        let req: StellarTransactionRequest = serde_json::from_str(json).unwrap();
85        assert_eq!(
86            req.source_account,
87            Some("GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF".to_string())
88        );
89        assert_eq!(req.operations.as_ref().map(|ops| ops.len()), Some(0));
90        assert_eq!(req.network, "testnet");
91    }
92
93    #[test]
94    fn test_validate_operations_and_xdr() {
95        let req = StellarTransactionRequest {
96            source_account: Some(
97                "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF".to_string(),
98            ),
99            network: "testnet".to_string(),
100            operations: Some(vec![OperationSpec::Payment {
101                destination: "GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB".to_string(),
102                amount: 1000000,
103                asset: crate::models::transaction::stellar::AssetSpec::Native,
104            }]),
105            memo: None,
106            valid_until: None,
107            transaction_xdr: Some("AAAAA...".to_string()),
108            fee_bump: None,
109            max_fee: None,
110        };
111
112        let result = req.validate();
113        assert!(result.is_err());
114        assert!(result
115            .unwrap_err()
116            .to_string()
117            .contains("Cannot provide both"));
118    }
119
120    #[test]
121    fn test_validate_neither_operations_nor_xdr() {
122        let req = StellarTransactionRequest {
123            source_account: Some(
124                "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF".to_string(),
125            ),
126            network: "testnet".to_string(),
127            operations: Some(vec![]),
128            memo: None,
129            valid_until: None,
130            transaction_xdr: None,
131            fee_bump: None,
132            max_fee: None,
133        };
134
135        let result = req.validate();
136        assert!(result.is_err());
137        assert!(result
138            .unwrap_err()
139            .to_string()
140            .contains("Must provide either"));
141    }
142
143    #[test]
144    fn test_validate_fee_bump_with_operations() {
145        let req = StellarTransactionRequest {
146            source_account: Some(
147                "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF".to_string(),
148            ),
149            network: "testnet".to_string(),
150            operations: Some(vec![OperationSpec::Payment {
151                destination: "GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB".to_string(),
152                amount: 1000000,
153                asset: crate::models::transaction::stellar::AssetSpec::Native,
154            }]),
155            memo: None,
156            valid_until: None,
157            transaction_xdr: None,
158            fee_bump: Some(true),
159            max_fee: None,
160        };
161
162        let result = req.validate();
163        assert!(result.is_err());
164        assert!(result
165            .unwrap_err()
166            .to_string()
167            .contains("Cannot request fee_bump with operations"));
168    }
169
170    #[test]
171    fn test_validate_fee_bump_with_xdr() {
172        let req = StellarTransactionRequest {
173            source_account: Some(
174                "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF".to_string(),
175            ),
176            network: "testnet".to_string(),
177            operations: None,
178            memo: None,
179            valid_until: None,
180            transaction_xdr: Some("AAAAA...".to_string()),
181            fee_bump: Some(true),
182            max_fee: Some(10000000),
183        };
184
185        let result = req.validate();
186        assert!(result.is_ok());
187    }
188
189    #[test]
190    fn test_validate_valid_operations_mode() {
191        let req = StellarTransactionRequest {
192            source_account: Some(
193                "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF".to_string(),
194            ),
195            network: "testnet".to_string(),
196            operations: Some(vec![OperationSpec::Payment {
197                destination: "GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB".to_string(),
198                amount: 1000000,
199                asset: crate::models::transaction::stellar::AssetSpec::Native,
200            }]),
201            memo: None,
202            valid_until: None,
203            transaction_xdr: None,
204            fee_bump: None,
205            max_fee: None,
206        };
207
208        let result = req.validate();
209        assert!(result.is_ok());
210    }
211
212    #[test]
213    fn test_validate_valid_xdr_mode() {
214        let req = StellarTransactionRequest {
215            source_account: Some(
216                "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF".to_string(),
217            ),
218            network: "testnet".to_string(),
219            operations: None,
220            memo: None,
221            valid_until: None,
222            transaction_xdr: Some("AAAAA...".to_string()),
223            fee_bump: None,
224            max_fee: None,
225        };
226
227        let result = req.validate();
228        assert!(result.is_ok());
229    }
230
231    #[test]
232    fn test_default_structure() {
233        let req = StellarTransactionRequest {
234            source_account: Some(
235                "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF".to_string(),
236            ),
237            network: "testnet".to_string(),
238            operations: Some(vec![]),
239            memo: None,
240            valid_until: None,
241            transaction_xdr: None,
242            fee_bump: None,
243            max_fee: None,
244        };
245
246        assert_eq!(
247            req.source_account,
248            Some("GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF".to_string())
249        );
250        assert_eq!(req.operations.as_ref().map(|ops| ops.len()), Some(0));
251        assert_eq!(req.network, "testnet");
252        assert!(req.memo.is_none());
253        assert!(req.valid_until.is_none());
254        assert!(req.transaction_xdr.is_none());
255        assert!(req.fee_bump.is_none());
256        assert!(req.max_fee.is_none());
257    }
258
259    #[test]
260    fn test_xdr_mode() {
261        let json = r#"{
262            "source_account": "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF",
263            "network": "testnet",
264            "operations": [],
265            "transaction_xdr": "AAAAAgAAAABjc+mbXCnvmVk4lxqVl7s0LAz5slXqmkHBg8PpH7p3DgAAAGQABpK0AAAACQAAAAAAAAAAAAAAAQAAAAAAAAABAAAAAGN0qQBW8x3mfbwGGYndt2uq4O4sZPUrDx5HlwuQke9zAAAAAAAAAAAAAA9CAAAAAA==",
266            "fee_bump": true,
267            "max_fee": 10000000
268        }"#;
269
270        let req: StellarTransactionRequest = serde_json::from_str(json).unwrap();
271        assert_eq!(
272            req.source_account,
273            Some("GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF".to_string())
274        );
275        assert!(req.transaction_xdr.is_some());
276        assert_eq!(req.fee_bump, Some(true));
277        assert_eq!(req.max_fee, Some(10000000));
278        assert_eq!(
279            req.operations.as_ref().map(|ops| ops.is_empty()),
280            Some(true)
281        );
282    }
283
284    #[test]
285    fn test_operations_with_fee_bump_is_invalid() {
286        // This test documents that operations and fee_bump together should be invalid
287        // The actual validation will happen in the request processing logic
288        let json = r#"{
289            "source_account": "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF",
290            "network": "testnet",
291            "operations": [],
292            "fee_bump": true
293        }"#;
294
295        // This should parse successfully (validation happens later)
296        let req: StellarTransactionRequest = serde_json::from_str(json).unwrap();
297        assert!(req.fee_bump == Some(true));
298        assert_eq!(
299            req.operations.as_ref().map(|ops| ops.is_empty()),
300            Some(true)
301        );
302    }
303
304    #[test]
305    fn test_xdr_mode_without_operations_field() {
306        // Test that we can deserialize without operations field
307        let json = r#"{
308            "network": "testnet",
309            "fee": 1,
310            "transaction_xdr": "AAAAAgAAAACige4lTdwSB/sto4SniEdJ2kOa2X65s5bqkd40J4DjSwAAAAEAAHAkAAAADwAAAAAAAAAAAAAAAQAAAAAAAAABAAAAAKKB7iVN3BIH+y2jhKeIR0naQ5rZfrmzluqR3jQngONLAAAAAAAAAAAAD0JAAAAAAAAAAAA="
311        }"#;
312
313        let req: StellarTransactionRequest = serde_json::from_str(json).unwrap();
314        assert_eq!(req.network, "testnet");
315        assert!(req.transaction_xdr.is_some());
316        assert!(req.operations.is_none());
317
318        // Validate should pass
319        assert!(req.validate().is_ok());
320    }
321}