Skip to main content

sui_rpc/light_client/events/
client.rs

1//! `AuthenticatedEventsClient` — the public surface for streaming
2//! authenticated events.
3
4use std::time::Duration;
5
6use futures::Stream;
7use futures::StreamExt;
8use sui_sdk_types::framework::EventStreamHead;
9use sui_sdk_types::framework::derive_event_stream_head_object_id;
10use tokio::sync::mpsc;
11
12use super::config::AuthenticatedEventsConfig;
13use super::envelope::AuthenticatedEvent;
14use super::state::StreamState;
15use super::state::buffer_response_batch;
16use super::state::extract_event_stream_head;
17use super::state::fold_and_reconcile;
18use crate::light_client::CheckpointObjectProof;
19use crate::light_client::LightClient;
20use crate::light_client::error::LightClientError;
21use crate::proto::sui::rpc::v2::EventFilter;
22use crate::proto::sui::rpc::v2::ListEventsRequest;
23use crate::proto::sui::rpc::v2::ListTransactionsRequest;
24use crate::proto::sui::rpc::v2::QueryEndReason;
25use crate::proto::sui::rpc::v2::QueryOptions;
26use crate::proto::sui::rpc::v2::TransactionFilter;
27use crate::proto::sui::rpc::v2::filter::event;
28use crate::proto::sui::rpc::v2::filter::transaction;
29
30/// A streaming verifier for a single authenticated event stream.
31///
32/// Construct with [`AuthenticatedEventsClient::new`], passing a
33/// configured [`LightClient`] and an [`AuthenticatedEventsConfig`].
34/// Call [`Self::stream`] to spawn the background verifier task and
35/// receive an async stream of cryptographically-authenticated events.
36///
37/// Once [`Self::stream`] is called the client is consumed. The
38/// returned stream pulls events from the spawned task via a bounded
39/// channel; events are yielded only after the periodic reconciliation
40/// confirms the local MMR matches the on-chain
41/// [`EventStreamHead`](sui_sdk_types::framework::EventStreamHead).
42pub struct AuthenticatedEventsClient {
43    light: LightClient,
44    config: AuthenticatedEventsConfig,
45}
46
47impl AuthenticatedEventsClient {
48    /// Construct a new streaming client.
49    pub fn new(light: LightClient, config: AuthenticatedEventsConfig) -> Self {
50        Self { light, config }
51    }
52
53    /// Spawn the verifier task and return a stream of authenticated
54    /// events.
55    ///
56    /// The stream terminates when the spawned task exits — either
57    /// because the consumer dropped the receiver, or because the task
58    /// hit an unrecoverable error (the error is yielded as the last
59    /// item before termination).
60    pub fn stream(
61        self,
62    ) -> impl Stream<Item = Result<AuthenticatedEvent, LightClientError>> + Send + 'static {
63        let (tx, rx) = mpsc::channel(self.config.channel_capacity);
64        tokio::spawn(run_stream_task(self.light, self.config, tx));
65        futures::stream::unfold(rx, |mut rx| async move { rx.recv().await.map(|v| (v, rx)) })
66    }
67}
68
69/// Driver loop for a single stream subscription.
70///
71/// On startup: fetch the latest checkpoint as the race-free floor,
72/// then fetch the on-chain stream head. If the head exists, resume
73/// from `head.checkpoint + 1`; otherwise start from
74/// `latest_checkpoint + 1`. Either way the floor ensures no event can
75/// land in the interval without being picked up by the head check.
76///
77/// Steady state: page through `ListEvents` and buffer the items, then at
78/// each [`AuthenticatedEventsConfig::head_check_interval`] tick fetch
79/// the settlement boundaries for the unconfirmed range via
80/// `ListTransactions(affected_object = event_stream_head)`, fold the
81/// buffered events into the local MMR partitioned by settlement, and
82/// reconcile the resulting head against the on-chain head proven at the
83/// last settled checkpoint. On match, drain the confirmed events
84/// through the channel. On mismatch, send the error and exit.
85async fn run_stream_task(
86    mut light: LightClient,
87    config: AuthenticatedEventsConfig,
88    tx: mpsc::Sender<Result<AuthenticatedEvent, LightClientError>>,
89) {
90    // The two startup helpers can fail; route the error through the
91    // channel and exit cleanly so the consumer sees one final item.
92    let start = match initial_state(&mut light, &config).await {
93        Ok(start) => start,
94        Err(e) => {
95            let _ = tx.send(Err(e)).await;
96            return;
97        }
98    };
99
100    let mut state = StreamState::new(start.initial_head, start.start_checkpoint);
101    let mut next_checkpoint = start
102        .start_checkpoint
103        .checked_add(1)
104        .unwrap_or(start.start_checkpoint);
105    let mut next_cursor: Option<prost::bytes::Bytes> = None;
106    let mut consecutive_failures = 0u32;
107    let mut last_head_check = std::time::Instant::now();
108
109    let stream_head_object_id = derive_event_stream_head_object_id(config.stream_id);
110    let filter = build_filter(config.stream_id);
111
112    loop {
113        // Decide whether to fetch the next page or reconcile.
114        //
115        // The "idle" condition (non-empty buffer, no cursor, drained
116        // through the next checkpoint) lets us reconcile as soon as
117        // we've drawn level with the indexed tip rather than waiting
118        // out the full interval.
119        let should_reconcile = last_head_check.elapsed() >= config.head_check_interval
120            || !state.buffer.is_empty()
121                && next_cursor.is_none()
122                && page_drain_done(&state, next_checkpoint);
123
124        if should_reconcile {
125            match reconcile_once(&mut light, &mut state, &stream_head_object_id, &config).await {
126                Ok(released) => {
127                    consecutive_failures = 0;
128                    last_head_check = std::time::Instant::now();
129                    for ev in released {
130                        if tx.send(Ok(ev)).await.is_err() {
131                            return;
132                        }
133                    }
134                }
135                Err(e) if e_is_retryable(&e) => {
136                    if !backoff_or_give_up(&tx, &config, &mut consecutive_failures, e).await {
137                        return;
138                    }
139                }
140                Err(e) => {
141                    let _ = tx.send(Err(e)).await;
142                    return;
143                }
144            }
145            continue;
146        }
147
148        // Fetch the next page of events.
149        let request = ListEventsRequest {
150            read_mask: None,
151            start_checkpoint: Some(next_checkpoint),
152            end_checkpoint: None,
153            filter: Some(filter.clone()),
154            options: Some(QueryOptions {
155                limit: Some(config.page_size),
156                after: next_cursor.clone(),
157                before: None,
158                ordering: None, // ascending (default)
159            }),
160        };
161
162        match fetch_one_page(&mut light, request).await {
163            Ok(page) => {
164                let PageResult {
165                    events,
166                    end_cursor,
167                    end_reason,
168                    watermark,
169                    partial_error,
170                } = page;
171
172                buffer_response_batch(&mut state, events, watermark);
173
174                // Mid-stream transport error: commit whatever items we
175                // got into the buffer (done above), advance
176                // `next_cursor` to the latest watermark the server
177                // sent, then backoff-or-give-up the same way a
178                // pre-stream error would. This is the fix for the
179                // resumption-on-timeout case: without it, repeated
180                // server-side timeouts would each lose the cursor
181                // accumulated during the failed page and the loop
182                // would replay the same stale position forever.
183                if let Some(err) = partial_error {
184                    if e_is_retryable(&err) {
185                        next_cursor = end_cursor;
186                        if !backoff_or_give_up(&tx, &config, &mut consecutive_failures, err).await {
187                            return;
188                        }
189                    } else {
190                        let _ = tx.send(Err(err)).await;
191                        return;
192                    }
193                    continue;
194                }
195
196                consecutive_failures = 0;
197                match end_reason {
198                    // The server stopped mid-range with unscanned work
199                    // remaining — resume from the latest in-stream
200                    // cursor so we don't skip the unscanned tail. Same
201                    // shape as ItemLimit: keep
202                    // advancing the cursor, no checkpoint bump, no
203                    // backoff sleep.
204                    Some(QueryEndReason::ItemLimit | QueryEndReason::ScanLimit) => {
205                        next_cursor = end_cursor;
206                    }
207                    Some(_) => {
208                        // Server reached the indexed tip, a requested
209                        // checkpoint range bound, or a cursor bound —
210                        // no more events available in this scan. Reset
211                        // cursor and bump start checkpoint to one past
212                        // the watermark / last buffered event so the
213                        // next page picks up new events as they
214                        // arrive. We can't use `local_head.checkpoint_seq`
215                        // here because folding is deferred to
216                        // reconciliation — it can lag the scan by an
217                        // entire interval.
218                        next_cursor = None;
219                        next_checkpoint = state
220                            .events_scanned_through
221                            .checked_add(1)
222                            .unwrap_or(next_checkpoint)
223                            .max(next_checkpoint);
224                        // Sleep briefly so we don't spin when at the tip.
225                        tokio::time::sleep(config.retry_backoff).await;
226                    }
227                    None => {
228                        // Stream ended cleanly (no transport error) but
229                        // without an `End` frame. This shouldn't happen
230                        // in normal operation; preserve the latest
231                        // cursor and back off briefly rather than
232                        // assume the scan finished and skip ahead.
233                        next_cursor = end_cursor;
234                        tokio::time::sleep(config.retry_backoff).await;
235                    }
236                }
237            }
238            Err(e) if e_is_retryable(&e) => {
239                if !backoff_or_give_up(&tx, &config, &mut consecutive_failures, e).await {
240                    return;
241                }
242            }
243            Err(e) => {
244                let _ = tx.send(Err(e)).await;
245                return;
246            }
247        }
248    }
249}
250
251struct InitialState {
252    initial_head: EventStreamHead,
253    start_checkpoint: u64,
254}
255
256/// Establish the starting position. Fetch the latest checkpoint
257/// *before* the head so that if an event lands in between, the head
258/// fetch observes it and we resume from `head.checkpoint + 1` rather
259/// than the now-stale tip.
260///
261/// Known limitation: an `EventStreamHead` object that exists on chain
262/// but was *not modified* in the checkpoint we query produces an
263/// authenticated `NonInclusion` result, which this function treats the
264/// same as "no head yet" — it falls back to the tip. The OCS-per-
265/// checkpoint commitment scheme attests only to objects modified at a
266/// given checkpoint, not to the contents of unmodified objects, so we
267/// cannot distinguish the two cases through this API alone. In
268/// practice this is rarely an issue because new events on a stream
269/// modify its head, so a head that hasn't been touched at the tip
270/// means the stream has been quiet — and starting from the tip is the
271/// correct resume point in both cases.
272async fn initial_state(
273    light: &mut LightClient,
274    config: &AuthenticatedEventsConfig,
275) -> Result<InitialState, LightClientError> {
276    let latest_tip = light.latest_checkpoint_seq().await?;
277    let stream_head_object_id = derive_event_stream_head_object_id(config.stream_id);
278
279    let proof = light
280        .prove_object_at_checkpoint(&stream_head_object_id, latest_tip)
281        .await?;
282
283    let (initial_head, start_checkpoint) = match proof {
284        CheckpointObjectProof::Inclusion {
285            object: Some(object),
286            ..
287        } => {
288            let head = extract_event_stream_head(&object)?;
289            let cp = head.checkpoint_seq;
290            (head, cp)
291        }
292        CheckpointObjectProof::Inclusion { object: None, .. } => {
293            // The head object was deleted or wrapped at the tip.
294            // For an authenticated event stream this is unrecoverable
295            // — the on-chain anchor is gone, so no future reconciliation
296            // can succeed.
297            return Err(LightClientError::UnexpectedObjectShape {
298                reason: "event stream head was deleted or wrapped at the initial tip",
299            });
300        }
301        CheckpointObjectProof::NonInclusion => (
302            EventStreamHead::default(),
303            config.start_checkpoint.unwrap_or(latest_tip),
304        ),
305    };
306
307    let start_checkpoint = config.start_checkpoint.unwrap_or(start_checkpoint);
308    Ok(InitialState {
309        initial_head,
310        start_checkpoint,
311    })
312}
313
314struct PageResult {
315    events: Vec<AuthenticatedEvent>,
316    /// Latest `Watermark.cursor` delivered during the page. Every frame
317    /// carries a watermark, so this is the safe resume point on any
318    /// termination.
319    end_cursor: Option<prost::bytes::Bytes>,
320    end_reason: Option<QueryEndReason>,
321    /// Most recent `Watermark.checkpoint` observed in this page. The
322    /// streaming state uses this as the "events scanned through" floor
323    /// when picking the settlement-fetch range.
324    watermark: Option<u64>,
325    /// Mid-stream transport error that interrupted page accumulation.
326    /// When present, `events` and `end_cursor` reflect what was
327    /// received before the error. The caller advances `next_cursor` to
328    /// `end_cursor` before dispatching retry vs. terminal — without
329    /// this, propagating the error via `?` would discard the cursor
330    /// progress and the retry loop would replay the same stale
331    /// position on every server-side timeout.
332    partial_error: Option<LightClientError>,
333}
334
335async fn fetch_one_page(
336    light: &mut LightClient,
337    request: ListEventsRequest,
338) -> Result<PageResult, LightClientError> {
339    let mut stream = light
340        .rpc()
341        .ledger_client()
342        .list_events(request)
343        .await?
344        .into_inner();
345
346    let mut events = Vec::new();
347    let mut end_cursor: Option<prost::bytes::Bytes> = None;
348    let mut end_reason = None;
349    let mut watermark: Option<u64> = None;
350    let mut partial_error: Option<LightClientError> = None;
351
352    while let Some(frame) = stream.next().await {
353        let frame = match frame {
354            Ok(f) => f,
355            Err(status) => {
356                partial_error = Some(status.into());
357                break;
358            }
359        };
360        if let Some(w) = frame.watermark.as_ref() {
361            if let Some(c) = w.cursor.clone() {
362                end_cursor = Some(c);
363            }
364            if let Some(hi) = w.checkpoint {
365                watermark = Some(watermark.map_or(hi, |prev| prev.max(hi)));
366            }
367        }
368        if frame.event.is_some() {
369            let ev = AuthenticatedEvent::try_from(&frame)?;
370            events.push(ev);
371        }
372        if let Some(end) = frame.end {
373            end_reason = end.reason.and_then(|r| QueryEndReason::try_from(r).ok());
374            break;
375        }
376    }
377
378    Ok(PageResult {
379        events,
380        end_cursor,
381        end_reason,
382        watermark,
383        partial_error,
384    })
385}
386
387/// Drive one reconciliation tick: fetch settlements for the unconfirmed
388/// range, fold the buffered events through the latest settled
389/// checkpoint into the local MMR, prove the on-chain head at that
390/// checkpoint, and release the folded events on a match.
391///
392/// Returns the released events on success. Returns `Ok(Vec::new())`
393/// when there's nothing to reconcile yet (the head wasn't modified
394/// anywhere in the unconfirmed range, so there's no chain anchor to
395/// compare against).
396async fn reconcile_once(
397    light: &mut LightClient,
398    state: &mut StreamState,
399    stream_head_object_id: &sui_sdk_types::Address,
400    config: &AuthenticatedEventsConfig,
401) -> Result<Vec<AuthenticatedEvent>, LightClientError> {
402    // Settlements only happen up through events we've fully scanned —
403    // querying past `events_scanned_through` risks partitioning into a
404    // settlement bucket whose events haven't all been buffered yet.
405    let settlement_upper_inclusive = state.events_scanned_through;
406    if settlement_upper_inclusive <= state.confirmed_through {
407        return Ok(Vec::new());
408    }
409
410    let settlements = fetch_settlements_for_range(
411        light,
412        stream_head_object_id,
413        state.confirmed_through.saturating_add(1),
414        settlement_upper_inclusive.saturating_add(1),
415        config.page_size,
416    )
417    .await?;
418
419    let Some((reconcile_cp, _)) = settlements.last().copied() else {
420        // No settlement in the unconfirmed range — the head wasn't
421        // modified, so there's no anchor to reconcile against. Pending
422        // events stay buffered; the next reconciliation tick will
423        // retry once a settlement lands.
424        return Ok(Vec::new());
425    };
426
427    let proof = light
428        .prove_object_at_checkpoint(stream_head_object_id, reconcile_cp)
429        .await?;
430    let chain_head = match proof {
431        CheckpointObjectProof::Inclusion {
432            object: Some(object),
433            ..
434        } => extract_event_stream_head(&object)?,
435        CheckpointObjectProof::Inclusion { object: None, .. } => {
436            // The on-chain head was deleted or wrapped at the
437            // settlement checkpoint. The local replay cannot be
438            // reconciled against a missing head; this is terminal for
439            // the stream.
440            return Err(LightClientError::UnexpectedObjectShape {
441                reason: "event stream head was deleted or wrapped at the reconciliation tip",
442            });
443        }
444        CheckpointObjectProof::NonInclusion => {
445            // `ListTransactions(affected_object)` placed a settlement
446            // at this checkpoint, but the OCS proof says the head
447            // wasn't modified there — the two indexes are
448            // inconsistent.
449            return Err(LightClientError::UnexpectedObjectShape {
450                reason: "settlement transaction listed at checkpoint but OCS proof reports \
451                         the event stream head was not modified",
452            });
453        }
454    };
455
456    fold_and_reconcile(state, &settlements, chain_head, reconcile_cp)
457}
458
459/// Page through `ListTransactions` filtered on `affected_object =
460/// stream_head_object_id` and return the ascending `(checkpoint,
461/// transaction_index)` settlement boundaries for `[start, end)`.
462///
463/// Each `settle_events` transaction mutates the stream's head object,
464/// so this filter returns exactly the per-stream settlement boundaries.
465/// `start` is inclusive and `end` is exclusive — matching the proto
466/// `start_checkpoint` / `end_checkpoint` semantics — so the caller
467/// passes `confirmed_through + 1` and `events_scanned_through + 1`.
468async fn fetch_settlements_for_range(
469    light: &mut LightClient,
470    stream_head_object_id: &sui_sdk_types::Address,
471    start_checkpoint: u64,
472    end_checkpoint_exclusive: u64,
473    page_size: u32,
474) -> Result<Vec<(u64, u64)>, LightClientError> {
475    if end_checkpoint_exclusive <= start_checkpoint {
476        return Ok(Vec::new());
477    }
478
479    let filter = build_affected_object_filter(stream_head_object_id);
480    let mut settlements: Vec<(u64, u64)> = Vec::new();
481    let mut cursor: Option<prost::bytes::Bytes> = None;
482
483    loop {
484        let request = ListTransactionsRequest {
485            read_mask: None,
486            start_checkpoint: Some(start_checkpoint),
487            end_checkpoint: Some(end_checkpoint_exclusive),
488            filter: Some(filter.clone()),
489            options: Some(QueryOptions {
490                limit: Some(page_size),
491                after: cursor.clone(),
492                before: None,
493                ordering: None, // ascending
494            }),
495        };
496
497        let page = fetch_settlements_page(light, request).await?;
498        settlements.extend(page.entries);
499
500        match page.end_reason {
501            // Server hit its per-request bound but the range may have
502            // more — keep paging from the latest cursor it gave us.
503            Some(QueryEndReason::ItemLimit | QueryEndReason::ScanLimit) => {
504                if page.end_cursor.is_none() {
505                    // Defensive: no cursor advance means we'd loop
506                    // forever. Treat as done; the next reconciliation
507                    // tick will retry with a fresh window.
508                    break;
509                }
510                cursor = page.end_cursor;
511            }
512            // Range fully scanned (CheckpointBound / CursorBound /
513            // LedgerTip / Unspecified) or end frame missing: nothing
514            // more to fetch in this window.
515            _ => break,
516        }
517    }
518
519    Ok(settlements)
520}
521
522struct SettlementsPage {
523    entries: Vec<(u64, u64)>,
524    end_cursor: Option<prost::bytes::Bytes>,
525    end_reason: Option<QueryEndReason>,
526}
527
528async fn fetch_settlements_page(
529    light: &mut LightClient,
530    request: ListTransactionsRequest,
531) -> Result<SettlementsPage, LightClientError> {
532    let mut stream = light
533        .rpc()
534        .ledger_client()
535        .list_transactions(request)
536        .await?
537        .into_inner();
538
539    let mut entries = Vec::new();
540    let mut end_cursor: Option<prost::bytes::Bytes> = None;
541    let mut end_reason = None;
542
543    while let Some(frame) = stream.next().await {
544        let frame = frame?;
545        if let Some(c) = frame.watermark.as_ref().and_then(|w| w.cursor.clone()) {
546            end_cursor = Some(c);
547        }
548        if let Some(transaction) = frame.transaction.as_ref() {
549            let checkpoint =
550                transaction
551                    .checkpoint
552                    .ok_or(LightClientError::UnexpectedObjectShape {
553                        reason: "settlement transaction missing checkpoint",
554                    })?;
555            let tx_offset =
556                transaction
557                    .transaction_index
558                    .ok_or(LightClientError::UnexpectedObjectShape {
559                        reason: "settlement transaction missing transaction_index",
560                    })?;
561            entries.push((checkpoint, tx_offset));
562        }
563        if let Some(end) = frame.end {
564            end_reason = end.reason.and_then(|r| QueryEndReason::try_from(r).ok());
565            break;
566        }
567    }
568
569    Ok(SettlementsPage {
570        entries,
571        end_cursor,
572        end_reason,
573    })
574}
575
576fn build_filter(stream_id: sui_sdk_types::Address) -> EventFilter {
577    EventFilter::matching(event::event_stream_head(stream_id))
578}
579
580fn build_affected_object_filter(object_id: &sui_sdk_types::Address) -> TransactionFilter {
581    TransactionFilter::matching(transaction::affected_object(*object_id))
582}
583
584/// True if buffered events were already scanned up through `next_checkpoint`,
585/// so the page-fetch loop is idle and may as well reconcile sooner.
586fn page_drain_done(state: &StreamState, next_checkpoint: u64) -> bool {
587    state.events_scanned_through.saturating_add(1) >= next_checkpoint
588}
589
590fn e_is_retryable(err: &LightClientError) -> bool {
591    matches!(
592        err,
593        LightClientError::Rpc(status)
594            if matches!(
595                status.code(),
596                tonic::Code::Unavailable
597                    | tonic::Code::DeadlineExceeded
598                    | tonic::Code::ResourceExhausted
599                    | tonic::Code::Aborted
600            )
601    )
602}
603
604/// Backoff for `retry_backoff * attempts + jitter`. Returns `true` if
605/// the task should continue, `false` if `max_connect_retries` has been
606/// exhausted (in which case the error has been forwarded to the
607/// channel).
608async fn backoff_or_give_up(
609    tx: &mpsc::Sender<Result<AuthenticatedEvent, LightClientError>>,
610    config: &AuthenticatedEventsConfig,
611    consecutive_failures: &mut u32,
612    err: LightClientError,
613) -> bool {
614    *consecutive_failures += 1;
615    if *consecutive_failures > config.max_connect_retries {
616        let _ = tx.send(Err(err)).await;
617        return false;
618    }
619    let base = config.retry_backoff.saturating_mul(*consecutive_failures);
620    let jitter = pseudo_jitter(*consecutive_failures, config.retry_jitter);
621    tokio::time::sleep(base.saturating_add(jitter)).await;
622    true
623}
624
625/// Deterministic jitter derived from the attempt count. Avoids
626/// pulling in `rand` for a single call; the goal is just to avoid
627/// lockstep retries across many concurrent streams, not cryptographic
628/// randomness.
629fn pseudo_jitter(attempts: u32, ceiling: Duration) -> Duration {
630    if ceiling.is_zero() {
631        return Duration::ZERO;
632    }
633    // Mix the attempt count with a fixed prime to spread values.
634    let mix = (u64::from(attempts).wrapping_mul(0x9E3779B97F4A7C15)) as u128;
635    let ceiling_ms = ceiling.as_millis().max(1);
636    let offset_ms = (mix % ceiling_ms) as u64;
637    Duration::from_millis(offset_ms)
638}