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;
10
11use crate::constants::{
12    get_stellar_sponsored_transaction_validity_duration, STELLAR_DEFAULT_TRANSACTION_FEE,
13};
14use crate::domain::relayer::{
15    stellar::xdr_utils::parse_transaction_xdr, GasAbstractionTrait, RelayerError, StellarRelayer,
16};
17use crate::domain::transaction::stellar::{
18    utils::{
19        add_operation_to_envelope, convert_xlm_fee_to_token, create_fee_payment_operation,
20        estimate_fee, set_time_bounds, FeeQuote,
21    },
22    StellarTransactionValidator,
23};
24use crate::domain::xdr_needs_simulation;
25use crate::jobs::JobProducerTrait;
26use crate::models::{
27    transaction::stellar::OperationSpec, SponsoredTransactionBuildRequest,
28    SponsoredTransactionBuildResponse, SponsoredTransactionQuoteRequest,
29    SponsoredTransactionQuoteResponse, StellarFeeEstimateResult, StellarPrepareTransactionResult,
30    StellarTransactionData, TransactionInput,
31};
32use crate::models::{NetworkRepoModel, RelayerRepoModel, TransactionRepoModel};
33use crate::repositories::{
34    NetworkRepository, RelayerRepository, Repository, TransactionRepository,
35};
36use crate::services::provider::StellarProviderTrait;
37use crate::services::signer::StellarSignTrait;
38use crate::services::stellar_dex::StellarDexServiceTrait;
39use crate::services::TransactionCounterServiceTrait;
40
41#[async_trait]
42impl<P, RR, NR, TR, J, TCS, S, D> GasAbstractionTrait
43    for StellarRelayer<P, RR, NR, TR, J, TCS, S, D>
44where
45    P: StellarProviderTrait + Send + Sync,
46    D: StellarDexServiceTrait + Send + Sync + 'static,
47    RR: Repository<RelayerRepoModel, String> + RelayerRepository + Send + Sync + 'static,
48    NR: NetworkRepository + Repository<NetworkRepoModel, String> + Send + Sync + 'static,
49    TR: Repository<TransactionRepoModel, String> + TransactionRepository + Send + Sync + 'static,
50    J: JobProducerTrait + Send + Sync + 'static,
51    TCS: TransactionCounterServiceTrait + Send + Sync + 'static,
52    S: StellarSignTrait + Send + Sync + 'static,
53{
54    async fn quote_sponsored_transaction(
55        &self,
56        params: SponsoredTransactionQuoteRequest,
57    ) -> Result<SponsoredTransactionQuoteResponse, RelayerError> {
58        let params = match params {
59            SponsoredTransactionQuoteRequest::Stellar(p) => p,
60            _ => {
61                return Err(RelayerError::ValidationError(
62                    "Expected Stellar fee estimate request parameters".to_string(),
63                ));
64            }
65        };
66        debug!(
67            "Processing quote sponsored transaction request for token: {}",
68            params.fee_token
69        );
70
71        // Validate allowed token
72        let policy = self.relayer.policies.get_stellar_policy();
73        StellarTransactionValidator::validate_allowed_token(&params.fee_token, &policy)
74            .map_err(|e| RelayerError::ValidationError(e.to_string()))?;
75
76        // Validate that either transaction_xdr or operations is provided
77        if params.transaction_xdr.is_none() && params.operations.is_none() {
78            return Err(RelayerError::ValidationError(
79                "Must provide either transaction_xdr or operations in the request".to_string(),
80            ));
81        }
82
83        // Build envelope from XDR or operations (reusing logic from build method)
84        let envelope = build_envelope_from_request(
85            params.transaction_xdr.as_ref(),
86            params.operations.as_ref(),
87            params.source_account.as_ref(),
88            &self.network.passphrase,
89            &self.provider,
90        )
91        .await?;
92
93        // Run comprehensive security validation (similar to build method)
94        StellarTransactionValidator::gasless_transaction_validation(
95            &envelope,
96            &self.relayer.address,
97            &policy,
98            &self.provider,
99            None, // Duration validation not needed for quote
100        )
101        .await
102        .map_err(|e| {
103            RelayerError::ValidationError(format!("Failed to validate gasless transaction: {e}"))
104        })?;
105
106        // Estimate fee using estimate_fee utility which handles simulation if needed
107        let inner_tx_fee = estimate_fee(&envelope, &self.provider, None)
108            .await
109            .map_err(crate::models::RelayerError::from)?;
110
111        // Add fees for fee payment operation (100 stroops) and fee-bump transaction (100 stroops)
112        // For Soroban transactions, the simulation already accounts for resource fees,
113        // we just need to add the inclusion fees for the additional operations
114        let is_soroban = xdr_needs_simulation(&envelope).unwrap_or(false);
115        let mut additional_fees = 0;
116        if !is_soroban {
117            additional_fees = 2 * STELLAR_DEFAULT_TRANSACTION_FEE as u64; // 200 stroops total
118        }
119        let xlm_fee = inner_tx_fee + additional_fees;
120
121        // Convert to token amount via DEX service
122        let fee_quote = convert_xlm_fee_to_token(
123            self.dex_service.as_ref(),
124            &policy,
125            xlm_fee,
126            &params.fee_token,
127        )
128        .await
129        .map_err(crate::models::RelayerError::from)?;
130
131        // Validate max fee
132        StellarTransactionValidator::validate_max_fee(fee_quote.fee_in_stroops, &policy)
133            .map_err(|e| RelayerError::ValidationError(e.to_string()))?;
134
135        // Validate token-specific max fee
136        StellarTransactionValidator::validate_token_max_fee(
137            &params.fee_token,
138            fee_quote.fee_in_token,
139            &policy,
140        )
141        .map_err(|e| RelayerError::ValidationError(e.to_string()))?;
142
143        // Check user token balance to ensure they have enough to pay the fee
144        StellarTransactionValidator::validate_user_token_balance(
145            &envelope,
146            &params.fee_token,
147            fee_quote.fee_in_token,
148            &self.provider,
149        )
150        .await
151        .map_err(|e| RelayerError::ValidationError(e.to_string()))?;
152
153        debug!("Fee estimate result: {:?}", fee_quote);
154
155        let result = StellarFeeEstimateResult {
156            fee_in_token_ui: fee_quote.fee_in_token_ui,
157            fee_in_token: fee_quote.fee_in_token.to_string(),
158            conversion_rate: fee_quote.conversion_rate.to_string(),
159        };
160        Ok(SponsoredTransactionQuoteResponse::Stellar(result))
161    }
162
163    async fn build_sponsored_transaction(
164        &self,
165        params: SponsoredTransactionBuildRequest,
166    ) -> Result<SponsoredTransactionBuildResponse, RelayerError> {
167        let params = match params {
168            SponsoredTransactionBuildRequest::Stellar(p) => p,
169            _ => {
170                return Err(RelayerError::ValidationError(
171                    "Expected Stellar prepare transaction request parameters".to_string(),
172                ));
173            }
174        };
175        debug!(
176            "Processing prepare transaction request for token: {}",
177            params.fee_token
178        );
179
180        // Validate allowed token
181        let policy = self.relayer.policies.get_stellar_policy();
182        StellarTransactionValidator::validate_allowed_token(&params.fee_token, &policy)
183            .map_err(|e| RelayerError::ValidationError(e.to_string()))?;
184
185        // Validate that either transaction_xdr or operations is provided
186        if params.transaction_xdr.is_none() && params.operations.is_none() {
187            return Err(RelayerError::ValidationError(
188                "Must provide either transaction_xdr or operations in the request".to_string(),
189            ));
190        }
191
192        // Build envelope from XDR or operations (reusing shared helper)
193        let envelope = build_envelope_from_request(
194            params.transaction_xdr.as_ref(),
195            params.operations.as_ref(),
196            params.source_account.as_ref(),
197            &self.network.passphrase,
198            &self.provider,
199        )
200        .await?;
201
202        StellarTransactionValidator::gasless_transaction_validation(
203            &envelope,
204            &self.relayer.address,
205            &policy,
206            &self.provider,
207            None, // Duration validation not needed here as time bounds are set during build
208        )
209        .await
210        .map_err(|e| {
211            RelayerError::ValidationError(format!("Failed to validate gasless transaction: {e}"))
212        })?;
213
214        // Get fee estimate using estimate_fee utility which handles simulation if needed
215        // For non-Soroban transactions, we'll add 200 stroops (100 for fee payment op + 100 for fee-bump)
216        let inner_tx_fee = estimate_fee(&envelope, &self.provider, None)
217            .await
218            .map_err(crate::models::RelayerError::from)?;
219
220        // Add fees for fee payment operation (100 stroops) and fee-bump transaction (100 stroops)
221        // For Soroban transactions, the simulation already accounts for resource fees,
222        // we just need to add the inclusion fees for the additional operations
223        let is_soroban = xdr_needs_simulation(&envelope).unwrap_or(false);
224        let mut additional_fees = 0;
225        if !is_soroban {
226            additional_fees = 2 * STELLAR_DEFAULT_TRANSACTION_FEE as u64; // 200 stroops total
227        }
228        let xlm_fee = inner_tx_fee + additional_fees;
229
230        debug!(
231            inner_tx_fee = inner_tx_fee,
232            additional_fees = additional_fees,
233            total_fee = xlm_fee,
234            "Fee estimated: inner transaction + fee payment op + fee-bump transaction fee"
235        );
236
237        // Calculate fee quote first to check user balance before modifying envelope
238        let preliminary_fee_quote = convert_xlm_fee_to_token(
239            self.dex_service.as_ref(),
240            &policy,
241            xlm_fee,
242            &params.fee_token,
243        )
244        .await
245        .map_err(crate::models::RelayerError::from)?;
246
247        // Validate max fee
248        StellarTransactionValidator::validate_max_fee(
249            preliminary_fee_quote.fee_in_stroops,
250            &policy,
251        )
252        .map_err(|e| RelayerError::ValidationError(e.to_string()))?;
253
254        // Validate token-specific max fee
255        StellarTransactionValidator::validate_token_max_fee(
256            &params.fee_token,
257            preliminary_fee_quote.fee_in_token,
258            &policy,
259        )
260        .map_err(|e| RelayerError::ValidationError(e.to_string()))?;
261
262        // Check user token balance to ensure they have enough to pay the fee
263        StellarTransactionValidator::validate_user_token_balance(
264            &envelope,
265            &params.fee_token,
266            preliminary_fee_quote.fee_in_token,
267            &self.provider,
268        )
269        .await
270        .map_err(|e| RelayerError::ValidationError(e.to_string()))?;
271
272        // Add payment operation using the validated fee quote
273        let mut final_envelope = add_payment_operation_to_envelope(
274            envelope,
275            &preliminary_fee_quote,
276            &params.fee_token,
277            &self.relayer.address,
278        )?;
279
280        // Use the validated fee quote (no duplicate DEX call)
281        let fee_quote = preliminary_fee_quote;
282
283        debug!(
284            estimated_fee = xlm_fee,
285            final_fee_in_token = fee_quote.fee_in_token_ui,
286            "Transaction prepared successfully"
287        );
288
289        // Set final time bounds just before returning to give user maximum time to review and submit
290        let valid_until = Utc::now() + get_stellar_sponsored_transaction_validity_duration();
291        set_time_bounds(&mut final_envelope, valid_until)
292            .map_err(crate::models::RelayerError::from)?;
293
294        // Serialize final transaction
295        let extended_xdr = final_envelope
296            .to_xdr_base64(Limits::none())
297            .map_err(|e| RelayerError::Internal(format!("Failed to serialize XDR: {e}")))?;
298
299        Ok(SponsoredTransactionBuildResponse::Stellar(
300            StellarPrepareTransactionResult {
301                transaction: extended_xdr,
302                fee_in_token: fee_quote.fee_in_token.to_string(),
303                fee_in_token_ui: fee_quote.fee_in_token_ui,
304                fee_in_stroops: fee_quote.fee_in_stroops.to_string(),
305                fee_token: params.fee_token,
306                valid_until: valid_until.to_rfc3339(),
307            },
308        ))
309    }
310}
311
312/// Add payment operation to envelope using a pre-computed fee quote
313///
314/// This function adds a fee payment operation to the transaction envelope using
315/// a pre-computed FeeQuote. This avoids duplicate DEX calls and ensures the
316/// validated fee quote matches the fee amount in the payment operation.
317///
318/// Note: Time bounds should be set separately just before returning the transaction
319/// to give the user maximum time to review and submit.
320///
321/// # Arguments
322/// * `envelope` - The transaction envelope to add the payment operation to
323/// * `fee_quote` - Pre-computed fee quote containing the token amount to charge
324/// * `fee_token` - Asset identifier for the fee token
325/// * `relayer_address` - Address of the relayer receiving the fee payment
326///
327/// # Returns
328/// The updated envelope with the payment operation added (if not Soroban)
329fn add_payment_operation_to_envelope(
330    mut envelope: TransactionEnvelope,
331    fee_quote: &FeeQuote,
332    fee_token: &str,
333    relayer_address: &str,
334) -> Result<TransactionEnvelope, RelayerError> {
335    // Convert fee amount to i64 for payment operation
336    let fee_amount = i64::try_from(fee_quote.fee_in_token).map_err(|_| {
337        RelayerError::Internal(
338            "Fee amount too large for payment operation (exceeds i64::MAX)".to_string(),
339        )
340    })?;
341
342    let is_soroban = xdr_needs_simulation(&envelope).unwrap_or(false);
343    // For Soroban we don't add the fee payment operation because of Soroban limitation to allow just single operation in the transaction
344    if !is_soroban {
345        // Add fee payment operation to envelope
346        add_fee_payment_operation(&mut envelope, fee_token, fee_amount, relayer_address)?;
347    }
348
349    Ok(envelope)
350}
351
352/// Build a transaction envelope from either XDR or operations
353///
354/// This helper function is used by both quote and build methods to construct
355/// a transaction envelope from either a pre-built XDR transaction or from
356/// operations with a source account.
357///
358/// When building from operations, this function fetches the user's current
359/// sequence number from the network to ensure the transaction can be properly
360/// signed and submitted by the user.
361async fn build_envelope_from_request<P>(
362    transaction_xdr: Option<&String>,
363    operations: Option<&Vec<OperationSpec>>,
364    source_account: Option<&String>,
365    network_passphrase: &str,
366    provider: &P,
367) -> Result<TransactionEnvelope, RelayerError>
368where
369    P: StellarProviderTrait + Send + Sync,
370{
371    if let Some(xdr) = transaction_xdr {
372        parse_transaction_xdr(xdr, false)
373            .map_err(|e| RelayerError::Internal(format!("Failed to parse XDR: {e}")))
374    } else if let Some(ops) = operations {
375        // Build envelope from operations
376        let source_account = source_account.ok_or_else(|| {
377            RelayerError::ValidationError(
378                "source_account is required when providing operations".to_string(),
379            )
380        })?;
381
382        // Create StellarTransactionData from operations
383        // Fetch the user's current sequence number from the network
384        // This is required because the user will sign the transaction with their account
385        let account_entry = provider.get_account(source_account).await.map_err(|e| {
386            RelayerError::Internal(format!(
387                "Failed to fetch account sequence number for {source_account}: {e}",
388            ))
389        })?;
390
391        // Use the next sequence number (current + 1)
392        let next_sequence = account_entry.seq_num.0 + 1;
393
394        let stellar_data = StellarTransactionData {
395            source_account: source_account.clone(),
396            fee: None,
397            sequence_number: Some(next_sequence as i64),
398            memo: None,
399            valid_until: None,
400            network_passphrase: network_passphrase.to_string(),
401            signatures: vec![],
402            hash: None,
403            simulation_transaction_data: None,
404            transaction_input: TransactionInput::Operations(ops.clone()),
405            signed_envelope_xdr: None,
406        };
407
408        // Build unsigned envelope from operations
409        stellar_data.build_unsigned_envelope().map_err(|e| {
410            RelayerError::Internal(format!("Failed to build envelope from operations: {e}"))
411        })
412    } else {
413        Err(RelayerError::ValidationError(
414            "Must provide either transaction_xdr or operations in the request".to_string(),
415        ))
416    }
417}
418
419/// Add a fee payment operation to the transaction envelope
420fn add_fee_payment_operation(
421    envelope: &mut TransactionEnvelope,
422    fee_token: &str,
423    fee_amount: i64,
424    relayer_address: &str,
425) -> Result<(), RelayerError> {
426    let payment_op_spec = create_fee_payment_operation(relayer_address, fee_token, fee_amount)
427        .map_err(crate::models::RelayerError::from)?;
428
429    // Convert OperationSpec to XDR Operation
430    let payment_op = Operation::try_from(payment_op_spec)
431        .map_err(|e| RelayerError::Internal(format!("Failed to convert payment operation: {e}")))?;
432
433    // Add payment operation to transaction
434    add_operation_to_envelope(envelope, payment_op).map_err(crate::models::RelayerError::from)?;
435
436    Ok(())
437}
438
439#[cfg(test)]
440mod tests {
441    use super::*;
442    use crate::domain::transaction::stellar::utils::parse_account_id;
443    use crate::services::stellar_dex::AssetType;
444    use crate::{
445        config::{NetworkConfigCommon, StellarNetworkConfig},
446        jobs::MockJobProducerTrait,
447        models::{
448            transaction::stellar::OperationSpec, AssetSpec, NetworkConfigData, NetworkRepoModel,
449            NetworkType, RelayerNetworkPolicy, RelayerRepoModel, RelayerStellarPolicy,
450            SponsoredTransactionBuildRequest, SponsoredTransactionQuoteRequest,
451        },
452        repositories::{
453            InMemoryNetworkRepository, MockRelayerRepository, MockTransactionRepository,
454        },
455        services::{
456            provider::MockStellarProviderTrait, signer::MockStellarSignTrait,
457            stellar_dex::MockStellarDexServiceTrait, MockTransactionCounterServiceTrait,
458        },
459    };
460    use mockall::predicate::*;
461    use soroban_rs::stellar_rpc_client::GetLedgerEntriesResponse;
462    use soroban_rs::stellar_rpc_client::LedgerEntryResult;
463    use soroban_rs::xdr::{
464        AccountEntry, AccountEntryExt, AccountId, AlphaNum4, AssetCode4, LedgerEntry,
465        LedgerEntryData, LedgerEntryExt, LedgerKey, Limits, MuxedAccount, Operation, OperationBody,
466        PaymentOp, Preconditions, PublicKey, SequenceNumber, String32, Thresholds, Transaction,
467        TransactionEnvelope, TransactionExt, TransactionV1Envelope, TrustLineEntry,
468        TrustLineEntryExt, Uint256, VecM, WriteXdr,
469    };
470    use std::future::ready;
471    use std::sync::Arc;
472    use stellar_strkey::ed25519::PublicKey as Ed25519PublicKey;
473
474    const TEST_PK: &str = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF";
475    const TEST_NETWORK_PASSPHRASE: &str = "Test SDF Network ; September 2015";
476    const USDC_ASSET: &str = "USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN";
477
478    /// Helper function to create a test transaction XDR
479    fn create_test_transaction_xdr() -> String {
480        // Use a different account than TEST_PK (relayer address) to avoid validation error
481        let source_pk = Ed25519PublicKey::from_string(
482            "GCZ54QGQCUZ6U5WJF4AG5JEZCUMYTS2F6JRLUS76XF2PQMEJ2E3JISI2",
483        )
484        .unwrap();
485        let dest_pk = Ed25519PublicKey::from_string(
486            "GCEZWKCA5VLDNRLN3RPRJMRZOX3Z6G5CHCGSNFHEYVXM3XOJMDS674JZ",
487        )
488        .unwrap();
489
490        let payment_op = PaymentOp {
491            destination: MuxedAccount::Ed25519(Uint256(dest_pk.0)),
492            asset: soroban_rs::xdr::Asset::Native,
493            amount: 1000000,
494        };
495
496        let operation = Operation {
497            source_account: None,
498            body: OperationBody::Payment(payment_op),
499        };
500
501        let operations: VecM<Operation, 100> = vec![operation].try_into().unwrap();
502
503        let tx = Transaction {
504            source_account: MuxedAccount::Ed25519(Uint256(source_pk.0)),
505            fee: 100,
506            seq_num: SequenceNumber(2), // Must be > account sequence (1)
507            cond: Preconditions::None,
508            memo: soroban_rs::xdr::Memo::None,
509            operations,
510            ext: TransactionExt::V0,
511        };
512
513        let envelope = TransactionV1Envelope {
514            tx,
515            signatures: vec![].try_into().unwrap(),
516        };
517
518        let tx_envelope = TransactionEnvelope::Tx(envelope);
519        tx_envelope.to_xdr_base64(Limits::none()).unwrap()
520    }
521
522    /// Helper function to create a test relayer with user fee payment strategy
523    fn create_test_relayer_with_user_fee_strategy() -> RelayerRepoModel {
524        let mut policy = RelayerStellarPolicy::default();
525        policy.fee_payment_strategy = Some(crate::models::StellarFeePaymentStrategy::User);
526        policy.allowed_tokens = Some(vec![crate::models::StellarAllowedTokensPolicy {
527            asset: USDC_ASSET.to_string(),
528            metadata: None,
529            max_allowed_fee: None,
530            swap_config: None,
531        }]);
532
533        RelayerRepoModel {
534            id: "test-relayer-id".to_string(),
535            name: "Test Relayer".to_string(),
536            network: "testnet".to_string(),
537            paused: false,
538            network_type: NetworkType::Stellar,
539            signer_id: "signer-id".to_string(),
540            policies: RelayerNetworkPolicy::Stellar(policy),
541            address: TEST_PK.to_string(),
542            notification_id: Some("notification-id".to_string()),
543            system_disabled: false,
544            custom_rpc_urls: None,
545            ..Default::default()
546        }
547    }
548
549    /// Helper function to create a mock DEX service
550    fn create_mock_dex_service() -> Arc<MockStellarDexServiceTrait> {
551        let mut mock_dex = MockStellarDexServiceTrait::new();
552        mock_dex
553            .expect_supported_asset_types()
554            .returning(|| std::collections::HashSet::from([AssetType::Native, AssetType::Classic]));
555        Arc::new(mock_dex)
556    }
557
558    /// Helper function to create a test network
559    fn create_test_network() -> NetworkRepoModel {
560        NetworkRepoModel {
561            id: "stellar:testnet".to_string(),
562            name: "testnet".to_string(),
563            network_type: NetworkType::Stellar,
564            config: NetworkConfigData::Stellar(StellarNetworkConfig {
565                common: NetworkConfigCommon {
566                    network: "testnet".to_string(),
567                    from: None,
568                    rpc_urls: Some(vec!["https://horizon-testnet.stellar.org".to_string()]),
569                    explorer_urls: None,
570                    average_blocktime_ms: Some(5000),
571                    is_testnet: Some(true),
572                    tags: None,
573                },
574                passphrase: Some(TEST_NETWORK_PASSPHRASE.to_string()),
575                horizon_url: Some("https://horizon-testnet.stellar.org".to_string()),
576            }),
577        }
578    }
579
580    /// Helper function to create a Stellar relayer instance for testing
581    async fn create_test_relayer_instance(
582        relayer_model: RelayerRepoModel,
583        provider: MockStellarProviderTrait,
584        dex_service: Arc<MockStellarDexServiceTrait>,
585    ) -> crate::domain::relayer::stellar::StellarRelayer<
586        MockStellarProviderTrait,
587        MockRelayerRepository,
588        InMemoryNetworkRepository,
589        MockTransactionRepository,
590        MockJobProducerTrait,
591        MockTransactionCounterServiceTrait,
592        MockStellarSignTrait,
593        MockStellarDexServiceTrait,
594    > {
595        let network_repository = Arc::new(InMemoryNetworkRepository::new());
596        let test_network = create_test_network();
597        network_repository.create(test_network).await.unwrap();
598
599        let relayer_repo = Arc::new(MockRelayerRepository::new());
600        let tx_repo = Arc::new(MockTransactionRepository::new());
601        let job_producer = Arc::new(MockJobProducerTrait::new());
602        let counter = Arc::new(MockTransactionCounterServiceTrait::new());
603        let signer = Arc::new(MockStellarSignTrait::new());
604
605        crate::domain::relayer::stellar::StellarRelayer::new(
606            relayer_model,
607            signer,
608            provider,
609            crate::domain::relayer::stellar::StellarRelayerDependencies::new(
610                relayer_repo,
611                network_repository,
612                tx_repo,
613                counter,
614                job_producer,
615            ),
616            dex_service,
617        )
618        .await
619        .unwrap()
620    }
621
622    #[tokio::test]
623    async fn test_quote_sponsored_transaction_with_xdr() {
624        let relayer_model = create_test_relayer_with_user_fee_strategy();
625        let mut provider = MockStellarProviderTrait::new();
626
627        // Mock account for validation
628        provider.expect_get_account().returning(|_| {
629            Box::pin(ready(Ok(AccountEntry {
630                account_id: AccountId(PublicKey::PublicKeyTypeEd25519(Uint256([0; 32]))),
631                balance: 1000000000,
632                seq_num: SequenceNumber(1),
633                num_sub_entries: 0,
634                inflation_dest: None,
635                flags: 0,
636                home_domain: String32::default(),
637                thresholds: Thresholds([0; 4]),
638                signers: VecM::default(),
639                ext: AccountEntryExt::V0,
640            })))
641        });
642
643        // Mock get_ledger_entries for token balance validation
644        // This mock extracts the account ID from the ledger key and returns a trustline with sufficient balance
645        provider.expect_get_ledger_entries().returning(|keys| {
646            // Extract account ID from the first ledger key (should be a Trustline key)
647            let account_id = if let Some(LedgerKey::Trustline(trustline_key)) = keys.first() {
648                trustline_key.account_id.clone()
649            } else {
650                // Fallback: try to parse TEST_PK
651                parse_account_id(TEST_PK).unwrap_or_else(|_| {
652                    AccountId(PublicKey::PublicKeyTypeEd25519(Uint256([0; 32])))
653                })
654            };
655
656            let issuer_id =
657                parse_account_id("GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN")
658                    .unwrap_or_else(|_| {
659                        AccountId(PublicKey::PublicKeyTypeEd25519(Uint256([0; 32])))
660                    });
661
662            // Create a trustline entry with sufficient balance
663            let trustline_entry = TrustLineEntry {
664                account_id,
665                asset: soroban_rs::xdr::TrustLineAsset::CreditAlphanum4(AlphaNum4 {
666                    asset_code: AssetCode4(*b"USDC"),
667                    issuer: issuer_id,
668                }),
669                balance: 10_000_000i64,
670                limit: i64::MAX,
671                flags: 0,
672                ext: TrustLineEntryExt::V0,
673            };
674
675            let ledger_entry = LedgerEntry {
676                last_modified_ledger_seq: 0,
677                data: LedgerEntryData::Trustline(trustline_entry),
678                ext: LedgerEntryExt::V0,
679            };
680
681            // Encode LedgerEntryData to XDR base64 (not the full LedgerEntry)
682            let xdr = ledger_entry
683                .data
684                .to_xdr_base64(soroban_rs::xdr::Limits::none())
685                .expect("Failed to encode trustline entry data to XDR");
686
687            Box::pin(ready(Ok(GetLedgerEntriesResponse {
688                entries: Some(vec![LedgerEntryResult {
689                    key: "test_key".to_string(),
690                    xdr,
691                    last_modified_ledger: 0u32,
692                    live_until_ledger_seq_ledger_seq: None,
693                }]),
694                latest_ledger: 0,
695            })))
696        });
697
698        let mut dex_service = MockStellarDexServiceTrait::new();
699        dex_service
700            .expect_supported_asset_types()
701            .returning(|| std::collections::HashSet::from([AssetType::Native, AssetType::Classic]));
702
703        // Mock get_xlm_to_token_quote for fee conversion (XLM -> token)
704        dex_service
705            .expect_get_xlm_to_token_quote()
706            .returning(|_, _, _, _| {
707                Box::pin(ready(Ok(
708                    crate::services::stellar_dex::StellarQuoteResponse {
709                        input_asset: "native".to_string(),
710                        output_asset: USDC_ASSET.to_string(),
711                        in_amount: 100000,
712                        out_amount: 1500000,
713                        price_impact_pct: 0.0,
714                        slippage_bps: 100,
715                        path: None,
716                    },
717                )))
718            });
719
720        let dex_service = Arc::new(dex_service);
721        let relayer = create_test_relayer_instance(relayer_model, provider, dex_service).await;
722
723        let transaction_xdr = create_test_transaction_xdr();
724        let request = SponsoredTransactionQuoteRequest::Stellar(
725            crate::models::StellarFeeEstimateRequestParams {
726                transaction_xdr: Some(transaction_xdr),
727                operations: None,
728                source_account: None,
729                fee_token: USDC_ASSET.to_string(),
730            },
731        );
732
733        let result = relayer.quote_sponsored_transaction(request).await;
734        if let Err(e) = &result {
735            eprintln!("Quote error: {:?}", e);
736        }
737        assert!(result.is_ok());
738
739        if let SponsoredTransactionQuoteResponse::Stellar(quote) = result.unwrap() {
740            assert_eq!(quote.fee_in_token, "1500000");
741            assert!(!quote.fee_in_token_ui.is_empty());
742            assert!(!quote.conversion_rate.is_empty());
743        } else {
744            panic!("Expected Stellar quote response");
745        }
746    }
747
748    #[tokio::test]
749    async fn test_quote_sponsored_transaction_with_operations() {
750        let relayer_model = create_test_relayer_with_user_fee_strategy();
751        let mut provider = MockStellarProviderTrait::new();
752
753        provider.expect_get_account().returning(|_| {
754            Box::pin(ready(Ok(AccountEntry {
755                account_id: AccountId(PublicKey::PublicKeyTypeEd25519(Uint256([0; 32]))),
756                balance: 1000000000,
757                seq_num: SequenceNumber(-1),
758                num_sub_entries: 0,
759                inflation_dest: None,
760                flags: 0,
761                home_domain: String32::default(),
762                thresholds: Thresholds([0; 4]),
763                signers: VecM::default(),
764                ext: AccountEntryExt::V0,
765            })))
766        });
767
768        // Mock get_ledger_entries for token balance validation
769        // This mock extracts the account ID from the ledger key and returns a trustline with sufficient balance
770        provider.expect_get_ledger_entries().returning(|keys| {
771            // Extract account ID from the first ledger key (should be a Trustline key)
772            let account_id = if let Some(LedgerKey::Trustline(trustline_key)) = keys.first() {
773                trustline_key.account_id.clone()
774            } else {
775                // Fallback: use the source account from the test
776                parse_account_id("GCZ54QGQCUZ6U5WJF4AG5JEZCUMYTS2F6JRLUS76XF2PQMEJ2E3JISI2")
777                    .unwrap_or_else(|_| {
778                        AccountId(PublicKey::PublicKeyTypeEd25519(Uint256([0; 32])))
779                    })
780            };
781
782            let issuer_id =
783                parse_account_id("GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN")
784                    .unwrap_or_else(|_| {
785                        AccountId(PublicKey::PublicKeyTypeEd25519(Uint256([0; 32])))
786                    });
787
788            // Create a trustline entry with sufficient balance
789            let trustline_entry = TrustLineEntry {
790                account_id,
791                asset: soroban_rs::xdr::TrustLineAsset::CreditAlphanum4(AlphaNum4 {
792                    asset_code: AssetCode4(*b"USDC"),
793                    issuer: issuer_id,
794                }),
795                balance: 10_000_000i64,
796                limit: i64::MAX,
797                flags: 0,
798                ext: TrustLineEntryExt::V0,
799            };
800
801            let ledger_entry = LedgerEntry {
802                last_modified_ledger_seq: 0,
803                data: LedgerEntryData::Trustline(trustline_entry),
804                ext: LedgerEntryExt::V0,
805            };
806
807            // Encode LedgerEntryData to XDR base64 (not the full LedgerEntry)
808            let xdr = ledger_entry
809                .data
810                .to_xdr_base64(soroban_rs::xdr::Limits::none())
811                .expect("Failed to encode trustline entry data to XDR");
812
813            Box::pin(ready(Ok(
814                soroban_rs::stellar_rpc_client::GetLedgerEntriesResponse {
815                    entries: Some(vec![LedgerEntryResult {
816                        key: "test_key".to_string(),
817                        xdr,
818                        last_modified_ledger: 0u32,
819                        live_until_ledger_seq_ledger_seq: None,
820                    }]),
821                    latest_ledger: 0,
822                },
823            )))
824        });
825
826        let mut dex_service = MockStellarDexServiceTrait::new();
827        dex_service
828            .expect_supported_asset_types()
829            .returning(|| std::collections::HashSet::from([AssetType::Native, AssetType::Classic]));
830
831        // Mock get_xlm_to_token_quote for fee conversion (XLM -> token)
832        dex_service
833            .expect_get_xlm_to_token_quote()
834            .returning(|_, _, _, _| {
835                Box::pin(ready(Ok(
836                    crate::services::stellar_dex::StellarQuoteResponse {
837                        input_asset: "native".to_string(),
838                        output_asset: USDC_ASSET.to_string(),
839                        in_amount: 100000,
840                        out_amount: 1500000,
841                        price_impact_pct: 0.0,
842                        slippage_bps: 100,
843                        path: None,
844                    },
845                )))
846            });
847
848        let dex_service = Arc::new(dex_service);
849        let relayer = create_test_relayer_instance(relayer_model, provider, dex_service).await;
850
851        let operations = vec![OperationSpec::Payment {
852            destination: TEST_PK.to_string(),
853            amount: 1000000,
854            asset: AssetSpec::Native,
855        }];
856
857        let request = SponsoredTransactionQuoteRequest::Stellar(
858            crate::models::StellarFeeEstimateRequestParams {
859                transaction_xdr: None,
860                operations: Some(operations),
861                source_account: Some(
862                    "GCZ54QGQCUZ6U5WJF4AG5JEZCUMYTS2F6JRLUS76XF2PQMEJ2E3JISI2".to_string(),
863                ),
864                fee_token: USDC_ASSET.to_string(),
865            },
866        );
867
868        let result = relayer.quote_sponsored_transaction(request).await;
869        if let Err(e) = &result {
870            eprintln!("Quote error: {:?}", e);
871        }
872        assert!(result.is_ok());
873    }
874
875    #[tokio::test]
876    async fn test_quote_sponsored_transaction_invalid_token() {
877        let relayer_model = create_test_relayer_with_user_fee_strategy();
878        let provider = MockStellarProviderTrait::new();
879        let dex_service = create_mock_dex_service();
880        let relayer = create_test_relayer_instance(relayer_model, provider, dex_service).await;
881
882        let transaction_xdr = create_test_transaction_xdr();
883        let request = SponsoredTransactionQuoteRequest::Stellar(
884            crate::models::StellarFeeEstimateRequestParams {
885                transaction_xdr: Some(transaction_xdr),
886                operations: None,
887                source_account: None,
888                fee_token: "INVALID:TOKEN".to_string(),
889            },
890        );
891
892        let result = relayer.quote_sponsored_transaction(request).await;
893        assert!(result.is_err());
894        assert!(matches!(
895            result.unwrap_err(),
896            RelayerError::ValidationError(_)
897        ));
898    }
899
900    #[tokio::test]
901    async fn test_quote_sponsored_transaction_missing_xdr_and_operations() {
902        let relayer_model = create_test_relayer_with_user_fee_strategy();
903        let provider = MockStellarProviderTrait::new();
904        let dex_service = create_mock_dex_service();
905        let relayer = create_test_relayer_instance(relayer_model, provider, dex_service).await;
906
907        let request = SponsoredTransactionQuoteRequest::Stellar(
908            crate::models::StellarFeeEstimateRequestParams {
909                transaction_xdr: None,
910                operations: None,
911                source_account: None,
912                fee_token: USDC_ASSET.to_string(),
913            },
914        );
915
916        let result = relayer.quote_sponsored_transaction(request).await;
917        assert!(result.is_err());
918        assert!(matches!(
919            result.unwrap_err(),
920            RelayerError::ValidationError(_)
921        ));
922    }
923
924    #[tokio::test]
925    async fn test_build_sponsored_transaction_with_xdr() {
926        let relayer_model = create_test_relayer_with_user_fee_strategy();
927        let mut provider = MockStellarProviderTrait::new();
928
929        provider.expect_get_account().returning(|_| {
930            Box::pin(ready(Ok(AccountEntry {
931                account_id: AccountId(PublicKey::PublicKeyTypeEd25519(Uint256([0; 32]))),
932                balance: 1000000000,
933                seq_num: SequenceNumber(-1),
934                num_sub_entries: 0,
935                inflation_dest: None,
936                flags: 0,
937                home_domain: String32::default(),
938                thresholds: Thresholds([0; 4]),
939                signers: VecM::default(),
940                ext: AccountEntryExt::V0,
941            })))
942        });
943
944        // Mock get_ledger_entries for token balance validation
945        // This mock extracts the account ID from the ledger key and returns a trustline with sufficient balance
946        provider.expect_get_ledger_entries().returning(|keys| {
947            // Extract account ID from the first ledger key (should be a Trustline key)
948            let account_id = if let Some(LedgerKey::Trustline(trustline_key)) = keys.first() {
949                trustline_key.account_id.clone()
950            } else {
951                // Fallback: try to parse TEST_PK
952                parse_account_id(TEST_PK).unwrap_or_else(|_| {
953                    AccountId(PublicKey::PublicKeyTypeEd25519(Uint256([0; 32])))
954                })
955            };
956
957            let issuer_id =
958                parse_account_id("GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN")
959                    .unwrap_or_else(|_| {
960                        AccountId(PublicKey::PublicKeyTypeEd25519(Uint256([0; 32])))
961                    });
962
963            // Create a trustline entry with sufficient balance (10 USDC = 10000000 with 6 decimals)
964            let trustline_entry = TrustLineEntry {
965                account_id,
966                asset: soroban_rs::xdr::TrustLineAsset::CreditAlphanum4(AlphaNum4 {
967                    asset_code: AssetCode4(*b"USDC"),
968                    issuer: issuer_id,
969                }),
970                balance: 10_000_000i64, // 10 USDC (with 6 decimals) - sufficient for fee
971                limit: i64::MAX,
972                flags: 0,
973                ext: TrustLineEntryExt::V0, // V0 has no liabilities
974            };
975
976            let ledger_entry = LedgerEntry {
977                last_modified_ledger_seq: 0,
978                data: LedgerEntryData::Trustline(trustline_entry),
979                ext: LedgerEntryExt::V0,
980            };
981
982            // Encode LedgerEntryData to XDR base64 (not the full LedgerEntry)
983            let xdr = ledger_entry
984                .data
985                .to_xdr_base64(soroban_rs::xdr::Limits::none())
986                .expect("Failed to encode trustline entry data to XDR");
987
988            Box::pin(ready(Ok(
989                soroban_rs::stellar_rpc_client::GetLedgerEntriesResponse {
990                    entries: Some(vec![LedgerEntryResult {
991                        key: "test_key".to_string(),
992                        xdr,
993                        last_modified_ledger: 0u32,
994                        live_until_ledger_seq_ledger_seq: None,
995                    }]),
996                    latest_ledger: 0,
997                },
998            )))
999        });
1000
1001        let mut dex_service = MockStellarDexServiceTrait::new();
1002        dex_service
1003            .expect_supported_asset_types()
1004            .returning(|| std::collections::HashSet::from([AssetType::Native, AssetType::Classic]));
1005
1006        // Mock get_xlm_to_token_quote for build (converting XLM fee to token)
1007        dex_service
1008            .expect_get_xlm_to_token_quote()
1009            .returning(|_, _, _, _| {
1010                Box::pin(ready(Ok(
1011                    crate::services::stellar_dex::StellarQuoteResponse {
1012                        input_asset: "native".to_string(),
1013                        output_asset: USDC_ASSET.to_string(),
1014                        in_amount: 1000000,
1015                        out_amount: 1500000,
1016                        price_impact_pct: 0.0,
1017                        slippage_bps: 100,
1018                        path: None,
1019                    },
1020                )))
1021            });
1022
1023        let dex_service = Arc::new(dex_service);
1024        let relayer = create_test_relayer_instance(relayer_model, provider, dex_service).await;
1025
1026        let transaction_xdr = create_test_transaction_xdr();
1027        let request = SponsoredTransactionBuildRequest::Stellar(
1028            crate::models::StellarPrepareTransactionRequestParams {
1029                transaction_xdr: Some(transaction_xdr),
1030                operations: None,
1031                source_account: None,
1032                fee_token: USDC_ASSET.to_string(),
1033            },
1034        );
1035
1036        let result = relayer.build_sponsored_transaction(request).await;
1037        assert!(result.is_ok());
1038
1039        if let SponsoredTransactionBuildResponse::Stellar(build) = result.unwrap() {
1040            assert!(!build.transaction.is_empty());
1041            assert_eq!(build.fee_in_token, "1500000");
1042            assert!(!build.fee_in_token_ui.is_empty());
1043            assert_eq!(build.fee_token, USDC_ASSET);
1044            assert!(!build.valid_until.is_empty());
1045        } else {
1046            panic!("Expected Stellar build response");
1047        }
1048    }
1049
1050    #[tokio::test]
1051    async fn test_build_sponsored_transaction_with_operations() {
1052        let relayer_model = create_test_relayer_with_user_fee_strategy();
1053        let mut provider = MockStellarProviderTrait::new();
1054
1055        provider.expect_get_account().returning(|_| {
1056            Box::pin(ready(Ok(AccountEntry {
1057                account_id: AccountId(PublicKey::PublicKeyTypeEd25519(Uint256([0; 32]))),
1058                balance: 1000000000,
1059                seq_num: SequenceNumber(-1),
1060                num_sub_entries: 0,
1061                inflation_dest: None,
1062                flags: 0,
1063                home_domain: String32::default(),
1064                thresholds: Thresholds([0; 4]),
1065                signers: VecM::default(),
1066                ext: AccountEntryExt::V0,
1067            })))
1068        });
1069
1070        provider.expect_get_ledger_entries().returning(|_| {
1071            use crate::domain::transaction::stellar::utils::parse_account_id;
1072            use soroban_rs::stellar_rpc_client::LedgerEntryResult;
1073            use soroban_rs::xdr::{
1074                AccountId, AlphaNum4, AssetCode4, LedgerEntry, LedgerEntryData, LedgerEntryExt,
1075                PublicKey, TrustLineEntry, TrustLineEntryExt, Uint256, WriteXdr,
1076            };
1077
1078            // Parse account IDs - use the source account from the test
1079            let account_id =
1080                parse_account_id("GCZ54QGQCUZ6U5WJF4AG5JEZCUMYTS2F6JRLUS76XF2PQMEJ2E3JISI2")
1081                    .unwrap_or_else(|_| {
1082                        AccountId(PublicKey::PublicKeyTypeEd25519(Uint256([0; 32])))
1083                    });
1084            let issuer_id =
1085                parse_account_id("GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN")
1086                    .unwrap_or_else(|_| {
1087                        AccountId(PublicKey::PublicKeyTypeEd25519(Uint256([0; 32])))
1088                    });
1089
1090            // Create a trustline entry with sufficient balance (10 USDC = 10000000 with 6 decimals)
1091            // The fee is 1500000 (from the quote), so 10 USDC is more than enough
1092            let trustline_entry = TrustLineEntry {
1093                account_id,
1094                asset: soroban_rs::xdr::TrustLineAsset::CreditAlphanum4(AlphaNum4 {
1095                    asset_code: AssetCode4(*b"USDC"),
1096                    issuer: issuer_id,
1097                }),
1098                balance: 10_000_000i64,
1099                limit: i64::MAX,
1100                flags: 0,
1101                ext: TrustLineEntryExt::V0,
1102            };
1103
1104            let ledger_entry = LedgerEntry {
1105                last_modified_ledger_seq: 0,
1106                data: LedgerEntryData::Trustline(trustline_entry),
1107                ext: LedgerEntryExt::V0,
1108            };
1109
1110            // Encode LedgerEntryData to XDR base64 (not the full LedgerEntry)
1111            // The parse_ledger_entry_from_xdr function expects just the data portion
1112            let xdr = ledger_entry
1113                .data
1114                .to_xdr_base64(soroban_rs::xdr::Limits::none())
1115                .expect("Failed to encode trustline entry data to XDR");
1116
1117            Box::pin(ready(Ok(
1118                soroban_rs::stellar_rpc_client::GetLedgerEntriesResponse {
1119                    entries: Some(vec![LedgerEntryResult {
1120                        key: "test_key".to_string(),
1121                        xdr,
1122                        last_modified_ledger: 0u32,
1123                        live_until_ledger_seq_ledger_seq: None,
1124                    }]),
1125                    latest_ledger: 0,
1126                },
1127            )))
1128        });
1129
1130        let mut dex_service = MockStellarDexServiceTrait::new();
1131        dex_service
1132            .expect_supported_asset_types()
1133            .returning(|| std::collections::HashSet::from([AssetType::Native, AssetType::Classic]));
1134
1135        dex_service
1136            .expect_get_xlm_to_token_quote()
1137            .returning(|_, _, _, _| {
1138                Box::pin(ready(Ok(
1139                    crate::services::stellar_dex::StellarQuoteResponse {
1140                        input_asset: "native".to_string(),
1141                        output_asset: USDC_ASSET.to_string(),
1142                        in_amount: 1000000,
1143                        out_amount: 1500000,
1144                        price_impact_pct: 0.0,
1145                        slippage_bps: 100,
1146                        path: None,
1147                    },
1148                )))
1149            });
1150
1151        let dex_service = Arc::new(dex_service);
1152        let relayer = create_test_relayer_instance(relayer_model, provider, dex_service).await;
1153
1154        let operations = vec![OperationSpec::Payment {
1155            destination: TEST_PK.to_string(),
1156            amount: 1000000,
1157            asset: AssetSpec::Native,
1158        }];
1159
1160        let request = SponsoredTransactionBuildRequest::Stellar(
1161            crate::models::StellarPrepareTransactionRequestParams {
1162                transaction_xdr: None,
1163                operations: Some(operations),
1164                source_account: Some(
1165                    "GCZ54QGQCUZ6U5WJF4AG5JEZCUMYTS2F6JRLUS76XF2PQMEJ2E3JISI2".to_string(),
1166                ),
1167                fee_token: USDC_ASSET.to_string(),
1168            },
1169        );
1170
1171        let result = relayer.build_sponsored_transaction(request).await;
1172
1173        assert!(result.is_ok());
1174    }
1175
1176    #[tokio::test]
1177    async fn test_build_sponsored_transaction_missing_source_account() {
1178        let relayer_model = create_test_relayer_with_user_fee_strategy();
1179        let provider = MockStellarProviderTrait::new();
1180        let dex_service = create_mock_dex_service();
1181        let relayer = create_test_relayer_instance(relayer_model, provider, dex_service).await;
1182
1183        let operations = vec![OperationSpec::Payment {
1184            destination: TEST_PK.to_string(),
1185            amount: 1000000,
1186            asset: AssetSpec::Native,
1187        }];
1188
1189        let request = SponsoredTransactionBuildRequest::Stellar(
1190            crate::models::StellarPrepareTransactionRequestParams {
1191                transaction_xdr: None,
1192                operations: Some(operations),
1193                source_account: None,
1194                fee_token: USDC_ASSET.to_string(),
1195            },
1196        );
1197
1198        let result = relayer.build_sponsored_transaction(request).await;
1199        assert!(result.is_err());
1200        assert!(matches!(
1201            result.unwrap_err(),
1202            RelayerError::ValidationError(_)
1203        ));
1204    }
1205
1206    #[tokio::test]
1207    async fn test_build_envelope_from_request_with_xdr() {
1208        let provider = MockStellarProviderTrait::new();
1209        let transaction_xdr = create_test_transaction_xdr();
1210        let result = build_envelope_from_request(
1211            Some(&transaction_xdr),
1212            None,
1213            None,
1214            TEST_NETWORK_PASSPHRASE,
1215            &provider,
1216        )
1217        .await;
1218        assert!(result.is_ok());
1219    }
1220
1221    #[tokio::test]
1222    async fn test_build_envelope_from_request_with_operations() {
1223        let mut provider = MockStellarProviderTrait::new();
1224
1225        // Mock get_account to return a valid account with sequence number
1226        provider.expect_get_account().returning(|_| {
1227            Box::pin(ready(Ok(AccountEntry {
1228                account_id: AccountId(PublicKey::PublicKeyTypeEd25519(Uint256([0; 32]))),
1229                balance: 1000000000,
1230                seq_num: SequenceNumber(100),
1231                num_sub_entries: 0,
1232                inflation_dest: None,
1233                flags: 0,
1234                home_domain: String32::default(),
1235                thresholds: Thresholds([0; 4]),
1236                signers: VecM::default(),
1237                ext: AccountEntryExt::V0,
1238            })))
1239        });
1240
1241        let operations = vec![OperationSpec::Payment {
1242            destination: TEST_PK.to_string(),
1243            amount: 1000000,
1244            asset: AssetSpec::Native,
1245        }];
1246
1247        let result = build_envelope_from_request(
1248            None,
1249            Some(&operations),
1250            Some(&TEST_PK.to_string()),
1251            TEST_NETWORK_PASSPHRASE,
1252            &provider,
1253        )
1254        .await;
1255        assert!(result.is_ok());
1256
1257        // Verify the sequence number is set correctly (current + 1 = 101)
1258        if let Ok(envelope) = result {
1259            if let TransactionEnvelope::Tx(tx_env) = envelope {
1260                assert_eq!(tx_env.tx.seq_num.0, 101);
1261            }
1262        }
1263    }
1264
1265    #[tokio::test]
1266    async fn test_build_envelope_from_request_missing_source_account() {
1267        let provider = MockStellarProviderTrait::new();
1268        let operations = vec![OperationSpec::Payment {
1269            destination: TEST_PK.to_string(),
1270            amount: 1000000,
1271            asset: AssetSpec::Native,
1272        }];
1273
1274        let result = build_envelope_from_request(
1275            None,
1276            Some(&operations),
1277            None,
1278            TEST_NETWORK_PASSPHRASE,
1279            &provider,
1280        )
1281        .await;
1282        assert!(result.is_err());
1283        assert!(matches!(
1284            result.unwrap_err(),
1285            RelayerError::ValidationError(_)
1286        ));
1287    }
1288
1289    #[tokio::test]
1290    async fn test_build_envelope_from_request_missing_both() {
1291        let provider = MockStellarProviderTrait::new();
1292        let result =
1293            build_envelope_from_request(None, None, None, TEST_NETWORK_PASSPHRASE, &provider).await;
1294        assert!(result.is_err());
1295        assert!(matches!(
1296            result.unwrap_err(),
1297            RelayerError::ValidationError(_)
1298        ));
1299    }
1300
1301    #[tokio::test]
1302    async fn test_build_envelope_from_request_invalid_xdr() {
1303        let provider = MockStellarProviderTrait::new();
1304        let result = build_envelope_from_request(
1305            Some(&"INVALID_XDR".to_string()),
1306            None,
1307            None,
1308            TEST_NETWORK_PASSPHRASE,
1309            &provider,
1310        )
1311        .await;
1312        assert!(result.is_err());
1313    }
1314}