diff --git a/src/domain/transaction/stellar/prepare/common.rs b/src/domain/transaction/stellar/prepare/common.rs
index 3b6c19719..693f85d7e 100644
--- a/src/domain/transaction/stellar/prepare/common.rs
+++ b/src/domain/transaction/stellar/prepare/common.rs
@@ -12,7 +12,8 @@ use crate::{
domain::{
stellar::i64_from_u64,
xdr_utils::{
- extract_soroban_resource_fee, update_xdr_fee, update_xdr_sequence, xdr_needs_simulation,
+ extract_operations, extract_soroban_resource_fee, update_xdr_fee, update_xdr_sequence,
+ xdr_needs_simulation,
},
SignTransactionResponse,
},
@@ -203,12 +204,15 @@ pub async fn ensure_minimum_fee(
Ok(())
}
-/// Calculate the required fee for a fee-bump transaction.
+/// Calculate the outer fee-bump fee bid for a transaction.
///
-/// For Soroban transactions, this includes both the inclusion fee and resource fee.
-/// If the inner transaction already has SorobanTransactionData with a resource fee,
-/// that fee is used instead of re-simulating (useful for pre-simulated signed transactions).
-/// For regular transactions, it uses the provided max_fee.
+/// Per CAP-0015, the fee-bump fee must cover `inner_num_ops + 1` operations and
+/// the fee rate must be at least the inner transaction's fee rate. For Soroban,
+/// the resource fee is carried once and only the inclusion-fee rate scales with
+/// the extra fee-bump operation.
+///
+/// Returns `max_fee` as u32 when sufficient, or an error if it falls below the
+/// required fee-bump minimum.
pub async fn calculate_fee_bump_required_fee
(
inner_envelope: &TransactionEnvelope,
max_fee: i64,
@@ -217,70 +221,125 @@ pub async fn calculate_fee_bump_required_fee
(
where
P: StellarProviderTrait + Send + Sync,
{
+ let inner_num_ops = extract_operations(inner_envelope)
+ .map(|ops| ops.len() as i64)
+ .unwrap_or(1)
+ .max(1);
+ let fee_bump_num_ops = inner_num_ops + 1; // CAP-0015
+
+ let inner_tx_fee = extract_inner_transaction_fee(inner_envelope);
+
// Check if the inner transaction already has SorobanTransactionData with resource fee.
// This allows skipping simulation for pre-simulated signed transactions.
if let Some(existing_resource_fee) = extract_soroban_resource_fee(inner_envelope) {
- let inclusion_fee = STELLAR_DEFAULT_TRANSACTION_FEE as i64;
- let required_fee = inclusion_fee.checked_add(existing_resource_fee).ok_or_else(|| {
- TransactionError::ValidationError(format!(
- "Fee overflow: inclusion fee ({inclusion_fee}) + resource fee ({existing_resource_fee}) exceeds i64::MAX"
- ))
- })?;
+ let required_fee = calculate_fee_bump_fee(
+ inner_tx_fee,
+ existing_resource_fee,
+ inner_num_ops,
+ fee_bump_num_ops,
+ )?;
debug!(
- "Using existing resource fee from inner transaction. \
- Inclusion fee: {}, Resource fee: {}, Total: {}",
- inclusion_fee, existing_resource_fee, required_fee
+ "Using existing Soroban resource fee for fee-bump calculation. \
+ Inner tx fee: {}, Resource fee: {}, Fee-bump num_ops: {} (inner: {}), \
+ Required fee-bump fee: {}",
+ inner_tx_fee, existing_resource_fee, fee_bump_num_ops, inner_num_ops, required_fee
);
- // Validate max_fee covers the existing fee
- if max_fee < required_fee {
- return Err(TransactionError::ValidationError(format!(
- "max_fee ({max_fee}) is insufficient. Required fee: {required_fee} \
- (inclusion: {inclusion_fee} + resource: {existing_resource_fee})"
- )));
+ return validated_max_fee_as_u32(max_fee, required_fee);
+ }
+
+ let resource_fee = match simulate_if_needed(inner_envelope, provider).await? {
+ Some(sim_resp) => {
+ let resource_fee = i64::try_from(sim_resp.min_resource_fee).map_err(|_| {
+ TransactionError::ValidationError(format!(
+ "Resource fee conversion overflow: min_resource_fee ({}) exceeds i64::MAX",
+ sim_resp.min_resource_fee
+ ))
+ })?;
+ debug!(
+ "Simulation complete. Inner tx fee: {}, Simulated resource fee: {}, \
+ Fee-bump num_ops: {} (inner: {})",
+ inner_tx_fee, resource_fee, fee_bump_num_ops, inner_num_ops,
+ );
+ resource_fee
}
+ None => 0,
+ };
+
+ let required_fee =
+ calculate_fee_bump_fee(inner_tx_fee, resource_fee, inner_num_ops, fee_bump_num_ops)?;
+ validated_max_fee_as_u32(max_fee, required_fee)
+}
- return Ok(max_fee as u32);
+fn extract_inner_transaction_fee(inner_envelope: &TransactionEnvelope) -> i64 {
+ match inner_envelope {
+ TransactionEnvelope::TxV0(e) => i64::from(e.tx.fee),
+ TransactionEnvelope::Tx(e) => i64::from(e.tx.fee),
+ TransactionEnvelope::TxFeeBump(fb) => {
+ let soroban_rs::xdr::FeeBumpTransactionInnerTx::Tx(inner) = &fb.tx.inner_tx;
+ i64::from(inner.tx.fee)
+ }
}
+}
- // Check if the inner transaction needs simulation (Soroban operations)
- if xdr_needs_simulation(inner_envelope).unwrap_or(false) {
- debug!("Inner transaction contains Soroban operations, simulating to determine resource fee...");
+/// Compute the minimum total fee-bump fee that satisfies CAP-0015.
+///
+/// The fee-bump operation scales the inner transaction's inclusion-fee rate over
+/// `inner_num_ops + 1` operations. Soroban `resource_fee` is carried once.
+fn calculate_fee_bump_fee(
+ inner_tx_fee: i64,
+ resource_fee: i64,
+ inner_num_ops: i64,
+ fee_bump_num_ops: i64,
+) -> Result {
+ if inner_num_ops <= 0 || fee_bump_num_ops <= 0 {
+ return Err(TransactionError::ValidationError(format!(
+ "Invalid operation counts: inner_num_ops={inner_num_ops}, fee_bump_num_ops={fee_bump_num_ops}"
+ )));
+ }
+ if inner_tx_fee < 0 || resource_fee < 0 {
+ return Err(TransactionError::ValidationError(format!(
+ "Invalid fee inputs: inner_tx_fee={inner_tx_fee}, resource_fee={resource_fee}"
+ )));
+ }
- match simulate_if_needed(inner_envelope, provider).await? {
- Some(sim_resp) => {
- // Soroban transactions always have exactly one operation
- let inclusion_fee = STELLAR_DEFAULT_TRANSACTION_FEE as u64;
- let resource_fee = sim_resp.min_resource_fee;
- let required_fee = inclusion_fee + resource_fee;
+ let inner_inclusion_total = inner_tx_fee.saturating_sub(resource_fee);
+ let inner_fee_rate =
+ ceil_div(inner_inclusion_total, inner_num_ops).max(STELLAR_DEFAULT_TRANSACTION_FEE as i64);
- debug!(
- "Simulation complete. Inclusion fee: {}, Resource fee: {}, Total: {}",
- inclusion_fee, resource_fee, required_fee
- );
+ inner_fee_rate
+ .checked_mul(fee_bump_num_ops)
+ .ok_or_else(|| {
+ TransactionError::ValidationError(format!(
+ "Fee overflow computing fee-bump base fee for {inner_fee_rate} * {fee_bump_num_ops}"
+ ))
+ })?
+ .checked_add(resource_fee)
+ .ok_or_else(|| {
+ TransactionError::ValidationError(format!(
+ "Fee overflow adding resource fee ({resource_fee}) to fee-bump fee"
+ ))
+ })
+}
- // Ensure max_fee covers the required amount
- if (max_fee as u64) < required_fee {
- return Err(TransactionError::ValidationError(
- format!(
- "max_fee ({max_fee}) is insufficient. Required fee: {required_fee} (inclusion: {inclusion_fee} + resource: {resource_fee})"
- )
- ));
- }
+/// Ceiling division for a non-negative divisor.
+/// Caller must ensure `divisor > 0`.
+fn ceil_div(value: i64, divisor: i64) -> i64 {
+ (value + divisor - 1) / divisor
+}
- // Use max_fee but ensure it's at least the required amount
- Ok(max_fee as u32)
- }
- None => {
- // No simulation needed, use max_fee
- Ok(max_fee as u32)
- }
- }
- } else {
- // No simulation needed, use max_fee
- Ok(max_fee as u32)
+fn validated_max_fee_as_u32(max_fee: i64, required_fee: i64) -> Result {
+ if max_fee < required_fee {
+ return Err(TransactionError::ValidationError(format!(
+ "max_fee ({max_fee}) is insufficient. Required fee-bump fee: {required_fee}"
+ )));
}
+ u32::try_from(max_fee).map_err(|_| {
+ TransactionError::ValidationError(format!(
+ "Fee conversion overflow: max_fee ({max_fee}) exceeds u32::MAX"
+ ))
+ })
}
// Additional helper methods for transaction preparation
@@ -644,17 +703,16 @@ mod send_submit_transaction_job_tests {
#[cfg(test)]
mod calculate_fee_bump_required_fee_tests {
use super::*;
+ use crate::models::TransactionError;
use crate::services::provider::MockStellarProviderTrait;
use soroban_rs::xdr::{
Hash, HostFunction, InvokeContractArgs, InvokeHostFunctionOp, LedgerFootprint, Memo,
- MuxedAccount, Operation, OperationBody, ScAddress, SequenceNumber, SorobanResources,
- SorobanTransactionData, SorobanTransactionDataExt, Transaction, TransactionV1Envelope,
- Uint256, VecM,
+ MuxedAccount, Operation, OperationBody, PaymentOp, ScAddress, SequenceNumber,
+ SorobanResources, SorobanTransactionData, SorobanTransactionDataExt, Transaction,
+ TransactionV1Envelope, Uint256, VecM,
};
use stellar_strkey::ed25519::PublicKey;
- /// Creates a Soroban transaction envelope with existing SorobanTransactionData.
- /// Includes an InvokeHostFunction operation so xdr_needs_simulation returns true.
fn create_soroban_envelope_with_existing_data(resource_fee: i64) -> TransactionEnvelope {
let pk = PublicKey([0; 32]);
let source = MuxedAccount::Ed25519(Uint256(pk.0));
@@ -673,7 +731,6 @@ mod calculate_fee_bump_required_fee_tests {
resource_fee,
};
- // Create an InvokeHostFunction operation so xdr_needs_simulation returns true
let invoke_op = Operation {
source_account: None,
body: OperationBody::InvokeHostFunction(InvokeHostFunctionOp {
@@ -690,7 +747,7 @@ mod calculate_fee_bump_required_fee_tests {
let tx = Transaction {
source_account: source,
- fee: 100,
+ fee: u32::try_from(resource_fee + STELLAR_DEFAULT_TRANSACTION_FEE as i64).unwrap(),
seq_num: SequenceNumber(1),
cond: soroban_rs::xdr::Preconditions::None,
memo: Memo::None,
@@ -704,17 +761,28 @@ mod calculate_fee_bump_required_fee_tests {
})
}
- fn create_envelope_without_soroban_data() -> TransactionEnvelope {
+ fn create_classic_envelope(fee: u32, op_count: usize) -> TransactionEnvelope {
let pk = PublicKey([0; 32]);
let source = MuxedAccount::Ed25519(Uint256(pk.0));
+ let operations: Vec<_> = (0..op_count)
+ .map(|_| Operation {
+ source_account: None,
+ body: OperationBody::Payment(PaymentOp {
+ destination: MuxedAccount::Ed25519(Uint256([1; 32])),
+ asset: soroban_rs::xdr::Asset::Native,
+ amount: 1_000_000,
+ }),
+ })
+ .collect();
+
let tx = Transaction {
source_account: source,
- fee: 100,
+ fee,
seq_num: SequenceNumber(1),
cond: soroban_rs::xdr::Preconditions::None,
memo: Memo::None,
- operations: VecM::default(),
+ operations: operations.try_into().unwrap(),
ext: soroban_rs::xdr::TransactionExt::V0,
};
@@ -726,21 +794,15 @@ mod calculate_fee_bump_required_fee_tests {
#[tokio::test]
async fn test_calculate_fee_bump_skips_simulation_with_existing_data() {
- // Create a Soroban transaction envelope with existing SorobanTransactionData
- // This includes an InvokeHostFunction op, so xdr_needs_simulation would return true
- // if we didn't short-circuit on the existing resource fee
let resource_fee = 50000i64;
let envelope = create_soroban_envelope_with_existing_data(resource_fee);
-
- // Verify that the envelope does need simulation (has Soroban op)
assert!(xdr_needs_simulation(&envelope).unwrap());
- // Provider should NOT be called for simulation since we have existing data
- // If simulate_transaction_envelope is called, mockall will panic because
- // no expectation is set
+ // No mock expectation set — panics if simulation is called
let provider = MockStellarProviderTrait::new();
- let max_fee = 100000i64;
+ // required = 100 * 2 + 50000 = 50200
+ let max_fee = 200000i64;
let result = calculate_fee_bump_required_fee(&envelope, max_fee, &provider).await;
assert!(result.is_ok());
@@ -748,38 +810,348 @@ mod calculate_fee_bump_required_fee_tests {
}
#[tokio::test]
- async fn test_calculate_fee_bump_validates_max_fee_with_existing_data() {
- // Create a Soroban transaction envelope with existing SorobanTransactionData
+ async fn test_calculate_fee_bump_rejects_bid_that_only_covers_inner_soroban_fee() {
let resource_fee = 50000i64;
let envelope = create_soroban_envelope_with_existing_data(resource_fee);
let provider = MockStellarProviderTrait::new();
- // max_fee is less than required (inclusion_fee + resource_fee)
- // inclusion_fee = 100 (STELLAR_DEFAULT_TRANSACTION_FEE)
- // required = 100 + 50000 = 50100
- let max_fee = 40000i64; // Less than required
+ let max_fee = 50100i64; // Same as inner_fee
let result = calculate_fee_bump_required_fee(&envelope, max_fee, &provider).await;
assert!(result.is_err());
let err = result.unwrap_err();
- assert!(
- matches!(err, TransactionError::ValidationError(msg) if msg.contains("insufficient"))
- );
+ assert!(matches!(err, TransactionError::ValidationError(msg) if msg.contains("50200")));
}
#[tokio::test]
- async fn test_calculate_fee_bump_falls_back_to_max_fee_without_soroban_data() {
- // Create a transaction envelope without SorobanTransactionData (V0 ext)
- let envelope = create_envelope_without_soroban_data();
+ async fn test_calculate_fee_bump_uses_actual_inner_fee_rate_when_higher() {
+ // tx.fee diverges from sorobanData.resourceFee when assembleTransaction()
+ // sets fee = inclusionFee + minResourceFee (which can be >> sorobanData.resourceFee)
+ let resource_fee = 33048i64;
+ let mut envelope = create_soroban_envelope_with_existing_data(resource_fee);
+ match &mut envelope {
+ TransactionEnvelope::Tx(ref mut e) => e.tx.fee = 66196,
+ _ => panic!("Expected Tx envelope"),
+ }
let provider = MockStellarProviderTrait::new();
- let max_fee = 100000i64;
+ // inclusion = 66196 - 33048 = 33148, required = 33148 * 2 + 33048 = 99344
+ let max_fee = 99_344i64;
+
let result = calculate_fee_bump_required_fee(&envelope, max_fee, &provider).await;
assert!(result.is_ok());
- assert_eq!(result.unwrap(), max_fee as u32);
+ assert_eq!(result.unwrap(), 99_344u32);
+ }
+
+ #[tokio::test]
+ async fn test_calculate_fee_bump_rejects_classic_bid_below_minimum_fee_bump_fee() {
+ let envelope = create_classic_envelope(100, 1);
+
+ let provider = MockStellarProviderTrait::new();
+
+ let max_fee = 100i64;
+ let result = calculate_fee_bump_required_fee(&envelope, max_fee, &provider).await;
+
+ assert!(result.is_err());
+ let err = result.unwrap_err();
+ assert!(matches!(err, TransactionError::ValidationError(msg) if msg.contains("200")));
+ }
+
+ #[test]
+ fn test_calculate_fee_bump_fee_scales_inclusion_rate_only() {
+ let required = calculate_fee_bump_fee(25_202, 25_102, 1, 2).unwrap();
+ assert_eq!(required, 25_302);
+ }
+
+ #[test]
+ fn test_calculate_fee_bump_fee_uses_ceil_division_for_classic_multi_op() {
+ let required = calculate_fee_bump_fee(201, 0, 2, 3).unwrap();
+ assert_eq!(required, 303);
+ }
+
+ #[test]
+ fn test_calculate_fee_bump_fee_rejects_invalid_inputs() {
+ let err = calculate_fee_bump_fee(-1, 0, 1, 2).unwrap_err();
+ assert!(matches!(err, TransactionError::ValidationError(_)));
+
+ let err = calculate_fee_bump_fee(100, 0, 0, 1).unwrap_err();
+ assert!(matches!(err, TransactionError::ValidationError(_)));
+ }
+
+ fn create_soroban_envelope_without_existing_data(fee: u32) -> TransactionEnvelope {
+ let pk = PublicKey([0; 32]);
+ let source = MuxedAccount::Ed25519(Uint256(pk.0));
+
+ let invoke_op = Operation {
+ source_account: None,
+ body: OperationBody::InvokeHostFunction(InvokeHostFunctionOp {
+ host_function: HostFunction::InvokeContract(InvokeContractArgs {
+ contract_address: ScAddress::Contract(soroban_rs::xdr::ContractId(Hash(
+ [0u8; 32],
+ ))),
+ function_name: "test".try_into().unwrap(),
+ args: vec![].try_into().unwrap(),
+ }),
+ auth: vec![].try_into().unwrap(),
+ }),
+ };
+
+ let tx = Transaction {
+ source_account: source,
+ fee,
+ seq_num: SequenceNumber(1),
+ cond: soroban_rs::xdr::Preconditions::None,
+ memo: Memo::None,
+ operations: vec![invoke_op].try_into().unwrap(),
+ ext: soroban_rs::xdr::TransactionExt::V0,
+ };
+
+ TransactionEnvelope::Tx(TransactionV1Envelope {
+ tx,
+ signatures: VecM::default(),
+ })
+ }
+
+ #[tokio::test]
+ async fn test_calculate_fee_bump_simulates_when_no_existing_soroban_data() {
+ let envelope = create_soroban_envelope_without_existing_data(100);
+
+ let mut provider = MockStellarProviderTrait::new();
+ provider
+ .expect_simulate_transaction_envelope()
+ .times(1)
+ .returning(|_| {
+ Box::pin(std::future::ready(Ok(SimulateTransactionResponse {
+ error: None,
+ min_resource_fee: 50000,
+ ..Default::default()
+ })))
+ });
+
+ // required = 100 * 2 + 50000 = 50200
+ let max_fee = 200_000i64;
+ let result = calculate_fee_bump_required_fee(&envelope, max_fee, &provider).await;
+
+ assert!(result.is_ok());
+ assert_eq!(result.unwrap(), 200_000u32);
+ }
+
+ #[tokio::test]
+ async fn test_calculate_fee_bump_simulation_uses_inner_fee_rate_when_higher() {
+ let envelope = create_soroban_envelope_without_existing_data(70_000);
+
+ let mut provider = MockStellarProviderTrait::new();
+ provider
+ .expect_simulate_transaction_envelope()
+ .times(1)
+ .returning(|_| {
+ Box::pin(std::future::ready(Ok(SimulateTransactionResponse {
+ error: None,
+ min_resource_fee: 30_000,
+ ..Default::default()
+ })))
+ });
+
+ // inclusion = 70000 - 30000 = 40000, required = 40000 * 2 + 30000 = 110000
+ let max_fee = 110_000i64;
+ let result = calculate_fee_bump_required_fee(&envelope, max_fee, &provider).await;
+
+ assert!(result.is_ok());
+ assert_eq!(result.unwrap(), 110_000u32);
+ }
+
+ #[tokio::test]
+ async fn test_calculate_fee_bump_simulation_required_fee_less_than_max_fee() {
+ let envelope = create_soroban_envelope_without_existing_data(100);
+
+ let mut provider = MockStellarProviderTrait::new();
+ provider
+ .expect_simulate_transaction_envelope()
+ .times(1)
+ .returning(|_| {
+ Box::pin(std::future::ready(Ok(SimulateTransactionResponse {
+ error: None,
+ min_resource_fee: 5_000,
+ ..Default::default()
+ })))
+ });
+
+ // required = 100 * 2 + 5000 = 5200
+ let max_fee = 500_000i64;
+ let result = calculate_fee_bump_required_fee(&envelope, max_fee, &provider).await;
+
+ assert!(result.is_ok());
+ assert_eq!(result.unwrap(), 500_000u32);
+ }
+
+ #[tokio::test]
+ async fn test_calculate_fee_bump_simulation_failure_propagates_error() {
+ let envelope = create_soroban_envelope_without_existing_data(100);
+
+ let mut provider = MockStellarProviderTrait::new();
+ provider
+ .expect_simulate_transaction_envelope()
+ .times(1)
+ .returning(|_| {
+ Box::pin(std::future::ready(Ok(SimulateTransactionResponse {
+ error: Some("Insufficient resources for operation".to_string()),
+ min_resource_fee: 0,
+ ..Default::default()
+ })))
+ });
+
+ let max_fee = 200_000i64;
+ let result = calculate_fee_bump_required_fee(&envelope, max_fee, &provider).await;
+
+ assert!(result.is_err());
+ match result.unwrap_err() {
+ TransactionError::SimulationFailed(msg) => {
+ assert!(msg.contains("Insufficient resources"));
+ }
+ other => panic!("Expected SimulationFailed, got: {other:?}"),
+ }
+ }
+}
+
+#[cfg(test)]
+mod simulate_if_needed_tests {
+ use super::*;
+ use crate::services::provider::MockStellarProviderTrait;
+ use soroban_rs::xdr::{
+ Hash, HostFunction, InvokeContractArgs, InvokeHostFunctionOp, Memo, MuxedAccount,
+ Operation, OperationBody, ScAddress, SequenceNumber, Transaction, TransactionV1Envelope,
+ Uint256, VecM,
+ };
+ use stellar_strkey::ed25519::PublicKey;
+
+ fn create_non_soroban_envelope() -> TransactionEnvelope {
+ let pk = PublicKey([0; 32]);
+ let source = MuxedAccount::Ed25519(Uint256(pk.0));
+
+ let payment_op = Operation {
+ source_account: None,
+ body: OperationBody::Payment(soroban_rs::xdr::PaymentOp {
+ destination: MuxedAccount::Ed25519(Uint256([0; 32])),
+ asset: soroban_rs::xdr::Asset::Native,
+ amount: 1_000_000,
+ }),
+ };
+
+ let tx = Transaction {
+ source_account: source,
+ fee: 100,
+ seq_num: SequenceNumber(1),
+ cond: soroban_rs::xdr::Preconditions::None,
+ memo: Memo::None,
+ operations: vec![payment_op].try_into().unwrap(),
+ ext: soroban_rs::xdr::TransactionExt::V0,
+ };
+
+ TransactionEnvelope::Tx(TransactionV1Envelope {
+ tx,
+ signatures: VecM::default(),
+ })
+ }
+
+ fn create_soroban_envelope() -> TransactionEnvelope {
+ let pk = PublicKey([0; 32]);
+ let source = MuxedAccount::Ed25519(Uint256(pk.0));
+
+ let invoke_op = Operation {
+ source_account: None,
+ body: OperationBody::InvokeHostFunction(InvokeHostFunctionOp {
+ host_function: HostFunction::InvokeContract(InvokeContractArgs {
+ contract_address: ScAddress::Contract(soroban_rs::xdr::ContractId(Hash(
+ [0u8; 32],
+ ))),
+ function_name: "test".try_into().unwrap(),
+ args: vec![].try_into().unwrap(),
+ }),
+ auth: vec![].try_into().unwrap(),
+ }),
+ };
+
+ let tx = Transaction {
+ source_account: source,
+ fee: 100,
+ seq_num: SequenceNumber(1),
+ cond: soroban_rs::xdr::Preconditions::None,
+ memo: Memo::None,
+ operations: vec![invoke_op].try_into().unwrap(),
+ ext: soroban_rs::xdr::TransactionExt::V0,
+ };
+
+ TransactionEnvelope::Tx(TransactionV1Envelope {
+ tx,
+ signatures: VecM::default(),
+ })
+ }
+
+ #[tokio::test]
+ async fn test_returns_none_for_non_soroban_transaction() {
+ let envelope = create_non_soroban_envelope();
+
+ // Provider should NOT be called; no expectations set → panics if called
+ let provider = MockStellarProviderTrait::new();
+
+ let result = simulate_if_needed(&envelope, &provider).await;
+
+ assert!(result.is_ok());
+ assert!(result.unwrap().is_none());
+ }
+
+ #[tokio::test]
+ async fn test_returns_response_on_successful_simulation() {
+ let envelope = create_soroban_envelope();
+
+ let mut provider = MockStellarProviderTrait::new();
+ provider
+ .expect_simulate_transaction_envelope()
+ .times(1)
+ .returning(|_| {
+ Box::pin(std::future::ready(Ok(SimulateTransactionResponse {
+ error: None,
+ min_resource_fee: 42_000,
+ ..Default::default()
+ })))
+ });
+
+ let result = simulate_if_needed(&envelope, &provider).await;
+
+ assert!(result.is_ok());
+ let resp = result.unwrap().expect("Expected Some(response)");
+ assert_eq!(resp.min_resource_fee, 42_000);
+ assert!(resp.error.is_none());
+ }
+
+ #[tokio::test]
+ async fn test_returns_error_on_simulation_failure() {
+ let envelope = create_soroban_envelope();
+
+ let mut provider = MockStellarProviderTrait::new();
+ provider
+ .expect_simulate_transaction_envelope()
+ .times(1)
+ .returning(|_| {
+ Box::pin(std::future::ready(Ok(SimulateTransactionResponse {
+ error: Some("tx simulation failed".to_string()),
+ min_resource_fee: 0,
+ ..Default::default()
+ })))
+ });
+
+ let result = simulate_if_needed(&envelope, &provider).await;
+
+ assert!(result.is_err());
+ match result.unwrap_err() {
+ TransactionError::SimulationFailed(msg) => {
+ assert_eq!(msg, "tx simulation failed");
+ }
+ other => panic!("Expected SimulationFailed, got: {other:?}"),
+ }
}
}