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