diff --git a/CHANGELOG.md b/CHANGELOG.md index 7be02915..9d0c0d62 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,7 @@ Ref: https://keepachangelog.com/en/1.0.0/ - (evmutil) [#1604] Emit events for MsgConvertCosmosCoinToERC20: `message` & `convert_cosmos_coin_to_erc20` - (evmutil) [#1605] Add query for deployed ERC20 contracts representing Cosmos coins in the EVM - (evmutil) [#1609] Add MsgConvertCosmosCoinFromERC20 for converting the ERC20 back to an sdk.Coin +- (evmutil) [#1610] Add new invariant checking that ERC20s are fully backed by sdk.Coins ### Client Breaking - (evmutil) [#1603] Renamed error `ErrConversionNotEnabled` to `ErrEVMConversionNotEnabled` @@ -252,6 +253,7 @@ the [changelog](https://github.com/cosmos/cosmos-sdk/blob/v0.38.4/CHANGELOG.md). - [#257](https://github.com/Kava-Labs/kava/pulls/257) Include scripts to run large-scale simulations remotely using aws-batch +[#1610]: https://github.com/Kava-Labs/kava/pull/1610 [#1609]: https://github.com/Kava-Labs/kava/pull/1609 [#1605]: https://github.com/Kava-Labs/kava/pull/1605 [#1604]: https://github.com/Kava-Labs/kava/pull/1604 diff --git a/app/app.go b/app/app.go index b4a13bee..03c05dba 100644 --- a/app/app.go +++ b/app/app.go @@ -1138,6 +1138,7 @@ func (app *App) loadBlockedMaccAddrs() map[string]bool { app.accountKeeper.GetModuleAddress(kavadisttypes.FundModuleAccount).String(): true, // community app.accountKeeper.GetModuleAddress(communitytypes.ModuleAccountName).String(): true, + // NOTE: if adding evmutil, adjust the cosmos-coins-fully-backed-invariant accordingly. } for addr := range modAccAddrs { diff --git a/x/evmutil/keeper/erc20.go b/x/evmutil/keeper/erc20.go index 7c99a084..f2096569 100644 --- a/x/evmutil/keeper/erc20.go +++ b/x/evmutil/keeper/erc20.go @@ -17,7 +17,10 @@ import ( ) const ( - erc20BalanceOfMethod = "balanceOf" + erc20BalanceOfMethod = "balanceOf" + erc20BurnMethod = "burn" + erc20MintMethod = "mint" + erc20TotalSupplyMethod = "totalSupply" ) // DeployTestMintableERC20Contract deploys an ERC20 contract on the EVM as the @@ -149,7 +152,7 @@ func (k Keeper) MintERC20( types.ERC20MintableBurnableContract.ABI, types.ModuleEVMAddress, contractAddr, - "mint", + erc20MintMethod, // Mint ERC20 args receiver.Address, amount, @@ -170,7 +173,7 @@ func (k Keeper) BurnERC20( types.ERC20KavaWrappedCosmosCoinContract.ABI, types.ModuleEVMAddress, contractAddr, - "burn", + erc20BurnMethod, // Burn ERC20 args initiator.Address, amount, @@ -179,6 +182,8 @@ func (k Keeper) BurnERC20( return err } +// QueryERC20BalanceOf makes a contract call to the balanceOf method of the ERC20 contract to get +// the ERC20 balance of the given account. func (k Keeper) QueryERC20BalanceOf( ctx sdk.Context, contractAddr types.InternalEVMAddress, @@ -197,6 +202,31 @@ func (k Keeper) QueryERC20BalanceOf( return nil, err } + return unpackERC20ResToBigInt(res, erc20BalanceOfMethod) +} + +// QueryERC20TotalSupply makes a contract call to the totalSupply method of the ERC20 contract to +// get the total supply of the token. +func (k Keeper) QueryERC20TotalSupply( + ctx sdk.Context, + contractAddr types.InternalEVMAddress, +) (*big.Int, error) { + res, err := k.CallEVM( + ctx, + types.ERC20KavaWrappedCosmosCoinContract.ABI, + types.ModuleEVMAddress, + contractAddr, + erc20TotalSupplyMethod, + // totalSupply takes no args + ) + if err != nil { + return nil, err + } + + return unpackERC20ResToBigInt(res, erc20TotalSupplyMethod) +} + +func unpackERC20ResToBigInt(res *evmtypes.MsgEthereumTxResponse, methodName string) (*big.Int, error) { if res.Failed() { if res.VmError == vm.ErrExecutionReverted.Error() { // Unpacks revert @@ -206,11 +236,11 @@ func (k Keeper) QueryERC20BalanceOf( return nil, status.Error(codes.Internal, res.VmError) } - anyOutput, err := types.ERC20MintableBurnableContract.ABI.Unpack(erc20BalanceOfMethod, res.Ret) + anyOutput, err := types.ERC20MintableBurnableContract.ABI.Unpack(methodName, res.Ret) if err != nil { return nil, fmt.Errorf( "failed to unpack method %v response: %w", - erc20BalanceOfMethod, + methodName, err, ) } @@ -218,7 +248,7 @@ func (k Keeper) QueryERC20BalanceOf( if len(anyOutput) != 1 { return nil, fmt.Errorf( "invalid ERC20 %v call return outputs %v, expected %v", - erc20BalanceOfMethod, + methodName, len(anyOutput), 1, ) diff --git a/x/evmutil/keeper/erc20_test.go b/x/evmutil/keeper/erc20_test.go index 019e3a12..5cfcd52a 100644 --- a/x/evmutil/keeper/erc20_test.go +++ b/x/evmutil/keeper/erc20_test.go @@ -86,6 +86,27 @@ func (suite *ERC20TestSuite) TestERC20Mint() { suite.Require().Equal(big.NewInt(1234), balance) } +func (suite *ERC20TestSuite) TestQueryERC20TotalSupply() { + suite.Run("with no balance", func() { + bal, err := suite.Keeper.QueryERC20TotalSupply(suite.Ctx, suite.contractAddr) + suite.NoError(err) + suite.BigIntsEqual(big.NewInt(0), bal, "expected total supply of 0") + }) + + suite.Run("with balance", func() { + amount := big.NewInt(1e10) + expectedTotal := big.NewInt(3e10) + // mint 1e10 to three random accounts + suite.NoError(suite.Keeper.MintERC20(suite.Ctx, suite.contractAddr, testutil.RandomInternalEVMAddress(), amount)) + suite.NoError(suite.Keeper.MintERC20(suite.Ctx, suite.contractAddr, testutil.RandomInternalEVMAddress(), amount)) + suite.NoError(suite.Keeper.MintERC20(suite.Ctx, suite.contractAddr, testutil.RandomInternalEVMAddress(), amount)) + + bal, err := suite.Keeper.QueryERC20TotalSupply(suite.Ctx, suite.contractAddr) + suite.NoError(err) + suite.BigIntsEqual(expectedTotal, bal, "unexpected total supply after minting") + }) +} + func (suite *ERC20TestSuite) TestDeployKavaWrappedCosmosCoinERC20Contract() { suite.Run("fails to deploy invalid contract", func() { // empty other fields means this token is invalid. diff --git a/x/evmutil/keeper/invariants.go b/x/evmutil/keeper/invariants.go index 84c5db64..b8c880b1 100644 --- a/x/evmutil/keeper/invariants.go +++ b/x/evmutil/keeper/invariants.go @@ -1,6 +1,8 @@ package keeper import ( + "fmt" + sdk "github.com/cosmos/cosmos-sdk/types" authtypes "github.com/cosmos/cosmos-sdk/x/auth/types" @@ -11,6 +13,7 @@ import ( func RegisterInvariants(ir sdk.InvariantRegistry, bankK types.BankKeeper, k Keeper) { ir.RegisterRoute(types.ModuleName, "fully-backed", FullyBackedInvariant(bankK, k)) ir.RegisterRoute(types.ModuleName, "small-balances", SmallBalancesInvariant(bankK, k)) + ir.RegisterRoute(types.ModuleName, "cosmos-coins-fully-backed", CosmosCoinsFullyBackedInvariant(bankK, k)) // Disable this invariant due to some issues with it requiring some staking params to be set in genesis. // ir.RegisterRoute(types.ModuleName, "backed-conversion-coins", BackedCoinsInvariant(bankK, k)) } @@ -24,6 +27,9 @@ func AllInvariants(bankK types.BankKeeper, k Keeper) sdk.Invariant { if res, stop := BackedCoinsInvariant(bankK, k)(ctx); stop { return res, stop } + if res, stop := CosmosCoinsFullyBackedInvariant(bankK, k)(ctx); stop { + return res, stop + } return SmallBalancesInvariant(bankK, k)(ctx) } } @@ -105,3 +111,38 @@ func BackedCoinsInvariant(_ types.BankKeeper, k Keeper) sdk.Invariant { return message, broken } } + +// CosmosCoinsFullyBackedInvariant ensures the total supply of ERC20 representations of sdk.Coins +// match the balances in the module account. +// +// This invariant depends on the fact that coins can only become part of the balance through +// conversion to ERC20s. +// If in the future sdk.Coins can be sent directly to the module account, +// or the module account balance can be increased in any other way, +// this invariant should be changed from checking that the balance equals the total supply, +// to check that the balance is greater than or equal to the total supply. +func CosmosCoinsFullyBackedInvariant(bankK types.BankKeeper, k Keeper) sdk.Invariant { + broken := false + message := sdk.FormatInvariant( + types.ModuleName, + "cosmos coins fully-backed broken", + "ERC20 total supply is not equal to module account balance", + ) + maccAddress := authtypes.NewModuleAddress(types.ModuleName) + + return func(ctx sdk.Context) (string, bool) { + k.IterateAllDeployedCosmosCoinContracts(ctx, func(c types.DeployedCosmosCoinContract) bool { + moduleBalance := bankK.GetBalance(ctx, maccAddress, c.CosmosDenom).Amount + totalSupply, err := k.QueryERC20TotalSupply(ctx, *c.Address) + if err != nil { + panic(fmt.Sprintf("failed to query total supply for %+v", c)) + } + // expect total supply to equal balance in the module + if totalSupply.Cmp(moduleBalance.BigInt()) != 0 { + broken = true + } + return broken + }) + return message, broken + } +} diff --git a/x/evmutil/keeper/invariants_test.go b/x/evmutil/keeper/invariants_test.go index faa93dc8..3b3867c0 100644 --- a/x/evmutil/keeper/invariants_test.go +++ b/x/evmutil/keeper/invariants_test.go @@ -6,8 +6,12 @@ import ( sdkmath "cosmossdk.io/math" sdk "github.com/cosmos/cosmos-sdk/types" + authtypes "github.com/cosmos/cosmos-sdk/x/auth/types" + bankkeeper "github.com/cosmos/cosmos-sdk/x/bank/keeper" + banktypes "github.com/cosmos/cosmos-sdk/x/bank/types" "github.com/stretchr/testify/suite" + "github.com/kava-labs/kava/app" "github.com/kava-labs/kava/x/evmutil/keeper" "github.com/kava-labs/kava/x/evmutil/testutil" "github.com/kava-labs/kava/x/evmutil/types" @@ -17,6 +21,9 @@ type invariantTestSuite struct { testutil.Suite invariants map[string]map[string]sdk.Invariant contractAddr types.InternalEVMAddress + + cosmosCoin types.AllowedCosmosCoinERC20Token + cosmosCoinContractAddr types.InternalEVMAddress } func TestInvariantTestSuite(t *testing.T) { @@ -62,6 +69,31 @@ func (suite *invariantTestSuite) SetupValidState() { big.NewInt(1000), ) suite.Require().NoError(err) + + // setup a cosmos coin erc20 with supply + tokenInfo := types.NewAllowedCosmosCoinERC20Token("magic", "Magic coin", "MAGIC", 6) + suite.cosmosCoin = tokenInfo + params := suite.Keeper.GetParams(suite.Ctx) + params.AllowedCosmosDenoms = append(params.AllowedCosmosDenoms, tokenInfo) + suite.Keeper.SetParams(suite.Ctx, params) + + suite.cosmosCoinContractAddr, err = suite.Keeper.GetOrDeployCosmosCoinERC20Contract(suite.Ctx, tokenInfo) + suite.NoError(err) + + // setup converted coin position + err = suite.Keeper.MintERC20( + suite.Ctx, + suite.cosmosCoinContractAddr, + testutil.RandomInternalEVMAddress(), + big.NewInt(1e12), + ) + suite.NoError(err) + err = suite.App.FundModuleAccount( + suite.Ctx, + types.ModuleName, + sdk.NewCoins(sdk.NewInt64Coin(tokenInfo.CosmosDenom, 1e12)), + ) + suite.NoError(err) } // RegisterRoutes implements sdk.InvariantRegistry @@ -137,3 +169,49 @@ func (suite *invariantTestSuite) TestSmallBalances() { suite.Equal("evmutil: small balances broken invariant\nminor balances not all less than overflow\n", message) suite.Equal(true, broken) } + +// the cosmos-coins-fully-backed invariant depends on 1-to-1 mapping of module balance to erc20s +// if coins can be sent directly to the module account, this assumption is broken. +// this test verifies that coins cannot be directly sent to the module account. +func (suite *invariantTestSuite) TestSendToModuleAccountNotAllowed() { + bKeeper := suite.App.GetBankKeeper() + maccAddress := authtypes.NewModuleAddress(types.ModuleName) + suite.True(bKeeper.BlockedAddr(maccAddress)) + + coins := sdk.NewCoins(sdk.NewInt64Coin(suite.cosmosCoin.CosmosDenom, 1e7)) + addr := app.RandomAddress() + + err := suite.App.FundAccount(suite.Ctx, addr, coins) + suite.NoError(err) + + bankMsgServer := bankkeeper.NewMsgServerImpl(bKeeper) + _, err = bankMsgServer.Send(suite.Ctx, &banktypes.MsgSend{ + FromAddress: addr.String(), + ToAddress: maccAddress.String(), + Amount: coins, + }) + suite.ErrorContains(err, "kava1w9vxuke5dz6hyza2j932qgmxltnfxwl78u920k is not allowed to receive funds: unauthorized") +} + +func (suite *invariantTestSuite) TestCosmosCoinsFullyBackedInvariant() { + invariantName := "cosmos-coins-fully-backed" + // default state is valid + _, broken := suite.runInvariant(invariantName, keeper.CosmosCoinsFullyBackedInvariant) + suite.False(broken) + + suite.SetupValidState() + _, broken = suite.runInvariant(invariantName, keeper.CosmosCoinsFullyBackedInvariant) + suite.False(broken) + + // break the invariant by removing module account balance without adjusting token supply + err := suite.BankKeeper.SendCoinsFromModuleToAccount( + suite.Ctx, + types.ModuleName, + app.RandomAddress(), + sdk.NewCoins(sdk.NewInt64Coin(suite.cosmosCoin.CosmosDenom, 1e5)), + ) + suite.NoError(err) + + _, broken = suite.runInvariant(invariantName, keeper.CosmosCoinsFullyBackedInvariant) + suite.True(broken) +} diff --git a/x/evmutil/keeper/keeper.go b/x/evmutil/keeper/keeper.go index aeb2db73..02098c8d 100644 --- a/x/evmutil/keeper/keeper.go +++ b/x/evmutil/keeper/keeper.go @@ -212,3 +212,21 @@ func (k *Keeper) GetDeployedCosmosCoinContract(ctx sdk.Context, cosmosDenom stri found := len(bz) != 0 return types.BytesToInternalEVMAddress(bz), found } + +// IterateAllDeployedCosmosCoinContracts iterates through all the deployed ERC20 contracts representing +// cosmos-sdk coins. If true is returned from the callback, iteration is halted. +func (k Keeper) IterateAllDeployedCosmosCoinContracts(ctx sdk.Context, cb func(types.DeployedCosmosCoinContract) bool) { + store := ctx.KVStore(k.storeKey) + iterator := sdk.KVStorePrefixIterator(store, types.DeployedCosmosCoinContractKeyPrefix) + + defer iterator.Close() + for ; iterator.Valid(); iterator.Next() { + contract := types.NewDeployedCosmosCoinContract( + types.DenomFromDeployedCosmosCoinContractKey(iterator.Key()), + types.BytesToInternalEVMAddress(iterator.Value()), + ) + if cb(contract) { + break + } + } +} diff --git a/x/evmutil/keeper/keeper_test.go b/x/evmutil/keeper/keeper_test.go index 803acec7..a5ea5c6e 100644 --- a/x/evmutil/keeper/keeper_test.go +++ b/x/evmutil/keeper/keeper_test.go @@ -391,6 +391,49 @@ func (suite *keeperTestSuite) TestDeployedCosmosCoinContractStoreState() { }) } +func (suite *keeperTestSuite) TestIterateAllDeployedCosmosCoinContracts() { + suite.SetupTest() + address := testutil.RandomInternalEVMAddress() + denoms := []string{} + register := func(denom string) { + addr := testutil.RandomInternalEVMAddress() + if denom == "waldo" { + addr = address + } + err := suite.Keeper.SetDeployedCosmosCoinContract(suite.Ctx, denom, addr) + suite.NoError(err) + denoms = append(denoms, denom) + } + + // register some contracts + register("magic") + register("popcorn") + register("waldo") + register("zzz") + register("ibc/27394FB092D2ECCD56123C74F36E4C1F926001CEADA9CA97EA622B25F41E5EB2") + + suite.Run("stops when told", func() { + // test out stopping the iteration + // NOTE: don't actually look for a single contract this way. the keys are deterministic by denom. + var contract types.DeployedCosmosCoinContract + suite.Keeper.IterateAllDeployedCosmosCoinContracts(suite.Ctx, func(c types.DeployedCosmosCoinContract) bool { + contract = c + return c.CosmosDenom == "waldo" + }) + suite.Equal(types.NewDeployedCosmosCoinContract("waldo", address), contract) + }) + + suite.Run("iterates all contracts", func() { + foundDenoms := make([]string, 0, len(denoms)) + suite.Keeper.IterateAllDeployedCosmosCoinContracts(suite.Ctx, func(c types.DeployedCosmosCoinContract) bool { + foundDenoms = append(foundDenoms, c.CosmosDenom) + return false + }) + suite.Len(foundDenoms, len(denoms)) + suite.ElementsMatch(denoms, foundDenoms) + }) +} + func TestKeeperTestSuite(t *testing.T) { suite.Run(t, new(keeperTestSuite)) }