Skip to main content

sui_rpc/light_client/events/
envelope.rs

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