From 9a183fe3e8e08ce7b53d648abea54d588b43485d Mon Sep 17 00:00:00 2001 From: mootz12 Date: Mon, 9 Feb 2026 17:26:38 -0500 Subject: [PATCH 1/5] chore: combine contract sim. sign, and send tx code --- .../src/commands/contract/deploy/asset.rs | 20 +----- .../src/commands/contract/deploy/wasm.rs | 27 +------ .../src/commands/contract/extend.rs | 24 +++---- .../src/commands/contract/invoke.rs | 32 ++------- .../src/commands/contract/restore.rs | 20 +++--- .../src/commands/contract/upload.rs | 26 +++---- cmd/soroban-cli/src/tx.rs | 71 +++++++++++++++++++ 7 files changed, 110 insertions(+), 110 deletions(-) diff --git a/cmd/soroban-cli/src/commands/contract/deploy/asset.rs b/cmd/soroban-cli/src/commands/contract/deploy/asset.rs index 0864952dcd..dfd8088229 100644 --- a/cmd/soroban-cli/src/commands/contract/deploy/asset.rs +++ b/cmd/soroban-cli/src/commands/contract/deploy/asset.rs @@ -1,5 +1,6 @@ use crate::config::locator; use crate::print::Print; +use crate::tx::sim_sign_and_send_tx; use crate::xdr::{ Asset, ContractDataDurability, ContractExecutable, ContractIdPreimage, CreateContractArgs, Error as XdrError, Hash, HostFunction, InvokeHostFunctionOp, LedgerKey::ContractData, @@ -12,7 +13,6 @@ use std::{array::TryFromSliceError, fmt::Debug, num::ParseIntError}; use crate::commands::tx::fetch; use crate::{ - assembled::simulate_and_assemble_transaction, commands::{ global, txn_result::{TxnEnvelopeResult, TxnResult}, @@ -168,25 +168,9 @@ impl Cmd { return Ok(TxnResult::Txn(Box::new(tx))); } - let assembled = simulate_and_assemble_transaction( - &client, - &tx, - self.resources.resource_config(), - self.resources.resource_fee, - ) - .await?; - let assembled = self.resources.apply_to_assembled_txn(assembled); - let txn = assembled.transaction().clone(); - let get_txn_resp = client - .send_transaction_polling(&self.config.sign(txn, quiet).await?) + sim_sign_and_send_tx::(&client, &tx, config, &self.resources, &[], quiet, no_cache) .await?; - self.resources.print_cost_info(&get_txn_resp)?; - - if !no_cache { - data::write(get_txn_resp.clone().try_into()?, &network.rpc_uri()?)?; - } - Ok(TxnResult::Res(stellar_strkey::Contract(contract_id.0))) } } diff --git a/cmd/soroban-cli/src/commands/contract/deploy/wasm.rs b/cmd/soroban-cli/src/commands/contract/deploy/wasm.rs index 7bab1a3bde..d09455fbf2 100644 --- a/cmd/soroban-cli/src/commands/contract/deploy/wasm.rs +++ b/cmd/soroban-cli/src/commands/contract/deploy/wasm.rs @@ -9,6 +9,7 @@ use soroban_spec_tools::contract as contract_spec; use crate::commands::contract::deploy::utils::alias_validator; use crate::resources; +use crate::tx::sim_sign_and_send_tx; use crate::xdr::{ AccountId, ContractExecutable, ContractIdPreimage, ContractIdPreimageFromAddress, CreateContractArgs, CreateContractArgsV2, Error as XdrError, Hash, HostFunction, @@ -19,7 +20,6 @@ use crate::xdr::{ use crate::commands::tx::fetch; use crate::{ - assembled::simulate_and_assemble_transaction, commands::{ contract::{self, arg_parsing, build, id::wasm::get_contract_id, upload}, global, @@ -415,29 +415,8 @@ impl Cmd { return Ok(TxnResult::Txn(txn)); } - print.infoln("Simulating deploy transaction…"); - - let assembled = simulate_and_assemble_transaction( - &client, - &txn, - self.resources.resource_config(), - self.resources.resource_fee, - ) - .await?; - let assembled = self.resources.apply_to_assembled_txn(assembled); - let txn = Box::new(assembled.transaction().clone()); - - print.log_transaction(&txn, &network, true)?; - let signed_txn = &config.sign(*txn, quiet).await?; - print.globeln("Submitting deploy transaction…"); - - let get_txn_resp = client.send_transaction_polling(signed_txn).await?; - - self.resources.print_cost_info(&get_txn_resp)?; - - if !no_cache { - data::write(get_txn_resp.clone().try_into()?, &network.rpc_uri()?)?; - } + sim_sign_and_send_tx::(&client, &txn, config, &self.resources, &[], quiet, no_cache) + .await?; if let Some(url) = utils::lab_url_for_contract(&network, &contract_id) { print.linkln(url); diff --git a/cmd/soroban-cli/src/commands/contract/extend.rs b/cmd/soroban-cli/src/commands/contract/extend.rs index 874c7db517..c473865fb8 100644 --- a/cmd/soroban-cli/src/commands/contract/extend.rs +++ b/cmd/soroban-cli/src/commands/contract/extend.rs @@ -4,6 +4,7 @@ use crate::{ log::extract_events, print::Print, resources, + tx::sim_sign_and_send_tx, xdr::{ ConfigSettingEntry, ConfigSettingId, Error as XdrError, ExtendFootprintTtlOp, ExtensionPoint, LedgerEntry, LedgerEntryChange, LedgerEntryData, LedgerFootprint, @@ -17,7 +18,6 @@ use clap::Parser; use crate::commands::tx::fetch; use crate::{ - assembled::simulate_and_assemble_transaction, commands::{ global, txn_result::{TxnEnvelopeResult, TxnResult}, @@ -236,24 +236,18 @@ impl Cmd { if self.build_only { return Ok(TxnResult::Txn(tx)); } - let assembled = simulate_and_assemble_transaction( + + let res = sim_sign_and_send_tx::( &client, &tx, - self.resources.resource_config(), - self.resources.resource_fee, + config, + &self.resources, + &[], + quiet, + no_cache, ) .await?; - let tx = assembled.transaction().clone(); - let res = client - .send_transaction_polling(&config.sign(tx, quiet).await?) - .await?; - self.resources.print_cost_info(&res)?; - - if !no_cache { - data::write(res.clone().try_into()?, &network.rpc_uri()?)?; - } - let meta = res.result_meta.ok_or(Error::MissingOperationResult)?; let events = extract_events(&meta); @@ -292,7 +286,7 @@ impl Cmd { return Ok(TxnResult::Res(extension)); } - match (&changes[0], &changes[1]) { + match (&changes.0[0], &changes.0[1]) { ( LedgerEntryChange::State(_), LedgerEntryChange::Updated(LedgerEntry { diff --git a/cmd/soroban-cli/src/commands/contract/invoke.rs b/cmd/soroban-cli/src/commands/contract/invoke.rs index 834f37718f..3fd242b1dd 100644 --- a/cmd/soroban-cli/src/commands/contract/invoke.rs +++ b/cmd/soroban-cli/src/commands/contract/invoke.rs @@ -15,6 +15,7 @@ use crate::assembled::Assembled; use crate::commands::tx::fetch; use crate::log::extract_events; use crate::print::Print; +use crate::tx::sim_sign_and_send_tx; use crate::utils::deprecate_message; use crate::{ assembled::simulate_and_assemble_transaction, @@ -352,35 +353,16 @@ impl Cmd { return Ok(TxnResult::Txn(tx)); } - let txn = simulate_and_assemble_transaction( + let res = sim_sign_and_send_tx::( &client, &tx, - self.resources.resource_config(), - self.resources.resource_fee, + config, + &self.resources, + &signers, + quiet, + no_cache, ) .await?; - let assembled = self.resources.apply_to_assembled_txn(txn); - let mut txn = Box::new(assembled.transaction().clone()); - let sim_res = assembled.sim_response(); - - if !no_cache { - data::write(sim_res.clone().into(), &network.rpc_uri()?)?; - } - - // Need to sign all auth entries - if let Some(tx) = config.sign_soroban_authorizations(&txn, &signers).await? { - *txn = tx; - } - - let res = client - .send_transaction_polling(&config.sign(*txn, quiet).await?) - .await?; - - self.resources.print_cost_info(&res)?; - - if !no_cache { - data::write(res.clone().try_into()?, &network.rpc_uri()?)?; - } let return_value = res.return_value()?; let events = extract_events(&res.result_meta.unwrap_or_default()); diff --git a/cmd/soroban-cli/src/commands/contract/restore.rs b/cmd/soroban-cli/src/commands/contract/restore.rs index d39f0a626e..54d72abf10 100644 --- a/cmd/soroban-cli/src/commands/contract/restore.rs +++ b/cmd/soroban-cli/src/commands/contract/restore.rs @@ -2,6 +2,7 @@ use std::{fmt::Debug, path::Path, str::FromStr}; use crate::{ log::extract_events, + tx::sim_sign_and_send_tx, xdr::{ Error as XdrError, ExtensionPoint, LedgerEntry, LedgerEntryChange, LedgerEntryData, LedgerFootprint, Limits, Memo, Operation, OperationBody, Preconditions, RestoreFootprintOp, @@ -15,7 +16,6 @@ use stellar_strkey::DecodeError; use crate::commands::tx::fetch; use crate::{ - assembled::simulate_and_assemble_transaction, commands::{ contract::extend, global, @@ -203,22 +203,18 @@ impl Cmd { if self.build_only { return Ok(TxnResult::Txn(tx)); } - let assembled = simulate_and_assemble_transaction( + + let res = sim_sign_and_send_tx::( &client, &tx, - self.resources.resource_config(), - self.resources.resource_fee, + config, + &self.resources, + &[], + quiet, + no_cache, ) .await?; - let tx = assembled.transaction().clone(); - let res = client - .send_transaction_polling(&config.sign(tx, quiet).await?) - .await?; - self.resources.print_cost_info(&res)?; - if !no_cache { - data::write(res.clone().try_into()?, &network.rpc_uri()?)?; - } let meta = res .result_meta .as_ref() diff --git a/cmd/soroban-cli/src/commands/contract/upload.rs b/cmd/soroban-cli/src/commands/contract/upload.rs index cb5a8e5b4f..f6ef295b9d 100644 --- a/cmd/soroban-cli/src/commands/contract/upload.rs +++ b/cmd/soroban-cli/src/commands/contract/upload.rs @@ -13,7 +13,6 @@ use clap::Parser; use super::{build, restore}; use crate::commands::tx::fetch; use crate::{ - assembled::simulate_and_assemble_transaction, commands::{ global, txn_result::{TxnEnvelopeResult, TxnResult}, @@ -22,7 +21,10 @@ use crate::{ key, print::Print, rpc, - tx::builder::{self, TxExt}, + tx::{ + builder::{self, TxExt}, + sim_sign_and_send_tx, + }, utils, wasm, }; @@ -291,24 +293,16 @@ impl Cmd { print.infoln("Simulating install transaction…"); - let assembled = simulate_and_assemble_transaction( + let txn_resp = sim_sign_and_send_tx::( &client, &tx_without_preflight, - self.resources.resource_config(), - self.resources.resource_fee, + config, + &self.resources, + &[], + quiet, + no_cache, ) .await?; - let assembled = self.resources.apply_to_assembled_txn(assembled); - let txn = Box::new(assembled.transaction().clone()); - let signed_txn = &self.config.sign(*txn, quiet).await?; - - print.globeln("Submitting install transaction…"); - let txn_resp = client.send_transaction_polling(signed_txn).await?; - self.resources.print_cost_info(&txn_resp)?; - - if !no_cache { - data::write(txn_resp.clone().try_into().unwrap(), &network.rpc_uri()?)?; - } // Currently internal errors are not returned if the contract code is expired if let Some(TransactionResult { diff --git a/cmd/soroban-cli/src/tx.rs b/cmd/soroban-cli/src/tx.rs index 940b673058..6c485832e8 100644 --- a/cmd/soroban-cli/src/tx.rs +++ b/cmd/soroban-cli/src/tx.rs @@ -1,4 +1,75 @@ +use std::str::FromStr; + +use crate::{ + assembled::simulate_and_assemble_transaction, + commands::tx::fetch, + config::{self, data, network}, + resources, + signer::Signer, + xdr::{self, Transaction}, +}; +use soroban_rpc::GetTransactionResponse; +use url::Url; + pub mod builder; /// 10,000,000 stroops in 1 XLM pub const ONE_XLM: i64 = 10_000_000; + +/// Simulates, signs, and sends a transaction to the network. +/// +/// Returns the `GetTransactionResponse` from the network. +pub async fn sim_sign_and_send_tx( + client: &soroban_rpc::Client, + tx: &Transaction, + config: &config::Args, + resources: &resources::Args, + auth_signers: &[Signer], + quiet: bool, + no_cache: bool, +) -> Result +where + E: From + + From + + From + + From + + From + + From, +{ + let txn = simulate_and_assemble_transaction( + client, + tx, + resources.resource_config(), + resources.resource_fee, + ) + .await?; + let assembled = resources.apply_to_assembled_txn(txn); + let mut txn = Box::new(assembled.transaction().clone()); + let sim_res = assembled.sim_response(); + + let rpc_uri = Url::from_str(client.base_url()) + .map_err(|_| config::network::Error::InvalidUrl(client.base_url().to_string()))?; + if !no_cache { + data::write(sim_res.clone().into(), &rpc_uri)?; + } + + // Need to sign all auth entries + if let Some(tx) = config + .sign_soroban_authorizations(&txn, auth_signers) + .await? + { + *txn = tx; + } + + let res = client + .send_transaction_polling(&config.sign(*txn, quiet).await?) + .await?; + + resources.print_cost_info(&res)?; + + if !no_cache { + data::write(res.clone().try_into()?, &rpc_uri)?; + } + + Ok(res) +} From aa80323efc809e308b5166691e90052dfb3c85ba Mon Sep 17 00:00:00 2001 From: mootz12 Date: Tue, 10 Feb 2026 09:11:35 -0500 Subject: [PATCH 2/5] fix: add fee bump wrapping logic to submission flow --- .../soroban-test/tests/it/integration.rs | 3 +- .../tests/it/integration/fee_stats.rs | 32 --- .../it/integration/{fee_args.rs => fees.rs} | 75 +++++- cmd/soroban-cli/src/assembled.rs | 234 ++++++++++-------- .../src/commands/contract/extend.rs | 2 +- cmd/soroban-cli/src/commands/tx/simulate.rs | 15 +- cmd/soroban-cli/src/config/mod.rs | 26 +- cmd/soroban-cli/src/resources.rs | 2 +- cmd/soroban-cli/src/signer/mod.rs | 58 +++-- cmd/soroban-cli/src/tx.rs | 34 ++- cmd/soroban-cli/src/utils.rs | 16 ++ 11 files changed, 329 insertions(+), 168 deletions(-) delete mode 100644 cmd/crates/soroban-test/tests/it/integration/fee_stats.rs rename cmd/crates/soroban-test/tests/it/integration/{fee_args.rs => fees.rs} (62%) diff --git a/cmd/crates/soroban-test/tests/it/integration.rs b/cmd/crates/soroban-test/tests/it/integration.rs index 8e5cf0b9fb..7f749a7674 100644 --- a/cmd/crates/soroban-test/tests/it/integration.rs +++ b/cmd/crates/soroban-test/tests/it/integration.rs @@ -5,8 +5,7 @@ mod contract; mod cookbook; mod custom_types; mod dotenv; -mod fee_args; -mod fee_stats; +mod fees; mod hello_world; mod init; mod keys; diff --git a/cmd/crates/soroban-test/tests/it/integration/fee_stats.rs b/cmd/crates/soroban-test/tests/it/integration/fee_stats.rs deleted file mode 100644 index 61d606703b..0000000000 --- a/cmd/crates/soroban-test/tests/it/integration/fee_stats.rs +++ /dev/null @@ -1,32 +0,0 @@ -use soroban_rpc::GetFeeStatsResponse; -use soroban_test::{AssertExt, TestEnv}; - -#[tokio::test] -async fn fee_stats_text_output() { - let sandbox = &TestEnv::new(); - sandbox - .new_assert_cmd("fees") - .arg("stats") - .arg("--output") - .arg("text") - .assert() - .success() - .stdout(predicates::str::contains("Max Soroban Inclusion Fee:")) - .stdout(predicates::str::contains("Max Inclusion Fee:")) - .stdout(predicates::str::contains("Latest Ledger:")); -} - -#[tokio::test] -async fn fee_stats_json_output() { - let sandbox = &TestEnv::new(); - let output = sandbox - .new_assert_cmd("fees") - .arg("stats") - .arg("--output") - .arg("json") - .assert() - .success() - .stdout_as_str(); - let fee_stats_response: GetFeeStatsResponse = serde_json::from_str(&output).unwrap(); - assert!(matches!(fee_stats_response, GetFeeStatsResponse { .. })) -} diff --git a/cmd/crates/soroban-test/tests/it/integration/fee_args.rs b/cmd/crates/soroban-test/tests/it/integration/fees.rs similarity index 62% rename from cmd/crates/soroban-test/tests/it/integration/fee_args.rs rename to cmd/crates/soroban-test/tests/it/integration/fees.rs index 76d249b1d0..ecc76e8805 100644 --- a/cmd/crates/soroban-test/tests/it/integration/fee_args.rs +++ b/cmd/crates/soroban-test/tests/it/integration/fees.rs @@ -1,8 +1,9 @@ use predicates::prelude::predicate; use soroban_cli::xdr::{self, Limits, ReadXdr}; +use soroban_rpc::GetFeeStatsResponse; use soroban_test::{AssertExt, TestEnv}; -use super::util::deploy_hello; +use super::util::{deploy_hello, HELLO_WORLD}; fn get_inclusion_fee_from_xdr(tx_xdr: &str) -> u32 { let tx = xdr::TransactionEnvelope::from_xdr_base64(tx_xdr, Limits::none()).unwrap(); @@ -13,6 +14,36 @@ fn get_inclusion_fee_from_xdr(tx_xdr: &str) -> u32 { } } +#[tokio::test] +async fn fee_stats_text_output() { + let sandbox = &TestEnv::new(); + sandbox + .new_assert_cmd("fees") + .arg("stats") + .arg("--output") + .arg("text") + .assert() + .success() + .stdout(predicates::str::contains("Max Soroban Inclusion Fee:")) + .stdout(predicates::str::contains("Max Inclusion Fee:")) + .stdout(predicates::str::contains("Latest Ledger:")); +} + +#[tokio::test] +async fn fee_stats_json_output() { + let sandbox = &TestEnv::new(); + let output = sandbox + .new_assert_cmd("fees") + .arg("stats") + .arg("--output") + .arg("json") + .assert() + .success() + .stdout_as_str(); + let fee_stats_response: GetFeeStatsResponse = serde_json::from_str(&output).unwrap(); + assert!(matches!(fee_stats_response, GetFeeStatsResponse { .. })) +} + #[tokio::test] async fn inclusion_fee_arg() { let sandbox = &TestEnv::new(); @@ -138,3 +169,45 @@ async fn inclusion_fee_arg() { .stdout_as_str(); assert_eq!(get_inclusion_fee_from_xdr(&tx_xdr), 100u32); } + +#[tokio::test] +async fn large_fee_transactions_use_fee_bump() { + let sandbox = &TestEnv::new(); + + // install HELLO_WORLD + // don't test fee bump here as other integration tests upload WASMs, so this + // might be a no-op + let wasm_hash = sandbox + .new_assert_cmd("contract") + .arg("upload") + .arg("--wasm") + .arg(HELLO_WORLD.path().to_string_lossy().to_string()) + .assert() + .success() + .stdout_as_str(); + + // deploy HELLO_WORLD with a high inclusion fee to trigger fee-bump wrapping + let id = sandbox + .new_assert_cmd("contract") + .arg("deploy") + .args(["--wasm-hash", wasm_hash.trim()]) + .args(["--inclusion-fee", &(u32::MAX - 50).to_string()]) + .assert() + .success() + .stdout_as_str(); + + // invoke HELLO_WORLD with a high resource fee to trigger fee-bump wrapping + let std_err = sandbox + .new_assert_cmd("contract") + .arg("invoke") + .args(["--id", &id.to_string()]) + .args(["--resource-fee", &(u64::from(u32::MAX) + 1).to_string()]) + .arg("--") + .arg("inc") + .assert() + .success() + .stderr_as_str(); + + // validate log output indicates fee bump was used + assert!(std_err.contains("Signing fee bump transaction")); +} diff --git a/cmd/soroban-cli/src/assembled.rs b/cmd/soroban-cli/src/assembled.rs index 02097e8138..656c569f99 100644 --- a/cmd/soroban-cli/src/assembled.rs +++ b/cmd/soroban-cli/src/assembled.rs @@ -1,15 +1,13 @@ use sha2::{Digest, Sha256}; use stellar_xdr::curr::{ - self as xdr, ExtensionPoint, Hash, InvokeHostFunctionOp, LedgerFootprint, Limits, Memo, - Operation, OperationBody, Preconditions, ReadXdr, RestoreFootprintOp, - SorobanAuthorizationEntry, SorobanAuthorizedFunction, SorobanResources, SorobanTransactionData, - Transaction, TransactionEnvelope, TransactionExt, TransactionSignaturePayload, - TransactionSignaturePayloadTaggedTransaction, TransactionV1Envelope, VecM, WriteXdr, + self as xdr, Hash, InvokeHostFunctionOp, LedgerFootprint, Limits, Operation, OperationBody, + ReadXdr, SorobanAuthorizationEntry, SorobanAuthorizedFunction, SorobanResources, + SorobanTransactionData, Transaction, TransactionEnvelope, TransactionExt, + TransactionSignaturePayload, TransactionSignaturePayloadTaggedTransaction, + TransactionV1Envelope, VecM, WriteXdr, }; -use soroban_rpc::{ - Error, LogEvents, LogResources, ResourceConfig, RestorePreamble, SimulateTransactionResponse, -}; +use soroban_rpc::{Error, LogEvents, LogResources, ResourceConfig, SimulateTransactionResponse}; pub async fn simulate_and_assemble_transaction( client: &soroban_rpc::Client, @@ -43,6 +41,7 @@ pub async fn simulate_and_assemble_transaction( pub struct Assembled { pub(crate) txn: Transaction, pub(crate) sim_res: SimulateTransactionResponse, + pub(crate) fee_bump_fee: Option, } /// Represents an assembled transaction ready to be signed and submitted to the network. @@ -64,8 +63,7 @@ impl Assembled { sim_res: SimulateTransactionResponse, resource_fee: Option, ) -> Result { - let txn = assemble(txn, &sim_res, resource_fee)?; - Ok(Self { txn, sim_res }) + assemble(txn, sim_res, resource_fee) } /// @@ -86,17 +84,6 @@ impl Assembled { Ok(Sha256::digest(signature_payload.to_xdr(Limits::none())?).into()) } - /// Create a transaction for restoring any data in the `restore_preamble` field of the `SimulateTransactionResponse`. - /// - /// # Errors - pub fn restore_txn(&self) -> Result, Error> { - if let Some(restore_preamble) = &self.sim_res.restore_preamble { - restore(self.transaction(), restore_preamble).map(Option::Some) - } else { - Ok(None) - } - } - /// Returns a reference to the original transaction. #[must_use] pub fn transaction(&self) -> &Transaction { @@ -109,6 +96,11 @@ impl Assembled { &self.sim_res } + #[must_use] + pub fn fee_bump_fee(&self) -> Option { + self.fee_bump_fee + } + #[must_use] pub fn bump_seq_num(mut self) -> Self { self.txn.seq_num.0 += 1; @@ -161,6 +153,11 @@ impl Assembled { requires_auth(&self.txn).is_some() } + #[must_use] + pub fn requires_fee_bump(&self) -> bool { + self.fee_bump_fee.is_some() + } + #[must_use] pub fn is_view(&self) -> bool { let TransactionExt::V1(SorobanTransactionData { @@ -202,9 +199,9 @@ impl Assembled { /// # Errors fn assemble( raw: &Transaction, - simulation: &SimulateTransactionResponse, + simulation: SimulateTransactionResponse, resource_fee: Option, -) -> Result { +) -> Result { let mut tx = raw.clone(); // Right now simulate.results is one-result-per-function, and assumes there is only one @@ -268,11 +265,26 @@ fn assemble( // Update the transaction fee to be the sum of the inclusion fee and the // minimum resource fee from simulation. let total_fee: u64 = u64::from(raw.fee) + min_resource_fee; - tx.fee = u32::try_from(total_fee).map_err(|_| Error::LargeFee(total_fee))?; + let mut fee_bump_fee: Option = None; + if let Ok(tx_fee) = u32::try_from(total_fee) { + tx.fee = tx_fee; + } else { + // Transaction needs a fee bump wrapper. Set the fee to 0 and assign the required fee + // to the fee_bump_fee field, which will be used later when constructing the FeeBumpTransaction. + // => fee_bump_fee = 2 * inclusion_fee + resource_fee + tx.fee = 0; + let fee_bump_fee_u64 = total_fee + u64::from(raw.fee); + fee_bump_fee = + Some(i64::try_from(fee_bump_fee_u64).map_err(|_| Error::LargeFee(fee_bump_fee_u64))?); + } tx.operations = vec![op].try_into()?; tx.ext = TransactionExt::V1(transaction_data); - Ok(tx) + Ok(Assembled { + txn: tx, + sim_res: simulation, + fee_bump_fee, + }) } fn requires_auth(txn: &Transaction) -> Option { @@ -290,31 +302,6 @@ fn requires_auth(txn: &Transaction) -> Option { .then(move || op.clone()) } -fn restore(parent: &Transaction, restore: &RestorePreamble) -> Result { - let transaction_data = - SorobanTransactionData::from_xdr_base64(&restore.transaction_data, Limits::none())?; - let fee = u32::try_from(restore.min_resource_fee) - .map_err(|_| Error::LargeFee(restore.min_resource_fee))?; - Ok(Transaction { - source_account: parent.source_account.clone(), - fee: parent - .fee - .checked_add(fee) - .ok_or(Error::LargeFee(restore.min_resource_fee))?, - seq_num: parent.seq_num.clone(), - cond: Preconditions::None, - memo: Memo::None, - operations: vec![Operation { - source_account: None, - body: OperationBody::RestoreFootprint(RestoreFootprintOp { - ext: ExtensionPoint::V0, - }), - }] - .try_into()?, - ext: TransactionExt::V1(transaction_data), - }) -} - #[cfg(test)] mod tests { use super::*; @@ -412,29 +399,29 @@ mod tests { fn test_assemble_transaction_updates_tx_data_from_simulation_response() { let sim = simulation_response(); let txn = single_contract_fn_transaction(); - let Ok(result) = assemble(&txn, &sim, None) else { + let Ok(result) = assemble(&txn, sim, None) else { panic!("assemble failed"); }; // validate it auto updated the tx fees from sim response fees // since it was greater than tx.fee - assert_eq!(215, result.fee); + assert_eq!(215, result.txn.fee); // validate it updated sorobantransactiondata block in the tx ext - assert_eq!(TransactionExt::V1(transaction_data()), result.ext); + assert_eq!(TransactionExt::V1(transaction_data()), result.txn.ext); } #[test] fn test_assemble_transaction_adds_the_auth_to_the_host_function() { let sim = simulation_response(); let txn = single_contract_fn_transaction(); - let Ok(result) = assemble(&txn, &sim, None) else { + let Ok(result) = assemble(&txn, sim, None) else { panic!("assemble failed"); }; - assert_eq!(1, result.operations.len()); - let OperationBody::InvokeHostFunction(ref op) = result.operations[0].body else { - panic!("unexpected operation type: {:#?}", result.operations[0]); + assert_eq!(1, result.txn.operations.len()); + let OperationBody::InvokeHostFunction(ref op) = result.txn.operations[0].body else { + panic!("unexpected operation type: {:#?}", result.txn.operations[0]); }; assert_eq!(1, op.auth.len()); @@ -486,7 +473,7 @@ mod tests { let result = assemble( &txn, - &SimulateTransactionResponse { + SimulateTransactionResponse { min_resource_fee: 115, transaction_data: transaction_data().to_xdr_base64(Limits::none()).unwrap(), latest_ledger: 3, @@ -507,7 +494,7 @@ mod tests { let result = assemble( &txn, - &SimulateTransactionResponse { + SimulateTransactionResponse { min_resource_fee: 115, transaction_data: transaction_data().to_xdr_base64(Limits::none()).unwrap(), latest_ledger: 3, @@ -520,7 +507,8 @@ mod tests { Err(Error::UnexpectedSimulateTransactionResultSize { length }) => { assert_eq!(0, length); } - r => panic!("expected UnexpectedSimulateTransactionResultSize error, got: {r:#?}"), + Ok(_) => panic!("expected error, got success"), + Err(e) => panic!("expected UnexpectedSimulateTransactionResultSize error, got: {e:#?}"), } } @@ -530,52 +518,68 @@ mod tests { sim.min_resource_fee = 12345; let mut txn = single_contract_fn_transaction(); txn.fee = 10000; - let Ok(result) = assemble(&txn, &sim, None) else { + let Ok(result) = assemble(&txn, sim, None) else { panic!("assemble failed"); }; - assert_eq!(12345 + 10000, result.fee); + assert_eq!(12345 + 10000, result.txn.fee); + assert_eq!(None, result.fee_bump_fee); + // validate it updated sorobantransactiondata block in the tx ext let expected_tx_data = transaction_data(); - assert_eq!(TransactionExt::V1(expected_tx_data), result.ext); + assert_eq!(TransactionExt::V1(expected_tx_data), result.txn.ext); } #[test] - fn test_assemble_transaction_overflow_behavior() { - // - // Test two separate cases: + fn test_assemble_transaction_fee_bump_fee_behavior() { + // Test three separate cases: // // 1. Given a near-max (u32::MAX - 100) resource fee make sure the tx - // fee does not overflow after adding the base inclusion fee (100). + // does not require a fee bump after adding the base inclusion fee (100). // 2. Given a large resource fee that WILL exceed u32::MAX with the - // base inclusion fee, ensure the overflow is caught with an error - // rather than silently ignored. - let txn = single_contract_fn_transaction(); + // base inclusion fee, ensure the fee is set to zero and the correct + // fee_bump_fee is set on the Assembled struct. + // 3. Given a total fee over i64::MAX, ensure an error is returned. + let mut txn = single_contract_fn_transaction(); let mut response = simulation_response(); - // sanity check so these can be adjusted if the above helper changes - assert_eq!(txn.fee, 100, "modified txn.fee: update the math below"); + let inclusion_fee: u32 = 500; + let inclusion_fee_i64: i64 = i64::from(inclusion_fee); + txn.fee = inclusion_fee; // 1: wiggle room math overflows but result fits - response.min_resource_fee = (u32::MAX - 100).into(); + response.min_resource_fee = (u32::MAX - inclusion_fee).into(); - match assemble(&txn, &response, None) { - Ok(asstxn) => { - let expected = u32::MAX; - assert_eq!(asstxn.fee, expected); + match assemble(&txn, response.clone(), None) { + Ok(assembled) => { + assert_eq!(assembled.txn.fee, u32::MAX); + assert_eq!(assembled.fee_bump_fee, None); } - r => panic!("expected success, got: {r:#?}"), + Err(e) => panic!("expected success, got error: {e:#?}"), } - // 2: combo overflows, should throw - response.min_resource_fee = (u32::MAX - 99).into(); + // 2: combo over u32::MAX, should set fee to 0 and fee_bump_fee to total + response.min_resource_fee = (u32::MAX - inclusion_fee + 1).into(); + match assemble(&txn, response.clone(), None) { + Ok(assembled) => { + assert_eq!(assembled.txn.fee, 0); + assert_eq!( + assembled.fee_bump_fee, + Some(i64::try_from(response.min_resource_fee).unwrap() + inclusion_fee_i64 * 2) + ); + } + Err(e) => panic!("expected success, got error: {e:#?}"), + } - match assemble(&txn, &response, None) { + // 3: total fee exceeds i64::MAX, should error + response.min_resource_fee = u64::try_from(i64::MAX - (2 * inclusion_fee_i64) + 1).unwrap(); + match assemble(&txn, response, None) { Err(Error::LargeFee(fee)) => { - let expected = u64::from(u32::MAX) + 1; + let expected = i64::MAX as u64 + 1; assert_eq!(expected, fee, "expected {expected} != {fee} actual"); } - r => panic!("expected LargeFee error, got: {r:#?}"), + Ok(_) => panic!("expected error, got success"), + Err(e) => panic!("expected success, got error: {e:#?}"), } } @@ -585,18 +589,19 @@ mod tests { let mut txn = single_contract_fn_transaction(); txn.fee = 500; let resource_fee = 12345i64; - let Ok(result) = assemble(&txn, &sim, Some(resource_fee)) else { + let Ok(result) = assemble(&txn, sim, Some(resource_fee)) else { panic!("assemble failed"); }; // validate the assembled tx fee is the sum of the inclusion fee (txn.fee) // and the resource fee - assert_eq!(12345 + 500, result.fee); + assert_eq!(12345 + 500, result.txn.fee); + assert_eq!(None, result.fee_bump_fee); // validate it updated sorobantransactiondata block in the tx ext let mut expected_tx_data = transaction_data(); expected_tx_data.resource_fee = resource_fee; - assert_eq!(TransactionExt::V1(expected_tx_data), result.ext); + assert_eq!(TransactionExt::V1(expected_tx_data), result.txn.ext); } // This should never occur, as resource fee is validated before being passed into @@ -608,47 +613,60 @@ mod tests { let mut txn = single_contract_fn_transaction(); txn.fee = 500; let resource_fee = -1; - let result = assemble(&txn, &sim, Some(resource_fee)); + let result = assemble(&txn, sim, Some(resource_fee)); assert!(result.is_err()); } #[test] - fn test_assemble_transaction_with_resource_fee_overflow_behavior() { - // - // Test two separate cases: + fn test_assemble_transaction_with_resource_fee_fee_bump_behavior() { + // Test three separate cases: // // 1. Given a near-max (u32::MAX - 100) resource fee make sure the tx - // fee does not overflow after adding the base inclusion fee (100). + // does not require a fee bump after adding the base inclusion fee (100). // 2. Given a large resource fee that WILL exceed u32::MAX with the - // base inclusion fee, ensure the overflow is caught with an error - // rather than silently ignored. - let txn = single_contract_fn_transaction(); + // base inclusion fee, ensure the fee is set to zero and the correct + // fee_bump_fee is set on the Assembled struct. + // 3. Given a total fee over i64::MAX, ensure an error is returned. + let mut txn = single_contract_fn_transaction(); let response = simulation_response(); - // sanity check so these can be adjusted if the above helper changes - assert_eq!(txn.fee, 100, "modified txn.fee: update the math below"); + let inclusion_fee: u32 = 500; + let inclusion_fee_i64: i64 = i64::from(inclusion_fee); + txn.fee = inclusion_fee; // 1: wiggle room math overflows but result fits - let resource_fee: i64 = (u32::MAX - 100).into(); - - match assemble(&txn, &response, Some(resource_fee)) { - Ok(asstxn) => { - let expected = u32::MAX; - assert_eq!(asstxn.fee, expected); + let resource_fee: i64 = (u32::MAX - inclusion_fee).into(); + match assemble(&txn, response.clone(), Some(resource_fee)) { + Ok(assembled) => { + assert_eq!(assembled.txn.fee, u32::MAX); + assert_eq!(assembled.fee_bump_fee, None); } - r => panic!("expected success, got: {r:#?}"), + Err(e) => panic!("expected success, got error: {e:#?}"), } - // 2: combo overflows, should throw - let resource_fee: i64 = (u32::MAX - 99).into(); + // 2: combo over u32::MAX, should set fee to 0 and fee_bump_fee to total + let resource_fee: i64 = (u32::MAX - inclusion_fee + 1).into(); + match assemble(&txn, response.clone(), Some(resource_fee)) { + Ok(assembled) => { + assert_eq!(assembled.txn.fee, 0); + assert_eq!( + assembled.fee_bump_fee, + Some(resource_fee + inclusion_fee_i64 * 2) + ); + } + Err(e) => panic!("expected success, got error: {e:#?}"), + } - match assemble(&txn, &response, Some(resource_fee)) { + // 3: total fee exceeds i64::MAX, should error + let resource_fee: i64 = i64::MAX - (2 * inclusion_fee_i64) + 1; + match assemble(&txn, response, Some(resource_fee)) { Err(Error::LargeFee(fee)) => { - let expected = u64::from(u32::MAX) + 1; + let expected = i64::MAX as u64 + 1; assert_eq!(expected, fee, "expected {expected} != {fee} actual"); } - r => panic!("expected LargeFee error, got: {r:#?}"), + Ok(_) => panic!("expected error, got success"), + Err(e) => panic!("expected success, got error: {e:#?}"), } } } diff --git a/cmd/soroban-cli/src/commands/contract/extend.rs b/cmd/soroban-cli/src/commands/contract/extend.rs index c473865fb8..3ae6aaad41 100644 --- a/cmd/soroban-cli/src/commands/contract/extend.rs +++ b/cmd/soroban-cli/src/commands/contract/extend.rs @@ -286,7 +286,7 @@ impl Cmd { return Ok(TxnResult::Res(extension)); } - match (&changes.0[0], &changes.0[1]) { + match (&changes[0], &changes[1]) { ( LedgerEntryChange::State(_), LedgerEntryChange::Updated(LedgerEntry { diff --git a/cmd/soroban-cli/src/commands/tx/simulate.rs b/cmd/soroban-cli/src/commands/tx/simulate.rs index 194e6cc4ba..d80e058401 100644 --- a/cmd/soroban-cli/src/commands/tx/simulate.rs +++ b/cmd/soroban-cli/src/commands/tx/simulate.rs @@ -1,5 +1,6 @@ use crate::{ assembled::{simulate_and_assemble_transaction, Assembled}, + print, xdr::{self, TransactionEnvelope, WriteXdr}, }; use std::ffi::OsString; @@ -38,14 +39,19 @@ pub struct Cmd { } impl Cmd { - pub async fn run(&self, _global_args: &global::Args) -> Result<(), Error> { - let res = self.execute(&self.config).await?; + pub async fn run(&self, global_args: &global::Args) -> Result<(), Error> { + let res = self.execute(global_args, &self.config).await?; let tx_env: TransactionEnvelope = res.transaction().clone().into(); println!("{}", tx_env.to_xdr_base64(xdr::Limits::none())?); Ok(()) } - pub async fn execute(&self, config: &config::Args) -> Result { + pub async fn execute( + &self, + global_args: &global::Args, + config: &config::Args, + ) -> Result { + let print = print::Print::new(global_args.quiet); let network = config.get_network()?; let client = network.rpc_client()?; let tx = super::xdr::unwrap_envelope_v1(super::xdr::tx_envelope_from_input(&self.tx_xdr)?)?; @@ -53,6 +59,9 @@ impl Cmd { .instruction_leeway .map(|instruction_leeway| soroban_rpc::ResourceConfig { instruction_leeway }); let tx = simulate_and_assemble_transaction(&client, &tx, resource_config, None).await?; + if let Some(fee_bump_fee) = tx.fee_bump_fee() { + print.warnln(format!("The transaction fee of {fee_bump_fee} is too large and needs to be wrapped in a fee bump transaction.")); + } Ok(tx) } } diff --git a/cmd/soroban-cli/src/config/mod.rs b/cmd/soroban-cli/src/config/mod.rs index cd86dc1324..7eb79fff45 100644 --- a/cmd/soroban-cli/src/config/mod.rs +++ b/cmd/soroban-cli/src/config/mod.rs @@ -8,7 +8,10 @@ use crate::{ print::Print, signer::{self, Signer}, utils::deprecate_message, - xdr::{self, SequenceNumber, Transaction, TransactionEnvelope, TransactionV1Envelope, VecM}, + xdr::{ + self, FeeBumpTransaction, FeeBumpTransactionEnvelope, SequenceNumber, Transaction, + TransactionEnvelope, TransactionV1Envelope, VecM, + }, Pwd, }; use network::Network; @@ -116,6 +119,27 @@ impl Args { .await?) } + pub async fn sign_fee_bump( + &self, + tx: FeeBumpTransaction, + quiet: bool, + ) -> Result { + let tx_env = TransactionEnvelope::TxFeeBump(FeeBumpTransactionEnvelope { + tx, + signatures: VecM::default(), + }); + Ok(self + .sign_with + .sign_tx_env( + &tx_env, + &self.locator, + &self.network.get(&self.locator)?, + quiet, + Some(&self.source_account), + ) + .await?) + } + pub async fn sign_soroban_authorizations( &self, tx: &Transaction, diff --git a/cmd/soroban-cli/src/resources.rs b/cmd/soroban-cli/src/resources.rs index 8945f5b6fd..d227b4dbff 100644 --- a/cmd/soroban-cli/src/resources.rs +++ b/cmd/soroban-cli/src/resources.rs @@ -11,7 +11,7 @@ use crate::commands::HEADING_RPC; #[group(skip)] pub struct Args { /// Set the fee for smart contract resource consumption, in stroops. 1 stroop = 0.0000001 xlm. Overrides the simulated resource fee - #[arg(long, env = "STELLAR_RESOURCE_FEE", value_parser = clap::value_parser!(i64).range(0..u32::MAX.into()), help_heading = HEADING_RPC)] + #[arg(long, env = "STELLAR_RESOURCE_FEE", value_parser = clap::value_parser!(i64).range(0..i64::MAX), help_heading = HEADING_RPC)] pub resource_fee: Option, /// ⚠️ Deprecated, use `--instruction-leeway` to increase instructions. Number of instructions to allocate for the transaction #[arg(long, help_heading = HEADING_RPC)] diff --git a/cmd/soroban-cli/src/signer/mod.rs b/cmd/soroban-cli/src/signer/mod.rs index a55c3a73a5..7482fcf94b 100644 --- a/cmd/soroban-cli/src/signer/mod.rs +++ b/cmd/soroban-cli/src/signer/mod.rs @@ -1,9 +1,13 @@ -use crate::xdr::{ - self, AccountId, DecoratedSignature, Hash, HashIdPreimage, HashIdPreimageSorobanAuthorization, - InvokeHostFunctionOp, Limits, Operation, OperationBody, PublicKey, ScAddress, ScMap, ScSymbol, - ScVal, Signature, SignatureHint, SorobanAddressCredentials, SorobanAuthorizationEntry, - SorobanAuthorizedFunction, SorobanCredentials, Transaction, TransactionEnvelope, - TransactionV1Envelope, Uint256, VecM, WriteXdr, +use crate::{ + utils::fee_bump_transaction_hash, + xdr::{ + self, AccountId, DecoratedSignature, FeeBumpTransactionEnvelope, Hash, HashIdPreimage, + HashIdPreimageSorobanAuthorization, InvokeHostFunctionOp, Limits, Operation, OperationBody, + PublicKey, ScAddress, ScMap, ScSymbol, ScVal, Signature, SignatureHint, + SorobanAddressCredentials, SorobanAuthorizationEntry, SorobanAuthorizedFunction, + SorobanCredentials, Transaction, TransactionEnvelope, TransactionV1Envelope, Uint256, VecM, + WriteXdr, + }, }; use ed25519_dalek::{ed25519::signature::Signer as _, Signature as Ed25519Signature}; use sha2::{Digest, Sha256}; @@ -30,7 +34,7 @@ pub enum Error { UserCancelledSigning, #[error(transparent)] Xdr(#[from] xdr::Error), - #[error("Only Transaction envelope V1 type is supported")] + #[error("Transaction envelope type not supported")] UnsupportedTransactionEnvelopeType, #[error(transparent)] Url(#[from] url::ParseError), @@ -245,12 +249,7 @@ impl Signer { let tx_hash = transaction_hash(tx, &network.network_passphrase)?; self.print .infoln(format!("Signing transaction: {}", hex::encode(tx_hash),)); - let decorated_signature = match &self.kind { - SignerKind::Local(key) => key.sign_tx_hash(tx_hash)?, - SignerKind::Lab => Lab::sign_tx_env(tx_env, network, &self.print)?, - SignerKind::Ledger(ledger) => ledger.sign_transaction_hash(&tx_hash).await?, - SignerKind::SecureStore(entry) => entry.sign_tx_hash(tx_hash)?, - }; + let decorated_signature = self.sign_tx_hash(tx_hash, tx_env, network).await?; let mut sigs = signatures.clone().into_vec(); sigs.push(decorated_signature); Ok(TransactionEnvelope::Tx(TransactionV1Envelope { @@ -258,7 +257,21 @@ impl Signer { signatures: sigs.try_into()?, })) } - _ => Err(Error::UnsupportedTransactionEnvelopeType), + TransactionEnvelope::TxFeeBump(FeeBumpTransactionEnvelope { tx, signatures }) => { + let tx_hash = fee_bump_transaction_hash(tx, &network.network_passphrase)?; + self.print.infoln(format!( + "Signing fee bump transaction: {}", + hex::encode(tx_hash), + )); + let decorated_signature = self.sign_tx_hash(tx_hash, tx_env, network).await?; + let mut sigs = signatures.clone().into_vec(); + sigs.push(decorated_signature); + Ok(TransactionEnvelope::TxFeeBump(FeeBumpTransactionEnvelope { + tx: tx.clone(), + signatures: sigs.try_into()?, + })) + } + TransactionEnvelope::TxV0(_) => Err(Error::UnsupportedTransactionEnvelopeType), } } @@ -283,6 +296,23 @@ impl Signer { SignerKind::SecureStore(secure_store_entry) => secure_store_entry.sign_payload(payload), } } + + async fn sign_tx_hash( + &self, + tx_hash: [u8; 32], + tx_env: &TransactionEnvelope, + network: &Network, + ) -> Result { + match &self.kind { + SignerKind::Local(key) => key.sign_tx_hash(tx_hash), + SignerKind::Lab => Lab::sign_tx_env(tx_env, network, &self.print), + SignerKind::Ledger(ledger) => ledger + .sign_transaction_hash(&tx_hash) + .await + .map_err(Error::from), + SignerKind::SecureStore(entry) => entry.sign_tx_hash(tx_hash), + } + } } pub struct LocalKey { diff --git a/cmd/soroban-cli/src/tx.rs b/cmd/soroban-cli/src/tx.rs index 6c485832e8..d9c3c15976 100644 --- a/cmd/soroban-cli/src/tx.rs +++ b/cmd/soroban-cli/src/tx.rs @@ -5,8 +5,11 @@ use crate::{ commands::tx::fetch, config::{self, data, network}, resources, - signer::Signer, - xdr::{self, Transaction}, + signer::{self, Signer}, + xdr::{ + self, FeeBumpTransaction, FeeBumpTransactionExt, FeeBumpTransactionInnerTx, Transaction, + TransactionEnvelope, + }, }; use soroban_rpc::GetTransactionResponse; use url::Url; @@ -61,9 +64,30 @@ where *txn = tx; } - let res = client - .send_transaction_polling(&config.sign(*txn, quiet).await?) - .await?; + let mut signed_tx = config.sign(*txn, quiet).await?; + + // If the simulation detected the need for a fee bump, + // wrap the transaction in a fee bump with the appropriate fee amount + if let Some(fee_bump_fee) = assembled.fee_bump_fee() { + let fee_bump_inner = match signed_tx { + TransactionEnvelope::Tx(tx_env) => FeeBumpTransactionInnerTx::Tx(tx_env), + _ => { + return Err(config::Error::Signer( + signer::Error::UnsupportedTransactionEnvelopeType, + ) + .into()) + } + }; + let fee_bump = FeeBumpTransaction { + fee_source: tx.source_account.clone(), + fee: fee_bump_fee, + inner_tx: fee_bump_inner, + ext: FeeBumpTransactionExt::V0, + }; + signed_tx = config.sign_fee_bump(fee_bump, quiet).await?; + } + + let res = client.send_transaction_polling(&signed_tx).await?; resources.print_cost_info(&res)?; diff --git a/cmd/soroban-cli/src/utils.rs b/cmd/soroban-cli/src/utils.rs index 2e5351af32..5f5c58e6b2 100644 --- a/cmd/soroban-cli/src/utils.rs +++ b/cmd/soroban-cli/src/utils.rs @@ -36,6 +36,22 @@ pub fn transaction_hash( Ok(Sha256::digest(signature_payload.to_xdr(Limits::none())?).into()) } +/// # Errors +/// +/// Might return an error +pub fn fee_bump_transaction_hash( + fee_bump_tx: &xdr::FeeBumpTransaction, + network_passphrase: &str, +) -> Result<[u8; 32], xdr::Error> { + let signature_payload = TransactionSignaturePayload { + network_id: Hash(Sha256::digest(network_passphrase).into()), + tagged_transaction: TransactionSignaturePayloadTaggedTransaction::TxFeeBump( + fee_bump_tx.clone(), + ), + }; + Ok(Sha256::digest(signature_payload.to_xdr(Limits::none())?).into()) +} + static EXPLORERS: phf::Map<&'static str, &'static str> = phf_map! { "Test SDF Network ; September 2015" => "https://stellar.expert/explorer/testnet", "Public Global Stellar Network ; September 2015" => "https://stellar.expert/explorer/public", From db74b554c6e5ddfc7ea01aa18fa39f222f296b74 Mon Sep 17 00:00:00 2001 From: mootz12 <38118608+mootz12@users.noreply.github.com> Date: Tue, 10 Feb 2026 09:29:48 -0500 Subject: [PATCH 3/5] Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- cmd/soroban-cli/src/assembled.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/soroban-cli/src/assembled.rs b/cmd/soroban-cli/src/assembled.rs index 656c569f99..27d9aa396b 100644 --- a/cmd/soroban-cli/src/assembled.rs +++ b/cmd/soroban-cli/src/assembled.rs @@ -579,7 +579,7 @@ mod tests { assert_eq!(expected, fee, "expected {expected} != {fee} actual"); } Ok(_) => panic!("expected error, got success"), - Err(e) => panic!("expected success, got error: {e:#?}"), + Err(e) => panic!("expected LargeFee error, got different error: {e:#?}"), } } From 18a4c6d6167626b487639fe3e517e41d0f6dcf19 Mon Sep 17 00:00:00 2001 From: mootz12 Date: Wed, 11 Feb 2026 14:44:06 -0500 Subject: [PATCH 4/5] chore: improve soroban tx logging across commands --- .../src/commands/contract/deploy/wasm.rs | 9 +---- .../src/commands/contract/upload.rs | 2 - cmd/soroban-cli/src/commands/tx/simulate.rs | 2 +- cmd/soroban-cli/src/print.rs | 27 ++++++++++++-- cmd/soroban-cli/src/tx.rs | 37 +++++++++++++++---- cmd/soroban-cli/src/utils.rs | 21 ++++++++++- 6 files changed, 76 insertions(+), 22 deletions(-) diff --git a/cmd/soroban-cli/src/commands/contract/deploy/wasm.rs b/cmd/soroban-cli/src/commands/contract/deploy/wasm.rs index d09455fbf2..7f55d69d01 100644 --- a/cmd/soroban-cli/src/commands/contract/deploy/wasm.rs +++ b/cmd/soroban-cli/src/commands/contract/deploy/wasm.rs @@ -309,6 +309,7 @@ impl Cmd { let hash = if is_build { wasm::Args { wasm: wasm.clone() }.hash()? } else { + print.infoln("Uploading contract WASM…"); upload::Cmd { wasm: Some(wasm.clone()), config: config.clone(), @@ -340,7 +341,7 @@ impl Cmd { .0, ); - print.infoln(format!("Using wasm hash {wasm_hash}").as_str()); + print.infoln(format!("Deploying contract using wasm hash {wasm_hash}").as_str()); let network = config.get_network()?; let salt: [u8; 32] = match &self.salt { @@ -418,12 +419,6 @@ impl Cmd { sim_sign_and_send_tx::(&client, &txn, config, &self.resources, &[], quiet, no_cache) .await?; - if let Some(url) = utils::lab_url_for_contract(&network, &contract_id) { - print.linkln(url); - } - - print.checkln("Deployed!"); - Ok(TxnResult::Res(contract_id)) } } diff --git a/cmd/soroban-cli/src/commands/contract/upload.rs b/cmd/soroban-cli/src/commands/contract/upload.rs index f6ef295b9d..5bc0c35f76 100644 --- a/cmd/soroban-cli/src/commands/contract/upload.rs +++ b/cmd/soroban-cli/src/commands/contract/upload.rs @@ -291,8 +291,6 @@ impl Cmd { } } - print.infoln("Simulating install transaction…"); - let txn_resp = sim_sign_and_send_tx::( &client, &tx_without_preflight, diff --git a/cmd/soroban-cli/src/commands/tx/simulate.rs b/cmd/soroban-cli/src/commands/tx/simulate.rs index d80e058401..3e8e1f88f0 100644 --- a/cmd/soroban-cli/src/commands/tx/simulate.rs +++ b/cmd/soroban-cli/src/commands/tx/simulate.rs @@ -60,7 +60,7 @@ impl Cmd { .map(|instruction_leeway| soroban_rpc::ResourceConfig { instruction_leeway }); let tx = simulate_and_assemble_transaction(&client, &tx, resource_config, None).await?; if let Some(fee_bump_fee) = tx.fee_bump_fee() { - print.warnln(format!("The transaction fee of {fee_bump_fee} is too large and needs to be wrapped in a fee bump transaction.")); + print.warnln(format!("The transaction fee of {} is too large and needs to be wrapped in a fee bump transaction.", print::format_number(fee_bump_fee, 7))); } Ok(tx) } diff --git a/cmd/soroban-cli/src/print.rs b/cmd/soroban-cli/src/print.rs index 6dc4231eec..b2ac93efbb 100644 --- a/cmd/soroban-cli/src/print.rs +++ b/cmd/soroban-cli/src/print.rs @@ -57,6 +57,12 @@ impl Print { emoji.to_string() } + pub fn log_explorer_url(&self, network: &Network, tx_hash: &str) { + if let Some(url) = explorer_url_for_transaction(network, tx_hash) { + self.linkln(url); + } + } + /// # Errors /// /// Might return an error @@ -72,9 +78,7 @@ impl Print { self.infoln(format!("Transaction hash is {hash}").as_str()); if show_link { - if let Some(url) = explorer_url_for_transaction(network, &hash) { - self.linkln(url); - } + self.log_explorer_url(network, &hash); } Ok(()) @@ -101,6 +105,23 @@ macro_rules! create_print_functions { }; } +/// Format a number with the appropriate number of decimals, trimming trailing zeros. +/// +/// If `n` cannot be represented as an i128 value, returns "Err(number out of bounds)". +pub fn format_number>(n: T, decimals: u32) -> String { + let n: i128 = match n.try_into() { + Ok(value) => value, + Err(_) => return "Err(number out of bounds)".to_string(), + }; + let divisor = 10i128.pow(decimals); + let integer_part = n / divisor; + let fractional_part = (n % divisor).abs(); + // Pad with leading zeros to match decimals width, then trim trailing zeros + let frac_str = format!("{:0width$}", fractional_part, width = decimals as usize); + let frac_trimmed = frac_str.trim_end_matches('0'); + format!("{integer_part}.{frac_trimmed}") +} + fn should_add_additional_space() -> bool { const TERMS: &[&str] = &["Apple_Terminal", "vscode", "unknown"]; let term_program = env::var("TERM_PROGRAM").unwrap_or("unknown".to_string()); diff --git a/cmd/soroban-cli/src/tx.rs b/cmd/soroban-cli/src/tx.rs index d9c3c15976..1161929251 100644 --- a/cmd/soroban-cli/src/tx.rs +++ b/cmd/soroban-cli/src/tx.rs @@ -1,18 +1,16 @@ -use std::str::FromStr; - use crate::{ assembled::simulate_and_assemble_transaction, commands::tx::fetch, config::{self, data, network}, - resources, + print, resources, signer::{self, Signer}, + utils::transaction_env_hash, xdr::{ self, FeeBumpTransaction, FeeBumpTransactionExt, FeeBumpTransactionInnerTx, Transaction, TransactionEnvelope, }, }; use soroban_rpc::GetTransactionResponse; -use url::Url; pub mod builder; @@ -21,7 +19,17 @@ pub const ONE_XLM: i64 = 10_000_000; /// Simulates, signs, and sends a transaction to the network. /// +/// This function handles a couple common tasks related to sending transactions: +/// * Log status messages to stderr when `quiet` is false +/// * Store results to the data cache when `no_cache` is false +/// * Logs a success message and block explorer link to stderr upon successful submission +/// +/// Does not handle any logging related to the result, events, of effects of the transaction. +/// /// Returns the `GetTransactionResponse` from the network. +/// +/// # Errors +/// If any step of the process fails (simulation, signing, sending) pub async fn sim_sign_and_send_tx( client: &soroban_rpc::Client, tx: &Transaction, @@ -39,6 +47,9 @@ where + From + From, { + let print = print::Print::new(quiet); + let network = config.get_network()?; + print.infoln("Simulating transaction…"); let txn = simulate_and_assemble_transaction( client, tx, @@ -50,10 +61,8 @@ where let mut txn = Box::new(assembled.transaction().clone()); let sim_res = assembled.sim_response(); - let rpc_uri = Url::from_str(client.base_url()) - .map_err(|_| config::network::Error::InvalidUrl(client.base_url().to_string()))?; if !no_cache { - data::write(sim_res.clone().into(), &rpc_uri)?; + data::write(sim_res.clone().into(), &network.rpc_uri()?)?; } // Need to sign all auth entries @@ -69,6 +78,10 @@ where // If the simulation detected the need for a fee bump, // wrap the transaction in a fee bump with the appropriate fee amount if let Some(fee_bump_fee) = assembled.fee_bump_fee() { + print.warnln(format!( + "Wrapping transaction with a fee bump transaction due to a fee of {} XLM.", + print::format_number(fee_bump_fee, 7) + )); let fee_bump_inner = match signed_tx { TransactionEnvelope::Tx(tx_env) => FeeBumpTransactionInnerTx::Tx(tx_env), _ => { @@ -86,13 +99,21 @@ where }; signed_tx = config.sign_fee_bump(fee_bump, quiet).await?; } + print.globeln("Sending transaction…"); + // returns an error if the transaction fails let res = client.send_transaction_polling(&signed_tx).await?; + print.checkln("Transaction submitted successfully!"); + + let tx_hash_bytes = transaction_env_hash(&signed_tx, &network.network_passphrase)?; + let tx_hash = hex::encode(tx_hash_bytes); + print.log_explorer_url(&network, &tx_hash); + resources.print_cost_info(&res)?; if !no_cache { - data::write(res.clone().try_into()?, &rpc_uri)?; + data::write(res.clone().try_into()?, &network.rpc_uri()?)?; } Ok(res) diff --git a/cmd/soroban-cli/src/utils.rs b/cmd/soroban-cli/src/utils.rs index 5f5c58e6b2..6ce3916dca 100644 --- a/cmd/soroban-cli/src/utils.rs +++ b/cmd/soroban-cli/src/utils.rs @@ -6,7 +6,7 @@ use crate::{ print::Print, xdr::{ self, Asset, ContractIdPreimage, Hash, HashIdPreimage, HashIdPreimageContractId, Limits, - ScMap, ScMapEntry, ScVal, Transaction, TransactionSignaturePayload, + ScMap, ScMapEntry, ScVal, Transaction, TransactionEnvelope, TransactionSignaturePayload, TransactionSignaturePayloadTaggedTransaction, WriteXdr, }, }; @@ -22,6 +22,25 @@ pub fn contract_hash(contract: &[u8]) -> Result { Ok(Hash(Sha256::digest(contract).into())) } +/// Compute the transaction hash for a given transaction envelope. +/// +/// # Errors +/// +/// If the transaction envelope contains unsupported types (e.g., TxV0), this function will return an error. +/// If an XDR error is encountered during processing, it will be propagated. +pub fn transaction_env_hash( + tx_env: &TransactionEnvelope, + network_passphrase: &str, +) -> Result<[u8; 32], xdr::Error> { + match tx_env { + TransactionEnvelope::Tx(ref v1_env) => transaction_hash(&v1_env.tx, network_passphrase), + TransactionEnvelope::TxFeeBump(ref fee_bump_env) => { + fee_bump_transaction_hash(&fee_bump_env.tx, network_passphrase) + } + TransactionEnvelope::TxV0(_) => Err(xdr::Error::Unsupported), + } +} + /// # Errors /// /// Might return an error From 25b3ccb0078d512041a2dcadf398e5280c764a88 Mon Sep 17 00:00:00 2001 From: mootz12 Date: Thu, 12 Feb 2026 16:51:51 -0500 Subject: [PATCH 5/5] fix: patch format number and lab contract url log --- .../src/commands/contract/deploy/asset.rs | 7 +++ .../src/commands/contract/deploy/wasm.rs | 5 ++ cmd/soroban-cli/src/print.rs | 50 ++++++++++++++++++- cmd/soroban-cli/src/tx.rs | 2 +- 4 files changed, 62 insertions(+), 2 deletions(-) diff --git a/cmd/soroban-cli/src/commands/contract/deploy/asset.rs b/cmd/soroban-cli/src/commands/contract/deploy/asset.rs index dfd8088229..ed68759ad6 100644 --- a/cmd/soroban-cli/src/commands/contract/deploy/asset.rs +++ b/cmd/soroban-cli/src/commands/contract/deploy/asset.rs @@ -1,6 +1,7 @@ use crate::config::locator; use crate::print::Print; use crate::tx::sim_sign_and_send_tx; +use crate::utils; use crate::xdr::{ Asset, ContractDataDurability, ContractExecutable, ContractIdPreimage, CreateContractArgs, Error as XdrError, Hash, HostFunction, InvokeHostFunctionOp, LedgerKey::ContractData, @@ -137,6 +138,7 @@ impl Cmd { no_cache: bool, ) -> Result, Error> { // Parse asset + let print = Print::new(quiet); let asset = self.asset.resolve(&config.locator)?; let network = config.get_network()?; @@ -171,6 +173,11 @@ impl Cmd { sim_sign_and_send_tx::(&client, &tx, config, &self.resources, &[], quiet, no_cache) .await?; + if let Some(url) = utils::lab_url_for_contract(&network, &contract_id) { + print.linkln(url); + } + print.checkln("Deployed!"); + Ok(TxnResult::Res(stellar_strkey::Contract(contract_id.0))) } } diff --git a/cmd/soroban-cli/src/commands/contract/deploy/wasm.rs b/cmd/soroban-cli/src/commands/contract/deploy/wasm.rs index 7f55d69d01..7914ee1bf9 100644 --- a/cmd/soroban-cli/src/commands/contract/deploy/wasm.rs +++ b/cmd/soroban-cli/src/commands/contract/deploy/wasm.rs @@ -419,6 +419,11 @@ impl Cmd { sim_sign_and_send_tx::(&client, &txn, config, &self.resources, &[], quiet, no_cache) .await?; + if let Some(url) = utils::lab_url_for_contract(&network, &contract_id) { + print.linkln(url); + } + print.checkln("Deployed!"); + Ok(TxnResult::Res(contract_id)) } } diff --git a/cmd/soroban-cli/src/print.rs b/cmd/soroban-cli/src/print.rs index b2ac93efbb..995a45a78f 100644 --- a/cmd/soroban-cli/src/print.rs +++ b/cmd/soroban-cli/src/print.rs @@ -113,13 +113,23 @@ pub fn format_number>(n: T, decimals: u32) -> String { Ok(value) => value, Err(_) => return "Err(number out of bounds)".to_string(), }; + if decimals == 0 { + return n.to_string(); + } let divisor = 10i128.pow(decimals); let integer_part = n / divisor; let fractional_part = (n % divisor).abs(); // Pad with leading zeros to match decimals width, then trim trailing zeros let frac_str = format!("{:0width$}", fractional_part, width = decimals as usize); let frac_trimmed = frac_str.trim_end_matches('0'); - format!("{integer_part}.{frac_trimmed}") + + if frac_trimmed.is_empty() { + format!("{integer_part}") + } else { + // If integer_part is 0, we still want to show the sign for negative numbers (e.g. -0.5) + let sign = if n < 0 && integer_part == 0 { "-" } else { "" }; + format!("{sign}{integer_part}.{frac_trimmed}") + } } fn should_add_additional_space() -> bool { @@ -150,3 +160,41 @@ create_print_functions!(event, eventln, "📅"); create_print_functions!(blank, blankln, " "); create_print_functions!(gear, gearln, "⚙️"); create_print_functions!(dir, dirln, "📁"); + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + #[allow(clippy::unreadable_literal)] + fn test_format_number() { + assert_eq!(format_number(0i128, 7), "0"); + assert_eq!(format_number(1234567i128, 7), "0.1234567"); + assert_eq!(format_number(12345000i128, 7), "1.2345"); + assert_eq!(format_number(10000000i128, 7), "1"); + assert_eq!(format_number(123456789012345i128, 7), "12345678.9012345"); + assert_eq!(format_number(-1234567i128, 7), "-0.1234567"); + assert_eq!(format_number(-12345000i128, 7), "-1.2345"); + assert_eq!(format_number(12345i128, 0), "12345"); + assert_eq!(format_number(12345i128, 1), "1234.5"); + assert_eq!(format_number(1i128, 7), "0.0000001"); + + assert_eq!(format_number(1u32, 7), "0.0000001"); + assert_eq!(format_number(1i32, 7), "0.0000001"); + assert_eq!(format_number(1u64, 7), "0.0000001"); + assert_eq!(format_number(1i64, 7), "0.0000001"); + assert_eq!(format_number(1u128, 7), "0.0000001"); + + let err: u128 = u128::try_from(i128::MAX).unwrap() + 1; + let result = format_number(err, 0); + assert_eq!(result, "Err(number out of bounds)"); + + let min: i128 = i128::MIN; + let result = format_number(min, 18); + assert_eq!(result, "-170141183460469231731.687303715884105728"); + + let max: i128 = i128::MAX; + let result = format_number(max, 18); + assert_eq!(result, "170141183460469231731.687303715884105727"); + } +} diff --git a/cmd/soroban-cli/src/tx.rs b/cmd/soroban-cli/src/tx.rs index 1161929251..8aa3a9c373 100644 --- a/cmd/soroban-cli/src/tx.rs +++ b/cmd/soroban-cli/src/tx.rs @@ -24,7 +24,7 @@ pub const ONE_XLM: i64 = 10_000_000; /// * Store results to the data cache when `no_cache` is false /// * Logs a success message and block explorer link to stderr upon successful submission /// -/// Does not handle any logging related to the result, events, of effects of the transaction. +/// Does not handle any logging related to the result, events, or effects of the transaction. /// /// Returns the `GetTransactionResponse` from the network. ///