From f4b8bf8f073ebbb6d5125844c915a6070fe96e3c Mon Sep 17 00:00:00 2001 From: Robert Pirtle Date: Fri, 2 Jun 2023 14:26:51 -0700 Subject: [PATCH] feat(evmutil): implement MsgConvertCosmosCoinFromERC20 (#1609) * first pass at convert cosmos coin -> evm msg * test ConvertCosmosCoinFromERC20 method * test message server for MsgConvertCosmosCoinFromERC20 * update spec to include MsgConvertCosmosCoinFromERC20 * update changelog * add CLI command for convert-cosmos-coin-from-erc20 * add test of removed/re-enable denom for convert --- CHANGELOG.md | 2 + docs/core/proto-docs.md | 4 +- proto/kava/evmutil/v1beta1/tx.proto | 4 +- x/evmutil/client/cli/tx.go | 40 ++++ x/evmutil/keeper/conversion_cosmos_native.go | 51 ++++ .../keeper/conversion_cosmos_native_test.go | 138 ++++++++++- x/evmutil/keeper/erc20.go | 21 ++ x/evmutil/keeper/msg_server.go | 33 ++- x/evmutil/keeper/msg_server_test.go | 219 ++++++++++++++++++ x/evmutil/spec/01_concepts.md | 2 + x/evmutil/spec/03_messages.md | 29 ++- x/evmutil/spec/04_events.md | 11 + x/evmutil/types/events.go | 3 +- x/evmutil/types/tx.pb.go | 4 +- 14 files changed, 545 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1651119f..7be02915 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,7 @@ Ref: https://keepachangelog.com/en/1.0.0/ - (evmutil) [#1603] Add MsgConvertCosmosCoinToERC20 for converting an sdk.Coin to an ERC20 in the EVM - (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 ### Client Breaking - (evmutil) [#1603] Renamed error `ErrConversionNotEnabled` to `ErrEVMConversionNotEnabled` @@ -251,6 +252,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 +[#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 [#1603]: https://github.com/Kava-Labs/kava/pull/1603 diff --git a/docs/core/proto-docs.md b/docs/core/proto-docs.md index 46ead3fe..3eca9d59 100644 --- a/docs/core/proto-docs.md +++ b/docs/core/proto-docs.md @@ -3914,7 +3914,7 @@ MsgConvertCoinToERC20Response defines the response value from Msg/ConvertCoinToE ### MsgConvertCosmosCoinFromERC20 -ConvertCosmosCoinFromERC20 defines a conversion from ERC20 to cosmos coins for cosmos-native assets. +MsgConvertCosmosCoinFromERC20 defines a conversion from ERC20 to cosmos coins for cosmos-native assets. | Field | Type | Label | Description | @@ -3941,7 +3941,7 @@ MsgConvertCosmosCoinFromERC20Response defines the response value from Msg/MsgCon ### MsgConvertCosmosCoinToERC20 -ConvertCosmosCoinToERC20 defines a conversion from cosmos sdk.Coin to ERC20 for cosmos-native assets. +MsgConvertCosmosCoinToERC20 defines a conversion from cosmos sdk.Coin to ERC20 for cosmos-native assets. | Field | Type | Label | Description | diff --git a/proto/kava/evmutil/v1beta1/tx.proto b/proto/kava/evmutil/v1beta1/tx.proto index f1449679..93c43f15 100644 --- a/proto/kava/evmutil/v1beta1/tx.proto +++ b/proto/kava/evmutil/v1beta1/tx.proto @@ -57,7 +57,7 @@ message MsgConvertERC20ToCoin { // Msg/MsgConvertERC20ToCoin. message MsgConvertERC20ToCoinResponse {} -// ConvertCosmosCoinToERC20 defines a conversion from cosmos sdk.Coin to ERC20 for cosmos-native assets. +// MsgConvertCosmosCoinToERC20 defines a conversion from cosmos sdk.Coin to ERC20 for cosmos-native assets. message MsgConvertCosmosCoinToERC20 { // Kava bech32 address initiating the conversion. string initiator = 1; @@ -70,7 +70,7 @@ message MsgConvertCosmosCoinToERC20 { // MsgConvertCosmosCoinToERC20Response defines the response value from Msg/MsgConvertCosmosCoinToERC20. message MsgConvertCosmosCoinToERC20Response {} -// ConvertCosmosCoinFromERC20 defines a conversion from ERC20 to cosmos coins for cosmos-native assets. +// MsgConvertCosmosCoinFromERC20 defines a conversion from ERC20 to cosmos coins for cosmos-native assets. message MsgConvertCosmosCoinFromERC20 { // EVM hex address initiating the conversion. string initiator = 1; diff --git a/x/evmutil/client/cli/tx.go b/x/evmutil/client/cli/tx.go index d6d19e3a..598e1494 100644 --- a/x/evmutil/client/cli/tx.go +++ b/x/evmutil/client/cli/tx.go @@ -31,6 +31,7 @@ func GetTxCmd() *cobra.Command { getCmdMsgConvertCoinToERC20(), getCmdConvertERC20ToCoin(), getCmdMsgConvertCosmosCoinToERC20(), + getCmdMsgConvertCosmosCoinFromERC20(), } for _, cmd := range cmds { @@ -159,3 +160,42 @@ func getCmdMsgConvertCosmosCoinToERC20() *cobra.Command { }, } } + +func getCmdMsgConvertCosmosCoinFromERC20() *cobra.Command { + return &cobra.Command{ + Use: "convert-cosmos-coin-from-erc20 [receiver_kava_address] [amount] [flags]", + Short: "converts asset native to Cosmos Co-chain back from an ERC20 on the EVM co-chain", + Example: fmt.Sprintf( + `Convert ERC20 representation of 500 ATOM back to a Cosmos coin, sending to kava1q0dkky0505r555etn6u2nz4h4kjcg5y8dg863a: + %s tx %s convert-cosmos-coin-from-erc20 kava1q0dkky0505r555etn6u2nz4h4kjcg5y8dg863a 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, err := sdk.AccAddressFromBech32(args[0]) + if err != nil { + return fmt.Errorf("receiver '%s' is an invalid kava address", args[0]) + } + + amount, err := sdk.ParseCoinNormalized(args[1]) + if err != nil { + return err + } + + signer := clientCtx.GetFromAddress() + initiator := common.BytesToAddress(signer.Bytes()) + + msg := types.NewMsgConvertCosmosCoinFromERC20(initiator.String(), receiver.String(), 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 index d59bdffd..6c8c813a 100644 --- a/x/evmutil/keeper/conversion_cosmos_native.go +++ b/x/evmutil/keeper/conversion_cosmos_native.go @@ -1,8 +1,11 @@ package keeper import ( + "fmt" + errorsmod "cosmossdk.io/errors" sdk "github.com/cosmos/cosmos-sdk/types" + sdkerrors "github.com/cosmos/cosmos-sdk/types/errors" "github.com/kava-labs/kava/x/evmutil/types" ) @@ -54,3 +57,51 @@ func (k *Keeper) ConvertCosmosCoinToERC20( return nil } + +// ConvertCosmosCoinFromERC20 burns the ERC20 wrapper of the cosmos coin and +// sends the underlying sdk coin form the module account to the receiver. +func (k *Keeper) ConvertCosmosCoinFromERC20( + ctx sdk.Context, + initiator types.InternalEVMAddress, + receiver sdk.AccAddress, + coin sdk.Coin, +) error { + amount := coin.Amount.BigInt() + // get deployed contract + contractAddress, found := k.GetDeployedCosmosCoinContract(ctx, coin.Denom) + if !found { + // no contract deployed + return errorsmod.Wrapf(types.ErrInvalidCosmosDenom, fmt.Sprintf("no erc20 contract found for %s", coin.Denom)) + } + + // verify sufficient balance + balance, err := k.QueryERC20BalanceOf(ctx, contractAddress, initiator) + if err != nil { + return errorsmod.Wrapf(types.ErrEVMCall, "failed to retrieve balance %s", err.Error()) + } + if balance.Cmp(amount) == -1 { + return errorsmod.Wrapf(sdkerrors.ErrInsufficientFunds, "failed to convert to cosmos coins") + } + + // burn initiator's ERC20 tokens + err = k.BurnERC20(ctx, contractAddress, initiator, amount) + if err != nil { + return err + } + + // send sdk coins to receiver, unlocking them from the module account + err = k.bankKeeper.SendCoinsFromModuleToAccount(ctx, types.ModuleName, receiver, sdk.NewCoins(coin)) + if err != nil { + return err + } + + ctx.EventManager().EmitEvent(sdk.NewEvent( + types.EventTypeConvertCosmosCoinFromERC20, + sdk.NewAttribute(types.AttributeKeyInitiator, initiator.String()), + sdk.NewAttribute(types.AttributeKeyReceiver, receiver.String()), + sdk.NewAttribute(types.AttributeKeyERC20Address, contractAddress.Hex()), + sdk.NewAttribute(types.AttributeKeyAmount, coin.String()), + )) + + return nil +} diff --git a/x/evmutil/keeper/conversion_cosmos_native_test.go b/x/evmutil/keeper/conversion_cosmos_native_test.go index 4fded163..15857957 100644 --- a/x/evmutil/keeper/conversion_cosmos_native_test.go +++ b/x/evmutil/keeper/conversion_cosmos_native_test.go @@ -15,29 +15,29 @@ import ( "github.com/kava-labs/kava/x/evmutil/types" ) -type ConversionCosmosNativeSuite struct { +type convertCosmosCoinToERC20Suite struct { testutil.Suite } -func TestConversionCosmosNativeSuite(t *testing.T) { - suite.Run(t, new(ConversionCosmosNativeSuite)) +func TestConversionCosmosNativeToEvmSuite(t *testing.T) { + suite.Run(t, new(convertCosmosCoinToERC20Suite)) } // fail test if contract for denom not registered -func (suite *ConversionCosmosNativeSuite) denomContractRegistered(denom string) types.InternalEVMAddress { +func (suite *convertCosmosCoinToERC20Suite) 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) { +func (suite *convertCosmosCoinToERC20Suite) 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() { +func (suite *convertCosmosCoinToERC20Suite) TestConvertCosmosCoinToERC20() { allowedDenom := "ibc/27394FB092D2ECCD56123C74F36E4C1F926001CEADA9CA97EA622B25F41E5EB2" initialFunding := sdk.NewInt64Coin(allowedDenom, int64(1e10)) initiator := app.RandomAddress() @@ -171,3 +171,129 @@ func (suite *ConversionCosmosNativeSuite) TestConvertCosmosCoinToERC20() { ) }) } + +type convertCosmosCoinFromERC20Suite struct { + testutil.Suite + + denom string + initiator types.InternalEVMAddress + receiver sdk.AccAddress + + contractAddress types.InternalEVMAddress + initialPosition sdk.Coin + + query func(method string, args ...interface{}) ([]interface{}, error) +} + +func (suite *convertCosmosCoinFromERC20Suite) SetupTest() { + var err error + suite.Suite.SetupTest() + + suite.denom = "magic" + suite.initiator = testutil.RandomInternalEVMAddress() + suite.receiver = app.RandomAddress() + + // manually create an initial position - sdk coin locked in module + suite.initialPosition = sdk.NewInt64Coin(suite.denom, 1e12) + err = suite.App.FundModuleAccount(suite.Ctx, types.ModuleName, sdk.NewCoins(suite.initialPosition)) + suite.NoError(err) + + // deploy erc20 contract for the denom + tokenInfo := types.AllowedCosmosCoinERC20Token{ + CosmosDenom: suite.denom, + Name: "Test Token", + Symbol: "MAGIC", + Decimals: 6, + } + suite.contractAddress, err = suite.Keeper.GetOrDeployCosmosCoinERC20Contract(suite.Ctx, tokenInfo) + suite.NoError(err) + + // manually create an initial position - minted tokens + err = suite.Keeper.MintERC20(suite.Ctx, suite.contractAddress, suite.initiator, suite.initialPosition.Amount.BigInt()) + suite.NoError(err) + + caller, key := testutil.RandomEvmAccount() + suite.query = func(method string, args ...interface{}) ([]interface{}, error) { + return suite.QueryContract( + types.ERC20KavaWrappedCosmosCoinContract.ABI, + caller, + key, + suite.contractAddress, + method, + args..., + ) + } +} + +func (suite *convertCosmosCoinFromERC20Suite) checkTotalSupply(expectedSupply sdkmath.Int) { + res, err := suite.query("totalSupply") + suite.NoError(err) + suite.Len(res, 1) + suite.BigIntsEqual(expectedSupply.BigInt(), res[0].(*big.Int), "unexpected total supply") +} + +func (suite *convertCosmosCoinFromERC20Suite) checkBalanceOf(address types.InternalEVMAddress, expectedBalance sdkmath.Int) { + res, err := suite.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)) +} + +func TestConversionCosmosNativeFromEVMSuite(t *testing.T) { + suite.Run(t, new(convertCosmosCoinFromERC20Suite)) +} + +func (suite *convertCosmosCoinFromERC20Suite) TestConvertCosmosCoinFromERC20_NoContractDeployed() { + err := suite.Keeper.ConvertCosmosCoinFromERC20( + suite.Ctx, + suite.initiator, + suite.receiver, + sdk.NewInt64Coin("unsupported-denom", 1e6), + ) + suite.ErrorContains(err, "no erc20 contract found for unsupported-denom") +} + +func (suite *convertCosmosCoinFromERC20Suite) TestConvertCosmosCoinFromERC20() { + // half the initial position + amount := suite.initialPosition.SubAmount(suite.initialPosition.Amount.QuoRaw(2)) + + suite.Run("partial withdraw", func() { + err := suite.Keeper.ConvertCosmosCoinFromERC20( + suite.Ctx, + suite.initiator, + suite.receiver, + amount, + ) + suite.NoError(err) + + suite.checkTotalSupply(amount.Amount) + suite.checkBalanceOf(suite.initiator, amount.Amount) + suite.App.CheckBalance(suite.T(), suite.Ctx, suite.receiver, sdk.NewCoins(amount)) + }) + + suite.Run("full withdraw", func() { + err := suite.Keeper.ConvertCosmosCoinFromERC20( + suite.Ctx, + suite.initiator, + suite.receiver, + amount, + ) + suite.NoError(err) + + // expect no remaining erc20 balance + suite.checkTotalSupply(sdkmath.ZeroInt()) + suite.checkBalanceOf(suite.initiator, sdkmath.ZeroInt()) + // expect full amount withdrawn to receiver + suite.App.CheckBalance(suite.T(), suite.Ctx, suite.receiver, sdk.NewCoins(suite.initialPosition)) + }) + + suite.Run("insufficient balance", func() { + err := suite.Keeper.ConvertCosmosCoinFromERC20( + suite.Ctx, + suite.initiator, + suite.receiver, + amount, + ) + suite.ErrorContains(err, "failed to convert to cosmos coins: insufficient funds") + }) +} diff --git a/x/evmutil/keeper/erc20.go b/x/evmutil/keeper/erc20.go index 884ea968..7c99a084 100644 --- a/x/evmutil/keeper/erc20.go +++ b/x/evmutil/keeper/erc20.go @@ -158,6 +158,27 @@ func (k Keeper) MintERC20( return err } +// BurnERC20 burns the token amount from the initiator's balance. +func (k Keeper) BurnERC20( + ctx sdk.Context, + contractAddr types.InternalEVMAddress, + initiator types.InternalEVMAddress, + amount *big.Int, +) error { + _, err := k.CallEVM( + ctx, + types.ERC20KavaWrappedCosmosCoinContract.ABI, + types.ModuleEVMAddress, + contractAddr, + "burn", + // Burn ERC20 args + initiator.Address, + amount, + ) + + return err +} + func (k Keeper) QueryERC20BalanceOf( ctx sdk.Context, contractAddr types.InternalEVMAddress, diff --git a/x/evmutil/keeper/msg_server.go b/x/evmutil/keeper/msg_server.go index c5adc9d9..6d843500 100644 --- a/x/evmutil/keeper/msg_server.go +++ b/x/evmutil/keeper/msg_server.go @@ -154,7 +154,36 @@ func (s msgServer) ConvertCosmosCoinToERC20( // back into an sdk.Coin. func (s msgServer) ConvertCosmosCoinFromERC20( goCtx context.Context, - req *types.MsgConvertCosmosCoinFromERC20, + msg *types.MsgConvertCosmosCoinFromERC20, ) (*types.MsgConvertCosmosCoinFromERC20Response, error) { - panic("unimplemented - coming soon!") + ctx := sdk.UnwrapSDKContext(goCtx) + + initiator, err := types.NewInternalEVMAddressFromString(msg.Initiator) + if err != nil { + return nil, fmt.Errorf("invalid initiator address: %w", err) + } + + receiver, err := sdk.AccAddressFromBech32(msg.Receiver) + if err != nil { + return nil, fmt.Errorf("invalid receiver address: %w", err) + } + + if err := s.keeper.ConvertCosmosCoinFromERC20( + ctx, + initiator, + receiver, + *msg.Amount, + ); err != nil { + return nil, err + } + + ctx.EventManager().EmitEvent( + sdk.NewEvent( + sdk.EventTypeMessage, + sdk.NewAttribute(sdk.AttributeKeyModule, types.AttributeValueCategory), + sdk.NewAttribute(sdk.AttributeKeySender, msg.Initiator), + ), + ) + + return &types.MsgConvertCosmosCoinFromERC20Response{}, nil } diff --git a/x/evmutil/keeper/msg_server_test.go b/x/evmutil/keeper/msg_server_test.go index c13f73e3..89c7c04a 100644 --- a/x/evmutil/keeper/msg_server_test.go +++ b/x/evmutil/keeper/msg_server_test.go @@ -509,3 +509,222 @@ func (suite *MsgServerSuite) TestConvertCosmosCoinToERC20_AlreadyDeployedContrac suite.Len(totalSupply, 1) suite.Equal(amount.MulRaw(2).BigInt(), totalSupply[0].(*big.Int)) } + +func (suite *MsgServerSuite) TestConvertCosmosCoinFromERC20() { + denom := "magic" + tokenInfo := types.NewAllowedCosmosCoinERC20Token(denom, "Cosmos Coin", "MAGIC", 6) + initialPosition := sdk.NewInt64Coin(denom, 1e10) + initiator := testutil.RandomInternalEVMAddress() + + var contractAddress types.InternalEVMAddress + setup := func() { + suite.SetupTest() + + // allow conversion to the denom + params := suite.Keeper.GetParams(suite.Ctx) + params.AllowedCosmosDenoms = append(params.AllowedCosmosDenoms, tokenInfo) + suite.Keeper.SetParams(suite.Ctx, params) + + // setup initial position + addr := app.RandomAddress() + err := suite.App.FundAccount(suite.Ctx, addr, sdk.NewCoins(initialPosition)) + suite.NoError(err) + err = suite.Keeper.ConvertCosmosCoinToERC20(suite.Ctx, addr, initiator, initialPosition) + suite.NoError(err) + + contractAddress, _ = suite.Keeper.GetDeployedCosmosCoinContract(suite.Ctx, denom) + } + + testCases := []struct { + name string + msg types.MsgConvertCosmosCoinFromERC20 + amountConverted sdkmath.Int + expectedErr string + }{ + { + name: "valid - full convert", + msg: types.NewMsgConvertCosmosCoinFromERC20( + initiator.Hex(), + app.RandomAddress().String(), + initialPosition, + ), + amountConverted: initialPosition.Amount, + expectedErr: "", + }, + { + name: "valid - partial convert", + msg: types.NewMsgConvertCosmosCoinFromERC20( + initiator.Hex(), + app.RandomAddress().String(), + sdk.NewInt64Coin(denom, 123456), + ), + amountConverted: sdkmath.NewInt(123456), + expectedErr: "", + }, + { + name: "invalid - bad initiator", + msg: types.NewMsgConvertCosmosCoinFromERC20( + "invalid-address", + app.RandomAddress().String(), + sdk.NewInt64Coin(denom, 123456), + ), + amountConverted: sdkmath.ZeroInt(), + expectedErr: "invalid initiator address", + }, + { + name: "invalid - bad receiver", + msg: types.NewMsgConvertCosmosCoinFromERC20( + testutil.RandomEvmAddress().Hex(), + "invalid-address", + sdk.NewInt64Coin(denom, 123456), + ), + amountConverted: sdkmath.ZeroInt(), + expectedErr: "invalid receiver address", + }, + { + name: "invalid - unsupported asset", + msg: types.NewMsgConvertCosmosCoinFromERC20( + initiator.Hex(), + app.RandomAddress().String(), + sdk.NewInt64Coin("not-supported", 123456), + ), + amountConverted: sdkmath.ZeroInt(), + expectedErr: "no erc20 contract found", + }, + { + name: "invalid - insufficient funds", + msg: types.NewMsgConvertCosmosCoinFromERC20( + initiator.Hex(), + app.RandomAddress().String(), + initialPosition.AddAmount(sdkmath.OneInt()), + ), + amountConverted: sdkmath.ZeroInt(), + expectedErr: "failed to convert to cosmos coins: insufficient funds", + }, + } + + for _, tc := range testCases { + suite.Run(tc.name, func() { + setup() + + _, err := suite.msgServer.ConvertCosmosCoinFromERC20(suite.Ctx, &tc.msg) + + if tc.expectedErr != "" { + suite.ErrorContains(err, tc.expectedErr) + // expect no change in erc20 balance + balance, err := suite.Keeper.QueryERC20BalanceOf(suite.Ctx, contractAddress, initiator) + suite.NoError(err) + suite.BigIntsEqual(initialPosition.Amount.BigInt(), balance, "expected no change in initiator's erc20 balance") + // expect no change in module balance + suite.Equal(initialPosition.Amount, suite.ModuleBalance(denom), "expected no change in module balance") + return + } + + suite.NoError(err) + + receiver := sdk.MustAccAddressFromBech32(tc.msg.Receiver) + // expect receiver to have the sdk coins + sdkBalance := suite.BankKeeper.GetBalance(suite.Ctx, receiver, denom) + suite.Equal(tc.amountConverted, sdkBalance.Amount) + + newEvmBalance := initialPosition.SubAmount(tc.amountConverted) + // expect initiator to have the balance deducted + evmBalance, err := suite.Keeper.QueryERC20BalanceOf(suite.Ctx, contractAddress, initiator) + suite.NoError(err) + suite.BigIntsEqual(newEvmBalance.Amount.BigInt(), evmBalance, "unexpected initiator final erc20 balance") + + // expect tokens to be deducted from module account + suite.True(newEvmBalance.Amount.Equal(suite.ModuleBalance(denom)), "unexpected module balance") + + // expect erc20 total supply to reflect new value + caller, key := testutil.RandomEvmAccount() + totalSupply, err := suite.QueryContract( + types.ERC20KavaWrappedCosmosCoinContract.ABI, + caller, + key, + contractAddress, + "totalSupply", + ) + suite.NoError(err) + suite.BigIntsEqual(newEvmBalance.Amount.BigInt(), totalSupply[0].(*big.Int), "unexpected total supply") + }) + } +} + +// the test verifies the behavior for when a denom is removed from the params list +// after conversions have been made: +// - it should prevent more conversions from sdk -> evm for that denom +// - existing erc20s should be allowed to get converted back to a sdk.Coins +// - allowing the denom again should use existing contract +func (suite *MsgServerSuite) TestConvertCosmosCoinForRemovedDenom() { + denom := "magic" + tokenInfo := types.NewAllowedCosmosCoinERC20Token(denom, "MAGIC COIN", "MAGIC", 6) + account := app.RandomAddress() + evmAddr := types.BytesToInternalEVMAddress(account.Bytes()) + coin := func(amt int64) sdk.Coin { return sdk.NewInt64Coin(denom, amt) } + + // fund account + suite.NoError(suite.App.FundAccount(suite.Ctx, account, sdk.NewCoins(coin(1e10)))) + + // setup the token as allowed + params := suite.Keeper.GetParams(suite.Ctx) + params.AllowedCosmosDenoms = append(params.AllowedCosmosDenoms, tokenInfo) + suite.Keeper.SetParams(suite.Ctx, params) + + // convert some coins while its allowed + msg := types.NewMsgConvertCosmosCoinToERC20(account.String(), evmAddr.Hex(), coin(5e9)) + _, err := suite.msgServer.ConvertCosmosCoinToERC20(suite.Ctx, &msg) + suite.NoError(err) + + // expect contract registered + contractAddress, isRegistered := suite.Keeper.GetDeployedCosmosCoinContract(suite.Ctx, denom) + suite.True(isRegistered) + suite.False(contractAddress.IsNil()) + + // unregister contract + params.AllowedCosmosDenoms = []types.AllowedCosmosCoinERC20Token{} + suite.Keeper.SetParams(suite.Ctx, params) + + suite.Run("disallows sdk -> evm when removed", func() { + msg := types.NewMsgConvertCosmosCoinToERC20(account.String(), evmAddr.Hex(), coin(5e9)) + _, err := suite.msgServer.ConvertCosmosCoinToERC20(suite.Ctx, &msg) + suite.ErrorContains(err, "sdk.Coin not enabled to convert to ERC20 token") + }) + + suite.Run("allows conversion of existing ERC20s", func() { + msg := types.NewMsgConvertCosmosCoinFromERC20(evmAddr.Hex(), account.String(), coin(5e9)) + _, err := suite.msgServer.ConvertCosmosCoinFromERC20(suite.Ctx, &msg) + suite.NoError(err) + + // should be fully withdrawn + erc20Bal, err := suite.Keeper.QueryERC20BalanceOf(suite.Ctx, contractAddress, evmAddr) + suite.NoError(err) + suite.BigIntsEqual(big.NewInt(0), erc20Bal, "cosmos coins were not converted back") + sdkBal := suite.BankKeeper.GetBalance(suite.Ctx, account, denom) + suite.Equal(coin(1e10), sdkBal) + }) + + suite.Run("contract stays registered", func() { + postDisableContractAddress, found := suite.Keeper.GetDeployedCosmosCoinContract(suite.Ctx, denom) + suite.True(found) + suite.Equal(contractAddress, postDisableContractAddress) + }) + + suite.Run("re-enable uses original contract", func() { + // re-enable contract + params.AllowedCosmosDenoms = append(params.AllowedCosmosDenoms, tokenInfo) + suite.Keeper.SetParams(suite.Ctx, params) + + // attempt conversion + msg := types.NewMsgConvertCosmosCoinToERC20(account.String(), evmAddr.Hex(), coin(1e10)) + _, err := suite.msgServer.ConvertCosmosCoinToERC20(suite.Ctx, &msg) + suite.NoError(err) + + // should have balance on original ERC20 contract + erc20Bal, err := suite.Keeper.QueryERC20BalanceOf(suite.Ctx, contractAddress, evmAddr) + suite.NoError(err) + suite.BigIntsEqual(big.NewInt(1e10), erc20Bal, "cosmos coins were not converted") + sdkBal := suite.BankKeeper.GetBalance(suite.Ctx, account, denom) + suite.True(sdkBal.IsZero()) + }) +} diff --git a/x/evmutil/spec/01_concepts.md b/x/evmutil/spec/01_concepts.md index af2cf724..7e3f9c82 100644 --- a/x/evmutil/spec/01_concepts.md +++ b/x/evmutil/spec/01_concepts.md @@ -54,6 +54,8 @@ Only Cosmos co-chain denominations that are in the `AllowedCosmosDenoms` param ( The ERC20 contracts are deployed and managed by x/evmutil. The contract is deployed on first convert of the coin. Once deployed, the addresses of the contracts can be queried via the `DeployedCosmosCoinContracts` query (`deployed_cosmos_coin_contracts` endpoint). +If a denom is removed from the `AllowedCosmosDenoms` param, existing ERC20 tokens can be converted back to the underlying sdk.Coin via `MsgConvertCosmosCoinFromERC20`, but no conversions from sdk.Coin -> ERC via `MsgConvertCosmosCoinToERC20` are allowed. + ### 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. diff --git a/x/evmutil/spec/03_messages.md b/x/evmutil/spec/03_messages.md index 0bbd3e9f..c0307ebc 100644 --- a/x/evmutil/spec/03_messages.md +++ b/x/evmutil/spec/03_messages.md @@ -18,7 +18,7 @@ service Msg { rpc ConvertCosmosCoinToERC20(MsgConvertCosmosCoinToERC20) returns (MsgConvertCosmosCoinToERC20Response); } -// ConvertCosmosCoinToERC20 defines a conversion from cosmos sdk.Coin to ERC20. +// MsgConvertCosmosCoinToERC20 defines a conversion from cosmos sdk.Coin to ERC20. message MsgConvertCosmosCoinToERC20 { // Kava bech32 address initiating the conversion. string initiator = 1; @@ -36,6 +36,33 @@ message MsgConvertCosmosCoinToERC20 { - 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`. +## MsgConvertCosmosCoinFromERC20 + +`MsgConvertCosmosCoinFromERC20` is the inverse of `MsgConvertCosmosCoinToERC20`. It converts an ERC20 representation of a cosmos-sdk coin back to its underlying sdk.Coin. + + +```proto +service Msg { + // ConvertCosmosCoinFromERC20 defines a method for converting a cosmos sdk.Coin to an ERC20. + rpc ConvertCosmosCoinFromERC20(MsgConvertCosmosCoinFromERC20) returns (MsgConvertCosmosCoinFromERC20Response); +} + +// MsgConvertCosmosCoinFromERC20 defines a conversion from ERC20 to cosmos coins for cosmos-native assets. +message MsgConvertCosmosCoinFromERC20 { + // EVM hex address initiating the conversion. + string initiator = 1; + // Kava bech32 address that will receive the cosmos coins. + string receiver = 2; + // Amount is the amount to convert, expressed as a Cosmos coin. + cosmos.base.v1beta1.Coin amount = 3; +} +``` + +### State Changes + +- The `amount` is transferred from the `x/evmutil` module account to the `receiver`. +- The same amount of the corresponding ERC20 is burned from the `initiator` account in the EVM. + ## MsgConvertERC20ToCoin `MsgConvertCoinToERC20` converts a Kava ERC20 coin to sdk.Coin. This message is for moving EVM-native assets from the EVM to the Cosmos ecosystem. diff --git a/x/evmutil/spec/04_events.md b/x/evmutil/spec/04_events.md index 504648b8..116fd733 100644 --- a/x/evmutil/spec/04_events.md +++ b/x/evmutil/spec/04_events.md @@ -40,3 +40,14 @@ The evmutil module emits the following events: | convert_cosmos_coin_to_erc20 | amount | `{amount}` | | message | module | evmutil | | message | sender | {'sender address'} | + +### MsgConvertCosmosCoinFromERC20 + +| Type | Attribute Key | Attribute Value | +| ------------------------------ | ------------- | ------------------ | +| convert_cosmos_coin_from_erc20 | initiator | `{initiator}` | +| convert_cosmos_coin_from_erc20 | receiver | `{receiver}` | +| convert_cosmos_coin_from_erc20 | erc20_address | `{erc20_address}` | +| convert_cosmos_coin_from_erc20 | amount | `{amount}` | +| message | module | evmutil | +| message | sender | {'sender address'} | diff --git a/x/evmutil/types/events.go b/x/evmutil/types/events.go index a9b48d20..0db8c402 100644 --- a/x/evmutil/types/events.go +++ b/x/evmutil/types/events.go @@ -8,7 +8,8 @@ const ( EventTypeConvertERC20ToCoin = "convert_evm_erc20_to_coin" EventTypeConvertCoinToERC20 = "convert_evm_erc20_from_coin" - EventTypeConvertCosmosCoinToERC20 = "convert_cosmos_coin_to_erc20" + EventTypeConvertCosmosCoinToERC20 = "convert_cosmos_coin_to_erc20" + EventTypeConvertCosmosCoinFromERC20 = "convert_cosmos_coin_from_erc20" // Event Attributes - Common AttributeKeyReceiver = "receiver" diff --git a/x/evmutil/types/tx.pb.go b/x/evmutil/types/tx.pb.go index 42d0a573..1d63fcfc 100644 --- a/x/evmutil/types/tx.pb.go +++ b/x/evmutil/types/tx.pb.go @@ -236,7 +236,7 @@ func (m *MsgConvertERC20ToCoinResponse) XXX_DiscardUnknown() { var xxx_messageInfo_MsgConvertERC20ToCoinResponse proto.InternalMessageInfo -// ConvertCosmosCoinToERC20 defines a conversion from cosmos sdk.Coin to ERC20 for cosmos-native assets. +// MsgConvertCosmosCoinToERC20 defines a conversion from cosmos sdk.Coin to ERC20 for cosmos-native assets. type MsgConvertCosmosCoinToERC20 struct { // Kava bech32 address initiating the conversion. Initiator string `protobuf:"bytes,1,opt,name=initiator,proto3" json:"initiator,omitempty"` @@ -337,7 +337,7 @@ func (m *MsgConvertCosmosCoinToERC20Response) XXX_DiscardUnknown() { var xxx_messageInfo_MsgConvertCosmosCoinToERC20Response proto.InternalMessageInfo -// ConvertCosmosCoinFromERC20 defines a conversion from ERC20 to cosmos coins for cosmos-native assets. +// MsgConvertCosmosCoinFromERC20 defines a conversion from ERC20 to cosmos coins for cosmos-native assets. type MsgConvertCosmosCoinFromERC20 struct { // EVM hex address initiating the conversion. Initiator string `protobuf:"bytes,1,opt,name=initiator,proto3" json:"initiator,omitempty"`