diff --git a/Cargo.lock b/Cargo.lock index 6b6b271bf4..c0cfd55bdc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4991,6 +4991,7 @@ dependencies = [ "hex", "home", "humantime", + "indexmap 2.11.0", "itertools 0.10.5", "jsonrpsee-core", "jsonrpsee-http-client", @@ -5246,7 +5247,9 @@ dependencies = [ "base64 0.21.7", "ethnum", "hex", + "indexmap 2.11.0", "itertools 0.10.5", + "serde", "serde_json", "soroban-spec", "stellar-strkey 0.0.15", diff --git a/FULL_HELP_DOCS.md b/FULL_HELP_DOCS.md index 79ec1a364a..d5a23df285 100644 --- a/FULL_HELP_DOCS.md +++ b/FULL_HELP_DOCS.md @@ -1094,9 +1094,10 @@ Watch the network for contract events Default value: `pretty` Possible values: - - `pretty`: Colorful, human-oriented console output - - `plain`: Human-oriented console output without colors - - `json`: JSON formatted console output + - `pretty`: Human-readable output with decoded event names and parameters + - `plain`: Human-readable output without colors + - `json`: JSON output with decoded event names and parameters + - `raw`: Raw event output without self-describing decoding - `-c`, `--count ` — The maximum number of events to display (defer to the server-defined limit) diff --git a/cmd/crates/soroban-spec-tools/Cargo.toml b/cmd/crates/soroban-spec-tools/Cargo.toml index a17a5d31f1..5f5be293e7 100644 --- a/cmd/crates/soroban-spec-tools/Cargo.toml +++ b/cmd/crates/soroban-spec-tools/Cargo.toml @@ -20,6 +20,7 @@ crate-type = ["rlib"] soroban-spec = { workspace = true } stellar-strkey = { workspace = true } stellar-xdr = { workspace = true, features = ["curr", "std", "serde", "base64"] } +serde = { workspace = true } serde_json = { workspace = true } itertools = { workspace = true } ethnum = { workspace = true } @@ -27,6 +28,7 @@ hex = { workspace = true } wasmparser = { workspace = true } base64 = { workspace = true } thiserror = "1.0.31" +indexmap = { version = "2.7", features = ["serde"] } [dev-dependencies] diff --git a/cmd/crates/soroban-spec-tools/src/event.rs b/cmd/crates/soroban-spec-tools/src/event.rs new file mode 100644 index 0000000000..750983fbc9 --- /dev/null +++ b/cmd/crates/soroban-spec-tools/src/event.rs @@ -0,0 +1,711 @@ +use indexmap::IndexMap; +use serde::Serialize; +use serde_json::Value; +use stellar_xdr::curr::{ + ScSpecEventDataFormat, ScSpecEventParamLocationV0, ScSpecEventParamV0, ScSpecEventV0, ScSymbol, + ScVal, +}; + +use crate::{Error, Spec}; + +/// Decoded event with named parameters +#[derive(Debug, Clone, Serialize)] +pub struct DecodedEvent { + pub contract_id: String, + /// The event name from the contract spec (e.g., "Transfer", "Approve") + pub event_name: String, + /// The prefix topics that identify this event (e.g., `["transfer"]`) + pub prefix_topics: Vec, + pub params: IndexMap, +} + +/// Errors that can occur during event decoding +#[derive(thiserror::Error, Debug)] +pub enum EventDecodeError { + #[error("No matching event spec found")] + NoMatchingSpec, + #[error("Topic count mismatch: expected at least {expected}, got {actual}")] + TopicCountMismatch { expected: usize, actual: usize }, + #[error("Data parameter count mismatch: expected {expected}, got {actual}")] + DataParamCountMismatch { expected: usize, actual: usize }, + #[error("Failed to decode parameter '{name}': {source}")] + ParamDecodeError { name: String, source: Error }, + #[error("Invalid topic format: expected symbol")] + InvalidTopicFormat, + #[error("Invalid data format for event")] + InvalidDataFormat, + #[error("Spec error: {0}")] + SpecError(#[from] Error), +} + +impl Spec { + /// Match event topics to find the corresponding spec + /// + /// Returns the matching event spec if the prefix topics match, otherwise None. + pub fn match_event_to_spec<'a>(&'a self, topics: &[ScVal]) -> Option<&'a ScSpecEventV0> { + self.find_events() + .ok()? + .find(|event| matches_prefix_topics(&event.prefix_topics, topics)) + } + + /// Decode event using spec, producing named parameters + /// + /// # Errors + /// + /// Returns an error if the event cannot be decoded + pub fn decode_event( + &self, + contract_id: &str, + topics: &[ScVal], + data: &ScVal, + ) -> Result { + let event_spec = self + .match_event_to_spec(topics) + .ok_or(EventDecodeError::NoMatchingSpec)?; + + decode_event_with_spec(self, contract_id, topics, data, event_spec) + } +} + +/// Check if the prefix topics match the first N event topics +fn matches_prefix_topics(prefix_topics: &[ScSymbol], topics: &[ScVal]) -> bool { + if prefix_topics.is_empty() { + return true; + } + + // Need at least as many topics as prefix topics + if topics.len() < prefix_topics.len() { + return false; + } + + // Check each prefix topic matches the corresponding event topic. + prefix_topics + .iter() + .zip(topics.iter()) + .all(|(prefix, topic)| match topic { + ScVal::Symbol(topic_sym) => prefix.as_vec() == topic_sym.as_vec(), + ScVal::String(topic_str) => prefix.as_vec() == topic_str.as_vec(), + _ => false, + }) +} + +/// Decode an event using the provided spec +fn decode_event_with_spec( + spec: &Spec, + contract_id: &str, + topics: &[ScVal], + data: &ScVal, + event_spec: &ScSpecEventV0, +) -> Result { + let event_name = event_spec.name.to_utf8_string_lossy(); + let mut params = IndexMap::new(); + + // Separate params by location + let (topic_params, data_params): (Vec<_>, Vec<_>) = event_spec + .params + .iter() + .partition(|p| p.location == ScSpecEventParamLocationV0::TopicList); + + // Skip past prefix topics to get to the parameter topics + let topic_offset = event_spec.prefix_topics.len(); + + // Extract topic parameters + extract_topic_params(spec, topics, &topic_params, topic_offset, &mut params)?; + + // Extract data parameters based on data_format + extract_data_params( + spec, + data, + &data_params, + event_spec.data_format, + &mut params, + )?; + + let prefix_topics = event_spec + .prefix_topics + .iter() + .map(|t| t.to_utf8_string_lossy()) + .collect(); + + Ok(DecodedEvent { + contract_id: contract_id.to_string(), + event_name, + prefix_topics, + params, + }) +} + +/// Extract parameters from topics +fn extract_topic_params( + spec: &Spec, + topics: &[ScVal], + topic_params: &[&ScSpecEventParamV0], + topic_offset: usize, + params: &mut IndexMap, +) -> Result<(), EventDecodeError> { + for (i, param) in topic_params.iter().enumerate() { + let topic_idx = topic_offset + i; + if topic_idx >= topics.len() { + // Topic count doesn't match spec - likely a spec mismatch + return Err(EventDecodeError::TopicCountMismatch { + expected: topic_offset + topic_params.len(), + actual: topics.len(), + }); + } + + let param_name = param.name.to_utf8_string_lossy(); + let topic_value = &topics[topic_idx]; + + let json_value = spec.xdr_to_json(topic_value, ¶m.type_).map_err(|e| { + EventDecodeError::ParamDecodeError { + name: param_name.clone(), + source: e, + } + })?; + + params.insert(param_name, json_value); + } + + Ok(()) +} + +/// Extract parameters from event data based on the data format +fn extract_data_params( + spec: &Spec, + data: &ScVal, + data_params: &[&ScSpecEventParamV0], + data_format: ScSpecEventDataFormat, + params: &mut IndexMap, +) -> Result<(), EventDecodeError> { + if data_params.is_empty() { + return Ok(()); + } + + match data_format { + ScSpecEventDataFormat::SingleValue => { + // Single value - should have exactly one data param + if let Some(param) = data_params.first() { + let param_name = param.name.to_utf8_string_lossy(); + let json_value = spec.xdr_to_json(data, ¶m.type_).map_err(|e| { + EventDecodeError::ParamDecodeError { + name: param_name.clone(), + source: e, + } + })?; + params.insert(param_name, json_value); + } + } + ScSpecEventDataFormat::Vec => { + // Vec format - data should be a Vec with elements matching params + if let ScVal::Vec(Some(vec)) = data { + for (i, param) in data_params.iter().enumerate() { + if i >= vec.len() { + break; + } + let param_name = param.name.to_utf8_string_lossy(); + let json_value = spec.xdr_to_json(&vec[i], ¶m.type_).map_err(|e| { + EventDecodeError::ParamDecodeError { + name: param_name.clone(), + source: e, + } + })?; + params.insert(param_name, json_value); + } + } else { + return Err(EventDecodeError::InvalidDataFormat); + } + } + ScSpecEventDataFormat::Map => { + // Map format - data should be a Map with keys matching param names + if let ScVal::Map(Some(map)) = data { + for param in data_params { + let param_name = param.name.to_utf8_string_lossy(); + // Find the map entry with matching key + if let Some(entry) = map.iter().find(|entry| { + if let ScVal::Symbol(sym) = &entry.key { + sym.to_utf8_string_lossy() == param_name + } else { + false + } + }) { + let json_value = + spec.xdr_to_json(&entry.val, ¶m.type_).map_err(|e| { + EventDecodeError::ParamDecodeError { + name: param_name.clone(), + source: e, + } + })?; + params.insert(param_name, json_value); + } + } + } else { + return Err(EventDecodeError::InvalidDataFormat); + } + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use stellar_xdr::curr::{ + Int128Parts, ScMap, ScMapEntry, ScSpecEntry, ScSpecTypeDef, ScString, ScVec, StringM, VecM, + }; + + fn make_symbol(s: &str) -> ScSymbol { + ScSymbol(s.try_into().unwrap()) + } + + fn make_sc_symbol(s: &str) -> ScVal { + ScVal::Symbol(make_symbol(s)) + } + + fn make_i128(val: i128) -> ScVal { + let bytes = val.to_be_bytes(); + let (hi, lo) = bytes.split_at(8); + ScVal::I128(Int128Parts { + hi: i64::from_be_bytes(hi.try_into().unwrap()), + lo: u64::from_be_bytes(lo.try_into().unwrap()), + }) + } + + fn make_event_param( + name: &str, + type_: ScSpecTypeDef, + location: ScSpecEventParamLocationV0, + ) -> ScSpecEventParamV0 { + ScSpecEventParamV0 { + doc: StringM::default(), + name: name.try_into().unwrap(), + type_, + location, + } + } + + fn make_event_spec( + name: &str, + prefix_topics: Vec<&str>, + params: Vec, + data_format: ScSpecEventDataFormat, + ) -> ScSpecEventV0 { + ScSpecEventV0 { + doc: StringM::default(), + lib: StringM::default(), + name: make_symbol(name), + prefix_topics: prefix_topics + .into_iter() + .map(make_symbol) + .collect::>() + .try_into() + .unwrap(), + params: params.try_into().unwrap(), + data_format, + } + } + + fn make_spec_with_events(events: Vec) -> Spec { + let entries: Vec = events.into_iter().map(ScSpecEntry::EventV0).collect(); + Spec::new(&entries) + } + + #[test] + fn test_matches_prefix_topics_empty_prefix() { + let prefix: VecM = VecM::default(); + let topics = vec![make_sc_symbol("transfer")]; + assert!(matches_prefix_topics(&prefix, &topics)); + } + + #[test] + fn test_matches_prefix_topics_single_match() { + let prefix: VecM = vec![make_symbol("transfer")].try_into().unwrap(); + let topics = vec![make_sc_symbol("transfer"), make_sc_symbol("from")]; + assert!(matches_prefix_topics(&prefix, &topics)); + } + + #[test] + fn test_matches_prefix_topics_two_prefixes_match() { + let prefix: VecM = vec![make_symbol("token"), make_symbol("transfer")] + .try_into() + .unwrap(); + let topics = vec![ + make_sc_symbol("token"), + make_sc_symbol("transfer"), + make_sc_symbol("from"), + ]; + assert!(matches_prefix_topics(&prefix, &topics)); + } + + #[test] + fn test_matches_prefix_topics_mismatch() { + let prefix: VecM = vec![make_symbol("approve")].try_into().unwrap(); + let topics = vec![make_sc_symbol("transfer")]; + assert!(!matches_prefix_topics(&prefix, &topics)); + } + + #[test] + fn test_matches_prefix_topics_insufficient_topics() { + let prefix: VecM = + vec![make_symbol("a"), make_symbol("b")].try_into().unwrap(); + let topics = vec![make_sc_symbol("a")]; + assert!(!matches_prefix_topics(&prefix, &topics)); + } + + #[test] + fn test_matches_prefix_topics_non_symbol_topic() { + let prefix: VecM = vec![make_symbol("transfer")].try_into().unwrap(); + let topics = vec![ScVal::U32(123)]; // Not a symbol or string + assert!(!matches_prefix_topics(&prefix, &topics)); + } + + #[test] + fn test_matches_prefix_topics_string_topic() { + // Some early contracts use String instead of Symbol + let prefix: VecM = vec![make_symbol("transfer")].try_into().unwrap(); + let s: StringM = "transfer".try_into().unwrap(); + let topics = vec![ScVal::String(ScString(s))]; + assert!(matches_prefix_topics(&prefix, &topics)); + } + + #[test] + fn test_match_event_to_spec_with_prefix() { + let event = make_event_spec( + "transfer", + vec!["token"], + vec![], + ScSpecEventDataFormat::SingleValue, + ); + let spec = make_spec_with_events(vec![event]); + + let topics = vec![make_sc_symbol("token"), make_sc_symbol("transfer")]; + let matched = spec.match_event_to_spec(&topics); + + assert!(matched.is_some()); + assert_eq!(matched.unwrap().name.to_utf8_string_lossy(), "transfer"); + } + + #[test] + fn test_match_event_to_spec_not_found() { + let event = make_event_spec( + "transfer", + vec!["transfer"], + vec![], + ScSpecEventDataFormat::SingleValue, + ); + let spec = make_spec_with_events(vec![event]); + + let topics = vec![make_sc_symbol("approve")]; + let matched = spec.match_event_to_spec(&topics); + + assert!(matched.is_none()); + } + + #[test] + fn test_match_event_to_spec_multiple_events() { + let transfer_event = make_event_spec( + "transfer", + vec!["transfer"], + vec![], + ScSpecEventDataFormat::SingleValue, + ); + let approve_event = make_event_spec( + "approve", + vec!["approve"], + vec![], + ScSpecEventDataFormat::SingleValue, + ); + let spec = make_spec_with_events(vec![transfer_event, approve_event]); + + let topics = vec![make_sc_symbol("approve")]; + let matched = spec.match_event_to_spec(&topics); + + assert!(matched.is_some()); + assert_eq!(matched.unwrap().name.to_utf8_string_lossy(), "approve"); + } + + #[test] + fn test_decode_event_single_value_data() { + let event = make_event_spec( + "transfer", + vec!["transfer"], + vec![make_event_param( + "amount", + ScSpecTypeDef::I128, + ScSpecEventParamLocationV0::Data, + )], + ScSpecEventDataFormat::SingleValue, + ); + let spec = make_spec_with_events(vec![event]); + + let topics = vec![make_sc_symbol("transfer")]; + let data = make_i128(1000); + + let decoded = spec.decode_event("CABC123", &topics, &data).unwrap(); + + assert_eq!(decoded.contract_id, "CABC123"); + assert_eq!(decoded.event_name, "transfer"); + assert_eq!(decoded.params.get("amount"), Some(&json!("1000"))); + } + + #[test] + fn test_decode_event_with_topic_params() { + // Event with prefix_topics identifying the event, plus topic params + let event = make_event_spec( + "transfer", + vec!["transfer"], // prefix_topics identifies this event + vec![ + make_event_param( + "from", + ScSpecTypeDef::Symbol, + ScSpecEventParamLocationV0::TopicList, + ), + make_event_param( + "amount", + ScSpecTypeDef::I128, + ScSpecEventParamLocationV0::Data, + ), + ], + ScSpecEventDataFormat::SingleValue, + ); + let spec = make_spec_with_events(vec![event]); + + // topics[0] = "transfer" (prefix), topics[1] = "alice" (from param) + let topics = vec![make_sc_symbol("transfer"), make_sc_symbol("alice")]; + let data = make_i128(500); + + let decoded = spec.decode_event("CONTRACT", &topics, &data).unwrap(); + + assert_eq!(decoded.event_name, "transfer"); + assert_eq!(decoded.params.get("from"), Some(&json!("alice"))); + assert_eq!(decoded.params.get("amount"), Some(&json!("500"))); + } + + #[test] + fn test_decode_event_vec_data_format() { + let event = make_event_spec( + "multi", + vec!["multi"], + vec![ + make_event_param("a", ScSpecTypeDef::I128, ScSpecEventParamLocationV0::Data), + make_event_param("b", ScSpecTypeDef::I128, ScSpecEventParamLocationV0::Data), + ], + ScSpecEventDataFormat::Vec, + ); + let spec = make_spec_with_events(vec![event]); + + let topics = vec![make_sc_symbol("multi")]; + let data = ScVal::Vec(Some(ScVec( + vec![make_i128(100), make_i128(200)].try_into().unwrap(), + ))); + + let decoded = spec.decode_event("CONTRACT", &topics, &data).unwrap(); + + assert_eq!(decoded.params.get("a"), Some(&json!("100"))); + assert_eq!(decoded.params.get("b"), Some(&json!("200"))); + } + + #[test] + fn test_decode_event_map_data_format() { + let event = make_event_spec( + "info", + vec!["info"], + vec![ + make_event_param( + "name", + ScSpecTypeDef::Symbol, + ScSpecEventParamLocationV0::Data, + ), + make_event_param( + "value", + ScSpecTypeDef::I128, + ScSpecEventParamLocationV0::Data, + ), + ], + ScSpecEventDataFormat::Map, + ); + let spec = make_spec_with_events(vec![event]); + + let topics = vec![make_sc_symbol("info")]; + let data = ScVal::Map(Some(ScMap( + vec![ + ScMapEntry { + key: make_sc_symbol("name"), + val: make_sc_symbol("test"), + }, + ScMapEntry { + key: make_sc_symbol("value"), + val: make_i128(42), + }, + ] + .try_into() + .unwrap(), + ))); + + let decoded = spec.decode_event("CONTRACT", &topics, &data).unwrap(); + + assert_eq!(decoded.params.get("name"), Some(&json!("test"))); + assert_eq!(decoded.params.get("value"), Some(&json!("42"))); + } + + #[test] + fn test_decode_event_no_matching_spec() { + let event = make_event_spec( + "transfer", + vec!["transfer"], + vec![], + ScSpecEventDataFormat::SingleValue, + ); + let spec = make_spec_with_events(vec![event]); + + let topics = vec![make_sc_symbol("unknown")]; + let data = ScVal::Void; + + let result = spec.decode_event("CONTRACT", &topics, &data); + assert!(matches!(result, Err(EventDecodeError::NoMatchingSpec))); + } + + #[test] + fn test_decode_event_invalid_vec_data_format() { + let event = make_event_spec( + "test", + vec!["test"], + vec![make_event_param( + "a", + ScSpecTypeDef::I128, + ScSpecEventParamLocationV0::Data, + )], + ScSpecEventDataFormat::Vec, + ); + let spec = make_spec_with_events(vec![event]); + + let topics = vec![make_sc_symbol("test")]; + let data = make_i128(100); // Should be Vec, not single value + + let result = spec.decode_event("CONTRACT", &topics, &data); + assert!(matches!(result, Err(EventDecodeError::InvalidDataFormat))); + } + + #[test] + fn test_decode_event_invalid_map_data_format() { + let event = make_event_spec( + "test", + vec!["test"], + vec![make_event_param( + "a", + ScSpecTypeDef::I128, + ScSpecEventParamLocationV0::Data, + )], + ScSpecEventDataFormat::Map, + ); + let spec = make_spec_with_events(vec![event]); + + let topics = vec![make_sc_symbol("test")]; + let data = make_i128(100); // Should be Map, not single value + + let result = spec.decode_event("CONTRACT", &topics, &data); + assert!(matches!(result, Err(EventDecodeError::InvalidDataFormat))); + } + + #[test] + fn test_decode_event_empty_spec() { + let spec = make_spec_with_events(vec![]); + + let topics = vec![make_sc_symbol("transfer")]; + let data = ScVal::Void; + + let result = spec.decode_event("CONTRACT", &topics, &data); + assert!(matches!(result, Err(EventDecodeError::NoMatchingSpec))); + } + + #[test] + fn test_decode_event_preserves_param_order() { + let event = make_event_spec( + "ordered", + vec!["ordered"], + vec![ + make_event_param( + "first", + ScSpecTypeDef::I128, + ScSpecEventParamLocationV0::Data, + ), + make_event_param( + "second", + ScSpecTypeDef::I128, + ScSpecEventParamLocationV0::Data, + ), + make_event_param( + "third", + ScSpecTypeDef::I128, + ScSpecEventParamLocationV0::Data, + ), + ], + ScSpecEventDataFormat::Vec, + ); + let spec = make_spec_with_events(vec![event]); + + let topics = vec![make_sc_symbol("ordered")]; + let data = ScVal::Vec(Some(ScVec( + vec![make_i128(1), make_i128(2), make_i128(3)] + .try_into() + .unwrap(), + ))); + + let decoded = spec.decode_event("CONTRACT", &topics, &data).unwrap(); + + // Verify order is preserved using IndexMap + let keys: Vec<_> = decoded.params.keys().collect(); + assert_eq!(keys, vec!["first", "second", "third"]); + } + + #[test] + fn test_decode_event_no_data_params() { + // Event with only topic params, no data params + let event = make_event_spec( + "simple", + vec!["simple"], // prefix_topics identifies this event + vec![make_event_param( + "who", + ScSpecTypeDef::Symbol, + ScSpecEventParamLocationV0::TopicList, + )], + ScSpecEventDataFormat::SingleValue, + ); + let spec = make_spec_with_events(vec![event]); + + // topics[0] = "simple" (prefix), topics[1] = "alice" (who param) + let topics = vec![make_sc_symbol("simple"), make_sc_symbol("alice")]; + let data = ScVal::Void; + + let decoded = spec.decode_event("CONTRACT", &topics, &data).unwrap(); + + assert_eq!(decoded.params.len(), 1); + assert_eq!(decoded.params.get("who"), Some(&json!("alice"))); + } + + #[test] + fn test_decoded_event_json_serialization() { + let mut params = IndexMap::new(); + params.insert("from".to_string(), json!("alice")); + params.insert("to".to_string(), json!("bob")); + params.insert("amount".to_string(), json!("1000")); + + let decoded = DecodedEvent { + contract_id: "CABC123".to_string(), + event_name: "transfer".to_string(), + prefix_topics: vec!["transfer".to_string()], + params, + }; + + let json_str = serde_json::to_string(&decoded).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap(); + + assert_eq!(parsed["contract_id"], "CABC123"); + assert_eq!(parsed["event_name"], "transfer"); + assert_eq!(parsed["prefix_topics"][0], "transfer"); + assert_eq!(parsed["params"]["from"], "alice"); + assert_eq!(parsed["params"]["to"], "bob"); + assert_eq!(parsed["params"]["amount"], "1000"); + } +} diff --git a/cmd/crates/soroban-spec-tools/src/lib.rs b/cmd/crates/soroban-spec-tools/src/lib.rs index e4dd8c659d..79671b8312 100644 --- a/cmd/crates/soroban-spec-tools/src/lib.rs +++ b/cmd/crates/soroban-spec-tools/src/lib.rs @@ -16,6 +16,7 @@ use stellar_xdr::curr::{ }; pub mod contract; +pub mod event; pub mod utils; #[derive(thiserror::Error, Debug)] @@ -222,6 +223,23 @@ impl Spec { Err(Error::MissingErrorCase(value)) } + /// Find all event specs in the contract spec + /// + /// # Errors + /// + /// Returns an error if the spec is missing + pub fn find_events(&self) -> Result, Error> { + Ok(self + .0 + .as_deref() + .ok_or(Error::MissingSpec)? + .iter() + .filter_map(|e| match e { + ScSpecEntry::EventV0(x) => Some(x), + _ => None, + })) + } + /// # Errors /// /// Might return errors @@ -2107,4 +2125,76 @@ mod tests { let scval = from_json_primitives(&json_val, &ScType::I64).unwrap(); assert_eq!(to_json(&scval).unwrap(), json_val); } + + fn make_test_event_spec(name: &str) -> ScSpecEventV0 { + ScSpecEventV0 { + doc: StringM::default(), + lib: StringM::default(), + name: ScSymbol(name.try_into().unwrap()), + prefix_topics: VecM::default(), + params: VecM::default(), + data_format: stellar_xdr::curr::ScSpecEventDataFormat::SingleValue, + } + } + + fn make_spec_with_events_and_functions( + events: Vec, + functions: Vec, + ) -> Spec { + let mut entries: Vec = events.into_iter().map(ScSpecEntry::EventV0).collect(); + entries.extend(functions.into_iter().map(ScSpecEntry::FunctionV0)); + Spec::new(&entries) + } + + fn make_test_function_spec(name: &str) -> ScSpecFunctionV0 { + ScSpecFunctionV0 { + doc: StringM::default(), + name: ScSymbol(name.try_into().unwrap()), + inputs: VecM::default(), + outputs: VecM::default(), + } + } + + #[test] + fn test_find_events_returns_all_events() { + let events = vec![ + make_test_event_spec("transfer"), + make_test_event_spec("approve"), + make_test_event_spec("mint"), + ]; + let spec = make_spec_with_events_and_functions(events, vec![]); + + let found_events: Vec<_> = spec.find_events().unwrap().collect(); + + assert_eq!(found_events.len(), 3); + assert_eq!(found_events[0].name.to_utf8_string_lossy(), "transfer"); + assert_eq!(found_events[1].name.to_utf8_string_lossy(), "approve"); + assert_eq!(found_events[2].name.to_utf8_string_lossy(), "mint"); + } + + #[test] + fn test_find_events_excludes_non_events() { + let events = vec![make_test_event_spec("transfer")]; + let functions = vec![make_test_function_spec("do_transfer")]; + let spec = make_spec_with_events_and_functions(events, functions); + + let found_events: Vec<_> = spec.find_events().unwrap().collect(); + + assert_eq!(found_events.len(), 1); + assert_eq!(found_events[0].name.to_utf8_string_lossy(), "transfer"); + } + + #[test] + fn test_find_events_empty_spec() { + let spec = make_spec_with_events_and_functions(vec![], vec![]); + let found_events: Vec<_> = spec.find_events().unwrap().collect(); + assert!(found_events.is_empty()); + } + + #[test] + fn test_find_events_missing_spec() { + let spec = Spec::default(); + let result = spec.find_events(); + assert!(result.is_err()); + } } diff --git a/cmd/soroban-cli/Cargo.toml b/cmd/soroban-cli/Cargo.toml index c3868c91c6..6fda825856 100644 --- a/cmd/soroban-cli/Cargo.toml +++ b/cmd/soroban-cli/Cargo.toml @@ -87,6 +87,7 @@ wasm-opt = { version = "0.116.1", optional = true } chrono = { version = "0.4.27", features = ["serde"] } rpassword = "7.2.0" toml = { workspace = true } +indexmap = { version = "2.7", features = ["serde"] } itertools = "0.10.5" shlex = "1.1.0" sep5 = { workspace = true } diff --git a/cmd/soroban-cli/src/commands/contract/invoke.rs b/cmd/soroban-cli/src/commands/contract/invoke.rs index 834f37718f..357ef9b732 100644 --- a/cmd/soroban-cli/src/commands/contract/invoke.rs +++ b/cmd/soroban-cli/src/commands/contract/invoke.rs @@ -333,7 +333,10 @@ impl Cmd { let events = sim_res.events()?; crate::log::event::all(&events); - crate::log::event::contract(&events, &print); + // Note: Only events from the invoked contract will be decoded with named parameters. + // Events emitted by other contracts (e.g., token transfers during a swap) will + // fall back to raw format since we only have the spec for the invoked contract. + crate::log::event::contract_with_spec(&events, &print, Some(&spec)); return Ok(output_to_string(&spec, &return_value[0].xdr, &function)?); }; @@ -386,7 +389,10 @@ impl Cmd { let events = extract_events(&res.result_meta.unwrap_or_default()); crate::log::event::all(&events); - crate::log::event::contract(&events, &print); + // Note: Only events from the invoked contract will be decoded with named parameters. + // Events emitted by other contracts (e.g., token transfers during a swap) will + // fall back to raw format since we only have the spec for the invoked contract. + crate::log::event::contract_with_spec(&events, &print, Some(&spec)); Ok(output_to_string(&spec, &return_value, &function)?) } diff --git a/cmd/soroban-cli/src/commands/events.rs b/cmd/soroban-cli/src/commands/events.rs index 630dc476bd..0712747d61 100644 --- a/cmd/soroban-cli/src/commands/events.rs +++ b/cmd/soroban-cli/src/commands/events.rs @@ -1,9 +1,14 @@ use clap::Parser; +use indexmap::IndexMap; +use soroban_spec_tools::event::DecodedEvent; +use soroban_spec_tools::Spec; +use std::collections::HashMap; use std::io; -use crate::xdr::{self, Limits, ReadXdr}; +use crate::xdr::{self, Limits, ReadXdr, ScVal}; use crate::{ config::{self, locator, network}, + get_spec::get_remote_contract_spec, rpc, }; @@ -15,6 +20,7 @@ pub struct Cmd { /// https://developers.stellar.org/docs/learn/encyclopedia/network-configuration/ledger-headers#ledger-sequence #[arg(long, conflicts_with = "cursor", required_unless_present = "cursor")] start_ledger: Option, + /// The cursor corresponding to the start of the event range. #[arg( long, @@ -22,12 +28,15 @@ pub struct Cmd { required_unless_present = "start_ledger" )] cursor: Option, + /// Output formatting options for event stream #[arg(long, value_enum, default_value = "pretty")] output: OutputFormat, + /// The maximum number of events to display (defer to the server-defined limit). #[arg(short, long, default_value = "10")] count: usize, + /// A set of (up to 5) contract IDs to filter events on. This parameter can /// be passed multiple times, e.g. `--id C123.. --id C456..`, or passed with /// multiple parameters, e.g. `--id C123 C456`. @@ -41,6 +50,7 @@ pub struct Cmd { help_heading = "FILTERS" )] contract_ids: Vec, + /// A set of (up to 5) topic filters to filter event topics on. A single /// topic filter can contain 1-4 different segments, separated by /// commas. An asterisk (`*` character) indicates a wildcard segment. @@ -63,6 +73,7 @@ pub struct Cmd { help_heading = "FILTERS" )] topic_filters: Vec, + /// Specifies which type of contract events to display. #[arg( long = "type", @@ -71,8 +82,10 @@ pub struct Cmd { help_heading = "FILTERS" )] event_type: rpc::EventType, + #[command(flatten)] locator: locator::Args, + #[command(flatten)] network: network::Args, } @@ -127,58 +140,287 @@ pub enum Error { Locator(#[from] locator::Error), #[error(transparent)] Config(#[from] config::Error), + #[error(transparent)] + GetSpec(#[from] crate::get_spec::Error), } #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, clap::ValueEnum)] pub enum OutputFormat { - /// Colorful, human-oriented console output + /// Human-readable output with decoded event names and parameters Pretty, - /// Human-oriented console output without colors + + /// Human-readable output without colors Plain, - /// JSON formatted console output + + /// JSON output with decoded event names and parameters Json, + + /// Raw event output without self-describing decoding + Raw, +} + +/// Cache for contract specs, keyed by contract ID +type SpecCache = HashMap>; + +/// Decoded event with metadata for JSON output. +/// +/// This is intentionally a different schema from the raw `rpc::Event` format, +/// focused on providing decoded event data with named parameters. Key differences: +/// - `event_name`: The decoded event name from the contract spec (e.g., "Transfer") +/// - `params`: Named parameters decoded using the contract spec +/// +/// For the raw event format with all original fields (topics, value as base64 XDR), +/// use `--output raw`. +#[derive(serde::Serialize, Debug)] +struct DecodedEventWithMetadata { + id: String, + ledger: u32, + ledger_closed_at: String, + #[serde(rename = "type")] + event_type: String, + contract_id: String, + event_name: String, + prefix_topics: Vec, + params: IndexMap, } impl Cmd { pub async fn run(&mut self) -> Result<(), Error> { - let response = self - .execute(&config::Args { - locator: self.locator.clone(), - network: self.network.clone(), - source_account: config::UnresolvedMuxedAccount::default(), - sign_with: config::sign_with::Args::default(), - fee: None, - inclusion_fee: None, - }) - .await?; + let config = config::Args { + locator: self.locator.clone(), + network: self.network.clone(), + source_account: config::UnresolvedMuxedAccount::default(), + sign_with: config::sign_with::Args::default(), + fee: None, + inclusion_fee: None, + }; + let response = self.execute(&config).await?; if response.events.is_empty() { eprintln!("No events"); + return Ok(()); } + // Build spec cache for decoded output formats (not raw) + let spec_cache = if self.output == OutputFormat::Raw { + HashMap::new() + } else { + self.build_spec_cache(&response.events, &config).await + }; + for event in &response.events { + let decoded = if self.output == OutputFormat::Raw { + None + } else { + Self::try_decode_event(event, &spec_cache) + }; + match self.output { - // Should we pretty-print the JSON like we're doing here or just - // dump an event in raw JSON on each line? The latter is easier - // to consume programmatically. + OutputFormat::Pretty => { + if let Some(decoded) = decoded { + Self::print_decoded_event(&decoded, event, true)?; + } else { + event.pretty_print()?; + } + } + OutputFormat::Plain => { + if let Some(decoded) = decoded { + Self::print_decoded_event(&decoded, event, false)?; + } else { + println!("{event}"); + } + } OutputFormat::Json => { - println!( - "{}", - serde_json::to_string_pretty(&event).map_err(|e| { - Error::InvalidJson { - debug: format!("{event:#?}"), - error: e, - } - })?, - ); + // Single-line JSON (NDJSON) for streaming processing + if let Some(decoded) = decoded { + let with_metadata = DecodedEventWithMetadata { + id: event.id.clone(), + ledger: event.ledger, + ledger_closed_at: event.ledger_closed_at.clone(), + event_type: event.event_type.clone(), + contract_id: decoded.contract_id.clone(), + event_name: decoded.event_name.clone(), + prefix_topics: decoded.prefix_topics.clone(), + params: decoded.params.clone(), + }; + println!( + "{}", + serde_json::to_string(&with_metadata).map_err(|e| { + Error::InvalidJson { + debug: format!("{with_metadata:#?}"), + error: e, + } + })? + ); + } else { + println!( + "{}", + serde_json::to_string(&event).map_err(|e| { + Error::InvalidJson { + debug: format!("{event:#?}"), + error: e, + } + })? + ); + } + } + OutputFormat::Raw => { + event.pretty_print()?; } - OutputFormat::Plain => println!("{event}"), - OutputFormat::Pretty => event.pretty_print()?, } } Ok(()) } + /// Build a cache of contract specs for the unique contract IDs in the events + async fn build_spec_cache(&self, events: &[rpc::Event], config: &config::Args) -> SpecCache { + // Collect unique contract IDs + let unique_ids: Vec<_> = events + .iter() + .map(|e| e.contract_id.clone()) + .collect::>() + .into_iter() + .collect(); + + // Fetch specs concurrently + let fetch_futures: Vec<_> = unique_ids + .iter() + .map(|id| Self::fetch_spec_for_contract(id, config)) + .collect(); + + let results = futures::future::join_all(fetch_futures).await; + + unique_ids.into_iter().zip(results).collect() + } + + /// Fetch the spec for a single contract, returning None on failure + async fn fetch_spec_for_contract(contract_id_str: &str, config: &config::Args) -> Option { + // Parse contract ID from string + let contract_id = match stellar_strkey::Contract::from_string(contract_id_str) { + Ok(id) => id, + Err(e) => { + tracing::debug!("Failed to parse contract ID {contract_id_str}: {e}"); + return None; + } + }; + + match get_remote_contract_spec( + &contract_id.0, + &config.locator, + &config.network, + None, + Some(config), + ) + .await + { + Ok(spec_entries) => Some(Spec::new(&spec_entries)), + Err(e) => { + tracing::debug!( + "Failed to fetch spec for contract {contract_id_str}: {e}. Events from this contract will use raw format." + ); + None + } + } + } + + /// Try to decode an event using the spec cache + fn try_decode_event(event: &rpc::Event, spec_cache: &SpecCache) -> Option { + let spec = spec_cache.get(&event.contract_id)?.as_ref()?; + + // Decode topics from base64 XDR + let topics: Vec = event + .topic + .iter() + .filter_map(|t| ScVal::from_xdr_base64(t, Limits::none()).ok()) + .collect(); + + if topics.len() != event.topic.len() { + return None; // Failed to decode some topics + } + + // Decode value from base64 XDR + let data = ScVal::from_xdr_base64(&event.value, Limits::none()).ok()?; + + spec.decode_event(&event.contract_id, &topics, &data) + .inspect_err(|e| tracing::debug!("Failed to decode event {}: {e}", event.id)) + .ok() + } + + /// Print a decoded event (with colors if use_colors is true, auto-detected for Pretty) + fn print_decoded_event( + decoded: &DecodedEvent, + event: &rpc::Event, + use_colors: bool, + ) -> Result<(), Error> { + use std::io::Write; + use termcolor::{Color, ColorChoice, ColorSpec, StandardStream, WriteColor}; + + let color_choice = if use_colors { + ColorChoice::Auto + } else { + ColorChoice::Never + }; + let mut stdout = StandardStream::stdout(color_choice); + + // Event header + stdout.set_color(ColorSpec::new().set_fg(Some(Color::Cyan)).set_bold(true))?; + write!(stdout, "Event")?; + stdout.reset()?; + writeln!( + stdout, + " {} [{}]:", + event.id, + event.event_type.to_uppercase() + )?; + + // Ledger info + stdout.set_color(ColorSpec::new().set_fg(Some(Color::White)).set_dimmed(true))?; + write!(stdout, " Ledger: ")?; + stdout.reset()?; + writeln!( + stdout, + "{} (closed at {})", + event.ledger, event.ledger_closed_at + )?; + + // Contract + stdout.set_color(ColorSpec::new().set_fg(Some(Color::White)).set_dimmed(true))?; + write!(stdout, " Contract: ")?; + stdout.reset()?; + writeln!(stdout, "{}", decoded.contract_id)?; + + // Event name with prefix topics + stdout.set_color(ColorSpec::new().set_fg(Some(Color::White)).set_dimmed(true))?; + write!(stdout, " Event: ")?; + stdout.reset()?; + stdout.set_color(ColorSpec::new().set_fg(Some(Color::Green)).set_bold(true))?; + write!(stdout, "{}", decoded.event_name)?; + stdout.reset()?; + if !decoded.prefix_topics.is_empty() { + write!(stdout, " ({})", decoded.prefix_topics.join(", "))?; + } + writeln!(stdout)?; + + // Params + if !decoded.params.is_empty() { + stdout.set_color(ColorSpec::new().set_fg(Some(Color::White)).set_dimmed(true))?; + writeln!(stdout, " Params:")?; + stdout.reset()?; + for (name, value) in &decoded.params { + stdout.set_color(ColorSpec::new().set_fg(Some(Color::Yellow)))?; + write!(stdout, " {name}")?; + stdout.reset()?; + write!(stdout, ": ")?; + stdout.set_color(ColorSpec::new().set_fg(Some(Color::White)))?; + writeln!(stdout, "{value}")?; + stdout.reset()?; + } + } + + writeln!(stdout)?; + Ok(()) + } + pub async fn execute(&self, config: &config::Args) -> Result { let start = self.start()?; let network = config.get_network()?; diff --git a/cmd/soroban-cli/src/log/event.rs b/cmd/soroban-cli/src/log/event.rs index d3bc195f02..96e8a3f4ea 100644 --- a/cmd/soroban-cli/src/log/event.rs +++ b/cmd/soroban-cli/src/log/event.rs @@ -1,3 +1,4 @@ +use soroban_spec_tools::Spec; use tracing::debug; use crate::{print::Print, xdr}; @@ -14,6 +15,17 @@ pub fn all(events: &[DiagnosticEvent]) { } pub fn contract(events: &[DiagnosticEvent], print: &Print) { + contract_with_spec(events, print, None); +} + +/// Display contract events with self-describing format if spec is available +/// +/// When a spec is provided, attempts to decode events using the spec to produce +/// human-readable output with named parameters. Falls back to raw format when: +/// - No spec is provided +/// - Event doesn't match any spec +/// - Decode fails +pub fn contract_with_spec(events: &[DiagnosticEvent], print: &Print, spec: Option<&Spec>) { for event in events.iter().cloned() { match event { DiagnosticEvent { @@ -27,16 +39,55 @@ pub fn contract(events: &[DiagnosticEvent], print: &Print) { in_successful_contract_call, .. } => { - let topics = serde_json::to_string(&topics).unwrap(); - let data = serde_json::to_string(&data).unwrap(); let status = if in_successful_contract_call { "Success" } else { "Failure" }; + // Try to decode with spec if available + if let Some(spec) = spec { + let contract_id_str = contract_id.to_string(); + match spec.decode_event(&contract_id_str, &topics, &data) { + Ok(decoded) => { + let params_str = decoded + .params + .iter() + .map(|(k, v)| format!("{k}: {v}")) + .collect::>() + .join(", "); + + let prefix_str = if decoded.prefix_topics.is_empty() { + String::new() + } else { + format!(" ({})", decoded.prefix_topics.join(", ")) + }; + let output = format!( + "{contract_id} - {status} - Event: {}{prefix_str}, {params_str}", + decoded.event_name + ) + .trim_end_matches([',', ' ']) + .to_string(); + + print.eventln(output); + continue; + } + Err(e) => { + // Event doesn't match the provided spec (likely from a different contract) + debug!( + "Event from {contract_id} not decoded: {e}. \ + This may be a cross-contract event (e.g., token transfer) \ + for which we don't have the spec." + ); + } + } + } + + // Fallback to raw format + let topics_json = serde_json::to_string(&topics).unwrap(); + let data_json = serde_json::to_string(&data).unwrap(); print.eventln(format!( - "{contract_id} - {status} - Event: {topics} = {data}" + "{contract_id} - {status} - Event: {topics_json} = {data_json}" )); }