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