Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions app/txpolicy/gasless.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
84 changes: 84 additions & 0 deletions app/txpolicy/gasless_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Loading