Skip to content

Commit

Permalink
Soft opt out (#833)
Browse files Browse the repository at this point in the history
* 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>
  • Loading branch information
4 people committed Apr 13, 2023
1 parent a718095 commit 1368b95
Show file tree
Hide file tree
Showing 21 changed files with 580 additions and 112 deletions.
1 change: 1 addition & 0 deletions app/consumer-democracy/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -498,6 +498,7 @@ func New(
// CanWithdrawInvariant invariant.
// NOTE: staking module is required if HistoricalEntries param > 0
// NOTE: capability module's beginblocker must come before any modules using capabilities (e.g. IBC)
// NOTE: the soft opt-out requires that the consumer module's beginblocker comes after the slashing module's beginblocker
app.MM.SetOrderBeginBlockers(
// upgrades should be run first
upgradetypes.ModuleName,
Expand Down
1 change: 1 addition & 0 deletions app/consumer/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -442,6 +442,7 @@ func New(
// NOTE: Capability module must occur first so that it can initialize any capabilities
// so that other modules that want to create or claim capabilities afterwards in InitChain
// can do so safely.
// NOTE: the soft opt-out requires that the consumer module's beginblocker comes after the slashing module's beginblocker
app.MM.SetOrderInitGenesis(
capabilitytypes.ModuleName,
authtypes.ModuleName,
Expand Down
5 changes: 5 additions & 0 deletions proto/interchain_security/ccv/consumer/v1/consumer.proto
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,11 @@ message Params {
// which should be smaller than that of the provider in general.
google.protobuf.Duration unbonding_period = 9
[(gogoproto.nullable) = false, (gogoproto.stdduration) = true];

// The threshold for the percentage of validators at the bottom of the set who
// can opt out of running the consumer chain without being punished. For example, a
// value of 0.05 means that the validators in the bottom 5% of the set can opt out
string soft_opt_out_threshold = 10;
}

// LastTransmissionBlockHeight is the last time validator holding
Expand Down
1 change: 1 addition & 0 deletions tests/difference/core/driver/setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -520,6 +520,7 @@ func (b *Builder) createConsumerGenesis(client *ibctmtypes.ClientState) *consume
consumertypes.DefaultConsumerRedistributeFrac,
consumertypes.DefaultHistoricalEntries,
b.initState.UnbondingC,
"0", // disable soft opt-out
)
return consumertypes.NewInitialGenesisState(client, providerConsState, valUpdates, params)
}
Expand Down
2 changes: 1 addition & 1 deletion tests/difference/core/driver/traces.json

Large diffs are not rendered by default.

44 changes: 44 additions & 0 deletions tests/difference/core/model/src/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -478,6 +478,50 @@ class CCVProvider {
return;
}

//
// Soft opt out logic
//

// Sort token powers from lowest to highest
const tokens = this.m.staking.tokens;
const sortedTokens = Object.values(tokens).sort((a, b) => a - b);

// Get total power (token is 1:1 to power)
let totalPower = 0;
sortedTokens.forEach((token, _) => {
totalPower += token;
});

let smallestNonOptOutPower = -1;

// Soft opt out threshold is set as 0 as for now soft opt-out is disabled.
// See createConsumerGenesis() in diff test setup.go
const softOptOutThreshold = 0;

if (softOptOutThreshold == 0) {
smallestNonOptOutPower = 0
} else {
// get power of the smallest validator that cannot soft opt out
let powerSum = 0;

for (let i = 0; i < sortedTokens.length; i++) {
powerSum += sortedTokens[i];
if (powerSum / totalPower > softOptOutThreshold) {
smallestNonOptOutPower = sortedTokens[i];
break;
}
}
}

if (smallestNonOptOutPower == -1) {
throw new Error('control flow should not reach here');
}

if (this.m.staking.tokens[data.val] < smallestNonOptOutPower) {
// soft opt out if validator power is smaller than smallest power which needs to be up
return;
}

this.m.events.push(Event.RECEIVE_DOWNTIME_SLASH_REQUEST);


Expand Down
9 changes: 9 additions & 0 deletions testutil/crypto/crypto.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,15 @@ func NewCryptoIdentityFromIntSeed(i int) *CryptoIdentity {
return NewCryptoIdentityFromBytesSeed(seed)
}

// GenMultipleCryptoIds generates and returns multiple CryptoIdentities from a starting int seed.
func GenMultipleCryptoIds(num int, fromIntSeed int) []*CryptoIdentity {
ids := make([]*CryptoIdentity, num)
for i := 0; i < num; i++ {
ids[i] = NewCryptoIdentityFromIntSeed(fromIntSeed + i)
}
return ids
}

func (v *CryptoIdentity) TMValidator(power int64) *tmtypes.Validator {
return tmtypes.NewValidator(v.TMCryptoPubKey(), power)
}
Expand Down
9 changes: 9 additions & 0 deletions x/ccv/consumer/keeper/params.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ func (k Keeper) GetParams(ctx sdk.Context) types.Params {
k.GetConsumerRedistributionFrac(ctx),
k.GetHistoricalEntries(ctx),
k.GetUnbondingPeriod(ctx),
k.GetSoftOptOutThreshold(ctx),
)
}

Expand Down Expand Up @@ -106,3 +107,11 @@ func (k Keeper) GetUnbondingPeriod(ctx sdk.Context) time.Duration {
k.paramStore.Get(ctx, types.KeyConsumerUnbondingPeriod, &period)
return period
}

// GetSoftOptOutThreshold returns the percentage of validators at the bottom of the set
// that can opt out of running the consumer chain
func (k Keeper) GetSoftOptOutThreshold(ctx sdk.Context) string {
var str string
k.paramStore.Get(ctx, types.KeySoftOptOutThreshold, &str)
return str
}
12 changes: 6 additions & 6 deletions x/ccv/consumer/keeper/params_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import (

testkeeper "github.com/cosmos/interchain-security/testutil/keeper"
"github.com/cosmos/interchain-security/x/ccv/consumer/types"
consumertypes "github.com/cosmos/interchain-security/x/ccv/consumer/types"
ccv "github.com/cosmos/interchain-security/x/ccv/types"
"github.com/stretchr/testify/require"
)
Expand All @@ -23,18 +22,19 @@ func TestParams(t *testing.T) {
"",
"",
ccv.DefaultCCVTimeoutPeriod,
consumertypes.DefaultTransferTimeoutPeriod,
consumertypes.DefaultConsumerRedistributeFrac,
consumertypes.DefaultHistoricalEntries,
consumertypes.DefaultConsumerUnbondingPeriod,
types.DefaultTransferTimeoutPeriod,
types.DefaultConsumerRedistributeFrac,
types.DefaultHistoricalEntries,
types.DefaultConsumerUnbondingPeriod,
types.DefaultSoftOptOutThreshold,
) // these are the default params, IBC suite independently sets enabled=true

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

newParams := types.NewParams(false, 1000,
"channel-2", "cosmos19pe9pg5dv9k5fzgzmsrgnw9rl9asf7ddwhu7lm",
7*24*time.Hour, 25*time.Hour, "0.5", 500, 24*21*time.Hour)
7*24*time.Hour, 25*time.Hour, "0.5", 500, 24*21*time.Hour, "0.05")
consumerKeeper.SetParams(ctx, newParams)
params = consumerKeeper.GetParams(ctx)
require.Equal(t, newParams, params)
Expand Down
74 changes: 74 additions & 0 deletions x/ccv/consumer/keeper/soft_opt_out.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package keeper

import (
"encoding/binary"
"sort"

sdk "github.com/cosmos/cosmos-sdk/types"
"github.com/cosmos/interchain-security/x/ccv/consumer/types"
)

// SetSmallestNonOptOutPower sets the smallest validator power that cannot soft opt out.
func (k Keeper) SetSmallestNonOptOutPower(ctx sdk.Context, power uint64) {
store := ctx.KVStore(k.storeKey)
store.Set(types.SmallestNonOptOutPowerKey(), sdk.Uint64ToBigEndian(power))
}

// UpdateSmallestNonOptOutPower updates the smallest validator power that cannot soft opt out.
// This is the smallest validator power such that the sum of the power of all validators with a lower power
// is less than [SoftOptOutThreshold] of the total power of all validators.
func (k Keeper) UpdateSmallestNonOptOutPower(ctx sdk.Context) {
// get soft opt-out threshold
optOutThreshold := sdk.MustNewDecFromStr(k.GetSoftOptOutThreshold(ctx))
if optOutThreshold.IsZero() {
// If the SoftOptOutThreshold is zero, then soft opt-out is disable.
// Setting the smallest non-opt-out power to zero, fixes the diff-testing
// when soft opt-out is disable.
k.SetSmallestNonOptOutPower(ctx, uint64(0))
return
}

// get all validators
valset := k.GetAllCCValidator(ctx)

// Valset should only be empty for hacky tests. Log error in case this ever happens in prod.
if len(valset) == 0 {
k.Logger(ctx).Error("UpdateSoftOptOutThresholdPower called with empty validator set")
return
}

// sort validators by power ascending
sort.SliceStable(valset, func(i, j int) bool {
return valset[i].Power < valset[j].Power
})

// get total power in set
totalPower := sdk.ZeroDec()
for _, val := range valset {
totalPower = totalPower.Add(sdk.NewDecFromInt(sdk.NewInt(val.Power)))
}

// get power of the smallest validator that cannot soft opt out
powerSum := sdk.ZeroDec()
for _, val := range valset {
powerSum = powerSum.Add(sdk.NewDecFromInt(sdk.NewInt(val.Power)))
// if powerSum / totalPower > SoftOptOutThreshold
if powerSum.Quo(totalPower).GT(optOutThreshold) {
// set smallest non opt out power
k.SetSmallestNonOptOutPower(ctx, uint64(val.Power))
k.Logger(ctx).Info("smallest non opt out power updated", "power", val.Power)
return
}
}
panic("UpdateSoftOptOutThresholdPower should not reach this point. Incorrect logic!")
}

// GetSmallestNonOptOutPower returns the smallest validator power that cannot soft opt out.
func (k Keeper) GetSmallestNonOptOutPower(ctx sdk.Context) int64 {
store := ctx.KVStore(k.storeKey)
bz := store.Get(types.SmallestNonOptOutPowerKey())
if bz == nil {
return 0
}
return int64(binary.BigEndian.Uint64(bz))
}
122 changes: 122 additions & 0 deletions x/ccv/consumer/keeper/soft_opt_out_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
package keeper_test

import (
"testing"

"github.com/cosmos/interchain-security/testutil/crypto"
testkeeper "github.com/cosmos/interchain-security/testutil/keeper"
"github.com/cosmos/interchain-security/x/ccv/consumer/types"
"github.com/stretchr/testify/require"
tmtypes "github.com/tendermint/tendermint/types"
)

// Tests that UpdateSmallestNonOptOutPower updates the smallest validator power that cannot soft opt out.
// Soft opt out allows the bottom [SoftOptOutThreshold] portion of validators in the set to opt out.
// UpdateSmallestNonOptOutPower should update the smallest validator power that cannot opt out.
func TestUpdateSmallestNonOptOutPower(t *testing.T) {
cIds := crypto.GenMultipleCryptoIds(7, 682934679238)

testCases := []struct {
name string
// soft opt out threshold set as param
optOutThresh string
// validators to set in store
validators []*tmtypes.Validator
// expected smallest power of validator which cannot opt out
expSmallestNonOptOutValPower int64
}{
{
name: "One",
optOutThresh: "0.05",
validators: []*tmtypes.Validator{
tmtypes.NewValidator(cIds[0].TMCryptoPubKey(), 1),
tmtypes.NewValidator(cIds[1].TMCryptoPubKey(), 1),
tmtypes.NewValidator(cIds[2].TMCryptoPubKey(), 1),
tmtypes.NewValidator(cIds[3].TMCryptoPubKey(), 3),
tmtypes.NewValidator(cIds[4].TMCryptoPubKey(), 49),
tmtypes.NewValidator(cIds[5].TMCryptoPubKey(), 51),
},
// 107 total power, validator with 3 power passes 0.05 threshold (6 / 107 = 0.056) and cannot opt out
expSmallestNonOptOutValPower: 3,
},
{
name: "One in different order",
optOutThresh: "0.05",
validators: []*tmtypes.Validator{
tmtypes.NewValidator(cIds[0].TMCryptoPubKey(), 3),
tmtypes.NewValidator(cIds[1].TMCryptoPubKey(), 51),
tmtypes.NewValidator(cIds[2].TMCryptoPubKey(), 1),
tmtypes.NewValidator(cIds[3].TMCryptoPubKey(), 49),
tmtypes.NewValidator(cIds[4].TMCryptoPubKey(), 1),
tmtypes.NewValidator(cIds[5].TMCryptoPubKey(), 1),
},
// Same result as first test case, just confirms order of validators doesn't matter
expSmallestNonOptOutValPower: 3,
},
{
name: "Two",
optOutThresh: "0.05",
validators: []*tmtypes.Validator{
tmtypes.NewValidator(cIds[0].TMCryptoPubKey(), 1),
tmtypes.NewValidator(cIds[1].TMCryptoPubKey(), 1),
tmtypes.NewValidator(cIds[2].TMCryptoPubKey(), 1),
tmtypes.NewValidator(cIds[3].TMCryptoPubKey(), 3),
tmtypes.NewValidator(cIds[4].TMCryptoPubKey(), 500),
},
// 506 total power, validator with 500 passes 0.05 threshold and cannot opt out
expSmallestNonOptOutValPower: 500,
},
{
name: "Three",
optOutThresh: "0.199999",
validators: []*tmtypes.Validator{
tmtypes.NewValidator(cIds[0].TMCryptoPubKey(), 54),
tmtypes.NewValidator(cIds[1].TMCryptoPubKey(), 53),
tmtypes.NewValidator(cIds[2].TMCryptoPubKey(), 52),
tmtypes.NewValidator(cIds[3].TMCryptoPubKey(), 51),
tmtypes.NewValidator(cIds[4].TMCryptoPubKey(), 50),
tmtypes.NewValidator(cIds[5].TMCryptoPubKey(), 1),
tmtypes.NewValidator(cIds[6].TMCryptoPubKey(), 1),
},
// 262 total power, (50 + 1 + 1) / 262 ~= 0.19, validator with 51 passes 0.199999 threshold and cannot opt out
expSmallestNonOptOutValPower: 51,
},
{
name: "soft opt-out disabled",
optOutThresh: "0",
validators: []*tmtypes.Validator{
tmtypes.NewValidator(cIds[0].TMCryptoPubKey(), 54),
tmtypes.NewValidator(cIds[1].TMCryptoPubKey(), 53),
tmtypes.NewValidator(cIds[2].TMCryptoPubKey(), 52),
tmtypes.NewValidator(cIds[3].TMCryptoPubKey(), 51),
tmtypes.NewValidator(cIds[4].TMCryptoPubKey(), 50),
tmtypes.NewValidator(cIds[5].TMCryptoPubKey(), 1),
tmtypes.NewValidator(cIds[6].TMCryptoPubKey(), 1),
},
expSmallestNonOptOutValPower: 0,
},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
keeperParams := testkeeper.NewInMemKeeperParams(t)
// explicitly register codec with public key interface
keeperParams.RegisterSdkCryptoCodecInterfaces()
consumerKeeper, ctx, ctrl, _ := testkeeper.GetConsumerKeeperAndCtx(t, keeperParams)
moduleParams := types.DefaultParams()

moduleParams.SoftOptOutThreshold = tc.optOutThresh
consumerKeeper.SetParams(ctx, moduleParams)
defer ctrl.Finish()

// set validators in store
SetCCValidators(t, consumerKeeper, ctx, tc.validators)

// update smallest power of validator which cannot opt out
consumerKeeper.UpdateSmallestNonOptOutPower(ctx)

// expect smallest power of validator which cannot opt out to be updated
require.Equal(t, tc.expSmallestNonOptOutValPower, consumerKeeper.GetSmallestNonOptOutPower(ctx))
})
}
}
12 changes: 12 additions & 0 deletions x/ccv/consumer/keeper/validators.go
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,18 @@ func (k Keeper) Slash(ctx sdk.Context, addr sdk.ConsAddress, infractionHeight, p
return
}

// if this is a downtime infraction and the validator is allowed to
// soft opt out, do not queue a slash packet
if infraction == stakingtypes.Downtime {
if power < k.GetSmallestNonOptOutPower(ctx) {
// soft opt out
k.Logger(ctx).Debug("soft opt out",
"validator", addr,
"power", power,
)
return
}
}
// get VSC ID for infraction height
vscID := k.GetHeightValsetUpdateID(ctx, uint64(infractionHeight))

Expand Down
3 changes: 3 additions & 0 deletions x/ccv/consumer/module.go
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,9 @@ func (AppModule) ConsensusVersion() uint64 { return 1 }
// Set the VSC ID for the subsequent block to the same value as the current block
// Panic if the provider's channel was established and then closed
func (am AppModule) BeginBlock(ctx sdk.Context, req abci.RequestBeginBlock) {
// Update smallest validator power that cannot opt out.
am.keeper.UpdateSmallestNonOptOutPower(ctx)

channelID, found := am.keeper.GetProviderChannel(ctx)
if found && am.keeper.IsChannelClosed(ctx, channelID) {
// The CCV channel was established, but it was then closed;
Expand Down
Loading

0 comments on commit 1368b95

Please sign in to comment.