From 58c1fd1190b9b7e8f7410c3c3f60f220fb95a9e5 Mon Sep 17 00:00:00 2001 From: Blaine Heffron Date: Thu, 29 Jan 2026 17:17:12 -0500 Subject: [PATCH 01/10] 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 02/10] 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 03/10] 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); + } +} From c9fb6f016b3f7217f98f11a8a8979e1892ad4063 Mon Sep 17 00:00:00 2001 From: Blaine Heffron Date: Wed, 4 Feb 2026 09:17:10 -0500 Subject: [PATCH 04/10] contract invoke error fields doc --- .../tests/it/integration/custom_types.rs | 2 +- cmd/soroban-cli/src/commands/contract/invoke.rs | 15 ++++++++++++--- 2 files changed, 13 insertions(+), 4 deletions(-) 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 677249a8ca..6d82ca731e 100644 --- a/cmd/crates/soroban-test/tests/it/integration/custom_types.rs +++ b/cmd/crates/soroban-test/tests/it/integration/custom_types.rs @@ -196,7 +196,7 @@ async fn number_arg_return_err(sandbox: &TestEnv, id: &str) { .await .unwrap_err(); match &res { - commands::contract::invoke::Error::ContractInvoke(enhanced_msg, detail) => { + commands::contract::invoke::Error::ContractInvoke { message: enhanced_msg, detail } => { assert!( enhanced_msg.contains("#1"), "expected enhanced msg to contain '#1', got: {enhanced_msg}" diff --git a/cmd/soroban-cli/src/commands/contract/invoke.rs b/cmd/soroban-cli/src/commands/contract/invoke.rs index 381f95766c..b7a1e6bdcf 100644 --- a/cmd/soroban-cli/src/commands/contract/invoke.rs +++ b/cmd/soroban-cli/src/commands/contract/invoke.rs @@ -132,8 +132,14 @@ pub enum Error { #[error(transparent)] Locator(#[from] locator::Error), - #[error("{0}")] - ContractInvoke(String, String), + #[error("{message}")] + ContractInvoke { + /// Full error message with the resolved error name inserted after the + /// contract error code. + message: String, + /// The resolved error name and doc string (e.g. `"ErrorName: description"`). + detail: String, + }, #[error(transparent)] StrKey(#[from] stellar_strkey::DecodeError), @@ -539,7 +545,10 @@ fn enhance_error(err: Error, spec: &soroban_spec_tools::Spec) -> Error { ); let enhanced_msg = insert_detail_after_error_code(&error_msg, &detail); - Error::ContractInvoke(enhanced_msg, detail) + Error::ContractInvoke { + message: enhanced_msg, + detail, + } } /// Insert a detail string into an error message right after the contract error From 468223e23668dc64d2a4ca0cc7dc4a81fa8943e1 Mon Sep 17 00:00:00 2001 From: Blaine Heffron Date: Wed, 4 Feb 2026 11:25:30 -0500 Subject: [PATCH 05/10] search spec / events for error instead of string parse --- cmd/crates/soroban-spec-tools/src/lib.rs | 24 - cmd/soroban-cli/src/assembled.rs | 16 +- .../src/commands/contract/invoke.rs | 414 +++++++++++++----- 3 files changed, 327 insertions(+), 127 deletions(-) diff --git a/cmd/crates/soroban-spec-tools/src/lib.rs b/cmd/crates/soroban-spec-tools/src/lib.rs index 4da8ac4849..c336f39c3d 100644 --- a/cmd/crates/soroban-spec-tools/src/lib.rs +++ b/cmd/crates/soroban-spec-tools/src/lib.rs @@ -221,30 +221,6 @@ 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/soroban-cli/src/assembled.rs b/cmd/soroban-cli/src/assembled.rs index 02097e8138..ff56d95110 100644 --- a/cmd/soroban-cli/src/assembled.rs +++ b/cmd/soroban-cli/src/assembled.rs @@ -11,12 +11,11 @@ use soroban_rpc::{ Error, LogEvents, LogResources, ResourceConfig, RestorePreamble, SimulateTransactionResponse, }; -pub async fn simulate_and_assemble_transaction( +pub async fn simulate_transaction( client: &soroban_rpc::Client, tx: &Transaction, resource_config: Option, - resource_fee: Option, -) -> Result { +) -> Result { let envelope = TransactionEnvelope::Tx(TransactionV1Envelope { tx: tx.clone(), signatures: VecM::default(), @@ -32,6 +31,17 @@ pub async fn simulate_and_assemble_transaction( .await?; tracing::trace!("{sim_res:#?}"); + Ok(sim_res) +} + +pub async fn simulate_and_assemble_transaction( + client: &soroban_rpc::Client, + tx: &Transaction, + resource_config: Option, + resource_fee: Option, +) -> Result { + let sim_res = simulate_transaction(client, tx, resource_config).await?; + if let Some(e) = &sim_res.error { crate::log::event::all(&sim_res.events()?); Err(Error::TransactionSimulationFailed(e.clone())) diff --git a/cmd/soroban-cli/src/commands/contract/invoke.rs b/cmd/soroban-cli/src/commands/contract/invoke.rs index b7a1e6bdcf..03280b9681 100644 --- a/cmd/soroban-cli/src/commands/contract/invoke.rs +++ b/cmd/soroban-cli/src/commands/contract/invoke.rs @@ -6,18 +6,19 @@ use std::str::FromStr; use std::{fmt::Debug, fs, io}; use clap::{Parser, ValueEnum}; -use soroban_rpc::{Client, SimulateHostFunctionResult, SimulateTransactionResponse}; +use soroban_rpc::{ + Client, GetTransactionResponse, SimulateHostFunctionResult, SimulateTransactionResponse, +}; use soroban_spec::read::FromWasmError; use super::super::events; use super::arg_parsing; -use crate::assembled::Assembled; use crate::commands::tx::fetch; use crate::log::extract_events; use crate::print::Print; use crate::utils::deprecate_message; use crate::{ - assembled::simulate_and_assemble_transaction, + assembled::Assembled, commands::{ contract::arg_parsing::{build_host_function_parameters, output_to_string}, global, @@ -28,11 +29,11 @@ use crate::{ get_spec::{self, get_remote_contract_spec}, print, rpc, xdr::{ - self, AccountEntry, AccountEntryExt, AccountId, ContractEvent, ContractEventType, - DiagnosticEvent, HostFunction, InvokeContractArgs, InvokeHostFunctionOp, Limits, Memo, - MuxedAccount, Operation, OperationBody, Preconditions, PublicKey, ScSpecEntry, - SequenceNumber, String32, StringM, Thresholds, Transaction, TransactionExt, Uint256, VecM, - WriteXdr, + self, AccountEntry, AccountEntryExt, AccountId, ContractEvent, ContractEventBody, + ContractEventType, ContractEventV0, DiagnosticEvent, HostFunction, InvokeContractArgs, + InvokeHostFunctionOp, Limits, Memo, MuxedAccount, Operation, OperationBody, Preconditions, + PublicKey, ScError, ScSpecEntry, ScSpecTypeDef, ScSpecTypeUdt, ScVal, SequenceNumber, + String32, StringM, Thresholds, Transaction, TransactionExt, Uint256, VecM, WriteXdr, }, Pwd, }; @@ -238,6 +239,8 @@ impl Cmd { host_function_params: &InvokeContractArgs, account_details: &AccountEntry, rpc_client: &Client, + spec: &soroban_spec_tools::Spec, + function: &str, ) -> Result { let sequence: i64 = account_details.seq_num.0; let AccountId(PublicKey::PublicKeyTypeEd25519(account_id)) = @@ -245,13 +248,15 @@ impl Cmd { let tx = build_invoke_contract_tx(host_function_params.clone(), sequence + 1, 100, account_id)?; - Ok(simulate_and_assemble_transaction( + simulate_and_enhance( rpc_client, &tx, self.resources.resource_config(), self.resources.resource_fee, + spec, + function, ) - .await?) + .await } #[allow(clippy::too_many_lines)] @@ -310,9 +315,14 @@ impl Cmd { (ShouldSend::Yes, None) } else { let assembled = self - .simulate(&host_function_params, &default_account_entry(), &client) - .await - .map_err(|e| enhance_error(e, &spec))?; + .simulate( + &host_function_params, + &default_account_entry(), + &client, + &spec, + &function, + ) + .await?; let should_send = self.should_send_tx(&assembled.sim_res)?; (should_send, Some(assembled)) }; @@ -359,14 +369,15 @@ impl Cmd { return Ok(TxnResult::Txn(tx)); } - let txn = simulate_and_assemble_transaction( + let txn = simulate_and_enhance( &client, &tx, self.resources.resource_config(), self.resources.resource_fee, + &spec, + &function, ) - .await - .map_err(|e| enhance_error(Error::Rpc(e), &spec))?; + .await?; let assembled = self.resources.apply_to_assembled_txn(txn); let mut txn = Box::new(assembled.transaction().clone()); let sim_res = assembled.sim_response(); @@ -380,10 +391,25 @@ impl Cmd { *txn = tx; } - let res = client - .send_transaction_polling(&config.sign(*txn, quiet).await?) - .await - .map_err(|e| enhance_error(Error::Rpc(e), &spec))?; + let signed_tx = config.sign(*txn, quiet).await?; + let hash = client.send_transaction(&signed_tx).await?; + + let res = match client.get_transaction_polling(&hash, None).await { + Ok(res) => res, + Err(e) => { + // For submission failures, extract the contract error code + if matches!(&e, rpc::Error::TransactionSubmissionFailed(_)) { + if let Ok(response) = client.get_transaction(&hash).await { + if let Some(err) = + enhance_error_from_meta(&response, &e.to_string(), &spec, &function) + { + return Err(err); + } + } + } + return Err(Error::Rpc(e)); + } + }; self.resources.print_cost_info(&res)?; @@ -461,77 +487,127 @@ 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`. +/// Simulate a transaction and assemble the result, enhancing any contract error +/// with human-readable information from the spec. On simulation failure, the +/// contract error code is extracted from the structured simulation response +async fn simulate_and_enhance( + client: &Client, + tx: &Transaction, + resource_config: Option, + resource_fee: Option, + spec: &soroban_spec_tools::Spec, + function: &str, +) -> Result { + let sim_res = + crate::assembled::simulate_transaction(client, tx, resource_config).await?; + + if let Some(e) = &sim_res.error { + crate::log::event::all(&sim_res.events()?); + return Err(enhance_simulation_error(e, &sim_res, spec, function)); + } + + Ok(Assembled::new(tx, sim_res, resource_fee)?) +} + +/// Try to enhance a simulation error by extracting the contract error code from +/// the structured simulation response (results and diagnostic events) +fn enhance_simulation_error( + error_msg: &str, + sim_res: &SimulateTransactionResponse, + spec: &soroban_spec_tools::Spec, + function: &str, +) -> Error { + if let Some(code) = extract_contract_error_from_sim(sim_res) { + if let Some(err) = build_enhanced_error(code, error_msg, spec, function) { + return err; + } + } + Error::Rpc(rpc::Error::TransactionSimulationFailed( + error_msg.to_string(), + )) +} + +/// Extract a contract error code from a simulation response's structured data. /// -/// 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); +/// Checks the simulation results first in case the return value is +/// `ScVal::Error(ScError::Contract(code))`, then scans diagnostic events +/// (in reverse order) for the outermost contract error code. +fn extract_contract_error_from_sim(sim_res: &SimulateTransactionResponse) -> Option { + if let Ok(results) = sim_res.results() { + for result in &results { + if let ScVal::Error(ScError::Contract(code)) = &result.xdr { + 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); - } + if let Ok(events) = sim_res.events() { + if let Some(code) = extract_contract_error_from_events(&events) { + 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. +/// Scan diagnostic events for an `ScError::Contract(code)` value in the event +/// data or topics. /// -/// 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, - }; +/// Events are scanned in **reverse** order (newest first) so that the +/// outermost error is returned. In cross-contract call scenarios, earlier +/// events contain inner-contract error codes while the last error event +/// carries the final remapped code from the outermost contract. +fn extract_contract_error_from_events(events: &[DiagnosticEvent]) -> Option { + for event in events.iter().rev() { + let ContractEventBody::V0(ContractEventV0 { topics, data, .. }) = &event.event.body; + + if let ScVal::Error(ScError::Contract(code)) = data { + return Some(*code); + } + for topic in topics.iter() { + if let ScVal::Error(ScError::Contract(code)) = topic { + return Some(*code); + } + } + } + None +} - let Some(code) = extract_contract_error_code(&error_msg) else { - return err; +/// Try to extract a contract error from a failed transaction's structured XDR +/// metadata by reading the `ScError::Contract(u32)` value directly from the +/// `SorobanTransactionMeta` return value. +/// +/// Returns `Some(Error::ContractInvoke { .. })` if a contract error was found +/// and resolved in the spec, or `None` to let the caller fall back. +fn enhance_error_from_meta( + response: &GetTransactionResponse, + rpc_error_msg: &str, + spec: &soroban_spec_tools::Spec, + function: &str, +) -> Option { + let code = match response.return_value() { + Ok(ScVal::Error(ScError::Contract(code))) => code, + _ => return None, }; + build_enhanced_error(code, rpc_error_msg, spec, function) +} - let Some((_enum_info, case)) = spec.find_error_type_any(code) else { - return err; - }; +/// Build an enhanced `ContractInvoke` error by looking up a contract error code +/// in the spec and inserting the resolved name and documentation into the error +/// message. +/// +/// The lookup is scoped to the error type declared in the function's return type +/// (e.g. `Result` only searches the `MyError` enum). If the +/// function's return type cannot be resolved, falls back to searching all error +/// enums in the spec. +fn build_enhanced_error( + code: u32, + error_msg: &str, + spec: &soroban_spec_tools::Spec, + function: &str, +) -> Option { + let case = find_error_for_function(spec, function, code) + .or_else(|| spec.find_error_type(code).ok())?; let name = case.name.to_utf8_string_lossy(); let doc = case.doc.to_utf8_string_lossy(); @@ -544,11 +620,35 @@ fn enhance_error(err: Error, spec: &soroban_spec_tools::Spec) -> Error { } ); - let enhanced_msg = insert_detail_after_error_code(&error_msg, &detail); - Error::ContractInvoke { + let enhanced_msg = insert_detail_after_error_code(error_msg, &detail); + Some(Error::ContractInvoke { message: enhanced_msg, detail, - } + }) +} + +/// Look up a contract error code in the specific error enum declared in the +/// function's return type. Returns `None` if the function doesn't declare a +/// `Result` return type with a UDT error enum, or if the code isn't found in +/// that enum. +fn find_error_for_function<'a>( + spec: &'a soroban_spec_tools::Spec, + function: &str, + code: u32, +) -> Option<&'a xdr::ScSpecUdtErrorEnumCaseV0> { + let func = spec.find_function(function).ok()?; + let output = func.outputs.first()?; + let ScSpecTypeDef::Result(result_type) = output else { + return None; + }; + let ScSpecTypeDef::Udt(ScSpecTypeUdt { name }) = result_type.error_type.as_ref() else { + return None; + }; + let error_enum_name = name.to_utf8_string_lossy(); + let ScSpecEntry::UdtErrorEnumV0(error_enum) = spec.find(&error_enum_name).ok()? else { + return None; + }; + error_enum.cases.iter().find(|c| c.value == code) } /// Insert a detail string into an error message right after the contract error @@ -596,38 +696,152 @@ fn has_auth(sim_res: &SimulateTransactionResponse) -> Result { #[cfg(test)] mod tests { use super::*; + use xdr::ExtensionPoint; #[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)); + fn extract_contract_error_from_event_data() { + let events = vec![DiagnosticEvent { + in_successful_contract_call: false, + event: ContractEvent { + ext: ExtensionPoint::V0, + contract_id: None, + type_: ContractEventType::Diagnostic, + body: ContractEventBody::V0(ContractEventV0 { + topics: VecM::default(), + data: ScVal::Error(ScError::Contract(1)), + }), + }, + }]; + assert_eq!(extract_contract_error_from_events(&events), 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. + fn extract_contract_error_from_event_topic() { + let events = vec![DiagnosticEvent { + in_successful_contract_call: false, + event: ContractEvent { + ext: ExtensionPoint::V0, + contract_id: None, + type_: ContractEventType::Diagnostic, + body: ContractEventBody::V0(ContractEventV0 { + topics: vec![ScVal::Error(ScError::Contract(42))].try_into().unwrap(), + data: ScVal::Void, + }), + }, + }]; + assert_eq!(extract_contract_error_from_events(&events), Some(42)); + } + + #[test] + fn extract_contract_error_ignores_non_contract_events() { + let events = vec![DiagnosticEvent { + in_successful_contract_call: true, + event: ContractEvent { + ext: ExtensionPoint::V0, + contract_id: None, + type_: ContractEventType::Diagnostic, + body: ContractEventBody::V0(ContractEventV0 { + topics: VecM::default(), + data: ScVal::Error(ScError::Budget(xdr::ScErrorCode::ExceededLimit)), + }), + }, + }]; + assert_eq!(extract_contract_error_from_events(&events), None); + } + + #[test] + fn extract_contract_error_returns_outermost_code() { + // Simulates a cross-contract call where events contain both the inner + // contract's error (code 1) and the outer contract's remapped error + // (code 7). The extraction should return the outermost (last) code. + let events = vec![ + // Inner contract: Error(Contract, #1) in topic + DiagnosticEvent { + in_successful_contract_call: false, + event: ContractEvent { + ext: ExtensionPoint::V0, + contract_id: None, + type_: ContractEventType::Diagnostic, + body: ContractEventBody::V0(ContractEventV0 { + topics: vec![ + ScVal::Symbol(xdr::ScSymbol("error".try_into().unwrap())), + ScVal::Error(ScError::Contract(1)), + ] + .try_into() + .unwrap(), + data: ScVal::String(xdr::ScString( + "escalating Ok(ScErrorType::Contract) frame-exit to Err" + .try_into() + .unwrap(), + )), + }), + }, + }, + // Outer contract: try_call failed, Error(Contract, #1) in topic + DiagnosticEvent { + in_successful_contract_call: false, + event: ContractEvent { + ext: ExtensionPoint::V0, + contract_id: None, + type_: ContractEventType::Diagnostic, + body: ContractEventBody::V0(ContractEventV0 { + topics: vec![ + ScVal::Symbol(xdr::ScSymbol("error".try_into().unwrap())), + ScVal::Error(ScError::Contract(1)), + ] + .try_into() + .unwrap(), + data: ScVal::String(xdr::ScString( + "contract try_call failed".try_into().unwrap(), + )), + }), + }, + }, + // Outer contract: final remapped Error(Contract, #7) in topic + DiagnosticEvent { + in_successful_contract_call: false, + event: ContractEvent { + ext: ExtensionPoint::V0, + contract_id: None, + type_: ContractEventType::Diagnostic, + body: ContractEventBody::V0(ContractEventV0 { + topics: vec![ + ScVal::Symbol(xdr::ScSymbol("error".try_into().unwrap())), + ScVal::Error(ScError::Contract(7)), + ] + .try_into() + .unwrap(), + data: ScVal::String(xdr::ScString( + "escalating Ok(ScErrorType::Contract) frame-exit to Err" + .try_into() + .unwrap(), + )), + }), + }, + }, + ]; + assert_eq!(extract_contract_error_from_events(&events), Some(7)); + } + + #[test] + fn insert_detail_with_event_log() { + // Simulation errors have the event log separated by a blank line. + let msg = "HostError: Error(Contract, #1)\n\nEvent log (newest first):"; + let result = insert_detail_after_error_code(msg, "NumberMustBeOdd: desc"); assert_eq!( - extract_contract_error_code("Err(\n Contract(\n 1,\n ),\n)"), - Some(1), + result, + "HostError: Error(Contract, #1)\nNumberMustBeOdd: desc\n\nEvent log (newest first):" ); } #[test] - fn extract_code_ignores_non_contract_errors() { - // Budget errors also use `#N` but should not match. + fn insert_detail_without_event_log() { + // Messages without an event log section get the detail appended. + let msg = "transaction submission failed: InvokeHostFunction(Trapped)"; + let result = insert_detail_after_error_code(msg, "NumberMustBeOdd: desc"); assert_eq!( - extract_contract_error_code( - "transaction simulation failed: HostError: Error(Budget, #3)" - ), - None, + result, + "transaction submission failed: InvokeHostFunction(Trapped)\nNumberMustBeOdd: desc" ); - // Bare `#N` without the `Contract, ` prefix should not match. - assert_eq!(extract_contract_error_code("something #123 happened"), None); } } From 68153782d837cad6042a8ea29567c42dea458058 Mon Sep 17 00:00:00 2001 From: Blaine Heffron Date: Wed, 4 Feb 2026 11:27:12 -0500 Subject: [PATCH 06/10] clippy, fmt --- .../tests/it/integration/custom_types.rs | 5 ++++- cmd/soroban-cli/src/commands/contract/invoke.rs | 12 ++++++------ 2 files changed, 10 insertions(+), 7 deletions(-) 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 6d82ca731e..c535296382 100644 --- a/cmd/crates/soroban-test/tests/it/integration/custom_types.rs +++ b/cmd/crates/soroban-test/tests/it/integration/custom_types.rs @@ -196,7 +196,10 @@ async fn number_arg_return_err(sandbox: &TestEnv, id: &str) { .await .unwrap_err(); match &res { - commands::contract::invoke::Error::ContractInvoke { message: enhanced_msg, detail } => { + commands::contract::invoke::Error::ContractInvoke { + message: enhanced_msg, + detail, + } => { assert!( enhanced_msg.contains("#1"), "expected enhanced msg to contain '#1', got: {enhanced_msg}" diff --git a/cmd/soroban-cli/src/commands/contract/invoke.rs b/cmd/soroban-cli/src/commands/contract/invoke.rs index 03280b9681..087d907dbf 100644 --- a/cmd/soroban-cli/src/commands/contract/invoke.rs +++ b/cmd/soroban-cli/src/commands/contract/invoke.rs @@ -498,8 +498,7 @@ async fn simulate_and_enhance( spec: &soroban_spec_tools::Spec, function: &str, ) -> Result { - let sim_res = - crate::assembled::simulate_transaction(client, tx, resource_config).await?; + let sim_res = crate::assembled::simulate_transaction(client, tx, resource_config).await?; if let Some(e) = &sim_res.error { crate::log::event::all(&sim_res.events()?); @@ -585,9 +584,8 @@ fn enhance_error_from_meta( spec: &soroban_spec_tools::Spec, function: &str, ) -> Option { - let code = match response.return_value() { - Ok(ScVal::Error(ScError::Contract(code))) => code, - _ => return None, + let Ok(ScVal::Error(ScError::Contract(code))) = response.return_value() else { + return None; }; build_enhanced_error(code, rpc_error_msg, spec, function) } @@ -724,7 +722,9 @@ mod tests { contract_id: None, type_: ContractEventType::Diagnostic, body: ContractEventBody::V0(ContractEventV0 { - topics: vec![ScVal::Error(ScError::Contract(42))].try_into().unwrap(), + topics: vec![ScVal::Error(ScError::Contract(42))] + .try_into() + .unwrap(), data: ScVal::Void, }), }, From ae94e87206fdbf401643f371e72df6a9c1624933 Mon Sep 17 00:00:00 2001 From: Blaine Heffron Date: Wed, 4 Feb 2026 11:42:31 -0500 Subject: [PATCH 07/10] only resolve error codes declared in the called function's spec --- cmd/soroban-cli/src/commands/contract/invoke.rs | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/cmd/soroban-cli/src/commands/contract/invoke.rs b/cmd/soroban-cli/src/commands/contract/invoke.rs index 087d907dbf..bff7c59829 100644 --- a/cmd/soroban-cli/src/commands/contract/invoke.rs +++ b/cmd/soroban-cli/src/commands/contract/invoke.rs @@ -595,18 +595,14 @@ fn enhance_error_from_meta( /// message. /// /// The lookup is scoped to the error type declared in the function's return type -/// (e.g. `Result` only searches the `MyError` enum). If the -/// function's return type cannot be resolved, falls back to searching all error -/// enums in the spec. +/// (e.g. `Result` only searches the `MyError` enum). fn build_enhanced_error( code: u32, error_msg: &str, spec: &soroban_spec_tools::Spec, function: &str, ) -> Option { - let case = find_error_for_function(spec, function, code) - .or_else(|| spec.find_error_type(code).ok())?; - + let case = find_error_for_function(spec, function, code)?; let name = case.name.to_utf8_string_lossy(); let doc = case.doc.to_utf8_string_lossy(); let detail = format!( From db1ba0fe5ec6a0de592f55c09b20a196101b7a29 Mon Sep 17 00:00:00 2001 From: Blaine Heffron Date: Thu, 5 Feb 2026 13:12:48 -0500 Subject: [PATCH 08/10] Fix panic_with_error! and cross contract call code misresolution --- Cargo.lock | 7 + .../test-wasms/custom_type/src/lib.rs | 12 +- .../test-wasms/error_caller/Cargo.toml | 17 ++ .../test-wasms/error_caller/src/lib.rs | 85 ++++++ .../soroban-test/tests/it/integration.rs | 1 + .../tests/it/integration/contract_errors.rs | 276 ++++++++++++++++++ .../soroban-test/tests/it/integration/util.rs | 5 + .../src/commands/contract/invoke.rs | 274 ++++------------- 8 files changed, 453 insertions(+), 224 deletions(-) create mode 100644 cmd/crates/soroban-test/tests/fixtures/test-wasms/error_caller/Cargo.toml create mode 100644 cmd/crates/soroban-test/tests/fixtures/test-wasms/error_caller/src/lib.rs create mode 100644 cmd/crates/soroban-test/tests/it/integration/contract_errors.rs diff --git a/Cargo.lock b/Cargo.lock index bf7fe379e0..c7e2711673 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5797,6 +5797,13 @@ dependencies = [ "soroban-sdk", ] +[[package]] +name = "test_error_caller" +version = "25.1.0" +dependencies = [ + "soroban-sdk", +] + [[package]] name = "test_hello_world" version = "25.1.0" diff --git a/cmd/crates/soroban-test/tests/fixtures/test-wasms/custom_type/src/lib.rs b/cmd/crates/soroban-test/tests/fixtures/test-wasms/custom_type/src/lib.rs index 4ca43b0146..0fabe04c9b 100644 --- a/cmd/crates/soroban-test/tests/fixtures/test-wasms/custom_type/src/lib.rs +++ b/cmd/crates/soroban-test/tests/fixtures/test-wasms/custom_type/src/lib.rs @@ -1,7 +1,7 @@ #![no_std] use soroban_sdk::{ - contract, contracterror, contractimpl, contracttype, symbol_short, vec, Address, Bytes, BytesN, - Duration, Env, Map, String, Symbol, Timepoint, Val, Vec, I256, U256, + contract, contracterror, contractimpl, contracttype, panic_with_error, symbol_short, vec, + Address, Bytes, BytesN, Duration, Env, Map, String, Symbol, Timepoint, Val, Vec, I256, U256, }; #[contract] @@ -90,6 +90,14 @@ impl Contract { } } + pub fn panic_on_even(env: Env, u32_: u32) -> Result { + if u32_ % 2 == 1 { + Ok(u32_) + } else { + panic_with_error!(&env, Error::NumberMustBeOdd) + } + } + pub fn u32_(_env: Env, u32_: u32) -> u32 { u32_ } diff --git a/cmd/crates/soroban-test/tests/fixtures/test-wasms/error_caller/Cargo.toml b/cmd/crates/soroban-test/tests/fixtures/test-wasms/error_caller/Cargo.toml new file mode 100644 index 0000000000..65e7f29c62 --- /dev/null +++ b/cmd/crates/soroban-test/tests/fixtures/test-wasms/error_caller/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "test_error_caller" +version = "25.1.0" +authors = ["Stellar Development Foundation "] +license = "Apache-2.0" +edition = "2021" +publish = false + +[lib] +crate-type = ["cdylib", "rlib"] +doctest = false + +[dependencies] +soroban-sdk = { workspace = true } + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"]} diff --git a/cmd/crates/soroban-test/tests/fixtures/test-wasms/error_caller/src/lib.rs b/cmd/crates/soroban-test/tests/fixtures/test-wasms/error_caller/src/lib.rs new file mode 100644 index 0000000000..c78b5b0f92 --- /dev/null +++ b/cmd/crates/soroban-test/tests/fixtures/test-wasms/error_caller/src/lib.rs @@ -0,0 +1,85 @@ +#![no_std] +use soroban_sdk::{ + contract, contracterror, contractimpl, panic_with_error, Address, Env, IntoVal, InvokeError, + Symbol, +}; + +mod custom_types { + soroban_sdk::contractimport!( + file = "../../../../../../../target/wasm32v1-none/test-wasms/test_custom_types.wasm" + ); +} + +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] +#[repr(u32)] +pub enum OuterError { + /// Caught inner error and remapped + RemappedInner = 10, + /// Uses the same error code as the inner contract + SameCodeAsInner = 1, +} + +#[contract] +pub struct ErrorCallerContract; + +#[contractimpl] +impl ErrorCallerContract { + /// Try-calls inner's u32_fail_on_even. Catches error, returns OuterError::RemappedInner. + pub fn catch_call(env: Env, inner: Address, u32_: u32) -> Result { + match env.try_invoke_contract::( + &inner, + &Symbol::new(&env, "u32_fail_on_even"), + (u32_,).into_val(&env), + ) { + Ok(Ok(val)) => Ok(val), + _ => Err(OuterError::RemappedInner), + } + } + + /// Try-calls inner's u32_fail_on_even. Catches error and returns SameCodeAsInner. + pub fn catch_call_same_code(env: Env, inner: Address, u32_: u32) -> Result { + match env.try_invoke_contract::( + &inner, + &Symbol::new(&env, "u32_fail_on_even"), + (u32_,).into_val(&env), + ) { + Ok(Ok(val)) => Ok(val), + _ => Err(OuterError::SameCodeAsInner), + } + } + + /// Try-calls inner via contractimport. Catches error, returns OuterError::RemappedInner. + pub fn catch_call_import(env: Env, inner: Address, u32_: u32) -> Result { + let client = custom_types::Client::new(&env, &inner); + match client.try_u32_fail_on_even(&u32_) { + Ok(Ok(val)) => Ok(val), + _ => Err(OuterError::RemappedInner), + } + } + + /// Non-try call to inner's u32_fail_on_even. If inner fails, propagates as VM trap. + pub fn call(env: Env, inner: Address, u32_: u32) -> Result { + Ok(env.invoke_contract( + &inner, + &Symbol::new(&env, "u32_fail_on_even"), + (u32_,).into_val(&env), + )) + } + + /// Non-try call to inner via contractimport. If inner fails, propagates as VM trap. + pub fn call_import(env: Env, inner: Address, u32_: u32) -> Result { + let client = custom_types::Client::new(&env, &inner); + Ok(client.u32_fail_on_even(&u32_)) + } + + /// Try-calls inner but returns non-Result type. Panics with error if inner fails. + /// Since this function doesn't return Result, the error shouldn't be resolved. + pub fn catch_panic_no_result(env: Env, inner: Address, u32_: u32) -> u32 { + let client = custom_types::Client::new(&env, &inner); + match client.try_u32_fail_on_even(&u32_) { + Ok(Ok(val)) => val, + _ => panic_with_error!(&env, OuterError::RemappedInner), + } + } +} diff --git a/cmd/crates/soroban-test/tests/it/integration.rs b/cmd/crates/soroban-test/tests/it/integration.rs index 386eca2665..c39087e044 100644 --- a/cmd/crates/soroban-test/tests/it/integration.rs +++ b/cmd/crates/soroban-test/tests/it/integration.rs @@ -1,6 +1,7 @@ mod bindings; mod constructor; mod contract; +mod contract_errors; mod cookbook; mod custom_types; mod dotenv; diff --git a/cmd/crates/soroban-test/tests/it/integration/contract_errors.rs b/cmd/crates/soroban-test/tests/it/integration/contract_errors.rs new file mode 100644 index 0000000000..29656f18d2 --- /dev/null +++ b/cmd/crates/soroban-test/tests/it/integration/contract_errors.rs @@ -0,0 +1,276 @@ +use soroban_cli::commands; +use soroban_test::TestEnv; + +use crate::integration::util::{ + deploy_contract, deploy_custom, deploy_error_caller, extend_contract, DeployOptions, + CUSTOM_TYPES, +}; + +#[tokio::test] +async fn direct_result_error_resolves_name() { + let sandbox = &TestEnv::new(); + let id = &deploy_custom(sandbox).await; + extend_contract(sandbox, id).await; + + let err = sandbox + .invoke_with_test(&["--id", id, "--", "u32_fail_on_even", "--u32_=2"]) + .await + .unwrap_err(); + + match &err { + commands::contract::invoke::Error::ContractInvoke { + detail, message, .. + } => { + assert!( + detail.starts_with("NumberMustBeOdd"), + "expected detail to start with 'NumberMustBeOdd', got: {detail}" + ); + assert!( + message.contains("NumberMustBeOdd"), + "expected message to include 'NumberMustBeOdd', got: {message}" + ); + } + other => panic!("expected ContractInvoke error, got: {other:#?}"), + } +} + +#[tokio::test] +async fn panic_with_error_resolves_name() { + let sandbox = &TestEnv::new(); + let id = &deploy_custom(sandbox).await; + extend_contract(sandbox, id).await; + + let err = sandbox + .invoke_with_test(&["--id", id, "--", "panic_on_even", "--u32_=2"]) + .await + .unwrap_err(); + + match &err { + commands::contract::invoke::Error::ContractInvoke { + detail, message, .. + } => { + assert!( + detail.starts_with("NumberMustBeOdd"), + "expected detail to start with 'NumberMustBeOdd', got: {detail}" + ); + assert!( + message.contains("NumberMustBeOdd"), + "expected message to include 'NumberMustBeOdd', got: {message}" + ); + } + other => panic!("expected ContractInvoke error, got: {other:#?}"), + } +} + +#[tokio::test] +async fn cross_contract_catch_call_resolves_outer_error() { + let sandbox = &TestEnv::new(); + + let inner_id = &deploy_contract(sandbox, CUSTOM_TYPES, DeployOptions::default()).await; + extend_contract(sandbox, inner_id).await; + + let outer_id = &deploy_error_caller(sandbox).await; + extend_contract(sandbox, outer_id).await; + + let err = sandbox + .invoke_with_test(&[ + "--id", + outer_id, + "--", + "catch_call", + "--inner", + inner_id, + "--u32_=2", + ]) + .await + .unwrap_err(); + + match &err { + commands::contract::invoke::Error::ContractInvoke { + detail, message, .. + } => { + assert!( + detail.starts_with("RemappedInner"), + "expected detail to start with 'RemappedInner', got: {detail}" + ); + assert!( + message.contains("RemappedInner"), + "expected message to include 'RemappedInner', got: {message}" + ); + } + other => panic!("expected ContractInvoke error, got: {other:#?}"), + } +} + +#[tokio::test] +async fn cross_contract_same_code_prefers_outer_error_name() { + let sandbox = &TestEnv::new(); + + let inner_id = &deploy_contract(sandbox, CUSTOM_TYPES, DeployOptions::default()).await; + extend_contract(sandbox, inner_id).await; + + let outer_id = &deploy_error_caller(sandbox).await; + extend_contract(sandbox, outer_id).await; + + let err = sandbox + .invoke_with_test(&[ + "--id", + outer_id, + "--", + "catch_call_same_code", + "--inner", + inner_id, + "--u32_=2", + ]) + .await + .unwrap_err(); + + match &err { + commands::contract::invoke::Error::ContractInvoke { + detail, message, .. + } => { + assert!( + detail.starts_with("SameCodeAsInner"), + "expected detail to start with 'SameCodeAsInner', got: {detail}" + ); + assert!( + message.contains("SameCodeAsInner"), + "expected message to include 'SameCodeAsInner', got: {message}" + ); + } + other => panic!("expected ContractInvoke error, got: {other:#?}"), + } +} + +#[tokio::test] +async fn cross_contract_import_try_resolves_outer_error() { + let sandbox = &TestEnv::new(); + + let inner_id = &deploy_contract(sandbox, CUSTOM_TYPES, DeployOptions::default()).await; + extend_contract(sandbox, inner_id).await; + + let outer_id = &deploy_error_caller(sandbox).await; + extend_contract(sandbox, outer_id).await; + + let err = sandbox + .invoke_with_test(&[ + "--id", + outer_id, + "--", + "catch_call_import", + "--inner", + inner_id, + "--u32_=2", + ]) + .await + .unwrap_err(); + + match &err { + commands::contract::invoke::Error::ContractInvoke { + detail, message, .. + } => { + assert!( + detail.starts_with("RemappedInner"), + "expected detail to start with 'RemappedInner', got: {detail}" + ); + assert!( + message.contains("RemappedInner"), + "expected message to include 'RemappedInner', got: {message}" + ); + } + other => panic!("expected ContractInvoke error, got: {other:#?}"), + } +} + +#[tokio::test] +async fn cross_contract_import_non_try_does_not_resolve() { + let sandbox = &TestEnv::new(); + + let inner_id = &deploy_contract(sandbox, CUSTOM_TYPES, DeployOptions::default()).await; + extend_contract(sandbox, inner_id).await; + + let outer_id = &deploy_error_caller(sandbox).await; + extend_contract(sandbox, outer_id).await; + + let err = sandbox + .invoke_with_test(&[ + "--id", + outer_id, + "--", + "call_import", + "--inner", + inner_id, + "--u32_=2", + ]) + .await + .unwrap_err(); + + assert!( + !matches!( + &err, + commands::contract::invoke::Error::ContractInvoke { .. } + ), + "expected non-ContractInvoke error for trapped cross-contract call, got: {err:#?}" + ); +} + +#[tokio::test] +async fn cross_contract_non_try_does_not_resolve() { + let sandbox = &TestEnv::new(); + + let inner_id = &deploy_contract(sandbox, CUSTOM_TYPES, DeployOptions::default()).await; + extend_contract(sandbox, inner_id).await; + + let outer_id = &deploy_error_caller(sandbox).await; + extend_contract(sandbox, outer_id).await; + + let err = sandbox + .invoke_with_test(&[ + "--id", outer_id, "--", "call", "--inner", inner_id, "--u32_=2", + ]) + .await + .unwrap_err(); + + assert!( + !matches!( + &err, + commands::contract::invoke::Error::ContractInvoke { .. } + ), + "expected non-ContractInvoke error for trapped cross-contract call, got: {err:#?}" + ); +} + +#[tokio::test] +async fn panic_with_error_no_result_type_does_not_resolve() { + let sandbox = &TestEnv::new(); + + let inner_id = &deploy_contract(sandbox, CUSTOM_TYPES, DeployOptions::default()).await; + extend_contract(sandbox, inner_id).await; + + let outer_id = &deploy_error_caller(sandbox).await; + extend_contract(sandbox, outer_id).await; + + // catch_panic_no_result uses try_* but returns u32, not Result. + // When inner fails, it panics with OuterError::RemappedInner. + // Since the function doesn't return Result, the error shouldn't be resolved. + let err = sandbox + .invoke_with_test(&[ + "--id", + outer_id, + "--", + "catch_panic_no_result", + "--inner", + inner_id, + "--u32_=2", + ]) + .await + .unwrap_err(); + + assert!( + !matches!( + &err, + commands::contract::invoke::Error::ContractInvoke { .. } + ), + "expected non-ContractInvoke error when function doesn't return Result, got: {err:#?}" + ); +} diff --git a/cmd/crates/soroban-test/tests/it/integration/util.rs b/cmd/crates/soroban-test/tests/it/integration/util.rs index 21eb71874d..9d329490c8 100644 --- a/cmd/crates/soroban-test/tests/it/integration/util.rs +++ b/cmd/crates/soroban-test/tests/it/integration/util.rs @@ -10,6 +10,7 @@ pub const CONSTRUCTOR: &Wasm = &Wasm::Custom("test-wasms", "test_constructor"); pub const CUSTOM_TYPES: &Wasm = &Wasm::Custom("test-wasms", "test_custom_types"); pub const CUSTOM_ACCOUNT: &Wasm = &Wasm::Custom("test-wasms", "test_custom_account"); pub const SWAP: &Wasm = &Wasm::Custom("test-wasms", "test_swap"); +pub const ERROR_CALLER: &Wasm = &Wasm::Custom("test-wasms", "test_error_caller"); pub async fn invoke(sandbox: &TestEnv, id: &str, func: &str, data: &str) -> String { sandbox @@ -55,6 +56,10 @@ pub async fn deploy_swap(sandbox: &TestEnv) -> String { deploy_contract(sandbox, SWAP, DeployOptions::default()).await } +pub async fn deploy_error_caller(sandbox: &TestEnv) -> String { + deploy_contract(sandbox, ERROR_CALLER, DeployOptions::default()).await +} + pub async fn deploy_custom_account(sandbox: &TestEnv) -> String { deploy_contract(sandbox, CUSTOM_ACCOUNT, DeployOptions::default()).await } diff --git a/cmd/soroban-cli/src/commands/contract/invoke.rs b/cmd/soroban-cli/src/commands/contract/invoke.rs index bff7c59829..f9e0e643a1 100644 --- a/cmd/soroban-cli/src/commands/contract/invoke.rs +++ b/cmd/soroban-cli/src/commands/contract/invoke.rs @@ -1,6 +1,5 @@ use std::convert::{Infallible, TryInto}; use std::ffi::OsString; -use std::num::ParseIntError; use std::path::{Path, PathBuf}; use std::str::FromStr; use std::{fmt::Debug, fs, io}; @@ -18,7 +17,7 @@ use crate::log::extract_events; use crate::print::Print; use crate::utils::deprecate_message; use crate::{ - assembled::Assembled, + assembled::{simulate_transaction, Assembled}, commands::{ contract::arg_parsing::{build_host_function_parameters, output_to_string}, global, @@ -109,9 +108,6 @@ pub enum Error { #[error(transparent)] Xdr(#[from] xdr::Error), - #[error("error parsing int: {0}")] - ParseIntError(#[from] ParseIntError), - #[error(transparent)] Rpc(#[from] rpc::Error), @@ -487,9 +483,6 @@ enum ShouldSend { Yes, } -/// Simulate a transaction and assemble the result, enhancing any contract error -/// with human-readable information from the spec. On simulation failure, the -/// contract error code is extracted from the structured simulation response async fn simulate_and_enhance( client: &Client, tx: &Transaction, @@ -498,64 +491,73 @@ async fn simulate_and_enhance( spec: &soroban_spec_tools::Spec, function: &str, ) -> Result { - let sim_res = crate::assembled::simulate_transaction(client, tx, resource_config).await?; + let sim_res = simulate_transaction(client, tx, resource_config).await?; if let Some(e) = &sim_res.error { - crate::log::event::all(&sim_res.events()?); + if let Ok(events) = sim_res.events() { + crate::log::event::all(&events); + } return Err(enhance_simulation_error(e, &sim_res, spec, function)); } Ok(Assembled::new(tx, sim_res, resource_fee)?) } -/// Try to enhance a simulation error by extracting the contract error code from -/// the structured simulation response (results and diagnostic events) +/// Attempt to resolve a contract error code from the simulation response and +/// enhance the error message with the error name and documentation from the spec. fn enhance_simulation_error( error_msg: &str, sim_res: &SimulateTransactionResponse, spec: &soroban_spec_tools::Spec, function: &str, ) -> Error { - if let Some(code) = extract_contract_error_from_sim(sim_res) { + let events = sim_res.events().ok(); + + // Non-try cross-contract calls that trap should not have their inner error + // codes resolved against the outer function's error enum. + if events.as_ref().is_some_and(|e| has_cross_contract_trap(e)) { + return Error::Rpc(rpc::Error::TransactionSimulationFailed( + error_msg.to_string(), + )); + } + + // Extract and resolve the contract error code from diagnostic events. + if let Some(code) = events + .as_ref() + .and_then(|e| extract_contract_error_from_events(e)) + { if let Some(err) = build_enhanced_error(code, error_msg, spec, function) { return err; } } + Error::Rpc(rpc::Error::TransactionSimulationFailed( error_msg.to_string(), )) } -/// Extract a contract error code from a simulation response's structured data. +/// Detect non-try cross-contract call traps. /// -/// Checks the simulation results first in case the return value is -/// `ScVal::Error(ScError::Contract(code))`, then scans diagnostic events -/// (in reverse order) for the outermost contract error code. -fn extract_contract_error_from_sim(sim_res: &SimulateTransactionResponse) -> Option { - if let Ok(results) = sim_res.results() { - for result in &results { - if let ScVal::Error(ScError::Contract(code)) = &result.xdr { - return Some(*code); - } - } - } - - if let Ok(events) = sim_res.events() { - if let Some(code) = extract_contract_error_from_events(&events) { - return Some(code); - } - } - - None +/// Both `panic_with_error!` and non-try cross-contract calls produce VM traps, +/// but with different host function names in the diagnostic message: +/// - `panic_with_error!`: "...host function call: fail_with_error" +/// - cross-contract trap: "...host function call: call" +/// +/// We only want to suppress resolution for cross-contract traps (where the +/// error code belongs to an inner contract, not the invoked function's spec). +fn has_cross_contract_trap(events: &[DiagnosticEvent]) -> bool { + const CROSS_CONTRACT_TRAP_MSG: &str = + "escalating error to VM trap from failed host function call: call"; + + events.iter().rev().any(|event| { + let ContractEventBody::V0(ContractEventV0 { data, .. }) = &event.event.body; + matches!(data, ScVal::String(s) if s.to_utf8_string_lossy().contains(CROSS_CONTRACT_TRAP_MSG)) + }) } -/// Scan diagnostic events for an `ScError::Contract(code)` value in the event -/// data or topics. +/// Extract the contract error code from diagnostic events. /// -/// Events are scanned in **reverse** order (newest first) so that the -/// outermost error is returned. In cross-contract call scenarios, earlier -/// events contain inner-contract error codes while the last error event -/// carries the final remapped code from the outermost contract. +/// Scans events (newest first) for the outermost contract error code fn extract_contract_error_from_events(events: &[DiagnosticEvent]) -> Option { for event in events.iter().rev() { let ContractEventBody::V0(ContractEventV0 { topics, data, .. }) = &event.event.body; @@ -572,12 +574,6 @@ fn extract_contract_error_from_events(events: &[DiagnosticEvent]) -> Option None } -/// Try to extract a contract error from a failed transaction's structured XDR -/// metadata by reading the `ScError::Contract(u32)` value directly from the -/// `SorobanTransactionMeta` return value. -/// -/// Returns `Some(Error::ContractInvoke { .. })` if a contract error was found -/// and resolved in the spec, or `None` to let the caller fall back. fn enhance_error_from_meta( response: &GetTransactionResponse, rpc_error_msg: &str, @@ -590,12 +586,6 @@ fn enhance_error_from_meta( build_enhanced_error(code, rpc_error_msg, spec, function) } -/// Build an enhanced `ContractInvoke` error by looking up a contract error code -/// in the spec and inserting the resolved name and documentation into the error -/// message. -/// -/// The lookup is scoped to the error type declared in the function's return type -/// (e.g. `Result` only searches the `MyError` enum). fn build_enhanced_error( code: u32, error_msg: &str, @@ -621,10 +611,6 @@ fn build_enhanced_error( }) } -/// Look up a contract error code in the specific error enum declared in the -/// function's return type. Returns `None` if the function doesn't declare a -/// `Result` return type with a UDT error enum, or if the code isn't found in -/// that enum. fn find_error_for_function<'a>( spec: &'a soroban_spec_tools::Spec, function: &str, @@ -635,25 +621,24 @@ fn find_error_for_function<'a>( let ScSpecTypeDef::Result(result_type) = output else { return None; }; - let ScSpecTypeDef::Udt(ScSpecTypeUdt { name }) = result_type.error_type.as_ref() else { - return None; + let error_type = result_type.error_type.as_ref(); + let name = match error_type { + ScSpecTypeDef::Udt(ScSpecTypeUdt { name }) => name, + ScSpecTypeDef::Error => { + return spec.find_error_type(code).ok(); + } + _ => { + return None; + } }; let error_enum_name = name.to_utf8_string_lossy(); - let ScSpecEntry::UdtErrorEnumV0(error_enum) = spec.find(&error_enum_name).ok()? else { + let error_entry = spec.find(&error_enum_name).ok()?; + let ScSpecEntry::UdtErrorEnumV0(error_enum) = error_entry else { return None; }; error_enum.cases.iter().find(|c| c.value == code) } -/// 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..]) @@ -686,158 +671,3 @@ fn has_auth(sim_res: &SimulateTransactionResponse) -> Result { .iter() .any(|SimulateHostFunctionResult { auth, .. }| !auth.is_empty())) } - -#[cfg(test)] -mod tests { - use super::*; - use xdr::ExtensionPoint; - - #[test] - fn extract_contract_error_from_event_data() { - let events = vec![DiagnosticEvent { - in_successful_contract_call: false, - event: ContractEvent { - ext: ExtensionPoint::V0, - contract_id: None, - type_: ContractEventType::Diagnostic, - body: ContractEventBody::V0(ContractEventV0 { - topics: VecM::default(), - data: ScVal::Error(ScError::Contract(1)), - }), - }, - }]; - assert_eq!(extract_contract_error_from_events(&events), Some(1)); - } - - #[test] - fn extract_contract_error_from_event_topic() { - let events = vec![DiagnosticEvent { - in_successful_contract_call: false, - event: ContractEvent { - ext: ExtensionPoint::V0, - contract_id: None, - type_: ContractEventType::Diagnostic, - body: ContractEventBody::V0(ContractEventV0 { - topics: vec![ScVal::Error(ScError::Contract(42))] - .try_into() - .unwrap(), - data: ScVal::Void, - }), - }, - }]; - assert_eq!(extract_contract_error_from_events(&events), Some(42)); - } - - #[test] - fn extract_contract_error_ignores_non_contract_events() { - let events = vec![DiagnosticEvent { - in_successful_contract_call: true, - event: ContractEvent { - ext: ExtensionPoint::V0, - contract_id: None, - type_: ContractEventType::Diagnostic, - body: ContractEventBody::V0(ContractEventV0 { - topics: VecM::default(), - data: ScVal::Error(ScError::Budget(xdr::ScErrorCode::ExceededLimit)), - }), - }, - }]; - assert_eq!(extract_contract_error_from_events(&events), None); - } - - #[test] - fn extract_contract_error_returns_outermost_code() { - // Simulates a cross-contract call where events contain both the inner - // contract's error (code 1) and the outer contract's remapped error - // (code 7). The extraction should return the outermost (last) code. - let events = vec![ - // Inner contract: Error(Contract, #1) in topic - DiagnosticEvent { - in_successful_contract_call: false, - event: ContractEvent { - ext: ExtensionPoint::V0, - contract_id: None, - type_: ContractEventType::Diagnostic, - body: ContractEventBody::V0(ContractEventV0 { - topics: vec![ - ScVal::Symbol(xdr::ScSymbol("error".try_into().unwrap())), - ScVal::Error(ScError::Contract(1)), - ] - .try_into() - .unwrap(), - data: ScVal::String(xdr::ScString( - "escalating Ok(ScErrorType::Contract) frame-exit to Err" - .try_into() - .unwrap(), - )), - }), - }, - }, - // Outer contract: try_call failed, Error(Contract, #1) in topic - DiagnosticEvent { - in_successful_contract_call: false, - event: ContractEvent { - ext: ExtensionPoint::V0, - contract_id: None, - type_: ContractEventType::Diagnostic, - body: ContractEventBody::V0(ContractEventV0 { - topics: vec![ - ScVal::Symbol(xdr::ScSymbol("error".try_into().unwrap())), - ScVal::Error(ScError::Contract(1)), - ] - .try_into() - .unwrap(), - data: ScVal::String(xdr::ScString( - "contract try_call failed".try_into().unwrap(), - )), - }), - }, - }, - // Outer contract: final remapped Error(Contract, #7) in topic - DiagnosticEvent { - in_successful_contract_call: false, - event: ContractEvent { - ext: ExtensionPoint::V0, - contract_id: None, - type_: ContractEventType::Diagnostic, - body: ContractEventBody::V0(ContractEventV0 { - topics: vec![ - ScVal::Symbol(xdr::ScSymbol("error".try_into().unwrap())), - ScVal::Error(ScError::Contract(7)), - ] - .try_into() - .unwrap(), - data: ScVal::String(xdr::ScString( - "escalating Ok(ScErrorType::Contract) frame-exit to Err" - .try_into() - .unwrap(), - )), - }), - }, - }, - ]; - assert_eq!(extract_contract_error_from_events(&events), Some(7)); - } - - #[test] - fn insert_detail_with_event_log() { - // Simulation errors have the event log separated by a blank line. - let msg = "HostError: Error(Contract, #1)\n\nEvent log (newest first):"; - let result = insert_detail_after_error_code(msg, "NumberMustBeOdd: desc"); - assert_eq!( - result, - "HostError: Error(Contract, #1)\nNumberMustBeOdd: desc\n\nEvent log (newest first):" - ); - } - - #[test] - fn insert_detail_without_event_log() { - // Messages without an event log section get the detail appended. - let msg = "transaction submission failed: InvokeHostFunction(Trapped)"; - let result = insert_detail_after_error_code(msg, "NumberMustBeOdd: desc"); - assert_eq!( - result, - "transaction submission failed: InvokeHostFunction(Trapped)\nNumberMustBeOdd: desc" - ); - } -} From dd92d3b37b22b3110b2d107eaeade7ecbd2e56e4 Mon Sep 17 00:00:00 2001 From: Blaine Heffron Date: Thu, 5 Feb 2026 13:49:50 -0500 Subject: [PATCH 09/10] fix ci issue --- .../test-wasms/error_caller/src/lib.rs | 32 ++++++++++++------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/cmd/crates/soroban-test/tests/fixtures/test-wasms/error_caller/src/lib.rs b/cmd/crates/soroban-test/tests/fixtures/test-wasms/error_caller/src/lib.rs index c78b5b0f92..53933714ab 100644 --- a/cmd/crates/soroban-test/tests/fixtures/test-wasms/error_caller/src/lib.rs +++ b/cmd/crates/soroban-test/tests/fixtures/test-wasms/error_caller/src/lib.rs @@ -1,13 +1,23 @@ #![no_std] use soroban_sdk::{ - contract, contracterror, contractimpl, panic_with_error, Address, Env, IntoVal, InvokeError, - Symbol, + contract, contractclient, contracterror, contractimpl, panic_with_error, Address, Env, IntoVal, + InvokeError, Symbol, }; -mod custom_types { - soroban_sdk::contractimport!( - file = "../../../../../../../target/wasm32v1-none/test-wasms/test_custom_types.wasm" - ); +/// Mirror of the inner contract's error enum. +/// Must match the error codes defined in custom_types contract. +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] +#[repr(u32)] +pub enum InnerError { + /// Please provide an odd number + NumberMustBeOdd = 1, +} + +/// Minimal client interface for the custom_types contract. +#[contractclient(name = "CustomTypesClient")] +pub trait CustomTypesInterface { + fn u32_fail_on_even(env: Env, u32_: u32) -> Result; } #[contracterror] @@ -49,9 +59,9 @@ impl ErrorCallerContract { } } - /// Try-calls inner via contractimport. Catches error, returns OuterError::RemappedInner. + /// Try-calls inner via contractclient. Catches error, returns OuterError::RemappedInner. pub fn catch_call_import(env: Env, inner: Address, u32_: u32) -> Result { - let client = custom_types::Client::new(&env, &inner); + let client = CustomTypesClient::new(&env, &inner); match client.try_u32_fail_on_even(&u32_) { Ok(Ok(val)) => Ok(val), _ => Err(OuterError::RemappedInner), @@ -67,16 +77,16 @@ impl ErrorCallerContract { )) } - /// Non-try call to inner via contractimport. If inner fails, propagates as VM trap. + /// Non-try call to inner via contractclient. If inner fails, propagates as VM trap. pub fn call_import(env: Env, inner: Address, u32_: u32) -> Result { - let client = custom_types::Client::new(&env, &inner); + let client = CustomTypesClient::new(&env, &inner); Ok(client.u32_fail_on_even(&u32_)) } /// Try-calls inner but returns non-Result type. Panics with error if inner fails. /// Since this function doesn't return Result, the error shouldn't be resolved. pub fn catch_panic_no_result(env: Env, inner: Address, u32_: u32) -> u32 { - let client = custom_types::Client::new(&env, &inner); + let client = CustomTypesClient::new(&env, &inner); match client.try_u32_fail_on_even(&u32_) { Ok(Ok(val)) => val, _ => panic_with_error!(&env, OuterError::RemappedInner), From 95295f4c3ee3c3939cfa824c20474eb7bb8e42ff Mon Sep 17 00:00:00 2001 From: Blaine Heffron Date: Mon, 9 Feb 2026 13:01:19 -0500 Subject: [PATCH 10/10] add string parsing fallback --- .../src/commands/contract/invoke.rs | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/cmd/soroban-cli/src/commands/contract/invoke.rs b/cmd/soroban-cli/src/commands/contract/invoke.rs index f9e0e643a1..2fefa6d5cf 100644 --- a/cmd/soroban-cli/src/commands/contract/invoke.rs +++ b/cmd/soroban-cli/src/commands/contract/invoke.rs @@ -530,6 +530,11 @@ fn enhance_simulation_error( return err; } } + if let Some(code) = extract_contract_error_from_error_msg(error_msg) { + if let Some(err) = build_enhanced_error(code, error_msg, spec, function) { + return err; + } + } Error::Rpc(rpc::Error::TransactionSimulationFailed( error_msg.to_string(), @@ -647,6 +652,14 @@ fn insert_detail_after_error_code(msg: &str, detail: &str) -> String { } } +fn extract_contract_error_from_error_msg(msg: &str) -> Option { + let first_line = msg.lines().next().unwrap_or(msg); + let marker = "Error(Contract, #"; + let start = first_line.find(marker)? + marker.len(); + let end = first_line[start..].find(')')?; + first_line[start..start + end].parse::().ok() +} + fn has_write(sim_res: &SimulateTransactionResponse) -> Result { Ok(!sim_res .transaction_data()? @@ -671,3 +684,20 @@ fn has_auth(sim_res: &SimulateTransactionResponse) -> Result { .iter() .any(|SimulateHostFunctionResult { auth, .. }| !auth.is_empty())) } + +#[cfg(test)] +mod tests { + use super::extract_contract_error_from_error_msg; + + #[test] + fn extracts_contract_error_code_from_first_line() { + let msg = "HostError: Error(Contract, #5)\nEvent log (newest first):\n 0: ..."; + assert_eq!(extract_contract_error_from_error_msg(msg), Some(5)); + } + + #[test] + fn ignores_contract_error_code_if_not_on_first_line() { + let msg = "HostError: Error(WasmVm, InvalidAction)\nEvent log: Error(Contract, #5)"; + assert_eq!(extract_contract_error_from_error_msg(msg), None); + } +}