Skip to content

Native Balance Handling Enhancement #185

@zsystm

Description

@zsystm

Goal — Eliminate race‑conditions and developer‑error around native balance mutations originating from both EVM contract code and stateful precompiles by moving to a shared, atomic write‑path that treats the CacheMultiStore as the single source‑of‑truth. The approach is inspired by the design shipped in crypto‑org‑chain/ethermint v1.4.7.

TL;DR

  • We propose using the SDK context as the single source of truth for all native balance changes.
  • This simplifies precompile development and ensures atomic, consistent state handling.

1 Background & Motivation

Cosmos EVM inherits Geth’s StateDB abstraction, but must also reconcile balance mutations with the Cosmos SDK x/bank module. Today this reconciliation relies on stateObject.Account.Balance as the authoritative value and a helper, SetBalanceChangeEntries, that precompiles must call to avoid overwriting balance changes written in the same transaction, especially for the case that contract calls precompile method and transfers balance at the same time.

Current Pain‑Points

  • Manual handling of balance change — Every new precompile that can change native balance must remember to call SetBalanceChangeEntries. If developers forget these calls, the state is not changed as they intended.

2 Observed Failure Scenario

When a precompile method that changes native balance omits the SetBalanceChangeEntries call, the scenario below can cause native balance inconsistencies.

function testDelegateWithCounterAndTransfer(
        string memory _validatorAddr,
        bool _before,
        bool _after
    ) public payable {
        if (_before) {
            counter++;
            (bool sent, ) = msg.sender.call{value: 15}("");
            require(sent, "Failed to send Ether to delegator");
        }
        bool success = staking.STAKING_CONTRACT.delegate(
            address(this), 
            _validatorAddr, 
            msg.value
        );
        require(success, "Failed to delegate");
        delegation[msg.sender][_validatorAddr] += msg.value;
        if (_after) {
            counter++;
            (bool sent, ) = msg.sender.call{value: 15}("");
            require(sent, "Failed to send Ether to delegator");
        }
    }

In the example above, a contract performs a transfer before and after a stakingPrecompile.delegate call. Without proper balance change tracking, the precompile’s native balance update is overwritten by subsequent calls.

Two alternative design directions emerge:

  • Mirror balance changes directly to the stateObject. (cosmos/evm)
  • Cache all balance changes—whether from contract logic or precompiles—to the CacheMultiStore, committing them atomically during StateDB commit. (crypto-org-chain/ethermint)

3 Current Strategy: stateObject-Centric Design (cosmos/evm)

Balance changes from precompiles are applied to the stateObject using the SetBalanceChangeEntries method. This preserves native balance integrity during state writes.

Pros

  • Closely mimics Geth’s internal balance model, simplifying future version upgrades.

Cons

  • Developer must explicitly manage balance tracking.
  • Precompile execution may fail due to timing inconsistencies with prior balance changes.
  • Inconsistency risk between stateObject and CacheMultiStore.

Design Alternatives

  • Auto-detecting balance changes (difficult without SDK modifications).
  • Mirroring stateObject balance to CacheMultiStore in RunSetup, though this adds complexity.

4 Alternative Strategy: Shared Write Model (crypto-org-chain/ethermint)

crypto-org-chain/ethermint removes the Balance field from stateObject.Account and funnels all native-balance operations through a single handler, ExecuteNativeAction. This guarantees atomicity, unified journaling, and consistent event emission.

ExecuteNativeAction is similar to Precompile.RunAtomic in cosmos/evm: both wrap SDK writes in a snapshot/revert and restore prior events on error.

// ExecuteNativeAction executes a Cosmos‑SDK write in isolation; rolls back on error.
func (s *StateDB) ExecuteNativeAction(
    contract  common.Address,
    converter EventConverter,
    action    func(ctx sdk.Context) error,
) error {
    snapshot    := s.snapshotNativeState()
    eventMgr    := sdk.NewEventManager()

    if err := action(s.ctx.WithEventManager(eventMgr)); err != nil {
        s.revertNativeStateToSnapshot(snapshot)
        return err
    }

    events := eventMgr.Events()
    s.emitNativeEvents(contract, converter, events)
    s.nativeEvents = s.nativeEvents.AppendEvents(events)
    s.journal.append(nativeChange{snapshot: snapshot, events: len(events)})
    return nil
}
ref: RunAtomic
// RunAtomic is used within the Run function of each Precompile implementation.
// It handles rolling back to the provided snapshot if an error is returned from the core precompile logic.
// Note: This is only required for stateful precompiles.
func (p Precompile) RunAtomic(s Snapshot, stateDB *statedb.StateDB, fn func() ([]byte, error)) ([]byte, error) {
	bz, err := fn()
	if err != nil {
		// revert to snapshot on error
		stateDB.RevertMultiStore(s.MultiStore, s.Events)
	}
	return bz, err
}

