Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
136 changes: 126 additions & 10 deletions sei-cosmos/x/distribution/keeper/delegation.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,22 @@ package keeper
import (
"fmt"

"golang.org/x/mod/semver"

sdk "github.com/sei-protocol/sei-chain/sei-cosmos/types"
"github.com/sei-protocol/sei-chain/sei-cosmos/x/distribution/types"
stakingtypes "github.com/sei-protocol/sei-chain/sei-cosmos/x/staking/types"
"github.com/sei-protocol/seilog"
)

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

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

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

Comment on lines +14 to +20

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.


var logger = seilog.NewLogger("cosmos", "x", "distribution", "keeper")

// initialize starting info for a new delegation
Expand All @@ -29,6 +39,18 @@ func (k Keeper) initializeDelegation(ctx sdk.Context, val sdk.ValAddress, del sd
k.SetDelegatorStartingInfo(ctx, val, del, types.NewDelegatorStartingInfo(previousPeriod, stake, uint64(ctx.BlockHeight()))) //nolint:gosec // block heights are always non-negative
}

// rewardsFromRatios returns stake * (endingRatio - startingRatio), truncated. It
// centralizes the ratio math shared by the store-backed reward calculation and the
// read-only calculation used by queries (which supply an in-memory ending ratio).
func rewardsFromRatios(startingRatio, endingRatio sdk.DecCoins, stake sdk.Dec) sdk.DecCoins {
difference := endingRatio.Sub(startingRatio)
if difference.IsAnyNegative() {
panic("negative rewards should not be possible")
}
// note: necessary to truncate so we don't allow withdrawing more rewards than owed
return difference.MulDecTruncate(stake)
}

