Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix!: Avoid immediately jailing validators that are no longer opted-out #1549

Merged
merged 14 commits into from
Jan 9, 2024
1 change: 1 addition & 0 deletions proto/interchain_security/ccv/consumer/v1/consumer.proto
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ message CrossChainValidator {
(cosmos_proto.accepts_interface) = "cosmos.crypto.PubKey",
(gogoproto.moretags) = "yaml:\"consensus_pubkey\""
];
bool opted_out = 4;
}

// A record storing the state of a slash packet sent to the provider chain
Expand Down
17 changes: 11 additions & 6 deletions tests/integration/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -597,20 +597,25 @@ func (suite *CCVTestSuite) GetConsumerEndpointClientAndConsState(

// setupValidatorPowers delegates from the sender account to give all
// validators on the provider chain 1000 power.
mpoke marked this conversation as resolved.
Show resolved Hide resolved
func (s *CCVTestSuite) setupValidatorPowers() {
func (s *CCVTestSuite) setupValidatorPowers(powers []int64) {
delAddr := s.providerChain.SenderAccount.GetAddress()
s.Require().Equal(len(powers), len(s.providerChain.Vals.Validators))
for idx := range s.providerChain.Vals.Validators {
delegateByIdx(s, delAddr, sdk.NewInt(999999999), idx)
bondAmt := sdk.NewInt(powers[idx]).Mul(sdk.DefaultPowerReduction)
bondAmt = bondAmt.Sub(sdk.NewInt(1)) // 1 token is bonded during the initial setup
delegateByIdx(s, delAddr, bondAmt, idx)
}

s.providerChain.NextBlock()

stakingKeeper := s.providerApp.GetTestStakingKeeper()
for _, val := range s.providerChain.Vals.Validators {
power := stakingKeeper.GetLastValidatorPower(s.providerCtx(), sdk.ValAddress(val.Address))
s.Require().Equal(int64(1000), power)
expectedTotalPower := int64(0)
for idx, val := range s.providerChain.Vals.Validators {
actualPower := stakingKeeper.GetLastValidatorPower(s.providerCtx(), sdk.ValAddress(val.Address))
s.Require().Equal(powers[idx], actualPower)
expectedTotalPower += powers[idx]
}
s.Require().Equal(int64(4000), stakingKeeper.GetLastTotalPower(s.providerCtx()).Int64())
s.Require().Equal(expectedTotalPower, stakingKeeper.GetLastTotalPower(s.providerCtx()).Int64())
}

// mustGetStakingValFromTmVal returns the staking validator from the current state of the staking keeper,
Expand Down
2 changes: 1 addition & 1 deletion tests/integration/slashing.go
Original file line number Diff line number Diff line change
Expand Up @@ -443,7 +443,7 @@ func (suite *CCVTestSuite) TestValidatorDowntime() {
ctx, ccv.ConsumerPortID, channelID)
suite.Require().True(ok)

// Sign 100 blocks
// Sign 100 blocks (default value for slahing.SignedBlocksWindow param).
mpoke marked this conversation as resolved.
Show resolved Hide resolved
valPower := int64(1)
height, signedBlocksWindow := int64(0), consumerSlashingKeeper.SignedBlocksWindow(ctx)
for ; height < signedBlocksWindow; height++ {
Expand Down
247 changes: 247 additions & 0 deletions tests/integration/soft_opt_out.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,247 @@
package integration

import (
"bytes"
"sort"

abci "github.com/cometbft/cometbft/abci/types"
sdk "github.com/cosmos/cosmos-sdk/types"
slashingkeeper "github.com/cosmos/cosmos-sdk/x/slashing/keeper"
stakingtypes "github.com/cosmos/cosmos-sdk/x/staking/types"
consumerKeeper "github.com/cosmos/interchain-security/v3/x/ccv/consumer/keeper"
ccv "github.com/cosmos/interchain-security/v3/x/ccv/types"
)

// TestSoftOptOut tests the soft opt-out feature
// - if a validator in the top 95% doesn't sign 50 blocks on the consumer, a SlashPacket is sent to the provider
// - if a validator in the bottom 5% doesn't sign 50 blocks on the consumer, a SlashPacket is NOT sent to the provider
// - if a validator in the bottom 5% doesn't sign 49 blocks on the consumer,
// then it moves to the top 95% and doesn't sign one more block, a SlashPacket is NOT sent to the provider
func (suite *CCVTestSuite) TestSoftOptOut() {
var votes []abci.VoteInfo

testCases := []struct {
name string
downtimeFunc func(*consumerKeeper.Keeper, *slashingkeeper.Keeper, []byte, int)
targetValidator int
expJailed bool
expSlashPacket bool
}{
{
"donwtime top 95%",
mpoke marked this conversation as resolved.
Show resolved Hide resolved
func(ck *consumerKeeper.Keeper, sk *slashingkeeper.Keeper, valAddr []byte, valIdx int) {
for i, voteInfo := range votes {
if bytes.Equal(voteInfo.Validator.Address, valAddr) {
votes[i].SignedLastBlock = false
}
}
blocksToDowntime := sk.SignedBlocksWindow(suite.consumerCtx()) - sk.MinSignedPerWindow(suite.consumerCtx()) + 1
slashingBeginBlocker(suite, votes, blocksToDowntime)
},
0,
true,
true,
},
{
"donwtime bottom 5%",
mpoke marked this conversation as resolved.
Show resolved Hide resolved
func(ck *consumerKeeper.Keeper, sk *slashingkeeper.Keeper, valAddr []byte, valIdx int) {
for i, voteInfo := range votes {
if bytes.Equal(voteInfo.Validator.Address, valAddr) {
votes[i].SignedLastBlock = false
}
}
blocksToDowntime := sk.SignedBlocksWindow(suite.consumerCtx()) - sk.MinSignedPerWindow(suite.consumerCtx()) + 1
slashingBeginBlocker(suite, votes, blocksToDowntime)
},
3,
true,
false,
},
{
"donwtime bottom 5% first and then top 95%, but not enough",
mpoke marked this conversation as resolved.
Show resolved Hide resolved
func(ck *consumerKeeper.Keeper, sk *slashingkeeper.Keeper, valAddr []byte, valIdx int) {
for i, voteInfo := range votes {
if bytes.Equal(voteInfo.Validator.Address, valAddr) {
votes[i].SignedLastBlock = false
}
}
blocksToDowntime := sk.SignedBlocksWindow(suite.consumerCtx()) - sk.MinSignedPerWindow(suite.consumerCtx())
slashingBeginBlocker(suite, votes, blocksToDowntime)

// Increase the power of this validator (to bring it in the top 95%)
delAddr := suite.providerChain.SenderAccount.GetAddress()
bondAmt := sdk.NewInt(100).Mul(sdk.DefaultPowerReduction)
delegateByIdx(suite, delAddr, bondAmt, valIdx)

suite.providerChain.NextBlock()

// Relay 1 VSC packet from provider to consumer
relayAllCommittedPackets(suite, suite.providerChain, suite.path, ccv.ProviderPortID, suite.path.EndpointB.ChannelID, 1)

// Update validator from store
val, found := ck.GetCCValidator(suite.consumerCtx(), valAddr)
suite.Require().True(found)
smallestNonOptOutPower := ck.GetSmallestNonOptOutPower(suite.consumerCtx())
suite.Require().Equal(val.Power, smallestNonOptOutPower)

// Let the validator continue not signing, but not enough to get jailed
for i, voteInfo := range votes {
if bytes.Equal(voteInfo.Validator.Address, valAddr) {
votes[i].Validator.Power = val.Power
}
}
slashingBeginBlocker(suite, votes, 10)
},
2,
false,
false,
},
{
"donwtime bottom 5% first and then top 95% until jailed",
func(ck *consumerKeeper.Keeper, sk *slashingkeeper.Keeper, valAddr []byte, valIdx int) {
for i, voteInfo := range votes {
if bytes.Equal(voteInfo.Validator.Address, valAddr) {
votes[i].SignedLastBlock = false
}
}
blocksToDowntime := sk.SignedBlocksWindow(suite.consumerCtx()) - sk.MinSignedPerWindow(suite.consumerCtx())
slashingBeginBlocker(suite, votes, blocksToDowntime)

// Increase the power of this validator (to bring it in the top 95%)
delAddr := suite.providerChain.SenderAccount.GetAddress()
bondAmt := sdk.NewInt(100).Mul(sdk.DefaultPowerReduction)
delegateByIdx(suite, delAddr, bondAmt, valIdx)

suite.providerChain.NextBlock()

// Relay 1 VSC packet from provider to consumer
relayAllCommittedPackets(suite, suite.providerChain, suite.path, ccv.ProviderPortID, suite.path.EndpointB.ChannelID, 1)

// Update validator from store
val, found := ck.GetCCValidator(suite.consumerCtx(), valAddr)
suite.Require().True(found)
smallestNonOptOutPower := ck.GetSmallestNonOptOutPower(suite.consumerCtx())
suite.Require().Equal(val.Power, smallestNonOptOutPower)

// Let the validator continue not signing until it gets jailed.
// Due to the starting height being just updated, the signed blocked window needs to pass.
for i, voteInfo := range votes {
if bytes.Equal(voteInfo.Validator.Address, valAddr) {
votes[i].Validator.Power = val.Power
}
}
slashingBeginBlocker(suite, votes, sk.SignedBlocksWindow(suite.consumerCtx())+1)
},
2,
true,
true,
},
}

for i, tc := range testCases {
// initial setup
suite.SetupCCVChannel(suite.path)

consumerKeeper := suite.consumerApp.GetConsumerKeeper()
consumerSlashingKeeper := suite.consumerApp.GetTestSlashingKeeper()

// Setup validator power s.t. the bottom 5% is non-empty
validatorPowers := []int64{1000, 500, 50, 10}
suite.setupValidatorPowers(validatorPowers)

// Relay 1 VSC packet from provider to consumer
relayAllCommittedPackets(suite, suite.providerChain, suite.path, ccv.ProviderPortID, suite.path.EndpointB.ChannelID, 1)

// Check that the third validator is the first in the top 95%
smallestNonOptOutPower := consumerKeeper.GetSmallestNonOptOutPower(suite.consumerCtx())
suite.Require().Equal(validatorPowers[1], smallestNonOptOutPower, "test: "+tc.name)

// Get the list of all CCV validators
vals := consumerKeeper.GetAllCCValidator(suite.consumerCtx())
// Note that GetAllCCValidator is iterating over a map so the result need to be sorted
sort.Slice(vals, func(i, j int) bool {
if vals[i].Power != vals[j].Power {
return vals[i].Power > vals[j].Power
}
return bytes.Compare(vals[i].Address, vals[j].Address) > 0
})

// Let everyone sign the first 100 blocks (default value for slahing.SignedBlocksWindow param).
// This populates the signingInfo of the slashing module so that
// the check for starting height passes.
votes = []abci.VoteInfo{}
for _, val := range vals {
votes = append(votes, abci.VoteInfo{
Validator: abci.Validator{Address: val.Address, Power: val.Power},
SignedLastBlock: true,
})
}
slashingBeginBlocker(suite, votes, consumerSlashingKeeper.SignedBlocksWindow(suite.consumerCtx()))

// Downtime infraction
sk := consumerSlashingKeeper.(slashingkeeper.Keeper)
tc.downtimeFunc(&consumerKeeper, &sk, vals[tc.targetValidator].Address, tc.targetValidator)

// Check the signing info for target validator
consAddr := sdk.ConsAddress(vals[tc.targetValidator].Address)
info, _ := consumerSlashingKeeper.GetValidatorSigningInfo(suite.consumerCtx(), consAddr)
if tc.expJailed {
// expect increased jail time
suite.Require().True(
info.JailedUntil.Equal(suite.consumerCtx().BlockTime().Add(consumerSlashingKeeper.DowntimeJailDuration(suite.consumerCtx()))),
"test: "+tc.name+"; did not update validator jailed until signing info",
)
// expect missed block counters reset
suite.Require().Zero(info.MissedBlocksCounter, "test: "+tc.name+"; did not reset validator missed block counter")
suite.Require().Zero(info.IndexOffset, "test: "+tc.name)
consumerSlashingKeeper.IterateValidatorMissedBlockBitArray(suite.consumerCtx(), consAddr, func(_ int64, missed bool) bool {
suite.Require().True(missed, "test: "+tc.name)
return false
})
} else {
suite.Require().True(
// expect not increased jail time
info.JailedUntil.Before(suite.consumerCtx().BlockTime()),
"test: "+tc.name+"; validator jailed until signing info was updated",
)
suite.Require().Positive(info.IndexOffset, "test: "+tc.name)
}

pendingPackets := consumerKeeper.GetPendingPackets(suite.consumerCtx())
if tc.expSlashPacket {
// Check that slash packet is queued
suite.Require().NotEmpty(pendingPackets, "test: "+tc.name+"; pending packets empty")
suite.Require().Len(pendingPackets, 1, "test: "+tc.name+"; pending packets len should be 1 is %d", len(pendingPackets))
cp := pendingPackets[0]
suite.Require().Equal(ccv.SlashPacket, cp.Type, "test: "+tc.name)
sp := cp.GetSlashPacketData()
suite.Require().Equal(stakingtypes.Infraction_INFRACTION_DOWNTIME, sp.Infraction, "test: "+tc.name)
suite.Require().Equal(vals[tc.targetValidator].Address, sp.Validator.Address, "test: "+tc.name)
} else {
suite.Require().Empty(pendingPackets, "test: "+tc.name+"; pending packets non-empty")
}

if i+1 < len(testCases) {
// reset suite
suite.SetupTest()
}
}
}

// slashingBeginBlocker is a mock for the slashing BeginBlocker.
// It applies the votes for a sequence of blocks
func slashingBeginBlocker(s *CCVTestSuite, votes []abci.VoteInfo, blocks int64) {
consumerSlashingKeeper := s.consumerApp.GetTestSlashingKeeper()
currentHeight := s.consumerCtx().BlockHeight()
for s.consumerCtx().BlockHeight() < currentHeight+blocks {
for _, voteInfo := range votes {
consumerSlashingKeeper.HandleValidatorSignature(
s.consumerCtx(),
voteInfo.Validator.Address,
voteInfo.Validator.Power,
voteInfo.SignedLastBlock,
)
}
s.consumerChain.NextBlock()
}
}
12 changes: 6 additions & 6 deletions tests/integration/throttle.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ func (s *CCVTestSuite) TestBasicSlashPacketThrottling() {

s.SetupTest()
s.SetupAllCCVChannels()
s.setupValidatorPowers()
s.setupValidatorPowers([]int64{1000, 1000, 1000, 1000})

providerStakingKeeper := s.providerApp.GetTestStakingKeeper()

Expand Down Expand Up @@ -186,7 +186,7 @@ func (s *CCVTestSuite) TestBasicSlashPacketThrottling() {
func (s *CCVTestSuite) TestMultiConsumerSlashPacketThrottling() {
// Setup test
s.SetupAllCCVChannels()
s.setupValidatorPowers()
s.setupValidatorPowers([]int64{1000, 1000, 1000, 1000})

var (
timeoutHeight = clienttypes.Height{}
Expand Down Expand Up @@ -307,7 +307,7 @@ func (s *CCVTestSuite) TestPacketSpam() {
s.SetupAllCCVChannels()

// Setup validator powers to be 25%, 25%, 25%, 25%
s.setupValidatorPowers()
s.setupValidatorPowers([]int64{1000, 1000, 1000, 1000})

// Explicitly set params, initialize slash meter
providerKeeper := s.providerApp.GetProviderKeeper()
Expand Down Expand Up @@ -373,7 +373,7 @@ func (s *CCVTestSuite) TestDoubleSignDoesNotAffectThrottling() {
s.SetupAllCCVChannels()

// Setup validator powers to be 25%, 25%, 25%, 25%
s.setupValidatorPowers()
s.setupValidatorPowers([]int64{1000, 1000, 1000, 1000})

// Explicitly set params, initialize slash meter
providerKeeper := s.providerApp.GetProviderKeeper()
Expand Down Expand Up @@ -518,7 +518,7 @@ func (s *CCVTestSuite) TestSlashMeterAllowanceChanges() {
// At first, allowance is based on 4 vals all with 1 power, min allowance is in effect.
s.Require().Equal(int64(1), providerKeeper.GetSlashMeterAllowance(s.providerCtx()).Int64())

s.setupValidatorPowers()
s.setupValidatorPowers([]int64{1000, 1000, 1000, 1000})

// Now all 4 validators have 1000 power (4000 total power) so allowance should be:
// default replenish frac * 4000 = 200
Expand All @@ -541,7 +541,7 @@ func (s CCVTestSuite) TestSlashAllValidators() { //nolint:govet // this is a tes
s.SetupAllCCVChannels()

// Setup 4 validators with 25% of the total power each.
s.setupValidatorPowers()
s.setupValidatorPowers([]int64{1000, 1000, 1000, 1000})

providerKeeper := s.providerApp.GetProviderKeeper()

Expand Down
2 changes: 1 addition & 1 deletion tests/integration/throttle_retry.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import (
func (s *CCVTestSuite) TestSlashRetries() {
s.SetupAllCCVChannels()
s.SendEmptyVSCPacket() // Establish ccv channel
s.setupValidatorPowers()
s.setupValidatorPowers([]int64{1000, 1000, 1000, 1000})

//
// Provider setup
Expand Down
8 changes: 8 additions & 0 deletions testutil/integration/debug_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,14 @@ func TestCISBeforeCCVEstablished(t *testing.T) {
runCCVTestByName(t, "TestCISBeforeCCVEstablished")
}

//
// Soft opt out tests
//

func TestSoftOptOut(t *testing.T) {
runCCVTestByName(t, "TestSoftOptOut")
}

//
// Stop consumer tests
//
Expand Down
12 changes: 12 additions & 0 deletions testutil/keeper/mocks.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading