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 max_time != 0 && now > max_time {
816 return Err(StellarTransactionValidationError::ValidationError(format!(
817 "Transaction has expired: max_time={max_time}, current_time={now}"
818 )));
819 }
820
821 if min_time > 0 && now < min_time {
823 return Err(StellarTransactionValidationError::ValidationError(format!(
824 "Transaction is not yet valid: min_time={min_time}, current_time={now}"
825 )));
826 }
827 }
828 Ok(())
832 }
833
834 pub fn validate_transaction_validity_duration(
847 envelope: &TransactionEnvelope,
848 max_duration: Duration,
849 ) -> Result<(), StellarTransactionValidationError> {
850 let time_bounds = extract_time_bounds(envelope);
851
852 if let Some(bounds) = time_bounds {
853 if bounds.max_time.0 == 0 {
856 return Err(StellarTransactionValidationError::ValidationError(
857 "Transaction has unbounded validity (max_time=0), but bounded validity is required".to_string(),
858 ));
859 }
860
861 let max_time =
862 DateTime::from_timestamp(bounds.max_time.0 as i64, 0).ok_or_else(|| {
863 StellarTransactionValidationError::ValidationError(
864 "Invalid max_time in time bounds".to_string(),
865 )
866 })?;
867 let now = Utc::now();
868 let duration = max_time - now;
869
870 if duration > max_duration {
871 return Err(StellarTransactionValidationError::ValidationError(format!(
872 "Transaction validity duration ({duration:?}) exceeds maximum allowed duration ({max_duration:?})"
873 )));
874 }
875 } else {
876 return Err(StellarTransactionValidationError::ValidationError(
877 "Transaction must have time bounds set".to_string(),
878 ));
879 }
880
881 Ok(())
882 }
883
884 pub async fn validate_user_fee_payment_transaction<P, D>(
913 envelope: &TransactionEnvelope,
914 relayer_address: &str,
915 policy: &RelayerStellarPolicy,
916 provider: &P,
917 dex_service: &D,
918 max_validity_duration: Option<Duration>,
919 ) -> Result<(), StellarTransactionValidationError>
920 where
921 P: StellarProviderTrait + Send + Sync,
922 D: StellarDexServiceTrait + Send + Sync,
923 {
924 Self::gasless_transaction_validation(
927 envelope,
928 relayer_address,
929 policy,
930 provider,
931 max_validity_duration,
932 )
933 .await?;
934
935 Self::validate_user_fee_payment_amounts(
937 envelope,
938 relayer_address,
939 policy,
940 provider,
941 dex_service,
942 )
943 .await?;
944
945 Ok(())
946 }
947
948 async fn validate_user_fee_payment_amounts<P, D>(
964 envelope: &TransactionEnvelope,
965 relayer_address: &str,
966 policy: &RelayerStellarPolicy,
967 provider: &P,
968 dex_service: &D,
969 ) -> Result<(), StellarTransactionValidationError>
970 where
971 P: StellarProviderTrait + Send + Sync,
972 D: StellarDexServiceTrait + Send + Sync,
973 {
974 let payments = Self::extract_relayer_payments(envelope, relayer_address)?;
976 if payments.is_empty() {
977 return Err(StellarTransactionValidationError::ValidationError(
978 "Gasless transactions must include a fee payment operation to the relayer"
979 .to_string(),
980 ));
981 }
982
983 if payments.len() > 1 {
985 return Err(StellarTransactionValidationError::ValidationError(format!(
986 "Gasless transactions must include exactly one fee payment operation to the relayer, found {}",
987 payments.len()
988 )));
989 }
990
991 let (asset_id, amount) = &payments[0];
993
994 Self::validate_allowed_token(asset_id, policy)?;
996
997 Self::validate_token_max_fee(asset_id, *amount, policy)?;
999
1000 let mut required_xlm_fee = estimate_fee(envelope, provider, None).await.map_err(|e| {
1003 StellarTransactionValidationError::ValidationError(format!(
1004 "Failed to estimate fee: {e}",
1005 ))
1006 })?;
1007
1008 let is_soroban = xdr_needs_simulation(envelope).unwrap_or(false);
1009 if !is_soroban {
1010 required_xlm_fee += STELLAR_DEFAULT_TRANSACTION_FEE as u64;
1012 }
1013
1014 let fee_quote = convert_xlm_fee_to_token(dex_service, policy, required_xlm_fee, asset_id)
1015 .await
1016 .map_err(|e| {
1017 StellarTransactionValidationError::ValidationError(format!(
1018 "Failed to convert XLM fee to token {asset_id}: {e}",
1019 ))
1020 })?;
1021
1022 if *amount < fee_quote.fee_in_token {
1024 return Err(StellarTransactionValidationError::InsufficientTokenPayment(
1025 fee_quote.fee_in_token,
1026 *amount,
1027 ));
1028 }
1029
1030 Self::validate_user_token_balance(envelope, asset_id, fee_quote.fee_in_token, provider)
1032 .await?;
1033
1034 Ok(())
1035 }
1036
1037 pub async fn validate_user_token_balance<P>(
1052 envelope: &TransactionEnvelope,
1053 fee_token: &str,
1054 required_fee_amount: u64,
1055 provider: &P,
1056 ) -> Result<(), StellarTransactionValidationError>
1057 where
1058 P: StellarProviderTrait + Send + Sync,
1059 {
1060 let source_account = extract_source_account(envelope).map_err(|e| {
1062 StellarTransactionValidationError::ValidationError(format!(
1063 "Failed to extract source account: {e}"
1064 ))
1065 })?;
1066
1067 let user_balance = get_token_balance(provider, &source_account, fee_token)
1069 .await
1070 .map_err(|e| {
1071 StellarTransactionValidationError::ValidationError(format!(
1072 "Failed to fetch user balance for token {fee_token}: {e}",
1073 ))
1074 })?;
1075
1076 if user_balance < required_fee_amount {
1078 return Err(StellarTransactionValidationError::ValidationError(format!(
1079 "Insufficient balance: user has {user_balance} {fee_token} but needs {required_fee_amount} {fee_token} for transaction fee"
1080 )));
1081 }
1082
1083 Ok(())
1084 }
1085}
1086
1087#[cfg(test)]
1088mod tests {
1089 use super::*;
1090 use crate::domain::transaction::stellar::test_helpers::{
1091 create_account_id, create_muxed_account, create_native_payment_operation,
1092 create_simple_v1_envelope, TEST_CONTRACT, TEST_PK, TEST_PK_2,
1093 };
1094 use crate::models::{AssetSpec, StellarAllowedTokensPolicy};
1095 use crate::services::provider::MockStellarProviderTrait;
1096 use crate::services::stellar_dex::MockStellarDexServiceTrait;
1097 use futures::future::ready;
1098 use soroban_rs::xdr::{
1099 AccountEntry, AccountEntryExt, Asset as XdrAsset, ChangeTrustAsset, ChangeTrustOp,
1100 HostFunction, InvokeContractArgs, InvokeHostFunctionOp, Operation, OperationBody,
1101 ScAddress, ScSymbol, SequenceNumber, SorobanAuthorizationEntry, SorobanAuthorizedFunction,
1102 SorobanCredentials, Thresholds, TimeBounds, TimePoint, Transaction, TransactionEnvelope,
1103 TransactionExt, TransactionV1Envelope,
1104 };
1105
1106 #[test]
1107 fn test_empty_operations_rejected() {
1108 let result = validate_operations(&[]);
1109 assert!(result.is_err());
1110 assert!(result
1111 .unwrap_err()
1112 .to_string()
1113 .contains("at least one operation"));
1114 }
1115
1116 #[test]
1117 fn test_too_many_operations_rejected() {
1118 let ops = vec![
1119 OperationSpec::Payment {
1120 destination: TEST_PK.to_string(),
1121 amount: 1000,
1122 asset: AssetSpec::Native,
1123 };
1124 101
1125 ];
1126 let result = validate_operations(&ops);
1127 assert!(result.is_err());
1128 assert!(result
1129 .unwrap_err()
1130 .to_string()
1131 .contains("maximum allowed is 100"));
1132 }
1133
1134 #[test]
1135 fn test_soroban_exclusivity_enforced() {
1136 let ops = vec![
1138 OperationSpec::InvokeContract {
1139 contract_address: TEST_CONTRACT.to_string(),
1140 function_name: "test".to_string(),
1141 args: vec![],
1142 auth: None,
1143 },
1144 OperationSpec::CreateContract {
1145 source: crate::models::ContractSource::Address {
1146 address: TEST_PK.to_string(),
1147 },
1148 wasm_hash: "abc123".to_string(),
1149 salt: None,
1150 constructor_args: None,
1151 auth: None,
1152 },
1153 ];
1154 let result = validate_operations(&ops);
1155 assert!(result.is_err());
1156
1157 let ops = vec![
1159 OperationSpec::InvokeContract {
1160 contract_address: TEST_CONTRACT.to_string(),
1161 function_name: "test".to_string(),
1162 args: vec![],
1163 auth: None,
1164 },
1165 OperationSpec::Payment {
1166 destination: TEST_PK.to_string(),
1167 amount: 1000,
1168 asset: AssetSpec::Native,
1169 },
1170 ];
1171 let result = validate_operations(&ops);
1172 assert!(result.is_err());
1173 assert!(result
1174 .unwrap_err()
1175 .to_string()
1176 .contains("Soroban operations must be exclusive"));
1177 }
1178
1179 #[test]
1180 fn test_soroban_memo_restriction() {
1181 let soroban_op = vec![OperationSpec::InvokeContract {
1182 contract_address: TEST_CONTRACT.to_string(),
1183 function_name: "test".to_string(),
1184 args: vec![],
1185 auth: None,
1186 }];
1187
1188 let result = validate_soroban_memo_restriction(
1190 &soroban_op,
1191 &Some(MemoSpec::Text {
1192 value: "test".to_string(),
1193 }),
1194 );
1195 assert!(result.is_err());
1196
1197 let result = validate_soroban_memo_restriction(&soroban_op, &Some(MemoSpec::None));
1199 assert!(result.is_ok());
1200
1201 let result = validate_soroban_memo_restriction(&soroban_op, &None);
1203 assert!(result.is_ok());
1204 }
1205
1206 mod validate_fee_token_structure_tests {
1207 use super::*;
1208
1209 #[test]
1210 fn test_native_xlm_valid() {
1211 assert!(StellarTransactionValidator::validate_fee_token_structure("native").is_ok());
1212 assert!(StellarTransactionValidator::validate_fee_token_structure("XLM").is_ok());
1213 assert!(StellarTransactionValidator::validate_fee_token_structure("").is_ok());
1214 }
1215
1216 #[test]
1217 fn test_contract_address_valid() {
1218 assert!(
1219 StellarTransactionValidator::validate_fee_token_structure(TEST_CONTRACT).is_ok()
1220 );
1221 }
1222
1223 #[test]
1224 fn test_contract_address_invalid_length() {
1225 let result = StellarTransactionValidator::validate_fee_token_structure("C123");
1226 assert!(result.is_err());
1227 assert!(result
1228 .unwrap_err()
1229 .to_string()
1230 .contains("Invalid fee_token format"));
1231 }
1232
1233 #[test]
1234 fn test_classic_asset_valid() {
1235 let result = StellarTransactionValidator::validate_fee_token_structure(&format!(
1236 "USDC:{}",
1237 TEST_PK
1238 ));
1239 assert!(result.is_ok());
1240 }
1241
1242 #[test]
1243 fn test_classic_asset_code_too_long() {
1244 let result = StellarTransactionValidator::validate_fee_token_structure(&format!(
1245 "VERYLONGCODE1:{}",
1246 TEST_PK
1247 ));
1248 assert!(result.is_err());
1249 assert!(result
1250 .unwrap_err()
1251 .to_string()
1252 .contains("Invalid asset code length"));
1253 }
1254
1255 #[test]
1256 fn test_classic_asset_invalid_issuer_length() {
1257 let result = StellarTransactionValidator::validate_fee_token_structure("USDC:GSHORT");
1258 assert!(result.is_err());
1259 assert!(result
1260 .unwrap_err()
1261 .to_string()
1262 .contains("Invalid issuer address length"));
1263 }
1264
1265 #[test]
1266 fn test_classic_asset_invalid_issuer_prefix() {
1267 let result = StellarTransactionValidator::validate_fee_token_structure(
1268 "USDC:SAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF",
1269 );
1270 assert!(result.is_err());
1271 assert!(result
1272 .unwrap_err()
1273 .to_string()
1274 .contains("Invalid issuer address prefix"));
1275 }
1276
1277 #[test]
1278 fn test_invalid_format_multiple_colons() {
1279 let result =
1280 StellarTransactionValidator::validate_fee_token_structure("USDC:ISSUER:EXTRA");
1281 assert!(result.is_err());
1282 assert!(result
1283 .unwrap_err()
1284 .to_string()
1285 .contains("Invalid fee_token format"));
1286 }
1287 }
1288
1289 mod validate_allowed_token_tests {
1290 use super::*;
1291
1292 #[test]
1293 fn test_empty_allowed_list_allows_all() {
1294 let policy = RelayerStellarPolicy::default();
1295 assert!(StellarTransactionValidator::validate_allowed_token("native", &policy).is_ok());
1296 assert!(
1297 StellarTransactionValidator::validate_allowed_token(TEST_CONTRACT, &policy).is_ok()
1298 );
1299 }
1300
1301 #[test]
1302 fn test_native_allowed() {
1303 let mut policy = RelayerStellarPolicy::default();
1304 policy.allowed_tokens = Some(vec![StellarAllowedTokensPolicy {
1305 asset: "native".to_string(),
1306 metadata: None,
1307 swap_config: None,
1308 max_allowed_fee: None,
1309 }]);
1310 assert!(StellarTransactionValidator::validate_allowed_token("native", &policy).is_ok());
1311 assert!(StellarTransactionValidator::validate_allowed_token("", &policy).is_ok());
1312 }
1313
1314 #[test]
1315 fn test_native_not_allowed() {
1316 let mut policy = RelayerStellarPolicy::default();
1317 policy.allowed_tokens = Some(vec![StellarAllowedTokensPolicy {
1318 asset: format!("USDC:{}", TEST_PK),
1319 metadata: None,
1320 swap_config: None,
1321 max_allowed_fee: None,
1322 }]);
1323 let result = StellarTransactionValidator::validate_allowed_token("native", &policy);
1324 assert!(result.is_err());
1325 assert!(result
1326 .unwrap_err()
1327 .to_string()
1328 .contains("Native XLM not in allowed tokens list"));
1329 }
1330
1331 #[test]
1332 fn test_token_allowed() {
1333 let token = format!("USDC:{}", TEST_PK);
1334 let mut policy = RelayerStellarPolicy::default();
1335 policy.allowed_tokens = Some(vec![StellarAllowedTokensPolicy {
1336 asset: token.clone(),
1337 metadata: None,
1338 swap_config: None,
1339 max_allowed_fee: None,
1340 }]);
1341 assert!(StellarTransactionValidator::validate_allowed_token(&token, &policy).is_ok());
1342 }
1343
1344 #[test]
1345 fn test_token_not_allowed() {
1346 let mut policy = RelayerStellarPolicy::default();
1347 policy.allowed_tokens = Some(vec![StellarAllowedTokensPolicy {
1348 asset: format!("USDC:{}", TEST_PK),
1349 metadata: None,
1350 swap_config: None,
1351 max_allowed_fee: None,
1352 }]);
1353 let result = StellarTransactionValidator::validate_allowed_token(
1354 &format!("AQUA:{}", TEST_PK_2),
1355 &policy,
1356 );
1357 assert!(result.is_err());
1358 assert!(result
1359 .unwrap_err()
1360 .to_string()
1361 .contains("not in allowed tokens list"));
1362 }
1363 }
1364
1365 mod validate_max_fee_tests {
1366 use super::*;
1367
1368 #[test]
1369 fn test_no_max_fee_allows_any() {
1370 let policy = RelayerStellarPolicy::default();
1371 assert!(StellarTransactionValidator::validate_max_fee(1_000_000, &policy).is_ok());
1372 }
1373
1374 #[test]
1375 fn test_fee_within_limit() {
1376 let mut policy = RelayerStellarPolicy::default();
1377 policy.max_fee = Some(1_000_000);
1378 assert!(StellarTransactionValidator::validate_max_fee(500_000, &policy).is_ok());
1379 }
1380
1381 #[test]
1382 fn test_fee_exceeds_limit() {
1383 let mut policy = RelayerStellarPolicy::default();
1384 policy.max_fee = Some(1_000_000);
1385 let result = StellarTransactionValidator::validate_max_fee(2_000_000, &policy);
1386 assert!(result.is_err());
1387 assert!(result.unwrap_err().to_string().contains("Max fee exceeded"));
1388 }
1389 }
1390
1391 mod validate_token_max_fee_tests {
1392 use super::*;
1393
1394 #[test]
1395 fn test_no_token_entry() {
1396 let policy = RelayerStellarPolicy::default();
1397 assert!(StellarTransactionValidator::validate_token_max_fee(
1398 "USDC:ISSUER",
1399 1_000_000,
1400 &policy
1401 )
1402 .is_ok());
1403 }
1404
1405 #[test]
1406 fn test_no_max_allowed_fee_in_entry() {
1407 let mut policy = RelayerStellarPolicy::default();
1408 policy.allowed_tokens = Some(vec![StellarAllowedTokensPolicy {
1409 asset: "USDC:ISSUER".to_string(),
1410 metadata: None,
1411 swap_config: None,
1412 max_allowed_fee: None,
1413 }]);
1414 assert!(StellarTransactionValidator::validate_token_max_fee(
1415 "USDC:ISSUER",
1416 1_000_000,
1417 &policy
1418 )
1419 .is_ok());
1420 }
1421
1422 #[test]
1423 fn test_fee_within_token_limit() {
1424 let mut policy = RelayerStellarPolicy::default();
1425 policy.allowed_tokens = Some(vec![StellarAllowedTokensPolicy {
1426 asset: "USDC:ISSUER".to_string(),
1427 metadata: None,
1428 swap_config: None,
1429 max_allowed_fee: Some(1_000_000),
1430 }]);
1431 assert!(StellarTransactionValidator::validate_token_max_fee(
1432 "USDC:ISSUER",
1433 500_000,
1434 &policy
1435 )
1436 .is_ok());
1437 }
1438
1439 #[test]
1440 fn test_fee_exceeds_token_limit() {
1441 let mut policy = RelayerStellarPolicy::default();
1442 policy.allowed_tokens = Some(vec![StellarAllowedTokensPolicy {
1443 asset: "USDC:ISSUER".to_string(),
1444 metadata: None,
1445 swap_config: None,
1446 max_allowed_fee: Some(1_000_000),
1447 }]);
1448 let result = StellarTransactionValidator::validate_token_max_fee(
1449 "USDC:ISSUER",
1450 2_000_000,
1451 &policy,
1452 );
1453 assert!(result.is_err());
1454 assert!(result.unwrap_err().to_string().contains("Max fee exceeded"));
1455 }
1456 }
1457
1458 mod extract_relayer_payments_tests {
1459 use super::*;
1460
1461 #[test]
1462 fn test_extract_single_payment() {
1463 let envelope = create_simple_v1_envelope(TEST_PK, TEST_PK_2);
1464 let payments =
1465 StellarTransactionValidator::extract_relayer_payments(&envelope, TEST_PK_2)
1466 .unwrap();
1467 assert_eq!(payments.len(), 1);
1468 assert_eq!(payments[0].0, "native");
1469 assert_eq!(payments[0].1, 1_000_000);
1470 }
1471
1472 #[test]
1473 fn test_extract_no_payments_to_relayer() {
1474 let envelope = create_simple_v1_envelope(TEST_PK, TEST_PK_2);
1475 let payments =
1476 StellarTransactionValidator::extract_relayer_payments(&envelope, TEST_PK).unwrap();
1477 assert_eq!(payments.len(), 0);
1478 }
1479
1480 #[test]
1481 fn test_extract_negative_amount_rejected() {
1482 let payment_op = Operation {
1483 source_account: None,
1484 body: OperationBody::Payment(soroban_rs::xdr::PaymentOp {
1485 destination: create_muxed_account(TEST_PK_2),
1486 asset: XdrAsset::Native,
1487 amount: -100, }),
1489 };
1490
1491 let tx = Transaction {
1492 source_account: create_muxed_account(TEST_PK),
1493 fee: 100,
1494 seq_num: SequenceNumber(1),
1495 cond: soroban_rs::xdr::Preconditions::None,
1496 memo: soroban_rs::xdr::Memo::None,
1497 operations: vec![payment_op].try_into().unwrap(),
1498 ext: TransactionExt::V0,
1499 };
1500
1501 let envelope = TransactionEnvelope::Tx(TransactionV1Envelope {
1502 tx,
1503 signatures: vec![].try_into().unwrap(),
1504 });
1505
1506 let result =
1507 StellarTransactionValidator::extract_relayer_payments(&envelope, TEST_PK_2);
1508 assert!(result.is_err());
1509 assert!(result
1510 .unwrap_err()
1511 .to_string()
1512 .contains("Negative payment amount"));
1513 }
1514 }
1515
1516 mod validate_time_bounds_tests {
1517 use super::*;
1518
1519 #[test]
1520 fn test_no_time_bounds_is_ok() {
1521 let envelope = create_simple_v1_envelope(TEST_PK, TEST_PK_2);
1522 assert!(
1523 StellarTransactionValidator::validate_time_bounds_not_expired(&envelope).is_ok()
1524 );
1525 }
1526
1527 #[test]
1528 fn test_valid_time_bounds() {
1529 let now = Utc::now().timestamp() as u64;
1530 let payment_op = create_native_payment_operation(TEST_PK_2, 1_000_000);
1531
1532 let tx = Transaction {
1533 source_account: create_muxed_account(TEST_PK),
1534 fee: 100,
1535 seq_num: SequenceNumber(1),
1536 cond: soroban_rs::xdr::Preconditions::Time(TimeBounds {
1537 min_time: TimePoint(now - 60),
1538 max_time: TimePoint(now + 60),
1539 }),
1540 memo: soroban_rs::xdr::Memo::None,
1541 operations: vec![payment_op].try_into().unwrap(),
1542 ext: TransactionExt::V0,
1543 };
1544
1545 let envelope = TransactionEnvelope::Tx(TransactionV1Envelope {
1546 tx,
1547 signatures: vec![].try_into().unwrap(),
1548 });
1549
1550 assert!(
1551 StellarTransactionValidator::validate_time_bounds_not_expired(&envelope).is_ok()
1552 );
1553 }
1554
1555 #[test]
1556 fn test_expired_transaction() {
1557 let now = Utc::now().timestamp() as u64;
1558 let payment_op = create_native_payment_operation(TEST_PK_2, 1_000_000);
1559
1560 let tx = Transaction {
1561 source_account: create_muxed_account(TEST_PK),
1562 fee: 100,
1563 seq_num: SequenceNumber(1),
1564 cond: soroban_rs::xdr::Preconditions::Time(TimeBounds {
1565 min_time: TimePoint(now - 120),
1566 max_time: TimePoint(now - 60), }),
1568 memo: soroban_rs::xdr::Memo::None,
1569 operations: vec![payment_op].try_into().unwrap(),
1570 ext: TransactionExt::V0,
1571 };
1572
1573 let envelope = TransactionEnvelope::Tx(TransactionV1Envelope {
1574 tx,
1575 signatures: vec![].try_into().unwrap(),
1576 });
1577
1578 let result = StellarTransactionValidator::validate_time_bounds_not_expired(&envelope);
1579 assert!(result.is_err());
1580 assert!(result.unwrap_err().to_string().contains("has expired"));
1581 }
1582
1583 #[test]
1584 fn test_not_yet_valid_transaction() {
1585 let now = Utc::now().timestamp() as u64;
1586 let payment_op = create_native_payment_operation(TEST_PK_2, 1_000_000);
1587
1588 let tx = Transaction {
1589 source_account: create_muxed_account(TEST_PK),
1590 fee: 100,
1591 seq_num: SequenceNumber(1),
1592 cond: soroban_rs::xdr::Preconditions::Time(TimeBounds {
1593 min_time: TimePoint(now + 60), max_time: TimePoint(now + 120),
1595 }),
1596 memo: soroban_rs::xdr::Memo::None,
1597 operations: vec![payment_op].try_into().unwrap(),
1598 ext: TransactionExt::V0,
1599 };
1600
1601 let envelope = TransactionEnvelope::Tx(TransactionV1Envelope {
1602 tx,
1603 signatures: vec![].try_into().unwrap(),
1604 });
1605
1606 let result = StellarTransactionValidator::validate_time_bounds_not_expired(&envelope);
1607 assert!(result.is_err());
1608 assert!(result.unwrap_err().to_string().contains("not yet valid"));
1609 }
1610 }
1611
1612 mod validate_transaction_validity_duration_tests {
1613 use super::*;
1614
1615 #[test]
1616 fn test_duration_within_limit() {
1617 let now = Utc::now().timestamp() as u64;
1618 let payment_op = create_native_payment_operation(TEST_PK_2, 1_000_000);
1619
1620 let tx = Transaction {
1621 source_account: create_muxed_account(TEST_PK),
1622 fee: 100,
1623 seq_num: SequenceNumber(1),
1624 cond: soroban_rs::xdr::Preconditions::Time(TimeBounds {
1625 min_time: TimePoint(0),
1626 max_time: TimePoint(now + 60), }),
1628 memo: soroban_rs::xdr::Memo::None,
1629 operations: vec![payment_op].try_into().unwrap(),
1630 ext: TransactionExt::V0,
1631 };
1632
1633 let envelope = TransactionEnvelope::Tx(TransactionV1Envelope {
1634 tx,
1635 signatures: vec![].try_into().unwrap(),
1636 });
1637
1638 let max_duration = Duration::minutes(5);
1639 assert!(
1640 StellarTransactionValidator::validate_transaction_validity_duration(
1641 &envelope,
1642 max_duration
1643 )
1644 .is_ok()
1645 );
1646 }
1647
1648 #[test]
1649 fn test_duration_exceeds_limit() {
1650 let now = Utc::now().timestamp() as u64;
1651 let payment_op = create_native_payment_operation(TEST_PK_2, 1_000_000);
1652
1653 let tx = Transaction {
1654 source_account: create_muxed_account(TEST_PK),
1655 fee: 100,
1656 seq_num: SequenceNumber(1),
1657 cond: soroban_rs::xdr::Preconditions::Time(TimeBounds {
1658 min_time: TimePoint(0),
1659 max_time: TimePoint(now + 600), }),
1661 memo: soroban_rs::xdr::Memo::None,
1662 operations: vec![payment_op].try_into().unwrap(),
1663 ext: TransactionExt::V0,
1664 };
1665
1666 let envelope = TransactionEnvelope::Tx(TransactionV1Envelope {
1667 tx,
1668 signatures: vec![].try_into().unwrap(),
1669 });
1670
1671 let max_duration = Duration::minutes(5);
1672 let result = StellarTransactionValidator::validate_transaction_validity_duration(
1673 &envelope,
1674 max_duration,
1675 );
1676 assert!(result.is_err());
1677 assert!(result
1678 .unwrap_err()
1679 .to_string()
1680 .contains("exceeds maximum allowed duration"));
1681 }
1682
1683 #[test]
1684 fn test_no_time_bounds_rejected() {
1685 let envelope = create_simple_v1_envelope(TEST_PK, TEST_PK_2);
1686 let max_duration = Duration::minutes(5);
1687 let result = StellarTransactionValidator::validate_transaction_validity_duration(
1688 &envelope,
1689 max_duration,
1690 );
1691 assert!(result.is_err());
1692 assert!(result
1693 .unwrap_err()
1694 .to_string()
1695 .contains("must have time bounds set"));
1696 }
1697 }
1698
1699 mod validate_sequence_number_tests {
1700 use super::*;
1701
1702 #[tokio::test]
1703 async fn test_valid_sequence_number() {
1704 let envelope = create_simple_v1_envelope(TEST_PK, TEST_PK_2);
1705
1706 let mut provider = MockStellarProviderTrait::new();
1707 provider.expect_get_account().returning(|_| {
1708 Box::pin(ready(Ok(AccountEntry {
1709 account_id: create_account_id(TEST_PK),
1710 balance: 1_000_000_000,
1711 seq_num: SequenceNumber(0), num_sub_entries: 0,
1713 inflation_dest: None,
1714 flags: 0,
1715 home_domain: Default::default(),
1716 thresholds: Thresholds([0; 4]),
1717 signers: Default::default(),
1718 ext: AccountEntryExt::V0,
1719 })))
1720 });
1721
1722 assert!(
1723 StellarTransactionValidator::validate_sequence_number(&envelope, &provider)
1724 .await
1725 .is_ok()
1726 );
1727 }
1728
1729 #[tokio::test]
1730 async fn test_equal_sequence_rejected() {
1731 let envelope = create_simple_v1_envelope(TEST_PK, TEST_PK_2);
1732
1733 let mut provider = MockStellarProviderTrait::new();
1734 provider.expect_get_account().returning(|_| {
1735 Box::pin(ready(Ok(AccountEntry {
1736 account_id: create_account_id(TEST_PK),
1737 balance: 1_000_000_000,
1738 seq_num: SequenceNumber(1), num_sub_entries: 0,
1740 inflation_dest: None,
1741 flags: 0,
1742 home_domain: Default::default(),
1743 thresholds: Thresholds([0; 4]),
1744 signers: Default::default(),
1745 ext: AccountEntryExt::V0,
1746 })))
1747 });
1748
1749 let result =
1750 StellarTransactionValidator::validate_sequence_number(&envelope, &provider).await;
1751 assert!(result.is_err());
1752 assert!(result
1753 .unwrap_err()
1754 .to_string()
1755 .contains("strictly greater than"));
1756 }
1757
1758 #[tokio::test]
1759 async fn test_past_sequence_rejected() {
1760 let envelope = create_simple_v1_envelope(TEST_PK, TEST_PK_2);
1761
1762 let mut provider = MockStellarProviderTrait::new();
1763 provider.expect_get_account().returning(|_| {
1764 Box::pin(ready(Ok(AccountEntry {
1765 account_id: create_account_id(TEST_PK),
1766 balance: 1_000_000_000,
1767 seq_num: SequenceNumber(10), num_sub_entries: 0,
1769 inflation_dest: None,
1770 flags: 0,
1771 home_domain: Default::default(),
1772 thresholds: Thresholds([0; 4]),
1773 signers: Default::default(),
1774 ext: AccountEntryExt::V0,
1775 })))
1776 });
1777
1778 let result =
1779 StellarTransactionValidator::validate_sequence_number(&envelope, &provider).await;
1780 assert!(result.is_err());
1781 assert!(result.unwrap_err().to_string().contains("is invalid"));
1782 }
1783 }
1784
1785 mod validate_operations_count_tests {
1786 use super::*;
1787
1788 #[test]
1789 fn test_valid_operations_count() {
1790 let envelope = create_simple_v1_envelope(TEST_PK, TEST_PK_2);
1791 assert!(StellarTransactionValidator::validate_operations_count(&envelope).is_ok());
1792 }
1793
1794 #[test]
1795 fn test_too_many_operations() {
1796 let operations: Vec<Operation> = (0..100)
1802 .map(|_| create_native_payment_operation(TEST_PK_2, 100))
1803 .collect();
1804
1805 let tx = Transaction {
1806 source_account: create_muxed_account(TEST_PK),
1807 fee: 100,
1808 seq_num: SequenceNumber(1),
1809 cond: soroban_rs::xdr::Preconditions::None,
1810 memo: soroban_rs::xdr::Memo::None,
1811 operations: operations.try_into().unwrap(),
1812 ext: TransactionExt::V0,
1813 };
1814
1815 let envelope = TransactionEnvelope::Tx(TransactionV1Envelope {
1816 tx,
1817 signatures: vec![].try_into().unwrap(),
1818 });
1819
1820 let result = StellarTransactionValidator::validate_operations_count(&envelope);
1822 assert!(result.is_ok());
1823 }
1824 }
1825
1826 mod validate_source_account_tests {
1827 use super::*;
1828
1829 #[test]
1830 fn test_source_account_not_relayer() {
1831 let envelope = create_simple_v1_envelope(TEST_PK, TEST_PK_2);
1832 assert!(
1833 StellarTransactionValidator::validate_source_account_not_relayer(
1834 &envelope, TEST_PK_2
1835 )
1836 .is_ok()
1837 );
1838 }
1839
1840 #[test]
1841 fn test_source_account_is_relayer_rejected() {
1842 let envelope = create_simple_v1_envelope(TEST_PK, TEST_PK_2);
1843 let result = StellarTransactionValidator::validate_source_account_not_relayer(
1844 &envelope, TEST_PK,
1845 );
1846 assert!(result.is_err());
1847 assert!(result
1848 .unwrap_err()
1849 .to_string()
1850 .contains("cannot be the relayer address"));
1851 }
1852 }
1853
1854 mod validate_operation_types_tests {
1855 use super::*;
1856
1857 #[test]
1858 fn test_payment_operation_allowed() {
1859 let envelope = create_simple_v1_envelope(TEST_PK, TEST_PK_2);
1860 let policy = RelayerStellarPolicy::default();
1861 assert!(StellarTransactionValidator::validate_operation_types(
1862 &envelope, TEST_PK_2, &policy
1863 )
1864 .is_ok());
1865 }
1866
1867 #[test]
1868 fn test_account_merge_rejected() {
1869 let operation = Operation {
1870 source_account: None,
1871 body: OperationBody::AccountMerge(create_muxed_account(TEST_PK_2)),
1872 };
1873
1874 let tx = Transaction {
1875 source_account: create_muxed_account(TEST_PK),
1876 fee: 100,
1877 seq_num: SequenceNumber(1),
1878 cond: soroban_rs::xdr::Preconditions::None,
1879 memo: soroban_rs::xdr::Memo::None,
1880 operations: vec![operation].try_into().unwrap(),
1881 ext: TransactionExt::V0,
1882 };
1883
1884 let envelope = TransactionEnvelope::Tx(TransactionV1Envelope {
1885 tx,
1886 signatures: vec![].try_into().unwrap(),
1887 });
1888
1889 let policy = RelayerStellarPolicy::default();
1890 let result = StellarTransactionValidator::validate_operation_types(
1891 &envelope, TEST_PK_2, &policy,
1892 );
1893 assert!(result.is_err());
1894 assert!(result
1895 .unwrap_err()
1896 .to_string()
1897 .contains("AccountMerge operations are not allowed"));
1898 }
1899
1900 #[test]
1901 fn test_set_options_rejected() {
1902 let operation = Operation {
1903 source_account: None,
1904 body: OperationBody::SetOptions(soroban_rs::xdr::SetOptionsOp {
1905 inflation_dest: None,
1906 clear_flags: None,
1907 set_flags: None,
1908 master_weight: None,
1909 low_threshold: None,
1910 med_threshold: None,
1911 high_threshold: None,
1912 home_domain: None,
1913 signer: None,
1914 }),
1915 };
1916
1917 let tx = Transaction {
1918 source_account: create_muxed_account(TEST_PK),
1919 fee: 100,
1920 seq_num: SequenceNumber(1),
1921 cond: soroban_rs::xdr::Preconditions::None,
1922 memo: soroban_rs::xdr::Memo::None,
1923 operations: vec![operation].try_into().unwrap(),
1924 ext: TransactionExt::V0,
1925 };
1926
1927 let envelope = TransactionEnvelope::Tx(TransactionV1Envelope {
1928 tx,
1929 signatures: vec![].try_into().unwrap(),
1930 });
1931
1932 let policy = RelayerStellarPolicy::default();
1933 let result = StellarTransactionValidator::validate_operation_types(
1934 &envelope, TEST_PK_2, &policy,
1935 );
1936 assert!(result.is_err());
1937 assert!(result
1938 .unwrap_err()
1939 .to_string()
1940 .contains("SetOptions operations are not allowed"));
1941 }
1942
1943 #[test]
1944 fn test_change_trust_allowed() {
1945 let operation = Operation {
1946 source_account: None,
1947 body: OperationBody::ChangeTrust(ChangeTrustOp {
1948 line: ChangeTrustAsset::CreditAlphanum4(soroban_rs::xdr::AlphaNum4 {
1949 asset_code: soroban_rs::xdr::AssetCode4(*b"USDC"),
1950 issuer: create_account_id(TEST_PK_2),
1951 }),
1952 limit: 1_000_000_000,
1953 }),
1954 };
1955
1956 let tx = Transaction {
1957 source_account: create_muxed_account(TEST_PK),
1958 fee: 100,
1959 seq_num: SequenceNumber(1),
1960 cond: soroban_rs::xdr::Preconditions::None,
1961 memo: soroban_rs::xdr::Memo::None,
1962 operations: vec![operation].try_into().unwrap(),
1963 ext: TransactionExt::V0,
1964 };
1965
1966 let envelope = TransactionEnvelope::Tx(TransactionV1Envelope {
1967 tx,
1968 signatures: vec![].try_into().unwrap(),
1969 });
1970
1971 let policy = RelayerStellarPolicy::default();
1972 assert!(StellarTransactionValidator::validate_operation_types(
1973 &envelope, TEST_PK_2, &policy
1974 )
1975 .is_ok());
1976 }
1977 }
1978
1979 mod validate_token_payment_tests {
1980 use super::*;
1981
1982 #[test]
1983 fn test_valid_native_payment() {
1984 let envelope = create_simple_v1_envelope(TEST_PK, TEST_PK_2);
1985 let policy = RelayerStellarPolicy::default();
1986
1987 let result = StellarTransactionValidator::validate_token_payment(
1988 &envelope, TEST_PK_2, "native", 1_000_000, &policy,
1989 );
1990 assert!(result.is_ok());
1991 }
1992
1993 #[test]
1994 fn test_no_payment_to_relayer() {
1995 let envelope = create_simple_v1_envelope(TEST_PK, TEST_PK_2);
1996 let policy = RelayerStellarPolicy::default();
1997
1998 let result = StellarTransactionValidator::validate_token_payment(
2000 &envelope, TEST_PK, "native", 1_000_000, &policy,
2002 );
2003 assert!(result.is_err());
2004 assert!(result
2005 .unwrap_err()
2006 .to_string()
2007 .contains("No payment operation found to relayer"));
2008 }
2009
2010 #[test]
2011 fn test_wrong_token_in_payment() {
2012 let envelope = create_simple_v1_envelope(TEST_PK, TEST_PK_2);
2013 let policy = RelayerStellarPolicy::default();
2014
2015 let result = StellarTransactionValidator::validate_token_payment(
2017 &envelope,
2018 TEST_PK_2,
2019 &format!("USDC:{}", TEST_PK),
2020 1_000_000,
2021 &policy,
2022 );
2023 assert!(result.is_err());
2024 assert!(result
2025 .unwrap_err()
2026 .to_string()
2027 .contains("No payment found for expected token"));
2028 }
2029
2030 #[test]
2031 fn test_insufficient_payment_amount() {
2032 let envelope = create_simple_v1_envelope(TEST_PK, TEST_PK_2);
2033 let policy = RelayerStellarPolicy::default();
2034
2035 let result = StellarTransactionValidator::validate_token_payment(
2037 &envelope, TEST_PK_2, "native", 2_000_000, &policy,
2038 );
2039 assert!(result.is_err());
2040 assert!(result
2041 .unwrap_err()
2042 .to_string()
2043 .contains("Insufficient token payment"));
2044 }
2045
2046 #[test]
2047 fn test_payment_within_tolerance() {
2048 let envelope = create_simple_v1_envelope(TEST_PK, TEST_PK_2);
2049 let policy = RelayerStellarPolicy::default();
2050
2051 let result = StellarTransactionValidator::validate_token_payment(
2052 &envelope, TEST_PK_2, "native", 990_000, &policy,
2053 );
2054 assert!(result.is_ok());
2055 }
2056
2057 #[test]
2058 fn test_token_not_in_allowed_list() {
2059 let envelope = create_simple_v1_envelope(TEST_PK, TEST_PK_2);
2060 let mut policy = RelayerStellarPolicy::default();
2061 policy.allowed_tokens = Some(vec![StellarAllowedTokensPolicy {
2062 asset: format!("USDC:{}", TEST_PK),
2063 metadata: None,
2064 swap_config: None,
2065 max_allowed_fee: None,
2066 }]);
2067
2068 let result = StellarTransactionValidator::validate_token_payment(
2070 &envelope, TEST_PK_2, "native", 1_000_000, &policy,
2071 );
2072 assert!(result.is_err());
2073 assert!(result
2074 .unwrap_err()
2075 .to_string()
2076 .contains("not in allowed tokens list"));
2077 }
2078
2079 #[test]
2080 fn test_payment_exceeds_token_max_fee() {
2081 let envelope = create_simple_v1_envelope(TEST_PK, TEST_PK_2);
2082 let mut policy = RelayerStellarPolicy::default();
2083 policy.allowed_tokens = Some(vec![StellarAllowedTokensPolicy {
2084 asset: "native".to_string(),
2085 metadata: None,
2086 swap_config: None,
2087 max_allowed_fee: Some(500_000), }]);
2089
2090 let result = StellarTransactionValidator::validate_token_payment(
2092 &envelope, TEST_PK_2, "native", 1_000_000, &policy,
2093 );
2094 assert!(result.is_err());
2095 assert!(result.unwrap_err().to_string().contains("Max fee exceeded"));
2096 }
2097
2098 #[test]
2099 fn test_classic_asset_payment() {
2100 let usdc_asset = format!("USDC:{}", TEST_PK);
2101 let payment_op = Operation {
2102 source_account: None,
2103 body: OperationBody::Payment(soroban_rs::xdr::PaymentOp {
2104 destination: create_muxed_account(TEST_PK_2),
2105 asset: XdrAsset::CreditAlphanum4(soroban_rs::xdr::AlphaNum4 {
2106 asset_code: soroban_rs::xdr::AssetCode4(*b"USDC"),
2107 issuer: create_account_id(TEST_PK),
2108 }),
2109 amount: 1_000_000,
2110 }),
2111 };
2112
2113 let tx = Transaction {
2114 source_account: create_muxed_account(TEST_PK),
2115 fee: 100,
2116 seq_num: SequenceNumber(1),
2117 cond: soroban_rs::xdr::Preconditions::None,
2118 memo: soroban_rs::xdr::Memo::None,
2119 operations: vec![payment_op].try_into().unwrap(),
2120 ext: TransactionExt::V0,
2121 };
2122
2123 let envelope = TransactionEnvelope::Tx(TransactionV1Envelope {
2124 tx,
2125 signatures: vec![].try_into().unwrap(),
2126 });
2127
2128 let mut policy = RelayerStellarPolicy::default();
2129 policy.allowed_tokens = Some(vec![StellarAllowedTokensPolicy {
2130 asset: usdc_asset.clone(),
2131 metadata: None,
2132 swap_config: None,
2133 max_allowed_fee: None,
2134 }]);
2135
2136 let result = StellarTransactionValidator::validate_token_payment(
2137 &envelope,
2138 TEST_PK_2,
2139 &usdc_asset,
2140 1_000_000,
2141 &policy,
2142 );
2143 assert!(result.is_ok());
2144 }
2145
2146 #[test]
2147 fn test_multiple_payments_finds_correct_token() {
2148 let usdc_asset = format!("USDC:{}", TEST_PK);
2150 let usdc_payment = Operation {
2151 source_account: None,
2152 body: OperationBody::Payment(soroban_rs::xdr::PaymentOp {
2153 destination: create_muxed_account(TEST_PK_2),
2154 asset: XdrAsset::CreditAlphanum4(soroban_rs::xdr::AlphaNum4 {
2155 asset_code: soroban_rs::xdr::AssetCode4(*b"USDC"),
2156 issuer: create_account_id(TEST_PK),
2157 }),
2158 amount: 500_000,
2159 }),
2160 };
2161
2162 let xlm_payment = create_native_payment_operation(TEST_PK, 1_000_000);
2163
2164 let tx = Transaction {
2165 source_account: create_muxed_account(TEST_PK),
2166 fee: 100,
2167 seq_num: SequenceNumber(1),
2168 cond: soroban_rs::xdr::Preconditions::None,
2169 memo: soroban_rs::xdr::Memo::None,
2170 operations: vec![xlm_payment, usdc_payment].try_into().unwrap(),
2171 ext: TransactionExt::V0,
2172 };
2173
2174 let envelope = TransactionEnvelope::Tx(TransactionV1Envelope {
2175 tx,
2176 signatures: vec![].try_into().unwrap(),
2177 });
2178
2179 let policy = RelayerStellarPolicy::default();
2180
2181 let result = StellarTransactionValidator::validate_token_payment(
2183 &envelope,
2184 TEST_PK_2,
2185 &usdc_asset,
2186 500_000,
2187 &policy,
2188 );
2189 assert!(result.is_ok());
2190 }
2191 }
2192
2193 mod validate_user_fee_payment_amounts_tests {
2194 use super::*;
2195 use soroban_rs::stellar_rpc_client::{
2196 GetLatestLedgerResponse, SimulateTransactionResponse,
2197 };
2198 use soroban_rs::xdr::WriteXdr;
2199
2200 const USDC_ISSUER: &str = TEST_PK;
2201
2202 fn create_usdc_payment_envelope(
2203 source: &str,
2204 destination: &str,
2205 amount: i64,
2206 ) -> TransactionEnvelope {
2207 let payment_op = Operation {
2208 source_account: None,
2209 body: OperationBody::Payment(PaymentOp {
2210 destination: create_muxed_account(destination),
2211 asset: soroban_rs::xdr::Asset::CreditAlphanum4(soroban_rs::xdr::AlphaNum4 {
2212 asset_code: soroban_rs::xdr::AssetCode4(*b"USDC"),
2213 issuer: create_account_id(USDC_ISSUER),
2214 }),
2215 amount,
2216 }),
2217 };
2218
2219 let tx = Transaction {
2220 source_account: create_muxed_account(source),
2221 fee: 100,
2222 seq_num: SequenceNumber(1),
2223 cond: soroban_rs::xdr::Preconditions::None,
2224 memo: soroban_rs::xdr::Memo::None,
2225 operations: vec![payment_op].try_into().unwrap(),
2226 ext: TransactionExt::V0,
2227 };
2228
2229 TransactionEnvelope::Tx(TransactionV1Envelope {
2230 tx,
2231 signatures: vec![].try_into().unwrap(),
2232 })
2233 }
2234
2235 fn create_usdc_policy() -> RelayerStellarPolicy {
2236 let usdc_asset = format!("USDC:{}", USDC_ISSUER);
2237 let mut policy = RelayerStellarPolicy::default();
2238 policy.allowed_tokens = Some(vec![StellarAllowedTokensPolicy {
2239 asset: usdc_asset,
2240 metadata: None,
2241 swap_config: None,
2242 max_allowed_fee: None,
2243 }]);
2244 policy
2245 }
2246
2247 fn create_mock_provider_with_balance(balance: i64) -> MockStellarProviderTrait {
2248 let mut provider = MockStellarProviderTrait::new();
2249
2250 provider.expect_get_account().returning(move |_| {
2252 Box::pin(ready(Ok(AccountEntry {
2253 account_id: create_account_id(TEST_PK),
2254 balance,
2255 seq_num: SequenceNumber(1),
2256 num_sub_entries: 0,
2257 inflation_dest: None,
2258 flags: 0,
2259 home_domain: Default::default(),
2260 thresholds: Thresholds([0; 4]),
2261 signers: Default::default(),
2262 ext: AccountEntryExt::V0,
2263 })))
2264 });
2265
2266 provider.expect_get_latest_ledger().returning(|| {
2268 Box::pin(ready(Ok(GetLatestLedgerResponse {
2269 id: "test".to_string(),
2270 protocol_version: 20,
2271 sequence: 1000,
2272 })))
2273 });
2274
2275 provider
2277 .expect_simulate_transaction_envelope()
2278 .returning(|_| {
2279 Box::pin(ready(Ok(SimulateTransactionResponse {
2280 min_resource_fee: 100,
2281 transaction_data: String::new(),
2282 ..Default::default()
2283 })))
2284 });
2285
2286 provider.expect_get_ledger_entries().returning(|_| {
2288 use soroban_rs::stellar_rpc_client::{GetLedgerEntriesResponse, LedgerEntryResult};
2289 use soroban_rs::xdr::{
2290 LedgerEntry, LedgerEntryData, LedgerEntryExt, TrustLineAsset, TrustLineEntry,
2291 TrustLineEntryExt,
2292 };
2293
2294 let trustline_entry = TrustLineEntry {
2295 account_id: create_account_id(TEST_PK),
2296 asset: TrustLineAsset::CreditAlphanum4(soroban_rs::xdr::AlphaNum4 {
2297 asset_code: soroban_rs::xdr::AssetCode4(*b"USDC"),
2298 issuer: create_account_id(TEST_PK_2),
2299 }),
2300 balance: 10_000_000, limit: 1_000_000_000,
2302 flags: 1,
2303 ext: TrustLineEntryExt::V0,
2304 };
2305
2306 let ledger_entry = LedgerEntry {
2307 last_modified_ledger_seq: 0,
2308 data: LedgerEntryData::Trustline(trustline_entry),
2309 ext: LedgerEntryExt::V0,
2310 };
2311
2312 let xdr_base64 = ledger_entry
2313 .data
2314 .to_xdr_base64(soroban_rs::xdr::Limits::none())
2315 .unwrap();
2316
2317 Box::pin(ready(Ok(GetLedgerEntriesResponse {
2318 entries: Some(vec![LedgerEntryResult {
2319 key: String::new(),
2320 xdr: xdr_base64,
2321 last_modified_ledger: 0,
2322 live_until_ledger_seq_ledger_seq: None,
2323 }]),
2324 latest_ledger: 0,
2325 })))
2326 });
2327
2328 provider
2329 }
2330
2331 fn create_mock_dex_service() -> MockStellarDexServiceTrait {
2332 let mut dex_service = MockStellarDexServiceTrait::new();
2333 dex_service
2334 .expect_get_xlm_to_token_quote()
2335 .returning(|_, _, _, _| {
2336 Box::pin(ready(Ok(
2337 crate::services::stellar_dex::StellarQuoteResponse {
2338 input_asset: "native".to_string(),
2339 output_asset: format!("USDC:{}", USDC_ISSUER),
2340 in_amount: 100,
2341 out_amount: 1_000_000, price_impact_pct: 0.0,
2343 slippage_bps: 100,
2344 path: None,
2345 },
2346 )))
2347 });
2348 dex_service
2349 }
2350
2351 #[tokio::test]
2352 async fn test_valid_fee_payment() {
2353 let envelope = create_usdc_payment_envelope(TEST_PK, TEST_PK_2, 1_000_000);
2354 let policy = create_usdc_policy();
2355 let provider = create_mock_provider_with_balance(10_000_000_000);
2356 let dex_service = create_mock_dex_service();
2357
2358 let result = StellarTransactionValidator::validate_user_fee_payment_amounts(
2359 &envelope,
2360 TEST_PK_2,
2361 &policy,
2362 &provider,
2363 &dex_service,
2364 )
2365 .await;
2366
2367 assert!(result.is_ok());
2368 }
2369
2370 #[tokio::test]
2371 async fn test_no_fee_payment() {
2372 let envelope = create_usdc_payment_envelope(TEST_PK, TEST_PK, 1_000_000);
2374 let policy = create_usdc_policy();
2375 let provider = create_mock_provider_with_balance(10_000_000_000);
2376 let dex_service = create_mock_dex_service();
2377
2378 let result = StellarTransactionValidator::validate_user_fee_payment_amounts(
2379 &envelope,
2380 TEST_PK_2, &policy,
2382 &provider,
2383 &dex_service,
2384 )
2385 .await;
2386
2387 assert!(result.is_err());
2388 assert!(result
2389 .unwrap_err()
2390 .to_string()
2391 .contains("must include a fee payment operation to the relayer"));
2392 }
2393
2394 #[tokio::test]
2395 async fn test_multiple_fee_payments_rejected() {
2396 let payment1 = Operation {
2398 source_account: None,
2399 body: OperationBody::Payment(PaymentOp {
2400 destination: create_muxed_account(TEST_PK_2),
2401 asset: soroban_rs::xdr::Asset::CreditAlphanum4(soroban_rs::xdr::AlphaNum4 {
2402 asset_code: soroban_rs::xdr::AssetCode4(*b"USDC"),
2403 issuer: create_account_id(USDC_ISSUER),
2404 }),
2405 amount: 500_000,
2406 }),
2407 };
2408 let payment2 = Operation {
2409 source_account: None,
2410 body: OperationBody::Payment(PaymentOp {
2411 destination: create_muxed_account(TEST_PK_2),
2412 asset: soroban_rs::xdr::Asset::CreditAlphanum4(soroban_rs::xdr::AlphaNum4 {
2413 asset_code: soroban_rs::xdr::AssetCode4(*b"USDC"),
2414 issuer: create_account_id(USDC_ISSUER),
2415 }),
2416 amount: 500_000,
2417 }),
2418 };
2419
2420 let tx = Transaction {
2421 source_account: create_muxed_account(TEST_PK),
2422 fee: 100,
2423 seq_num: SequenceNumber(1),
2424 cond: soroban_rs::xdr::Preconditions::None,
2425 memo: soroban_rs::xdr::Memo::None,
2426 operations: vec![payment1, payment2].try_into().unwrap(),
2427 ext: TransactionExt::V0,
2428 };
2429
2430 let envelope = TransactionEnvelope::Tx(TransactionV1Envelope {
2431 tx,
2432 signatures: vec![].try_into().unwrap(),
2433 });
2434
2435 let policy = create_usdc_policy();
2436 let provider = create_mock_provider_with_balance(10_000_000_000);
2437 let dex_service = create_mock_dex_service();
2438
2439 let result = StellarTransactionValidator::validate_user_fee_payment_amounts(
2440 &envelope,
2441 TEST_PK_2,
2442 &policy,
2443 &provider,
2444 &dex_service,
2445 )
2446 .await;
2447
2448 assert!(result.is_err());
2449 assert!(result
2450 .unwrap_err()
2451 .to_string()
2452 .contains("exactly one fee payment operation"));
2453 }
2454
2455 #[tokio::test]
2456 async fn test_token_not_allowed() {
2457 let payment_op = Operation {
2459 source_account: None,
2460 body: OperationBody::Payment(PaymentOp {
2461 destination: create_muxed_account(TEST_PK_2),
2462 asset: soroban_rs::xdr::Asset::CreditAlphanum4(soroban_rs::xdr::AlphaNum4 {
2463 asset_code: soroban_rs::xdr::AssetCode4(*b"EURC"),
2464 issuer: create_account_id(TEST_PK),
2465 }),
2466 amount: 1_000_000,
2467 }),
2468 };
2469
2470 let tx = Transaction {
2471 source_account: create_muxed_account(TEST_PK),
2472 fee: 100,
2473 seq_num: SequenceNumber(1),
2474 cond: soroban_rs::xdr::Preconditions::None,
2475 memo: soroban_rs::xdr::Memo::None,
2476 operations: vec![payment_op].try_into().unwrap(),
2477 ext: TransactionExt::V0,
2478 };
2479
2480 let envelope = TransactionEnvelope::Tx(TransactionV1Envelope {
2481 tx,
2482 signatures: vec![].try_into().unwrap(),
2483 });
2484
2485 let policy = create_usdc_policy(); let provider = create_mock_provider_with_balance(10_000_000_000);
2488 let dex_service = create_mock_dex_service();
2489
2490 let result = StellarTransactionValidator::validate_user_fee_payment_amounts(
2491 &envelope,
2492 TEST_PK_2,
2493 &policy,
2494 &provider,
2495 &dex_service,
2496 )
2497 .await;
2498
2499 assert!(result.is_err());
2500 assert!(result
2501 .unwrap_err()
2502 .to_string()
2503 .contains("not in allowed tokens list"));
2504 }
2505
2506 #[tokio::test]
2507 async fn test_fee_exceeds_token_max() {
2508 let envelope = create_usdc_payment_envelope(TEST_PK, TEST_PK_2, 1_000_000);
2509 let usdc_asset = format!("USDC:{}", USDC_ISSUER);
2510 let mut policy = RelayerStellarPolicy::default();
2511 policy.allowed_tokens = Some(vec![StellarAllowedTokensPolicy {
2512 asset: usdc_asset,
2513 metadata: None,
2514 swap_config: None,
2515 max_allowed_fee: Some(500_000), }]);
2517
2518 let provider = create_mock_provider_with_balance(10_000_000_000);
2519 let dex_service = create_mock_dex_service();
2520
2521 let result = StellarTransactionValidator::validate_user_fee_payment_amounts(
2522 &envelope,
2523 TEST_PK_2,
2524 &policy,
2525 &provider,
2526 &dex_service,
2527 )
2528 .await;
2529
2530 assert!(result.is_err());
2531 assert!(result.unwrap_err().to_string().contains("Max fee exceeded"));
2532 }
2533
2534 #[tokio::test]
2535 async fn test_insufficient_payment_amount() {
2536 let envelope = create_usdc_payment_envelope(TEST_PK, TEST_PK_2, 1_000_000);
2537 let policy = create_usdc_policy();
2538 let provider = create_mock_provider_with_balance(10_000_000_000);
2539
2540 let mut dex_service = MockStellarDexServiceTrait::new();
2542 dex_service
2543 .expect_get_xlm_to_token_quote()
2544 .returning(|_, _, _, _| {
2545 Box::pin(ready(Ok(
2546 crate::services::stellar_dex::StellarQuoteResponse {
2547 input_asset: "native".to_string(),
2548 output_asset: "USDC:...".to_string(),
2549 in_amount: 200,
2550 out_amount: 2_000_000, price_impact_pct: 0.0,
2552 slippage_bps: 100,
2553 path: None,
2554 },
2555 )))
2556 });
2557
2558 let result = StellarTransactionValidator::validate_user_fee_payment_amounts(
2559 &envelope,
2560 TEST_PK_2,
2561 &policy,
2562 &provider,
2563 &dex_service,
2564 )
2565 .await;
2566
2567 assert!(result.is_err());
2568 assert!(result
2569 .unwrap_err()
2570 .to_string()
2571 .contains("Insufficient token payment"));
2572 }
2573
2574 #[tokio::test]
2575 async fn test_insufficient_user_balance() {
2576 let envelope = create_usdc_payment_envelope(TEST_PK, TEST_PK_2, 1_000_000);
2577 let policy = create_usdc_policy();
2578
2579 let mut provider = MockStellarProviderTrait::new();
2581
2582 provider.expect_get_account().returning(move |_| {
2583 Box::pin(ready(Ok(AccountEntry {
2584 account_id: create_account_id(TEST_PK),
2585 balance: 10_000_000_000,
2586 seq_num: SequenceNumber(1),
2587 num_sub_entries: 0,
2588 inflation_dest: None,
2589 flags: 0,
2590 home_domain: Default::default(),
2591 thresholds: Thresholds([0; 4]),
2592 signers: Default::default(),
2593 ext: AccountEntryExt::V0,
2594 })))
2595 });
2596
2597 provider.expect_get_latest_ledger().returning(|| {
2598 Box::pin(ready(Ok(GetLatestLedgerResponse {
2599 id: "test".to_string(),
2600 protocol_version: 20,
2601 sequence: 1000,
2602 })))
2603 });
2604
2605 provider
2606 .expect_simulate_transaction_envelope()
2607 .returning(|_| {
2608 Box::pin(ready(Ok(SimulateTransactionResponse {
2609 min_resource_fee: 100,
2610 transaction_data: String::new(),
2611 ..Default::default()
2612 })))
2613 });
2614
2615 provider.expect_get_ledger_entries().returning(|_| {
2617 use soroban_rs::stellar_rpc_client::{GetLedgerEntriesResponse, LedgerEntryResult};
2618 use soroban_rs::xdr::{
2619 LedgerEntry, LedgerEntryData, LedgerEntryExt, TrustLineAsset, TrustLineEntry,
2620 TrustLineEntryExt,
2621 };
2622
2623 let trustline_entry = TrustLineEntry {
2624 account_id: create_account_id(TEST_PK),
2625 asset: TrustLineAsset::CreditAlphanum4(soroban_rs::xdr::AlphaNum4 {
2626 asset_code: soroban_rs::xdr::AssetCode4(*b"USDC"),
2627 issuer: create_account_id(USDC_ISSUER),
2628 }),
2629 balance: 500_000, limit: 1_000_000_000,
2631 flags: 1,
2632 ext: TrustLineEntryExt::V0,
2633 };
2634
2635 let ledger_entry = LedgerEntry {
2636 last_modified_ledger_seq: 0,
2637 data: LedgerEntryData::Trustline(trustline_entry),
2638 ext: LedgerEntryExt::V0,
2639 };
2640
2641 let xdr_base64 = ledger_entry
2642 .data
2643 .to_xdr_base64(soroban_rs::xdr::Limits::none())
2644 .unwrap();
2645
2646 Box::pin(ready(Ok(GetLedgerEntriesResponse {
2647 entries: Some(vec![LedgerEntryResult {
2648 key: String::new(),
2649 xdr: xdr_base64,
2650 last_modified_ledger: 0,
2651 live_until_ledger_seq_ledger_seq: None,
2652 }]),
2653 latest_ledger: 0,
2654 })))
2655 });
2656
2657 let dex_service = create_mock_dex_service();
2658
2659 let result = StellarTransactionValidator::validate_user_fee_payment_amounts(
2660 &envelope,
2661 TEST_PK_2,
2662 &policy,
2663 &provider,
2664 &dex_service,
2665 )
2666 .await;
2667
2668 assert!(result.is_err());
2669 assert!(result
2670 .unwrap_err()
2671 .to_string()
2672 .contains("Insufficient balance"));
2673 }
2674
2675 #[tokio::test]
2676 async fn test_valid_fee_payment_with_usdc() {
2677 let envelope = create_usdc_payment_envelope(TEST_PK, TEST_PK_2, 1_000_000);
2678 let policy = create_usdc_policy();
2679 let provider = create_mock_provider_with_balance(10_000_000_000);
2680 let dex_service = create_mock_dex_service();
2681
2682 let result = StellarTransactionValidator::validate_user_fee_payment_amounts(
2683 &envelope,
2684 TEST_PK_2,
2685 &policy,
2686 &provider,
2687 &dex_service,
2688 )
2689 .await;
2690
2691 assert!(result.is_ok());
2692 }
2693
2694 #[tokio::test]
2695 async fn test_dex_conversion_failure() {
2696 let envelope = create_usdc_payment_envelope(TEST_PK, TEST_PK_2, 1_000_000);
2697 let policy = create_usdc_policy();
2698 let provider = create_mock_provider_with_balance(10_000_000_000);
2699
2700 let mut dex_service = MockStellarDexServiceTrait::new();
2701 dex_service
2702 .expect_get_xlm_to_token_quote()
2703 .returning(|_, _, _, _| {
2704 Box::pin(ready(Err(
2705 crate::services::stellar_dex::StellarDexServiceError::UnknownError(
2706 "DEX unavailable".to_string(),
2707 ),
2708 )))
2709 });
2710
2711 let result = StellarTransactionValidator::validate_user_fee_payment_amounts(
2712 &envelope,
2713 TEST_PK_2,
2714 &policy,
2715 &provider,
2716 &dex_service,
2717 )
2718 .await;
2719
2720 assert!(result.is_err());
2721 assert!(result
2722 .unwrap_err()
2723 .to_string()
2724 .contains("Failed to convert XLM fee to token"));
2725 }
2726 }
2727
2728 mod validate_contract_invocation_tests {
2729 use super::*;
2730
2731 #[test]
2732 fn test_invoke_contract_allowed() {
2733 let invoke_op = InvokeHostFunctionOp {
2734 host_function: HostFunction::InvokeContract(InvokeContractArgs {
2735 contract_address: ScAddress::Contract(soroban_rs::xdr::ContractId(
2736 soroban_rs::xdr::Hash([0u8; 32]),
2737 )),
2738 function_name: ScSymbol("test".try_into().unwrap()),
2739 args: Default::default(),
2740 }),
2741 auth: Default::default(),
2742 };
2743
2744 let policy = RelayerStellarPolicy::default();
2745 assert!(StellarTransactionValidator::validate_contract_invocation(
2746 &invoke_op, 0, TEST_PK_2, &policy
2747 )
2748 .is_ok());
2749 }
2750
2751 #[test]
2752 fn test_create_contract_rejected() {
2753 let invoke_op = InvokeHostFunctionOp {
2754 host_function: HostFunction::CreateContract(soroban_rs::xdr::CreateContractArgs {
2755 contract_id_preimage: soroban_rs::xdr::ContractIdPreimage::Address(
2756 soroban_rs::xdr::ContractIdPreimageFromAddress {
2757 address: ScAddress::Account(create_account_id(TEST_PK)),
2758 salt: soroban_rs::xdr::Uint256([0u8; 32]),
2759 },
2760 ),
2761 executable: soroban_rs::xdr::ContractExecutable::Wasm(soroban_rs::xdr::Hash(
2762 [0u8; 32],
2763 )),
2764 }),
2765 auth: Default::default(),
2766 };
2767
2768 let policy = RelayerStellarPolicy::default();
2769 let result = StellarTransactionValidator::validate_contract_invocation(
2770 &invoke_op, 0, TEST_PK_2, &policy,
2771 );
2772 assert!(result.is_err());
2773 assert!(result
2774 .unwrap_err()
2775 .to_string()
2776 .contains("CreateContract not allowed"));
2777 }
2778
2779 #[test]
2780 fn test_upload_wasm_rejected() {
2781 let invoke_op = InvokeHostFunctionOp {
2782 host_function: HostFunction::UploadContractWasm(vec![0u8; 100].try_into().unwrap()),
2783 auth: Default::default(),
2784 };
2785
2786 let policy = RelayerStellarPolicy::default();
2787 let result = StellarTransactionValidator::validate_contract_invocation(
2788 &invoke_op, 0, TEST_PK_2, &policy,
2789 );
2790 assert!(result.is_err());
2791 assert!(result
2792 .unwrap_err()
2793 .to_string()
2794 .contains("UploadContractWasm not allowed"));
2795 }
2796
2797 #[test]
2798 fn test_relayer_in_auth_rejected() {
2799 let auth_entry = SorobanAuthorizationEntry {
2800 credentials: SorobanCredentials::Address(
2801 soroban_rs::xdr::SorobanAddressCredentials {
2802 address: ScAddress::Account(create_account_id(TEST_PK_2)),
2803 nonce: 0,
2804 signature_expiration_ledger: 0,
2805 signature: soroban_rs::xdr::ScVal::Void,
2806 },
2807 ),
2808 root_invocation: soroban_rs::xdr::SorobanAuthorizedInvocation {
2809 function: SorobanAuthorizedFunction::ContractFn(
2810 soroban_rs::xdr::InvokeContractArgs {
2811 contract_address: ScAddress::Contract(soroban_rs::xdr::ContractId(
2812 soroban_rs::xdr::Hash([0u8; 32]),
2813 )),
2814 function_name: ScSymbol("test".try_into().unwrap()),
2815 args: Default::default(),
2816 },
2817 ),
2818 sub_invocations: Default::default(),
2819 },
2820 };
2821
2822 let invoke_op = InvokeHostFunctionOp {
2823 host_function: HostFunction::InvokeContract(InvokeContractArgs {
2824 contract_address: ScAddress::Contract(soroban_rs::xdr::ContractId(
2825 soroban_rs::xdr::Hash([0u8; 32]),
2826 )),
2827 function_name: ScSymbol("test".try_into().unwrap()),
2828 args: Default::default(),
2829 }),
2830 auth: vec![auth_entry].try_into().unwrap(),
2831 };
2832
2833 let policy = RelayerStellarPolicy::default();
2834 let result = StellarTransactionValidator::validate_contract_invocation(
2835 &invoke_op, 0, TEST_PK_2, &policy,
2837 );
2838 assert!(result.is_err());
2839 assert!(result.unwrap_err().to_string().contains("requires relayer"));
2840 }
2841 }
2842}