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:?}"), + } } }