1use clap::Parser;
2use indexmap::IndexMap;
3use soroban_spec_tools::event::DecodedEvent;
4use soroban_spec_tools::{sanitize, Spec};
5use std::collections::HashMap;
6use std::io;
7
8use crate::xdr::{self, Limits, ReadXdr, ScVal};
9use crate::{
10 config::{self, locator, network},
11 get_spec::get_remote_contract_spec,
12 rpc,
13 utils::XDR_DEPTH_LIMIT,
14};
15
16#[derive(Parser, Debug, Clone)]
17#[group(skip)]
18pub struct Cmd {
19 #[allow(clippy::doc_markdown)]
20 #[arg(long, conflicts_with = "cursor", required_unless_present = "cursor")]
23 start_ledger: Option<u32>,
24
25 #[arg(
27 long,
28 conflicts_with = "start_ledger",
29 required_unless_present = "start_ledger"
30 )]
31 cursor: Option<String>,
32
33 #[arg(long, value_enum, default_value = "pretty")]
35 output: OutputFormat,
36
37 #[arg(short, long, default_value = "10")]
39 count: usize,
40
41 #[arg(
49 long = "id",
50 num_args = 1..=6,
51 help_heading = "FILTERS"
52 )]
53 contract_ids: Vec<config::UnresolvedContract>,
54
55 #[arg(
72 long = "topic",
73 num_args = 1.., help_heading = "FILTERS"
75 )]
76 topic_filters: Vec<String>,
77
78 #[arg(
80 long = "type",
81 value_enum,
82 default_value = "all",
83 help_heading = "FILTERS"
84 )]
85 event_type: rpc::EventType,
86
87 #[command(flatten)]
88 locator: locator::Args,
89
90 #[command(flatten)]
91 network: network::Args,
92}
93
94#[derive(thiserror::Error, Debug)]
95pub enum Error {
96 #[error("cursor is not valid")]
97 InvalidCursor,
98 #[error("filepath does not exist: {path}")]
99 InvalidFile { path: String },
100 #[error("filepath ({path}) cannot be read: {error}")]
101 CannotReadFile { path: String, error: String },
102 #[error("max of 5 topic filters allowed per request, received {filter_count}")]
103 MaxTopicFilters { filter_count: usize },
104 #[error("cannot parse topic filter {topic} into 1-4 segments")]
105 InvalidTopicFilter { topic: String },
106 #[error("invalid segment ({segment}) in topic filter ({topic}): {error}")]
107 InvalidSegment {
108 topic: String,
109 segment: String,
110 error: xdr::Error,
111 },
112 #[error("cannot parse contract ID {contract_id}: {error}")]
113 InvalidContractId {
114 contract_id: String,
115 error: stellar_strkey::DecodeError,
116 },
117 #[error("invalid JSON string: {error} ({debug})")]
118 InvalidJson {
119 debug: String,
120 error: serde_json::Error,
121 },
122 #[error("invalid timestamp in event: {ts}")]
123 InvalidTimestamp { ts: String },
124 #[error("missing start_ledger and cursor")]
125 MissingStartLedgerAndCursor,
126 #[error("missing target")]
127 MissingTarget,
128 #[error(transparent)]
129 Rpc(#[from] rpc::Error),
130 #[error(transparent)]
131 Generic(#[from] Box<dyn std::error::Error>),
132 #[error(transparent)]
133 Io(#[from] io::Error),
134 #[error(transparent)]
135 Xdr(#[from] xdr::Error),
136 #[error(transparent)]
137 Serde(#[from] serde_json::Error),
138 #[error(transparent)]
139 Network(#[from] network::Error),
140 #[error(transparent)]
141 Locator(#[from] locator::Error),
142 #[error(transparent)]
143 Config(#[from] config::Error),
144 #[error(transparent)]
145 GetSpec(#[from] crate::get_spec::Error),
146}
147
148#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, clap::ValueEnum)]
149pub enum OutputFormat {
150 Pretty,
152
153 Plain,
155
156 Json,
158
159 Raw,
161}
162
163type SpecCache = HashMap<String, Option<Spec>>;
165
166#[derive(serde::Serialize, Debug)]
176struct DecodedEventWithMetadata {
177 id: String,
178 ledger: u32,
179 ledger_closed_at: String,
180 #[serde(rename = "type")]
181 event_type: String,
182 contract_id: String,
183 event_name: String,
184 prefix_topics: Vec<String>,
185 params: IndexMap<String, serde_json::Value>,
186}
187
188impl Cmd {
189 pub async fn run(&mut self) -> Result<(), Error> {
190 let config = config::Args {
191 locator: self.locator.clone(),
192 network: self.network.clone(),
193 source_account: config::UnresolvedMuxedAccount::default(),
194 sign_with: config::sign_with::Args::default(),
195 fee: None,
196 inclusion_fee: None,
197 };
198 let response = self.execute(&config).await?;
199
200 if response.events.is_empty() {
201 eprintln!("No events");
202 return Ok(());
203 }
204
205 let spec_cache = if self.output == OutputFormat::Raw {
207 HashMap::new()
208 } else {
209 self.build_spec_cache(&response.events, &config).await
210 };
211
212 for event in &response.events {
213 let decoded = if self.output == OutputFormat::Raw {
214 None
215 } else {
216 Self::try_decode_event(event, &spec_cache)
217 };
218
219 match self.output {
220 OutputFormat::Pretty => {
221 if let Some(decoded) = decoded {
222 Self::print_decoded_event(&decoded, event, true)?;
223 } else {
224 event.pretty_print()?;
225 }
226 }
227 OutputFormat::Plain => {
228 if let Some(decoded) = decoded {
229 Self::print_decoded_event(&decoded, event, false)?;
230 } else {
231 println!("{event}");
232 }
233 }
234 OutputFormat::Json => {
235 if let Some(decoded) = decoded {
237 let with_metadata = DecodedEventWithMetadata {
238 id: event.id.clone(),
239 ledger: event.ledger,
240 ledger_closed_at: event.ledger_closed_at.clone(),
241 event_type: event.event_type.clone(),
242 contract_id: decoded.contract_id.clone(),
243 event_name: decoded.event_name.clone(),
244 prefix_topics: decoded.prefix_topics.clone(),
245 params: decoded.params.clone(),
246 };
247 println!(
248 "{}",
249 serde_json::to_string(&with_metadata).map_err(|e| {
250 Error::InvalidJson {
251 debug: format!("{with_metadata:#?}"),
252 error: e,
253 }
254 })?
255 );
256 } else {
257 println!(
258 "{}",
259 serde_json::to_string(&event).map_err(|e| {
260 Error::InvalidJson {
261 debug: format!("{event:#?}"),
262 error: e,
263 }
264 })?
265 );
266 }
267 }
268 OutputFormat::Raw => {
269 event.pretty_print()?;
270 }
271 }
272 }
273 Ok(())
274 }
275
276 async fn build_spec_cache(&self, events: &[rpc::Event], config: &config::Args) -> SpecCache {
278 let unique_ids: Vec<_> = events
280 .iter()
281 .map(|e| e.contract_id.clone())
282 .collect::<std::collections::HashSet<_>>()
283 .into_iter()
284 .collect();
285
286 let fetch_futures: Vec<_> = unique_ids
288 .iter()
289 .map(|id| Self::fetch_spec_for_contract(id, config))
290 .collect();
291
292 let results = futures::future::join_all(fetch_futures).await;
293
294 unique_ids.into_iter().zip(results).collect()
295 }
296
297 async fn fetch_spec_for_contract(contract_id_str: &str, config: &config::Args) -> Option<Spec> {
299 let contract_id = match stellar_strkey::Contract::from_string(contract_id_str) {
301 Ok(id) => id,
302 Err(e) => {
303 tracing::debug!("Failed to parse contract ID {contract_id_str}: {e}");
304 return None;
305 }
306 };
307
308 match get_remote_contract_spec(
309 &contract_id.0,
310 &config.locator,
311 &config.network,
312 None,
313 Some(config),
314 )
315 .await
316 {
317 Ok(spec_entries) => Some(Spec::new(&spec_entries)),
318 Err(e) => {
319 tracing::debug!(
320 "Failed to fetch spec for contract {contract_id_str}: {e}. Events from this contract will use raw format."
321 );
322 None
323 }
324 }
325 }
326
327 fn try_decode_event(event: &rpc::Event, spec_cache: &SpecCache) -> Option<DecodedEvent> {
329 let spec = spec_cache.get(&event.contract_id)?.as_ref()?;
330
331 let topics: Vec<ScVal> = event
333 .topic
334 .iter()
335 .filter_map(|t| ScVal::from_xdr_base64(t, Limits::depth(XDR_DEPTH_LIMIT)).ok())
336 .collect();
337
338 if topics.len() != event.topic.len() {
339 return None; }
341
342 let data = ScVal::from_xdr_base64(&event.value, Limits::depth(XDR_DEPTH_LIMIT)).ok()?;
344
345 spec.decode_event(&event.contract_id, &topics, &data)
346 .inspect_err(|e| tracing::debug!("Failed to decode event {}: {e}", event.id))
347 .ok()
348 }
349
350 fn print_decoded_event(
352 decoded: &DecodedEvent,
353 event: &rpc::Event,
354 use_colors: bool,
355 ) -> Result<(), Error> {
356 use termcolor::{ColorChoice, StandardStream};
357
358 let color_choice = if use_colors {
359 ColorChoice::Auto
360 } else {
361 ColorChoice::Never
362 };
363 let mut stdout = StandardStream::stdout(color_choice);
364 Self::write_decoded_event(&mut stdout, decoded, event)
365 }
366
367 fn write_decoded_event<W: termcolor::WriteColor>(
368 stdout: &mut W,
369 decoded: &DecodedEvent,
370 event: &rpc::Event,
371 ) -> Result<(), Error> {
372 use termcolor::{Color, ColorSpec};
373
374 stdout.set_color(ColorSpec::new().set_fg(Some(Color::Cyan)).set_bold(true))?;
376 write!(stdout, "Event")?;
377 stdout.reset()?;
378 writeln!(
379 stdout,
380 " {} [{}]:",
381 sanitize(&event.id),
382 sanitize(&event.event_type.to_uppercase())
383 )?;
384
385 stdout.set_color(ColorSpec::new().set_fg(Some(Color::White)).set_dimmed(true))?;
387 write!(stdout, " Ledger: ")?;
388 stdout.reset()?;
389 writeln!(
390 stdout,
391 "{} (closed at {})",
392 event.ledger,
393 sanitize(&event.ledger_closed_at)
394 )?;
395
396 stdout.set_color(ColorSpec::new().set_fg(Some(Color::White)).set_dimmed(true))?;
398 write!(stdout, " Contract: ")?;
399 stdout.reset()?;
400 writeln!(stdout, "{}", sanitize(&decoded.contract_id))?;
401
402 stdout.set_color(ColorSpec::new().set_fg(Some(Color::White)).set_dimmed(true))?;
404 write!(stdout, " Event: ")?;
405 stdout.reset()?;
406 stdout.set_color(ColorSpec::new().set_fg(Some(Color::Green)).set_bold(true))?;
407 write!(stdout, "{}", sanitize(&decoded.event_name))?;
408 stdout.reset()?;
409 if !decoded.prefix_topics.is_empty() {
410 let prefix = decoded
411 .prefix_topics
412 .iter()
413 .map(|t| sanitize(t))
414 .collect::<Vec<_>>()
415 .join(", ");
416 write!(stdout, " ({prefix})")?;
417 }
418 writeln!(stdout)?;
419
420 if !decoded.params.is_empty() {
422 stdout.set_color(ColorSpec::new().set_fg(Some(Color::White)).set_dimmed(true))?;
423 writeln!(stdout, " Params:")?;
424 stdout.reset()?;
425 for (name, value) in &decoded.params {
426 stdout.set_color(ColorSpec::new().set_fg(Some(Color::Yellow)))?;
427 write!(stdout, " {}", sanitize(name))?;
428 stdout.reset()?;
429 write!(stdout, ": ")?;
430 stdout.set_color(ColorSpec::new().set_fg(Some(Color::White)))?;
431 writeln!(stdout, "{value}")?;
432 stdout.reset()?;
433 }
434 }
435
436 writeln!(stdout)?;
437 Ok(())
438 }
439
440 pub async fn execute(&self, config: &config::Args) -> Result<rpc::GetEventsResponse, Error> {
441 let start = self.start()?;
442 let network = config.get_network()?;
443 let client = network.rpc_client()?;
444 client
445 .verify_network_passphrase(Some(&network.network_passphrase))
446 .await?;
447
448 let contract_ids: Vec<String> = self
449 .contract_ids
450 .iter()
451 .map(|id| {
452 Ok(format!(
453 "{}",
454 id.resolve_contract_id(&self.locator, &network.network_passphrase)?
455 ))
456 })
457 .collect::<Result<Vec<_>, Error>>()?;
458
459 let parsed_topics = self.parse_topics()?;
460
461 client
462 .get_events(
463 start,
464 Some(self.event_type),
465 &contract_ids,
466 &parsed_topics,
467 Some(self.count),
468 )
469 .await
470 .map_err(Error::Rpc)
471 }
472
473 fn parse_topics(&self) -> Result<Vec<rpc::TopicFilter>, Error> {
474 if self.topic_filters.len() > 5 {
475 return Err(Error::MaxTopicFilters {
476 filter_count: self.topic_filters.len(),
477 });
478 }
479 let mut topic_filters: Vec<rpc::TopicFilter> = Vec::new();
480 for topic in &self.topic_filters {
481 let mut topic_filter: rpc::TopicFilter = Vec::new(); for (i, segment) in topic.split(',').enumerate() {
483 if i > 4 {
484 return Err(Error::InvalidTopicFilter {
485 topic: topic.clone(),
486 });
487 }
488
489 if segment == "*" || segment == "**" {
490 topic_filter.push(segment.to_owned());
491 } else {
492 match xdr::ScVal::from_xdr_base64(segment, Limits::depth(XDR_DEPTH_LIMIT)) {
493 Ok(_s) => {
494 topic_filter.push(segment.to_owned());
495 }
496 Err(e) => {
497 return Err(Error::InvalidSegment {
498 topic: topic.clone(),
499 segment: segment.to_string(),
500 error: e,
501 });
502 }
503 }
504 }
505 }
506 topic_filters.push(topic_filter);
507 }
508
509 Ok(topic_filters)
510 }
511
512 fn start(&self) -> Result<rpc::EventStart, Error> {
513 let start = match (self.start_ledger, self.cursor.clone()) {
514 (Some(start), _) => rpc::EventStart::Ledger(start),
515 (_, Some(c)) => rpc::EventStart::Cursor(c),
516 _ => return Err(Error::MissingStartLedgerAndCursor),
518 };
519 Ok(start)
520 }
521}
522
523#[cfg(test)]
524mod tests {
525 use super::*;
526 use serde_json::json;
527 use soroban_spec_tools::test_utils::assert_no_control_chars;
528 use termcolor::Buffer;
529
530 fn evil_event() -> rpc::Event {
531 rpc::Event {
532 event_type: "contract\x1b[31m".into(),
533 ledger: 1,
534 ledger_closed_at: "2026-01-01T00:00:00Z\x1b[2J".into(),
535 contract_id: "CACA".into(),
536 id: "0000000001-0000000001\x1b[H".into(),
537 operation_index: None,
538 transaction_index: None,
539 tx_hash: None,
540 #[allow(deprecated)]
541 is_successful_contract_call: None,
542 topic: vec![],
543 value: String::new(),
544 }
545 }
546
547 fn evil_decoded() -> DecodedEvent {
548 let mut params = IndexMap::new();
549 params.insert("amount\x1b[31m".to_string(), json!(1000));
550 DecodedEvent {
551 contract_id: "CACA\x1b[0m".to_string(),
552 event_name: "\x1b[2J\x1b[Htransfer".to_string(),
553 prefix_topics: vec!["\x1b[31mEVIL".into(), "topic2".into()],
554 params,
555 }
556 }
557
558 #[test]
559 fn write_decoded_event_strips_attacker_control_bytes() {
560 let mut buf = Buffer::no_color();
561 Cmd::write_decoded_event(&mut buf, &evil_decoded(), &evil_event()).unwrap();
562 let output = String::from_utf8(buf.into_inner()).unwrap();
563 assert_no_control_chars(&output);
564 }
565}