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