func (s *StateDB) RevertMultiStore(cms storetypes.CacheMultiStore, events sdk.Events) {
	s.cacheCtx = s.cacheCtx.WithMultiStore(cms)
	s.writeCache = func() {
		// rollback the events to the ones
		// on the snapshot
		s.ctx.EventManager().EmitEvents(events)
		cms.Write()
	}
}

The key usage difference is that cosmos/evm tracks balance changes in a separate journal entry you must commit later, whereas crypto-org-chain/ethermint applies all changes directly to the sdk.Context (CacheMultiStore) and relies on its built-in rollback, making balance updates feel more natural and reducing room for error.

StateDB

StateDB.Transfer
// Transfer from one account to another
func (s *StateDB) Transfer(sender, recipient common.Address, amount *big.Int) {
	if amount.Sign() == 0 {
		return
	}
	if amount.Sign() < 0 {
		panic("negative amount")
	}

	coins := sdk.NewCoins(sdk.NewCoin(s.evmDenom, sdkmath.NewIntFromBigIntMut(amount)))
	senderAddr := sdk.AccAddress(sender.Bytes())
	recipientAddr := sdk.AccAddress(recipient.Bytes())
	if err := s.ExecuteNativeAction(common.Address{}, nil, func(ctx sdk.Context) error {
		return s.keeper.Transfer(ctx, senderAddr, recipientAddr, coins)
	}); err != nil {
		s.err = err
	}
}
StateDB.AddBalance
// AddBalance adds amount to the account associated with addr.
func (s *StateDB) AddBalance(addr common.Address, amount *big.Int) {
	if amount.Sign() == 0 {
		return
	}
	if amount.Sign() < 0 {
		panic("negative amount")
	}
	coins := sdk.Coins{sdk.NewCoin(s.evmDenom, sdkmath.NewIntFromBigInt(amount))}
	if err := s.ExecuteNativeAction(common.Address{}, nil, func(ctx sdk.Context) error {
		return s.keeper.AddBalance(ctx, sdk.AccAddress(addr.Bytes()), coins)
	}); err != nil {
		s.err = err
	}
}
StateDB.SubBalance
// SubBalance subtracts amount from the account associated with addr.
func (s *StateDB) SubBalance(addr common.Address, amount *big.Int) {
	if amount.Sign() == 0 {
		return
	}
	if amount.Sign() < 0 {
		panic("negative amount")
	}
	coins := sdk.Coins{sdk.NewCoin(s.evmDenom, sdkmath.NewIntFromBigInt(amount))}
	if err := s.ExecuteNativeAction(common.Address{}, nil, func(ctx sdk.Context) error {
		return s.keeper.SubBalance(ctx, sdk.AccAddress(addr.Bytes()), coins)
	}); err != nil {
		s.err = err
	}
}
StateDB.SetBalance
// SetBalance is called by state override
func (s *StateDB) SetBalance(addr common.Address, amount *big.Int) {
	if err := s.ExecuteNativeAction(common.Address{}, nil, func(ctx sdk.Context) error {
		return s.keeper.SetBalance(ctx, addr, amount, s.evmDenom)
	}); err != nil {
		s.err = err
	}
}

Precompiles

e.g. mint / burn of bank precompile
err = stateDB.ExecuteNativeAction(precompileAddr, nil, func(ctx sdk.Context) error {
	if err := bc.bankKeeper.IsSendEnabledCoins(ctx, amt); err != nil {
		return err
	}
	if method.Name == "mint" {
		if err := bc.bankKeeper.MintCoins(ctx, types.ModuleName, sdk.NewCoins(amt)); err != nil {
			return errorsmod.Wrap(err, "fail to mint coins in precompiled contract")
		}
		if err := bc.bankKeeper.SendCoinsFromModuleToAccount(ctx, types.ModuleName, addr, sdk.NewCoins(amt)); err != nil {
			return errorsmod.Wrap(err, "fail to send mint coins to account")
		}
	} else {
		if err := bc.bankKeeper.SendCoinsFromAccountToModule(ctx, addr, types.ModuleName, sdk.NewCoins(amt)); err != nil {
			return errorsmod.Wrap(err, "fail to send burn coins to module")
		}
		if err := bc.bankKeeper.BurnCoins(ctx, types.ModuleName, sdk.NewCoins(amt)); err != nil {
			return errorsmod.Wrap(err, "fail to burn coins in precompiled contract")
		}
	}
	return nil
})

