Skip to main content

soroban_cli/commands/
events.rs

1use clap::Parser;
2use std::io;
3
4use crate::xdr::{self, Limits, ReadXdr};
5use crate::{
6    config::{self, locator, network},
7    rpc,
8};
9
10#[derive(Parser, Debug, Clone)]
11#[group(skip)]
12pub struct Cmd {
13    #[allow(clippy::doc_markdown)]
14    /// The first ledger sequence number in the range to pull events
15    /// https://developers.stellar.org/docs/learn/encyclopedia/network-configuration/ledger-headers#ledger-sequence
16    #[arg(long, conflicts_with = "cursor", required_unless_present = "cursor")]
17    start_ledger: Option<u32>,
18    /// The cursor corresponding to the start of the event range.
19    #[arg(
20        long,
21        conflicts_with = "start_ledger",
22        required_unless_present = "start_ledger"
23    )]
24    cursor: Option<String>,
25    /// Output formatting options for event stream
26    #[arg(long, value_enum, default_value = "pretty")]
27    output: OutputFormat,
28    /// The maximum number of events to display (defer to the server-defined limit).
29    #[arg(short, long, default_value = "10")]
30    count: usize,
31    /// A set of (up to 5) contract IDs to filter events on. This parameter can
32    /// be passed multiple times, e.g. `--id C123.. --id C456..`, or passed with
33    /// multiple parameters, e.g. `--id C123 C456`.
34    ///
35    /// Though the specification supports multiple filter objects (i.e.
36    /// combinations of type, IDs, and topics), only one set can be specified on
37    /// the command-line today, though that set can have multiple IDs/topics.
38    #[arg(
39        long = "id",
40        num_args = 1..=6,
41        help_heading = "FILTERS"
42    )]
43    contract_ids: Vec<config::UnresolvedContract>,
44    /// A set of (up to 5) topic filters to filter event topics on. A single
45    /// topic filter can contain 1-4 different segments, separated by
46    /// commas. An asterisk (`*` character) indicates a wildcard segment.
47    ///
48    /// 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.
49    ///
50    /// If the "**" wildcard is not included, only events with the exact number of topics as the given filter will be returned.
51    ///
52    /// **Example:** topic filter with two segments: `--topic "AAAABQAAAAdDT1VOVEVSAA==,*"`
53    ///
54    /// **Example:** two topic filters with one and two segments each: `--topic "AAAABQAAAAdDT1VOVEVSAA==" --topic '*,*'`
55    ///
56    /// **Example:** topic filter with four segments and the "**" wildcard: --topic "AAAABQAAAAdDT1VOVEVSAA==,*,*,*,**"
57    ///
58    /// Note that all of these topic filters are combined with the contract IDs
59    /// into a single filter (i.e. combination of type, IDs, and topics).
60    #[arg(
61        long = "topic",
62        num_args = 1.., // allowing 1+ arguments here, and doing additional validation in parse_topics
63        help_heading = "FILTERS"
64    )]
65    topic_filters: Vec<String>,
66    /// Specifies which type of contract events to display.
67    #[arg(
68        long = "type",
69        value_enum,
70        default_value = "all",
71        help_heading = "FILTERS"
72    )]
73    event_type: rpc::EventType,
74    #[command(flatten)]
75    locator: locator::Args,
76    #[command(flatten)]
77    network: network::Args,
78}
79
80#[derive(thiserror::Error, Debug)]
81pub enum Error {
82    #[error("cursor is not valid")]
83    InvalidCursor,
84    #[error("filepath does not exist: {path}")]
85    InvalidFile { path: String },
86    #[error("filepath ({path}) cannot be read: {error}")]
87    CannotReadFile { path: String, error: String },
88    #[error("max of 5 topic filters allowed per request, received {filter_count}")]
89    MaxTopicFilters { filter_count: usize },
90    #[error("cannot parse topic filter {topic} into 1-4 segments")]
91    InvalidTopicFilter { topic: String },
92    #[error("invalid segment ({segment}) in topic filter ({topic}): {error}")]
93    InvalidSegment {
94        topic: String,
95        segment: String,
96        error: xdr::Error,
97    },
98    #[error("cannot parse contract ID {contract_id}: {error}")]
99    InvalidContractId {
100        contract_id: String,
101        error: stellar_strkey::DecodeError,
102    },
103    #[error("invalid JSON string: {error} ({debug})")]
104    InvalidJson {
105        debug: String,
106        error: serde_json::Error,
107    },
108    #[error("invalid timestamp in event: {ts}")]
109    InvalidTimestamp { ts: String },
110    #[error("missing start_ledger and cursor")]
111    MissingStartLedgerAndCursor,
112    #[error("missing target")]
113    MissingTarget,
114    #[error(transparent)]
115    Rpc(#[from] rpc::Error),
116    #[error(transparent)]
117    Generic(#[from] Box<dyn std::error::Error>),
118    #[error(transparent)]
119    Io(#[from] io::Error),
120    #[error(transparent)]
121    Xdr(#[from] xdr::Error),
122    #[error(transparent)]
123    Serde(#[from] serde_json::Error),
124    #[error(transparent)]
125    Network(#[from] network::Error),
126    #[error(transparent)]
127    Locator(#[from] locator::Error),
128    #[error(transparent)]
129    Config(#[from] config::Error),
130}
131
132#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, clap::ValueEnum)]
133pub enum OutputFormat {
134    /// Colorful, human-oriented console output
135    Pretty,
136    /// Human-oriented console output without colors
137    Plain,
138    /// JSON formatted console output
139    Json,
140}
141
142impl Cmd {
143    pub async fn run(&mut self) -> Result<(), Error> {
144        let response = self
145            .execute(&config::Args {
146                locator: self.locator.clone(),
147                network: self.network.clone(),
148                source_account: config::UnresolvedMuxedAccount::default(),
149                sign_with: config::sign_with::Args::default(),
150                fee: None,
151                inclusion_fee: None,
152            })
153            .await?;
154
155        if response.events.is_empty() {
156            eprintln!("No events");
157        }
158
159        for event in &response.events {
160            match self.output {
161                // Should we pretty-print the JSON like we're doing here or just
162                // dump an event in raw JSON on each line? The latter is easier
163                // to consume programmatically.
164                OutputFormat::Json => {
165                    println!(
166                        "{}",
167                        serde_json::to_string_pretty(&event).map_err(|e| {
168                            Error::InvalidJson {
169                                debug: format!("{event:#?}"),
170                                error: e,
171                            }
172                        })?,
173                    );
174                }
175                OutputFormat::Plain => println!("{event}"),
176                OutputFormat::Pretty => event.pretty_print()?,
177            }
178        }
179        Ok(())
180    }
181
182    pub async fn execute(&self, config: &config::Args) -> Result<rpc::GetEventsResponse, Error> {
183        let start = self.start()?;
184        let network = config.get_network()?;
185        let client = network.rpc_client()?;
186        client
187            .verify_network_passphrase(Some(&network.network_passphrase))
188            .await?;
189
190        let contract_ids: Vec<String> = self
191            .contract_ids
192            .iter()
193            .map(|id| {
194                Ok(id
195                    .resolve_contract_id(&self.locator, &network.network_passphrase)?
196                    .to_string())
197            })
198            .collect::<Result<Vec<_>, Error>>()?;
199
200        let parsed_topics = self.parse_topics()?;
201
202        client
203            .get_events(
204                start,
205                Some(self.event_type),
206                &contract_ids,
207                &parsed_topics,
208                Some(self.count),
209            )
210            .await
211            .map_err(Error::Rpc)
212    }
213
214    fn parse_topics(&self) -> Result<Vec<rpc::TopicFilter>, Error> {
215        if self.topic_filters.len() > 5 {
216            return Err(Error::MaxTopicFilters {
217                filter_count: self.topic_filters.len(),
218            });
219        }
220        let mut topic_filters: Vec<rpc::TopicFilter> = Vec::new();
221        for topic in &self.topic_filters {
222            let mut topic_filter: rpc::TopicFilter = Vec::new(); // a topic filter is a collection of segments
223            for (i, segment) in topic.split(',').enumerate() {
224                if i > 4 {
225                    return Err(Error::InvalidTopicFilter {
226                        topic: topic.clone(),
227                    });
228                }
229
230                if segment == "*" || segment == "**" {
231                    topic_filter.push(segment.to_owned());
232                } else {
233                    match xdr::ScVal::from_xdr_base64(segment, Limits::none()) {
234                        Ok(_s) => {
235                            topic_filter.push(segment.to_owned());
236                        }
237                        Err(e) => {
238                            return Err(Error::InvalidSegment {
239                                topic: topic.clone(),
240                                segment: segment.to_string(),
241                                error: e,
242                            });
243                        }
244                    }
245                }
246            }
247            topic_filters.push(topic_filter);
248        }
249
250        Ok(topic_filters)
251    }
252
253    fn start(&self) -> Result<rpc::EventStart, Error> {
254        let start = match (self.start_ledger, self.cursor.clone()) {
255            (Some(start), _) => rpc::EventStart::Ledger(start),
256            (_, Some(c)) => rpc::EventStart::Cursor(c),
257            // should never happen because of required_unless_present flags
258            _ => return Err(Error::MissingStartLedgerAndCursor),
259        };
260        Ok(start)
261    }
262}