Skip to content

Commit 1368b95

Browse files
shaspitzjtrembackmpokesainoe
committed
Soft opt out (#833)
* WIP soft opt out code with incomplete boilerplate * proto changes * Seems like it should work * Unit test for UpdateLargestSoftOptOutValidatorPower * fixes and renames, unit tests work * update comment * log * Update proto/interchain_security/ccv/consumer/v1/consumer.proto Co-authored-by: Marius Poke <marius.poke@posteo.de> * better validation for soft opt out threshhold * improve test * slicestable * semantics and improved test * use correct key util * Update module.go * comment * updated semantics * separate files * fix TestMakeConsumerGenesis test * fix naming * change upper bound on soft opt out thresh * fix test * allow empty valset for tests * gofumpt and fix from merge * Update x/ccv/consumer/types/params_test.go * Update x/ccv/consumer/types/params.go * Soft opt out diff tests (#847) * wip * fixes for ts build * AI fixed my bug lol * throw error when needed * comment * disable soft opt-out in diff testing * update diff testing model * update UTs --------- Co-authored-by: mpoke <marius.poke@posteo.de> * add comment about beginblocker order requirement for soft opt-out --------- Co-authored-by: Jehan Tremback <hi@jehan.email> Co-authored-by: Marius Poke <marius.poke@posteo.de> Co-authored-by: Simon Noetzlin <simon.ntz@gmail.com>
1 parent a718095 commit 1368b95

File tree

21 files changed

+580
-112
lines changed

21 files changed

+580
-112
lines changed

app/consumer-democracy/app.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -498,6 +498,7 @@ func New(
498498
// CanWithdrawInvariant invariant.
499499
// NOTE: staking module is required if HistoricalEntries param > 0
500500
// NOTE: capability module's beginblocker must come before any modules using capabilities (e.g. IBC)
501+
// NOTE: the soft opt-out requires that the consumer module's beginblocker comes after the slashing module's beginblocker
501502
app.MM.SetOrderBeginBlockers(
502503
// upgrades should be run first
503504
upgradetypes.ModuleName,

app/consumer/app.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -442,6 +442,7 @@ func New(
442442
// NOTE: Capability module must occur first so that it can initialize any capabilities
443443
// so that other modules that want to create or claim capabilities afterwards in InitChain
444444
// can do so safely.
445+
// NOTE: the soft opt-out requires that the consumer module's beginblocker comes after the slashing module's beginblocker
445446
app.MM.SetOrderInitGenesis(
446447
capabilitytypes.ModuleName,
447448
authtypes.ModuleName,

proto/interchain_security/ccv/consumer/v1/consumer.proto

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,11 @@ message Params {
5252
// which should be smaller than that of the provider in general.
5353
google.protobuf.Duration unbonding_period = 9
5454
[(gogoproto.nullable) = false, (gogoproto.stdduration) = true];
55+
56+
// The threshold for the percentage of validators at the bottom of the set who
57+
// can opt out of running the consumer chain without being punished. For example, a
58+
// value of 0.05 means that the validators in the bottom 5% of the set can opt out
59+
string soft_opt_out_threshold = 10;
5560
}
5661

5762
// LastTransmissionBlockHeight is the last time validator holding

tests/difference/core/driver/setup.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -520,6 +520,7 @@ func (b *Builder) createConsumerGenesis(client *ibctmtypes.ClientState) *consume
520520
consumertypes.DefaultConsumerRedistributeFrac,
521521
consumertypes.DefaultHistoricalEntries,
522522
b.initState.UnbondingC,
523+
"0", // disable soft opt-out
523524
)
524525
return consumertypes.NewInitialGenesisState(client, providerConsState, valUpdates, params)
525526
}

tests/difference/core/driver/traces.json

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

tests/difference/core/model/src/model.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -478,6 +478,50 @@ class CCVProvider {
478478
return;
479479
}
480480

481+
//
482+
// Soft opt out logic
483+
//
484+
485+
// Sort token powers from lowest to highest
486+
const tokens = this.m.staking.tokens;
487+
const sortedTokens = Object.values(tokens).sort((a, b) => a - b);
488+
489+
// Get total power (token is 1:1 to power)
490+
let totalPower = 0;
491+
sortedTokens.forEach((token, _) => {
492+
totalPower += token;
493+
});
494+
495+
let smallestNonOptOutPower = -1;
496+
497+
// Soft opt out threshold is set as 0 as for now soft opt-out is disabled.
498+
// See createConsumerGenesis() in diff test setup.go
499+
const softOptOutThreshold = 0;
500+
501+
if (softOptOutThreshold == 0) {
502+
smallestNonOptOutPower = 0
503+
} else {
504+
// get power of the smallest validator that cannot soft opt out
505+
let powerSum = 0;
506+
507+
for (let i = 0; i < sortedTokens.length; i++) {
508+
powerSum += sortedTokens[i];
509+
if (powerSum / totalPower > softOptOutThreshold) {
510+
smallestNonOptOutPower = sortedTokens[i];
511+
break;
512+
}
513+
}
514+
}
515+
516+
if (smallestNonOptOutPower == -1) {
517+
throw new Error('control flow should not reach here');
518+
}
519+
520+
if (this.m.staking.tokens[data.val] < smallestNonOptOutPower) {
521+
// soft opt out if validator power is smaller than smallest power which needs to be up
522+
return;
523+
}
524+
481525
this.m.events.push(Event.RECEIVE_DOWNTIME_SLASH_REQUEST);
482526

483527

testutil/crypto/crypto.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,15 @@ func NewCryptoIdentityFromIntSeed(i int) *CryptoIdentity {
4646
return NewCryptoIdentityFromBytesSeed(seed)
4747
}
4848

49+
// GenMultipleCryptoIds generates and returns multiple CryptoIdentities from a starting int seed.
50+
func GenMultipleCryptoIds(num int, fromIntSeed int) []*CryptoIdentity {
51+
ids := make([]*CryptoIdentity, num)
52+
for i := 0; i < num; i++ {
53+
ids[i] = NewCryptoIdentityFromIntSeed(fromIntSeed + i)
54+
}
55+
return ids
56+
}
57+
4958
func (v *CryptoIdentity) TMValidator(power int64) *tmtypes.Validator {
5059
return tmtypes.NewValidator(v.TMCryptoPubKey(), power)
5160
}

x/ccv/consumer/keeper/params.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ func (k Keeper) GetParams(ctx sdk.Context) types.Params {
2121
k.GetConsumerRedistributionFrac(ctx),
2222
k.GetHistoricalEntries(ctx),
2323
k.GetUnbondingPeriod(ctx),
24+
k.GetSoftOptOutThreshold(ctx),
2425
)
2526
}
2627

@@ -106,3 +107,11 @@ func (k Keeper) GetUnbondingPeriod(ctx sdk.Context) time.Duration {
106107
k.paramStore.Get(ctx, types.KeyConsumerUnbondingPeriod, &period)
107108
return period
108109
}
110+
111+
// GetSoftOptOutThreshold returns the percentage of validators at the bottom of the set
112+
// that can opt out of running the consumer chain
113+
func (k Keeper) GetSoftOptOutThreshold(ctx sdk.Context) string {
114+
var str string
115+
k.paramStore.Get(ctx, types.KeySoftOptOutThreshold, &str)
116+
return str
117+
}

x/ccv/consumer/keeper/params_test.go

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import (
66

77
testkeeper "github.com/cosmos/interchain-security/testutil/keeper"
88
"github.com/cosmos/interchain-security/x/ccv/consumer/types"
9-
consumertypes "github.com/cosmos/interchain-security/x/ccv/consumer/types"
109
ccv "github.com/cosmos/interchain-security/x/ccv/types"
1110
"github.com/stretchr/testify/require"
1211
)
@@ -23,18 +22,19 @@ func TestParams(t *testing.T) {
2322
"",
2423
"",
2524
ccv.DefaultCCVTimeoutPeriod,
26-
consumertypes.DefaultTransferTimeoutPeriod,
27-
consumertypes.DefaultConsumerRedistributeFrac,
28-
consumertypes.DefaultHistoricalEntries,
29-
consumertypes.DefaultConsumerUnbondingPeriod,
25+
types.DefaultTransferTimeoutPeriod,
26+
types.DefaultConsumerRedistributeFrac,
27+
types.DefaultHistoricalEntries,
28+
types.DefaultConsumerUnbondingPeriod,
29+
types.DefaultSoftOptOutThreshold,
3030
) // these are the default params, IBC suite independently sets enabled=true
3131

3232
params := consumerKeeper.GetParams(ctx)
3333
require.Equal(t, expParams, params)
3434

3535
newParams := types.NewParams(false, 1000,
3636
"channel-2", "cosmos19pe9pg5dv9k5fzgzmsrgnw9rl9asf7ddwhu7lm",
37-
7*24*time.Hour, 25*time.Hour, "0.5", 500, 24*21*time.Hour)
37+
7*24*time.Hour, 25*time.Hour, "0.5", 500, 24*21*time.Hour, "0.05")
3838
consumerKeeper.SetParams(ctx, newParams)
3939
params = consumerKeeper.GetParams(ctx)
4040
require.Equal(t, newParams, params)

x/ccv/consumer/keeper/soft_opt_out.go

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
package keeper
2+
3+
import (
4+
"encoding/binary"
5+
"sort"
6+
7+
sdk "github.com/cosmos/cosmos-sdk/types"
8+
"github.com/cosmos/interchain-security/x/ccv/consumer/types"
9+
)
10+
11+
// SetSmallestNonOptOutPower sets the smallest validator power that cannot soft opt out.
12+
func (k Keeper) SetSmallestNonOptOutPower(ctx sdk.Context, power uint64) {
13+
store := ctx.KVStore(k.storeKey)
14+
store.Set(types.SmallestNonOptOutPowerKey(), sdk.Uint64ToBigEndian(power))
15+
}
16+
17+
// UpdateSmallestNonOptOutPower updates the smallest validator power that cannot soft opt out.
18+
// This is the smallest validator power such that the sum of the power of all validators with a lower power
19+
// is less than [SoftOptOutThreshold] of the total power of all validators.
20+
func (k Keeper) UpdateSmallestNonOptOutPower(ctx sdk.Context) {
21+
// get soft opt-out threshold
22+
optOutThreshold := sdk.MustNewDecFromStr(k.GetSoftOptOutThreshold(ctx))
23+
if optOutThreshold.IsZero() {
24+
// If the SoftOptOutThreshold is zero, then soft opt-out is disable.
25+
// Setting the smallest non-opt-out power to zero, fixes the diff-testing
26+
// when soft opt-out is disable.
27+
k.SetSmallestNonOptOutPower(ctx, uint64(0))
28+
return
29+
}
30+
31+
// get all validators
32+
valset := k.GetAllCCValidator(ctx)
33+
34+
// Valset should only be empty for hacky tests. Log error in case this ever happens in prod.
35+
if len(valset) == 0 {
36+
k.Logger(ctx).Error("UpdateSoftOptOutThresholdPower called with empty validator set")
37+
return
38+
}
39+
40+
// sort validators by power ascending
41+
sort.SliceStable(valset, func(i, j int) bool {
42+
return valset[i].Power < valset[j].Power
43+
})
44+
45+
// get total power in set
46+
totalPower := sdk.ZeroDec()
47+
for _, val := range valset {
48+
totalPower = totalPower.Add(sdk.NewDecFromInt(sdk.NewInt(val.Power)))
49+
}
50+
51+
// get power of the smallest validator that cannot soft opt out
52+
powerSum := sdk.ZeroDec()
53+
for _, val := range valset {
54+
powerSum = powerSum.Add(sdk.NewDecFromInt(sdk.NewInt(val.Power)))
55+
// if powerSum / totalPower > SoftOptOutThreshold
56+
if powerSum.Quo(totalPower).GT(optOutThreshold) {
57+
// set smallest non opt out power
58+
k.SetSmallestNonOptOutPower(ctx, uint64(val.Power))
59+
k.Logger(ctx).Info("smallest non opt out power updated", "power", val.Power)
60+
return
61+
}
62+
}
63+
panic("UpdateSoftOptOutThresholdPower should not reach this point. Incorrect logic!")
64+
}
65+
66+
// GetSmallestNonOptOutPower returns the smallest validator power that cannot soft opt out.
67+
func (k Keeper) GetSmallestNonOptOutPower(ctx sdk.Context) int64 {
68+
store := ctx.KVStore(k.storeKey)
69+
bz := store.Get(types.SmallestNonOptOutPowerKey())
70+
if bz == nil {
71+
return 0
72+
}
73+
return int64(binary.BigEndian.Uint64(bz))
74+
}
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
package keeper_test
2+
3+
import (
4+
"testing"
5+
6+
"github.com/cosmos/interchain-security/testutil/crypto"
7+
testkeeper "github.com/cosmos/interchain-security/testutil/keeper"
8+
"github.com/cosmos/interchain-security/x/ccv/consumer/types"
9+
"github.com/stretchr/testify/require"
10+
tmtypes "github.com/tendermint/tendermint/types"
11+
)
12+
13+
// Tests that UpdateSmallestNonOptOutPower updates the smallest validator power that cannot soft opt out.
14+
// Soft opt out allows the bottom [SoftOptOutThreshold] portion of validators in the set to opt out.
15+
// UpdateSmallestNonOptOutPower should update the smallest validator power that cannot opt out.
16+
func TestUpdateSmallestNonOptOutPower(t *testing.T) {
17+
cIds := crypto.GenMultipleCryptoIds(7, 682934679238)
18+
19+
testCases := []struct {
20+
name string
21+
// soft opt out threshold set as param
22+
optOutThresh string
23+
// validators to set in store
24+
validators []*tmtypes.Validator
25+
// expected smallest power of validator which cannot opt out
26+
expSmallestNonOptOutValPower int64
27+
}{
28+
{
29+
name: "One",
30+
optOutThresh: "0.05",
31+
validators: []*tmtypes.Validator{
32+
tmtypes.NewValidator(cIds[0].TMCryptoPubKey(), 1),
33+
tmtypes.NewValidator(cIds[1].TMCryptoPubKey(), 1),
34+
tmtypes.NewValidator(cIds[2].TMCryptoPubKey(), 1),
35+
tmtypes.NewValidator(cIds[3].TMCryptoPubKey(), 3),
36+
tmtypes.NewValidator(cIds[4].TMCryptoPubKey(), 49),
37+
tmtypes.NewValidator(cIds[5].TMCryptoPubKey(), 51),
38+
},
39+
// 107 total power, validator with 3 power passes 0.05 threshold (6 / 107 = 0.056) and cannot opt out
40+
expSmallestNonOptOutValPower: 3,
41+
},
42+
{
43+
name: "One in different order",
44+
optOutThresh: "0.05",
45+
validators: []*tmtypes.Validator{
46+
tmtypes.NewValidator(cIds[0].TMCryptoPubKey(), 3),
47+
tmtypes.NewValidator(cIds[1].TMCryptoPubKey(), 51),
48+
tmtypes.NewValidator(cIds[2].TMCryptoPubKey(), 1),
49+
tmtypes.NewValidator(cIds[3].TMCryptoPubKey(), 49),
50+
tmtypes.NewValidator(cIds[4].TMCryptoPubKey(), 1),
51+
tmtypes.NewValidator(cIds[5].TMCryptoPubKey(), 1),
52+
},
53+
// Same result as first test case, just confirms order of validators doesn't matter
54+
expSmallestNonOptOutValPower: 3,
55+
},
56+
{
57+
name: "Two",
58+
optOutThresh: "0.05",
59+
validators: []*tmtypes.Validator{
60+
tmtypes.NewValidator(cIds[0].TMCryptoPubKey(), 1),
61+
tmtypes.NewValidator(cIds[1].TMCryptoPubKey(), 1),
62+
tmtypes.NewValidator(cIds[2].TMCryptoPubKey(), 1),
63+
tmtypes.NewValidator(cIds[3].TMCryptoPubKey(), 3),
64+
tmtypes.NewValidator(cIds[4].TMCryptoPubKey(), 500),
65+
},
66+
// 506 total power, validator with 500 passes 0.05 threshold and cannot opt out
67+
expSmallestNonOptOutValPower: 500,
68+
},
69+
{
70+
name: "Three",
71+
optOutThresh: "0.199999",
72+
validators: []*tmtypes.Validator{
73+
tmtypes.NewValidator(cIds[0].TMCryptoPubKey(), 54),
74+
tmtypes.NewValidator(cIds[1].TMCryptoPubKey(), 53),
75+
tmtypes.NewValidator(cIds[2].TMCryptoPubKey(), 52),
76+
tmtypes.NewValidator(cIds[3].TMCryptoPubKey(), 51),
77+
tmtypes.NewValidator(cIds[4].TMCryptoPubKey(), 50),
78+
tmtypes.NewValidator(cIds[5].TMCryptoPubKey(), 1),
79+
tmtypes.NewValidator(cIds[6].TMCryptoPubKey(), 1),
80+
},
81+
// 262 total power, (50 + 1 + 1) / 262 ~= 0.19, validator with 51 passes 0.199999 threshold and cannot opt out
82+
expSmallestNonOptOutValPower: 51,
83+
},
84+
{
85+
name: "soft opt-out disabled",
86+
optOutThresh: "0",
87+
validators: []*tmtypes.Validator{
88+
tmtypes.NewValidator(cIds[0].TMCryptoPubKey(), 54),
89+
tmtypes.NewValidator(cIds[1].TMCryptoPubKey(), 53),
90+
tmtypes.NewValidator(cIds[2].TMCryptoPubKey(), 52),
91+
tmtypes.NewValidator(cIds[3].TMCryptoPubKey(), 51),
92+
tmtypes.NewValidator(cIds[4].TMCryptoPubKey(), 50),
93+
tmtypes.NewValidator(cIds[5].TMCryptoPubKey(), 1),
94+
tmtypes.NewValidator(cIds[6].TMCryptoPubKey(), 1),
95+
},
96+
expSmallestNonOptOutValPower: 0,
97+
},
98+
}
99+
100+
for _, tc := range testCases {
101+
t.Run(tc.name, func(t *testing.T) {
102+
keeperParams := testkeeper.NewInMemKeeperParams(t)
103+
// explicitly register codec with public key interface
104+
keeperParams.RegisterSdkCryptoCodecInterfaces()
105+
consumerKeeper, ctx, ctrl, _ := testkeeper.GetConsumerKeeperAndCtx(t, keeperParams)
106+
moduleParams := types.DefaultParams()
107+
108+
moduleParams.SoftOptOutThreshold = tc.optOutThresh
109+
consumerKeeper.SetParams(ctx, moduleParams)
110+
defer ctrl.Finish()
111+
112+
// set validators in store
113+
SetCCValidators(t, consumerKeeper, ctx, tc.validators)
114+
115+
// update smallest power of validator which cannot opt out
116+
consumerKeeper.UpdateSmallestNonOptOutPower(ctx)
117+
118+
// expect smallest power of validator which cannot opt out to be updated
119+
require.Equal(t, tc.expSmallestNonOptOutValPower, consumerKeeper.GetSmallestNonOptOutPower(ctx))
120+
})
121+
}
122+
}

x/ccv/consumer/keeper/validators.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,18 @@ func (k Keeper) Slash(ctx sdk.Context, addr sdk.ConsAddress, infractionHeight, p
9999
return
100100
}
101101

102+
// if this is a downtime infraction and the validator is allowed to
103+
// soft opt out, do not queue a slash packet
104+
if infraction == stakingtypes.Downtime {
105+
if power < k.GetSmallestNonOptOutPower(ctx) {
106+
// soft opt out
107+
k.Logger(ctx).Debug("soft opt out",
108+
"validator", addr,
109+
"power", power,
110+
)
111+
return
112+
}
113+
}
102114
// get VSC ID for infraction height
103115
vscID := k.GetHeightValsetUpdateID(ctx, uint64(infractionHeight))
104116

x/ccv/consumer/module.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,9 @@ func (AppModule) ConsensusVersion() uint64 { return 1 }
149149
// Set the VSC ID for the subsequent block to the same value as the current block
150150
// Panic if the provider's channel was established and then closed
151151
func (am AppModule) BeginBlock(ctx sdk.Context, req abci.RequestBeginBlock) {
152+
// Update smallest validator power that cannot opt out.
153+
am.keeper.UpdateSmallestNonOptOutPower(ctx)
154+
152155
channelID, found := am.keeper.GetProviderChannel(ctx)
153156
if found && am.keeper.IsChannelClosed(ctx, channelID) {
154157
// The CCV channel was established, but it was then closed;

0 commit comments

Comments
 (0)