-
Notifications
You must be signed in to change notification settings - Fork 118
Description
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
stateObjectandCacheMultiStore.
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 |
|
|
| B – Shared Write Mechanism (Proposed) | Remove Balance from stateObject; funnel all native balance ops through a single helper (ExecuteNativeAction) that writes directly to CacheMultiStore |
|
|
6 Proposed Architecture (Option B)
-
Prune
BalancefromstateObject.Account// Account is the Ethereum consensus representation of accounts. type Account struct { Nonce uint64 CodeHash []byte }
-
Add
StateDB.ExecuteNativeAction— a thin wrapper around ansdk.Contextsnapshot/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 }
-
Override all balance‑mutating
StateDBmethods to route throughExecuteNativeAction:// 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...
-
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 |
|
| 6 | Run full integration suite |
5 Compatibility & Invariants
- Upstream Geth Divergence — Future Geth changes to
Account.Balanceare 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