`balanceChanges` in legacy JSON-RPC loses `Owner` kind and reconstructs everything as `AddressOwner`
Summary
The public legacy JSON-RPC BalanceChange type exposes owner: Owner, but the balance-change derivation path used by legacy JSON-RPC collapses coin ownership into SuiAddress and then reconstructs every derived entry as Owner::AddressOwner(...).
This means object-owned and consensus-owned coin balance changes are reported as address-owned balance changes.
Affected surfaces
This appears to affect every legacy JSON-RPC surface that derives balance changes through get_balance_changes_from_effect, including:
executeTransactionBlockdryRunTransactionBlockgetTransactionBlockmultiGetTransactionBlocks
Why this happens
The evidence chain seems to be:
- Public legacy JSON-RPC
BalanceChangecarriesowner: Ownerincrates/sui-json-rpc-types/src/balance_changes.rs - Shared
sui-typesbalance-change structs carry onlyaddress: SuiAddressincrates/sui-types/src/balance_change.rs coins()flattensAddressOwner,ObjectOwner, andConsensusAddressOwnerinto the same address-like keyderive_detailed_balance_changes()accumulates coin and address-balance deltas by(address, coin_type)- Legacy JSON-RPC reconstructs every result as
Owner::AddressOwner(change.address)incrates/sui-json-rpc/src/balance_changes.rs
Current reconstruction:
derive_balance_changes(effects, &input_coins, &mutated_coins)
.into_iter()
.map(|change| BalanceChange {
owner: Owner::AddressOwner(change.address),
coin_type: change.coin_type,
amount: change.amount,
})Minimal reproducer sketch
This is modeled on the existing tests in crates/sui-json-rpc/src/balance_changes.rs.
#[tokio::test]
async fn object_owned_coin_balance_change_keeps_owner_kind() {
let parent = ObjectID::random();
let gas_id = ObjectID::random();
let old_version = SequenceNumber::from_u64(1);
let new_version = SequenceNumber::from_u64(2);
let input_obj = Object::new_move(
MoveObject::new_gas_coin(old_version, gas_id, 1_000),
Owner::ObjectOwner(parent.into()),
TransactionDigest::random(),
);
let output_obj = Object::new_move(
MoveObject::new_gas_coin(new_version, gas_id, 600),
Owner::ObjectOwner(parent.into()),
TransactionDigest::random(),
);
let mut changed_objects = BTreeMap::new();
changed_objects.insert(
gas_id,
EffectsObjectChange::new(
Some(((old_version, input_obj.digest()), Owner::ObjectOwner(parent.into()))),
Some(&output_obj),
false,
false,
),
);
let effects = TransactionEffects::new_from_execution_v2(
ExecutionStatus::Success,
0,
GasCostSummary::default(),
vec![],
std::collections::BTreeSet::new(),
TransactionDigest::random(),
new_version,
changed_objects,
Some(gas_id),
None,
vec![],
);
let mut provider = MockObjectProvider::new();
provider.insert(input_obj);
provider.insert(output_obj);
let changes = get_balance_changes_from_effect(&provider, &effects, vec![], None)
.await
.unwrap();
assert_eq!(changes.len(), 1);
assert_eq!(changes[0].coin_type, GAS::type_tag());
assert_eq!(changes[0].amount, -400);
// Expected:
assert_eq!(changes[0].owner, Owner::ObjectOwner(parent.into()));
// Actual today:
// Owner::AddressOwner(parent.into())
}Expected JSON shape:
{
"owner": { "ObjectOwner": "0x..." },
"coinType": "0x2::sui::SUI",
"amount": "-400"
}Actual JSON shape today:
{
"owner": { "AddressOwner": "0x..." },
"coinType": "0x2::sui::SUI",
"amount": "-400"
}Worse than relabeling
This is not only owner relabeling.
A ConsensusAddressOwner { owner: a, ... } coin delta and an accumulator/address-balance delta for AddressOwner(a) are both keyed by (address, coin_type), so they can merge and even net out entirely.
That means the bug can erase distinct balance-change entries, not just mislabel them.
Suggested fix direction
Preserve Owner through the internal balance-change structs/maps for the JSON-RPC-facing derivation path, and use Owner::AddressOwner(addr) only for accumulator/address-balance events.
The indexer-alt path already follows that shape:
- accumulator events are keyed as
Owner::AddressOwner(addr) - coin deltas use
object.owner().clone()
Useful regression tests
- Object-owned coin: owner stays
Owner::ObjectOwner(parent) - Consensus-owned coin: owner stays
Owner::ConsensusAddressOwner { owner, start_version } - Collision regression: same
a, same coin type,ConsensusAddressOwner { owner: a }coin delta-100plus accumulatorAddressOwner(a)delta+100should produce two separate entries, not zero entries - JSON-RPC smoke:
get_balance_changes_from_effectshould not blindly emitOwner::AddressOwnerfor non-address-owned coin objects
Comparison point
For reference, the indexer-alt path already preserves owner kind for coin deltas in crates/sui-indexer-alt/src/handlers/tx_balance_changes.rs by using object.owner().clone() for coins and Owner::AddressOwner(addr) only for accumulator events.
Source: MystenLabs/sui