Skip to content

Commit 1974284

Browse files
Manav-Aggarwalclauderootulp
authored
refactor(feeaddress): improve validation separation and add docs (#6464)
## Summary Addresses review comments from #6441: - Move fee/gas validation from ProcessProposal to ante handler (`ProtocolFeeTerminatorDecorator`) - Ante validates: fee == balance, gas == ProtocolFeeGasLimit - ProcessProposal only validates block structure (tx presence/absence) - Add `pkg/feeaddress/README.md` with architecture, consensus safety, threat model - Add `IsProtocolFeeTxBytes` helper, remove `parseProtocolFeeTx` and `validateProtocolFeeTx` - Refactor tests: remove nested ifs, split into focused functions ## Test plan - [x] `go build ./...` passes - [x] `go test -run Protocol ./app/...` passes - [x] `golangci-lint run ./app/... ./pkg/feeaddress/...` passes 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> Co-authored-by: Rootul P <rootulp@gmail.com>
1 parent 247d026 commit 1974284

File tree

9 files changed

+442
-293
lines changed

9 files changed

+442
-293
lines changed

app/ante/ante.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,14 @@ import (
99
minfeekeeper "github.com/celestiaorg/celestia-app/v7/x/minfee/keeper"
1010
sdk "github.com/cosmos/cosmos-sdk/types"
1111
"github.com/cosmos/cosmos-sdk/x/auth/ante"
12-
authtypes "github.com/cosmos/cosmos-sdk/x/auth/types"
12+
bankkeeper "github.com/cosmos/cosmos-sdk/x/bank/keeper"
1313
ibcante "github.com/cosmos/ibc-go/v8/modules/core/ante"
1414
ibckeeper "github.com/cosmos/ibc-go/v8/modules/core/keeper"
1515
)
1616

1717
func NewAnteHandler(
1818
accountKeeper ante.AccountKeeper,
19-
bankKeeper authtypes.BankKeeper,
19+
bankKeeper bankkeeper.Keeper,
2020
blobKeeper blob.Keeper,
2121
feegrantKeeper ante.FeegrantKeeper,
2222
signModeHandler *txsigning.HandlerMap,

app/ante/protocol_fee.go

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
package ante
22

33
import (
4+
"fmt"
5+
46
"cosmossdk.io/errors"
7+
"github.com/celestiaorg/celestia-app/v7/pkg/appconsts"
58
"github.com/celestiaorg/celestia-app/v7/pkg/feeaddress"
69
sdk "github.com/cosmos/cosmos-sdk/types"
710
sdkerrors "github.com/cosmos/cosmos-sdk/types/errors"
@@ -15,9 +18,10 @@ import (
1518
//
1619
// For MsgPayProtocolFee transactions, this decorator:
1720
// 1. Rejects user submissions (only valid when protocol-injected)
18-
// 2. Validates the fee format
19-
// 3. Transfers the fee from fee address to fee collector
20-
// 4. Returns without calling next() - skipping the rest of the ante chain
21+
// 2. Validates fee == fee address balance (ensures ALL funds are forwarded)
22+
// 3. Validates gas == ProtocolFeeGasLimit
23+
// 4. Transfers the fee from fee address to fee collector
24+
// 5. Returns without calling next() - skipping the rest of the ante chain
2125
//
2226
// For all other transactions, this decorator simply calls next().
2327
type ProtocolFeeTerminatorDecorator struct {
@@ -53,10 +57,20 @@ func (d ProtocolFeeTerminatorDecorator) AnteHandle(ctx sdk.Context, tx sdk.Tx, s
5357
}
5458
fee := feeTx.GetFee()
5559

56-
if err := feeaddress.ValidateProtocolFee(fee, nil); err != nil {
60+
// Get current fee address balance - the fee MUST equal this balance exactly.
61+
// This ensures ALL accumulated funds are forwarded to validators, preventing
62+
// a malicious proposer from only forwarding a partial amount.
63+
feeAddressBalance := d.bankKeeper.GetBalance(ctx, feeaddress.FeeAddress, appconsts.BondDenom)
64+
if err := feeaddress.ValidateProtocolFee(fee, &feeAddressBalance); err != nil {
5765
return ctx, errors.Wrap(sdkerrors.ErrInvalidRequest, err.Error())
5866
}
5967

68+
// Validate gas limit matches expected constant
69+
if feeTx.GetGas() != feeaddress.ProtocolFeeGasLimit {
70+
return ctx, errors.Wrap(sdkerrors.ErrInvalidRequest,
71+
fmt.Sprintf("gas limit %d does not match expected %d", feeTx.GetGas(), feeaddress.ProtocolFeeGasLimit))
72+
}
73+
6074
err := d.bankKeeper.SendCoinsFromAccountToModule(ctx, feeaddress.FeeAddress, authtypes.FeeCollectorName, fee)
6175
if err != nil {
6276
return ctx, errors.Wrap(err, "failed to deduct fee from fee address")

app/ante/protocol_fee_test.go

Lines changed: 81 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,11 @@ func (m *protoFeeMockFeeTx) FeeGranter() []byte { return nil
3535
// protoFeeMockBankKeeper implements feeaddress.ProtocolFeeBankKeeper for testing.
3636
type protoFeeMockBankKeeper struct {
3737
sentToModule map[string]sdk.Coins
38+
balance sdk.Coin // balance to return from GetBalance
39+
}
40+
41+
func (m *protoFeeMockBankKeeper) GetBalance(_ context.Context, _ sdk.AccAddress, _ string) sdk.Coin {
42+
return m.balance
3843
}
3944

4045
func (m *protoFeeMockBankKeeper) SendCoinsFromAccountToModule(_ context.Context, _ sdk.AccAddress, recipientModule string, amt sdk.Coins) error {
@@ -56,7 +61,12 @@ func (m *protoFeeMockNonFeeTx) ValidateBasic() error { return n
5661

5762
// protoFeeMockBankKeeperWithError implements feeaddress.ProtocolFeeBankKeeper and returns an error.
5863
type protoFeeMockBankKeeperWithError struct {
59-
err error
64+
err error
65+
balance sdk.Coin
66+
}
67+
68+
func (m *protoFeeMockBankKeeperWithError) GetBalance(_ context.Context, _ sdk.AccAddress, _ string) sdk.Coin {
69+
return m.balance
6070
}
6171

6272
func (m *protoFeeMockBankKeeperWithError) SendCoinsFromAccountToModule(_ context.Context, _ sdk.AccAddress, _ string, _ sdk.Coins) error {
@@ -68,12 +78,13 @@ func protoFeeNextAnteHandler(ctx sdk.Context, _ sdk.Tx, _ bool) (sdk.Context, er
6878
}
6979

7080
func TestProtocolFeeTerminatorRejectsUserSubmittedTx(t *testing.T) {
71-
bankKeeper := &protoFeeMockBankKeeper{}
81+
balance := sdk.NewCoin(appconsts.BondDenom, math.NewInt(1000))
82+
bankKeeper := &protoFeeMockBankKeeper{balance: balance}
7283
decorator := ante.NewProtocolFeeTerminatorDecorator(bankKeeper)
7384

7485
msg := feeaddress.NewMsgPayProtocolFee()
75-
fee := sdk.NewCoins(sdk.NewCoin(appconsts.BondDenom, math.NewInt(1000)))
76-
tx := &protoFeeMockFeeTx{msgs: []sdk.Msg{msg}, fee: fee, gas: 50000}
86+
fee := sdk.NewCoins(balance)
87+
tx := &protoFeeMockFeeTx{msgs: []sdk.Msg{msg}, fee: fee, gas: feeaddress.ProtocolFeeGasLimit}
7788

7889
// Create CheckTx context - this simulates a user submitting the tx
7990
ctx := sdk.NewContext(nil, tmproto.Header{}, true, log.NewNopLogger()) // isCheckTx = true
@@ -85,12 +96,13 @@ func TestProtocolFeeTerminatorRejectsUserSubmittedTx(t *testing.T) {
8596
}
8697

8798
func TestProtocolFeeTerminatorRejectsReCheckTx(t *testing.T) {
88-
bankKeeper := &protoFeeMockBankKeeper{}
99+
balance := sdk.NewCoin(appconsts.BondDenom, math.NewInt(1000))
100+
bankKeeper := &protoFeeMockBankKeeper{balance: balance}
89101
decorator := ante.NewProtocolFeeTerminatorDecorator(bankKeeper)
90102

91103
msg := feeaddress.NewMsgPayProtocolFee()
92-
fee := sdk.NewCoins(sdk.NewCoin(appconsts.BondDenom, math.NewInt(1000)))
93-
tx := &protoFeeMockFeeTx{msgs: []sdk.Msg{msg}, fee: fee, gas: 50000}
104+
fee := sdk.NewCoins(balance)
105+
tx := &protoFeeMockFeeTx{msgs: []sdk.Msg{msg}, fee: fee, gas: feeaddress.ProtocolFeeGasLimit}
94106

95107
// Create ReCheckTx context
96108
ctx := sdk.NewContext(nil, tmproto.Header{}, true, log.NewNopLogger()).WithIsReCheckTx(true)
@@ -102,7 +114,8 @@ func TestProtocolFeeTerminatorRejectsReCheckTx(t *testing.T) {
102114
}
103115

104116
func TestProtocolFeeTerminatorValidatesSingleDenom(t *testing.T) {
105-
bankKeeper := &protoFeeMockBankKeeper{}
117+
balance := sdk.NewCoin(appconsts.BondDenom, math.NewInt(1000))
118+
bankKeeper := &protoFeeMockBankKeeper{balance: balance}
106119
decorator := ante.NewProtocolFeeTerminatorDecorator(bankKeeper)
107120

108121
msg := feeaddress.NewMsgPayProtocolFee()
@@ -111,7 +124,7 @@ func TestProtocolFeeTerminatorValidatesSingleDenom(t *testing.T) {
111124
sdk.NewCoin(appconsts.BondDenom, math.NewInt(1000)),
112125
sdk.NewCoin("otherdenom", math.NewInt(500)),
113126
)
114-
tx := &protoFeeMockFeeTx{msgs: []sdk.Msg{msg}, fee: fee, gas: 50000}
127+
tx := &protoFeeMockFeeTx{msgs: []sdk.Msg{msg}, fee: fee, gas: feeaddress.ProtocolFeeGasLimit}
115128

116129
// Create DeliverTx context (not CheckTx)
117130
ctx := sdk.NewContext(nil, tmproto.Header{}, false, log.NewNopLogger())
@@ -123,13 +136,14 @@ func TestProtocolFeeTerminatorValidatesSingleDenom(t *testing.T) {
123136
}
124137

125138
func TestProtocolFeeTerminatorRejectsWrongDenom(t *testing.T) {
126-
bankKeeper := &protoFeeMockBankKeeper{}
139+
balance := sdk.NewCoin(appconsts.BondDenom, math.NewInt(1000))
140+
bankKeeper := &protoFeeMockBankKeeper{balance: balance}
127141
decorator := ante.NewProtocolFeeTerminatorDecorator(bankKeeper)
128142

129143
msg := feeaddress.NewMsgPayProtocolFee()
130144
// Wrong denom - should be rejected
131145
fee := sdk.NewCoins(sdk.NewCoin("wrongdenom", math.NewInt(1000)))
132-
tx := &protoFeeMockFeeTx{msgs: []sdk.Msg{msg}, fee: fee, gas: 50000}
146+
tx := &protoFeeMockFeeTx{msgs: []sdk.Msg{msg}, fee: fee, gas: feeaddress.ProtocolFeeGasLimit}
133147

134148
// Create DeliverTx context (not CheckTx)
135149
ctx := sdk.NewContext(nil, tmproto.Header{}, false, log.NewNopLogger())
@@ -141,12 +155,13 @@ func TestProtocolFeeTerminatorRejectsWrongDenom(t *testing.T) {
141155
}
142156

143157
func TestProtocolFeeTerminatorSuccess(t *testing.T) {
144-
bankKeeper := &protoFeeMockBankKeeper{}
158+
balance := sdk.NewCoin(appconsts.BondDenom, math.NewInt(1000))
159+
bankKeeper := &protoFeeMockBankKeeper{balance: balance}
145160
decorator := ante.NewProtocolFeeTerminatorDecorator(bankKeeper)
146161

147162
msg := feeaddress.NewMsgPayProtocolFee()
148-
fee := sdk.NewCoins(sdk.NewCoin(appconsts.BondDenom, math.NewInt(1000)))
149-
tx := &protoFeeMockFeeTx{msgs: []sdk.Msg{msg}, fee: fee, gas: 50000}
163+
fee := sdk.NewCoins(balance)
164+
tx := &protoFeeMockFeeTx{msgs: []sdk.Msg{msg}, fee: fee, gas: feeaddress.ProtocolFeeGasLimit}
150165

151166
// Create DeliverTx context (not CheckTx)
152167
ctx := sdk.NewContext(nil, tmproto.Header{}, false, log.NewNopLogger())
@@ -159,12 +174,13 @@ func TestProtocolFeeTerminatorSuccess(t *testing.T) {
159174
}
160175

161176
func TestProtocolFeeTerminatorRejectsSimulation(t *testing.T) {
162-
bankKeeper := &protoFeeMockBankKeeper{}
177+
balance := sdk.NewCoin(appconsts.BondDenom, math.NewInt(1000))
178+
bankKeeper := &protoFeeMockBankKeeper{balance: balance}
163179
decorator := ante.NewProtocolFeeTerminatorDecorator(bankKeeper)
164180

165181
msg := feeaddress.NewMsgPayProtocolFee()
166-
fee := sdk.NewCoins(sdk.NewCoin(appconsts.BondDenom, math.NewInt(1000)))
167-
tx := &protoFeeMockFeeTx{msgs: []sdk.Msg{msg}, fee: fee, gas: 50000}
182+
fee := sdk.NewCoins(balance)
183+
tx := &protoFeeMockFeeTx{msgs: []sdk.Msg{msg}, fee: fee, gas: feeaddress.ProtocolFeeGasLimit}
168184

169185
// Create DeliverTx context but pass simulate=true
170186
ctx := sdk.NewContext(nil, tmproto.Header{}, false, log.NewNopLogger())
@@ -176,7 +192,8 @@ func TestProtocolFeeTerminatorRejectsSimulation(t *testing.T) {
176192
}
177193

178194
func TestProtocolFeeTerminatorRejectsNonFeeTx(t *testing.T) {
179-
bankKeeper := &protoFeeMockBankKeeper{}
195+
balance := sdk.NewCoin(appconsts.BondDenom, math.NewInt(1000))
196+
bankKeeper := &protoFeeMockBankKeeper{balance: balance}
180197
decorator := ante.NewProtocolFeeTerminatorDecorator(bankKeeper)
181198

182199
msg := feeaddress.NewMsgPayProtocolFee()
@@ -192,12 +209,13 @@ func TestProtocolFeeTerminatorRejectsNonFeeTx(t *testing.T) {
192209
}
193210

194211
func TestProtocolFeeTerminatorBankTransferFailure(t *testing.T) {
195-
bankKeeper := &protoFeeMockBankKeeperWithError{err: sdkerrors.ErrInsufficientFunds}
212+
balance := sdk.NewCoin(appconsts.BondDenom, math.NewInt(1000))
213+
bankKeeper := &protoFeeMockBankKeeperWithError{err: sdkerrors.ErrInsufficientFunds, balance: balance}
196214
decorator := ante.NewProtocolFeeTerminatorDecorator(bankKeeper)
197215

198216
msg := feeaddress.NewMsgPayProtocolFee()
199-
fee := sdk.NewCoins(sdk.NewCoin(appconsts.BondDenom, math.NewInt(1000)))
200-
tx := &protoFeeMockFeeTx{msgs: []sdk.Msg{msg}, fee: fee, gas: 50000}
217+
fee := sdk.NewCoins(balance)
218+
tx := &protoFeeMockFeeTx{msgs: []sdk.Msg{msg}, fee: fee, gas: feeaddress.ProtocolFeeGasLimit}
201219

202220
// Create DeliverTx context (not CheckTx)
203221
ctx := sdk.NewContext(nil, tmproto.Header{}, false, log.NewNopLogger())
@@ -209,12 +227,13 @@ func TestProtocolFeeTerminatorBankTransferFailure(t *testing.T) {
209227
}
210228

211229
func TestProtocolFeeTerminatorZeroFeeRejected(t *testing.T) {
212-
bankKeeper := &protoFeeMockBankKeeper{}
230+
balance := sdk.NewCoin(appconsts.BondDenom, math.NewInt(1000))
231+
bankKeeper := &protoFeeMockBankKeeper{balance: balance}
213232
decorator := ante.NewProtocolFeeTerminatorDecorator(bankKeeper)
214233

215234
msg := feeaddress.NewMsgPayProtocolFee()
216235
// Zero fee should be rejected
217-
tx := &protoFeeMockFeeTx{msgs: []sdk.Msg{msg}, fee: sdk.Coins{}, gas: 50000}
236+
tx := &protoFeeMockFeeTx{msgs: []sdk.Msg{msg}, fee: sdk.Coins{}, gas: feeaddress.ProtocolFeeGasLimit}
218237

219238
// Create DeliverTx context (not CheckTx)
220239
ctx := sdk.NewContext(nil, tmproto.Header{}, false, log.NewNopLogger())
@@ -224,3 +243,42 @@ func TestProtocolFeeTerminatorZeroFeeRejected(t *testing.T) {
224243
require.Error(t, err)
225244
require.ErrorContains(t, err, "protocol fee tx requires exactly one fee coin")
226245
}
246+
247+
func TestProtocolFeeTerminatorRejectsFeeNotEqualToBalance(t *testing.T) {
248+
balance := sdk.NewCoin(appconsts.BondDenom, math.NewInt(1000))
249+
bankKeeper := &protoFeeMockBankKeeper{balance: balance}
250+
decorator := ante.NewProtocolFeeTerminatorDecorator(bankKeeper)
251+
252+
msg := feeaddress.NewMsgPayProtocolFee()
253+
// Fee is less than balance - should be rejected
254+
fee := sdk.NewCoins(sdk.NewCoin(appconsts.BondDenom, math.NewInt(500)))
255+
tx := &protoFeeMockFeeTx{msgs: []sdk.Msg{msg}, fee: fee, gas: feeaddress.ProtocolFeeGasLimit}
256+
257+
// Create DeliverTx context (not CheckTx)
258+
ctx := sdk.NewContext(nil, tmproto.Header{}, false, log.NewNopLogger())
259+
260+
_, err := decorator.AnteHandle(ctx, tx, false, protoFeeNextAnteHandler)
261+
262+
require.Error(t, err)
263+
require.ErrorContains(t, err, "does not equal expected fee")
264+
}
265+
266+
func TestProtocolFeeTerminatorRejectsWrongGasLimit(t *testing.T) {
267+
balance := sdk.NewCoin(appconsts.BondDenom, math.NewInt(1000))
268+
bankKeeper := &protoFeeMockBankKeeper{balance: balance}
269+
decorator := ante.NewProtocolFeeTerminatorDecorator(bankKeeper)
270+
271+
msg := feeaddress.NewMsgPayProtocolFee()
272+
fee := sdk.NewCoins(balance)
273+
// Wrong gas limit - should be rejected
274+
tx := &protoFeeMockFeeTx{msgs: []sdk.Msg{msg}, fee: fee, gas: feeaddress.ProtocolFeeGasLimit * 2}
275+
276+
// Create DeliverTx context (not CheckTx)
277+
ctx := sdk.NewContext(nil, tmproto.Header{}, false, log.NewNopLogger())
278+
279+
_, err := decorator.AnteHandle(ctx, tx, false, protoFeeNextAnteHandler)
280+
281+
require.Error(t, err)
282+
require.ErrorContains(t, err, "gas limit")
283+
require.ErrorContains(t, err, "does not match expected")
284+
}

app/prepare_proposal.go

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -115,11 +115,21 @@ func (app *App) injectProtocolFeeTx(ctx sdk.Context, txs [][]byte) ([][]byte, sd
115115
return append([][]byte{protocolFeeTx}, txs...), feeBalance, nil
116116
}
117117

118-
// verifyProtocolFeeTxSurvived performs defense-in-depth verification that the fee
119-
// forward tx was not filtered out. If the fee address had a balance and we created
120-
// a protocol fee tx, it MUST be the first tx in the output. The protocol fee tx should
121-
// never be filtered since it has no signature requirements and uses minimal gas.
122-
// If it's filtered, there's a bug in the ante handler chain.
118+
// verifyProtocolFeeTxSurvived performs defense-in-depth verification that the protocol
119+
// fee tx was not filtered out by the ante handler chain.
120+
//
121+
// INVARIANT: If fee address had a balance, the protocol fee tx MUST be the first tx in output.
122+
//
123+
// The protocol fee tx should NEVER be filtered because:
124+
// - No signatures to fail verification
125+
// - Minimal gas (40k, well under any limit)
126+
// - Positive fees
127+
//
128+
// If this check fails, it indicates a BUG in the ante handler chain, not normal operation.
129+
//
130+
// IMPACT: Returns error → PrepareProposal fails → this validator cannot propose this block.
131+
// This is NOT a chain halt. Other validators can still propose valid blocks.
132+
// Explicit failure is preferred over silently producing blocks that ProcessProposal will reject.
123133
//
124134
// If feeBalance is zero, this is a no-op (no protocol fee tx was injected).
125135
func (app *App) verifyProtocolFeeTxSurvived(feeBalance sdk.Coin, filteredTxs [][]byte) error {
@@ -131,11 +141,7 @@ func (app *App) verifyProtocolFeeTxSurvived(feeBalance sdk.Coin, filteredTxs [][
131141
return fmt.Errorf("protocol fee tx was filtered out (no txs remain); fee_balance=%s", feeBalance.String())
132142
}
133143

134-
_, firstTxIsProtocolFee, err := app.parseProtocolFeeTx(filteredTxs[0])
135-
if err != nil {
136-
return fmt.Errorf("failed to parse protocol fee tx: %w; fee_balance=%s", err, feeBalance.String())
137-
}
138-
if !firstTxIsProtocolFee {
144+
if !feeaddress.IsProtocolFeeTxBytes(app.encodingConfig.TxConfig.TxDecoder(), filteredTxs[0]) {
139145
return fmt.Errorf("protocol fee tx was filtered out unexpectedly; fee_balance=%s", feeBalance.String())
140146
}
141147

0 commit comments

Comments
 (0)