diff --git a/CHANGELOG.md b/CHANGELOG.md index 287f4462..e239b9ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # CHANGELOG +## UNRELEASED + +### IMPROVEMENTS +- [\#1238](https://github.com/cosmos/evm/pull/1238) Validate ICS-20 acknowledgement encoding in the erc20 IBC v2 middleware. +- [\#1243](https://github.com/cosmos/evm/pull/1243) Deploy contracts from an EOA rather than a module account in the test helpers. It is also now required: contract creation bumps the sender's nonce, `SetAccount` persists nonce and balance together, and the EVM commit path may not write a module account's balance. + +### BUG FIXES +- [\#1222](https://github.com/cosmos/evm/pull/1222) Propagate ERC20 conversion ack in IBC v2 `OnRecvPacket`. +- [\#1253](https://github.com/cosmos/evm/pull/1253) Guard StateDB `SubBalance` against underflow and parse precompile balance-change events using both base and extended denoms. +- Make StateDB `Commit()` apply keeper writes through a cache context so a late failure cannot leave a partial write. + ## v0.6.0 Follow the [migration document](docs/migrations/v0.5.x_to_v0.6.0.md) for upgrade instructions. diff --git a/README.md b/README.md index d6a56a48..f023c303 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,12 @@ src="repo_header.svg" alt="Cosmos EVM - A plug-and-play solution that adds EVM compatibility and customizability to your chain" /> +## What is Cosmos EVM? + +Cosmos EVM is a plug-and-play solution that adds EVM compatibility and customizability to your Cosmos SDK chain. Cosmos EVM is used by Ondo, Mezo, Mantra, XRP sidechain, Telegram Application Chain (TAC), Stable, and others. Cosmos EVM equips Cosmos chains with complete Ethereum capabilities: Solidity smart contracts, Ethereum JSON-RPC, native support for the EVM wallet/token/user experience, and access to the entire Ethereum developer ecosystem. Its precompiles and extensions allow developers to leverage modules like [IBC](https://github.com/cosmos/ibc-go) with EVM and get native ERC-20 support for tokens on Cosmos. + +Cosmos EVM is customizable for your business use case, chain architecture, and performance needs. + **Please note**: This repo is undergoing changes while the code is being audited and tested. For the time being we will be making v0.x releases. Some breaking changes might occur. Cosmos Labs will only mark the Cosmos EVM repository as stable with a v1 release after the audit, key stability features and benchmarking are completed. diff --git a/evmd/tests/ibc/helper.go b/evmd/tests/ibc/helper.go index ba682116..a3d4a3ab 100644 --- a/evmd/tests/ibc/helper.go +++ b/evmd/tests/ibc/helper.go @@ -54,20 +54,22 @@ func SetupNativeErc20(t *testing.T, chain *evmibctesting.TestChain, senderAcc ev evmCtx := chain.GetContext() evmApp := chain.App.(evm.EvmApp) - ak := evmApp.GetAccountKeeper() - deployerAccAddr := sdk.AccAddress(erc20TestDeployer.Bytes()) - if ak.GetAccount(evmCtx, deployerAccAddr) == nil { - ak.SetAccount(evmCtx, ak.NewAccountWithAddress(evmCtx, deployerAccAddr)) + // Deploy new ERC20 contract with default metadata. + // The deployer is a dedicated EOA. + deployer := common.BytesToAddress([]byte("erc20-test-deployer")) + deployerAccAddr := sdk.AccAddress(deployer.Bytes()) + if evmApp.GetAccountKeeper().GetAccount(evmCtx, deployerAccAddr) == nil { + evmApp.GetAccountKeeper().SetAccount(evmCtx, evmApp.GetAccountKeeper().NewAccountWithAddress(evmCtx, deployerAccAddr)) } - stateDB := statedb.New(chain.GetContext(), chain.App.(evm.EvmApp).GetEVMKeeper(), statedb.NewEmptyTxConfig()) + stateDB := statedb.New(evmCtx, evmApp.GetEVMKeeper(), statedb.NewEmptyTxConfig()) contractAddr, err := DeployERC20Contract(evmCtx, stateDB, evmApp.GetAccountKeeper(), evmApp.GetEVMKeeper(), banktypes.Metadata{ DenomUnits: []*banktypes.DenomUnit{ {Denom: "example", Exponent: 18}, }, Name: "Example", Symbol: "Ex", - }) + }, deployer) if err != nil { t.Fatalf("ERC20 deployment failed: %v", err) } @@ -93,7 +95,7 @@ func SetupNativeErc20(t *testing.T, chain *evmibctesting.TestChain, senderAcc ev evmCtx, stateDB, contractAbi, - erc20TestDeployer, + deployer, contractAddr, true, false, @@ -121,7 +123,8 @@ func SetupNativeErc20(t *testing.T, chain *evmibctesting.TestChain, senderAcc ev } } -// SetupNativeErc20 deploys, registers, and mints a native ERC20 token on an EVM-based chain. +// DeployContract deploys an arbitrary contract on an EVM-based chain. +// Like DeployERC20Contract, the sender is an EOA rather than a module account. func DeployContract(t *testing.T, chain *evmibctesting.TestChain, deploymentData testutiltypes.ContractDeploymentData) (common.Address, error) { t.Helper() @@ -150,14 +153,20 @@ func DeployContract(t *testing.T, chain *evmibctesting.TestChain, deploymentData return crypto.CreateAddress(from, account.Nonce), nil } -var erc20TestDeployer = common.HexToAddress("0x000000000000000000000000000000000000beef") - +// DeployERC20Contract creates and deploys an ERC20 contract on the EVM with +// deployer as owner. +// +// deployer must be an EOA. Do not pass a module account. +// It is also required in practice. Contract creation bumps the sender's nonce, +// SetAccount persists nonce and balance together, and the EVM commit path is +// not allowed to write a module account's balance. func DeployERC20Contract( ctx sdk.Context, stateDB *statedb.StateDB, accountKeeper erc20types.AccountKeeper, evmKeeper erc20types.EVMKeeper, coinMetadata banktypes.Metadata, + deployer common.Address, ) (common.Address, error) { decimals := uint8(0) if len(coinMetadata.DenomUnits) > 0 { @@ -178,13 +187,13 @@ func DeployERC20Contract( copy(data[:len(contracts.ERC20MinterBurnerDecimalsContract.Bin)], contracts.ERC20MinterBurnerDecimalsContract.Bin) copy(data[len(contracts.ERC20MinterBurnerDecimalsContract.Bin):], ctorArgs) - nonce, err := accountKeeper.GetSequence(ctx, erc20TestDeployer.Bytes()) + nonce, err := accountKeeper.GetSequence(ctx, deployer.Bytes()) if err != nil { return common.Address{}, err } - contractAddr := crypto.CreateAddress(erc20TestDeployer, nonce) - _, err = evmKeeper.CallEVMWithData(ctx, stateDB, erc20TestDeployer, nil, data, true, false, nil) + contractAddr := crypto.CreateAddress(deployer, nonce) + _, err = evmKeeper.CallEVMWithData(ctx, stateDB, deployer, nil, data, true, false, nil) if err != nil { return common.Address{}, errorsmod.Wrapf(err, "failed to deploy contract for %s", coinMetadata.Name) } diff --git a/mempool/interface.go b/mempool/interface.go index b0984198..5d2fcf62 100644 --- a/mempool/interface.go +++ b/mempool/interface.go @@ -18,6 +18,7 @@ type VMKeeperI interface { GetParams(ctx sdk.Context) (params vmtypes.Params) GetEvmCoinInfo(ctx sdk.Context) (coinInfo vmtypes.EvmCoinInfo) GetAccount(ctx sdk.Context, addr common.Address) *statedb.Account + IsBaseAccountOrEmpty(ctx sdk.Context, addr common.Address) bool GetState(ctx sdk.Context, addr common.Address, key common.Hash) common.Hash GetCode(ctx sdk.Context, codeHash common.Hash) []byte GetCodeHash(ctx sdk.Context, addr common.Address) common.Hash diff --git a/mempool/mocks/VMKeeper.go b/mempool/mocks/VMKeeper.go index 585af69d..9dc7ac1c 100644 --- a/mempool/mocks/VMKeeper.go +++ b/mempool/mocks/VMKeeper.go @@ -77,6 +77,24 @@ func (_m *VMKeeper) GetAccount(ctx types.Context, addr common.Address) *statedb. return r0 } +// IsBaseAccountOrEmpty provides a mock function with given fields: ctx, addr +func (_m *VMKeeper) IsBaseAccountOrEmpty(ctx types.Context, addr common.Address) bool { + ret := _m.Called(ctx, addr) + + if len(ret) == 0 { + panic("no return value specified for IsBaseAccountOrEmpty") + } + + var r0 bool + if rf, ok := ret.Get(0).(func(types.Context, common.Address) bool); ok { + r0 = rf(ctx, addr) + } else { + r0 = ret.Get(0).(bool) + } + + return r0 +} + // GetBaseFee provides a mock function with given fields: ctx func (_m *VMKeeper) GetBaseFee(ctx types.Context) *big.Int { ret := _m.Called(ctx) diff --git a/precompiles/common/balance_handler_test.go b/precompiles/common/balance_handler_test.go index 560c9377..2675b506 100644 --- a/precompiles/common/balance_handler_test.go +++ b/precompiles/common/balance_handler_test.go @@ -99,12 +99,14 @@ func TestParseAddress(t *testing.T) { func TestParseAmount(t *testing.T) { testCases := []struct { name string + chainID testconstants.ChainID maleate func() sdk.Event expAmt *uint256.Int expError bool }{ { - name: "valid amount", + name: "valid amount", + chainID: testconstants.ExampleChainID, maleate: func() sdk.Event { coinStr := sdk.NewCoins(sdk.NewInt64Coin(evmtypes.GetEVMCoinDenom(), 5)).String() return sdk.NewEvent("bank", sdk.NewAttribute(sdk.AttributeKeyAmount, coinStr)) @@ -112,14 +114,55 @@ func TestParseAmount(t *testing.T) { expAmt: uint256.NewInt(5), }, { - name: "missing amount", + name: "unrelated denom is ignored", + chainID: testconstants.ExampleChainID, + maleate: func() sdk.Event { + coinStr := sdk.NewCoins(sdk.NewInt64Coin("foobar", 7)).String() + return sdk.NewEvent("bank", sdk.NewAttribute(sdk.AttributeKeyAmount, coinStr)) + }, + expAmt: uint256.NewInt(0), + }, + { + name: "base denom is scaled to 18 decimals", + chainID: testconstants.SixDecimalsChainID, + maleate: func() sdk.Event { + coinStr := sdk.NewCoins(sdk.NewInt64Coin(evmtypes.GetEVMCoinDenom(), 100)).String() + return sdk.NewEvent("bank", sdk.NewAttribute(sdk.AttributeKeyAmount, coinStr)) + }, + expAmt: uint256.NewInt(100_000_000_000_000), + }, + { + name: "extended denom is taken as is", + chainID: testconstants.SixDecimalsChainID, + maleate: func() sdk.Event { + coinStr := sdk.NewCoins(sdk.NewInt64Coin(evmtypes.GetEVMCoinExtendedDenom(), 500)).String() + return sdk.NewEvent("bank", sdk.NewAttribute(sdk.AttributeKeyAmount, coinStr)) + }, + expAmt: uint256.NewInt(500), + }, + { + name: "base and extended denoms are summed", + chainID: testconstants.SixDecimalsChainID, + maleate: func() sdk.Event { + coinStr := sdk.NewCoins( + sdk.NewInt64Coin(evmtypes.GetEVMCoinDenom(), 100), + sdk.NewInt64Coin(evmtypes.GetEVMCoinExtendedDenom(), 500), + ).String() + return sdk.NewEvent("bank", sdk.NewAttribute(sdk.AttributeKeyAmount, coinStr)) + }, + expAmt: uint256.NewInt(100_000_000_000_500), + }, + { + name: "missing amount", + chainID: testconstants.ExampleChainID, maleate: func() sdk.Event { return sdk.NewEvent("bank") }, expError: true, }, { - name: "invalid coins", + name: "invalid coins", + chainID: testconstants.ExampleChainID, maleate: func() sdk.Event { return sdk.NewEvent("bank", sdk.NewAttribute(sdk.AttributeKeyAmount, "invalid")) }, @@ -129,7 +172,9 @@ func TestParseAmount(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - setupBalanceHandlerTest(t) + configurator := evmtypes.NewEVMConfigurator() + configurator.ResetTestConfig() + require.NoError(t, configurator.WithEVMCoinInfo(testconstants.ExampleChainCoinInfo[tc.chainID]).Configure()) amt, err := cmn.ParseAmount(tc.maleate()) if tc.expError { @@ -138,7 +183,7 @@ func TestParseAmount(t *testing.T) { } require.NoError(t, err) - require.True(t, amt.Eq(tc.expAmt)) + require.Equal(t, tc.expAmt.String(), amt.String()) }) } } diff --git a/precompiles/common/utils.go b/precompiles/common/utils.go index 520dc5e0..d0be25ab 100644 --- a/precompiles/common/utils.go +++ b/precompiles/common/utils.go @@ -42,8 +42,14 @@ func ParseAmount(event sdk.Event) (*uint256.Int, error) { 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)) + baseAmount := amountCoins.AmountOf(evmtypes.GetEVMCoinDenom()).BigInt() + amountBigInt := evmtypes.ConvertAmountTo18DecimalsBigInt(baseAmount) + if evmtypes.GetEVMCoinExtendedDenom() != evmtypes.GetEVMCoinDenom() { + extendedAmount := amountCoins.AmountOf(evmtypes.GetEVMCoinExtendedDenom()).BigInt() + amountBigInt = new(big.Int).Add(amountBigInt, extendedAmount) + } + + amount, err := utils.Uint256FromBigInt(amountBigInt) if err != nil { return nil, fmt.Errorf("failed to convert coin amount to Uint256: %w", err) } diff --git a/tests/integration/x/vm/test_call_evm.go b/tests/integration/x/vm/test_call_evm.go index 4724cf10..abe98149 100644 --- a/tests/integration/x/vm/test_call_evm.go +++ b/tests/integration/x/vm/test_call_evm.go @@ -11,6 +11,8 @@ import ( "github.com/cosmos/evm/x/erc20/types" "github.com/cosmos/evm/x/vm/statedb" evmtypes "github.com/cosmos/evm/x/vm/types" + + sdk "github.com/cosmos/cosmos-sdk/types" ) func (s *KeeperTestSuite) TestCallEVM() { @@ -73,10 +75,25 @@ func (s *KeeperTestSuite) TestCallEVM() { func (s *KeeperTestSuite) TestCallEVMWithData() { erc20 := contracts.ERC20MinterBurnerDecimalsContract.ABI wcosmosEVMContract := common.HexToAddress(testconstants.WEVMOSContractMainnet) + + // Deployments run from a dedicated EOA rather than a module account. + // It also fails in practice: contract creation bumps the sender's nonce, + // SetAccount writes nonce and balance together, and the EVM commit path may + // not write a module account's balance. + deployer := common.BytesToAddress([]byte("vm-test-deployer")) + // The sender's sequence is read before the message runs, so the account has to exist. + ensureDeployer := func() { + ctx := s.Network.GetContext() + ak := s.Network.App.GetAccountKeeper() + accAddr := sdk.AccAddress(deployer.Bytes()) + if ak.GetAccount(ctx, accAddr) == nil { + ak.SetAccount(ctx, ak.NewAccountWithAddress(ctx, accAddr)) + } + } + testCases := []struct { name string from common.Address - fromFn func() common.Address malleate func() []byte deploy bool useNilDB bool @@ -146,10 +163,9 @@ func (s *KeeperTestSuite) TestCallEVMWithData() { }, { name: "deploy", - fromFn: func() common.Address { - return s.Keyring.GetAddr(0) - }, + from: deployer, malleate: func() []byte { + ensureDeployer() ctorArgs, _ := contracts.ERC20MinterBurnerDecimalsContract.ABI.Pack("", "test", "test", uint8(18)) data := append(contracts.ERC20MinterBurnerDecimalsContract.Bin, ctorArgs...) //nolint:gocritic return data @@ -161,8 +177,9 @@ func (s *KeeperTestSuite) TestCallEVMWithData() { }, { name: "fail deploy", - from: types.ModuleAddress, + from: deployer, malleate: func() []byte { + ensureDeployer() params := s.Network.App.GetEVMKeeper().GetParams(s.Network.GetContext()) params.AccessControl.Create = evmtypes.AccessControlType{ AccessType: evmtypes.AccessTypeRestricted, @@ -175,12 +192,13 @@ func (s *KeeperTestSuite) TestCallEVMWithData() { deploy: true, useNilDB: false, expPass: false, - expError: "", + expError: "does not have permission to deploy contracts", }, { name: "fail deploy with nil statedb", - from: types.ModuleAddress, + from: deployer, malleate: func() []byte { + ensureDeployer() ctorArgs, _ := contracts.ERC20MinterBurnerDecimalsContract.ABI.Pack("", "test", "test", uint8(18)) data := append(contracts.ERC20MinterBurnerDecimalsContract.Bin, ctorArgs...) //nolint:gocritic return data @@ -206,9 +224,6 @@ func (s *KeeperTestSuite) TestCallEVMWithData() { } from := tc.from - if tc.fromFn != nil { - from = tc.fromFn() - } if tc.deploy { res, err = s.Network.App.GetEVMKeeper().CallEVMWithData(s.Network.GetContext(), stateDB, from, nil, data, true, false, nil) diff --git a/tests/integration/x/vm/test_statedb.go b/tests/integration/x/vm/test_statedb.go index 80a5d56a..d8f88fd0 100644 --- a/tests/integration/x/vm/test_statedb.go +++ b/tests/integration/x/vm/test_statedb.go @@ -98,6 +98,122 @@ func (s *KeeperTestSuite) TestCreateAccount() { } } +// TestIsBaseAccountOrEmpty exercises Keeper.IsBaseAccountOrEmpty directly +// against the real AccountKeeper: it must report an address as safe for EVM +// contract deployment only when no account exists there yet, or when the +// account is a plain BaseAccount -- and must report it unsafe once the +// address is staged as a DelayedVestingAccount +func (s *KeeperTestSuite) TestIsBaseAccountOrEmpty() { + testCases := []struct { + name string + malleate func(sdk.Context, common.Address) + expSafe bool + }{ + { + "no account at all", + func(sdk.Context, common.Address) {}, + true, + }, + { + "plain funded BaseAccount", + func(ctx sdk.Context, addr common.Address) { + err := s.Network.App.GetBankKeeper().SendCoins( + ctx, s.Keyring.GetAccAddr(0), addr.Bytes(), + sdk.NewCoins(sdk.NewCoin(s.Network.GetBaseDenom(), math.NewInt(100))), + ) + s.Require().NoError(err) + }, + true, + }, + { + "staged DelayedVestingAccount", + func(ctx sdk.Context, addr common.Address) { + accAddr := sdk.AccAddress(addr.Bytes()) + err := s.Network.App.GetBankKeeper().SendCoins( + ctx, s.Keyring.GetAccAddr(0), accAddr, + sdk.NewCoins(sdk.NewCoin(s.Network.GetBaseDenom(), math.NewInt(2))), + ) + s.Require().NoError(err) + + baseAccount := s.Network.App.GetAccountKeeper().GetAccount(ctx, accAddr).(*authtypes.BaseAccount) + vestingAcc, err := vestingtypes.NewDelayedVestingAccount( + baseAccount, + sdk.NewCoins(sdk.NewCoin(s.Network.GetBaseDenom(), math.NewInt(2))), + ctx.BlockTime().Unix()+31536000, // ~1 year, mirrors the incident's staging + ) + s.Require().NoError(err) + s.Network.App.GetAccountKeeper().SetAccount(ctx, vestingAcc) + }, + false, + }, + } + + for _, tc := range testCases { + s.Run(tc.name, func() { + s.SetupTest() + ctx := s.Network.GetContext() + addr := utiltx.GenerateAddress() + tc.malleate(ctx, addr) + + s.Require().Equal(tc.expSafe, s.Network.App.GetEVMKeeper().IsBaseAccountOrEmpty(ctx, addr)) + }) + } +} + +// TestCreateAccountBlocksStagedVestingAccount replays the KiiChain incident's +// account-staging step against the real AccountKeeper: deploying an EVM +// contract onto an address already turned into a DelayedVestingAccount must +// panic, and the counterfactual-wallet pattern (a plain BaseAccount created +// by pre-funding a not-yet-deployed address) must keep working +func (s *KeeperTestSuite) TestCreateAccountBlocksStagedVestingAccount() { + s.Run("staged DelayedVestingAccount blocks deployment", func() { + s.SetupTest() + ctx := s.Network.GetContext() + addr := utiltx.GenerateAddress() + accAddr := sdk.AccAddress(addr.Bytes()) + + err := s.Network.App.GetBankKeeper().SendCoins( + ctx, s.Keyring.GetAccAddr(0), accAddr, + sdk.NewCoins(sdk.NewCoin(s.Network.GetBaseDenom(), math.NewInt(2))), + ) + s.Require().NoError(err) + + baseAccount := s.Network.App.GetAccountKeeper().GetAccount(ctx, accAddr).(*authtypes.BaseAccount) + vestingAcc, err := vestingtypes.NewDelayedVestingAccount( + baseAccount, + sdk.NewCoins(sdk.NewCoin(s.Network.GetBaseDenom(), math.NewInt(2))), + ctx.BlockTime().Unix()+31536000, + ) + s.Require().NoError(err) + s.Network.App.GetAccountKeeper().SetAccount(ctx, vestingAcc) + + vmdb := s.StateDB() + s.Require().Panics(func() { + vmdb.CreateAccount(addr) + }) + }) + + s.Run("pre-funded BaseAccount still allows deployment", func() { + s.SetupTest() + ctx := s.Network.GetContext() + addr := utiltx.GenerateAddress() + accAddr := sdk.AccAddress(addr.Bytes()) + + // counterfactual-wallet pattern: fund the address before any + // contract is deployed there. + err := s.Network.App.GetBankKeeper().SendCoins( + ctx, s.Keyring.GetAccAddr(0), accAddr, + sdk.NewCoins(sdk.NewCoin(s.Network.GetBaseDenom(), math.NewInt(100))), + ) + s.Require().NoError(err) + + vmdb := s.StateDB() + s.Require().NotPanics(func() { + vmdb.CreateAccount(addr) + }) + }) +} + func (s *KeeperTestSuite) TestAddBalance() { testCases := []struct { name string @@ -132,6 +248,152 @@ func (s *KeeperTestSuite) TestAddBalance() { } } +// TestAddBalanceOverflow exercises the AddBalance overflow guard through the +// full StateDB/keeper integration path. It partitions inputs into: a normal +// credit that must commit, a boundary credit that lands exactly on max +// uint256 and must commit, and a credit that would wrap past max uint256 and +// must instead revert the state transition via panic rather than silently +// wrapping the account balance +func (s *KeeperTestSuite) TestAddBalanceOverflow() { + maxUint256 := func() *uint256.Int { return new(uint256.Int).SetAllOne() } + + testCases := []struct { + name string + malleate func(vm.StateDB, common.Address) + amount *uint256.Int + expectPanic bool + }{ + { + "normal credit commits", + func(vm.StateDB, common.Address) {}, + uint256.NewInt(100), + false, + }, + { + "boundary credit up to max uint256 commits", + func(vm.StateDB, common.Address) {}, + maxUint256(), + false, + }, + { + "credit past max uint256 reverts instead of wrapping", + func(vmdb vm.StateDB, addr common.Address) { + vmdb.AddBalance(addr, maxUint256(), tracing.BalanceChangeUnspecified) + }, + uint256.NewInt(1), + true, + }, + } + + for _, tc := range testCases { + s.Run(tc.name, func() { + vmdb := s.StateDB() + addr := utiltx.GenerateAddress() + tc.malleate(vmdb, addr) + prev := vmdb.GetBalance(addr) + + addBalance := func() { + vmdb.AddBalance(addr, tc.amount, tracing.BalanceChangeUnspecified) + } + + if tc.expectPanic { + s.Require().Panics(addBalance) + // the balance must be left unchanged by the panicking call. + s.Require().Equal(prev, vmdb.GetBalance(addr)) + } else { + s.Require().NotPanics(addBalance) + s.Require().Equal(new(uint256.Int).Add(prev, tc.amount), vmdb.GetBalance(addr)) + } + }) + } +} + +// TestDelegateThenDrainExploitChain replays, at the StateDB level, the exact +// two-step call sequence from the reconstructed KiiChain incident exploit +// contract's delegateThenDrain(): step 1 mirrors a staking precompile's +// post-delegation balance write-back (an over-delegation subtracted from the +// delegator's spendable balance), step 2 mirrors the delegator's +// negated-value call draining a victim by crediting it with +// `0 - victim.balance` (Solidity's `unchecked { 0 - victim.balance }`). +// +// It proves the two hardening guards compose correctly against the chained +// attack shape: the underflow guard on step 1 alone stops the exploit before +// the drain is ever attempted, and the overflow guard on step 2 alone stops +// the drain even when the attacker's balance is already inflated through +// means unrelated to the delegation write-back. +func (s *KeeperTestSuite) TestDelegateThenDrainExploitChain() { + maxUint256 := func() *uint256.Int { return new(uint256.Int).SetAllOne() } + + // drainAmountFor replays `unchecked { 0 - victim.balance }` from the + // exploit contract: it must wrap around uint256, matching Solidity's + // unchecked block, not panic. + drainAmountFor := func(victimBalance *uint256.Int) *uint256.Int { + return new(uint256.Int).Sub(new(uint256.Int), victimBalance) + } + + s.Run("step 1 underflow guard stops the exploit before the drain is reached", func() { + vmdb := s.StateDB() + attacker := utiltx.GenerateAddress() + victim := utiltx.GenerateAddress() + + spendable := uint256.NewInt(100) + vmdb.AddBalance(attacker, spendable, tracing.BalanceChangeUnspecified) + vmdb.AddBalance(victim, uint256.NewInt(50), tracing.BalanceChangeUnspecified) + victimBalanceBefore := vmdb.GetBalance(victim) + + // delegate(spendable + 1 wei): the over-delegation the exploit relies + // on to underflow the delegator's mirrored EVM balance. + delegateAmount := new(uint256.Int).AddUint64(spendable, 1) + + s.Require().Panics(func() { + // step 1: staking precompile's post-delegation write-back. + vmdb.SubBalance(attacker, delegateAmount, tracing.BalanceChangeUnspecified) + + // step 2 would run here in the real contract, but must never be + // reached: the panic above aborts the call first. + drainAmount := drainAmountFor(vmdb.GetBalance(victim)) + vmdb.SubBalance(attacker, drainAmount, tracing.BalanceChangeUnspecified) + vmdb.AddBalance(victim, drainAmount, tracing.BalanceChangeUnspecified) + }) + + // neither balance moved: the whole chained call aborted at step 1. + s.Require().Equal(spendable, vmdb.GetBalance(attacker)) + s.Require().Equal(victimBalanceBefore, vmdb.GetBalance(victim)) + }) + + s.Run("step 2 overflow guard stops the drain even with an already-inflated attacker balance", func() { + vmdb := s.StateDB() + attacker := utiltx.GenerateAddress() + victim := utiltx.GenerateAddress() + + // simulate an attacker balance already at the maximum through means + // unrelated to the (already-guarded) delegation write-back, to prove + // the overflow guard is an independent layer, not merely downstream + // of the underflow guard. + vmdb.AddBalance(attacker, maxUint256(), tracing.BalanceChangeUnspecified) + vmdb.AddBalance(victim, uint256.NewInt(100), tracing.BalanceChangeUnspecified) + victimBalanceBefore := vmdb.GetBalance(victim) + attackerBalanceBefore := vmdb.GetBalance(attacker) + + drainAmount := drainAmountFor(victimBalanceBefore) + + s.Require().Panics(func() { + // step 2: the negated-value call. The sender-side leg succeeds + // (the attacker's inflated balance comfortably covers it, exactly + // as in the real exploit)... + vmdb.SubBalance(attacker, drainAmount, tracing.BalanceChangeUnspecified) + // ...but the recipient-side credit must overflow-guard instead of + // wrapping the victim's balance to (near) zero. + vmdb.AddBalance(victim, drainAmount, tracing.BalanceChangeUnspecified) + }) + + // the sender-side leg did debit normally (it never underflowed)... + s.Require().Equal(new(uint256.Int).Sub(attackerBalanceBefore, drainAmount), vmdb.GetBalance(attacker)) + // ...but the victim must be untouched: the drain never completed. + s.Require().Equal(victimBalanceBefore, vmdb.GetBalance(victim)) + }) +} + func (s *KeeperTestSuite) TestSubBalance() { testCases := []struct { name string @@ -1148,6 +1410,90 @@ func (s *KeeperTestSuite) TestSetBalance() { } } +func (s *KeeperTestSuite) TestSetBalanceRejectsModuleAccounts() { + type setup struct { + addr common.Address + current *uint256.Int + } + + cases := []struct { + name string + prepare func() setup + amountFn func(current *uint256.Int) *uint256.Int + }{ + { + name: "mocked module account (isModule arm)", + prepare: func() setup { + ctx := s.Network.GetContext() + ak := s.Network.App.GetAccountKeeper() + acc := authtypes.NewEmptyModuleAccount("test-blocked-stale-overwrite", authtypes.Minter) + ak.NewAccount(ctx, acc) + ak.SetAccount(ctx, acc) + modEth := common.BytesToAddress(acc.GetAddress().Bytes()) + return setup{ + addr: modEth, + current: s.Network.App.GetEVMKeeper().GetBalance(ctx, modEth), + } + }, + amountFn: func(_ *uint256.Int) *uint256.Int { return uint256.NewInt(12345) }, + }, + { + name: "bonded_tokens_pool, decrease (isBlockedChange arm)", + prepare: func() setup { + modEth := common.BytesToAddress(authtypes.NewModuleAddress(stakingtypes.BondedPoolName).Bytes()) + return setup{ + addr: modEth, + current: s.Network.App.GetEVMKeeper().GetBalance(s.Network.GetContext(), modEth), + } + }, + amountFn: func(cur *uint256.Int) *uint256.Int { + if cur.IsZero() { + return uint256.NewInt(0) + } + return new(uint256.Int).Sub(cur, uint256.NewInt(1)) + }, + }, + { + name: "bonded_tokens_pool, equal (isModule arm, isBlockedChange skipped)", + prepare: func() setup { + modEth := common.BytesToAddress(authtypes.NewModuleAddress(stakingtypes.BondedPoolName).Bytes()) + return setup{ + addr: modEth, + current: s.Network.App.GetEVMKeeper().GetBalance(s.Network.GetContext(), modEth), + } + }, + amountFn: func(cur *uint256.Int) *uint256.Int { return new(uint256.Int).Set(cur) }, + }, + } + + for _, tc := range cases { + s.Run(tc.name, func() { + s.SetupTest() + st := tc.prepare() + amount := tc.amountFn(st.current) + + err := s.Network.App.GetEVMKeeper().SetBalance(s.Network.GetContext(), st.addr, amount) + s.Require().Error(err) + s.Require().Contains(err.Error(), "is not allowed to receive funds") + + after := s.Network.App.GetEVMKeeper().GetBalance(s.Network.GetContext(), st.addr) + s.Require().Equal(st.current, after) + }) + } +} + +func (s *KeeperTestSuite) TestSetBalanceAllowsEOA() { + s.SetupTest() + addr := utiltx.GenerateAddress() + amount := uint256.NewInt(12345) + + err := s.Network.App.GetEVMKeeper().SetBalance(s.Network.GetContext(), addr, amount) + s.Require().NoError(err) + + got := s.Network.App.GetEVMKeeper().GetBalance(s.Network.GetContext(), addr) + s.Require().Equal(amount, got) +} + func (s *KeeperTestSuite) TestSetBalanceWithLocked() { amount := common.U2560 var locked *big.Int @@ -1330,91 +1676,6 @@ func (s *KeeperTestSuite) TestDeleteAccount() { } } -func (s *KeeperTestSuite) TestSetBalanceRejectsModuleAccounts() { - type setup struct { - addr common.Address - current *uint256.Int - } - - mockModuleSetup := func(name string, initialBalance int64) func() setup { - return func() setup { - ctx := s.Network.GetContext() - ak := s.Network.App.GetAccountKeeper() - acc := authtypes.NewEmptyModuleAccount(name, authtypes.Minter) - ak.NewAccount(ctx, acc) - ak.SetAccount(ctx, acc) - if initialBalance > 0 { - err := s.Network.App.GetBankKeeper().SendCoins( - ctx, - s.Keyring.GetAccAddr(0), - acc.GetAddress(), - sdk.NewCoins(sdk.NewCoin(s.Network.GetBaseDenom(), math.NewInt(initialBalance))), - ) - s.Require().NoError(err) - } - modEth := common.BytesToAddress(acc.GetAddress().Bytes()) - return setup{ - addr: modEth, - current: s.Network.App.GetEVMKeeper().GetBalance(ctx, modEth), - } - } - } - - cases := []struct { - name string - prepare func() setup - amountFn func(current *uint256.Int) *uint256.Int - }{ - { - name: "mock module account, zero balance, write nonzero", - prepare: mockModuleSetup("test-mod-zero", 0), - amountFn: func(_ *uint256.Int) *uint256.Int { return uint256.NewInt(12345) }, - }, - { - name: "mock module account, decrease", - prepare: mockModuleSetup("test-mod-decrease", 1000), - amountFn: func(cur *uint256.Int) *uint256.Int { - if cur.IsZero() { - return uint256.NewInt(0) - } - return new(uint256.Int).Sub(cur, uint256.NewInt(1)) - }, - }, - { - name: "mock module account, equal", - prepare: mockModuleSetup("test-mod-equal", 1000), - amountFn: func(cur *uint256.Int) *uint256.Int { return new(uint256.Int).Set(cur) }, - }, - } - - for _, tc := range cases { - s.Run(tc.name, func() { - s.SetupTest() - st := tc.prepare() - amount := tc.amountFn(st.current) - - err := s.Network.App.GetEVMKeeper().SetBalance(s.Network.GetContext(), st.addr, amount) - s.Require().Error(err) - s.Require().Contains(err.Error(), "is not allowed to receive funds") - - after := s.Network.App.GetEVMKeeper().GetBalance(s.Network.GetContext(), st.addr) - s.Require().Equal(st.current, after) - }) - } -} - -func (s *KeeperTestSuite) TestSetBalanceAllowsEOA() { - s.SetupTest() - addr := utiltx.GenerateAddress() - amount := uint256.NewInt(12345) - - err := s.Network.App.GetEVMKeeper().SetBalance(s.Network.GetContext(), addr, amount) - s.Require().NoError(err) - - got := s.Network.App.GetEVMKeeper().GetBalance(s.Network.GetContext(), addr) - s.Require().Equal(amount, got) -} - func (s *KeeperTestSuite) TestSetBalanceBlockedNonModuleArm() { s.SetupTest() diff --git a/x/erc20/ibc_middleware.go b/x/erc20/ibc_middleware.go index 1b653233..6574d485 100644 --- a/x/erc20/ibc_middleware.go +++ b/x/erc20/ibc_middleware.go @@ -1,6 +1,7 @@ package erc20 import ( + "bytes" "errors" "github.com/cosmos/evm/ibc" @@ -81,6 +82,11 @@ func (im IBCMiddleware) OnAcknowledgementPacket( return errorsmod.Wrapf(errortypes.ErrUnknownRequest, "cannot unmarshal ICS-20 transfer packet acknowledgement: %v", err) } + bz := transfertypes.ModuleCdc.MustMarshalJSON(&ack) + if !bytes.Equal(bz, acknowledgement) { + return errorsmod.Wrapf(errortypes.ErrInvalidType, "acknowledgement did not marshal to expected bytes: %X ≠ %X", bz, acknowledgement) + } + var data transfertypes.FungibleTokenPacketData if err := transfertypes.ModuleCdc.UnmarshalJSON(packet.GetData(), &data); err != nil { return errorsmod.Wrapf(errortypes.ErrUnknownRequest, "cannot unmarshal ICS-20 transfer packet data: %s", err.Error()) diff --git a/x/erc20/types/interfaces.go b/x/erc20/types/interfaces.go index a03dbe2f..f5e644f4 100644 --- a/x/erc20/types/interfaces.go +++ b/x/erc20/types/interfaces.go @@ -51,6 +51,7 @@ type EVMKeeper interface { SetCode(ctx sdk.Context, hash []byte, bytecode []byte) SetAccount(ctx sdk.Context, address common.Address, account statedb.Account) error GetAccount(ctx sdk.Context, address common.Address) *statedb.Account + IsBaseAccountOrEmpty(ctx sdk.Context, addr common.Address) bool IsContract(ctx sdk.Context, address common.Address) bool GetState(ctx sdk.Context, addr common.Address, key common.Hash) common.Hash GetCodeHash(ctx sdk.Context, addr common.Address) common.Hash diff --git a/x/erc20/types/mocks/EVMKeeper.go b/x/erc20/types/mocks/EVMKeeper.go index 71dba53c..4fbaceee 100644 --- a/x/erc20/types/mocks/EVMKeeper.go +++ b/x/erc20/types/mocks/EVMKeeper.go @@ -341,6 +341,24 @@ func (_m *EVMKeeper) IsContract(ctx types.Context, address common.Address) bool return r0 } +// IsBaseAccountOrEmpty provides a mock function with given fields: ctx, addr +func (_m *EVMKeeper) IsBaseAccountOrEmpty(ctx types.Context, addr common.Address) bool { + ret := _m.Called(ctx, addr) + + if len(ret) == 0 { + panic("no return value specified for IsBaseAccountOrEmpty") + } + + var r0 bool + if rf, ok := ret.Get(0).(func(types.Context, common.Address) bool); ok { + r0 = rf(ctx, addr) + } else { + r0 = ret.Get(0).(bool) + } + + return r0 +} + // KVStoreKeys provides a mock function with no fields func (_m *EVMKeeper) KVStoreKeys() map[string]*storetypes.KVStoreKey { ret := _m.Called() diff --git a/x/erc20/v2/ibc_middleware.go b/x/erc20/v2/ibc_middleware.go index e3a0f35f..9cba22c3 100644 --- a/x/erc20/v2/ibc_middleware.go +++ b/x/erc20/v2/ibc_middleware.go @@ -12,6 +12,9 @@ import ( channeltypes "github.com/cosmos/ibc-go/v10/modules/core/04-channel/types" channeltypesv2 "github.com/cosmos/ibc-go/v10/modules/core/04-channel/v2/types" ibcapi "github.com/cosmos/ibc-go/v10/modules/core/api" + ibcerrors "github.com/cosmos/ibc-go/v10/modules/core/errors" + + errorsmod "cosmossdk.io/errors" sdk "github.com/cosmos/cosmos-sdk/types" ) @@ -86,8 +89,14 @@ func (im IBCMiddleware) OnRecvPacket( Status: channeltypesv2.PacketStatus_Failure, } } - im.keeper.OnRecvPacket(ctx, packet, ack) - return recvResult + modifiedAck := im.keeper.OnRecvPacket(ctx, packet, ack) + if !modifiedAck.Success() { + return channeltypesv2.RecvPacketResult{Status: channeltypesv2.PacketStatus_Failure} + } + if !bytes.Equal(modifiedAck.Acknowledgement(), ack.Acknowledgement()) { + ctx.Logger().Error("erc20 ibcv2 middleware keeper modified the application ack", "original", ack.Acknowledgement(), "modified", modifiedAck.Acknowledgement()) + } + return channeltypesv2.RecvPacketResult{Status: recvResult.Status, Acknowledgement: modifiedAck.Acknowledgement()} } // OnAcknowledgementPacket implements the IBCModule interface. @@ -102,6 +111,26 @@ func (im IBCMiddleware) OnAcknowledgementPacket( payload channeltypesv2.Payload, relayer sdk.AccAddress, ) error { + var ack channeltypes.Acknowledgement + if bytes.Equal(acknowledgement, channeltypesv2.ErrorAcknowledgement[:]) { + // construct an error acknowledgement from the sentinel so we can reuse the shared transfer logic + ack = channeltypes.NewErrorAcknowledgement(transfertypes.ErrReceiveFailed) + } else { + if err := transfertypes.ModuleCdc.UnmarshalJSON(acknowledgement, &ack); err != nil { + im.keeper.Logger(ctx).Error(fmt.Sprintf("erc20 middleware OnAckPacket failed to unmarshal acknowledgement: %s", err.Error())) + return errorsmod.Wrapf(ibcerrors.ErrUnknownRequest, "cannot unmarshal ICS-20 transfer packet acknowledgement: %v", err) + } + + bz := transfertypes.ModuleCdc.MustMarshalJSON(&ack) + if !bytes.Equal(bz, acknowledgement) { + return errorsmod.Wrapf(ibcerrors.ErrInvalidType, "acknowledgement did not marshal to expected bytes: %X ≠ %X", bz, acknowledgement) + } + + if !ack.Success() { + return errorsmod.Wrapf(ibcerrors.ErrInvalidRequest, "cannot pass in a custom error acknowledgement with IBC v2") + } + } + if err := im.app.OnAcknowledgementPacket(ctx, sourceClient, destinationClient, sequence, acknowledgement, payload, relayer); err != nil { im.keeper.Logger(ctx).Error(fmt.Sprintf("erc20 middleware OnAckPacket failed to call underlying app: %s", err.Error())) return err @@ -112,20 +141,13 @@ func (im IBCMiddleware) OnAcknowledgementPacket( im.keeper.Logger(ctx).Error(fmt.Sprintf("erc20 middleware OnAckPacketfailed failed to convert v2 packet to v1 packet: %s", err.Error())) return err } + var data transfertypes.FungibleTokenPacketData - if err = transfertypes.ModuleCdc.UnmarshalJSON(packet.GetData(), &data); err != nil { + if err := transfertypes.ModuleCdc.UnmarshalJSON(packet.GetData(), &data); err != nil { im.keeper.Logger(ctx).Error(fmt.Sprintf("erc20 middleware OnAckPacket failed to unmarshal packet data: %s", err.Error())) return err } - var ack channeltypes.Acknowledgement - if bytes.Equal(acknowledgement, channeltypesv2.ErrorAcknowledgement[:]) { - ack = channeltypes.NewErrorAcknowledgement(transfertypes.ErrReceiveFailed) - } else { - if err = transfertypes.ModuleCdc.UnmarshalJSON(acknowledgement, &ack); err != nil { - im.keeper.Logger(ctx).Error(fmt.Sprintf("erc20 middleware OnAckPacket failed to unmarshal acknowledgement: %s", err.Error())) - return err - } - } + return im.keeper.OnAcknowledgementPacket(ctx, packet, data, ack) } diff --git a/x/ibc/callbacks/types/expected_keepers.go b/x/ibc/callbacks/types/expected_keepers.go index 546a1c7e..ccb32f79 100644 --- a/x/ibc/callbacks/types/expected_keepers.go +++ b/x/ibc/callbacks/types/expected_keepers.go @@ -28,6 +28,7 @@ type EVMKeeper interface { CallEVMWithData(ctx sdk.Context, stateDB *statedb.StateDB, from common.Address, contract *common.Address, data []byte, commit bool, callFromPrecompile bool, gasCap *big.Int) (*evmtypes.MsgEthereumTxResponse, error) GetAccountOrEmpty(ctx sdk.Context, addr common.Address) statedb.Account GetAccount(ctx sdk.Context, addr common.Address) *statedb.Account + IsBaseAccountOrEmpty(ctx sdk.Context, addr common.Address) bool IsContract(ctx sdk.Context, addr common.Address) bool GetState(ctx sdk.Context, addr common.Address, key common.Hash) common.Hash GetCode(ctx sdk.Context, codeHash common.Hash) []byte diff --git a/x/vm/keeper/statedb.go b/x/vm/keeper/statedb.go index 87596add..605c98c0 100644 --- a/x/vm/keeper/statedb.go +++ b/x/vm/keeper/statedb.go @@ -41,6 +41,20 @@ func (k *Keeper) GetAccount(ctx sdk.Context, addr common.Address) *statedb.Accou ) } +// IsBaseAccountOrEmpty reports whether addr has no Cosmos account yet, or +// carries exactly the plain BaseAccount type. It is the guard CreateAccount +// uses to reject EVM contract deployment onto an address that already +// carries Cosmos-native privileges (vesting, module accounts, or any other +// non-BaseAccount AccountI implementation) +func (k *Keeper) IsBaseAccountOrEmpty(ctx sdk.Context, addr common.Address) bool { + acct := k.accountKeeper.GetAccount(ctx, addr.Bytes()) + if acct == nil { + return true + } + _, ok := acct.(*authtypes.BaseAccount) + return ok +} + // GetState loads contract state from database. func (k *Keeper) GetState(ctx sdk.Context, addr common.Address, key common.Hash) common.Hash { store := prefix.NewStore(ctx.KVStore(k.storeKey), types.AddressStoragePrefix(addr)) diff --git a/x/vm/statedb/commit_atomicity_test.go b/x/vm/statedb/commit_atomicity_test.go new file mode 100644 index 00000000..4c80cb2b --- /dev/null +++ b/x/vm/statedb/commit_atomicity_test.go @@ -0,0 +1,145 @@ +package statedb_test + +import ( + "errors" + "math/big" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/tracing" + "github.com/holiman/uint256" + "github.com/stretchr/testify/require" + + "github.com/cosmos/evm/x/vm/statedb" + + storetypes "cosmossdk.io/store/types" + + "github.com/cosmos/cosmos-sdk/testutil" + sdk "github.com/cosmos/cosmos-sdk/types" +) + +// atomicTestKeeper routes writes through ctx's real KVStore, unlike the +// in-memory-map mocks elsewhere in this package, so a write discarded via +// CacheContext is actually observable as absent. +type atomicTestKeeper struct { + key *storetypes.KVStoreKey + errAddr common.Address +} + +var _ statedb.Keeper = &atomicTestKeeper{} + +func (k *atomicTestKeeper) store(ctx sdk.Context) storetypes.KVStore { return ctx.KVStore(k.key) } + +func (k *atomicTestKeeper) GetAccount(ctx sdk.Context, addr common.Address) *statedb.Account { + bz := k.store(ctx).Get(addr.Bytes()) + if bz == nil { + return nil + } + return &statedb.Account{Balance: new(uint256.Int).SetBytes(bz)} +} + +// IsBaseAccountOrEmpty always reports true: this fake never models +// privileged Cosmos account types +func (k *atomicTestKeeper) IsBaseAccountOrEmpty(_ sdk.Context, _ common.Address) bool { + return true +} + +func (k *atomicTestKeeper) SetAccount(ctx sdk.Context, addr common.Address, acc statedb.Account) error { + if addr == k.errAddr { + return errors.New("blocked") + } + k.store(ctx).Set(addr.Bytes(), acc.Balance.Bytes()) + return nil +} + +func (k *atomicTestKeeper) DeleteAccount(ctx sdk.Context, addr common.Address) error { + if addr == k.errAddr { + return errors.New("blocked") + } + k.store(ctx).Delete(addr.Bytes()) + return nil +} + +func (k *atomicTestKeeper) GetState(sdk.Context, common.Address, common.Hash) common.Hash { + return common.Hash{} +} +func (k *atomicTestKeeper) GetCode(sdk.Context, common.Hash) []byte { return nil } +func (k *atomicTestKeeper) GetCodeHash(sdk.Context, common.Address) common.Hash { return common.Hash{} } +func (k *atomicTestKeeper) ForEachStorage(sdk.Context, common.Address, func(common.Hash, common.Hash) bool) { +} +func (k *atomicTestKeeper) DeleteState(sdk.Context, common.Address, common.Hash) {} +func (k *atomicTestKeeper) SetState(sdk.Context, common.Address, common.Hash, []byte) {} +func (k *atomicTestKeeper) DeleteCode(sdk.Context, []byte) {} +func (k *atomicTestKeeper) SetCode(sdk.Context, []byte, []byte) {} + +func (k *atomicTestKeeper) KVStoreKeys() map[string]*storetypes.KVStoreKey { + return map[string]*storetypes.KVStoreKey{k.key.Name(): k.key} +} + +// TestCommitAtomicity commits a dirty set sorted [credit, blocked, debit]. A +// late failure on blocked must discard the whole commit, including credit, +// which a non-atomic commit would already have written. +func TestCommitAtomicity(t *testing.T) { + credit := common.BigToAddress(big.NewInt(10)) + blocked := common.BigToAddress(big.NewInt(50)) + debit := common.BigToAddress(big.NewInt(90)) + precompileAddr := common.BigToAddress(big.NewInt(1)) // written via cacheCtx, bypassing the journal + + setup := func(name string, errAddr common.Address) *statedb.StateDB { + key := storetypes.NewKVStoreKey(name) + tkey := storetypes.NewTransientStoreKey(name + "_t") + ctx := testutil.DefaultContext(key, tkey).WithEventManager(sdk.NewEventManager()) + return statedb.New(ctx, &atomicTestKeeper{key: key, errAddr: errAddr}, emptyTxConfig) + } + seed := func(db *statedb.StateDB) { + db.AddBalance(credit, uint256.NewInt(1_000_000), tracing.BalanceChangeUnspecified) + db.AddBalance(blocked, uint256.NewInt(1), tracing.BalanceChangeUnspecified) + db.AddBalance(debit, uint256.NewInt(1), tracing.BalanceChangeUnspecified) + } + // persisted reads through db's own keeper/ctx, bypassing db's in-memory + // cache, to check what actually reached the real store. + persisted := func(db *statedb.StateDB, addr common.Address) *statedb.Account { + return db.Keeper().GetAccount(db.GetContext(), addr) + } + // stageViaPrecompile writes directly through the cache context the way a + // real precompile does, bypassing the journal entirely. + stageViaPrecompile := func(t *testing.T, db *statedb.StateDB) { + t.Helper() + cacheCtx, err := db.GetCacheContext() + require.NoError(t, err) + require.NoError(t, db.Keeper().SetAccount(cacheCtx, precompileAddr, statedb.Account{Balance: uint256.NewInt(7)})) + } + + t.Run("late failure discards the whole commit", func(t *testing.T) { + db := setup("fail", blocked) + seed(db) + require.Error(t, db.Commit()) + require.Nil(t, persisted(db, credit)) + }) + + t.Run("late failure discards precompile-staged writes too", func(t *testing.T) { + db := setup("fail_precompile", blocked) + stageViaPrecompile(t, db) + seed(db) + require.Error(t, db.Commit()) + require.Nil(t, persisted(db, credit)) + require.Nil(t, persisted(db, precompileAddr)) + }) + + t.Run("success still persists everything", func(t *testing.T) { + db := setup("ok", common.Address{}) + seed(db) + require.NoError(t, db.Commit()) + require.Equal(t, uint256.NewInt(1_000_000), persisted(db, credit).Balance) + require.Equal(t, uint256.NewInt(1), persisted(db, debit).Balance) + }) + + t.Run("success persists precompile-staged writes too", func(t *testing.T) { + db := setup("ok_precompile", common.Address{}) + stageViaPrecompile(t, db) + seed(db) + require.NoError(t, db.Commit()) + require.Equal(t, uint256.NewInt(1_000_000), persisted(db, credit).Balance) + require.Equal(t, uint256.NewInt(7), persisted(db, precompileAddr).Balance) + }) +} diff --git a/x/vm/statedb/interfaces.go b/x/vm/statedb/interfaces.go index b5591690..c24c6a5b 100644 --- a/x/vm/statedb/interfaces.go +++ b/x/vm/statedb/interfaces.go @@ -23,6 +23,12 @@ type ExtStateDB interface { type Keeper interface { // Read methods GetAccount(ctx sdk.Context, addr common.Address) *Account + // IsBaseAccountOrEmpty reports whether addr has no Cosmos account yet, or + // carries exactly the plain BaseAccount type. EVM contract code may only + // ever attach to such an address; any other concrete account type + // (vesting, module, or any future AccountI implementation) must never + // receive code + IsBaseAccountOrEmpty(ctx sdk.Context, addr common.Address) bool GetState(ctx sdk.Context, addr common.Address, key common.Hash) common.Hash GetCode(ctx sdk.Context, codeHash common.Hash) []byte GetCodeHash(ctx sdk.Context, addr common.Address) common.Hash diff --git a/x/vm/statedb/mock_test.go b/x/vm/statedb/mock_test.go index 198ccfc9..80ebca41 100644 --- a/x/vm/statedb/mock_test.go +++ b/x/vm/statedb/mock_test.go @@ -49,6 +49,12 @@ func (k MockKeeper) GetAccount(_ sdk.Context, addr common.Address) *statedb.Acco return &acct.account } +// IsBaseAccountOrEmpty always reports true: this fake never models +// privileged Cosmos account types +func (k MockKeeper) IsBaseAccountOrEmpty(_ sdk.Context, _ common.Address) bool { + return true +} + func (k MockKeeper) GetState(_ sdk.Context, addr common.Address, key common.Hash) common.Hash { return k.accounts[addr].states[key] } diff --git a/x/vm/statedb/state_object.go b/x/vm/statedb/state_object.go index 0b06e64e..8f520f99 100644 --- a/x/vm/statedb/state_object.go +++ b/x/vm/statedb/state_object.go @@ -2,6 +2,7 @@ package statedb import ( "bytes" + "fmt" "math/big" "sort" @@ -137,7 +138,17 @@ func (s *stateObject) AddBalance(amount *uint256.Int) uint256.Int { if amount.IsZero() { return *(s.Balance()) } - return s.SetBalance(new(uint256.Int).Add(s.Balance(), amount)) + balance := s.Balance() + sum, overflow := new(uint256.Int).AddOverflow(balance, amount) + if overflow { + panic(fmt.Sprintf( + "state balance overflow for %s: have=%s add=%s", + s.address.Hex(), + balance.String(), + amount.String(), + )) + } + return s.SetBalance(sum) } // SubBalance removes amount from s's balance. @@ -147,7 +158,16 @@ func (s *stateObject) SubBalance(amount *uint256.Int) uint256.Int { if amount.IsZero() { return *(s.Balance()) } - return s.SetBalance(new(uint256.Int).Sub(s.Balance(), amount)) + balance := s.Balance() + if balance.Lt(amount) { + panic(fmt.Sprintf( + "state balance underflow for %s: have=%s sub=%s", + s.address.Hex(), + balance.String(), + amount.String(), + )) + } + return s.SetBalance(new(uint256.Int).Sub(balance, amount)) } // SetBalance updates account balance. diff --git a/x/vm/statedb/statedb.go b/x/vm/statedb/statedb.go index b3ba592e..06d76bf9 100644 --- a/x/vm/statedb/statedb.go +++ b/x/vm/statedb/statedb.go @@ -401,6 +401,9 @@ func (s *StateDB) createObject(addr common.Address) (newobj, prev *stateObject) // // Carrying over the balance ensures that Ether doesn't disappear. func (s *StateDB) CreateAccount(addr common.Address) { + if !s.keeper.IsBaseAccountOrEmpty(s.ctx, addr) { + panic(fmt.Sprintf("cannot deploy EVM contract on top of non-base account %s", addr.Hex())) + } newObj, prev := s.createObject(addr) if prev != nil { newObj.setBalance(prev.account.Balance) @@ -709,15 +712,28 @@ func (s *StateDB) RevertToSnapshot(revid int) { s.validRevisions = s.validRevisions[:idx] } -// Commit writes the dirty states to keeper -// the StateDB object should be discarded after committed. +// Commit writes the dirty states to keeper. +// The StateDB object should be discarded after being committed. func (s *StateDB) Commit() error { // writeCache func will exist only when there's a call to a precompile. // It applies all the store updates preformed by precompile calls. if s.writeCache != nil { + // fold the remaining dirty set into the precompile cache so the + // state changes are atomic. + if err := s.commitWithCtx(s.cacheCtx); err != nil { + return err + } s.writeCache() + return nil } - return s.commitWithCtx(s.ctx) + + // stage writes here so a late failure leaves s.ctx untouched. + cacheCtx, writeCache := s.ctx.CacheContext() + if err := s.commitWithCtx(cacheCtx); err != nil { + return err + } + writeCache() + return nil } // FlushToCacheCtx writes the dirty states to keeper using the cacheCtx. diff --git a/x/vm/statedb/statedb_test.go b/x/vm/statedb/statedb_test.go index 2e380cad..0c5b184f 100644 --- a/x/vm/statedb/statedb_test.go +++ b/x/vm/statedb/statedb_test.go @@ -17,9 +17,18 @@ import ( "github.com/cosmos/evm/x/vm/statedb" "github.com/cosmos/evm/x/vm/types/mocks" + storetypes "cosmossdk.io/store/types" + + "github.com/cosmos/cosmos-sdk/testutil" sdk "github.com/cosmos/cosmos-sdk/types" ) +func newTestCtx() sdk.Context { + key := storetypes.NewKVStoreKey("statedb_test") + tkey := storetypes.NewTransientStoreKey("statedb_test_transient") + return testutil.DefaultContext(key, tkey).WithEventManager(sdk.NewEventManager()) +} + var ( address common.Address = common.BigToAddress(big.NewInt(101)) address2 common.Address = common.BigToAddress(big.NewInt(102)) @@ -58,7 +67,7 @@ func (suite *StateDBTestSuite) TestAccount() { suite.Require().Empty(acct.Balance) suite.Require().False(acct.HasCodeHash()) - db = statedb.New(sdk.Context{}.WithEventManager(sdk.NewEventManager()), keeper, emptyTxConfig) + db = statedb.New(newTestCtx(), keeper, emptyTxConfig) suite.Require().Equal(true, db.Exist(address)) suite.Require().Equal(true, db.Empty(address)) suite.Require().Equal(common.U2560, db.GetBalance(address)) @@ -81,7 +90,7 @@ func (suite *StateDBTestSuite) TestAccount() { suite.Require().NoError(db.Commit()) // SelfDestruct - db = statedb.New(sdk.Context{}.WithEventManager(sdk.NewEventManager()), db.Keeper(), emptyTxConfig) + db = statedb.New(newTestCtx(), db.Keeper(), emptyTxConfig) suite.Require().False(db.HasSelfDestructed(address)) db.SelfDestruct(address) @@ -96,7 +105,7 @@ func (suite *StateDBTestSuite) TestAccount() { suite.Require().NoError(db.Commit()) // not accessible from StateDB anymore - db = statedb.New(sdk.Context{}.WithEventManager(sdk.NewEventManager()), db.Keeper(), emptyTxConfig) + db = statedb.New(newTestCtx(), db.Keeper(), emptyTxConfig) suite.Require().False(db.Exist(address)) // and cleared in keeper too @@ -134,7 +143,7 @@ func (suite *StateDBTestSuite) TestAccount() { suite.Require().NoError(db.Commit()) // not accessible from StateDB anymore - db = statedb.New(sdk.Context{}.WithEventManager(sdk.NewEventManager()), db.Keeper(), emptyTxConfig) + db = statedb.New(newTestCtx(), db.Keeper(), emptyTxConfig) suite.Require().False(db.Exist(address)) // and cleared in keeper too @@ -159,7 +168,7 @@ func (suite *StateDBTestSuite) TestAccount() { suite.Require().NoError(db.Commit()) // SelfDestruct - db = statedb.New(sdk.Context{}.WithEventManager(sdk.NewEventManager()), db.Keeper(), emptyTxConfig) + db = statedb.New(newTestCtx(), db.Keeper(), emptyTxConfig) suite.Require().False(db.HasSelfDestructed(address)) _, _ = db.SelfDestruct6780(address) @@ -172,7 +181,7 @@ func (suite *StateDBTestSuite) TestAccount() { suite.Require().NoError(db.Commit()) // Same-tx maintains state - db = statedb.New(sdk.Context{}.WithEventManager(sdk.NewEventManager()), db.Keeper(), emptyTxConfig) + db = statedb.New(newTestCtx(), db.Keeper(), emptyTxConfig) suite.Require().True(db.Exist(address)) suite.Require().False(db.HasSelfDestructed(address)) // but code and state are still accessible in dirty state @@ -191,9 +200,9 @@ func (suite *StateDBTestSuite) TestAccount() { } for _, tc := range testCases { suite.Run(tc.name, func() { - ctx := sdk.Context{}.WithEventManager(sdk.NewEventManager()) + ctx := newTestCtx() keeper := mocks.NewEVMKeeper() - db := statedb.New(sdk.Context{}.WithEventManager(sdk.NewEventManager()), keeper, emptyTxConfig) + db := statedb.New(newTestCtx(), keeper, emptyTxConfig) tc.malleate(ctx, db) }) } @@ -201,7 +210,7 @@ func (suite *StateDBTestSuite) TestAccount() { func (suite *StateDBTestSuite) TestAccountOverride() { keeper := mocks.NewEVMKeeper() - db := statedb.New(sdk.Context{}.WithEventManager(sdk.NewEventManager()), keeper, emptyTxConfig) + db := statedb.New(newTestCtx(), keeper, emptyTxConfig) // test balance carry over when overwritten amount := uint256.NewInt(1) @@ -233,14 +242,18 @@ func (suite *StateDBTestSuite) TestDBError() { }}, } for _, tc := range testCases { - db := statedb.New(sdk.Context{}.WithEventManager(sdk.NewEventManager()), mocks.NewEVMKeeper(), emptyTxConfig) + db := statedb.New(newTestCtx(), mocks.NewEVMKeeper(), emptyTxConfig) tc.malleate(db) suite.Require().Error(db.Commit()) } } +// maxUint256 returns the maximum representable uint256 value (2^256 - 1). +func maxUint256() *uint256.Int { + return new(uint256.Int).SetAllOne() +} + func (suite *StateDBTestSuite) TestBalance() { - // NOTE: no need to test overflow/underflow, that is guaranteed by evm implementation. testCases := []struct { name string malleate func(*statedb.StateDB) @@ -261,13 +274,20 @@ func (suite *StateDBTestSuite) TestBalance() { {"sub zero balance", func(db *statedb.StateDB) { db.SubBalance(address, uint256.NewInt(0), tracing.BalanceChangeUnspecified) }, uint256.NewInt(0)}, + {"add balance up to max uint256 boundary", func(db *statedb.StateDB) { + db.AddBalance(address, maxUint256(), tracing.BalanceChangeUnspecified) + }, maxUint256()}, + {"add balance reaching max uint256 across two adds", func(db *statedb.StateDB) { + db.AddBalance(address, new(uint256.Int).Sub(maxUint256(), uint256.NewInt(1)), tracing.BalanceChangeUnspecified) + db.AddBalance(address, uint256.NewInt(1), tracing.BalanceChangeUnspecified) + }, maxUint256()}, } for _, tc := range testCases { suite.Run(tc.name, func() { - ctx := sdk.Context{}.WithEventManager(sdk.NewEventManager()) + ctx := newTestCtx() keeper := mocks.NewEVMKeeper() - db := statedb.New(sdk.Context{}.WithEventManager(sdk.NewEventManager()), keeper, emptyTxConfig) + db := statedb.New(newTestCtx(), keeper, emptyTxConfig) tc.malleate(db) // check dirty state @@ -279,6 +299,107 @@ func (suite *StateDBTestSuite) TestBalance() { } } +func (suite *StateDBTestSuite) TestSubBalanceUnderflowPanics() { + db := statedb.New(sdk.Context{}.WithEventManager(sdk.NewEventManager()), mocks.NewEVMKeeper(), emptyTxConfig) + db.AddBalance(address, uint256.NewInt(1), tracing.BalanceChangeUnspecified) + + expectedPanic := fmt.Sprintf("state balance underflow for %s: have=%s sub=%s", address.Hex(), "1", "2") + suite.Require().PanicsWithValue(expectedPanic, func() { + db.SubBalance(address, uint256.NewInt(2), tracing.BalanceChangeUnspecified) + }) +} + +// TestAddBalanceOverflowPanics partitions AddBalance overflow inputs into +// equivalence classes: smallest-possible overflow (boundary value, one wei +// past max uint256), a large-amount overflow where both operands are near +// max uint256, and an overflow driven by a moderate pre-existing balance +// plus a huge credited amount. Each must panic instead of silently wrapping, +// mirroring the already-hardened SubBalance underflow guard +func (suite *StateDBTestSuite) TestAddBalanceOverflowPanics() { + testCases := []struct { + name string + haveBalance *uint256.Int + addAmount *uint256.Int + }{ + { + name: "smallest overflow: max uint256 plus one wei", + haveBalance: maxUint256(), + addAmount: uint256.NewInt(1), + }, + { + name: "large overflow: max uint256 plus max uint256", + haveBalance: maxUint256(), + addAmount: maxUint256(), + }, + { + name: "moderate balance overflowed by a huge credit", + haveBalance: uint256.NewInt(100), + addAmount: new(uint256.Int).Sub(maxUint256(), uint256.NewInt(98)), // have + amount = max+2 + }, + } + + for _, tc := range testCases { + suite.Run(tc.name, func() { + db := statedb.New(sdk.Context{}.WithEventManager(sdk.NewEventManager()), mocks.NewEVMKeeper(), emptyTxConfig) + db.AddBalance(address, tc.haveBalance, tracing.BalanceChangeUnspecified) + + expectedPanic := fmt.Sprintf("state balance overflow for %s: have=%s add=%s", + address.Hex(), tc.haveBalance.String(), tc.addAmount.String()) + suite.Require().PanicsWithValue(expectedPanic, func() { + db.AddBalance(address, tc.addAmount, tracing.BalanceChangeUnspecified) + }) + + // the balance must be left unchanged by the panicking call. + suite.Require().Equal(tc.haveBalance, db.GetBalance(address)) + }) + } +} + +// TestCreateAccountBlocksNonBaseAccountTypes partitions StateDB.CreateAccount +// by what's already at the target address: no account, an empty BaseAccount, +// a funded BaseAccount (the counterfactual-wallet boundary case), and an +// address whose underlying Cosmos account is not a BaseAccount at all +// (simulating a staged vesting/module account). Only the last must panic +func (suite *StateDBTestSuite) TestCreateAccountBlocksNonBaseAccountTypes() { + testCases := []struct { + name string + malleate func(*mocks.EVMKeeper, common.Address) + expectPanic bool + }{ + {"no account", func(*mocks.EVMKeeper, common.Address) {}, false}, + {"empty BaseAccount", func(k *mocks.EVMKeeper, addr common.Address) { + err := k.SetAccount(sdk.Context{}, addr, statedb.Account{Balance: uint256.NewInt(0), CodeHash: mocks.EmptyCodeHash}) + suite.Require().NoError(err) + }, false}, + {"funded BaseAccount", func(k *mocks.EVMKeeper, addr common.Address) { + err := k.SetAccount(sdk.Context{}, addr, statedb.Account{Balance: uint256.NewInt(100), CodeHash: mocks.EmptyCodeHash}) + suite.Require().NoError(err) + }, false}, + {"non-BaseAccount type present", func(k *mocks.EVMKeeper, addr common.Address) { + err := k.SetAccount(sdk.Context{}, addr, statedb.Account{Balance: uint256.NewInt(2), CodeHash: mocks.EmptyCodeHash}) + suite.Require().NoError(err) + k.SetBlockedAccountType(addr, true) + }, true}, + } + + for _, tc := range testCases { + suite.Run(tc.name, func() { + keeper := mocks.NewEVMKeeper() + addr := common.BigToAddress(big.NewInt(200)) + tc.malleate(keeper, addr) + + db := statedb.New(newTestCtx(), keeper, emptyTxConfig) + createAccount := func() { db.CreateAccount(addr) } + + if tc.expectPanic { + suite.Require().Panics(createAccount) + } else { + suite.Require().NotPanics(createAccount) + } + }) + } +} + func (suite *StateDBTestSuite) TestState() { key1 := common.BigToHash(big.NewInt(1)) value1 := common.BigToHash(big.NewInt(1)) @@ -320,9 +441,9 @@ func (suite *StateDBTestSuite) TestState() { for _, tc := range testCases { suite.Run(tc.name, func() { - ctx := sdk.Context{}.WithEventManager(sdk.NewEventManager()) + ctx := newTestCtx() keeper := mocks.NewEVMKeeper() - db := statedb.New(sdk.Context{}.WithEventManager(sdk.NewEventManager()), keeper, emptyTxConfig) + db := statedb.New(newTestCtx(), keeper, emptyTxConfig) tc.malleate(db) suite.Require().NoError(db.Commit()) @@ -332,7 +453,7 @@ func (suite *StateDBTestSuite) TestState() { } // check ForEachStorage - db = statedb.New(sdk.Context{}.WithEventManager(sdk.NewEventManager()), keeper, emptyTxConfig) + db = statedb.New(newTestCtx(), keeper, emptyTxConfig) collected := CollectContractStorage(db) if len(tc.expStates) > 0 { suite.Require().Equal(tc.expStates, collected) @@ -365,7 +486,7 @@ func (suite *StateDBTestSuite) TestCode() { for _, tc := range testCases { suite.Run(tc.name, func() { keeper := mocks.NewEVMKeeper() - db := statedb.New(sdk.Context{}.WithEventManager(sdk.NewEventManager()), keeper, emptyTxConfig) + db := statedb.New(newTestCtx(), keeper, emptyTxConfig) tc.malleate(db) // check dirty state @@ -376,7 +497,7 @@ func (suite *StateDBTestSuite) TestCode() { suite.Require().NoError(db.Commit()) // check again - db = statedb.New(sdk.Context{}.WithEventManager(sdk.NewEventManager()), keeper, emptyTxConfig) + db = statedb.New(newTestCtx(), keeper, emptyTxConfig) suite.Require().Equal(tc.expCode, db.GetCode(address)) suite.Require().Equal(len(tc.expCode), db.GetCodeSize(address)) suite.Require().Equal(tc.expCodeHash, db.GetCodeHash(address)) @@ -430,7 +551,7 @@ func (suite *StateDBTestSuite) TestRevertSnapshot() { } for _, tc := range testCases { suite.Run(tc.name, func() { - ctx := sdk.Context{}.WithEventManager(sdk.NewEventManager()) + ctx := newTestCtx() keeper := mocks.NewEVMKeeper() { @@ -469,7 +590,7 @@ func (suite *StateDBTestSuite) TestNestedSnapshot() { value1 := common.BigToHash(big.NewInt(1)) value2 := common.BigToHash(big.NewInt(2)) - db := statedb.New(sdk.Context{}.WithEventManager(sdk.NewEventManager()), mocks.NewEVMKeeper(), emptyTxConfig) + db := statedb.New(newTestCtx(), mocks.NewEVMKeeper(), emptyTxConfig) rev1 := db.Snapshot() db.SetState(address, key, value1) @@ -486,7 +607,7 @@ func (suite *StateDBTestSuite) TestNestedSnapshot() { } func (suite *StateDBTestSuite) TestInvalidSnapshotId() { - db := statedb.New(sdk.Context{}.WithEventManager(sdk.NewEventManager()), mocks.NewEVMKeeper(), emptyTxConfig) + db := statedb.New(newTestCtx(), mocks.NewEVMKeeper(), emptyTxConfig) suite.Require().Panics(func() { db.RevertToSnapshot(1) }) @@ -577,7 +698,7 @@ func (suite *StateDBTestSuite) TestAccessList() { } for _, tc := range testCases { - db := statedb.New(sdk.Context{}.WithEventManager(sdk.NewEventManager()), mocks.NewEVMKeeper(), emptyTxConfig) + db := statedb.New(newTestCtx(), mocks.NewEVMKeeper(), emptyTxConfig) tc.malleate(db) } } @@ -589,7 +710,7 @@ func (suite *StateDBTestSuite) TestLog() { txHash, 1, 1, ) - db := statedb.New(sdk.Context{}.WithEventManager(sdk.NewEventManager()), mocks.NewEVMKeeper(), txConfig) + db := statedb.New(newTestCtx(), mocks.NewEVMKeeper(), txConfig) data := []byte("hello world") db.AddLog(ðtypes.Log{ Address: address, @@ -639,7 +760,7 @@ func (suite *StateDBTestSuite) TestRefund() { }, 0, true}, } for _, tc := range testCases { - db := statedb.New(sdk.Context{}.WithEventManager(sdk.NewEventManager()), mocks.NewEVMKeeper(), emptyTxConfig) + db := statedb.New(newTestCtx(), mocks.NewEVMKeeper(), emptyTxConfig) if !tc.expPanic { tc.malleate(db) suite.Require().Equal(tc.expRefund, db.GetRefund()) @@ -652,7 +773,7 @@ func (suite *StateDBTestSuite) TestRefund() { } func (suite *StateDBTestSuite) TestIterateStorage() { - ctx := sdk.Context{}.WithEventManager(sdk.NewEventManager()) + ctx := newTestCtx() key1 := common.BigToHash(big.NewInt(1)) value1 := common.BigToHash(big.NewInt(2)) @@ -660,7 +781,7 @@ func (suite *StateDBTestSuite) TestIterateStorage() { value2 := common.BigToHash(big.NewInt(4)) keeper := mocks.NewEVMKeeper() - db := statedb.New(sdk.Context{}.WithEventManager(sdk.NewEventManager()), keeper, emptyTxConfig) + db := statedb.New(newTestCtx(), keeper, emptyTxConfig) db.SetState(address, key1, value1) db.SetState(address, key2, value2) @@ -716,7 +837,7 @@ func (suite *StateDBTestSuite) TestSetStorage() { for _, tc := range testCases { suite.Run(tc.name, func() { keeper := mocks.NewEVMKeeper() - db := statedb.New(sdk.Context{}.WithEventManager(sdk.NewEventManager()), keeper, emptyTxConfig) + db := statedb.New(newTestCtx(), keeper, emptyTxConfig) for k, v := range tc.prestate { db.SetState(contract, k, v) } diff --git a/x/vm/types/mocks/EVMKeeper.go b/x/vm/types/mocks/EVMKeeper.go index 4aa50b93..2a8c0c66 100644 --- a/x/vm/types/mocks/EVMKeeper.go +++ b/x/vm/types/mocks/EVMKeeper.go @@ -23,8 +23,9 @@ var ( ) type Account struct { - account statedb.Account - states statedb.Storage + account statedb.Account + states statedb.Storage + blockedAccountType bool } type EVMKeeper struct { @@ -49,6 +50,29 @@ func (k EVMKeeper) GetAccount(_ sdk.Context, addr common.Address) *statedb.Accou return &acct.account } +// IsBaseAccountOrEmpty reports true unless the address has been explicitly +// marked via SetBlockedAccountType, simulating a privileged (non-BaseAccount) +// Cosmos account already staged there +func (k EVMKeeper) IsBaseAccountOrEmpty(_ sdk.Context, addr common.Address) bool { + acct, ok := k.accounts[addr] + if !ok { + return true + } + return !acct.blockedAccountType +} + +// SetBlockedAccountType marks addr as backed by a non-BaseAccount Cosmos +// account type (e.g. simulating a vesting or module account already staged +// there), for tests exercising the CreateAccount guard +func (k EVMKeeper) SetBlockedAccountType(addr common.Address, blocked bool) { + acct, exists := k.accounts[addr] + if !exists { + acct = Account{states: make(statedb.Storage)} + } + acct.blockedAccountType = blocked + k.accounts[addr] = acct +} + func (k EVMKeeper) GetState(_ sdk.Context, addr common.Address, key common.Hash) common.Hash { return k.accounts[addr].states[key] }