From 65d702ab54bcee890111596541cd2936d9c21580 Mon Sep 17 00:00:00 2001 From: Nilesh Gupta Date: Wed, 26 Aug 2026 08:20:17 +0530 Subject: [PATCH] fix: reject empty authz.MsgExec in IsGaslessTx (F-2026-18816) An empty inner message list passed the allowlist loop vacuously, making the tx gasless and skipping the fee and min-gas-price decorators. --- app/txpolicy/gasless.go | 5 +++ app/txpolicy/gasless_test.go | 84 ++++++++++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+) diff --git a/app/txpolicy/gasless.go b/app/txpolicy/gasless.go index 40acd8f1..b77fe52e 100644 --- a/app/txpolicy/gasless.go +++ b/app/txpolicy/gasless.go @@ -33,6 +33,11 @@ func IsGaslessTx(tx sdk.Tx) bool { for _, msg := range msgs { switch m := msg.(type) { case *authz.MsgExec: + // An empty nest would pass the loop below vacuously and make the whole + // tx gasless, bypassing the fee and min-gas-price decorators (F-2026-18816). + if len(m.Msgs) == 0 { + return false + } // Only gasless if ALL inner messages are allowed for _, innerMsg := range m.Msgs { if !slices.Contains(GaslessMsgTypes, innerMsg.TypeUrl) { diff --git a/app/txpolicy/gasless_test.go b/app/txpolicy/gasless_test.go index 78f2c57e..9531ce0a 100644 --- a/app/txpolicy/gasless_test.go +++ b/app/txpolicy/gasless_test.go @@ -52,3 +52,87 @@ func TestGaslessMsgTypesExcludeEthereumTx(t *testing.T) { require.True(t, txpolicy.IsGaslessTx(tx)) }) } + +// TestIsGaslessTxAuthzExecNesting guards the authz.MsgExec branch of IsGaslessTx. +// +// The inner-message loop is an "all must be allowlisted" check, so an empty nest +// satisfies it vacuously and would make the whole tx gasless - skipping +// DeductFeeDecorator and MinGasPriceDecorator for a zero-fee tx, and handing the +// signer a free on-chain account via AccountInitDecorator, which gates on this +// same predicate. Nothing upstream catches it: authz.MsgExec has no ValidateBasic +// in SDK v0.53.7, and the empty check lives only in the msg server, which runs +// after the fee decorators (F-2026-18816). +func TestIsGaslessTxAuthzExecNesting(t *testing.T) { + anyOf := func(t *testing.T, msg sdk.Msg) *codectypes.Any { + t.Helper() + a, err := codectypes.NewAnyWithValue(msg) + require.NoError(t, err) + return a + } + + tests := []struct { + name string + inner []sdk.Msg + gasless bool + reason string + }{ + { + name: "empty nest is not gasless", + inner: nil, + gasless: false, + reason: "an empty authz.MsgExec must not pass the inner allowlist loop vacuously", + }, + { + name: "empty non-nil nest is not gasless", + inner: []sdk.Msg{}, + gasless: false, + reason: "a zero-length (but non-nil) inner message list must be rejected too", + }, + { + name: "all-allowlisted nest stays gasless", + inner: []sdk.Msg{&uexecutortypes.MsgVoteInbound{}, &uexecutortypes.MsgVoteOutbound{}}, + gasless: true, + reason: "a nest of only allowlisted messages must remain gasless", + }, + { + name: "mixed nest is not gasless", + inner: []sdk.Msg{&uexecutortypes.MsgVoteInbound{}, &evmtypes.MsgEthereumTx{}}, + gasless: false, + reason: "one non-allowlisted inner message must disqualify the whole tx", + }, + { + name: "nested MsgExec is not gasless", + inner: []sdk.Msg{&authz.MsgExec{Msgs: []*codectypes.Any{}}}, + gasless: false, + reason: "authz.MsgExec is not itself an allowlisted type, so nesting one must not recurse into a vacuous pass", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Preserve the nil vs. zero-length distinction: len() treats them the + // same, but constructing both proves the guard does not depend on it. + var inner []*codectypes.Any + if tc.inner != nil { + inner = make([]*codectypes.Any, 0, len(tc.inner)) + for _, m := range tc.inner { + inner = append(inner, anyOf(t, m)) + } + } + + tx := msgsOnlyTx{msgs: []sdk.Msg{&authz.MsgExec{Msgs: inner}}} + require.Equal(t, tc.gasless, txpolicy.IsGaslessTx(tx), tc.reason) + }) + } +} + +// TestIsGaslessTxEmptyExecAlongsideAllowedMsg pins the multi-message case: the +// outer loop must not let an allowlisted sibling carry an empty nest through. +func TestIsGaslessTxEmptyExecAlongsideAllowedMsg(t *testing.T) { + tx := msgsOnlyTx{msgs: []sdk.Msg{ + &uexecutortypes.MsgVoteInbound{}, + &authz.MsgExec{}, + }} + require.False(t, txpolicy.IsGaslessTx(tx), + "an empty authz.MsgExec must disqualify the tx even next to an allowlisted message") +}