Skip to content

fix(feegrant): bound DenomsSubsetOf cost and cap allowance denoms#3710

Open
amir-deris wants to merge 7 commits into
mainfrom
amir/fee-grant-validation-issue
Open

fix(feegrant): bound DenomsSubsetOf cost and cap allowance denoms#3710
amir-deris wants to merge 7 commits into
mainfrom
amir/fee-grant-validation-issue

Conversation

@amir-deris

@amir-deris amir-deris commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes a quadratic-complexity issue in fee-grant validation . An
allowance carrying a large coin list forced Coins.DenomsSubsetOf to run in
O(n·m), so a single crafted grant/allowance could burn disproportionate CPU
during ValidateBasic. This PR closes the hole on two fronts:

  1. Linear DenomsSubsetOf — replace the per-element AmountOf membership
    check (O(n·m)) with a two-pointer merge walk (O(n+m)). Both coin sets are
    already sorted and duplicate-free per the Coins invariant, so the result is
    identical while the worst case drops from quadratic to linear.

  2. Bounded allowance denom count — cap allowance coin lists at
    MaxAllowanceDenoms = 100 and reject anything larger with a new
    ErrTooManyDenoms. Enforced in BasicAllowance.ValidateBasic (spend limit)
    and PeriodicAllowance.ValidateBasic (period spend limit + period can spend).

@amir-deris amir-deris self-assigned this Jul 7, 2026
@amir-deris amir-deris changed the title Added max denom check + pointer search for coin denoms fix(feegrant): bound DenomsSubsetOf cost and cap allowance denoms (Immunefi 79950) Jul 7, 2026
@cursor

cursor Bot commented Jul 7, 2026

Copy link
Copy Markdown

PR Summary

Medium Risk
Changes core Coins.DenomsSubsetOf behavior assumptions (sorted inputs) and tightens feegrant validation, which can reject previously accepted oversized grants but closes a DoS vector on tx validation.

Overview
Addresses fee-grant validation CPU abuse where huge coin lists made denom-subset checks quadratic during ValidateBasic.

Coins.DenomsSubsetOf no longer does a per-receiver-denom AmountOf scan over coinsB. It uses a two-pointer merge on sorted, duplicate-free coin lists (same membership semantics when invariants hold), dropping worst-case cost from O(n·m) to O(n+m). Docs now call out the sorted-input assumption.

Fee allowances are bounded: MaxAllowanceDenoms = 100 on spend-limit coin lists, with new ErrTooManyDenoms. Rejection happens in BasicAllowance.ValidateBasic (spend limit) and PeriodicAllowance.ValidateBasic (period spend limit and period can spend).

Tests cover TestCoinsDenomsSubsetOf (including large lists) and max-denom validation for basic and periodic allowances.

Reviewed by Cursor Bugbot for commit bba284d. Bugbot is set up for automated code reviews on this repo. Configure here.

@amir-deris amir-deris changed the title fix(feegrant): bound DenomsSubsetOf cost and cap allowance denoms (Immunefi 79950) fix(feegrant): bound DenomsSubsetOf cost and cap allowance denoms Jul 7, 2026
@github-actions

github-actions Bot commented Jul 7, 2026

Copy link
Copy Markdown

The latest Buf updates on your PR. Results from workflow Buf / buf (pull_request).

BuildFormatLintBreakingUpdated (UTC)
✅ passed✅ passed✅ passed✅ passedJul 8, 2026, 6:48 PM

@seidroid seidroid Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

A focused, well-tested fix that makes Coins.DenomsSubsetOf linear and caps feegrant allowance denom counts, closing a quadratic-cost DoS in ValidateBasic. Logic is correct for all in-tree callers; only one subtle behavioral-assumption note and one tooling note.

Findings: 0 blocking | 4 non-blocking | 1 posted inline

Blockers

  • None at the file/PR level.

Non-blocking

  • The 5000-denom cap is a new stateless-validation rejection: MsgGrantAllowance messages with >5000 denoms that were previously accepted will now be rejected. This is intended (defense-in-depth) and does not affect already-stored grants, but confirm no legitimate existing flow creates allowances above this bound.
  • Cursor second-opinion review (cursor-review.md) is empty — that pass produced no output. Codex (codex-review.md) reported no material findings (it could not run tests due to sandbox toolchain download restrictions), consistent with this review.
  • Coverage of the cap is complete: BasicAllowance (SpendLimit), PeriodicAllowance (PeriodSpendLimit + PeriodCanSpend), and AllowedMsgAllowance (delegates to the wrapped allowance's ValidateBasic) are all bounded. No gap found.
  • 1 suggestion(s)/nit(s) flagged inline on specific lines.

Comment thread sei-cosmos/types/coin.go
@codecov

codecov Bot commented Jul 7, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 58.41%. Comparing base (c6ab0be) to head (bba284d).

Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main    #3710      +/-   ##
==========================================
- Coverage   59.85%   58.41%   -1.44%     
==========================================
  Files        2278     2187      -91     
  Lines      189140   178136   -11004     
==========================================
- Hits       113202   104065    -9137     
+ Misses      65861    64804    -1057     
+ Partials    10077     9267     -810     
Flag Coverage Δ
sei-chain-pr 85.81% <100.00%> (?)
sei-db 70.41% <ø> (ø)
sei-db-state-db ?

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
sei-cosmos/types/coin.go 95.41% <100.00%> (+0.04%) ⬆️
sei-cosmos/x/feegrant/basic_fee.go 90.47% <100.00%> (+1.00%) ⬆️
sei-cosmos/x/feegrant/periodic_fee.go 77.77% <100.00%> (+7.04%) ⬆️

... and 179 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Comment thread sei-cosmos/x/feegrant/basic_fee.go Outdated

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nit: I'd lower this to 100

seidroid[bot]
seidroid Bot previously requested changes Jul 7, 2026

@seidroid seidroid Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The DenomsSubsetOf two-pointer rewrite is correct and well-tested, but the MaxAllowanceDenoms cap is set to 100 while the PR description, tests, and benchmark all assume 5000 — a discrepancy in app-hash-breaking consensus validation that must be reconciled. The new periodic-allowance test also doesn't actually cover the periodic-specific caps it targets.

Findings: 1 blocking | 3 non-blocking | 2 posted inline

Blockers

  • None at the file/PR level.
  • 1 blocking issue(s) flagged inline on specific lines.

Non-blocking

  • Cursor's second-opinion review (cursor-review.md) was empty — no output produced by that pass. Codex's review was present and its two findings are incorporated here.
  • Consider a dedicated unit test for the linear-vs-quadratic behavioral equivalence isn't needed, but a comment or test asserting DenomsSubsetOf requires sorted input (it now does, matching the pre-existing AmountOf binary-search assumption) would help future callers avoid passing unsorted Coins.
  • 1 suggestion(s)/nit(s) flagged inline on specific lines.


// 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.

Comment thread sei-cosmos/x/feegrant/basic_fee_test.go Outdated
func TestPeriodicAllowanceMaxDenoms(t *testing.T) {
over := sortedCoins(feegrant.MaxAllowanceDenoms + 1)
allowance := &feegrant.PeriodicAllowance{
Basic: feegrant.BasicAllowance{SpendLimit: over},

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 periodic-specific caps. PeriodicAllowance.ValidateBasic() calls a.Basic.ValidateBasic() first (periodic_fee.go:78), and since Basic.SpendLimit is set to over (already over cap), it returns ErrTooManyDenoms there — the PeriodSpendLimit (line 82) and PeriodCanSpend (line 91) checks are never reached. The test passes for the wrong reason. Add cases where Basic.SpendLimit is nil/at-cap and only one periodic field is over cap, so each new check is covered independently.

Comment thread sei-cosmos/x/feegrant/basic_fee_test.go

@seidroid seidroid Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

A focused, well-tested fix that makes Coins.DenomsSubsetOf linear via a two-pointer merge and caps fee-grant allowance denom counts at 100. Logic is correct for valid (sorted, positive) Coins; no blocking issues, but two non-blocking notes on a narrowed precondition and a test that doesn't exercise the code path it claims to.

Findings: 0 blocking | 5 non-blocking | 2 posted inline

Blockers

  • None at the file/PR level.

Non-blocking

  • Precondition narrowing (Codex #1): In this fork AmountOfNoDenomValidation is a linear scan, so the previous DenomsSubsetOf was order-insensitive and treated zero-amount coins as absent. The new merge walk requires BOTH the receiver and coinsB to be sorted and treats any listed denom as present. This is a no-op for canonical Coins (the sorted/positive invariant), and both non-test callers (IsAllGT and periodic_fee.go:103) pass validated Coins, so it is safe — but it is a real behavior narrowing worth being explicit about since IsAllGT/DenomsSubsetOf are public APIs.
  • cursor-review.md is empty — the Cursor second-opinion pass produced no output.
  • The 100-denom cap will reject creation of allowances with >100 denoms; existing on-chain grants are not re-validated on read, so this is a message-handling-time change (consistent with the app-hash-breaking label). No action needed, just noting the intended scope.
  • 2 suggestion(s)/nit(s) flagged inline on specific lines.

Comment thread sei-cosmos/types/coin.go
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 {

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.

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.

@seidroid seidroid Bot dismissed their stale review July 8, 2026 18:28

Superseded: latest AI review found no blocking issues.

@seidroid seidroid Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

A well-scoped, well-tested security fix: it replaces the O(n·m) Coins.DenomsSubsetOf with a correct linear two-pointer merge and caps feegrant allowance coin lists at 100 denoms. No blocking issues; only minor non-blocking notes.

Findings: 0 blocking | 3 non-blocking | 0 posted inline

Blockers

  • None at the file/PR level.

Non-blocking

  • DenomsSubsetOf now requires the receiver to be sorted, whereas the old AmountOf-based scan was fully order-independent. This is documented in the new comment and matches the Coins invariant, but it is a subtle behavioral change for any external caller that passes an unsorted receiver (would now get a false negative). Since it's app-hash-breaking and invariant-backed, this is acceptable — noting for awareness.
  • cursor-review.md was empty (no Cursor output was produced for this pass); the Codex pass reported no material findings but noted its targeted tests could not run (sandbox could not download the Go 1.25.6 toolchain), so neither second-opinion tool executed the tests.
  • Consider a brief note in the PR/changelog that existing on-chain grants with >100 denoms are unaffected because ValidateBasic runs on tx ingestion, not on stored-grant reads — this clarifies the app-hash-breaking scope.

@seidroid seidroid Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

A focused, correct fix that converts Coins.DenomsSubsetOf from O(n·m) to a linear two-pointer merge and caps allowance denom lists at 100 to bound ValidateBasic cost. The logic is sound and thoroughly tested; only minor, non-blocking observations remain.

Findings: 0 blocking | 4 non-blocking | 0 posted inline

Blockers

  • None at the file/PR level.

Non-blocking

  • Behavioral change worth flagging: the previous DenomsSubsetOf used a linear AmountOf scan and was order-independent for both operands; the new merge walk requires BOTH the receiver and coinsB to be sorted ascending. This is safe under the standard Coins sorted/dedup invariant (and all in-tree callers — IsAllGT and PeriodicAllowance.ValidateBasic — pass validated Coins), and the doc comment documents it, but any caller passing an unsorted receiver would now get a false negative. Low risk given the invariant.
  • The MaxAllowanceDenoms=100 cap is app-hash/consensus-breaking (correctly labeled): a new grant/allowance with >100 denoms will now be rejected by ValidateBasic. Confirm this only affects newly submitted MsgGrantAllowance and does not break replay/migration of any existing on-chain allowances that already exceed 100 denoms (existing stored grants bypass ValidateBasic on read, so this should be fine). Worth a note in release/upgrade docs.
  • Cursor's second-opinion pass produced no output (empty file); Codex reported no material findings.
  • No prompt-injection or suspicious content detected in the diff or PR description.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants