From 58c1fd1190b9b7e8f7410c3c3f60f220fb95a9e5 Mon Sep 17 00:00:00 2001 From: Blaine Heffron Date: Thu, 29 Jan 2026 17:17:12 -0500 Subject: [PATCH 1/3] error text on invoke --- cmd/crates/soroban-spec-tools/src/lib.rs | 23 ++ .../tests/it/integration/custom_types.rs | 19 +- .../src/commands/contract/invoke.rs | 326 +++++++++++++++++- 3 files changed, 359 insertions(+), 9 deletions(-) diff --git a/cmd/crates/soroban-spec-tools/src/lib.rs b/cmd/crates/soroban-spec-tools/src/lib.rs index e4dd8c659d..4da8ac4849 100644 --- a/cmd/crates/soroban-spec-tools/src/lib.rs +++ b/cmd/crates/soroban-spec-tools/src/lib.rs @@ -222,6 +222,29 @@ impl Spec { Err(Error::MissingErrorCase(value)) } + /// Search all error enums in the spec for a case matching the given value. + /// + /// Unlike `find_error_type`, which only looks at the error enum named + /// "Error", this method searches across all error enums in the contract + /// spec. This handles contracts that include multiple error enums from + /// dependencies. + pub fn find_error_type_any( + &self, + value: u32, + ) -> Option<(&ScSpecUdtErrorEnumV0, &ScSpecUdtErrorEnumCaseV0)> { + self.0.as_ref()?.iter().find_map(|entry| { + if let ScSpecEntry::UdtErrorEnumV0(error_enum) = entry { + error_enum + .cases + .iter() + .find(|case| case.value == value) + .map(|case| (error_enum, case)) + } else { + None + } + }) + } + /// # Errors /// /// Might return errors diff --git a/cmd/crates/soroban-test/tests/it/integration/custom_types.rs b/cmd/crates/soroban-test/tests/it/integration/custom_types.rs index df0549249d..677249a8ca 100644 --- a/cmd/crates/soroban-test/tests/it/integration/custom_types.rs +++ b/cmd/crates/soroban-test/tests/it/integration/custom_types.rs @@ -195,11 +195,20 @@ async fn number_arg_return_err(sandbox: &TestEnv, id: &str) { .invoke_with_test(&["--id", id, "--", "u32_fail_on_even", "--u32_=2"]) .await .unwrap_err(); - if let commands::contract::invoke::Error::ContractInvoke(name, doc) = &res { - assert_eq!(name, "NumberMustBeOdd"); - assert_eq!(doc, "Please provide an odd number"); - }; - println!("{res:#?}"); + match &res { + commands::contract::invoke::Error::ContractInvoke(enhanced_msg, detail) => { + assert!( + enhanced_msg.contains("#1"), + "expected enhanced msg to contain '#1', got: {enhanced_msg}" + ); + assert!( + enhanced_msg.contains("NumberMustBeOdd"), + "expected enhanced msg to contain resolved error name, got: {enhanced_msg}" + ); + assert_eq!(detail, "NumberMustBeOdd: Please provide an odd number"); + } + other => panic!("expected ContractInvoke error, got: {other:#?}"), + } } fn void(sandbox: &TestEnv, id: &str) { diff --git a/cmd/soroban-cli/src/commands/contract/invoke.rs b/cmd/soroban-cli/src/commands/contract/invoke.rs index 834f37718f..5ee9db24e7 100644 --- a/cmd/soroban-cli/src/commands/contract/invoke.rs +++ b/cmd/soroban-cli/src/commands/contract/invoke.rs @@ -132,7 +132,7 @@ pub enum Error { #[error(transparent)] Locator(#[from] locator::Error), - #[error("Contract Error\n{0}: {1}")] + #[error("Contract Error\n{0}")] ContractInvoke(String, String), #[error(transparent)] @@ -305,7 +305,8 @@ impl Cmd { } else { let assembled = self .simulate(&host_function_params, &default_account_entry(), &client) - .await?; + .await + .map_err(|e| enhance_error(e, &spec))?; let should_send = self.should_send_tx(&assembled.sim_res)?; (should_send, Some(assembled)) }; @@ -358,7 +359,8 @@ impl Cmd { self.resources.resource_config(), self.resources.resource_fee, ) - .await?; + .await + .map_err(|e| enhance_error(Error::Rpc(e), &spec))?; let assembled = self.resources.apply_to_assembled_txn(txn); let mut txn = Box::new(assembled.transaction().clone()); let sim_res = assembled.sim_response(); @@ -374,7 +376,8 @@ impl Cmd { let res = client .send_transaction_polling(&config.sign(*txn, quiet).await?) - .await?; + .await + .map_err(|e| enhance_error(Error::Rpc(e), &spec))?; self.resources.print_cost_info(&res)?; @@ -452,6 +455,110 @@ enum ShouldSend { Yes, } +/// Extract a contract error code (u32) from an error string. +/// +/// Supports two formats: +/// - `Error(Contract, #N)` from the Soroban host display format (simulation errors) +/// - `Contract(N)` from Rust Debug format of `ScError::Contract(u32)` (submission errors) +/// +/// The Display format uses the prefix `Contract, #` to distinguish contract errors +/// from other Soroban error types (Budget, Auth, etc.) which also use `#N`. +/// +/// The Debug format is used by `TransactionSubmissionFailed` errors which +/// pretty-print (`{:#?}`) the `TransactionResult`, where the number may +/// appear on a separate line with surrounding whitespace. +fn extract_contract_error_code(msg: &str) -> Option { + // Try `Contract, #N` format (simulation errors). + // Must match the full prefix to avoid false positives on non-contract + // error types like `Error(Budget, #3)`. + if let Some(idx) = msg.find("Contract, #") { + let after = &msg[idx + "Contract, #".len()..]; + let end = after + .find(|c: char| !c.is_ascii_digit()) + .unwrap_or(after.len()); + if end > 0 { + if let Ok(code) = after[..end].parse() { + return Some(code); + } + } + } + + // Try `Contract(N)` format (transaction submission errors via Debug). + // In the Debug-printed XDR, `ScError::Contract(u32)` is the only variant + // that uses `Contract(` followed by a number. + if let Some(idx) = msg.find("Contract(") { + let after = &msg[idx + "Contract(".len()..]; + let trimmed = after.trim_start(); + let end = trimmed + .find(|c: char| !c.is_ascii_digit()) + .unwrap_or(trimmed.len()); + if end > 0 { + if let Ok(code) = trimmed[..end].parse() { + return Some(code); + } + } + } + + None +} + +/// Try to enhance an error with human-readable contract error information from +/// the contract spec. If the error contains a contract error code — either +/// `#N` from simulation errors or `Contract(N)` from transaction submission +/// errors — looks it up across all error enums in the spec and returns a +/// `ContractInvoke` error with the resolved name and documentation. +/// +/// The resolved error name is inserted into the error message right after the +/// error code, so it appears next to `Error(Contract, #N)` rather than being +/// separated from it by the event log. +/// +/// Returns the original error unchanged if enhancement is not possible. +fn enhance_error(err: Error, spec: &soroban_spec_tools::Spec) -> Error { + let error_msg = match &err { + Error::Rpc(rpc_err) => rpc_err.to_string(), + _ => return err, + }; + + let Some(code) = extract_contract_error_code(&error_msg) else { + return err; + }; + + let Some((_enum_info, case)) = spec.find_error_type_any(code) else { + return err; + }; + + let name = case.name.to_utf8_string_lossy(); + let doc = case.doc.to_utf8_string_lossy(); + let detail = format!( + "{name}{}", + if doc.is_empty() { + String::new() + } else { + format!(": {doc}") + } + ); + + let enhanced_msg = insert_detail_after_error_code(&error_msg, &detail); + Error::ContractInvoke(enhanced_msg, detail) +} + +/// Insert a detail string into an error message right after the contract error +/// code line, before the event log section. +/// +/// The RPC simulation error typically has the error on the first line, followed +/// by a blank line (`\n\n`) and then the "Event log (newest first):" section. +/// This function inserts the detail between the error line and the event log so +/// the resolved error name appears next to the error code. +/// +/// If no blank line separator is found, the detail is appended at the end. +fn insert_detail_after_error_code(msg: &str, detail: &str) -> String { + if let Some(pos) = msg.find("\n\n") { + format!("{}\n{}{}", &msg[..pos], detail, &msg[pos..]) + } else { + format!("{msg}\n{detail}") + } +} + fn has_write(sim_res: &SimulateTransactionResponse) -> Result { Ok(!sim_res .transaction_data()? @@ -476,3 +583,214 @@ fn has_auth(sim_res: &SimulateTransactionResponse) -> Result { .iter() .any(|SimulateHostFunctionResult { auth, .. }| !auth.is_empty())) } + +#[cfg(test)] +mod tests { + use super::*; + use soroban_spec_tools::Spec; + use xdr::{ScSpecUdtErrorEnumCaseV0, ScSpecUdtErrorEnumV0}; + + fn test_spec(cases: Vec<(u32, &str, &str)>) -> Spec { + let entries = vec![ScSpecEntry::UdtErrorEnumV0(ScSpecUdtErrorEnumV0 { + lib: StringM::default(), + name: "Error".try_into().unwrap(), + doc: StringM::default(), + cases: cases + .into_iter() + .map(|(value, name, doc)| ScSpecUdtErrorEnumCaseV0 { + doc: doc.try_into().unwrap(), + name: name.try_into().unwrap(), + value, + }) + .collect::>() + .try_into() + .unwrap(), + })]; + Spec(Some(entries)) + } + + // --- extract_contract_error_code tests --- + + #[test] + fn extract_code_from_simulation_error() { + let msg = "transaction simulation failed: HostError: Error(Contract, #1)"; + assert_eq!(extract_contract_error_code(msg), Some(1)); + } + + #[test] + fn extract_code_large_number() { + let msg = "transaction simulation failed: HostError: Error(Contract, #100)"; + assert_eq!(extract_contract_error_code(msg), Some(100)); + } + + #[test] + fn extract_code_from_debug_format_compact() { + let msg = "transaction submission failed: Contract(1)"; + assert_eq!(extract_contract_error_code(msg), Some(1)); + } + + #[test] + fn extract_code_from_debug_format_pretty() { + let msg = "Err(\n Contract(\n 1,\n ),\n)"; + assert_eq!(extract_contract_error_code(msg), Some(1)); + } + + #[test] + fn extract_code_no_match() { + let msg = "transaction simulation failed: some other error"; + assert_eq!(extract_contract_error_code(msg), None); + } + + #[test] + fn extract_code_non_contract_error_type() { + let msg = "transaction simulation failed: HostError: Error(Budget, #3)"; + assert_eq!(extract_contract_error_code(msg), None); + } + + #[test] + fn extract_code_bare_hash() { + let msg = "something #123 happened"; + assert_eq!(extract_contract_error_code(msg), None); + } + + // --- insert_detail_after_error_code tests --- + + #[test] + fn insert_detail_with_event_log() { + let msg = "transaction simulation failed: HostError: Error(Contract, #1)\n\n\ + Event log (newest first):\n 0: [Diagnostic Event] ..."; + let detail = "NotFound: The requested resource was not found."; + let result = insert_detail_after_error_code(msg, detail); + assert_eq!( + result, + "transaction simulation failed: HostError: Error(Contract, #1)\n\ + NotFound: The requested resource was not found.\n\n\ + Event log (newest first):\n 0: [Diagnostic Event] ..." + ); + } + + #[test] + fn insert_detail_without_event_log() { + let msg = "transaction simulation failed: HostError: Error(Contract, #1)"; + let detail = "NotFound: The requested resource was not found."; + let result = insert_detail_after_error_code(msg, detail); + assert_eq!( + result, + "transaction simulation failed: HostError: Error(Contract, #1)\n\ + NotFound: The requested resource was not found." + ); + } + + // --- enhance_error tests --- + + #[test] + fn enhance_simulation_error() { + let spec = test_spec(vec![(1, "NumberMustBeOdd", "Please provide an odd number")]); + let rpc_err = soroban_rpc::Error::TransactionSimulationFailed( + "HostError: Error(Contract, #1)".to_string(), + ); + let err = Error::Rpc(rpc_err); + let result = enhance_error(err, &spec); + match result { + Error::ContractInvoke(enhanced_msg, detail) => { + assert!( + enhanced_msg.contains("#1"), + "expected enhanced msg to contain '#1', got: {enhanced_msg}" + ); + assert!( + enhanced_msg.contains("NumberMustBeOdd"), + "expected enhanced msg to contain resolved name, got: {enhanced_msg}" + ); + assert_eq!(detail, "NumberMustBeOdd: Please provide an odd number"); + } + other => panic!("expected ContractInvoke, got: {other:#?}"), + } + } + + #[test] + fn enhance_simulation_error_with_event_log() { + let spec = test_spec(vec![( + 1, + "NotFound", + "The requested resource was not found.", + )]); + let error_str = "HostError: Error(Contract, #1)\n\n\ + Event log (newest first):\n 0: [Diagnostic Event] ..."; + let rpc_err = soroban_rpc::Error::TransactionSimulationFailed(error_str.to_string()); + let err = Error::Rpc(rpc_err); + let result = enhance_error(err, &spec); + match result { + Error::ContractInvoke(enhanced_msg, detail) => { + // The detail should appear BEFORE the event log + let code_pos = enhanced_msg.find("#1").unwrap(); + let detail_pos = enhanced_msg.find("NotFound:").unwrap(); + let event_pos = enhanced_msg.find("Event log").unwrap(); + assert!( + detail_pos < event_pos, + "detail ({detail_pos}) should appear before event log ({event_pos})" + ); + assert!( + detail_pos > code_pos, + "detail ({detail_pos}) should appear after error code ({code_pos})" + ); + assert_eq!(detail, "NotFound: The requested resource was not found."); + } + other => panic!("expected ContractInvoke, got: {other:#?}"), + } + } + + #[test] + fn enhance_submission_error() { + let spec = test_spec(vec![(1, "NumberMustBeOdd", "Please provide an odd number")]); + let debug_msg = "TransactionResult { result: Err(Contract(1)) }".to_string(); + let rpc_err = soroban_rpc::Error::TransactionSubmissionFailed(debug_msg); + let err = Error::Rpc(rpc_err); + let result = enhance_error(err, &spec); + match result { + Error::ContractInvoke(enhanced_msg, detail) => { + assert!(enhanced_msg.contains("Contract(1)")); + assert!(enhanced_msg.contains("NumberMustBeOdd")); + assert_eq!(detail, "NumberMustBeOdd: Please provide an odd number"); + } + other => panic!("expected ContractInvoke, got: {other:#?}"), + } + } + + #[test] + fn enhance_error_no_match_returns_original() { + let spec = test_spec(vec![(1, "NumberMustBeOdd", "Please provide an odd number")]); + let rpc_err = + soroban_rpc::Error::TransactionSimulationFailed("some other error".to_string()); + let err = Error::Rpc(rpc_err); + let result = enhance_error(err, &spec); + assert!(matches!(result, Error::Rpc(_))); + } + + #[test] + fn enhance_error_unknown_code_returns_original() { + let spec = test_spec(vec![(1, "NumberMustBeOdd", "Please provide an odd number")]); + let rpc_err = soroban_rpc::Error::TransactionSimulationFailed( + "HostError: Error(Contract, #99)".to_string(), + ); + let err = Error::Rpc(rpc_err); + let result = enhance_error(err, &spec); + assert!(matches!(result, Error::Rpc(_))); + } + + #[test] + fn enhance_error_empty_doc() { + let spec = test_spec(vec![(1, "SomeError", "")]); + let rpc_err = soroban_rpc::Error::TransactionSimulationFailed( + "HostError: Error(Contract, #1)".to_string(), + ); + let err = Error::Rpc(rpc_err); + let result = enhance_error(err, &spec); + match result { + Error::ContractInvoke(enhanced_msg, detail) => { + assert!(enhanced_msg.contains("SomeError")); + assert_eq!(detail, "SomeError"); + } + other => panic!("expected ContractInvoke, got: {other:#?}"), + } + } +} From a95ab5bcb9c4178fc57cf859168da42cba3f2d9d Mon Sep 17 00:00:00 2001 From: Blaine Heffron Date: Thu, 29 Jan 2026 17:30:33 -0500 Subject: [PATCH 2/3] cleanup --- .../src/commands/contract/invoke.rs | 213 +----------------- 1 file changed, 1 insertion(+), 212 deletions(-) diff --git a/cmd/soroban-cli/src/commands/contract/invoke.rs b/cmd/soroban-cli/src/commands/contract/invoke.rs index 5ee9db24e7..9784f14811 100644 --- a/cmd/soroban-cli/src/commands/contract/invoke.rs +++ b/cmd/soroban-cli/src/commands/contract/invoke.rs @@ -132,7 +132,7 @@ pub enum Error { #[error(transparent)] Locator(#[from] locator::Error), - #[error("Contract Error\n{0}")] + #[error("{0}")] ContractInvoke(String, String), #[error(transparent)] @@ -583,214 +583,3 @@ fn has_auth(sim_res: &SimulateTransactionResponse) -> Result { .iter() .any(|SimulateHostFunctionResult { auth, .. }| !auth.is_empty())) } - -#[cfg(test)] -mod tests { - use super::*; - use soroban_spec_tools::Spec; - use xdr::{ScSpecUdtErrorEnumCaseV0, ScSpecUdtErrorEnumV0}; - - fn test_spec(cases: Vec<(u32, &str, &str)>) -> Spec { - let entries = vec![ScSpecEntry::UdtErrorEnumV0(ScSpecUdtErrorEnumV0 { - lib: StringM::default(), - name: "Error".try_into().unwrap(), - doc: StringM::default(), - cases: cases - .into_iter() - .map(|(value, name, doc)| ScSpecUdtErrorEnumCaseV0 { - doc: doc.try_into().unwrap(), - name: name.try_into().unwrap(), - value, - }) - .collect::>() - .try_into() - .unwrap(), - })]; - Spec(Some(entries)) - } - - // --- extract_contract_error_code tests --- - - #[test] - fn extract_code_from_simulation_error() { - let msg = "transaction simulation failed: HostError: Error(Contract, #1)"; - assert_eq!(extract_contract_error_code(msg), Some(1)); - } - - #[test] - fn extract_code_large_number() { - let msg = "transaction simulation failed: HostError: Error(Contract, #100)"; - assert_eq!(extract_contract_error_code(msg), Some(100)); - } - - #[test] - fn extract_code_from_debug_format_compact() { - let msg = "transaction submission failed: Contract(1)"; - assert_eq!(extract_contract_error_code(msg), Some(1)); - } - - #[test] - fn extract_code_from_debug_format_pretty() { - let msg = "Err(\n Contract(\n 1,\n ),\n)"; - assert_eq!(extract_contract_error_code(msg), Some(1)); - } - - #[test] - fn extract_code_no_match() { - let msg = "transaction simulation failed: some other error"; - assert_eq!(extract_contract_error_code(msg), None); - } - - #[test] - fn extract_code_non_contract_error_type() { - let msg = "transaction simulation failed: HostError: Error(Budget, #3)"; - assert_eq!(extract_contract_error_code(msg), None); - } - - #[test] - fn extract_code_bare_hash() { - let msg = "something #123 happened"; - assert_eq!(extract_contract_error_code(msg), None); - } - - // --- insert_detail_after_error_code tests --- - - #[test] - fn insert_detail_with_event_log() { - let msg = "transaction simulation failed: HostError: Error(Contract, #1)\n\n\ - Event log (newest first):\n 0: [Diagnostic Event] ..."; - let detail = "NotFound: The requested resource was not found."; - let result = insert_detail_after_error_code(msg, detail); - assert_eq!( - result, - "transaction simulation failed: HostError: Error(Contract, #1)\n\ - NotFound: The requested resource was not found.\n\n\ - Event log (newest first):\n 0: [Diagnostic Event] ..." - ); - } - - #[test] - fn insert_detail_without_event_log() { - let msg = "transaction simulation failed: HostError: Error(Contract, #1)"; - let detail = "NotFound: The requested resource was not found."; - let result = insert_detail_after_error_code(msg, detail); - assert_eq!( - result, - "transaction simulation failed: HostError: Error(Contract, #1)\n\ - NotFound: The requested resource was not found." - ); - } - - // --- enhance_error tests --- - - #[test] - fn enhance_simulation_error() { - let spec = test_spec(vec![(1, "NumberMustBeOdd", "Please provide an odd number")]); - let rpc_err = soroban_rpc::Error::TransactionSimulationFailed( - "HostError: Error(Contract, #1)".to_string(), - ); - let err = Error::Rpc(rpc_err); - let result = enhance_error(err, &spec); - match result { - Error::ContractInvoke(enhanced_msg, detail) => { - assert!( - enhanced_msg.contains("#1"), - "expected enhanced msg to contain '#1', got: {enhanced_msg}" - ); - assert!( - enhanced_msg.contains("NumberMustBeOdd"), - "expected enhanced msg to contain resolved name, got: {enhanced_msg}" - ); - assert_eq!(detail, "NumberMustBeOdd: Please provide an odd number"); - } - other => panic!("expected ContractInvoke, got: {other:#?}"), - } - } - - #[test] - fn enhance_simulation_error_with_event_log() { - let spec = test_spec(vec![( - 1, - "NotFound", - "The requested resource was not found.", - )]); - let error_str = "HostError: Error(Contract, #1)\n\n\ - Event log (newest first):\n 0: [Diagnostic Event] ..."; - let rpc_err = soroban_rpc::Error::TransactionSimulationFailed(error_str.to_string()); - let err = Error::Rpc(rpc_err); - let result = enhance_error(err, &spec); - match result { - Error::ContractInvoke(enhanced_msg, detail) => { - // The detail should appear BEFORE the event log - let code_pos = enhanced_msg.find("#1").unwrap(); - let detail_pos = enhanced_msg.find("NotFound:").unwrap(); - let event_pos = enhanced_msg.find("Event log").unwrap(); - assert!( - detail_pos < event_pos, - "detail ({detail_pos}) should appear before event log ({event_pos})" - ); - assert!( - detail_pos > code_pos, - "detail ({detail_pos}) should appear after error code ({code_pos})" - ); - assert_eq!(detail, "NotFound: The requested resource was not found."); - } - other => panic!("expected ContractInvoke, got: {other:#?}"), - } - } - - #[test] - fn enhance_submission_error() { - let spec = test_spec(vec![(1, "NumberMustBeOdd", "Please provide an odd number")]); - let debug_msg = "TransactionResult { result: Err(Contract(1)) }".to_string(); - let rpc_err = soroban_rpc::Error::TransactionSubmissionFailed(debug_msg); - let err = Error::Rpc(rpc_err); - let result = enhance_error(err, &spec); - match result { - Error::ContractInvoke(enhanced_msg, detail) => { - assert!(enhanced_msg.contains("Contract(1)")); - assert!(enhanced_msg.contains("NumberMustBeOdd")); - assert_eq!(detail, "NumberMustBeOdd: Please provide an odd number"); - } - other => panic!("expected ContractInvoke, got: {other:#?}"), - } - } - - #[test] - fn enhance_error_no_match_returns_original() { - let spec = test_spec(vec![(1, "NumberMustBeOdd", "Please provide an odd number")]); - let rpc_err = - soroban_rpc::Error::TransactionSimulationFailed("some other error".to_string()); - let err = Error::Rpc(rpc_err); - let result = enhance_error(err, &spec); - assert!(matches!(result, Error::Rpc(_))); - } - - #[test] - fn enhance_error_unknown_code_returns_original() { - let spec = test_spec(vec![(1, "NumberMustBeOdd", "Please provide an odd number")]); - let rpc_err = soroban_rpc::Error::TransactionSimulationFailed( - "HostError: Error(Contract, #99)".to_string(), - ); - let err = Error::Rpc(rpc_err); - let result = enhance_error(err, &spec); - assert!(matches!(result, Error::Rpc(_))); - } - - #[test] - fn enhance_error_empty_doc() { - let spec = test_spec(vec![(1, "SomeError", "")]); - let rpc_err = soroban_rpc::Error::TransactionSimulationFailed( - "HostError: Error(Contract, #1)".to_string(), - ); - let err = Error::Rpc(rpc_err); - let result = enhance_error(err, &spec); - match result { - Error::ContractInvoke(enhanced_msg, detail) => { - assert!(enhanced_msg.contains("SomeError")); - assert_eq!(detail, "SomeError"); - } - other => panic!("expected ContractInvoke, got: {other:#?}"), - } - } -} From 3418d60c2d761c3ab46793bb0b6e303d6a7e0bc6 Mon Sep 17 00:00:00 2001 From: Blaine Heffron Date: Thu, 29 Jan 2026 18:00:25 -0500 Subject: [PATCH 3/3] minimal unit tests --- .../src/commands/contract/invoke.rs | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/cmd/soroban-cli/src/commands/contract/invoke.rs b/cmd/soroban-cli/src/commands/contract/invoke.rs index 9784f14811..381f95766c 100644 --- a/cmd/soroban-cli/src/commands/contract/invoke.rs +++ b/cmd/soroban-cli/src/commands/contract/invoke.rs @@ -583,3 +583,42 @@ fn has_auth(sim_res: &SimulateTransactionResponse) -> Result { .iter() .any(|SimulateHostFunctionResult { auth, .. }| !auth.is_empty())) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn extract_code_from_simulation_format() { + let msg = "transaction simulation failed: HostError: Error(Contract, #1)"; + assert_eq!(extract_contract_error_code(msg), Some(1)); + } + + #[test] + fn extract_code_from_debug_format() { + // Debug format from TransactionSubmissionFailed errors, which pretty-print + // the TransactionResult XDR containing ScError::Contract(u32). + assert_eq!( + extract_contract_error_code("transaction submission failed: Contract(1)"), + Some(1), + ); + // Pretty-printed variant with whitespace around the number. + assert_eq!( + extract_contract_error_code("Err(\n Contract(\n 1,\n ),\n)"), + Some(1), + ); + } + + #[test] + fn extract_code_ignores_non_contract_errors() { + // Budget errors also use `#N` but should not match. + assert_eq!( + extract_contract_error_code( + "transaction simulation failed: HostError: Error(Budget, #3)" + ), + None, + ); + // Bare `#N` without the `Contract, ` prefix should not match. + assert_eq!(extract_contract_error_code("something #123 happened"), None); + } +}