From 3afb656d1f86d75cba34c7c11d7b2c195bd00f4a Mon Sep 17 00:00:00 2001 From: Draco Date: Mon, 25 Mar 2024 13:43:31 -0400 Subject: [PATCH] Implement bep3 evm native conversion logic (#1848) * Implement bep3 evm native conversion logic * Update changelog * Fix indentation * Add bep3 conversion keeper tests * make DefaultBEP3ConversionDenoms private * refactor bep3 conversion * update bep3 tests to cover all bep3 assets * minor refactor --- CHANGELOG.md | 2 + x/evmutil/keeper/conversion_evm_native.go | 24 +- .../keeper/conversion_evm_native_bep3.go | 55 +++ .../keeper/conversion_evm_native_bep3_test.go | 415 ++++++++++++++++++ x/evmutil/types/errors.go | 15 +- 5 files changed, 501 insertions(+), 10 deletions(-) create mode 100644 x/evmutil/keeper/conversion_evm_native_bep3.go create mode 100644 x/evmutil/keeper/conversion_evm_native_bep3_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index e5419e96..b7d6ee48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,6 +45,7 @@ Ref: https://keepachangelog.com/en/1.0.0/ - (validator-vesting) [#1832] Add grpc query service to replace removed legacy querier - (incentive) [#1836] Update x/incentive cli to use grpc query client - (ibc) [#1839] Add ibc packet forward middleware for ibc transfers +- (evmutil) [#1848] Update evm native conversion logic to handle bep3 assets ## [v0.25.0] @@ -328,6 +329,7 @@ the [changelog](https://github.com/cosmos/cosmos-sdk/blob/v0.38.4/CHANGELOG.md). large-scale simulations remotely using aws-batch [#1846]: https://github.com/Kava-Labs/kava/pull/1846 +[#1848]: https://github.com/Kava-Labs/kava/pull/1848 [#1839]: https://github.com/Kava-Labs/kava/pull/1839 [#1836]: https://github.com/Kava-Labs/kava/pull/1836 [#1832]: https://github.com/Kava-Labs/kava/pull/1832 diff --git a/x/evmutil/keeper/conversion_evm_native.go b/x/evmutil/keeper/conversion_evm_native.go index 9765beaa..69a97de2 100644 --- a/x/evmutil/keeper/conversion_evm_native.go +++ b/x/evmutil/keeper/conversion_evm_native.go @@ -71,7 +71,15 @@ func (k Keeper) ConvertCoinToERC20( return err } - if err := k.UnlockERC20Tokens(ctx, pair, coin.Amount.BigInt(), receiverAccount); err != nil { + // handle bep3 conversion pair (8 decimals sdk.Coin -> 18 decimals erc20) + // when converting bep3 bank coins to erc20, we will need to unlock the 18 + // decimals erc20 equivalent of the 8 decimals bep3 sdk.Coins + amountToUnlock := coin.Amount.BigInt() + if isBep3Asset(pair.Denom) { + amountToUnlock = convertBep3CoinAmountToERC20Amount(coin.Amount.BigInt()) + } + + if err := k.UnlockERC20Tokens(ctx, pair, amountToUnlock, receiverAccount); err != nil { return err } @@ -102,13 +110,23 @@ func (k Keeper) ConvertERC20ToCoin( return err } + amountToLock := amount.BigInt() + amountToMint := amount.BigInt() + + if isBep3Asset(pair.Denom) { + amountToMint, amountToLock, err = bep3ERC20AmountToCoinMintAndERC20LockAmount(amount.BigInt()) + if err != nil { + return err + } + } + // lock erc20 tokens - if err := k.LockERC20Tokens(ctx, pair, amount.BigInt(), initiator); err != nil { + if err := k.LockERC20Tokens(ctx, pair, amountToLock, initiator); err != nil { return err } // mint conversion pair coin - coin, err := k.MintConversionPairCoin(ctx, pair, amount.BigInt(), receiver) + coin, err := k.MintConversionPairCoin(ctx, pair, amountToMint, receiver) if err != nil { return err } diff --git a/x/evmutil/keeper/conversion_evm_native_bep3.go b/x/evmutil/keeper/conversion_evm_native_bep3.go new file mode 100644 index 00000000..b727996d --- /dev/null +++ b/x/evmutil/keeper/conversion_evm_native_bep3.go @@ -0,0 +1,55 @@ +package keeper + +import ( + "math/big" + + errorsmod "cosmossdk.io/errors" + "github.com/kava-labs/kava/x/evmutil/types" +) + +var ( + bep3Denoms = map[string]bool{ + "bnb": true, + "busd": true, + "btcb": true, + "xrpb": true, + } + + bep3ConversionFactor = new(big.Int).Exp(big.NewInt(10), big.NewInt(10), nil) +) + +func isBep3Asset(denom string) bool { + return bep3Denoms[denom] +} + +// convertBep3CoinAmountToERC20Amount converts a bep3 coin amount with 8 decimals +// to the equivalent ERC20 token with 18 decimals. +func convertBep3CoinAmountToERC20Amount(amount *big.Int) (erc20Amount *big.Int) { + return new(big.Int).Mul(amount, bep3ConversionFactor) +} + +// convertBep3ERC20AmountToCoinAmount converts a bep3 ERC20 token with 18 decimals +// to the equivalent coin amount with 8 decimals, and dropping the remainder. +func convertBep3ERC20AmountToCoinAmount(amount *big.Int) (coinAmount *big.Int) { + return new(big.Int).Div(amount, bep3ConversionFactor) +} + +// bep3ERC20AmountToCoinMintAndERC20LockAmount converts 18 decimals erc20 bep3 +// amount to the equivalent 8 decimals coin amount to mint, and the +// 18 decimals erc20 bep3 amount to lock for the converted coin amount. +func bep3ERC20AmountToCoinMintAndERC20LockAmount(amount *big.Int) ( + amountToMint *big.Int, amountToLock *big.Int, err error, +) { + amountToMint = convertBep3ERC20AmountToCoinAmount(amount) + + // make sure we have at least 1 sdk.Coin to mint + if amountToMint.Cmp(big.NewInt(0)) == 0 { + err := errorsmod.Wrapf( + types.ErrInsufficientConversionAmount, + "unable to convert bep3 coin due converting less than 1 native unit", + ) + return nil, nil, err + } + amountToLock = convertBep3CoinAmountToERC20Amount(amountToMint) + return amountToMint, amountToLock, nil +} diff --git a/x/evmutil/keeper/conversion_evm_native_bep3_test.go b/x/evmutil/keeper/conversion_evm_native_bep3_test.go new file mode 100644 index 00000000..c2d06128 --- /dev/null +++ b/x/evmutil/keeper/conversion_evm_native_bep3_test.go @@ -0,0 +1,415 @@ +package keeper_test + +import ( + "fmt" + "testing" + + sdkmath "cosmossdk.io/math" + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/stretchr/testify/suite" + + "github.com/kava-labs/kava/x/evmutil/testutil" + "github.com/kava-labs/kava/x/evmutil/types" +) + +type Bep3ConversionTestSuite struct { + testutil.Suite +} + +var ( + bep3Denoms = []string{"bnb", "busd", "btcb", "xrpb"} +) + +func TestBep3ConversionTestSuite(t *testing.T) { + suite.Run(t, new(Bep3ConversionTestSuite)) +} + +func (suite *Bep3ConversionTestSuite) TestConvertCoinToERC20_Bep3() { + for _, denom := range bep3Denoms { + suite.testConvertBep3CoinToERC20(denom) + } +} + +func (suite *Bep3ConversionTestSuite) TestConvertERC20ToCoin_Bep3() { + for _, denom := range bep3Denoms { + suite.testConvertBep3ERC20ToCoin(denom) + } +} + +func (suite *Bep3ConversionTestSuite) setEnabledConversionPairDenom(denom string) { + params := suite.Keeper.GetParams(suite.Ctx) + params.EnabledConversionPairs[0].Denom = denom + suite.Keeper.SetParams(suite.Ctx, params) +} + +func (suite *Bep3ConversionTestSuite) testConvertBep3CoinToERC20(denom string) { + invoker, err := sdk.AccAddressFromBech32("kava123fxg0l602etulhhcdm0vt7l57qya5wjcrwhzz") + receiverAddr := testutil.MustNewInternalEVMAddressFromString("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2") + suite.Require().NoError(err) + + type errArgs struct { + expectPass bool + contains string + } + + tests := []struct { + name string + userBankBalance sdkmath.Int + expUserErc20Balance sdkmath.Int + moduleErc20Balance sdkmath.Int + expModuleErc20Balance sdkmath.Int + disablePair bool + conversionDenom string + coinToConvert sdk.Coin + errArgs errArgs + }{ + { + name: "success", + userBankBalance: sdkmath.NewInt(1_234), + moduleErc20Balance: sdkmath.NewInt(10e13), + expUserErc20Balance: sdkmath.NewInt(1.234e13), + expModuleErc20Balance: sdkmath.NewInt(8.766e13), + coinToConvert: sdk.NewCoin(denom, sdkmath.NewInt(1_234)), + errArgs: errArgs{ + expectPass: true, + }, + }, + { + name: "success - convert smallest unit", + userBankBalance: sdkmath.NewInt(2), + moduleErc20Balance: sdkmath.NewInt(10e13), + expUserErc20Balance: sdkmath.NewInt(1e10), + expModuleErc20Balance: sdkmath.NewInt(9.999e13), + coinToConvert: sdk.NewCoin(denom, sdkmath.NewInt(1)), + errArgs: errArgs{ + expectPass: true, + }, + }, + { + name: "success - no change when 0 amount converted", + userBankBalance: sdkmath.NewInt(1234), + moduleErc20Balance: sdkmath.NewInt(1e14), + expUserErc20Balance: sdkmath.ZeroInt(), + expModuleErc20Balance: sdkmath.NewInt(1e14), + coinToConvert: sdk.NewCoin(denom, sdkmath.NewInt(0)), + errArgs: errArgs{ + expectPass: true, + }, + }, + { + name: "error - bep3 not enabled", + userBankBalance: sdkmath.NewInt(1_234), + moduleErc20Balance: sdkmath.NewInt(1e14), + disablePair: true, + coinToConvert: sdk.NewCoin(denom, sdkmath.NewInt(1_234)), + errArgs: errArgs{ + expectPass: false, + contains: fmt.Sprintf("%s: ERC20 token not enabled to convert to sdk.Coin", denom), + }, + }, + { + name: "error - module account does not have enough balance to unlock", + userBankBalance: sdkmath.NewInt(1_234), + moduleErc20Balance: sdkmath.NewInt(1e9), + coinToConvert: sdk.NewCoin(denom, sdkmath.NewInt(1)), + errArgs: errArgs{ + expectPass: false, + contains: "execution reverted: ERC20: transfer amount exceeds balance", + }, + }, + + { + name: "success - not bep3 conversion", + conversionDenom: "hard", + userBankBalance: sdkmath.NewInt(1_234), + moduleErc20Balance: sdkmath.NewInt(2_000), + expUserErc20Balance: sdkmath.NewInt(1_234), + expModuleErc20Balance: sdkmath.NewInt(766), + coinToConvert: sdk.NewCoin("hard", sdkmath.NewInt(1_234)), + errArgs: errArgs{ + expectPass: true, + }, + }, + { + name: "error - user converting more than user balance", + userBankBalance: sdkmath.NewInt(1_234), + moduleErc20Balance: sdkmath.NewInt(1e14), + coinToConvert: sdk.NewCoin(denom, sdkmath.NewInt(20_000)), + errArgs: errArgs{ + expectPass: false, + contains: fmt.Sprintf("spendable balance 1234%s is smaller than 20000%s: insufficient funds", denom, denom), + }, + }, + } + + for _, tc := range tests { + suite.Run(fmt.Sprintf("%s: %s", denom, tc.name), func() { + suite.SetupTest() + contractAddr := suite.DeployERC20() + + // override conversion denom if needed + conversionDenom := tc.conversionDenom + if conversionDenom == "" { + conversionDenom = denom + } + + if !tc.disablePair { + suite.setEnabledConversionPairDenom(conversionDenom) + } + + pair := types.NewConversionPair( + contractAddr, + conversionDenom, + ) + + // fund user & module account + if tc.userBankBalance.GT(sdkmath.ZeroInt()) { + err = suite.App.FundAccount( + suite.Ctx, + invoker, + sdk.NewCoins(sdk.NewCoin(conversionDenom, tc.userBankBalance)), + ) + suite.Require().NoError(err) + } + if tc.moduleErc20Balance.GT(sdkmath.ZeroInt()) { + err := suite.Keeper.MintERC20( + suite.Ctx, + pair.GetAddress(), // contractAddr + types.NewInternalEVMAddress(types.ModuleEVMAddress), //receiver + tc.moduleErc20Balance.BigInt(), + ) + suite.Require().NoError(err) + } + + // execute bep3 conversion + err := suite.Keeper.ConvertCoinToERC20(suite.Ctx, invoker, receiverAddr, tc.coinToConvert) + + if tc.errArgs.expectPass { + suite.Require().NoError(err) + + // validate user balance + bal := suite.GetERC20BalanceOf( + types.ERC20MintableBurnableContract.ABI, + pair.GetAddress(), + receiverAddr, + ) + suite.Require().Equal( + tc.expUserErc20Balance.BigInt().Int64(), + bal.Int64(), + "user erc20 balance should match expected amount", + ) + + // validate module balance + bal = suite.GetERC20BalanceOf( + types.ERC20MintableBurnableContract.ABI, + pair.GetAddress(), + types.NewInternalEVMAddress(types.ModuleEVMAddress), + ) + suite.Require().Equal( + tc.expModuleErc20Balance.BigInt().Int64(), + bal.Int64(), + "module erc20 balance should match expected amount", + ) + + // keeper event + suite.EventsContains(suite.GetEvents(), + sdk.NewEvent( + types.EventTypeConvertCoinToERC20, + sdk.NewAttribute(types.AttributeKeyInitiator, invoker.String()), + sdk.NewAttribute(types.AttributeKeyReceiver, receiverAddr.String()), + sdk.NewAttribute(types.AttributeKeyERC20Address, pair.GetAddress().String()), + sdk.NewAttribute(types.AttributeKeyAmount, tc.coinToConvert.String()), + )) + } else { + suite.Require().Error(err) + suite.Require().Contains(err.Error(), tc.errArgs.contains) + } + }) + } +} + +func (suite *Bep3ConversionTestSuite) testConvertBep3ERC20ToCoin(denom string) { + invoker := testutil.MustNewInternalEVMAddressFromString("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2") + invokerCosmosAddr, err := sdk.AccAddressFromHexUnsafe(invoker.String()[2:]) + suite.Require().NoError(err) + + type errArgs struct { + expectPass bool + contains string + } + + tests := []struct { + name string + contractAddr string + conversionDenom string + disablePair bool + userErc20Balance sdkmath.Int + expUserBankBalance sdkmath.Int + expUserErc20Balance sdkmath.Int + convertAmount sdkmath.Int + errArgs errArgs + }{ + { + name: "success - conversion with no dust", + userErc20Balance: sdkmath.NewInt(1.12e18), + expUserBankBalance: sdkmath.NewInt(1.0031e8), + expUserErc20Balance: sdkmath.NewInt(0.1169e18), + convertAmount: sdkmath.NewInt(1.0031e18), + errArgs: errArgs{ + expectPass: true, + }, + }, + { + name: "success - convert smallest bank unit", + userErc20Balance: sdkmath.NewInt(2e18), + expUserBankBalance: sdkmath.NewInt(1), + expUserErc20Balance: sdkmath.NewInt(1.99999999e18), + convertAmount: sdkmath.NewInt(1.12e10), + errArgs: errArgs{ + expectPass: true, + }, + }, + { + name: "success - bnb conversion with dust", + userErc20Balance: sdkmath.NewInt(2e18), + expUserBankBalance: sdkmath.NewInt(12), + expUserErc20Balance: sdkmath.NewInt(1.99999988e18), + convertAmount: sdkmath.NewInt(12.123e10), + errArgs: errArgs{ + expectPass: true, + }, + }, + { + name: "fail - converting less than 1 bank unit", + userErc20Balance: sdkmath.NewInt(2e18), + convertAmount: sdkmath.NewInt(12e8), + errArgs: errArgs{ + expectPass: false, + contains: "unable to convert bep3 coin due converting less than 1 native unit", + }, + }, + { + name: "fail - contract not enabled", + disablePair: true, + contractAddr: "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", + userErc20Balance: sdkmath.NewInt(2e18), + convertAmount: sdkmath.NewInt(2e18), + errArgs: errArgs{ + expectPass: false, + contains: "ERC20 token not enabled to convert", + }, + }, + { + name: "fail - converting 0 amount of bep3 erc20 token", + userErc20Balance: sdkmath.NewInt(2e18), + convertAmount: sdkmath.NewInt(0), + errArgs: errArgs{ + expectPass: false, + contains: "unable to convert bep3 coin due converting less than 1 native unit", + }, + }, + { + name: "success - not bep3 conversion", + conversionDenom: "xrp", + userErc20Balance: sdkmath.NewInt(2.5e18), + expUserBankBalance: sdkmath.NewInt(2.1e18), + expUserErc20Balance: sdkmath.NewInt(0.4e18), + convertAmount: sdkmath.NewInt(2.1e18), + errArgs: errArgs{ + expectPass: true, + }, + }, + { + name: "fail - user converting more than user balance", + userErc20Balance: sdkmath.NewInt(2e18), + convertAmount: sdkmath.NewInt(2.3e18), + errArgs: errArgs{ + expectPass: false, + contains: "transfer amount exceeds balance", + }, + }, + { + name: "success - user converting more than balance but only by dust amount", + userErc20Balance: sdkmath.NewInt(2e18), + expUserBankBalance: sdkmath.NewInt(2e8), + expUserErc20Balance: sdkmath.NewInt(0), + convertAmount: sdkmath.NewInt(2.0000000091e18), + errArgs: errArgs{ + expectPass: true, + }, + }, + } + + for _, tc := range tests { + suite.Run(fmt.Sprintf("%s: %s", denom, tc.name), func() { + suite.SetupTest() + contractAddr := suite.DeployERC20() + + // override conversion denom if needed + conversionDenom := tc.conversionDenom + if conversionDenom == "" { + conversionDenom = denom + } + + if !tc.disablePair { + suite.setEnabledConversionPairDenom(conversionDenom) + } + + if tc.contractAddr != "" { + contractAddr = testutil.MustNewInternalEVMAddressFromString(tc.contractAddr) + } + + pair := types.NewConversionPair( + contractAddr, + conversionDenom, + ) + + // fund user erc20 balance + if tc.userErc20Balance.GT(sdkmath.ZeroInt()) { + err := suite.Keeper.MintERC20( + suite.Ctx, + pair.GetAddress(), + invoker, + tc.userErc20Balance.BigInt(), + ) + suite.Require().NoError(err) + } + + // create user account, otherwise `CallEVMWithData` will fail due to failing to get user account when finding its sequence. + err = suite.App.FundAccount(suite.Ctx, invokerCosmosAddr, sdk.NewCoins(sdk.NewCoin(conversionDenom, sdk.ZeroInt()))) + suite.Require().NoError(err) + + // execute bep3 conversion + err := suite.Keeper.ConvertERC20ToCoin(suite.Ctx, invoker, invokerCosmosAddr, pair.GetAddress(), tc.convertAmount) + + if tc.errArgs.expectPass { + suite.Require().NoError(err) + + // validate user balance after conversion + bal := suite.GetERC20BalanceOf( + types.ERC20MintableBurnableContract.ABI, + pair.GetAddress(), + testutil.MustNewInternalEVMAddressFromString(invoker.String()), + ) + suite.Require().Equal(tc.expUserErc20Balance.BigInt().Int64(), bal.Int64(), "user erc20 balance is invalid") + + // validate user coin balance + coinBal := suite.App.GetBankKeeper().GetBalance(suite.Ctx, invokerCosmosAddr, pair.Denom) + suite.Require().Equal(tc.expUserBankBalance, coinBal.Amount, "user coin balance is invalid") + + // keeper event + suite.EventsContains(suite.GetEvents(), + sdk.NewEvent( + types.EventTypeConvertERC20ToCoin, + sdk.NewAttribute(types.AttributeKeyERC20Address, pair.GetAddress().String()), + sdk.NewAttribute(types.AttributeKeyInitiator, invoker.String()), + sdk.NewAttribute(types.AttributeKeyReceiver, invokerCosmosAddr.String()), + sdk.NewAttribute(types.AttributeKeyAmount, sdk.NewCoin(pair.Denom, tc.expUserBankBalance).String()), + )) + } else { + suite.Require().Error(err) + suite.Require().Contains(err.Error(), tc.errArgs.contains) + } + }) + } +} diff --git a/x/evmutil/types/errors.go b/x/evmutil/types/errors.go index ab416562..dc35468f 100644 --- a/x/evmutil/types/errors.go +++ b/x/evmutil/types/errors.go @@ -4,11 +4,12 @@ import errorsmod "cosmossdk.io/errors" // errors var ( - ErrABIPack = errorsmod.Register(ModuleName, 2, "contract ABI pack failed") - ErrEVMCall = errorsmod.Register(ModuleName, 3, "EVM call unexpected error") - 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") - ErrSDKConversionNotEnabled = errorsmod.Register(ModuleName, 8, "sdk.Coin not enabled to convert to ERC20 token") + ErrABIPack = errorsmod.Register(ModuleName, 2, "contract ABI pack failed") + ErrEVMCall = errorsmod.Register(ModuleName, 3, "EVM call unexpected error") + 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") + ErrSDKConversionNotEnabled = errorsmod.Register(ModuleName, 8, "sdk.Coin not enabled to convert to ERC20 token") + ErrInsufficientConversionAmount = errorsmod.Register(ModuleName, 9, "insufficient conversion amount") )