1use crate::constants::STELLAR_DEFAULT_TRANSACTION_FEE;
7use crate::constants::STELLAR_MAX_OPERATIONS;
8use crate::domain::relayer::xdr_utils::{
9 extract_operations, extract_source_account, muxed_account_to_string,
10};
11use crate::domain::transaction::stellar::token::get_token_balance;
12use crate::domain::transaction::stellar::utils::{
13 asset_to_asset_id, convert_xlm_fee_to_token, estimate_fee, extract_time_bounds,
14};
15use crate::domain::xdr_needs_simulation;
16use crate::models::RelayerStellarPolicy;
17use crate::models::{MemoSpec, OperationSpec, StellarValidationError, TransactionError};
18use crate::services::provider::StellarProviderTrait;
19use crate::services::stellar_dex::StellarDexServiceTrait;
20use chrono::{DateTime, Duration, Utc};
21use serde::Serialize;
22use soroban_rs::xdr::{
23 AccountId, HostFunction, InvokeHostFunctionOp, LedgerKey, OperationBody, PaymentOp,
24 PublicKey as XdrPublicKey, ScAddress, SorobanCredentials, TransactionEnvelope,
25};
26use stellar_strkey::ed25519::PublicKey;
27use thiserror::Error;
28#[derive(Debug, Error, Serialize)]
29pub enum StellarTransactionValidationError {
30 #[error("Validation error: {0}")]
31 ValidationError(String),
32 #[error("Policy violation: {0}")]
33 PolicyViolation(String),
34 #[error("Invalid asset identifier: {0}")]
35 InvalidAssetIdentifier(String),
36 #[error("Token not allowed: {0}")]
37 TokenNotAllowed(String),
38 #[error("Insufficient token payment: expected {0}, got {1}")]
39 InsufficientTokenPayment(u64, u64),
40 #[error("Max fee exceeded: {0}")]
41 MaxFeeExceeded(u64),
42}
43
44pub fn validate_operations(ops: &[OperationSpec]) -> Result<(), TransactionError> {
46 if ops.is_empty() {
48 return Err(StellarValidationError::EmptyOperations.into());
49 }
50
51 if ops.len() > STELLAR_MAX_OPERATIONS {
52 return Err(StellarValidationError::TooManyOperations {
53 count: ops.len(),
54 max: STELLAR_MAX_OPERATIONS,
55 }
56 .into());
57 }
58
59 validate_soroban_exclusivity(ops)?;
61
62 Ok(())
63}
64
65fn validate_soroban_exclusivity(ops: &[OperationSpec]) -> Result<(), TransactionError> {
67 let soroban_ops = ops.iter().filter(|op| is_soroban_operation(op)).count();
68
69 if soroban_ops > 1 {
70 return Err(StellarValidationError::MultipleSorobanOperations.into());
71 }
72
73 if soroban_ops == 1 && ops.len() > 1 {
74 return Err(StellarValidationError::SorobanNotExclusive.into());
75 }
76
77 Ok(())
78}
79
80fn is_soroban_operation(op: &OperationSpec) -> bool {
82 matches!(
83 op,
84 OperationSpec::InvokeContract { .. }
85 | OperationSpec::CreateContract { .. }
86 | OperationSpec::UploadWasm { .. }
87 )
88}
89
90pub fn validate_soroban_memo_restriction(
92 ops: &[OperationSpec],
93 memo: &Option<MemoSpec>,
94) -> Result<(), TransactionError> {
95 let has_soroban = ops.iter().any(is_soroban_operation);
96
97 if has_soroban && memo.is_some() && !matches!(memo, Some(MemoSpec::None)) {
98 return Err(StellarValidationError::SorobanWithMemo.into());
99 }
100
101 Ok(())
102}
103
104pub struct StellarTransactionValidator;
106
107impl StellarTransactionValidator {
108 pub fn validate_fee_token_structure(
115 fee_token: &str,
116 ) -> Result<(), StellarTransactionValidationError> {
117 if fee_token == "native" || fee_token == "XLM" || fee_token.is_empty() {
119 return Ok(());
120 }
121
122 if fee_token.starts_with('C') && fee_token.len() == 56 && !fee_token.contains(':') {
124 if stellar_strkey::Contract::from_string(fee_token).is_ok() {
126 return Ok(());
127 }
128 return Err(StellarTransactionValidationError::InvalidAssetIdentifier(
129 format!(
130 "Invalid contract address format: {fee_token} (must be 56 characters and valid StrKey)"
131 ),
132 ));
133 }
134
135 let parts: Vec<&str> = fee_token.split(':').collect();
137 if parts.len() != 2 {
138 return Err(StellarTransactionValidationError::InvalidAssetIdentifier(format!(
139 "Invalid fee_token format: {fee_token}. Expected 'native', 'CODE:ISSUER', or contract address (C...)"
140 )));
141 }
142
143 let code = parts[0];
144 let issuer = parts[1];
145
146 if code.is_empty() || code.len() > 12 {
148 return Err(StellarTransactionValidationError::InvalidAssetIdentifier(
149 format!("Invalid asset code length: {code} (must be 1-12 characters)"),
150 ));
151 }
152
153 if issuer.len() != 56 {
155 return Err(StellarTransactionValidationError::InvalidAssetIdentifier(
156 format!("Invalid issuer address length: {issuer} (must be 56 characters)"),
157 ));
158 }
159
160 if !issuer.starts_with('G') {
161 return Err(StellarTransactionValidationError::InvalidAssetIdentifier(
162 format!("Invalid issuer address prefix: {issuer} (must start with 'G')"),
163 ));
164 }
165
166 if stellar_strkey::ed25519::PublicKey::from_string(issuer).is_err() {
168 return Err(StellarTransactionValidationError::InvalidAssetIdentifier(
169 format!(
170 "Invalid issuer address format: {issuer} (must be a valid Stellar public key)"
171 ),
172 ));
173 }
174
175 Ok(())
176 }
177
178 pub fn validate_allowed_token(
180 asset: &str,
181 policy: &RelayerStellarPolicy,
182 ) -> Result<(), StellarTransactionValidationError> {
183 let allowed_tokens = policy.get_allowed_tokens();
184
185 if allowed_tokens.is_empty() {
186 return Ok(());
188 }
189
190 if asset == "native" || asset.is_empty() {
192 let native_allowed = allowed_tokens
193 .iter()
194 .any(|token| token.asset == "native" || token.asset.is_empty());
195 if !native_allowed {
196 return Err(StellarTransactionValidationError::TokenNotAllowed(
197 "Native XLM not in allowed tokens list".to_string(),
198 ));
199 }
200 return Ok(());
201 }
202
203 let is_allowed = allowed_tokens.iter().any(|token| token.asset == asset);
205
206 if !is_allowed {
207 return Err(StellarTransactionValidationError::TokenNotAllowed(format!(
208 "Token {asset} not in allowed tokens list"
209 )));
210 }
211
212 Ok(())
213 }
214
215 pub fn validate_max_fee(
217 fee: u64,
218 policy: &RelayerStellarPolicy,
219 ) -> Result<(), StellarTransactionValidationError> {
220 if let Some(max_fee) = policy.max_fee {
221 if fee > max_fee as u64 {
222 return Err(StellarTransactionValidationError::MaxFeeExceeded(fee));
223 }
224 }
225
226 Ok(())
227 }
228
229 pub fn validate_token_max_fee(
231 asset_id: &str,
232 fee: u64,
233 policy: &RelayerStellarPolicy,
234 ) -> Result<(), StellarTransactionValidationError> {
235 if let Some(token_entry) = policy.get_allowed_token_entry(asset_id) {
236 if let Some(max_allowed_fee) = token_entry.max_allowed_fee {
237 if fee > max_allowed_fee {
238 return Err(StellarTransactionValidationError::MaxFeeExceeded(fee));
239 }
240 }
241 }
242
243 Ok(())
244 }
245
246 pub fn extract_relayer_payments(
250 envelope: &TransactionEnvelope,
251 relayer_address: &str,
252 ) -> Result<Vec<(String, u64)>, StellarTransactionValidationError> {
253 let operations = extract_operations(envelope).map_err(|e| {
254 StellarTransactionValidationError::ValidationError(format!(
255 "Failed to extract operations: {e}"
256 ))
257 })?;
258
259 let mut payments = Vec::new();
260
261 for op in operations.iter() {
262 if let OperationBody::Payment(PaymentOp {
263 destination,
264 asset,
265 amount,
266 }) = &op.body
267 {
268 let dest_str = muxed_account_to_string(destination).map_err(|e| {
270 StellarTransactionValidationError::ValidationError(format!(
271 "Failed to parse destination: {e}"
272 ))
273 })?;
274
275 if dest_str == relayer_address {
277 let asset_id = asset_to_asset_id(asset).map_err(|e| {
279 StellarTransactionValidationError::InvalidAssetIdentifier(format!(
280 "Failed to convert asset to asset_id: {e}"
281 ))
282 })?;
283 if *amount < 0 {
285 return Err(StellarTransactionValidationError::ValidationError(
286 "Negative payment amount".to_string(),
287 ));
288 }
289 let amount_u64 = *amount as u64;
290 payments.push((asset_id, amount_u64));
291 }
292 }
293 }
294
295 Ok(payments)
296 }
297
298 pub fn validate_token_payment(
305 envelope: &TransactionEnvelope,
306 relayer_address: &str,
307 expected_fee_token: &str,
308 expected_fee_amount: u64,
309 policy: &RelayerStellarPolicy,
310 ) -> Result<(), StellarTransactionValidationError> {
311 let payments = Self::extract_relayer_payments(envelope, relayer_address)?;
313
314 if payments.is_empty() {
315 return Err(StellarTransactionValidationError::ValidationError(
316 "No payment operation found to relayer".to_string(),
317 ));
318 }
319
320 let matching_payment = payments
322 .iter()
323 .find(|(asset_id, _)| asset_id == expected_fee_token);
324
325 match matching_payment {
326 Some((asset_id, amount)) => {
327 Self::validate_allowed_token(asset_id, policy)?;
329
330 let tolerance = (expected_fee_amount as f64 * 0.01) as u64;
332 if *amount < expected_fee_amount.saturating_sub(tolerance) {
333 return Err(StellarTransactionValidationError::InsufficientTokenPayment(
334 expected_fee_amount,
335 *amount,
336 ));
337 }
338
339 Self::validate_token_max_fee(asset_id, *amount, policy)?;
341
342 Ok(())
343 }
344 None => Err(StellarTransactionValidationError::ValidationError(format!(
345 "No payment found for expected token: {expected_fee_token}. Found payments: {payments:?}"
346 ))),
347 }
348 }
349
350 fn validate_source_account_not_relayer(
355 envelope: &TransactionEnvelope,
356 relayer_address: &str,
357 ) -> Result<(), StellarTransactionValidationError> {
358 let source_account = extract_source_account(envelope).map_err(|e| {
359 StellarTransactionValidationError::ValidationError(format!(
360 "Failed to extract source account: {e}"
361 ))
362 })?;
363
364 if source_account == relayer_address {
365 return Err(StellarTransactionValidationError::ValidationError(
366 "Transaction source account cannot be the relayer address. This is a security measure to prevent relayer fund drainage.".to_string(),
367 ));
368 }
369
370 Ok(())
371 }
372
373 fn validate_transaction_type(
377 envelope: &TransactionEnvelope,
378 ) -> Result<(), StellarTransactionValidationError> {
379 match envelope {
380 soroban_rs::xdr::TransactionEnvelope::TxFeeBump(_) => {
381 Err(StellarTransactionValidationError::ValidationError(
382 "Fee-bump transactions are not supported for gasless transactions".to_string(),
383 ))
384 }
385 _ => Ok(()),
386 }
387 }
388
389 fn validate_operations_not_targeting_relayer(
394 envelope: &TransactionEnvelope,
395 relayer_address: &str,
396 ) -> Result<(), StellarTransactionValidationError> {
397 let operations = extract_operations(envelope).map_err(|e| {
398 StellarTransactionValidationError::ValidationError(format!(
399 "Failed to extract operations: {e}"
400 ))
401 })?;
402
403 for op in operations.iter() {
404 match &op.body {
405 OperationBody::Payment(PaymentOp { destination, .. }) => {
406 let dest_str = muxed_account_to_string(destination).map_err(|e| {
407 StellarTransactionValidationError::ValidationError(format!(
408 "Failed to parse destination: {e}"
409 ))
410 })?;
411
412 if dest_str == relayer_address {
414 continue;
417 }
418 }
419 OperationBody::AccountMerge(destination) => {
420 let dest_str = muxed_account_to_string(destination).map_err(|e| {
421 StellarTransactionValidationError::ValidationError(format!(
422 "Failed to parse merge destination: {e}"
423 ))
424 })?;
425
426 if dest_str == relayer_address {
427 return Err(StellarTransactionValidationError::ValidationError(
428 "Account merge operations targeting the relayer are not allowed"
429 .to_string(),
430 ));
431 }
432 }
433 OperationBody::SetOptions(_) => {
434 }
439 _ => {
440 }
442 }
443 }
444
445 Ok(())
446 }
447
448 fn validate_operations_count(
452 envelope: &TransactionEnvelope,
453 ) -> Result<(), StellarTransactionValidationError> {
454 let operations = extract_operations(envelope).map_err(|e| {
455 StellarTransactionValidationError::ValidationError(format!(
456 "Failed to extract operations: {e}"
457 ))
458 })?;
459
460 if operations.is_empty() {
461 return Err(StellarTransactionValidationError::ValidationError(
462 "Transaction must contain at least one operation".to_string(),
463 ));
464 }
465
466 if operations.len() > STELLAR_MAX_OPERATIONS {
467 return Err(StellarTransactionValidationError::ValidationError(format!(
468 "Transaction contains too many operations: {} (maximum is {})",
469 operations.len(),
470 STELLAR_MAX_OPERATIONS
471 )));
472 }
473
474 Ok(())
475 }
476
477 fn account_id_to_string(
479 account_id: &AccountId,
480 ) -> Result<String, StellarTransactionValidationError> {
481 match &account_id.0 {
482 XdrPublicKey::PublicKeyTypeEd25519(uint256) => {
483 let bytes: [u8; 32] = uint256.0;
484 let pk = PublicKey(bytes);
485 Ok(pk.to_string())
486 }
487 }
488 }
489
490 #[allow(dead_code)]
492 fn footprint_key_targets_relayer(
493 key: &LedgerKey,
494 relayer_address: &str,
495 ) -> Result<bool, StellarTransactionValidationError> {
496 match key {
497 LedgerKey::Account(account_key) => {
498 let account_str = Self::account_id_to_string(&account_key.account_id)?;
500 Ok(account_str == relayer_address)
501 }
502 LedgerKey::Trustline(trustline_key) => {
503 let account_str = Self::account_id_to_string(&trustline_key.account_id)?;
505 Ok(account_str == relayer_address)
506 }
507 LedgerKey::ContractData(contract_data_key) => {
508 match &contract_data_key.contract {
510 ScAddress::Account(acc_id) => {
511 let account_str = Self::account_id_to_string(acc_id)?;
512 Ok(account_str == relayer_address)
513 }
514 ScAddress::Contract(_) => {
515 Ok(false)
517 }
518 ScAddress::MuxedAccount(_)
519 | ScAddress::ClaimableBalance(_)
520 | ScAddress::LiquidityPool(_) => {
521 Ok(false)
523 }
524 }
525 }
526 LedgerKey::ContractCode(_) => {
527 Ok(false)
529 }
530 _ => {
531 Ok(false)
533 }
534 }
535 }
536
537 fn validate_contract_invocation(
543 invoke: &InvokeHostFunctionOp,
544 op_idx: usize,
545 relayer_address: &str,
546 _policy: &RelayerStellarPolicy,
547 ) -> Result<(), StellarTransactionValidationError> {
548 match &invoke.host_function {
550 HostFunction::InvokeContract(_) => {
551 }
553 HostFunction::CreateContract(_) => {
554 return Err(StellarTransactionValidationError::ValidationError(format!(
555 "Op {op_idx}: CreateContract not allowed for gasless transactions"
556 )));
557 }
558 HostFunction::UploadContractWasm(_) => {
559 return Err(StellarTransactionValidationError::ValidationError(format!(
560 "Op {op_idx}: UploadContractWasm not allowed for gasless transactions"
561 )));
562 }
563 _ => {
564 return Err(StellarTransactionValidationError::ValidationError(format!(
565 "Op {op_idx}: Unsupported host function"
566 )));
567 }
568 }
569
570 for (i, entry) in invoke.auth.iter().enumerate() {
572 match &entry.credentials {
574 SorobanCredentials::SourceAccount => {
575 }
578 SorobanCredentials::Address(address_creds) => {
579 match &address_creds.address {
581 ScAddress::Account(acc_id) => {
582 let account_str = Self::account_id_to_string(acc_id)?;
584 if account_str == relayer_address {
585 return Err(StellarTransactionValidationError::ValidationError(
586 format!(
587 "Op {op_idx}: Soroban auth entry {i} requires relayer ({relayer_address}). Forbidden."
588 ),
589 ));
590 }
591 }
592 ScAddress::Contract(_) => {
593 }
595 ScAddress::MuxedAccount(_) => {
596 }
598 ScAddress::ClaimableBalance(_) | ScAddress::LiquidityPool(_) => {
599 }
601 }
602 }
603 }
604 }
605
606 Ok(())
607 }
608
609 fn validate_operation_types(
614 envelope: &TransactionEnvelope,
615 relayer_address: &str,
616 policy: &RelayerStellarPolicy,
617 ) -> Result<(), StellarTransactionValidationError> {
618 let operations = extract_operations(envelope).map_err(|e| {
619 StellarTransactionValidationError::ValidationError(format!(
620 "Failed to extract operations: {e}"
621 ))
622 })?;
623
624 for (idx, op) in operations.iter().enumerate() {
625 match &op.body {
626 OperationBody::AccountMerge(_) => {
628 return Err(StellarTransactionValidationError::ValidationError(format!(
629 "Operation {idx}: AccountMerge operations are not allowed"
630 )));
631 }
632
633 OperationBody::SetOptions(_set_opts) => {
635 return Err(StellarTransactionValidationError::ValidationError(format!(
636 "Operation {idx}: SetOptions operations are not allowed"
637 )));
638 }
639
640 OperationBody::InvokeHostFunction(invoke) => {
642 Self::validate_contract_invocation(invoke, idx, relayer_address, policy)?;
643 }
644
645 OperationBody::Payment(_)
647 | OperationBody::PathPaymentStrictReceive(_)
648 | OperationBody::PathPaymentStrictSend(_)
649 | OperationBody::ManageSellOffer(_)
650 | OperationBody::ManageBuyOffer(_)
651 | OperationBody::CreatePassiveSellOffer(_)
652 | OperationBody::ChangeTrust(_)
653 | OperationBody::ManageData(_)
654 | OperationBody::BumpSequence(_)
655 | OperationBody::CreateClaimableBalance(_)
656 | OperationBody::ClaimClaimableBalance(_)
657 | OperationBody::BeginSponsoringFutureReserves(_)
658 | OperationBody::EndSponsoringFutureReserves
659 | OperationBody::RevokeSponsorship(_)
660 | OperationBody::Clawback(_)
661 | OperationBody::ClawbackClaimableBalance(_)
662 | OperationBody::SetTrustLineFlags(_)
663 | OperationBody::LiquidityPoolDeposit(_)
664 | OperationBody::LiquidityPoolWithdraw(_) => {
665 }
667
668 OperationBody::CreateAccount(_) | OperationBody::AllowTrust(_) => {
670 return Err(StellarTransactionValidationError::ValidationError(format!(
671 "Operation {idx}: Deprecated operation type not allowed"
672 )));
673 }
674
675 OperationBody::Inflation
677 | OperationBody::ExtendFootprintTtl(_)
678 | OperationBody::RestoreFootprint(_) => {
679 }
681 }
682 }
683
684 Ok(())
685 }
686
687 pub async fn validate_sequence_number<P>(
696 envelope: &TransactionEnvelope,
697 provider: &P,
698 ) -> Result<(), StellarTransactionValidationError>
699 where
700 P: StellarProviderTrait + Send + Sync,
701 {
702 let source_account = extract_source_account(envelope).map_err(|e| {
704 StellarTransactionValidationError::ValidationError(format!(
705 "Failed to extract source account: {e}"
706 ))
707 })?;
708
709 let account_entry = provider.get_account(&source_account).await.map_err(|e| {
711 StellarTransactionValidationError::ValidationError(format!(
712 "Failed to get account sequence: {e}"
713 ))
714 })?;
715 let account_seq_num = account_entry.seq_num.0;
716
717 let tx_seq_num = match envelope {
719 TransactionEnvelope::TxV0(e) => e.tx.seq_num.0,
720 TransactionEnvelope::Tx(e) => e.tx.seq_num.0,
721 TransactionEnvelope::TxFeeBump(_) => {
722 return Err(StellarTransactionValidationError::ValidationError(
723 "Fee-bump transactions are not supported for gasless transactions".to_string(),
724 ));
725 }
726 };
727
728 if tx_seq_num <= account_seq_num {
732 return Err(StellarTransactionValidationError::ValidationError(format!(
733 "Transaction sequence number {tx_seq_num} is invalid. Account's current sequence is {account_seq_num}. \
734 The transaction sequence must be strictly greater than the account's current sequence."
735 )));
736 }
737
738 Ok(())
739 }
740
741 pub async fn gasless_transaction_validation<P>(
764 envelope: &TransactionEnvelope,
765 relayer_address: &str,
766 policy: &RelayerStellarPolicy,
767 provider: &P,
768 max_validity_duration: Option<Duration>,
769 ) -> Result<(), StellarTransactionValidationError>
770 where
771 P: StellarProviderTrait + Send + Sync,
772 {
773 Self::validate_source_account_not_relayer(envelope, relayer_address)?;
774 Self::validate_transaction_type(envelope)?;
775 Self::validate_operations_not_targeting_relayer(envelope, relayer_address)?;
776 Self::validate_operations_count(envelope)?;
777 Self::validate_operation_types(envelope, relayer_address, policy)?;
778 Self::validate_sequence_number(envelope, provider).await?;
779
780 Self::validate_time_bounds_not_expired(envelope)?;
782
783 if let Some(max_duration) = max_validity_duration {
785 Self::validate_transaction_validity_duration(envelope, max_duration)?;
786 }
787
788 Ok(())
789 }
790
791 pub fn validate_time_bounds_not_expired(
804 envelope: &TransactionEnvelope,
805 ) -> Result<(), StellarTransactionValidationError> {
806 let time_bounds = extract_time_bounds(envelope);
807
808 if let Some(bounds) = time_bounds {
809 let now = Utc::now().timestamp() as u64;
810 let min_time = bounds.min_time.0;
811 let max_time = bounds.max_time.0;
812
813 if now > max_time {
815 return Err(StellarTransactionValidationError::ValidationError(format!(
816 "Transaction has expired: max_time={max_time}, current_time={now}"
817 )));
818 }
819
820 if min_time > 0 && now < min_time {
822 return Err(StellarTransactionValidationError::ValidationError(format!(
823 "Transaction is not yet valid: min_time={min_time}, current_time={now}"
824 )));
825 }
826 }
827 Ok(())
831 }
832
833 pub fn validate_transaction_validity_duration(
846 envelope: &TransactionEnvelope,
847 max_duration: Duration,
848 ) -> Result<(), StellarTransactionValidationError> {
849 let time_bounds = extract_time_bounds(envelope);
850
851 if let Some(bounds) = time_bounds {
852 let max_time =
853 DateTime::from_timestamp(bounds.max_time.0 as i64, 0).ok_or_else(|| {
854 StellarTransactionValidationError::ValidationError(
855 "Invalid max_time in time bounds".to_string(),
856 )
857 })?;
858 let now = Utc::now();
859 let duration = max_time - now;
860
861 if duration > max_duration {
862 return Err(StellarTransactionValidationError::ValidationError(format!(
863 "Transaction validity duration ({duration:?}) exceeds maximum allowed duration ({max_duration:?})"
864 )));
865 }
866 } else {
867 return Err(StellarTransactionValidationError::ValidationError(
868 "Transaction must have time bounds set".to_string(),
869 ));
870 }
871
872 Ok(())
873 }
874
875 pub async fn validate_user_fee_payment_transaction<P, D>(
904 envelope: &TransactionEnvelope,
905 relayer_address: &str,
906 policy: &RelayerStellarPolicy,
907 provider: &P,
908 dex_service: &D,
909 max_validity_duration: Option<Duration>,
910 ) -> Result<(), StellarTransactionValidationError>
911 where
912 P: StellarProviderTrait + Send + Sync,
913 D: StellarDexServiceTrait + Send + Sync,
914 {
915 Self::gasless_transaction_validation(
918 envelope,
919 relayer_address,
920 policy,
921 provider,
922 max_validity_duration,
923 )
924 .await?;
925
926 Self::validate_user_fee_payment_amounts(
928 envelope,
929 relayer_address,
930 policy,
931 provider,
932 dex_service,
933 )
934 .await?;
935
936 Ok(())
937 }
938
939 async fn validate_user_fee_payment_amounts<P, D>(
955 envelope: &TransactionEnvelope,
956 relayer_address: &str,
957 policy: &RelayerStellarPolicy,
958 provider: &P,
959 dex_service: &D,
960 ) -> Result<(), StellarTransactionValidationError>
961 where
962 P: StellarProviderTrait + Send + Sync,
963 D: StellarDexServiceTrait + Send + Sync,
964 {
965 let payments = Self::extract_relayer_payments(envelope, relayer_address)?;
967 if payments.is_empty() {
968 return Err(StellarTransactionValidationError::ValidationError(
969 "Gasless transactions must include a fee payment operation to the relayer"
970 .to_string(),
971 ));
972 }
973
974 if payments.len() > 1 {
976 return Err(StellarTransactionValidationError::ValidationError(format!(
977 "Gasless transactions must include exactly one fee payment operation to the relayer, found {}",
978 payments.len()
979 )));
980 }
981
982 let (asset_id, amount) = &payments[0];
984
985 Self::validate_allowed_token(asset_id, policy)?;
987
988 Self::validate_token_max_fee(asset_id, *amount, policy)?;
990
991 let mut required_xlm_fee = estimate_fee(envelope, provider, None).await.map_err(|e| {
994 StellarTransactionValidationError::ValidationError(format!(
995 "Failed to estimate fee: {e}",
996 ))
997 })?;
998
999 let is_soroban = xdr_needs_simulation(envelope).unwrap_or(false);
1000 if !is_soroban {
1001 required_xlm_fee += STELLAR_DEFAULT_TRANSACTION_FEE as u64;
1003 }
1004
1005 let fee_quote = convert_xlm_fee_to_token(dex_service, policy, required_xlm_fee, asset_id)
1006 .await
1007 .map_err(|e| {
1008 StellarTransactionValidationError::ValidationError(format!(
1009 "Failed to convert XLM fee to token {asset_id}: {e}",
1010 ))
1011 })?;
1012
1013 if *amount < fee_quote.fee_in_token {
1015 return Err(StellarTransactionValidationError::InsufficientTokenPayment(
1016 fee_quote.fee_in_token,
1017 *amount,
1018 ));
1019 }
1020
1021 Self::validate_user_token_balance(envelope, asset_id, fee_quote.fee_in_token, provider)
1023 .await?;
1024
1025 Ok(())
1026 }
1027
1028 pub async fn validate_user_token_balance<P>(
1043 envelope: &TransactionEnvelope,
1044 fee_token: &str,
1045 required_fee_amount: u64,
1046 provider: &P,
1047 ) -> Result<(), StellarTransactionValidationError>
1048 where
1049 P: StellarProviderTrait + Send + Sync,
1050 {
1051 let source_account = extract_source_account(envelope).map_err(|e| {
1053 StellarTransactionValidationError::ValidationError(format!(
1054 "Failed to extract source account: {e}"
1055 ))
1056 })?;
1057
1058 let user_balance = get_token_balance(provider, &source_account, fee_token)
1060 .await
1061 .map_err(|e| {
1062 StellarTransactionValidationError::ValidationError(format!(
1063 "Failed to fetch user balance for token {fee_token}: {e}",
1064 ))
1065 })?;
1066
1067 if user_balance < required_fee_amount {
1069 return Err(StellarTransactionValidationError::ValidationError(format!(
1070 "Insufficient balance: user has {user_balance} {fee_token} but needs {required_fee_amount} {fee_token} for transaction fee"
1071 )));
1072 }
1073
1074 Ok(())
1075 }
1076}
1077
1078#[cfg(test)]
1079mod tests {
1080 use super::*;
1081 use crate::domain::transaction::stellar::test_helpers::{
1082 create_account_id, create_muxed_account, create_native_payment_operation,
1083 create_simple_v1_envelope, TEST_CONTRACT, TEST_PK, TEST_PK_2,
1084 };
1085 use crate::models::{AssetSpec, StellarAllowedTokensPolicy};
1086 use crate::services::provider::MockStellarProviderTrait;
1087 use crate::services::stellar_dex::MockStellarDexServiceTrait;
1088 use futures::future::ready;
1089 use soroban_rs::xdr::{
1090 AccountEntry, AccountEntryExt, Asset as XdrAsset, ChangeTrustAsset, ChangeTrustOp,
1091 HostFunction, InvokeContractArgs, InvokeHostFunctionOp, Operation, OperationBody,
1092 ScAddress, ScSymbol, SequenceNumber, SorobanAuthorizationEntry, SorobanAuthorizedFunction,
1093 SorobanCredentials, Thresholds, TimeBounds, TimePoint, Transaction, TransactionEnvelope,
1094 TransactionExt, TransactionV1Envelope,
1095 };
1096
1097 #[test]
1098 fn test_empty_operations_rejected() {
1099 let result = validate_operations(&[]);
1100 assert!(result.is_err());
1101 assert!(result
1102 .unwrap_err()
1103 .to_string()
1104 .contains("at least one operation"));
1105 }
1106
1107 #[test]
1108 fn test_too_many_operations_rejected() {
1109 let ops = vec![
1110 OperationSpec::Payment {
1111 destination: TEST_PK.to_string(),
1112 amount: 1000,
1113 asset: AssetSpec::Native,
1114 };
1115 101
1116 ];
1117 let result = validate_operations(&ops);
1118 assert!(result.is_err());
1119 assert!(result
1120 .unwrap_err()
1121 .to_string()
1122 .contains("maximum allowed is 100"));
1123 }
1124
1125 #[test]
1126 fn test_soroban_exclusivity_enforced() {
1127 let ops = vec![
1129 OperationSpec::InvokeContract {
1130 contract_address: TEST_CONTRACT.to_string(),
1131 function_name: "test".to_string(),
1132 args: vec![],
1133 auth: None,
1134 },
1135 OperationSpec::CreateContract {
1136 source: crate::models::ContractSource::Address {
1137 address: TEST_PK.to_string(),
1138 },
1139 wasm_hash: "abc123".to_string(),
1140 salt: None,
1141 constructor_args: None,
1142 auth: None,
1143 },
1144 ];
1145 let result = validate_operations(&ops);
1146 assert!(result.is_err());
1147
1148 let ops = vec![
1150 OperationSpec::InvokeContract {
1151 contract_address: TEST_CONTRACT.to_string(),
1152 function_name: "test".to_string(),
1153 args: vec![],
1154 auth: None,
1155 },
1156 OperationSpec::Payment {
1157 destination: TEST_PK.to_string(),
1158 amount: 1000,
1159 asset: AssetSpec::Native,
1160 },
1161 ];
1162 let result = validate_operations(&ops);
1163 assert!(result.is_err());
1164 assert!(result
1165 .unwrap_err()
1166 .to_string()
1167 .contains("Soroban operations must be exclusive"));
1168 }
1169
1170 #[test]
1171 fn test_soroban_memo_restriction() {
1172 let soroban_op = vec![OperationSpec::InvokeContract {
1173 contract_address: TEST_CONTRACT.to_string(),
1174 function_name: "test".to_string(),
1175 args: vec![],
1176 auth: None,
1177 }];
1178
1179 let result = validate_soroban_memo_restriction(
1181 &soroban_op,
1182 &Some(MemoSpec::Text {
1183 value: "test".to_string(),
1184 }),
1185 );
1186 assert!(result.is_err());
1187
1188 let result = validate_soroban_memo_restriction(&soroban_op, &Some(MemoSpec::None));
1190 assert!(result.is_ok());
1191
1192 let result = validate_soroban_memo_restriction(&soroban_op, &None);
1194 assert!(result.is_ok());
1195 }
1196
1197 mod validate_fee_token_structure_tests {
1198 use super::*;
1199
1200 #[test]
1201 fn test_native_xlm_valid() {
1202 assert!(StellarTransactionValidator::validate_fee_token_structure("native").is_ok());
1203 assert!(StellarTransactionValidator::validate_fee_token_structure("XLM").is_ok());
1204 assert!(StellarTransactionValidator::validate_fee_token_structure("").is_ok());
1205 }
1206
1207 #[test]
1208 fn test_contract_address_valid() {
1209 assert!(
1210 StellarTransactionValidator::validate_fee_token_structure(TEST_CONTRACT).is_ok()
1211 );
1212 }
1213
1214 #[test]
1215 fn test_contract_address_invalid_length() {
1216 let result = StellarTransactionValidator::validate_fee_token_structure("C123");
1217 assert!(result.is_err());
1218 assert!(result
1219 .unwrap_err()
1220 .to_string()
1221 .contains("Invalid fee_token format"));
1222 }
1223
1224 #[test]
1225 fn test_classic_asset_valid() {
1226 let result = StellarTransactionValidator::validate_fee_token_structure(&format!(
1227 "USDC:{}",
1228 TEST_PK
1229 ));
1230 assert!(result.is_ok());
1231 }
1232
1233 #[test]
1234 fn test_classic_asset_code_too_long() {
1235 let result = StellarTransactionValidator::validate_fee_token_structure(&format!(
1236 "VERYLONGCODE1:{}",
1237 TEST_PK
1238 ));
1239 assert!(result.is_err());
1240 assert!(result
1241 .unwrap_err()
1242 .to_string()
1243 .contains("Invalid asset code length"));
1244 }
1245
1246 #[test]
1247 fn test_classic_asset_invalid_issuer_length() {
1248 let result = StellarTransactionValidator::validate_fee_token_structure("USDC:GSHORT");
1249 assert!(result.is_err());
1250 assert!(result
1251 .unwrap_err()
1252 .to_string()
1253 .contains("Invalid issuer address length"));
1254 }
1255
1256 #[test]
1257 fn test_classic_asset_invalid_issuer_prefix() {
1258 let result = StellarTransactionValidator::validate_fee_token_structure(
1259 "USDC:SAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF",
1260 );
1261 assert!(result.is_err());
1262 assert!(result
1263 .unwrap_err()
1264 .to_string()
1265 .contains("Invalid issuer address prefix"));
1266 }
1267
1268 #[test]
1269 fn test_invalid_format_multiple_colons() {
1270 let result =
1271 StellarTransactionValidator::validate_fee_token_structure("USDC:ISSUER:EXTRA");
1272 assert!(result.is_err());
1273 assert!(result
1274 .unwrap_err()
1275 .to_string()
1276 .contains("Invalid fee_token format"));
1277 }
1278 }
1279
1280 mod validate_allowed_token_tests {
1281 use super::*;
1282
1283 #[test]
1284 fn test_empty_allowed_list_allows_all() {
1285 let policy = RelayerStellarPolicy::default();
1286 assert!(StellarTransactionValidator::validate_allowed_token("native", &policy).is_ok());
1287 assert!(
1288 StellarTransactionValidator::validate_allowed_token(TEST_CONTRACT, &policy).is_ok()
1289 );
1290 }
1291
1292 #[test]
1293 fn test_native_allowed() {
1294 let mut policy = RelayerStellarPolicy::default();
1295 policy.allowed_tokens = Some(vec![StellarAllowedTokensPolicy {
1296 asset: "native".to_string(),
1297 metadata: None,
1298 swap_config: None,
1299 max_allowed_fee: None,
1300 }]);
1301 assert!(StellarTransactionValidator::validate_allowed_token("native", &policy).is_ok());
1302 assert!(StellarTransactionValidator::validate_allowed_token("", &policy).is_ok());
1303 }
1304
1305 #[test]
1306 fn test_native_not_allowed() {
1307 let mut policy = RelayerStellarPolicy::default();
1308 policy.allowed_tokens = Some(vec![StellarAllowedTokensPolicy {
1309 asset: format!("USDC:{}", TEST_PK),
1310 metadata: None,
1311 swap_config: None,
1312 max_allowed_fee: None,
1313 }]);
1314 let result = StellarTransactionValidator::validate_allowed_token("native", &policy);
1315 assert!(result.is_err());
1316 assert!(result
1317 .unwrap_err()
1318 .to_string()
1319 .contains("Native XLM not in allowed tokens list"));
1320 }
1321
1322 #[test]
1323 fn test_token_allowed() {
1324 let token = format!("USDC:{}", TEST_PK);
1325 let mut policy = RelayerStellarPolicy::default();
1326 policy.allowed_tokens = Some(vec![StellarAllowedTokensPolicy {
1327 asset: token.clone(),
1328 metadata: None,
1329 swap_config: None,
1330 max_allowed_fee: None,
1331 }]);
1332 assert!(StellarTransactionValidator::validate_allowed_token(&token, &policy).is_ok());
1333 }
1334
1335 #[test]
1336 fn test_token_not_allowed() {
1337 let mut policy = RelayerStellarPolicy::default();
1338 policy.allowed_tokens = Some(vec![StellarAllowedTokensPolicy {
1339 asset: format!("USDC:{}", TEST_PK),
1340 metadata: None,
1341 swap_config: None,
1342 max_allowed_fee: None,
1343 }]);
1344 let result = StellarTransactionValidator::validate_allowed_token(
1345 &format!("AQUA:{}", TEST_PK_2),
1346 &policy,
1347 );
1348 assert!(result.is_err());
1349 assert!(result
1350 .unwrap_err()
1351 .to_string()
1352 .contains("not in allowed tokens list"));
1353 }
1354 }
1355
1356 mod validate_max_fee_tests {
1357 use super::*;
1358
1359 #[test]
1360 fn test_no_max_fee_allows_any() {
1361 let policy = RelayerStellarPolicy::default();
1362 assert!(StellarTransactionValidator::validate_max_fee(1_000_000, &policy).is_ok());
1363 }
1364
1365 #[test]
1366 fn test_fee_within_limit() {
1367 let mut policy = RelayerStellarPolicy::default();
1368 policy.max_fee = Some(1_000_000);
1369 assert!(StellarTransactionValidator::validate_max_fee(500_000, &policy).is_ok());
1370 }
1371
1372 #[test]
1373 fn test_fee_exceeds_limit() {
1374 let mut policy = RelayerStellarPolicy::default();
1375 policy.max_fee = Some(1_000_000);
1376 let result = StellarTransactionValidator::validate_max_fee(2_000_000, &policy);
1377 assert!(result.is_err());
1378 assert!(result.unwrap_err().to_string().contains("Max fee exceeded"));
1379 }
1380 }
1381
1382 mod validate_token_max_fee_tests {
1383 use super::*;
1384
1385 #[test]
1386 fn test_no_token_entry() {
1387 let policy = RelayerStellarPolicy::default();
1388 assert!(StellarTransactionValidator::validate_token_max_fee(
1389 "USDC:ISSUER",
1390 1_000_000,
1391 &policy
1392 )
1393 .is_ok());
1394 }
1395
1396 #[test]
1397 fn test_no_max_allowed_fee_in_entry() {
1398 let mut policy = RelayerStellarPolicy::default();
1399 policy.allowed_tokens = Some(vec![StellarAllowedTokensPolicy {
1400 asset: "USDC:ISSUER".to_string(),
1401 metadata: None,
1402 swap_config: None,
1403 max_allowed_fee: None,
1404 }]);
1405 assert!(StellarTransactionValidator::validate_token_max_fee(
1406 "USDC:ISSUER",
1407 1_000_000,
1408 &policy
1409 )
1410 .is_ok());
1411 }
1412
1413 #[test]
1414 fn test_fee_within_token_limit() {
1415 let mut policy = RelayerStellarPolicy::default();
1416 policy.allowed_tokens = Some(vec![StellarAllowedTokensPolicy {
1417 asset: "USDC:ISSUER".to_string(),
1418 metadata: None,
1419 swap_config: None,
1420 max_allowed_fee: Some(1_000_000),
1421 }]);
1422 assert!(StellarTransactionValidator::validate_token_max_fee(
1423 "USDC:ISSUER",
1424 500_000,
1425 &policy
1426 )
1427 .is_ok());
1428 }
1429
1430 #[test]
1431 fn test_fee_exceeds_token_limit() {
1432 let mut policy = RelayerStellarPolicy::default();
1433 policy.allowed_tokens = Some(vec![StellarAllowedTokensPolicy {
1434 asset: "USDC:ISSUER".to_string(),
1435 metadata: None,
1436 swap_config: None,
1437 max_allowed_fee: Some(1_000_000),
1438 }]);
1439 let result = StellarTransactionValidator::validate_token_max_fee(
1440 "USDC:ISSUER",
1441 2_000_000,
1442 &policy,
1443 );
1444 assert!(result.is_err());
1445 assert!(result.unwrap_err().to_string().contains("Max fee exceeded"));
1446 }
1447 }
1448
1449 mod extract_relayer_payments_tests {
1450 use super::*;
1451
1452 #[test]
1453 fn test_extract_single_payment() {
1454 let envelope = create_simple_v1_envelope(TEST_PK, TEST_PK_2);
1455 let payments =
1456 StellarTransactionValidator::extract_relayer_payments(&envelope, TEST_PK_2)
1457 .unwrap();
1458 assert_eq!(payments.len(), 1);
1459 assert_eq!(payments[0].0, "native");
1460 assert_eq!(payments[0].1, 1_000_000);
1461 }
1462
1463 #[test]
1464 fn test_extract_no_payments_to_relayer() {
1465 let envelope = create_simple_v1_envelope(TEST_PK, TEST_PK_2);
1466 let payments =
1467 StellarTransactionValidator::extract_relayer_payments(&envelope, TEST_PK).unwrap();
1468 assert_eq!(payments.len(), 0);
1469 }
1470
1471 #[test]
1472 fn test_extract_negative_amount_rejected() {
1473 let payment_op = Operation {
1474 source_account: None,
1475 body: OperationBody::Payment(soroban_rs::xdr::PaymentOp {
1476 destination: create_muxed_account(TEST_PK_2),
1477 asset: XdrAsset::Native,
1478 amount: -100, }),
1480 };
1481
1482 let tx = Transaction {
1483 source_account: create_muxed_account(TEST_PK),
1484 fee: 100,
1485 seq_num: SequenceNumber(1),
1486 cond: soroban_rs::xdr::Preconditions::None,
1487 memo: soroban_rs::xdr::Memo::None,
1488 operations: vec![payment_op].try_into().unwrap(),
1489 ext: TransactionExt::V0,
1490 };
1491
1492 let envelope = TransactionEnvelope::Tx(TransactionV1Envelope {
1493 tx,
1494 signatures: vec![].try_into().unwrap(),
1495 });
1496
1497 let result =
1498 StellarTransactionValidator::extract_relayer_payments(&envelope, TEST_PK_2);
1499 assert!(result.is_err());
1500 assert!(result
1501 .unwrap_err()
1502 .to_string()
1503 .contains("Negative payment amount"));
1504 }
1505 }
1506
1507 mod validate_time_bounds_tests {
1508 use super::*;
1509
1510 #[test]
1511 fn test_no_time_bounds_is_ok() {
1512 let envelope = create_simple_v1_envelope(TEST_PK, TEST_PK_2);
1513 assert!(
1514 StellarTransactionValidator::validate_time_bounds_not_expired(&envelope).is_ok()
1515 );
1516 }
1517
1518 #[test]
1519 fn test_valid_time_bounds() {
1520 let now = Utc::now().timestamp() as u64;
1521 let payment_op = create_native_payment_operation(TEST_PK_2, 1_000_000);
1522
1523 let tx = Transaction {
1524 source_account: create_muxed_account(TEST_PK),
1525 fee: 100,
1526 seq_num: SequenceNumber(1),
1527 cond: soroban_rs::xdr::Preconditions::Time(TimeBounds {
1528 min_time: TimePoint(now - 60),
1529 max_time: TimePoint(now + 60),
1530 }),
1531 memo: soroban_rs::xdr::Memo::None,
1532 operations: vec![payment_op].try_into().unwrap(),
1533 ext: TransactionExt::V0,
1534 };
1535
1536 let envelope = TransactionEnvelope::Tx(TransactionV1Envelope {
1537 tx,
1538 signatures: vec![].try_into().unwrap(),
1539 });
1540
1541 assert!(
1542 StellarTransactionValidator::validate_time_bounds_not_expired(&envelope).is_ok()
1543 );
1544 }
1545
1546 #[test]
1547 fn test_expired_transaction() {
1548 let now = Utc::now().timestamp() as u64;
1549 let payment_op = create_native_payment_operation(TEST_PK_2, 1_000_000);
1550
1551 let tx = Transaction {
1552 source_account: create_muxed_account(TEST_PK),
1553 fee: 100,
1554 seq_num: SequenceNumber(1),
1555 cond: soroban_rs::xdr::Preconditions::Time(TimeBounds {
1556 min_time: TimePoint(now - 120),
1557 max_time: TimePoint(now - 60), }),
1559 memo: soroban_rs::xdr::Memo::None,
1560 operations: vec![payment_op].try_into().unwrap(),
1561 ext: TransactionExt::V0,
1562 };
1563
1564 let envelope = TransactionEnvelope::Tx(TransactionV1Envelope {
1565 tx,
1566 signatures: vec![].try_into().unwrap(),
1567 });
1568
1569 let result = StellarTransactionValidator::validate_time_bounds_not_expired(&envelope);
1570 assert!(result.is_err());
1571 assert!(result.unwrap_err().to_string().contains("has expired"));
1572 }
1573
1574 #[test]
1575 fn test_not_yet_valid_transaction() {
1576 let now = Utc::now().timestamp() as u64;
1577 let payment_op = create_native_payment_operation(TEST_PK_2, 1_000_000);
1578
1579 let tx = Transaction {
1580 source_account: create_muxed_account(TEST_PK),
1581 fee: 100,
1582 seq_num: SequenceNumber(1),
1583 cond: soroban_rs::xdr::Preconditions::Time(TimeBounds {
1584 min_time: TimePoint(now + 60), max_time: TimePoint(now + 120),
1586 }),
1587 memo: soroban_rs::xdr::Memo::None,
1588 operations: vec![payment_op].try_into().unwrap(),
1589 ext: TransactionExt::V0,
1590 };
1591
1592 let envelope = TransactionEnvelope::Tx(TransactionV1Envelope {
1593 tx,
1594 signatures: vec![].try_into().unwrap(),
1595 });
1596
1597 let result = StellarTransactionValidator::validate_time_bounds_not_expired(&envelope);
1598 assert!(result.is_err());
1599 assert!(result.unwrap_err().to_string().contains("not yet valid"));
1600 }
1601 }
1602
1603 mod validate_transaction_validity_duration_tests {
1604 use super::*;
1605
1606 #[test]
1607 fn test_duration_within_limit() {
1608 let now = Utc::now().timestamp() as u64;
1609 let payment_op = create_native_payment_operation(TEST_PK_2, 1_000_000);
1610
1611 let tx = Transaction {
1612 source_account: create_muxed_account(TEST_PK),
1613 fee: 100,
1614 seq_num: SequenceNumber(1),
1615 cond: soroban_rs::xdr::Preconditions::Time(TimeBounds {
1616 min_time: TimePoint(0),
1617 max_time: TimePoint(now + 60), }),
1619 memo: soroban_rs::xdr::Memo::None,
1620 operations: vec![payment_op].try_into().unwrap(),
1621 ext: TransactionExt::V0,
1622 };
1623
1624 let envelope = TransactionEnvelope::Tx(TransactionV1Envelope {
1625 tx,
1626 signatures: vec![].try_into().unwrap(),
1627 });
1628
1629 let max_duration = Duration::minutes(5);
1630 assert!(
1631 StellarTransactionValidator::validate_transaction_validity_duration(
1632 &envelope,
1633 max_duration
1634 )
1635 .is_ok()
1636 );
1637 }
1638
1639 #[test]
1640 fn test_duration_exceeds_limit() {
1641 let now = Utc::now().timestamp() as u64;
1642 let payment_op = create_native_payment_operation(TEST_PK_2, 1_000_000);
1643
1644 let tx = Transaction {
1645 source_account: create_muxed_account(TEST_PK),
1646 fee: 100,
1647 seq_num: SequenceNumber(1),
1648 cond: soroban_rs::xdr::Preconditions::Time(TimeBounds {
1649 min_time: TimePoint(0),
1650 max_time: TimePoint(now + 600), }),
1652 memo: soroban_rs::xdr::Memo::None,
1653 operations: vec![payment_op].try_into().unwrap(),
1654 ext: TransactionExt::V0,
1655 };
1656
1657 let envelope = TransactionEnvelope::Tx(TransactionV1Envelope {
1658 tx,
1659 signatures: vec![].try_into().unwrap(),
1660 });
1661
1662 let max_duration = Duration::minutes(5);
1663 let result = StellarTransactionValidator::validate_transaction_validity_duration(
1664 &envelope,
1665 max_duration,
1666 );
1667 assert!(result.is_err());
1668 assert!(result
1669 .unwrap_err()
1670 .to_string()
1671 .contains("exceeds maximum allowed duration"));
1672 }
1673
1674 #[test]
1675 fn test_no_time_bounds_rejected() {
1676 let envelope = create_simple_v1_envelope(TEST_PK, TEST_PK_2);
1677 let max_duration = Duration::minutes(5);
1678 let result = StellarTransactionValidator::validate_transaction_validity_duration(
1679 &envelope,
1680 max_duration,
1681 );
1682 assert!(result.is_err());
1683 assert!(result
1684 .unwrap_err()
1685 .to_string()
1686 .contains("must have time bounds set"));
1687 }
1688 }
1689
1690 mod validate_sequence_number_tests {
1691 use super::*;
1692
1693 #[tokio::test]
1694 async fn test_valid_sequence_number() {
1695 let envelope = create_simple_v1_envelope(TEST_PK, TEST_PK_2);
1696
1697 let mut provider = MockStellarProviderTrait::new();
1698 provider.expect_get_account().returning(|_| {
1699 Box::pin(ready(Ok(AccountEntry {
1700 account_id: create_account_id(TEST_PK),
1701 balance: 1_000_000_000,
1702 seq_num: SequenceNumber(0), num_sub_entries: 0,
1704 inflation_dest: None,
1705 flags: 0,
1706 home_domain: Default::default(),
1707 thresholds: Thresholds([0; 4]),
1708 signers: Default::default(),
1709 ext: AccountEntryExt::V0,
1710 })))
1711 });
1712
1713 assert!(
1714 StellarTransactionValidator::validate_sequence_number(&envelope, &provider)
1715 .await
1716 .is_ok()
1717 );
1718 }
1719
1720 #[tokio::test]
1721 async fn test_equal_sequence_rejected() {
1722 let envelope = create_simple_v1_envelope(TEST_PK, TEST_PK_2);
1723
1724 let mut provider = MockStellarProviderTrait::new();
1725 provider.expect_get_account().returning(|_| {
1726 Box::pin(ready(Ok(AccountEntry {
1727 account_id: create_account_id(TEST_PK),
1728 balance: 1_000_000_000,
1729 seq_num: SequenceNumber(1), num_sub_entries: 0,
1731 inflation_dest: None,
1732 flags: 0,
1733 home_domain: Default::default(),
1734 thresholds: Thresholds([0; 4]),
1735 signers: Default::default(),
1736 ext: AccountEntryExt::V0,
1737 })))
1738 });
1739
1740 let result =
1741 StellarTransactionValidator::validate_sequence_number(&envelope, &provider).await;
1742 assert!(result.is_err());
1743 assert!(result
1744 .unwrap_err()
1745 .to_string()
1746 .contains("strictly greater than"));
1747 }
1748
1749 #[tokio::test]
1750 async fn test_past_sequence_rejected() {
1751 let envelope = create_simple_v1_envelope(TEST_PK, TEST_PK_2);
1752
1753 let mut provider = MockStellarProviderTrait::new();
1754 provider.expect_get_account().returning(|_| {
1755 Box::pin(ready(Ok(AccountEntry {
1756 account_id: create_account_id(TEST_PK),
1757 balance: 1_000_000_000,
1758 seq_num: SequenceNumber(10), num_sub_entries: 0,
1760 inflation_dest: None,
1761 flags: 0,
1762 home_domain: Default::default(),
1763 thresholds: Thresholds([0; 4]),
1764 signers: Default::default(),
1765 ext: AccountEntryExt::V0,
1766 })))
1767 });
1768
1769 let result =
1770 StellarTransactionValidator::validate_sequence_number(&envelope, &provider).await;
1771 assert!(result.is_err());
1772 assert!(result.unwrap_err().to_string().contains("is invalid"));
1773 }
1774 }
1775
1776 mod validate_operations_count_tests {
1777 use super::*;
1778
1779 #[test]
1780 fn test_valid_operations_count() {
1781 let envelope = create_simple_v1_envelope(TEST_PK, TEST_PK_2);
1782 assert!(StellarTransactionValidator::validate_operations_count(&envelope).is_ok());
1783 }
1784
1785 #[test]
1786 fn test_too_many_operations() {
1787 let operations: Vec<Operation> = (0..100)
1793 .map(|_| create_native_payment_operation(TEST_PK_2, 100))
1794 .collect();
1795
1796 let tx = Transaction {
1797 source_account: create_muxed_account(TEST_PK),
1798 fee: 100,
1799 seq_num: SequenceNumber(1),
1800 cond: soroban_rs::xdr::Preconditions::None,
1801 memo: soroban_rs::xdr::Memo::None,
1802 operations: operations.try_into().unwrap(),
1803 ext: TransactionExt::V0,
1804 };
1805
1806 let envelope = TransactionEnvelope::Tx(TransactionV1Envelope {
1807 tx,
1808 signatures: vec![].try_into().unwrap(),
1809 });
1810
1811 let result = StellarTransactionValidator::validate_operations_count(&envelope);
1813 assert!(result.is_ok());
1814 }
1815 }
1816
1817 mod validate_source_account_tests {
1818 use super::*;
1819
1820 #[test]
1821 fn test_source_account_not_relayer() {
1822 let envelope = create_simple_v1_envelope(TEST_PK, TEST_PK_2);
1823 assert!(
1824 StellarTransactionValidator::validate_source_account_not_relayer(
1825 &envelope, TEST_PK_2
1826 )
1827 .is_ok()
1828 );
1829 }
1830
1831 #[test]
1832 fn test_source_account_is_relayer_rejected() {
1833 let envelope = create_simple_v1_envelope(TEST_PK, TEST_PK_2);
1834 let result = StellarTransactionValidator::validate_source_account_not_relayer(
1835 &envelope, TEST_PK,
1836 );
1837 assert!(result.is_err());
1838 assert!(result
1839 .unwrap_err()
1840 .to_string()
1841 .contains("cannot be the relayer address"));
1842 }
1843 }
1844
1845 mod validate_operation_types_tests {
1846 use super::*;
1847
1848 #[test]
1849 fn test_payment_operation_allowed() {
1850 let envelope = create_simple_v1_envelope(TEST_PK, TEST_PK_2);
1851 let policy = RelayerStellarPolicy::default();
1852 assert!(StellarTransactionValidator::validate_operation_types(
1853 &envelope, TEST_PK_2, &policy
1854 )
1855 .is_ok());
1856 }
1857
1858 #[test]
1859 fn test_account_merge_rejected() {
1860 let operation = Operation {
1861 source_account: None,
1862 body: OperationBody::AccountMerge(create_muxed_account(TEST_PK_2)),
1863 };
1864
1865 let tx = Transaction {
1866 source_account: create_muxed_account(TEST_PK),
1867 fee: 100,
1868 seq_num: SequenceNumber(1),
1869 cond: soroban_rs::xdr::Preconditions::None,
1870 memo: soroban_rs::xdr::Memo::None,
1871 operations: vec![operation].try_into().unwrap(),
1872 ext: TransactionExt::V0,
1873 };
1874
1875 let envelope = TransactionEnvelope::Tx(TransactionV1Envelope {
1876 tx,
1877 signatures: vec![].try_into().unwrap(),
1878 });
1879
1880 let policy = RelayerStellarPolicy::default();
1881 let result = StellarTransactionValidator::validate_operation_types(
1882 &envelope, TEST_PK_2, &policy,
1883 );
1884 assert!(result.is_err());
1885 assert!(result
1886 .unwrap_err()
1887 .to_string()
1888 .contains("AccountMerge operations are not allowed"));
1889 }
1890
1891 #[test]
1892 fn test_set_options_rejected() {
1893 let operation = Operation {
1894 source_account: None,
1895 body: OperationBody::SetOptions(soroban_rs::xdr::SetOptionsOp {
1896 inflation_dest: None,
1897 clear_flags: None,
1898 set_flags: None,
1899 master_weight: None,
1900 low_threshold: None,
1901 med_threshold: None,
1902 high_threshold: None,
1903 home_domain: None,
1904 signer: None,
1905 }),
1906 };
1907
1908 let tx = Transaction {
1909 source_account: create_muxed_account(TEST_PK),
1910 fee: 100,
1911 seq_num: SequenceNumber(1),
1912 cond: soroban_rs::xdr::Preconditions::None,
1913 memo: soroban_rs::xdr::Memo::None,
1914 operations: vec![operation].try_into().unwrap(),
1915 ext: TransactionExt::V0,
1916 };
1917
1918 let envelope = TransactionEnvelope::Tx(TransactionV1Envelope {
1919 tx,
1920 signatures: vec![].try_into().unwrap(),
1921 });
1922
1923 let policy = RelayerStellarPolicy::default();
1924 let result = StellarTransactionValidator::validate_operation_types(
1925 &envelope, TEST_PK_2, &policy,
1926 );
1927 assert!(result.is_err());
1928 assert!(result
1929 .unwrap_err()
1930 .to_string()
1931 .contains("SetOptions operations are not allowed"));
1932 }
1933
1934 #[test]
1935 fn test_change_trust_allowed() {
1936 let operation = Operation {
1937 source_account: None,
1938 body: OperationBody::ChangeTrust(ChangeTrustOp {
1939 line: ChangeTrustAsset::CreditAlphanum4(soroban_rs::xdr::AlphaNum4 {
1940 asset_code: soroban_rs::xdr::AssetCode4(*b"USDC"),
1941 issuer: create_account_id(TEST_PK_2),
1942 }),
1943 limit: 1_000_000_000,
1944 }),
1945 };
1946
1947 let tx = Transaction {
1948 source_account: create_muxed_account(TEST_PK),
1949 fee: 100,
1950 seq_num: SequenceNumber(1),
1951 cond: soroban_rs::xdr::Preconditions::None,
1952 memo: soroban_rs::xdr::Memo::None,
1953 operations: vec![operation].try_into().unwrap(),
1954 ext: TransactionExt::V0,
1955 };
1956
1957 let envelope = TransactionEnvelope::Tx(TransactionV1Envelope {
1958 tx,
1959 signatures: vec![].try_into().unwrap(),
1960 });
1961
1962 let policy = RelayerStellarPolicy::default();
1963 assert!(StellarTransactionValidator::validate_operation_types(
1964 &envelope, TEST_PK_2, &policy
1965 )
1966 .is_ok());
1967 }
1968 }
1969
1970 mod validate_token_payment_tests {
1971 use super::*;
1972
1973 #[test]
1974 fn test_valid_native_payment() {
1975 let envelope = create_simple_v1_envelope(TEST_PK, TEST_PK_2);
1976 let policy = RelayerStellarPolicy::default();
1977
1978 let result = StellarTransactionValidator::validate_token_payment(
1979 &envelope, TEST_PK_2, "native", 1_000_000, &policy,
1980 );
1981 assert!(result.is_ok());
1982 }
1983
1984 #[test]
1985 fn test_no_payment_to_relayer() {
1986 let envelope = create_simple_v1_envelope(TEST_PK, TEST_PK_2);
1987 let policy = RelayerStellarPolicy::default();
1988
1989 let result = StellarTransactionValidator::validate_token_payment(
1991 &envelope, TEST_PK, "native", 1_000_000, &policy,
1993 );
1994 assert!(result.is_err());
1995 assert!(result
1996 .unwrap_err()
1997 .to_string()
1998 .contains("No payment operation found to relayer"));
1999 }
2000
2001 #[test]
2002 fn test_wrong_token_in_payment() {
2003 let envelope = create_simple_v1_envelope(TEST_PK, TEST_PK_2);
2004 let policy = RelayerStellarPolicy::default();
2005
2006 let result = StellarTransactionValidator::validate_token_payment(
2008 &envelope,
2009 TEST_PK_2,
2010 &format!("USDC:{}", TEST_PK),
2011 1_000_000,
2012 &policy,
2013 );
2014 assert!(result.is_err());
2015 assert!(result
2016 .unwrap_err()
2017 .to_string()
2018 .contains("No payment found for expected token"));
2019 }
2020
2021 #[test]
2022 fn test_insufficient_payment_amount() {
2023 let envelope = create_simple_v1_envelope(TEST_PK, TEST_PK_2);
2024 let policy = RelayerStellarPolicy::default();
2025
2026 let result = StellarTransactionValidator::validate_token_payment(
2028 &envelope, TEST_PK_2, "native", 2_000_000, &policy,
2029 );
2030 assert!(result.is_err());
2031 assert!(result
2032 .unwrap_err()
2033 .to_string()
2034 .contains("Insufficient token payment"));
2035 }
2036
2037 #[test]
2038 fn test_payment_within_tolerance() {
2039 let envelope = create_simple_v1_envelope(TEST_PK, TEST_PK_2);
2040 let policy = RelayerStellarPolicy::default();
2041
2042 let result = StellarTransactionValidator::validate_token_payment(
2043 &envelope, TEST_PK_2, "native", 990_000, &policy,
2044 );
2045 assert!(result.is_ok());
2046 }
2047
2048 #[test]
2049 fn test_token_not_in_allowed_list() {
2050 let envelope = create_simple_v1_envelope(TEST_PK, TEST_PK_2);
2051 let mut policy = RelayerStellarPolicy::default();
2052 policy.allowed_tokens = Some(vec![StellarAllowedTokensPolicy {
2053 asset: format!("USDC:{}", TEST_PK),
2054 metadata: None,
2055 swap_config: None,
2056 max_allowed_fee: None,
2057 }]);
2058
2059 let result = StellarTransactionValidator::validate_token_payment(
2061 &envelope, TEST_PK_2, "native", 1_000_000, &policy,
2062 );
2063 assert!(result.is_err());
2064 assert!(result
2065 .unwrap_err()
2066 .to_string()
2067 .contains("not in allowed tokens list"));
2068 }
2069
2070 #[test]
2071 fn test_payment_exceeds_token_max_fee() {
2072 let envelope = create_simple_v1_envelope(TEST_PK, TEST_PK_2);
2073 let mut policy = RelayerStellarPolicy::default();
2074 policy.allowed_tokens = Some(vec![StellarAllowedTokensPolicy {
2075 asset: "native".to_string(),
2076 metadata: None,
2077 swap_config: None,
2078 max_allowed_fee: Some(500_000), }]);
2080
2081 let result = StellarTransactionValidator::validate_token_payment(
2083 &envelope, TEST_PK_2, "native", 1_000_000, &policy,
2084 );
2085 assert!(result.is_err());
2086 assert!(result.unwrap_err().to_string().contains("Max fee exceeded"));
2087 }
2088
2089 #[test]
2090 fn test_classic_asset_payment() {
2091 let usdc_asset = format!("USDC:{}", TEST_PK);
2092 let payment_op = Operation {
2093 source_account: None,
2094 body: OperationBody::Payment(soroban_rs::xdr::PaymentOp {
2095 destination: create_muxed_account(TEST_PK_2),
2096 asset: XdrAsset::CreditAlphanum4(soroban_rs::xdr::AlphaNum4 {
2097 asset_code: soroban_rs::xdr::AssetCode4(*b"USDC"),
2098 issuer: create_account_id(TEST_PK),
2099 }),
2100 amount: 1_000_000,
2101 }),
2102 };
2103
2104 let tx = Transaction {
2105 source_account: create_muxed_account(TEST_PK),
2106 fee: 100,
2107 seq_num: SequenceNumber(1),
2108 cond: soroban_rs::xdr::Preconditions::None,
2109 memo: soroban_rs::xdr::Memo::None,
2110 operations: vec![payment_op].try_into().unwrap(),
2111 ext: TransactionExt::V0,
2112 };
2113
2114 let envelope = TransactionEnvelope::Tx(TransactionV1Envelope {
2115 tx,
2116 signatures: vec![].try_into().unwrap(),
2117 });
2118
2119 let mut policy = RelayerStellarPolicy::default();
2120 policy.allowed_tokens = Some(vec![StellarAllowedTokensPolicy {
2121 asset: usdc_asset.clone(),
2122 metadata: None,
2123 swap_config: None,
2124 max_allowed_fee: None,
2125 }]);
2126
2127 let result = StellarTransactionValidator::validate_token_payment(
2128 &envelope,
2129 TEST_PK_2,
2130 &usdc_asset,
2131 1_000_000,
2132 &policy,
2133 );
2134 assert!(result.is_ok());
2135 }
2136
2137 #[test]
2138 fn test_multiple_payments_finds_correct_token() {
2139 let usdc_asset = format!("USDC:{}", TEST_PK);
2141 let usdc_payment = Operation {
2142 source_account: None,
2143 body: OperationBody::Payment(soroban_rs::xdr::PaymentOp {
2144 destination: create_muxed_account(TEST_PK_2),
2145 asset: XdrAsset::CreditAlphanum4(soroban_rs::xdr::AlphaNum4 {
2146 asset_code: soroban_rs::xdr::AssetCode4(*b"USDC"),
2147 issuer: create_account_id(TEST_PK),
2148 }),
2149 amount: 500_000,
2150 }),
2151 };
2152
2153 let xlm_payment = create_native_payment_operation(TEST_PK, 1_000_000);
2154
2155 let tx = Transaction {
2156 source_account: create_muxed_account(TEST_PK),
2157 fee: 100,
2158 seq_num: SequenceNumber(1),
2159 cond: soroban_rs::xdr::Preconditions::None,
2160 memo: soroban_rs::xdr::Memo::None,
2161 operations: vec![xlm_payment, usdc_payment].try_into().unwrap(),
2162 ext: TransactionExt::V0,
2163 };
2164
2165 let envelope = TransactionEnvelope::Tx(TransactionV1Envelope {
2166 tx,
2167 signatures: vec![].try_into().unwrap(),
2168 });
2169
2170 let policy = RelayerStellarPolicy::default();
2171
2172 let result = StellarTransactionValidator::validate_token_payment(
2174 &envelope,
2175 TEST_PK_2,
2176 &usdc_asset,
2177 500_000,
2178 &policy,
2179 );
2180 assert!(result.is_ok());
2181 }
2182 }
2183
2184 mod validate_user_fee_payment_amounts_tests {
2185 use super::*;
2186 use soroban_rs::stellar_rpc_client::{
2187 GetLatestLedgerResponse, SimulateTransactionResponse,
2188 };
2189 use soroban_rs::xdr::WriteXdr;
2190
2191 const USDC_ISSUER: &str = TEST_PK;
2192
2193 fn create_usdc_payment_envelope(
2194 source: &str,
2195 destination: &str,
2196 amount: i64,
2197 ) -> TransactionEnvelope {
2198 let payment_op = Operation {
2199 source_account: None,
2200 body: OperationBody::Payment(PaymentOp {
2201 destination: create_muxed_account(destination),
2202 asset: soroban_rs::xdr::Asset::CreditAlphanum4(soroban_rs::xdr::AlphaNum4 {
2203 asset_code: soroban_rs::xdr::AssetCode4(*b"USDC"),
2204 issuer: create_account_id(USDC_ISSUER),
2205 }),
2206 amount,
2207 }),
2208 };
2209
2210 let tx = Transaction {
2211 source_account: create_muxed_account(source),
2212 fee: 100,
2213 seq_num: SequenceNumber(1),
2214 cond: soroban_rs::xdr::Preconditions::None,
2215 memo: soroban_rs::xdr::Memo::None,
2216 operations: vec![payment_op].try_into().unwrap(),
2217 ext: TransactionExt::V0,
2218 };
2219
2220 TransactionEnvelope::Tx(TransactionV1Envelope {
2221 tx,
2222 signatures: vec![].try_into().unwrap(),
2223 })
2224 }
2225
2226 fn create_usdc_policy() -> RelayerStellarPolicy {
2227 let usdc_asset = format!("USDC:{}", USDC_ISSUER);
2228 let mut policy = RelayerStellarPolicy::default();
2229 policy.allowed_tokens = Some(vec![StellarAllowedTokensPolicy {
2230 asset: usdc_asset,
2231 metadata: None,
2232 swap_config: None,
2233 max_allowed_fee: None,
2234 }]);
2235 policy
2236 }
2237
2238 fn create_mock_provider_with_balance(balance: i64) -> MockStellarProviderTrait {
2239 let mut provider = MockStellarProviderTrait::new();
2240
2241 provider.expect_get_account().returning(move |_| {
2243 Box::pin(ready(Ok(AccountEntry {
2244 account_id: create_account_id(TEST_PK),
2245 balance,
2246 seq_num: SequenceNumber(1),
2247 num_sub_entries: 0,
2248 inflation_dest: None,
2249 flags: 0,
2250 home_domain: Default::default(),
2251 thresholds: Thresholds([0; 4]),
2252 signers: Default::default(),
2253 ext: AccountEntryExt::V0,
2254 })))
2255 });
2256
2257 provider.expect_get_latest_ledger().returning(|| {
2259 Box::pin(ready(Ok(GetLatestLedgerResponse {
2260 id: "test".to_string(),
2261 protocol_version: 20,
2262 sequence: 1000,
2263 })))
2264 });
2265
2266 provider
2268 .expect_simulate_transaction_envelope()
2269 .returning(|_| {
2270 Box::pin(ready(Ok(SimulateTransactionResponse {
2271 min_resource_fee: 100,
2272 transaction_data: String::new(),
2273 ..Default::default()
2274 })))
2275 });
2276
2277 provider.expect_get_ledger_entries().returning(|_| {
2279 use soroban_rs::stellar_rpc_client::{GetLedgerEntriesResponse, LedgerEntryResult};
2280 use soroban_rs::xdr::{
2281 LedgerEntry, LedgerEntryData, LedgerEntryExt, TrustLineAsset, TrustLineEntry,
2282 TrustLineEntryExt,
2283 };
2284
2285 let trustline_entry = TrustLineEntry {
2286 account_id: create_account_id(TEST_PK),
2287 asset: TrustLineAsset::CreditAlphanum4(soroban_rs::xdr::AlphaNum4 {
2288 asset_code: soroban_rs::xdr::AssetCode4(*b"USDC"),
2289 issuer: create_account_id(TEST_PK_2),
2290 }),
2291 balance: 10_000_000, limit: 1_000_000_000,
2293 flags: 1,
2294 ext: TrustLineEntryExt::V0,
2295 };
2296
2297 let ledger_entry = LedgerEntry {
2298 last_modified_ledger_seq: 0,
2299 data: LedgerEntryData::Trustline(trustline_entry),
2300 ext: LedgerEntryExt::V0,
2301 };
2302
2303 let xdr_base64 = ledger_entry
2304 .data
2305 .to_xdr_base64(soroban_rs::xdr::Limits::none())
2306 .unwrap();
2307
2308 Box::pin(ready(Ok(GetLedgerEntriesResponse {
2309 entries: Some(vec![LedgerEntryResult {
2310 key: String::new(),
2311 xdr: xdr_base64,
2312 last_modified_ledger: 0,
2313 live_until_ledger_seq_ledger_seq: None,
2314 }]),
2315 latest_ledger: 0,
2316 })))
2317 });
2318
2319 provider
2320 }
2321
2322 fn create_mock_dex_service() -> MockStellarDexServiceTrait {
2323 let mut dex_service = MockStellarDexServiceTrait::new();
2324 dex_service
2325 .expect_get_xlm_to_token_quote()
2326 .returning(|_, _, _, _| {
2327 Box::pin(ready(Ok(
2328 crate::services::stellar_dex::StellarQuoteResponse {
2329 input_asset: "native".to_string(),
2330 output_asset: format!("USDC:{}", USDC_ISSUER),
2331 in_amount: 100,
2332 out_amount: 1_000_000, price_impact_pct: 0.0,
2334 slippage_bps: 100,
2335 path: None,
2336 },
2337 )))
2338 });
2339 dex_service
2340 }
2341
2342 #[tokio::test]
2343 async fn test_valid_fee_payment() {
2344 let envelope = create_usdc_payment_envelope(TEST_PK, TEST_PK_2, 1_000_000);
2345 let policy = create_usdc_policy();
2346 let provider = create_mock_provider_with_balance(10_000_000_000);
2347 let dex_service = create_mock_dex_service();
2348
2349 let result = StellarTransactionValidator::validate_user_fee_payment_amounts(
2350 &envelope,
2351 TEST_PK_2,
2352 &policy,
2353 &provider,
2354 &dex_service,
2355 )
2356 .await;
2357
2358 assert!(result.is_ok());
2359 }
2360
2361 #[tokio::test]
2362 async fn test_no_fee_payment() {
2363 let envelope = create_usdc_payment_envelope(TEST_PK, TEST_PK, 1_000_000);
2365 let policy = create_usdc_policy();
2366 let provider = create_mock_provider_with_balance(10_000_000_000);
2367 let dex_service = create_mock_dex_service();
2368
2369 let result = StellarTransactionValidator::validate_user_fee_payment_amounts(
2370 &envelope,
2371 TEST_PK_2, &policy,
2373 &provider,
2374 &dex_service,
2375 )
2376 .await;
2377
2378 assert!(result.is_err());
2379 assert!(result
2380 .unwrap_err()
2381 .to_string()
2382 .contains("must include a fee payment operation to the relayer"));
2383 }
2384
2385 #[tokio::test]
2386 async fn test_multiple_fee_payments_rejected() {
2387 let payment1 = Operation {
2389 source_account: None,
2390 body: OperationBody::Payment(PaymentOp {
2391 destination: create_muxed_account(TEST_PK_2),
2392 asset: soroban_rs::xdr::Asset::CreditAlphanum4(soroban_rs::xdr::AlphaNum4 {
2393 asset_code: soroban_rs::xdr::AssetCode4(*b"USDC"),
2394 issuer: create_account_id(USDC_ISSUER),
2395 }),
2396 amount: 500_000,
2397 }),
2398 };
2399 let payment2 = Operation {
2400 source_account: None,
2401 body: OperationBody::Payment(PaymentOp {
2402 destination: create_muxed_account(TEST_PK_2),
2403 asset: soroban_rs::xdr::Asset::CreditAlphanum4(soroban_rs::xdr::AlphaNum4 {
2404 asset_code: soroban_rs::xdr::AssetCode4(*b"USDC"),
2405 issuer: create_account_id(USDC_ISSUER),
2406 }),
2407 amount: 500_000,
2408 }),
2409 };
2410
2411 let tx = Transaction {
2412 source_account: create_muxed_account(TEST_PK),
2413 fee: 100,
2414 seq_num: SequenceNumber(1),
2415 cond: soroban_rs::xdr::Preconditions::None,
2416 memo: soroban_rs::xdr::Memo::None,
2417 operations: vec![payment1, payment2].try_into().unwrap(),
2418 ext: TransactionExt::V0,
2419 };
2420
2421 let envelope = TransactionEnvelope::Tx(TransactionV1Envelope {
2422 tx,
2423 signatures: vec![].try_into().unwrap(),
2424 });
2425
2426 let policy = create_usdc_policy();
2427 let provider = create_mock_provider_with_balance(10_000_000_000);
2428 let dex_service = create_mock_dex_service();
2429
2430 let result = StellarTransactionValidator::validate_user_fee_payment_amounts(
2431 &envelope,
2432 TEST_PK_2,
2433 &policy,
2434 &provider,
2435 &dex_service,
2436 )
2437 .await;
2438
2439 assert!(result.is_err());
2440 assert!(result
2441 .unwrap_err()
2442 .to_string()
2443 .contains("exactly one fee payment operation"));
2444 }
2445
2446 #[tokio::test]
2447 async fn test_token_not_allowed() {
2448 let payment_op = Operation {
2450 source_account: None,
2451 body: OperationBody::Payment(PaymentOp {
2452 destination: create_muxed_account(TEST_PK_2),
2453 asset: soroban_rs::xdr::Asset::CreditAlphanum4(soroban_rs::xdr::AlphaNum4 {
2454 asset_code: soroban_rs::xdr::AssetCode4(*b"EURC"),
2455 issuer: create_account_id(TEST_PK),
2456 }),
2457 amount: 1_000_000,
2458 }),
2459 };
2460
2461 let tx = Transaction {
2462 source_account: create_muxed_account(TEST_PK),
2463 fee: 100,
2464 seq_num: SequenceNumber(1),
2465 cond: soroban_rs::xdr::Preconditions::None,
2466 memo: soroban_rs::xdr::Memo::None,
2467 operations: vec![payment_op].try_into().unwrap(),
2468 ext: TransactionExt::V0,
2469 };
2470
2471 let envelope = TransactionEnvelope::Tx(TransactionV1Envelope {
2472 tx,
2473 signatures: vec![].try_into().unwrap(),
2474 });
2475
2476 let policy = create_usdc_policy(); let provider = create_mock_provider_with_balance(10_000_000_000);
2479 let dex_service = create_mock_dex_service();
2480
2481 let result = StellarTransactionValidator::validate_user_fee_payment_amounts(
2482 &envelope,
2483 TEST_PK_2,
2484 &policy,
2485 &provider,
2486 &dex_service,
2487 )
2488 .await;
2489
2490 assert!(result.is_err());
2491 assert!(result
2492 .unwrap_err()
2493 .to_string()
2494 .contains("not in allowed tokens list"));
2495 }
2496
2497 #[tokio::test]
2498 async fn test_fee_exceeds_token_max() {
2499 let envelope = create_usdc_payment_envelope(TEST_PK, TEST_PK_2, 1_000_000);
2500 let usdc_asset = format!("USDC:{}", USDC_ISSUER);
2501 let mut policy = RelayerStellarPolicy::default();
2502 policy.allowed_tokens = Some(vec![StellarAllowedTokensPolicy {
2503 asset: usdc_asset,
2504 metadata: None,
2505 swap_config: None,
2506 max_allowed_fee: Some(500_000), }]);
2508
2509 let provider = create_mock_provider_with_balance(10_000_000_000);
2510 let dex_service = create_mock_dex_service();
2511
2512 let result = StellarTransactionValidator::validate_user_fee_payment_amounts(
2513 &envelope,
2514 TEST_PK_2,
2515 &policy,
2516 &provider,
2517 &dex_service,
2518 )
2519 .await;
2520
2521 assert!(result.is_err());
2522 assert!(result.unwrap_err().to_string().contains("Max fee exceeded"));
2523 }
2524
2525 #[tokio::test]
2526 async fn test_insufficient_payment_amount() {
2527 let envelope = create_usdc_payment_envelope(TEST_PK, TEST_PK_2, 1_000_000);
2528 let policy = create_usdc_policy();
2529 let provider = create_mock_provider_with_balance(10_000_000_000);
2530
2531 let mut dex_service = MockStellarDexServiceTrait::new();
2533 dex_service
2534 .expect_get_xlm_to_token_quote()
2535 .returning(|_, _, _, _| {
2536 Box::pin(ready(Ok(
2537 crate::services::stellar_dex::StellarQuoteResponse {
2538 input_asset: "native".to_string(),
2539 output_asset: "USDC:...".to_string(),
2540 in_amount: 200,
2541 out_amount: 2_000_000, price_impact_pct: 0.0,
2543 slippage_bps: 100,
2544 path: None,
2545 },
2546 )))
2547 });
2548
2549 let result = StellarTransactionValidator::validate_user_fee_payment_amounts(
2550 &envelope,
2551 TEST_PK_2,
2552 &policy,
2553 &provider,
2554 &dex_service,
2555 )
2556 .await;
2557
2558 assert!(result.is_err());
2559 assert!(result
2560 .unwrap_err()
2561 .to_string()
2562 .contains("Insufficient token payment"));
2563 }
2564
2565 #[tokio::test]
2566 async fn test_insufficient_user_balance() {
2567 let envelope = create_usdc_payment_envelope(TEST_PK, TEST_PK_2, 1_000_000);
2568 let policy = create_usdc_policy();
2569
2570 let mut provider = MockStellarProviderTrait::new();
2572
2573 provider.expect_get_account().returning(move |_| {
2574 Box::pin(ready(Ok(AccountEntry {
2575 account_id: create_account_id(TEST_PK),
2576 balance: 10_000_000_000,
2577 seq_num: SequenceNumber(1),
2578 num_sub_entries: 0,
2579 inflation_dest: None,
2580 flags: 0,
2581 home_domain: Default::default(),
2582 thresholds: Thresholds([0; 4]),
2583 signers: Default::default(),
2584 ext: AccountEntryExt::V0,
2585 })))
2586 });
2587
2588 provider.expect_get_latest_ledger().returning(|| {
2589 Box::pin(ready(Ok(GetLatestLedgerResponse {
2590 id: "test".to_string(),
2591 protocol_version: 20,
2592 sequence: 1000,
2593 })))
2594 });
2595
2596 provider
2597 .expect_simulate_transaction_envelope()
2598 .returning(|_| {
2599 Box::pin(ready(Ok(SimulateTransactionResponse {
2600 min_resource_fee: 100,
2601 transaction_data: String::new(),
2602 ..Default::default()
2603 })))
2604 });
2605
2606 provider.expect_get_ledger_entries().returning(|_| {
2608 use soroban_rs::stellar_rpc_client::{GetLedgerEntriesResponse, LedgerEntryResult};
2609 use soroban_rs::xdr::{
2610 LedgerEntry, LedgerEntryData, LedgerEntryExt, TrustLineAsset, TrustLineEntry,
2611 TrustLineEntryExt,
2612 };
2613
2614 let trustline_entry = TrustLineEntry {
2615 account_id: create_account_id(TEST_PK),
2616 asset: TrustLineAsset::CreditAlphanum4(soroban_rs::xdr::AlphaNum4 {
2617 asset_code: soroban_rs::xdr::AssetCode4(*b"USDC"),
2618 issuer: create_account_id(USDC_ISSUER),
2619 }),
2620 balance: 500_000, limit: 1_000_000_000,
2622 flags: 1,
2623 ext: TrustLineEntryExt::V0,
2624 };
2625
2626 let ledger_entry = LedgerEntry {
2627 last_modified_ledger_seq: 0,
2628 data: LedgerEntryData::Trustline(trustline_entry),
2629 ext: LedgerEntryExt::V0,
2630 };
2631
2632 let xdr_base64 = ledger_entry
2633 .data
2634 .to_xdr_base64(soroban_rs::xdr::Limits::none())
2635 .unwrap();
2636
2637 Box::pin(ready(Ok(GetLedgerEntriesResponse {
2638 entries: Some(vec![LedgerEntryResult {
2639 key: String::new(),
2640 xdr: xdr_base64,
2641 last_modified_ledger: 0,
2642 live_until_ledger_seq_ledger_seq: None,
2643 }]),
2644 latest_ledger: 0,
2645 })))
2646 });
2647
2648 let dex_service = create_mock_dex_service();
2649
2650 let result = StellarTransactionValidator::validate_user_fee_payment_amounts(
2651 &envelope,
2652 TEST_PK_2,
2653 &policy,
2654 &provider,
2655 &dex_service,
2656 )
2657 .await;
2658
2659 assert!(result.is_err());
2660 assert!(result
2661 .unwrap_err()
2662 .to_string()
2663 .contains("Insufficient balance"));
2664 }
2665
2666 #[tokio::test]
2667 async fn test_valid_fee_payment_with_usdc() {
2668 let envelope = create_usdc_payment_envelope(TEST_PK, TEST_PK_2, 1_000_000);
2669 let policy = create_usdc_policy();
2670 let provider = create_mock_provider_with_balance(10_000_000_000);
2671 let dex_service = create_mock_dex_service();
2672
2673 let result = StellarTransactionValidator::validate_user_fee_payment_amounts(
2674 &envelope,
2675 TEST_PK_2,
2676 &policy,
2677 &provider,
2678 &dex_service,
2679 )
2680 .await;
2681
2682 assert!(result.is_ok());
2683 }
2684
2685 #[tokio::test]
2686 async fn test_dex_conversion_failure() {
2687 let envelope = create_usdc_payment_envelope(TEST_PK, TEST_PK_2, 1_000_000);
2688 let policy = create_usdc_policy();
2689 let provider = create_mock_provider_with_balance(10_000_000_000);
2690
2691 let mut dex_service = MockStellarDexServiceTrait::new();
2692 dex_service
2693 .expect_get_xlm_to_token_quote()
2694 .returning(|_, _, _, _| {
2695 Box::pin(ready(Err(
2696 crate::services::stellar_dex::StellarDexServiceError::UnknownError(
2697 "DEX unavailable".to_string(),
2698 ),
2699 )))
2700 });
2701
2702 let result = StellarTransactionValidator::validate_user_fee_payment_amounts(
2703 &envelope,
2704 TEST_PK_2,
2705 &policy,
2706 &provider,
2707 &dex_service,
2708 )
2709 .await;
2710
2711 assert!(result.is_err());
2712 assert!(result
2713 .unwrap_err()
2714 .to_string()
2715 .contains("Failed to convert XLM fee to token"));
2716 }
2717 }
2718
2719 mod validate_contract_invocation_tests {
2720 use super::*;
2721
2722 #[test]
2723 fn test_invoke_contract_allowed() {
2724 let invoke_op = InvokeHostFunctionOp {
2725 host_function: HostFunction::InvokeContract(InvokeContractArgs {
2726 contract_address: ScAddress::Contract(soroban_rs::xdr::ContractId(
2727 soroban_rs::xdr::Hash([0u8; 32]),
2728 )),
2729 function_name: ScSymbol("test".try_into().unwrap()),
2730 args: Default::default(),
2731 }),
2732 auth: Default::default(),
2733 };
2734
2735 let policy = RelayerStellarPolicy::default();
2736 assert!(StellarTransactionValidator::validate_contract_invocation(
2737 &invoke_op, 0, TEST_PK_2, &policy
2738 )
2739 .is_ok());
2740 }
2741
2742 #[test]
2743 fn test_create_contract_rejected() {
2744 let invoke_op = InvokeHostFunctionOp {
2745 host_function: HostFunction::CreateContract(soroban_rs::xdr::CreateContractArgs {
2746 contract_id_preimage: soroban_rs::xdr::ContractIdPreimage::Address(
2747 soroban_rs::xdr::ContractIdPreimageFromAddress {
2748 address: ScAddress::Account(create_account_id(TEST_PK)),
2749 salt: soroban_rs::xdr::Uint256([0u8; 32]),
2750 },
2751 ),
2752 executable: soroban_rs::xdr::ContractExecutable::Wasm(soroban_rs::xdr::Hash(
2753 [0u8; 32],
2754 )),
2755 }),
2756 auth: Default::default(),
2757 };
2758
2759 let policy = RelayerStellarPolicy::default();
2760 let result = StellarTransactionValidator::validate_contract_invocation(
2761 &invoke_op, 0, TEST_PK_2, &policy,
2762 );
2763 assert!(result.is_err());
2764 assert!(result
2765 .unwrap_err()
2766 .to_string()
2767 .contains("CreateContract not allowed"));
2768 }
2769
2770 #[test]
2771 fn test_upload_wasm_rejected() {
2772 let invoke_op = InvokeHostFunctionOp {
2773 host_function: HostFunction::UploadContractWasm(vec![0u8; 100].try_into().unwrap()),
2774 auth: Default::default(),
2775 };
2776
2777 let policy = RelayerStellarPolicy::default();
2778 let result = StellarTransactionValidator::validate_contract_invocation(
2779 &invoke_op, 0, TEST_PK_2, &policy,
2780 );
2781 assert!(result.is_err());
2782 assert!(result
2783 .unwrap_err()
2784 .to_string()
2785 .contains("UploadContractWasm not allowed"));
2786 }
2787
2788 #[test]
2789 fn test_relayer_in_auth_rejected() {
2790 let auth_entry = SorobanAuthorizationEntry {
2791 credentials: SorobanCredentials::Address(
2792 soroban_rs::xdr::SorobanAddressCredentials {
2793 address: ScAddress::Account(create_account_id(TEST_PK_2)),
2794 nonce: 0,
2795 signature_expiration_ledger: 0,
2796 signature: soroban_rs::xdr::ScVal::Void,
2797 },
2798 ),
2799 root_invocation: soroban_rs::xdr::SorobanAuthorizedInvocation {
2800 function: SorobanAuthorizedFunction::ContractFn(
2801 soroban_rs::xdr::InvokeContractArgs {
2802 contract_address: ScAddress::Contract(soroban_rs::xdr::ContractId(
2803 soroban_rs::xdr::Hash([0u8; 32]),
2804 )),
2805 function_name: ScSymbol("test".try_into().unwrap()),
2806 args: Default::default(),
2807 },
2808 ),
2809 sub_invocations: Default::default(),
2810 },
2811 };
2812
2813 let invoke_op = InvokeHostFunctionOp {
2814 host_function: HostFunction::InvokeContract(InvokeContractArgs {
2815 contract_address: ScAddress::Contract(soroban_rs::xdr::ContractId(
2816 soroban_rs::xdr::Hash([0u8; 32]),
2817 )),
2818 function_name: ScSymbol("test".try_into().unwrap()),
2819 args: Default::default(),
2820 }),
2821 auth: vec![auth_entry].try_into().unwrap(),
2822 };
2823
2824 let policy = RelayerStellarPolicy::default();
2825 let result = StellarTransactionValidator::validate_contract_invocation(
2826 &invoke_op, 0, TEST_PK_2, &policy,
2828 );
2829 assert!(result.is_err());
2830 assert!(result.unwrap_err().to_string().contains("requires relayer"));
2831 }
2832 }
2833}