Skip to content

feat(precompiles): add BalanceHandler to handle native balance change #201

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 14 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 0 additions & 9 deletions evmd/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -449,15 +449,6 @@ func NewExampleApp(
runtime.ProvideCometInfoService(),
)
// If evidence needs to be handled for the app, set routes in router here and seal
// Note: The evidence precompile allows evidence to be submitted through an EVM transaction.
// If you implement a custom evidence handler in the router that changes token balances (e.g. penalizing
// addresses, deducting fees, etc.), be aware that the precompile logic (e.g. SetBalanceChangeEntries)
// must be properly integrated to reflect these balance changes in the EVM state. Otherwise, there is a risk
// of desynchronization between the Cosmos SDK state and the EVM state when evidence is submitted via the EVM.
//
// For example, if your custom evidence handler deducts tokens from a user’s account, ensure that the evidence
// precompile also applies these deductions through the EVM’s balance tracking. Failing to do so may cause
// inconsistencies in reported balances and break state synchronization.
app.EvidenceKeeper = *evidenceKeeper

// Cosmos EVM keepers
Expand Down
5 changes: 1 addition & 4 deletions precompiles/bank/bank.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ func (p Precompile) RequiredGas(input []byte) uint64 {

// Run executes the precompiled contract bank query methods defined in the ABI.
func (p Precompile) Run(evm *vm.EVM, contract *vm.Contract, readOnly bool) (bz []byte, err error) {
ctx, stateDB, method, initialGas, args, err := p.RunSetup(evm, contract, readOnly, p.IsTransaction)
ctx, _, method, initialGas, args, err := p.RunSetup(evm, contract, readOnly, p.IsTransaction)
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -134,9 +134,6 @@ func (p Precompile) Run(evm *vm.EVM, contract *vm.Contract, readOnly bool) (bz [
if !contract.UseGas(cost, nil, tracing.GasChangeCallPrecompiledContract) {
return nil, vm.ErrOutOfGas
}
if err = p.AddJournalEntries(stateDB); err != nil {
return nil, err
}

return bz, nil
}
Expand Down
107 changes: 107 additions & 0 deletions precompiles/common/balance_handler.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
package common

import (
"fmt"

"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/core/tracing"
"github.com/holiman/uint256"

"github.com/cosmos/evm/utils"
"github.com/cosmos/evm/x/vm/statedb"
evmtypes "github.com/cosmos/evm/x/vm/types"

sdk "github.com/cosmos/cosmos-sdk/types"
banktypes "github.com/cosmos/cosmos-sdk/x/bank/types"
)

// BalanceHandler is a struct that handles balance changes in the Cosmos SDK context.
type BalanceHandler struct {
prevEventsLen int
}

// NewBalanceHandler creates a new BalanceHandler instance.
func NewBalanceHandler() *BalanceHandler {
return &BalanceHandler{
prevEventsLen: 0,
}
}

// BeforeBalanceChange is called before any balance changes by precompile methods.
// It records the current number of events in the context to later process balance changes
// using the recorded events.
func (bh *BalanceHandler) BeforeBalanceChange(ctx sdk.Context) {
bh.prevEventsLen = len(ctx.EventManager().Events())
}

// AfterBalanceChange processes the recorded events and updates the stateDB accordingly.
// It handles the bank events for coin spent and coin received, updating the balances
// of the spender and receiver addresses respectively.
func (bh *BalanceHandler) AfterBalanceChange(ctx sdk.Context, stateDB *statedb.StateDB) error {
events := ctx.EventManager().Events()

for _, event := range events[bh.prevEventsLen:] {
switch event.Type {
case banktypes.EventTypeCoinSpent:
spenderHexAddr, err := parseHexAddress(event, banktypes.AttributeKeySpender)
if err != nil {
return fmt.Errorf("failed to parse spender address from event %q: %w", banktypes.EventTypeCoinSpent, err)
}

amount, err := parseAmount(event)
if err != nil {
return fmt.Errorf("failed to parse amount from event %q: %w", banktypes.EventTypeCoinSpent, err)
}

stateDB.SubBalance(spenderHexAddr, amount, tracing.BalanceChangeUnspecified)

case banktypes.EventTypeCoinReceived:
receiverHexAddr, err := parseHexAddress(event, banktypes.AttributeKeyReceiver)
if err != nil {
return fmt.Errorf("failed to parse receiver address from event %q: %w", banktypes.EventTypeCoinReceived, err)
}

amount, err := parseAmount(event)
if err != nil {
return fmt.Errorf("failed to parse amount from event %q: %w", banktypes.EventTypeCoinReceived, err)
}

stateDB.AddBalance(receiverHexAddr, amount, tracing.BalanceChangeUnspecified)
}
}

return nil
}

func parseHexAddress(event sdk.Event, key string) (common.Address, error) {
attr, ok := event.GetAttribute(key)
if !ok {
return common.Address{}, fmt.Errorf("event %q missing attribute %q", event.Type, key)
}

accAddr, err := sdk.AccAddressFromBech32(attr.Value)
if err != nil {
return common.Address{}, fmt.Errorf("invalid address %q: %w", attr.Value, err)
}

return common.Address(accAddr.Bytes()), nil
}

func parseAmount(event sdk.Event) (*uint256.Int, error) {
amountAttr, ok := event.GetAttribute(sdk.AttributeKeyAmount)
if !ok {
return nil, fmt.Errorf("event %q missing attribute %q", banktypes.EventTypeCoinSpent, sdk.AttributeKeyAmount)
}

amountCoins, err := sdk.ParseCoinsNormalized(amountAttr.Value)
if err != nil {
return nil, fmt.Errorf("failed to parse coins from %q: %w", amountAttr.Value, err)
}

amountBigInt := amountCoins.AmountOf(evmtypes.GetEVMCoinDenom()).BigInt()
amount, err := utils.Uint256FromBigInt(evmtypes.ConvertAmountTo18DecimalsBigInt(amountBigInt))
if err != nil {
return nil, fmt.Errorf("failed to convert coin amount to Uint256: %w", err)
}
return amount, nil
}
144 changes: 144 additions & 0 deletions precompiles/common/balance_handler_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
package common

import (
"testing"

"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/core/tracing"
"github.com/holiman/uint256"
"github.com/stretchr/testify/require"

testutil "github.com/cosmos/evm/testutil"
testconstants "github.com/cosmos/evm/testutil/constants"
"github.com/cosmos/evm/x/vm/statedb"
evmtypes "github.com/cosmos/evm/x/vm/types"

storetypes "cosmossdk.io/store/types"

sdktestutil "github.com/cosmos/cosmos-sdk/testutil"
sdk "github.com/cosmos/cosmos-sdk/types"
banktypes "github.com/cosmos/cosmos-sdk/x/bank/types"
)

func setupBalanceHandlerTest(t *testing.T) {
t.Helper()

sdk.GetConfig().SetBech32PrefixForAccount(testconstants.ExampleBech32Prefix, "")
configurator := evmtypes.NewEVMConfigurator()
configurator.ResetTestConfig()
require.NoError(t, configurator.WithEVMCoinInfo(testconstants.ExampleChainCoinInfo[testconstants.ExampleChainID]).Configure())
}

func TestParseHexAddress(t *testing.T) {
setupBalanceHandlerTest(t)

_, addrs, err := testutil.GeneratePrivKeyAddressPairs(1)
require.NoError(t, err)
accAddr := addrs[0]

// valid address
ev := sdk.NewEvent("bank", sdk.NewAttribute(banktypes.AttributeKeySpender, accAddr.String()))
addr, err := parseHexAddress(ev, banktypes.AttributeKeySpender)
require.NoError(t, err)
require.Equal(t, common.Address(accAddr.Bytes()), addr)

// missing attribute
ev = sdk.NewEvent("bank")
_, err = parseHexAddress(ev, banktypes.AttributeKeySpender)
require.Error(t, err)

// invalid address
ev = sdk.NewEvent("bank", sdk.NewAttribute(banktypes.AttributeKeySpender, "invalid"))
_, err = parseHexAddress(ev, banktypes.AttributeKeySpender)
require.Error(t, err)
}

func TestParseAmount(t *testing.T) {
setupBalanceHandlerTest(t)

coinStr := sdk.NewCoins(sdk.NewInt64Coin(evmtypes.GetEVMCoinDenom(), 5)).String()
ev := sdk.NewEvent("bank", sdk.NewAttribute(sdk.AttributeKeyAmount, coinStr))
amt, err := parseAmount(ev)
require.NoError(t, err)
require.True(t, amt.Eq(uint256.NewInt(5)))

// missing amount
ev = sdk.NewEvent("bank")
_, err = parseAmount(ev)
require.Error(t, err)

// invalid coins
ev = sdk.NewEvent("bank", sdk.NewAttribute(sdk.AttributeKeyAmount, "invalid"))
_, err = parseAmount(ev)
require.Error(t, err)
}

func TestAfterBalanceChange(t *testing.T) {
setupBalanceHandlerTest(t)

storeKey := storetypes.NewKVStoreKey("test")
tKey := storetypes.NewTransientStoreKey("test_t")
ctx := sdktestutil.DefaultContext(storeKey, tKey)

stateDB := statedb.New(ctx, testutil.NewMockKeeper(), statedb.NewEmptyTxConfig(common.BytesToHash(ctx.HeaderHash())))

_, addrs, err := testutil.GeneratePrivKeyAddressPairs(2)
require.NoError(t, err)
spenderAcc := addrs[0]
receiverAcc := addrs[1]
spender := common.Address(spenderAcc.Bytes())
receiver := common.Address(receiverAcc.Bytes())

// initial balance for spender
stateDB.AddBalance(spender, uint256.NewInt(5), tracing.BalanceChangeUnspecified)

bh := NewBalanceHandler()
bh.BeforeBalanceChange(ctx)

coins := sdk.NewCoins(sdk.NewInt64Coin(evmtypes.GetEVMCoinDenom(), 3))
ctx.EventManager().EmitEvents(sdk.Events{
banktypes.NewCoinSpentEvent(spenderAcc, coins),
banktypes.NewCoinReceivedEvent(receiverAcc, coins),
})

err = bh.AfterBalanceChange(ctx, stateDB)
require.NoError(t, err)

require.Equal(t, "2", stateDB.GetBalance(spender).String())
require.Equal(t, "3", stateDB.GetBalance(receiver).String())
}

func TestAfterBalanceChangeErrors(t *testing.T) {
setupBalanceHandlerTest(t)

storeKey := storetypes.NewKVStoreKey("test")
tKey := storetypes.NewTransientStoreKey("test_t")
ctx := sdktestutil.DefaultContext(storeKey, tKey)
stateDB := statedb.New(ctx, testutil.NewMockKeeper(), statedb.NewEmptyTxConfig(common.BytesToHash(ctx.HeaderHash())))

_, addrs, err := testutil.GeneratePrivKeyAddressPairs(1)
require.NoError(t, err)
addr := addrs[0]

bh := NewBalanceHandler()
bh.BeforeBalanceChange(ctx)

// invalid address in event
coins := sdk.NewCoins(sdk.NewInt64Coin(evmtypes.GetEVMCoinDenom(), 1))
ctx.EventManager().EmitEvent(banktypes.NewCoinSpentEvent(addr, coins))
ctx.EventManager().Events()[len(ctx.EventManager().Events())-1].Attributes[0].Value = "invalid"
err = bh.AfterBalanceChange(ctx, stateDB)
require.Error(t, err)

// reset events
ctx = ctx.WithEventManager(sdk.NewEventManager())
bh.BeforeBalanceChange(ctx)

// invalid amount
ev := sdk.NewEvent(banktypes.EventTypeCoinSpent,
sdk.NewAttribute(banktypes.AttributeKeySpender, addr.String()),
sdk.NewAttribute(sdk.AttributeKeyAmount, "invalid"))
ctx.EventManager().EmitEvent(ev)
err = bh.AfterBalanceChange(ctx, stateDB)
require.Error(t, err)
}
33 changes: 8 additions & 25 deletions precompiles/common/precompile.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ type Precompile struct {
KvGasConfig storetypes.GasConfig
TransientKVGasConfig storetypes.GasConfig
address common.Address
journalEntries []BalanceChangeEntry
balanceHandler *BalanceHandler
}

// Operation is a type that defines if the precompile call
Expand Down Expand Up @@ -172,30 +172,6 @@ func HandleGasError(ctx sdk.Context, contract *vm.Contract, initialGas storetype
}
}

// AddJournalEntries adds the balanceChange (if corresponds)
func (p Precompile) AddJournalEntries(stateDB *statedb.StateDB) error {
for _, entry := range p.journalEntries {
switch entry.Op {
case Sub:
// add the corresponding balance change to the journal
stateDB.SubBalance(entry.Account, entry.Amount, tracing.BalanceChangeUnspecified)
case Add:
// add the corresponding balance change to the journal
stateDB.AddBalance(entry.Account, entry.Amount, tracing.BalanceChangeUnspecified)
}
}

return nil
}

// SetBalanceChangeEntries sets the balanceChange entries
// as the journalEntries field of the precompile.
// These entries will be added to the stateDB's journal
// when calling the AddJournalEntries function
func (p *Precompile) SetBalanceChangeEntries(entries ...BalanceChangeEntry) {
p.journalEntries = entries
}

func (p Precompile) Address() common.Address {
return p.address
}
Expand Down Expand Up @@ -248,3 +224,10 @@ func (p Precompile) standardCallData(contract *vm.Contract) (method *abi.Method,

return method, nil
}

func (p *Precompile) GetBalanceHandler() *BalanceHandler {
if p.balanceHandler == nil {
p.balanceHandler = NewBalanceHandler()
}
return p.balanceHandler
}
6 changes: 5 additions & 1 deletion precompiles/distribution/distribution.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,9 @@ func (p Precompile) Run(evm *vm.EVM, contract *vm.Contract, readOnly bool) (bz [
return nil, err
}

// Start the balance change handler before executing the precompile.
p.GetBalanceHandler().BeforeBalanceChange(ctx)

// This handles any out of gas errors that may occur during the execution of a precompile tx or query.
// It avoids panics and returns the out of gas error so the EVM can continue gracefully.
defer cmn.HandleGasError(ctx, contract, initialGas, &err)()
Expand Down Expand Up @@ -140,7 +143,8 @@ func (p Precompile) Run(evm *vm.EVM, contract *vm.Contract, readOnly bool) (bz [
return nil, vm.ErrOutOfGas
}

if err = p.AddJournalEntries(stateDB); err != nil {
// Process the native balance changes after the method execution.
if err = p.GetBalanceHandler().AfterBalanceChange(ctx, stateDB); err != nil {
return nil, err
}

Expand Down
Loading
Loading