diff --git a/app/ante/validator_tx_fee.go b/app/ante/validator_tx_fee.go index 08df08d54..a1ee1b7ea 100644 --- a/app/ante/validator_tx_fee.go +++ b/app/ante/validator_tx_fee.go @@ -2,72 +2,95 @@ package ante import ( "math" - "strings" errorsmod "cosmossdk.io/errors" sdkmath "cosmossdk.io/math" sdk "github.com/cosmos/cosmos-sdk/types" sdkerrors "github.com/cosmos/cosmos-sdk/types/errors" - oracletypes "github.com/elys-network/elys/v7/x/oracle/types" + authtypes "github.com/cosmos/cosmos-sdk/x/auth/types" + govtypes "github.com/cosmos/cosmos-sdk/x/gov/types" parametertypes "github.com/elys-network/elys/v7/x/parameter/types" ) -// CheckTxFeeWithValidatorMinGasPrices implements the default fee logic, where the minimum price per -// unit of gas is fixed and set by each validator, can the tx priority is computed from the gas price. -func CheckTxFeeWithValidatorMinGasPrices(ctx sdk.Context, tx sdk.Tx) (sdk.Coins, int64, error) { - feeTx, ok := tx.(sdk.FeeTx) - if !ok { - return nil, 0, errorsmod.Wrap(sdkerrors.ErrTxDecode, "Tx must be a FeeTx") - } - - feeCoins := feeTx.GetFee() - gas := feeTx.GetGas() - // Ensure that the provided fees meet a minimum threshold for the validator, - // if this is a CheckTx. This is only for local mempool purposes, and thus - // is only ran on check tx. - if ctx.IsCheckTx() { - minGasPrices := ctx.MinGasPrices() +// GaslessAddrs contains only the governance module address. +// Governance can grant feegrants to operational addresses (price feeders, liquidation bots, etc.) +// When those addresses use feegrants, the fee payer becomes governance, enabling gasless transactions. +var GaslessAddrs = []string{ + authtypes.NewModuleAddress(govtypes.ModuleName).String(), // Governance module address +} - // Check for specific message types to adjust gas price - msgs := tx.GetMsgs() - if len(msgs) == 1 { - msgType := strings.ToLower(sdk.MsgTypeURL(msgs[0])) - if strings.Contains(msgType, sdk.MsgTypeURL(&oracletypes.MsgFeedPrice{})) || strings.Contains(msgType, sdk.MsgTypeURL(&oracletypes.MsgFeedMultiplePrices{})) { - // set the minimum gas price to 0 ELYS if the message is a feed price - minGasPrice := sdk.DecCoin{ - Denom: parametertypes.Elys, - Amount: sdkmath.LegacyZeroDec(), - } - if !minGasPrice.IsValid() { - return nil, 0, errorsmod.Wrap(sdkerrors.ErrLogic, "invalid gas price") - } - minGasPrices = sdk.NewDecCoins(minGasPrice) +// CheckTxFeeWithValidatorMinGasPrices checks fees in CheckTx (mempool). If the +// fee-payer is in GaslessAddrs, we force minGasPrices to zero so 0uelys txs pass. +func CheckTxFeeWithValidatorMinGasPrices( + ctx sdk.Context, tx sdk.Tx, +) (sdk.Coins, int64, error) { + feeTx, ok := tx.(sdk.FeeTx) + if !ok { + return nil, 0, errorsmod.Wrap(sdkerrors.ErrTxDecode, + "tx must implement FeeTx") + } - // print minGasPrices - ctx.Logger().Info("Override minimum gas prices: " + minGasPrices.String()) - } - } + feeCoins, gas := feeTx.GetFee(), feeTx.GetGas() + isGasless := false - if !minGasPrices.IsZero() { - requiredFees := make(sdk.Coins, len(minGasPrices)) + if ctx.IsCheckTx() { + // start with the validator's configured minGasPrices + minGasPrices := ctx.MinGasPrices() - // Determine the required fees by multiplying each required minimum gas - // price by the gas limit, where fee = ceil(minGasPrice * gasLimit). - glDec := sdkmath.LegacyNewDec(int64(gas)) - for i, gp := range minGasPrices { - fee := gp.Amount.Mul(glDec) - requiredFees[i] = sdk.NewCoin(gp.Denom, fee.Ceil().RoundInt()) - } + // Check if the fee payer is in the gasless whitelist + feePayerBytes := feeTx.FeePayer() + if len(feePayerBytes) > 0 { + feePayer := sdk.AccAddress(feePayerBytes).String() + + for _, ga := range GaslessAddrs { + if feePayer == ga { + isGasless = true + zero := sdkmath.LegacyZeroDec() + minGasPrices = sdk.NewDecCoins( + sdk.NewDecCoinFromDec(parametertypes.Elys, zero), + ) + ctx.Logger().Info( + "override minimum gas price to 0 for gasless address", + "address", ga, + ) + break + } + } + } - if !feeCoins.IsAnyGTE(requiredFees) { - return nil, 0, errorsmod.Wrapf(sdkerrors.ErrInsufficientFee, "insufficient fees; got: %s required: %s", feeCoins, requiredFees) - } - } - } + // now enforce: feeCoins >= minGasPrices * gasLimit + if !minGasPrices.IsZero() { + required := make(sdk.Coins, len(minGasPrices)) + gdec := sdkmath.LegacyNewDec(int64(gas)) + for i, gp := range minGasPrices { + amt := gp.Amount.Mul(gdec).Ceil().RoundInt() + required[i] = sdk.NewCoin(gp.Denom, amt) + } + if !feeCoins.IsAnyGTE(required) { + return nil, 0, errorsmod.Wrapf( + sdkerrors.ErrInsufficientFee, + "insufficient fees; got: %s required: %s", + feeCoins, required, + ) + } + } + } - priority := getTxPriority(feeCoins, int64(gas)) - return feeCoins, priority, nil + // on DeliverTx/Simulate we just deduct whatever feeCoins was attached + // Give gasless transactions the highest priority + var priority int64 + if isGasless { + priority = math.MaxInt64 + ctx.Logger().Info( + "gasless transaction given highest priority", + "priority", priority, + ) + } else { + priority = getTxPriority(feeCoins, int64(gas)) + } + + return feeCoins, priority, nil } // getTxPriority returns a naive tx priority based on the amount of the smallest denomination of the gas price diff --git a/app/ante/validator_tx_fee_test.go b/app/ante/validator_tx_fee_test.go new file mode 100644 index 000000000..4ce84e883 --- /dev/null +++ b/app/ante/validator_tx_fee_test.go @@ -0,0 +1,267 @@ +package ante + +import ( + "math" + "testing" + + "cosmossdk.io/log" + sdkmath "cosmossdk.io/math" + cmtproto "github.com/cometbft/cometbft/proto/tendermint/types" + "github.com/cosmos/cosmos-sdk/crypto/keys/secp256k1" + cryptotypes "github.com/cosmos/cosmos-sdk/crypto/types" + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/cosmos/cosmos-sdk/types/tx/signing" + banktypes "github.com/cosmos/cosmos-sdk/x/bank/types" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/proto" + + parametertypes "github.com/elys-network/elys/v7/x/parameter/types" +) + +// Mock implementation of sdk.FeeTx for testing +type MockFeeTx struct { + msgs []sdk.Msg + fee sdk.Coins + gas uint64 + feePayer sdk.AccAddress +} + +func (tx MockFeeTx) GetMsgs() []sdk.Msg { return tx.msgs } +func (tx MockFeeTx) GetMsgsV2() ([]proto.Message, error) { + protoMsgs := make([]proto.Message, len(tx.msgs)) + for i, msg := range tx.msgs { + if protoMsg, ok := msg.(proto.Message); ok { + protoMsgs[i] = protoMsg + } else { + return nil, nil + } + } + return protoMsgs, nil +} +func (tx MockFeeTx) ValidateBasic() error { return nil } +func (tx MockFeeTx) GetFee() sdk.Coins { return tx.fee } +func (tx MockFeeTx) GetGas() uint64 { return tx.gas } +func (tx MockFeeTx) FeePayer() []byte { return tx.feePayer } +func (tx MockFeeTx) FeeGranter() []byte { return nil } +func (tx MockFeeTx) GetSigners() ([][]byte, error) { return [][]byte{tx.feePayer}, nil } +func (tx MockFeeTx) GetPubKeys() ([]cryptotypes.PubKey, error) { return nil, nil } +func (tx MockFeeTx) GetSignaturesV2() ([]signing.SignatureV2, error) { return nil, nil } + +// Mock transaction that doesn't implement FeeTx +type MockInvalidTx struct{} + +func (tx MockInvalidTx) GetMsgs() []sdk.Msg { return nil } +func (tx MockInvalidTx) GetMsgsV2() ([]proto.Message, error) { return nil, nil } +func (tx MockInvalidTx) ValidateBasic() error { return nil } +func (tx MockInvalidTx) GetSigners() ([][]byte, error) { return nil, nil } +func (tx MockInvalidTx) GetPubKeys() ([]cryptotypes.PubKey, error) { return nil, nil } +func (tx MockInvalidTx) GetSignaturesV2() ([]signing.SignatureV2, error) { return nil, nil } + +// Helper function to create a mock transaction +func createTestTx(from sdk.AccAddress, fee sdk.Coins, gas uint64) sdk.Tx { + privKey := secp256k1.GenPrivKey() + recipientAddr := sdk.AccAddress(privKey.PubKey().Address()) + + msg := banktypes.NewMsgSend(from, recipientAddr, sdk.NewCoins(sdk.NewCoin(parametertypes.Elys, sdkmath.NewInt(100)))) + + return &MockFeeTx{ + msgs: []sdk.Msg{msg}, + fee: fee, + gas: gas, + feePayer: from, + } +} + +func TestCheckTxFeeWithValidatorMinGasPrices(t *testing.T) { + // Create test addresses + regularAddr := sdk.AccAddress("regular_address____") + + // Use the actual governance address from the whitelist + govAddr := sdk.MustAccAddressFromBech32(GaslessAddrs[0]) + + for _, tc := range []struct { + desc string + fromAddr sdk.AccAddress + fee sdk.Coins + gas uint64 + minGasPrices sdk.DecCoins + isCheckTx bool + expectError bool + expectedPriority int64 + errorContains string + }{ + { + desc: "Regular transaction with sufficient fees", + fromAddr: regularAddr, + fee: sdk.NewCoins(sdk.NewCoin(parametertypes.Elys, sdkmath.NewInt(50000))), + gas: 50000, + minGasPrices: sdk.NewDecCoins(sdk.NewDecCoinFromDec(parametertypes.Elys, sdkmath.LegacyNewDec(1))), + isCheckTx: true, + expectError: false, + expectedPriority: 1, // 50000/50000 = 1 + }, + { + desc: "Regular transaction with insufficient fees", + fromAddr: regularAddr, + fee: sdk.NewCoins(sdk.NewCoin(parametertypes.Elys, sdkmath.NewInt(10000))), + gas: 50000, + minGasPrices: sdk.NewDecCoins(sdk.NewDecCoinFromDec(parametertypes.Elys, sdkmath.LegacyNewDec(1))), + isCheckTx: true, + expectError: true, + errorContains: "insufficient fees", + }, + { + desc: "Governance transaction with no fees (feegrant scenario)", + fromAddr: govAddr, + fee: sdk.NewCoins(), + gas: 50000, + minGasPrices: sdk.NewDecCoins(sdk.NewDecCoinFromDec(parametertypes.Elys, sdkmath.LegacyNewDec(1))), + isCheckTx: true, + expectError: false, + expectedPriority: math.MaxInt64, // Should get max priority + }, + { + desc: "Governance transaction with fees (still gets max priority)", + fromAddr: govAddr, + fee: sdk.NewCoins(sdk.NewCoin(parametertypes.Elys, sdkmath.NewInt(10000))), + gas: 50000, + minGasPrices: sdk.NewDecCoins(sdk.NewDecCoinFromDec(parametertypes.Elys, sdkmath.LegacyNewDec(1))), + isCheckTx: true, + expectError: false, + expectedPriority: math.MaxInt64, // Should still get max priority + }, + { + desc: "Governance transaction bypasses high min gas prices", + fromAddr: govAddr, + fee: sdk.NewCoins(), + gas: 50000, + minGasPrices: sdk.NewDecCoins(sdk.NewDecCoinFromDec(parametertypes.Elys, sdkmath.LegacyNewDec(1000))), + isCheckTx: true, + expectError: false, + expectedPriority: math.MaxInt64, // Should get max priority + }, + { + desc: "Non-CheckTx mode doesn't validate fees", + fromAddr: regularAddr, + fee: sdk.NewCoins(sdk.NewCoin(parametertypes.Elys, sdkmath.NewInt(10000))), + gas: 50000, + minGasPrices: sdk.NewDecCoins(sdk.NewDecCoinFromDec(parametertypes.Elys, sdkmath.LegacyNewDec(1))), + isCheckTx: false, + expectError: false, + expectedPriority: 0, // 10000/50000 = 0 (rounded down) + }, + { + desc: "Multiple coin fees - minimum priority used", + fromAddr: regularAddr, + fee: sdk.NewCoins( + sdk.NewCoin(parametertypes.Elys, sdkmath.NewInt(50000)), + sdk.NewCoin("uusdc", sdkmath.NewInt(25000)), + ), + gas: 50000, + minGasPrices: sdk.NewDecCoins(sdk.NewDecCoinFromDec(parametertypes.Elys, sdkmath.LegacyNewDec(1))), + isCheckTx: true, + expectError: false, + expectedPriority: 0, // min(50000/50000, 25000/50000) = min(1, 0) = 0 + }, + } { + t.Run(tc.desc, func(t *testing.T) { + ctx := sdk.NewContext(nil, cmtproto.Header{}, false, log.NewNopLogger()) + ctx = ctx.WithIsCheckTx(tc.isCheckTx).WithMinGasPrices(tc.minGasPrices) + tx := createTestTx(tc.fromAddr, tc.fee, tc.gas) + + resultFee, priority, err := CheckTxFeeWithValidatorMinGasPrices(ctx, tx) + + if tc.expectError { + require.Error(t, err) + require.Contains(t, err.Error(), tc.errorContains) + } else { + require.NoError(t, err) + require.Equal(t, tc.fee, resultFee) + require.Equal(t, tc.expectedPriority, priority) + } + }) + } +} + +func TestCheckTxFeeWithInvalidTransaction(t *testing.T) { + ctx := sdk.NewContext(nil, cmtproto.Header{}, false, log.NewNopLogger()) + + invalidTx := &MockInvalidTx{} + + _, _, err := CheckTxFeeWithValidatorMinGasPrices(ctx, invalidTx) + + require.Error(t, err) + require.Contains(t, err.Error(), "tx must implement FeeTx") +} + +func TestGaslessTransactionPriorityComparison(t *testing.T) { + // Test that governance transactions have higher priority than regular transactions + ctx := sdk.NewContext(nil, cmtproto.Header{}, false, log.NewNopLogger()) + ctx = ctx.WithIsCheckTx(true).WithMinGasPrices( + sdk.NewDecCoins(sdk.NewDecCoinFromDec(parametertypes.Elys, sdkmath.LegacyNewDec(1))), + ) + + // Create addresses + regularAddr := sdk.AccAddress("regular_address____") + govAddr := sdk.MustAccAddressFromBech32(GaslessAddrs[0]) + + // Regular transaction with very high fees + regularTx := createTestTx(regularAddr, sdk.NewCoins(sdk.NewCoin(parametertypes.Elys, sdkmath.NewInt(1000000))), 50000) + + // Governance transaction with no fees (simulating feegrant usage) + govTx := createTestTx(govAddr, sdk.NewCoins(), 50000) + + // Check regular transaction priority + _, regularPriority, err := CheckTxFeeWithValidatorMinGasPrices(ctx, regularTx) + require.NoError(t, err) + + // Check governance transaction priority + _, govPriority, err := CheckTxFeeWithValidatorMinGasPrices(ctx, govTx) + require.NoError(t, err) + + // Governance should have higher priority + require.Greater(t, govPriority, regularPriority) + require.Equal(t, int64(math.MaxInt64), govPriority) +} + +func TestGetTxPriority(t *testing.T) { + for _, tc := range []struct { + desc string + fee sdk.Coins + gas int64 + expectedPriority int64 + }{ + { + desc: "Single coin fee", + fee: sdk.NewCoins(sdk.NewCoin(parametertypes.Elys, sdkmath.NewInt(100000))), + gas: 50000, + expectedPriority: 2, // 100000/50000 = 2 + }, + { + desc: "Multiple coin fees - minimum used", + fee: sdk.NewCoins( + sdk.NewCoin(parametertypes.Elys, sdkmath.NewInt(100000)), + sdk.NewCoin("uusdc", sdkmath.NewInt(25000)), + ), + gas: 50000, + expectedPriority: 0, // min(100000/50000, 25000/50000) = min(2, 0) = 0 + }, + { + desc: "Zero fees", + fee: sdk.NewCoins(), + gas: 50000, + expectedPriority: 0, + }, + { + desc: "High gas price", + fee: sdk.NewCoins(sdk.NewCoin(parametertypes.Elys, sdkmath.NewInt(1000000000))), + gas: 50000, + expectedPriority: 20000, // 1000000000/50000 = 20000 + }, + } { + t.Run(tc.desc, func(t *testing.T) { + priority := getTxPriority(tc.fee, tc.gas) + require.Equal(t, tc.expectedPriority, priority) + }) + } +}