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..381f95766c 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("{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,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); + } +}