openzeppelin_relayer/domain/relayer/stellar/
gas_abstraction.rs

1//! Gas abstraction implementation for Stellar relayers.
2//!
3//! This module implements the `GasAbstractionTrait` for Stellar relayers, providing
4//! gas abstraction functionality including fee estimation and transaction preparation.
5
6use async_trait::async_trait;
7use chrono::Utc;
8use soroban_rs::xdr::{Limits, Operation, TransactionEnvelope, WriteXdr};
9use tracing::{debug, warn};
10
11use crate::constants::{
12    get_stellar_sponsored_transaction_validity_duration, STELLAR_DEFAULT_TRANSACTION_FEE,
13    STELLAR_LEDGER_TIME_SECONDS,
14};
15
16use crate::domain::relayer::{
17    stellar::utils::{apply_max_fee_slippage, get_expiration_ledger},
18    stellar::xdr_utils::{extract_source_account, parse_transaction_xdr},
19    GasAbstractionTrait, RelayerError, StellarRelayer,
20};
21use crate::domain::transaction::stellar::{
22    utils::{
23        add_operation_to_envelope, amount_to_ui_amount, convert_xlm_fee_to_token,
24        create_fee_payment_operation, estimate_fee, set_time_bounds, FeeQuote,
25    },
26    StellarTransactionValidator,
27};
28use crate::domain::xdr_needs_simulation;
29use crate::jobs::JobProducerTrait;
30use crate::models::{
31    transaction::stellar::OperationSpec, SponsoredTransactionBuildRequest,
32    SponsoredTransactionBuildResponse, SponsoredTransactionQuoteRequest,
33    SponsoredTransactionQuoteResponse, StellarFeeEstimateResult, StellarPrepareTransactionResult,
34    StellarTransactionData, TransactionInput,
35};
36use crate::models::{NetworkRepoModel, RelayerRepoModel, TransactionRepoModel};
37use crate::repositories::{
38    NetworkRepository, RelayerRepository, Repository, TransactionRepository,
39};
40use crate::services::provider::StellarProviderTrait;
41use crate::services::signer::StellarSignTrait;
42use crate::services::stellar_dex::StellarDexServiceTrait;
43use crate::services::stellar_fee_forwarder::{FeeForwarderParams, FeeForwarderService};
44use crate::services::TransactionCounterServiceTrait;
45use soroban_rs::xdr::{HostFunction, OperationBody, ReadXdr, ScVal};
46
47/// Information extracted from a Soroban InvokeHostFunction operation
48#[derive(Debug, Clone)]
49pub struct SorobanInvokeInfo {
50    /// Target contract address (C... format)
51    pub target_contract: String,
52    /// Target function name
53    pub target_fn: String,
54    /// Target function arguments
55    pub target_args: Vec<ScVal>,
56}
57
58/// Detect if a transaction XDR contains a Soroban InvokeHostFunction operation
59/// and extract the contract call details.
60///
61/// Returns:
62/// - `Ok(Some(info))` if XDR contains an InvokeHostFunction operation
63/// - `Ok(None)` if XDR is a classic transaction (no InvokeHostFunction)
64/// - `Err(...)` if XDR is invalid
65fn detect_soroban_invoke_from_xdr(xdr: &str) -> Result<Option<SorobanInvokeInfo>, RelayerError> {
66    use soroban_rs::xdr::TransactionEnvelope;
67
68    let envelope = TransactionEnvelope::from_xdr_base64(xdr, Limits::none())
69        .map_err(|e| RelayerError::ValidationError(format!("Invalid XDR: {e}")))?;
70
71    // Extract operations from envelope
72    let operations = match &envelope {
73        TransactionEnvelope::TxV0(env) => env.tx.operations.to_vec(),
74        TransactionEnvelope::Tx(env) => env.tx.operations.to_vec(),
75        TransactionEnvelope::TxFeeBump(env) => match &env.tx.inner_tx {
76            soroban_rs::xdr::FeeBumpTransactionInnerTx::Tx(inner) => inner.tx.operations.to_vec(),
77        },
78    };
79
80    let mut invoke_index = None;
81    let mut invoke_op = None;
82
83    for (idx, op) in operations.iter().enumerate() {
84        if let OperationBody::InvokeHostFunction(invoke) = &op.body {
85            invoke_index = Some(idx);
86            invoke_op = Some(invoke);
87            break;
88        }
89    }
90
91    if let Some(idx) = invoke_index {
92        // Soroban transactions must contain exactly one operation
93        if operations.len() != 1 {
94            return Err(RelayerError::ValidationError(
95                "Soroban transactions must contain exactly one operation".to_string(),
96            ));
97        }
98
99        // Single-operation Soroban must be InvokeHostFunction
100        let invoke_op = invoke_op.ok_or_else(|| {
101            RelayerError::ValidationError("InvokeHostFunction operation missing".to_string())
102        })?;
103
104        if idx != 0 {
105            return Err(RelayerError::ValidationError(
106                "InvokeHostFunction must be the first operation".to_string(),
107            ));
108        }
109
110        if let HostFunction::InvokeContract(invoke_args) = &invoke_op.host_function {
111            // Extract contract address
112            let target_contract = match &invoke_args.contract_address {
113                soroban_rs::xdr::ScAddress::Contract(contract_id) => {
114                    stellar_strkey::Contract(contract_id.0 .0).to_string()
115                }
116                _ => {
117                    return Err(RelayerError::ValidationError(
118                        "InvokeHostFunction must target a contract address".to_string(),
119                    ));
120                }
121            };
122
123            // Extract function name
124            let target_fn = invoke_args.function_name.to_utf8_string_lossy();
125
126            // Extract arguments
127            let target_args: Vec<ScVal> = invoke_args.args.to_vec();
128
129            return Ok(Some(SorobanInvokeInfo {
130                target_contract,
131                target_fn,
132                target_args,
133            }));
134        }
135    }
136
137    // Not a Soroban InvokeHostFunction transaction
138    Ok(None)
139}
140
141#[async_trait]
142impl<P, RR, NR, TR, J, TCS, S, D> GasAbstractionTrait
143    for StellarRelayer<P, RR, NR, TR, J, TCS, S, D>
144where
145    P: StellarProviderTrait + Send + Sync,
146    D: StellarDexServiceTrait + Send + Sync + 'static,
147    RR: Repository<RelayerRepoModel, String> + RelayerRepository + Send + Sync + 'static,
148    NR: NetworkRepository + Repository<NetworkRepoModel, String> + Send + Sync + 'static,
149    TR: Repository<TransactionRepoModel, String> + TransactionRepository + Send + Sync + 'static,
150    J: JobProducerTrait + Send + Sync + 'static,
151    TCS: TransactionCounterServiceTrait + Send + Sync + 'static,
152    S: StellarSignTrait + Send + Sync + 'static,
153{
154    async fn quote_sponsored_transaction(
155        &self,
156        params: SponsoredTransactionQuoteRequest,
157    ) -> Result<SponsoredTransactionQuoteResponse, RelayerError> {
158        let params = match params {
159            SponsoredTransactionQuoteRequest::Stellar(p) => p,
160            _ => {
161                return Err(RelayerError::ValidationError(
162                    "Expected Stellar fee estimate request parameters".to_string(),
163                ));
164            }
165        };
166
167        // Check if this is a Soroban gas abstraction request by detecting InvokeHostFunction in XDR
168        // Soroban mode is detected when transaction_xdr contains an InvokeHostFunction operation
169        if let Some(xdr) = &params.transaction_xdr {
170            if let Some(soroban_info) = detect_soroban_invoke_from_xdr(xdr)? {
171                return self.quote_soroban_from_xdr(&params, &soroban_info).await;
172            }
173        }
174
175        // Classic sponsored transaction flow
176        self.quote_classic_sponsored(&params).await
177    }
178
179    async fn build_sponsored_transaction(
180        &self,
181        params: SponsoredTransactionBuildRequest,
182    ) -> Result<SponsoredTransactionBuildResponse, RelayerError> {
183        let params = match params {
184            SponsoredTransactionBuildRequest::Stellar(p) => p,
185            _ => {
186                return Err(RelayerError::ValidationError(
187                    "Expected Stellar prepare transaction request parameters".to_string(),
188                ));
189            }
190        };
191
192        let policy = self.relayer.policies.get_stellar_policy();
193
194        // Validate allowed token
195        StellarTransactionValidator::validate_allowed_token(&params.fee_token, &policy)
196            .map_err(|e| RelayerError::ValidationError(e.to_string()))?;
197
198        // Validate fee_payment_strategy is User
199        if !policy.is_user_fee_payment() {
200            return Err(RelayerError::ValidationError(
201                "Gas abstraction requires fee_payment_strategy: User".to_string(),
202            ));
203        }
204
205        // Check if this is a Soroban gas abstraction request by detecting InvokeHostFunction in XDR
206        if let Some(xdr) = &params.transaction_xdr {
207            if let Some(soroban_info) = detect_soroban_invoke_from_xdr(xdr)? {
208                return self.build_soroban_sponsored(&params, &soroban_info).await;
209            }
210        }
211
212        // Classic sponsored transaction flow
213        self.build_classic_sponsored(&params).await
214    }
215}
216
217// ============================================================================
218// Classic Sponsored Transaction Handlers (Fee-bump Flow)
219// ============================================================================
220
221impl<P, RR, NR, TR, J, TCS, S, D> StellarRelayer<P, RR, NR, TR, J, TCS, S, D>
222where
223    P: StellarProviderTrait + Send + Sync,
224    D: StellarDexServiceTrait + Send + Sync + 'static,
225    RR: Repository<RelayerRepoModel, String> + RelayerRepository + Send + Sync + 'static,
226    NR: NetworkRepository + Repository<NetworkRepoModel, String> + Send + Sync + 'static,
227    TR: Repository<TransactionRepoModel, String> + TransactionRepository + Send + Sync + 'static,
228    J: JobProducerTrait + Send + Sync + 'static,
229    TCS: TransactionCounterServiceTrait + Send + Sync + 'static,
230    S: StellarSignTrait + Send + Sync + 'static,
231{
232    /// Quote a classic sponsored transaction (fee-bump flow)
233    ///
234    /// Estimates the fee for a standard Stellar transaction where the relayer
235    /// pays the network fee and user pays in a token.
236    async fn quote_classic_sponsored(
237        &self,
238        params: &crate::models::StellarFeeEstimateRequestParams,
239    ) -> Result<SponsoredTransactionQuoteResponse, RelayerError> {
240        debug!(
241            "Processing classic quote sponsored transaction for token: {}",
242            params.fee_token
243        );
244
245        let policy = self.relayer.policies.get_stellar_policy();
246
247        // Validate allowed token
248        StellarTransactionValidator::validate_allowed_token(&params.fee_token, &policy)
249            .map_err(|e| RelayerError::ValidationError(e.to_string()))?;
250
251        // Validate that either transaction_xdr or operations is provided
252        if params.transaction_xdr.is_none() && params.operations.is_none() {
253            return Err(RelayerError::ValidationError(
254                "Must provide either transaction_xdr or operations in the request".to_string(),
255            ));
256        }
257
258        // Build envelope from XDR or operations
259        let envelope = build_envelope_from_request(
260            params.transaction_xdr.as_ref(),
261            params.operations.as_ref(),
262            params.source_account.as_ref(),
263            &self.network.passphrase,
264            &self.provider,
265        )
266        .await?;
267
268        // Run comprehensive security validation
269        StellarTransactionValidator::gasless_transaction_validation(
270            &envelope,
271            &self.relayer.address,
272            &policy,
273            &self.provider,
274            None, // Duration validation not needed for quote
275        )
276        .await
277        .map_err(|e| {
278            RelayerError::ValidationError(format!("Failed to validate gasless transaction: {e}"))
279        })?;
280
281        // Estimate fee using estimate_fee utility which handles simulation if needed
282        let inner_tx_fee = estimate_fee(&envelope, &self.provider, None)
283            .await
284            .map_err(crate::models::RelayerError::from)?;
285
286        // Add fees for fee payment operation (100 stroops) and fee-bump transaction (100 stroops)
287        let is_soroban = xdr_needs_simulation(&envelope).unwrap_or(false);
288        let additional_fees = if is_soroban {
289            0 // Soroban simulation already accounts for resource fees
290        } else {
291            2 * STELLAR_DEFAULT_TRANSACTION_FEE as u64 // 200 stroops total
292        };
293        let xlm_fee = inner_tx_fee + additional_fees;
294
295        // Convert to token amount via DEX service
296        let fee_quote = convert_xlm_fee_to_token(
297            self.dex_service.as_ref(),
298            &policy,
299            xlm_fee,
300            &params.fee_token,
301        )
302        .await
303        .map_err(crate::models::RelayerError::from)?;
304
305        // Validate max fee
306        StellarTransactionValidator::validate_max_fee(fee_quote.fee_in_stroops, &policy)
307            .map_err(|e| RelayerError::ValidationError(e.to_string()))?;
308
309        // Validate token-specific max fee
310        StellarTransactionValidator::validate_token_max_fee(
311            &params.fee_token,
312            fee_quote.fee_in_token,
313            &policy,
314        )
315        .map_err(|e| RelayerError::ValidationError(e.to_string()))?;
316
317        // Check user token balance to ensure they have enough to pay the fee
318        StellarTransactionValidator::validate_user_token_balance(
319            &envelope,
320            &params.fee_token,
321            fee_quote.fee_in_token,
322            &self.provider,
323        )
324        .await
325        .map_err(|e| RelayerError::ValidationError(e.to_string()))?;
326
327        debug!("Classic fee estimate result: {:?}", fee_quote);
328
329        Ok(SponsoredTransactionQuoteResponse::Stellar(
330            StellarFeeEstimateResult {
331                fee_in_token_ui: fee_quote.fee_in_token_ui,
332                fee_in_token: fee_quote.fee_in_token.to_string(),
333                conversion_rate: fee_quote.conversion_rate.to_string(),
334                // Classic transactions have deterministic fees, no slippage buffer needed
335                max_fee_in_token: None,
336                max_fee_in_token_ui: None,
337            },
338        ))
339    }
340
341    /// Build a classic sponsored transaction (fee-bump flow)
342    ///
343    /// Builds a complete transaction envelope with fee payment operation,
344    /// ready for user signature. The relayer will later wrap this in a fee-bump.
345    async fn build_classic_sponsored(
346        &self,
347        params: &crate::models::StellarPrepareTransactionRequestParams,
348    ) -> Result<SponsoredTransactionBuildResponse, RelayerError> {
349        debug!(
350            "Processing classic build sponsored transaction for token: {}",
351            params.fee_token
352        );
353
354        let policy = self.relayer.policies.get_stellar_policy();
355
356        // Validate allowed token
357        StellarTransactionValidator::validate_allowed_token(&params.fee_token, &policy)
358            .map_err(|e| RelayerError::ValidationError(e.to_string()))?;
359
360        // Validate that either transaction_xdr or operations is provided
361        if params.transaction_xdr.is_none() && params.operations.is_none() {
362            return Err(RelayerError::ValidationError(
363                "Must provide either transaction_xdr or operations in the request".to_string(),
364            ));
365        }
366
367        // Build envelope from XDR or operations
368        let envelope = build_envelope_from_request(
369            params.transaction_xdr.as_ref(),
370            params.operations.as_ref(),
371            params.source_account.as_ref(),
372            &self.network.passphrase,
373            &self.provider,
374        )
375        .await?;
376
377        // Run comprehensive security validation
378        StellarTransactionValidator::gasless_transaction_validation(
379            &envelope,
380            &self.relayer.address,
381            &policy,
382            &self.provider,
383            None, // Duration validation not needed here as time bounds are set during build
384        )
385        .await
386        .map_err(|e| {
387            RelayerError::ValidationError(format!("Failed to validate gasless transaction: {e}"))
388        })?;
389
390        // Estimate fee using estimate_fee utility which handles simulation if needed
391        let inner_tx_fee = estimate_fee(&envelope, &self.provider, None)
392            .await
393            .map_err(crate::models::RelayerError::from)?;
394
395        // Add fees for fee payment operation and fee-bump transaction
396        let is_soroban = xdr_needs_simulation(&envelope).unwrap_or(false);
397        let additional_fees = if is_soroban {
398            0
399        } else {
400            2 * STELLAR_DEFAULT_TRANSACTION_FEE as u64 // 200 stroops total
401        };
402        let xlm_fee = inner_tx_fee + additional_fees;
403
404        debug!(
405            inner_tx_fee = inner_tx_fee,
406            additional_fees = additional_fees,
407            total_fee = xlm_fee,
408            "Fee estimated: inner transaction + fee payment op + fee-bump"
409        );
410
411        // Calculate fee quote to check user balance before modifying envelope
412        let fee_quote = convert_xlm_fee_to_token(
413            self.dex_service.as_ref(),
414            &policy,
415            xlm_fee,
416            &params.fee_token,
417        )
418        .await
419        .map_err(crate::models::RelayerError::from)?;
420
421        // Validate max fee
422        StellarTransactionValidator::validate_max_fee(fee_quote.fee_in_stroops, &policy)
423            .map_err(|e| RelayerError::ValidationError(e.to_string()))?;
424
425        // Validate token-specific max fee
426        StellarTransactionValidator::validate_token_max_fee(
427            &params.fee_token,
428            fee_quote.fee_in_token,
429            &policy,
430        )
431        .map_err(|e| RelayerError::ValidationError(e.to_string()))?;
432
433        // Check user token balance to ensure they have enough to pay the fee
434        StellarTransactionValidator::validate_user_token_balance(
435            &envelope,
436            &params.fee_token,
437            fee_quote.fee_in_token,
438            &self.provider,
439        )
440        .await
441        .map_err(|e| RelayerError::ValidationError(e.to_string()))?;
442
443        // Add payment operation using the validated fee quote
444        let mut final_envelope = add_payment_operation_to_envelope(
445            envelope,
446            &fee_quote,
447            &params.fee_token,
448            &self.relayer.address,
449        )?;
450
451        debug!(
452            estimated_fee = xlm_fee,
453            final_fee_in_token = fee_quote.fee_in_token_ui,
454            "Classic transaction prepared successfully"
455        );
456
457        // Set final time bounds just before returning to give user maximum time to sign
458        let valid_until = Utc::now() + get_stellar_sponsored_transaction_validity_duration();
459        set_time_bounds(&mut final_envelope, valid_until)
460            .map_err(crate::models::RelayerError::from)?;
461
462        // Serialize final transaction
463        let extended_xdr = final_envelope
464            .to_xdr_base64(Limits::none())
465            .map_err(|e| RelayerError::Internal(format!("Failed to serialize XDR: {e}")))?;
466
467        Ok(SponsoredTransactionBuildResponse::Stellar(
468            StellarPrepareTransactionResult {
469                transaction: extended_xdr,
470                fee_in_token: fee_quote.fee_in_token.to_string(),
471                fee_in_token_ui: fee_quote.fee_in_token_ui,
472                fee_in_stroops: fee_quote.fee_in_stroops.to_string(),
473                fee_token: params.fee_token.clone(),
474                valid_until: valid_until.to_rfc3339(),
475                // Classic mode: no Soroban-specific fields
476                user_auth_entry: None,
477                // Classic transactions have deterministic fees, no slippage buffer needed
478                max_fee_in_token: None,
479                max_fee_in_token_ui: None,
480            },
481        ))
482    }
483}
484
485// ============================================================================
486// Soroban Gas Abstraction Handlers (FeeForwarder Flow with XDR-based detection)
487// ============================================================================
488
489impl<P, RR, NR, TR, J, TCS, S, D> StellarRelayer<P, RR, NR, TR, J, TCS, S, D>
490where
491    P: StellarProviderTrait + Send + Sync,
492    D: StellarDexServiceTrait + Send + Sync + 'static,
493    RR: Repository<RelayerRepoModel, String> + RelayerRepository + Send + Sync + 'static,
494    NR: NetworkRepository + Repository<NetworkRepoModel, String> + Send + Sync + 'static,
495    TR: Repository<TransactionRepoModel, String> + TransactionRepository + Send + Sync + 'static,
496    J: JobProducerTrait + Send + Sync + 'static,
497    TCS: TransactionCounterServiceTrait + Send + Sync + 'static,
498    S: StellarSignTrait + Send + Sync + 'static,
499{
500    /// Quote a Soroban sponsored transaction using FeeForwarder (XDR-based detection)
501    ///
502    /// Called when transaction_xdr contains an InvokeHostFunction operation.
503    /// Extracts contract call details from the XDR and estimates fee.
504    async fn quote_soroban_from_xdr(
505        &self,
506        params: &crate::models::StellarFeeEstimateRequestParams,
507        soroban_info: &SorobanInvokeInfo,
508    ) -> Result<SponsoredTransactionQuoteResponse, RelayerError> {
509        debug!(
510            "Processing Soroban quote request for token: {}, target: {}::{}",
511            params.fee_token, soroban_info.target_contract, soroban_info.target_fn
512        );
513
514        let policy = self.relayer.policies.get_stellar_policy();
515
516        // Validate fee_payment_strategy is User
517        if !policy.is_user_fee_payment() {
518            return Err(RelayerError::ValidationError(
519                "Gas abstraction requires fee_payment_strategy: User".to_string(),
520            ));
521        }
522
523        // Validate allowed token (same as classic flow)
524        StellarTransactionValidator::validate_allowed_token(&params.fee_token, &policy)
525            .map_err(|e| RelayerError::ValidationError(e.to_string()))?;
526
527        // Get fee_forwarder address: env var override takes precedence, otherwise use mainnet default
528        let fee_forwarder = crate::config::ServerConfig::resolve_stellar_fee_forwarder_address(
529            self.network.is_testnet(),
530        )
531        .ok_or_else(|| {
532            let env_var = if self.network.is_testnet() {
533                "STELLAR_TESTNET_FEE_FORWARDER_ADDRESS"
534            } else {
535                "STELLAR_MAINNET_FEE_FORWARDER_ADDRESS"
536            };
537            RelayerError::ValidationError(format!(
538                "FeeForwarder address not configured. Set {env_var} env var."
539            ))
540        })?;
541
542        // Validate fee_token is a valid Soroban contract address (C...)
543        if stellar_strkey::Contract::from_string(&params.fee_token).is_err() {
544            return Err(RelayerError::ValidationError(format!(
545                "fee_token must be a valid Soroban contract address (C...), got '{}'",
546                params.fee_token
547            )));
548        }
549
550        // Extract user_address from transaction_xdr source account (or use source_account if provided)
551        // For quote, we don't need the actual user_address, just validation that XDR is valid
552        // The user_address will be extracted in build phase when we have the XDR
553
554        let xdr = params.transaction_xdr.as_ref().ok_or_else(|| {
555            RelayerError::ValidationError(
556                "Soroban gas abstraction requires transaction_xdr".to_string(),
557            )
558        })?;
559
560        let source_envelope = TransactionEnvelope::from_xdr_base64(xdr, Limits::none())
561            .map_err(|e| RelayerError::ValidationError(format!("Invalid XDR: {e}")))?;
562        let user_address = extract_source_account(&source_envelope).map_err(|e| {
563            RelayerError::ValidationError(format!("Failed to extract source account: {e}"))
564        })?;
565
566        // Build FeeForwarder params with a placeholder fee (will simulate to get accurate fee)
567        let base_fee_stroops: u64 = STELLAR_DEFAULT_TRANSACTION_FEE as u64;
568        let base_fee_quote = convert_xlm_fee_to_token(
569            self.dex_service.as_ref(),
570            &policy,
571            base_fee_stroops,
572            &params.fee_token,
573        )
574        .await
575        .map_err(crate::models::RelayerError::from)?;
576
577        let validity_duration = get_stellar_sponsored_transaction_validity_duration();
578        let validity_seconds = validity_duration.num_seconds() as u64;
579        let expiration_ledger = get_expiration_ledger(&self.provider, validity_seconds)
580            .await
581            .map_err(|e| RelayerError::Internal(format!("Failed to get expiration ledger: {e}")))?;
582
583        let fee_params = FeeForwarderParams {
584            fee_token: params.fee_token.clone(),
585            fee_amount: base_fee_quote.fee_in_token as i128,
586            max_fee_amount: apply_max_fee_slippage(base_fee_quote.fee_in_token),
587            expiration_ledger,
588            target_contract: soroban_info.target_contract.clone(),
589            target_fn: soroban_info.target_fn.clone(),
590            target_args: soroban_info.target_args.clone(),
591            user: user_address,
592            relayer: self.relayer.address.clone(),
593        };
594
595        // For quote/simulation, we don't include auth entries because the FeeForwarder
596        // contract has custom auth verification that fails on empty signatures.
597        // Unlike standard Soroban "recording mode", this contract explicitly checks
598        // for valid signatures and returns Error when none are found.
599        // The build flow will include proper auth entries for accurate resource estimation.
600        let invoke_op = FeeForwarderService::<P>::build_invoke_operation_standalone(
601            &fee_forwarder,
602            &fee_params,
603            vec![],
604        )
605        .map_err(|e| RelayerError::Internal(format!("Failed to build invoke operation: {e}")))?;
606
607        let envelope = build_soroban_transaction_envelope(
608            &self.relayer.address,
609            invoke_op,
610            base_fee_stroops as u32,
611        )?;
612
613        let sim_response = self
614            .provider
615            .simulate_transaction_envelope(&envelope)
616            .await
617            .map_err(|e| RelayerError::Internal(format!("Failed to simulate transaction: {e}")))?;
618
619        let total_fee = calculate_total_soroban_fee(&sim_response, 1)?;
620
621        let fee_quote = convert_xlm_fee_to_token(
622            self.dex_service.as_ref(),
623            &policy,
624            total_fee as u64,
625            &params.fee_token,
626        )
627        .await
628        .map_err(crate::models::RelayerError::from)?;
629
630        debug!(
631            "Soroban fee estimate: {} stroops, {} token",
632            fee_quote.fee_in_stroops, fee_quote.fee_in_token
633        );
634
635        // Validate max fee
636        StellarTransactionValidator::validate_max_fee(fee_quote.fee_in_stroops, &policy)
637            .map_err(|e| RelayerError::ValidationError(e.to_string()))?;
638
639        // Validate token-specific max fee
640        StellarTransactionValidator::validate_token_max_fee(
641            &params.fee_token,
642            fee_quote.fee_in_token,
643            &policy,
644        )
645        .map_err(|e| RelayerError::ValidationError(e.to_string()))?;
646
647        // Check user token balance using the original source envelope (user as source)
648        StellarTransactionValidator::validate_user_token_balance(
649            &source_envelope,
650            &params.fee_token,
651            fee_quote.fee_in_token,
652            &self.provider,
653        )
654        .await
655        .map_err(|e| RelayerError::ValidationError(e.to_string()))?;
656
657        // Calculate max_fee with slippage buffer for Soroban
658        let max_fee_in_token = apply_max_fee_slippage(fee_quote.fee_in_token);
659        let token_decimals = policy
660            .get_allowed_token_decimals(&params.fee_token)
661            .unwrap_or(7);
662        let max_fee_in_token_ui = amount_to_ui_amount(max_fee_in_token as u64, token_decimals);
663
664        // Return using consolidated result struct
665        let result = StellarFeeEstimateResult {
666            fee_in_token_ui: fee_quote.fee_in_token_ui,
667            fee_in_token: fee_quote.fee_in_token.to_string(),
668            conversion_rate: fee_quote.conversion_rate.to_string(),
669            max_fee_in_token: Some(max_fee_in_token.to_string()),
670            max_fee_in_token_ui: Some(max_fee_in_token_ui),
671        };
672
673        Ok(SponsoredTransactionQuoteResponse::Stellar(result))
674    }
675
676    /// Build a Soroban sponsored transaction using FeeForwarder (XDR-based detection)
677    ///
678    /// Called when transaction_xdr contains an InvokeHostFunction operation.
679    /// Builds the FeeForwarder transaction wrapping the original contract call.
680    async fn build_soroban_sponsored(
681        &self,
682        params: &crate::models::StellarPrepareTransactionRequestParams,
683        soroban_info: &SorobanInvokeInfo,
684    ) -> Result<SponsoredTransactionBuildResponse, RelayerError> {
685        debug!(
686            "Processing Soroban build request for token: {}, target: {}::{}",
687            params.fee_token, soroban_info.target_contract, soroban_info.target_fn
688        );
689
690        let policy = self.relayer.policies.get_stellar_policy();
691
692        // Note: validate_allowed_token is already called in build_sponsored_transaction
693
694        // Get fee_forwarder address: env var override takes precedence, otherwise use mainnet default
695        let fee_forwarder = crate::config::ServerConfig::resolve_stellar_fee_forwarder_address(
696            self.network.is_testnet(),
697        )
698        .ok_or_else(|| {
699            let env_var = if self.network.is_testnet() {
700                "STELLAR_TESTNET_FEE_FORWARDER_ADDRESS"
701            } else {
702                "STELLAR_MAINNET_FEE_FORWARDER_ADDRESS"
703            };
704            RelayerError::ValidationError(format!(
705                "FeeForwarder address not configured. Set {env_var} env var."
706            ))
707        })?;
708
709        // Validate fee_token is a valid Soroban contract address (C...)
710        if stellar_strkey::Contract::from_string(&params.fee_token).is_err() {
711            return Err(RelayerError::ValidationError(format!(
712                "fee_token must be a valid Soroban contract address (C...), got '{}'",
713                params.fee_token
714            )));
715        }
716
717        // Extract user_address from transaction_xdr source account
718        // Soroban gas abstraction requires transaction_xdr, so we can unwrap here
719        let xdr = params.transaction_xdr.as_ref().ok_or_else(|| {
720            RelayerError::ValidationError(
721                "Soroban gas abstraction requires transaction_xdr".to_string(),
722            )
723        })?;
724
725        let source_envelope = TransactionEnvelope::from_xdr_base64(xdr, Limits::none())
726            .map_err(|e| RelayerError::ValidationError(format!("Invalid XDR: {e}")))?;
727        let user_address = extract_source_account(&source_envelope).map_err(|e| {
728            RelayerError::ValidationError(format!("Failed to extract source account: {e}"))
729        })?;
730
731        // Use default validity duration (same as classic sponsored transactions)
732        let validity_duration = get_stellar_sponsored_transaction_validity_duration();
733        let validity_seconds = validity_duration.num_seconds() as u64;
734        let expiration_ledger = get_expiration_ledger(&self.provider, validity_seconds)
735            .await
736            .map_err(|e| RelayerError::Internal(format!("Failed to get expiration ledger: {e}")))?;
737
738        // Build initial fee quote based on base fee, then simulate to get accurate Soroban fee
739        let base_fee_stroops: u64 = STELLAR_DEFAULT_TRANSACTION_FEE as u64;
740        let base_fee_quote = convert_xlm_fee_to_token(
741            self.dex_service.as_ref(),
742            &policy,
743            base_fee_stroops,
744            &params.fee_token,
745        )
746        .await
747        .map_err(crate::models::RelayerError::from)?;
748
749        // Build the FeeForwarder parameters using extracted Soroban info
750        let mut fee_params = FeeForwarderParams {
751            fee_token: params.fee_token.clone(),
752            fee_amount: base_fee_quote.fee_in_token as i128,
753            max_fee_amount: apply_max_fee_slippage(base_fee_quote.fee_in_token),
754            expiration_ledger,
755            target_contract: soroban_info.target_contract.clone(),
756            target_fn: soroban_info.target_fn.clone(),
757            target_args: soroban_info.target_args.clone(),
758            user: user_address.clone(),
759            relayer: self.relayer.address.clone(),
760        };
761
762        // For simulation, we don't include auth entries because the FeeForwarder
763        // contract has custom auth verification that fails on empty signatures.
764        // Unlike standard Soroban "recording mode", this contract explicitly checks
765        // for valid signatures and returns Error when none are found.
766        let invoke_op = FeeForwarderService::<P>::build_invoke_operation_standalone(
767            &fee_forwarder,
768            &fee_params,
769            vec![], // Empty auth entries for simulation
770        )
771        .map_err(|e| RelayerError::Internal(format!("Failed to build invoke operation: {e}")))?;
772
773        let envelope = build_soroban_transaction_envelope(
774            &self.relayer.address,
775            invoke_op,
776            base_fee_stroops as u32,
777        )?;
778
779        let sim_response = self
780            .provider
781            .simulate_transaction_envelope(&envelope)
782            .await
783            .map_err(|e| RelayerError::Internal(format!("Failed to simulate transaction: {e}")))?;
784
785        let total_fee = calculate_total_soroban_fee(&sim_response, 1)?;
786
787        let fee_quote = convert_xlm_fee_to_token(
788            self.dex_service.as_ref(),
789            &policy,
790            total_fee as u64,
791            &params.fee_token,
792        )
793        .await
794        .map_err(crate::models::RelayerError::from)?;
795
796        // Validate max fee
797        StellarTransactionValidator::validate_max_fee(fee_quote.fee_in_stroops, &policy)
798            .map_err(|e| RelayerError::ValidationError(e.to_string()))?;
799
800        // Validate token-specific max fee
801        StellarTransactionValidator::validate_token_max_fee(
802            &params.fee_token,
803            fee_quote.fee_in_token,
804            &policy,
805        )
806        .map_err(|e| RelayerError::ValidationError(e.to_string()))?;
807
808        // Check user token balance using the original source envelope (user as source)
809        StellarTransactionValidator::validate_user_token_balance(
810            &source_envelope,
811            &params.fee_token,
812            fee_quote.fee_in_token,
813            &self.provider,
814        )
815        .await
816        .map_err(|e| RelayerError::ValidationError(e.to_string()))?;
817
818        // Rebuild params with the final fee amounts
819        // Apply slippage buffer to max_fee_amount to allow for fee fluctuation
820        fee_params.fee_amount = fee_quote.fee_in_token as i128;
821        fee_params.max_fee_amount = apply_max_fee_slippage(fee_quote.fee_in_token);
822
823        // Build the user authorization entry for the user to sign
824        let user_auth_entry = FeeForwarderService::<P>::build_user_auth_entry_standalone(
825            &fee_forwarder,
826            &fee_params,
827            true,
828        )
829        .map_err(|e| RelayerError::Internal(format!("Failed to build user auth entry: {e}")))?;
830
831        let user_auth_xdr = FeeForwarderService::<P>::serialize_auth_entry(&user_auth_entry)
832            .map_err(|e| RelayerError::Internal(format!("Failed to serialize auth entry: {e}")))?;
833
834        // Build relayer auth entry - required by FeeForwarder contract
835        let relayer_auth_entry = FeeForwarderService::<P>::build_relayer_auth_entry_standalone(
836            &fee_forwarder,
837            &fee_params,
838        )
839        .map_err(|e| RelayerError::Internal(format!("Failed to build relayer auth entry: {e}")))?;
840
841        // Build the final invoke operation WITH auth entries
842        // Note: We don't simulate again because the contract's custom auth verification
843        // would fail on empty signatures. We use the simulation data from the first
844        // simulation (without auth entries) to set the fee and resources.
845        let invoke_op = FeeForwarderService::<P>::build_invoke_operation_standalone(
846            &fee_forwarder,
847            &fee_params,
848            vec![user_auth_entry, relayer_auth_entry],
849        )
850        .map_err(|e| RelayerError::Internal(format!("Failed to build invoke operation: {e}")))?;
851
852        let mut envelope = build_soroban_transaction_envelope(
853            &self.relayer.address,
854            invoke_op,
855            base_fee_stroops as u32,
856        )?;
857
858        // Apply simulation data from the first simulation (without auth entries)
859        // This sets the fee and Soroban resource data on the final envelope
860        // Also extends the footprint to include the relayer's account for require_auth
861        apply_simulation_to_soroban_envelope(&mut envelope, &sim_response, 1)?;
862
863        let transaction_xdr = envelope
864            .to_xdr_base64(Limits::none())
865            .map_err(|e| RelayerError::Internal(format!("Failed to serialize transaction: {e}")))?;
866
867        // Derive valid_until from expiration_ledger to ensure consistency
868        // Get current ledger to calculate time until expiration
869        let current_ledger =
870            self.provider.get_latest_ledger().await.map_err(|e| {
871                RelayerError::Internal(format!("Failed to get current ledger: {e}"))
872            })?;
873        let ledgers_until_expiration = expiration_ledger.saturating_sub(current_ledger.sequence);
874        let seconds_until_expiration =
875            ledgers_until_expiration as u64 * STELLAR_LEDGER_TIME_SECONDS;
876        let valid_until = Utc::now() + chrono::Duration::seconds(seconds_until_expiration as i64);
877
878        debug!(
879            "Soroban build complete: transaction_xdr length={}, auth_xdr length={}, expiration_ledger={}, valid_until={}",
880            transaction_xdr.len(),
881            user_auth_xdr.len(),
882            expiration_ledger,
883            valid_until.to_rfc3339()
884        );
885
886        // Calculate max_fee with slippage buffer for Soroban
887        let max_fee_in_token = fee_params.max_fee_amount;
888        let token_decimals = policy
889            .get_allowed_token_decimals(&params.fee_token)
890            .unwrap_or(7);
891        let max_fee_in_token_ui = amount_to_ui_amount(max_fee_in_token as u64, token_decimals);
892
893        // Return using consolidated result struct with Soroban-specific fields populated
894        let result = StellarPrepareTransactionResult {
895            transaction: transaction_xdr,
896            fee_in_token: fee_quote.fee_in_token.to_string(),
897            fee_in_token_ui: fee_quote.fee_in_token_ui,
898            fee_in_stroops: fee_quote.fee_in_stroops.to_string(),
899            fee_token: params.fee_token.clone(),
900            valid_until: valid_until.to_rfc3339(),
901            // Soroban-specific fields
902            user_auth_entry: Some(user_auth_xdr),
903            max_fee_in_token: Some(max_fee_in_token.to_string()),
904            max_fee_in_token_ui: Some(max_fee_in_token_ui),
905        };
906
907        Ok(SponsoredTransactionBuildResponse::Stellar(result))
908    }
909}
910
911/// Build a Soroban transaction envelope with the given operation
912fn build_soroban_transaction_envelope(
913    source_address: &str,
914    operation: Operation,
915    fee: u32,
916) -> Result<TransactionEnvelope, RelayerError> {
917    use soroban_rs::xdr::{
918        Memo, MuxedAccount, Preconditions, SequenceNumber, Transaction, TransactionExt,
919        TransactionV1Envelope, Uint256, VecM,
920    };
921
922    // Parse source address
923    let pk = stellar_strkey::ed25519::PublicKey::from_string(source_address)
924        .map_err(|e| RelayerError::ValidationError(format!("Invalid source address: {e}")))?;
925    let source = MuxedAccount::Ed25519(Uint256(pk.0));
926
927    // Build transaction with placeholder sequence (0) - will be updated at submit time
928    let tx = Transaction {
929        source_account: source,
930        fee,
931        seq_num: SequenceNumber(0),
932        cond: Preconditions::None,
933        memo: Memo::None,
934        operations: vec![operation].try_into().map_err(|_| {
935            RelayerError::Internal("Failed to create operations vector".to_string())
936        })?,
937        ext: TransactionExt::V0,
938    };
939
940    Ok(TransactionEnvelope::Tx(TransactionV1Envelope {
941        tx,
942        signatures: VecM::default(),
943    }))
944}
945
946/// Calculate total fee for a Soroban transaction from simulation response.
947fn calculate_total_soroban_fee(
948    sim_response: &soroban_rs::stellar_rpc_client::SimulateTransactionResponse,
949    operations_count: u64,
950) -> Result<u32, RelayerError> {
951    if let Some(err) = sim_response.error.clone() {
952        return Err(RelayerError::ValidationError(format!(
953            "Simulation failed: {err}"
954        )));
955    }
956
957    let inclusion_fee = operations_count * STELLAR_DEFAULT_TRANSACTION_FEE as u64;
958    let resource_fee = sim_response.min_resource_fee;
959    let total_fee = inclusion_fee + resource_fee;
960    let total_fee_u32 = u32::try_from(total_fee)
961        .map_err(|_| RelayerError::Internal("Soroban fee exceeds u32::MAX".to_string()))?;
962
963    Ok(total_fee_u32.max(STELLAR_DEFAULT_TRANSACTION_FEE))
964}
965
966/// Apply Soroban simulation data to a transaction envelope (fee + extension data).
967fn apply_simulation_to_soroban_envelope(
968    envelope: &mut TransactionEnvelope,
969    sim_response: &soroban_rs::stellar_rpc_client::SimulateTransactionResponse,
970    operations_count: u64,
971) -> Result<(), RelayerError> {
972    use soroban_rs::xdr::SorobanTransactionData;
973
974    let total_fee = calculate_total_soroban_fee(sim_response, operations_count)?;
975
976    let tx_data = SorobanTransactionData::from_xdr_base64(
977        sim_response.transaction_data.as_str(),
978        Limits::none(),
979    )
980    .map_err(|e| RelayerError::Internal(format!("Invalid transaction_data XDR: {e}")))?;
981
982    match envelope {
983        TransactionEnvelope::Tx(ref mut env) => {
984            env.tx.fee = total_fee;
985            env.tx.ext = soroban_rs::xdr::TransactionExt::V1(tx_data);
986        }
987        TransactionEnvelope::TxV0(_) | TransactionEnvelope::TxFeeBump(_) => {
988            return Err(RelayerError::Internal(
989                "Soroban transaction must be a V1 envelope".to_string(),
990            ));
991        }
992    }
993
994    Ok(())
995}
996
997/// Add payment operation to envelope using a pre-computed fee quote
998///
999/// This function adds a fee payment operation to the transaction envelope using
1000/// a pre-computed FeeQuote. This avoids duplicate DEX calls and ensures the
1001/// validated fee quote matches the fee amount in the payment operation.
1002///
1003/// Note: Time bounds should be set separately just before returning the transaction
1004/// to give the user maximum time to review and submit.
1005///
1006/// # Arguments
1007/// * `envelope` - The transaction envelope to add the payment operation to
1008/// * `fee_quote` - Pre-computed fee quote containing the token amount to charge
1009/// * `fee_token` - Asset identifier for the fee token
1010/// * `relayer_address` - Address of the relayer receiving the fee payment
1011///
1012/// # Returns
1013/// The updated envelope with the payment operation added (if not Soroban)
1014fn add_payment_operation_to_envelope(
1015    mut envelope: TransactionEnvelope,
1016    fee_quote: &FeeQuote,
1017    fee_token: &str,
1018    relayer_address: &str,
1019) -> Result<TransactionEnvelope, RelayerError> {
1020    // Convert fee amount to i64 for payment operation
1021    let fee_amount = i64::try_from(fee_quote.fee_in_token).map_err(|_| {
1022        RelayerError::Internal(
1023            "Fee amount too large for payment operation (exceeds i64::MAX)".to_string(),
1024        )
1025    })?;
1026
1027    let is_soroban = xdr_needs_simulation(&envelope).unwrap_or(false);
1028    // For Soroban we don't add the fee payment operation because of Soroban limitation to allow just single operation in the transaction
1029    if !is_soroban {
1030        // Add fee payment operation to envelope
1031        add_fee_payment_operation(&mut envelope, fee_token, fee_amount, relayer_address)?;
1032    }
1033
1034    Ok(envelope)
1035}
1036
1037/// Build a transaction envelope from either XDR or operations
1038///
1039/// This helper function is used by both quote and build methods to construct
1040/// a transaction envelope from either a pre-built XDR transaction or from
1041/// operations with a source account.
1042///
1043/// When building from operations, this function fetches the user's current
1044/// sequence number from the network to ensure the transaction can be properly
1045/// signed and submitted by the user.
1046async fn build_envelope_from_request<P>(
1047    transaction_xdr: Option<&String>,
1048    operations: Option<&Vec<OperationSpec>>,
1049    source_account: Option<&String>,
1050    network_passphrase: &str,
1051    provider: &P,
1052) -> Result<TransactionEnvelope, RelayerError>
1053where
1054    P: StellarProviderTrait + Send + Sync,
1055{
1056    if let Some(xdr) = transaction_xdr {
1057        parse_transaction_xdr(xdr, false)
1058            .map_err(|e| RelayerError::Internal(format!("Failed to parse XDR: {e}")))
1059    } else if let Some(ops) = operations {
1060        // Build envelope from operations
1061        let source_account = source_account.ok_or_else(|| {
1062            RelayerError::ValidationError(
1063                "source_account is required when providing operations".to_string(),
1064            )
1065        })?;
1066
1067        // Create StellarTransactionData from operations
1068        // Fetch the user's current sequence number from the network
1069        // This is required because the user will sign the transaction with their account
1070        let account_entry = provider.get_account(source_account).await.map_err(|e| {
1071            warn!(
1072                source_account = %source_account,
1073                error = %e,
1074                "get_account failed in build_envelope_from_request (called before transaction creation)"
1075            );
1076            // Note: We don't have relayer_id here, so we can't track the metric with relayer_id
1077            // This is called during gas abstraction operations before transaction creation
1078            RelayerError::Internal(format!(
1079                "Failed to fetch account sequence number for {source_account}: {e}",
1080            ))
1081        })?;
1082
1083        // Use the next sequence number (current + 1)
1084        let next_sequence = account_entry.seq_num.0 + 1;
1085
1086        let stellar_data = StellarTransactionData {
1087            source_account: source_account.clone(),
1088            fee: None,
1089            sequence_number: Some(next_sequence as i64),
1090            memo: None,
1091            valid_until: None,
1092            network_passphrase: network_passphrase.to_string(),
1093            signatures: vec![],
1094            hash: None,
1095            simulation_transaction_data: None,
1096            transaction_input: TransactionInput::Operations(ops.clone()),
1097            signed_envelope_xdr: None,
1098            transaction_result_xdr: None,
1099        };
1100
1101        // Build unsigned envelope from operations
1102        stellar_data.build_unsigned_envelope().map_err(|e| {
1103            RelayerError::Internal(format!("Failed to build envelope from operations: {e}"))
1104        })
1105    } else {
1106        Err(RelayerError::ValidationError(
1107            "Must provide either transaction_xdr or operations in the request".to_string(),
1108        ))
1109    }
1110}
1111
1112/// Add a fee payment operation to the transaction envelope
1113fn add_fee_payment_operation(
1114    envelope: &mut TransactionEnvelope,
1115    fee_token: &str,
1116    fee_amount: i64,
1117    relayer_address: &str,
1118) -> Result<(), RelayerError> {
1119    let payment_op_spec = create_fee_payment_operation(relayer_address, fee_token, fee_amount)
1120        .map_err(crate::models::RelayerError::from)?;
1121
1122    // Convert OperationSpec to XDR Operation
1123    let payment_op = Operation::try_from(payment_op_spec)
1124        .map_err(|e| RelayerError::Internal(format!("Failed to convert payment operation: {e}")))?;
1125
1126    // Add payment operation to transaction
1127    add_operation_to_envelope(envelope, payment_op).map_err(crate::models::RelayerError::from)?;
1128
1129    Ok(())
1130}
1131
1132#[cfg(test)]
1133mod tests {
1134    use super::*;
1135    use crate::domain::transaction::stellar::utils::parse_account_id;
1136    use crate::services::stellar_dex::AssetType;
1137    use crate::{
1138        config::{NetworkConfigCommon, StellarNetworkConfig},
1139        jobs::MockJobProducerTrait,
1140        models::{
1141            transaction::stellar::OperationSpec, AssetSpec, NetworkConfigData, NetworkRepoModel,
1142            NetworkType, RelayerNetworkPolicy, RelayerRepoModel, RelayerStellarPolicy, RpcConfig,
1143            SponsoredTransactionBuildRequest, SponsoredTransactionQuoteRequest,
1144        },
1145        repositories::{
1146            InMemoryNetworkRepository, MockRelayerRepository, MockTransactionRepository,
1147        },
1148        services::{
1149            provider::MockStellarProviderTrait, signer::MockStellarSignTrait,
1150            stellar_dex::MockStellarDexServiceTrait, MockTransactionCounterServiceTrait,
1151        },
1152    };
1153    use mockall::predicate::*;
1154    use serial_test::serial;
1155    use soroban_rs::stellar_rpc_client::GetLedgerEntriesResponse;
1156    use soroban_rs::stellar_rpc_client::LedgerEntryResult;
1157    use soroban_rs::xdr::{
1158        AccountEntry, AccountEntryExt, AccountId, AlphaNum4, AssetCode4, LedgerEntry,
1159        LedgerEntryData, LedgerEntryExt, LedgerKey, Limits, MuxedAccount, Operation, OperationBody,
1160        PaymentOp, Preconditions, PublicKey, SequenceNumber, String32, Thresholds, Transaction,
1161        TransactionEnvelope, TransactionExt, TransactionV1Envelope, TrustLineEntry,
1162        TrustLineEntryExt, Uint256, VecM, WriteXdr,
1163    };
1164    use std::future::ready;
1165    use std::sync::Arc;
1166    use stellar_strkey::ed25519::PublicKey as Ed25519PublicKey;
1167
1168    const TEST_PK: &str = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF";
1169    const TEST_NETWORK_PASSPHRASE: &str = "Test SDF Network ; September 2015";
1170    const USDC_ASSET: &str = "USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN";
1171
1172    /// Helper function to create a test transaction XDR
1173    fn create_test_transaction_xdr() -> String {
1174        // Use a different account than TEST_PK (relayer address) to avoid validation error
1175        let source_pk = Ed25519PublicKey::from_string(
1176            "GCZ54QGQCUZ6U5WJF4AG5JEZCUMYTS2F6JRLUS76XF2PQMEJ2E3JISI2",
1177        )
1178        .unwrap();
1179        let dest_pk = Ed25519PublicKey::from_string(
1180            "GCEZWKCA5VLDNRLN3RPRJMRZOX3Z6G5CHCGSNFHEYVXM3XOJMDS674JZ",
1181        )
1182        .unwrap();
1183
1184        let payment_op = PaymentOp {
1185            destination: MuxedAccount::Ed25519(Uint256(dest_pk.0)),
1186            asset: soroban_rs::xdr::Asset::Native,
1187            amount: 1000000,
1188        };
1189
1190        let operation = Operation {
1191            source_account: None,
1192            body: OperationBody::Payment(payment_op),
1193        };
1194
1195        let operations: VecM<Operation, 100> = vec![operation].try_into().unwrap();
1196
1197        let tx = Transaction {
1198            source_account: MuxedAccount::Ed25519(Uint256(source_pk.0)),
1199            fee: 100,
1200            seq_num: SequenceNumber(2), // Must be > account sequence (1)
1201            cond: Preconditions::None,
1202            memo: soroban_rs::xdr::Memo::None,
1203            operations,
1204            ext: TransactionExt::V0,
1205        };
1206
1207        let envelope = TransactionV1Envelope {
1208            tx,
1209            signatures: vec![].try_into().unwrap(),
1210        };
1211
1212        let tx_envelope = TransactionEnvelope::Tx(envelope);
1213        tx_envelope.to_xdr_base64(Limits::none()).unwrap()
1214    }
1215
1216    /// Helper function to create a test relayer with user fee payment strategy
1217    fn create_test_relayer_with_user_fee_strategy() -> RelayerRepoModel {
1218        let mut policy = RelayerStellarPolicy::default();
1219        policy.fee_payment_strategy = Some(crate::models::StellarFeePaymentStrategy::User);
1220        policy.allowed_tokens = Some(vec![crate::models::StellarAllowedTokensPolicy {
1221            asset: USDC_ASSET.to_string(),
1222            metadata: None,
1223            max_allowed_fee: None,
1224            swap_config: None,
1225        }]);
1226
1227        RelayerRepoModel {
1228            id: "test-relayer-id".to_string(),
1229            name: "Test Relayer".to_string(),
1230            network: "testnet".to_string(),
1231            paused: false,
1232            network_type: NetworkType::Stellar,
1233            signer_id: "signer-id".to_string(),
1234            policies: RelayerNetworkPolicy::Stellar(policy),
1235            address: TEST_PK.to_string(),
1236            notification_id: Some("notification-id".to_string()),
1237            system_disabled: false,
1238            custom_rpc_urls: None,
1239            ..Default::default()
1240        }
1241    }
1242
1243    /// Helper function to create a mock DEX service
1244    fn create_mock_dex_service() -> Arc<MockStellarDexServiceTrait> {
1245        let mut mock_dex = MockStellarDexServiceTrait::new();
1246        mock_dex
1247            .expect_supported_asset_types()
1248            .returning(|| std::collections::HashSet::from([AssetType::Native, AssetType::Classic]));
1249        Arc::new(mock_dex)
1250    }
1251
1252    /// Helper function to create a test network
1253    fn create_test_network() -> NetworkRepoModel {
1254        NetworkRepoModel {
1255            id: "stellar:testnet".to_string(),
1256            name: "testnet".to_string(),
1257            network_type: NetworkType::Stellar,
1258            config: NetworkConfigData::Stellar(StellarNetworkConfig {
1259                common: NetworkConfigCommon {
1260                    network: "testnet".to_string(),
1261                    from: None,
1262                    rpc_urls: Some(vec![RpcConfig::new(
1263                        "https://horizon-testnet.stellar.org".to_string(),
1264                    )]),
1265                    explorer_urls: None,
1266                    average_blocktime_ms: Some(5000),
1267                    is_testnet: Some(true),
1268                    tags: None,
1269                },
1270                passphrase: Some(TEST_NETWORK_PASSPHRASE.to_string()),
1271                horizon_url: Some("https://horizon-testnet.stellar.org".to_string()),
1272            }),
1273        }
1274    }
1275
1276    /// Helper function to create a mainnet test network (no default FeeForwarder)
1277    fn create_test_mainnet_network() -> NetworkRepoModel {
1278        NetworkRepoModel {
1279            id: "stellar:mainnet".to_string(),
1280            name: "mainnet".to_string(),
1281            network_type: NetworkType::Stellar,
1282            config: NetworkConfigData::Stellar(StellarNetworkConfig {
1283                common: NetworkConfigCommon {
1284                    network: "mainnet".to_string(),
1285                    from: None,
1286                    rpc_urls: Some(vec![RpcConfig::new(
1287                        "https://horizon.stellar.org".to_string(),
1288                    )]),
1289                    explorer_urls: None,
1290                    average_blocktime_ms: Some(5000),
1291                    is_testnet: Some(false),
1292                    tags: None,
1293                },
1294                passphrase: Some("Public Global Stellar Network ; September 2015".to_string()),
1295                horizon_url: Some("https://horizon.stellar.org".to_string()),
1296            }),
1297        }
1298    }
1299
1300    /// Helper function to create a Stellar relayer instance for testing
1301    async fn create_test_relayer_instance(
1302        relayer_model: RelayerRepoModel,
1303        provider: MockStellarProviderTrait,
1304        dex_service: Arc<MockStellarDexServiceTrait>,
1305    ) -> crate::domain::relayer::stellar::StellarRelayer<
1306        MockStellarProviderTrait,
1307        MockRelayerRepository,
1308        InMemoryNetworkRepository,
1309        MockTransactionRepository,
1310        MockJobProducerTrait,
1311        MockTransactionCounterServiceTrait,
1312        MockStellarSignTrait,
1313        MockStellarDexServiceTrait,
1314    > {
1315        let network_repository = Arc::new(InMemoryNetworkRepository::new());
1316        let test_network = create_test_network();
1317        network_repository.create(test_network).await.unwrap();
1318
1319        let relayer_repo = Arc::new(MockRelayerRepository::new());
1320        let tx_repo = Arc::new(MockTransactionRepository::new());
1321        let job_producer = Arc::new(MockJobProducerTrait::new());
1322        let counter = Arc::new(MockTransactionCounterServiceTrait::new());
1323        let signer = Arc::new(MockStellarSignTrait::new());
1324
1325        crate::domain::relayer::stellar::StellarRelayer::new(
1326            relayer_model,
1327            signer,
1328            provider,
1329            crate::domain::relayer::stellar::StellarRelayerDependencies::new(
1330                relayer_repo,
1331                network_repository,
1332                tx_repo,
1333                counter,
1334                job_producer,
1335            ),
1336            dex_service,
1337        )
1338        .await
1339        .unwrap()
1340    }
1341
1342    /// Helper function to create a Stellar relayer instance with custom network for testing
1343    async fn create_test_relayer_instance_with_network(
1344        relayer_model: RelayerRepoModel,
1345        provider: MockStellarProviderTrait,
1346        dex_service: Arc<MockStellarDexServiceTrait>,
1347        network: NetworkRepoModel,
1348    ) -> crate::domain::relayer::stellar::StellarRelayer<
1349        MockStellarProviderTrait,
1350        MockRelayerRepository,
1351        InMemoryNetworkRepository,
1352        MockTransactionRepository,
1353        MockJobProducerTrait,
1354        MockTransactionCounterServiceTrait,
1355        MockStellarSignTrait,
1356        MockStellarDexServiceTrait,
1357    > {
1358        let network_repository = Arc::new(InMemoryNetworkRepository::new());
1359        network_repository.create(network).await.unwrap();
1360
1361        let relayer_repo = Arc::new(MockRelayerRepository::new());
1362        let tx_repo = Arc::new(MockTransactionRepository::new());
1363        let job_producer = Arc::new(MockJobProducerTrait::new());
1364        let counter = Arc::new(MockTransactionCounterServiceTrait::new());
1365        let signer = Arc::new(MockStellarSignTrait::new());
1366
1367        crate::domain::relayer::stellar::StellarRelayer::new(
1368            relayer_model,
1369            signer,
1370            provider,
1371            crate::domain::relayer::stellar::StellarRelayerDependencies::new(
1372                relayer_repo,
1373                network_repository,
1374                tx_repo,
1375                counter,
1376                job_producer,
1377            ),
1378            dex_service,
1379        )
1380        .await
1381        .unwrap()
1382    }
1383
1384    #[tokio::test]
1385    async fn test_quote_sponsored_transaction_with_xdr() {
1386        let relayer_model = create_test_relayer_with_user_fee_strategy();
1387        let mut provider = MockStellarProviderTrait::new();
1388
1389        // Mock account for validation
1390        provider.expect_get_account().returning(|_| {
1391            Box::pin(ready(Ok(AccountEntry {
1392                account_id: AccountId(PublicKey::PublicKeyTypeEd25519(Uint256([0; 32]))),
1393                balance: 1000000000,
1394                seq_num: SequenceNumber(1),
1395                num_sub_entries: 0,
1396                inflation_dest: None,
1397                flags: 0,
1398                home_domain: String32::default(),
1399                thresholds: Thresholds([0; 4]),
1400                signers: VecM::default(),
1401                ext: AccountEntryExt::V0,
1402            })))
1403        });
1404
1405        // Mock get_ledger_entries for token balance validation
1406        // This mock extracts the account ID from the ledger key and returns a trustline with sufficient balance
1407        provider.expect_get_ledger_entries().returning(|keys| {
1408            // Extract account ID from the first ledger key (should be a Trustline key)
1409            let account_id = if let Some(LedgerKey::Trustline(trustline_key)) = keys.first() {
1410                trustline_key.account_id.clone()
1411            } else {
1412                // Fallback: try to parse TEST_PK
1413                parse_account_id(TEST_PK)
1414                    .unwrap_or(AccountId(PublicKey::PublicKeyTypeEd25519(Uint256([0; 32]))))
1415            };
1416
1417            let issuer_id =
1418                parse_account_id("GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN")
1419                    .unwrap_or(AccountId(PublicKey::PublicKeyTypeEd25519(Uint256([0; 32]))));
1420
1421            // Create a trustline entry with sufficient balance
1422            let trustline_entry = TrustLineEntry {
1423                account_id,
1424                asset: soroban_rs::xdr::TrustLineAsset::CreditAlphanum4(AlphaNum4 {
1425                    asset_code: AssetCode4(*b"USDC"),
1426                    issuer: issuer_id,
1427                }),
1428                balance: 10_000_000i64,
1429                limit: i64::MAX,
1430                flags: 0,
1431                ext: TrustLineEntryExt::V0,
1432            };
1433
1434            let ledger_entry = LedgerEntry {
1435                last_modified_ledger_seq: 0,
1436                data: LedgerEntryData::Trustline(trustline_entry),
1437                ext: LedgerEntryExt::V0,
1438            };
1439
1440            // Encode LedgerEntryData to XDR base64 (not the full LedgerEntry)
1441            let xdr = ledger_entry
1442                .data
1443                .to_xdr_base64(soroban_rs::xdr::Limits::none())
1444                .expect("Failed to encode trustline entry data to XDR");
1445
1446            Box::pin(ready(Ok(GetLedgerEntriesResponse {
1447                entries: Some(vec![LedgerEntryResult {
1448                    key: "test_key".to_string(),
1449                    xdr,
1450                    last_modified_ledger: 0u32,
1451                    live_until_ledger_seq_ledger_seq: None,
1452                }]),
1453                latest_ledger: 0,
1454            })))
1455        });
1456
1457        let mut dex_service = MockStellarDexServiceTrait::new();
1458        dex_service
1459            .expect_supported_asset_types()
1460            .returning(|| std::collections::HashSet::from([AssetType::Native, AssetType::Classic]));
1461
1462        // Mock get_xlm_to_token_quote for fee conversion (XLM -> token)
1463        dex_service
1464            .expect_get_xlm_to_token_quote()
1465            .returning(|_, _, _, _| {
1466                Box::pin(ready(Ok(
1467                    crate::services::stellar_dex::StellarQuoteResponse {
1468                        input_asset: "native".to_string(),
1469                        output_asset: USDC_ASSET.to_string(),
1470                        in_amount: 100000,
1471                        out_amount: 1500000,
1472                        price_impact_pct: 0.0,
1473                        slippage_bps: 100,
1474                        path: None,
1475                    },
1476                )))
1477            });
1478
1479        let dex_service = Arc::new(dex_service);
1480        let relayer = create_test_relayer_instance(relayer_model, provider, dex_service).await;
1481
1482        let transaction_xdr = create_test_transaction_xdr();
1483        let request = SponsoredTransactionQuoteRequest::Stellar(
1484            crate::models::StellarFeeEstimateRequestParams {
1485                transaction_xdr: Some(transaction_xdr),
1486                operations: None,
1487                source_account: None,
1488                fee_token: USDC_ASSET.to_string(),
1489            },
1490        );
1491
1492        let result = relayer.quote_sponsored_transaction(request).await;
1493        if let Err(e) = &result {
1494            eprintln!("Quote error: {e:?}");
1495        }
1496        assert!(result.is_ok());
1497
1498        if let SponsoredTransactionQuoteResponse::Stellar(quote) = result.unwrap() {
1499            assert_eq!(quote.fee_in_token, "1500000");
1500            assert!(!quote.fee_in_token_ui.is_empty());
1501            assert!(!quote.conversion_rate.is_empty());
1502        } else {
1503            panic!("Expected Stellar quote response");
1504        }
1505    }
1506
1507    #[tokio::test]
1508    async fn test_quote_sponsored_transaction_with_operations() {
1509        let relayer_model = create_test_relayer_with_user_fee_strategy();
1510        let mut provider = MockStellarProviderTrait::new();
1511
1512        provider.expect_get_account().returning(|_| {
1513            Box::pin(ready(Ok(AccountEntry {
1514                account_id: AccountId(PublicKey::PublicKeyTypeEd25519(Uint256([0; 32]))),
1515                balance: 1000000000,
1516                seq_num: SequenceNumber(-1),
1517                num_sub_entries: 0,
1518                inflation_dest: None,
1519                flags: 0,
1520                home_domain: String32::default(),
1521                thresholds: Thresholds([0; 4]),
1522                signers: VecM::default(),
1523                ext: AccountEntryExt::V0,
1524            })))
1525        });
1526
1527        // Mock get_ledger_entries for token balance validation
1528        // This mock extracts the account ID from the ledger key and returns a trustline with sufficient balance
1529        provider.expect_get_ledger_entries().returning(|keys| {
1530            // Extract account ID from the first ledger key (should be a Trustline key)
1531            let account_id = if let Some(LedgerKey::Trustline(trustline_key)) = keys.first() {
1532                trustline_key.account_id.clone()
1533            } else {
1534                // Fallback: use the source account from the test
1535                parse_account_id("GCZ54QGQCUZ6U5WJF4AG5JEZCUMYTS2F6JRLUS76XF2PQMEJ2E3JISI2")
1536                    .unwrap_or(AccountId(PublicKey::PublicKeyTypeEd25519(Uint256([0; 32]))))
1537            };
1538
1539            let issuer_id =
1540                parse_account_id("GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN")
1541                    .unwrap_or(AccountId(PublicKey::PublicKeyTypeEd25519(Uint256([0; 32]))));
1542
1543            // Create a trustline entry with sufficient balance
1544            let trustline_entry = TrustLineEntry {
1545                account_id,
1546                asset: soroban_rs::xdr::TrustLineAsset::CreditAlphanum4(AlphaNum4 {
1547                    asset_code: AssetCode4(*b"USDC"),
1548                    issuer: issuer_id,
1549                }),
1550                balance: 10_000_000i64,
1551                limit: i64::MAX,
1552                flags: 0,
1553                ext: TrustLineEntryExt::V0,
1554            };
1555
1556            let ledger_entry = LedgerEntry {
1557                last_modified_ledger_seq: 0,
1558                data: LedgerEntryData::Trustline(trustline_entry),
1559                ext: LedgerEntryExt::V0,
1560            };
1561
1562            // Encode LedgerEntryData to XDR base64 (not the full LedgerEntry)
1563            let xdr = ledger_entry
1564                .data
1565                .to_xdr_base64(soroban_rs::xdr::Limits::none())
1566                .expect("Failed to encode trustline entry data to XDR");
1567
1568            Box::pin(ready(Ok(
1569                soroban_rs::stellar_rpc_client::GetLedgerEntriesResponse {
1570                    entries: Some(vec![LedgerEntryResult {
1571                        key: "test_key".to_string(),
1572                        xdr,
1573                        last_modified_ledger: 0u32,
1574                        live_until_ledger_seq_ledger_seq: None,
1575                    }]),
1576                    latest_ledger: 0,
1577                },
1578            )))
1579        });
1580
1581        let mut dex_service = MockStellarDexServiceTrait::new();
1582        dex_service
1583            .expect_supported_asset_types()
1584            .returning(|| std::collections::HashSet::from([AssetType::Native, AssetType::Classic]));
1585
1586        // Mock get_xlm_to_token_quote for fee conversion (XLM -> token)
1587        dex_service
1588            .expect_get_xlm_to_token_quote()
1589            .returning(|_, _, _, _| {
1590                Box::pin(ready(Ok(
1591                    crate::services::stellar_dex::StellarQuoteResponse {
1592                        input_asset: "native".to_string(),
1593                        output_asset: USDC_ASSET.to_string(),
1594                        in_amount: 100000,
1595                        out_amount: 1500000,
1596                        price_impact_pct: 0.0,
1597                        slippage_bps: 100,
1598                        path: None,
1599                    },
1600                )))
1601            });
1602
1603        let dex_service = Arc::new(dex_service);
1604        let relayer = create_test_relayer_instance(relayer_model, provider, dex_service).await;
1605
1606        let operations = vec![OperationSpec::Payment {
1607            destination: TEST_PK.to_string(),
1608            amount: 1000000,
1609            asset: AssetSpec::Native,
1610        }];
1611
1612        let request = SponsoredTransactionQuoteRequest::Stellar(
1613            crate::models::StellarFeeEstimateRequestParams {
1614                transaction_xdr: None,
1615                operations: Some(operations),
1616                source_account: Some(
1617                    "GCZ54QGQCUZ6U5WJF4AG5JEZCUMYTS2F6JRLUS76XF2PQMEJ2E3JISI2".to_string(),
1618                ),
1619                fee_token: USDC_ASSET.to_string(),
1620            },
1621        );
1622
1623        let result = relayer.quote_sponsored_transaction(request).await;
1624        if let Err(e) = &result {
1625            eprintln!("Quote error: {e:?}");
1626        }
1627        assert!(result.is_ok());
1628    }
1629
1630    #[tokio::test]
1631    async fn test_quote_sponsored_transaction_invalid_token() {
1632        let relayer_model = create_test_relayer_with_user_fee_strategy();
1633        let provider = MockStellarProviderTrait::new();
1634        let dex_service = create_mock_dex_service();
1635        let relayer = create_test_relayer_instance(relayer_model, provider, dex_service).await;
1636
1637        let transaction_xdr = create_test_transaction_xdr();
1638        let request = SponsoredTransactionQuoteRequest::Stellar(
1639            crate::models::StellarFeeEstimateRequestParams {
1640                transaction_xdr: Some(transaction_xdr),
1641                operations: None,
1642                source_account: None,
1643                fee_token: "INVALID:TOKEN".to_string(),
1644            },
1645        );
1646
1647        let result = relayer.quote_sponsored_transaction(request).await;
1648        assert!(result.is_err());
1649        assert!(matches!(
1650            result.unwrap_err(),
1651            RelayerError::ValidationError(_)
1652        ));
1653    }
1654
1655    #[tokio::test]
1656    async fn test_quote_sponsored_transaction_missing_xdr_and_operations() {
1657        let relayer_model = create_test_relayer_with_user_fee_strategy();
1658        let provider = MockStellarProviderTrait::new();
1659        let dex_service = create_mock_dex_service();
1660        let relayer = create_test_relayer_instance(relayer_model, provider, dex_service).await;
1661
1662        let request = SponsoredTransactionQuoteRequest::Stellar(
1663            crate::models::StellarFeeEstimateRequestParams {
1664                transaction_xdr: None,
1665                operations: None,
1666                source_account: None,
1667                fee_token: USDC_ASSET.to_string(),
1668            },
1669        );
1670
1671        let result = relayer.quote_sponsored_transaction(request).await;
1672        assert!(result.is_err());
1673        assert!(matches!(
1674            result.unwrap_err(),
1675            RelayerError::ValidationError(_)
1676        ));
1677    }
1678
1679    #[tokio::test]
1680    async fn test_build_sponsored_transaction_with_xdr() {
1681        let relayer_model = create_test_relayer_with_user_fee_strategy();
1682        let mut provider = MockStellarProviderTrait::new();
1683
1684        provider.expect_get_account().returning(|_| {
1685            Box::pin(ready(Ok(AccountEntry {
1686                account_id: AccountId(PublicKey::PublicKeyTypeEd25519(Uint256([0; 32]))),
1687                balance: 1000000000,
1688                seq_num: SequenceNumber(-1),
1689                num_sub_entries: 0,
1690                inflation_dest: None,
1691                flags: 0,
1692                home_domain: String32::default(),
1693                thresholds: Thresholds([0; 4]),
1694                signers: VecM::default(),
1695                ext: AccountEntryExt::V0,
1696            })))
1697        });
1698
1699        // Mock get_ledger_entries for token balance validation
1700        // This mock extracts the account ID from the ledger key and returns a trustline with sufficient balance
1701        provider.expect_get_ledger_entries().returning(|keys| {
1702            // Extract account ID from the first ledger key (should be a Trustline key)
1703            let account_id = if let Some(LedgerKey::Trustline(trustline_key)) = keys.first() {
1704                trustline_key.account_id.clone()
1705            } else {
1706                // Fallback: try to parse TEST_PK
1707                parse_account_id(TEST_PK)
1708                    .unwrap_or(AccountId(PublicKey::PublicKeyTypeEd25519(Uint256([0; 32]))))
1709            };
1710
1711            let issuer_id =
1712                parse_account_id("GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN")
1713                    .unwrap_or(AccountId(PublicKey::PublicKeyTypeEd25519(Uint256([0; 32]))));
1714
1715            // Create a trustline entry with sufficient balance (10 USDC = 10000000 with 6 decimals)
1716            let trustline_entry = TrustLineEntry {
1717                account_id,
1718                asset: soroban_rs::xdr::TrustLineAsset::CreditAlphanum4(AlphaNum4 {
1719                    asset_code: AssetCode4(*b"USDC"),
1720                    issuer: issuer_id,
1721                }),
1722                balance: 10_000_000i64, // 10 USDC (with 6 decimals) - sufficient for fee
1723                limit: i64::MAX,
1724                flags: 0,
1725                ext: TrustLineEntryExt::V0, // V0 has no liabilities
1726            };
1727
1728            let ledger_entry = LedgerEntry {
1729                last_modified_ledger_seq: 0,
1730                data: LedgerEntryData::Trustline(trustline_entry),
1731                ext: LedgerEntryExt::V0,
1732            };
1733
1734            // Encode LedgerEntryData to XDR base64 (not the full LedgerEntry)
1735            let xdr = ledger_entry
1736                .data
1737                .to_xdr_base64(soroban_rs::xdr::Limits::none())
1738                .expect("Failed to encode trustline entry data to XDR");
1739
1740            Box::pin(ready(Ok(
1741                soroban_rs::stellar_rpc_client::GetLedgerEntriesResponse {
1742                    entries: Some(vec![LedgerEntryResult {
1743                        key: "test_key".to_string(),
1744                        xdr,
1745                        last_modified_ledger: 0u32,
1746                        live_until_ledger_seq_ledger_seq: None,
1747                    }]),
1748                    latest_ledger: 0,
1749                },
1750            )))
1751        });
1752
1753        let mut dex_service = MockStellarDexServiceTrait::new();
1754        dex_service
1755            .expect_supported_asset_types()
1756            .returning(|| std::collections::HashSet::from([AssetType::Native, AssetType::Classic]));
1757
1758        // Mock get_xlm_to_token_quote for build (converting XLM fee to token)
1759        dex_service
1760            .expect_get_xlm_to_token_quote()
1761            .returning(|_, _, _, _| {
1762                Box::pin(ready(Ok(
1763                    crate::services::stellar_dex::StellarQuoteResponse {
1764                        input_asset: "native".to_string(),
1765                        output_asset: USDC_ASSET.to_string(),
1766                        in_amount: 1000000,
1767                        out_amount: 1500000,
1768                        price_impact_pct: 0.0,
1769                        slippage_bps: 100,
1770                        path: None,
1771                    },
1772                )))
1773            });
1774
1775        let dex_service = Arc::new(dex_service);
1776        let relayer = create_test_relayer_instance(relayer_model, provider, dex_service).await;
1777
1778        let transaction_xdr = create_test_transaction_xdr();
1779        let request = SponsoredTransactionBuildRequest::Stellar(
1780            crate::models::StellarPrepareTransactionRequestParams {
1781                transaction_xdr: Some(transaction_xdr),
1782                operations: None,
1783                source_account: None,
1784                fee_token: USDC_ASSET.to_string(),
1785            },
1786        );
1787
1788        let result = relayer.build_sponsored_transaction(request).await;
1789        assert!(result.is_ok());
1790
1791        if let SponsoredTransactionBuildResponse::Stellar(build) = result.unwrap() {
1792            assert!(!build.transaction.is_empty());
1793            assert_eq!(build.fee_in_token, "1500000");
1794            assert!(!build.fee_in_token_ui.is_empty());
1795            assert_eq!(build.fee_token, USDC_ASSET);
1796            assert!(!build.valid_until.is_empty());
1797        } else {
1798            panic!("Expected Stellar build response");
1799        }
1800    }
1801
1802    #[tokio::test]
1803    async fn test_build_sponsored_transaction_with_operations() {
1804        let relayer_model = create_test_relayer_with_user_fee_strategy();
1805        let mut provider = MockStellarProviderTrait::new();
1806
1807        provider.expect_get_account().returning(|_| {
1808            Box::pin(ready(Ok(AccountEntry {
1809                account_id: AccountId(PublicKey::PublicKeyTypeEd25519(Uint256([0; 32]))),
1810                balance: 1000000000,
1811                seq_num: SequenceNumber(-1),
1812                num_sub_entries: 0,
1813                inflation_dest: None,
1814                flags: 0,
1815                home_domain: String32::default(),
1816                thresholds: Thresholds([0; 4]),
1817                signers: VecM::default(),
1818                ext: AccountEntryExt::V0,
1819            })))
1820        });
1821
1822        provider.expect_get_ledger_entries().returning(|_| {
1823            use crate::domain::transaction::stellar::utils::parse_account_id;
1824            use soroban_rs::stellar_rpc_client::LedgerEntryResult;
1825            use soroban_rs::xdr::{
1826                AccountId, AlphaNum4, AssetCode4, LedgerEntry, LedgerEntryData, LedgerEntryExt,
1827                PublicKey, TrustLineEntry, TrustLineEntryExt, Uint256, WriteXdr,
1828            };
1829
1830            // Parse account IDs - use the source account from the test
1831            let account_id =
1832                parse_account_id("GCZ54QGQCUZ6U5WJF4AG5JEZCUMYTS2F6JRLUS76XF2PQMEJ2E3JISI2")
1833                    .unwrap_or(AccountId(PublicKey::PublicKeyTypeEd25519(Uint256([0; 32]))));
1834            let issuer_id =
1835                parse_account_id("GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN")
1836                    .unwrap_or(AccountId(PublicKey::PublicKeyTypeEd25519(Uint256([0; 32]))));
1837
1838            // Create a trustline entry with sufficient balance (10 USDC = 10000000 with 6 decimals)
1839            // The fee is 1500000 (from the quote), so 10 USDC is more than enough
1840            let trustline_entry = TrustLineEntry {
1841                account_id,
1842                asset: soroban_rs::xdr::TrustLineAsset::CreditAlphanum4(AlphaNum4 {
1843                    asset_code: AssetCode4(*b"USDC"),
1844                    issuer: issuer_id,
1845                }),
1846                balance: 10_000_000i64,
1847                limit: i64::MAX,
1848                flags: 0,
1849                ext: TrustLineEntryExt::V0,
1850            };
1851
1852            let ledger_entry = LedgerEntry {
1853                last_modified_ledger_seq: 0,
1854                data: LedgerEntryData::Trustline(trustline_entry),
1855                ext: LedgerEntryExt::V0,
1856            };
1857
1858            // Encode LedgerEntryData to XDR base64 (not the full LedgerEntry)
1859            // The parse_ledger_entry_from_xdr function expects just the data portion
1860            let xdr = ledger_entry
1861                .data
1862                .to_xdr_base64(soroban_rs::xdr::Limits::none())
1863                .expect("Failed to encode trustline entry data to XDR");
1864
1865            Box::pin(ready(Ok(
1866                soroban_rs::stellar_rpc_client::GetLedgerEntriesResponse {
1867                    entries: Some(vec![LedgerEntryResult {
1868                        key: "test_key".to_string(),
1869                        xdr,
1870                        last_modified_ledger: 0u32,
1871                        live_until_ledger_seq_ledger_seq: None,
1872                    }]),
1873                    latest_ledger: 0,
1874                },
1875            )))
1876        });
1877
1878        let mut dex_service = MockStellarDexServiceTrait::new();
1879        dex_service
1880            .expect_supported_asset_types()
1881            .returning(|| std::collections::HashSet::from([AssetType::Native, AssetType::Classic]));
1882
1883        dex_service
1884            .expect_get_xlm_to_token_quote()
1885            .returning(|_, _, _, _| {
1886                Box::pin(ready(Ok(
1887                    crate::services::stellar_dex::StellarQuoteResponse {
1888                        input_asset: "native".to_string(),
1889                        output_asset: USDC_ASSET.to_string(),
1890                        in_amount: 1000000,
1891                        out_amount: 1500000,
1892                        price_impact_pct: 0.0,
1893                        slippage_bps: 100,
1894                        path: None,
1895                    },
1896                )))
1897            });
1898
1899        let dex_service = Arc::new(dex_service);
1900        let relayer = create_test_relayer_instance(relayer_model, provider, dex_service).await;
1901
1902        let operations = vec![OperationSpec::Payment {
1903            destination: TEST_PK.to_string(),
1904            amount: 1000000,
1905            asset: AssetSpec::Native,
1906        }];
1907
1908        let request = SponsoredTransactionBuildRequest::Stellar(
1909            crate::models::StellarPrepareTransactionRequestParams {
1910                transaction_xdr: None,
1911                operations: Some(operations),
1912                source_account: Some(
1913                    "GCZ54QGQCUZ6U5WJF4AG5JEZCUMYTS2F6JRLUS76XF2PQMEJ2E3JISI2".to_string(),
1914                ),
1915                fee_token: USDC_ASSET.to_string(),
1916            },
1917        );
1918
1919        let result = relayer.build_sponsored_transaction(request).await;
1920
1921        assert!(result.is_ok());
1922    }
1923
1924    #[tokio::test]
1925    async fn test_build_sponsored_transaction_missing_source_account() {
1926        let relayer_model = create_test_relayer_with_user_fee_strategy();
1927        let provider = MockStellarProviderTrait::new();
1928        let dex_service = create_mock_dex_service();
1929        let relayer = create_test_relayer_instance(relayer_model, provider, dex_service).await;
1930
1931        let operations = vec![OperationSpec::Payment {
1932            destination: TEST_PK.to_string(),
1933            amount: 1000000,
1934            asset: AssetSpec::Native,
1935        }];
1936
1937        let request = SponsoredTransactionBuildRequest::Stellar(
1938            crate::models::StellarPrepareTransactionRequestParams {
1939                transaction_xdr: None,
1940                operations: Some(operations),
1941                source_account: None,
1942                fee_token: USDC_ASSET.to_string(),
1943            },
1944        );
1945
1946        let result = relayer.build_sponsored_transaction(request).await;
1947        assert!(result.is_err());
1948        assert!(matches!(
1949            result.unwrap_err(),
1950            RelayerError::ValidationError(_)
1951        ));
1952    }
1953
1954    #[tokio::test]
1955    async fn test_build_envelope_from_request_with_xdr() {
1956        let provider = MockStellarProviderTrait::new();
1957        let transaction_xdr = create_test_transaction_xdr();
1958        let result = build_envelope_from_request(
1959            Some(&transaction_xdr),
1960            None,
1961            None,
1962            TEST_NETWORK_PASSPHRASE,
1963            &provider,
1964        )
1965        .await;
1966        assert!(result.is_ok());
1967    }
1968
1969    #[tokio::test]
1970    async fn test_build_envelope_from_request_with_operations() {
1971        let mut provider = MockStellarProviderTrait::new();
1972
1973        // Mock get_account to return a valid account with sequence number
1974        provider.expect_get_account().returning(|_| {
1975            Box::pin(ready(Ok(AccountEntry {
1976                account_id: AccountId(PublicKey::PublicKeyTypeEd25519(Uint256([0; 32]))),
1977                balance: 1000000000,
1978                seq_num: SequenceNumber(100),
1979                num_sub_entries: 0,
1980                inflation_dest: None,
1981                flags: 0,
1982                home_domain: String32::default(),
1983                thresholds: Thresholds([0; 4]),
1984                signers: VecM::default(),
1985                ext: AccountEntryExt::V0,
1986            })))
1987        });
1988
1989        let operations = vec![OperationSpec::Payment {
1990            destination: TEST_PK.to_string(),
1991            amount: 1000000,
1992            asset: AssetSpec::Native,
1993        }];
1994
1995        let result = build_envelope_from_request(
1996            None,
1997            Some(&operations),
1998            Some(&TEST_PK.to_string()),
1999            TEST_NETWORK_PASSPHRASE,
2000            &provider,
2001        )
2002        .await;
2003        assert!(result.is_ok());
2004
2005        // Verify the sequence number is set correctly (current + 1 = 101)
2006        if let Ok(envelope) = result {
2007            if let TransactionEnvelope::Tx(tx_env) = envelope {
2008                assert_eq!(tx_env.tx.seq_num.0, 101);
2009            }
2010        }
2011    }
2012
2013    #[tokio::test]
2014    async fn test_build_envelope_from_request_missing_source_account() {
2015        let provider = MockStellarProviderTrait::new();
2016        let operations = vec![OperationSpec::Payment {
2017            destination: TEST_PK.to_string(),
2018            amount: 1000000,
2019            asset: AssetSpec::Native,
2020        }];
2021
2022        let result = build_envelope_from_request(
2023            None,
2024            Some(&operations),
2025            None,
2026            TEST_NETWORK_PASSPHRASE,
2027            &provider,
2028        )
2029        .await;
2030        assert!(result.is_err());
2031        assert!(matches!(
2032            result.unwrap_err(),
2033            RelayerError::ValidationError(_)
2034        ));
2035    }
2036
2037    #[tokio::test]
2038    async fn test_build_envelope_from_request_missing_both() {
2039        let provider = MockStellarProviderTrait::new();
2040        let result =
2041            build_envelope_from_request(None, None, None, TEST_NETWORK_PASSPHRASE, &provider).await;
2042        assert!(result.is_err());
2043        assert!(matches!(
2044            result.unwrap_err(),
2045            RelayerError::ValidationError(_)
2046        ));
2047    }
2048
2049    #[tokio::test]
2050    async fn test_build_envelope_from_request_invalid_xdr() {
2051        let provider = MockStellarProviderTrait::new();
2052        let result = build_envelope_from_request(
2053            Some(&"INVALID_XDR".to_string()),
2054            None,
2055            None,
2056            TEST_NETWORK_PASSPHRASE,
2057            &provider,
2058        )
2059        .await;
2060        assert!(result.is_err());
2061    }
2062
2063    // ============================================================================
2064    // Tests for detect_soroban_invoke_from_xdr
2065    // ============================================================================
2066
2067    #[test]
2068    fn test_detect_soroban_invoke_from_xdr_classic_transaction() {
2069        // Classic payment transaction should return None
2070        let xdr = create_test_transaction_xdr();
2071        let result = detect_soroban_invoke_from_xdr(&xdr);
2072        assert!(result.is_ok());
2073        assert!(result.unwrap().is_none());
2074    }
2075
2076    #[test]
2077    fn test_detect_soroban_invoke_from_xdr_invalid_xdr() {
2078        let result = detect_soroban_invoke_from_xdr("INVALID_XDR");
2079        assert!(result.is_err());
2080        assert!(matches!(
2081            result.unwrap_err(),
2082            RelayerError::ValidationError(_)
2083        ));
2084    }
2085
2086    #[test]
2087    fn test_detect_soroban_invoke_from_xdr_with_soroban_transaction() {
2088        use soroban_rs::xdr::{
2089            ContractId, Hash, HostFunction, InvokeContractArgs, InvokeHostFunctionOp, Memo,
2090            MuxedAccount, Operation, OperationBody, Preconditions, ScAddress, ScSymbol, ScVal,
2091            SequenceNumber, Transaction, TransactionEnvelope, TransactionExt,
2092            TransactionV1Envelope, Uint256, VecM,
2093        };
2094
2095        // Create a Soroban InvokeHostFunction transaction
2096        let contract_id = ContractId(Hash([1u8; 32]));
2097        let invoke_args = InvokeContractArgs {
2098            contract_address: ScAddress::Contract(contract_id),
2099            function_name: ScSymbol("test_function".try_into().unwrap()),
2100            args: vec![ScVal::Bool(true)].try_into().unwrap(),
2101        };
2102
2103        let invoke_op = InvokeHostFunctionOp {
2104            host_function: HostFunction::InvokeContract(invoke_args),
2105            auth: VecM::default(),
2106        };
2107
2108        let operation = Operation {
2109            source_account: None,
2110            body: OperationBody::InvokeHostFunction(invoke_op),
2111        };
2112
2113        let source_pk = Ed25519PublicKey::from_string(
2114            "GCZ54QGQCUZ6U5WJF4AG5JEZCUMYTS2F6JRLUS76XF2PQMEJ2E3JISI2",
2115        )
2116        .unwrap();
2117
2118        let tx = Transaction {
2119            source_account: MuxedAccount::Ed25519(Uint256(source_pk.0)),
2120            fee: 100,
2121            seq_num: SequenceNumber(1),
2122            cond: Preconditions::None,
2123            memo: Memo::None,
2124            operations: vec![operation].try_into().unwrap(),
2125            ext: TransactionExt::V0,
2126        };
2127
2128        let envelope = TransactionEnvelope::Tx(TransactionV1Envelope {
2129            tx,
2130            signatures: VecM::default(),
2131        });
2132
2133        let xdr = envelope.to_xdr_base64(Limits::none()).unwrap();
2134        let result = detect_soroban_invoke_from_xdr(&xdr);
2135        assert!(result.is_ok());
2136
2137        let soroban_info = result.unwrap();
2138        assert!(soroban_info.is_some());
2139
2140        let info = soroban_info.unwrap();
2141        assert_eq!(info.target_fn, "test_function");
2142        assert_eq!(info.target_args.len(), 1);
2143        // Verify contract address format (C...)
2144        assert!(info.target_contract.starts_with('C'));
2145    }
2146
2147    #[test]
2148    fn test_detect_soroban_invoke_from_xdr_multiple_operations_error() {
2149        use soroban_rs::xdr::{
2150            ContractId, Hash, HostFunction, InvokeContractArgs, InvokeHostFunctionOp, Memo,
2151            MuxedAccount, Operation, OperationBody, PaymentOp, Preconditions, ScAddress, ScSymbol,
2152            SequenceNumber, Transaction, TransactionEnvelope, TransactionExt,
2153            TransactionV1Envelope, Uint256, VecM,
2154        };
2155
2156        // Create a transaction with InvokeHostFunction AND another operation (invalid for Soroban)
2157        let contract_id = ContractId(Hash([1u8; 32]));
2158        let invoke_args = InvokeContractArgs {
2159            contract_address: ScAddress::Contract(contract_id),
2160            function_name: ScSymbol("test".try_into().unwrap()),
2161            args: VecM::default(),
2162        };
2163
2164        let invoke_op = InvokeHostFunctionOp {
2165            host_function: HostFunction::InvokeContract(invoke_args),
2166            auth: VecM::default(),
2167        };
2168
2169        let source_pk = Ed25519PublicKey::from_string(
2170            "GCZ54QGQCUZ6U5WJF4AG5JEZCUMYTS2F6JRLUS76XF2PQMEJ2E3JISI2",
2171        )
2172        .unwrap();
2173        let dest_pk = Ed25519PublicKey::from_string(
2174            "GCEZWKCA5VLDNRLN3RPRJMRZOX3Z6G5CHCGSNFHEYVXM3XOJMDS674JZ",
2175        )
2176        .unwrap();
2177
2178        let payment_op = PaymentOp {
2179            destination: MuxedAccount::Ed25519(Uint256(dest_pk.0)),
2180            asset: soroban_rs::xdr::Asset::Native,
2181            amount: 1000000,
2182        };
2183
2184        let operations: VecM<Operation, 100> = vec![
2185            Operation {
2186                source_account: None,
2187                body: OperationBody::InvokeHostFunction(invoke_op),
2188            },
2189            Operation {
2190                source_account: None,
2191                body: OperationBody::Payment(payment_op),
2192            },
2193        ]
2194        .try_into()
2195        .unwrap();
2196
2197        let tx = Transaction {
2198            source_account: MuxedAccount::Ed25519(Uint256(source_pk.0)),
2199            fee: 100,
2200            seq_num: SequenceNumber(1),
2201            cond: Preconditions::None,
2202            memo: Memo::None,
2203            operations,
2204            ext: TransactionExt::V0,
2205        };
2206
2207        let envelope = TransactionEnvelope::Tx(TransactionV1Envelope {
2208            tx,
2209            signatures: VecM::default(),
2210        });
2211
2212        let xdr = envelope.to_xdr_base64(Limits::none()).unwrap();
2213        let result = detect_soroban_invoke_from_xdr(&xdr);
2214
2215        assert!(result.is_err());
2216        let err = result.unwrap_err();
2217        assert!(matches!(err, RelayerError::ValidationError(_)));
2218        if let RelayerError::ValidationError(msg) = err {
2219            assert!(msg.contains("exactly one operation"));
2220        }
2221    }
2222
2223    #[test]
2224    fn test_detect_soroban_invoke_from_xdr_v0_envelope() {
2225        use soroban_rs::xdr::{
2226            Memo, Operation, OperationBody, PaymentOp, SequenceNumber, TransactionEnvelope,
2227            TransactionV0, TransactionV0Envelope, TransactionV0Ext, Uint256, VecM,
2228        };
2229
2230        // Create a V0 envelope (legacy format)
2231        let source_pk = Ed25519PublicKey::from_string(
2232            "GCZ54QGQCUZ6U5WJF4AG5JEZCUMYTS2F6JRLUS76XF2PQMEJ2E3JISI2",
2233        )
2234        .unwrap();
2235        let dest_pk = Ed25519PublicKey::from_string(
2236            "GCEZWKCA5VLDNRLN3RPRJMRZOX3Z6G5CHCGSNFHEYVXM3XOJMDS674JZ",
2237        )
2238        .unwrap();
2239
2240        let payment_op = PaymentOp {
2241            destination: MuxedAccount::Ed25519(Uint256(dest_pk.0)),
2242            asset: soroban_rs::xdr::Asset::Native,
2243            amount: 1000000,
2244        };
2245
2246        let tx = TransactionV0 {
2247            source_account_ed25519: Uint256(source_pk.0),
2248            fee: 100,
2249            seq_num: SequenceNumber(1),
2250            time_bounds: None,
2251            memo: Memo::None,
2252            operations: vec![Operation {
2253                source_account: None,
2254                body: OperationBody::Payment(payment_op),
2255            }]
2256            .try_into()
2257            .unwrap(),
2258            ext: TransactionV0Ext::V0,
2259        };
2260
2261        let envelope = TransactionEnvelope::TxV0(TransactionV0Envelope {
2262            tx,
2263            signatures: VecM::default(),
2264        });
2265
2266        let xdr = envelope.to_xdr_base64(Limits::none()).unwrap();
2267        let result = detect_soroban_invoke_from_xdr(&xdr);
2268
2269        // V0 envelope with classic operation should return None
2270        assert!(result.is_ok());
2271        assert!(result.unwrap().is_none());
2272    }
2273
2274    #[test]
2275    fn test_detect_soroban_invoke_from_xdr_fee_bump_envelope() {
2276        use soroban_rs::xdr::{
2277            FeeBumpTransaction, FeeBumpTransactionEnvelope, FeeBumpTransactionExt,
2278            FeeBumpTransactionInnerTx, Memo, MuxedAccount, Operation, OperationBody, PaymentOp,
2279            Preconditions, SequenceNumber, Transaction, TransactionEnvelope, TransactionExt,
2280            TransactionV1Envelope, Uint256, VecM,
2281        };
2282
2283        let source_pk = Ed25519PublicKey::from_string(
2284            "GCZ54QGQCUZ6U5WJF4AG5JEZCUMYTS2F6JRLUS76XF2PQMEJ2E3JISI2",
2285        )
2286        .unwrap();
2287        let dest_pk = Ed25519PublicKey::from_string(
2288            "GCEZWKCA5VLDNRLN3RPRJMRZOX3Z6G5CHCGSNFHEYVXM3XOJMDS674JZ",
2289        )
2290        .unwrap();
2291
2292        let payment_op = PaymentOp {
2293            destination: MuxedAccount::Ed25519(Uint256(dest_pk.0)),
2294            asset: soroban_rs::xdr::Asset::Native,
2295            amount: 1000000,
2296        };
2297
2298        let inner_tx = Transaction {
2299            source_account: MuxedAccount::Ed25519(Uint256(source_pk.0)),
2300            fee: 100,
2301            seq_num: SequenceNumber(1),
2302            cond: Preconditions::None,
2303            memo: Memo::None,
2304            operations: vec![Operation {
2305                source_account: None,
2306                body: OperationBody::Payment(payment_op),
2307            }]
2308            .try_into()
2309            .unwrap(),
2310            ext: TransactionExt::V0,
2311        };
2312
2313        let inner_envelope = TransactionV1Envelope {
2314            tx: inner_tx,
2315            signatures: VecM::default(),
2316        };
2317
2318        let fee_bump_tx = FeeBumpTransaction {
2319            fee_source: MuxedAccount::Ed25519(Uint256(source_pk.0)),
2320            fee: 200,
2321            inner_tx: FeeBumpTransactionInnerTx::Tx(inner_envelope),
2322            ext: FeeBumpTransactionExt::V0,
2323        };
2324
2325        let envelope = TransactionEnvelope::TxFeeBump(FeeBumpTransactionEnvelope {
2326            tx: fee_bump_tx,
2327            signatures: VecM::default(),
2328        });
2329
2330        let xdr = envelope.to_xdr_base64(Limits::none()).unwrap();
2331        let result = detect_soroban_invoke_from_xdr(&xdr);
2332
2333        // Fee bump with classic operation should return None
2334        assert!(result.is_ok());
2335        assert!(result.unwrap().is_none());
2336    }
2337
2338    #[test]
2339    fn test_detect_soroban_invoke_non_contract_address_error() {
2340        use soroban_rs::xdr::{
2341            HostFunction, InvokeContractArgs, InvokeHostFunctionOp, Memo, MuxedAccount, Operation,
2342            OperationBody, Preconditions, ScAddress, ScSymbol, SequenceNumber, Transaction,
2343            TransactionEnvelope, TransactionExt, TransactionV1Envelope, Uint256, VecM,
2344        };
2345
2346        // Create a Soroban transaction with account address instead of contract address
2347        let source_pk = Ed25519PublicKey::from_string(
2348            "GCZ54QGQCUZ6U5WJF4AG5JEZCUMYTS2F6JRLUS76XF2PQMEJ2E3JISI2",
2349        )
2350        .unwrap();
2351
2352        let invoke_args = InvokeContractArgs {
2353            contract_address: ScAddress::Account(AccountId(PublicKey::PublicKeyTypeEd25519(
2354                Uint256(source_pk.0),
2355            ))),
2356            function_name: ScSymbol("test".try_into().unwrap()),
2357            args: VecM::default(),
2358        };
2359
2360        let invoke_op = InvokeHostFunctionOp {
2361            host_function: HostFunction::InvokeContract(invoke_args),
2362            auth: VecM::default(),
2363        };
2364
2365        let tx = Transaction {
2366            source_account: MuxedAccount::Ed25519(Uint256(source_pk.0)),
2367            fee: 100,
2368            seq_num: SequenceNumber(1),
2369            cond: Preconditions::None,
2370            memo: Memo::None,
2371            operations: vec![Operation {
2372                source_account: None,
2373                body: OperationBody::InvokeHostFunction(invoke_op),
2374            }]
2375            .try_into()
2376            .unwrap(),
2377            ext: TransactionExt::V0,
2378        };
2379
2380        let envelope = TransactionEnvelope::Tx(TransactionV1Envelope {
2381            tx,
2382            signatures: VecM::default(),
2383        });
2384
2385        let xdr = envelope.to_xdr_base64(Limits::none()).unwrap();
2386        let result = detect_soroban_invoke_from_xdr(&xdr);
2387
2388        assert!(result.is_err());
2389        let err = result.unwrap_err();
2390        assert!(matches!(err, RelayerError::ValidationError(_)));
2391        if let RelayerError::ValidationError(msg) = err {
2392            assert!(msg.contains("contract address"));
2393        }
2394    }
2395
2396    // ============================================================================
2397    // Tests for calculate_total_soroban_fee
2398    // ============================================================================
2399
2400    #[test]
2401    fn test_calculate_total_soroban_fee_success() {
2402        let sim_response = soroban_rs::stellar_rpc_client::SimulateTransactionResponse {
2403            error: None,
2404            transaction_data: "".to_string(),
2405            min_resource_fee: 50000,
2406            ..Default::default()
2407        };
2408
2409        let result = calculate_total_soroban_fee(&sim_response, 1);
2410        assert!(result.is_ok());
2411        // inclusion_fee (100) + resource_fee (50000) = 50100
2412        let fee = result.unwrap();
2413        assert_eq!(fee, 50100);
2414    }
2415
2416    #[test]
2417    fn test_calculate_total_soroban_fee_with_multiple_operations() {
2418        let sim_response = soroban_rs::stellar_rpc_client::SimulateTransactionResponse {
2419            error: None,
2420            transaction_data: "".to_string(),
2421            min_resource_fee: 50000,
2422            ..Default::default()
2423        };
2424
2425        let result = calculate_total_soroban_fee(&sim_response, 3);
2426        assert!(result.is_ok());
2427        // inclusion_fee (100 * 3) + resource_fee (50000) = 50300
2428        let fee = result.unwrap();
2429        assert_eq!(fee, 50300);
2430    }
2431
2432    #[test]
2433    fn test_calculate_total_soroban_fee_simulation_error() {
2434        let sim_response = soroban_rs::stellar_rpc_client::SimulateTransactionResponse {
2435            error: Some("Simulation failed: insufficient funds".to_string()),
2436            transaction_data: "".to_string(),
2437            min_resource_fee: 0,
2438            ..Default::default()
2439        };
2440
2441        let result = calculate_total_soroban_fee(&sim_response, 1);
2442        assert!(result.is_err());
2443        let err = result.unwrap_err();
2444        assert!(matches!(err, RelayerError::ValidationError(_)));
2445        if let RelayerError::ValidationError(msg) = err {
2446            assert!(msg.contains("Simulation failed"));
2447        }
2448    }
2449
2450    #[test]
2451    fn test_calculate_total_soroban_fee_minimum_fee() {
2452        // When calculated fee is less than minimum, should return minimum
2453        let sim_response = soroban_rs::stellar_rpc_client::SimulateTransactionResponse {
2454            error: None,
2455            transaction_data: "".to_string(),
2456            min_resource_fee: 0, // Very low resource fee
2457            ..Default::default()
2458        };
2459
2460        let result = calculate_total_soroban_fee(&sim_response, 1);
2461        assert!(result.is_ok());
2462        // Should be at least STELLAR_DEFAULT_TRANSACTION_FEE (100)
2463        let fee = result.unwrap();
2464        assert!(fee >= STELLAR_DEFAULT_TRANSACTION_FEE);
2465    }
2466
2467    // ============================================================================
2468    // Tests for build_soroban_transaction_envelope
2469    // ============================================================================
2470
2471    #[test]
2472    fn test_build_soroban_transaction_envelope_success() {
2473        use soroban_rs::xdr::{
2474            ContractId, Hash, HostFunction, InvokeContractArgs, InvokeHostFunctionOp, Operation,
2475            OperationBody, ScAddress, ScSymbol, VecM,
2476        };
2477
2478        let contract_id = ContractId(Hash([1u8; 32]));
2479        let invoke_args = InvokeContractArgs {
2480            contract_address: ScAddress::Contract(contract_id),
2481            function_name: ScSymbol("test".try_into().unwrap()),
2482            args: VecM::default(),
2483        };
2484
2485        let invoke_op = InvokeHostFunctionOp {
2486            host_function: HostFunction::InvokeContract(invoke_args),
2487            auth: VecM::default(),
2488        };
2489
2490        let operation = Operation {
2491            source_account: None,
2492            body: OperationBody::InvokeHostFunction(invoke_op),
2493        };
2494
2495        let result = build_soroban_transaction_envelope(TEST_PK, operation.clone(), 100);
2496        assert!(result.is_ok());
2497
2498        let envelope = result.unwrap();
2499        if let TransactionEnvelope::Tx(tx_env) = envelope {
2500            assert_eq!(tx_env.tx.fee, 100);
2501            assert_eq!(tx_env.tx.seq_num.0, 0); // Placeholder sequence
2502            assert_eq!(tx_env.tx.operations.len(), 1);
2503        } else {
2504            panic!("Expected Tx envelope");
2505        }
2506    }
2507
2508    #[test]
2509    fn test_build_soroban_transaction_envelope_invalid_source() {
2510        use soroban_rs::xdr::{
2511            ContractId, Hash, HostFunction, InvokeContractArgs, InvokeHostFunctionOp, Operation,
2512            OperationBody, ScAddress, ScSymbol, VecM,
2513        };
2514
2515        let contract_id = ContractId(Hash([1u8; 32]));
2516        let invoke_args = InvokeContractArgs {
2517            contract_address: ScAddress::Contract(contract_id),
2518            function_name: ScSymbol("test".try_into().unwrap()),
2519            args: VecM::default(),
2520        };
2521
2522        let invoke_op = InvokeHostFunctionOp {
2523            host_function: HostFunction::InvokeContract(invoke_args),
2524            auth: VecM::default(),
2525        };
2526
2527        let operation = Operation {
2528            source_account: None,
2529            body: OperationBody::InvokeHostFunction(invoke_op),
2530        };
2531
2532        let result = build_soroban_transaction_envelope("INVALID_ADDRESS", operation, 100);
2533        assert!(result.is_err());
2534        assert!(matches!(
2535            result.unwrap_err(),
2536            RelayerError::ValidationError(_)
2537        ));
2538    }
2539
2540    // ============================================================================
2541    // Tests for add_payment_operation_to_envelope
2542    // ============================================================================
2543
2544    #[test]
2545    fn test_add_payment_operation_to_envelope_classic() {
2546        let envelope = create_test_envelope_for_payment();
2547        let fee_quote = FeeQuote {
2548            fee_in_token: 1000000,
2549            fee_in_token_ui: "1.0".to_string(),
2550            fee_in_stroops: 10000,
2551            conversion_rate: 100.0,
2552        };
2553
2554        let result = add_payment_operation_to_envelope(envelope, &fee_quote, USDC_ASSET, TEST_PK);
2555        assert!(result.is_ok());
2556
2557        let updated_envelope = result.unwrap();
2558        // Classic transaction should have 2 operations now (original + payment)
2559        if let TransactionEnvelope::Tx(tx_env) = updated_envelope {
2560            assert_eq!(tx_env.tx.operations.len(), 2);
2561        }
2562    }
2563
2564    #[test]
2565    fn test_add_payment_operation_to_envelope_soroban_no_op_added() {
2566        use soroban_rs::xdr::{
2567            ContractId, Hash, HostFunction, InvokeContractArgs, InvokeHostFunctionOp, Memo,
2568            Operation, OperationBody, Preconditions, ScAddress, ScSymbol, SequenceNumber,
2569            Transaction, TransactionEnvelope, TransactionExt, TransactionV1Envelope, Uint256, VecM,
2570        };
2571
2572        // Create a Soroban transaction (InvokeHostFunction)
2573        let source_pk = Ed25519PublicKey::from_string(
2574            "GCZ54QGQCUZ6U5WJF4AG5JEZCUMYTS2F6JRLUS76XF2PQMEJ2E3JISI2",
2575        )
2576        .unwrap();
2577
2578        let contract_id = ContractId(Hash([1u8; 32]));
2579        let invoke_args = InvokeContractArgs {
2580            contract_address: ScAddress::Contract(contract_id),
2581            function_name: ScSymbol("test".try_into().unwrap()),
2582            args: VecM::default(),
2583        };
2584
2585        let invoke_op = InvokeHostFunctionOp {
2586            host_function: HostFunction::InvokeContract(invoke_args),
2587            auth: VecM::default(),
2588        };
2589
2590        let tx = Transaction {
2591            source_account: MuxedAccount::Ed25519(Uint256(source_pk.0)),
2592            fee: 100,
2593            seq_num: SequenceNumber(1),
2594            cond: Preconditions::None,
2595            memo: Memo::None,
2596            operations: vec![Operation {
2597                source_account: None,
2598                body: OperationBody::InvokeHostFunction(invoke_op),
2599            }]
2600            .try_into()
2601            .unwrap(),
2602            ext: TransactionExt::V0,
2603        };
2604
2605        let envelope = TransactionEnvelope::Tx(TransactionV1Envelope {
2606            tx,
2607            signatures: VecM::default(),
2608        });
2609
2610        let fee_quote = FeeQuote {
2611            fee_in_token: 1000000,
2612            fee_in_token_ui: "1.0".to_string(),
2613            fee_in_stroops: 10000,
2614            conversion_rate: 100.0,
2615        };
2616
2617        let result = add_payment_operation_to_envelope(envelope, &fee_quote, USDC_ASSET, TEST_PK);
2618        assert!(result.is_ok());
2619
2620        // Soroban transactions should NOT have payment operation added
2621        let updated_envelope = result.unwrap();
2622        if let TransactionEnvelope::Tx(tx_env) = updated_envelope {
2623            assert_eq!(tx_env.tx.operations.len(), 1); // Still only 1 operation
2624        }
2625    }
2626
2627    /// Helper to create a test envelope for payment tests
2628    fn create_test_envelope_for_payment() -> TransactionEnvelope {
2629        let source_pk = Ed25519PublicKey::from_string(
2630            "GCZ54QGQCUZ6U5WJF4AG5JEZCUMYTS2F6JRLUS76XF2PQMEJ2E3JISI2",
2631        )
2632        .unwrap();
2633        let dest_pk = Ed25519PublicKey::from_string(
2634            "GCEZWKCA5VLDNRLN3RPRJMRZOX3Z6G5CHCGSNFHEYVXM3XOJMDS674JZ",
2635        )
2636        .unwrap();
2637
2638        let payment_op = PaymentOp {
2639            destination: MuxedAccount::Ed25519(Uint256(dest_pk.0)),
2640            asset: soroban_rs::xdr::Asset::Native,
2641            amount: 1000000,
2642        };
2643
2644        let tx = Transaction {
2645            source_account: MuxedAccount::Ed25519(Uint256(source_pk.0)),
2646            fee: 100,
2647            seq_num: SequenceNumber(1),
2648            cond: Preconditions::None,
2649            memo: soroban_rs::xdr::Memo::None,
2650            operations: vec![Operation {
2651                source_account: None,
2652                body: OperationBody::Payment(payment_op),
2653            }]
2654            .try_into()
2655            .unwrap(),
2656            ext: TransactionExt::V0,
2657        };
2658
2659        TransactionEnvelope::Tx(TransactionV1Envelope {
2660            tx,
2661            signatures: VecM::default(),
2662        })
2663    }
2664
2665    // ============================================================================
2666    // Tests for add_fee_payment_operation
2667    // ============================================================================
2668
2669    #[test]
2670    fn test_add_fee_payment_operation_success() {
2671        let mut envelope = create_test_envelope_for_payment();
2672        let result = add_fee_payment_operation(&mut envelope, USDC_ASSET, 1000000, TEST_PK);
2673        assert!(result.is_ok());
2674
2675        // Verify operation was added
2676        if let TransactionEnvelope::Tx(tx_env) = envelope {
2677            assert_eq!(tx_env.tx.operations.len(), 2);
2678        }
2679    }
2680
2681    #[test]
2682    fn test_add_fee_payment_operation_native_asset() {
2683        let mut envelope = create_test_envelope_for_payment();
2684        let result = add_fee_payment_operation(&mut envelope, "native", 1000000, TEST_PK);
2685        assert!(result.is_ok());
2686    }
2687
2688    // ============================================================================
2689    // Tests for SorobanInvokeInfo
2690    // ============================================================================
2691
2692    #[test]
2693    fn test_soroban_invoke_info_debug_clone() {
2694        use soroban_rs::xdr::ScVal;
2695
2696        let info = SorobanInvokeInfo {
2697            target_contract: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC".to_string(),
2698            target_fn: "transfer".to_string(),
2699            target_args: vec![ScVal::Bool(true)],
2700        };
2701
2702        // Test Debug trait
2703        let debug_str = format!("{:?}", info);
2704        assert!(debug_str.contains("SorobanInvokeInfo"));
2705        assert!(debug_str.contains("transfer"));
2706
2707        // Test Clone trait
2708        let cloned = info.clone();
2709        assert_eq!(cloned.target_contract, info.target_contract);
2710        assert_eq!(cloned.target_fn, info.target_fn);
2711        assert_eq!(cloned.target_args.len(), info.target_args.len());
2712    }
2713
2714    // ============================================================================
2715    // Tests for fee payment strategy validation
2716    // ============================================================================
2717
2718    #[tokio::test]
2719    async fn test_build_sponsored_transaction_non_user_fee_strategy() {
2720        // Create relayer with Relayer fee payment strategy (not User)
2721        let mut policy = RelayerStellarPolicy::default();
2722        policy.fee_payment_strategy = Some(crate::models::StellarFeePaymentStrategy::Relayer);
2723        policy.allowed_tokens = Some(vec![crate::models::StellarAllowedTokensPolicy {
2724            asset: USDC_ASSET.to_string(),
2725            metadata: None,
2726            max_allowed_fee: None,
2727            swap_config: None,
2728        }]);
2729
2730        let relayer_model = RelayerRepoModel {
2731            id: "test-relayer-id".to_string(),
2732            name: "Test Relayer".to_string(),
2733            network: "testnet".to_string(),
2734            paused: false,
2735            network_type: NetworkType::Stellar,
2736            signer_id: "signer-id".to_string(),
2737            policies: RelayerNetworkPolicy::Stellar(policy),
2738            address: TEST_PK.to_string(),
2739            notification_id: Some("notification-id".to_string()),
2740            system_disabled: false,
2741            custom_rpc_urls: None,
2742            ..Default::default()
2743        };
2744
2745        let provider = MockStellarProviderTrait::new();
2746        let dex_service = create_mock_dex_service();
2747        let relayer = create_test_relayer_instance(relayer_model, provider, dex_service).await;
2748
2749        let transaction_xdr = create_test_transaction_xdr();
2750        let request = SponsoredTransactionBuildRequest::Stellar(
2751            crate::models::StellarPrepareTransactionRequestParams {
2752                transaction_xdr: Some(transaction_xdr),
2753                operations: None,
2754                source_account: None,
2755                fee_token: USDC_ASSET.to_string(),
2756            },
2757        );
2758
2759        let result = relayer.build_sponsored_transaction(request).await;
2760        assert!(result.is_err());
2761        let err = result.unwrap_err();
2762        assert!(matches!(err, RelayerError::ValidationError(_)));
2763        if let RelayerError::ValidationError(msg) = err {
2764            assert!(msg.contains("fee_payment_strategy: User"));
2765        }
2766    }
2767
2768    // ============================================================================
2769    // Tests for quote_soroban_from_xdr (via quote_sponsored_transaction)
2770    // ============================================================================
2771
2772    /// Helper function to create a valid SorobanTransactionData XDR for mocking simulation responses
2773    fn create_valid_soroban_transaction_data_xdr() -> String {
2774        use soroban_rs::xdr::{
2775            LedgerFootprint, SorobanResources, SorobanTransactionData, SorobanTransactionDataExt,
2776        };
2777
2778        let soroban_data = SorobanTransactionData {
2779            ext: SorobanTransactionDataExt::V0,
2780            resources: SorobanResources {
2781                footprint: LedgerFootprint {
2782                    read_only: VecM::default(),
2783                    read_write: VecM::default(),
2784                },
2785                instructions: 1000000,
2786                disk_read_bytes: 10000,
2787                write_bytes: 1000,
2788            },
2789            resource_fee: 50000,
2790        };
2791
2792        soroban_data.to_xdr_base64(Limits::none()).unwrap()
2793    }
2794
2795    /// Helper function to create a Soroban InvokeHostFunction transaction XDR
2796    fn create_test_soroban_transaction_xdr() -> String {
2797        use soroban_rs::xdr::{
2798            ContractId, Hash, HostFunction, InvokeContractArgs, InvokeHostFunctionOp, Memo,
2799            ScAddress, ScSymbol, ScVal,
2800        };
2801
2802        let source_pk = Ed25519PublicKey::from_string(
2803            "GCZ54QGQCUZ6U5WJF4AG5JEZCUMYTS2F6JRLUS76XF2PQMEJ2E3JISI2",
2804        )
2805        .unwrap();
2806
2807        // Create a Soroban contract call operation
2808        let contract_id = ContractId(Hash([1u8; 32]));
2809        let invoke_args = InvokeContractArgs {
2810            contract_address: ScAddress::Contract(contract_id),
2811            function_name: ScSymbol("transfer".try_into().unwrap()),
2812            args: vec![ScVal::Bool(true)].try_into().unwrap(),
2813        };
2814
2815        let invoke_op = InvokeHostFunctionOp {
2816            host_function: HostFunction::InvokeContract(invoke_args),
2817            auth: VecM::default(),
2818        };
2819
2820        let operation = Operation {
2821            source_account: None,
2822            body: OperationBody::InvokeHostFunction(invoke_op),
2823        };
2824
2825        let tx = Transaction {
2826            source_account: MuxedAccount::Ed25519(Uint256(source_pk.0)),
2827            fee: 100,
2828            seq_num: SequenceNumber(1),
2829            cond: Preconditions::None,
2830            memo: Memo::None,
2831            operations: vec![operation].try_into().unwrap(),
2832            ext: TransactionExt::V0,
2833        };
2834
2835        let envelope = TransactionEnvelope::Tx(TransactionV1Envelope {
2836            tx,
2837            signatures: VecM::default(),
2838        });
2839
2840        envelope.to_xdr_base64(Limits::none()).unwrap()
2841    }
2842
2843    /// Helper function to create a relayer with Soroban token support
2844    fn create_test_relayer_with_soroban_token() -> RelayerRepoModel {
2845        let mut policy = RelayerStellarPolicy::default();
2846        policy.fee_payment_strategy = Some(crate::models::StellarFeePaymentStrategy::User);
2847        // Use a Soroban contract address (C...) as the allowed token
2848        policy.allowed_tokens = Some(vec![crate::models::StellarAllowedTokensPolicy {
2849            asset: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC".to_string(),
2850            metadata: None,
2851            max_allowed_fee: None,
2852            swap_config: None,
2853        }]);
2854
2855        RelayerRepoModel {
2856            id: "test-relayer-id".to_string(),
2857            name: "Test Relayer".to_string(),
2858            network: "testnet".to_string(),
2859            paused: false,
2860            network_type: NetworkType::Stellar,
2861            signer_id: "signer-id".to_string(),
2862            policies: RelayerNetworkPolicy::Stellar(policy),
2863            address: TEST_PK.to_string(),
2864            notification_id: Some("notification-id".to_string()),
2865            system_disabled: false,
2866            custom_rpc_urls: None,
2867            ..Default::default()
2868        }
2869    }
2870
2871    #[tokio::test]
2872    #[serial]
2873    async fn test_quote_soroban_from_xdr_success() {
2874        // Set required env var for FeeForwarder (testnet network)
2875        std::env::set_var(
2876            "STELLAR_TESTNET_FEE_FORWARDER_ADDRESS",
2877            "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC",
2878        );
2879
2880        let relayer_model = create_test_relayer_with_soroban_token();
2881        let mut provider = MockStellarProviderTrait::new();
2882
2883        // Mock get_latest_ledger for expiration calculation
2884        provider.expect_get_latest_ledger().returning(|| {
2885            Box::pin(ready(Ok(
2886                soroban_rs::stellar_rpc_client::GetLatestLedgerResponse {
2887                    id: "test".to_string(),
2888                    protocol_version: 20,
2889                    sequence: 1000,
2890                },
2891            )))
2892        });
2893
2894        // Mock simulate_transaction_envelope for Soroban fee estimation
2895        provider
2896            .expect_simulate_transaction_envelope()
2897            .returning(|_| {
2898                Box::pin(ready(Ok(
2899                    soroban_rs::stellar_rpc_client::SimulateTransactionResponse {
2900                        min_resource_fee: 50000,
2901                        transaction_data: "AAAAAQAAAAAAAAACAAAAAAAAAAAAAAAAAAAABgAAAAEAAAAGAAAAAG0JZTO9fU6p3NeJp5w3TpKhZmx6p1pR7mq9wFwCnEIuAAAAFAAAAAEAAAAAAAAAB8NVb2IAAAH0AAAAAQAAAAAAABfAAAAAAAAAAPUAAAAAAAAENgAAAAA=".to_string(),
2902                        ..Default::default()
2903                    },
2904                )))
2905            });
2906
2907        // Mock call_contract for Soroban token balance check (balance function)
2908        provider.expect_call_contract().returning(|_, _, _| {
2909            use soroban_rs::xdr::Int128Parts;
2910            // Return a balance of 10_000_000 (10 tokens with 6 decimals)
2911            Box::pin(ready(Ok(ScVal::I128(Int128Parts {
2912                hi: 0,
2913                lo: 10_000_000,
2914            }))))
2915        });
2916
2917        let mut dex_service = MockStellarDexServiceTrait::new();
2918        dex_service.expect_supported_asset_types().returning(|| {
2919            std::collections::HashSet::from([AssetType::Native, AssetType::Contract])
2920        });
2921
2922        // Mock get_xlm_to_token_quote for fee conversion
2923        dex_service
2924            .expect_get_xlm_to_token_quote()
2925            .returning(|_, _, _, _| {
2926                Box::pin(ready(Ok(
2927                    crate::services::stellar_dex::StellarQuoteResponse {
2928                        input_asset: "native".to_string(),
2929                        output_asset: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC"
2930                            .to_string(),
2931                        in_amount: 50100,    // fee in stroops
2932                        out_amount: 1500000, // fee in token
2933                        price_impact_pct: 0.0,
2934                        slippage_bps: 100,
2935                        path: None,
2936                    },
2937                )))
2938            });
2939
2940        let dex_service = Arc::new(dex_service);
2941        let relayer = create_test_relayer_instance(relayer_model, provider, dex_service).await;
2942
2943        let transaction_xdr = create_test_soroban_transaction_xdr();
2944        let request = SponsoredTransactionQuoteRequest::Stellar(
2945            crate::models::StellarFeeEstimateRequestParams {
2946                transaction_xdr: Some(transaction_xdr),
2947                operations: None,
2948                source_account: None,
2949                fee_token: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC".to_string(),
2950            },
2951        );
2952
2953        let result = relayer.quote_sponsored_transaction(request).await;
2954        if let Err(e) = &result {
2955            eprintln!("Soroban quote error: {:?}", e);
2956        }
2957        assert!(result.is_ok());
2958
2959        if let SponsoredTransactionQuoteResponse::Stellar(quote) = result.unwrap() {
2960            assert_eq!(quote.fee_in_token, "1500000");
2961            assert!(!quote.fee_in_token_ui.is_empty());
2962            assert!(!quote.conversion_rate.is_empty());
2963        } else {
2964            panic!("Expected Stellar quote response");
2965        }
2966
2967        // Clean up env var
2968        std::env::remove_var("STELLAR_TESTNET_FEE_FORWARDER_ADDRESS");
2969    }
2970
2971    #[tokio::test]
2972    #[serial]
2973    async fn test_quote_soroban_from_xdr_missing_fee_forwarder() {
2974        // Ensure env var is NOT set
2975        std::env::remove_var("STELLAR_MAINNET_FEE_FORWARDER_ADDRESS");
2976
2977        // Use mainnet network where FeeForwarder is not deployed (empty default address)
2978        let mut relayer_model = create_test_relayer_with_soroban_token();
2979        relayer_model.network = "mainnet".to_string();
2980
2981        let provider = MockStellarProviderTrait::new();
2982
2983        let mut dex_service = MockStellarDexServiceTrait::new();
2984        dex_service.expect_supported_asset_types().returning(|| {
2985            std::collections::HashSet::from([AssetType::Native, AssetType::Contract])
2986        });
2987
2988        let dex_service = Arc::new(dex_service);
2989        let relayer = create_test_relayer_instance_with_network(
2990            relayer_model,
2991            provider,
2992            dex_service,
2993            create_test_mainnet_network(),
2994        )
2995        .await;
2996
2997        let transaction_xdr = create_test_soroban_transaction_xdr();
2998        let request = SponsoredTransactionQuoteRequest::Stellar(
2999            crate::models::StellarFeeEstimateRequestParams {
3000                transaction_xdr: Some(transaction_xdr),
3001                operations: None,
3002                source_account: None,
3003                fee_token: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC".to_string(),
3004            },
3005        );
3006
3007        let result = relayer.quote_sponsored_transaction(request).await;
3008        assert!(result.is_err());
3009        let err = result.unwrap_err();
3010        assert!(matches!(err, RelayerError::ValidationError(_)));
3011        if let RelayerError::ValidationError(msg) = err {
3012            assert!(msg.contains("STELLAR_MAINNET_FEE_FORWARDER_ADDRESS"));
3013        }
3014    }
3015
3016    #[tokio::test]
3017    #[serial]
3018    async fn test_quote_soroban_from_xdr_invalid_fee_token_format() {
3019        // Set required env var for FeeForwarder (testnet network)
3020        std::env::set_var(
3021            "STELLAR_TESTNET_FEE_FORWARDER_ADDRESS",
3022            "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC",
3023        );
3024
3025        // Create relayer that allows both classic and Soroban tokens
3026        let mut policy = RelayerStellarPolicy::default();
3027        policy.fee_payment_strategy = Some(crate::models::StellarFeePaymentStrategy::User);
3028        policy.allowed_tokens = Some(vec![
3029            crate::models::StellarAllowedTokensPolicy {
3030                asset: USDC_ASSET.to_string(), // Classic asset
3031                metadata: None,
3032                max_allowed_fee: None,
3033                swap_config: None,
3034            },
3035            crate::models::StellarAllowedTokensPolicy {
3036                asset: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC".to_string(),
3037                metadata: None,
3038                max_allowed_fee: None,
3039                swap_config: None,
3040            },
3041        ]);
3042
3043        let relayer_model = RelayerRepoModel {
3044            id: "test-relayer-id".to_string(),
3045            name: "Test Relayer".to_string(),
3046            network: "testnet".to_string(),
3047            paused: false,
3048            network_type: NetworkType::Stellar,
3049            signer_id: "signer-id".to_string(),
3050            policies: RelayerNetworkPolicy::Stellar(policy),
3051            address: TEST_PK.to_string(),
3052            notification_id: Some("notification-id".to_string()),
3053            system_disabled: false,
3054            custom_rpc_urls: None,
3055            ..Default::default()
3056        };
3057
3058        let provider = MockStellarProviderTrait::new();
3059
3060        let mut dex_service = MockStellarDexServiceTrait::new();
3061        dex_service
3062            .expect_supported_asset_types()
3063            .returning(|| std::collections::HashSet::from([AssetType::Native, AssetType::Classic]));
3064
3065        let dex_service = Arc::new(dex_service);
3066        let relayer = create_test_relayer_instance(relayer_model, provider, dex_service).await;
3067
3068        // Use Soroban XDR but with classic asset as fee_token (invalid for Soroban path)
3069        let transaction_xdr = create_test_soroban_transaction_xdr();
3070        let request = SponsoredTransactionQuoteRequest::Stellar(
3071            crate::models::StellarFeeEstimateRequestParams {
3072                transaction_xdr: Some(transaction_xdr),
3073                operations: None,
3074                source_account: None,
3075                fee_token: USDC_ASSET.to_string(), // Classic asset, not valid C... format
3076            },
3077        );
3078
3079        let result = relayer.quote_sponsored_transaction(request).await;
3080        assert!(result.is_err());
3081        let err = result.unwrap_err();
3082        assert!(matches!(err, RelayerError::ValidationError(_)));
3083        if let RelayerError::ValidationError(msg) = err {
3084            assert!(msg.contains("Soroban contract address"));
3085        }
3086
3087        // Clean up env var
3088        std::env::remove_var("STELLAR_TESTNET_FEE_FORWARDER_ADDRESS");
3089    }
3090
3091    // ============================================================================
3092    // Tests for build_soroban_sponsored (via build_sponsored_transaction)
3093    // ============================================================================
3094
3095    #[tokio::test]
3096    #[serial]
3097    async fn test_build_soroban_sponsored_success() {
3098        // Set required env var for FeeForwarder (testnet network)
3099        std::env::set_var(
3100            "STELLAR_TESTNET_FEE_FORWARDER_ADDRESS",
3101            "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC",
3102        );
3103
3104        let relayer_model = create_test_relayer_with_soroban_token();
3105        let mut provider = MockStellarProviderTrait::new();
3106
3107        // Mock get_latest_ledger for expiration calculation (called twice - for simulation and for valid_until)
3108        provider.expect_get_latest_ledger().returning(|| {
3109            Box::pin(ready(Ok(
3110                soroban_rs::stellar_rpc_client::GetLatestLedgerResponse {
3111                    id: "test".to_string(),
3112                    protocol_version: 20,
3113                    sequence: 1000,
3114                },
3115            )))
3116        });
3117
3118        // Mock simulate_transaction_envelope for Soroban fee estimation
3119        let valid_tx_data = create_valid_soroban_transaction_data_xdr();
3120        provider
3121            .expect_simulate_transaction_envelope()
3122            .returning(move |_| {
3123                let tx_data = valid_tx_data.clone();
3124                Box::pin(ready(Ok(
3125                    soroban_rs::stellar_rpc_client::SimulateTransactionResponse {
3126                        min_resource_fee: 50000,
3127                        transaction_data: tx_data,
3128                        ..Default::default()
3129                    },
3130                )))
3131            });
3132
3133        // Mock call_contract for Soroban token balance check
3134        provider.expect_call_contract().returning(|_, _, _| {
3135            use soroban_rs::xdr::Int128Parts;
3136            // Return a balance of 10_000_000 (sufficient for fee)
3137            Box::pin(ready(Ok(ScVal::I128(Int128Parts {
3138                hi: 0,
3139                lo: 10_000_000,
3140            }))))
3141        });
3142
3143        let mut dex_service = MockStellarDexServiceTrait::new();
3144        dex_service.expect_supported_asset_types().returning(|| {
3145            std::collections::HashSet::from([AssetType::Native, AssetType::Contract])
3146        });
3147
3148        // Mock get_xlm_to_token_quote for fee conversion
3149        dex_service
3150            .expect_get_xlm_to_token_quote()
3151            .returning(|_, _, _, _| {
3152                Box::pin(ready(Ok(
3153                    crate::services::stellar_dex::StellarQuoteResponse {
3154                        input_asset: "native".to_string(),
3155                        output_asset: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC"
3156                            .to_string(),
3157                        in_amount: 50100,
3158                        out_amount: 1500000,
3159                        price_impact_pct: 0.0,
3160                        slippage_bps: 100,
3161                        path: None,
3162                    },
3163                )))
3164            });
3165
3166        let dex_service = Arc::new(dex_service);
3167        let relayer = create_test_relayer_instance(relayer_model, provider, dex_service).await;
3168
3169        let transaction_xdr = create_test_soroban_transaction_xdr();
3170        let request = SponsoredTransactionBuildRequest::Stellar(
3171            crate::models::StellarPrepareTransactionRequestParams {
3172                transaction_xdr: Some(transaction_xdr),
3173                operations: None,
3174                source_account: None,
3175                fee_token: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC".to_string(),
3176            },
3177        );
3178
3179        let result = relayer.build_sponsored_transaction(request).await;
3180        if let Err(e) = &result {
3181            eprintln!("Soroban build error: {:?}", e);
3182        }
3183        assert!(result.is_ok());
3184
3185        if let SponsoredTransactionBuildResponse::Stellar(build) = result.unwrap() {
3186            assert!(!build.transaction.is_empty());
3187            assert_eq!(build.fee_in_token, "1500000");
3188            assert!(!build.fee_in_token_ui.is_empty());
3189            assert_eq!(
3190                build.fee_token,
3191                "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC"
3192            );
3193            assert!(!build.valid_until.is_empty());
3194            // Soroban transactions should have user_auth_entry
3195            assert!(build.user_auth_entry.is_some());
3196            assert!(!build.user_auth_entry.unwrap().is_empty());
3197        } else {
3198            panic!("Expected Stellar build response");
3199        }
3200
3201        // Clean up env var
3202        std::env::remove_var("STELLAR_TESTNET_FEE_FORWARDER_ADDRESS");
3203    }
3204
3205    #[tokio::test]
3206    #[serial]
3207    async fn test_build_soroban_sponsored_missing_fee_forwarder() {
3208        // Ensure env var is NOT set
3209        std::env::remove_var("STELLAR_MAINNET_FEE_FORWARDER_ADDRESS");
3210
3211        // Use mainnet network where FeeForwarder is not deployed (empty default address)
3212        let mut relayer_model = create_test_relayer_with_soroban_token();
3213        relayer_model.network = "mainnet".to_string();
3214
3215        let provider = MockStellarProviderTrait::new();
3216
3217        let mut dex_service = MockStellarDexServiceTrait::new();
3218        dex_service.expect_supported_asset_types().returning(|| {
3219            std::collections::HashSet::from([AssetType::Native, AssetType::Contract])
3220        });
3221
3222        let dex_service = Arc::new(dex_service);
3223        let relayer = create_test_relayer_instance_with_network(
3224            relayer_model,
3225            provider,
3226            dex_service,
3227            create_test_mainnet_network(),
3228        )
3229        .await;
3230
3231        let transaction_xdr = create_test_soroban_transaction_xdr();
3232        let request = SponsoredTransactionBuildRequest::Stellar(
3233            crate::models::StellarPrepareTransactionRequestParams {
3234                transaction_xdr: Some(transaction_xdr),
3235                operations: None,
3236                source_account: None,
3237                fee_token: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC".to_string(),
3238            },
3239        );
3240
3241        let result = relayer.build_sponsored_transaction(request).await;
3242        assert!(result.is_err());
3243        let err = result.unwrap_err();
3244        assert!(matches!(err, RelayerError::ValidationError(_)));
3245        if let RelayerError::ValidationError(msg) = err {
3246            assert!(msg.contains("STELLAR_MAINNET_FEE_FORWARDER_ADDRESS"));
3247        }
3248    }
3249
3250    #[tokio::test]
3251    #[serial]
3252    async fn test_build_soroban_sponsored_insufficient_balance() {
3253        // Set required env var for FeeForwarder (testnet network)
3254        std::env::set_var(
3255            "STELLAR_TESTNET_FEE_FORWARDER_ADDRESS",
3256            "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC",
3257        );
3258
3259        let relayer_model = create_test_relayer_with_soroban_token();
3260        let mut provider = MockStellarProviderTrait::new();
3261
3262        // Mock get_latest_ledger
3263        provider.expect_get_latest_ledger().returning(|| {
3264            Box::pin(ready(Ok(
3265                soroban_rs::stellar_rpc_client::GetLatestLedgerResponse {
3266                    id: "test".to_string(),
3267                    protocol_version: 20,
3268                    sequence: 1000,
3269                },
3270            )))
3271        });
3272
3273        // Mock simulate_transaction_envelope
3274        provider
3275            .expect_simulate_transaction_envelope()
3276            .returning(|_| {
3277                Box::pin(ready(Ok(
3278                    soroban_rs::stellar_rpc_client::SimulateTransactionResponse {
3279                        min_resource_fee: 50000,
3280                        transaction_data: "AAAAAQAAAAAAAAACAAAAAAAAAAAAAAAAAAAABgAAAAEAAAAGAAAAAG0JZTO9fU6p3NeJp5w3TpKhZmx6p1pR7mq9wFwCnEIuAAAAFAAAAAEAAAAAAAAAB8NVb2IAAAH0AAAAAQAAAAAAABfAAAAAAAAAAPUAAAAAAAAENgAAAAA=".to_string(),
3281                        ..Default::default()
3282                    },
3283                )))
3284            });
3285
3286        // Mock call_contract with INSUFFICIENT balance
3287        provider.expect_call_contract().returning(|_, _, _| {
3288            use soroban_rs::xdr::Int128Parts;
3289            // Return a very low balance (100, much less than required 1500000)
3290            Box::pin(ready(Ok(ScVal::I128(Int128Parts { hi: 0, lo: 100 }))))
3291        });
3292
3293        let mut dex_service = MockStellarDexServiceTrait::new();
3294        dex_service.expect_supported_asset_types().returning(|| {
3295            std::collections::HashSet::from([AssetType::Native, AssetType::Contract])
3296        });
3297
3298        // Mock get_xlm_to_token_quote
3299        dex_service
3300            .expect_get_xlm_to_token_quote()
3301            .returning(|_, _, _, _| {
3302                Box::pin(ready(Ok(
3303                    crate::services::stellar_dex::StellarQuoteResponse {
3304                        input_asset: "native".to_string(),
3305                        output_asset: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC"
3306                            .to_string(),
3307                        in_amount: 50100,
3308                        out_amount: 1500000, // Fee required
3309                        price_impact_pct: 0.0,
3310                        slippage_bps: 100,
3311                        path: None,
3312                    },
3313                )))
3314            });
3315
3316        let dex_service = Arc::new(dex_service);
3317        let relayer = create_test_relayer_instance(relayer_model, provider, dex_service).await;
3318
3319        let transaction_xdr = create_test_soroban_transaction_xdr();
3320        let request = SponsoredTransactionBuildRequest::Stellar(
3321            crate::models::StellarPrepareTransactionRequestParams {
3322                transaction_xdr: Some(transaction_xdr),
3323                operations: None,
3324                source_account: None,
3325                fee_token: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC".to_string(),
3326            },
3327        );
3328
3329        let result = relayer.build_sponsored_transaction(request).await;
3330        assert!(result.is_err());
3331        let err = result.unwrap_err();
3332        assert!(matches!(err, RelayerError::ValidationError(_)));
3333        if let RelayerError::ValidationError(msg) = err {
3334            assert!(msg.contains("Insufficient balance"));
3335        }
3336
3337        // Clean up env var
3338        std::env::remove_var("STELLAR_TESTNET_FEE_FORWARDER_ADDRESS");
3339    }
3340
3341    #[tokio::test]
3342    #[serial]
3343    async fn test_build_soroban_sponsored_simulation_error() {
3344        // Set required env var for FeeForwarder (testnet network)
3345        std::env::set_var(
3346            "STELLAR_TESTNET_FEE_FORWARDER_ADDRESS",
3347            "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC",
3348        );
3349
3350        let relayer_model = create_test_relayer_with_soroban_token();
3351        let mut provider = MockStellarProviderTrait::new();
3352
3353        // Mock get_latest_ledger
3354        provider.expect_get_latest_ledger().returning(|| {
3355            Box::pin(ready(Ok(
3356                soroban_rs::stellar_rpc_client::GetLatestLedgerResponse {
3357                    id: "test".to_string(),
3358                    protocol_version: 20,
3359                    sequence: 1000,
3360                },
3361            )))
3362        });
3363
3364        // Mock simulate_transaction_envelope to return error
3365        provider
3366            .expect_simulate_transaction_envelope()
3367            .returning(|_| {
3368                Box::pin(ready(Ok(
3369                    soroban_rs::stellar_rpc_client::SimulateTransactionResponse {
3370                        error: Some(
3371                            "Contract execution failed: insufficient resources".to_string(),
3372                        ),
3373                        min_resource_fee: 0,
3374                        transaction_data: "".to_string(),
3375                        ..Default::default()
3376                    },
3377                )))
3378            });
3379
3380        let mut dex_service = MockStellarDexServiceTrait::new();
3381        dex_service.expect_supported_asset_types().returning(|| {
3382            std::collections::HashSet::from([AssetType::Native, AssetType::Contract])
3383        });
3384
3385        // Mock get_xlm_to_token_quote for initial fee estimation
3386        dex_service
3387            .expect_get_xlm_to_token_quote()
3388            .returning(|_, _, _, _| {
3389                Box::pin(ready(Ok(
3390                    crate::services::stellar_dex::StellarQuoteResponse {
3391                        input_asset: "native".to_string(),
3392                        output_asset: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC"
3393                            .to_string(),
3394                        in_amount: 100,
3395                        out_amount: 1500,
3396                        price_impact_pct: 0.0,
3397                        slippage_bps: 100,
3398                        path: None,
3399                    },
3400                )))
3401            });
3402
3403        let dex_service = Arc::new(dex_service);
3404        let relayer = create_test_relayer_instance(relayer_model, provider, dex_service).await;
3405
3406        let transaction_xdr = create_test_soroban_transaction_xdr();
3407        let request = SponsoredTransactionBuildRequest::Stellar(
3408            crate::models::StellarPrepareTransactionRequestParams {
3409                transaction_xdr: Some(transaction_xdr),
3410                operations: None,
3411                source_account: None,
3412                fee_token: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC".to_string(),
3413            },
3414        );
3415
3416        let result = relayer.build_sponsored_transaction(request).await;
3417        assert!(result.is_err());
3418        let err = result.unwrap_err();
3419        // Simulation errors are wrapped in ValidationError via calculate_total_soroban_fee
3420        assert!(matches!(err, RelayerError::ValidationError(_)));
3421        if let RelayerError::ValidationError(msg) = err {
3422            assert!(msg.contains("Simulation failed"));
3423        }
3424
3425        // Clean up env var
3426        std::env::remove_var("STELLAR_TESTNET_FEE_FORWARDER_ADDRESS");
3427    }
3428}