sui_sdk_types/framework.rs
1//! Rust definitions of move/sui framework types.
2
3use super::Address;
4use super::Object;
5use super::TypeTag;
6use std::borrow::Cow;
7
8#[cfg(feature = "unstable")]
9use super::Digest;
10#[cfg(feature = "unstable")]
11use super::U256;
12
13#[derive(Debug, Clone)]
14pub struct Coin<'a> {
15 coin_type: Cow<'a, TypeTag>,
16 id: Address,
17 balance: u64,
18}
19
20impl<'a> Coin<'a> {
21 pub fn coin_type(&self) -> &TypeTag {
22 &self.coin_type
23 }
24
25 pub fn id(&self) -> &Address {
26 &self.id
27 }
28
29 pub fn balance(&self) -> u64 {
30 self.balance
31 }
32
33 pub fn try_from_object(object: &'a Object) -> Option<Self> {
34 match &object.data {
35 super::ObjectData::Struct(move_struct) => {
36 let coin_type = move_struct.type_.is_coin()?;
37
38 let contents = &move_struct.contents;
39 if contents.len() != Address::LENGTH + std::mem::size_of::<u64>() {
40 return None;
41 }
42
43 let id = Address::new((&contents[..Address::LENGTH]).try_into().unwrap());
44 let balance =
45 u64::from_le_bytes((&contents[Address::LENGTH..]).try_into().unwrap());
46
47 Some(Self {
48 coin_type: Cow::Borrowed(coin_type),
49 id,
50 balance,
51 })
52 }
53 _ => None, // package
54 }
55 }
56
57 pub fn into_owned(self) -> Coin<'static> {
58 Coin {
59 coin_type: Cow::Owned(self.coin_type.into_owned()),
60 id: self.id,
61 balance: self.balance,
62 }
63 }
64}
65
66/// A commitment to a single event for inclusion in an authenticated event
67/// stream's Merkle Mountain Range.
68///
69/// Each leaf of the per-checkpoint merkle tree is the BCS encoding of this
70/// struct. The four fields together identify the event's position in the
71/// ledger and bind it to its content:
72///
73/// - `checkpoint_seq`: the checkpoint containing the emitting transaction.
74/// - `transaction_idx`: the emitting transaction's 0-based index within its
75/// checkpoint (user transactions are numbered first, settlement transactions
76/// continue the same sequence).
77/// - `event_idx`: the event's 0-based index within its transaction's event
78/// list.
79/// - `digest`: the per-event digest, `BLAKE2b-256(BCS of `[`Event`])`.
80///
81/// Ordering is lexicographic over `(checkpoint_seq, transaction_idx,
82/// event_idx)` only; the per-event digest is not part of the comparison since
83/// the positional tuple already uniquely identifies an event.
84///
85/// Mirrors `sui::accumulator_settlement::EventCommitment` on the Move side.
86///
87/// [`Event`]: crate::Event
88///
89/// # BCS
90///
91/// ```text
92/// event-commitment = u64 u64 u64 digest
93/// ```
94#[cfg(feature = "unstable")]
95#[cfg_attr(doc_cfg, doc(cfg(feature = "unstable")))]
96#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
97#[cfg_attr(
98 feature = "serde",
99 derive(serde_derive::Serialize, serde_derive::Deserialize)
100)]
101pub struct EventCommitment {
102 pub checkpoint_seq: u64,
103 pub transaction_idx: u64,
104 pub event_idx: u64,
105 pub digest: Digest,
106}
107
108#[cfg(feature = "unstable")]
109impl PartialOrd for EventCommitment {
110 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
111 Some(self.cmp(other))
112 }
113}
114
115#[cfg(feature = "unstable")]
116impl Ord for EventCommitment {
117 fn cmp(&self, other: &Self) -> std::cmp::Ordering {
118 (self.checkpoint_seq, self.transaction_idx, self.event_idx).cmp(&(
119 other.checkpoint_seq,
120 other.transaction_idx,
121 other.event_idx,
122 ))
123 }
124}
125
126/// On-chain head of an authenticated event stream.
127///
128/// The framework maintains one of these per event stream as a dynamic field
129/// on the accumulator root object (`0xacc`), keyed by
130/// `accumulator::Key<accumulator_settlement::EventStreamHead> { owner:
131/// stream_id }`. Use [`derive_event_stream_head_object_id`] to compute the
132/// dynamic field's object id from the stream id.
133///
134/// Each settlement transaction that processes events for the stream folds a
135/// per-checkpoint merkle tree root into the MMR using carry-propagation. The
136/// framework guarantees at most one such settlement per stream per
137/// checkpoint, so [`checkpoint_seq`] strictly identifies which checkpoint the
138/// head reflects.
139///
140/// Mirrors `sui::accumulator_settlement::EventStreamHead`.
141///
142/// [`checkpoint_seq`]: Self::checkpoint_seq
143///
144/// # BCS
145///
146/// ```text
147/// event-stream-head = vector u256 u64 u64
148/// ```
149#[cfg(feature = "unstable")]
150#[cfg_attr(doc_cfg, doc(cfg(feature = "unstable")))]
151#[derive(Clone, Debug, Default, PartialEq, Eq)]
152#[cfg_attr(
153 feature = "serde",
154 derive(serde_derive::Serialize, serde_derive::Deserialize)
155)]
156pub struct EventStreamHead {
157 /// Merkle Mountain Range peaks, ordered from the lowest level upward.
158 /// Empty slots hold `U256::ZERO`; the vector grows by one slot whenever
159 /// the carry propagates past the current highest level.
160 pub mmr: Vec<U256>,
161 /// The latest checkpoint whose events have been folded into the MMR.
162 pub checkpoint_seq: u64,
163 /// Total number of events ever folded into the MMR.
164 pub num_events: u64,
165}
166
167/// Compute the object id of the [`EventStreamHead`] dynamic field for a
168/// given stream.
169///
170/// The framework stores each stream's head as a dynamic field on the
171/// accumulator root object (`0xacc`). The field is keyed by
172/// `sui::accumulator::Key<sui::accumulator_settlement::EventStreamHead>`,
173/// with the `owner` field of `Key` set to the stream id. This helper
174/// reproduces that derivation so a client can fetch the head via an OCS
175/// inclusion proof anchored to a verified checkpoint.
176///
177/// The BCS encoding of `Key { owner: stream_id }` is identical to that of
178/// the bare 32-byte address, since `Key` is a single-field struct over the
179/// owner.
180///
181/// ```
182/// use sui_sdk_types::Address;
183/// use sui_sdk_types::framework::derive_event_stream_head_object_id;
184///
185/// // Pinned interop vector cross-verified against
186/// // `sui_types::accumulator_root::derive_event_stream_head_object_id`.
187/// let stream_id = Address::ZERO;
188/// let object_id = derive_event_stream_head_object_id(stream_id);
189/// assert_eq!(
190/// object_id,
191/// Address::from_static("0x9461a724d957b41485e094fdced6c668bd388070108dbfbdc12277ad68a2717f"),
192/// );
193/// ```
194#[cfg(feature = "unstable")]
195#[cfg_attr(doc_cfg, doc(cfg(feature = "unstable")))]
196pub fn derive_event_stream_head_object_id(stream_id: Address) -> Address {
197 use super::Identifier;
198 use super::StructTag;
199
200 // Parent: the accumulator root object at `0xacc`.
201 let accumulator_root = const { Address::from_static("0xacc") };
202
203 // Value type: `sui::accumulator_settlement::EventStreamHead`. Captured
204 // inline rather than via a dedicated constant since this is the only
205 // call site that needs it.
206 let value_type = StructTag::new(
207 Address::TWO,
208 Identifier::from_static("accumulator_settlement"),
209 Identifier::from_static("EventStreamHead"),
210 vec![],
211 );
212
213 // Key type: `sui::accumulator::Key<EventStreamHead>`.
214 let key_type_tag: TypeTag = StructTag::new(
215 Address::TWO,
216 Identifier::from_static("accumulator"),
217 Identifier::from_static("Key"),
218 vec![value_type.into()],
219 )
220 .into();
221
222 // Key bytes: BCS of `AccumulatorKey { owner: stream_id }` reduces to
223 // the 32 raw address bytes because BCS encodes a single-field struct
224 // identically to the bare field.
225 accumulator_root.derive_dynamic_child_id(&key_type_tag, stream_id.as_ref())
226}
227
228/// Build the per-checkpoint merkle root over an ordered slice of event
229/// commitments.
230///
231/// Each leaf is the BCS encoding of an [`EventCommitment`], hashed through
232/// the standard Blake2b256 leaf/inner-prefix scheme defined in
233/// [`crate::merkle`]. The output matches the root the framework computes
234/// when sealing a per-checkpoint event tree.
235///
236/// Callers must provide `commitments` pre-sorted by
237/// `(checkpoint_seq, transaction_idx, event_idx)`. The framework folds the
238/// sorted sequence into its MMR, so an out-of-order input would yield a
239/// root that fails reconciliation. Debug builds verify the ordering with a
240/// `debug_assert!`; in release builds, ordering is the caller's
241/// responsibility.
242///
243/// An empty input yields the all-zero "empty" root from [`crate::merkle`].
244/// This is a degenerate case the framework never produces — there is no
245/// merkle tree to fold when a checkpoint has no events for a stream — but
246/// the function itself is defined for any input length so it can be used
247/// as a primitive elsewhere.
248#[cfg(feature = "unstable")]
249#[cfg_attr(doc_cfg, doc(cfg(feature = "unstable")))]
250pub fn build_event_merkle_root(commitments: &[EventCommitment]) -> Digest {
251 debug_assert!(
252 commitments.windows(2).all(|w| w[0] <= w[1]),
253 "EventCommitments must be sorted by (checkpoint_seq, transaction_idx, event_idx)",
254 );
255 let tree = crate::merkle::MerkleTree::build_from_unserialized(commitments.iter())
256 .expect("EventCommitment BCS encoding is infallible");
257 Digest::new(tree.root().bytes())
258}
259
260/// A non-empty group of event commitments folded into an [`EventStreamHead`]
261/// as a single MMR update.
262///
263/// The framework guarantees at most one accumulator settlement per
264/// `(checkpoint, stream)` pair, so callers grouping incoming events by
265/// `checkpoint_seq` reproduce the on-chain batching exactly. Within a
266/// batch, every commitment's `checkpoint_seq` must equal the batch's, and
267/// commitments must be sorted by their positional tuple
268/// `(checkpoint_seq, transaction_idx, event_idx)`.
269#[cfg(feature = "unstable")]
270#[cfg_attr(doc_cfg, doc(cfg(feature = "unstable")))]
271#[derive(Clone, Debug, PartialEq, Eq)]
272pub struct EventBatch {
273 /// The checkpoint sequence number that produced these commitments.
274 pub checkpoint_seq: u64,
275 /// Commitments to fold, sorted by their positional tuple. Must be
276 /// non-empty and every commitment's `checkpoint_seq` must equal
277 /// [`Self::checkpoint_seq`].
278 pub commitments: Vec<EventCommitment>,
279}
280
281/// Reasons [`apply_stream_updates`] can reject a batch.
282#[cfg(feature = "unstable")]
283#[cfg_attr(doc_cfg, doc(cfg(feature = "unstable")))]
284#[derive(Clone, Debug, PartialEq, Eq)]
285#[non_exhaustive]
286pub enum ApplyStreamError {
287 /// A batch contained no commitments. The framework never folds an
288 /// empty batch into the MMR, so a verifier should not produce one.
289 EmptyBatch {
290 /// Index into the `batches` slice where the empty batch was found.
291 batch_index: usize,
292 },
293
294 /// A commitment's `checkpoint_seq` field disagreed with the enclosing
295 /// batch's `checkpoint_seq`. A batch corresponds to a single settlement
296 /// transaction; all of its commitments share the settlement's
297 /// checkpoint.
298 CommitmentCheckpointMismatch {
299 /// Index into the `batches` slice.
300 batch_index: usize,
301 /// Index into the offending batch's `commitments` vector.
302 commitment_index: usize,
303 /// The batch's authoritative checkpoint sequence number.
304 batch_checkpoint_seq: u64,
305 /// The commitment's contradictory checkpoint sequence number.
306 commitment_checkpoint_seq: u64,
307 },
308
309 /// A batch's `checkpoint_seq` was strictly less than the head's last
310 /// folded checkpoint. Batches must be applied in monotonic order.
311 NonMonotonicCheckpoint {
312 /// Index into the `batches` slice.
313 batch_index: usize,
314 /// The head's `checkpoint_seq` before this batch was attempted.
315 previous_checkpoint_seq: u64,
316 /// The offending batch's `checkpoint_seq`.
317 batch_checkpoint_seq: u64,
318 },
319}
320
321#[cfg(feature = "unstable")]
322impl std::fmt::Display for ApplyStreamError {
323 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
324 match self {
325 Self::EmptyBatch { batch_index } => {
326 write!(f, "batch {batch_index} is empty")
327 }
328 Self::CommitmentCheckpointMismatch {
329 batch_index,
330 commitment_index,
331 batch_checkpoint_seq,
332 commitment_checkpoint_seq,
333 } => write!(
334 f,
335 "batch {batch_index} declares checkpoint {batch_checkpoint_seq} \
336 but commitment {commitment_index} carries checkpoint \
337 {commitment_checkpoint_seq}",
338 ),
339 Self::NonMonotonicCheckpoint {
340 batch_index,
341 previous_checkpoint_seq,
342 batch_checkpoint_seq,
343 } => write!(
344 f,
345 "batch {batch_index} at checkpoint {batch_checkpoint_seq} would \
346 regress the head's checkpoint {previous_checkpoint_seq}",
347 ),
348 }
349 }
350}
351
352#[cfg(feature = "unstable")]
353impl std::error::Error for ApplyStreamError {}
354
355/// Fold a sequence of `batches` into `head`, returning the updated head.
356///
357/// For each batch in order, this:
358///
359/// 1. Computes the merkle root over the batch's commitments via
360/// [`build_event_merkle_root`].
361/// 2. Reinterprets the 32-byte root as a [`U256`] in little-endian.
362/// 3. Folds the result into `head.mmr` using BLAKE2b-256
363/// carry-propagation: while the lowest empty slot is occupied, hash
364/// that peak with the carry and try the next slot up; otherwise drop
365/// the carry into the slot. If the carry propagates past the highest
366/// occupied level, push a new slot.
367/// 4. Advances `head.checkpoint_seq` to the batch's checkpoint and
368/// increments `head.num_events` by the number of commitments folded.
369///
370/// The MMR fold matches the on-chain Move implementation byte-for-byte —
371/// see `test_mmr_digest_compat_with_rust` under
372/// `sui-framework/sources/accumulator_settlement.move`. Any divergence
373/// here breaks reconciliation against fetched [`EventStreamHead`]
374/// objects.
375#[cfg(feature = "unstable")]
376#[cfg_attr(doc_cfg, doc(cfg(feature = "unstable")))]
377pub fn apply_stream_updates(
378 head: EventStreamHead,
379 batches: &[EventBatch],
380) -> Result<EventStreamHead, ApplyStreamError> {
381 let mut head = head;
382 for (batch_index, batch) in batches.iter().enumerate() {
383 if batch.commitments.is_empty() {
384 return Err(ApplyStreamError::EmptyBatch { batch_index });
385 }
386 for (commitment_index, commitment) in batch.commitments.iter().enumerate() {
387 if commitment.checkpoint_seq != batch.checkpoint_seq {
388 return Err(ApplyStreamError::CommitmentCheckpointMismatch {
389 batch_index,
390 commitment_index,
391 batch_checkpoint_seq: batch.checkpoint_seq,
392 commitment_checkpoint_seq: commitment.checkpoint_seq,
393 });
394 }
395 }
396 // The head has not been folded into yet when `num_events == 0`, so
397 // any starting checkpoint is acceptable; only subsequent batches
398 // must be monotonic relative to the prior fold.
399 if head.num_events != 0 && batch.checkpoint_seq < head.checkpoint_seq {
400 return Err(ApplyStreamError::NonMonotonicCheckpoint {
401 batch_index,
402 previous_checkpoint_seq: head.checkpoint_seq,
403 batch_checkpoint_seq: batch.checkpoint_seq,
404 });
405 }
406
407 let root_digest = build_event_merkle_root(&batch.commitments);
408 let merkle_root = U256::from_digits(root_digest.into_inner());
409 fold_into_mmr(&mut head.mmr, merkle_root);
410 head.num_events += batch.commitments.len() as u64;
411 head.checkpoint_seq = batch.checkpoint_seq;
412 }
413 Ok(head)
414}
415
416/// MMR carry-propagation fold.
417///
418/// Walks `mmr` from the lowest level upward. If a slot is empty
419/// (`U256::ZERO`), drop the carry into it and stop. Otherwise hash the
420/// existing peak together with the carry, clear the slot, and continue
421/// with the result as the new carry at the next level up. If the carry
422/// propagates past the highest occupied slot, append a new one.
423#[cfg(feature = "unstable")]
424fn fold_into_mmr(mmr: &mut Vec<U256>, mut carry: U256) {
425 let mut i = 0;
426 while i < mmr.len() {
427 if mmr[i] == U256::ZERO {
428 mmr[i] = carry;
429 return;
430 }
431 carry = hash_two_to_one(mmr[i], carry);
432 mmr[i] = U256::ZERO;
433 i += 1;
434 }
435 mmr.push(carry);
436}
437
438/// Compute `BLAKE2b-256(bcs(left) || bcs(right))` and reinterpret the
439/// 32-byte digest as a `U256` in little-endian.
440///
441/// `U256`'s BCS encoding is its 32 little-endian bytes verbatim (see
442/// [`U256`]'s `Serialize` impl), so `digits()` is byte-equivalent to
443/// `bcs::to_bytes(&u256)` and we avoid two throwaway allocations per
444/// fold by hashing the digit slices directly.
445#[cfg(feature = "unstable")]
446fn hash_two_to_one(left: U256, right: U256) -> U256 {
447 use super::hash::Hasher;
448
449 let mut hasher = Hasher::new();
450 hasher.update(left.digits());
451 hasher.update(right.digits());
452 U256::from_digits(hasher.finalize().into_inner())
453}
454
455#[cfg(test)]
456#[cfg(feature = "unstable")]
457mod test {
458 use super::*;
459
460 // Pinned BCS encoding of an `EventCommitment`. The shape must agree with
461 // `sui::accumulator_settlement::EventCommitment` upstream byte-for-byte,
462 // since this is what the framework hashes to produce the per-checkpoint
463 // merkle root.
464 #[test]
465 fn event_commitment_bcs_shape() {
466 let commitment = EventCommitment {
467 checkpoint_seq: 0x0102030405060708,
468 transaction_idx: 0x1112131415161718,
469 event_idx: 0x2122232425262728,
470 digest: Digest::new([0xaa; 32]),
471 };
472
473 let mut expected = Vec::new();
474 expected.extend_from_slice(&0x0102030405060708u64.to_le_bytes());
475 expected.extend_from_slice(&0x1112131415161718u64.to_le_bytes());
476 expected.extend_from_slice(&0x2122232425262728u64.to_le_bytes());
477 // The `Digest` BCS shape is length-prefixed: a single ULEB128 byte
478 // `0x20` (=32) followed by the 32 digest bytes.
479 expected.push(0x20);
480 expected.extend_from_slice(&[0xaa; 32]);
481
482 let bytes = bcs::to_bytes(&commitment).unwrap();
483 assert_eq!(bytes, expected);
484 assert_eq!(bytes.len(), 8 + 8 + 8 + 33);
485
486 let back: EventCommitment = bcs::from_bytes(&bytes).unwrap();
487 assert_eq!(back, commitment);
488 }
489
490 // `EventCommitment` ordering matches the ledger-position tuple
491 // `(checkpoint_seq, transaction_idx, event_idx)` and ignores the digest
492 // so that two distinct digests sharing the same position compare equal
493 // under the ordering. This mirrors the upstream `Ord` impl, which is
494 // what the framework relies on when sorting commitments before
495 // building the per-checkpoint merkle tree.
496 #[test]
497 fn event_commitment_ord_ignores_digest() {
498 let a = EventCommitment {
499 checkpoint_seq: 1,
500 transaction_idx: 2,
501 event_idx: 3,
502 digest: Digest::new([0x00; 32]),
503 };
504 let b = EventCommitment {
505 digest: Digest::new([0xff; 32]),
506 ..a
507 };
508 assert_eq!(a.cmp(&b), std::cmp::Ordering::Equal);
509
510 let c = EventCommitment { event_idx: 4, ..a };
511 assert!(a < c);
512 }
513
514 // Pinned BCS encoding of an `EventStreamHead`. As with
515 // `EventCommitment`, the framework reads/writes this struct byte-for-byte
516 // on chain; any divergence here breaks every consumer's ability to
517 // reconcile a locally-replayed MMR against a fetched head.
518 #[test]
519 fn event_stream_head_bcs_shape() {
520 let head = EventStreamHead {
521 mmr: vec![U256::ZERO, U256::ONE],
522 checkpoint_seq: 0x4142434445464748,
523 num_events: 0x5152535455565758,
524 };
525
526 let mut expected = Vec::new();
527 // `mmr`: ULEB128 length (=2), then two 32-byte little-endian `u256`s.
528 expected.push(0x02);
529 expected.extend_from_slice(&[0u8; 32]);
530 let mut one_le = [0u8; 32];
531 one_le[0] = 1;
532 expected.extend_from_slice(&one_le);
533 expected.extend_from_slice(&0x4142434445464748u64.to_le_bytes());
534 expected.extend_from_slice(&0x5152535455565758u64.to_le_bytes());
535
536 let bytes = bcs::to_bytes(&head).unwrap();
537 assert_eq!(bytes, expected);
538
539 let back: EventStreamHead = bcs::from_bytes(&bytes).unwrap();
540 assert_eq!(back, head);
541 }
542
543 #[test]
544 fn event_stream_head_default_is_empty() {
545 let head = EventStreamHead::default();
546 assert!(head.mmr.is_empty());
547 assert_eq!(head.checkpoint_seq, 0);
548 assert_eq!(head.num_events, 0);
549 }
550
551 // Cross-implementation pin: the expected merkle root was captured by
552 // running `sui_types::accumulator_root::build_event_merkle_root`
553 // upstream on the same three commitments. A regression in either the
554 // `EventCommitment` BCS shape or the underlying merkle tree
555 // construction (leaf/inner prefix, padding, hash function) would shift
556 // this digest. The `merkle` module's root construction and
557 // `event_commitment_bcs_shape` above already pin those pieces against
558 // their respective upstream sources, so this test seals the
559 // composition end-to-end.
560 #[test]
561 fn build_event_merkle_root_pinned_vector() {
562 let commitments = vec![
563 EventCommitment {
564 checkpoint_seq: 1,
565 transaction_idx: 0,
566 event_idx: 0,
567 digest: Digest::new([0x11; 32]),
568 },
569 EventCommitment {
570 checkpoint_seq: 1,
571 transaction_idx: 0,
572 event_idx: 1,
573 digest: Digest::new([0x22; 32]),
574 },
575 EventCommitment {
576 checkpoint_seq: 1,
577 transaction_idx: 1,
578 event_idx: 0,
579 digest: Digest::new([0x33; 32]),
580 },
581 ];
582 const EXPECTED: [u8; 32] = [
583 254, 183, 87, 247, 72, 14, 90, 116, 221, 195, 244, 87, 250, 236, 226, 161, 99, 106,
584 199, 246, 85, 138, 180, 110, 112, 50, 103, 77, 160, 104, 239, 61,
585 ];
586 assert_eq!(build_event_merkle_root(&commitments).into_inner(), EXPECTED);
587 }
588
589 #[test]
590 fn build_event_merkle_root_empty_input_is_empty_node() {
591 // Documented degenerate case: the merkle tree over zero leaves is the
592 // all-zero "empty" node. `apply_stream_updates` rejects this so a
593 // verifier would never reach it via the normal path, but the helper
594 // is defined for arbitrary input and should not panic.
595 assert_eq!(build_event_merkle_root(&[]).into_inner(), [0u8; 32]);
596 }
597
598 // Cross-implementation pin: each `(stream_id, object_id)` pair was
599 // captured by running upstream's
600 // `sui_types::accumulator_root::derive_event_stream_head_object_id`
601 // directly on the listed stream id. The derivation composes
602 // `Address::derive_dynamic_child_id` (validated against the Move-side
603 // snapshot test) with a fixed `StructTag`, so a regression here would
604 // most likely indicate an unintended change to either the type tag or
605 // the parent accumulator root address.
606 #[test]
607 fn derive_event_stream_head_object_id_pinned_vectors() {
608 let cases: &[(Address, Address)] = &[
609 (
610 Address::ZERO,
611 Address::from_static(
612 "0x9461a724d957b41485e094fdced6c668bd388070108dbfbdc12277ad68a2717f",
613 ),
614 ),
615 (
616 Address::TWO,
617 Address::from_static(
618 "0x1b877f5c7664df8957f127a95d1b2c8c1c239fd49566f9f69205df44133fc37f",
619 ),
620 ),
621 (
622 Address::from_static("0xacc"),
623 Address::from_static(
624 "0x452652326e8df295af20a4e0744acac9a74f87d93ba976dd97d3e93e1a542e37",
625 ),
626 ),
627 (
628 Address::from_static("0x42424242"),
629 Address::from_static(
630 "0xdbe2cd3f24c357c434991a4348e6ceb43bcb7d22696eb6797a6739e5351cb149",
631 ),
632 ),
633 ];
634 for (stream_id, expected) in cases {
635 assert_eq!(
636 derive_event_stream_head_object_id(*stream_id),
637 *expected,
638 "mismatch for stream id {stream_id}",
639 );
640 }
641 }
642
643 fn u256_from_decimal(s: &str) -> U256 {
644 s.parse().expect("decimal U256 literal must parse")
645 }
646
647 fn single_event_batch(checkpoint_seq: u64, digest_byte: u8) -> EventBatch {
648 EventBatch {
649 checkpoint_seq,
650 commitments: vec![EventCommitment {
651 checkpoint_seq,
652 transaction_idx: 0,
653 event_idx: 0,
654 digest: Digest::new([digest_byte; 32]),
655 }],
656 }
657 }
658
659 // Load-bearing interop pin against the Move test
660 // `test_mmr_digest_compat_with_rust` in
661 // `sui-framework/sources/accumulator_settlement.move`. Inserting
662 // `U256::from(50..58)` as eight successive carries must collapse to a
663 // single peak at level 3 with the exact decimal value below; the lower
664 // three slots are zeroed. Any divergence in the carry-propagation
665 // loop or the `BLAKE2b-256(bcs(left) || bcs(right))` two-to-one step
666 // breaks reconciliation against on-chain `EventStreamHead` objects.
667 #[test]
668 fn fold_into_mmr_matches_move_compat_fixture() {
669 let mut mmr = Vec::new();
670 for value in 50u64..58 {
671 fold_into_mmr(&mut mmr, U256::from(value));
672 }
673 assert_eq!(mmr.len(), 4);
674 assert_eq!(mmr[0], U256::ZERO);
675 assert_eq!(mmr[1], U256::ZERO);
676 assert_eq!(mmr[2], U256::ZERO);
677 assert_eq!(
678 mmr[3],
679 u256_from_decimal(
680 "69725770072863840208899320192042305265295220676851872214494910464384102654361",
681 ),
682 );
683 }
684
685 // Two carries collapse into one peak at level 1, leaving level 0
686 // empty. Stand-alone sanity check that the carry propagation is wired
687 // correctly even for tiny inputs.
688 #[test]
689 fn fold_into_mmr_two_inserts_collapse_to_level_one() {
690 let mut mmr = Vec::new();
691 fold_into_mmr(&mut mmr, U256::from(7u64));
692 assert_eq!(mmr, vec![U256::from(7u64)]);
693
694 fold_into_mmr(&mut mmr, U256::from(11u64));
695 assert_eq!(mmr.len(), 2);
696 assert_eq!(mmr[0], U256::ZERO);
697 assert_eq!(mmr[1], hash_two_to_one(U256::from(7u64), U256::from(11u64)));
698 }
699
700 // End-to-end interop pin: feed a 3-commitment batch through
701 // `apply_stream_updates` starting from an empty head and compare to
702 // the `EventStreamHead` produced by
703 // `sui_light_client::authenticated_events::mmr::apply_stream_updates`
704 // upstream on the identical input.
705 #[test]
706 fn apply_stream_updates_single_batch_matches_upstream() {
707 let batch = EventBatch {
708 checkpoint_seq: 1,
709 commitments: vec![
710 EventCommitment {
711 checkpoint_seq: 1,
712 transaction_idx: 0,
713 event_idx: 0,
714 digest: Digest::new([0x11; 32]),
715 },
716 EventCommitment {
717 checkpoint_seq: 1,
718 transaction_idx: 0,
719 event_idx: 1,
720 digest: Digest::new([0x22; 32]),
721 },
722 EventCommitment {
723 checkpoint_seq: 1,
724 transaction_idx: 1,
725 event_idx: 0,
726 digest: Digest::new([0x33; 32]),
727 },
728 ],
729 };
730 let head = apply_stream_updates(EventStreamHead::default(), &[batch]).unwrap();
731 assert_eq!(head.checkpoint_seq, 1);
732 assert_eq!(head.num_events, 3);
733 assert_eq!(
734 head.mmr,
735 vec![u256_from_decimal(
736 "28014082315424315761761458464083312323394111104237010481447392654866601457662",
737 )],
738 );
739 }
740
741 // End-to-end interop pin across two sequential batches at different
742 // checkpoints. The merkle root from batch 2 carries up through level 0
743 // and lands at level 1, vacating slot 0.
744 #[test]
745 fn apply_stream_updates_two_batches_match_upstream() {
746 let batch_1 = EventBatch {
747 checkpoint_seq: 1,
748 commitments: vec![
749 EventCommitment {
750 checkpoint_seq: 1,
751 transaction_idx: 0,
752 event_idx: 0,
753 digest: Digest::new([0x11; 32]),
754 },
755 EventCommitment {
756 checkpoint_seq: 1,
757 transaction_idx: 0,
758 event_idx: 1,
759 digest: Digest::new([0x22; 32]),
760 },
761 EventCommitment {
762 checkpoint_seq: 1,
763 transaction_idx: 1,
764 event_idx: 0,
765 digest: Digest::new([0x33; 32]),
766 },
767 ],
768 };
769 let batch_2 = EventBatch {
770 checkpoint_seq: 2,
771 commitments: vec![
772 EventCommitment {
773 checkpoint_seq: 2,
774 transaction_idx: 0,
775 event_idx: 0,
776 digest: Digest::new([0x44; 32]),
777 },
778 EventCommitment {
779 checkpoint_seq: 2,
780 transaction_idx: 0,
781 event_idx: 1,
782 digest: Digest::new([0x55; 32]),
783 },
784 ],
785 };
786 let head = apply_stream_updates(EventStreamHead::default(), &[batch_1, batch_2]).unwrap();
787 assert_eq!(head.checkpoint_seq, 2);
788 assert_eq!(head.num_events, 5);
789 assert_eq!(head.mmr.len(), 2);
790 assert_eq!(head.mmr[0], U256::ZERO);
791 assert_eq!(
792 head.mmr[1],
793 u256_from_decimal(
794 "80180905428222716273420959625814881301112107405105460786291242224918309625423",
795 ),
796 );
797 }
798
799 // Four single-event batches drive the carry up through two levels,
800 // leaving levels 0 and 1 empty and one peak at level 2. End-to-end pin
801 // against upstream.
802 #[test]
803 fn apply_stream_updates_four_single_batches_match_upstream() {
804 let batches: Vec<EventBatch> = (1u64..=4)
805 .map(|cp| single_event_batch(cp, cp as u8))
806 .collect();
807 let head = apply_stream_updates(EventStreamHead::default(), &batches).unwrap();
808 assert_eq!(head.checkpoint_seq, 4);
809 assert_eq!(head.num_events, 4);
810 assert_eq!(head.mmr.len(), 3);
811 assert_eq!(head.mmr[0], U256::ZERO);
812 assert_eq!(head.mmr[1], U256::ZERO);
813 assert_eq!(
814 head.mmr[2],
815 u256_from_decimal(
816 "43434128249102587327404298804800250101556402749045331898264216785541514599480",
817 ),
818 );
819 }
820
821 // Folding zero batches into an arbitrary head must return the head
822 // unchanged.
823 #[test]
824 fn apply_stream_updates_no_batches_is_identity() {
825 let head = EventStreamHead {
826 mmr: vec![U256::ONE, U256::ZERO, U256::from(42u64)],
827 checkpoint_seq: 17,
828 num_events: 9,
829 };
830 let out = apply_stream_updates(head.clone(), &[]).unwrap();
831 assert_eq!(out, head);
832 }
833
834 // Same-checkpoint re-application is permitted (a stream can produce
835 // multiple settlement transactions in one checkpoint in unrelated
836 // accumulator object spaces, although per-stream the framework
837 // guarantees only one — the SDK helper still allows equal
838 // `checkpoint_seq` to keep the contract symmetric with strict less-than
839 // being the violation).
840 #[test]
841 fn apply_stream_updates_equal_checkpoint_seq_is_allowed() {
842 let head = apply_stream_updates(
843 EventStreamHead::default(),
844 &[single_event_batch(5, 0x01), single_event_batch(5, 0x02)],
845 )
846 .unwrap();
847 assert_eq!(head.checkpoint_seq, 5);
848 assert_eq!(head.num_events, 2);
849 }
850
851 #[test]
852 fn apply_stream_updates_rejects_empty_batch() {
853 let err = apply_stream_updates(
854 EventStreamHead::default(),
855 &[
856 single_event_batch(1, 0x01),
857 EventBatch {
858 checkpoint_seq: 2,
859 commitments: vec![],
860 },
861 ],
862 )
863 .unwrap_err();
864 assert_eq!(err, ApplyStreamError::EmptyBatch { batch_index: 1 });
865 }
866
867 #[test]
868 fn apply_stream_updates_rejects_commitment_checkpoint_mismatch() {
869 let err = apply_stream_updates(
870 EventStreamHead::default(),
871 &[EventBatch {
872 checkpoint_seq: 5,
873 commitments: vec![
874 EventCommitment {
875 checkpoint_seq: 5,
876 transaction_idx: 0,
877 event_idx: 0,
878 digest: Digest::new([0x01; 32]),
879 },
880 EventCommitment {
881 checkpoint_seq: 6, // wrong
882 transaction_idx: 0,
883 event_idx: 1,
884 digest: Digest::new([0x02; 32]),
885 },
886 ],
887 }],
888 )
889 .unwrap_err();
890 assert_eq!(
891 err,
892 ApplyStreamError::CommitmentCheckpointMismatch {
893 batch_index: 0,
894 commitment_index: 1,
895 batch_checkpoint_seq: 5,
896 commitment_checkpoint_seq: 6,
897 },
898 );
899 }
900
901 #[test]
902 fn apply_stream_updates_rejects_non_monotonic_checkpoint() {
903 let err = apply_stream_updates(
904 EventStreamHead::default(),
905 &[single_event_batch(10, 0x01), single_event_batch(9, 0x02)],
906 )
907 .unwrap_err();
908 assert_eq!(
909 err,
910 ApplyStreamError::NonMonotonicCheckpoint {
911 batch_index: 1,
912 previous_checkpoint_seq: 10,
913 batch_checkpoint_seq: 9,
914 },
915 );
916 }
917
918 /// Property-based coverage for the MMR fold and the streaming
919 /// `apply_stream_updates` driver. Gated on `feature = "proptest"`
920 /// to match the rest of the crate's proptest surface.
921 #[cfg(feature = "proptest")]
922 mod proptests {
923 use super::*;
924
925 use proptest::collection::vec;
926 use proptest::prelude::*;
927 use test_strategy::proptest;
928
929 // See the matching comment in `merkle::tests::proptests` for why
930 // this explicit binding is needed on wasm.
931 #[cfg(target_arch = "wasm32")]
932 use wasm_bindgen_test::wasm_bindgen_test as test;
933
934 /// Generate a list of monotonically-increasing batches with at
935 /// least one commitment each. Strict monotonicity (delta >= 1)
936 /// keeps every batch on its own checkpoint, matching the
937 /// "one settlement per `(checkpoint, stream)`" rule the
938 /// framework enforces on-chain.
939 fn monotonic_batches() -> impl Strategy<Value = Vec<EventBatch>> {
940 vec((1u64..=10, 1usize..=4), 0..=8).prop_map(|deltas| {
941 let mut seq = 0u64;
942 let mut batches = Vec::with_capacity(deltas.len());
943 for (delta, n_events) in deltas {
944 seq += delta;
945 let commitments: Vec<EventCommitment> = (0..n_events)
946 .map(|i| EventCommitment {
947 checkpoint_seq: seq,
948 transaction_idx: 0,
949 event_idx: i as u64,
950 // Mix the seq and index into the digest so
951 // that distinct commitments produce distinct
952 // leaves (and therefore distinct merkle
953 // roots), which keeps the fold's hash
954 // collisions vanishingly improbable.
955 digest: {
956 let mut d = [0u8; 32];
957 d[..8].copy_from_slice(&seq.to_le_bytes());
958 d[8..16].copy_from_slice(&(i as u64).to_le_bytes());
959 Digest::new(d)
960 },
961 })
962 .collect();
963 batches.push(EventBatch {
964 checkpoint_seq: seq,
965 commitments,
966 });
967 }
968 batches
969 })
970 }
971
972 /// After N non-zero carries are folded, the MMR's non-zero
973 /// peaks land at exactly the set-bit positions of N, and its
974 /// length is `floor(log2(N)) + 1`.
975 ///
976 /// Carries are constrained to `1..=u64::MAX` (mapped through
977 /// `U256::from`) so that no slot is accidentally zeroed by an
978 /// empty insert. Hash collisions to `U256::ZERO` are
979 /// possible in principle but require a 2^-256 birthday hit,
980 /// well outside the property's effective probability budget.
981 #[proptest]
982 fn mmr_popcount_invariant(#[strategy(vec(1u64..=u64::MAX, 0..=20))] carries: Vec<u64>) {
983 let mut mmr = Vec::new();
984 for c in &carries {
985 fold_into_mmr(&mut mmr, U256::from(*c));
986 }
987 let n = carries.len() as u64;
988 let expected_len = if n == 0 {
989 0
990 } else {
991 64 - n.leading_zeros() as usize
992 };
993 prop_assert_eq!(
994 mmr.len(),
995 expected_len,
996 "mmr length must match highest set bit"
997 );
998 for (i, slot) in mmr.iter().enumerate() {
999 let bit_set = (n >> i) & 1 == 1;
1000 if bit_set {
1001 prop_assert_ne!(
1002 *slot,
1003 U256::ZERO,
1004 "slot {} should be non-zero (bit set in n={})",
1005 i,
1006 n,
1007 );
1008 } else {
1009 prop_assert_eq!(
1010 *slot,
1011 U256::ZERO,
1012 "slot {} should be zero (bit unset in n={})",
1013 i,
1014 n,
1015 );
1016 }
1017 }
1018 }
1019
1020 /// `apply_stream_updates` is associative over the batch slice:
1021 /// folding all batches in one call must match folding them
1022 /// one at a time, starting from the same `EventStreamHead`.
1023 ///
1024 /// This is the contract a streaming client relies on when it
1025 /// applies batches as they arrive vs. when it replays a
1026 /// captured trace in bulk.
1027 #[proptest]
1028 fn apply_stream_updates_is_associative_over_batches(
1029 #[strategy(monotonic_batches())] batches: Vec<EventBatch>,
1030 ) {
1031 let one_shot = apply_stream_updates(EventStreamHead::default(), &batches).unwrap();
1032 let mut step_by_step = EventStreamHead::default();
1033 for batch in &batches {
1034 step_by_step =
1035 apply_stream_updates(step_by_step, std::slice::from_ref(batch)).unwrap();
1036 }
1037 prop_assert_eq!(one_shot, step_by_step);
1038 }
1039
1040 /// The head's `num_events` after `apply_stream_updates` equals
1041 /// the sum of `commitments.len()` across the input batches
1042 /// (starting from a default head, which has `num_events = 0`).
1043 #[proptest]
1044 fn apply_stream_updates_num_events_is_sum_of_commitments(
1045 #[strategy(monotonic_batches())] batches: Vec<EventBatch>,
1046 ) {
1047 let head = apply_stream_updates(EventStreamHead::default(), &batches).unwrap();
1048 let expected: u64 = batches.iter().map(|b| b.commitments.len() as u64).sum();
1049 prop_assert_eq!(head.num_events, expected);
1050 }
1051
1052 /// `apply_stream_updates` carries the last batch's
1053 /// `checkpoint_seq` into the head verbatim. For an empty input
1054 /// the head's checkpoint is left unchanged at its default
1055 /// value of zero.
1056 #[proptest]
1057 fn apply_stream_updates_advances_checkpoint_seq(
1058 #[strategy(monotonic_batches())] batches: Vec<EventBatch>,
1059 ) {
1060 let head = apply_stream_updates(EventStreamHead::default(), &batches).unwrap();
1061 let expected = batches.last().map(|b| b.checkpoint_seq).unwrap_or(0);
1062 prop_assert_eq!(head.checkpoint_seq, expected);
1063 }
1064 }
1065}