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-spec-tools/src/lib.rs b/cmd/crates/soroban-spec-tools/src/lib.rs index e4dd8c659d..c336f39c3d 100644 --- a/cmd/crates/soroban-spec-tools/src/lib.rs +++ b/cmd/crates/soroban-spec-tools/src/lib.rs @@ -221,7 +221,6 @@ impl Spec { } Err(Error::MissingErrorCase(value)) } - /// # Errors /// /// Might return errors 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..53933714ab --- /dev/null +++ b/cmd/crates/soroban-test/tests/fixtures/test-wasms/error_caller/src/lib.rs @@ -0,0 +1,95 @@ +#![no_std] +use soroban_sdk::{ + contract, contractclient, contracterror, contractimpl, panic_with_error, Address, Env, IntoVal, + InvokeError, Symbol, +}; + +/// 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] +#[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 contractclient. Catches error, returns OuterError::RemappedInner. + pub fn catch_call_import(env: Env, inner: Address, u32_: u32) -> Result { + let client = CustomTypesClient::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 contractclient. If inner fails, propagates as VM trap. + pub fn call_import(env: Env, inner: Address, u32_: u32) -> Result { + 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 = CustomTypesClient::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/custom_types.rs b/cmd/crates/soroban-test/tests/it/integration/custom_types.rs index df0549249d..c535296382 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,23 @@ 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 { + message: 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/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/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 834f37718f..2fefa6d5cf 100644 --- a/cmd/soroban-cli/src/commands/contract/invoke.rs +++ b/cmd/soroban-cli/src/commands/contract/invoke.rs @@ -1,23 +1,23 @@ 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}; 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::{simulate_transaction, Assembled}, commands::{ contract::arg_parsing::{build_host_function_parameters, output_to_string}, global, @@ -28,11 +28,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, }; @@ -108,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), @@ -132,8 +129,14 @@ pub enum Error { #[error(transparent)] Locator(#[from] locator::Error), - #[error("Contract Error\n{0}: {1}")] - 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), @@ -232,6 +235,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)) = @@ -239,13 +244,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)] @@ -304,7 +311,13 @@ impl Cmd { (ShouldSend::Yes, None) } else { let assembled = self - .simulate(&host_function_params, &default_account_entry(), &client) + .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)) @@ -352,11 +365,13 @@ 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?; let assembled = self.resources.apply_to_assembled_txn(txn); @@ -372,9 +387,25 @@ impl Cmd { *txn = tx; } - let res = client - .send_transaction_polling(&config.sign(*txn, quiet).await?) - .await?; + 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)?; @@ -452,6 +483,183 @@ enum ShouldSend { Yes, } +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 = simulate_transaction(client, tx, resource_config).await?; + + if let Some(e) = &sim_res.error { + 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)?) +} + +/// 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 { + 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; + } + } + 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(), + )) +} + +/// Detect non-try cross-contract call traps. +/// +/// 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)) + }) +} + +/// Extract the contract error code from diagnostic events. +/// +/// 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; + + 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 +} + +fn enhance_error_from_meta( + response: &GetTransactionResponse, + rpc_error_msg: &str, + spec: &soroban_spec_tools::Spec, + function: &str, +) -> Option { + let Ok(ScVal::Error(ScError::Contract(code))) = response.return_value() else { + return None; + }; + build_enhanced_error(code, rpc_error_msg, spec, function) +} + +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)?; + 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); + Some(Error::ContractInvoke { + message: enhanced_msg, + detail, + }) +} + +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 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 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) +} + +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 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()? @@ -476,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); + } +}