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