From 741f1e42eed7c63297a12fa150a270ce4d6cf4e9 Mon Sep 17 00:00:00 2001 From: Robert Pirtle Date: Tue, 30 May 2023 12:00:01 -0700 Subject: [PATCH] feat(evmutil)!: implement MsgConvertCosmosCoinToERC20 (#1603) * feat(evmutil): implement MsgConvertCosmosCoinToERC20 * docs(evmutil): update module spec * update changelog * rename conversion -> conversion_evm_native * refactor ConvertCosmosCoinToERC20 to keeper method * add CLI cmd for MsgConvertCosmosCoinToERC20 * updates from pr --- CHANGELOG.md | 5 + x/evmutil/client/cli/tx.go | 40 +++- x/evmutil/keeper/conversion_cosmos_native.go | 50 ++++ .../keeper/conversion_cosmos_native_test.go | 151 ++++++++++++ ...conversion.go => conversion_evm_native.go} | 0 ..._test.go => conversion_evm_native_test.go} | 0 x/evmutil/keeper/msg_server.go | 25 +- x/evmutil/keeper/msg_server_test.go | 224 ++++++++++++++++++ x/evmutil/keeper/params.go | 4 +- x/evmutil/spec/01_concepts.md | 28 ++- x/evmutil/spec/03_messages.md | 34 ++- x/evmutil/spec/05_params.md | 12 +- x/evmutil/testutil/suite.go | 5 + x/evmutil/types/codec.go | 1 - x/evmutil/types/errors.go | 2 +- 15 files changed, 565 insertions(+), 16 deletions(-) create mode 100644 x/evmutil/keeper/conversion_cosmos_native.go create mode 100644 x/evmutil/keeper/conversion_cosmos_native_test.go rename x/evmutil/keeper/{conversion.go => conversion_evm_native.go} (100%) rename x/evmutil/keeper/{conversion_test.go => conversion_evm_native_test.go} (100%) diff --git a/CHANGELOG.md b/CHANGELOG.md index be16579a..4aabec15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,10 @@ Ref: https://keepachangelog.com/en/1.0.0/ - (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 +- (evmutil) [#1603] Add MsgConvertCosmosCoinToERC20 for converting an sdk.Coin to an ERC20 in the EVM + +### Client Breaking +- (evmutil) [#1603] Renamed error `ErrConversionNotEnabled` to `ErrEVMConversionNotEnabled` ## [v0.23.0] @@ -242,6 +246,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 +[#1603]: https://github.com/Kava-Labs/kava/pull/1603 [#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 diff --git a/x/evmutil/client/cli/tx.go b/x/evmutil/client/cli/tx.go index b0bb2c0b..d6d19e3a 100644 --- a/x/evmutil/client/cli/tx.go +++ b/x/evmutil/client/cli/tx.go @@ -30,6 +30,7 @@ func GetTxCmd() *cobra.Command { cmds := []*cobra.Command{ getCmdMsgConvertCoinToERC20(), getCmdConvertERC20ToCoin(), + getCmdMsgConvertCosmosCoinToERC20(), } for _, cmd := range cmds { @@ -43,7 +44,7 @@ func GetTxCmd() *cobra.Command { func getCmdMsgConvertCoinToERC20() *cobra.Command { return &cobra.Command{ - Use: "convert-coin-to-erc20 [Kava ERC20 address] [coin]", + Use: "convert-coin-to-erc20 [Kava EVM address] [coin]", Short: "converts sdk.Coin to erc20 tokens on Kava eth co-chain", Example: fmt.Sprintf( `%s tx %s convert-coin-to-erc20 0x7Bbf300890857b8c241b219C6a489431669b3aFA 500000000erc20/usdc --from --gas 2000000`, @@ -121,3 +122,40 @@ func getCmdConvertERC20ToCoin() *cobra.Command { }, } } + +func getCmdMsgConvertCosmosCoinToERC20() *cobra.Command { + return &cobra.Command{ + Use: "convert-cosmos-coin-to-erc20 [receiver_0x_address] [amount] [flags]", + Short: "converts asset native to Cosmos Co-chain to an ERC20 on the EVM Co-chain", + Example: fmt.Sprintf( + `Convert 500 ATOM and send ERC20 to 0x03db6b11F47d074a532b9eb8a98aB7AdA5845087: + %s tx %s convert-cosmos-coin-to-erc20 0x03db6b11F47d074a532b9eb8a98aB7AdA5845087 500000000ibc/27394FB092D2ECCD56123C74F36E4C1F926001CEADA9CA97EA622B25F41E5EB2 --from --gas 2000000`, + version.AppName, types.ModuleName, + ), + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + clientCtx, err := client.GetClientTxContext(cmd) + if err != nil { + return err + } + + receiver := args[0] + if !common.IsHexAddress(receiver) { + return fmt.Errorf("receiver '%s' is an invalid hex address", args[0]) + } + + amount, err := sdk.ParseCoinNormalized(args[1]) + if err != nil { + return err + } + + signer := clientCtx.GetFromAddress() + msg := types.NewMsgConvertCosmosCoinToERC20(signer.String(), receiver, amount) + if err := msg.ValidateBasic(); err != nil { + return err + } + + return tx.GenerateOrBroadcastTxCLI(clientCtx, cmd.Flags(), &msg) + }, + } +} diff --git a/x/evmutil/keeper/conversion_cosmos_native.go b/x/evmutil/keeper/conversion_cosmos_native.go new file mode 100644 index 00000000..6296f398 --- /dev/null +++ b/x/evmutil/keeper/conversion_cosmos_native.go @@ -0,0 +1,50 @@ +package keeper + +import ( + errorsmod "cosmossdk.io/errors" + sdk "github.com/cosmos/cosmos-sdk/types" + + "github.com/kava-labs/kava/x/evmutil/types" +) + +// ConvertCosmosCoinToERC20 locks the initiator's sdk.Coin in the module account +// and mints the receiver a corresponding amount of an ERC20 representing the Coin. +// If a conversion has never been made before and no contract exists, one will be deployed. +// Only denoms registered to the AllowedCosmosDenoms param may be converted. +func (k *Keeper) ConvertCosmosCoinToERC20( + ctx sdk.Context, + initiator sdk.AccAddress, + receiver types.InternalEVMAddress, + amount sdk.Coin, +) error { + // check that the conversion is allowed + tokenInfo, allowed := k.GetAllowedTokenMetadata(ctx, amount.Denom) + if !allowed { + return errorsmod.Wrapf(types.ErrSDKConversionNotEnabled, amount.Denom) + } + + // send coins from initiator to the module account + // do this before possible contract deploy to prevent unnecessary store interactions + err := k.bankKeeper.SendCoinsFromAccountToModule( + ctx, initiator, types.ModuleName, sdk.NewCoins(amount), + ) + if err != nil { + return err + } + + // find deployed contract if it exits + contractAddress, err := k.GetOrDeployCosmosCoinERC20Contract(ctx, tokenInfo) + if err != nil { + return err + } + + // mint erc20 tokens for the user + err = k.MintERC20(ctx, contractAddress, receiver, amount.Amount.BigInt()) + if err != nil { + return err + } + + // TODO emit conversion event + + return nil +} diff --git a/x/evmutil/keeper/conversion_cosmos_native_test.go b/x/evmutil/keeper/conversion_cosmos_native_test.go new file mode 100644 index 00000000..154f79ce --- /dev/null +++ b/x/evmutil/keeper/conversion_cosmos_native_test.go @@ -0,0 +1,151 @@ +package keeper_test + +import ( + "fmt" + "math/big" + "testing" + + "github.com/stretchr/testify/suite" + + sdkmath "cosmossdk.io/math" + sdk "github.com/cosmos/cosmos-sdk/types" + + "github.com/kava-labs/kava/app" + "github.com/kava-labs/kava/x/evmutil/testutil" + "github.com/kava-labs/kava/x/evmutil/types" +) + +type ConversionCosmosNativeSuite struct { + testutil.Suite +} + +func TestConversionCosmosNativeSuite(t *testing.T) { + suite.Run(t, new(ConversionCosmosNativeSuite)) +} + +// fail test if contract for denom not registered +func (suite *ConversionCosmosNativeSuite) denomContractRegistered(denom string) types.InternalEVMAddress { + contractAddress, found := suite.Keeper.GetDeployedCosmosCoinContract(suite.Ctx, denom) + suite.True(found) + return contractAddress +} + +// fail test if contract for denom IS registered +func (suite *ConversionCosmosNativeSuite) denomContractNotRegistered(denom string) { + _, found := suite.Keeper.GetDeployedCosmosCoinContract(suite.Ctx, denom) + suite.False(found) +} + +// more tests of tests of this method are made to the msg handler, see ./msg_server_test.go +func (suite *ConversionCosmosNativeSuite) TestConvertCosmosCoinToERC20() { + allowedDenom := "ibc/27394FB092D2ECCD56123C74F36E4C1F926001CEADA9CA97EA622B25F41E5EB2" + initialFunding := sdk.NewInt64Coin(allowedDenom, int64(1e10)) + initiator := app.RandomAddress() + + amount := sdk.NewInt64Coin(allowedDenom, 6e8) + receiver1 := types.BytesToInternalEVMAddress(app.RandomAddress().Bytes()) + receiver2 := types.BytesToInternalEVMAddress(app.RandomAddress().Bytes()) + + var contractAddress types.InternalEVMAddress + + caller, key := testutil.RandomEvmAccount() + query := func(method string, args ...interface{}) ([]interface{}, error) { + return suite.QueryContract( + types.ERC20KavaWrappedCosmosCoinContract.ABI, + caller, + key, + contractAddress, + method, + args..., + ) + } + checkTotalSupply := func(expectedSupply sdkmath.Int) { + res, err := query("totalSupply") + suite.NoError(err) + suite.Len(res, 1) + suite.BigIntsEqual(expectedSupply.BigInt(), res[0].(*big.Int), "unexpected total supply") + } + checkBalanceOf := func(address types.InternalEVMAddress, expectedBalance sdkmath.Int) { + res, err := query("balanceOf", address.Address) + suite.NoError(err) + suite.Len(res, 1) + suite.BigIntsEqual(expectedBalance.BigInt(), res[0].(*big.Int), fmt.Sprintf("unexpected balanceOf for %s", address)) + } + + suite.SetupTest() + + suite.Run("fails when denom not allowed", func() { + suite.denomContractNotRegistered(allowedDenom) + err := suite.Keeper.ConvertCosmosCoinToERC20( + suite.Ctx, + initiator, + receiver1, + sdk.NewCoin(allowedDenom, sdkmath.NewInt(6e8)), + ) + suite.ErrorContains(err, "sdk.Coin not enabled to convert to ERC20 token") + suite.denomContractNotRegistered(allowedDenom) + }) + + suite.Run("allowed denoms have contract deploys on first conversion", func() { + // make the denom allowed for conversion + params := suite.Keeper.GetParams(suite.Ctx) + params.AllowedCosmosDenoms = types.NewAllowedCosmosCoinERC20Tokens( + types.NewAllowedCosmosCoinERC20Token(allowedDenom, "Kava EVM Atom", "ATOM", 6), + ) + suite.Keeper.SetParams(suite.Ctx, params) + + // fund account + err := suite.App.FundAccount(suite.Ctx, initiator, sdk.NewCoins(initialFunding)) + suite.NoError(err, "failed to initially fund account") + + // first conversion + err = suite.Keeper.ConvertCosmosCoinToERC20( + suite.Ctx, + initiator, + receiver1, + sdk.NewCoin(allowedDenom, sdkmath.NewInt(6e8)), + ) + suite.NoError(err) + + // contract should be deployed & registered + contractAddress = suite.denomContractRegistered(allowedDenom) + + // sdk coin deducted from initiator + expectedBalance := initialFunding.Sub(amount) + balance := suite.BankKeeper.GetBalance(suite.Ctx, initiator, allowedDenom) + suite.Equal(expectedBalance, balance) + + // erc20 minted to receiver + checkBalanceOf(receiver1, amount.Amount) + // total supply of erc20 should have increased + checkTotalSupply(amount.Amount) + }) + + suite.Run("2nd deploy uses same contract", func() { + // expect no initial balance + checkBalanceOf(receiver2, sdkmath.NewInt(0)) + + // 2nd conversion + err := suite.Keeper.ConvertCosmosCoinToERC20( + suite.Ctx, + initiator, + receiver2, + sdk.NewCoin(allowedDenom, sdkmath.NewInt(6e8)), + ) + suite.NoError(err) + + // contract address should not change + convertTwiceContractAddress := suite.denomContractRegistered(allowedDenom) + suite.Equal(contractAddress, convertTwiceContractAddress) + + // sdk coin deducted from initiator + expectedBalance := initialFunding.Sub(amount).Sub(amount) + balance := suite.BankKeeper.GetBalance(suite.Ctx, initiator, allowedDenom) + suite.Equal(expectedBalance, balance) + + // erc20 minted to receiver + checkBalanceOf(receiver2, amount.Amount) + // total supply of erc20 should have increased + checkTotalSupply(amount.Amount.MulRaw(2)) + }) +} diff --git a/x/evmutil/keeper/conversion.go b/x/evmutil/keeper/conversion_evm_native.go similarity index 100% rename from x/evmutil/keeper/conversion.go rename to x/evmutil/keeper/conversion_evm_native.go diff --git a/x/evmutil/keeper/conversion_test.go b/x/evmutil/keeper/conversion_evm_native_test.go similarity index 100% rename from x/evmutil/keeper/conversion_test.go rename to x/evmutil/keeper/conversion_evm_native_test.go diff --git a/x/evmutil/keeper/msg_server.go b/x/evmutil/keeper/msg_server.go index bfb113e8..855f48a3 100644 --- a/x/evmutil/keeper/msg_server.go +++ b/x/evmutil/keeper/msg_server.go @@ -118,5 +118,28 @@ func (s msgServer) ConvertCosmosCoinToERC20( goCtx context.Context, msg *types.MsgConvertCosmosCoinToERC20, ) (*types.MsgConvertCosmosCoinToERC20Response, error) { - return nil, fmt.Errorf("unimplemented - coming soon") + ctx := sdk.UnwrapSDKContext(goCtx) + + initiator, err := sdk.AccAddressFromBech32(msg.Initiator) + if err != nil { + return nil, fmt.Errorf("invalid initiator address: %w", err) + } + + receiver, err := types.NewInternalEVMAddressFromString(msg.Receiver) + if err != nil { + return nil, fmt.Errorf("invalid receiver address: %w", err) + } + + if err := s.keeper.ConvertCosmosCoinToERC20( + ctx, + initiator, + receiver, + *msg.Amount, + ); err != nil { + return nil, err + } + + // TODO: emit message event + + return &types.MsgConvertCosmosCoinToERC20Response{}, nil } diff --git a/x/evmutil/keeper/msg_server_test.go b/x/evmutil/keeper/msg_server_test.go index cf6f76ab..3d80a392 100644 --- a/x/evmutil/keeper/msg_server_test.go +++ b/x/evmutil/keeper/msg_server_test.go @@ -9,8 +9,10 @@ import ( sdkmath "cosmossdk.io/math" sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/common/math" + "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" @@ -268,3 +270,225 @@ func (suite *MsgServerSuite) TestConvertERC20ToCoin() { }) } } + +func (suite *MsgServerSuite) TestConvertCosmosCoinToERC20_InitialContractDeploy() { + allowedDenom := "ibc/27394FB092D2ECCD56123C74F36E4C1F926001CEADA9CA97EA622B25F41E5EB2" + initialFunding := int64(1e10) + fundedAccount := app.RandomAddress() + + setup := func() { + suite.SetupTest() + + // make the denom allowed for conversion + params := suite.Keeper.GetParams(suite.Ctx) + params.AllowedCosmosDenoms = types.NewAllowedCosmosCoinERC20Tokens( + types.NewAllowedCosmosCoinERC20Token(allowedDenom, "Kava EVM Atom", "ATOM", 6), + ) + suite.Keeper.SetParams(suite.Ctx, params) + + // fund account + err := suite.App.FundAccount(suite.Ctx, fundedAccount, sdk.NewCoins( + sdk.NewInt64Coin(allowedDenom, initialFunding), + )) + suite.NoError(err, "failed to initially fund account") + } + + testCases := []struct { + name string + msg types.MsgConvertCosmosCoinToERC20 + amountConverted sdkmath.Int + expectedErr string + }{ + { + name: "valid - first conversion deploys contract, send to self", + msg: types.NewMsgConvertCosmosCoinToERC20( + fundedAccount.String(), + common.BytesToAddress(fundedAccount.Bytes()).Hex(), // it's me! + sdk.NewInt64Coin(allowedDenom, 5e7), + ), + amountConverted: sdkmath.NewInt(5e7), + expectedErr: "", + }, + { + name: "valid - first conversion deploys contract, send to other", + msg: types.NewMsgConvertCosmosCoinToERC20( + fundedAccount.String(), + testutil.RandomEvmAddress().Hex(), // someone else! + sdk.NewInt64Coin(allowedDenom, 9993317), + ), + amountConverted: sdkmath.NewInt(9993317), + expectedErr: "", + }, + { + name: "invalid - un-allowed denom", + msg: types.NewMsgConvertCosmosCoinToERC20( + app.RandomAddress().String(), + testutil.RandomEvmAddress().Hex(), + sdk.NewInt64Coin("not-allowed-denom", 1e4), + ), + expectedErr: "sdk.Coin not enabled to convert to ERC20 token", + }, + { + name: "invalid - bad initiator", + msg: types.NewMsgConvertCosmosCoinToERC20( + "invalid-kava-address", + testutil.RandomEvmAddress().Hex(), + sdk.NewInt64Coin(allowedDenom, 1e4), + ), + expectedErr: "invalid initiator address", + }, + { + name: "invalid - bad receiver", + msg: types.NewMsgConvertCosmosCoinToERC20( + app.RandomAddress().String(), + "invalid-0x-address", + sdk.NewInt64Coin(allowedDenom, 1e4), + ), + expectedErr: "invalid receiver address", + }, + { + name: "invalid - bad receiver", + msg: types.NewMsgConvertCosmosCoinToERC20( + app.RandomAddress().String(), + "invalid-0x-address", + sdk.NewInt64Coin(allowedDenom, 1e4), + ), + expectedErr: "invalid receiver address", + }, + { + name: "invalid - insufficient balance", + msg: types.NewMsgConvertCosmosCoinToERC20( + fundedAccount.String(), + testutil.RandomEvmAddress().Hex(), + sdk.NewInt64Coin(allowedDenom, initialFunding+1), + ), + expectedErr: "insufficient funds", + }, + // NOTE: a zero amount tx passes in this scope but will fail to pass ValidateBasic() + } + + for _, tc := range testCases { + suite.Run(tc.name, func() { + // initial setup + setup() + + moduleBalanceBefore := suite.ModuleBalance(allowedDenom) + + // submit message + _, err := suite.msgServer.ConvertCosmosCoinToERC20(suite.Ctx, &tc.msg) + + // verify error, if expected + if tc.expectedErr != "" { + suite.ErrorContains(err, tc.expectedErr) + // the contract wasn't previously deployed, so still shouldn't be + _, found := suite.Keeper.GetDeployedCosmosCoinContract(suite.Ctx, allowedDenom) + suite.False(found) + return + } + + // verify success + suite.NoError(err) + suite.Commit() + + initiator := sdk.MustAccAddressFromBech32(tc.msg.Initiator) + receiver := testutil.MustNewInternalEVMAddressFromString(tc.msg.Receiver) + + // initiator no longer has sdk coins + cosmosBalanceAfter := suite.BankKeeper.GetBalance(suite.Ctx, initiator, allowedDenom) + suite.Equal( + sdkmath.NewInt(initialFunding).Sub(tc.amountConverted), + cosmosBalanceAfter.Amount, + "unexpected sdk.Coin balance of initiator", + ) + + // sdk coins are locked into module + moduleBalanceAfter := suite.ModuleBalance(allowedDenom) + suite.Equal( + moduleBalanceBefore.Add(tc.amountConverted), + moduleBalanceAfter, + "unexpected module balance", + ) + + // deployed contract address is registered in module store + contractAddress, found := suite.Keeper.GetDeployedCosmosCoinContract(suite.Ctx, allowedDenom) + suite.True(found, "expected deployed contract address to be registered, found none") + + // receiver has been minted correct number of tokens + erc20Balance, err := suite.Keeper.QueryERC20BalanceOf(suite.Ctx, contractAddress, receiver) + suite.NoError(err) + suite.Equal(tc.amountConverted.BigInt(), erc20Balance, "unexpected erc20 balance for receiver") + }) + } +} + +func (suite *MsgServerSuite) TestConvertCosmosCoinToERC20_AlreadyDeployedContract() { + allowedDenom := "ibc/27394FB092D2ECCD56123C74F36E4C1F926001CEADA9CA97EA622B25F41E5EB2" + initialFunding := int64(1e10) + fundedAccount := app.RandomAddress() + + amount := sdkmath.NewInt(6e8) + receiver1 := types.BytesToInternalEVMAddress(app.RandomAddress().Bytes()) + receiver2 := types.BytesToInternalEVMAddress(app.RandomAddress().Bytes()) + + suite.SetupTest() + + // make the denom allowed for conversion + params := suite.Keeper.GetParams(suite.Ctx) + params.AllowedCosmosDenoms = types.NewAllowedCosmosCoinERC20Tokens( + types.NewAllowedCosmosCoinERC20Token(allowedDenom, "Kava EVM Atom", "ATOM", 6), + ) + suite.Keeper.SetParams(suite.Ctx, params) + + // fund account + err := suite.App.FundAccount(suite.Ctx, fundedAccount, sdk.NewCoins( + sdk.NewInt64Coin(allowedDenom, initialFunding), + )) + suite.NoError(err, "failed to initially fund account") + + // verify contract is not deployed + _, found := suite.Keeper.GetDeployedCosmosCoinContract(suite.Ctx, allowedDenom) + suite.False(found) + + // initial convert deploys contract + msg := types.NewMsgConvertCosmosCoinToERC20( + fundedAccount.String(), + receiver1.Hex(), + sdk.NewCoin(allowedDenom, amount), + ) + _, err = suite.msgServer.ConvertCosmosCoinToERC20(suite.Ctx, &msg) + suite.NoError(err) + + contractAddress, found := suite.Keeper.GetDeployedCosmosCoinContract(suite.Ctx, allowedDenom) + suite.True(found) + + // second convert uses same contract + msg.Receiver = receiver2.Hex() + _, err = suite.msgServer.ConvertCosmosCoinToERC20(suite.Ctx, &msg) + suite.NoError(err) + + after2ndUseAddress, found := suite.Keeper.GetDeployedCosmosCoinContract(suite.Ctx, allowedDenom) + suite.True(found) + suite.Equal(contractAddress, after2ndUseAddress, "contract address should remain the same") + + // check balances + bal1, err := suite.Keeper.QueryERC20BalanceOf(suite.Ctx, contractAddress, receiver1) + suite.NoError(err) + suite.Equal(amount.BigInt(), bal1) + + bal2, err := suite.Keeper.QueryERC20BalanceOf(suite.Ctx, contractAddress, receiver2) + suite.NoError(err) + suite.Equal(amount.BigInt(), bal2) + + // check total supply + caller, key := testutil.RandomEvmAccount() + totalSupply, err := suite.QueryContract( + types.ERC20KavaWrappedCosmosCoinContract.ABI, + caller, + key, + contractAddress, + "totalSupply", + ) + suite.NoError(err) + suite.Len(totalSupply, 1) + suite.Equal(amount.MulRaw(2).BigInt(), totalSupply[0].(*big.Int)) +} diff --git a/x/evmutil/keeper/params.go b/x/evmutil/keeper/params.go index 82eff757..2b13cb1a 100644 --- a/x/evmutil/keeper/params.go +++ b/x/evmutil/keeper/params.go @@ -44,7 +44,7 @@ func (k Keeper) GetEnabledConversionPairFromERC20Address( } } - return types.ConversionPair{}, errorsmod.Wrap(types.ErrConversionNotEnabled, address.String()) + return types.ConversionPair{}, errorsmod.Wrap(types.ErrEVMConversionNotEnabled, address.String()) } // GetEnabledConversionPairFromDenom returns an ConversionPair from the sdk.Coin denom. @@ -59,5 +59,5 @@ func (k Keeper) GetEnabledConversionPairFromDenom( } } - return types.ConversionPair{}, errorsmod.Wrap(types.ErrConversionNotEnabled, denom) + return types.ConversionPair{}, errorsmod.Wrap(types.ErrEVMConversionNotEnabled, denom) } diff --git a/x/evmutil/spec/01_concepts.md b/x/evmutil/spec/01_concepts.md index 79d5e129..00b318b0 100644 --- a/x/evmutil/spec/01_concepts.md +++ b/x/evmutil/spec/01_concepts.md @@ -34,9 +34,33 @@ The swap logic ensures that all `akava` is backed by the equivalent `ukava` bala ## ERC20 token <> sdk.Coin Conversion -`x/evmutil` enables the conversion between ERC20 tokens and sdk.Coins. This done through the use of the `MsgConvertERC20ToCoin` & `MsgConvertCoinToERC20` messages (see **[Messages](03_messages.md)**). +`x/evmutil` facilitates moving assets between Kava's EVM and Cosmos co-chains. This must be handled differently depending on which co-chain to which the asset it native. The messages controlling these flows involve two accounts: +1. The _initiator_ who sends coins from their co-chain +2. The _receiver_ who receives coins on the other co-chain -Only ERC20 contract address that are whitelist via the `EnabledConversionPairs` param (see **[Params](05_params.md)**) can be converted via these messages. +When converting assets from the EVM to the Cosmos co-chain, the initiator is an 0x EVM address and the receiver is a `kava1` Bech32 address. + +When converting assets from the Cosmos co-chain to the EVM, the initiator is a `kava1` Bech32 address and the receiver is an 0x EVM address. + +### Cosmos-Native Assets + +`sdk.Coin`s native to the Cosmos co-chain can be converted to an ERC-20 representing the coin in the EVM. This works by transferring the coin from the initiator to `x/evmutil`'s module account and then minting an ERC-20 token to the receiver. Converting back, the initiator's ERC-20 representation of the coin is burned and the original Cosmos-native asset is transferred to the receiver. + +Cosmos-native asset converstion is done through the use of the `MsgConvertCosmosCoinToERC20` & `MsgConvertCosmosCoinFromERC20` messages (see **[Messages](03_messages.md)**). + +Only Cosmos co-chain denominations that are in the `AllowedCosmosDenoms` param (see **[Params](05_params.md)**) can be converted via these messages. + +`AllowedCosmosDenoms` can be altered through governance. + +### EVM-Native Assets + +ERC-20 tokens native to the EVM can be converted into an `sdk.Coin` in the Cosmos ecosystem. This works by transferring the tokens to `x/evmutil`'s module account and then minting an `sdk.Coin` to the receiver. Converting back is the inverse: the `sdk.Coin` of the initiator is burned and the original ERC-20 tokens that were locked into the module account are transferred back to the receiver. + +EVM-native asset conversion is done through the use of the `MsgConvertERC20ToCoin` & `MsgConvertCoinToERC20` messages (see **[Messages](03_messages.md)**). + +Only ERC20 contract address that are in the `EnabledConversionPairs` param (see **[Params](05_params.md)**) can be converted via these messages. + +`EnabledConversionPairs` can be altered through governance. ## Module Keeper diff --git a/x/evmutil/spec/03_messages.md b/x/evmutil/spec/03_messages.md index cedc0095..0bbd3e9f 100644 --- a/x/evmutil/spec/03_messages.md +++ b/x/evmutil/spec/03_messages.md @@ -6,9 +6,39 @@ order: 3 Users can submit various messages to the evmutil module which trigger state changes detailed below. +## MsgConvertCosmosCoinToERC20 + +`MsgConvertCosmosCoinToERC20` converts an sdk.Coin to an ERC20. This message is for moving Cosmos-native assets from the Cosmos ecosystem to the EVM. + +Upon first conversion, the message also deploys the ERC20 contract that will represent the cosmos-sdk asset in the EVM. The contract is owned by the `x/evmutil` module. + +```proto +service Msg { + // ConvertCosmosCoinToERC20 defines a method for converting a cosmos sdk.Coin to an ERC20. + rpc ConvertCosmosCoinToERC20(MsgConvertCosmosCoinToERC20) returns (MsgConvertCosmosCoinToERC20Response); +} + +// ConvertCosmosCoinToERC20 defines a conversion from cosmos sdk.Coin to ERC20. +message MsgConvertCosmosCoinToERC20 { + // Kava bech32 address initiating the conversion. + string initiator = 1; + // EVM hex address that will receive the ERC20 tokens. + string receiver = 2; + // Amount is the sdk.Coin amount to convert. + cosmos.base.v1beta1.Coin amount = 3; +} +``` + +### State Changes + +- The `AllowedCosmosDenoms` param from `x/evmutil` is checked to ensure the conversion is allowed. +- The module's store is checked for the address of the deployed ERC20 contract. If none is found, a new contract is deployed and its address is saved to the module store. +- The `amount` is deducted from the `initiator`'s balance and transferred to the module account. +- An equivalent amount of ERC20 tokens are minted by `x/evmutil` to the `receiver`. + ## MsgConvertERC20ToCoin -`MsgConvertCoinToERC20` converts a Kava ERC20 coin to sdk.Coin. +`MsgConvertCoinToERC20` converts a Kava ERC20 coin to sdk.Coin. This message is for moving EVM-native assets from the EVM to the Cosmos ecosystem. ```protobuf service Msg { @@ -39,7 +69,7 @@ message MsgConvertERC20ToCoin { ## MsgConvertCoinToERC20 -`MsgConvertCoinToERC20` converts sdk.Coin to Kava ERC20. +`MsgConvertCoinToERC20` converts sdk.Coin to Kava ERC20. This message is for moving EVM-native assets from the Cosmos ecosystem back to the EVM. ```protobuf service Msg { diff --git a/x/evmutil/spec/05_params.md b/x/evmutil/spec/05_params.md index e476f6fd..fb7935bb 100644 --- a/x/evmutil/spec/05_params.md +++ b/x/evmutil/spec/05_params.md @@ -20,12 +20,12 @@ Example parameters for `ConversionPair`: Example parameters for `AllowedCosmosCoinERC20Token`: -| Key | Type | Example | Description | -| ------------ | ------ | ---------------------------------------------------------------------- | -------------------------------------------------- | -| cosmos_denom | string | "ibc/27394FB092D2ECCD56123C74F36E4C1F926001CEADA9CA97EA622B25F41E5EB2" | denom of the sdk.Coin | -| name | string | "Kava-wrapped Atom" | name field of the erc20 token | -| symbol | string | "kATOM" | symbol field of the erc20 token | -| decimal | uint32 | 6 | decimal field of the erc20 token, for display only | +| Key | Type | Example | Description | +| ------------ | ------ | ---------------------------------------------------------------------- | --------------------------------------------------- | +| cosmos_denom | string | "ibc/27394FB092D2ECCD56123C74F36E4C1F926001CEADA9CA97EA622B25F41E5EB2" | denom of the sdk.Coin | +| name | string | "Kava-wrapped Atom" | name field of the erc20 token | +| symbol | string | "kATOM" | symbol field of the erc20 token | +| decimals | uint32 | 6 | decimals field of the erc20 token, for display only | ## EnabledConversionPairs diff --git a/x/evmutil/testutil/suite.go b/x/evmutil/testutil/suite.go index 368c5493..e7838c95 100644 --- a/x/evmutil/testutil/suite.go +++ b/x/evmutil/testutil/suite.go @@ -386,6 +386,11 @@ func (suite *Suite) EventsDoNotContain(events sdk.Events, eventType string) { suite.Falsef(foundMatch, "event of type %s should not be found, but was found", eventType) } +// BigIntsEqual is a helper method for comparing the equality of two big ints +func (suite *Suite) BigIntsEqual(expected *big.Int, actual *big.Int, msg string) { + suite.Truef(expected.Cmp(actual) == 0, "%s (expected: %s, actual: %s)", msg, expected.String(), actual.String()) +} + func attrsToMap(attrs []abci.EventAttribute) []sdk.Attribute { out := []sdk.Attribute{} diff --git a/x/evmutil/types/codec.go b/x/evmutil/types/codec.go index 8ee40e13..2523b83b 100644 --- a/x/evmutil/types/codec.go +++ b/x/evmutil/types/codec.go @@ -16,7 +16,6 @@ func RegisterLegacyAminoCodec(cdc *codec.LegacyAmino) { legacy.RegisterAminoMsg(cdc, &MsgConvertCoinToERC20{}, "evmutil/MsgConvertCoinToERC20") legacy.RegisterAminoMsg(cdc, &MsgConvertERC20ToCoin{}, "evmutil/MsgConvertERC20ToCoin") legacy.RegisterAminoMsg(cdc, &MsgConvertCosmosCoinToERC20{}, "evmutil/MsgConvertCosmosCoinToERC20") - } func RegisterInterfaces(registry cdctypes.InterfaceRegistry) { diff --git a/x/evmutil/types/errors.go b/x/evmutil/types/errors.go index de38302a..ab416562 100644 --- a/x/evmutil/types/errors.go +++ b/x/evmutil/types/errors.go @@ -6,7 +6,7 @@ import errorsmod "cosmossdk.io/errors" var ( ErrABIPack = errorsmod.Register(ModuleName, 2, "contract ABI pack failed") ErrEVMCall = errorsmod.Register(ModuleName, 3, "EVM call unexpected error") - ErrConversionNotEnabled = errorsmod.Register(ModuleName, 4, "ERC20 token not enabled to convert to sdk.Coin") + ErrEVMConversionNotEnabled = 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")