Skip to main content

sui_rpc/light_client/events/
envelope.rs

1//! SDK-side envelope for authenticated events received via the v2alpha
2//! `LedgerService.ListEvents` RPC filtered by `EventStreamHeadFilter`.
3
4use sui_sdk_types::Digest;
5use sui_sdk_types::Event;
6
7use crate::proto::TryFromProtoError;
8use crate::proto::sui::rpc::v2alpha::EventItem;
9
10/// A single authenticated event paired with the positional metadata a
11/// verifier needs to reconstruct its `EventCommitment` leaf.
12///
13/// The tuple `(checkpoint, transaction_offset, event_index)` identifies
14/// the event's position in the ledger; combined with the per-event
15/// digest derived from `event`, those four pieces form the BCS-encoded
16/// merkle leaf the framework folds into the stream's MMR — see
17/// `sui_sdk_types::framework::EventCommitment`.
18///
19/// `transaction_digest` is carried for caller convenience (e.g.,
20/// correlating with an explorer URL or another transaction-keyed
21/// lookup) but is not an input to the cryptographic verification.
22///
23/// The envelope deliberately omits any `stream_id`: every event in a
24/// `ListEvents` response filtered by `EventStreamHeadFilter` belongs to
25/// the same stream by construction, and the caller already knows which
26/// stream they asked for.
27#[derive(Clone, Debug, PartialEq, Eq)]
28pub struct AuthenticatedEvent {
29    /// The checkpoint containing the transaction that emitted this event.
30    pub checkpoint: u64,
31    /// 0-based index of the emitting transaction within its containing
32    /// checkpoint.
33    pub transaction_offset: u64,
34    /// 0-based index of this event within its transaction's event list.
35    pub event_index: u32,
36    /// The digest of the emitting transaction.
37    pub transaction_digest: Digest,
38    /// The event payload itself.
39    pub event: Event,
40}
41
42impl TryFrom<&EventItem> for AuthenticatedEvent {
43    type Error = TryFromProtoError;
44
45    fn try_from(value: &EventItem) -> Result<Self, Self::Error> {
46        let checkpoint = value
47            .checkpoint
48            .ok_or_else(|| TryFromProtoError::missing(EventItem::CHECKPOINT_FIELD.name))?;
49        let transaction_offset = value
50            .transaction_offset
51            .ok_or_else(|| TryFromProtoError::missing(EventItem::TRANSACTION_OFFSET_FIELD.name))?;
52        let event_index = value
53            .event_index
54            .ok_or_else(|| TryFromProtoError::missing(EventItem::EVENT_INDEX_FIELD.name))?;
55        let transaction_digest = value
56            .transaction_digest
57            .as_ref()
58            .ok_or_else(|| TryFromProtoError::missing(EventItem::TRANSACTION_DIGEST_FIELD.name))?
59            .parse()
60            .map_err(|e| TryFromProtoError::invalid(EventItem::TRANSACTION_DIGEST_FIELD, e))?;
61        let event_proto = value
62            .event
63            .as_ref()
64            .ok_or_else(|| TryFromProtoError::missing(EventItem::EVENT_FIELD.name))?;
65        let event =
66            Event::try_from(event_proto).map_err(|e| e.nested(EventItem::EVENT_FIELD.name))?;
67
68        Ok(Self {
69            checkpoint,
70            transaction_offset,
71            event_index,
72            transaction_digest,
73            event,
74        })
75    }
76}
77
78impl From<&AuthenticatedEvent> for EventItem {
79    fn from(value: &AuthenticatedEvent) -> Self {
80        Self {
81            // `watermark` is server-assigned (cursor + checkpoint_hi);
82            // leave unset on the way out.
83            watermark: None,
84            checkpoint: Some(value.checkpoint),
85            event_index: Some(value.event_index),
86            transaction_digest: Some(value.transaction_digest.to_string()),
87            event: Some(value.event.clone().into()),
88            transaction_offset: Some(value.transaction_offset),
89        }
90    }
91}
92
93impl From<AuthenticatedEvent> for EventItem {
94    fn from(value: AuthenticatedEvent) -> Self {
95        (&value).into()
96    }
97}
98
99#[cfg(test)]
100mod tests {
101    use super::*;
102    use crate::proto::sui::rpc::v2::Bcs;
103    use crate::proto::sui::rpc::v2::Event as ProtoEvent;
104    use sui_sdk_types::Address;
105    use sui_sdk_types::Identifier;
106    use sui_sdk_types::StructTag;
107
108    fn sample_event() -> Event {
109        Event {
110            package_id: Address::TWO,
111            module: Identifier::from_static("clock"),
112            sender: Address::TWO,
113            type_: StructTag::new(
114                Address::TWO,
115                Identifier::from_static("clock"),
116                Identifier::from_static("Tick"),
117                vec![],
118            ),
119            contents: vec![0xde, 0xad, 0xbe, 0xef],
120        }
121    }
122
123    fn sample_authenticated_event() -> AuthenticatedEvent {
124        AuthenticatedEvent {
125            checkpoint: 42,
126            transaction_offset: 3,
127            event_index: 7,
128            transaction_digest: Digest::new([0xaa; 32]),
129            event: sample_event(),
130        }
131    }
132
133    #[test]
134    fn round_trip_through_proto_preserves_envelope() {
135        let original = sample_authenticated_event();
136        let proto: EventItem = (&original).into();
137        let back = AuthenticatedEvent::try_from(&proto).unwrap();
138        assert_eq!(back, original);
139    }
140
141    #[test]
142    fn outbound_conversion_leaves_watermark_unset() {
143        let proto: EventItem = (&sample_authenticated_event()).into();
144        assert!(
145            proto.watermark.is_none(),
146            "watermark (cursor + checkpoint_hi) must be server-assigned"
147        );
148    }
149
150    #[test]
151    fn missing_checkpoint_is_rejected() {
152        let mut proto: EventItem = (&sample_authenticated_event()).into();
153        proto.checkpoint = None;
154        let err = AuthenticatedEvent::try_from(&proto).unwrap_err();
155        assert_eq!(err.field_violation().field, "checkpoint");
156    }
157
158    #[test]
159    fn missing_transaction_offset_is_rejected() {
160        let mut proto: EventItem = (&sample_authenticated_event()).into();
161        proto.transaction_offset = None;
162        let err = AuthenticatedEvent::try_from(&proto).unwrap_err();
163        assert_eq!(err.field_violation().field, "transaction_offset");
164    }
165
166    #[test]
167    fn missing_event_index_is_rejected() {
168        let mut proto: EventItem = (&sample_authenticated_event()).into();
169        proto.event_index = None;
170        let err = AuthenticatedEvent::try_from(&proto).unwrap_err();
171        assert_eq!(err.field_violation().field, "event_index");
172    }
173
174    #[test]
175    fn missing_transaction_digest_is_rejected() {
176        let mut proto: EventItem = (&sample_authenticated_event()).into();
177        proto.transaction_digest = None;
178        let err = AuthenticatedEvent::try_from(&proto).unwrap_err();
179        assert_eq!(err.field_violation().field, "transaction_digest");
180    }
181
182    #[test]
183    fn missing_event_is_rejected() {
184        let mut proto: EventItem = (&sample_authenticated_event()).into();
185        proto.event = None;
186        let err = AuthenticatedEvent::try_from(&proto).unwrap_err();
187        assert_eq!(err.field_violation().field, "event");
188    }
189
190    /// A malformed inner `Event` field surfaces with a field path that
191    /// names the parent `event` field so the failure points at the
192    /// containing slot in `EventItem`.
193    #[test]
194    fn malformed_inner_event_reports_nested_field_path() {
195        let mut proto: EventItem = (&sample_authenticated_event()).into();
196        // Strip the inner event's required `package_id`.
197        proto.event = Some(ProtoEvent {
198            package_id: None,
199            module: Some("clock".into()),
200            sender: Some(Address::TWO.to_string()),
201            event_type: Some("0x2::clock::Tick".into()),
202            contents: Some(Bcs::from(prost::bytes::Bytes::from_static(&[0x00]))),
203            json: None,
204        });
205        let err = AuthenticatedEvent::try_from(&proto).unwrap_err();
206        let path = &err.field_violation().field;
207        assert!(
208            path.starts_with("event."),
209            "field path should be nested under the parent `event` field, got {path}",
210        );
211    }
212
213    /// A malformed `transaction_digest` (base58-undecodable) reports the
214    /// `transaction_digest` field with a parse-error source.
215    #[test]
216    fn malformed_transaction_digest_is_rejected() {
217        let mut proto: EventItem = (&sample_authenticated_event()).into();
218        proto.transaction_digest = Some("not-a-real-digest!".into());
219        let err = AuthenticatedEvent::try_from(&proto).unwrap_err();
220        assert_eq!(err.field_violation().field, "transaction_digest");
221    }
222}