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 }