use std::time::Duration;
use futures::Stream;
use futures::StreamExt;
use sui_sdk_types::framework::EventStreamHead;
use sui_sdk_types::framework::derive_event_stream_head_object_id;
use tokio::sync::mpsc;
use super::config::AuthenticatedEventsConfig;
use super::envelope::AuthenticatedEvent;
use super::state::StreamState;
use super::state::buffer_response_batch;
use super::state::extract_event_stream_head;
use super::state::fold_and_reconcile;
use crate::light_client::CheckpointObjectProof;
use crate::light_client::LightClient;
use crate::light_client::error::LightClientError;
use crate::proto::sui::rpc::v2::EventFilter;
use crate::proto::sui::rpc::v2::ListEventsRequest;
use crate::proto::sui::rpc::v2::ListTransactionsRequest;
use crate::proto::sui::rpc::v2::QueryEndReason;
use crate::proto::sui::rpc::v2::QueryOptions;
use crate::proto::sui::rpc::v2::TransactionFilter;
use crate::proto::sui::rpc::v2::filter::event;
use crate::proto::sui::rpc::v2::filter::transaction;
pub struct AuthenticatedEventsClient {
light: LightClient,
config: AuthenticatedEventsConfig,
}
impl AuthenticatedEventsClient {
pub fn new(light: LightClient, config: AuthenticatedEventsConfig) -> Self {
Self { light, config }
}
pub fn stream(
self,
) -> impl Stream<Item = Result<AuthenticatedEvent, LightClientError>> + Send + 'static {
let (tx, rx) = mpsc::channel(self.config.channel_capacity);
tokio::spawn(run_stream_task(self.light, self.config, tx));
futures::stream::unfold(rx, |mut rx| async move { rx.recv().await.map(|v| (v, rx)) })
}
}
async fn run_stream_task(
mut light: LightClient,
config: AuthenticatedEventsConfig,
tx: mpsc::Sender<Result<AuthenticatedEvent, LightClientError>>,
) {
let start = match initial_state(&mut light, &config).await {
Ok(start) => start,
Err(e) => {
let _ = tx.send(Err(e)).await;
return;
}
};
let mut state = StreamState::new(start.initial_head, start.start_checkpoint);
let mut next_checkpoint = start
.start_checkpoint
.checked_add(1)
.unwrap_or(start.start_checkpoint);
let mut next_cursor: Option<prost::bytes::Bytes> = None;
let mut consecutive_failures = 0u32;
let mut last_head_check = std::time::Instant::now();
let stream_head_object_id = derive_event_stream_head_object_id(config.stream_id);
let filter = build_filter(config.stream_id);
loop {
let should_reconcile = last_head_check.elapsed() >= config.head_check_interval
|| !state.buffer.is_empty()
&& next_cursor.is_none()
&& page_drain_done(&state, next_checkpoint);
if should_reconcile {
match reconcile_once(&mut light, &mut state, &stream_head_object_id, &config).await {
Ok(released) => {
consecutive_failures = 0;
last_head_check = std::time::Instant::now();
for ev in released {
if tx.send(Ok(ev)).await.is_err() {
return;
}
}
}
Err(e) if e_is_retryable(&e) => {
if !backoff_or_give_up(&tx, &config, &mut consecutive_failures, e).await {
return;
}
}
Err(e) => {
let _ = tx.send(Err(e)).await;
return;
}
}
continue;
}
let request = ListEventsRequest {
read_mask: None,
start_checkpoint: Some(next_checkpoint),
end_checkpoint: None,
filter: Some(filter.clone()),
options: Some(QueryOptions {
limit: Some(config.page_size),
after: next_cursor.clone(),
before: None,
ordering: None, }),
};
match fetch_one_page(&mut light, request).await {
Ok(page) => {
let PageResult {
events,
end_cursor,
end_reason,
watermark,
partial_error,
} = page;
buffer_response_batch(&mut state, events, watermark);
if let Some(err) = partial_error {
if e_is_retryable(&err) {
next_cursor = end_cursor;
if !backoff_or_give_up(&tx, &config, &mut consecutive_failures, err).await {
return;
}
} else {
let _ = tx.send(Err(err)).await;
return;
}
continue;
}
consecutive_failures = 0;
match end_reason {
Some(QueryEndReason::ItemLimit | QueryEndReason::ScanLimit) => {
next_cursor = end_cursor;
}
Some(_) => {
next_cursor = None;
next_checkpoint = state
.events_scanned_through
.checked_add(1)
.unwrap_or(next_checkpoint)
.max(next_checkpoint);
tokio::time::sleep(config.retry_backoff).await;
}
None => {
next_cursor = end_cursor;
tokio::time::sleep(config.retry_backoff).await;
}
}
}
Err(e) if e_is_retryable(&e) => {
if !backoff_or_give_up(&tx, &config, &mut consecutive_failures, e).await {
return;
}
}
Err(e) => {
let _ = tx.send(Err(e)).await;
return;
}
}
}
}
struct InitialState {
initial_head: EventStreamHead,
start_checkpoint: u64,
}
async fn initial_state(
light: &mut LightClient,
config: &AuthenticatedEventsConfig,
) -> Result<InitialState, LightClientError> {
let latest_tip = light.latest_checkpoint_seq().await?;
let stream_head_object_id = derive_event_stream_head_object_id(config.stream_id);
let proof = light
.prove_object_at_checkpoint(&stream_head_object_id, latest_tip)
.await?;
let (initial_head, start_checkpoint) = match proof {
CheckpointObjectProof::Inclusion {
object: Some(object),
..
} => {
let head = extract_event_stream_head(&object)?;
let cp = head.checkpoint_seq;
(head, cp)
}
CheckpointObjectProof::Inclusion { object: None, .. } => {
return Err(LightClientError::UnexpectedObjectShape {
reason: "event stream head was deleted or wrapped at the initial tip",
});
}
CheckpointObjectProof::NonInclusion => (
EventStreamHead::default(),
config.start_checkpoint.unwrap_or(latest_tip),
),
};
let start_checkpoint = config.start_checkpoint.unwrap_or(start_checkpoint);
Ok(InitialState {
initial_head,
start_checkpoint,
})
}
struct PageResult {
events: Vec<AuthenticatedEvent>,
end_cursor: Option<prost::bytes::Bytes>,
end_reason: Option<QueryEndReason>,
watermark: Option<u64>,
partial_error: Option<LightClientError>,
}
async fn fetch_one_page(
light: &mut LightClient,
request: ListEventsRequest,
) -> Result<PageResult, LightClientError> {
let mut stream = light
.rpc()
.ledger_client()
.list_events(request)
.await?
.into_inner();
let mut events = Vec::new();
let mut end_cursor: Option<prost::bytes::Bytes> = None;
let mut end_reason = None;
let mut watermark: Option<u64> = None;
let mut partial_error: Option<LightClientError> = None;
while let Some(frame) = stream.next().await {
let frame = match frame {
Ok(f) => f,
Err(status) => {
partial_error = Some(status.into());
break;
}
};
if let Some(w) = frame.watermark.as_ref() {
if let Some(c) = w.cursor.clone() {
end_cursor = Some(c);
}
if let Some(hi) = w.checkpoint {
watermark = Some(watermark.map_or(hi, |prev| prev.max(hi)));
}
}
if frame.event.is_some() {
let ev = AuthenticatedEvent::try_from(&frame)?;
events.push(ev);
}
if let Some(end) = frame.end {
end_reason = end.reason.and_then(|r| QueryEndReason::try_from(r).ok());
break;
}
}
Ok(PageResult {
events,
end_cursor,
end_reason,
watermark,
partial_error,
})
}
async fn reconcile_once(
light: &mut LightClient,
state: &mut StreamState,
stream_head_object_id: &sui_sdk_types::Address,
config: &AuthenticatedEventsConfig,
) -> Result<Vec<AuthenticatedEvent>, LightClientError> {
let settlement_upper_inclusive = state.events_scanned_through;
if settlement_upper_inclusive <= state.confirmed_through {
return Ok(Vec::new());
}
let settlements = fetch_settlements_for_range(
light,
stream_head_object_id,
state.confirmed_through.saturating_add(1),
settlement_upper_inclusive.saturating_add(1),
config.page_size,
)
.await?;
let Some((reconcile_cp, _)) = settlements.last().copied() else {
return Ok(Vec::new());
};
let proof = light
.prove_object_at_checkpoint(stream_head_object_id, reconcile_cp)
.await?;
let chain_head = match proof {
CheckpointObjectProof::Inclusion {
object: Some(object),
..
} => extract_event_stream_head(&object)?,
CheckpointObjectProof::Inclusion { object: None, .. } => {
return Err(LightClientError::UnexpectedObjectShape {
reason: "event stream head was deleted or wrapped at the reconciliation tip",
});
}
CheckpointObjectProof::NonInclusion => {
return Err(LightClientError::UnexpectedObjectShape {
reason: "settlement transaction listed at checkpoint but OCS proof reports \
the event stream head was not modified",
});
}
};
fold_and_reconcile(state, &settlements, chain_head, reconcile_cp)
}
async fn fetch_settlements_for_range(
light: &mut LightClient,
stream_head_object_id: &sui_sdk_types::Address,
start_checkpoint: u64,
end_checkpoint_exclusive: u64,
page_size: u32,
) -> Result<Vec<(u64, u64)>, LightClientError> {
if end_checkpoint_exclusive <= start_checkpoint {
return Ok(Vec::new());
}
let filter = build_affected_object_filter(stream_head_object_id);
let mut settlements: Vec<(u64, u64)> = Vec::new();
let mut cursor: Option<prost::bytes::Bytes> = None;
loop {
let request = ListTransactionsRequest {
read_mask: None,
start_checkpoint: Some(start_checkpoint),
end_checkpoint: Some(end_checkpoint_exclusive),
filter: Some(filter.clone()),
options: Some(QueryOptions {
limit: Some(page_size),
after: cursor.clone(),
before: None,
ordering: None, }),
};
let page = fetch_settlements_page(light, request).await?;
settlements.extend(page.entries);
match page.end_reason {
Some(QueryEndReason::ItemLimit | QueryEndReason::ScanLimit) => {
if page.end_cursor.is_none() {
break;
}
cursor = page.end_cursor;
}
_ => break,
}
}
Ok(settlements)
}
struct SettlementsPage {
entries: Vec<(u64, u64)>,
end_cursor: Option<prost::bytes::Bytes>,
end_reason: Option<QueryEndReason>,
}
async fn fetch_settlements_page(
light: &mut LightClient,
request: ListTransactionsRequest,
) -> Result<SettlementsPage, LightClientError> {
let mut stream = light
.rpc()
.ledger_client()
.list_transactions(request)
.await?
.into_inner();
let mut entries = Vec::new();
let mut end_cursor: Option<prost::bytes::Bytes> = None;
let mut end_reason = None;
while let Some(frame) = stream.next().await {
let frame = frame?;
if let Some(c) = frame.watermark.as_ref().and_then(|w| w.cursor.clone()) {
end_cursor = Some(c);
}
if let Some(transaction) = frame.transaction.as_ref() {
let checkpoint =
transaction
.checkpoint
.ok_or(LightClientError::UnexpectedObjectShape {
reason: "settlement transaction missing checkpoint",
})?;
let tx_offset =
transaction
.transaction_index
.ok_or(LightClientError::UnexpectedObjectShape {
reason: "settlement transaction missing transaction_index",
})?;
entries.push((checkpoint, tx_offset));
}
if let Some(end) = frame.end {
end_reason = end.reason.and_then(|r| QueryEndReason::try_from(r).ok());
break;
}
}
Ok(SettlementsPage {
entries,
end_cursor,
end_reason,
})
}
fn build_filter(stream_id: sui_sdk_types::Address) -> EventFilter {
EventFilter::matching(event::event_stream_head(stream_id))
}
fn build_affected_object_filter(object_id: &sui_sdk_types::Address) -> TransactionFilter {
TransactionFilter::matching(transaction::affected_object(*object_id))
}
fn page_drain_done(state: &StreamState, next_checkpoint: u64) -> bool {
state.events_scanned_through.saturating_add(1) >= next_checkpoint
}
fn e_is_retryable(err: &LightClientError) -> bool {
matches!(
err,
LightClientError::Rpc(status)
if matches!(
status.code(),
tonic::Code::Unavailable
| tonic::Code::DeadlineExceeded
| tonic::Code::ResourceExhausted
| tonic::Code::Aborted
)
)
}
async fn backoff_or_give_up(
tx: &mpsc::Sender<Result<AuthenticatedEvent, LightClientError>>,
config: &AuthenticatedEventsConfig,
consecutive_failures: &mut u32,
err: LightClientError,
) -> bool {
*consecutive_failures += 1;
if *consecutive_failures > config.max_connect_retries {
let _ = tx.send(Err(err)).await;
return false;
}
let base = config.retry_backoff.saturating_mul(*consecutive_failures);
let jitter = pseudo_jitter(*consecutive_failures, config.retry_jitter);
tokio::time::sleep(base.saturating_add(jitter)).await;
true
}
fn pseudo_jitter(attempts: u32, ceiling: Duration) -> Duration {
if ceiling.is_zero() {
return Duration::ZERO;
}
let mix = (u64::from(attempts).wrapping_mul(0x9E3779B97F4A7C15)) as u128;
let ceiling_ms = ceiling.as_millis().max(1);
let offset_ms = (mix % ceiling_ms) as u64;
Duration::from_millis(offset_ms)
}