diff --git a/cmd/soroban-cli/src/commands/tx/args.rs b/cmd/soroban-cli/src/commands/tx/args.rs index dd5c7b859..78c45510f 100644 --- a/cmd/soroban-cli/src/commands/tx/args.rs +++ b/cmd/soroban-cli/src/commands/tx/args.rs @@ -111,9 +111,22 @@ impl Args { return Ok(TxnEnvelopeResult::TxnEnvelope(Box::new(tx.into()))); } - let txn_resp = client - .send_transaction_polling(&self.config.sign(tx, args.quiet).await?) - .await?; + let signed_tx = self.config.sign(tx, args.quiet).await?; + let txn_resp = match client.send_transaction_polling(&signed_tx).await { + Ok(res) => res, + Err(e) => { + // Preserve the signed envelope on a failed send so it can be + // resubmitted without re-signing (#2609). + if !args.no_cache { + crate::tx::save_failed_send( + &signed_tx, + &network, + &crate::print::Print::new(args.quiet), + ); + } + return Err(e.into()); + } + }; if !args.no_cache { data::write(txn_resp.clone().try_into().unwrap(), &network.rpc_uri()?)?; diff --git a/cmd/soroban-cli/src/config/data.rs b/cmd/soroban-cli/src/config/data.rs index a5f733c7e..918052f30 100644 --- a/cmd/soroban-cli/src/config/data.rs +++ b/cmd/soroban-cli/src/config/data.rs @@ -132,6 +132,7 @@ impl std::fmt::Display for DatedAction { .as_ref() .map_or_else(|| "SUCCESS".to_string(), |_| "ERROR".to_string()), Action::Send { response } => response.status.clone(), + Action::SendFailed { .. } => "FAILED".to_string(), }; write!( f, @@ -164,6 +165,13 @@ pub enum Action { Send { response: GetTransactionResponseRaw, }, + /// A signed transaction whose submission failed. Preserved so it can be + /// resubmitted (e.g. piped back into `stellar tx send`) without + /// re-signing (#2609). + SendFailed { + /// Base64 XDR of the signed transaction envelope. + envelope_xdr: String, + }, } impl Action { @@ -171,6 +179,7 @@ impl Action { match self { Action::Simulate { .. } => "Simulate", Action::Send { .. } => "Send ", + Action::SendFailed { .. } => "SendFail", } .to_string() } @@ -233,6 +242,48 @@ mod test { }); } + #[test] + #[serial] + fn test_send_failed_round_trips_and_renders_as_failed() { + let t = assert_fs::TempDir::new().unwrap(); + with_env_set("STELLAR_DATA_HOME", t.path(), || { + let rpc_uri = Url::from_str("http://localhost:8000").unwrap(); + let envelope = "AAAAAgAAAABiQCwo7WLMLL5nQ+q8XW4dGSXHbqhUFTv2FSNQ".to_string(); + + let id = write( + Action::SendFailed { + envelope_xdr: envelope.clone(), + }, + &rpc_uri, + ) + .unwrap(); + + let (action, _) = read(&id).unwrap(); + match action { + Action::SendFailed { envelope_xdr } => assert_eq!( + envelope_xdr, envelope, + "the saved envelope must round-trip unchanged" + ), + _ => panic!("expected Action::SendFailed"), + } + + let rendered = list_actions() + .unwrap() + .iter() + .map(ToString::to_string) + .collect::>() + .join("\n"); + assert!( + rendered.contains("SendFail"), + "ls should name the action type: {rendered}" + ); + assert!( + rendered.contains("FAILED"), + "ls should render a FAILED status: {rendered}" + ); + }); + } + #[test] #[serial] fn actionlog_write_redacts_rpc_url_password_on_disk() { diff --git a/cmd/soroban-cli/src/tx.rs b/cmd/soroban-cli/src/tx.rs index 6a5baba42..67db4eb66 100644 --- a/cmd/soroban-cli/src/tx.rs +++ b/cmd/soroban-cli/src/tx.rs @@ -6,8 +6,8 @@ use crate::{ signer::{self, Signer}, utils::transaction_env_hash, xdr::{ - self, FeeBumpTransaction, FeeBumpTransactionExt, FeeBumpTransactionInnerTx, Transaction, - TransactionEnvelope, + self, FeeBumpTransaction, FeeBumpTransactionExt, FeeBumpTransactionInnerTx, Limits, + Transaction, TransactionEnvelope, WriteXdr, }, }; use soroban_rpc::GetTransactionResponse; @@ -17,6 +17,38 @@ pub mod builder; /// 10,000,000 stroops in 1 XLM pub const ONE_XLM: i64 = 10_000_000; +/// Preserve a signed envelope whose submission failed in the action log, and +/// tell the user how to resubmit it without re-signing (#2609). +/// +/// Best-effort on purpose: a failure to save must not mask the original RPC +/// error, so it is only logged at debug level. +pub(crate) fn save_failed_send( + signed_tx: &TransactionEnvelope, + network: &network::Network, + print: &print::Print, +) { + let saved = network + .rpc_uri() + .map_err(|e| e.to_string()) + .and_then(|uri| { + signed_tx + .to_xdr_base64(Limits::none()) + .map_err(|e| e.to_string()) + .and_then(|envelope_xdr| { + data::write(data::Action::SendFailed { envelope_xdr }, &uri) + .map_err(|e| e.to_string()) + }) + }); + match saved { + Ok(id) => print.warnln(format!( + "The transaction failed to send, but the signed envelope was saved to the \ + action log and can be resubmitted without re-signing:\n \ + stellar cache actionlog read --id {id} | jq -r .action.send_failed.envelope_xdr | stellar tx send" + )), + Err(e) => tracing::debug!("failed to save the signed envelope to the action log: {e}"), + } +} + /// Simulates, signs, and sends a transaction to the network. /// /// This function handles a couple common tasks related to sending transactions: @@ -106,7 +138,18 @@ where print.globeln("Sending transaction…"); // returns an error if the transaction fails - let res = client.send_transaction_polling(&signed_tx).await?; + let res = match client.send_transaction_polling(&signed_tx).await { + Ok(res) => res, + Err(e) => { + // A failed send would otherwise lose the signed (and possibly + // fee-bumped) envelope; preserve it in the action log so it can + // be resubmitted without collecting signatures again (#2609). + if !no_cache { + save_failed_send(&signed_tx, &network, &print); + } + return Err(e.into()); + } + }; print.checkln("Transaction submitted successfully!");