diff --git a/sei-cosmos/x/gov/abci.go b/sei-cosmos/x/gov/abci.go index 528c7ed707..612825da82 100644 --- a/sei-cosmos/x/gov/abci.go +++ b/sei-cosmos/x/gov/abci.go @@ -13,7 +13,10 @@ import ( var logger = seilog.NewLogger("cosmos", "x", "gov") -// EndBlocker called every block, process inflation, update validator set. +// MaxVotesProcessedPerBlock is the governance vote-record budget shared by tallying and cleanup. +const MaxVotesProcessedPerBlock = 1000 + +// EndBlocker expires governance proposals and advances bounded vote tally work. func EndBlocker(ctx sdk.Context, keeper keeper.Keeper) { endBlockerStart := time.Now() defer func() { @@ -50,11 +53,17 @@ func EndBlocker(ctx sdk.Context, keeper keeper.Keeper) { return false }) + remainingVotes := MaxVotesProcessedPerBlock + // fetch active proposals whose voting periods have ended (are passed the block time) keeper.IterateActiveProposalsQueue(ctx, ctx.BlockHeader().Time, func(proposal types.Proposal) bool { var tagValue, logMsg string - passes, burnDeposits, tallyResults := keeper.Tally(ctx, proposal) + complete, processed, passes, burnDeposits, tallyResults := keeper.TallyIncremental(ctx, proposal, remainingVotes) + remainingVotes -= processed + if !complete { + return true + } // If an expedited proposal fails, we do not want to update // the deposit at this point since the proposal is converted to regular. @@ -141,6 +150,8 @@ func EndBlocker(ctx sdk.Context, keeper keeper.Keeper) { sdk.NewAttribute(types.AttributeKeyProposalResult, tagValue), ), ) - return false + return remainingVotes == 0 }) + + keeper.CleanupTallyVotes(ctx, remainingVotes) } diff --git a/sei-cosmos/x/gov/abci_test.go b/sei-cosmos/x/gov/abci_test.go index 4dd8f55935..9244d1b7f3 100644 --- a/sei-cosmos/x/gov/abci_test.go +++ b/sei-cosmos/x/gov/abci_test.go @@ -2,6 +2,7 @@ package gov_test import ( "context" + "encoding/binary" "testing" "time" @@ -606,6 +607,55 @@ func TestEndBlockerProposalHandlerFailed(t *testing.T) { gov.EndBlocker(ctx, app.GovKeeper) } +func TestEndBlockerBoundsVoteTallyAndCleanupWork(t *testing.T) { + app := seiapp.Setup(t, false, false, false) + ctx := app.BaseApp.NewContext(false, tmproto.Header{}) + + proposal, err := app.GovKeeper.SubmitProposal(ctx, TestProposal) + require.NoError(t, err) + app.GovKeeper.ActivateVotingPeriod(ctx, proposal) + proposal, found := app.GovKeeper.GetProposal(ctx, proposal.ProposalId) + require.True(t, found) + + for i := 0; i < gov.MaxVotesProcessedPerBlock+1; i++ { + addr := make(sdk.AccAddress, 20) + binary.BigEndian.PutUint64(addr[12:], uint64(i+1)) + require.NoError(t, app.GovKeeper.AddVote( + ctx, + proposal.ProposalId, + addr, + types.NewNonSplitVoteOption(types.OptionYes), + )) + } + + ctx = ctx.WithBlockTime(proposal.VotingEndTime) + gov.EndBlocker(ctx, app.GovKeeper) + + proposal, found = app.GovKeeper.GetProposal(ctx, proposal.ProposalId) + require.True(t, found) + require.Equal(t, types.StatusVotingPeriod, proposal.Status) + require.True(t, app.GovKeeper.IsTallying(ctx, proposal.ProposalId)) + require.Len(t, app.GovKeeper.GetVotes(ctx, proposal.ProposalId), 1) + require.Len(t, app.GovKeeper.GetArchivedTallyVotes(ctx, proposal.ProposalId, false), gov.MaxVotesProcessedPerBlock) + + newVoter := make(sdk.AccAddress, 20) + binary.BigEndian.PutUint64(newVoter[12:], uint64(gov.MaxVotesProcessedPerBlock+2)) + err = app.GovKeeper.AddVote(ctx, proposal.ProposalId, newVoter, types.NewNonSplitVoteOption(types.OptionNo)) + require.ErrorIs(t, err, types.ErrInactiveProposal) + + gov.EndBlocker(ctx, app.GovKeeper) + + proposal, found = app.GovKeeper.GetProposal(ctx, proposal.ProposalId) + require.True(t, found) + require.Equal(t, types.StatusRejected, proposal.Status) + require.False(t, app.GovKeeper.IsTallying(ctx, proposal.ProposalId)) + require.Empty(t, app.GovKeeper.GetVotes(ctx, proposal.ProposalId)) + require.Len(t, app.GovKeeper.GetArchivedTallyVotes(ctx, proposal.ProposalId, false), 2) + + gov.EndBlocker(ctx, app.GovKeeper) + require.Empty(t, app.GovKeeper.GetArchivedTallyVotes(ctx, proposal.ProposalId, false)) +} + // With expedited proposal's minimum deposit set higher than the default deposit, we must // initialize and deposit an amount depositMultiplier times larger // than the regular min deposit amount. diff --git a/sei-cosmos/x/gov/genesis.go b/sei-cosmos/x/gov/genesis.go index 609f8abc96..783c1e3341 100644 --- a/sei-cosmos/x/gov/genesis.go +++ b/sei-cosmos/x/gov/genesis.go @@ -67,6 +67,10 @@ func ExportGenesis(ctx sdk.Context, k keeper.Keeper) *types.GenesisState { deposits := k.GetDeposits(ctx, proposal.ProposalId) proposalsDeposits = append(proposalsDeposits, deposits...) + if k.IsTallying(ctx, proposal.ProposalId) { + archivedVotes := k.GetArchivedTallyVotes(ctx, proposal.ProposalId, proposal.IsExpedited) + proposalsVotes = append(proposalsVotes, archivedVotes...) + } votes := k.GetVotes(ctx, proposal.ProposalId) proposalsVotes = append(proposalsVotes, votes...) } diff --git a/sei-cosmos/x/gov/genesis_test.go b/sei-cosmos/x/gov/genesis_test.go index 176735c39e..904d4b5a7b 100644 --- a/sei-cosmos/x/gov/genesis_test.go +++ b/sei-cosmos/x/gov/genesis_test.go @@ -2,6 +2,7 @@ package gov_test import ( "context" + "encoding/binary" "encoding/json" "testing" @@ -168,3 +169,32 @@ func TestEqualProposals(t *testing.T) { require.Equal(t, state1, state2) require.True(t, state1.Equal(state2)) } + +func TestExportGenesisIncludesVotesFromUnfinishedTally(t *testing.T) { + app := seiapp.Setup(t, false, false, false) + ctx := app.BaseApp.NewContext(false, tmproto.Header{}) + + proposal, err := app.GovKeeper.SubmitProposal(ctx, TestProposal) + require.NoError(t, err) + app.GovKeeper.ActivateVotingPeriod(ctx, proposal) + proposal, found := app.GovKeeper.GetProposal(ctx, proposal.ProposalId) + require.True(t, found) + + for i := 0; i < 3; i++ { + addr := make(sdk.AccAddress, 20) + binary.BigEndian.PutUint64(addr[12:], uint64(i+1)) + require.NoError(t, app.GovKeeper.AddVote( + ctx, + proposal.ProposalId, + addr, + types.NewNonSplitVoteOption(types.OptionYes), + )) + } + + complete, processed, _, _, _ := app.GovKeeper.TallyIncremental(ctx, proposal, 1) + require.False(t, complete) + require.Equal(t, 1, processed) + + genesis := gov.ExportGenesis(ctx, app.GovKeeper) + require.Len(t, genesis.Votes, 3) +} diff --git a/sei-cosmos/x/gov/keeper/tally.go b/sei-cosmos/x/gov/keeper/tally.go index e806446cab..f289523174 100644 --- a/sei-cosmos/x/gov/keeper/tally.go +++ b/sei-cosmos/x/gov/keeper/tally.go @@ -1,125 +1,358 @@ package keeper import ( + "encoding/json" + "fmt" + "math" + sdk "github.com/sei-protocol/sei-chain/sei-cosmos/types" "github.com/sei-protocol/sei-chain/sei-cosmos/x/gov/types" stakingtypes "github.com/sei-protocol/sei-chain/sei-cosmos/x/staking/types" ) -// TODO: Break into several smaller functions for clarity +const cleanupCursorUnset byte = 0 + +type tallyProgress struct { + Cursor []byte `json:"cursor,omitempty"` + Results tallyOptionResults `json:"results"` + TotalVotingPower sdk.Dec `json:"total_voting_power"` + TotalBondedTokens sdk.Int `json:"total_bonded_tokens"` + TallyParams types.TallyParams `json:"tally_params"` + Validators []tallyValidator `json:"validators"` + Expedited bool `json:"expedited"` +} + +type tallyOptionResults struct { + Yes sdk.Dec `json:"yes"` + Abstain sdk.Dec `json:"abstain"` + No sdk.Dec `json:"no"` + NoWithVeto sdk.Dec `json:"no_with_veto"` +} -// Tally iterates over the votes and updates the tally of a proposal based on the voting power of the -// voters +type tallyValidator struct { + Address string `json:"address"` + BondedTokens sdk.Int `json:"bonded_tokens"` + DelegatorShares sdk.Dec `json:"delegator_shares"` + DelegatorDeductions sdk.Dec `json:"delegator_deductions"` + Vote types.WeightedVoteOptions `json:"vote"` +} + +// Tally processes every vote for a proposal and returns its result. func (keeper Keeper) Tally(ctx sdk.Context, proposal types.Proposal) (passes bool, burnDeposits bool, tallyResults types.TallyResult) { - results := make(map[types.VoteOption]sdk.Dec) - results[types.OptionYes] = sdk.ZeroDec() - results[types.OptionAbstain] = sdk.ZeroDec() - results[types.OptionNo] = sdk.ZeroDec() - results[types.OptionNoWithVeto] = sdk.ZeroDec() - - totalVotingPower := sdk.ZeroDec() - currValidators := make(map[string]types.ValidatorGovInfo) - - // fetch all the bonded validators, insert them into currValidators - keeper.sk.IterateBondedValidatorsByPower(ctx, func(index int64, validator stakingtypes.ValidatorI) (stop bool) { - currValidators[validator.GetOperator().String()] = types.NewValidatorGovInfo( - validator.GetOperator(), - validator.GetBondedTokens(), - validator.GetDelegatorShares(), - sdk.ZeroDec(), - types.WeightedVoteOptions{}, + complete, _, passes, burnDeposits, tallyResults := keeper.TallyIncremental(ctx, proposal, math.MaxInt) + if !complete { + panic(fmt.Sprintf("tally for proposal %d did not complete", proposal.ProposalId)) + } + + keeper.cleanupProposalTallyVotes(ctx, proposal.ProposalId, proposal.IsExpedited, math.MaxInt, nil) + return passes, burnDeposits, tallyResults +} + +// TallyIncremental processes at most maxVotes vote records and persists an unfinished tally. +func (keeper Keeper) TallyIncremental( + ctx sdk.Context, + proposal types.Proposal, + maxVotes int, +) (complete bool, processed int, passes bool, burnDeposits bool, tallyResults types.TallyResult) { + if maxVotes < 0 { + panic("maximum votes to tally cannot be negative") + } + + progress, found := keeper.getTallyProgress(ctx, proposal.ProposalId) + if !found { + progress = keeper.initializeTally(ctx, proposal) + } else if progress.Expedited != proposal.IsExpedited { + panic(fmt.Sprintf("tally round for proposal %d changed", proposal.ProposalId)) + } + + complete, processed = keeper.processTallyVotes(ctx, proposal.ProposalId, &progress, maxVotes) + if !complete { + keeper.setTallyProgress(ctx, proposal.ProposalId, progress) + return false, processed, false, false, types.EmptyTallyResult() + } + + passes, burnDeposits, tallyResults = keeper.finishTally(progress) + keeper.deleteTallyProgress(ctx, proposal.ProposalId) + keeper.markTallyVotesForCleanup(ctx, proposal.ProposalId, progress.Expedited) + return true, processed, passes, burnDeposits, tallyResults +} + +// IsTallying reports whether a proposal has an unfinished incremental tally. +func (keeper Keeper) IsTallying(ctx sdk.Context, proposalID uint64) bool { + store := ctx.KVStore(keeper.storeKey) + return store.Has(types.TallyProgressKey(proposalID)) +} + +// CleanupTallyVotes deletes at most maxVotes vote records archived by completed tallies. +func (keeper Keeper) CleanupTallyVotes(ctx sdk.Context, maxVotes int) (deleted int) { + if maxVotes <= 0 { + return 0 + } + + store := ctx.KVStore(keeper.storeKey) + iterator := sdk.KVStorePrefixIterator(store, types.TallyCleanupKeyPrefix) + defer func() { _ = iterator.Close() }() + + for ; iterator.Valid() && deleted < maxVotes; iterator.Next() { + proposalID, expedited := splitTallyCleanupKey(iterator.Key()) + cursor := decodeCleanupCursor(iterator.Value()) + count, complete, nextCursor := keeper.cleanupProposalTallyVotes( + ctx, + proposalID, + expedited, + maxVotes-deleted, + cursor, ) + deleted += count + + cleanupKey := types.TallyCleanupKey(proposalID, expedited) + if complete { + store.Delete(cleanupKey) + } else { + store.Set(cleanupKey, nextCursor) + } + } + + return deleted +} + +func (keeper Keeper) initializeTally(ctx sdk.Context, proposal types.Proposal) tallyProgress { + progress := tallyProgress{ + Results: tallyOptionResults{ + Yes: sdk.ZeroDec(), + Abstain: sdk.ZeroDec(), + No: sdk.ZeroDec(), + NoWithVeto: sdk.ZeroDec(), + }, + TotalVotingPower: sdk.ZeroDec(), + TotalBondedTokens: keeper.sk.TotalBondedTokens(ctx), + TallyParams: keeper.GetTallyParams(ctx), + Expedited: proposal.IsExpedited, + } + keeper.sk.IterateBondedValidatorsByPower(ctx, func(_ int64, validator stakingtypes.ValidatorI) bool { + progress.Validators = append(progress.Validators, tallyValidator{ + Address: validator.GetOperator().String(), + BondedTokens: validator.GetBondedTokens(), + DelegatorShares: validator.GetDelegatorShares(), + DelegatorDeductions: sdk.ZeroDec(), + }) return false }) - keeper.IterateVotes(ctx, proposal.ProposalId, func(vote types.Vote) bool { - // if validator, just record it in the map - voter := sdk.MustAccAddressFromBech32(vote.Voter) + return progress +} - valAddrStr := sdk.ValAddress(voter.Bytes()).String() - if val, ok := currValidators[valAddrStr]; ok { - val.Vote = vote.Options - currValidators[valAddrStr] = val - } +func (keeper Keeper) processTallyVotes( + ctx sdk.Context, + proposalID uint64, + progress *tallyProgress, + maxVotes int, +) (complete bool, processed int) { + validators := make(map[string]*tallyValidator, len(progress.Validators)) + for i := range progress.Validators { + validator := &progress.Validators[i] + validators[validator.Address] = validator + } - // iterate over all delegations from voter, deduct from any delegated-to validators - keeper.sk.IterateDelegations(ctx, voter, func(index int64, delegation stakingtypes.DelegationI) (stop bool) { - valAddrStr := delegation.GetValidatorAddr().String() + store := ctx.KVStore(keeper.storeKey) + votesPrefix := types.VotesKey(proposalID) + start := votesPrefix + if len(progress.Cursor) != 0 { + start = sdk.PrefixEndBytes(progress.Cursor) + } + iterator := store.Iterator(start, sdk.PrefixEndBytes(votesPrefix)) + defer func() { _ = iterator.Close() }() - if val, ok := currValidators[valAddrStr]; ok { - // There is no need to handle the special case that validator address equal to voter address. - // Because voter's voting power will tally again even if there will deduct voter's voting power from validator. - val.DelegatorDeductions = val.DelegatorDeductions.Add(delegation.GetShares()) - currValidators[valAddrStr] = val + for ; iterator.Valid() && processed < maxVotes; iterator.Next() { + key := append([]byte(nil), iterator.Key()...) + value := append([]byte(nil), iterator.Value()...) - // delegation shares * bonded / total shares - votingPower := delegation.GetShares().MulInt(val.BondedTokens).Quo(val.DelegatorShares) + var vote types.Vote + keeper.cdc.MustUnmarshal(value, &vote) + populateLegacyOption(&vote) + keeper.addVoteToTally(ctx, progress, validators, vote) - for _, option := range vote.Options { - subPower := votingPower.Mul(option.Weight) - results[option.Option] = results[option.Option].Add(subPower) - } - totalVotingPower = totalVotingPower.Add(votingPower) - } + voter := sdk.MustAccAddressFromBech32(vote.Voter) + store.Set(types.TallyVoteKey(proposalID, progress.Expedited, voter), value) + store.Delete(key) + progress.Cursor = key + processed++ + } + + return !iterator.Valid(), processed +} +func (keeper Keeper) addVoteToTally( + ctx sdk.Context, + progress *tallyProgress, + validators map[string]*tallyValidator, + vote types.Vote, +) { + voter := sdk.MustAccAddressFromBech32(vote.Voter) + if validator, ok := validators[sdk.ValAddress(voter.Bytes()).String()]; ok { + validator.Vote = vote.Options + } + + keeper.sk.IterateDelegations(ctx, voter, func(_ int64, delegation stakingtypes.DelegationI) bool { + validator, ok := validators[delegation.GetValidatorAddr().String()] + if !ok { return false - }) + } - keeper.deleteVote(ctx, vote.ProposalId, voter) + validator.DelegatorDeductions = validator.DelegatorDeductions.Add(delegation.GetShares()) + votingPower := delegation.GetShares().MulInt(validator.BondedTokens).Quo(validator.DelegatorShares) + progress.Results.add(vote.Options, votingPower) + progress.TotalVotingPower = progress.TotalVotingPower.Add(votingPower) return false }) +} - // iterate over the validators again to tally their voting power - for _, val := range currValidators { - if len(val.Vote) == 0 { +func (keeper Keeper) finishTally(progress tallyProgress) (passes bool, burnDeposits bool, tallyResults types.TallyResult) { + for _, validator := range progress.Validators { + if len(validator.Vote) == 0 { continue } - sharesAfterDeductions := val.DelegatorShares.Sub(val.DelegatorDeductions) - votingPower := sharesAfterDeductions.MulInt(val.BondedTokens).Quo(val.DelegatorShares) - - for _, option := range val.Vote { - subPower := votingPower.Mul(option.Weight) - results[option.Option] = results[option.Option].Add(subPower) - } - totalVotingPower = totalVotingPower.Add(votingPower) + sharesAfterDeductions := validator.DelegatorShares.Sub(validator.DelegatorDeductions) + votingPower := sharesAfterDeductions.MulInt(validator.BondedTokens).Quo(validator.DelegatorShares) + progress.Results.add(validator.Vote, votingPower) + progress.TotalVotingPower = progress.TotalVotingPower.Add(votingPower) } - tallyParams := keeper.GetTallyParams(ctx) - tallyResults = types.NewTallyResultFromMap(results) - - // TODO: Upgrade the spec to cover all of these cases & remove pseudocode. - // If there is no staked coins, the proposal fails - if keeper.sk.TotalBondedTokens(ctx).IsZero() { + tallyResults = progress.Results.tallyResult() + if progress.TotalBondedTokens.IsZero() { return false, false, tallyResults } - // If there is not enough quorum of votes, the proposal fails - percentVoting := totalVotingPower.Quo(keeper.sk.TotalBondedTokens(ctx).ToDec()) - // Get the quorum threshold based on if the proposal is expedited or not - quorumThreshold := tallyParams.GetQuorum(proposal.IsExpedited) - if percentVoting.LT(quorumThreshold) { + percentVoting := progress.TotalVotingPower.Quo(progress.TotalBondedTokens.ToDec()) + if percentVoting.LT(progress.TallyParams.GetQuorum(progress.Expedited)) { return false, true, tallyResults } - // If no one votes (everyone abstains), proposal fails - if totalVotingPower.Sub(results[types.OptionAbstain]).Equal(sdk.ZeroDec()) { + if progress.TotalVotingPower.Sub(progress.Results.Abstain).IsZero() { return false, false, tallyResults } - // If more than 1/3 of voters veto, proposal fails - if results[types.OptionNoWithVeto].Quo(totalVotingPower).GT(tallyParams.VetoThreshold) { + if progress.Results.NoWithVeto.Quo(progress.TotalVotingPower).GT(progress.TallyParams.VetoThreshold) { return false, true, tallyResults } - // If more than threshold of non-abstaining voters vote Yes, proposal passes - // default value for regular proposals is 1/2. For expedited 2/3 - voteYesThreshold := tallyParams.GetThreshold(proposal.IsExpedited) - if results[types.OptionYes].Quo(totalVotingPower.Sub(results[types.OptionAbstain])).GT(voteYesThreshold) { + nonAbstainingPower := progress.TotalVotingPower.Sub(progress.Results.Abstain) + if progress.Results.Yes.Quo(nonAbstainingPower).GT(progress.TallyParams.GetThreshold(progress.Expedited)) { return true, false, tallyResults } - // Otherwise proposal fails return false, false, tallyResults } + +func (results *tallyOptionResults) add(options types.WeightedVoteOptions, votingPower sdk.Dec) { + for _, option := range options { + subPower := votingPower.Mul(option.Weight) + switch option.Option { + case types.OptionYes: + results.Yes = results.Yes.Add(subPower) + case types.OptionAbstain: + results.Abstain = results.Abstain.Add(subPower) + case types.OptionNo: + results.No = results.No.Add(subPower) + case types.OptionNoWithVeto: + results.NoWithVeto = results.NoWithVeto.Add(subPower) + default: + panic(fmt.Sprintf("unsupported vote option %s", option.Option)) + } + } +} + +func (results tallyOptionResults) tallyResult() types.TallyResult { + return types.NewTallyResult( + results.Yes.TruncateInt(), + results.Abstain.TruncateInt(), + results.No.TruncateInt(), + results.NoWithVeto.TruncateInt(), + ) +} + +func (keeper Keeper) getTallyProgress(ctx sdk.Context, proposalID uint64) (progress tallyProgress, found bool) { + store := ctx.KVStore(keeper.storeKey) + bz := store.Get(types.TallyProgressKey(proposalID)) + if bz == nil { + return tallyProgress{}, false + } + if err := json.Unmarshal(bz, &progress); err != nil { + panic(fmt.Errorf("unmarshal tally progress for proposal %d: %w", proposalID, err)) + } + return progress, true +} + +func (keeper Keeper) setTallyProgress(ctx sdk.Context, proposalID uint64, progress tallyProgress) { + bz, err := json.Marshal(progress) + if err != nil { + panic(fmt.Errorf("marshal tally progress for proposal %d: %w", proposalID, err)) + } + ctx.KVStore(keeper.storeKey).Set(types.TallyProgressKey(proposalID), bz) +} + +func (keeper Keeper) deleteTallyProgress(ctx sdk.Context, proposalID uint64) { + ctx.KVStore(keeper.storeKey).Delete(types.TallyProgressKey(proposalID)) +} + +func (keeper Keeper) markTallyVotesForCleanup(ctx sdk.Context, proposalID uint64, expedited bool) { + store := ctx.KVStore(keeper.storeKey) + iterator := sdk.KVStorePrefixIterator(store, types.TallyVotesKey(proposalID, expedited)) + defer func() { _ = iterator.Close() }() + if iterator.Valid() { + store.Set(types.TallyCleanupKey(proposalID, expedited), []byte{cleanupCursorUnset}) + } +} + +func (keeper Keeper) cleanupProposalTallyVotes( + ctx sdk.Context, + proposalID uint64, + expedited bool, + maxVotes int, + after []byte, +) (deleted int, complete bool, cursor []byte) { + store := ctx.KVStore(keeper.storeKey) + votesPrefix := types.TallyVotesKey(proposalID, expedited) + start := votesPrefix + if len(after) != 0 { + start = sdk.PrefixEndBytes(after) + } + iterator := store.Iterator(start, sdk.PrefixEndBytes(votesPrefix)) + defer func() { _ = iterator.Close() }() + + for ; iterator.Valid() && deleted < maxVotes; iterator.Next() { + cursor = append(cursor[:0], iterator.Key()...) + store.Delete(iterator.Key()) + deleted++ + } + + complete = !iterator.Valid() + if complete { + store.Delete(types.TallyCleanupKey(proposalID, expedited)) + } + return deleted, complete, cursor +} + +func decodeCleanupCursor(value []byte) []byte { + if len(value) == 1 && value[0] == cleanupCursorUnset { + return nil + } + return append([]byte(nil), value...) +} + +func splitTallyCleanupKey(key []byte) (proposalID uint64, expedited bool) { + if len(key) != 10 { + panic(fmt.Sprintf("invalid tally cleanup key length %d", len(key))) + } + proposalID = types.GetProposalIDFromBytes(key[1:9]) + switch key[9] { + case 0: + return proposalID, true + case 1: + return proposalID, false + default: + panic(fmt.Sprintf("invalid tally round %d", key[9])) + } +} diff --git a/sei-cosmos/x/gov/keeper/tally_test.go b/sei-cosmos/x/gov/keeper/tally_test.go index 7c535e504d..04cfd875c7 100644 --- a/sei-cosmos/x/gov/keeper/tally_test.go +++ b/sei-cosmos/x/gov/keeper/tally_test.go @@ -499,3 +499,86 @@ func TestTallyValidatorMultipleDelegations(t *testing.T) { require.True(t, tallyResults.Equals(expectedTallyResult)) } + +func TestTallyIncrementalPersistsProgressAndCleansArchivedVotes(t *testing.T) { + app := seiapp.Setup(t, false, false, false) + ctx := app.BaseApp.NewContext(false, tmproto.Header{}) + + addrs, _ := createValidators(t, ctx, app, []int64{5, 5, 5}) + proposal, err := app.GovKeeper.SubmitProposal(ctx, TestProposal) + require.NoError(t, err) + proposal.Status = types.StatusVotingPeriod + app.GovKeeper.SetProposal(ctx, proposal) + + for _, addr := range addrs[:3] { + require.NoError(t, app.GovKeeper.AddVote( + ctx, + proposal.ProposalId, + addr, + types.NewNonSplitVoteOption(types.OptionYes), + )) + } + + complete, processed, _, _, _ := app.GovKeeper.TallyIncremental(ctx, proposal, 1) + require.False(t, complete) + require.Equal(t, 1, processed) + require.True(t, app.GovKeeper.IsTallying(ctx, proposal.ProposalId)) + require.Len(t, app.GovKeeper.GetArchivedTallyVotes(ctx, proposal.ProposalId, false), 1) + require.Len(t, app.GovKeeper.GetVotes(ctx, proposal.ProposalId), 2) + + err = app.GovKeeper.AddVote(ctx, proposal.ProposalId, addrs[3], types.NewNonSplitVoteOption(types.OptionNo)) + require.ErrorIs(t, err, types.ErrInactiveProposal) + + complete, processed, _, _, _ = app.GovKeeper.TallyIncremental(ctx, proposal, 1) + require.False(t, complete) + require.Equal(t, 1, processed) + + complete, processed, passes, burnDeposits, tallyResult := app.GovKeeper.TallyIncremental(ctx, proposal, 1) + require.True(t, complete) + require.Equal(t, 1, processed) + require.True(t, passes) + require.False(t, burnDeposits) + require.False(t, tallyResult.Equals(types.EmptyTallyResult())) + require.False(t, app.GovKeeper.IsTallying(ctx, proposal.ProposalId)) + require.Empty(t, app.GovKeeper.GetVotes(ctx, proposal.ProposalId)) + require.Len(t, app.GovKeeper.GetArchivedTallyVotes(ctx, proposal.ProposalId, false), 3) + + require.Equal(t, 2, app.GovKeeper.CleanupTallyVotes(ctx, 2)) + require.Len(t, app.GovKeeper.GetArchivedTallyVotes(ctx, proposal.ProposalId, false), 1) + require.Equal(t, 1, app.GovKeeper.CleanupTallyVotes(ctx, 2)) + require.Empty(t, app.GovKeeper.GetArchivedTallyVotes(ctx, proposal.ProposalId, false)) +} + +func TestTallyArchivesExpeditedAndRegularRoundsSeparately(t *testing.T) { + app := seiapp.Setup(t, false, false, false) + ctx := app.BaseApp.NewContext(false, tmproto.Header{}) + addrs, _ := createValidators(t, ctx, app, []int64{5, 5, 5}) + + proposal, err := app.GovKeeper.SubmitProposalWithExpedite(ctx, TestExpeditedProposal, true) + require.NoError(t, err) + proposal.Status = types.StatusVotingPeriod + app.GovKeeper.SetProposal(ctx, proposal) + require.NoError(t, app.GovKeeper.AddVote( + ctx, + proposal.ProposalId, + addrs[0], + types.NewNonSplitVoteOption(types.OptionYes), + )) + + complete, _, _, _, _ := app.GovKeeper.TallyIncremental(ctx, proposal, 1) + require.True(t, complete) + + proposal.IsExpedited = false + app.GovKeeper.SetProposal(ctx, proposal) + require.NoError(t, app.GovKeeper.AddVote( + ctx, + proposal.ProposalId, + addrs[0], + types.NewNonSplitVoteOption(types.OptionNo), + )) + complete, _, _, _, _ = app.GovKeeper.TallyIncremental(ctx, proposal, 1) + require.True(t, complete) + + require.Len(t, app.GovKeeper.GetArchivedTallyVotes(ctx, proposal.ProposalId, true), 1) + require.Len(t, app.GovKeeper.GetArchivedTallyVotes(ctx, proposal.ProposalId, false), 1) +} diff --git a/sei-cosmos/x/gov/keeper/vote.go b/sei-cosmos/x/gov/keeper/vote.go index 1988281eb8..9dfc7edd37 100644 --- a/sei-cosmos/x/gov/keeper/vote.go +++ b/sei-cosmos/x/gov/keeper/vote.go @@ -17,6 +17,9 @@ func (keeper Keeper) AddVote(ctx sdk.Context, proposalID uint64, voterAddr sdk.A if proposal.Status != types.StatusVotingPeriod { return sdkerrors.Wrapf(types.ErrInactiveProposal, "%d", proposalID) } + if keeper.IsTallying(ctx, proposalID) { + return sdkerrors.Wrapf(types.ErrInactiveProposal, "%d", proposalID) + } for _, option := range options { if !types.ValidWeightedVoteOption(option) { @@ -61,6 +64,21 @@ func (keeper Keeper) GetVotes(ctx sdk.Context, proposalID uint64) (votes types.V return } +// GetArchivedTallyVotes returns votes already processed by an unfinished proposal tally. +func (keeper Keeper) GetArchivedTallyVotes(ctx sdk.Context, proposalID uint64, expedited bool) (votes types.Votes) { + store := ctx.KVStore(keeper.storeKey) + iterator := sdk.KVStorePrefixIterator(store, types.TallyVotesKey(proposalID, expedited)) + defer func() { _ = iterator.Close() }() + + for ; iterator.Valid(); iterator.Next() { + var vote types.Vote + keeper.cdc.MustUnmarshal(iterator.Value(), &vote) + populateLegacyOption(&vote) + votes = append(votes, vote) + } + return votes +} + // GetVote gets the vote from an address on a specific proposal func (keeper Keeper) GetVote(ctx sdk.Context, proposalID uint64, voterAddr sdk.AccAddress) (vote types.Vote, found bool) { store := ctx.KVStore(keeper.storeKey) @@ -123,12 +141,6 @@ func (keeper Keeper) IterateVotes(ctx sdk.Context, proposalID uint64, cb func(vo } } -// deleteVote deletes a vote from a given proposalID and voter from the store -func (keeper Keeper) deleteVote(ctx sdk.Context, proposalID uint64, voterAddr sdk.AccAddress) { - store := ctx.KVStore(keeper.storeKey) - store.Delete(types.VoteKey(proposalID, voterAddr)) -} - // populateLegacyOption adds graceful fallback of deprecated `Option` field, in case // there's only 1 VoteOption. func populateLegacyOption(vote *types.Vote) { diff --git a/sei-cosmos/x/gov/simulation/decoder.go b/sei-cosmos/x/gov/simulation/decoder.go index dbfa8c8c84..479860c12e 100644 --- a/sei-cosmos/x/gov/simulation/decoder.go +++ b/sei-cosmos/x/gov/simulation/decoder.go @@ -41,12 +41,17 @@ func NewDecodeStore(cdc codec.Codec) func(kvA, kvB kv.Pair) string { cdc.MustUnmarshal(kvB.Value, &depositB) return fmt.Sprintf("%v\n%v", depositA, depositB) - case bytes.Equal(kvA.Key[:1], types.VotesKeyPrefix): + case bytes.Equal(kvA.Key[:1], types.VotesKeyPrefix), + bytes.Equal(kvA.Key[:1], types.TallyVotesKeyPrefix): var voteA, voteB types.Vote cdc.MustUnmarshal(kvA.Value, &voteA) cdc.MustUnmarshal(kvB.Value, &voteB) return fmt.Sprintf("%v\n%v", voteA, voteB) + case bytes.Equal(kvA.Key[:1], types.TallyProgressKeyPrefix), + bytes.Equal(kvA.Key[:1], types.TallyCleanupKeyPrefix): + return fmt.Sprintf("%X\n%X", kvA.Value, kvB.Value) + default: panic(fmt.Sprintf("invalid governance key prefix %X", kvA.Key[:1])) } diff --git a/sei-cosmos/x/gov/spec/02_state.md b/sei-cosmos/x/gov/spec/02_state.md index 269ff69272..0b192b3554 100644 --- a/sei-cosmos/x/gov/spec/02_state.md +++ b/sei-cosmos/x/gov/spec/02_state.md @@ -137,7 +137,17 @@ For pseudocode purposes, here are the two function we will use to read or write - `ProposalProcessingQueue`: A queue `queue[proposalID]` containing all the `ProposalIDs` of proposals that reached `MinDeposit`. During each `EndBlock`, - all the proposals that have reached the end of their voting period are processed. + proposals that have reached the end of their voting period are advanced within + the block's vote-processing budget. + +## Incremental tally state + +An expired proposal retains a tally accumulator, a cursor, and a snapshot of the +bonded validators and tally parameters until all of its vote records have been +processed. Processed votes move to a round-specific archive so an application-state +export can reconstruct every vote while a tally is unfinished. New votes are rejected +after the accumulator is created. Completed tally archives are removed incrementally +under the same per-block vote-record budget. To process a finished proposal, the application tallies the votes, computes the votes of each validator and checks if every validator in the validator set has voted. If the proposal is accepted, deposits are refunded. Finally, the proposal diff --git a/sei-cosmos/x/gov/types/keys.go b/sei-cosmos/x/gov/types/keys.go index 9f590db78b..b60d054b52 100644 --- a/sei-cosmos/x/gov/types/keys.go +++ b/sei-cosmos/x/gov/types/keys.go @@ -37,6 +37,12 @@ const ( // - 0x10: Deposit // // - 0x20: Voter +// +// - 0x30: Tally progress +// +// - 0x31: Archived voter +// +// - 0x32: Tally archive cleanup cursor var ( ProposalsKeyPrefix = []byte{0x00} ActiveProposalQueuePrefix = []byte{0x01} @@ -46,6 +52,10 @@ var ( DepositsKeyPrefix = []byte{0x10} VotesKeyPrefix = []byte{0x20} + + TallyProgressKeyPrefix = []byte{0x30} + TallyVotesKeyPrefix = []byte{0x31} + TallyCleanupKeyPrefix = []byte{0x32} ) var lenTime = len(sdk.FormatTimeBytes(time.Now())) @@ -107,6 +117,33 @@ func VoteKey(proposalID uint64, voterAddr sdk.AccAddress) []byte { return append(VotesKey(proposalID), address.MustLengthPrefix(voterAddr.Bytes())...) } +// TallyProgressKey returns the key for a proposal's incremental tally state. +func TallyProgressKey(proposalID uint64) []byte { + return append(TallyProgressKeyPrefix, GetProposalIDBytes(proposalID)...) +} + +// TallyVotesKey returns the prefix for votes archived during a proposal tally round. +func TallyVotesKey(proposalID uint64, expedited bool) []byte { + return append(append(TallyVotesKeyPrefix, GetProposalIDBytes(proposalID)...), tallyRound(expedited)) +} + +// TallyVoteKey returns the key for a vote archived during a proposal tally. +func TallyVoteKey(proposalID uint64, expedited bool, voterAddr sdk.AccAddress) []byte { + return append(TallyVotesKey(proposalID, expedited), address.MustLengthPrefix(voterAddr.Bytes())...) +} + +// TallyCleanupKey returns the key for a proposal tally round's archived-vote cleanup cursor. +func TallyCleanupKey(proposalID uint64, expedited bool) []byte { + return append(append(TallyCleanupKeyPrefix, GetProposalIDBytes(proposalID)...), tallyRound(expedited)) +} + +func tallyRound(expedited bool) byte { + if expedited { + return 0 + } + return 1 +} + // Split keys function; used for iterators // SplitProposalKey split the proposal key and returns the proposal id diff --git a/sei-cosmos/x/gov/types/keys_test.go b/sei-cosmos/x/gov/types/keys_test.go index b98b450620..b8e265d26e 100644 --- a/sei-cosmos/x/gov/types/keys_test.go +++ b/sei-cosmos/x/gov/types/keys_test.go @@ -59,3 +59,10 @@ func TestVoteKeys(t *testing.T) { require.Equal(t, int(proposalID), 2) require.Equal(t, addr, voterAddr) } + +func TestTallyKeys(t *testing.T) { + require.Equal(t, append(TallyProgressKeyPrefix, GetProposalIDBytes(2)...), TallyProgressKey(2)) + require.NotEqual(t, TallyVotesKey(2, true), TallyVotesKey(2, false)) + require.NotEqual(t, TallyVoteKey(2, true, addr), TallyVoteKey(2, false, addr)) + require.NotEqual(t, TallyCleanupKey(2, true), TallyCleanupKey(2, false)) +}