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