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}