fix(distribution): make delegation-reward queries read-only#3738
fix(distribution): make delegation-reward queries read-only#3738codchen wants to merge 2 commits into
Conversation
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>
|
The latest Buf updates on your PR. Results from workflow Buf / buf (pull_request).
|
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ 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
Flags with carried forward coverage won't be shown. Click here to find out more.
🚀 New features to boost your workflow:
|
PR SummaryHigh Risk Overview They now go through v6.7 replay gating: live execution always uses the read-only path; when Reviewed by Cursor Bugbot for commit b5a3010. Bugbot is set up for automated code reviews on this repo. Configure here. |
There was a problem hiding this comment.
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) callsDelegationTotalRewardswith the LIVE context (sdk.WrapSDKContext(ctx), no cache branch), so the previousIncrementValidatorPeriodwrites (validator period, historical/current rewards, reference counts, zero-token community-pool transfer) were actually persisted and committed for any tx that invokedrewards(). Removing them changes committed state — and gas consumed — for those historical txs, so replaying pre-upgrade blocks will diverge. This is confirmed by theapp-hash-breakinglabel 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
DelegationRewardshandler (grpc_query.go:155). Note the concrete consensus break is via theDelegationTotalRewardshandler, 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) |
There was a problem hiding this comment.
[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.)
There was a problem hiding this comment.
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:
currentRewardsEndingPeriodAndRatiofaithfully mirrors the ratio math inIncrementValidatorPeriod(historical[period-1] + current, zero-token → zero ratio), and the nil-endingRatiolazy 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 { |
There was a problem hiding this comment.
[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:
- 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 withIsTracing()==false, so it skipsIncrementValidatorPeriodand fails to reproduce the writes that were committed to that block's app hash → app-hash mismatch / halt. - 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.
1011463 to
025cce2
Compare
There was a problem hiding this comment.
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 inapp/tags(the list ends atv6.6), so no upgrade handler and no "done" marker are ever created for it.IsUpgradeActiveAtHeightdoes an exact store-key lookup on that marker, so the live/non-tracing path never flips to read-only and the EVMrewards()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.5andv6.6(no patch), so the next upgrade will almost certainly be namedv6.7, notv6.7.0. The live path (IsUpgradeActiveAtHeight) matches the upgrade name by exact string, so av6.7marker would never satisfy a"v6.7.0"lookup; meanwhile the tracing path (semver.Compare(ClosestUpgradeName(), "v6.7.0")) treatsv6.7as equal tov6.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/WithIsTracingboth setisTracing=true, so RPC/trace contexts take the semver branch and live block execution takes theupgradeCheckerbranch — matching the PR's stated design. cursor-review.mdis empty — the Cursor second-opinion pass produced no output.REVIEW_GUIDELINES.mdis empty — no repo-specific review standards were available to apply.- Aside from the upgrade-name gating, the change is high quality: the shared
calculateDelegationRewardscore preserves the withdraw path's lazy final-period store read (byte-for-byte gas), the read-only ratio derivation mirrorsIncrementValidatorPeriodexactly, 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" |
There was a problem hiding this comment.
[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.
025cce2 to
4b0e3d6
Compare
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
[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" |
There was a problem hiding this comment.
[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.
There was a problem hiding this comment.
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 (
IsUpgradeActiveAtHeighton 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.7vsv6.7.0) againstapp/tagsconvention and the new reconciliation test. - Investigated whether tracing's
ClosestUpgradeName/GetClosestUpgradecould report a not-yet-triggered v6.7 as active (including theeth_call/eth_estimateGas-at-latest defaulting toLatestUpgrade) — ruled out, sinceGetClosestUpgradeonly 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>
4b0e3d6 to
b5a3010
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
❌ 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 | ||
| } |
There was a problem hiding this comment.
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.
Reviewed by Cursor Bugbot for commit b5a3010. Configure here.
There was a problem hiding this comment.
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" |
There was a problem hiding this comment.
[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 { |
There was a problem hiding this comment.
[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.
Superseded: latest AI review found no blocking issues.
There was a problem hiding this comment.
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.
| // 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" |
There was a problem hiding this comment.
🔴 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:
- This binary is deployed at chain height H.
app/tagsstill ends atv6.6; no v6.7 upgrade handler exists or is registered anywhere. - At height H+10, a contract transaction calls the EVM
rewards()precompile, which invokesDelegationTotalRewardson the live context.ctx.IsTracing()is false, sorewardQueryIsReadOnlyreturnstrueunconditionally. The block commits with the read-only reward computation — no period increment, no historical-rewards persistence. - Later, an operator runs
debug_traceBlockByNumber(or similar) against block H+10 to re-execute it for tracing. The trace harness setsctx.IsTracing() = trueand populatesctx.ClosestUpgradeName()viaUpgradeKeeper.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). semver.Compare("v6.6", "v6.7") = -1, so the gate's tracing branch evaluates tofalse, andDelegationRewardsForQuerytakes the pre-fix branch:IncrementValidatorPeriod+CalculateDelegationRewards.- 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.


Problem
The distribution reward query handlers —
DelegationRewards/DelegationTotalRewardsingrpc_query.go, and the amino equivalents inquerier.go— callIncrementValidatorPeriod, 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:
ctx.CacheContext()"to isolate state changes";But the EVM
rewards()distribution precompile calls the gRPC handler with the live context (sdk.WrapSDKContext(ctx), no branch) and is not behind thereadOnlyguard — so those writes actually persist and commit when a contract callsrewards()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 thedistributioncache layers).Change
Replace the
IncrementValidatorPeriod+CalculateDelegationRewardssequence in the query paths with a read-only computation:currentRewardsEndingPeriodAndRatiocomputes, in memory, the ending period and cumulative reward ratio thatIncrementValidatorPeriodwould persist — without writing. The ratio is identical to the persisted one, so the reward figure matches the state-changing path exactly.CalculateDelegationRewardsis factored into a shared core that takes the ending ratio.CalculateDelegationRewardsReadOnlypasses the in-memory ratio and never writes.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.DelegationRewardsForQuerydispatches viarewardQueryIsReadOnly(ctx):debug_trace*re-executes historical pre-v6.7 blocks, whereIsTracing()is set and the era is signaled by the trace harness viaClosestUpgradeName(seeapp.RPCContextProvider), compared by semver. This matches the existingx/evm/keeper/params.goidiom (if !ctx.IsTracing() { return current }).ReadOnlyRewardsUpgrade = "v6.7"(following theapp/tagsconvention — 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 theIncrementValidatorPeriod+CalculateDelegationRewardsresult (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.TestDelegationRewardsForQueryUpgradeGate—live is read-only,tracing pre-v6.7 mutates,tracing v6.7+ is read-only; the reward figure is identical across all three.-race.Companion to the store-cache depth-amplification fix (#3713): keeping
distributionlayers clean lets that fix's read short-circuit actually hold on the reward path.🤖 Generated with Claude Code