Skip to main content

soroban_cli/commands/
events.rs

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    /// The first ledger sequence number in the range to pull events
21    /// https://developers.stellar.org/docs/learn/encyclopedia/network-configuration/ledger-headers#ledger-sequence
22    #[arg(long, conflicts_with = "cursor", required_unless_present = "cursor")]
23    start_ledger: Option<u32>,
24
25    /// The cursor corresponding to the start of the event range.
26    #[arg(
27        long,
28        conflicts_with = "start_ledger",
29        required_unless_present = "start_ledger"
30    )]
31    cursor: Option<String>,
32
33    /// Output formatting options for event stream
34    #[arg(long, value_enum, default_value = "pretty")]
35    output: OutputFormat,
36
37    /// The maximum number of events to display (defer to the server-defined limit).
38    #[arg(short, long, default_value = "10")]
39    count: usize,
40
41    /// A set of (up to 5) contract IDs to filter events on. This parameter can
42    /// be passed multiple times, e.g. `--id C123.. --id C456..`, or passed with
43    /// multiple parameters, e.g. `--id C123 C456`.
44    ///
45    /// Though the specification supports multiple filter objects (i.e.
46    /// combinations of type, IDs, and topics), only one set can be specified on
47    /// the command-line today, though that set can have multiple IDs/topics.
48    #[arg(
49        long = "id",
50        num_args = 1..=6,
51        help_heading = "FILTERS"
52    )]
53    contract_ids: Vec<config::UnresolvedContract>,
54
55    /// A set of (up to 5) topic filters to filter event topics on. A single
56    /// topic filter can contain 1-4 different segments, separated by
57    /// commas. An asterisk (`*` character) indicates a wildcard segment.
58    ///
59    /// In addition to up to 4 possible topic filter segments, the "**" wildcard can also be added, and will allow for a flexible number of topics in the returned events. The "**" wildcard must be the last segment in a query.
60    ///
61    /// If the "**" wildcard is not included, only events with the exact number of topics as the given filter will be returned.
62    ///
63    /// **Example:** topic filter with two segments: `--topic "AAAABQAAAAdDT1VOVEVSAA==,*"`
64    ///
65    /// **Example:** two topic filters with one and two segments each: `--topic "AAAABQAAAAdDT1VOVEVSAA==" --topic '*,*'`
66    ///
67    /// **Example:** topic filter with four segments and the "**" wildcard: --topic "AAAABQAAAAdDT1VOVEVSAA==,*,*,*,**"
68    ///
69    /// Note that all of these topic filters are combined with the contract IDs
70    /// into a single filter (i.e. combination of type, IDs, and topics).
71    #[arg(
72        long = "topic",
73        num_args = 1.., // allowing 1+ arguments here, and doing additional validation in parse_topics
74        help_heading = "FILTERS"
75    )]
76    topic_filters: Vec<String>,
77
78    /// Specifies which type of contract events to display.
79    #[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    /// Human-readable output with decoded event names and parameters
151    Pretty,
152
153    /// Human-readable output without colors
154    Plain,
155
156    /// JSON output with decoded event names and parameters
157    Json,
158
159    /// Raw event output without self-describing decoding
160    Raw,
161}
162
163/// Cache for contract specs, keyed by contract ID
164type SpecCache = HashMap<String, Option<Spec>>;
165
166/// Decoded event with metadata for JSON output.
167///
168/// This is intentionally a different schema from the raw `rpc::Event` format,
169/// focused on providing decoded event data with named parameters. Key differences:
170/// - `event_name`: The decoded event name from the contract spec (e.g., "Transfer")
171/// - `params`: Named parameters decoded using the contract spec
172///
173/// For the raw event format with all original fields (topics, value as base64 XDR),
174/// use `--output raw`.
175#[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        // Build spec cache for decoded output formats (not raw)
206        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                    // Single-line JSON (NDJSON) for streaming processing
236                    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    /// Build a cache of contract specs for the unique contract IDs in the events
277    async fn build_spec_cache(&self, events: &[rpc::Event], config: &config::Args) -> SpecCache {
278        // Collect unique contract IDs
279        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        // Fetch specs concurrently
287        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    /// Fetch the spec for a single contract, returning None on failure
298    async fn fetch_spec_for_contract(contract_id_str: &str, config: &config::Args) -> Option<Spec> {
299        // Parse contract ID from string
300        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    /// Try to decode an event using the spec cache
328    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        // Decode topics from base64 XDR
332        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; // Failed to decode some topics
340        }
341
342        // Decode value from base64 XDR
343        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    /// Print a decoded event (with colors if use_colors is true, auto-detected for Pretty)
351    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        // Event header
375        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        // Ledger info
386        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        // Contract
397        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        // Event name with prefix topics
403        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        // Params
421        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(); // a topic filter is a collection of segments
482            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            // should never happen because of required_unless_present flags
517            _ => 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}