From cae7503f7be602d6695cfd8f7c6c643bb95f73e4 Mon Sep 17 00:00:00 2001 From: Denali Marsh Date: Mon, 7 Jun 2021 18:08:03 +0200 Subject: [PATCH] Token holder governance (#917) * Committee types (#899) * committee types * refactor to committee interface * include tokencommitee stringer method * add members to BaseCommittee * address revisions * update querier * update querier * fix compilation errors, tests, etc. * Update MsgVote with vote type (#900) * add vote to msg * update querier/rest * update example cli vote msg * remove incorrect comments * address revisions * update handler, stub keeper method * add vote type to vote struct * Committee module keeper logic for token holder governance (#902) * fix keeper/test compilation errors * fix keeper/test compilation errors pt 2 * add setters to committee interface * fix sims compilation errors * fix incentive tests compilation errors * update types, expected keepers * core keeper logic * don't allow bond denom * implement vote tallying * query proposal polling status * update module keepers in app.go * register committee interface * fix failing incentive test * commitee types tests * refactor GetProposalResult by committee types * update invariants * implement most proposal keeper tests * add nulls to custom enums * remove abstain vote type * add test for close proposal * remove outdated TODOs * update ProcessProposals * switch on committee type directly * reintroduce Abstain votes and update vote tallying * don't allow divide by 0 panics * delete unused setters on committee interface * clean up tally methods return values for querier * update enum validation to catch negative ints * reintroduce setters for sims compilation * address revisions * remove commented out test * implement ProcessProposals test * additional revisions * Committee migrations (#909) * add committee v14 legacy types * update migration imports for compile * addRegisterCodec() to committee v14 legacy types * migrate committee genesis state from v14 to v15 * set stability committee permissions properly * fix committee allowed params * migration test, kava-7 sample data * add concrete types to committees (#911) * revisions: migrate + tests * register msgs on legacy codec * Prepare Committee module for migrations (#906) * remove invariants * edits * fix abci test * fix keeper querier tests * add committee interface registration * use codec.Codec * don't allow null vote types * don't allow null tally option * minor spelling fixes * update example cli proposal * fix cli tally query * enable vote abstain from cli * include vote options in cli help text * call CloseProposal from handler * custom enum marshaling * committee: fix failing tests (#921) * fix failing tests * fix: spelling Co-authored-by: rhuairahrighairigh Co-authored-by: Ruaridh Co-authored-by: Kevin Davis --- app/app.go | 2 + go.mod | 1 + migrate/v0_14/migrate.go | 2 +- migrate/v0_14/migrate_test.go | 2 +- migrate/v0_15/migrate.go | 193 +++ migrate/v0_15/migrate_test.go | 56 + .../testdata/kava-7-committee-state.json | 402 ++++++ x/committee/abci.go | 4 +- x/committee/abci_test.go | 77 +- x/committee/alias.go | 15 +- x/committee/client/cli/query.go | 8 +- x/committee/client/cli/tx.go | 34 +- x/committee/client/common/query.go | 2 +- x/committee/client/rest/rest.go | 1 + x/committee/client/rest/tx.go | 26 +- x/committee/genesis_test.go | 98 +- x/committee/handler.go | 2 +- x/committee/handler_test.go | 17 +- x/committee/keeper/committee_test.go | 3 +- x/committee/keeper/integration_test.go | 6 + x/committee/keeper/invariants.go | 140 --- x/committee/keeper/keeper.go | 23 +- x/committee/keeper/keeper_test.go | 28 +- x/committee/keeper/proposal.go | 269 ++-- x/committee/keeper/proposal_test.go | 911 ++++++++++++-- x/committee/keeper/querier.go | 25 +- x/committee/keeper/querier_test.go | 83 +- x/committee/legacy/v0_14/types.go | 1094 +++++++++++++++++ x/committee/module.go | 4 +- x/committee/proposal_handler.go | 7 +- x/committee/proposal_handler_test.go | 87 +- x/committee/simulation/decoder_test.go | 3 +- x/committee/simulation/genesis.go | 44 +- x/committee/simulation/operations.go | 26 +- x/committee/simulation/proposals.go | 12 +- x/committee/types/codec.go | 5 + x/committee/types/committee.go | 354 +++++- x/committee/types/committee_test.go | 342 ++++++ x/committee/types/errors.go | 1 + x/committee/types/events.go | 6 +- x/committee/types/expected_keepers.go | 13 + x/committee/types/genesis.go | 18 +- x/committee/types/genesis_test.go | 59 +- x/committee/types/msg.go | 116 +- x/committee/types/msg_test.go | 24 +- x/committee/types/proposal.go | 58 + x/committee/types/querier.go | 21 + x/incentive/keeper/integration_test.go | 17 +- x/incentive/keeper/rewards_borrow_test.go | 8 +- x/incentive/keeper/rewards_supply_test.go | 9 +- 50 files changed, 4100 insertions(+), 658 deletions(-) create mode 100644 migrate/v0_15/migrate.go create mode 100644 migrate/v0_15/migrate_test.go create mode 100644 migrate/v0_15/testdata/kava-7-committee-state.json delete mode 100644 x/committee/keeper/invariants.go create mode 100644 x/committee/legacy/v0_14/types.go create mode 100644 x/committee/types/committee_test.go diff --git a/app/app.go b/app/app.go index 5293b761..aa87ec1d 100644 --- a/app/app.go +++ b/app/app.go @@ -299,6 +299,8 @@ func NewApp(logger log.Logger, db dbm.DB, traceStore io.Writer, appOpts AppOptio keys[committee.StoreKey], committeeGovRouter, app.paramsKeeper, + app.accountKeeper, + app.supplyKeeper, ) app.kavadistKeeper = kavadist.NewKeeper( app.cdc, diff --git a/go.mod b/go.mod index 68b1183f..eb367ca9 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/spf13/cobra v1.0.0 github.com/spf13/viper v1.6.3 github.com/stretchr/testify v1.6.1 + github.com/tendermint/go-amino v0.15.1 github.com/tendermint/tendermint v0.33.9 github.com/tendermint/tm-db v0.5.1 gopkg.in/yaml.v2 v2.3.0 diff --git a/migrate/v0_14/migrate.go b/migrate/v0_14/migrate.go index 812238cc..dbd17099 100644 --- a/migrate/v0_14/migrate.go +++ b/migrate/v0_14/migrate.go @@ -19,8 +19,8 @@ import ( "github.com/kava-labs/kava/x/bep3" v0_14cdp "github.com/kava-labs/kava/x/cdp" v0_11cdp "github.com/kava-labs/kava/x/cdp/legacy/v0_11" - v0_14committee "github.com/kava-labs/kava/x/committee" v0_11committee "github.com/kava-labs/kava/x/committee/legacy/v0_11" + v0_14committee "github.com/kava-labs/kava/x/committee/legacy/v0_14" v0_14hard "github.com/kava-labs/kava/x/hard" v0_11hard "github.com/kava-labs/kava/x/hard/legacy/v0_11" v0_14incentive "github.com/kava-labs/kava/x/incentive" diff --git a/migrate/v0_14/migrate_test.go b/migrate/v0_14/migrate_test.go index b680f6f5..e2035ea5 100644 --- a/migrate/v0_14/migrate_test.go +++ b/migrate/v0_14/migrate_test.go @@ -20,8 +20,8 @@ import ( "github.com/kava-labs/kava/app" "github.com/kava-labs/kava/x/bep3" v0_11cdp "github.com/kava-labs/kava/x/cdp/legacy/v0_11" - v0_14committee "github.com/kava-labs/kava/x/committee" v0_11committee "github.com/kava-labs/kava/x/committee/legacy/v0_11" + v0_14committee "github.com/kava-labs/kava/x/committee/legacy/v0_14" v0_14hard "github.com/kava-labs/kava/x/hard" v0_11hard "github.com/kava-labs/kava/x/hard/legacy/v0_11" v0_14incentive "github.com/kava-labs/kava/x/incentive" diff --git a/migrate/v0_15/migrate.go b/migrate/v0_15/migrate.go new file mode 100644 index 00000000..d5b79e27 --- /dev/null +++ b/migrate/v0_15/migrate.go @@ -0,0 +1,193 @@ +package v0_15 + +import ( + "time" + + "github.com/cosmos/cosmos-sdk/codec" + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/cosmos/cosmos-sdk/x/genutil" + + cryptoAmino "github.com/tendermint/tendermint/crypto/encoding/amino" + tmtypes "github.com/tendermint/tendermint/types" + + "github.com/kava-labs/kava/app" + v0_14committee "github.com/kava-labs/kava/x/committee/legacy/v0_14" + "github.com/kava-labs/kava/x/committee/types" + v0_15committee "github.com/kava-labs/kava/x/committee/types" +) + +var ( + // TODO: update GenesisTime for kava-8 launch + GenesisTime = time.Date(2021, 4, 8, 15, 0, 0, 0, time.UTC) +) + +// Migrate translates a genesis file from kava v0.14 format to kava v0.15 format +func Migrate(genDoc tmtypes.GenesisDoc) tmtypes.GenesisDoc { + // migrate app state + var appStateMap genutil.AppMap + cdc := codec.New() + cryptoAmino.RegisterAmino(cdc) + tmtypes.RegisterEvidences(cdc) + + if err := cdc.UnmarshalJSON(genDoc.AppState, &appStateMap); err != nil { + panic(err) + } + newAppState := MigrateAppState(appStateMap) + v0_15Codec := app.MakeCodec() + marshaledNewAppState, err := v0_15Codec.MarshalJSON(newAppState) + if err != nil { + panic(err) + } + genDoc.AppState = marshaledNewAppState + genDoc.GenesisTime = GenesisTime + genDoc.ChainID = "kava-8" + return genDoc +} + +// MigrateAppState migrates application state from v0.14 format to a kava v0.15 format +func MigrateAppState(v0_14AppState genutil.AppMap) genutil.AppMap { + v0_15AppState := v0_14AppState + + // Migrate commmittee app state + if v0_14AppState[v0_14committee.ModuleName] != nil { + // Unmarshal v14 committee genesis state and delete it + var committeeGS v0_14committee.GenesisState + cdc := codec.New() + sdk.RegisterCodec(cdc) + v0_14committee.RegisterCodec(cdc) + cdc.MustUnmarshalJSON(v0_14AppState[v0_14committee.ModuleName], &committeeGS) + delete(v0_14AppState, v0_14committee.ModuleName) + // Marshal v15 committee genesis state + cdc = app.MakeCodec() + v0_15AppState[v0_15committee.ModuleName] = cdc.MustMarshalJSON(Committee(committeeGS)) + } + + return v0_15AppState +} + +// Committee migrates from a v0.14 committee genesis state to a v0.15 committee genesis state +func Committee(genesisState v0_14committee.GenesisState) v0_15committee.GenesisState { + + committees := []v0_15committee.Committee{} + votes := []v0_15committee.Vote{} + proposals := []v0_15committee.Proposal{} + + for _, com := range genesisState.Committees { + if com.ID == 1 { + // Initialize member committee without permissions + stabilityCom := types.NewMemberCommittee(com.ID, com.Description, com.Members, + []v0_15committee.Permission{}, com.VoteThreshold, com.ProposalDuration, + v0_15committee.FirstPastThePost) + + // Build stability committee permissions + var newStabilityCommitteePermissions []v0_15committee.Permission + var newStabilitySubParamPermissions v0_15committee.SubParamChangePermission + for _, perm := range com.Permissions { + subPerm, ok := perm.(v0_14committee.SubParamChangePermission) + if ok { + // update AllowedParams + var newAllowedParams v0_15committee.AllowedParams + for _, ap := range subPerm.AllowedParams { + newAP := v0_15committee.AllowedParam(ap) + newAllowedParams = append(newAllowedParams, newAP) + } + newStabilitySubParamPermissions.AllowedParams = newAllowedParams + + // update AllowedCollateralParams + var newCollateralParams v0_15committee.AllowedCollateralParams + collateralTypes := []string{"bnb-a", "busd-a", "busd-b", "btcb-a", "xrpb-a", "ukava-a", "hard-a", "hbtc-a"} + for _, cp := range subPerm.AllowedCollateralParams { + newCP := v0_15committee.NewAllowedCollateralParam( + cp.Type, + cp.Denom, + cp.LiquidationRatio, + cp.DebtLimit, + cp.StabilityFee, + cp.AuctionSize, + cp.LiquidationPenalty, + cp.Prefix, + cp.SpotMarketID, + cp.LiquidationMarketID, + cp.ConversionFactor, + true, + true, + ) + newCollateralParams = append(newCollateralParams, newCP) + } + for _, cType := range collateralTypes { + var foundCtype bool + for _, cp := range newCollateralParams { + if cType == cp.Type { + foundCtype = true + } + } + if !foundCtype { + newCP := v0_15committee.NewAllowedCollateralParam(cType, false, false, true, true, true, false, false, false, false, false, true, true) + newCollateralParams = append(newCollateralParams, newCP) + } + } + newStabilitySubParamPermissions.AllowedCollateralParams = newCollateralParams + + // update AllowedDebtParam + newDP := v0_15committee.AllowedDebtParam{ + Denom: subPerm.AllowedDebtParam.Denom, + ReferenceAsset: subPerm.AllowedDebtParam.ReferenceAsset, + ConversionFactor: subPerm.AllowedDebtParam.ConversionFactor, + DebtFloor: subPerm.AllowedDebtParam.DebtFloor, + } + newStabilitySubParamPermissions.AllowedDebtParam = newDP + + // update AllowedAssetParams + var newAssetParams v0_15committee.AllowedAssetParams + for _, ap := range subPerm.AllowedAssetParams { + newAP := v0_15committee.AllowedAssetParam(ap) + newAssetParams = append(newAssetParams, newAP) + } + newStabilitySubParamPermissions.AllowedAssetParams = newAssetParams + + // Update Allowed Markets + var newMarketParams v0_15committee.AllowedMarkets + for _, mp := range subPerm.AllowedMarkets { + newMP := v0_15committee.AllowedMarket(mp) + newMarketParams = append(newMarketParams, newMP) + } + newStabilitySubParamPermissions.AllowedMarkets = newMarketParams + + // Add hard money market committee permissions + var newMoneyMarketParams v0_15committee.AllowedMoneyMarkets + hardMMDenoms := []string{"bnb", "busd", "btcb", "xrpb", "usdx", "ukava", "hard"} + for _, mmDenom := range hardMMDenoms { + newMoneyMarketParam := v0_15committee.NewAllowedMoneyMarket(mmDenom, true, false, false, true, true, true) + newMoneyMarketParams = append(newMoneyMarketParams, newMoneyMarketParam) + } + newStabilitySubParamPermissions.AllowedMoneyMarkets = newMoneyMarketParams + newStabilityCommitteePermissions = append(newStabilityCommitteePermissions, newStabilitySubParamPermissions) + } + } + newStabilityCommitteePermissions = append(newStabilityCommitteePermissions, v0_15committee.TextPermission{}) + + // Set stability committee permissions + baseStabilityCom := stabilityCom.SetPermissions(newStabilityCommitteePermissions) + newStabilityCom := v0_15committee.MemberCommittee{BaseCommittee: baseStabilityCom} + committees = append(committees, newStabilityCom) + } else { + safetyCom := types.NewMemberCommittee(com.ID, com.Description, com.Members, + []v0_15committee.Permission{v0_15committee.SoftwareUpgradePermission{}}, + com.VoteThreshold, com.ProposalDuration, v0_15committee.FirstPastThePost) + committees = append(committees, safetyCom) + } + } + + for _, v := range genesisState.Votes { + newVote := v0_15committee.NewVote(v.ProposalID, v.Voter, types.Yes) + votes = append(votes, v0_15committee.Vote(newVote)) + } + + for _, p := range genesisState.Proposals { + newPubProp := v0_15committee.PubProposal(p.PubProposal) + newProp := v0_15committee.NewProposal(newPubProp, p.ID, p.CommitteeID, p.Deadline) + proposals = append(proposals, newProp) + } + return v0_15committee.NewGenesisState( + genesisState.NextProposalID, committees, proposals, votes) +} diff --git a/migrate/v0_15/migrate_test.go b/migrate/v0_15/migrate_test.go new file mode 100644 index 00000000..55798c2b --- /dev/null +++ b/migrate/v0_15/migrate_test.go @@ -0,0 +1,56 @@ +package v0_15 + +import ( + "io/ioutil" + "os" + "path/filepath" + "testing" + + "github.com/cosmos/cosmos-sdk/codec" + sdk "github.com/cosmos/cosmos-sdk/types" + + "github.com/kava-labs/kava/app" + v0_14committee "github.com/kava-labs/kava/x/committee/legacy/v0_14" + v0_15committee "github.com/kava-labs/kava/x/committee/types" + + "github.com/stretchr/testify/require" +) + +func TestMain(m *testing.M) { + config := sdk.GetConfig() + app.SetBech32AddressPrefixes(config) + app.SetBip44CoinType(config) + + os.Exit(m.Run()) +} + +func TestCommittee(t *testing.T) { + bz, err := ioutil.ReadFile(filepath.Join("testdata", "kava-7-committee-state.json")) + require.NoError(t, err) + + var oldGenState v0_14committee.GenesisState + cdc := codec.New() + sdk.RegisterCodec(cdc) + v0_14committee.RegisterCodec(cdc) + + require.NotPanics(t, func() { + cdc.MustUnmarshalJSON(bz, &oldGenState) + }) + + newGenState := Committee(oldGenState) + err = newGenState.Validate() + require.NoError(t, err) + + require.Equal(t, len(oldGenState.Committees), len(newGenState.Committees)) + for i := 0; i < len(oldGenState.Committees); i++ { + require.Equal(t, len(oldGenState.Committees[i].Permissions), len(newGenState.Committees[i].GetPermissions())) + } + + oldSPCP := oldGenState.Committees[0].Permissions[0].(v0_14committee.SubParamChangePermission) + newSPCP := newGenState.Committees[0].GetPermissions()[0].(v0_15committee.SubParamChangePermission) + require.Equal(t, len(oldSPCP.AllowedParams), len(newSPCP.AllowedParams)) + require.Equal(t, len(oldSPCP.AllowedAssetParams), len(newSPCP.AllowedAssetParams)) + require.Equal(t, len(oldSPCP.AllowedCollateralParams), len(newSPCP.AllowedCollateralParams)) + require.Equal(t, len(oldSPCP.AllowedMarkets), len(newSPCP.AllowedMarkets)) + require.Equal(t, len(oldSPCP.AllowedMoneyMarkets), len(newSPCP.AllowedMoneyMarkets)) +} diff --git a/migrate/v0_15/testdata/kava-7-committee-state.json b/migrate/v0_15/testdata/kava-7-committee-state.json new file mode 100644 index 00000000..d0a6de80 --- /dev/null +++ b/migrate/v0_15/testdata/kava-7-committee-state.json @@ -0,0 +1,402 @@ +{ + "committees": [ + { + "id": "1", + "description": "Kava Stability Committee", + "members": [ + "kava1gru35up50ql2wxhegr880qy6ynl63ujlv8gum2", + "kava1sc3mh3pkas5e7xd269am4xm5mp6zweyzmhjagj", + "kava1c9ye54e3pzwm3e0zpdlel6pnavrj9qqv6e8r4h", + "kava1m7p6sjqrz6mylz776ct48wj6lpnpcd0z82209d", + "kava1a9pmkzk570egv3sflu3uwdf3gejl7qfy9hghzl" + ], + "permissions": [ + { + "type": "kava/SubParamChangePermission", + "value": { + "allowed_params": [ + { + "subspace": "auction", + "key": "BidDuration" + }, + { + "subspace": "auction", + "key": "IncrementSurplus" + }, + { + "subspace": "auction", + "key": "IncrementDebt" + }, + { + "subspace": "auction", + "key": "IncrementCollateral" + }, + { + "subspace": "bep3", + "key": "AssetParams" + }, + { + "subspace": "cdp", + "key": "GlobalDebtLimit" + }, + { + "subspace": "cdp", + "key": "SurplusThreshold" + }, + { + "subspace": "cdp", + "key": "SurplusLot" + }, + { + "subspace": "cdp", + "key": "DebtThreshold" + }, + { + "subspace": "cdp", + "key": "DebtLot" + }, + { + "subspace": "cdp", + "key": "DistributionFrequency" + }, + { + "subspace": "cdp", + "key": "CollateralParams" + }, + { + "subspace": "cdp", + "key": "DebtParam" + }, + { + "subspace": "incentive", + "key": "Active" + }, + { + "subspace": "kavadist", + "key": "Active" + }, + { + "subspace": "pricefeed", + "key": "Markets" + }, + { + "subspace": "hard", + "key": "MoneyMarkets" + }, + { + "subspace": "hard", + "key": "MinimumBorrowUSDValue" + } + ], + "allowed_collateral_params": [ + { + "type": "bnb-a", + "denom": false, + "liquidation_ratio": false, + "debt_limit": true, + "stability_fee": true, + "auction_size": true, + "liquidation_penalty": false, + "prefix": false, + "spot_market_id": false, + "liquidation_market_id": false, + "conversion_factor": false, + "keeper_reward_percentage": true, + "check_collateralization_index_count": true + }, + { + "type": "busd-a", + "denom": false, + "liquidation_ratio": false, + "debt_limit": true, + "stability_fee": true, + "auction_size": true, + "liquidation_penalty": false, + "prefix": false, + "spot_market_id": false, + "liquidation_market_id": false, + "conversion_factor": false, + "keeper_reward_percentage": true, + "check_collateralization_index_count": true + }, + { + "type": "busd-b", + "denom": false, + "liquidation_ratio": false, + "debt_limit": true, + "stability_fee": true, + "auction_size": true, + "liquidation_penalty": false, + "prefix": false, + "spot_market_id": false, + "liquidation_market_id": false, + "conversion_factor": false, + "keeper_reward_percentage": true, + "check_collateralization_index_count": true + }, + { + "type": "btcb-a", + "denom": false, + "liquidation_ratio": false, + "debt_limit": true, + "stability_fee": true, + "auction_size": true, + "liquidation_penalty": false, + "prefix": false, + "spot_market_id": false, + "liquidation_market_id": false, + "conversion_factor": false, + "keeper_reward_percentage": true, + "check_collateralization_index_count": true + }, + { + "type": "xrpb-a", + "denom": false, + "liquidation_ratio": false, + "debt_limit": true, + "stability_fee": true, + "auction_size": true, + "liquidation_penalty": false, + "prefix": false, + "spot_market_id": false, + "liquidation_market_id": false, + "conversion_factor": false, + "keeper_reward_percentage": true, + "check_collateralization_index_count": true + }, + { + "type": "ukava-a", + "denom": false, + "liquidation_ratio": false, + "debt_limit": true, + "stability_fee": true, + "auction_size": true, + "liquidation_penalty": false, + "prefix": false, + "spot_market_id": false, + "liquidation_market_id": false, + "conversion_factor": false, + "keeper_reward_percentage": true, + "check_collateralization_index_count": true + }, + { + "type": "hard-a", + "denom": false, + "liquidation_ratio": false, + "debt_limit": true, + "stability_fee": true, + "auction_size": true, + "liquidation_penalty": false, + "prefix": false, + "spot_market_id": false, + "liquidation_market_id": false, + "conversion_factor": false, + "keeper_reward_percentage": true, + "check_collateralization_index_count": true + }, + { + "type": "hbtc-a", + "denom": false, + "liquidation_ratio": false, + "debt_limit": true, + "stability_fee": true, + "auction_size": true, + "liquidation_penalty": false, + "prefix": false, + "spot_market_id": false, + "liquidation_market_id": false, + "conversion_factor": false, + "keeper_reward_percentage": true, + "check_collateralization_index_count": true + } + ], + "allowed_debt_param": { + "denom": false, + "reference_asset": false, + "conversion_factor": false, + "debt_floor": true + }, + "allowed_asset_params": [ + { + "denom": "bnb", + "coin_id": false, + "limit": true, + "active": true, + "max_swap_amount": true, + "min_block_lock": true + }, + { + "denom": "busd", + "coin_id": true, + "limit": true, + "active": true, + "max_swap_amount": true, + "min_block_lock": true + }, + { + "denom": "btcb", + "coin_id": false, + "limit": true, + "active": true, + "max_swap_amount": true, + "min_block_lock": true + }, + { + "denom": "xrpb", + "coin_id": false, + "limit": true, + "active": true, + "max_swap_amount": true, + "min_block_lock": true + } + ], + "allowed_markets": [ + { + "market_id": "bnb:usd", + "base_asset": false, + "quote_asset": false, + "oracles": false, + "active": true + }, + { + "market_id": "bnb:usd:30", + "base_asset": false, + "quote_asset": false, + "oracles": false, + "active": true + }, + { + "market_id": "btc:usd", + "base_asset": false, + "quote_asset": false, + "oracles": false, + "active": true + }, + { + "market_id": "btc:usd:30", + "base_asset": false, + "quote_asset": false, + "oracles": false, + "active": true + }, + { + "market_id": "xrp:usd", + "base_asset": false, + "quote_asset": false, + "oracles": false, + "active": true + }, + { + "market_id": "xrp:usd:30", + "base_asset": false, + "quote_asset": false, + "oracles": false, + "active": true + }, + { + "market_id": "busd:usd", + "base_asset": false, + "quote_asset": false, + "oracles": false, + "active": true + }, + { + "market_id": "busd:usd:30", + "base_asset": false, + "quote_asset": false, + "oracles": false, + "active": true + } + ], + "allowed_money_markets": [ + { + "denom": "bnb", + "borrow_limit": true, + "spot_market_id": false, + "conversion_factor": false, + "interest_rate_model": true, + "reserve_factor": true, + "keeper_reward_percentage": true + }, + { + "denom": "busd", + "borrow_limit": true, + "spot_market_id": false, + "conversion_factor": false, + "interest_rate_model": true, + "reserve_factor": true, + "keeper_reward_percentage": true + }, + { + "denom": "btcb", + "borrow_limit": true, + "spot_market_id": false, + "conversion_factor": false, + "interest_rate_model": true, + "reserve_factor": true, + "keeper_reward_percentage": true + }, + { + "denom": "xrpb", + "borrow_limit": true, + "spot_market_id": false, + "conversion_factor": false, + "interest_rate_model": true, + "reserve_factor": true, + "keeper_reward_percentage": true + }, + { + "denom": "usdx", + "borrow_limit": true, + "spot_market_id": false, + "conversion_factor": false, + "interest_rate_model": true, + "reserve_factor": true, + "keeper_reward_percentage": true + }, + { + "denom": "ukava", + "borrow_limit": true, + "spot_market_id": false, + "conversion_factor": false, + "interest_rate_model": true, + "reserve_factor": true, + "keeper_reward_percentage": true + }, + { + "denom": "hard", + "borrow_limit": true, + "spot_market_id": false, + "conversion_factor": false, + "interest_rate_model": true, + "reserve_factor": true, + "keeper_reward_percentage": true + } + ] + } + }, + { + "type": "kava/TextPermission", + "value": {} + } + ], + "vote_threshold": "0.500000000000000000", + "proposal_duration": "604800000000000" + }, + { + "id": "2", + "description": "Kava Safety Committee", + "members": [ + "kava1e0agyg6eug9r62fly9sls77ycjgw8ax6xk73es" + ], + "permissions": [ + { + "type": "kava/SoftwareUpgradePermission", + "value": {} + } + ], + "vote_threshold": "0.500000000000000000", + "proposal_duration": "604800000000000" + } + ] +} diff --git a/x/committee/abci.go b/x/committee/abci.go index bbf7e84b..2b1fa939 100644 --- a/x/committee/abci.go +++ b/x/committee/abci.go @@ -8,7 +8,5 @@ import ( // BeginBlocker runs at the start of every block. func BeginBlocker(ctx sdk.Context, _ abci.RequestBeginBlock, k Keeper) { - // enact proposals ignoring their expiry time - they could have received enough votes last block before expiring this block - k.EnactPassedProposals(ctx) - k.CloseExpiredProposals(ctx) + k.ProcessProposals(ctx) } diff --git a/x/committee/abci_test.go b/x/committee/abci_test.go index 03741069..65878d26 100644 --- a/x/committee/abci_test.go +++ b/x/committee/abci_test.go @@ -17,6 +17,7 @@ import ( "github.com/kava-labs/kava/x/cdp" cdptypes "github.com/kava-labs/kava/x/cdp/types" "github.com/kava-labs/kava/x/committee" + "github.com/kava-labs/kava/x/committee/types" ) type ModuleTestSuite struct { @@ -39,26 +40,28 @@ func (suite *ModuleTestSuite) SetupTest() { func (suite *ModuleTestSuite) TestBeginBlock_ClosesExpired() { suite.app.InitializeFromGenesisStates() - normalCom := committee.Committee{ - ID: 12, - Members: suite.addresses[:2], - Permissions: []committee.Permission{committee.GodPermission{}}, - VoteThreshold: d("0.8"), - ProposalDuration: time.Hour * 24 * 7, + memberCom := committee.MemberCommittee{ + BaseCommittee: committee.BaseCommittee{ + ID: 12, + Members: suite.addresses[:2], + Permissions: []committee.Permission{committee.GodPermission{}}, + VoteThreshold: d("0.8"), + ProposalDuration: time.Hour * 24 * 7, + }, } - suite.keeper.SetCommittee(suite.ctx, normalCom) + suite.keeper.SetCommittee(suite.ctx, memberCom) pprop1 := gov.NewTextProposal("Title 1", "A description of this proposal.") - id1, err := suite.keeper.SubmitProposal(suite.ctx, normalCom.Members[0], normalCom.ID, pprop1) + id1, err := suite.keeper.SubmitProposal(suite.ctx, memberCom.Members[0], memberCom.ID, pprop1) suite.NoError(err) oneHrLaterCtx := suite.ctx.WithBlockTime(suite.ctx.BlockTime().Add(time.Hour)) pprop2 := gov.NewTextProposal("Title 2", "A description of this proposal.") - id2, err := suite.keeper.SubmitProposal(oneHrLaterCtx, normalCom.Members[0], normalCom.ID, pprop2) + id2, err := suite.keeper.SubmitProposal(oneHrLaterCtx, memberCom.Members[0], memberCom.ID, pprop2) suite.NoError(err) // Run BeginBlocker - proposalDurationLaterCtx := suite.ctx.WithBlockTime(suite.ctx.BlockTime().Add(normalCom.ProposalDuration)) + proposalDurationLaterCtx := suite.ctx.WithBlockTime(suite.ctx.BlockTime().Add(memberCom.ProposalDuration)) suite.NotPanics(func() { committee.BeginBlocker(proposalDurationLaterCtx, abci.RequestBeginBlock{}, suite.keeper) }) @@ -74,13 +77,9 @@ func (suite *ModuleTestSuite) TestBeginBlock_EnactsPassed() { suite.app.InitializeFromGenesisStates() // setup committee - normalCom := committee.Committee{ - ID: 12, - Members: suite.addresses[:2], - Permissions: []committee.Permission{committee.GodPermission{}}, - VoteThreshold: d("0.8"), - ProposalDuration: time.Hour * 24 * 7, - } + normalCom := committee.NewMemberCommittee(12, "committee description", suite.addresses[:2], + []committee.Permission{committee.GodPermission{}}, d("0.8"), time.Hour*24*7, types.FirstPastThePost) + suite.keeper.SetCommittee(suite.ctx, normalCom) // setup 2 proposals @@ -109,9 +108,9 @@ func (suite *ModuleTestSuite) TestBeginBlock_EnactsPassed() { suite.NoError(err) // add enough votes to make the first proposal pass, but not the second - suite.NoError(suite.keeper.AddVote(suite.ctx, id1, suite.addresses[0])) - suite.NoError(suite.keeper.AddVote(suite.ctx, id1, suite.addresses[1])) - suite.NoError(suite.keeper.AddVote(suite.ctx, id2, suite.addresses[0])) + suite.NoError(suite.keeper.AddVote(suite.ctx, id1, suite.addresses[0], types.Yes)) + suite.NoError(suite.keeper.AddVote(suite.ctx, id1, suite.addresses[1], types.Yes)) + suite.NoError(suite.keeper.AddVote(suite.ctx, id2, suite.addresses[0], types.Yes)) // Run BeginBlocker suite.NotPanics(func() { @@ -131,16 +130,12 @@ func (suite *ModuleTestSuite) TestBeginBlock_DoesntEnactFailed() { suite.app.InitializeFromGenesisStates() // setup committee - normalCom := committee.Committee{ - ID: 12, - Members: suite.addresses[:1], - Permissions: []committee.Permission{committee.SoftwareUpgradePermission{}}, - VoteThreshold: d("1.0"), - ProposalDuration: time.Hour * 24 * 7, - } + memberCom := committee.NewMemberCommittee(12, "committee description", suite.addresses[:1], + []committee.Permission{committee.SoftwareUpgradePermission{}}, d("1.0"), time.Hour*24*7, types.FirstPastThePost) + firstBlockTime := time.Date(1998, 1, 1, 0, 0, 0, 0, time.UTC) ctx := suite.ctx.WithBlockTime(firstBlockTime) - suite.keeper.SetCommittee(ctx, normalCom) + suite.keeper.SetCommittee(ctx, memberCom) // setup an upgrade proposal pprop1 := upgrade.NewSoftwareUpgradeProposal("Title 1", "A description of this proposal.", @@ -150,11 +145,11 @@ func (suite *ModuleTestSuite) TestBeginBlock_DoesntEnactFailed() { Info: "some information about the upgrade", }, ) - id1, err := suite.keeper.SubmitProposal(ctx, normalCom.Members[0], normalCom.ID, pprop1) + id1, err := suite.keeper.SubmitProposal(ctx, memberCom.Members[0], memberCom.ID, pprop1) suite.NoError(err) // add enough votes to make the proposal pass - suite.NoError(suite.keeper.AddVote(ctx, id1, suite.addresses[0])) + suite.NoError(suite.keeper.AddVote(ctx, id1, suite.addresses[0], types.Yes)) // Run BeginBlocker 10 seconds later (5 seconds after upgrade expires) tenSecLaterCtx := ctx.WithBlockTime(ctx.BlockTime().Add(time.Second * 10)) @@ -180,16 +175,20 @@ func (suite *ModuleTestSuite) TestBeginBlock_EnactsPassedUpgrade() { suite.app.InitializeFromGenesisStates() // setup committee - normalCom := committee.Committee{ - ID: 12, - Members: suite.addresses[:1], - Permissions: []committee.Permission{committee.SoftwareUpgradePermission{}}, - VoteThreshold: d("1.0"), - ProposalDuration: time.Hour * 24 * 7, + memberCom := committee.MemberCommittee{ + BaseCommittee: committee.BaseCommittee{ + ID: 12, + Members: suite.addresses[:1], + Permissions: []committee.Permission{committee.SoftwareUpgradePermission{}}, + VoteThreshold: d("1.0"), + ProposalDuration: time.Hour * 24 * 7, + TallyOption: types.FirstPastThePost, + }, } + firstBlockTime := time.Date(1998, 1, 1, 0, 0, 0, 0, time.UTC) ctx := suite.ctx.WithBlockTime(firstBlockTime) - suite.keeper.SetCommittee(ctx, normalCom) + suite.keeper.SetCommittee(ctx, memberCom) // setup an upgrade proposal pprop1 := upgrade.NewSoftwareUpgradeProposal("Title 1", "A description of this proposal.", @@ -199,11 +198,11 @@ func (suite *ModuleTestSuite) TestBeginBlock_EnactsPassedUpgrade() { Info: "some information about the upgrade", }, ) - id1, err := suite.keeper.SubmitProposal(ctx, normalCom.Members[0], normalCom.ID, pprop1) + id1, err := suite.keeper.SubmitProposal(ctx, memberCom.Members[0], memberCom.ID, pprop1) suite.NoError(err) // add enough votes to make the proposal pass - suite.NoError(suite.keeper.AddVote(ctx, id1, suite.addresses[0])) + suite.NoError(suite.keeper.AddVote(ctx, id1, suite.addresses[0], types.Yes)) // Run BeginBlocker fiveSecLaterCtx := ctx.WithBlockTime(ctx.BlockTime().Add(time.Second * 5)) diff --git a/x/committee/alias.go b/x/committee/alias.go index b476f7cc..d0bf33ca 100644 --- a/x/committee/alias.go +++ b/x/committee/alias.go @@ -14,9 +14,6 @@ const ( AttributeKeyProposalID = types.AttributeKeyProposalID AttributeKeyVoter = types.AttributeKeyVoter AttributeValueCategory = types.AttributeValueCategory - AttributeValueProposalFailed = types.AttributeValueProposalFailed - AttributeValueProposalPassed = types.AttributeValueProposalPassed - AttributeValueProposalTimeout = types.AttributeValueProposalTimeout DefaultNextProposalID = types.DefaultNextProposalID DefaultParamspace = types.DefaultParamspace EventTypeProposalClose = types.EventTypeProposalClose @@ -24,6 +21,7 @@ const ( EventTypeProposalVote = types.EventTypeProposalVote MaxCommitteeDescriptionLength = types.MaxCommitteeDescriptionLength ModuleName = types.ModuleName + No = types.No ProposalTypeCommitteeChange = types.ProposalTypeCommitteeChange ProposalTypeCommitteeDelete = types.ProposalTypeCommitteeDelete QuerierRoute = types.QuerierRoute @@ -40,25 +38,23 @@ const ( StoreKey = types.StoreKey TypeMsgSubmitProposal = types.TypeMsgSubmitProposal TypeMsgVote = types.TypeMsgVote + Yes = types.Yes ) var ( // function aliases NewKeeper = keeper.NewKeeper NewQuerier = keeper.NewQuerier - RegisterInvariants = keeper.RegisterInvariants - ValidCommitteesInvariant = keeper.ValidCommitteesInvariant - ValidProposalsInvariant = keeper.ValidProposalsInvariant - ValidVotesInvariant = keeper.ValidVotesInvariant DefaultGenesisState = types.DefaultGenesisState GetKeyFromID = types.GetKeyFromID GetVoteKey = types.GetVoteKey NewAllowedCollateralParam = types.NewAllowedCollateralParam NewAllowedMoneyMarket = types.NewAllowedMoneyMarket - NewCommittee = types.NewCommittee NewCommitteeChangeProposal = types.NewCommitteeChangeProposal NewCommitteeDeleteProposal = types.NewCommitteeDeleteProposal NewGenesisState = types.NewGenesisState + NewMemberCommittee = types.NewMemberCommittee + NewTokenCommittee = types.NewTokenCommittee NewMsgSubmitProposal = types.NewMsgSubmitProposal NewMsgVote = types.NewMsgVote NewProposal = types.NewProposal @@ -104,12 +100,15 @@ type ( AllowedParam = types.AllowedParam AllowedParams = types.AllowedParams Committee = types.Committee + BaseCommittee = types.BaseCommittee CommitteeChangeProposal = types.CommitteeChangeProposal CommitteeDeleteProposal = types.CommitteeDeleteProposal GenesisState = types.GenesisState GodPermission = types.GodPermission MsgSubmitProposal = types.MsgSubmitProposal + MemberCommittee = types.MemberCommittee MsgVote = types.MsgVote + TokenCommittee = types.TokenCommittee ParamKeeper = types.ParamKeeper Permission = types.Permission Proposal = types.Proposal diff --git a/x/committee/client/cli/query.go b/x/committee/client/cli/query.go index c44170fe..2fee1497 100644 --- a/x/committee/client/cli/query.go +++ b/x/committee/client/cli/query.go @@ -74,7 +74,7 @@ func GetCmdQueryCommittee(queryRoute string, cdc *codec.Codec) *cobra.Command { } // Decode and print result - committee := types.Committee{} + var committee types.Committee if err = cdc.UnmarshalJSON(res, &committee); err != nil { return err } @@ -251,11 +251,11 @@ func GetCmdQueryTally(queryRoute string, cdc *codec.Codec) *cobra.Command { } // Decode and print results - var tally bool - if err = cdc.UnmarshalJSON(res, &tally); err != nil { + var pollingStatus types.ProposalPollingStatus + if err = cdc.UnmarshalJSON(res, &pollingStatus); err != nil { return err } - return cliCtx.PrintOutput(tally) + return cliCtx.PrintOutput(pollingStatus) }, } } diff --git a/x/committee/client/cli/tx.go b/x/committee/client/cli/tx.go index 61dda2bb..45832985 100644 --- a/x/committee/client/cli/tx.go +++ b/x/committee/client/cli/tx.go @@ -5,6 +5,7 @@ import ( "fmt" "io/ioutil" "strconv" + "strings" "time" "github.com/spf13/cobra" @@ -100,11 +101,11 @@ For example: // GetCmdVote returns the command to vote on a proposal. func GetCmdVote(cdc *codec.Codec) *cobra.Command { return &cobra.Command{ - Use: "vote [proposal-id]", - Args: cobra.ExactArgs(1), + Use: "vote [proposal-id] [vote]", + Args: cobra.ExactArgs(2), Short: "Vote for an active proposal", - Long: "Submit a yes vote for the proposal with id [proposal-id].", - Example: fmt.Sprintf("%s tx %s vote 2", version.ClientName, types.ModuleName), + Long: "Submit a [yes/no/abstain] vote for the proposal with id [proposal-id].", + Example: fmt.Sprintf("%s tx %s vote 2 yes", version.ClientName, types.ModuleName), RunE: func(cmd *cobra.Command, args []string) error { inBuf := bufio.NewReader(cmd.InOrStdin()) txBldr := auth.NewTxBuilderFromCLI(inBuf).WithTxEncoder(utils.GetTxEncoder(cdc)) @@ -119,8 +120,25 @@ func GetCmdVote(cdc *codec.Codec) *cobra.Command { return fmt.Errorf("proposal-id %s not a valid int, please input a valid proposal-id", args[0]) } + rawVote := strings.ToLower(strings.TrimSpace(args[1])) + if len(rawVote) == 0 { + return fmt.Errorf("must specify a vote") + } + + var vote types.VoteType + switch rawVote { + case "yes", "y": + vote = types.Yes + case "no", "n": + vote = types.No + case "abstain", "a": + vote = types.Abstain + default: + return fmt.Errorf("must specify a valid vote type: (yes/y, no/n, abstain/a)") + } + // Build vote message and run basic validation - msg := types.NewMsgVote(from, proposalID) + msg := types.NewMsgVote(from, proposalID, vote) err = msg.ValidateBasic() if err != nil { return err @@ -192,7 +210,7 @@ func MustGetExampleCommitteeChangeProposal(cdc *codec.Codec) string { exampleChangeProposal := types.NewCommitteeChangeProposal( "A Title", "A description of this proposal.", - types.NewCommittee( + types.NewMemberCommittee( 1, "The description of this committee.", []sdk.AccAddress{sdk.AccAddress(crypto.AddressHash([]byte("exampleAddress")))}, @@ -203,6 +221,7 @@ func MustGetExampleCommitteeChangeProposal(cdc *codec.Codec) string { }, sdk.MustNewDecFromStr("0.8"), time.Hour*24*7, + types.FirstPastThePost, ), ) exampleChangeProposalBz, err := cdc.MarshalJSONIndent(exampleChangeProposal, "", " ") @@ -228,10 +247,11 @@ func MustGetExampleCommitteeDeleteProposal(cdc *codec.Codec) string { // MustGetExampleParameterChangeProposal is a helper function to return an example json proposal func MustGetExampleParameterChangeProposal(cdc *codec.Codec) string { + value := fmt.Sprintf("\"%d\"", 1000000000) exampleParameterChangeProposal := params.NewParameterChangeProposal( "A Title", "A description of this proposal.", - []params.ParamChange{params.NewParamChange("cdp", "SurplusAuctionThreshold", "1000000000")}, + []params.ParamChange{params.NewParamChange("cdp", "SurplusAuctionThreshold", value)}, ) exampleParameterChangeProposalBz, err := cdc.MarshalJSONIndent(exampleParameterChangeProposal, "", " ") if err != nil { diff --git a/x/committee/client/common/query.go b/x/committee/client/common/query.go index 89de2353..d79df624 100644 --- a/x/committee/client/common/query.go +++ b/x/committee/client/common/query.go @@ -159,6 +159,6 @@ func calculateDeadline(cliCtx context.CLIContext, cdc *codec.Codec, queryRoute s return deadline, err } - deadline = resultBlock.Block.Header.Time.Add(committee.ProposalDuration) + deadline = resultBlock.Block.Header.Time.Add(committee.GetProposalDuration()) return deadline, nil } diff --git a/x/committee/client/rest/rest.go b/x/committee/client/rest/rest.go index 96849d09..a326afd3 100644 --- a/x/committee/client/rest/rest.go +++ b/x/committee/client/rest/rest.go @@ -10,6 +10,7 @@ import ( const ( RestProposalID = "proposal-id" RestCommitteeID = "committee-id" + RestVote = "vote" ) // RegisterRoutes - Central function to define routes that get registered by the main application diff --git a/x/committee/client/rest/tx.go b/x/committee/client/rest/tx.go index 263acf51..96f273d5 100644 --- a/x/committee/client/rest/tx.go +++ b/x/committee/client/rest/tx.go @@ -3,6 +3,7 @@ package rest import ( "fmt" "net/http" + "strings" "github.com/gorilla/mux" @@ -70,6 +71,7 @@ func postProposalHandlerFn(cliCtx context.CLIContext) http.HandlerFunc { type PostVoteReq struct { BaseReq rest.BaseReq `json:"base_req" yaml:"base_req"` Voter sdk.AccAddress `json:"voter" yaml:"voter"` + Vote types.VoteType `json:"vote" yaml:"vote"` } func postVoteHandlerFn(cliCtx context.CLIContext) http.HandlerFunc { @@ -86,6 +88,28 @@ func postVoteHandlerFn(cliCtx context.CLIContext) http.HandlerFunc { return } + if len(vars[RestVote]) == 0 { + rest.WriteErrorResponse(w, http.StatusBadRequest, fmt.Sprintf("%s required but not specified", RestVote)) + return + } + + rawVote := strings.ToLower(strings.TrimSpace(vars[RestVote])) + if len(rawVote) == 0 { + rest.WriteErrorResponse(w, http.StatusBadRequest, fmt.Sprintf("invalid %s: %s", RestVote, rawVote)) + return + } + + var vote types.VoteType + switch rawVote { + case "yes", "y": + vote = types.Yes + case "no", "n": + vote = types.No + default: + rest.WriteErrorResponse(w, http.StatusBadRequest, "must specify a valid vote type (\"yes\", \"y\"/\"no\" \"n\")") + return + } + // Parse and validate http request body var req PostVoteReq if !rest.ReadRESTReq(w, r, cliCtx.Codec, &req) { @@ -97,7 +121,7 @@ func postVoteHandlerFn(cliCtx context.CLIContext) http.HandlerFunc { } // Create and return a StdTx - msg := types.NewMsgVote(req.Voter, proposalID) + msg := types.NewMsgVote(req.Voter, proposalID, vote) if err := msg.ValidateBasic(); err != nil { rest.WriteErrorResponse(w, http.StatusBadRequest, err.Error()) return diff --git a/x/committee/genesis_test.go b/x/committee/genesis_test.go index 5cf8f7de..429a52ff 100644 --- a/x/committee/genesis_test.go +++ b/x/committee/genesis_test.go @@ -2,6 +2,7 @@ package committee_test import ( "testing" + "time" "github.com/stretchr/testify/suite" @@ -17,12 +18,49 @@ import ( type GenesisTestSuite struct { suite.Suite - app app.TestApp - ctx sdk.Context - keeper committee.Keeper + app app.TestApp + ctx sdk.Context + keeper committee.Keeper + addresses []sdk.AccAddress } -func (suite *GenesisTestSuite) TestGenesis() { +func (suite *GenesisTestSuite) SetupTest() { + suite.app = app.NewTestApp() + suite.keeper = suite.app.GetCommitteeKeeper() + suite.ctx = suite.app.NewContext(true, abci.Header{}) + _, suite.addresses = app.GeneratePrivKeyAddressPairs(10) +} + +func (suite *GenesisTestSuite) TestInitGenesis() { + + memberCom := types.MemberCommittee{ + BaseCommittee: types.BaseCommittee{ + ID: 1, + Description: "This member committee is for testing.", + Members: suite.addresses[:2], + Permissions: []types.Permission{types.GodPermission{}}, + VoteThreshold: d("0.667"), + ProposalDuration: time.Hour * 24 * 7, + TallyOption: types.FirstPastThePost, + }, + } + + tokenCom := types.TokenCommittee{ + BaseCommittee: types.BaseCommittee{ + ID: 1, + Description: "This token committee is for testing.", + Members: suite.addresses[:2], + Permissions: []types.Permission{types.GodPermission{}}, + VoteThreshold: d("0.667"), + ProposalDuration: time.Hour * 24 * 7, + TallyOption: types.FirstPastThePost, + }, + Quorum: d("0.4"), + TallyDenom: "hard", + } + + // Most genesis validation tests are located in the types directory. The 'invalid' test cases are + // randomly selected subset of those tests. testCases := []struct { name string genState types.GenesisState @@ -34,7 +72,37 @@ func (suite *GenesisTestSuite) TestGenesis() { expectPass: true, }, { - name: "invalid", + name: "member committee is correctly validated", + genState: types.NewGenesisState( + 1, + []types.Committee{memberCom}, + []types.Proposal{}, + []types.Vote{}, + ), + expectPass: true, + }, + { + name: "token committee is correctly validated", + genState: types.NewGenesisState( + 1, + []types.Committee{tokenCom}, + []types.Proposal{}, + []types.Vote{}, + ), + expectPass: true, + }, + { + name: "invalid: duplicate committee ID", + genState: types.NewGenesisState( + 1, + []types.Committee{memberCom, memberCom}, + []types.Proposal{}, + []types.Vote{}, + ), + expectPass: false, + }, + { + name: "invalid: proposal doesn't have committee", genState: types.NewGenesisState( 2, []types.Committee{}, @@ -43,6 +111,26 @@ func (suite *GenesisTestSuite) TestGenesis() { ), expectPass: false, }, + { + name: "invalid: vote doesn't have proposal", + genState: types.NewGenesisState( + 1, + []types.Committee{}, + []types.Proposal{}, + []types.Vote{{Voter: suite.addresses[0], ProposalID: 1, VoteType: types.Yes}}, + ), + expectPass: false, + }, + { + name: "invalid: next proposal ID isn't greater than proposal ID", + genState: types.NewGenesisState( + 4, + []types.Committee{memberCom}, + []types.Proposal{{ID: 3, CommitteeID: 1}, {ID: 4, CommitteeID: 1}}, + []types.Vote{}, + ), + expectPass: false, + }, } for _, tc := range testCases { suite.Run(tc.name, func() { diff --git a/x/committee/handler.go b/x/committee/handler.go index 011c5040..af22ef30 100644 --- a/x/committee/handler.go +++ b/x/committee/handler.go @@ -45,7 +45,7 @@ func handleMsgSubmitProposal(ctx sdk.Context, k keeper.Keeper, msg types.MsgSubm } func handleMsgVote(ctx sdk.Context, k keeper.Keeper, msg types.MsgVote) (*sdk.Result, error) { - err := k.AddVote(ctx, msg.ProposalID, msg.Voter) + err := k.AddVote(ctx, msg.ProposalID, msg.Voter, msg.VoteType) if err != nil { return nil, err } diff --git a/x/committee/handler_test.go b/x/committee/handler_test.go index 841121ee..1d79abf3 100644 --- a/x/committee/handler_test.go +++ b/x/committee/handler_test.go @@ -49,13 +49,16 @@ func (suite *HandlerTestSuite) SetupTest() { testGenesis := types.NewGenesisState( 3, []types.Committee{ - { - ID: 1, - Description: "This committee is for testing.", - Members: suite.addresses[:3], - Permissions: []types.Permission{types.GodPermission{}}, - VoteThreshold: d("0.5"), - ProposalDuration: time.Hour * 24 * 7, + types.MemberCommittee{ + BaseCommittee: types.BaseCommittee{ + ID: 1, + Description: "This committee is for testing.", + Members: suite.addresses[:3], + Permissions: []types.Permission{types.GodPermission{}}, + VoteThreshold: d("0.5"), + ProposalDuration: time.Hour * 24 * 7, + TallyOption: types.FirstPastThePost, + }, }, }, []types.Proposal{}, diff --git a/x/committee/keeper/committee_test.go b/x/committee/keeper/committee_test.go index 5dcc78d0..496b5a0a 100644 --- a/x/committee/keeper/committee_test.go +++ b/x/committee/keeper/committee_test.go @@ -167,13 +167,14 @@ func (suite *TypesTestSuite) TestCommittee_HasPermissionsFor() { tApp := app.NewTestApp() ctx := tApp.NewContext(true, abci.Header{}) tApp.InitializeFromGenesisStates() - com := types.NewCommittee( + com := types.NewMemberCommittee( 12, "a description of this committee", nil, tc.permissions, d("0.5"), 24*time.Hour, + types.FirstPastThePost, ) suite.Equal( tc.expectHasPermissions, diff --git a/x/committee/keeper/integration_test.go b/x/committee/keeper/integration_test.go index ff0a1cef..b17ea017 100644 --- a/x/committee/keeper/integration_test.go +++ b/x/committee/keeper/integration_test.go @@ -3,6 +3,7 @@ package keeper_test import ( "github.com/cosmos/cosmos-sdk/codec" sdk "github.com/cosmos/cosmos-sdk/types" + authexported "github.com/cosmos/cosmos-sdk/x/auth/exported" "github.com/kava-labs/kava/app" "github.com/kava-labs/kava/x/committee" @@ -28,6 +29,11 @@ func getProposalVoteMap(k keeper.Keeper, ctx sdk.Context) map[uint64]([]types.Vo return proposalVoteMap } +func (suite *KeeperTestSuite) getAccount(addr sdk.AccAddress) authexported.Account { + ak := suite.app.GetAccountKeeper() + return ak.GetAccount(suite.ctx, addr) +} + // NewCommitteeGenesisState marshals a committee genesis state into json for use in initializing test apps. func NewCommitteeGenesisState(cdc *codec.Codec, gs committee.GenesisState) app.GenesisState { return app.GenesisState{committee.ModuleName: cdc.MustMarshalJSON(gs)} diff --git a/x/committee/keeper/invariants.go b/x/committee/keeper/invariants.go deleted file mode 100644 index f8ff6979..00000000 --- a/x/committee/keeper/invariants.go +++ /dev/null @@ -1,140 +0,0 @@ -package keeper - -import ( - "fmt" - "time" - - sdk "github.com/cosmos/cosmos-sdk/types" - - "github.com/kava-labs/kava/x/committee/types" -) - -// RegisterInvariants registers all committee invariants -func RegisterInvariants(ir sdk.InvariantRegistry, k Keeper) { - - ir.RegisterRoute(types.ModuleName, "valid-committees", - ValidCommitteesInvariant(k)) - ir.RegisterRoute(types.ModuleName, "valid-proposals", - ValidProposalsInvariant(k)) - ir.RegisterRoute(types.ModuleName, "valid-votes", - ValidVotesInvariant(k)) -} - -// ValidCommitteesInvariant verifies that all committees in the store are independently valid -func ValidCommitteesInvariant(k Keeper) sdk.Invariant { - return func(ctx sdk.Context) (string, bool) { - - var validationErr error - var invalidCommittee types.Committee - k.IterateCommittees(ctx, func(com types.Committee) bool { - - if err := com.Validate(); err != nil { - validationErr = err - invalidCommittee = com - return true - } - return false - }) - - broken := validationErr != nil - invariantMessage := sdk.FormatInvariant( - types.ModuleName, - "valid committees", - fmt.Sprintf( - "\tfound invalid committee, reason: %s\n"+ - "\tcommittee:\n\t%+v\n", - validationErr, invalidCommittee), - ) - return invariantMessage, broken - } -} - -// ValidProposalsInvariant verifies that all proposals in the store are valid -func ValidProposalsInvariant(k Keeper) sdk.Invariant { - return func(ctx sdk.Context) (string, bool) { - - var validationErr error - var invalidProposal types.Proposal - k.IterateProposals(ctx, func(proposal types.Proposal) bool { - invalidProposal = proposal - - if err := proposal.PubProposal.ValidateBasic(); err != nil { - validationErr = err - return true - } - - currentTime := ctx.BlockTime() - if !currentTime.Equal(time.Time{}) { // this avoids a simulator bug where app.InitGenesis is called with blockTime=0 instead of the correct time - if proposal.Deadline.Before(currentTime) { - validationErr = fmt.Errorf("deadline after current block time %s", currentTime) - return true - } - } - - _, found := k.GetCommittee(ctx, proposal.CommitteeID) - if !found { - validationErr = fmt.Errorf("proposal has no committee %d", proposal.CommitteeID) - return true - } - - return false - }) - - broken := validationErr != nil - invariantMessage := sdk.FormatInvariant( - types.ModuleName, - "valid proposals", - fmt.Sprintf( - "\tfound invalid proposal, reason: %s\n"+ - "\tproposal:\n\t%s\n", - validationErr, invalidProposal), - ) - return invariantMessage, broken - } -} - -// ValidVotesInvariant verifies that all votes in the store are valid -func ValidVotesInvariant(k Keeper) sdk.Invariant { - return func(ctx sdk.Context) (string, bool) { - - var validationErr error - var invalidVote types.Vote - k.IterateVotes(ctx, func(vote types.Vote) bool { - invalidVote = vote - - if err := vote.Validate(); err != nil { - validationErr = err - return true - } - - proposal, found := k.GetProposal(ctx, vote.ProposalID) - if !found { - validationErr = fmt.Errorf("vote has no proposal %d", vote.ProposalID) - return true - } - - com, found := k.GetCommittee(ctx, proposal.CommitteeID) - if !found { - validationErr = fmt.Errorf("vote's proposal has no committee %d", proposal.CommitteeID) - return true - } - if !com.HasMember(vote.Voter) { - validationErr = fmt.Errorf("voter is not a member of committee %+v", com) - return true - } - - return false - }) - - broken := validationErr != nil - invariantMessage := sdk.FormatInvariant( - types.ModuleName, - "valid votes", - fmt.Sprintf( - "\tfound invalid vote, reason: %s\n"+ - "\tvote:\n\t%+v\n", - validationErr, invalidVote), - ) - return invariantMessage, broken - } -} diff --git a/x/committee/keeper/keeper.go b/x/committee/keeper/keeper.go index d66356c9..785525a5 100644 --- a/x/committee/keeper/keeper.go +++ b/x/committee/keeper/keeper.go @@ -16,22 +16,27 @@ type Keeper struct { cdc *codec.Codec storeKey sdk.StoreKey - ParamKeeper types.ParamKeeper // TODO ideally don't export, only sims need it exported + ParamKeeper types.ParamKeeper // TODO ideally don't export, only sims need it exported + accountKeeper types.AccountKeeper + supplyKeeper types.SupplyKeeper // Proposal router router govtypes.Router } -func NewKeeper(cdc *codec.Codec, storeKey sdk.StoreKey, router govtypes.Router, paramKeeper types.ParamKeeper) Keeper { +func NewKeeper(cdc *codec.Codec, storeKey sdk.StoreKey, router govtypes.Router, + paramKeeper types.ParamKeeper, ak types.AccountKeeper, sk types.SupplyKeeper) Keeper { // Logic in the keeper methods assume the set of gov handlers is fixed. // So the gov router must be sealed so no handlers can be added or removed after the keeper is created. router.Seal() return Keeper{ - cdc: cdc, - storeKey: storeKey, - ParamKeeper: paramKeeper, - router: router, + cdc: cdc, + storeKey: storeKey, + ParamKeeper: paramKeeper, + accountKeeper: ak, + supplyKeeper: sk, + router: router, } } @@ -41,12 +46,12 @@ func NewKeeper(cdc *codec.Codec, storeKey sdk.StoreKey, router govtypes.Router, // GetCommittee gets a committee from the store. func (k Keeper) GetCommittee(ctx sdk.Context, committeeID uint64) (types.Committee, bool) { + var committee types.Committee store := prefix.NewStore(ctx.KVStore(k.storeKey), types.CommitteeKeyPrefix) bz := store.Get(types.GetKeyFromID(committeeID)) if bz == nil { - return types.Committee{}, false + return committee, false } - var committee types.Committee k.cdc.MustUnmarshalBinaryBare(bz, &committee) return committee, true } @@ -55,7 +60,7 @@ func (k Keeper) GetCommittee(ctx sdk.Context, committeeID uint64) (types.Committ func (k Keeper) SetCommittee(ctx sdk.Context, committee types.Committee) { store := prefix.NewStore(ctx.KVStore(k.storeKey), types.CommitteeKeyPrefix) bz := k.cdc.MustMarshalBinaryBare(committee) - store.Set(types.GetKeyFromID(committee.ID), bz) + store.Set(types.GetKeyFromID(committee.GetID()), bz) } // DeleteCommittee removes a committee from the store. diff --git a/x/committee/keeper/keeper_test.go b/x/committee/keeper/keeper_test.go index 1d712e1c..dbb91797 100644 --- a/x/committee/keeper/keeper_test.go +++ b/x/committee/keeper/keeper_test.go @@ -8,6 +8,7 @@ import ( sdk "github.com/cosmos/cosmos-sdk/types" "github.com/cosmos/cosmos-sdk/x/gov" + "github.com/cosmos/cosmos-sdk/x/supply" abci "github.com/tendermint/tendermint/abci/types" @@ -19,9 +20,10 @@ import ( type KeeperTestSuite struct { suite.Suite - keeper keeper.Keeper - app app.TestApp - ctx sdk.Context + keeper keeper.Keeper + supplyKeeper supply.Keeper + app app.TestApp + ctx sdk.Context addresses []sdk.AccAddress } @@ -29,19 +31,23 @@ type KeeperTestSuite struct { func (suite *KeeperTestSuite) SetupTest() { suite.app = app.NewTestApp() suite.keeper = suite.app.GetCommitteeKeeper() + suite.supplyKeeper = suite.app.GetSupplyKeeper() suite.ctx = suite.app.NewContext(true, abci.Header{}) - _, suite.addresses = app.GeneratePrivKeyAddressPairs(5) + _, suite.addresses = app.GeneratePrivKeyAddressPairs(10) } func (suite *KeeperTestSuite) TestGetSetDeleteCommittee() { // setup test - com := types.Committee{ - ID: 12, - Description: "This committee is for testing.", - Members: suite.addresses, - Permissions: []types.Permission{types.GodPermission{}}, - VoteThreshold: d("0.667"), - ProposalDuration: time.Hour * 24 * 7, + com := types.MemberCommittee{ + BaseCommittee: types.BaseCommittee{ + ID: 12, + Description: "This committee is for testing.", + Members: suite.addresses, + Permissions: []types.Permission{types.GodPermission{}}, + VoteThreshold: d("0.667"), + ProposalDuration: time.Hour * 24 * 7, + TallyOption: types.FirstPastThePost, + }, } // write and read from store diff --git a/x/committee/keeper/proposal.go b/x/committee/keeper/proposal.go index 204fbd76..a983e3e3 100644 --- a/x/committee/keeper/proposal.go +++ b/x/committee/keeper/proposal.go @@ -31,7 +31,7 @@ func (k Keeper) SubmitProposal(ctx sdk.Context, proposer sdk.AccAddress, committ } // Get a new ID and store the proposal - deadline := ctx.BlockTime().Add(com.ProposalDuration) + deadline := ctx.BlockTime().Add(com.GetProposalDuration()) proposalID, err := k.StoreNewProposal(ctx, pubProposal, committeeID, deadline) if err != nil { return 0, err @@ -40,15 +40,16 @@ func (k Keeper) SubmitProposal(ctx sdk.Context, proposer sdk.AccAddress, committ ctx.EventManager().EmitEvent( sdk.NewEvent( types.EventTypeProposalSubmit, - sdk.NewAttribute(types.AttributeKeyCommitteeID, fmt.Sprintf("%d", com.ID)), + sdk.NewAttribute(types.AttributeKeyCommitteeID, fmt.Sprintf("%d", com.GetID())), sdk.NewAttribute(types.AttributeKeyProposalID, fmt.Sprintf("%d", proposalID)), + sdk.NewAttribute(types.AttributeKeyDeadline, deadline.String()), ), ) return proposalID, nil } // AddVote submits a vote on a proposal. -func (k Keeper) AddVote(ctx sdk.Context, proposalID uint64, voter sdk.AccAddress) error { +func (k Keeper) AddVote(ctx sdk.Context, proposalID uint64, voter sdk.AccAddress, voteType types.VoteType) error { // Validate pr, found := k.GetProposal(ctx, proposalID) if !found { @@ -62,129 +63,31 @@ func (k Keeper) AddVote(ctx sdk.Context, proposalID uint64, voter sdk.AccAddress if !found { return sdkerrors.Wrapf(types.ErrUnknownCommittee, "%d", pr.CommitteeID) } - if !com.HasMember(voter) { - return sdkerrors.Wrap(sdkerrors.ErrUnauthorized, "voter must be a member of committee") + + if _, ok := com.(types.MemberCommittee); ok { + if !com.HasMember(voter) { + return sdkerrors.Wrap(sdkerrors.ErrUnauthorized, "voter must be a member of committee") + } + if voteType != types.Yes { + return sdkerrors.Wrap(types.ErrInvalidVoteType, "member committees only accept yes votes") + } } // Store vote, overwriting any prior vote - k.SetVote(ctx, types.NewVote(proposalID, voter)) + k.SetVote(ctx, types.NewVote(proposalID, voter, voteType)) ctx.EventManager().EmitEvent( sdk.NewEvent( types.EventTypeProposalVote, - sdk.NewAttribute(types.AttributeKeyCommitteeID, fmt.Sprintf("%d", com.ID)), + sdk.NewAttribute(types.AttributeKeyCommitteeID, fmt.Sprintf("%d", com.GetID())), sdk.NewAttribute(types.AttributeKeyProposalID, fmt.Sprintf("%d", pr.ID)), sdk.NewAttribute(types.AttributeKeyVoter, voter.String()), + sdk.NewAttribute(types.AttributeKeyVote, fmt.Sprintf("%d", voteType)), ), ) return nil } -// GetProposalResult calculates if a proposal currently has enough votes to pass. -func (k Keeper) GetProposalResult(ctx sdk.Context, proposalID uint64) (bool, error) { - pr, found := k.GetProposal(ctx, proposalID) - if !found { - return false, sdkerrors.Wrapf(types.ErrUnknownProposal, "%d", proposalID) - } - com, found := k.GetCommittee(ctx, pr.CommitteeID) - if !found { - return false, sdkerrors.Wrapf(types.ErrUnknownCommittee, "%d", pr.CommitteeID) - } - - numVotes := k.TallyVotes(ctx, proposalID) - - proposalResult := sdk.NewDec(numVotes).GTE(com.VoteThreshold.MulInt64(int64(len(com.Members)))) - - return proposalResult, nil -} - -// TallyVotes counts all the votes on a proposal -func (k Keeper) TallyVotes(ctx sdk.Context, proposalID uint64) int64 { - - votes := k.GetVotesByProposal(ctx, proposalID) - - return int64(len(votes)) -} - -// EnactProposal makes the changes proposed in a proposal. -func (k Keeper) EnactProposal(ctx sdk.Context, proposal types.Proposal) error { - // Check committee still has permissions for the proposal - // Since the proposal was submitted params could have changed, invalidating the permission of the committee. - com, found := k.GetCommittee(ctx, proposal.CommitteeID) - if !found { - return sdkerrors.Wrapf(types.ErrUnknownCommittee, "%d", proposal.CommitteeID) - } - if !com.HasPermissionsFor(ctx, k.cdc, k.ParamKeeper, proposal.PubProposal) { - return sdkerrors.Wrap(sdkerrors.ErrUnauthorized, "committee does not have permissions to enact proposal") - } - - if err := k.ValidatePubProposal(ctx, proposal.PubProposal); err != nil { - return err - } - - // enact the proposal - handler := k.router.GetRoute(proposal.ProposalRoute()) - if err := handler(ctx, proposal.PubProposal); err != nil { - // the handler should not error as it was checked in ValidatePubProposal - panic(fmt.Sprintf("unexpected handler error: %s", err)) - } - return nil -} - -// EnactPassedProposals puts in place the changes proposed in any proposal that has enough votes -func (k Keeper) EnactPassedProposals(ctx sdk.Context) { - k.IterateProposals(ctx, func(proposal types.Proposal) bool { - passes, err := k.GetProposalResult(ctx, proposal.ID) - if err != nil { - panic(err) - } - - if !passes { - // continue to next proposal - return false - } - - err = k.EnactProposal(ctx, proposal) - outcome := types.AttributeValueProposalPassed - if err != nil { - outcome = types.AttributeValueProposalFailed - } - - k.DeleteProposalAndVotes(ctx, proposal.ID) - - ctx.EventManager().EmitEvent( - sdk.NewEvent( - types.EventTypeProposalClose, - sdk.NewAttribute(types.AttributeKeyCommitteeID, fmt.Sprintf("%d", proposal.CommitteeID)), - sdk.NewAttribute(types.AttributeKeyProposalID, fmt.Sprintf("%d", proposal.ID)), - sdk.NewAttribute(types.AttributeKeyProposalCloseStatus, outcome), - ), - ) - return false - }) -} - -// CloseExpiredProposals removes proposals (and associated votes) that have past their deadline. -func (k Keeper) CloseExpiredProposals(ctx sdk.Context) { - k.IterateProposals(ctx, func(proposal types.Proposal) bool { - if !proposal.HasExpiredBy(ctx.BlockTime()) { - return false - } - - k.DeleteProposalAndVotes(ctx, proposal.ID) - - ctx.EventManager().EmitEvent( - sdk.NewEvent( - types.EventTypeProposalClose, - sdk.NewAttribute(types.AttributeKeyCommitteeID, fmt.Sprintf("%d", proposal.CommitteeID)), - sdk.NewAttribute(types.AttributeKeyProposalID, fmt.Sprintf("%d", proposal.ID)), - sdk.NewAttribute(types.AttributeKeyProposalCloseStatus, types.AttributeValueProposalTimeout), - ), - ) - return false - }) -} - // ValidatePubProposal checks if a pubproposal is valid. func (k Keeper) ValidatePubProposal(ctx sdk.Context, pubProposal types.PubProposal) (returnErr error) { if pubProposal == nil { @@ -217,3 +120,145 @@ func (k Keeper) ValidatePubProposal(ctx sdk.Context, pubProposal types.PubPropos } return nil } + +func (k Keeper) ProcessProposals(ctx sdk.Context) { + + k.IterateProposals(ctx, func(proposal types.Proposal) bool { + + committee, found := k.GetCommittee(ctx, proposal.CommitteeID) + if !found { + k.CloseProposal(ctx, proposal, types.Failed) + return false + } + + if !proposal.HasExpiredBy(ctx.BlockTime()) { + if committee.GetTallyOption() == types.FirstPastThePost { + passed := k.GetProposalResult(ctx, proposal.ID, committee) + if passed { + outcome := k.attemptEnactProposal(ctx, proposal) + k.CloseProposal(ctx, proposal, outcome) + } + } + } else { + passed := k.GetProposalResult(ctx, proposal.ID, committee) + outcome := types.Failed + if passed { + outcome = k.attemptEnactProposal(ctx, proposal) + } + k.CloseProposal(ctx, proposal, outcome) + } + return false + }) +} + +func (k Keeper) GetProposalResult(ctx sdk.Context, proposalID uint64, committee types.Committee) bool { + switch com := committee.(type) { + case types.MemberCommittee: + return k.GetMemberCommitteeProposalResult(ctx, proposalID, com) + case types.TokenCommittee: + return k.GetTokenCommitteeProposalResult(ctx, proposalID, com) + default: // Should never hit default case + return false + } +} + +// GetMemberCommitteeProposalResult gets the result of a member committee proposal +func (k Keeper) GetMemberCommitteeProposalResult(ctx sdk.Context, proposalID uint64, committee types.Committee) bool { + currVotes := k.TallyMemberCommitteeVotes(ctx, proposalID) + possibleVotes := sdk.NewDec(int64(len(committee.GetMembers()))) + return currVotes.GTE(committee.GetVoteThreshold().Mul(possibleVotes)) // vote threshold requirements +} + +// TallyMemberCommitteeVotes returns the polling status of a member committee vote +func (k Keeper) TallyMemberCommitteeVotes(ctx sdk.Context, proposalID uint64) (totalVotes sdk.Dec) { + votes := k.GetVotesByProposal(ctx, proposalID) + return sdk.NewDec(int64(len(votes))) +} + +// GetTokenCommitteeProposalResult gets the result of a token committee proposal +func (k Keeper) GetTokenCommitteeProposalResult(ctx sdk.Context, proposalID uint64, committee types.TokenCommittee) bool { + yesVotes, noVotes, totalVotes, possibleVotes := k.TallyTokenCommitteeVotes(ctx, proposalID, committee.TallyDenom) + if totalVotes.GTE(committee.Quorum.Mul(possibleVotes)) { // quorum requirement + nonAbstainVotes := yesVotes.Add(noVotes) + if yesVotes.GTE(nonAbstainVotes.Mul(committee.VoteThreshold)) { // vote threshold requirements + return true + } + } + return false +} + +// TallyMemberCommitteeVotes returns the polling status of a token committee vote. Returns yes votes, +// total current votes, total possible votes (equal to token supply), vote threshold (yes vote ratio +// required for proposal to pass), and quorum (votes tallied at this percentage). +func (k Keeper) TallyTokenCommitteeVotes(ctx sdk.Context, proposalID uint64, + tallyDenom string) (yesVotes, noVotes, totalVotes, possibleVotes sdk.Dec) { + votes := k.GetVotesByProposal(ctx, proposalID) + + yesVotes = sdk.ZeroDec() + noVotes = sdk.ZeroDec() + totalVotes = sdk.ZeroDec() + for _, vote := range votes { + // 1 token = 1 vote + acc := k.accountKeeper.GetAccount(ctx, vote.Voter) + accNumCoins := acc.GetCoins().AmountOf(tallyDenom) + + // Add votes to counters + totalVotes = totalVotes.Add(accNumCoins.ToDec()) + if vote.VoteType == types.Yes { + yesVotes = yesVotes.Add(accNumCoins.ToDec()) + } else if vote.VoteType == types.No { + noVotes = noVotes.Add(accNumCoins.ToDec()) + } + } + + possibleVotesInt := k.supplyKeeper.GetSupply(ctx).GetTotal().AmountOf(tallyDenom) + return yesVotes, noVotes, totalVotes, possibleVotesInt.ToDec() +} + +func (k Keeper) attemptEnactProposal(ctx sdk.Context, proposal types.Proposal) types.ProposalOutcome { + err := k.enactProposal(ctx, proposal) + if err != nil { + return types.Invalid + } + return types.Passed +} + +// enactProposal makes the changes proposed in a proposal. +func (k Keeper) enactProposal(ctx sdk.Context, proposal types.Proposal) error { + // Check committee still has permissions for the proposal + // Since the proposal was submitted params could have changed, invalidating the permission of the committee. + com, found := k.GetCommittee(ctx, proposal.CommitteeID) + if !found { + return sdkerrors.Wrapf(types.ErrUnknownCommittee, "%d", proposal.CommitteeID) + } + if !com.HasPermissionsFor(ctx, k.cdc, k.ParamKeeper, proposal.PubProposal) { + return sdkerrors.Wrap(sdkerrors.ErrUnauthorized, "committee does not have permissions to enact proposal") + } + + if err := k.ValidatePubProposal(ctx, proposal.PubProposal); err != nil { + return err + } + + // enact the proposal + handler := k.router.GetRoute(proposal.ProposalRoute()) + if err := handler(ctx, proposal.PubProposal); err != nil { + // the handler should not error as it was checked in ValidatePubProposal + panic(fmt.Sprintf("unexpected handler error: %s", err)) + } + return nil +} + +// CloseProposal deletes proposals and their votes, emitting an event denoting the final status of the proposal +func (k Keeper) CloseProposal(ctx sdk.Context, proposal types.Proposal, outcome types.ProposalOutcome) { + + k.DeleteProposalAndVotes(ctx, proposal.ID) + + ctx.EventManager().EmitEvent( + sdk.NewEvent( + types.EventTypeProposalClose, + sdk.NewAttribute(types.AttributeKeyCommitteeID, fmt.Sprintf("%d", proposal.CommitteeID)), + sdk.NewAttribute(types.AttributeKeyProposalID, fmt.Sprintf("%d", proposal.ID)), + sdk.NewAttribute(types.AttributeKeyProposalOutcome, outcome.String()), + ), + ) +} diff --git a/x/committee/keeper/proposal_test.go b/x/committee/keeper/proposal_test.go index fcf1d51d..f0c014f9 100644 --- a/x/committee/keeper/proposal_test.go +++ b/x/committee/keeper/proposal_test.go @@ -1,14 +1,16 @@ package keeper_test import ( - "reflect" + "bytes" "time" "github.com/cosmos/cosmos-sdk/codec" sdk "github.com/cosmos/cosmos-sdk/types" "github.com/cosmos/cosmos-sdk/x/gov" "github.com/cosmos/cosmos-sdk/x/params" + "github.com/cosmos/cosmos-sdk/x/supply" + amino "github.com/tendermint/go-amino" abci "github.com/tendermint/tendermint/abci/types" "github.com/kava-labs/kava/app" @@ -56,13 +58,15 @@ func newPricefeedGenState(assets []string, prices []sdk.Dec) app.GenesisState { } func (suite *KeeperTestSuite) TestSubmitProposal() { - normalCom := types.Committee{ - ID: 12, + defaultCommitteeID := uint64(12) + normalCom := types.BaseCommittee{ + ID: defaultCommitteeID, Description: "This committee is for testing.", Members: suite.addresses[:2], Permissions: []types.Permission{types.GodPermission{}}, VoteThreshold: d("0.667"), ProposalDuration: time.Hour * 24 * 7, + TallyOption: types.FirstPastThePost, } noPermissionsCom := normalCom @@ -112,7 +116,7 @@ func (suite *KeeperTestSuite) TestSubmitProposal() { testcases := []struct { name string - committee types.Committee + committee types.BaseCommittee pubProposal types.PubProposal proposer sdk.AccAddress committeeID uint64 @@ -180,10 +184,14 @@ func (suite *KeeperTestSuite) TestSubmitProposal() { "A Title", "A description of this proposal.", []params.ParamChange{ { - "cdp", string(cdptypes.KeyDebtThreshold), string(suite.app.Codec().MustMarshalJSON(i(1000000000))), + Subspace: "cdp", + Key: string(cdptypes.KeyDebtThreshold), + Value: string(suite.app.Codec().MustMarshalJSON(i(1000000000))), }, { - "cdp", string(cdptypes.KeyCollateralParams), string(suite.app.Codec().MustMarshalJSON(newValidCP)), + Subspace: "cdp", + Key: string(cdptypes.KeyCollateralParams), + Value: string(suite.app.Codec().MustMarshalJSON(newValidCP)), }, }, ), @@ -198,10 +206,14 @@ func (suite *KeeperTestSuite) TestSubmitProposal() { "A Title", "A description of this proposal.", []params.ParamChange{ { - "cdp", string(cdptypes.KeyDebtThreshold), string(suite.app.Codec().MustMarshalJSON(i(1000000000))), + Subspace: "cdp", + Key: string(cdptypes.KeyDebtThreshold), + Value: string(suite.app.Codec().MustMarshalJSON(i(1000000000))), }, { - "cdp", string(cdptypes.KeyCollateralParams), string(suite.app.Codec().MustMarshalJSON(newInvalidCP)), + Subspace: "cdp", + Key: string(cdptypes.KeyCollateralParams), + Value: string(suite.app.Codec().MustMarshalJSON(newInvalidCP)), }, }, ), @@ -221,9 +233,9 @@ func (suite *KeeperTestSuite) TestSubmitProposal() { newPricefeedGenState([]string{"bnb"}, []sdk.Dec{d("15.01")}), newCDPGenesisState(testCDPParams), ) - // setup committee (if required) - if !(reflect.DeepEqual(tc.committee, types.Committee{})) { - keeper.SetCommittee(ctx, tc.committee) + // Cast BaseCommittee to MemberCommittee (if required) to meet Committee interface requirement + if tc.committee.ID == defaultCommitteeID { + keeper.SetCommittee(ctx, types.MemberCommittee{BaseCommittee: tc.committee}) } id, err := keeper.SubmitProposal(ctx, tc.proposer, tc.committeeID, tc.pubProposal) @@ -235,50 +247,88 @@ func (suite *KeeperTestSuite) TestSubmitProposal() { pr, found := keeper.GetProposal(ctx, id) suite.True(found) suite.Equal(tc.committeeID, pr.CommitteeID) - suite.Equal(ctx.BlockTime().Add(tc.committee.ProposalDuration), pr.Deadline) + suite.Equal(ctx.BlockTime().Add(tc.committee.GetProposalDuration()), pr.Deadline) } }) } } func (suite *KeeperTestSuite) TestAddVote() { - normalCom := types.Committee{ - ID: 12, - Members: suite.addresses[:2], - Permissions: []types.Permission{types.GodPermission{}}, + memberCom := types.MemberCommittee{ + BaseCommittee: types.BaseCommittee{ + ID: 12, + Members: suite.addresses[:2], + Permissions: []types.Permission{types.GodPermission{}}, + }, } + tokenCom := types.TokenCommittee{ + BaseCommittee: types.BaseCommittee{ + ID: 12, + Members: suite.addresses[:2], + Permissions: []types.Permission{types.GodPermission{}}, + }, + Quorum: d("0.4"), + TallyDenom: "hard", + } + nonMemberAddr := suite.addresses[4] firstBlockTime := time.Date(1998, time.January, 1, 1, 0, 0, 0, time.UTC) testcases := []struct { name string proposalID uint64 + committee types.Committee voter sdk.AccAddress + voteType types.VoteType voteTime time.Time expectErr bool }{ { - name: "normal", + name: "normal MemberCommittee", + committee: memberCom, proposalID: types.DefaultNextProposalID, - voter: normalCom.Members[0], + voter: memberCom.Members[0], + voteType: types.Yes, + expectErr: false, + }, + { + name: "normal TokenCommittee", + committee: tokenCom, + proposalID: types.DefaultNextProposalID, + voter: nonMemberAddr, + voteType: types.Yes, expectErr: false, }, { name: "nonexistent proposal", + committee: memberCom, proposalID: 9999999, - voter: normalCom.Members[0], - expectErr: true, - }, - { - name: "voter not committee member", - proposalID: types.DefaultNextProposalID, - voter: suite.addresses[4], + voter: memberCom.Members[0], + voteType: types.Yes, expectErr: true, }, { name: "proposal expired", + committee: memberCom, proposalID: types.DefaultNextProposalID, - voter: normalCom.Members[0], - voteTime: firstBlockTime.Add(normalCom.ProposalDuration), + voter: memberCom.Members[0], + voteTime: firstBlockTime.Add(memberCom.ProposalDuration), + voteType: types.Yes, + expectErr: true, + }, + { + name: "MemberCommittee: voter not committee member", + committee: memberCom, + proposalID: types.DefaultNextProposalID, + voter: nonMemberAddr, + voteType: types.Yes, + expectErr: true, + }, + { + name: "MemberCommittee: voter votes no", + committee: memberCom, + proposalID: types.DefaultNextProposalID, + voter: memberCom.Members[0], + voteType: types.No, expectErr: true, }, } @@ -292,12 +342,12 @@ func (suite *KeeperTestSuite) TestAddVote() { tApp.InitializeFromGenesisStates() // setup the committee and proposal - keeper.SetCommittee(ctx, normalCom) - _, err := keeper.SubmitProposal(ctx, normalCom.Members[0], normalCom.ID, gov.NewTextProposal("A Title", "A description of this proposal.")) + keeper.SetCommittee(ctx, tc.committee) + _, err := keeper.SubmitProposal(ctx, tc.committee.GetMembers()[0], tc.committee.GetID(), gov.NewTextProposal("A Title", "A description of this proposal.")) suite.NoError(err) ctx = ctx.WithBlockTime(tc.voteTime) - err = keeper.AddVote(ctx, tc.proposalID, tc.voter) + err = keeper.AddVote(ctx, tc.proposalID, tc.voter, tc.voteType) if tc.expectErr { suite.NotNil(err) @@ -310,14 +360,238 @@ func (suite *KeeperTestSuite) TestAddVote() { } } -func (suite *KeeperTestSuite) TestGetProposalResult() { - normalCom := types.Committee{ - ID: 12, - Description: "This committee is for testing.", - Members: suite.addresses[:5], - Permissions: []types.Permission{types.GodPermission{}}, - VoteThreshold: d("0.667"), - ProposalDuration: time.Hour * 24 * 7, +func (suite *KeeperTestSuite) TestTallyMemberCommitteeVotes() { + memberCom := types.MemberCommittee{ + BaseCommittee: types.BaseCommittee{ + ID: 12, + Description: "This committee is for testing.", + Members: suite.addresses[:5], + Permissions: []types.Permission{types.GodPermission{}}, + VoteThreshold: d("0.667"), + ProposalDuration: time.Hour * 24 * 7, + TallyOption: types.Deadline, + }, + } + var defaultProposalID uint64 = 1 + firstBlockTime := time.Date(1998, time.January, 1, 1, 0, 0, 0, time.UTC) + + testcases := []struct { + name string + votes []types.Vote + expectedVoteCount sdk.Dec + }{ + { + name: "has 0 votes", + votes: []types.Vote{}, + expectedVoteCount: d("0"), + }, + { + name: "has 1 vote", + votes: []types.Vote{ + {ProposalID: defaultProposalID, Voter: suite.addresses[0], VoteType: types.Yes}, + }, + expectedVoteCount: d("1"), + }, + { + name: "has multiple votes", + votes: []types.Vote{ + {ProposalID: defaultProposalID, Voter: suite.addresses[0], VoteType: types.Yes}, + {ProposalID: defaultProposalID, Voter: suite.addresses[1], VoteType: types.Yes}, + {ProposalID: defaultProposalID, Voter: suite.addresses[2], VoteType: types.Yes}, + {ProposalID: defaultProposalID, Voter: suite.addresses[3], VoteType: types.Yes}, + }, + expectedVoteCount: d("4"), + }, + } + + for _, tc := range testcases { + // Set up test app + tApp := app.NewTestApp() + keeper := tApp.GetCommitteeKeeper() + ctx := tApp.NewContext(true, abci.Header{Height: 1, Time: firstBlockTime}) + + // Initialize test app with genesis state + tApp.InitializeFromGenesisStates( + committeeGenState( + tApp.Codec(), + []types.Committee{memberCom}, + []types.Proposal{{ + PubProposal: gov.NewTextProposal("A Title", "A description of this proposal."), + ID: defaultProposalID, + CommitteeID: memberCom.GetID(), + Deadline: firstBlockTime.Add(time.Hour * 24 * 7), + }}, + tc.votes, + ), + ) + + // Check that all votes are counted + currentVotes := keeper.TallyMemberCommitteeVotes(ctx, defaultProposalID) + suite.Equal(tc.expectedVoteCount, currentVotes) + } +} + +func (suite *KeeperTestSuite) TestTallyTokenCommitteeVotes() { + tokenCom := types.TokenCommittee{ + BaseCommittee: types.BaseCommittee{ + ID: 12, + Description: "This committee is for testing.", + Members: suite.addresses[:5], + Permissions: []types.Permission{types.GodPermission{}}, + VoteThreshold: d("0.667"), + ProposalDuration: time.Hour * 24 * 7, + TallyOption: types.Deadline, + }, + TallyDenom: "hard", + Quorum: d("0.4"), + } + var defaultProposalID uint64 = 1 + firstBlockTime := time.Date(1998, time.January, 1, 1, 0, 0, 0, time.UTC) + + genAddrs := suite.addresses[:8] // Genesis accounts + genCoinCounts := []int64{0, 0, 0, 10, 20, 30, 40, 50} // Genesis token balances + + testcases := []struct { + name string + votes []types.Vote + expectedYesVoteCount sdk.Dec + expectedNoVoteCount sdk.Dec + expectedTotalVoteCount sdk.Dec + }{ + { + name: "has 0 votes", + votes: []types.Vote{}, + expectedYesVoteCount: d("0"), + expectedNoVoteCount: d("0"), + expectedTotalVoteCount: d("0"), + }, + { + name: "counts token holder 'Yes' votes", + votes: []types.Vote{ + {ProposalID: defaultProposalID, Voter: genAddrs[4], VoteType: types.Yes}, // Token holder + }, + expectedYesVoteCount: sdk.NewDec(genCoinCounts[4]), + expectedNoVoteCount: d("0"), + expectedTotalVoteCount: sdk.NewDec(genCoinCounts[4]), + }, + { + name: "does not count non-token holder 'Yes' votes", + votes: []types.Vote{ + {ProposalID: defaultProposalID, Voter: genAddrs[4], VoteType: types.Yes}, // Token holder + {ProposalID: defaultProposalID, Voter: genAddrs[0], VoteType: types.Yes}, // Non-token holder + }, + expectedYesVoteCount: sdk.NewDec(genCoinCounts[4]), + expectedNoVoteCount: d("0"), + expectedTotalVoteCount: sdk.NewDec(genCoinCounts[4]), + }, + { + name: "counts multiple 'Yes' votes from token holders", + votes: []types.Vote{ + {ProposalID: defaultProposalID, Voter: genAddrs[4], VoteType: types.Yes}, // Token holder + {ProposalID: defaultProposalID, Voter: genAddrs[5], VoteType: types.Yes}, // Token holder + {ProposalID: defaultProposalID, Voter: genAddrs[6], VoteType: types.Yes}, // Token holder + }, + expectedYesVoteCount: sdk.NewDec(genCoinCounts[4] + genCoinCounts[5] + genCoinCounts[6]), + expectedNoVoteCount: d("0"), + expectedTotalVoteCount: sdk.NewDec(genCoinCounts[4] + genCoinCounts[5] + genCoinCounts[6]), + }, + { + name: "counts token holder 'No' votes", + votes: []types.Vote{ + {ProposalID: defaultProposalID, Voter: genAddrs[4], VoteType: types.No}, // Token holder + }, + expectedYesVoteCount: d("0"), + expectedNoVoteCount: sdk.NewDec(genCoinCounts[4]), + expectedTotalVoteCount: sdk.NewDec(genCoinCounts[4]), + }, + { + name: "does not count non-token holder 'No' votes", + votes: []types.Vote{ + {ProposalID: defaultProposalID, Voter: genAddrs[4], VoteType: types.No}, // Token holder + {ProposalID: defaultProposalID, Voter: genAddrs[0], VoteType: types.No}, // Non-token holder + }, + expectedYesVoteCount: d("0"), + expectedNoVoteCount: sdk.NewDec(genCoinCounts[4]), + expectedTotalVoteCount: sdk.NewDec(genCoinCounts[4]), + }, + { + name: "counts multiple 'No' votes from token holders", + votes: []types.Vote{ + {ProposalID: defaultProposalID, Voter: genAddrs[4], VoteType: types.No}, // Token holder + {ProposalID: defaultProposalID, Voter: genAddrs[5], VoteType: types.No}, // Token holder + {ProposalID: defaultProposalID, Voter: genAddrs[6], VoteType: types.No}, // Token holder + }, + expectedYesVoteCount: d("0"), + expectedNoVoteCount: sdk.NewDec(genCoinCounts[4] + genCoinCounts[5] + genCoinCounts[6]), + expectedTotalVoteCount: sdk.NewDec(genCoinCounts[4] + genCoinCounts[5] + genCoinCounts[6]), + }, + { + name: "includes token holder 'Abstain' votes in total vote count", + votes: []types.Vote{ + {ProposalID: defaultProposalID, Voter: genAddrs[4], VoteType: types.Abstain}, // Token holder + }, + expectedYesVoteCount: d("0"), + expectedNoVoteCount: d("0"), + expectedTotalVoteCount: sdk.NewDec(genCoinCounts[4]), + }, + } + + // Convert accounts/token balances into format expected by genesis generation + var genCoins []sdk.Coins + var totalSupply sdk.Coins + for _, amount := range genCoinCounts { + userCoin := c("hard", amount) + genCoins = append(genCoins, cs(userCoin)) + totalSupply = totalSupply.Add(userCoin) + } + + for _, tc := range testcases { + // Set up test app + tApp := app.NewTestApp() + keeper := tApp.GetCommitteeKeeper() + ctx := tApp.NewContext(true, abci.Header{Height: 1, Time: firstBlockTime}) + + // Initialize test app with genesis state + tApp.InitializeFromGenesisStates( + committeeGenState( + tApp.Codec(), + []types.Committee{tokenCom}, + []types.Proposal{{ + PubProposal: gov.NewTextProposal("A Title", "A description of this proposal."), + ID: defaultProposalID, + CommitteeID: tokenCom.GetID(), + Deadline: firstBlockTime.Add(time.Hour * 24 * 7), + }}, + tc.votes, + ), + supplyGenState(tApp.Codec(), totalSupply), + app.NewAuthGenState(genAddrs, genCoins), + ) + + yesVotes, noVotes, currVotes, possibleVotes := keeper.TallyTokenCommitteeVotes(ctx, defaultProposalID, tokenCom.TallyDenom) + + // Check that all Yes votes are counted according to their weight + suite.Equal(tc.expectedYesVoteCount, yesVotes) + // Check that all No votes are counted according to their weight + suite.Equal(tc.expectedNoVoteCount, noVotes) + // Check that all non-Yes votes are counted according to their weight + suite.Equal(tc.expectedTotalVoteCount, currVotes) + // Check that possible votes equals the number of members on the committee + suite.Equal(totalSupply.AmountOf(tokenCom.GetTallyDenom()).ToDec(), possibleVotes) + } +} + +func (suite *KeeperTestSuite) TestGetMemberCommitteeProposalResult() { + memberCom := types.MemberCommittee{ + BaseCommittee: types.BaseCommittee{ + ID: 12, + Description: "This committee is for testing.", + Members: suite.addresses[:5], + Permissions: []types.Permission{types.GodPermission{}}, + VoteThreshold: d("0.667"), + ProposalDuration: time.Hour * 24 * 7, + TallyOption: types.Deadline, + }, } var defaultID uint64 = 1 firstBlockTime := time.Date(1998, time.January, 1, 1, 0, 0, 0, time.UTC) @@ -327,28 +601,25 @@ func (suite *KeeperTestSuite) TestGetProposalResult() { committee types.Committee votes []types.Vote proposalPasses bool - expectErr bool }{ { name: "enough votes", - committee: normalCom, + committee: memberCom, votes: []types.Vote{ - {ProposalID: defaultID, Voter: suite.addresses[0]}, - {ProposalID: defaultID, Voter: suite.addresses[1]}, - {ProposalID: defaultID, Voter: suite.addresses[2]}, - {ProposalID: defaultID, Voter: suite.addresses[3]}, + {ProposalID: defaultID, Voter: suite.addresses[0], VoteType: types.Yes}, + {ProposalID: defaultID, Voter: suite.addresses[1], VoteType: types.Yes}, + {ProposalID: defaultID, Voter: suite.addresses[2], VoteType: types.Yes}, + {ProposalID: defaultID, Voter: suite.addresses[3], VoteType: types.Yes}, }, proposalPasses: true, - expectErr: false, }, { name: "not enough votes", - committee: normalCom, + committee: memberCom, votes: []types.Vote{ - {ProposalID: defaultID, Voter: suite.addresses[0]}, + {ProposalID: defaultID, Voter: suite.addresses[0], VoteType: types.Yes}, }, proposalPasses: false, - expectErr: false, }, } @@ -366,25 +637,194 @@ func (suite *KeeperTestSuite) TestGetProposalResult() { []types.Proposal{{ PubProposal: gov.NewTextProposal("A Title", "A description of this proposal."), ID: defaultID, - CommitteeID: tc.committee.ID, + CommitteeID: tc.committee.GetID(), Deadline: firstBlockTime.Add(time.Hour * 24 * 7), }}, tc.votes, ), ) - proposalPasses, err := keeper.GetProposalResult(ctx, defaultID) - - if tc.expectErr { - suite.NotNil(err) - } else { - suite.NoError(err) - suite.Equal(tc.proposalPasses, proposalPasses) - } + proposalPasses := keeper.GetMemberCommitteeProposalResult(ctx, defaultID, tc.committee) + suite.Equal(tc.proposalPasses, proposalPasses) }) } } +func (suite *KeeperTestSuite) TestGetTokenCommitteeProposalResult() { + tokenCom := types.TokenCommittee{ + BaseCommittee: types.BaseCommittee{ + ID: 12, + Description: "This committee is for testing.", + Members: suite.addresses[:5], + Permissions: []types.Permission{types.GodPermission{}}, + VoteThreshold: d("0.667"), + ProposalDuration: time.Hour * 24 * 7, + TallyOption: types.Deadline, + }, + TallyDenom: "hard", + Quorum: d("0.4"), + } + var defaultID uint64 = 1 + firstBlockTime := time.Date(1998, time.January, 1, 1, 0, 0, 0, time.UTC) + + genAddrs := suite.addresses[:8] // Genesis accounts + genCoinCounts := []int64{0, 0, 0, 10, 20, 30, 40, 50} // Genesis token balances + + // ---------------------- Polling information ---------------------- + // 150hard total token supply: 150 possible votes + // 40% quroum: 60 votes required to meet quroum + // 66.67% voting threshold: 2/3rds of votes must be Yes votes + // ----------------------------------------------------------------- + + testcases := []struct { + name string + committee types.TokenCommittee + votes []types.Vote + proposalPasses bool + }{ + { + name: "not enough votes to meet quroum", + committee: tokenCom, + votes: []types.Vote{ + {ProposalID: defaultID, Voter: genAddrs[7], VoteType: types.Yes}, // Holds 50 tokens + }, + proposalPasses: false, // 60 vote quroum; 50 total votes; 50 yes votes. Doesn't pass 40% quroum. + }, + { + name: "enough votes to meet quroum and enough Yes votes to pass voting threshold", + committee: tokenCom, + votes: []types.Vote{ + {ProposalID: defaultID, Voter: genAddrs[3], VoteType: types.No}, // Holds 10 tokens + {ProposalID: defaultID, Voter: genAddrs[7], VoteType: types.Yes}, // Holds 50 tokens + }, + proposalPasses: true, // 60 vote quroum; 60 total votes; 50 Yes votes. Passes the 66.67% voting threshold. + }, + { + name: "enough votes to meet quroum via Abstain votes and enough Yes votes to pass voting threshold", + committee: tokenCom, + votes: []types.Vote{ + {ProposalID: defaultID, Voter: genAddrs[3], VoteType: types.Abstain}, // Holds 10 tokens + {ProposalID: defaultID, Voter: genAddrs[7], VoteType: types.Yes}, // Holds 50 tokens + }, + proposalPasses: true, // 60 vote quroum; 60 total votes; 50 Yes votes. Passes the 66.67% voting threshold. + }, + { + name: "enough votes to meet quroum but not enough Yes votes to pass voting threshold", + committee: tokenCom, + votes: []types.Vote{ + {ProposalID: defaultID, Voter: genAddrs[4], VoteType: types.Yes}, // Holds 20 tokens + {ProposalID: defaultID, Voter: genAddrs[6], VoteType: types.No}, // Holds 40 tokens + }, + proposalPasses: false, // 60 vote quroum; 60 total votes; 20 Yes votes. Doesn't pass 66.67% voting threshold. + }, + { + name: "enough votes to pass voting threshold (multiple Yes votes, multiple No votes)", + committee: tokenCom, + votes: []types.Vote{ + {ProposalID: defaultID, Voter: genAddrs[3], VoteType: types.Yes}, // Holds 10 tokens + {ProposalID: defaultID, Voter: genAddrs[4], VoteType: types.Yes}, // Holds 20 tokens + {ProposalID: defaultID, Voter: genAddrs[5], VoteType: types.Yes}, // Holds 30 tokens + {ProposalID: defaultID, Voter: genAddrs[6], VoteType: types.No}, // Holds 40 tokens + {ProposalID: defaultID, Voter: genAddrs[7], VoteType: types.Yes}, // Holds 50 tokens + }, + proposalPasses: true, // 60 vote quroum; 150 total votes; 110 Yes votes. Passes the 66.67% voting threshold. + }, + { + name: "not enough votes to pass voting threshold (multiple Yes votes, multiple No votes)", + committee: tokenCom, + votes: []types.Vote{ + {ProposalID: defaultID, Voter: genAddrs[3], VoteType: types.Yes}, // Holds 10 tokens + {ProposalID: defaultID, Voter: genAddrs[4], VoteType: types.Yes}, // Holds 20 tokens + {ProposalID: defaultID, Voter: genAddrs[5], VoteType: types.Yes}, // Holds 30 tokens + {ProposalID: defaultID, Voter: genAddrs[6], VoteType: types.Yes}, // Holds 40 tokens + {ProposalID: defaultID, Voter: genAddrs[7], VoteType: types.No}, // Holds 50 tokens + }, + proposalPasses: false, // 60 vote quroum; 150 total votes; 100 Yes votes. Doesn't pass 66.67% voting threshold. + }, + } + + // Convert accounts/token balances into format expected by genesis generation + var genCoins []sdk.Coins + var totalSupply sdk.Coins + for _, amount := range genCoinCounts { + userCoin := c("hard", amount) + genCoins = append(genCoins, cs(userCoin)) + totalSupply = totalSupply.Add(userCoin) + } + + for _, tc := range testcases { + suite.Run(tc.name, func() { + // Create local testApp because suite doesn't run the SetupTest function for subtests + tApp := app.NewTestApp() + keeper := tApp.GetCommitteeKeeper() + ctx := tApp.NewContext(true, abci.Header{Height: 1, Time: firstBlockTime}) + + tApp.InitializeFromGenesisStates( + committeeGenState( + tApp.Codec(), + []types.Committee{tc.committee}, + []types.Proposal{{ + PubProposal: gov.NewTextProposal("A Title", "A description of this proposal."), + ID: defaultID, + CommitteeID: tc.committee.GetID(), + Deadline: firstBlockTime.Add(time.Hour * 24 * 7), + }}, + tc.votes, + ), + supplyGenState(tApp.Codec(), totalSupply), + app.NewAuthGenState(genAddrs, genCoins), + ) + + proposalPasses := keeper.GetTokenCommitteeProposalResult(ctx, defaultID, tc.committee) + suite.Equal(tc.proposalPasses, proposalPasses) + }) + } +} + +func (suite *KeeperTestSuite) TestCloseProposal() { + memberCom := types.MemberCommittee{ + BaseCommittee: types.BaseCommittee{ + ID: 12, + Description: "This committee is for testing.", + Members: suite.addresses[:5], + Permissions: []types.Permission{types.GodPermission{}}, + VoteThreshold: d("0.667"), + ProposalDuration: time.Hour * 24 * 7, + TallyOption: types.Deadline, + }, + } + + var proposalID uint64 = 1 + firstBlockTime := time.Date(1998, time.January, 1, 1, 0, 0, 0, time.UTC) + + tApp := app.NewTestApp() + keeper := tApp.GetCommitteeKeeper() + ctx := tApp.NewContext(true, abci.Header{Height: 1, Time: firstBlockTime}) + + tApp.InitializeFromGenesisStates( + committeeGenState( + tApp.Codec(), + []types.Committee{memberCom}, + []types.Proposal{{ + PubProposal: gov.NewTextProposal("A Title", "A description of this proposal."), + ID: proposalID, + CommitteeID: memberCom.GetID(), + Deadline: firstBlockTime.Add(time.Hour * 24 * 7), + }}, + []types.Vote{}, + ), + ) + + // Confirm proposal exists + proposal, found := keeper.GetProposal(ctx, proposalID) + suite.True(found) + // Close proposal + keeper.CloseProposal(ctx, proposal, types.Passed) + // Confirm proposal doesn't exist + _, found = keeper.GetProposal(ctx, proposalID) + suite.False(found) +} + func committeeGenState(cdc *codec.Codec, committees []types.Committee, proposals []types.Proposal, votes []types.Vote) app.GenesisState { gs := types.NewGenesisState( uint64(len(proposals)+1), @@ -395,6 +835,11 @@ func committeeGenState(cdc *codec.Codec, committees []types.Committee, proposals return app.GenesisState{committee.ModuleName: cdc.MustMarshalJSON(gs)} } +func supplyGenState(cdc *codec.Codec, coins sdk.Coins) app.GenesisState { + gs := supply.NewGenesisState(coins) + return app.GenesisState{supply.ModuleName: cdc.MustMarshalJSON(gs)} +} + type UnregisteredPubProposal struct { gov.TextProposal } @@ -497,86 +942,312 @@ func (suite *KeeperTestSuite) TestValidatePubProposal() { } } -func (suite *KeeperTestSuite) TestCloseExpiredProposals() { +func (suite *KeeperTestSuite) TestProcessProposals() { - // Setup test state firstBlockTime := time.Date(1998, time.January, 1, 1, 0, 0, 0, time.UTC) - testGenesis := types.NewGenesisState( - 3, - []types.Committee{ - { + + genAddrs := suite.addresses[:4] // Genesis accounts + genCoinCounts := []int64{1, 1, 1, 1} // Genesis token balances + // Convert accounts/token balances into format expected by genesis generation + var genCoins []sdk.Coins + var totalSupply sdk.Coins + for _, amount := range genCoinCounts { + userCoin := c("hard", amount) + genCoins = append(genCoins, cs(userCoin)) + totalSupply = totalSupply.Add(userCoin) + } + + // Set up committees + committees := []types.Committee{ + // 1. FPTP MemberCommmittee + types.MemberCommittee{ + BaseCommittee: types.BaseCommittee{ ID: 1, - Description: "This committee is for testing.", - Members: suite.addresses[:3], + Description: "FTPT MemberCommittee", + Members: genAddrs, Permissions: []types.Permission{types.GodPermission{}}, VoteThreshold: d("0.667"), ProposalDuration: time.Hour * 24 * 7, + TallyOption: types.FirstPastThePost, }, - { + }, + // 2. FPTP TokenCommittee + types.TokenCommittee{ + BaseCommittee: types.BaseCommittee{ ID: 2, - Members: suite.addresses[2:], + Description: "FTPT TokenCommittee", + Members: genAddrs, + Permissions: []types.Permission{types.GodPermission{}}, + VoteThreshold: d("0.667"), + ProposalDuration: time.Hour * 24 * 7, + TallyOption: types.FirstPastThePost, + }, + TallyDenom: "hard", + Quorum: d("0.30"), + }, + // 3. Deadline MemberCommmittee + types.MemberCommittee{ + BaseCommittee: types.BaseCommittee{ + ID: 3, + Description: "Deadline MemberCommittee", + Members: genAddrs, + Permissions: []types.Permission{types.GodPermission{}}, + VoteThreshold: d("0.667"), + ProposalDuration: time.Hour * 24 * 7, + TallyOption: types.Deadline, + }, + }, + // 4. Deadline TokenCommittee + types.TokenCommittee{ + BaseCommittee: types.BaseCommittee{ + ID: 4, + Description: "Deadline TokenCommittee", + Members: genAddrs, + Permissions: []types.Permission{types.GodPermission{}}, + VoteThreshold: d("0.667"), + ProposalDuration: time.Hour * 24 * 7, + TallyOption: types.Deadline, + }, + TallyDenom: "hard", + Quorum: d("0.30"), + }, + // 5. PTP MemberCommmittee without permissions + types.MemberCommittee{ + BaseCommittee: types.BaseCommittee{ + ID: 5, + Description: "FTPT MemberCommittee without permissions", + Members: genAddrs, Permissions: nil, VoteThreshold: d("0.667"), ProposalDuration: time.Hour * 24 * 7, + TallyOption: types.FirstPastThePost, }, }, - []types.Proposal{ - { - ID: 1, - CommitteeID: 1, - PubProposal: gov.NewTextProposal("A Title", "A description of this proposal."), - Deadline: firstBlockTime.Add(7 * 24 * time.Hour), - }, - { - ID: 2, - CommitteeID: 1, - PubProposal: gov.NewTextProposal("Another Title", "A description of this other proposal."), - Deadline: firstBlockTime.Add(21 * 24 * time.Hour), - }, - }, - []types.Vote{ - {ProposalID: 1, Voter: suite.addresses[0]}, - {ProposalID: 1, Voter: suite.addresses[1]}, - {ProposalID: 2, Voter: suite.addresses[2]}, - }, - ) - suite.app.InitializeFromGenesisStates( - NewCommitteeGenesisState(suite.app.Codec(), testGenesis), - ) - - // close proposals - ctx := suite.app.NewContext(true, abci.Header{Height: 1, Time: firstBlockTime}) - suite.keeper.CloseExpiredProposals(ctx) - - // check - for _, p := range testGenesis.Proposals { - _, found := suite.keeper.GetProposal(ctx, p.ID) - votes := getProposalVoteMap(suite.keeper, ctx) - - if ctx.BlockTime().After(p.Deadline) { - suite.False(found) - suite.Empty(votes[p.ID]) - } else { - suite.True(found) - suite.NotEmpty(votes[p.ID]) - } } - // close (later time) - ctx = suite.app.NewContext(true, abci.Header{Height: 1, Time: firstBlockTime.Add(7 * 24 * time.Hour)}) - suite.keeper.CloseExpiredProposals(ctx) + // Set up proposals that correspond 1:1 with each committee + proposals := []types.Proposal{ + { + ID: 1, + CommitteeID: 1, + PubProposal: gov.NewTextProposal("Proposal 1", "This proposal is for the FPTP MemberCommmittee."), + Deadline: firstBlockTime.Add(7 * 24 * time.Hour), + }, + { + ID: 2, + CommitteeID: 2, + PubProposal: gov.NewTextProposal("Proposal 2", "This proposal is for the FPTP TokenCommittee."), + Deadline: firstBlockTime.Add(7 * 24 * time.Hour), + }, + { + ID: 3, + CommitteeID: 3, + PubProposal: gov.NewTextProposal("Proposal 3", "This proposal is for the Deadline MemberCommmittee."), + Deadline: firstBlockTime.Add(7 * 24 * time.Hour), + }, + { + ID: 4, + CommitteeID: 4, + PubProposal: gov.NewTextProposal("Proposal 4", "This proposal is for the Deadline TokenCommittee."), + Deadline: firstBlockTime.Add(7 * 24 * time.Hour), + }, + { + ID: 5, + CommitteeID: 5, + PubProposal: gov.NewTextProposal("Proposal 5", "This proposal is for the FPTP MemberCommmittee without permissions."), + Deadline: firstBlockTime.Add(7 * 24 * time.Hour), + }, + } - // check - for _, p := range testGenesis.Proposals { - _, found := suite.keeper.GetProposal(ctx, p.ID) - votes := getProposalVoteMap(suite.keeper, ctx) + // Each test case targets 1 committee/proposal via targeted votes + testcases := []struct { + name string + ID uint64 + votes []types.Vote + expectedToCompleteBeforeDeadline bool + expectedOutcome types.ProposalOutcome + }{ + { + name: "FPTP MemberCommittee proposal does not have enough votes to pass", + ID: 1, + votes: []types.Vote{ + {ProposalID: 1, Voter: genAddrs[0], VoteType: types.Yes}, + }, + expectedToCompleteBeforeDeadline: false, + expectedOutcome: types.Failed, + }, + { + name: "FPTP MemberCommittee proposal has enough votes to pass before deadline", + ID: 1, + votes: []types.Vote{ + {ProposalID: 1, Voter: genAddrs[0], VoteType: types.Yes}, + {ProposalID: 1, Voter: genAddrs[1], VoteType: types.Yes}, + {ProposalID: 1, Voter: genAddrs[2], VoteType: types.Yes}, + }, + expectedToCompleteBeforeDeadline: true, + expectedOutcome: types.Passed, + }, + { + name: "FPTP TokenCommittee proposal does not have enough votes to pass", + ID: 2, + votes: []types.Vote{ + {ProposalID: 2, Voter: genAddrs[0], VoteType: types.Yes}, + }, + expectedToCompleteBeforeDeadline: false, + expectedOutcome: types.Failed, + }, + { + name: "FPTP TokenCommittee proposal has enough votes to pass before deadline", + ID: 2, + votes: []types.Vote{ + {ProposalID: 2, Voter: genAddrs[0], VoteType: types.Yes}, + {ProposalID: 2, Voter: genAddrs[1], VoteType: types.Yes}, + {ProposalID: 2, Voter: genAddrs[2], VoteType: types.Yes}, + }, + expectedToCompleteBeforeDeadline: true, + expectedOutcome: types.Passed, + }, + { + name: "Deadline MemberCommittee proposal with enough votes to pass only passes after deadline", + ID: 3, + votes: []types.Vote{ + {ProposalID: 3, Voter: genAddrs[0], VoteType: types.Yes}, + {ProposalID: 3, Voter: genAddrs[1], VoteType: types.Yes}, + {ProposalID: 3, Voter: genAddrs[2], VoteType: types.Yes}, + }, + expectedOutcome: types.Passed, + }, + { + name: "Deadline MemberCommittee proposal doesn't have enough votes to pass", + ID: 3, + votes: []types.Vote{ + {ProposalID: 3, Voter: genAddrs[0], VoteType: types.Yes}, + }, + expectedOutcome: types.Failed, + }, + { + name: "Deadline TokenCommittee proposal with enough votes to pass only passes after deadline", + ID: 4, + votes: []types.Vote{ + {ProposalID: 4, Voter: genAddrs[0], VoteType: types.Yes}, + {ProposalID: 4, Voter: genAddrs[1], VoteType: types.Yes}, + {ProposalID: 4, Voter: genAddrs[2], VoteType: types.Yes}, + }, + expectedOutcome: types.Passed, + }, + { + name: "Deadline TokenCommittee proposal doesn't have enough votes to pass", + ID: 4, + votes: []types.Vote{ + {ProposalID: 4, Voter: genAddrs[0], VoteType: types.Yes}, + }, + expectedOutcome: types.Failed, + }, + { + name: "FPTP MemberCommittee doesn't have permissions to enact passed proposal", + ID: 5, + votes: []types.Vote{ + {ProposalID: 5, Voter: genAddrs[0], VoteType: types.Yes}, + {ProposalID: 5, Voter: genAddrs[1], VoteType: types.Yes}, + {ProposalID: 5, Voter: genAddrs[2], VoteType: types.Yes}, + }, + expectedToCompleteBeforeDeadline: true, + expectedOutcome: types.Invalid, + }, + } - if ctx.BlockTime().Equal(p.Deadline) || ctx.BlockTime().After(p.Deadline) { - suite.False(found) - suite.Empty(votes[p.ID]) - } else { + for _, tc := range testcases { + suite.Run(tc.name, func() { + // Create local testApp because suite doesn't run the SetupTest function for subtests + tApp := app.NewTestApp() + keeper := tApp.GetCommitteeKeeper() + ctx := tApp.NewContext(true, abci.Header{Height: 1, Time: firstBlockTime}) + + // Initialize all committees, proposals, and votes via Genesis + tApp.InitializeFromGenesisStates( + committeeGenState(tApp.Codec(), committees, proposals, tc.votes), + supplyGenState(tApp.Codec(), totalSupply), + app.NewAuthGenState(genAddrs, genCoins), + ) + + // Load committee from the store + committee, found := keeper.GetCommittee(ctx, tc.ID) suite.True(found) - suite.NotEmpty(votes[p.ID]) - } + + // Process proposals + ctx = ctx.WithBlockTime(firstBlockTime) + keeper.ProcessProposals(ctx) + + // Fetch proposal and votes from the store + votes := getProposalVoteMap(keeper, ctx) + proposal, found := keeper.GetProposal(ctx, tc.ID) + + if committee.GetTallyOption() == types.FirstPastThePost { + if tc.expectedToCompleteBeforeDeadline { + suite.False(found) + suite.Empty(votes[tc.ID]) + + // Check proposal outcome + outcome, err := getProposalOutcome(tc.ID, ctx.EventManager().Events(), tApp.Codec()) + suite.NoError(err) + suite.Equal(tc.expectedOutcome, outcome) + return + } else { + suite.True(found) + suite.NotEmpty(votes[tc.ID]) + } + } + + // Move block time to deadline + ctx = ctx.WithBlockTime(proposal.Deadline) + keeper.ProcessProposals(ctx) + + // Fetch proposal and votes from the store + votes = getProposalVoteMap(keeper, ctx) + proposal, found = keeper.GetProposal(ctx, tc.ID) + suite.False(found) + suite.Empty(votes[proposal.ID]) + + // Check proposal outcome + outcome, err := getProposalOutcome(tc.ID, ctx.EventManager().Events(), tApp.Codec()) + suite.NoError(err) + suite.Equal(tc.expectedOutcome, outcome) + }) } } + +// getProposalOutcome checks the outcome of a proposal via a `proposal_close` event whose `proposal_id` +// matches argument proposalID +func getProposalOutcome(proposalID uint64, events sdk.Events, cdc *amino.Codec) (types.ProposalOutcome, error) { + // Marshal proposal ID to match against event attribute + x, _ := cdc.MarshalJSON(proposalID) + marshaledID := x[1 : len(x)-1] + + for _, event := range events { + if event.Type == types.EventTypeProposalClose { + var proposalOutcome types.ProposalOutcome + correctProposal := false + for _, attribute := range event.Attributes { + // Only get outcome of specific proposal + if bytes.Compare(attribute.GetKey(), []byte("proposal_id")) == 0 { + if bytes.Compare(attribute.GetValue(), marshaledID) == 0 { + correctProposal = true + } + } + // Match event attribute bytes to marshaled outcome + if bytes.Compare(attribute.GetKey(), []byte(types.AttributeKeyProposalOutcome)) == 0 { + outcome, err := types.MatchMarshaledOutcome(attribute.GetValue(), cdc) + if err != nil { + return 0, err + } + proposalOutcome = outcome + } + } + // If this is the desired proposal, return the outcome + if correctProposal { + return proposalOutcome, nil + } + } + } + return 0, nil +} diff --git a/x/committee/keeper/querier.go b/x/committee/keeper/querier.go index d48bcd6b..9d4195b8 100644 --- a/x/committee/keeper/querier.go +++ b/x/committee/keeper/querier.go @@ -173,13 +173,32 @@ func queryTally(ctx sdk.Context, path []string, req abci.RequestQuery, keeper Ke return nil, sdkerrors.Wrap(sdkerrors.ErrJSONUnmarshal, err.Error()) } - _, found := keeper.GetProposal(ctx, params.ProposalID) + proposal, found := keeper.GetProposal(ctx, params.ProposalID) if !found { return nil, sdkerrors.Wrapf(types.ErrUnknownProposal, "%d", params.ProposalID) } - numVotes := keeper.TallyVotes(ctx, params.ProposalID) - bz, err := codec.MarshalJSONIndent(keeper.cdc, numVotes) + committee, found := keeper.GetCommittee(ctx, proposal.CommitteeID) + if !found { + return nil, sdkerrors.Wrapf(types.ErrUnknownCommittee, "%d", proposal.CommitteeID) + } + + var pollingStatus types.ProposalPollingStatus + switch com := committee.(type) { + case types.MemberCommittee: + currVotes := keeper.TallyMemberCommitteeVotes(ctx, params.ProposalID) + possibleVotes := sdk.NewDec(int64(len(com.Members))) + memberPollingStatus := types.NewProposalPollingStatus(params.ProposalID, currVotes, + currVotes, possibleVotes, com.VoteThreshold, sdk.Dec{Int: nil}) + pollingStatus = memberPollingStatus + case types.TokenCommittee: + yesVotes, _, currVotes, possibleVotes := keeper.TallyTokenCommitteeVotes(ctx, params.ProposalID, com.TallyDenom) + tokenPollingStatus := types.NewProposalPollingStatus(params.ProposalID, yesVotes, + currVotes, possibleVotes, com.VoteThreshold, com.Quorum) + pollingStatus = tokenPollingStatus + } + + bz, err := codec.MarshalJSONIndent(keeper.cdc, pollingStatus) if err != nil { return nil, sdkerrors.Wrap(sdkerrors.ErrJSONMarshal, err.Error()) } diff --git a/x/committee/keeper/querier_test.go b/x/committee/keeper/querier_test.go index 3c901e91..ddb4ea74 100644 --- a/x/committee/keeper/querier_test.go +++ b/x/committee/keeper/querier_test.go @@ -51,20 +51,26 @@ func (suite *QuerierTestSuite) SetupTest() { suite.testGenesis = types.NewGenesisState( 3, []types.Committee{ - { - ID: 1, - Description: "This committee is for testing.", - Members: suite.addresses[:3], - Permissions: []types.Permission{types.GodPermission{}}, - VoteThreshold: d("0.667"), - ProposalDuration: time.Hour * 24 * 7, + types.MemberCommittee{ + BaseCommittee: types.BaseCommittee{ + ID: 1, + Description: "This committee is for testing.", + Members: suite.addresses[:3], + Permissions: []types.Permission{types.GodPermission{}}, + VoteThreshold: d("0.667"), + ProposalDuration: time.Hour * 24 * 7, + TallyOption: types.FirstPastThePost, + }, }, - { - ID: 2, - Members: suite.addresses[2:], - Permissions: nil, - VoteThreshold: d("0.667"), - ProposalDuration: time.Hour * 24 * 7, + types.MemberCommittee{ + BaseCommittee: types.BaseCommittee{ + ID: 2, + Members: suite.addresses[2:], + Permissions: nil, + VoteThreshold: d("0.667"), + ProposalDuration: time.Hour * 24 * 7, + TallyOption: types.FirstPastThePost, + }, }, }, []types.Proposal{ @@ -72,9 +78,9 @@ func (suite *QuerierTestSuite) SetupTest() { {ID: 2, CommitteeID: 1, PubProposal: gov.NewTextProposal("Another Title", "A description of this other proposal."), Deadline: testTime.Add(21 * 24 * time.Hour)}, }, []types.Vote{ - {ProposalID: 1, Voter: suite.addresses[0]}, - {ProposalID: 1, Voter: suite.addresses[1]}, - {ProposalID: 2, Voter: suite.addresses[2]}, + {ProposalID: 1, Voter: suite.addresses[0], VoteType: types.Yes}, + {ProposalID: 1, Voter: suite.addresses[1], VoteType: types.Yes}, + {ProposalID: 2, Voter: suite.addresses[2], VoteType: types.Yes}, }, ) suite.app.InitializeFromGenesisStates( @@ -97,7 +103,7 @@ func (suite *QuerierTestSuite) TestQueryCommittees() { suite.NotNil(bz) // Unmarshal the bytes - var committees []types.Committee + var committees types.Committees suite.NoError(suite.cdc.UnmarshalJSON(bz, &committees)) // Check @@ -105,11 +111,11 @@ func (suite *QuerierTestSuite) TestQueryCommittees() { } func (suite *QuerierTestSuite) TestQueryCommittee() { - ctx := suite.ctx.WithIsCheckTx(false) // ? + ctx := suite.ctx.WithIsCheckTx(false) // Set up request query query := abci.RequestQuery{ Path: strings.Join([]string{custom, types.QuerierRoute, types.QueryCommittee}, "/"), - Data: suite.cdc.MustMarshalJSON(types.NewQueryCommitteeParams(suite.testGenesis.Committees[0].ID)), + Data: suite.cdc.MustMarshalJSON(types.NewQueryCommitteeParams(suite.testGenesis.Committees[0].GetID())), } // Execute query and check the []byte result @@ -154,7 +160,7 @@ func (suite *QuerierTestSuite) TestQueryProposals() { } func (suite *QuerierTestSuite) TestQueryProposal() { - ctx := suite.ctx.WithIsCheckTx(false) // ? + ctx := suite.ctx.WithIsCheckTx(false) // Set up request query query := abci.RequestQuery{ Path: strings.Join([]string{custom, types.QuerierRoute, types.QueryProposal}, "/"), @@ -209,7 +215,7 @@ func (suite *QuerierTestSuite) TestQueryVotes() { } func (suite *QuerierTestSuite) TestQueryVote() { - ctx := suite.ctx.WithIsCheckTx(false) // ? + ctx := suite.ctx.WithIsCheckTx(false) // Set up request query propID := suite.testGenesis.Proposals[0].ID query := abci.RequestQuery{ @@ -231,9 +237,21 @@ func (suite *QuerierTestSuite) TestQueryVote() { } func (suite *QuerierTestSuite) TestQueryTally() { - ctx := suite.ctx.WithIsCheckTx(false) // ? - // Set up request query + + ctx := suite.ctx.WithIsCheckTx(false) + + // Expected result propID := suite.testGenesis.Proposals[0].ID + expectedPollingStatus := types.ProposalPollingStatus{ + ProposalID: 1, + YesVotes: sdk.NewDec(int64(len(suite.votes[propID]))), + CurrentVotes: sdk.NewDec(int64(len(suite.votes[propID]))), + PossibleVotes: d("3.0"), + VoteThreshold: d("0.667"), + Quorum: d("0"), + } + + // Set up request query query := abci.RequestQuery{ Path: strings.Join([]string{custom, types.QuerierRoute, types.QueryTally}, "/"), Data: suite.cdc.MustMarshalJSON(types.NewQueryProposalParams(propID)), @@ -245,11 +263,9 @@ func (suite *QuerierTestSuite) TestQueryTally() { suite.NotNil(bz) // Unmarshal the bytes - var tally int64 - suite.NoError(suite.cdc.UnmarshalJSON(bz, &tally)) - - // Check - suite.Equal(int64(len(suite.votes[propID])), tally) + var propPollingStatus types.ProposalPollingStatus + suite.NoError(suite.cdc.UnmarshalJSON(bz, &propPollingStatus)) + suite.Equal(expectedPollingStatus, propPollingStatus) } type TestSubParam struct { @@ -269,7 +285,7 @@ func (p *TestParams) ParamSetPairs() params.ParamSetPairs { } } func (suite *QuerierTestSuite) TestQueryRawParams() { - ctx := suite.ctx.WithIsCheckTx(false) // ? + ctx := suite.ctx.WithIsCheckTx(false) // Create a new param subspace to avoid adding dependency to another module. Set a test param value. subspaceName := "test" @@ -277,9 +293,12 @@ func (suite *QuerierTestSuite) TestQueryRawParams() { subspace = subspace.WithKeyTable(params.NewKeyTable().RegisterParamSet(&TestParams{})) paramValue := TestSubParam{ - Some: "test", - Test: d("1000000000000.000000000000000001"), - Params: []types.Vote{{1, suite.addresses[0]}, {12, suite.addresses[1]}}, + Some: "test", + Test: d("1000000000000.000000000000000001"), + Params: []types.Vote{ + types.NewVote(1, suite.addresses[0], types.Yes), + types.NewVote(12, suite.addresses[1], types.Yes), + }, } subspace.Set(ctx, []byte(paramKey), paramValue) diff --git a/x/committee/legacy/v0_14/types.go b/x/committee/legacy/v0_14/types.go new file mode 100644 index 00000000..13744498 --- /dev/null +++ b/x/committee/legacy/v0_14/types.go @@ -0,0 +1,1094 @@ +package v14 + +import ( + "bytes" + "fmt" + "time" + + yaml "gopkg.in/yaml.v2" + + "github.com/cosmos/cosmos-sdk/codec" + sdk "github.com/cosmos/cosmos-sdk/types" + sdkerrors "github.com/cosmos/cosmos-sdk/types/errors" + govtypes "github.com/cosmos/cosmos-sdk/x/gov/types" + "github.com/cosmos/cosmos-sdk/x/params" + paramstypes "github.com/cosmos/cosmos-sdk/x/params/types" + upgrade "github.com/cosmos/cosmos-sdk/x/upgrade" + + bep3types "github.com/kava-labs/kava/x/bep3/types" + v0_14cdp "github.com/kava-labs/kava/x/cdp" + "github.com/kava-labs/kava/x/hard" + "github.com/kava-labs/kava/x/pricefeed" + pricefeedtypes "github.com/kava-labs/kava/x/pricefeed/types" +) + +const ( + ModuleName = "committee" + RouterKey = ModuleName // RouterKey Top level router key + MaxCommitteeDescriptionLength int = 512 + DefaultNextProposalID uint64 = 1 // DefaultNextProposalID is the starting poiint for proposal IDs + TypeMsgSubmitProposal = "commmittee_submit_proposal" // 'committee' prefix appended to avoid potential conflicts with gov msg types + TypeMsgVote = "committee_vote" +) + +var ( + ErrInvalidPubProposal = sdkerrors.New(ModuleName, 6, "invalid pubproposal") // Declare custom error type, but don't register it here + ModuleCdc *codec.Codec // ModuleCdc is a generic codec to be used throughout module + _, _ sdk.Msg = MsgSubmitProposal{}, MsgVote{} // Enforce msg interface compliance +) + +// Permission is anything with a method that validates whether a proposal is allowed by it or not. +type Permission interface { + Allows(sdk.Context, *codec.Codec, ParamKeeper, PubProposal) bool +} + +type ParamKeeper interface { + GetSubspace(string) (params.Subspace, bool) +} + +// ------------------------------------------ +// Committees +// ------------------------------------------ + +// A Committee is a collection of addresses that are allowed to vote and enact any governance proposal that passes their permissions. +type Committee struct { + ID uint64 `json:"id" yaml:"id"` + Description string `json:"description" yaml:"description"` + Members []sdk.AccAddress `json:"members" yaml:"members"` + Permissions []Permission `json:"permissions" yaml:"permissions"` + VoteThreshold sdk.Dec `json:"vote_threshold" yaml:"vote_threshold"` // Smallest percentage of members that must vote for a proposal to pass. + ProposalDuration time.Duration `json:"proposal_duration" yaml:"proposal_duration"` // The length of time a proposal remains active for. Proposals will close earlier if they get enough votes. +} + +func NewCommittee(id uint64, description string, members []sdk.AccAddress, permissions []Permission, threshold sdk.Dec, duration time.Duration) Committee { + return Committee{ + ID: id, + Description: description, + Members: members, + Permissions: permissions, + VoteThreshold: threshold, + ProposalDuration: duration, + } +} + +func (c Committee) HasMember(addr sdk.AccAddress) bool { + for _, m := range c.Members { + if m.Equals(addr) { + return true + } + } + return false +} + +// HasPermissionsFor returns whether the committee is authorized to enact a proposal. +// As long as one permission allows the proposal then it goes through. Its the OR of all permissions. +func (c Committee) HasPermissionsFor(ctx sdk.Context, appCdc *codec.Codec, pk ParamKeeper, proposal PubProposal) bool { + for _, p := range c.Permissions { + if p.Allows(ctx, appCdc, pk, proposal) { + return true + } + } + return false +} + +func (c Committee) Validate() error { + + addressMap := make(map[string]bool, len(c.Members)) + for _, m := range c.Members { + // check there are no duplicate members + if _, ok := addressMap[m.String()]; ok { + return fmt.Errorf("committe cannot have duplicate members, %s", m) + } + // check for valid addresses + if m.Empty() { + return fmt.Errorf("committee cannot have empty member address") + } + addressMap[m.String()] = true + } + + if len(c.Members) == 0 { + return fmt.Errorf("committee cannot have zero members") + } + + if len(c.Description) > MaxCommitteeDescriptionLength { + return fmt.Errorf("description length %d longer than max allowed %d", len(c.Description), MaxCommitteeDescriptionLength) + } + + for _, p := range c.Permissions { + if p == nil { + return fmt.Errorf("committee cannot have a nil permission") + } + } + + // threshold must be in the range (0,1] + if c.VoteThreshold.IsNil() || c.VoteThreshold.LTE(sdk.ZeroDec()) || c.VoteThreshold.GT(sdk.NewDec(1)) { + return fmt.Errorf("invalid threshold: %s", c.VoteThreshold) + } + + if c.ProposalDuration < 0 { + return fmt.Errorf("invalid proposal duration: %s", c.ProposalDuration) + } + + return nil +} + +// ------------------------------------------ +// Proposals +// ------------------------------------------ + +// PubProposal is the interface that all proposals must fulfill to be submitted to a committee. +// Proposal types can be created external to this module. For example a ParamChangeProposal, or CommunityPoolSpendProposal. +// It is pinned to the equivalent type in the gov module to create compatibility between proposal types. +type PubProposal govtypes.Content + +// Proposal is an internal record of a governance proposal submitted to a committee. +type Proposal struct { + PubProposal `json:"pub_proposal" yaml:"pub_proposal"` + ID uint64 `json:"id" yaml:"id"` + CommitteeID uint64 `json:"committee_id" yaml:"committee_id"` + Deadline time.Time `json:"deadline" yaml:"deadline"` +} + +func NewProposal(pubProposal PubProposal, id uint64, committeeID uint64, deadline time.Time) Proposal { + return Proposal{ + PubProposal: pubProposal, + ID: id, + CommitteeID: committeeID, + Deadline: deadline, + } +} + +// HasExpiredBy calculates if the proposal will have expired by a certain time. +// All votes must be cast before deadline, those cast at time == deadline are not valid +func (p Proposal) HasExpiredBy(time time.Time) bool { + return !time.Before(p.Deadline) +} + +// String implements the fmt.Stringer interface, and importantly overrides the String methods inherited from the embedded PubProposal type. +func (p Proposal) String() string { + bz, _ := yaml.Marshal(p) + return string(bz) +} + +// ------------------------------------------ +// Votes +// ------------------------------------------ + +type Vote struct { + ProposalID uint64 `json:"proposal_id" yaml:"proposal_id"` + Voter sdk.AccAddress `json:"voter" yaml:"voter"` +} + +func NewVote(proposalID uint64, voter sdk.AccAddress) Vote { + return Vote{ + ProposalID: proposalID, + Voter: voter, + } +} + +func (v Vote) Validate() error { + if v.Voter.Empty() { + return fmt.Errorf("voter address cannot be empty") + } + return nil +} + +// ------------------------------------------ +// GodPermission +// ------------------------------------------ + +// GodPermission allows any governance proposal. It is used mainly for testing. +type GodPermission struct{} + +var _ Permission = GodPermission{} + +// Allows implement permission interface +func (GodPermission) Allows(sdk.Context, *codec.Codec, ParamKeeper, PubProposal) bool { return true } + +// MarshalYAML implement yaml marshalling +func (GodPermission) MarshalYAML() (interface{}, error) { + valueToMarshal := struct { + Type string `yaml:"type"` + }{ + Type: "god_permission", + } + return valueToMarshal, nil +} + +// ------------------------------------------ +// SimpleParamChangePermission +// ------------------------------------------ + +// SimpleParamChangePermission only allows changes to certain params +type SimpleParamChangePermission struct { + AllowedParams AllowedParams `json:"allowed_params" yaml:"allowed_params"` +} + +var _ Permission = SimpleParamChangePermission{} + +// Allows implement permission interface +func (perm SimpleParamChangePermission) Allows(_ sdk.Context, _ *codec.Codec, _ ParamKeeper, p PubProposal) bool { + proposal, ok := p.(paramstypes.ParameterChangeProposal) + if !ok { + return false + } + for _, change := range proposal.Changes { + if !perm.AllowedParams.Contains(change) { + return false + } + } + return true +} + +// MarshalYAML implement yaml marshalling +func (perm SimpleParamChangePermission) MarshalYAML() (interface{}, error) { + valueToMarshal := struct { + Type string `yaml:"type"` + AllowedParams AllowedParams `yaml:"allowed_params"` + }{ + Type: "param_change_permission", + AllowedParams: perm.AllowedParams, + } + return valueToMarshal, nil +} + +// AllowedParam permission type for module parameter keys +type AllowedParam struct { + Subspace string `json:"subspace" yaml:"subspace"` + Key string `json:"key" yaml:"key"` +} + +// AllowedParams slice of AllowedParam +type AllowedParams []AllowedParam + +// Contains checks if a key is included in param permissions +func (allowed AllowedParams) Contains(paramChange paramstypes.ParamChange) bool { + for _, p := range allowed { + if paramChange.Subspace == p.Subspace && paramChange.Key == p.Key { + return true + } + } + return false +} + +// ------------------------------------------ +// TextPermission +// ------------------------------------------ + +// TextPermission allows any text governance proposal. +type TextPermission struct{} + +var _ Permission = TextPermission{} + +// Allows implement permission interface +func (TextPermission) Allows(_ sdk.Context, _ *codec.Codec, _ ParamKeeper, p PubProposal) bool { + _, ok := p.(govtypes.TextProposal) + return ok +} + +// MarshalYAML implement yaml marshalling +func (TextPermission) MarshalYAML() (interface{}, error) { + valueToMarshal := struct { + Type string `yaml:"type"` + }{ + Type: "text_permission", + } + return valueToMarshal, nil +} + +// ------------------------------------------ +// SoftwareUpgradePermission +// ------------------------------------------ + +// SoftwareUpgradePermission permission type for software upgrade proposals +type SoftwareUpgradePermission struct{} + +var _ Permission = SoftwareUpgradePermission{} + +// Allows implement permission interface +func (SoftwareUpgradePermission) Allows(_ sdk.Context, _ *codec.Codec, _ ParamKeeper, p PubProposal) bool { + _, ok := p.(upgrade.SoftwareUpgradeProposal) + return ok +} + +// MarshalYAML implement yaml marshalling +func (SoftwareUpgradePermission) MarshalYAML() (interface{}, error) { + valueToMarshal := struct { + Type string `yaml:"type"` + }{ + Type: "software_upgrade_permission", + } + return valueToMarshal, nil +} + +// ------------------------------------------ +// SubParamChangePermission +// ------------------------------------------ + +// SubParamChangePermission permission type for allowing changes to specific sub-keys within module parameter keys +type SubParamChangePermission struct { + AllowedParams AllowedParams `json:"allowed_params" yaml:"allowed_params"` + AllowedCollateralParams AllowedCollateralParams `json:"allowed_collateral_params" yaml:"allowed_collateral_params"` + AllowedDebtParam AllowedDebtParam `json:"allowed_debt_param" yaml:"allowed_debt_param"` + AllowedAssetParams AllowedAssetParams `json:"allowed_asset_params" yaml:"allowed_asset_params"` + AllowedMarkets AllowedMarkets `json:"allowed_markets" yaml:"allowed_markets"` + AllowedMoneyMarkets AllowedMoneyMarkets `json:"allowed_money_markets" yaml:"allowed_money_markets"` +} + +var _ Permission = SubParamChangePermission{} + +// MarshalYAML implement yaml marshalling +func (perm SubParamChangePermission) MarshalYAML() (interface{}, error) { + valueToMarshal := struct { + Type string `yaml:"type" json:"type"` + AllowedParams AllowedParams `yaml:"allowed_params" json:"allowed_params"` + AllowedCollateralParams AllowedCollateralParams `yaml:"allowed_collateral_params" json:"allowed_collateral_params"` + AllowedDebtParam AllowedDebtParam `yaml:"allowed_debt_param" json:"allowed_debt_param"` + AllowedAssetParams AllowedAssetParams `yaml:"allowed_asset_params" json:"allowed_asset_params"` + AllowedMarkets AllowedMarkets `yaml:"allowed_markets" json:"allowed_markets"` + AllowedMoneyMarkets AllowedMoneyMarkets `json:"allowed_money_markets" yaml:"allowed_money_markets"` + }{ + Type: "param_change_permission", + AllowedParams: perm.AllowedParams, + AllowedCollateralParams: perm.AllowedCollateralParams, + AllowedDebtParam: perm.AllowedDebtParam, + AllowedAssetParams: perm.AllowedAssetParams, + AllowedMarkets: perm.AllowedMarkets, + AllowedMoneyMarkets: perm.AllowedMoneyMarkets, + } + return valueToMarshal, nil +} + +// Allows implement permission interface +func (perm SubParamChangePermission) Allows(ctx sdk.Context, appCdc *codec.Codec, pk ParamKeeper, p PubProposal) bool { + // Check pubproposal has correct type + proposal, ok := p.(paramstypes.ParameterChangeProposal) + if !ok { + return false + } + // Check the param changes match the allowed keys + for _, change := range proposal.Changes { + if !perm.AllowedParams.Contains(change) { + return false + } + } + // Check any CollateralParam changes are allowed + + // Get the incoming CollaterParams value + var foundIncomingCP bool + var incomingCP v0_14cdp.CollateralParams + for _, change := range proposal.Changes { + if !(change.Subspace == v0_14cdp.ModuleName && change.Key == string(v0_14cdp.KeyCollateralParams)) { + continue + } + // note: in case of duplicates take the last value + foundIncomingCP = true + if err := appCdc.UnmarshalJSON([]byte(change.Value), &incomingCP); err != nil { + return false // invalid json value, so just disallow + } + } + // only check if there was a proposed change + if foundIncomingCP { + // Get the current value of the CollateralParams + cdpSubspace, found := pk.GetSubspace(v0_14cdp.ModuleName) + if !found { + return false // not using a panic to help avoid begin blocker panics + } + var currentCP v0_14cdp.CollateralParams + cdpSubspace.Get(ctx, v0_14cdp.KeyCollateralParams, ¤tCP) // panics if something goes wrong + + // Check all the incoming changes in the CollateralParams are allowed + collateralParamChangesAllowed := perm.AllowedCollateralParams.Allows(currentCP, incomingCP) + if !collateralParamChangesAllowed { + return false + } + } + + // Check any DebtParam changes are allowed + + // Get the incoming DebtParam value + var foundIncomingDP bool + var incomingDP v0_14cdp.DebtParam + for _, change := range proposal.Changes { + if !(change.Subspace == v0_14cdp.ModuleName && change.Key == string(v0_14cdp.KeyDebtParam)) { + continue + } + // note: in case of duplicates take the last value + foundIncomingDP = true + if err := appCdc.UnmarshalJSON([]byte(change.Value), &incomingDP); err != nil { + return false // invalid json value, so just disallow + } + } + // only check if there was a proposed change + if foundIncomingDP { + // Get the current value of the DebtParams + cdpSubspace, found := pk.GetSubspace(v0_14cdp.ModuleName) + if !found { + return false // not using a panic to help avoid begin blocker panics + } + var currentDP v0_14cdp.DebtParam + cdpSubspace.Get(ctx, v0_14cdp.KeyDebtParam, ¤tDP) // panics if something goes wrong + + // Check the incoming changes in the DebtParam are allowed + debtParamChangeAllowed := perm.AllowedDebtParam.Allows(currentDP, incomingDP) + if !debtParamChangeAllowed { + return false + } + } + + // Check any AssetParams changes are allowed + + // Get the incoming AssetParams value + var foundIncomingAPs bool + var incomingAPs bep3types.AssetParams + for _, change := range proposal.Changes { + if !(change.Subspace == bep3types.ModuleName && change.Key == string(bep3types.KeyAssetParams)) { + continue + } + // note: in case of duplicates take the last value + foundIncomingAPs = true + if err := appCdc.UnmarshalJSON([]byte(change.Value), &incomingAPs); err != nil { + return false // invalid json value, so just disallow + } + } + + // only check if there was a proposed change + if foundIncomingAPs { + // Get the current value of the SupportedAssets + subspace, found := pk.GetSubspace(bep3types.ModuleName) + if !found { + return false // not using a panic to help avoid begin blocker panics + } + var currentAPs bep3types.AssetParams + subspace.Get(ctx, bep3types.KeyAssetParams, ¤tAPs) // panics if something goes wrong + + // Check all the incoming changes in the CollateralParams are allowed + assetParamsChangesAllowed := perm.AllowedAssetParams.Allows(currentAPs, incomingAPs) + if !assetParamsChangesAllowed { + return false + } + } + + // Check any Markets changes are allowed + + // Get the incoming Markets value + var foundIncomingMs bool + var incomingMs pricefeedtypes.Markets + for _, change := range proposal.Changes { + if !(change.Subspace == pricefeedtypes.ModuleName && change.Key == string(pricefeedtypes.KeyMarkets)) { + continue + } + // note: in case of duplicates take the last value + foundIncomingMs = true + if err := appCdc.UnmarshalJSON([]byte(change.Value), &incomingMs); err != nil { + return false // invalid json value, so just disallow + } + } + // only check if there was a proposed change + if foundIncomingMs { + // Get the current value of the Markets + subspace, found := pk.GetSubspace(pricefeedtypes.ModuleName) + if !found { + return false // not using a panic to help avoid begin blocker panics + } + var currentMs pricefeedtypes.Markets + subspace.Get(ctx, pricefeedtypes.KeyMarkets, ¤tMs) // panics if something goes wrong + + // Check all the incoming changes in the Markets are allowed + marketsChangesAllowed := perm.AllowedMarkets.Allows(currentMs, incomingMs) + if !marketsChangesAllowed { + return false + } + } + + // Check any MoneyMarket changes are allowed + + var foundIncomingMMs bool + var incomingMMs hard.MoneyMarkets + for _, change := range proposal.Changes { + if !(change.Subspace == hard.ModuleName && change.Key == string(hard.KeyMoneyMarkets)) { + continue + } + foundIncomingMMs = true + if err := appCdc.UnmarshalJSON([]byte(change.Value), &incomingMMs); err != nil { + return false + } + } + + if foundIncomingMMs { + subspace, found := pk.GetSubspace(hard.ModuleName) + if !found { + return false + } + var currentMMs hard.MoneyMarkets + subspace.Get(ctx, hard.KeyMoneyMarkets, ¤tMMs) + mmChangesAllowed := perm.AllowedMoneyMarkets.Allows(currentMMs, incomingMMs) + if !mmChangesAllowed { + return false + } + } + + return true +} + +// AllowedCollateralParams slice of AllowedCollateralParam +type AllowedCollateralParams []AllowedCollateralParam + +// Allows determine if collateral params changes are permitted +func (acps AllowedCollateralParams) Allows(current, incoming v0_14cdp.CollateralParams) bool { + allAllowed := true + + // do not allow CollateralParams to be added or removed + // this checks both lists are the same size, then below checks each incoming matches a current + if len(incoming) != len(current) { + return false + } + + // for each param struct, check it is allowed, and if it is not, check the value has not changed + for _, incomingCP := range incoming { + // 1) check incoming cp is in list of allowed cps + var foundAllowedCP bool + var allowedCP AllowedCollateralParam + for _, p := range acps { + if p.Type != incomingCP.Type { + continue + } + foundAllowedCP = true + allowedCP = p + } + if !foundAllowedCP { + // incoming had a CollateralParam that wasn't in the list of allowed ones + return false + } + + // 2) Check incoming changes are individually allowed + // find existing CollateralParam + var foundCurrentCP bool + var currentCP v0_14cdp.CollateralParam + for _, p := range current { + if p.Type != incomingCP.Type { + continue + } + foundCurrentCP = true + currentCP = p + } + if !foundCurrentCP { + return false // not allowed to add param to list + } + // check changed values are all allowed + allowed := allowedCP.Allows(currentCP, incomingCP) + + allAllowed = allAllowed && allowed + } + return allAllowed +} + +// AllowedCollateralParam permission struct for changes to collateral parameter keys (cdp module) +type AllowedCollateralParam struct { + Type string `json:"type" yaml:"type"` + Denom bool `json:"denom" yaml:"denom"` + LiquidationRatio bool `json:"liquidation_ratio" yaml:"liquidation_ratio"` + DebtLimit bool `json:"debt_limit" yaml:"debt_limit"` + StabilityFee bool `json:"stability_fee" yaml:"stability_fee"` + AuctionSize bool `json:"auction_size" yaml:"auction_size"` + LiquidationPenalty bool `json:"liquidation_penalty" yaml:"liquidation_penalty"` + Prefix bool `json:"prefix" yaml:"prefix"` + SpotMarketID bool `json:"spot_market_id" yaml:"spot_market_id"` + LiquidationMarketID bool `json:"liquidation_market_id" yaml:"liquidation_market_id"` + ConversionFactor bool `json:"conversion_factor" yaml:"conversion_factor"` + KeeperRewardPercentage bool `json:"keeper_reward_percentage" yaml:"keeper_reward_percentage"` + CheckCollateralizationIndexCount bool `json:"check_collateralization_index_count" yaml:"check_collateralization_index_count"` +} + +// NewAllowedCollateralParam return a new AllowedCollateralParam +func NewAllowedCollateralParam( + ctype string, denom, liqRatio, debtLimit, + stabilityFee, auctionSize, liquidationPenalty, + prefix, spotMarket, liquidationMarket, conversionFactor, keeperReward, ltvIndexCount bool) AllowedCollateralParam { + return AllowedCollateralParam{ + Type: ctype, + Denom: denom, + LiquidationRatio: liqRatio, + DebtLimit: debtLimit, + StabilityFee: stabilityFee, + AuctionSize: auctionSize, + LiquidationPenalty: liquidationPenalty, + Prefix: prefix, + SpotMarketID: spotMarket, + LiquidationMarketID: liquidationMarket, + ConversionFactor: conversionFactor, + KeeperRewardPercentage: keeperReward, + CheckCollateralizationIndexCount: ltvIndexCount, + } +} + +// Allows determine if collateral param changes are permitted +func (acp AllowedCollateralParam) Allows(current, incoming v0_14cdp.CollateralParam) bool { + allowed := ((acp.Type == current.Type) && (acp.Type == incoming.Type)) && // require collateral types to be all equal + (current.Denom == incoming.Denom || acp.Denom) && + (current.LiquidationRatio.Equal(incoming.LiquidationRatio) || acp.LiquidationRatio) && + (current.DebtLimit.IsEqual(incoming.DebtLimit) || acp.DebtLimit) && + (current.StabilityFee.Equal(incoming.StabilityFee) || acp.StabilityFee) && + (current.AuctionSize.Equal(incoming.AuctionSize) || acp.AuctionSize) && + (current.LiquidationPenalty.Equal(incoming.LiquidationPenalty) || acp.LiquidationPenalty) && + ((current.Prefix == incoming.Prefix) || acp.Prefix) && + ((current.SpotMarketID == incoming.SpotMarketID) || acp.SpotMarketID) && + ((current.LiquidationMarketID == incoming.LiquidationMarketID) || acp.LiquidationMarketID) && + ((current.KeeperRewardPercentage.Equal(incoming.KeeperRewardPercentage)) || acp.KeeperRewardPercentage) && + ((current.CheckCollateralizationIndexCount.Equal(incoming.CheckCollateralizationIndexCount)) || acp.CheckCollateralizationIndexCount) && + (current.ConversionFactor.Equal(incoming.ConversionFactor) || acp.ConversionFactor) + return allowed +} + +// AllowedDebtParam permission struct for changes to debt parameter keys (cdp module) +type AllowedDebtParam struct { + Denom bool `json:"denom" yaml:"denom"` + ReferenceAsset bool `json:"reference_asset" yaml:"reference_asset"` + ConversionFactor bool `json:"conversion_factor" yaml:"conversion_factor"` + DebtFloor bool `json:"debt_floor" yaml:"debt_floor"` +} + +// Allows determines if debt params changes are permitted +func (adp AllowedDebtParam) Allows(current, incoming v0_14cdp.DebtParam) bool { + allowed := ((current.Denom == incoming.Denom) || adp.Denom) && + ((current.ReferenceAsset == incoming.ReferenceAsset) || adp.ReferenceAsset) && + (current.ConversionFactor.Equal(incoming.ConversionFactor) || adp.ConversionFactor) && + (current.DebtFloor.Equal(incoming.DebtFloor) || adp.DebtFloor) + return allowed +} + +// AllowedAssetParams slice of AllowedAssetParam +type AllowedAssetParams []AllowedAssetParam + +// Allows determines if asset params changes are permitted +func (aaps AllowedAssetParams) Allows(current, incoming bep3types.AssetParams) bool { + allAllowed := true + + // do not allow AssetParams to be added or removed + // this checks both lists are the same size, then below checks each incoming matches a current + if len(incoming) != len(current) { + return false + } + + // for each asset struct, check it is allowed, and if it is not, check the value has not changed + for _, incomingAP := range incoming { + // 1) check incoming ap is in list of allowed aps + var foundAllowedAP bool + var allowedAP AllowedAssetParam + for _, p := range aaps { + if p.Denom != incomingAP.Denom { + continue + } + foundAllowedAP = true + allowedAP = p + } + if !foundAllowedAP { + // incoming had a AssetParam that wasn't in the list of allowed ones + return false + } + + // 2) Check incoming changes are individually allowed + // find existing SupportedAsset + var foundCurrentAP bool + var currentAP bep3types.AssetParam + for _, p := range current { + if p.Denom != incomingAP.Denom { + continue + } + foundCurrentAP = true + currentAP = p + } + if !foundCurrentAP { + return false // not allowed to add asset to list + } + // check changed values are all allowed + allowed := allowedAP.Allows(currentAP, incomingAP) + + allAllowed = allAllowed && allowed + } + return allAllowed +} + +// AllowedAssetParam bep3 asset parameters that can be changed by committee +type AllowedAssetParam struct { + Denom string `json:"denom" yaml:"denom"` + CoinID bool `json:"coin_id" yaml:"coin_id"` + Limit bool `json:"limit" yaml:"limit"` + Active bool `json:"active" yaml:"active"` + MaxSwapAmount bool `json:"max_swap_amount" yaml:"max_swap_amount"` + MinBlockLock bool `json:"min_block_lock" yaml:"min_block_lock"` +} + +// Allows bep3 AssetParam parameters than can be changed by committee +func (aap AllowedAssetParam) Allows(current, incoming bep3types.AssetParam) bool { + + allowed := ((aap.Denom == current.Denom) && (aap.Denom == incoming.Denom)) && // require denoms to be all equal + ((current.CoinID == incoming.CoinID) || aap.CoinID) && + (current.SupplyLimit.Equals(incoming.SupplyLimit) || aap.Limit) && + ((current.Active == incoming.Active) || aap.Active) && + ((current.MaxSwapAmount.Equal(incoming.MaxSwapAmount)) || aap.MaxSwapAmount) && + ((current.MinBlockLock == incoming.MinBlockLock) || aap.MinBlockLock) + return allowed +} + +// AllowedMarkets slice of AllowedMarket +type AllowedMarkets []AllowedMarket + +// Allows determines if markets params changed are permitted +func (ams AllowedMarkets) Allows(current, incoming pricefeedtypes.Markets) bool { + allAllowed := true + + // do not allow Markets to be added or removed + // this checks both lists are the same size, then below checks each incoming matches a current + if len(incoming) != len(current) { + return false + } + + // for each market struct, check it is allowed, and if it is not, check the value has not changed + for _, incomingM := range incoming { + // 1) check incoming market is in list of allowed markets + var foundAllowedM bool + var allowedM AllowedMarket + for _, p := range ams { + if p.MarketID != incomingM.MarketID { + continue + } + foundAllowedM = true + allowedM = p + } + if !foundAllowedM { + // incoming had a Market that wasn't in the list of allowed ones + return false + } + + // 2) Check incoming changes are individually allowed + // find existing SupportedAsset + var foundCurrentM bool + var currentM pricefeed.Market + for _, p := range current { + if p.MarketID != incomingM.MarketID { + continue + } + foundCurrentM = true + currentM = p + } + if !foundCurrentM { + return false // not allowed to add market to list + } + // check changed values are all allowed + allowed := allowedM.Allows(currentM, incomingM) + + allAllowed = allAllowed && allowed + } + return allAllowed +} + +// AllowedMarket permission struct for market parameters (pricefeed module) +type AllowedMarket struct { + MarketID string `json:"market_id" yaml:"market_id"` + BaseAsset bool `json:"base_asset" yaml:"base_asset"` + QuoteAsset bool `json:"quote_asset" yaml:"quote_asset"` + Oracles bool `json:"oracles" yaml:"oracles"` + Active bool `json:"active" yaml:"active"` +} + +// Allows determines if market param changes are permitted +func (am AllowedMarket) Allows(current, incoming pricefeedtypes.Market) bool { + allowed := ((am.MarketID == current.MarketID) && (am.MarketID == incoming.MarketID)) && // require denoms to be all equal + ((current.BaseAsset == incoming.BaseAsset) || am.BaseAsset) && + ((current.QuoteAsset == incoming.QuoteAsset) || am.QuoteAsset) && + (addressesEqual(current.Oracles, incoming.Oracles) || am.Oracles) && + ((current.Active == incoming.Active) || am.Active) + return allowed +} + +// addressesEqual check if slices of addresses are equal, the order matters +func addressesEqual(addrs1, addrs2 []sdk.AccAddress) bool { + if len(addrs1) != len(addrs2) { + return false + } + areEqual := true + for i := range addrs1 { + areEqual = areEqual && addrs1[i].Equals(addrs2[i]) + } + return areEqual +} + +// AllowedMoneyMarket permission struct for money market parameters (hard module) +type AllowedMoneyMarket struct { + Denom string `json:"denom" yaml:"denom"` + BorrowLimit bool `json:"borrow_limit" yaml:"borrow_limit"` + SpotMarketID bool `json:"spot_market_id" yaml:"spot_market_id"` + ConversionFactor bool `json:"conversion_factor" yaml:"conversion_factor"` + InterestRateModel bool `json:"interest_rate_model" yaml:"interest_rate_model"` + ReserveFactor bool `json:"reserve_factor" yaml:"reserve_factor"` + KeeperRewardPercentage bool `json:"keeper_reward_percentage" yaml:"keeper_reward_percentage"` +} + +// NewAllowedMoneyMarket returns a new AllowedMoneyMarket +func NewAllowedMoneyMarket(denom string, bl, sm, cf, irm, rf, kr bool) AllowedMoneyMarket { + return AllowedMoneyMarket{ + Denom: denom, + BorrowLimit: bl, + SpotMarketID: sm, + ConversionFactor: cf, + InterestRateModel: irm, + ReserveFactor: rf, + KeeperRewardPercentage: kr, + } +} + +// Allows determines if money market param changes are permitted +func (amm AllowedMoneyMarket) Allows(current, incoming hard.MoneyMarket) bool { + allowed := ((amm.Denom == current.Denom) && (amm.Denom == incoming.Denom)) && + ((current.BorrowLimit.Equal(incoming.BorrowLimit)) || amm.BorrowLimit) && + ((current.SpotMarketID == incoming.SpotMarketID) || amm.SpotMarketID) && + ((current.ConversionFactor.Equal(incoming.ConversionFactor)) || amm.ConversionFactor) && + ((current.InterestRateModel.Equal(incoming.InterestRateModel)) || amm.InterestRateModel) && + ((current.ReserveFactor.Equal(incoming.ReserveFactor)) || amm.ReserveFactor) && + ((current.KeeperRewardPercentage.Equal(incoming.KeeperRewardPercentage)) || amm.KeeperRewardPercentage) + return allowed +} + +// AllowedMoneyMarkets slice of AllowedMoneyMarket +type AllowedMoneyMarkets []AllowedMoneyMarket + +// Allows determins if money market params changes are permitted +func (amms AllowedMoneyMarkets) Allows(current, incoming hard.MoneyMarkets) bool { + allAllowed := true + + if len(incoming) != len(current) { + return false + } + + for _, incomingMM := range incoming { + var foundAllowedMM bool + var allowedMM AllowedMoneyMarket + + for _, p := range amms { + if p.Denom != incomingMM.Denom { + continue + } + foundAllowedMM = true + allowedMM = p + } + if !foundAllowedMM { + return false + } + + var foundCurrentMM bool + var currentMM hard.MoneyMarket + + for _, p := range current { + if p.Denom != incomingMM.Denom { + continue + } + foundCurrentMM = true + currentMM = p + } + if !foundCurrentMM { + return false + } + allowed := allowedMM.Allows(currentMM, incomingMM) + allAllowed = allAllowed && allowed + } + + return allAllowed +} + +// GenesisState is state that must be provided at chain genesis. +type GenesisState struct { + NextProposalID uint64 `json:"next_proposal_id" yaml:"next_proposal_id"` + Committees []Committee `json:"committees" yaml:"committees"` + Proposals []Proposal `json:"proposals" yaml:"proposals"` + Votes []Vote `json:"votes" yaml:"votes"` +} + +// NewGenesisState returns a new genesis state object for the module. +func NewGenesisState(nextProposalID uint64, committees []Committee, proposals []Proposal, votes []Vote) GenesisState { + return GenesisState{ + NextProposalID: nextProposalID, + Committees: committees, + Proposals: proposals, + Votes: votes, + } +} + +// DefaultGenesisState returns the default genesis state for the module. +func DefaultGenesisState() GenesisState { + return NewGenesisState( + DefaultNextProposalID, + []Committee{}, + []Proposal{}, + []Vote{}, + ) +} + +// Equal checks whether two gov GenesisState structs are equivalent +func (data GenesisState) Equal(data2 GenesisState) bool { + b1 := v0_14cdp.ModuleCdc.MustMarshalBinaryBare(data) + b2 := v0_14cdp.ModuleCdc.MustMarshalBinaryBare(data2) + return bytes.Equal(b1, b2) +} + +// IsEmpty returns true if a GenesisState is empty +func (data GenesisState) IsEmpty() bool { + return data.Equal(GenesisState{}) +} + +// Validate performs basic validation of genesis data. +func (gs GenesisState) Validate() error { + // validate committees + committeeMap := make(map[uint64]bool, len(gs.Committees)) + for _, com := range gs.Committees { + // check there are no duplicate IDs + if _, ok := committeeMap[com.ID]; ok { + return fmt.Errorf("duplicate committee ID found in genesis state; id: %d", com.ID) + } + committeeMap[com.ID] = true + + // validate committee + if err := com.Validate(); err != nil { + return err + } + } + + // validate proposals + proposalMap := make(map[uint64]bool, len(gs.Proposals)) + for _, p := range gs.Proposals { + // check there are no duplicate IDs + if _, ok := proposalMap[p.ID]; ok { + return fmt.Errorf("duplicate proposal ID found in genesis state; id: %d", p.ID) + } + proposalMap[p.ID] = true + + // validate next proposal ID + if p.ID >= gs.NextProposalID { + return fmt.Errorf("NextProposalID is not greater than all proposal IDs; id: %d", p.ID) + } + + // check committee exists + if !committeeMap[p.CommitteeID] { + return fmt.Errorf("proposal refers to non existent committee; proposal: %+v", p) + } + + // validate pubProposal + if err := p.PubProposal.ValidateBasic(); err != nil { + return fmt.Errorf("proposal %d invalid: %w", p.ID, err) + } + } + + // validate votes + for _, v := range gs.Votes { + // validate committee + if err := v.Validate(); err != nil { + return err + } + + // check proposal exists + if !proposalMap[v.ProposalID] { + return fmt.Errorf("vote refers to non existent proposal; vote: %+v", v) + } + } + return nil +} + +// RegisterCodec registers the necessary types for the module +func RegisterCodec(cdc *codec.Codec) { + + // Proposals + cdc.RegisterInterface((*PubProposal)(nil), nil) + + // Permissions + cdc.RegisterInterface((*Permission)(nil), nil) + cdc.RegisterConcrete(GodPermission{}, "kava/GodPermission", nil) + cdc.RegisterConcrete(SimpleParamChangePermission{}, "kava/SimpleParamChangePermission", nil) + cdc.RegisterConcrete(TextPermission{}, "kava/TextPermission", nil) + cdc.RegisterConcrete(SoftwareUpgradePermission{}, "kava/SoftwareUpgradePermission", nil) + cdc.RegisterConcrete(SubParamChangePermission{}, "kava/SubParamChangePermission", nil) + + // Msgs + cdc.RegisterConcrete(MsgSubmitProposal{}, "kava/MsgSubmitProposal", nil) + cdc.RegisterConcrete(MsgVote{}, "kava/MsgVote", nil) +} + +// MsgSubmitProposal is used by committee members to create a new proposal that they can vote on. +type MsgSubmitProposal struct { + PubProposal PubProposal `json:"pub_proposal" yaml:"pub_proposal"` + Proposer sdk.AccAddress `json:"proposer" yaml:"proposer"` + CommitteeID uint64 `json:"committee_id" yaml:"committee_id"` +} + +// NewMsgSubmitProposal creates a new MsgSubmitProposal instance +func NewMsgSubmitProposal(pubProposal PubProposal, proposer sdk.AccAddress, committeeID uint64) MsgSubmitProposal { + return MsgSubmitProposal{ + PubProposal: pubProposal, + Proposer: proposer, + CommitteeID: committeeID, + } +} + +// Route return the message type used for routing the message. +func (msg MsgSubmitProposal) Route() string { return RouterKey } + +// Type returns a human-readable string for the message, intended for utilization within events. +func (msg MsgSubmitProposal) Type() string { return TypeMsgSubmitProposal } + +// ValidateBasic does a simple validation check that doesn't require access to any other information. +func (msg MsgSubmitProposal) ValidateBasic() error { + if msg.PubProposal == nil { + return sdkerrors.Wrap(ErrInvalidPubProposal, "pub proposal cannot be nil") + } + if msg.Proposer.Empty() { + return sdkerrors.Wrap(sdkerrors.ErrInvalidAddress, "proposer address cannot be empty") + } + + return msg.PubProposal.ValidateBasic() +} + +// GetSignBytes gets the canonical byte representation of the Msg. +func (msg MsgSubmitProposal) GetSignBytes() []byte { + bz := ModuleCdc.MustMarshalJSON(msg) + return sdk.MustSortJSON(bz) +} + +// GetSigners returns the addresses of signers that must sign. +func (msg MsgSubmitProposal) GetSigners() []sdk.AccAddress { + return []sdk.AccAddress{msg.Proposer} +} + +// MsgVote is submitted by committee members to vote on proposals. +type MsgVote struct { + ProposalID uint64 `json:"proposal_id" yaml:"proposal_id"` + Voter sdk.AccAddress `json:"voter" yaml:"voter"` +} + +// NewMsgVote creates a message to cast a vote on an active proposal +func NewMsgVote(voter sdk.AccAddress, proposalID uint64) MsgVote { + return MsgVote{proposalID, voter} +} + +// Route return the message type used for routing the message. +func (msg MsgVote) Route() string { return RouterKey } + +// Type returns a human-readable string for the message, intended for utilization within events. +func (msg MsgVote) Type() string { return TypeMsgVote } + +// ValidateBasic does a simple validation check that doesn't require access to any other information. +func (msg MsgVote) ValidateBasic() error { + if msg.Voter.Empty() { + return sdkerrors.Wrap(sdkerrors.ErrInvalidAddress, "voter address cannot be empty") + } + return nil +} + +// GetSignBytes gets the canonical byte representation of the Msg. +func (msg MsgVote) GetSignBytes() []byte { + bz := ModuleCdc.MustMarshalJSON(msg) + return sdk.MustSortJSON(bz) +} + +// GetSigners returns the addresses of signers that must sign. +func (msg MsgVote) GetSigners() []sdk.AccAddress { + return []sdk.AccAddress{msg.Voter} +} diff --git a/x/committee/module.go b/x/committee/module.go index cdb55138..ebb87bf1 100644 --- a/x/committee/module.go +++ b/x/committee/module.go @@ -95,9 +95,7 @@ func (AppModule) Name() string { } // RegisterInvariants register module invariants -func (am AppModule) RegisterInvariants(ir sdk.InvariantRegistry) { - RegisterInvariants(ir, am.keeper) -} +func (AppModule) RegisterInvariants(_ sdk.InvariantRegistry) {} // Route module message route name func (AppModule) Route() string { diff --git a/x/committee/proposal_handler.go b/x/committee/proposal_handler.go index e3043feb..bd282ed6 100644 --- a/x/committee/proposal_handler.go +++ b/x/committee/proposal_handler.go @@ -4,6 +4,7 @@ import ( sdk "github.com/cosmos/cosmos-sdk/types" sdkerrors "github.com/cosmos/cosmos-sdk/types/errors" govtypes "github.com/cosmos/cosmos-sdk/x/gov/types" + "github.com/kava-labs/kava/x/committee/types" ) func NewProposalHandler(k Keeper) govtypes.Handler { @@ -26,9 +27,9 @@ func handleCommitteeChangeProposal(ctx sdk.Context, k Keeper, committeeProposal } // Remove all committee's ongoing proposals - proposals := k.GetProposalsByCommittee(ctx, committeeProposal.NewCommittee.ID) + proposals := k.GetProposalsByCommittee(ctx, committeeProposal.NewCommittee.GetID()) for _, p := range proposals { - k.DeleteProposalAndVotes(ctx, p.ID) + k.CloseProposal(ctx, p, types.Failed) } // update/create the committee @@ -44,7 +45,7 @@ func handleCommitteeDeleteProposal(ctx sdk.Context, k Keeper, committeeProposal // Remove all committee's ongoing proposals proposals := k.GetProposalsByCommittee(ctx, committeeProposal.CommitteeID) for _, p := range proposals { - k.DeleteProposalAndVotes(ctx, p.ID) + k.CloseProposal(ctx, p, types.Failed) } k.DeleteCommittee(ctx, committeeProposal.CommitteeID) diff --git a/x/committee/proposal_handler_test.go b/x/committee/proposal_handler_test.go index 4fa97b26..15a4ff73 100644 --- a/x/committee/proposal_handler_test.go +++ b/x/committee/proposal_handler_test.go @@ -39,27 +39,33 @@ func (suite *ProposalHandlerTestSuite) SetupTest() { suite.testGenesis = committee.NewGenesisState( 2, []committee.Committee{ - { - ID: 1, - Description: "This committee is for testing.", - Members: suite.addresses[:3], - Permissions: []types.Permission{types.GodPermission{}}, - VoteThreshold: d("0.667"), - ProposalDuration: time.Hour * 24 * 7, + committee.MemberCommittee{ + BaseCommittee: committee.BaseCommittee{ + ID: 1, + Description: "This committee is for testing.", + Members: suite.addresses[:3], + Permissions: []types.Permission{types.GodPermission{}}, + VoteThreshold: d("0.667"), + ProposalDuration: time.Hour * 24 * 7, + TallyOption: types.FirstPastThePost, + }, }, - { - ID: 2, - Members: suite.addresses[2:], - Permissions: nil, - VoteThreshold: d("0.667"), - ProposalDuration: time.Hour * 24 * 7, + committee.MemberCommittee{ + BaseCommittee: committee.BaseCommittee{ + ID: 2, + Members: suite.addresses[2:], + Permissions: nil, + VoteThreshold: d("0.667"), + ProposalDuration: time.Hour * 24 * 7, + TallyOption: types.FirstPastThePost, + }, }, }, []committee.Proposal{ {ID: 1, CommitteeID: 1, PubProposal: gov.NewTextProposal("A Title", "A description of this proposal."), Deadline: testTime.Add(7 * 24 * time.Hour)}, }, []committee.Vote{ - {ProposalID: 1, Voter: suite.addresses[0]}, + {ProposalID: 1, Voter: suite.addresses[0], VoteType: types.Yes}, }, ) } @@ -75,11 +81,14 @@ func (suite *ProposalHandlerTestSuite) TestProposalHandler_ChangeCommittee() { proposal: committee.NewCommitteeChangeProposal( "A Title", "A proposal description.", - committee.Committee{ - ID: 34, - Members: suite.addresses[:1], - VoteThreshold: d("1"), - ProposalDuration: time.Hour * 24, + committee.MemberCommittee{ + BaseCommittee: committee.BaseCommittee{ + ID: 34, + Members: suite.addresses[:1], + VoteThreshold: d("1"), + ProposalDuration: time.Hour * 24, + TallyOption: types.FirstPastThePost, + }, }, ), expectPass: true, @@ -89,12 +98,16 @@ func (suite *ProposalHandlerTestSuite) TestProposalHandler_ChangeCommittee() { proposal: committee.NewCommitteeChangeProposal( "A Title", "A proposal description.", - committee.Committee{ - ID: suite.testGenesis.Committees[0].ID, - Members: suite.addresses, // add new members - Permissions: suite.testGenesis.Committees[0].Permissions, - VoteThreshold: suite.testGenesis.Committees[0].VoteThreshold, - ProposalDuration: suite.testGenesis.Committees[0].ProposalDuration, + committee.MemberCommittee{ + BaseCommittee: committee.BaseCommittee{ + ID: suite.testGenesis.Committees[0].GetID(), + Members: suite.addresses, // add new members + Permissions: suite.testGenesis.Committees[0].GetPermissions(), + VoteThreshold: suite.testGenesis.Committees[0].GetVoteThreshold(), + ProposalDuration: suite.testGenesis.Committees[0].GetProposalDuration(), + TallyOption: types.FirstPastThePost, + Type: types.MemberCommitteeType, + }, }, ), expectPass: true, @@ -113,12 +126,14 @@ func (suite *ProposalHandlerTestSuite) TestProposalHandler_ChangeCommittee() { proposal: committee.NewCommitteeChangeProposal( "A Title", "A proposal description.", - committee.Committee{ - ID: suite.testGenesis.Committees[0].ID, - Members: append(suite.addresses, suite.addresses[0]), // duplicate address - Permissions: suite.testGenesis.Committees[0].Permissions, - VoteThreshold: suite.testGenesis.Committees[0].VoteThreshold, - ProposalDuration: suite.testGenesis.Committees[0].ProposalDuration, + committee.MemberCommittee{ + BaseCommittee: committee.BaseCommittee{ + ID: suite.testGenesis.Committees[0].GetID(), + Members: append(suite.addresses, suite.addresses[0]), // duplicate address + Permissions: suite.testGenesis.Committees[0].GetPermissions(), + VoteThreshold: suite.testGenesis.Committees[0].GetVoteThreshold(), + ProposalDuration: suite.testGenesis.Committees[0].GetProposalDuration(), + }, }, ), expectPass: false, @@ -135,7 +150,7 @@ func (suite *ProposalHandlerTestSuite) TestProposalHandler_ChangeCommittee() { suite.ctx = suite.app.NewContext(true, abci.Header{Height: 1, Time: testTime}) handler := committee.NewProposalHandler(suite.keeper) - oldProposals := suite.keeper.GetProposalsByCommittee(suite.ctx, tc.proposal.NewCommittee.ID) + oldProposals := suite.keeper.GetProposalsByCommittee(suite.ctx, tc.proposal.NewCommittee.GetID()) // Run err := handler(suite.ctx, tc.proposal) @@ -144,12 +159,12 @@ func (suite *ProposalHandlerTestSuite) TestProposalHandler_ChangeCommittee() { if tc.expectPass { suite.NoError(err) // check committee is accurate - actualCom, found := suite.keeper.GetCommittee(suite.ctx, tc.proposal.NewCommittee.ID) + actualCom, found := suite.keeper.GetCommittee(suite.ctx, tc.proposal.NewCommittee.GetID()) suite.True(found) suite.Equal(tc.proposal.NewCommittee, actualCom) // check proposals and votes for this committee have been removed - suite.Empty(suite.keeper.GetProposalsByCommittee(suite.ctx, tc.proposal.NewCommittee.ID)) + suite.Empty(suite.keeper.GetProposalsByCommittee(suite.ctx, tc.proposal.NewCommittee.GetID())) for _, p := range oldProposals { suite.Empty(suite.keeper.GetVotesByProposal(suite.ctx, p.ID)) } @@ -172,7 +187,7 @@ func (suite *ProposalHandlerTestSuite) TestProposalHandler_DeleteCommittee() { proposal: committee.NewCommitteeDeleteProposal( "A Title", "A proposal description.", - suite.testGenesis.Committees[0].ID, + suite.testGenesis.Committees[0].GetID(), ), expectPass: true, }, @@ -181,7 +196,7 @@ func (suite *ProposalHandlerTestSuite) TestProposalHandler_DeleteCommittee() { proposal: committee.NewCommitteeDeleteProposal( "A Title That Is Much Too Long And Really Quite Unreasonable Given That It Is Trying To Fulfill The Roll Of An Acceptable Governance Proposal Title That Should Succinctly Communicate The Goal And Contents Of The Proposed Proposal To All Parties Involved", "A proposal description.", - suite.testGenesis.Committees[1].ID, + suite.testGenesis.Committees[1].GetID(), ), expectPass: false, }, diff --git a/x/committee/simulation/decoder_test.go b/x/committee/simulation/decoder_test.go index 3e361ac7..bd41a0e3 100644 --- a/x/committee/simulation/decoder_test.go +++ b/x/committee/simulation/decoder_test.go @@ -26,13 +26,14 @@ func makeTestCodec() (cdc *codec.Codec) { func TestDecodeStore(t *testing.T) { cdc := makeTestCodec() - committee := types.NewCommittee( + committee := types.NewMemberCommittee( 12, "This committee is for testing.", nil, []types.Permission{types.TextPermission{}}, sdk.MustNewDecFromStr("0.667"), time.Hour*24*7, + types.FirstPastThePost, ) proposal := types.Proposal{ ID: 34, diff --git a/x/committee/simulation/genesis.go b/x/committee/simulation/genesis.go index 8b7a9001..1886d517 100644 --- a/x/committee/simulation/genesis.go +++ b/x/committee/simulation/genesis.go @@ -3,6 +3,7 @@ package simulation import ( "fmt" "math/rand" + "strings" "time" "github.com/cosmos/cosmos-sdk/codec" @@ -32,13 +33,14 @@ func RandomizedGenState(simState *module.SimulationState) { // Without this, proposals can often not be submitted as there aren't any committees with the right set of permissions available. // It provides more control over how often different proposal types happen during simulation. // It also makes the code simpler--proposals can just be randomly generated and submitted without having to comply to permissions that happen to be available at the time. - fallbackCommittee := types.NewCommittee( + fallbackCommittee := types.NewMemberCommittee( FallbackCommitteeID, "A committee with god permissions that will always be in state and not deleted. It ensures any generated proposal can always be submitted and passed.", RandomAddresses(r, simState.Accounts), []types.Permission{types.GodPermission{}}, sdk.MustNewDecFromStr("0.5"), AverageBlockTime*10, + types.FirstPastThePost, ) // Create other committees @@ -66,7 +68,7 @@ func RandomizedGenState(simState *module.SimulationState) { func RandomCommittee(r *rand.Rand, availableAccs []simulation.Account, allowedParams []types.AllowedParam) (types.Committee, error) { // pick committee members if len(availableAccs) < 1 { - return types.Committee{}, fmt.Errorf("must be ≥ 1 addresses") + return types.MemberCommittee{}, fmt.Errorf("must be ≥ 1 addresses") } var members []sdk.AccAddress for len(members) < 1 { @@ -76,20 +78,40 @@ func RandomCommittee(r *rand.Rand, availableAccs []simulation.Account, allowedPa // pick proposal duration dur, err := RandomPositiveDuration(r, 0, AverageBlockTime*10) if err != nil { - return types.Committee{}, err + return types.MemberCommittee{}, err } // pick committee vote threshold, must be in interval (0,1] threshold := simulation.RandomDecAmount(r, sdk.MustNewDecFromStr("1").Sub(sdk.SmallestDec())).Add(sdk.SmallestDec()) - return types.NewCommittee( - r.Uint64(), // could collide with other committees, but unlikely - simulation.RandStringOfLength(r, r.Intn(types.MaxCommitteeDescriptionLength+1)), - members, - RandomPermissions(r, allowedParams), - threshold, - dur, - ), nil + var committee types.Committee + if r.Uint64()%2 == 0 { + committee = types.NewMemberCommittee( + r.Uint64(), // could collide with other committees, but unlikely + simulation.RandStringOfLength(r, r.Intn(types.MaxCommitteeDescriptionLength+1)), + members, + RandomPermissions(r, allowedParams), + threshold, + dur, + types.FirstPastThePost, + ) + } else { + tallyDenom := strings.ToLower(simulation.RandStringOfLength(r, (r.Intn(3) + 3))) + + committee = types.NewTokenCommittee( + r.Uint64(), // could collide with other committees, but unlikely + simulation.RandStringOfLength(r, r.Intn(types.MaxCommitteeDescriptionLength+1)), + members, + RandomPermissions(r, allowedParams), + threshold, + dur, + types.FirstPastThePost, + sdk.MustNewDecFromStr("0.25"), + tallyDenom, + ) + } + + return committee, nil } func RandomPermissions(r *rand.Rand, allowedParams []types.AllowedParam) []types.Permission { diff --git a/x/committee/simulation/operations.go b/x/committee/simulation/operations.go index 4322b659..74dd320d 100644 --- a/x/committee/simulation/operations.go +++ b/x/committee/simulation/operations.go @@ -69,7 +69,7 @@ func SimulateMsgSubmitProposal(cdc *codec.Codec, ak AccountKeeper, k keeper.Keep }) // move fallback committee to the end of slice for i, c := range committees { - if c.ID == FallbackCommitteeID { + if c.GetID() == FallbackCommitteeID { // switch places with last element committees[i], committees[len(committees)-1] = committees[len(committees)-1], committees[i] } @@ -94,11 +94,11 @@ func SimulateMsgSubmitProposal(cdc *codec.Codec, ak AccountKeeper, k keeper.Keep } // create the msg and tx - proposer := selectedCommittee.Members[r.Intn(len(selectedCommittee.Members))] // won't panic as committees must have ≥ 1 members + proposer := selectedCommittee.GetMembers()[r.Intn(len(selectedCommittee.GetMembers()))] // won't panic as committees must have ≥ 1 members msg := types.NewMsgSubmitProposal( pp, proposer, - selectedCommittee.ID, + selectedCommittee.GetID(), ) account := ak.GetAccount(ctx, proposer) fees, err := simulation.RandomFees(r, ctx, account.SpendableCoins(ctx.BlockTime())) @@ -138,15 +138,15 @@ func SimulateMsgSubmitProposal(cdc *codec.Codec, ak AccountKeeper, k keeper.Keep // pick the voters // num voters determined by whether the proposal should pass or not - numMembers := int64(len(selectedCommittee.Members)) - majority := selectedCommittee.VoteThreshold.Mul(sdk.NewInt(numMembers).ToDec()).Ceil().TruncateInt64() + numMembers := int64(len(selectedCommittee.GetMembers())) + majority := selectedCommittee.GetVoteThreshold().Mul(sdk.NewInt(numMembers).ToDec()).Ceil().TruncateInt64() numVoters := r.Int63n(majority) // in interval [0, majority) shouldPass := r.Float64() < proposalPassPercentage if shouldPass { numVoters = majority + r.Int63n(numMembers-majority+1) // in interval [majority, numMembers] } - voters := selectedCommittee.Members[:numVoters] + voters := selectedCommittee.GetMembers()[:numVoters] // schedule vote operations var futureOps []simulation.FutureOperation @@ -155,9 +155,17 @@ func SimulateMsgSubmitProposal(cdc *codec.Codec, ak AccountKeeper, k keeper.Keep if err != nil { return simulation.NoOpMsg(types.ModuleName), nil, fmt.Errorf("random time generation failed: %w", err) } + + // Valid vote types: 0, 1, 2 + randInt, err := RandIntInclusive(r, sdk.ZeroInt(), sdk.NewInt(2)) + if err != nil { + return simulation.NoOpMsg(types.ModuleName), nil, fmt.Errorf("random vote type generation failed: %w", err) + } + voteType := types.VoteType(randInt.Int64()) + fop := simulation.FutureOperation{ BlockTime: voteTime, - Op: SimulateMsgVote(k, ak, v, proposal.ID), + Op: SimulateMsgVote(k, ak, v, proposal.ID, voteType), } futureOps = append(futureOps, fop) } @@ -166,11 +174,11 @@ func SimulateMsgSubmitProposal(cdc *codec.Codec, ak AccountKeeper, k keeper.Keep } } -func SimulateMsgVote(k keeper.Keeper, ak AccountKeeper, voter sdk.AccAddress, proposalID uint64) simulation.Operation { +func SimulateMsgVote(k keeper.Keeper, ak AccountKeeper, voter sdk.AccAddress, proposalID uint64, voteType types.VoteType) simulation.Operation { return func(r *rand.Rand, app *baseapp.BaseApp, ctx sdk.Context, accs []simulation.Account, chainID string) ( opMsg simulation.OperationMsg, fOps []simulation.FutureOperation, err error) { - msg := types.NewMsgVote(voter, proposalID) + msg := types.NewMsgVote(voter, proposalID, voteType) account := ak.GetAccount(ctx, voter) fees, err := simulation.RandomFees(r, ctx, account.SpendableCoins(ctx.BlockTime())) diff --git a/x/committee/simulation/proposals.go b/x/committee/simulation/proposals.go index b0eb3a7a..ae213198 100644 --- a/x/committee/simulation/proposals.go +++ b/x/committee/simulation/proposals.go @@ -38,7 +38,7 @@ func SimulateCommitteeChangeProposalContent(k keeper.Keeper, paramChanges []simu // get current committees, ignoring the fallback committee var committees []types.Committee k.IterateCommittees(ctx, func(com types.Committee) bool { - if com.ID != FallbackCommitteeID { + if com.GetID() != FallbackCommitteeID { committees = append(committees, com) } return false @@ -85,11 +85,11 @@ func SimulateCommitteeChangeProposalContent(k keeper.Keeper, paramChanges []simu for len(members) < 1 { members = RandomAddresses(r, firstNAccounts(25, accs)) // limit num members to avoid overflowing hardcoded gov ops gas limit } - com.Members = members + com.SetMembers(members) } // update permissions if r.Intn(100) < 50 { - com.Permissions = RandomPermissions(r, allowedParams) + com.SetPermissions(RandomPermissions(r, allowedParams)) } // update proposal duration if r.Intn(100) < 50 { @@ -97,12 +97,12 @@ func SimulateCommitteeChangeProposalContent(k keeper.Keeper, paramChanges []simu if err != nil { panic(err) } - com.ProposalDuration = dur + com.SetProposalDuration(dur) } // update vote threshold if r.Intn(100) < 50 { // VoteThreshold must be in interval (0,1] - com.VoteThreshold = simulation.RandomDecAmount(r, sdk.MustNewDecFromStr("1").Sub(sdk.SmallestDec())).Add(sdk.SmallestDec()) + com.SetVoteThreshold(simulation.RandomDecAmount(r, sdk.MustNewDecFromStr("1").Sub(sdk.SmallestDec())).Add(sdk.SmallestDec())) } content = types.NewCommitteeChangeProposal( @@ -117,7 +117,7 @@ func SimulateCommitteeChangeProposalContent(k keeper.Keeper, paramChanges []simu content = types.NewCommitteeDeleteProposal( simulation.RandStringOfLength(r, 10), simulation.RandStringOfLength(r, 100), - com.ID, + com.GetID(), ) } diff --git a/x/committee/types/codec.go b/x/committee/types/codec.go index 737f9c8c..b9a8e150 100644 --- a/x/committee/types/codec.go +++ b/x/committee/types/codec.go @@ -34,6 +34,11 @@ func RegisterCodec(cdc *codec.Codec) { cdc.RegisterConcrete(CommitteeChangeProposal{}, "kava/CommitteeChangeProposal", nil) cdc.RegisterConcrete(CommitteeDeleteProposal{}, "kava/CommitteeDeleteProposal", nil) + // Committees + cdc.RegisterInterface((*Committee)(nil), nil) + cdc.RegisterConcrete(MemberCommittee{}, "kava/MemberCommittee", nil) + cdc.RegisterConcrete(TokenCommittee{}, "kava/TokenCommittee", nil) + // Permissions cdc.RegisterInterface((*Permission)(nil), nil) cdc.RegisterConcrete(GodPermission{}, "kava/GodPermission", nil) diff --git a/x/committee/types/committee.go b/x/committee/types/committee.go index f3afaa28..0b66655e 100644 --- a/x/committee/types/committee.go +++ b/x/committee/types/committee.go @@ -1,7 +1,9 @@ package types import ( + "encoding/json" "fmt" + "strings" "time" yaml "gopkg.in/yaml.v2" @@ -13,32 +15,169 @@ import ( const MaxCommitteeDescriptionLength int = 512 -// ------------------------------------------ -// Committees -// ------------------------------------------ +type TallyOption uint64 -// A Committee is a collection of addresses that are allowed to vote and enact any governance proposal that passes their permissions. -type Committee struct { +const ( + NullTallyOption TallyOption = iota + FirstPastThePost TallyOption = iota // Votes are tallied each block and the proposal passes as soon as the vote threshold is reached + Deadline TallyOption = iota // Votes are tallied exactly once, when the deadline time is reached +) + +const ( + MemberCommitteeType = "kava/MemberCommittee" // Committee is composed of member addresses that vote to enact proposals within their permissions + TokenCommitteeType = "kava/TokenCommittee" // Committee is composed of token holders with voting power determined by total token balance + BondDenom = "ukava" +) + +func init() { + // CommitteeChange/Delete proposals are registered on gov's ModuleCdc (see proposal.go). + // But since these proposals contain Committees, these types also need registering: + govtypes.ModuleCdc.RegisterInterface((*Committee)(nil), nil) + govtypes.RegisterProposalTypeCodec(MemberCommittee{}, "kava/MemberCommittee") + govtypes.RegisterProposalTypeCodec(TokenCommittee{}, "kava/TokenCommittee") +} + +// TallyOptionFromString returns a TallyOption from a string. It returns an error +// if the string is invalid. +func TallyOptionFromString(str string) (TallyOption, error) { + switch strings.ToLower(str) { + case "firstpastthepost", "fptp": + return FirstPastThePost, nil + + case "deadline", "d": + return Deadline, nil + + default: + return TallyOption(0xff), fmt.Errorf("'%s' is not a valid tally option", str) + } +} + +// Marshal needed for protobuf compatibility. +func (t TallyOption) Marshal() ([]byte, error) { + return []byte{byte(t)}, nil +} + +// Unmarshal needed for protobuf compatibility. +func (t *TallyOption) Unmarshal(data []byte) error { + *t = TallyOption(data[0]) + return nil +} + +// Marshals to JSON using string. +func (t TallyOption) MarshalJSON() ([]byte, error) { + return json.Marshal(t.String()) +} + +// UnmarshalJSON decodes from JSON assuming Bech32 encoding. +func (t *TallyOption) UnmarshalJSON(data []byte) error { + var s string + err := json.Unmarshal(data, &s) + if err != nil { + return err + } + + bz2, err := TallyOptionFromString(s) + if err != nil { + return err + } + + *t = bz2 + return nil +} + +// Marshals to YAML using string. +func (t TallyOption) MarshalYAML() ([]byte, error) { + return yaml.Marshal(t.String()) +} + +// UnmarshalJSON decodes from YAML assuming Bech32 encoding. +func (t *TallyOption) UnmarshalYAML(data []byte) error { + var s string + err := yaml.Unmarshal(data, &s) + if err != nil { + return err + } + + bz2, err := TallyOptionFromString(s) + if err != nil { + return err + } + + *t = bz2 + return nil +} + +// String implements the Stringer interface. +func (t TallyOption) String() string { + switch t { + case FirstPastThePost: + return "FirstPastThePost" + case Deadline: + return "Deadline" + default: + return "" + } +} + +// Committee is an interface for handling common actions on committees +type Committee interface { + GetID() uint64 + GetType() string + GetDescription() string + + GetMembers() []sdk.AccAddress + SetMembers([]sdk.AccAddress) BaseCommittee + HasMember(addr sdk.AccAddress) bool + + GetPermissions() []Permission + SetPermissions([]Permission) BaseCommittee + HasPermissionsFor(ctx sdk.Context, appCdc *codec.Codec, pk ParamKeeper, proposal PubProposal) bool + + GetProposalDuration() time.Duration + SetProposalDuration(time.Duration) BaseCommittee + + GetVoteThreshold() sdk.Dec + SetVoteThreshold(sdk.Dec) BaseCommittee + + GetTallyOption() TallyOption + Validate() error +} + +// Committees is a slice of committees +type Committees []Committee + +// BaseCommittee is a common type shared by all Committees +type BaseCommittee struct { + Type string `json:"type" yaml:"type"` ID uint64 `json:"id" yaml:"id"` Description string `json:"description" yaml:"description"` Members []sdk.AccAddress `json:"members" yaml:"members"` Permissions []Permission `json:"permissions" yaml:"permissions"` - VoteThreshold sdk.Dec `json:"vote_threshold" yaml:"vote_threshold"` // Smallest percentage of members that must vote for a proposal to pass. + VoteThreshold sdk.Dec `json:"vote_threshold" yaml:"vote_threshold"` // Smallest percentage that must vote for a proposal to pass ProposalDuration time.Duration `json:"proposal_duration" yaml:"proposal_duration"` // The length of time a proposal remains active for. Proposals will close earlier if they get enough votes. + TallyOption TallyOption `json:"tally_option" yaml:"tally_option"` } -func NewCommittee(id uint64, description string, members []sdk.AccAddress, permissions []Permission, threshold sdk.Dec, duration time.Duration) Committee { - return Committee{ - ID: id, - Description: description, - Members: members, - Permissions: permissions, - VoteThreshold: threshold, - ProposalDuration: duration, - } +// GetType is a getter for committee type +func (c BaseCommittee) GetType() string { return c.Type } + +// GetID is a getter for committee ID +func (c BaseCommittee) GetID() uint64 { return c.ID } + +// GetDescription is a getter for committee description +func (c BaseCommittee) GetDescription() string { return c.Description } + +// GetMembers is a getter for committee members +func (c BaseCommittee) GetMembers() []sdk.AccAddress { return c.Members } + +// SetMembers is a setter for committee members +func (c BaseCommittee) SetMembers(members []sdk.AccAddress) BaseCommittee { + c.Members = members + return c } -func (c Committee) HasMember(addr sdk.AccAddress) bool { +// HasMember returns if a committee contains a given member address +func (c BaseCommittee) HasMember(addr sdk.AccAddress) bool { for _, m := range c.Members { if m.Equals(addr) { return true @@ -47,9 +186,18 @@ func (c Committee) HasMember(addr sdk.AccAddress) bool { return false } +// GetPermissions is a getter for committee permissions +func (c BaseCommittee) GetPermissions() []Permission { return c.Permissions } + +// SetPermissions is a setter for committee permissions +func (c BaseCommittee) SetPermissions(permissions []Permission) BaseCommittee { + c.Permissions = permissions + return c +} + // HasPermissionsFor returns whether the committee is authorized to enact a proposal. // As long as one permission allows the proposal then it goes through. Its the OR of all permissions. -func (c Committee) HasPermissionsFor(ctx sdk.Context, appCdc *codec.Codec, pk ParamKeeper, proposal PubProposal) bool { +func (c BaseCommittee) HasPermissionsFor(ctx sdk.Context, appCdc *codec.Codec, pk ParamKeeper, proposal PubProposal) bool { for _, p := range c.Permissions { if p.Allows(ctx, appCdc, pk, proposal) { return true @@ -58,13 +206,42 @@ func (c Committee) HasPermissionsFor(ctx sdk.Context, appCdc *codec.Codec, pk Pa return false } -func (c Committee) Validate() error { +// GetVoteThreshold is a getter for committee VoteThreshold +func (c BaseCommittee) GetVoteThreshold() sdk.Dec { return c.VoteThreshold } + +// SetVoteThreshold is a setter for committee VoteThreshold +func (c BaseCommittee) SetVoteThreshold(voteThreshold sdk.Dec) BaseCommittee { + c.VoteThreshold = voteThreshold + return c +} + +// GetProposalDuration is a getter for committee ProposalDuration +func (c BaseCommittee) GetProposalDuration() time.Duration { return c.ProposalDuration } + +// SetProposalDuration is a setter for committee ProposalDuration +func (c BaseCommittee) SetProposalDuration(proposalDuration time.Duration) BaseCommittee { + c.ProposalDuration = proposalDuration + return c +} + +// GetTallyOption is a getter for committee TallyOption +func (c BaseCommittee) GetTallyOption() TallyOption { return c.TallyOption } + +// Validate validates BaseCommittee fields +func (c BaseCommittee) Validate() error { + if len(c.Description) > MaxCommitteeDescriptionLength { + return fmt.Errorf("description length %d longer than max allowed %d", len(c.Description), MaxCommitteeDescriptionLength) + } + + if len(c.Members) <= 0 { + return fmt.Errorf("committee must have members") + } addressMap := make(map[string]bool, len(c.Members)) for _, m := range c.Members { // check there are no duplicate members if _, ok := addressMap[m.String()]; ok { - return fmt.Errorf("committe cannot have duplicate members, %s", m) + return fmt.Errorf("committee cannot have duplicate members, %s", m) } // check for valid addresses if m.Empty() { @@ -73,32 +250,138 @@ func (c Committee) Validate() error { addressMap[m.String()] = true } - if len(c.Members) == 0 { - return fmt.Errorf("committee cannot have zero members") - } - - if len(c.Description) > MaxCommitteeDescriptionLength { - return fmt.Errorf("description length %d longer than max allowed %d", len(c.Description), MaxCommitteeDescriptionLength) - } - for _, p := range c.Permissions { if p == nil { return fmt.Errorf("committee cannot have a nil permission") } } - // threshold must be in the range (0,1] - if c.VoteThreshold.IsNil() || c.VoteThreshold.LTE(sdk.ZeroDec()) || c.VoteThreshold.GT(sdk.NewDec(1)) { - return fmt.Errorf("invalid threshold: %s", c.VoteThreshold) - } - if c.ProposalDuration < 0 { return fmt.Errorf("invalid proposal duration: %s", c.ProposalDuration) } + // threshold must be in the range [0, 1] + if c.VoteThreshold.IsNil() || c.VoteThreshold.LTE(sdk.ZeroDec()) || c.VoteThreshold.GT(sdk.NewDec(1)) { + return fmt.Errorf("invalid threshold: %s", c.VoteThreshold) + } + + if c.TallyOption <= 0 || c.TallyOption > 2 { + return fmt.Errorf("invalid tally option: %d", c.TallyOption) + } + return nil } +// String implements fmt.Stringer +func (c BaseCommittee) String() string { + return fmt.Sprintf(`Committee %d: + Type: %s + Description: %s + Members: %s + Permissions: %s + VoteThreshold: %s + ProposalDuration: %s + TallyOption: %s`, + c.ID, c.Type, c.Description, c.GetMembers(), c.Permissions, + c.VoteThreshold.String(), c.ProposalDuration.String(), + c.TallyOption.String(), + ) +} + +// MemberCommittee is an alias of BaseCommittee +type MemberCommittee struct { + BaseCommittee `json:"base_committee" yaml:"base_committee"` +} + +// NewMemberCommittee instantiates a new instance of MemberCommittee +func NewMemberCommittee(id uint64, description string, members []sdk.AccAddress, permissions []Permission, + threshold sdk.Dec, duration time.Duration, tallyOption TallyOption) MemberCommittee { + return MemberCommittee{ + BaseCommittee: BaseCommittee{ + Type: MemberCommitteeType, + ID: id, + Description: description, + Members: members, + Permissions: permissions, + VoteThreshold: threshold, + ProposalDuration: duration, + TallyOption: tallyOption, + }, + } +} + +// Validate validates the committee's fields +func (c MemberCommittee) Validate() error { + return c.BaseCommittee.Validate() +} + +// TokenCommittee supports voting on proposals by token holders +type TokenCommittee struct { + BaseCommittee `json:"base_committee" yaml:"base_committee"` + Quorum sdk.Dec `json:"quorum" yaml:"quorum"` + TallyDenom string `json:"tally_denom" yaml:"tally_denom"` +} + +// NewTokenCommittee instantiates a new instance of TokenCommittee +func NewTokenCommittee(id uint64, description string, members []sdk.AccAddress, permissions []Permission, + threshold sdk.Dec, duration time.Duration, tallyOption TallyOption, quorum sdk.Dec, tallyDenom string) TokenCommittee { + return TokenCommittee{ + BaseCommittee: BaseCommittee{ + Type: TokenCommitteeType, + ID: id, + Description: description, + Members: members, + Permissions: permissions, + VoteThreshold: threshold, + ProposalDuration: duration, + TallyOption: tallyOption, + }, + Quorum: quorum, + TallyDenom: tallyDenom, + } +} + +// GetQuorum returns the quorum of the committee +func (c TokenCommittee) GetQuorum() sdk.Dec { return c.Quorum } + +// GetTallyDenom returns the tally denom of the committee +func (c TokenCommittee) GetTallyDenom() string { return c.TallyDenom } + +// Validate validates the committee's fields +func (c TokenCommittee) Validate() error { + if c.TallyDenom == BondDenom { + return fmt.Errorf("invalid tally denom: %s", c.TallyDenom) + } + + err := sdk.ValidateDenom(c.TallyDenom) + if err != nil { + return err + } + + if c.Quorum.IsNil() || c.Quorum.IsNegative() || c.Quorum.GT(sdk.NewDec(1)) { + return fmt.Errorf("invalid quorum: %s", c.Quorum) + } + + return c.BaseCommittee.Validate() +} + +// String implements fmt.Stringer +func (c TokenCommittee) String() string { + return fmt.Sprintf(`Committee %d: + Type: %s + Description: %s + Permissions: %s + VoteThreshold: %s + ProposalDuration: %s + TallyOption: %d + Quorum: %s + TallyDenom: %s`, + c.ID, c.GetType(), c.Description, c.Permissions, + c.VoteThreshold.String(), c.ProposalDuration.String(), + c.TallyOption, c.Quorum, c.TallyDenom, + ) +} + // ------------------------------------------ // Proposals // ------------------------------------------ @@ -144,12 +427,14 @@ func (p Proposal) String() string { type Vote struct { ProposalID uint64 `json:"proposal_id" yaml:"proposal_id"` Voter sdk.AccAddress `json:"voter" yaml:"voter"` + VoteType VoteType `json:"vote_type" yaml:"vote_type"` } -func NewVote(proposalID uint64, voter sdk.AccAddress) Vote { +func NewVote(proposalID uint64, voter sdk.AccAddress, voteType VoteType) Vote { return Vote{ ProposalID: proposalID, Voter: voter, + VoteType: voteType, } } @@ -157,5 +442,6 @@ func (v Vote) Validate() error { if v.Voter.Empty() { return fmt.Errorf("voter address cannot be empty") } - return nil + + return v.VoteType.Validate() } diff --git a/x/committee/types/committee_test.go b/x/committee/types/committee_test.go new file mode 100644 index 00000000..223b15a2 --- /dev/null +++ b/x/committee/types/committee_test.go @@ -0,0 +1,342 @@ +package types + +import ( + "fmt" + "testing" + "time" + + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/stretchr/testify/require" + "github.com/tendermint/tendermint/crypto" +) + +func TestBaseCommittee(t *testing.T) { + addresses := []sdk.AccAddress{ + sdk.AccAddress(crypto.AddressHash([]byte("KavaTest1"))), + sdk.AccAddress(crypto.AddressHash([]byte("KavaTest2"))), + sdk.AccAddress(crypto.AddressHash([]byte("KavaTest3"))), + } + + testCases := []struct { + name string + committee BaseCommittee + expectPass bool + }{ + { + name: "normal", + committee: BaseCommittee{ + ID: 1, + Description: "This base committee is for testing.", + Members: addresses[:3], + Permissions: []Permission{GodPermission{}}, + VoteThreshold: d("0.667"), + ProposalDuration: time.Hour * 24 * 7, + TallyOption: FirstPastThePost, + }, + expectPass: true, + }, + { + name: "description length too long", + committee: BaseCommittee{ + ID: 1, + Description: fmt.Sprintln("This base committee has a long description.", + "This base committee has a long description. This base committee has a long description.", + "This base committee has a long description. This base committee has a long description.", + "This base committee has a long description. This base committee has a long description.", + "This base committee has a long description. This base committee has a long description.", + "This base committee has a long description. This base committee has a long description.", + "This base committee has a long description. This base committee has a long description.", + "This base committee has a long description. This base committee has a long description."), + Members: addresses[:3], + Permissions: []Permission{GodPermission{}}, + VoteThreshold: d("0.667"), + ProposalDuration: time.Hour * 24 * 7, + TallyOption: FirstPastThePost, + }, + expectPass: false, + }, + { + name: "no members", + committee: BaseCommittee{ + ID: 1, + Description: "This base committee is for testing.", + Members: []sdk.AccAddress{}, + Permissions: []Permission{GodPermission{}}, + VoteThreshold: d("0.667"), + ProposalDuration: time.Hour * 24 * 7, + TallyOption: FirstPastThePost, + }, + expectPass: false, + }, + { + name: "duplicate member", + committee: BaseCommittee{ + ID: 1, + Description: "This base committee is for testing.", + Members: []sdk.AccAddress{addresses[2], addresses[2]}, + Permissions: []Permission{GodPermission{}}, + VoteThreshold: d("0.667"), + ProposalDuration: time.Hour * 24 * 7, + TallyOption: FirstPastThePost, + }, + expectPass: false, + }, + { + name: "nil permissions", + committee: BaseCommittee{ + ID: 1, + Description: "This base committee is for testing.", + Members: addresses[:3], + Permissions: []Permission{nil}, + VoteThreshold: d("0.667"), + ProposalDuration: time.Hour * 24 * 7, + TallyOption: FirstPastThePost, + }, + expectPass: false, + }, + { + name: "negative proposal duration", + committee: BaseCommittee{ + ID: 1, + Description: "This base committee is for testing.", + Members: addresses[:3], + Permissions: []Permission{GodPermission{}}, + VoteThreshold: d("0.667"), + ProposalDuration: time.Hour * 24 * -7, + TallyOption: FirstPastThePost, + }, + expectPass: false, + }, + { + name: "vote threshold is nil", + committee: BaseCommittee{ + ID: 1, + Description: "This base committee is for testing.", + Members: addresses[:3], + Permissions: []Permission{GodPermission{}}, + VoteThreshold: sdk.Dec{Int: nil}, + ProposalDuration: time.Hour * 24 * 7, + TallyOption: FirstPastThePost, + }, + expectPass: false, + }, + { + name: "vote threshold is 0", + committee: BaseCommittee{ + ID: 1, + Description: "This base committee is for testing.", + Members: addresses[:3], + Permissions: []Permission{GodPermission{}}, + VoteThreshold: d("0"), + ProposalDuration: time.Hour * 24 * 7, + TallyOption: FirstPastThePost, + }, + expectPass: false, + }, + { + name: "vote threshold above 1", + committee: BaseCommittee{ + ID: 1, + Description: "This base committee is for testing.", + Members: addresses[:3], + Permissions: []Permission{GodPermission{}}, + VoteThreshold: d("1.001"), + ProposalDuration: time.Hour * 24 * 7, + TallyOption: FirstPastThePost, + }, + expectPass: false, + }, + { + name: "invalid tally option", + committee: BaseCommittee{ + ID: 1, + Description: "This base committee is for testing.", + Members: addresses[:3], + Permissions: []Permission{GodPermission{}}, + VoteThreshold: d("0.667"), + ProposalDuration: time.Hour * 24 * 7, + TallyOption: NullTallyOption, + }, + expectPass: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + + err := tc.committee.Validate() + + if tc.expectPass { + require.NoError(t, err) + } else { + require.Error(t, err) + } + }) + } +} + +// TestMemberCommittee is an alias for BaseCommittee that has 'MemberCommittee' type +func TestMemberCommittee(t *testing.T) { + addresses := []sdk.AccAddress{ + sdk.AccAddress(crypto.AddressHash([]byte("KavaTest1"))), + sdk.AccAddress(crypto.AddressHash([]byte("KavaTest2"))), + sdk.AccAddress(crypto.AddressHash([]byte("KavaTest3"))), + } + + testCases := []struct { + name string + committee MemberCommittee + expectPass bool + }{ + { + name: "normal", + committee: MemberCommittee{ + BaseCommittee: BaseCommittee{ + ID: 1, + Description: "This member committee is for testing.", + Members: addresses[:3], + Permissions: []Permission{GodPermission{}}, + VoteThreshold: d("0.667"), + ProposalDuration: time.Hour * 24 * 7, + TallyOption: FirstPastThePost, + Type: MemberCommitteeType, + }, + }, + expectPass: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + + require.Equal(t, MemberCommitteeType, tc.committee.GetType()) + + err := tc.committee.Validate() + if tc.expectPass { + require.NoError(t, err) + } else { + require.Error(t, err) + } + }) + } +} + +// TestTokenCommittee tests unique TokenCommittee functionality +func TestTokenCommittee(t *testing.T) { + addresses := []sdk.AccAddress{ + sdk.AccAddress(crypto.AddressHash([]byte("KavaTest1"))), + sdk.AccAddress(crypto.AddressHash([]byte("KavaTest2"))), + sdk.AccAddress(crypto.AddressHash([]byte("KavaTest3"))), + } + + testCases := []struct { + name string + committee TokenCommittee + expectPass bool + }{ + { + name: "normal", + committee: TokenCommittee{ + BaseCommittee: BaseCommittee{ + ID: 1, + Description: "This token committee is for testing.", + Members: addresses[:3], + Permissions: []Permission{GodPermission{}}, + VoteThreshold: d("0.667"), + ProposalDuration: time.Hour * 24 * 7, + TallyOption: FirstPastThePost, + Type: TokenCommitteeType, + }, + Quorum: d("0.4"), + TallyDenom: "hard", + }, + expectPass: true, + }, + { + name: "nil quorum", + committee: TokenCommittee{ + BaseCommittee: BaseCommittee{ + ID: 1, + Description: "This token committee is for testing.", + Members: addresses[:3], + Permissions: []Permission{GodPermission{}}, + VoteThreshold: d("0.667"), + ProposalDuration: time.Hour * 24 * 7, + TallyOption: FirstPastThePost, + Type: TokenCommitteeType, + }, + Quorum: sdk.Dec{Int: nil}, + TallyDenom: "hard", + }, + expectPass: false, + }, + { + name: "negative quorum", + committee: TokenCommittee{ + BaseCommittee: BaseCommittee{ + ID: 1, + Description: "This token committee is for testing.", + Members: addresses[:3], + Permissions: []Permission{GodPermission{}}, + VoteThreshold: d("0.667"), + ProposalDuration: time.Hour * 24 * 7, + TallyOption: FirstPastThePost, + Type: TokenCommitteeType, + }, + Quorum: d("-0.1"), + TallyDenom: "hard", + }, + expectPass: false, + }, + { + name: "quroum greater than 1", + committee: TokenCommittee{ + BaseCommittee: BaseCommittee{ + ID: 1, + Description: "This token committee is for testing.", + Members: addresses[:3], + Permissions: []Permission{GodPermission{}}, + VoteThreshold: d("0.667"), + ProposalDuration: time.Hour * 24 * 7, + TallyOption: FirstPastThePost, + Type: TokenCommitteeType, + }, + Quorum: d("1.001"), + TallyDenom: "hard", + }, + expectPass: false, + }, + { + name: "bond denom as tally denom", + committee: TokenCommittee{ + BaseCommittee: BaseCommittee{ + ID: 1, + Description: "This token committee is for testing.", + Members: addresses[:3], + Permissions: []Permission{GodPermission{}}, + VoteThreshold: d("0.667"), + ProposalDuration: time.Hour * 24 * 7, + TallyOption: FirstPastThePost, + Type: TokenCommitteeType, + }, + Quorum: d("0.4"), + TallyDenom: BondDenom, + }, + expectPass: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + + require.Equal(t, TokenCommitteeType, tc.committee.GetType()) + + err := tc.committee.Validate() + if tc.expectPass { + require.NoError(t, err) + } else { + require.Error(t, err) + } + }) + } +} diff --git a/x/committee/types/errors.go b/x/committee/types/errors.go index e95779c0..f13b6e43 100644 --- a/x/committee/types/errors.go +++ b/x/committee/types/errors.go @@ -14,4 +14,5 @@ var ( ErrInvalidGenesis = sdkerrors.Register(ModuleName, 8, "invalid genesis") ErrNoProposalHandlerExists = sdkerrors.Register(ModuleName, 9, "pubproposal has no corresponding handler") ErrUnknownSubspace = sdkerrors.Register(ModuleName, 10, "subspace not found") + ErrInvalidVoteType = sdkerrors.Register(ModuleName, 11, "invalid vote type") ) diff --git a/x/committee/types/events.go b/x/committee/types/events.go index 777256a9..6794859c 100644 --- a/x/committee/types/events.go +++ b/x/committee/types/events.go @@ -9,9 +9,9 @@ const ( AttributeValueCategory = "committee" AttributeKeyCommitteeID = "committee_id" AttributeKeyProposalID = "proposal_id" + AttributeKeyDeadline = "deadline" AttributeKeyProposalCloseStatus = "status" AttributeKeyVoter = "voter" - AttributeValueProposalPassed = "proposal_passed" - AttributeValueProposalTimeout = "proposal_timeout" - AttributeValueProposalFailed = "proposal_failed" + AttributeKeyVote = "vote" + AttributeKeyProposalOutcome = "proposal_outcome" ) diff --git a/x/committee/types/expected_keepers.go b/x/committee/types/expected_keepers.go index 88d02e5d..327106f2 100644 --- a/x/committee/types/expected_keepers.go +++ b/x/committee/types/expected_keepers.go @@ -1,9 +1,22 @@ package types import ( + sdk "github.com/cosmos/cosmos-sdk/types" + authexported "github.com/cosmos/cosmos-sdk/x/auth/exported" "github.com/cosmos/cosmos-sdk/x/params" + supplyexported "github.com/cosmos/cosmos-sdk/x/supply/exported" ) type ParamKeeper interface { GetSubspace(string) (params.Subspace, bool) } + +// AccountKeeper defines the expected account keeper (noalias) +type AccountKeeper interface { + GetAccount(ctx sdk.Context, addr sdk.AccAddress) authexported.Account +} + +// SupplyKeeper defines the expected supply keeper (noalias) +type SupplyKeeper interface { + GetSupply(ctx sdk.Context) (supply supplyexported.SupplyI) +} diff --git a/x/committee/types/genesis.go b/x/committee/types/genesis.go index 7205ac02..f08b4707 100644 --- a/x/committee/types/genesis.go +++ b/x/committee/types/genesis.go @@ -10,14 +10,14 @@ const DefaultNextProposalID uint64 = 1 // GenesisState is state that must be provided at chain genesis. type GenesisState struct { - NextProposalID uint64 `json:"next_proposal_id" yaml:"next_proposal_id"` - Committees []Committee `json:"committees" yaml:"committees"` - Proposals []Proposal `json:"proposals" yaml:"proposals"` - Votes []Vote `json:"votes" yaml:"votes"` + NextProposalID uint64 `json:"next_proposal_id" yaml:"next_proposal_id"` + Committees Committees `json:"committees" yaml:"committees"` + Proposals []Proposal `json:"proposals" yaml:"proposals"` + Votes []Vote `json:"votes" yaml:"votes"` } // NewGenesisState returns a new genesis state object for the module. -func NewGenesisState(nextProposalID uint64, committees []Committee, proposals []Proposal, votes []Vote) GenesisState { +func NewGenesisState(nextProposalID uint64, committees Committees, proposals []Proposal, votes []Vote) GenesisState { return GenesisState{ NextProposalID: nextProposalID, Committees: committees, @@ -30,7 +30,7 @@ func NewGenesisState(nextProposalID uint64, committees []Committee, proposals [] func DefaultGenesisState() GenesisState { return NewGenesisState( DefaultNextProposalID, - []Committee{}, + Committees{}, []Proposal{}, []Vote{}, ) @@ -54,10 +54,10 @@ func (gs GenesisState) Validate() error { committeeMap := make(map[uint64]bool, len(gs.Committees)) for _, com := range gs.Committees { // check there are no duplicate IDs - if _, ok := committeeMap[com.ID]; ok { - return fmt.Errorf("duplicate committee ID found in genesis state; id: %d", com.ID) + if _, ok := committeeMap[com.GetID()]; ok { + return fmt.Errorf("duplicate committee ID found in genesis state; id: %d", com.GetID()) } - committeeMap[com.ID] = true + committeeMap[com.GetID()] = true // validate committee if err := com.Validate(); err != nil { diff --git a/x/committee/types/genesis_test.go b/x/committee/types/genesis_test.go index f3bb4cda..195224eb 100644 --- a/x/committee/types/genesis_test.go +++ b/x/committee/types/genesis_test.go @@ -21,32 +21,55 @@ func TestGenesisState_Validate(t *testing.T) { sdk.AccAddress(crypto.AddressHash([]byte("KavaTest4"))), sdk.AccAddress(crypto.AddressHash([]byte("KavaTest5"))), } + testGenesis := GenesisState{ NextProposalID: 2, - Committees: []Committee{ - { - ID: 1, - Description: "This committee is for testing.", - Members: addresses[:3], - Permissions: []Permission{GodPermission{}}, - VoteThreshold: d("0.667"), - ProposalDuration: time.Hour * 24 * 7, + Committees: Committees{ + MemberCommittee{ + BaseCommittee: BaseCommittee{ + ID: 1, + Description: "This members committee is for testing.", + Members: addresses[:3], + Permissions: []Permission{GodPermission{}}, + VoteThreshold: d("0.667"), + ProposalDuration: time.Hour * 24 * 7, + Type: MemberCommitteeType, + TallyOption: FirstPastThePost, + }, }, - { - ID: 2, - Description: "This committee is also for testing.", - Members: addresses[2:], - Permissions: nil, - VoteThreshold: d("0.8"), - ProposalDuration: time.Hour * 24 * 21, + MemberCommittee{ + BaseCommittee: BaseCommittee{ + ID: 2, + Description: "This members committee is also for testing.", + Members: addresses[:3], + Permissions: nil, + VoteThreshold: d("0.8"), + ProposalDuration: time.Hour * 24 * 21, + Type: MemberCommitteeType, + TallyOption: FirstPastThePost, + }, + }, + TokenCommittee{ + BaseCommittee: BaseCommittee{ + ID: 3, + Description: "This token committee is for testing.", + Members: addresses[:3], + Permissions: nil, + VoteThreshold: d("0.8"), + ProposalDuration: time.Hour * 24 * 21, + Type: TokenCommitteeType, + TallyOption: Deadline, + }, + Quorum: sdk.MustNewDecFromStr("0.4"), + TallyDenom: "hard", }, }, Proposals: []Proposal{ {ID: 1, CommitteeID: 1, PubProposal: govtypes.NewTextProposal("A Title", "A description of this proposal."), Deadline: testTime.Add(7 * 24 * time.Hour)}, }, Votes: []Vote{ - {ProposalID: 1, Voter: addresses[0]}, - {ProposalID: 1, Voter: addresses[1]}, + {ProposalID: 1, Voter: addresses[0], VoteType: Yes}, + {ProposalID: 1, Voter: addresses[1], VoteType: Yes}, }, } @@ -79,7 +102,7 @@ func TestGenesisState_Validate(t *testing.T) { name: "invalid committee", genState: GenesisState{ NextProposalID: testGenesis.NextProposalID, - Committees: append(testGenesis.Committees, Committee{}), + Committees: append(testGenesis.Committees, MemberCommittee{}), Proposals: testGenesis.Proposals, Votes: testGenesis.Votes, }, diff --git a/x/committee/types/msg.go b/x/committee/types/msg.go index 50ac64f6..4d560b7c 100644 --- a/x/committee/types/msg.go +++ b/x/committee/types/msg.go @@ -1,8 +1,13 @@ package types import ( + "encoding/json" + "fmt" + "strings" + sdk "github.com/cosmos/cosmos-sdk/types" sdkerrors "github.com/cosmos/cosmos-sdk/types/errors" + yaml "gopkg.in/yaml.v2" ) const ( @@ -57,15 +62,119 @@ func (msg MsgSubmitProposal) GetSigners() []sdk.AccAddress { return []sdk.AccAddress{msg.Proposer} } +type VoteType uint64 + +const ( + NullVoteType VoteType = iota // 0 + Yes VoteType = iota // 1 + No VoteType = iota // 2 + Abstain VoteType = iota // 3 +) + +// VoteTypeFromString returns a VoteType from a string. It returns an error +// if the string is invalid. +func VoteTypeFromString(str string) (VoteType, error) { + switch strings.ToLower(str) { + case "yes", "y": + return Yes, nil + + case "abstain", "a": + return Abstain, nil + + case "no", "n": + return No, nil + + default: + return VoteType(0xff), fmt.Errorf("'%s' is not a valid vote type", str) + } +} + +// Marshal needed for protobuf compatibility. +func (vt VoteType) Marshal() ([]byte, error) { + return []byte{byte(vt)}, nil +} + +// Unmarshal needed for protobuf compatibility. +func (vt *VoteType) Unmarshal(data []byte) error { + *vt = VoteType(data[0]) + return nil +} + +// Marshals to JSON using string. +func (vt VoteType) MarshalJSON() ([]byte, error) { + return json.Marshal(vt.String()) +} + +// UnmarshalJSON decodes from JSON assuming Bech32 encoding. +func (vt *VoteType) UnmarshalJSON(data []byte) error { + var s string + err := json.Unmarshal(data, &s) + if err != nil { + return err + } + + bz2, err := VoteTypeFromString(s) + if err != nil { + return err + } + + *vt = bz2 + return nil +} + +// Marshals to YAML using string. +func (vt VoteType) MarshalYAML() ([]byte, error) { + return yaml.Marshal(vt.String()) +} + +// UnmarshalJSON decodes from YAML assuming Bech32 encoding. +func (vt *VoteType) UnmarshalYAML(data []byte) error { + var s string + err := yaml.Unmarshal(data, &s) + if err != nil { + return err + } + + bz2, err := VoteTypeFromString(s) + if err != nil { + return err + } + + *vt = bz2 + return nil +} + +// String implements the Stringer interface. +func (vt VoteType) String() string { + switch vt { + case Yes: + return "Yes" + case Abstain: + return "Abstain" + case No: + return "No" + default: + return "" + } +} + +func (vt VoteType) Validate() error { + if vt <= 0 || vt > 3 { + return fmt.Errorf("invalid vote type: %d", vt) + } + return nil +} + // MsgVote is submitted by committee members to vote on proposals. type MsgVote struct { ProposalID uint64 `json:"proposal_id" yaml:"proposal_id"` Voter sdk.AccAddress `json:"voter" yaml:"voter"` + VoteType VoteType `json:"vote_type" yaml:"vote_type"` } // NewMsgVote creates a message to cast a vote on an active proposal -func NewMsgVote(voter sdk.AccAddress, proposalID uint64) MsgVote { - return MsgVote{proposalID, voter} +func NewMsgVote(voter sdk.AccAddress, proposalID uint64, voteType VoteType) MsgVote { + return MsgVote{proposalID, voter, voteType} } // Route return the message type used for routing the message. @@ -79,7 +188,8 @@ func (msg MsgVote) ValidateBasic() error { if msg.Voter.Empty() { return sdkerrors.Wrap(sdkerrors.ErrInvalidAddress, "voter address cannot be empty") } - return nil + + return msg.VoteType.Validate() } // GetSignBytes gets the canonical byte representation of the Msg. diff --git a/x/committee/types/msg_test.go b/x/committee/types/msg_test.go index b4c6a585..e5c8c0f3 100644 --- a/x/committee/types/msg_test.go +++ b/x/committee/types/msg_test.go @@ -56,12 +56,32 @@ func TestMsgVote_ValidateBasic(t *testing.T) { }{ { name: "normal", - msg: MsgVote{5, addr}, + msg: MsgVote{5, addr, Yes}, expectPass: true, }, + { + name: "No", + msg: MsgVote{5, addr, No}, + expectPass: true, + }, + { + name: "Abstain", + msg: MsgVote{5, addr, Abstain}, + expectPass: true, + }, + { + name: "Null vote", + msg: MsgVote{5, addr, NullVoteType}, + expectPass: false, + }, { name: "empty address", - msg: MsgVote{5, nil}, + msg: MsgVote{5, nil, Yes}, + expectPass: false, + }, + { + name: "invalid vote (greater)", + msg: MsgVote{5, addr, 4}, expectPass: false, }, } diff --git a/x/committee/types/proposal.go b/x/committee/types/proposal.go index ebc1baaf..54ce382b 100644 --- a/x/committee/types/proposal.go +++ b/x/committee/types/proposal.go @@ -1,8 +1,11 @@ package types import ( + "bytes" + yaml "gopkg.in/yaml.v2" + "github.com/cosmos/cosmos-sdk/codec" sdkerrors "github.com/cosmos/cosmos-sdk/types/errors" govtypes "github.com/cosmos/cosmos-sdk/x/gov/types" ) @@ -12,6 +15,61 @@ const ( ProposalTypeCommitteeDelete = "CommitteeDelete" ) +// ProposalOutcome indicates the status of a proposal when it's closed and deleted from the store +type ProposalOutcome uint64 + +const ( + // Passed indicates that the proposal passed and was successfully enacted + Passed ProposalOutcome = iota + // Failed indicates that the proposal failed and was not enacted + Failed + // Invalid indicates that proposal passed but an error occurred when attempting to enact it + Invalid +) + +var toString = map[ProposalOutcome]string{ + Passed: "Passed", + Failed: "Failed", + Invalid: "Invalid", +} + +func (p ProposalOutcome) String() string { + return toString[p] +} + +func (p ProposalOutcome) Marshal(cdc *codec.Codec) ([]byte, error) { + x, err := cdc.MarshalJSON(p.String()) + if err != nil { + return []byte{}, err + } + return x[1 : len(x)-1], nil +} + +func MatchMarshaledOutcome(value []byte, cdc *codec.Codec) (ProposalOutcome, error) { + passed, err := Passed.Marshal(cdc) + if err != nil { + return 0, err + } + if bytes.Compare(passed, value) == 0 { + return Passed, nil + } + failed, err := Failed.Marshal(cdc) + if err != nil { + return 0, err + } + if bytes.Compare(failed, value) == 0 { + return Failed, nil + } + invalid, err := Invalid.Marshal(cdc) + if err != nil { + return 0, err + } + if bytes.Compare(invalid, value) == 0 { + return Invalid, nil + } + return 0, nil +} + // ensure proposal types fulfill the PubProposal interface and the gov Content interface. var _, _ govtypes.Content = CommitteeChangeProposal{}, CommitteeDeleteProposal{} var _, _ PubProposal = CommitteeChangeProposal{}, CommitteeDeleteProposal{} diff --git a/x/committee/types/querier.go b/x/committee/types/querier.go index 29970036..557c6342 100644 --- a/x/committee/types/querier.go +++ b/x/committee/types/querier.go @@ -60,3 +60,24 @@ func NewQueryRawParamsParams(subspace, key string) QueryRawParamsParams { Key: key, } } + +type ProposalPollingStatus struct { + ProposalID uint64 `json:"proposal_id" yaml:"proposal_id"` + YesVotes sdk.Dec `json:"yes_votes" yaml:"yes_votes"` + CurrentVotes sdk.Dec `json:"current_votes" yaml:"current_votes"` + PossibleVotes sdk.Dec `json:"possible_votes" yaml:"possible_votes"` + VoteThreshold sdk.Dec `json:"vote_threshold" yaml:"vote_threshold"` + Quorum sdk.Dec `json:"quorum" yaml:"quorum"` +} + +func NewProposalPollingStatus(proposalID uint64, yesVotes, currentVotes, possibleVotes, + voteThreshold, quorum sdk.Dec) ProposalPollingStatus { + return ProposalPollingStatus{ + ProposalID: proposalID, + YesVotes: yesVotes, + CurrentVotes: currentVotes, + PossibleVotes: possibleVotes, + VoteThreshold: voteThreshold, + Quorum: quorum, + } +} diff --git a/x/incentive/keeper/integration_test.go b/x/incentive/keeper/integration_test.go index 91d40f97..80bb29a7 100644 --- a/x/incentive/keeper/integration_test.go +++ b/x/incentive/keeper/integration_test.go @@ -222,13 +222,16 @@ func (suite *KeeperTestSuite) SetupWithGenState() { // Set up a god committee committeeModKeeper := tApp.GetCommitteeKeeper() - godCommittee := committeetypes.Committee{ - ID: 1, - Description: "This committee is for testing.", - Members: suite.addrs[:2], - Permissions: []committeetypes.Permission{committeetypes.GodPermission{}}, - VoteThreshold: d("0.667"), - ProposalDuration: time.Hour * 24 * 7, + godCommittee := committeetypes.MemberCommittee{ + BaseCommittee: committeetypes.BaseCommittee{ + ID: 1, + Description: "This committee is for testing.", + Members: suite.addrs[:2], + Permissions: []committeetypes.Permission{committeetypes.GodPermission{}}, + VoteThreshold: d("0.667"), + ProposalDuration: time.Hour * 24 * 7, + TallyOption: committeetypes.FirstPastThePost, + }, } committeeModKeeper.SetCommittee(ctx, godCommittee) diff --git a/x/incentive/keeper/rewards_borrow_test.go b/x/incentive/keeper/rewards_borrow_test.go index 31799ea2..e5f643b4 100644 --- a/x/incentive/keeper/rewards_borrow_test.go +++ b/x/incentive/keeper/rewards_borrow_test.go @@ -741,11 +741,13 @@ func (suite *KeeperTestSuite) TestSynchronizeHardBorrowReward() { suite.Require().NoError(err) // 5. Committee votes and passes proposal - err = suite.committeeKeeper.AddVote(suite.ctx, proposalID, committeeMemberOne) - err = suite.committeeKeeper.AddVote(suite.ctx, proposalID, committeeMemberTwo) + err = suite.committeeKeeper.AddVote(suite.ctx, proposalID, committeeMemberOne, committee.Yes) + err = suite.committeeKeeper.AddVote(suite.ctx, proposalID, committeeMemberTwo, committee.Yes) // 6. Check proposal passed - proposalPasses, err := suite.committeeKeeper.GetProposalResult(suite.ctx, proposalID) + com, found := suite.committeeKeeper.GetCommittee(suite.ctx, 1) + suite.Require().True(found) + proposalPasses := suite.committeeKeeper.GetProposalResult(suite.ctx, proposalID, com) suite.Require().NoError(err) suite.Require().True(proposalPasses) diff --git a/x/incentive/keeper/rewards_supply_test.go b/x/incentive/keeper/rewards_supply_test.go index 7b76ef35..64090fd1 100644 --- a/x/incentive/keeper/rewards_supply_test.go +++ b/x/incentive/keeper/rewards_supply_test.go @@ -741,12 +741,13 @@ func (suite *KeeperTestSuite) TestSynchronizeHardSupplyReward() { suite.Require().NoError(err) // 5. Committee votes and passes proposal - err = suite.committeeKeeper.AddVote(suite.ctx, proposalID, committeeMemberOne) - err = suite.committeeKeeper.AddVote(suite.ctx, proposalID, committeeMemberTwo) + err = suite.committeeKeeper.AddVote(suite.ctx, proposalID, committeeMemberOne, committee.Yes) + err = suite.committeeKeeper.AddVote(suite.ctx, proposalID, committeeMemberTwo, committee.Yes) // 6. Check proposal passed - proposalPasses, err := suite.committeeKeeper.GetProposalResult(suite.ctx, proposalID) - suite.Require().NoError(err) + com, found := suite.committeeKeeper.GetCommittee(suite.ctx, 1) + suite.Require().True(found) + proposalPasses := suite.committeeKeeper.GetProposalResult(suite.ctx, proposalID, com) suite.Require().True(proposalPasses) // 7. Run committee module's begin blocker to enact proposal