From 6585ac24b06f591feaf0cd87d02d066de21093d2 Mon Sep 17 00:00:00 2001 From: Robert Pirtle Date: Wed, 24 May 2023 16:23:33 -0700 Subject: [PATCH] feat(evmutil): track deployed contracts in state (#1598) * feat(evmutil): track deployed contracts in state * docs(evmutil): update state spec * update changelog --- CHANGELOG.md | 4 +- x/evmutil/keeper/erc20.go | 26 ++++++++++++ x/evmutil/keeper/erc20_test.go | 64 +++++++++++++++++++++++++++++ x/evmutil/keeper/keeper.go | 29 +++++++++++++ x/evmutil/keeper/keeper_test.go | 36 ++++++++++++++++ x/evmutil/keeper/msg_server.go | 4 +- x/evmutil/keeper/msg_server_test.go | 5 ++- x/evmutil/keeper/params.go | 12 ++++++ x/evmutil/keeper/params_test.go | 27 ++++++++++++ x/evmutil/spec/02_state.md | 14 ++++++- x/evmutil/testutil/suite.go | 13 ++++++ x/evmutil/types/address.go | 10 +++++ x/evmutil/types/address_test.go | 38 +++++++++++++++++ x/evmutil/types/errors.go | 2 + x/evmutil/types/keys.go | 14 ++++++- 15 files changed, 291 insertions(+), 7 deletions(-) create mode 100644 x/evmutil/types/address_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index af1f6a8a..be16579a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,9 +36,10 @@ Ref: https://keepachangelog.com/en/1.0.0/ ## [Unreleased] -## Features +### Features - (evmutil) [#1590] & [#1596] Add allow list param of sdk native denoms that can be transferred to evm - (evmutil) [#1591] & [#1596] Configure module to support deploying ERC20KavaWrappedCosmosCoin contracts +- (evmutil) [#1598] Track deployed ERC20 contract addresses for representing cosmos coins in module state ## [v0.23.0] @@ -241,6 +242,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 +[#1598]: https://github.com/Kava-Labs/kava/pull/1598 [#1596]: https://github.com/Kava-Labs/kava/pull/1596 [#1591]: https://github.com/Kava-Labs/kava/pull/1591 [#1590]: https://github.com/Kava-Labs/kava/pull/1590 diff --git a/x/evmutil/keeper/erc20.go b/x/evmutil/keeper/erc20.go index 1097860b..884ea968 100644 --- a/x/evmutil/keeper/erc20.go +++ b/x/evmutil/keeper/erc20.go @@ -110,6 +110,32 @@ func (k Keeper) DeployKavaWrappedCosmosCoinERC20Contract( return types.NewInternalEVMAddress(contractAddr), nil } +// GetOrDeployCosmosCoinERC20Contract checks the module store for a deployed contract for the given +// token info and returns it if preset. Otherwise, it deploys and registers the contract. +func (k *Keeper) GetOrDeployCosmosCoinERC20Contract( + ctx sdk.Context, + tokenInfo types.AllowedCosmosCoinERC20Token, +) (types.InternalEVMAddress, error) { + contractAddress, found := k.GetDeployedCosmosCoinContract(ctx, tokenInfo.CosmosDenom) + if found { + // contract has already been deployed + return contractAddress, nil + } + + // deploy a new contract + contractAddress, err := k.DeployKavaWrappedCosmosCoinERC20Contract(ctx, tokenInfo) + if err != nil { + return contractAddress, err + } + + // register the contract to the module store + err = k.SetDeployedCosmosCoinContract(ctx, tokenInfo.CosmosDenom, contractAddress) + + // TODO: emit event that contract was deployed + + return contractAddress, err +} + // MintERC20 mints the given amount of an ERC20 token to an address. This is // unchecked and should only be called after permission and enabled ERC20 checks. func (k Keeper) MintERC20( diff --git a/x/evmutil/keeper/erc20_test.go b/x/evmutil/keeper/erc20_test.go index d92183b0..019e3a12 100644 --- a/x/evmutil/keeper/erc20_test.go +++ b/x/evmutil/keeper/erc20_test.go @@ -7,6 +7,7 @@ import ( "github.com/ethereum/go-ethereum/common" "github.com/stretchr/testify/suite" + "github.com/kava-labs/kava/app" "github.com/kava-labs/kava/x/evmutil/testutil" "github.com/kava-labs/kava/x/evmutil/types" ) @@ -145,3 +146,66 @@ func (suite *ERC20TestSuite) TestDeployKavaWrappedCosmosCoinERC20Contract() { suite.ErrorContains(err, "Ownable: caller is not the owner") }) } + +func (suite *ERC20TestSuite) TestGetOrDeployCosmosCoinERC20Contract() { + suite.Run("finds existing contract address", func() { + suite.SetupTest() + denom := "magic" + addr := types.BytesToInternalEVMAddress(app.RandomAddress().Bytes()) + // pretend like we've registered a contract in a previous life + err := suite.Keeper.SetDeployedCosmosCoinContract(suite.Ctx, denom, addr) + suite.NoError(err) + + // expect it to find the registered address + tokenInfo := types.AllowedCosmosCoinERC20Token{CosmosDenom: denom} + contractAddress, err := suite.Keeper.GetOrDeployCosmosCoinERC20Contract(suite.Ctx, tokenInfo) + suite.NoError(err) + suite.Equal(addr, contractAddress) + + // expect it to still be registered + contractAddress, found := suite.Keeper.GetDeployedCosmosCoinContract(suite.Ctx, denom) + suite.True(found) + suite.Equal(addr, contractAddress) + }) + + suite.Run("deploys & registers contract when one does not exist", func() { + suite.SetupTest() + denom := "magic" + tokenInfo := types.NewAllowedCosmosCoinERC20Token(denom, "Magic Coin", "MAGIC", 6) + + // expect it to not be registered + _, found := suite.Keeper.GetDeployedCosmosCoinContract(suite.Ctx, denom) + suite.False(found) + + // deploy the contract + contractAddress, err := suite.Keeper.GetOrDeployCosmosCoinERC20Contract(suite.Ctx, tokenInfo) + suite.NoError(err) + + // expect it to be registered now + registeredAddress, found := suite.Keeper.GetDeployedCosmosCoinContract(suite.Ctx, denom) + suite.True(found) + suite.False(registeredAddress.IsNil()) + suite.Equal(contractAddress, registeredAddress) + }) + + // this can only happen if governance passes a bad allowed token + suite.Run("fails when token can't be deployed", func() { + suite.SetupTest() + denom := "nope" + // empty other fields means this token is invalid. + invalidToken := types.AllowedCosmosCoinERC20Token{CosmosDenom: denom} + + // expect it to not be registered + _, found := suite.Keeper.GetDeployedCosmosCoinContract(suite.Ctx, denom) + suite.False(found) + + // attempt to deploy the contract + contractAddress, err := suite.Keeper.GetOrDeployCosmosCoinERC20Contract(suite.Ctx, invalidToken) + suite.ErrorContains(err, "failed to deploy erc20") + suite.True(contractAddress.IsNil()) + + // still expect it to not be registered + _, found = suite.Keeper.GetDeployedCosmosCoinContract(suite.Ctx, denom) + suite.False(found) + }) +} diff --git a/x/evmutil/keeper/keeper.go b/x/evmutil/keeper/keeper.go index f39b27db..aeb2db73 100644 --- a/x/evmutil/keeper/keeper.go +++ b/x/evmutil/keeper/keeper.go @@ -183,3 +183,32 @@ func (k Keeper) RemoveBalance(ctx sdk.Context, addr sdk.AccAddress, amt sdkmath. } return k.SetBalance(ctx, addr, finalBal) } + +// SetDeployedCosmosCoinContract stores a single deployed ERC20KavaWrappedCosmosCoin contract address +func (k *Keeper) SetDeployedCosmosCoinContract(ctx sdk.Context, cosmosDenom string, contractAddress types.InternalEVMAddress) error { + if err := sdk.ValidateDenom(cosmosDenom); err != nil { + return errorsmod.Wrap(types.ErrInvalidCosmosDenom, cosmosDenom) + } + if contractAddress.IsNil() { + return errorsmod.Wrapf( + sdkerrors.ErrInvalidAddress, + "attempting to register empty contract address for denom '%s'", + cosmosDenom, + ) + } + store := ctx.KVStore(k.storeKey) + storeKey := types.DeployedCosmosCoinContractKey(cosmosDenom) + + store.Set(storeKey, contractAddress.Bytes()) + return nil +} + +// SetDeployedCosmosCoinContract gets a deployed ERC20KavaWrappedCosmosCoin contract address by cosmos denom +// Returns the stored address and a bool indicating if it was found or not +func (k *Keeper) GetDeployedCosmosCoinContract(ctx sdk.Context, cosmosDenom string) (types.InternalEVMAddress, bool) { + store := ctx.KVStore(k.storeKey) + storeKey := types.DeployedCosmosCoinContractKey(cosmosDenom) + bz := store.Get(storeKey) + found := len(bz) != 0 + return types.BytesToInternalEVMAddress(bz), found +} diff --git a/x/evmutil/keeper/keeper_test.go b/x/evmutil/keeper/keeper_test.go index a8ea7857..803acec7 100644 --- a/x/evmutil/keeper/keeper_test.go +++ b/x/evmutil/keeper/keeper_test.go @@ -355,6 +355,42 @@ func (suite *keeperTestSuite) TestGetBalance() { } } +func (suite *keeperTestSuite) TestDeployedCosmosCoinContractStoreState() { + suite.Run("returns nil for nonexistent denom", func() { + suite.SetupTest() + addr, found := suite.Keeper.GetDeployedCosmosCoinContract(suite.Ctx, "undeployed-denom") + suite.False(found) + suite.Equal(addr, types.InternalEVMAddress{}) + }) + + suite.Run("handles setting & getting a contract address", func() { + suite.SetupTest() + denom := "ibc/27394FB092D2ECCD56123C74F36E4C1F926001CEADA9CA97EA622B25F41E5EB2" + address := testutil.RandomInternalEVMAddress() + + err := suite.Keeper.SetDeployedCosmosCoinContract(suite.Ctx, denom, address) + suite.NoError(err) + + stored, found := suite.Keeper.GetDeployedCosmosCoinContract(suite.Ctx, denom) + suite.True(found) + suite.Equal(address, stored) + }) + + suite.Run("fails when setting an invalid denom", func() { + suite.SetupTest() + invalidDenom := "" + err := suite.Keeper.SetDeployedCosmosCoinContract(suite.Ctx, invalidDenom, testutil.RandomInternalEVMAddress()) + suite.ErrorContains(err, "invalid cosmos denom") + }) + + suite.Run("fails when setting 0 address", func() { + suite.SetupTest() + invalidAddr := types.InternalEVMAddress{} + err := suite.Keeper.SetDeployedCosmosCoinContract(suite.Ctx, "denom", invalidAddr) + suite.ErrorContains(err, "attempting to register empty contract address") + }) +} + func TestKeeperTestSuite(t *testing.T) { suite.Run(t, new(keeperTestSuite)) } diff --git a/x/evmutil/keeper/msg_server.go b/x/evmutil/keeper/msg_server.go index 6f2d28dd..bfb113e8 100644 --- a/x/evmutil/keeper/msg_server.go +++ b/x/evmutil/keeper/msg_server.go @@ -114,8 +114,8 @@ func (s msgServer) ConvertERC20ToCoin( // ConvertCosmosCoinToERC20 converts a native sdk.Coin to an ERC20. // If no ERC20 contract has been deployed for the given denom, a new // contract will be deployed and registered to the module. -func (msgServer) ConvertCosmosCoinToERC20( - ctx context.Context, +func (s msgServer) ConvertCosmosCoinToERC20( + goCtx context.Context, msg *types.MsgConvertCosmosCoinToERC20, ) (*types.MsgConvertCosmosCoinToERC20Response, error) { return nil, fmt.Errorf("unimplemented - coming soon") diff --git a/x/evmutil/keeper/msg_server_test.go b/x/evmutil/keeper/msg_server_test.go index 1215603e..cf6f76ab 100644 --- a/x/evmutil/keeper/msg_server_test.go +++ b/x/evmutil/keeper/msg_server_test.go @@ -4,13 +4,16 @@ import ( "math/big" "testing" + "github.com/stretchr/testify/suite" + sdkmath "cosmossdk.io/math" sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/ethereum/go-ethereum/common/math" + "github.com/kava-labs/kava/x/evmutil/keeper" "github.com/kava-labs/kava/x/evmutil/testutil" "github.com/kava-labs/kava/x/evmutil/types" - "github.com/stretchr/testify/suite" ) type MsgServerSuite struct { diff --git a/x/evmutil/keeper/params.go b/x/evmutil/keeper/params.go index 2aae4112..82eff757 100644 --- a/x/evmutil/keeper/params.go +++ b/x/evmutil/keeper/params.go @@ -20,6 +20,18 @@ func (k Keeper) SetParams(ctx sdk.Context, params types.Params) { k.paramSubspace.SetParamSet(ctx, ¶ms) } +// GetAllowedTokenMetadata gets the token metadata for the given cosmosDenom if it is allowed. +// Returns the metadata if allowed, and a bool indicating if the denom was in the allow list or not. +func (k Keeper) GetAllowedTokenMetadata(ctx sdk.Context, cosmosDenom string) (types.AllowedCosmosCoinERC20Token, bool) { + params := k.GetParams(ctx) + for _, token := range params.AllowedCosmosDenoms { + if token.CosmosDenom == cosmosDenom { + return token, true + } + } + return types.AllowedCosmosCoinERC20Token{}, false +} + // GetEnabledConversionPairFromERC20Address returns an ConversionPair from the internal contract address. func (k Keeper) GetEnabledConversionPairFromERC20Address( ctx sdk.Context, diff --git a/x/evmutil/keeper/params_test.go b/x/evmutil/keeper/params_test.go index f3a051ec..4a1a016a 100644 --- a/x/evmutil/keeper/params_test.go +++ b/x/evmutil/keeper/params_test.go @@ -60,3 +60,30 @@ func (suite *ParamsTestSuite) TestHistoricParamsQuery() { _ = oldStateKeeper.GetParams(suite.Ctx) }) } + +func (suite *keeperTestSuite) TestGetAllowedTokenMetadata() { + suite.SetupTest() + + atom := types.NewAllowedCosmosCoinERC20Token( + "ibc/27394FB092D2ECCD56123C74F36E4C1F926001CEADA9CA97EA622B25F41E5EB2", + "Kava EVM ATOM", "ATOM", 6, + ) + hard := types.NewAllowedCosmosCoinERC20Token("hard", "Kava EVM Hard", "HARD", 6) + + // init state with some allowed tokens + params := suite.Keeper.GetParams(suite.Ctx) + params.AllowedCosmosDenoms = types.NewAllowedCosmosCoinERC20Tokens(atom, hard) + suite.Keeper.SetParams(suite.Ctx, params) + + // finds allowed tokens by denom + storedAtom, allowed := suite.Keeper.GetAllowedTokenMetadata(suite.Ctx, atom.CosmosDenom) + suite.True(allowed) + suite.Equal(atom, storedAtom) + storedHard, allowed := suite.Keeper.GetAllowedTokenMetadata(suite.Ctx, hard.CosmosDenom) + suite.True(allowed) + suite.Equal(hard, storedHard) + + // returns not-allowed when token not allowed + _, allowed = suite.Keeper.GetAllowedTokenMetadata(suite.Ctx, "not-in-list") + suite.False(allowed) +} diff --git a/x/evmutil/spec/02_state.md b/x/evmutil/spec/02_state.md index c5d24961..9d861d9c 100644 --- a/x/evmutil/spec/02_state.md +++ b/x/evmutil/spec/02_state.md @@ -45,7 +45,6 @@ message AllowedCosmosCoinERC20Token { // Number of decimals ERC20 contract is deployed with. uint32 decimals = 4; } - ``` `GenesisState` defines the state that must be persisted when the blockchain stops/restarts in order for normal function of the evmutil module to resume. @@ -70,6 +69,17 @@ message Account { } ``` +## Deployed Cosmos Coin Contract Addresses + +Addresses for the ERC20 contracts representing cosmos-sdk `Coin`s are kept in the module store. They are stored as bytes by the cosmos-sdk denom they represent. + +Example: +If a contract for representing the cosmos-sdk denom `cow` as an ERC20 in the EVM is deployed by the module to the address `0xbeef00000000000000000000000000000000beef`, the module store will contain: + +`0x01 | bytes("cow") => bytes(0xbeef00000000000000000000000000000000beef)` + +Where `0x01` is the `DeployedCosmosCoinContractKeyPrefix` defined in [keys.go](../types/keys.go). + ## Store -For complete implementation details for how items are stored, see [keys.go](../types/keys.go). `x/evmutil` store state consists of accounts. +For complete implementation details for how items are stored, see [keys.go](../types/keys.go). `x/evmutil` store state consists of accounts and deployed contract addresses. diff --git a/x/evmutil/testutil/suite.go b/x/evmutil/testutil/suite.go index b4696c40..368c5493 100644 --- a/x/evmutil/testutil/suite.go +++ b/x/evmutil/testutil/suite.go @@ -180,6 +180,10 @@ func (suite *Suite) Commit() { suite.Ctx = suite.App.NewContext(false, header) } +func (suite *Suite) ModuleBalance(denom string) sdk.Int { + return suite.App.GetModuleAccountBalance(suite.Ctx, types.ModuleName, denom) +} + func (suite *Suite) FundAccountWithKava(addr sdk.AccAddress, coins sdk.Coins) { ukava := coins.AmountOf("ukava") if ukava.IsPositive() { @@ -411,3 +415,12 @@ func RandomEvmAccount() (common.Address, *ethsecp256k1.PrivKey) { addr := common.BytesToAddress(privKey.PubKey().Address()) return addr, privKey } + +func RandomEvmAddress() common.Address { + addr, _ := RandomEvmAccount() + return addr +} + +func RandomInternalEVMAddress() types.InternalEVMAddress { + return types.NewInternalEVMAddress(RandomEvmAddress()) +} diff --git a/x/evmutil/types/address.go b/x/evmutil/types/address.go index bfdb0ce6..4d3ab2ff 100644 --- a/x/evmutil/types/address.go +++ b/x/evmutil/types/address.go @@ -12,6 +12,11 @@ type InternalEVMAddress struct { common.Address } +// IsNil returns true when the address is the 0 address +func (a InternalEVMAddress) IsNil() bool { + return a.Address == common.Address{} +} + // NewInternalEVMAddress returns a new InternalEVMAddress from a common.Address. func NewInternalEVMAddress(addr common.Address) InternalEVMAddress { return InternalEVMAddress{ @@ -19,6 +24,11 @@ func NewInternalEVMAddress(addr common.Address) InternalEVMAddress { } } +// BytesToInternalEVMAddress creates an InternalEVMAddress from a slice of bytes +func BytesToInternalEVMAddress(bz []byte) InternalEVMAddress { + return NewInternalEVMAddress(common.BytesToAddress(bz)) +} + // NewInternalEVMAddressFromString returns a new InternalEVMAddress from a hex // string. Returns an error if hex string is invalid. func NewInternalEVMAddressFromString(addrStr string) (InternalEVMAddress, error) { diff --git a/x/evmutil/types/address_test.go b/x/evmutil/types/address_test.go new file mode 100644 index 00000000..5ce07695 --- /dev/null +++ b/x/evmutil/types/address_test.go @@ -0,0 +1,38 @@ +package types_test + +import ( + "testing" + + "github.com/kava-labs/kava/x/evmutil/testutil" + "github.com/kava-labs/kava/x/evmutil/types" + "github.com/stretchr/testify/require" +) + +func TestInternalEVMAddress_BytesToInternalEVMAddress(t *testing.T) { + addr := testutil.RandomEvmAddress() + require.Equal(t, + types.NewInternalEVMAddress(addr), + types.BytesToInternalEVMAddress(addr.Bytes()), + ) +} + +func TestInternalEVMAddress_IsNil(t *testing.T) { + addr := types.InternalEVMAddress{} + require.True(t, addr.IsNil()) + addr.Address = testutil.RandomEvmAddress() + require.False(t, addr.IsNil()) +} + +func TestInternalEVMAddress_NewInternalEVMAddressFromString(t *testing.T) { + t.Run("works with valid address string", func(t *testing.T) { + validAddr := testutil.RandomEvmAddress() + addr, err := types.NewInternalEVMAddressFromString(validAddr.Hex()) + require.NoError(t, err) + require.Equal(t, types.NewInternalEVMAddress(validAddr), addr) + }) + + t.Run("fails with invalid hex string", func(t *testing.T) { + _, err := types.NewInternalEVMAddressFromString("0xinvalid-address") + require.ErrorContains(t, err, "string is not a hex address") + }) +} diff --git a/x/evmutil/types/errors.go b/x/evmutil/types/errors.go index 4687ca28..de38302a 100644 --- a/x/evmutil/types/errors.go +++ b/x/evmutil/types/errors.go @@ -9,4 +9,6 @@ var ( ErrConversionNotEnabled = errorsmod.Register(ModuleName, 4, "ERC20 token not enabled to convert to sdk.Coin") ErrBalanceInvariance = errorsmod.Register(ModuleName, 5, "post EVM transfer balance invariant failed") ErrUnexpectedContractEvent = errorsmod.Register(ModuleName, 6, "unexpected contract event") + ErrInvalidCosmosDenom = errorsmod.Register(ModuleName, 7, "invalid cosmos denom") + ErrSDKConversionNotEnabled = errorsmod.Register(ModuleName, 8, "sdk.Coin not enabled to convert to ERC20 token") ) diff --git a/x/evmutil/types/keys.go b/x/evmutil/types/keys.go index 15e61cc9..578ec0b4 100644 --- a/x/evmutil/types/keys.go +++ b/x/evmutil/types/keys.go @@ -17,13 +17,25 @@ const ( RouterKey = ModuleName ) -var AccountStoreKeyPrefix = []byte{0x00} // prefix for keys that store accounts +// KVStore keys +var ( + // AccountStoreKeyPrefix is the prefix for keys that store accounts + AccountStoreKeyPrefix = []byte{0x00} + // DeployedCosmosCoinContractKeyPrefix is the key for storing deployed KavaWrappedCosmosCoinERC20s contract addresses + DeployedCosmosCoinContractKeyPrefix = []byte{0x01} +) // AccountStoreKey turns an address to a key used to get the account from the store func AccountStoreKey(addr sdk.AccAddress) []byte { return append(AccountStoreKeyPrefix, address.MustLengthPrefix(addr)...) } +// DeployedCosmosCoinContractKey gives the store key that holds the address of the deployed ERC20 +// that wraps the given cosmosDenom sdk.Coin +func DeployedCosmosCoinContractKey(cosmosDenom string) []byte { + return append(DeployedCosmosCoinContractKeyPrefix, []byte(cosmosDenom)...) +} + // ModuleAddress is the native module address for EVM var ModuleEVMAddress common.Address