diff --git a/tests/integration/x/vm/test_gas.go b/tests/integration/x/vm/test_gas.go new file mode 100644 index 00000000..a3e56362 --- /dev/null +++ b/tests/integration/x/vm/test_gas.go @@ -0,0 +1,216 @@ +package vm + +import ( + "math/big" + + "github.com/cosmos/evm/testutil/integration/evm/factory" + "github.com/cosmos/evm/testutil/integration/evm/grpc" + "github.com/cosmos/evm/testutil/integration/evm/network" + testkeyring "github.com/cosmos/evm/testutil/keyring" + "github.com/cosmos/evm/x/vm/keeper" + "github.com/cosmos/evm/x/vm/types" + + sdkmath "cosmossdk.io/math" + + sdk "github.com/cosmos/cosmos-sdk/types" + authtypes "github.com/cosmos/cosmos-sdk/x/auth/types" + banktypes "github.com/cosmos/cosmos-sdk/x/bank/types" +) + +const ( + DefaultCoreMsgGasUsage = 21000 + DefaultGasPrice = 120000 + TestDenom = "acoin" +) + +// TestGasRefundGas tests the refund gas exclusively without going though the state transition +// The gas part on the name refers to the file name to not generate a duplicated test name +func (suite *KeeperTestSuite) TestGasRefundGas() { + // FeeCollector account is pre-funded with enough tokens + // for refund to work + // NOTE: everything should happen within the same block for + // feecollector account to remain funded + baseDenom := types.GetEVMCoinDenom() + + coins := sdk.NewCoins( + sdk.NewCoin( + baseDenom, + sdkmath.NewInt(6e18), + ), + sdk.NewCoin( + TestDenom, + sdkmath.NewInt(6e18), + ), + ) + feeAddress := authtypes.NewModuleAddress(authtypes.FeeCollectorName) + + balances := []banktypes.Balance{ + { + Address: feeAddress.String(), + Coins: coins, + }, + } + bankGenesis := banktypes.DefaultGenesisState() + bankGenesis.Balances = balances + customGenesis := network.CustomGenesisState{} + customGenesis[banktypes.ModuleName] = bankGenesis + + // Create a txFactory + keyring := testkeyring.New(2) + unitNetwork := network.NewUnitTestNetwork( + suite.Create, + network.WithPreFundedAccounts(keyring.GetAllAccAddrs()...), + network.WithCustomGenesis(customGenesis), + ) + grpcHandler := grpc.NewIntegrationHandler(unitNetwork) + txFactory := factory.New(unitNetwork, grpcHandler) + + // Create a core message to use for the test + sender := keyring.GetKey(0) + recipient := keyring.GetAddr(1) + coreMsg, err := txFactory.GenerateGethCoreMsg( + sender.Priv, + types.EvmTxArgs{ + To: &recipient, + Amount: big.NewInt(100), + GasPrice: big.NewInt(DefaultGasPrice), + }, + ) + suite.Require().NoError(err) + + // Produce all the test cases + testCases := []struct { + name string + leftoverGas uint64 // The coreMsg always uses 21000 gas limit + malleate func(sdk.Context) sdk.Context + expectedRefund sdk.Coins + errContains string + }{ + { + name: "Refund the full value as no gas was used", + leftoverGas: DefaultCoreMsgGasUsage, + expectedRefund: sdk.NewCoins( + sdk.NewCoin(baseDenom, sdkmath.NewInt(DefaultCoreMsgGasUsage*DefaultGasPrice)), + ), + }, + { + name: "Refund half the value as half gas was used", + leftoverGas: DefaultCoreMsgGasUsage / 2, + expectedRefund: sdk.NewCoins( + sdk.NewCoin(baseDenom, sdkmath.NewInt((DefaultCoreMsgGasUsage*DefaultGasPrice)/2)), + ), + }, + { + name: "No refund as no gas was left over used", + leftoverGas: 0, + expectedRefund: sdk.NewCoins( + sdk.NewCoin(baseDenom, sdkmath.NewInt(0)), + ), + }, + { + name: "Refund with context fees, refunding the full value", + leftoverGas: DefaultCoreMsgGasUsage, + malleate: func(ctx sdk.Context) sdk.Context { + // Set the fee abstraction paid fee key with a single coin + return ctx.WithValue( + keeper.ContextPaidFeesKey{}, + sdk.NewCoins( + sdk.NewCoin(TestDenom, sdkmath.NewInt(750_000_000)), + ), + ) + }, + expectedRefund: sdk.NewCoins( + sdk.NewCoin(TestDenom, sdkmath.NewInt(750_000_000)), + ), + }, + { + name: "Refund with context fees, refunding the half the value", + leftoverGas: DefaultCoreMsgGasUsage / 2, + malleate: func(ctx sdk.Context) sdk.Context { + // Set the fee abstraction paid fee key with a single coin + return ctx.WithValue( + keeper.ContextPaidFeesKey{}, + sdk.NewCoins( + sdk.NewCoin(TestDenom, sdkmath.NewInt(750_000_000)), + ), + ) + }, + expectedRefund: sdk.NewCoins( + sdk.NewCoin(TestDenom, sdkmath.NewInt(750_000_000/2)), + ), + }, + { + name: "Refund with context fees, no refund", + leftoverGas: 0, + malleate: func(ctx sdk.Context) sdk.Context { + // Set the fee abstraction paid fee key with a single coin + return ctx.WithValue( + keeper.ContextPaidFeesKey{}, + sdk.NewCoins( + sdk.NewCoin(TestDenom, sdkmath.NewInt(750_000_000)), + ), + ) + }, + expectedRefund: sdk.NewCoins( + sdk.NewCoin(TestDenom, sdkmath.NewInt(0)), + ), + }, + { + name: "Error - More than one coin being passed", + leftoverGas: DefaultCoreMsgGasUsage, + malleate: func(ctx sdk.Context) sdk.Context { + // Set the fee abstraction paid fee key with a single coin + return ctx.WithValue( + keeper.ContextPaidFeesKey{}, + sdk.NewCoins( + sdk.NewCoin(TestDenom, sdkmath.NewInt(750_000_000)), + sdk.NewCoin("atwo", sdkmath.NewInt(750_000_000)), + ), + ) + }, + errContains: "expected a single coin for EVM refunds, got 2", + }, + } + + // Iterate though the test cases + for _, tc := range testCases { + suite.Run(tc.name, func() { + // Generate a cached context to not leak data between tests + ctx, _ := unitNetwork.GetContext().CacheContext() + + // Apply the malleate function to the context + if tc.malleate != nil { + ctx = tc.malleate(ctx) + } + + if tc.leftoverGas > DefaultCoreMsgGasUsage { + return + } + + initialBalances := unitNetwork.App.GetBankKeeper().GetAllBalances(ctx, feeAddress) + + err = unitNetwork.App.GetEVMKeeper().RefundGas( + ctx, + *coreMsg, + tc.leftoverGas, + DefaultCoreMsgGasUsage, + baseDenom, + ) + + // Check the error + if tc.errContains != "" { + suite.Require().ErrorContains(err, tc.errContains, "RefundGas should return an error") + } else { + suite.Require().NoError(err, "RefundGas should not return an error") + } + + // Check the balance change + if !tc.expectedRefund.Empty() { + diff := initialBalances.Sub(unitNetwork.App.GetBankKeeper().GetAllBalances(ctx, feeAddress)...) + for _, coin := range tc.expectedRefund { + suite.Require().Equal(coin.Amount, diff.AmountOf(coin.Denom)) + } + } + }) + } +} diff --git a/tests/integration/x/vm/test_state_transition.go b/tests/integration/x/vm/test_state_transition.go index f44f5d5e..450d8ce5 100644 --- a/tests/integration/x/vm/test_state_transition.go +++ b/tests/integration/x/vm/test_state_transition.go @@ -488,6 +488,7 @@ func (s *KeeperTestSuite) TestRefundGas() { unitNetwork.GetContext(), *coreMsg, refund, + gasUsed, unitNetwork.GetBaseDenom(), ) diff --git a/x/vm/keeper/gas.go b/x/vm/keeper/gas.go index ab5dcf08..c7bdd5f4 100644 --- a/x/vm/keeper/gas.go +++ b/x/vm/keeper/gas.go @@ -16,6 +16,9 @@ import ( authtypes "github.com/cosmos/cosmos-sdk/x/auth/types" ) +// ContextPaidFeesKey is a key used to store the paid fee in the context +type ContextPaidFeesKey struct{} + // GetEthIntrinsicGas returns the intrinsic gas cost for the transaction func (k *Keeper) GetEthIntrinsicGas(ctx sdk.Context, msg core.Message, cfg *params.ChainConfig, isContractCreation bool, @@ -32,26 +35,59 @@ func (k *Keeper) GetEthIntrinsicGas(ctx sdk.Context, msg core.Message, cfg *para // consumed in the transaction. Additionally, the function sets the total gas consumed to the value // returned by the EVM execution, thus ignoring the previous intrinsic gas consumed during in the // AnteHandler. -func (k *Keeper) RefundGas(ctx sdk.Context, msg core.Message, leftoverGas uint64, denom string) error { +func (k *Keeper) RefundGas(ctx sdk.Context, msg core.Message, leftoverGas uint64, gasUsed uint64, denom string) error { // Return EVM tokens for remaining gas, exchanged at the original rate. remaining := new(big.Int).Mul(new(big.Int).SetUint64(leftoverGas), msg.GasPrice) + // Check if gas is zero + if gasUsed == 0 { + // If gas is zero, we cannot refund anything, so we return early + return nil + } + switch remaining.Sign() { case -1: // negative refund errors return errorsmod.Wrapf(types.ErrInvalidRefund, "refunded amount value cannot be negative %d", remaining.Int64()) case 1: - // positive amount refund + // Attempt to extract the paid coin from the context + // This is used when fee abstraction is applied into the fee payment + // If no value is found under the context, the original denom is used + if val := ctx.Value(ContextPaidFeesKey{}); val != nil { + // We check if a coin exists under the value and if it's not empty + if paidCoins, ok := val.(sdk.Coins); ok && !paidCoins.IsZero() { + // We know that only a single coin is used for EVM payments + if len(paidCoins) != 1 { + // This should never happen, but if it does, we return an error + return errorsmod.Wrapf(types.ErrInvalidRefund, "expected a single coin for EVM refunds, got %d", len(paidCoins)) + } + paidCoin := paidCoins[0] + + // Extract the coin information + denom = paidCoin.Denom + amount := paidCoin.Amount.BigInt() + + // Calculate the amount to refund + // This is calculated as: + // remaining = amount * leftoverGas / gasUsed + remaining = new(big.Int).Div( + new(big.Int).Mul(amount, new(big.Int).SetUint64(leftoverGas)), + new(big.Int).SetUint64(gasUsed), + ) + } + } + + // Positive amount refund refundedCoins := sdk.Coins{sdk.NewCoin(denom, sdkmath.NewIntFromBigInt(remaining))} - // refund to sender from the fee collector module account, which is the escrow account in charge of collecting tx fees + // Refund to sender from the fee collector module account, which is the escrow account in charge of collecting tx fees err := k.bankWrapper.SendCoinsFromModuleToAccount(ctx, authtypes.FeeCollectorName, msg.From.Bytes(), refundedCoins) if err != nil { err = errorsmod.Wrapf(errortypes.ErrInsufficientFunds, "fee collector account failed to refund fees: %s", err.Error()) return errorsmod.Wrapf(err, "failed to refund %d leftover gas (%s)", leftoverGas, refundedCoins.String()) } default: - // no refund, consume gas and update the tx gas meter + // No refund, consume gas and update the tx gas meter } return nil diff --git a/x/vm/keeper/state_transition.go b/x/vm/keeper/state_transition.go index eb8975cf..8cce23b8 100644 --- a/x/vm/keeper/state_transition.go +++ b/x/vm/keeper/state_transition.go @@ -311,7 +311,7 @@ func (k *Keeper) ApplyTransaction(ctx sdk.Context, tx *ethtypes.Transaction) (*t if msg.GasLimit > res.GasUsed { remainingGas = msg.GasLimit - res.GasUsed } - if err = k.RefundGas(ctx, *msg, remainingGas, types.GetEVMCoinDenom()); err != nil { + if err = k.RefundGas(ctx, *msg, remainingGas, res.GasUsed, types.GetEVMCoinDenom()); err != nil { return nil, errorsmod.Wrapf(err, "failed to refund gas leftover gas to sender %s", msg.From) }