Skip to main content

sui_types/
balance_change.rs

1// Copyright (c) Mysten Labs, Inc.
2// SPDX-License-Identifier: Apache-2.0
3
4use crate::balance::Balance;
5use crate::base_types::SuiAddress;
6use crate::coin::Coin;
7use crate::effects::{
8    AccumulatorOperation, AccumulatorValue, TransactionEffects, TransactionEffectsAPI,
9};
10use crate::full_checkpoint_content::ObjectSet;
11use crate::object::Object;
12use crate::object::Owner;
13use crate::storage::ObjectKey;
14use move_core_types::language_storage::TypeTag;
15
16#[derive(Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq, PartialOrd, Ord)]
17pub struct BalanceChange {
18    /// Owner of the balance change
19    pub address: SuiAddress,
20
21    /// Type of the Coin
22    pub coin_type: TypeTag,
23
24    /// The amount indicate the balance value changes.
25    ///
26    /// A negative amount means spending coin value and positive means receiving coin value.
27    pub amount: i128,
28}
29
30#[derive(Clone, PartialEq, Eq, PartialOrd, Ord)]
31pub struct DetailedBalanceChange {
32    /// Owner of the balance change
33    pub address: SuiAddress,
34
35    /// Type of the Coin
36    pub coin_type: TypeTag,
37
38    /// The amount indicates the balance value changes from coins.
39    ///
40    /// A negative amount means spending coin value and positive means receiving coin value.
41    pub coin_amount: i128,
42
43    /// The amount indicates the balance value changes from address balances.
44    ///
45    /// A negative amount means spending coin value and positive means receiving coin value.
46    pub address_amount: i128,
47}
48
49impl From<DetailedBalanceChange> for BalanceChange {
50    fn from(value: DetailedBalanceChange) -> Self {
51        Self {
52            address: value.address,
53            coin_type: value.coin_type,
54            amount: value.coin_amount + value.address_amount,
55        }
56    }
57}
58
59impl std::fmt::Debug for BalanceChange {
60    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
61        f.debug_struct("BalanceChange")
62            .field("address", &self.address)
63            .field("coin_type", &self.coin_type.to_canonical_string(true))
64            .field("amount", &self.amount)
65            .finish()
66    }
67}
68
69fn coins<'a, I>(objects: I) -> impl Iterator<Item = (&'a SuiAddress, TypeTag, u64)> + 'a
70where
71    I: IntoIterator<Item = &'a Object> + 'a,
72{
73    objects.into_iter().filter_map(|object| {
74        // Balance changes are an address-level view: only coins held
75        // directly by an address count, with `ConsensusAddressOwner`
76        // (the consensus-sequenced form of address ownership) combined
77        // into the same address. Coins held by objects (e.g. in dynamic
78        // fields) are deliberately excluded -- the parent object's id
79        // reinterpreted as an address is not a meaningful balance
80        // owner, and the balance indexes
81        // (`sui-indexer-alt-consistent-store`, `sui-rpc-store`) track
82        // only address-held coins, so including them here would let a
83        // transaction's reported changes disagree with the balances
84        // they change.
85        let address = match object.owner() {
86            Owner::AddressOwner(sui_address)
87            | Owner::ConsensusAddressOwner {
88                owner: sui_address, ..
89            } => sui_address,
90            // TODO(Party WIP)
91            Owner::Party { .. } => todo!("Party WIP"),
92            Owner::ObjectOwner(_) | Owner::Shared { .. } | Owner::Immutable => return None,
93        };
94        let (coin_type, balance) = Coin::extract_balance_if_coin(object).ok().flatten()?;
95        Some((address, coin_type, balance))
96    })
97}
98
99/// Extract the signed balance change from a single accumulator event, if it
100/// has a `Balance<T>` type and an integer value.
101fn signed_balance_change_from_event(
102    event: &crate::accumulator_event::AccumulatorEvent,
103) -> Option<(SuiAddress, TypeTag, i128)> {
104    let ty = &event.write.address.ty;
105    // Only process events with Balance<T> types
106    let coin_type = Balance::maybe_get_balance_type_param(ty)?;
107
108    let amount = match &event.write.value {
109        AccumulatorValue::Integer(v) => *v as i128,
110        // IntegerTuple and EventDigest are not balance-related
111        AccumulatorValue::IntegerTuple(_, _) | AccumulatorValue::EventDigest(_) => {
112            return None;
113        }
114    };
115
116    // Convert operation to signed amount: Split means balance decreased, Merge means increased
117    let signed_amount = match event.write.operation {
118        AccumulatorOperation::Split => -amount,
119        AccumulatorOperation::Merge => amount,
120    };
121
122    Some((event.write.address.address, coin_type, signed_amount))
123}
124
125/// Extract balance changes from a slice of accumulator events.
126pub fn signed_balance_changes_from_events(
127    events: &[crate::accumulator_event::AccumulatorEvent],
128) -> impl Iterator<Item = (SuiAddress, TypeTag, i128)> + '_ {
129    events.iter().filter_map(signed_balance_change_from_event)
130}
131
132/// Extract balance changes from accumulator events in transaction effects.
133pub fn address_balance_changes_from_accumulator_events(
134    effects: &TransactionEffects,
135) -> impl Iterator<Item = (SuiAddress, TypeTag, i128)> {
136    effects
137        .accumulator_events()
138        .into_iter()
139        .filter_map(|ref event| signed_balance_change_from_event(event))
140}
141
142pub fn derive_balance_changes(
143    effects: &TransactionEffects,
144    input_objects: &[Object],
145    output_objects: &[Object],
146) -> Vec<BalanceChange> {
147    derive_detailed_balance_changes(effects, input_objects, output_objects)
148        .into_iter()
149        // Filter out when coin and address changes net to 0
150        .filter_map(|detailed_change| {
151            let change = BalanceChange::from(detailed_change);
152            if change.amount == 0 {
153                None
154            } else {
155                Some(change)
156            }
157        })
158        .collect()
159}
160
161pub fn derive_detailed_balance_changes(
162    effects: &TransactionEffects,
163    input_objects: &[Object],
164    output_objects: &[Object],
165) -> Vec<DetailedBalanceChange> {
166    derive_detailed_balance_changes_inner(effects, input_objects, output_objects)
167}
168
169/// `ObjectSet`-keyed sibling of [`derive_detailed_balance_changes`]: looks
170/// up input and output objects in `objects` rather than taking pre-collected
171/// `&[Object]` slices, so callers that already hold an `ObjectSet` don't
172/// have to materialize separate `Vec<Object>`s.
173pub fn derive_detailed_balance_changes_2(
174    effects: &TransactionEffects,
175    objects: &ObjectSet,
176) -> Vec<DetailedBalanceChange> {
177    let input_objects = effects
178        .modified_at_versions()
179        .into_iter()
180        .filter_map(|(object_id, version)| objects.get(&ObjectKey(object_id, version)));
181    let output_objects = effects
182        .all_changed_objects()
183        .into_iter()
184        .filter_map(|(object_ref, _owner, _kind)| objects.get(&object_ref.into()));
185
186    derive_detailed_balance_changes_inner(effects, input_objects, output_objects)
187}
188
189/// Shared implementation behind [`derive_detailed_balance_changes`] and
190/// [`derive_detailed_balance_changes_2`]. Generic over the input/output
191/// object iterators so callers can stream `&Object` references straight from
192/// an [`ObjectSet`] without cloning into intermediate `Vec<Object>`s.
193fn derive_detailed_balance_changes_inner<'a, I, O>(
194    effects: &TransactionEffects,
195    input_objects: I,
196    output_objects: O,
197) -> Vec<DetailedBalanceChange>
198where
199    I: IntoIterator<Item = &'a Object> + 'a,
200    O: IntoIterator<Item = &'a Object> + 'a,
201{
202    // 1. subtract all input coins
203    let balances = coins(input_objects).fold(
204        std::collections::BTreeMap::<_, (i128, i128)>::new(),
205        |mut acc, (address, coin_type, balance)| {
206            acc.entry((*address, coin_type)).or_default().0 -= balance as i128;
207            acc
208        },
209    );
210
211    // 2. add all mutated/output coins
212    let balances =
213        coins(output_objects).fold(balances, |mut acc, (address, coin_type, balance)| {
214            acc.entry((*address, coin_type)).or_default().0 += balance as i128;
215            acc
216        });
217
218    // 3. add address balance changes from accumulator events
219    let balances = address_balance_changes_from_accumulator_events(effects).fold(
220        balances,
221        |mut acc, (address, coin_type, signed_amount)| {
222            acc.entry((address, coin_type)).or_default().1 += signed_amount;
223            acc
224        },
225    );
226
227    balances
228        .into_iter()
229        .filter_map(|((address, coin_type), (coin_amount, address_amount))| {
230            if coin_amount == 0 && address_amount == 0 {
231                return None;
232            }
233
234            Some(DetailedBalanceChange {
235                address,
236                coin_type,
237                coin_amount,
238                address_amount,
239            })
240        })
241        .collect()
242}
243
244pub fn derive_balance_changes_2(
245    effects: &TransactionEffects,
246    objects: &ObjectSet,
247) -> Vec<BalanceChange> {
248    derive_detailed_balance_changes_2(effects, objects)
249        .into_iter()
250        // Filter out when coin and address changes net to 0
251        .filter_map(|detailed_change| {
252            let change = BalanceChange::from(detailed_change);
253            if change.amount == 0 {
254                None
255            } else {
256                Some(change)
257            }
258        })
259        .collect()
260}
261
262#[cfg(test)]
263mod tests {
264    use super::*;
265    use crate::accumulator_root::AccumulatorValue as AccumulatorValueRoot;
266    use crate::balance::Balance;
267    use crate::base_types::ObjectID;
268    use crate::digests::TransactionDigest;
269    use crate::effects::{
270        AccumulatorAddress, AccumulatorOperation, AccumulatorValue, AccumulatorWriteV1,
271        EffectsObjectChange, IDOperation, ObjectIn, ObjectOut, TransactionEffects,
272    };
273    use crate::execution_status::ExecutionStatus;
274    use crate::gas::GasCostSummary;
275    use move_core_types::language_storage::TypeTag;
276
277    fn create_effects_with_accumulator_writes(
278        writes: Vec<(ObjectID, AccumulatorWriteV1)>,
279    ) -> TransactionEffects {
280        let changed_objects = writes
281            .into_iter()
282            .map(|(id, write)| {
283                (
284                    id,
285                    EffectsObjectChange {
286                        input_state: ObjectIn::NotExist,
287                        output_state: ObjectOut::AccumulatorWriteV1(write),
288                        id_operation: IDOperation::None,
289                    },
290                )
291            })
292            .collect();
293
294        TransactionEffects::new_from_execution_v2(
295            ExecutionStatus::Success,
296            0,
297            GasCostSummary::default(),
298            vec![],
299            TransactionDigest::random(),
300            crate::base_types::SequenceNumber::new(),
301            changed_objects,
302            None,
303            None,
304            vec![],
305        )
306    }
307
308    fn sui_balance_type() -> TypeTag {
309        Balance::type_tag("0x2::sui::SUI".parse().unwrap())
310    }
311
312    fn custom_coin_type() -> TypeTag {
313        "0xabc::my_coin::MY_COIN".parse().unwrap()
314    }
315
316    fn custom_balance_type() -> TypeTag {
317        Balance::type_tag(custom_coin_type())
318    }
319
320    fn get_accumulator_obj_id(address: SuiAddress, balance_type: &TypeTag) -> ObjectID {
321        *AccumulatorValueRoot::get_field_id(address, balance_type)
322            .unwrap()
323            .inner()
324    }
325
326    #[test]
327    fn test_derive_balance_changes_with_no_accumulator_events() {
328        let effects = create_effects_with_accumulator_writes(vec![]);
329        let result = derive_balance_changes(&effects, &[], &[]);
330        assert!(result.is_empty());
331    }
332
333    #[test]
334    fn test_derive_balance_changes_with_split_accumulator_event() {
335        let address = SuiAddress::random_for_testing_only();
336        let balance_type = sui_balance_type();
337        let obj_id = get_accumulator_obj_id(address, &balance_type);
338        let write = AccumulatorWriteV1 {
339            address: AccumulatorAddress::new(address, balance_type),
340            operation: AccumulatorOperation::Split,
341            value: AccumulatorValue::Integer(1000),
342        };
343        let effects = create_effects_with_accumulator_writes(vec![(obj_id, write)]);
344
345        let result = derive_balance_changes(&effects, &[], &[]);
346
347        assert_eq!(result.len(), 1);
348        assert_eq!(result[0].address, address);
349        assert_eq!(
350            result[0].coin_type,
351            "0x2::sui::SUI".parse::<TypeTag>().unwrap()
352        );
353        assert_eq!(result[0].amount, -1000);
354    }
355
356    #[test]
357    fn test_derive_balance_changes_with_merge_accumulator_event() {
358        let address = SuiAddress::random_for_testing_only();
359        let balance_type = sui_balance_type();
360        let obj_id = get_accumulator_obj_id(address, &balance_type);
361        let write = AccumulatorWriteV1 {
362            address: AccumulatorAddress::new(address, balance_type),
363            operation: AccumulatorOperation::Merge,
364            value: AccumulatorValue::Integer(500),
365        };
366        let effects = create_effects_with_accumulator_writes(vec![(obj_id, write)]);
367
368        let result = derive_balance_changes(&effects, &[], &[]);
369
370        assert_eq!(result.len(), 1);
371        assert_eq!(result[0].address, address);
372        assert_eq!(result[0].amount, 500);
373    }
374
375    #[test]
376    fn test_derive_balance_changes_with_multiple_addresses() {
377        let address1 = SuiAddress::random_for_testing_only();
378        let address2 = SuiAddress::random_for_testing_only();
379        let balance_type = sui_balance_type();
380
381        let obj_id1 = get_accumulator_obj_id(address1, &balance_type);
382        let obj_id2 = get_accumulator_obj_id(address2, &balance_type);
383
384        let write1 = AccumulatorWriteV1 {
385            address: AccumulatorAddress::new(address1, balance_type.clone()),
386            operation: AccumulatorOperation::Split,
387            value: AccumulatorValue::Integer(1000),
388        };
389        let write2 = AccumulatorWriteV1 {
390            address: AccumulatorAddress::new(address2, balance_type),
391            operation: AccumulatorOperation::Merge,
392            value: AccumulatorValue::Integer(1000),
393        };
394
395        let effects =
396            create_effects_with_accumulator_writes(vec![(obj_id1, write1), (obj_id2, write2)]);
397
398        let result = derive_balance_changes(&effects, &[], &[]);
399
400        assert_eq!(result.len(), 2);
401        let addr1_change = result.iter().find(|c| c.address == address1).unwrap();
402        let addr2_change = result.iter().find(|c| c.address == address2).unwrap();
403        assert_eq!(addr1_change.amount, -1000);
404        assert_eq!(addr2_change.amount, 1000);
405    }
406
407    #[test]
408    fn test_derive_balance_changes_with_custom_coin_type() {
409        let address = SuiAddress::random_for_testing_only();
410        let balance_type = custom_balance_type();
411        let obj_id = get_accumulator_obj_id(address, &balance_type);
412        let write = AccumulatorWriteV1 {
413            address: AccumulatorAddress::new(address, balance_type),
414            operation: AccumulatorOperation::Split,
415            value: AccumulatorValue::Integer(2000),
416        };
417        let effects = create_effects_with_accumulator_writes(vec![(obj_id, write)]);
418
419        let result = derive_balance_changes(&effects, &[], &[]);
420
421        assert_eq!(result.len(), 1);
422        assert_eq!(result[0].address, address);
423        assert_eq!(result[0].coin_type, custom_coin_type());
424        assert_eq!(result[0].amount, -2000);
425    }
426
427    #[test]
428    fn test_derive_balance_changes_ignores_non_balance_types() {
429        let address = SuiAddress::random_for_testing_only();
430        // Use a non-Balance type - use a random ObjectID since we can't derive it
431        let non_balance_type: TypeTag = "0x2::accumulator_settlement::EventStreamHead"
432            .parse()
433            .unwrap();
434        let write = AccumulatorWriteV1 {
435            address: AccumulatorAddress::new(address, non_balance_type),
436            operation: AccumulatorOperation::Split,
437            value: AccumulatorValue::Integer(1000),
438        };
439        let effects = create_effects_with_accumulator_writes(vec![(ObjectID::random(), write)]);
440
441        let result = derive_balance_changes(&effects, &[], &[]);
442
443        assert!(result.is_empty());
444    }
445
446    #[test]
447    fn test_derive_balance_changes_ignores_event_digest_values() {
448        use crate::digests::Digest;
449        use nonempty::nonempty;
450
451        let address = SuiAddress::random_for_testing_only();
452        let balance_type = sui_balance_type();
453        let obj_id = get_accumulator_obj_id(address, &balance_type);
454        let write = AccumulatorWriteV1 {
455            address: AccumulatorAddress::new(address, balance_type),
456            operation: AccumulatorOperation::Merge,
457            value: AccumulatorValue::EventDigest(nonempty![(0, Digest::random())]),
458        };
459        let effects = create_effects_with_accumulator_writes(vec![(obj_id, write)]);
460
461        let result = derive_balance_changes(&effects, &[], &[]);
462
463        assert!(result.is_empty());
464    }
465
466    #[test]
467    fn test_derive_balance_changes_accumulator_zero_amount_filtered() {
468        // Test that a zero amount accumulator event results in no balance change
469        let address = SuiAddress::random_for_testing_only();
470        let balance_type = sui_balance_type();
471        let obj_id = get_accumulator_obj_id(address, &balance_type);
472
473        let write = AccumulatorWriteV1 {
474            address: AccumulatorAddress::new(address, balance_type),
475            operation: AccumulatorOperation::Split,
476            value: AccumulatorValue::Integer(0),
477        };
478        let effects = create_effects_with_accumulator_writes(vec![(obj_id, write)]);
479
480        let result = derive_balance_changes(&effects, &[], &[]);
481
482        // Zero amount should be filtered out
483        assert!(result.is_empty());
484    }
485
486    #[test]
487    fn test_derive_balance_changes_2_with_accumulator_events() {
488        let address = SuiAddress::random_for_testing_only();
489        let balance_type = sui_balance_type();
490        let obj_id = get_accumulator_obj_id(address, &balance_type);
491        let write = AccumulatorWriteV1 {
492            address: AccumulatorAddress::new(address, balance_type),
493            operation: AccumulatorOperation::Split,
494            value: AccumulatorValue::Integer(1000),
495        };
496        let effects = create_effects_with_accumulator_writes(vec![(obj_id, write)]);
497
498        let objects = crate::full_checkpoint_content::ObjectSet::default();
499        let result = derive_balance_changes_2(&effects, &objects);
500
501        assert_eq!(result.len(), 1);
502        assert_eq!(result[0].address, address);
503        assert_eq!(
504            result[0].coin_type,
505            "0x2::sui::SUI".parse::<TypeTag>().unwrap()
506        );
507        assert_eq!(result[0].amount, -1000);
508    }
509
510    // Tests combining coin objects with accumulator events
511
512    fn create_gas_coin_object(owner: SuiAddress, value: u64) -> Object {
513        create_gas_coin_object_with_owner(Owner::AddressOwner(owner), value)
514    }
515
516    fn create_gas_coin_object_with_owner(owner: Owner, value: u64) -> Object {
517        use crate::base_types::SequenceNumber;
518        use crate::object::MoveObject;
519
520        let obj_id = ObjectID::random();
521        let move_obj = MoveObject::new_gas_coin(SequenceNumber::new(), obj_id, value);
522        Object::new_move(move_obj, owner, TransactionDigest::random())
523    }
524
525    fn create_custom_coin_object(owner: SuiAddress, coin_type: TypeTag, value: u64) -> Object {
526        use crate::base_types::SequenceNumber;
527        use crate::object::MoveObject;
528
529        let obj_id = ObjectID::random();
530        let move_obj = MoveObject::new_coin(coin_type, SequenceNumber::new(), obj_id, value);
531        Object::new_move(
532            move_obj,
533            Owner::AddressOwner(owner),
534            TransactionDigest::random(),
535        )
536    }
537
538    #[test]
539    fn test_derive_balance_changes_with_coin_objects_only() {
540        let address = SuiAddress::random_for_testing_only();
541
542        // Create input coin with 5000 SUI
543        let input_coin = create_gas_coin_object(address, 5000);
544        // Create output coin with 3000 SUI (spent 2000)
545        let output_coin = create_gas_coin_object(address, 3000);
546
547        let effects = create_effects_with_accumulator_writes(vec![]);
548
549        let result = derive_balance_changes(&effects, &[input_coin], &[output_coin]);
550
551        assert_eq!(result.len(), 1);
552        assert_eq!(result[0].address, address);
553        assert_eq!(result[0].amount, -2000); // 3000 - 5000 = -2000
554    }
555
556    /// Coins held by objects (dynamic fields, transfer-to-object) are
557    /// excluded from balance changes: moving a coin from an address
558    /// into an object's custody reports only the address's spend, and
559    /// nothing is ever attributed to the parent object's id
560    /// reinterpreted as an address. This pins the address-level
561    /// contract the balance indexes share.
562    #[test]
563    fn test_derive_balance_changes_excludes_object_owned_coins() {
564        let sender = SuiAddress::random_for_testing_only();
565        let parent = ObjectID::random();
566
567        // The sender's coin moves into a dynamic field of `parent`.
568        let input_coin = create_gas_coin_object(sender, 3000);
569        let output_coin =
570            create_gas_coin_object_with_owner(Owner::ObjectOwner(parent.into()), 3000);
571
572        let effects = create_effects_with_accumulator_writes(vec![]);
573        let result = derive_balance_changes(&effects, &[input_coin], &[output_coin]);
574
575        assert_eq!(result.len(), 1);
576        assert_eq!(result[0].address, sender);
577        assert_eq!(result[0].amount, -3000);
578    }
579
580    /// Consensus-address-owned coins are combined with address-owned
581    /// coins for the same address: converting between the two forms of
582    /// address custody is not a balance change.
583    #[test]
584    fn test_derive_balance_changes_combines_consensus_address_owner() {
585        use crate::base_types::SequenceNumber;
586
587        let address = SuiAddress::random_for_testing_only();
588
589        let input_coin = create_gas_coin_object(address, 4000);
590        let output_coin = create_gas_coin_object_with_owner(
591            Owner::ConsensusAddressOwner {
592                start_version: SequenceNumber::new(),
593                owner: address,
594            },
595            4000,
596        );
597
598        let effects = create_effects_with_accumulator_writes(vec![]);
599        let result = derive_balance_changes(&effects, &[input_coin], &[output_coin]);
600
601        assert!(
602            result.is_empty(),
603            "moving a coin between fastpath and consensus address custody \
604             is not a balance change"
605        );
606    }
607
608    #[test]
609    fn test_derive_balance_changes_coin_transfer_between_addresses() {
610        let sender = SuiAddress::random_for_testing_only();
611        let receiver = SuiAddress::random_for_testing_only();
612
613        // Sender has 10000 SUI initially
614        let input_coin = create_gas_coin_object(sender, 10000);
615        // After transfer: sender keeps 7000, receiver gets 3000
616        let output_coin_sender = create_gas_coin_object(sender, 7000);
617        let output_coin_receiver = create_gas_coin_object(receiver, 3000);
618
619        let effects = create_effects_with_accumulator_writes(vec![]);
620
621        let result = derive_balance_changes(
622            &effects,
623            &[input_coin],
624            &[output_coin_sender, output_coin_receiver],
625        );
626
627        assert_eq!(result.len(), 2);
628        let sender_change = result.iter().find(|c| c.address == sender).unwrap();
629        let receiver_change = result.iter().find(|c| c.address == receiver).unwrap();
630        assert_eq!(sender_change.amount, -3000); // 7000 - 10000 = -3000
631        assert_eq!(receiver_change.amount, 3000); // 3000 - 0 = 3000
632    }
633
634    #[test]
635    fn test_derive_balance_changes_combines_coins_and_accumulator_events() {
636        let address = SuiAddress::random_for_testing_only();
637        let balance_type = sui_balance_type();
638        let obj_id = get_accumulator_obj_id(address, &balance_type);
639
640        // Coin: spent 2000 SUI (5000 -> 3000)
641        let input_coin = create_gas_coin_object(address, 5000);
642        let output_coin = create_gas_coin_object(address, 3000);
643
644        // Accumulator: received 500 SUI via address balance
645        let write = AccumulatorWriteV1 {
646            address: AccumulatorAddress::new(address, balance_type),
647            operation: AccumulatorOperation::Merge,
648            value: AccumulatorValue::Integer(500),
649        };
650        let effects = create_effects_with_accumulator_writes(vec![(obj_id, write)]);
651
652        let result = derive_balance_changes(&effects, &[input_coin], &[output_coin]);
653
654        // Net change: -2000 (coins) + 500 (accumulator merge) = -1500
655        assert_eq!(result.len(), 1);
656        assert_eq!(result[0].address, address);
657        assert_eq!(result[0].amount, -1500);
658    }
659
660    #[test]
661    fn test_derive_balance_changes_coins_and_accumulator_different_addresses() {
662        let coin_owner = SuiAddress::random_for_testing_only();
663        let accumulator_owner = SuiAddress::random_for_testing_only();
664        let balance_type = sui_balance_type();
665        let obj_id = get_accumulator_obj_id(accumulator_owner, &balance_type);
666
667        // Coin owner spends 1000 SUI
668        let input_coin = create_gas_coin_object(coin_owner, 5000);
669        let output_coin = create_gas_coin_object(coin_owner, 4000);
670
671        // Accumulator owner receives 2000 SUI
672        let write = AccumulatorWriteV1 {
673            address: AccumulatorAddress::new(accumulator_owner, balance_type),
674            operation: AccumulatorOperation::Merge,
675            value: AccumulatorValue::Integer(2000),
676        };
677        let effects = create_effects_with_accumulator_writes(vec![(obj_id, write)]);
678
679        let result = derive_balance_changes(&effects, &[input_coin], &[output_coin]);
680
681        assert_eq!(result.len(), 2);
682        let coin_change = result.iter().find(|c| c.address == coin_owner).unwrap();
683        let acc_change = result
684            .iter()
685            .find(|c| c.address == accumulator_owner)
686            .unwrap();
687        assert_eq!(coin_change.amount, -1000);
688        assert_eq!(acc_change.amount, 2000);
689    }
690
691    #[test]
692    fn test_derive_balance_changes_coins_and_accumulator_net_to_zero() {
693        let address = SuiAddress::random_for_testing_only();
694        let balance_type = sui_balance_type();
695        let obj_id = get_accumulator_obj_id(address, &balance_type);
696
697        // Coin: spent 1000 SUI (5000 -> 4000)
698        let input_coin = create_gas_coin_object(address, 5000);
699        let output_coin = create_gas_coin_object(address, 4000);
700
701        // Accumulator: received exactly 1000 SUI - perfectly offsets coin spend
702        let write = AccumulatorWriteV1 {
703            address: AccumulatorAddress::new(address, balance_type),
704            operation: AccumulatorOperation::Merge,
705            value: AccumulatorValue::Integer(1000),
706        };
707        let effects = create_effects_with_accumulator_writes(vec![(obj_id, write)]);
708
709        let result = derive_balance_changes(&effects, &[input_coin], &[output_coin]);
710
711        // Net change: -1000 (coins) + 1000 (accumulator) = 0, should be filtered out
712        assert!(result.is_empty());
713    }
714
715    #[test]
716    fn test_derive_balance_changes_different_coin_types() {
717        let address = SuiAddress::random_for_testing_only();
718        let custom_type = custom_coin_type();
719        let custom_balance = custom_balance_type();
720        let obj_id = get_accumulator_obj_id(address, &custom_balance);
721
722        // SUI coin: spent 1000
723        let sui_input = create_gas_coin_object(address, 5000);
724        let sui_output = create_gas_coin_object(address, 4000);
725
726        // Custom coin: received 500 via coins
727        let custom_output = create_custom_coin_object(address, custom_type.clone(), 500);
728
729        // Custom coin: also received 300 via accumulator
730        let write = AccumulatorWriteV1 {
731            address: AccumulatorAddress::new(address, custom_balance),
732            operation: AccumulatorOperation::Merge,
733            value: AccumulatorValue::Integer(300),
734        };
735        let effects = create_effects_with_accumulator_writes(vec![(obj_id, write)]);
736
737        let result = derive_balance_changes(&effects, &[sui_input], &[sui_output, custom_output]);
738
739        assert_eq!(result.len(), 2);
740
741        let sui_change = result
742            .iter()
743            .find(|c| c.coin_type == "0x2::sui::SUI".parse::<TypeTag>().unwrap())
744            .unwrap();
745        let custom_change = result.iter().find(|c| c.coin_type == custom_type).unwrap();
746
747        assert_eq!(sui_change.amount, -1000);
748        assert_eq!(custom_change.amount, 800); // 500 (coin) + 300 (accumulator)
749    }
750
751    #[test]
752    fn test_derive_balance_changes_accumulator_split_with_coins() {
753        let sender = SuiAddress::random_for_testing_only();
754        let receiver = SuiAddress::random_for_testing_only();
755        let balance_type = sui_balance_type();
756        let sender_obj_id = get_accumulator_obj_id(sender, &balance_type);
757        let receiver_obj_id = get_accumulator_obj_id(receiver, &balance_type.clone());
758
759        // Sender sends coins: 5000 -> 4000 (sends 1000)
760        let input_coin = create_gas_coin_object(sender, 5000);
761        let output_coin = create_gas_coin_object(sender, 4000);
762
763        // Sender also sends 500 via address balance (Merge)
764        let sender_write = AccumulatorWriteV1 {
765            address: AccumulatorAddress::new(sender, balance_type.clone()),
766            operation: AccumulatorOperation::Split,
767            value: AccumulatorValue::Integer(500),
768        };
769        // Receiver receives 500 via address balance (Split)
770        let receiver_write = AccumulatorWriteV1 {
771            address: AccumulatorAddress::new(receiver, balance_type),
772            operation: AccumulatorOperation::Merge,
773            value: AccumulatorValue::Integer(500),
774        };
775        let effects = create_effects_with_accumulator_writes(vec![
776            (sender_obj_id, sender_write),
777            (receiver_obj_id, receiver_write),
778        ]);
779
780        let result = derive_balance_changes(&effects, &[input_coin], &[output_coin]);
781
782        assert_eq!(result.len(), 2);
783        let sender_change = result.iter().find(|c| c.address == sender).unwrap();
784        let receiver_change = result.iter().find(|c| c.address == receiver).unwrap();
785
786        // Sender: -1000 (coins) + -500 (accumulator merge) = -1500
787        assert_eq!(sender_change.amount, -1500);
788        // Receiver: +500 (accumulator split)
789        assert_eq!(receiver_change.amount, 500);
790    }
791}