// calculate the rewards accrued by a delegation between two periods
func (k Keeper) calculateDelegationRewardsBetween(ctx sdk.Context, val stakingtypes.ValidatorI,
startingPeriod, endingPeriod uint64, stake sdk.Dec) (rewards sdk.DecCoins) {
Expand All @@ -45,17 +67,96 @@ func (k Keeper) calculateDelegationRewardsBetween(ctx sdk.Context, val stakingty
// return staking * (ending - starting)
starting := k.GetValidatorHistoricalRewards(ctx, val.GetOperator(), startingPeriod)
ending := k.GetValidatorHistoricalRewards(ctx, val.GetOperator(), endingPeriod)
difference := ending.CumulativeRewardRatio.Sub(starting.CumulativeRewardRatio)
if difference.IsAnyNegative() {
panic("negative rewards should not be possible")
}
// note: necessary to truncate so we don't allow withdrawing more rewards than owed
rewards = difference.MulDecTruncate(stake)
return
return rewardsFromRatios(starting.CumulativeRewardRatio, ending.CumulativeRewardRatio, stake)
}

// calculate the total rewards accrued by a delegation
// CalculateDelegationRewards calculates the total rewards accrued by a delegation
// up to endingPeriod, reading that period's cumulative reward ratio from the
// store. State-changing callers (e.g. withdrawals) persist endingPeriod via
// IncrementValidatorPeriod before calling this. Passing a nil ending ratio makes
// the core read endingPeriod from the store lazily (only if the final period is
// reached), preserving this path's exact store-access pattern and gas cost.
func (k Keeper) CalculateDelegationRewards(ctx sdk.Context, val stakingtypes.ValidatorI, del stakingtypes.DelegationI, endingPeriod uint64) (rewards sdk.DecCoins) {
return k.calculateDelegationRewards(ctx, val, del, endingPeriod, nil)
}

// currentRewardsEndingPeriodAndRatio returns the ending period and its cumulative
// reward ratio that IncrementValidatorPeriod WOULD produce for the validator's
// current (open) period — computed in memory, WITHOUT writing to the store. This
// lets read-only reward queries avoid the state mutation (historical/current
// rewards, reference counts, and the zero-token community-pool transfer) that
// IncrementValidatorPeriod performs. The returned ratio is identical to the one
// IncrementValidatorPeriod would persist for that period, so the resulting reward
// figure matches the state-changing path exactly.
func (k Keeper) currentRewardsEndingPeriodAndRatio(ctx sdk.Context, val stakingtypes.ValidatorI) (uint64, sdk.DecCoins) {
rewards := k.GetValidatorCurrentRewards(ctx, val.GetOperator())

var current sdk.DecCoins
if val.GetTokens().IsZero() {
// The state-changing path routes a zero-token validator's rewards to the
// community pool and uses a zero ratio; a read-only query only needs the
// (zero) ratio, not the transfer.
current = sdk.DecCoins{}
} else {
// note: necessary to truncate so we don't allow withdrawing more rewards than owed
current = rewards.Rewards.QuoDecTruncate(val.GetTokens().ToDec())
}

historical := k.GetValidatorHistoricalRewards(ctx, val.GetOperator(), rewards.Period-1).CumulativeRewardRatio
return rewards.Period, historical.Add(current...)
}

// CalculateDelegationRewardsReadOnly computes a delegation's outstanding rewards
// without mutating any state. It mirrors the "IncrementValidatorPeriod then
// CalculateDelegationRewards" sequence used by the withdraw path, but derives the
// ending period's cumulative reward ratio in memory instead of persisting it, so
// it is safe to call from read-only queries.
func (k Keeper) CalculateDelegationRewardsReadOnly(ctx sdk.Context, val stakingtypes.ValidatorI, del stakingtypes.DelegationI) sdk.DecCoins {
endingPeriod, endingRatio := k.currentRewardsEndingPeriodAndRatio(ctx, val)
return k.calculateDelegationRewards(ctx, val, del, endingPeriod, &endingRatio)
}

// DelegationRewardsForQuery computes a delegation's rewards for a read-only reward
// query. It uses the non-mutating read-only path (CalculateDelegationRewardsReadOnly).
// Before v6.7 these queries persisted a validator-period increment
// (IncrementValidatorPeriod), which committed when the EVM distribution `rewards()`
// precompile ran the query on the live context. That old behavior only needs to be
// reproduced when re-tracing a pre-v6.7 block via debug_trace* — live execution runs
// only on the current (post-upgrade) binary, which never non-tracingly re-executes a
// pre-upgrade block (see rewardQueryIsReadOnly).
func (k Keeper) DelegationRewardsForQuery(ctx sdk.Context, val stakingtypes.ValidatorI, del stakingtypes.DelegationI) sdk.DecCoins {
if k.rewardQueryIsReadOnly(ctx) {
return k.CalculateDelegationRewardsReadOnly(ctx, val, del)
}
// Pre-v6.7 behavior: the query incremented the validator period and read the
// freshly-persisted ending ratio. Reproduce it exactly for deterministic replay.
endingPeriod := k.IncrementValidatorPeriod(ctx, val)
return k.CalculateDelegationRewards(ctx, val, del, endingPeriod)
}

// rewardQueryIsReadOnly reports whether a reward query should use the non-mutating
// read-only path. Live execution always does: the current binary only ever executes
// at/after the upgrade that shipped this behavior, so a non-tracing block is never a
// pre-upgrade block. The only place the old, state-mutating behavior must be
// 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.

if !ctx.IsTracing() {
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.


// calculateDelegationRewards is the shared core of the reward calculation. When
// endingRatio is non-nil it is used as the cumulative reward ratio at endingPeriod
// for the final period, so read-only callers can pass an in-memory ratio and avoid
// persisting the period. When endingRatio is nil the ratio is read from the store
// (the state-changing withdraw path, which persists it via IncrementValidatorPeriod
// beforehand); reading it lazily here — only if the final period is reached —
// preserves that path's exact store-access pattern and gas cost. Intermediate
// slash periods are always read from the store (they are always persisted).
func (k Keeper) calculateDelegationRewards(ctx sdk.Context, val stakingtypes.ValidatorI, del stakingtypes.DelegationI, endingPeriod uint64, endingRatio *sdk.DecCoins) (rewards sdk.DecCoins) {
// fetch starting info for delegation
startingInfo := k.GetDelegatorStartingInfo(ctx, del.GetValidatorAddr(), del.GetDelegatorAddr())

Expand Down Expand Up @@ -133,8 +234,23 @@ func (k Keeper) CalculateDelegationRewards(ctx sdk.Context, val stakingtypes.Val
}
}

// calculate rewards for final period
rewards = rewards.Add(k.calculateDelegationRewardsBetween(ctx, val, startingPeriod, endingPeriod, stake)...)
// calculate rewards for the final period (mirrors calculateDelegationRewardsBetween:
// same sanity checks and same two historical-rewards reads for the nil path).
if startingPeriod > endingPeriod {
panic("startingPeriod cannot be greater than endingPeriod")
}
if stake.IsNegative() {
panic("stake should not be negative")
}
startingRatio := k.GetValidatorHistoricalRewards(ctx, val.GetOperator(), startingPeriod).CumulativeRewardRatio
finalRatio := endingRatio
if finalRatio == nil {
// state-changing path: read the persisted ending ratio here (lazily, only
// once the final period is reached) to preserve the original gas cost.
ratio := k.GetValidatorHistoricalRewards(ctx, val.GetOperator(), endingPeriod).CumulativeRewardRatio
finalRatio = &ratio
}
rewards = rewards.Add(rewardsFromRatios(startingRatio, *finalRatio, stake)...)

return rewards
}
Expand Down
151 changes: 151 additions & 0 deletions sei-cosmos/x/distribution/keeper/delegation_readonly_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
package keeper_test

import (
"testing"

tmproto "github.com/sei-protocol/sei-chain/sei-tendermint/proto/tendermint/types"
"github.com/stretchr/testify/require"

seiapp "github.com/sei-protocol/sei-chain/app"
sdk "github.com/sei-protocol/sei-chain/sei-cosmos/types"
distrkeeper "github.com/sei-protocol/sei-chain/sei-cosmos/x/distribution/keeper"
"github.com/sei-protocol/sei-chain/sei-cosmos/x/staking"
"github.com/sei-protocol/sei-chain/sei-cosmos/x/staking/teststaking"
stakingtypes "github.com/sei-protocol/sei-chain/sei-cosmos/x/staking/types"

types "github.com/sei-protocol/sei-chain/sei-cosmos/x/distribution/types"
)

// setupRewardsWithSlash creates a bonded validator with a self-delegation, slashes
// it, and allocates rewards, leaving accrued (un-incremented) rewards so both the
// simple and slash-event reward paths are exercised. It returns the context, the
// validator, the delegation, and the initial allocation.
func setupRewardsWithSlash(t *testing.T) (sdk.Context, *seiapp.App, stakingtypes.ValidatorI, stakingtypes.DelegationI, sdk.Int, sdk.ValAddress) {
t.Helper()
app := seiapp.Setup(t, false, false, false)
ctx := app.BaseApp.NewContext(false, tmproto.Header{})

addr := seiapp.AddTestAddrs(app, ctx, 2, sdk.NewInt(100000000))
valAddrs := seiapp.ConvertAddrsToValAddrs(addr)
tstaking := teststaking.NewHelper(t, ctx, app.StakingKeeper)

tstaking.Commission = stakingtypes.NewCommissionRates(sdk.NewDecWithPrec(5, 1), sdk.NewDecWithPrec(5, 1), sdk.NewDec(0))
valPower := int64(100)
tstaking.CreateValidatorWithValPower(valAddrs[0], valConsPk1, valPower, true)

staking.EndBlocker(ctx, app.StakingKeeper)
ctx = ctx.WithBlockHeight(ctx.BlockHeight() + 1)

// slash so the reward computation walks a slash event
ctx = ctx.WithBlockHeight(ctx.BlockHeight() + 3)
app.StakingKeeper.Slash(ctx, valConsAddr1, ctx.BlockHeight(), valPower, sdk.NewDecWithPrec(5, 1))
val := app.StakingKeeper.Validator(ctx, valAddrs[0])

ctx = ctx.WithBlockHeight(ctx.BlockHeight() + 3)
initial := app.StakingKeeper.TokensFromConsensusPower(ctx, 10)
tokens := sdk.DecCoins{{Denom: sdk.DefaultBondDenom, Amount: initial.ToDec()}}
app.DistrKeeper.AllocateTokensToValidator(ctx, val, tokens)

del := app.StakingKeeper.Delegation(ctx, sdk.AccAddress(valAddrs[0]), valAddrs[0])
return ctx, app, val, del, initial, valAddrs[0]
}

// TestCalculateDelegationRewardsReadOnlyMatchesWritePath asserts the read-only
// reward computation (1) returns exactly what the IncrementValidatorPeriod +
// CalculateDelegationRewards sequence returns, and (2) mutates no distribution
// state.
func TestCalculateDelegationRewardsReadOnlyMatchesWritePath(t *testing.T) {
ctx, app, val, del, initial, valAddr := setupRewardsWithSlash(t)

// snapshot the state IncrementValidatorPeriod would mutate
periodBefore := app.DistrKeeper.GetValidatorCurrentRewards(ctx, valAddr).Period
refCountBefore := app.DistrKeeper.GetValidatorHistoricalReferenceCount(ctx)

readOnly := app.DistrKeeper.CalculateDelegationRewardsReadOnly(ctx, val, del)

// (2) no state mutated by the read-only path
require.Equal(t, periodBefore, app.DistrKeeper.GetValidatorCurrentRewards(ctx, valAddr).Period,
"read-only reward calc must not increment the validator period")
require.Equal(t, refCountBefore, app.DistrKeeper.GetValidatorHistoricalReferenceCount(ctx),
"read-only reward calc must not change the historical reference count")

// (1) equals the write path, computed on an isolated (cached) context
cctx, _ := ctx.CacheContext()
endingPeriod := app.DistrKeeper.IncrementValidatorPeriod(cctx, val)
writePath := app.DistrKeeper.CalculateDelegationRewards(cctx, val, del, endingPeriod)
require.Equal(t, writePath, readOnly, "read-only rewards must equal the write-path rewards")

// sanity: the slashing scenario yields half the allocation (other half is commission)
require.Equal(t, sdk.DecCoins{{Denom: sdk.DefaultBondDenom, Amount: initial.QuoRaw(2).ToDec()}}, readOnly)
}

// TestDelegationTotalRewardsQueryIsReadOnly asserts the gRPC query (live, i.e. not
// tracing) does not mutate distribution state and is idempotent across repeated
// calls (before the fix, each call incremented the validator period).
func TestDelegationTotalRewardsQueryIsReadOnly(t *testing.T) {
ctx, app, _, _, _, valAddr := setupRewardsWithSlash(t)

periodBefore := app.DistrKeeper.GetValidatorCurrentRewards(ctx, valAddr).Period
refCountBefore := app.DistrKeeper.GetValidatorHistoricalReferenceCount(ctx)

req := &types.QueryDelegationTotalRewardsRequest{
DelegatorAddress: sdk.AccAddress(valAddr).String(),
}

resp1, err := app.DistrKeeper.DelegationTotalRewards(sdk.WrapSDKContext(ctx), req)
require.NoError(t, err)
require.False(t, resp1.Total.IsZero(), "test should have non-zero rewards")

require.Equal(t, periodBefore, app.DistrKeeper.GetValidatorCurrentRewards(ctx, valAddr).Period,
"query must not increment the validator period")
require.Equal(t, refCountBefore, app.DistrKeeper.GetValidatorHistoricalReferenceCount(ctx),
"query must not change the historical reference count")

// idempotent: a second query returns identical totals and still no mutation
resp2, err := app.DistrKeeper.DelegationTotalRewards(sdk.WrapSDKContext(ctx), req)
require.NoError(t, err)
require.Equal(t, resp1.Total, resp2.Total)
require.Equal(t, periodBefore, app.DistrKeeper.GetValidatorCurrentRewards(ctx, valAddr).Period)
}

// TestDelegationRewardsForQueryUpgradeGate asserts the gating: live (non-tracing)
// execution always uses the read-only path, while re-tracing a historical block
// reproduces the old mutating behavior for pre-v6.7 blocks and the read-only path
// for v6.7+ blocks. The reward figure is identical across every path.
func TestDelegationRewardsForQueryUpgradeGate(t *testing.T) {
period := func(app *seiapp.App, ctx sdk.Context, valAddr sdk.ValAddress) uint64 {
return app.DistrKeeper.GetValidatorCurrentRewards(ctx, valAddr).Period
}
expected := func(initial sdk.Int) sdk.DecCoins {
return sdk.DecCoins{{Denom: sdk.DefaultBondDenom, Amount: initial.QuoRaw(2).ToDec()}}
}

t.Run("live is read-only", func(t *testing.T) {
ctx, app, val, del, initial, valAddr := setupRewardsWithSlash(t)
before := period(app, ctx, valAddr)
reward := app.DistrKeeper.DelegationRewardsForQuery(ctx, val, del)
require.Equal(t, before, period(app, ctx, valAddr),
"live (non-tracing) execution must not mutate")
require.Equal(t, expected(initial), reward)
})

t.Run("tracing pre-v6.7 mutates", func(t *testing.T) {
ctx, app, val, del, initial, valAddr := setupRewardsWithSlash(t)
traceOld := ctx.WithIsTracing(true).WithClosestUpgradeName("v6.6")
before := period(app, traceOld, valAddr)
reward := app.DistrKeeper.DelegationRewardsForQuery(traceOld, val, del)
require.Equal(t, before+1, period(app, traceOld, valAddr),
"tracing a pre-v6.7 block must reproduce the period increment")
require.Equal(t, expected(initial), reward)
})

t.Run("tracing v6.7+ is read-only", func(t *testing.T) {
ctx, app, val, del, initial, valAddr := setupRewardsWithSlash(t)
traceNew := ctx.WithIsTracing(true).WithClosestUpgradeName(distrkeeper.ReadOnlyRewardsUpgrade)
before := period(app, traceNew, valAddr)
reward := app.DistrKeeper.DelegationRewardsForQuery(traceNew, val, del)
require.Equal(t, before, period(app, traceNew, valAddr),
"tracing a v6.7+ block must not mutate")
require.Equal(t, expected(initial), reward)
})
}
10 changes: 6 additions & 4 deletions sei-cosmos/x/distribution/keeper/grpc_query.go
Original file line number Diff line number Diff line change
Expand Up @@ -150,8 +150,9 @@ func (k Keeper) DelegationRewards(c context.Context, req *types.QueryDelegationR
return nil, types.ErrNoDelegationExists
}

endingPeriod := k.IncrementValidatorPeriod(ctx, val)
rewards := k.CalculateDelegationRewards(ctx, val, del, endingPeriod)
// Read-only for v6.7.0+; reproduces the pre-v6.7.0 period-increment write only
// when re-tracing an older block (see DelegationRewardsForQuery).
rewards := k.DelegationRewardsForQuery(ctx, val, del)

return &types.QueryDelegationRewardsResponse{Rewards: rewards}, nil
}
Expand Down Expand Up @@ -181,8 +182,9 @@ func (k Keeper) DelegationTotalRewards(c context.Context, req *types.QueryDelega
func(_ int64, del stakingtypes.DelegationI) (stop bool) {
valAddr := del.GetValidatorAddr()
val := k.stakingKeeper.Validator(ctx, valAddr)
endingPeriod := k.IncrementValidatorPeriod(ctx, val)
delReward := k.CalculateDelegationRewards(ctx, val, del, endingPeriod)
// Read-only for v6.7.0+; reproduces the pre-v6.7.0 period-increment write
// only when re-tracing an older block (see DelegationRewardsForQuery).
delReward := k.DelegationRewardsForQuery(ctx, val, del)

delRewards = append(delRewards, types.NewDelegationDelegatorReward(valAddr, delReward))
total = total.Add(delReward...)
Expand Down
11 changes: 7 additions & 4 deletions sei-cosmos/x/distribution/keeper/querier.go
Original file line number Diff line number Diff line change
Expand Up @@ -142,8 +142,10 @@ func queryDelegationRewards(ctx sdk.Context, _ []string, req abci.RequestQuery,
return nil, types.ErrNoDelegationExists
}

endingPeriod := k.IncrementValidatorPeriod(ctx, val)
rewards := k.CalculateDelegationRewards(ctx, val, del, endingPeriod)
// Read-only for v6.7.0+; reproduces the pre-v6.7.0 period-increment write only
// when re-tracing an older block (see DelegationRewardsForQuery). The
// CacheContext above already isolates any such write from committed state.
rewards := k.DelegationRewardsForQuery(ctx, val, del)
if rewards == nil {
rewards = sdk.DecCoins{}
}
Expand Down Expand Up @@ -175,8 +177,9 @@ func queryDelegatorTotalRewards(ctx sdk.Context, _ []string, req abci.RequestQue
func(_ int64, del stakingtypes.DelegationI) (stop bool) {
valAddr := del.GetValidatorAddr()
val := k.stakingKeeper.Validator(ctx, valAddr)
endingPeriod := k.IncrementValidatorPeriod(ctx, val)
delReward := k.CalculateDelegationRewards(ctx, val, del, endingPeriod)
// Read-only for v6.7.0+; reproduces the pre-v6.7.0 period-increment write
// only when re-tracing an older block (see DelegationRewardsForQuery).
delReward := k.DelegationRewardsForQuery(ctx, val, del)

delRewards = append(delRewards, types.NewDelegationDelegatorReward(valAddr, delReward))
total = total.Add(delReward...)
Expand Down
Loading