diff --git a/x/oracle/abci.go b/x/oracle/abci.go index f82e9ae064..a6f86190cd 100644 --- a/x/oracle/abci.go +++ b/x/oracle/abci.go @@ -38,16 +38,12 @@ func CalcPrices(ctx sdk.Context, params types.Params, k keeper.Keeper) error { validatorClaimMap := make(map[string]types.Claim) powerReduction := k.StakingKeeper.PowerReduction(ctx) // Calculate total validator power - var totalBondedValidatorPower int64 - for _, v := range k.StakingKeeper.GetBondedValidatorsByPower(ctx) { - totalBondedValidatorPower += v.GetConsensusPower(powerReduction) - } + var totalBondedPower int64 for _, v := range k.StakingKeeper.GetBondedValidatorsByPower(ctx) { addr := v.GetOperator() - validatorPowerRatio := sdk.NewDec(v.GetConsensusPower(powerReduction)).QuoInt64(totalBondedValidatorPower) - // Power is tracked as an int64 ranging from 0-100 - validatorPower := validatorPowerRatio.MulInt64(100).RoundInt64() - validatorClaimMap[addr.String()] = types.NewClaim(validatorPower, 0, 0, addr) + power := v.GetConsensusPower(powerReduction) + totalBondedPower += power + validatorClaimMap[addr.String()] = types.NewClaim(power, 0, 0, addr) } // voteTargets defines the symbol (ticker) denoms that we require votes on @@ -62,11 +58,14 @@ func CalcPrices(ctx sdk.Context, params types.Params, k keeper.Keeper) error { // NOTE: it filters out inactive or jailed validators ballotDenomSlice := k.OrganizeBallotByDenom(ctx, validatorClaimMap) + threshold := k.VoteThreshold(ctx).MulInt64(types.MaxVoteThresholdMultiplier).TruncateInt64() // Iterate through ballots and update exchange rates; drop if not enough votes have been achieved. for _, ballotDenom := range ballotDenomSlice { - // Convert ballot power to a percentage to compare with VoteThreshold param - if sdk.NewDecWithPrec(ballotDenom.Ballot.Power(), 2).LTE(k.VoteThreshold(ctx)) { + // Calculate the portion of votes received as an integer, scaled up using the + // same multiplier as the `threshold` computed above + support := ballotDenom.Ballot.Power() * types.MaxVoteThresholdMultiplier / totalBondedPower + if support < threshold { ctx.Logger().Info("Ballot voting power is under vote threshold, dropping ballot", "denom", ballotDenom) continue } diff --git a/x/oracle/abci_test.go b/x/oracle/abci_test.go index 6223422c56..062efef773 100644 --- a/x/oracle/abci_test.go +++ b/x/oracle/abci_test.go @@ -35,40 +35,50 @@ type IntegrationTestSuite struct { } const ( - initialPower = int64(10000) + initialPower = int64(1000) ) func (s *IntegrationTestSuite) SetupTest() { require := s.Require() isCheckTx := false app := umeeapp.Setup(s.T()) - ctx := app.BaseApp.NewContext(isCheckTx, tmproto.Header{ + ctx := app.NewContext(isCheckTx, tmproto.Header{ ChainID: fmt.Sprintf("test-chain-%s", tmrand.Str(4)), }) oracle.InitGenesis(ctx, app.OracleKeeper, *types.DefaultGenesisState()) + // validate setup... umeeapp.Setup creates one validator, with 1uumee self delegation + setupVals := app.StakingKeeper.GetBondedValidatorsByPower(ctx) + s.Require().Len(setupVals, 1) + s.Require().Equal(int64(1), setupVals[0].GetConsensusPower(app.StakingKeeper.PowerReduction(ctx))) + sh := teststaking.NewHelper(s.T(), ctx, *app.StakingKeeper) sh.Denom = bondDenom - // mint and send coins to validator - require.NoError(app.BankKeeper.MintCoins(ctx, minttypes.ModuleName, initCoins)) + // mint and send coins to validators + require.NoError(app.BankKeeper.MintCoins(ctx, minttypes.ModuleName, initCoins.MulInt(sdk.NewIntFromUint64(3)))) require.NoError(app.BankKeeper.SendCoinsFromModuleToAccount(ctx, minttypes.ModuleName, addr1, initCoins)) - require.NoError(app.BankKeeper.MintCoins(ctx, minttypes.ModuleName, initCoins)) require.NoError(app.BankKeeper.SendCoinsFromModuleToAccount(ctx, minttypes.ModuleName, addr2, initCoins)) + require.NoError(app.BankKeeper.SendCoinsFromModuleToAccount(ctx, minttypes.ModuleName, addr3, initCoins)) - sh.CreateValidatorWithValPower(valAddr1, valPubKey1, 7000, true) - sh.CreateValidatorWithValPower(valAddr2, valPubKey2, 3000, true) + // self delegate 999 in total ... 1 val with 1uumee is already created in umeeapp.Setup + sh.CreateValidatorWithValPower(valAddr1, valPubKey1, 599, true) + sh.CreateValidatorWithValPower(valAddr2, valPubKey2, 398, true) + sh.CreateValidatorWithValPower(valAddr3, valPubKey3, 2, true) staking.EndBlocker(ctx, *app.StakingKeeper) + err := app.OracleKeeper.SetVoteThreshold(ctx, sdk.MustNewDecFromStr("0.4")) + s.Require().NoError(err) + s.app = app s.ctx = ctx } // Test addresses var ( - valPubKeys = simapp.CreateTestPubKeys(2) + valPubKeys = simapp.CreateTestPubKeys(3) valPubKey1 = valPubKeys[0] pubKey1 = secp256k1.GenPrivKey().PubKey() @@ -80,24 +90,25 @@ var ( addr2 = sdk.AccAddress(pubKey2.Address()) valAddr2 = sdk.ValAddress(pubKey2.Address()) + valPubKey3 = valPubKeys[2] + pubKey3 = secp256k1.GenPrivKey().PubKey() + addr3 = sdk.AccAddress(pubKey3.Address()) + valAddr3 = sdk.ValAddress(pubKey3.Address()) + initTokens = sdk.TokensFromConsensusPower(initialPower, sdk.DefaultPowerReduction) initCoins = sdk.NewCoins(sdk.NewCoin(bondDenom, initTokens)) ) func (s *IntegrationTestSuite) TestEndBlockerVoteThreshold() { app, ctx := s.app, s.ctx - originalBlockHeight := ctx.BlockHeight() ctx = ctx.WithBlockHeight(1) preVoteBlockDiff := int64(app.OracleKeeper.VotePeriod(ctx) / 2) voteBlockDiff := int64(app.OracleKeeper.VotePeriod(ctx)/2 + 1) var ( - val1Tuples types.ExchangeRateTuples - val2Tuples types.ExchangeRateTuples - val1PreVotes types.AggregateExchangeRatePrevote - val2PreVotes types.AggregateExchangeRatePrevote - val1Votes types.AggregateExchangeRateVote - val2Votes types.AggregateExchangeRateVote + val1Tuples types.ExchangeRateTuples + val2Tuples types.ExchangeRateTuples + val3Tuples types.ExchangeRateTuples ) for _, denom := range app.OracleKeeper.AcceptList(ctx) { val1Tuples = append(val1Tuples, types.ExchangeRateTuple{ @@ -108,36 +119,39 @@ func (s *IntegrationTestSuite) TestEndBlockerVoteThreshold() { Denom: denom.SymbolDenom, ExchangeRate: sdk.MustNewDecFromStr("0.5"), }) + val3Tuples = append(val3Tuples, types.ExchangeRateTuple{ + Denom: denom.SymbolDenom, + ExchangeRate: sdk.MustNewDecFromStr("0.6"), + }) } - val1PreVotes = types.AggregateExchangeRatePrevote{ - Hash: "hash1", - Voter: valAddr1.String(), - SubmitBlock: uint64(ctx.BlockHeight()), - } - val2PreVotes = types.AggregateExchangeRatePrevote{ - Hash: "hash2", - Voter: valAddr2.String(), - SubmitBlock: uint64(ctx.BlockHeight()), - } - - val1Votes = types.AggregateExchangeRateVote{ - ExchangeRateTuples: val1Tuples, - Voter: valAddr1.String(), - } - val2Votes = types.AggregateExchangeRateVote{ - ExchangeRateTuples: val2Tuples, - Voter: valAddr2.String(), + createVote := func(hash string, val sdk.ValAddress, rates types.ExchangeRateTuples, blockHeight uint64) (types.AggregateExchangeRatePrevote, types.AggregateExchangeRateVote) { + preVote := types.AggregateExchangeRatePrevote{ + Hash: "hash1", + Voter: val.String(), + SubmitBlock: uint64(blockHeight), + } + vote := types.AggregateExchangeRateVote{ + ExchangeRateTuples: rates, + Voter: val.String(), + } + return preVote, vote } + h := uint64(ctx.BlockHeight()) + val1PreVotes, val1Votes := createVote("hash1", valAddr1, val1Tuples, h) + val2PreVotes, val2Votes := createVote("hash2", valAddr2, val2Tuples, h) + val3PreVotes, val3Votes := createVote("hash3", valAddr3, val3Tuples, h) // total voting power per denom is 100% app.OracleKeeper.SetAggregateExchangeRatePrevote(ctx, valAddr1, val1PreVotes) app.OracleKeeper.SetAggregateExchangeRatePrevote(ctx, valAddr2, val2PreVotes) + app.OracleKeeper.SetAggregateExchangeRatePrevote(ctx, valAddr3, val3PreVotes) oracle.EndBlocker(ctx, app.OracleKeeper) ctx = ctx.WithBlockHeight(ctx.BlockHeight() + voteBlockDiff) app.OracleKeeper.SetAggregateExchangeRateVote(ctx, valAddr1, val1Votes) app.OracleKeeper.SetAggregateExchangeRateVote(ctx, valAddr2, val2Votes) + app.OracleKeeper.SetAggregateExchangeRateVote(ctx, valAddr3, val3Votes) oracle.EndBlocker(ctx, app.OracleKeeper) for _, denom := range app.OracleKeeper.AcceptList(ctx) { @@ -146,12 +160,13 @@ func (s *IntegrationTestSuite) TestEndBlockerVoteThreshold() { s.Require().Equal(sdk.MustNewDecFromStr("1.0"), rate) } - // update prevotes' block + // Test: only val2 votes (has 39% vote power). + // Total voting power per denom must be bigger or equal than 40% (see SetupTest). + // So if only val2 votes, we won't have any prices next block. ctx = ctx.WithBlockHeight(ctx.BlockHeight() + preVoteBlockDiff) - val1PreVotes.SubmitBlock = uint64(ctx.BlockHeight()) - val2PreVotes.SubmitBlock = uint64(ctx.BlockHeight()) + h = uint64(ctx.BlockHeight()) + val2PreVotes.SubmitBlock = h - // total voting power per denom is 30% app.OracleKeeper.SetAggregateExchangeRatePrevote(ctx, valAddr2, val2PreVotes) oracle.EndBlocker(ctx, app.OracleKeeper) @@ -165,30 +180,50 @@ func (s *IntegrationTestSuite) TestEndBlockerVoteThreshold() { s.Require().Equal(sdk.ZeroDec(), rate) } - // update prevotes' block + // Test: val2 and val3 votes. + // now we will have 40% of the power, so now we should have prices + ctx = ctx.WithBlockHeight(ctx.BlockHeight() + preVoteBlockDiff) + h = uint64(ctx.BlockHeight()) + val2PreVotes.SubmitBlock = h + val3PreVotes.SubmitBlock = h + + app.OracleKeeper.SetAggregateExchangeRatePrevote(ctx, valAddr2, val2PreVotes) + app.OracleKeeper.SetAggregateExchangeRatePrevote(ctx, valAddr3, val3PreVotes) + oracle.EndBlocker(ctx, app.OracleKeeper) + + ctx = ctx.WithBlockHeight(ctx.BlockHeight() + voteBlockDiff) + app.OracleKeeper.SetAggregateExchangeRateVote(ctx, valAddr2, val2Votes) + app.OracleKeeper.SetAggregateExchangeRateVote(ctx, valAddr3, val3Votes) + oracle.EndBlocker(ctx, app.OracleKeeper) + + for _, denom := range app.OracleKeeper.AcceptList(ctx) { + rate, err := app.OracleKeeper.GetExchangeRate(ctx, denom.SymbolDenom) + s.Require().NoError(err) + s.Require().Equal(sdk.MustNewDecFromStr("0.5"), rate) + } + + // TODO: check reward distribution + // https://github.com/umee-network/umee/issues/1853 + + // Test: val1 and val2 vote again + // umee has 69.9% power, and atom has 30%, so we should have price for umee, but not for atom ctx = ctx.WithBlockHeight(ctx.BlockHeight() + preVoteBlockDiff) - val1PreVotes.SubmitBlock = uint64(ctx.BlockHeight()) - val2PreVotes.SubmitBlock = uint64(ctx.BlockHeight()) + h = uint64(ctx.BlockHeight()) + val1PreVotes.SubmitBlock = h + val2PreVotes.SubmitBlock = h - // umee has 100% power, and atom has 30% - val1Tuples = types.ExchangeRateTuples{ + val1Votes.ExchangeRateTuples = types.ExchangeRateTuples{ types.ExchangeRateTuple{ Denom: "umee", ExchangeRate: sdk.MustNewDecFromStr("1.0"), }, } - val2Tuples = types.ExchangeRateTuples{ - types.ExchangeRateTuple{ - Denom: "umee", - ExchangeRate: sdk.MustNewDecFromStr("0.5"), - }, + val2Votes.ExchangeRateTuples = types.ExchangeRateTuples{ types.ExchangeRateTuple{ Denom: "atom", ExchangeRate: sdk.MustNewDecFromStr("0.5"), }, } - val1Votes.ExchangeRateTuples = val1Tuples - val2Votes.ExchangeRateTuples = val2Tuples app.OracleKeeper.SetAggregateExchangeRatePrevote(ctx, valAddr1, val1PreVotes) app.OracleKeeper.SetAggregateExchangeRatePrevote(ctx, valAddr2, val2PreVotes) @@ -205,8 +240,6 @@ func (s *IntegrationTestSuite) TestEndBlockerVoteThreshold() { rate, err = app.OracleKeeper.GetExchangeRate(ctx, "atom") s.Require().ErrorIs(err, sdkerrors.Wrap(types.ErrUnknownDenom, "atom")) s.Require().Equal(sdk.ZeroDec(), rate) - - ctx = ctx.WithBlockHeight(originalBlockHeight) } var exchangeRates = map[string][]sdk.Dec{ diff --git a/x/oracle/keeper/ballot.go b/x/oracle/keeper/ballot.go index ec707022ad..bb6320d8bc 100644 --- a/x/oracle/keeper/ballot.go +++ b/x/oracle/keeper/ballot.go @@ -21,13 +21,10 @@ func (k Keeper) OrganizeBallotByDenom( // organize ballot only for the active validators claim, ok := validatorClaimMap[vote.Voter] if ok { - power := claim.Power - for _, tuple := range vote.ExchangeRateTuples { - tmpPower := power votes[tuple.Denom] = append( votes[tuple.Denom], - types.NewVoteForTally(tuple.ExchangeRate, tuple.Denom, voterAddr, tmpPower), + types.NewVoteForTally(tuple.ExchangeRate, tuple.Denom, voterAddr, claim.Power), ) } } diff --git a/x/oracle/keeper/params.go b/x/oracle/keeper/params.go index 59b681de82..603f62208a 100644 --- a/x/oracle/keeper/params.go +++ b/x/oracle/keeper/params.go @@ -12,13 +12,24 @@ func (k Keeper) VotePeriod(ctx sdk.Context) (res uint64) { return } -// VoteThreshold returns the minimum percentage of votes that must be received -// for a ballot to pass. +// VoteThreshold returns the minimum portion of combined validator power of votes +// that must be received for a ballot to pass. func (k Keeper) VoteThreshold(ctx sdk.Context) (res sdk.Dec) { k.paramSpace.Get(ctx, types.KeyVoteThreshold, &res) return } +// SetVoteThreshold sets min combined validator power voting on a denom to accept +// it as valid. +// TODO: this is used in tests, we should refactor the way how this is handled. +func (k Keeper) SetVoteThreshold(ctx sdk.Context, threshold sdk.Dec) error { + if err := types.ValidateVoteThreshold(threshold); err != nil { + return err + } + k.paramSpace.Set(ctx, types.KeyVoteThreshold, &threshold) + return nil +} + // RewardBand returns the ratio of allowable exchange rate error that a validator // can be rewarded. func (k Keeper) RewardBand(ctx sdk.Context) (res sdk.Dec) { diff --git a/x/oracle/simulations/genesis.go b/x/oracle/simulations/genesis.go index ac53d07db7..900dff9b3e 100644 --- a/x/oracle/simulations/genesis.go +++ b/x/oracle/simulations/genesis.go @@ -30,9 +30,9 @@ func GenVotePeriod(r *rand.Rand) uint64 { return uint64(5 + r.Intn(100)) } -// GenVoteThreshold produces a randomized VoteThreshold in the range of [0.333, 0.666] +// GenVoteThreshold produces a randomized VoteThreshold in the range of [0.34, 0.67] func GenVoteThreshold(r *rand.Rand) sdk.Dec { - return sdk.NewDecWithPrec(333, 3).Add(sdk.NewDecWithPrec(int64(r.Intn(333)), 3)) + return sdk.NewDecWithPrec(34, 2).Add(sdk.NewDecWithPrec(int64(r.Intn(33)), 2)) } // GenRewardBand produces a randomized RewardBand in the range of [0.000, 0.100] diff --git a/x/oracle/types/params.go b/x/oracle/types/params.go index 59cc431633..93a4137f6d 100644 --- a/x/oracle/types/params.go +++ b/x/oracle/types/params.go @@ -4,10 +4,22 @@ import ( "fmt" sdk "github.com/cosmos/cosmos-sdk/types" + sdkerrors "github.com/cosmos/cosmos-sdk/types/errors" paramstypes "github.com/cosmos/cosmos-sdk/x/params/types" "gopkg.in/yaml.v3" ) +var ( + oneDec = sdk.OneDec() + minVoteThreshold = sdk.NewDecWithPrec(33, 2) // 0.33 +) + +// maxium number of decimals allowed for VoteThreshold +const ( + MaxVoteThresholdPrecision = 2 + MaxVoteThresholdMultiplier = 100 // must be 10^MaxVoteThresholdPrecision +) + // Parameter keys var ( KeyVotePeriod = []byte("VotePeriod") @@ -220,16 +232,7 @@ func validateVoteThreshold(i interface{}) error { if !ok { return fmt.Errorf("invalid parameter type: %T", i) } - - if v.LT(sdk.NewDecWithPrec(33, 2)) { - return fmt.Errorf("vote threshold must be bigger than 33%%: %s", v) - } - - if v.GT(sdk.OneDec()) { - return fmt.Errorf("vote threshold too large: %s", v) - } - - return nil + return ValidateVoteThreshold(v) } func validateRewardBand(i interface{}) error { @@ -378,3 +381,19 @@ func validateMaximumMedianStamps(i interface{}) error { return nil } + +// ValidateVoteThreshold validates oracle exchange rates power vote threshold. +// Must be +// * a decimal value > 0.33 and <= 1. +// * max precision is 2 (so 0.501 is not allowed) +func ValidateVoteThreshold(x sdk.Dec) error { + if x.LTE(minVoteThreshold) || x.GT(oneDec) { + return sdkerrors.ErrInvalidRequest.Wrapf("threshold must be bigger than %s and <= 1", minVoteThreshold) + } + i := x.MulInt64(100).TruncateInt64() + x2 := sdk.NewDecWithPrec(i, MaxVoteThresholdPrecision) + if !x2.Equal(x) { + return sdkerrors.ErrInvalidRequest.Wrap("threshold precision must be maximum 2 decimals") + } + return nil +} diff --git a/x/oracle/types/params_test.go b/x/oracle/types/params_test.go index d6505fb78f..f91fc1130f 100644 --- a/x/oracle/types/params_test.go +++ b/x/oracle/types/params_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/stretchr/testify/require" + "gotest.tools/v3/assert" sdk "github.com/cosmos/cosmos-sdk/types" ) @@ -27,12 +28,6 @@ func TestValidateVoteThreshold(t *testing.T) { err := validateVoteThreshold("invalidSdkType") require.ErrorContains(t, err, "invalid parameter type: string") - err = validateVoteThreshold(sdk.MustNewDecFromStr("0.31")) - require.ErrorContains(t, err, "vote threshold must be bigger than 33%: 0.310000000000000000") - - err = validateVoteThreshold(sdk.MustNewDecFromStr("40.0")) - require.ErrorContains(t, err, "vote threshold too large: 40.000000000000000000") - err = validateVoteThreshold(sdk.MustNewDecFromStr("0.35")) require.Nil(t, err) } @@ -246,3 +241,36 @@ func TestParamsEqual(t *testing.T) { require.NotNil(t, p13.ParamSetPairs()) require.NotNil(t, p13.String()) } + +func TestValidateVotingThreshold(t *testing.T) { + tcs := []struct { + name string + t sdk.Dec + errMsg string + }{ + {"fail: negative", sdk.MustNewDecFromStr("-1"), "threshold must be"}, + {"fail: zero", sdk.ZeroDec(), "threshold must be"}, + {"fail: less than 0.33", sdk.MustNewDecFromStr("0.3"), "threshold must be"}, + {"fail: equal 0.33", sdk.MustNewDecFromStr("0.33"), "threshold must be"}, + {"fail: more than 1", sdk.MustNewDecFromStr("1.1"), "threshold must be"}, + {"fail: more than 1", sdk.MustNewDecFromStr("10"), "threshold must be"}, + {"fail: max precision 2", sdk.MustNewDecFromStr("0.333"), "maximum 2 decimals"}, + {"fail: max precision 2", sdk.MustNewDecFromStr("0.401"), "maximum 2 decimals"}, + {"fail: max precision 2", sdk.MustNewDecFromStr("0.409"), "maximum 2 decimals"}, + {"fail: max precision 2", sdk.MustNewDecFromStr("0.4009"), "maximum 2 decimals"}, + {"fail: max precision 2", sdk.MustNewDecFromStr("0.999"), "maximum 2 decimals"}, + + {"ok: 1", sdk.MustNewDecFromStr("1"), ""}, + {"ok: 0.34", sdk.MustNewDecFromStr("0.34"), ""}, + {"ok: 0.99", sdk.MustNewDecFromStr("0.99"), ""}, + } + + for _, tc := range tcs { + err := ValidateVoteThreshold(tc.t) + if tc.errMsg == "" { + assert.NilError(t, err, "test_case", tc.name) + } else { + assert.ErrorContains(t, err, tc.errMsg, tc.name) + } + } +}