Skip to content

Commit a5aead7

Browse files
authored
fix: cap the gas a gasless tx may declare, as a governance parameter (F-2026-18144) (#350)
Fee-paying txs are self-limiting: the ante handler requires ceil(minGasPrice * gasLimit), so an absurd gas limit costs absurd money. Gasless txs pay nothing, so nothing bounded the gas they declared, while that declared gas was still added to the fee market's cumulative gas wanted for the block. Under max_gas: -1 the per-tx block-limit check is inert, so two gasless txs each declaring MaxInt64 sum past what EndBlock can convert to int64 - an error that surfaces through FinalizeBlock after the block is decided. New uexecutor param max_gasless_tx_gas (default 100,000,000), enforced by GaslessGasLimitDecorator on the existing txpolicy.IsGaslessTx predicate, before NewGasWantedDecorator accumulates the declaration. A parameter and not a constant because "too low" stops the universal validators voting and must be fixable by proposal in minutes. Zero is rejected at genesis and on update, and an unreadable or unset parameter falls back to the default rather than to "no cap". universalClient/pushsigner declares 100,000,000 instead of 500,000,000. Live donut data: gas_wanted median/max 500,000,000 against a gas_used max of 5,011,877 - the largest real consumer used 1.0024% of what it declared.
1 parent f96eed0 commit a5aead7

17 files changed

Lines changed: 1287 additions & 436 deletions

File tree

api/uexecutor/v1/types.pulsar.go

Lines changed: 373 additions & 309 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

app/ante/ante_cosmos.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,10 @@ func NewCosmosAnteHandler(ctx sdk.Context, options HandlerOptions) sdk.AnteHandl
3737
),
3838

3939
ante.NewSetUpContextDecorator(),
40+
// Gasless txs pay no fee, so the fee is not a bound on the gas they
41+
// declare. Cap it explicitly, before NewGasWantedDecorator adds the
42+
// declared gas to the block's cumulative gas wanted.
43+
NewGaslessGasLimitDecorator(options.UexecutorKeeper),
4044
wasmkeeper.NewLimitSimulationGasDecorator(options.WasmConfig.SimulationGasLimit), // after setup context to enforce limits early
4145
wasmkeeper.NewCountTXDecorator(options.TXCounterStoreService),
4246
wasmkeeper.NewGasRegisterDecorator(options.WasmKeeper.GetGasRegister()),

