Skip to content
Open
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
18 changes: 16 additions & 2 deletions sei-cosmos/types/coin.go
Original file line number Diff line number Diff line change
Expand Up @@ -358,16 +358,30 @@ func (coins Coins) safeAdd(coinsB Coins) Coins {

// DenomsSubsetOf returns true if receiver's denom set
// is subset of coinsB's denoms.
//
// Both coin sets — the receiver and coinsB — are assumed to be sorted ascending
// by denomination and free of zero amounts (the Coins invariant enforced by
// Validate/IsValid). The receiver must be sorted too: the merge walk only
// advances forward through coinsB, so an unsorted receiver can produce a false
// negative. Under the sorted invariant a denom is "present" iff it appears, so
// this merge walk can run in O(len(coins) + len(coinsB)).
func (coins Coins) DenomsSubsetOf(coinsB Coins) bool {
// more denoms in B than in receiver
// more denoms in receiver than in B => cannot be a subset
if len(coins) > len(coinsB) {
return false
}

indexB := 0
for _, coin := range coins {
if coinsB.AmountOf(coin.Denom).IsZero() {
// advance B until it reaches or passes coin's denom
for indexB < len(coinsB) && coinsB[indexB].Denom < coin.Denom {
Comment thread
amir-deris marked this conversation as resolved.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] This merge walk now requires the receiver (coins) to be sorted ascending and duplicate-free, and treats any listed denom as present. Note this narrows the previous behavior: because this fork's AmountOfNoDenomValidation is a linear scan (not a binary search), the old AmountOf-based version was order-insensitive and treated zero-amount coins as absent. For canonical Coins (the sorted/positive invariant enforced by Validate/IsValid) the result is identical, and both non-test callers pass validated Coins — so this is safe. Consider keeping the doc comment's precondition prominent so future IsAllGT/DenomsSubsetOf callers know a non-canonical (unsorted or zero-amount) input can now silently produce a wrong result.

indexB++
}
if indexB == len(coinsB) || coinsB[indexB].Denom != coin.Denom {
return false
}
// matched; both lists are duplicate-free so B never re-matches this denom
indexB++
}

return true
Expand Down
50 changes: 50 additions & 0 deletions sei-cosmos/types/coin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -932,6 +932,56 @@ func (s *coinTestSuite) TestCoinsIsAllGTE() {
s.Require().False(sdk.Coins{{"xxx", one}, {"yyy", one}}.IsAllGTE(sdk.Coins{{testDenom2, one}, {"ccc", one}, {"yyy", one}, {"zzz", one}}))
}

func (s *coinTestSuite) TestCoinsDenomsSubsetOf() {
one := sdk.OneInt()
two := sdk.NewInt(2)

// empty receiver is a subset of anything (including empty)
s.Require().True(sdk.Coins{}.DenomsSubsetOf(sdk.Coins{}))
s.Require().True(sdk.Coins{}.DenomsSubsetOf(sdk.Coins{{testDenom1, one}}))

// a larger receiver can never be a subset
s.Require().False(sdk.Coins{{testDenom1, one}}.DenomsSubsetOf(sdk.Coins{}))

// amounts are irrelevant, only denom membership matters
s.Require().True(sdk.Coins{{testDenom1, one}}.DenomsSubsetOf(sdk.Coins{{testDenom1, one}}))
s.Require().True(sdk.Coins{{testDenom1, two}}.DenomsSubsetOf(sdk.Coins{{testDenom1, one}}))

// proper subset (receiver denoms all present in B)
s.Require().True(sdk.Coins{{testDenom1, one}}.DenomsSubsetOf(sdk.Coins{{testDenom1, one}, {testDenom2, one}}))
s.Require().True(sdk.Coins{{testDenom1, one}, {testDenom2, one}}.DenomsSubsetOf(sdk.Coins{{testDenom1, one}, {testDenom2, one}}))
s.Require().True(sdk.Coins{{testDenom1, one}, {testDenom3, one}}.DenomsSubsetOf(sdk.Coins{{testDenom1, one}, {testDenom2, one}, {testDenom3, one}}))

// receiver contains a denom missing from B
s.Require().False(sdk.Coins{{testDenom1, one}}.DenomsSubsetOf(sdk.Coins{{testDenom2, one}}))
s.Require().False(sdk.Coins{{testDenom1, one}, {testDenom2, one}}.DenomsSubsetOf(sdk.Coins{{testDenom1, one}, {testDenom3, one}}))
// same length, one denom differs (guards the merge-walk termination)
s.Require().False(sdk.Coins{{testDenom2, one}, {testDenom3, one}}.DenomsSubsetOf(sdk.Coins{{testDenom1, one}, {testDenom2, one}}))
// receiver denom sorts after everything in B
s.Require().False(sdk.Coins{{"zzz", one}}.DenomsSubsetOf(sdk.Coins{{testDenom1, one}, {testDenom2, one}}))

// large sorted inputs: subset and non-subset
const n = 5000
full := make(sdk.Coins, n)
for i := 0; i < n; i++ {
full[i] = sdk.NewCoin(fmt.Sprintf("coin%08d", i), one)
}
s.Require().NoError(full.Validate(), "generated coins must satisfy the sorted/no-dup invariant")

half := make(sdk.Coins, 0, n/2)
for i := 0; i < n; i += 2 {
half = append(half, full[i])
}
s.Require().True(half.DenomsSubsetOf(full))

// flip one denom to something not in full => no longer a subset
notSubset := make(sdk.Coins, len(half))
copy(notSubset, half)
notSubset[len(notSubset)-1] = sdk.NewCoin("zzz99999999", one)
s.Require().True(notSubset.IsValid())
s.Require().False(notSubset.DenomsSubsetOf(full))
}

func (s *coinTestSuite) TestNewCoins() {
tenatom := sdk.NewInt64Coin("atom", 10)
tenbtc := sdk.NewInt64Coin("btc", 10)
Expand Down
7 changes: 7 additions & 0 deletions sei-cosmos/x/feegrant/basic_fee.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ import (

var _ FeeAllowanceI = (*BasicAllowance)(nil)

// MaxAllowanceDenoms bounds the number of coins allowed in a single allowance
// spend-limit list.
const MaxAllowanceDenoms = 100

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[blocker] MaxAllowanceDenoms is set to 100, but the PR description states the intended cap is 5000 (twice), and the added tests/benchmark are scaled around 5000/56500. This is app-hash-breaking consensus validation, so the exact bound matters: at 100, any allowance with 101–5000 denoms is rejected, which contradicts the stated intent. Please reconcile — if 5000 is correct, update this constant; if 100 is intended, fix the PR description. The tests won't catch this because they reference the constant symbolically.


// Accept can use fee payment requested as well as timestamp of the current block
// to determine whether or not to process this. This is checked in
// Keeper.UseGrantedFees and the return values should match how it is handled there.
Expand Down Expand Up @@ -38,6 +42,9 @@ func (a *BasicAllowance) Accept(ctx sdk.Context, fee sdk.Coins, _ []sdk.Msg) (bo
// ValidateBasic implements FeeAllowance and enforces basic sanity checks
func (a BasicAllowance) ValidateBasic() error {
if a.SpendLimit != nil {
if len(a.SpendLimit) > MaxAllowanceDenoms {
return sdkerrors.Wrapf(ErrTooManyDenoms, "spend limit has %d denoms, max %d", len(a.SpendLimit), MaxAllowanceDenoms)
}
if !a.SpendLimit.IsValid() {
return sdkerrors.Wrapf(sdkerrors.ErrInvalidCoins, "send amount is invalid: %s", a.SpendLimit)
}
Expand Down
60 changes: 60 additions & 0 deletions sei-cosmos/x/feegrant/basic_fee_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package feegrant_test

import (
"fmt"
"testing"
"time"

Expand Down Expand Up @@ -148,3 +149,62 @@ func TestBasicFeeValidAllow(t *testing.T) {
})
}
}

// sortedCoins builds a valid, sorted, duplicate-free Coins list of n denoms
// (zero-padded so lexical order matches numeric order).
func sortedCoins(n int) sdk.Coins {
coins := make(sdk.Coins, n)
for i := 0; i < n; i++ {
coins[i] = sdk.NewInt64Coin(fmt.Sprintf("coin%08d", i), 1)
}
return coins.Sort()
}

func TestBasicAllowanceMaxDenoms(t *testing.T) {
atCap := &feegrant.BasicAllowance{SpendLimit: sortedCoins(feegrant.MaxAllowanceDenoms)}
require.NoError(t, atCap.ValidateBasic())

overCap := &feegrant.BasicAllowance{SpendLimit: sortedCoins(feegrant.MaxAllowanceDenoms + 1)}
err := overCap.ValidateBasic()
require.Error(t, err)
require.ErrorIs(t, err, feegrant.ErrTooManyDenoms)
}

func TestPeriodicAllowanceMaxDenoms(t *testing.T) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] This test doesn't actually exercise the new PeriodSpendLimit/PeriodCanSpend cap checks: PeriodicAllowance.ValidateBasic calls a.Basic.ValidateBasic() first, and since Basic.SpendLimit is also set to the over-cap over, it returns ErrTooManyDenoms before the period-limit checks are reached. The assertion passes, but via the Basic path. Add a case where Basic.SpendLimit is valid (or nil) and only PeriodSpendLimit exceeds the cap, plus another where only PeriodCanSpend does, to genuinely cover the new checks in periodic_fee.go.

atCap := sortedCoins(feegrant.MaxAllowanceDenoms)
over := sortedCoins(feegrant.MaxAllowanceDenoms + 1)

// Each case keeps the other allowance fields under cap so that a single
// over-cap field is the only thing that can trip ErrTooManyDenoms. This
// isolates the periodic-specific checks; if they only oversized every field
// at once, the embedded Basic.ValidateBasic() would short-circuit and the
// periodic checks would never be exercised.
cases := map[string]*feegrant.PeriodicAllowance{
"basic spend limit over cap": {
Basic: feegrant.BasicAllowance{SpendLimit: over},
PeriodSpendLimit: atCap,
PeriodCanSpend: atCap,
Period: time.Hour,
},
"period spend limit over cap": {
Basic: feegrant.BasicAllowance{SpendLimit: atCap},
PeriodSpendLimit: over,
PeriodCanSpend: atCap,
Period: time.Hour,
},
"period can spend over cap": {
Basic: feegrant.BasicAllowance{SpendLimit: atCap},
PeriodSpendLimit: atCap,
PeriodCanSpend: over,
Period: time.Hour,
},
}

for name, allowance := range cases {
t.Run(name, func(t *testing.T) {
err := allowance.ValidateBasic()
require.Error(t, err)
require.ErrorIs(t, err, feegrant.ErrTooManyDenoms)
})
}
}
Comment thread
claude[bot] marked this conversation as resolved.
2 changes: 2 additions & 0 deletions sei-cosmos/x/feegrant/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,6 @@ var (
ErrNoMessages = sdkerrors.Register(DefaultCodespace, 6, "allowed messages are empty")
// ErrMessageNotAllowed error if message is not allowed
ErrMessageNotAllowed = sdkerrors.Register(DefaultCodespace, 7, "message not allowed")
// ErrTooManyDenoms error if an allowance coin list exceeds MaxAllowanceDenoms
ErrTooManyDenoms = sdkerrors.Register(DefaultCodespace, 8, "too many denominations in allowance")
)
6 changes: 6 additions & 0 deletions sei-cosmos/x/feegrant/periodic_fee.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,12 +79,18 @@ func (a PeriodicAllowance) ValidateBasic() error {
return err
}

if len(a.PeriodSpendLimit) > MaxAllowanceDenoms {
return sdkerrors.Wrapf(ErrTooManyDenoms, "period spend limit has %d denoms, max %d", len(a.PeriodSpendLimit), MaxAllowanceDenoms)
}
if !a.PeriodSpendLimit.IsValid() {
return sdkerrors.Wrapf(sdkerrors.ErrInvalidCoins, "spend amount is invalid: %s", a.PeriodSpendLimit)
}
if !a.PeriodSpendLimit.IsAllPositive() {
return sdkerrors.Wrap(sdkerrors.ErrInvalidCoins, "spend limit must be positive")
}
if len(a.PeriodCanSpend) > MaxAllowanceDenoms {
return sdkerrors.Wrapf(ErrTooManyDenoms, "period can spend has %d denoms, max %d", len(a.PeriodCanSpend), MaxAllowanceDenoms)
}
if !a.PeriodCanSpend.IsValid() {
return sdkerrors.Wrapf(sdkerrors.ErrInvalidCoins, "can spend amount is invalid: %s", a.PeriodCanSpend)
}
Expand Down
Loading