= 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 +163,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 +255,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 +265,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 +284,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 +324,36 @@ 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(),
+ // Classic transactions have deterministic fees, no slippage buffer needed
+ max_fee_in_token: None,
+ max_fee_in_token_ui: None,
+ },
+ ))
}
- 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,190 +470,1830 @@ 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,
+ // Classic transactions have deterministic fees, no slippage buffer needed
+ max_fee_in_token: None,
+ max_fee_in_token_ui: None,
},
))
}
}
-/// Add payment operation to envelope using a pre-computed fee quote
-///
-/// This function adds a fee payment operation to the transaction envelope using
-/// a pre-computed FeeQuote. This avoids duplicate DEX calls and ensures the
-/// validated fee quote matches the fee amount in the payment operation.
-///
-/// Note: Time bounds should be set separately just before returning the transaction
-/// to give the user maximum time to review and submit.
-///
-/// # Arguments
-/// * `envelope` - The transaction envelope to add the payment operation to
-/// * `fee_quote` - Pre-computed fee quote containing the token amount to charge
-/// * `fee_token` - Asset identifier for the fee token
-/// * `relayer_address` - Address of the relayer receiving the fee payment
-///
-/// # Returns
-/// The updated envelope with the payment operation added (if not Soroban)
-fn add_payment_operation_to_envelope(
- mut envelope: TransactionEnvelope,
- fee_quote: &FeeQuote,
- fee_token: &str,
- relayer_address: &str,
-) -> Result {
- // Convert fee amount to i64 for payment operation
- let fee_amount = i64::try_from(fee_quote.fee_in_token).map_err(|_| {
- RelayerError::Internal(
- "Fee amount too large for payment operation (exceeds i64::MAX)".to_string(),
- )
- })?;
-
- let is_soroban = xdr_needs_simulation(&envelope).unwrap_or(false);
- // For Soroban we don't add the fee payment operation because of Soroban limitation to allow just single operation in the transaction
- if !is_soroban {
- // Add fee payment operation to envelope
- add_fee_payment_operation(&mut envelope, fee_token, fee_amount, relayer_address)?;
- }
-
- Ok(envelope)
-}
+// ============================================================================
+// Soroban Gas Abstraction Handlers (FeeForwarder Flow with XDR-based detection)
+// ============================================================================
-/// Build a transaction envelope from either XDR or operations
-///
-/// This helper function is used by both quote and build methods to construct
-/// a transaction envelope from either a pre-built XDR transaction or from
-/// operations with a source account.
-///
-/// When building from operations, this function fetches the user's current
-/// sequence number from the network to ensure the transaction can be properly
-/// signed and submitted by the user.
-async fn build_envelope_from_request(
- transaction_xdr: Option<&String>,
- operations: Option<&Vec>,
- source_account: Option<&String>,
- network_passphrase: &str,
- provider: &P,
-) -> Result
+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,
{
- if let Some(xdr) = transaction_xdr {
- parse_transaction_xdr(xdr, false)
- .map_err(|e| RelayerError::Internal(format!("Failed to parse XDR: {e}")))
- } else if let Some(ops) = operations {
- // Build envelope from operations
- let source_account = source_account.ok_or_else(|| {
+ /// 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()))?;
+
+ // 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() {
+ 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(
- "source_account is required when providing operations".to_string(),
+ "Soroban gas abstraction requires transaction_xdr".to_string(),
)
})?;
- // Create StellarTransactionData from operations
- // Fetch the user's current sequence number from the network
- // This is required because the user will sign the transaction with their account
- let account_entry = provider.get_account(source_account).await.map_err(|e| {
- warn!(
- source_account = %source_account,
- error = %e,
- "get_account failed in build_envelope_from_request (called before transaction creation)"
- );
- // Note: We don't have relayer_id here, so we can't track the metric with relayer_id
- // This is called during gas abstraction operations before transaction creation
- RelayerError::Internal(format!(
- "Failed to fetch account sequence number for {source_account}: {e}",
- ))
+ 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 the next sequence number (current + 1)
- let next_sequence = account_entry.seq_num.0 + 1;
+ // 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 stellar_data = StellarTransactionData {
- source_account: source_account.clone(),
- fee: None,
- sequence_number: Some(next_sequence as i64),
- memo: None,
- valid_until: None,
- network_passphrase: network_passphrase.to_string(),
- signatures: vec![],
- hash: None,
- simulation_transaction_data: None,
- transaction_input: TransactionInput::Operations(ops.clone()),
- signed_envelope_xdr: None,
- transaction_result_xdr: None,
+ 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(),
};
- // Build unsigned envelope from operations
- stellar_data.build_unsigned_envelope().map_err(|e| {
- RelayerError::Internal(format!("Failed to build envelope from operations: {e}"))
- })
- } else {
- Err(RelayerError::ValidationError(
- "Must provide either transaction_xdr or operations in the request".to_string(),
- ))
+ // 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![],
+ )
+ .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()))?;
+
+ // 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))
+ }
+
+ /// 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: 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() {
+ 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(|| {
+ 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(),
+ };
+
+ // 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![], // Empty auth entries for simulation
+ )
+ .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);
+
+ // Build the user authorization entry for the user to sign
+ 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(&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}")))?;
+
+ // 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,
+ vec![user_auth_entry, relayer_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,
+ )?;
+
+ // 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
+ .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 * STELLAR_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()
+ );
+
+ // 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,
+ 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),
+ 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))
}
}
-/// Add a fee payment operation to the transaction envelope
-fn add_fee_payment_operation(
+/// 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,
- fee_token: &str,
- fee_amount: i64,
- relayer_address: &str,
+ sim_response: &soroban_rs::stellar_rpc_client::SimulateTransactionResponse,
+ operations_count: u64,
) -> Result<(), RelayerError> {
- let payment_op_spec = create_fee_payment_operation(relayer_address, fee_token, fee_amount)
- .map_err(crate::models::RelayerError::from)?;
+ use soroban_rs::xdr::SorobanTransactionData;
- // Convert OperationSpec to XDR Operation
- let payment_op = Operation::try_from(payment_op_spec)
- .map_err(|e| RelayerError::Internal(format!("Failed to convert payment operation: {e}")))?;
+ let total_fee = calculate_total_soroban_fee(sim_response, operations_count)?;
- // Add payment operation to transaction
- add_operation_to_envelope(envelope, payment_op).map_err(crate::models::RelayerError::from)?;
+ 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(())
}
-#[cfg(test)]
-mod tests {
- use super::*;
- use crate::domain::transaction::stellar::utils::parse_account_id;
- use crate::services::stellar_dex::AssetType;
- use crate::{
- config::{NetworkConfigCommon, StellarNetworkConfig},
- jobs::MockJobProducerTrait,
- models::{
- transaction::stellar::OperationSpec, AssetSpec, NetworkConfigData, NetworkRepoModel,
- NetworkType, RelayerNetworkPolicy, RelayerRepoModel, RelayerStellarPolicy, RpcConfig,
- SponsoredTransactionBuildRequest, SponsoredTransactionQuoteRequest,
- },
- repositories::{
- InMemoryNetworkRepository, MockRelayerRepository, MockTransactionRepository,
- },
- services::{
- provider::MockStellarProviderTrait, signer::MockStellarSignTrait,
- stellar_dex::MockStellarDexServiceTrait, MockTransactionCounterServiceTrait,
- },
- };
- use mockall::predicate::*;
- use soroban_rs::stellar_rpc_client::GetLedgerEntriesResponse;
- use soroban_rs::stellar_rpc_client::LedgerEntryResult;
- use soroban_rs::xdr::{
- AccountEntry, AccountEntryExt, AccountId, AlphaNum4, AssetCode4, LedgerEntry,
- LedgerEntryData, LedgerEntryExt, LedgerKey, Limits, MuxedAccount, Operation, OperationBody,
- PaymentOp, Preconditions, PublicKey, SequenceNumber, String32, Thresholds, Transaction,
- TransactionEnvelope, TransactionExt, TransactionV1Envelope, TrustLineEntry,
- TrustLineEntryExt, Uint256, VecM, WriteXdr,
- };
- use std::future::ready;
- use std::sync::Arc;
- use stellar_strkey::ed25519::PublicKey as Ed25519PublicKey;
+/// Add payment operation to envelope using a pre-computed fee quote
+///
+/// This function adds a fee payment operation to the transaction envelope using
+/// a pre-computed FeeQuote. This avoids duplicate DEX calls and ensures the
+/// validated fee quote matches the fee amount in the payment operation.
+///
+/// Note: Time bounds should be set separately just before returning the transaction
+/// to give the user maximum time to review and submit.
+///
+/// # Arguments
+/// * `envelope` - The transaction envelope to add the payment operation to
+/// * `fee_quote` - Pre-computed fee quote containing the token amount to charge
+/// * `fee_token` - Asset identifier for the fee token
+/// * `relayer_address` - Address of the relayer receiving the fee payment
+///
+/// # Returns
+/// The updated envelope with the payment operation added (if not Soroban)
+fn add_payment_operation_to_envelope(
+ mut envelope: TransactionEnvelope,
+ fee_quote: &FeeQuote,
+ fee_token: &str,
+ relayer_address: &str,
+) -> Result {
+ // Convert fee amount to i64 for payment operation
+ let fee_amount = i64::try_from(fee_quote.fee_in_token).map_err(|_| {
+ RelayerError::Internal(
+ "Fee amount too large for payment operation (exceeds i64::MAX)".to_string(),
+ )
+ })?;
+
+ let is_soroban = xdr_needs_simulation(&envelope).unwrap_or(false);
+ // For Soroban we don't add the fee payment operation because of Soroban limitation to allow just single operation in the transaction
+ if !is_soroban {
+ // Add fee payment operation to envelope
+ add_fee_payment_operation(&mut envelope, fee_token, fee_amount, relayer_address)?;
+ }
+
+ Ok(envelope)
+}
+
+/// Build a transaction envelope from either XDR or operations
+///
+/// This helper function is used by both quote and build methods to construct
+/// a transaction envelope from either a pre-built XDR transaction or from
+/// operations with a source account.
+///
+/// When building from operations, this function fetches the user's current
+/// sequence number from the network to ensure the transaction can be properly
+/// signed and submitted by the user.
+async fn build_envelope_from_request(
+ transaction_xdr: Option<&String>,
+ operations: Option<&Vec>,
+ source_account: Option<&String>,
+ network_passphrase: &str,
+ provider: &P,
+) -> Result
+where
+ P: StellarProviderTrait + Send + Sync,
+{
+ if let Some(xdr) = transaction_xdr {
+ parse_transaction_xdr(xdr, false)
+ .map_err(|e| RelayerError::Internal(format!("Failed to parse XDR: {e}")))
+ } else if let Some(ops) = operations {
+ // Build envelope from operations
+ let source_account = source_account.ok_or_else(|| {
+ RelayerError::ValidationError(
+ "source_account is required when providing operations".to_string(),
+ )
+ })?;
+
+ // Create StellarTransactionData from operations
+ // Fetch the user's current sequence number from the network
+ // This is required because the user will sign the transaction with their account
+ let account_entry = provider.get_account(source_account).await.map_err(|e| {
+ warn!(
+ source_account = %source_account,
+ error = %e,
+ "get_account failed in build_envelope_from_request (called before transaction creation)"
+ );
+ // Note: We don't have relayer_id here, so we can't track the metric with relayer_id
+ // This is called during gas abstraction operations before transaction creation
+ RelayerError::Internal(format!(
+ "Failed to fetch account sequence number for {source_account}: {e}",
+ ))
+ })?;
+
+ // Use the next sequence number (current + 1)
+ let next_sequence = account_entry.seq_num.0 + 1;
+
+ let stellar_data = StellarTransactionData {
+ source_account: source_account.clone(),
+ fee: None,
+ sequence_number: Some(next_sequence as i64),
+ memo: None,
+ valid_until: None,
+ network_passphrase: network_passphrase.to_string(),
+ signatures: vec![],
+ hash: None,
+ simulation_transaction_data: None,
+ transaction_input: TransactionInput::Operations(ops.clone()),
+ signed_envelope_xdr: None,
+ transaction_result_xdr: None,
+ };
+
+ // Build unsigned envelope from operations
+ stellar_data.build_unsigned_envelope().map_err(|e| {
+ RelayerError::Internal(format!("Failed to build envelope from operations: {e}"))
+ })
+ } else {
+ Err(RelayerError::ValidationError(
+ "Must provide either transaction_xdr or operations in the request".to_string(),
+ ))
+ }
+}
+
+/// Add a fee payment operation to the transaction envelope
+fn add_fee_payment_operation(
+ envelope: &mut TransactionEnvelope,
+ fee_token: &str,
+ fee_amount: i64,
+ relayer_address: &str,
+) -> Result<(), RelayerError> {
+ let payment_op_spec = create_fee_payment_operation(relayer_address, fee_token, fee_amount)
+ .map_err(crate::models::RelayerError::from)?;
+
+ // Convert OperationSpec to XDR Operation
+ let payment_op = Operation::try_from(payment_op_spec)
+ .map_err(|e| RelayerError::Internal(format!("Failed to convert payment operation: {e}")))?;
+
+ // Add payment operation to transaction
+ add_operation_to_envelope(envelope, payment_op).map_err(crate::models::RelayerError::from)?;
+
+ Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::domain::transaction::stellar::utils::parse_account_id;
+ use crate::services::stellar_dex::AssetType;
+ use crate::{
+ config::{NetworkConfigCommon, StellarNetworkConfig},
+ jobs::MockJobProducerTrait,
+ models::{
+ transaction::stellar::OperationSpec, AssetSpec, NetworkConfigData, NetworkRepoModel,
+ NetworkType, RelayerNetworkPolicy, RelayerRepoModel, RelayerStellarPolicy, RpcConfig,
+ SponsoredTransactionBuildRequest, SponsoredTransactionQuoteRequest,
+ },
+ repositories::{
+ InMemoryNetworkRepository, MockRelayerRepository, MockTransactionRepository,
+ },
+ services::{
+ provider::MockStellarProviderTrait, signer::MockStellarSignTrait,
+ stellar_dex::MockStellarDexServiceTrait, MockTransactionCounterServiceTrait,
+ },
+ };
+ 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::{
+ AccountEntry, AccountEntryExt, AccountId, AlphaNum4, AssetCode4, LedgerEntry,
+ LedgerEntryData, LedgerEntryExt, LedgerKey, Limits, MuxedAccount, Operation, OperationBody,
+ PaymentOp, Preconditions, PublicKey, SequenceNumber, String32, Thresholds, Transaction,
+ TransactionEnvelope, TransactionExt, TransactionV1Envelope, TrustLineEntry,
+ TrustLineEntryExt, Uint256, VecM, WriteXdr,
+ };
+ use std::future::ready;
+ use std::sync::Arc;
+ use stellar_strkey::ed25519::PublicKey as Ed25519PublicKey;
+
+ const TEST_PK: &str = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF";
+ const TEST_NETWORK_PASSPHRASE: &str = "Test SDF Network ; September 2015";
+ const USDC_ASSET: &str = "USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN";
+
+ /// Helper function to create a test transaction XDR
+ fn create_test_transaction_xdr() -> String {
+ // Use a different account than TEST_PK (relayer address) to avoid validation error
+ 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 operation = Operation {
+ source_account: None,
+ body: OperationBody::Payment(payment_op),
+ };
+
+ let operations: VecM = vec![operation].try_into().unwrap();
+
+ let tx = Transaction {
+ source_account: MuxedAccount::Ed25519(Uint256(source_pk.0)),
+ fee: 100,
+ seq_num: SequenceNumber(2), // Must be > account sequence (1)
+ cond: Preconditions::None,
+ memo: soroban_rs::xdr::Memo::None,
+ operations,
+ ext: TransactionExt::V0,
+ };
+
+ let envelope = TransactionV1Envelope {
+ tx,
+ signatures: vec![].try_into().unwrap(),
+ };
+
+ let tx_envelope = TransactionEnvelope::Tx(envelope);
+ tx_envelope.to_xdr_base64(Limits::none()).unwrap()
+ }
+
+ /// Helper function to create a test relayer with user fee payment strategy
+ fn create_test_relayer_with_user_fee_strategy() -> RelayerRepoModel {
+ 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(),
+ 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()
+ }
+ }
+
+ /// Helper function to create a mock DEX service
+ fn create_mock_dex_service() -> Arc {
+ let mut mock_dex = MockStellarDexServiceTrait::new();
+ mock_dex
+ .expect_supported_asset_types()
+ .returning(|| std::collections::HashSet::from([AssetType::Native, AssetType::Classic]));
+ Arc::new(mock_dex)
+ }
+
+ /// Helper function to create a test network
+ fn create_test_network() -> NetworkRepoModel {
+ NetworkRepoModel {
+ id: "stellar:testnet".to_string(),
+ name: "testnet".to_string(),
+ network_type: NetworkType::Stellar,
+ config: NetworkConfigData::Stellar(StellarNetworkConfig {
+ common: NetworkConfigCommon {
+ network: "testnet".to_string(),
+ from: None,
+ rpc_urls: Some(vec![RpcConfig::new(
+ "https://horizon-testnet.stellar.org".to_string(),
+ )]),
+ explorer_urls: None,
+ average_blocktime_ms: Some(5000),
+ is_testnet: Some(true),
+ tags: None,
+ },
+ passphrase: Some(TEST_NETWORK_PASSPHRASE.to_string()),
+ horizon_url: Some("https://horizon-testnet.stellar.org".to_string()),
+ }),
+ }
+ }
+
+ /// 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,
+ provider: MockStellarProviderTrait,
+ dex_service: Arc,
+ ) -> crate::domain::relayer::stellar::StellarRelayer<
+ MockStellarProviderTrait,
+ MockRelayerRepository,
+ InMemoryNetworkRepository,
+ MockTransactionRepository,
+ MockJobProducerTrait,
+ MockTransactionCounterServiceTrait,
+ MockStellarSignTrait,
+ MockStellarDexServiceTrait,
+ > {
+ let network_repository = Arc::new(InMemoryNetworkRepository::new());
+ let test_network = create_test_network();
+ network_repository.create(test_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()
+ }
+
+ /// 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();
+ let mut provider = MockStellarProviderTrait::new();
+
+ // Mock account for validation
+ provider.expect_get_account().returning(|_| {
+ Box::pin(ready(Ok(AccountEntry {
+ account_id: AccountId(PublicKey::PublicKeyTypeEd25519(Uint256([0; 32]))),
+ balance: 1000000000,
+ seq_num: SequenceNumber(1),
+ num_sub_entries: 0,
+ inflation_dest: None,
+ flags: 0,
+ home_domain: String32::default(),
+ thresholds: Thresholds([0; 4]),
+ signers: VecM::default(),
+ ext: AccountEntryExt::V0,
+ })))
+ });
+
+ // Mock get_ledger_entries for token balance validation
+ // This mock extracts the account ID from the ledger key and returns a trustline with sufficient balance
+ provider.expect_get_ledger_entries().returning(|keys| {
+ // Extract account ID from the first ledger key (should be a Trustline key)
+ let account_id = if let Some(LedgerKey::Trustline(trustline_key)) = keys.first() {
+ trustline_key.account_id.clone()
+ } else {
+ // Fallback: try to parse TEST_PK
+ parse_account_id(TEST_PK).unwrap_or_else(|_| {
+ AccountId(PublicKey::PublicKeyTypeEd25519(Uint256([0; 32])))
+ })
+ };
+
+ let issuer_id =
+ parse_account_id("GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN")
+ .unwrap_or_else(|_| {
+ AccountId(PublicKey::PublicKeyTypeEd25519(Uint256([0; 32])))
+ });
+
+ // Create a trustline entry with sufficient balance
+ let trustline_entry = TrustLineEntry {
+ account_id,
+ asset: soroban_rs::xdr::TrustLineAsset::CreditAlphanum4(AlphaNum4 {
+ asset_code: AssetCode4(*b"USDC"),
+ issuer: issuer_id,
+ }),
+ balance: 10_000_000i64,
+ limit: i64::MAX,
+ flags: 0,
+ ext: TrustLineEntryExt::V0,
+ };
+
+ let ledger_entry = LedgerEntry {
+ last_modified_ledger_seq: 0,
+ data: LedgerEntryData::Trustline(trustline_entry),
+ ext: LedgerEntryExt::V0,
+ };
+
+ // Encode LedgerEntryData to XDR base64 (not the full LedgerEntry)
+ let xdr = ledger_entry
+ .data
+ .to_xdr_base64(soroban_rs::xdr::Limits::none())
+ .expect("Failed to encode trustline entry data to XDR");
+
+ Box::pin(ready(Ok(GetLedgerEntriesResponse {
+ entries: Some(vec![LedgerEntryResult {
+ key: "test_key".to_string(),
+ xdr,
+ last_modified_ledger: 0u32,
+ live_until_ledger_seq_ledger_seq: None,
+ }]),
+ latest_ledger: 0,
+ })))
+ });
+
+ let mut dex_service = MockStellarDexServiceTrait::new();
+ dex_service
+ .expect_supported_asset_types()
+ .returning(|| std::collections::HashSet::from([AssetType::Native, AssetType::Classic]));
+
+ // Mock get_xlm_to_token_quote for fee conversion (XLM -> token)
+ 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: USDC_ASSET.to_string(),
+ in_amount: 100000,
+ 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_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(),
+ },
+ );
+
+ let result = relayer.quote_sponsored_transaction(request).await;
+ if let Err(e) = &result {
+ eprintln!("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");
+ }
+ }
+
+ #[tokio::test]
+ async fn test_quote_sponsored_transaction_with_operations() {
+ let relayer_model = create_test_relayer_with_user_fee_strategy();
+ let mut provider = MockStellarProviderTrait::new();
+
+ provider.expect_get_account().returning(|_| {
+ Box::pin(ready(Ok(AccountEntry {
+ account_id: AccountId(PublicKey::PublicKeyTypeEd25519(Uint256([0; 32]))),
+ balance: 1000000000,
+ seq_num: SequenceNumber(-1),
+ num_sub_entries: 0,
+ inflation_dest: None,
+ flags: 0,
+ home_domain: String32::default(),
+ thresholds: Thresholds([0; 4]),
+ signers: VecM::default(),
+ ext: AccountEntryExt::V0,
+ })))
+ });
+
+ // Mock get_ledger_entries for token balance validation
+ // This mock extracts the account ID from the ledger key and returns a trustline with sufficient balance
+ provider.expect_get_ledger_entries().returning(|keys| {
+ // Extract account ID from the first ledger key (should be a Trustline key)
+ let account_id = if let Some(LedgerKey::Trustline(trustline_key)) = keys.first() {
+ trustline_key.account_id.clone()
+ } else {
+ // Fallback: use the source account from the test
+ parse_account_id("GCZ54QGQCUZ6U5WJF4AG5JEZCUMYTS2F6JRLUS76XF2PQMEJ2E3JISI2")
+ .unwrap_or_else(|_| {
+ AccountId(PublicKey::PublicKeyTypeEd25519(Uint256([0; 32])))
+ })
+ };
+
+ let issuer_id =
+ parse_account_id("GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN")
+ .unwrap_or_else(|_| {
+ AccountId(PublicKey::PublicKeyTypeEd25519(Uint256([0; 32])))
+ });
+
+ // Create a trustline entry with sufficient balance
+ let trustline_entry = TrustLineEntry {
+ account_id,
+ asset: soroban_rs::xdr::TrustLineAsset::CreditAlphanum4(AlphaNum4 {
+ asset_code: AssetCode4(*b"USDC"),
+ issuer: issuer_id,
+ }),
+ balance: 10_000_000i64,
+ limit: i64::MAX,
+ flags: 0,
+ ext: TrustLineEntryExt::V0,
+ };
+
+ let ledger_entry = LedgerEntry {
+ last_modified_ledger_seq: 0,
+ data: LedgerEntryData::Trustline(trustline_entry),
+ ext: LedgerEntryExt::V0,
+ };
+
+ // Encode LedgerEntryData to XDR base64 (not the full LedgerEntry)
+ let xdr = ledger_entry
+ .data
+ .to_xdr_base64(soroban_rs::xdr::Limits::none())
+ .expect("Failed to encode trustline entry data to XDR");
+
+ Box::pin(ready(Ok(
+ soroban_rs::stellar_rpc_client::GetLedgerEntriesResponse {
+ entries: Some(vec![LedgerEntryResult {
+ key: "test_key".to_string(),
+ xdr,
+ last_modified_ledger: 0u32,
+ live_until_ledger_seq_ledger_seq: None,
+ }]),
+ latest_ledger: 0,
+ },
+ )))
+ });
+
+ let mut dex_service = MockStellarDexServiceTrait::new();
+ dex_service
+ .expect_supported_asset_types()
+ .returning(|| std::collections::HashSet::from([AssetType::Native, AssetType::Classic]));
+
+ // Mock get_xlm_to_token_quote for fee conversion (XLM -> token)
+ 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: USDC_ASSET.to_string(),
+ in_amount: 100000,
+ 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 operations = vec![OperationSpec::Payment {
+ destination: TEST_PK.to_string(),
+ amount: 1000000,
+ asset: AssetSpec::Native,
+ }];
+
+ let request = SponsoredTransactionQuoteRequest::Stellar(
+ crate::models::StellarFeeEstimateRequestParams {
+ transaction_xdr: None,
+ operations: Some(operations),
+ source_account: Some(
+ "GCZ54QGQCUZ6U5WJF4AG5JEZCUMYTS2F6JRLUS76XF2PQMEJ2E3JISI2".to_string(),
+ ),
+ fee_token: USDC_ASSET.to_string(),
+ },
+ );
+
+ let result = relayer.quote_sponsored_transaction(request).await;
+ if let Err(e) = &result {
+ eprintln!("Quote error: {:?}", e);
+ }
+ assert!(result.is_ok());
+ }
+
+ #[tokio::test]
+ async fn test_quote_sponsored_transaction_invalid_token() {
+ let relayer_model = create_test_relayer_with_user_fee_strategy();
+ 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 = SponsoredTransactionQuoteRequest::Stellar(
+ crate::models::StellarFeeEstimateRequestParams {
+ transaction_xdr: Some(transaction_xdr),
+ operations: None,
+ source_account: None,
+ fee_token: "INVALID:TOKEN".to_string(),
+ },
+ );
+
+ let result = relayer.quote_sponsored_transaction(request).await;
+ assert!(result.is_err());
+ assert!(matches!(
+ result.unwrap_err(),
+ RelayerError::ValidationError(_)
+ ));
+ }
+
+ #[tokio::test]
+ async fn test_quote_sponsored_transaction_missing_xdr_and_operations() {
+ let relayer_model = create_test_relayer_with_user_fee_strategy();
+ let provider = MockStellarProviderTrait::new();
+ let dex_service = create_mock_dex_service();
+ let relayer = create_test_relayer_instance(relayer_model, provider, dex_service).await;
+
+ let request = SponsoredTransactionQuoteRequest::Stellar(
+ crate::models::StellarFeeEstimateRequestParams {
+ transaction_xdr: None,
+ operations: None,
+ source_account: None,
+ fee_token: USDC_ASSET.to_string(),
+ },
+ );
+
+ let result = relayer.quote_sponsored_transaction(request).await;
+ assert!(result.is_err());
+ assert!(matches!(
+ result.unwrap_err(),
+ RelayerError::ValidationError(_)
+ ));
+ }
+
+ #[tokio::test]
+ async fn test_build_sponsored_transaction_with_xdr() {
+ let relayer_model = create_test_relayer_with_user_fee_strategy();
+ let mut provider = MockStellarProviderTrait::new();
+
+ provider.expect_get_account().returning(|_| {
+ Box::pin(ready(Ok(AccountEntry {
+ account_id: AccountId(PublicKey::PublicKeyTypeEd25519(Uint256([0; 32]))),
+ balance: 1000000000,
+ seq_num: SequenceNumber(-1),
+ num_sub_entries: 0,
+ inflation_dest: None,
+ flags: 0,
+ home_domain: String32::default(),
+ thresholds: Thresholds([0; 4]),
+ signers: VecM::default(),
+ ext: AccountEntryExt::V0,
+ })))
+ });
+
+ // Mock get_ledger_entries for token balance validation
+ // This mock extracts the account ID from the ledger key and returns a trustline with sufficient balance
+ provider.expect_get_ledger_entries().returning(|keys| {
+ // Extract account ID from the first ledger key (should be a Trustline key)
+ let account_id = if let Some(LedgerKey::Trustline(trustline_key)) = keys.first() {
+ trustline_key.account_id.clone()
+ } else {
+ // Fallback: try to parse TEST_PK
+ parse_account_id(TEST_PK).unwrap_or_else(|_| {
+ AccountId(PublicKey::PublicKeyTypeEd25519(Uint256([0; 32])))
+ })
+ };
+
+ let issuer_id =
+ parse_account_id("GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN")
+ .unwrap_or_else(|_| {
+ AccountId(PublicKey::PublicKeyTypeEd25519(Uint256([0; 32])))
+ });
+
+ // Create a trustline entry with sufficient balance (10 USDC = 10000000 with 6 decimals)
+ let trustline_entry = TrustLineEntry {
+ account_id,
+ asset: soroban_rs::xdr::TrustLineAsset::CreditAlphanum4(AlphaNum4 {
+ asset_code: AssetCode4(*b"USDC"),
+ issuer: issuer_id,
+ }),
+ balance: 10_000_000i64, // 10 USDC (with 6 decimals) - sufficient for fee
+ limit: i64::MAX,
+ flags: 0,
+ ext: TrustLineEntryExt::V0, // V0 has no liabilities
+ };
+
+ let ledger_entry = LedgerEntry {
+ last_modified_ledger_seq: 0,
+ data: LedgerEntryData::Trustline(trustline_entry),
+ ext: LedgerEntryExt::V0,
+ };
+
+ // Encode LedgerEntryData to XDR base64 (not the full LedgerEntry)
+ let xdr = ledger_entry
+ .data
+ .to_xdr_base64(soroban_rs::xdr::Limits::none())
+ .expect("Failed to encode trustline entry data to XDR");
+
+ Box::pin(ready(Ok(
+ soroban_rs::stellar_rpc_client::GetLedgerEntriesResponse {
+ entries: Some(vec![LedgerEntryResult {
+ key: "test_key".to_string(),
+ xdr,
+ last_modified_ledger: 0u32,
+ live_until_ledger_seq_ledger_seq: None,
+ }]),
+ latest_ledger: 0,
+ },
+ )))
+ });
+
+ let mut dex_service = MockStellarDexServiceTrait::new();
+ dex_service
+ .expect_supported_asset_types()
+ .returning(|| std::collections::HashSet::from([AssetType::Native, AssetType::Classic]));
+
+ // Mock get_xlm_to_token_quote for build (converting XLM fee to token)
+ 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: USDC_ASSET.to_string(),
+ in_amount: 1000000,
+ 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_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_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, USDC_ASSET);
+ assert!(!build.valid_until.is_empty());
+ } else {
+ panic!("Expected Stellar build response");
+ }
+ }
+
+ #[tokio::test]
+ async fn test_build_sponsored_transaction_with_operations() {
+ let relayer_model = create_test_relayer_with_user_fee_strategy();
+ let mut provider = MockStellarProviderTrait::new();
+
+ provider.expect_get_account().returning(|_| {
+ Box::pin(ready(Ok(AccountEntry {
+ account_id: AccountId(PublicKey::PublicKeyTypeEd25519(Uint256([0; 32]))),
+ balance: 1000000000,
+ seq_num: SequenceNumber(-1),
+ num_sub_entries: 0,
+ inflation_dest: None,
+ flags: 0,
+ home_domain: String32::default(),
+ thresholds: Thresholds([0; 4]),
+ signers: VecM::default(),
+ ext: AccountEntryExt::V0,
+ })))
+ });
+
+ provider.expect_get_ledger_entries().returning(|_| {
+ use crate::domain::transaction::stellar::utils::parse_account_id;
+ use soroban_rs::stellar_rpc_client::LedgerEntryResult;
+ use soroban_rs::xdr::{
+ AccountId, AlphaNum4, AssetCode4, LedgerEntry, LedgerEntryData, LedgerEntryExt,
+ PublicKey, TrustLineEntry, TrustLineEntryExt, Uint256, WriteXdr,
+ };
+
+ // Parse account IDs - use the source account from the test
+ let account_id =
+ parse_account_id("GCZ54QGQCUZ6U5WJF4AG5JEZCUMYTS2F6JRLUS76XF2PQMEJ2E3JISI2")
+ .unwrap_or_else(|_| {
+ AccountId(PublicKey::PublicKeyTypeEd25519(Uint256([0; 32])))
+ });
+ let issuer_id =
+ parse_account_id("GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN")
+ .unwrap_or_else(|_| {
+ AccountId(PublicKey::PublicKeyTypeEd25519(Uint256([0; 32])))
+ });
+
+ // Create a trustline entry with sufficient balance (10 USDC = 10000000 with 6 decimals)
+ // The fee is 1500000 (from the quote), so 10 USDC is more than enough
+ let trustline_entry = TrustLineEntry {
+ account_id,
+ asset: soroban_rs::xdr::TrustLineAsset::CreditAlphanum4(AlphaNum4 {
+ asset_code: AssetCode4(*b"USDC"),
+ issuer: issuer_id,
+ }),
+ balance: 10_000_000i64,
+ limit: i64::MAX,
+ flags: 0,
+ ext: TrustLineEntryExt::V0,
+ };
+
+ let ledger_entry = LedgerEntry {
+ last_modified_ledger_seq: 0,
+ data: LedgerEntryData::Trustline(trustline_entry),
+ ext: LedgerEntryExt::V0,
+ };
+
+ // Encode LedgerEntryData to XDR base64 (not the full LedgerEntry)
+ // The parse_ledger_entry_from_xdr function expects just the data portion
+ let xdr = ledger_entry
+ .data
+ .to_xdr_base64(soroban_rs::xdr::Limits::none())
+ .expect("Failed to encode trustline entry data to XDR");
+
+ Box::pin(ready(Ok(
+ soroban_rs::stellar_rpc_client::GetLedgerEntriesResponse {
+ entries: Some(vec![LedgerEntryResult {
+ key: "test_key".to_string(),
+ xdr,
+ last_modified_ledger: 0u32,
+ live_until_ledger_seq_ledger_seq: None,
+ }]),
+ latest_ledger: 0,
+ },
+ )))
+ });
+
+ let mut dex_service = MockStellarDexServiceTrait::new();
+ dex_service
+ .expect_supported_asset_types()
+ .returning(|| std::collections::HashSet::from([AssetType::Native, AssetType::Classic]));
+
+ 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: USDC_ASSET.to_string(),
+ in_amount: 1000000,
+ 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 operations = vec![OperationSpec::Payment {
+ destination: TEST_PK.to_string(),
+ amount: 1000000,
+ asset: AssetSpec::Native,
+ }];
+
+ let request = SponsoredTransactionBuildRequest::Stellar(
+ crate::models::StellarPrepareTransactionRequestParams {
+ transaction_xdr: None,
+ operations: Some(operations),
+ source_account: Some(
+ "GCZ54QGQCUZ6U5WJF4AG5JEZCUMYTS2F6JRLUS76XF2PQMEJ2E3JISI2".to_string(),
+ ),
+ fee_token: USDC_ASSET.to_string(),
+ },
+ );
+
+ let result = relayer.build_sponsored_transaction(request).await;
+
+ assert!(result.is_ok());
+ }
+
+ #[tokio::test]
+ async fn test_build_sponsored_transaction_missing_source_account() {
+ let relayer_model = create_test_relayer_with_user_fee_strategy();
+ let provider = MockStellarProviderTrait::new();
+ let dex_service = create_mock_dex_service();
+ let relayer = create_test_relayer_instance(relayer_model, provider, dex_service).await;
+
+ let operations = vec![OperationSpec::Payment {
+ destination: TEST_PK.to_string(),
+ amount: 1000000,
+ asset: AssetSpec::Native,
+ }];
+
+ let request = SponsoredTransactionBuildRequest::Stellar(
+ crate::models::StellarPrepareTransactionRequestParams {
+ transaction_xdr: None,
+ operations: Some(operations),
+ source_account: None,
+ fee_token: USDC_ASSET.to_string(),
+ },
+ );
+
+ let result = relayer.build_sponsored_transaction(request).await;
+ assert!(result.is_err());
+ assert!(matches!(
+ result.unwrap_err(),
+ RelayerError::ValidationError(_)
+ ));
+ }
+
+ #[tokio::test]
+ async fn test_build_envelope_from_request_with_xdr() {
+ let provider = MockStellarProviderTrait::new();
+ let transaction_xdr = create_test_transaction_xdr();
+ let result = build_envelope_from_request(
+ Some(&transaction_xdr),
+ None,
+ None,
+ TEST_NETWORK_PASSPHRASE,
+ &provider,
+ )
+ .await;
+ assert!(result.is_ok());
+ }
+
+ #[tokio::test]
+ async fn test_build_envelope_from_request_with_operations() {
+ let mut provider = MockStellarProviderTrait::new();
+
+ // Mock get_account to return a valid account with sequence number
+ provider.expect_get_account().returning(|_| {
+ Box::pin(ready(Ok(AccountEntry {
+ account_id: AccountId(PublicKey::PublicKeyTypeEd25519(Uint256([0; 32]))),
+ balance: 1000000000,
+ seq_num: SequenceNumber(100),
+ num_sub_entries: 0,
+ inflation_dest: None,
+ flags: 0,
+ home_domain: String32::default(),
+ thresholds: Thresholds([0; 4]),
+ signers: VecM::default(),
+ ext: AccountEntryExt::V0,
+ })))
+ });
+
+ let operations = vec![OperationSpec::Payment {
+ destination: TEST_PK.to_string(),
+ amount: 1000000,
+ asset: AssetSpec::Native,
+ }];
+
+ let result = build_envelope_from_request(
+ None,
+ Some(&operations),
+ Some(&TEST_PK.to_string()),
+ TEST_NETWORK_PASSPHRASE,
+ &provider,
+ )
+ .await;
+ assert!(result.is_ok());
+
+ // Verify the sequence number is set correctly (current + 1 = 101)
+ if let Ok(envelope) = result {
+ if let TransactionEnvelope::Tx(tx_env) = envelope {
+ assert_eq!(tx_env.tx.seq_num.0, 101);
+ }
+ }
+ }
+
+ #[tokio::test]
+ async fn test_build_envelope_from_request_missing_source_account() {
+ let provider = MockStellarProviderTrait::new();
+ let operations = vec![OperationSpec::Payment {
+ destination: TEST_PK.to_string(),
+ amount: 1000000,
+ asset: AssetSpec::Native,
+ }];
+
+ let result = build_envelope_from_request(
+ None,
+ Some(&operations),
+ None,
+ TEST_NETWORK_PASSPHRASE,
+ &provider,
+ )
+ .await;
+ assert!(result.is_err());
+ assert!(matches!(
+ result.unwrap_err(),
+ RelayerError::ValidationError(_)
+ ));
+ }
+
+ #[tokio::test]
+ async fn test_build_envelope_from_request_missing_both() {
+ let provider = MockStellarProviderTrait::new();
+ let result =
+ build_envelope_from_request(None, None, None, TEST_NETWORK_PASSPHRASE, &provider).await;
+ assert!(result.is_err());
+ assert!(matches!(
+ result.unwrap_err(),
+ RelayerError::ValidationError(_)
+ ));
+ }
+
+ #[tokio::test]
+ async fn test_build_envelope_from_request_invalid_xdr() {
+ let provider = MockStellarProviderTrait::new();
+ let result = build_envelope_from_request(
+ Some(&"INVALID_XDR".to_string()),
+ None,
+ None,
+ TEST_NETWORK_PASSPHRASE,
+ &provider,
+ )
+ .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(),
+ });
- const TEST_PK: &str = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF";
- const TEST_NETWORK_PASSPHRASE: &str = "Test SDF Network ; September 2015";
- const USDC_ASSET: &str = "USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN";
+ 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,
+ };
- /// Helper function to create a test transaction XDR
- fn create_test_transaction_xdr() -> String {
- // Use a different account than TEST_PK (relayer address) to avoid validation error
let source_pk = Ed25519PublicKey::from_string(
"GCZ54QGQCUZ6U5WJF4AG5JEZCUMYTS2F6JRLUS76XF2PQMEJ2E3JISI2",
)
@@ -501,238 +2309,460 @@ mod tests {
amount: 1000000,
};
- let operation = Operation {
- source_account: None,
- body: OperationBody::Payment(payment_op),
+ 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 operations: VecM = vec![operation].try_into().unwrap();
+ 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(2), // Must be > account sequence (1)
+ seq_num: SequenceNumber(1),
cond: Preconditions::None,
- memo: soroban_rs::xdr::Memo::None,
- operations,
+ memo: Memo::None,
+ operations: vec![Operation {
+ source_account: None,
+ body: OperationBody::InvokeHostFunction(invoke_op),
+ }]
+ .try_into()
+ .unwrap(),
ext: TransactionExt::V0,
};
- let envelope = TransactionV1Envelope {
+ let envelope = TransactionEnvelope::Tx(TransactionV1Envelope {
tx,
- signatures: vec![].try_into().unwrap(),
+ 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 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 tx_envelope = TransactionEnvelope::Tx(envelope);
- tx_envelope.to_xdr_base64(Limits::none()).unwrap()
+ 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);
}
- /// Helper function to create a test relayer with user fee payment strategy
- fn create_test_relayer_with_user_fee_strategy() -> RelayerRepoModel {
- 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(),
- metadata: None,
- max_allowed_fee: None,
- swap_config: None,
- }]);
+ #[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()
+ };
- 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,
+ 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"));
}
}
- /// Helper function to create a mock DEX service
- fn create_mock_dex_service() -> Arc {
- let mut mock_dex = MockStellarDexServiceTrait::new();
- mock_dex
- .expect_supported_asset_types()
- .returning(|| std::collections::HashSet::from([AssetType::Native, AssetType::Classic]));
- Arc::new(mock_dex)
+ #[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);
}
- /// Helper function to create a test network
- fn create_test_network() -> NetworkRepoModel {
- NetworkRepoModel {
- id: "stellar:testnet".to_string(),
- name: "testnet".to_string(),
- network_type: NetworkType::Stellar,
- config: NetworkConfigData::Stellar(StellarNetworkConfig {
- common: NetworkConfigCommon {
- network: "testnet".to_string(),
- from: None,
- rpc_urls: Some(vec![RpcConfig::new(
- "https://horizon-testnet.stellar.org".to_string(),
- )]),
- explorer_urls: None,
- average_blocktime_ms: Some(5000),
- is_testnet: Some(true),
- tags: None,
- },
- passphrase: Some(TEST_NETWORK_PASSPHRASE.to_string()),
- horizon_url: Some("https://horizon-testnet.stellar.org".to_string()),
- }),
+ // ============================================================================
+ // 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");
}
}
- /// Helper function to create a Stellar relayer instance for testing
- async fn create_test_relayer_instance(
- relayer_model: RelayerRepoModel,
- provider: MockStellarProviderTrait,
- dex_service: Arc,
- ) -> crate::domain::relayer::stellar::StellarRelayer<
- MockStellarProviderTrait,
- MockRelayerRepository,
- InMemoryNetworkRepository,
- MockTransactionRepository,
- MockJobProducerTrait,
- MockTransactionCounterServiceTrait,
- MockStellarSignTrait,
- MockStellarDexServiceTrait,
- > {
- let network_repository = Arc::new(InMemoryNetworkRepository::new());
- let test_network = create_test_network();
- network_repository.create(test_network).await.unwrap();
+ #[test]
+ fn test_build_soroban_transaction_envelope_invalid_source() {
+ use soroban_rs::xdr::{
+ ContractId, Hash, HostFunction, InvokeContractArgs, InvokeHostFunctionOp, Operation,
+ OperationBody, ScAddress, ScSymbol, VecM,
+ };
- 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());
+ 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(),
+ };
- 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,
+ 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 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",
)
- .await
- .unwrap()
+ .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
+ }
}
- #[tokio::test]
- async fn test_quote_sponsored_transaction_with_xdr() {
- let relayer_model = create_test_relayer_with_user_fee_strategy();
- let mut provider = MockStellarProviderTrait::new();
+ /// 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);
+ }
+ }
- // Mock account for validation
- provider.expect_get_account().returning(|_| {
- Box::pin(ready(Ok(AccountEntry {
- account_id: AccountId(PublicKey::PublicKeyTypeEd25519(Uint256([0; 32]))),
- balance: 1000000000,
- seq_num: SequenceNumber(1),
- num_sub_entries: 0,
- inflation_dest: None,
- flags: 0,
- home_domain: String32::default(),
- thresholds: Thresholds([0; 4]),
- signers: VecM::default(),
- ext: AccountEntryExt::V0,
- })))
- });
+ #[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());
+ }
- // Mock get_ledger_entries for token balance validation
- // This mock extracts the account ID from the ledger key and returns a trustline with sufficient balance
- provider.expect_get_ledger_entries().returning(|keys| {
- // Extract account ID from the first ledger key (should be a Trustline key)
- let account_id = if let Some(LedgerKey::Trustline(trustline_key)) = keys.first() {
- trustline_key.account_id.clone()
- } else {
- // Fallback: try to parse TEST_PK
- parse_account_id(TEST_PK).unwrap_or_else(|_| {
- AccountId(PublicKey::PublicKeyTypeEd25519(Uint256([0; 32])))
- })
- };
+ // ============================================================================
+ // Tests for SorobanInvokeInfo
+ // ============================================================================
- let issuer_id =
- parse_account_id("GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN")
- .unwrap_or_else(|_| {
- AccountId(PublicKey::PublicKeyTypeEd25519(Uint256([0; 32])))
- });
+ #[test]
+ fn test_soroban_invoke_info_debug_clone() {
+ use soroban_rs::xdr::ScVal;
- // Create a trustline entry with sufficient balance
- let trustline_entry = TrustLineEntry {
- account_id,
- asset: soroban_rs::xdr::TrustLineAsset::CreditAlphanum4(AlphaNum4 {
- asset_code: AssetCode4(*b"USDC"),
- issuer: issuer_id,
- }),
- balance: 10_000_000i64,
- limit: i64::MAX,
- flags: 0,
- ext: TrustLineEntryExt::V0,
- };
+ let info = SorobanInvokeInfo {
+ target_contract: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC".to_string(),
+ target_fn: "transfer".to_string(),
+ target_args: vec![ScVal::Bool(true)],
+ };
- let ledger_entry = LedgerEntry {
- last_modified_ledger_seq: 0,
- data: LedgerEntryData::Trustline(trustline_entry),
- ext: LedgerEntryExt::V0,
- };
+ // Test Debug trait
+ let debug_str = format!("{:?}", info);
+ assert!(debug_str.contains("SorobanInvokeInfo"));
+ assert!(debug_str.contains("transfer"));
- // Encode LedgerEntryData to XDR base64 (not the full LedgerEntry)
- let xdr = ledger_entry
- .data
- .to_xdr_base64(soroban_rs::xdr::Limits::none())
- .expect("Failed to encode trustline entry data to XDR");
+ // 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());
+ }
- Box::pin(ready(Ok(GetLedgerEntriesResponse {
- entries: Some(vec![LedgerEntryResult {
- key: "test_key".to_string(),
- xdr,
- last_modified_ledger: 0u32,
- live_until_ledger_seq_ledger_seq: None,
- }]),
- latest_ledger: 0,
- })))
- });
+ // ============================================================================
+ // Tests for fee payment strategy validation
+ // ============================================================================
- let mut dex_service = MockStellarDexServiceTrait::new();
- dex_service
- .expect_supported_asset_types()
- .returning(|| std::collections::HashSet::from([AssetType::Native, AssetType::Classic]));
+ #[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,
+ }]);
- // Mock get_xlm_to_token_quote for fee conversion (XLM -> token)
- 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: USDC_ASSET.to_string(),
- in_amount: 100000,
- out_amount: 1500000,
- price_impact_pct: 0.0,
- slippage_bps: 100,
- path: 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 dex_service = Arc::new(dex_service);
+ 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 = SponsoredTransactionQuoteRequest::Stellar(
- crate::models::StellarFeeEstimateRequestParams {
+ let request = SponsoredTransactionBuildRequest::Stellar(
+ crate::models::StellarPrepareTransactionRequestParams {
transaction_xdr: Some(transaction_xdr),
operations: None,
source_account: None,
@@ -740,114 +2770,180 @@ mod tests {
},
);
- let result = relayer.quote_sponsored_transaction(request).await;
- if let Err(e) = &result {
- eprintln!("Quote error: {:?}", e);
+ 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"));
}
- 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");
- }
+ // ============================================================================
+ // 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()
}
- #[tokio::test]
- async fn test_quote_sponsored_transaction_with_operations() {
- let relayer_model = create_test_relayer_with_user_fee_strategy();
- let mut provider = MockStellarProviderTrait::new();
+ /// 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,
+ };
- provider.expect_get_account().returning(|_| {
- Box::pin(ready(Ok(AccountEntry {
- account_id: AccountId(PublicKey::PublicKeyTypeEd25519(Uint256([0; 32]))),
- balance: 1000000000,
- seq_num: SequenceNumber(-1),
- num_sub_entries: 0,
- inflation_dest: None,
- flags: 0,
- home_domain: String32::default(),
- thresholds: Thresholds([0; 4]),
- signers: VecM::default(),
- ext: AccountEntryExt::V0,
- })))
+ 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(),
});
- // Mock get_ledger_entries for token balance validation
- // This mock extracts the account ID from the ledger key and returns a trustline with sufficient balance
- provider.expect_get_ledger_entries().returning(|keys| {
- // Extract account ID from the first ledger key (should be a Trustline key)
- let account_id = if let Some(LedgerKey::Trustline(trustline_key)) = keys.first() {
- trustline_key.account_id.clone()
- } else {
- // Fallback: use the source account from the test
- parse_account_id("GCZ54QGQCUZ6U5WJF4AG5JEZCUMYTS2F6JRLUS76XF2PQMEJ2E3JISI2")
- .unwrap_or_else(|_| {
- AccountId(PublicKey::PublicKeyTypeEd25519(Uint256([0; 32])))
- })
- };
+ envelope.to_xdr_base64(Limits::none()).unwrap()
+ }
- let issuer_id =
- parse_account_id("GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN")
- .unwrap_or_else(|_| {
- AccountId(PublicKey::PublicKeyTypeEd25519(Uint256([0; 32])))
- });
+ /// 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,
+ }]);
- // Create a trustline entry with sufficient balance
- let trustline_entry = TrustLineEntry {
- account_id,
- asset: soroban_rs::xdr::TrustLineAsset::CreditAlphanum4(AlphaNum4 {
- asset_code: AssetCode4(*b"USDC"),
- issuer: issuer_id,
- }),
- balance: 10_000_000i64,
- limit: i64::MAX,
- flags: 0,
- ext: TrustLineEntryExt::V0,
- };
+ 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 ledger_entry = LedgerEntry {
- last_modified_ledger_seq: 0,
- data: LedgerEntryData::Trustline(trustline_entry),
- ext: LedgerEntryExt::V0,
- };
+ #[tokio::test]
+ #[serial]
+ async fn test_quote_soroban_from_xdr_success() {
+ // Set required env var for FeeForwarder (testnet network)
+ std::env::set_var(
+ "STELLAR_TESTNET_FEE_FORWARDER_ADDRESS",
+ "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC",
+ );
- // Encode LedgerEntryData to XDR base64 (not the full LedgerEntry)
- let xdr = ledger_entry
- .data
- .to_xdr_base64(soroban_rs::xdr::Limits::none())
- .expect("Failed to encode trustline entry data to XDR");
+ 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::GetLedgerEntriesResponse {
- entries: Some(vec![LedgerEntryResult {
- key: "test_key".to_string(),
- xdr,
- last_modified_ledger: 0u32,
- live_until_ledger_seq_ledger_seq: None,
- }]),
- latest_ledger: 0,
+ 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::Classic]));
+ dex_service.expect_supported_asset_types().returning(|| {
+ std::collections::HashSet::from([AssetType::Native, AssetType::Contract])
+ });
- // Mock get_xlm_to_token_quote for fee conversion (XLM -> token)
+ // 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: USDC_ASSET.to_string(),
- in_amount: 100000,
- out_amount: 1500000,
+ 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,
@@ -858,171 +2954,372 @@ mod tests {
let dex_service = Arc::new(dex_service);
let relayer = create_test_relayer_instance(relayer_model, provider, dex_service).await;
- let operations = vec![OperationSpec::Payment {
- destination: TEST_PK.to_string(),
- amount: 1000000,
- asset: AssetSpec::Native,
- }];
-
+ let transaction_xdr = create_test_soroban_transaction_xdr();
let request = SponsoredTransactionQuoteRequest::Stellar(
crate::models::StellarFeeEstimateRequestParams {
- transaction_xdr: None,
- operations: Some(operations),
- source_account: Some(
- "GCZ54QGQCUZ6U5WJF4AG5JEZCUMYTS2F6JRLUS76XF2PQMEJ2E3JISI2".to_string(),
- ),
- fee_token: USDC_ASSET.to_string(),
+ 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!("Quote error: {:?}", e);
+ 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_TESTNET_FEE_FORWARDER_ADDRESS");
}
#[tokio::test]
- async fn test_quote_sponsored_transaction_invalid_token() {
- let relayer_model = create_test_relayer_with_user_fee_strategy();
+ #[serial]
+ async fn test_quote_soroban_from_xdr_missing_fee_forwarder() {
+ // Ensure env var is NOT set
+ 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();
+ relayer_model.network = "mainnet".to_string();
+
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 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_with_network(
+ relayer_model,
+ provider,
+ dex_service,
+ create_test_mainnet_network(),
+ )
+ .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: "INVALID:TOKEN".to_string(),
+ fee_token: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC".to_string(),
},
);
let result = relayer.quote_sponsored_transaction(request).await;
assert!(result.is_err());
- assert!(matches!(
- result.unwrap_err(),
- RelayerError::ValidationError(_)
- ));
+ let err = result.unwrap_err();
+ assert!(matches!(err, RelayerError::ValidationError(_)));
+ if let RelayerError::ValidationError(msg) = err {
+ assert!(msg.contains("STELLAR_MAINNET_FEE_FORWARDER_ADDRESS"));
+ }
}
#[tokio::test]
- async fn test_quote_sponsored_transaction_missing_xdr_and_operations() {
- let relayer_model = create_test_relayer_with_user_fee_strategy();
+ #[serial]
+ async fn test_quote_soroban_from_xdr_invalid_fee_token_format() {
+ // Set required env var for FeeForwarder (testnet network)
+ std::env::set_var(
+ "STELLAR_TESTNET_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 dex_service = create_mock_dex_service();
+
+ 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: None,
+ transaction_xdr: Some(transaction_xdr),
operations: None,
source_account: None,
- fee_token: USDC_ASSET.to_string(),
+ fee_token: USDC_ASSET.to_string(), // Classic asset, not valid C... format
},
);
let result = relayer.quote_sponsored_transaction(request).await;
assert!(result.is_err());
- assert!(matches!(
- result.unwrap_err(),
- RelayerError::ValidationError(_)
- ));
+ 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_TESTNET_FEE_FORWARDER_ADDRESS");
}
+ // ============================================================================
+ // Tests for build_soroban_sponsored (via build_sponsored_transaction)
+ // ============================================================================
+
#[tokio::test]
- async fn test_build_sponsored_transaction_with_xdr() {
- let relayer_model = create_test_relayer_with_user_fee_strategy();
+ #[serial]
+ async fn test_build_soroban_sponsored_success() {
+ // Set required env var for FeeForwarder (testnet network)
+ std::env::set_var(
+ "STELLAR_TESTNET_FEE_FORWARDER_ADDRESS",
+ "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC",
+ );
+
+ let relayer_model = create_test_relayer_with_soroban_token();
let mut provider = MockStellarProviderTrait::new();
- provider.expect_get_account().returning(|_| {
- Box::pin(ready(Ok(AccountEntry {
- account_id: AccountId(PublicKey::PublicKeyTypeEd25519(Uint256([0; 32]))),
- balance: 1000000000,
- seq_num: SequenceNumber(-1),
- num_sub_entries: 0,
- inflation_dest: None,
- flags: 0,
- home_domain: String32::default(),
- thresholds: Thresholds([0; 4]),
- signers: VecM::default(),
- ext: AccountEntryExt::V0,
- })))
+ // 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 get_ledger_entries for token balance validation
- // This mock extracts the account ID from the ledger key and returns a trustline with sufficient balance
- provider.expect_get_ledger_entries().returning(|keys| {
- // Extract account ID from the first ledger key (should be a Trustline key)
- let account_id = if let Some(LedgerKey::Trustline(trustline_key)) = keys.first() {
- trustline_key.account_id.clone()
- } else {
- // Fallback: try to parse TEST_PK
- parse_account_id(TEST_PK).unwrap_or_else(|_| {
- AccountId(PublicKey::PublicKeyTypeEd25519(Uint256([0; 32])))
- })
- };
+ // 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()
+ },
+ )))
+ });
- let issuer_id =
- parse_account_id("GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN")
- .unwrap_or_else(|_| {
- AccountId(PublicKey::PublicKeyTypeEd25519(Uint256([0; 32])))
- });
+ // 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,
+ }))))
+ });
- // Create a trustline entry with sufficient balance (10 USDC = 10000000 with 6 decimals)
- let trustline_entry = TrustLineEntry {
- account_id,
- asset: soroban_rs::xdr::TrustLineAsset::CreditAlphanum4(AlphaNum4 {
- asset_code: AssetCode4(*b"USDC"),
- issuer: issuer_id,
- }),
- balance: 10_000_000i64, // 10 USDC (with 6 decimals) - sufficient for fee
- limit: i64::MAX,
- flags: 0,
- ext: TrustLineEntryExt::V0, // V0 has no liabilities
- };
+ let mut dex_service = MockStellarDexServiceTrait::new();
+ dex_service.expect_supported_asset_types().returning(|| {
+ std::collections::HashSet::from([AssetType::Native, AssetType::Contract])
+ });
- let ledger_entry = LedgerEntry {
- last_modified_ledger_seq: 0,
- data: LedgerEntryData::Trustline(trustline_entry),
- ext: LedgerEntryExt::V0,
- };
+ // 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,
+ },
+ )))
+ });
- // Encode LedgerEntryData to XDR base64 (not the full LedgerEntry)
- let xdr = ledger_entry
- .data
- .to_xdr_base64(soroban_rs::xdr::Limits::none())
- .expect("Failed to encode trustline entry data to XDR");
+ 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_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_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();
+ relayer_model.network = "mainnet".to_string();
+
+ 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_with_network(
+ relayer_model,
+ provider,
+ dex_service,
+ create_test_mainnet_network(),
+ )
+ .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_MAINNET_FEE_FORWARDER_ADDRESS"));
+ }
+ }
+
+ #[tokio::test]
+ #[serial]
+ async fn test_build_soroban_sponsored_insufficient_balance() {
+ // Set required env var for FeeForwarder (testnet network)
+ std::env::set_var(
+ "STELLAR_TESTNET_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::GetLedgerEntriesResponse {
- entries: Some(vec![LedgerEntryResult {
- key: "test_key".to_string(),
- xdr,
- last_modified_ledger: 0u32,
- live_until_ledger_seq_ledger_seq: None,
- }]),
- latest_ledger: 0,
+ 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::Classic]));
+ dex_service.expect_supported_asset_types().returning(|| {
+ std::collections::HashSet::from([AssetType::Native, AssetType::Contract])
+ });
- // Mock get_xlm_to_token_quote for build (converting XLM fee to token)
+ // 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: USDC_ASSET.to_string(),
- in_amount: 1000000,
- out_amount: 1500000,
+ output_asset: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC"
+ .to_string(),
+ in_amount: 50100,
+ out_amount: 1500000, // Fee required
price_impact_pct: 0.0,
slippage_bps: 100,
path: None,
@@ -1033,124 +3330,83 @@ mod tests {
let dex_service = Arc::new(dex_service);
let relayer = create_test_relayer_instance(relayer_model, provider, dex_service).await;
- let transaction_xdr = create_test_transaction_xdr();
+ 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: USDC_ASSET.to_string(),
+ fee_token: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC".to_string(),
},
);
let result = relayer.build_sponsored_transaction(request).await;
- 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, USDC_ASSET);
- assert!(!build.valid_until.is_empty());
- } else {
- panic!("Expected Stellar build response");
+ 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_TESTNET_FEE_FORWARDER_ADDRESS");
}
#[tokio::test]
- async fn test_build_sponsored_transaction_with_operations() {
- let relayer_model = create_test_relayer_with_user_fee_strategy();
- let mut provider = MockStellarProviderTrait::new();
-
- provider.expect_get_account().returning(|_| {
- Box::pin(ready(Ok(AccountEntry {
- account_id: AccountId(PublicKey::PublicKeyTypeEd25519(Uint256([0; 32]))),
- balance: 1000000000,
- seq_num: SequenceNumber(-1),
- num_sub_entries: 0,
- inflation_dest: None,
- flags: 0,
- home_domain: String32::default(),
- thresholds: Thresholds([0; 4]),
- signers: VecM::default(),
- ext: AccountEntryExt::V0,
- })))
- });
-
- provider.expect_get_ledger_entries().returning(|_| {
- use crate::domain::transaction::stellar::utils::parse_account_id;
- use soroban_rs::stellar_rpc_client::LedgerEntryResult;
- use soroban_rs::xdr::{
- AccountId, AlphaNum4, AssetCode4, LedgerEntry, LedgerEntryData, LedgerEntryExt,
- PublicKey, TrustLineEntry, TrustLineEntryExt, Uint256, WriteXdr,
- };
-
- // Parse account IDs - use the source account from the test
- let account_id =
- parse_account_id("GCZ54QGQCUZ6U5WJF4AG5JEZCUMYTS2F6JRLUS76XF2PQMEJ2E3JISI2")
- .unwrap_or_else(|_| {
- AccountId(PublicKey::PublicKeyTypeEd25519(Uint256([0; 32])))
- });
- let issuer_id =
- parse_account_id("GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN")
- .unwrap_or_else(|_| {
- AccountId(PublicKey::PublicKeyTypeEd25519(Uint256([0; 32])))
- });
-
- // Create a trustline entry with sufficient balance (10 USDC = 10000000 with 6 decimals)
- // The fee is 1500000 (from the quote), so 10 USDC is more than enough
- let trustline_entry = TrustLineEntry {
- account_id,
- asset: soroban_rs::xdr::TrustLineAsset::CreditAlphanum4(AlphaNum4 {
- asset_code: AssetCode4(*b"USDC"),
- issuer: issuer_id,
- }),
- balance: 10_000_000i64,
- limit: i64::MAX,
- flags: 0,
- ext: TrustLineEntryExt::V0,
- };
-
- let ledger_entry = LedgerEntry {
- last_modified_ledger_seq: 0,
- data: LedgerEntryData::Trustline(trustline_entry),
- ext: LedgerEntryExt::V0,
- };
+ #[serial]
+ async fn test_build_soroban_sponsored_simulation_error() {
+ // Set required env var for FeeForwarder (testnet network)
+ std::env::set_var(
+ "STELLAR_TESTNET_FEE_FORWARDER_ADDRESS",
+ "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC",
+ );
- // Encode LedgerEntryData to XDR base64 (not the full LedgerEntry)
- // The parse_ledger_entry_from_xdr function expects just the data portion
- let xdr = ledger_entry
- .data
- .to_xdr_base64(soroban_rs::xdr::Limits::none())
- .expect("Failed to encode trustline entry data to XDR");
+ 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::GetLedgerEntriesResponse {
- entries: Some(vec![LedgerEntryResult {
- key: "test_key".to_string(),
- xdr,
- last_modified_ledger: 0u32,
- live_until_ledger_seq_ledger_seq: None,
- }]),
- latest_ledger: 0,
+ 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::Classic]));
+ 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: USDC_ASSET.to_string(),
- in_amount: 1000000,
- out_amount: 1500000,
+ output_asset: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC"
+ .to_string(),
+ in_amount: 100,
+ out_amount: 1500,
price_impact_pct: 0.0,
slippage_bps: 100,
path: None,
@@ -1161,164 +3417,26 @@ mod tests {
let dex_service = Arc::new(dex_service);
let relayer = create_test_relayer_instance(relayer_model, provider, dex_service).await;
- let operations = vec![OperationSpec::Payment {
- destination: TEST_PK.to_string(),
- amount: 1000000,
- asset: AssetSpec::Native,
- }];
-
- let request = SponsoredTransactionBuildRequest::Stellar(
- crate::models::StellarPrepareTransactionRequestParams {
- transaction_xdr: None,
- operations: Some(operations),
- source_account: Some(
- "GCZ54QGQCUZ6U5WJF4AG5JEZCUMYTS2F6JRLUS76XF2PQMEJ2E3JISI2".to_string(),
- ),
- fee_token: USDC_ASSET.to_string(),
- },
- );
-
- let result = relayer.build_sponsored_transaction(request).await;
-
- assert!(result.is_ok());
- }
-
- #[tokio::test]
- async fn test_build_sponsored_transaction_missing_source_account() {
- let relayer_model = create_test_relayer_with_user_fee_strategy();
- let provider = MockStellarProviderTrait::new();
- let dex_service = create_mock_dex_service();
- let relayer = create_test_relayer_instance(relayer_model, provider, dex_service).await;
-
- let operations = vec![OperationSpec::Payment {
- destination: TEST_PK.to_string(),
- amount: 1000000,
- asset: AssetSpec::Native,
- }];
-
+ let transaction_xdr = create_test_soroban_transaction_xdr();
let request = SponsoredTransactionBuildRequest::Stellar(
crate::models::StellarPrepareTransactionRequestParams {
- transaction_xdr: None,
- operations: Some(operations),
+ transaction_xdr: Some(transaction_xdr),
+ operations: None,
source_account: None,
- fee_token: USDC_ASSET.to_string(),
+ fee_token: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC".to_string(),
},
);
let result = relayer.build_sponsored_transaction(request).await;
assert!(result.is_err());
- assert!(matches!(
- result.unwrap_err(),
- RelayerError::ValidationError(_)
- ));
- }
-
- #[tokio::test]
- async fn test_build_envelope_from_request_with_xdr() {
- let provider = MockStellarProviderTrait::new();
- let transaction_xdr = create_test_transaction_xdr();
- let result = build_envelope_from_request(
- Some(&transaction_xdr),
- None,
- None,
- TEST_NETWORK_PASSPHRASE,
- &provider,
- )
- .await;
- assert!(result.is_ok());
- }
-
- #[tokio::test]
- async fn test_build_envelope_from_request_with_operations() {
- let mut provider = MockStellarProviderTrait::new();
-
- // Mock get_account to return a valid account with sequence number
- provider.expect_get_account().returning(|_| {
- Box::pin(ready(Ok(AccountEntry {
- account_id: AccountId(PublicKey::PublicKeyTypeEd25519(Uint256([0; 32]))),
- balance: 1000000000,
- seq_num: SequenceNumber(100),
- num_sub_entries: 0,
- inflation_dest: None,
- flags: 0,
- home_domain: String32::default(),
- thresholds: Thresholds([0; 4]),
- signers: VecM::default(),
- ext: AccountEntryExt::V0,
- })))
- });
-
- let operations = vec![OperationSpec::Payment {
- destination: TEST_PK.to_string(),
- amount: 1000000,
- asset: AssetSpec::Native,
- }];
-
- let result = build_envelope_from_request(
- None,
- Some(&operations),
- Some(&TEST_PK.to_string()),
- TEST_NETWORK_PASSPHRASE,
- &provider,
- )
- .await;
- assert!(result.is_ok());
-
- // Verify the sequence number is set correctly (current + 1 = 101)
- if let Ok(envelope) = result {
- if let TransactionEnvelope::Tx(tx_env) = envelope {
- assert_eq!(tx_env.tx.seq_num.0, 101);
- }
+ 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"));
}
- }
-
- #[tokio::test]
- async fn test_build_envelope_from_request_missing_source_account() {
- let provider = MockStellarProviderTrait::new();
- let operations = vec![OperationSpec::Payment {
- destination: TEST_PK.to_string(),
- amount: 1000000,
- asset: AssetSpec::Native,
- }];
-
- let result = build_envelope_from_request(
- None,
- Some(&operations),
- None,
- TEST_NETWORK_PASSPHRASE,
- &provider,
- )
- .await;
- assert!(result.is_err());
- assert!(matches!(
- result.unwrap_err(),
- RelayerError::ValidationError(_)
- ));
- }
-
- #[tokio::test]
- async fn test_build_envelope_from_request_missing_both() {
- let provider = MockStellarProviderTrait::new();
- let result =
- build_envelope_from_request(None, None, None, TEST_NETWORK_PASSPHRASE, &provider).await;
- assert!(result.is_err());
- assert!(matches!(
- result.unwrap_err(),
- RelayerError::ValidationError(_)
- ));
- }
- #[tokio::test]
- async fn test_build_envelope_from_request_invalid_xdr() {
- let provider = MockStellarProviderTrait::new();
- let result = build_envelope_from_request(
- Some(&"INVALID_XDR".to_string()),
- None,
- None,
- TEST_NETWORK_PASSPHRASE,
- &provider,
- )
- .await;
- assert!(result.is_err());
+ // Clean up env var
+ 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 697c730b0..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::*;
@@ -25,7 +26,7 @@ use crate::{
services::{
provider::get_network_provider,
signer::StellarSignerFactory,
- stellar_dex::{DexServiceWrapper, OrderBookService, StellarDexService},
+ stellar_dex::{DexServiceWrapper, OrderBookService, SoroswapService, StellarDexService},
TransactionCounterService,
},
};
@@ -114,9 +115,48 @@ 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");
+ let is_testnet = network.is_testnet();
+ let network_label = if is_testnet { "TESTNET" } else { "MAINNET" };
+
+ let router_address =
+ 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."
+ ))
+ })?;
+
+ let factory_address =
+ 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."
+ ))
+ })?;
+
+ let 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,
+ factory_address,
+ native_wrapper_address,
+ provider_arc.clone(),
+ network.passphrase.clone(),
+ ));
+ dex_services.push(DexServiceWrapper::Soroswap(soroswap_service));
+ tracing::info!("Soroswap DEX service initialized");
}
}
}
diff --git a/src/domain/relayer/stellar/stellar_relayer.rs b/src/domain/relayer/stellar/stellar_relayer.rs
index 37b1c0a72..cbb7cd5c3 100644
--- a/src/domain/relayer/stellar/stellar_relayer.rs
+++ b/src/domain/relayer/stellar/stellar_relayer.rs
@@ -131,7 +131,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,
@@ -2004,6 +2004,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/relayer/stellar/utils.rs b/src/domain/relayer/stellar/utils.rs
new file mode 100644
index 000000000..1387bed8b
--- /dev/null
+++ b/src/domain/relayer/stellar/utils.rs
@@ -0,0 +1,295 @@
+//! 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 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
+ // ============================================================================
+
+ #[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)
+ }
+
+ #[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/mod.rs b/src/domain/transaction/mod.rs
index 5523ce9a1..5d5c7b41e 100644
--- a/src/domain/transaction/mod.rs
+++ b/src/domain/transaction/mod.rs
@@ -16,7 +16,7 @@ use crate::{
jobs::{JobProducer, StatusCheckContext},
models::{
EvmNetwork, NetworkTransactionRequest, NetworkType, RelayerRepoModel, SignerRepoModel,
- SolanaNetwork, StellarNetwork, TransactionError, TransactionRepoModel,
+ SolanaNetwork, StellarNetwork, StellarSwapStrategy, TransactionError, TransactionRepoModel,
},
repositories::{
NetworkRepository, NetworkRepositoryStorage, RelayerRepositoryStorage,
@@ -29,7 +29,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 +635,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 +646,80 @@ 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 is_testnet = network.is_testnet();
+ let network_label = if is_testnet { "TESTNET" } else { "MAINNET" };
+
+ let router_address =
+ 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::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,
+ factory_address,
+ native_wrapper_address,
+ provider_arc.clone(),
+ network.passphrase.clone(),
+ ));
+ 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/mod.rs b/src/domain/transaction/stellar/prepare/mod.rs
index ae73a3d42..38115068e 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,
@@ -168,6 +172,22 @@ where
)
.await
}
+ TransactionInput::SorobanGasAbstraction { .. } => {
+ 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(),
+ &self.relayer().id,
+ &self.relayer().address,
+ self.provider(),
+ stellar_data,
+ Some(&policy),
+ self.dex_service(),
+ )
+ .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..7db65f37a
--- /dev/null
+++ b/src/domain/transaction/stellar/prepare/soroban_gas_abstraction.rs
@@ -0,0 +1,2270 @@
+//! 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, ScAddress, ScVal,
+ SorobanAuthorizationEntry, SorobanAuthorizedFunction, SorobanCredentials, SorobanResources,
+ SorobanTransactionData, TransactionEnvelope, TransactionExt, WriteXdr,
+};
+use tracing::{debug, info, warn};
+
+use crate::{
+ 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::{
+ provider::{StellarProvider, StellarProviderTrait},
+ stellar_dex::StellarDexServiceTrait,
+ 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. 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
+///
+/// * `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)
+/// * `provider` - The Stellar provider for simulation
+/// * `stellar_data` - The transaction data containing the XDR and signed auth entry
+/// * `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 {
+ 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
+ // 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)?;
+
+ // 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?;
+ }
+
+ // 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)
+ .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| {
+ 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 operation (Soroban transactions must have exactly one operation)
+ let operations: Vec<_> = tx.operations.to_vec();
+ if operations.is_empty() {
+ return Err(TransactionError::ValidationError(
+ "Transaction has no operations".to_string(),
+ ));
+ }
+
+ // 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(),
+ _ => {
+ 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() {
+ 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)
+ 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)
+}
+
+/// 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.
+/// Uses saturating arithmetic to prevent silent truncation if scaled values exceed u32::MAX.
+fn apply_resource_buffer(resources: &mut SorobanResources) {
+ 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.
+///
+/// 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,
+{
+ 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
+ // 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)
+ debug!(
+ "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
+ );
+
+ // 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
+ // 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);
+ env.tx.fee = fee_to_set;
+
+ debug!(
+ "Updated transaction sorobanData with simulation results, fee={}",
+ fee_to_set
+ );
+ 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(),
+ )),
+ }
+}
+
+// ============================================================================
+// 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 when override is None)
+/// * `signed_user_auth` - The user's signed authorization entry
+/// * `policy` - The relayer policy containing allowed tokens and fee settings
+/// * `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
+///
+/// Ok(()) if validation passes, TransactionError if validation fails
+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>
+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 must be positive".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: 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",
+ 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::*;
+ 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(),
+ },
+ }
+ }
+
+ // ==================== 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_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};
+
+ // 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,
+ }),
+ };
+
+ 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 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("First operation is not InvokeHostFunction"));
+ }
+ _ => panic!("Expected ValidationError"),
+ }
+ }
+
+ #[test]
+ 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_err());
+ let err = result.unwrap_err();
+ assert!(matches!(err, TransactionError::ValidationError(_)));
+ assert!(err.to_string().contains("must contain auth entries"));
+ }
+
+ #[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");
+ }
+ }
+
+ // ==================== 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)]
+mod integration_tests {
+ use super::*;
+ 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,
+ MuxedAccount, Operation, Preconditions, ScAddress, ScSymbol, ScVal, SequenceNumber,
+ SorobanAddressCredentials, SorobanAuthorizationEntry, SorobanAuthorizedFunction,
+ SorobanAuthorizedInvocation, SorobanCredentials, SorobanTransactionData, Transaction,
+ TransactionExt, TransactionV1Envelope, Uint256, VecM,
+ };
+ 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 {
+ 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 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;
+
+ 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 dex_service = create_mock_dex_service();
+
+ 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,
+ None, // No policy - skip fee validation
+ &dex_service,
+ )
+ .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 dex_service = create_mock_dex_service();
+
+ 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,
+ None, // No policy - skip fee validation
+ &dex_service,
+ )
+ .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 dex_service = create_mock_dex_service();
+
+ 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,
+ None, // No policy - skip fee validation
+ &dex_service,
+ )
+ .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 dex_service = create_mock_dex_service();
+
+ 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,
+ None, // No policy - skip fee validation
+ &dex_service,
+ )
+ .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"),
+ }
+ }
+}
+
+#[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,
+ None,
+ &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,
+ None,
+ &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,
+ None,
+ &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,
+ None,
+ &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,
+ None,
+ &provider,
+ &dex_service,
+ )
+ .await;
+
+ // Assert
+ assert!(result.is_err());
+ let err = result.unwrap_err();
+ match err {
+ TransactionError::ValidationError(msg) => {
+ assert!(
+ msg.contains("must be positive"),
+ "Expected 'must be positive' 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,
+ None,
+ &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/domain/transaction/stellar/prepare/unsigned_xdr.rs b/src/domain/transaction/stellar/prepare/unsigned_xdr.rs
index d9bb7845b..0c16a1354 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)
@@ -1026,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)]
diff --git a/src/domain/transaction/stellar/status.rs b/src/domain/transaction/stellar/status.rs
index f53556f20..024da7d3c 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,
@@ -2002,8 +2005,7 @@ mod tests {
MockRelayerRepository, MockTransactionCounterTrait, MockTransactionRepository,
},
services::{
- provider::MockStellarProviderTrait, signer::MockSigner,
- stellar_dex::MockStellarDexServiceTrait,
+ provider::MockStellarProviderTrait, stellar_dex::MockStellarDexServiceTrait,
},
};
use chrono::{Duration, Utc};
@@ -2013,7 +2015,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 1e6bc1545..25a8ad48b 100644
--- a/src/domain/transaction/stellar/stellar_transaction.rs
+++ b/src/domain/transaction/stellar/stellar_transaction.rs
@@ -18,8 +18,8 @@ use crate::{
},
services::{
provider::{StellarProvider, StellarProviderTrait},
- signer::{Signer, StellarSigner},
- stellar_dex::{OrderBookService, StellarDexServiceTrait},
+ signer::{Signer, StellarSignTrait, StellarSigner},
+ stellar_dex::{StellarDexService, 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,
@@ -295,7 +295,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,
@@ -366,7 +366,7 @@ pub type DefaultStellarTransaction = StellarRelayerTransaction<
StellarSigner,
StellarProvider,
TransactionCounterRepositoryStorage,
- OrderBookService,
+ StellarDexService,
>;
#[cfg(test)]
diff --git a/src/domain/transaction/stellar/submit.rs b/src/domain/transaction/stellar/submit.rs
index 5fa100a64..3166c6091 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/domain/transaction/stellar/token.rs b/src/domain/transaction/stellar/token.rs
index f9f828489..e3da0958f 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:?}"),
+ )),
}
}
@@ -1344,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;
@@ -1368,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";
@@ -1420,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";
@@ -1481,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";
@@ -1539,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;
diff --git a/src/domain/transaction/stellar/utils.rs b/src/domain/transaction/stellar/utils.rs
index 6edb6255d..2ad8df821 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
// ============================================================================
@@ -2891,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"),
+ }
+ }
+}
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..37729a82e 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
@@ -1042,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/models/rpc/stellar/mod.rs b/src/models/rpc/stellar/mod.rs
index d2f9a9cac..01a39fc8e 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,
}
@@ -81,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
@@ -90,7 +103,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 +115,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 +183,21 @@ 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,
+ /// 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
@@ -511,4 +542,86 @@ 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(),
+ 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 {
+ 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()),
+ 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]
+ 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,
+ 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/models/transaction/repository.rs b/src/models/transaction/repository.rs
index 3739c3ea9..537003d40 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")]
@@ -470,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 {
@@ -483,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)
@@ -641,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)
+ }
}
}
@@ -683,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)
+ }
}
}
@@ -917,6 +951,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 +964,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,
};
@@ -1870,6 +1906,7 @@ mod tests {
transaction_xdr: None,
fee_bump: None,
max_fee: None,
+ signed_auth_entry: None,
});
let relayer_model = RelayerRepoModel {
@@ -2314,6 +2351,7 @@ mod tests {
transaction_xdr: None,
fee_bump: None,
max_fee: None,
+ signed_auth_entry: None,
};
let request = NetworkTransactionRequest::Stellar(stellar_request);
@@ -2342,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);
@@ -2426,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);
@@ -2455,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);
@@ -2481,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);
@@ -2514,6 +2556,7 @@ mod tests {
transaction_xdr: None,
fee_bump: None,
max_fee: None,
+ signed_auth_entry: None,
};
let request = NetworkTransactionRequest::Stellar(stellar_request);
@@ -2546,6 +2589,7 @@ mod tests {
transaction_xdr: None,
fee_bump: None,
max_fee: None,
+ signed_auth_entry: None,
};
let request = NetworkTransactionRequest::Stellar(stellar_request);
@@ -2590,6 +2634,7 @@ mod tests {
transaction_xdr: None,
fee_bump: None,
max_fee: None,
+ signed_auth_entry: None,
};
let request = NetworkTransactionRequest::Stellar(stellar_request);
@@ -2632,6 +2677,7 @@ mod tests {
transaction_xdr: None,
fee_bump: None,
max_fee: None,
+ signed_auth_entry: None,
};
let request = NetworkTransactionRequest::Stellar(stellar_request);
@@ -2658,6 +2704,7 @@ mod tests {
transaction_xdr: None,
fee_bump: None,
max_fee: None,
+ signed_auth_entry: None,
};
let request = NetworkTransactionRequest::Stellar(stellar_request);
@@ -2693,6 +2740,7 @@ mod tests {
transaction_xdr: None,
fee_bump: None,
max_fee: None,
+ signed_auth_entry: None,
};
let request = NetworkTransactionRequest::Stellar(stellar_request);
@@ -2720,6 +2768,7 @@ mod tests {
transaction_xdr: None,
fee_bump: None,
max_fee: None,
+ signed_auth_entry: None,
};
let request = NetworkTransactionRequest::Stellar(stellar_request);
@@ -2747,6 +2796,7 @@ mod tests {
transaction_xdr: None,
fee_bump: None,
max_fee: None,
+ signed_auth_entry: None,
};
let request = NetworkTransactionRequest::Stellar(stellar_request);
@@ -3211,6 +3261,7 @@ mod tests {
transaction_xdr,
fee_bump: None,
max_fee: None,
+ signed_auth_entry: 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..cc20c67fc 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
@@ -25,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 {
@@ -32,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;
@@ -42,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) => {
@@ -64,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(())
}
}
@@ -107,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();
@@ -130,6 +156,7 @@ mod tests {
transaction_xdr: None,
fee_bump: None,
max_fee: None,
+ signed_auth_entry: None,
};
let result = req.validate();
@@ -157,6 +184,7 @@ mod tests {
transaction_xdr: None,
fee_bump: Some(true),
max_fee: None,
+ signed_auth_entry: None,
};
let result = req.validate();
@@ -180,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();
@@ -203,6 +232,7 @@ mod tests {
transaction_xdr: None,
fee_bump: None,
max_fee: None,
+ signed_auth_entry: None,
};
let result = req.validate();
@@ -222,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();
@@ -241,6 +272,7 @@ mod tests {
transaction_xdr: None,
fee_bump: None,
max_fee: None,
+ signed_auth_entry: None,
};
assert_eq!(
@@ -254,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]
@@ -318,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/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/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/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}"
)));
}
diff --git a/src/services/mod.rs b/src/services/mod.rs
index 935323be5..7e5c6ac2e 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/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..5c0c3ec5f 100644
--- a/src/services/signer/stellar/local_signer.rs
+++ b/src/services/signer/stellar/local_signer.rs
@@ -32,8 +32,7 @@ use eyre::Result;
use sha2::{Digest, Sha256};
use soroban_rs::xdr::{
DecoratedSignature, Hash, Limits, ReadXdr, Signature, SignatureHint, Transaction,
- TransactionEnvelope, TransactionSignaturePayload, TransactionSignaturePayloadTaggedTransaction,
- Uint256, VecM, WriteXdr,
+ TransactionEnvelope, Uint256, WriteXdr,
};
use tracing::info;
@@ -132,7 +131,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_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..40bf536bc
--- /dev/null
+++ b/src/services/stellar_dex/soroswap_service.rs
@@ -0,0 +1,1623 @@
+//! 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::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;
+use async_trait::async_trait;
+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, info, warn};
+
+/// Transaction validity window in minutes
+const TRANSACTION_VALIDITY_MINUTES: i64 = 5;
+
+/// 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,
+ /// 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
+ 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
+ /// * `factory_address` - Soroswap factory contract address (required for get_amounts_out)
+ /// * `native_wrapper_address` - Native XLM wrapper token address
+ /// * `provider` - Stellar provider for contract calls
+ /// * `network_passphrase` - Network passphrase
+ pub fn new(
+ router_address: String,
+ factory_address: String,
+ native_wrapper_address: String,
+ provider: Arc
,
+ network_passphrase: String,
+ ) -> Self {
+ Self {
+ router_address,
+ factory_address,
+ native_wrapper_address,
+ provider,
+ network_passphrase,
+ }
+ }
+
+ /// Parse a Soroban contract address (C...) to ScAddress
+ 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)))
+ }
+
+ /// Build a Vec path for router calls
+ fn build_path(
+ &self,
+ from_token: &str,
+ to_token: &str,
+ ) -> Result {
+ 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)]
+ .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
+ /// Soroswap's get_amounts_out requires: (factory_address, amount_in, 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())
+ })?;
+
+ // Soroswap's get_amounts_out requires factory address as first argument
+ 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),
+ path,
+ ];
+
+ debug!(
+ router = %self.router_address,
+ factory = %self.factory_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)
+ }
+
+ /// 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]
+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"
+ ))
+ })?;
+
+ 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: 0.0,
+ 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> {
+ // Get a quote for the swap
+ 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(),
+ ));
+ };
+
+ 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)?;
+
+ info!(
+ "Successfully prepared Soroswap swap transaction XDR ({} bytes)",
+ xdr.len()
+ );
+
+ 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 {
+ 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::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,
+ ) -> SoroswapService {
+ SoroswapService::new(
+ "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC".to_string(), // router
+ "CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA".to_string(), // factory
+ TEST_NATIVE_WRAPPER.to_string(),
+ provider,
+ "Test SDF Network ; September 2015".to_string(),
+ )
+ }
+
+ // ==================== Constructor Tests ====================
+
+ #[test]
+ fn test_new_stores_provided_native_wrapper() {
+ let provider = create_mock_provider();
+ let service = create_test_service(provider);
+ assert_eq!(service.native_wrapper_address, TEST_NATIVE_WRAPPER);
+ }
+
+ #[test]
+ fn test_new_with_mainnet_native_wrapper() {
+ let provider = create_mock_provider();
+ 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
+ );
+ }
+
+ #[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(),
+ custom_wrapper.clone(),
+ provider,
+ "Test SDF Network ; September 2015".to_string(),
+ );
+ assert_eq!(service.native_wrapper_address, custom_wrapper);
+ }
+
+ // ==================== parse_contract_to_sc_address Tests ====================
+
+ #[test]
+ fn test_parse_contract_to_sc_address_valid() {
+ let addr = "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC";
+ let result =
+ SoroswapService::::parse_contract_to_sc_address(addr);
+ assert!(result.is_ok());
+ match result.unwrap() {
+ ScAddress::Contract(_) => {}
+ _ => panic!("Expected Contract address"),
+ }
+ }
+
+ #[test]
+ fn test_parse_contract_to_sc_address_invalid_format() {
+ let addr = "INVALID_ADDRESS";
+ let result =
+ SoroswapService::::parse_contract_to_sc_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_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_to_sc_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);
+ 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);
+ assert!(service.can_handle_asset(""));
+ }
+
+ #[test]
+ fn test_can_handle_asset_valid_contract() {
+ let provider = create_mock_provider();
+ let service = create_test_service(provider);
+ let contract_addr = "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC";
+ 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);
+ let classic_asset = "USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN";
+ 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);
+ 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);
+ // 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);
+ // 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);
+ 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_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();
+ 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);
+ 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);
+ 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);
+ 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);
+
+ 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);
+
+ 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);
+
+ 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);
+
+ 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);
+
+ 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);
+
+ 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);
+
+ 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);
+
+ 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);
+
+ 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);
+
+ 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);
+
+ 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());
+ 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]
+ 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);
+
+ 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());
+ 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]
+ async fn test_prepare_swap_transaction_token_to_token_not_supported() {
+ let provider = create_mock_provider();
+ let service = create_test_service(provider);
+
+ 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),
+ }
+ }
+
+ // ==================== 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);
+
+ 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);
+
+ 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);
+
+ 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);
+
+ // 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]
+ async fn test_execute_swap_not_implemented() {
+ let provider = create_mock_provider();
+ let service = create_test_service(provider);
+
+ 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);
+
+ let quote = service
+ .get_token_to_xlm_quote(
+ "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC",
+ 1_000_000,
+ 0.5,
+ None,
+ )
+ .await
+ .unwrap();
+
+ // 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_dex/stellar_dex_service.rs b/src/services/stellar_dex/stellar_dex_service.rs
index 58680781c..a31194033 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,499 @@ 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,
+ }
+ }
+}
+
+#[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(),
+ "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC".to_string(),
+ provider,
+ "Test SDF Network ; September 2015".to_string(),
+ ))
+ }
+
+ /// 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"),
}
}
}
diff --git a/src/services/stellar_fee_forwarder/mod.rs b/src/services/stellar_fee_forwarder/mod.rs
new file mode 100644
index 000000000..f4c35c9c6
--- /dev/null
+++ b/src/services/stellar_fee_forwarder/mod.rs
@@ -0,0 +1,1181 @@
+//! 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(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,
+ ScSymbol, ScVal, ScVec, SorobanAddressCredentials, SorobanAuthorizationEntry,
+ SorobanAuthorizedFunction, SorobanAuthorizedInvocation, SorobanCredentials, VecM, WriteXdr,
+};
+use std::sync::Arc;
+use thiserror::Error;
+
+/// 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)]
+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(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),
+ ]
+ .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 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: user_auth_args.into(),
+ }),
+ sub_invocations: sub_invocations.try_into().map_err(|_| {
+ FeeForwarderError::AuthorizationBuildError(
+ "Failed to create sub-invocations".to_string(),
+ )
+ })?,
+ };
+
+ // Generate nonce using timestamp combined with randomness for uniqueness
+ let nonce = {
+ use rand::Rng;
+ use std::time::{SystemTime, UNIX_EPOCH};
+ let timestamp = SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .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)
+ // 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: 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 combined with randomness for uniqueness
+ let nonce = {
+ use rand::Rng;
+ use std::time::{SystemTime, UNIX_EPOCH};
+ let timestamp = SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .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)
+ // 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 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,
+ ) -> 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())
+ })?;
+
+ // Relayer signs all 9 parameters
+ 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 / STELLAR_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(user, 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(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),
+ ]
+ .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 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: user_auth_args.into(),
+ }),
+ sub_invocations: sub_invocations.try_into().map_err(|_| {
+ FeeForwarderError::AuthorizationBuildError(
+ "Failed to create sub-invocations".to_string(),
+ )
+ })?,
+ };
+
+ // Build the authorization entry
+ // 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: empty_signature,
+ }),
+ root_invocation,
+ })
+ }
+
+ /// Generate a nonce for authorization
+ ///
+ /// The nonce should be unique per authorization to prevent replay attacks.
+ /// Combines timestamp with randomness for improved uniqueness.
+ fn generate_nonce(&self) -> i64 {
+ use rand::Rng;
+ use std::time::{SystemTime, UNIX_EPOCH};
+ let timestamp = SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .map(|d| d.as_nanos() as i64)
+ .unwrap_or(0);
+ let random: i64 = rand::rng().random();
+ timestamp ^ random
+ }
+
+ /// 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