Skip to content

fix(distribution): make delegation-reward queries read-only#3738

Open
codchen wants to merge 2 commits into
mainfrom
claude/distr-readonly-reward-query
Open

fix(distribution): make delegation-reward queries read-only#3738
codchen wants to merge 2 commits into
mainfrom
claude/distr-readonly-reward-query

Conversation

@codchen

@codchen codchen commented Jul 10, 2026

Copy link
Copy Markdown
Collaborator

Problem

The distribution reward query handlers — DelegationRewards / DelegationTotalRewards in grpc_query.go, and the amino equivalents in querier.go — call IncrementValidatorPeriod, which mutates distribution state (validator period, historical/current rewards, reference counts, and the zero-token community-pool transfer).

Queries are supposed to be side-effect free, and the codebase's own intent confirms it:

  • the amino querier explicitly branches ctx.CacheContext() "to isolate state changes";
  • the gRPC query server runs handlers on a cached context.

But the EVM rewards() distribution precompile calls the gRPC handler with the live context (sdk.WrapSDKContext(ctx), no branch) and is not behind the readOnly guard — so those writes actually persist and commit when a contract calls rewards() in a transaction. That's an unintended state mutation from a view method, and it also defeats the empty-layer read optimization in the companion store-cache fix (the writes dirty the distribution cache layers).

Change

Replace the IncrementValidatorPeriod + CalculateDelegationRewards sequence in the query paths with a read-only computation:

  • currentRewardsEndingPeriodAndRatio computes, in memory, the ending period and cumulative reward ratio that IncrementValidatorPeriod would persist — without writing. The ratio is identical to the persisted one, so the reward figure matches the state-changing path exactly.
  • CalculateDelegationRewards is factored into a shared core that takes the ending ratio. CalculateDelegationRewardsReadOnly passes the in-memory ratio and never writes.
  • The withdraw (state-changing) path is unchanged: it passes a nil ratio, so the core reads the ending ratio from the store lazily in the final period — byte-for-byte the same store accesses and gas as before. (An earlier eager read regressed withdraw gas; the lazy sentinel restores it — verified against the precompile withdraw-gas tests.)

Upgrade gating (v6.7, tracing only)

Because the pre-fix query persisted state on the precompile path, flipping to read-only is state-machine-breaking: re-executing an old block that called rewards() must still reproduce the committed writes. DelegationRewardsForQuery dispatches via rewardQueryIsReadOnly(ctx):

func (k Keeper) rewardQueryIsReadOnly(ctx sdk.Context) bool {
    if !ctx.IsTracing() {
        return true // live execution: always current (read-only) behavior
    }
    // re-tracing a historical block: reproduce the era's behavior
    return semver.Compare(ctx.ClosestUpgradeName(), ReadOnlyRewardsUpgrade) >= 0
}
  • Live execution always uses the read-only path. The current binary only ever executes blocks at/after the upgrade that ships this behavior — under cosmovisor the prior binary runs pre-upgrade blocks, and we never replay pre-upgrade transactions non-tracingly. So a non-tracing block is never a pre-upgrade block; no per-height/upgrade-keeper check is needed.
  • Only debug_trace* re-executes historical pre-v6.7 blocks, where IsTracing() is set and the era is signaled by the trace harness via ClosestUpgradeName (see app.RPCContextProvider), compared by semver. This matches the existing x/evm/keeper/params.go idiom (if !ctx.IsTracing() { return current }).

ReadOnlyRewardsUpgrade = "v6.7" (following the app/tags convention — recent tags dropped the patch: …, v6.5, v6.6). It is only used in the semver tracing comparison, so the exact patch form is not load-bearing.

The reward figure is identical in both paths; only whether the query mutates state differs.

Tests

  • TestCalculateDelegationRewardsReadOnlyMatchesWritePath — read-only result equals the IncrementValidatorPeriod + CalculateDelegationRewards result (over the slash-event path), and the read-only helper mutates neither the validator period nor the historical reference count.
  • TestDelegationTotalRewardsQueryIsReadOnly — the (live) gRPC query leaves the validator period/reference count unchanged and is idempotent across repeated calls.
  • TestDelegationRewardsForQueryUpgradeGatelive is read-only, tracing pre-v6.7 mutates, tracing v6.7+ is read-only; the reward figure is identical across all three.
  • All existing distribution reward/withdraw/query tests pass unchanged; precompile-distribution suite (incl. withdraw-gas assertions) passes. Verified with -race.

