From bc8ae3dfec848c3ea3977b6a6f3dfe772e43e168 Mon Sep 17 00:00:00 2001 From: Umeokonkwo Samuel Date: Tue, 21 Jul 2026 11:35:50 +0100 Subject: [PATCH 1/4] feat(core): implement ChainAnalyzer for nested contract call failures (#343) --- crates/core/src/decode/chain_analyzer.rs | 718 +++++++++++++++++++++++ crates/core/src/decode/mod.rs | 19 + crates/core/src/decode/report.rs | 1 + crates/core/src/types/report.rs | 8 + 4 files changed, 746 insertions(+) create mode 100644 crates/core/src/decode/chain_analyzer.rs diff --git a/crates/core/src/decode/chain_analyzer.rs b/crates/core/src/decode/chain_analyzer.rs new file mode 100644 index 00000000..0fb17086 --- /dev/null +++ b/crates/core/src/decode/chain_analyzer.rs @@ -0,0 +1,718 @@ +//! # Chain Analyzer +//! +//! Reconstructs the full contract call chain from a chronological sequence of +//! [`DiagnosticEvent`] objects emitted during a Soroban transaction. +//! +//! ## Algorithm +//! +//! The analyzer maintains an explicit **stack** data structure that mirrors the +//! live call stack of the Soroban host at every point in the event sequence: +//! +//! - When an `fn_call` event is encountered the callee is **pushed** onto the +//! stack, recording the contract address, function name, and current depth. +//! - When an `fn_return` event is encountered the top frame is **popped**. +//! - When a failure indicator is encountered (a topic matching `error`, +//! `panic`, etc., an `ScVal::Error` payload, or +//! `in_successful_contract_call = false`) the stack snapshot is captured and +//! classified into **root cause** (deepest / first erroring frame) and +//! **fault-line** (all frames that propagated the error upward). +//! +//! ## Recursive contract calls +//! +//! Recursive invocations are handled naturally: because we track depth as the +//! stack length at the moment the frame is pushed, a call from contract A to +//! contract A simply adds a new frame on top of an existing frame for A. +//! No special cycle detection is required and the algorithm terminates in +//! exactly O(n) time with respect to the number of events. +//! +//! ## Visual hierarchy +//! +//! The resulting [`CallChain`] exposes a `render_trace()` method that formats +//! the chain as an indented tree, for example: +//! +//! ```text +//! ? Router (depth 0) +//! ? LiquidityPool::swap (depth 1) +//! ? Token::transfer [ROOT CAUSE] (depth 2) +//! ? LiquidityPool::swap [FAULT LINE] (depth 1) +//! ? Router [FAULT LINE] (depth 0) +//! ``` + +use serde::{Deserialize, Serialize}; +use stellar_strkey::Contract as StrkeyContract; +use stellar_xdr::curr::{ContractEventBody, DiagnosticEvent, Hash, ScVal}; + +// --------------------------------------------------------------------------- +// Public output types +// --------------------------------------------------------------------------- + +/// Role a frame played when the transaction failed. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum FrameRole { + /// This frame was executing normally and had not yet encountered an error. + Normal, + /// This frame is where the error was first raised (the deepest erroring + /// frame in the chain at the point of first failure). + RootCause, + /// This frame itself did not raise the error but it propagated upward + /// because a sub-invocation it triggered failed. + FaultLine, +} + +impl std::fmt::Display for FrameRole { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Normal => write!(f, "Normal"), + Self::RootCause => write!(f, "Root Cause"), + Self::FaultLine => write!(f, "Fault Line"), + } + } +} + +/// A single frame captured from the call stack at the moment of failure. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ChainFrame { + /// Strkey-encoded contract address (`C...`). + pub contract_address: String, + + /// The function name as reported in the `fn_call` event, if present. + pub function_name: Option, + + /// Call depth, where `0` is the top-level (outermost) invocation. + pub depth: usize, + + /// Whether this frame was executing at the moment the first error was + /// detected. + pub is_error_frame: bool, + + /// The role this frame played in the failure. + pub role: FrameRole, +} + +impl ChainFrame { + fn label(&self) -> String { + match &self.function_name { + Some(fn_name) => format!("{}::{}", self.contract_address, fn_name), + None => self.contract_address.clone(), + } + } +} + +/// The reconstructed call chain for a failed transaction. +/// +/// Contains the ordered sequence of contract frames that were active when the +/// first failure was detected, plus a pre-formatted visual trace string. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CallChain { + /// Ordered frames from outermost (depth 0) to innermost (highest depth). + /// Only frames that were on the stack at the time of the first detected + /// failure are included. + pub frames: Vec, + + /// The index into `frames` of the frame identified as the root cause, or + /// `None` when the chain is empty. + pub root_cause_index: Option, + + /// Pre-formatted visual trace suitable for display in reports and CLI + /// output. Generated by [`CallChain::render_trace`]. + pub trace: String, + + /// A short human-readable summary of where the fault occurred. + pub summary: String, +} + +impl CallChain { + /// Render a visual, indented representation of the call chain. + /// + /// Frames are ordered from outermost (shallowest depth) to innermost + /// (deepest depth). Root-cause frames are marked with `x ... [ROOT CAUSE]` + /// and fault-line frames with `x ... [FAULT LINE]`. Normal frames use `->`. + pub fn render_trace(&self) -> String { + if self.frames.is_empty() { + return String::from("(no call chain captured)"); + } + + let mut lines: Vec = Vec::with_capacity(self.frames.len()); + + for frame in &self.frames { + let indent = " ".repeat(frame.depth); + let label = frame.label(); + let depth_tag = format!("(depth {})", frame.depth); + + let line = match frame.role { + FrameRole::RootCause => { + format!("{indent}x {label} [ROOT CAUSE] {depth_tag}") + } + FrameRole::FaultLine => { + format!("{indent}x {label} [FAULT LINE] {depth_tag}") + } + FrameRole::Normal => { + format!("{indent}-> {label} {depth_tag}") + } + }; + + lines.push(line); + } + + lines.join("\n") + } +} + +// --------------------------------------------------------------------------- +// Internal frame used during stack construction +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone)] +struct StackFrame { + contract_address: String, + function_name: Option, + depth: usize, +} + +// --------------------------------------------------------------------------- +// ChainAnalyzer +// --------------------------------------------------------------------------- + +/// Analyzes a sequence of [`DiagnosticEvent`] objects and reconstructs the +/// contract call chain at the point of first failure. +/// +/// The analyzer is stateless; create a new instance with [`ChainAnalyzer::new`] +/// and call [`ChainAnalyzer::analyze`] as many times as needed. +pub struct ChainAnalyzer; + +impl ChainAnalyzer { + /// Create a new analyzer instance. + pub fn new() -> Self { + Self + } + + /// Analyze `events` and return the reconstructed [`CallChain`], or `None` + /// when the event sequence contains no failure indicators. + /// + /// The algorithm iterates the events exactly once (O(n)) and never + /// re-visits events, so it is safe to call on arbitrarily long sequences + /// without risk of infinite loops. + pub fn analyze(&self, events: &[DiagnosticEvent]) -> Option { + let mut stack: Vec = Vec::new(); + + for event in events { + let ContractEventBody::V0(v0) = &event.event.body; + + let topics: Vec = v0 + .topics + .iter() + .filter_map(Self::topic_to_string) + .collect(); + + let first_topic = topics.first().map(String::as_str); + + // ---------------------------------------------------------------- + // Maintain the call stack + // ---------------------------------------------------------------- + match first_topic { + Some("fn_call") => { + let address = match &event.event.contract_id { + Some(hash) => Self::hash_to_strkey(hash), + // Host-level fn_call events sometimes lack a contract_id; + // use the second topic (callee address hint) as a fallback. + None => topics + .get(1) + .cloned() + .unwrap_or_else(|| "".to_string()), + }; + // The function name is the second topic for fn_call events + // (first topic = "fn_call", second = function name). + let function_name = topics.get(1).cloned(); + stack.push(StackFrame { + contract_address: address, + function_name, + depth: stack.len(), + }); + continue; + } + Some("fn_return") => { + stack.pop(); + continue; + } + _ => {} + } + + // ---------------------------------------------------------------- + // Detect failure + // ---------------------------------------------------------------- + if Self::is_failure(event, &topics, &v0.data) { + return Some(self.build_chain(&stack, event)); + } + } + + None + } + + // ----------------------------------------------------------------------- + // Private helpers + // ----------------------------------------------------------------------- + + /// Assemble a [`CallChain`] from the current stack snapshot and the event + /// that triggered the failure detection. + fn build_chain(&self, stack: &[StackFrame], failing_event: &DiagnosticEvent) -> CallChain { + if stack.is_empty() { + // Failure occurred before any fn_call was seen — synthesize a + // single-frame chain from the event's own contract_id. + let address = failing_event + .event + .contract_id + .as_ref() + .map(Self::hash_to_strkey) + .unwrap_or_else(|| "".to_string()); + + let frame = ChainFrame { + contract_address: address.clone(), + function_name: None, + depth: 0, + is_error_frame: true, + role: FrameRole::RootCause, + }; + let summary = format!("Root cause: {address} (no call chain captured)"); + let chain = CallChain { + frames: vec![frame], + root_cause_index: Some(0), + trace: String::new(), // filled below + summary, + }; + return self.finalize(chain); + } + + // The deepest frame (innermost) is the root cause; everything above it + // in the stack is a fault-line frame. + let root_cause_index = stack.len().saturating_sub(1); + + let frames: Vec = stack + .iter() + .enumerate() + .map(|(i, sf)| { + let role = if i == root_cause_index { + FrameRole::RootCause + } else { + FrameRole::FaultLine + }; + ChainFrame { + contract_address: sf.contract_address.clone(), + function_name: sf.function_name.clone(), + depth: sf.depth, + is_error_frame: i == root_cause_index, + role, + } + }) + .collect(); + + let root_frame = &frames[root_cause_index]; + let summary = format!( + "Root cause: {} | Fault line: {} contract(s) affected", + root_frame.label(), + frames.len().saturating_sub(1), + ); + + let chain = CallChain { + frames, + root_cause_index: Some(root_cause_index), + trace: String::new(), // filled by finalize + summary, + }; + self.finalize(chain) + } + + /// Populate the `trace` field by calling `render_trace` on the chain. + fn finalize(&self, mut chain: CallChain) -> CallChain { + chain.trace = chain.render_trace(); + chain + } + + /// Extract a string representation of an `ScVal` topic. + fn topic_to_string(val: &ScVal) -> Option { + match val { + ScVal::Symbol(sym) => Some(sym.to_string()), + ScVal::String(s) => Some(s.to_string()), + _ => None, + } + } + + /// Returns `true` when an event is considered a failure indicator. + fn is_failure(event: &DiagnosticEvent, topics: &[String], data: &ScVal) -> bool { + // The host sets this flag to false for every event inside a failing frame. + if !event.in_successful_contract_call { + return true; + } + + // An ScVal::Error payload always signals a contract-level error. + if matches!(data, ScVal::Error(_)) { + return true; + } + + // Well-known failure topic keywords. + topics.iter().any(|t| { + let lower = t.to_ascii_lowercase(); + lower == "error" + || lower == "panic" + || lower == "revert" + || lower == "hosterror" + || lower.contains("failed") + || lower.contains("trap") + }) + } + + /// Encode a 32-byte hash as a Stellar contract strkey (`C...`). + fn hash_to_strkey(hash: &Hash) -> String { + StrkeyContract(hash.0).to_string() + } +} + +impl Default for ChainAnalyzer { + fn default() -> Self { + Self::new() + } +} + +// --------------------------------------------------------------------------- +// Convenience free function +// --------------------------------------------------------------------------- + +/// Analyze `events` with a default [`ChainAnalyzer`] and return the +/// reconstructed [`CallChain`], or `None` if no failure was detected. +pub fn analyze_call_chain(events: &[DiagnosticEvent]) -> Option { + ChainAnalyzer::new().analyze(events) +} + +// --------------------------------------------------------------------------- +// Unit tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use stellar_xdr::curr::{ + ContractEvent, ContractEventBody, ContractEventType, ContractEventV0, ExtensionPoint, Hash, + ScSymbol, ScVal, + }; + + // ----------------------------------------------------------------------- + // Test helpers + // ----------------------------------------------------------------------- + + fn contract_hash(seed: u8) -> Hash { + Hash([seed; 32]) + } + + fn strkey(seed: u8) -> String { + StrkeyContract(contract_hash(seed).0).to_string() + } + + fn make_event( + contract_id: Option, + topics: Vec, + data: ScVal, + in_successful_contract_call: bool, + ) -> DiagnosticEvent { + DiagnosticEvent { + in_successful_contract_call, + event: ContractEvent { + ext: ExtensionPoint::V0, + contract_id, + type_: ContractEventType::Diagnostic, + body: ContractEventBody::V0(ContractEventV0 { + topics: topics.try_into().expect("topics VecM"), + data, + }), + }, + } + } + + fn sym(s: &str) -> ScVal { + ScVal::Symbol(ScSymbol(s.try_into().expect("symbol string"))) + } + + fn fn_call(contract: Hash, function: &str) -> DiagnosticEvent { + make_event( + Some(contract), + vec![sym("fn_call"), sym(function)], + ScVal::Void, + true, + ) + } + + fn fn_return(contract: Hash) -> DiagnosticEvent { + make_event(Some(contract), vec![sym("fn_return")], ScVal::Void, true) + } + + fn error_event(contract: Hash, msg: &str) -> DiagnosticEvent { + make_event( + Some(contract), + vec![sym("error"), sym(msg)], + ScVal::Void, + false, + ) + } + + // ----------------------------------------------------------------------- + // No events -> no chain + // ----------------------------------------------------------------------- + + #[test] + fn empty_events_returns_none() { + assert!(ChainAnalyzer::new().analyze(&[]).is_none()); + } + + // ----------------------------------------------------------------------- + // No failure events -> no chain + // ----------------------------------------------------------------------- + + #[test] + fn no_failure_returns_none() { + let events = vec![ + fn_call(contract_hash(1), "swap"), + fn_return(contract_hash(1)), + ]; + assert!(ChainAnalyzer::new().analyze(&events).is_none()); + } + + // ----------------------------------------------------------------------- + // Single contract fails -> root cause, no fault line + // ----------------------------------------------------------------------- + + #[test] + fn single_contract_failure_is_root_cause() { + let h1 = contract_hash(1); + let events = vec![ + fn_call(h1.clone(), "transfer"), + error_event(h1.clone(), "insufficient balance"), + ]; + + let chain = ChainAnalyzer::new() + .analyze(&events) + .expect("chain should be found"); + + assert_eq!(chain.frames.len(), 1); + assert_eq!(chain.root_cause_index, Some(0)); + assert_eq!(chain.frames[0].role, FrameRole::RootCause); + assert_eq!(chain.frames[0].contract_address, strkey(1)); + assert_eq!( + chain.frames[0].function_name.as_deref(), + Some("transfer") + ); + } + + // ----------------------------------------------------------------------- + // A -> B -> C, C fails: C = root cause, A and B = fault line + // ----------------------------------------------------------------------- + + #[test] + fn nested_three_level_failure_identifies_root_cause_and_fault_line() { + let (h1, h2, h3) = (contract_hash(1), contract_hash(2), contract_hash(3)); + + let events = vec![ + fn_call(h1.clone(), "route"), + fn_call(h2.clone(), "swap"), + fn_call(h3.clone(), "transfer"), + error_event(h3.clone(), "trapped"), + ]; + + let chain = ChainAnalyzer::new() + .analyze(&events) + .expect("chain should be found"); + + assert_eq!(chain.frames.len(), 3); + + assert_eq!(chain.frames[0].contract_address, strkey(1)); + assert_eq!(chain.frames[0].role, FrameRole::FaultLine); + assert_eq!(chain.frames[0].depth, 0); + + assert_eq!(chain.frames[1].contract_address, strkey(2)); + assert_eq!(chain.frames[1].role, FrameRole::FaultLine); + assert_eq!(chain.frames[1].depth, 1); + + assert_eq!(chain.frames[2].contract_address, strkey(3)); + assert_eq!(chain.frames[2].role, FrameRole::RootCause); + assert_eq!(chain.frames[2].depth, 2); + assert_eq!(chain.root_cause_index, Some(2)); + } + + // ----------------------------------------------------------------------- + // Recursive call: A -> B -> A -> fails (no infinite loop) + // ----------------------------------------------------------------------- + + #[test] + fn recursive_call_does_not_loop_and_identifies_correct_frame() { + let (h1, h2) = (contract_hash(10), contract_hash(20)); + + let events = vec![ + fn_call(h1.clone(), "entry"), + fn_call(h2.clone(), "helper"), + fn_call(h1.clone(), "callback"), + error_event(h1.clone(), "panic"), + ]; + + let chain = ChainAnalyzer::new() + .analyze(&events) + .expect("chain should be found"); + + assert_eq!(chain.frames.len(), 3); + assert_eq!(chain.frames[2].depth, 2); + assert_eq!(chain.frames[2].role, FrameRole::RootCause); + assert_eq!(chain.frames[2].contract_address, strkey(10)); + assert_eq!(chain.frames[0].role, FrameRole::FaultLine); + } + + // ----------------------------------------------------------------------- + // fn_return before failure correctly unwinds the stack + // ----------------------------------------------------------------------- + + #[test] + fn completed_frame_is_not_on_stack_when_later_failure_occurs() { + let (h1, h2, h3) = (contract_hash(1), contract_hash(2), contract_hash(3)); + + let events = vec![ + fn_call(h1.clone(), "outer"), + fn_call(h2.clone(), "inner"), + fn_return(h2.clone()), + fn_call(h3.clone(), "another"), + error_event(h3.clone(), "failed"), + ]; + + let chain = ChainAnalyzer::new() + .analyze(&events) + .expect("chain should be found"); + + assert_eq!(chain.frames.len(), 2); + assert_eq!(chain.frames[0].contract_address, strkey(1)); + assert_eq!(chain.frames[1].contract_address, strkey(3)); + assert_eq!(chain.frames[1].role, FrameRole::RootCause); + } + + // ----------------------------------------------------------------------- + // Failure before any fn_call: synthesized single-frame chain + // ----------------------------------------------------------------------- + + #[test] + fn failure_before_any_fn_call_produces_single_synthesized_frame() { + let h1 = contract_hash(5); + let events = vec![error_event(h1.clone(), "early panic")]; + + let chain = ChainAnalyzer::new() + .analyze(&events) + .expect("chain should be found"); + + assert_eq!(chain.frames.len(), 1); + assert_eq!(chain.frames[0].depth, 0); + assert_eq!(chain.frames[0].role, FrameRole::RootCause); + } + + // ----------------------------------------------------------------------- + // ScVal::Error payload triggers failure detection + // ----------------------------------------------------------------------- + + #[test] + fn scval_error_payload_triggers_failure() { + let h1 = contract_hash(7); + let events = vec![ + fn_call(h1.clone(), "call"), + make_event( + Some(h1.clone()), + vec![sym("status")], + ScVal::Error(stellar_xdr::curr::ScError::Contract(42)), + true, + ), + ]; + + let chain = ChainAnalyzer::new() + .analyze(&events) + .expect("chain with ScVal::Error payload"); + + assert_eq!(chain.frames[0].role, FrameRole::RootCause); + } + + // ----------------------------------------------------------------------- + // in_successful_contract_call = false triggers failure + // ----------------------------------------------------------------------- + + #[test] + fn unsuccessful_call_flag_triggers_failure() { + let h1 = contract_hash(8); + let events = vec![ + fn_call(h1.clone(), "execute"), + make_event(Some(h1.clone()), vec![sym("transfer")], ScVal::Void, false), + ]; + + let chain = ChainAnalyzer::new() + .analyze(&events) + .expect("chain with unsuccessful flag"); + + assert_eq!(chain.frames.len(), 1); + assert_eq!(chain.frames[0].role, FrameRole::RootCause); + } + + // ----------------------------------------------------------------------- + // Trace rendering — no panic, contains expected markers + // ----------------------------------------------------------------------- + + #[test] + fn render_trace_contains_root_cause_and_fault_line_markers() { + let (h1, h2) = (contract_hash(1), contract_hash(2)); + let events = vec![ + fn_call(h1.clone(), "outer"), + fn_call(h2.clone(), "inner"), + error_event(h2.clone(), "boom"), + ]; + + let chain = ChainAnalyzer::new() + .analyze(&events) + .expect("chain"); + + let trace = chain.render_trace(); + assert!( + trace.contains("[ROOT CAUSE]"), + "trace must mark the root cause: {trace}" + ); + assert!( + trace.contains("[FAULT LINE]"), + "trace must mark the fault line: {trace}" + ); + } + + // ----------------------------------------------------------------------- + // Convenience free function delegates to ChainAnalyzer + // ----------------------------------------------------------------------- + + #[test] + fn analyze_call_chain_free_fn_returns_same_result_as_struct() { + let h1 = contract_hash(9); + let events = vec![ + fn_call(h1.clone(), "go"), + error_event(h1.clone(), "nope"), + ]; + + let via_fn = analyze_call_chain(&events); + let via_struct = ChainAnalyzer::new().analyze(&events); + + assert_eq!(via_fn.is_some(), via_struct.is_some()); + let fn_chain = via_fn.unwrap(); + let struct_chain = via_struct.unwrap(); + assert_eq!(fn_chain.frames.len(), struct_chain.frames.len()); + assert_eq!(fn_chain.root_cause_index, struct_chain.root_cause_index); + } + + // ----------------------------------------------------------------------- + // Default impl + // ----------------------------------------------------------------------- + + #[test] + fn default_impl_behaves_like_new() { + let h1 = contract_hash(11); + let events = vec![ + fn_call(h1.clone(), "init"), + error_event(h1.clone(), "err"), + ]; + let a = ChainAnalyzer::new().analyze(&events); + let b = ChainAnalyzer::default().analyze(&events); + assert_eq!(a.is_some(), b.is_some()); + } +} diff --git a/crates/core/src/decode/mod.rs b/crates/core/src/decode/mod.rs index 7253cecc..143faf2b 100644 --- a/crates/core/src/decode/mod.rs +++ b/crates/core/src/decode/mod.rs @@ -1,6 +1,7 @@ pub mod auth; pub mod auth_address_nonce; pub mod auth_signature; +pub mod chain_analyzer; pub mod context; pub mod contract_error; pub mod contract_error_resolver; @@ -19,6 +20,7 @@ pub use auth::{ AuthorizationType, }; pub use auth_address_nonce::AddressWithNonce; +pub use chain_analyzer::{analyze_call_chain, CallChain, ChainAnalyzer, ChainFrame, FrameRole}; pub use scval_to_json::scval_to_json; pub use walker::{ walk_diagnostic_events, DiagnosticEventKind, DiagnosticEventWalker, StructuredDiagnosticEvent, @@ -209,6 +211,23 @@ pub async fn decode_transaction_with_op_filter( diagnostic::enrich_report(&mut report, &tx_data)?; context::enrich_report(&mut report, &tx_data)?; cross_contract::attribute_failure(&mut report, &tx_data)?; + + // Reconstruct the call chain from diagnostic events. + if let Some(events_b64) = tx_data + .get("diagnosticEventsXdr") + .and_then(|v| v.as_array()) + { + let raw_events: Vec = events_b64 + .iter() + .filter_map(|v| v.as_str()) + .filter_map(|s| { + use crate::xdr::codec::XdrCodec; + stellar_xdr::curr::DiagnosticEvent::from_xdr_base64(s).ok() + }) + .collect(); + report.call_chain = chain_analyzer::analyze_call_chain(&raw_events); + } + reports.push(report); } diff --git a/crates/core/src/decode/report.rs b/crates/core/src/decode/report.rs index b9711c68..e2343f08 100644 --- a/crates/core/src/decode/report.rs +++ b/crates/core/src/decode/report.rs @@ -46,6 +46,7 @@ pub fn build_report(error: &ClassifiedError) -> GratResult { auth_signatures: Vec::new(), auth_entries: Vec::new(), failing_contract_id: None, + call_chain: None, learn_more: "https://developers.stellar.org/docs/learn/smart-contracts/errors" .to_string(), }; diff --git a/crates/core/src/types/report.rs b/crates/core/src/types/report.rs index e1337941..fca9aca8 100644 --- a/crates/core/src/types/report.rs +++ b/crates/core/src/types/report.rs @@ -1,3 +1,4 @@ +use crate::decode::chain_analyzer::CallChain; use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -134,6 +135,12 @@ pub struct DiagnosticReport { #[serde(skip_serializing_if = "Option::is_none", default)] pub failing_contract_id: Option, + /// Full reconstructed call chain at the point of failure, including + /// root-cause and fault-line identification. `None` when no diagnostic + /// events were present or no failure was detected in the chain. + #[serde(skip_serializing_if = "Option::is_none", default)] + pub call_chain: Option, + pub learn_more: String, } @@ -155,6 +162,7 @@ impl DiagnosticReport { auth_signatures: Vec::new(), auth_entries: Vec::new(), failing_contract_id: None, + call_chain: None, learn_more: "https://developers.stellar.org/docs/learn/smart-contracts/errors" .to_string(), } From 81badc5010c3f880f890a535aa2bdc50d61b12ba Mon Sep 17 00:00:00 2001 From: Umeokonkwo Samuel Date: Tue, 21 Jul 2026 11:54:16 +0100 Subject: [PATCH 2/4] style: fix rustfmt formatting and encoding issues --- crates/core/src/decode/chain_analyzer.rs | 29 ++------ crates/core/src/decode/contract_error.rs | 3 +- .../src/decode/contract_error_resolver.rs | 72 ++++++++++++------- crates/core/src/types/address.rs | 11 +-- 4 files changed, 61 insertions(+), 54 deletions(-) diff --git a/crates/core/src/decode/chain_analyzer.rs b/crates/core/src/decode/chain_analyzer.rs index 0fb17086..e129760d 100644 --- a/crates/core/src/decode/chain_analyzer.rs +++ b/crates/core/src/decode/chain_analyzer.rs @@ -199,11 +199,7 @@ impl ChainAnalyzer { for event in events { let ContractEventBody::V0(v0) = &event.event.body; - let topics: Vec = v0 - .topics - .iter() - .filter_map(Self::topic_to_string) - .collect(); + let topics: Vec = v0.topics.iter().filter_map(Self::topic_to_string).collect(); let first_topic = topics.first().map(String::as_str); @@ -257,7 +253,7 @@ impl ChainAnalyzer { /// that triggered the failure detection. fn build_chain(&self, stack: &[StackFrame], failing_event: &DiagnosticEvent) -> CallChain { if stack.is_empty() { - // Failure occurred before any fn_call was seen — synthesize a + // Failure occurred before any fn_call was seen - synthesize a // single-frame chain from the event's own contract_id. let address = failing_event .event @@ -495,10 +491,7 @@ mod tests { assert_eq!(chain.root_cause_index, Some(0)); assert_eq!(chain.frames[0].role, FrameRole::RootCause); assert_eq!(chain.frames[0].contract_address, strkey(1)); - assert_eq!( - chain.frames[0].function_name.as_deref(), - Some("transfer") - ); + assert_eq!(chain.frames[0].function_name.as_deref(), Some("transfer")); } // ----------------------------------------------------------------------- @@ -651,7 +644,7 @@ mod tests { } // ----------------------------------------------------------------------- - // Trace rendering — no panic, contains expected markers + // Trace rendering - no panic, contains expected markers // ----------------------------------------------------------------------- #[test] @@ -663,9 +656,7 @@ mod tests { error_event(h2.clone(), "boom"), ]; - let chain = ChainAnalyzer::new() - .analyze(&events) - .expect("chain"); + let chain = ChainAnalyzer::new().analyze(&events).expect("chain"); let trace = chain.render_trace(); assert!( @@ -685,10 +676,7 @@ mod tests { #[test] fn analyze_call_chain_free_fn_returns_same_result_as_struct() { let h1 = contract_hash(9); - let events = vec![ - fn_call(h1.clone(), "go"), - error_event(h1.clone(), "nope"), - ]; + let events = vec![fn_call(h1.clone(), "go"), error_event(h1.clone(), "nope")]; let via_fn = analyze_call_chain(&events); let via_struct = ChainAnalyzer::new().analyze(&events); @@ -707,10 +695,7 @@ mod tests { #[test] fn default_impl_behaves_like_new() { let h1 = contract_hash(11); - let events = vec![ - fn_call(h1.clone(), "init"), - error_event(h1.clone(), "err"), - ]; + let events = vec![fn_call(h1.clone(), "init"), error_event(h1.clone(), "err")]; let a = ChainAnalyzer::new().analyze(&events); let b = ChainAnalyzer::default().analyze(&events); assert_eq!(a.is_some(), b.is_some()); diff --git a/crates/core/src/decode/contract_error.rs b/crates/core/src/decode/contract_error.rs index fe4a60ab..cdd7d222 100644 --- a/crates/core/src/decode/contract_error.rs +++ b/crates/core/src/decode/contract_error.rs @@ -20,7 +20,8 @@ async fn resolve_with_network( ) -> GratResult { ContractId::new(contract_id)?; - let resolver = crate::decode::contract_error_resolver::ContractErrorResolver::new(network.clone()); + let resolver = + crate::decode::contract_error_resolver::ContractErrorResolver::new(network.clone()); let (error_name, doc_comment) = resolver.resolve(contract_id, error_code).await; let error_name_opt = if error_name == error_code.to_string() { diff --git a/crates/core/src/decode/contract_error_resolver.rs b/crates/core/src/decode/contract_error_resolver.rs index 7ec5a005..fd102354 100644 --- a/crates/core/src/decode/contract_error_resolver.rs +++ b/crates/core/src/decode/contract_error_resolver.rs @@ -1,13 +1,13 @@ +use crate::cache::store::{CacheCategory, CacheStore}; use crate::error::{GratError, GratResult}; -use crate::types::config::NetworkConfig; -use crate::types::address::Address; -use crate::spec::decoder::{decode_contract_spec, resolve_error_code}; -use crate::cache::store::{CacheStore, CacheCategory}; use crate::rpc::SorobanRpcClient; +use crate::spec::decoder::{decode_contract_spec, resolve_error_code}; +use crate::types::address::Address; +use crate::types::config::NetworkConfig; use crate::xdr::codec::XdrCodec; use stellar_xdr::curr::{ - LedgerEntry, LedgerEntryData, LedgerKey, LedgerKeyContractData, LedgerKeyContractCode, - ContractDataDurability, Hash, ScAddress, ScVal, ContractExecutable + ContractDataDurability, ContractExecutable, Hash, LedgerEntry, LedgerEntryData, LedgerKey, + LedgerKeyContractCode, LedgerKeyContractData, ScAddress, ScVal, }; pub struct ContractErrorResolver { @@ -36,14 +36,18 @@ impl ContractErrorResolver { } } - async fn resolve_inner(&self, contract_id: &str, error_code: u32) -> GratResult<(String, Option)> { + async fn resolve_inner( + &self, + contract_id: &str, + error_code: u32, + ) -> GratResult<(String, Option)> { // Validate Contract ID Address::validate_contract_id(contract_id)?; // Try local cache first let cache = CacheStore::default_location()?; let cache_key = format!("{contract_id}_spec"); - + let wasm_bytes = if let Some(cached) = cache.get(CacheCategory::WasmBlob, &cache_key)? { cached } else { @@ -71,7 +75,7 @@ impl ContractErrorResolver { async fn fetch_wasm_from_network(&self, contract_id: &str) -> GratResult> { let rpc_client = SorobanRpcClient::new(&self.network); let address = Address::from_contract_id(contract_id)?; - + let mut contract_bytes = [0u8; 32]; contract_bytes.copy_from_slice(&address.bytes); @@ -109,24 +113,30 @@ impl ContractErrorResolver { let ledger_entry = LedgerEntry::from_xdr_base64(xdr_base64)?; let wasm_hash = match ledger_entry.data { - LedgerEntryData::ContractData(contract_data) => { - match contract_data.val { - ScVal::ContractInstance(instance) => { - match instance.executable { - ContractExecutable::Wasm(wasm_hash) => wasm_hash, - _ => return Err(GratError::SpecError("Contract is not a WASM executable".to_string())), - } + LedgerEntryData::ContractData(contract_data) => match contract_data.val { + ScVal::ContractInstance(instance) => match instance.executable { + ContractExecutable::Wasm(wasm_hash) => wasm_hash, + _ => { + return Err(GratError::SpecError( + "Contract is not a WASM executable".to_string(), + )) } - _ => return Err(GratError::SpecError("Ledger entry value is not a ContractInstance".to_string())), + }, + _ => { + return Err(GratError::SpecError( + "Ledger entry value is not a ContractInstance".to_string(), + )) } + }, + _ => { + return Err(GratError::SpecError( + "Ledger entry is not ContractData".to_string(), + )) } - _ => return Err(GratError::SpecError("Ledger entry is not ContractData".to_string())), }; // Step 2: Fetch the ContractCode LedgerEntry using wasm_hash - let code_key = LedgerKey::ContractCode(LedgerKeyContractCode { - hash: wasm_hash, - }); + let code_key = LedgerKey::ContractCode(LedgerKeyContractCode { hash: wasm_hash }); let base64_code_key = code_key.to_xdr_base64()?; let code_response = rpc_client.get_ledger_entries(&[base64_code_key]).await?; @@ -135,7 +145,9 @@ impl ContractErrorResolver { .get("entries") .and_then(serde_json::Value::as_array) .ok_or_else(|| { - GratError::ContractNotFound(format!("Invalid response for contract code {contract_id}")) + GratError::ContractNotFound(format!( + "Invalid response for contract code {contract_id}" + )) })?; if code_entries.is_empty() { @@ -147,7 +159,9 @@ impl ContractErrorResolver { .get("xdr") .and_then(serde_json::Value::as_str) .ok_or_else(|| { - GratError::SpecError(format!("Missing XDR in response for contract code {contract_id}")) + GratError::SpecError(format!( + "Missing XDR in response for contract code {contract_id}" + )) })?; let code_ledger_entry = LedgerEntry::from_xdr_base64(code_xdr_base64)?; @@ -164,7 +178,9 @@ impl ContractErrorResolver { #[cfg(test)] mod tests { use super::*; - use stellar_xdr::curr::{ScSpecEntry, ScSpecUdtErrorEnumV0, ScSpecUdtErrorEnumCaseV0, WriteXdr}; + use stellar_xdr::curr::{ + ScSpecEntry, ScSpecUdtErrorEnumCaseV0, ScSpecUdtErrorEnumV0, WriteXdr, + }; #[tokio::test] async fn test_resolve_from_cache_success() { @@ -176,7 +192,9 @@ mod tests { doc: "Some doc".try_into().unwrap(), name: "Failed".try_into().unwrap(), value: 1042, - }].try_into().unwrap(), + }] + .try_into() + .unwrap(), }; let entry = ScSpecEntry::UdtErrorEnumV0(error_enum); let mut entry_bytes = Vec::new(); @@ -199,7 +217,9 @@ mod tests { let contract_id = "CA3D5KTHBN6TOVTTTA74S4O4O4O4O4O4O4O4O4O4O4O4O4O4O4O4O4O4"; let cache = CacheStore::default_location().unwrap(); let cache_key = format!("{contract_id}_spec"); - cache.put(CacheCategory::WasmBlob, &cache_key, &wasm).unwrap(); + cache + .put(CacheCategory::WasmBlob, &cache_key, &wasm) + .unwrap(); let resolver = ContractErrorResolver::new(NetworkConfig::testnet()); let (name, doc) = resolver.resolve(contract_id, 1042).await; diff --git a/crates/core/src/types/address.rs b/crates/core/src/types/address.rs index bac488b5..99aa28ea 100644 --- a/crates/core/src/types/address.rs +++ b/crates/core/src/types/address.rs @@ -48,9 +48,7 @@ impl Address { .map_err(|e| GratError::InvalidAddress(format!("Failed to parse strkey: {e}")))?; match strkey { - Strkey::PublicKeyEd25519(pk) => { - Self::new(pk.0.to_vec(), AddressType::Account) - } + Strkey::PublicKeyEd25519(pk) => Self::new(pk.0.to_vec(), AddressType::Account), Strkey::Contract(c) => Self::new(c.0.to_vec(), AddressType::Contract), _ => Err(GratError::InvalidAddress(format!( "Unsupported address type: {s}" @@ -107,14 +105,17 @@ impl Address { impl fmt::Display for Address { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let rendered = self.to_strkey().unwrap_or_else(|e| format!("")); + let rendered = self + .to_strkey() + .unwrap_or_else(|e| format!("")); write!(f, "{rendered}") } } impl From
for String { fn from(addr: Address) -> String { - addr.to_strkey().unwrap_or_else(|e| format!("")) + addr.to_strkey() + .unwrap_or_else(|e| format!("")) } } From 60bab3018af0f81de19dbf1b29b0b112dfece92c Mon Sep 17 00:00:00 2001 From: Umeokonkwo Samuel Date: Tue, 21 Jul 2026 12:03:01 +0100 Subject: [PATCH 3/4] fix(core,cli): resolve compilation errors and unused imports for CI --- crates/cli/src/output/renderers.rs | 1 + crates/core/src/decode/contract_error.rs | 3 +-- crates/core/src/decode/contract_error_resolver.rs | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/cli/src/output/renderers.rs b/crates/cli/src/output/renderers.rs index 888e6d66..8b14c12b 100644 --- a/crates/cli/src/output/renderers.rs +++ b/crates/cli/src/output/renderers.rs @@ -510,6 +510,7 @@ mod tests { auth_signatures: Vec::new(), auth_entries: Vec::new(), failing_contract_id: None, + call_chain: None, learn_more: "https://developers.stellar.org/docs/learn/smart-contracts/errors" .to_string(), } diff --git a/crates/core/src/decode/contract_error.rs b/crates/core/src/decode/contract_error.rs index cdd7d222..74a0c86f 100644 --- a/crates/core/src/decode/contract_error.rs +++ b/crates/core/src/decode/contract_error.rs @@ -1,6 +1,5 @@ use crate::decode::decode_context::DecodeContext; -use crate::error::{GratError, GratResult}; -use crate::spec::decoder; +use crate::error::GratResult; use crate::types::config::NetworkConfig; use crate::types::contract_id::ContractId; use crate::types::report::ContractErrorInfo; diff --git a/crates/core/src/decode/contract_error_resolver.rs b/crates/core/src/decode/contract_error_resolver.rs index fd102354..53629635 100644 --- a/crates/core/src/decode/contract_error_resolver.rs +++ b/crates/core/src/decode/contract_error_resolver.rs @@ -179,7 +179,7 @@ impl ContractErrorResolver { mod tests { use super::*; use stellar_xdr::curr::{ - ScSpecEntry, ScSpecUdtErrorEnumCaseV0, ScSpecUdtErrorEnumV0, WriteXdr, + Limits, ScSpecEntry, ScSpecUdtErrorEnumCaseV0, ScSpecUdtErrorEnumV0, WriteXdr, }; #[tokio::test] @@ -187,6 +187,7 @@ mod tests { // Construct a mock error enum spec entry let error_enum = ScSpecUdtErrorEnumV0 { doc: "Error documentation".try_into().unwrap(), + lib: "".try_into().unwrap(), name: "MyError".try_into().unwrap(), cases: vec![ScSpecUdtErrorEnumCaseV0 { doc: "Some doc".try_into().unwrap(), @@ -197,8 +198,7 @@ mod tests { .unwrap(), }; let entry = ScSpecEntry::UdtErrorEnumV0(error_enum); - let mut entry_bytes = Vec::new(); - entry.write_xdr(&mut entry_bytes).unwrap(); + let entry_bytes = entry.to_xdr(Limits::none()).unwrap(); // Construct mock WASM payload let mut wasm = vec![0x00, 0x61, 0x73, 0x6D, 0x01, 0x00, 0x00, 0x00]; From 89df09598e18a936f1938d0656e60eabfe8b4f2e Mon Sep 17 00:00:00 2001 From: Umeokonkwo Samuel Date: Tue, 21 Jul 2026 12:11:42 +0100 Subject: [PATCH 4/4] fix(core): use valid strkey contract_id in contract_error_resolver test --- crates/core/src/decode/contract_error_resolver.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/core/src/decode/contract_error_resolver.rs b/crates/core/src/decode/contract_error_resolver.rs index 53629635..379fbbbe 100644 --- a/crates/core/src/decode/contract_error_resolver.rs +++ b/crates/core/src/decode/contract_error_resolver.rs @@ -214,7 +214,8 @@ mod tests { wasm.push(custom_payload.len() as u8); wasm.extend(custom_payload); - let contract_id = "CA3D5KTHBN6TOVTTTA74S4O4O4O4O4O4O4O4O4O4O4O4O4O4O4O4O4O4"; + let contract_id_str = stellar_strkey::Contract([7u8; 32]).to_string(); + let contract_id = &contract_id_str; let cache = CacheStore::default_location().unwrap(); let cache_key = format!("{contract_id}_spec"); cache