From b91317670d80f64b46f5f18587b6ea4ad5fb24c6 Mon Sep 17 00:00:00 2001 From: Zeljko Date: Wed, 28 Jan 2026 13:53:42 +0100 Subject: [PATCH 01/22] feat: Add initial stellar soroban gas abstraction support --- src/config/server_config.rs | 25 + src/domain/relayer/stellar/gas_abstraction.rs | 791 ++++++++++++++++-- src/domain/relayer/stellar/mod.rs | 37 +- src/domain/relayer/stellar/stellar_relayer.rs | 2 +- src/models/relayer/config.rs | 1 + src/models/relayer/mod.rs | 5 + src/models/rpc/stellar/mod.rs | 61 +- src/models/transaction/repository.rs | 11 +- src/models/transaction/request/mod.rs | 12 +- src/models/transaction/request/stellar.rs | 4 +- src/models/transaction/response.rs | 5 +- src/services/mod.rs | 3 + src/services/stellar_dex/mod.rs | 4 +- src/services/stellar_dex/soroswap_service.rs | 463 ++++++++++ .../stellar_dex/stellar_dex_service.rs | 34 +- src/services/stellar_fee_forwarder/mod.rs | 560 +++++++++++++ src/utils/mocks.rs | 3 + 17 files changed, 1928 insertions(+), 93 deletions(-) create mode 100644 src/services/stellar_dex/soroswap_service.rs create mode 100644 src/services/stellar_fee_forwarder/mod.rs diff --git a/src/config/server_config.rs b/src/config/server_config.rs index fdcc18fc9..e90c60324 100644 --- a/src/config/server_config.rs +++ b/src/config/server_config.rs @@ -90,6 +90,12 @@ pub struct ServerConfig { pub connection_backlog: u32, /// Request handler timeout in seconds for API endpoints. pub request_timeout_seconds: u64, + /// Stellar FeeForwarder contract address for gas abstraction (C... format). + pub stellar_fee_forwarder_address: Option, + /// Stellar Soroswap router contract address for token-to-XLM quotes. + pub stellar_soroswap_router_address: Option, + /// Stellar native XLM wrapper token address for Soroswap. + pub stellar_soroswap_native_wrapper_address: Option, } impl ServerConfig { @@ -150,6 +156,10 @@ impl ServerConfig { max_connections: Self::get_max_connections(), connection_backlog: Self::get_connection_backlog(), request_timeout_seconds: Self::get_request_timeout_seconds(), + stellar_fee_forwarder_address: Self::get_stellar_fee_forwarder_address(), + stellar_soroswap_router_address: Self::get_stellar_soroswap_router_address(), + stellar_soroswap_native_wrapper_address: + Self::get_stellar_soroswap_native_wrapper_address(), } } @@ -424,6 +434,21 @@ impl ServerConfig { .unwrap_or(30) } + /// Gets the Stellar FeeForwarder contract address from environment variable + pub fn get_stellar_fee_forwarder_address() -> Option { + env::var("STELLAR_FEE_FORWARDER_ADDRESS").ok() + } + + /// Gets the Stellar Soroswap router contract address from environment variable + pub fn get_stellar_soroswap_router_address() -> Option { + env::var("STELLAR_SOROSWAP_ROUTER_ADDRESS").ok() + } + + /// Gets the Stellar Soroswap native wrapper token address from environment variable + pub fn get_stellar_soroswap_native_wrapper_address() -> Option { + env::var("STELLAR_SOROSWAP_NATIVE_WRAPPER_ADDRESS").ok() + } + /// Get worker concurrency from environment variable or use default /// /// Environment variable format: `BACKGROUND_WORKER_{WORKER_NAME}_CONCURRENCY` diff --git a/src/domain/relayer/stellar/gas_abstraction.rs b/src/domain/relayer/stellar/gas_abstraction.rs index 1a0ea5cbe..72012dc0b 100644 --- a/src/domain/relayer/stellar/gas_abstraction.rs +++ b/src/domain/relayer/stellar/gas_abstraction.rs @@ -11,8 +11,13 @@ use tracing::debug; use crate::constants::{ get_stellar_sponsored_transaction_validity_duration, STELLAR_DEFAULT_TRANSACTION_FEE, }; + +/// Default slippage tolerance for max_fee_amount in basis points (500 = 5%) +/// This allows fee fluctuation between quote and execution time +const DEFAULT_MAX_FEE_SLIPPAGE_BPS: u64 = 500; use crate::domain::relayer::{ - stellar::xdr_utils::parse_transaction_xdr, GasAbstractionTrait, RelayerError, StellarRelayer, + stellar::xdr_utils::{extract_source_account, parse_transaction_xdr}, + GasAbstractionTrait, RelayerError, StellarRelayer, }; use crate::domain::transaction::stellar::{ utils::{ @@ -36,7 +41,105 @@ use crate::repositories::{ use crate::services::provider::StellarProviderTrait; use crate::services::signer::StellarSignTrait; use crate::services::stellar_dex::StellarDexServiceTrait; +use crate::services::stellar_fee_forwarder::{ + FeeForwarderParams, FeeForwarderService, LEDGER_TIME_SECONDS, +}; use crate::services::TransactionCounterServiceTrait; +use soroban_rs::xdr::{HostFunction, OperationBody, ReadXdr, ScVal}; + +/// Information extracted from a Soroban InvokeHostFunction operation +#[derive(Debug, Clone)] +pub struct SorobanInvokeInfo { + /// Target contract address (C... format) + pub target_contract: String, + /// Target function name + pub target_fn: String, + /// Target function arguments + pub target_args: Vec, +} + +/// Detect if a transaction XDR contains a Soroban InvokeHostFunction operation +/// and extract the contract call details. +/// +/// Returns: +/// - `Ok(Some(info))` if XDR contains an InvokeHostFunction operation +/// - `Ok(None)` if XDR is a classic transaction (no InvokeHostFunction) +/// - `Err(...)` if XDR is invalid +fn detect_soroban_invoke_from_xdr(xdr: &str) -> Result, RelayerError> { + use soroban_rs::xdr::TransactionEnvelope; + + let envelope = TransactionEnvelope::from_xdr_base64(xdr, Limits::none()) + .map_err(|e| RelayerError::ValidationError(format!("Invalid XDR: {e}")))?; + + // Extract operations from envelope + let operations = match &envelope { + TransactionEnvelope::TxV0(env) => env.tx.operations.to_vec(), + TransactionEnvelope::Tx(env) => env.tx.operations.to_vec(), + TransactionEnvelope::TxFeeBump(env) => match &env.tx.inner_tx { + soroban_rs::xdr::FeeBumpTransactionInnerTx::Tx(inner) => inner.tx.operations.to_vec(), + }, + }; + + let mut invoke_index = None; + let mut invoke_op = None; + + for (idx, op) in operations.iter().enumerate() { + if let OperationBody::InvokeHostFunction(invoke) = &op.body { + invoke_index = Some(idx); + invoke_op = Some(invoke); + break; + } + } + + if let Some(idx) = invoke_index { + // Soroban transactions must contain exactly one operation + if operations.len() != 1 { + return Err(RelayerError::ValidationError( + "Soroban transactions must contain exactly one operation".to_string(), + )); + } + + // Single-operation Soroban must be InvokeHostFunction + let invoke_op = invoke_op.ok_or_else(|| { + RelayerError::ValidationError("InvokeHostFunction operation missing".to_string()) + })?; + + if idx != 0 { + return Err(RelayerError::ValidationError( + "InvokeHostFunction must be the first operation".to_string(), + )); + } + + if let HostFunction::InvokeContract(invoke_args) = &invoke_op.host_function { + // Extract contract address + let target_contract = match &invoke_args.contract_address { + soroban_rs::xdr::ScAddress::Contract(contract_id) => { + stellar_strkey::Contract(contract_id.0 .0).to_string() + } + _ => { + return Err(RelayerError::ValidationError( + "InvokeHostFunction must target a contract address".to_string(), + )); + } + }; + + // Extract function name + let target_fn = invoke_args.function_name.to_utf8_string_lossy(); + + // Extract arguments + let target_args: Vec = invoke_args.args.to_vec(); + + return Ok(Some(SorobanInvokeInfo { + target_contract, + target_fn, + target_args, + })); + } + } + + // Not a Soroban InvokeHostFunction transaction + Ok(None) +} #[async_trait] impl GasAbstractionTrait @@ -63,13 +166,88 @@ where )); } }; + + // Check if this is a Soroban gas abstraction request by detecting InvokeHostFunction in XDR + // Soroban mode is detected when transaction_xdr contains an InvokeHostFunction operation + if let Some(xdr) = ¶ms.transaction_xdr { + if let Some(soroban_info) = detect_soroban_invoke_from_xdr(xdr)? { + return self.quote_soroban_from_xdr(¶ms, &soroban_info).await; + } + } + + // Classic sponsored transaction flow + self.quote_classic_sponsored(¶ms).await + } + + async fn build_sponsored_transaction( + &self, + params: SponsoredTransactionBuildRequest, + ) -> Result { + let params = match params { + SponsoredTransactionBuildRequest::Stellar(p) => p, + _ => { + return Err(RelayerError::ValidationError( + "Expected Stellar prepare transaction request parameters".to_string(), + )); + } + }; + + let policy = self.relayer.policies.get_stellar_policy(); + + // Validate allowed token + StellarTransactionValidator::validate_allowed_token(¶ms.fee_token, &policy) + .map_err(|e| RelayerError::ValidationError(e.to_string()))?; + + // Validate fee_payment_strategy is User + if !policy.is_user_fee_payment() { + return Err(RelayerError::ValidationError( + "Gas abstraction requires fee_payment_strategy: User".to_string(), + )); + } + + // Check if this is a Soroban gas abstraction request by detecting InvokeHostFunction in XDR + if let Some(xdr) = ¶ms.transaction_xdr { + if let Some(soroban_info) = detect_soroban_invoke_from_xdr(xdr)? { + return self.build_soroban_sponsored(¶ms, &soroban_info).await; + } + } + + // Classic sponsored transaction flow + self.build_classic_sponsored(¶ms).await + } +} + +// ============================================================================ +// Classic Sponsored Transaction Handlers (Fee-bump Flow) +// ============================================================================ + +impl StellarRelayer +where + P: StellarProviderTrait + Send + Sync, + D: StellarDexServiceTrait + Send + Sync + 'static, + RR: Repository + RelayerRepository + Send + Sync + 'static, + NR: NetworkRepository + Repository + Send + Sync + 'static, + TR: Repository + TransactionRepository + Send + Sync + 'static, + J: JobProducerTrait + Send + Sync + 'static, + TCS: TransactionCounterServiceTrait + Send + Sync + 'static, + S: StellarSignTrait + Send + Sync + 'static, +{ + /// Quote a classic sponsored transaction (fee-bump flow) + /// + /// Estimates the fee for a standard Stellar transaction where the relayer + /// pays the network fee and user pays in a token. + async fn quote_classic_sponsored( + &self, + params: &crate::models::StellarFeeEstimateRequestParams, + ) -> Result { debug!( - "Processing quote sponsored transaction request for token: {}", + "Processing classic quote sponsored transaction for token: {}", params.fee_token ); - // Validate allowed token let policy = self.relayer.policies.get_stellar_policy(); + + // Validate allowed token StellarTransactionValidator::validate_allowed_token(¶ms.fee_token, &policy) .map_err(|e| RelayerError::ValidationError(e.to_string()))?; @@ -80,7 +258,7 @@ where )); } - // Build envelope from XDR or operations (reusing logic from build method) + // Build envelope from XDR or operations let envelope = build_envelope_from_request( params.transaction_xdr.as_ref(), params.operations.as_ref(), @@ -90,7 +268,7 @@ where ) .await?; - // Run comprehensive security validation (similar to build method) + // Run comprehensive security validation StellarTransactionValidator::gasless_transaction_validation( &envelope, &self.relayer.address, @@ -109,13 +287,12 @@ where .map_err(crate::models::RelayerError::from)?; // Add fees for fee payment operation (100 stroops) and fee-bump transaction (100 stroops) - // For Soroban transactions, the simulation already accounts for resource fees, - // we just need to add the inclusion fees for the additional operations let is_soroban = xdr_needs_simulation(&envelope).unwrap_or(false); - let mut additional_fees = 0; - if !is_soroban { - additional_fees = 2 * STELLAR_DEFAULT_TRANSACTION_FEE as u64; // 200 stroops total - } + let additional_fees = if is_soroban { + 0 // Soroban simulation already accounts for resource fees + } else { + 2 * STELLAR_DEFAULT_TRANSACTION_FEE as u64 // 200 stroops total + }; let xlm_fee = inner_tx_fee + additional_fees; // Convert to token amount via DEX service @@ -150,35 +327,33 @@ where .await .map_err(|e| RelayerError::ValidationError(e.to_string()))?; - debug!("Fee estimate result: {:?}", fee_quote); + debug!("Classic fee estimate result: {:?}", fee_quote); - let result = StellarFeeEstimateResult { - fee_in_token_ui: fee_quote.fee_in_token_ui, - fee_in_token: fee_quote.fee_in_token.to_string(), - conversion_rate: fee_quote.conversion_rate.to_string(), - }; - Ok(SponsoredTransactionQuoteResponse::Stellar(result)) + Ok(SponsoredTransactionQuoteResponse::Stellar( + StellarFeeEstimateResult { + fee_in_token_ui: fee_quote.fee_in_token_ui, + fee_in_token: fee_quote.fee_in_token.to_string(), + conversion_rate: fee_quote.conversion_rate.to_string(), + }, + )) } - async fn build_sponsored_transaction( + /// Build a classic sponsored transaction (fee-bump flow) + /// + /// Builds a complete transaction envelope with fee payment operation, + /// ready for user signature. The relayer will later wrap this in a fee-bump. + async fn build_classic_sponsored( &self, - params: SponsoredTransactionBuildRequest, + params: &crate::models::StellarPrepareTransactionRequestParams, ) -> Result { - let params = match params { - SponsoredTransactionBuildRequest::Stellar(p) => p, - _ => { - return Err(RelayerError::ValidationError( - "Expected Stellar prepare transaction request parameters".to_string(), - )); - } - }; debug!( - "Processing prepare transaction request for token: {}", + "Processing classic build sponsored transaction for token: {}", params.fee_token ); - // Validate allowed token let policy = self.relayer.policies.get_stellar_policy(); + + // Validate allowed token StellarTransactionValidator::validate_allowed_token(¶ms.fee_token, &policy) .map_err(|e| RelayerError::ValidationError(e.to_string()))?; @@ -189,7 +364,7 @@ where )); } - // Build envelope from XDR or operations (reusing shared helper) + // Build envelope from XDR or operations let envelope = build_envelope_from_request( params.transaction_xdr.as_ref(), params.operations.as_ref(), @@ -199,6 +374,7 @@ where ) .await?; + // Run comprehensive security validation StellarTransactionValidator::gasless_transaction_validation( &envelope, &self.relayer.address, @@ -211,31 +387,29 @@ where RelayerError::ValidationError(format!("Failed to validate gasless transaction: {e}")) })?; - // Get fee estimate using estimate_fee utility which handles simulation if needed - // For non-Soroban transactions, we'll add 200 stroops (100 for fee payment op + 100 for fee-bump) + // Estimate fee using estimate_fee utility which handles simulation if needed let inner_tx_fee = estimate_fee(&envelope, &self.provider, None) .await .map_err(crate::models::RelayerError::from)?; - // Add fees for fee payment operation (100 stroops) and fee-bump transaction (100 stroops) - // For Soroban transactions, the simulation already accounts for resource fees, - // we just need to add the inclusion fees for the additional operations + // Add fees for fee payment operation and fee-bump transaction let is_soroban = xdr_needs_simulation(&envelope).unwrap_or(false); - let mut additional_fees = 0; - if !is_soroban { - additional_fees = 2 * STELLAR_DEFAULT_TRANSACTION_FEE as u64; // 200 stroops total - } + let additional_fees = if is_soroban { + 0 + } else { + 2 * STELLAR_DEFAULT_TRANSACTION_FEE as u64 // 200 stroops total + }; let xlm_fee = inner_tx_fee + additional_fees; debug!( inner_tx_fee = inner_tx_fee, additional_fees = additional_fees, total_fee = xlm_fee, - "Fee estimated: inner transaction + fee payment op + fee-bump transaction fee" + "Fee estimated: inner transaction + fee payment op + fee-bump" ); - // Calculate fee quote first to check user balance before modifying envelope - let preliminary_fee_quote = convert_xlm_fee_to_token( + // Calculate fee quote to check user balance before modifying envelope + let fee_quote = convert_xlm_fee_to_token( self.dex_service.as_ref(), &policy, xlm_fee, @@ -245,16 +419,13 @@ where .map_err(crate::models::RelayerError::from)?; // Validate max fee - StellarTransactionValidator::validate_max_fee( - preliminary_fee_quote.fee_in_stroops, - &policy, - ) - .map_err(|e| RelayerError::ValidationError(e.to_string()))?; + StellarTransactionValidator::validate_max_fee(fee_quote.fee_in_stroops, &policy) + .map_err(|e| RelayerError::ValidationError(e.to_string()))?; // Validate token-specific max fee StellarTransactionValidator::validate_token_max_fee( ¶ms.fee_token, - preliminary_fee_quote.fee_in_token, + fee_quote.fee_in_token, &policy, ) .map_err(|e| RelayerError::ValidationError(e.to_string()))?; @@ -263,7 +434,7 @@ where StellarTransactionValidator::validate_user_token_balance( &envelope, ¶ms.fee_token, - preliminary_fee_quote.fee_in_token, + fee_quote.fee_in_token, &self.provider, ) .await @@ -272,21 +443,18 @@ where // Add payment operation using the validated fee quote let mut final_envelope = add_payment_operation_to_envelope( envelope, - &preliminary_fee_quote, + &fee_quote, ¶ms.fee_token, &self.relayer.address, )?; - // Use the validated fee quote (no duplicate DEX call) - let fee_quote = preliminary_fee_quote; - debug!( estimated_fee = xlm_fee, final_fee_in_token = fee_quote.fee_in_token_ui, - "Transaction prepared successfully" + "Classic transaction prepared successfully" ); - // Set final time bounds just before returning to give user maximum time to review and submit + // Set final time bounds just before returning to give user maximum time to sign let valid_until = Utc::now() + get_stellar_sponsored_transaction_validity_duration(); set_time_bounds(&mut final_envelope, valid_until) .map_err(crate::models::RelayerError::from)?; @@ -302,13 +470,522 @@ where fee_in_token: fee_quote.fee_in_token.to_string(), fee_in_token_ui: fee_quote.fee_in_token_ui, fee_in_stroops: fee_quote.fee_in_stroops.to_string(), - fee_token: params.fee_token, + fee_token: params.fee_token.clone(), valid_until: valid_until.to_rfc3339(), + // Classic mode: no Soroban-specific fields + user_auth_entry: None, }, )) } } +// ============================================================================ +// Soroban Gas Abstraction Handlers (FeeForwarder Flow with XDR-based detection) +// ============================================================================ + +impl StellarRelayer +where + P: StellarProviderTrait + Send + Sync, + D: StellarDexServiceTrait + Send + Sync + 'static, + RR: Repository + RelayerRepository + Send + Sync + 'static, + NR: NetworkRepository + Repository + Send + Sync + 'static, + TR: Repository + TransactionRepository + Send + Sync + 'static, + J: JobProducerTrait + Send + Sync + 'static, + TCS: TransactionCounterServiceTrait + Send + Sync + 'static, + S: StellarSignTrait + Send + Sync + 'static, +{ + /// Quote a Soroban sponsored transaction using FeeForwarder (XDR-based detection) + /// + /// Called when transaction_xdr contains an InvokeHostFunction operation. + /// Extracts contract call details from the XDR and estimates fee. + async fn quote_soroban_from_xdr( + &self, + params: &crate::models::StellarFeeEstimateRequestParams, + soroban_info: &SorobanInvokeInfo, + ) -> Result { + debug!( + "Processing Soroban quote request for token: {}, target: {}::{}", + params.fee_token, soroban_info.target_contract, soroban_info.target_fn + ); + + let policy = self.relayer.policies.get_stellar_policy(); + + // Validate fee_payment_strategy is User + if !policy.is_user_fee_payment() { + return Err(RelayerError::ValidationError( + "Gas abstraction requires fee_payment_strategy: User".to_string(), + )); + } + + // Validate allowed token (same as classic flow) + StellarTransactionValidator::validate_allowed_token(¶ms.fee_token, &policy) + .map_err(|e| RelayerError::ValidationError(e.to_string()))?; + + // Validate fee_forwarder is configured in server config + let fee_forwarder = crate::config::ServerConfig::get_stellar_fee_forwarder_address() + .ok_or_else(|| { + RelayerError::ValidationError( + "STELLAR_FEE_FORWARDER_ADDRESS env var is required for gas abstraction" + .to_string(), + ) + })?; + + // Validate fee_token is a valid Soroban contract address (C...) + if stellar_strkey::Contract::from_string(¶ms.fee_token).is_err() { + return Err(RelayerError::ValidationError(format!( + "fee_token must be a valid Soroban contract address (C...), got '{}'", + params.fee_token + ))); + } + + // Extract user_address from transaction_xdr source account (or use source_account if provided) + // For quote, we don't need the actual user_address, just validation that XDR is valid + // The user_address will be extracted in build phase when we have the XDR + + let xdr = params.transaction_xdr.as_ref().ok_or_else(|| { + RelayerError::ValidationError( + "Soroban gas abstraction requires transaction_xdr".to_string(), + ) + })?; + + let source_envelope = TransactionEnvelope::from_xdr_base64(xdr, Limits::none()) + .map_err(|e| RelayerError::ValidationError(format!("Invalid XDR: {e}")))?; + let user_address = extract_source_account(&source_envelope).map_err(|e| { + RelayerError::ValidationError(format!("Failed to extract source account: {e}")) + })?; + + // Build FeeForwarder params with a placeholder fee (will simulate to get accurate fee) + let base_fee_stroops: u64 = STELLAR_DEFAULT_TRANSACTION_FEE as u64; + let base_fee_quote = convert_xlm_fee_to_token( + self.dex_service.as_ref(), + &policy, + base_fee_stroops, + ¶ms.fee_token, + ) + .await + .map_err(crate::models::RelayerError::from)?; + + let validity_duration = get_stellar_sponsored_transaction_validity_duration(); + let validity_seconds = validity_duration.num_seconds() as u64; + let expiration_ledger = get_expiration_ledger(&self.provider, validity_seconds) + .await + .map_err(|e| RelayerError::Internal(format!("Failed to get expiration ledger: {e}")))?; + + let fee_params = FeeForwarderParams { + fee_token: params.fee_token.clone(), + fee_amount: base_fee_quote.fee_in_token as i128, + max_fee_amount: apply_max_fee_slippage(base_fee_quote.fee_in_token), + expiration_ledger, + target_contract: soroban_info.target_contract.clone(), + target_fn: soroban_info.target_fn.clone(), + target_args: soroban_info.target_args.clone(), + user: user_address, + relayer: self.relayer.address.clone(), + }; + + let auth_entry = FeeForwarderService::

::build_user_auth_entry_standalone( + &fee_forwarder, + &fee_params, + true, + ) + .map_err(|e| RelayerError::Internal(format!("Failed to build user auth entry: {e}")))?; + + let invoke_op = FeeForwarderService::

::build_invoke_operation_standalone( + &fee_forwarder, + &fee_params, + vec![auth_entry], + ) + .map_err(|e| RelayerError::Internal(format!("Failed to build invoke operation: {e}")))?; + + let envelope = build_soroban_transaction_envelope( + &self.relayer.address, + invoke_op, + base_fee_stroops as u32, + )?; + + let sim_response = self + .provider + .simulate_transaction_envelope(&envelope) + .await + .map_err(|e| RelayerError::Internal(format!("Failed to simulate transaction: {e}")))?; + + let total_fee = calculate_total_soroban_fee(&sim_response, 1)?; + + let fee_quote = convert_xlm_fee_to_token( + self.dex_service.as_ref(), + &policy, + total_fee as u64, + ¶ms.fee_token, + ) + .await + .map_err(crate::models::RelayerError::from)?; + + debug!( + "Soroban fee estimate: {} stroops, {} token", + fee_quote.fee_in_stroops, fee_quote.fee_in_token + ); + + // Validate max fee + StellarTransactionValidator::validate_max_fee(fee_quote.fee_in_stroops, &policy) + .map_err(|e| RelayerError::ValidationError(e.to_string()))?; + + // Validate token-specific max fee + StellarTransactionValidator::validate_token_max_fee( + ¶ms.fee_token, + fee_quote.fee_in_token, + &policy, + ) + .map_err(|e| RelayerError::ValidationError(e.to_string()))?; + + // Check user token balance using the original source envelope (user as source) + StellarTransactionValidator::validate_user_token_balance( + &source_envelope, + ¶ms.fee_token, + fee_quote.fee_in_token, + &self.provider, + ) + .await + .map_err(|e| RelayerError::ValidationError(e.to_string()))?; + + // Return using consolidated result struct + let result = StellarFeeEstimateResult { + fee_in_token_ui: fee_quote.fee_in_token_ui, + fee_in_token: fee_quote.fee_in_token.to_string(), + conversion_rate: fee_quote.conversion_rate.to_string(), + }; + + Ok(SponsoredTransactionQuoteResponse::Stellar(result)) + } + + /// Build a Soroban sponsored transaction using FeeForwarder (XDR-based detection) + /// + /// Called when transaction_xdr contains an InvokeHostFunction operation. + /// Builds the FeeForwarder transaction wrapping the original contract call. + async fn build_soroban_sponsored( + &self, + params: &crate::models::StellarPrepareTransactionRequestParams, + soroban_info: &SorobanInvokeInfo, + ) -> Result { + debug!( + "Processing Soroban build request for token: {}, target: {}::{}", + params.fee_token, soroban_info.target_contract, soroban_info.target_fn + ); + + let policy = self.relayer.policies.get_stellar_policy(); + + // Note: validate_allowed_token is already called in build_sponsored_transaction + + // Get fee_forwarder address from server config + let fee_forwarder = crate::config::ServerConfig::get_stellar_fee_forwarder_address() + .ok_or_else(|| { + RelayerError::ValidationError( + "STELLAR_FEE_FORWARDER_ADDRESS env var is required for gas abstraction" + .to_string(), + ) + })?; + + // Extract user_address from transaction_xdr source account + // Soroban gas abstraction requires transaction_xdr, so we can unwrap here + let xdr = params.transaction_xdr.as_ref().ok_or_else(|| { + RelayerError::ValidationError( + "Soroban gas abstraction requires transaction_xdr".to_string(), + ) + })?; + + let source_envelope = TransactionEnvelope::from_xdr_base64(xdr, Limits::none()) + .map_err(|e| RelayerError::ValidationError(format!("Invalid XDR: {e}")))?; + let user_address = extract_source_account(&source_envelope).map_err(|e| { + RelayerError::ValidationError(format!("Failed to extract source account: {e}")) + })?; + + // Use default validity duration (same as classic sponsored transactions) + let validity_duration = get_stellar_sponsored_transaction_validity_duration(); + let validity_seconds = validity_duration.num_seconds() as u64; + let expiration_ledger = get_expiration_ledger(&self.provider, validity_seconds) + .await + .map_err(|e| RelayerError::Internal(format!("Failed to get expiration ledger: {e}")))?; + + // Build initial fee quote based on base fee, then simulate to get accurate Soroban fee + let base_fee_stroops: u64 = STELLAR_DEFAULT_TRANSACTION_FEE as u64; + let base_fee_quote = convert_xlm_fee_to_token( + self.dex_service.as_ref(), + &policy, + base_fee_stroops, + ¶ms.fee_token, + ) + .await + .map_err(crate::models::RelayerError::from)?; + + // Build the FeeForwarder parameters using extracted Soroban info + let mut fee_params = FeeForwarderParams { + fee_token: params.fee_token.clone(), + fee_amount: base_fee_quote.fee_in_token as i128, + max_fee_amount: apply_max_fee_slippage(base_fee_quote.fee_in_token), + expiration_ledger, + target_contract: soroban_info.target_contract.clone(), + target_fn: soroban_info.target_fn.clone(), + target_args: soroban_info.target_args.clone(), + user: user_address.clone(), + relayer: self.relayer.address.clone(), + }; + + // Build the user authorization entry using the standalone method + // requires_target_auth defaults to true (safer default) + let auth_entry = FeeForwarderService::

::build_user_auth_entry_standalone( + &fee_forwarder, + &fee_params, + true, + ) + .map_err(|e| RelayerError::Internal(format!("Failed to build user auth entry: {e}")))?; + + let invoke_op = FeeForwarderService::

::build_invoke_operation_standalone( + &fee_forwarder, + &fee_params, + vec![auth_entry], + ) + .map_err(|e| RelayerError::Internal(format!("Failed to build invoke operation: {e}")))?; + + let envelope = build_soroban_transaction_envelope( + &self.relayer.address, + invoke_op, + base_fee_stroops as u32, + )?; + + let sim_response = self + .provider + .simulate_transaction_envelope(&envelope) + .await + .map_err(|e| RelayerError::Internal(format!("Failed to simulate transaction: {e}")))?; + + let total_fee = calculate_total_soroban_fee(&sim_response, 1)?; + + let fee_quote = convert_xlm_fee_to_token( + self.dex_service.as_ref(), + &policy, + total_fee as u64, + ¶ms.fee_token, + ) + .await + .map_err(crate::models::RelayerError::from)?; + + // Validate max fee + StellarTransactionValidator::validate_max_fee(fee_quote.fee_in_stroops, &policy) + .map_err(|e| RelayerError::ValidationError(e.to_string()))?; + + // Validate token-specific max fee + StellarTransactionValidator::validate_token_max_fee( + ¶ms.fee_token, + fee_quote.fee_in_token, + &policy, + ) + .map_err(|e| RelayerError::ValidationError(e.to_string()))?; + + // Check user token balance using the original source envelope (user as source) + StellarTransactionValidator::validate_user_token_balance( + &source_envelope, + ¶ms.fee_token, + fee_quote.fee_in_token, + &self.provider, + ) + .await + .map_err(|e| RelayerError::ValidationError(e.to_string()))?; + + // Rebuild params with the final fee amounts + // Apply slippage buffer to max_fee_amount to allow for fee fluctuation + fee_params.fee_amount = fee_quote.fee_in_token as i128; + fee_params.max_fee_amount = apply_max_fee_slippage(fee_quote.fee_in_token); + + let auth_entry = FeeForwarderService::

::build_user_auth_entry_standalone( + &fee_forwarder, + &fee_params, + true, + ) + .map_err(|e| RelayerError::Internal(format!("Failed to build user auth entry: {e}")))?; + + let user_auth_xdr = FeeForwarderService::

::serialize_auth_entry(&auth_entry) + .map_err(|e| RelayerError::Internal(format!("Failed to serialize auth entry: {e}")))?; + + let invoke_op = FeeForwarderService::

::build_invoke_operation_standalone( + &fee_forwarder, + &fee_params, + vec![auth_entry], + ) + .map_err(|e| RelayerError::Internal(format!("Failed to build invoke operation: {e}")))?; + + let mut envelope = build_soroban_transaction_envelope( + &self.relayer.address, + invoke_op, + base_fee_stroops as u32, + )?; + + let sim_response = self + .provider + .simulate_transaction_envelope(&envelope) + .await + .map_err(|e| RelayerError::Internal(format!("Failed to simulate transaction: {e}")))?; + + apply_simulation_to_soroban_envelope(&mut envelope, &sim_response, 1)?; + + let transaction_xdr = envelope + .to_xdr_base64(Limits::none()) + .map_err(|e| RelayerError::Internal(format!("Failed to serialize transaction: {e}")))?; + + // Derive valid_until from expiration_ledger to ensure consistency + // Get current ledger to calculate time until expiration + let current_ledger = + self.provider.get_latest_ledger().await.map_err(|e| { + RelayerError::Internal(format!("Failed to get current ledger: {e}")) + })?; + let ledgers_until_expiration = expiration_ledger.saturating_sub(current_ledger.sequence); + let seconds_until_expiration = ledgers_until_expiration as u64 * LEDGER_TIME_SECONDS; + let valid_until = Utc::now() + chrono::Duration::seconds(seconds_until_expiration as i64); + + debug!( + "Soroban build complete: transaction_xdr length={}, auth_xdr length={}, expiration_ledger={}, valid_until={}", + transaction_xdr.len(), + user_auth_xdr.len(), + expiration_ledger, + valid_until.to_rfc3339() + ); + + // Return using consolidated result struct with Soroban-specific fields populated + let result = StellarPrepareTransactionResult { + transaction: transaction_xdr, + fee_in_token: fee_quote.fee_in_token.to_string(), + fee_in_token_ui: fee_quote.fee_in_token_ui, + fee_in_stroops: fee_quote.fee_in_stroops.to_string(), + fee_token: params.fee_token.clone(), + valid_until: valid_until.to_rfc3339(), + // Soroban-specific fields + user_auth_entry: Some(user_auth_xdr), + }; + + Ok(SponsoredTransactionBuildResponse::Stellar(result)) + } +} + +/// Build a Soroban transaction envelope with the given operation +fn build_soroban_transaction_envelope( + source_address: &str, + operation: Operation, + fee: u32, +) -> Result { + use soroban_rs::xdr::{ + Memo, MuxedAccount, Preconditions, SequenceNumber, Transaction, TransactionExt, + TransactionV1Envelope, Uint256, VecM, + }; + + // Parse source address + let pk = stellar_strkey::ed25519::PublicKey::from_string(source_address) + .map_err(|e| RelayerError::ValidationError(format!("Invalid source address: {e}")))?; + let source = MuxedAccount::Ed25519(Uint256(pk.0)); + + // Build transaction with placeholder sequence (0) - will be updated at submit time + let tx = Transaction { + source_account: source, + fee, + seq_num: SequenceNumber(0), + cond: Preconditions::None, + memo: Memo::None, + operations: vec![operation].try_into().map_err(|_| { + RelayerError::Internal("Failed to create operations vector".to_string()) + })?, + ext: TransactionExt::V0, + }; + + Ok(TransactionEnvelope::Tx(TransactionV1Envelope { + tx, + signatures: VecM::default(), + })) +} + +/// Calculate total fee for a Soroban transaction from simulation response. +fn calculate_total_soroban_fee( + sim_response: &soroban_rs::stellar_rpc_client::SimulateTransactionResponse, + operations_count: u64, +) -> Result { + if let Some(err) = sim_response.error.clone() { + return Err(RelayerError::ValidationError(format!( + "Simulation failed: {err}" + ))); + } + + let inclusion_fee = operations_count * STELLAR_DEFAULT_TRANSACTION_FEE as u64; + let resource_fee = sim_response.min_resource_fee; + let total_fee = inclusion_fee + resource_fee; + let total_fee_u32 = u32::try_from(total_fee) + .map_err(|_| RelayerError::Internal("Soroban fee exceeds u32::MAX".to_string()))?; + + Ok(total_fee_u32.max(STELLAR_DEFAULT_TRANSACTION_FEE)) +} + +/// Apply Soroban simulation data to a transaction envelope (fee + extension data). +fn apply_simulation_to_soroban_envelope( + envelope: &mut TransactionEnvelope, + sim_response: &soroban_rs::stellar_rpc_client::SimulateTransactionResponse, + operations_count: u64, +) -> Result<(), RelayerError> { + use soroban_rs::xdr::SorobanTransactionData; + + let total_fee = calculate_total_soroban_fee(sim_response, operations_count)?; + + let tx_data = SorobanTransactionData::from_xdr_base64( + sim_response.transaction_data.as_str(), + Limits::none(), + ) + .map_err(|e| RelayerError::Internal(format!("Invalid transaction_data XDR: {e}")))?; + + match envelope { + TransactionEnvelope::Tx(ref mut env) => { + env.tx.fee = total_fee; + env.tx.ext = soroban_rs::xdr::TransactionExt::V1(tx_data); + } + TransactionEnvelope::TxV0(_) | TransactionEnvelope::TxFeeBump(_) => { + return Err(RelayerError::Internal( + "Soroban transaction must be a V1 envelope".to_string(), + )); + } + } + + Ok(()) +} + +/// Apply slippage tolerance to max_fee_amount for FeeForwarder +/// +/// The FeeForwarder contract has separate `fee_amount` (what relayer charges at execution) +/// and `max_fee_amount` (user's authorized ceiling). Setting them equal means no room for +/// fee fluctuation between quote and execution. This function applies a slippage buffer +/// to allow for price movement. +/// +/// # Arguments +/// * `fee_in_token` - The calculated fee amount in token units +/// +/// # Returns +/// The max_fee_amount with slippage buffer applied as i128 +fn apply_max_fee_slippage(fee_in_token: u64) -> i128 { + // Apply slippage: max_fee = fee * (10000 + slippage_bps) / 10000 + let fee_with_slippage = + (fee_in_token as u128) * (10000 + DEFAULT_MAX_FEE_SLIPPAGE_BPS as u128) / 10000; + fee_with_slippage as i128 +} + +/// Calculate the expiration ledger for authorization +/// +/// Uses the provider to get the current ledger sequence and adds the +/// specified validity duration (in seconds) converted to ledger count. +async fn get_expiration_ledger

(provider: &P, validity_seconds: u64) -> Result +where + P: StellarProviderTrait + Send + Sync, +{ + let current_ledger = provider + .get_latest_ledger() + .await + .map_err(|e| RelayerError::Internal(format!("Failed to get latest ledger: {e}")))?; + + let ledgers_to_add = validity_seconds / LEDGER_TIME_SECONDS; + Ok(current_ledger.sequence + ledgers_to_add as u32) +} + /// Add payment operation to envelope using a pre-computed fee quote /// /// This function adds a fee payment operation to the transaction envelope using diff --git a/src/domain/relayer/stellar/mod.rs b/src/domain/relayer/stellar/mod.rs index 697c730b0..f9794411a 100644 --- a/src/domain/relayer/stellar/mod.rs +++ b/src/domain/relayer/stellar/mod.rs @@ -25,7 +25,7 @@ use crate::{ services::{ provider::get_network_provider, signer::StellarSignerFactory, - stellar_dex::{DexServiceWrapper, OrderBookService, StellarDexService}, + stellar_dex::{DexServiceWrapper, OrderBookService, SoroswapService, StellarDexService}, TransactionCounterService, }, }; @@ -114,9 +114,24 @@ pub async fn create_stellar_relayer< dex_services.push(DexServiceWrapper::OrderBook(order_book_service)); } StellarSwapStrategy::Soroswap => { - // TODO: Implement Soroswap service when available - // For now, skip if not available - tracing::warn!("Soroswap strategy is not yet implemented, skipping"); + // Get Soroswap router address from server config, falling back to default + let router_address = + crate::config::ServerConfig::get_stellar_soroswap_router_address() + .unwrap_or_else(|| get_default_soroswap_router(network.is_testnet())); + + // Get native wrapper address from server config if configured + let native_wrapper_address = + crate::config::ServerConfig::get_stellar_soroswap_native_wrapper_address(); + + let soroswap_service = Arc::new(SoroswapService::new( + router_address, + native_wrapper_address, + provider_arc.clone(), + network.passphrase.clone(), + network.is_testnet(), + )); + dex_services.push(DexServiceWrapper::Soroswap(soroswap_service)); + tracing::info!("Soroswap DEX service initialized"); } } } @@ -141,3 +156,17 @@ pub async fn create_stellar_relayer< Ok(relayer) } + +/// Get the default Soroswap router contract address for the given network +/// +/// These addresses are the official Soroswap router deployments. +/// Users can override these via configuration. +fn get_default_soroswap_router(is_testnet: bool) -> String { + if is_testnet { + // Soroswap testnet router + "CDVQVKOY2YSXS2IC7KN6MNASSHPAO7UN2UR2ON4OI2SKMFJNVAMDX6DP".to_string() + } else { + // Soroswap mainnet router + "CBQDHNBFBZYE4MKPWBSJOPIYLW4SFSXAXUTSXJN76GNKYVYPCKWC6QUK".to_string() + } +} diff --git a/src/domain/relayer/stellar/stellar_relayer.rs b/src/domain/relayer/stellar/stellar_relayer.rs index 3dbdd25e2..e20cd02ba 100644 --- a/src/domain/relayer/stellar/stellar_relayer.rs +++ b/src/domain/relayer/stellar/stellar_relayer.rs @@ -130,7 +130,7 @@ where D: StellarDexServiceTrait + Send + Sync + 'static, { pub(crate) relayer: RelayerRepoModel, - signer: Arc, + pub(crate) signer: Arc, pub(crate) network: StellarNetwork, pub(crate) provider: P, pub(crate) relayer_repository: Arc, diff --git a/src/models/relayer/config.rs b/src/models/relayer/config.rs index 229681fed..b213d4436 100644 --- a/src/models/relayer/config.rs +++ b/src/models/relayer/config.rs @@ -195,6 +195,7 @@ pub struct ConfigFileRelayerStellarPolicy { pub min_balance: Option, pub concurrent_transactions: Option, /// Determines if the relayer pays the transaction fee or the user. Optional. + /// When set to "user" with STELLAR_FEE_FORWARDER_ADDRESS env var, enables soroban gas abstraction as well. pub fee_payment_strategy: Option, /// Default slippage percentage for token conversions. Optional. pub slippage_percentage: Option, diff --git a/src/models/relayer/mod.rs b/src/models/relayer/mod.rs index d9e4f83ad..9400a52ce 100644 --- a/src/models/relayer/mod.rs +++ b/src/models/relayer/mod.rs @@ -654,6 +654,11 @@ impl RelayerStellarPolicy { pub fn get_swap_config(&self) -> Option { self.swap_config.clone() } + + /// Check if user fee payment strategy is enabled (gas abstraction requires this + STELLAR_FEE_FORWARDER_ADDRESS env var) + pub fn is_user_fee_payment(&self) -> bool { + self.fee_payment_strategy == Some(StellarFeePaymentStrategy::User) + } } /// Network-specific policy for relayers diff --git a/src/models/rpc/stellar/mod.rs b/src/models/rpc/stellar/mod.rs index d2f9a9cac..bd47ef1a4 100644 --- a/src/models/rpc/stellar/mod.rs +++ b/src/models/rpc/stellar/mod.rs @@ -12,7 +12,8 @@ use crate::{ #[schema(as = StellarFeeEstimateRequestParams)] pub struct FeeEstimateRequestParams { /// Pre-built transaction XDR (base64 encoded, signed or unsigned) - /// Mutually exclusive with operations field + /// Mutually exclusive with operations field. + /// For Soroban gas abstraction: pass XDR containing InvokeHostFunction operation. #[schema(nullable = true)] pub transaction_xdr: Option, /// Source account address (required when operations are provided) @@ -23,7 +24,9 @@ pub struct FeeEstimateRequestParams { /// Mutually exclusive with transaction_xdr field #[schema(nullable = true)] pub operations: Option>, - /// Asset identifier for fee token (e.g., "native" or "USDC:GA5Z...") + /// Asset identifier for fee token. + /// For classic: "native" or "USDC:GA5Z..." format. + /// For Soroban: contract address (C...) format. pub fee_token: String, } @@ -90,7 +93,8 @@ pub struct FeeEstimateResult { #[schema(as = StellarPrepareTransactionRequestParams)] pub struct PrepareTransactionRequestParams { /// Pre-built transaction XDR (base64 encoded, signed or unsigned) - /// Mutually exclusive with operations field + /// Mutually exclusive with operations field. + /// For Soroban gas abstraction: pass XDR containing InvokeHostFunction operation. #[schema(nullable = true)] pub transaction_xdr: Option, /// Operations array to build transaction from @@ -101,7 +105,9 @@ pub struct PrepareTransactionRequestParams { /// For gasless transactions, this should be the user's account address #[schema(nullable = true)] pub source_account: Option, - /// Asset identifier for fee token + /// Asset identifier for fee token. + /// For classic: "native" or "USDC:GA5Z..." format. + /// For Soroban: contract address (C...) format. pub fee_token: String, } @@ -167,6 +173,11 @@ pub struct PrepareTransactionResult { pub fee_token: String, /// Transaction validity timestamp (ISO 8601 format) pub valid_until: String, + /// User authorization entry XDR (base64 encoded). + /// Present for Soroban gas abstraction - user must sign this auth entry. + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(nullable = true)] + pub user_auth_entry: Option, } /// Stellar RPC method enum @@ -511,4 +522,46 @@ mod tests { panic!("Expected BadRequest error for empty operations"); } } + + #[test] + fn test_fee_estimate_result() { + let result = FeeEstimateResult { + fee_in_token_ui: "1.5".to_string(), + fee_in_token: "1500000".to_string(), + conversion_rate: "10.0".to_string(), + }; + assert_eq!(result.fee_in_token_ui, "1.5"); + assert_eq!(result.fee_in_token, "1500000"); + assert_eq!(result.conversion_rate, "10.0"); + } + + #[test] + fn test_prepare_transaction_result_with_soroban_fields() { + let result = PrepareTransactionResult { + transaction: "AAAAAgAAAAA=".to_string(), + fee_in_token: "1500000".to_string(), + fee_in_token_ui: "1.5".to_string(), + fee_in_stroops: "150000".to_string(), + fee_token: "CUSDC".to_string(), + valid_until: "2024-01-01T00:00:00Z".to_string(), + user_auth_entry: Some("AAAABgAAAAA=".to_string()), + }; + assert!(result.user_auth_entry.is_some()); + } + + #[test] + fn test_prepare_transaction_result_without_soroban_fields() { + let result = PrepareTransactionResult { + transaction: "AAAAAgAAAAA=".to_string(), + fee_in_token: "1500000".to_string(), + fee_in_token_ui: "1.5".to_string(), + fee_in_stroops: "150000".to_string(), + fee_token: "USDC:GA...".to_string(), + valid_until: "2024-01-01T00:00:00Z".to_string(), + user_auth_entry: None, + }; + // Verify serialization skips None fields + let json = serde_json::to_string(&result).unwrap(); + assert!(!json.contains("user_auth_entry")); + } } diff --git a/src/models/transaction/repository.rs b/src/models/transaction/repository.rs index 3739c3ea9..33f50c5ad 100644 --- a/src/models/transaction/repository.rs +++ b/src/models/transaction/repository.rs @@ -33,15 +33,14 @@ use alloy::{ use chrono::{Duration, Utc}; use serde::{Deserialize, Serialize}; +use soroban_rs::xdr::{TransactionEnvelope, TransactionV1Envelope, VecM}; use std::{convert::TryFrom, str::FromStr}; use strum::Display; use utoipa::ToSchema; use uuid::Uuid; -use soroban_rs::xdr::{ - Transaction as SorobanTransaction, TransactionEnvelope, TransactionV1Envelope, VecM, -}; +use soroban_rs::xdr::Transaction as SorobanTransaction; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, ToSchema, Display)] #[serde(rename_all = "lowercase")] @@ -917,6 +916,9 @@ impl let valid_until = extract_stellar_valid_until(stellar_request, Utc::now()); + let transaction_input = TransactionInput::from_stellar_request(stellar_request) + .map_err(|e| RelayerError::ValidationError(e.to_string()))?; + let stellar_data = StellarTransactionData { source_account: source_account.unwrap_or_else(|| relayer_model.address.clone()), memo: stellar_request.memo.clone(), @@ -927,8 +929,7 @@ impl fee: None, sequence_number: None, simulation_transaction_data: None, - transaction_input: TransactionInput::from_stellar_request(stellar_request) - .map_err(|e| RelayerError::ValidationError(e.to_string()))?, + transaction_input, signed_envelope_xdr: None, transaction_result_xdr: None, }; diff --git a/src/models/transaction/request/mod.rs b/src/models/transaction/request/mod.rs index a9564d02b..69f6a7fbb 100644 --- a/src/models/transaction/request/mod.rs +++ b/src/models/transaction/request/mod.rs @@ -52,13 +52,17 @@ impl NetworkTransactionRequest { /// Network-agnostic fee estimate request parameters for gasless transactions. /// Contains network-specific request parameters for fee estimation. /// The network type is inferred from the relayer's network configuration. +/// +/// For Stellar, supports both classic and Soroban gas abstraction: +/// - Classic: Pass operations or transaction_xdr with classic fee token (native/USDC:GA...) +/// - Soroban: Pass transaction_xdr containing InvokeHostFunction, user_address, and contract fee token (C...) #[derive(Debug, Deserialize, Serialize, PartialEq, ToSchema, Clone)] #[serde(untagged)] #[schema(as = SponsoredTransactionQuoteRequest)] pub enum SponsoredTransactionQuoteRequest { /// Solana-specific fee estimate request parameters Solana(SolanaFeeEstimateRequestParams), - /// Stellar-specific fee estimate request parameters + /// Stellar-specific fee estimate request parameters (classic and Soroban) Stellar(StellarFeeEstimateRequestParams), } @@ -74,13 +78,17 @@ impl SponsoredTransactionQuoteRequest { /// Network-agnostic prepare transaction request parameters for gasless transactions. /// Contains network-specific request parameters for preparing transactions with fee payments. /// The network type is inferred from the relayer's network configuration. +/// +/// For Stellar, supports both classic and Soroban gas abstraction: +/// - Classic: Pass operations or transaction_xdr with classic fee token +/// - Soroban: Pass transaction_xdr containing InvokeHostFunction, user_address, and contract fee token #[derive(Debug, Deserialize, Serialize, PartialEq, ToSchema, Clone)] #[serde(untagged)] #[schema(as = SponsoredTransactionBuildRequest)] pub enum SponsoredTransactionBuildRequest { /// Solana-specific prepare transaction request parameters Solana(SolanaPrepareTransactionRequestParams), - /// Stellar-specific prepare transaction request parameters + /// Stellar-specific prepare transaction request parameters (classic and Soroban) Stellar(StellarPrepareTransactionRequestParams), } diff --git a/src/models/transaction/request/stellar.rs b/src/models/transaction/request/stellar.rs index 4905c0281..ebf731a1c 100644 --- a/src/models/transaction/request/stellar.rs +++ b/src/models/transaction/request/stellar.rs @@ -15,7 +15,9 @@ pub struct StellarTransactionRequest { #[schema(nullable = true)] pub valid_until: Option, /// Pre-built transaction XDR (base64 encoded, signed or unsigned) - /// Mutually exclusive with operations field + /// Mutually exclusive with operations field. + /// For Soroban gas abstraction: submit the transaction XDR from sponsored/build response + /// with the user's signed auth entry updated inside. #[schema(nullable = true)] pub transaction_xdr: Option, /// Explicitly request fee-bump wrapper diff --git a/src/models/transaction/response.rs b/src/models/transaction/response.rs index e2bba2ec2..2b7e1a5d1 100644 --- a/src/models/transaction/response.rs +++ b/src/models/transaction/response.rs @@ -181,7 +181,7 @@ impl From for TransactionResponse { pub enum SponsoredTransactionQuoteResponse { /// Solana-specific fee estimate result Solana(SolanaFeeEstimateResult), - /// Stellar-specific fee estimate result + /// Stellar-specific fee estimate result (classic and Soroban) Stellar(StellarFeeEstimateResult), } @@ -193,7 +193,8 @@ pub enum SponsoredTransactionQuoteResponse { pub enum SponsoredTransactionBuildResponse { /// Solana-specific prepare transaction result Solana(SolanaPrepareTransactionResult), - /// Stellar-specific prepare transaction result + /// Stellar-specific prepare transaction result (classic and Soroban) + /// For Soroban: includes optional user_auth_entry, expiration_ledger Stellar(StellarPrepareTransactionResult), } diff --git a/src/services/mod.rs b/src/services/mod.rs index 83fb7f7a8..3cb31e8a2 100644 --- a/src/services/mod.rs +++ b/src/services/mod.rs @@ -20,6 +20,9 @@ pub use jupiter::*; pub mod stellar_dex; pub use stellar_dex::*; +pub mod stellar_fee_forwarder; +pub use stellar_fee_forwarder::*; + mod vault; pub use vault::*; diff --git a/src/services/stellar_dex/mod.rs b/src/services/stellar_dex/mod.rs index 4cbacb0be..c2f0f7d27 100644 --- a/src/services/stellar_dex/mod.rs +++ b/src/services/stellar_dex/mod.rs @@ -1,11 +1,13 @@ //! Stellar DEX service module //! Provides quote conversion services for Stellar tokens to XLM -//! Supports native Stellar paths API and optional Soroswap integration +//! Supports native Stellar paths API and Soroswap integration for Soroban tokens mod order_book_service; +mod soroswap_service; mod stellar_dex_service; pub use order_book_service::OrderBookService; +pub use soroswap_service::SoroswapService; pub use stellar_dex_service::{DexServiceWrapper, StellarDexService}; use async_trait::async_trait; diff --git a/src/services/stellar_dex/soroswap_service.rs b/src/services/stellar_dex/soroswap_service.rs new file mode 100644 index 000000000..810fef2e6 --- /dev/null +++ b/src/services/stellar_dex/soroswap_service.rs @@ -0,0 +1,463 @@ +//! Soroswap DEX Service implementation +//! +//! Uses Soroswap AMM router contract for token swaps on Soroban. +//! This service handles swaps between Soroban token contracts (C... addresses) and XLM. +//! +//! The router contract provides `get_amounts_out` for quotes and +//! `swap_exact_tokens_for_tokens` for executing swaps. + +use super::{ + AssetType, PathStep, StellarDexServiceError, StellarDexServiceTrait, StellarQuoteResponse, + SwapExecutionResult, SwapTransactionParams, +}; +use crate::services::provider::StellarProviderTrait; +use async_trait::async_trait; +use soroban_rs::xdr::{ContractId, Hash, Int128Parts, ScAddress, ScSymbol, ScVal, ScVec}; +use std::collections::HashSet; +use std::sync::Arc; +use tracing::{debug, warn}; + +/// Native XLM wrapper token contract address +/// This is the Soroban token contract that wraps native XLM for use in Soroswap +const MAINNET_NATIVE_WRAPPER: &str = "CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA"; +const TESTNET_NATIVE_WRAPPER: &str = "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC"; + +/// Soroswap AMM DEX service for Soroban token swaps +/// +/// This service uses Soroswap's router contract to: +/// - Get quotes by simulating `get_amounts_out` +/// - Execute swaps via `swap_exact_tokens_for_tokens` +pub struct SoroswapService

+where + P: StellarProviderTrait + Send + Sync + 'static, +{ + /// Soroswap router contract address + router_address: String, + /// Native XLM wrapper token address + native_wrapper_address: String, + /// Stellar provider for contract calls + provider: Arc

, + /// Network passphrase for signing (used for swap execution) + #[allow(dead_code)] + network_passphrase: String, +} + +impl

SoroswapService

+where + P: StellarProviderTrait + Send + Sync + 'static, +{ + /// Create a new SoroswapService instance + /// + /// # Arguments + /// + /// * `router_address` - Soroswap router contract address + /// * `native_wrapper_address` - Optional native XLM wrapper token address (uses default if None) + /// * `provider` - Stellar provider for contract calls + /// * `network_passphrase` - Network passphrase + /// * `is_testnet` - Whether this is testnet (affects default addresses) + pub fn new( + router_address: String, + native_wrapper_address: Option, + provider: Arc

, + network_passphrase: String, + is_testnet: bool, + ) -> Self { + let native_wrapper = native_wrapper_address.unwrap_or_else(|| { + if is_testnet { + TESTNET_NATIVE_WRAPPER.to_string() + } else { + MAINNET_NATIVE_WRAPPER.to_string() + } + }); + + Self { + router_address, + native_wrapper_address: native_wrapper, + provider, + network_passphrase, + } + } + + /// Parse a Soroban contract address (C...) to ScAddress + fn parse_contract_address(address: &str) -> Result { + let contract = stellar_strkey::Contract::from_string(address).map_err(|e| { + StellarDexServiceError::InvalidAssetIdentifier(format!( + "Invalid Soroban contract address '{address}': {e}" + )) + })?; + + Ok(ScAddress::Contract(ContractId(Hash(contract.0)))) + } + + /// Build a Vec path for router calls + fn build_path( + &self, + from_token: &str, + to_token: &str, + ) -> Result { + let from_addr = Self::parse_contract_address(from_token)?; + let to_addr = Self::parse_contract_address(to_token)?; + + // Simple direct path: [from_token, to_token] + let path_vec: ScVec = vec![ScVal::Address(from_addr), ScVal::Address(to_addr)] + .try_into() + .map_err(|_| { + StellarDexServiceError::UnknownError("Failed to create path vector".to_string()) + })?; + + Ok(ScVal::Vec(Some(path_vec))) + } + + /// Convert i128 to ScVal::I128 + fn i128_to_scval(amount: i128) -> ScVal { + let hi = (amount >> 64) as i64; + let lo = amount as u64; + ScVal::I128(Int128Parts { hi, lo }) + } + + /// Extract i128 from ScVal::I128 + fn scval_to_i128(val: &ScVal) -> Result { + match val { + ScVal::I128(parts) => { + let result = ((parts.hi as i128) << 64) | (parts.lo as i128); + Ok(result) + } + _ => Err(StellarDexServiceError::UnknownError( + "Expected I128 value from router".to_string(), + )), + } + } + + /// Extract Vec from ScVal::Vec of I128s + fn scval_to_amounts_vec(val: &ScVal) -> Result, StellarDexServiceError> { + match val { + ScVal::Vec(Some(sc_vec)) => { + let mut amounts = Vec::new(); + for item in sc_vec.iter() { + amounts.push(Self::scval_to_i128(item)?); + } + Ok(amounts) + } + _ => Err(StellarDexServiceError::UnknownError( + "Expected Vec of I128 values from router".to_string(), + )), + } + } + + /// Call router.get_amounts_out to get quote + /// + /// Returns the expected output amounts for each step in the path + async fn call_get_amounts_out( + &self, + amount_in: i128, + path: ScVal, + ) -> Result, StellarDexServiceError> { + let function_name = ScSymbol::try_from("get_amounts_out").map_err(|_| { + StellarDexServiceError::UnknownError("Failed to create function symbol".to_string()) + })?; + + let args = vec![Self::i128_to_scval(amount_in), path]; + + debug!( + router = %self.router_address, + amount_in = amount_in, + "Calling Soroswap router get_amounts_out" + ); + + let result = self + .provider + .call_contract(&self.router_address, &function_name, args) + .await + .map_err(|e| StellarDexServiceError::ApiError { + message: format!("Soroswap router call failed: {e}"), + })?; + + Self::scval_to_amounts_vec(&result) + } +} + +#[async_trait] +impl

StellarDexServiceTrait for SoroswapService

+where + P: StellarProviderTrait + Send + Sync + 'static, +{ + fn supported_asset_types(&self) -> HashSet { + // Soroswap supports Soroban contract tokens and Native XLM (via wrapper) + HashSet::from([AssetType::Native, AssetType::Contract]) + } + + fn can_handle_asset(&self, asset_id: &str) -> bool { + // Handle native XLM (will use wrapper) + if asset_id == "native" || asset_id.is_empty() { + return true; + } + + // Handle Soroban contract tokens (C... format, 56 chars) + if asset_id.starts_with('C') + && asset_id.len() == 56 + && !asset_id.contains(':') + && stellar_strkey::Contract::from_string(asset_id).is_ok() + { + return true; + } + + false + } + + async fn get_token_to_xlm_quote( + &self, + asset_id: &str, + amount: u64, + slippage: f32, + _asset_decimals: Option, + ) -> Result { + // For native XLM, return 1:1 + if asset_id == "native" || asset_id.is_empty() { + return Ok(StellarQuoteResponse { + input_asset: "native".to_string(), + output_asset: "native".to_string(), + in_amount: amount, + out_amount: amount, + price_impact_pct: 0.0, + slippage_bps: (slippage * 100.0) as u32, + path: None, + }); + } + + // Build path: [token, native_wrapper] + let path = self.build_path(asset_id, &self.native_wrapper_address)?; + + // Call router to get quote + let amounts = self.call_get_amounts_out(amount as i128, path).await?; + + // Last amount is the output + let out_amount = amounts + .last() + .copied() + .ok_or_else(|| StellarDexServiceError::NoPathFound)?; + + if out_amount <= 0 { + return Err(StellarDexServiceError::NoPathFound); + } + + // Safe conversion from i128 to u64 - we already checked out_amount > 0 above + let out_amount_u64 = u64::try_from(out_amount).map_err(|_| { + StellarDexServiceError::UnknownError(format!( + "Output amount {out_amount} exceeds u64::MAX" + )) + })?; + + // Calculate price impact (simplified - assumes 1:1 expected ratio) + // TODO: Use pool reserves for accurate price impact calculation + let price_impact = if amount > 0 && out_amount_u64 > 0 { + let expected_ratio = 1.0; + let actual_ratio = out_amount_u64 as f64 / amount as f64; + ((expected_ratio - actual_ratio).abs() / expected_ratio * 100.0).min(100.0) + } else { + 0.0 + }; + + debug!( + asset = %asset_id, + in_amount = amount, + out_amount = out_amount_u64, + "Soroswap quote: token -> XLM" + ); + + Ok(StellarQuoteResponse { + input_asset: asset_id.to_string(), + output_asset: "native".to_string(), + in_amount: amount, + out_amount: out_amount_u64, + price_impact_pct: price_impact, + slippage_bps: (slippage * 100.0) as u32, + path: Some(vec![ + PathStep { + asset_code: Some(asset_id.to_string()), + asset_issuer: None, + amount, + }, + PathStep { + asset_code: Some("native".to_string()), + asset_issuer: None, + amount: out_amount_u64, + }, + ]), + }) + } + + async fn get_xlm_to_token_quote( + &self, + asset_id: &str, + amount: u64, + slippage: f32, + _asset_decimals: Option, + ) -> Result { + // For native XLM, return 1:1 + if asset_id == "native" || asset_id.is_empty() { + return Ok(StellarQuoteResponse { + input_asset: "native".to_string(), + output_asset: "native".to_string(), + in_amount: amount, + out_amount: amount, + price_impact_pct: 0.0, + slippage_bps: (slippage * 100.0) as u32, + path: None, + }); + } + + // Build path: [native_wrapper, token] + let path = self.build_path(&self.native_wrapper_address, asset_id)?; + + // Call router to get quote + let amounts = self.call_get_amounts_out(amount as i128, path).await?; + + // Last amount is the output + let out_amount = amounts + .last() + .copied() + .ok_or_else(|| StellarDexServiceError::NoPathFound)?; + + if out_amount <= 0 { + return Err(StellarDexServiceError::NoPathFound); + } + + // Safe conversion from i128 to u64 - we already checked out_amount > 0 above + let out_amount_u64 = u64::try_from(out_amount).map_err(|_| { + StellarDexServiceError::UnknownError(format!( + "Output amount {out_amount} exceeds u64::MAX" + )) + })?; + + // Calculate price impact (simplified - assumes 1:1 expected ratio) + // TODO: Use pool reserves for accurate price impact calculation + let price_impact = if amount > 0 && out_amount_u64 > 0 { + let expected_ratio = 1.0; + let actual_ratio = out_amount_u64 as f64 / amount as f64; + ((expected_ratio - actual_ratio).abs() / expected_ratio * 100.0).min(100.0) + } else { + 0.0 + }; + + debug!( + asset = %asset_id, + in_amount = amount, + out_amount = out_amount_u64, + "Soroswap quote: XLM -> token" + ); + + Ok(StellarQuoteResponse { + input_asset: "native".to_string(), + output_asset: asset_id.to_string(), + in_amount: amount, + out_amount: out_amount_u64, + price_impact_pct: price_impact, + slippage_bps: (slippage * 100.0) as u32, + path: Some(vec![ + PathStep { + asset_code: Some("native".to_string()), + asset_issuer: None, + amount, + }, + PathStep { + asset_code: Some(asset_id.to_string()), + asset_issuer: None, + amount: out_amount_u64, + }, + ]), + }) + } + + async fn prepare_swap_transaction( + &self, + params: SwapTransactionParams, + ) -> Result<(String, StellarQuoteResponse), StellarDexServiceError> { + // For gas abstraction, we don't actually need to prepare a swap transaction + // The FeeForwarder contract handles the token transfer + // This method is mainly used for the relayer's own token swaps + + warn!("Soroswap prepare_swap_transaction is not yet fully implemented"); + + // Get the quote first + let quote = if params.destination_asset == "native" { + self.get_token_to_xlm_quote( + ¶ms.source_asset, + params.amount, + params.slippage_percent, + params.source_asset_decimals, + ) + .await? + } else if params.source_asset == "native" { + self.get_xlm_to_token_quote( + ¶ms.destination_asset, + params.amount, + params.slippage_percent, + params.destination_asset_decimals, + ) + .await? + } else { + return Err(StellarDexServiceError::InvalidAssetIdentifier( + "Soroswap currently only supports swaps involving native XLM".to_string(), + )); + }; + + // TODO: Build the actual swap transaction XDR + // For now, return empty transaction as placeholder + let placeholder_xdr = String::new(); + + Ok((placeholder_xdr, quote)) + } + + async fn execute_swap( + &self, + _params: SwapTransactionParams, + ) -> Result { + // TODO: Implement actual swap execution + // This requires building and submitting a Soroswap swap transaction + + warn!("Soroswap execute_swap is not yet implemented"); + + Err(StellarDexServiceError::UnknownError( + "Soroswap swap execution is not yet implemented".to_string(), + )) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::services::provider::StellarProvider; + + #[test] + fn test_can_handle_asset_native() { + // We can't easily test this without a mock provider + // This test just validates the asset type detection logic + assert!( + "native".is_empty() || "native" == "native", + "Should handle native" + ); + } + + #[test] + fn test_can_handle_asset_contract() { + let contract_addr = "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC"; + assert!(contract_addr.starts_with('C')); + assert_eq!(contract_addr.len(), 56); + assert!(!contract_addr.contains(':')); + assert!(stellar_strkey::Contract::from_string(contract_addr).is_ok()); + } + + #[test] + fn test_cannot_handle_classic_asset() { + let classic_asset = "USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN"; + assert!(classic_asset.contains(':')); + } + + #[test] + fn test_i128_conversion() { + let original: i128 = 1_000_000_000; + let scval = SoroswapService::::i128_to_scval(original); + let recovered = SoroswapService::::scval_to_i128(&scval).unwrap(); + assert_eq!(original, recovered); + } +} diff --git a/src/services/stellar_dex/stellar_dex_service.rs b/src/services/stellar_dex/stellar_dex_service.rs index 58680781c..8902b218a 100644 --- a/src/services/stellar_dex/stellar_dex_service.rs +++ b/src/services/stellar_dex/stellar_dex_service.rs @@ -5,7 +5,7 @@ //! and internally routes calls to the first strategy that can handle the requested asset. use super::{ - AssetType, OrderBookService, StellarDexServiceError, StellarDexServiceTrait, + AssetType, OrderBookService, SoroswapService, StellarDexServiceError, StellarDexServiceTrait, StellarQuoteResponse, SwapExecutionResult, SwapTransactionParams, }; use crate::services::{provider::StellarProviderTrait, signer::Signer, signer::StellarSignTrait}; @@ -24,10 +24,10 @@ where P: StellarProviderTrait + Send + Sync + 'static, S: StellarSignTrait + Signer + Send + Sync + 'static, { - /// Order Book DEX service + /// Order Book DEX service (for classic Stellar assets) OrderBook(Arc>), - // TODO: Add Soroswap variant when implemented - // Soroswap(Arc>), + /// Soroswap DEX service (for Soroban contract tokens) + Soroswap(Arc>), } impl DexServiceWrapper @@ -38,14 +38,14 @@ where fn can_handle_asset(&self, asset_id: &str) -> bool { match self { DexServiceWrapper::OrderBook(service) => service.can_handle_asset(asset_id), - // DexServiceWrapper::Soroswap(service) => service.can_handle_asset(asset_id), + DexServiceWrapper::Soroswap(service) => service.can_handle_asset(asset_id), } } fn supported_asset_types(&self) -> HashSet { match self { DexServiceWrapper::OrderBook(service) => service.supported_asset_types(), - // DexServiceWrapper::Soroswap(service) => service.supported_asset_types(), + DexServiceWrapper::Soroswap(service) => service.supported_asset_types(), } } } @@ -137,10 +137,11 @@ where DexServiceWrapper::OrderBook(svc) => { svc.get_token_to_xlm_quote(asset_id, amount, slippage, asset_decimals) .await - } // DexServiceWrapper::Soroswap(svc) => { - // svc.get_token_to_xlm_quote(asset_id, amount, slippage, asset_decimals) - // .await - // } + } + DexServiceWrapper::Soroswap(svc) => { + svc.get_token_to_xlm_quote(asset_id, amount, slippage, asset_decimals) + .await + } } } @@ -161,10 +162,11 @@ where DexServiceWrapper::OrderBook(svc) => { svc.get_xlm_to_token_quote(asset_id, amount, slippage, asset_decimals) .await - } // DexServiceWrapper::Soroswap(svc) => { - // svc.get_xlm_to_token_quote(asset_id, amount, slippage, asset_decimals) - // .await - // } + } + DexServiceWrapper::Soroswap(svc) => { + svc.get_xlm_to_token_quote(asset_id, amount, slippage, asset_decimals) + .await + } } } @@ -183,7 +185,7 @@ where match strategy { DexServiceWrapper::OrderBook(svc) => svc.prepare_swap_transaction(params).await, - // DexServiceWrapper::Soroswap(svc) => svc.prepare_swap_transaction(params).await, + DexServiceWrapper::Soroswap(svc) => svc.prepare_swap_transaction(params).await, } } @@ -202,7 +204,7 @@ where match strategy { DexServiceWrapper::OrderBook(svc) => svc.execute_swap(params).await, - // DexServiceWrapper::Soroswap(svc) => svc.execute_swap(params).await, + DexServiceWrapper::Soroswap(svc) => svc.execute_swap(params).await, } } } diff --git a/src/services/stellar_fee_forwarder/mod.rs b/src/services/stellar_fee_forwarder/mod.rs new file mode 100644 index 000000000..5599a0ea7 --- /dev/null +++ b/src/services/stellar_fee_forwarder/mod.rs @@ -0,0 +1,560 @@ +//! FeeForwarder Service for Soroban Gas Abstraction +//! +//! This module provides functionality to build and manage transactions +//! using the FeeForwarder contract for gas abstraction on Soroban. +//! +//! The FeeForwarder contract enables fee abstraction by allowing users to pay +//! relayers in tokens instead of native XLM. It atomically: +//! 1. Collects fee payment from user +//! 2. Forwards the call to the target contract +//! +//! ## Authorization Flow +//! +//! User signs authorization for `fee_forwarder.forward()` with sub-invocations: +//! - `fee_token.approve(fee_forwarder, max_fee_amount, expiration_ledger)` +//! - `target_contract.target_fn(target_args)` (if target requires auth) + +use crate::services::provider::StellarProviderTrait; +use soroban_rs::xdr::{ + ContractId, Hash, Int128Parts, InvokeContractArgs, Limits, Operation, OperationBody, ScAddress, + ScSymbol, ScVal, ScVec, SorobanAddressCredentials, SorobanAuthorizationEntry, + SorobanAuthorizedFunction, SorobanAuthorizedInvocation, SorobanCredentials, VecM, WriteXdr, +}; +use std::sync::Arc; +use thiserror::Error; + +/// Default validity duration for gas abstraction authorizations (5 minutes) +pub const DEFAULT_VALIDITY_SECONDS: u64 = 300; + +/// Approximate ledger time in seconds +pub const LEDGER_TIME_SECONDS: u64 = 5; + +/// Errors that can occur in FeeForwarder operations +#[derive(Error, Debug)] +pub enum FeeForwarderError { + #[error("Invalid contract address: {0}")] + InvalidContractAddress(String), + + #[error("Invalid account address: {0}")] + InvalidAccountAddress(String), + + #[error("Failed to build authorization: {0}")] + AuthorizationBuildError(String), + + #[error("Provider error: {0}")] + ProviderError(String), + + #[error("XDR serialization error: {0}")] + XdrError(String), + + #[error("Invalid function name: {0}")] + InvalidFunctionName(String), +} + +/// Parameters for building a FeeForwarder transaction +#[derive(Debug, Clone)] +pub struct FeeForwarderParams { + /// Soroban token contract address for fee payment + pub fee_token: String, + /// Actual fee amount to charge (determined at submission time) + pub fee_amount: i128, + /// Maximum fee amount user authorized + pub max_fee_amount: i128, + /// Authorization expiration ledger + pub expiration_ledger: u32, + /// Target contract address to call + pub target_contract: String, + /// Target function name + pub target_fn: String, + /// Target function arguments + pub target_args: Vec, + /// User's Stellar address + pub user: String, + /// Relayer's Stellar address (fee recipient) + pub relayer: String, +} + +/// Service for building FeeForwarder transactions +pub struct FeeForwarderService

+where + P: StellarProviderTrait + Send + Sync, +{ + /// FeeForwarder contract address + fee_forwarder_address: String, + /// Stellar provider for network queries + provider: Arc

, +} + +impl

FeeForwarderService

+where + P: StellarProviderTrait + Send + Sync, +{ + /// Create a new FeeForwarderService + /// + /// # Arguments + /// + /// * `fee_forwarder_address` - The deployed FeeForwarder contract address + /// * `provider` - Stellar provider for network queries + pub fn new(fee_forwarder_address: String, provider: Arc

) -> Self { + Self { + fee_forwarder_address, + provider, + } + } + + /// Build a user authorization entry without requiring a FeeForwarderService instance + /// + /// This static method is useful when you don't have an `Arc

` provider + /// but still need to build authorization entries for the FeeForwarder. + pub fn build_user_auth_entry_standalone( + fee_forwarder_address: &str, + params: &FeeForwarderParams, + requires_target_auth: bool, + ) -> Result { + let fee_forwarder_addr = Self::parse_contract_address(fee_forwarder_address)?; + let fee_token_addr = Self::parse_contract_address(¶ms.fee_token)?; + let target_contract_addr = Self::parse_contract_address(¶ms.target_contract)?; + let user_addr = Self::parse_account_address(¶ms.user)?; + let _relayer_addr = Self::parse_account_address(¶ms.relayer)?; + + // Build sub-invocations + let mut sub_invocations = Vec::new(); + + // 1. fee_token.approve(fee_forwarder, max_fee_amount, expiration_ledger) + let approve_args: ScVec = vec![ + ScVal::Address(fee_forwarder_addr.clone()), + Self::i128_to_scval(params.max_fee_amount), + ScVal::U32(params.expiration_ledger), + ] + .try_into() + .map_err(|_| { + FeeForwarderError::AuthorizationBuildError("Failed to create approve args".to_string()) + })?; + + sub_invocations.push(SorobanAuthorizedInvocation { + function: SorobanAuthorizedFunction::ContractFn(InvokeContractArgs { + contract_address: fee_token_addr, + function_name: Self::create_symbol("approve")?, + args: approve_args.into(), + }), + sub_invocations: VecM::default(), + }); + + // 2. target_contract.target_fn(target_args) - if needed + if requires_target_auth { + let target_args: ScVec = params.target_args.clone().try_into().map_err(|_| { + FeeForwarderError::AuthorizationBuildError( + "Failed to create target args".to_string(), + ) + })?; + + sub_invocations.push(SorobanAuthorizedInvocation { + function: SorobanAuthorizedFunction::ContractFn(InvokeContractArgs { + contract_address: target_contract_addr, + function_name: Self::create_symbol(¶ms.target_fn)?, + args: target_args.into(), + }), + sub_invocations: VecM::default(), + }); + } + + // Build the forward() function arguments + let forward_args = Self::build_forward_args_standalone(fee_forwarder_address, params)?; + + // Build the root invocation for fee_forwarder.forward() + let root_invocation = SorobanAuthorizedInvocation { + function: SorobanAuthorizedFunction::ContractFn(InvokeContractArgs { + contract_address: fee_forwarder_addr, + function_name: Self::create_symbol("forward")?, + args: forward_args.into(), + }), + sub_invocations: sub_invocations.try_into().map_err(|_| { + FeeForwarderError::AuthorizationBuildError( + "Failed to create sub-invocations".to_string(), + ) + })?, + }; + + // Generate nonce using timestamp + let nonce = { + use std::time::{SystemTime, UNIX_EPOCH}; + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos() as i64) + .unwrap_or(0) + }; + + Ok(SorobanAuthorizationEntry { + credentials: SorobanCredentials::Address(SorobanAddressCredentials { + address: user_addr, + nonce, + signature_expiration_ledger: params.expiration_ledger, + signature: ScVal::Void, + }), + root_invocation, + }) + } + + /// Build forward args without requiring a service instance + fn build_forward_args_standalone( + _fee_forwarder_address: &str, + params: &FeeForwarderParams, + ) -> Result { + let fee_token_addr = Self::parse_contract_address(¶ms.fee_token)?; + let target_contract_addr = Self::parse_contract_address(¶ms.target_contract)?; + let user_addr = Self::parse_account_address(¶ms.user)?; + let relayer_addr = Self::parse_account_address(¶ms.relayer)?; + + let target_args_vec: ScVec = params.target_args.clone().try_into().map_err(|_| { + FeeForwarderError::AuthorizationBuildError("Failed to create target args".to_string()) + })?; + + let args: Vec = vec![ + ScVal::Address(fee_token_addr), + Self::i128_to_scval(params.fee_amount), + Self::i128_to_scval(params.max_fee_amount), + ScVal::U32(params.expiration_ledger), + ScVal::Address(target_contract_addr), + ScVal::Symbol(Self::create_symbol(¶ms.target_fn)?), + ScVal::Vec(Some(target_args_vec)), + ScVal::Address(user_addr), + ScVal::Address(relayer_addr), + ]; + + args.try_into().map_err(|_| { + FeeForwarderError::AuthorizationBuildError("Failed to create forward args".to_string()) + }) + } + + /// Get the FeeForwarder contract address + pub fn fee_forwarder_address(&self) -> &str { + &self.fee_forwarder_address + } + + /// Public wrapper for building forward args (for serializing InvokeContractArgs) + pub fn build_forward_args_for_invoke_contract_args( + fee_forwarder_address: &str, + params: &FeeForwarderParams, + ) -> Result { + Self::build_forward_args_standalone(fee_forwarder_address, params) + } + + /// Public wrapper for parsing a contract address + pub fn parse_contract_address_public(address: &str) -> Result { + Self::parse_contract_address(address) + } + + /// Calculate the expiration ledger for authorization + /// + /// # Arguments + /// + /// * `validity_seconds` - How long the authorization should be valid + /// + /// # Returns + /// + /// The ledger number when the authorization expires + pub async fn get_expiration_ledger( + &self, + validity_seconds: u64, + ) -> Result { + let current_ledger = self + .provider + .get_latest_ledger() + .await + .map_err(|e| FeeForwarderError::ProviderError(e.to_string()))?; + + let ledgers_to_add = validity_seconds / LEDGER_TIME_SECONDS; + Ok(current_ledger.sequence + ledgers_to_add as u32) + } + + /// Parse a Soroban contract address (C...) to ScAddress + fn parse_contract_address(address: &str) -> Result { + let contract = stellar_strkey::Contract::from_string(address).map_err(|e| { + FeeForwarderError::InvalidContractAddress(format!("Invalid contract '{address}': {e}")) + })?; + + Ok(ScAddress::Contract(ContractId(Hash(contract.0)))) + } + + /// Parse a Stellar account address (G...) to ScAddress + fn parse_account_address(address: &str) -> Result { + let account = stellar_strkey::ed25519::PublicKey::from_string(address).map_err(|e| { + FeeForwarderError::InvalidAccountAddress(format!("Invalid account '{address}': {e}")) + })?; + + Ok(ScAddress::Account(soroban_rs::xdr::AccountId( + soroban_rs::xdr::PublicKey::PublicKeyTypeEd25519(soroban_rs::xdr::Uint256(account.0)), + ))) + } + + /// Convert i128 to ScVal::I128 + fn i128_to_scval(amount: i128) -> ScVal { + let hi = (amount >> 64) as i64; + let lo = amount as u64; + ScVal::I128(Int128Parts { hi, lo }) + } + + /// Create a ScSymbol from a function name + fn create_symbol(name: &str) -> Result { + ScSymbol::try_from(name.as_bytes().to_vec()) + .map_err(|_| FeeForwarderError::InvalidFunctionName(name.to_string())) + } + + /// Build the user authorization entry for FeeForwarder.forward() + /// + /// This creates the authorization structure that the user needs to sign. + /// The authorization includes sub-invocations for: + /// - fee_token.approve(fee_forwarder, max_fee_amount, expiration_ledger) + /// - target_contract.target_fn(target_args) (if requires_target_auth is true) + /// + /// # Arguments + /// + /// * `params` - FeeForwarder transaction parameters + /// * `requires_target_auth` - Whether the target contract call requires user authorization + /// + /// # Returns + /// + /// A SorobanAuthorizationEntry that the user needs to sign + pub fn build_user_auth_entry( + &self, + params: &FeeForwarderParams, + requires_target_auth: bool, + ) -> Result { + let fee_forwarder_addr = Self::parse_contract_address(&self.fee_forwarder_address)?; + let fee_token_addr = Self::parse_contract_address(¶ms.fee_token)?; + let target_contract_addr = Self::parse_contract_address(¶ms.target_contract)?; + let user_addr = Self::parse_account_address(¶ms.user)?; + // Validate relayer address is valid (used later in build_forward_args) + let _relayer_addr = Self::parse_account_address(¶ms.relayer)?; + + // Build sub-invocations + let mut sub_invocations = Vec::new(); + + // 1. fee_token.approve(fee_forwarder, max_fee_amount, expiration_ledger) + let approve_args: ScVec = vec![ + ScVal::Address(fee_forwarder_addr.clone()), + Self::i128_to_scval(params.max_fee_amount), + ScVal::U32(params.expiration_ledger), + ] + .try_into() + .map_err(|_| { + FeeForwarderError::AuthorizationBuildError("Failed to create approve args".to_string()) + })?; + + sub_invocations.push(SorobanAuthorizedInvocation { + function: SorobanAuthorizedFunction::ContractFn(InvokeContractArgs { + contract_address: fee_token_addr, + function_name: Self::create_symbol("approve")?, + args: approve_args.into(), + }), + sub_invocations: VecM::default(), + }); + + // 2. target_contract.target_fn(target_args) - if needed + if requires_target_auth { + let target_args: ScVec = params.target_args.clone().try_into().map_err(|_| { + FeeForwarderError::AuthorizationBuildError( + "Failed to create target args".to_string(), + ) + })?; + + sub_invocations.push(SorobanAuthorizedInvocation { + function: SorobanAuthorizedFunction::ContractFn(InvokeContractArgs { + contract_address: target_contract_addr.clone(), + function_name: Self::create_symbol(¶ms.target_fn)?, + args: target_args.into(), + }), + sub_invocations: VecM::default(), + }); + } + + // Build the forward() function arguments + let forward_args = self.build_forward_args(params)?; + + // Build the root invocation for fee_forwarder.forward() + let root_invocation = SorobanAuthorizedInvocation { + function: SorobanAuthorizedFunction::ContractFn(InvokeContractArgs { + contract_address: fee_forwarder_addr, + function_name: Self::create_symbol("forward")?, + args: forward_args.into(), + }), + sub_invocations: sub_invocations.try_into().map_err(|_| { + FeeForwarderError::AuthorizationBuildError( + "Failed to create sub-invocations".to_string(), + ) + })?, + }; + + // Build the authorization entry + // Note: The signature field is left empty (Void) - user will sign this + Ok(SorobanAuthorizationEntry { + credentials: SorobanCredentials::Address(SorobanAddressCredentials { + address: user_addr, + nonce: self.generate_nonce(), + signature_expiration_ledger: params.expiration_ledger, + signature: ScVal::Void, // User will fill this with their signature + }), + root_invocation, + }) + } + + /// Build the arguments for FeeForwarder.forward() call + fn build_forward_args(&self, params: &FeeForwarderParams) -> Result { + let fee_token_addr = Self::parse_contract_address(¶ms.fee_token)?; + let target_contract_addr = Self::parse_contract_address(¶ms.target_contract)?; + let user_addr = Self::parse_account_address(¶ms.user)?; + let relayer_addr = Self::parse_account_address(¶ms.relayer)?; + + // Convert target_args to ScVec for the target_args parameter + let target_args_vec: ScVec = params.target_args.clone().try_into().map_err(|_| { + FeeForwarderError::AuthorizationBuildError("Failed to create target args".to_string()) + })?; + + // forward() parameters: + // fee_token, fee_amount, max_fee_amount, expiration_ledger, + // target_contract, target_fn, target_args, user, relayer + let args: Vec = vec![ + ScVal::Address(fee_token_addr), + Self::i128_to_scval(params.fee_amount), + Self::i128_to_scval(params.max_fee_amount), + ScVal::U32(params.expiration_ledger), + ScVal::Address(target_contract_addr), + ScVal::Symbol(Self::create_symbol(¶ms.target_fn)?), + ScVal::Vec(Some(target_args_vec)), + ScVal::Address(user_addr), + ScVal::Address(relayer_addr), + ]; + + args.try_into().map_err(|_| { + FeeForwarderError::AuthorizationBuildError("Failed to create forward args".to_string()) + }) + } + + /// Generate a nonce for authorization + /// + /// The nonce should be unique per authorization to prevent replay attacks. + /// Using current timestamp as a simple nonce generator. + fn generate_nonce(&self) -> i64 { + use std::time::{SystemTime, UNIX_EPOCH}; + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos() as i64) + .unwrap_or(0) + } + + /// Serialize an authorization entry to base64 XDR + pub fn serialize_auth_entry( + auth: &SorobanAuthorizationEntry, + ) -> Result { + auth.to_xdr_base64(Limits::none()) + .map_err(|e| FeeForwarderError::XdrError(format!("Failed to serialize auth: {e}"))) + } + + /// Deserialize an authorization entry from base64 XDR + pub fn deserialize_auth_entry( + xdr: &str, + ) -> Result { + use soroban_rs::xdr::ReadXdr; + SorobanAuthorizationEntry::from_xdr_base64(xdr, Limits::none()) + .map_err(|e| FeeForwarderError::XdrError(format!("Failed to deserialize auth: {e}"))) + } + + /// Build the InvokeHostFunction operation for FeeForwarder.forward() + /// + /// This creates the operation that will be included in the transaction. + /// The authorization entries should include both user and relayer signatures. + /// + /// # Arguments + /// + /// * `params` - FeeForwarder transaction parameters + /// * `auth_entries` - Signed authorization entries (user + relayer) + pub fn build_invoke_operation( + &self, + params: &FeeForwarderParams, + auth_entries: Vec, + ) -> Result { + Self::build_invoke_operation_standalone(&self.fee_forwarder_address, params, auth_entries) + } + + /// Build the InvokeHostFunction operation without requiring a service instance. + /// + /// This static method is useful when you don't have an `Arc

` provider + /// but still need to build InvokeHostFunction operations for the FeeForwarder. + pub fn build_invoke_operation_standalone( + fee_forwarder_address: &str, + params: &FeeForwarderParams, + auth_entries: Vec, + ) -> Result { + let fee_forwarder_addr = Self::parse_contract_address(fee_forwarder_address)?; + let forward_args = Self::build_forward_args_standalone(fee_forwarder_address, params)?; + + let host_function = soroban_rs::xdr::HostFunction::InvokeContract(InvokeContractArgs { + contract_address: fee_forwarder_addr, + function_name: Self::create_symbol("forward")?, + args: forward_args.into(), + }); + + let invoke_op = soroban_rs::xdr::InvokeHostFunctionOp { + host_function, + auth: auth_entries.try_into().map_err(|_| { + FeeForwarderError::AuthorizationBuildError( + "Failed to create auth entries vector".to_string(), + ) + })?, + }; + + Ok(Operation { + source_account: None, + body: OperationBody::InvokeHostFunction(invoke_op), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::services::provider::StellarProvider; + + #[test] + fn test_parse_contract_address_valid() { + let addr = "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC"; + let result = FeeForwarderService::::parse_contract_address(addr); + assert!(result.is_ok()); + } + + #[test] + fn test_parse_contract_address_invalid() { + let addr = "INVALID"; + let result = FeeForwarderService::::parse_contract_address(addr); + assert!(result.is_err()); + } + + #[test] + fn test_parse_account_address_valid() { + let addr = "GABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890ABCDEFGH"; + // This is not a valid strkey, just testing the function signature + let result = FeeForwarderService::::parse_account_address(addr); + // Expected to fail since it's not a valid G... address + assert!(result.is_err()); + } + + #[test] + fn test_i128_to_scval() { + let amount: i128 = 1_000_000_000; + let scval = FeeForwarderService::::i128_to_scval(amount); + match scval { + ScVal::I128(parts) => { + let recovered = ((parts.hi as i128) << 64) | (parts.lo as i128); + assert_eq!(amount, recovered); + } + _ => panic!("Expected I128"), + } + } + + #[test] + fn test_create_symbol() { + let result = FeeForwarderService::::create_symbol("forward"); + assert!(result.is_ok()); + assert_eq!(result.unwrap().to_utf8_string_lossy(), "forward"); + } +} diff --git a/src/utils/mocks.rs b/src/utils/mocks.rs index 8056d540a..cad7283f7 100644 --- a/src/utils/mocks.rs +++ b/src/utils/mocks.rs @@ -337,6 +337,9 @@ pub mod mockutils { max_connections: 256, connection_backlog: 511, request_timeout_seconds: 30, + stellar_fee_forwarder_address: None, + stellar_soroswap_router_address: None, + stellar_soroswap_native_wrapper_address: None, } } } From 5be6fd55b957550fedb7ea4c81e7f96555137e6d Mon Sep 17 00:00:00 2001 From: Nicolas Molina Date: Fri, 30 Jan 2026 16:26:39 -0300 Subject: [PATCH 02/22] fix: Add factory contract address --- src/config/server_config.rs | 8 +++++++ src/domain/relayer/stellar/mod.rs | 24 ++++++++++++++++++-- src/services/stellar_dex/soroswap_service.rs | 15 +++++++++++- 3 files changed, 44 insertions(+), 3 deletions(-) diff --git a/src/config/server_config.rs b/src/config/server_config.rs index b538f4239..ebe37e14b 100644 --- a/src/config/server_config.rs +++ b/src/config/server_config.rs @@ -105,6 +105,8 @@ pub struct ServerConfig { pub stellar_fee_forwarder_address: Option, /// Stellar Soroswap router contract address for token-to-XLM quotes. pub stellar_soroswap_router_address: Option, + /// Stellar Soroswap factory contract address (required for get_amounts_out). + pub stellar_soroswap_factory_address: Option, /// Stellar native XLM wrapper token address for Soroswap. pub stellar_soroswap_native_wrapper_address: Option, } @@ -173,6 +175,7 @@ impl ServerConfig { request_timeout_seconds: Self::get_request_timeout_seconds(), stellar_fee_forwarder_address: Self::get_stellar_fee_forwarder_address(), stellar_soroswap_router_address: Self::get_stellar_soroswap_router_address(), + stellar_soroswap_factory_address: Self::get_stellar_soroswap_factory_address(), stellar_soroswap_native_wrapper_address: Self::get_stellar_soroswap_native_wrapper_address(), } @@ -498,6 +501,11 @@ impl ServerConfig { env::var("STELLAR_SOROSWAP_ROUTER_ADDRESS").ok() } + /// Gets the Stellar Soroswap factory contract address from environment variable + pub fn get_stellar_soroswap_factory_address() -> Option { + env::var("STELLAR_SOROSWAP_FACTORY_ADDRESS").ok() + } + /// Gets the Stellar Soroswap native wrapper token address from environment variable pub fn get_stellar_soroswap_native_wrapper_address() -> Option { env::var("STELLAR_SOROSWAP_NATIVE_WRAPPER_ADDRESS").ok() diff --git a/src/domain/relayer/stellar/mod.rs b/src/domain/relayer/stellar/mod.rs index f9794411a..55ff64ed7 100644 --- a/src/domain/relayer/stellar/mod.rs +++ b/src/domain/relayer/stellar/mod.rs @@ -119,12 +119,18 @@ pub async fn create_stellar_relayer< crate::config::ServerConfig::get_stellar_soroswap_router_address() .unwrap_or_else(|| get_default_soroswap_router(network.is_testnet())); + // Get Soroswap factory address from server config, falling back to default + let factory_address = + crate::config::ServerConfig::get_stellar_soroswap_factory_address() + .unwrap_or_else(|| get_default_soroswap_factory(network.is_testnet())); + // Get native wrapper address from server config if configured let native_wrapper_address = crate::config::ServerConfig::get_stellar_soroswap_native_wrapper_address(); let soroswap_service = Arc::new(SoroswapService::new( router_address, + factory_address, native_wrapper_address, provider_arc.clone(), network.passphrase.clone(), @@ -164,9 +170,23 @@ pub async fn create_stellar_relayer< fn get_default_soroswap_router(is_testnet: bool) -> String { if is_testnet { // Soroswap testnet router - "CDVQVKOY2YSXS2IC7KN6MNASSHPAO7UN2UR2ON4OI2SKMFJNVAMDX6DP".to_string() + "CCJUD55AG6W5HAI5LRVNKAE5WDP5XGZBUDS5WNTIVDU7O264UZZE7BRD".to_string() } else { // Soroswap mainnet router - "CBQDHNBFBZYE4MKPWBSJOPIYLW4SFSXAXUTSXJN76GNKYVYPCKWC6QUK".to_string() + "CAG5LRYQ5JVEUI5TEID72EYOVX44TTUJT5BQR2J6J77FH65PCCFAJDDH".to_string() + } +} + +/// Get the default Soroswap factory contract address for the given network +/// +/// These addresses are the official Soroswap factory deployments. +/// Users can override these via configuration. +fn get_default_soroswap_factory(is_testnet: bool) -> String { + if is_testnet { + // Soroswap testnet factory + "CDP3HMUH6SMS3S7NPGNDJLULCOXXEPSHY4JKUKMBNQMATHDHWXRRJTBY".to_string() + } else { + // Soroswap mainnet factory + "CA4HEQTL2WPEUYKYKCDOHCDNIV4QHNJ7EL4J4NQ6VADP7SYHVRYZ7AW2".to_string() } } diff --git a/src/services/stellar_dex/soroswap_service.rs b/src/services/stellar_dex/soroswap_service.rs index 810fef2e6..2627fdfb3 100644 --- a/src/services/stellar_dex/soroswap_service.rs +++ b/src/services/stellar_dex/soroswap_service.rs @@ -33,6 +33,8 @@ where { /// Soroswap router contract address router_address: String, + /// Soroswap factory contract address (required for get_amounts_out) + factory_address: String, /// Native XLM wrapper token address native_wrapper_address: String, /// Stellar provider for contract calls @@ -51,12 +53,14 @@ where /// # Arguments /// /// * `router_address` - Soroswap router contract address + /// * `factory_address` - Soroswap factory contract address (required for get_amounts_out) /// * `native_wrapper_address` - Optional native XLM wrapper token address (uses default if None) /// * `provider` - Stellar provider for contract calls /// * `network_passphrase` - Network passphrase /// * `is_testnet` - Whether this is testnet (affects default addresses) pub fn new( router_address: String, + factory_address: String, native_wrapper_address: Option, provider: Arc

, network_passphrase: String, @@ -72,6 +76,7 @@ where Self { router_address, + factory_address, native_wrapper_address: native_wrapper, provider, network_passphrase, @@ -147,6 +152,7 @@ where /// Call router.get_amounts_out to get quote /// /// Returns the expected output amounts for each step in the path + /// Soroswap's get_amounts_out requires: (factory_address, amount_in, path) async fn call_get_amounts_out( &self, amount_in: i128, @@ -156,10 +162,17 @@ where StellarDexServiceError::UnknownError("Failed to create function symbol".to_string()) })?; - let args = vec![Self::i128_to_scval(amount_in), path]; + // Soroswap's get_amounts_out requires factory address as first argument + let factory_addr = Self::parse_contract_address(&self.factory_address)?; + let args = vec![ + ScVal::Address(factory_addr), + Self::i128_to_scval(amount_in), + path, + ]; debug!( router = %self.router_address, + factory = %self.factory_address, amount_in = amount_in, "Calling Soroswap router get_amounts_out" ); From 022b8624dc74b4b3b7c6afa34c6160b3e47caa8a Mon Sep 17 00:00:00 2001 From: Nicolas Molina Date: Fri, 30 Jan 2026 16:48:47 -0300 Subject: [PATCH 03/22] fix: Fixing /quote endpoint --- src/domain/relayer/stellar/gas_abstraction.rs | 40 +++-- src/domain/transaction/stellar/token.rs | 108 ++++++------ src/services/stellar_fee_forwarder/mod.rs | 157 +++++++++++++----- 3 files changed, 194 insertions(+), 111 deletions(-) diff --git a/src/domain/relayer/stellar/gas_abstraction.rs b/src/domain/relayer/stellar/gas_abstraction.rs index 72012dc0b..fca220b65 100644 --- a/src/domain/relayer/stellar/gas_abstraction.rs +++ b/src/domain/relayer/stellar/gas_abstraction.rs @@ -583,17 +583,15 @@ where relayer: self.relayer.address.clone(), }; - let auth_entry = FeeForwarderService::

::build_user_auth_entry_standalone( - &fee_forwarder, - &fee_params, - true, - ) - .map_err(|e| RelayerError::Internal(format!("Failed to build user auth entry: {e}")))?; - + // For quote/simulation, we don't include auth entries because the FeeForwarder + // contract has custom auth verification that fails on empty signatures. + // Unlike standard Soroban "recording mode", this contract explicitly checks + // for valid signatures and returns Error when none are found. + // The build flow will include proper auth entries for accurate resource estimation. let invoke_op = FeeForwarderService::

::build_invoke_operation_standalone( &fee_forwarder, &fee_params, - vec![auth_entry], + vec![], ) .map_err(|e| RelayerError::Internal(format!("Failed to build invoke operation: {e}")))?; @@ -731,17 +729,24 @@ where // Build the user authorization entry using the standalone method // requires_target_auth defaults to true (safer default) - let auth_entry = FeeForwarderService::

::build_user_auth_entry_standalone( + let user_auth_entry = FeeForwarderService::

::build_user_auth_entry_standalone( &fee_forwarder, &fee_params, true, ) .map_err(|e| RelayerError::Internal(format!("Failed to build user auth entry: {e}")))?; + // Build relayer auth entry - required by FeeForwarder contract + let relayer_auth_entry = FeeForwarderService::

::build_relayer_auth_entry_standalone( + &fee_forwarder, + &fee_params, + ) + .map_err(|e| RelayerError::Internal(format!("Failed to build relayer auth entry: {e}")))?; + let invoke_op = FeeForwarderService::

::build_invoke_operation_standalone( &fee_forwarder, &fee_params, - vec![auth_entry], + vec![user_auth_entry, relayer_auth_entry], ) .map_err(|e| RelayerError::Internal(format!("Failed to build invoke operation: {e}")))?; @@ -795,20 +800,27 @@ where fee_params.fee_amount = fee_quote.fee_in_token as i128; fee_params.max_fee_amount = apply_max_fee_slippage(fee_quote.fee_in_token); - let auth_entry = FeeForwarderService::

::build_user_auth_entry_standalone( + let user_auth_entry = FeeForwarderService::

::build_user_auth_entry_standalone( &fee_forwarder, &fee_params, true, ) .map_err(|e| RelayerError::Internal(format!("Failed to build user auth entry: {e}")))?; - let user_auth_xdr = FeeForwarderService::

::serialize_auth_entry(&auth_entry) + let user_auth_xdr = FeeForwarderService::

::serialize_auth_entry(&user_auth_entry) .map_err(|e| RelayerError::Internal(format!("Failed to serialize auth entry: {e}")))?; + // Build relayer auth entry - required by FeeForwarder contract + let relayer_auth_entry = FeeForwarderService::

::build_relayer_auth_entry_standalone( + &fee_forwarder, + &fee_params, + ) + .map_err(|e| RelayerError::Internal(format!("Failed to build relayer auth entry: {e}")))?; + let invoke_op = FeeForwarderService::

::build_invoke_operation_standalone( &fee_forwarder, &fee_params, - vec![auth_entry], + vec![user_auth_entry, relayer_auth_entry], ) .map_err(|e| RelayerError::Internal(format!("Failed to build invoke operation: {e}")))?; @@ -822,7 +834,7 @@ where .provider .simulate_transaction_envelope(&envelope) .await - .map_err(|e| RelayerError::Internal(format!("Failed to simulate transaction: {e}")))?; + .map_err(|e| RelayerError::Internal(format!("Simulation failed: {e}")))?; apply_simulation_to_soroban_envelope(&mut envelope, &sim_response, 1)?; diff --git a/src/domain/transaction/stellar/token.rs b/src/domain/transaction/stellar/token.rs index f9f828489..f370c6972 100644 --- a/src/domain/transaction/stellar/token.rs +++ b/src/domain/transaction/stellar/token.rs @@ -7,9 +7,9 @@ use crate::domain::transaction::stellar::utils::{ use crate::models::{StellarTokenKind, StellarTokenMetadata}; use crate::services::provider::StellarProviderTrait; use soroban_rs::xdr::{ - AccountId, AlphaNum12, AlphaNum4, Asset, AssetCode12, AssetCode4, ContractDataEntry, - ContractId, Hash, LedgerEntryData, LedgerKey, ScAddress, ScSymbol, ScVal, TrustLineEntry, - TrustLineEntryExt, TrustLineEntryV1, + AccountId, AlphaNum12, AlphaNum4, Asset, AssetCode12, AssetCode4, ContractId, Hash, + LedgerEntryData, LedgerKey, ScAddress, ScSymbol, ScVal, TrustLineEntry, TrustLineEntryExt, + TrustLineEntryV1, }; use std::str::FromStr; use tracing::{debug, trace, warn}; @@ -246,7 +246,16 @@ where } } -/// Fetch balance for a Soroban contract token via ContractData +/// Fetch balance for a Soroban contract token by invoking the balance() function +/// +/// This function works for all SEP-41 compliant tokens: +/// - SAC (Stellar Asset Contract) tokens +/// - Native Soroban tokens +/// +/// Uses simulation to invoke the contract's balance(id: Address) -> i128 function. +/// This approach is simpler and more reliable than direct storage queries because +/// it lets the contract handle the balance lookup internally (SAC tokens delegate +/// to classic trustlines, native tokens read from contract storage). async fn get_contract_token_balance

( provider: &P, account_id: &str, @@ -255,64 +264,53 @@ async fn get_contract_token_balance

( where P: StellarProviderTrait + Send + Sync, { - // Parse contract address and account ID - let contract_hash = parse_contract_address(contract_address)?; + // Build the account address as ScVal::Address let account_xdr_id = parse_account_id(account_id)?; let account_sc_address = ScAddress::Account(account_xdr_id); - // Create balance key (Soroban token standard uses "Balance" as the key) - let balance_key = create_contract_data_key("Balance", Some(account_sc_address))?; - - // Query contract data with durability fallback - let error_context = format!("contract {contract_address} balance for account {account_id}"); - let ledger_entries = - query_contract_data_with_fallback(provider, contract_hash, balance_key, &error_context) - .await?; - - // Extract balance from contract data entry - let entries = match ledger_entries.entries { - Some(entries) if !entries.is_empty() => entries, - _ => { - // No balance entry means balance is 0 - warn!( - "No balance entry found for contract {} on account {}, assuming zero balance", - contract_address, account_id - ); - return Ok(0); - } - }; + // Create the "balance" function name symbol + let function_name = ScSymbol::try_from("balance".as_bytes().to_vec()).map_err(|e| { + StellarTransactionUtilsError::SymbolCreationFailed("balance".into(), format!("{e:?}")) + })?; - let entry_result = &entries[0]; - let entry = parse_ledger_entry_from_xdr(&entry_result.xdr, &error_context)?; + // Call balance(id: Address) -> i128 via simulation + debug!( + "Querying balance for account {} on contract {} via simulation", + account_id, contract_address + ); - match entry { - LedgerEntryData::ContractData(ContractDataEntry { val, .. }) => match val { - ScVal::I128(parts) => { - if parts.hi != 0 { - return Err(StellarTransactionUtilsError::BalanceTooLarge( - parts.hi, parts.lo, - )); - } - // Check if parts.lo represents a negative value when interpreted as i64 - // Similar to the I64 branch, we check for negative before casting to u64 - let lo_as_i64 = parts.lo as i64; - if lo_as_i64 < 0 { - return Err(StellarTransactionUtilsError::NegativeBalanceI128(parts.lo)); - } - Ok(lo_as_i64 as u64) + let result = provider + .call_contract( + contract_address, + &function_name, + vec![ScVal::Address(account_sc_address)], + ) + .await + .map_err(|e| StellarTransactionUtilsError::SimulationFailed(e.to_string()))?; + + // Parse i128 result to u64 + match result { + ScVal::I128(parts) => { + // Check for overflow (hi should be 0 for values that fit in u64) + if parts.hi != 0 { + return Err(StellarTransactionUtilsError::BalanceTooLarge( + parts.hi, parts.lo, + )); } - ScVal::U64(n) => Ok(n), - ScVal::I64(n) => { - if n < 0 { - return Err(StellarTransactionUtilsError::NegativeBalanceI64(n)); - } - Ok(n as u64) + // Check for negative balance + let lo_as_i64 = parts.lo as i64; + if lo_as_i64 < 0 { + return Err(StellarTransactionUtilsError::NegativeBalanceI128(parts.lo)); } - other => Err(StellarTransactionUtilsError::UnexpectedBalanceType( - format!("{other:?}"), - )), - }, - _ => Err(StellarTransactionUtilsError::UnexpectedContractDataEntryType), + debug!( + "Balance for account {} on contract {}: {}", + account_id, contract_address, lo_as_i64 + ); + Ok(lo_as_i64 as u64) + } + other => Err(StellarTransactionUtilsError::UnexpectedBalanceType( + format!("{other:?}"), + )), } } diff --git a/src/services/stellar_fee_forwarder/mod.rs b/src/services/stellar_fee_forwarder/mod.rs index 5599a0ea7..77674d5c9 100644 --- a/src/services/stellar_fee_forwarder/mod.rs +++ b/src/services/stellar_fee_forwarder/mod.rs @@ -158,15 +158,17 @@ where }); } - // Build the forward() function arguments - let forward_args = Self::build_forward_args_standalone(fee_forwarder_address, params)?; + // Build the forward() function arguments for USER (6 parameters) + // User signs: fee_token, max_fee_amount, expiration_ledger, target_contract, target_fn, target_args + // User does NOT sign: fee_amount, user, relayer + let user_auth_args = Self::build_user_auth_args_standalone(params)?; // Build the root invocation for fee_forwarder.forward() let root_invocation = SorobanAuthorizedInvocation { function: SorobanAuthorizedFunction::ContractFn(InvokeContractArgs { contract_address: fee_forwarder_addr, function_name: Self::create_symbol("forward")?, - args: forward_args.into(), + args: user_auth_args.into(), }), sub_invocations: sub_invocations.try_into().map_err(|_| { FeeForwarderError::AuthorizationBuildError( @@ -184,18 +186,114 @@ where .unwrap_or(0) }; + // For simulation, signature must be an empty vector (not Void) + // Void causes Error(Value, UnexpectedType) during auth verification + let empty_signature = ScVal::Vec(Some(ScVec::default())); + Ok(SorobanAuthorizationEntry { credentials: SorobanCredentials::Address(SorobanAddressCredentials { address: user_addr, nonce, signature_expiration_ledger: params.expiration_ledger, - signature: ScVal::Void, + signature: empty_signature, + }), + root_invocation, + }) + } + + /// Build a relayer authorization entry without requiring a FeeForwarderService instance + /// + /// The FeeForwarder contract requires the relayer to authorize receiving the fee payment. + /// This creates an auth entry for the relayer's authorization of the `forward` call. + /// + /// # Arguments + /// + /// * `fee_forwarder_address` - The FeeForwarder contract address + /// * `params` - FeeForwarder transaction parameters + /// + /// # Returns + /// + /// A SorobanAuthorizationEntry for the relayer (with empty signature for simulation) + pub fn build_relayer_auth_entry_standalone( + fee_forwarder_address: &str, + params: &FeeForwarderParams, + ) -> Result { + let fee_forwarder_addr = Self::parse_contract_address(fee_forwarder_address)?; + let relayer_addr = Self::parse_account_address(¶ms.relayer)?; + + // Build the forward() function arguments + let forward_args = Self::build_forward_args_standalone(fee_forwarder_address, params)?; + + // Build the root invocation for fee_forwarder.forward() + // Relayer only needs to authorize the forward call itself (no sub-invocations) + let root_invocation = SorobanAuthorizedInvocation { + function: SorobanAuthorizedFunction::ContractFn(InvokeContractArgs { + contract_address: fee_forwarder_addr, + function_name: Self::create_symbol("forward")?, + args: forward_args.into(), + }), + sub_invocations: VecM::default(), + }; + + // Generate nonce using timestamp (different from user nonce) + let nonce = { + use std::time::{SystemTime, UNIX_EPOCH}; + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| (d.as_nanos() + 1) as i64) // +1 to ensure different from user nonce + .unwrap_or(1) + }; + + // For simulation, signature must be an empty vector (not Void) + // Void causes Error(Value, UnexpectedType) during auth verification + let empty_signature = ScVal::Vec(Some(ScVec::default())); + + Ok(SorobanAuthorizationEntry { + credentials: SorobanCredentials::Address(SorobanAddressCredentials { + address: relayer_addr, + nonce, + signature_expiration_ledger: params.expiration_ledger, + signature: empty_signature, }), root_invocation, }) } - /// Build forward args without requiring a service instance + /// Build forward args for USER authorization (6 parameters) + /// + /// User signs: fee_token, max_fee_amount, expiration_ledger, target_contract, target_fn, target_args + /// User does NOT sign: fee_amount, user, relayer + fn build_user_auth_args_standalone( + params: &FeeForwarderParams, + ) -> Result { + let fee_token_addr = Self::parse_contract_address(¶ms.fee_token)?; + let target_contract_addr = Self::parse_contract_address(¶ms.target_contract)?; + + let target_args_vec: ScVec = params.target_args.clone().try_into().map_err(|_| { + FeeForwarderError::AuthorizationBuildError("Failed to create target args".to_string()) + })?; + + // User signs 6 parameters (excludes fee_amount, user, relayer) + let args: Vec = vec![ + ScVal::Address(fee_token_addr), + Self::i128_to_scval(params.max_fee_amount), + ScVal::U32(params.expiration_ledger), + ScVal::Address(target_contract_addr), + ScVal::Symbol(Self::create_symbol(¶ms.target_fn)?), + ScVal::Vec(Some(target_args_vec)), + ]; + + args.try_into().map_err(|_| { + FeeForwarderError::AuthorizationBuildError( + "Failed to create user auth args".to_string(), + ) + }) + } + + /// Build forward args for RELAYER authorization and invoke operation (9 parameters) + /// + /// Relayer signs ALL parameters in the exact order they appear in the function signature: + /// fee_token, fee_amount, max_fee_amount, expiration_ledger, target_contract, target_fn, target_args, user, relayer fn build_forward_args_standalone( _fee_forwarder_address: &str, params: &FeeForwarderParams, @@ -209,6 +307,7 @@ where FeeForwarderError::AuthorizationBuildError("Failed to create target args".to_string()) })?; + // Relayer signs all 9 parameters let args: Vec = vec![ ScVal::Address(fee_token_addr), Self::i128_to_scval(params.fee_amount), @@ -368,15 +467,17 @@ where }); } - // Build the forward() function arguments - let forward_args = self.build_forward_args(params)?; + // Build the forward() function arguments for USER (6 parameters) + // User signs: fee_token, max_fee_amount, expiration_ledger, target_contract, target_fn, target_args + // User does NOT sign: fee_amount, user, relayer + let user_auth_args = Self::build_user_auth_args_standalone(params)?; // Build the root invocation for fee_forwarder.forward() let root_invocation = SorobanAuthorizedInvocation { function: SorobanAuthorizedFunction::ContractFn(InvokeContractArgs { contract_address: fee_forwarder_addr, function_name: Self::create_symbol("forward")?, - args: forward_args.into(), + args: user_auth_args.into(), }), sub_invocations: sub_invocations.try_into().map_err(|_| { FeeForwarderError::AuthorizationBuildError( @@ -386,50 +487,22 @@ where }; // Build the authorization entry - // Note: The signature field is left empty (Void) - user will sign this + // For simulation, signature must be an empty vector (not Void) + // Void causes Error(Value, UnexpectedType) during auth verification + // User will replace this with their actual signature after signing + let empty_signature = ScVal::Vec(Some(ScVec::default())); + Ok(SorobanAuthorizationEntry { credentials: SorobanCredentials::Address(SorobanAddressCredentials { address: user_addr, nonce: self.generate_nonce(), signature_expiration_ledger: params.expiration_ledger, - signature: ScVal::Void, // User will fill this with their signature + signature: empty_signature, }), root_invocation, }) } - /// Build the arguments for FeeForwarder.forward() call - fn build_forward_args(&self, params: &FeeForwarderParams) -> Result { - let fee_token_addr = Self::parse_contract_address(¶ms.fee_token)?; - let target_contract_addr = Self::parse_contract_address(¶ms.target_contract)?; - let user_addr = Self::parse_account_address(¶ms.user)?; - let relayer_addr = Self::parse_account_address(¶ms.relayer)?; - - // Convert target_args to ScVec for the target_args parameter - let target_args_vec: ScVec = params.target_args.clone().try_into().map_err(|_| { - FeeForwarderError::AuthorizationBuildError("Failed to create target args".to_string()) - })?; - - // forward() parameters: - // fee_token, fee_amount, max_fee_amount, expiration_ledger, - // target_contract, target_fn, target_args, user, relayer - let args: Vec = vec![ - ScVal::Address(fee_token_addr), - Self::i128_to_scval(params.fee_amount), - Self::i128_to_scval(params.max_fee_amount), - ScVal::U32(params.expiration_ledger), - ScVal::Address(target_contract_addr), - ScVal::Symbol(Self::create_symbol(¶ms.target_fn)?), - ScVal::Vec(Some(target_args_vec)), - ScVal::Address(user_addr), - ScVal::Address(relayer_addr), - ]; - - args.try_into().map_err(|_| { - FeeForwarderError::AuthorizationBuildError("Failed to create forward args".to_string()) - }) - } - /// Generate a nonce for authorization /// /// The nonce should be unique per authorization to prevent replay attacks. From 7a3db032a7e190c8188bc42ddcf40a1f6de531ee Mon Sep 17 00:00:00 2001 From: Nicolas Molina Date: Tue, 3 Feb 2026 17:30:05 -0300 Subject: [PATCH 04/22] feat: Introducing soroban gas abstraction --- src/domain/relayer/stellar/gas_abstraction.rs | 36 +- src/domain/relayer/stellar/stellar_relayer.rs | 1 + src/domain/relayer/stellar/token_swap.rs | 1 + src/domain/transaction/stellar/prepare/mod.rs | 23 +- .../prepare/soroban_gas_abstraction.rs | 465 ++++++++++++++++++ src/domain/transaction/stellar/status.rs | 12 +- .../stellar/stellar_transaction.rs | 8 +- src/domain/transaction/stellar/submit.rs | 7 +- .../transaction/stellar/test_helpers.rs | 90 +++- src/models/transaction/repository.rs | 50 ++ src/models/transaction/request/stellar.rs | 122 +++++ src/models/transaction/stellar/conversion.rs | 3 +- src/services/signer/stellar/aws_kms_signer.rs | 7 +- .../signer/stellar/google_cloud_kms_signer.rs | 3 +- src/services/signer/stellar/local_signer.rs | 8 +- src/services/signer/stellar/turnkey_signer.rs | 4 +- src/services/stellar_fee_forwarder/mod.rs | 12 +- src/utils/mocks.rs | 1 + 18 files changed, 799 insertions(+), 54 deletions(-) create mode 100644 src/domain/transaction/stellar/prepare/soroban_gas_abstraction.rs diff --git a/src/domain/relayer/stellar/gas_abstraction.rs b/src/domain/relayer/stellar/gas_abstraction.rs index fca220b65..8fe256716 100644 --- a/src/domain/relayer/stellar/gas_abstraction.rs +++ b/src/domain/relayer/stellar/gas_abstraction.rs @@ -727,26 +727,14 @@ where relayer: self.relayer.address.clone(), }; - // Build the user authorization entry using the standalone method - // requires_target_auth defaults to true (safer default) - let user_auth_entry = FeeForwarderService::

::build_user_auth_entry_standalone( - &fee_forwarder, - &fee_params, - true, - ) - .map_err(|e| RelayerError::Internal(format!("Failed to build user auth entry: {e}")))?; - - // Build relayer auth entry - required by FeeForwarder contract - let relayer_auth_entry = FeeForwarderService::

::build_relayer_auth_entry_standalone( - &fee_forwarder, - &fee_params, - ) - .map_err(|e| RelayerError::Internal(format!("Failed to build relayer auth entry: {e}")))?; - + // For simulation, we don't include auth entries because the FeeForwarder + // contract has custom auth verification that fails on empty signatures. + // Unlike standard Soroban "recording mode", this contract explicitly checks + // for valid signatures and returns Error when none are found. let invoke_op = FeeForwarderService::

::build_invoke_operation_standalone( &fee_forwarder, &fee_params, - vec![user_auth_entry, relayer_auth_entry], + vec![], // Empty auth entries for simulation ) .map_err(|e| RelayerError::Internal(format!("Failed to build invoke operation: {e}")))?; @@ -800,6 +788,7 @@ where fee_params.fee_amount = fee_quote.fee_in_token as i128; fee_params.max_fee_amount = apply_max_fee_slippage(fee_quote.fee_in_token); + // Build the user authorization entry for the user to sign let user_auth_entry = FeeForwarderService::

::build_user_auth_entry_standalone( &fee_forwarder, &fee_params, @@ -817,6 +806,10 @@ where ) .map_err(|e| RelayerError::Internal(format!("Failed to build relayer auth entry: {e}")))?; + // Build the final invoke operation WITH auth entries + // Note: We don't simulate again because the contract's custom auth verification + // would fail on empty signatures. We use the simulation data from the first + // simulation (without auth entries) to set the fee and resources. let invoke_op = FeeForwarderService::

::build_invoke_operation_standalone( &fee_forwarder, &fee_params, @@ -830,12 +823,9 @@ where base_fee_stroops as u32, )?; - let sim_response = self - .provider - .simulate_transaction_envelope(&envelope) - .await - .map_err(|e| RelayerError::Internal(format!("Simulation failed: {e}")))?; - + // Apply simulation data from the first simulation (without auth entries) + // This sets the fee and Soroban resource data on the final envelope + // Also extends the footprint to include the relayer's account for require_auth apply_simulation_to_soroban_envelope(&mut envelope, &sim_response, 1)?; let transaction_xdr = envelope diff --git a/src/domain/relayer/stellar/stellar_relayer.rs b/src/domain/relayer/stellar/stellar_relayer.rs index e20cd02ba..3d49e8c5d 100644 --- a/src/domain/relayer/stellar/stellar_relayer.rs +++ b/src/domain/relayer/stellar/stellar_relayer.rs @@ -1817,6 +1817,7 @@ mod tests { transaction_xdr: Some("AAAAAgAAAACige4lTdwSB/sto4SniEdJ2kOa2X65s5bqkd40J4DjSwAAAAEAAHAkAAAADwAAAAAAAAAAAAAAAQAAAAAAAAABAAAAAKKB7iVN3BIH+y2jhKeIR0naQ5rZfrmzluqR3jQngONLAAAAAAAAAAAAD0JAAAAAAAAAAAA=".to_string()), fee_bump: None, max_fee: None, + signed_auth_entry: None, }) } diff --git a/src/domain/relayer/stellar/token_swap.rs b/src/domain/relayer/stellar/token_swap.rs index 3dcc6e242..fdad4b310 100644 --- a/src/domain/relayer/stellar/token_swap.rs +++ b/src/domain/relayer/stellar/token_swap.rs @@ -237,6 +237,7 @@ where transaction_xdr: Some(xdr), fee_bump: None, max_fee: None, + signed_auth_entry: None, }; let network_request = NetworkTransactionRequest::Stellar(stellar_request); diff --git a/src/domain/transaction/stellar/prepare/mod.rs b/src/domain/transaction/stellar/prepare/mod.rs index 864801c51..4d1e1ad6d 100644 --- a/src/domain/transaction/stellar/prepare/mod.rs +++ b/src/domain/transaction/stellar/prepare/mod.rs @@ -6,6 +6,7 @@ pub mod common; pub mod fee_bump; pub mod operations; +pub mod soroban_gas_abstraction; pub mod unsigned_xdr; use eyre::Result; @@ -20,7 +21,10 @@ use crate::{ TransactionUpdateRequest, }, repositories::{Repository, TransactionCounterTrait, TransactionRepository}, - services::{provider::StellarProviderTrait, signer::Signer}, + services::{ + provider::StellarProviderTrait, + signer::{Signer, StellarSignTrait}, + }, }; use common::{sign_and_finalize_transaction, update_and_notify_transaction}; @@ -30,7 +34,7 @@ where R: Repository + Send + Sync, T: TransactionRepository + Send + Sync, J: JobProducerTrait + Send + Sync, - S: Signer + Send + Sync, + S: Signer + StellarSignTrait + Send + Sync, P: StellarProviderTrait + Send + Sync, C: TransactionCounterTrait + Send + Sync, D: crate::services::stellar_dex::StellarDexServiceTrait + Send + Sync + 'static, @@ -142,6 +146,21 @@ where ) .await } + TransactionInput::SorobanGasAbstraction { .. } => { + debug!("preparing soroban gas abstraction transaction {}", tx.id); + let stellar_data_with_auth = + soroban_gas_abstraction::process_soroban_gas_abstraction( + self.transaction_counter_service(), + &self.relayer().id, + &self.relayer().address, + self.signer(), + self.provider(), + stellar_data, + ) + .await?; + self.finalize_with_signature(tx, stellar_data_with_auth) + .await + } } } diff --git a/src/domain/transaction/stellar/prepare/soroban_gas_abstraction.rs b/src/domain/transaction/stellar/prepare/soroban_gas_abstraction.rs new file mode 100644 index 000000000..0ee4ce775 --- /dev/null +++ b/src/domain/transaction/stellar/prepare/soroban_gas_abstraction.rs @@ -0,0 +1,465 @@ +//! This module handles the preparation of Soroban gas abstraction transactions. +//! These are transactions where the user pays fees in tokens via the FeeForwarder contract. +//! The user signs an authorization entry, which is injected into the transaction before submission. +//! The relayer also signs its own authorization entry for the FeeForwarder contract. + +use soroban_rs::xdr::{ + InvokeHostFunctionOp, Limits, Operation, OperationBody, ReadXdr, SorobanAuthorizationEntry, + SorobanCredentials, SorobanResources, SorobanTransactionData, TransactionEnvelope, + TransactionExt, WriteXdr, +}; +use tracing::{debug, info}; + +use crate::{ + models::{StellarTransactionData, TransactionError, TransactionInput}, + repositories::TransactionCounterTrait, + services::{ + provider::{StellarProvider, StellarProviderTrait}, + signer::StellarSignTrait, + stellar_fee_forwarder::{FeeForwarderError, FeeForwarderService}, + }, +}; + +use super::common::get_next_sequence; + +/// Process a Soroban gas abstraction transaction. +/// +/// This function: +/// 1. Parses the FeeForwarder transaction XDR +/// 2. Deserializes the user's signed authorization entry +/// 3. Signs the relayer's authorization entry using the provided signer +/// 4. Injects both signed auth entries into the transaction +/// 5. Re-simulates with signed auth entries to get accurate footprint +/// 6. Updates the transaction's sorobanData with accurate resources +/// 7. Updates the sequence number +/// 8. Returns the prepared transaction data for signing +/// +/// # Arguments +/// +/// * `counter_service` - Service for managing sequence numbers +/// * `relayer_id` - The relayer's ID for sequence tracking +/// * `relayer_address` - The relayer's Stellar address (source account for the transaction) +/// * `_signer` - Unused, kept for API compatibility +/// * `provider` - The Stellar provider for simulation +/// * `stellar_data` - The transaction data containing the XDR and signed auth entry +pub async fn process_soroban_gas_abstraction( + counter_service: &C, + relayer_id: &str, + relayer_address: &str, + _signer: &S, + provider: &P, + mut stellar_data: StellarTransactionData, +) -> Result +where + C: TransactionCounterTrait + Send + Sync, + S: StellarSignTrait + Send + Sync, + P: StellarProviderTrait + Send + Sync, +{ + // Extract XDR and signed auth entry from transaction input + let (xdr, signed_auth_entry_xdr) = match &stellar_data.transaction_input { + TransactionInput::SorobanGasAbstraction { + xdr, + signed_auth_entry, + } => (xdr.clone(), signed_auth_entry.clone()), + _ => { + return Err(TransactionError::ValidationError( + "Expected SorobanGasAbstraction transaction input".to_string(), + )); + } + }; + + debug!( + "Processing Soroban gas abstraction: xdr_len={}, auth_entry_len={}", + xdr.len(), + signed_auth_entry_xdr.len() + ); + + // Parse the transaction envelope + let mut envelope = TransactionEnvelope::from_xdr_base64(&xdr, Limits::none()).map_err(|e| { + TransactionError::ValidationError(format!("Failed to parse transaction XDR: {e}")) + })?; + + // Deserialize the user's signed authorization entry + let signed_user_auth = + FeeForwarderService::::deserialize_auth_entry(&signed_auth_entry_xdr) + .map_err(|e| match e { + FeeForwarderError::XdrError(msg) => TransactionError::ValidationError(msg), + _ => TransactionError::ValidationError(format!( + "Failed to deserialize signed auth entry: {e}" + )), + })?; + + // Inject the user's signed auth entry and convert relayer's auth to SourceAccount + let signed_auth_entries = inject_auth_entries_into_envelope(&mut envelope, signed_user_auth)?; + + // Re-simulate with signed auth entries to get accurate footprint. + // + // According to Soroban flow, after signing auth entries you must re-simulate: + // 1. Simulation validates the signatures + // 2. Calculates ledger resources accurately + // 3. The footprint will include all accounts accessed via require_auth/require_auth_for_args + // 4. Returns a fully-resourced transaction ready for submission + // + // The signed auth entries are used directly - this ensures the simulation executes + // the full auth verification code path and captures the correct footprint. + simulate_and_update_resources(&mut envelope, &signed_auth_entries, provider).await?; + + // Get the next sequence number for the relayer + let sequence_number = get_next_sequence(counter_service, relayer_id, relayer_address).await?; + + // Update the sequence number in the envelope + update_envelope_sequence(&mut envelope, sequence_number)?; + + // Serialize the updated envelope back to XDR + let updated_xdr = envelope.to_xdr_base64(Limits::none()).map_err(|e| { + TransactionError::UnexpectedError(format!("Failed to serialize updated envelope: {e}")) + })?; + + // Update the transaction data with the new XDR and sequence number + stellar_data.sequence_number = Some(sequence_number); + stellar_data.transaction_input = TransactionInput::UnsignedXdr(updated_xdr); + + debug!( + "Soroban gas abstraction prepared: sequence={}", + sequence_number + ); + + Ok(stellar_data) +} + +/// Inject signed authorization entries into the transaction envelope. +/// +/// For FeeForwarder transactions, there are two auth entries: +/// 1. User's auth entry (first) - already signed by the user +/// 2. Relayer's auth entry (second) - uses SourceAccount credentials (no separate signature needed) +/// +/// This function: +/// - Replaces the first auth entry with the user's signed version +/// - Converts the relayer's auth entry to use SourceAccount credentials +/// - Returns the auth entries for use in simulation +fn inject_auth_entries_into_envelope( + envelope: &mut TransactionEnvelope, + signed_user_auth: SorobanAuthorizationEntry, +) -> Result, TransactionError> { + let tx = match envelope { + TransactionEnvelope::Tx(v1) => &mut v1.tx, + TransactionEnvelope::TxV0(_) => { + return Err(TransactionError::ValidationError( + "V0 transactions are not supported for Soroban".to_string(), + )); + } + TransactionEnvelope::TxFeeBump(_) => { + return Err(TransactionError::ValidationError( + "Fee bump transactions should not be used for Soroban gas abstraction".to_string(), + )); + } + }; + + // Get the first operation (should be InvokeHostFunction for FeeForwarder) + let operations: Vec<_> = tx.operations.to_vec(); + if operations.is_empty() { + return Err(TransactionError::ValidationError( + "Transaction has no operations".to_string(), + )); + } + + let first_op = &operations[0]; + let invoke_op = match &first_op.body { + OperationBody::InvokeHostFunction(invoke) => invoke.clone(), + _ => { + return Err(TransactionError::ValidationError( + "First operation is not InvokeHostFunction".to_string(), + )); + } + }; + + // The auth entries should have user's auth entry as the first entry, relayer's as second + let mut auth_entries: Vec = invoke_op.auth.to_vec(); + + if auth_entries.is_empty() { + // If there are no auth entries, just add the user's signed one + auth_entries.push(signed_user_auth); + } else { + // Replace the first auth entry (user's) with the signed version + auth_entries[0] = signed_user_auth; + + // Convert the relayer's auth entry (second entry) to use SourceAccount credentials. + // Since the relayer is the transaction source account, the transaction signature + // already authorizes this entry - no separate auth entry signature is needed. + if auth_entries.len() > 1 { + let relayer_auth = &auth_entries[1]; + let source_account_auth = SorobanAuthorizationEntry { + credentials: SorobanCredentials::SourceAccount, + root_invocation: relayer_auth.root_invocation.clone(), + }; + auth_entries[1] = source_account_auth; + debug!("Converted relayer auth entry to SourceAccount credentials"); + } + } + + // Clone auth_entries before consuming them in try_into (we need to return them) + let result_auth_entries = auth_entries.clone(); + + // Create the updated InvokeHostFunction operation + let updated_invoke = soroban_rs::xdr::InvokeHostFunctionOp { + host_function: invoke_op.host_function, + auth: auth_entries.try_into().map_err(|_| { + TransactionError::UnexpectedError("Failed to create auth entries vector".to_string()) + })?, + }; + + // Create the updated operation + let updated_op = soroban_rs::xdr::Operation { + source_account: first_op.source_account.clone(), + body: OperationBody::InvokeHostFunction(updated_invoke), + }; + + // Replace the first operation with the updated one + let mut updated_operations = operations; + updated_operations[0] = updated_op; + + // Update the transaction's operations + tx.operations = updated_operations.try_into().map_err(|_| { + TransactionError::UnexpectedError("Failed to update operations vector".to_string()) + })?; + + debug!("Successfully injected signed auth entries into transaction"); + + Ok(result_auth_entries) +} + +/// Update the sequence number in a transaction envelope. +fn update_envelope_sequence( + envelope: &mut TransactionEnvelope, + sequence: i64, +) -> Result<(), TransactionError> { + match envelope { + TransactionEnvelope::Tx(v1) => { + v1.tx.seq_num = soroban_rs::xdr::SequenceNumber(sequence); + Ok(()) + } + TransactionEnvelope::TxV0(_) => Err(TransactionError::ValidationError( + "V0 transactions are not supported".to_string(), + )), + TransactionEnvelope::TxFeeBump(_) => Err(TransactionError::ValidationError( + "Cannot update sequence number on fee bump transaction".to_string(), + )), + } +} + +/// Apply a buffer to Soroban resources to account for simulation variance. +/// +/// Simulation can be slightly inaccurate due to timing differences or other factors. +/// Adding a 15% buffer prevents "exceeded limit" errors during execution. +fn apply_resource_buffer(resources: &mut SorobanResources) { + const RESOURCE_BUFFER_PERCENT: u32 = 15; + const BUFFER_MULTIPLIER: u32 = 100 + RESOURCE_BUFFER_PERCENT; // 115 + + resources.instructions = + (resources.instructions as u64 * BUFFER_MULTIPLIER as u64 / 100) as u32; + resources.disk_read_bytes = + (resources.disk_read_bytes as u64 * BUFFER_MULTIPLIER as u64 / 100) as u32; + resources.write_bytes = (resources.write_bytes as u64 * BUFFER_MULTIPLIER as u64 / 100) as u32; +} + +/// Re-simulate the transaction with signed auth entries and update resources. +/// +/// This function: +/// 1. Builds a simulation envelope with the actual signed auth entries +/// 2. Simulates to get accurate footprint and resources +/// 3. Updates the original envelope's sorobanData with the accurate values +/// +/// Using the actual signed auth entries allows the simulation to: +/// - Verify signatures (they should be valid since values haven't changed) +/// - Execute the full auth verification code path +/// - Capture the correct footprint including accounts accessed via require_auth +async fn simulate_and_update_resources

( + envelope: &mut TransactionEnvelope, + signed_auth_entries: &[SorobanAuthorizationEntry], + provider: &P, +) -> Result<(), TransactionError> +where + P: StellarProviderTrait + Send + Sync, +{ + info!("Re-simulating transaction with signed auth entries for accurate footprint"); + + // Use the actual signed auth entries for simulation + // This allows the simulation to verify signatures and capture the correct footprint + // including all accounts accessed via require_auth/require_auth_for_args + let simulation_auth_entries: Vec = signed_auth_entries.to_vec(); + + // Build simulation envelope (clone the original and replace auth entries) + let simulation_envelope = build_simulation_envelope(envelope, &simulation_auth_entries)?; + + // Simulate the transaction + let sim_response = provider + .simulate_transaction_envelope(&simulation_envelope) + .await + .map_err(|e| { + TransactionError::UnexpectedError(format!("Failed to simulate transaction: {e}")) + })?; + + // Check for simulation errors + if let Some(err) = &sim_response.error { + return Err(TransactionError::UnexpectedError(format!( + "Simulation failed: {err}" + ))); + } + + // Parse the new transaction data from simulation + let mut new_tx_data = + SorobanTransactionData::from_xdr_base64(&sim_response.transaction_data, Limits::none()) + .map_err(|e| { + TransactionError::UnexpectedError(format!( + "Failed to parse simulation transaction_data: {e}" + )) + })?; + + // Log the resource values from simulation (before buffer) + info!( + "Simulation complete: instructions={}, read_bytes={}, write_bytes={}", + new_tx_data.resources.instructions, + new_tx_data.resources.disk_read_bytes, + new_tx_data.resources.write_bytes + ); + + // Apply buffer to resources to account for simulation variance + apply_resource_buffer(&mut new_tx_data.resources); + + // Log the resource values after buffer + info!( + "Resources after buffer: instructions={}, read_bytes={}, write_bytes={}", + new_tx_data.resources.instructions, + new_tx_data.resources.disk_read_bytes, + new_tx_data.resources.write_bytes + ); + + // Update the original envelope's sorobanData with accurate resources + // Keep the original fee (already calculated and validated at /build time) + match envelope { + TransactionEnvelope::Tx(ref mut env) => { + let original_fee = env.tx.fee; + + // Update the transaction extension with new soroban data + env.tx.ext = TransactionExt::V1(new_tx_data); + + // Preserve the original fee + env.tx.fee = original_fee; + + debug!( + "Updated transaction sorobanData with simulation results, preserved fee={}", + original_fee + ); + Ok(()) + } + _ => Err(TransactionError::ValidationError( + "Expected V1 transaction envelope".to_string(), + )), + } +} + +/// Build a simulation envelope with the provided auth entries. +/// +/// This creates a copy of the envelope with the specified auth entries. +/// The auth entries should be the actual signed entries to ensure proper +/// signature verification and footprint capture during simulation. +fn build_simulation_envelope( + original: &TransactionEnvelope, + simulation_auth_entries: &[SorobanAuthorizationEntry], +) -> Result { + match original { + TransactionEnvelope::Tx(env) => { + let mut sim_tx = env.tx.clone(); + + // Get the operations and update the auth entries + let operations: Vec<_> = sim_tx.operations.to_vec(); + if operations.is_empty() { + return Err(TransactionError::ValidationError( + "Transaction has no operations".to_string(), + )); + } + + let first_op = &operations[0]; + let invoke_op = match &first_op.body { + OperationBody::InvokeHostFunction(invoke) => invoke.clone(), + _ => { + return Err(TransactionError::ValidationError( + "First operation is not InvokeHostFunction".to_string(), + )); + } + }; + + // Create updated invoke operation with simulation auth entries + let updated_invoke = InvokeHostFunctionOp { + host_function: invoke_op.host_function, + auth: simulation_auth_entries.to_vec().try_into().map_err(|_| { + TransactionError::UnexpectedError( + "Failed to create simulation auth entries".to_string(), + ) + })?, + }; + + let updated_op = Operation { + source_account: first_op.source_account.clone(), + body: OperationBody::InvokeHostFunction(updated_invoke), + }; + + let mut updated_operations = operations; + updated_operations[0] = updated_op; + + sim_tx.operations = updated_operations.try_into().map_err(|_| { + TransactionError::UnexpectedError("Failed to update operations".to_string()) + })?; + + Ok(TransactionEnvelope::Tx( + soroban_rs::xdr::TransactionV1Envelope { + tx: sim_tx, + signatures: Default::default(), + }, + )) + } + _ => Err(TransactionError::ValidationError( + "Expected V1 transaction envelope".to_string(), + )), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use soroban_rs::xdr::VecM; + + #[test] + fn test_update_envelope_sequence() { + use soroban_rs::xdr::{ + Memo, MuxedAccount, Preconditions, SequenceNumber, Transaction, TransactionExt, + TransactionV1Envelope, Uint256, + }; + + // Create a minimal transaction envelope + let tx = Transaction { + source_account: MuxedAccount::Ed25519(Uint256([0u8; 32])), + fee: 100, + seq_num: SequenceNumber(0), + cond: Preconditions::None, + memo: Memo::None, + operations: VecM::default(), + ext: TransactionExt::V0, + }; + + let mut envelope = TransactionEnvelope::Tx(TransactionV1Envelope { + tx, + signatures: VecM::default(), + }); + + // Update sequence number + update_envelope_sequence(&mut envelope, 12345).unwrap(); + + // Verify the update + if let TransactionEnvelope::Tx(v1) = &envelope { + assert_eq!(v1.tx.seq_num.0, 12345); + } else { + panic!("Expected Tx envelope"); + } + } +} diff --git a/src/domain/transaction/stellar/status.rs b/src/domain/transaction/stellar/status.rs index 5f13620ae..6ea4a41a4 100644 --- a/src/domain/transaction/stellar/status.rs +++ b/src/domain/transaction/stellar/status.rs @@ -21,7 +21,10 @@ use crate::{ TransactionStatus, TransactionUpdateRequest, }, repositories::{Repository, TransactionCounterTrait, TransactionRepository}, - services::{provider::StellarProviderTrait, signer::Signer}, + services::{ + provider::StellarProviderTrait, + signer::{Signer, StellarSignTrait}, + }, }; impl StellarRelayerTransaction @@ -29,7 +32,7 @@ where R: Repository + Send + Sync, T: TransactionRepository + Send + Sync, J: JobProducerTrait + Send + Sync, - S: Signer + Send + Sync, + S: Signer + StellarSignTrait + Send + Sync, P: StellarProviderTrait + Send + Sync, C: TransactionCounterTrait + Send + Sync, D: crate::services::stellar_dex::StellarDexServiceTrait + Send + Sync + 'static, @@ -1987,8 +1990,7 @@ mod tests { MockRelayerRepository, MockTransactionCounterTrait, MockTransactionRepository, }, services::{ - provider::MockStellarProviderTrait, signer::MockSigner, - stellar_dex::MockStellarDexServiceTrait, + provider::MockStellarProviderTrait, stellar_dex::MockStellarDexServiceTrait, }, }; use chrono::{Duration, Utc}; @@ -1998,7 +2000,7 @@ mod tests { MockRelayerRepository, MockTransactionRepository, MockJobProducerTrait, - MockSigner, + MockStellarCombinedSigner, MockStellarProviderTrait, MockTransactionCounterTrait, MockStellarDexServiceTrait, diff --git a/src/domain/transaction/stellar/stellar_transaction.rs b/src/domain/transaction/stellar/stellar_transaction.rs index 710dc75ac..28ea8f0e8 100644 --- a/src/domain/transaction/stellar/stellar_transaction.rs +++ b/src/domain/transaction/stellar/stellar_transaction.rs @@ -18,7 +18,7 @@ use crate::{ }, services::{ provider::{StellarProvider, StellarProviderTrait}, - signer::{Signer, StellarSigner}, + signer::{Signer, StellarSignTrait, StellarSigner}, stellar_dex::{OrderBookService, StellarDexServiceTrait}, }, utils::calculate_scheduled_timestamp, @@ -35,7 +35,7 @@ where R: Repository, T: TransactionRepository, J: JobProducerTrait, - S: Signer, + S: Signer + StellarSignTrait, P: StellarProviderTrait, C: TransactionCounterTrait, D: StellarDexServiceTrait + Send + Sync + 'static, @@ -56,7 +56,7 @@ where R: Repository, T: TransactionRepository, J: JobProducerTrait, - S: Signer, + S: Signer + StellarSignTrait, P: StellarProviderTrait, C: TransactionCounterTrait, D: StellarDexServiceTrait + Send + Sync + 'static, @@ -286,7 +286,7 @@ where R: Repository + Send + Sync, T: TransactionRepository + Send + Sync, J: JobProducerTrait + Send + Sync, - S: Signer + Send + Sync, + S: Signer + StellarSignTrait + Send + Sync, P: StellarProviderTrait + Send + Sync, C: TransactionCounterTrait + Send + Sync, D: StellarDexServiceTrait + Send + Sync + 'static, diff --git a/src/domain/transaction/stellar/submit.rs b/src/domain/transaction/stellar/submit.rs index a9fdeed75..a2a074ad2 100644 --- a/src/domain/transaction/stellar/submit.rs +++ b/src/domain/transaction/stellar/submit.rs @@ -13,7 +13,10 @@ use crate::{ TransactionStatus, TransactionUpdateRequest, }, repositories::{Repository, TransactionCounterTrait, TransactionRepository}, - services::{provider::StellarProviderTrait, signer::Signer}, + services::{ + provider::StellarProviderTrait, + signer::{Signer, StellarSignTrait}, + }, }; impl StellarRelayerTransaction @@ -21,7 +24,7 @@ where R: Repository + Send + Sync, T: TransactionRepository + Send + Sync, J: JobProducerTrait + Send + Sync, - S: Signer + Send + Sync, + S: Signer + StellarSignTrait + Send + Sync, P: StellarProviderTrait + Send + Sync, C: TransactionCounterTrait + Send + Sync, D: crate::services::stellar_dex::StellarDexServiceTrait + Send + Sync + 'static, diff --git a/src/domain/transaction/stellar/test_helpers.rs b/src/domain/transaction/stellar/test_helpers.rs index 7730b8b16..3be1194c5 100644 --- a/src/domain/transaction/stellar/test_helpers.rs +++ b/src/domain/transaction/stellar/test_helpers.rs @@ -1,5 +1,9 @@ #[cfg(test)] use crate::domain::transaction::stellar::StellarRelayerTransaction; +#[cfg(test)] +use crate::models::SignerError; +#[cfg(test)] +use crate::services::signer::{MockStellarSignTrait, Signer, StellarSignTrait}; use crate::{ jobs::MockJobProducerTrait, models::{ @@ -8,11 +12,10 @@ use crate::{ TransactionRepoModel, TransactionStatus, }, repositories::{MockRelayerRepository, MockTransactionCounterTrait, MockTransactionRepository}, - services::{ - provider::MockStellarProviderTrait, signer::MockSigner, - stellar_dex::MockStellarDexServiceTrait, - }, + services::{provider::MockStellarProviderTrait, stellar_dex::MockStellarDexServiceTrait}, }; +#[cfg(test)] +use async_trait::async_trait; use chrono::Utc; use soroban_rs::xdr::{ AccountId, Asset, BytesM, Limits, Memo, MuxedAccount, Operation, OperationBody, PaymentOp, @@ -269,12 +272,85 @@ pub fn create_test_transaction(relayer_id: &str) -> TransactionRepoModel { } } +/// A combined mock signer that implements both `Signer` and `StellarSignTrait` +/// +/// This struct wraps both `MockSigner` and `MockStellarSignTrait` to allow tests +/// to set expectations on both the base `Signer` trait methods (via `signer_mock`) +/// and the Stellar-specific methods (via `stellar_mock`). +#[cfg(test)] +pub struct MockStellarCombinedSigner { + pub signer_mock: crate::services::signer::MockSigner, + pub stellar_mock: MockStellarSignTrait, +} + +#[cfg(test)] +impl MockStellarCombinedSigner { + pub fn new() -> Self { + Self { + signer_mock: crate::services::signer::MockSigner::new(), + stellar_mock: MockStellarSignTrait::new(), + } + } +} + +#[cfg(test)] +impl Default for MockStellarCombinedSigner { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +impl std::ops::Deref for MockStellarCombinedSigner { + type Target = crate::services::signer::MockSigner; + + fn deref(&self) -> &Self::Target { + &self.signer_mock + } +} + +#[cfg(test)] +impl std::ops::DerefMut for MockStellarCombinedSigner { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.signer_mock + } +} + +#[cfg(test)] +#[async_trait] +impl Signer for MockStellarCombinedSigner { + async fn address(&self) -> Result { + self.signer_mock.address().await + } + + async fn sign_transaction( + &self, + transaction: NetworkTransactionData, + ) -> Result { + self.signer_mock.sign_transaction(transaction).await + } +} + +#[cfg(test)] +#[async_trait] +impl StellarSignTrait for MockStellarCombinedSigner { + async fn sign_xdr_transaction( + &self, + unsigned_xdr: &str, + network_passphrase: &str, + ) -> Result { + self.stellar_mock + .sign_xdr_transaction(unsigned_xdr, network_passphrase) + .await + } +} + pub struct TestMocks { pub provider: MockStellarProviderTrait, pub relayer_repo: MockRelayerRepository, pub tx_repo: MockTransactionRepository, pub job_producer: MockJobProducerTrait, - pub signer: MockSigner, + pub signer: MockStellarCombinedSigner, pub counter: MockTransactionCounterTrait, pub dex_service: MockStellarDexServiceTrait, } @@ -285,7 +361,7 @@ pub fn default_test_mocks() -> TestMocks { relayer_repo: MockRelayerRepository::new(), tx_repo: MockTransactionRepository::new(), job_producer: MockJobProducerTrait::new(), - signer: MockSigner::new(), + signer: MockStellarCombinedSigner::new(), counter: MockTransactionCounterTrait::new(), dex_service: MockStellarDexServiceTrait::new(), } @@ -299,7 +375,7 @@ pub fn make_stellar_tx_handler( MockRelayerRepository, MockTransactionRepository, MockJobProducerTrait, - MockSigner, + MockStellarCombinedSigner, MockStellarProviderTrait, MockTransactionCounterTrait, MockStellarDexServiceTrait, diff --git a/src/models/transaction/repository.rs b/src/models/transaction/repository.rs index 33f50c5ad..537003d40 100644 --- a/src/models/transaction/repository.rs +++ b/src/models/transaction/repository.rs @@ -469,6 +469,13 @@ pub enum TransactionInput { UnsignedXdr(String), /// Pre-built signed XDR that needs fee-bumping SignedXdr { xdr: String, max_fee: i64 }, + /// Soroban gas abstraction: FeeForwarder transaction with user's signed auth entry + /// The XDR is the FeeForwarder transaction from /build, and the signed_auth_entry + /// contains the user's signed SorobanAuthorizationEntry to be injected. + SorobanGasAbstraction { + xdr: String, + signed_auth_entry: String, + }, } impl Default for TransactionInput { @@ -482,6 +489,24 @@ impl TransactionInput { pub fn from_stellar_request( request: &StellarTransactionRequest, ) -> Result { + // Handle Soroban gas abstraction mode (XDR + signed_auth_entry) + if let (Some(xdr), Some(signed_auth_entry)) = + (&request.transaction_xdr, &request.signed_auth_entry) + { + // Validation: signed_auth_entry and fee_bump are mutually exclusive + // (already validated in StellarTransactionRequest::validate(), but double-check here) + if request.fee_bump == Some(true) { + return Err(TransactionError::ValidationError( + "Cannot use both signed_auth_entry and fee_bump".to_string(), + )); + } + + return Ok(TransactionInput::SorobanGasAbstraction { + xdr: xdr.clone(), + signed_auth_entry: signed_auth_entry.clone(), + }); + } + // Handle XDR mode if let Some(xdr) = &request.transaction_xdr { let envelope = parse_transaction_xdr(xdr, false) @@ -640,6 +665,10 @@ impl StellarTransactionData { // Parse the inner transaction (for fee-bump cases) self.parse_xdr_envelope(xdr) } + TransactionInput::SorobanGasAbstraction { xdr, .. } => { + // Parse the FeeForwarder transaction XDR + self.parse_xdr_envelope(xdr) + } } } @@ -682,6 +711,12 @@ impl StellarTransactionData { // Already signed self.parse_xdr_envelope(xdr) } + TransactionInput::SorobanGasAbstraction { xdr, .. } => { + // For Soroban gas abstraction, the signed auth entry is injected during prepare + // Parse and attach the relayer's signature + let envelope = self.parse_xdr_envelope(xdr)?; + self.attach_signatures_to_envelope(envelope) + } } } @@ -1871,6 +1906,7 @@ mod tests { transaction_xdr: None, fee_bump: None, max_fee: None, + signed_auth_entry: None, }); let relayer_model = RelayerRepoModel { @@ -2315,6 +2351,7 @@ mod tests { transaction_xdr: None, fee_bump: None, max_fee: None, + signed_auth_entry: None, }; let request = NetworkTransactionRequest::Stellar(stellar_request); @@ -2343,6 +2380,7 @@ mod tests { transaction_xdr: Some(unsigned_xdr.to_string()), fee_bump: None, max_fee: None, + signed_auth_entry: None, }; let request = NetworkTransactionRequest::Stellar(stellar_request); @@ -2427,6 +2465,7 @@ mod tests { transaction_xdr: Some(signed_xdr.to_string()), fee_bump: Some(true), max_fee: Some(20000000), + signed_auth_entry: None, }; let request = NetworkTransactionRequest::Stellar(stellar_request); @@ -2456,6 +2495,7 @@ mod tests { transaction_xdr: Some(signed_xdr.clone()), fee_bump: None, max_fee: None, + signed_auth_entry: None, }; let request = NetworkTransactionRequest::Stellar(stellar_request); @@ -2482,6 +2522,7 @@ mod tests { transaction_xdr: None, fee_bump: Some(true), max_fee: None, + signed_auth_entry: None, }; let request = NetworkTransactionRequest::Stellar(stellar_request); @@ -2515,6 +2556,7 @@ mod tests { transaction_xdr: None, fee_bump: None, max_fee: None, + signed_auth_entry: None, }; let request = NetworkTransactionRequest::Stellar(stellar_request); @@ -2547,6 +2589,7 @@ mod tests { transaction_xdr: None, fee_bump: None, max_fee: None, + signed_auth_entry: None, }; let request = NetworkTransactionRequest::Stellar(stellar_request); @@ -2591,6 +2634,7 @@ mod tests { transaction_xdr: None, fee_bump: None, max_fee: None, + signed_auth_entry: None, }; let request = NetworkTransactionRequest::Stellar(stellar_request); @@ -2633,6 +2677,7 @@ mod tests { transaction_xdr: None, fee_bump: None, max_fee: None, + signed_auth_entry: None, }; let request = NetworkTransactionRequest::Stellar(stellar_request); @@ -2659,6 +2704,7 @@ mod tests { transaction_xdr: None, fee_bump: None, max_fee: None, + signed_auth_entry: None, }; let request = NetworkTransactionRequest::Stellar(stellar_request); @@ -2694,6 +2740,7 @@ mod tests { transaction_xdr: None, fee_bump: None, max_fee: None, + signed_auth_entry: None, }; let request = NetworkTransactionRequest::Stellar(stellar_request); @@ -2721,6 +2768,7 @@ mod tests { transaction_xdr: None, fee_bump: None, max_fee: None, + signed_auth_entry: None, }; let request = NetworkTransactionRequest::Stellar(stellar_request); @@ -2748,6 +2796,7 @@ mod tests { transaction_xdr: None, fee_bump: None, max_fee: None, + signed_auth_entry: None, }; let request = NetworkTransactionRequest::Stellar(stellar_request); @@ -3212,6 +3261,7 @@ mod tests { transaction_xdr, fee_bump: None, max_fee: None, + signed_auth_entry: None, } } diff --git a/src/models/transaction/request/stellar.rs b/src/models/transaction/request/stellar.rs index ebf731a1c..cc20c67fc 100644 --- a/src/models/transaction/request/stellar.rs +++ b/src/models/transaction/request/stellar.rs @@ -27,6 +27,12 @@ pub struct StellarTransactionRequest { /// Maximum fee in stroops (defaults to 0.1 XLM = 1,000,000 stroops) #[schema(nullable = true)] pub max_fee: Option, + /// Signed Soroban authorization entry (base64 encoded SorobanAuthorizationEntry XDR) + /// Used for Soroban gas abstraction: contains the user's signed auth entry from /build response. + /// When provided, transaction_xdr must also be provided (the FeeForwarder transaction from /build). + /// The relayer will inject this signed auth entry into the transaction before submitting. + #[schema(nullable = true)] + pub signed_auth_entry: Option, } impl StellarTransactionRequest { @@ -34,6 +40,8 @@ impl StellarTransactionRequest { /// - Only one input type allowed (operations XOR transaction_xdr) /// - If fee_bump is true, transaction_xdr must be provided /// - Operations mode cannot use fee_bump + /// - If signed_auth_entry is provided, transaction_xdr must also be provided + /// - signed_auth_entry and fee_bump are mutually exclusive pub fn validate(&self) -> Result<(), crate::models::ApiError> { use crate::models::ApiError; @@ -44,6 +52,7 @@ impl StellarTransactionRequest { .map(|ops| !ops.is_empty()) .unwrap_or(false); let has_xdr = self.transaction_xdr.is_some(); + let has_signed_auth_entry = self.signed_auth_entry.is_some(); match (has_operations, has_xdr) { (true, true) => { @@ -66,6 +75,20 @@ impl StellarTransactionRequest { )); } + // Validate signed_auth_entry usage (Soroban gas abstraction) + if has_signed_auth_entry { + if !has_xdr { + return Err(ApiError::BadRequest( + "signed_auth_entry requires transaction_xdr to be provided".to_string(), + )); + } + if self.fee_bump == Some(true) { + return Err(ApiError::BadRequest( + "Cannot use both signed_auth_entry and fee_bump".to_string(), + )); + } + } + Ok(()) } } @@ -109,6 +132,7 @@ mod tests { transaction_xdr: Some("AAAAA...".to_string()), fee_bump: None, max_fee: None, + signed_auth_entry: None, }; let result = req.validate(); @@ -132,6 +156,7 @@ mod tests { transaction_xdr: None, fee_bump: None, max_fee: None, + signed_auth_entry: None, }; let result = req.validate(); @@ -159,6 +184,7 @@ mod tests { transaction_xdr: None, fee_bump: Some(true), max_fee: None, + signed_auth_entry: None, }; let result = req.validate(); @@ -182,6 +208,7 @@ mod tests { transaction_xdr: Some("AAAAA...".to_string()), fee_bump: Some(true), max_fee: Some(10000000), + signed_auth_entry: None, }; let result = req.validate(); @@ -205,6 +232,7 @@ mod tests { transaction_xdr: None, fee_bump: None, max_fee: None, + signed_auth_entry: None, }; let result = req.validate(); @@ -224,6 +252,7 @@ mod tests { transaction_xdr: Some("AAAAA...".to_string()), fee_bump: None, max_fee: None, + signed_auth_entry: None, }; let result = req.validate(); @@ -243,6 +272,7 @@ mod tests { transaction_xdr: None, fee_bump: None, max_fee: None, + signed_auth_entry: None, }; assert_eq!( @@ -256,6 +286,7 @@ mod tests { assert!(req.transaction_xdr.is_none()); assert!(req.fee_bump.is_none()); assert!(req.max_fee.is_none()); + assert!(req.signed_auth_entry.is_none()); } #[test] @@ -320,4 +351,95 @@ mod tests { // Validate should pass assert!(req.validate().is_ok()); } + + #[test] + fn test_validate_signed_auth_entry_with_xdr() { + // Soroban gas abstraction: signed_auth_entry with transaction_xdr is valid + let req = StellarTransactionRequest { + source_account: None, + network: "testnet".to_string(), + operations: None, + memo: None, + valid_until: None, + transaction_xdr: Some("AAAAA...".to_string()), + fee_bump: None, + max_fee: None, + signed_auth_entry: Some("BBBBB...".to_string()), + }; + + let result = req.validate(); + assert!(result.is_ok()); + } + + #[test] + fn test_validate_signed_auth_entry_without_xdr() { + // signed_auth_entry without transaction_xdr should fail + let req = StellarTransactionRequest { + source_account: None, + network: "testnet".to_string(), + operations: Some(vec![OperationSpec::Payment { + destination: "GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB".to_string(), + amount: 1000000, + asset: crate::models::transaction::stellar::AssetSpec::Native, + }]), + memo: None, + valid_until: None, + transaction_xdr: None, + fee_bump: None, + max_fee: None, + signed_auth_entry: Some("BBBBB...".to_string()), + }; + + let result = req.validate(); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("signed_auth_entry requires transaction_xdr")); + } + + #[test] + fn test_validate_signed_auth_entry_with_fee_bump() { + // signed_auth_entry with fee_bump should fail (mutually exclusive) + let req = StellarTransactionRequest { + source_account: None, + network: "testnet".to_string(), + operations: None, + memo: None, + valid_until: None, + transaction_xdr: Some("AAAAA...".to_string()), + fee_bump: Some(true), + max_fee: Some(10000000), + signed_auth_entry: Some("BBBBB...".to_string()), + }; + + let result = req.validate(); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("Cannot use both signed_auth_entry and fee_bump")); + } + + #[test] + fn test_serde_signed_auth_entry() { + // Test JSON deserialization with signed_auth_entry + let json = r#"{ + "network": "testnet", + "transaction_xdr": "AAAAAgAAAACige4lTdwSB/sto4SniEdJ2kOa2X65s5bqkd40J4DjSwAAAAEAAHAkAAAADwAAAAAAAAAAAAAAAQAAAAAAAAABAAAAAKKB7iVN3BIH+y2jhKeIR0naQ5rZfrmzluqR3jQngONLAAAAAAAAAAAAD0JAAAAAAAAAAAA=", + "signed_auth_entry": "AAAAAQAAAAEAAAAHYm9pbGVycz..." + }"#; + + let req: StellarTransactionRequest = serde_json::from_str(json).unwrap(); + assert_eq!(req.network, "testnet"); + assert!(req.transaction_xdr.is_some()); + assert!(req.signed_auth_entry.is_some()); + assert_eq!( + req.signed_auth_entry, + Some("AAAAAQAAAAEAAAAHYm9pbGVycz...".to_string()) + ); + + // Validate should pass + assert!(req.validate().is_ok()); + } } diff --git a/src/models/transaction/stellar/conversion.rs b/src/models/transaction/stellar/conversion.rs index c210336e0..3e25c6fd1 100644 --- a/src/models/transaction/stellar/conversion.rs +++ b/src/models/transaction/stellar/conversion.rs @@ -110,7 +110,8 @@ impl TryFrom for Transaction { }) } crate::models::TransactionInput::UnsignedXdr(_) - | crate::models::TransactionInput::SignedXdr { .. } => { + | crate::models::TransactionInput::SignedXdr { .. } + | crate::models::TransactionInput::SorobanGasAbstraction { .. } => { // XDR inputs should not be converted to Transaction // The signer handles TransactionEnvelope XDR directly Err(SignerError::ConversionError( diff --git a/src/services/signer/stellar/aws_kms_signer.rs b/src/services/signer/stellar/aws_kms_signer.rs index cf1a16c49..5fc433ed5 100644 --- a/src/services/signer/stellar/aws_kms_signer.rs +++ b/src/services/signer/stellar/aws_kms_signer.rs @@ -17,7 +17,9 @@ use crate::{ use async_trait::async_trait; use sha2::{Digest, Sha256}; use soroban_rs::xdr::{ - DecoratedSignature, Hash, Limits, ReadXdr, Signature, SignatureHint, Transaction, + DecoratedSignature, Hash, HashIdPreimage, HashIdPreimageSorobanAuthorization, Limits, ReadXdr, + ScBytes, ScMap, ScMapEntry, ScSymbol, ScVal, ScVec, Signature, SignatureHint, + SorobanAddressCredentials, SorobanAuthorizationEntry, SorobanCredentials, Transaction, TransactionEnvelope, WriteXdr, }; use tracing::debug; @@ -81,7 +83,8 @@ impl Signer for AwsKmsSigner { .await? } crate::models::TransactionInput::UnsignedXdr(xdr) - | crate::models::TransactionInput::SignedXdr { xdr, .. } => { + | crate::models::TransactionInput::SignedXdr { xdr, .. } + | crate::models::TransactionInput::SorobanGasAbstraction { xdr, .. } => { // Parse the XDR envelope and sign let envelope = TransactionEnvelope::from_xdr_base64(xdr, Limits::none()).map_err(|e| { diff --git a/src/services/signer/stellar/google_cloud_kms_signer.rs b/src/services/signer/stellar/google_cloud_kms_signer.rs index 5f7aa58de..5f38d3bf9 100644 --- a/src/services/signer/stellar/google_cloud_kms_signer.rs +++ b/src/services/signer/stellar/google_cloud_kms_signer.rs @@ -90,7 +90,8 @@ impl Signer .await? } crate::models::TransactionInput::UnsignedXdr(xdr) - | crate::models::TransactionInput::SignedXdr { xdr, .. } => { + | crate::models::TransactionInput::SignedXdr { xdr, .. } + | crate::models::TransactionInput::SorobanGasAbstraction { xdr, .. } => { // Parse the XDR envelope and sign let envelope = TransactionEnvelope::from_xdr_base64(xdr, Limits::none()).map_err(|e| { diff --git a/src/services/signer/stellar/local_signer.rs b/src/services/signer/stellar/local_signer.rs index cb4cf25fd..cae35d72e 100644 --- a/src/services/signer/stellar/local_signer.rs +++ b/src/services/signer/stellar/local_signer.rs @@ -31,7 +31,9 @@ use ed25519_dalek::{ed25519::signature::SignerMut, SigningKey}; use eyre::Result; use sha2::{Digest, Sha256}; use soroban_rs::xdr::{ - DecoratedSignature, Hash, Limits, ReadXdr, Signature, SignatureHint, Transaction, + DecoratedSignature, Hash, HashIdPreimage, HashIdPreimageSorobanAuthorization, Limits, ReadXdr, + ScBytes, ScMap, ScMapEntry, ScSymbol, ScVal, ScVec, Signature, SignatureHint, + SorobanAddressCredentials, SorobanAuthorizationEntry, SorobanCredentials, Transaction, TransactionEnvelope, TransactionSignaturePayload, TransactionSignaturePayloadTaggedTransaction, Uint256, VecM, WriteXdr, }; @@ -132,7 +134,9 @@ impl Signer for LocalSigner { SignerError::SigningError(format!("failed to sign transaction: {e}")) })? } - TransactionInput::UnsignedXdr(xdr) | TransactionInput::SignedXdr { xdr, .. } => { + TransactionInput::UnsignedXdr(xdr) + | TransactionInput::SignedXdr { xdr, .. } + | TransactionInput::SorobanGasAbstraction { xdr, .. } => { // Parse the XDR envelope and sign let envelope = TransactionEnvelope::from_xdr_base64(xdr, Limits::none()) .map_err(|e| SignerError::SigningError(format!("invalid envelope XDR: {e}")))?; diff --git a/src/services/signer/stellar/turnkey_signer.rs b/src/services/signer/stellar/turnkey_signer.rs index 4328cf71b..7d8851066 100644 --- a/src/services/signer/stellar/turnkey_signer.rs +++ b/src/services/signer/stellar/turnkey_signer.rs @@ -232,7 +232,9 @@ impl Signer for TurnkeySigner { self.sign_transaction_directly(&transaction, &network_id) .await? } - TransactionInput::UnsignedXdr(xdr) | TransactionInput::SignedXdr { xdr, .. } => { + TransactionInput::UnsignedXdr(xdr) + | TransactionInput::SignedXdr { xdr, .. } + | TransactionInput::SorobanGasAbstraction { xdr, .. } => { let envelope = TransactionEnvelope::from_xdr_base64(xdr, Limits::none()).map_err(|e| { SignerError::SigningError(format!( diff --git a/src/services/stellar_fee_forwarder/mod.rs b/src/services/stellar_fee_forwarder/mod.rs index 77674d5c9..f1feb195d 100644 --- a/src/services/stellar_fee_forwarder/mod.rs +++ b/src/services/stellar_fee_forwarder/mod.rs @@ -11,7 +11,7 @@ //! ## Authorization Flow //! //! User signs authorization for `fee_forwarder.forward()` with sub-invocations: -//! - `fee_token.approve(fee_forwarder, max_fee_amount, expiration_ledger)` +//! - `fee_token.approve(user, fee_forwarder, max_fee_amount, expiration_ledger)` //! - `target_contract.target_fn(target_args)` (if target requires auth) use crate::services::provider::StellarProviderTrait; @@ -120,8 +120,10 @@ where // Build sub-invocations let mut sub_invocations = Vec::new(); - // 1. fee_token.approve(fee_forwarder, max_fee_amount, expiration_ledger) + // 1. fee_token.approve(user, fee_forwarder, max_fee_amount, expiration_ledger) + // Note: When called from another contract, approve requires 'from' address as first arg let approve_args: ScVec = vec![ + ScVal::Address(user_addr.clone()), ScVal::Address(fee_forwarder_addr.clone()), Self::i128_to_scval(params.max_fee_amount), ScVal::U32(params.expiration_ledger), @@ -403,7 +405,7 @@ where /// /// This creates the authorization structure that the user needs to sign. /// The authorization includes sub-invocations for: - /// - fee_token.approve(fee_forwarder, max_fee_amount, expiration_ledger) + /// - fee_token.approve(user, fee_forwarder, max_fee_amount, expiration_ledger) /// - target_contract.target_fn(target_args) (if requires_target_auth is true) /// /// # Arguments @@ -429,8 +431,10 @@ where // Build sub-invocations let mut sub_invocations = Vec::new(); - // 1. fee_token.approve(fee_forwarder, max_fee_amount, expiration_ledger) + // 1. fee_token.approve(user, fee_forwarder, max_fee_amount, expiration_ledger) + // Note: When called from another contract, approve requires 'from' address as first arg let approve_args: ScVec = vec![ + ScVal::Address(user_addr.clone()), ScVal::Address(fee_forwarder_addr.clone()), Self::i128_to_scval(params.max_fee_amount), ScVal::U32(params.expiration_ledger), diff --git a/src/utils/mocks.rs b/src/utils/mocks.rs index e8d03dc50..067f2ab50 100644 --- a/src/utils/mocks.rs +++ b/src/utils/mocks.rs @@ -343,6 +343,7 @@ pub mod mockutils { redis_reader_pool_max_size: 1000, stellar_fee_forwarder_address: None, stellar_soroswap_router_address: None, + stellar_soroswap_factory_address: None, stellar_soroswap_native_wrapper_address: None, } } From e37b4d8b0989776bf8e77bc3af4fe7f0e67dfddb Mon Sep 17 00:00:00 2001 From: Nicolas Molina Date: Tue, 3 Feb 2026 17:34:12 -0300 Subject: [PATCH 05/22] feat: Tool for signing user auth entry --- Cargo.toml | 4 + helpers/sign_soroban_auth_entry.rs | 293 +++++++++++++++++++++++++++++ 2 files changed, 297 insertions(+) create mode 100644 helpers/sign_soroban_auth_entry.rs diff --git a/Cargo.toml b/Cargo.toml index 4e22dd13f..01ad1094e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -146,5 +146,9 @@ path = "helpers/generate_openapi.rs" name = "verify_eip712" path = "helpers/verify_eip712.rs" +[[example]] +name = "sign_soroban_auth_entry" +path = "helpers/sign_soroban_auth_entry.rs" + [lib] path = "src/lib.rs" diff --git a/helpers/sign_soroban_auth_entry.rs b/helpers/sign_soroban_auth_entry.rs new file mode 100644 index 000000000..027e6f3e8 --- /dev/null +++ b/helpers/sign_soroban_auth_entry.rs @@ -0,0 +1,293 @@ +//! Soroban Authorization Entry Signing Tool +//! +//! Signs a `user_auth_entry` from the `/build` endpoint response for gas abstraction. +//! +//! # Usage +//! +//! ```bash +//! cargo run --example sign_auth_entry -- \ +//! --secret-key "S..." \ +//! --auth-entry "" \ +//! --network testnet +//! ``` +//! +//! # Output +//! +//! - Default: Signed auth entry as base64 XDR +//! - With `--transaction-xdr`: JSON payload for `/transactions` endpoint + +use clap::Parser; +use ed25519_dalek::{Signer, SigningKey}; +use eyre::{eyre, Result, WrapErr}; +use sha2::{Digest, Sha256}; +use soroban_rs::xdr::{ + AccountId, Hash, HashIdPreimage, HashIdPreimageSorobanAuthorization, Limits, ReadXdr, + ScAddress, ScBytes, ScMap, ScMapEntry, ScSymbol, ScVal, ScVec, SorobanAddressCredentials, + SorobanAuthorizationEntry, SorobanCredentials, WriteXdr, +}; +use stellar_strkey::ed25519::{PrivateKey, PublicKey}; + +/// CLI arguments for signing Soroban authorization entries +#[derive(Parser, Debug)] +#[command(name = "sign-auth-entry")] +#[command(about = "Sign Soroban authorization entries for gas abstraction")] +#[command(version)] +struct Args { + /// Stellar secret key (S... format) + #[arg(short, long)] + secret_key: String, + + /// Base64 XDR encoded SorobanAuthorizationEntry (from /build endpoint) + #[arg(short, long)] + auth_entry: String, + + /// Network: "testnet", "mainnet", or "custom:" + #[arg(short, long, default_value = "testnet")] + network: String, + + /// Transaction XDR from /build endpoint (optional, for JSON output) + #[arg(short, long)] + transaction_xdr: Option, +} + +/// Resolves network name to passphrase +fn resolve_network_passphrase(network: &str) -> Result { + let lower = network.to_lowercase(); + if lower == "testnet" { + Ok("Test SDF Network ; September 2015".to_string()) + } else if lower == "mainnet" { + Ok("Public Global Stellar Network ; September 2015".to_string()) + } else if lower.starts_with("custom:") { + // Preserve original case for custom passphrase + Ok(network + .get(7..) // Skip "custom:" prefix + .ok_or_else(|| eyre!("Invalid custom network format"))? + .to_string()) + } else { + Err(eyre!( + "Invalid network '{}'. Use 'testnet', 'mainnet', or 'custom:'", + network + )) + } +} + +/// Extract the public key bytes from an ScAddress (Account type) +fn extract_account_public_key(address: &ScAddress) -> Result<[u8; 32]> { + match address { + ScAddress::Account(AccountId(public_key)) => match public_key { + soroban_rs::xdr::PublicKey::PublicKeyTypeEd25519(uint256) => Ok(uint256.0), + }, + _ => Err(eyre!( + "Expected Account address (G...), got a different address type" + )), + } +} + +/// Signs a Soroban authorization entry +fn sign_auth_entry( + secret_key: &str, + auth_entry_xdr: &str, + network_passphrase: &str, +) -> Result { + // 1. Parse the secret key + let private_key = + PrivateKey::from_string(secret_key).map_err(|e| eyre!("Invalid secret key: {}", e))?; + let signing_key = SigningKey::from_bytes(&private_key.0); + let verifying_key = signing_key.verifying_key(); + let public_key_bytes = verifying_key.to_bytes(); + + // 2. Decode the auth entry + let auth_entry = SorobanAuthorizationEntry::from_xdr_base64(auth_entry_xdr, Limits::none()) + .wrap_err("Failed to decode auth entry XDR")?; + + // 3. Extract credentials + let addr_creds = match &auth_entry.credentials { + SorobanCredentials::Address(creds) => creds, + _ => return Err(eyre!("Expected Address credentials in auth entry")), + }; + + // 4. Verify the public key matches the address in the auth entry + let expected_public_key = extract_account_public_key(&addr_creds.address)?; + let signer_public_key = PublicKey(public_key_bytes); + let expected_g_address = PublicKey(expected_public_key); + + eprintln!( + "Auth entry address: G{}", + stellar_strkey::Strkey::PublicKeyEd25519(expected_g_address) + .to_string() + .strip_prefix("G") + .unwrap_or("") + ); + eprintln!( + "Signer public key: G{}", + stellar_strkey::Strkey::PublicKeyEd25519(signer_public_key) + .to_string() + .strip_prefix("G") + .unwrap_or("") + ); + + if public_key_bytes != expected_public_key { + return Err(eyre!( + "Secret key does not match auth entry address!\n\ + Expected: {}\n\ + Got: {}", + stellar_strkey::Strkey::PublicKeyEd25519(expected_g_address), + stellar_strkey::Strkey::PublicKeyEd25519(signer_public_key) + )); + } + + eprintln!("Public key matches auth entry address"); + + // 5. Create the network ID (hash of passphrase) + let network_id_bytes: [u8; 32] = Sha256::digest(network_passphrase.as_bytes()).into(); + let network_id = Hash(network_id_bytes); + + eprintln!("Network: {}", network_passphrase); + eprintln!("Nonce: {}", addr_creds.nonce); + eprintln!( + "Signature expiration ledger: {}", + addr_creds.signature_expiration_ledger + ); + + // 6. Build the preimage for signing + let preimage = HashIdPreimage::SorobanAuthorization(HashIdPreimageSorobanAuthorization { + network_id, + nonce: addr_creds.nonce, + signature_expiration_ledger: addr_creds.signature_expiration_ledger, + invocation: auth_entry.root_invocation.clone(), + }); + + // 7. Serialize preimage to XDR and hash + let preimage_xdr = preimage + .to_xdr(Limits::none()) + .wrap_err("Failed to serialize preimage")?; + let preimage_hash = Sha256::digest(&preimage_xdr); + + eprintln!("Preimage hash: {}", hex::encode(&preimage_hash)); + + // 8. Sign the hash + let signature = signing_key.sign(&preimage_hash); + + eprintln!( + "Signature (64 bytes): {}", + hex::encode(signature.to_bytes()) + ); + + // 10. Create the signature ScVal (Vec containing a Map with public_key and signature) + // Format: Vec where each entry is a Map with: + // - "public_key": BytesN<32> + // - "signature": BytesN<64> + let signature_map = ScMap::try_from(vec![ + ScMapEntry { + key: ScVal::Symbol( + ScSymbol::try_from("public_key".as_bytes().to_vec()) + .map_err(|_| eyre!("Failed to create public_key symbol"))?, + ), + val: ScVal::Bytes( + ScBytes::try_from(public_key_bytes.to_vec()) + .map_err(|_| eyre!("Failed to create public_key bytes"))?, + ), + }, + ScMapEntry { + key: ScVal::Symbol( + ScSymbol::try_from("signature".as_bytes().to_vec()) + .map_err(|_| eyre!("Failed to create signature symbol"))?, + ), + val: ScVal::Bytes( + ScBytes::try_from(signature.to_bytes().to_vec()) + .map_err(|_| eyre!("Failed to create signature bytes"))?, + ), + }, + ]) + .map_err(|_| eyre!("Failed to create signature map"))?; + + let signature_scval = ScVal::Vec(Some( + ScVec::try_from(vec![ScVal::Map(Some(signature_map))]) + .map_err(|_| eyre!("Failed to create signature vec"))?, + )); + + eprintln!("Created signature ScVal structure"); + + // 11. Create the signed auth entry + let signed_auth_entry = SorobanAuthorizationEntry { + credentials: SorobanCredentials::Address(SorobanAddressCredentials { + address: addr_creds.address.clone(), + nonce: addr_creds.nonce, + signature_expiration_ledger: addr_creds.signature_expiration_ledger, + signature: signature_scval, + }), + root_invocation: auth_entry.root_invocation, + }; + + // 12. Serialize to base64 XDR + let signed_xdr = signed_auth_entry + .to_xdr_base64(Limits::none()) + .wrap_err("Failed to serialize signed auth entry")?; + + Ok(signed_xdr) +} + +fn main() -> Result<()> { + let args = Args::parse(); + + // Resolve network passphrase + let network_passphrase = resolve_network_passphrase(&args.network)?; + + // Sign the auth entry + let signed_auth_entry = + sign_auth_entry(&args.secret_key, &args.auth_entry, &network_passphrase)?; + + // Output based on whether transaction_xdr was provided + match args.transaction_xdr { + Some(tx_xdr) => { + // Output JSON payload for /transactions endpoint + let payload = serde_json::json!({ + "network": args.network, + "transaction_xdr": tx_xdr, + "signed_auth_entry": signed_auth_entry + }); + println!("{}", serde_json::to_string_pretty(&payload)?); + } + None => { + // Output just the signed auth entry + println!("{}", signed_auth_entry); + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_resolve_network_passphrase_testnet() { + let passphrase = resolve_network_passphrase("testnet").unwrap(); + assert_eq!(passphrase, "Test SDF Network ; September 2015"); + } + + #[test] + fn test_resolve_network_passphrase_mainnet() { + let passphrase = resolve_network_passphrase("mainnet").unwrap(); + assert_eq!(passphrase, "Public Global Stellar Network ; September 2015"); + } + + #[test] + fn test_resolve_network_passphrase_custom() { + let passphrase = resolve_network_passphrase("custom:My Custom Network").unwrap(); + assert_eq!(passphrase, "My Custom Network"); + } + + #[test] + fn test_resolve_network_passphrase_invalid() { + let result = resolve_network_passphrase("invalid"); + assert!(result.is_err()); + } + + #[test] + fn test_resolve_network_passphrase_case_insensitive() { + let passphrase = resolve_network_passphrase("TESTNET").unwrap(); + assert_eq!(passphrase, "Test SDF Network ; September 2015"); + } +} From 1e7d6b9ebbc1ac275b83dfef0ba0ea82e488d433 Mon Sep 17 00:00:00 2001 From: Nicolas Molina Date: Tue, 3 Feb 2026 18:12:07 -0300 Subject: [PATCH 06/22] test: Fixing test cases --- src/domain/transaction/stellar/token.rs | 337 ++++-------------------- 1 file changed, 51 insertions(+), 286 deletions(-) diff --git a/src/domain/transaction/stellar/token.rs b/src/domain/transaction/stellar/token.rs index f370c6972..e3da0958f 100644 --- a/src/domain/transaction/stellar/token.rs +++ b/src/domain/transaction/stellar/token.rs @@ -1342,17 +1342,14 @@ mod tests { #[tokio::test] async fn test_get_token_balance_contract_token_no_balance_entry() { + use soroban_rs::xdr::Int128Parts; + let mut provider = create_mock_provider(); - // Mock empty response (no balance entry) - provider.expect_get_ledger_entries().returning(|_| { - Box::pin(ready(Ok( - soroban_rs::stellar_rpc_client::GetLedgerEntriesResponse { - entries: None, - latest_ledger: 0, - }, - ))) - }); + // Mock call_contract to return 0 balance (contract returns 0 for non-existent balances) + provider + .expect_call_contract() + .returning(|_, _, _| Box::pin(ready(Ok(ScVal::I128(Int128Parts { hi: 0, lo: 0 }))))); let contract_addr = "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA"; let account = TEST_PK; @@ -1366,46 +1363,13 @@ mod tests { #[tokio::test] async fn test_get_token_balance_contract_token_i128_balance() { - use soroban_rs::stellar_rpc_client::{GetLedgerEntriesResponse, LedgerEntryResult}; - use soroban_rs::xdr::{ - ContractDataDurability, ContractDataEntry, ExtensionPoint, Int128Parts, LedgerEntry, - LedgerEntryData, LedgerEntryExt, ScVal, WriteXdr, - }; + use soroban_rs::xdr::Int128Parts; let mut provider = create_mock_provider(); - // Mock response with I128 balance - provider.expect_get_ledger_entries().returning(|_| { - let balance_val = ScVal::I128(Int128Parts { hi: 0, lo: 1000000 }); - - let contract_data = ContractDataEntry { - ext: ExtensionPoint::V0, - contract: ScAddress::Contract(ContractId(Hash([0u8; 32]))), - key: ScVal::Vec(None), - durability: ContractDataDurability::Persistent, - val: balance_val, - }; - - let ledger_entry = LedgerEntry { - last_modified_ledger_seq: 0, - data: LedgerEntryData::ContractData(contract_data), - ext: LedgerEntryExt::V0, - }; - - let xdr_base64 = ledger_entry - .data - .to_xdr_base64(soroban_rs::xdr::Limits::none()) - .unwrap(); - - Box::pin(ready(Ok(GetLedgerEntriesResponse { - entries: Some(vec![LedgerEntryResult { - key: String::new(), - xdr: xdr_base64, - last_modified_ledger: 0, - live_until_ledger_seq_ledger_seq: None, - }]), - latest_ledger: 0, - }))) + // Mock call_contract to return I128 balance + provider.expect_call_contract().returning(|_, _, _| { + Box::pin(ready(Ok(ScVal::I128(Int128Parts { hi: 0, lo: 1000000 })))) }); let contract_addr = "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA"; @@ -1418,49 +1382,16 @@ mod tests { #[tokio::test] async fn test_get_token_balance_contract_token_i128_balance_too_large() { - use soroban_rs::stellar_rpc_client::{GetLedgerEntriesResponse, LedgerEntryResult}; - use soroban_rs::xdr::{ - ContractDataDurability, ContractDataEntry, ExtensionPoint, Int128Parts, LedgerEntry, - LedgerEntryData, LedgerEntryExt, ScVal, WriteXdr, - }; + use soroban_rs::xdr::Int128Parts; let mut provider = create_mock_provider(); - // Mock response with I128 balance where hi != 0 - provider.expect_get_ledger_entries().returning(|_| { - let balance_val = ScVal::I128(Int128Parts { + // Mock call_contract to return I128 balance with hi != 0 (too large) + provider.expect_call_contract().returning(|_, _, _| { + Box::pin(ready(Ok(ScVal::I128(Int128Parts { hi: 1, // Non-zero hi means balance is too large lo: 1000000, - }); - - let contract_data = ContractDataEntry { - ext: ExtensionPoint::V0, - contract: ScAddress::Contract(ContractId(Hash([0u8; 32]))), - key: ScVal::Vec(None), - durability: ContractDataDurability::Persistent, - val: balance_val, - }; - - let ledger_entry = LedgerEntry { - last_modified_ledger_seq: 0, - data: LedgerEntryData::ContractData(contract_data), - ext: LedgerEntryExt::V0, - }; - - let xdr_base64 = ledger_entry - .data - .to_xdr_base64(soroban_rs::xdr::Limits::none()) - .unwrap(); - - Box::pin(ready(Ok(GetLedgerEntriesResponse { - entries: Some(vec![LedgerEntryResult { - key: String::new(), - xdr: xdr_base64, - last_modified_ledger: 0, - live_until_ledger_seq_ledger_seq: None, - }]), - latest_ledger: 0, - }))) + })))) }); let contract_addr = "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA"; @@ -1479,49 +1410,16 @@ mod tests { #[tokio::test] async fn test_get_token_balance_contract_token_i128_negative() { - use soroban_rs::stellar_rpc_client::{GetLedgerEntriesResponse, LedgerEntryResult}; - use soroban_rs::xdr::{ - ContractDataDurability, ContractDataEntry, ExtensionPoint, Int128Parts, LedgerEntry, - LedgerEntryData, LedgerEntryExt, ScVal, WriteXdr, - }; + use soroban_rs::xdr::Int128Parts; let mut provider = create_mock_provider(); - // Mock response with negative I128 balance - provider.expect_get_ledger_entries().returning(|_| { - let balance_val = ScVal::I128(Int128Parts { + // Mock call_contract to return negative I128 balance + provider.expect_call_contract().returning(|_, _, _| { + Box::pin(ready(Ok(ScVal::I128(Int128Parts { hi: 0, lo: u64::MAX, // When cast to i64, this is negative - }); - - let contract_data = ContractDataEntry { - ext: ExtensionPoint::V0, - contract: ScAddress::Contract(ContractId(Hash([0u8; 32]))), - key: ScVal::Vec(None), - durability: ContractDataDurability::Persistent, - val: balance_val, - }; - - let ledger_entry = LedgerEntry { - last_modified_ledger_seq: 0, - data: LedgerEntryData::ContractData(contract_data), - ext: LedgerEntryExt::V0, - }; - - let xdr_base64 = ledger_entry - .data - .to_xdr_base64(soroban_rs::xdr::Limits::none()) - .unwrap(); - - Box::pin(ready(Ok(GetLedgerEntriesResponse { - entries: Some(vec![LedgerEntryResult { - key: String::new(), - xdr: xdr_base64, - last_modified_ledger: 0, - live_until_ledger_seq_ledger_seq: None, - }]), - latest_ledger: 0, - }))) + })))) }); let contract_addr = "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA"; @@ -1537,208 +1435,75 @@ mod tests { #[tokio::test] async fn test_get_token_balance_contract_token_u64_balance() { - use soroban_rs::stellar_rpc_client::{GetLedgerEntriesResponse, LedgerEntryResult}; - use soroban_rs::xdr::{ - ContractDataDurability, ContractDataEntry, ExtensionPoint, LedgerEntry, - LedgerEntryData, LedgerEntryExt, ScVal, WriteXdr, - }; - let mut provider = create_mock_provider(); - // Mock response with U64 balance - provider.expect_get_ledger_entries().returning(|_| { - let balance_val = ScVal::U64(5000000); - - let contract_data = ContractDataEntry { - ext: ExtensionPoint::V0, - contract: ScAddress::Contract(ContractId(Hash([0u8; 32]))), - key: ScVal::Vec(None), - durability: ContractDataDurability::Persistent, - val: balance_val, - }; - - let ledger_entry = LedgerEntry { - last_modified_ledger_seq: 0, - data: LedgerEntryData::ContractData(contract_data), - ext: LedgerEntryExt::V0, - }; - - let xdr_base64 = ledger_entry - .data - .to_xdr_base64(soroban_rs::xdr::Limits::none()) - .unwrap(); - - Box::pin(ready(Ok(GetLedgerEntriesResponse { - entries: Some(vec![LedgerEntryResult { - key: String::new(), - xdr: xdr_base64, - last_modified_ledger: 0, - live_until_ledger_seq_ledger_seq: None, - }]), - latest_ledger: 0, - }))) - }); + // Mock call_contract to return U64 balance - this is unexpected for balance() which returns i128 + provider + .expect_call_contract() + .returning(|_, _, _| Box::pin(ready(Ok(ScVal::U64(5000000))))); let contract_addr = "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA"; let account = TEST_PK; let result = get_token_balance(&provider, account, contract_addr).await; - assert!(result.is_ok()); - assert_eq!(result.unwrap(), 5000000); + // U64 is not a valid return type for balance(), should return UnexpectedBalanceType + assert!(result.is_err()); + match result.unwrap_err() { + StellarTransactionUtilsError::UnexpectedBalanceType(_) => {} + e => panic!("Expected UnexpectedBalanceType, got: {:?}", e), + } } #[tokio::test] async fn test_get_token_balance_contract_token_i64_positive() { - use soroban_rs::stellar_rpc_client::{GetLedgerEntriesResponse, LedgerEntryResult}; - use soroban_rs::xdr::{ - ContractDataDurability, ContractDataEntry, ExtensionPoint, LedgerEntry, - LedgerEntryData, LedgerEntryExt, ScVal, WriteXdr, - }; - let mut provider = create_mock_provider(); - // Mock response with positive I64 balance - provider.expect_get_ledger_entries().returning(|_| { - let balance_val = ScVal::I64(3000000); - - let contract_data = ContractDataEntry { - ext: ExtensionPoint::V0, - contract: ScAddress::Contract(ContractId(Hash([0u8; 32]))), - key: ScVal::Vec(None), - durability: ContractDataDurability::Persistent, - val: balance_val, - }; - - let ledger_entry = LedgerEntry { - last_modified_ledger_seq: 0, - data: LedgerEntryData::ContractData(contract_data), - ext: LedgerEntryExt::V0, - }; - - let xdr_base64 = ledger_entry - .data - .to_xdr_base64(soroban_rs::xdr::Limits::none()) - .unwrap(); - - Box::pin(ready(Ok(GetLedgerEntriesResponse { - entries: Some(vec![LedgerEntryResult { - key: String::new(), - xdr: xdr_base64, - last_modified_ledger: 0, - live_until_ledger_seq_ledger_seq: None, - }]), - latest_ledger: 0, - }))) - }); + // Mock call_contract to return I64 balance - this is unexpected for balance() which returns i128 + provider + .expect_call_contract() + .returning(|_, _, _| Box::pin(ready(Ok(ScVal::I64(3000000))))); let contract_addr = "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA"; let account = TEST_PK; let result = get_token_balance(&provider, account, contract_addr).await; - assert!(result.is_ok()); - assert_eq!(result.unwrap(), 3000000); + // I64 is not a valid return type for balance(), should return UnexpectedBalanceType + assert!(result.is_err()); + match result.unwrap_err() { + StellarTransactionUtilsError::UnexpectedBalanceType(_) => {} + e => panic!("Expected UnexpectedBalanceType, got: {:?}", e), + } } #[tokio::test] async fn test_get_token_balance_contract_token_i64_negative() { - use soroban_rs::stellar_rpc_client::{GetLedgerEntriesResponse, LedgerEntryResult}; - use soroban_rs::xdr::{ - ContractDataDurability, ContractDataEntry, ExtensionPoint, LedgerEntry, - LedgerEntryData, LedgerEntryExt, ScVal, WriteXdr, - }; - let mut provider = create_mock_provider(); - // Mock response with negative I64 balance - provider.expect_get_ledger_entries().returning(|_| { - let balance_val = ScVal::I64(-1000); - - let contract_data = ContractDataEntry { - ext: ExtensionPoint::V0, - contract: ScAddress::Contract(ContractId(Hash([0u8; 32]))), - key: ScVal::Vec(None), - durability: ContractDataDurability::Persistent, - val: balance_val, - }; - - let ledger_entry = LedgerEntry { - last_modified_ledger_seq: 0, - data: LedgerEntryData::ContractData(contract_data), - ext: LedgerEntryExt::V0, - }; - - let xdr_base64 = ledger_entry - .data - .to_xdr_base64(soroban_rs::xdr::Limits::none()) - .unwrap(); - - Box::pin(ready(Ok(GetLedgerEntriesResponse { - entries: Some(vec![LedgerEntryResult { - key: String::new(), - xdr: xdr_base64, - last_modified_ledger: 0, - live_until_ledger_seq_ledger_seq: None, - }]), - latest_ledger: 0, - }))) - }); + // Mock call_contract to return I64 balance - this is unexpected for balance() which returns i128 + provider + .expect_call_contract() + .returning(|_, _, _| Box::pin(ready(Ok(ScVal::I64(-1000))))); let contract_addr = "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA"; let account = TEST_PK; let result = get_token_balance(&provider, account, contract_addr).await; + // I64 is not a valid return type for balance(), should return UnexpectedBalanceType assert!(result.is_err()); match result.unwrap_err() { - StellarTransactionUtilsError::NegativeBalanceI64(n) => { - assert_eq!(n, -1000); - } - e => panic!("Expected NegativeBalanceI64, got: {:?}", e), + StellarTransactionUtilsError::UnexpectedBalanceType(_) => {} + e => panic!("Expected UnexpectedBalanceType, got: {:?}", e), } } #[tokio::test] async fn test_get_token_balance_contract_token_unexpected_balance_type() { - use soroban_rs::stellar_rpc_client::{GetLedgerEntriesResponse, LedgerEntryResult}; - use soroban_rs::xdr::{ - ContractDataDurability, ContractDataEntry, ExtensionPoint, LedgerEntry, - LedgerEntryData, LedgerEntryExt, ScVal, WriteXdr, - }; - let mut provider = create_mock_provider(); - // Mock response with unexpected balance type (Bool) - provider.expect_get_ledger_entries().returning(|_| { - let balance_val = ScVal::Bool(true); - - let contract_data = ContractDataEntry { - ext: ExtensionPoint::V0, - contract: ScAddress::Contract(ContractId(Hash([0u8; 32]))), - key: ScVal::Vec(None), - durability: ContractDataDurability::Persistent, - val: balance_val, - }; - - let ledger_entry = LedgerEntry { - last_modified_ledger_seq: 0, - data: LedgerEntryData::ContractData(contract_data), - ext: LedgerEntryExt::V0, - }; - - let xdr_base64 = ledger_entry - .data - .to_xdr_base64(soroban_rs::xdr::Limits::none()) - .unwrap(); - - Box::pin(ready(Ok(GetLedgerEntriesResponse { - entries: Some(vec![LedgerEntryResult { - key: String::new(), - xdr: xdr_base64, - last_modified_ledger: 0, - live_until_ledger_seq_ledger_seq: None, - }]), - latest_ledger: 0, - }))) - }); + // Mock call_contract to return unexpected balance type (Bool) + provider + .expect_call_contract() + .returning(|_, _, _| Box::pin(ready(Ok(ScVal::Bool(true))))); let contract_addr = "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA"; let account = TEST_PK; From f7d4617b3e72255a209558b14e6b06d6a679d8d2 Mon Sep 17 00:00:00 2001 From: Nicolas Molina Date: Wed, 4 Feb 2026 10:14:20 -0300 Subject: [PATCH 07/22] fix: Improvements --- helpers/sign_soroban_auth_entry.rs | 2 +- src/domain/relayer/stellar/gas_abstraction.rs | 17 +++++++++++-- src/domain/transaction/stellar/prepare/mod.rs | 1 - .../prepare/soroban_gas_abstraction.rs | 24 +++++++++---------- src/services/signer/stellar/local_signer.rs | 7 ++---- src/services/stellar_fee_forwarder/mod.rs | 4 +--- 6 files changed, 30 insertions(+), 25 deletions(-) diff --git a/helpers/sign_soroban_auth_entry.rs b/helpers/sign_soroban_auth_entry.rs index 027e6f3e8..83179730f 100644 --- a/helpers/sign_soroban_auth_entry.rs +++ b/helpers/sign_soroban_auth_entry.rs @@ -5,7 +5,7 @@ //! # Usage //! //! ```bash -//! cargo run --example sign_auth_entry -- \ +//! cargo run --example sign_soroban_auth_entry -- \ //! --secret-key "S..." \ //! --auth-entry "" \ //! --network testnet diff --git a/src/domain/relayer/stellar/gas_abstraction.rs b/src/domain/relayer/stellar/gas_abstraction.rs index 8fe256716..27dbf4c26 100644 --- a/src/domain/relayer/stellar/gas_abstraction.rs +++ b/src/domain/relayer/stellar/gas_abstraction.rs @@ -682,6 +682,14 @@ where ) })?; + // Validate fee_token is a valid Soroban contract address (C...) + if stellar_strkey::Contract::from_string(¶ms.fee_token).is_err() { + return Err(RelayerError::ValidationError(format!( + "fee_token must be a valid Soroban contract address (C...), got '{}'", + params.fee_token + ))); + } + // Extract user_address from transaction_xdr source account // Soroban gas abstraction requires transaction_xdr, so we can unwrap here let xdr = params.transaction_xdr.as_ref().ok_or_else(|| { @@ -984,8 +992,13 @@ where .await .map_err(|e| RelayerError::Internal(format!("Failed to get latest ledger: {e}")))?; - let ledgers_to_add = validity_seconds / LEDGER_TIME_SECONDS; - Ok(current_ledger.sequence + ledgers_to_add as u32) + let mut ledgers_to_add = validity_seconds.div_ceil(LEDGER_TIME_SECONDS); + if ledgers_to_add == 0 { + ledgers_to_add = 1; + } + Ok(current_ledger + .sequence + .saturating_add(ledgers_to_add as u32)) } /// Add payment operation to envelope using a pre-computed fee quote diff --git a/src/domain/transaction/stellar/prepare/mod.rs b/src/domain/transaction/stellar/prepare/mod.rs index 4d1e1ad6d..6e9f4b98a 100644 --- a/src/domain/transaction/stellar/prepare/mod.rs +++ b/src/domain/transaction/stellar/prepare/mod.rs @@ -153,7 +153,6 @@ where self.transaction_counter_service(), &self.relayer().id, &self.relayer().address, - self.signer(), self.provider(), stellar_data, ) diff --git a/src/domain/transaction/stellar/prepare/soroban_gas_abstraction.rs b/src/domain/transaction/stellar/prepare/soroban_gas_abstraction.rs index 0ee4ce775..60eb674e6 100644 --- a/src/domain/transaction/stellar/prepare/soroban_gas_abstraction.rs +++ b/src/domain/transaction/stellar/prepare/soroban_gas_abstraction.rs @@ -15,7 +15,6 @@ use crate::{ repositories::TransactionCounterTrait, services::{ provider::{StellarProvider, StellarProviderTrait}, - signer::StellarSignTrait, stellar_fee_forwarder::{FeeForwarderError, FeeForwarderService}, }, }; @@ -39,20 +38,17 @@ use super::common::get_next_sequence; /// * `counter_service` - Service for managing sequence numbers /// * `relayer_id` - The relayer's ID for sequence tracking /// * `relayer_address` - The relayer's Stellar address (source account for the transaction) -/// * `_signer` - Unused, kept for API compatibility /// * `provider` - The Stellar provider for simulation /// * `stellar_data` - The transaction data containing the XDR and signed auth entry -pub async fn process_soroban_gas_abstraction( +pub async fn process_soroban_gas_abstraction( counter_service: &C, relayer_id: &str, relayer_address: &str, - _signer: &S, provider: &P, mut stellar_data: StellarTransactionData, ) -> Result where C: TransactionCounterTrait + Send + Sync, - S: StellarSignTrait + Send + Sync, P: StellarProviderTrait + Send + Sync, { // Extract XDR and signed auth entry from transaction input @@ -251,15 +247,17 @@ fn update_envelope_sequence( /// /// Simulation can be slightly inaccurate due to timing differences or other factors. /// Adding a 15% buffer prevents "exceeded limit" errors during execution. +/// Uses saturating arithmetic to prevent silent truncation if scaled values exceed u32::MAX. fn apply_resource_buffer(resources: &mut SorobanResources) { - const RESOURCE_BUFFER_PERCENT: u32 = 15; - const BUFFER_MULTIPLIER: u32 = 100 + RESOURCE_BUFFER_PERCENT; // 115 - - resources.instructions = - (resources.instructions as u64 * BUFFER_MULTIPLIER as u64 / 100) as u32; - resources.disk_read_bytes = - (resources.disk_read_bytes as u64 * BUFFER_MULTIPLIER as u64 / 100) as u32; - resources.write_bytes = (resources.write_bytes as u64 * BUFFER_MULTIPLIER as u64 / 100) as u32; + const BUFFER_MULTIPLIER: u64 = 115; + + let scale = |value: u32| -> u32 { + ((value as u64).saturating_mul(BUFFER_MULTIPLIER) / 100).min(u32::MAX as u64) as u32 + }; + + resources.instructions = scale(resources.instructions); + resources.disk_read_bytes = scale(resources.disk_read_bytes); + resources.write_bytes = scale(resources.write_bytes); } /// Re-simulate the transaction with signed auth entries and update resources. diff --git a/src/services/signer/stellar/local_signer.rs b/src/services/signer/stellar/local_signer.rs index cae35d72e..5c0c3ec5f 100644 --- a/src/services/signer/stellar/local_signer.rs +++ b/src/services/signer/stellar/local_signer.rs @@ -31,11 +31,8 @@ use ed25519_dalek::{ed25519::signature::SignerMut, SigningKey}; use eyre::Result; use sha2::{Digest, Sha256}; use soroban_rs::xdr::{ - DecoratedSignature, Hash, HashIdPreimage, HashIdPreimageSorobanAuthorization, Limits, ReadXdr, - ScBytes, ScMap, ScMapEntry, ScSymbol, ScVal, ScVec, Signature, SignatureHint, - SorobanAddressCredentials, SorobanAuthorizationEntry, SorobanCredentials, Transaction, - TransactionEnvelope, TransactionSignaturePayload, TransactionSignaturePayloadTaggedTransaction, - Uint256, VecM, WriteXdr, + DecoratedSignature, Hash, Limits, ReadXdr, Signature, SignatureHint, Transaction, + TransactionEnvelope, Uint256, WriteXdr, }; use tracing::info; diff --git a/src/services/stellar_fee_forwarder/mod.rs b/src/services/stellar_fee_forwarder/mod.rs index f1feb195d..a86282e82 100644 --- a/src/services/stellar_fee_forwarder/mod.rs +++ b/src/services/stellar_fee_forwarder/mod.rs @@ -607,11 +607,9 @@ mod tests { } #[test] - fn test_parse_account_address_valid() { + fn test_parse_account_address_invalid_format() { let addr = "GABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890ABCDEFGH"; - // This is not a valid strkey, just testing the function signature let result = FeeForwarderService::::parse_account_address(addr); - // Expected to fail since it's not a valid G... address assert!(result.is_err()); } From b0ff07d1dbb32bf2212b2c3f7f2413e35f2452dc Mon Sep 17 00:00:00 2001 From: Nicolas Molina Date: Wed, 4 Feb 2026 11:25:57 -0300 Subject: [PATCH 08/22] test: Adding test cases --- src/domain/relayer/stellar/gas_abstraction.rs | 1426 +++++++++++++++++ .../prepare/soroban_gas_abstraction.rs | 957 ++++++++++- src/services/stellar_fee_forwarder/mod.rs | 541 ++++++- 3 files changed, 2911 insertions(+), 13 deletions(-) diff --git a/src/domain/relayer/stellar/gas_abstraction.rs b/src/domain/relayer/stellar/gas_abstraction.rs index 27dbf4c26..26ea83db8 100644 --- a/src/domain/relayer/stellar/gas_abstraction.rs +++ b/src/domain/relayer/stellar/gas_abstraction.rs @@ -1151,6 +1151,7 @@ mod tests { }, }; use mockall::predicate::*; + use serial_test::serial; use soroban_rs::stellar_rpc_client::GetLedgerEntriesResponse; use soroban_rs::stellar_rpc_client::LedgerEntryResult; use soroban_rs::xdr::{ @@ -2006,4 +2007,1429 @@ mod tests { .await; assert!(result.is_err()); } + + // ============================================================================ + // Tests for detect_soroban_invoke_from_xdr + // ============================================================================ + + #[test] + fn test_detect_soroban_invoke_from_xdr_classic_transaction() { + // Classic payment transaction should return None + let xdr = create_test_transaction_xdr(); + let result = detect_soroban_invoke_from_xdr(&xdr); + assert!(result.is_ok()); + assert!(result.unwrap().is_none()); + } + + #[test] + fn test_detect_soroban_invoke_from_xdr_invalid_xdr() { + let result = detect_soroban_invoke_from_xdr("INVALID_XDR"); + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + RelayerError::ValidationError(_) + )); + } + + #[test] + fn test_detect_soroban_invoke_from_xdr_with_soroban_transaction() { + use soroban_rs::xdr::{ + ContractId, Hash, HostFunction, InvokeContractArgs, InvokeHostFunctionOp, Memo, + MuxedAccount, Operation, OperationBody, Preconditions, ScAddress, ScSymbol, ScVal, + SequenceNumber, Transaction, TransactionEnvelope, TransactionExt, + TransactionV1Envelope, Uint256, VecM, + }; + + // Create a Soroban InvokeHostFunction transaction + let contract_id = ContractId(Hash([1u8; 32])); + let invoke_args = InvokeContractArgs { + contract_address: ScAddress::Contract(contract_id), + function_name: ScSymbol("test_function".try_into().unwrap()), + args: vec![ScVal::Bool(true)].try_into().unwrap(), + }; + + let invoke_op = InvokeHostFunctionOp { + host_function: HostFunction::InvokeContract(invoke_args), + auth: VecM::default(), + }; + + let operation = Operation { + source_account: None, + body: OperationBody::InvokeHostFunction(invoke_op), + }; + + let source_pk = Ed25519PublicKey::from_string( + "GCZ54QGQCUZ6U5WJF4AG5JEZCUMYTS2F6JRLUS76XF2PQMEJ2E3JISI2", + ) + .unwrap(); + + let tx = Transaction { + source_account: MuxedAccount::Ed25519(Uint256(source_pk.0)), + fee: 100, + seq_num: SequenceNumber(1), + cond: Preconditions::None, + memo: Memo::None, + operations: vec![operation].try_into().unwrap(), + ext: TransactionExt::V0, + }; + + let envelope = TransactionEnvelope::Tx(TransactionV1Envelope { + tx, + signatures: VecM::default(), + }); + + let xdr = envelope.to_xdr_base64(Limits::none()).unwrap(); + let result = detect_soroban_invoke_from_xdr(&xdr); + assert!(result.is_ok()); + + let soroban_info = result.unwrap(); + assert!(soroban_info.is_some()); + + let info = soroban_info.unwrap(); + assert_eq!(info.target_fn, "test_function"); + assert_eq!(info.target_args.len(), 1); + // Verify contract address format (C...) + assert!(info.target_contract.starts_with('C')); + } + + #[test] + fn test_detect_soroban_invoke_from_xdr_multiple_operations_error() { + use soroban_rs::xdr::{ + ContractId, Hash, HostFunction, InvokeContractArgs, InvokeHostFunctionOp, Memo, + MuxedAccount, Operation, OperationBody, PaymentOp, Preconditions, ScAddress, ScSymbol, + SequenceNumber, Transaction, TransactionEnvelope, TransactionExt, + TransactionV1Envelope, Uint256, VecM, + }; + + // Create a transaction with InvokeHostFunction AND another operation (invalid for Soroban) + let contract_id = ContractId(Hash([1u8; 32])); + let invoke_args = InvokeContractArgs { + contract_address: ScAddress::Contract(contract_id), + function_name: ScSymbol("test".try_into().unwrap()), + args: VecM::default(), + }; + + let invoke_op = InvokeHostFunctionOp { + host_function: HostFunction::InvokeContract(invoke_args), + auth: VecM::default(), + }; + + let source_pk = Ed25519PublicKey::from_string( + "GCZ54QGQCUZ6U5WJF4AG5JEZCUMYTS2F6JRLUS76XF2PQMEJ2E3JISI2", + ) + .unwrap(); + let dest_pk = Ed25519PublicKey::from_string( + "GCEZWKCA5VLDNRLN3RPRJMRZOX3Z6G5CHCGSNFHEYVXM3XOJMDS674JZ", + ) + .unwrap(); + + let payment_op = PaymentOp { + destination: MuxedAccount::Ed25519(Uint256(dest_pk.0)), + asset: soroban_rs::xdr::Asset::Native, + amount: 1000000, + }; + + let operations: VecM = vec![ + Operation { + source_account: None, + body: OperationBody::InvokeHostFunction(invoke_op), + }, + Operation { + source_account: None, + body: OperationBody::Payment(payment_op), + }, + ] + .try_into() + .unwrap(); + + let tx = Transaction { + source_account: MuxedAccount::Ed25519(Uint256(source_pk.0)), + fee: 100, + seq_num: SequenceNumber(1), + cond: Preconditions::None, + memo: Memo::None, + operations, + ext: TransactionExt::V0, + }; + + let envelope = TransactionEnvelope::Tx(TransactionV1Envelope { + tx, + signatures: VecM::default(), + }); + + let xdr = envelope.to_xdr_base64(Limits::none()).unwrap(); + let result = detect_soroban_invoke_from_xdr(&xdr); + + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(matches!(err, RelayerError::ValidationError(_))); + if let RelayerError::ValidationError(msg) = err { + assert!(msg.contains("exactly one operation")); + } + } + + #[test] + fn test_detect_soroban_invoke_from_xdr_v0_envelope() { + use soroban_rs::xdr::{ + Memo, Operation, OperationBody, PaymentOp, SequenceNumber, TransactionEnvelope, + TransactionV0, TransactionV0Envelope, TransactionV0Ext, Uint256, VecM, + }; + + // Create a V0 envelope (legacy format) + let source_pk = Ed25519PublicKey::from_string( + "GCZ54QGQCUZ6U5WJF4AG5JEZCUMYTS2F6JRLUS76XF2PQMEJ2E3JISI2", + ) + .unwrap(); + let dest_pk = Ed25519PublicKey::from_string( + "GCEZWKCA5VLDNRLN3RPRJMRZOX3Z6G5CHCGSNFHEYVXM3XOJMDS674JZ", + ) + .unwrap(); + + let payment_op = PaymentOp { + destination: MuxedAccount::Ed25519(Uint256(dest_pk.0)), + asset: soroban_rs::xdr::Asset::Native, + amount: 1000000, + }; + + let tx = TransactionV0 { + source_account_ed25519: Uint256(source_pk.0), + fee: 100, + seq_num: SequenceNumber(1), + time_bounds: None, + memo: Memo::None, + operations: vec![Operation { + source_account: None, + body: OperationBody::Payment(payment_op), + }] + .try_into() + .unwrap(), + ext: TransactionV0Ext::V0, + }; + + let envelope = TransactionEnvelope::TxV0(TransactionV0Envelope { + tx, + signatures: VecM::default(), + }); + + let xdr = envelope.to_xdr_base64(Limits::none()).unwrap(); + let result = detect_soroban_invoke_from_xdr(&xdr); + + // V0 envelope with classic operation should return None + assert!(result.is_ok()); + assert!(result.unwrap().is_none()); + } + + #[test] + fn test_detect_soroban_invoke_from_xdr_fee_bump_envelope() { + use soroban_rs::xdr::{ + FeeBumpTransaction, FeeBumpTransactionEnvelope, FeeBumpTransactionExt, + FeeBumpTransactionInnerTx, Memo, MuxedAccount, Operation, OperationBody, PaymentOp, + Preconditions, SequenceNumber, Transaction, TransactionEnvelope, TransactionExt, + TransactionV1Envelope, Uint256, VecM, + }; + + let source_pk = Ed25519PublicKey::from_string( + "GCZ54QGQCUZ6U5WJF4AG5JEZCUMYTS2F6JRLUS76XF2PQMEJ2E3JISI2", + ) + .unwrap(); + let dest_pk = Ed25519PublicKey::from_string( + "GCEZWKCA5VLDNRLN3RPRJMRZOX3Z6G5CHCGSNFHEYVXM3XOJMDS674JZ", + ) + .unwrap(); + + let payment_op = PaymentOp { + destination: MuxedAccount::Ed25519(Uint256(dest_pk.0)), + asset: soroban_rs::xdr::Asset::Native, + amount: 1000000, + }; + + let inner_tx = Transaction { + source_account: MuxedAccount::Ed25519(Uint256(source_pk.0)), + fee: 100, + seq_num: SequenceNumber(1), + cond: Preconditions::None, + memo: Memo::None, + operations: vec![Operation { + source_account: None, + body: OperationBody::Payment(payment_op), + }] + .try_into() + .unwrap(), + ext: TransactionExt::V0, + }; + + let inner_envelope = TransactionV1Envelope { + tx: inner_tx, + signatures: VecM::default(), + }; + + let fee_bump_tx = FeeBumpTransaction { + fee_source: MuxedAccount::Ed25519(Uint256(source_pk.0)), + fee: 200, + inner_tx: FeeBumpTransactionInnerTx::Tx(inner_envelope), + ext: FeeBumpTransactionExt::V0, + }; + + let envelope = TransactionEnvelope::TxFeeBump(FeeBumpTransactionEnvelope { + tx: fee_bump_tx, + signatures: VecM::default(), + }); + + let xdr = envelope.to_xdr_base64(Limits::none()).unwrap(); + let result = detect_soroban_invoke_from_xdr(&xdr); + + // Fee bump with classic operation should return None + assert!(result.is_ok()); + assert!(result.unwrap().is_none()); + } + + #[test] + fn test_detect_soroban_invoke_non_contract_address_error() { + use soroban_rs::xdr::{ + HostFunction, InvokeContractArgs, InvokeHostFunctionOp, Memo, MuxedAccount, Operation, + OperationBody, Preconditions, ScAddress, ScSymbol, SequenceNumber, Transaction, + TransactionEnvelope, TransactionExt, TransactionV1Envelope, Uint256, VecM, + }; + + // Create a Soroban transaction with account address instead of contract address + let source_pk = Ed25519PublicKey::from_string( + "GCZ54QGQCUZ6U5WJF4AG5JEZCUMYTS2F6JRLUS76XF2PQMEJ2E3JISI2", + ) + .unwrap(); + + let invoke_args = InvokeContractArgs { + contract_address: ScAddress::Account(AccountId(PublicKey::PublicKeyTypeEd25519( + Uint256(source_pk.0), + ))), + function_name: ScSymbol("test".try_into().unwrap()), + args: VecM::default(), + }; + + let invoke_op = InvokeHostFunctionOp { + host_function: HostFunction::InvokeContract(invoke_args), + auth: VecM::default(), + }; + + let tx = Transaction { + source_account: MuxedAccount::Ed25519(Uint256(source_pk.0)), + fee: 100, + seq_num: SequenceNumber(1), + cond: Preconditions::None, + memo: Memo::None, + operations: vec![Operation { + source_account: None, + body: OperationBody::InvokeHostFunction(invoke_op), + }] + .try_into() + .unwrap(), + ext: TransactionExt::V0, + }; + + let envelope = TransactionEnvelope::Tx(TransactionV1Envelope { + tx, + signatures: VecM::default(), + }); + + let xdr = envelope.to_xdr_base64(Limits::none()).unwrap(); + let result = detect_soroban_invoke_from_xdr(&xdr); + + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(matches!(err, RelayerError::ValidationError(_))); + if let RelayerError::ValidationError(msg) = err { + assert!(msg.contains("contract address")); + } + } + + // ============================================================================ + // Tests for apply_max_fee_slippage + // ============================================================================ + + #[test] + fn test_apply_max_fee_slippage_basic() { + // 5% slippage on 10000 should give 10500 + let result = apply_max_fee_slippage(10000); + assert_eq!(result, 10500); + } + + #[test] + fn test_apply_max_fee_slippage_zero() { + let result = apply_max_fee_slippage(0); + assert_eq!(result, 0); + } + + #[test] + fn test_apply_max_fee_slippage_large_value() { + // Test with a large value to ensure no overflow + let large_fee: u64 = 1_000_000_000_000; + let result = apply_max_fee_slippage(large_fee); + // 5% of 1 trillion = 50 billion, so result = 1.05 trillion + assert_eq!(result, 1_050_000_000_000i128); + } + + #[test] + fn test_apply_max_fee_slippage_small_value() { + // Small value: 100 * 10500 / 10000 = 105 + let result = apply_max_fee_slippage(100); + assert_eq!(result, 105); + } + + // ============================================================================ + // Tests for calculate_total_soroban_fee + // ============================================================================ + + #[test] + fn test_calculate_total_soroban_fee_success() { + let sim_response = soroban_rs::stellar_rpc_client::SimulateTransactionResponse { + error: None, + transaction_data: "".to_string(), + min_resource_fee: 50000, + ..Default::default() + }; + + let result = calculate_total_soroban_fee(&sim_response, 1); + assert!(result.is_ok()); + // inclusion_fee (100) + resource_fee (50000) = 50100 + let fee = result.unwrap(); + assert_eq!(fee, 50100); + } + + #[test] + fn test_calculate_total_soroban_fee_with_multiple_operations() { + let sim_response = soroban_rs::stellar_rpc_client::SimulateTransactionResponse { + error: None, + transaction_data: "".to_string(), + min_resource_fee: 50000, + ..Default::default() + }; + + let result = calculate_total_soroban_fee(&sim_response, 3); + assert!(result.is_ok()); + // inclusion_fee (100 * 3) + resource_fee (50000) = 50300 + let fee = result.unwrap(); + assert_eq!(fee, 50300); + } + + #[test] + fn test_calculate_total_soroban_fee_simulation_error() { + let sim_response = soroban_rs::stellar_rpc_client::SimulateTransactionResponse { + error: Some("Simulation failed: insufficient funds".to_string()), + transaction_data: "".to_string(), + min_resource_fee: 0, + ..Default::default() + }; + + let result = calculate_total_soroban_fee(&sim_response, 1); + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(matches!(err, RelayerError::ValidationError(_))); + if let RelayerError::ValidationError(msg) = err { + assert!(msg.contains("Simulation failed")); + } + } + + #[test] + fn test_calculate_total_soroban_fee_minimum_fee() { + // When calculated fee is less than minimum, should return minimum + let sim_response = soroban_rs::stellar_rpc_client::SimulateTransactionResponse { + error: None, + transaction_data: "".to_string(), + min_resource_fee: 0, // Very low resource fee + ..Default::default() + }; + + let result = calculate_total_soroban_fee(&sim_response, 1); + assert!(result.is_ok()); + // Should be at least STELLAR_DEFAULT_TRANSACTION_FEE (100) + let fee = result.unwrap(); + assert!(fee >= STELLAR_DEFAULT_TRANSACTION_FEE); + } + + // ============================================================================ + // Tests for build_soroban_transaction_envelope + // ============================================================================ + + #[test] + fn test_build_soroban_transaction_envelope_success() { + use soroban_rs::xdr::{ + ContractId, Hash, HostFunction, InvokeContractArgs, InvokeHostFunctionOp, Operation, + OperationBody, ScAddress, ScSymbol, VecM, + }; + + let contract_id = ContractId(Hash([1u8; 32])); + let invoke_args = InvokeContractArgs { + contract_address: ScAddress::Contract(contract_id), + function_name: ScSymbol("test".try_into().unwrap()), + args: VecM::default(), + }; + + let invoke_op = InvokeHostFunctionOp { + host_function: HostFunction::InvokeContract(invoke_args), + auth: VecM::default(), + }; + + let operation = Operation { + source_account: None, + body: OperationBody::InvokeHostFunction(invoke_op), + }; + + let result = build_soroban_transaction_envelope(TEST_PK, operation.clone(), 100); + assert!(result.is_ok()); + + let envelope = result.unwrap(); + if let TransactionEnvelope::Tx(tx_env) = envelope { + assert_eq!(tx_env.tx.fee, 100); + assert_eq!(tx_env.tx.seq_num.0, 0); // Placeholder sequence + assert_eq!(tx_env.tx.operations.len(), 1); + } else { + panic!("Expected Tx envelope"); + } + } + + #[test] + fn test_build_soroban_transaction_envelope_invalid_source() { + use soroban_rs::xdr::{ + ContractId, Hash, HostFunction, InvokeContractArgs, InvokeHostFunctionOp, Operation, + OperationBody, ScAddress, ScSymbol, VecM, + }; + + let contract_id = ContractId(Hash([1u8; 32])); + let invoke_args = InvokeContractArgs { + contract_address: ScAddress::Contract(contract_id), + function_name: ScSymbol("test".try_into().unwrap()), + args: VecM::default(), + }; + + let invoke_op = InvokeHostFunctionOp { + host_function: HostFunction::InvokeContract(invoke_args), + auth: VecM::default(), + }; + + let operation = Operation { + source_account: None, + body: OperationBody::InvokeHostFunction(invoke_op), + }; + + let result = build_soroban_transaction_envelope("INVALID_ADDRESS", operation, 100); + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + RelayerError::ValidationError(_) + )); + } + + // ============================================================================ + // Tests for get_expiration_ledger + // ============================================================================ + + #[tokio::test] + async fn test_get_expiration_ledger_success() { + let mut provider = MockStellarProviderTrait::new(); + provider.expect_get_latest_ledger().returning(|| { + Box::pin(ready(Ok( + soroban_rs::stellar_rpc_client::GetLatestLedgerResponse { + id: "test".to_string(), + protocol_version: 20, + sequence: 1000, + }, + ))) + }); + + // 300 seconds / 5 seconds per ledger = 60 ledgers + let result = get_expiration_ledger(&provider, 300).await; + assert!(result.is_ok()); + let expiration = result.unwrap(); + assert_eq!(expiration, 1060); // 1000 + 60 + } + + #[tokio::test] + async fn test_get_expiration_ledger_zero_seconds() { + let mut provider = MockStellarProviderTrait::new(); + provider.expect_get_latest_ledger().returning(|| { + Box::pin(ready(Ok( + soroban_rs::stellar_rpc_client::GetLatestLedgerResponse { + id: "test".to_string(), + protocol_version: 20, + sequence: 1000, + }, + ))) + }); + + // 0 seconds should still add at least 1 ledger + let result = get_expiration_ledger(&provider, 0).await; + assert!(result.is_ok()); + let expiration = result.unwrap(); + assert_eq!(expiration, 1001); // 1000 + 1 (minimum) + } + + // ============================================================================ + // Tests for add_payment_operation_to_envelope + // ============================================================================ + + #[test] + fn test_add_payment_operation_to_envelope_classic() { + let envelope = create_test_envelope_for_payment(); + let fee_quote = FeeQuote { + fee_in_token: 1000000, + fee_in_token_ui: "1.0".to_string(), + fee_in_stroops: 10000, + conversion_rate: 100.0, + }; + + let result = add_payment_operation_to_envelope(envelope, &fee_quote, USDC_ASSET, TEST_PK); + assert!(result.is_ok()); + + let updated_envelope = result.unwrap(); + // Classic transaction should have 2 operations now (original + payment) + if let TransactionEnvelope::Tx(tx_env) = updated_envelope { + assert_eq!(tx_env.tx.operations.len(), 2); + } + } + + #[test] + fn test_add_payment_operation_to_envelope_soroban_no_op_added() { + use soroban_rs::xdr::{ + ContractId, Hash, HostFunction, InvokeContractArgs, InvokeHostFunctionOp, Memo, + Operation, OperationBody, Preconditions, ScAddress, ScSymbol, SequenceNumber, + Transaction, TransactionEnvelope, TransactionExt, TransactionV1Envelope, Uint256, VecM, + }; + + // Create a Soroban transaction (InvokeHostFunction) + let source_pk = Ed25519PublicKey::from_string( + "GCZ54QGQCUZ6U5WJF4AG5JEZCUMYTS2F6JRLUS76XF2PQMEJ2E3JISI2", + ) + .unwrap(); + + let contract_id = ContractId(Hash([1u8; 32])); + let invoke_args = InvokeContractArgs { + contract_address: ScAddress::Contract(contract_id), + function_name: ScSymbol("test".try_into().unwrap()), + args: VecM::default(), + }; + + let invoke_op = InvokeHostFunctionOp { + host_function: HostFunction::InvokeContract(invoke_args), + auth: VecM::default(), + }; + + let tx = Transaction { + source_account: MuxedAccount::Ed25519(Uint256(source_pk.0)), + fee: 100, + seq_num: SequenceNumber(1), + cond: Preconditions::None, + memo: Memo::None, + operations: vec![Operation { + source_account: None, + body: OperationBody::InvokeHostFunction(invoke_op), + }] + .try_into() + .unwrap(), + ext: TransactionExt::V0, + }; + + let envelope = TransactionEnvelope::Tx(TransactionV1Envelope { + tx, + signatures: VecM::default(), + }); + + let fee_quote = FeeQuote { + fee_in_token: 1000000, + fee_in_token_ui: "1.0".to_string(), + fee_in_stroops: 10000, + conversion_rate: 100.0, + }; + + let result = add_payment_operation_to_envelope(envelope, &fee_quote, USDC_ASSET, TEST_PK); + assert!(result.is_ok()); + + // Soroban transactions should NOT have payment operation added + let updated_envelope = result.unwrap(); + if let TransactionEnvelope::Tx(tx_env) = updated_envelope { + assert_eq!(tx_env.tx.operations.len(), 1); // Still only 1 operation + } + } + + /// Helper to create a test envelope for payment tests + fn create_test_envelope_for_payment() -> TransactionEnvelope { + let source_pk = Ed25519PublicKey::from_string( + "GCZ54QGQCUZ6U5WJF4AG5JEZCUMYTS2F6JRLUS76XF2PQMEJ2E3JISI2", + ) + .unwrap(); + let dest_pk = Ed25519PublicKey::from_string( + "GCEZWKCA5VLDNRLN3RPRJMRZOX3Z6G5CHCGSNFHEYVXM3XOJMDS674JZ", + ) + .unwrap(); + + let payment_op = PaymentOp { + destination: MuxedAccount::Ed25519(Uint256(dest_pk.0)), + asset: soroban_rs::xdr::Asset::Native, + amount: 1000000, + }; + + let tx = Transaction { + source_account: MuxedAccount::Ed25519(Uint256(source_pk.0)), + fee: 100, + seq_num: SequenceNumber(1), + cond: Preconditions::None, + memo: soroban_rs::xdr::Memo::None, + operations: vec![Operation { + source_account: None, + body: OperationBody::Payment(payment_op), + }] + .try_into() + .unwrap(), + ext: TransactionExt::V0, + }; + + TransactionEnvelope::Tx(TransactionV1Envelope { + tx, + signatures: VecM::default(), + }) + } + + // ============================================================================ + // Tests for add_fee_payment_operation + // ============================================================================ + + #[test] + fn test_add_fee_payment_operation_success() { + let mut envelope = create_test_envelope_for_payment(); + let result = add_fee_payment_operation(&mut envelope, USDC_ASSET, 1000000, TEST_PK); + assert!(result.is_ok()); + + // Verify operation was added + if let TransactionEnvelope::Tx(tx_env) = envelope { + assert_eq!(tx_env.tx.operations.len(), 2); + } + } + + #[test] + fn test_add_fee_payment_operation_native_asset() { + let mut envelope = create_test_envelope_for_payment(); + let result = add_fee_payment_operation(&mut envelope, "native", 1000000, TEST_PK); + assert!(result.is_ok()); + } + + // ============================================================================ + // Tests for SorobanInvokeInfo + // ============================================================================ + + #[test] + fn test_soroban_invoke_info_debug_clone() { + use soroban_rs::xdr::ScVal; + + let info = SorobanInvokeInfo { + target_contract: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC".to_string(), + target_fn: "transfer".to_string(), + target_args: vec![ScVal::Bool(true)], + }; + + // Test Debug trait + let debug_str = format!("{:?}", info); + assert!(debug_str.contains("SorobanInvokeInfo")); + assert!(debug_str.contains("transfer")); + + // Test Clone trait + let cloned = info.clone(); + assert_eq!(cloned.target_contract, info.target_contract); + assert_eq!(cloned.target_fn, info.target_fn); + assert_eq!(cloned.target_args.len(), info.target_args.len()); + } + + // ============================================================================ + // Tests for fee payment strategy validation + // ============================================================================ + + #[tokio::test] + async fn test_build_sponsored_transaction_non_user_fee_strategy() { + // Create relayer with Relayer fee payment strategy (not User) + let mut policy = RelayerStellarPolicy::default(); + policy.fee_payment_strategy = Some(crate::models::StellarFeePaymentStrategy::Relayer); + policy.allowed_tokens = Some(vec![crate::models::StellarAllowedTokensPolicy { + asset: USDC_ASSET.to_string(), + metadata: None, + max_allowed_fee: None, + swap_config: None, + }]); + + let relayer_model = RelayerRepoModel { + id: "test-relayer-id".to_string(), + name: "Test Relayer".to_string(), + network: "testnet".to_string(), + paused: false, + network_type: NetworkType::Stellar, + signer_id: "signer-id".to_string(), + policies: RelayerNetworkPolicy::Stellar(policy), + address: TEST_PK.to_string(), + notification_id: Some("notification-id".to_string()), + system_disabled: false, + custom_rpc_urls: None, + ..Default::default() + }; + + let provider = MockStellarProviderTrait::new(); + let dex_service = create_mock_dex_service(); + let relayer = create_test_relayer_instance(relayer_model, provider, dex_service).await; + + let transaction_xdr = create_test_transaction_xdr(); + let request = SponsoredTransactionBuildRequest::Stellar( + crate::models::StellarPrepareTransactionRequestParams { + transaction_xdr: Some(transaction_xdr), + operations: None, + source_account: None, + fee_token: USDC_ASSET.to_string(), + }, + ); + + let result = relayer.build_sponsored_transaction(request).await; + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(matches!(err, RelayerError::ValidationError(_))); + if let RelayerError::ValidationError(msg) = err { + assert!(msg.contains("fee_payment_strategy: User")); + } + } + + // ============================================================================ + // Tests for quote_soroban_from_xdr (via quote_sponsored_transaction) + // ============================================================================ + + /// Helper function to create a valid SorobanTransactionData XDR for mocking simulation responses + fn create_valid_soroban_transaction_data_xdr() -> String { + use soroban_rs::xdr::{ + LedgerFootprint, SorobanResources, SorobanTransactionData, SorobanTransactionDataExt, + }; + + let soroban_data = SorobanTransactionData { + ext: SorobanTransactionDataExt::V0, + resources: SorobanResources { + footprint: LedgerFootprint { + read_only: VecM::default(), + read_write: VecM::default(), + }, + instructions: 1000000, + disk_read_bytes: 10000, + write_bytes: 1000, + }, + resource_fee: 50000, + }; + + soroban_data.to_xdr_base64(Limits::none()).unwrap() + } + + /// Helper function to create a Soroban InvokeHostFunction transaction XDR + fn create_test_soroban_transaction_xdr() -> String { + use soroban_rs::xdr::{ + ContractId, Hash, HostFunction, InvokeContractArgs, InvokeHostFunctionOp, Memo, + ScAddress, ScSymbol, ScVal, + }; + + let source_pk = Ed25519PublicKey::from_string( + "GCZ54QGQCUZ6U5WJF4AG5JEZCUMYTS2F6JRLUS76XF2PQMEJ2E3JISI2", + ) + .unwrap(); + + // Create a Soroban contract call operation + let contract_id = ContractId(Hash([1u8; 32])); + let invoke_args = InvokeContractArgs { + contract_address: ScAddress::Contract(contract_id), + function_name: ScSymbol("transfer".try_into().unwrap()), + args: vec![ScVal::Bool(true)].try_into().unwrap(), + }; + + let invoke_op = InvokeHostFunctionOp { + host_function: HostFunction::InvokeContract(invoke_args), + auth: VecM::default(), + }; + + let operation = Operation { + source_account: None, + body: OperationBody::InvokeHostFunction(invoke_op), + }; + + let tx = Transaction { + source_account: MuxedAccount::Ed25519(Uint256(source_pk.0)), + fee: 100, + seq_num: SequenceNumber(1), + cond: Preconditions::None, + memo: Memo::None, + operations: vec![operation].try_into().unwrap(), + ext: TransactionExt::V0, + }; + + let envelope = TransactionEnvelope::Tx(TransactionV1Envelope { + tx, + signatures: VecM::default(), + }); + + envelope.to_xdr_base64(Limits::none()).unwrap() + } + + /// Helper function to create a relayer with Soroban token support + fn create_test_relayer_with_soroban_token() -> RelayerRepoModel { + let mut policy = RelayerStellarPolicy::default(); + policy.fee_payment_strategy = Some(crate::models::StellarFeePaymentStrategy::User); + // Use a Soroban contract address (C...) as the allowed token + policy.allowed_tokens = Some(vec![crate::models::StellarAllowedTokensPolicy { + asset: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC".to_string(), + metadata: None, + max_allowed_fee: None, + swap_config: None, + }]); + + RelayerRepoModel { + id: "test-relayer-id".to_string(), + name: "Test Relayer".to_string(), + network: "testnet".to_string(), + paused: false, + network_type: NetworkType::Stellar, + signer_id: "signer-id".to_string(), + policies: RelayerNetworkPolicy::Stellar(policy), + address: TEST_PK.to_string(), + notification_id: Some("notification-id".to_string()), + system_disabled: false, + custom_rpc_urls: None, + ..Default::default() + } + } + + #[tokio::test] + #[serial] + async fn test_quote_soroban_from_xdr_success() { + // Set required env var for FeeForwarder + std::env::set_var( + "STELLAR_FEE_FORWARDER_ADDRESS", + "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC", + ); + + let relayer_model = create_test_relayer_with_soroban_token(); + let mut provider = MockStellarProviderTrait::new(); + + // Mock get_latest_ledger for expiration calculation + provider.expect_get_latest_ledger().returning(|| { + Box::pin(ready(Ok( + soroban_rs::stellar_rpc_client::GetLatestLedgerResponse { + id: "test".to_string(), + protocol_version: 20, + sequence: 1000, + }, + ))) + }); + + // Mock simulate_transaction_envelope for Soroban fee estimation + provider + .expect_simulate_transaction_envelope() + .returning(|_| { + Box::pin(ready(Ok( + soroban_rs::stellar_rpc_client::SimulateTransactionResponse { + min_resource_fee: 50000, + transaction_data: "AAAAAQAAAAAAAAACAAAAAAAAAAAAAAAAAAAABgAAAAEAAAAGAAAAAG0JZTO9fU6p3NeJp5w3TpKhZmx6p1pR7mq9wFwCnEIuAAAAFAAAAAEAAAAAAAAAB8NVb2IAAAH0AAAAAQAAAAAAABfAAAAAAAAAAPUAAAAAAAAENgAAAAA=".to_string(), + ..Default::default() + }, + ))) + }); + + // Mock call_contract for Soroban token balance check (balance function) + provider.expect_call_contract().returning(|_, _, _| { + use soroban_rs::xdr::Int128Parts; + // Return a balance of 10_000_000 (10 tokens with 6 decimals) + Box::pin(ready(Ok(ScVal::I128(Int128Parts { + hi: 0, + lo: 10_000_000, + })))) + }); + + let mut dex_service = MockStellarDexServiceTrait::new(); + dex_service.expect_supported_asset_types().returning(|| { + std::collections::HashSet::from([AssetType::Native, AssetType::Contract]) + }); + + // Mock get_xlm_to_token_quote for fee conversion + dex_service + .expect_get_xlm_to_token_quote() + .returning(|_, _, _, _| { + Box::pin(ready(Ok( + crate::services::stellar_dex::StellarQuoteResponse { + input_asset: "native".to_string(), + output_asset: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC" + .to_string(), + in_amount: 50100, // fee in stroops + out_amount: 1500000, // fee in token + price_impact_pct: 0.0, + slippage_bps: 100, + path: None, + }, + ))) + }); + + let dex_service = Arc::new(dex_service); + let relayer = create_test_relayer_instance(relayer_model, provider, dex_service).await; + + let transaction_xdr = create_test_soroban_transaction_xdr(); + let request = SponsoredTransactionQuoteRequest::Stellar( + crate::models::StellarFeeEstimateRequestParams { + transaction_xdr: Some(transaction_xdr), + operations: None, + source_account: None, + fee_token: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC".to_string(), + }, + ); + + let result = relayer.quote_sponsored_transaction(request).await; + if let Err(e) = &result { + eprintln!("Soroban quote error: {:?}", e); + } + assert!(result.is_ok()); + + if let SponsoredTransactionQuoteResponse::Stellar(quote) = result.unwrap() { + assert_eq!(quote.fee_in_token, "1500000"); + assert!(!quote.fee_in_token_ui.is_empty()); + assert!(!quote.conversion_rate.is_empty()); + } else { + panic!("Expected Stellar quote response"); + } + + // Clean up env var + std::env::remove_var("STELLAR_FEE_FORWARDER_ADDRESS"); + } + + #[tokio::test] + #[serial] + async fn test_quote_soroban_from_xdr_missing_fee_forwarder() { + // Ensure env var is NOT set + std::env::remove_var("STELLAR_FEE_FORWARDER_ADDRESS"); + + let relayer_model = create_test_relayer_with_soroban_token(); + let provider = MockStellarProviderTrait::new(); + + let mut dex_service = MockStellarDexServiceTrait::new(); + dex_service.expect_supported_asset_types().returning(|| { + std::collections::HashSet::from([AssetType::Native, AssetType::Contract]) + }); + + let dex_service = Arc::new(dex_service); + let relayer = create_test_relayer_instance(relayer_model, provider, dex_service).await; + + let transaction_xdr = create_test_soroban_transaction_xdr(); + let request = SponsoredTransactionQuoteRequest::Stellar( + crate::models::StellarFeeEstimateRequestParams { + transaction_xdr: Some(transaction_xdr), + operations: None, + source_account: None, + fee_token: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC".to_string(), + }, + ); + + let result = relayer.quote_sponsored_transaction(request).await; + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(matches!(err, RelayerError::ValidationError(_))); + if let RelayerError::ValidationError(msg) = err { + assert!(msg.contains("STELLAR_FEE_FORWARDER_ADDRESS")); + } + } + + #[tokio::test] + #[serial] + async fn test_quote_soroban_from_xdr_invalid_fee_token_format() { + // Set required env var for FeeForwarder + std::env::set_var( + "STELLAR_FEE_FORWARDER_ADDRESS", + "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC", + ); + + // Create relayer that allows both classic and Soroban tokens + let mut policy = RelayerStellarPolicy::default(); + policy.fee_payment_strategy = Some(crate::models::StellarFeePaymentStrategy::User); + policy.allowed_tokens = Some(vec![ + crate::models::StellarAllowedTokensPolicy { + asset: USDC_ASSET.to_string(), // Classic asset + metadata: None, + max_allowed_fee: None, + swap_config: None, + }, + crate::models::StellarAllowedTokensPolicy { + asset: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC".to_string(), + metadata: None, + max_allowed_fee: None, + swap_config: None, + }, + ]); + + let relayer_model = RelayerRepoModel { + id: "test-relayer-id".to_string(), + name: "Test Relayer".to_string(), + network: "testnet".to_string(), + paused: false, + network_type: NetworkType::Stellar, + signer_id: "signer-id".to_string(), + policies: RelayerNetworkPolicy::Stellar(policy), + address: TEST_PK.to_string(), + notification_id: Some("notification-id".to_string()), + system_disabled: false, + custom_rpc_urls: None, + ..Default::default() + }; + + let provider = MockStellarProviderTrait::new(); + + let mut dex_service = MockStellarDexServiceTrait::new(); + dex_service + .expect_supported_asset_types() + .returning(|| std::collections::HashSet::from([AssetType::Native, AssetType::Classic])); + + let dex_service = Arc::new(dex_service); + let relayer = create_test_relayer_instance(relayer_model, provider, dex_service).await; + + // Use Soroban XDR but with classic asset as fee_token (invalid for Soroban path) + let transaction_xdr = create_test_soroban_transaction_xdr(); + let request = SponsoredTransactionQuoteRequest::Stellar( + crate::models::StellarFeeEstimateRequestParams { + transaction_xdr: Some(transaction_xdr), + operations: None, + source_account: None, + fee_token: USDC_ASSET.to_string(), // Classic asset, not valid C... format + }, + ); + + let result = relayer.quote_sponsored_transaction(request).await; + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(matches!(err, RelayerError::ValidationError(_))); + if let RelayerError::ValidationError(msg) = err { + assert!(msg.contains("Soroban contract address")); + } + + // Clean up env var + std::env::remove_var("STELLAR_FEE_FORWARDER_ADDRESS"); + } + + // ============================================================================ + // Tests for build_soroban_sponsored (via build_sponsored_transaction) + // ============================================================================ + + #[tokio::test] + #[serial] + async fn test_build_soroban_sponsored_success() { + // Set required env var for FeeForwarder + std::env::set_var( + "STELLAR_FEE_FORWARDER_ADDRESS", + "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC", + ); + + let relayer_model = create_test_relayer_with_soroban_token(); + let mut provider = MockStellarProviderTrait::new(); + + // Mock get_latest_ledger for expiration calculation (called twice - for simulation and for valid_until) + provider.expect_get_latest_ledger().returning(|| { + Box::pin(ready(Ok( + soroban_rs::stellar_rpc_client::GetLatestLedgerResponse { + id: "test".to_string(), + protocol_version: 20, + sequence: 1000, + }, + ))) + }); + + // Mock simulate_transaction_envelope for Soroban fee estimation + let valid_tx_data = create_valid_soroban_transaction_data_xdr(); + provider + .expect_simulate_transaction_envelope() + .returning(move |_| { + let tx_data = valid_tx_data.clone(); + Box::pin(ready(Ok( + soroban_rs::stellar_rpc_client::SimulateTransactionResponse { + min_resource_fee: 50000, + transaction_data: tx_data, + ..Default::default() + }, + ))) + }); + + // Mock call_contract for Soroban token balance check + provider.expect_call_contract().returning(|_, _, _| { + use soroban_rs::xdr::Int128Parts; + // Return a balance of 10_000_000 (sufficient for fee) + Box::pin(ready(Ok(ScVal::I128(Int128Parts { + hi: 0, + lo: 10_000_000, + })))) + }); + + let mut dex_service = MockStellarDexServiceTrait::new(); + dex_service.expect_supported_asset_types().returning(|| { + std::collections::HashSet::from([AssetType::Native, AssetType::Contract]) + }); + + // Mock get_xlm_to_token_quote for fee conversion + dex_service + .expect_get_xlm_to_token_quote() + .returning(|_, _, _, _| { + Box::pin(ready(Ok( + crate::services::stellar_dex::StellarQuoteResponse { + input_asset: "native".to_string(), + output_asset: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC" + .to_string(), + in_amount: 50100, + out_amount: 1500000, + price_impact_pct: 0.0, + slippage_bps: 100, + path: None, + }, + ))) + }); + + let dex_service = Arc::new(dex_service); + let relayer = create_test_relayer_instance(relayer_model, provider, dex_service).await; + + let transaction_xdr = create_test_soroban_transaction_xdr(); + let request = SponsoredTransactionBuildRequest::Stellar( + crate::models::StellarPrepareTransactionRequestParams { + transaction_xdr: Some(transaction_xdr), + operations: None, + source_account: None, + fee_token: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC".to_string(), + }, + ); + + let result = relayer.build_sponsored_transaction(request).await; + if let Err(e) = &result { + eprintln!("Soroban build error: {:?}", e); + } + assert!(result.is_ok()); + + if let SponsoredTransactionBuildResponse::Stellar(build) = result.unwrap() { + assert!(!build.transaction.is_empty()); + assert_eq!(build.fee_in_token, "1500000"); + assert!(!build.fee_in_token_ui.is_empty()); + assert_eq!( + build.fee_token, + "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC" + ); + assert!(!build.valid_until.is_empty()); + // Soroban transactions should have user_auth_entry + assert!(build.user_auth_entry.is_some()); + assert!(!build.user_auth_entry.unwrap().is_empty()); + } else { + panic!("Expected Stellar build response"); + } + + // Clean up env var + std::env::remove_var("STELLAR_FEE_FORWARDER_ADDRESS"); + } + + #[tokio::test] + #[serial] + async fn test_build_soroban_sponsored_missing_fee_forwarder() { + // Ensure env var is NOT set + std::env::remove_var("STELLAR_FEE_FORWARDER_ADDRESS"); + + let relayer_model = create_test_relayer_with_soroban_token(); + let provider = MockStellarProviderTrait::new(); + + let mut dex_service = MockStellarDexServiceTrait::new(); + dex_service.expect_supported_asset_types().returning(|| { + std::collections::HashSet::from([AssetType::Native, AssetType::Contract]) + }); + + let dex_service = Arc::new(dex_service); + let relayer = create_test_relayer_instance(relayer_model, provider, dex_service).await; + + let transaction_xdr = create_test_soroban_transaction_xdr(); + let request = SponsoredTransactionBuildRequest::Stellar( + crate::models::StellarPrepareTransactionRequestParams { + transaction_xdr: Some(transaction_xdr), + operations: None, + source_account: None, + fee_token: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC".to_string(), + }, + ); + + let result = relayer.build_sponsored_transaction(request).await; + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(matches!(err, RelayerError::ValidationError(_))); + if let RelayerError::ValidationError(msg) = err { + assert!(msg.contains("STELLAR_FEE_FORWARDER_ADDRESS")); + } + } + + #[tokio::test] + #[serial] + async fn test_build_soroban_sponsored_insufficient_balance() { + // Set required env var for FeeForwarder + std::env::set_var( + "STELLAR_FEE_FORWARDER_ADDRESS", + "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC", + ); + + let relayer_model = create_test_relayer_with_soroban_token(); + let mut provider = MockStellarProviderTrait::new(); + + // Mock get_latest_ledger + provider.expect_get_latest_ledger().returning(|| { + Box::pin(ready(Ok( + soroban_rs::stellar_rpc_client::GetLatestLedgerResponse { + id: "test".to_string(), + protocol_version: 20, + sequence: 1000, + }, + ))) + }); + + // Mock simulate_transaction_envelope + provider + .expect_simulate_transaction_envelope() + .returning(|_| { + Box::pin(ready(Ok( + soroban_rs::stellar_rpc_client::SimulateTransactionResponse { + min_resource_fee: 50000, + transaction_data: "AAAAAQAAAAAAAAACAAAAAAAAAAAAAAAAAAAABgAAAAEAAAAGAAAAAG0JZTO9fU6p3NeJp5w3TpKhZmx6p1pR7mq9wFwCnEIuAAAAFAAAAAEAAAAAAAAAB8NVb2IAAAH0AAAAAQAAAAAAABfAAAAAAAAAAPUAAAAAAAAENgAAAAA=".to_string(), + ..Default::default() + }, + ))) + }); + + // Mock call_contract with INSUFFICIENT balance + provider.expect_call_contract().returning(|_, _, _| { + use soroban_rs::xdr::Int128Parts; + // Return a very low balance (100, much less than required 1500000) + Box::pin(ready(Ok(ScVal::I128(Int128Parts { hi: 0, lo: 100 })))) + }); + + let mut dex_service = MockStellarDexServiceTrait::new(); + dex_service.expect_supported_asset_types().returning(|| { + std::collections::HashSet::from([AssetType::Native, AssetType::Contract]) + }); + + // Mock get_xlm_to_token_quote + dex_service + .expect_get_xlm_to_token_quote() + .returning(|_, _, _, _| { + Box::pin(ready(Ok( + crate::services::stellar_dex::StellarQuoteResponse { + input_asset: "native".to_string(), + output_asset: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC" + .to_string(), + in_amount: 50100, + out_amount: 1500000, // Fee required + price_impact_pct: 0.0, + slippage_bps: 100, + path: None, + }, + ))) + }); + + let dex_service = Arc::new(dex_service); + let relayer = create_test_relayer_instance(relayer_model, provider, dex_service).await; + + let transaction_xdr = create_test_soroban_transaction_xdr(); + let request = SponsoredTransactionBuildRequest::Stellar( + crate::models::StellarPrepareTransactionRequestParams { + transaction_xdr: Some(transaction_xdr), + operations: None, + source_account: None, + fee_token: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC".to_string(), + }, + ); + + let result = relayer.build_sponsored_transaction(request).await; + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(matches!(err, RelayerError::ValidationError(_))); + if let RelayerError::ValidationError(msg) = err { + assert!(msg.contains("Insufficient balance")); + } + + // Clean up env var + std::env::remove_var("STELLAR_FEE_FORWARDER_ADDRESS"); + } + + #[tokio::test] + #[serial] + async fn test_build_soroban_sponsored_simulation_error() { + // Set required env var for FeeForwarder + std::env::set_var( + "STELLAR_FEE_FORWARDER_ADDRESS", + "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC", + ); + + let relayer_model = create_test_relayer_with_soroban_token(); + let mut provider = MockStellarProviderTrait::new(); + + // Mock get_latest_ledger + provider.expect_get_latest_ledger().returning(|| { + Box::pin(ready(Ok( + soroban_rs::stellar_rpc_client::GetLatestLedgerResponse { + id: "test".to_string(), + protocol_version: 20, + sequence: 1000, + }, + ))) + }); + + // Mock simulate_transaction_envelope to return error + provider + .expect_simulate_transaction_envelope() + .returning(|_| { + Box::pin(ready(Ok( + soroban_rs::stellar_rpc_client::SimulateTransactionResponse { + error: Some( + "Contract execution failed: insufficient resources".to_string(), + ), + min_resource_fee: 0, + transaction_data: "".to_string(), + ..Default::default() + }, + ))) + }); + + let mut dex_service = MockStellarDexServiceTrait::new(); + dex_service.expect_supported_asset_types().returning(|| { + std::collections::HashSet::from([AssetType::Native, AssetType::Contract]) + }); + + // Mock get_xlm_to_token_quote for initial fee estimation + dex_service + .expect_get_xlm_to_token_quote() + .returning(|_, _, _, _| { + Box::pin(ready(Ok( + crate::services::stellar_dex::StellarQuoteResponse { + input_asset: "native".to_string(), + output_asset: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC" + .to_string(), + in_amount: 100, + out_amount: 1500, + price_impact_pct: 0.0, + slippage_bps: 100, + path: None, + }, + ))) + }); + + let dex_service = Arc::new(dex_service); + let relayer = create_test_relayer_instance(relayer_model, provider, dex_service).await; + + let transaction_xdr = create_test_soroban_transaction_xdr(); + let request = SponsoredTransactionBuildRequest::Stellar( + crate::models::StellarPrepareTransactionRequestParams { + transaction_xdr: Some(transaction_xdr), + operations: None, + source_account: None, + fee_token: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC".to_string(), + }, + ); + + let result = relayer.build_sponsored_transaction(request).await; + assert!(result.is_err()); + let err = result.unwrap_err(); + // Simulation errors are wrapped in ValidationError via calculate_total_soroban_fee + assert!(matches!(err, RelayerError::ValidationError(_))); + if let RelayerError::ValidationError(msg) = err { + assert!(msg.contains("Simulation failed")); + } + + // Clean up env var + std::env::remove_var("STELLAR_FEE_FORWARDER_ADDRESS"); + } } diff --git a/src/domain/transaction/stellar/prepare/soroban_gas_abstraction.rs b/src/domain/transaction/stellar/prepare/soroban_gas_abstraction.rs index 60eb674e6..ddd997e11 100644 --- a/src/domain/transaction/stellar/prepare/soroban_gas_abstraction.rs +++ b/src/domain/transaction/stellar/prepare/soroban_gas_abstraction.rs @@ -425,23 +425,395 @@ fn build_simulation_envelope( #[cfg(test)] mod tests { use super::*; - use soroban_rs::xdr::VecM; + use soroban_rs::xdr::{ + ContractId, FeeBumpTransaction, FeeBumpTransactionEnvelope, FeeBumpTransactionExt, + FeeBumpTransactionInnerTx, Hash, HostFunction, InvokeContractArgs, InvokeHostFunctionOp, + Memo, MuxedAccount, Preconditions, ScAddress, ScSymbol, ScVal, SequenceNumber, + SorobanAddressCredentials, SorobanAuthorizedFunction, SorobanAuthorizedInvocation, + Transaction, TransactionExt, TransactionV0, TransactionV0Envelope, TransactionV0Ext, + TransactionV1Envelope, Uint256, VecM, + }; + + // Helper to create a minimal V1 transaction envelope + fn create_minimal_v1_envelope() -> TransactionEnvelope { + let tx = Transaction { + source_account: MuxedAccount::Ed25519(Uint256([0u8; 32])), + fee: 100, + seq_num: SequenceNumber(0), + cond: Preconditions::None, + memo: Memo::None, + operations: VecM::default(), + ext: TransactionExt::V0, + }; + + TransactionEnvelope::Tx(TransactionV1Envelope { + tx, + signatures: VecM::default(), + }) + } + + // Helper to create a V0 transaction envelope + fn create_v0_envelope() -> TransactionEnvelope { + let tx = TransactionV0 { + source_account_ed25519: Uint256([0u8; 32]), + fee: 100, + seq_num: SequenceNumber(0), + time_bounds: None, + memo: Memo::None, + operations: VecM::default(), + ext: TransactionV0Ext::V0, + }; + + TransactionEnvelope::TxV0(TransactionV0Envelope { + tx, + signatures: VecM::default(), + }) + } + + // Helper to create a fee bump transaction envelope + fn create_fee_bump_envelope() -> TransactionEnvelope { + let inner_tx = Transaction { + source_account: MuxedAccount::Ed25519(Uint256([0u8; 32])), + fee: 100, + seq_num: SequenceNumber(0), + cond: Preconditions::None, + memo: Memo::None, + operations: VecM::default(), + ext: TransactionExt::V0, + }; + + let inner_envelope = TransactionV1Envelope { + tx: inner_tx, + signatures: VecM::default(), + }; + + let fee_bump_tx = FeeBumpTransaction { + fee_source: MuxedAccount::Ed25519(Uint256([1u8; 32])), + fee: 200, + inner_tx: FeeBumpTransactionInnerTx::Tx(inner_envelope), + ext: FeeBumpTransactionExt::V0, + }; + + TransactionEnvelope::TxFeeBump(FeeBumpTransactionEnvelope { + tx: fee_bump_tx, + signatures: VecM::default(), + }) + } + + // Helper to create a V1 envelope with an InvokeHostFunction operation + fn create_invoke_host_function_envelope( + auth_entries: Vec, + ) -> TransactionEnvelope { + let invoke_op = InvokeHostFunctionOp { + host_function: HostFunction::InvokeContract(InvokeContractArgs { + contract_address: ScAddress::Contract(ContractId(Hash([0u8; 32]))), + function_name: ScSymbol("test".try_into().unwrap()), + args: VecM::default(), + }), + auth: auth_entries.try_into().unwrap_or_default(), + }; + + let operation = Operation { + source_account: None, + body: OperationBody::InvokeHostFunction(invoke_op), + }; + + let tx = Transaction { + source_account: MuxedAccount::Ed25519(Uint256([0u8; 32])), + fee: 100, + seq_num: SequenceNumber(0), + cond: Preconditions::None, + memo: Memo::None, + operations: vec![operation].try_into().unwrap(), + ext: TransactionExt::V0, + }; + + TransactionEnvelope::Tx(TransactionV1Envelope { + tx, + signatures: VecM::default(), + }) + } + + // Helper to create a mock SorobanAuthorizationEntry with Address credentials + fn create_address_auth_entry() -> SorobanAuthorizationEntry { + SorobanAuthorizationEntry { + credentials: SorobanCredentials::Address(SorobanAddressCredentials { + address: ScAddress::Contract(ContractId(Hash([0u8; 32]))), + nonce: 0, + signature_expiration_ledger: 100, + signature: ScVal::Void, + }), + root_invocation: SorobanAuthorizedInvocation { + function: SorobanAuthorizedFunction::ContractFn(InvokeContractArgs { + contract_address: ScAddress::Contract(ContractId(Hash([0u8; 32]))), + function_name: ScSymbol("test".try_into().unwrap()), + args: VecM::default(), + }), + sub_invocations: VecM::default(), + }, + } + } + + // Helper to create a mock SorobanAuthorizationEntry with SourceAccount credentials + fn create_source_account_auth_entry() -> SorobanAuthorizationEntry { + SorobanAuthorizationEntry { + credentials: SorobanCredentials::SourceAccount, + root_invocation: SorobanAuthorizedInvocation { + function: SorobanAuthorizedFunction::ContractFn(InvokeContractArgs { + contract_address: ScAddress::Contract(ContractId(Hash([1u8; 32]))), + function_name: ScSymbol("relayer_fn".try_into().unwrap()), + args: VecM::default(), + }), + sub_invocations: VecM::default(), + }, + } + } + + // ==================== update_envelope_sequence tests ==================== #[test] fn test_update_envelope_sequence() { - use soroban_rs::xdr::{ - Memo, MuxedAccount, Preconditions, SequenceNumber, Transaction, TransactionExt, - TransactionV1Envelope, Uint256, + let mut envelope = create_minimal_v1_envelope(); + update_envelope_sequence(&mut envelope, 12345).unwrap(); + + if let TransactionEnvelope::Tx(v1) = &envelope { + assert_eq!(v1.tx.seq_num.0, 12345); + } else { + panic!("Expected Tx envelope"); + } + } + + #[test] + fn test_update_envelope_sequence_v0_returns_error() { + let mut envelope = create_v0_envelope(); + let result = update_envelope_sequence(&mut envelope, 12345); + + assert!(result.is_err()); + match result.unwrap_err() { + TransactionError::ValidationError(msg) => { + assert!(msg.contains("V0 transactions are not supported")); + } + _ => panic!("Expected ValidationError"), + } + } + + #[test] + fn test_update_envelope_sequence_fee_bump_returns_error() { + let mut envelope = create_fee_bump_envelope(); + let result = update_envelope_sequence(&mut envelope, 12345); + + assert!(result.is_err()); + match result.unwrap_err() { + TransactionError::ValidationError(msg) => { + assert!(msg.contains("Cannot update sequence number on fee bump transaction")); + } + _ => panic!("Expected ValidationError"), + } + } + + #[test] + fn test_update_envelope_sequence_zero() { + let mut envelope = create_minimal_v1_envelope(); + update_envelope_sequence(&mut envelope, 0).unwrap(); + + if let TransactionEnvelope::Tx(v1) = &envelope { + assert_eq!(v1.tx.seq_num.0, 0); + } else { + panic!("Expected Tx envelope"); + } + } + + #[test] + fn test_update_envelope_sequence_max_value() { + let mut envelope = create_minimal_v1_envelope(); + update_envelope_sequence(&mut envelope, i64::MAX).unwrap(); + + if let TransactionEnvelope::Tx(v1) = &envelope { + assert_eq!(v1.tx.seq_num.0, i64::MAX); + } else { + panic!("Expected Tx envelope"); + } + } + + // ==================== apply_resource_buffer tests ==================== + + #[test] + fn test_apply_resource_buffer_standard_values() { + let mut resources = SorobanResources { + footprint: soroban_rs::xdr::LedgerFootprint { + read_only: VecM::default(), + read_write: VecM::default(), + }, + instructions: 1000, + disk_read_bytes: 2000, + write_bytes: 500, + }; + + apply_resource_buffer(&mut resources); + + // 15% buffer: value * 115 / 100 + assert_eq!(resources.instructions, 1150); + assert_eq!(resources.disk_read_bytes, 2300); + assert_eq!(resources.write_bytes, 575); + } + + #[test] + fn test_apply_resource_buffer_zero_values() { + let mut resources = SorobanResources { + footprint: soroban_rs::xdr::LedgerFootprint { + read_only: VecM::default(), + read_write: VecM::default(), + }, + instructions: 0, + disk_read_bytes: 0, + write_bytes: 0, + }; + + apply_resource_buffer(&mut resources); + + assert_eq!(resources.instructions, 0); + assert_eq!(resources.disk_read_bytes, 0); + assert_eq!(resources.write_bytes, 0); + } + + #[test] + fn test_apply_resource_buffer_large_values_no_overflow() { + let large_value = u32::MAX - 1000; + let mut resources = SorobanResources { + footprint: soroban_rs::xdr::LedgerFootprint { + read_only: VecM::default(), + read_write: VecM::default(), + }, + instructions: large_value, + disk_read_bytes: large_value, + write_bytes: large_value, + }; + + apply_resource_buffer(&mut resources); + + // Should saturate at u32::MAX, not overflow + assert!(resources.instructions <= u32::MAX); + assert!(resources.disk_read_bytes <= u32::MAX); + assert!(resources.write_bytes <= u32::MAX); + } + + #[test] + fn test_apply_resource_buffer_max_value_saturates() { + let mut resources = SorobanResources { + footprint: soroban_rs::xdr::LedgerFootprint { + read_only: VecM::default(), + read_write: VecM::default(), + }, + instructions: u32::MAX, + disk_read_bytes: u32::MAX, + write_bytes: u32::MAX, + }; + + apply_resource_buffer(&mut resources); + + // Should saturate at u32::MAX + assert_eq!(resources.instructions, u32::MAX); + assert_eq!(resources.disk_read_bytes, u32::MAX); + assert_eq!(resources.write_bytes, u32::MAX); + } + + #[test] + fn test_apply_resource_buffer_preserves_footprint() { + use soroban_rs::xdr::{LedgerFootprint, LedgerKey, LedgerKeyAccount}; + + let account_key = LedgerKey::Account(LedgerKeyAccount { + account_id: soroban_rs::xdr::AccountId( + soroban_rs::xdr::PublicKey::PublicKeyTypeEd25519(Uint256([0u8; 32])), + ), + }); + + let mut resources = SorobanResources { + footprint: LedgerFootprint { + read_only: vec![account_key.clone()].try_into().unwrap(), + read_write: VecM::default(), + }, + instructions: 1000, + disk_read_bytes: 1000, + write_bytes: 1000, + }; + + apply_resource_buffer(&mut resources); + + // Footprint should be unchanged + assert_eq!(resources.footprint.read_only.len(), 1); + } + + // ==================== inject_auth_entries_into_envelope tests ==================== + + #[test] + fn test_inject_auth_entries_v0_envelope_returns_error() { + let mut envelope = create_v0_envelope(); + let signed_user_auth = create_address_auth_entry(); + + let result = inject_auth_entries_into_envelope(&mut envelope, signed_user_auth); + + assert!(result.is_err()); + match result.unwrap_err() { + TransactionError::ValidationError(msg) => { + assert!(msg.contains("V0 transactions are not supported for Soroban")); + } + _ => panic!("Expected ValidationError"), + } + } + + #[test] + fn test_inject_auth_entries_fee_bump_returns_error() { + let mut envelope = create_fee_bump_envelope(); + let signed_user_auth = create_address_auth_entry(); + + let result = inject_auth_entries_into_envelope(&mut envelope, signed_user_auth); + + assert!(result.is_err()); + match result.unwrap_err() { + TransactionError::ValidationError(msg) => { + assert!(msg.contains("Fee bump transactions should not be used")); + } + _ => panic!("Expected ValidationError"), + } + } + + #[test] + fn test_inject_auth_entries_no_operations_returns_error() { + let mut envelope = create_minimal_v1_envelope(); + let signed_user_auth = create_address_auth_entry(); + + let result = inject_auth_entries_into_envelope(&mut envelope, signed_user_auth); + + assert!(result.is_err()); + match result.unwrap_err() { + TransactionError::ValidationError(msg) => { + assert!(msg.contains("Transaction has no operations")); + } + _ => panic!("Expected ValidationError"), + } + } + + #[test] + fn test_inject_auth_entries_non_invoke_host_function_returns_error() { + use soroban_rs::xdr::{Asset, PaymentOp}; + + // Create envelope with a Payment operation (not InvokeHostFunction) + let payment_op = Operation { + source_account: None, + body: OperationBody::Payment(PaymentOp { + destination: MuxedAccount::Ed25519(Uint256([0u8; 32])), + asset: Asset::Native, + amount: 1000, + }), }; - // Create a minimal transaction envelope let tx = Transaction { source_account: MuxedAccount::Ed25519(Uint256([0u8; 32])), fee: 100, seq_num: SequenceNumber(0), cond: Preconditions::None, memo: Memo::None, - operations: VecM::default(), + operations: vec![payment_op].try_into().unwrap(), ext: TransactionExt::V0, }; @@ -450,14 +822,577 @@ mod tests { signatures: VecM::default(), }); - // Update sequence number - update_envelope_sequence(&mut envelope, 12345).unwrap(); + let signed_user_auth = create_address_auth_entry(); + let result = inject_auth_entries_into_envelope(&mut envelope, signed_user_auth); - // Verify the update - if let TransactionEnvelope::Tx(v1) = &envelope { - assert_eq!(v1.tx.seq_num.0, 12345); + assert!(result.is_err()); + match result.unwrap_err() { + TransactionError::ValidationError(msg) => { + assert!(msg.contains("First operation is not InvokeHostFunction")); + } + _ => panic!("Expected ValidationError"), + } + } + + #[test] + fn test_inject_auth_entries_empty_auth_adds_user_entry() { + let mut envelope = create_invoke_host_function_envelope(vec![]); + let signed_user_auth = create_address_auth_entry(); + + let result = inject_auth_entries_into_envelope(&mut envelope, signed_user_auth.clone()); + + assert!(result.is_ok()); + let auth_entries = result.unwrap(); + assert_eq!(auth_entries.len(), 1); + } + + #[test] + fn test_inject_auth_entries_replaces_first_entry() { + let original_auth = create_address_auth_entry(); + let mut envelope = create_invoke_host_function_envelope(vec![original_auth]); + + let signed_user_auth = create_address_auth_entry(); + let result = inject_auth_entries_into_envelope(&mut envelope, signed_user_auth); + + assert!(result.is_ok()); + let auth_entries = result.unwrap(); + assert_eq!(auth_entries.len(), 1); + } + + #[test] + fn test_inject_auth_entries_converts_relayer_to_source_account() { + let user_auth = create_address_auth_entry(); + let relayer_auth = create_address_auth_entry(); + let mut envelope = create_invoke_host_function_envelope(vec![user_auth, relayer_auth]); + + let signed_user_auth = create_address_auth_entry(); + let result = inject_auth_entries_into_envelope(&mut envelope, signed_user_auth); + + assert!(result.is_ok()); + let auth_entries = result.unwrap(); + assert_eq!(auth_entries.len(), 2); + + // Second entry should have SourceAccount credentials + match &auth_entries[1].credentials { + SorobanCredentials::SourceAccount => {} // expected + _ => panic!("Expected SourceAccount credentials for relayer auth entry"), + } + } + + // ==================== build_simulation_envelope tests ==================== + + #[test] + fn test_build_simulation_envelope_v0_returns_error() { + let envelope = create_v0_envelope(); + let auth_entries = vec![create_address_auth_entry()]; + + let result = build_simulation_envelope(&envelope, &auth_entries); + + assert!(result.is_err()); + match result.unwrap_err() { + TransactionError::ValidationError(msg) => { + assert!(msg.contains("Expected V1 transaction envelope")); + } + _ => panic!("Expected ValidationError"), + } + } + + #[test] + fn test_build_simulation_envelope_fee_bump_returns_error() { + let envelope = create_fee_bump_envelope(); + let auth_entries = vec![create_address_auth_entry()]; + + let result = build_simulation_envelope(&envelope, &auth_entries); + + assert!(result.is_err()); + match result.unwrap_err() { + TransactionError::ValidationError(msg) => { + assert!(msg.contains("Expected V1 transaction envelope")); + } + _ => panic!("Expected ValidationError"), + } + } + + #[test] + fn test_build_simulation_envelope_no_operations_returns_error() { + let envelope = create_minimal_v1_envelope(); + let auth_entries = vec![create_address_auth_entry()]; + + let result = build_simulation_envelope(&envelope, &auth_entries); + + assert!(result.is_err()); + match result.unwrap_err() { + TransactionError::ValidationError(msg) => { + assert!(msg.contains("Transaction has no operations")); + } + _ => panic!("Expected ValidationError"), + } + } + + #[test] + fn test_build_simulation_envelope_non_invoke_host_function_returns_error() { + use soroban_rs::xdr::{Asset, PaymentOp}; + + let payment_op = Operation { + source_account: None, + body: OperationBody::Payment(PaymentOp { + destination: MuxedAccount::Ed25519(Uint256([0u8; 32])), + asset: Asset::Native, + amount: 1000, + }), + }; + + let tx = Transaction { + source_account: MuxedAccount::Ed25519(Uint256([0u8; 32])), + fee: 100, + seq_num: SequenceNumber(0), + cond: Preconditions::None, + memo: Memo::None, + operations: vec![payment_op].try_into().unwrap(), + ext: TransactionExt::V0, + }; + + let envelope = TransactionEnvelope::Tx(TransactionV1Envelope { + tx, + signatures: VecM::default(), + }); + + let auth_entries = vec![create_address_auth_entry()]; + let result = build_simulation_envelope(&envelope, &auth_entries); + + assert!(result.is_err()); + match result.unwrap_err() { + TransactionError::ValidationError(msg) => { + assert!(msg.contains("First operation is not InvokeHostFunction")); + } + _ => panic!("Expected ValidationError"), + } + } + + #[test] + fn test_build_simulation_envelope_success() { + let original_auth = create_address_auth_entry(); + let envelope = create_invoke_host_function_envelope(vec![original_auth]); + + let new_auth = create_source_account_auth_entry(); + let result = build_simulation_envelope(&envelope, &vec![new_auth]); + + assert!(result.is_ok()); + + // Verify the simulation envelope has the new auth entries + let sim_envelope = result.unwrap(); + if let TransactionEnvelope::Tx(v1) = sim_envelope { + assert_eq!(v1.signatures.len(), 0); // Simulation envelope should have no signatures + assert_eq!(v1.tx.operations.len(), 1); + } else { + panic!("Expected Tx envelope"); + } + } + + #[test] + fn test_build_simulation_envelope_preserves_transaction_fields() { + let original_auth = create_address_auth_entry(); + let mut envelope = create_invoke_host_function_envelope(vec![original_auth]); + + // Modify some fields to verify they're preserved + if let TransactionEnvelope::Tx(ref mut v1) = envelope { + v1.tx.fee = 500; + v1.tx.seq_num = SequenceNumber(42); + } + + let new_auth = create_source_account_auth_entry(); + let result = build_simulation_envelope(&envelope, &vec![new_auth]); + + assert!(result.is_ok()); + let sim_envelope = result.unwrap(); + + if let TransactionEnvelope::Tx(v1) = sim_envelope { + assert_eq!(v1.tx.fee, 500); + assert_eq!(v1.tx.seq_num.0, 42); } else { panic!("Expected Tx envelope"); } } } + +#[cfg(test)] +mod integration_tests { + use super::*; + use crate::models::TransactionInput; + use crate::repositories::MockTransactionCounterTrait; + use crate::services::provider::MockStellarProviderTrait; + use soroban_rs::stellar_rpc_client::SimulateTransactionResponse; + use soroban_rs::xdr::{ + ContractId, Hash, HostFunction, InvokeContractArgs, InvokeHostFunctionOp, Memo, + MuxedAccount, Operation, Preconditions, ScAddress, ScSymbol, ScVal, SequenceNumber, + SorobanAddressCredentials, SorobanAuthorizationEntry, SorobanAuthorizedFunction, + SorobanAuthorizedInvocation, SorobanCredentials, SorobanTransactionData, Transaction, + TransactionExt, TransactionV1Envelope, Uint256, VecM, + }; + use std::future::ready; + + fn create_gas_abstraction_envelope() -> TransactionEnvelope { + let user_auth = SorobanAuthorizationEntry { + credentials: SorobanCredentials::Address(SorobanAddressCredentials { + address: ScAddress::Contract(ContractId(Hash([1u8; 32]))), + nonce: 12345, + signature_expiration_ledger: 1000, + signature: ScVal::Void, + }), + root_invocation: SorobanAuthorizedInvocation { + function: SorobanAuthorizedFunction::ContractFn(InvokeContractArgs { + contract_address: ScAddress::Contract(ContractId(Hash([0u8; 32]))), + function_name: ScSymbol("forward".try_into().unwrap()), + args: VecM::default(), + }), + sub_invocations: VecM::default(), + }, + }; + + let relayer_auth = SorobanAuthorizationEntry { + credentials: SorobanCredentials::Address(SorobanAddressCredentials { + address: ScAddress::Contract(ContractId(Hash([2u8; 32]))), + nonce: 67890, + signature_expiration_ledger: 1000, + signature: ScVal::Void, + }), + root_invocation: SorobanAuthorizedInvocation { + function: SorobanAuthorizedFunction::ContractFn(InvokeContractArgs { + contract_address: ScAddress::Contract(ContractId(Hash([0u8; 32]))), + function_name: ScSymbol("collect".try_into().unwrap()), + args: VecM::default(), + }), + sub_invocations: VecM::default(), + }, + }; + + let invoke_op = InvokeHostFunctionOp { + host_function: HostFunction::InvokeContract(InvokeContractArgs { + contract_address: ScAddress::Contract(ContractId(Hash([0u8; 32]))), + function_name: ScSymbol("forward".try_into().unwrap()), + args: VecM::default(), + }), + auth: vec![user_auth, relayer_auth].try_into().unwrap(), + }; + + let operation = Operation { + source_account: None, + body: OperationBody::InvokeHostFunction(invoke_op), + }; + + let tx = Transaction { + source_account: MuxedAccount::Ed25519(Uint256([0u8; 32])), + fee: 100, + seq_num: SequenceNumber(0), + cond: Preconditions::None, + memo: Memo::None, + operations: vec![operation].try_into().unwrap(), + ext: TransactionExt::V0, + }; + + TransactionEnvelope::Tx(TransactionV1Envelope { + tx, + signatures: VecM::default(), + }) + } + + fn create_valid_soroban_tx_data_xdr() -> String { + use soroban_rs::xdr::SorobanTransactionDataExt; + + let tx_data = SorobanTransactionData { + ext: SorobanTransactionDataExt::V0, + resources: soroban_rs::xdr::SorobanResources { + footprint: soroban_rs::xdr::LedgerFootprint { + read_only: VecM::default(), + read_write: VecM::default(), + }, + instructions: 1000, + disk_read_bytes: 500, + write_bytes: 200, + }, + resource_fee: 100, + }; + tx_data.to_xdr_base64(Limits::none()).unwrap() + } + + #[tokio::test] + async fn test_process_soroban_gas_abstraction_invalid_input_type() { + let counter = MockTransactionCounterTrait::new(); + let provider = MockStellarProviderTrait::new(); + + let stellar_data = StellarTransactionData { + source_account: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF".to_string(), + network_passphrase: "Test SDF Network ; September 2015".to_string(), + fee: Some(100), + sequence_number: None, + transaction_input: TransactionInput::Operations(vec![]), // Wrong type + memo: None, + valid_until: None, + signatures: vec![], + hash: None, + simulation_transaction_data: None, + signed_envelope_xdr: None, + transaction_result_xdr: None, + }; + + let result = process_soroban_gas_abstraction( + &counter, + "relayer-1", + "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF", + &provider, + stellar_data, + ) + .await; + + assert!(result.is_err()); + match result.unwrap_err() { + TransactionError::ValidationError(msg) => { + assert!(msg.contains("Expected SorobanGasAbstraction")); + } + _ => panic!("Expected ValidationError"), + } + } + + #[tokio::test] + async fn test_process_soroban_gas_abstraction_invalid_xdr() { + let counter = MockTransactionCounterTrait::new(); + let provider = MockStellarProviderTrait::new(); + + let stellar_data = StellarTransactionData { + source_account: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF".to_string(), + network_passphrase: "Test SDF Network ; September 2015".to_string(), + fee: Some(100), + sequence_number: None, + transaction_input: TransactionInput::SorobanGasAbstraction { + xdr: "invalid-xdr".to_string(), + signed_auth_entry: "also-invalid".to_string(), + }, + memo: None, + valid_until: None, + signatures: vec![], + hash: None, + simulation_transaction_data: None, + signed_envelope_xdr: None, + transaction_result_xdr: None, + }; + + let result = process_soroban_gas_abstraction( + &counter, + "relayer-1", + "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF", + &provider, + stellar_data, + ) + .await; + + assert!(result.is_err()); + match result.unwrap_err() { + TransactionError::ValidationError(msg) => { + assert!(msg.contains("Failed to parse transaction XDR")); + } + _ => panic!("Expected ValidationError"), + } + } + + #[tokio::test] + async fn test_process_soroban_gas_abstraction_invalid_auth_entry() { + let counter = MockTransactionCounterTrait::new(); + let provider = MockStellarProviderTrait::new(); + + let envelope = create_gas_abstraction_envelope(); + let xdr = envelope.to_xdr_base64(Limits::none()).unwrap(); + + let stellar_data = StellarTransactionData { + source_account: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF".to_string(), + network_passphrase: "Test SDF Network ; September 2015".to_string(), + fee: Some(100), + sequence_number: None, + transaction_input: TransactionInput::SorobanGasAbstraction { + xdr, + signed_auth_entry: "invalid-auth-entry".to_string(), + }, + memo: None, + valid_until: None, + signatures: vec![], + hash: None, + simulation_transaction_data: None, + signed_envelope_xdr: None, + transaction_result_xdr: None, + }; + + let result = process_soroban_gas_abstraction( + &counter, + "relayer-1", + "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF", + &provider, + stellar_data, + ) + .await; + + assert!(result.is_err()); + match result.unwrap_err() { + TransactionError::ValidationError(msg) => { + assert!( + msg.contains("Failed to deserialize") || msg.contains("XdrError"), + "Unexpected error message: {}", + msg + ); + } + other => panic!("Expected ValidationError, got: {:?}", other), + } + } + + #[tokio::test] + async fn test_process_soroban_gas_abstraction_simulation_error() { + let mut counter = MockTransactionCounterTrait::new(); + counter + .expect_get_and_increment() + .returning(|_, _| Box::pin(ready(Ok(42u64)))); + + let mut provider = MockStellarProviderTrait::new(); + provider + .expect_simulate_transaction_envelope() + .returning(|_| { + Box::pin(ready(Ok(SimulateTransactionResponse { + error: Some("Simulation failed: insufficient resources".to_string()), + min_resource_fee: 0, + transaction_data: String::new(), + ..Default::default() + }))) + }); + + let envelope = create_gas_abstraction_envelope(); + let xdr = envelope.to_xdr_base64(Limits::none()).unwrap(); + + // Create a valid signed auth entry + let signed_auth = SorobanAuthorizationEntry { + credentials: SorobanCredentials::Address(SorobanAddressCredentials { + address: ScAddress::Contract(ContractId(Hash([1u8; 32]))), + nonce: 12345, + signature_expiration_ledger: 1000, + signature: ScVal::Void, + }), + root_invocation: SorobanAuthorizedInvocation { + function: SorobanAuthorizedFunction::ContractFn(InvokeContractArgs { + contract_address: ScAddress::Contract(ContractId(Hash([0u8; 32]))), + function_name: ScSymbol("forward".try_into().unwrap()), + args: VecM::default(), + }), + sub_invocations: VecM::default(), + }, + }; + let signed_auth_xdr = signed_auth.to_xdr_base64(Limits::none()).unwrap(); + + let stellar_data = StellarTransactionData { + source_account: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF".to_string(), + network_passphrase: "Test SDF Network ; September 2015".to_string(), + fee: Some(100), + sequence_number: None, + transaction_input: TransactionInput::SorobanGasAbstraction { + xdr, + signed_auth_entry: signed_auth_xdr, + }, + memo: None, + valid_until: None, + signatures: vec![], + hash: None, + simulation_transaction_data: None, + signed_envelope_xdr: None, + transaction_result_xdr: None, + }; + + let result = process_soroban_gas_abstraction( + &counter, + "relayer-1", + "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF", + &provider, + stellar_data, + ) + .await; + + assert!(result.is_err()); + match result.unwrap_err() { + TransactionError::UnexpectedError(msg) => { + assert!(msg.contains("Simulation failed")); + } + other => panic!("Expected UnexpectedError, got: {:?}", other), + } + } + + #[tokio::test] + async fn test_process_soroban_gas_abstraction_success() { + let mut counter = MockTransactionCounterTrait::new(); + counter + .expect_get_and_increment() + .returning(|_, _| Box::pin(ready(Ok(42u64)))); + + let mut provider = MockStellarProviderTrait::new(); + provider + .expect_simulate_transaction_envelope() + .returning(|_| { + Box::pin(ready(Ok(SimulateTransactionResponse { + error: None, + min_resource_fee: 1000, + transaction_data: create_valid_soroban_tx_data_xdr(), + ..Default::default() + }))) + }); + + let envelope = create_gas_abstraction_envelope(); + let xdr = envelope.to_xdr_base64(Limits::none()).unwrap(); + + let signed_auth = SorobanAuthorizationEntry { + credentials: SorobanCredentials::Address(SorobanAddressCredentials { + address: ScAddress::Contract(ContractId(Hash([1u8; 32]))), + nonce: 12345, + signature_expiration_ledger: 1000, + signature: ScVal::Void, + }), + root_invocation: SorobanAuthorizedInvocation { + function: SorobanAuthorizedFunction::ContractFn(InvokeContractArgs { + contract_address: ScAddress::Contract(ContractId(Hash([0u8; 32]))), + function_name: ScSymbol("forward".try_into().unwrap()), + args: VecM::default(), + }), + sub_invocations: VecM::default(), + }, + }; + let signed_auth_xdr = signed_auth.to_xdr_base64(Limits::none()).unwrap(); + + let stellar_data = StellarTransactionData { + source_account: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF".to_string(), + network_passphrase: "Test SDF Network ; September 2015".to_string(), + fee: Some(100), + sequence_number: None, + transaction_input: TransactionInput::SorobanGasAbstraction { + xdr, + signed_auth_entry: signed_auth_xdr, + }, + memo: None, + valid_until: None, + signatures: vec![], + hash: None, + simulation_transaction_data: None, + signed_envelope_xdr: None, + transaction_result_xdr: None, + }; + + let result = process_soroban_gas_abstraction( + &counter, + "relayer-1", + "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF", + &provider, + stellar_data, + ) + .await; + + assert!(result.is_ok()); + let prepared = result.unwrap(); + + assert_eq!(prepared.sequence_number, Some(42)); + match prepared.transaction_input { + TransactionInput::UnsignedXdr(_) => {} // Expected + _ => panic!("Expected UnsignedXdr transaction input"), + } + } +} diff --git a/src/services/stellar_fee_forwarder/mod.rs b/src/services/stellar_fee_forwarder/mod.rs index a86282e82..48f1e98de 100644 --- a/src/services/stellar_fee_forwarder/mod.rs +++ b/src/services/stellar_fee_forwarder/mod.rs @@ -592,11 +592,36 @@ mod tests { use super::*; use crate::services::provider::StellarProvider; + // Test constants + const VALID_CONTRACT_ADDR: &str = "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC"; + const VALID_ACCOUNT_ADDR: &str = "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5"; + const VALID_ACCOUNT_ADDR_2: &str = "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN"; + + fn create_test_params() -> FeeForwarderParams { + FeeForwarderParams { + fee_token: VALID_CONTRACT_ADDR.to_string(), + fee_amount: 1_000_000, + max_fee_amount: 2_000_000, + expiration_ledger: 100000, + target_contract: VALID_CONTRACT_ADDR.to_string(), + target_fn: "transfer".to_string(), + target_args: vec![ScVal::U32(42)], + user: VALID_ACCOUNT_ADDR.to_string(), + relayer: VALID_ACCOUNT_ADDR_2.to_string(), + } + } + + // ==================== parse_contract_address tests ==================== + #[test] fn test_parse_contract_address_valid() { - let addr = "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC"; - let result = FeeForwarderService::::parse_contract_address(addr); + let result = + FeeForwarderService::::parse_contract_address(VALID_CONTRACT_ADDR); assert!(result.is_ok()); + match result.unwrap() { + ScAddress::Contract(_) => {} + _ => panic!("Expected Contract address"), + } } #[test] @@ -604,6 +629,50 @@ mod tests { let addr = "INVALID"; let result = FeeForwarderService::::parse_contract_address(addr); assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(matches!(err, FeeForwarderError::InvalidContractAddress(_))); + } + + #[test] + fn test_parse_contract_address_with_account_address() { + // Account addresses (G...) should fail when parsed as contract addresses + let result = + FeeForwarderService::::parse_contract_address(VALID_ACCOUNT_ADDR); + assert!(result.is_err()); + } + + #[test] + fn test_parse_contract_address_empty() { + let result = FeeForwarderService::::parse_contract_address(""); + assert!(result.is_err()); + } + + #[test] + fn test_parse_contract_address_public() { + let result = FeeForwarderService::::parse_contract_address_public( + VALID_CONTRACT_ADDR, + ); + assert!(result.is_ok()); + } + + // ==================== parse_account_address tests ==================== + + #[test] + fn test_parse_account_address_valid() { + let result = + FeeForwarderService::::parse_account_address(VALID_ACCOUNT_ADDR); + assert!(result.is_ok()); + match result.unwrap() { + ScAddress::Account(_) => {} + _ => panic!("Expected Account address"), + } + } + + #[test] + fn test_parse_account_address_valid_2() { + let result = + FeeForwarderService::::parse_account_address(VALID_ACCOUNT_ADDR_2); + assert!(result.is_ok()); } #[test] @@ -611,8 +680,26 @@ mod tests { let addr = "GABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890ABCDEFGH"; let result = FeeForwarderService::::parse_account_address(addr); assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(matches!(err, FeeForwarderError::InvalidAccountAddress(_))); } + #[test] + fn test_parse_account_address_with_contract_address() { + // Contract addresses (C...) should fail when parsed as account addresses + let result = + FeeForwarderService::::parse_account_address(VALID_CONTRACT_ADDR); + assert!(result.is_err()); + } + + #[test] + fn test_parse_account_address_empty() { + let result = FeeForwarderService::::parse_account_address(""); + assert!(result.is_err()); + } + + // ==================== i128_to_scval tests ==================== + #[test] fn test_i128_to_scval() { let amount: i128 = 1_000_000_000; @@ -626,10 +713,460 @@ mod tests { } } + #[test] + fn test_i128_to_scval_zero() { + let amount: i128 = 0; + let scval = FeeForwarderService::::i128_to_scval(amount); + match scval { + ScVal::I128(parts) => { + assert_eq!(parts.hi, 0); + assert_eq!(parts.lo, 0); + } + _ => panic!("Expected I128"), + } + } + + #[test] + fn test_i128_to_scval_negative() { + let amount: i128 = -1_000_000; + let scval = FeeForwarderService::::i128_to_scval(amount); + match scval { + ScVal::I128(parts) => { + let recovered = ((parts.hi as i128) << 64) | (parts.lo as i128); + assert_eq!(amount, recovered); + } + _ => panic!("Expected I128"), + } + } + + #[test] + fn test_i128_to_scval_max() { + let amount: i128 = i128::MAX; + let scval = FeeForwarderService::::i128_to_scval(amount); + match scval { + ScVal::I128(parts) => { + let recovered = ((parts.hi as i128) << 64) | (parts.lo as i128); + assert_eq!(amount, recovered); + } + _ => panic!("Expected I128"), + } + } + + #[test] + fn test_i128_to_scval_min() { + let amount: i128 = i128::MIN; + let scval = FeeForwarderService::::i128_to_scval(amount); + match scval { + ScVal::I128(parts) => { + let recovered = ((parts.hi as i128) << 64) | (parts.lo as i128); + assert_eq!(amount, recovered); + } + _ => panic!("Expected I128"), + } + } + + // ==================== create_symbol tests ==================== + #[test] fn test_create_symbol() { let result = FeeForwarderService::::create_symbol("forward"); assert!(result.is_ok()); assert_eq!(result.unwrap().to_utf8_string_lossy(), "forward"); } + + #[test] + fn test_create_symbol_approve() { + let result = FeeForwarderService::::create_symbol("approve"); + assert!(result.is_ok()); + assert_eq!(result.unwrap().to_utf8_string_lossy(), "approve"); + } + + #[test] + fn test_create_symbol_transfer() { + let result = FeeForwarderService::::create_symbol("transfer"); + assert!(result.is_ok()); + assert_eq!(result.unwrap().to_utf8_string_lossy(), "transfer"); + } + + #[test] + fn test_create_symbol_empty() { + let result = FeeForwarderService::::create_symbol(""); + assert!(result.is_ok()); + } + + // ==================== build_user_auth_entry_standalone tests ==================== + + #[test] + fn test_build_user_auth_entry_standalone_without_target_auth() { + let params = create_test_params(); + let result = FeeForwarderService::::build_user_auth_entry_standalone( + VALID_CONTRACT_ADDR, + ¶ms, + false, + ); + assert!(result.is_ok()); + + let auth_entry = result.unwrap(); + match &auth_entry.credentials { + SorobanCredentials::Address(creds) => { + assert_eq!(creds.signature_expiration_ledger, params.expiration_ledger); + // Signature should be empty vec for simulation + match &creds.signature { + ScVal::Vec(Some(v)) => assert!(v.is_empty()), + _ => panic!("Expected empty Vec signature"), + } + } + _ => panic!("Expected Address credentials"), + } + + // Should have 1 sub-invocation (approve only) + assert_eq!(auth_entry.root_invocation.sub_invocations.len(), 1); + } + + #[test] + fn test_build_user_auth_entry_standalone_with_target_auth() { + let params = create_test_params(); + let result = FeeForwarderService::::build_user_auth_entry_standalone( + VALID_CONTRACT_ADDR, + ¶ms, + true, + ); + assert!(result.is_ok()); + + let auth_entry = result.unwrap(); + // Should have 2 sub-invocations (approve + target) + assert_eq!(auth_entry.root_invocation.sub_invocations.len(), 2); + } + + #[test] + fn test_build_user_auth_entry_standalone_invalid_fee_forwarder() { + let params = create_test_params(); + let result = FeeForwarderService::::build_user_auth_entry_standalone( + "INVALID", ¶ms, false, + ); + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + FeeForwarderError::InvalidContractAddress(_) + )); + } + + #[test] + fn test_build_user_auth_entry_standalone_invalid_fee_token() { + let mut params = create_test_params(); + params.fee_token = "INVALID".to_string(); + let result = FeeForwarderService::::build_user_auth_entry_standalone( + VALID_CONTRACT_ADDR, + ¶ms, + false, + ); + assert!(result.is_err()); + } + + #[test] + fn test_build_user_auth_entry_standalone_invalid_user() { + let mut params = create_test_params(); + params.user = "INVALID".to_string(); + let result = FeeForwarderService::::build_user_auth_entry_standalone( + VALID_CONTRACT_ADDR, + ¶ms, + false, + ); + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + FeeForwarderError::InvalidAccountAddress(_) + )); + } + + #[test] + fn test_build_user_auth_entry_standalone_invalid_relayer() { + let mut params = create_test_params(); + params.relayer = "INVALID".to_string(); + let result = FeeForwarderService::::build_user_auth_entry_standalone( + VALID_CONTRACT_ADDR, + ¶ms, + false, + ); + assert!(result.is_err()); + } + + // ==================== build_relayer_auth_entry_standalone tests ==================== + + #[test] + fn test_build_relayer_auth_entry_standalone() { + let params = create_test_params(); + let result = FeeForwarderService::::build_relayer_auth_entry_standalone( + VALID_CONTRACT_ADDR, + ¶ms, + ); + assert!(result.is_ok()); + + let auth_entry = result.unwrap(); + match &auth_entry.credentials { + SorobanCredentials::Address(creds) => { + assert_eq!(creds.signature_expiration_ledger, params.expiration_ledger); + // Relayer has no sub-invocations + } + _ => panic!("Expected Address credentials"), + } + + // Relayer should have no sub-invocations + assert!(auth_entry.root_invocation.sub_invocations.is_empty()); + } + + #[test] + fn test_build_relayer_auth_entry_standalone_invalid_fee_forwarder() { + let params = create_test_params(); + let result = FeeForwarderService::::build_relayer_auth_entry_standalone( + "INVALID", ¶ms, + ); + assert!(result.is_err()); + } + + #[test] + fn test_build_relayer_auth_entry_standalone_invalid_relayer() { + let mut params = create_test_params(); + params.relayer = "INVALID".to_string(); + let result = FeeForwarderService::::build_relayer_auth_entry_standalone( + VALID_CONTRACT_ADDR, + ¶ms, + ); + assert!(result.is_err()); + } + + // ==================== build_forward_args tests ==================== + + #[test] + fn test_build_forward_args_for_invoke_contract_args() { + let params = create_test_params(); + let result = + FeeForwarderService::::build_forward_args_for_invoke_contract_args( + VALID_CONTRACT_ADDR, + ¶ms, + ); + assert!(result.is_ok()); + + let args = result.unwrap(); + // Should have 9 arguments + assert_eq!(args.len(), 9); + } + + #[test] + fn test_build_forward_args_standalone_invalid_fee_token() { + let mut params = create_test_params(); + params.fee_token = "INVALID".to_string(); + let result = + FeeForwarderService::::build_forward_args_for_invoke_contract_args( + VALID_CONTRACT_ADDR, + ¶ms, + ); + assert!(result.is_err()); + } + + #[test] + fn test_build_forward_args_standalone_invalid_target_contract() { + let mut params = create_test_params(); + params.target_contract = "INVALID".to_string(); + let result = + FeeForwarderService::::build_forward_args_for_invoke_contract_args( + VALID_CONTRACT_ADDR, + ¶ms, + ); + assert!(result.is_err()); + } + + // ==================== serialize/deserialize auth entry tests ==================== + + #[test] + fn test_serialize_and_deserialize_auth_entry() { + let params = create_test_params(); + let auth_entry = FeeForwarderService::::build_user_auth_entry_standalone( + VALID_CONTRACT_ADDR, + ¶ms, + false, + ) + .unwrap(); + + let serialized = FeeForwarderService::::serialize_auth_entry(&auth_entry); + assert!(serialized.is_ok()); + + let xdr_string = serialized.unwrap(); + assert!(!xdr_string.is_empty()); + + let deserialized = + FeeForwarderService::::deserialize_auth_entry(&xdr_string); + assert!(deserialized.is_ok()); + } + + #[test] + fn test_deserialize_auth_entry_invalid_xdr() { + let result = + FeeForwarderService::::deserialize_auth_entry("not-valid-base64-xdr!"); + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + FeeForwarderError::XdrError(_) + )); + } + + #[test] + fn test_deserialize_auth_entry_empty() { + let result = FeeForwarderService::::deserialize_auth_entry(""); + assert!(result.is_err()); + } + + // ==================== build_invoke_operation_standalone tests ==================== + + #[test] + fn test_build_invoke_operation_standalone() { + let params = create_test_params(); + let user_auth = FeeForwarderService::::build_user_auth_entry_standalone( + VALID_CONTRACT_ADDR, + ¶ms, + false, + ) + .unwrap(); + let relayer_auth = + FeeForwarderService::::build_relayer_auth_entry_standalone( + VALID_CONTRACT_ADDR, + ¶ms, + ) + .unwrap(); + + let result = FeeForwarderService::::build_invoke_operation_standalone( + VALID_CONTRACT_ADDR, + ¶ms, + vec![user_auth, relayer_auth], + ); + assert!(result.is_ok()); + + let operation = result.unwrap(); + assert!(operation.source_account.is_none()); + match operation.body { + OperationBody::InvokeHostFunction(op) => { + assert_eq!(op.auth.len(), 2); + } + _ => panic!("Expected InvokeHostFunction operation"), + } + } + + #[test] + fn test_build_invoke_operation_standalone_invalid_fee_forwarder() { + let params = create_test_params(); + let result = FeeForwarderService::::build_invoke_operation_standalone( + "INVALID", + ¶ms, + vec![], + ); + assert!(result.is_err()); + } + + #[test] + fn test_build_invoke_operation_standalone_empty_auth() { + let params = create_test_params(); + let result = FeeForwarderService::::build_invoke_operation_standalone( + VALID_CONTRACT_ADDR, + ¶ms, + vec![], + ); + assert!(result.is_ok()); + } + + // ==================== FeeForwarderParams tests ==================== + + #[test] + fn test_fee_forwarder_params_clone() { + let params = create_test_params(); + let cloned = params.clone(); + assert_eq!(params.fee_token, cloned.fee_token); + assert_eq!(params.fee_amount, cloned.fee_amount); + assert_eq!(params.max_fee_amount, cloned.max_fee_amount); + assert_eq!(params.expiration_ledger, cloned.expiration_ledger); + assert_eq!(params.target_contract, cloned.target_contract); + assert_eq!(params.target_fn, cloned.target_fn); + assert_eq!(params.user, cloned.user); + assert_eq!(params.relayer, cloned.relayer); + } + + #[test] + fn test_fee_forwarder_params_with_empty_target_args() { + let mut params = create_test_params(); + params.target_args = vec![]; + + let result = FeeForwarderService::::build_user_auth_entry_standalone( + VALID_CONTRACT_ADDR, + ¶ms, + true, + ); + assert!(result.is_ok()); + } + + #[test] + fn test_fee_forwarder_params_with_multiple_target_args() { + let mut params = create_test_params(); + params.target_args = vec![ + ScVal::U32(1), + ScVal::U32(2), + ScVal::U32(3), + ScVal::Bool(true), + ]; + + let result = FeeForwarderService::::build_user_auth_entry_standalone( + VALID_CONTRACT_ADDR, + ¶ms, + true, + ); + assert!(result.is_ok()); + } + + // ==================== Error display tests ==================== + + #[test] + fn test_error_display_invalid_contract_address() { + let err = FeeForwarderError::InvalidContractAddress("test".to_string()); + assert!(err.to_string().contains("Invalid contract address")); + } + + #[test] + fn test_error_display_invalid_account_address() { + let err = FeeForwarderError::InvalidAccountAddress("test".to_string()); + assert!(err.to_string().contains("Invalid account address")); + } + + #[test] + fn test_error_display_authorization_build_error() { + let err = FeeForwarderError::AuthorizationBuildError("test".to_string()); + assert!(err.to_string().contains("Failed to build authorization")); + } + + #[test] + fn test_error_display_provider_error() { + let err = FeeForwarderError::ProviderError("test".to_string()); + assert!(err.to_string().contains("Provider error")); + } + + #[test] + fn test_error_display_xdr_error() { + let err = FeeForwarderError::XdrError("test".to_string()); + assert!(err.to_string().contains("XDR serialization error")); + } + + #[test] + fn test_error_display_invalid_function_name() { + let err = FeeForwarderError::InvalidFunctionName("test".to_string()); + assert!(err.to_string().contains("Invalid function name")); + } + + // ==================== Constants tests ==================== + + #[test] + fn test_default_validity_seconds() { + assert_eq!(DEFAULT_VALIDITY_SECONDS, 300); + } + + #[test] + fn test_ledger_time_seconds() { + assert_eq!(LEDGER_TIME_SECONDS, 5); + } } From c0bc10c8614552e77775d0d21923123d791f230a Mon Sep 17 00:00:00 2001 From: Nicolas Molina Date: Wed, 4 Feb 2026 11:59:12 -0300 Subject: [PATCH 09/22] test: Adding test cases --- src/domain/relayer/stellar/mod.rs | 164 ++++ src/services/stellar_dex/soroswap_service.rs | 755 +++++++++++++++++- .../stellar_dex/stellar_dex_service.rs | 493 ++++++++++++ 3 files changed, 1396 insertions(+), 16 deletions(-) diff --git a/src/domain/relayer/stellar/mod.rs b/src/domain/relayer/stellar/mod.rs index 55ff64ed7..d9dd5983c 100644 --- a/src/domain/relayer/stellar/mod.rs +++ b/src/domain/relayer/stellar/mod.rs @@ -190,3 +190,167 @@ fn get_default_soroswap_factory(is_testnet: bool) -> String { "CA4HEQTL2WPEUYKYKCDOHCDNIV4QHNJ7EL4J4NQ6VADP7SYHVRYZ7AW2".to_string() } } + +#[cfg(test)] +mod tests { + use super::*; + + mod get_default_soroswap_router_tests { + use super::*; + + #[test] + fn test_returns_testnet_router_address_when_testnet() { + let router = get_default_soroswap_router(true); + assert_eq!( + router, "CCJUD55AG6W5HAI5LRVNKAE5WDP5XGZBUDS5WNTIVDU7O264UZZE7BRD", + "Should return Soroswap testnet router address" + ); + } + + #[test] + fn test_returns_mainnet_router_address_when_mainnet() { + let router = get_default_soroswap_router(false); + assert_eq!( + router, "CAG5LRYQ5JVEUI5TEID72EYOVX44TTUJT5BQR2J6J77FH65PCCFAJDDH", + "Should return Soroswap mainnet router address" + ); + } + + #[test] + fn test_testnet_and_mainnet_router_addresses_are_different() { + let testnet_router = get_default_soroswap_router(true); + let mainnet_router = get_default_soroswap_router(false); + assert_ne!( + testnet_router, mainnet_router, + "Testnet and mainnet router addresses should be different" + ); + } + + #[test] + fn test_router_addresses_are_valid_stellar_contract_addresses() { + let testnet_router = get_default_soroswap_router(true); + let mainnet_router = get_default_soroswap_router(false); + + // Stellar contract addresses start with 'C' and are 56 characters long + assert!( + testnet_router.starts_with('C'), + "Testnet router should start with 'C'" + ); + assert_eq!( + testnet_router.len(), + 56, + "Testnet router should be 56 characters" + ); + + assert!( + mainnet_router.starts_with('C'), + "Mainnet router should start with 'C'" + ); + assert_eq!( + mainnet_router.len(), + 56, + "Mainnet router should be 56 characters" + ); + } + } + + mod get_default_soroswap_factory_tests { + use super::*; + + #[test] + fn test_returns_testnet_factory_address_when_testnet() { + let factory = get_default_soroswap_factory(true); + assert_eq!( + factory, "CDP3HMUH6SMS3S7NPGNDJLULCOXXEPSHY4JKUKMBNQMATHDHWXRRJTBY", + "Should return Soroswap testnet factory address" + ); + } + + #[test] + fn test_returns_mainnet_factory_address_when_mainnet() { + let factory = get_default_soroswap_factory(false); + assert_eq!( + factory, "CA4HEQTL2WPEUYKYKCDOHCDNIV4QHNJ7EL4J4NQ6VADP7SYHVRYZ7AW2", + "Should return Soroswap mainnet factory address" + ); + } + + #[test] + fn test_testnet_and_mainnet_factory_addresses_are_different() { + let testnet_factory = get_default_soroswap_factory(true); + let mainnet_factory = get_default_soroswap_factory(false); + assert_ne!( + testnet_factory, mainnet_factory, + "Testnet and mainnet factory addresses should be different" + ); + } + + #[test] + fn test_factory_addresses_are_valid_stellar_contract_addresses() { + let testnet_factory = get_default_soroswap_factory(true); + let mainnet_factory = get_default_soroswap_factory(false); + + // Stellar contract addresses start with 'C' and are 56 characters long + assert!( + testnet_factory.starts_with('C'), + "Testnet factory should start with 'C'" + ); + assert_eq!( + testnet_factory.len(), + 56, + "Testnet factory should be 56 characters" + ); + + assert!( + mainnet_factory.starts_with('C'), + "Mainnet factory should start with 'C'" + ); + assert_eq!( + mainnet_factory.len(), + 56, + "Mainnet factory should be 56 characters" + ); + } + } + + mod soroswap_address_consistency_tests { + use super::*; + + #[test] + fn test_router_and_factory_addresses_are_different() { + let testnet_router = get_default_soroswap_router(true); + let testnet_factory = get_default_soroswap_factory(true); + let mainnet_router = get_default_soroswap_router(false); + let mainnet_factory = get_default_soroswap_factory(false); + + assert_ne!( + testnet_router, testnet_factory, + "Testnet router and factory should be different contracts" + ); + assert_ne!( + mainnet_router, mainnet_factory, + "Mainnet router and factory should be different contracts" + ); + } + + #[test] + fn test_all_four_addresses_are_unique() { + let addresses = vec![ + get_default_soroswap_router(true), + get_default_soroswap_router(false), + get_default_soroswap_factory(true), + get_default_soroswap_factory(false), + ]; + + let mut unique_addresses = addresses.clone(); + unique_addresses.sort(); + unique_addresses.dedup(); + + assert_eq!( + addresses.len(), + unique_addresses.len(), + "All Soroswap contract addresses should be unique" + ); + } + } +} diff --git a/src/services/stellar_dex/soroswap_service.rs b/src/services/stellar_dex/soroswap_service.rs index 2627fdfb3..306f721a6 100644 --- a/src/services/stellar_dex/soroswap_service.rs +++ b/src/services/stellar_dex/soroswap_service.rs @@ -439,38 +439,761 @@ where #[cfg(test)] mod tests { use super::*; - use crate::services::provider::StellarProvider; + use crate::services::provider::MockStellarProviderTrait; + use futures::FutureExt; + + fn create_mock_provider() -> Arc { + Arc::new(MockStellarProviderTrait::new()) + } + + fn create_test_service( + provider: Arc, + is_testnet: bool, + ) -> SoroswapService { + SoroswapService::new( + "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC".to_string(), // router + "CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA".to_string(), // factory + None, + provider, + "Test SDF Network ; September 2015".to_string(), + is_testnet, + ) + } + + // ==================== Constructor Tests ==================== #[test] - fn test_can_handle_asset_native() { - // We can't easily test this without a mock provider - // This test just validates the asset type detection logic - assert!( - "native".is_empty() || "native" == "native", - "Should handle native" + fn test_new_testnet_uses_testnet_native_wrapper() { + let provider = create_mock_provider(); + let service = create_test_service(provider, true); + assert_eq!(service.native_wrapper_address, TESTNET_NATIVE_WRAPPER); + } + + #[test] + fn test_new_mainnet_uses_mainnet_native_wrapper() { + let provider = create_mock_provider(); + let service = create_test_service(provider, false); + assert_eq!(service.native_wrapper_address, MAINNET_NATIVE_WRAPPER); + } + + #[test] + fn test_new_with_custom_native_wrapper() { + let provider = create_mock_provider(); + let custom_wrapper = "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M".to_string(); + let service = SoroswapService::new( + "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC".to_string(), + "CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA".to_string(), + Some(custom_wrapper.clone()), + provider, + "Test SDF Network ; September 2015".to_string(), + true, ); + assert_eq!(service.native_wrapper_address, custom_wrapper); + } + + // ==================== parse_contract_address Tests ==================== + + #[test] + fn test_parse_contract_address_valid() { + let addr = "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC"; + let result = SoroswapService::::parse_contract_address(addr); + assert!(result.is_ok()); + match result.unwrap() { + ScAddress::Contract(_) => {} + _ => panic!("Expected Contract address"), + } + } + + #[test] + fn test_parse_contract_address_invalid_format() { + let addr = "INVALID_ADDRESS"; + let result = SoroswapService::::parse_contract_address(addr); + assert!(result.is_err()); + match result.unwrap_err() { + StellarDexServiceError::InvalidAssetIdentifier(msg) => { + assert!(msg.contains("Invalid Soroban contract address")); + } + _ => panic!("Expected InvalidAssetIdentifier error"), + } + } + + #[test] + fn test_parse_contract_address_stellar_account_not_contract() { + // A valid Stellar account address (G...) but not a contract (C...) + let addr = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF"; + let result = SoroswapService::::parse_contract_address(addr); + assert!(result.is_err()); + } + + // ==================== can_handle_asset Tests ==================== + + #[test] + fn test_can_handle_asset_native() { + let provider = create_mock_provider(); + let service = create_test_service(provider, true); + assert!(service.can_handle_asset("native")); + } + + #[test] + fn test_can_handle_asset_empty_string() { + let provider = create_mock_provider(); + let service = create_test_service(provider, true); + assert!(service.can_handle_asset("")); } #[test] - fn test_can_handle_asset_contract() { + fn test_can_handle_asset_valid_contract() { + let provider = create_mock_provider(); + let service = create_test_service(provider, true); let contract_addr = "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC"; - assert!(contract_addr.starts_with('C')); - assert_eq!(contract_addr.len(), 56); - assert!(!contract_addr.contains(':')); - assert!(stellar_strkey::Contract::from_string(contract_addr).is_ok()); + assert!(service.can_handle_asset(contract_addr)); } #[test] fn test_cannot_handle_classic_asset() { + let provider = create_mock_provider(); + let service = create_test_service(provider, true); let classic_asset = "USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN"; - assert!(classic_asset.contains(':')); + assert!(!service.can_handle_asset(classic_asset)); + } + + #[test] + fn test_cannot_handle_short_address() { + let provider = create_mock_provider(); + let service = create_test_service(provider, true); + assert!(!service.can_handle_asset("CSHORT")); + } + + #[test] + fn test_cannot_handle_non_c_prefix() { + let provider = create_mock_provider(); + let service = create_test_service(provider, true); + // Stellar account address (G prefix) + let addr = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF"; + assert!(!service.can_handle_asset(addr)); + } + + #[test] + fn test_cannot_handle_invalid_contract_checksum() { + let provider = create_mock_provider(); + let service = create_test_service(provider, true); + // Valid format but invalid checksum + let invalid_addr = "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; + assert!(!service.can_handle_asset(invalid_addr)); } + // ==================== supported_asset_types Tests ==================== + + #[test] + fn test_supported_asset_types() { + let provider = create_mock_provider(); + let service = create_test_service(provider, true); + let types = service.supported_asset_types(); + assert!(types.contains(&AssetType::Native)); + assert!(types.contains(&AssetType::Contract)); + assert_eq!(types.len(), 2); + } + + // ==================== i128 Conversion Tests ==================== + #[test] - fn test_i128_conversion() { + fn test_i128_to_scval_and_back_positive() { let original: i128 = 1_000_000_000; - let scval = SoroswapService::::i128_to_scval(original); - let recovered = SoroswapService::::scval_to_i128(&scval).unwrap(); + let scval = SoroswapService::::i128_to_scval(original); + let recovered = SoroswapService::::scval_to_i128(&scval).unwrap(); assert_eq!(original, recovered); } + + #[test] + fn test_i128_to_scval_and_back_zero() { + let original: i128 = 0; + let scval = SoroswapService::::i128_to_scval(original); + let recovered = SoroswapService::::scval_to_i128(&scval).unwrap(); + assert_eq!(original, recovered); + } + + #[test] + fn test_i128_to_scval_and_back_negative() { + let original: i128 = -1_000_000_000; + let scval = SoroswapService::::i128_to_scval(original); + let recovered = SoroswapService::::scval_to_i128(&scval).unwrap(); + assert_eq!(original, recovered); + } + + #[test] + fn test_i128_to_scval_and_back_large_positive() { + let original: i128 = i128::MAX / 2; + let scval = SoroswapService::::i128_to_scval(original); + let recovered = SoroswapService::::scval_to_i128(&scval).unwrap(); + assert_eq!(original, recovered); + } + + #[test] + fn test_i128_to_scval_and_back_large_negative() { + let original: i128 = i128::MIN / 2; + let scval = SoroswapService::::i128_to_scval(original); + let recovered = SoroswapService::::scval_to_i128(&scval).unwrap(); + assert_eq!(original, recovered); + } + + #[test] + fn test_scval_to_i128_wrong_type() { + let scval = ScVal::Bool(true); + let result = SoroswapService::::scval_to_i128(&scval); + assert!(result.is_err()); + match result.unwrap_err() { + StellarDexServiceError::UnknownError(msg) => { + assert!(msg.contains("Expected I128 value")); + } + _ => panic!("Expected UnknownError"), + } + } + + // ==================== scval_to_amounts_vec Tests ==================== + + #[test] + fn test_scval_to_amounts_vec_valid() { + let amounts: Vec = vec![100, 200, 300]; + let sc_vals: Vec = amounts + .iter() + .map(|&a| SoroswapService::::i128_to_scval(a)) + .collect(); + let sc_vec: ScVec = sc_vals.try_into().unwrap(); + let scval = ScVal::Vec(Some(sc_vec)); + + let result = + SoroswapService::::scval_to_amounts_vec(&scval).unwrap(); + assert_eq!(result, vec![100, 200, 300]); + } + + #[test] + fn test_scval_to_amounts_vec_empty() { + let sc_vec: ScVec = vec![].try_into().unwrap(); + let scval = ScVal::Vec(Some(sc_vec)); + + let result = + SoroswapService::::scval_to_amounts_vec(&scval).unwrap(); + assert!(result.is_empty()); + } + + #[test] + fn test_scval_to_amounts_vec_wrong_type() { + let scval = ScVal::Bool(true); + let result = SoroswapService::::scval_to_amounts_vec(&scval); + assert!(result.is_err()); + match result.unwrap_err() { + StellarDexServiceError::UnknownError(msg) => { + assert!(msg.contains("Expected Vec of I128 values")); + } + _ => panic!("Expected UnknownError"), + } + } + + #[test] + fn test_scval_to_amounts_vec_none() { + let scval = ScVal::Vec(None); + let result = SoroswapService::::scval_to_amounts_vec(&scval); + assert!(result.is_err()); + } + + #[test] + fn test_scval_to_amounts_vec_mixed_types() { + // Vec containing a non-I128 value + let sc_vec: ScVec = vec![ScVal::Bool(true)].try_into().unwrap(); + let scval = ScVal::Vec(Some(sc_vec)); + + let result = SoroswapService::::scval_to_amounts_vec(&scval); + assert!(result.is_err()); + } + + // ==================== build_path Tests ==================== + + #[test] + fn test_build_path_valid() { + let provider = create_mock_provider(); + let service = create_test_service(provider, true); + let from = "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC"; + let to = "CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA"; + + let result = service.build_path(from, to); + assert!(result.is_ok()); + match result.unwrap() { + ScVal::Vec(Some(vec)) => { + assert_eq!(vec.len(), 2); + } + _ => panic!("Expected Vec"), + } + } + + #[test] + fn test_build_path_invalid_from() { + let provider = create_mock_provider(); + let service = create_test_service(provider, true); + let result = service.build_path( + "INVALID", + "CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA", + ); + assert!(result.is_err()); + } + + #[test] + fn test_build_path_invalid_to() { + let provider = create_mock_provider(); + let service = create_test_service(provider, true); + let result = service.build_path( + "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC", + "INVALID", + ); + assert!(result.is_err()); + } + + // ==================== Async Quote Tests ==================== + + #[tokio::test] + async fn test_get_token_to_xlm_quote_native_returns_1_to_1() { + let provider = create_mock_provider(); + let service = create_test_service(provider, true); + + let quote = service + .get_token_to_xlm_quote("native", 1_000_000, 0.5, None) + .await + .unwrap(); + + assert_eq!(quote.input_asset, "native"); + assert_eq!(quote.output_asset, "native"); + assert_eq!(quote.in_amount, 1_000_000); + assert_eq!(quote.out_amount, 1_000_000); + assert_eq!(quote.price_impact_pct, 0.0); + assert_eq!(quote.slippage_bps, 50); + assert!(quote.path.is_none()); + } + + #[tokio::test] + async fn test_get_token_to_xlm_quote_empty_returns_1_to_1() { + let provider = create_mock_provider(); + let service = create_test_service(provider, true); + + let quote = service + .get_token_to_xlm_quote("", 1_000_000, 1.0, None) + .await + .unwrap(); + + assert_eq!(quote.input_asset, "native"); + assert_eq!(quote.output_asset, "native"); + assert_eq!(quote.in_amount, quote.out_amount); + } + + #[tokio::test] + async fn test_get_xlm_to_token_quote_native_returns_1_to_1() { + let provider = create_mock_provider(); + let service = create_test_service(provider, true); + + let quote = service + .get_xlm_to_token_quote("native", 1_000_000, 0.5, None) + .await + .unwrap(); + + assert_eq!(quote.input_asset, "native"); + assert_eq!(quote.output_asset, "native"); + assert_eq!(quote.in_amount, 1_000_000); + assert_eq!(quote.out_amount, 1_000_000); + } + + #[tokio::test] + async fn test_get_xlm_to_token_quote_empty_returns_1_to_1() { + let provider = create_mock_provider(); + let service = create_test_service(provider, true); + + let quote = service + .get_xlm_to_token_quote("", 500_000, 0.25, None) + .await + .unwrap(); + + assert_eq!(quote.input_asset, "native"); + assert_eq!(quote.output_asset, "native"); + assert_eq!(quote.slippage_bps, 25); + } + + #[tokio::test] + async fn test_get_token_to_xlm_quote_with_mock_provider() { + let mut mock = MockStellarProviderTrait::new(); + + // Build expected output - amounts vec with input and output + let amounts: Vec = vec![1_000_000, 950_000]; + let sc_vals: Vec = amounts + .iter() + .map(|&a| SoroswapService::::i128_to_scval(a)) + .collect(); + let sc_vec: ScVec = sc_vals.try_into().unwrap(); + let result_scval = ScVal::Vec(Some(sc_vec)); + + mock.expect_call_contract().returning(move |_, _, _| { + let result = result_scval.clone(); + async move { Ok(result) }.boxed() + }); + + let provider = Arc::new(mock); + let service = create_test_service(provider, true); + + let quote = service + .get_token_to_xlm_quote( + "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC", + 1_000_000, + 0.5, + None, + ) + .await + .unwrap(); + + assert_eq!(quote.in_amount, 1_000_000); + assert_eq!(quote.out_amount, 950_000); + assert_eq!(quote.output_asset, "native"); + assert!(quote.path.is_some()); + assert_eq!(quote.path.as_ref().unwrap().len(), 2); + } + + #[tokio::test] + async fn test_get_xlm_to_token_quote_with_mock_provider() { + let mut mock = MockStellarProviderTrait::new(); + + let amounts: Vec = vec![1_000_000, 1_050_000]; + let sc_vals: Vec = amounts + .iter() + .map(|&a| SoroswapService::::i128_to_scval(a)) + .collect(); + let sc_vec: ScVec = sc_vals.try_into().unwrap(); + let result_scval = ScVal::Vec(Some(sc_vec)); + + mock.expect_call_contract().returning(move |_, _, _| { + let result = result_scval.clone(); + async move { Ok(result) }.boxed() + }); + + let provider = Arc::new(mock); + let service = create_test_service(provider, true); + + let quote = service + .get_xlm_to_token_quote( + "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC", + 1_000_000, + 0.5, + None, + ) + .await + .unwrap(); + + assert_eq!(quote.in_amount, 1_000_000); + assert_eq!(quote.out_amount, 1_050_000); + assert_eq!(quote.input_asset, "native"); + } + + #[tokio::test] + async fn test_get_token_to_xlm_quote_empty_amounts_returns_no_path() { + let mut mock = MockStellarProviderTrait::new(); + + // Return empty amounts vec + let sc_vec: ScVec = vec![].try_into().unwrap(); + let result_scval = ScVal::Vec(Some(sc_vec)); + + mock.expect_call_contract().returning(move |_, _, _| { + let result = result_scval.clone(); + async move { Ok(result) }.boxed() + }); + + let provider = Arc::new(mock); + let service = create_test_service(provider, true); + + let result = service + .get_token_to_xlm_quote( + "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC", + 1_000_000, + 0.5, + None, + ) + .await; + + assert!(result.is_err()); + match result.unwrap_err() { + StellarDexServiceError::NoPathFound => {} + e => panic!("Expected NoPathFound error, got {:?}", e), + } + } + + #[tokio::test] + async fn test_get_token_to_xlm_quote_zero_output_returns_no_path() { + let mut mock = MockStellarProviderTrait::new(); + + let amounts: Vec = vec![1_000_000, 0]; + let sc_vals: Vec = amounts + .iter() + .map(|&a| SoroswapService::::i128_to_scval(a)) + .collect(); + let sc_vec: ScVec = sc_vals.try_into().unwrap(); + let result_scval = ScVal::Vec(Some(sc_vec)); + + mock.expect_call_contract().returning(move |_, _, _| { + let result = result_scval.clone(); + async move { Ok(result) }.boxed() + }); + + let provider = Arc::new(mock); + let service = create_test_service(provider, true); + + let result = service + .get_token_to_xlm_quote( + "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC", + 1_000_000, + 0.5, + None, + ) + .await; + + assert!(result.is_err()); + match result.unwrap_err() { + StellarDexServiceError::NoPathFound => {} + e => panic!("Expected NoPathFound error, got {:?}", e), + } + } + + #[tokio::test] + async fn test_get_token_to_xlm_quote_negative_output_returns_no_path() { + let mut mock = MockStellarProviderTrait::new(); + + let amounts: Vec = vec![1_000_000, -100]; + let sc_vals: Vec = amounts + .iter() + .map(|&a| SoroswapService::::i128_to_scval(a)) + .collect(); + let sc_vec: ScVec = sc_vals.try_into().unwrap(); + let result_scval = ScVal::Vec(Some(sc_vec)); + + mock.expect_call_contract().returning(move |_, _, _| { + let result = result_scval.clone(); + async move { Ok(result) }.boxed() + }); + + let provider = Arc::new(mock); + let service = create_test_service(provider, true); + + let result = service + .get_token_to_xlm_quote( + "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC", + 1_000_000, + 0.5, + None, + ) + .await; + + assert!(result.is_err()); + match result.unwrap_err() { + StellarDexServiceError::NoPathFound => {} + e => panic!("Expected NoPathFound error, got {:?}", e), + } + } + + #[tokio::test] + async fn test_get_token_to_xlm_quote_provider_error() { + let mut mock = MockStellarProviderTrait::new(); + + mock.expect_call_contract().returning(|_, _, _| { + async move { + Err(crate::services::provider::ProviderError::Other( + "Connection failed".to_string(), + )) + } + .boxed() + }); + + let provider = Arc::new(mock); + let service = create_test_service(provider, true); + + let result = service + .get_token_to_xlm_quote( + "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC", + 1_000_000, + 0.5, + None, + ) + .await; + + assert!(result.is_err()); + match result.unwrap_err() { + StellarDexServiceError::ApiError { message } => { + assert!(message.contains("router call failed")); + } + e => panic!("Expected ApiError, got {:?}", e), + } + } + + // ==================== prepare_swap_transaction Tests ==================== + + #[tokio::test] + async fn test_prepare_swap_transaction_token_to_native() { + let mut mock = MockStellarProviderTrait::new(); + + let amounts: Vec = vec![1_000_000, 950_000]; + let sc_vals: Vec = amounts + .iter() + .map(|&a| SoroswapService::::i128_to_scval(a)) + .collect(); + let sc_vec: ScVec = sc_vals.try_into().unwrap(); + let result_scval = ScVal::Vec(Some(sc_vec)); + + mock.expect_call_contract().returning(move |_, _, _| { + let result = result_scval.clone(); + async move { Ok(result) }.boxed() + }); + + let provider = Arc::new(mock); + let service = create_test_service(provider, true); + + let params = SwapTransactionParams { + source_account: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF".to_string(), + source_asset: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC".to_string(), + destination_asset: "native".to_string(), + amount: 1_000_000, + slippage_percent: 0.5, + network_passphrase: "Test SDF Network ; September 2015".to_string(), + source_asset_decimals: Some(7), + destination_asset_decimals: None, + }; + + let (xdr, quote) = service.prepare_swap_transaction(params).await.unwrap(); + + assert!(xdr.is_empty()); // Placeholder + assert_eq!(quote.out_amount, 950_000); + } + + #[tokio::test] + async fn test_prepare_swap_transaction_native_to_token() { + let mut mock = MockStellarProviderTrait::new(); + + let amounts: Vec = vec![1_000_000, 1_050_000]; + let sc_vals: Vec = amounts + .iter() + .map(|&a| SoroswapService::::i128_to_scval(a)) + .collect(); + let sc_vec: ScVec = sc_vals.try_into().unwrap(); + let result_scval = ScVal::Vec(Some(sc_vec)); + + mock.expect_call_contract().returning(move |_, _, _| { + let result = result_scval.clone(); + async move { Ok(result) }.boxed() + }); + + let provider = Arc::new(mock); + let service = create_test_service(provider, true); + + let params = SwapTransactionParams { + source_account: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF".to_string(), + source_asset: "native".to_string(), + destination_asset: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC" + .to_string(), + amount: 1_000_000, + slippage_percent: 0.5, + network_passphrase: "Test SDF Network ; September 2015".to_string(), + source_asset_decimals: None, + destination_asset_decimals: Some(7), + }; + + let (xdr, quote) = service.prepare_swap_transaction(params).await.unwrap(); + + assert!(xdr.is_empty()); // Placeholder + assert_eq!(quote.out_amount, 1_050_000); + } + + #[tokio::test] + async fn test_prepare_swap_transaction_token_to_token_not_supported() { + let provider = create_mock_provider(); + let service = create_test_service(provider, true); + + let params = SwapTransactionParams { + source_account: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF".to_string(), + source_asset: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC".to_string(), + destination_asset: "CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA" + .to_string(), + amount: 1_000_000, + slippage_percent: 0.5, + network_passphrase: "Test SDF Network ; September 2015".to_string(), + source_asset_decimals: Some(7), + destination_asset_decimals: Some(7), + }; + + let result = service.prepare_swap_transaction(params).await; + + assert!(result.is_err()); + match result.unwrap_err() { + StellarDexServiceError::InvalidAssetIdentifier(msg) => { + assert!(msg.contains("only supports swaps involving native XLM")); + } + e => panic!("Expected InvalidAssetIdentifier, got {:?}", e), + } + } + + // ==================== execute_swap Tests ==================== + + #[tokio::test] + async fn test_execute_swap_not_implemented() { + let provider = create_mock_provider(); + let service = create_test_service(provider, true); + + let params = SwapTransactionParams { + source_account: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF".to_string(), + source_asset: "native".to_string(), + destination_asset: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC" + .to_string(), + amount: 1_000_000, + slippage_percent: 0.5, + network_passphrase: "Test SDF Network ; September 2015".to_string(), + source_asset_decimals: None, + destination_asset_decimals: Some(7), + }; + + let result = service.execute_swap(params).await; + + assert!(result.is_err()); + match result.unwrap_err() { + StellarDexServiceError::UnknownError(msg) => { + assert!(msg.contains("not yet implemented")); + } + e => panic!("Expected UnknownError, got {:?}", e), + } + } + + // ==================== Price Impact Calculation Tests ==================== + + #[tokio::test] + async fn test_price_impact_calculation() { + let mut mock = MockStellarProviderTrait::new(); + + // 10% price impact: in 1_000_000, out 900_000 + let amounts: Vec = vec![1_000_000, 900_000]; + let sc_vals: Vec = amounts + .iter() + .map(|&a| SoroswapService::::i128_to_scval(a)) + .collect(); + let sc_vec: ScVec = sc_vals.try_into().unwrap(); + let result_scval = ScVal::Vec(Some(sc_vec)); + + mock.expect_call_contract().returning(move |_, _, _| { + let result = result_scval.clone(); + async move { Ok(result) }.boxed() + }); + + let provider = Arc::new(mock); + let service = create_test_service(provider, true); + + let quote = service + .get_token_to_xlm_quote( + "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC", + 1_000_000, + 0.5, + None, + ) + .await + .unwrap(); + + // Price impact should be around 10% + assert!(quote.price_impact_pct > 9.0 && quote.price_impact_pct < 11.0); + } } diff --git a/src/services/stellar_dex/stellar_dex_service.rs b/src/services/stellar_dex/stellar_dex_service.rs index 8902b218a..dbabf3134 100644 --- a/src/services/stellar_dex/stellar_dex_service.rs +++ b/src/services/stellar_dex/stellar_dex_service.rs @@ -208,3 +208,496 @@ where } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::SignerError; + use crate::services::provider::MockStellarProviderTrait; + use crate::services::signer::{MockStellarSignTrait, Signer}; + use async_trait::async_trait; + + // ==================== Mock Setup ==================== + + /// Combined mock that implements both StellarSignTrait and Signer + struct MockCombinedSigner { + stellar_mock: MockStellarSignTrait, + } + + impl MockCombinedSigner { + fn new() -> Self { + Self { + stellar_mock: MockStellarSignTrait::new(), + } + } + } + + #[async_trait] + impl StellarSignTrait for MockCombinedSigner { + async fn sign_xdr_transaction( + &self, + unsigned_xdr: &str, + network_passphrase: &str, + ) -> Result + { + self.stellar_mock + .sign_xdr_transaction(unsigned_xdr, network_passphrase) + .await + } + } + + #[async_trait] + impl Signer for MockCombinedSigner { + async fn address(&self) -> Result { + Ok(crate::models::Address::Stellar( + "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF".to_string(), + )) + } + + async fn sign_transaction( + &self, + _transaction: crate::models::NetworkTransactionData, + ) -> Result { + Ok(crate::domain::SignTransactionResponse::Stellar( + crate::domain::SignTransactionResponseStellar { + signature: crate::models::DecoratedSignature { + hint: soroban_rs::xdr::SignatureHint([0; 4]), + signature: soroban_rs::xdr::Signature( + soroban_rs::xdr::BytesM::try_from(vec![0u8; 64]).unwrap(), + ), + }, + }, + )) + } + } + + // Test constants + const CLASSIC_ASSET: &str = "USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN"; + const CONTRACT_ASSET: &str = "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC"; + + /// Create a test OrderBookService wrapped in Arc + fn create_order_book_service( + ) -> Arc> { + let provider = Arc::new(MockStellarProviderTrait::new()); + let signer = Arc::new(MockCombinedSigner::new()); + Arc::new( + OrderBookService::new( + "https://horizon-testnet.stellar.org".to_string(), + provider, + signer, + ) + .expect("Failed to create OrderBookService"), + ) + } + + /// Create a test SoroswapService wrapped in Arc + fn create_soroswap_service() -> Arc> { + let provider = Arc::new(MockStellarProviderTrait::new()); + Arc::new(SoroswapService::new( + "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC".to_string(), + "CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA".to_string(), + None, + provider, + "Test SDF Network ; September 2015".to_string(), + true, + )) + } + + /// Create a StellarDexService with both OrderBook and Soroswap strategies + fn create_multi_strategy_service( + ) -> StellarDexService { + let order_book = DexServiceWrapper::OrderBook(create_order_book_service()); + let soroswap = DexServiceWrapper::Soroswap(create_soroswap_service()); + StellarDexService::new(vec![order_book, soroswap]) + } + + /// Create a StellarDexService with only OrderBook strategy + fn create_order_book_only_service( + ) -> StellarDexService { + let order_book = DexServiceWrapper::OrderBook(create_order_book_service()); + StellarDexService::new(vec![order_book]) + } + + /// Create a StellarDexService with only Soroswap strategy + fn create_soroswap_only_service( + ) -> StellarDexService { + let soroswap = DexServiceWrapper::Soroswap(create_soroswap_service()); + StellarDexService::new(vec![soroswap]) + } + + /// Create a StellarDexService with no strategies + fn create_empty_service() -> StellarDexService { + StellarDexService::new(vec![]) + } + + /// Create SwapTransactionParams for testing + fn create_swap_params(source_asset: &str) -> SwapTransactionParams { + SwapTransactionParams { + source_account: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF".to_string(), + source_asset: source_asset.to_string(), + destination_asset: "native".to_string(), + amount: 1000000, + slippage_percent: 1.0, + network_passphrase: "Test SDF Network ; September 2015".to_string(), + source_asset_decimals: Some(7), + destination_asset_decimals: Some(7), + } + } + + // ==================== DexServiceWrapper Tests ==================== + + #[test] + fn test_wrapper_order_book_can_handle_native() { + let wrapper: DexServiceWrapper = + DexServiceWrapper::OrderBook(create_order_book_service()); + assert!(wrapper.can_handle_asset("native")); + } + + #[test] + fn test_wrapper_order_book_can_handle_classic_asset() { + let wrapper: DexServiceWrapper = + DexServiceWrapper::OrderBook(create_order_book_service()); + assert!(wrapper.can_handle_asset(CLASSIC_ASSET)); + } + + #[test] + fn test_wrapper_order_book_cannot_handle_contract() { + let wrapper: DexServiceWrapper = + DexServiceWrapper::OrderBook(create_order_book_service()); + assert!(!wrapper.can_handle_asset(CONTRACT_ASSET)); + } + + #[test] + fn test_wrapper_soroswap_can_handle_native() { + let wrapper: DexServiceWrapper = + DexServiceWrapper::Soroswap(create_soroswap_service()); + assert!(wrapper.can_handle_asset("native")); + } + + #[test] + fn test_wrapper_soroswap_can_handle_contract() { + let wrapper: DexServiceWrapper = + DexServiceWrapper::Soroswap(create_soroswap_service()); + assert!(wrapper.can_handle_asset(CONTRACT_ASSET)); + } + + #[test] + fn test_wrapper_soroswap_cannot_handle_classic_asset() { + let wrapper: DexServiceWrapper = + DexServiceWrapper::Soroswap(create_soroswap_service()); + assert!(!wrapper.can_handle_asset(CLASSIC_ASSET)); + } + + #[test] + fn test_wrapper_order_book_supported_asset_types() { + let wrapper: DexServiceWrapper = + DexServiceWrapper::OrderBook(create_order_book_service()); + let types = wrapper.supported_asset_types(); + assert!(types.contains(&AssetType::Native)); + assert!(types.contains(&AssetType::Classic)); + assert!(!types.contains(&AssetType::Contract)); + } + + #[test] + fn test_wrapper_soroswap_supported_asset_types() { + let wrapper: DexServiceWrapper = + DexServiceWrapper::Soroswap(create_soroswap_service()); + let types = wrapper.supported_asset_types(); + assert!(types.contains(&AssetType::Native)); + assert!(types.contains(&AssetType::Contract)); + assert!(!types.contains(&AssetType::Classic)); + } + + // ==================== StellarDexService Constructor Tests ==================== + + #[test] + fn test_new_with_multiple_strategies() { + let service = create_multi_strategy_service(); + assert_eq!(service.strategies.len(), 2); + } + + #[test] + fn test_new_with_single_strategy() { + let service = create_order_book_only_service(); + assert_eq!(service.strategies.len(), 1); + } + + #[test] + fn test_new_with_empty_strategies() { + let service = create_empty_service(); + assert_eq!(service.strategies.len(), 0); + } + + // ==================== find_strategy_for_asset Tests ==================== + + #[test] + fn test_find_strategy_for_native_asset() { + let service = create_multi_strategy_service(); + let strategy = service.find_strategy_for_asset("native"); + assert!(strategy.is_some()); + } + + #[test] + fn test_find_strategy_for_classic_asset() { + let service = create_multi_strategy_service(); + let strategy = service.find_strategy_for_asset(CLASSIC_ASSET); + assert!(strategy.is_some()); + // Verify it's the OrderBook strategy + assert!(matches!(strategy.unwrap(), DexServiceWrapper::OrderBook(_))); + } + + #[test] + fn test_find_strategy_for_contract_asset() { + let service = create_multi_strategy_service(); + let strategy = service.find_strategy_for_asset(CONTRACT_ASSET); + assert!(strategy.is_some()); + // Verify it's the Soroswap strategy + assert!(matches!(strategy.unwrap(), DexServiceWrapper::Soroswap(_))); + } + + #[test] + fn test_find_strategy_returns_none_for_unhandled_asset() { + let service = create_order_book_only_service(); + // Contract assets are not handled by OrderBook + let strategy = service.find_strategy_for_asset(CONTRACT_ASSET); + assert!(strategy.is_none()); + } + + #[test] + fn test_find_strategy_returns_none_for_empty_service() { + let service = create_empty_service(); + let strategy = service.find_strategy_for_asset("native"); + assert!(strategy.is_none()); + } + + #[test] + fn test_find_strategy_priority_order() { + // OrderBook comes first, so it should handle native assets + let service = create_multi_strategy_service(); + let strategy = service.find_strategy_for_asset("native"); + assert!(strategy.is_some()); + // First strategy (OrderBook) should be selected for native + assert!(matches!(strategy.unwrap(), DexServiceWrapper::OrderBook(_))); + } + + // ==================== StellarDexServiceTrait::supported_asset_types Tests ==================== + + #[test] + fn test_supported_asset_types_union_of_all_strategies() { + let service = create_multi_strategy_service(); + let types = service.supported_asset_types(); + // Should include Native, Classic (from OrderBook), and Contract (from Soroswap) + assert!(types.contains(&AssetType::Native)); + assert!(types.contains(&AssetType::Classic)); + assert!(types.contains(&AssetType::Contract)); + assert_eq!(types.len(), 3); + } + + #[test] + fn test_supported_asset_types_order_book_only() { + let service = create_order_book_only_service(); + let types = service.supported_asset_types(); + assert!(types.contains(&AssetType::Native)); + assert!(types.contains(&AssetType::Classic)); + assert!(!types.contains(&AssetType::Contract)); + } + + #[test] + fn test_supported_asset_types_soroswap_only() { + let service = create_soroswap_only_service(); + let types = service.supported_asset_types(); + assert!(types.contains(&AssetType::Native)); + assert!(types.contains(&AssetType::Contract)); + assert!(!types.contains(&AssetType::Classic)); + } + + #[test] + fn test_supported_asset_types_empty_service() { + let service = create_empty_service(); + let types = service.supported_asset_types(); + assert!(types.is_empty()); + } + + // ==================== StellarDexServiceTrait::can_handle_asset Tests ==================== + + #[test] + fn test_can_handle_asset_native() { + let service = create_multi_strategy_service(); + assert!(service.can_handle_asset("native")); + } + + #[test] + fn test_can_handle_asset_empty_string() { + let service = create_multi_strategy_service(); + assert!(service.can_handle_asset("")); + } + + #[test] + fn test_can_handle_asset_classic() { + let service = create_multi_strategy_service(); + assert!(service.can_handle_asset(CLASSIC_ASSET)); + } + + #[test] + fn test_can_handle_asset_contract() { + let service = create_multi_strategy_service(); + assert!(service.can_handle_asset(CONTRACT_ASSET)); + } + + #[test] + fn test_cannot_handle_contract_with_order_book_only() { + let service = create_order_book_only_service(); + assert!(!service.can_handle_asset(CONTRACT_ASSET)); + } + + #[test] + fn test_cannot_handle_classic_with_soroswap_only() { + let service = create_soroswap_only_service(); + assert!(!service.can_handle_asset(CLASSIC_ASSET)); + } + + #[test] + fn test_cannot_handle_any_asset_with_empty_service() { + let service = create_empty_service(); + assert!(!service.can_handle_asset("native")); + assert!(!service.can_handle_asset(CLASSIC_ASSET)); + assert!(!service.can_handle_asset(CONTRACT_ASSET)); + } + + #[test] + fn test_cannot_handle_invalid_asset() { + let service = create_multi_strategy_service(); + assert!(!service.can_handle_asset("INVALID")); + assert!(!service.can_handle_asset("random_string")); + } + + // ==================== get_token_to_xlm_quote Error Tests ==================== + + #[tokio::test] + async fn test_get_token_to_xlm_quote_no_strategy_error() { + let service = create_empty_service(); + let result = service + .get_token_to_xlm_quote("native", 1000000, 1.0, Some(7)) + .await; + assert!(result.is_err()); + match result.unwrap_err() { + StellarDexServiceError::InvalidAssetIdentifier(msg) => { + assert!(msg.contains("No configured strategy can handle asset")); + } + _ => panic!("Expected InvalidAssetIdentifier error"), + } + } + + #[tokio::test] + async fn test_get_token_to_xlm_quote_unhandled_asset_error() { + let service = create_order_book_only_service(); + // Contract assets are not handled by OrderBook + let result = service + .get_token_to_xlm_quote(CONTRACT_ASSET, 1000000, 1.0, Some(7)) + .await; + assert!(result.is_err()); + match result.unwrap_err() { + StellarDexServiceError::InvalidAssetIdentifier(msg) => { + assert!(msg.contains("No configured strategy can handle asset")); + assert!(msg.contains(CONTRACT_ASSET)); + } + _ => panic!("Expected InvalidAssetIdentifier error"), + } + } + + // ==================== get_xlm_to_token_quote Error Tests ==================== + + #[tokio::test] + async fn test_get_xlm_to_token_quote_no_strategy_error() { + let service = create_empty_service(); + let result = service + .get_xlm_to_token_quote("native", 1000000, 1.0, Some(7)) + .await; + assert!(result.is_err()); + match result.unwrap_err() { + StellarDexServiceError::InvalidAssetIdentifier(msg) => { + assert!(msg.contains("No configured strategy can handle asset")); + } + _ => panic!("Expected InvalidAssetIdentifier error"), + } + } + + #[tokio::test] + async fn test_get_xlm_to_token_quote_unhandled_asset_error() { + let service = create_soroswap_only_service(); + // Classic assets are not handled by Soroswap + let result = service + .get_xlm_to_token_quote(CLASSIC_ASSET, 1000000, 1.0, Some(7)) + .await; + assert!(result.is_err()); + match result.unwrap_err() { + StellarDexServiceError::InvalidAssetIdentifier(msg) => { + assert!(msg.contains("No configured strategy can handle asset")); + assert!(msg.contains(CLASSIC_ASSET)); + } + _ => panic!("Expected InvalidAssetIdentifier error"), + } + } + + // ==================== prepare_swap_transaction Error Tests ==================== + + #[tokio::test] + async fn test_prepare_swap_transaction_no_strategy_error() { + let service = create_empty_service(); + let params = create_swap_params("native"); + let result = service.prepare_swap_transaction(params).await; + assert!(result.is_err()); + match result.unwrap_err() { + StellarDexServiceError::InvalidAssetIdentifier(msg) => { + assert!(msg.contains("No configured strategy can handle asset")); + } + _ => panic!("Expected InvalidAssetIdentifier error"), + } + } + + #[tokio::test] + async fn test_prepare_swap_transaction_unhandled_asset_error() { + let service = create_order_book_only_service(); + let params = create_swap_params(CONTRACT_ASSET); + let result = service.prepare_swap_transaction(params).await; + assert!(result.is_err()); + match result.unwrap_err() { + StellarDexServiceError::InvalidAssetIdentifier(msg) => { + assert!(msg.contains("No configured strategy can handle asset")); + assert!(msg.contains(CONTRACT_ASSET)); + } + _ => panic!("Expected InvalidAssetIdentifier error"), + } + } + + // ==================== execute_swap Error Tests ==================== + + #[tokio::test] + async fn test_execute_swap_no_strategy_error() { + let service = create_empty_service(); + let params = create_swap_params("native"); + let result = service.execute_swap(params).await; + assert!(result.is_err()); + match result.unwrap_err() { + StellarDexServiceError::InvalidAssetIdentifier(msg) => { + assert!(msg.contains("No configured strategy can handle asset")); + } + _ => panic!("Expected InvalidAssetIdentifier error"), + } + } + + #[tokio::test] + async fn test_execute_swap_unhandled_asset_error() { + let service = create_soroswap_only_service(); + let params = create_swap_params(CLASSIC_ASSET); + let result = service.execute_swap(params).await; + assert!(result.is_err()); + match result.unwrap_err() { + StellarDexServiceError::InvalidAssetIdentifier(msg) => { + assert!(msg.contains("No configured strategy can handle asset")); + assert!(msg.contains(CLASSIC_ASSET)); + } + _ => panic!("Expected InvalidAssetIdentifier error"), + } + } +} From 43d36d62e888d7380dc6b2c35dd7221ee4f08121 Mon Sep 17 00:00:00 2001 From: Nicolas Molina Date: Thu, 5 Feb 2026 11:18:30 -0300 Subject: [PATCH 10/22] fix: Improvements --- helpers/sign_soroban_auth_entry.rs | 4 +- src/constants/stellar_transaction.rs | 111 +++ src/domain/relayer/stellar/gas_abstraction.rs | 29 +- src/domain/relayer/stellar/mod.rs | 205 +--- src/domain/transaction/stellar/prepare/mod.rs | 12 +- .../prepare/soroban_gas_abstraction.rs | 912 +++++++++++++++++- src/services/stellar_dex/soroswap_service.rs | 28 +- 7 files changed, 1064 insertions(+), 237 deletions(-) diff --git a/helpers/sign_soroban_auth_entry.rs b/helpers/sign_soroban_auth_entry.rs index 83179730f..45b96c7d8 100644 --- a/helpers/sign_soroban_auth_entry.rs +++ b/helpers/sign_soroban_auth_entry.rs @@ -142,7 +142,6 @@ fn sign_auth_entry( let network_id_bytes: [u8; 32] = Sha256::digest(network_passphrase.as_bytes()).into(); let network_id = Hash(network_id_bytes); - eprintln!("Network: {}", network_passphrase); eprintln!("Nonce: {}", addr_creds.nonce); eprintln!( "Signature expiration ledger: {}", @@ -249,8 +248,7 @@ fn main() -> Result<()> { println!("{}", serde_json::to_string_pretty(&payload)?); } None => { - // Output just the signed auth entry - println!("{}", signed_auth_entry); + eprintln!("Auth entry signed successfully"); } } diff --git a/src/constants/stellar_transaction.rs b/src/constants/stellar_transaction.rs index 8d757703f..e9fc32ee8 100644 --- a/src/constants/stellar_transaction.rs +++ b/src/constants/stellar_transaction.rs @@ -2,9 +2,22 @@ //! //! This module contains default values used throughout the Stellar transaction //! handling logic, including fees, retry delays, and timeout thresholds. +//! +//! ## Gas Abstraction Contract Addresses +//! +//! This module also contains default contract addresses for Soroban gas abstraction: +//! - **Soroswap**: AMM DEX for token-to-XLM quotes and swaps +//! - **FeeForwarder**: Contract that enables fee payment in tokens instead of XLM +//! +//! These addresses can be overridden via environment variables if needed. +//! See `ServerConfig` for the corresponding env var names. use chrono::Duration; +// ============================================================================= +// Transaction Fees +// ============================================================================= + pub const STELLAR_DEFAULT_TRANSACTION_FEE: u32 = 100; /// Default maximum fee for fee-bump transactions (0.1 XLM = 1,000,000 stroops) pub const STELLAR_DEFAULT_MAX_FEE: i64 = 1_000_000; @@ -59,3 +72,101 @@ pub fn get_stellar_resend_timeout() -> Duration { pub fn get_stellar_max_stuck_transaction_lifetime() -> Duration { Duration::minutes(STELLAR_MAX_STUCK_TRANSACTION_LIFETIME_MINUTES) } + +// ============================================================================= +// Soroswap DEX Contract Addresses +// ============================================================================= +// Official Soroswap deployments from: +// - Mainnet: https://github.com/soroswap/core/blob/main/public/mainnet.contracts.json +// - Testnet: https://github.com/soroswap/core/blob/main/public/testnet.contracts.json + +/// Soroswap router contract address on Stellar mainnet +pub const STELLAR_SOROSWAP_MAINNET_ROUTER: &str = + "CAG5LRYQ5JVEUI5TEID72EYOVX44TTUJT5BQR2J6J77FH65PCCFAJDDH"; + +/// Soroswap router contract address on Stellar testnet +pub const STELLAR_SOROSWAP_TESTNET_ROUTER: &str = + "CCJUD55AG6W5HAI5LRVNKAE5WDP5XGZBUDS5WNTIVDU7O264UZZE7BRD"; + +/// Soroswap factory contract address on Stellar mainnet +pub const STELLAR_SOROSWAP_MAINNET_FACTORY: &str = + "CA4HEQTL2WPEUYKYKCDOHCDNIV4QHNJ7EL4J4NQ6VADP7SYHVRYZ7AW2"; + +/// Soroswap factory contract address on Stellar testnet +pub const STELLAR_SOROSWAP_TESTNET_FACTORY: &str = + "CDP3HMUH6SMS3S7NPGNDJLULCOXXEPSHY4JKUKMBNQMATHDHWXRRJTBY"; + +/// Native XLM wrapper token contract address on Stellar mainnet +/// This is the Soroban token contract that wraps native XLM for use in Soroswap +pub const STELLAR_SOROSWAP_MAINNET_NATIVE_WRAPPER: &str = + "CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA"; + +/// Native XLM wrapper token contract address on Stellar testnet +pub const STELLAR_SOROSWAP_TESTNET_NATIVE_WRAPPER: &str = + "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC"; + +// ============================================================================= +// FeeForwarder Contract Addresses +// ============================================================================= +// The FeeForwarder contract enables gas abstraction by allowing users to pay +// transaction fees in tokens instead of native XLM. + +/// FeeForwarder contract address on Stellar mainnet +/// Set to empty string until mainnet deployment is available +pub const STELLAR_FEE_FORWARDER_MAINNET: &str = ""; + +/// FeeForwarder contract address on Stellar testnet +pub const STELLAR_FEE_FORWARDER_TESTNET: &str = + "CADRAMMYU62IUEQZEKG5MYVL7DYMH424ETG7QRZ5BVGP552DZNR4MH2Q"; + +// ============================================================================= +// Helper Functions for Network-Aware Address Resolution +// ============================================================================= + +/// Get the default Soroswap router contract address for the given network +/// +/// Returns the official Soroswap router deployment address. +/// Users can override this via the `STELLAR_SOROSWAP_ROUTER_ADDRESS` env var. +pub fn get_default_soroswap_router(is_testnet: bool) -> &'static str { + if is_testnet { + STELLAR_SOROSWAP_TESTNET_ROUTER + } else { + STELLAR_SOROSWAP_MAINNET_ROUTER + } +} + +/// Get the default Soroswap factory contract address for the given network +/// +/// Returns the official Soroswap factory deployment address. +/// Users can override this via the `STELLAR_SOROSWAP_FACTORY_ADDRESS` env var. +pub fn get_default_soroswap_factory(is_testnet: bool) -> &'static str { + if is_testnet { + STELLAR_SOROSWAP_TESTNET_FACTORY + } else { + STELLAR_SOROSWAP_MAINNET_FACTORY + } +} + +/// Get the default native XLM wrapper token address for the given network +/// +/// Returns the Soroban token contract that wraps native XLM for use in Soroswap. +/// Users can override this via the `STELLAR_SOROSWAP_NATIVE_WRAPPER_ADDRESS` env var. +pub fn get_default_soroswap_native_wrapper(is_testnet: bool) -> &'static str { + if is_testnet { + STELLAR_SOROSWAP_TESTNET_NATIVE_WRAPPER + } else { + STELLAR_SOROSWAP_MAINNET_NATIVE_WRAPPER + } +} + +/// Get the default FeeForwarder contract address for the given network +/// +/// Returns the FeeForwarder deployment address, or None if not yet deployed. +/// Users can override this via the `STELLAR_FEE_FORWARDER_ADDRESS` env var. +pub fn get_default_fee_forwarder(is_testnet: bool) -> &'static str { + if is_testnet { + STELLAR_FEE_FORWARDER_TESTNET + } else { + STELLAR_FEE_FORWARDER_MAINNET + } +} diff --git a/src/domain/relayer/stellar/gas_abstraction.rs b/src/domain/relayer/stellar/gas_abstraction.rs index 26ea83db8..a89bbe954 100644 --- a/src/domain/relayer/stellar/gas_abstraction.rs +++ b/src/domain/relayer/stellar/gas_abstraction.rs @@ -9,7 +9,8 @@ use soroban_rs::xdr::{Limits, Operation, TransactionEnvelope, WriteXdr}; use tracing::debug; use crate::constants::{ - get_stellar_sponsored_transaction_validity_duration, STELLAR_DEFAULT_TRANSACTION_FEE, + get_default_fee_forwarder, get_stellar_sponsored_transaction_validity_duration, + STELLAR_DEFAULT_TRANSACTION_FEE, }; /// Default slippage tolerance for max_fee_amount in basis points (500 = 5%) @@ -521,12 +522,19 @@ where StellarTransactionValidator::validate_allowed_token(¶ms.fee_token, &policy) .map_err(|e| RelayerError::ValidationError(e.to_string()))?; - // Validate fee_forwarder is configured in server config + // Get fee_forwarder address: env var override takes precedence, otherwise use network default let fee_forwarder = crate::config::ServerConfig::get_stellar_fee_forwarder_address() + .or_else(|| { + let default = get_default_fee_forwarder(self.network.is_testnet()); + if default.is_empty() { + None + } else { + Some(default.to_string()) + } + }) .ok_or_else(|| { RelayerError::ValidationError( - "STELLAR_FEE_FORWARDER_ADDRESS env var is required for gas abstraction" - .to_string(), + "FeeForwarder address not configured. Set STELLAR_FEE_FORWARDER_ADDRESS env var or wait for default deployment.".to_string(), ) })?; @@ -673,12 +681,19 @@ where // Note: validate_allowed_token is already called in build_sponsored_transaction - // Get fee_forwarder address from server config + // Get fee_forwarder address: env var override takes precedence, otherwise use network default let fee_forwarder = crate::config::ServerConfig::get_stellar_fee_forwarder_address() + .or_else(|| { + let default = get_default_fee_forwarder(self.network.is_testnet()); + if default.is_empty() { + None + } else { + Some(default.to_string()) + } + }) .ok_or_else(|| { RelayerError::ValidationError( - "STELLAR_FEE_FORWARDER_ADDRESS env var is required for gas abstraction" - .to_string(), + "FeeForwarder address not configured. Set STELLAR_FEE_FORWARDER_ADDRESS env var or wait for default deployment.".to_string(), ) })?; diff --git a/src/domain/relayer/stellar/mod.rs b/src/domain/relayer/stellar/mod.rs index d9dd5983c..ffe68d796 100644 --- a/src/domain/relayer/stellar/mod.rs +++ b/src/domain/relayer/stellar/mod.rs @@ -12,7 +12,10 @@ pub use crate::services::stellar_dex::StellarDexServiceTrait; use std::sync::Arc; use crate::{ - constants::{STELLAR_HORIZON_MAINNET_URL, STELLAR_HORIZON_TESTNET_URL}, + constants::{ + get_default_soroswap_factory, get_default_soroswap_router, STELLAR_HORIZON_MAINNET_URL, + STELLAR_HORIZON_TESTNET_URL, + }, jobs::JobProducerTrait, models::{ NetworkRepoModel, NetworkType, RelayerError, RelayerRepoModel, SignerRepoModel, @@ -117,12 +120,16 @@ pub async fn create_stellar_relayer< // Get Soroswap router address from server config, falling back to default let router_address = crate::config::ServerConfig::get_stellar_soroswap_router_address() - .unwrap_or_else(|| get_default_soroswap_router(network.is_testnet())); + .unwrap_or_else(|| { + get_default_soroswap_router(network.is_testnet()).to_string() + }); // Get Soroswap factory address from server config, falling back to default let factory_address = crate::config::ServerConfig::get_stellar_soroswap_factory_address() - .unwrap_or_else(|| get_default_soroswap_factory(network.is_testnet())); + .unwrap_or_else(|| { + get_default_soroswap_factory(network.is_testnet()).to_string() + }); // Get native wrapper address from server config if configured let native_wrapper_address = @@ -162,195 +169,3 @@ pub async fn create_stellar_relayer< Ok(relayer) } - -/// Get the default Soroswap router contract address for the given network -/// -/// These addresses are the official Soroswap router deployments. -/// Users can override these via configuration. -fn get_default_soroswap_router(is_testnet: bool) -> String { - if is_testnet { - // Soroswap testnet router - "CCJUD55AG6W5HAI5LRVNKAE5WDP5XGZBUDS5WNTIVDU7O264UZZE7BRD".to_string() - } else { - // Soroswap mainnet router - "CAG5LRYQ5JVEUI5TEID72EYOVX44TTUJT5BQR2J6J77FH65PCCFAJDDH".to_string() - } -} - -/// Get the default Soroswap factory contract address for the given network -/// -/// These addresses are the official Soroswap factory deployments. -/// Users can override these via configuration. -fn get_default_soroswap_factory(is_testnet: bool) -> String { - if is_testnet { - // Soroswap testnet factory - "CDP3HMUH6SMS3S7NPGNDJLULCOXXEPSHY4JKUKMBNQMATHDHWXRRJTBY".to_string() - } else { - // Soroswap mainnet factory - "CA4HEQTL2WPEUYKYKCDOHCDNIV4QHNJ7EL4J4NQ6VADP7SYHVRYZ7AW2".to_string() - } -} - -#[cfg(test)] -mod tests { - use super::*; - - mod get_default_soroswap_router_tests { - use super::*; - - #[test] - fn test_returns_testnet_router_address_when_testnet() { - let router = get_default_soroswap_router(true); - assert_eq!( - router, "CCJUD55AG6W5HAI5LRVNKAE5WDP5XGZBUDS5WNTIVDU7O264UZZE7BRD", - "Should return Soroswap testnet router address" - ); - } - - #[test] - fn test_returns_mainnet_router_address_when_mainnet() { - let router = get_default_soroswap_router(false); - assert_eq!( - router, "CAG5LRYQ5JVEUI5TEID72EYOVX44TTUJT5BQR2J6J77FH65PCCFAJDDH", - "Should return Soroswap mainnet router address" - ); - } - - #[test] - fn test_testnet_and_mainnet_router_addresses_are_different() { - let testnet_router = get_default_soroswap_router(true); - let mainnet_router = get_default_soroswap_router(false); - assert_ne!( - testnet_router, mainnet_router, - "Testnet and mainnet router addresses should be different" - ); - } - - #[test] - fn test_router_addresses_are_valid_stellar_contract_addresses() { - let testnet_router = get_default_soroswap_router(true); - let mainnet_router = get_default_soroswap_router(false); - - // Stellar contract addresses start with 'C' and are 56 characters long - assert!( - testnet_router.starts_with('C'), - "Testnet router should start with 'C'" - ); - assert_eq!( - testnet_router.len(), - 56, - "Testnet router should be 56 characters" - ); - - assert!( - mainnet_router.starts_with('C'), - "Mainnet router should start with 'C'" - ); - assert_eq!( - mainnet_router.len(), - 56, - "Mainnet router should be 56 characters" - ); - } - } - - mod get_default_soroswap_factory_tests { - use super::*; - - #[test] - fn test_returns_testnet_factory_address_when_testnet() { - let factory = get_default_soroswap_factory(true); - assert_eq!( - factory, "CDP3HMUH6SMS3S7NPGNDJLULCOXXEPSHY4JKUKMBNQMATHDHWXRRJTBY", - "Should return Soroswap testnet factory address" - ); - } - - #[test] - fn test_returns_mainnet_factory_address_when_mainnet() { - let factory = get_default_soroswap_factory(false); - assert_eq!( - factory, "CA4HEQTL2WPEUYKYKCDOHCDNIV4QHNJ7EL4J4NQ6VADP7SYHVRYZ7AW2", - "Should return Soroswap mainnet factory address" - ); - } - - #[test] - fn test_testnet_and_mainnet_factory_addresses_are_different() { - let testnet_factory = get_default_soroswap_factory(true); - let mainnet_factory = get_default_soroswap_factory(false); - assert_ne!( - testnet_factory, mainnet_factory, - "Testnet and mainnet factory addresses should be different" - ); - } - - #[test] - fn test_factory_addresses_are_valid_stellar_contract_addresses() { - let testnet_factory = get_default_soroswap_factory(true); - let mainnet_factory = get_default_soroswap_factory(false); - - // Stellar contract addresses start with 'C' and are 56 characters long - assert!( - testnet_factory.starts_with('C'), - "Testnet factory should start with 'C'" - ); - assert_eq!( - testnet_factory.len(), - 56, - "Testnet factory should be 56 characters" - ); - - assert!( - mainnet_factory.starts_with('C'), - "Mainnet factory should start with 'C'" - ); - assert_eq!( - mainnet_factory.len(), - 56, - "Mainnet factory should be 56 characters" - ); - } - } - - mod soroswap_address_consistency_tests { - use super::*; - - #[test] - fn test_router_and_factory_addresses_are_different() { - let testnet_router = get_default_soroswap_router(true); - let testnet_factory = get_default_soroswap_factory(true); - let mainnet_router = get_default_soroswap_router(false); - let mainnet_factory = get_default_soroswap_factory(false); - - assert_ne!( - testnet_router, testnet_factory, - "Testnet router and factory should be different contracts" - ); - assert_ne!( - mainnet_router, mainnet_factory, - "Mainnet router and factory should be different contracts" - ); - } - - #[test] - fn test_all_four_addresses_are_unique() { - let addresses = vec![ - get_default_soroswap_router(true), - get_default_soroswap_router(false), - get_default_soroswap_factory(true), - get_default_soroswap_factory(false), - ]; - - let mut unique_addresses = addresses.clone(); - unique_addresses.sort(); - unique_addresses.dedup(); - - assert_eq!( - addresses.len(), - unique_addresses.len(), - "All Soroswap contract addresses should be unique" - ); - } - } -} diff --git a/src/domain/transaction/stellar/prepare/mod.rs b/src/domain/transaction/stellar/prepare/mod.rs index 6e9f4b98a..a47ffcd86 100644 --- a/src/domain/transaction/stellar/prepare/mod.rs +++ b/src/domain/transaction/stellar/prepare/mod.rs @@ -72,7 +72,7 @@ where return Ok(tx); } - debug!("preparing transaction {}", tx.id); + debug!(tx_id = %tx.id, "preparing transaction"); // Call core preparation logic with error handling match self.prepare_core(tx.clone()).await { @@ -95,7 +95,7 @@ where let policy = self.relayer().policies.get_stellar_policy(); match &stellar_data.transaction_input { TransactionInput::Operations(_) => { - debug!("preparing operations-based transaction {}", tx.id); + debug!(tx_id = %tx.id, "preparing operations-based transaction"); let stellar_data_with_sim = operations::process_operations( self.transaction_counter_service(), &self.relayer().id, @@ -111,7 +111,7 @@ where .await } TransactionInput::UnsignedXdr(_) => { - debug!("preparing unsigned xdr transaction {}", tx.id); + debug!(tx_id = %tx.id, "preparing unsigned xdr transaction"); let stellar_data_with_sim = unsigned_xdr::process_unsigned_xdr( self.transaction_counter_service(), &self.relayer().id, @@ -127,7 +127,7 @@ where .await } TransactionInput::SignedXdr { .. } => { - debug!("preparing fee-bump transaction {}", tx.id); + debug!(tx_id = %tx.id, "preparing fee-bump transaction"); let stellar_data_with_fee_bump = fee_bump::process_fee_bump( &self.relayer().address, stellar_data, @@ -147,7 +147,7 @@ where .await } TransactionInput::SorobanGasAbstraction { .. } => { - debug!("preparing soroban gas abstraction transaction {}", tx.id); + debug!(tx_id = %tx.id, "preparing soroban gas abstraction transaction"); let stellar_data_with_auth = soroban_gas_abstraction::process_soroban_gas_abstraction( self.transaction_counter_service(), @@ -155,6 +155,8 @@ where &self.relayer().address, self.provider(), stellar_data, + Some(&policy), + self.dex_service(), ) .await?; self.finalize_with_signature(tx, stellar_data_with_auth) diff --git a/src/domain/transaction/stellar/prepare/soroban_gas_abstraction.rs b/src/domain/transaction/stellar/prepare/soroban_gas_abstraction.rs index ddd997e11..afaf5e419 100644 --- a/src/domain/transaction/stellar/prepare/soroban_gas_abstraction.rs +++ b/src/domain/transaction/stellar/prepare/soroban_gas_abstraction.rs @@ -4,17 +4,19 @@ //! The relayer also signs its own authorization entry for the FeeForwarder contract. use soroban_rs::xdr::{ - InvokeHostFunctionOp, Limits, Operation, OperationBody, ReadXdr, SorobanAuthorizationEntry, - SorobanCredentials, SorobanResources, SorobanTransactionData, TransactionEnvelope, - TransactionExt, WriteXdr, + InvokeHostFunctionOp, Limits, Operation, OperationBody, ReadXdr, ScAddress, ScVal, + SorobanAuthorizationEntry, SorobanAuthorizedFunction, SorobanCredentials, SorobanResources, + SorobanTransactionData, TransactionEnvelope, TransactionExt, WriteXdr, }; -use tracing::{debug, info}; +use tracing::{debug, info, warn}; use crate::{ - models::{StellarTransactionData, TransactionError, TransactionInput}, + domain::transaction::stellar::{utils::convert_xlm_fee_to_token, StellarTransactionValidator}, + models::{RelayerStellarPolicy, StellarTransactionData, TransactionError, TransactionInput}, repositories::TransactionCounterTrait, services::{ provider::{StellarProvider, StellarProviderTrait}, + stellar_dex::StellarDexServiceTrait, stellar_fee_forwarder::{FeeForwarderError, FeeForwarderService}, }, }; @@ -26,12 +28,13 @@ use super::common::get_next_sequence; /// This function: /// 1. Parses the FeeForwarder transaction XDR /// 2. Deserializes the user's signed authorization entry -/// 3. Signs the relayer's authorization entry using the provided signer -/// 4. Injects both signed auth entries into the transaction -/// 5. Re-simulates with signed auth entries to get accurate footprint -/// 6. Updates the transaction's sorobanData with accurate resources -/// 7. Updates the sequence number -/// 8. Returns the prepared transaction data for signing +/// 3. Validates the fee parameters ensure relayer liquidity (token allowed, amount sufficient) +/// 4. Signs the relayer's authorization entry using the provided signer +/// 5. Injects both signed auth entries into the transaction +/// 6. Re-simulates with signed auth entries to get accurate footprint +/// 7. Updates the transaction's sorobanData with accurate resources +/// 8. Updates the sequence number +/// 9. Returns the prepared transaction data for signing /// /// # Arguments /// @@ -40,16 +43,21 @@ use super::common::get_next_sequence; /// * `relayer_address` - The relayer's Stellar address (source account for the transaction) /// * `provider` - The Stellar provider for simulation /// * `stellar_data` - The transaction data containing the XDR and signed auth entry -pub async fn process_soroban_gas_abstraction( +/// * `policy` - Optional relayer policy for fee validation +/// * `dex_service` - DEX service for token-to-XLM conversion quotes +pub async fn process_soroban_gas_abstraction( counter_service: &C, relayer_id: &str, relayer_address: &str, provider: &P, mut stellar_data: StellarTransactionData, + policy: Option<&RelayerStellarPolicy>, + dex_service: &D, ) -> Result where C: TransactionCounterTrait + Send + Sync, P: StellarProviderTrait + Send + Sync, + D: StellarDexServiceTrait + Send + Sync, { // Extract XDR and signed auth entry from transaction input let (xdr, signed_auth_entry_xdr) = match &stellar_data.transaction_input { @@ -85,6 +93,13 @@ where )), })?; + // Validate fee parameters to ensure relayer liquidity + // This validates: token is allowed, max_fee_amount covers the required fee + if let Some(policy) = policy { + validate_gas_abstraction_fee(&envelope, &signed_user_auth, policy, provider, dex_service) + .await?; + } + // Inject the user's signed auth entry and convert relayer's auth to SourceAccount let signed_auth_entries = inject_auth_entries_into_envelope(&mut envelope, signed_user_auth)?; @@ -151,7 +166,7 @@ fn inject_auth_entries_into_envelope( } }; - // Get the first operation (should be InvokeHostFunction for FeeForwarder) + // Get the operation (Soroban transactions must have exactly one operation) let operations: Vec<_> = tx.operations.to_vec(); if operations.is_empty() { return Err(TransactionError::ValidationError( @@ -159,6 +174,14 @@ fn inject_auth_entries_into_envelope( )); } + // Soroban protocol constraint: transactions must contain exactly one operation + if operations.len() != 1 { + return Err(TransactionError::ValidationError(format!( + "Soroban transactions must contain exactly one operation, found {}", + operations.len() + ))); + } + let first_op = &operations[0]; let invoke_op = match &first_op.body { OperationBody::InvokeHostFunction(invoke) => invoke.clone(), @@ -422,6 +445,223 @@ fn build_simulation_envelope( } } +// ============================================================================ +// Fee Validation for Soroban Gas Abstraction +// ============================================================================ + +/// Validate that the FeeForwarder transaction parameters ensure relayer liquidity. +/// +/// This function validates that the user's signed authorization entry contains: +/// 1. An allowed fee token (per relayer policy) +/// 2. A max_fee_amount sufficient to cover the required network fee (converted via DEX) +/// +/// This validation is critical to prevent malicious users from submitting transactions +/// where the token payment doesn't adequately compensate the relayer for XLM fees. +/// +/// # Arguments +/// +/// * `envelope` - The transaction envelope (used to estimate required XLM fee) +/// * `signed_user_auth` - The user's signed authorization entry +/// * `policy` - The relayer policy containing allowed tokens and fee settings +/// * `provider` - Provider for Stellar RPC operations (fee estimation) +/// * `dex_service` - DEX service for token-to-XLM conversion quotes +/// +/// # Returns +/// +/// Ok(()) if validation passes, TransactionError if validation fails +async fn validate_gas_abstraction_fee( + envelope: &TransactionEnvelope, + signed_user_auth: &SorobanAuthorizationEntry, + policy: &RelayerStellarPolicy, + provider: &P, + dex_service: &D, +) -> Result<(), TransactionError> +where + P: StellarProviderTrait + Send + Sync, + D: StellarDexServiceTrait + Send + Sync, +{ + // Step 1: Extract fee parameters from the user's signed auth entry + let (fee_token, max_fee_amount) = extract_fee_params_from_auth(signed_user_auth)?; + + debug!( + "Validating gas abstraction fee: token={}, max_fee_amount={}", + fee_token, max_fee_amount + ); + + // Step 2: Validate the fee token is allowed by policy + StellarTransactionValidator::validate_allowed_token(&fee_token, policy).map_err(|e| { + TransactionError::ValidationError(format!("Fee token validation failed: {e}")) + })?; + + // Step 3: Validate max_fee_amount against policy's max_allowed_fee (if configured) + if max_fee_amount < 0 { + return Err(TransactionError::ValidationError( + "max_fee_amount cannot be negative".to_string(), + )); + } + StellarTransactionValidator::validate_token_max_fee(&fee_token, max_fee_amount as u64, policy) + .map_err(|e| { + TransactionError::ValidationError(format!("Max fee validation failed: {e}")) + })?; + + // Step 4: Estimate the required XLM fee from the transaction + // For Soroban transactions, this includes both inclusion fee and resource fee + let required_xlm_fee = estimate_required_xlm_fee(envelope, provider).await?; + + debug!( + "Required XLM fee: {} stroops for gas abstraction transaction", + required_xlm_fee + ); + + // Step 5: Convert the required XLM fee to token amount + let fee_quote = convert_xlm_fee_to_token(dex_service, policy, required_xlm_fee, &fee_token) + .await + .map_err(|e| { + TransactionError::ValidationError(format!( + "Failed to convert XLM fee to token {fee_token}: {e}" + )) + })?; + + // Step 6: Validate that max_fee_amount covers the required fee in tokens + let required_token_fee = fee_quote.fee_in_token as i128; + if max_fee_amount < required_token_fee { + return Err(TransactionError::ValidationError(format!( + "Insufficient max_fee_amount: user authorized {max_fee_amount} but required fee is {required_token_fee} (in token {fee_token}). \ + This would result in relayer liquidity loss." + ))); + } + + info!( + "Gas abstraction fee validation passed: max_fee_amount={} >= required={} (token={})", + max_fee_amount, required_token_fee, fee_token + ); + + Ok(()) +} + +/// Extract fee parameters from the user's signed FeeForwarder authorization entry. +/// +/// The user's auth entry for FeeForwarder.forward() contains these arguments: +/// - fee_token (arg 0): Address of the token contract +/// - max_fee_amount (arg 1): Maximum fee amount user authorized (i128) +/// - expiration_ledger (arg 2): When the authorization expires +/// - target_contract (arg 3): Contract being called +/// - target_fn (arg 4): Function being called +/// - target_args (arg 5): Arguments to the target function +/// +/// # Arguments +/// +/// * `auth` - The user's signed authorization entry +/// +/// # Returns +/// +/// A tuple of (fee_token_address, max_fee_amount) or an error if extraction fails +fn extract_fee_params_from_auth( + auth: &SorobanAuthorizationEntry, +) -> Result<(String, i128), TransactionError> { + // Extract the function arguments from the root invocation + let args = match &auth.root_invocation.function { + SorobanAuthorizedFunction::ContractFn(invoke) => invoke.args.to_vec(), + _ => { + return Err(TransactionError::ValidationError( + "Expected ContractFn in auth entry root invocation".to_string(), + )); + } + }; + + // User auth args should have at least 2 arguments: fee_token, max_fee_amount + if args.len() < 2 { + return Err(TransactionError::ValidationError(format!( + "Invalid auth entry: expected at least 2 arguments, found {}", + args.len() + ))); + } + + // Extract fee_token (arg 0) - should be an Address (contract) + let fee_token = extract_contract_address_from_scval(&args[0]).map_err(|e| { + TransactionError::ValidationError(format!("Failed to extract fee_token: {e}")) + })?; + + // Extract max_fee_amount (arg 1) - should be an I128 + let max_fee_amount = extract_i128_from_scval(&args[1]).map_err(|e| { + TransactionError::ValidationError(format!("Failed to extract max_fee_amount: {e}")) + })?; + + Ok((fee_token, max_fee_amount)) +} + +/// Extract a contract address string from an ScVal::Address. +/// +/// Converts a Soroban contract address to its string representation (C...). +fn extract_contract_address_from_scval(val: &ScVal) -> Result { + match val { + ScVal::Address(ScAddress::Contract(contract_id)) => { + let strkey = stellar_strkey::Contract(contract_id.0 .0); + Ok(strkey.to_string()) + } + ScVal::Address(ScAddress::Account(_)) => { + Err("Expected contract address, found account address".to_string()) + } + _ => Err(format!("Expected Address, found {val:?}")), + } +} + +/// Extract an i128 value from an ScVal::I128. +fn extract_i128_from_scval(val: &ScVal) -> Result { + match val { + ScVal::I128(parts) => { + let value = ((parts.hi as i128) << 64) | (parts.lo as i128); + Ok(value) + } + _ => Err(format!("Expected I128, found {val:?}")), + } +} + +/// Estimate the required XLM fee for a Soroban gas abstraction transaction. +/// +/// This performs a simulation to get accurate resource requirements and calculates +/// the total fee (inclusion fee + resource fee). +async fn estimate_required_xlm_fee

( + envelope: &TransactionEnvelope, + provider: &P, +) -> Result +where + P: StellarProviderTrait + Send + Sync, +{ + // Simulate the transaction to get resource fee + let sim_response = provider + .simulate_transaction_envelope(envelope) + .await + .map_err(|e| { + TransactionError::UnexpectedError(format!( + "Failed to simulate transaction for fee estimation: {e}" + )) + })?; + + // Check for simulation errors + if let Some(err) = &sim_response.error { + warn!("Simulation returned error during fee estimation: {}", err); + // Still try to use the fee if available, otherwise return error + if sim_response.min_resource_fee == 0 { + return Err(TransactionError::ValidationError(format!( + "Simulation failed during fee estimation: {err}" + ))); + } + } + + // Calculate total fee: inclusion fee (100 stroops) + resource fee + let inclusion_fee = crate::constants::STELLAR_DEFAULT_TRANSACTION_FEE as u64; + let resource_fee = sim_response.min_resource_fee; + let total_fee = inclusion_fee + resource_fee; + + debug!( + "Estimated XLM fee: inclusion={}, resource={}, total={}", + inclusion_fee, resource_fee, total_fee + ); + + Ok(total_fee) +} + #[cfg(test)] mod tests { use super::*; @@ -793,6 +1033,65 @@ mod tests { } } + #[test] + fn test_inject_auth_entries_multiple_operations_returns_error() { + use soroban_rs::xdr::{Asset, PaymentOp}; + + // Create an InvokeHostFunction operation + let invoke_op = Operation { + source_account: None, + body: OperationBody::InvokeHostFunction(InvokeHostFunctionOp { + host_function: HostFunction::InvokeContract(InvokeContractArgs { + contract_address: ScAddress::Contract(ContractId(Hash([0u8; 32]))), + function_name: ScSymbol("test".try_into().unwrap()), + args: VecM::default(), + }), + auth: VecM::default(), + }), + }; + + // Create a second operation (Payment) + let payment_op = Operation { + source_account: None, + body: OperationBody::Payment(PaymentOp { + destination: MuxedAccount::Ed25519(Uint256([0u8; 32])), + asset: Asset::Native, + amount: 1000, + }), + }; + + // Create envelope with two operations (invalid for Soroban) + let tx = Transaction { + source_account: MuxedAccount::Ed25519(Uint256([0u8; 32])), + fee: 100, + seq_num: SequenceNumber(0), + cond: Preconditions::None, + memo: Memo::None, + operations: vec![invoke_op, payment_op].try_into().unwrap(), + ext: TransactionExt::V0, + }; + + let mut envelope = TransactionEnvelope::Tx(TransactionV1Envelope { + tx, + signatures: VecM::default(), + }); + + let signed_user_auth = create_address_auth_entry(); + let result = inject_auth_entries_into_envelope(&mut envelope, signed_user_auth); + + assert!(result.is_err()); + match result.unwrap_err() { + TransactionError::ValidationError(msg) => { + assert!( + msg.contains("Soroban transactions must contain exactly one operation"), + "Expected error about single operation, got: {}", + msg + ); + } + _ => panic!("Expected ValidationError"), + } + } + #[test] fn test_inject_auth_entries_non_invoke_host_function_returns_error() { use soroban_rs::xdr::{Asset, PaymentOp}; @@ -1013,6 +1312,144 @@ mod tests { panic!("Expected Tx envelope"); } } + + // ==================== Fee validation helper tests ==================== + + #[test] + fn test_extract_i128_from_scval_positive() { + use soroban_rs::xdr::Int128Parts; + let val = ScVal::I128(Int128Parts { hi: 0, lo: 1000000 }); + let result = extract_i128_from_scval(&val); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), 1000000i128); + } + + #[test] + fn test_extract_i128_from_scval_large() { + use soroban_rs::xdr::Int128Parts; + // Test a large value that uses both hi and lo parts + let val = ScVal::I128(Int128Parts { hi: 1, lo: 0 }); + let result = extract_i128_from_scval(&val); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), 1i128 << 64); + } + + #[test] + fn test_extract_i128_from_scval_negative() { + use soroban_rs::xdr::Int128Parts; + let val = ScVal::I128(Int128Parts { + hi: -1, + lo: u64::MAX, + }); + let result = extract_i128_from_scval(&val); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), -1i128); + } + + #[test] + fn test_extract_i128_from_scval_wrong_type() { + let val = ScVal::U32(42); + let result = extract_i128_from_scval(&val); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Expected I128")); + } + + #[test] + fn test_extract_contract_address_from_scval_valid() { + let contract_id = ContractId(Hash([1u8; 32])); + let val = ScVal::Address(ScAddress::Contract(contract_id)); + let result = extract_contract_address_from_scval(&val); + assert!(result.is_ok()); + // The result should be a valid C... address + assert!(result.unwrap().starts_with('C')); + } + + #[test] + fn test_extract_contract_address_from_scval_account_address() { + // Account addresses (G...) should return error + let account_id = soroban_rs::xdr::AccountId( + soroban_rs::xdr::PublicKey::PublicKeyTypeEd25519(Uint256([0u8; 32])), + ); + let val = ScVal::Address(ScAddress::Account(account_id)); + let result = extract_contract_address_from_scval(&val); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Expected contract address")); + } + + #[test] + fn test_extract_contract_address_from_scval_wrong_type() { + let val = ScVal::U32(42); + let result = extract_contract_address_from_scval(&val); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Expected Address")); + } + + #[test] + fn test_extract_fee_params_from_auth_valid() { + use soroban_rs::xdr::{Int128Parts, InvokeContractArgs, ScSymbol}; + + // Create auth entry with proper FeeForwarder args: + // fee_token, max_fee_amount, expiration_ledger, target_contract, target_fn, target_args + let fee_token = ScVal::Address(ScAddress::Contract(ContractId(Hash([1u8; 32])))); + let max_fee_amount = ScVal::I128(Int128Parts { hi: 0, lo: 5000000 }); // 5 tokens + let expiration_ledger = ScVal::U32(100000); + let target_contract = ScVal::Address(ScAddress::Contract(ContractId(Hash([2u8; 32])))); + let target_fn = ScVal::Symbol(ScSymbol("transfer".try_into().unwrap())); + let target_args = ScVal::Vec(None); + + let auth = SorobanAuthorizationEntry { + credentials: SorobanCredentials::SourceAccount, + root_invocation: SorobanAuthorizedInvocation { + function: SorobanAuthorizedFunction::ContractFn(InvokeContractArgs { + contract_address: ScAddress::Contract(ContractId(Hash([0u8; 32]))), + function_name: ScSymbol("forward".try_into().unwrap()), + args: vec![ + fee_token, + max_fee_amount, + expiration_ledger, + target_contract, + target_fn, + target_args, + ] + .try_into() + .unwrap(), + }), + sub_invocations: VecM::default(), + }, + }; + + let result = extract_fee_params_from_auth(&auth); + assert!(result.is_ok()); + + let (fee_token_addr, max_fee) = result.unwrap(); + assert!(fee_token_addr.starts_with('C')); // Contract address + assert_eq!(max_fee, 5000000i128); + } + + #[test] + fn test_extract_fee_params_from_auth_insufficient_args() { + use soroban_rs::xdr::{InvokeContractArgs, ScSymbol}; + + // Create auth entry with only 1 argument (not enough) + let auth = SorobanAuthorizationEntry { + credentials: SorobanCredentials::SourceAccount, + root_invocation: SorobanAuthorizedInvocation { + function: SorobanAuthorizedFunction::ContractFn(InvokeContractArgs { + contract_address: ScAddress::Contract(ContractId(Hash([0u8; 32]))), + function_name: ScSymbol("forward".try_into().unwrap()), + args: vec![ScVal::U32(42)].try_into().unwrap(), + }), + sub_invocations: VecM::default(), + }, + }; + + let result = extract_fee_params_from_auth(&auth); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("at least 2 arguments")); + } } #[cfg(test)] @@ -1021,6 +1458,7 @@ mod integration_tests { use crate::models::TransactionInput; use crate::repositories::MockTransactionCounterTrait; use crate::services::provider::MockStellarProviderTrait; + use crate::services::stellar_dex::MockStellarDexServiceTrait; use soroban_rs::stellar_rpc_client::SimulateTransactionResponse; use soroban_rs::xdr::{ ContractId, Hash, HostFunction, InvokeContractArgs, InvokeHostFunctionOp, Memo, @@ -1031,6 +1469,11 @@ mod integration_tests { }; use std::future::ready; + /// Create a mock DEX service for tests (not used when policy is None) + fn create_mock_dex_service() -> MockStellarDexServiceTrait { + MockStellarDexServiceTrait::new() + } + fn create_gas_abstraction_envelope() -> TransactionEnvelope { let user_auth = SorobanAuthorizationEntry { credentials: SorobanCredentials::Address(SorobanAddressCredentials { @@ -1135,12 +1578,16 @@ mod integration_tests { transaction_result_xdr: None, }; + let dex_service = create_mock_dex_service(); + let result = process_soroban_gas_abstraction( &counter, "relayer-1", "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF", &provider, stellar_data, + None, // No policy - skip fee validation + &dex_service, ) .await; @@ -1157,6 +1604,7 @@ mod integration_tests { async fn test_process_soroban_gas_abstraction_invalid_xdr() { let counter = MockTransactionCounterTrait::new(); let provider = MockStellarProviderTrait::new(); + let dex_service = create_mock_dex_service(); let stellar_data = StellarTransactionData { source_account: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF".to_string(), @@ -1182,6 +1630,8 @@ mod integration_tests { "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF", &provider, stellar_data, + None, // No policy - skip fee validation + &dex_service, ) .await; @@ -1198,6 +1648,7 @@ mod integration_tests { async fn test_process_soroban_gas_abstraction_invalid_auth_entry() { let counter = MockTransactionCounterTrait::new(); let provider = MockStellarProviderTrait::new(); + let dex_service = create_mock_dex_service(); let envelope = create_gas_abstraction_envelope(); let xdr = envelope.to_xdr_base64(Limits::none()).unwrap(); @@ -1226,6 +1677,8 @@ mod integration_tests { "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF", &provider, stellar_data, + None, // No policy - skip fee validation + &dex_service, ) .await; @@ -1261,6 +1714,8 @@ mod integration_tests { }))) }); + let dex_service = create_mock_dex_service(); + let envelope = create_gas_abstraction_envelope(); let xdr = envelope.to_xdr_base64(Limits::none()).unwrap(); @@ -1307,6 +1762,8 @@ mod integration_tests { "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF", &provider, stellar_data, + None, // No policy - skip fee validation + &dex_service, ) .await; @@ -1338,6 +1795,8 @@ mod integration_tests { }))) }); + let dex_service = create_mock_dex_service(); + let envelope = create_gas_abstraction_envelope(); let xdr = envelope.to_xdr_base64(Limits::none()).unwrap(); @@ -1383,6 +1842,8 @@ mod integration_tests { "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF", &provider, stellar_data, + None, // No policy - skip fee validation + &dex_service, ) .await; @@ -1396,3 +1857,428 @@ mod integration_tests { } } } + +#[cfg(test)] +mod validate_gas_abstraction_fee_tests { + use super::*; + use crate::models::{ + RelayerStellarPolicy, StellarAllowedTokensPolicy, StellarAllowedTokensSwapConfig, + }; + use crate::services::provider::MockStellarProviderTrait; + use crate::services::stellar_dex::{MockStellarDexServiceTrait, StellarQuoteResponse}; + use soroban_rs::stellar_rpc_client::SimulateTransactionResponse; + use soroban_rs::xdr::{ + ContractId, Hash, HostFunction, Int128Parts, InvokeContractArgs, InvokeHostFunctionOp, + Memo, MuxedAccount, Operation, OperationBody, Preconditions, ScAddress, ScSymbol, ScVal, + SequenceNumber, SorobanAddressCredentials, SorobanAuthorizationEntry, + SorobanAuthorizedFunction, SorobanAuthorizedInvocation, SorobanCredentials, + SorobanTransactionData, SorobanTransactionDataExt, Transaction, TransactionExt, + TransactionV1Envelope, Uint256, VecM, + }; + use std::future::ready; + + // Helper to get the contract address string from a 32-byte hash + fn get_contract_address_from_hash(hash: [u8; 32]) -> String { + let strkey = stellar_strkey::Contract(hash); + strkey.to_string() + } + + /// Create a valid FeeForwarder auth entry with specified fee token and max fee amount + fn create_fee_forwarder_auth( + fee_token_hash: [u8; 32], + max_fee_amount: i128, + ) -> SorobanAuthorizationEntry { + let fee_token = ScVal::Address(ScAddress::Contract(ContractId(Hash(fee_token_hash)))); + let max_fee = ScVal::I128(Int128Parts { + hi: (max_fee_amount >> 64) as i64, + lo: max_fee_amount as u64, + }); + let expiration_ledger = ScVal::U32(100000); + let target_contract = ScVal::Address(ScAddress::Contract(ContractId(Hash([2u8; 32])))); + let target_fn = ScVal::Symbol(ScSymbol("transfer".try_into().unwrap())); + let target_args = ScVal::Vec(None); + + SorobanAuthorizationEntry { + credentials: SorobanCredentials::Address(SorobanAddressCredentials { + address: ScAddress::Contract(ContractId(Hash([1u8; 32]))), + nonce: 12345, + signature_expiration_ledger: 1000, + signature: ScVal::Void, + }), + root_invocation: SorobanAuthorizedInvocation { + function: SorobanAuthorizedFunction::ContractFn(InvokeContractArgs { + contract_address: ScAddress::Contract(ContractId(Hash([0u8; 32]))), + function_name: ScSymbol("forward".try_into().unwrap()), + args: vec![ + fee_token, + max_fee, + expiration_ledger, + target_contract, + target_fn, + target_args, + ] + .try_into() + .unwrap(), + }), + sub_invocations: VecM::default(), + }, + } + } + + /// Create a basic gas abstraction transaction envelope + fn create_test_envelope() -> TransactionEnvelope { + let invoke_op = InvokeHostFunctionOp { + host_function: HostFunction::InvokeContract(InvokeContractArgs { + contract_address: ScAddress::Contract(ContractId(Hash([0u8; 32]))), + function_name: ScSymbol("forward".try_into().unwrap()), + args: VecM::default(), + }), + auth: VecM::default(), + }; + + let operation = Operation { + source_account: None, + body: OperationBody::InvokeHostFunction(invoke_op), + }; + + let tx = Transaction { + source_account: MuxedAccount::Ed25519(Uint256([0u8; 32])), + fee: 100, + seq_num: SequenceNumber(0), + cond: Preconditions::None, + memo: Memo::None, + operations: vec![operation].try_into().unwrap(), + ext: TransactionExt::V0, + }; + + TransactionEnvelope::Tx(TransactionV1Envelope { + tx, + signatures: VecM::default(), + }) + } + + /// Create a valid soroban transaction data XDR for simulation response + fn create_valid_soroban_tx_data_xdr() -> String { + let tx_data = SorobanTransactionData { + ext: SorobanTransactionDataExt::V0, + resources: soroban_rs::xdr::SorobanResources { + footprint: soroban_rs::xdr::LedgerFootprint { + read_only: VecM::default(), + read_write: VecM::default(), + }, + instructions: 1000, + disk_read_bytes: 500, + write_bytes: 200, + }, + resource_fee: 100, + }; + tx_data.to_xdr_base64(Limits::none()).unwrap() + } + + /// Create a policy with a specific allowed token + fn create_policy_with_token( + token_address: &str, + max_allowed_fee: Option, + ) -> RelayerStellarPolicy { + RelayerStellarPolicy { + min_balance: None, + max_fee: None, + timeout_seconds: None, + concurrent_transactions: None, + allowed_tokens: Some(vec![StellarAllowedTokensPolicy { + asset: token_address.to_string(), + metadata: None, + max_allowed_fee, + swap_config: Some(StellarAllowedTokensSwapConfig { + slippage_percentage: Some(1.0), + min_amount: None, + max_amount: None, + retain_min_amount: None, + }), + }]), + fee_payment_strategy: None, + slippage_percentage: Some(1.0), + fee_margin_percentage: Some(10.0), + swap_config: None, + } + } + + #[tokio::test] + async fn test_validate_gas_abstraction_fee_disallowed_token() { + // Setup: provider and dex_service (won't be called for disallowed token) + let provider = MockStellarProviderTrait::new(); + let dex_service = MockStellarDexServiceTrait::new(); + + // Create envelope and auth entry + let envelope = create_test_envelope(); + let fee_token_hash = [5u8; 32]; + let signed_auth = create_fee_forwarder_auth(fee_token_hash, 10_000_000); + + // Create a policy that does NOT include our fee token + let different_token = get_contract_address_from_hash([9u8; 32]); + let policy = create_policy_with_token(&different_token, Some(50_000_000)); + + // Execute + let result = + validate_gas_abstraction_fee(&envelope, &signed_auth, &policy, &provider, &dex_service) + .await; + + // Assert + assert!(result.is_err()); + let err = result.unwrap_err(); + match err { + TransactionError::ValidationError(msg) => { + assert!( + msg.contains("Fee token validation failed"), + "Expected 'Fee token validation failed' in error message, got: {}", + msg + ); + } + other => panic!("Expected ValidationError, got: {:?}", other), + } + } + + #[tokio::test] + async fn test_validate_gas_abstraction_fee_exceeds_max_allowed() { + // Setup: provider and dex_service (won't be called if max_allowed_fee check fails first) + let provider = MockStellarProviderTrait::new(); + let dex_service = MockStellarDexServiceTrait::new(); + + // Create envelope and auth entry + let envelope = create_test_envelope(); + let fee_token_hash = [5u8; 32]; + let fee_token_address = get_contract_address_from_hash(fee_token_hash); + + // User is trying to authorize 100 tokens, but policy only allows max 50 + let signed_auth = create_fee_forwarder_auth(fee_token_hash, 100_000_000); + + // Policy allows this token but with max_allowed_fee of 50 + let policy = create_policy_with_token(&fee_token_address, Some(50_000_000)); + + // Execute + let result = + validate_gas_abstraction_fee(&envelope, &signed_auth, &policy, &provider, &dex_service) + .await; + + // Assert + assert!(result.is_err()); + let err = result.unwrap_err(); + match err { + TransactionError::ValidationError(msg) => { + assert!( + msg.contains("Max fee validation failed"), + "Expected 'Max fee validation failed' in error message, got: {}", + msg + ); + } + other => panic!("Expected ValidationError, got: {:?}", other), + } + } + + #[tokio::test] + async fn test_validate_gas_abstraction_fee_insufficient_max_fee() { + // Setup: simulate transaction returns a resource fee, DEX converts it + let mut provider = MockStellarProviderTrait::new(); + provider + .expect_simulate_transaction_envelope() + .returning(|_| { + Box::pin(ready(Ok(SimulateTransactionResponse { + error: None, + min_resource_fee: 10000, // 10000 stroops resource fee + transaction_data: create_valid_soroban_tx_data_xdr(), + ..Default::default() + }))) + }); + + let mut dex_service = MockStellarDexServiceTrait::new(); + // DEX returns that required fee in token is 5_000_000 (5 tokens) + // convert_xlm_fee_to_token uses get_xlm_to_token_quote + dex_service + .expect_get_xlm_to_token_quote() + .returning(|_, _, _, _| { + Box::pin(ready(Ok(StellarQuoteResponse { + input_asset: "native".to_string(), + output_asset: "token".to_string(), + in_amount: 10000, // Input XLM + out_amount: 5_000_000, // Required fee in token + price_impact_pct: 0.1, + slippage_bps: 100, + path: None, + }))) + }); + + // Create envelope and auth entry + let envelope = create_test_envelope(); + let fee_token_hash = [5u8; 32]; + let fee_token_address = get_contract_address_from_hash(fee_token_hash); + + // User authorizes only 1_000_000 (1 token), but 5 tokens are needed + let signed_auth = create_fee_forwarder_auth(fee_token_hash, 1_000_000); + + // Policy allows token with high max_allowed_fee (no issue there) + let policy = create_policy_with_token(&fee_token_address, Some(100_000_000)); + + // Execute + let result = + validate_gas_abstraction_fee(&envelope, &signed_auth, &policy, &provider, &dex_service) + .await; + + // Assert + assert!(result.is_err()); + let err = result.unwrap_err(); + match err { + TransactionError::ValidationError(msg) => { + assert!( + msg.contains("Insufficient max_fee_amount"), + "Expected 'Insufficient max_fee_amount' in error message, got: {}", + msg + ); + assert!( + msg.contains("relayer liquidity loss"), + "Expected 'relayer liquidity loss' warning in error message, got: {}", + msg + ); + } + other => panic!("Expected ValidationError, got: {:?}", other), + } + } + + #[tokio::test] + async fn test_validate_gas_abstraction_fee_success() { + // Setup: simulate transaction returns a resource fee, DEX converts it + let mut provider = MockStellarProviderTrait::new(); + provider + .expect_simulate_transaction_envelope() + .returning(|_| { + Box::pin(ready(Ok(SimulateTransactionResponse { + error: None, + min_resource_fee: 10000, // 10000 stroops resource fee + transaction_data: create_valid_soroban_tx_data_xdr(), + ..Default::default() + }))) + }); + + let mut dex_service = MockStellarDexServiceTrait::new(); + // DEX returns that required fee in token is 5_000_000 (5 tokens) + // convert_xlm_fee_to_token uses get_xlm_to_token_quote + dex_service + .expect_get_xlm_to_token_quote() + .returning(|_, _, _, _| { + Box::pin(ready(Ok(StellarQuoteResponse { + input_asset: "native".to_string(), + output_asset: "token".to_string(), + in_amount: 10000, // Input XLM + out_amount: 5_000_000, // Required fee in token + price_impact_pct: 0.1, + slippage_bps: 100, + path: None, + }))) + }); + + // Create envelope and auth entry + let envelope = create_test_envelope(); + let fee_token_hash = [5u8; 32]; + let fee_token_address = get_contract_address_from_hash(fee_token_hash); + + // User authorizes 10_000_000 (10 tokens), which exceeds the required 5 tokens + let signed_auth = create_fee_forwarder_auth(fee_token_hash, 10_000_000); + + // Policy allows token with max_allowed_fee of 20 tokens + let policy = create_policy_with_token(&fee_token_address, Some(20_000_000)); + + // Execute + let result = + validate_gas_abstraction_fee(&envelope, &signed_auth, &policy, &provider, &dex_service) + .await; + + // Assert + assert!( + result.is_ok(), + "Expected validation to pass, got error: {:?}", + result.unwrap_err() + ); + } + + #[tokio::test] + async fn test_validate_gas_abstraction_fee_negative_amount_rejected() { + // Setup + let provider = MockStellarProviderTrait::new(); + let dex_service = MockStellarDexServiceTrait::new(); + + // Create envelope and auth entry with negative max_fee_amount + let envelope = create_test_envelope(); + let fee_token_hash = [5u8; 32]; + let fee_token_address = get_contract_address_from_hash(fee_token_hash); + + // User provides negative max_fee_amount + let signed_auth = create_fee_forwarder_auth(fee_token_hash, -1000); + + // Policy allows token + let policy = create_policy_with_token(&fee_token_address, Some(100_000_000)); + + // Execute + let result = + validate_gas_abstraction_fee(&envelope, &signed_auth, &policy, &provider, &dex_service) + .await; + + // Assert + assert!(result.is_err()); + let err = result.unwrap_err(); + match err { + TransactionError::ValidationError(msg) => { + assert!( + msg.contains("cannot be negative"), + "Expected 'cannot be negative' in error message, got: {}", + msg + ); + } + other => panic!("Expected ValidationError, got: {:?}", other), + } + } + + #[tokio::test] + async fn test_validate_gas_abstraction_fee_simulation_error() { + // Setup: simulation returns an error with zero resource fee + let mut provider = MockStellarProviderTrait::new(); + provider + .expect_simulate_transaction_envelope() + .returning(|_| { + Box::pin(ready(Ok(SimulateTransactionResponse { + error: Some("Simulation failed: contract not found".to_string()), + min_resource_fee: 0, + transaction_data: String::new(), + ..Default::default() + }))) + }); + + let dex_service = MockStellarDexServiceTrait::new(); + + // Create envelope and auth entry + let envelope = create_test_envelope(); + let fee_token_hash = [5u8; 32]; + let fee_token_address = get_contract_address_from_hash(fee_token_hash); + let signed_auth = create_fee_forwarder_auth(fee_token_hash, 10_000_000); + + // Policy allows token + let policy = create_policy_with_token(&fee_token_address, Some(100_000_000)); + + // Execute + let result = + validate_gas_abstraction_fee(&envelope, &signed_auth, &policy, &provider, &dex_service) + .await; + + // Assert: When simulation fails with zero resource fee, it returns ValidationError + assert!(result.is_err()); + let err = result.unwrap_err(); + match err { + TransactionError::ValidationError(msg) => { + assert!( + msg.contains("Simulation failed"), + "Expected 'Simulation failed' in error message, got: {}", + msg + ); + } + other => panic!("Expected ValidationError, got: {:?}", other), + } + } +} diff --git a/src/services/stellar_dex/soroswap_service.rs b/src/services/stellar_dex/soroswap_service.rs index 306f721a6..64d649a78 100644 --- a/src/services/stellar_dex/soroswap_service.rs +++ b/src/services/stellar_dex/soroswap_service.rs @@ -10,6 +10,7 @@ use super::{ AssetType, PathStep, StellarDexServiceError, StellarDexServiceTrait, StellarQuoteResponse, SwapExecutionResult, SwapTransactionParams, }; +use crate::constants::get_default_soroswap_native_wrapper; use crate::services::provider::StellarProviderTrait; use async_trait::async_trait; use soroban_rs::xdr::{ContractId, Hash, Int128Parts, ScAddress, ScSymbol, ScVal, ScVec}; @@ -17,11 +18,6 @@ use std::collections::HashSet; use std::sync::Arc; use tracing::{debug, warn}; -/// Native XLM wrapper token contract address -/// This is the Soroban token contract that wraps native XLM for use in Soroswap -const MAINNET_NATIVE_WRAPPER: &str = "CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA"; -const TESTNET_NATIVE_WRAPPER: &str = "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC"; - /// Soroswap AMM DEX service for Soroban token swaps /// /// This service uses Soroswap's router contract to: @@ -66,13 +62,8 @@ where network_passphrase: String, is_testnet: bool, ) -> Self { - let native_wrapper = native_wrapper_address.unwrap_or_else(|| { - if is_testnet { - TESTNET_NATIVE_WRAPPER.to_string() - } else { - MAINNET_NATIVE_WRAPPER.to_string() - } - }); + let native_wrapper = native_wrapper_address + .unwrap_or_else(|| get_default_soroswap_native_wrapper(is_testnet).to_string()); Self { router_address, @@ -439,6 +430,9 @@ where #[cfg(test)] mod tests { use super::*; + use crate::constants::{ + STELLAR_SOROSWAP_MAINNET_NATIVE_WRAPPER, STELLAR_SOROSWAP_TESTNET_NATIVE_WRAPPER, + }; use crate::services::provider::MockStellarProviderTrait; use futures::FutureExt; @@ -466,14 +460,20 @@ mod tests { fn test_new_testnet_uses_testnet_native_wrapper() { let provider = create_mock_provider(); let service = create_test_service(provider, true); - assert_eq!(service.native_wrapper_address, TESTNET_NATIVE_WRAPPER); + assert_eq!( + service.native_wrapper_address, + STELLAR_SOROSWAP_TESTNET_NATIVE_WRAPPER + ); } #[test] fn test_new_mainnet_uses_mainnet_native_wrapper() { let provider = create_mock_provider(); let service = create_test_service(provider, false); - assert_eq!(service.native_wrapper_address, MAINNET_NATIVE_WRAPPER); + assert_eq!( + service.native_wrapper_address, + STELLAR_SOROSWAP_MAINNET_NATIVE_WRAPPER + ); } #[test] From 55281cb144e65d0ccdefb967f9a9bb57e46f61c2 Mon Sep 17 00:00:00 2001 From: Nicolas Molina Date: Thu, 5 Feb 2026 12:49:28 -0300 Subject: [PATCH 11/22] fix: Improvements --- src/domain/relayer/stellar/gas_abstraction.rs | 120 +++++++++++++++++- .../prepare/soroban_gas_abstraction.rs | 48 +++---- src/models/rpc/stellar/mod.rs | 60 +++++++++ src/services/stellar_dex/soroswap_service.rs | 48 +++---- src/services/stellar_fee_forwarder/mod.rs | 29 +++-- 5 files changed, 240 insertions(+), 65 deletions(-) diff --git a/src/domain/relayer/stellar/gas_abstraction.rs b/src/domain/relayer/stellar/gas_abstraction.rs index a89bbe954..c8fd2bb3e 100644 --- a/src/domain/relayer/stellar/gas_abstraction.rs +++ b/src/domain/relayer/stellar/gas_abstraction.rs @@ -22,8 +22,8 @@ use crate::domain::relayer::{ }; use crate::domain::transaction::stellar::{ utils::{ - add_operation_to_envelope, convert_xlm_fee_to_token, create_fee_payment_operation, - estimate_fee, set_time_bounds, FeeQuote, + add_operation_to_envelope, amount_to_ui_amount, convert_xlm_fee_to_token, + create_fee_payment_operation, estimate_fee, set_time_bounds, FeeQuote, }, StellarTransactionValidator, }; @@ -335,6 +335,9 @@ where fee_in_token_ui: fee_quote.fee_in_token_ui, fee_in_token: fee_quote.fee_in_token.to_string(), conversion_rate: fee_quote.conversion_rate.to_string(), + // Classic transactions have deterministic fees, no slippage buffer needed + max_fee_in_token: None, + max_fee_in_token_ui: None, }, )) } @@ -475,6 +478,9 @@ where valid_until: valid_until.to_rfc3339(), // Classic mode: no Soroban-specific fields user_auth_entry: None, + // Classic transactions have deterministic fees, no slippage buffer needed + max_fee_in_token: None, + max_fee_in_token_ui: None, }, )) } @@ -653,11 +659,20 @@ where .await .map_err(|e| RelayerError::ValidationError(e.to_string()))?; + // Calculate max_fee with slippage buffer for Soroban + let max_fee_in_token = apply_max_fee_slippage(fee_quote.fee_in_token); + let token_decimals = policy + .get_allowed_token_decimals(¶ms.fee_token) + .unwrap_or(7); + let max_fee_in_token_ui = amount_to_ui_amount(max_fee_in_token as u64, token_decimals); + // Return using consolidated result struct let result = StellarFeeEstimateResult { fee_in_token_ui: fee_quote.fee_in_token_ui, fee_in_token: fee_quote.fee_in_token.to_string(), conversion_rate: fee_quote.conversion_rate.to_string(), + max_fee_in_token: Some(max_fee_in_token.to_string()), + max_fee_in_token_ui: Some(max_fee_in_token_ui), }; Ok(SponsoredTransactionQuoteResponse::Stellar(result)) @@ -873,6 +888,13 @@ where valid_until.to_rfc3339() ); + // Calculate max_fee with slippage buffer for Soroban + let max_fee_in_token = fee_params.max_fee_amount; + let token_decimals = policy + .get_allowed_token_decimals(¶ms.fee_token) + .unwrap_or(7); + let max_fee_in_token_ui = amount_to_ui_amount(max_fee_in_token as u64, token_decimals); + // Return using consolidated result struct with Soroban-specific fields populated let result = StellarPrepareTransactionResult { transaction: transaction_xdr, @@ -883,6 +905,8 @@ where valid_until: valid_until.to_rfc3339(), // Soroban-specific fields user_auth_entry: Some(user_auth_xdr), + max_fee_in_token: Some(max_fee_in_token.to_string()), + max_fee_in_token_ui: Some(max_fee_in_token_ui), }; Ok(SponsoredTransactionBuildResponse::Stellar(result)) @@ -1288,6 +1312,30 @@ mod tests { } } + /// Helper function to create a mainnet test network (no default FeeForwarder) + fn create_test_mainnet_network() -> NetworkRepoModel { + NetworkRepoModel { + id: "stellar:mainnet".to_string(), + name: "mainnet".to_string(), + network_type: NetworkType::Stellar, + config: NetworkConfigData::Stellar(StellarNetworkConfig { + common: NetworkConfigCommon { + network: "mainnet".to_string(), + from: None, + rpc_urls: Some(vec![RpcConfig::new( + "https://horizon.stellar.org".to_string(), + )]), + explorer_urls: None, + average_blocktime_ms: Some(5000), + is_testnet: Some(false), + tags: None, + }, + passphrase: Some("Public Global Stellar Network ; September 2015".to_string()), + horizon_url: Some("https://horizon.stellar.org".to_string()), + }), + } + } + /// Helper function to create a Stellar relayer instance for testing async fn create_test_relayer_instance( relayer_model: RelayerRepoModel, @@ -1330,6 +1378,48 @@ mod tests { .unwrap() } + /// Helper function to create a Stellar relayer instance with custom network for testing + async fn create_test_relayer_instance_with_network( + relayer_model: RelayerRepoModel, + provider: MockStellarProviderTrait, + dex_service: Arc, + network: NetworkRepoModel, + ) -> crate::domain::relayer::stellar::StellarRelayer< + MockStellarProviderTrait, + MockRelayerRepository, + InMemoryNetworkRepository, + MockTransactionRepository, + MockJobProducerTrait, + MockTransactionCounterServiceTrait, + MockStellarSignTrait, + MockStellarDexServiceTrait, + > { + let network_repository = Arc::new(InMemoryNetworkRepository::new()); + network_repository.create(network).await.unwrap(); + + let relayer_repo = Arc::new(MockRelayerRepository::new()); + let tx_repo = Arc::new(MockTransactionRepository::new()); + let job_producer = Arc::new(MockJobProducerTrait::new()); + let counter = Arc::new(MockTransactionCounterServiceTrait::new()); + let signer = Arc::new(MockStellarSignTrait::new()); + + crate::domain::relayer::stellar::StellarRelayer::new( + relayer_model, + signer, + provider, + crate::domain::relayer::stellar::StellarRelayerDependencies::new( + relayer_repo, + network_repository, + tx_repo, + counter, + job_producer, + ), + dex_service, + ) + .await + .unwrap() + } + #[tokio::test] async fn test_quote_sponsored_transaction_with_xdr() { let relayer_model = create_test_relayer_with_user_fee_strategy(); @@ -3014,7 +3104,10 @@ mod tests { // Ensure env var is NOT set std::env::remove_var("STELLAR_FEE_FORWARDER_ADDRESS"); - let relayer_model = create_test_relayer_with_soroban_token(); + // Use mainnet network where FeeForwarder is not deployed (empty default address) + let mut relayer_model = create_test_relayer_with_soroban_token(); + relayer_model.network = "mainnet".to_string(); + let provider = MockStellarProviderTrait::new(); let mut dex_service = MockStellarDexServiceTrait::new(); @@ -3023,7 +3116,13 @@ mod tests { }); let dex_service = Arc::new(dex_service); - let relayer = create_test_relayer_instance(relayer_model, provider, dex_service).await; + let relayer = create_test_relayer_instance_with_network( + relayer_model, + provider, + dex_service, + create_test_mainnet_network(), + ) + .await; let transaction_xdr = create_test_soroban_transaction_xdr(); let request = SponsoredTransactionQuoteRequest::Stellar( @@ -3239,7 +3338,10 @@ mod tests { // Ensure env var is NOT set std::env::remove_var("STELLAR_FEE_FORWARDER_ADDRESS"); - let relayer_model = create_test_relayer_with_soroban_token(); + // Use mainnet network where FeeForwarder is not deployed (empty default address) + let mut relayer_model = create_test_relayer_with_soroban_token(); + relayer_model.network = "mainnet".to_string(); + let provider = MockStellarProviderTrait::new(); let mut dex_service = MockStellarDexServiceTrait::new(); @@ -3248,7 +3350,13 @@ mod tests { }); let dex_service = Arc::new(dex_service); - let relayer = create_test_relayer_instance(relayer_model, provider, dex_service).await; + let relayer = create_test_relayer_instance_with_network( + relayer_model, + provider, + dex_service, + create_test_mainnet_network(), + ) + .await; let transaction_xdr = create_test_soroban_transaction_xdr(); let request = SponsoredTransactionBuildRequest::Stellar( diff --git a/src/domain/transaction/stellar/prepare/soroban_gas_abstraction.rs b/src/domain/transaction/stellar/prepare/soroban_gas_abstraction.rs index afaf5e419..11a076efa 100644 --- a/src/domain/transaction/stellar/prepare/soroban_gas_abstraction.rs +++ b/src/domain/transaction/stellar/prepare/soroban_gas_abstraction.rs @@ -196,24 +196,27 @@ fn inject_auth_entries_into_envelope( let mut auth_entries: Vec = invoke_op.auth.to_vec(); if auth_entries.is_empty() { - // If there are no auth entries, just add the user's signed one - auth_entries.push(signed_user_auth); - } else { - // Replace the first auth entry (user's) with the signed version - auth_entries[0] = signed_user_auth; - - // Convert the relayer's auth entry (second entry) to use SourceAccount credentials. - // Since the relayer is the transaction source account, the transaction signature - // already authorizes this entry - no separate auth entry signature is needed. - if auth_entries.len() > 1 { - let relayer_auth = &auth_entries[1]; - let source_account_auth = SorobanAuthorizationEntry { - credentials: SorobanCredentials::SourceAccount, - root_invocation: relayer_auth.root_invocation.clone(), - }; - auth_entries[1] = source_account_auth; - debug!("Converted relayer auth entry to SourceAccount credentials"); - } + return Err(TransactionError::ValidationError( + "FeeForwarder transaction must contain auth entries. The transaction may be \ + malformed or was not built via the /build endpoint." + .to_string(), + )); + } + + // Replace the first auth entry (user's) with the signed version + auth_entries[0] = signed_user_auth; + + // Convert the relayer's auth entry (second entry) to use SourceAccount credentials. + // Since the relayer is the transaction source account, the transaction signature + // already authorizes this entry - no separate auth entry signature is needed. + if auth_entries.len() > 1 { + let relayer_auth = &auth_entries[1]; + let source_account_auth = SorobanAuthorizationEntry { + credentials: SorobanCredentials::SourceAccount, + root_invocation: relayer_auth.root_invocation.clone(), + }; + auth_entries[1] = source_account_auth; + debug!("Converted relayer auth entry to SourceAccount credentials"); } // Clone auth_entries before consuming them in try_into (we need to return them) @@ -1134,15 +1137,16 @@ mod tests { } #[test] - fn test_inject_auth_entries_empty_auth_adds_user_entry() { + fn test_inject_auth_entries_empty_auth_returns_error() { let mut envelope = create_invoke_host_function_envelope(vec![]); let signed_user_auth = create_address_auth_entry(); let result = inject_auth_entries_into_envelope(&mut envelope, signed_user_auth.clone()); - assert!(result.is_ok()); - let auth_entries = result.unwrap(); - assert_eq!(auth_entries.len(), 1); + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(matches!(err, TransactionError::ValidationError(_))); + assert!(err.to_string().contains("must contain auth entries")); } #[test] diff --git a/src/models/rpc/stellar/mod.rs b/src/models/rpc/stellar/mod.rs index bd47ef1a4..01a39fc8e 100644 --- a/src/models/rpc/stellar/mod.rs +++ b/src/models/rpc/stellar/mod.rs @@ -84,6 +84,16 @@ pub struct FeeEstimateResult { pub fee_in_token: String, /// Conversion rate from XLM to token (as string) pub conversion_rate: String, + /// Maximum fee in token amount (raw units as string). + /// Only present for Soroban gas abstraction - includes slippage buffer. + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(nullable = true)] + pub max_fee_in_token: Option, + /// Maximum fee in token amount (decimal UI representation as string). + /// Only present for Soroban gas abstraction - includes slippage buffer. + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(nullable = true)] + pub max_fee_in_token_ui: Option, } // prepareTransaction @@ -178,6 +188,16 @@ pub struct PrepareTransactionResult { #[serde(skip_serializing_if = "Option::is_none")] #[schema(nullable = true)] pub user_auth_entry: Option, + /// Maximum fee in token amount (raw units as string). + /// Only present for Soroban gas abstraction - includes slippage buffer. + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(nullable = true)] + pub max_fee_in_token: Option, + /// Maximum fee in token amount (decimal UI representation as string). + /// Only present for Soroban gas abstraction - includes slippage buffer. + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(nullable = true)] + pub max_fee_in_token_ui: Option, } /// Stellar RPC method enum @@ -529,12 +549,45 @@ mod tests { fee_in_token_ui: "1.5".to_string(), fee_in_token: "1500000".to_string(), conversion_rate: "10.0".to_string(), + max_fee_in_token: None, + max_fee_in_token_ui: None, }; assert_eq!(result.fee_in_token_ui, "1.5"); assert_eq!(result.fee_in_token, "1500000"); assert_eq!(result.conversion_rate, "10.0"); } + #[test] + fn test_fee_estimate_result_with_max_fee() { + let result = FeeEstimateResult { + fee_in_token_ui: "1.5".to_string(), + fee_in_token: "1500000".to_string(), + conversion_rate: "10.0".to_string(), + max_fee_in_token: Some("1575000".to_string()), + max_fee_in_token_ui: Some("1.575".to_string()), + }; + assert_eq!(result.max_fee_in_token, Some("1575000".to_string())); + assert_eq!(result.max_fee_in_token_ui, Some("1.575".to_string())); + // Verify serialization includes max_fee fields when present + let json = serde_json::to_string(&result).unwrap(); + assert!(json.contains("max_fee_in_token")); + assert!(json.contains("max_fee_in_token_ui")); + } + + #[test] + fn test_fee_estimate_result_skips_none_max_fee() { + let result = FeeEstimateResult { + fee_in_token_ui: "1.5".to_string(), + fee_in_token: "1500000".to_string(), + conversion_rate: "10.0".to_string(), + max_fee_in_token: None, + max_fee_in_token_ui: None, + }; + // Verify serialization skips None fields + let json = serde_json::to_string(&result).unwrap(); + assert!(!json.contains("max_fee_in_token")); + } + #[test] fn test_prepare_transaction_result_with_soroban_fields() { let result = PrepareTransactionResult { @@ -545,8 +598,12 @@ mod tests { fee_token: "CUSDC".to_string(), valid_until: "2024-01-01T00:00:00Z".to_string(), user_auth_entry: Some("AAAABgAAAAA=".to_string()), + max_fee_in_token: Some("1575000".to_string()), + max_fee_in_token_ui: Some("1.575".to_string()), }; assert!(result.user_auth_entry.is_some()); + assert!(result.max_fee_in_token.is_some()); + assert!(result.max_fee_in_token_ui.is_some()); } #[test] @@ -559,9 +616,12 @@ mod tests { fee_token: "USDC:GA...".to_string(), valid_until: "2024-01-01T00:00:00Z".to_string(), user_auth_entry: None, + max_fee_in_token: None, + max_fee_in_token_ui: None, }; // Verify serialization skips None fields let json = serde_json::to_string(&result).unwrap(); assert!(!json.contains("user_auth_entry")); + assert!(!json.contains("max_fee_in_token")); } } diff --git a/src/services/stellar_dex/soroswap_service.rs b/src/services/stellar_dex/soroswap_service.rs index 64d649a78..76f936f5b 100644 --- a/src/services/stellar_dex/soroswap_service.rs +++ b/src/services/stellar_dex/soroswap_service.rs @@ -11,9 +11,10 @@ use super::{ SwapExecutionResult, SwapTransactionParams, }; use crate::constants::get_default_soroswap_native_wrapper; +use crate::domain::transaction::stellar::utils::parse_contract_address; use crate::services::provider::StellarProviderTrait; use async_trait::async_trait; -use soroban_rs::xdr::{ContractId, Hash, Int128Parts, ScAddress, ScSymbol, ScVal, ScVec}; +use soroban_rs::xdr::{ContractId, Int128Parts, ScAddress, ScSymbol, ScVal, ScVec}; use std::collections::HashSet; use std::sync::Arc; use tracing::{debug, warn}; @@ -75,14 +76,14 @@ where } /// Parse a Soroban contract address (C...) to ScAddress - fn parse_contract_address(address: &str) -> Result { - let contract = stellar_strkey::Contract::from_string(address).map_err(|e| { + fn parse_contract_to_sc_address(address: &str) -> Result { + let hash = parse_contract_address(address).map_err(|e| { StellarDexServiceError::InvalidAssetIdentifier(format!( "Invalid Soroban contract address '{address}': {e}" )) })?; - Ok(ScAddress::Contract(ContractId(Hash(contract.0)))) + Ok(ScAddress::Contract(ContractId(hash))) } /// Build a Vec path for router calls @@ -91,8 +92,8 @@ where from_token: &str, to_token: &str, ) -> Result { - let from_addr = Self::parse_contract_address(from_token)?; - let to_addr = Self::parse_contract_address(to_token)?; + let from_addr = Self::parse_contract_to_sc_address(from_token)?; + let to_addr = Self::parse_contract_to_sc_address(to_token)?; // Simple direct path: [from_token, to_token] let path_vec: ScVec = vec![ScVal::Address(from_addr), ScVal::Address(to_addr)] @@ -154,7 +155,7 @@ where })?; // Soroswap's get_amounts_out requires factory address as first argument - let factory_addr = Self::parse_contract_address(&self.factory_address)?; + let factory_addr = Self::parse_contract_to_sc_address(&self.factory_address)?; let args = vec![ ScVal::Address(factory_addr), Self::i128_to_scval(amount_in), @@ -251,16 +252,6 @@ where )) })?; - // Calculate price impact (simplified - assumes 1:1 expected ratio) - // TODO: Use pool reserves for accurate price impact calculation - let price_impact = if amount > 0 && out_amount_u64 > 0 { - let expected_ratio = 1.0; - let actual_ratio = out_amount_u64 as f64 / amount as f64; - ((expected_ratio - actual_ratio).abs() / expected_ratio * 100.0).min(100.0) - } else { - 0.0 - }; - debug!( asset = %asset_id, in_amount = amount, @@ -273,7 +264,7 @@ where output_asset: "native".to_string(), in_amount: amount, out_amount: out_amount_u64, - price_impact_pct: price_impact, + price_impact_pct: 0.0, slippage_bps: (slippage * 100.0) as u32, path: Some(vec![ PathStep { @@ -491,12 +482,13 @@ mod tests { assert_eq!(service.native_wrapper_address, custom_wrapper); } - // ==================== parse_contract_address Tests ==================== + // ==================== parse_contract_to_sc_address Tests ==================== #[test] - fn test_parse_contract_address_valid() { + fn test_parse_contract_to_sc_address_valid() { let addr = "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC"; - let result = SoroswapService::::parse_contract_address(addr); + let result = + SoroswapService::::parse_contract_to_sc_address(addr); assert!(result.is_ok()); match result.unwrap() { ScAddress::Contract(_) => {} @@ -505,9 +497,10 @@ mod tests { } #[test] - fn test_parse_contract_address_invalid_format() { + fn test_parse_contract_to_sc_address_invalid_format() { let addr = "INVALID_ADDRESS"; - let result = SoroswapService::::parse_contract_address(addr); + let result = + SoroswapService::::parse_contract_to_sc_address(addr); assert!(result.is_err()); match result.unwrap_err() { StellarDexServiceError::InvalidAssetIdentifier(msg) => { @@ -518,10 +511,11 @@ mod tests { } #[test] - fn test_parse_contract_address_stellar_account_not_contract() { + fn test_parse_contract_to_sc_address_stellar_account_not_contract() { // A valid Stellar account address (G...) but not a contract (C...) let addr = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF"; - let result = SoroswapService::::parse_contract_address(addr); + let result = + SoroswapService::::parse_contract_to_sc_address(addr); assert!(result.is_err()); } @@ -1193,7 +1187,7 @@ mod tests { .await .unwrap(); - // Price impact should be around 10% - assert!(quote.price_impact_pct > 9.0 && quote.price_impact_pct < 11.0); + // Price impact is not calculated for token -> XLM quotes (returns 0.0) + assert_eq!(quote.price_impact_pct, 0.0); } } diff --git a/src/services/stellar_fee_forwarder/mod.rs b/src/services/stellar_fee_forwarder/mod.rs index 48f1e98de..c1d3d77e4 100644 --- a/src/services/stellar_fee_forwarder/mod.rs +++ b/src/services/stellar_fee_forwarder/mod.rs @@ -179,13 +179,16 @@ where })?, }; - // Generate nonce using timestamp + // Generate nonce using timestamp combined with randomness for uniqueness let nonce = { + use rand::Rng; use std::time::{SystemTime, UNIX_EPOCH}; - SystemTime::now() + let timestamp = SystemTime::now() .duration_since(UNIX_EPOCH) .map(|d| d.as_nanos() as i64) - .unwrap_or(0) + .unwrap_or(0); + let random: i64 = rand::rng().random(); + timestamp ^ random }; // For simulation, signature must be an empty vector (not Void) @@ -237,13 +240,16 @@ where sub_invocations: VecM::default(), }; - // Generate nonce using timestamp (different from user nonce) + // Generate nonce using timestamp combined with randomness for uniqueness let nonce = { + use rand::Rng; use std::time::{SystemTime, UNIX_EPOCH}; - SystemTime::now() + let timestamp = SystemTime::now() .duration_since(UNIX_EPOCH) - .map(|d| (d.as_nanos() + 1) as i64) // +1 to ensure different from user nonce - .unwrap_or(1) + .map(|d| d.as_nanos() as i64) + .unwrap_or(0); + let random: i64 = rand::rng().random(); + timestamp ^ random }; // For simulation, signature must be an empty vector (not Void) @@ -510,13 +516,16 @@ where /// Generate a nonce for authorization /// /// The nonce should be unique per authorization to prevent replay attacks. - /// Using current timestamp as a simple nonce generator. + /// Combines timestamp with randomness for improved uniqueness. fn generate_nonce(&self) -> i64 { + use rand::Rng; use std::time::{SystemTime, UNIX_EPOCH}; - SystemTime::now() + let timestamp = SystemTime::now() .duration_since(UNIX_EPOCH) .map(|d| d.as_nanos() as i64) - .unwrap_or(0) + .unwrap_or(0); + let random: i64 = rand::rng().random(); + timestamp ^ random } /// Serialize an authorization entry to base64 XDR From 27590222e08f25794f4b0a03a68e2526534515c2 Mon Sep 17 00:00:00 2001 From: Nicolas Molina Date: Thu, 5 Feb 2026 12:51:33 -0300 Subject: [PATCH 12/22] fix: Update openapi file --- openapi.json | 68 ++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 55 insertions(+), 13 deletions(-) diff --git a/openapi.json b/openapi.json index 3cbee8653..47fd3cf26 100644 --- a/openapi.json +++ b/openapi.json @@ -5315,7 +5315,7 @@ }, { "$ref": "#/components/schemas/StellarPrepareTransactionResult", - "description": "Stellar-specific prepare transaction result" + "description": "Stellar-specific prepare transaction result (classic and Soroban)\nFor Soroban: includes optional user_auth_entry, expiration_ledger" } ], "description": "Network-agnostic prepare transaction response for gasless transactions.\nContains network-specific prepare transaction results." @@ -5348,7 +5348,7 @@ }, { "$ref": "#/components/schemas/StellarFeeEstimateResult", - "description": "Stellar-specific fee estimate result" + "description": "Stellar-specific fee estimate result (classic and Soroban)" } ], "description": "Network-agnostic fee estimate response for gasless transactions.\nContains network-specific fee estimate results." @@ -9104,10 +9104,10 @@ }, { "$ref": "#/components/schemas/StellarPrepareTransactionRequestParams", - "description": "Stellar-specific prepare transaction request parameters" + "description": "Stellar-specific prepare transaction request parameters (classic and Soroban)" } ], - "description": "Network-agnostic prepare transaction request parameters for gasless transactions.\nContains network-specific request parameters for preparing transactions with fee payments.\nThe network type is inferred from the relayer's network configuration." + "description": "Network-agnostic prepare transaction request parameters for gasless transactions.\nContains network-specific request parameters for preparing transactions with fee payments.\nThe network type is inferred from the relayer's network configuration.\n\nFor Stellar, supports both classic and Soroban gas abstraction:\n- Classic: Pass operations or transaction_xdr with classic fee token\n- Soroban: Pass transaction_xdr containing InvokeHostFunction, user_address, and contract fee token" }, "SponsoredTransactionBuildResponse": { "oneOf": [ @@ -9117,7 +9117,7 @@ }, { "$ref": "#/components/schemas/StellarPrepareTransactionResult", - "description": "Stellar-specific prepare transaction result" + "description": "Stellar-specific prepare transaction result (classic and Soroban)\nFor Soroban: includes optional user_auth_entry, expiration_ledger" } ], "description": "Network-agnostic prepare transaction response for gasless transactions.\nContains network-specific prepare transaction results." @@ -9130,10 +9130,10 @@ }, { "$ref": "#/components/schemas/StellarFeeEstimateRequestParams", - "description": "Stellar-specific fee estimate request parameters" + "description": "Stellar-specific fee estimate request parameters (classic and Soroban)" } ], - "description": "Network-agnostic fee estimate request parameters for gasless transactions.\nContains network-specific request parameters for fee estimation.\nThe network type is inferred from the relayer's network configuration." + "description": "Network-agnostic fee estimate request parameters for gasless transactions.\nContains network-specific request parameters for fee estimation.\nThe network type is inferred from the relayer's network configuration.\n\nFor Stellar, supports both classic and Soroban gas abstraction:\n- Classic: Pass operations or transaction_xdr with classic fee token (native/USDC:GA...)\n- Soroban: Pass transaction_xdr containing InvokeHostFunction, user_address, and contract fee token (C...)" }, "SponsoredTransactionQuoteResponse": { "oneOf": [ @@ -9143,7 +9143,7 @@ }, { "$ref": "#/components/schemas/StellarFeeEstimateResult", - "description": "Stellar-specific fee estimate result" + "description": "Stellar-specific fee estimate result (classic and Soroban)" } ], "description": "Network-agnostic fee estimate response for gasless transactions.\nContains network-specific fee estimate results." @@ -9210,7 +9210,7 @@ "properties": { "fee_token": { "type": "string", - "description": "Asset identifier for fee token (e.g., \"native\" or \"USDC:GA5Z...\")" + "description": "Asset identifier for fee token.\nFor classic: \"native\" or \"USDC:GA5Z...\" format.\nFor Soroban: contract address (C...) format." }, "operations": { "type": [ @@ -9234,7 +9234,7 @@ "string", "null" ], - "description": "Pre-built transaction XDR (base64 encoded, signed or unsigned)\nMutually exclusive with operations field" + "description": "Pre-built transaction XDR (base64 encoded, signed or unsigned)\nMutually exclusive with operations field.\nFor Soroban gas abstraction: pass XDR containing InvokeHostFunction operation." } }, "additionalProperties": false @@ -9258,6 +9258,20 @@ "fee_in_token_ui": { "type": "string", "description": "Estimated fee in token amount (decimal UI representation as string)" + }, + "max_fee_in_token": { + "type": [ + "string", + "null" + ], + "description": "Maximum fee in token amount (raw units as string).\nOnly present for Soroban gas abstraction - includes slippage buffer." + }, + "max_fee_in_token_ui": { + "type": [ + "string", + "null" + ], + "description": "Maximum fee in token amount (decimal UI representation as string).\nOnly present for Soroban gas abstraction - includes slippage buffer." } } }, @@ -9322,7 +9336,7 @@ "properties": { "fee_token": { "type": "string", - "description": "Asset identifier for fee token" + "description": "Asset identifier for fee token.\nFor classic: \"native\" or \"USDC:GA5Z...\" format.\nFor Soroban: contract address (C...) format." }, "operations": { "type": [ @@ -9346,7 +9360,7 @@ "string", "null" ], - "description": "Pre-built transaction XDR (base64 encoded, signed or unsigned)\nMutually exclusive with operations field" + "description": "Pre-built transaction XDR (base64 encoded, signed or unsigned)\nMutually exclusive with operations field.\nFor Soroban gas abstraction: pass XDR containing InvokeHostFunction operation." } }, "additionalProperties": false @@ -9378,10 +9392,31 @@ "type": "string", "description": "Asset identifier for fee token" }, + "max_fee_in_token": { + "type": [ + "string", + "null" + ], + "description": "Maximum fee in token amount (raw units as string).\nOnly present for Soroban gas abstraction - includes slippage buffer." + }, + "max_fee_in_token_ui": { + "type": [ + "string", + "null" + ], + "description": "Maximum fee in token amount (decimal UI representation as string).\nOnly present for Soroban gas abstraction - includes slippage buffer." + }, "transaction": { "type": "string", "description": "Extended transaction XDR (base64 encoded)" }, + "user_auth_entry": { + "type": [ + "string", + "null" + ], + "description": "User authorization entry XDR (base64 encoded).\nPresent for Soroban gas abstraction - user must sign this auth entry." + }, "valid_until": { "type": "string", "description": "Transaction validity timestamp (ISO 8601 format)" @@ -9538,6 +9573,13 @@ "$ref": "#/components/schemas/OperationSpec" } }, + "signed_auth_entry": { + "type": [ + "string", + "null" + ], + "description": "Signed Soroban authorization entry (base64 encoded SorobanAuthorizationEntry XDR)\nUsed for Soroban gas abstraction: contains the user's signed auth entry from /build response.\nWhen provided, transaction_xdr must also be provided (the FeeForwarder transaction from /build).\nThe relayer will inject this signed auth entry into the transaction before submitting." + }, "source_account": { "type": [ "string", @@ -9549,7 +9591,7 @@ "string", "null" ], - "description": "Pre-built transaction XDR (base64 encoded, signed or unsigned)\nMutually exclusive with operations field" + "description": "Pre-built transaction XDR (base64 encoded, signed or unsigned)\nMutually exclusive with operations field.\nFor Soroban gas abstraction: submit the transaction XDR from sponsored/build response\nwith the user's signed auth entry updated inside." }, "valid_until": { "type": [ From f09c6b6376fdec205aa7ac90e32465cae7184218 Mon Sep 17 00:00:00 2001 From: Nicolas Molina Date: Thu, 5 Feb 2026 14:58:35 -0300 Subject: [PATCH 13/22] feat: Enhance Stellar DEX service integration --- src/domain/transaction/mod.rs | 82 ++++++++++++++++--- .../prepare/soroban_gas_abstraction.rs | 19 +++-- .../stellar/stellar_transaction.rs | 4 +- 3 files changed, 87 insertions(+), 18 deletions(-) diff --git a/src/domain/transaction/mod.rs b/src/domain/transaction/mod.rs index 5523ce9a1..aba32ee36 100644 --- a/src/domain/transaction/mod.rs +++ b/src/domain/transaction/mod.rs @@ -12,11 +12,14 @@ //! The module leverages async traits to handle asynchronous operations and uses the `eyre` crate //! for error handling. use crate::{ - constants::{STELLAR_HORIZON_MAINNET_URL, STELLAR_HORIZON_TESTNET_URL}, + constants::{ + get_default_soroswap_factory, get_default_soroswap_router, STELLAR_HORIZON_MAINNET_URL, + STELLAR_HORIZON_TESTNET_URL, + }, jobs::{JobProducer, StatusCheckContext}, models::{ EvmNetwork, NetworkTransactionRequest, NetworkType, RelayerRepoModel, SignerRepoModel, - SolanaNetwork, StellarNetwork, TransactionError, TransactionRepoModel, + SolanaNetwork, StellarNetwork, StellarSwapStrategy, TransactionError, TransactionRepoModel, }, repositories::{ NetworkRepository, NetworkRepositoryStorage, RelayerRepositoryStorage, @@ -29,7 +32,7 @@ use crate::{ }, provider::get_network_provider, signer::{EvmSignerFactory, SolanaSignerFactory, StellarSignerFactory}, - stellar_dex::OrderBookService, + stellar_dex::{DexServiceWrapper, OrderBookService, SoroswapService, StellarDexService}, }, }; use async_trait::async_trait; @@ -635,7 +638,7 @@ impl RelayerTransactionFactory { get_network_provider(&network, relayer.custom_rpc_urls.clone()) .map_err(|e| TransactionError::NetworkConfiguration(e.to_string()))?; - // Create DEX service for swap operations and validations using Horizon API + // Create DEX service for swap operations and validations let horizon_url = network.horizon_url.clone().unwrap_or_else(|| { if network.is_testnet() { STELLAR_HORIZON_TESTNET_URL.to_string() @@ -646,13 +649,70 @@ impl RelayerTransactionFactory { let provider_arc = Arc::new(stellar_provider.clone()); // Clone Arc for DEX service (cheap - just increments reference count) let signer_arc = signer_service.clone(); - let dex_service = Arc::new( - OrderBookService::new(horizon_url, provider_arc, signer_arc).map_err(|e| { - TransactionError::NetworkConfiguration(format!( - "Failed to create DEX service: {e}", - )) - })?, - ); + + // Get strategies from relayer policy (default to OrderBook if none specified) + let strategies = relayer + .policies + .get_stellar_policy() + .get_swap_config() + .and_then(|config| { + if config.strategies.is_empty() { + None + } else { + Some(config.strategies.clone()) + } + }) + .unwrap_or_else(|| vec![StellarSwapStrategy::OrderBook]); + + // Create DEX services for each configured strategy + let mut dex_services: Vec> = Vec::new(); + for strategy in &strategies { + match strategy { + StellarSwapStrategy::OrderBook => { + let order_book_service = Arc::new( + OrderBookService::new( + horizon_url.clone(), + provider_arc.clone(), + signer_arc.clone(), + ) + .map_err(|e| { + TransactionError::NetworkConfiguration(format!( + "Failed to create OrderBook DEX service: {e}" + )) + })?, + ); + dex_services.push(DexServiceWrapper::OrderBook(order_book_service)); + } + StellarSwapStrategy::Soroswap => { + let router_address = + crate::config::ServerConfig::get_stellar_soroswap_router_address() + .unwrap_or_else(|| { + get_default_soroswap_router(network.is_testnet()) + .to_string() + }); + let factory_address = + crate::config::ServerConfig::get_stellar_soroswap_factory_address() + .unwrap_or_else(|| { + get_default_soroswap_factory(network.is_testnet()) + .to_string() + }); + let native_wrapper_address = crate::config::ServerConfig::get_stellar_soroswap_native_wrapper_address(); + + let soroswap_service = Arc::new(SoroswapService::new( + router_address, + factory_address, + native_wrapper_address, + provider_arc.clone(), + network.passphrase.clone(), + network.is_testnet(), + )); + dex_services.push(DexServiceWrapper::Soroswap(soroswap_service)); + } + } + } + + // Create multi-strategy DEX service + let dex_service = Arc::new(StellarDexService::new(dex_services)); Ok(NetworkTransaction::Stellar(DefaultStellarTransaction::new( relayer, diff --git a/src/domain/transaction/stellar/prepare/soroban_gas_abstraction.rs b/src/domain/transaction/stellar/prepare/soroban_gas_abstraction.rs index 11a076efa..b22d8c620 100644 --- a/src/domain/transaction/stellar/prepare/soroban_gas_abstraction.rs +++ b/src/domain/transaction/stellar/prepare/soroban_gas_abstraction.rs @@ -93,16 +93,25 @@ where )), })?; + // Inject the user's signed auth entry and convert relayer's auth to SourceAccount + // This must happen BEFORE fee validation because the simulation in fee estimation + // requires proper auth entries with signatures to succeed. + let signed_auth_entries = inject_auth_entries_into_envelope(&mut envelope, signed_user_auth)?; + // Validate fee parameters to ensure relayer liquidity // This validates: token is allowed, max_fee_amount covers the required fee + // Note: We pass the first auth entry (user's) which contains the fee parameters if let Some(policy) = policy { - validate_gas_abstraction_fee(&envelope, &signed_user_auth, policy, provider, dex_service) - .await?; + validate_gas_abstraction_fee( + &envelope, + &signed_auth_entries[0], + policy, + provider, + dex_service, + ) + .await?; } - // Inject the user's signed auth entry and convert relayer's auth to SourceAccount - let signed_auth_entries = inject_auth_entries_into_envelope(&mut envelope, signed_user_auth)?; - // Re-simulate with signed auth entries to get accurate footprint. // // According to Soroban flow, after signing auth entries you must re-simulate: diff --git a/src/domain/transaction/stellar/stellar_transaction.rs b/src/domain/transaction/stellar/stellar_transaction.rs index 66ba7d6a0..25a8ad48b 100644 --- a/src/domain/transaction/stellar/stellar_transaction.rs +++ b/src/domain/transaction/stellar/stellar_transaction.rs @@ -19,7 +19,7 @@ use crate::{ services::{ provider::{StellarProvider, StellarProviderTrait}, signer::{Signer, StellarSignTrait, StellarSigner}, - stellar_dex::{OrderBookService, StellarDexServiceTrait}, + stellar_dex::{StellarDexService, StellarDexServiceTrait}, }, utils::calculate_scheduled_timestamp, }; @@ -366,7 +366,7 @@ pub type DefaultStellarTransaction = StellarRelayerTransaction< StellarSigner, StellarProvider, TransactionCounterRepositoryStorage, - OrderBookService, + StellarDexService, >; #[cfg(test)] From 2a12a945e5d252d54b2546c4909cd6675f30e657 Mon Sep 17 00:00:00 2001 From: Nicolas Molina Date: Thu, 5 Feb 2026 16:40:00 -0300 Subject: [PATCH 14/22] feat: Adding soroban token swaps --- .../stellar/prepare/unsigned_xdr.rs | 146 +++++- src/models/relayer/mod.rs | 9 +- src/services/stellar_dex/soroswap_service.rs | 468 +++++++++++++++++- 3 files changed, 592 insertions(+), 31 deletions(-) diff --git a/src/domain/transaction/stellar/prepare/unsigned_xdr.rs b/src/domain/transaction/stellar/prepare/unsigned_xdr.rs index d9bb7845b..1e3ab5545 100644 --- a/src/domain/transaction/stellar/prepare/unsigned_xdr.rs +++ b/src/domain/transaction/stellar/prepare/unsigned_xdr.rs @@ -2,7 +2,10 @@ //! It includes XDR parsing, validation, sequence updating, and fee updating. use eyre::Result; -use soroban_rs::xdr::{Limits, OperationBody, ReadXdr, TransactionEnvelope, WriteXdr}; +use soroban_rs::xdr::{ + HostFunction, Limits, OperationBody, ReadXdr, ScAddress, ScVal, SorobanTransactionData, + TransactionEnvelope, TransactionExt, WriteXdr, +}; use tracing::debug; use crate::{ @@ -132,13 +135,27 @@ fn is_valid_swap_transaction( return Ok(false); } - // Check if the operation is PathPaymentStrictSend let op = &operations[0]; - let path_payment = match &op.body { - OperationBody::PathPaymentStrictSend(path_payment_op) => path_payment_op, - _ => return Ok(false), - }; + match &op.body { + // OrderBook swap: PathPaymentStrictSend (classic assets) + OperationBody::PathPaymentStrictSend(path_payment) => { + is_valid_orderbook_swap(path_payment, relayer_address, policy) + } + // Soroswap swap: InvokeHostFunction calling swap_exact_tokens_for_tokens + OperationBody::InvokeHostFunction(invoke_op) => { + is_valid_soroswap_swap(&invoke_op.host_function, relayer_address) + } + _ => Ok(false), + } +} + +/// Validate an OrderBook swap transaction (PathPaymentStrictSend) +fn is_valid_orderbook_swap( + path_payment: &soroban_rs::xdr::PathPaymentStrictSendOp, + relayer_address: &str, + policy: &RelayerStellarPolicy, +) -> Result { // Validate source asset is an allowed token let source_asset_id = asset_to_asset_id(&path_payment.send_asset).map_err(|e| { TransactionError::ValidationError(format!( @@ -179,7 +196,46 @@ fn is_valid_swap_transaction( return Ok(false); } - // All validations passed - this is a valid swap transaction + Ok(true) +} + +/// Validate a Soroswap swap transaction (InvokeHostFunction calling swap_exact_tokens_for_tokens) +fn is_valid_soroswap_swap( + host_function: &HostFunction, + relayer_address: &str, +) -> Result { + let invoke_args = match host_function { + HostFunction::InvokeContract(args) => args, + _ => return Ok(false), + }; + + // Must be calling swap_exact_tokens_for_tokens + if invoke_args.function_name.to_string() != "swap_exact_tokens_for_tokens" { + return Ok(false); + } + + // swap_exact_tokens_for_tokens(amount_in, amount_out_min, path, to, deadline) + // args[3] is the `to` address — must be the relayer + if invoke_args.args.len() != 5 { + return Ok(false); + } + + let to_address = match &invoke_args.args[3] { + ScVal::Address(ScAddress::Account(account_id)) => { + use soroban_rs::xdr::PublicKey; + match &account_id.0 { + PublicKey::PublicKeyTypeEd25519(key) => { + stellar_strkey::ed25519::PublicKey(key.0).to_string() + } + } + } + _ => return Ok(false), + }; + + if to_address != relayer_address { + return Ok(false); + } + Ok(true) } @@ -317,7 +373,81 @@ where let stellar_data_with_sim = match simulate_if_needed(&envelope, provider).await? { Some(sim_resp) => { debug!("Applying simulation results to unsigned XDR transaction"); - // Get operation count from the envelope + + // Parse SorobanTransactionData from simulation response + let soroban_tx_data = + SorobanTransactionData::from_xdr_base64(&sim_resp.transaction_data, Limits::none()) + .map_err(|e| { + TransactionError::ValidationError(format!( + "Failed to parse simulation transaction_data: {e}" + )) + })?; + + // Apply simulation results to the envelope: + // 1. Set TransactionExt::V1 with SorobanTransactionData (resource footprint) + // 2. Set auth entries on InvokeHostFunction operations + // 3. Update fee based on simulation + match &mut envelope { + TransactionEnvelope::Tx(ref mut env) => { + // Apply SorobanTransactionData (resource footprint, read/write sets) + env.tx.ext = TransactionExt::V1(soroban_tx_data); + + // Apply auth entries from simulation results + if let Ok(results) = sim_resp.results() { + if let Some(result) = results.first() { + if !result.auth.is_empty() { + // Find the InvokeHostFunction operation and set auth entries + for op in env.tx.operations.iter_mut() { + if let OperationBody::InvokeHostFunction(ref mut invoke_op) = + op.body + { + invoke_op.auth = + result.auth.clone().try_into().map_err(|_| { + TransactionError::ValidationError( + "Failed to set simulation auth entries" + .to_string(), + ) + })?; + debug!( + "Applied {} auth entries from simulation", + result.auth.len() + ); + } + } + } + } + } + + // Update fee: inclusion fee + resource fee from simulation + let op_count = env.tx.operations.len() as u64; + let inclusion_fee = op_count * STELLAR_DEFAULT_TRANSACTION_FEE as u64; + let resource_fee = sim_resp.min_resource_fee; + let updated_fee = u32::try_from(inclusion_fee + resource_fee) + .unwrap_or(u32::MAX) + .max(STELLAR_DEFAULT_TRANSACTION_FEE); + env.tx.fee = updated_fee; + + debug!( + "Applied simulation: fee={}, resource_fee={}", + updated_fee, resource_fee + ); + } + _ => { + return Err(TransactionError::ValidationError( + "Expected V1 transaction envelope for Soroban simulation".to_string(), + )); + } + } + + // Re-serialize the updated envelope with simulation data applied + let updated_xdr = envelope.to_xdr_base64(Limits::none()).map_err(|e| { + TransactionError::ValidationError(format!( + "Failed to serialize envelope after simulation: {e}" + )) + })?; + + // Update stellar data with new XDR and simulation metadata + stellar_data.transaction_input = TransactionInput::UnsignedXdr(updated_xdr); let op_count = extract_operations(&envelope)?.len() as u64; stellar_data .with_simulation_data(sim_resp, op_count) diff --git a/src/models/relayer/mod.rs b/src/models/relayer/mod.rs index 9400a52ce..37729a82e 100644 --- a/src/models/relayer/mod.rs +++ b/src/models/relayer/mod.rs @@ -1047,12 +1047,9 @@ impl Relayer { // Check if it's a contract address (StrKey format starting with 'C') if asset.starts_with('C') && asset.len() == 56 && !asset.contains(':') { - return Err(RelayerValidationError::InvalidPolicy( - "Contract addresses are not supported. Soroban will be supported soon.".into(), - )); - // // Basic validation - contract addresses are 56 characters starting with 'C' - // // Full validation would require StrKey decoding, but this catches most invalid formats - // return Ok(()); + // Basic validation - contract addresses are 56 characters starting with 'C' + // Full validation would require StrKey decoding, but this catches most invalid formats + return Ok(()); } // Check if it's a classic asset format "CODE:ISSUER" diff --git a/src/services/stellar_dex/soroswap_service.rs b/src/services/stellar_dex/soroswap_service.rs index 76f936f5b..d989ce504 100644 --- a/src/services/stellar_dex/soroswap_service.rs +++ b/src/services/stellar_dex/soroswap_service.rs @@ -10,14 +10,24 @@ use super::{ AssetType, PathStep, StellarDexServiceError, StellarDexServiceTrait, StellarQuoteResponse, SwapExecutionResult, SwapTransactionParams, }; -use crate::constants::get_default_soroswap_native_wrapper; -use crate::domain::transaction::stellar::utils::parse_contract_address; +use crate::constants::{get_default_soroswap_native_wrapper, STELLAR_DEFAULT_TRANSACTION_FEE}; +use crate::domain::relayer::string_to_muxed_account; +use crate::domain::transaction::stellar::utils::{parse_account_id, parse_contract_address}; use crate::services::provider::StellarProviderTrait; use async_trait::async_trait; -use soroban_rs::xdr::{ContractId, Int128Parts, ScAddress, ScSymbol, ScVal, ScVec}; +use chrono::{Duration as ChronoDuration, Utc}; +use soroban_rs::xdr::{ + ContractId, HostFunction, Int128Parts, InvokeContractArgs, InvokeHostFunctionOp, Limits, Memo, + Operation, OperationBody, Preconditions, ScAddress, ScSymbol, ScVal, ScVec, SequenceNumber, + TimeBounds, TimePoint, Transaction, TransactionEnvelope, TransactionExt, TransactionV1Envelope, + VecM, WriteXdr, +}; use std::collections::HashSet; use std::sync::Arc; -use tracing::{debug, warn}; +use tracing::{debug, info, warn}; + +/// Transaction validity window in minutes +const TRANSACTION_VALIDITY_MINUTES: i64 = 5; /// Soroswap AMM DEX service for Soroban token swaps /// @@ -179,6 +189,161 @@ where Self::scval_to_amounts_vec(&result) } + + /// Parse a Stellar account address (G...) to ScAddress::Account + fn parse_account_to_sc_address(address: &str) -> Result { + let account_id = parse_account_id(address).map_err(|e| { + StellarDexServiceError::InvalidAssetIdentifier(format!( + "Invalid Stellar account address '{address}': {e}" + )) + })?; + Ok(ScAddress::Account(account_id)) + } + + /// Build the Soroswap router swap transaction XDR (unsigned) + /// + /// Creates an `InvokeHostFunction` transaction that calls the Soroswap router's + /// `swap_exact_tokens_for_tokens` function. The transaction is returned as unsigned + /// base64-encoded XDR with placeholder sequence number (0). The transaction pipeline + /// will handle simulation (to get resources, footprint, and auth entries), sequence + /// number assignment, fee calculation, signing, and submission. + /// + /// Soroswap router function signature: + /// ```text + /// swap_exact_tokens_for_tokens( + /// amount_in: i128, + /// amount_out_min: i128, + /// path: Vec

, + /// to: Address, + /// deadline: u64 + /// ) -> Vec + /// ``` + fn build_swap_transaction_xdr( + &self, + params: &SwapTransactionParams, + quote: &StellarQuoteResponse, + ) -> Result { + // Step 1: Parse source account to MuxedAccount (for transaction source) + let source_account = string_to_muxed_account(¶ms.source_account).map_err(|e| { + StellarDexServiceError::InvalidAssetIdentifier(format!("Invalid source account: {e}")) + })?; + + // Step 2: Parse source account to ScAddress (for the `to` parameter — relayer swaps to itself) + let to_address = Self::parse_account_to_sc_address(¶ms.source_account)?; + + // Step 3: Calculate amount_out_min with slippage protection + // Formula: out_amount * (10000 - slippage_bps) / 10000 + let out_amount = quote.out_amount as u128; + let slippage_bps = quote.slippage_bps as u128; + let basis = 10000u128; + + let amount_out_min_u128 = out_amount + .checked_mul(basis.saturating_sub(slippage_bps)) + .ok_or_else(|| { + StellarDexServiceError::UnknownError( + "Overflow calculating minimum output amount".to_string(), + ) + })? + .checked_div(basis) + .ok_or_else(|| StellarDexServiceError::UnknownError("Division error".to_string()))?; + + // Ensure we don't request 0 if the quote was non-zero + let amount_out_min = if amount_out_min_u128 == 0 && out_amount > 0 { + 1i128 + } else { + amount_out_min_u128 as i128 + }; + + // Step 4: Resolve token addresses (replace "native" with wrapper contract address) + let from_token = if params.source_asset == "native" || params.source_asset.is_empty() { + self.native_wrapper_address.clone() + } else { + params.source_asset.clone() + }; + + let to_token = + if params.destination_asset == "native" || params.destination_asset.is_empty() { + self.native_wrapper_address.clone() + } else { + params.destination_asset.clone() + }; + + // Step 5: Build the path as Vec + let path = self.build_path(&from_token, &to_token)?; + + // Step 6: Calculate deadline (Unix timestamp, now + validity window) + let now = Utc::now(); + let deadline = now + ChronoDuration::minutes(TRANSACTION_VALIDITY_MINUTES); + let deadline_timestamp = deadline.timestamp() as u64; + + // Step 7: Build router contract invocation args + let router_addr = Self::parse_contract_to_sc_address(&self.router_address)?; + let function_name = ScSymbol::try_from("swap_exact_tokens_for_tokens").map_err(|_| { + StellarDexServiceError::UnknownError( + "Failed to create swap function symbol".to_string(), + ) + })?; + + let args: VecM = vec![ + Self::i128_to_scval(params.amount as i128), // amount_in + Self::i128_to_scval(amount_out_min), // amount_out_min + path, // path: Vec
+ ScVal::Address(to_address), // to: relayer address + ScVal::U64(deadline_timestamp), // deadline: Unix timestamp + ] + .try_into() + .map_err(|_| { + StellarDexServiceError::UnknownError("Failed to create swap function args".to_string()) + })?; + + // Step 8: Create InvokeHostFunction operation + let host_function = HostFunction::InvokeContract(InvokeContractArgs { + contract_address: router_addr, + function_name, + args, + }); + + let invoke_op = Operation { + source_account: None, + body: OperationBody::InvokeHostFunction(InvokeHostFunctionOp { + host_function, + auth: VecM::default(), // Empty — simulation will populate auth entries + }), + }; + + // Step 9: Build time bounds + let time_bounds = TimeBounds { + min_time: TimePoint(0), + max_time: TimePoint(deadline_timestamp), + }; + + // Step 10: Build Transaction with placeholder sequence and fee + let transaction = Transaction { + source_account, + fee: STELLAR_DEFAULT_TRANSACTION_FEE, + seq_num: SequenceNumber(0), // Placeholder — pipeline updates + cond: Preconditions::Time(time_bounds), + memo: Memo::None, + operations: vec![invoke_op].try_into().map_err(|_| { + StellarDexServiceError::UnknownError( + "Failed to create operations vector".to_string(), + ) + })?, + ext: TransactionExt::V0, + }; + + // Step 11: Create TransactionEnvelope and serialize + let envelope = TransactionEnvelope::Tx(TransactionV1Envelope { + tx: transaction, + signatures: VecM::default(), // Unsigned + }); + + envelope.to_xdr_base64(Limits::none()).map_err(|e| { + StellarDexServiceError::UnknownError(format!( + "Failed to serialize transaction to XDR: {e}" + )) + }) + } } #[async_trait] @@ -367,13 +532,7 @@ where &self, params: SwapTransactionParams, ) -> Result<(String, StellarQuoteResponse), StellarDexServiceError> { - // For gas abstraction, we don't actually need to prepare a swap transaction - // The FeeForwarder contract handles the token transfer - // This method is mainly used for the relayer's own token swaps - - warn!("Soroswap prepare_swap_transaction is not yet fully implemented"); - - // Get the quote first + // Get a quote for the swap let quote = if params.destination_asset == "native" { self.get_token_to_xlm_quote( ¶ms.source_asset, @@ -396,11 +555,20 @@ where )); }; - // TODO: Build the actual swap transaction XDR - // For now, return empty transaction as placeholder - let placeholder_xdr = String::new(); + info!( + "Preparing Soroswap swap transaction: {} {} -> {} (min receive: {})", + params.amount, params.source_asset, params.destination_asset, quote.out_amount + ); + + // Build the unsigned swap transaction XDR + let xdr = self.build_swap_transaction_xdr(¶ms, "e)?; - Ok((placeholder_xdr, quote)) + info!( + "Successfully prepared Soroswap swap transaction XDR ({} bytes)", + xdr.len() + ); + + Ok((xdr, quote)) } async fn execute_swap( @@ -426,6 +594,7 @@ mod tests { }; use crate::services::provider::MockStellarProviderTrait; use futures::FutureExt; + use soroban_rs::xdr::ReadXdr; fn create_mock_provider() -> Arc { Arc::new(MockStellarProviderTrait::new()) @@ -1054,8 +1223,23 @@ mod tests { let (xdr, quote) = service.prepare_swap_transaction(params).await.unwrap(); - assert!(xdr.is_empty()); // Placeholder + assert!(!xdr.is_empty()); assert_eq!(quote.out_amount, 950_000); + + // Verify XDR is a valid TransactionEnvelope with InvokeHostFunction + let envelope = TransactionEnvelope::from_xdr_base64(&xdr, Limits::none()).unwrap(); + match &envelope { + TransactionEnvelope::Tx(env) => { + assert_eq!(env.tx.operations.len(), 1); + assert!(matches!( + env.tx.operations[0].body, + OperationBody::InvokeHostFunction(_) + )); + // Sequence should be 0 (placeholder) + assert_eq!(env.tx.seq_num.0, 0); + } + _ => panic!("Expected Tx envelope"), + } } #[tokio::test] @@ -1092,8 +1276,21 @@ mod tests { let (xdr, quote) = service.prepare_swap_transaction(params).await.unwrap(); - assert!(xdr.is_empty()); // Placeholder + assert!(!xdr.is_empty()); assert_eq!(quote.out_amount, 1_050_000); + + // Verify XDR is valid + let envelope = TransactionEnvelope::from_xdr_base64(&xdr, Limits::none()).unwrap(); + match &envelope { + TransactionEnvelope::Tx(env) => { + assert_eq!(env.tx.operations.len(), 1); + assert!(matches!( + env.tx.operations[0].body, + OperationBody::InvokeHostFunction(_) + )); + } + _ => panic!("Expected Tx envelope"), + } } #[tokio::test] @@ -1124,6 +1321,243 @@ mod tests { } } + // ==================== parse_account_to_sc_address Tests ==================== + + #[test] + fn test_parse_account_to_sc_address_valid() { + let addr = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF"; + let result = SoroswapService::::parse_account_to_sc_address(addr); + assert!(result.is_ok()); + match result.unwrap() { + ScAddress::Account(_) => {} + _ => panic!("Expected Account address"), + } + } + + #[test] + fn test_parse_account_to_sc_address_invalid() { + let addr = "INVALID"; + let result = SoroswapService::::parse_account_to_sc_address(addr); + assert!(result.is_err()); + } + + // ==================== build_swap_transaction_xdr Tests ==================== + + #[test] + fn test_build_swap_transaction_xdr_token_to_xlm() { + let provider = create_mock_provider(); + let service = create_test_service(provider, true); + + let quote = StellarQuoteResponse { + input_asset: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC".to_string(), + output_asset: "native".to_string(), + in_amount: 1_000_000, + out_amount: 950_000, + price_impact_pct: 0.0, + slippage_bps: 50, + path: None, + }; + + let params = SwapTransactionParams { + source_account: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF".to_string(), + source_asset: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC".to_string(), + destination_asset: "native".to_string(), + amount: 1_000_000, + slippage_percent: 0.5, + network_passphrase: "Test SDF Network ; September 2015".to_string(), + source_asset_decimals: Some(7), + destination_asset_decimals: None, + }; + + let xdr = service.build_swap_transaction_xdr(¶ms, "e).unwrap(); + + // Verify the XDR parses correctly + let envelope = TransactionEnvelope::from_xdr_base64(&xdr, Limits::none()).unwrap(); + match &envelope { + TransactionEnvelope::Tx(env) => { + // Verify transaction structure + assert_eq!(env.tx.operations.len(), 1); + assert_eq!(env.tx.seq_num.0, 0); // Placeholder + assert_eq!(env.tx.fee, STELLAR_DEFAULT_TRANSACTION_FEE); + assert!(env.signatures.is_empty()); // Unsigned + + // Verify it's an InvokeHostFunction with InvokeContract + match &env.tx.operations[0].body { + OperationBody::InvokeHostFunction(op) => { + match &op.host_function { + HostFunction::InvokeContract(args) => { + // Verify router contract address + match &args.contract_address { + ScAddress::Contract(_) => {} + _ => panic!("Expected Contract address for router"), + } + // Verify function name + assert_eq!( + args.function_name.to_string(), + "swap_exact_tokens_for_tokens" + ); + // Verify 5 arguments + assert_eq!(args.args.len(), 5); + } + _ => panic!("Expected InvokeContract"), + } + // Auth should be empty (simulation fills it) + assert!(op.auth.is_empty()); + } + _ => panic!("Expected InvokeHostFunction"), + } + + // Verify time bounds + match &env.tx.cond { + Preconditions::Time(tb) => { + assert_eq!(tb.min_time.0, 0); + assert!(tb.max_time.0 > 0); + } + _ => panic!("Expected Time preconditions"), + } + } + _ => panic!("Expected Tx envelope"), + } + } + + #[test] + fn test_build_swap_transaction_xdr_native_to_token() { + let provider = create_mock_provider(); + let service = create_test_service(provider, true); + + let quote = StellarQuoteResponse { + input_asset: "native".to_string(), + output_asset: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC".to_string(), + in_amount: 1_000_000, + out_amount: 1_050_000, + price_impact_pct: 0.0, + slippage_bps: 50, + path: None, + }; + + let params = SwapTransactionParams { + source_account: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF".to_string(), + source_asset: "native".to_string(), + destination_asset: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC" + .to_string(), + amount: 1_000_000, + slippage_percent: 0.5, + network_passphrase: "Test SDF Network ; September 2015".to_string(), + source_asset_decimals: None, + destination_asset_decimals: Some(7), + }; + + let xdr = service.build_swap_transaction_xdr(¶ms, "e).unwrap(); + + // Verify valid XDR with InvokeHostFunction + let envelope = TransactionEnvelope::from_xdr_base64(&xdr, Limits::none()).unwrap(); + match &envelope { + TransactionEnvelope::Tx(env) => { + assert_eq!(env.tx.operations.len(), 1); + match &env.tx.operations[0].body { + OperationBody::InvokeHostFunction(op) => match &op.host_function { + HostFunction::InvokeContract(args) => { + assert_eq!( + args.function_name.to_string(), + "swap_exact_tokens_for_tokens" + ); + assert_eq!(args.args.len(), 5); + } + _ => panic!("Expected InvokeContract"), + }, + _ => panic!("Expected InvokeHostFunction"), + } + } + _ => panic!("Expected Tx envelope"), + } + } + + #[test] + fn test_build_swap_transaction_xdr_invalid_source_account() { + let provider = create_mock_provider(); + let service = create_test_service(provider, true); + + let quote = StellarQuoteResponse { + input_asset: "native".to_string(), + output_asset: "native".to_string(), + in_amount: 1_000_000, + out_amount: 1_000_000, + price_impact_pct: 0.0, + slippage_bps: 50, + path: None, + }; + + let params = SwapTransactionParams { + source_account: "INVALID_ACCOUNT".to_string(), + source_asset: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC".to_string(), + destination_asset: "native".to_string(), + amount: 1_000_000, + slippage_percent: 0.5, + network_passphrase: "Test SDF Network ; September 2015".to_string(), + source_asset_decimals: None, + destination_asset_decimals: None, + }; + + let result = service.build_swap_transaction_xdr(¶ms, "e); + assert!(result.is_err()); + } + + #[test] + fn test_build_swap_transaction_xdr_slippage_calculation() { + let provider = create_mock_provider(); + let service = create_test_service(provider, true); + + // With 100 bps (1%) slippage on 1_000_000 output, min should be 990_000 + let quote = StellarQuoteResponse { + input_asset: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC".to_string(), + output_asset: "native".to_string(), + in_amount: 1_000_000, + out_amount: 1_000_000, + price_impact_pct: 0.0, + slippage_bps: 100, // 1% + path: None, + }; + + let params = SwapTransactionParams { + source_account: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF".to_string(), + source_asset: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC".to_string(), + destination_asset: "native".to_string(), + amount: 1_000_000, + slippage_percent: 1.0, + network_passphrase: "Test SDF Network ; September 2015".to_string(), + source_asset_decimals: Some(7), + destination_asset_decimals: None, + }; + + let xdr = service.build_swap_transaction_xdr(¶ms, "e).unwrap(); + + // Parse and verify amount_out_min argument + let envelope = TransactionEnvelope::from_xdr_base64(&xdr, Limits::none()).unwrap(); + match &envelope { + TransactionEnvelope::Tx(env) => { + match &env.tx.operations[0].body { + OperationBody::InvokeHostFunction(op) => { + match &op.host_function { + HostFunction::InvokeContract(args) => { + // args[1] is amount_out_min + let amount_out_min = + SoroswapService::::scval_to_i128( + &args.args[1], + ) + .unwrap(); + // 1_000_000 * (10000 - 100) / 10000 = 990_000 + assert_eq!(amount_out_min, 990_000); + } + _ => panic!("Expected InvokeContract"), + } + } + _ => panic!("Expected InvokeHostFunction"), + } + } + _ => panic!("Expected Tx envelope"), + } + } + // ==================== execute_swap Tests ==================== #[tokio::test] From 3597cdb445b3a5bdeac476cd80b8dd7d879104f3 Mon Sep 17 00:00:00 2001 From: Nicolas Molina Date: Thu, 5 Feb 2026 17:25:19 -0300 Subject: [PATCH 15/22] test: Adding test cases --- .../stellar/prepare/unsigned_xdr.rs | 520 ++++++++++++++++++ 1 file changed, 520 insertions(+) diff --git a/src/domain/transaction/stellar/prepare/unsigned_xdr.rs b/src/domain/transaction/stellar/prepare/unsigned_xdr.rs index 1e3ab5545..0c16a1354 100644 --- a/src/domain/transaction/stellar/prepare/unsigned_xdr.rs +++ b/src/domain/transaction/stellar/prepare/unsigned_xdr.rs @@ -1156,6 +1156,526 @@ mod tests { assert!(result.is_ok()); assert!(!result.unwrap()); // Should return false for wrong destination } + + // ===== Soroswap Swap Validation Tests ===== + + /// Helper: build a V1 envelope with a single InvokeHostFunction operation + fn create_soroswap_invoke_envelope( + source: &str, + function_name: &str, + args: Vec, + ) -> TransactionEnvelope { + use soroban_rs::xdr::{ContractId, Hash, InvokeContractArgs, InvokeHostFunctionOp, VecM}; + + let invoke_args = InvokeContractArgs { + contract_address: ScAddress::Contract(ContractId(Hash([0u8; 32]))), + function_name: function_name.as_bytes().to_vec().try_into().unwrap(), + args: args.try_into().unwrap(), + }; + + let op = soroban_rs::xdr::Operation { + source_account: None, + body: OperationBody::InvokeHostFunction(InvokeHostFunctionOp { + host_function: HostFunction::InvokeContract(invoke_args), + auth: VecM::default(), + }), + }; + + create_v1_envelope(source, vec![op], 100, 1) + } + + /// Helper: build the 5 ScVal args for `swap_exact_tokens_for_tokens` + fn create_soroswap_swap_args(to_address: &str) -> Vec { + use soroban_rs::xdr::{AccountId, Int128Parts, PublicKey, Uint256}; + + let pk = stellar_strkey::ed25519::PublicKey::from_string(to_address).unwrap(); + let to_account = ScVal::Address(ScAddress::Account(AccountId( + PublicKey::PublicKeyTypeEd25519(Uint256(pk.0)), + ))); + + vec![ + ScVal::I128(Int128Parts { hi: 0, lo: 1000000 }), // amount_in + ScVal::I128(Int128Parts { hi: 0, lo: 900000 }), // amount_out_min + ScVal::Vec(Some(Default::default())), // path (empty) + to_account, // to + ScVal::U64(1_000_000_000), // deadline + ] + } + + #[test] + fn test_is_valid_swap_transaction_with_valid_soroswap_swap() { + let relayer_address = TEST_PK; + let args = create_soroswap_swap_args(relayer_address); + let envelope = + create_soroswap_invoke_envelope(relayer_address, "swap_exact_tokens_for_tokens", args); + + let policy = RelayerStellarPolicy::default(); + let result = is_valid_swap_transaction(&envelope, relayer_address, &policy); + assert!(result.is_ok()); + assert!(result.unwrap()); + } + + #[test] + fn test_is_valid_swap_transaction_soroswap_wrong_function_name() { + let relayer_address = TEST_PK; + let args = create_soroswap_swap_args(relayer_address); + let envelope = create_soroswap_invoke_envelope(relayer_address, "transfer", args); + + let policy = RelayerStellarPolicy::default(); + let result = is_valid_swap_transaction(&envelope, relayer_address, &policy); + assert!(result.is_ok()); + assert!(!result.unwrap()); + } + + #[test] + fn test_is_valid_swap_transaction_soroswap_wrong_arg_count() { + use soroban_rs::xdr::Int128Parts; + + let relayer_address = TEST_PK; + // Only 3 args instead of 5 + let args = vec![ + ScVal::I128(Int128Parts { hi: 0, lo: 1000000 }), + ScVal::I128(Int128Parts { hi: 0, lo: 900000 }), + ScVal::U64(1_000_000_000), + ]; + let envelope = + create_soroswap_invoke_envelope(relayer_address, "swap_exact_tokens_for_tokens", args); + + let policy = RelayerStellarPolicy::default(); + let result = is_valid_swap_transaction(&envelope, relayer_address, &policy); + assert!(result.is_ok()); + assert!(!result.unwrap()); + } + + #[test] + fn test_is_valid_swap_transaction_soroswap_non_address_to_arg() { + use soroban_rs::xdr::Int128Parts; + + let relayer_address = TEST_PK; + // args[3] is U64 instead of Address + let args = vec![ + ScVal::I128(Int128Parts { hi: 0, lo: 1000000 }), + ScVal::I128(Int128Parts { hi: 0, lo: 900000 }), + ScVal::Vec(Some(Default::default())), + ScVal::U64(12345), // NOT an address + ScVal::U64(1_000_000_000), + ]; + let envelope = + create_soroswap_invoke_envelope(relayer_address, "swap_exact_tokens_for_tokens", args); + + let policy = RelayerStellarPolicy::default(); + let result = is_valid_swap_transaction(&envelope, relayer_address, &policy); + assert!(result.is_ok()); + assert!(!result.unwrap()); + } + + #[test] + fn test_is_valid_swap_transaction_soroswap_wrong_to_address() { + let relayer_address = TEST_PK; + let args = create_soroswap_swap_args(TEST_PK_2); // different address + let envelope = + create_soroswap_invoke_envelope(relayer_address, "swap_exact_tokens_for_tokens", args); + + let policy = RelayerStellarPolicy::default(); + let result = is_valid_swap_transaction(&envelope, relayer_address, &policy); + assert!(result.is_ok()); + assert!(!result.unwrap()); + } + + // ===== Simulation Path Tests ===== + + /// Helper: create a valid SorobanTransactionData XDR base64 string + fn create_valid_soroban_tx_data_xdr() -> String { + use soroban_rs::xdr::{LedgerFootprint, SorobanResources, SorobanTransactionDataExt, VecM}; + + let data = SorobanTransactionData { + ext: SorobanTransactionDataExt::V0, + resources: SorobanResources { + footprint: LedgerFootprint { + read_only: VecM::default(), + read_write: VecM::default(), + }, + instructions: 1000, + disk_read_bytes: 500, + write_bytes: 200, + }, + resource_fee: 10000, + }; + data.to_xdr_base64(Limits::none()).unwrap() + } + + /// Helper: create a valid SorobanAuthorizationEntry XDR base64 string + fn create_valid_auth_entry_xdr() -> String { + use soroban_rs::xdr::{ + ContractId, Hash, InvokeContractArgs, SorobanAuthorizationEntry, + SorobanAuthorizedFunction, SorobanAuthorizedInvocation, SorobanCredentials, VecM, + }; + + let entry = SorobanAuthorizationEntry { + credentials: SorobanCredentials::SourceAccount, + root_invocation: SorobanAuthorizedInvocation { + function: SorobanAuthorizedFunction::ContractFn(InvokeContractArgs { + contract_address: ScAddress::Contract(ContractId(Hash([0u8; 32]))), + function_name: "test".as_bytes().to_vec().try_into().unwrap(), + args: VecM::default(), + }), + sub_invocations: VecM::default(), + }, + }; + entry.to_xdr_base64(Limits::none()).unwrap() + } + + /// Helper: create StellarTransactionData from an envelope + fn create_stellar_data_with_invoke( + source: &str, + envelope: &TransactionEnvelope, + ) -> crate::models::StellarTransactionData { + use crate::models::TransactionInput; + + let xdr = envelope.to_xdr_base64(Limits::none()).unwrap(); + crate::models::StellarTransactionData { + source_account: source.to_string(), + network_passphrase: "Test SDF Network ; September 2015".to_string(), + fee: None, + sequence_number: None, + transaction_input: TransactionInput::UnsignedXdr(xdr), + memo: None, + valid_until: None, + signatures: vec![], + hash: None, + simulation_transaction_data: None, + signed_envelope_xdr: None, + transaction_result_xdr: None, + } + } + + /// Helper: set up default mocks for simulation tests + fn setup_simulation_mocks( + min_resource_fee: u64, + tx_data_xdr: String, + results: Vec, + ) -> ( + MockTransactionCounterTrait, + MockStellarProviderTrait, + MockSigner, + ) { + let mut counter = MockTransactionCounterTrait::new(); + counter + .expect_get_and_increment() + .returning(|_, _| Box::pin(ready(Ok(42)))); + + let mut provider = MockStellarProviderTrait::new(); + provider + .expect_simulate_transaction_envelope() + .returning(move |_| { + let tx_data = tx_data_xdr.clone(); + let res = results.clone(); + Box::pin(ready(Ok( + soroban_rs::stellar_rpc_client::SimulateTransactionResponse { + min_resource_fee, + transaction_data: tx_data, + results: res, + ..Default::default() + }, + ))) + }); + + let mut signer = MockSigner::new(); + signer.expect_address().returning(|| { + Box::pin(ready(Ok(crate::models::Address::Stellar( + "test-signer-address".to_string(), + )))) + }); + signer.expect_sign_transaction().returning(|_| { + let sig_bytes: Vec = vec![1u8; 64]; + let sig_bytes_m: BytesM<64> = sig_bytes.try_into().unwrap(); + Box::pin(ready(Ok(SignTransactionResponse::Stellar( + crate::domain::SignTransactionResponseStellar { + signature: DecoratedSignature { + hint: SignatureHint([0; 4]), + signature: Signature(sig_bytes_m), + }, + }, + )))) + }); + + (counter, provider, signer) + } + + #[tokio::test] + async fn test_process_unsigned_xdr_with_simulation_and_auth() { + let relayer_address = TEST_PK; + let relayer_id = "test-relayer"; + let tx_data_xdr = create_valid_soroban_tx_data_xdr(); + let auth_xdr = create_valid_auth_entry_xdr(); + let results = vec![ + soroban_rs::stellar_rpc_client::SimulateHostFunctionResultRaw { + auth: vec![auth_xdr], + xdr: ScVal::Void.to_xdr_base64(Limits::none()).unwrap(), + }, + ]; + + let (counter, provider, signer) = setup_simulation_mocks(5000, tx_data_xdr, results); + + let args = create_soroswap_swap_args(relayer_address); + let envelope = + create_soroswap_invoke_envelope(relayer_address, "swap_exact_tokens_for_tokens", args); + let stellar_data = create_stellar_data_with_invoke(relayer_address, &envelope); + + let dex_service = MockStellarDexServiceTrait::new(); + let result = process_unsigned_xdr( + &counter, + relayer_id, + relayer_address, + stellar_data, + &provider, + &signer, + None, + &dex_service, + ) + .await; + + assert!(result.is_ok(), "Expected success, got: {:?}", result); + let updated_data = result.unwrap(); + assert!(updated_data.signed_envelope_xdr.is_some()); + assert!(!updated_data.signatures.is_empty()); + assert!(updated_data.simulation_transaction_data.is_some()); + } + + #[tokio::test] + async fn test_process_unsigned_xdr_with_simulation_no_results() { + let relayer_address = TEST_PK; + let relayer_id = "test-relayer"; + let tx_data_xdr = create_valid_soroban_tx_data_xdr(); + + let (counter, provider, signer) = setup_simulation_mocks(3000, tx_data_xdr, vec![]); + + let args = create_soroswap_swap_args(relayer_address); + let envelope = + create_soroswap_invoke_envelope(relayer_address, "swap_exact_tokens_for_tokens", args); + let stellar_data = create_stellar_data_with_invoke(relayer_address, &envelope); + + let dex_service = MockStellarDexServiceTrait::new(); + let result = process_unsigned_xdr( + &counter, + relayer_id, + relayer_address, + stellar_data, + &provider, + &signer, + None, + &dex_service, + ) + .await; + + assert!(result.is_ok(), "Expected success, got: {:?}", result); + assert!(result.unwrap().simulation_transaction_data.is_some()); + } + + #[tokio::test] + async fn test_process_unsigned_xdr_with_simulation_empty_auth() { + let relayer_address = TEST_PK; + let relayer_id = "test-relayer"; + let tx_data_xdr = create_valid_soroban_tx_data_xdr(); + let results = vec![ + soroban_rs::stellar_rpc_client::SimulateHostFunctionResultRaw { + auth: vec![], // empty auth + xdr: ScVal::Void.to_xdr_base64(Limits::none()).unwrap(), + }, + ]; + + let (counter, provider, signer) = setup_simulation_mocks(5000, tx_data_xdr, results); + + let args = create_soroswap_swap_args(relayer_address); + let envelope = + create_soroswap_invoke_envelope(relayer_address, "swap_exact_tokens_for_tokens", args); + let stellar_data = create_stellar_data_with_invoke(relayer_address, &envelope); + + let dex_service = MockStellarDexServiceTrait::new(); + let result = process_unsigned_xdr( + &counter, + relayer_id, + relayer_address, + stellar_data, + &provider, + &signer, + None, + &dex_service, + ) + .await; + + assert!(result.is_ok(), "Expected success, got: {:?}", result); + } + + #[tokio::test] + async fn test_process_unsigned_xdr_with_simulation_invalid_auth_skipped() { + let relayer_address = TEST_PK; + let relayer_id = "test-relayer"; + let tx_data_xdr = create_valid_soroban_tx_data_xdr(); + // Invalid auth XDR causes results() to fail, which is silently skipped + let results = vec![ + soroban_rs::stellar_rpc_client::SimulateHostFunctionResultRaw { + auth: vec!["invalid-auth-xdr".to_string()], + xdr: ScVal::Void.to_xdr_base64(Limits::none()).unwrap(), + }, + ]; + + let (counter, provider, signer) = setup_simulation_mocks(5000, tx_data_xdr, results); + + let args = create_soroswap_swap_args(relayer_address); + let envelope = + create_soroswap_invoke_envelope(relayer_address, "swap_exact_tokens_for_tokens", args); + let stellar_data = create_stellar_data_with_invoke(relayer_address, &envelope); + + let dex_service = MockStellarDexServiceTrait::new(); + let result = process_unsigned_xdr( + &counter, + relayer_id, + relayer_address, + stellar_data, + &provider, + &signer, + None, + &dex_service, + ) + .await; + + // Invalid auth XDR is silently skipped via `if let Ok(results)` + assert!( + result.is_ok(), + "Expected success despite invalid auth, got: {:?}", + result + ); + } + + #[tokio::test] + async fn test_process_unsigned_xdr_with_simulation_invalid_tx_data() { + let relayer_address = TEST_PK; + let relayer_id = "test-relayer"; + + let (counter, provider, signer) = + setup_simulation_mocks(5000, "invalid-xdr-data".to_string(), vec![]); + + let args = create_soroswap_swap_args(relayer_address); + let envelope = + create_soroswap_invoke_envelope(relayer_address, "swap_exact_tokens_for_tokens", args); + let stellar_data = create_stellar_data_with_invoke(relayer_address, &envelope); + + let dex_service = MockStellarDexServiceTrait::new(); + let result = process_unsigned_xdr( + &counter, + relayer_id, + relayer_address, + stellar_data, + &provider, + &signer, + None, + &dex_service, + ) + .await; + + assert!(result.is_err()); + match result.unwrap_err() { + TransactionError::ValidationError(msg) => { + assert!( + msg.contains("Failed to parse simulation transaction_data"), + "Error message was: {}", + msg + ); + } + other => panic!("Expected ValidationError, got: {:?}", other), + } + } + + #[tokio::test] + async fn test_process_unsigned_xdr_with_simulation_fee_calculation() { + let relayer_address = TEST_PK; + let relayer_id = "test-relayer"; + let tx_data_xdr = create_valid_soroban_tx_data_xdr(); + let min_resource_fee = 7500u64; + + let (counter, provider, signer) = + setup_simulation_mocks(min_resource_fee, tx_data_xdr, vec![]); + + let args = create_soroswap_swap_args(relayer_address); + let envelope = + create_soroswap_invoke_envelope(relayer_address, "swap_exact_tokens_for_tokens", args); + let stellar_data = create_stellar_data_with_invoke(relayer_address, &envelope); + + let dex_service = MockStellarDexServiceTrait::new(); + let result = process_unsigned_xdr( + &counter, + relayer_id, + relayer_address, + stellar_data, + &provider, + &signer, + None, + &dex_service, + ) + .await; + + assert!(result.is_ok(), "Expected success, got: {:?}", result); + let updated_data = result.unwrap(); + // fee = 1 op * 100 (STELLAR_DEFAULT_TRANSACTION_FEE) + 7500 = 7600 + assert_eq!(updated_data.fee, Some(100 + min_resource_fee as u32)); + } + + #[tokio::test] + async fn test_process_unsigned_xdr_with_simulation_error_response() { + let relayer_address = TEST_PK; + let relayer_id = "test-relayer"; + + let mut counter = MockTransactionCounterTrait::new(); + counter + .expect_get_and_increment() + .returning(|_, _| Box::pin(ready(Ok(42)))); + + let mut provider = MockStellarProviderTrait::new(); + provider + .expect_simulate_transaction_envelope() + .returning(|_| { + Box::pin(ready(Ok( + soroban_rs::stellar_rpc_client::SimulateTransactionResponse { + error: Some("Simulation failed: insufficient resources".to_string()), + ..Default::default() + }, + ))) + }); + + let mut signer = MockSigner::new(); + signer.expect_address().returning(|| { + Box::pin(ready(Ok(crate::models::Address::Stellar( + "test-signer-address".to_string(), + )))) + }); + + let args = create_soroswap_swap_args(relayer_address); + let envelope = + create_soroswap_invoke_envelope(relayer_address, "swap_exact_tokens_for_tokens", args); + let stellar_data = create_stellar_data_with_invoke(relayer_address, &envelope); + + let dex_service = MockStellarDexServiceTrait::new(); + let result = process_unsigned_xdr( + &counter, + relayer_id, + relayer_address, + stellar_data, + &provider, + &signer, + None, + &dex_service, + ) + .await; + + assert!(result.is_err()); + match result.unwrap_err() { + TransactionError::SimulationFailed(msg) => { + assert!(msg.contains("Simulation failed")); + } + other => panic!("Expected SimulationFailed, got: {:?}", other), + } + } } #[cfg(test)] From cc5adf36b5e3b4b6094a1b0efbc010eaa93466ec Mon Sep 17 00:00:00 2001 From: Nicolas Molina Date: Fri, 6 Feb 2026 10:58:37 -0300 Subject: [PATCH 16/22] fix: Improvements to env vars and default addresses --- src/config/server_config.rs | 142 +++++++++++++++--- src/constants/stellar_transaction.rs | 85 ++--------- src/domain/relayer/stellar/gas_abstraction.rs | 99 ++++++------ src/domain/relayer/stellar/mod.rs | 45 +++--- src/domain/transaction/mod.rs | 39 +++-- src/services/stellar_dex/soroswap_service.rs | 105 ++++++------- .../stellar_dex/stellar_dex_service.rs | 3 +- src/utils/mocks.rs | 12 +- 8 files changed, 287 insertions(+), 243 deletions(-) diff --git a/src/config/server_config.rs b/src/config/server_config.rs index ebe37e14b..d1ba03bf2 100644 --- a/src/config/server_config.rs +++ b/src/config/server_config.rs @@ -6,6 +6,8 @@ use crate::{ constants::{ DEFAULT_PROVIDER_FAILURE_EXPIRATION_SECS, DEFAULT_PROVIDER_FAILURE_THRESHOLD, DEFAULT_PROVIDER_PAUSE_DURATION_SECS, MINIMUM_SECRET_VALUE_LENGTH, + STELLAR_FEE_FORWARDER_MAINNET, STELLAR_SOROSWAP_MAINNET_FACTORY, + STELLAR_SOROSWAP_MAINNET_NATIVE_WRAPPER, STELLAR_SOROSWAP_MAINNET_ROUTER, }, models::SecretString, }; @@ -28,6 +30,15 @@ impl FromStr for RepositoryStorageType { } } +/// Returns `Some(s.to_string())` when `s` is non-empty, `None` otherwise. +fn non_empty_const(s: &str) -> Option { + if s.is_empty() { + None + } else { + Some(s.to_string()) + } +} + #[derive(Debug, Clone)] pub struct ServerConfig { /// The host address the server will bind to. @@ -101,14 +112,22 @@ pub struct ServerConfig { pub connection_backlog: u32, /// Request handler timeout in seconds for API endpoints. pub request_timeout_seconds: u64, - /// Stellar FeeForwarder contract address for gas abstraction (C... format). - pub stellar_fee_forwarder_address: Option, - /// Stellar Soroswap router contract address for token-to-XLM quotes. - pub stellar_soroswap_router_address: Option, - /// Stellar Soroswap factory contract address (required for get_amounts_out). - pub stellar_soroswap_factory_address: Option, - /// Stellar native XLM wrapper token address for Soroswap. - pub stellar_soroswap_native_wrapper_address: Option, + /// Stellar mainnet FeeForwarder contract address for gas abstraction. + pub stellar_mainnet_fee_forwarder_address: Option, + /// Stellar testnet FeeForwarder contract address for gas abstraction. + pub stellar_testnet_fee_forwarder_address: Option, + /// Stellar mainnet Soroswap router contract address. + pub stellar_mainnet_soroswap_router_address: Option, + /// Stellar testnet Soroswap router contract address. + pub stellar_testnet_soroswap_router_address: Option, + /// Stellar mainnet Soroswap factory contract address. + pub stellar_mainnet_soroswap_factory_address: Option, + /// Stellar testnet Soroswap factory contract address. + pub stellar_testnet_soroswap_factory_address: Option, + /// Stellar mainnet native XLM wrapper token address for Soroswap. + pub stellar_mainnet_soroswap_native_wrapper_address: Option, + /// Stellar testnet native XLM wrapper token address for Soroswap. + pub stellar_testnet_soroswap_native_wrapper_address: Option, } impl ServerConfig { @@ -173,11 +192,22 @@ impl ServerConfig { max_connections: Self::get_max_connections(), connection_backlog: Self::get_connection_backlog(), request_timeout_seconds: Self::get_request_timeout_seconds(), - stellar_fee_forwarder_address: Self::get_stellar_fee_forwarder_address(), - stellar_soroswap_router_address: Self::get_stellar_soroswap_router_address(), - stellar_soroswap_factory_address: Self::get_stellar_soroswap_factory_address(), - stellar_soroswap_native_wrapper_address: - Self::get_stellar_soroswap_native_wrapper_address(), + stellar_mainnet_fee_forwarder_address: Self::get_stellar_mainnet_fee_forwarder_address( + ), + stellar_testnet_fee_forwarder_address: Self::get_stellar_testnet_fee_forwarder_address( + ), + stellar_mainnet_soroswap_router_address: + Self::get_stellar_mainnet_soroswap_router_address(), + stellar_testnet_soroswap_router_address: + Self::get_stellar_testnet_soroswap_router_address(), + stellar_mainnet_soroswap_factory_address: + Self::get_stellar_mainnet_soroswap_factory_address(), + stellar_testnet_soroswap_factory_address: + Self::get_stellar_testnet_soroswap_factory_address(), + stellar_mainnet_soroswap_native_wrapper_address: + Self::get_stellar_mainnet_soroswap_native_wrapper_address(), + stellar_testnet_soroswap_native_wrapper_address: + Self::get_stellar_testnet_soroswap_native_wrapper_address(), } } @@ -491,24 +521,86 @@ impl ServerConfig { .unwrap_or(30) } - /// Gets the Stellar FeeForwarder contract address from environment variable - pub fn get_stellar_fee_forwarder_address() -> Option { - env::var("STELLAR_FEE_FORWARDER_ADDRESS").ok() + // ========================================================================= + // Stellar Contract Address Getters (raw env var reads) + // ========================================================================= + + pub fn get_stellar_mainnet_fee_forwarder_address() -> Option { + env::var("STELLAR_MAINNET_FEE_FORWARDER_ADDRESS").ok() + } + + pub fn get_stellar_testnet_fee_forwarder_address() -> Option { + env::var("STELLAR_TESTNET_FEE_FORWARDER_ADDRESS").ok() + } + + pub fn get_stellar_mainnet_soroswap_router_address() -> Option { + env::var("STELLAR_MAINNET_SOROSWAP_ROUTER_ADDRESS").ok() } - /// Gets the Stellar Soroswap router contract address from environment variable - pub fn get_stellar_soroswap_router_address() -> Option { - env::var("STELLAR_SOROSWAP_ROUTER_ADDRESS").ok() + pub fn get_stellar_testnet_soroswap_router_address() -> Option { + env::var("STELLAR_TESTNET_SOROSWAP_ROUTER_ADDRESS").ok() } - /// Gets the Stellar Soroswap factory contract address from environment variable - pub fn get_stellar_soroswap_factory_address() -> Option { - env::var("STELLAR_SOROSWAP_FACTORY_ADDRESS").ok() + pub fn get_stellar_mainnet_soroswap_factory_address() -> Option { + env::var("STELLAR_MAINNET_SOROSWAP_FACTORY_ADDRESS").ok() } - /// Gets the Stellar Soroswap native wrapper token address from environment variable - pub fn get_stellar_soroswap_native_wrapper_address() -> Option { - env::var("STELLAR_SOROSWAP_NATIVE_WRAPPER_ADDRESS").ok() + pub fn get_stellar_testnet_soroswap_factory_address() -> Option { + env::var("STELLAR_TESTNET_SOROSWAP_FACTORY_ADDRESS").ok() + } + + pub fn get_stellar_mainnet_soroswap_native_wrapper_address() -> Option { + env::var("STELLAR_MAINNET_SOROSWAP_NATIVE_WRAPPER_ADDRESS").ok() + } + + pub fn get_stellar_testnet_soroswap_native_wrapper_address() -> Option { + env::var("STELLAR_TESTNET_SOROSWAP_NATIVE_WRAPPER_ADDRESS").ok() + } + + // ========================================================================= + // Stellar Contract Address Resolvers + // ========================================================================= + // For mainnet: env var override → hardcoded default from constants. + // For testnet: env var only (no hardcoded defaults). + + /// Resolves the FeeForwarder contract address for the given network. + pub fn resolve_stellar_fee_forwarder_address(is_testnet: bool) -> Option { + if is_testnet { + Self::get_stellar_testnet_fee_forwarder_address() + } else { + Self::get_stellar_mainnet_fee_forwarder_address() + .or_else(|| non_empty_const(STELLAR_FEE_FORWARDER_MAINNET)) + } + } + + /// Resolves the Soroswap router contract address for the given network. + pub fn resolve_stellar_soroswap_router_address(is_testnet: bool) -> Option { + if is_testnet { + Self::get_stellar_testnet_soroswap_router_address() + } else { + Self::get_stellar_mainnet_soroswap_router_address() + .or_else(|| Some(STELLAR_SOROSWAP_MAINNET_ROUTER.to_string())) + } + } + + /// Resolves the Soroswap factory contract address for the given network. + pub fn resolve_stellar_soroswap_factory_address(is_testnet: bool) -> Option { + if is_testnet { + Self::get_stellar_testnet_soroswap_factory_address() + } else { + Self::get_stellar_mainnet_soroswap_factory_address() + .or_else(|| Some(STELLAR_SOROSWAP_MAINNET_FACTORY.to_string())) + } + } + + /// Resolves the Soroswap native wrapper token address for the given network. + pub fn resolve_stellar_soroswap_native_wrapper_address(is_testnet: bool) -> Option { + if is_testnet { + Self::get_stellar_testnet_soroswap_native_wrapper_address() + } else { + Self::get_stellar_mainnet_soroswap_native_wrapper_address() + .or_else(|| Some(STELLAR_SOROSWAP_MAINNET_NATIVE_WRAPPER.to_string())) + } } /// Get worker concurrency from environment variable or use default diff --git a/src/constants/stellar_transaction.rs b/src/constants/stellar_transaction.rs index e9fc32ee8..2e3aaf8c1 100644 --- a/src/constants/stellar_transaction.rs +++ b/src/constants/stellar_transaction.rs @@ -74,99 +74,38 @@ pub fn get_stellar_max_stuck_transaction_lifetime() -> Duration { } // ============================================================================= -// Soroswap DEX Contract Addresses +// Soroswap DEX Contract Addresses (Mainnet Only) // ============================================================================= -// Official Soroswap deployments from: -// - Mainnet: https://github.com/soroswap/core/blob/main/public/mainnet.contracts.json -// - Testnet: https://github.com/soroswap/core/blob/main/public/testnet.contracts.json +// Official Soroswap mainnet deployments from: +// https://github.com/soroswap/core/blob/main/public/mainnet.contracts.json +// +// Testnet addresses must be provided via environment variables: +// - STELLAR_TESTNET_SOROSWAP_ROUTER_ADDRESS +// - STELLAR_TESTNET_SOROSWAP_FACTORY_ADDRESS +// - STELLAR_TESTNET_SOROSWAP_NATIVE_WRAPPER_ADDRESS /// Soroswap router contract address on Stellar mainnet pub const STELLAR_SOROSWAP_MAINNET_ROUTER: &str = "CAG5LRYQ5JVEUI5TEID72EYOVX44TTUJT5BQR2J6J77FH65PCCFAJDDH"; -/// Soroswap router contract address on Stellar testnet -pub const STELLAR_SOROSWAP_TESTNET_ROUTER: &str = - "CCJUD55AG6W5HAI5LRVNKAE5WDP5XGZBUDS5WNTIVDU7O264UZZE7BRD"; - /// Soroswap factory contract address on Stellar mainnet pub const STELLAR_SOROSWAP_MAINNET_FACTORY: &str = "CA4HEQTL2WPEUYKYKCDOHCDNIV4QHNJ7EL4J4NQ6VADP7SYHVRYZ7AW2"; -/// Soroswap factory contract address on Stellar testnet -pub const STELLAR_SOROSWAP_TESTNET_FACTORY: &str = - "CDP3HMUH6SMS3S7NPGNDJLULCOXXEPSHY4JKUKMBNQMATHDHWXRRJTBY"; - /// Native XLM wrapper token contract address on Stellar mainnet /// This is the Soroban token contract that wraps native XLM for use in Soroswap pub const STELLAR_SOROSWAP_MAINNET_NATIVE_WRAPPER: &str = "CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA"; -/// Native XLM wrapper token contract address on Stellar testnet -pub const STELLAR_SOROSWAP_TESTNET_NATIVE_WRAPPER: &str = - "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC"; - // ============================================================================= -// FeeForwarder Contract Addresses +// FeeForwarder Contract Addresses (Mainnet Only) // ============================================================================= // The FeeForwarder contract enables gas abstraction by allowing users to pay // transaction fees in tokens instead of native XLM. +// +// Testnet address must be provided via environment variable: +// - STELLAR_TESTNET_FEE_FORWARDER_ADDRESS /// FeeForwarder contract address on Stellar mainnet /// Set to empty string until mainnet deployment is available pub const STELLAR_FEE_FORWARDER_MAINNET: &str = ""; - -/// FeeForwarder contract address on Stellar testnet -pub const STELLAR_FEE_FORWARDER_TESTNET: &str = - "CADRAMMYU62IUEQZEKG5MYVL7DYMH424ETG7QRZ5BVGP552DZNR4MH2Q"; - -// ============================================================================= -// Helper Functions for Network-Aware Address Resolution -// ============================================================================= - -/// Get the default Soroswap router contract address for the given network -/// -/// Returns the official Soroswap router deployment address. -/// Users can override this via the `STELLAR_SOROSWAP_ROUTER_ADDRESS` env var. -pub fn get_default_soroswap_router(is_testnet: bool) -> &'static str { - if is_testnet { - STELLAR_SOROSWAP_TESTNET_ROUTER - } else { - STELLAR_SOROSWAP_MAINNET_ROUTER - } -} - -/// Get the default Soroswap factory contract address for the given network -/// -/// Returns the official Soroswap factory deployment address. -/// Users can override this via the `STELLAR_SOROSWAP_FACTORY_ADDRESS` env var. -pub fn get_default_soroswap_factory(is_testnet: bool) -> &'static str { - if is_testnet { - STELLAR_SOROSWAP_TESTNET_FACTORY - } else { - STELLAR_SOROSWAP_MAINNET_FACTORY - } -} - -/// Get the default native XLM wrapper token address for the given network -/// -/// Returns the Soroban token contract that wraps native XLM for use in Soroswap. -/// Users can override this via the `STELLAR_SOROSWAP_NATIVE_WRAPPER_ADDRESS` env var. -pub fn get_default_soroswap_native_wrapper(is_testnet: bool) -> &'static str { - if is_testnet { - STELLAR_SOROSWAP_TESTNET_NATIVE_WRAPPER - } else { - STELLAR_SOROSWAP_MAINNET_NATIVE_WRAPPER - } -} - -/// Get the default FeeForwarder contract address for the given network -/// -/// Returns the FeeForwarder deployment address, or None if not yet deployed. -/// Users can override this via the `STELLAR_FEE_FORWARDER_ADDRESS` env var. -pub fn get_default_fee_forwarder(is_testnet: bool) -> &'static str { - if is_testnet { - STELLAR_FEE_FORWARDER_TESTNET - } else { - STELLAR_FEE_FORWARDER_MAINNET - } -} diff --git a/src/domain/relayer/stellar/gas_abstraction.rs b/src/domain/relayer/stellar/gas_abstraction.rs index 7494bfa33..45f485c86 100644 --- a/src/domain/relayer/stellar/gas_abstraction.rs +++ b/src/domain/relayer/stellar/gas_abstraction.rs @@ -9,8 +9,7 @@ use soroban_rs::xdr::{Limits, Operation, TransactionEnvelope, WriteXdr}; use tracing::{debug, warn}; use crate::constants::{ - get_default_fee_forwarder, get_stellar_sponsored_transaction_validity_duration, - STELLAR_DEFAULT_TRANSACTION_FEE, + get_stellar_sponsored_transaction_validity_duration, STELLAR_DEFAULT_TRANSACTION_FEE, }; /// Default slippage tolerance for max_fee_amount in basis points (500 = 5%) @@ -528,21 +527,20 @@ where StellarTransactionValidator::validate_allowed_token(¶ms.fee_token, &policy) .map_err(|e| RelayerError::ValidationError(e.to_string()))?; - // Get fee_forwarder address: env var override takes precedence, otherwise use network default - let fee_forwarder = crate::config::ServerConfig::get_stellar_fee_forwarder_address() - .or_else(|| { - let default = get_default_fee_forwarder(self.network.is_testnet()); - if default.is_empty() { - None - } else { - Some(default.to_string()) - } - }) - .ok_or_else(|| { - RelayerError::ValidationError( - "FeeForwarder address not configured. Set STELLAR_FEE_FORWARDER_ADDRESS env var or wait for default deployment.".to_string(), - ) - })?; + // Get fee_forwarder address: env var override takes precedence, otherwise use mainnet default + let fee_forwarder = crate::config::ServerConfig::resolve_stellar_fee_forwarder_address( + self.network.is_testnet(), + ) + .ok_or_else(|| { + let env_var = if self.network.is_testnet() { + "STELLAR_TESTNET_FEE_FORWARDER_ADDRESS" + } else { + "STELLAR_MAINNET_FEE_FORWARDER_ADDRESS" + }; + RelayerError::ValidationError(format!( + "FeeForwarder address not configured. Set {env_var} env var." + )) + })?; // Validate fee_token is a valid Soroban contract address (C...) if stellar_strkey::Contract::from_string(¶ms.fee_token).is_err() { @@ -696,21 +694,20 @@ where // Note: validate_allowed_token is already called in build_sponsored_transaction - // Get fee_forwarder address: env var override takes precedence, otherwise use network default - let fee_forwarder = crate::config::ServerConfig::get_stellar_fee_forwarder_address() - .or_else(|| { - let default = get_default_fee_forwarder(self.network.is_testnet()); - if default.is_empty() { - None - } else { - Some(default.to_string()) - } - }) - .ok_or_else(|| { - RelayerError::ValidationError( - "FeeForwarder address not configured. Set STELLAR_FEE_FORWARDER_ADDRESS env var or wait for default deployment.".to_string(), - ) - })?; + // Get fee_forwarder address: env var override takes precedence, otherwise use mainnet default + let fee_forwarder = crate::config::ServerConfig::resolve_stellar_fee_forwarder_address( + self.network.is_testnet(), + ) + .ok_or_else(|| { + let env_var = if self.network.is_testnet() { + "STELLAR_TESTNET_FEE_FORWARDER_ADDRESS" + } else { + "STELLAR_MAINNET_FEE_FORWARDER_ADDRESS" + }; + RelayerError::ValidationError(format!( + "FeeForwarder address not configured. Set {env_var} env var." + )) + })?; // Validate fee_token is a valid Soroban contract address (C...) if stellar_strkey::Contract::from_string(¶ms.fee_token).is_err() { @@ -3008,9 +3005,9 @@ mod tests { #[tokio::test] #[serial] async fn test_quote_soroban_from_xdr_success() { - // Set required env var for FeeForwarder + // Set required env var for FeeForwarder (testnet network) std::env::set_var( - "STELLAR_FEE_FORWARDER_ADDRESS", + "STELLAR_TESTNET_FEE_FORWARDER_ADDRESS", "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC", ); @@ -3102,14 +3099,14 @@ mod tests { } // Clean up env var - std::env::remove_var("STELLAR_FEE_FORWARDER_ADDRESS"); + std::env::remove_var("STELLAR_TESTNET_FEE_FORWARDER_ADDRESS"); } #[tokio::test] #[serial] async fn test_quote_soroban_from_xdr_missing_fee_forwarder() { // Ensure env var is NOT set - std::env::remove_var("STELLAR_FEE_FORWARDER_ADDRESS"); + std::env::remove_var("STELLAR_MAINNET_FEE_FORWARDER_ADDRESS"); // Use mainnet network where FeeForwarder is not deployed (empty default address) let mut relayer_model = create_test_relayer_with_soroban_token(); @@ -3146,16 +3143,16 @@ mod tests { let err = result.unwrap_err(); assert!(matches!(err, RelayerError::ValidationError(_))); if let RelayerError::ValidationError(msg) = err { - assert!(msg.contains("STELLAR_FEE_FORWARDER_ADDRESS")); + assert!(msg.contains("STELLAR_MAINNET_FEE_FORWARDER_ADDRESS")); } } #[tokio::test] #[serial] async fn test_quote_soroban_from_xdr_invalid_fee_token_format() { - // Set required env var for FeeForwarder + // Set required env var for FeeForwarder (testnet network) std::env::set_var( - "STELLAR_FEE_FORWARDER_ADDRESS", + "STELLAR_TESTNET_FEE_FORWARDER_ADDRESS", "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC", ); @@ -3222,7 +3219,7 @@ mod tests { } // Clean up env var - std::env::remove_var("STELLAR_FEE_FORWARDER_ADDRESS"); + std::env::remove_var("STELLAR_TESTNET_FEE_FORWARDER_ADDRESS"); } // ============================================================================ @@ -3232,9 +3229,9 @@ mod tests { #[tokio::test] #[serial] async fn test_build_soroban_sponsored_success() { - // Set required env var for FeeForwarder + // Set required env var for FeeForwarder (testnet network) std::env::set_var( - "STELLAR_FEE_FORWARDER_ADDRESS", + "STELLAR_TESTNET_FEE_FORWARDER_ADDRESS", "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC", ); @@ -3336,14 +3333,14 @@ mod tests { } // Clean up env var - std::env::remove_var("STELLAR_FEE_FORWARDER_ADDRESS"); + std::env::remove_var("STELLAR_TESTNET_FEE_FORWARDER_ADDRESS"); } #[tokio::test] #[serial] async fn test_build_soroban_sponsored_missing_fee_forwarder() { // Ensure env var is NOT set - std::env::remove_var("STELLAR_FEE_FORWARDER_ADDRESS"); + std::env::remove_var("STELLAR_MAINNET_FEE_FORWARDER_ADDRESS"); // Use mainnet network where FeeForwarder is not deployed (empty default address) let mut relayer_model = create_test_relayer_with_soroban_token(); @@ -3380,16 +3377,16 @@ mod tests { let err = result.unwrap_err(); assert!(matches!(err, RelayerError::ValidationError(_))); if let RelayerError::ValidationError(msg) = err { - assert!(msg.contains("STELLAR_FEE_FORWARDER_ADDRESS")); + assert!(msg.contains("STELLAR_MAINNET_FEE_FORWARDER_ADDRESS")); } } #[tokio::test] #[serial] async fn test_build_soroban_sponsored_insufficient_balance() { - // Set required env var for FeeForwarder + // Set required env var for FeeForwarder (testnet network) std::env::set_var( - "STELLAR_FEE_FORWARDER_ADDRESS", + "STELLAR_TESTNET_FEE_FORWARDER_ADDRESS", "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC", ); @@ -3472,15 +3469,15 @@ mod tests { } // Clean up env var - std::env::remove_var("STELLAR_FEE_FORWARDER_ADDRESS"); + std::env::remove_var("STELLAR_TESTNET_FEE_FORWARDER_ADDRESS"); } #[tokio::test] #[serial] async fn test_build_soroban_sponsored_simulation_error() { - // Set required env var for FeeForwarder + // Set required env var for FeeForwarder (testnet network) std::env::set_var( - "STELLAR_FEE_FORWARDER_ADDRESS", + "STELLAR_TESTNET_FEE_FORWARDER_ADDRESS", "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC", ); @@ -3560,6 +3557,6 @@ mod tests { } // Clean up env var - std::env::remove_var("STELLAR_FEE_FORWARDER_ADDRESS"); + std::env::remove_var("STELLAR_TESTNET_FEE_FORWARDER_ADDRESS"); } } diff --git a/src/domain/relayer/stellar/mod.rs b/src/domain/relayer/stellar/mod.rs index ffe68d796..03675cc9a 100644 --- a/src/domain/relayer/stellar/mod.rs +++ b/src/domain/relayer/stellar/mod.rs @@ -12,10 +12,7 @@ pub use crate::services::stellar_dex::StellarDexServiceTrait; use std::sync::Arc; use crate::{ - constants::{ - get_default_soroswap_factory, get_default_soroswap_router, STELLAR_HORIZON_MAINNET_URL, - STELLAR_HORIZON_TESTNET_URL, - }, + constants::{STELLAR_HORIZON_MAINNET_URL, STELLAR_HORIZON_TESTNET_URL}, jobs::JobProducerTrait, models::{ NetworkRepoModel, NetworkType, RelayerError, RelayerRepoModel, SignerRepoModel, @@ -117,23 +114,38 @@ pub async fn create_stellar_relayer< dex_services.push(DexServiceWrapper::OrderBook(order_book_service)); } StellarSwapStrategy::Soroswap => { - // Get Soroswap router address from server config, falling back to default + let is_testnet = network.is_testnet(); + let network_label = if is_testnet { "TESTNET" } else { "MAINNET" }; + let router_address = - crate::config::ServerConfig::get_stellar_soroswap_router_address() - .unwrap_or_else(|| { - get_default_soroswap_router(network.is_testnet()).to_string() - }); + crate::config::ServerConfig::resolve_stellar_soroswap_router_address( + is_testnet, + ) + .ok_or_else(|| { + RelayerError::NetworkConfiguration(format!( + "Soroswap router address not configured. Set STELLAR_{network_label}_SOROSWAP_ROUTER_ADDRESS env var." + )) + })?; - // Get Soroswap factory address from server config, falling back to default let factory_address = - crate::config::ServerConfig::get_stellar_soroswap_factory_address() - .unwrap_or_else(|| { - get_default_soroswap_factory(network.is_testnet()).to_string() - }); + crate::config::ServerConfig::resolve_stellar_soroswap_factory_address( + is_testnet, + ) + .ok_or_else(|| { + RelayerError::NetworkConfiguration(format!( + "Soroswap factory address not configured. Set STELLAR_{network_label}_SOROSWAP_FACTORY_ADDRESS env var." + )) + })?; - // Get native wrapper address from server config if configured let native_wrapper_address = - crate::config::ServerConfig::get_stellar_soroswap_native_wrapper_address(); + crate::config::ServerConfig::resolve_stellar_soroswap_native_wrapper_address( + is_testnet, + ) + .ok_or_else(|| { + RelayerError::NetworkConfiguration(format!( + "Soroswap native wrapper address not configured. Set STELLAR_{network_label}_SOROSWAP_NATIVE_WRAPPER_ADDRESS env var." + )) + })?; let soroswap_service = Arc::new(SoroswapService::new( router_address, @@ -141,7 +153,6 @@ pub async fn create_stellar_relayer< native_wrapper_address, provider_arc.clone(), network.passphrase.clone(), - network.is_testnet(), )); dex_services.push(DexServiceWrapper::Soroswap(soroswap_service)); tracing::info!("Soroswap DEX service initialized"); diff --git a/src/domain/transaction/mod.rs b/src/domain/transaction/mod.rs index aba32ee36..5d5c7b41e 100644 --- a/src/domain/transaction/mod.rs +++ b/src/domain/transaction/mod.rs @@ -12,10 +12,7 @@ //! The module leverages async traits to handle asynchronous operations and uses the `eyre` crate //! for error handling. use crate::{ - constants::{ - get_default_soroswap_factory, get_default_soroswap_router, STELLAR_HORIZON_MAINNET_URL, - STELLAR_HORIZON_TESTNET_URL, - }, + constants::{STELLAR_HORIZON_MAINNET_URL, STELLAR_HORIZON_TESTNET_URL}, jobs::{JobProducer, StatusCheckContext}, models::{ EvmNetwork, NetworkTransactionRequest, NetworkType, RelayerRepoModel, SignerRepoModel, @@ -684,19 +681,30 @@ impl RelayerTransactionFactory { dex_services.push(DexServiceWrapper::OrderBook(order_book_service)); } StellarSwapStrategy::Soroswap => { + let is_testnet = network.is_testnet(); + let network_label = if is_testnet { "TESTNET" } else { "MAINNET" }; + let router_address = - crate::config::ServerConfig::get_stellar_soroswap_router_address() - .unwrap_or_else(|| { - get_default_soroswap_router(network.is_testnet()) - .to_string() - }); + crate::config::ServerConfig::resolve_stellar_soroswap_router_address(is_testnet) + .ok_or_else(|| { + eyre::eyre!( + "Soroswap router address not configured. Set STELLAR_{network_label}_SOROSWAP_ROUTER_ADDRESS env var." + ) + })?; let factory_address = - crate::config::ServerConfig::get_stellar_soroswap_factory_address() - .unwrap_or_else(|| { - get_default_soroswap_factory(network.is_testnet()) - .to_string() - }); - let native_wrapper_address = crate::config::ServerConfig::get_stellar_soroswap_native_wrapper_address(); + crate::config::ServerConfig::resolve_stellar_soroswap_factory_address(is_testnet) + .ok_or_else(|| { + eyre::eyre!( + "Soroswap factory address not configured. Set STELLAR_{network_label}_SOROSWAP_FACTORY_ADDRESS env var." + ) + })?; + let native_wrapper_address = + crate::config::ServerConfig::resolve_stellar_soroswap_native_wrapper_address(is_testnet) + .ok_or_else(|| { + eyre::eyre!( + "Soroswap native wrapper address not configured. Set STELLAR_{network_label}_SOROSWAP_NATIVE_WRAPPER_ADDRESS env var." + ) + })?; let soroswap_service = Arc::new(SoroswapService::new( router_address, @@ -704,7 +712,6 @@ impl RelayerTransactionFactory { native_wrapper_address, provider_arc.clone(), network.passphrase.clone(), - network.is_testnet(), )); dex_services.push(DexServiceWrapper::Soroswap(soroswap_service)); } diff --git a/src/services/stellar_dex/soroswap_service.rs b/src/services/stellar_dex/soroswap_service.rs index d989ce504..9e9869a45 100644 --- a/src/services/stellar_dex/soroswap_service.rs +++ b/src/services/stellar_dex/soroswap_service.rs @@ -10,7 +10,7 @@ use super::{ AssetType, PathStep, StellarDexServiceError, StellarDexServiceTrait, StellarQuoteResponse, SwapExecutionResult, SwapTransactionParams, }; -use crate::constants::{get_default_soroswap_native_wrapper, STELLAR_DEFAULT_TRANSACTION_FEE}; +use crate::constants::STELLAR_DEFAULT_TRANSACTION_FEE; use crate::domain::relayer::string_to_muxed_account; use crate::domain::transaction::stellar::utils::{parse_account_id, parse_contract_address}; use crate::services::provider::StellarProviderTrait; @@ -61,25 +61,20 @@ where /// /// * `router_address` - Soroswap router contract address /// * `factory_address` - Soroswap factory contract address (required for get_amounts_out) - /// * `native_wrapper_address` - Optional native XLM wrapper token address (uses default if None) + /// * `native_wrapper_address` - Native XLM wrapper token address /// * `provider` - Stellar provider for contract calls /// * `network_passphrase` - Network passphrase - /// * `is_testnet` - Whether this is testnet (affects default addresses) pub fn new( router_address: String, factory_address: String, - native_wrapper_address: Option, + native_wrapper_address: String, provider: Arc

, network_passphrase: String, - is_testnet: bool, ) -> Self { - let native_wrapper = native_wrapper_address - .unwrap_or_else(|| get_default_soroswap_native_wrapper(is_testnet).to_string()); - Self { router_address, factory_address, - native_wrapper_address: native_wrapper, + native_wrapper_address, provider, network_passphrase, } @@ -589,47 +584,48 @@ where #[cfg(test)] mod tests { use super::*; - use crate::constants::{ - STELLAR_SOROSWAP_MAINNET_NATIVE_WRAPPER, STELLAR_SOROSWAP_TESTNET_NATIVE_WRAPPER, - }; + use crate::constants::STELLAR_SOROSWAP_MAINNET_NATIVE_WRAPPER; use crate::services::provider::MockStellarProviderTrait; use futures::FutureExt; use soroban_rs::xdr::ReadXdr; + const TEST_NATIVE_WRAPPER: &str = "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC"; + fn create_mock_provider() -> Arc { Arc::new(MockStellarProviderTrait::new()) } fn create_test_service( provider: Arc, - is_testnet: bool, ) -> SoroswapService { SoroswapService::new( "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC".to_string(), // router "CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA".to_string(), // factory - None, + TEST_NATIVE_WRAPPER.to_string(), provider, "Test SDF Network ; September 2015".to_string(), - is_testnet, ) } // ==================== Constructor Tests ==================== #[test] - fn test_new_testnet_uses_testnet_native_wrapper() { + fn test_new_stores_provided_native_wrapper() { let provider = create_mock_provider(); - let service = create_test_service(provider, true); - assert_eq!( - service.native_wrapper_address, - STELLAR_SOROSWAP_TESTNET_NATIVE_WRAPPER - ); + let service = create_test_service(provider); + assert_eq!(service.native_wrapper_address, TEST_NATIVE_WRAPPER); } #[test] - fn test_new_mainnet_uses_mainnet_native_wrapper() { + fn test_new_with_mainnet_native_wrapper() { let provider = create_mock_provider(); - let service = create_test_service(provider, false); + let service = SoroswapService::new( + "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC".to_string(), + "CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA".to_string(), + STELLAR_SOROSWAP_MAINNET_NATIVE_WRAPPER.to_string(), + provider, + "Public Global Stellar Network ; September 2015".to_string(), + ); assert_eq!( service.native_wrapper_address, STELLAR_SOROSWAP_MAINNET_NATIVE_WRAPPER @@ -643,10 +639,9 @@ mod tests { let service = SoroswapService::new( "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC".to_string(), "CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA".to_string(), - Some(custom_wrapper.clone()), + custom_wrapper.clone(), provider, "Test SDF Network ; September 2015".to_string(), - true, ); assert_eq!(service.native_wrapper_address, custom_wrapper); } @@ -693,21 +688,21 @@ mod tests { #[test] fn test_can_handle_asset_native() { let provider = create_mock_provider(); - let service = create_test_service(provider, true); + let service = create_test_service(provider); assert!(service.can_handle_asset("native")); } #[test] fn test_can_handle_asset_empty_string() { let provider = create_mock_provider(); - let service = create_test_service(provider, true); + let service = create_test_service(provider); assert!(service.can_handle_asset("")); } #[test] fn test_can_handle_asset_valid_contract() { let provider = create_mock_provider(); - let service = create_test_service(provider, true); + let service = create_test_service(provider); let contract_addr = "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC"; assert!(service.can_handle_asset(contract_addr)); } @@ -715,7 +710,7 @@ mod tests { #[test] fn test_cannot_handle_classic_asset() { let provider = create_mock_provider(); - let service = create_test_service(provider, true); + let service = create_test_service(provider); let classic_asset = "USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN"; assert!(!service.can_handle_asset(classic_asset)); } @@ -723,14 +718,14 @@ mod tests { #[test] fn test_cannot_handle_short_address() { let provider = create_mock_provider(); - let service = create_test_service(provider, true); + let service = create_test_service(provider); assert!(!service.can_handle_asset("CSHORT")); } #[test] fn test_cannot_handle_non_c_prefix() { let provider = create_mock_provider(); - let service = create_test_service(provider, true); + let service = create_test_service(provider); // Stellar account address (G prefix) let addr = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF"; assert!(!service.can_handle_asset(addr)); @@ -739,7 +734,7 @@ mod tests { #[test] fn test_cannot_handle_invalid_contract_checksum() { let provider = create_mock_provider(); - let service = create_test_service(provider, true); + let service = create_test_service(provider); // Valid format but invalid checksum let invalid_addr = "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; assert!(!service.can_handle_asset(invalid_addr)); @@ -750,7 +745,7 @@ mod tests { #[test] fn test_supported_asset_types() { let provider = create_mock_provider(); - let service = create_test_service(provider, true); + let service = create_test_service(provider); let types = service.supported_asset_types(); assert!(types.contains(&AssetType::Native)); assert!(types.contains(&AssetType::Contract)); @@ -874,7 +869,7 @@ mod tests { #[test] fn test_build_path_valid() { let provider = create_mock_provider(); - let service = create_test_service(provider, true); + let service = create_test_service(provider); let from = "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC"; let to = "CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA"; @@ -891,7 +886,7 @@ mod tests { #[test] fn test_build_path_invalid_from() { let provider = create_mock_provider(); - let service = create_test_service(provider, true); + let service = create_test_service(provider); let result = service.build_path( "INVALID", "CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA", @@ -902,7 +897,7 @@ mod tests { #[test] fn test_build_path_invalid_to() { let provider = create_mock_provider(); - let service = create_test_service(provider, true); + let service = create_test_service(provider); let result = service.build_path( "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC", "INVALID", @@ -915,7 +910,7 @@ mod tests { #[tokio::test] async fn test_get_token_to_xlm_quote_native_returns_1_to_1() { let provider = create_mock_provider(); - let service = create_test_service(provider, true); + let service = create_test_service(provider); let quote = service .get_token_to_xlm_quote("native", 1_000_000, 0.5, None) @@ -934,7 +929,7 @@ mod tests { #[tokio::test] async fn test_get_token_to_xlm_quote_empty_returns_1_to_1() { let provider = create_mock_provider(); - let service = create_test_service(provider, true); + let service = create_test_service(provider); let quote = service .get_token_to_xlm_quote("", 1_000_000, 1.0, None) @@ -949,7 +944,7 @@ mod tests { #[tokio::test] async fn test_get_xlm_to_token_quote_native_returns_1_to_1() { let provider = create_mock_provider(); - let service = create_test_service(provider, true); + let service = create_test_service(provider); let quote = service .get_xlm_to_token_quote("native", 1_000_000, 0.5, None) @@ -965,7 +960,7 @@ mod tests { #[tokio::test] async fn test_get_xlm_to_token_quote_empty_returns_1_to_1() { let provider = create_mock_provider(); - let service = create_test_service(provider, true); + let service = create_test_service(provider); let quote = service .get_xlm_to_token_quote("", 500_000, 0.25, None) @@ -996,7 +991,7 @@ mod tests { }); let provider = Arc::new(mock); - let service = create_test_service(provider, true); + let service = create_test_service(provider); let quote = service .get_token_to_xlm_quote( @@ -1033,7 +1028,7 @@ mod tests { }); let provider = Arc::new(mock); - let service = create_test_service(provider, true); + let service = create_test_service(provider); let quote = service .get_xlm_to_token_quote( @@ -1064,7 +1059,7 @@ mod tests { }); let provider = Arc::new(mock); - let service = create_test_service(provider, true); + let service = create_test_service(provider); let result = service .get_token_to_xlm_quote( @@ -1100,7 +1095,7 @@ mod tests { }); let provider = Arc::new(mock); - let service = create_test_service(provider, true); + let service = create_test_service(provider); let result = service .get_token_to_xlm_quote( @@ -1136,7 +1131,7 @@ mod tests { }); let provider = Arc::new(mock); - let service = create_test_service(provider, true); + let service = create_test_service(provider); let result = service .get_token_to_xlm_quote( @@ -1168,7 +1163,7 @@ mod tests { }); let provider = Arc::new(mock); - let service = create_test_service(provider, true); + let service = create_test_service(provider); let result = service .get_token_to_xlm_quote( @@ -1208,7 +1203,7 @@ mod tests { }); let provider = Arc::new(mock); - let service = create_test_service(provider, true); + let service = create_test_service(provider); let params = SwapTransactionParams { source_account: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF".to_string(), @@ -1260,7 +1255,7 @@ mod tests { }); let provider = Arc::new(mock); - let service = create_test_service(provider, true); + let service = create_test_service(provider); let params = SwapTransactionParams { source_account: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF".to_string(), @@ -1296,7 +1291,7 @@ mod tests { #[tokio::test] async fn test_prepare_swap_transaction_token_to_token_not_supported() { let provider = create_mock_provider(); - let service = create_test_service(provider, true); + let service = create_test_service(provider); let params = SwapTransactionParams { source_account: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF".to_string(), @@ -1346,7 +1341,7 @@ mod tests { #[test] fn test_build_swap_transaction_xdr_token_to_xlm() { let provider = create_mock_provider(); - let service = create_test_service(provider, true); + let service = create_test_service(provider); let quote = StellarQuoteResponse { input_asset: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC".to_string(), @@ -1423,7 +1418,7 @@ mod tests { #[test] fn test_build_swap_transaction_xdr_native_to_token() { let provider = create_mock_provider(); - let service = create_test_service(provider, true); + let service = create_test_service(provider); let quote = StellarQuoteResponse { input_asset: "native".to_string(), @@ -1475,7 +1470,7 @@ mod tests { #[test] fn test_build_swap_transaction_xdr_invalid_source_account() { let provider = create_mock_provider(); - let service = create_test_service(provider, true); + let service = create_test_service(provider); let quote = StellarQuoteResponse { input_asset: "native".to_string(), @@ -1505,7 +1500,7 @@ mod tests { #[test] fn test_build_swap_transaction_xdr_slippage_calculation() { let provider = create_mock_provider(); - let service = create_test_service(provider, true); + let service = create_test_service(provider); // With 100 bps (1%) slippage on 1_000_000 output, min should be 990_000 let quote = StellarQuoteResponse { @@ -1563,7 +1558,7 @@ mod tests { #[tokio::test] async fn test_execute_swap_not_implemented() { let provider = create_mock_provider(); - let service = create_test_service(provider, true); + let service = create_test_service(provider); let params = SwapTransactionParams { source_account: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF".to_string(), @@ -1609,7 +1604,7 @@ mod tests { }); let provider = Arc::new(mock); - let service = create_test_service(provider, true); + let service = create_test_service(provider); let quote = service .get_token_to_xlm_quote( diff --git a/src/services/stellar_dex/stellar_dex_service.rs b/src/services/stellar_dex/stellar_dex_service.rs index dbabf3134..a31194033 100644 --- a/src/services/stellar_dex/stellar_dex_service.rs +++ b/src/services/stellar_dex/stellar_dex_service.rs @@ -296,10 +296,9 @@ mod tests { Arc::new(SoroswapService::new( "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC".to_string(), "CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA".to_string(), - None, + "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC".to_string(), provider, "Test SDF Network ; September 2015".to_string(), - true, )) } diff --git a/src/utils/mocks.rs b/src/utils/mocks.rs index 067f2ab50..00bf789cf 100644 --- a/src/utils/mocks.rs +++ b/src/utils/mocks.rs @@ -341,10 +341,14 @@ pub mod mockutils { request_timeout_seconds: 30, redis_reader_url: None, redis_reader_pool_max_size: 1000, - stellar_fee_forwarder_address: None, - stellar_soroswap_router_address: None, - stellar_soroswap_factory_address: None, - stellar_soroswap_native_wrapper_address: None, + stellar_mainnet_fee_forwarder_address: None, + stellar_testnet_fee_forwarder_address: None, + stellar_mainnet_soroswap_router_address: None, + stellar_testnet_soroswap_router_address: None, + stellar_mainnet_soroswap_factory_address: None, + stellar_testnet_soroswap_factory_address: None, + stellar_mainnet_soroswap_native_wrapper_address: None, + stellar_testnet_soroswap_native_wrapper_address: None, } } } From de116dd4049eb09b53b5c96dbd7abaa246670d27 Mon Sep 17 00:00:00 2001 From: Nicolas Molina Date: Fri, 6 Feb 2026 11:36:53 -0300 Subject: [PATCH 17/22] fix: Improvements --- src/constants/stellar_transaction.rs | 7 + src/domain/relayer/stellar/gas_abstraction.rs | 130 +-------- src/domain/relayer/stellar/mod.rs | 1 + src/domain/relayer/stellar/utils.rs | 140 ++++++++++ .../prepare/soroban_gas_abstraction.rs | 248 ++++++++---------- src/domain/transaction/stellar/utils.rs | 202 ++++++++++++++ src/services/stellar_dex/soroswap_service.rs | 7 +- src/services/stellar_fee_forwarder/mod.rs | 16 +- 8 files changed, 478 insertions(+), 273 deletions(-) create mode 100644 src/domain/relayer/stellar/utils.rs diff --git a/src/constants/stellar_transaction.rs b/src/constants/stellar_transaction.rs index 2e3aaf8c1..4d098ba41 100644 --- a/src/constants/stellar_transaction.rs +++ b/src/constants/stellar_transaction.rs @@ -40,10 +40,17 @@ pub const STELLAR_STATUS_CHECK_INITIAL_DELAY_SECONDS: i64 = 2; pub const STELLAR_PENDING_RECOVERY_TRIGGER_SECONDS: i64 = 10; // Transaction validity +/// Approximate Stellar ledger close time in seconds (used for ledger-based expiration) +pub const STELLAR_LEDGER_TIME_SECONDS: u64 = 5; + /// Default transaction validity duration (in minutes) for sponsored transactions /// Provides reasonable time for users to review and submit while ensuring transaction doesn't expire too quickly pub const STELLAR_SPONSORED_TRANSACTION_VALIDITY_MINUTES: i64 = 2; +/// Sponsored transaction validity in seconds (2 minutes). +/// Used for gas abstraction authorization validity so it aligns with the transaction submission window. +pub const STELLAR_SPONSORED_TRANSACTION_VALIDITY_SECONDS: u64 = 120; + /// Get status check initial delay duration pub fn get_stellar_status_check_initial_delay() -> Duration { Duration::seconds(STELLAR_STATUS_CHECK_INITIAL_DELAY_SECONDS) diff --git a/src/domain/relayer/stellar/gas_abstraction.rs b/src/domain/relayer/stellar/gas_abstraction.rs index 45f485c86..e6d90c108 100644 --- a/src/domain/relayer/stellar/gas_abstraction.rs +++ b/src/domain/relayer/stellar/gas_abstraction.rs @@ -10,12 +10,11 @@ use tracing::{debug, warn}; use crate::constants::{ get_stellar_sponsored_transaction_validity_duration, STELLAR_DEFAULT_TRANSACTION_FEE, + STELLAR_LEDGER_TIME_SECONDS, }; -/// Default slippage tolerance for max_fee_amount in basis points (500 = 5%) -/// This allows fee fluctuation between quote and execution time -const DEFAULT_MAX_FEE_SLIPPAGE_BPS: u64 = 500; use crate::domain::relayer::{ + stellar::utils::{apply_max_fee_slippage, get_expiration_ledger}, stellar::xdr_utils::{extract_source_account, parse_transaction_xdr}, GasAbstractionTrait, RelayerError, StellarRelayer, }; @@ -41,9 +40,7 @@ use crate::repositories::{ use crate::services::provider::StellarProviderTrait; use crate::services::signer::StellarSignTrait; use crate::services::stellar_dex::StellarDexServiceTrait; -use crate::services::stellar_fee_forwarder::{ - FeeForwarderParams, FeeForwarderService, LEDGER_TIME_SECONDS, -}; +use crate::services::stellar_fee_forwarder::{FeeForwarderParams, FeeForwarderService}; use crate::services::TransactionCounterServiceTrait; use soroban_rs::xdr::{HostFunction, OperationBody, ReadXdr, ScVal}; @@ -874,7 +871,8 @@ where RelayerError::Internal(format!("Failed to get current ledger: {e}")) })?; let ledgers_until_expiration = expiration_ledger.saturating_sub(current_ledger.sequence); - let seconds_until_expiration = ledgers_until_expiration as u64 * LEDGER_TIME_SECONDS; + let seconds_until_expiration = + ledgers_until_expiration as u64 * STELLAR_LEDGER_TIME_SECONDS; let valid_until = Utc::now() + chrono::Duration::seconds(seconds_until_expiration as i64); debug!( @@ -996,47 +994,6 @@ fn apply_simulation_to_soroban_envelope( Ok(()) } -/// Apply slippage tolerance to max_fee_amount for FeeForwarder -/// -/// The FeeForwarder contract has separate `fee_amount` (what relayer charges at execution) -/// and `max_fee_amount` (user's authorized ceiling). Setting them equal means no room for -/// fee fluctuation between quote and execution. This function applies a slippage buffer -/// to allow for price movement. -/// -/// # Arguments -/// * `fee_in_token` - The calculated fee amount in token units -/// -/// # Returns -/// The max_fee_amount with slippage buffer applied as i128 -fn apply_max_fee_slippage(fee_in_token: u64) -> i128 { - // Apply slippage: max_fee = fee * (10000 + slippage_bps) / 10000 - let fee_with_slippage = - (fee_in_token as u128) * (10000 + DEFAULT_MAX_FEE_SLIPPAGE_BPS as u128) / 10000; - fee_with_slippage as i128 -} - -/// Calculate the expiration ledger for authorization -/// -/// Uses the provider to get the current ledger sequence and adds the -/// specified validity duration (in seconds) converted to ledger count. -async fn get_expiration_ledger

(provider: &P, validity_seconds: u64) -> Result -where - P: StellarProviderTrait + Send + Sync, -{ - let current_ledger = provider - .get_latest_ledger() - .await - .map_err(|e| RelayerError::Internal(format!("Failed to get latest ledger: {e}")))?; - - let mut ledgers_to_add = validity_seconds.div_ceil(LEDGER_TIME_SECONDS); - if ledgers_to_add == 0 { - ledgers_to_add = 1; - } - Ok(current_ledger - .sequence - .saturating_add(ledgers_to_add as u32)) -} - /// Add payment operation to envelope using a pre-computed fee quote /// /// This function adds a fee payment operation to the transaction envelope using @@ -2450,39 +2407,6 @@ mod tests { } } - // ============================================================================ - // Tests for apply_max_fee_slippage - // ============================================================================ - - #[test] - fn test_apply_max_fee_slippage_basic() { - // 5% slippage on 10000 should give 10500 - let result = apply_max_fee_slippage(10000); - assert_eq!(result, 10500); - } - - #[test] - fn test_apply_max_fee_slippage_zero() { - let result = apply_max_fee_slippage(0); - assert_eq!(result, 0); - } - - #[test] - fn test_apply_max_fee_slippage_large_value() { - // Test with a large value to ensure no overflow - let large_fee: u64 = 1_000_000_000_000; - let result = apply_max_fee_slippage(large_fee); - // 5% of 1 trillion = 50 billion, so result = 1.05 trillion - assert_eq!(result, 1_050_000_000_000i128); - } - - #[test] - fn test_apply_max_fee_slippage_small_value() { - // Small value: 100 * 10500 / 10000 = 105 - let result = apply_max_fee_slippage(100); - assert_eq!(result, 105); - } - // ============================================================================ // Tests for calculate_total_soroban_fee // ============================================================================ @@ -2627,50 +2551,6 @@ mod tests { )); } - // ============================================================================ - // Tests for get_expiration_ledger - // ============================================================================ - - #[tokio::test] - async fn test_get_expiration_ledger_success() { - let mut provider = MockStellarProviderTrait::new(); - provider.expect_get_latest_ledger().returning(|| { - Box::pin(ready(Ok( - soroban_rs::stellar_rpc_client::GetLatestLedgerResponse { - id: "test".to_string(), - protocol_version: 20, - sequence: 1000, - }, - ))) - }); - - // 300 seconds / 5 seconds per ledger = 60 ledgers - let result = get_expiration_ledger(&provider, 300).await; - assert!(result.is_ok()); - let expiration = result.unwrap(); - assert_eq!(expiration, 1060); // 1000 + 60 - } - - #[tokio::test] - async fn test_get_expiration_ledger_zero_seconds() { - let mut provider = MockStellarProviderTrait::new(); - provider.expect_get_latest_ledger().returning(|| { - Box::pin(ready(Ok( - soroban_rs::stellar_rpc_client::GetLatestLedgerResponse { - id: "test".to_string(), - protocol_version: 20, - sequence: 1000, - }, - ))) - }); - - // 0 seconds should still add at least 1 ledger - let result = get_expiration_ledger(&provider, 0).await; - assert!(result.is_ok()); - let expiration = result.unwrap(); - assert_eq!(expiration, 1001); // 1000 + 1 (minimum) - } - // ============================================================================ // Tests for add_payment_operation_to_envelope // ============================================================================ diff --git a/src/domain/relayer/stellar/mod.rs b/src/domain/relayer/stellar/mod.rs index 03675cc9a..851279fc9 100644 --- a/src/domain/relayer/stellar/mod.rs +++ b/src/domain/relayer/stellar/mod.rs @@ -4,6 +4,7 @@ pub use stellar_relayer::*; mod gas_abstraction; mod token_swap; +pub mod utils; pub mod xdr_utils; pub use xdr_utils::*; diff --git a/src/domain/relayer/stellar/utils.rs b/src/domain/relayer/stellar/utils.rs new file mode 100644 index 000000000..4cd8e33eb --- /dev/null +++ b/src/domain/relayer/stellar/utils.rs @@ -0,0 +1,140 @@ +//! Stellar relayer utility functions. +//! +//! Generic helpers for ledger math, fee slippage, and other reusable logic +//! shared across gas abstraction and related code. + +use crate::constants::STELLAR_LEDGER_TIME_SECONDS; +use crate::models::RelayerError; +use crate::services::provider::StellarProviderTrait; + +/// Default slippage tolerance for max_fee_amount in basis points (500 = 5%). +/// Allows fee fluctuation between quote and execution time. +pub const DEFAULT_SOROBAN_MAX_FEE_SLIPPAGE_BPS: u64 = 500; + +/// Apply slippage tolerance to max_fee_amount for FeeForwarder. +/// +/// The FeeForwarder contract has separate `fee_amount` (what relayer charges at execution) +/// and `max_fee_amount` (user's authorized ceiling). Setting them equal means no room for +/// fee fluctuation between quote and execution. This function applies a slippage buffer +/// to allow for price movement. +/// +/// # Arguments +/// * `fee_in_token` - The calculated fee amount in token units +/// * `slippage_bps` - Slippage in basis points (default: [`DEFAULT_SOROBAN_MAX_FEE_SLIPPAGE_BPS`]) +/// +/// # Returns +/// The max_fee_amount with slippage buffer applied as i128 +pub fn apply_max_fee_slippage_with_bps(fee_in_token: u64, slippage_bps: u64) -> i128 { + let fee_with_slippage = (fee_in_token as u128) * (10000 + slippage_bps as u128) / 10000; + fee_with_slippage as i128 +} + +/// Apply default slippage to max_fee_amount (uses [`DEFAULT_SOROBAN_MAX_FEE_SLIPPAGE_BPS`]). +pub fn apply_max_fee_slippage(fee_in_token: u64) -> i128 { + apply_max_fee_slippage_with_bps(fee_in_token, DEFAULT_SOROBAN_MAX_FEE_SLIPPAGE_BPS) +} + +/// Calculate the expiration ledger for authorization. +/// +/// Uses the provider to get the current ledger sequence and adds the +/// specified validity duration (in seconds) converted to ledger count. +pub async fn get_expiration_ledger

( + provider: &P, + validity_seconds: u64, +) -> Result +where + P: StellarProviderTrait + Send + Sync, +{ + let current_ledger = provider + .get_latest_ledger() + .await + .map_err(|e| RelayerError::Internal(format!("Failed to get latest ledger: {e}")))?; + + let mut ledgers_to_add = validity_seconds.div_ceil(STELLAR_LEDGER_TIME_SECONDS); + if ledgers_to_add == 0 { + ledgers_to_add = 1; + } + Ok(current_ledger + .sequence + .saturating_add(ledgers_to_add as u32)) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::future::ready; + + use crate::services::provider::MockStellarProviderTrait; + + // ============================================================================ + // Tests for apply_max_fee_slippage + // ============================================================================ + + #[test] + fn test_apply_max_fee_slippage_basic() { + // 5% slippage on 10000 should give 10500 + let result = apply_max_fee_slippage(10000); + assert_eq!(result, 10500); + } + + #[test] + fn test_apply_max_fee_slippage_zero() { + let result = apply_max_fee_slippage(0); + assert_eq!(result, 0); + } + + #[test] + fn test_apply_max_fee_slippage_large_value() { + let large_fee: u64 = 1_000_000_000_000; + let result = apply_max_fee_slippage(large_fee); + assert_eq!(result, 1_050_000_000_000i128); + } + + #[test] + fn test_apply_max_fee_slippage_small_value() { + let result = apply_max_fee_slippage(100); + assert_eq!(result, 105); + } + + // ============================================================================ + // Tests for get_expiration_ledger + // ============================================================================ + + #[tokio::test] + async fn test_get_expiration_ledger_success() { + let mut provider = MockStellarProviderTrait::new(); + provider.expect_get_latest_ledger().returning(|| { + Box::pin(ready(Ok( + soroban_rs::stellar_rpc_client::GetLatestLedgerResponse { + id: "test".to_string(), + protocol_version: 20, + sequence: 1000, + }, + ))) + }); + + let result = get_expiration_ledger(&provider, 300).await; + assert!(result.is_ok()); + let expiration = result.unwrap(); + assert_eq!(expiration, 1060); // 1000 + 60 + } + + #[tokio::test] + async fn test_get_expiration_ledger_zero_seconds() { + let mut provider = MockStellarProviderTrait::new(); + provider.expect_get_latest_ledger().returning(|| { + Box::pin(ready(Ok( + soroban_rs::stellar_rpc_client::GetLatestLedgerResponse { + id: "test".to_string(), + protocol_version: 20, + sequence: 1000, + }, + ))) + }); + + let result = get_expiration_ledger(&provider, 0).await; + assert!(result.is_ok()); + let expiration = result.unwrap(); + assert_eq!(expiration, 1001); // 1000 + 1 (minimum) + } +} diff --git a/src/domain/transaction/stellar/prepare/soroban_gas_abstraction.rs b/src/domain/transaction/stellar/prepare/soroban_gas_abstraction.rs index b22d8c620..0798d21b4 100644 --- a/src/domain/transaction/stellar/prepare/soroban_gas_abstraction.rs +++ b/src/domain/transaction/stellar/prepare/soroban_gas_abstraction.rs @@ -11,7 +11,11 @@ use soroban_rs::xdr::{ use tracing::{debug, info, warn}; use crate::{ - domain::transaction::stellar::{utils::convert_xlm_fee_to_token, StellarTransactionValidator}, + constants::STELLAR_DEFAULT_TRANSACTION_FEE, + domain::transaction::stellar::{ + utils::{convert_xlm_fee_to_token, envelope_fee_in_stroops, update_envelope_sequence}, + StellarTransactionValidator, + }, models::{RelayerStellarPolicy, StellarTransactionData, TransactionError, TransactionInput}, repositories::TransactionCounterTrait, services::{ @@ -94,41 +98,43 @@ where })?; // Inject the user's signed auth entry and convert relayer's auth to SourceAccount - // This must happen BEFORE fee validation because the simulation in fee estimation - // requires proper auth entries with signatures to succeed. + // This must happen before re-simulation so the simulation uses proper auth entries. let signed_auth_entries = inject_auth_entries_into_envelope(&mut envelope, signed_user_auth)?; - // Validate fee parameters to ensure relayer liquidity - // This validates: token is allowed, max_fee_amount covers the required fee - // Note: We pass the first auth entry (user's) which contains the fee parameters + // Re-simulate with signed auth entries to get accurate footprint and resources. + // This may bump the envelope fee when the new required fee is higher (still under max_fee + // check in validation below). + // + // According to Soroban flow, after signing auth entries you must re-simulate: + // 1. Simulation validates the signatures + // 2. Calculates ledger resources accurately + // 3. The footprint will include all accounts accessed via require_auth/require_auth_for_args + // 4. Returns a fully-resourced transaction ready for submission + simulate_and_update_resources(&mut envelope, &signed_auth_entries, provider).await?; + + // Validate fee parameters after resource update: token allowed, max_fee_amount covers + // the (possibly bumped) required fee. We use the envelope's current fee as required_xlm_fee + // to avoid a second simulation. if let Some(policy) = policy { + let required_xlm_fee = envelope_fee_in_stroops(&envelope) + .map_err(|e| TransactionError::ValidationError(e.to_string()))?; validate_gas_abstraction_fee( &envelope, &signed_auth_entries[0], policy, + Some(required_xlm_fee), provider, dex_service, ) .await?; } - // Re-simulate with signed auth entries to get accurate footprint. - // - // According to Soroban flow, after signing auth entries you must re-simulate: - // 1. Simulation validates the signatures - // 2. Calculates ledger resources accurately - // 3. The footprint will include all accounts accessed via require_auth/require_auth_for_args - // 4. Returns a fully-resourced transaction ready for submission - // - // The signed auth entries are used directly - this ensures the simulation executes - // the full auth verification code path and captures the correct footprint. - simulate_and_update_resources(&mut envelope, &signed_auth_entries, provider).await?; - // Get the next sequence number for the relayer let sequence_number = get_next_sequence(counter_service, relayer_id, relayer_address).await?; // Update the sequence number in the envelope - update_envelope_sequence(&mut envelope, sequence_number)?; + update_envelope_sequence(&mut envelope, sequence_number) + .map_err(|e| TransactionError::ValidationError(e.to_string()))?; // Serialize the updated envelope back to XDR let updated_xdr = envelope.to_xdr_base64(Limits::none()).map_err(|e| { @@ -259,25 +265,6 @@ fn inject_auth_entries_into_envelope( Ok(result_auth_entries) } -/// Update the sequence number in a transaction envelope. -fn update_envelope_sequence( - envelope: &mut TransactionEnvelope, - sequence: i64, -) -> Result<(), TransactionError> { - match envelope { - TransactionEnvelope::Tx(v1) => { - v1.tx.seq_num = soroban_rs::xdr::SequenceNumber(sequence); - Ok(()) - } - TransactionEnvelope::TxV0(_) => Err(TransactionError::ValidationError( - "V0 transactions are not supported".to_string(), - )), - TransactionEnvelope::TxFeeBump(_) => Err(TransactionError::ValidationError( - "Cannot update sequence number on fee bump transaction".to_string(), - )), - } -} - /// Apply a buffer to Soroban resources to account for simulation variance. /// /// Simulation can be slightly inaccurate due to timing differences or other factors. @@ -314,7 +301,7 @@ async fn simulate_and_update_resources

( where P: StellarProviderTrait + Send + Sync, { - info!("Re-simulating transaction with signed auth entries for accurate footprint"); + debug!("Re-simulating transaction with signed auth entries for accurate footprint"); // Use the actual signed auth entries for simulation // This allows the simulation to verify signatures and capture the correct footprint @@ -349,7 +336,7 @@ where })?; // Log the resource values from simulation (before buffer) - info!( + debug!( "Simulation complete: instructions={}, read_bytes={}, write_bytes={}", new_tx_data.resources.instructions, new_tx_data.resources.disk_read_bytes, @@ -367,21 +354,33 @@ where new_tx_data.resources.write_bytes ); + // Compute required XLM fee from this simulation (inclusion + resource fee) + let inclusion_fee = STELLAR_DEFAULT_TRANSACTION_FEE as u64; + let new_required_xlm_fee = inclusion_fee + sim_response.min_resource_fee; + let new_fee_u32 = new_required_xlm_fee.min(u32::MAX as u64) as u32; + // Update the original envelope's sorobanData with accurate resources - // Keep the original fee (already calculated and validated at /build time) + // Bump fee when the new required fee is higher (still validated against max_fee_amount later) match envelope { TransactionEnvelope::Tx(ref mut env) => { let original_fee = env.tx.fee; + let fee_to_set = if new_fee_u32 > original_fee { + debug!( + "Bumping envelope fee from {} to {} (required by updated simulation)", + original_fee, new_fee_u32 + ); + new_fee_u32 + } else { + original_fee + }; // Update the transaction extension with new soroban data env.tx.ext = TransactionExt::V1(new_tx_data); - - // Preserve the original fee - env.tx.fee = original_fee; + env.tx.fee = fee_to_set; debug!( - "Updated transaction sorobanData with simulation results, preserved fee={}", - original_fee + "Updated transaction sorobanData with simulation results, fee={}", + fee_to_set ); Ok(()) } @@ -472,10 +471,12 @@ fn build_simulation_envelope( /// /// # Arguments /// -/// * `envelope` - The transaction envelope (used to estimate required XLM fee) +/// * `envelope` - The transaction envelope (used to estimate required XLM fee when override is None) /// * `signed_user_auth` - The user's signed authorization entry /// * `policy` - The relayer policy containing allowed tokens and fee settings -/// * `provider` - Provider for Stellar RPC operations (fee estimation) +/// * `required_xlm_fee_override` - If Some, use this as the required XLM fee (e.g. envelope's +/// current fee after simulate_and_update_resources). If None, estimate via simulation. +/// * `provider` - Provider for Stellar RPC operations (fee estimation when override is None) /// * `dex_service` - DEX service for token-to-XLM conversion quotes /// /// # Returns @@ -485,6 +486,7 @@ async fn validate_gas_abstraction_fee( envelope: &TransactionEnvelope, signed_user_auth: &SorobanAuthorizationEntry, policy: &RelayerStellarPolicy, + required_xlm_fee_override: Option, provider: &P, dex_service: &D, ) -> Result<(), TransactionError> @@ -506,9 +508,9 @@ where })?; // Step 3: Validate max_fee_amount against policy's max_allowed_fee (if configured) - if max_fee_amount < 0 { + if max_fee_amount <= 0 { return Err(TransactionError::ValidationError( - "max_fee_amount cannot be negative".to_string(), + "max_fee_amount must be positive".to_string(), )); } StellarTransactionValidator::validate_token_max_fee(&fee_token, max_fee_amount as u64, policy) @@ -516,9 +518,11 @@ where TransactionError::ValidationError(format!("Max fee validation failed: {e}")) })?; - // Step 4: Estimate the required XLM fee from the transaction - // For Soroban transactions, this includes both inclusion fee and resource fee - let required_xlm_fee = estimate_required_xlm_fee(envelope, provider).await?; + // Step 4: Required XLM fee - use override (e.g. envelope fee after bump) or estimate + let required_xlm_fee = match required_xlm_fee_override { + Some(fee) => fee, + None => estimate_required_xlm_fee(envelope, provider).await?, + }; debug!( "Required XLM fee: {} stroops for gas abstraction transaction", @@ -821,72 +825,6 @@ mod tests { } } - // ==================== update_envelope_sequence tests ==================== - - #[test] - fn test_update_envelope_sequence() { - let mut envelope = create_minimal_v1_envelope(); - update_envelope_sequence(&mut envelope, 12345).unwrap(); - - if let TransactionEnvelope::Tx(v1) = &envelope { - assert_eq!(v1.tx.seq_num.0, 12345); - } else { - panic!("Expected Tx envelope"); - } - } - - #[test] - fn test_update_envelope_sequence_v0_returns_error() { - let mut envelope = create_v0_envelope(); - let result = update_envelope_sequence(&mut envelope, 12345); - - assert!(result.is_err()); - match result.unwrap_err() { - TransactionError::ValidationError(msg) => { - assert!(msg.contains("V0 transactions are not supported")); - } - _ => panic!("Expected ValidationError"), - } - } - - #[test] - fn test_update_envelope_sequence_fee_bump_returns_error() { - let mut envelope = create_fee_bump_envelope(); - let result = update_envelope_sequence(&mut envelope, 12345); - - assert!(result.is_err()); - match result.unwrap_err() { - TransactionError::ValidationError(msg) => { - assert!(msg.contains("Cannot update sequence number on fee bump transaction")); - } - _ => panic!("Expected ValidationError"), - } - } - - #[test] - fn test_update_envelope_sequence_zero() { - let mut envelope = create_minimal_v1_envelope(); - update_envelope_sequence(&mut envelope, 0).unwrap(); - - if let TransactionEnvelope::Tx(v1) = &envelope { - assert_eq!(v1.tx.seq_num.0, 0); - } else { - panic!("Expected Tx envelope"); - } - } - - #[test] - fn test_update_envelope_sequence_max_value() { - let mut envelope = create_minimal_v1_envelope(); - update_envelope_sequence(&mut envelope, i64::MAX).unwrap(); - - if let TransactionEnvelope::Tx(v1) = &envelope { - assert_eq!(v1.tx.seq_num.0, i64::MAX); - } else { - panic!("Expected Tx envelope"); - } - } - // ==================== apply_resource_buffer tests ==================== #[test] @@ -2032,9 +1970,15 @@ mod validate_gas_abstraction_fee_tests { let policy = create_policy_with_token(&different_token, Some(50_000_000)); // Execute - let result = - validate_gas_abstraction_fee(&envelope, &signed_auth, &policy, &provider, &dex_service) - .await; + let result = validate_gas_abstraction_fee( + &envelope, + &signed_auth, + &policy, + None, + &provider, + &dex_service, + ) + .await; // Assert assert!(result.is_err()); @@ -2069,9 +2013,15 @@ mod validate_gas_abstraction_fee_tests { let policy = create_policy_with_token(&fee_token_address, Some(50_000_000)); // Execute - let result = - validate_gas_abstraction_fee(&envelope, &signed_auth, &policy, &provider, &dex_service) - .await; + let result = validate_gas_abstraction_fee( + &envelope, + &signed_auth, + &policy, + None, + &provider, + &dex_service, + ) + .await; // Assert assert!(result.is_err()); @@ -2132,9 +2082,15 @@ mod validate_gas_abstraction_fee_tests { let policy = create_policy_with_token(&fee_token_address, Some(100_000_000)); // Execute - let result = - validate_gas_abstraction_fee(&envelope, &signed_auth, &policy, &provider, &dex_service) - .await; + let result = validate_gas_abstraction_fee( + &envelope, + &signed_auth, + &policy, + None, + &provider, + &dex_service, + ) + .await; // Assert assert!(result.is_err()); @@ -2200,9 +2156,15 @@ mod validate_gas_abstraction_fee_tests { let policy = create_policy_with_token(&fee_token_address, Some(20_000_000)); // Execute - let result = - validate_gas_abstraction_fee(&envelope, &signed_auth, &policy, &provider, &dex_service) - .await; + let result = validate_gas_abstraction_fee( + &envelope, + &signed_auth, + &policy, + None, + &provider, + &dex_service, + ) + .await; // Assert assert!( @@ -2230,9 +2192,15 @@ mod validate_gas_abstraction_fee_tests { let policy = create_policy_with_token(&fee_token_address, Some(100_000_000)); // Execute - let result = - validate_gas_abstraction_fee(&envelope, &signed_auth, &policy, &provider, &dex_service) - .await; + let result = validate_gas_abstraction_fee( + &envelope, + &signed_auth, + &policy, + None, + &provider, + &dex_service, + ) + .await; // Assert assert!(result.is_err()); @@ -2276,9 +2244,15 @@ mod validate_gas_abstraction_fee_tests { let policy = create_policy_with_token(&fee_token_address, Some(100_000_000)); // Execute - let result = - validate_gas_abstraction_fee(&envelope, &signed_auth, &policy, &provider, &dex_service) - .await; + let result = validate_gas_abstraction_fee( + &envelope, + &signed_auth, + &policy, + None, + &provider, + &dex_service, + ) + .await; // Assert: When simulation fails with zero resource fee, it returns ValidationError assert!(result.is_err()); diff --git a/src/domain/transaction/stellar/utils.rs b/src/domain/transaction/stellar/utils.rs index 6edb6255d..7d48f236d 100644 --- a/src/domain/transaction/stellar/utils.rs +++ b/src/domain/transaction/stellar/utils.rs @@ -67,6 +67,12 @@ pub enum StellarTransactionUtilsError { #[error("Cannot set time bounds on fee-bump transactions")] CannotSetTimeBoundsOnFeeBump, + #[error("V0 transactions are not supported")] + V0TransactionsNotSupported, + + #[error("Cannot update sequence number on fee bump transaction")] + CannotUpdateSequenceOnFeeBump, + #[error("Invalid transaction format: {0}")] InvalidTransactionFormat(String), @@ -184,6 +190,14 @@ impl From for RelayerError { "Cannot set time bounds on fee-bump transactions".to_string(), ) } + StellarTransactionUtilsError::V0TransactionsNotSupported => { + RelayerError::ValidationError("V0 transactions are not supported".to_string()) + } + StellarTransactionUtilsError::CannotUpdateSequenceOnFeeBump => { + RelayerError::ValidationError( + "Cannot update sequence number on fee bump transaction".to_string(), + ) + } StellarTransactionUtilsError::InvalidAccountAddress(_, msg) | StellarTransactionUtilsError::InvalidContractAddress(_, msg) | StellarTransactionUtilsError::SymbolCreationFailed(_, msg) @@ -356,6 +370,39 @@ pub fn create_transaction_signature_payload( } } +/// Update the sequence number in a transaction envelope. +/// +/// Only V1 (Tx) envelopes are supported; V0 and fee-bump envelopes return an error. +pub fn update_envelope_sequence( + envelope: &mut TransactionEnvelope, + sequence: i64, +) -> Result<(), StellarTransactionUtilsError> { + match envelope { + TransactionEnvelope::Tx(v1) => { + v1.tx.seq_num = soroban_rs::xdr::SequenceNumber(sequence); + Ok(()) + } + TransactionEnvelope::TxV0(_) => { + Err(StellarTransactionUtilsError::V0TransactionsNotSupported) + } + TransactionEnvelope::TxFeeBump(_) => { + Err(StellarTransactionUtilsError::CannotUpdateSequenceOnFeeBump) + } + } +} + +/// Extract the fee (in stroops) from a V1 transaction envelope. +pub fn envelope_fee_in_stroops( + envelope: &TransactionEnvelope, +) -> Result { + match envelope { + TransactionEnvelope::Tx(env) => Ok(u64::from(env.tx.fee)), + _ => Err(StellarTransactionUtilsError::InvalidTransactionFormat( + "Expected V1 transaction envelope".to_string(), + )), + } +} + // ============================================================================ // Account and Contract Address Utilities // ============================================================================ @@ -1655,6 +1702,161 @@ mod parse_contract_address_tests { } } +// ============================================================================ +// Update Envelope Sequence and Envelope Fee Tests +// ============================================================================ + +#[cfg(test)] +mod update_envelope_sequence_tests { + use super::*; + use soroban_rs::xdr::{ + FeeBumpTransaction, FeeBumpTransactionEnvelope, FeeBumpTransactionExt, + FeeBumpTransactionInnerTx, Memo, MuxedAccount, Preconditions, SequenceNumber, Transaction, + TransactionExt, TransactionV0, TransactionV0Envelope, TransactionV0Ext, + TransactionV1Envelope, Uint256, VecM, + }; + + fn create_minimal_v1_envelope() -> TransactionEnvelope { + let tx = Transaction { + source_account: MuxedAccount::Ed25519(Uint256([0u8; 32])), + fee: 100, + seq_num: SequenceNumber(0), + cond: Preconditions::None, + memo: Memo::None, + operations: VecM::default(), + ext: TransactionExt::V0, + }; + TransactionEnvelope::Tx(TransactionV1Envelope { + tx, + signatures: VecM::default(), + }) + } + + fn create_v0_envelope() -> TransactionEnvelope { + let tx = TransactionV0 { + source_account_ed25519: Uint256([0u8; 32]), + fee: 100, + seq_num: SequenceNumber(0), + time_bounds: None, + memo: Memo::None, + operations: VecM::default(), + ext: TransactionV0Ext::V0, + }; + TransactionEnvelope::TxV0(TransactionV0Envelope { + tx, + signatures: VecM::default(), + }) + } + + fn create_fee_bump_envelope() -> TransactionEnvelope { + let inner_tx = Transaction { + source_account: MuxedAccount::Ed25519(Uint256([0u8; 32])), + fee: 100, + seq_num: SequenceNumber(0), + cond: Preconditions::None, + memo: Memo::None, + operations: VecM::default(), + ext: TransactionExt::V0, + }; + let inner_envelope = TransactionV1Envelope { + tx: inner_tx, + signatures: VecM::default(), + }; + let fee_bump_tx = FeeBumpTransaction { + fee_source: MuxedAccount::Ed25519(Uint256([1u8; 32])), + fee: 200, + inner_tx: FeeBumpTransactionInnerTx::Tx(inner_envelope), + ext: FeeBumpTransactionExt::V0, + }; + TransactionEnvelope::TxFeeBump(FeeBumpTransactionEnvelope { + tx: fee_bump_tx, + signatures: VecM::default(), + }) + } + + #[test] + fn test_update_envelope_sequence() { + let mut envelope = create_minimal_v1_envelope(); + update_envelope_sequence(&mut envelope, 12345).unwrap(); + if let TransactionEnvelope::Tx(v1) = &envelope { + assert_eq!(v1.tx.seq_num.0, 12345); + } else { + panic!("Expected Tx envelope"); + } + } + + #[test] + fn test_update_envelope_sequence_v0_returns_error() { + let mut envelope = create_v0_envelope(); + let result = update_envelope_sequence(&mut envelope, 12345); + assert!(result.is_err()); + match result.unwrap_err() { + StellarTransactionUtilsError::V0TransactionsNotSupported => {} + _ => panic!("Expected V0TransactionsNotSupported error"), + } + } + + #[test] + fn test_update_envelope_sequence_fee_bump_returns_error() { + let mut envelope = create_fee_bump_envelope(); + let result = update_envelope_sequence(&mut envelope, 12345); + assert!(result.is_err()); + match result.unwrap_err() { + StellarTransactionUtilsError::CannotUpdateSequenceOnFeeBump => {} + _ => panic!("Expected CannotUpdateSequenceOnFeeBump error"), + } + } + + #[test] + fn test_update_envelope_sequence_zero() { + let mut envelope = create_minimal_v1_envelope(); + update_envelope_sequence(&mut envelope, 0).unwrap(); + if let TransactionEnvelope::Tx(v1) = &envelope { + assert_eq!(v1.tx.seq_num.0, 0); + } else { + panic!("Expected Tx envelope"); + } + } + + #[test] + fn test_update_envelope_sequence_max_value() { + let mut envelope = create_minimal_v1_envelope(); + update_envelope_sequence(&mut envelope, i64::MAX).unwrap(); + if let TransactionEnvelope::Tx(v1) = &envelope { + assert_eq!(v1.tx.seq_num.0, i64::MAX); + } else { + panic!("Expected Tx envelope"); + } + } + + #[test] + fn test_envelope_fee_in_stroops_v1() { + let envelope = create_minimal_v1_envelope(); + let fee = envelope_fee_in_stroops(&envelope).unwrap(); + assert_eq!(fee, 100); + } + + #[test] + fn test_envelope_fee_in_stroops_v0_returns_error() { + let envelope = create_v0_envelope(); + let result = envelope_fee_in_stroops(&envelope); + assert!(result.is_err()); + match result.unwrap_err() { + StellarTransactionUtilsError::InvalidTransactionFormat(msg) => { + assert!(msg.contains("Expected V1")); + } + _ => panic!("Expected InvalidTransactionFormat error"), + } + } + + #[test] + fn test_envelope_fee_in_stroops_fee_bump_returns_error() { + let envelope = create_fee_bump_envelope(); + let result = envelope_fee_in_stroops(&envelope); + assert!(result.is_err()); + } +} + // ============================================================================ // Contract Data Key Tests // ============================================================================ diff --git a/src/services/stellar_dex/soroswap_service.rs b/src/services/stellar_dex/soroswap_service.rs index 9e9869a45..40bf536bc 100644 --- a/src/services/stellar_dex/soroswap_service.rs +++ b/src/services/stellar_dex/soroswap_service.rs @@ -566,13 +566,14 @@ where Ok((xdr, quote)) } + /// Swap execution is not yet implemented. + /// + /// Required by [`StellarDexServiceTrait`]. `params` is intentionally unused and will be + /// used when building and submitting the Soroswap swap transaction. async fn execute_swap( &self, _params: SwapTransactionParams, ) -> Result { - // TODO: Implement actual swap execution - // This requires building and submitting a Soroswap swap transaction - warn!("Soroswap execute_swap is not yet implemented"); Err(StellarDexServiceError::UnknownError( diff --git a/src/services/stellar_fee_forwarder/mod.rs b/src/services/stellar_fee_forwarder/mod.rs index c1d3d77e4..f4c35c9c6 100644 --- a/src/services/stellar_fee_forwarder/mod.rs +++ b/src/services/stellar_fee_forwarder/mod.rs @@ -14,6 +14,7 @@ //! - `fee_token.approve(user, fee_forwarder, max_fee_amount, expiration_ledger)` //! - `target_contract.target_fn(target_args)` (if target requires auth) +use crate::constants::STELLAR_LEDGER_TIME_SECONDS; use crate::services::provider::StellarProviderTrait; use soroban_rs::xdr::{ ContractId, Hash, Int128Parts, InvokeContractArgs, Limits, Operation, OperationBody, ScAddress, @@ -23,11 +24,10 @@ use soroban_rs::xdr::{ use std::sync::Arc; use thiserror::Error; -/// Default validity duration for gas abstraction authorizations (5 minutes) -pub const DEFAULT_VALIDITY_SECONDS: u64 = 300; - -/// Approximate ledger time in seconds -pub const LEDGER_TIME_SECONDS: u64 = 5; +/// Default validity duration for gas abstraction authorizations (2 minutes). +/// +/// Matches sponsored transaction validity so authorization expiration aligns with the submission window. +pub const DEFAULT_VALIDITY_SECONDS: u64 = 120; /// Errors that can occur in FeeForwarder operations #[derive(Error, Debug)] @@ -370,7 +370,7 @@ where .await .map_err(|e| FeeForwarderError::ProviderError(e.to_string()))?; - let ledgers_to_add = validity_seconds / LEDGER_TIME_SECONDS; + let ledgers_to_add = validity_seconds / STELLAR_LEDGER_TIME_SECONDS; Ok(current_ledger.sequence + ledgers_to_add as u32) } @@ -1171,11 +1171,11 @@ mod tests { #[test] fn test_default_validity_seconds() { - assert_eq!(DEFAULT_VALIDITY_SECONDS, 300); + assert_eq!(DEFAULT_VALIDITY_SECONDS, 120); // 2 minutes, matches sponsored tx validity } #[test] fn test_ledger_time_seconds() { - assert_eq!(LEDGER_TIME_SECONDS, 5); + assert_eq!(STELLAR_LEDGER_TIME_SECONDS, 5); } } From 77625fcabbb0f365501cd284328d3cdddd53ec94 Mon Sep 17 00:00:00 2001 From: Nicolas Molina Date: Fri, 6 Feb 2026 11:57:45 -0300 Subject: [PATCH 18/22] fix: Adding coverage --- src/domain/relayer/stellar/utils.rs | 155 +++++++ src/domain/transaction/stellar/utils.rs | 535 ++++++++++++++++++++++++ 2 files changed, 690 insertions(+) diff --git a/src/domain/relayer/stellar/utils.rs b/src/domain/relayer/stellar/utils.rs index 4cd8e33eb..1387bed8b 100644 --- a/src/domain/relayer/stellar/utils.rs +++ b/src/domain/relayer/stellar/utils.rs @@ -96,6 +96,60 @@ mod tests { assert_eq!(result, 105); } + // ============================================================================ + // Tests for apply_max_fee_slippage_with_bps (direct) + // ============================================================================ + + #[test] + fn test_apply_max_fee_slippage_with_bps_zero_slippage() { + // 0 BPS = no slippage + let result = apply_max_fee_slippage_with_bps(10000, 0); + assert_eq!(result, 10000); + } + + #[test] + fn test_apply_max_fee_slippage_with_bps_one_percent() { + // 100 BPS = 1% + let result = apply_max_fee_slippage_with_bps(10000, 100); + assert_eq!(result, 10100); + } + + #[test] + fn test_apply_max_fee_slippage_with_bps_ten_percent() { + // 1000 BPS = 10% + let result = apply_max_fee_slippage_with_bps(10000, 1000); + assert_eq!(result, 11000); + } + + #[test] + fn test_apply_max_fee_slippage_with_bps_hundred_percent() { + // 10000 BPS = 100% (double) + let result = apply_max_fee_slippage_with_bps(10000, 10000); + assert_eq!(result, 20000); + } + + #[test] + fn test_apply_max_fee_slippage_with_bps_zero_fee() { + let result = apply_max_fee_slippage_with_bps(0, 500); + assert_eq!(result, 0); + } + + #[test] + fn test_apply_max_fee_slippage_with_bps_large_fee() { + let large_fee: u64 = 1_000_000_000_000; + // 5% slippage + let result = apply_max_fee_slippage_with_bps(large_fee, 500); + assert_eq!(result, 1_050_000_000_000i128); + } + + #[test] + fn test_apply_max_fee_slippage_with_bps_small_fee_rounds_down() { + // 1 unit with 1 BPS (0.01%) — should round down to 1 + let result = apply_max_fee_slippage_with_bps(1, 1); + // (1 * 10001) / 10000 = 1 (integer division) + assert_eq!(result, 1); + } + // ============================================================================ // Tests for get_expiration_ledger // ============================================================================ @@ -137,4 +191,105 @@ mod tests { let expiration = result.unwrap(); assert_eq!(expiration, 1001); // 1000 + 1 (minimum) } + + #[tokio::test] + async fn test_get_expiration_ledger_provider_error() { + use crate::services::provider::ProviderError; + + let mut provider = MockStellarProviderTrait::new(); + provider.expect_get_latest_ledger().returning(|| { + Box::pin(ready(Err(ProviderError::Other( + "network error".to_string(), + )))) + }); + + let result = get_expiration_ledger(&provider, 300).await; + assert!(result.is_err()); + match result.unwrap_err() { + RelayerError::Internal(msg) => { + assert!(msg.contains("Failed to get latest ledger")); + } + _ => panic!("Expected Internal error"), + } + } + + #[tokio::test] + async fn test_get_expiration_ledger_non_divisible_seconds() { + // 7 seconds / 5 seconds per ledger = 2 ledgers (div_ceil) + let mut provider = MockStellarProviderTrait::new(); + provider.expect_get_latest_ledger().returning(|| { + Box::pin(ready(Ok( + soroban_rs::stellar_rpc_client::GetLatestLedgerResponse { + id: "test".to_string(), + protocol_version: 20, + sequence: 1000, + }, + ))) + }); + + let result = get_expiration_ledger(&provider, 7).await; + assert!(result.is_ok()); + let expiration = result.unwrap(); + assert_eq!(expiration, 1002); // 1000 + ceil(7/5) = 1000 + 2 + } + + #[tokio::test] + async fn test_get_expiration_ledger_one_second() { + let mut provider = MockStellarProviderTrait::new(); + provider.expect_get_latest_ledger().returning(|| { + Box::pin(ready(Ok( + soroban_rs::stellar_rpc_client::GetLatestLedgerResponse { + id: "test".to_string(), + protocol_version: 20, + sequence: 500, + }, + ))) + }); + + let result = get_expiration_ledger(&provider, 1).await; + assert!(result.is_ok()); + let expiration = result.unwrap(); + assert_eq!(expiration, 501); // 500 + ceil(1/5) = 500 + 1 + } + + #[tokio::test] + async fn test_get_expiration_ledger_sequence_near_max() { + // Test saturating_add behavior near u32::MAX + let mut provider = MockStellarProviderTrait::new(); + provider.expect_get_latest_ledger().returning(|| { + Box::pin(ready(Ok( + soroban_rs::stellar_rpc_client::GetLatestLedgerResponse { + id: "test".to_string(), + protocol_version: 20, + sequence: u32::MAX - 1, + }, + ))) + }); + + let result = get_expiration_ledger(&provider, 300).await; + assert!(result.is_ok()); + let expiration = result.unwrap(); + // saturating_add should cap at u32::MAX + assert_eq!(expiration, u32::MAX); + } + + #[tokio::test] + async fn test_get_expiration_ledger_exact_ledger_time() { + // Exactly STELLAR_LEDGER_TIME_SECONDS (5 seconds) = 1 ledger + let mut provider = MockStellarProviderTrait::new(); + provider.expect_get_latest_ledger().returning(|| { + Box::pin(ready(Ok( + soroban_rs::stellar_rpc_client::GetLatestLedgerResponse { + id: "test".to_string(), + protocol_version: 20, + sequence: 2000, + }, + ))) + }); + + let result = get_expiration_ledger(&provider, STELLAR_LEDGER_TIME_SECONDS).await; + assert!(result.is_ok()); + let expiration = result.unwrap(); + assert_eq!(expiration, 2001); // 2000 + 1 + } } diff --git a/src/domain/transaction/stellar/utils.rs b/src/domain/transaction/stellar/utils.rs index 7d48f236d..2ad8df821 100644 --- a/src/domain/transaction/stellar/utils.rs +++ b/src/domain/transaction/stellar/utils.rs @@ -3093,3 +3093,538 @@ mod set_time_bounds_tests { } } } + +// ============================================================================ +// From for RelayerError Tests +// ============================================================================ + +#[cfg(test)] +mod stellar_transaction_utils_error_conversion_tests { + use super::*; + + #[test] + fn test_v0_transactions_not_supported_converts_to_validation_error() { + let err = StellarTransactionUtilsError::V0TransactionsNotSupported; + let relayer_err: RelayerError = err.into(); + match relayer_err { + RelayerError::ValidationError(msg) => { + assert_eq!(msg, "V0 transactions are not supported"); + } + _ => panic!("Expected ValidationError"), + } + } + + #[test] + fn test_cannot_update_sequence_on_fee_bump_converts_to_validation_error() { + let err = StellarTransactionUtilsError::CannotUpdateSequenceOnFeeBump; + let relayer_err: RelayerError = err.into(); + match relayer_err { + RelayerError::ValidationError(msg) => { + assert_eq!(msg, "Cannot update sequence number on fee bump transaction"); + } + _ => panic!("Expected ValidationError"), + } + } + + #[test] + fn test_cannot_set_time_bounds_on_fee_bump_converts_to_validation_error() { + let err = StellarTransactionUtilsError::CannotSetTimeBoundsOnFeeBump; + let relayer_err: RelayerError = err.into(); + match relayer_err { + RelayerError::ValidationError(msg) => { + assert_eq!(msg, "Cannot set time bounds on fee-bump transactions"); + } + _ => panic!("Expected ValidationError"), + } + } + + #[test] + fn test_invalid_transaction_format_converts_to_validation_error() { + let err = StellarTransactionUtilsError::InvalidTransactionFormat("bad format".to_string()); + let relayer_err: RelayerError = err.into(); + match relayer_err { + RelayerError::ValidationError(msg) => { + assert_eq!(msg, "bad format"); + } + _ => panic!("Expected ValidationError"), + } + } + + #[test] + fn test_cannot_modify_fee_bump_converts_to_validation_error() { + let err = StellarTransactionUtilsError::CannotModifyFeeBump; + let relayer_err: RelayerError = err.into(); + match relayer_err { + RelayerError::ValidationError(msg) => { + assert_eq!(msg, "Cannot add operations to fee-bump transactions"); + } + _ => panic!("Expected ValidationError"), + } + } + + #[test] + fn test_too_many_operations_converts_to_validation_error() { + let err = StellarTransactionUtilsError::TooManyOperations(100); + let relayer_err: RelayerError = err.into(); + match relayer_err { + RelayerError::ValidationError(msg) => { + assert!(msg.contains("Too many operations")); + assert!(msg.contains("100")); + } + _ => panic!("Expected ValidationError"), + } + } + + #[test] + fn test_sequence_overflow_converts_to_internal_error() { + let err = StellarTransactionUtilsError::SequenceOverflow("overflow msg".to_string()); + let relayer_err: RelayerError = err.into(); + match relayer_err { + RelayerError::Internal(msg) => { + assert_eq!(msg, "overflow msg"); + } + _ => panic!("Expected Internal error"), + } + } + + #[test] + fn test_simulation_no_results_converts_to_internal_error() { + let err = StellarTransactionUtilsError::SimulationNoResults; + let relayer_err: RelayerError = err.into(); + match relayer_err { + RelayerError::Internal(msg) => { + assert!(msg.contains("no results")); + } + _ => panic!("Expected Internal error"), + } + } + + #[test] + fn test_asset_code_too_long_converts_to_validation_error() { + let err = + StellarTransactionUtilsError::AssetCodeTooLong(12, "VERYLONGASSETCODE".to_string()); + let relayer_err: RelayerError = err.into(); + match relayer_err { + RelayerError::ValidationError(msg) => { + assert!(msg.contains("Asset code too long")); + assert!(msg.contains("12")); + } + _ => panic!("Expected ValidationError"), + } + } + + #[test] + fn test_invalid_asset_format_converts_to_validation_error() { + let err = StellarTransactionUtilsError::InvalidAssetFormat("bad asset".to_string()); + let relayer_err: RelayerError = err.into(); + match relayer_err { + RelayerError::ValidationError(msg) => { + assert_eq!(msg, "bad asset"); + } + _ => panic!("Expected ValidationError"), + } + } + + #[test] + fn test_invalid_account_address_converts_to_internal_error() { + let err = StellarTransactionUtilsError::InvalidAccountAddress( + "GABC".to_string(), + "parse error".to_string(), + ); + let relayer_err: RelayerError = err.into(); + match relayer_err { + RelayerError::Internal(msg) => { + assert_eq!(msg, "parse error"); + } + _ => panic!("Expected Internal error"), + } + } + + #[test] + fn test_invalid_contract_address_converts_to_internal_error() { + let err = StellarTransactionUtilsError::InvalidContractAddress( + "CABC".to_string(), + "contract parse error".to_string(), + ); + let relayer_err: RelayerError = err.into(); + match relayer_err { + RelayerError::Internal(msg) => { + assert_eq!(msg, "contract parse error"); + } + _ => panic!("Expected Internal error"), + } + } + + #[test] + fn test_symbol_creation_failed_converts_to_internal_error() { + let err = StellarTransactionUtilsError::SymbolCreationFailed( + "Balance".to_string(), + "too long".to_string(), + ); + let relayer_err: RelayerError = err.into(); + match relayer_err { + RelayerError::Internal(msg) => { + assert_eq!(msg, "too long"); + } + _ => panic!("Expected Internal error"), + } + } + + #[test] + fn test_key_vector_creation_failed_converts_to_internal_error() { + let err = StellarTransactionUtilsError::KeyVectorCreationFailed( + "Balance".to_string(), + "vec error".to_string(), + ); + let relayer_err: RelayerError = err.into(); + match relayer_err { + RelayerError::Internal(msg) => { + assert_eq!(msg, "vec error"); + } + _ => panic!("Expected Internal error"), + } + } + + #[test] + fn test_contract_data_query_persistent_failed_converts_to_internal_error() { + let err = StellarTransactionUtilsError::ContractDataQueryPersistentFailed( + "balance".to_string(), + "rpc error".to_string(), + ); + let relayer_err: RelayerError = err.into(); + match relayer_err { + RelayerError::Internal(msg) => { + assert_eq!(msg, "rpc error"); + } + _ => panic!("Expected Internal error"), + } + } + + #[test] + fn test_contract_data_query_temporary_failed_converts_to_internal_error() { + let err = StellarTransactionUtilsError::ContractDataQueryTemporaryFailed( + "balance".to_string(), + "temp error".to_string(), + ); + let relayer_err: RelayerError = err.into(); + match relayer_err { + RelayerError::Internal(msg) => { + assert_eq!(msg, "temp error"); + } + _ => panic!("Expected Internal error"), + } + } + + #[test] + fn test_ledger_entry_parse_failed_converts_to_internal_error() { + let err = StellarTransactionUtilsError::LedgerEntryParseFailed( + "entry".to_string(), + "xdr error".to_string(), + ); + let relayer_err: RelayerError = err.into(); + match relayer_err { + RelayerError::Internal(msg) => { + assert_eq!(msg, "xdr error"); + } + _ => panic!("Expected Internal error"), + } + } + + #[test] + fn test_no_entries_found_converts_to_validation_error() { + let err = StellarTransactionUtilsError::NoEntriesFound("balance".to_string()); + let relayer_err: RelayerError = err.into(); + match relayer_err { + RelayerError::ValidationError(msg) => { + assert!(msg.contains("No entries found")); + } + _ => panic!("Expected ValidationError"), + } + } + + #[test] + fn test_empty_entries_converts_to_validation_error() { + let err = StellarTransactionUtilsError::EmptyEntries("balance".to_string()); + let relayer_err: RelayerError = err.into(); + match relayer_err { + RelayerError::ValidationError(msg) => { + assert!(msg.contains("Empty entries")); + } + _ => panic!("Expected ValidationError"), + } + } + + #[test] + fn test_unexpected_ledger_entry_type_converts_to_validation_error() { + let err = StellarTransactionUtilsError::UnexpectedLedgerEntryType("balance".to_string()); + let relayer_err: RelayerError = err.into(); + match relayer_err { + RelayerError::ValidationError(msg) => { + assert!(msg.contains("Unexpected ledger entry type")); + } + _ => panic!("Expected ValidationError"), + } + } + + #[test] + fn test_invalid_issuer_length_converts_to_validation_error() { + let err = StellarTransactionUtilsError::InvalidIssuerLength(56, "SHORT".to_string()); + let relayer_err: RelayerError = err.into(); + match relayer_err { + RelayerError::ValidationError(msg) => { + assert!(msg.contains("56")); + assert!(msg.contains("SHORT")); + } + _ => panic!("Expected ValidationError"), + } + } + + #[test] + fn test_invalid_issuer_prefix_converts_to_validation_error() { + let err = StellarTransactionUtilsError::InvalidIssuerPrefix('G', "CABC123".to_string()); + let relayer_err: RelayerError = err.into(); + match relayer_err { + RelayerError::ValidationError(msg) => { + assert!(msg.contains("'G'")); + assert!(msg.contains("CABC123")); + } + _ => panic!("Expected ValidationError"), + } + } + + #[test] + fn test_account_fetch_failed_converts_to_provider_error() { + let err = StellarTransactionUtilsError::AccountFetchFailed("fetch error".to_string()); + let relayer_err: RelayerError = err.into(); + match relayer_err { + RelayerError::ProviderError(msg) => { + assert_eq!(msg, "fetch error"); + } + _ => panic!("Expected ProviderError"), + } + } + + #[test] + fn test_trustline_query_failed_converts_to_provider_error() { + let err = StellarTransactionUtilsError::TrustlineQueryFailed( + "USDC".to_string(), + "rpc fail".to_string(), + ); + let relayer_err: RelayerError = err.into(); + match relayer_err { + RelayerError::ProviderError(msg) => { + assert_eq!(msg, "rpc fail"); + } + _ => panic!("Expected ProviderError"), + } + } + + #[test] + fn test_contract_invocation_failed_converts_to_provider_error() { + let err = StellarTransactionUtilsError::ContractInvocationFailed( + "transfer".to_string(), + "invoke error".to_string(), + ); + let relayer_err: RelayerError = err.into(); + match relayer_err { + RelayerError::ProviderError(msg) => { + assert_eq!(msg, "invoke error"); + } + _ => panic!("Expected ProviderError"), + } + } + + #[test] + fn test_xdr_parse_failed_converts_to_internal_error() { + let err = StellarTransactionUtilsError::XdrParseFailed("xdr parse fail".to_string()); + let relayer_err: RelayerError = err.into(); + match relayer_err { + RelayerError::Internal(msg) => { + assert_eq!(msg, "xdr parse fail"); + } + _ => panic!("Expected Internal error"), + } + } + + #[test] + fn test_operation_extraction_failed_converts_to_internal_error() { + let err = + StellarTransactionUtilsError::OperationExtractionFailed("extract fail".to_string()); + let relayer_err: RelayerError = err.into(); + match relayer_err { + RelayerError::Internal(msg) => { + assert_eq!(msg, "extract fail"); + } + _ => panic!("Expected Internal error"), + } + } + + #[test] + fn test_simulation_failed_converts_to_internal_error() { + let err = StellarTransactionUtilsError::SimulationFailed("sim error".to_string()); + let relayer_err: RelayerError = err.into(); + match relayer_err { + RelayerError::Internal(msg) => { + assert_eq!(msg, "sim error"); + } + _ => panic!("Expected Internal error"), + } + } + + #[test] + fn test_simulation_check_failed_converts_to_internal_error() { + let err = StellarTransactionUtilsError::SimulationCheckFailed("check fail".to_string()); + let relayer_err: RelayerError = err.into(); + match relayer_err { + RelayerError::Internal(msg) => { + assert_eq!(msg, "check fail"); + } + _ => panic!("Expected Internal error"), + } + } + + #[test] + fn test_dex_quote_failed_converts_to_internal_error() { + let err = StellarTransactionUtilsError::DexQuoteFailed("dex error".to_string()); + let relayer_err: RelayerError = err.into(); + match relayer_err { + RelayerError::Internal(msg) => { + assert_eq!(msg, "dex error"); + } + _ => panic!("Expected Internal error"), + } + } + + #[test] + fn test_empty_asset_code_converts_to_validation_error() { + let err = StellarTransactionUtilsError::EmptyAssetCode("CODE:ISSUER".to_string()); + let relayer_err: RelayerError = err.into(); + match relayer_err { + RelayerError::ValidationError(msg) => { + assert!(msg.contains("Asset code cannot be empty")); + } + _ => panic!("Expected ValidationError"), + } + } + + #[test] + fn test_empty_issuer_address_converts_to_validation_error() { + let err = StellarTransactionUtilsError::EmptyIssuerAddress("USDC:".to_string()); + let relayer_err: RelayerError = err.into(); + match relayer_err { + RelayerError::ValidationError(msg) => { + assert!(msg.contains("Issuer address cannot be empty")); + } + _ => panic!("Expected ValidationError"), + } + } + + #[test] + fn test_no_trustline_found_converts_to_validation_error() { + let err = + StellarTransactionUtilsError::NoTrustlineFound("USDC".to_string(), "GABC".to_string()); + let relayer_err: RelayerError = err.into(); + match relayer_err { + RelayerError::ValidationError(msg) => { + assert!(msg.contains("No trustline found")); + } + _ => panic!("Expected ValidationError"), + } + } + + #[test] + fn test_unsupported_trustline_version_converts_to_validation_error() { + let err = StellarTransactionUtilsError::UnsupportedTrustlineVersion; + let relayer_err: RelayerError = err.into(); + match relayer_err { + RelayerError::ValidationError(msg) => { + assert!(msg.contains("Unsupported trustline")); + } + _ => panic!("Expected ValidationError"), + } + } + + #[test] + fn test_unexpected_trustline_entry_type_converts_to_validation_error() { + let err = StellarTransactionUtilsError::UnexpectedTrustlineEntryType; + let relayer_err: RelayerError = err.into(); + match relayer_err { + RelayerError::ValidationError(msg) => { + assert!(msg.contains("Unexpected ledger entry type")); + } + _ => panic!("Expected ValidationError"), + } + } + + #[test] + fn test_balance_too_large_converts_to_validation_error() { + let err = StellarTransactionUtilsError::BalanceTooLarge(1, 999); + let relayer_err: RelayerError = err.into(); + match relayer_err { + RelayerError::ValidationError(msg) => { + assert!(msg.contains("Balance too large")); + } + _ => panic!("Expected ValidationError"), + } + } + + #[test] + fn test_negative_balance_i128_converts_to_validation_error() { + let err = StellarTransactionUtilsError::NegativeBalanceI128(42); + let relayer_err: RelayerError = err.into(); + match relayer_err { + RelayerError::ValidationError(msg) => { + assert!(msg.contains("Negative balance")); + } + _ => panic!("Expected ValidationError"), + } + } + + #[test] + fn test_negative_balance_i64_converts_to_validation_error() { + let err = StellarTransactionUtilsError::NegativeBalanceI64(-5); + let relayer_err: RelayerError = err.into(); + match relayer_err { + RelayerError::ValidationError(msg) => { + assert!(msg.contains("Negative balance")); + } + _ => panic!("Expected ValidationError"), + } + } + + #[test] + fn test_unexpected_balance_type_converts_to_validation_error() { + let err = StellarTransactionUtilsError::UnexpectedBalanceType("Bool(true)".to_string()); + let relayer_err: RelayerError = err.into(); + match relayer_err { + RelayerError::ValidationError(msg) => { + assert!(msg.contains("Unexpected balance value type")); + } + _ => panic!("Expected ValidationError"), + } + } + + #[test] + fn test_unexpected_contract_data_entry_type_converts_to_validation_error() { + let err = StellarTransactionUtilsError::UnexpectedContractDataEntryType; + let relayer_err: RelayerError = err.into(); + match relayer_err { + RelayerError::ValidationError(msg) => { + assert!(msg.contains("Unexpected ledger entry type")); + } + _ => panic!("Expected ValidationError"), + } + } + + #[test] + fn test_native_asset_in_trustline_query_converts_to_validation_error() { + let err = StellarTransactionUtilsError::NativeAssetInTrustlineQuery; + let relayer_err: RelayerError = err.into(); + match relayer_err { + RelayerError::ValidationError(msg) => { + assert!(msg.contains("Native asset")); + } + _ => panic!("Expected ValidationError"), + } + } +} From 064b159080ee1780863648ab59c2cc8f540de3d6 Mon Sep 17 00:00:00 2001 From: Nicolas Molina Date: Fri, 6 Feb 2026 12:10:52 -0300 Subject: [PATCH 19/22] fix: Fixing test --- .../transaction/stellar/prepare/soroban_gas_abstraction.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/domain/transaction/stellar/prepare/soroban_gas_abstraction.rs b/src/domain/transaction/stellar/prepare/soroban_gas_abstraction.rs index 0798d21b4..7db65f37a 100644 --- a/src/domain/transaction/stellar/prepare/soroban_gas_abstraction.rs +++ b/src/domain/transaction/stellar/prepare/soroban_gas_abstraction.rs @@ -2208,9 +2208,8 @@ mod validate_gas_abstraction_fee_tests { match err { TransactionError::ValidationError(msg) => { assert!( - msg.contains("cannot be negative"), - "Expected 'cannot be negative' in error message, got: {}", - msg + msg.contains("must be positive"), + "Expected 'must be positive' in error message, got: {msg}", ); } other => panic!("Expected ValidationError, got: {:?}", other), From f4b638da83ba6d2d3930705a700f10db1458d273 Mon Sep 17 00:00:00 2001 From: Nicolas Molina Date: Fri, 6 Feb 2026 12:20:49 -0300 Subject: [PATCH 20/22] fix: Fixing clippy issue --- src/repositories/transaction/transaction_redis.rs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/repositories/transaction/transaction_redis.rs b/src/repositories/transaction/transaction_redis.rs index 332b9b02b..bce9e52f1 100644 --- a/src/repositories/transaction/transaction_redis.rs +++ b/src/repositories/transaction/transaction_redis.rs @@ -1363,8 +1363,7 @@ impl TransactionRepository for RedisTransactionRepository { if cas_result == -1 { return Err(RepositoryError::NotFound(format!( - "Transaction with ID {} not found", - tx_id + "Transaction with ID {tx_id} not found" ))); } @@ -1382,8 +1381,7 @@ impl TransactionRepository for RedisTransactionRepository { continue; } return Err(RepositoryError::TransactionFailure(format!( - "Concurrent update conflict for transaction {}", - tx_id + "Concurrent update conflict for transaction {tx_id}" ))); } From 4ad5ccf77f75694633ee9dfadd8d3f984e4f67e8 Mon Sep 17 00:00:00 2001 From: Nicolas Molina Date: Fri, 6 Feb 2026 17:52:52 -0300 Subject: [PATCH 21/22] docs: Adding documentation --- .../stellar-sponsored-transactions-guide.mdx | 269 +++++++++++++++++- 1 file changed, 265 insertions(+), 4 deletions(-) diff --git a/docs/guides/stellar-sponsored-transactions-guide.mdx b/docs/guides/stellar-sponsored-transactions-guide.mdx index 17723ddee..94d9d2bc5 100644 --- a/docs/guides/stellar-sponsored-transactions-guide.mdx +++ b/docs/guides/stellar-sponsored-transactions-guide.mdx @@ -70,10 +70,12 @@ First, configure a Stellar relayer in your `config.json`: ### 2. Policy Configuration Explained #### `fee_payment_strategy` + - **`relayer`**: Relayer pays all network fees. Users pay fees in tokens. - **`user`**: User must include fee payment in the transaction. #### `allowed_tokens` + List of tokens that can be used for fee payment. Each token can have: - **`asset`**: Asset identifier in format `"native"` or `"CODE:ISSUER"` (e.g., `"USDC:GA5Z..."`) @@ -85,6 +87,7 @@ List of tokens that can be used for fee payment. Each token can have: - `retain_min_amount`: Minimum amount to retain after swap #### `swap_config` (Global) + Configuration for converting collected tokens back to XLM: - **`strategies`**: DEX strategies to use (currently supports `order-book`) @@ -92,9 +95,11 @@ Configuration for converting collected tokens back to XLM: - **`min_balance_threshold`**: Minimum XLM balance (in stroops) before triggering swaps #### `slippage_percentage` + Default slippage tolerance for token conversions (default: 1.0%) #### `fee_margin_percentage` + Additional fee margin added to estimated fees to account for price fluctuations (default: 10.0%) ### 3. Enabling Trustlines @@ -103,7 +108,6 @@ Before users can pay fees in tokens, the relayer account must establish trustlin For a complete example of how to create trustlines, see the [Relayer SDK example](https://github.com/OpenZeppelin/openzeppelin-relayer-sdk/tree/main/examples/relayer/stellar/sponsored/create-trustline.ts). - ## Using the API The sponsored transaction flow consists of four steps: **Quote**, **Build**, **Sign**, and **Send**. @@ -226,7 +230,7 @@ If the user's address is connected with another relayer configured in **relayer **Endpoint:** `POST /api/v1/relayers/{relayer_id}/sign-transaction` -``` +```` #### Option 3: Custom Signing Solution @@ -251,7 +255,7 @@ Submit the signed transaction to the relayer. The relayer will: "network": "testnet", "fee_bump": true } -``` +```` **Response:** @@ -304,6 +308,7 @@ When building transactions from operations, use the following format: ``` Supported operation types: + - `payment`: Standard payment operation - `createAccount`: Account creation - `changeTrust`: Trustline management @@ -331,7 +336,6 @@ Supported operation types: - `extendFootprintTtl`: Extend footprint TTL - `restoreFootprint`: Restore footprint - ## Best Practices 1. **Always Quote First**: Get a fee quote before building to show users the expected cost @@ -342,6 +346,263 @@ Supported operation types: 6. **Use Fee Margins**: The `fee_margin_percentage` helps account for price fluctuations 7. **Monitor Trustlines**: Ensure trustlines are established before users attempt transactions +## Soroban Gas Abstraction + +### Overview + +Soroban Gas Abstraction extends the sponsored transaction concept to Soroban smart contracts. Instead of requiring users to hold XLM for contract invocation fees, users can pay in any Soroban token (e.g., a USDC Soroban token contract). This is powered by a **FeeForwarder smart contract** that wraps the user's contract call with fee collection logic. + +**How it differs from Classic Stellar Sponsored Transactions:** + +| Aspect | Classic Stellar | Soroban Gas Abstraction | +| -------------------- | ----------------------------------------------- | -------------------------------------------------------- | +| **Transaction type** | Classic operations (payments, trustlines, etc.) | Soroban `InvokeHostFunction` operations | +| **Fee mechanism** | Fee-bump transaction wrapper | FeeForwarder smart contract | +| **Fee token format** | Credit assets (`CODE:ISSUER`) | Soroban token contracts (`C...` address) | +| **Authorization** | Standard transaction signing | User signs a `SorobanAuthorizationEntry` | +| **Submission** | Relayer wraps in fee-bump envelope | Relayer injects signed auth entries and submits directly | + +### Prerequisites + +- A running OpenZeppelin Relayer instance +- A Stellar relayer configured with `"fee_payment_strategy": "user"` +- A deployed **FeeForwarder contract** on the target network. [Check the OpenZeppelin contract here](https://github.com/OpenZeppelin/stellar-contracts/tree/main/packages/fee-abstraction) +- The `STELLAR_TESTNET_FEE_FORWARDER_ADDRESS` or `STELLAR_MAINNET_FEE_FORWARDER_ADDRESS` environment variable set to the FeeForwarder contract address +- Allowed tokens configured with Soroban token contract addresses (`C...` format) +- Sufficient XLM balance in the relayer account for network fees + +### Configuration + +Configure the relayer with Soroban token contracts in the `allowed_tokens` list. Note that Soroban tokens use contract addresses (`C...` format) rather than the `CODE:ISSUER` format used for classic Stellar assets. + +```json +{ + "relayers": [ + { + "id": "stellar-soroban-gas", + "name": "Stellar Soroban Gas Abstraction", + "network": "testnet", + "paused": false, + "signer_id": "local-signer", + "network_type": "stellar", + "policies": { + "fee_payment_strategy": "user", + "fee_margin_percentage": 5, + "allowed_tokens": [ + { + "asset": "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC", + "max_allowed_fee": 1000000000, + "swap_config": { + "slippage_percentage": 0.5, + "min_amount": 10000000, + "max_amount": 1000000000, + "retain_min_amount": 10000000 + } + } + ], + "swap_config": { + "strategies": ["soroswap"], + "cron_schedule": "0 */6 * * *", + "min_balance_threshold": 50000000 + } + } + } + ] +} +``` + +Set the required environment variables: + + + For testnet, you must provide all environment variables. For mainnet, the code + already contains default addresses, but you can override them using + environment variables. + + +```bash +# Stellar FeeForwarder Contract Addresses +# STELLAR_MAINNET_FEE_FORWARDER_ADDRESS= +STELLAR_TESTNET_FEE_FORWARDER_ADDRESS= + +# Stellar Soroswap Router Contract Addresses +STELLAR_MAINNET_SOROSWAP_ROUTER_ADDRESS= +STELLAR_TESTNET_SOROSWAP_ROUTER_ADDRESS= + +# Stellar Soroswap Factory Contract Addresses +STELLAR_MAINNET_SOROSWAP_FACTORY_ADDRESS= +STELLAR_TESTNET_SOROSWAP_FACTORY_ADDRESS= + +# Stellar Soroswap Native Wrapper (XLM) Contract Addresses +STELLAR_MAINNET_SOROSWAP_NATIVE_WRAPPER_ADDRESS= +STELLAR_TESTNET_SOROSWAP_NATIVE_WRAPPER_ADDRESS= +``` + +### Using the API + +The Soroban gas abstraction flow uses the same endpoints as classic sponsored transactions but with key differences in the request/response payloads and an additional authorization signing step. + +#### Step 1: Get Fee Quote + +Estimate the fee for a Soroban contract invocation in your preferred token. + +**Endpoint:** `POST /api/v1/relayers/{relayer_id}/transactions/sponsored/quote` + +**Request Body:** + +The `transaction_xdr` should contain the Soroban `InvokeHostFunction` operation, and the `fee_token` should be a Soroban token contract address (`C...` format). + +```json +{ + "transaction_xdr": "AAAAAgAAAAD...", + "fee_token": "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC" +} +``` + +**Response:** + +The response includes `max_fee_in_token` and `max_fee_in_token_ui` fields that account for slippage buffer. These represent the maximum amount the user authorizes. + +```json +{ + "success": true, + "data": { + "fee_in_token_ui": "1.5", + "fee_in_token": "15000000", + "conversion_rate": "0.15", + "max_fee_in_token": "15750000", + "max_fee_in_token_ui": "1.575" + } +} +``` + +#### Step 2: Build Sponsored Transaction + +Prepare a Soroban transaction that wraps your contract call with the FeeForwarder contract. + +**Endpoint:** `POST /api/v1/relayers/{relayer_id}/transactions/sponsored/build` + +**Request Body:** + +```json +{ + "transaction_xdr": "AAAAAgAAAAD...", + "fee_token": "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC" +} +``` + +**Response:** + +The response includes a `user_auth_entry` field containing the Soroban authorization entry that the user must sign. This authorization entry authorizes the FeeForwarder contract to collect fees in the specified token on the user's behalf. + +```json +{ + "success": true, + "data": { + "transaction": "AAAAAgAAAAD...", + "fee_in_token_ui": "1.5", + "fee_in_token": "15000000", + "fee_in_stroops": "100000", + "fee_token": "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC", + "valid_until": "2024-01-01T00:01:00Z", + "user_auth_entry": "AAAABgAAAAA...", + "max_fee_in_token": "15750000", + "max_fee_in_token_ui": "1.575" + } +} +``` + +#### Step 3: Sign the Authorization Entry + +Unlike classic sponsored transactions where the user signs the full transaction XDR, for Soroban gas abstraction the user must sign the **`user_auth_entry`** returned from the Build response. This is a `SorobanAuthorizationEntry` XDR that authorizes: + +1. The FeeForwarder contract to transfer fee tokens from the user to the relayer +2. The user's original contract invocation (wrapped inside the FeeForwarder call) + +The signing method depends on your setup: + +- **Programmatic signing**: Use the Stellar SDK to sign the authorization entry with the user's private key +- **Wallet signing**: Use a Soroban-compatible wallet that supports signing authorization entries + +For complete code examples of signing authorization entries, see the [Relayer SDK examples](https://github.com/OpenZeppelin/openzeppelin-relayer-sdk/tree/main/examples/relayers/stellar/src/soroban). + +#### Step 4: Submit to Relayer + +Submit the transaction XDR from the Build response along with the user's **signed authorization entry**. The relayer will: + +1. Inject the user's signed authorization entry into the transaction +2. Add the relayer's own authorization entry (as the transaction source account) +3. Re-simulate the transaction with the signed auth entries for accurate resource calculation +4. Apply a resource buffer (15% safety margin) to prevent execution failures +5. Sign the transaction with the relayer's key +6. Submit it to the Stellar network + +**Endpoint:** `POST /api/v1/relayers/{relayer_id}/transactions` + +**Request Body:** + +```json +{ + "transaction_xdr": "AAAAAgAAAAD...", + "network": "testnet", + "signed_auth_entry": "AAAABgAAAAA..." +} +``` + +> **Note:** The `signed_auth_entry` field is used instead of `fee_bump` for Soroban gas abstraction. These two fields are mutually exclusive. + +**Response:** + +```json +{ + "success": true, + "data": { + "id": "tx-123456", + "status": "pending", + "network_data": { + "signature": "abc123...", + "transaction": "AAAAAgAAAAD..." + } + } +} +``` + +### Fee Token Format + +For Soroban gas abstraction, fee tokens are specified using Soroban contract addresses: + +- **Soroban token**: `"C..."` (e.g., `"CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC"`) + +This differs from classic sponsored transactions which use the `"CODE:ISSUER"` format. The relayer automatically detects whether to use the classic or Soroban flow based on the `fee_token` format and the presence of `InvokeHostFunction` operations in the transaction XDR. + +### How the FeeForwarder Contract Works + +The FeeForwarder contract acts as a proxy that: + +1. **Collects the fee**: Transfers the fee token amount from the user to the relayer +2. **Executes the user's call**: Invokes the target contract function on behalf of the user +3. **Manages authorization**: Bundles fee approval and contract invocation into a single authorization entry + +The contract's `forward()` function takes these parameters: + +- `fee_token` - Soroban token contract address for fee payment +- `fee_amount` - Actual fee to charge +- `max_fee_amount` - Maximum fee authorized by the user (includes slippage buffer) +- `expiration_ledger` - Ledger number when the authorization expires +- `target_contract` - The contract the user wants to invoke +- `target_fn` - The function name to call +- `target_args` - The function arguments +- `user` - The user's account address +- `relayer` - The relayer's account address (fee recipient) + +### Best Practices for Soroban Gas Abstraction + +1. **Use the Quote endpoint first**: Always get a fee estimate before building to show users the expected cost, including the max fee with slippage +2. **Handle authorization expiration**: Authorization entries expire at a specific ledger. Rebuild if the authorization has expired +3. **Validate token contract addresses**: Ensure fee tokens use valid Soroban contract addresses (`C...` format, 56 characters) +4. **Monitor resource fees**: Soroban transactions have dynamic resource fees based on CPU instructions, storage reads/writes, and ledger entry access. The relayer applies a 15% buffer, but complex contract calls may need higher limits +5. **Set appropriate max fees**: Configure `max_allowed_fee` per token to prevent excessive fees during network congestion +6. **Configure the FeeForwarder address**: Ensure the correct FeeForwarder contract address is set via environment variables for each network + ## Additional Resources - [Stellar Documentation](https://developers.stellar.org/) From bb64e53f5c0f5dc4a86a619d31406b69e19e018d Mon Sep 17 00:00:00 2001 From: Nicolas Molina Date: Mon, 9 Feb 2026 12:13:45 -0300 Subject: [PATCH 22/22] docs: Improvements --- .../stellar-sponsored-transactions-guide.mdx | 26 ++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/docs/guides/stellar-sponsored-transactions-guide.mdx b/docs/guides/stellar-sponsored-transactions-guide.mdx index 94d9d2bc5..b8327640d 100644 --- a/docs/guides/stellar-sponsored-transactions-guide.mdx +++ b/docs/guides/stellar-sponsored-transactions-guide.mdx @@ -522,8 +522,32 @@ The signing method depends on your setup: - **Programmatic signing**: Use the Stellar SDK to sign the authorization entry with the user's private key - **Wallet signing**: Use a Soroban-compatible wallet that supports signing authorization entries +- **CLI helper tool**: A ready-to-use signing tool is provided in the repository at `helpers/sign_soroban_auth_entry.rs` -For complete code examples of signing authorization entries, see the [Relayer SDK examples](https://github.com/OpenZeppelin/openzeppelin-relayer-sdk/tree/main/examples/relayers/stellar/src/soroban). +##### Using the CLI Helper Tool + +The repository includes a helper tool that signs Soroban authorization entries directly from the command line. This is useful for testing and development: + +```bash +cargo run --example sign_soroban_auth_entry -- \ + --secret-key "S..." \ + --auth-entry "" \ + --network testnet +``` + +To generate the full JSON payload ready for the `/transactions` endpoint, include the `--transaction-xdr` flag: + +```bash +cargo run --example sign_soroban_auth_entry -- \ + --secret-key "S..." \ + --auth-entry "" \ + --network testnet \ + --transaction-xdr "" +``` + +This outputs a JSON object with `network`, `transaction_xdr`, and `signed_auth_entry` fields that can be sent directly to the Submit endpoint. + +For complete code examples of signing authorization entries and testing the full gas abstraction flow (quote, build, sign, and send), see the [Relayer SDK examples](https://github.com/OpenZeppelin/openzeppelin-relayer-sdk/tree/main/examples/relayers/stellar/src/soroban). The example demonstrates the end-to-end flow including fee estimation, transaction building, authorization signing, and submission. #### Step 4: Submit to Relayer