diff --git a/x/oracle/abci.go b/x/oracle/abci.go index 4aa97780e..4b0783a31 100644 --- a/x/oracle/abci.go +++ b/x/oracle/abci.go @@ -4,6 +4,7 @@ import ( "time" core "github.com/classic-terra/core/v3/types" + "github.com/classic-terra/core/v3/types/util" "github.com/classic-terra/core/v3/x/oracle/keeper" "github.com/classic-terra/core/v3/x/oracle/types" @@ -117,5 +118,10 @@ func EndBlocker(ctx sdk.Context, k keeper.Keeper) { // reset miss counters of all validators at the last block of slash window if core.IsPeriodLastBlock(ctx, params.SlashWindow) { k.SlashAndResetMissCounters(ctx) + } else if core.IsPeriodLastBlock(ctx, util.BlocksPerHour*12) { + // Every ~12 hours, check if any validator has exceeded the maximum allowed misses + // and can no longer recover to meet MinValidPerWindow - slash them immediately + // rather than waiting until the end of SlashWindow + k.SlashExceedingMissCounters(ctx) } } diff --git a/x/oracle/keeper/slash.go b/x/oracle/keeper/slash.go index 55ea85796..7b387ece6 100644 --- a/x/oracle/keeper/slash.go +++ b/x/oracle/keeper/slash.go @@ -4,8 +4,11 @@ import ( sdk "github.com/cosmos/cosmos-sdk/types" ) -// SlashAndResetMissCounters do slash any operator who over criteria & clear all operators miss counter to zero -func (k Keeper) SlashAndResetMissCounters(ctx sdk.Context) { +// SlashExceedingMissCounters slashes validators whose miss count has already exceeded the +// maximum allowed threshold, meaning they cannot possibly recover to meet MinValidPerWindow +// by the end of the current SlashWindow. This allows for early slashing/jailing rather than +// waiting until the end of the SlashWindow. +func (k Keeper) SlashExceedingMissCounters(ctx sdk.Context) { height := ctx.BlockHeight() distributionHeight := height - sdk.ValidatorUpdateDelay - 1 @@ -15,17 +18,24 @@ func (k Keeper) SlashAndResetMissCounters(ctx sdk.Context) { QuoInt64(int64(k.VotePeriod(ctx))). TruncateInt64(), ) + minValidPerWindow := k.MinValidPerWindow(ctx) slashFraction := k.SlashFraction(ctx) powerReduction := k.StakingKeeper.PowerReduction(ctx) k.IterateMissCounters(ctx, func(operator sdk.ValAddress, missCounter uint64) bool { - // Calculate valid vote rate; (SlashWindow - MissCounter)/SlashWindow + // Calculate valid vote rate; (votePeriodsPerWindow - missCounter) / votePeriodsPerWindow + // This is the BEST CASE scenario assuming perfect voting for the rest of the window + if missCounter >= votePeriodsPerWindow { + // Already exceeded total periods - definitely slash + missCounter = votePeriodsPerWindow + } + validVoteRate := sdk.NewDecFromInt( sdk.NewInt(int64(votePeriodsPerWindow - missCounter))). QuoInt64(int64(votePeriodsPerWindow)) - // Penalize the validator whose the valid vote rate is smaller than min threshold + // If even the best case valid vote rate is below threshold, validator cannot recover if validVoteRate.LT(minValidPerWindow) { validator := k.StakingKeeper.Validator(ctx, operator) if validator.IsBonded() && !validator.IsJailed() { @@ -42,6 +52,18 @@ func (k Keeper) SlashAndResetMissCounters(ctx sdk.Context) { } } + return false + }) +} + +// SlashAndResetMissCounters slashes any operator who is over the miss threshold +// and clears all operators' miss counters to zero at the end of the SlashWindow. +func (k Keeper) SlashAndResetMissCounters(ctx sdk.Context) { + // Slash validators who have exceeded the threshold + k.SlashExceedingMissCounters(ctx) + + // Reset all miss counters + k.IterateMissCounters(ctx, func(operator sdk.ValAddress, _ uint64) bool { k.DeleteMissCounter(ctx, operator) return false }) diff --git a/x/oracle/keeper/slash_test.go b/x/oracle/keeper/slash_test.go index ecad35522..a9b6a6921 100644 --- a/x/oracle/keeper/slash_test.go +++ b/x/oracle/keeper/slash_test.go @@ -81,3 +81,76 @@ func TestSlashAndResetMissCounters(t *testing.T) { validator, _ = input.StakingKeeper.GetValidator(input.Ctx, ValAddrs[0]) require.Equal(t, amt, validator.Tokens) } + +func TestSlashExceedingMissCounters(t *testing.T) { + // initial setup + input := CreateTestInput(t) + addr, val := ValAddrs[0], ValPubKeys[0] + addr1, val1 := ValAddrs[1], ValPubKeys[1] + amt := sdk.TokensFromConsensusPower(100, sdk.DefaultPowerReduction) + stakingMsgSvr := stakingkeeper.NewMsgServerImpl(input.StakingKeeper) + ctx := input.Ctx + + // Validator created + _, err := stakingMsgSvr.CreateValidator(ctx, NewTestMsgCreateValidator(addr, val, amt)) + require.NoError(t, err) + _, err = stakingMsgSvr.CreateValidator(ctx, NewTestMsgCreateValidator(addr1, val1, amt)) + require.NoError(t, err) + staking.EndBlocker(ctx, input.StakingKeeper) + + require.Equal(t, amt, input.StakingKeeper.Validator(ctx, addr).GetBondedTokens()) + require.Equal(t, amt, input.StakingKeeper.Validator(ctx, addr1).GetBondedTokens()) + + votePeriodsPerWindow := sdk.NewDec(int64(input.OracleKeeper.SlashWindow(input.Ctx))).QuoInt64(int64(input.OracleKeeper.VotePeriod(input.Ctx))).TruncateInt64() + slashFraction := input.OracleKeeper.SlashFraction(input.Ctx) + minValidVotes := input.OracleKeeper.MinValidPerWindow(input.Ctx).MulInt64(votePeriodsPerWindow).TruncateInt64() + + // Case 1: Miss counter at threshold boundary - should NOT slash (validator can still meet minValidPerWindow) + // If missCounter = votePeriodsPerWindow - minValidVotes, validVoteRate = minValidVotes/votePeriodsPerWindow = minValidPerWindow (exactly at threshold, not below) + input.OracleKeeper.SetMissCounter(input.Ctx, ValAddrs[0], uint64(votePeriodsPerWindow-minValidVotes)) + input.OracleKeeper.SlashExceedingMissCounters(input.Ctx) + staking.EndBlocker(input.Ctx, input.StakingKeeper) + + validator, _ := input.StakingKeeper.GetValidator(input.Ctx, ValAddrs[0]) + require.Equal(t, amt, validator.GetBondedTokens()) + require.False(t, validator.IsJailed()) + + // Case 2: Miss counter exceeds threshold by 1 - should slash (validVoteRate < minValidPerWindow, cannot recover) + input.OracleKeeper.SetMissCounter(input.Ctx, ValAddrs[0], uint64(votePeriodsPerWindow-minValidVotes+1)) + input.OracleKeeper.SlashExceedingMissCounters(input.Ctx) + + validator, _ = input.StakingKeeper.GetValidator(input.Ctx, ValAddrs[0]) + require.Equal(t, amt.Sub(slashFraction.MulInt(amt).TruncateInt()), validator.GetBondedTokens()) + require.True(t, validator.IsJailed()) + + // Case 3: Unbonded validator should not be slashed + validator, _ = input.StakingKeeper.GetValidator(input.Ctx, ValAddrs[1]) + validator.Status = stakingtypes.Unbonded + validator.Jailed = false + validator.Tokens = amt + input.StakingKeeper.SetValidator(input.Ctx, validator) + + input.OracleKeeper.SetMissCounter(input.Ctx, ValAddrs[1], uint64(votePeriodsPerWindow-minValidVotes+1)) + input.OracleKeeper.SlashExceedingMissCounters(input.Ctx) + + validator, _ = input.StakingKeeper.GetValidator(input.Ctx, ValAddrs[1]) + require.Equal(t, amt, validator.Tokens) + require.False(t, validator.IsJailed()) + + // Case 4: Already jailed validator should not be slashed again + validator, _ = input.StakingKeeper.GetValidator(input.Ctx, ValAddrs[1]) + validator.Status = stakingtypes.Bonded + validator.Jailed = true + validator.Tokens = amt + input.StakingKeeper.SetValidator(input.Ctx, validator) + + input.OracleKeeper.SetMissCounter(input.Ctx, ValAddrs[1], uint64(votePeriodsPerWindow-minValidVotes+1)) + input.OracleKeeper.SlashExceedingMissCounters(input.Ctx) + + validator, _ = input.StakingKeeper.GetValidator(input.Ctx, ValAddrs[1]) + require.Equal(t, amt, validator.Tokens) + + // Case 5: Verify miss counter is NOT deleted (only SlashAndResetMissCounters deletes it) + missCounter := input.OracleKeeper.GetMissCounter(input.Ctx, ValAddrs[1]) + require.Equal(t, uint64(votePeriodsPerWindow-minValidVotes+1), missCounter) +}