Skip to main content

sdkt_rpc/
events.rs

1use serde::{Deserialize, Serialize};
2
3use crate::client::SorobanRpcClient;
4use crate::error::RpcError;
5
6#[derive(Debug, Serialize, Deserialize)]
7pub struct ContractEvent {
8    pub contract_id: String,
9    pub ledger: Option<u32>,
10    pub topics: Vec<String>,
11    pub value: Option<String>,
12}
13
14/// One RPC topic filter: a list of per-position segment matchers, where each
15/// segment is a base64 XDR `ScVal`, [`TOPIC_WILDCARD_ONE`] or (last position
16/// only) [`TOPIC_WILDCARD_REST`].
17pub type TopicFilter = Vec<String>;
18
19/// Segment matcher that matches exactly one topic of any value.
20pub const TOPIC_WILDCARD_ONE: &str = "*";
21/// Trailing segment matcher that matches zero or more remaining topics.
22pub const TOPIC_WILDCARD_REST: &str = "**";
23
24/// A `getEvents` filter. Build it with [`EventFilter::contract`] (and
25/// [`EventFilter::with_topics`]); it is `#[non_exhaustive]` so new RPC filter
26/// fields can be added without breaking downstream struct literals.
27#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
28#[serde(rename_all = "camelCase")]
29#[non_exhaustive]
30pub struct EventFilter {
31    #[serde(rename = "type")]
32    pub filter_type: String,
33    pub contract_ids: Vec<String>,
34    /// Topic matchers; an event matches if any one filter matches. Omitted from
35    /// the request when `None`, so unfiltered queries are unchanged on the wire.
36    #[serde(default, skip_serializing_if = "Option::is_none")]
37    pub topics: Option<Vec<TopicFilter>>,
38}
39
40impl EventFilter {
41    /// Filter on contract events emitted by any of `contract_ids`, with no
42    /// topic matcher.
43    pub fn contract<I, S>(contract_ids: I) -> Self
44    where
45        I: IntoIterator<Item = S>,
46        S: Into<String>,
47    {
48        Self {
49            filter_type: "contract".to_string(),
50            contract_ids: contract_ids.into_iter().map(Into::into).collect(),
51            topics: None,
52        }
53    }
54
55    /// Restrict the filter to events matching any of `topics`.
56    pub fn with_topics(mut self, topics: Vec<TopicFilter>) -> Self {
57        self.topics = Some(topics);
58        self
59    }
60}
61
62/// Topic filter matching events whose first topic (the event name symbol) is
63/// `first_topic_b64`, regardless of how many topics follow it.
64pub fn first_topic_filter(first_topic_b64: impl Into<String>) -> TopicFilter {
65    vec![first_topic_b64.into(), TOPIC_WILDCARD_REST.to_string()]
66}
67
68#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
69#[serde(rename_all = "camelCase")]
70pub struct GetEventsRequest {
71    pub start_ledger: u32,
72    #[serde(skip_serializing_if = "Option::is_none")]
73    pub end_ledger: Option<u32>,
74    pub filters: Vec<EventFilter>,
75}
76
77#[derive(Deserialize)]
78struct GetEventsResponse {
79    events: Vec<RpcEvent>,
80}
81
82#[derive(Deserialize)]
83#[allow(non_snake_case, dead_code)]
84struct RpcEvent {
85    // The Soroban RPC `getEvents` response returns `ledger` as an integer, not a
86    // string (unlike some other RPC fields). Declared as `u32` to match the wire
87    // format; callers in `get_contract_events` map it into `ContractEvent.ledger`
88    // (Option<u32>) directly.
89    ledger: u32,
90    contractId: String,
91    topic: Vec<String>,
92    // The RPC returns `value` as a base64-encoded XDR *string* (not an object with
93    // an `xdr` field), so it maps directly onto `ContractEvent.value: Option<String>`.
94    value: String,
95}
96
97/// Resolve the start and end ledger query range.
98///
99/// When `start_ledger` is omitted:
100/// - If `end_ledger` is provided, `start_ledger` defaults to `end_ledger.saturating_sub(1000).max(1)`.
101/// - If `end_ledger` is also omitted, `start_ledger` defaults to `latest_ledger.saturating_sub(1000).max(1)`.
102///
103/// Returns an error if `start_ledger > end_ledger`.
104pub fn resolve_ledger_range(
105    start_ledger: Option<u32>,
106    end_ledger: Option<u32>,
107    latest_ledger: u32,
108) -> Result<(u32, Option<u32>), RpcError> {
109    let start = match start_ledger {
110        Some(s) => s,
111        None => {
112            if let Some(end) = end_ledger {
113                end.saturating_sub(1000).max(1)
114            } else {
115                latest_ledger.saturating_sub(1000).max(1)
116            }
117        }
118    };
119
120    if let Some(end) = end_ledger {
121        if start > end {
122            return Err(RpcError::Rpc(format!(
123                "Invalid ledger range: start ledger ({start}) cannot be greater than end ledger ({end})"
124            )));
125        }
126    }
127
128    Ok((start, end_ledger))
129}
130
131pub async fn get_contract_events(
132    client: &SorobanRpcClient,
133    contract_id: &str,
134    start_ledger: Option<u32>,
135    end_ledger: Option<u32>,
136) -> Result<Vec<ContractEvent>, RpcError> {
137    get_contract_events_filtered(client, contract_id, start_ledger, end_ledger, None).await
138}
139
140/// Like [`get_contract_events`], but sends `topics` as the server-side topic
141/// matcher of the contract filter so only matching events are returned.
142pub async fn get_contract_events_filtered(
143    client: &SorobanRpcClient,
144    contract_id: &str,
145    start_ledger: Option<u32>,
146    end_ledger: Option<u32>,
147    topics: Option<Vec<TopicFilter>>,
148) -> Result<Vec<ContractEvent>, RpcError> {
149    // Fast rejection if both start and end are provided and inverted, prior to any RPC call.
150    if let (Some(start), Some(end)) = (start_ledger, end_ledger) {
151        if start > end {
152            return Err(RpcError::Rpc(format!(
153                "Invalid ledger range: start ledger ({start}) cannot be greater than end ledger ({end})"
154            )));
155        }
156    }
157
158    // Determine start and end ledger. When both are omitted, query latest ledger
159    // and look back up to 1000 ledgers.
160    let (start_ledger, end_ledger) = if start_ledger.is_none() && end_ledger.is_none() {
161        let latest_ledger_info =
162            client
163                .get_ledger()
164                .await
165                .unwrap_or_else(|_| crate::client::LedgerInfo {
166                    id: "".to_string(),
167                    protocol_version: 0,
168                    sequence: 1000,
169                });
170        resolve_ledger_range(start_ledger, end_ledger, latest_ledger_info.sequence)?
171    } else {
172        resolve_ledger_range(start_ledger, end_ledger, 0)?
173    };
174
175    // The Soroban RPC `getEvents` treats `endLedger` as exclusive. Convert the
176    // user-supplied bound to inclusive by sending end + 1.
177    let request_end_ledger = match end_ledger {
178        Some(end) => Some(end.checked_add(1).ok_or_else(|| {
179            RpcError::Rpc(format!(
180                "End ledger ({end}) exceeds maximum supported ledger sequence"
181            ))
182        })?),
183        None => None,
184    };
185
186    let request_body = GetEventsRequest {
187        start_ledger,
188        end_ledger: request_end_ledger,
189        filters: vec![EventFilter {
190            topics,
191            ..EventFilter::contract([contract_id])
192        }],
193    };
194
195    let result: GetEventsResponse = client.request("getEvents", request_body).await?;
196
197    let mut contract_events = Vec::new();
198
199    for ev in result.events {
200        contract_events.push(ContractEvent {
201            contract_id: ev.contractId,
202            ledger: Some(ev.ledger),
203            topics: ev.topic,
204            value: Some(ev.value),
205        });
206    }
207
208    Ok(contract_events)
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214
215    #[test]
216    fn test_resolve_ledger_range_defaults() {
217        // When both start and end are omitted, look back up to 1000 ledgers from latest
218        let (start, end) = resolve_ledger_range(None, None, 2500).unwrap();
219        assert_eq!(start, 1500);
220        assert_eq!(end, None);
221
222        // When latest ledger is < 1000, start ledger saturates at 1
223        let (start, end) = resolve_ledger_range(None, None, 500).unwrap();
224        assert_eq!(start, 1);
225        assert_eq!(end, None);
226    }
227
228    #[test]
229    fn test_resolve_ledger_range_explicit_range() {
230        let (start, end) = resolve_ledger_range(Some(1000), Some(2000), 5000).unwrap();
231        assert_eq!(start, 1000);
232        assert_eq!(end, Some(2000));
233
234        // Single ledger range is valid
235        let (start, end) = resolve_ledger_range(Some(1000), Some(1000), 5000).unwrap();
236        assert_eq!(start, 1000);
237        assert_eq!(end, Some(1000));
238    }
239
240    #[test]
241    fn test_resolve_ledger_range_start_only() {
242        // start_ledger without end_ledger leaves end_ledger as None (uses latest ledger as upper bound)
243        let (start, end) = resolve_ledger_range(Some(1000), None, 5000).unwrap();
244        assert_eq!(start, 1000);
245        assert_eq!(end, None);
246    }
247
248    #[test]
249    fn test_resolve_ledger_range_end_only() {
250        // end_ledger without start_ledger computes start from end.saturating_sub(1000).max(1)
251        let (start, end) = resolve_ledger_range(None, Some(2500), 5000).unwrap();
252        assert_eq!(start, 1500);
253        assert_eq!(end, Some(2500));
254
255        let (start, end) = resolve_ledger_range(None, Some(500), 5000).unwrap();
256        assert_eq!(start, 1);
257        assert_eq!(end, Some(500));
258    }
259
260    #[test]
261    fn test_resolve_ledger_range_rejects_inverted() {
262        let err = resolve_ledger_range(Some(5000), Some(1000), 10000).unwrap_err();
263        match err {
264            RpcError::Rpc(msg) => {
265                assert!(
266                    msg.contains("start ledger (5000) cannot be greater than end ledger (1000)")
267                );
268            }
269            other => panic!("Unexpected error variant: {other:?}"),
270        }
271    }
272
273    #[test]
274    fn test_resolve_ledger_range_rejects_end_zero() {
275        let err = resolve_ledger_range(None, Some(0), 10000).unwrap_err();
276        match err {
277            RpcError::Rpc(msg) => {
278                assert!(msg.contains("start ledger (1) cannot be greater than end ledger (0)"));
279            }
280            other => panic!("Unexpected error variant: {other:?}"),
281        }
282    }
283
284    #[test]
285    fn test_get_events_request_serialization_with_end_ledger() {
286        let req = GetEventsRequest {
287            start_ledger: 1000,
288            end_ledger: Some(2000),
289            filters: vec![EventFilter {
290                filter_type: "contract".to_string(),
291                contract_ids: vec![
292                    "CA3D5KRYM6CB7OWQ6TWYRR3Z4T7GNZLKERYNZGGA5CWVMOMG2P3T2YYW".to_string()
293                ],
294                topics: None,
295            }],
296        };
297
298        let json = serde_json::to_string(&req).unwrap();
299        let value: serde_json::Value = serde_json::from_str(&json).unwrap();
300
301        assert_eq!(value["startLedger"], 1000);
302        assert_eq!(value["endLedger"], 2000);
303        assert_eq!(value["filters"][0]["type"], "contract");
304        assert_eq!(
305            value["filters"][0]["contractIds"][0],
306            "CA3D5KRYM6CB7OWQ6TWYRR3Z4T7GNZLKERYNZGGA5CWVMOMG2P3T2YYW"
307        );
308    }
309
310    #[test]
311    fn test_get_events_request_serialization_omits_none_end_ledger() {
312        let req = GetEventsRequest {
313            start_ledger: 1000,
314            end_ledger: None,
315            filters: vec![EventFilter {
316                filter_type: "contract".to_string(),
317                contract_ids: vec![
318                    "CA3D5KRYM6CB7OWQ6TWYRR3Z4T7GNZLKERYNZGGA5CWVMOMG2P3T2YYW".to_string()
319                ],
320                topics: None,
321            }],
322        };
323
324        let json = serde_json::to_string(&req).unwrap();
325        assert!(json.contains("\"startLedger\":1000"));
326        assert!(!json.contains("endLedger"));
327
328        let value: serde_json::Value = serde_json::from_str(&json).unwrap();
329        assert_eq!(value["startLedger"], 1000);
330        assert!(value.get("endLedger").is_none());
331    }
332
333    #[test]
334    fn test_event_filter_omits_topics_when_absent() {
335        let filter = EventFilter {
336            filter_type: "contract".to_string(),
337            contract_ids: vec![
338                "CA3D5KRYM6CB7OWQ6TWYRR3Z4T7GNZLKERYNZGGA5CWVMOMG2P3T2YYW".to_string()
339            ],
340            topics: None,
341        };
342        // Byte-identical to the pre-`topics` wire format.
343        assert_eq!(
344            serde_json::to_string(&filter).unwrap(),
345            r#"{"type":"contract","contractIds":["CA3D5KRYM6CB7OWQ6TWYRR3Z4T7GNZLKERYNZGGA5CWVMOMG2P3T2YYW"]}"#
346        );
347    }
348
349    #[test]
350    fn test_event_filter_serializes_topics_when_present() {
351        let filter = EventFilter {
352            filter_type: "contract".to_string(),
353            contract_ids: vec![
354                "CA3D5KRYM6CB7OWQ6TWYRR3Z4T7GNZLKERYNZGGA5CWVMOMG2P3T2YYW".to_string()
355            ],
356            topics: Some(vec![first_topic_filter("AAAADwAAAAhUcmFuc2Zlcg==")]),
357        };
358        let value = serde_json::to_value(&filter).unwrap();
359        assert_eq!(
360            value["topics"],
361            serde_json::json!([["AAAADwAAAAhUcmFuc2Zlcg==", "**"]])
362        );
363        assert_eq!(value["type"], "contract");
364
365        // Round-trips, and a filter without `topics` deserializes to `None`.
366        let back: EventFilter = serde_json::from_value(value).unwrap();
367        assert_eq!(back, filter);
368        let legacy: EventFilter =
369            serde_json::from_str(r#"{"type":"contract","contractIds":[]}"#).unwrap();
370        assert_eq!(legacy.topics, None);
371    }
372
373    #[test]
374    fn test_event_filter_constructors() {
375        let id = "CA3D5KRYM6CB7OWQ6TWYRR3Z4T7GNZLKERYNZGGA5CWVMOMG2P3T2YYW";
376        let plain = EventFilter::contract([id]);
377        assert_eq!(plain.filter_type, "contract");
378        assert_eq!(plain.contract_ids, vec![id.to_string()]);
379        assert_eq!(plain.topics, None);
380
381        let filtered = EventFilter::contract([id]).with_topics(vec![first_topic_filter("AAAA")]);
382        assert_eq!(
383            filtered.topics,
384            Some(vec![vec!["AAAA".to_string(), "**".to_string()]])
385        );
386    }
387
388    #[test]
389    fn test_first_topic_filter_matches_any_trailing_topics() {
390        assert_eq!(
391            first_topic_filter("AAAA"),
392            vec!["AAAA".to_string(), TOPIC_WILDCARD_REST.to_string()]
393        );
394    }
395
396    #[tokio::test]
397    async fn test_get_contract_events_rejects_inverted_range_before_network() {
398        let client = SorobanRpcClient::new("http://127.0.0.1:9999");
399        let result = get_contract_events(
400            &client,
401            "CA3D5KRYM6CB7OWQ6TWYRR3Z4T7GNZLKERYNZGGA5CWVMOMG2P3T2YYW",
402            Some(5000),
403            Some(1000),
404        )
405        .await;
406
407        let err = result.unwrap_err();
408        match err {
409            RpcError::Rpc(msg) => {
410                assert!(
411                    msg.contains("start ledger (5000) cannot be greater than end ledger (1000)")
412                );
413            }
414            other => panic!("Unexpected error variant: {other:?}"),
415        }
416    }
417
418    #[tokio::test]
419    async fn test_get_contract_events_rejects_u32_max_end_ledger_overflow() {
420        let client = SorobanRpcClient::new("http://127.0.0.1:9999");
421        let result = get_contract_events(
422            &client,
423            "CA3D5KRYM6CB7OWQ6TWYRR3Z4T7GNZLKERYNZGGA5CWVMOMG2P3T2YYW",
424            Some(1000),
425            Some(u32::MAX),
426        )
427        .await;
428
429        let err = result.unwrap_err();
430        match err {
431            RpcError::Rpc(msg) => {
432                assert!(msg.contains("exceeds maximum supported ledger sequence"));
433            }
434            other => panic!("Unexpected error variant: {other:?}"),
435        }
436    }
437}