From 31e79b2512c6b47203be0b1c7c8511511697a737 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Thu, 30 Jun 2022 07:27:37 -0400 Subject: [PATCH] fix: deadlock when querying group members (backport #12342) (#12381) --- x/group/internal/orm/indexer.go | 10 + x/group/internal/orm/table.go | 17 +- x/group/internal/orm/types.go | 8 +- x/group/keeper/keeper.go | 94 +- x/group/keeper/keeper_test.go | 2126 ++++++++++++++++++++++++++++++- 5 files changed, 2165 insertions(+), 90 deletions(-) diff --git a/x/group/internal/orm/indexer.go b/x/group/internal/orm/indexer.go index e8dc212ec71b..bdba57584fd2 100644 --- a/x/group/internal/orm/indexer.go +++ b/x/group/internal/orm/indexer.go @@ -158,6 +158,16 @@ func checkUniqueIndexKey(store storetypes.KVStore, secondaryIndexKeyBytes []byte return nil } +// checkUniqueIndexKey checks that the given secondary index key is unique +func checkUniqueIndexKey(store sdk.KVStore, secondaryIndexKeyBytes []byte) error { + it := store.Iterator(PrefixRange(secondaryIndexKeyBytes)) + defer it.Close() + if it.Valid() { + return errors.ErrORMUniqueConstraint + } + return nil +} + // multiKeyAddFunc allows multiple entries for a key func multiKeyAddFunc(store storetypes.KVStore, secondaryIndexKey interface{}, rowID RowID) error { secondaryIndexKeyBytes, err := keyPartBytes(secondaryIndexKey, false) diff --git a/x/group/internal/orm/table.go b/x/group/internal/orm/table.go index 4d6aec8a5235..8672a04c218e 100644 --- a/x/group/internal/orm/table.go +++ b/x/group/internal/orm/table.go @@ -185,12 +185,8 @@ func (a table) Has(store storetypes.KVStore, key RowID) bool { if len(key) == 0 { return false } - pStore := prefixstore.New(store, a.prefix[:]) - has, err := pStore.Has(key) - if err != nil { - panic(err) - } - return has + pStore := prefix.NewStore(store, a.prefix[:]) + return pStore.Has(key) } // GetOne load the object persisted for the given RowID into the dest parameter. @@ -311,12 +307,9 @@ func (a table) Import(store storetypes.KVStore, data interface{}, _ uint64) erro return nil } -func (a table) keys(store storetypes.KVStore) [][]byte { - pStore := prefixstore.New(store, a.prefix[:]) - it, err := pStore.ReverseIterator(nil, nil) - if err != nil { - panic(err) - } +func (a table) keys(store sdk.KVStore) [][]byte { + pStore := prefix.NewStore(store, a.prefix[:]) + it := pStore.Iterator(nil, nil) defer it.Close() var keys [][]byte diff --git a/x/group/internal/orm/types.go b/x/group/internal/orm/types.go index 11d113cd68a9..93648429e7f5 100644 --- a/x/group/internal/orm/types.go +++ b/x/group/internal/orm/types.go @@ -116,12 +116,8 @@ func NewTypeSafeRowGetter(prefixKey [2]byte, model reflect.Type, cdc codec.Codec return err } - pStore := prefixstore.New(store, prefixKey[:]) - bz, err := pStore.Get(rowID) - if err != nil { - return err - } - + pStore := prefix.NewStore(store, prefixKey[:]) + bz := pStore.Get(rowID) if len(bz) == 0 { return sdkerrors.ErrNotFound } diff --git a/x/group/keeper/keeper.go b/x/group/keeper/keeper.go index 5336704535dd..b784a93a09ae 100644 --- a/x/group/keeper/keeper.go +++ b/x/group/keeper/keeper.go @@ -241,7 +241,7 @@ func (k Keeper) GetGroupPolicySeq(ctx sdk.Context) uint64 { } // proposalsByVPEnd returns all proposals whose voting_period_end is after the `endTime` time argument. -func (k Keeper) proposalsByVPEnd(ctx context.Context, endTime time.Time) (proposals []group.Proposal, err error) { +func (k Keeper) proposalsByVPEnd(ctx sdk.Context, endTime time.Time) (proposals []group.Proposal, err error) { timeBytes := sdk.FormatTimeBytes(endTime) it, err := k.proposalsByVotingPeriodEnd.PrefixScan(k.KVStoreService.OpenKVStore(ctx), nil, timeBytes) if err != nil { @@ -286,7 +286,7 @@ func (k Keeper) pruneProposal(ctx context.Context, proposalID uint64) error { // abortProposals iterates through all proposals by group policy index // and marks submitted proposals as aborted. -func (k Keeper) abortProposals(ctx context.Context, groupPolicyAddr sdk.AccAddress) error { +func (k Keeper) abortProposals(ctx sdk.Context, groupPolicyAddr sdk.AccAddress) error { proposals, err := k.proposalsByGroupPolicy(ctx, groupPolicyAddr) if err != nil { return err @@ -297,7 +297,7 @@ func (k Keeper) abortProposals(ctx context.Context, groupPolicyAddr sdk.AccAddre if proposalInfo.Status == group.PROPOSAL_STATUS_SUBMITTED { proposalInfo.Status = group.PROPOSAL_STATUS_ABORTED - if err := k.proposalTable.Update(k.KVStoreService.OpenKVStore(ctx), proposalInfo.Id, &proposalInfo); err != nil { + if err := k.proposalTable.Update(ctx.KVStore(k.key), proposalInfo.Id, &proposalInfo); err != nil { return err } } @@ -306,8 +306,8 @@ func (k Keeper) abortProposals(ctx context.Context, groupPolicyAddr sdk.AccAddre } // proposalsByGroupPolicy returns all proposals for a given group policy. -func (k Keeper) proposalsByGroupPolicy(ctx context.Context, groupPolicyAddr sdk.AccAddress) ([]group.Proposal, error) { - proposalIt, err := k.proposalByGroupPolicyIndex.Get(k.KVStoreService.OpenKVStore(ctx), groupPolicyAddr.Bytes()) +func (k Keeper) proposalsByGroupPolicy(ctx sdk.Context, groupPolicyAddr sdk.AccAddress) ([]group.Proposal, error) { + proposalIt, err := k.proposalByGroupPolicyIndex.Get(ctx.KVStore(k.key), groupPolicyAddr.Bytes()) if err != nil { return nil, err } @@ -330,14 +330,14 @@ func (k Keeper) proposalsByGroupPolicy(ctx context.Context, groupPolicyAddr sdk. } // pruneVotes prunes all votes for a proposal from state. -func (k Keeper) pruneVotes(ctx context.Context, proposalID uint64) error { +func (k Keeper) pruneVotes(ctx sdk.Context, proposalID uint64) error { votes, err := k.votesByProposal(ctx, proposalID) if err != nil { - return err + return nil, err } for _, v := range votes { - err = k.voteTable.Delete(k.KVStoreService.OpenKVStore(ctx), &v) + err = k.voteTable.Delete(ctx.KVStore(k.key), &v) if err != nil { return err } @@ -347,8 +347,8 @@ func (k Keeper) pruneVotes(ctx context.Context, proposalID uint64) error { } // votesByProposal returns all votes for a given proposal. -func (k Keeper) votesByProposal(ctx context.Context, proposalID uint64) ([]group.Vote, error) { - it, err := k.voteByProposalIndex.Get(k.KVStoreService.OpenKVStore(ctx), proposalID) +func (k Keeper) votesByProposal(ctx sdk.Context, proposalID uint64) ([]group.Vote, error) { + it, err := k.voteByProposalIndex.Get(ctx.KVStore(k.key), proposalID) if err != nil { return nil, err } @@ -372,9 +372,8 @@ func (k Keeper) votesByProposal(ctx context.Context, proposalID uint64) ([]group // PruneProposals prunes all proposals that are expired, i.e. whose // `voting_period + max_execution_period` is greater than the current block // time. -func (k Keeper) PruneProposals(ctx context.Context) error { - endTime := k.HeaderService.HeaderInfo(ctx).Time.Add(-k.config.MaxExecutionPeriod) - proposals, err := k.proposalsByVPEnd(ctx, endTime) +func (k Keeper) PruneProposals(ctx sdk.Context) error { + proposals, err := k.proposalsByVPEnd(ctx, ctx.BlockTime().Add(-k.config.MaxExecutionPeriod)) if err != nil { return nil } @@ -383,16 +382,6 @@ func (k Keeper) PruneProposals(ctx context.Context) error { if err != nil { return err } - // Emit event for proposal finalized with its result - if err := k.EventService.EventManager(ctx).Emit( - &group.EventProposalPruned{ - ProposalId: proposal.Id, - Status: proposal.Status, - TallyResult: &proposal.FinalTallyResult, - }, - ); err != nil { - return err - } } return nil @@ -401,77 +390,40 @@ func (k Keeper) PruneProposals(ctx context.Context) error { // TallyProposalsAtVPEnd iterates over all proposals whose voting period // has ended, tallies their votes, prunes them, and updates the proposal's // `FinalTallyResult` field. -func (k Keeper) TallyProposalsAtVPEnd(ctx context.Context) error { - proposals, err := k.proposalsByVPEnd(ctx, k.HeaderService.HeaderInfo(ctx).Time) +func (k Keeper) TallyProposalsAtVPEnd(ctx sdk.Context) error { + proposals, err := k.proposalsByVPEnd(ctx, ctx.BlockTime()) if err != nil { return nil } for _, proposal := range proposals { policyInfo, err := k.getGroupPolicyInfo(ctx, proposal.GroupPolicyAddress) if err != nil { - return errorsmod.Wrap(err, "group policy") + return sdkerrors.Wrap(err, "group policy") } electorate, err := k.getGroupInfo(ctx, policyInfo.GroupId) if err != nil { - return errorsmod.Wrap(err, "group") + return sdkerrors.Wrap(err, "group") } - proposalID := proposal.Id if proposal.Status == group.PROPOSAL_STATUS_ABORTED || proposal.Status == group.PROPOSAL_STATUS_WITHDRAWN { + proposalID := proposal.Id if err := k.pruneProposal(ctx, proposalID); err != nil { return err } if err := k.pruneVotes(ctx, proposalID); err != nil { return err } - // Emit event for proposal finalized with its result - if err := k.EventService.EventManager(ctx).Emit( - &group.EventProposalPruned{ - ProposalId: proposal.Id, - Status: proposal.Status, - }, - ); err != nil { - return err - } - } else if proposal.Status == group.PROPOSAL_STATUS_SUBMITTED { - if err := k.doTallyAndUpdate(ctx, &proposal, electorate, policyInfo); err != nil { - return errorsmod.Wrap(err, "doTallyAndUpdate") + } else { + err = k.doTallyAndUpdate(ctx, &proposal, electorate, policyInfo) + if err != nil { + return sdkerrors.Wrap(err, "doTallyAndUpdate") } - if err := k.proposalTable.Update(k.KVStoreService.OpenKVStore(ctx), proposal.Id, &proposal); err != nil { - return errorsmod.Wrap(err, "proposal update") + if err := k.proposalTable.Update(ctx.KVStore(k.key), proposal.Id, &proposal); err != nil { + return sdkerrors.Wrap(err, "proposal update") } } - // Note: We do nothing if the proposal has been marked as ACCEPTED or - // REJECTED. - } - return nil -} - -// assertMetadataLength returns an error if given metadata length -// is greater than defined MaxMetadataLen in the module configuration -func (k Keeper) assertMetadataLength(metadata, description string) error { - if uint64(len(metadata)) > k.config.MaxMetadataLen { - return errors.ErrMetadataTooLong.Wrap(description) - } - return nil -} - -// assertSummaryLength returns an error if given summary length -// is greater than defined MaxProposalSummaryLen in the module configuration -func (k Keeper) assertSummaryLength(summary string) error { - if uint64(len(summary)) > k.config.MaxProposalSummaryLen { - return errors.ErrSummaryTooLong - } - return nil -} - -// assertTitleLength returns an error if given summary length -// is greater than defined MaxProposalTitleLen in the module configuration -func (k Keeper) assertTitleLength(title string) error { - if uint64(len(title)) > k.config.MaxProposalTitleLen { - return errors.ErrTitleTooLong } return nil } diff --git a/x/group/keeper/keeper_test.go b/x/group/keeper/keeper_test.go index 449fe039407f..a866cd705759 100644 --- a/x/group/keeper/keeper_test.go +++ b/x/group/keeper/keeper_test.go @@ -31,7 +31,2131 @@ import ( authtypes "github.com/cosmos/cosmos-sdk/x/auth/types" ) -const minExecutionPeriod = 5 * time.Second +type TestSuite struct { + suite.Suite + + app *simapp.SimApp + sdkCtx sdk.Context + ctx context.Context + addrs []sdk.AccAddress + groupID uint64 + groupPolicyAddr sdk.AccAddress + policy group.DecisionPolicy + keeper keeper.Keeper + blockTime time.Time +} + +func (s *TestSuite) SetupTest() { + app := simapp.Setup(s.T(), false) + ctx := app.BaseApp.NewContext(false, tmproto.Header{}) + + s.blockTime = tmtime.Now() + ctx = ctx.WithBlockHeader(tmproto.Header{Time: s.blockTime}) + + s.app = app + s.sdkCtx = ctx + s.ctx = sdk.WrapSDKContext(ctx) + s.keeper = s.app.GroupKeeper + s.addrs = simapp.AddTestAddrsIncremental(app, ctx, 6, sdk.NewInt(30000000)) + + // Initial group, group policy and balance setup + members := []group.MemberRequest{ + {Address: s.addrs[4].String(), Weight: "1"}, {Address: s.addrs[1].String(), Weight: "2"}, + } + groupRes, err := s.keeper.CreateGroup(s.ctx, &group.MsgCreateGroup{ + Admin: s.addrs[0].String(), + Members: members, + }) + s.Require().NoError(err) + s.groupID = groupRes.GroupId + + policy := group.NewThresholdDecisionPolicy( + "2", + time.Second, + 0, + ) + policyReq := &group.MsgCreateGroupPolicy{ + Admin: s.addrs[0].String(), + GroupId: s.groupID, + } + err = policyReq.SetDecisionPolicy(policy) + s.Require().NoError(err) + policyRes, err := s.keeper.CreateGroupPolicy(s.ctx, policyReq) + s.Require().NoError(err) + s.policy = policy + addr, err := sdk.AccAddressFromBech32(policyRes.Address) + s.Require().NoError(err) + s.groupPolicyAddr = addr + s.Require().NoError(testutil.FundAccount(s.app.BankKeeper, s.sdkCtx, s.groupPolicyAddr, sdk.Coins{sdk.NewInt64Coin("test", 10000)})) +} + +func TestKeeperTestSuite(t *testing.T) { + suite.Run(t, new(TestSuite)) +} + +// Testing a deadlock issue when querying group members +// https://github.com/cosmos/cosmos-sdk/issues/12111 +func (s *TestSuite) TestCreateGroupWithLotsOfMembers() { + for i := 50; i < 70; i++ { + membersResp := s.createGroupAndGetMembers(i) + s.Require().Equal(len(membersResp), i) + } +} + +func (s *TestSuite) createGroupAndGetMembers(numMembers int) []*group.GroupMember { + addressPool := simapp.AddTestAddrsIncremental(s.app, s.sdkCtx, numMembers, sdk.NewInt(30000000)) + members := make([]group.MemberRequest, numMembers) + for i := 0; i < len(members); i++ { + members[i] = group.MemberRequest{ + Address: addressPool[i].String(), + Weight: "1", + } + } + + g, err := s.keeper.CreateGroup(s.ctx, &group.MsgCreateGroup{ + Admin: members[0].Address, + Members: members, + }) + s.Require().NoErrorf(err, "failed to create group with %d members", len(members)) + s.T().Logf("group %d created with %d members", g.GroupId, len(members)) + + groupMemberResp, err := s.keeper.GroupMembers(s.ctx, &group.QueryGroupMembersRequest{GroupId: g.GroupId}) + s.Require().NoError(err) + + s.T().Logf("got %d members from group %d", len(groupMemberResp.Members), g.GroupId) + + return groupMemberResp.Members +} + +func (s *TestSuite) TestCreateGroup() { + addrs := s.addrs + addr1 := addrs[0] + addr3 := addrs[2] + addr5 := addrs[4] + addr6 := addrs[5] + + members := []group.MemberRequest{{ + Address: addr5.String(), + Weight: "1", + }, { + Address: addr6.String(), + Weight: "2", + }} + + expGroups := []*group.GroupInfo{ + { + Id: s.groupID, + Version: 1, + Admin: addr1.String(), + TotalWeight: "3", + CreatedAt: s.blockTime, + }, + { + Id: 2, + Version: 1, + Admin: addr1.String(), + TotalWeight: "3", + CreatedAt: s.blockTime, + }, + } + + specs := map[string]struct { + req *group.MsgCreateGroup + expErr bool + expGroups []*group.GroupInfo + }{ + "all good": { + req: &group.MsgCreateGroup{ + Admin: addr1.String(), + Members: members, + }, + expGroups: expGroups, + }, + "group metadata too long": { + req: &group.MsgCreateGroup{ + Admin: addr1.String(), + Members: members, + Metadata: strings.Repeat("a", 256), + }, + expErr: true, + }, + "member metadata too long": { + req: &group.MsgCreateGroup{ + Admin: addr1.String(), + Members: []group.MemberRequest{{ + Address: addr3.String(), + Weight: "1", + Metadata: strings.Repeat("a", 256), + }}, + }, + expErr: true, + }, + "zero member weight": { + req: &group.MsgCreateGroup{ + Admin: addr1.String(), + Members: []group.MemberRequest{{ + Address: addr3.String(), + Weight: "0", + }}, + }, + expErr: true, + }, + } + + var seq uint32 = 1 + for msg, spec := range specs { + spec := spec + s.Run(msg, func() { + blockTime := sdk.UnwrapSDKContext(s.ctx).BlockTime() + res, err := s.keeper.CreateGroup(s.ctx, spec.req) + if spec.expErr { + s.Require().Error(err) + _, err := s.keeper.GroupInfo(s.ctx, &group.QueryGroupInfoRequest{GroupId: uint64(seq + 1)}) + s.Require().Error(err) + return + } + s.Require().NoError(err) + id := res.GroupId + + seq++ + s.Assert().Equal(uint64(seq), id) + + // then all data persisted + loadedGroupRes, err := s.keeper.GroupInfo(s.ctx, &group.QueryGroupInfoRequest{GroupId: id}) + s.Require().NoError(err) + s.Assert().Equal(spec.req.Admin, loadedGroupRes.Info.Admin) + s.Assert().Equal(spec.req.Metadata, loadedGroupRes.Info.Metadata) + s.Assert().Equal(id, loadedGroupRes.Info.Id) + s.Assert().Equal(uint64(1), loadedGroupRes.Info.Version) + + // and members are stored as well + membersRes, err := s.keeper.GroupMembers(s.ctx, &group.QueryGroupMembersRequest{GroupId: id}) + s.Require().NoError(err) + loadedMembers := membersRes.Members + s.Require().Equal(len(members), len(loadedMembers)) + // we reorder members by address to be able to compare them + sort.Slice(members, func(i, j int) bool { + addri, err := sdk.AccAddressFromBech32(members[i].Address) + s.Require().NoError(err) + addrj, err := sdk.AccAddressFromBech32(members[j].Address) + s.Require().NoError(err) + return bytes.Compare(addri, addrj) < 0 + }) + for i := range loadedMembers { + s.Assert().Equal(members[i].Metadata, loadedMembers[i].Member.Metadata) + s.Assert().Equal(members[i].Address, loadedMembers[i].Member.Address) + s.Assert().Equal(members[i].Weight, loadedMembers[i].Member.Weight) + s.Assert().Equal(blockTime, loadedMembers[i].Member.AddedAt) + s.Assert().Equal(id, loadedMembers[i].GroupId) + } + + // query groups by admin + groupsRes, err := s.keeper.GroupsByAdmin(s.ctx, &group.QueryGroupsByAdminRequest{Admin: addr1.String()}) + s.Require().NoError(err) + loadedGroups := groupsRes.Groups + s.Require().Equal(len(spec.expGroups), len(loadedGroups)) + for i := range loadedGroups { + s.Assert().Equal(spec.expGroups[i].Metadata, loadedGroups[i].Metadata) + s.Assert().Equal(spec.expGroups[i].Admin, loadedGroups[i].Admin) + s.Assert().Equal(spec.expGroups[i].TotalWeight, loadedGroups[i].TotalWeight) + s.Assert().Equal(spec.expGroups[i].Id, loadedGroups[i].Id) + s.Assert().Equal(spec.expGroups[i].Version, loadedGroups[i].Version) + s.Assert().Equal(spec.expGroups[i].CreatedAt, loadedGroups[i].CreatedAt) + } + }) + } +} + +func (s *TestSuite) TestUpdateGroupAdmin() { + addrs := s.addrs + addr1 := addrs[0] + addr2 := addrs[1] + addr3 := addrs[2] + addr4 := addrs[3] + + members := []group.MemberRequest{{ + Address: addr1.String(), + Weight: "1", + }} + oldAdmin := addr2.String() + newAdmin := addr3.String() + groupRes, err := s.keeper.CreateGroup(s.ctx, &group.MsgCreateGroup{ + Admin: oldAdmin, + Members: members, + }) + s.Require().NoError(err) + groupID := groupRes.GroupId + specs := map[string]struct { + req *group.MsgUpdateGroupAdmin + expStored *group.GroupInfo + expErr bool + }{ + "with correct admin": { + req: &group.MsgUpdateGroupAdmin{ + GroupId: groupID, + Admin: oldAdmin, + NewAdmin: newAdmin, + }, + expStored: &group.GroupInfo{ + Id: groupID, + Admin: newAdmin, + TotalWeight: "1", + Version: 2, + CreatedAt: s.blockTime, + }, + }, + "with wrong admin": { + req: &group.MsgUpdateGroupAdmin{ + GroupId: groupID, + Admin: addr4.String(), + NewAdmin: newAdmin, + }, + expErr: true, + expStored: &group.GroupInfo{ + Id: groupID, + Admin: oldAdmin, + TotalWeight: "1", + Version: 1, + CreatedAt: s.blockTime, + }, + }, + "with unknown groupID": { + req: &group.MsgUpdateGroupAdmin{ + GroupId: 999, + Admin: oldAdmin, + NewAdmin: newAdmin, + }, + expErr: true, + expStored: &group.GroupInfo{ + Id: groupID, + Admin: oldAdmin, + TotalWeight: "1", + Version: 1, + CreatedAt: s.blockTime, + }, + }, + } + for msg, spec := range specs { + spec := spec + s.Run(msg, func() { + _, err := s.keeper.UpdateGroupAdmin(s.ctx, spec.req) + if spec.expErr { + s.Require().Error(err) + return + } + s.Require().NoError(err) + + // then + res, err := s.keeper.GroupInfo(s.ctx, &group.QueryGroupInfoRequest{GroupId: groupID}) + s.Require().NoError(err) + s.Assert().Equal(spec.expStored, res.Info) + }) + } +} + +func (s *TestSuite) TestUpdateGroupMetadata() { + addrs := s.addrs + addr1 := addrs[0] + addr3 := addrs[2] + + oldAdmin := addr1.String() + groupID := s.groupID + + specs := map[string]struct { + req *group.MsgUpdateGroupMetadata + expErr bool + expStored *group.GroupInfo + }{ + "with correct admin": { + req: &group.MsgUpdateGroupMetadata{ + GroupId: groupID, + Admin: oldAdmin, + }, + expStored: &group.GroupInfo{ + Id: groupID, + Admin: oldAdmin, + TotalWeight: "3", + Version: 2, + CreatedAt: s.blockTime, + }, + }, + "with wrong admin": { + req: &group.MsgUpdateGroupMetadata{ + GroupId: groupID, + Admin: addr3.String(), + }, + expErr: true, + expStored: &group.GroupInfo{ + Id: groupID, + Admin: oldAdmin, + TotalWeight: "1", + Version: 1, + CreatedAt: s.blockTime, + }, + }, + "with unknown groupid": { + req: &group.MsgUpdateGroupMetadata{ + GroupId: 999, + Admin: oldAdmin, + }, + expErr: true, + expStored: &group.GroupInfo{ + Id: groupID, + Admin: oldAdmin, + TotalWeight: "1", + Version: 1, + CreatedAt: s.blockTime, + }, + }, + } + for msg, spec := range specs { + spec := spec + s.Run(msg, func() { + sdkCtx, _ := s.sdkCtx.CacheContext() + ctx := sdk.WrapSDKContext(sdkCtx) + _, err := s.keeper.UpdateGroupMetadata(ctx, spec.req) + if spec.expErr { + s.Require().Error(err) + return + } + s.Require().NoError(err) + + // then + res, err := s.keeper.GroupInfo(ctx, &group.QueryGroupInfoRequest{GroupId: groupID}) + s.Require().NoError(err) + s.Assert().Equal(spec.expStored, res.Info) + }) + } +} + +func (s *TestSuite) TestUpdateGroupMembers() { + addrs := s.addrs + addr3 := addrs[2] + addr4 := addrs[3] + addr5 := addrs[4] + addr6 := addrs[5] + + member1 := addr5.String() + member2 := addr6.String() + members := []group.MemberRequest{{ + Address: member1, + Weight: "1", + }} + + myAdmin := addr4.String() + groupRes, err := s.keeper.CreateGroup(s.ctx, &group.MsgCreateGroup{ + Admin: myAdmin, + Members: members, + }) + s.Require().NoError(err) + groupID := groupRes.GroupId + + specs := map[string]struct { + req *group.MsgUpdateGroupMembers + expErr bool + expGroup *group.GroupInfo + expMembers []*group.GroupMember + }{ + "add new member": { + req: &group.MsgUpdateGroupMembers{ + GroupId: groupID, + Admin: myAdmin, + MemberUpdates: []group.MemberRequest{{ + Address: member2, + Weight: "2", + }}, + }, + expGroup: &group.GroupInfo{ + Id: groupID, + Admin: myAdmin, + TotalWeight: "3", + Version: 2, + CreatedAt: s.blockTime, + }, + expMembers: []*group.GroupMember{ + { + Member: &group.Member{ + Address: member2, + Weight: "2", + AddedAt: s.sdkCtx.BlockTime(), + }, + GroupId: groupID, + }, + { + Member: &group.Member{ + Address: member1, + Weight: "1", + AddedAt: s.blockTime, + }, + GroupId: groupID, + }, + }, + }, + "update member": { + req: &group.MsgUpdateGroupMembers{ + GroupId: groupID, + Admin: myAdmin, + MemberUpdates: []group.MemberRequest{{ + Address: member1, + Weight: "2", + }}, + }, + expGroup: &group.GroupInfo{ + Id: groupID, + Admin: myAdmin, + TotalWeight: "2", + Version: 2, + CreatedAt: s.blockTime, + }, + expMembers: []*group.GroupMember{ + { + GroupId: groupID, + Member: &group.Member{ + Address: member1, + Weight: "2", + AddedAt: s.blockTime, + }, + }, + }, + }, + "update member with same data": { + req: &group.MsgUpdateGroupMembers{ + GroupId: groupID, + Admin: myAdmin, + MemberUpdates: []group.MemberRequest{{ + Address: member1, + Weight: "1", + }}, + }, + expGroup: &group.GroupInfo{ + Id: groupID, + Admin: myAdmin, + TotalWeight: "1", + Version: 2, + CreatedAt: s.blockTime, + }, + expMembers: []*group.GroupMember{ + { + GroupId: groupID, + Member: &group.Member{ + Address: member1, + Weight: "1", + AddedAt: s.blockTime, + }, + }, + }, + }, + "replace member": { + req: &group.MsgUpdateGroupMembers{ + GroupId: groupID, + Admin: myAdmin, + MemberUpdates: []group.MemberRequest{ + { + Address: member1, + Weight: "0", + }, + { + Address: member2, + Weight: "1", + }, + }, + }, + expGroup: &group.GroupInfo{ + Id: groupID, + Admin: myAdmin, + TotalWeight: "1", + Version: 2, + CreatedAt: s.blockTime, + }, + expMembers: []*group.GroupMember{{ + GroupId: groupID, + Member: &group.Member{ + Address: member2, + Weight: "1", + AddedAt: s.sdkCtx.BlockTime(), + }, + }}, + }, + "remove existing member": { + req: &group.MsgUpdateGroupMembers{ + GroupId: groupID, + Admin: myAdmin, + MemberUpdates: []group.MemberRequest{{ + Address: member1, + Weight: "0", + }}, + }, + expGroup: &group.GroupInfo{ + Id: groupID, + Admin: myAdmin, + TotalWeight: "0", + Version: 2, + CreatedAt: s.blockTime, + }, + expMembers: []*group.GroupMember{}, + }, + "remove unknown member": { + req: &group.MsgUpdateGroupMembers{ + GroupId: groupID, + Admin: myAdmin, + MemberUpdates: []group.MemberRequest{{ + Address: addr4.String(), + Weight: "0", + }}, + }, + expErr: true, + expGroup: &group.GroupInfo{ + Id: groupID, + Admin: myAdmin, + TotalWeight: "1", + Version: 1, + CreatedAt: s.blockTime, + }, + expMembers: []*group.GroupMember{{ + GroupId: groupID, + Member: &group.Member{ + Address: member1, + Weight: "1", + }, + }}, + }, + "with wrong admin": { + req: &group.MsgUpdateGroupMembers{ + GroupId: groupID, + Admin: addr3.String(), + MemberUpdates: []group.MemberRequest{{ + Address: member1, + Weight: "2", + }}, + }, + expErr: true, + expGroup: &group.GroupInfo{ + Id: groupID, + Admin: myAdmin, + TotalWeight: "1", + Version: 1, + CreatedAt: s.blockTime, + }, + expMembers: []*group.GroupMember{{ + GroupId: groupID, + Member: &group.Member{ + Address: member1, + Weight: "1", + }, + }}, + }, + "with unknown groupID": { + req: &group.MsgUpdateGroupMembers{ + GroupId: 999, + Admin: myAdmin, + MemberUpdates: []group.MemberRequest{{ + Address: member1, + Weight: "2", + }}, + }, + expErr: true, + expGroup: &group.GroupInfo{ + Id: groupID, + Admin: myAdmin, + TotalWeight: "1", + Version: 1, + CreatedAt: s.blockTime, + }, + expMembers: []*group.GroupMember{{ + GroupId: groupID, + Member: &group.Member{ + Address: member1, + Weight: "1", + }, + }}, + }, + } + for msg, spec := range specs { + spec := spec + s.Run(msg, func() { + sdkCtx, _ := s.sdkCtx.CacheContext() + ctx := sdk.WrapSDKContext(sdkCtx) + _, err := s.keeper.UpdateGroupMembers(ctx, spec.req) + if spec.expErr { + s.Require().Error(err) + return + } + s.Require().NoError(err) + + // then + res, err := s.keeper.GroupInfo(ctx, &group.QueryGroupInfoRequest{GroupId: groupID}) + s.Require().NoError(err) + s.Assert().Equal(spec.expGroup, res.Info) + + // and members persisted + membersRes, err := s.keeper.GroupMembers(ctx, &group.QueryGroupMembersRequest{GroupId: groupID}) + s.Require().NoError(err) + loadedMembers := membersRes.Members + s.Require().Equal(len(spec.expMembers), len(loadedMembers)) + // we reorder group members by address to be able to compare them + sort.Slice(spec.expMembers, func(i, j int) bool { + addri, err := sdk.AccAddressFromBech32(spec.expMembers[i].Member.Address) + s.Require().NoError(err) + addrj, err := sdk.AccAddressFromBech32(spec.expMembers[j].Member.Address) + s.Require().NoError(err) + return bytes.Compare(addri, addrj) < 0 + }) + for i := range loadedMembers { + s.Assert().Equal(spec.expMembers[i].Member.Metadata, loadedMembers[i].Member.Metadata) + s.Assert().Equal(spec.expMembers[i].Member.Address, loadedMembers[i].Member.Address) + s.Assert().Equal(spec.expMembers[i].Member.Weight, loadedMembers[i].Member.Weight) + s.Assert().Equal(spec.expMembers[i].Member.AddedAt, loadedMembers[i].Member.AddedAt) + s.Assert().Equal(spec.expMembers[i].GroupId, loadedMembers[i].GroupId) + } + }) + } +} + +func (s *TestSuite) TestCreateGroupWithPolicy() { + addrs := s.addrs + addr1 := addrs[0] + addr3 := addrs[2] + addr5 := addrs[4] + addr6 := addrs[5] + + members := []group.MemberRequest{{ + Address: addr5.String(), + Weight: "1", + }, { + Address: addr6.String(), + Weight: "2", + }} + + specs := map[string]struct { + req *group.MsgCreateGroupWithPolicy + policy group.DecisionPolicy + expErr bool + expErrMsg string + }{ + "all good": { + req: &group.MsgCreateGroupWithPolicy{ + Admin: addr1.String(), + Members: members, + GroupPolicyAsAdmin: false, + }, + policy: group.NewThresholdDecisionPolicy( + "1", + time.Second, + 0, + ), + }, + "group policy as admin is true": { + req: &group.MsgCreateGroupWithPolicy{ + Admin: addr1.String(), + Members: members, + GroupPolicyAsAdmin: true, + }, + policy: group.NewThresholdDecisionPolicy( + "1", + time.Second, + 0, + ), + }, + "group metadata too long": { + req: &group.MsgCreateGroupWithPolicy{ + Admin: addr1.String(), + Members: members, + GroupPolicyAsAdmin: false, + GroupMetadata: strings.Repeat("a", 256), + }, + policy: group.NewThresholdDecisionPolicy( + "1", + time.Second, + 0, + ), + expErr: true, + expErrMsg: "limit exceeded", + }, + "group policy metadata too long": { + req: &group.MsgCreateGroupWithPolicy{ + Admin: addr1.String(), + Members: members, + GroupPolicyAsAdmin: false, + GroupPolicyMetadata: strings.Repeat("a", 256), + }, + policy: group.NewThresholdDecisionPolicy( + "1", + time.Second, + 0, + ), + expErr: true, + expErrMsg: "limit exceeded", + }, + "member metadata too long": { + req: &group.MsgCreateGroupWithPolicy{ + Admin: addr1.String(), + Members: []group.MemberRequest{{ + Address: addr3.String(), + Weight: "1", + Metadata: strings.Repeat("a", 256), + }}, + GroupPolicyAsAdmin: false, + }, + policy: group.NewThresholdDecisionPolicy( + "1", + time.Second, + 0, + ), + expErr: true, + expErrMsg: "limit exceeded", + }, + "zero member weight": { + req: &group.MsgCreateGroupWithPolicy{ + Admin: addr1.String(), + Members: []group.MemberRequest{{ + Address: addr3.String(), + Weight: "0", + }}, + GroupPolicyAsAdmin: false, + }, + policy: group.NewThresholdDecisionPolicy( + "1", + time.Second, + 0, + ), + expErr: true, + expErrMsg: "expected a positive decimal", + }, + "decision policy threshold > total group weight": { + req: &group.MsgCreateGroupWithPolicy{ + Admin: addr1.String(), + Members: members, + GroupPolicyAsAdmin: false, + }, + policy: group.NewThresholdDecisionPolicy( + "10", + time.Second, + 0, + ), + expErr: false, + }, + } + + for msg, spec := range specs { + spec := spec + s.Run(msg, func() { + err := spec.req.SetDecisionPolicy(spec.policy) + s.Require().NoError(err) + + blockTime := sdk.UnwrapSDKContext(s.ctx).BlockTime() + res, err := s.keeper.CreateGroupWithPolicy(s.ctx, spec.req) + if spec.expErr { + s.Require().Error(err) + s.Require().Contains(err.Error(), spec.expErrMsg) + return + } + s.Require().NoError(err) + id := res.GroupId + groupPolicyAddr := res.GroupPolicyAddress + + // then all data persisted in group + loadedGroupRes, err := s.keeper.GroupInfo(s.ctx, &group.QueryGroupInfoRequest{GroupId: id}) + s.Require().NoError(err) + s.Assert().Equal(spec.req.GroupMetadata, loadedGroupRes.Info.Metadata) + s.Assert().Equal(id, loadedGroupRes.Info.Id) + if spec.req.GroupPolicyAsAdmin { + s.Assert().NotEqual(spec.req.Admin, loadedGroupRes.Info.Admin) + s.Assert().Equal(groupPolicyAddr, loadedGroupRes.Info.Admin) + } else { + s.Assert().Equal(spec.req.Admin, loadedGroupRes.Info.Admin) + } + + // and members are stored as well + membersRes, err := s.keeper.GroupMembers(s.ctx, &group.QueryGroupMembersRequest{GroupId: id}) + s.Require().NoError(err) + loadedMembers := membersRes.Members + s.Require().Equal(len(members), len(loadedMembers)) + // we reorder members by address to be able to compare them + sort.Slice(members, func(i, j int) bool { + addri, err := sdk.AccAddressFromBech32(members[i].Address) + s.Require().NoError(err) + addrj, err := sdk.AccAddressFromBech32(members[j].Address) + s.Require().NoError(err) + return bytes.Compare(addri, addrj) < 0 + }) + for i := range loadedMembers { + s.Assert().Equal(members[i].Metadata, loadedMembers[i].Member.Metadata) + s.Assert().Equal(members[i].Address, loadedMembers[i].Member.Address) + s.Assert().Equal(members[i].Weight, loadedMembers[i].Member.Weight) + s.Assert().Equal(blockTime, loadedMembers[i].Member.AddedAt) + s.Assert().Equal(id, loadedMembers[i].GroupId) + } + + // then all data persisted in group policy + groupPolicyRes, err := s.keeper.GroupPolicyInfo(s.ctx, &group.QueryGroupPolicyInfoRequest{Address: groupPolicyAddr}) + s.Require().NoError(err) + + groupPolicy := groupPolicyRes.Info + s.Assert().Equal(groupPolicyAddr, groupPolicy.Address) + s.Assert().Equal(id, groupPolicy.GroupId) + s.Assert().Equal(spec.req.GroupPolicyMetadata, groupPolicy.Metadata) + dp, err := groupPolicy.GetDecisionPolicy() + s.Assert().NoError(err) + s.Assert().Equal(spec.policy.(*group.ThresholdDecisionPolicy), dp) + if spec.req.GroupPolicyAsAdmin { + s.Assert().NotEqual(spec.req.Admin, groupPolicy.Admin) + s.Assert().Equal(groupPolicyAddr, groupPolicy.Admin) + } else { + s.Assert().Equal(spec.req.Admin, groupPolicy.Admin) + } + }) + } +} + +func (s *TestSuite) TestCreateGroupPolicy() { + addrs := s.addrs + addr1 := addrs[0] + addr4 := addrs[3] + + groupRes, err := s.keeper.CreateGroup(s.ctx, &group.MsgCreateGroup{ + Admin: addr1.String(), + Members: nil, + }) + s.Require().NoError(err) + myGroupID := groupRes.GroupId + + specs := map[string]struct { + req *group.MsgCreateGroupPolicy + policy group.DecisionPolicy + expErr bool + expErrMsg string + }{ + "all good": { + req: &group.MsgCreateGroupPolicy{ + Admin: addr1.String(), + GroupId: myGroupID, + }, + policy: group.NewThresholdDecisionPolicy( + "1", + time.Second, + 0, + ), + }, + "all good with percentage decision policy": { + req: &group.MsgCreateGroupPolicy{ + Admin: addr1.String(), + GroupId: myGroupID, + }, + policy: group.NewPercentageDecisionPolicy( + "0.5", + time.Second, + 0, + ), + }, + "decision policy threshold > total group weight": { + req: &group.MsgCreateGroupPolicy{ + Admin: addr1.String(), + GroupId: myGroupID, + }, + policy: group.NewThresholdDecisionPolicy( + "10", + time.Second, + 0, + ), + }, + "group id does not exists": { + req: &group.MsgCreateGroupPolicy{ + Admin: addr1.String(), + GroupId: 9999, + }, + policy: group.NewThresholdDecisionPolicy( + "1", + time.Second, + 0, + ), + expErr: true, + expErrMsg: "not found", + }, + "admin not group admin": { + req: &group.MsgCreateGroupPolicy{ + Admin: addr4.String(), + GroupId: myGroupID, + }, + policy: group.NewThresholdDecisionPolicy( + "1", + time.Second, + 0, + ), + expErr: true, + expErrMsg: "not group admin", + }, + "metadata too long": { + req: &group.MsgCreateGroupPolicy{ + Admin: addr1.String(), + GroupId: myGroupID, + Metadata: strings.Repeat("a", 256), + }, + policy: group.NewThresholdDecisionPolicy( + "1", + time.Second, + 0, + ), + expErr: true, + expErrMsg: "limit exceeded", + }, + "percentage decision policy with negative value": { + req: &group.MsgCreateGroupPolicy{ + Admin: addr1.String(), + GroupId: myGroupID, + }, + policy: group.NewPercentageDecisionPolicy( + "-0.5", + time.Second, + 0, + ), + expErr: true, + expErrMsg: "expected a positive decimal", + }, + "percentage decision policy with value greater than 1": { + req: &group.MsgCreateGroupPolicy{ + Admin: addr1.String(), + GroupId: myGroupID, + }, + policy: group.NewPercentageDecisionPolicy( + "2", + time.Second, + 0, + ), + expErr: true, + expErrMsg: "percentage must be > 0 and <= 1", + }, + } + for msg, spec := range specs { + spec := spec + s.Run(msg, func() { + err := spec.req.SetDecisionPolicy(spec.policy) + s.Require().NoError(err) + + res, err := s.keeper.CreateGroupPolicy(s.ctx, spec.req) + if spec.expErr { + s.Require().Error(err) + s.Require().Contains(err.Error(), spec.expErrMsg) + return + } + s.Require().NoError(err) + addr := res.Address + + // then all data persisted + groupPolicyRes, err := s.keeper.GroupPolicyInfo(s.ctx, &group.QueryGroupPolicyInfoRequest{Address: addr}) + s.Require().NoError(err) + + groupPolicy := groupPolicyRes.Info + s.Assert().Equal(addr, groupPolicy.Address) + s.Assert().Equal(myGroupID, groupPolicy.GroupId) + s.Assert().Equal(spec.req.Admin, groupPolicy.Admin) + s.Assert().Equal(spec.req.Metadata, groupPolicy.Metadata) + s.Assert().Equal(uint64(1), groupPolicy.Version) + percentageDecisionPolicy, ok := spec.policy.(*group.PercentageDecisionPolicy) + if ok { + dp, err := groupPolicy.GetDecisionPolicy() + s.Assert().NoError(err) + s.Assert().Equal(percentageDecisionPolicy, dp) + } else { + dp, err := groupPolicy.GetDecisionPolicy() + s.Assert().NoError(err) + s.Assert().Equal(spec.policy.(*group.ThresholdDecisionPolicy), dp) + } + }) + } +} + +func (s *TestSuite) TestUpdateGroupPolicyAdmin() { + addrs := s.addrs + addr1 := addrs[0] + addr2 := addrs[1] + addr5 := addrs[4] + + admin, newAdmin := addr1, addr2 + policy := group.NewThresholdDecisionPolicy( + "1", + time.Second, + 0, + ) + groupPolicyAddr, myGroupID := s.createGroupAndGroupPolicy(admin, nil, policy) + + specs := map[string]struct { + req *group.MsgUpdateGroupPolicyAdmin + expGroupPolicy *group.GroupPolicyInfo + expErr bool + }{ + "with wrong admin": { + req: &group.MsgUpdateGroupPolicyAdmin{ + Admin: addr5.String(), + GroupPolicyAddress: groupPolicyAddr, + NewAdmin: newAdmin.String(), + }, + expGroupPolicy: &group.GroupPolicyInfo{ + Admin: admin.String(), + Address: groupPolicyAddr, + GroupId: myGroupID, + Version: 2, + DecisionPolicy: nil, + CreatedAt: s.blockTime, + }, + expErr: true, + }, + "with wrong group policy": { + req: &group.MsgUpdateGroupPolicyAdmin{ + Admin: admin.String(), + GroupPolicyAddress: addr5.String(), + NewAdmin: newAdmin.String(), + }, + expGroupPolicy: &group.GroupPolicyInfo{ + Admin: admin.String(), + Address: groupPolicyAddr, + GroupId: myGroupID, + Version: 2, + DecisionPolicy: nil, + CreatedAt: s.blockTime, + }, + expErr: true, + }, + "correct data": { + req: &group.MsgUpdateGroupPolicyAdmin{ + Admin: admin.String(), + GroupPolicyAddress: groupPolicyAddr, + NewAdmin: newAdmin.String(), + }, + expGroupPolicy: &group.GroupPolicyInfo{ + Admin: newAdmin.String(), + Address: groupPolicyAddr, + GroupId: myGroupID, + Version: 2, + DecisionPolicy: nil, + CreatedAt: s.blockTime, + }, + expErr: false, + }, + } + for msg, spec := range specs { + spec := spec + err := spec.expGroupPolicy.SetDecisionPolicy(policy) + s.Require().NoError(err) + + s.Run(msg, func() { + _, err := s.keeper.UpdateGroupPolicyAdmin(s.ctx, spec.req) + if spec.expErr { + s.Require().Error(err) + return + } + s.Require().NoError(err) + res, err := s.keeper.GroupPolicyInfo(s.ctx, &group.QueryGroupPolicyInfoRequest{ + Address: groupPolicyAddr, + }) + s.Require().NoError(err) + s.Assert().Equal(spec.expGroupPolicy, res.Info) + }) + } +} + +func (s *TestSuite) TestUpdateGroupPolicyMetadata() { + addrs := s.addrs + addr1 := addrs[0] + addr5 := addrs[4] + + admin := addr1 + policy := group.NewThresholdDecisionPolicy( + "1", + time.Second, + 0, + ) + groupPolicyAddr, myGroupID := s.createGroupAndGroupPolicy(admin, nil, policy) + + specs := map[string]struct { + req *group.MsgUpdateGroupPolicyMetadata + expGroupPolicy *group.GroupPolicyInfo + expErr bool + }{ + "with wrong admin": { + req: &group.MsgUpdateGroupPolicyMetadata{ + Admin: addr5.String(), + GroupPolicyAddress: groupPolicyAddr, + }, + expGroupPolicy: &group.GroupPolicyInfo{}, + expErr: true, + }, + "with wrong group policy": { + req: &group.MsgUpdateGroupPolicyMetadata{ + Admin: admin.String(), + GroupPolicyAddress: addr5.String(), + }, + expGroupPolicy: &group.GroupPolicyInfo{}, + expErr: true, + }, + "with comment too long": { + req: &group.MsgUpdateGroupPolicyMetadata{ + Admin: admin.String(), + GroupPolicyAddress: addr5.String(), + }, + expGroupPolicy: &group.GroupPolicyInfo{}, + expErr: true, + }, + "correct data": { + req: &group.MsgUpdateGroupPolicyMetadata{ + Admin: admin.String(), + GroupPolicyAddress: groupPolicyAddr, + }, + expGroupPolicy: &group.GroupPolicyInfo{ + Admin: admin.String(), + Address: groupPolicyAddr, + GroupId: myGroupID, + Version: 2, + DecisionPolicy: nil, + CreatedAt: s.blockTime, + }, + expErr: false, + }, + } + for msg, spec := range specs { + spec := spec + err := spec.expGroupPolicy.SetDecisionPolicy(policy) + s.Require().NoError(err) + + s.Run(msg, func() { + _, err := s.keeper.UpdateGroupPolicyMetadata(s.ctx, spec.req) + if spec.expErr { + s.Require().Error(err) + return + } + s.Require().NoError(err) + res, err := s.keeper.GroupPolicyInfo(s.ctx, &group.QueryGroupPolicyInfoRequest{ + Address: groupPolicyAddr, + }) + s.Require().NoError(err) + s.Assert().Equal(spec.expGroupPolicy, res.Info) + }) + } +} + +func (s *TestSuite) TestUpdateGroupPolicyDecisionPolicy() { + addrs := s.addrs + addr1 := addrs[0] + addr5 := addrs[4] + + admin := addr1 + policy := group.NewThresholdDecisionPolicy( + "1", + time.Second, + 0, + ) + groupPolicyAddr, myGroupID := s.createGroupAndGroupPolicy(admin, nil, policy) + + specs := map[string]struct { + preRun func(admin sdk.AccAddress) (policyAddr string, groupId uint64) + req *group.MsgUpdateGroupPolicyDecisionPolicy + policy group.DecisionPolicy + expGroupPolicy *group.GroupPolicyInfo + expErr bool + }{ + "with wrong admin": { + req: &group.MsgUpdateGroupPolicyDecisionPolicy{ + Admin: addr5.String(), + GroupPolicyAddress: groupPolicyAddr, + }, + policy: policy, + expGroupPolicy: &group.GroupPolicyInfo{}, + expErr: true, + }, + "with wrong group policy": { + req: &group.MsgUpdateGroupPolicyDecisionPolicy{ + Admin: admin.String(), + GroupPolicyAddress: addr5.String(), + }, + policy: policy, + expGroupPolicy: &group.GroupPolicyInfo{}, + expErr: true, + }, + "correct data": { + req: &group.MsgUpdateGroupPolicyDecisionPolicy{ + Admin: admin.String(), + GroupPolicyAddress: groupPolicyAddr, + }, + policy: group.NewThresholdDecisionPolicy( + "2", + time.Duration(2)*time.Second, + 0, + ), + expGroupPolicy: &group.GroupPolicyInfo{ + Admin: admin.String(), + Address: groupPolicyAddr, + GroupId: myGroupID, + Version: 2, + DecisionPolicy: nil, + CreatedAt: s.blockTime, + }, + expErr: false, + }, + "correct data with percentage decision policy": { + preRun: func(admin sdk.AccAddress) (string, uint64) { + return s.createGroupAndGroupPolicy(admin, nil, policy) + }, + req: &group.MsgUpdateGroupPolicyDecisionPolicy{ + Admin: admin.String(), + GroupPolicyAddress: groupPolicyAddr, + }, + policy: group.NewPercentageDecisionPolicy( + "0.5", + time.Duration(2)*time.Second, + 0, + ), + expGroupPolicy: &group.GroupPolicyInfo{ + Admin: admin.String(), + DecisionPolicy: nil, + Version: 2, + CreatedAt: s.blockTime, + }, + expErr: false, + }, + } + for msg, spec := range specs { + spec := spec + policyAddr := groupPolicyAddr + err := spec.expGroupPolicy.SetDecisionPolicy(spec.policy) + s.Require().NoError(err) + if spec.preRun != nil { + policyAddr1, groupId := spec.preRun(admin) + policyAddr = policyAddr1 + + // update the expected info with new group policy details + spec.expGroupPolicy.Address = policyAddr1 + spec.expGroupPolicy.GroupId = groupId + + // update req with new group policy addr + spec.req.GroupPolicyAddress = policyAddr1 + } + + err = spec.req.SetDecisionPolicy(spec.policy) + s.Require().NoError(err) + + s.Run(msg, func() { + _, err := s.keeper.UpdateGroupPolicyDecisionPolicy(s.ctx, spec.req) + if spec.expErr { + s.Require().Error(err) + return + } + s.Require().NoError(err) + res, err := s.keeper.GroupPolicyInfo(s.ctx, &group.QueryGroupPolicyInfoRequest{ + Address: policyAddr, + }) + s.Require().NoError(err) + s.Assert().Equal(spec.expGroupPolicy, res.Info) + }) + } +} + +func (s *TestSuite) TestGroupPoliciesByAdminOrGroup() { + addrs := s.addrs + addr2 := addrs[1] + + admin := addr2 + groupRes, err := s.keeper.CreateGroup(s.ctx, &group.MsgCreateGroup{ + Admin: admin.String(), + Members: nil, + }) + s.Require().NoError(err) + myGroupID := groupRes.GroupId + + policies := []group.DecisionPolicy{ + group.NewThresholdDecisionPolicy( + "1", + time.Second, + 0, + ), + group.NewThresholdDecisionPolicy( + "10", + time.Second, + 0, + ), + group.NewPercentageDecisionPolicy( + "0.5", + time.Second, + 0, + ), + } + + count := 3 + expectAccs := make([]*group.GroupPolicyInfo, count) + for i := range expectAccs { + req := &group.MsgCreateGroupPolicy{ + Admin: admin.String(), + GroupId: myGroupID, + } + err := req.SetDecisionPolicy(policies[i]) + s.Require().NoError(err) + res, err := s.keeper.CreateGroupPolicy(s.ctx, req) + s.Require().NoError(err) + + expectAcc := &group.GroupPolicyInfo{ + Address: res.Address, + Admin: admin.String(), + GroupId: myGroupID, + Version: uint64(1), + CreatedAt: s.blockTime, + } + err = expectAcc.SetDecisionPolicy(policies[i]) + s.Require().NoError(err) + expectAccs[i] = expectAcc + } + sort.Slice(expectAccs, func(i, j int) bool { return expectAccs[i].Address < expectAccs[j].Address }) + + // query group policy by group + policiesByGroupRes, err := s.keeper.GroupPoliciesByGroup(s.ctx, &group.QueryGroupPoliciesByGroupRequest{ + GroupId: myGroupID, + }) + s.Require().NoError(err) + policyAccs := policiesByGroupRes.GroupPolicies + s.Require().Equal(len(policyAccs), count) + // we reorder policyAccs by address to be able to compare them + sort.Slice(policyAccs, func(i, j int) bool { return policyAccs[i].Address < policyAccs[j].Address }) + for i := range policyAccs { + s.Assert().Equal(policyAccs[i].Address, expectAccs[i].Address) + s.Assert().Equal(policyAccs[i].GroupId, expectAccs[i].GroupId) + s.Assert().Equal(policyAccs[i].Admin, expectAccs[i].Admin) + s.Assert().Equal(policyAccs[i].Metadata, expectAccs[i].Metadata) + s.Assert().Equal(policyAccs[i].Version, expectAccs[i].Version) + s.Assert().Equal(policyAccs[i].CreatedAt, expectAccs[i].CreatedAt) + dp1, err := policyAccs[i].GetDecisionPolicy() + s.Assert().NoError(err) + dp2, err := expectAccs[i].GetDecisionPolicy() + s.Assert().NoError(err) + s.Assert().Equal(dp1, dp2) + } + + // query group policy by admin + policiesByAdminRes, err := s.keeper.GroupPoliciesByAdmin(s.ctx, &group.QueryGroupPoliciesByAdminRequest{ + Admin: admin.String(), + }) + s.Require().NoError(err) + policyAccs = policiesByAdminRes.GroupPolicies + s.Require().Equal(len(policyAccs), count) + // we reorder policyAccs by address to be able to compare them + sort.Slice(policyAccs, func(i, j int) bool { return policyAccs[i].Address < policyAccs[j].Address }) + for i := range policyAccs { + s.Assert().Equal(policyAccs[i].Address, expectAccs[i].Address) + s.Assert().Equal(policyAccs[i].GroupId, expectAccs[i].GroupId) + s.Assert().Equal(policyAccs[i].Admin, expectAccs[i].Admin) + s.Assert().Equal(policyAccs[i].Metadata, expectAccs[i].Metadata) + s.Assert().Equal(policyAccs[i].Version, expectAccs[i].Version) + s.Assert().Equal(policyAccs[i].CreatedAt, expectAccs[i].CreatedAt) + dp1, err := policyAccs[i].GetDecisionPolicy() + s.Assert().NoError(err) + dp2, err := expectAccs[i].GetDecisionPolicy() + s.Assert().NoError(err) + s.Assert().Equal(dp1, dp2) + } +} + +func (s *TestSuite) TestSubmitProposal() { + addrs := s.addrs + addr1 := addrs[0] + addr2 := addrs[1] + addr4 := addrs[3] + addr5 := addrs[4] + + myGroupID := s.groupID + accountAddr := s.groupPolicyAddr + + msgSend := &banktypes.MsgSend{ + FromAddress: s.groupPolicyAddr.String(), + ToAddress: addr2.String(), + Amount: sdk.Coins{sdk.NewInt64Coin("test", 100)}, + } + + policyReq := &group.MsgCreateGroupPolicy{ + Admin: addr1.String(), + GroupId: myGroupID, + } + policy := group.NewThresholdDecisionPolicy( + "100", + time.Second, + 0, + ) + err := policyReq.SetDecisionPolicy(policy) + s.Require().NoError(err) + bigThresholdRes, err := s.keeper.CreateGroupPolicy(s.ctx, policyReq) + s.Require().NoError(err) + bigThresholdAddr := bigThresholdRes.Address + + defaultProposal := group.Proposal{ + GroupPolicyAddress: accountAddr.String(), + Status: group.PROPOSAL_STATUS_SUBMITTED, + FinalTallyResult: group.TallyResult{ + YesCount: "0", + NoCount: "0", + AbstainCount: "0", + NoWithVetoCount: "0", + }, + ExecutorResult: group.PROPOSAL_EXECUTOR_RESULT_NOT_RUN, + } + specs := map[string]struct { + req *group.MsgSubmitProposal + msgs []sdk.Msg + expProposal group.Proposal + expErr bool + postRun func(sdkCtx sdk.Context) + }{ + "all good with minimal fields set": { + req: &group.MsgSubmitProposal{ + GroupPolicyAddress: accountAddr.String(), + Proposers: []string{addr2.String()}, + }, + expProposal: defaultProposal, + postRun: func(sdkCtx sdk.Context) {}, + }, + "all good with good msg payload": { + req: &group.MsgSubmitProposal{ + GroupPolicyAddress: accountAddr.String(), + Proposers: []string{addr2.String()}, + }, + msgs: []sdk.Msg{&banktypes.MsgSend{ + FromAddress: accountAddr.String(), + ToAddress: addr2.String(), + Amount: sdk.Coins{sdk.NewInt64Coin("token", 100)}, + }}, + expProposal: defaultProposal, + postRun: func(sdkCtx sdk.Context) {}, + }, + "metadata too long": { + req: &group.MsgSubmitProposal{ + GroupPolicyAddress: accountAddr.String(), + Proposers: []string{addr2.String()}, + Metadata: strings.Repeat("a", 256), + }, + expErr: true, + postRun: func(sdkCtx sdk.Context) {}, + }, + "group policy required": { + req: &group.MsgSubmitProposal{ + Proposers: []string{addr2.String()}, + }, + expErr: true, + postRun: func(sdkCtx sdk.Context) {}, + }, + "existing group policy required": { + req: &group.MsgSubmitProposal{ + GroupPolicyAddress: addr1.String(), + Proposers: []string{addr2.String()}, + }, + expErr: true, + postRun: func(sdkCtx sdk.Context) {}, + }, + "decision policy threshold > total group weight": { + req: &group.MsgSubmitProposal{ + GroupPolicyAddress: bigThresholdAddr, + Proposers: []string{addr2.String()}, + }, + expErr: false, + expProposal: group.Proposal{ + GroupPolicyAddress: bigThresholdAddr, + Status: group.PROPOSAL_STATUS_SUBMITTED, + FinalTallyResult: group.DefaultTallyResult(), + ExecutorResult: group.PROPOSAL_EXECUTOR_RESULT_NOT_RUN, + }, + postRun: func(sdkCtx sdk.Context) {}, + }, + "only group members can create a proposal": { + req: &group.MsgSubmitProposal{ + GroupPolicyAddress: accountAddr.String(), + Proposers: []string{addr4.String()}, + }, + expErr: true, + postRun: func(sdkCtx sdk.Context) {}, + }, + "all proposers must be in group": { + req: &group.MsgSubmitProposal{ + GroupPolicyAddress: accountAddr.String(), + Proposers: []string{addr2.String(), addr4.String()}, + }, + expErr: true, + postRun: func(sdkCtx sdk.Context) {}, + }, + "admin that is not a group member can not create proposal": { + req: &group.MsgSubmitProposal{ + GroupPolicyAddress: accountAddr.String(), + Proposers: []string{addr1.String()}, + }, + expErr: true, + postRun: func(sdkCtx sdk.Context) {}, + }, + "reject msgs that are not authz by group policy": { + req: &group.MsgSubmitProposal{ + GroupPolicyAddress: accountAddr.String(), + Proposers: []string{addr2.String()}, + }, + msgs: []sdk.Msg{&testdata.TestMsg{Signers: []string{addr1.String()}}}, + expErr: true, + postRun: func(sdkCtx sdk.Context) {}, + }, + "with try exec": { + req: &group.MsgSubmitProposal{ + GroupPolicyAddress: accountAddr.String(), + Proposers: []string{addr2.String()}, + Exec: group.Exec_EXEC_TRY, + }, + msgs: []sdk.Msg{msgSend}, + expProposal: group.Proposal{ + GroupPolicyAddress: accountAddr.String(), + Status: group.PROPOSAL_STATUS_ACCEPTED, + FinalTallyResult: group.TallyResult{ + YesCount: "2", + NoCount: "0", + AbstainCount: "0", + NoWithVetoCount: "0", + }, + ExecutorResult: group.PROPOSAL_EXECUTOR_RESULT_SUCCESS, + }, + postRun: func(sdkCtx sdk.Context) { + fromBalances := s.app.BankKeeper.GetAllBalances(sdkCtx, accountAddr) + s.Require().Contains(fromBalances, sdk.NewInt64Coin("test", 9900)) + toBalances := s.app.BankKeeper.GetAllBalances(sdkCtx, addr2) + s.Require().Contains(toBalances, sdk.NewInt64Coin("test", 100)) + }, + }, + "with try exec, not enough yes votes for proposal to pass": { + req: &group.MsgSubmitProposal{ + GroupPolicyAddress: accountAddr.String(), + Proposers: []string{addr5.String()}, + Exec: group.Exec_EXEC_TRY, + }, + msgs: []sdk.Msg{msgSend}, + expProposal: group.Proposal{ + GroupPolicyAddress: accountAddr.String(), + Status: group.PROPOSAL_STATUS_SUBMITTED, + FinalTallyResult: group.TallyResult{ + YesCount: "0", // Since tally doesn't pass Allow(), we consider the proposal not final + NoCount: "0", + AbstainCount: "0", + NoWithVetoCount: "0", + }, + ExecutorResult: group.PROPOSAL_EXECUTOR_RESULT_NOT_RUN, + }, + postRun: func(sdkCtx sdk.Context) {}, + }, + } + for msg, spec := range specs { + spec := spec + s.Run(msg, func() { + err := spec.req.SetMsgs(spec.msgs) + s.Require().NoError(err) + + res, err := s.keeper.SubmitProposal(s.ctx, spec.req) + if spec.expErr { + s.Require().Error(err) + return + } + s.Require().NoError(err) + id := res.ProposalId + + if !(spec.expProposal.ExecutorResult == group.PROPOSAL_EXECUTOR_RESULT_SUCCESS) { + // then all data persisted + proposalRes, err := s.keeper.Proposal(s.ctx, &group.QueryProposalRequest{ProposalId: id}) + s.Require().NoError(err) + proposal := proposalRes.Proposal + + s.Assert().Equal(spec.expProposal.GroupPolicyAddress, proposal.GroupPolicyAddress) + s.Assert().Equal(spec.req.Metadata, proposal.Metadata) + s.Assert().Equal(spec.req.Proposers, proposal.Proposers) + s.Assert().Equal(s.blockTime, proposal.SubmitTime) + s.Assert().Equal(uint64(1), proposal.GroupVersion) + s.Assert().Equal(uint64(1), proposal.GroupPolicyVersion) + s.Assert().Equal(spec.expProposal.Status, proposal.Status) + s.Assert().Equal(spec.expProposal.FinalTallyResult, proposal.FinalTallyResult) + s.Assert().Equal(spec.expProposal.ExecutorResult, proposal.ExecutorResult) + s.Assert().Equal(s.blockTime.Add(time.Second), proposal.VotingPeriodEnd) + + msgs, err := proposal.GetMsgs() + s.Assert().NoError(err) + if spec.msgs == nil { // then empty list is ok + s.Assert().Len(msgs, 0) + } else { + s.Assert().Equal(spec.msgs, msgs) + } + } + + spec.postRun(s.sdkCtx) + }) + } +} + +func (s *TestSuite) TestWithdrawProposal() { + addrs := s.addrs + addr2 := addrs[1] + addr5 := addrs[4] + + msgSend := &banktypes.MsgSend{ + FromAddress: s.groupPolicyAddr.String(), + ToAddress: addr2.String(), + Amount: sdk.Coins{sdk.NewInt64Coin("test", 100)}, + } + + proposers := []string{addr2.String()} + proposalID := submitProposal(s.ctx, s, []sdk.Msg{msgSend}, proposers) + + specs := map[string]struct { + preRun func(sdkCtx sdk.Context) uint64 + proposalId uint64 + admin string + expErrMsg string + }{ + "wrong admin": { + preRun: func(sdkCtx sdk.Context) uint64 { + return submitProposal(s.ctx, s, []sdk.Msg{msgSend}, proposers) + }, + admin: addr5.String(), + expErrMsg: "unauthorized", + }, + "wrong proposalId": { + preRun: func(sdkCtx sdk.Context) uint64 { + return 1111 + }, + admin: proposers[0], + expErrMsg: "not found", + }, + "happy case with proposer": { + preRun: func(sdkCtx sdk.Context) uint64 { + return submitProposal(s.ctx, s, []sdk.Msg{msgSend}, proposers) + }, + proposalId: proposalID, + admin: proposers[0], + }, + "already closed proposal": { + preRun: func(sdkCtx sdk.Context) uint64 { + pId := submitProposal(s.ctx, s, []sdk.Msg{msgSend}, proposers) + _, err := s.keeper.WithdrawProposal(s.ctx, &group.MsgWithdrawProposal{ + ProposalId: pId, + Address: proposers[0], + }) + s.Require().NoError(err) + return pId + }, + proposalId: proposalID, + admin: proposers[0], + expErrMsg: "cannot withdraw a proposal with the status of PROPOSAL_STATUS_WITHDRAWN", + }, + "happy case with group admin address": { + preRun: func(sdkCtx sdk.Context) uint64 { + return submitProposal(s.ctx, s, []sdk.Msg{msgSend}, proposers) + }, + proposalId: proposalID, + admin: proposers[0], + }, + } + for msg, spec := range specs { + spec := spec + s.Run(msg, func() { + pId := spec.preRun(s.sdkCtx) + + _, err := s.keeper.WithdrawProposal(s.ctx, &group.MsgWithdrawProposal{ + ProposalId: pId, + Address: spec.admin, + }) + + if spec.expErrMsg != "" { + s.Require().Error(err) + s.Require().Contains(err.Error(), spec.expErrMsg) + return + } + + s.Require().NoError(err) + resp, err := s.keeper.Proposal(s.ctx, &group.QueryProposalRequest{ProposalId: pId}) + s.Require().NoError(err) + s.Require().Equal(resp.GetProposal().Status, group.PROPOSAL_STATUS_WITHDRAWN) + }) + } +} + +func (s *TestSuite) TestVote() { + addrs := s.addrs + addr1 := addrs[0] + addr2 := addrs[1] + addr3 := addrs[2] + addr4 := addrs[3] + addr5 := addrs[4] + members := []group.MemberRequest{ + {Address: addr4.String(), Weight: "1"}, + {Address: addr3.String(), Weight: "2"}, + } + groupRes, err := s.keeper.CreateGroup(s.ctx, &group.MsgCreateGroup{ + Admin: addr1.String(), + Members: members, + }) + s.Require().NoError(err) + myGroupID := groupRes.GroupId + + policy := group.NewThresholdDecisionPolicy( + "2", + time.Duration(2), + 0, + ) + policyReq := &group.MsgCreateGroupPolicy{ + Admin: addr1.String(), + GroupId: myGroupID, + } + err = policyReq.SetDecisionPolicy(policy) + s.Require().NoError(err) + policyRes, err := s.keeper.CreateGroupPolicy(s.ctx, policyReq) + s.Require().NoError(err) + accountAddr := policyRes.Address + groupPolicy, err := sdk.AccAddressFromBech32(accountAddr) + s.Require().NoError(err) + s.Require().NotNil(groupPolicy) + + s.Require().NoError(testutil.FundAccount(s.app.BankKeeper, s.sdkCtx, groupPolicy, sdk.Coins{sdk.NewInt64Coin("test", 10000)})) + + req := &group.MsgSubmitProposal{ + GroupPolicyAddress: accountAddr, + Proposers: []string{addr4.String()}, + Messages: nil, + } + err = req.SetMsgs([]sdk.Msg{&banktypes.MsgSend{ + FromAddress: accountAddr, + ToAddress: addr5.String(), + Amount: sdk.Coins{sdk.NewInt64Coin("test", 100)}, + }}) + s.Require().NoError(err) + + proposalRes, err := s.keeper.SubmitProposal(s.ctx, req) + s.Require().NoError(err) + myProposalID := proposalRes.ProposalId + + // proposals by group policy + proposalsRes, err := s.keeper.ProposalsByGroupPolicy(s.ctx, &group.QueryProposalsByGroupPolicyRequest{ + Address: accountAddr, + }) + s.Require().NoError(err) + proposals := proposalsRes.Proposals + s.Require().Equal(len(proposals), 1) + s.Assert().Equal(req.GroupPolicyAddress, proposals[0].GroupPolicyAddress) + s.Assert().Equal(req.Metadata, proposals[0].Metadata) + s.Assert().Equal(req.Proposers, proposals[0].Proposers) + s.Assert().Equal(s.blockTime, proposals[0].SubmitTime) + s.Assert().Equal(uint64(1), proposals[0].GroupVersion) + s.Assert().Equal(uint64(1), proposals[0].GroupPolicyVersion) + s.Assert().Equal(group.PROPOSAL_STATUS_SUBMITTED, proposals[0].Status) + s.Assert().Equal(group.DefaultTallyResult(), proposals[0].FinalTallyResult) + + specs := map[string]struct { + srcCtx sdk.Context + expTallyResult group.TallyResult // expected after tallying + isFinal bool // is the tally result final? + req *group.MsgVote + doBefore func(ctx context.Context) + postRun func(sdkCtx sdk.Context) + expProposalStatus group.ProposalStatus // expected after tallying + expExecutorResult group.ProposalExecutorResult // expected after tallying + expErr bool + }{ + "vote yes": { + req: &group.MsgVote{ + ProposalId: myProposalID, + Voter: addr4.String(), + Option: group.VOTE_OPTION_YES, + }, + expTallyResult: group.TallyResult{ + YesCount: "1", + NoCount: "0", + AbstainCount: "0", + NoWithVetoCount: "0", + }, + expProposalStatus: group.PROPOSAL_STATUS_SUBMITTED, + expExecutorResult: group.PROPOSAL_EXECUTOR_RESULT_NOT_RUN, + postRun: func(sdkCtx sdk.Context) {}, + }, + "with try exec": { + req: &group.MsgVote{ + ProposalId: myProposalID, + Voter: addr3.String(), + Option: group.VOTE_OPTION_YES, + Exec: group.Exec_EXEC_TRY, + }, + expTallyResult: group.TallyResult{ + YesCount: "2", + NoCount: "0", + AbstainCount: "0", + NoWithVetoCount: "0", + }, + isFinal: true, + expProposalStatus: group.PROPOSAL_STATUS_ACCEPTED, + expExecutorResult: group.PROPOSAL_EXECUTOR_RESULT_SUCCESS, + postRun: func(sdkCtx sdk.Context) { + fromBalances := s.app.BankKeeper.GetAllBalances(sdkCtx, groupPolicy) + s.Require().Contains(fromBalances, sdk.NewInt64Coin("test", 9900)) + toBalances := s.app.BankKeeper.GetAllBalances(sdkCtx, addr5) + s.Require().Contains(toBalances, sdk.NewInt64Coin("test", 100)) + }, + }, + "with try exec, not enough yes votes for proposal to pass": { + req: &group.MsgVote{ + ProposalId: myProposalID, + Voter: addr4.String(), + Option: group.VOTE_OPTION_YES, + Exec: group.Exec_EXEC_TRY, + }, + expTallyResult: group.TallyResult{ + YesCount: "1", + NoCount: "0", + AbstainCount: "0", + NoWithVetoCount: "0", + }, + expProposalStatus: group.PROPOSAL_STATUS_SUBMITTED, + expExecutorResult: group.PROPOSAL_EXECUTOR_RESULT_NOT_RUN, + postRun: func(sdkCtx sdk.Context) {}, + }, + "vote no": { + req: &group.MsgVote{ + ProposalId: myProposalID, + Voter: addr4.String(), + Option: group.VOTE_OPTION_NO, + }, + expTallyResult: group.TallyResult{ + YesCount: "0", + NoCount: "1", + AbstainCount: "0", + NoWithVetoCount: "0", + }, + expProposalStatus: group.PROPOSAL_STATUS_SUBMITTED, + expExecutorResult: group.PROPOSAL_EXECUTOR_RESULT_NOT_RUN, + postRun: func(sdkCtx sdk.Context) {}, + }, + "vote abstain": { + req: &group.MsgVote{ + ProposalId: myProposalID, + Voter: addr4.String(), + Option: group.VOTE_OPTION_ABSTAIN, + }, + expTallyResult: group.TallyResult{ + YesCount: "0", + NoCount: "0", + AbstainCount: "1", + NoWithVetoCount: "0", + }, + expProposalStatus: group.PROPOSAL_STATUS_SUBMITTED, + expExecutorResult: group.PROPOSAL_EXECUTOR_RESULT_NOT_RUN, + postRun: func(sdkCtx sdk.Context) {}, + }, + "vote veto": { + req: &group.MsgVote{ + ProposalId: myProposalID, + Voter: addr4.String(), + Option: group.VOTE_OPTION_NO_WITH_VETO, + }, + expTallyResult: group.TallyResult{ + YesCount: "0", + NoCount: "0", + AbstainCount: "0", + NoWithVetoCount: "1", + }, + expProposalStatus: group.PROPOSAL_STATUS_SUBMITTED, + expExecutorResult: group.PROPOSAL_EXECUTOR_RESULT_NOT_RUN, + postRun: func(sdkCtx sdk.Context) {}, + }, + "apply decision policy early": { + req: &group.MsgVote{ + ProposalId: myProposalID, + Voter: addr3.String(), + Option: group.VOTE_OPTION_YES, + }, + expTallyResult: group.TallyResult{ + YesCount: "2", + NoCount: "0", + AbstainCount: "0", + NoWithVetoCount: "0", + }, + expProposalStatus: group.PROPOSAL_STATUS_ACCEPTED, + expExecutorResult: group.PROPOSAL_EXECUTOR_RESULT_NOT_RUN, + postRun: func(sdkCtx sdk.Context) {}, + }, + "reject new votes when final decision is made already": { + req: &group.MsgVote{ + ProposalId: myProposalID, + Voter: addr4.String(), + Option: group.VOTE_OPTION_YES, + }, + doBefore: func(ctx context.Context) { + _, err := s.keeper.Vote(ctx, &group.MsgVote{ + ProposalId: myProposalID, + Voter: addr3.String(), + Option: group.VOTE_OPTION_NO_WITH_VETO, + Exec: 1, // Execute the proposal so that its status is final + }) + s.Require().NoError(err) + }, + expErr: true, + postRun: func(sdkCtx sdk.Context) {}, + }, + "metadata too long": { + req: &group.MsgVote{ + ProposalId: myProposalID, + Voter: addr4.String(), + Option: group.VOTE_OPTION_NO, + Metadata: strings.Repeat("a", 256), + }, + expErr: true, + postRun: func(sdkCtx sdk.Context) {}, + }, + "existing proposal required": { + req: &group.MsgVote{ + ProposalId: 999, + Voter: addr4.String(), + Option: group.VOTE_OPTION_NO, + }, + expErr: true, + postRun: func(sdkCtx sdk.Context) {}, + }, + "empty vote option": { + req: &group.MsgVote{ + ProposalId: myProposalID, + Voter: addr4.String(), + }, + expErr: true, + postRun: func(sdkCtx sdk.Context) {}, + }, + "invalid vote option": { + req: &group.MsgVote{ + ProposalId: myProposalID, + Voter: addr4.String(), + Option: 5, + }, + expErr: true, + postRun: func(sdkCtx sdk.Context) {}, + }, + "voter must be in group": { + req: &group.MsgVote{ + ProposalId: myProposalID, + Voter: addr2.String(), + Option: group.VOTE_OPTION_NO, + }, + expErr: true, + postRun: func(sdkCtx sdk.Context) {}, + }, + "admin that is not a group member can not vote": { + req: &group.MsgVote{ + ProposalId: myProposalID, + Voter: addr1.String(), + Option: group.VOTE_OPTION_NO, + }, + expErr: true, + postRun: func(sdkCtx sdk.Context) {}, + }, + "on voting period end": { + req: &group.MsgVote{ + ProposalId: myProposalID, + Voter: addr4.String(), + Option: group.VOTE_OPTION_NO, + }, + srcCtx: s.sdkCtx.WithBlockTime(s.blockTime.Add(time.Second)), + expErr: true, + postRun: func(sdkCtx sdk.Context) {}, + }, + "closed already": { + req: &group.MsgVote{ + ProposalId: myProposalID, + Voter: addr4.String(), + Option: group.VOTE_OPTION_NO, + }, + doBefore: func(ctx context.Context) { + _, err := s.keeper.Vote(ctx, &group.MsgVote{ + ProposalId: myProposalID, + Voter: addr3.String(), + Option: group.VOTE_OPTION_YES, + Exec: 1, // Execute to close the proposal. + }) + s.Require().NoError(err) + }, + expErr: true, + postRun: func(sdkCtx sdk.Context) {}, + }, + "voted already": { + req: &group.MsgVote{ + ProposalId: myProposalID, + Voter: addr4.String(), + Option: group.VOTE_OPTION_NO, + }, + doBefore: func(ctx context.Context) { + _, err := s.keeper.Vote(ctx, &group.MsgVote{ + ProposalId: myProposalID, + Voter: addr4.String(), + Option: group.VOTE_OPTION_YES, + }) + s.Require().NoError(err) + }, + expErr: true, + postRun: func(sdkCtx sdk.Context) {}, + }, + } + for msg, spec := range specs { + spec := spec + s.Run(msg, func() { + sdkCtx := s.sdkCtx + if !spec.srcCtx.IsZero() { + sdkCtx = spec.srcCtx + } + sdkCtx, _ = sdkCtx.CacheContext() + ctx := sdk.WrapSDKContext(sdkCtx) + + if spec.doBefore != nil { + spec.doBefore(ctx) + } + _, err := s.keeper.Vote(ctx, spec.req) + if spec.expErr { + s.Require().Error(err) + return + } + s.Require().NoError(err) + + s.Require().NoError(err) + + if !(spec.expExecutorResult == group.PROPOSAL_EXECUTOR_RESULT_SUCCESS) { + // vote is stored and all data persisted + res, err := s.keeper.VoteByProposalVoter(ctx, &group.QueryVoteByProposalVoterRequest{ + ProposalId: spec.req.ProposalId, + Voter: spec.req.Voter, + }) + s.Require().NoError(err) + loaded := res.Vote + s.Assert().Equal(spec.req.ProposalId, loaded.ProposalId) + s.Assert().Equal(spec.req.Voter, loaded.Voter) + s.Assert().Equal(spec.req.Option, loaded.Option) + s.Assert().Equal(spec.req.Metadata, loaded.Metadata) + s.Assert().Equal(s.blockTime, loaded.SubmitTime) + + // query votes by proposal + votesByProposalRes, err := s.keeper.VotesByProposal(ctx, &group.QueryVotesByProposalRequest{ + ProposalId: spec.req.ProposalId, + }) + s.Require().NoError(err) + votesByProposal := votesByProposalRes.Votes + s.Require().Equal(1, len(votesByProposal)) + vote := votesByProposal[0] + s.Assert().Equal(spec.req.ProposalId, vote.ProposalId) + s.Assert().Equal(spec.req.Voter, vote.Voter) + s.Assert().Equal(spec.req.Option, vote.Option) + s.Assert().Equal(spec.req.Metadata, vote.Metadata) + s.Assert().Equal(s.blockTime, vote.SubmitTime) + + // query votes by voter + voter := spec.req.Voter + votesByVoterRes, err := s.keeper.VotesByVoter(ctx, &group.QueryVotesByVoterRequest{ + Voter: voter, + }) + s.Require().NoError(err) + votesByVoter := votesByVoterRes.Votes + s.Require().Equal(1, len(votesByVoter)) + s.Assert().Equal(spec.req.ProposalId, votesByVoter[0].ProposalId) + s.Assert().Equal(voter, votesByVoter[0].Voter) + s.Assert().Equal(spec.req.Option, votesByVoter[0].Option) + s.Assert().Equal(spec.req.Metadata, votesByVoter[0].Metadata) + s.Assert().Equal(s.blockTime, votesByVoter[0].SubmitTime) + + proposalRes, err := s.keeper.Proposal(ctx, &group.QueryProposalRequest{ + ProposalId: spec.req.ProposalId, + }) + s.Require().NoError(err) type TestSuite struct { suite.Suite