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:{TEST_PK}"
1237 ));
1238 assert!(result.is_ok());
1239 }
1240
1241 #[test]
1242 fn test_classic_asset_code_too_long() {
1243 let result = StellarTransactionValidator::validate_fee_token_structure(&format!(
1244 "VERYLONGCODE1:{TEST_PK}"
1245 ));
1246 assert!(result.is_err());
1247 assert!(result
1248 .unwrap_err()
1249 .to_string()
1250 .contains("Invalid asset code length"));
1251 }
1252
1253 #[test]
1254 fn test_classic_asset_invalid_issuer_length() {
1255 let result = StellarTransactionValidator::validate_fee_token_structure("USDC:GSHORT");
1256 assert!(result.is_err());
1257 assert!(result
1258 .unwrap_err()
1259 .to_string()
1260 .contains("Invalid issuer address length"));
1261 }
1262
1263 #[test]
1264 fn test_classic_asset_invalid_issuer_prefix() {
1265 let result = StellarTransactionValidator::validate_fee_token_structure(
1266 "USDC:SAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF",
1267 );
1268 assert!(result.is_err());
1269 assert!(result
1270 .unwrap_err()
1271 .to_string()
1272 .contains("Invalid issuer address prefix"));
1273 }
1274
1275 #[test]
1276 fn test_invalid_format_multiple_colons() {
1277 let result =
1278 StellarTransactionValidator::validate_fee_token_structure("USDC:ISSUER:EXTRA");
1279 assert!(result.is_err());
1280 assert!(result
1281 .unwrap_err()
1282 .to_string()
1283 .contains("Invalid fee_token format"));
1284 }
1285 }
1286
1287 mod validate_allowed_token_tests {
1288 use super::*;
1289
1290 #[test]
1291 fn test_empty_allowed_list_allows_all() {
1292 let policy = RelayerStellarPolicy::default();
1293 assert!(StellarTransactionValidator::validate_allowed_token("native", &policy).is_ok());
1294 assert!(
1295 StellarTransactionValidator::validate_allowed_token(TEST_CONTRACT, &policy).is_ok()
1296 );
1297 }
1298
1299 #[test]
1300 fn test_native_allowed() {
1301 let mut policy = RelayerStellarPolicy::default();
1302 policy.allowed_tokens = Some(vec![StellarAllowedTokensPolicy {
1303 asset: "native".to_string(),
1304 metadata: None,
1305 swap_config: None,
1306 max_allowed_fee: None,
1307 }]);
1308 assert!(StellarTransactionValidator::validate_allowed_token("native", &policy).is_ok());
1309 assert!(StellarTransactionValidator::validate_allowed_token("", &policy).is_ok());
1310 }
1311
1312 #[test]
1313 fn test_native_not_allowed() {
1314 let mut policy = RelayerStellarPolicy::default();
1315 policy.allowed_tokens = Some(vec![StellarAllowedTokensPolicy {
1316 asset: format!("USDC:{TEST_PK}"),
1317 metadata: None,
1318 swap_config: None,
1319 max_allowed_fee: None,
1320 }]);
1321 let result = StellarTransactionValidator::validate_allowed_token("native", &policy);
1322 assert!(result.is_err());
1323 assert!(result
1324 .unwrap_err()
1325 .to_string()
1326 .contains("Native XLM not in allowed tokens list"));
1327 }
1328
1329 #[test]
1330 fn test_token_allowed() {
1331 let token = format!("USDC:{TEST_PK}");
1332 let mut policy = RelayerStellarPolicy::default();
1333 policy.allowed_tokens = Some(vec![StellarAllowedTokensPolicy {
1334 asset: token.clone(),
1335 metadata: None,
1336 swap_config: None,
1337 max_allowed_fee: None,
1338 }]);
1339 assert!(StellarTransactionValidator::validate_allowed_token(&token, &policy).is_ok());
1340 }
1341
1342 #[test]
1343 fn test_token_not_allowed() {
1344 let mut policy = RelayerStellarPolicy::default();
1345 policy.allowed_tokens = Some(vec![StellarAllowedTokensPolicy {
1346 asset: format!("USDC:{TEST_PK}"),
1347 metadata: None,
1348 swap_config: None,
1349 max_allowed_fee: None,
1350 }]);
1351 let result = StellarTransactionValidator::validate_allowed_token(
1352 &format!("AQUA:{TEST_PK_2}"),
1353 &policy,
1354 );
1355 assert!(result.is_err());
1356 assert!(result
1357 .unwrap_err()
1358 .to_string()
1359 .contains("not in allowed tokens list"));
1360 }
1361 }
1362
1363 mod validate_max_fee_tests {
1364 use super::*;
1365
1366 #[test]
1367 fn test_no_max_fee_allows_any() {
1368 let policy = RelayerStellarPolicy::default();
1369 assert!(StellarTransactionValidator::validate_max_fee(1_000_000, &policy).is_ok());
1370 }
1371
1372 #[test]
1373 fn test_fee_within_limit() {
1374 let mut policy = RelayerStellarPolicy::default();
1375 policy.max_fee = Some(1_000_000);
1376 assert!(StellarTransactionValidator::validate_max_fee(500_000, &policy).is_ok());
1377 }
1378
1379 #[test]
1380 fn test_fee_exceeds_limit() {
1381 let mut policy = RelayerStellarPolicy::default();
1382 policy.max_fee = Some(1_000_000);
1383 let result = StellarTransactionValidator::validate_max_fee(2_000_000, &policy);
1384 assert!(result.is_err());
1385 assert!(result.unwrap_err().to_string().contains("Max fee exceeded"));
1386 }
1387 }
1388
1389 mod validate_token_max_fee_tests {
1390 use super::*;
1391
1392 #[test]
1393 fn test_no_token_entry() {
1394 let policy = RelayerStellarPolicy::default();
1395 assert!(StellarTransactionValidator::validate_token_max_fee(
1396 "USDC:ISSUER",
1397 1_000_000,
1398 &policy
1399 )
1400 .is_ok());
1401 }
1402
1403 #[test]
1404 fn test_no_max_allowed_fee_in_entry() {
1405 let mut policy = RelayerStellarPolicy::default();
1406 policy.allowed_tokens = Some(vec![StellarAllowedTokensPolicy {
1407 asset: "USDC:ISSUER".to_string(),
1408 metadata: None,
1409 swap_config: None,
1410 max_allowed_fee: None,
1411 }]);
1412 assert!(StellarTransactionValidator::validate_token_max_fee(
1413 "USDC:ISSUER",
1414 1_000_000,
1415 &policy
1416 )
1417 .is_ok());
1418 }
1419
1420 #[test]
1421 fn test_fee_within_token_limit() {
1422 let mut policy = RelayerStellarPolicy::default();
1423 policy.allowed_tokens = Some(vec![StellarAllowedTokensPolicy {
1424 asset: "USDC:ISSUER".to_string(),
1425 metadata: None,
1426 swap_config: None,
1427 max_allowed_fee: Some(1_000_000),
1428 }]);
1429 assert!(StellarTransactionValidator::validate_token_max_fee(
1430 "USDC:ISSUER",
1431 500_000,
1432 &policy
1433 )
1434 .is_ok());
1435 }
1436
1437 #[test]
1438 fn test_fee_exceeds_token_limit() {
1439 let mut policy = RelayerStellarPolicy::default();
1440 policy.allowed_tokens = Some(vec![StellarAllowedTokensPolicy {
1441 asset: "USDC:ISSUER".to_string(),
1442 metadata: None,
1443 swap_config: None,
1444 max_allowed_fee: Some(1_000_000),
1445 }]);
1446 let result = StellarTransactionValidator::validate_token_max_fee(
1447 "USDC:ISSUER",
1448 2_000_000,
1449 &policy,
1450 );
1451 assert!(result.is_err());
1452 assert!(result.unwrap_err().to_string().contains("Max fee exceeded"));
1453 }
1454 }
1455
1456 mod extract_relayer_payments_tests {
1457 use super::*;
1458
1459 #[test]
1460 fn test_extract_single_payment() {
1461 let envelope = create_simple_v1_envelope(TEST_PK, TEST_PK_2);
1462 let payments =
1463 StellarTransactionValidator::extract_relayer_payments(&envelope, TEST_PK_2)
1464 .unwrap();
1465 assert_eq!(payments.len(), 1);
1466 assert_eq!(payments[0].0, "native");
1467 assert_eq!(payments[0].1, 1_000_000);
1468 }
1469
1470 #[test]
1471 fn test_extract_no_payments_to_relayer() {
1472 let envelope = create_simple_v1_envelope(TEST_PK, TEST_PK_2);
1473 let payments =
1474 StellarTransactionValidator::extract_relayer_payments(&envelope, TEST_PK).unwrap();
1475 assert_eq!(payments.len(), 0);
1476 }
1477
1478 #[test]
1479 fn test_extract_negative_amount_rejected() {
1480 let payment_op = Operation {
1481 source_account: None,
1482 body: OperationBody::Payment(soroban_rs::xdr::PaymentOp {
1483 destination: create_muxed_account(TEST_PK_2),
1484 asset: XdrAsset::Native,
1485 amount: -100, }),
1487 };
1488
1489 let tx = Transaction {
1490 source_account: create_muxed_account(TEST_PK),
1491 fee: 100,
1492 seq_num: SequenceNumber(1),
1493 cond: soroban_rs::xdr::Preconditions::None,
1494 memo: soroban_rs::xdr::Memo::None,
1495 operations: vec![payment_op].try_into().unwrap(),
1496 ext: TransactionExt::V0,
1497 };
1498
1499 let envelope = TransactionEnvelope::Tx(TransactionV1Envelope {
1500 tx,
1501 signatures: vec![].try_into().unwrap(),
1502 });
1503
1504 let result =
1505 StellarTransactionValidator::extract_relayer_payments(&envelope, TEST_PK_2);
1506 assert!(result.is_err());
1507 assert!(result
1508 .unwrap_err()
1509 .to_string()
1510 .contains("Negative payment amount"));
1511 }
1512 }
1513
1514 mod validate_time_bounds_tests {
1515 use super::*;
1516
1517 #[test]
1518 fn test_no_time_bounds_is_ok() {
1519 let envelope = create_simple_v1_envelope(TEST_PK, TEST_PK_2);
1520 assert!(
1521 StellarTransactionValidator::validate_time_bounds_not_expired(&envelope).is_ok()
1522 );
1523 }
1524
1525 #[test]
1526 fn test_valid_time_bounds() {
1527 let now = Utc::now().timestamp() as u64;
1528 let payment_op = create_native_payment_operation(TEST_PK_2, 1_000_000);
1529
1530 let tx = Transaction {
1531 source_account: create_muxed_account(TEST_PK),
1532 fee: 100,
1533 seq_num: SequenceNumber(1),
1534 cond: soroban_rs::xdr::Preconditions::Time(TimeBounds {
1535 min_time: TimePoint(now - 60),
1536 max_time: TimePoint(now + 60),
1537 }),
1538 memo: soroban_rs::xdr::Memo::None,
1539 operations: vec![payment_op].try_into().unwrap(),
1540 ext: TransactionExt::V0,
1541 };
1542
1543 let envelope = TransactionEnvelope::Tx(TransactionV1Envelope {
1544 tx,
1545 signatures: vec![].try_into().unwrap(),
1546 });
1547
1548 assert!(
1549 StellarTransactionValidator::validate_time_bounds_not_expired(&envelope).is_ok()
1550 );
1551 }
1552
1553 #[test]
1554 fn test_expired_transaction() {
1555 let now = Utc::now().timestamp() as u64;
1556 let payment_op = create_native_payment_operation(TEST_PK_2, 1_000_000);
1557
1558 let tx = Transaction {
1559 source_account: create_muxed_account(TEST_PK),
1560 fee: 100,
1561 seq_num: SequenceNumber(1),
1562 cond: soroban_rs::xdr::Preconditions::Time(TimeBounds {
1563 min_time: TimePoint(now - 120),
1564 max_time: TimePoint(now - 60), }),
1566 memo: soroban_rs::xdr::Memo::None,
1567 operations: vec![payment_op].try_into().unwrap(),
1568 ext: TransactionExt::V0,
1569 };
1570
1571 let envelope = TransactionEnvelope::Tx(TransactionV1Envelope {
1572 tx,
1573 signatures: vec![].try_into().unwrap(),
1574 });
1575
1576 let result = StellarTransactionValidator::validate_time_bounds_not_expired(&envelope);
1577 assert!(result.is_err());
1578 assert!(result.unwrap_err().to_string().contains("has expired"));
1579 }
1580
1581 #[test]
1582 fn test_not_yet_valid_transaction() {
1583 let now = Utc::now().timestamp() as u64;
1584 let payment_op = create_native_payment_operation(TEST_PK_2, 1_000_000);
1585
1586 let tx = Transaction {
1587 source_account: create_muxed_account(TEST_PK),
1588 fee: 100,
1589 seq_num: SequenceNumber(1),
1590 cond: soroban_rs::xdr::Preconditions::Time(TimeBounds {
1591 min_time: TimePoint(now + 60), max_time: TimePoint(now + 120),
1593 }),
1594 memo: soroban_rs::xdr::Memo::None,
1595 operations: vec![payment_op].try_into().unwrap(),
1596 ext: TransactionExt::V0,
1597 };
1598
1599 let envelope = TransactionEnvelope::Tx(TransactionV1Envelope {
1600 tx,
1601 signatures: vec![].try_into().unwrap(),
1602 });
1603
1604 let result = StellarTransactionValidator::validate_time_bounds_not_expired(&envelope);
1605 assert!(result.is_err());
1606 assert!(result.unwrap_err().to_string().contains("not yet valid"));
1607 }
1608 }
1609
1610 mod validate_transaction_validity_duration_tests {
1611 use super::*;
1612
1613 #[test]
1614 fn test_duration_within_limit() {
1615 let now = Utc::now().timestamp() as u64;
1616 let payment_op = create_native_payment_operation(TEST_PK_2, 1_000_000);
1617
1618 let tx = Transaction {
1619 source_account: create_muxed_account(TEST_PK),
1620 fee: 100,
1621 seq_num: SequenceNumber(1),
1622 cond: soroban_rs::xdr::Preconditions::Time(TimeBounds {
1623 min_time: TimePoint(0),
1624 max_time: TimePoint(now + 60), }),
1626 memo: soroban_rs::xdr::Memo::None,
1627 operations: vec![payment_op].try_into().unwrap(),
1628 ext: TransactionExt::V0,
1629 };
1630
1631 let envelope = TransactionEnvelope::Tx(TransactionV1Envelope {
1632 tx,
1633 signatures: vec![].try_into().unwrap(),
1634 });
1635
1636 let max_duration = Duration::minutes(5);
1637 assert!(
1638 StellarTransactionValidator::validate_transaction_validity_duration(
1639 &envelope,
1640 max_duration
1641 )
1642 .is_ok()
1643 );
1644 }
1645
1646 #[test]
1647 fn test_duration_exceeds_limit() {
1648 let now = Utc::now().timestamp() as u64;
1649 let payment_op = create_native_payment_operation(TEST_PK_2, 1_000_000);
1650
1651 let tx = Transaction {
1652 source_account: create_muxed_account(TEST_PK),
1653 fee: 100,
1654 seq_num: SequenceNumber(1),
1655 cond: soroban_rs::xdr::Preconditions::Time(TimeBounds {
1656 min_time: TimePoint(0),
1657 max_time: TimePoint(now + 600), }),
1659 memo: soroban_rs::xdr::Memo::None,
1660 operations: vec![payment_op].try_into().unwrap(),
1661 ext: TransactionExt::V0,
1662 };
1663
1664 let envelope = TransactionEnvelope::Tx(TransactionV1Envelope {
1665 tx,
1666 signatures: vec![].try_into().unwrap(),
1667 });
1668
1669 let max_duration = Duration::minutes(5);
1670 let result = StellarTransactionValidator::validate_transaction_validity_duration(
1671 &envelope,
1672 max_duration,
1673 );
1674 assert!(result.is_err());
1675 assert!(result
1676 .unwrap_err()
1677 .to_string()
1678 .contains("exceeds maximum allowed duration"));
1679 }
1680
1681 #[test]
1682 fn test_no_time_bounds_rejected() {
1683 let envelope = create_simple_v1_envelope(TEST_PK, TEST_PK_2);
1684 let max_duration = Duration::minutes(5);
1685 let result = StellarTransactionValidator::validate_transaction_validity_duration(
1686 &envelope,
1687 max_duration,
1688 );
1689 assert!(result.is_err());
1690 assert!(result
1691 .unwrap_err()
1692 .to_string()
1693 .contains("must have time bounds set"));
1694 }
1695 }
1696
1697 mod validate_sequence_number_tests {
1698 use super::*;
1699
1700 #[tokio::test]
1701 async fn test_valid_sequence_number() {
1702 let envelope = create_simple_v1_envelope(TEST_PK, TEST_PK_2);
1703
1704 let mut provider = MockStellarProviderTrait::new();
1705 provider.expect_get_account().returning(|_| {
1706 Box::pin(ready(Ok(AccountEntry {
1707 account_id: create_account_id(TEST_PK),
1708 balance: 1_000_000_000,
1709 seq_num: SequenceNumber(0), num_sub_entries: 0,
1711 inflation_dest: None,
1712 flags: 0,
1713 home_domain: Default::default(),
1714 thresholds: Thresholds([0; 4]),
1715 signers: Default::default(),
1716 ext: AccountEntryExt::V0,
1717 })))
1718 });
1719
1720 assert!(
1721 StellarTransactionValidator::validate_sequence_number(&envelope, &provider)
1722 .await
1723 .is_ok()
1724 );
1725 }
1726
1727 #[tokio::test]
1728 async fn test_equal_sequence_rejected() {
1729 let envelope = create_simple_v1_envelope(TEST_PK, TEST_PK_2);
1730
1731 let mut provider = MockStellarProviderTrait::new();
1732 provider.expect_get_account().returning(|_| {
1733 Box::pin(ready(Ok(AccountEntry {
1734 account_id: create_account_id(TEST_PK),
1735 balance: 1_000_000_000,
1736 seq_num: SequenceNumber(1), num_sub_entries: 0,
1738 inflation_dest: None,
1739 flags: 0,
1740 home_domain: Default::default(),
1741 thresholds: Thresholds([0; 4]),
1742 signers: Default::default(),
1743 ext: AccountEntryExt::V0,
1744 })))
1745 });
1746
1747 let result =
1748 StellarTransactionValidator::validate_sequence_number(&envelope, &provider).await;
1749 assert!(result.is_err());
1750 assert!(result
1751 .unwrap_err()
1752 .to_string()
1753 .contains("strictly greater than"));
1754 }
1755
1756 #[tokio::test]
1757 async fn test_past_sequence_rejected() {
1758 let envelope = create_simple_v1_envelope(TEST_PK, TEST_PK_2);
1759
1760 let mut provider = MockStellarProviderTrait::new();
1761 provider.expect_get_account().returning(|_| {
1762 Box::pin(ready(Ok(AccountEntry {
1763 account_id: create_account_id(TEST_PK),
1764 balance: 1_000_000_000,
1765 seq_num: SequenceNumber(10), num_sub_entries: 0,
1767 inflation_dest: None,
1768 flags: 0,
1769 home_domain: Default::default(),
1770 thresholds: Thresholds([0; 4]),
1771 signers: Default::default(),
1772 ext: AccountEntryExt::V0,
1773 })))
1774 });
1775
1776 let result =
1777 StellarTransactionValidator::validate_sequence_number(&envelope, &provider).await;
1778 assert!(result.is_err());
1779 assert!(result.unwrap_err().to_string().contains("is invalid"));
1780 }
1781 }
1782
1783 mod validate_operations_count_tests {
1784 use super::*;
1785
1786 #[test]
1787 fn test_valid_operations_count() {
1788 let envelope = create_simple_v1_envelope(TEST_PK, TEST_PK_2);
1789 assert!(StellarTransactionValidator::validate_operations_count(&envelope).is_ok());
1790 }
1791
1792 #[test]
1793 fn test_too_many_operations() {
1794 let operations: Vec<Operation> = (0..100)
1800 .map(|_| create_native_payment_operation(TEST_PK_2, 100))
1801 .collect();
1802
1803 let tx = Transaction {
1804 source_account: create_muxed_account(TEST_PK),
1805 fee: 100,
1806 seq_num: SequenceNumber(1),
1807 cond: soroban_rs::xdr::Preconditions::None,
1808 memo: soroban_rs::xdr::Memo::None,
1809 operations: operations.try_into().unwrap(),
1810 ext: TransactionExt::V0,
1811 };
1812
1813 let envelope = TransactionEnvelope::Tx(TransactionV1Envelope {
1814 tx,
1815 signatures: vec![].try_into().unwrap(),
1816 });
1817
1818 let result = StellarTransactionValidator::validate_operations_count(&envelope);
1820 assert!(result.is_ok());
1821 }
1822 }
1823
1824 mod validate_source_account_tests {
1825 use super::*;
1826
1827 #[test]
1828 fn test_source_account_not_relayer() {
1829 let envelope = create_simple_v1_envelope(TEST_PK, TEST_PK_2);
1830 assert!(
1831 StellarTransactionValidator::validate_source_account_not_relayer(
1832 &envelope, TEST_PK_2
1833 )
1834 .is_ok()
1835 );
1836 }
1837
1838 #[test]
1839 fn test_source_account_is_relayer_rejected() {
1840 let envelope = create_simple_v1_envelope(TEST_PK, TEST_PK_2);
1841 let result = StellarTransactionValidator::validate_source_account_not_relayer(
1842 &envelope, TEST_PK,
1843 );
1844 assert!(result.is_err());
1845 assert!(result
1846 .unwrap_err()
1847 .to_string()
1848 .contains("cannot be the relayer address"));
1849 }
1850 }
1851
1852 mod validate_operation_types_tests {
1853 use super::*;
1854
1855 #[test]
1856 fn test_payment_operation_allowed() {
1857 let envelope = create_simple_v1_envelope(TEST_PK, TEST_PK_2);
1858 let policy = RelayerStellarPolicy::default();
1859 assert!(StellarTransactionValidator::validate_operation_types(
1860 &envelope, TEST_PK_2, &policy
1861 )
1862 .is_ok());
1863 }
1864
1865 #[test]
1866 fn test_account_merge_rejected() {
1867 let operation = Operation {
1868 source_account: None,
1869 body: OperationBody::AccountMerge(create_muxed_account(TEST_PK_2)),
1870 };
1871
1872 let tx = Transaction {
1873 source_account: create_muxed_account(TEST_PK),
1874 fee: 100,
1875 seq_num: SequenceNumber(1),
1876 cond: soroban_rs::xdr::Preconditions::None,
1877 memo: soroban_rs::xdr::Memo::None,
1878 operations: vec![operation].try_into().unwrap(),
1879 ext: TransactionExt::V0,
1880 };
1881
1882 let envelope = TransactionEnvelope::Tx(TransactionV1Envelope {
1883 tx,
1884 signatures: vec![].try_into().unwrap(),
1885 });
1886
1887 let policy = RelayerStellarPolicy::default();
1888 let result = StellarTransactionValidator::validate_operation_types(
1889 &envelope, TEST_PK_2, &policy,
1890 );
1891 assert!(result.is_err());
1892 assert!(result
1893 .unwrap_err()
1894 .to_string()
1895 .contains("AccountMerge operations are not allowed"));
1896 }
1897
1898 #[test]
1899 fn test_set_options_rejected() {
1900 let operation = Operation {
1901 source_account: None,
1902 body: OperationBody::SetOptions(soroban_rs::xdr::SetOptionsOp {
1903 inflation_dest: None,
1904 clear_flags: None,
1905 set_flags: None,
1906 master_weight: None,
1907 low_threshold: None,
1908 med_threshold: None,
1909 high_threshold: None,
1910 home_domain: None,
1911 signer: None,
1912 }),
1913 };
1914
1915 let tx = Transaction {
1916 source_account: create_muxed_account(TEST_PK),
1917 fee: 100,
1918 seq_num: SequenceNumber(1),
1919 cond: soroban_rs::xdr::Preconditions::None,
1920 memo: soroban_rs::xdr::Memo::None,
1921 operations: vec![operation].try_into().unwrap(),
1922 ext: TransactionExt::V0,
1923 };
1924
1925 let envelope = TransactionEnvelope::Tx(TransactionV1Envelope {
1926 tx,
1927 signatures: vec![].try_into().unwrap(),
1928 });
1929
1930 let policy = RelayerStellarPolicy::default();
1931 let result = StellarTransactionValidator::validate_operation_types(
1932 &envelope, TEST_PK_2, &policy,
1933 );
1934 assert!(result.is_err());
1935 assert!(result
1936 .unwrap_err()
1937 .to_string()
1938 .contains("SetOptions operations are not allowed"));
1939 }
1940
1941 #[test]
1942 fn test_change_trust_allowed() {
1943 let operation = Operation {
1944 source_account: None,
1945 body: OperationBody::ChangeTrust(ChangeTrustOp {
1946 line: ChangeTrustAsset::CreditAlphanum4(soroban_rs::xdr::AlphaNum4 {
1947 asset_code: soroban_rs::xdr::AssetCode4(*b"USDC"),
1948 issuer: create_account_id(TEST_PK_2),
1949 }),
1950 limit: 1_000_000_000,
1951 }),
1952 };
1953
1954 let tx = Transaction {
1955 source_account: create_muxed_account(TEST_PK),
1956 fee: 100,
1957 seq_num: SequenceNumber(1),
1958 cond: soroban_rs::xdr::Preconditions::None,
1959 memo: soroban_rs::xdr::Memo::None,
1960 operations: vec![operation].try_into().unwrap(),
1961 ext: TransactionExt::V0,
1962 };
1963
1964 let envelope = TransactionEnvelope::Tx(TransactionV1Envelope {
1965 tx,
1966 signatures: vec![].try_into().unwrap(),
1967 });
1968
1969 let policy = RelayerStellarPolicy::default();
1970 assert!(StellarTransactionValidator::validate_operation_types(
1971 &envelope, TEST_PK_2, &policy
1972 )
1973 .is_ok());
1974 }
1975 }
1976
1977 mod validate_token_payment_tests {
1978 use super::*;
1979
1980 #[test]
1981 fn test_valid_native_payment() {
1982 let envelope = create_simple_v1_envelope(TEST_PK, TEST_PK_2);
1983 let policy = RelayerStellarPolicy::default();
1984
1985 let result = StellarTransactionValidator::validate_token_payment(
1986 &envelope, TEST_PK_2, "native", 1_000_000, &policy,
1987 );
1988 assert!(result.is_ok());
1989 }
1990
1991 #[test]
1992 fn test_no_payment_to_relayer() {
1993 let envelope = create_simple_v1_envelope(TEST_PK, TEST_PK_2);
1994 let policy = RelayerStellarPolicy::default();
1995
1996 let result = StellarTransactionValidator::validate_token_payment(
1998 &envelope, TEST_PK, "native", 1_000_000, &policy,
2000 );
2001 assert!(result.is_err());
2002 assert!(result
2003 .unwrap_err()
2004 .to_string()
2005 .contains("No payment operation found to relayer"));
2006 }
2007
2008 #[test]
2009 fn test_wrong_token_in_payment() {
2010 let envelope = create_simple_v1_envelope(TEST_PK, TEST_PK_2);
2011 let policy = RelayerStellarPolicy::default();
2012
2013 let result = StellarTransactionValidator::validate_token_payment(
2015 &envelope,
2016 TEST_PK_2,
2017 &format!("USDC:{TEST_PK}"),
2018 1_000_000,
2019 &policy,
2020 );
2021 assert!(result.is_err());
2022 assert!(result
2023 .unwrap_err()
2024 .to_string()
2025 .contains("No payment found for expected token"));
2026 }
2027
2028 #[test]
2029 fn test_insufficient_payment_amount() {
2030 let envelope = create_simple_v1_envelope(TEST_PK, TEST_PK_2);
2031 let policy = RelayerStellarPolicy::default();
2032
2033 let result = StellarTransactionValidator::validate_token_payment(
2035 &envelope, TEST_PK_2, "native", 2_000_000, &policy,
2036 );
2037 assert!(result.is_err());
2038 assert!(result
2039 .unwrap_err()
2040 .to_string()
2041 .contains("Insufficient token payment"));
2042 }
2043
2044 #[test]
2045 fn test_payment_within_tolerance() {
2046 let envelope = create_simple_v1_envelope(TEST_PK, TEST_PK_2);
2047 let policy = RelayerStellarPolicy::default();
2048
2049 let result = StellarTransactionValidator::validate_token_payment(
2050 &envelope, TEST_PK_2, "native", 990_000, &policy,
2051 );
2052 assert!(result.is_ok());
2053 }
2054
2055 #[test]
2056 fn test_token_not_in_allowed_list() {
2057 let envelope = create_simple_v1_envelope(TEST_PK, TEST_PK_2);
2058 let mut policy = RelayerStellarPolicy::default();
2059 policy.allowed_tokens = Some(vec![StellarAllowedTokensPolicy {
2060 asset: format!("USDC:{TEST_PK}"),
2061 metadata: None,
2062 swap_config: None,
2063 max_allowed_fee: None,
2064 }]);
2065
2066 let result = StellarTransactionValidator::validate_token_payment(
2068 &envelope, TEST_PK_2, "native", 1_000_000, &policy,
2069 );
2070 assert!(result.is_err());
2071 assert!(result
2072 .unwrap_err()
2073 .to_string()
2074 .contains("not in allowed tokens list"));
2075 }
2076
2077 #[test]
2078 fn test_payment_exceeds_token_max_fee() {
2079 let envelope = create_simple_v1_envelope(TEST_PK, TEST_PK_2);
2080 let mut policy = RelayerStellarPolicy::default();
2081 policy.allowed_tokens = Some(vec![StellarAllowedTokensPolicy {
2082 asset: "native".to_string(),
2083 metadata: None,
2084 swap_config: None,
2085 max_allowed_fee: Some(500_000), }]);
2087
2088 let result = StellarTransactionValidator::validate_token_payment(
2090 &envelope, TEST_PK_2, "native", 1_000_000, &policy,
2091 );
2092 assert!(result.is_err());
2093 assert!(result.unwrap_err().to_string().contains("Max fee exceeded"));
2094 }
2095
2096 #[test]
2097 fn test_classic_asset_payment() {
2098 let usdc_asset = format!("USDC:{TEST_PK}");
2099 let payment_op = Operation {
2100 source_account: None,
2101 body: OperationBody::Payment(soroban_rs::xdr::PaymentOp {
2102 destination: create_muxed_account(TEST_PK_2),
2103 asset: XdrAsset::CreditAlphanum4(soroban_rs::xdr::AlphaNum4 {
2104 asset_code: soroban_rs::xdr::AssetCode4(*b"USDC"),
2105 issuer: create_account_id(TEST_PK),
2106 }),
2107 amount: 1_000_000,
2108 }),
2109 };
2110
2111 let tx = Transaction {
2112 source_account: create_muxed_account(TEST_PK),
2113 fee: 100,
2114 seq_num: SequenceNumber(1),
2115 cond: soroban_rs::xdr::Preconditions::None,
2116 memo: soroban_rs::xdr::Memo::None,
2117 operations: vec![payment_op].try_into().unwrap(),
2118 ext: TransactionExt::V0,
2119 };
2120
2121 let envelope = TransactionEnvelope::Tx(TransactionV1Envelope {
2122 tx,
2123 signatures: vec![].try_into().unwrap(),
2124 });
2125
2126 let mut policy = RelayerStellarPolicy::default();
2127 policy.allowed_tokens = Some(vec![StellarAllowedTokensPolicy {
2128 asset: usdc_asset.clone(),
2129 metadata: None,
2130 swap_config: None,
2131 max_allowed_fee: None,
2132 }]);
2133
2134 let result = StellarTransactionValidator::validate_token_payment(
2135 &envelope,
2136 TEST_PK_2,
2137 &usdc_asset,
2138 1_000_000,
2139 &policy,
2140 );
2141 assert!(result.is_ok());
2142 }
2143
2144 #[test]
2145 fn test_multiple_payments_finds_correct_token() {
2146 let usdc_asset = format!("USDC:{TEST_PK}");
2148 let usdc_payment = Operation {
2149 source_account: None,
2150 body: OperationBody::Payment(soroban_rs::xdr::PaymentOp {
2151 destination: create_muxed_account(TEST_PK_2),
2152 asset: XdrAsset::CreditAlphanum4(soroban_rs::xdr::AlphaNum4 {
2153 asset_code: soroban_rs::xdr::AssetCode4(*b"USDC"),
2154 issuer: create_account_id(TEST_PK),
2155 }),
2156 amount: 500_000,
2157 }),
2158 };
2159
2160 let xlm_payment = create_native_payment_operation(TEST_PK, 1_000_000);
2161
2162 let tx = Transaction {
2163 source_account: create_muxed_account(TEST_PK),
2164 fee: 100,
2165 seq_num: SequenceNumber(1),
2166 cond: soroban_rs::xdr::Preconditions::None,
2167 memo: soroban_rs::xdr::Memo::None,
2168 operations: vec![xlm_payment, usdc_payment].try_into().unwrap(),
2169 ext: TransactionExt::V0,
2170 };
2171
2172 let envelope = TransactionEnvelope::Tx(TransactionV1Envelope {
2173 tx,
2174 signatures: vec![].try_into().unwrap(),
2175 });
2176
2177 let policy = RelayerStellarPolicy::default();
2178
2179 let result = StellarTransactionValidator::validate_token_payment(
2181 &envelope,
2182 TEST_PK_2,
2183 &usdc_asset,
2184 500_000,
2185 &policy,
2186 );
2187 assert!(result.is_ok());
2188 }
2189 }
2190
2191 mod validate_user_fee_payment_amounts_tests {
2192 use super::*;
2193 use soroban_rs::stellar_rpc_client::{
2194 GetLatestLedgerResponse, SimulateTransactionResponse,
2195 };
2196 use soroban_rs::xdr::WriteXdr;
2197
2198 const USDC_ISSUER: &str = TEST_PK;
2199
2200 fn create_usdc_payment_envelope(
2201 source: &str,
2202 destination: &str,
2203 amount: i64,
2204 ) -> TransactionEnvelope {
2205 let payment_op = Operation {
2206 source_account: None,
2207 body: OperationBody::Payment(PaymentOp {
2208 destination: create_muxed_account(destination),
2209 asset: soroban_rs::xdr::Asset::CreditAlphanum4(soroban_rs::xdr::AlphaNum4 {
2210 asset_code: soroban_rs::xdr::AssetCode4(*b"USDC"),
2211 issuer: create_account_id(USDC_ISSUER),
2212 }),
2213 amount,
2214 }),
2215 };
2216
2217 let tx = Transaction {
2218 source_account: create_muxed_account(source),
2219 fee: 100,
2220 seq_num: SequenceNumber(1),
2221 cond: soroban_rs::xdr::Preconditions::None,
2222 memo: soroban_rs::xdr::Memo::None,
2223 operations: vec![payment_op].try_into().unwrap(),
2224 ext: TransactionExt::V0,
2225 };
2226
2227 TransactionEnvelope::Tx(TransactionV1Envelope {
2228 tx,
2229 signatures: vec![].try_into().unwrap(),
2230 })
2231 }
2232
2233 fn create_usdc_policy() -> RelayerStellarPolicy {
2234 let usdc_asset = format!("USDC:{USDC_ISSUER}");
2235 let mut policy = RelayerStellarPolicy::default();
2236 policy.allowed_tokens = Some(vec![StellarAllowedTokensPolicy {
2237 asset: usdc_asset,
2238 metadata: None,
2239 swap_config: None,
2240 max_allowed_fee: None,
2241 }]);
2242 policy
2243 }
2244
2245 fn create_mock_provider_with_balance(balance: i64) -> MockStellarProviderTrait {
2246 let mut provider = MockStellarProviderTrait::new();
2247
2248 provider.expect_get_account().returning(move |_| {
2250 Box::pin(ready(Ok(AccountEntry {
2251 account_id: create_account_id(TEST_PK),
2252 balance,
2253 seq_num: SequenceNumber(1),
2254 num_sub_entries: 0,
2255 inflation_dest: None,
2256 flags: 0,
2257 home_domain: Default::default(),
2258 thresholds: Thresholds([0; 4]),
2259 signers: Default::default(),
2260 ext: AccountEntryExt::V0,
2261 })))
2262 });
2263
2264 provider.expect_get_latest_ledger().returning(|| {
2266 Box::pin(ready(Ok(GetLatestLedgerResponse {
2267 id: "test".to_string(),
2268 protocol_version: 20,
2269 sequence: 1000,
2270 })))
2271 });
2272
2273 provider
2275 .expect_simulate_transaction_envelope()
2276 .returning(|_| {
2277 Box::pin(ready(Ok(SimulateTransactionResponse {
2278 min_resource_fee: 100,
2279 transaction_data: String::new(),
2280 ..Default::default()
2281 })))
2282 });
2283
2284 provider.expect_get_ledger_entries().returning(|_| {
2286 use soroban_rs::stellar_rpc_client::{GetLedgerEntriesResponse, LedgerEntryResult};
2287 use soroban_rs::xdr::{
2288 LedgerEntry, LedgerEntryData, LedgerEntryExt, TrustLineAsset, TrustLineEntry,
2289 TrustLineEntryExt,
2290 };
2291
2292 let trustline_entry = TrustLineEntry {
2293 account_id: create_account_id(TEST_PK),
2294 asset: TrustLineAsset::CreditAlphanum4(soroban_rs::xdr::AlphaNum4 {
2295 asset_code: soroban_rs::xdr::AssetCode4(*b"USDC"),
2296 issuer: create_account_id(TEST_PK_2),
2297 }),
2298 balance: 10_000_000, limit: 1_000_000_000,
2300 flags: 1,
2301 ext: TrustLineEntryExt::V0,
2302 };
2303
2304 let ledger_entry = LedgerEntry {
2305 last_modified_ledger_seq: 0,
2306 data: LedgerEntryData::Trustline(trustline_entry),
2307 ext: LedgerEntryExt::V0,
2308 };
2309
2310 let xdr_base64 = ledger_entry
2311 .data
2312 .to_xdr_base64(soroban_rs::xdr::Limits::none())
2313 .unwrap();
2314
2315 Box::pin(ready(Ok(GetLedgerEntriesResponse {
2316 entries: Some(vec![LedgerEntryResult {
2317 key: String::new(),
2318 xdr: xdr_base64,
2319 last_modified_ledger: 0,
2320 live_until_ledger_seq_ledger_seq: None,
2321 }]),
2322 latest_ledger: 0,
2323 })))
2324 });
2325
2326 provider
2327 }
2328
2329 fn create_mock_dex_service() -> MockStellarDexServiceTrait {
2330 let mut dex_service = MockStellarDexServiceTrait::new();
2331 dex_service
2332 .expect_get_xlm_to_token_quote()
2333 .returning(|_, _, _, _| {
2334 Box::pin(ready(Ok(
2335 crate::services::stellar_dex::StellarQuoteResponse {
2336 input_asset: "native".to_string(),
2337 output_asset: format!("USDC:{USDC_ISSUER}"),
2338 in_amount: 100,
2339 out_amount: 1_000_000, price_impact_pct: 0.0,
2341 slippage_bps: 100,
2342 path: None,
2343 },
2344 )))
2345 });
2346 dex_service
2347 }
2348
2349 #[tokio::test]
2350 async fn test_valid_fee_payment() {
2351 let envelope = create_usdc_payment_envelope(TEST_PK, TEST_PK_2, 1_000_000);
2352 let policy = create_usdc_policy();
2353 let provider = create_mock_provider_with_balance(10_000_000_000);
2354 let dex_service = create_mock_dex_service();
2355
2356 let result = StellarTransactionValidator::validate_user_fee_payment_amounts(
2357 &envelope,
2358 TEST_PK_2,
2359 &policy,
2360 &provider,
2361 &dex_service,
2362 )
2363 .await;
2364
2365 assert!(result.is_ok());
2366 }
2367
2368 #[tokio::test]
2369 async fn test_no_fee_payment() {
2370 let envelope = create_usdc_payment_envelope(TEST_PK, TEST_PK, 1_000_000);
2372 let policy = create_usdc_policy();
2373 let provider = create_mock_provider_with_balance(10_000_000_000);
2374 let dex_service = create_mock_dex_service();
2375
2376 let result = StellarTransactionValidator::validate_user_fee_payment_amounts(
2377 &envelope,
2378 TEST_PK_2, &policy,
2380 &provider,
2381 &dex_service,
2382 )
2383 .await;
2384
2385 assert!(result.is_err());
2386 assert!(result
2387 .unwrap_err()
2388 .to_string()
2389 .contains("must include a fee payment operation to the relayer"));
2390 }
2391
2392 #[tokio::test]
2393 async fn test_multiple_fee_payments_rejected() {
2394 let payment1 = Operation {
2396 source_account: None,
2397 body: OperationBody::Payment(PaymentOp {
2398 destination: create_muxed_account(TEST_PK_2),
2399 asset: soroban_rs::xdr::Asset::CreditAlphanum4(soroban_rs::xdr::AlphaNum4 {
2400 asset_code: soroban_rs::xdr::AssetCode4(*b"USDC"),
2401 issuer: create_account_id(USDC_ISSUER),
2402 }),
2403 amount: 500_000,
2404 }),
2405 };
2406 let payment2 = Operation {
2407 source_account: None,
2408 body: OperationBody::Payment(PaymentOp {
2409 destination: create_muxed_account(TEST_PK_2),
2410 asset: soroban_rs::xdr::Asset::CreditAlphanum4(soroban_rs::xdr::AlphaNum4 {
2411 asset_code: soroban_rs::xdr::AssetCode4(*b"USDC"),
2412 issuer: create_account_id(USDC_ISSUER),
2413 }),
2414 amount: 500_000,
2415 }),
2416 };
2417
2418 let tx = Transaction {
2419 source_account: create_muxed_account(TEST_PK),
2420 fee: 100,
2421 seq_num: SequenceNumber(1),
2422 cond: soroban_rs::xdr::Preconditions::None,
2423 memo: soroban_rs::xdr::Memo::None,
2424 operations: vec![payment1, payment2].try_into().unwrap(),
2425 ext: TransactionExt::V0,
2426 };
2427
2428 let envelope = TransactionEnvelope::Tx(TransactionV1Envelope {
2429 tx,
2430 signatures: vec![].try_into().unwrap(),
2431 });
2432
2433 let policy = create_usdc_policy();
2434 let provider = create_mock_provider_with_balance(10_000_000_000);
2435 let dex_service = create_mock_dex_service();
2436
2437 let result = StellarTransactionValidator::validate_user_fee_payment_amounts(
2438 &envelope,
2439 TEST_PK_2,
2440 &policy,
2441 &provider,
2442 &dex_service,
2443 )
2444 .await;
2445
2446 assert!(result.is_err());
2447 assert!(result
2448 .unwrap_err()
2449 .to_string()
2450 .contains("exactly one fee payment operation"));
2451 }
2452
2453 #[tokio::test]
2454 async fn test_token_not_allowed() {
2455 let payment_op = Operation {
2457 source_account: None,
2458 body: OperationBody::Payment(PaymentOp {
2459 destination: create_muxed_account(TEST_PK_2),
2460 asset: soroban_rs::xdr::Asset::CreditAlphanum4(soroban_rs::xdr::AlphaNum4 {
2461 asset_code: soroban_rs::xdr::AssetCode4(*b"EURC"),
2462 issuer: create_account_id(TEST_PK),
2463 }),
2464 amount: 1_000_000,
2465 }),
2466 };
2467
2468 let tx = Transaction {
2469 source_account: create_muxed_account(TEST_PK),
2470 fee: 100,
2471 seq_num: SequenceNumber(1),
2472 cond: soroban_rs::xdr::Preconditions::None,
2473 memo: soroban_rs::xdr::Memo::None,
2474 operations: vec![payment_op].try_into().unwrap(),
2475 ext: TransactionExt::V0,
2476 };
2477
2478 let envelope = TransactionEnvelope::Tx(TransactionV1Envelope {
2479 tx,
2480 signatures: vec![].try_into().unwrap(),
2481 });
2482
2483 let policy = create_usdc_policy(); let provider = create_mock_provider_with_balance(10_000_000_000);
2486 let dex_service = create_mock_dex_service();
2487
2488 let result = StellarTransactionValidator::validate_user_fee_payment_amounts(
2489 &envelope,
2490 TEST_PK_2,
2491 &policy,
2492 &provider,
2493 &dex_service,
2494 )
2495 .await;
2496
2497 assert!(result.is_err());
2498 assert!(result
2499 .unwrap_err()
2500 .to_string()
2501 .contains("not in allowed tokens list"));
2502 }
2503
2504 #[tokio::test]
2505 async fn test_fee_exceeds_token_max() {
2506 let envelope = create_usdc_payment_envelope(TEST_PK, TEST_PK_2, 1_000_000);
2507 let usdc_asset = format!("USDC:{USDC_ISSUER}");
2508 let mut policy = RelayerStellarPolicy::default();
2509 policy.allowed_tokens = Some(vec![StellarAllowedTokensPolicy {
2510 asset: usdc_asset,
2511 metadata: None,
2512 swap_config: None,
2513 max_allowed_fee: Some(500_000), }]);
2515
2516 let provider = create_mock_provider_with_balance(10_000_000_000);
2517 let dex_service = create_mock_dex_service();
2518
2519 let result = StellarTransactionValidator::validate_user_fee_payment_amounts(
2520 &envelope,
2521 TEST_PK_2,
2522 &policy,
2523 &provider,
2524 &dex_service,
2525 )
2526 .await;
2527
2528 assert!(result.is_err());
2529 assert!(result.unwrap_err().to_string().contains("Max fee exceeded"));
2530 }
2531
2532 #[tokio::test]
2533 async fn test_insufficient_payment_amount() {
2534 let envelope = create_usdc_payment_envelope(TEST_PK, TEST_PK_2, 1_000_000);
2535 let policy = create_usdc_policy();
2536 let provider = create_mock_provider_with_balance(10_000_000_000);
2537
2538 let mut dex_service = MockStellarDexServiceTrait::new();
2540 dex_service
2541 .expect_get_xlm_to_token_quote()
2542 .returning(|_, _, _, _| {
2543 Box::pin(ready(Ok(
2544 crate::services::stellar_dex::StellarQuoteResponse {
2545 input_asset: "native".to_string(),
2546 output_asset: "USDC:...".to_string(),
2547 in_amount: 200,
2548 out_amount: 2_000_000, price_impact_pct: 0.0,
2550 slippage_bps: 100,
2551 path: None,
2552 },
2553 )))
2554 });
2555
2556 let result = StellarTransactionValidator::validate_user_fee_payment_amounts(
2557 &envelope,
2558 TEST_PK_2,
2559 &policy,
2560 &provider,
2561 &dex_service,
2562 )
2563 .await;
2564
2565 assert!(result.is_err());
2566 assert!(result
2567 .unwrap_err()
2568 .to_string()
2569 .contains("Insufficient token payment"));
2570 }
2571
2572 #[tokio::test]
2573 async fn test_insufficient_user_balance() {
2574 let envelope = create_usdc_payment_envelope(TEST_PK, TEST_PK_2, 1_000_000);
2575 let policy = create_usdc_policy();
2576
2577 let mut provider = MockStellarProviderTrait::new();
2579
2580 provider.expect_get_account().returning(move |_| {
2581 Box::pin(ready(Ok(AccountEntry {
2582 account_id: create_account_id(TEST_PK),
2583 balance: 10_000_000_000,
2584 seq_num: SequenceNumber(1),
2585 num_sub_entries: 0,
2586 inflation_dest: None,
2587 flags: 0,
2588 home_domain: Default::default(),
2589 thresholds: Thresholds([0; 4]),
2590 signers: Default::default(),
2591 ext: AccountEntryExt::V0,
2592 })))
2593 });
2594
2595 provider.expect_get_latest_ledger().returning(|| {
2596 Box::pin(ready(Ok(GetLatestLedgerResponse {
2597 id: "test".to_string(),
2598 protocol_version: 20,
2599 sequence: 1000,
2600 })))
2601 });
2602
2603 provider
2604 .expect_simulate_transaction_envelope()
2605 .returning(|_| {
2606 Box::pin(ready(Ok(SimulateTransactionResponse {
2607 min_resource_fee: 100,
2608 transaction_data: String::new(),
2609 ..Default::default()
2610 })))
2611 });
2612
2613 provider.expect_get_ledger_entries().returning(|_| {
2615 use soroban_rs::stellar_rpc_client::{GetLedgerEntriesResponse, LedgerEntryResult};
2616 use soroban_rs::xdr::{
2617 LedgerEntry, LedgerEntryData, LedgerEntryExt, TrustLineAsset, TrustLineEntry,
2618 TrustLineEntryExt,
2619 };
2620
2621 let trustline_entry = TrustLineEntry {
2622 account_id: create_account_id(TEST_PK),
2623 asset: TrustLineAsset::CreditAlphanum4(soroban_rs::xdr::AlphaNum4 {
2624 asset_code: soroban_rs::xdr::AssetCode4(*b"USDC"),
2625 issuer: create_account_id(USDC_ISSUER),
2626 }),
2627 balance: 500_000, limit: 1_000_000_000,
2629 flags: 1,
2630 ext: TrustLineEntryExt::V0,
2631 };
2632
2633 let ledger_entry = LedgerEntry {
2634 last_modified_ledger_seq: 0,
2635 data: LedgerEntryData::Trustline(trustline_entry),
2636 ext: LedgerEntryExt::V0,
2637 };
2638
2639 let xdr_base64 = ledger_entry
2640 .data
2641 .to_xdr_base64(soroban_rs::xdr::Limits::none())
2642 .unwrap();
2643
2644 Box::pin(ready(Ok(GetLedgerEntriesResponse {
2645 entries: Some(vec![LedgerEntryResult {
2646 key: String::new(),
2647 xdr: xdr_base64,
2648 last_modified_ledger: 0,
2649 live_until_ledger_seq_ledger_seq: None,
2650 }]),
2651 latest_ledger: 0,
2652 })))
2653 });
2654
2655 let dex_service = create_mock_dex_service();
2656
2657 let result = StellarTransactionValidator::validate_user_fee_payment_amounts(
2658 &envelope,
2659 TEST_PK_2,
2660 &policy,
2661 &provider,
2662 &dex_service,
2663 )
2664 .await;
2665
2666 assert!(result.is_err());
2667 assert!(result
2668 .unwrap_err()
2669 .to_string()
2670 .contains("Insufficient balance"));
2671 }
2672
2673 #[tokio::test]
2674 async fn test_valid_fee_payment_with_usdc() {
2675 let envelope = create_usdc_payment_envelope(TEST_PK, TEST_PK_2, 1_000_000);
2676 let policy = create_usdc_policy();
2677 let provider = create_mock_provider_with_balance(10_000_000_000);
2678 let dex_service = create_mock_dex_service();
2679
2680 let result = StellarTransactionValidator::validate_user_fee_payment_amounts(
2681 &envelope,
2682 TEST_PK_2,
2683 &policy,
2684 &provider,
2685 &dex_service,
2686 )
2687 .await;
2688
2689 assert!(result.is_ok());
2690 }
2691
2692 #[tokio::test]
2693 async fn test_dex_conversion_failure() {
2694 let envelope = create_usdc_payment_envelope(TEST_PK, TEST_PK_2, 1_000_000);
2695 let policy = create_usdc_policy();
2696 let provider = create_mock_provider_with_balance(10_000_000_000);
2697
2698 let mut dex_service = MockStellarDexServiceTrait::new();
2699 dex_service
2700 .expect_get_xlm_to_token_quote()
2701 .returning(|_, _, _, _| {
2702 Box::pin(ready(Err(
2703 crate::services::stellar_dex::StellarDexServiceError::UnknownError(
2704 "DEX unavailable".to_string(),
2705 ),
2706 )))
2707 });
2708
2709 let result = StellarTransactionValidator::validate_user_fee_payment_amounts(
2710 &envelope,
2711 TEST_PK_2,
2712 &policy,
2713 &provider,
2714 &dex_service,
2715 )
2716 .await;
2717
2718 assert!(result.is_err());
2719 assert!(result
2720 .unwrap_err()
2721 .to_string()
2722 .contains("Failed to convert XLM fee to token"));
2723 }
2724 }
2725
2726 mod validate_contract_invocation_tests {
2727 use super::*;
2728
2729 #[test]
2730 fn test_invoke_contract_allowed() {
2731 let invoke_op = InvokeHostFunctionOp {
2732 host_function: HostFunction::InvokeContract(InvokeContractArgs {
2733 contract_address: ScAddress::Contract(soroban_rs::xdr::ContractId(
2734 soroban_rs::xdr::Hash([0u8; 32]),
2735 )),
2736 function_name: ScSymbol("test".try_into().unwrap()),
2737 args: Default::default(),
2738 }),
2739 auth: Default::default(),
2740 };
2741
2742 let policy = RelayerStellarPolicy::default();
2743 assert!(StellarTransactionValidator::validate_contract_invocation(
2744 &invoke_op, 0, TEST_PK_2, &policy
2745 )
2746 .is_ok());
2747 }
2748
2749 #[test]
2750 fn test_create_contract_rejected() {
2751 let invoke_op = InvokeHostFunctionOp {
2752 host_function: HostFunction::CreateContract(soroban_rs::xdr::CreateContractArgs {
2753 contract_id_preimage: soroban_rs::xdr::ContractIdPreimage::Address(
2754 soroban_rs::xdr::ContractIdPreimageFromAddress {
2755 address: ScAddress::Account(create_account_id(TEST_PK)),
2756 salt: soroban_rs::xdr::Uint256([0u8; 32]),
2757 },
2758 ),
2759 executable: soroban_rs::xdr::ContractExecutable::Wasm(soroban_rs::xdr::Hash(
2760 [0u8; 32],
2761 )),
2762 }),
2763 auth: Default::default(),
2764 };
2765
2766 let policy = RelayerStellarPolicy::default();
2767 let result = StellarTransactionValidator::validate_contract_invocation(
2768 &invoke_op, 0, TEST_PK_2, &policy,
2769 );
2770 assert!(result.is_err());
2771 assert!(result
2772 .unwrap_err()
2773 .to_string()
2774 .contains("CreateContract not allowed"));
2775 }
2776
2777 #[test]
2778 fn test_upload_wasm_rejected() {
2779 let invoke_op = InvokeHostFunctionOp {
2780 host_function: HostFunction::UploadContractWasm(vec![0u8; 100].try_into().unwrap()),
2781 auth: Default::default(),
2782 };
2783
2784 let policy = RelayerStellarPolicy::default();
2785 let result = StellarTransactionValidator::validate_contract_invocation(
2786 &invoke_op, 0, TEST_PK_2, &policy,
2787 );
2788 assert!(result.is_err());
2789 assert!(result
2790 .unwrap_err()
2791 .to_string()
2792 .contains("UploadContractWasm not allowed"));
2793 }
2794
2795 #[test]
2796 fn test_relayer_in_auth_rejected() {
2797 let auth_entry = SorobanAuthorizationEntry {
2798 credentials: SorobanCredentials::Address(
2799 soroban_rs::xdr::SorobanAddressCredentials {
2800 address: ScAddress::Account(create_account_id(TEST_PK_2)),
2801 nonce: 0,
2802 signature_expiration_ledger: 0,
2803 signature: soroban_rs::xdr::ScVal::Void,
2804 },
2805 ),
2806 root_invocation: soroban_rs::xdr::SorobanAuthorizedInvocation {
2807 function: SorobanAuthorizedFunction::ContractFn(
2808 soroban_rs::xdr::InvokeContractArgs {
2809 contract_address: ScAddress::Contract(soroban_rs::xdr::ContractId(
2810 soroban_rs::xdr::Hash([0u8; 32]),
2811 )),
2812 function_name: ScSymbol("test".try_into().unwrap()),
2813 args: Default::default(),
2814 },
2815 ),
2816 sub_invocations: Default::default(),
2817 },
2818 };
2819
2820 let invoke_op = InvokeHostFunctionOp {
2821 host_function: HostFunction::InvokeContract(InvokeContractArgs {
2822 contract_address: ScAddress::Contract(soroban_rs::xdr::ContractId(
2823 soroban_rs::xdr::Hash([0u8; 32]),
2824 )),
2825 function_name: ScSymbol("test".try_into().unwrap()),
2826 args: Default::default(),
2827 }),
2828 auth: vec![auth_entry].try_into().unwrap(),
2829 };
2830
2831 let policy = RelayerStellarPolicy::default();
2832 let result = StellarTransactionValidator::validate_contract_invocation(
2833 &invoke_op, 0, TEST_PK_2, &policy,
2835 );
2836 assert!(result.is_err());
2837 assert!(result.unwrap_err().to_string().contains("requires relayer"));
2838 }
2839 }
2840}