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            TransactionDigest::random(),
291            crate::base_types::SequenceNumber::new(),
292            changed_objects,
293            None,
294            None,
295            vec![],
296        )
297    }
298
299    fn sui_balance_type() -> TypeTag {
300        Balance::type_tag("0x2::sui::SUI".parse().unwrap())
301    }
302
303    fn custom_coin_type() -> TypeTag {
304        "0xabc::my_coin::MY_COIN".parse().unwrap()
305    }
306
307    fn custom_balance_type() -> TypeTag {
308        Balance::type_tag(custom_coin_type())
309    }
310
311    fn get_accumulator_obj_id(address: SuiAddress, balance_type: &TypeTag) -> ObjectID {
312        *AccumulatorValueRoot::get_field_id(address, balance_type)
313            .unwrap()
314            .inner()
315    }
316
317    #[test]
318    fn test_derive_balance_changes_with_no_accumulator_events() {
319        let effects = create_effects_with_accumulator_writes(vec![]);
320        let result = derive_balance_changes(&effects, &[], &[]);
321        assert!(result.is_empty());
322    }
323
324    #[test]
325    fn test_derive_balance_changes_with_split_accumulator_event() {
326        let address = SuiAddress::random_for_testing_only();
327        let balance_type = sui_balance_type();
328        let obj_id = get_accumulator_obj_id(address, &balance_type);
329        let write = AccumulatorWriteV1 {
330            address: AccumulatorAddress::new(address, balance_type),
331            operation: AccumulatorOperation::Split,
332            value: AccumulatorValue::Integer(1000),
333        };
334        let effects = create_effects_with_accumulator_writes(vec![(obj_id, write)]);
335
336        let result = derive_balance_changes(&effects, &[], &[]);
337
338        assert_eq!(result.len(), 1);
339        assert_eq!(result[0].address, address);
340        assert_eq!(
341            result[0].coin_type,
342            "0x2::sui::SUI".parse::<TypeTag>().unwrap()
343        );
344        assert_eq!(result[0].amount, -1000);
345    }
346
347    #[test]
348    fn test_derive_balance_changes_with_merge_accumulator_event() {
349        let address = SuiAddress::random_for_testing_only();
350        let balance_type = sui_balance_type();
351        let obj_id = get_accumulator_obj_id(address, &balance_type);
352        let write = AccumulatorWriteV1 {
353            address: AccumulatorAddress::new(address, balance_type),
354            operation: AccumulatorOperation::Merge,
355            value: AccumulatorValue::Integer(500),
356        };
357        let effects = create_effects_with_accumulator_writes(vec![(obj_id, write)]);
358
359        let result = derive_balance_changes(&effects, &[], &[]);
360
361        assert_eq!(result.len(), 1);
362        assert_eq!(result[0].address, address);
363        assert_eq!(result[0].amount, 500);
364    }
365
366    #[test]
367    fn test_derive_balance_changes_with_multiple_addresses() {
368        let address1 = SuiAddress::random_for_testing_only();
369        let address2 = SuiAddress::random_for_testing_only();
370        let balance_type = sui_balance_type();
371
372        let obj_id1 = get_accumulator_obj_id(address1, &balance_type);
373        let obj_id2 = get_accumulator_obj_id(address2, &balance_type);
374
375        let write1 = AccumulatorWriteV1 {
376            address: AccumulatorAddress::new(address1, balance_type.clone()),
377            operation: AccumulatorOperation::Split,
378            value: AccumulatorValue::Integer(1000),
379        };
380        let write2 = AccumulatorWriteV1 {
381            address: AccumulatorAddress::new(address2, balance_type),
382            operation: AccumulatorOperation::Merge,
383            value: AccumulatorValue::Integer(1000),
384        };
385
386        let effects =
387            create_effects_with_accumulator_writes(vec![(obj_id1, write1), (obj_id2, write2)]);
388
389        let result = derive_balance_changes(&effects, &[], &[]);
390
391        assert_eq!(result.len(), 2);
392        let addr1_change = result.iter().find(|c| c.address == address1).unwrap();
393        let addr2_change = result.iter().find(|c| c.address == address2).unwrap();
394        assert_eq!(addr1_change.amount, -1000);
395        assert_eq!(addr2_change.amount, 1000);
396    }
397
398    #[test]
399    fn test_derive_balance_changes_with_custom_coin_type() {
400        let address = SuiAddress::random_for_testing_only();
401        let balance_type = custom_balance_type();
402        let obj_id = get_accumulator_obj_id(address, &balance_type);
403        let write = AccumulatorWriteV1 {
404            address: AccumulatorAddress::new(address, balance_type),
405            operation: AccumulatorOperation::Split,
406            value: AccumulatorValue::Integer(2000),
407        };
408        let effects = create_effects_with_accumulator_writes(vec![(obj_id, write)]);
409
410        let result = derive_balance_changes(&effects, &[], &[]);
411
412        assert_eq!(result.len(), 1);
413        assert_eq!(result[0].address, address);
414        assert_eq!(result[0].coin_type, custom_coin_type());
415        assert_eq!(result[0].amount, -2000);
416    }
417
418    #[test]
419    fn test_derive_balance_changes_ignores_non_balance_types() {
420        let address = SuiAddress::random_for_testing_only();
421        // Use a non-Balance type - use a random ObjectID since we can't derive it
422        let non_balance_type: TypeTag = "0x2::accumulator_settlement::EventStreamHead"
423            .parse()
424            .unwrap();
425        let write = AccumulatorWriteV1 {
426            address: AccumulatorAddress::new(address, non_balance_type),
427            operation: AccumulatorOperation::Split,
428            value: AccumulatorValue::Integer(1000),
429        };
430        let effects = create_effects_with_accumulator_writes(vec![(ObjectID::random(), write)]);
431
432        let result = derive_balance_changes(&effects, &[], &[]);
433
434        assert!(result.is_empty());
435    }
436
437    #[test]
438    fn test_derive_balance_changes_ignores_event_digest_values() {
439        use crate::digests::Digest;
440        use nonempty::nonempty;
441
442        let address = SuiAddress::random_for_testing_only();
443        let balance_type = sui_balance_type();
444        let obj_id = get_accumulator_obj_id(address, &balance_type);
445        let write = AccumulatorWriteV1 {
446            address: AccumulatorAddress::new(address, balance_type),
447            operation: AccumulatorOperation::Merge,
448            value: AccumulatorValue::EventDigest(nonempty![(0, Digest::random())]),
449        };
450        let effects = create_effects_with_accumulator_writes(vec![(obj_id, write)]);
451
452        let result = derive_balance_changes(&effects, &[], &[]);
453
454        assert!(result.is_empty());
455    }
456
457    #[test]
458    fn test_derive_balance_changes_accumulator_zero_amount_filtered() {
459        // Test that a zero amount accumulator event results in no balance change
460        let address = SuiAddress::random_for_testing_only();
461        let balance_type = sui_balance_type();
462        let obj_id = get_accumulator_obj_id(address, &balance_type);
463
464        let write = AccumulatorWriteV1 {
465            address: AccumulatorAddress::new(address, balance_type),
466            operation: AccumulatorOperation::Split,
467            value: AccumulatorValue::Integer(0),
468        };
469        let effects = create_effects_with_accumulator_writes(vec![(obj_id, write)]);
470
471        let result = derive_balance_changes(&effects, &[], &[]);
472
473        // Zero amount should be filtered out
474        assert!(result.is_empty());
475    }
476
477    #[test]
478    fn test_derive_balance_changes_2_with_accumulator_events() {
479        let address = SuiAddress::random_for_testing_only();
480        let balance_type = sui_balance_type();
481        let obj_id = get_accumulator_obj_id(address, &balance_type);
482        let write = AccumulatorWriteV1 {
483            address: AccumulatorAddress::new(address, balance_type),
484            operation: AccumulatorOperation::Split,
485            value: AccumulatorValue::Integer(1000),
486        };
487        let effects = create_effects_with_accumulator_writes(vec![(obj_id, write)]);
488
489        let objects = crate::full_checkpoint_content::ObjectSet::default();
490        let result = derive_balance_changes_2(&effects, &objects);
491
492        assert_eq!(result.len(), 1);
493        assert_eq!(result[0].address, address);
494        assert_eq!(
495            result[0].coin_type,
496            "0x2::sui::SUI".parse::<TypeTag>().unwrap()
497        );
498        assert_eq!(result[0].amount, -1000);
499    }
500
501    // Tests combining coin objects with accumulator events
502
503    fn create_gas_coin_object(owner: SuiAddress, value: u64) -> Object {
504        use crate::base_types::SequenceNumber;
505        use crate::object::MoveObject;
506
507        let obj_id = ObjectID::random();
508        let move_obj = MoveObject::new_gas_coin(SequenceNumber::new(), obj_id, value);
509        Object::new_move(
510            move_obj,
511            Owner::AddressOwner(owner),
512            TransactionDigest::random(),
513        )
514    }
515
516    fn create_custom_coin_object(owner: SuiAddress, coin_type: TypeTag, 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_coin(coin_type, SequenceNumber::new(), obj_id, value);
522        Object::new_move(
523            move_obj,
524            Owner::AddressOwner(owner),
525            TransactionDigest::random(),
526        )
527    }
528
529    #[test]
530    fn test_derive_balance_changes_with_coin_objects_only() {
531        let address = SuiAddress::random_for_testing_only();
532
533        // Create input coin with 5000 SUI
534        let input_coin = create_gas_coin_object(address, 5000);
535        // Create output coin with 3000 SUI (spent 2000)
536        let output_coin = create_gas_coin_object(address, 3000);
537
538        let effects = create_effects_with_accumulator_writes(vec![]);
539
540        let result = derive_balance_changes(&effects, &[input_coin], &[output_coin]);
541
542        assert_eq!(result.len(), 1);
543        assert_eq!(result[0].address, address);
544        assert_eq!(result[0].amount, -2000); // 3000 - 5000 = -2000
545    }
546
547    #[test]
548    fn test_derive_balance_changes_coin_transfer_between_addresses() {
549        let sender = SuiAddress::random_for_testing_only();
550        let receiver = SuiAddress::random_for_testing_only();
551
552        // Sender has 10000 SUI initially
553        let input_coin = create_gas_coin_object(sender, 10000);
554        // After transfer: sender keeps 7000, receiver gets 3000
555        let output_coin_sender = create_gas_coin_object(sender, 7000);
556        let output_coin_receiver = create_gas_coin_object(receiver, 3000);
557
558        let effects = create_effects_with_accumulator_writes(vec![]);
559
560        let result = derive_balance_changes(
561            &effects,
562            &[input_coin],
563            &[output_coin_sender, output_coin_receiver],
564        );
565
566        assert_eq!(result.len(), 2);
567        let sender_change = result.iter().find(|c| c.address == sender).unwrap();
568        let receiver_change = result.iter().find(|c| c.address == receiver).unwrap();
569        assert_eq!(sender_change.amount, -3000); // 7000 - 10000 = -3000
570        assert_eq!(receiver_change.amount, 3000); // 3000 - 0 = 3000
571    }
572
573    #[test]
574    fn test_derive_balance_changes_combines_coins_and_accumulator_events() {
575        let address = SuiAddress::random_for_testing_only();
576        let balance_type = sui_balance_type();
577        let obj_id = get_accumulator_obj_id(address, &balance_type);
578
579        // Coin: spent 2000 SUI (5000 -> 3000)
580        let input_coin = create_gas_coin_object(address, 5000);
581        let output_coin = create_gas_coin_object(address, 3000);
582
583        // Accumulator: received 500 SUI via address balance
584        let write = AccumulatorWriteV1 {
585            address: AccumulatorAddress::new(address, balance_type),
586            operation: AccumulatorOperation::Merge,
587            value: AccumulatorValue::Integer(500),
588        };
589        let effects = create_effects_with_accumulator_writes(vec![(obj_id, write)]);
590
591        let result = derive_balance_changes(&effects, &[input_coin], &[output_coin]);
592
593        // Net change: -2000 (coins) + 500 (accumulator merge) = -1500
594        assert_eq!(result.len(), 1);
595        assert_eq!(result[0].address, address);
596        assert_eq!(result[0].amount, -1500);
597    }
598
599    #[test]
600    fn test_derive_balance_changes_coins_and_accumulator_different_addresses() {
601        let coin_owner = SuiAddress::random_for_testing_only();
602        let accumulator_owner = SuiAddress::random_for_testing_only();
603        let balance_type = sui_balance_type();
604        let obj_id = get_accumulator_obj_id(accumulator_owner, &balance_type);
605
606        // Coin owner spends 1000 SUI
607        let input_coin = create_gas_coin_object(coin_owner, 5000);
608        let output_coin = create_gas_coin_object(coin_owner, 4000);
609
610        // Accumulator owner receives 2000 SUI
611        let write = AccumulatorWriteV1 {
612            address: AccumulatorAddress::new(accumulator_owner, balance_type),
613            operation: AccumulatorOperation::Merge,
614            value: AccumulatorValue::Integer(2000),
615        };
616        let effects = create_effects_with_accumulator_writes(vec![(obj_id, write)]);
617
618        let result = derive_balance_changes(&effects, &[input_coin], &[output_coin]);
619
620        assert_eq!(result.len(), 2);
621        let coin_change = result.iter().find(|c| c.address == coin_owner).unwrap();
622        let acc_change = result
623            .iter()
624            .find(|c| c.address == accumulator_owner)
625            .unwrap();
626        assert_eq!(coin_change.amount, -1000);
627        assert_eq!(acc_change.amount, 2000);
628    }
629
630    #[test]
631    fn test_derive_balance_changes_coins_and_accumulator_net_to_zero() {
632        let address = SuiAddress::random_for_testing_only();
633        let balance_type = sui_balance_type();
634        let obj_id = get_accumulator_obj_id(address, &balance_type);
635
636        // Coin: spent 1000 SUI (5000 -> 4000)
637        let input_coin = create_gas_coin_object(address, 5000);
638        let output_coin = create_gas_coin_object(address, 4000);
639
640        // Accumulator: received exactly 1000 SUI - perfectly offsets coin spend
641        let write = AccumulatorWriteV1 {
642            address: AccumulatorAddress::new(address, balance_type),
643            operation: AccumulatorOperation::Merge,
644            value: AccumulatorValue::Integer(1000),
645        };
646        let effects = create_effects_with_accumulator_writes(vec![(obj_id, write)]);
647
648        let result = derive_balance_changes(&effects, &[input_coin], &[output_coin]);
649
650        // Net change: -1000 (coins) + 1000 (accumulator) = 0, should be filtered out
651        assert!(result.is_empty());
652    }
653
654    #[test]
655    fn test_derive_balance_changes_different_coin_types() {
656        let address = SuiAddress::random_for_testing_only();
657        let custom_type = custom_coin_type();
658        let custom_balance = custom_balance_type();
659        let obj_id = get_accumulator_obj_id(address, &custom_balance);
660
661        // SUI coin: spent 1000
662        let sui_input = create_gas_coin_object(address, 5000);
663        let sui_output = create_gas_coin_object(address, 4000);
664
665        // Custom coin: received 500 via coins
666        let custom_output = create_custom_coin_object(address, custom_type.clone(), 500);
667
668        // Custom coin: also received 300 via accumulator
669        let write = AccumulatorWriteV1 {
670            address: AccumulatorAddress::new(address, custom_balance),
671            operation: AccumulatorOperation::Merge,
672            value: AccumulatorValue::Integer(300),
673        };
674        let effects = create_effects_with_accumulator_writes(vec![(obj_id, write)]);
675
676        let result = derive_balance_changes(&effects, &[sui_input], &[sui_output, custom_output]);
677
678        assert_eq!(result.len(), 2);
679
680        let sui_change = result
681            .iter()
682            .find(|c| c.coin_type == "0x2::sui::SUI".parse::<TypeTag>().unwrap())
683            .unwrap();
684        let custom_change = result.iter().find(|c| c.coin_type == custom_type).unwrap();
685
686        assert_eq!(sui_change.amount, -1000);
687        assert_eq!(custom_change.amount, 800); // 500 (coin) + 300 (accumulator)
688    }
689
690    #[test]
691    fn test_derive_balance_changes_accumulator_split_with_coins() {
692        let sender = SuiAddress::random_for_testing_only();
693        let receiver = SuiAddress::random_for_testing_only();
694        let balance_type = sui_balance_type();
695        let sender_obj_id = get_accumulator_obj_id(sender, &balance_type);
696        let receiver_obj_id = get_accumulator_obj_id(receiver, &balance_type.clone());
697
698        // Sender sends coins: 5000 -> 4000 (sends 1000)
699        let input_coin = create_gas_coin_object(sender, 5000);
700        let output_coin = create_gas_coin_object(sender, 4000);
701
702        // Sender also sends 500 via address balance (Merge)
703        let sender_write = AccumulatorWriteV1 {
704            address: AccumulatorAddress::new(sender, balance_type.clone()),
705            operation: AccumulatorOperation::Split,
706            value: AccumulatorValue::Integer(500),
707        };
708        // Receiver receives 500 via address balance (Split)
709        let receiver_write = AccumulatorWriteV1 {
710            address: AccumulatorAddress::new(receiver, balance_type),
711            operation: AccumulatorOperation::Merge,
712            value: AccumulatorValue::Integer(500),
713        };
714        let effects = create_effects_with_accumulator_writes(vec![
715            (sender_obj_id, sender_write),
716            (receiver_obj_id, receiver_write),
717        ]);
718
719        let result = derive_balance_changes(&effects, &[input_coin], &[output_coin]);
720
721        assert_eq!(result.len(), 2);
722        let sender_change = result.iter().find(|c| c.address == sender).unwrap();
723        let receiver_change = result.iter().find(|c| c.address == receiver).unwrap();
724
725        // Sender: -1000 (coins) + -500 (accumulator merge) = -1500
726        assert_eq!(sender_change.amount, -1500);
727        // Receiver: +500 (accumulator split)
728        assert_eq!(receiver_change.amount, 500);
729    }
730}