feat(evmutil): add CosmosCoinsFullyBackedInvariant (#1610)

* add IterateAllDeployedCosmosCoinContracts method

* refactor unpacking big int from erc20 query

* add QueryERC20TotalSupply method

* feat(evmutil): add CosmosCoinsFullyBackedInvariant

* update changelog
This commit is contained in:
Robert Pirtle 2023-06-05 11:39:53 -07:00 committed by GitHub
parent f4b8bf8f07
commit 528be6350e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 240 additions and 6 deletions

View File

@ -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

View File

@ -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 {

View File

@ -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,
)

View File

@ -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.

View File

@ -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
}
}

View File

@ -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)
}

View File

@ -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
}
}
}

View File

@ -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))
}