openzeppelin_relayer/domain/transaction/stellar/
validation.rs

1//! Validation logic for Stellar transactions
2//!
3//! This module focuses on business logic validations that aren't
4//! already handled by XDR parsing or the type system.
5
6use crate::models::{MemoSpec, OperationSpec, StellarValidationError, TransactionError};
7
8/// Validate operations for business rules
9pub fn validate_operations(ops: &[OperationSpec]) -> Result<(), TransactionError> {
10    // Basic sanity checks
11    if ops.is_empty() {
12        return Err(StellarValidationError::EmptyOperations.into());
13    }
14
15    if ops.len() > 100 {
16        return Err(StellarValidationError::TooManyOperations {
17            count: ops.len(),
18            max: 100,
19        }
20        .into());
21    }
22
23    // Check Soroban exclusivity - this is a specific business rule
24    validate_soroban_exclusivity(ops)?;
25
26    Ok(())
27}
28
29/// Validate that Soroban operations are exclusive
30fn validate_soroban_exclusivity(ops: &[OperationSpec]) -> Result<(), TransactionError> {
31    let soroban_ops = ops.iter().filter(|op| is_soroban_operation(op)).count();
32
33    if soroban_ops > 1 {
34        return Err(StellarValidationError::MultipleSorobanOperations.into());
35    }
36
37    if soroban_ops == 1 && ops.len() > 1 {
38        return Err(StellarValidationError::SorobanNotExclusive.into());
39    }
40
41    Ok(())
42}
43
44/// Check if an operation is a Soroban operation
45fn is_soroban_operation(op: &OperationSpec) -> bool {
46    matches!(
47        op,
48        OperationSpec::InvokeContract { .. }
49            | OperationSpec::CreateContract { .. }
50            | OperationSpec::UploadWasm { .. }
51    )
52}
53
54/// Validate that Soroban operations don't have a non-None memo
55pub fn validate_soroban_memo_restriction(
56    ops: &[OperationSpec],
57    memo: &Option<MemoSpec>,
58) -> Result<(), TransactionError> {
59    let has_soroban = ops.iter().any(is_soroban_operation);
60
61    if has_soroban && memo.is_some() && !matches!(memo, Some(MemoSpec::None)) {
62        return Err(StellarValidationError::SorobanWithMemo.into());
63    }
64
65    Ok(())
66}
67
68#[cfg(test)]
69mod tests {
70    use super::*;
71    use crate::models::AssetSpec;
72
73    #[test]
74    fn test_empty_operations_rejected() {
75        let result = validate_operations(&[]);
76        assert!(result.is_err());
77        assert!(result
78            .unwrap_err()
79            .to_string()
80            .contains("at least one operation"));
81    }
82
83    #[test]
84    fn test_too_many_operations_rejected() {
85        let ops = vec![
86            OperationSpec::Payment {
87                destination: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF".to_string(),
88                amount: 1000,
89                asset: AssetSpec::Native,
90            };
91            101
92        ];
93        let result = validate_operations(&ops);
94        assert!(result.is_err());
95        assert!(result
96            .unwrap_err()
97            .to_string()
98            .contains("maximum allowed is 100"));
99    }
100
101    #[test]
102    fn test_soroban_exclusivity_enforced() {
103        // Multiple Soroban operations should fail
104        let ops = vec![
105            OperationSpec::InvokeContract {
106                contract_address: "CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC"
107                    .to_string(),
108                function_name: "test".to_string(),
109                args: vec![],
110                auth: None,
111            },
112            OperationSpec::CreateContract {
113                source: crate::models::ContractSource::Address {
114                    address: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF".to_string(),
115                },
116                wasm_hash: "abc123".to_string(),
117                salt: None,
118                constructor_args: None,
119                auth: None,
120            },
121        ];
122        let result = validate_operations(&ops);
123        assert!(result.is_err());
124
125        // Soroban mixed with non-Soroban should fail
126        let ops = vec![
127            OperationSpec::InvokeContract {
128                contract_address: "CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC"
129                    .to_string(),
130                function_name: "test".to_string(),
131                args: vec![],
132                auth: None,
133            },
134            OperationSpec::Payment {
135                destination: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF".to_string(),
136                amount: 1000,
137                asset: AssetSpec::Native,
138            },
139        ];
140        let result = validate_operations(&ops);
141        assert!(result.is_err());
142        assert!(result
143            .unwrap_err()
144            .to_string()
145            .contains("Soroban operations must be exclusive"));
146    }
147
148    #[test]
149    fn test_soroban_memo_restriction() {
150        let soroban_op = vec![OperationSpec::InvokeContract {
151            contract_address: "CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC"
152                .to_string(),
153            function_name: "test".to_string(),
154            args: vec![],
155            auth: None,
156        }];
157
158        // Soroban with text memo should fail
159        let result = validate_soroban_memo_restriction(
160            &soroban_op,
161            &Some(MemoSpec::Text {
162                value: "test".to_string(),
163            }),
164        );
165        assert!(result.is_err());
166
167        // Soroban with MemoNone should succeed
168        let result = validate_soroban_memo_restriction(&soroban_op, &Some(MemoSpec::None));
169        assert!(result.is_ok());
170
171        // Soroban with no memo should succeed
172        let result = validate_soroban_memo_restriction(&soroban_op, &None);
173        assert!(result.is_ok());
174    }
175}