use std::fmt;
use log::warn;
use soroban_env_host::events::Events;
use soroban_env_host::xdr::{ContractEventBody, ContractEventType, ScMapEntry, ScVal};
#[derive(Debug, Clone, Default)]
pub struct Trace {
pub roots: Vec<TraceFrame>,
}
#[derive(Debug, Clone)]
pub struct TraceFrame {
pub contract: stellar_strkey::Contract,
pub function: String,
pub args: Vec<ScVal>,
pub result: TraceResult,
pub rolled_back: bool,
pub children: Vec<TraceFrame>,
}
#[derive(Debug, Clone)]
pub enum TraceResult {
Returned(ScVal),
Trapped,
}
impl Trace {
pub fn from_events(events: &Events) -> Self {
let mut stack: Vec<TraceFrame> = Vec::new();
let mut roots: Vec<TraceFrame> = Vec::new();
for host_event in &events.0 {
if host_event.event.type_ != ContractEventType::Diagnostic {
continue;
}
let ContractEventBody::V0(body) = &host_event.event.body;
let topics = body.topics.as_slice();
let kind = topics.first().and_then(scval_as_symbol_str);
match kind.as_deref() {
Some("fn_call") => {
let Some(frame) = parse_fn_call(topics, &body.data, host_event.failed_call)
else {
warn!("soroban-fork: malformed fn_call event, skipped");
continue;
};
stack.push(frame);
}
Some("fn_return") => {
if let Some(mut frame) = stack.pop() {
frame.result = TraceResult::Returned(body.data.clone());
if host_event.failed_call {
frame.rolled_back = true;
}
attach(&mut stack, &mut roots, frame);
} else {
warn!("soroban-fork: orphan fn_return event with no open frame");
}
}
_ => continue,
}
}
while let Some(mut frame) = stack.pop() {
frame.result = TraceResult::Trapped;
frame.rolled_back = true;
attach(&mut stack, &mut roots, frame);
}
Trace { roots }
}
pub fn frame_count(&self) -> usize {
self.roots.iter().map(count_frames).sum()
}
pub fn had_failures(&self) -> bool {
self.roots.iter().any(any_failure)
}
}
fn count_frames(frame: &TraceFrame) -> usize {
1 + frame.children.iter().map(count_frames).sum::<usize>()
}
fn any_failure(frame: &TraceFrame) -> bool {
frame.rolled_back
|| matches!(frame.result, TraceResult::Trapped)
|| frame.children.iter().any(any_failure)
}
fn attach(stack: &mut [TraceFrame], roots: &mut Vec<TraceFrame>, frame: TraceFrame) {
if let Some(parent) = stack.last_mut() {
parent.children.push(frame);
} else {
roots.push(frame);
}
}
fn parse_fn_call(topics: &[ScVal], data: &ScVal, failed: bool) -> Option<TraceFrame> {
if topics.len() < 3 {
return None;
}
let contract = scval_as_contract(&topics[1])?;
let function = scval_as_symbol_str(&topics[2])?;
let args = unwrap_args_payload(data);
Some(TraceFrame {
contract,
function,
args,
result: TraceResult::Trapped, rolled_back: failed,
children: Vec::new(),
})
}
fn unwrap_args_payload(data: &ScVal) -> Vec<ScVal> {
match data {
ScVal::Void => Vec::new(),
ScVal::Vec(Some(v)) => v.0.iter().cloned().collect(),
ScVal::Vec(None) => Vec::new(),
single => vec![single.clone()],
}
}
fn scval_as_symbol_str(v: &ScVal) -> Option<String> {
match v {
ScVal::Symbol(s) => std::str::from_utf8(s.0.as_slice()).ok().map(str::to_string),
_ => None,
}
}
fn scval_as_contract(v: &ScVal) -> Option<stellar_strkey::Contract> {
match v {
ScVal::Bytes(b) => {
let arr: [u8; 32] = b.0.as_slice().try_into().ok()?;
Some(stellar_strkey::Contract(arr))
}
_ => None,
}
}
const INDENT: &str = " ";
impl fmt::Display for Trace {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if self.roots.is_empty() {
return writeln!(
f,
"[TRACE] (empty — was tracing(true) set on ForkConfig before invoke?)"
);
}
writeln!(f, "[TRACE]")?;
for root in &self.roots {
render_frame(f, root, 1)?;
}
Ok(())
}
}
fn render_frame(f: &mut fmt::Formatter<'_>, frame: &TraceFrame, depth: usize) -> fmt::Result {
let pad = INDENT.repeat(depth);
let contract_short = abbreviate_strkey(&frame.contract.to_string());
write!(f, "{pad}[{contract_short}] {}(", frame.function)?;
for (i, arg) in frame.args.iter().enumerate() {
if i > 0 {
write!(f, ", ")?;
}
render_scval(f, arg)?;
}
write!(f, ")")?;
if frame.rolled_back {
write!(f, " [rolled back]")?;
}
writeln!(f)?;
for child in &frame.children {
render_frame(f, child, depth + 1)?;
}
let return_pad = INDENT.repeat(depth + 1);
match &frame.result {
TraceResult::Returned(v) => {
write!(f, "{return_pad}\u{2190} ")?;
render_scval(f, v)?;
writeln!(f)
}
TraceResult::Trapped => writeln!(f, "{return_pad}\u{2190} TRAPPED (no fn_return)"),
}
}
pub(crate) fn render_scval(f: &mut fmt::Formatter<'_>, v: &ScVal) -> fmt::Result {
match v {
ScVal::Void => write!(f, "()"),
ScVal::Bool(b) => write!(f, "{b}"),
ScVal::U32(n) => write!(f, "{n}"),
ScVal::I32(n) => write!(f, "{n}"),
ScVal::U64(n) => write!(f, "{n}"),
ScVal::I64(n) => write!(f, "{n}"),
ScVal::U128(p) => write!(f, "{}", u128_from_pieces(p.hi, p.lo)),
ScVal::I128(p) => write!(f, "{}", i128_from_pieces(p.hi, p.lo)),
ScVal::Symbol(s) => match std::str::from_utf8(s.0.as_slice()) {
Ok(text) => write!(f, "{text}"),
Err(_) => write!(f, "Symbol(<non-utf8>)"),
},
ScVal::String(s) => match std::str::from_utf8(s.0.as_slice()) {
Ok(text) => write!(f, "\"{text}\""),
Err(_) => write!(f, "String(<non-utf8>)"),
},
ScVal::Bytes(b) => render_bytes(f, b.0.as_slice()),
ScVal::Address(addr) => render_address(f, addr),
ScVal::Vec(None) => write!(f, "[]"),
ScVal::Vec(Some(vec)) => {
write!(f, "[")?;
for (i, e) in vec.0.iter().enumerate() {
if i > 0 {
write!(f, ", ")?;
}
render_scval(f, e)?;
}
write!(f, "]")
}
ScVal::Map(None) => write!(f, "{{}}"),
ScVal::Map(Some(m)) => render_map(f, m.0.as_slice()),
other => write!(f, "{other:?}"),
}
}
fn render_bytes(f: &mut fmt::Formatter<'_>, bytes: &[u8]) -> fmt::Result {
const PREVIEW: usize = 8;
if bytes.len() <= PREVIEW * 2 {
write!(f, "0x")?;
for b in bytes {
write!(f, "{b:02x}")?;
}
Ok(())
} else {
write!(f, "0x")?;
for b in &bytes[..PREVIEW] {
write!(f, "{b:02x}")?;
}
write!(f, "\u{2026}({} bytes)", bytes.len())
}
}
fn render_map(f: &mut fmt::Formatter<'_>, entries: &[ScMapEntry]) -> fmt::Result {
write!(f, "{{")?;
for (i, e) in entries.iter().enumerate() {
if i > 0 {
write!(f, ", ")?;
}
render_scval(f, &e.key)?;
write!(f, ": ")?;
render_scval(f, &e.val)?;
}
write!(f, "}}")
}
pub(crate) fn render_address(
f: &mut fmt::Formatter<'_>,
addr: &soroban_env_host::xdr::ScAddress,
) -> fmt::Result {
use soroban_env_host::xdr::{PublicKey, ScAddress};
match addr {
ScAddress::Account(a) => {
let PublicKey::PublicKeyTypeEd25519(k) = &a.0;
let full = stellar_strkey::ed25519::PublicKey(k.0).to_string();
write!(f, "{}", abbreviate_strkey(&full))
}
ScAddress::Contract(h) => {
let full = stellar_strkey::Contract(h.0 .0).to_string();
write!(f, "{}", abbreviate_strkey(&full))
}
other => write!(f, "{other:?}"),
}
}
fn abbreviate_strkey(s: &str) -> String {
if s.len() > 12 {
format!("{}\u{2026}{}", &s[..4], &s[s.len() - 4..])
} else {
s.to_string()
}
}
fn u128_from_pieces(hi: u64, lo: u64) -> u128 {
((hi as u128) << 64) | (lo as u128)
}
fn i128_from_pieces(hi: i64, lo: u64) -> i128 {
((hi as i128) << 64) | (lo as i128)
}
#[cfg(test)]
mod tests {
use super::*;
use soroban_env_host::events::HostEvent;
use soroban_env_host::xdr::{
ContractEvent, ContractEventBody, ContractEventType, ContractEventV0, ContractId,
ExtensionPoint, Hash, Int128Parts, ScBytes, ScSymbol, ScVal, ScVec, VecM,
};
fn sym(s: &str) -> ScVal {
ScVal::Symbol(ScSymbol(s.try_into().expect("symbol fits in 32 bytes")))
}
fn i128_val(v: i128) -> ScVal {
ScVal::I128(Int128Parts {
hi: (v >> 64) as i64,
lo: v as u64,
})
}
fn bytes32(b: [u8; 32]) -> ScVal {
ScVal::Bytes(ScBytes(
b.to_vec().try_into().expect("32 bytes fits BytesM"),
))
}
fn make_event(
contract_id: Option<[u8; 32]>,
topics: Vec<ScVal>,
data: ScVal,
failed_call: bool,
) -> HostEvent {
let topics_v: VecM<ScVal> = topics.try_into().expect("topics fit in VecM");
HostEvent {
event: ContractEvent {
ext: ExtensionPoint::V0,
contract_id: contract_id.map(|id| ContractId(Hash(id))),
type_: ContractEventType::Diagnostic,
body: ContractEventBody::V0(ContractEventV0 {
topics: topics_v,
data,
}),
},
failed_call,
}
}
fn fn_call(callee: [u8; 32], func: &str, data: ScVal, failed: bool) -> HostEvent {
make_event(
None, vec![sym("fn_call"), bytes32(callee), sym(func)],
data,
failed,
)
}
fn fn_return(callee: [u8; 32], func: &str, value: ScVal, failed: bool) -> HostEvent {
make_event(
Some(callee),
vec![sym("fn_return"), sym(func)],
value,
failed,
)
}
fn evs(events: Vec<HostEvent>) -> Events {
Events(events)
}
const C1: [u8; 32] = [1u8; 32];
const C2: [u8; 32] = [2u8; 32];
const C3: [u8; 32] = [3u8; 32];
#[test]
fn empty_event_stream_yields_empty_trace() {
let trace = Trace::from_events(&evs(vec![]));
assert!(trace.roots.is_empty());
assert_eq!(trace.frame_count(), 0);
assert!(!trace.had_failures());
}
#[test]
fn single_call_returns_one_root() {
let trace = Trace::from_events(&evs(vec![
fn_call(C1, "balance", ScVal::Void, false),
fn_return(C1, "balance", i128_val(42), false),
]));
assert_eq!(trace.roots.len(), 1);
let root = &trace.roots[0];
assert_eq!(root.function, "balance");
assert!(matches!(
&root.result,
TraceResult::Returned(ScVal::I128(_))
));
assert!(!root.rolled_back);
assert!(root.children.is_empty());
assert_eq!(trace.frame_count(), 1);
assert!(!trace.had_failures());
}
#[test]
fn nested_call_becomes_child() {
let trace = Trace::from_events(&evs(vec![
fn_call(C1, "deposit", ScVal::U32(100), false),
fn_call(C2, "transfer", ScVal::U32(100), false),
fn_return(C2, "transfer", ScVal::Void, false),
fn_return(C1, "deposit", ScVal::U32(100), false),
]));
assert_eq!(trace.roots.len(), 1);
assert_eq!(trace.roots[0].function, "deposit");
assert_eq!(trace.roots[0].children.len(), 1);
assert_eq!(trace.roots[0].children[0].function, "transfer");
assert_eq!(trace.frame_count(), 2);
}
#[test]
fn three_level_nesting_preserves_order() {
let trace = Trace::from_events(&evs(vec![
fn_call(C1, "a", ScVal::Void, false),
fn_call(C2, "b", ScVal::Void, false),
fn_call(C3, "c", ScVal::Void, false),
fn_return(C3, "c", ScVal::Void, false),
fn_return(C2, "b", ScVal::Void, false),
fn_return(C1, "a", ScVal::Void, false),
]));
let a = &trace.roots[0];
assert_eq!(a.function, "a");
let b = &a.children[0];
assert_eq!(b.function, "b");
let c = &b.children[0];
assert_eq!(c.function, "c");
assert!(c.children.is_empty());
}
#[test]
fn sibling_calls_at_same_depth() {
let trace = Trace::from_events(&evs(vec![
fn_call(C1, "root", ScVal::Void, false),
fn_call(C2, "first", ScVal::Void, false),
fn_return(C2, "first", ScVal::Void, false),
fn_call(C3, "second", ScVal::Void, false),
fn_return(C3, "second", ScVal::Void, false),
fn_return(C1, "root", ScVal::Void, false),
]));
assert_eq!(trace.roots[0].children.len(), 2);
assert_eq!(trace.roots[0].children[0].function, "first");
assert_eq!(trace.roots[0].children[1].function, "second");
}
#[test]
fn trapped_frame_is_emitted_on_missing_return() {
let trace = Trace::from_events(&evs(vec![fn_call(C1, "panicking", ScVal::Void, false)]));
assert_eq!(trace.roots.len(), 1);
assert!(matches!(trace.roots[0].result, TraceResult::Trapped));
assert!(trace.roots[0].rolled_back);
assert!(trace.had_failures());
}
#[test]
fn rolled_back_frame_keeps_return_value() {
let trace = Trace::from_events(&evs(vec![
fn_call(C1, "child", ScVal::Void, false),
fn_return(C1, "child", ScVal::U32(7), true),
]));
assert_eq!(trace.roots.len(), 1);
assert!(matches!(
&trace.roots[0].result,
TraceResult::Returned(ScVal::U32(7))
));
assert!(trace.roots[0].rolled_back);
assert!(trace.had_failures());
}
#[test]
fn orphan_return_is_skipped_not_panic() {
let trace = Trace::from_events(&evs(vec![fn_return(C1, "lonely", ScVal::Void, false)]));
assert!(trace.roots.is_empty());
}
#[test]
fn malformed_fn_call_is_skipped() {
let trace = Trace::from_events(&evs(vec![make_event(
None,
vec![sym("fn_call"), bytes32(C1)], ScVal::Void,
false,
)]));
assert!(trace.roots.is_empty());
}
#[test]
fn non_diagnostic_events_are_ignored() {
let mut he = make_event(
None,
vec![sym("fn_call"), bytes32(C1), sym("evil")],
ScVal::Void,
false,
);
he.event.type_ = ContractEventType::Contract;
let trace = Trace::from_events(&evs(vec![he]));
assert!(trace.roots.is_empty());
}
#[test]
fn multi_arg_data_is_unwrapped() {
let args_vec = ScVal::Vec(Some(ScVec(
vec![ScVal::U32(1), ScVal::U32(2), ScVal::U32(3)]
.try_into()
.unwrap(),
)));
let trace = Trace::from_events(&evs(vec![
fn_call(C1, "f", args_vec, false),
fn_return(C1, "f", ScVal::Void, false),
]));
assert_eq!(trace.roots[0].args.len(), 3);
}
#[test]
fn single_arg_data_is_one_arg() {
let trace = Trace::from_events(&evs(vec![
fn_call(C1, "f", ScVal::U32(99), false),
fn_return(C1, "f", ScVal::Void, false),
]));
assert_eq!(trace.roots[0].args, vec![ScVal::U32(99)]);
}
#[test]
fn zero_arg_data_is_no_args() {
let trace = Trace::from_events(&evs(vec![
fn_call(C1, "f", ScVal::Void, false),
fn_return(C1, "f", ScVal::Void, false),
]));
assert!(trace.roots[0].args.is_empty());
}
#[test]
fn display_empty_trace_is_explicit() {
let trace = Trace::default();
let s = format!("{trace}");
assert!(s.contains("empty"), "empty trace should explain why: {s:?}");
}
#[test]
fn display_simple_trace_renders_function_and_return() {
let trace = Trace::from_events(&evs(vec![
fn_call(C1, "balance", ScVal::Void, false),
fn_return(C1, "balance", i128_val(42), false),
]));
let s = format!("{trace}");
assert!(s.contains("[TRACE]"));
assert!(s.contains("balance"));
assert!(s.contains("42"));
}
#[test]
fn display_marks_rolled_back_frames() {
let trace = Trace::from_events(&evs(vec![
fn_call(C1, "child", ScVal::Void, false),
fn_return(C1, "child", ScVal::Void, true),
]));
let s = format!("{trace}");
assert!(
s.contains("rolled back"),
"rolled-back frame should be tagged: {s:?}"
);
}
#[test]
fn display_marks_trapped_frames() {
let trace = Trace::from_events(&evs(vec![fn_call(C1, "boom", ScVal::Void, false)]));
let s = format!("{trace}");
assert!(
s.contains("TRAPPED"),
"trapped frame should be tagged: {s:?}"
);
}
fn render(v: &ScVal) -> String {
struct W<'a>(&'a ScVal);
impl fmt::Display for W<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
render_scval(f, self.0)
}
}
W(v).to_string()
}
#[test]
fn renders_primitives_compactly() {
assert_eq!(render(&ScVal::Void), "()");
assert_eq!(render(&ScVal::Bool(true)), "true");
assert_eq!(render(&ScVal::U32(42)), "42");
assert_eq!(render(&ScVal::I32(-7)), "-7");
assert_eq!(render(&ScVal::U64(123_456)), "123456");
}
#[test]
fn renders_i128_correctly() {
let v = i128_val(-1_000_000_000_000);
assert_eq!(render(&v), "-1000000000000");
}
#[test]
fn renders_short_bytes_as_full_hex() {
let b = ScVal::Bytes(ScBytes(vec![0xde, 0xad, 0xbe, 0xef].try_into().unwrap()));
assert_eq!(render(&b), "0xdeadbeef");
}
#[test]
fn renders_long_bytes_with_preview() {
let b = ScVal::Bytes(ScBytes(vec![0xab; 64].try_into().unwrap()));
let s = render(&b);
assert!(s.starts_with("0xabababababababab"));
assert!(s.contains("64 bytes"));
}
#[test]
fn renders_symbol_as_text() {
assert_eq!(render(&sym("transfer")), "transfer");
}
#[test]
fn renders_vec_with_commas() {
let v = ScVal::Vec(Some(ScVec(
vec![ScVal::U32(1), ScVal::U32(2)].try_into().unwrap(),
)));
assert_eq!(render(&v), "[1, 2]");
}
#[test]
fn abbreviate_strkey_short_unchanged() {
assert_eq!(abbreviate_strkey("abc"), "abc");
}
#[test]
fn abbreviate_strkey_long_truncates_with_ellipsis() {
let full = "GABCDEFGHIJKLMNOPQRSTUVWXYZ";
let s = abbreviate_strkey(full);
assert!(s.starts_with("GABC"));
assert!(s.ends_with("XYZ"));
assert!(s.contains('\u{2026}'));
}
#[test]
fn u128_from_pieces_roundtrip() {
assert_eq!(u128_from_pieces(0, 0), 0);
assert_eq!(u128_from_pieces(0, 42), 42);
assert_eq!(u128_from_pieces(1, 0), 1u128 << 64);
assert_eq!(u128_from_pieces(u64::MAX, u64::MAX), u128::MAX);
}
#[test]
fn i128_from_pieces_handles_negatives() {
assert_eq!(i128_from_pieces(0, 0), 0);
assert_eq!(i128_from_pieces(0, 42), 42);
assert_eq!(i128_from_pieces(-1, u64::MAX), -1);
assert_eq!(i128_from_pieces(-1, 0), i128::from(-1i64) << 64);
}
}