|
| 1 | +package ante_test |
| 2 | + |
| 3 | +import ( |
| 4 | + "context" |
| 5 | + "errors" |
| 6 | + "math" |
| 7 | + "testing" |
| 8 | + |
| 9 | + codectypes "github.com/cosmos/cosmos-sdk/codec/types" |
| 10 | + sdk "github.com/cosmos/cosmos-sdk/types" |
| 11 | + sdkerrors "github.com/cosmos/cosmos-sdk/types/errors" |
| 12 | + "github.com/cosmos/cosmos-sdk/x/authz" |
| 13 | + banktypes "github.com/cosmos/cosmos-sdk/x/bank/types" |
| 14 | + "github.com/stretchr/testify/require" |
| 15 | + |
| 16 | + "github.com/pushchain/push-chain-node/app/ante" |
| 17 | + uexecutortypes "github.com/pushchain/push-chain-node/x/uexecutor/types" |
| 18 | +) |
| 19 | + |
| 20 | +// --------------------------------------------------------------------------- |
| 21 | +// GaslessGasLimitDecorator — F-2026-18144 |
| 22 | +// |
| 23 | +// Gasless txs pay no fee, so the fee is not a bound on the gas they declare, |
| 24 | +// and the declared gas is what accumulates into the block's cumulative gas |
| 25 | +// wanted. These tests pin the cap that replaces the missing economic bound. |
| 26 | +// --------------------------------------------------------------------------- |
| 27 | + |
| 28 | +// mockGaslessParamsKeeper satisfies ante.GaslessParamsKeeper. |
| 29 | +type mockGaslessParamsKeeper struct { |
| 30 | + params uexecutortypes.Params |
| 31 | + err error |
| 32 | + calls int |
| 33 | +} |
| 34 | + |
| 35 | +func (m *mockGaslessParamsKeeper) GetParams(_ context.Context) (uexecutortypes.Params, error) { |
| 36 | + m.calls++ |
| 37 | + if m.err != nil { |
| 38 | + return uexecutortypes.Params{}, m.err |
| 39 | + } |
| 40 | + return m.params, nil |
| 41 | +} |
| 42 | + |
| 43 | +func paramsWithCap(cap uint64) *mockGaslessParamsKeeper { |
| 44 | + return &mockGaslessParamsKeeper{params: uexecutortypes.Params{SomeValue: true, MaxGaslessTxGas: cap}} |
| 45 | +} |
| 46 | + |
| 47 | +// gaslessTx returns a tx whose only msg is on the IsGaslessTx allowlist. |
| 48 | +func gaslessTx(gas uint64) mockFeeTx { |
| 49 | + return mockFeeTx{ |
| 50 | + msgs: []sdk.Msg{&uexecutortypes.MsgVoteInbound{}}, |
| 51 | + gas: gas, |
| 52 | + fee: sdk.NewCoins(), |
| 53 | + feePayer: sdk.AccAddress([]byte("payer")), |
| 54 | + } |
| 55 | +} |
| 56 | + |
| 57 | +// runDecorator returns (nextCalled, err). |
| 58 | +func runDecorator(t *testing.T, pk ante.GaslessParamsKeeper, tx sdk.Tx, simulate bool) (bool, error) { |
| 59 | + t.Helper() |
| 60 | + ggd := ante.NewGaslessGasLimitDecorator(pk) |
| 61 | + ctx := newAnteTestCtx(t, false) |
| 62 | + nextCalled := false |
| 63 | + _, err := ggd.AnteHandle(ctx, tx, simulate, func(ctx sdk.Context, tx sdk.Tx, simulate bool) (sdk.Context, error) { |
| 64 | + nextCalled = true |
| 65 | + return ctx, nil |
| 66 | + }) |
| 67 | + return nextCalled, err |
| 68 | +} |
| 69 | + |
| 70 | +// TestGaslessGasLimit_AboveDefaultCapRejected is the core regression: a gasless |
| 71 | +// tx declaring more than the cap must not reach the rest of the ante chain, so |
| 72 | +// its declared gas is never added to the block's cumulative gas wanted. |
| 73 | +func TestGaslessGasLimit_AboveDefaultCapRejected(t *testing.T) { |
| 74 | + pk := paramsWithCap(uexecutortypes.DefaultMaxGaslessTxGas) |
| 75 | + |
| 76 | + nextCalled, err := runDecorator(t, pk, gaslessTx(uexecutortypes.DefaultMaxGaslessTxGas+1), false) |
| 77 | + |
| 78 | + require.False(t, nextCalled, "over-cap gasless tx must not reach the next decorator") |
| 79 | + require.Error(t, err) |
| 80 | + require.True(t, sdkerrors.ErrInvalidGasLimit.Is(err), "expected ErrInvalidGasLimit, got: %v", err) |
| 81 | + require.Contains(t, err.Error(), "100000001") |
| 82 | + require.Contains(t, err.Error(), "100000000") |
| 83 | +} |
| 84 | + |
| 85 | +// TestGaslessGasLimit_AtCapAccepted pins the boundary: exactly the cap passes. |
| 86 | +func TestGaslessGasLimit_AtCapAccepted(t *testing.T) { |
| 87 | + pk := paramsWithCap(uexecutortypes.DefaultMaxGaslessTxGas) |
| 88 | + |
| 89 | + nextCalled, err := runDecorator(t, pk, gaslessTx(uexecutortypes.DefaultMaxGaslessTxGas), false) |
| 90 | + |
| 91 | + require.True(t, nextCalled, "gasless tx at exactly the cap must be accepted") |
| 92 | + require.NoError(t, err) |
| 93 | +} |
| 94 | + |
| 95 | +// TestGaslessGasLimit_BelowCapAccepted covers the ordinary case. |
| 96 | +func TestGaslessGasLimit_BelowCapAccepted(t *testing.T) { |
| 97 | + pk := paramsWithCap(uexecutortypes.DefaultMaxGaslessTxGas) |
| 98 | + |
| 99 | + nextCalled, err := runDecorator(t, pk, gaslessTx(200_000), false) |
| 100 | + |
| 101 | + require.True(t, nextCalled) |
| 102 | + require.NoError(t, err) |
| 103 | +} |
| 104 | + |
| 105 | +// TestGaslessGasLimit_MaxInt64Rejected is the shape from the finding: two txs |
| 106 | +// each declaring MaxInt64 sum past what the fee market EndBlock can convert. |
| 107 | +func TestGaslessGasLimit_MaxInt64Rejected(t *testing.T) { |
| 108 | + pk := paramsWithCap(uexecutortypes.DefaultMaxGaslessTxGas) |
| 109 | + |
| 110 | + nextCalled, err := runDecorator(t, pk, gaslessTx(math.MaxInt64), false) |
| 111 | + |
| 112 | + require.False(t, nextCalled, "MaxInt64 gasless tx must not reach the next decorator") |
| 113 | + require.Error(t, err) |
| 114 | + require.True(t, sdkerrors.ErrInvalidGasLimit.Is(err), "expected ErrInvalidGasLimit, got: %v", err) |
| 115 | +} |
| 116 | + |
| 117 | +// TestGaslessGasLimit_NonGaslessTxNotCapped proves the cap is scoped to |
| 118 | +// fee-exempt txs: a fee-paying tx is bounded by its own fee, not by this cap, |
| 119 | +// and the params are not even read for it. |
| 120 | +func TestGaslessGasLimit_NonGaslessTxNotCapped(t *testing.T) { |
| 121 | + pk := paramsWithCap(uexecutortypes.DefaultMaxGaslessTxGas) |
| 122 | + |
| 123 | + tx := mockFeeTx{ |
| 124 | + msgs: []sdk.Msg{&banktypes.MsgSend{}}, |
| 125 | + gas: math.MaxInt64, |
| 126 | + fee: sdk.NewCoins(sdk.NewInt64Coin("upc", 1)), |
| 127 | + feePayer: sdk.AccAddress([]byte("payer")), |
| 128 | + } |
| 129 | + |
| 130 | + nextCalled, err := runDecorator(t, pk, tx, false) |
| 131 | + |
| 132 | + require.True(t, nextCalled, "fee-paying tx must not be capped here") |
| 133 | + require.Equal(t, 0, pk.calls, "params must not be read for a non-gasless tx") |
| 134 | + require.NoError(t, err) |
| 135 | +} |
| 136 | + |
| 137 | +// TestGaslessGasLimit_AuthzExecVoteShape uses the exact wire shape the universal |
| 138 | +// validators send: an authz.MsgExec wrapping a vote. This is what declared the |
| 139 | +// hardcoded 500,000,000 on donut. |
| 140 | +func TestGaslessGasLimit_AuthzExecVoteShape(t *testing.T) { |
| 141 | + inner, err := codectypes.NewAnyWithValue(&uexecutortypes.MsgVoteInbound{}) |
| 142 | + require.NoError(t, err) |
| 143 | + |
| 144 | + execTx := func(gas uint64) mockFeeTx { |
| 145 | + return mockFeeTx{ |
| 146 | + msgs: []sdk.Msg{&authz.MsgExec{Grantee: "push1grantee", Msgs: []*codectypes.Any{inner}}}, |
| 147 | + gas: gas, |
| 148 | + fee: sdk.NewCoins(), |
| 149 | + feePayer: sdk.AccAddress([]byte("payer")), |
| 150 | + } |
| 151 | + } |
| 152 | + |
| 153 | + t.Run("500M vote is rejected", func(t *testing.T) { |
| 154 | + pk := paramsWithCap(uexecutortypes.DefaultMaxGaslessTxGas) |
| 155 | + |
| 156 | + nextCalled, err := runDecorator(t, pk, execTx(500_000_000), false) |
| 157 | + |
| 158 | + require.False(t, nextCalled, "500M MsgExec vote must not reach the next decorator") |
| 159 | + require.Error(t, err) |
| 160 | + require.True(t, sdkerrors.ErrInvalidGasLimit.Is(err), "expected ErrInvalidGasLimit, got: %v", err) |
| 161 | + }) |
| 162 | + |
| 163 | + t.Run("100M vote is accepted", func(t *testing.T) { |
| 164 | + pk := paramsWithCap(uexecutortypes.DefaultMaxGaslessTxGas) |
| 165 | + |
| 166 | + nextCalled, err := runDecorator(t, pk, execTx(100_000_000), false) |
| 167 | + |
| 168 | + require.True(t, nextCalled, "100M MsgExec vote must be accepted") |
| 169 | + require.NoError(t, err) |
| 170 | + }) |
| 171 | +} |
| 172 | + |
| 173 | +// TestGaslessGasLimit_GovernanceParamTakesEffect proves the cap is the |
| 174 | +// governance parameter and not a compiled-in constant: it must bind both |
| 175 | +// tighter and looser than the default. |
| 176 | +func TestGaslessGasLimit_GovernanceParamTakesEffect(t *testing.T) { |
| 177 | + t.Run("lowered cap binds below the default", func(t *testing.T) { |
| 178 | + pk := paramsWithCap(30_000_000) |
| 179 | + |
| 180 | + acceptedNext, acceptedErr := runDecorator(t, pk, gaslessTx(30_000_000), false) |
| 181 | + require.True(t, acceptedNext, "tx at the lowered cap must be accepted") |
| 182 | + require.NoError(t, acceptedErr) |
| 183 | + |
| 184 | + rejectedNext, rejectedErr := runDecorator(t, pk, gaslessTx(30_000_001), false) |
| 185 | + require.False(t, rejectedNext, "tx above the lowered cap must be rejected") |
| 186 | + require.Error(t, rejectedErr) |
| 187 | + require.Contains(t, rejectedErr.Error(), "30000000") |
| 188 | + }) |
| 189 | + |
| 190 | + t.Run("raised cap admits gas the default would reject", func(t *testing.T) { |
| 191 | + pk := paramsWithCap(500_000_000) |
| 192 | + |
| 193 | + nextCalled, err := runDecorator(t, pk, gaslessTx(400_000_000), false) |
| 194 | + |
| 195 | + require.True(t, nextCalled, "raised cap must admit 400M") |
| 196 | + require.NoError(t, err) |
| 197 | + }) |
| 198 | +} |
| 199 | + |
| 200 | +// TestGaslessGasLimit_UnsetParamFallsBackToDefault: a missing parameter must |
| 201 | +// never mean "no cap". |
| 202 | +func TestGaslessGasLimit_UnsetParamFallsBackToDefault(t *testing.T) { |
| 203 | + t.Run("zero param", func(t *testing.T) { |
| 204 | + pk := paramsWithCap(0) |
| 205 | + |
| 206 | + acceptedNext, acceptedErr := runDecorator(t, pk, gaslessTx(uexecutortypes.DefaultMaxGaslessTxGas), false) |
| 207 | + require.True(t, acceptedNext) |
| 208 | + require.NoError(t, acceptedErr) |
| 209 | + |
| 210 | + rejectedNext, rejectedErr := runDecorator(t, pk, gaslessTx(uexecutortypes.DefaultMaxGaslessTxGas+1), false) |
| 211 | + require.False(t, rejectedNext, "zero param must fall back to the default cap, not disable it") |
| 212 | + require.Error(t, rejectedErr) |
| 213 | + }) |
| 214 | + |
| 215 | + t.Run("params read failure", func(t *testing.T) { |
| 216 | + pk := &mockGaslessParamsKeeper{err: errors.New("collections: not found")} |
| 217 | + |
| 218 | + nextCalled, err := runDecorator(t, pk, gaslessTx(uexecutortypes.DefaultMaxGaslessTxGas+1), false) |
| 219 | + |
| 220 | + require.False(t, nextCalled, "unreadable params must fall back to the default cap, not disable it") |
| 221 | + require.Error(t, err) |
| 222 | + require.True(t, sdkerrors.ErrInvalidGasLimit.Is(err), "expected ErrInvalidGasLimit, got: %v", err) |
| 223 | + }) |
| 224 | +} |
| 225 | + |
| 226 | +// TestGaslessGasLimit_SimulationIsAlsoCapped keeps simulation honest: a gas |
| 227 | +// estimate that would be rejected on delivery must not come back clean. |
| 228 | +func TestGaslessGasLimit_SimulationIsAlsoCapped(t *testing.T) { |
| 229 | + pk := paramsWithCap(uexecutortypes.DefaultMaxGaslessTxGas) |
| 230 | + |
| 231 | + nextCalled, err := runDecorator(t, pk, gaslessTx(uexecutortypes.DefaultMaxGaslessTxGas+1), true) |
| 232 | + |
| 233 | + require.False(t, nextCalled, "simulation must apply the same cap") |
| 234 | + require.Error(t, err) |
| 235 | +} |
0 commit comments