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:
executeTransactionBlock
dryRunTransactionBlock
getTransactionBlock
multiGetTransactionBlocks
Why this happens
The evidence chain seems to be:
- Public legacy JSON-RPC
BalanceChange carries owner: Owner in crates/sui-json-rpc-types/src/balance_changes.rs
- Shared
sui-types balance-change structs carry only address: SuiAddress in crates/sui-types/src/balance_change.rs
coins() flattens AddressOwner, ObjectOwner, and ConsensusAddressOwner into the same address-like key
derive_detailed_balance_changes() accumulates coin and address-balance deltas by (address, coin_type)
- Legacy JSON-RPC reconstructs every result as
Owner::AddressOwner(change.address) in crates/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 -100 plus accumulator AddressOwner(a) delta +100 should produce two separate entries, not zero entries
- JSON-RPC smoke:
get_balance_changes_from_effect should not blindly emit Owner::AddressOwner for 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.
Summary
The public legacy JSON-RPC
BalanceChangetype exposesowner: Owner, but the balance-change derivation path used by legacy JSON-RPC collapses coin ownership intoSuiAddressand then reconstructs every derived entry asOwner::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:executeTransactionBlockdryRunTransactionBlockgetTransactionBlockmultiGetTransactionBlocksWhy this happens
The evidence chain seems to be:
BalanceChangecarriesowner: Ownerincrates/sui-json-rpc-types/src/balance_changes.rssui-typesbalance-change structs carry onlyaddress: SuiAddressincrates/sui-types/src/balance_change.rscoins()flattensAddressOwner,ObjectOwner, andConsensusAddressOwnerinto the same address-like keyderive_detailed_balance_changes()accumulates coin and address-balance deltas by(address, coin_type)Owner::AddressOwner(change.address)incrates/sui-json-rpc/src/balance_changes.rsCurrent reconstruction:
Minimal reproducer sketch
This is modeled on the existing tests in
crates/sui-json-rpc/src/balance_changes.rs.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 forAddressOwner(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
Ownerthrough the internal balance-change structs/maps for the JSON-RPC-facing derivation path, and useOwner::AddressOwner(addr)only for accumulator/address-balance events.The indexer-alt path already follows that shape:
Owner::AddressOwner(addr)object.owner().clone()Useful regression tests
Owner::ObjectOwner(parent)Owner::ConsensusAddressOwner { owner, start_version }a, same coin type,ConsensusAddressOwner { owner: a }coin delta-100plus accumulatorAddressOwner(a)delta+100should produce two separate entries, not zero entriesget_balance_changes_from_effectshould not blindly emitOwner::AddressOwnerfor non-address-owned coin objectsComparison point
For reference, the indexer-alt path already preserves owner kind for coin deltas in
crates/sui-indexer-alt/src/handlers/tx_balance_changes.rsby usingobject.owner().clone()for coins andOwner::AddressOwner(addr)only for accumulator events.