app/ante/gasless_gas_limit.go

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
package ante
2+
3+
import (
4+
"context"
5+
6+
errorsmod "cosmossdk.io/errors"
7+
8+
sdk "github.com/cosmos/cosmos-sdk/types"
9+
sdkerrors "github.com/cosmos/cosmos-sdk/types/errors"
10+
txpolicy "github.com/pushchain/push-chain-node/app/txpolicy"
11+
uexecutortypes "github.com/pushchain/push-chain-node/x/uexecutor/types"
12+
)
13+
14+
// GaslessParamsKeeper reads the module parameters that bound fee-exempt txs.
15+
type GaslessParamsKeeper interface {
16+
GetParams(ctx context.Context) (uexecutortypes.Params, error)
17+
}
18+
19+
// GaslessGasLimitDecorator caps the gas limit a fee-exempt (gasless) tx may
20+
// declare.
21+
//
22+
// A fee-paying tx is bounded by its own fee: the ante handler requires
23+
// ceil(minGasPrice * gasLimit), so an absurd gas limit costs an absurd amount
24+
// of tokens. A gasless tx pays nothing, so nothing bounds the gas it declares
25+
// while that declared gas is still added to the block's cumulative gas wanted.
26+
// Enough of them, or few enough with a large enough declaration, push the
27+
// cumulative total past what the fee market can represent.
28+
//
29+
// CONTRACT: must run before the EVM GasWantedDecorator, which is what
30+
// accumulates the declared gas into the fee market transient store.
31+
type GaslessGasLimitDecorator struct {
32+
paramsKeeper GaslessParamsKeeper
33+
}
34+
35+
func NewGaslessGasLimitDecorator(pk GaslessParamsKeeper) GaslessGasLimitDecorator {
36+
return GaslessGasLimitDecorator{paramsKeeper: pk}
37+
}
38+
39+
func (ggd GaslessGasLimitDecorator) AnteHandle(ctx sdk.Context, tx sdk.Tx, simulate bool, next sdk.AnteHandler) (sdk.Context, error) {
40+
if !txpolicy.IsGaslessTx(tx) {
41+
return next(ctx, tx, simulate)
42+
}
43+
44+
feeTx, ok := tx.(sdk.FeeTx)
45+
if !ok {
46+
return ctx, errorsmod.Wrap(sdkerrors.ErrTxDecode, "Tx must be a FeeTx")
47+
}
48+
49+
maxGas := ggd.maxGaslessTxGas(ctx)
50+
if gas := feeTx.GetGas(); gas > maxGas {
51+
ctx.Logger().Debug("gasless gas limit decorator: declared gas over cap",
52+
"gas", gas,
53+
"max_gas", maxGas,
54+
)
55+
return ctx, errorsmod.Wrapf(sdkerrors.ErrInvalidGasLimit,
56+
"gasless tx gas limit %d exceeds the maximum allowed %d", gas, maxGas)
57+
}
58+
59+
return next(ctx, tx, simulate)
60+
}
61+
62+
// maxGaslessTxGas resolves the governance-controlled cap, falling back to the
63+
// default when it cannot be read or was never set. The fallback is deliberate:
64+
// a missing parameter must not mean "no cap".
65+
func (ggd GaslessGasLimitDecorator) maxGaslessTxGas(ctx sdk.Context) uint64 {
66+
params, err := ggd.paramsKeeper.GetParams(ctx)
67+
if err != nil {
68+
ctx.Logger().Error("gasless gas limit decorator: failed to read uexecutor params, using default cap",
69+
"error", err,
70+
)
71+
return uexecutortypes.DefaultMaxGaslessTxGas
72+
}
73+
74+
if params.MaxGaslessTxGas == 0 {
75+
return uexecutortypes.DefaultMaxGaslessTxGas
76+
}
77+
78+
return params.MaxGaslessTxGas
79+
}

app/ante/gasless_gas_limit_test.go

Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
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+
}

app/ante/handler_options.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,10 @@ type HandlerOptions struct {
6161
FeeMarketKeeper anteinterfaces.FeeMarketKeeper
6262
EvmKeeper anteinterfaces.EVMKeeper
6363

64+
// UexecutorKeeper supplies the governance-controlled cap on the gas a
65+
// fee-exempt (gasless) tx may declare.
66+
UexecutorKeeper GaslessParamsKeeper
67+
6468

6569
IBCKeeper *ibckeeper.Keeper
6670
CircuitKeeper *circuitkeeper.Keeper
@@ -103,6 +107,9 @@ func (options HandlerOptions) Validate() error {
103107
if options.EvmKeeper == nil {
104108
return errorsmod.Wrap(errortypes.ErrLogic, "evm keeper is required for AnteHandler")
105109
}
110+
if options.UexecutorKeeper == nil {
111+
return errorsmod.Wrap(errortypes.ErrLogic, "uexecutor keeper is required for AnteHandler")
112+
}
106113

107114
return nil
108115
}

app/app.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1251,6 +1251,7 @@ func NewChainApp(
12511251
CircuitKeeper: &app.CircuitKeeper,
12521252

12531253
EvmKeeper: app.EVMKeeper,
1254+
UexecutorKeeper: app.UexecutorKeeper,
12541255
ExtensionOptionChecker: antetypes.HasDynamicFeeExtensionOption,
12551256
SigGasConsumer: cosmosevmante.SigVerificationGasConsumer,
12561257
MaxTxGasWanted: cast.ToUint64(appOpts.Get(srvflags.EVMMaxTxGasWanted)),

proto/uexecutor/v1/types.proto

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,13 @@ message Params {
1313
option (gogoproto.goproto_stringer) = false;
1414

1515
bool some_value = 2;
16+
17+
// max_gasless_tx_gas is the maximum gas limit a fee-exempt (gasless)
18+
// transaction is allowed to declare. Gasless transactions pay no fee, so
19+
// their declared gas is not bounded by anything the sender has to spend;
20+
// this cap is the only bound on how much a single gasless transaction can
21+
// contribute to the block's cumulative gas wanted.
22+
uint64 max_gasless_tx_gas = 3;
1623
}
1724

1825
// Signature verification types

0 commit comments

Comments
 (0)