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.0)

Because the pre-fix query persisted state on the precompile path, flipping to read-only is state-machine-breaking: re-executing an old block where a contract called rewards() must still reproduce the committed writes, or the node diverges on app hash and halts. That applies to both historical tracing and non-tracing block-by-block sync — so gating on IsTracing() alone is insufficient. DelegationRewardsForQuery dispatches via rewardQueryIsReadOnly(ctx):

func (k Keeper) rewardQueryIsReadOnly(ctx sdk.Context) bool {
    if ctx.IsTracing() {
        // tracing: era signaled by the trace harness (empty during live execution)
        return semver.Compare(ctx.ClosestUpgradeName(), "v6.7.0") >= 0
    }
    // live / non-tracing replay: gate on the upgrade being active at this height,
    // derived from the persisted upgrade "done" marker -> tracks the block being executed
    return k.upgradeChecker != nil &&
        k.upgradeChecker.IsUpgradeActiveAtHeight(ctx, "v6.7.0", ctx.BlockHeight())
}
  • Live tip / non-tracing replay: IsUpgradeActiveAtHeight reads the upgrade module's "done" marker, which is part of versioned state — so replaying a pre-v6.7.0 block (marker not yet written at that height) reproduces the old mutating behavior, and a post-upgrade block uses read-only. This is the case that would otherwise halt a syncing node.
  • Tracing: uses ClosestUpgradeName (set by app.RPCContextProvider from GetClosestUpgrade), matching the existing x/evm/keeper/params.go idiom.

New behavior activates at the v6.7.0 upgrade height. The reward figure is identical in both paths; only whether the query mutates state differs.

Wiring: a minimal types.UpgradeChecker interface (IsUpgradeActiveAtHeight) plus Keeper.SetUpgradeChecker; app.go injects app.UpgradeKeeper into the distribution keeper right after it is created and before it is copied into hooks/modules (same pattern as StakingKeeper.SetHooks). readOnlyRewardsUpgrade = "v6.7.0" — adjust if the on-chain upgrade name differs.

Note: a chain launched from genesis at v6.7.0+ (no v6.7.0 upgrade applied → no "done" marker) keeps the old mutating behavior. That is always reward-correct and halt-free; only the mainnet upgrade path (and its replay) needs the flip, which this handles.

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 — with v6.7.0 active, the gRPC query leaves the validator period/reference count unchanged and is idempotent across repeated calls.
  • TestDelegationRewardsForQueryUpgradeGate — four sub-cases: live pre-v6.7.0 mutates (the sync-halt case), live v6.7.0-active is read-only, tracing pre-v6.7.0 mutates, tracing v6.7.0+ is read-only; the reward figure is identical across all four.
  • 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 13, 2026, 5:34 AM

@codecov

codecov Bot commented Jul 10, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 81.25000% with 9 lines in your changes missing coverage. Please review.
✅ Project coverage is 58.95%. Comparing base (73e596a) to head (025cce2).
⚠️ Report is 3 commits behind head on main.

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

Impacted file tree graph

@@            Coverage Diff             @@
##             main    #3738      +/-   ##
==========================================
- Coverage   59.87%   58.95%   -0.92%     
==========================================
  Files        2286     2199      -87     
  Lines      189603   179806    -9797     
==========================================
- Hits       113522   106012    -7510     
+ Misses      65959    64470    -1489     
+ Partials    10122     9324     -798     
Flag Coverage Δ
sei-chain-pr 54.79% <81.25%> (?)
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 Δ
app/app.go 70.78% <100.00%> (+0.01%) ⬆️
sei-cosmos/x/distribution/keeper/grpc_query.go 65.38% <100.00%> (-0.53%) ⬇️
sei-cosmos/x/distribution/keeper/keeper.go 87.65% <100.00%> (+0.47%) ⬆️
sei-cosmos/x/distribution/keeper/querier.go 54.79% <100.00%> (-0.62%) ⬇️
sei-cosmos/x/distribution/keeper/delegation.go 70.96% <77.50%> (+3.18%) ⬆️

... and 87 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
Touches consensus-critical distribution state and upgrade-gated behavior for historical replay; incorrect gating or reward parity would cause app-hash divergence or wrong rewards on the EVM precompile path.

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

Read-only path: CalculateDelegationRewardsReadOnly derives the ending period and cumulative reward ratio in memory (currentRewardsEndingPeriodAndRatio) so reward amounts match the old write path without persisting period/reference-count changes. Reward math is refactored through rewardsFromRatios and a shared calculateDelegationRewards core; withdraw still uses IncrementValidatorPeriod plus lazy store reads for gas parity.

Upgrade gating (v6.7.0): DelegationRewardsForQuery uses the read-only path when the upgrade is active at the block height (via UpgradeChecker on live replay) or when tracing with ClosestUpgradeName ≥ v6.7.0; pre-v6.7.0 blocks still run the old period increment so block sync and historical trace match committed app hashes. app.DistrKeeper is wired with SetUpgradeChecker(app.UpgradeKeeper) before hooks/modules copy the keeper.

Tests cover read-only vs write-path equality, query idempotency, and all gating branches (live/tracing, pre/post v6.7.0).

Reviewed by Cursor Bugbot for commit 025cce2. 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 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 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
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 any pre-v6.7.0 block
is re-executed with the old mutating behavior — whether it is re-traced OR
replayed block-by-block during non-tracing sync — otherwise a syncing node would
skip the writes committed to that block's app hash and halt on an app-hash
mismatch.

DelegationRewardsForQuery now dispatches via rewardQueryIsReadOnly(ctx):
- Tracing: gate on ctx.ClosestUpgradeName() (set by the trace harness; empty
  during live execution) vs "v6.7.0".
- Live / non-tracing replay: gate on the upgrade being active at ctx.BlockHeight()
  via UpgradeChecker.IsUpgradeActiveAtHeight, which is derived from the persisted
  upgrade "done" marker and therefore tracks the block being (re-)executed. If the
  checker is unset (some test harnesses), default to the old, always-reward-correct
  mutating behavior.

Wiring:
- Add types.UpgradeChecker (minimal IsUpgradeActiveAtHeight interface) and
  Keeper.SetUpgradeChecker; app.go injects app.UpgradeKeeper into the distribution
  keeper before it is copied into hooks/modules (mirroring SetHooks wiring).

The reward figure is identical in both paths; only whether the query mutates state
differs. TestDelegationRewardsForQueryUpgradeGate covers all four cases: live
pre-v6.7.0 (mutates), live v6.7.0-active (read-only), tracing pre-v6.7.0 (mutates),
tracing v6.7.0+ (read-only).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@codchen codchen force-pushed the claude/distr-readonly-reward-query branch from 1011463 to 025cce2 Compare July 13, 2026 05:33

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

@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 025cce2. Configure here.


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

Wrong upgrade name format

High Severity

readOnlyRewardsUpgrade is "v6.7.0", but from v6.5 onward Sei upgrade names use "v6.7" with no .0. IsUpgradeActiveAtHeight looks up the done marker by exact string key, so after a real "v6.7" upgrade the live gate stays false and reward queries keep mutating state.

Additional Locations (2)
Fix in Cursor Fix in Web

Triggered by learned rule: app: upgrade names from v6.5+ use v. format — no .0 suffix

Reviewed by Cursor Bugbot for commit 025cce2. Configure here.

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

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