diff --git a/CHANGELOG.md b/CHANGELOG.md index c75bf39eff..42f906b1d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,6 +51,7 @@ Ref: https://keepachangelog.com/en/1.0.0/ - (bank-precompile) [#2095](https://github.com/evmos/evmos/pull/2095) Add `bank` precompile. - (incentives) [#2070](https://github.com/evmos/evmos/pull/2070) Remove `x/incentives` module and burn incentives pool balance (burning reverted in [#2131](https://github.com/evmos/evmos/pull/2131)). - (evm) [#2084](https://github.com/evmos/evmos/pull/2084) Remove `x/claims` params and migrate the `EVMChannels` param to the `x/evm` module params. +- (post) [#2128](https://github.com/evmos/evmos/pull/2128) Add `BurnDecorator` to `PostHandler` to burn cosmos transaction fees. ### API Breaking diff --git a/app/app.go b/app/app.go index 327eebe045..f9cd910cf9 100644 --- a/app/app.go +++ b/app/app.go @@ -50,7 +50,6 @@ import ( "github.com/cosmos/cosmos-sdk/version" "github.com/cosmos/cosmos-sdk/x/auth" authkeeper "github.com/cosmos/cosmos-sdk/x/auth/keeper" - "github.com/cosmos/cosmos-sdk/x/auth/posthandler" authsims "github.com/cosmos/cosmos-sdk/x/auth/simulation" authtx "github.com/cosmos/cosmos-sdk/x/auth/tx" authtypes "github.com/cosmos/cosmos-sdk/x/auth/types" @@ -125,6 +124,7 @@ import ( "github.com/evmos/evmos/v16/app/ante" ethante "github.com/evmos/evmos/v16/app/ante/evm" + "github.com/evmos/evmos/v16/app/post" v10 "github.com/evmos/evmos/v16/app/upgrades/v10" v11 "github.com/evmos/evmos/v16/app/upgrades/v11" v12 "github.com/evmos/evmos/v16/app/upgrades/v12" @@ -874,14 +874,16 @@ func (app *Evmos) setAnteHandler(txConfig client.TxConfig, maxGasWanted uint64) } func (app *Evmos) setPostHandler() { - postHandler, err := posthandler.NewPostHandler( - posthandler.HandlerOptions{}, - ) - if err != nil { + options := post.HandlerOptions{ + FeeCollectorName: authtypes.FeeCollectorName, + BankKeeper: app.BankKeeper, + } + + if err := options.Validate(); err != nil { panic(err) } - app.SetPostHandler(postHandler) + app.SetPostHandler(post.NewPostHandler(options)) } // BeginBlocker runs the Tendermint ABCI BeginBlock logic. It executes state changes at the beginning diff --git a/app/post/burn.go b/app/post/burn.go new file mode 100644 index 0000000000..ca80a21692 --- /dev/null +++ b/app/post/burn.go @@ -0,0 +1,76 @@ +// Copyright Tharsis Labs Ltd.(Evmos) +// SPDX-License-Identifier:ENCL-1.0(https://github.com/evmos/evmos/blob/main/LICENSE) + +package post + +import ( + errorsmod "cosmossdk.io/errors" + sdkmath "cosmossdk.io/math" + + sdk "github.com/cosmos/cosmos-sdk/types" + errortypes "github.com/cosmos/cosmos-sdk/types/errors" + authtypes "github.com/cosmos/cosmos-sdk/x/auth/types" + bankkeeper "github.com/cosmos/cosmos-sdk/x/bank/keeper" + evmtypes "github.com/evmos/evmos/v16/x/evm/types" +) + +var _ sdk.PostDecorator = &BurnDecorator{} + +// BurnDecorator is the decorator that burns all the transaction fees from Cosmos transactions. +type BurnDecorator struct { + feeCollectorName string + bankKeeper bankkeeper.Keeper +} + +// NewBurnDecorator creates a new instance of the BurnDecorator. +func NewBurnDecorator(feeCollector string, bankKeeper bankkeeper.Keeper) sdk.PostDecorator { + return &BurnDecorator{ + feeCollectorName: feeCollector, + bankKeeper: bankKeeper, + } +} + +// PostHandle burns all the transaction fees from Cosmos transactions. If an Ethereum transaction is present, this logic +// is skipped. +func (bd BurnDecorator) PostHandle(ctx sdk.Context, tx sdk.Tx, simulate, success bool, next sdk.PostHandler) (newCtx sdk.Context, err error) { + feeTx, ok := tx.(sdk.FeeTx) + if !ok { + return ctx, errorsmod.Wrapf(errortypes.ErrInvalidType, "invalid transaction type %T, expected sdk.FeeTx", tx) + } + + // skip logic if there is an Ethereum transaction + for _, msg := range tx.GetMsgs() { + if _, ok := msg.(*evmtypes.MsgEthereumTx); ok { + return next(ctx, tx, simulate, success) + } + } + + fees := feeTx.GetFee() + + // safety check: ensure the fees are not empty and with positive amounts + // before burning + if len(fees) == 0 || !fees.IsAllPositive() { + return next(ctx, tx, simulate, success) + } + + // burn min(balance, fee) + var burnedCoins sdk.Coins + for _, fee := range fees { + balance := bd.bankKeeper.GetBalance(ctx, authtypes.NewModuleAddress(bd.feeCollectorName), fee.Denom) + if !balance.IsPositive() { + continue + } + + amount := sdkmath.MinInt(fee.Amount, balance.Amount) + + burnedCoins = append(burnedCoins, sdk.Coin{Denom: fee.Denom, Amount: amount}) + } + + // NOTE: since all Cosmos tx fees are pooled by the fee collector module account, + // we burn them directly from it + if err := bd.bankKeeper.BurnCoins(ctx, bd.feeCollectorName, burnedCoins); err != nil { + return ctx, err + } + + return next(ctx, tx, simulate, success) +} diff --git a/app/post/burn_test.go b/app/post/burn_test.go new file mode 100644 index 0000000000..bea61bb7ba --- /dev/null +++ b/app/post/burn_test.go @@ -0,0 +1,170 @@ +// Copyright Tharsis Labs Ltd.(Evmos) +// SPDX-License-Identifier:ENCL-1.0(https://github.com/evmos/evmos/blob/main/LICENSE) + +package post_test + +import ( + sdkmath "cosmossdk.io/math" + authtypes "github.com/cosmos/cosmos-sdk/x/auth/types" + "github.com/evmos/evmos/v16/app/post" + + // "github.com/evmos/evmos/v16/testutil/integration/evmos/factory" + sdk "github.com/cosmos/cosmos-sdk/types" +) + +func (s *PostTestSuite) TestPostHandle() { + testCases := []struct { + name string + tx func() sdk.Tx + expPass bool + errContains string + postChecks func() + }{ + { + name: "pass - noop with Ethereum message", + tx: func() sdk.Tx { + return s.BuildEthTx() + }, + expPass: true, + postChecks: func() {}, + }, + { + name: "pass - burn fees of a single token with empty end balance", + tx: func() sdk.Tx { + feeAmount := sdk.Coins{sdk.Coin{Amount: sdkmath.NewInt(10), Denom: "btc"}} + amount := feeAmount + s.MintCoinsForFeeCollector(amount) + + return s.BuildCosmosTxWithNSendMsg(1, feeAmount) + }, + expPass: true, + postChecks: func() { + expected := sdk.Coins{} + balance := s.GetFeeCollectorBalance() + s.Require().Equal(expected, balance) + }, + }, + { + name: "pass - burn fees of a single token with non-empty end balance", + tx: func() sdk.Tx { + feeAmount := sdk.Coins{sdk.Coin{Amount: sdkmath.NewInt(10), Denom: "evmos"}} + amount := sdk.Coins{sdk.Coin{Amount: sdkmath.NewInt(20), Denom: "evmos"}} + s.MintCoinsForFeeCollector(amount) + + return s.BuildCosmosTxWithNSendMsg(1, feeAmount) + }, + expPass: true, + postChecks: func() { + expected := sdk.Coins{sdk.Coin{Amount: sdkmath.NewInt(10), Denom: "evmos"}} + balance := s.GetFeeCollectorBalance() + s.Require().Equal(expected, balance) + }, + }, + { + name: "pass - burn fees of multiple tokens with empty end balance", + tx: func() sdk.Tx { + feeAmount := sdk.Coins{ + sdk.Coin{Amount: sdkmath.NewInt(10), Denom: "eth"}, + sdk.Coin{Amount: sdkmath.NewInt(10), Denom: "evmos"}, + } + amount := feeAmount + s.MintCoinsForFeeCollector(amount) + + return s.BuildCosmosTxWithNSendMsg(1, feeAmount) + }, + expPass: true, + postChecks: func() { + balance := s.GetFeeCollectorBalance() + s.Require().Equal(sdk.Coins{}, balance) + }, + }, + { //nolint:dupl + name: "pass - burn fees of multiple tokens with non-empty end balance", + tx: func() sdk.Tx { + feeAmount := sdk.Coins{ + sdk.Coin{Amount: sdkmath.NewInt(10), Denom: "btc"}, + sdk.Coin{Amount: sdkmath.NewInt(10), Denom: "evmos"}, + } + amount := sdk.Coins{ + sdk.Coin{Amount: sdkmath.NewInt(20), Denom: "btc"}, + sdk.Coin{Amount: sdkmath.NewInt(10), Denom: "evmos"}, + sdk.Coin{Amount: sdkmath.NewInt(3), Denom: "osmo"}, + } + s.MintCoinsForFeeCollector(amount) + + return s.BuildCosmosTxWithNSendMsg(1, feeAmount) + }, + expPass: true, + postChecks: func() { + expected := sdk.Coins{ + sdk.Coin{Amount: sdkmath.NewInt(10), Denom: "btc"}, + sdk.Coin{Amount: sdkmath.NewInt(3), Denom: "osmo"}, + } + balance := s.GetFeeCollectorBalance() + s.Require().Equal(expected, balance) + }, + }, + { //nolint:dupl + name: "pass - burn fees of multiple tokens, non-empty end balance, and multiple messages", + tx: func() sdk.Tx { + feeAmount := sdk.Coins{ + sdk.Coin{Amount: sdkmath.NewInt(10), Denom: "btc"}, + sdk.Coin{Amount: sdkmath.NewInt(10), Denom: "evmos"}, + } + amount := sdk.Coins{ + sdk.Coin{Amount: sdkmath.NewInt(20), Denom: "btc"}, + sdk.Coin{Amount: sdkmath.NewInt(10), Denom: "evmos"}, + sdk.Coin{Amount: sdkmath.NewInt(3), Denom: "osmo"}, + } + s.MintCoinsForFeeCollector(amount) + + return s.BuildCosmosTxWithNSendMsg(100, feeAmount) + }, + expPass: true, + postChecks: func() { + expected := sdk.Coins{ + sdk.Coin{Amount: sdkmath.NewInt(10), Denom: "btc"}, + sdk.Coin{Amount: sdkmath.NewInt(3), Denom: "osmo"}, + } + balance := s.GetFeeCollectorBalance() + s.Require().Equal(expected, balance) + }, + }, + } + + for _, tc := range testCases { + // Be sure to have a fresh new network before each test. It is not required for following + // test but it is still a good practice. + s.SetupTest() + s.Run(tc.name, func() { + // start each test with a fresh new block. + err := s.unitNetwork.NextBlock() + s.Require().NoError(err) + + burnDecorator := post.NewBurnDecorator( + authtypes.FeeCollectorName, + s.unitNetwork.App.BankKeeper, + ) + + // In the execution of the PostHandle method, simulate, success, and next have been + // hard-coded because they are not influencing the behavior of the BurnDecorator. + terminator := sdk.ChainPostDecorators(sdk.Terminator{}) + _, err = burnDecorator.PostHandle( + s.unitNetwork.GetContext(), + tc.tx(), + false, + false, + terminator, + ) + + if tc.expPass { + s.Require().NoError(err) + } else { + s.Require().Error(err, "expected error during HandlerOptions validation") + s.Require().Contains(err.Error(), tc.errContains, "expected a different error") + } + + tc.postChecks() + }) + } +} diff --git a/app/post/post.go b/app/post/post.go new file mode 100644 index 0000000000..302f0959e1 --- /dev/null +++ b/app/post/post.go @@ -0,0 +1,38 @@ +// Copyright Tharsis Labs Ltd.(Evmos) +// SPDX-License-Identifier:ENCL-1.0(https://github.com/evmos/evmos/blob/main/LICENSE) + +package post + +import ( + "errors" + + sdk "github.com/cosmos/cosmos-sdk/types" + bankkeeper "github.com/cosmos/cosmos-sdk/x/bank/keeper" +) + +// HandlerOptions are the options required for constructing a PostHandler. +type HandlerOptions struct { + FeeCollectorName string + BankKeeper bankkeeper.Keeper +} + +func (h HandlerOptions) Validate() error { + if h.FeeCollectorName == "" { + return errors.New("fee collector name cannot be empty") + } + + if h.BankKeeper == nil { + return errors.New("bank keeper cannot be nil") + } + + return nil +} + +// NewPostHandler returns a new PostHandler decorators chain. +func NewPostHandler(ho HandlerOptions) sdk.PostHandler { + postDecorators := []sdk.PostDecorator{ + NewBurnDecorator(ho.FeeCollectorName, ho.BankKeeper), + } + + return sdk.ChainPostDecorators(postDecorators...) +} diff --git a/app/post/post_test.go b/app/post/post_test.go new file mode 100644 index 0000000000..065732c862 --- /dev/null +++ b/app/post/post_test.go @@ -0,0 +1,69 @@ +// Copyright Tharsis Labs Ltd.(Evmos) +// SPDX-License-Identifier:ENCL-1.0(https://github.com/evmos/evmos/blob/main/LICENSE) + +package post_test + +import ( + authtypes "github.com/cosmos/cosmos-sdk/x/auth/types" + bankkeeper "github.com/cosmos/cosmos-sdk/x/bank/keeper" + "github.com/evmos/evmos/v16/app/post" +) + +func (s *PostTestSuite) TestPostHandlerOptions() { + validBankKeeper := s.unitNetwork.App.BankKeeper + validFeeCollector := authtypes.FeeCollectorName + + testCases := []struct { + name string + feeCollector string + bankKeeper bankkeeper.Keeper + expPass bool + errContains string + }{ + { + name: "fail - empty fee collector name", + feeCollector: "", + bankKeeper: validBankKeeper, + expPass: false, + errContains: "fee collector name cannot be empty", + }, + { + name: "fail - nil bank keeper", + feeCollector: validFeeCollector, + bankKeeper: nil, + expPass: false, + errContains: "bank keeper cannot be nil", + }, + { + name: "pass - correct inputs", + feeCollector: validFeeCollector, + bankKeeper: validBankKeeper, + expPass: true, + }, + } + + for _, tc := range testCases { + // Be sure to have a fresh new network before each test. It is not required for following + // test but it is still a good practice. + s.SetupTest() + s.Run(tc.name, func() { + // start each test with a fresh new block. + err := s.unitNetwork.NextBlock() + s.Require().NoError(err) + + handlerOptions := post.HandlerOptions{ + FeeCollectorName: tc.feeCollector, + BankKeeper: tc.bankKeeper, + } + + err = handlerOptions.Validate() + + if tc.expPass { + s.Require().NoError(err) + } else { + s.Require().Error(err, "expected error during HandlerOptions validation") + s.Require().Contains(err.Error(), tc.errContains, "expected a different error") + } + }) + } +} diff --git a/app/post/setup_test.go b/app/post/setup_test.go new file mode 100644 index 0000000000..52eeb450c3 --- /dev/null +++ b/app/post/setup_test.go @@ -0,0 +1,147 @@ +// Copyright Tharsis Labs Ltd.(Evmos) +// SPDX-License-Identifier:ENCL-1.0(https://github.com/evmos/evmos/blob/main/LICENSE) + +package post_test + +import ( + "math/big" + "testing" + + "github.com/cosmos/cosmos-sdk/codec" + codectypes "github.com/cosmos/cosmos-sdk/codec/types" + sdk "github.com/cosmos/cosmos-sdk/types" + authtx "github.com/cosmos/cosmos-sdk/x/auth/tx" + authtypes "github.com/cosmos/cosmos-sdk/x/auth/types" + banktypes "github.com/cosmos/cosmos-sdk/x/bank/types" + "github.com/ethereum/go-ethereum/common" + "github.com/evmos/evmos/v16/testutil/integration/evmos/grpc" + testkeyring "github.com/evmos/evmos/v16/testutil/integration/evmos/keyring" + "github.com/evmos/evmos/v16/testutil/integration/evmos/network" + evmtypes "github.com/evmos/evmos/v16/x/evm/types" + inflationtypes "github.com/evmos/evmos/v16/x/inflation/v1/types" + + "github.com/cosmos/cosmos-sdk/client" + "github.com/stretchr/testify/suite" +) + +const ( + gasLimit = 100_000 +) + +type PostTestSuite struct { + suite.Suite + + unitNetwork *network.UnitTestNetwork + grpcHandler grpc.Handler + keyring testkeyring.Keyring + + txBuilder client.TxBuilder + + from common.Address + to common.Address +} + +func (s *PostTestSuite) SetupTest() { + keyring := testkeyring.New(2) + unitNetwork := network.NewUnitTestNetwork( + network.WithPreFundedAccounts(keyring.GetAllAccAddrs()...), + ) + grpcHandler := grpc.NewIntegrationHandler(unitNetwork) + + // TxBuilder is used to create Ethereum and Cosmos Tx to test + // the fee burner. + interfaceRegistry := codectypes.NewInterfaceRegistry() + codec := codec.NewProtoCodec(interfaceRegistry) + txConfig := authtx.NewTxConfig(codec, authtx.DefaultSignModes) + txBuilder := txConfig.NewTxBuilder() + + s.from = keyring.GetAddr(0) + s.to = keyring.GetAddr(1) + s.unitNetwork = unitNetwork + s.grpcHandler = grpcHandler + s.keyring = keyring + s.txBuilder = txBuilder +} + +func TestPostTestSuite(t *testing.T) { + suite.Run(t, new(PostTestSuite)) +} + +func (s *PostTestSuite) BuildEthTx() sdk.Tx { + chainID := s.unitNetwork.App.EvmKeeper.ChainID() + nonce := s.unitNetwork.App.EvmKeeper.GetNonce( + s.unitNetwork.GetContext(), + common.BytesToAddress(s.from.Bytes()), + ) + + ethTxParams := &evmtypes.EvmTxArgs{ + ChainID: chainID, + Nonce: nonce, + To: &s.to, + GasLimit: gasLimit, + GasPrice: big.NewInt(1), + GasTipCap: big.NewInt(1), + } + + msgEthereumTx := evmtypes.NewTx(ethTxParams) + msgEthereumTx.From = s.from.String() + tx, err := msgEthereumTx.BuildTx(s.txBuilder, "evmos") + s.Require().NoError(err) + return tx +} + +// BuildCosmosTxWithSendMsg is an utils function to create an sdk.Tx containing +// a single message of type MsgSend from the bank module. +func (s *PostTestSuite) BuildCosmosTxWithNSendMsg(n int, feeAmount sdk.Coins) sdk.Tx { + messages := make([]sdk.Msg, n) + + sendMsg := banktypes.MsgSend{ + FromAddress: s.from.String(), + ToAddress: s.to.String(), + Amount: feeAmount, + } + + for i := range messages { + messages[i] = &sendMsg + } + + s.txBuilder.SetGasLimit(gasLimit) + s.txBuilder.SetFeeAmount(feeAmount) + err := s.txBuilder.SetMsgs(messages...) + s.Require().NoError(err) + return s.txBuilder.GetTx() +} + +// MintCoinForFeeCollector allows to mint a specific amount of coins from the bank +// and to transfer them to the FeeCollector. +func (s *PostTestSuite) MintCoinsForFeeCollector(amount sdk.Coins) { + // Minting tokens for the FeeCollector to simulate fee accrued. + err := s.unitNetwork.App.BankKeeper.MintCoins( + s.unitNetwork.GetContext(), + inflationtypes.ModuleName, + amount, + ) + s.Require().NoError(err) + + err = s.unitNetwork.App.BankKeeper.SendCoinsFromModuleToModule( + s.unitNetwork.GetContext(), + inflationtypes.ModuleName, + authtypes.FeeCollectorName, + amount, + ) + s.Require().NoError(err) + + balance := s.GetFeeCollectorBalance() + s.Require().Equal(amount, balance) +} + +// GetFeeCollectorBalance is an utility function to query the balance +// of the FeeCollector module. +func (s *PostTestSuite) GetFeeCollectorBalance() sdk.Coins { + address := s.unitNetwork.App.AccountKeeper.GetModuleAddress(authtypes.FeeCollectorName) + balance := s.unitNetwork.App.BankKeeper.GetAllBalances( + s.unitNetwork.GetContext(), + address, + ) + return balance +}