Companion to the store-cache depth-amplification fix (#3713): keeping distribution layers clean lets that fix's read short-circuit actually hold on the reward path.

🤖 Generated with Claude Code

The distribution reward query handlers (DelegationRewards / DelegationTotalRewards
in grpc_query.go, and their amino equivalents in querier.go) call
IncrementValidatorPeriod, which mutates distribution state (validator period,
historical/current rewards, reference counts, and the zero-token community-pool
transfer). Queries are meant to be side-effect free — the amino querier already
branches a CacheContext "to isolate state changes", and the gRPC query server runs
on a cached context — but the EVM distribution `rewards()` precompile calls the
gRPC handler with the live context, so those writes actually persist and commit
with the transaction.

Replace the "IncrementValidatorPeriod then CalculateDelegationRewards" sequence in
the query paths with a read-only computation:

- Add currentRewardsEndingPeriodAndRatio, which computes in memory the ending
  period and cumulative reward ratio that IncrementValidatorPeriod would persist,
  without writing.
- Factor CalculateDelegationRewards into a shared core that accepts the ending
  ratio; CalculateDelegationRewardsReadOnly passes the in-memory ratio and never
  writes. The state-changing (withdraw) path passes nil so the core reads the
  ending ratio from the store lazily in the final period — byte-for-byte the same
  store accesses and gas as before (verified: precompile withdraw gas unchanged).

Tests assert the read-only result equals the write-path result (including the
slash-event path) and that neither the helper nor the gRPC query mutates the
validator period or historical reference count, and that repeated queries are
idempotent.

NOTE (gating): the gRPC handlers are called by the EVM distribution precompile
(current and every legacy version), so no longer persisting these writes changes
committed state for any tx that called `rewards()`. This is state-machine-breaking
and must ship gated (precompile version / upgrade height) to preserve historical
replay. Gating is intentionally left out of this focused change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Jul 10, 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 17, 2026, 4:48 AM

@codecov

codecov Bot commented Jul 10, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 79.06977% with 9 lines in your changes missing coverage. Please review.
✅ Project coverage is 58.96%. Comparing base (73e596a) to head (b5a3010).
⚠️ Report is 16 commits behind head on main.

Files with missing lines Patch % Lines
sei-cosmos/x/distribution/keeper/delegation.go 76.92% 4 Missing and 5 partials ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main    #3738      +/-   ##
==========================================
- Coverage   59.87%   58.96%   -0.92%     
==========================================
  Files        2286     2199      -87     
  Lines      189603   179822    -9781     
==========================================
- Hits       113522   106025    -7497     
+ Misses      65959    64473    -1486     
+ Partials    10122     9324     -798     
Flag Coverage Δ
sei-chain-pr 62.77% <79.06%> (?)
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/x/distribution/keeper/grpc_query.go 65.38% <100.00%> (-0.53%) ⬇️
sei-cosmos/x/distribution/keeper/querier.go 54.79% <100.00%> (-0.62%) ⬇️
sei-cosmos/x/distribution/keeper/delegation.go 70.73% <76.92%> (+2.95%) ⬆️

... and 88 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.

@codchen
codchen marked this pull request as ready for review July 13, 2026 03:27
@cursor

cursor Bot commented Jul 13, 2026

Copy link
Copy Markdown

PR Summary

High Risk
Changes consensus-sensitive distribution reward math and state side effects on query paths, with upgrade-gated behavior for historical tracing; incorrect gating or ratio parity would cause halts or wrong rewards.

Overview
Delegation reward queries (gRPC and legacy amino) no longer call IncrementValidatorPeriod, which was mutating distribution state when handlers ran on a live context (e.g. the EVM rewards() precompile).

They now go through DelegationRewardsForQuery, which computes the same amounts via an in-memory ending period/ratio (CalculateDelegationRewardsReadOnly / currentRewardsEndingPeriodAndRatio) while withdraw still increments the period and uses the shared calculateDelegationRewards core with lazy store reads so gas behavior stays the same.

v6.7 replay gating: live execution always uses the read-only path; when ctx.IsTracing(), pre-v6.7 blocks still run the old IncrementValidatorPeriod + calculate sequence so historical debug_trace* replay matches prior committed behavior. New tests assert read-only parity with the write path, query idempotency, and the tracing upgrade gate.

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

Comment thread sei-cosmos/x/distribution/keeper/grpc_query.go Outdated
seidroid[bot]
seidroid Bot previously requested changes Jul 13, 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 read-only reward computation is correctly implemented (it mirrors IncrementValidatorPeriod's ratio math exactly and preserves the withdraw path's store accesses/gas), and it's well-tested. However, the change alters committed consensus state for historical blocks without any version/upgrade-height gating, so it must not merge as-is — the PR author acknowledges this.

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

Blockers

  • Missing consensus/replay gate. The EVM distribution precompile rewards() (precompiles/distribution/distribution.go:349-350) calls DelegationTotalRewards with the LIVE context (sdk.WrapSDKContext(ctx), no cache branch), so the previous IncrementValidatorPeriod writes (validator period, historical/current rewards, reference counts, zero-token community-pool transfer) were actually persisted and committed for any tx that invoked rewards(). Removing them changes committed state — and gas consumed — for those historical txs, so replaying pre-upgrade blocks will diverge. This is confirmed by the app-hash-breaking label and the PR description itself, which states the change 'must ship gated' and that gating was intentionally left out. The change should be gated behind a precompile version snapshot or upgrade-height flag before merge.
  • 1 blocking issue(s) flagged inline on specific lines.

Non-blocking

  • cursor-review.md is empty — the Cursor second-opinion pass produced no output; only Codex's P0 (which this review confirms) was available.
  • REVIEW_GUIDELINES.md is empty/missing, so no repo-specific standards could be applied.
  • The Codex P0 anchors the gating concern to the single-delegation DelegationRewards handler (grpc_query.go:155). Note the concrete consensus break is via the DelegationTotalRewards handler, which is the one the EVM precompile actually invokes with a live context; both query handlers change, but only the precompile-reached path commits state today.

delReward := k.CalculateDelegationRewards(ctx, val, del, endingPeriod)
// Read-only: this is a query, so compute rewards without persisting a
// validator-period increment (see CalculateDelegationRewardsReadOnly).
delReward := k.CalculateDelegationRewardsReadOnly(ctx, val, del)

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] Blocker (consensus/replay): this is the handler the EVM rewards() precompile invokes with a LIVE context (sdk.WrapSDKContext(ctx) at precompiles/distribution/distribution.go:349), so the removed IncrementValidatorPeriod writes were previously persisted and committed for every tx that called rewards(). Dropping them — and reducing the gas consumed on that path — changes committed state and app hashes for historical blocks, breaking replay of every prior version (current + legacy v65…v640). As the PR description states, this must be gated behind a precompile-version snapshot or upgrade-height flag; it should not merge ungated. (The read-only math itself is correct and matches the write path — the only issue is the missing gate.)

Comment thread sei-cosmos/x/distribution/keeper/delegation.go Outdated
seidroid[bot]
seidroid Bot previously requested changes Jul 13, 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 read-only reward computation is correct, well-factored, and gas-preserving on the withdraw path, with good tests. However the upgrade gate keys the "reproduce old mutating behavior" branch on ctx.IsTracing(), so any non-tracing execution (early binary rollout before the upgrade height, or block-by-block replay/sync of a pre-v6.7.0 block that invoked the rewards() precompile) will diverge from the historically-committed app hash — a consensus-safety concern that must be resolved before merge.

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

Blockers

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

Non-blocking

  • readOnlyRewardsUpgrade = "v6.7.0" is a hardcoded constant; the PR body already notes it must be updated if the shipping upgrade name changes. Since a mismatched name silently mis-gates a state-machine-breaking change, consider deriving/validating it against the actual registered upgrade name (or add a compile-time/test assertion) rather than relying on a manual edit.
  • Cursor's second-opinion pass (cursor-review.md) produced no output — no findings were available to merge from it. Codex's single P1 finding is the same consensus-divergence concern flagged inline below.
  • The read-only refactor itself looks correct: currentRewardsEndingPeriodAndRatio faithfully mirrors the ratio math in IncrementValidatorPeriod (historical[period-1] + current, zero-token → zero ratio), and the nil-endingRatio lazy read preserves the withdraw path's exact store accesses and gas. Tests cover match-with-write-path, query idempotency, and the gate.

// behavior when tracing a block from before the v6.7.0 upgrade. Live execution
// (not tracing) always uses the read-only path.
func (k Keeper) DelegationRewardsForQuery(ctx sdk.Context, val stakingtypes.ValidatorI, del stakingtypes.DelegationI) sdk.DecCoins {
if ctx.IsTracing() && semver.Compare(ctx.ClosestUpgradeName(), readOnlyRewardsUpgrade) < 0 {

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] The gate only reproduces the pre-v6.7.0 mutating behavior when ctx.IsTracing() is true, but ClosestUpgradeName is populated only on the RPC/trace context paths (app.go ~2670/2681/2721) — it is empty during normal DeliverTx/BeginBlock. That means all non-tracing execution takes the read-only branch unconditionally, regardless of height/upgrade, which breaks historical determinism in two ways:

  1. Non-tracing replay/sync of pre-v6.7.0 blocks. A node re-executing (block-by-block sync, not state-sync snapshot) a pre-v6.7.0 block that called the rewards() precompile in a tx runs with IsTracing()==false, so it skips IncrementValidatorPeriod and fails to reproduce the writes that were committed to that block's app hash → app-hash mismatch / halt.
  2. Early binary rollout. If this binary runs live before the v6.7.0 upgrade height, it produces read-only blocks while old-binary nodes still mutate → divergence before the upgrade activates.

The existing !IsTracing() idiom in giga/deps/xevm/keeper/params.go is safe under replay only because it reads height-correct state (GetParams); here you're changing a write behavior that height-correct state can't reconstruct, so the analogy doesn't hold. This is Codex's P1.

Suggested fix: gate the live behavior on the upgrade actually being active for the block being executed (e.g. via the upgrade keeper's IsUpgradeActiveAtHeight / comparing ctx.BlockHeight() to the v6.7.0 upgrade height) in addition to the tracing branch, so binary-rollout timing and non-tracing replay both reproduce the correct behavior. The distribution keeper would need a reference to the upgrade keeper (or height) to do this.

Comment thread sei-cosmos/x/distribution/keeper/delegation.go Outdated
@codchen
codchen force-pushed the claude/distr-readonly-reward-query branch from 1011463 to 025cce2 Compare July 13, 2026 05:33
seidroid[bot]
seidroid Bot previously requested changes Jul 13, 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 refactor to make delegation-reward queries read-only is careful, well-documented, and well-tested, and the read-only computation faithfully mirrors the write path. However, the read-only behavior is gated on upgrade name "v6.7.0", which is not registered in app/tags (which ends at v6.6) and does not match the repo's current tag naming convention (v6.5, v6.6 — no patch component), so in production the fix is inert on the live path and risks trace/live divergence.

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

Blockers

  • Upgrade gating is inert in production. readOnlyRewardsUpgrade = "v6.7.0" is never registered in app/tags (the list ends at v6.6), so no upgrade handler and no "done" marker are ever created for it. IsUpgradeActiveAtHeight does an exact store-key lookup on that marker, so the live/non-tracing path never flips to read-only and the EVM rewards() precompile keeps persisting distribution state indefinitely — the exact bug this PR sets out to fix. This confirms Codex's P1. Before merge, the v6.7.0 upgrade tag must be registered and the constant must exactly match the on-chain upgrade name.
  • Naming-convention mismatch creates a determinism hazard. The recent tags are v6.5 and v6.6 (no patch), so the next upgrade will almost certainly be named v6.7, not v6.7.0. The live path (IsUpgradeActiveAtHeight) matches the upgrade name by exact string, so a v6.7 marker would never satisfy a "v6.7.0" lookup; meanwhile the tracing path (semver.Compare(ClosestUpgradeName(), "v6.7.0")) treats v6.7 as equal to v6.7.0. Result: for a v6.7 block, live/sync would commit the mutating result while tracing reproduces the read-only result — trace/replay divergence, i.e. the precise determinism problem the gating is meant to prevent. The constant and the registered tag must be reconciled (exact-match on both paths).
  • 1 blocking issue(s) flagged inline on specific lines.

Non-blocking

  • Confirmed the trace-vs-live dispatch is sound: WithTraceMode/WithIsTracing both set isTracing=true, so RPC/trace contexts take the semver branch and live block execution takes the upgradeChecker branch — matching the PR's stated design.
  • cursor-review.md is empty — the Cursor second-opinion pass produced no output.
  • REVIEW_GUIDELINES.md is empty — no repo-specific review standards were available to apply.
  • Aside from the upgrade-name gating, the change is high quality: the shared calculateDelegationRewards core preserves the withdraw path's lazy final-period store read (byte-for-byte gas), the read-only ratio derivation mirrors IncrementValidatorPeriod exactly, and the new tests cover the read-only equivalence, idempotency, and all four gating quadrants.


// readOnlyRewardsUpgrade is the upgrade at which delegation-reward queries stopped
// persisting a validator-period increment (see DelegationRewardsForQuery).
const readOnlyRewardsUpgrade = "v6.7.0"

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] "v6.7.0" is not registered in app/tags (which ends at v6.6), so IsUpgradeActiveAtHeight(ctx, "v6.7.0", ...) — an exact store-key lookup on the upgrade "done" marker — never returns true on the live path. The read-only behavior therefore never activates in production and the rewards() precompile keeps mutating/committing distribution state (the bug this PR fixes). Additionally, the current tag convention is v6.5/v6.6 (no patch), so the next tag is likely v6.7, which would match the tracing gate (semver.Compare treats v6.7==v6.7.0) but NOT the live exact-match gate — causing trace/live app-hash divergence for that block. Register the upgrade tag and make this constant exactly equal the on-chain upgrade name before merge.

Comment thread sei-cosmos/x/distribution/keeper/delegation.go Outdated

@claude claude 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.

Warning

This review may be incomplete: some analysis steps could not run due to a temporary API capacity limit.

@codchen
codchen force-pushed the claude/distr-readonly-reward-query branch from 025cce2 to 4b0e3d6 Compare July 14, 2026 04:14
seidroid[bot]
seidroid Bot previously requested changes Jul 14, 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 core refactor (read-only reward computation matching the write path byte-for-byte, lazy final-period ratio read to preserve withdraw gas) is well-structured and well-tested, but the upgrade gating has two real problems: the tracing gate over-activates read-only for blocks in the v6.6→v6.7 window, and the gate is inert until v6.7 is actually registered in app/tags. Cursor produced no output; Codex flagged both issues, which I confirmed.

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

Blockers

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

Non-blocking

  • A chain launched from genesis at v6.7+ (no v6.7 upgrade ever applied → no done marker, ClosestUpgradeName resolves to the latest tag) will keep the old state-mutating query behavior indefinitely. The PR acknowledges this and it is reward-correct, but the original view-side state-mutation bug then persists forever on such chains — worth a note/follow-up if genesis-launched networks are in scope.
  • Cursor's second-opinion review file (cursor-review.md) was empty — that pass produced no output. Codex's review was present and its two P1 findings are reflected inline below.
  • TestReadOnlyRewardsUpgradeGateReconciled currently passes vacuously because no tag in app/tags is semver-equal to "v6.7". Good defensive guard for the future, but note it exercises nothing today; it only becomes meaningful once v6.7 is registered.

// default to the old state-mutating behavior, which is always reward-correct.
func (k Keeper) rewardQueryIsReadOnly(ctx sdk.Context) bool {
if ctx.IsTracing() {
return semver.Compare(ctx.ClosestUpgradeName(), ReadOnlyRewardsUpgrade) >= 0

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] Tracing gate over-activates read-only for the v6.6→v6.7 window. GetClosestUpgrade (upgrade keeper) returns the earliest done-upgrade whose height is >= queryHeight — i.e. the next applied upgrade for a block strictly between two upgrades. On a node that has already applied v6.7, tracing / historical eth_call at a height between v6.6's and v6.7's done markers resolves ClosestUpgradeName() == "v6.7", so this returns true (read-only), while live execution at that same height took the mutating path (v6.7 not yet active). That makes historical eth_call/debug_trace of rewards() calls in the entire v6.6-era window inconsistent with the state/gas actually committed at those heights — the exact trace/replay divergence this gate is meant to prevent. Note this mirrors the existing x/evm/keeper/params.go idiom, but that idiom's gates are all at ancient boundaries where mid-interval blocks resolve to "latest" anyway; this is the first gate placed at the newest upgrade boundary, where the mid-interval semantics actually bite. Please confirm GetClosestUpgrade's behavior for mid-interval historical blocks and gate tracing on the block height relative to v6.7's done height rather than on ClosestUpgradeName() >= v6.7.

// trace/replay divergence this gate exists to prevent. Recent tags drop the patch
// component (…, v6.5, v6.6), so this is "v6.7", not "v6.7.0". The invariant is
// guarded by app.TestReadOnlyRewardsUpgradeGateReconciled.
const ReadOnlyRewardsUpgrade = "v6.7"

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] The gate never activates as shipped: app/tags currently ends at v6.6, so no v6.7 upgrade handler is registered and no v6.7 done marker will ever be written. IsUpgradeActiveAtHeight("v6.7", …) is therefore always false on live/synced nodes, meaning the read-only fix is inert and the query handlers keep mutating state indefinitely — the bug this PR fixes stays live. TestReadOnlyRewardsUpgradeGateReconciled also passes vacuously in this state. Please confirm the v6.7 tag (and its upgrade handler) land together with — or ahead of — this change so the fix actually takes effect, and keep the constant byte-matched to the registered tag as the comment/test require.

@claude claude 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.

No new bugs found this round. Since my earlier blocking comment, the gate has been reworked: the live/non-tracing path now checks IsUpgradeActiveAtHeight against ctx.BlockHeight() (rather than being gated on IsTracing(), which never fires during DeliverTx/sync), and the constant was corrected from v6.7.0 to v6.7 with a new TestReadOnlyRewardsUpgradeGateReconciled guarding the exact-tag-match invariant going forward.

This is still an app-hash-breaking change to consensus-critical distribution/upgrade-replay logic that has already gone through several rounds of high-severity findings (ungated mutation, gate-boundary math, tag-format mismatch). Given that history and the subtlety of getting historical replay right across both tracing and live paths, I'd recommend a human sign-off before merge even though this pass turned up nothing new.

What was reviewed:

  • Re-checked the live-path gate fix (IsUpgradeActiveAtHeight on block height) against the DeliverTx-never-fires concern raised previously — now height-derived, so it applies uniformly to sync/replay.
  • Checked the tag-format fix (v6.7 vs v6.7.0) against app/tags convention and the new reconciliation test.
  • Investigated whether tracing's ClosestUpgradeName/GetClosestUpgrade could report a not-yet-triggered v6.7 as active (including the eth_call/eth_estimateGas-at-latest defaulting to LatestUpgrade) — ruled out, since GetClosestUpgrade only returns already-applied (done) upgrades.
Extended reasoning...

Overview

PR #3738 fixes distribution reward queries (DelegationRewards/DelegationTotalRewards in grpc_query.go and their amino equivalents in querier.go) to stop mutating state via IncrementValidatorPeriod, which mattered because the EVM rewards() precompile invokes these on a live (non-cached) context. Flipping to read-only is app-hash-breaking, so the PR adds an upgrade gate (v6.7) so historical blocks still replay with the old mutating behavior. Touches app/app.go (wiring UpgradeKeeper into the distribution keeper), sei-cosmos/x/distribution/keeper/{delegation,grpc_query,keeper,querier}.go, and sei-cosmos/x/distribution/types/expected_keepers.go.

Security risks

No injection/auth-bypass/data-exposure surface — this is purely internal consensus-state computation. The risk category here is determinism/consensus-safety: an incorrect gate could cause app-hash divergence and halt nodes during sync or produce incorrect trace results, rather than a conventional security vulnerability.

Level of scrutiny

High. This is labeled app-hash-breaking and touches a chain-halting failure mode (upgrade-gated state-machine behavior change) rather than a simple logic fix. The PR has already been through multiple rounds of high-severity bugbot/seidroid/claude[bot] findings (ungated mutation on the precompile path, an off-by-one in the tracing gate boundary, the live path never actually gating on IsTracing() during DeliverTx, and a v6.7 vs v6.7.0 tag-format mismatch), all of which appear addressed in the current diff (live path now uses IsUpgradeActiveAtHeight on block height; the constant is now v6.7; a reconciliation test guards the tag match). Given the subtlety already demonstrated in this thread and the consensus-critical nature of the change, this warrants a human sign-off before merge regardless of this round's outcome.

Other factors

The PR includes solid test coverage for the new behavior (TestCalculateDelegationRewardsReadOnlyMatchesWritePath, TestDelegationTotalRewardsQueryIsReadOnly, TestDelegationRewardsForQueryUpgradeGate with four sub-cases, and TestReadOnlyRewardsUpgradeGateReconciled), and the withdraw (state-changing) path is explicitly preserved byte-for-byte via the nil-ratio sentinel. No CODEOWNERS-restricted files were touched beyond core distribution keeper code. Given the iteration history and the criticality of getting this right, deferring to a human is the safer call even with a clean bug-hunt result this round.

The read-only reward-query change is state-machine-breaking on the EVM
distribution `rewards()` precompile path (which ran the query on the live context
and persisted IncrementValidatorPeriod's writes). Gate it so historical
re-execution stays deterministic.

Live (non-tracing) execution always uses the read-only path: the current binary
only ever executes blocks at/after the upgrade that ships this behavior, so a
non-tracing block is never a pre-upgrade block (cosmovisor runs the prior binary
for those; we never replay pre-upgrade txs non-tracingly). The only place the old
mutating behavior must be reproduced is re-tracing a historical pre-v6.7 block via
debug_trace*, where the era is signaled by the trace harness through
ClosestUpgradeName and compared by semver — matching the existing
x/evm/keeper/params.go idiom (`if !ctx.IsTracing() { return current }`).

    func (k Keeper) rewardQueryIsReadOnly(ctx sdk.Context) bool {
        if !ctx.IsTracing() {
            return true
        }
        return semver.Compare(ctx.ClosestUpgradeName(), ReadOnlyRewardsUpgrade) >= 0
    }

This drops the earlier live-path gate (UpgradeChecker.IsUpgradeActiveAtHeight and
its wiring): it was unnecessary given the above, and it had introduced an
exact-match-vs-semver naming hazard between the two paths. With only the semver
tracing gate the hazard is gone, so the app-level reconciliation guardrail test is
removed too. ReadOnlyRewardsUpgrade stays "v6.7" (app/tags convention: v6.5, v6.6).

The reward figure is identical in both paths; only whether the query mutates state
differs. TestDelegationRewardsForQueryUpgradeGate covers: live is read-only,
tracing pre-v6.7 mutates, tracing v6.7+ is read-only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@codchen
codchen force-pushed the claude/distr-readonly-reward-query branch from 4b0e3d6 to b5a3010 Compare July 17, 2026 04:46

@cursor cursor 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.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit b5a3010. Configure here.

return true
}
return semver.Compare(ctx.ClosestUpgradeName(), ReadOnlyRewardsUpgrade) >= 0
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Upgrade gate skips live replay

High Severity

rewardQueryIsReadOnly returns true for every non-tracing context, so pre-v6.7 blocks that called the distribution rewards() precompile on a live context no longer reproduce the committed IncrementValidatorPeriod writes. Replaying those blocks during non-tracing sync diverges on app hash and can halt the node. The PR calls for gating live/replay on IsUpgradeActiveAtHeight, matching existing GetDoneHeight consensus gates elsewhere.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit b5a3010. Configure here.

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

Makes distribution reward queries read-only, correctly mirroring the IncrementValidatorPeriod + CalculateDelegationRewards math without mutating state, with solid test coverage. The core logic and gating pattern are sound; the main open item is that the tracing gate depends on a "v6.7" upgrade that isn't yet registered in app/tags, which must be coordinated with the shipping release.

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

Blockers

  • None at the file/PR level.

Non-blocking

  • Release-coordination dependency (see inline on delegation.go:20): this app-hash-breaking change's tracing/replay correctness hinges on a v6.7 (or later) upgrade being registered in app/tags at the exact deploy height. Confirm that ships in the same release; otherwise debug_trace* of post-deploy rewards()-calling txs diverges from live read-only execution.
  • Re Codex's sole P1: I disagree that returning read-only for all non-tracing contexts breaks deterministic replay. It matches the established x/evm/keeper/params.go idiom (if !ctx.IsTracing() { return current } + semver on ClosestUpgradeName); under cosmovisor the new binary never non-tracingly re-executes pre-upgrade blocks. The underlying dependency it points at (a real v6.7 upgrade must exist) is valid and captured in the inline note.
  • cursor-review.md is empty — the Cursor pass produced no output.
  • REVIEW_GUIDELINES.md is empty/missing on the base branch — reviewed without repo-specific guidelines.
  • Verified the read-only helper reproduces IncrementValidatorPeriod's ending period and ratio exactly, including the zero-token branch (zero ratio, community-pool transfer skipped), and that the withdraw path keeps its lazy final-period store read to preserve gas. No parallel mutating copy exists under giga/deps.
  • 2 suggestion(s)/nit(s) flagged inline on specific lines.

// semver comparison against ctx.ClosestUpgradeName(), so it just needs to be
// semver-equal to that release. It follows the app/tags convention (…, v6.5, v6.6),
// hence "v6.7".
const ReadOnlyRewardsUpgrade = "v6.7"

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] ReadOnlyRewardsUpgrade = "v6.7" names an upgrade that doesn't exist in app/tags yet (latest is v6.6), and this PR doesn't add it. GetClosestUpgrade only ever returns names of applied upgrades, so ctx.ClosestUpgradeName() can never be ≥ v6.7 until such an upgrade is registered and applied. Consequently rewardQueryIsReadOnly returns false for every traced block, meaning debug_trace* of any post-deploy block that invoked the rewards() precompile will take the mutating pre-v6.7 branch and diverge from the read-only path that live execution actually took. This only becomes correct once a v6.7 (or later) upgrade is registered at the exact height this behavior ships — and if the release is instead cut as, say, a v6.6.x hotfix, semver.Compare("v6.6.x", "v6.7") < 0 makes the boundary wrong. Please confirm the v6.7 tag/upgrade-handler lands in the same release at the deploy height (this is a stricter coupling than params.go, whose gated versions all already exist in app/tags).

// reproduced is when re-tracing a historical pre-v6.7 block via debug_trace*, where
// the era is signaled by the trace harness through ClosestUpgradeName (see
// app.RPCContextProvider) and compared by semver (empty name sorts before v6.7).
func (k Keeper) rewardQueryIsReadOnly(ctx sdk.Context) bool {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[nit] The !ctx.IsTracing() early-return matches the established idiom in x/evm/keeper/params.go, so this is consistent with the codebase. Worth noting it inherits the same assumption those call sites rely on: correctness depends on the new binary never non-tracingly re-executing a pre-upgrade block (cosmovisor swaps binaries at upgrade heights). That holds here; flagging only so the coupling to the v6.7 tag (above) is understood as the load-bearing part, not this branch.

@seidroid
seidroid Bot dismissed stale reviews from themself July 17, 2026 04:53

Superseded: latest AI review found no blocking issues.

@claude claude 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.

Beyond the inline finding, I also checked whether plain eth_call/eth_estimateGas could be misclassified as tracing (which would make rewardQueryIsReadOnly incorrectly take the mutating branch on those paths) — ctx.IsTracing() is only ever set to true in evmrpc/tracers.go and evmrpc/trace_profile.go (the debug_trace* context providers), so that's not the case here.

Extended reasoning...

Checked whether IsTracing() could be set by non-tracing RPC entry points (eth_call, eth_estimateGas) by grepping all WithIsTracing call sites across the repo. Only evmrpc/tracers.go:599 and evmrpc/trace_profile.go:143 (both debug_trace*/profile handlers) set it; standard eth_call/eth_estimateGas code paths never do. So the candidate concern that rewardQueryIsReadOnly could misclassify plain calls as tracing and take the wrong (mutating) branch does not hold.

Comment on lines +14 to +20
// ReadOnlyRewardsUpgrade is the upgrade at which delegation-reward queries stopped
// persisting a validator-period increment (see DelegationRewardsForQuery). It is
// only consulted when re-tracing a historical block (ctx.IsTracing()), and only via
// semver comparison against ctx.ClosestUpgradeName(), so it just needs to be
// semver-equal to that release. It follows the app/tags convention (…, v6.5, v6.6),
// hence "v6.7".
const ReadOnlyRewardsUpgrade = "v6.7"

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 tracing gate's target, ReadOnlyRewardsUpgrade = "v6.7", is never registered: app/tags still ends at v6.6 and this PR adds no v6.7 upgrade handler, so ctx.ClosestUpgradeName() can never reach "v6.7" and the tracing branch of rewardQueryIsReadOnly is permanently false. As shipped, live rewards() queries are correctly read-only, but every debug_trace*/historical trace of a post-deployment block that called the EVM rewards() precompile will incorrectly replay the old mutating IncrementValidatorPeriod path, diverging in gas/state from what was actually committed — the v6.7 tag and its upgrade handler need to land together with (or ahead of) this change.

Extended reasoning...

The tracing half of the upgrade gate this PR introduces can never activate within the scope of this PR. rewardQueryIsReadOnly (delegation.go:14-20, ~145-149) is:

func (k Keeper) rewardQueryIsReadOnly(ctx sdk.Context) bool {
	if !ctx.IsTracing() {
		return true
	}
	return semver.Compare(ctx.ClosestUpgradeName(), ReadOnlyRewardsUpgrade) >= 0
}

with ReadOnlyRewardsUpgrade = "v6.7". ctx.ClosestUpgradeName() is only ever populated from UpgradeKeeper.GetClosestUpgrade, which walks the upgrade keeper's "Done" store — it can only return the name of an upgrade that was actually registered via SetUpgradeHandler (driven by app/upgrades.go's upgradesList, itself parsed from the app/tags file) and applied on-chain. The fallback used when there is no closest-upgrade match, LatestUpgrade, is set to upgradesList[len-1] — again, the last entry in app/tags.

I verified directly that app/tags currently ends at v6.6 (..., v6.4.0, v6.5, v6.6), and a repo-wide grep for v6.7 under app/ returns nothing — the only occurrences of "v6.7" anywhere in the tree are this PR's own ReadOnlyRewardsUpgrade constant and its accompanying comments/tests in the four changed distribution files. This PR's diff is scoped to sei-cosmos/x/distribution/keeper/{delegation.go,delegation_readonly_test.go,grpc_query.go,querier.go} only; it does not touch app/tags or app/upgrades.go.

Consequently ClosestUpgradeName() can never resolve to "v6.7" (or anything semver >= "v6.7") — it is always bounded above by "v6.6", and semver.Compare("v6.6", "v6.7") < 0 always. The tracing branch of the gate is therefore permanently false: debug_trace*/historical-trace calls will always take the old, pre-fix, mutating IncrementValidatorPeriod + CalculateDelegationRewards branch, forever — including for blocks produced after this binary goes live.

This matters because the two branches are no longer equivalent once the binary is deployed. Live (non-tracing) execution of DelegationRewardsForQuery always takes the new read-only branch unconditionally (!ctx.IsTracing() → return true), so every post-deployment block that invoked the EVM rewards() precompile actually commits with the read-only behavior — no IncrementValidatorPeriod write, no period bump, no historical-reward-ratio persistence, no zero-token community-pool transfer. But because the tracing branch can never flip to read-only (per the above), tracing/replaying that exact same block with debug_trace* takes the mutating branch instead. That replay computes different gas (extra IncrementValidatorPeriod SDK gas flowing into remainingGas at precompiles/distribution/distribution.go:358) and touches store keys (validator period, historical rewards, reference counts) that were never actually written when the block was originally committed. This is exactly the historical-trace divergence the v6.7 gate exists to prevent — it just never engages in the direction that protects post-deployment blocks.

Step-by-step proof:

  1. This binary is deployed at chain height H. app/tags still ends at v6.6; no v6.7 upgrade handler exists or is registered anywhere.
  2. At height H+10, a contract transaction calls the EVM rewards() precompile, which invokes DelegationTotalRewards on the live context. ctx.IsTracing() is false, so rewardQueryIsReadOnly returns true unconditionally. The block commits with the read-only reward computation — no period increment, no historical-rewards persistence.
  3. Later, an operator runs debug_traceBlockByNumber (or similar) against block H+10 to re-execute it for tracing. The trace harness sets ctx.IsTracing() = true and populates ctx.ClosestUpgradeName() via UpgradeKeeper.GetClosestUpgrade/LatestUpgrade — both of which can return at most "v6.6" because no v6.7 "Done" marker was ever written (there is no v6.7 upgrade handler to have ever run).
  4. semver.Compare("v6.6", "v6.7") = -1, so the gate's tracing branch evaluates to false, and DelegationRewardsForQuery takes the pre-fix branch: IncrementValidatorPeriod + CalculateDelegationRewards.
  5. This produces a different gas trace and touches store state (period, historical ratio, reference count) that never actually changed when block H+10 was originally committed in step 2. The trace/replay output diverges from the real committed execution — defeating the exact purpose of the v6.7 gate for every block produced after this deploys.

The fix is to register a v6.7 entry in app/tags (exactly "v6.7", matching the tag convention that dropped patch versions starting at v6.5/v6.6) with a corresponding upgrade handler in app/upgrades.go, landing together with or ahead of this change, ideally with a compile-time or CI check so ReadOnlyRewardsUpgrade can never drift from the registered tag name. Without that, the tracing gate is permanently inert in the one direction (mutating → read-only) it is meant to protect, even though the PR's own test (TestDelegationRewardsForQueryUpgradeGate) passes today because it hardcodes ReadOnlyRewardsUpgrade directly into ClosestUpgradeName rather than exercising a real registered upgrade — giving false confidence that the gate works in production.

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.

1 participant