Pros

  • Eliminates separate truth sources and their associated bugs.
  • Developers no longer manage balance tracking in precompile code.

Cons

  • Diverges from Geth’s native architecture, requiring more attention during future upgrades.

Reference Code: ExecuteNativeAction

5 Design Options Considered

Option Description Pros Cons
A – Status Quo Keep stateObject.Balance + SetBalanceChangeEntries
  • Matches vanilla Geth semantics
  • No migration work
  • Developer‑error prone
  • Inconsistent read‑path
  • Valid TX may fail
B – Shared Write Mechanism (Proposed) Remove Balance from stateObject; funnel all native balance ops through a single helper (ExecuteNativeAction) that writes directly to CacheMultiStore
  • Single truth source
  • No special‑case code in precompiles
  • Atomic rollback via snapshot
  • Diverges from upstream Geth
  • Extra vigilance needed during future version bumps

6 Proposed Architecture (Option B)

  1. Prune Balance from stateObject.Account

    // Account is the Ethereum consensus representation of accounts.
    type Account struct {
        Nonce    uint64
        CodeHash []byte
    }
  2. Add StateDB.ExecuteNativeAction — a thin wrapper around an sdk.Context snapshot/rollback with integrated event emission:

    // RunAtomic executes a Cosmos‑SDK write in isolation; rolls back on error.
    func (s *StateDB) ExecuteNativeAction(
        fn func(ctx sdk.Context) error,
    ) ([]byte, error) {
        snapshot := s.snapshotNativeState()
    
        if bz, err := fn(s.ctx.WithEventManager(eventMgr)); err != nil {
            s.RevertMultiStore(s.MultiStore, s.Events)
        }
    
    		//     
        s.journal.append(
    	    nativeChange{snapshot: snapshot, events: len(s.Events)},
    	  )
    
        return bz, nil
    }
  3. Override all balance‑mutating StateDB methods to route through ExecuteNativeAction:

    // Transfer from one account to another.
    func (s *StateDB) Transfer(sender, recipient common.Address, amt *big.Int) {
        if amt.Sign() <= 0 { return }
        coins := sdk.NewCoins(sdk.NewCoin(s.evmDenom, sdkmath.NewIntFromBigIntMut(amt)))
        if _, err := s.**ExecuteNativeAction**(func(ctx sdk.Context) error {
            return s.keeper.Transfer(ctx,
                sdk.AccAddress(sender.Bytes()),
                sdk.AccAddress(recipient.Bytes()), coins)
        }); err != nil { s.err = err }
    }
    // Similar overrides for AddBalance, SubBalance, SetBalance...
  4. Refactor stateful precompiles for removing SetBalanceChangeEntries.

    // We don't need this part anymore
    
    if contract.CallerAddress != origin && msg.Amount.Denom == evmtypes.GetEVMCoinDenom() {
    		scaledAmt := evmtypes.ConvertAmountTo18DecimalsBigInt(msg.Amount.Amount.BigInt())
    		p.SetBalanceChangeEntries(cmn.NewBalanceChangeEntry(delegatorHexAddr, scaledAmt, cmn.Sub))
    	}

4 Implementation Plan

Step Task
1 Remove Balance field; regenerate protobuf / amino codecs
2 Add ExecuteNativeAction + snapshot helpers to StateDB
3 Override Transfer, AddBalance, SubBalance, implement SetBalance
4 Delete SetBalanceChangeEntries; update existing precompiles (staking, bank, distribution, etc.)
5
  • Round-trip bank balances after mixed contract + precompile calls
  • Snapshot rollback on SDK error
  • Event emission integrity
6 Run full integration suite

5 Compatibility & Invariants

  • Upstream Geth Divergence — Future Geth changes to Account.Balance are insulated by our explicit overrides, but merge conflicts must be resolved manually.
  • Module Invariants — Bank module invariants remain valid; new tests added in Step 5 guard against regressions.

6 Benefits

  • Eliminates balance overwrite class of bugs.
  • Reduces cognitive load on precompile authors

7 Conclusion

Adopting SDK context as the single balance source unifies native balance handling across EVM contracts and precompiles, guarantees atomicity, and aligns Cosmos EVM with a proven architecture already battle‑tested in [Cronos](https://github.com/crypto-org-chain/cronos/tree/v1.4.7). The migration cost is modest, the risk is contained, and the payoff is a more robust and developer‑friendly system


8 Reference

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions