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    /// For Soroban gas abstraction: submit the transaction XDR from sponsored/build response
20    /// with the user's signed auth entry updated inside.
21    #[schema(nullable = true)]
22    pub transaction_xdr: Option<String>,
23    /// Explicitly request fee-bump wrapper
24    /// Only valid when transaction_xdr contains a signed transaction
25    #[schema(nullable = true)]
26    pub fee_bump: Option<bool>,
27    /// Maximum fee in stroops (defaults to 0.1 XLM = 1,000,000 stroops)
28    #[schema(nullable = true)]
29    pub max_fee: Option<i64>,
30    /// Signed Soroban authorization entry (base64 encoded SorobanAuthorizationEntry XDR)
31    /// Used for Soroban gas abstraction: contains the user's signed auth entry from /build response.
32    /// When provided, transaction_xdr must also be provided (the FeeForwarder transaction from /build).
33    /// The relayer will inject this signed auth entry into the transaction before submitting.
34    #[schema(nullable = true)]
35    pub signed_auth_entry: Option<String>,
36}
37
38impl StellarTransactionRequest {
39    /// Validate the transaction request according to the rules:
40    /// - Only one input type allowed (operations XOR transaction_xdr)
41    /// - If fee_bump is true, transaction_xdr must be provided
42    /// - Operations mode cannot use fee_bump
43    /// - If signed_auth_entry is provided, transaction_xdr must also be provided
44    /// - signed_auth_entry and fee_bump are mutually exclusive
45    pub fn validate(&self) -> Result<(), crate::models::ApiError> {
46        use crate::models::ApiError;
47
48        // Check that exactly one input type is provided
49        let has_operations = self
50            .operations
51            .as_ref()
52            .map(|ops| !ops.is_empty())
53            .unwrap_or(false);
54        let has_xdr = self.transaction_xdr.is_some();
55        let has_signed_auth_entry = self.signed_auth_entry.is_some();
56
57        match (has_operations, has_xdr) {
58            (true, true) => {
59                return Err(ApiError::BadRequest(
60                    "Cannot provide both operations and transaction_xdr".to_string(),
61                ));
62            }
63            (false, false) => {
64                return Err(ApiError::BadRequest(
65                    "Must provide either operations or transaction_xdr".to_string(),
66                ));
67            }
68            _ => {}
69        }
70
71        // Validate fee_bump flag usage
72        if self.fee_bump == Some(true) && has_operations {
73            return Err(ApiError::BadRequest(
74                "Cannot request fee_bump with operations mode".to_string(),
75            ));
76        }
77
78        // Validate signed_auth_entry usage (Soroban gas abstraction)
79        if has_signed_auth_entry {
80            if !has_xdr {
81                return Err(ApiError::BadRequest(
82                    "signed_auth_entry requires transaction_xdr to be provided".to_string(),
83                ));
84            }
85            if self.fee_bump == Some(true) {
86                return Err(ApiError::BadRequest(
87                    "Cannot use both signed_auth_entry and fee_bump".to_string(),
88                ));
89            }
90        }
91
92        Ok(())
93    }
94}
95
96#[cfg(test)]
97mod tests {
98    use super::*;
99    use serde_json;
100
101    #[test]
102    fn test_serde_operations_mode() {
103        let json = r#"{
104            "source_account": "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF",
105            "network": "testnet",
106            "operations": []
107        }"#;
108
109        let req: StellarTransactionRequest = serde_json::from_str(json).unwrap();
110        assert_eq!(
111            req.source_account,
112            Some("GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF".to_string())
113        );
114        assert_eq!(req.operations.as_ref().map(|ops| ops.len()), Some(0));
115        assert_eq!(req.network, "testnet");
116    }
117
118    #[test]
119    fn test_validate_operations_and_xdr() {
120        let req = StellarTransactionRequest {
121            source_account: Some(
122                "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF".to_string(),
123            ),
124            network: "testnet".to_string(),
125            operations: Some(vec![OperationSpec::Payment {
126                destination: "GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB".to_string(),
127                amount: 1000000,
128                asset: crate::models::transaction::stellar::AssetSpec::Native,
129            }]),
130            memo: None,
131            valid_until: None,
132            transaction_xdr: Some("AAAAA...".to_string()),
133            fee_bump: None,
134            max_fee: None,
135            signed_auth_entry: None,
136        };
137
138        let result = req.validate();
139        assert!(result.is_err());
140        assert!(result
141            .unwrap_err()
142            .to_string()
143            .contains("Cannot provide both"));
144    }
145
146    #[test]
147    fn test_validate_neither_operations_nor_xdr() {
148        let req = StellarTransactionRequest {
149            source_account: Some(
150                "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF".to_string(),
151            ),
152            network: "testnet".to_string(),
153            operations: Some(vec![]),
154            memo: None,
155            valid_until: None,
156            transaction_xdr: None,
157            fee_bump: None,
158            max_fee: None,
159            signed_auth_entry: None,
160        };
161
162        let result = req.validate();
163        assert!(result.is_err());
164        assert!(result
165            .unwrap_err()
166            .to_string()
167            .contains("Must provide either"));
168    }
169
170    #[test]
171    fn test_validate_fee_bump_with_operations() {
172        let req = StellarTransactionRequest {
173            source_account: Some(
174                "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF".to_string(),
175            ),
176            network: "testnet".to_string(),
177            operations: Some(vec![OperationSpec::Payment {
178                destination: "GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB".to_string(),
179                amount: 1000000,
180                asset: crate::models::transaction::stellar::AssetSpec::Native,
181            }]),
182            memo: None,
183            valid_until: None,
184            transaction_xdr: None,
185            fee_bump: Some(true),
186            max_fee: None,
187            signed_auth_entry: None,
188        };
189
190        let result = req.validate();
191        assert!(result.is_err());
192        assert!(result
193            .unwrap_err()
194            .to_string()
195            .contains("Cannot request fee_bump with operations"));
196    }
197
198    #[test]
199    fn test_validate_fee_bump_with_xdr() {
200        let req = StellarTransactionRequest {
201            source_account: Some(
202                "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF".to_string(),
203            ),
204            network: "testnet".to_string(),
205            operations: None,
206            memo: None,
207            valid_until: None,
208            transaction_xdr: Some("AAAAA...".to_string()),
209            fee_bump: Some(true),
210            max_fee: Some(10000000),
211            signed_auth_entry: None,
212        };
213
214        let result = req.validate();
215        assert!(result.is_ok());
216    }
217
218    #[test]
219    fn test_validate_valid_operations_mode() {
220        let req = StellarTransactionRequest {
221            source_account: Some(
222                "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF".to_string(),
223            ),
224            network: "testnet".to_string(),
225            operations: Some(vec![OperationSpec::Payment {
226                destination: "GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB".to_string(),
227                amount: 1000000,
228                asset: crate::models::transaction::stellar::AssetSpec::Native,
229            }]),
230            memo: None,
231            valid_until: None,
232            transaction_xdr: None,
233            fee_bump: None,
234            max_fee: None,
235            signed_auth_entry: None,
236        };
237
238        let result = req.validate();
239        assert!(result.is_ok());
240    }
241
242    #[test]
243    fn test_validate_valid_xdr_mode() {
244        let req = StellarTransactionRequest {
245            source_account: Some(
246                "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF".to_string(),
247            ),
248            network: "testnet".to_string(),
249            operations: None,
250            memo: None,
251            valid_until: None,
252            transaction_xdr: Some("AAAAA...".to_string()),
253            fee_bump: None,
254            max_fee: None,
255            signed_auth_entry: None,
256        };
257
258        let result = req.validate();
259        assert!(result.is_ok());
260    }
261
262    #[test]
263    fn test_default_structure() {
264        let req = StellarTransactionRequest {
265            source_account: Some(
266                "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF".to_string(),
267            ),
268            network: "testnet".to_string(),
269            operations: Some(vec![]),
270            memo: None,
271            valid_until: None,
272            transaction_xdr: None,
273            fee_bump: None,
274            max_fee: None,
275            signed_auth_entry: None,
276        };
277
278        assert_eq!(
279            req.source_account,
280            Some("GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF".to_string())
281        );
282        assert_eq!(req.operations.as_ref().map(|ops| ops.len()), Some(0));
283        assert_eq!(req.network, "testnet");
284        assert!(req.memo.is_none());
285        assert!(req.valid_until.is_none());
286        assert!(req.transaction_xdr.is_none());
287        assert!(req.fee_bump.is_none());
288        assert!(req.max_fee.is_none());
289        assert!(req.signed_auth_entry.is_none());
290    }
291
292    #[test]
293    fn test_xdr_mode() {
294        let json = r#"{
295            "source_account": "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF",
296            "network": "testnet",
297            "operations": [],
298            "transaction_xdr": "AAAAAgAAAABjc+mbXCnvmVk4lxqVl7s0LAz5slXqmkHBg8PpH7p3DgAAAGQABpK0AAAACQAAAAAAAAAAAAAAAQAAAAAAAAABAAAAAGN0qQBW8x3mfbwGGYndt2uq4O4sZPUrDx5HlwuQke9zAAAAAAAAAAAAAA9CAAAAAA==",
299            "fee_bump": true,
300            "max_fee": 10000000
301        }"#;
302
303        let req: StellarTransactionRequest = serde_json::from_str(json).unwrap();
304        assert_eq!(
305            req.source_account,
306            Some("GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF".to_string())
307        );
308        assert!(req.transaction_xdr.is_some());
309        assert_eq!(req.fee_bump, Some(true));
310        assert_eq!(req.max_fee, Some(10000000));
311        assert_eq!(
312            req.operations.as_ref().map(|ops| ops.is_empty()),
313            Some(true)
314        );
315    }
316
317    #[test]
318    fn test_operations_with_fee_bump_is_invalid() {
319        // This test documents that operations and fee_bump together should be invalid
320        // The actual validation will happen in the request processing logic
321        let json = r#"{
322            "source_account": "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF",
323            "network": "testnet",
324            "operations": [],
325            "fee_bump": true
326        }"#;
327
328        // This should parse successfully (validation happens later)
329        let req: StellarTransactionRequest = serde_json::from_str(json).unwrap();
330        assert!(req.fee_bump == Some(true));
331        assert_eq!(
332            req.operations.as_ref().map(|ops| ops.is_empty()),
333            Some(true)
334        );
335    }
336
337    #[test]
338    fn test_xdr_mode_without_operations_field() {
339        // Test that we can deserialize without operations field
340        let json = r#"{
341            "network": "testnet",
342            "fee": 1,
343            "transaction_xdr": "AAAAAgAAAACige4lTdwSB/sto4SniEdJ2kOa2X65s5bqkd40J4DjSwAAAAEAAHAkAAAADwAAAAAAAAAAAAAAAQAAAAAAAAABAAAAAKKB7iVN3BIH+y2jhKeIR0naQ5rZfrmzluqR3jQngONLAAAAAAAAAAAAD0JAAAAAAAAAAAA="
344        }"#;
345
346        let req: StellarTransactionRequest = serde_json::from_str(json).unwrap();
347        assert_eq!(req.network, "testnet");
348        assert!(req.transaction_xdr.is_some());
349        assert!(req.operations.is_none());
350
351        // Validate should pass
352        assert!(req.validate().is_ok());
353    }
354
355    #[test]
356    fn test_validate_signed_auth_entry_with_xdr() {
357        // Soroban gas abstraction: signed_auth_entry with transaction_xdr is valid
358        let req = StellarTransactionRequest {
359            source_account: None,
360            network: "testnet".to_string(),
361            operations: None,
362            memo: None,
363            valid_until: None,
364            transaction_xdr: Some("AAAAA...".to_string()),
365            fee_bump: None,
366            max_fee: None,
367            signed_auth_entry: Some("BBBBB...".to_string()),
368        };
369
370        let result = req.validate();
371        assert!(result.is_ok());
372    }
373
374    #[test]
375    fn test_validate_signed_auth_entry_without_xdr() {
376        // signed_auth_entry without transaction_xdr should fail
377        let req = StellarTransactionRequest {
378            source_account: None,
379            network: "testnet".to_string(),
380            operations: Some(vec![OperationSpec::Payment {
381                destination: "GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB".to_string(),
382                amount: 1000000,
383                asset: crate::models::transaction::stellar::AssetSpec::Native,
384            }]),
385            memo: None,
386            valid_until: None,
387            transaction_xdr: None,
388            fee_bump: None,
389            max_fee: None,
390            signed_auth_entry: Some("BBBBB...".to_string()),
391        };
392
393        let result = req.validate();
394        assert!(result.is_err());
395        assert!(result
396            .unwrap_err()
397            .to_string()
398            .contains("signed_auth_entry requires transaction_xdr"));
399    }
400
401    #[test]
402    fn test_validate_signed_auth_entry_with_fee_bump() {
403        // signed_auth_entry with fee_bump should fail (mutually exclusive)
404        let req = StellarTransactionRequest {
405            source_account: None,
406            network: "testnet".to_string(),
407            operations: None,
408            memo: None,
409            valid_until: None,
410            transaction_xdr: Some("AAAAA...".to_string()),
411            fee_bump: Some(true),
412            max_fee: Some(10000000),
413            signed_auth_entry: Some("BBBBB...".to_string()),
414        };
415
416        let result = req.validate();
417        assert!(result.is_err());
418        assert!(result
419            .unwrap_err()
420            .to_string()
421            .contains("Cannot use both signed_auth_entry and fee_bump"));
422    }
423
424    #[test]
425    fn test_serde_signed_auth_entry() {
426        // Test JSON deserialization with signed_auth_entry
427        let json = r#"{
428            "network": "testnet",
429            "transaction_xdr": "AAAAAgAAAACige4lTdwSB/sto4SniEdJ2kOa2X65s5bqkd40J4DjSwAAAAEAAHAkAAAADwAAAAAAAAAAAAAAAQAAAAAAAAABAAAAAKKB7iVN3BIH+y2jhKeIR0naQ5rZfrmzluqR3jQngONLAAAAAAAAAAAAD0JAAAAAAAAAAAA=",
430            "signed_auth_entry": "AAAAAQAAAAEAAAAHYm9pbGVycz..."
431        }"#;
432
433        let req: StellarTransactionRequest = serde_json::from_str(json).unwrap();
434        assert_eq!(req.network, "testnet");
435        assert!(req.transaction_xdr.is_some());
436        assert!(req.signed_auth_entry.is_some());
437        assert_eq!(
438            req.signed_auth_entry,
439            Some("AAAAAQAAAAEAAAAHYm9pbGVycz...".to_string())
440        );
441
442        // Validate should pass
443        assert!(req.validate().is_ok());
444    }
445}