Skip to main content

sui_sdk_types/effects/
mod.rs

1mod v1;
2mod v2;
3
4pub use v1::ModifiedAtVersion;
5pub use v1::ObjectReferenceWithOwner;
6pub use v1::TransactionEffectsV1;
7pub use v2::AccumulatorOperation;
8pub use v2::AccumulatorValue;
9pub use v2::AccumulatorWrite;
10pub use v2::ChangedObject;
11pub use v2::IdOperation;
12pub use v2::ObjectIn;
13pub use v2::ObjectOut;
14pub use v2::TransactionEffectsV2;
15pub use v2::UnchangedConsensusKind;
16pub use v2::UnchangedConsensusObject;
17
18use crate::Address;
19use crate::Digest;
20use crate::execution_status::ExecutionStatus;
21use crate::object::Owner;
22use crate::object::Version;
23
24/// The output or effects of executing a transaction
25///
26/// # BCS
27///
28/// The BCS serialized form for this type is defined by the following ABNF:
29///
30/// ```text
31/// transaction-effects =  %x00 effects-v1
32///                     =/ %x01 effects-v2
33/// ```
34#[derive(Eq, PartialEq, Clone, Debug)]
35#[cfg_attr(
36    feature = "serde",
37    derive(serde_derive::Serialize, serde_derive::Deserialize)
38)]
39#[cfg_attr(feature = "proptest", derive(test_strategy::Arbitrary))]
40pub enum TransactionEffects {
41    V1(Box<TransactionEffectsV1>),
42    V2(Box<TransactionEffectsV2>),
43}
44
45impl TransactionEffects {
46    /// Return the status of the transaction.
47    pub fn status(&self) -> &ExecutionStatus {
48        match self {
49            TransactionEffects::V1(e) => e.status(),
50            TransactionEffects::V2(e) => e.status(),
51        }
52    }
53
54    /// Return the epoch in which this transaction was executed.
55    pub fn epoch(&self) -> u64 {
56        match self {
57            TransactionEffects::V1(e) => e.epoch(),
58            TransactionEffects::V2(e) => e.epoch(),
59        }
60    }
61
62    /// Return the gas cost summary of the transaction.
63    pub fn gas_summary(&self) -> &crate::gas::GasCostSummary {
64        match self {
65            TransactionEffects::V1(e) => e.gas_summary(),
66            TransactionEffects::V2(e) => e.gas_summary(),
67        }
68    }
69
70    /// Return the digest of the transaction that produced these effects.
71    pub fn transaction_digest(&self) -> &Digest {
72        match self {
73            TransactionEffects::V1(e) => &e.transaction_digest,
74            TransactionEffects::V2(e) => &e.transaction_digest,
75        }
76    }
77
78    /// Return the digest of the events emitted by this transaction, or `None`
79    /// if the transaction did not emit any events.
80    pub fn events_digest(&self) -> Option<&Digest> {
81        match self {
82            TransactionEffects::V1(e) => e.events_digest.as_ref(),
83            TransactionEffects::V2(e) => e.events_digest.as_ref(),
84        }
85    }
86
87    /// Iterate over the per-object changes reported by this transaction.
88    ///
89    /// Yields one [`ObjectChange`] for every object affected by the
90    /// transaction — created, mutated, unwrapped, deleted, wrapped, or
91    /// unwrapped-then-deleted. V1 effects drain in this order: `created`,
92    /// `mutated`, `unwrapped`, `deleted`, `unwrapped_then_deleted`,
93    /// `wrapped`. V2 effects drain in the order they appear in
94    /// `changed_objects`.
95    ///
96    /// Accumulator writes are not surfaced here, since they describe a
97    /// write to an accumulator rather than a change to an ordinary object.
98    ///
99    /// This is the "base" view used to verify a transaction's effects.
100    /// Narrower iterators (e.g. one that yields only [`ObjectReference`]s
101    /// written by this transaction, for building an OCS Merkle leaf set)
102    /// can be composed on top via `.filter(...)` / `.filter_map(...)`.
103    ///
104    /// V1 effects do not store input digests or input owners, so the
105    /// optional input fields are always `None` on V1.
106    ///
107    /// [`ObjectReference`]: crate::ObjectReference
108    pub fn object_changes(&self) -> impl Iterator<Item = ObjectChange<'_>> + '_ {
109        ObjectChanges::new(self)
110    }
111}
112
113/// A per-object change reported by a transaction's effects.
114///
115/// This is a unified, classified view over both V1 and V2 effects. Each
116/// variant carries the fields that are meaningful for that kind of change.
117///
118/// `Address`, `Digest`, and `Owner` fields are borrowed from the underlying
119/// effects rather than copied, so the enum is parameterised by the lifetime
120/// of the borrowed [`TransactionEffects`]. `Version` (a `u64`) is returned
121/// by value, since a reference to it would not be any smaller.
122///
123/// Fields that V1 effects do not store (input digests, input owners) are
124/// modelled as `Option`, and are always `None` for V1-sourced changes.
125///
126/// The enum is `#[non_exhaustive]` because the Sui protocol may add new kinds
127/// of object change in the future; pattern matches on it should include a
128/// catch-all arm.
129#[derive(Copy, Clone, Debug, PartialEq, Eq)]
130#[non_exhaustive]
131pub enum ObjectChange<'a> {
132    /// A new object was created by this transaction.
133    Created {
134        object_id: &'a Address,
135        output_version: Version,
136        output_digest: &'a Digest,
137        output_owner: &'a Owner,
138    },
139
140    /// An existing object's contents (and possibly owner) were modified by
141    /// this transaction.
142    Mutated {
143        object_id: &'a Address,
144        /// The object's version before execution.
145        ///
146        /// Always `Some` on V2 effects (read from `ObjectIn::Exist`). On V1
147        /// effects this is looked up in the effects' `modified_at_versions`
148        /// table, which may be absent for malformed effects; in that case
149        /// it is `None`.
150        input_version: Option<Version>,
151        /// The object's digest before execution. `None` on V1 effects.
152        input_digest: Option<&'a Digest>,
153        /// The object's owner before execution. `None` on V1 effects.
154        input_owner: Option<&'a Owner>,
155        output_version: Version,
156        output_digest: &'a Digest,
157        output_owner: &'a Owner,
158    },
159
160    /// A previously wrapped object was extracted (unwrapped) by this
161    /// transaction and now appears at the root level of the object store.
162    Unwrapped {
163        object_id: &'a Address,
164        output_version: Version,
165        output_digest: &'a Digest,
166        output_owner: &'a Owner,
167    },
168
169    /// An object was deleted by this transaction.
170    Deleted {
171        object_id: &'a Address,
172        /// The object's version before deletion. See [`ObjectChange::Mutated`]
173        /// for when this may be `None`.
174        input_version: Option<Version>,
175        input_digest: Option<&'a Digest>,
176        input_owner: Option<&'a Owner>,
177    },
178
179    /// An object was wrapped inside another object by this transaction (and
180    /// is therefore no longer at the root level of the object store).
181    Wrapped {
182        object_id: &'a Address,
183        /// The object's version before being wrapped. See
184        /// [`ObjectChange::Mutated`] for when this may be `None`.
185        input_version: Option<Version>,
186        input_digest: Option<&'a Digest>,
187        input_owner: Option<&'a Owner>,
188    },
189
190    /// An object was unwrapped and then deleted in the same transaction.
191    /// Neither the prior nor the new state is recorded.
192    UnwrappedThenDeleted { object_id: &'a Address },
193}
194
195impl<'a> ObjectChange<'a> {
196    /// The id of the object affected by this change.
197    pub fn object_id(&self) -> &'a Address {
198        match self {
199            Self::Created { object_id, .. }
200            | Self::Mutated { object_id, .. }
201            | Self::Unwrapped { object_id, .. }
202            | Self::Deleted { object_id, .. }
203            | Self::Wrapped { object_id, .. }
204            | Self::UnwrappedThenDeleted { object_id } => object_id,
205        }
206    }
207}
208
209/// Private iterator state behind [`TransactionEffects::object_changes`].
210///
211/// V1 drains in this order: `created`, `mutated`, `unwrapped`, `deleted`,
212/// `unwrapped_then_deleted`, `wrapped`. V2 drains in the order entries appear
213/// in `changed_objects`, skipping `(input_state, output_state, id_operation)`
214/// triples that do not match any known kind (forward-compatible with future
215/// protocol additions).
216enum ObjectChanges<'a> {
217    V1 {
218        /// Slice borrowed from `TransactionEffectsV1::modified_at_versions`.
219        /// Walking `mutated`, `deleted`, and `wrapped` entries needs the
220        /// prior version for each object id from here. The lookup is a
221        /// linear scan rather than a `BTreeMap` so that the iterator
222        /// allocates nothing — V1 effects typically carry only a handful of
223        /// modified-at entries, so the scan beats an upfront `BTreeMap`
224        /// build cost.
225        modified_at: &'a [ModifiedAtVersion],
226        created: std::slice::Iter<'a, ObjectReferenceWithOwner>,
227        mutated: std::slice::Iter<'a, ObjectReferenceWithOwner>,
228        unwrapped: std::slice::Iter<'a, ObjectReferenceWithOwner>,
229        deleted: std::slice::Iter<'a, crate::ObjectReference>,
230        unwrapped_then_deleted: std::slice::Iter<'a, crate::ObjectReference>,
231        wrapped: std::slice::Iter<'a, crate::ObjectReference>,
232    },
233    V2 {
234        lamport_version: Version,
235        inner: std::slice::Iter<'a, ChangedObject>,
236    },
237}
238
239/// Linear scan of `modified_at_versions` for the prior version of `id`.
240fn find_modified_at(modified_at: &[ModifiedAtVersion], id: &Address) -> Option<Version> {
241    modified_at
242        .iter()
243        .find(|m| &m.object_id == id)
244        .map(|m| m.version)
245}
246
247impl<'a> ObjectChanges<'a> {
248    fn new(effects: &'a TransactionEffects) -> Self {
249        match effects {
250            TransactionEffects::V1(e) => Self::V1 {
251                modified_at: &e.modified_at_versions,
252                created: e.created.iter(),
253                mutated: e.mutated.iter(),
254                unwrapped: e.unwrapped.iter(),
255                deleted: e.deleted.iter(),
256                unwrapped_then_deleted: e.unwrapped_then_deleted.iter(),
257                wrapped: e.wrapped.iter(),
258            },
259            TransactionEffects::V2(e) => Self::V2 {
260                lamport_version: e.lamport_version,
261                inner: e.changed_objects.iter(),
262            },
263        }
264    }
265}
266
267impl<'a> Iterator for ObjectChanges<'a> {
268    type Item = ObjectChange<'a>;
269
270    fn next(&mut self) -> Option<Self::Item> {
271        match self {
272            Self::V1 {
273                modified_at,
274                created,
275                mutated,
276                unwrapped,
277                deleted,
278                unwrapped_then_deleted,
279                wrapped,
280            } => {
281                if let Some(o) = created.next() {
282                    return Some(ObjectChange::Created {
283                        object_id: o.reference.object_id(),
284                        output_version: o.reference.version(),
285                        output_digest: o.reference.digest(),
286                        output_owner: &o.owner,
287                    });
288                }
289                if let Some(o) = mutated.next() {
290                    let object_id = o.reference.object_id();
291                    return Some(ObjectChange::Mutated {
292                        object_id,
293                        input_version: find_modified_at(modified_at, object_id),
294                        input_digest: None,
295                        input_owner: None,
296                        output_version: o.reference.version(),
297                        output_digest: o.reference.digest(),
298                        output_owner: &o.owner,
299                    });
300                }
301                if let Some(o) = unwrapped.next() {
302                    return Some(ObjectChange::Unwrapped {
303                        object_id: o.reference.object_id(),
304                        output_version: o.reference.version(),
305                        output_digest: o.reference.digest(),
306                        output_owner: &o.owner,
307                    });
308                }
309                if let Some(r) = deleted.next() {
310                    let object_id = r.object_id();
311                    return Some(ObjectChange::Deleted {
312                        object_id,
313                        input_version: find_modified_at(modified_at, object_id),
314                        input_digest: None,
315                        input_owner: None,
316                    });
317                }
318                if let Some(r) = unwrapped_then_deleted.next() {
319                    return Some(ObjectChange::UnwrappedThenDeleted {
320                        object_id: r.object_id(),
321                    });
322                }
323                if let Some(r) = wrapped.next() {
324                    let object_id = r.object_id();
325                    return Some(ObjectChange::Wrapped {
326                        object_id,
327                        input_version: find_modified_at(modified_at, object_id),
328                        input_digest: None,
329                        input_owner: None,
330                    });
331                }
332                None
333            }
334            Self::V2 {
335                lamport_version,
336                inner,
337            } => {
338                for changed in inner.by_ref() {
339                    if let Some(change) = v2_object_change(changed, *lamport_version) {
340                        return Some(change);
341                    }
342                }
343                None
344            }
345        }
346    }
347}
348
349/// `Owner::Immutable` materialised in static memory so that variants which
350/// require `&'a Owner` (e.g. `ObjectChange::Created::output_owner`) can hand
351/// out a borrow when classifying a Move package write — packages are always
352/// immutable but `ObjectOut::PackageWrite` does not carry an `Owner` field.
353static IMMUTABLE_OWNER: Owner = Owner::Immutable;
354
355/// Classify a single V2 [`ChangedObject`] into an [`ObjectChange`].
356///
357/// Returns `None` for `(input_state, output_state, id_operation)` triples
358/// that do not match any known kind (forward-compatibility with future
359/// protocol additions). All currently-defined combinations are handled.
360fn v2_object_change<'a>(
361    changed: &'a ChangedObject,
362    lamport_version: Version,
363) -> Option<ObjectChange<'a>> {
364    let object_id = &changed.object_id;
365    match (
366        &changed.input_state,
367        &changed.output_state,
368        changed.id_operation,
369    ) {
370        // Created: nothing before, an object now exists.
371        (ObjectIn::NotExist, ObjectOut::ObjectWrite { digest, owner }, IdOperation::Created) => {
372            Some(ObjectChange::Created {
373                object_id,
374                output_version: lamport_version,
375                output_digest: digest,
376                output_owner: owner,
377            })
378        }
379
380        // Created package: surfaced as `Created` with `Owner::Immutable`.
381        // `PackageWrite` carries its own `version` (packages start at 1 and
382        // increment by 1 on upgrade) rather than reusing `lamport_version`.
383        (ObjectIn::NotExist, ObjectOut::PackageWrite { version, digest }, IdOperation::Created) => {
384            Some(ObjectChange::Created {
385                object_id,
386                output_version: *version,
387                output_digest: digest,
388                output_owner: &IMMUTABLE_OWNER,
389            })
390        }
391
392        // Mutated: existed before, exists after (same id).
393        (
394            ObjectIn::Exist {
395                version: input_version,
396                digest: input_digest,
397                owner: input_owner,
398            },
399            ObjectOut::ObjectWrite { digest, owner },
400            IdOperation::None,
401        ) => Some(ObjectChange::Mutated {
402            object_id,
403            input_version: Some(*input_version),
404            input_digest: Some(input_digest),
405            input_owner: Some(input_owner),
406            output_version: lamport_version,
407            output_digest: digest,
408            output_owner: owner,
409        }),
410
411        // Upgraded package: surfaced as `Mutated` with `Owner::Immutable`.
412        // See the package-create arm above for why `version` comes from
413        // `PackageWrite` rather than `lamport_version`.
414        (
415            ObjectIn::Exist {
416                version: input_version,
417                digest: input_digest,
418                owner: input_owner,
419            },
420            ObjectOut::PackageWrite { version, digest },
421            IdOperation::None,
422        ) => Some(ObjectChange::Mutated {
423            object_id,
424            input_version: Some(*input_version),
425            input_digest: Some(input_digest),
426            input_owner: Some(input_owner),
427            output_version: *version,
428            output_digest: digest,
429            output_owner: &IMMUTABLE_OWNER,
430        }),
431
432        // Unwrapped: nothing at root before, an object now exists, id is not new.
433        (ObjectIn::NotExist, ObjectOut::ObjectWrite { digest, owner }, IdOperation::None) => {
434            Some(ObjectChange::Unwrapped {
435                object_id,
436                output_version: lamport_version,
437                output_digest: digest,
438                output_owner: owner,
439            })
440        }
441
442        // Deleted: existed before, gone now, id is deleted.
443        (
444            ObjectIn::Exist {
445                version: input_version,
446                digest: input_digest,
447                owner: input_owner,
448            },
449            ObjectOut::NotExist,
450            IdOperation::Deleted,
451        ) => Some(ObjectChange::Deleted {
452            object_id,
453            input_version: Some(*input_version),
454            input_digest: Some(input_digest),
455            input_owner: Some(input_owner),
456        }),
457
458        // Unwrapped then deleted: nothing before, nothing after, id is deleted.
459        (ObjectIn::NotExist, ObjectOut::NotExist, IdOperation::Deleted) => {
460            Some(ObjectChange::UnwrappedThenDeleted { object_id })
461        }
462
463        // Wrapped: existed at root before, no longer at root, id still alive.
464        (
465            ObjectIn::Exist {
466                version: input_version,
467                digest: input_digest,
468                owner: input_owner,
469            },
470            ObjectOut::NotExist,
471            IdOperation::None,
472        ) => Some(ObjectChange::Wrapped {
473            object_id,
474            input_version: Some(*input_version),
475            input_digest: Some(input_digest),
476            input_owner: Some(input_owner),
477        }),
478
479        // Accumulator writes are not surfaced as `ObjectChange`s; they
480        // describe a write to an accumulator rather than a change to an
481        // ordinary object.
482        (_, ObjectOut::AccumulatorWrite(_), _) => None,
483
484        // Future-protocol or otherwise unrecognised combination. Skip rather
485        // than crash so that newer effects formats degrade gracefully.
486        _ => None,
487    }
488}
489
490#[cfg(test)]
491mod tests {
492    use super::ObjectChange;
493    use super::ObjectIn;
494    use super::ObjectOut;
495    use super::TransactionEffects;
496
497    use base64ct::Base64;
498    use base64ct::Encoding;
499
500    #[cfg(target_arch = "wasm32")]
501    use wasm_bindgen_test::wasm_bindgen_test as test;
502
503    const GENESIS_EFFECTS: &str = include_str!("fixtures/genesis-transaction-effects");
504    const PYTH_WORMHOLE_V2: &str = include_str!("fixtures/pyth-wormhole-v2");
505
506    fn decode(fixture: &str) -> TransactionEffects {
507        let bytes = Base64::decode_vec(fixture.trim()).unwrap();
508        bcs::from_bytes(&bytes).unwrap()
509    }
510
511    #[test]
512    fn effects_fixtures() {
513        for fixture in [GENESIS_EFFECTS, PYTH_WORMHOLE_V2] {
514            let bytes = Base64::decode_vec(fixture.trim()).unwrap();
515            let fx: TransactionEffects = bcs::from_bytes(&bytes).unwrap();
516            assert_eq!(bcs::to_bytes(&fx).unwrap(), bytes);
517
518            let json = serde_json::to_string_pretty(&fx).unwrap();
519            println!("{json}");
520            assert_eq!(fx, serde_json::from_str(&json).unwrap());
521        }
522    }
523
524    /// V1 effects drain in the canonical order, the total count matches the
525    /// union of the six per-kind vectors, V1's lossy fields are honestly
526    /// `None`, and V1 never emits the V2-only variants.
527    #[test]
528    fn v1_object_changes_match_fields() {
529        let fx = decode(GENESIS_EFFECTS);
530        let TransactionEffects::V1(v1) = &fx else {
531            panic!("expected V1 fixture");
532        };
533
534        assert_eq!(fx.transaction_digest(), &v1.transaction_digest);
535        assert_eq!(fx.events_digest(), v1.events_digest.as_ref());
536
537        let changes: Vec<ObjectChange<'_>> = fx.object_changes().collect();
538        let expected_total = v1.created.len()
539            + v1.mutated.len()
540            + v1.unwrapped.len()
541            + v1.deleted.len()
542            + v1.unwrapped_then_deleted.len()
543            + v1.wrapped.len();
544        assert_eq!(changes.len(), expected_total);
545
546        // First `created.len()` entries must be Created.
547        for (i, change) in changes.iter().copied().take(v1.created.len()).enumerate() {
548            let expected = &v1.created[i];
549            match change {
550                ObjectChange::Created {
551                    object_id,
552                    output_version,
553                    output_digest,
554                    output_owner,
555                } => {
556                    assert_eq!(object_id, expected.reference.object_id());
557                    assert_eq!(output_version, expected.reference.version());
558                    assert_eq!(output_digest, expected.reference.digest());
559                    assert_eq!(*output_owner, expected.owner);
560                }
561                other => panic!("expected Created at index {i}, got {other:?}"),
562            }
563        }
564
565        // V1 never carries an input digest or input owner.
566        for change in changes.iter().copied() {
567            match change {
568                ObjectChange::Mutated {
569                    input_digest,
570                    input_owner,
571                    ..
572                }
573                | ObjectChange::Deleted {
574                    input_digest,
575                    input_owner,
576                    ..
577                }
578                | ObjectChange::Wrapped {
579                    input_digest,
580                    input_owner,
581                    ..
582                } => {
583                    assert!(input_digest.is_none(), "V1 has no input digest");
584                    assert!(input_owner.is_none(), "V1 has no input owner");
585                }
586                _ => {}
587            }
588        }
589    }
590
591    /// V2 effects carry full input state. Every ObjectWrite is paired with
592    /// the effects' `lamport_version`, and (when present) Mutated entries
593    /// round-trip the input version, digest, and owner as `Some`.
594    #[test]
595    fn v2_object_changes_match_fields() {
596        let fx = decode(PYTH_WORMHOLE_V2);
597        let TransactionEffects::V2(v2) = &fx else {
598            panic!("expected V2 fixture");
599        };
600
601        assert_eq!(fx.transaction_digest(), &v2.transaction_digest);
602        assert_eq!(fx.events_digest(), v2.events_digest.as_ref());
603
604        let changes: Vec<ObjectChange<'_>> = fx.object_changes().collect();
605        // Every non-accumulator entry in `changed_objects` should yield a change.
606        let expected_count = v2
607            .changed_objects
608            .iter()
609            .filter(|c| !matches!(c.output_state, ObjectOut::AccumulatorWrite(_)))
610            .count();
611        assert_eq!(changes.len(), expected_count);
612
613        let mut saw_object_write = false;
614        let raws = v2
615            .changed_objects
616            .iter()
617            .filter(|c| !matches!(c.output_state, ObjectOut::AccumulatorWrite(_)));
618        for (raw, change) in raws.zip(changes.iter().copied()) {
619            match (&raw.input_state, &raw.output_state) {
620                (
621                    ObjectIn::Exist {
622                        version,
623                        digest,
624                        owner,
625                    },
626                    ObjectOut::ObjectWrite { .. },
627                ) => {
628                    saw_object_write = true;
629                    let ObjectChange::Mutated {
630                        input_version,
631                        input_digest,
632                        input_owner,
633                        output_version,
634                        ..
635                    } = change
636                    else {
637                        panic!("expected Mutated, got {change:?}");
638                    };
639                    assert_eq!(input_version, Some(*version));
640                    assert_eq!(input_digest, Some(digest));
641                    assert_eq!(input_owner, Some(owner));
642                    assert_eq!(output_version, v2.lamport_version);
643                }
644                (ObjectIn::NotExist, ObjectOut::ObjectWrite { .. }) => {
645                    saw_object_write = true;
646                    match change {
647                        ObjectChange::Created { output_version, .. }
648                        | ObjectChange::Unwrapped { output_version, .. } => {
649                            assert_eq!(output_version, v2.lamport_version);
650                        }
651                        other => panic!("expected Created or Unwrapped, got {other:?}"),
652                    }
653                }
654                _ => {}
655            }
656        }
657        assert!(saw_object_write, "fixture should exercise ObjectWrite");
658    }
659
660    /// `object_id()` agrees with the variant's inner field on every change.
661    #[test]
662    fn object_change_object_id_covers_every_variant() {
663        let v1 = decode(GENESIS_EFFECTS);
664        let v2 = decode(PYTH_WORMHOLE_V2);
665
666        for change in v1.object_changes().chain(v2.object_changes()) {
667            let id_from_accessor = change.object_id();
668            let id_from_match = match change {
669                ObjectChange::Created { object_id, .. }
670                | ObjectChange::Mutated { object_id, .. }
671                | ObjectChange::Unwrapped { object_id, .. }
672                | ObjectChange::Deleted { object_id, .. }
673                | ObjectChange::Wrapped { object_id, .. }
674                | ObjectChange::UnwrappedThenDeleted { object_id } => object_id,
675            };
676            assert_eq!(id_from_accessor, id_from_match);
677        }
678    }
679}