0g-chain/x/committee/keeper/proposal_test.go
Denali Marsh baf17b4ec8
Emit final proposal tally in close proposal event (#919)
* 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

* 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

* emit final proposal tally

* marshal proposal tally to bytes
2021-06-22 16:49:46 +02:00

1275 lines
41 KiB
Go

package keeper_test
import (
"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"
bep3types "github.com/kava-labs/kava/x/bep3/types"
cdptypes "github.com/kava-labs/kava/x/cdp/types"
"github.com/kava-labs/kava/x/committee"
"github.com/kava-labs/kava/x/committee/types"
"github.com/kava-labs/kava/x/pricefeed"
)
func newCDPGenesisState(params cdptypes.Params) app.GenesisState {
genesis := cdptypes.DefaultGenesisState()
genesis.Params = params
return app.GenesisState{cdptypes.ModuleName: cdptypes.ModuleCdc.MustMarshalJSON(genesis)}
}
func newBep3GenesisState(params bep3types.Params) app.GenesisState {
genesis := bep3types.DefaultGenesisState()
genesis.Params = params
return app.GenesisState{bep3types.ModuleName: bep3types.ModuleCdc.MustMarshalJSON(genesis)}
}
func newPricefeedGenState(assets []string, prices []sdk.Dec) app.GenesisState {
if len(assets) != len(prices) {
panic("assets and prices must be the same length")
}
pfGenesis := pricefeed.DefaultGenesisState()
for i := range assets {
pfGenesis.Params.Markets = append(
pfGenesis.Params.Markets,
pricefeed.Market{
MarketID: assets[i] + ":usd", BaseAsset: assets[i], QuoteAsset: "usd", Oracles: []sdk.AccAddress{}, Active: true,
})
pfGenesis.PostedPrices = append(
pfGenesis.PostedPrices,
pricefeed.PostedPrice{
MarketID: assets[i] + ":usd",
OracleAddress: sdk.AccAddress{},
Price: prices[i],
Expiry: time.Date(1998, 1, 1, 0, 0, 0, 0, time.UTC),
})
}
return app.GenesisState{pricefeed.ModuleName: pricefeed.ModuleCdc.MustMarshalJSON(pfGenesis)}
}
func (suite *KeeperTestSuite) TestSubmitProposal() {
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
noPermissionsCom.Permissions = []types.Permission{}
paramChangePermissionsCom := normalCom
paramChangePermissionsCom.Permissions = []types.Permission{
types.SubParamChangePermission{
AllowedParams: types.AllowedParams{
{Subspace: cdptypes.ModuleName, Key: string(cdptypes.KeyDebtThreshold)},
{Subspace: cdptypes.ModuleName, Key: string(cdptypes.KeyCollateralParams)},
},
AllowedCollateralParams: types.AllowedCollateralParams{
types.AllowedCollateralParam{
Type: "bnb-a",
DebtLimit: true,
StabilityFee: true,
},
},
},
}
testCP := cdptypes.CollateralParams{{
Denom: "bnb",
Type: "bnb-a",
LiquidationRatio: d("1.5"),
DebtLimit: c("usdx", 1000000000000),
StabilityFee: d("1.000000001547125958"), // %5 apr
LiquidationPenalty: d("0.05"),
AuctionSize: i(100),
Prefix: 0x20,
ConversionFactor: i(6),
LiquidationMarketID: "bnb:usd",
SpotMarketID: "bnb:usd",
}}
testCDPParams := cdptypes.DefaultParams()
testCDPParams.CollateralParams = testCP
testCDPParams.GlobalDebtLimit = testCP[0].DebtLimit
newValidCP := make(cdptypes.CollateralParams, len(testCP))
copy(newValidCP, testCP)
newValidCP[0].DebtLimit = c("usdx", 500000000000)
newInvalidCP := make(cdptypes.CollateralParams, len(testCP))
copy(newInvalidCP, testCP)
newInvalidCP[0].SpotMarketID = "btc:usd"
testcases := []struct {
name string
committee types.BaseCommittee
pubProposal types.PubProposal
proposer sdk.AccAddress
committeeID uint64
expectErr bool
}{
{
name: "normal text proposal",
committee: normalCom,
pubProposal: gov.NewTextProposal("A Title", "A description of this proposal."),
proposer: normalCom.Members[0],
committeeID: normalCom.ID,
expectErr: false,
},
{
name: "normal param change proposal",
committee: normalCom,
pubProposal: params.NewParameterChangeProposal(
"A Title", "A description of this proposal.",
[]params.ParamChange{
{
Subspace: "cdp", Key: string(cdptypes.KeyDebtThreshold), Value: string(suite.app.Codec().MustMarshalJSON(i(1000000))),
},
},
),
proposer: normalCom.Members[0],
committeeID: normalCom.ID,
expectErr: false,
},
{
name: "invalid proposal",
committee: normalCom,
pubProposal: nil,
proposer: normalCom.Members[0],
committeeID: normalCom.ID,
expectErr: true,
},
{
name: "missing committee",
// no committee
pubProposal: gov.NewTextProposal("A Title", "A description of this proposal."),
proposer: suite.addresses[0],
committeeID: 0,
expectErr: true,
},
{
name: "not a member",
committee: normalCom,
pubProposal: gov.NewTextProposal("A Title", "A description of this proposal."),
proposer: suite.addresses[4],
committeeID: normalCom.ID,
expectErr: true,
},
{
name: "not enough permissions",
committee: noPermissionsCom,
pubProposal: gov.NewTextProposal("A Title", "A description of this proposal."),
proposer: noPermissionsCom.Members[0],
committeeID: noPermissionsCom.ID,
expectErr: true,
},
{
name: "valid sub param change",
committee: paramChangePermissionsCom,
pubProposal: params.NewParameterChangeProposal(
"A Title", "A description of this proposal.",
[]params.ParamChange{
{
Subspace: "cdp",
Key: string(cdptypes.KeyDebtThreshold),
Value: string(suite.app.Codec().MustMarshalJSON(i(1000000000))),
},
{
Subspace: "cdp",
Key: string(cdptypes.KeyCollateralParams),
Value: string(suite.app.Codec().MustMarshalJSON(newValidCP)),
},
},
),
proposer: paramChangePermissionsCom.Members[0],
committeeID: paramChangePermissionsCom.ID,
expectErr: false,
},
{
name: "invalid sub param change permission",
committee: paramChangePermissionsCom,
pubProposal: params.NewParameterChangeProposal(
"A Title", "A description of this proposal.",
[]params.ParamChange{
{
Subspace: "cdp",
Key: string(cdptypes.KeyDebtThreshold),
Value: string(suite.app.Codec().MustMarshalJSON(i(1000000000))),
},
{
Subspace: "cdp",
Key: string(cdptypes.KeyCollateralParams),
Value: string(suite.app.Codec().MustMarshalJSON(newInvalidCP)),
},
},
),
proposer: paramChangePermissionsCom.Members[0],
committeeID: paramChangePermissionsCom.ID,
expectErr: true,
},
}
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{})
tApp.InitializeFromGenesisStates(
newPricefeedGenState([]string{"bnb"}, []sdk.Dec{d("15.01")}),
newCDPGenesisState(testCDPParams),
)
// 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)
if tc.expectErr {
suite.NotNil(err)
} else {
suite.NoError(err)
pr, found := keeper.GetProposal(ctx, id)
suite.True(found)
suite.Equal(tc.committeeID, pr.CommitteeID)
suite.Equal(ctx.BlockTime().Add(tc.committee.GetProposalDuration()), pr.Deadline)
}
})
}
}
func (suite *KeeperTestSuite) TestAddVote() {
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 MemberCommittee",
committee: memberCom,
proposalID: types.DefaultNextProposalID,
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: memberCom.Members[0],
voteType: types.Yes,
expectErr: true,
},
{
name: "proposal expired",
committee: memberCom,
proposalID: types.DefaultNextProposalID,
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,
},
}
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()
// setup the committee and 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, tc.voteType)
if tc.expectErr {
suite.NotNil(err)
} else {
suite.NoError(err)
_, found := keeper.GetVote(ctx, tc.proposalID, tc.voter)
suite.True(found)
}
})
}
}
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)
testcases := []struct {
name string
committee types.Committee
votes []types.Vote
proposalPasses bool
}{
{
name: "enough votes",
committee: memberCom,
votes: []types.Vote{
{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,
},
{
name: "not enough votes",
committee: memberCom,
votes: []types.Vote{
{ProposalID: defaultID, Voter: suite.addresses[0], VoteType: types.Yes},
},
proposalPasses: false,
},
}
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,
),
)
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)
events := ctx.EventManager().Events()
event := events[0]
suite.Require().Equal("proposal_close", event.Type)
hasProposalTallyAttr := false
for _, attr := range event.Attributes {
if string(attr.GetKey()) == "proposal_tally" {
hasProposalTallyAttr = true
valueStr := string(attr.GetValue())
suite.Contains(valueStr, "proposal_id")
suite.Contains(valueStr, "yes_votes")
suite.Contains(valueStr, "current_votes")
suite.Contains(valueStr, "possible_votes")
suite.Contains(valueStr, "vote_threshold")
suite.Contains(valueStr, "quorum")
}
}
suite.Require().True(hasProposalTallyAttr)
// 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),
committees,
proposals,
votes,
)
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
}
func (UnregisteredPubProposal) ProposalRoute() string { return "unregistered" }
func (UnregisteredPubProposal) ProposalType() string { return "unregistered" }
var _ types.PubProposal = UnregisteredPubProposal{}
func (suite *KeeperTestSuite) TestValidatePubProposal() {
testcases := []struct {
name string
pubProposal types.PubProposal
expectErr bool
}{
{
name: "valid (text proposal)",
pubProposal: gov.NewTextProposal("A Title", "A description of this proposal."),
expectErr: false,
},
{
name: "valid (param change proposal)",
pubProposal: params.NewParameterChangeProposal(
"Change the debt limit",
"This proposal changes the debt limit of the cdp module.",
[]params.ParamChange{{
Subspace: cdptypes.ModuleName,
Key: string(cdptypes.KeyGlobalDebtLimit),
Value: string(types.ModuleCdc.MustMarshalJSON(c("usdx", 100000000000))),
}},
),
expectErr: false,
},
{
name: "invalid (missing title)",
pubProposal: gov.TextProposal{Description: "A description of this proposal."},
expectErr: true,
},
{
name: "invalid (unregistered)",
pubProposal: UnregisteredPubProposal{gov.TextProposal{Title: "A Title", Description: "A description of this proposal."}},
expectErr: true,
},
{
name: "invalid (nil)",
pubProposal: nil,
expectErr: true,
},
{
name: "invalid (proposal handler fails)",
pubProposal: params.NewParameterChangeProposal(
"A Title",
"A description of this proposal.",
[]params.ParamChange{{
Subspace: "nonsense-subspace",
Key: "nonsense-key",
Value: "nonsense-value",
}},
),
expectErr: true,
},
{
name: "invalid (proposal handler panics)",
pubProposal: params.NewParameterChangeProposal(
"A Title",
"A description of this proposal.",
[]params.ParamChange{{
Subspace: cdptypes.ModuleName,
Key: "nonsense-key", // a valid Subspace but invalid Key will trigger a panic in the paramchange propsal handler
Value: "nonsense-value",
}},
),
expectErr: true,
},
{
name: "invalid (proposal handler fails - invalid json)",
pubProposal: params.NewParameterChangeProposal(
"A Title",
"A description of this proposal.",
[]params.ParamChange{{
Subspace: cdptypes.ModuleName,
Key: string(cdptypes.KeyGlobalDebtLimit),
Value: `{"denom": "usdx",`,
}},
),
expectErr: true,
},
}
for _, tc := range testcases {
suite.Run(tc.name, func() {
err := suite.keeper.ValidatePubProposal(suite.ctx, tc.pubProposal)
if tc.expectErr {
suite.NotNil(err)
} else {
suite.NoError(err)
}
})
}
}
func (suite *KeeperTestSuite) TestProcessProposals() {
firstBlockTime := time.Date(1998, time.January, 1, 1, 0, 0, 0, time.UTC)
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: "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,
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,
},
},
}
// 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),
},
}
// 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,
},
}
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)
// 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
}