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).unwrap_or_else(|_| {
1414                    AccountId(PublicKey::PublicKeyTypeEd25519(Uint256([0; 32])))
1415                })
1416            };
1417
1418            let issuer_id =
1419                parse_account_id("GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN")
1420                    .unwrap_or_else(|_| {
1421                        AccountId(PublicKey::PublicKeyTypeEd25519(Uint256([0; 32])))
1422                    });
1423
1424            // Create a trustline entry with sufficient balance
1425            let trustline_entry = TrustLineEntry {
1426                account_id,
1427                asset: soroban_rs::xdr::TrustLineAsset::CreditAlphanum4(AlphaNum4 {
1428                    asset_code: AssetCode4(*b"USDC"),
1429                    issuer: issuer_id,
1430                }),
1431                balance: 10_000_000i64,
1432                limit: i64::MAX,
1433                flags: 0,
1434                ext: TrustLineEntryExt::V0,
1435            };
1436
1437            let ledger_entry = LedgerEntry {
1438                last_modified_ledger_seq: 0,
1439                data: LedgerEntryData::Trustline(trustline_entry),
1440                ext: LedgerEntryExt::V0,
1441            };
1442
1443            // Encode LedgerEntryData to XDR base64 (not the full LedgerEntry)
1444            let xdr = ledger_entry
1445                .data
1446                .to_xdr_base64(soroban_rs::xdr::Limits::none())
1447                .expect("Failed to encode trustline entry data to XDR");
1448
1449            Box::pin(ready(Ok(GetLedgerEntriesResponse {
1450                entries: Some(vec![LedgerEntryResult {
1451                    key: "test_key".to_string(),
1452                    xdr,
1453                    last_modified_ledger: 0u32,
1454                    live_until_ledger_seq_ledger_seq: None,
1455                }]),
1456                latest_ledger: 0,
1457            })))
1458        });
1459
1460        let mut dex_service = MockStellarDexServiceTrait::new();
1461        dex_service
1462            .expect_supported_asset_types()
1463            .returning(|| std::collections::HashSet::from([AssetType::Native, AssetType::Classic]));
1464
1465        // Mock get_xlm_to_token_quote for fee conversion (XLM -> token)
1466        dex_service
1467            .expect_get_xlm_to_token_quote()
1468            .returning(|_, _, _, _| {
1469                Box::pin(ready(Ok(
1470                    crate::services::stellar_dex::StellarQuoteResponse {
1471                        input_asset: "native".to_string(),
1472                        output_asset: USDC_ASSET.to_string(),
1473                        in_amount: 100000,
1474                        out_amount: 1500000,
1475                        price_impact_pct: 0.0,
1476                        slippage_bps: 100,
1477                        path: None,
1478                    },
1479                )))
1480            });
1481
1482        let dex_service = Arc::new(dex_service);
1483        let relayer = create_test_relayer_instance(relayer_model, provider, dex_service).await;
1484
1485        let transaction_xdr = create_test_transaction_xdr();
1486        let request = SponsoredTransactionQuoteRequest::Stellar(
1487            crate::models::StellarFeeEstimateRequestParams {
1488                transaction_xdr: Some(transaction_xdr),
1489                operations: None,
1490                source_account: None,
1491                fee_token: USDC_ASSET.to_string(),
1492            },
1493        );
1494
1495        let result = relayer.quote_sponsored_transaction(request).await;
1496        if let Err(e) = &result {
1497            eprintln!("Quote error: {:?}", e);
1498        }
1499        assert!(result.is_ok());
1500
1501        if let SponsoredTransactionQuoteResponse::Stellar(quote) = result.unwrap() {
1502            assert_eq!(quote.fee_in_token, "1500000");
1503            assert!(!quote.fee_in_token_ui.is_empty());
1504            assert!(!quote.conversion_rate.is_empty());
1505        } else {
1506            panic!("Expected Stellar quote response");
1507        }
1508    }
1509
1510    #[tokio::test]
1511    async fn test_quote_sponsored_transaction_with_operations() {
1512        let relayer_model = create_test_relayer_with_user_fee_strategy();
1513        let mut provider = MockStellarProviderTrait::new();
1514
1515        provider.expect_get_account().returning(|_| {
1516            Box::pin(ready(Ok(AccountEntry {
1517                account_id: AccountId(PublicKey::PublicKeyTypeEd25519(Uint256([0; 32]))),
1518                balance: 1000000000,
1519                seq_num: SequenceNumber(-1),
1520                num_sub_entries: 0,
1521                inflation_dest: None,
1522                flags: 0,
1523                home_domain: String32::default(),
1524                thresholds: Thresholds([0; 4]),
1525                signers: VecM::default(),
1526                ext: AccountEntryExt::V0,
1527            })))
1528        });
1529
1530        // Mock get_ledger_entries for token balance validation
1531        // This mock extracts the account ID from the ledger key and returns a trustline with sufficient balance
1532        provider.expect_get_ledger_entries().returning(|keys| {
1533            // Extract account ID from the first ledger key (should be a Trustline key)
1534            let account_id = if let Some(LedgerKey::Trustline(trustline_key)) = keys.first() {
1535                trustline_key.account_id.clone()
1536            } else {
1537                // Fallback: use the source account from the test
1538                parse_account_id("GCZ54QGQCUZ6U5WJF4AG5JEZCUMYTS2F6JRLUS76XF2PQMEJ2E3JISI2")
1539                    .unwrap_or_else(|_| {
1540                        AccountId(PublicKey::PublicKeyTypeEd25519(Uint256([0; 32])))
1541                    })
1542            };
1543
1544            let issuer_id =
1545                parse_account_id("GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN")
1546                    .unwrap_or_else(|_| {
1547                        AccountId(PublicKey::PublicKeyTypeEd25519(Uint256([0; 32])))
1548                    });
1549
1550            // Create a trustline entry with sufficient balance
1551            let trustline_entry = TrustLineEntry {
1552                account_id,
1553                asset: soroban_rs::xdr::TrustLineAsset::CreditAlphanum4(AlphaNum4 {
1554                    asset_code: AssetCode4(*b"USDC"),
1555                    issuer: issuer_id,
1556                }),
1557                balance: 10_000_000i64,
1558                limit: i64::MAX,
1559                flags: 0,
1560                ext: TrustLineEntryExt::V0,
1561            };
1562
1563            let ledger_entry = LedgerEntry {
1564                last_modified_ledger_seq: 0,
1565                data: LedgerEntryData::Trustline(trustline_entry),
1566                ext: LedgerEntryExt::V0,
1567            };
1568
1569            // Encode LedgerEntryData to XDR base64 (not the full LedgerEntry)
1570            let xdr = ledger_entry
1571                .data
1572                .to_xdr_base64(soroban_rs::xdr::Limits::none())
1573                .expect("Failed to encode trustline entry data to XDR");
1574
1575            Box::pin(ready(Ok(
1576                soroban_rs::stellar_rpc_client::GetLedgerEntriesResponse {
1577                    entries: Some(vec![LedgerEntryResult {
1578                        key: "test_key".to_string(),
1579                        xdr,
1580                        last_modified_ledger: 0u32,
1581                        live_until_ledger_seq_ledger_seq: None,
1582                    }]),
1583                    latest_ledger: 0,
1584                },
1585            )))
1586        });
1587
1588        let mut dex_service = MockStellarDexServiceTrait::new();
1589        dex_service
1590            .expect_supported_asset_types()
1591            .returning(|| std::collections::HashSet::from([AssetType::Native, AssetType::Classic]));
1592
1593        // Mock get_xlm_to_token_quote for fee conversion (XLM -> token)
1594        dex_service
1595            .expect_get_xlm_to_token_quote()
1596            .returning(|_, _, _, _| {
1597                Box::pin(ready(Ok(
1598                    crate::services::stellar_dex::StellarQuoteResponse {
1599                        input_asset: "native".to_string(),
1600                        output_asset: USDC_ASSET.to_string(),
1601                        in_amount: 100000,
1602                        out_amount: 1500000,
1603                        price_impact_pct: 0.0,
1604                        slippage_bps: 100,
1605                        path: None,
1606                    },
1607                )))
1608            });
1609
1610        let dex_service = Arc::new(dex_service);
1611        let relayer = create_test_relayer_instance(relayer_model, provider, dex_service).await;
1612
1613        let operations = vec![OperationSpec::Payment {
1614            destination: TEST_PK.to_string(),
1615            amount: 1000000,
1616            asset: AssetSpec::Native,
1617        }];
1618
1619        let request = SponsoredTransactionQuoteRequest::Stellar(
1620            crate::models::StellarFeeEstimateRequestParams {
1621                transaction_xdr: None,
1622                operations: Some(operations),
1623                source_account: Some(
1624                    "GCZ54QGQCUZ6U5WJF4AG5JEZCUMYTS2F6JRLUS76XF2PQMEJ2E3JISI2".to_string(),
1625                ),
1626                fee_token: USDC_ASSET.to_string(),
1627            },
1628        );
1629
1630        let result = relayer.quote_sponsored_transaction(request).await;
1631        if let Err(e) = &result {
1632            eprintln!("Quote error: {:?}", e);
1633        }
1634        assert!(result.is_ok());
1635    }
1636
1637    #[tokio::test]
1638    async fn test_quote_sponsored_transaction_invalid_token() {
1639        let relayer_model = create_test_relayer_with_user_fee_strategy();
1640        let provider = MockStellarProviderTrait::new();
1641        let dex_service = create_mock_dex_service();
1642        let relayer = create_test_relayer_instance(relayer_model, provider, dex_service).await;
1643
1644        let transaction_xdr = create_test_transaction_xdr();
1645        let request = SponsoredTransactionQuoteRequest::Stellar(
1646            crate::models::StellarFeeEstimateRequestParams {
1647                transaction_xdr: Some(transaction_xdr),
1648                operations: None,
1649                source_account: None,
1650                fee_token: "INVALID:TOKEN".to_string(),
1651            },
1652        );
1653
1654        let result = relayer.quote_sponsored_transaction(request).await;
1655        assert!(result.is_err());
1656        assert!(matches!(
1657            result.unwrap_err(),
1658            RelayerError::ValidationError(_)
1659        ));
1660    }
1661
1662    #[tokio::test]
1663    async fn test_quote_sponsored_transaction_missing_xdr_and_operations() {
1664        let relayer_model = create_test_relayer_with_user_fee_strategy();
1665        let provider = MockStellarProviderTrait::new();
1666        let dex_service = create_mock_dex_service();
1667        let relayer = create_test_relayer_instance(relayer_model, provider, dex_service).await;
1668
1669        let request = SponsoredTransactionQuoteRequest::Stellar(
1670            crate::models::StellarFeeEstimateRequestParams {
1671                transaction_xdr: None,
1672                operations: None,
1673                source_account: None,
1674                fee_token: USDC_ASSET.to_string(),
1675            },
1676        );
1677
1678        let result = relayer.quote_sponsored_transaction(request).await;
1679        assert!(result.is_err());
1680        assert!(matches!(
1681            result.unwrap_err(),
1682            RelayerError::ValidationError(_)
1683        ));
1684    }
1685
1686    #[tokio::test]
1687    async fn test_build_sponsored_transaction_with_xdr() {
1688        let relayer_model = create_test_relayer_with_user_fee_strategy();
1689        let mut provider = MockStellarProviderTrait::new();
1690
1691        provider.expect_get_account().returning(|_| {
1692            Box::pin(ready(Ok(AccountEntry {
1693                account_id: AccountId(PublicKey::PublicKeyTypeEd25519(Uint256([0; 32]))),
1694                balance: 1000000000,
1695                seq_num: SequenceNumber(-1),
1696                num_sub_entries: 0,
1697                inflation_dest: None,
1698                flags: 0,
1699                home_domain: String32::default(),
1700                thresholds: Thresholds([0; 4]),
1701                signers: VecM::default(),
1702                ext: AccountEntryExt::V0,
1703            })))
1704        });
1705
1706        // Mock get_ledger_entries for token balance validation
1707        // This mock extracts the account ID from the ledger key and returns a trustline with sufficient balance
1708        provider.expect_get_ledger_entries().returning(|keys| {
1709            // Extract account ID from the first ledger key (should be a Trustline key)
1710            let account_id = if let Some(LedgerKey::Trustline(trustline_key)) = keys.first() {
1711                trustline_key.account_id.clone()
1712            } else {
1713                // Fallback: try to parse TEST_PK
1714                parse_account_id(TEST_PK).unwrap_or_else(|_| {
1715                    AccountId(PublicKey::PublicKeyTypeEd25519(Uint256([0; 32])))
1716                })
1717            };
1718
1719            let issuer_id =
1720                parse_account_id("GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN")
1721                    .unwrap_or_else(|_| {
1722                        AccountId(PublicKey::PublicKeyTypeEd25519(Uint256([0; 32])))
1723                    });
1724
1725            // Create a trustline entry with sufficient balance (10 USDC = 10000000 with 6 decimals)
1726            let trustline_entry = TrustLineEntry {
1727                account_id,
1728                asset: soroban_rs::xdr::TrustLineAsset::CreditAlphanum4(AlphaNum4 {
1729                    asset_code: AssetCode4(*b"USDC"),
1730                    issuer: issuer_id,
1731                }),
1732                balance: 10_000_000i64, // 10 USDC (with 6 decimals) - sufficient for fee
1733                limit: i64::MAX,
1734                flags: 0,
1735                ext: TrustLineEntryExt::V0, // V0 has no liabilities
1736            };
1737
1738            let ledger_entry = LedgerEntry {
1739                last_modified_ledger_seq: 0,
1740                data: LedgerEntryData::Trustline(trustline_entry),
1741                ext: LedgerEntryExt::V0,
1742            };
1743
1744            // Encode LedgerEntryData to XDR base64 (not the full LedgerEntry)
1745            let xdr = ledger_entry
1746                .data
1747                .to_xdr_base64(soroban_rs::xdr::Limits::none())
1748                .expect("Failed to encode trustline entry data to XDR");
1749
1750            Box::pin(ready(Ok(
1751                soroban_rs::stellar_rpc_client::GetLedgerEntriesResponse {
1752                    entries: Some(vec![LedgerEntryResult {
1753                        key: "test_key".to_string(),
1754                        xdr,
1755                        last_modified_ledger: 0u32,
1756                        live_until_ledger_seq_ledger_seq: None,
1757                    }]),
1758                    latest_ledger: 0,
1759                },
1760            )))
1761        });
1762
1763        let mut dex_service = MockStellarDexServiceTrait::new();
1764        dex_service
1765            .expect_supported_asset_types()
1766            .returning(|| std::collections::HashSet::from([AssetType::Native, AssetType::Classic]));
1767
1768        // Mock get_xlm_to_token_quote for build (converting XLM fee to token)
1769        dex_service
1770            .expect_get_xlm_to_token_quote()
1771            .returning(|_, _, _, _| {
1772                Box::pin(ready(Ok(
1773                    crate::services::stellar_dex::StellarQuoteResponse {
1774                        input_asset: "native".to_string(),
1775                        output_asset: USDC_ASSET.to_string(),
1776                        in_amount: 1000000,
1777                        out_amount: 1500000,
1778                        price_impact_pct: 0.0,
1779                        slippage_bps: 100,
1780                        path: None,
1781                    },
1782                )))
1783            });
1784
1785        let dex_service = Arc::new(dex_service);
1786        let relayer = create_test_relayer_instance(relayer_model, provider, dex_service).await;
1787
1788        let transaction_xdr = create_test_transaction_xdr();
1789        let request = SponsoredTransactionBuildRequest::Stellar(
1790            crate::models::StellarPrepareTransactionRequestParams {
1791                transaction_xdr: Some(transaction_xdr),
1792                operations: None,
1793                source_account: None,
1794                fee_token: USDC_ASSET.to_string(),
1795            },
1796        );
1797
1798        let result = relayer.build_sponsored_transaction(request).await;
1799        assert!(result.is_ok());
1800
1801        if let SponsoredTransactionBuildResponse::Stellar(build) = result.unwrap() {
1802            assert!(!build.transaction.is_empty());
1803            assert_eq!(build.fee_in_token, "1500000");
1804            assert!(!build.fee_in_token_ui.is_empty());
1805            assert_eq!(build.fee_token, USDC_ASSET);
1806            assert!(!build.valid_until.is_empty());
1807        } else {
1808            panic!("Expected Stellar build response");
1809        }
1810    }
1811
1812    #[tokio::test]
1813    async fn test_build_sponsored_transaction_with_operations() {
1814        let relayer_model = create_test_relayer_with_user_fee_strategy();
1815        let mut provider = MockStellarProviderTrait::new();
1816
1817        provider.expect_get_account().returning(|_| {
1818            Box::pin(ready(Ok(AccountEntry {
1819                account_id: AccountId(PublicKey::PublicKeyTypeEd25519(Uint256([0; 32]))),
1820                balance: 1000000000,
1821                seq_num: SequenceNumber(-1),
1822                num_sub_entries: 0,
1823                inflation_dest: None,
1824                flags: 0,
1825                home_domain: String32::default(),
1826                thresholds: Thresholds([0; 4]),
1827                signers: VecM::default(),
1828                ext: AccountEntryExt::V0,
1829            })))
1830        });
1831
1832        provider.expect_get_ledger_entries().returning(|_| {
1833            use crate::domain::transaction::stellar::utils::parse_account_id;
1834            use soroban_rs::stellar_rpc_client::LedgerEntryResult;
1835            use soroban_rs::xdr::{
1836                AccountId, AlphaNum4, AssetCode4, LedgerEntry, LedgerEntryData, LedgerEntryExt,
1837                PublicKey, TrustLineEntry, TrustLineEntryExt, Uint256, WriteXdr,
1838            };
1839
1840            // Parse account IDs - use the source account from the test
1841            let account_id =
1842                parse_account_id("GCZ54QGQCUZ6U5WJF4AG5JEZCUMYTS2F6JRLUS76XF2PQMEJ2E3JISI2")
1843                    .unwrap_or_else(|_| {
1844                        AccountId(PublicKey::PublicKeyTypeEd25519(Uint256([0; 32])))
1845                    });
1846            let issuer_id =
1847                parse_account_id("GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN")
1848                    .unwrap_or_else(|_| {
1849                        AccountId(PublicKey::PublicKeyTypeEd25519(Uint256([0; 32])))
1850                    });
1851
1852            // Create a trustline entry with sufficient balance (10 USDC = 10000000 with 6 decimals)
1853            // The fee is 1500000 (from the quote), so 10 USDC is more than enough
1854            let trustline_entry = TrustLineEntry {
1855                account_id,
1856                asset: soroban_rs::xdr::TrustLineAsset::CreditAlphanum4(AlphaNum4 {
1857                    asset_code: AssetCode4(*b"USDC"),
1858                    issuer: issuer_id,
1859                }),
1860                balance: 10_000_000i64,
1861                limit: i64::MAX,
1862                flags: 0,
1863                ext: TrustLineEntryExt::V0,
1864            };
1865
1866            let ledger_entry = LedgerEntry {
1867                last_modified_ledger_seq: 0,
1868                data: LedgerEntryData::Trustline(trustline_entry),
1869                ext: LedgerEntryExt::V0,
1870            };
1871
1872            // Encode LedgerEntryData to XDR base64 (not the full LedgerEntry)
1873            // The parse_ledger_entry_from_xdr function expects just the data portion
1874            let xdr = ledger_entry
1875                .data
1876                .to_xdr_base64(soroban_rs::xdr::Limits::none())
1877                .expect("Failed to encode trustline entry data to XDR");
1878
1879            Box::pin(ready(Ok(
1880                soroban_rs::stellar_rpc_client::GetLedgerEntriesResponse {
1881                    entries: Some(vec![LedgerEntryResult {
1882                        key: "test_key".to_string(),
1883                        xdr,
1884                        last_modified_ledger: 0u32,
1885                        live_until_ledger_seq_ledger_seq: None,
1886                    }]),
1887                    latest_ledger: 0,
1888                },
1889            )))
1890        });
1891
1892        let mut dex_service = MockStellarDexServiceTrait::new();
1893        dex_service
1894            .expect_supported_asset_types()
1895            .returning(|| std::collections::HashSet::from([AssetType::Native, AssetType::Classic]));
1896
1897        dex_service
1898            .expect_get_xlm_to_token_quote()
1899            .returning(|_, _, _, _| {
1900                Box::pin(ready(Ok(
1901                    crate::services::stellar_dex::StellarQuoteResponse {
1902                        input_asset: "native".to_string(),
1903                        output_asset: USDC_ASSET.to_string(),
1904                        in_amount: 1000000,
1905                        out_amount: 1500000,
1906                        price_impact_pct: 0.0,
1907                        slippage_bps: 100,
1908                        path: None,
1909                    },
1910                )))
1911            });
1912
1913        let dex_service = Arc::new(dex_service);
1914        let relayer = create_test_relayer_instance(relayer_model, provider, dex_service).await;
1915
1916        let operations = vec![OperationSpec::Payment {
1917            destination: TEST_PK.to_string(),
1918            amount: 1000000,
1919            asset: AssetSpec::Native,
1920        }];
1921
1922        let request = SponsoredTransactionBuildRequest::Stellar(
1923            crate::models::StellarPrepareTransactionRequestParams {
1924                transaction_xdr: None,
1925                operations: Some(operations),
1926                source_account: Some(
1927                    "GCZ54QGQCUZ6U5WJF4AG5JEZCUMYTS2F6JRLUS76XF2PQMEJ2E3JISI2".to_string(),
1928                ),
1929                fee_token: USDC_ASSET.to_string(),
1930            },
1931        );
1932
1933        let result = relayer.build_sponsored_transaction(request).await;
1934
1935        assert!(result.is_ok());
1936    }
1937
1938    #[tokio::test]
1939    async fn test_build_sponsored_transaction_missing_source_account() {
1940        let relayer_model = create_test_relayer_with_user_fee_strategy();
1941        let provider = MockStellarProviderTrait::new();
1942        let dex_service = create_mock_dex_service();
1943        let relayer = create_test_relayer_instance(relayer_model, provider, dex_service).await;
1944
1945        let operations = vec![OperationSpec::Payment {
1946            destination: TEST_PK.to_string(),
1947            amount: 1000000,
1948            asset: AssetSpec::Native,
1949        }];
1950
1951        let request = SponsoredTransactionBuildRequest::Stellar(
1952            crate::models::StellarPrepareTransactionRequestParams {
1953                transaction_xdr: None,
1954                operations: Some(operations),
1955                source_account: None,
1956                fee_token: USDC_ASSET.to_string(),
1957            },
1958        );
1959
1960        let result = relayer.build_sponsored_transaction(request).await;
1961        assert!(result.is_err());
1962        assert!(matches!(
1963            result.unwrap_err(),
1964            RelayerError::ValidationError(_)
1965        ));
1966    }
1967
1968    #[tokio::test]
1969    async fn test_build_envelope_from_request_with_xdr() {
1970        let provider = MockStellarProviderTrait::new();
1971        let transaction_xdr = create_test_transaction_xdr();
1972        let result = build_envelope_from_request(
1973            Some(&transaction_xdr),
1974            None,
1975            None,
1976            TEST_NETWORK_PASSPHRASE,
1977            &provider,
1978        )
1979        .await;
1980        assert!(result.is_ok());
1981    }
1982
1983    #[tokio::test]
1984    async fn test_build_envelope_from_request_with_operations() {
1985        let mut provider = MockStellarProviderTrait::new();
1986
1987        // Mock get_account to return a valid account with sequence number
1988        provider.expect_get_account().returning(|_| {
1989            Box::pin(ready(Ok(AccountEntry {
1990                account_id: AccountId(PublicKey::PublicKeyTypeEd25519(Uint256([0; 32]))),
1991                balance: 1000000000,
1992                seq_num: SequenceNumber(100),
1993                num_sub_entries: 0,
1994                inflation_dest: None,
1995                flags: 0,
1996                home_domain: String32::default(),
1997                thresholds: Thresholds([0; 4]),
1998                signers: VecM::default(),
1999                ext: AccountEntryExt::V0,
2000            })))
2001        });
2002
2003        let operations = vec![OperationSpec::Payment {
2004            destination: TEST_PK.to_string(),
2005            amount: 1000000,
2006            asset: AssetSpec::Native,
2007        }];
2008
2009        let result = build_envelope_from_request(
2010            None,
2011            Some(&operations),
2012            Some(&TEST_PK.to_string()),
2013            TEST_NETWORK_PASSPHRASE,
2014            &provider,
2015        )
2016        .await;
2017        assert!(result.is_ok());
2018
2019        // Verify the sequence number is set correctly (current + 1 = 101)
2020        if let Ok(envelope) = result {
2021            if let TransactionEnvelope::Tx(tx_env) = envelope {
2022                assert_eq!(tx_env.tx.seq_num.0, 101);
2023            }
2024        }
2025    }
2026
2027    #[tokio::test]
2028    async fn test_build_envelope_from_request_missing_source_account() {
2029        let provider = MockStellarProviderTrait::new();
2030        let operations = vec![OperationSpec::Payment {
2031            destination: TEST_PK.to_string(),
2032            amount: 1000000,
2033            asset: AssetSpec::Native,
2034        }];
2035
2036        let result = build_envelope_from_request(
2037            None,
2038            Some(&operations),
2039            None,
2040            TEST_NETWORK_PASSPHRASE,
2041            &provider,
2042        )
2043        .await;
2044        assert!(result.is_err());
2045        assert!(matches!(
2046            result.unwrap_err(),
2047            RelayerError::ValidationError(_)
2048        ));
2049    }
2050
2051    #[tokio::test]
2052    async fn test_build_envelope_from_request_missing_both() {
2053        let provider = MockStellarProviderTrait::new();
2054        let result =
2055            build_envelope_from_request(None, None, None, TEST_NETWORK_PASSPHRASE, &provider).await;
2056        assert!(result.is_err());
2057        assert!(matches!(
2058            result.unwrap_err(),
2059            RelayerError::ValidationError(_)
2060        ));
2061    }
2062
2063    #[tokio::test]
2064    async fn test_build_envelope_from_request_invalid_xdr() {
2065        let provider = MockStellarProviderTrait::new();
2066        let result = build_envelope_from_request(
2067            Some(&"INVALID_XDR".to_string()),
2068            None,
2069            None,
2070            TEST_NETWORK_PASSPHRASE,
2071            &provider,
2072        )
2073        .await;
2074        assert!(result.is_err());
2075    }
2076
2077    // ============================================================================
2078    // Tests for detect_soroban_invoke_from_xdr
2079    // ============================================================================
2080
2081    #[test]
2082    fn test_detect_soroban_invoke_from_xdr_classic_transaction() {
2083        // Classic payment transaction should return None
2084        let xdr = create_test_transaction_xdr();
2085        let result = detect_soroban_invoke_from_xdr(&xdr);
2086        assert!(result.is_ok());
2087        assert!(result.unwrap().is_none());
2088    }
2089
2090    #[test]
2091    fn test_detect_soroban_invoke_from_xdr_invalid_xdr() {
2092        let result = detect_soroban_invoke_from_xdr("INVALID_XDR");
2093        assert!(result.is_err());
2094        assert!(matches!(
2095            result.unwrap_err(),
2096            RelayerError::ValidationError(_)
2097        ));
2098    }
2099
2100    #[test]
2101    fn test_detect_soroban_invoke_from_xdr_with_soroban_transaction() {
2102        use soroban_rs::xdr::{
2103            ContractId, Hash, HostFunction, InvokeContractArgs, InvokeHostFunctionOp, Memo,
2104            MuxedAccount, Operation, OperationBody, Preconditions, ScAddress, ScSymbol, ScVal,
2105            SequenceNumber, Transaction, TransactionEnvelope, TransactionExt,
2106            TransactionV1Envelope, Uint256, VecM,
2107        };
2108
2109        // Create a Soroban InvokeHostFunction transaction
2110        let contract_id = ContractId(Hash([1u8; 32]));
2111        let invoke_args = InvokeContractArgs {
2112            contract_address: ScAddress::Contract(contract_id),
2113            function_name: ScSymbol("test_function".try_into().unwrap()),
2114            args: vec![ScVal::Bool(true)].try_into().unwrap(),
2115        };
2116
2117        let invoke_op = InvokeHostFunctionOp {
2118            host_function: HostFunction::InvokeContract(invoke_args),
2119            auth: VecM::default(),
2120        };
2121
2122        let operation = Operation {
2123            source_account: None,
2124            body: OperationBody::InvokeHostFunction(invoke_op),
2125        };
2126
2127        let source_pk = Ed25519PublicKey::from_string(
2128            "GCZ54QGQCUZ6U5WJF4AG5JEZCUMYTS2F6JRLUS76XF2PQMEJ2E3JISI2",
2129        )
2130        .unwrap();
2131
2132        let tx = Transaction {
2133            source_account: MuxedAccount::Ed25519(Uint256(source_pk.0)),
2134            fee: 100,
2135            seq_num: SequenceNumber(1),
2136            cond: Preconditions::None,
2137            memo: Memo::None,
2138            operations: vec![operation].try_into().unwrap(),
2139            ext: TransactionExt::V0,
2140        };
2141
2142        let envelope = TransactionEnvelope::Tx(TransactionV1Envelope {
2143            tx,
2144            signatures: VecM::default(),
2145        });
2146
2147        let xdr = envelope.to_xdr_base64(Limits::none()).unwrap();
2148        let result = detect_soroban_invoke_from_xdr(&xdr);
2149        assert!(result.is_ok());
2150
2151        let soroban_info = result.unwrap();
2152        assert!(soroban_info.is_some());
2153
2154        let info = soroban_info.unwrap();
2155        assert_eq!(info.target_fn, "test_function");
2156        assert_eq!(info.target_args.len(), 1);
2157        // Verify contract address format (C...)
2158        assert!(info.target_contract.starts_with('C'));
2159    }
2160
2161    #[test]
2162    fn test_detect_soroban_invoke_from_xdr_multiple_operations_error() {
2163        use soroban_rs::xdr::{
2164            ContractId, Hash, HostFunction, InvokeContractArgs, InvokeHostFunctionOp, Memo,
2165            MuxedAccount, Operation, OperationBody, PaymentOp, Preconditions, ScAddress, ScSymbol,
2166            SequenceNumber, Transaction, TransactionEnvelope, TransactionExt,
2167            TransactionV1Envelope, Uint256, VecM,
2168        };
2169
2170        // Create a transaction with InvokeHostFunction AND another operation (invalid for Soroban)
2171        let contract_id = ContractId(Hash([1u8; 32]));
2172        let invoke_args = InvokeContractArgs {
2173            contract_address: ScAddress::Contract(contract_id),
2174            function_name: ScSymbol("test".try_into().unwrap()),
2175            args: VecM::default(),
2176        };
2177
2178        let invoke_op = InvokeHostFunctionOp {
2179            host_function: HostFunction::InvokeContract(invoke_args),
2180            auth: VecM::default(),
2181        };
2182
2183        let source_pk = Ed25519PublicKey::from_string(
2184            "GCZ54QGQCUZ6U5WJF4AG5JEZCUMYTS2F6JRLUS76XF2PQMEJ2E3JISI2",
2185        )
2186        .unwrap();
2187        let dest_pk = Ed25519PublicKey::from_string(
2188            "GCEZWKCA5VLDNRLN3RPRJMRZOX3Z6G5CHCGSNFHEYVXM3XOJMDS674JZ",
2189        )
2190        .unwrap();
2191
2192        let payment_op = PaymentOp {
2193            destination: MuxedAccount::Ed25519(Uint256(dest_pk.0)),
2194            asset: soroban_rs::xdr::Asset::Native,
2195            amount: 1000000,
2196        };
2197
2198        let operations: VecM<Operation, 100> = vec![
2199            Operation {
2200                source_account: None,
2201                body: OperationBody::InvokeHostFunction(invoke_op),
2202            },
2203            Operation {
2204                source_account: None,
2205                body: OperationBody::Payment(payment_op),
2206            },
2207        ]
2208        .try_into()
2209        .unwrap();
2210
2211        let tx = Transaction {
2212            source_account: MuxedAccount::Ed25519(Uint256(source_pk.0)),
2213            fee: 100,
2214            seq_num: SequenceNumber(1),
2215            cond: Preconditions::None,
2216            memo: Memo::None,
2217            operations,
2218            ext: TransactionExt::V0,
2219        };
2220
2221        let envelope = TransactionEnvelope::Tx(TransactionV1Envelope {
2222            tx,
2223            signatures: VecM::default(),
2224        });
2225
2226        let xdr = envelope.to_xdr_base64(Limits::none()).unwrap();
2227        let result = detect_soroban_invoke_from_xdr(&xdr);
2228
2229        assert!(result.is_err());
2230        let err = result.unwrap_err();
2231        assert!(matches!(err, RelayerError::ValidationError(_)));
2232        if let RelayerError::ValidationError(msg) = err {
2233            assert!(msg.contains("exactly one operation"));
2234        }
2235    }
2236
2237    #[test]
2238    fn test_detect_soroban_invoke_from_xdr_v0_envelope() {
2239        use soroban_rs::xdr::{
2240            Memo, Operation, OperationBody, PaymentOp, SequenceNumber, TransactionEnvelope,
2241            TransactionV0, TransactionV0Envelope, TransactionV0Ext, Uint256, VecM,
2242        };
2243
2244        // Create a V0 envelope (legacy format)
2245        let source_pk = Ed25519PublicKey::from_string(
2246            "GCZ54QGQCUZ6U5WJF4AG5JEZCUMYTS2F6JRLUS76XF2PQMEJ2E3JISI2",
2247        )
2248        .unwrap();
2249        let dest_pk = Ed25519PublicKey::from_string(
2250            "GCEZWKCA5VLDNRLN3RPRJMRZOX3Z6G5CHCGSNFHEYVXM3XOJMDS674JZ",
2251        )
2252        .unwrap();
2253
2254        let payment_op = PaymentOp {
2255            destination: MuxedAccount::Ed25519(Uint256(dest_pk.0)),
2256            asset: soroban_rs::xdr::Asset::Native,
2257            amount: 1000000,
2258        };
2259
2260        let tx = TransactionV0 {
2261            source_account_ed25519: Uint256(source_pk.0),
2262            fee: 100,
2263            seq_num: SequenceNumber(1),
2264            time_bounds: None,
2265            memo: Memo::None,
2266            operations: vec![Operation {
2267                source_account: None,
2268                body: OperationBody::Payment(payment_op),
2269            }]
2270            .try_into()
2271            .unwrap(),
2272            ext: TransactionV0Ext::V0,
2273        };
2274
2275        let envelope = TransactionEnvelope::TxV0(TransactionV0Envelope {
2276            tx,
2277            signatures: VecM::default(),
2278        });
2279
2280        let xdr = envelope.to_xdr_base64(Limits::none()).unwrap();
2281        let result = detect_soroban_invoke_from_xdr(&xdr);
2282
2283        // V0 envelope with classic operation should return None
2284        assert!(result.is_ok());
2285        assert!(result.unwrap().is_none());
2286    }
2287
2288    #[test]
2289    fn test_detect_soroban_invoke_from_xdr_fee_bump_envelope() {
2290        use soroban_rs::xdr::{
2291            FeeBumpTransaction, FeeBumpTransactionEnvelope, FeeBumpTransactionExt,
2292            FeeBumpTransactionInnerTx, Memo, MuxedAccount, Operation, OperationBody, PaymentOp,
2293            Preconditions, SequenceNumber, Transaction, TransactionEnvelope, TransactionExt,
2294            TransactionV1Envelope, Uint256, VecM,
2295        };
2296
2297        let source_pk = Ed25519PublicKey::from_string(
2298            "GCZ54QGQCUZ6U5WJF4AG5JEZCUMYTS2F6JRLUS76XF2PQMEJ2E3JISI2",
2299        )
2300        .unwrap();
2301        let dest_pk = Ed25519PublicKey::from_string(
2302            "GCEZWKCA5VLDNRLN3RPRJMRZOX3Z6G5CHCGSNFHEYVXM3XOJMDS674JZ",
2303        )
2304        .unwrap();
2305
2306        let payment_op = PaymentOp {
2307            destination: MuxedAccount::Ed25519(Uint256(dest_pk.0)),
2308            asset: soroban_rs::xdr::Asset::Native,
2309            amount: 1000000,
2310        };
2311
2312        let inner_tx = Transaction {
2313            source_account: MuxedAccount::Ed25519(Uint256(source_pk.0)),
2314            fee: 100,
2315            seq_num: SequenceNumber(1),
2316            cond: Preconditions::None,
2317            memo: Memo::None,
2318            operations: vec![Operation {
2319                source_account: None,
2320                body: OperationBody::Payment(payment_op),
2321            }]
2322            .try_into()
2323            .unwrap(),
2324            ext: TransactionExt::V0,
2325        };
2326
2327        let inner_envelope = TransactionV1Envelope {
2328            tx: inner_tx,
2329            signatures: VecM::default(),
2330        };
2331
2332        let fee_bump_tx = FeeBumpTransaction {
2333            fee_source: MuxedAccount::Ed25519(Uint256(source_pk.0)),
2334            fee: 200,
2335            inner_tx: FeeBumpTransactionInnerTx::Tx(inner_envelope),
2336            ext: FeeBumpTransactionExt::V0,
2337        };
2338
2339        let envelope = TransactionEnvelope::TxFeeBump(FeeBumpTransactionEnvelope {
2340            tx: fee_bump_tx,
2341            signatures: VecM::default(),
2342        });
2343
2344        let xdr = envelope.to_xdr_base64(Limits::none()).unwrap();
2345        let result = detect_soroban_invoke_from_xdr(&xdr);
2346
2347        // Fee bump with classic operation should return None
2348        assert!(result.is_ok());
2349        assert!(result.unwrap().is_none());
2350    }
2351
2352    #[test]
2353    fn test_detect_soroban_invoke_non_contract_address_error() {
2354        use soroban_rs::xdr::{
2355            HostFunction, InvokeContractArgs, InvokeHostFunctionOp, Memo, MuxedAccount, Operation,
2356            OperationBody, Preconditions, ScAddress, ScSymbol, SequenceNumber, Transaction,
2357            TransactionEnvelope, TransactionExt, TransactionV1Envelope, Uint256, VecM,
2358        };
2359
2360        // Create a Soroban transaction with account address instead of contract address
2361        let source_pk = Ed25519PublicKey::from_string(
2362            "GCZ54QGQCUZ6U5WJF4AG5JEZCUMYTS2F6JRLUS76XF2PQMEJ2E3JISI2",
2363        )
2364        .unwrap();
2365
2366        let invoke_args = InvokeContractArgs {
2367            contract_address: ScAddress::Account(AccountId(PublicKey::PublicKeyTypeEd25519(
2368                Uint256(source_pk.0),
2369            ))),
2370            function_name: ScSymbol("test".try_into().unwrap()),
2371            args: VecM::default(),
2372        };
2373
2374        let invoke_op = InvokeHostFunctionOp {
2375            host_function: HostFunction::InvokeContract(invoke_args),
2376            auth: VecM::default(),
2377        };
2378
2379        let tx = Transaction {
2380            source_account: MuxedAccount::Ed25519(Uint256(source_pk.0)),
2381            fee: 100,
2382            seq_num: SequenceNumber(1),
2383            cond: Preconditions::None,
2384            memo: Memo::None,
2385            operations: vec![Operation {
2386                source_account: None,
2387                body: OperationBody::InvokeHostFunction(invoke_op),
2388            }]
2389            .try_into()
2390            .unwrap(),
2391            ext: TransactionExt::V0,
2392        };
2393
2394        let envelope = TransactionEnvelope::Tx(TransactionV1Envelope {
2395            tx,
2396            signatures: VecM::default(),
2397        });
2398
2399        let xdr = envelope.to_xdr_base64(Limits::none()).unwrap();
2400        let result = detect_soroban_invoke_from_xdr(&xdr);
2401
2402        assert!(result.is_err());
2403        let err = result.unwrap_err();
2404        assert!(matches!(err, RelayerError::ValidationError(_)));
2405        if let RelayerError::ValidationError(msg) = err {
2406            assert!(msg.contains("contract address"));
2407        }
2408    }
2409
2410    // ============================================================================
2411    // Tests for calculate_total_soroban_fee
2412    // ============================================================================
2413
2414    #[test]
2415    fn test_calculate_total_soroban_fee_success() {
2416        let sim_response = soroban_rs::stellar_rpc_client::SimulateTransactionResponse {
2417            error: None,
2418            transaction_data: "".to_string(),
2419            min_resource_fee: 50000,
2420            ..Default::default()
2421        };
2422
2423        let result = calculate_total_soroban_fee(&sim_response, 1);
2424        assert!(result.is_ok());
2425        // inclusion_fee (100) + resource_fee (50000) = 50100
2426        let fee = result.unwrap();
2427        assert_eq!(fee, 50100);
2428    }
2429
2430    #[test]
2431    fn test_calculate_total_soroban_fee_with_multiple_operations() {
2432        let sim_response = soroban_rs::stellar_rpc_client::SimulateTransactionResponse {
2433            error: None,
2434            transaction_data: "".to_string(),
2435            min_resource_fee: 50000,
2436            ..Default::default()
2437        };
2438
2439        let result = calculate_total_soroban_fee(&sim_response, 3);
2440        assert!(result.is_ok());
2441        // inclusion_fee (100 * 3) + resource_fee (50000) = 50300
2442        let fee = result.unwrap();
2443        assert_eq!(fee, 50300);
2444    }
2445
2446    #[test]
2447    fn test_calculate_total_soroban_fee_simulation_error() {
2448        let sim_response = soroban_rs::stellar_rpc_client::SimulateTransactionResponse {
2449            error: Some("Simulation failed: insufficient funds".to_string()),
2450            transaction_data: "".to_string(),
2451            min_resource_fee: 0,
2452            ..Default::default()
2453        };
2454
2455        let result = calculate_total_soroban_fee(&sim_response, 1);
2456        assert!(result.is_err());
2457        let err = result.unwrap_err();
2458        assert!(matches!(err, RelayerError::ValidationError(_)));
2459        if let RelayerError::ValidationError(msg) = err {
2460            assert!(msg.contains("Simulation failed"));
2461        }
2462    }
2463
2464    #[test]
2465    fn test_calculate_total_soroban_fee_minimum_fee() {
2466        // When calculated fee is less than minimum, should return minimum
2467        let sim_response = soroban_rs::stellar_rpc_client::SimulateTransactionResponse {
2468            error: None,
2469            transaction_data: "".to_string(),
2470            min_resource_fee: 0, // Very low resource fee
2471            ..Default::default()
2472        };
2473
2474        let result = calculate_total_soroban_fee(&sim_response, 1);
2475        assert!(result.is_ok());
2476        // Should be at least STELLAR_DEFAULT_TRANSACTION_FEE (100)
2477        let fee = result.unwrap();
2478        assert!(fee >= STELLAR_DEFAULT_TRANSACTION_FEE);
2479    }
2480
2481    // ============================================================================
2482    // Tests for build_soroban_transaction_envelope
2483    // ============================================================================
2484
2485    #[test]
2486    fn test_build_soroban_transaction_envelope_success() {
2487        use soroban_rs::xdr::{
2488            ContractId, Hash, HostFunction, InvokeContractArgs, InvokeHostFunctionOp, Operation,
2489            OperationBody, ScAddress, ScSymbol, VecM,
2490        };
2491
2492        let contract_id = ContractId(Hash([1u8; 32]));
2493        let invoke_args = InvokeContractArgs {
2494            contract_address: ScAddress::Contract(contract_id),
2495            function_name: ScSymbol("test".try_into().unwrap()),
2496            args: VecM::default(),
2497        };
2498
2499        let invoke_op = InvokeHostFunctionOp {
2500            host_function: HostFunction::InvokeContract(invoke_args),
2501            auth: VecM::default(),
2502        };
2503
2504        let operation = Operation {
2505            source_account: None,
2506            body: OperationBody::InvokeHostFunction(invoke_op),
2507        };
2508
2509        let result = build_soroban_transaction_envelope(TEST_PK, operation.clone(), 100);
2510        assert!(result.is_ok());
2511
2512        let envelope = result.unwrap();
2513        if let TransactionEnvelope::Tx(tx_env) = envelope {
2514            assert_eq!(tx_env.tx.fee, 100);
2515            assert_eq!(tx_env.tx.seq_num.0, 0); // Placeholder sequence
2516            assert_eq!(tx_env.tx.operations.len(), 1);
2517        } else {
2518            panic!("Expected Tx envelope");
2519        }
2520    }
2521
2522    #[test]
2523    fn test_build_soroban_transaction_envelope_invalid_source() {
2524        use soroban_rs::xdr::{
2525            ContractId, Hash, HostFunction, InvokeContractArgs, InvokeHostFunctionOp, Operation,
2526            OperationBody, ScAddress, ScSymbol, VecM,
2527        };
2528
2529        let contract_id = ContractId(Hash([1u8; 32]));
2530        let invoke_args = InvokeContractArgs {
2531            contract_address: ScAddress::Contract(contract_id),
2532            function_name: ScSymbol("test".try_into().unwrap()),
2533            args: VecM::default(),
2534        };
2535
2536        let invoke_op = InvokeHostFunctionOp {
2537            host_function: HostFunction::InvokeContract(invoke_args),
2538            auth: VecM::default(),
2539        };
2540
2541        let operation = Operation {
2542            source_account: None,
2543            body: OperationBody::InvokeHostFunction(invoke_op),
2544        };
2545
2546        let result = build_soroban_transaction_envelope("INVALID_ADDRESS", operation, 100);
2547        assert!(result.is_err());
2548        assert!(matches!(
2549            result.unwrap_err(),
2550            RelayerError::ValidationError(_)
2551        ));
2552    }
2553
2554    // ============================================================================
2555    // Tests for add_payment_operation_to_envelope
2556    // ============================================================================
2557
2558    #[test]
2559    fn test_add_payment_operation_to_envelope_classic() {
2560        let envelope = create_test_envelope_for_payment();
2561        let fee_quote = FeeQuote {
2562            fee_in_token: 1000000,
2563            fee_in_token_ui: "1.0".to_string(),
2564            fee_in_stroops: 10000,
2565            conversion_rate: 100.0,
2566        };
2567
2568        let result = add_payment_operation_to_envelope(envelope, &fee_quote, USDC_ASSET, TEST_PK);
2569        assert!(result.is_ok());
2570
2571        let updated_envelope = result.unwrap();
2572        // Classic transaction should have 2 operations now (original + payment)
2573        if let TransactionEnvelope::Tx(tx_env) = updated_envelope {
2574            assert_eq!(tx_env.tx.operations.len(), 2);
2575        }
2576    }
2577
2578    #[test]
2579    fn test_add_payment_operation_to_envelope_soroban_no_op_added() {
2580        use soroban_rs::xdr::{
2581            ContractId, Hash, HostFunction, InvokeContractArgs, InvokeHostFunctionOp, Memo,
2582            Operation, OperationBody, Preconditions, ScAddress, ScSymbol, SequenceNumber,
2583            Transaction, TransactionEnvelope, TransactionExt, TransactionV1Envelope, Uint256, VecM,
2584        };
2585
2586        // Create a Soroban transaction (InvokeHostFunction)
2587        let source_pk = Ed25519PublicKey::from_string(
2588            "GCZ54QGQCUZ6U5WJF4AG5JEZCUMYTS2F6JRLUS76XF2PQMEJ2E3JISI2",
2589        )
2590        .unwrap();
2591
2592        let contract_id = ContractId(Hash([1u8; 32]));
2593        let invoke_args = InvokeContractArgs {
2594            contract_address: ScAddress::Contract(contract_id),
2595            function_name: ScSymbol("test".try_into().unwrap()),
2596            args: VecM::default(),
2597        };
2598
2599        let invoke_op = InvokeHostFunctionOp {
2600            host_function: HostFunction::InvokeContract(invoke_args),
2601            auth: VecM::default(),
2602        };
2603
2604        let tx = Transaction {
2605            source_account: MuxedAccount::Ed25519(Uint256(source_pk.0)),
2606            fee: 100,
2607            seq_num: SequenceNumber(1),
2608            cond: Preconditions::None,
2609            memo: Memo::None,
2610            operations: vec![Operation {
2611                source_account: None,
2612                body: OperationBody::InvokeHostFunction(invoke_op),
2613            }]
2614            .try_into()
2615            .unwrap(),
2616            ext: TransactionExt::V0,
2617        };
2618
2619        let envelope = TransactionEnvelope::Tx(TransactionV1Envelope {
2620            tx,
2621            signatures: VecM::default(),
2622        });
2623
2624        let fee_quote = FeeQuote {
2625            fee_in_token: 1000000,
2626            fee_in_token_ui: "1.0".to_string(),
2627            fee_in_stroops: 10000,
2628            conversion_rate: 100.0,
2629        };
2630
2631        let result = add_payment_operation_to_envelope(envelope, &fee_quote, USDC_ASSET, TEST_PK);
2632        assert!(result.is_ok());
2633
2634        // Soroban transactions should NOT have payment operation added
2635        let updated_envelope = result.unwrap();
2636        if let TransactionEnvelope::Tx(tx_env) = updated_envelope {
2637            assert_eq!(tx_env.tx.operations.len(), 1); // Still only 1 operation
2638        }
2639    }
2640
2641    /// Helper to create a test envelope for payment tests
2642    fn create_test_envelope_for_payment() -> TransactionEnvelope {
2643        let source_pk = Ed25519PublicKey::from_string(
2644            "GCZ54QGQCUZ6U5WJF4AG5JEZCUMYTS2F6JRLUS76XF2PQMEJ2E3JISI2",
2645        )
2646        .unwrap();
2647        let dest_pk = Ed25519PublicKey::from_string(
2648            "GCEZWKCA5VLDNRLN3RPRJMRZOX3Z6G5CHCGSNFHEYVXM3XOJMDS674JZ",
2649        )
2650        .unwrap();
2651
2652        let payment_op = PaymentOp {
2653            destination: MuxedAccount::Ed25519(Uint256(dest_pk.0)),
2654            asset: soroban_rs::xdr::Asset::Native,
2655            amount: 1000000,
2656        };
2657
2658        let tx = Transaction {
2659            source_account: MuxedAccount::Ed25519(Uint256(source_pk.0)),
2660            fee: 100,
2661            seq_num: SequenceNumber(1),
2662            cond: Preconditions::None,
2663            memo: soroban_rs::xdr::Memo::None,
2664            operations: vec![Operation {
2665                source_account: None,
2666                body: OperationBody::Payment(payment_op),
2667            }]
2668            .try_into()
2669            .unwrap(),
2670            ext: TransactionExt::V0,
2671        };
2672
2673        TransactionEnvelope::Tx(TransactionV1Envelope {
2674            tx,
2675            signatures: VecM::default(),
2676        })
2677    }
2678
2679    // ============================================================================
2680    // Tests for add_fee_payment_operation
2681    // ============================================================================
2682
2683    #[test]
2684    fn test_add_fee_payment_operation_success() {
2685        let mut envelope = create_test_envelope_for_payment();
2686        let result = add_fee_payment_operation(&mut envelope, USDC_ASSET, 1000000, TEST_PK);
2687        assert!(result.is_ok());
2688
2689        // Verify operation was added
2690        if let TransactionEnvelope::Tx(tx_env) = envelope {
2691            assert_eq!(tx_env.tx.operations.len(), 2);
2692        }
2693    }
2694
2695    #[test]
2696    fn test_add_fee_payment_operation_native_asset() {
2697        let mut envelope = create_test_envelope_for_payment();
2698        let result = add_fee_payment_operation(&mut envelope, "native", 1000000, TEST_PK);
2699        assert!(result.is_ok());
2700    }
2701
2702    // ============================================================================
2703    // Tests for SorobanInvokeInfo
2704    // ============================================================================
2705
2706    #[test]
2707    fn test_soroban_invoke_info_debug_clone() {
2708        use soroban_rs::xdr::ScVal;
2709
2710        let info = SorobanInvokeInfo {
2711            target_contract: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC".to_string(),
2712            target_fn: "transfer".to_string(),
2713            target_args: vec![ScVal::Bool(true)],
2714        };
2715
2716        // Test Debug trait
2717        let debug_str = format!("{:?}", info);
2718        assert!(debug_str.contains("SorobanInvokeInfo"));
2719        assert!(debug_str.contains("transfer"));
2720
2721        // Test Clone trait
2722        let cloned = info.clone();
2723        assert_eq!(cloned.target_contract, info.target_contract);
2724        assert_eq!(cloned.target_fn, info.target_fn);
2725        assert_eq!(cloned.target_args.len(), info.target_args.len());
2726    }
2727
2728    // ============================================================================
2729    // Tests for fee payment strategy validation
2730    // ============================================================================
2731
2732    #[tokio::test]
2733    async fn test_build_sponsored_transaction_non_user_fee_strategy() {
2734        // Create relayer with Relayer fee payment strategy (not User)
2735        let mut policy = RelayerStellarPolicy::default();
2736        policy.fee_payment_strategy = Some(crate::models::StellarFeePaymentStrategy::Relayer);
2737        policy.allowed_tokens = Some(vec![crate::models::StellarAllowedTokensPolicy {
2738            asset: USDC_ASSET.to_string(),
2739            metadata: None,
2740            max_allowed_fee: None,
2741            swap_config: None,
2742        }]);
2743
2744        let relayer_model = RelayerRepoModel {
2745            id: "test-relayer-id".to_string(),
2746            name: "Test Relayer".to_string(),
2747            network: "testnet".to_string(),
2748            paused: false,
2749            network_type: NetworkType::Stellar,
2750            signer_id: "signer-id".to_string(),
2751            policies: RelayerNetworkPolicy::Stellar(policy),
2752            address: TEST_PK.to_string(),
2753            notification_id: Some("notification-id".to_string()),
2754            system_disabled: false,
2755            custom_rpc_urls: None,
2756            ..Default::default()
2757        };
2758
2759        let provider = MockStellarProviderTrait::new();
2760        let dex_service = create_mock_dex_service();
2761        let relayer = create_test_relayer_instance(relayer_model, provider, dex_service).await;
2762
2763        let transaction_xdr = create_test_transaction_xdr();
2764        let request = SponsoredTransactionBuildRequest::Stellar(
2765            crate::models::StellarPrepareTransactionRequestParams {
2766                transaction_xdr: Some(transaction_xdr),
2767                operations: None,
2768                source_account: None,
2769                fee_token: USDC_ASSET.to_string(),
2770            },
2771        );
2772
2773        let result = relayer.build_sponsored_transaction(request).await;
2774        assert!(result.is_err());
2775        let err = result.unwrap_err();
2776        assert!(matches!(err, RelayerError::ValidationError(_)));
2777        if let RelayerError::ValidationError(msg) = err {
2778            assert!(msg.contains("fee_payment_strategy: User"));
2779        }
2780    }
2781
2782    // ============================================================================
2783    // Tests for quote_soroban_from_xdr (via quote_sponsored_transaction)
2784    // ============================================================================
2785
2786    /// Helper function to create a valid SorobanTransactionData XDR for mocking simulation responses
2787    fn create_valid_soroban_transaction_data_xdr() -> String {
2788        use soroban_rs::xdr::{
2789            LedgerFootprint, SorobanResources, SorobanTransactionData, SorobanTransactionDataExt,
2790        };
2791
2792        let soroban_data = SorobanTransactionData {
2793            ext: SorobanTransactionDataExt::V0,
2794            resources: SorobanResources {
2795                footprint: LedgerFootprint {
2796                    read_only: VecM::default(),
2797                    read_write: VecM::default(),
2798                },
2799                instructions: 1000000,
2800                disk_read_bytes: 10000,
2801                write_bytes: 1000,
2802            },
2803            resource_fee: 50000,
2804        };
2805
2806        soroban_data.to_xdr_base64(Limits::none()).unwrap()
2807    }
2808
2809    /// Helper function to create a Soroban InvokeHostFunction transaction XDR
2810    fn create_test_soroban_transaction_xdr() -> String {
2811        use soroban_rs::xdr::{
2812            ContractId, Hash, HostFunction, InvokeContractArgs, InvokeHostFunctionOp, Memo,
2813            ScAddress, ScSymbol, ScVal,
2814        };
2815
2816        let source_pk = Ed25519PublicKey::from_string(
2817            "GCZ54QGQCUZ6U5WJF4AG5JEZCUMYTS2F6JRLUS76XF2PQMEJ2E3JISI2",
2818        )
2819        .unwrap();
2820
2821        // Create a Soroban contract call operation
2822        let contract_id = ContractId(Hash([1u8; 32]));
2823        let invoke_args = InvokeContractArgs {
2824            contract_address: ScAddress::Contract(contract_id),
2825            function_name: ScSymbol("transfer".try_into().unwrap()),
2826            args: vec![ScVal::Bool(true)].try_into().unwrap(),
2827        };
2828
2829        let invoke_op = InvokeHostFunctionOp {
2830            host_function: HostFunction::InvokeContract(invoke_args),
2831            auth: VecM::default(),
2832        };
2833
2834        let operation = Operation {
2835            source_account: None,
2836            body: OperationBody::InvokeHostFunction(invoke_op),
2837        };
2838
2839        let tx = Transaction {
2840            source_account: MuxedAccount::Ed25519(Uint256(source_pk.0)),
2841            fee: 100,
2842            seq_num: SequenceNumber(1),
2843            cond: Preconditions::None,
2844            memo: Memo::None,
2845            operations: vec![operation].try_into().unwrap(),
2846            ext: TransactionExt::V0,
2847        };
2848
2849        let envelope = TransactionEnvelope::Tx(TransactionV1Envelope {
2850            tx,
2851            signatures: VecM::default(),
2852        });
2853
2854        envelope.to_xdr_base64(Limits::none()).unwrap()
2855    }
2856
2857    /// Helper function to create a relayer with Soroban token support
2858    fn create_test_relayer_with_soroban_token() -> RelayerRepoModel {
2859        let mut policy = RelayerStellarPolicy::default();
2860        policy.fee_payment_strategy = Some(crate::models::StellarFeePaymentStrategy::User);
2861        // Use a Soroban contract address (C...) as the allowed token
2862        policy.allowed_tokens = Some(vec![crate::models::StellarAllowedTokensPolicy {
2863            asset: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC".to_string(),
2864            metadata: None,
2865            max_allowed_fee: None,
2866            swap_config: None,
2867        }]);
2868
2869        RelayerRepoModel {
2870            id: "test-relayer-id".to_string(),
2871            name: "Test Relayer".to_string(),
2872            network: "testnet".to_string(),
2873            paused: false,
2874            network_type: NetworkType::Stellar,
2875            signer_id: "signer-id".to_string(),
2876            policies: RelayerNetworkPolicy::Stellar(policy),
2877            address: TEST_PK.to_string(),
2878            notification_id: Some("notification-id".to_string()),
2879            system_disabled: false,
2880            custom_rpc_urls: None,
2881            ..Default::default()
2882        }
2883    }
2884
2885    #[tokio::test]
2886    #[serial]
2887    async fn test_quote_soroban_from_xdr_success() {
2888        // Set required env var for FeeForwarder (testnet network)
2889        std::env::set_var(
2890            "STELLAR_TESTNET_FEE_FORWARDER_ADDRESS",
2891            "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC",
2892        );
2893
2894        let relayer_model = create_test_relayer_with_soroban_token();
2895        let mut provider = MockStellarProviderTrait::new();
2896
2897        // Mock get_latest_ledger for expiration calculation
2898        provider.expect_get_latest_ledger().returning(|| {
2899            Box::pin(ready(Ok(
2900                soroban_rs::stellar_rpc_client::GetLatestLedgerResponse {
2901                    id: "test".to_string(),
2902                    protocol_version: 20,
2903                    sequence: 1000,
2904                },
2905            )))
2906        });
2907
2908        // Mock simulate_transaction_envelope for Soroban fee estimation
2909        provider
2910            .expect_simulate_transaction_envelope()
2911            .returning(|_| {
2912                Box::pin(ready(Ok(
2913                    soroban_rs::stellar_rpc_client::SimulateTransactionResponse {
2914                        min_resource_fee: 50000,
2915                        transaction_data: "AAAAAQAAAAAAAAACAAAAAAAAAAAAAAAAAAAABgAAAAEAAAAGAAAAAG0JZTO9fU6p3NeJp5w3TpKhZmx6p1pR7mq9wFwCnEIuAAAAFAAAAAEAAAAAAAAAB8NVb2IAAAH0AAAAAQAAAAAAABfAAAAAAAAAAPUAAAAAAAAENgAAAAA=".to_string(),
2916                        ..Default::default()
2917                    },
2918                )))
2919            });
2920
2921        // Mock call_contract for Soroban token balance check (balance function)
2922        provider.expect_call_contract().returning(|_, _, _| {
2923            use soroban_rs::xdr::Int128Parts;
2924            // Return a balance of 10_000_000 (10 tokens with 6 decimals)
2925            Box::pin(ready(Ok(ScVal::I128(Int128Parts {
2926                hi: 0,
2927                lo: 10_000_000,
2928            }))))
2929        });
2930
2931        let mut dex_service = MockStellarDexServiceTrait::new();
2932        dex_service.expect_supported_asset_types().returning(|| {
2933            std::collections::HashSet::from([AssetType::Native, AssetType::Contract])
2934        });
2935
2936        // Mock get_xlm_to_token_quote for fee conversion
2937        dex_service
2938            .expect_get_xlm_to_token_quote()
2939            .returning(|_, _, _, _| {
2940                Box::pin(ready(Ok(
2941                    crate::services::stellar_dex::StellarQuoteResponse {
2942                        input_asset: "native".to_string(),
2943                        output_asset: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC"
2944                            .to_string(),
2945                        in_amount: 50100,    // fee in stroops
2946                        out_amount: 1500000, // fee in token
2947                        price_impact_pct: 0.0,
2948                        slippage_bps: 100,
2949                        path: None,
2950                    },
2951                )))
2952            });
2953
2954        let dex_service = Arc::new(dex_service);
2955        let relayer = create_test_relayer_instance(relayer_model, provider, dex_service).await;
2956
2957        let transaction_xdr = create_test_soroban_transaction_xdr();
2958        let request = SponsoredTransactionQuoteRequest::Stellar(
2959            crate::models::StellarFeeEstimateRequestParams {
2960                transaction_xdr: Some(transaction_xdr),
2961                operations: None,
2962                source_account: None,
2963                fee_token: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC".to_string(),
2964            },
2965        );
2966
2967        let result = relayer.quote_sponsored_transaction(request).await;
2968        if let Err(e) = &result {
2969            eprintln!("Soroban quote error: {:?}", e);
2970        }
2971        assert!(result.is_ok());
2972
2973        if let SponsoredTransactionQuoteResponse::Stellar(quote) = result.unwrap() {
2974            assert_eq!(quote.fee_in_token, "1500000");
2975            assert!(!quote.fee_in_token_ui.is_empty());
2976            assert!(!quote.conversion_rate.is_empty());
2977        } else {
2978            panic!("Expected Stellar quote response");
2979        }
2980
2981        // Clean up env var
2982        std::env::remove_var("STELLAR_TESTNET_FEE_FORWARDER_ADDRESS");
2983    }
2984
2985    #[tokio::test]
2986    #[serial]
2987    async fn test_quote_soroban_from_xdr_missing_fee_forwarder() {
2988        // Ensure env var is NOT set
2989        std::env::remove_var("STELLAR_MAINNET_FEE_FORWARDER_ADDRESS");
2990
2991        // Use mainnet network where FeeForwarder is not deployed (empty default address)
2992        let mut relayer_model = create_test_relayer_with_soroban_token();
2993        relayer_model.network = "mainnet".to_string();
2994
2995        let provider = MockStellarProviderTrait::new();
2996
2997        let mut dex_service = MockStellarDexServiceTrait::new();
2998        dex_service.expect_supported_asset_types().returning(|| {
2999            std::collections::HashSet::from([AssetType::Native, AssetType::Contract])
3000        });
3001
3002        let dex_service = Arc::new(dex_service);
3003        let relayer = create_test_relayer_instance_with_network(
3004            relayer_model,
3005            provider,
3006            dex_service,
3007            create_test_mainnet_network(),
3008        )
3009        .await;
3010
3011        let transaction_xdr = create_test_soroban_transaction_xdr();
3012        let request = SponsoredTransactionQuoteRequest::Stellar(
3013            crate::models::StellarFeeEstimateRequestParams {
3014                transaction_xdr: Some(transaction_xdr),
3015                operations: None,
3016                source_account: None,
3017                fee_token: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC".to_string(),
3018            },
3019        );
3020
3021        let result = relayer.quote_sponsored_transaction(request).await;
3022        assert!(result.is_err());
3023        let err = result.unwrap_err();
3024        assert!(matches!(err, RelayerError::ValidationError(_)));
3025        if let RelayerError::ValidationError(msg) = err {
3026            assert!(msg.contains("STELLAR_MAINNET_FEE_FORWARDER_ADDRESS"));
3027        }
3028    }
3029
3030    #[tokio::test]
3031    #[serial]
3032    async fn test_quote_soroban_from_xdr_invalid_fee_token_format() {
3033        // Set required env var for FeeForwarder (testnet network)
3034        std::env::set_var(
3035            "STELLAR_TESTNET_FEE_FORWARDER_ADDRESS",
3036            "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC",
3037        );
3038
3039        // Create relayer that allows both classic and Soroban tokens
3040        let mut policy = RelayerStellarPolicy::default();
3041        policy.fee_payment_strategy = Some(crate::models::StellarFeePaymentStrategy::User);
3042        policy.allowed_tokens = Some(vec![
3043            crate::models::StellarAllowedTokensPolicy {
3044                asset: USDC_ASSET.to_string(), // Classic asset
3045                metadata: None,
3046                max_allowed_fee: None,
3047                swap_config: None,
3048            },
3049            crate::models::StellarAllowedTokensPolicy {
3050                asset: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC".to_string(),
3051                metadata: None,
3052                max_allowed_fee: None,
3053                swap_config: None,
3054            },
3055        ]);
3056
3057        let relayer_model = RelayerRepoModel {
3058            id: "test-relayer-id".to_string(),
3059            name: "Test Relayer".to_string(),
3060            network: "testnet".to_string(),
3061            paused: false,
3062            network_type: NetworkType::Stellar,
3063            signer_id: "signer-id".to_string(),
3064            policies: RelayerNetworkPolicy::Stellar(policy),
3065            address: TEST_PK.to_string(),
3066            notification_id: Some("notification-id".to_string()),
3067            system_disabled: false,
3068            custom_rpc_urls: None,
3069            ..Default::default()
3070        };
3071
3072        let provider = MockStellarProviderTrait::new();
3073
3074        let mut dex_service = MockStellarDexServiceTrait::new();
3075        dex_service
3076            .expect_supported_asset_types()
3077            .returning(|| std::collections::HashSet::from([AssetType::Native, AssetType::Classic]));
3078
3079        let dex_service = Arc::new(dex_service);
3080        let relayer = create_test_relayer_instance(relayer_model, provider, dex_service).await;
3081
3082        // Use Soroban XDR but with classic asset as fee_token (invalid for Soroban path)
3083        let transaction_xdr = create_test_soroban_transaction_xdr();
3084        let request = SponsoredTransactionQuoteRequest::Stellar(
3085            crate::models::StellarFeeEstimateRequestParams {
3086                transaction_xdr: Some(transaction_xdr),
3087                operations: None,
3088                source_account: None,
3089                fee_token: USDC_ASSET.to_string(), // Classic asset, not valid C... format
3090            },
3091        );
3092
3093        let result = relayer.quote_sponsored_transaction(request).await;
3094        assert!(result.is_err());
3095        let err = result.unwrap_err();
3096        assert!(matches!(err, RelayerError::ValidationError(_)));
3097        if let RelayerError::ValidationError(msg) = err {
3098            assert!(msg.contains("Soroban contract address"));
3099        }
3100
3101        // Clean up env var
3102        std::env::remove_var("STELLAR_TESTNET_FEE_FORWARDER_ADDRESS");
3103    }
3104
3105    // ============================================================================
3106    // Tests for build_soroban_sponsored (via build_sponsored_transaction)
3107    // ============================================================================
3108
3109    #[tokio::test]
3110    #[serial]
3111    async fn test_build_soroban_sponsored_success() {
3112        // Set required env var for FeeForwarder (testnet network)
3113        std::env::set_var(
3114            "STELLAR_TESTNET_FEE_FORWARDER_ADDRESS",
3115            "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC",
3116        );
3117
3118        let relayer_model = create_test_relayer_with_soroban_token();
3119        let mut provider = MockStellarProviderTrait::new();
3120
3121        // Mock get_latest_ledger for expiration calculation (called twice - for simulation and for valid_until)
3122        provider.expect_get_latest_ledger().returning(|| {
3123            Box::pin(ready(Ok(
3124                soroban_rs::stellar_rpc_client::GetLatestLedgerResponse {
3125                    id: "test".to_string(),
3126                    protocol_version: 20,
3127                    sequence: 1000,
3128                },
3129            )))
3130        });
3131
3132        // Mock simulate_transaction_envelope for Soroban fee estimation
3133        let valid_tx_data = create_valid_soroban_transaction_data_xdr();
3134        provider
3135            .expect_simulate_transaction_envelope()
3136            .returning(move |_| {
3137                let tx_data = valid_tx_data.clone();
3138                Box::pin(ready(Ok(
3139                    soroban_rs::stellar_rpc_client::SimulateTransactionResponse {
3140                        min_resource_fee: 50000,
3141                        transaction_data: tx_data,
3142                        ..Default::default()
3143                    },
3144                )))
3145            });
3146
3147        // Mock call_contract for Soroban token balance check
3148        provider.expect_call_contract().returning(|_, _, _| {
3149            use soroban_rs::xdr::Int128Parts;
3150            // Return a balance of 10_000_000 (sufficient for fee)
3151            Box::pin(ready(Ok(ScVal::I128(Int128Parts {
3152                hi: 0,
3153                lo: 10_000_000,
3154            }))))
3155        });
3156
3157        let mut dex_service = MockStellarDexServiceTrait::new();
3158        dex_service.expect_supported_asset_types().returning(|| {
3159            std::collections::HashSet::from([AssetType::Native, AssetType::Contract])
3160        });
3161
3162        // Mock get_xlm_to_token_quote for fee conversion
3163        dex_service
3164            .expect_get_xlm_to_token_quote()
3165            .returning(|_, _, _, _| {
3166                Box::pin(ready(Ok(
3167                    crate::services::stellar_dex::StellarQuoteResponse {
3168                        input_asset: "native".to_string(),
3169                        output_asset: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC"
3170                            .to_string(),
3171                        in_amount: 50100,
3172                        out_amount: 1500000,
3173                        price_impact_pct: 0.0,
3174                        slippage_bps: 100,
3175                        path: None,
3176                    },
3177                )))
3178            });
3179
3180        let dex_service = Arc::new(dex_service);
3181        let relayer = create_test_relayer_instance(relayer_model, provider, dex_service).await;
3182
3183        let transaction_xdr = create_test_soroban_transaction_xdr();
3184        let request = SponsoredTransactionBuildRequest::Stellar(
3185            crate::models::StellarPrepareTransactionRequestParams {
3186                transaction_xdr: Some(transaction_xdr),
3187                operations: None,
3188                source_account: None,
3189                fee_token: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC".to_string(),
3190            },
3191        );
3192
3193        let result = relayer.build_sponsored_transaction(request).await;
3194        if let Err(e) = &result {
3195            eprintln!("Soroban build error: {:?}", e);
3196        }
3197        assert!(result.is_ok());
3198
3199        if let SponsoredTransactionBuildResponse::Stellar(build) = result.unwrap() {
3200            assert!(!build.transaction.is_empty());
3201            assert_eq!(build.fee_in_token, "1500000");
3202            assert!(!build.fee_in_token_ui.is_empty());
3203            assert_eq!(
3204                build.fee_token,
3205                "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC"
3206            );
3207            assert!(!build.valid_until.is_empty());
3208            // Soroban transactions should have user_auth_entry
3209            assert!(build.user_auth_entry.is_some());
3210            assert!(!build.user_auth_entry.unwrap().is_empty());
3211        } else {
3212            panic!("Expected Stellar build response");
3213        }
3214
3215        // Clean up env var
3216        std::env::remove_var("STELLAR_TESTNET_FEE_FORWARDER_ADDRESS");
3217    }
3218
3219    #[tokio::test]
3220    #[serial]
3221    async fn test_build_soroban_sponsored_missing_fee_forwarder() {
3222        // Ensure env var is NOT set
3223        std::env::remove_var("STELLAR_MAINNET_FEE_FORWARDER_ADDRESS");
3224
3225        // Use mainnet network where FeeForwarder is not deployed (empty default address)
3226        let mut relayer_model = create_test_relayer_with_soroban_token();
3227        relayer_model.network = "mainnet".to_string();
3228
3229        let provider = MockStellarProviderTrait::new();
3230
3231        let mut dex_service = MockStellarDexServiceTrait::new();
3232        dex_service.expect_supported_asset_types().returning(|| {
3233            std::collections::HashSet::from([AssetType::Native, AssetType::Contract])
3234        });
3235
3236        let dex_service = Arc::new(dex_service);
3237        let relayer = create_test_relayer_instance_with_network(
3238            relayer_model,
3239            provider,
3240            dex_service,
3241            create_test_mainnet_network(),
3242        )
3243        .await;
3244
3245        let transaction_xdr = create_test_soroban_transaction_xdr();
3246        let request = SponsoredTransactionBuildRequest::Stellar(
3247            crate::models::StellarPrepareTransactionRequestParams {
3248                transaction_xdr: Some(transaction_xdr),
3249                operations: None,
3250                source_account: None,
3251                fee_token: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC".to_string(),
3252            },
3253        );
3254
3255        let result = relayer.build_sponsored_transaction(request).await;
3256        assert!(result.is_err());
3257        let err = result.unwrap_err();
3258        assert!(matches!(err, RelayerError::ValidationError(_)));
3259        if let RelayerError::ValidationError(msg) = err {
3260            assert!(msg.contains("STELLAR_MAINNET_FEE_FORWARDER_ADDRESS"));
3261        }
3262    }
3263
3264    #[tokio::test]
3265    #[serial]
3266    async fn test_build_soroban_sponsored_insufficient_balance() {
3267        // Set required env var for FeeForwarder (testnet network)
3268        std::env::set_var(
3269            "STELLAR_TESTNET_FEE_FORWARDER_ADDRESS",
3270            "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC",
3271        );
3272
3273        let relayer_model = create_test_relayer_with_soroban_token();
3274        let mut provider = MockStellarProviderTrait::new();
3275
3276        // Mock get_latest_ledger
3277        provider.expect_get_latest_ledger().returning(|| {
3278            Box::pin(ready(Ok(
3279                soroban_rs::stellar_rpc_client::GetLatestLedgerResponse {
3280                    id: "test".to_string(),
3281                    protocol_version: 20,
3282                    sequence: 1000,
3283                },
3284            )))
3285        });
3286
3287        // Mock simulate_transaction_envelope
3288        provider
3289            .expect_simulate_transaction_envelope()
3290            .returning(|_| {
3291                Box::pin(ready(Ok(
3292                    soroban_rs::stellar_rpc_client::SimulateTransactionResponse {
3293                        min_resource_fee: 50000,
3294                        transaction_data: "AAAAAQAAAAAAAAACAAAAAAAAAAAAAAAAAAAABgAAAAEAAAAGAAAAAG0JZTO9fU6p3NeJp5w3TpKhZmx6p1pR7mq9wFwCnEIuAAAAFAAAAAEAAAAAAAAAB8NVb2IAAAH0AAAAAQAAAAAAABfAAAAAAAAAAPUAAAAAAAAENgAAAAA=".to_string(),
3295                        ..Default::default()
3296                    },
3297                )))
3298            });
3299
3300        // Mock call_contract with INSUFFICIENT balance
3301        provider.expect_call_contract().returning(|_, _, _| {
3302            use soroban_rs::xdr::Int128Parts;
3303            // Return a very low balance (100, much less than required 1500000)
3304            Box::pin(ready(Ok(ScVal::I128(Int128Parts { hi: 0, lo: 100 }))))
3305        });
3306
3307        let mut dex_service = MockStellarDexServiceTrait::new();
3308        dex_service.expect_supported_asset_types().returning(|| {
3309            std::collections::HashSet::from([AssetType::Native, AssetType::Contract])
3310        });
3311
3312        // Mock get_xlm_to_token_quote
3313        dex_service
3314            .expect_get_xlm_to_token_quote()
3315            .returning(|_, _, _, _| {
3316                Box::pin(ready(Ok(
3317                    crate::services::stellar_dex::StellarQuoteResponse {
3318                        input_asset: "native".to_string(),
3319                        output_asset: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC"
3320                            .to_string(),
3321                        in_amount: 50100,
3322                        out_amount: 1500000, // Fee required
3323                        price_impact_pct: 0.0,
3324                        slippage_bps: 100,
3325                        path: None,
3326                    },
3327                )))
3328            });
3329
3330        let dex_service = Arc::new(dex_service);
3331        let relayer = create_test_relayer_instance(relayer_model, provider, dex_service).await;
3332
3333        let transaction_xdr = create_test_soroban_transaction_xdr();
3334        let request = SponsoredTransactionBuildRequest::Stellar(
3335            crate::models::StellarPrepareTransactionRequestParams {
3336                transaction_xdr: Some(transaction_xdr),
3337                operations: None,
3338                source_account: None,
3339                fee_token: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC".to_string(),
3340            },
3341        );
3342
3343        let result = relayer.build_sponsored_transaction(request).await;
3344        assert!(result.is_err());
3345        let err = result.unwrap_err();
3346        assert!(matches!(err, RelayerError::ValidationError(_)));
3347        if let RelayerError::ValidationError(msg) = err {
3348            assert!(msg.contains("Insufficient balance"));
3349        }
3350
3351        // Clean up env var
3352        std::env::remove_var("STELLAR_TESTNET_FEE_FORWARDER_ADDRESS");
3353    }
3354
3355    #[tokio::test]
3356    #[serial]
3357    async fn test_build_soroban_sponsored_simulation_error() {
3358        // Set required env var for FeeForwarder (testnet network)
3359        std::env::set_var(
3360            "STELLAR_TESTNET_FEE_FORWARDER_ADDRESS",
3361            "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC",
3362        );
3363
3364        let relayer_model = create_test_relayer_with_soroban_token();
3365        let mut provider = MockStellarProviderTrait::new();
3366
3367        // Mock get_latest_ledger
3368        provider.expect_get_latest_ledger().returning(|| {
3369            Box::pin(ready(Ok(
3370                soroban_rs::stellar_rpc_client::GetLatestLedgerResponse {
3371                    id: "test".to_string(),
3372                    protocol_version: 20,
3373                    sequence: 1000,
3374                },
3375            )))
3376        });
3377
3378        // Mock simulate_transaction_envelope to return error
3379        provider
3380            .expect_simulate_transaction_envelope()
3381            .returning(|_| {
3382                Box::pin(ready(Ok(
3383                    soroban_rs::stellar_rpc_client::SimulateTransactionResponse {
3384                        error: Some(
3385                            "Contract execution failed: insufficient resources".to_string(),
3386                        ),
3387                        min_resource_fee: 0,
3388                        transaction_data: "".to_string(),
3389                        ..Default::default()
3390                    },
3391                )))
3392            });
3393
3394        let mut dex_service = MockStellarDexServiceTrait::new();
3395        dex_service.expect_supported_asset_types().returning(|| {
3396            std::collections::HashSet::from([AssetType::Native, AssetType::Contract])
3397        });
3398
3399        // Mock get_xlm_to_token_quote for initial fee estimation
3400        dex_service
3401            .expect_get_xlm_to_token_quote()
3402            .returning(|_, _, _, _| {
3403                Box::pin(ready(Ok(
3404                    crate::services::stellar_dex::StellarQuoteResponse {
3405                        input_asset: "native".to_string(),
3406                        output_asset: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC"
3407                            .to_string(),
3408                        in_amount: 100,
3409                        out_amount: 1500,
3410                        price_impact_pct: 0.0,
3411                        slippage_bps: 100,
3412                        path: None,
3413                    },
3414                )))
3415            });
3416
3417        let dex_service = Arc::new(dex_service);
3418        let relayer = create_test_relayer_instance(relayer_model, provider, dex_service).await;
3419
3420        let transaction_xdr = create_test_soroban_transaction_xdr();
3421        let request = SponsoredTransactionBuildRequest::Stellar(
3422            crate::models::StellarPrepareTransactionRequestParams {
3423                transaction_xdr: Some(transaction_xdr),
3424                operations: None,
3425                source_account: None,
3426                fee_token: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC".to_string(),
3427            },
3428        );
3429
3430        let result = relayer.build_sponsored_transaction(request).await;
3431        assert!(result.is_err());
3432        let err = result.unwrap_err();
3433        // Simulation errors are wrapped in ValidationError via calculate_total_soroban_fee
3434        assert!(matches!(err, RelayerError::ValidationError(_)));
3435        if let RelayerError::ValidationError(msg) = err {
3436            assert!(msg.contains("Simulation failed"));
3437        }
3438
3439        // Clean up env var
3440        std::env::remove_var("STELLAR_TESTNET_FEE_FORWARDER_ADDRESS");
3441    }
3442}