From 309c0a7d4300883cce73b2420e40582c776eabf9 Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Tue, 25 Aug 2026 13:12:42 +0800 Subject: [PATCH 01/17] fix(gov): bound end-block vote tally work --- sei-cosmos/x/gov/abci.go | 17 +- sei-cosmos/x/gov/abci_test.go | 50 ++++ sei-cosmos/x/gov/genesis.go | 4 + sei-cosmos/x/gov/genesis_test.go | 30 ++ sei-cosmos/x/gov/keeper/tally.go | 387 ++++++++++++++++++++----- sei-cosmos/x/gov/keeper/tally_test.go | 83 ++++++ sei-cosmos/x/gov/keeper/vote.go | 24 +- sei-cosmos/x/gov/simulation/decoder.go | 7 +- sei-cosmos/x/gov/spec/02_state.md | 12 +- sei-cosmos/x/gov/types/keys.go | 37 +++ sei-cosmos/x/gov/types/keys_test.go | 7 + 11 files changed, 570 insertions(+), 88 deletions(-) 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)) +} From b89a7bd32ab9cddeea4dd51b80163635917155bd Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Wed, 26 Aug 2026 15:35:57 +0800 Subject: [PATCH 02/17] fix(gov): address incremental tally review --- sei-cosmos/x/gov/abci.go | 4 ++ sei-cosmos/x/gov/abci_test.go | 82 +++++++++++++++++++-- sei-cosmos/x/gov/genesis.go | 7 +- sei-cosmos/x/gov/genesis_test.go | 22 ++++++ sei-cosmos/x/gov/keeper/grpc_query.go | 3 +- sei-cosmos/x/gov/keeper/grpc_query_test.go | 66 +++++++++++++++++ sei-cosmos/x/gov/keeper/tally.go | 59 ++++++++++----- sei-cosmos/x/gov/keeper/tally_test.go | 49 ++++++++++++- sei-cosmos/x/gov/keeper/vote.go | 83 ++++++++++++++++++++-- sei-cosmos/x/gov/spec/02_state.md | 34 +++++---- 10 files changed, 364 insertions(+), 45 deletions(-) diff --git a/sei-cosmos/x/gov/abci.go b/sei-cosmos/x/gov/abci.go index 612825da82..1bf98457cb 100644 --- a/sei-cosmos/x/gov/abci.go +++ b/sei-cosmos/x/gov/abci.go @@ -16,6 +16,9 @@ var logger = seilog.NewLogger("cosmos", "x", "gov") // MaxVotesProcessedPerBlock is the governance vote-record budget shared by tallying and cleanup. const MaxVotesProcessedPerBlock = 1000 +// minTallyCleanupVotesPerBlock reserves part of the budget for completed tally archives. +const minTallyCleanupVotesPerBlock = 100 + // EndBlocker expires governance proposals and advances bounded vote tally work. func EndBlocker(ctx sdk.Context, keeper keeper.Keeper) { endBlockerStart := time.Now() @@ -54,6 +57,7 @@ func EndBlocker(ctx sdk.Context, keeper keeper.Keeper) { }) remainingVotes := MaxVotesProcessedPerBlock + remainingVotes -= keeper.CleanupTallyVotes(ctx, minTallyCleanupVotesPerBlock) // fetch active proposals whose voting periods have ended (are passed the block time) keeper.IterateActiveProposalsQueue(ctx, ctx.BlockHeader().Time, func(proposal types.Proposal) bool { diff --git a/sei-cosmos/x/gov/abci_test.go b/sei-cosmos/x/gov/abci_test.go index 9244d1b7f3..aee5c85748 100644 --- a/sei-cosmos/x/gov/abci_test.go +++ b/sei-cosmos/x/gov/abci_test.go @@ -611,10 +611,32 @@ func TestEndBlockerBoundsVoteTallyAndCleanupWork(t *testing.T) { app := seiapp.Setup(t, false, false, false) ctx := app.BaseApp.NewContext(false, tmproto.Header{}) + cleanupProposal, err := app.GovKeeper.SubmitProposal(ctx, TestProposal) + require.NoError(t, err) + app.GovKeeper.ActivateVotingPeriod(ctx, cleanupProposal) + cleanupProposal, found := app.GovKeeper.GetProposal(ctx, cleanupProposal.ProposalId) + require.True(t, found) + for i := 0; i < 101; i++ { + addr := make(sdk.AccAddress, 20) + binary.BigEndian.PutUint64(addr[12:], uint64(i+1)) + require.NoError(t, app.GovKeeper.AddVote( + ctx, + cleanupProposal.ProposalId, + addr, + types.NewNonSplitVoteOption(types.OptionYes), + )) + } + complete, processed, _, _, _ := app.GovKeeper.TallyIncremental(ctx, cleanupProposal, 101) + require.True(t, complete) + require.Equal(t, 101, processed) + app.GovKeeper.RemoveFromActiveProposalQueue(ctx, cleanupProposal.ProposalId, cleanupProposal.VotingEndTime) + cleanupProposal.Status = types.StatusRejected + app.GovKeeper.SetProposal(ctx, cleanupProposal) + proposal, err := app.GovKeeper.SubmitProposal(ctx, TestProposal) require.NoError(t, err) app.GovKeeper.ActivateVotingPeriod(ctx, proposal) - proposal, found := app.GovKeeper.GetProposal(ctx, proposal.ProposalId) + proposal, found = app.GovKeeper.GetProposal(ctx, proposal.ProposalId) require.True(t, found) for i := 0; i < gov.MaxVotesProcessedPerBlock+1; i++ { @@ -635,8 +657,9 @@ func TestEndBlockerBoundsVoteTallyAndCleanupWork(t *testing.T) { 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) + require.Len(t, app.GovKeeper.GetVotes(ctx, proposal.ProposalId), gov.MaxVotesProcessedPerBlock+1) + require.Len(t, app.GovKeeper.GetArchivedTallyVotes(ctx, proposal.ProposalId, false), 900) + require.Len(t, app.GovKeeper.GetArchivedTallyVotes(ctx, cleanupProposal.ProposalId, false), 1) newVoter := make(sdk.AccAddress, 20) binary.BigEndian.PutUint64(newVoter[12:], uint64(gov.MaxVotesProcessedPerBlock+2)) @@ -650,12 +673,63 @@ func TestEndBlockerBoundsVoteTallyAndCleanupWork(t *testing.T) { 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) + require.Len(t, app.GovKeeper.GetArchivedTallyVotes(ctx, proposal.ProposalId, false), 103) gov.EndBlocker(ctx, app.GovKeeper) require.Empty(t, app.GovKeeper.GetArchivedTallyVotes(ctx, proposal.ProposalId, false)) } +func TestEndBlockerKeepsExpeditedAndRegularTallyArchivesSeparate(t *testing.T) { + app := seiapp.Setup(t, false, false, false) + ctx := app.BaseApp.NewContext(false, tmproto.Header{}) + + proposal, err := app.GovKeeper.SubmitProposalWithExpedite(ctx, TestExpeditedProposal, true) + 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 < 2*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) + gov.EndBlocker(ctx, app.GovKeeper) + 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.False(t, proposal.IsExpedited) + require.Len(t, app.GovKeeper.GetArchivedTallyVotes(ctx, proposal.ProposalId, true), 1002) + + regularVoter := make(sdk.AccAddress, 20) + binary.BigEndian.PutUint64(regularVoter[12:], uint64(2*gov.MaxVotesProcessedPerBlock+2)) + require.NoError(t, app.GovKeeper.AddVote( + ctx, + proposal.ProposalId, + regularVoter, + types.NewNonSplitVoteOption(types.OptionNo), + )) + + 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.StatusRejected, proposal.Status) + require.Len(t, app.GovKeeper.GetArchivedTallyVotes(ctx, proposal.ProposalId, true), 3) + require.Len(t, app.GovKeeper.GetArchivedTallyVotes(ctx, proposal.ProposalId, false), 1) +} + // 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 783c1e3341..4252059457 100644 --- a/sei-cosmos/x/gov/genesis.go +++ b/sei-cosmos/x/gov/genesis.go @@ -37,6 +37,9 @@ func InitGenesis(ctx sdk.Context, ak types.AccountKeeper, bk types.BankKeeper, k k.InsertInactiveProposalQueue(ctx, proposal.ProposalId, proposal.DepositEndTime) case types.StatusVotingPeriod: k.InsertActiveProposalQueue(ctx, proposal.ProposalId, proposal.VotingEndTime) + if !proposal.VotingEndTime.After(ctx.BlockTime()) { + k.InitializeTally(ctx, proposal) + } } k.SetProposal(ctx, proposal) } @@ -67,10 +70,6 @@ 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 904d4b5a7b..c07a4d1c33 100644 --- a/sei-cosmos/x/gov/genesis_test.go +++ b/sei-cosmos/x/gov/genesis_test.go @@ -197,4 +197,26 @@ func TestExportGenesisIncludesVotesFromUnfinishedTally(t *testing.T) { genesis := gov.ExportGenesis(ctx, app.GovKeeper) require.Len(t, genesis.Votes, 3) + + importedApp := seiapp.Setup(t, false, false, false) + importedCtx := importedApp.BaseApp.NewContext(false, tmproto.Header{}).WithBlockTime(proposal.VotingEndTime) + gov.InitGenesis( + importedCtx, + importedApp.AccountKeeper, + importedApp.BankKeeper, + importedApp.GovKeeper, + genesis, + ) + + require.True(t, importedApp.GovKeeper.IsTallying(importedCtx, proposal.ProposalId)) + require.Len(t, importedApp.GovKeeper.GetVotes(importedCtx, proposal.ProposalId), 3) + newVoter := make(sdk.AccAddress, 20) + binary.BigEndian.PutUint64(newVoter[12:], 4) + err = importedApp.GovKeeper.AddVote( + importedCtx, + proposal.ProposalId, + newVoter, + types.NewNonSplitVoteOption(types.OptionNo), + ) + require.ErrorIs(t, err, types.ErrInactiveProposal) } diff --git a/sei-cosmos/x/gov/keeper/grpc_query.go b/sei-cosmos/x/gov/keeper/grpc_query.go index 4beb84fce9..cc18d4861b 100644 --- a/sei-cosmos/x/gov/keeper/grpc_query.go +++ b/sei-cosmos/x/gov/keeper/grpc_query.go @@ -134,8 +134,7 @@ func (q Keeper) Votes(c context.Context, req *types.QueryVotesRequest) (*types.Q var votes types.Votes ctx := sdk.UnwrapSDKContext(c) - store := ctx.KVStore(q.storeKey) - votesStore := prefix.NewStore(store, types.VotesKey(req.ProposalId)) + votesStore := q.visibleVotesStore(ctx, req.ProposalId) pageRes, err := query.Paginate(ctx, votesStore, req.Pagination, func(key []byte, value []byte) error { var vote types.Vote diff --git a/sei-cosmos/x/gov/keeper/grpc_query_test.go b/sei-cosmos/x/gov/keeper/grpc_query_test.go index 3f078d2ca2..efb3eaef32 100644 --- a/sei-cosmos/x/gov/keeper/grpc_query_test.go +++ b/sei-cosmos/x/gov/keeper/grpc_query_test.go @@ -434,6 +434,72 @@ func (suite *KeeperTestSuite) TestGRPCQueryVotes() { } } +func (suite *KeeperTestSuite) TestGRPCQueryVotesDuringIncrementalTally() { + app, ctx, queryClient, addrs := suite.app, suite.ctx, suite.queryClient, suite.addrs + + proposal, err := app.GovKeeper.SubmitProposal(ctx, TestProposal) + suite.Require().NoError(err) + proposal.Status = types.StatusVotingPeriod + app.GovKeeper.SetProposal(ctx, proposal) + + for i, addr := range addrs { + option := types.OptionYes + if i == 1 { + option = types.OptionNo + } + suite.Require().NoError(app.GovKeeper.AddVote( + ctx, + proposal.ProposalId, + addr, + types.NewNonSplitVoteOption(option), + )) + } + + complete, processed, _, _, _ := app.GovKeeper.TallyIncremental(ctx, proposal, 1) + suite.Require().False(complete) + suite.Require().Equal(1, processed) + archivedVotes := app.GovKeeper.GetArchivedTallyVotes(ctx, proposal.ProposalId, false) + suite.Require().Len(archivedVotes, 1) + + voteResponse, err := queryClient.Vote(gocontext.Background(), &types.QueryVoteRequest{ + ProposalId: proposal.ProposalId, + Voter: archivedVotes[0].Voter, + }) + suite.Require().NoError(err) + suite.Require().Equal(archivedVotes[0], voteResponse.Vote) + + firstPage, err := queryClient.Votes(gocontext.Background(), &types.QueryVotesRequest{ + ProposalId: proposal.ProposalId, + Pagination: &query.PageRequest{Limit: 1, CountTotal: true}, + }) + suite.Require().NoError(err) + suite.Require().Len(firstPage.Votes, 1) + suite.Require().Equal(uint64(2), firstPage.Pagination.Total) + suite.Require().NotEmpty(firstPage.Pagination.NextKey) + + secondPage, err := queryClient.Votes(gocontext.Background(), &types.QueryVotesRequest{ + ProposalId: proposal.ProposalId, + Pagination: &query.PageRequest{Key: firstPage.Pagination.NextKey, Limit: 1}, + }) + suite.Require().NoError(err) + suite.Require().Len(secondPage.Votes, 1) + suite.Require().ElementsMatch(app.GovKeeper.GetVotes(ctx, proposal.ProposalId), append(firstPage.Votes, secondPage.Votes...)) + suite.Require().Len(app.GovKeeper.GetAllVotes(ctx), 2) + + reversePage, err := queryClient.Votes(gocontext.Background(), &types.QueryVotesRequest{ + ProposalId: proposal.ProposalId, + Pagination: &query.PageRequest{Limit: 2, Reverse: true}, + }) + suite.Require().NoError(err) + suite.Require().ElementsMatch(app.GovKeeper.GetVotes(ctx, proposal.ProposalId), reversePage.Votes) + + _, err = queryClient.TallyResult(gocontext.Background(), &types.QueryTallyResultRequest{ProposalId: proposal.ProposalId}) + suite.Require().NoError(err) + suite.Require().True(app.GovKeeper.IsTallying(ctx, proposal.ProposalId)) + suite.Require().Len(app.GovKeeper.GetArchivedTallyVotes(ctx, proposal.ProposalId, false), 1) + suite.Require().Len(app.GovKeeper.GetVotes(ctx, proposal.ProposalId), 2) +} + func (suite *KeeperTestSuite) TestGRPCQueryParams() { queryClient := suite.queryClient diff --git a/sei-cosmos/x/gov/keeper/tally.go b/sei-cosmos/x/gov/keeper/tally.go index f289523174..fbfc4ac7a5 100644 --- a/sei-cosmos/x/gov/keeper/tally.go +++ b/sei-cosmos/x/gov/keeper/tally.go @@ -3,7 +3,6 @@ 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" @@ -37,15 +36,15 @@ type tallyValidator struct { Vote types.WeightedVoteOptions `json:"vote"` } -// Tally processes every vote for a proposal and returns its result. +// Tally calculates a proposal's result without changing its tally state. func (keeper Keeper) Tally(ctx sdk.Context, proposal types.Proposal) (passes bool, burnDeposits bool, tallyResults types.TallyResult) { - 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 + progress := keeper.initializeTally(ctx, proposal) + validators := progress.validatorMap() + keeper.IterateVotes(ctx, proposal.ProposalId, func(vote types.Vote) bool { + keeper.addVoteToTally(ctx, &progress, validators, vote) + return false + }) + return keeper.finishTally(progress) } // TallyIncremental processes at most maxVotes vote records and persists an unfinished tally. @@ -83,6 +82,18 @@ func (keeper Keeper) IsTallying(ctx sdk.Context, proposalID uint64) bool { return store.Has(types.TallyProgressKey(proposalID)) } +// InitializeTally persists a proposal's tally accumulator when one does not exist. +func (keeper Keeper) InitializeTally(ctx sdk.Context, proposal types.Proposal) { + progress, found := keeper.getTallyProgress(ctx, proposal.ProposalId) + if found { + if progress.Expedited != proposal.IsExpedited { + panic(fmt.Sprintf("tally round for proposal %d changed", proposal.ProposalId)) + } + return + } + keeper.setTallyProgress(ctx, proposal.ProposalId, keeper.initializeTally(ctx, proposal)) +} + // 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 { @@ -149,11 +160,7 @@ func (keeper Keeper) processTallyVotes( 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 - } + validators := progress.validatorMap() store := ctx.KVStore(keeper.storeKey) votesPrefix := types.VotesKey(proposalID) @@ -196,18 +203,36 @@ func (keeper Keeper) addVoteToTally( keeper.sk.IterateDelegations(ctx, voter, func(_ int64, delegation stakingtypes.DelegationI) bool { validator, ok := validators[delegation.GetValidatorAddr().String()] - if !ok { + if !ok || validator.DelegatorShares.IsZero() { + return false + } + + remainingShares := validator.DelegatorShares.Sub(validator.DelegatorDeductions) + if !remainingShares.IsPositive() { return false } - validator.DelegatorDeductions = validator.DelegatorDeductions.Add(delegation.GetShares()) - votingPower := delegation.GetShares().MulInt(validator.BondedTokens).Quo(validator.DelegatorShares) + // Delegations can change while an incremental tally is in progress. The + // validator snapshot is a fixed voting-power budget, so later delegation + // reads cannot deduct more shares than that snapshot contains. + votingShares := sdk.MinDec(delegation.GetShares(), remainingShares) + validator.DelegatorDeductions = validator.DelegatorDeductions.Add(votingShares) + votingPower := votingShares.MulInt(validator.BondedTokens).Quo(validator.DelegatorShares) progress.Results.add(vote.Options, votingPower) progress.TotalVotingPower = progress.TotalVotingPower.Add(votingPower) return false }) } +func (progress *tallyProgress) validatorMap() map[string]*tallyValidator { + validators := make(map[string]*tallyValidator, len(progress.Validators)) + for i := range progress.Validators { + validator := &progress.Validators[i] + validators[validator.Address] = validator + } + return validators +} + func (keeper Keeper) finishTally(progress tallyProgress) (passes bool, burnDeposits bool, tallyResults types.TallyResult) { for _, validator := range progress.Validators { if len(validator.Vote) == 0 { diff --git a/sei-cosmos/x/gov/keeper/tally_test.go b/sei-cosmos/x/gov/keeper/tally_test.go index 04cfd875c7..ae77f77136 100644 --- a/sei-cosmos/x/gov/keeper/tally_test.go +++ b/sei-cosmos/x/gov/keeper/tally_test.go @@ -524,7 +524,13 @@ func TestTallyIncrementalPersistsProgressAndCleansArchivedVotes(t *testing.T) { 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) + require.Len(t, app.GovKeeper.GetVotes(ctx, proposal.ProposalId), 3) + + _, _, queryResult := app.GovKeeper.Tally(ctx, proposal) + require.False(t, queryResult.Equals(types.EmptyTallyResult())) + 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), 3) err = app.GovKeeper.AddVote(ctx, proposal.ProposalId, addrs[3], types.NewNonSplitVoteOption(types.OptionNo)) require.ErrorIs(t, err, types.ErrInactiveProposal) @@ -549,6 +555,47 @@ func TestTallyIncrementalPersistsProgressAndCleansArchivedVotes(t *testing.T) { require.Empty(t, app.GovKeeper.GetArchivedTallyVotes(ctx, proposal.ProposalId, false)) } +func TestTallyIncrementalCapsDelegationsAddedAfterSnapshot(t *testing.T) { + app := seiapp.Setup(t, false, false, false) + ctx := app.BaseApp.NewContext(false, tmproto.Header{}) + addrs, valAddrs := 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) + + require.NoError(t, app.GovKeeper.AddVote( + ctx, + proposal.ProposalId, + addrs[0], + types.NewNonSplitVoteOption(types.OptionYes), + )) + require.NoError(t, app.GovKeeper.AddVote( + ctx, + proposal.ProposalId, + addrs[3], + types.NewNonSplitVoteOption(types.OptionNo), + )) + + validator, found := app.StakingKeeper.GetValidator(ctx, valAddrs[0]) + require.True(t, found) + snapshotValidatorTokens := validator.GetBondedTokens() + snapshotTotalBonded := app.StakingKeeper.TotalBondedTokens(ctx) + app.GovKeeper.InitializeTally(ctx, proposal) + + delegatedTokens := app.StakingKeeper.TokensFromConsensusPower(ctx, 20) + _, err = app.StakingKeeper.Delegate(ctx, addrs[3], delegatedTokens, stakingtypes.Unbonded, validator, true) + require.NoError(t, err) + + complete, processed, _, _, tallyResult := app.GovKeeper.TallyIncremental(ctx, proposal, 2) + require.True(t, complete) + require.Equal(t, 2, processed) + require.True(t, tallyResult.Yes.Add(tallyResult.No).Equal(snapshotValidatorTokens)) + totalVotingPower := tallyResult.Yes.Add(tallyResult.Abstain).Add(tallyResult.No).Add(tallyResult.NoWithVeto) + require.False(t, totalVotingPower.GT(snapshotTotalBonded)) +} + func TestTallyArchivesExpeditedAndRegularRoundsSeparately(t *testing.T) { app := seiapp.Setup(t, false, false, false) ctx := app.BaseApp.NewContext(false, tmproto.Header{}) diff --git a/sei-cosmos/x/gov/keeper/vote.go b/sei-cosmos/x/gov/keeper/vote.go index 9dfc7edd37..49cf78b7a7 100644 --- a/sei-cosmos/x/gov/keeper/vote.go +++ b/sei-cosmos/x/gov/keeper/vote.go @@ -3,6 +3,9 @@ package keeper import ( "fmt" + "github.com/sei-protocol/sei-chain/sei-cosmos/store/cachekv" + "github.com/sei-protocol/sei-chain/sei-cosmos/store/prefix" + storetypes "github.com/sei-protocol/sei-chain/sei-cosmos/store/types" sdk "github.com/sei-protocol/sei-chain/sei-cosmos/types" sdkerrors "github.com/sei-protocol/sei-chain/sei-cosmos/types/errors" "github.com/sei-protocol/sei-chain/sei-cosmos/x/gov/types" @@ -81,8 +84,10 @@ func (keeper Keeper) GetArchivedTallyVotes(ctx sdk.Context, proposalID uint64, e // 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) - bz := store.Get(types.VoteKey(proposalID, voterAddr)) + store := keeper.visibleVotesStore(ctx, proposalID) + votesPrefix := types.VotesKey(proposalID) + voteKey := types.VoteKey(proposalID, voterAddr) + bz := store.Get(voteKey[len(votesPrefix):]) if bz == nil { return vote, false } @@ -110,6 +115,20 @@ func (keeper Keeper) SetVote(ctx sdk.Context, vote types.Vote) { // IterateAllVotes iterates over the all the stored votes and performs a callback function func (keeper Keeper) IterateAllVotes(ctx sdk.Context, cb func(vote types.Vote) (stop bool)) { store := ctx.KVStore(keeper.storeKey) + progressIterator := sdk.KVStorePrefixIterator(store, types.TallyProgressKeyPrefix) + for ; progressIterator.Valid(); progressIterator.Next() { + proposalID := types.GetProposalIDFromBytes(progressIterator.Key()[len(types.TallyProgressKeyPrefix):]) + progress, found := keeper.getTallyProgress(ctx, proposalID) + if !found { + continue + } + if keeper.iterateVoteStore(prefix.NewStore(store, types.TallyVotesKey(proposalID, progress.Expedited)), cb) { + _ = progressIterator.Close() + return + } + } + _ = progressIterator.Close() + iterator := sdk.KVStorePrefixIterator(store, types.VotesKeyPrefix) defer func() { _ = iterator.Close() }() @@ -126,8 +145,11 @@ func (keeper Keeper) IterateAllVotes(ctx sdk.Context, cb func(vote types.Vote) ( // IterateVotes iterates over the all the proposals votes and performs a callback function func (keeper Keeper) IterateVotes(ctx sdk.Context, proposalID uint64, cb func(vote types.Vote) (stop bool)) { - store := ctx.KVStore(keeper.storeKey) - iterator := sdk.KVStorePrefixIterator(store, types.VotesKey(proposalID)) + keeper.iterateVoteStore(keeper.visibleVotesStore(ctx, proposalID), cb) +} + +func (keeper Keeper) iterateVoteStore(store storetypes.KVStore, cb func(vote types.Vote) (stop bool)) bool { + iterator := store.Iterator(nil, nil) defer func() { _ = iterator.Close() }() for ; iterator.Valid(); iterator.Next() { @@ -136,9 +158,60 @@ func (keeper Keeper) IterateVotes(ctx sdk.Context, proposalID uint64, cb func(vo populateLegacyOption(&vote) if cb(vote) { - break + return true } } + return false +} + +func (keeper Keeper) visibleVotesStore(ctx sdk.Context, proposalID uint64) storetypes.KVStore { + store := ctx.KVStore(keeper.storeKey) + pending := prefix.NewStore(store, types.VotesKey(proposalID)) + progress, found := keeper.getTallyProgress(ctx, proposalID) + if !found { + return pending + } + + return visibleVotesStore{ + KVStore: pending, + archived: prefix.NewStore(store, types.TallyVotesKey(proposalID, progress.Expedited)), + storeKey: keeper.storeKey, + } +} + +type visibleVotesStore struct { + storetypes.KVStore + archived storetypes.KVStore + storeKey sdk.StoreKey +} + +func (store visibleVotesStore) Get(key []byte) []byte { + if value := store.KVStore.Get(key); value != nil { + return value + } + return store.archived.Get(key) +} + +func (store visibleVotesStore) Has(key []byte) bool { + return store.KVStore.Has(key) || store.archived.Has(key) +} + +func (store visibleVotesStore) Iterator(start, end []byte) storetypes.Iterator { + return cachekv.NewCacheMergeIterator( + store.archived.Iterator(start, end), + store.KVStore.Iterator(start, end), + true, + store.storeKey, + ) +} + +func (store visibleVotesStore) ReverseIterator(start, end []byte) storetypes.Iterator { + return cachekv.NewCacheMergeIterator( + store.archived.ReverseIterator(start, end), + store.KVStore.ReverseIterator(start, end), + false, + store.storeKey, + ) } // populateLegacyOption adds graceful fallback of deprecated `Option` field, in case diff --git a/sei-cosmos/x/gov/spec/02_state.md b/sei-cosmos/x/gov/spec/02_state.md index 0b192b3554..fd40a6d4a0 100644 --- a/sei-cosmos/x/gov/spec/02_state.md +++ b/sei-cosmos/x/gov/spec/02_state.md @@ -140,18 +140,10 @@ For pseudocode purposes, here are the two function we will use to read or write 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 - content `Handler` is executed. +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 +content `Handler` is executed. And the pseudocode for the `ProposalProcessingQueue`: @@ -213,3 +205,21 @@ And the pseudocode for the `ProposalProcessingQueue`: store(Governance, , proposal) ``` + +## 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. Delegator deductions are capped by the validator's +snapshotted shares, keeping every validator's contribution within its snapshotted +voting-power budget if delegations change between tally blocks. Completed tally +archives are removed incrementally under the same per-block vote-record budget, with +part of that budget reserved so cleanup cannot be starved by unfinished tallies. + +Application-state export serializes all archived and pending votes, but not the +in-progress accumulator. On import, an expired voting proposal starts a new tally from +those votes and the imported staking and governance-parameter state. The accumulator +is created during genesis initialization, so the proposal does not reopen for votes +before its first `EndBlock`. From ae3de5ff6493b10e8b7631d5cad16e0f9b37d4eb Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Wed, 26 Aug 2026 22:06:41 +0800 Subject: [PATCH 03/17] fix(gov): scale incremental delegator power fairly --- sei-cosmos/x/gov/keeper/tally.go | 97 ++++++++++++++++----------- sei-cosmos/x/gov/keeper/tally_test.go | 8 ++- sei-cosmos/x/gov/spec/02_state.md | 12 ++-- 3 files changed, 72 insertions(+), 45 deletions(-) diff --git a/sei-cosmos/x/gov/keeper/tally.go b/sei-cosmos/x/gov/keeper/tally.go index fbfc4ac7a5..dc3faccd27 100644 --- a/sei-cosmos/x/gov/keeper/tally.go +++ b/sei-cosmos/x/gov/keeper/tally.go @@ -29,11 +29,12 @@ type tallyOptionResults struct { } 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"` + Address string `json:"address"` + BondedTokens sdk.Int `json:"bonded_tokens"` + DelegatorShares sdk.Dec `json:"delegator_shares"` + ObservedDelegatorShares sdk.Dec `json:"observed_delegator_shares"` + DelegatorResults tallyOptionResults `json:"delegator_results"` + Vote types.WeightedVoteOptions `json:"vote"` } // Tally calculates a proposal's result without changing its tally state. @@ -41,7 +42,7 @@ func (keeper Keeper) Tally(ctx sdk.Context, proposal types.Proposal) (passes boo progress := keeper.initializeTally(ctx, proposal) validators := progress.validatorMap() keeper.IterateVotes(ctx, proposal.ProposalId, func(vote types.Vote) bool { - keeper.addVoteToTally(ctx, &progress, validators, vote) + keeper.addVoteToTally(ctx, validators, vote) return false }) return keeper.finishTally(progress) @@ -129,12 +130,7 @@ func (keeper Keeper) CleanupTallyVotes(ctx sdk.Context, maxVotes int) (deleted i 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(), - }, + Results: newTallyOptionResults(), TotalVotingPower: sdk.ZeroDec(), TotalBondedTokens: keeper.sk.TotalBondedTokens(ctx), TallyParams: keeper.GetTallyParams(ctx), @@ -143,10 +139,11 @@ func (keeper Keeper) initializeTally(ctx sdk.Context, proposal types.Proposal) t 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(), + Address: validator.GetOperator().String(), + BondedTokens: validator.GetBondedTokens(), + DelegatorShares: validator.GetDelegatorShares(), + ObservedDelegatorShares: sdk.ZeroDec(), + DelegatorResults: newTallyOptionResults(), }) return false }) @@ -178,7 +175,7 @@ func (keeper Keeper) processTallyVotes( var vote types.Vote keeper.cdc.MustUnmarshal(value, &vote) populateLegacyOption(&vote) - keeper.addVoteToTally(ctx, progress, validators, vote) + keeper.addVoteToTally(ctx, validators, vote) voter := sdk.MustAccAddressFromBech32(vote.Voter) store.Set(types.TallyVoteKey(proposalID, progress.Expedited, voter), value) @@ -192,7 +189,6 @@ func (keeper Keeper) processTallyVotes( func (keeper Keeper) addVoteToTally( ctx sdk.Context, - progress *tallyProgress, validators map[string]*tallyValidator, vote types.Vote, ) { @@ -207,19 +203,10 @@ func (keeper Keeper) addVoteToTally( return false } - remainingShares := validator.DelegatorShares.Sub(validator.DelegatorDeductions) - if !remainingShares.IsPositive() { - return false - } - - // Delegations can change while an incremental tally is in progress. The - // validator snapshot is a fixed voting-power budget, so later delegation - // reads cannot deduct more shares than that snapshot contains. - votingShares := sdk.MinDec(delegation.GetShares(), remainingShares) - validator.DelegatorDeductions = validator.DelegatorDeductions.Add(votingShares) + votingShares := delegation.GetShares() + validator.ObservedDelegatorShares = validator.ObservedDelegatorShares.Add(votingShares) votingPower := votingShares.MulInt(validator.BondedTokens).Quo(validator.DelegatorShares) - progress.Results.add(vote.Options, votingPower) - progress.TotalVotingPower = progress.TotalVotingPower.Add(votingPower) + validator.DelegatorResults.add(vote.Options, votingPower) return false }) } @@ -235,14 +222,7 @@ func (progress *tallyProgress) validatorMap() map[string]*tallyValidator { 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 := 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) + progress.addValidatorResults(validator) } tallyResults = progress.Results.tallyResult() @@ -271,6 +251,31 @@ func (keeper Keeper) finishTally(progress tallyProgress) (passes bool, burnDepos return false, false, tallyResults } +func (progress *tallyProgress) addValidatorResults(validator tallyValidator) { + if validator.DelegatorShares.IsZero() { + return + } + + countedDelegatorShares := validator.ObservedDelegatorShares + delegatorScale := sdk.OneDec() + if countedDelegatorShares.GT(validator.DelegatorShares) { + delegatorScale = validator.DelegatorShares.Quo(countedDelegatorShares) + countedDelegatorShares = validator.DelegatorShares + } + + progress.Results.addScaled(validator.DelegatorResults, delegatorScale) + delegatorVotingPower := countedDelegatorShares.MulInt(validator.BondedTokens).Quo(validator.DelegatorShares) + progress.TotalVotingPower = progress.TotalVotingPower.Add(delegatorVotingPower) + + if len(validator.Vote) == 0 { + return + } + validatorShares := validator.DelegatorShares.Sub(countedDelegatorShares) + validatorVotingPower := validatorShares.MulInt(validator.BondedTokens).Quo(validator.DelegatorShares) + progress.Results.add(validator.Vote, validatorVotingPower) + progress.TotalVotingPower = progress.TotalVotingPower.Add(validatorVotingPower) +} + func (results *tallyOptionResults) add(options types.WeightedVoteOptions, votingPower sdk.Dec) { for _, option := range options { subPower := votingPower.Mul(option.Weight) @@ -289,6 +294,22 @@ func (results *tallyOptionResults) add(options types.WeightedVoteOptions, voting } } +func (results *tallyOptionResults) addScaled(other tallyOptionResults, scale sdk.Dec) { + results.Yes = results.Yes.Add(other.Yes.Mul(scale)) + results.Abstain = results.Abstain.Add(other.Abstain.Mul(scale)) + results.No = results.No.Add(other.No.Mul(scale)) + results.NoWithVeto = results.NoWithVeto.Add(other.NoWithVeto.Mul(scale)) +} + +func newTallyOptionResults() tallyOptionResults { + return tallyOptionResults{ + Yes: sdk.ZeroDec(), + Abstain: sdk.ZeroDec(), + No: sdk.ZeroDec(), + NoWithVeto: sdk.ZeroDec(), + } +} + func (results tallyOptionResults) tallyResult() types.TallyResult { return types.NewTallyResult( results.Yes.TruncateInt(), diff --git a/sei-cosmos/x/gov/keeper/tally_test.go b/sei-cosmos/x/gov/keeper/tally_test.go index ae77f77136..3e3bc37294 100644 --- a/sei-cosmos/x/gov/keeper/tally_test.go +++ b/sei-cosmos/x/gov/keeper/tally_test.go @@ -555,7 +555,7 @@ func TestTallyIncrementalPersistsProgressAndCleansArchivedVotes(t *testing.T) { require.Empty(t, app.GovKeeper.GetArchivedTallyVotes(ctx, proposal.ProposalId, false)) } -func TestTallyIncrementalCapsDelegationsAddedAfterSnapshot(t *testing.T) { +func TestTallyIncrementalScalesDelegationsAddedAfterSnapshot(t *testing.T) { app := seiapp.Setup(t, false, false, false) ctx := app.BaseApp.NewContext(false, tmproto.Header{}) addrs, valAddrs := createValidators(t, ctx, app, []int64{5, 5, 5}) @@ -591,7 +591,11 @@ func TestTallyIncrementalCapsDelegationsAddedAfterSnapshot(t *testing.T) { complete, processed, _, _, tallyResult := app.GovKeeper.TallyIncremental(ctx, proposal, 2) require.True(t, complete) require.Equal(t, 2, processed) - require.True(t, tallyResult.Yes.Add(tallyResult.No).Equal(snapshotValidatorTokens)) + observedValidatorTokens := snapshotValidatorTokens.Add(delegatedTokens) + expectedYes := snapshotValidatorTokens.Mul(snapshotValidatorTokens).Quo(observedValidatorTokens) + expectedNo := snapshotValidatorTokens.Sub(expectedYes) + require.True(t, tallyResult.Yes.Equal(expectedYes)) + require.True(t, tallyResult.No.Equal(expectedNo)) totalVotingPower := tallyResult.Yes.Add(tallyResult.Abstain).Add(tallyResult.No).Add(tallyResult.NoWithVeto) require.False(t, totalVotingPower.GT(snapshotTotalBonded)) } diff --git a/sei-cosmos/x/gov/spec/02_state.md b/sei-cosmos/x/gov/spec/02_state.md index fd40a6d4a0..30ce772ff8 100644 --- a/sei-cosmos/x/gov/spec/02_state.md +++ b/sei-cosmos/x/gov/spec/02_state.md @@ -212,11 +212,13 @@ 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. Delegator deductions are capped by the validator's -snapshotted shares, keeping every validator's contribution within its snapshotted -voting-power budget if delegations change between tally blocks. Completed tally -archives are removed incrementally under the same per-block vote-record budget, with -part of that budget reserved so cleanup cannot be starved by unfinished tallies. +after the accumulator is created. Delegator results are accumulated per validator. If +the observed live delegation shares exceed that validator's snapshot, every delegator +option is scaled by the same factor to fit the snapshotted voting-power budget. This +makes the result independent of vote-record order and prevents later records from +being dropped when delegations change between tally blocks. Completed tally archives +are removed incrementally under the same per-block vote-record budget, with part of +that budget reserved so cleanup cannot be starved by unfinished tallies. Application-state export serializes all archived and pending votes, but not the in-progress accumulator. On import, an expired voting proposal starts a new tally from From d179fdd9531b88055c935425431976018663a101 Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Thu, 27 Aug 2026 13:06:42 +0800 Subject: [PATCH 04/17] fix(gov): freeze delegation shares when votes are cast --- .../proto/cosmos/gov/v1beta1/genesis.proto | 18 + sei-cosmos/x/gov/genesis.go | 20 +- sei-cosmos/x/gov/genesis_test.go | 7 + sei-cosmos/x/gov/keeper/tally.go | 48 +- sei-cosmos/x/gov/keeper/tally_test.go | 50 +- sei-cosmos/x/gov/keeper/vote.go | 57 ++ sei-cosmos/x/gov/simulation/decoder.go | 4 +- sei-cosmos/x/gov/spec/02_state.md | 34 +- sei-cosmos/x/gov/types/genesis.go | 69 +- sei-cosmos/x/gov/types/genesis.pb.go | 617 +++++++++++++++++- sei-cosmos/x/gov/types/genesis_test.go | 17 + sei-cosmos/x/gov/types/keys.go | 22 +- sei-cosmos/x/gov/types/keys_test.go | 3 + 13 files changed, 893 insertions(+), 73 deletions(-) diff --git a/sei-cosmos/proto/cosmos/gov/v1beta1/genesis.proto b/sei-cosmos/proto/cosmos/gov/v1beta1/genesis.proto index 8229d3c0b4..5403109227 100644 --- a/sei-cosmos/proto/cosmos/gov/v1beta1/genesis.proto +++ b/sei-cosmos/proto/cosmos/gov/v1beta1/genesis.proto @@ -41,4 +41,22 @@ message GenesisState { (gogoproto.nullable) = false, (gogoproto.moretags) = "yaml:\"tally_params\"" ]; + // vote_delegation_snapshots defines the delegation shares captured with each vote. + repeated VoteDelegationSnapshot vote_delegation_snapshots = 8 [(gogoproto.nullable) = false]; +} + +// VoteDelegationSnapshot defines the per-validator delegation shares captured with a vote. +message VoteDelegationSnapshot { + uint64 proposal_id = 1 [(gogoproto.moretags) = "yaml:\"proposal_id\""]; + string voter = 2; + repeated VoteDelegation delegations = 3 [(gogoproto.nullable) = false]; +} + +// VoteDelegation defines a voter's shares in one validator. +message VoteDelegation { + string validator = 1; + string shares = 2 [ + (gogoproto.customtype) = "github.com/sei-protocol/sei-chain/sei-cosmos/types.Dec", + (gogoproto.nullable) = false + ]; } diff --git a/sei-cosmos/x/gov/genesis.go b/sei-cosmos/x/gov/genesis.go index 4252059457..81d5adff6e 100644 --- a/sei-cosmos/x/gov/genesis.go +++ b/sei-cosmos/x/gov/genesis.go @@ -30,6 +30,9 @@ func InitGenesis(ctx sdk.Context, ak types.AccountKeeper, bk types.BankKeeper, k for _, vote := range data.Votes { k.SetVote(ctx, vote) } + for _, snapshot := range data.VoteDelegationSnapshots { + k.SetVoteDelegationSnapshot(ctx, snapshot) + } for _, proposal := range data.Proposals { switch proposal.Status { @@ -66,21 +69,24 @@ func ExportGenesis(ctx sdk.Context, k keeper.Keeper) *types.GenesisState { var proposalsDeposits types.Deposits var proposalsVotes types.Votes + voteDelegationSnapshots := make([]types.VoteDelegationSnapshot, 0, len(proposals)) for _, proposal := range proposals { deposits := k.GetDeposits(ctx, proposal.ProposalId) proposalsDeposits = append(proposalsDeposits, deposits...) votes := k.GetVotes(ctx, proposal.ProposalId) proposalsVotes = append(proposalsVotes, votes...) + voteDelegationSnapshots = append(voteDelegationSnapshots, k.GetVoteDelegationSnapshots(ctx, proposal)...) } return &types.GenesisState{ - StartingProposalId: startingProposalID, - Deposits: proposalsDeposits, - Votes: proposalsVotes, - Proposals: proposals, - DepositParams: depositParams, - VotingParams: votingParams, - TallyParams: tallyParams, + StartingProposalId: startingProposalID, + Deposits: proposalsDeposits, + Votes: proposalsVotes, + Proposals: proposals, + DepositParams: depositParams, + VotingParams: votingParams, + TallyParams: tallyParams, + VoteDelegationSnapshots: voteDelegationSnapshots, } } diff --git a/sei-cosmos/x/gov/genesis_test.go b/sei-cosmos/x/gov/genesis_test.go index c07a4d1c33..b4ad98f7cc 100644 --- a/sei-cosmos/x/gov/genesis_test.go +++ b/sei-cosmos/x/gov/genesis_test.go @@ -197,6 +197,12 @@ func TestExportGenesisIncludesVotesFromUnfinishedTally(t *testing.T) { genesis := gov.ExportGenesis(ctx, app.GovKeeper) require.Len(t, genesis.Votes, 3) + require.Len(t, genesis.VoteDelegationSnapshots, 3) + genesisJSON := app.AppCodec().MustMarshalJSON(genesis) + var decodedGenesis types.GenesisState + app.AppCodec().MustUnmarshalJSON(genesisJSON, &decodedGenesis) + require.True(t, genesis.Equal(decodedGenesis)) + genesis = &decodedGenesis importedApp := seiapp.Setup(t, false, false, false) importedCtx := importedApp.BaseApp.NewContext(false, tmproto.Header{}).WithBlockTime(proposal.VotingEndTime) @@ -210,6 +216,7 @@ func TestExportGenesisIncludesVotesFromUnfinishedTally(t *testing.T) { require.True(t, importedApp.GovKeeper.IsTallying(importedCtx, proposal.ProposalId)) require.Len(t, importedApp.GovKeeper.GetVotes(importedCtx, proposal.ProposalId), 3) + require.Len(t, importedApp.GovKeeper.GetVoteDelegationSnapshots(importedCtx, proposal), 3) newVoter := make(sdk.AccAddress, 20) binary.BigEndian.PutUint64(newVoter[12:], 4) err = importedApp.GovKeeper.AddVote( diff --git a/sei-cosmos/x/gov/keeper/tally.go b/sei-cosmos/x/gov/keeper/tally.go index dc3faccd27..92fd716294 100644 --- a/sei-cosmos/x/gov/keeper/tally.go +++ b/sei-cosmos/x/gov/keeper/tally.go @@ -42,7 +42,7 @@ func (keeper Keeper) Tally(ctx sdk.Context, proposal types.Proposal) (passes boo progress := keeper.initializeTally(ctx, proposal) validators := progress.validatorMap() keeper.IterateVotes(ctx, proposal.ProposalId, func(vote types.Vote) bool { - keeper.addVoteToTally(ctx, validators, vote) + keeper.addVoteToTally(validators, vote, keeper.voteDelegations(ctx, proposal.ProposalId, progress.Expedited, vote)) return false }) return keeper.finishTally(progress) @@ -175,11 +175,23 @@ func (keeper Keeper) processTallyVotes( var vote types.Vote keeper.cdc.MustUnmarshal(value, &vote) populateLegacyOption(&vote) - keeper.addVoteToTally(ctx, validators, vote) voter := sdk.MustAccAddressFromBech32(vote.Voter) + snapshotKey := types.VoteDelegationsKey(proposalID, voter) + snapshotValue := store.Get(snapshotKey) + var snapshot types.VoteDelegationSnapshot + if snapshotValue == nil { + snapshot = keeper.snapshotVoteDelegations(ctx, proposalID, voter) + snapshotValue = keeper.cdc.MustMarshal(&snapshot) + } else { + snapshot = keeper.unmarshalVoteDelegations(snapshotValue) + } + keeper.addVoteToTally(validators, vote, snapshot) + store.Set(types.TallyVoteKey(proposalID, progress.Expedited, voter), value) + store.Set(types.TallyVoteDelegationsKey(proposalID, progress.Expedited, voter), snapshotValue) store.Delete(key) + store.Delete(snapshotKey) progress.Cursor = key processed++ } @@ -188,27 +200,43 @@ func (keeper Keeper) processTallyVotes( } func (keeper Keeper) addVoteToTally( - ctx sdk.Context, validators map[string]*tallyValidator, vote types.Vote, + snapshot types.VoteDelegationSnapshot, ) { 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()] + for _, delegation := range snapshot.Delegations { + validator, ok := validators[delegation.Validator] if !ok || validator.DelegatorShares.IsZero() { - return false + continue } - votingShares := delegation.GetShares() + votingShares := delegation.Shares validator.ObservedDelegatorShares = validator.ObservedDelegatorShares.Add(votingShares) votingPower := votingShares.MulInt(validator.BondedTokens).Quo(validator.DelegatorShares) validator.DelegatorResults.add(vote.Options, votingPower) - return false - }) + } +} + +func (keeper Keeper) voteDelegations( + ctx sdk.Context, + proposalID uint64, + expedited bool, + vote types.Vote, +) types.VoteDelegationSnapshot { + voter := sdk.MustAccAddressFromBech32(vote.Voter) + store := ctx.KVStore(keeper.storeKey) + if bz := store.Get(types.VoteDelegationsKey(proposalID, voter)); bz != nil { + return keeper.unmarshalVoteDelegations(bz) + } + if bz := store.Get(types.TallyVoteDelegationsKey(proposalID, expedited, voter)); bz != nil { + return keeper.unmarshalVoteDelegations(bz) + } + return keeper.snapshotVoteDelegations(ctx, proposalID, voter) } func (progress *tallyProgress) validatorMap() map[string]*tallyValidator { @@ -371,6 +399,8 @@ func (keeper Keeper) cleanupProposalTallyVotes( for ; iterator.Valid() && deleted < maxVotes; iterator.Next() { cursor = append(cursor[:0], iterator.Key()...) store.Delete(iterator.Key()) + snapshotKey := append([]byte{types.TallyVoteDelegationsKeyPrefix[0]}, iterator.Key()[1:]...) + store.Delete(snapshotKey) deleted++ } diff --git a/sei-cosmos/x/gov/keeper/tally_test.go b/sei-cosmos/x/gov/keeper/tally_test.go index 3e3bc37294..6154f473e3 100644 --- a/sei-cosmos/x/gov/keeper/tally_test.go +++ b/sei-cosmos/x/gov/keeper/tally_test.go @@ -525,6 +525,9 @@ func TestTallyIncrementalPersistsProgressAndCleansArchivedVotes(t *testing.T) { 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), 3) + store := ctx.KVStore(app.GetKey(types.StoreKey)) + require.True(t, store.Has(types.TallyVoteDelegationsKey(proposal.ProposalId, false, addrs[0]))) + require.False(t, store.Has(types.VoteDelegationsKey(proposal.ProposalId, addrs[0]))) _, _, queryResult := app.GovKeeper.Tally(ctx, proposal) require.False(t, queryResult.Equals(types.EmptyTallyResult())) @@ -553,9 +556,12 @@ func TestTallyIncrementalPersistsProgressAndCleansArchivedVotes(t *testing.T) { 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)) + for _, addr := range addrs[:3] { + require.False(t, store.Has(types.TallyVoteDelegationsKey(proposal.ProposalId, false, addr))) + } } -func TestTallyIncrementalScalesDelegationsAddedAfterSnapshot(t *testing.T) { +func TestTallyIncrementalIgnoresDelegationsAddedAfterVote(t *testing.T) { app := seiapp.Setup(t, false, false, false) ctx := app.BaseApp.NewContext(false, tmproto.Header{}) addrs, valAddrs := createValidators(t, ctx, app, []int64{5, 5, 5}) @@ -591,15 +597,47 @@ func TestTallyIncrementalScalesDelegationsAddedAfterSnapshot(t *testing.T) { complete, processed, _, _, tallyResult := app.GovKeeper.TallyIncremental(ctx, proposal, 2) require.True(t, complete) require.Equal(t, 2, processed) - observedValidatorTokens := snapshotValidatorTokens.Add(delegatedTokens) - expectedYes := snapshotValidatorTokens.Mul(snapshotValidatorTokens).Quo(observedValidatorTokens) - expectedNo := snapshotValidatorTokens.Sub(expectedYes) - require.True(t, tallyResult.Yes.Equal(expectedYes)) - require.True(t, tallyResult.No.Equal(expectedNo)) + require.True(t, tallyResult.Yes.Equal(snapshotValidatorTokens)) + require.True(t, tallyResult.No.IsZero()) totalVotingPower := tallyResult.Yes.Add(tallyResult.Abstain).Add(tallyResult.No).Add(tallyResult.NoWithVeto) require.False(t, totalVotingPower.GT(snapshotTotalBonded)) } +func TestTallyIncrementalKeepsDelegationsRemovedAfterVote(t *testing.T) { + app := seiapp.Setup(t, false, false, false) + ctx := app.BaseApp.NewContext(false, tmproto.Header{}) + addrs, valAddrs := createValidators(t, ctx, app, []int64{5, 5, 5}) + + validator, found := app.StakingKeeper.GetValidator(ctx, valAddrs[0]) + require.True(t, found) + delegatedTokens := app.StakingKeeper.TokensFromConsensusPower(ctx, 20) + _, err := app.StakingKeeper.Delegate(ctx, addrs[3], delegatedTokens, stakingtypes.Unbonded, validator, true) + require.NoError(t, err) + + proposal, err := app.GovKeeper.SubmitProposal(ctx, TestProposal) + require.NoError(t, err) + proposal.Status = types.StatusVotingPeriod + app.GovKeeper.SetProposal(ctx, proposal) + require.NoError(t, app.GovKeeper.AddVote( + ctx, + proposal.ProposalId, + addrs[3], + types.NewNonSplitVoteOption(types.OptionNo), + )) + app.GovKeeper.InitializeTally(ctx, proposal) + + delegation, found := app.StakingKeeper.GetDelegation(ctx, addrs[3], valAddrs[0]) + require.True(t, found) + _, err = app.StakingKeeper.Undelegate(ctx, addrs[3], valAddrs[0], delegation.GetShares()) + require.NoError(t, err) + + complete, processed, _, burnDeposits, tallyResult := app.GovKeeper.TallyIncremental(ctx, proposal, 1) + require.True(t, complete) + require.Equal(t, 1, processed) + require.False(t, burnDeposits) + require.True(t, tallyResult.No.Equal(delegatedTokens)) +} + func TestTallyArchivesExpeditedAndRegularRoundsSeparately(t *testing.T) { app := seiapp.Setup(t, false, false, false) ctx := app.BaseApp.NewContext(false, tmproto.Header{}) diff --git a/sei-cosmos/x/gov/keeper/vote.go b/sei-cosmos/x/gov/keeper/vote.go index 49cf78b7a7..4ea363c2fc 100644 --- a/sei-cosmos/x/gov/keeper/vote.go +++ b/sei-cosmos/x/gov/keeper/vote.go @@ -9,6 +9,7 @@ import ( sdk "github.com/sei-protocol/sei-chain/sei-cosmos/types" sdkerrors "github.com/sei-protocol/sei-chain/sei-cosmos/types/errors" "github.com/sei-protocol/sei-chain/sei-cosmos/x/gov/types" + stakingtypes "github.com/sei-protocol/sei-chain/sei-cosmos/x/staking/types" ) // AddVote adds a vote on a specific proposal @@ -110,6 +111,62 @@ func (keeper Keeper) SetVote(ctx sdk.Context, vote types.Vote) { addr := sdk.MustAccAddressFromBech32(vote.Voter) store.Set(types.VoteKey(vote.ProposalId, addr), bz) + keeper.setVoteDelegations(ctx, vote.ProposalId, addr) +} + +func (keeper Keeper) setVoteDelegations(ctx sdk.Context, proposalID uint64, voter sdk.AccAddress) { + snapshot := keeper.snapshotVoteDelegations(ctx, proposalID, voter) + keeper.setVoteDelegationSnapshot(ctx, snapshot) +} + +func (keeper Keeper) snapshotVoteDelegations( + ctx sdk.Context, + proposalID uint64, + voter sdk.AccAddress, +) types.VoteDelegationSnapshot { + snapshot := types.VoteDelegationSnapshot{ + ProposalId: proposalID, + Voter: voter.String(), + Delegations: []types.VoteDelegation{}, + } + keeper.sk.IterateDelegations(ctx, voter, func(_ int64, delegation stakingtypes.DelegationI) bool { + snapshot.Delegations = append(snapshot.Delegations, types.VoteDelegation{ + Validator: delegation.GetValidatorAddr().String(), + Shares: delegation.GetShares(), + }) + return false + }) + return snapshot +} + +func (keeper Keeper) setVoteDelegationSnapshot(ctx sdk.Context, snapshot types.VoteDelegationSnapshot) { + voter := sdk.MustAccAddressFromBech32(snapshot.Voter) + bz := keeper.cdc.MustMarshal(&snapshot) + ctx.KVStore(keeper.storeKey).Set(types.VoteDelegationsKey(snapshot.ProposalId, voter), bz) +} + +// SetVoteDelegationSnapshot stores a vote's exported delegation snapshot. +func (keeper Keeper) SetVoteDelegationSnapshot(ctx sdk.Context, snapshot types.VoteDelegationSnapshot) { + keeper.setVoteDelegationSnapshot(ctx, snapshot) +} + +// GetVoteDelegationSnapshots returns the stored delegation snapshots for a proposal's visible votes. +func (keeper Keeper) GetVoteDelegationSnapshots( + ctx sdk.Context, + proposal types.Proposal, +) []types.VoteDelegationSnapshot { + snapshots := make([]types.VoteDelegationSnapshot, 0) + keeper.IterateVotes(ctx, proposal.ProposalId, func(vote types.Vote) bool { + snapshots = append(snapshots, keeper.voteDelegations(ctx, proposal.ProposalId, proposal.IsExpedited, vote)) + return false + }) + return snapshots +} + +func (keeper Keeper) unmarshalVoteDelegations(bz []byte) types.VoteDelegationSnapshot { + var snapshot types.VoteDelegationSnapshot + keeper.cdc.MustUnmarshal(bz, &snapshot) + return snapshot } // IterateAllVotes iterates over the all the stored votes and performs a callback function diff --git a/sei-cosmos/x/gov/simulation/decoder.go b/sei-cosmos/x/gov/simulation/decoder.go index 479860c12e..94f1d37023 100644 --- a/sei-cosmos/x/gov/simulation/decoder.go +++ b/sei-cosmos/x/gov/simulation/decoder.go @@ -49,7 +49,9 @@ func NewDecodeStore(cdc codec.Codec) func(kvA, kvB kv.Pair) string { return fmt.Sprintf("%v\n%v", voteA, voteB) case bytes.Equal(kvA.Key[:1], types.TallyProgressKeyPrefix), - bytes.Equal(kvA.Key[:1], types.TallyCleanupKeyPrefix): + bytes.Equal(kvA.Key[:1], types.TallyCleanupKeyPrefix), + bytes.Equal(kvA.Key[:1], types.VoteDelegationsKeyPrefix), + bytes.Equal(kvA.Key[:1], types.TallyVoteDelegationsKeyPrefix): return fmt.Sprintf("%X\n%X", kvA.Value, kvB.Value) default: diff --git a/sei-cosmos/x/gov/spec/02_state.md b/sei-cosmos/x/gov/spec/02_state.md index 30ce772ff8..295f43162c 100644 --- a/sei-cosmos/x/gov/spec/02_state.md +++ b/sei-cosmos/x/gov/spec/02_state.md @@ -119,12 +119,14 @@ We also mention a method to update the tally for a given proposal: _Stores are KVStores in the multi-store. The key to find the store is the first parameter in the list_` -We will use one KVStore `Governance` to store two mappings: +We will use one KVStore `Governance` to store three mappings: - A mapping from `proposalID|'proposal'` to `Proposal`. - A mapping from `proposalID|'addresses'|address` to `Vote`. This mapping allows us to query all addresses that voted on the proposal along with their vote by doing a range query on `proposalID:addresses`. +- A mapping from `proposalID|'delegations'|address` to the voter's per-validator + delegation shares when the vote was recorded. For pseudocode purposes, here are the two function we will use to read or write in stores: @@ -163,7 +165,7 @@ And the pseudocode for the `ProposalProcessingQueue`: // Tally voterIterator = rangeQuery(Governance, ) //return all the addresses that voted on the proposal for each (voterAddress, vote) in voterIterator - delegations = stakingKeeper.getDelegations(voterAddress) // get all delegations for current voter + delegations = getVoteDelegationSnapshot(voterAddress) for each delegation in delegations // make sure delegation.Shares does NOT include shares being unbonded @@ -212,16 +214,20 @@ 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. Delegator results are accumulated per validator. If -the observed live delegation shares exceed that validator's snapshot, every delegator +after the accumulator is created. Each vote retains the voter's per-validator +delegation shares from the time the vote was recorded, and that snapshot moves with +the vote into the tally archive. Delegator results are accumulated per validator from +those stored shares, so delegation changes after voting do not alter the result. If +the stored delegation shares exceed that validator's tally snapshot, every delegator option is scaled by the same factor to fit the snapshotted voting-power budget. This -makes the result independent of vote-record order and prevents later records from -being dropped when delegations change between tally blocks. Completed tally archives -are removed incrementally under the same per-block vote-record budget, with part of -that budget reserved so cleanup cannot be starved by unfinished tallies. - -Application-state export serializes all archived and pending votes, but not the -in-progress accumulator. On import, an expired voting proposal starts a new tally from -those votes and the imported staking and governance-parameter state. The accumulator -is created during genesis initialization, so the proposal does not reopen for votes -before its first `EndBlock`. +makes the result independent of vote-record order. Completed tally archives and their +delegation snapshots are removed incrementally under the same per-block vote-record +budget, with part of that budget reserved so cleanup cannot be starved by unfinished +tallies. + +Application-state export serializes all archived and pending votes together with their +delegation snapshots, but not the in-progress accumulator. On import, an expired voting +proposal starts a new tally from those votes, their original delegation snapshots, and +the imported validator and governance-parameter state. The accumulator is created +during genesis initialization, so the proposal does not reopen for votes before its +first `EndBlock`. diff --git a/sei-cosmos/x/gov/types/genesis.go b/sei-cosmos/x/gov/types/genesis.go index f0f2547927..52249e4cfb 100644 --- a/sei-cosmos/x/gov/types/genesis.go +++ b/sei-cosmos/x/gov/types/genesis.go @@ -3,7 +3,8 @@ package types import ( "fmt" - "github.com/sei-protocol/sei-chain/sei-cosmos/codec/types" + codecTypes "github.com/sei-protocol/sei-chain/sei-cosmos/codec/types" + sdk "github.com/sei-protocol/sei-chain/sei-cosmos/types" ) // NewGenesisState creates a new genesis state for the governance module @@ -33,7 +34,26 @@ func (data GenesisState) Equal(other GenesisState) bool { data.Proposals.Equal(other.Proposals) && data.DepositParams.Equal(other.DepositParams) && data.TallyParams.Equal(other.TallyParams) && - data.VotingParams.Equal(other.VotingParams) + data.VotingParams.Equal(other.VotingParams) && + voteDelegationSnapshotsEqual(data.VoteDelegationSnapshots, other.VoteDelegationSnapshots) +} + +func voteDelegationSnapshotsEqual(a, b []VoteDelegationSnapshot) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i].ProposalId != b[i].ProposalId || a[i].Voter != b[i].Voter || len(a[i].Delegations) != len(b[i].Delegations) { + return false + } + for j := range a[i].Delegations { + if a[i].Delegations[j].Validator != b[i].Delegations[j].Validator || + !a[i].Delegations[j].Shares.Equal(b[i].Delegations[j].Shares) { + return false + } + } + } + return true } // Empty returns true if a GenesisState is empty @@ -71,13 +91,54 @@ func ValidateGenesis(data *GenesisState) error { data.DepositParams.MinDeposit.String()) } + if err := validateVoteDelegationSnapshots(data.Votes, data.VoteDelegationSnapshots); err != nil { + return err + } + + return nil +} + +func validateVoteDelegationSnapshots(votes Votes, snapshots []VoteDelegationSnapshot) error { + voteKeys := make(map[string]struct{}, len(votes)) + for _, vote := range votes { + voteKeys[fmt.Sprintf("%d/%s", vote.ProposalId, vote.Voter)] = struct{}{} + } + + seenSnapshots := make(map[string]struct{}, len(snapshots)) + for _, snapshot := range snapshots { + key := fmt.Sprintf("%d/%s", snapshot.ProposalId, snapshot.Voter) + if _, found := voteKeys[key]; !found { + return fmt.Errorf("vote delegation snapshot %s has no matching vote", key) + } + if _, found := seenSnapshots[key]; found { + return fmt.Errorf("duplicate vote delegation snapshot %s", key) + } + seenSnapshots[key] = struct{}{} + + if _, err := sdk.AccAddressFromBech32(snapshot.Voter); err != nil { + return fmt.Errorf("invalid vote delegation snapshot voter %q: %w", snapshot.Voter, err) + } + seenValidators := make(map[string]struct{}, len(snapshot.Delegations)) + for _, delegation := range snapshot.Delegations { + if _, err := sdk.ValAddressFromBech32(delegation.Validator); err != nil { + return fmt.Errorf("invalid vote delegation snapshot validator %q: %w", delegation.Validator, err) + } + if !delegation.Shares.IsPositive() { + return fmt.Errorf("vote delegation snapshot shares must be positive: %s", delegation.Shares) + } + if _, found := seenValidators[delegation.Validator]; found { + return fmt.Errorf("duplicate validator %q in vote delegation snapshot %s", delegation.Validator, key) + } + seenValidators[delegation.Validator] = struct{}{} + } + } return nil } -var _ types.UnpackInterfacesMessage = GenesisState{} +var _ codecTypes.UnpackInterfacesMessage = GenesisState{} // UnpackInterfaces implements UnpackInterfacesMessage.UnpackInterfaces -func (data GenesisState) UnpackInterfaces(unpacker types.AnyUnpacker) error { +func (data GenesisState) UnpackInterfaces(unpacker codecTypes.AnyUnpacker) error { for _, p := range data.Proposals { err := p.UnpackInterfaces(unpacker) if err != nil { diff --git a/sei-cosmos/x/gov/types/genesis.pb.go b/sei-cosmos/x/gov/types/genesis.pb.go index 0986a5e939..501d4dfc3f 100644 --- a/sei-cosmos/x/gov/types/genesis.pb.go +++ b/sei-cosmos/x/gov/types/genesis.pb.go @@ -7,6 +7,7 @@ import ( fmt "fmt" _ "github.com/gogo/protobuf/gogoproto" proto "github.com/gogo/protobuf/proto" + github_com_sei_protocol_sei_chain_sei_cosmos_types "github.com/sei-protocol/sei-chain/sei-cosmos/types" io "io" math "math" math_bits "math/bits" @@ -39,6 +40,8 @@ type GenesisState struct { VotingParams VotingParams `protobuf:"bytes,6,opt,name=voting_params,json=votingParams,proto3" json:"voting_params" yaml:"voting_params"` // params defines all the paramaters of related to tally. TallyParams TallyParams `protobuf:"bytes,7,opt,name=tally_params,json=tallyParams,proto3" json:"tally_params" yaml:"tally_params"` + // vote_delegation_snapshots defines the delegation shares captured with each vote. + VoteDelegationSnapshots []VoteDelegationSnapshot `protobuf:"bytes,8,rep,name=vote_delegation_snapshots,json=voteDelegationSnapshots,proto3" json:"vote_delegation_snapshots"` } func (m *GenesisState) Reset() { *m = GenesisState{} } @@ -123,42 +126,167 @@ func (m *GenesisState) GetTallyParams() TallyParams { return TallyParams{} } +func (m *GenesisState) GetVoteDelegationSnapshots() []VoteDelegationSnapshot { + if m != nil { + return m.VoteDelegationSnapshots + } + return nil +} + +// VoteDelegationSnapshot defines the per-validator delegation shares captured with a vote. +type VoteDelegationSnapshot struct { + ProposalId uint64 `protobuf:"varint,1,opt,name=proposal_id,json=proposalId,proto3" json:"proposal_id,omitempty" yaml:"proposal_id"` + Voter string `protobuf:"bytes,2,opt,name=voter,proto3" json:"voter,omitempty"` + Delegations []VoteDelegation `protobuf:"bytes,3,rep,name=delegations,proto3" json:"delegations"` +} + +func (m *VoteDelegationSnapshot) Reset() { *m = VoteDelegationSnapshot{} } +func (m *VoteDelegationSnapshot) String() string { return proto.CompactTextString(m) } +func (*VoteDelegationSnapshot) ProtoMessage() {} +func (*VoteDelegationSnapshot) Descriptor() ([]byte, []int) { + return fileDescriptor_43cd825e0fa7a627, []int{1} +} +func (m *VoteDelegationSnapshot) XXX_Unmarshal(b []byte) error { + return m.Unmarshal(b) +} +func (m *VoteDelegationSnapshot) XXX_Marshal(b []byte, deterministic bool) ([]byte, error) { + if deterministic { + return xxx_messageInfo_VoteDelegationSnapshot.Marshal(b, m, deterministic) + } else { + b = b[:cap(b)] + n, err := m.MarshalToSizedBuffer(b) + if err != nil { + return nil, err + } + return b[:n], nil + } +} +func (m *VoteDelegationSnapshot) XXX_Merge(src proto.Message) { + xxx_messageInfo_VoteDelegationSnapshot.Merge(m, src) +} +func (m *VoteDelegationSnapshot) XXX_Size() int { + return m.Size() +} +func (m *VoteDelegationSnapshot) XXX_DiscardUnknown() { + xxx_messageInfo_VoteDelegationSnapshot.DiscardUnknown(m) +} + +var xxx_messageInfo_VoteDelegationSnapshot proto.InternalMessageInfo + +func (m *VoteDelegationSnapshot) GetProposalId() uint64 { + if m != nil { + return m.ProposalId + } + return 0 +} + +func (m *VoteDelegationSnapshot) GetVoter() string { + if m != nil { + return m.Voter + } + return "" +} + +func (m *VoteDelegationSnapshot) GetDelegations() []VoteDelegation { + if m != nil { + return m.Delegations + } + return nil +} + +// VoteDelegation defines a voter's shares in one validator. +type VoteDelegation struct { + Validator string `protobuf:"bytes,1,opt,name=validator,proto3" json:"validator,omitempty"` + Shares github_com_sei_protocol_sei_chain_sei_cosmos_types.Dec `protobuf:"bytes,2,opt,name=shares,proto3,customtype=github.com/sei-protocol/sei-chain/sei-cosmos/types.Dec" json:"shares"` +} + +func (m *VoteDelegation) Reset() { *m = VoteDelegation{} } +func (m *VoteDelegation) String() string { return proto.CompactTextString(m) } +func (*VoteDelegation) ProtoMessage() {} +func (*VoteDelegation) Descriptor() ([]byte, []int) { + return fileDescriptor_43cd825e0fa7a627, []int{2} +} +func (m *VoteDelegation) XXX_Unmarshal(b []byte) error { + return m.Unmarshal(b) +} +func (m *VoteDelegation) XXX_Marshal(b []byte, deterministic bool) ([]byte, error) { + if deterministic { + return xxx_messageInfo_VoteDelegation.Marshal(b, m, deterministic) + } else { + b = b[:cap(b)] + n, err := m.MarshalToSizedBuffer(b) + if err != nil { + return nil, err + } + return b[:n], nil + } +} +func (m *VoteDelegation) XXX_Merge(src proto.Message) { + xxx_messageInfo_VoteDelegation.Merge(m, src) +} +func (m *VoteDelegation) XXX_Size() int { + return m.Size() +} +func (m *VoteDelegation) XXX_DiscardUnknown() { + xxx_messageInfo_VoteDelegation.DiscardUnknown(m) +} + +var xxx_messageInfo_VoteDelegation proto.InternalMessageInfo + +func (m *VoteDelegation) GetValidator() string { + if m != nil { + return m.Validator + } + return "" +} + func init() { proto.RegisterType((*GenesisState)(nil), "cosmos.gov.v1beta1.GenesisState") + proto.RegisterType((*VoteDelegationSnapshot)(nil), "cosmos.gov.v1beta1.VoteDelegationSnapshot") + proto.RegisterType((*VoteDelegation)(nil), "cosmos.gov.v1beta1.VoteDelegation") } func init() { proto.RegisterFile("cosmos/gov/v1beta1/genesis.proto", fileDescriptor_43cd825e0fa7a627) } var fileDescriptor_43cd825e0fa7a627 = []byte{ - // 438 bytes of a gzipped FileDescriptorProto - 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0x7c, 0x92, 0x41, 0x6f, 0xd3, 0x30, - 0x14, 0xc7, 0x1b, 0xd6, 0x8e, 0xcd, 0x6d, 0x11, 0x98, 0x22, 0x45, 0x6b, 0x49, 0x42, 0x4e, 0xbd, - 0x90, 0x68, 0xe3, 0x82, 0x90, 0xb8, 0x44, 0x48, 0x68, 0x07, 0xa4, 0x61, 0x10, 0x07, 0x2e, 0x95, - 0x9b, 0x5a, 0x5e, 0xa4, 0xb4, 0x2f, 0xea, 0x33, 0x11, 0xfd, 0x16, 0x7c, 0x0e, 0x3e, 0xc9, 0x8e, - 0x3b, 0x72, 0x2a, 0xa8, 0x3d, 0x71, 0xdd, 0x27, 0x40, 0xb1, 0x1d, 0xc8, 0x44, 0xe0, 0x66, 0x3f, - 0xfd, 0xdf, 0xef, 0xf7, 0x6c, 0x3d, 0x12, 0xa4, 0x80, 0x4b, 0xc0, 0x58, 0x42, 0x19, 0x97, 0xa7, - 0x73, 0xa1, 0xf8, 0x69, 0x2c, 0xc5, 0x4a, 0x60, 0x86, 0x51, 0xb1, 0x06, 0x05, 0x94, 0x9a, 0x44, - 0x24, 0xa1, 0x8c, 0x6c, 0xe2, 0x64, 0xd2, 0xd6, 0x05, 0xa5, 0xe9, 0x38, 0x19, 0x49, 0x90, 0xa0, - 0x8f, 0x71, 0x75, 0x32, 0xd5, 0xf0, 0x67, 0x97, 0x0c, 0x5e, 0x1b, 0xf2, 0x3b, 0xc5, 0x95, 0xa0, - 0x6f, 0xc9, 0x08, 0x15, 0x5f, 0xab, 0x6c, 0x25, 0x67, 0xc5, 0x1a, 0x0a, 0x40, 0x9e, 0xcf, 0xb2, - 0x85, 0xeb, 0x04, 0xce, 0xb4, 0x9b, 0xf8, 0x37, 0x5b, 0x7f, 0xbc, 0xe1, 0xcb, 0xfc, 0x45, 0xd8, - 0x96, 0x0a, 0x19, 0xad, 0xcb, 0x17, 0xb6, 0x7a, 0xbe, 0xa0, 0xe7, 0xe4, 0x68, 0x21, 0x0a, 0xc0, - 0x4c, 0xa1, 0x7b, 0x27, 0x38, 0x98, 0xf6, 0xcf, 0xc6, 0xd1, 0xdf, 0xe3, 0x47, 0xaf, 0x4c, 0x26, - 0xb9, 0x7f, 0xb5, 0xf5, 0x3b, 0x5f, 0xbf, 0xfb, 0x47, 0xb6, 0x80, 0xec, 0x77, 0x3b, 0x7d, 0x49, - 0x7a, 0x25, 0x28, 0x81, 0xee, 0x81, 0xe6, 0xb8, 0x6d, 0x9c, 0x0f, 0xa0, 0x44, 0x32, 0xb4, 0x90, - 0x5e, 0x75, 0x43, 0x66, 0xba, 0xe8, 0x1b, 0x72, 0x5c, 0x4f, 0x8b, 0x6e, 0x57, 0x23, 0x26, 0x6d, - 0x88, 0x7a, 0xf8, 0xe4, 0x81, 0xc5, 0x1c, 0xd7, 0x15, 0x64, 0x7f, 0x08, 0x54, 0x92, 0x7b, 0x76, - 0xb2, 0x59, 0xc1, 0xd7, 0x7c, 0x89, 0x6e, 0x2f, 0x70, 0xa6, 0xfd, 0xb3, 0x27, 0xff, 0x79, 0xde, - 0x85, 0x0e, 0x26, 0x8f, 0x2b, 0xf0, 0xcd, 0xd6, 0x7f, 0x64, 0x3e, 0xf3, 0x36, 0x26, 0x64, 0xc3, - 0x45, 0x33, 0x4d, 0x53, 0x32, 0x2c, 0xc1, 0x7c, 0xb6, 0xf1, 0x1c, 0x6a, 0x4f, 0xf0, 0x8f, 0xe7, - 0x57, 0xdf, 0x6f, 0x34, 0x13, 0xab, 0x19, 0x19, 0xcd, 0x2d, 0x48, 0xc8, 0x06, 0x65, 0x23, 0x4b, - 0x67, 0x64, 0xa0, 0x78, 0x9e, 0x6f, 0x6a, 0xc7, 0x5d, 0xed, 0xf0, 0xdb, 0x1c, 0xef, 0xab, 0x9c, - 0x55, 0x8c, 0xad, 0xe2, 0xa1, 0x51, 0x34, 0x11, 0x21, 0xeb, 0xab, 0x46, 0x92, 0x5d, 0xed, 0x3c, - 0xe7, 0x7a, 0xe7, 0x39, 0x3f, 0x76, 0x9e, 0xf3, 0x65, 0xef, 0x75, 0xae, 0xf7, 0x5e, 0xe7, 0xdb, - 0xde, 0xeb, 0x7c, 0x7c, 0x2e, 0x33, 0x75, 0xf9, 0x69, 0x1e, 0xa5, 0xb0, 0x8c, 0x51, 0x64, 0x4f, - 0xf5, 0x6e, 0xa6, 0x90, 0xeb, 0x4b, 0x7a, 0xc9, 0xb3, 0x95, 0x39, 0x99, 0xfd, 0xfe, 0xac, 0x37, - 0x5c, 0x6d, 0x0a, 0x81, 0xf3, 0x43, 0x1d, 0x7d, 0xf6, 0x2b, 0x00, 0x00, 0xff, 0xff, 0x00, 0x82, - 0x10, 0xf3, 0x32, 0x03, 0x00, 0x00, + // 590 bytes of a gzipped FileDescriptorProto + 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0x94, 0x94, 0x41, 0x6f, 0xd3, 0x3c, + 0x18, 0xc7, 0x9b, 0x6d, 0xed, 0xdb, 0xba, 0xed, 0xf4, 0x62, 0xca, 0x08, 0x6b, 0x49, 0x42, 0x4e, + 0x15, 0x12, 0xa9, 0x36, 0x24, 0x40, 0x48, 0x70, 0x88, 0x2a, 0xa1, 0x21, 0x21, 0x0d, 0x0f, 0xed, + 0xc0, 0x25, 0x72, 0x13, 0x2b, 0x8d, 0x94, 0xd6, 0x51, 0x6c, 0x22, 0xfa, 0x05, 0x38, 0xf3, 0x39, + 0xb8, 0xf2, 0x25, 0x76, 0xdc, 0x0d, 0xc4, 0xa1, 0xa0, 0xf6, 0x1b, 0xf4, 0x13, 0xa0, 0xd8, 0xce, + 0xda, 0x6a, 0x19, 0x88, 0x9b, 0xfd, 0xe4, 0xff, 0xfc, 0xfe, 0xcf, 0x63, 0x3f, 0x31, 0xb0, 0x7c, + 0xca, 0x26, 0x94, 0x0d, 0x42, 0x9a, 0x0d, 0xb2, 0xa3, 0x11, 0xe1, 0xf8, 0x68, 0x10, 0x92, 0x29, + 0x61, 0x11, 0x73, 0x92, 0x94, 0x72, 0x0a, 0xa1, 0x54, 0x38, 0x21, 0xcd, 0x1c, 0xa5, 0x38, 0xec, + 0x95, 0x65, 0xd1, 0x4c, 0x66, 0x1c, 0x76, 0x42, 0x1a, 0x52, 0xb1, 0x1c, 0xe4, 0x2b, 0x19, 0xb5, + 0xbf, 0x55, 0x41, 0xeb, 0x95, 0x24, 0x9f, 0x71, 0xcc, 0x09, 0x7c, 0x0b, 0x3a, 0x8c, 0xe3, 0x94, + 0x47, 0xd3, 0xd0, 0x4b, 0x52, 0x9a, 0x50, 0x86, 0x63, 0x2f, 0x0a, 0x74, 0xcd, 0xd2, 0xfa, 0x7b, + 0xae, 0xb9, 0x9a, 0x9b, 0xdd, 0x19, 0x9e, 0xc4, 0xcf, 0xed, 0x32, 0x95, 0x8d, 0x60, 0x11, 0x3e, + 0x55, 0xd1, 0x93, 0x00, 0x9e, 0x80, 0x7a, 0x40, 0x12, 0xca, 0x22, 0xce, 0xf4, 0x1d, 0x6b, 0xb7, + 0xdf, 0x3c, 0xee, 0x3a, 0xd7, 0xcb, 0x77, 0x86, 0x52, 0xe3, 0xfe, 0x7f, 0x31, 0x37, 0x2b, 0x5f, + 0x7e, 0x9a, 0x75, 0x15, 0x60, 0xe8, 0x2a, 0x1d, 0xbe, 0x00, 0xd5, 0x8c, 0x72, 0xc2, 0xf4, 0x5d, + 0xc1, 0xd1, 0xcb, 0x38, 0xe7, 0x94, 0x13, 0xb7, 0xad, 0x20, 0xd5, 0x7c, 0xc7, 0x90, 0xcc, 0x82, + 0x6f, 0x40, 0xa3, 0xa8, 0x96, 0xe9, 0x7b, 0x02, 0xd1, 0x2b, 0x43, 0x14, 0xc5, 0xbb, 0xb7, 0x14, + 0xa6, 0x51, 0x44, 0x18, 0x5a, 0x13, 0x60, 0x08, 0xf6, 0x55, 0x65, 0x5e, 0x82, 0x53, 0x3c, 0x61, + 0x7a, 0xd5, 0xd2, 0xfa, 0xcd, 0xe3, 0x07, 0x7f, 0x68, 0xef, 0x54, 0x08, 0xdd, 0xfb, 0x39, 0x78, + 0x35, 0x37, 0xef, 0xc8, 0xc3, 0xdc, 0xc6, 0xd8, 0xa8, 0x1d, 0x6c, 0xaa, 0xa1, 0x0f, 0xda, 0x19, + 0x95, 0x87, 0x2d, 0x7d, 0x6a, 0xc2, 0xc7, 0xba, 0xa1, 0xfd, 0xfc, 0xf8, 0xa5, 0x4d, 0x4f, 0xd9, + 0x74, 0xa4, 0xcd, 0x16, 0xc4, 0x46, 0xad, 0x6c, 0x43, 0x0b, 0x3d, 0xd0, 0xe2, 0x38, 0x8e, 0x67, + 0x85, 0xc7, 0x7f, 0xc2, 0xc3, 0x2c, 0xf3, 0x78, 0x97, 0xeb, 0x94, 0x45, 0x57, 0x59, 0xdc, 0x96, + 0x16, 0x9b, 0x08, 0x1b, 0x35, 0xf9, 0x5a, 0x09, 0x63, 0x70, 0x2f, 0xbf, 0x06, 0x2f, 0x20, 0x31, + 0x09, 0x31, 0x8f, 0xe8, 0xd4, 0x63, 0x53, 0x9c, 0xb0, 0x31, 0xe5, 0x4c, 0xaf, 0x8b, 0xdb, 0x78, + 0x78, 0xd3, 0x85, 0x0e, 0xaf, 0x72, 0xce, 0x54, 0x8a, 0xbb, 0x97, 0x1b, 0xa3, 0xbb, 0x59, 0xe9, + 0x57, 0x66, 0x7f, 0xd5, 0xc0, 0x41, 0x79, 0x26, 0x7c, 0x0a, 0x9a, 0xd7, 0x47, 0xfb, 0x60, 0x35, + 0x37, 0xa1, 0xec, 0x61, 0x6b, 0xa2, 0x41, 0xb2, 0x9e, 0xe4, 0x8e, 0x1c, 0xbf, 0x54, 0xdf, 0xb1, + 0xb4, 0x7e, 0x43, 0x4e, 0x55, 0x0a, 0x5f, 0x83, 0xe6, 0xba, 0xa5, 0x62, 0x34, 0xed, 0xbf, 0x77, + 0xa2, 0x3a, 0xd8, 0x4c, 0xb6, 0x3f, 0x69, 0x60, 0x7f, 0x5b, 0x05, 0x7b, 0xa0, 0x91, 0xe1, 0x38, + 0x0a, 0x30, 0xa7, 0xa9, 0xa8, 0xb5, 0x81, 0xd6, 0x01, 0x78, 0x0e, 0x6a, 0x6c, 0x8c, 0x53, 0xc2, + 0x64, 0x4d, 0xee, 0xcb, 0x9c, 0xf9, 0x63, 0x6e, 0x3e, 0x09, 0x23, 0x3e, 0xfe, 0x30, 0x72, 0x7c, + 0x3a, 0x19, 0x30, 0x12, 0x3d, 0x12, 0xbf, 0xbb, 0x4f, 0x63, 0xb1, 0xf1, 0xc7, 0x38, 0x9a, 0xca, + 0x95, 0x7c, 0x32, 0xf8, 0x2c, 0x21, 0xcc, 0x19, 0x12, 0x1f, 0x29, 0x9a, 0x8b, 0x2e, 0x16, 0x86, + 0x76, 0xb9, 0x30, 0xb4, 0x5f, 0x0b, 0x43, 0xfb, 0xbc, 0x34, 0x2a, 0x97, 0x4b, 0xa3, 0xf2, 0x7d, + 0x69, 0x54, 0xde, 0x3f, 0xfb, 0x27, 0xf2, 0x47, 0xf1, 0x1c, 0x09, 0xfe, 0xa8, 0x26, 0xa4, 0x8f, + 0x7f, 0x07, 0x00, 0x00, 0xff, 0xff, 0x7b, 0x26, 0x3d, 0x35, 0xdf, 0x04, 0x00, 0x00, } func (m *GenesisState) Marshal() (dAtA []byte, err error) { @@ -181,6 +309,20 @@ func (m *GenesisState) MarshalToSizedBuffer(dAtA []byte) (int, error) { _ = i var l int _ = l + if len(m.VoteDelegationSnapshots) > 0 { + for iNdEx := len(m.VoteDelegationSnapshots) - 1; iNdEx >= 0; iNdEx-- { + { + size, err := m.VoteDelegationSnapshots[iNdEx].MarshalToSizedBuffer(dAtA[:i]) + if err != nil { + return 0, err + } + i -= size + i = encodeVarintGenesis(dAtA, i, uint64(size)) + } + i-- + dAtA[i] = 0x42 + } + } { size, err := m.TallyParams.MarshalToSizedBuffer(dAtA[:i]) if err != nil { @@ -261,6 +403,95 @@ func (m *GenesisState) MarshalToSizedBuffer(dAtA []byte) (int, error) { return len(dAtA) - i, nil } +func (m *VoteDelegationSnapshot) Marshal() (dAtA []byte, err error) { + size := m.Size() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBuffer(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *VoteDelegationSnapshot) MarshalTo(dAtA []byte) (int, error) { + size := m.Size() + return m.MarshalToSizedBuffer(dAtA[:size]) +} + +func (m *VoteDelegationSnapshot) MarshalToSizedBuffer(dAtA []byte) (int, error) { + i := len(dAtA) + _ = i + var l int + _ = l + if len(m.Delegations) > 0 { + for iNdEx := len(m.Delegations) - 1; iNdEx >= 0; iNdEx-- { + { + size, err := m.Delegations[iNdEx].MarshalToSizedBuffer(dAtA[:i]) + if err != nil { + return 0, err + } + i -= size + i = encodeVarintGenesis(dAtA, i, uint64(size)) + } + i-- + dAtA[i] = 0x1a + } + } + if len(m.Voter) > 0 { + i -= len(m.Voter) + copy(dAtA[i:], m.Voter) + i = encodeVarintGenesis(dAtA, i, uint64(len(m.Voter))) + i-- + dAtA[i] = 0x12 + } + if m.ProposalId != 0 { + i = encodeVarintGenesis(dAtA, i, uint64(m.ProposalId)) + i-- + dAtA[i] = 0x8 + } + return len(dAtA) - i, nil +} + +func (m *VoteDelegation) Marshal() (dAtA []byte, err error) { + size := m.Size() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBuffer(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *VoteDelegation) MarshalTo(dAtA []byte) (int, error) { + size := m.Size() + return m.MarshalToSizedBuffer(dAtA[:size]) +} + +func (m *VoteDelegation) MarshalToSizedBuffer(dAtA []byte) (int, error) { + i := len(dAtA) + _ = i + var l int + _ = l + { + size := m.Shares.Size() + i -= size + if _, err := m.Shares.MarshalTo(dAtA[i:]); err != nil { + return 0, err + } + i = encodeVarintGenesis(dAtA, i, uint64(size)) + } + i-- + dAtA[i] = 0x12 + if len(m.Validator) > 0 { + i -= len(m.Validator) + copy(dAtA[i:], m.Validator) + i = encodeVarintGenesis(dAtA, i, uint64(len(m.Validator))) + i-- + dAtA[i] = 0xa + } + return len(dAtA) - i, nil +} + func encodeVarintGenesis(dAtA []byte, offset int, v uint64) int { offset -= sovGenesis(v) base := offset @@ -305,6 +536,49 @@ func (m *GenesisState) Size() (n int) { n += 1 + l + sovGenesis(uint64(l)) l = m.TallyParams.Size() n += 1 + l + sovGenesis(uint64(l)) + if len(m.VoteDelegationSnapshots) > 0 { + for _, e := range m.VoteDelegationSnapshots { + l = e.Size() + n += 1 + l + sovGenesis(uint64(l)) + } + } + return n +} + +func (m *VoteDelegationSnapshot) Size() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + if m.ProposalId != 0 { + n += 1 + sovGenesis(uint64(m.ProposalId)) + } + l = len(m.Voter) + if l > 0 { + n += 1 + l + sovGenesis(uint64(l)) + } + if len(m.Delegations) > 0 { + for _, e := range m.Delegations { + l = e.Size() + n += 1 + l + sovGenesis(uint64(l)) + } + } + return n +} + +func (m *VoteDelegation) Size() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + l = len(m.Validator) + if l > 0 { + n += 1 + l + sovGenesis(uint64(l)) + } + l = m.Shares.Size() + n += 1 + l + sovGenesis(uint64(l)) return n } @@ -563,6 +837,291 @@ func (m *GenesisState) Unmarshal(dAtA []byte) error { return err } iNdEx = postIndex + case 8: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field VoteDelegationSnapshots", wireType) + } + var msglen int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowGenesis + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + msglen |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + if msglen < 0 { + return ErrInvalidLengthGenesis + } + postIndex := iNdEx + msglen + if postIndex < 0 { + return ErrInvalidLengthGenesis + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.VoteDelegationSnapshots = append(m.VoteDelegationSnapshots, VoteDelegationSnapshot{}) + if err := m.VoteDelegationSnapshots[len(m.VoteDelegationSnapshots)-1].Unmarshal(dAtA[iNdEx:postIndex]); err != nil { + return err + } + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skipGenesis(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLengthGenesis + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *VoteDelegationSnapshot) Unmarshal(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowGenesis + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: VoteDelegationSnapshot: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: VoteDelegationSnapshot: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 0 { + return fmt.Errorf("proto: wrong wireType = %d for field ProposalId", wireType) + } + m.ProposalId = 0 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowGenesis + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + m.ProposalId |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + case 2: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Voter", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowGenesis + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLengthGenesis + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLengthGenesis + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Voter = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + case 3: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Delegations", wireType) + } + var msglen int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowGenesis + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + msglen |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + if msglen < 0 { + return ErrInvalidLengthGenesis + } + postIndex := iNdEx + msglen + if postIndex < 0 { + return ErrInvalidLengthGenesis + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Delegations = append(m.Delegations, VoteDelegation{}) + if err := m.Delegations[len(m.Delegations)-1].Unmarshal(dAtA[iNdEx:postIndex]); err != nil { + return err + } + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skipGenesis(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLengthGenesis + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *VoteDelegation) Unmarshal(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowGenesis + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: VoteDelegation: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: VoteDelegation: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Validator", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowGenesis + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLengthGenesis + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLengthGenesis + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Validator = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + case 2: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Shares", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowGenesis + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLengthGenesis + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLengthGenesis + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + if err := m.Shares.Unmarshal(dAtA[iNdEx:postIndex]); err != nil { + return err + } + iNdEx = postIndex default: iNdEx = preIndex skippy, err := skipGenesis(dAtA[iNdEx:]) diff --git a/sei-cosmos/x/gov/types/genesis_test.go b/sei-cosmos/x/gov/types/genesis_test.go index a0fbebde22..cb8d4b8a7a 100644 --- a/sei-cosmos/x/gov/types/genesis_test.go +++ b/sei-cosmos/x/gov/types/genesis_test.go @@ -3,6 +3,7 @@ package types import ( "testing" + sdk "github.com/sei-protocol/sei-chain/sei-cosmos/types" "github.com/stretchr/testify/require" ) @@ -21,6 +22,22 @@ func TestEqualProposalID(t *testing.T) { require.True(t, state1.Equal(state2)) } +func TestGenesisStateEqualIncludesVoteDelegationSnapshots(t *testing.T) { + state1 := GenesisState{VoteDelegationSnapshots: []VoteDelegationSnapshot{{ + ProposalId: 1, + Voter: "voter", + Delegations: []VoteDelegation{{ + Validator: "validator", + Shares: sdk.OneDec(), + }}, + }}} + state2 := state1 + require.True(t, state1.Equal(state2)) + + state2.VoteDelegationSnapshots = nil + require.False(t, state1.Equal(state2)) +} + func TestValidateGenesis(t *testing.T) { require.Nil(t, ValidateGenesis(DefaultGenesisState())) require.Error(t, ValidateGenesis(&GenesisState{})) diff --git a/sei-cosmos/x/gov/types/keys.go b/sei-cosmos/x/gov/types/keys.go index b60d054b52..4d3822bc6f 100644 --- a/sei-cosmos/x/gov/types/keys.go +++ b/sei-cosmos/x/gov/types/keys.go @@ -43,6 +43,10 @@ const ( // - 0x31: Archived voter // // - 0x32: Tally archive cleanup cursor +// +// - 0x33: Voter delegation snapshot +// +// - 0x34: Archived voter delegation snapshot var ( ProposalsKeyPrefix = []byte{0x00} ActiveProposalQueuePrefix = []byte{0x01} @@ -53,9 +57,11 @@ var ( VotesKeyPrefix = []byte{0x20} - TallyProgressKeyPrefix = []byte{0x30} - TallyVotesKeyPrefix = []byte{0x31} - TallyCleanupKeyPrefix = []byte{0x32} + TallyProgressKeyPrefix = []byte{0x30} + TallyVotesKeyPrefix = []byte{0x31} + TallyCleanupKeyPrefix = []byte{0x32} + VoteDelegationsKeyPrefix = []byte{0x33} + TallyVoteDelegationsKeyPrefix = []byte{0x34} ) var lenTime = len(sdk.FormatTimeBytes(time.Now())) @@ -117,6 +123,11 @@ func VoteKey(proposalID uint64, voterAddr sdk.AccAddress) []byte { return append(VotesKey(proposalID), address.MustLengthPrefix(voterAddr.Bytes())...) } +// VoteDelegationsKey returns the key for the delegation snapshot captured with a vote. +func VoteDelegationsKey(proposalID uint64, voterAddr sdk.AccAddress) []byte { + return append(append(VoteDelegationsKeyPrefix, GetProposalIDBytes(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)...) @@ -132,6 +143,11 @@ func TallyVoteKey(proposalID uint64, expedited bool, voterAddr sdk.AccAddress) [ return append(TallyVotesKey(proposalID, expedited), address.MustLengthPrefix(voterAddr.Bytes())...) } +// TallyVoteDelegationsKey returns the key for an archived vote's delegation snapshot. +func TallyVoteDelegationsKey(proposalID uint64, expedited bool, voterAddr sdk.AccAddress) []byte { + return append(append(append(TallyVoteDelegationsKeyPrefix, GetProposalIDBytes(proposalID)...), tallyRound(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)) diff --git a/sei-cosmos/x/gov/types/keys_test.go b/sei-cosmos/x/gov/types/keys_test.go index b8e265d26e..459cba0402 100644 --- a/sei-cosmos/x/gov/types/keys_test.go +++ b/sei-cosmos/x/gov/types/keys_test.go @@ -8,6 +8,7 @@ import ( "github.com/sei-protocol/sei-chain/sei-cosmos/crypto/keys/ed25519" sdk "github.com/sei-protocol/sei-chain/sei-cosmos/types" + "github.com/sei-protocol/sei-chain/sei-cosmos/types/address" ) var addr = sdk.AccAddress(ed25519.GenPrivKey().PubKey().Address()) @@ -65,4 +66,6 @@ func TestTallyKeys(t *testing.T) { 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)) + require.Equal(t, append(append(VoteDelegationsKeyPrefix, GetProposalIDBytes(2)...), address.MustLengthPrefix(addr.Bytes())...), VoteDelegationsKey(2, addr)) + require.Equal(t, append(append(append(TallyVoteDelegationsKeyPrefix, GetProposalIDBytes(2)...), byte(1)), address.MustLengthPrefix(addr.Bytes())...), TallyVoteDelegationsKey(2, false, addr)) } From de2cc77f9761a63ab64c6a6290c1e95d3a428097 Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Thu, 27 Aug 2026 13:09:34 +0800 Subject: [PATCH 05/17] chore(gov): format generated genesis types --- sei-cosmos/x/gov/types/genesis.pb.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/sei-cosmos/x/gov/types/genesis.pb.go b/sei-cosmos/x/gov/types/genesis.pb.go index 501d4dfc3f..098c6b0d9b 100644 --- a/sei-cosmos/x/gov/types/genesis.pb.go +++ b/sei-cosmos/x/gov/types/genesis.pb.go @@ -5,12 +5,13 @@ package types import ( fmt "fmt" - _ "github.com/gogo/protobuf/gogoproto" - proto "github.com/gogo/protobuf/proto" - github_com_sei_protocol_sei_chain_sei_cosmos_types "github.com/sei-protocol/sei-chain/sei-cosmos/types" io "io" math "math" math_bits "math/bits" + + _ "github.com/gogo/protobuf/gogoproto" + proto "github.com/gogo/protobuf/proto" + github_com_sei_protocol_sei_chain_sei_cosmos_types "github.com/sei-protocol/sei-chain/sei-cosmos/types" ) // Reference imports to suppress errors if they are not otherwise used. From 9c297150218e6217c8b7ed2361ea2b240beb69cb Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Thu, 27 Aug 2026 19:11:24 +0800 Subject: [PATCH 06/17] fix(gov): keep vote delegation snapshots current --- app/app.go | 6 +-- .../proto/cosmos/gov/v1beta1/genesis.proto | 4 +- sei-cosmos/x/gov/keeper/common_test.go | 2 + sei-cosmos/x/gov/keeper/staking_hooks.go | 52 +++++++++++++++++++ sei-cosmos/x/gov/keeper/tally.go | 1 + sei-cosmos/x/gov/keeper/tally_test.go | 47 ++++++++++++++++- sei-cosmos/x/gov/keeper/vote.go | 37 +++++++++++++ sei-cosmos/x/gov/simulation/decoder.go | 3 +- sei-cosmos/x/gov/spec/02_state.md | 25 ++++----- sei-cosmos/x/gov/types/genesis.pb.go | 4 +- sei-cosmos/x/gov/types/keys.go | 15 +++++- sei-cosmos/x/gov/types/keys_test.go | 1 + sei-cosmos/x/staking/types/hooks.go | 5 ++ sei-wasmd/app/app.go | 6 +-- 14 files changed, 182 insertions(+), 26 deletions(-) create mode 100644 sei-cosmos/x/gov/keeper/staking_hooks.go diff --git a/app/app.go b/app/app.go index ec1c7e8324..d23bbaaafb 100644 --- a/app/app.go +++ b/app/app.go @@ -583,9 +583,8 @@ func New( // register the staking hooks // NOTE: stakingKeeper above is passed by reference, so that it will contain these hooks - app.StakingKeeper = *stakingKeeper.SetHooks( - stakingtypes.NewMultiStakingHooks(app.DistrKeeper.Hooks(), app.SlashingKeeper.Hooks()), - ) + stakingHooks := stakingtypes.NewMultiStakingHooks(app.DistrKeeper.Hooks(), app.SlashingKeeper.Hooks()) + app.StakingKeeper = *stakingKeeper.SetHooks(&stakingHooks) // ... other modules keepers @@ -806,6 +805,7 @@ func New( appCodec, keys[govtypes.StoreKey], app.GetSubspace(govtypes.ModuleName), app.AccountKeeper, app.BankKeeper, &stakingKeeper, app.ParamsKeeper, govRouter, ) + stakingHooks.AddHooks(app.GovKeeper.StakingHooks()) // this line is used by starport scaffolding # stargate/app/keeperDefinition diff --git a/sei-cosmos/proto/cosmos/gov/v1beta1/genesis.proto b/sei-cosmos/proto/cosmos/gov/v1beta1/genesis.proto index 5403109227..67f46639e7 100644 --- a/sei-cosmos/proto/cosmos/gov/v1beta1/genesis.proto +++ b/sei-cosmos/proto/cosmos/gov/v1beta1/genesis.proto @@ -41,11 +41,11 @@ message GenesisState { (gogoproto.nullable) = false, (gogoproto.moretags) = "yaml:\"tally_params\"" ]; - // vote_delegation_snapshots defines the delegation shares captured with each vote. + // vote_delegation_snapshots defines the delegation shares maintained for each vote. repeated VoteDelegationSnapshot vote_delegation_snapshots = 8 [(gogoproto.nullable) = false]; } -// VoteDelegationSnapshot defines the per-validator delegation shares captured with a vote. +// VoteDelegationSnapshot defines the per-validator delegation shares maintained for a vote. message VoteDelegationSnapshot { uint64 proposal_id = 1 [(gogoproto.moretags) = "yaml:\"proposal_id\""]; string voter = 2; diff --git a/sei-cosmos/x/gov/keeper/common_test.go b/sei-cosmos/x/gov/keeper/common_test.go index 3424b5b753..8ecce56efe 100644 --- a/sei-cosmos/x/gov/keeper/common_test.go +++ b/sei-cosmos/x/gov/keeper/common_test.go @@ -31,6 +31,8 @@ func createValidators(t *testing.T, ctx sdk.Context, app *seiapp.App, powers []i app.BankKeeper, app.GetSubspace(stakingtypes.ModuleName), ) + stakingHooks := stakingtypes.NewMultiStakingHooks(app.GovKeeper.StakingHooks()) + app.StakingKeeper.SetHooks(&stakingHooks) val1, err := stakingtypes.NewValidator(valAddrs[0], pks[0], stakingtypes.Description{}) require.NoError(t, err) diff --git a/sei-cosmos/x/gov/keeper/staking_hooks.go b/sei-cosmos/x/gov/keeper/staking_hooks.go new file mode 100644 index 0000000000..59895604d1 --- /dev/null +++ b/sei-cosmos/x/gov/keeper/staking_hooks.go @@ -0,0 +1,52 @@ +package keeper + +import ( + sdk "github.com/sei-protocol/sei-chain/sei-cosmos/types" + stakingtypes "github.com/sei-protocol/sei-chain/sei-cosmos/x/staking/types" +) + +var _ stakingtypes.StakingHooks = StakingHooks{} + +// StakingHooks maintains governance vote delegation snapshots as staking state changes. +type StakingHooks struct { + keeper Keeper +} + +// StakingHooks returns the governance staking hooks. +func (keeper Keeper) StakingHooks() StakingHooks { + return StakingHooks{keeper: keeper} +} + +func (StakingHooks) AfterValidatorCreated(sdk.Context, sdk.ValAddress) {} + +func (StakingHooks) BeforeValidatorModified(sdk.Context, sdk.ValAddress) {} + +func (StakingHooks) AfterValidatorRemoved(sdk.Context, sdk.ConsAddress, sdk.ValAddress) {} + +func (StakingHooks) AfterValidatorBonded(sdk.Context, sdk.ConsAddress, sdk.ValAddress) {} + +func (StakingHooks) AfterValidatorBeginUnbonding(sdk.Context, sdk.ConsAddress, sdk.ValAddress) {} + +func (StakingHooks) BeforeDelegationCreated(sdk.Context, sdk.AccAddress, sdk.ValAddress) {} + +func (StakingHooks) BeforeDelegationSharesModified(sdk.Context, sdk.AccAddress, sdk.ValAddress) {} + +// BeforeDelegationRemoved removes the outgoing delegation from active vote snapshots. +func (hooks StakingHooks) BeforeDelegationRemoved( + ctx sdk.Context, + delegator sdk.AccAddress, + validator sdk.ValAddress, +) { + hooks.keeper.refreshVoteDelegationSnapshots(ctx, delegator, validator) +} + +// AfterDelegationModified refreshes active vote snapshots from the updated delegation state. +func (hooks StakingHooks) AfterDelegationModified( + ctx sdk.Context, + delegator sdk.AccAddress, + _ sdk.ValAddress, +) { + hooks.keeper.refreshVoteDelegationSnapshots(ctx, delegator, nil) +} + +func (StakingHooks) BeforeValidatorSlashed(sdk.Context, sdk.ValAddress, sdk.Dec) {} diff --git a/sei-cosmos/x/gov/keeper/tally.go b/sei-cosmos/x/gov/keeper/tally.go index 92fd716294..023cae8077 100644 --- a/sei-cosmos/x/gov/keeper/tally.go +++ b/sei-cosmos/x/gov/keeper/tally.go @@ -192,6 +192,7 @@ func (keeper Keeper) processTallyVotes( store.Set(types.TallyVoteDelegationsKey(proposalID, progress.Expedited, voter), snapshotValue) store.Delete(key) store.Delete(snapshotKey) + store.Delete(types.VoterProposalsKey(voter, proposalID)) progress.Cursor = key processed++ } diff --git a/sei-cosmos/x/gov/keeper/tally_test.go b/sei-cosmos/x/gov/keeper/tally_test.go index 6154f473e3..6281d45c20 100644 --- a/sei-cosmos/x/gov/keeper/tally_test.go +++ b/sei-cosmos/x/gov/keeper/tally_test.go @@ -528,6 +528,8 @@ func TestTallyIncrementalPersistsProgressAndCleansArchivedVotes(t *testing.T) { store := ctx.KVStore(app.GetKey(types.StoreKey)) require.True(t, store.Has(types.TallyVoteDelegationsKey(proposal.ProposalId, false, addrs[0]))) require.False(t, store.Has(types.VoteDelegationsKey(proposal.ProposalId, addrs[0]))) + require.False(t, store.Has(types.VoterProposalsKey(addrs[0], proposal.ProposalId))) + require.True(t, store.Has(types.VoterProposalsKey(addrs[1], proposal.ProposalId))) _, _, queryResult := app.GovKeeper.Tally(ctx, proposal) require.False(t, queryResult.Equals(types.EmptyTallyResult())) @@ -561,7 +563,7 @@ func TestTallyIncrementalPersistsProgressAndCleansArchivedVotes(t *testing.T) { } } -func TestTallyIncrementalIgnoresDelegationsAddedAfterVote(t *testing.T) { +func TestTallyIncrementalIgnoresDelegationsAddedAfterTallyStarts(t *testing.T) { app := seiapp.Setup(t, false, false, false) ctx := app.BaseApp.NewContext(false, tmproto.Header{}) addrs, valAddrs := createValidators(t, ctx, app, []int64{5, 5, 5}) @@ -603,7 +605,7 @@ func TestTallyIncrementalIgnoresDelegationsAddedAfterVote(t *testing.T) { require.False(t, totalVotingPower.GT(snapshotTotalBonded)) } -func TestTallyIncrementalKeepsDelegationsRemovedAfterVote(t *testing.T) { +func TestTallyIncrementalKeepsDelegationsRemovedAfterTallyStarts(t *testing.T) { app := seiapp.Setup(t, false, false, false) ctx := app.BaseApp.NewContext(false, tmproto.Header{}) addrs, valAddrs := createValidators(t, ctx, app, []int64{5, 5, 5}) @@ -638,6 +640,47 @@ func TestTallyIncrementalKeepsDelegationsRemovedAfterVote(t *testing.T) { require.True(t, tallyResult.No.Equal(delegatedTokens)) } +func TestTallyIncrementalUsesRedelegationsBeforeTallyStarts(t *testing.T) { + app := seiapp.Setup(t, false, false, false) + ctx := app.BaseApp.NewContext(false, tmproto.Header{}) + addrs, valAddrs := createValidators(t, ctx, app, []int64{5, 5, 5}) + + sourceValidator, found := app.StakingKeeper.GetValidator(ctx, valAddrs[0]) + require.True(t, found) + delegatedTokens := app.StakingKeeper.TokensFromConsensusPower(ctx, 20) + _, err := app.StakingKeeper.Delegate(ctx, addrs[3], delegatedTokens, stakingtypes.Unbonded, sourceValidator, true) + require.NoError(t, err) + + proposal, err := app.GovKeeper.SubmitProposal(ctx, TestProposal) + 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), + )) + require.NoError(t, app.GovKeeper.AddVote( + ctx, + proposal.ProposalId, + addrs[3], + types.NewNonSplitVoteOption(types.OptionNo), + )) + + delegation, found := app.StakingKeeper.GetDelegation(ctx, addrs[3], valAddrs[0]) + require.True(t, found) + _, err = app.StakingKeeper.BeginRedelegation(ctx, addrs[3], valAddrs[0], valAddrs[1], delegation.GetShares()) + require.NoError(t, err) + app.GovKeeper.InitializeTally(ctx, proposal) + + complete, processed, _, _, tallyResult := app.GovKeeper.TallyIncremental(ctx, proposal, 2) + require.True(t, complete) + require.Equal(t, 2, processed) + require.True(t, tallyResult.Yes.Equal(app.StakingKeeper.TokensFromConsensusPower(ctx, 5))) + require.True(t, tallyResult.No.Equal(delegatedTokens)) +} + func TestTallyArchivesExpeditedAndRegularRoundsSeparately(t *testing.T) { app := seiapp.Setup(t, false, false, false) ctx := app.BaseApp.NewContext(false, tmproto.Header{}) diff --git a/sei-cosmos/x/gov/keeper/vote.go b/sei-cosmos/x/gov/keeper/vote.go index 4ea363c2fc..a5cd6a9749 100644 --- a/sei-cosmos/x/gov/keeper/vote.go +++ b/sei-cosmos/x/gov/keeper/vote.go @@ -111,6 +111,7 @@ func (keeper Keeper) SetVote(ctx sdk.Context, vote types.Vote) { addr := sdk.MustAccAddressFromBech32(vote.Voter) store.Set(types.VoteKey(vote.ProposalId, addr), bz) + store.Set(types.VoterProposalsKey(addr, vote.ProposalId), []byte{1}) keeper.setVoteDelegations(ctx, vote.ProposalId, addr) } @@ -123,6 +124,15 @@ func (keeper Keeper) snapshotVoteDelegations( ctx sdk.Context, proposalID uint64, voter sdk.AccAddress, +) types.VoteDelegationSnapshot { + return keeper.snapshotVoteDelegationsExcept(ctx, proposalID, voter, nil) +} + +func (keeper Keeper) snapshotVoteDelegationsExcept( + ctx sdk.Context, + proposalID uint64, + voter sdk.AccAddress, + excludedValidator sdk.ValAddress, ) types.VoteDelegationSnapshot { snapshot := types.VoteDelegationSnapshot{ ProposalId: proposalID, @@ -130,6 +140,9 @@ func (keeper Keeper) snapshotVoteDelegations( Delegations: []types.VoteDelegation{}, } keeper.sk.IterateDelegations(ctx, voter, func(_ int64, delegation stakingtypes.DelegationI) bool { + if excludedValidator != nil && delegation.GetValidatorAddr().Equals(excludedValidator) { + return false + } snapshot.Delegations = append(snapshot.Delegations, types.VoteDelegation{ Validator: delegation.GetValidatorAddr().String(), Shares: delegation.GetShares(), @@ -139,6 +152,30 @@ func (keeper Keeper) snapshotVoteDelegations( return snapshot } +func (keeper Keeper) refreshVoteDelegationSnapshots( + ctx sdk.Context, + voter sdk.AccAddress, + excludedValidator sdk.ValAddress, +) { + store := ctx.KVStore(keeper.storeKey) + prefix := types.VoterProposalsKeyPrefixForAddress(voter) + iterator := sdk.KVStorePrefixIterator(store, prefix) + defer func() { _ = iterator.Close() }() + if !iterator.Valid() { + return + } + + snapshot := keeper.snapshotVoteDelegationsExcept(ctx, 0, voter, excludedValidator) + for ; iterator.Valid(); iterator.Next() { + proposalID := types.GetProposalIDFromBytes(iterator.Key()[len(prefix):]) + if keeper.IsTallying(ctx, proposalID) { + continue + } + snapshot.ProposalId = proposalID + keeper.setVoteDelegationSnapshot(ctx, snapshot) + } +} + func (keeper Keeper) setVoteDelegationSnapshot(ctx sdk.Context, snapshot types.VoteDelegationSnapshot) { voter := sdk.MustAccAddressFromBech32(snapshot.Voter) bz := keeper.cdc.MustMarshal(&snapshot) diff --git a/sei-cosmos/x/gov/simulation/decoder.go b/sei-cosmos/x/gov/simulation/decoder.go index 94f1d37023..004a4a7e3d 100644 --- a/sei-cosmos/x/gov/simulation/decoder.go +++ b/sei-cosmos/x/gov/simulation/decoder.go @@ -51,7 +51,8 @@ func NewDecodeStore(cdc codec.Codec) func(kvA, kvB kv.Pair) string { case bytes.Equal(kvA.Key[:1], types.TallyProgressKeyPrefix), bytes.Equal(kvA.Key[:1], types.TallyCleanupKeyPrefix), bytes.Equal(kvA.Key[:1], types.VoteDelegationsKeyPrefix), - bytes.Equal(kvA.Key[:1], types.TallyVoteDelegationsKeyPrefix): + bytes.Equal(kvA.Key[:1], types.TallyVoteDelegationsKeyPrefix), + bytes.Equal(kvA.Key[:1], types.VoterProposalsKeyPrefix): return fmt.Sprintf("%X\n%X", kvA.Value, kvB.Value) default: diff --git a/sei-cosmos/x/gov/spec/02_state.md b/sei-cosmos/x/gov/spec/02_state.md index 295f43162c..b7a1dc8cb3 100644 --- a/sei-cosmos/x/gov/spec/02_state.md +++ b/sei-cosmos/x/gov/spec/02_state.md @@ -126,7 +126,7 @@ We will use one KVStore `Governance` to store three mappings: us to query all addresses that voted on the proposal along with their vote by doing a range query on `proposalID:addresses`. - A mapping from `proposalID|'delegations'|address` to the voter's per-validator - delegation shares when the vote was recorded. + delegation shares, maintained until tallying starts. For pseudocode purposes, here are the two function we will use to read or write in stores: @@ -214,20 +214,21 @@ 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. Each vote retains the voter's per-validator -delegation shares from the time the vote was recorded, and that snapshot moves with -the vote into the tally archive. Delegator results are accumulated per validator from -those stored shares, so delegation changes after voting do not alter the result. If -the stored delegation shares exceed that validator's tally snapshot, every delegator -option is scaled by the same factor to fit the snapshotted voting-power budget. This -makes the result independent of vote-record order. Completed tally archives and their -delegation snapshots are removed incrementally under the same per-block vote-record -budget, with part of that budget reserved so cleanup cannot be starved by unfinished -tallies. +after the accumulator is created. A vote's per-validator delegation snapshot is +created with the vote and refreshed by staking hooks whenever that voter delegates, +undelegates, or redelegates. The snapshot is frozen when tallying starts, at the same +point as the validator snapshot, and moves with the vote into the tally archive. +Delegator results are accumulated per validator from those stored shares, so changes +after tallying starts do not alter the result. If the stored delegation shares exceed +that validator's tally snapshot, every delegator option is scaled by the same factor +to fit the snapshotted voting-power budget. This makes the result independent of +vote-record order. Completed tally archives and their delegation snapshots are +removed incrementally under the same per-block vote-record budget, with part of that +budget reserved so cleanup cannot be starved by unfinished tallies. Application-state export serializes all archived and pending votes together with their delegation snapshots, but not the in-progress accumulator. On import, an expired voting -proposal starts a new tally from those votes, their original delegation snapshots, and +proposal starts a new tally from those votes, their frozen delegation snapshots, and the imported validator and governance-parameter state. The accumulator is created during genesis initialization, so the proposal does not reopen for votes before its first `EndBlock`. diff --git a/sei-cosmos/x/gov/types/genesis.pb.go b/sei-cosmos/x/gov/types/genesis.pb.go index 098c6b0d9b..f7bf78f9d5 100644 --- a/sei-cosmos/x/gov/types/genesis.pb.go +++ b/sei-cosmos/x/gov/types/genesis.pb.go @@ -41,7 +41,7 @@ type GenesisState struct { VotingParams VotingParams `protobuf:"bytes,6,opt,name=voting_params,json=votingParams,proto3" json:"voting_params" yaml:"voting_params"` // params defines all the paramaters of related to tally. TallyParams TallyParams `protobuf:"bytes,7,opt,name=tally_params,json=tallyParams,proto3" json:"tally_params" yaml:"tally_params"` - // vote_delegation_snapshots defines the delegation shares captured with each vote. + // vote_delegation_snapshots defines the delegation shares maintained for each vote. VoteDelegationSnapshots []VoteDelegationSnapshot `protobuf:"bytes,8,rep,name=vote_delegation_snapshots,json=voteDelegationSnapshots,proto3" json:"vote_delegation_snapshots"` } @@ -134,7 +134,7 @@ func (m *GenesisState) GetVoteDelegationSnapshots() []VoteDelegationSnapshot { return nil } -// VoteDelegationSnapshot defines the per-validator delegation shares captured with a vote. +// VoteDelegationSnapshot defines the per-validator delegation shares maintained for a vote. type VoteDelegationSnapshot struct { ProposalId uint64 `protobuf:"varint,1,opt,name=proposal_id,json=proposalId,proto3" json:"proposal_id,omitempty" yaml:"proposal_id"` Voter string `protobuf:"bytes,2,opt,name=voter,proto3" json:"voter,omitempty"` diff --git a/sei-cosmos/x/gov/types/keys.go b/sei-cosmos/x/gov/types/keys.go index 4d3822bc6f..a879cf7c1c 100644 --- a/sei-cosmos/x/gov/types/keys.go +++ b/sei-cosmos/x/gov/types/keys.go @@ -47,6 +47,8 @@ const ( // - 0x33: Voter delegation snapshot // // - 0x34: Archived voter delegation snapshot +// +// - 0x35: Active proposal voted on by address var ( ProposalsKeyPrefix = []byte{0x00} ActiveProposalQueuePrefix = []byte{0x01} @@ -62,6 +64,7 @@ var ( TallyCleanupKeyPrefix = []byte{0x32} VoteDelegationsKeyPrefix = []byte{0x33} TallyVoteDelegationsKeyPrefix = []byte{0x34} + VoterProposalsKeyPrefix = []byte{0x35} ) var lenTime = len(sdk.FormatTimeBytes(time.Now())) @@ -123,11 +126,21 @@ func VoteKey(proposalID uint64, voterAddr sdk.AccAddress) []byte { return append(VotesKey(proposalID), address.MustLengthPrefix(voterAddr.Bytes())...) } -// VoteDelegationsKey returns the key for the delegation snapshot captured with a vote. +// VoteDelegationsKey returns the key for a vote's current delegation snapshot. func VoteDelegationsKey(proposalID uint64, voterAddr sdk.AccAddress) []byte { return append(append(VoteDelegationsKeyPrefix, GetProposalIDBytes(proposalID)...), address.MustLengthPrefix(voterAddr.Bytes())...) } +// VoterProposalsKey returns the key indexing an address's vote on an active proposal. +func VoterProposalsKey(voterAddr sdk.AccAddress, proposalID uint64) []byte { + return append(VoterProposalsKeyPrefixForAddress(voterAddr), GetProposalIDBytes(proposalID)...) +} + +// VoterProposalsKeyPrefixForAddress returns the active-proposal vote prefix for an address. +func VoterProposalsKeyPrefixForAddress(voterAddr sdk.AccAddress) []byte { + return append(VoterProposalsKeyPrefix, 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)...) diff --git a/sei-cosmos/x/gov/types/keys_test.go b/sei-cosmos/x/gov/types/keys_test.go index 459cba0402..000b9eb3da 100644 --- a/sei-cosmos/x/gov/types/keys_test.go +++ b/sei-cosmos/x/gov/types/keys_test.go @@ -68,4 +68,5 @@ func TestTallyKeys(t *testing.T) { require.NotEqual(t, TallyCleanupKey(2, true), TallyCleanupKey(2, false)) require.Equal(t, append(append(VoteDelegationsKeyPrefix, GetProposalIDBytes(2)...), address.MustLengthPrefix(addr.Bytes())...), VoteDelegationsKey(2, addr)) require.Equal(t, append(append(append(TallyVoteDelegationsKeyPrefix, GetProposalIDBytes(2)...), byte(1)), address.MustLengthPrefix(addr.Bytes())...), TallyVoteDelegationsKey(2, false, addr)) + require.Equal(t, append(append(VoterProposalsKeyPrefix, address.MustLengthPrefix(addr.Bytes())...), GetProposalIDBytes(2)...), VoterProposalsKey(addr, 2)) } diff --git a/sei-cosmos/x/staking/types/hooks.go b/sei-cosmos/x/staking/types/hooks.go index 4c12ffd3d8..f6fdacd282 100644 --- a/sei-cosmos/x/staking/types/hooks.go +++ b/sei-cosmos/x/staking/types/hooks.go @@ -11,6 +11,11 @@ func NewMultiStakingHooks(hooks ...StakingHooks) MultiStakingHooks { return hooks } +// AddHooks appends staking hooks to this ordered hook set. +func (h *MultiStakingHooks) AddHooks(hooks ...StakingHooks) { + *h = append(*h, hooks...) +} + func (h MultiStakingHooks) AfterValidatorCreated(ctx sdk.Context, valAddr sdk.ValAddress) { for i := range h { h[i].AfterValidatorCreated(ctx, valAddr) diff --git a/sei-wasmd/app/app.go b/sei-wasmd/app/app.go index 5ab8ee7b48..4772c143c8 100644 --- a/sei-wasmd/app/app.go +++ b/sei-wasmd/app/app.go @@ -351,9 +351,8 @@ func NewWasmApp( // register the staking hooks // NOTE: stakingKeeper above is passed by reference, so that it will contain these hooks - app.stakingKeeper = *stakingKeeper.SetHooks( - stakingtypes.NewMultiStakingHooks(app.distrKeeper.Hooks(), app.slashingKeeper.Hooks()), - ) + stakingHooks := stakingtypes.NewMultiStakingHooks(app.distrKeeper.Hooks(), app.slashingKeeper.Hooks()) + app.stakingKeeper = *stakingKeeper.SetHooks(&stakingHooks) // register the proposal types govRouter := govtypes.NewRouter() @@ -413,6 +412,7 @@ func NewWasmApp( app.paramsKeeper, govRouter, ) + stakingHooks.AddHooks(app.govKeeper.StakingHooks()) // NOTE: Any module instantiated in the module manager that is later modified // must be passed by reference here. app.mm = module.NewManager( From 5bffcf247bcbd998113891f7cc80a072c41373e4 Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Thu, 27 Aug 2026 19:14:33 +0800 Subject: [PATCH 07/17] refactor(gov): centralize tally archive keys --- sei-cosmos/x/gov/keeper/tally.go | 19 ++-------------- sei-cosmos/x/gov/spec/02_state.md | 6 +++++ sei-cosmos/x/gov/types/keys.go | 34 +++++++++++++++++++++++++++++ sei-cosmos/x/gov/types/keys_test.go | 11 ++++++++++ 4 files changed, 53 insertions(+), 17 deletions(-) diff --git a/sei-cosmos/x/gov/keeper/tally.go b/sei-cosmos/x/gov/keeper/tally.go index 023cae8077..45cd600fc5 100644 --- a/sei-cosmos/x/gov/keeper/tally.go +++ b/sei-cosmos/x/gov/keeper/tally.go @@ -106,7 +106,7 @@ func (keeper Keeper) CleanupTallyVotes(ctx sdk.Context, maxVotes int) (deleted i defer func() { _ = iterator.Close() }() for ; iterator.Valid() && deleted < maxVotes; iterator.Next() { - proposalID, expedited := splitTallyCleanupKey(iterator.Key()) + proposalID, expedited := types.SplitTallyCleanupKey(iterator.Key()) cursor := decodeCleanupCursor(iterator.Value()) count, complete, nextCursor := keeper.cleanupProposalTallyVotes( ctx, @@ -400,7 +400,7 @@ func (keeper Keeper) cleanupProposalTallyVotes( for ; iterator.Valid() && deleted < maxVotes; iterator.Next() { cursor = append(cursor[:0], iterator.Key()...) store.Delete(iterator.Key()) - snapshotKey := append([]byte{types.TallyVoteDelegationsKeyPrefix[0]}, iterator.Key()[1:]...) + snapshotKey := types.TallyVoteDelegationsKeyFromVoteKey(iterator.Key()) store.Delete(snapshotKey) deleted++ } @@ -418,18 +418,3 @@ func decodeCleanupCursor(value []byte) []byte { } 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/spec/02_state.md b/sei-cosmos/x/gov/spec/02_state.md index b7a1dc8cb3..81bcd50673 100644 --- a/sei-cosmos/x/gov/spec/02_state.md +++ b/sei-cosmos/x/gov/spec/02_state.md @@ -147,6 +147,12 @@ 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 content `Handler` is executed. +Expired proposals remain queue-ordered. If an earlier proposal does not finish +within the block's vote-processing budget, the queue scan stops and later proposals +wait for the earlier tally to complete. This also prevents the block from initializing +validator snapshots for an unbounded number of proposals after the vote budget is +exhausted. + And the pseudocode for the `ProposalProcessingQueue`: ```go diff --git a/sei-cosmos/x/gov/types/keys.go b/sei-cosmos/x/gov/types/keys.go index a879cf7c1c..c4995d84c5 100644 --- a/sei-cosmos/x/gov/types/keys.go +++ b/sei-cosmos/x/gov/types/keys.go @@ -2,6 +2,7 @@ package types import ( "encoding/binary" + "fmt" "time" sdk "github.com/sei-protocol/sei-chain/sei-cosmos/types" @@ -161,6 +162,19 @@ func TallyVoteDelegationsKey(proposalID uint64, expedited bool, voterAddr sdk.Ac return append(append(append(TallyVoteDelegationsKeyPrefix, GetProposalIDBytes(proposalID)...), tallyRound(expedited)), address.MustLengthPrefix(voterAddr.Bytes())...) } +// TallyVoteDelegationsKeyFromVoteKey returns the delegation-snapshot key paired with an archived vote key. +func TallyVoteDelegationsKeyFromVoteKey(voteKey []byte) []byte { + kv.AssertKeyAtLeastLength(voteKey, 11) + if voteKey[0] != TallyVotesKeyPrefix[0] { + panic(fmt.Sprintf("invalid tally vote key prefix %d", voteKey[0])) + } + decodeTallyRound(voteKey[9]) + + key := append([]byte(nil), voteKey...) + key[0] = TallyVoteDelegationsKeyPrefix[0] + return key +} + // 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)) @@ -173,6 +187,17 @@ func tallyRound(expedited bool) byte { return 1 } +func decodeTallyRound(round byte) bool { + switch round { + case tallyRound(true): + return true + case tallyRound(false): + return false + default: + panic(fmt.Sprintf("invalid tally round %d", round)) + } +} + // Split keys function; used for iterators // SplitProposalKey split the proposal key and returns the proposal id @@ -192,6 +217,15 @@ func SplitInactiveProposalQueueKey(key []byte) (proposalID uint64, endTime time. return splitKeyWithTime(key) } +// SplitTallyCleanupKey returns the proposal and tally round encoded in a cleanup key. +func SplitTallyCleanupKey(key []byte) (proposalID uint64, expedited bool) { + kv.AssertKeyLength(key, 10) + if key[0] != TallyCleanupKeyPrefix[0] { + panic(fmt.Sprintf("invalid tally cleanup key prefix %d", key[0])) + } + return GetProposalIDFromBytes(key[1:9]), decodeTallyRound(key[9]) +} + // SplitKeyDeposit split the deposits key and returns the proposal id and depositor address func SplitKeyDeposit(key []byte) (proposalID uint64, depositorAddr sdk.AccAddress) { return splitKeyWithAddress(key) diff --git a/sei-cosmos/x/gov/types/keys_test.go b/sei-cosmos/x/gov/types/keys_test.go index 000b9eb3da..895f8b4426 100644 --- a/sei-cosmos/x/gov/types/keys_test.go +++ b/sei-cosmos/x/gov/types/keys_test.go @@ -69,4 +69,15 @@ func TestTallyKeys(t *testing.T) { require.Equal(t, append(append(VoteDelegationsKeyPrefix, GetProposalIDBytes(2)...), address.MustLengthPrefix(addr.Bytes())...), VoteDelegationsKey(2, addr)) require.Equal(t, append(append(append(TallyVoteDelegationsKeyPrefix, GetProposalIDBytes(2)...), byte(1)), address.MustLengthPrefix(addr.Bytes())...), TallyVoteDelegationsKey(2, false, addr)) require.Equal(t, append(append(VoterProposalsKeyPrefix, address.MustLengthPrefix(addr.Bytes())...), GetProposalIDBytes(2)...), VoterProposalsKey(addr, 2)) + + for _, expedited := range []bool{false, true} { + proposalID, decodedExpedited := SplitTallyCleanupKey(TallyCleanupKey(2, expedited)) + require.Equal(t, uint64(2), proposalID) + require.Equal(t, expedited, decodedExpedited) + require.Equal( + t, + TallyVoteDelegationsKey(2, expedited, addr), + TallyVoteDelegationsKeyFromVoteKey(TallyVoteKey(2, expedited, addr)), + ) + } } From fe1923e73b1b5f85b26ac3475f5e94467c90805c Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Thu, 27 Aug 2026 21:42:32 +0800 Subject: [PATCH 08/17] fix(gov): migrate legacy vote snapshots --- sei-cosmos/x/gov/keeper/migrations.go | 14 +++++ sei-cosmos/x/gov/keeper/migrations_test.go | 62 ++++++++++++++++++++++ sei-cosmos/x/gov/keeper/tally.go | 9 ++-- sei-cosmos/x/gov/keeper/vote.go | 6 +-- sei-cosmos/x/gov/module.go | 6 ++- sei-cosmos/x/gov/spec/02_state.md | 4 ++ 6 files changed, 91 insertions(+), 10 deletions(-) create mode 100644 sei-cosmos/x/gov/keeper/migrations_test.go diff --git a/sei-cosmos/x/gov/keeper/migrations.go b/sei-cosmos/x/gov/keeper/migrations.go index 6e67c4fee4..0bb4c80ee7 100644 --- a/sei-cosmos/x/gov/keeper/migrations.go +++ b/sei-cosmos/x/gov/keeper/migrations.go @@ -2,6 +2,7 @@ package keeper import ( sdk "github.com/sei-protocol/sei-chain/sei-cosmos/types" + "github.com/sei-protocol/sei-chain/sei-cosmos/x/gov/types" ) // Migrator is a struct for handling in-place store migrations. @@ -23,3 +24,16 @@ func (m Migrator) Migrate1to2(ctx sdk.Context) error { func (m Migrator) Migrate2to3(ctx sdk.Context) error { return nil } + +// Migrate3to4 creates delegation snapshots and active-proposal indexes for all stored votes. +func (m Migrator) Migrate3to4(ctx sdk.Context) error { + store := ctx.KVStore(m.keeper.storeKey) + iterator := sdk.KVStorePrefixIterator(store, types.VotesKeyPrefix) + defer func() { _ = iterator.Close() }() + + for ; iterator.Valid(); iterator.Next() { + proposalID, voter := types.SplitKeyVote(iterator.Key()) + m.keeper.initializeVoteDelegationTracking(ctx, proposalID, voter) + } + return nil +} diff --git a/sei-cosmos/x/gov/keeper/migrations_test.go b/sei-cosmos/x/gov/keeper/migrations_test.go new file mode 100644 index 0000000000..a1b1c6fead --- /dev/null +++ b/sei-cosmos/x/gov/keeper/migrations_test.go @@ -0,0 +1,62 @@ +package keeper_test + +import ( + "testing" + + tmproto "github.com/sei-protocol/sei-chain/sei-tendermint/proto/tendermint/types" + "github.com/stretchr/testify/require" + + seiapp "github.com/sei-protocol/sei-chain/app" + govkeeper "github.com/sei-protocol/sei-chain/sei-cosmos/x/gov/keeper" + govtypes "github.com/sei-protocol/sei-chain/sei-cosmos/x/gov/types" + stakingtypes "github.com/sei-protocol/sei-chain/sei-cosmos/x/staking/types" +) + +func TestMigrate3to4BackfillsVoteDelegationTracking(t *testing.T) { + app := seiapp.Setup(t, false, false, false) + ctx := app.BaseApp.NewContext(false, tmproto.Header{}) + addrs, valAddrs := createValidators(t, ctx, app, []int64{5, 5, 5}) + + proposal, err := app.GovKeeper.SubmitProposal(ctx, TestProposal) + require.NoError(t, err) + proposal.Status = govtypes.StatusVotingPeriod + app.GovKeeper.SetProposal(ctx, proposal) + require.NoError(t, app.GovKeeper.AddVote( + ctx, + proposal.ProposalId, + addrs[0], + govtypes.NewNonSplitVoteOption(govtypes.OptionYes), + )) + require.NoError(t, app.GovKeeper.AddVote( + ctx, + proposal.ProposalId, + addrs[3], + govtypes.NewNonSplitVoteOption(govtypes.OptionNo), + )) + + store := ctx.KVStore(app.GetKey(govtypes.StoreKey)) + for _, voter := range []int{0, 3} { + store.Delete(govtypes.VoteDelegationsKey(proposal.ProposalId, addrs[voter])) + store.Delete(govtypes.VoterProposalsKey(addrs[voter], proposal.ProposalId)) + } + + migrator := govkeeper.NewMigrator(app.GovKeeper) + require.NoError(t, migrator.Migrate3to4(ctx)) + for _, voter := range []int{0, 3} { + require.True(t, store.Has(govtypes.VoteDelegationsKey(proposal.ProposalId, addrs[voter]))) + require.True(t, store.Has(govtypes.VoterProposalsKey(addrs[voter], proposal.ProposalId))) + } + + app.GovKeeper.InitializeTally(ctx, proposal) + validator, found := app.StakingKeeper.GetValidator(ctx, valAddrs[0]) + require.True(t, found) + delegatedTokens := app.StakingKeeper.TokensFromConsensusPower(ctx, 20) + _, err = app.StakingKeeper.Delegate(ctx, addrs[3], delegatedTokens, stakingtypes.Unbonded, validator, true) + require.NoError(t, err) + + complete, processed, _, _, tallyResult := app.GovKeeper.TallyIncremental(ctx, proposal, 2) + require.True(t, complete) + require.Equal(t, 2, processed) + require.True(t, tallyResult.Yes.Equal(app.StakingKeeper.TokensFromConsensusPower(ctx, 5))) + require.True(t, tallyResult.No.IsZero()) +} diff --git a/sei-cosmos/x/gov/keeper/tally.go b/sei-cosmos/x/gov/keeper/tally.go index 45cd600fc5..014f9ee4e8 100644 --- a/sei-cosmos/x/gov/keeper/tally.go +++ b/sei-cosmos/x/gov/keeper/tally.go @@ -179,13 +179,10 @@ func (keeper Keeper) processTallyVotes( voter := sdk.MustAccAddressFromBech32(vote.Voter) snapshotKey := types.VoteDelegationsKey(proposalID, voter) snapshotValue := store.Get(snapshotKey) - var snapshot types.VoteDelegationSnapshot if snapshotValue == nil { - snapshot = keeper.snapshotVoteDelegations(ctx, proposalID, voter) - snapshotValue = keeper.cdc.MustMarshal(&snapshot) - } else { - snapshot = keeper.unmarshalVoteDelegations(snapshotValue) + panic(fmt.Sprintf("missing delegation snapshot for proposal %d voter %s", proposalID, voter)) } + snapshot := keeper.unmarshalVoteDelegations(snapshotValue) keeper.addVoteToTally(validators, vote, snapshot) store.Set(types.TallyVoteKey(proposalID, progress.Expedited, voter), value) @@ -237,7 +234,7 @@ func (keeper Keeper) voteDelegations( if bz := store.Get(types.TallyVoteDelegationsKey(proposalID, expedited, voter)); bz != nil { return keeper.unmarshalVoteDelegations(bz) } - return keeper.snapshotVoteDelegations(ctx, proposalID, voter) + panic(fmt.Sprintf("missing delegation snapshot for proposal %d voter %s", proposalID, voter)) } func (progress *tallyProgress) validatorMap() map[string]*tallyValidator { diff --git a/sei-cosmos/x/gov/keeper/vote.go b/sei-cosmos/x/gov/keeper/vote.go index a5cd6a9749..ed1d58ddd8 100644 --- a/sei-cosmos/x/gov/keeper/vote.go +++ b/sei-cosmos/x/gov/keeper/vote.go @@ -111,11 +111,11 @@ func (keeper Keeper) SetVote(ctx sdk.Context, vote types.Vote) { addr := sdk.MustAccAddressFromBech32(vote.Voter) store.Set(types.VoteKey(vote.ProposalId, addr), bz) - store.Set(types.VoterProposalsKey(addr, vote.ProposalId), []byte{1}) - keeper.setVoteDelegations(ctx, vote.ProposalId, addr) + keeper.initializeVoteDelegationTracking(ctx, vote.ProposalId, addr) } -func (keeper Keeper) setVoteDelegations(ctx sdk.Context, proposalID uint64, voter sdk.AccAddress) { +func (keeper Keeper) initializeVoteDelegationTracking(ctx sdk.Context, proposalID uint64, voter sdk.AccAddress) { + ctx.KVStore(keeper.storeKey).Set(types.VoterProposalsKey(voter, proposalID), []byte{1}) snapshot := keeper.snapshotVoteDelegations(ctx, proposalID, voter) keeper.setVoteDelegationSnapshot(ctx, snapshot) } diff --git a/sei-cosmos/x/gov/module.go b/sei-cosmos/x/gov/module.go index 2f61b161df..3629302218 100644 --- a/sei-cosmos/x/gov/module.go +++ b/sei-cosmos/x/gov/module.go @@ -173,6 +173,10 @@ func (am AppModule) RegisterServices(cfg module.Configurator) { if err != nil { panic(err) } + err = cfg.RegisterMigration(types.ModuleName, 3, m.Migrate3to4) + if err != nil { + panic(err) + } } // InitGenesis performs genesis initialization for the gov module. It returns @@ -201,7 +205,7 @@ func (am AppModule) ExportGenesisStream(ctx sdk.Context, cdc codec.JSONCodec) <- } // ConsensusVersion implements AppModule/ConsensusVersion. -func (AppModule) ConsensusVersion() uint64 { return 3 } +func (AppModule) ConsensusVersion() uint64 { return 4 } // AppModuleSimulation functions diff --git a/sei-cosmos/x/gov/spec/02_state.md b/sei-cosmos/x/gov/spec/02_state.md index 81bcd50673..8e2db8364b 100644 --- a/sei-cosmos/x/gov/spec/02_state.md +++ b/sei-cosmos/x/gov/spec/02_state.md @@ -232,6 +232,10 @@ vote-record order. Completed tally archives and their delegation snapshots are removed incrementally under the same per-block vote-record budget, with part of that budget reserved so cleanup cannot be starved by unfinished tallies. +The version 4 governance store migration creates delegation snapshots and active-vote +index entries for votes that predate this state. Tally processing treats a missing +snapshot as an invariant failure rather than reading delegations from a later block. + Application-state export serializes all archived and pending votes together with their delegation snapshots, but not the in-progress accumulator. On import, an expired voting proposal starts a new tally from those votes, their frozen delegation snapshots, and From fd4e3a325a73bd5084bbedadfca3a421079d12c9 Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Fri, 28 Aug 2026 16:09:13 +0800 Subject: [PATCH 09/17] fix(gov): bound legacy vote backfill --- sei-cosmos/x/gov/abci.go | 2 +- sei-cosmos/x/gov/abci_test.go | 75 ++++++++++++++------ sei-cosmos/x/gov/keeper/migrations.go | 81 ++++++++++++++++++++-- sei-cosmos/x/gov/keeper/migrations_test.go | 74 ++++++++++++++++++-- sei-cosmos/x/gov/keeper/tally.go | 15 +++- sei-cosmos/x/gov/keeper/vote.go | 5 +- sei-cosmos/x/gov/keeper/vote_test.go | 29 ++++++++ sei-cosmos/x/gov/simulation/decoder.go | 4 +- sei-cosmos/x/gov/spec/02_state.md | 12 +++- sei-cosmos/x/gov/types/keys.go | 12 ++++ sei-cosmos/x/gov/types/keys_test.go | 2 + 11 files changed, 271 insertions(+), 40 deletions(-) diff --git a/sei-cosmos/x/gov/abci.go b/sei-cosmos/x/gov/abci.go index 1bf98457cb..206bd03525 100644 --- a/sei-cosmos/x/gov/abci.go +++ b/sei-cosmos/x/gov/abci.go @@ -13,7 +13,7 @@ import ( var logger = seilog.NewLogger("cosmos", "x", "gov") -// MaxVotesProcessedPerBlock is the governance vote-record budget shared by tallying and cleanup. +// MaxVotesProcessedPerBlock is the governance vote-record budget shared by backfill, tallying, and cleanup. const MaxVotesProcessedPerBlock = 1000 // minTallyCleanupVotesPerBlock reserves part of the budget for completed tally archives. diff --git a/sei-cosmos/x/gov/abci_test.go b/sei-cosmos/x/gov/abci_test.go index aee5c85748..916fed10f3 100644 --- a/sei-cosmos/x/gov/abci_test.go +++ b/sei-cosmos/x/gov/abci_test.go @@ -15,6 +15,7 @@ import ( "github.com/sei-protocol/sei-chain/app/legacyabci" sdk "github.com/sei-protocol/sei-chain/sei-cosmos/types" "github.com/sei-protocol/sei-chain/sei-cosmos/x/gov" + govkeeper "github.com/sei-protocol/sei-chain/sei-cosmos/x/gov/keeper" "github.com/sei-protocol/sei-chain/sei-cosmos/x/gov/types" "github.com/sei-protocol/sei-chain/sei-cosmos/x/staking" ) @@ -433,10 +434,18 @@ func TestExpeditedProposalPassAndConvertToRegular(t *testing.T) { require.NoError(t, err) require.NotNil(t, res) + proposal, ok := app.GovKeeper.GetProposal(ctx, proposalID) + require.True(t, ok) + if tc.isExpeditedPasses { + // Validator votes YES before the expedited voting period expires. + err = app.GovKeeper.AddVote(ctx, proposal.ProposalId, addrs[0], types.NewNonSplitVoteOption(types.OptionYes)) + require.NoError(t, err) + } + votingParams := app.GovKeeper.GetVotingParams(ctx) newHeader = ctx.BlockHeader() - newHeader.Time = ctx.BlockHeader().Time.Add(app.GovKeeper.GetDepositParams(ctx).MaxDepositPeriod).Add(votingParams.ExpeditedVotingPeriod) + newHeader.Time = proposal.VotingEndTime ctx = ctx.WithBlockHeader(newHeader) inactiveQueue = app.GovKeeper.InactiveProposalQueueIterator(ctx, ctx.BlockHeader().Time) @@ -447,18 +456,12 @@ func TestExpeditedProposalPassAndConvertToRegular(t *testing.T) { require.True(t, activeQueue.Valid()) activeProposalID := types.GetProposalIDFromBytes(activeQueue.Value()) - proposal, ok := app.GovKeeper.GetProposal(ctx, activeProposalID) + proposal, ok = app.GovKeeper.GetProposal(ctx, activeProposalID) require.True(t, ok) require.Equal(t, types.StatusVotingPeriod, proposal.Status) activeQueue.Close() - if tc.isExpeditedPasses { - // Validator votes YES, letting the expedited proposal pass. - err = app.GovKeeper.AddVote(ctx, proposal.ProposalId, addrs[0], types.NewNonSplitVoteOption(types.OptionYes)) - require.NoError(t, err) - } - // Here the expedited proposal is converted to regular after expiry. gov.EndBlocker(ctx, app.GovKeeper) @@ -466,6 +469,7 @@ func TestExpeditedProposalPassAndConvertToRegular(t *testing.T) { if tc.isExpeditedPasses { require.False(t, activeQueue.Valid()) + activeQueue.Close() proposal, ok = app.GovKeeper.GetProposal(ctx, activeProposalID) require.True(t, ok) @@ -486,9 +490,7 @@ func TestExpeditedProposalPassAndConvertToRegular(t *testing.T) { } // Expedited proposal should be converted to a regular proposal instead. - require.True(t, activeQueue.Valid()) - - activeProposalID = types.GetProposalIDFromBytes(activeQueue.Value()) + require.False(t, activeQueue.Valid()) activeQueue.Close() proposal, ok = app.GovKeeper.GetProposal(ctx, activeProposalID) @@ -507,8 +509,14 @@ func TestExpeditedProposalPassAndConvertToRegular(t *testing.T) { expectedIntermediateMofuleAccCoings := initialModuleAccCoins.Add(proposalCoins...).Add(proposalCoins...) require.Equal(t, expectedIntermediateMofuleAccCoings, intermediateModuleAccCoins) + if tc.isRegularEventuallyPassing { + // Validator votes YES before the converted regular voting period expires. + err = app.GovKeeper.AddVote(ctx, proposal.ProposalId, addrs[0], types.NewNonSplitVoteOption(types.OptionYes)) + require.NoError(t, err) + } + // block header time at the voting period - newHeader.Time = ctx.BlockHeader().Time.Add(app.GovKeeper.GetDepositParams(ctx).MaxDepositPeriod).Add(votingParams.VotingPeriod) + newHeader.Time = proposal.VotingEndTime ctx = ctx.WithBlockHeader(newHeader) inactiveQueue = app.GovKeeper.InactiveProposalQueueIterator(ctx, ctx.BlockHeader().Time) @@ -518,12 +526,6 @@ func TestExpeditedProposalPassAndConvertToRegular(t *testing.T) { activeQueue = app.GovKeeper.ActiveProposalQueueIterator(ctx, ctx.BlockHeader().Time) require.True(t, activeQueue.Valid()) - if tc.isRegularEventuallyPassing { - // Validator votes YES, letting the converted regular proposal pass. - err = app.GovKeeper.AddVote(ctx, proposal.ProposalId, addrs[0], types.NewNonSplitVoteOption(types.OptionYes)) - require.NoError(t, err) - } - // Here we validate the converted regular proposal gov.EndBlocker(ctx, app.GovKeeper) @@ -607,7 +609,7 @@ func TestEndBlockerProposalHandlerFailed(t *testing.T) { gov.EndBlocker(ctx, app.GovKeeper) } -func TestEndBlockerBoundsVoteTallyAndCleanupWork(t *testing.T) { +func TestEndBlockerBoundsVoteBackfillTallyAndCleanupWork(t *testing.T) { app := seiapp.Setup(t, false, false, false) ctx := app.BaseApp.NewContext(false, tmproto.Header{}) @@ -649,6 +651,14 @@ func TestEndBlockerBoundsVoteTallyAndCleanupWork(t *testing.T) { types.NewNonSplitVoteOption(types.OptionYes), )) } + store := ctx.KVStore(app.GetKey(types.StoreKey)) + for i := 0; i < gov.MaxVotesProcessedPerBlock+1; i++ { + addr := make(sdk.AccAddress, 20) + binary.BigEndian.PutUint64(addr[12:], uint64(i+1)) + store.Delete(types.VoteDelegationsKey(proposal.ProposalId, addr)) + store.Delete(types.VoterProposalsKey(addr, proposal.ProposalId)) + } + require.NoError(t, govkeeper.NewMigrator(app.GovKeeper).Migrate3to4(ctx)) ctx = ctx.WithBlockTime(proposal.VotingEndTime) gov.EndBlocker(ctx, app.GovKeeper) @@ -656,10 +666,20 @@ func TestEndBlockerBoundsVoteTallyAndCleanupWork(t *testing.T) { 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.False(t, app.GovKeeper.IsTallying(ctx, proposal.ProposalId)) + require.True(t, app.GovKeeper.IsVoteDelegationBackfillInProgress(ctx, proposal.ProposalId)) require.Len(t, app.GovKeeper.GetVotes(ctx, proposal.ProposalId), gov.MaxVotesProcessedPerBlock+1) - require.Len(t, app.GovKeeper.GetArchivedTallyVotes(ctx, proposal.ProposalId, false), 900) + require.Empty(t, app.GovKeeper.GetArchivedTallyVotes(ctx, proposal.ProposalId, false)) require.Len(t, app.GovKeeper.GetArchivedTallyVotes(ctx, cleanupProposal.ProposalId, false), 1) + tracked := 0 + for i := 0; i < gov.MaxVotesProcessedPerBlock+1; i++ { + addr := make(sdk.AccAddress, 20) + binary.BigEndian.PutUint64(addr[12:], uint64(i+1)) + if store.Has(types.VoteDelegationsKey(proposal.ProposalId, addr)) { + tracked++ + } + } + require.Equal(t, 900, tracked) newVoter := make(sdk.AccAddress, 20) binary.BigEndian.PutUint64(newVoter[12:], uint64(gov.MaxVotesProcessedPerBlock+2)) @@ -668,12 +688,23 @@ func TestEndBlockerBoundsVoteTallyAndCleanupWork(t *testing.T) { 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.False(t, app.GovKeeper.IsVoteDelegationBackfillInProgress(ctx, proposal.ProposalId)) + require.Len(t, app.GovKeeper.GetVotes(ctx, proposal.ProposalId), gov.MaxVotesProcessedPerBlock+1) + require.Len(t, app.GovKeeper.GetArchivedTallyVotes(ctx, proposal.ProposalId, false), 898) + require.Empty(t, app.GovKeeper.GetArchivedTallyVotes(ctx, cleanupProposal.ProposalId, false)) + + 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), 103) + require.Len(t, app.GovKeeper.GetArchivedTallyVotes(ctx, proposal.ProposalId, false), 104) gov.EndBlocker(ctx, app.GovKeeper) require.Empty(t, app.GovKeeper.GetArchivedTallyVotes(ctx, proposal.ProposalId, false)) diff --git a/sei-cosmos/x/gov/keeper/migrations.go b/sei-cosmos/x/gov/keeper/migrations.go index 0bb4c80ee7..040993be93 100644 --- a/sei-cosmos/x/gov/keeper/migrations.go +++ b/sei-cosmos/x/gov/keeper/migrations.go @@ -5,6 +5,8 @@ import ( "github.com/sei-protocol/sei-chain/sei-cosmos/x/gov/types" ) +const voteDelegationBackfillComplete byte = 0 + // Migrator is a struct for handling in-place store migrations. type Migrator struct { keeper Keeper @@ -25,15 +27,82 @@ func (m Migrator) Migrate2to3(ctx sdk.Context) error { return nil } -// Migrate3to4 creates delegation snapshots and active-proposal indexes for all stored votes. +// Migrate3to4 schedules delegation-tracking backfill for existing votes. func (m Migrator) Migrate3to4(ctx sdk.Context) error { + nextProposalID, err := m.keeper.GetProposalID(ctx) + if err != nil { + return err + } + store := ctx.KVStore(m.keeper.storeKey) - iterator := sdk.KVStorePrefixIterator(store, types.VotesKeyPrefix) + store.Set(types.VoteDelegationBackfillCutoffKey, types.GetProposalIDBytes(nextProposalID)) + return nil +} + +// BackfillVoteDelegationTracking initializes tracking for at most maxVotes of a proposal's votes. +func (keeper Keeper) BackfillVoteDelegationTracking( + ctx sdk.Context, + proposalID uint64, + maxVotes int, +) (complete bool, processed int) { + if maxVotes < 0 { + panic("maximum votes to backfill cannot be negative") + } + if !keeper.voteNeedsDelegationBackfill(ctx, proposalID) { + return true, 0 + } + + store := ctx.KVStore(keeper.storeKey) + progressKey := types.VoteDelegationBackfillProgressKey(proposalID) + cursor := store.Get(progressKey) + if cursor == nil { + cursor = types.VotesKey(proposalID) + store.Set(progressKey, cursor) + } + + votesPrefix := types.VotesKey(proposalID) + iterator := store.Iterator(cursor, sdk.PrefixEndBytes(votesPrefix)) defer func() { _ = iterator.Close() }() - for ; iterator.Valid(); iterator.Next() { - proposalID, voter := types.SplitKeyVote(iterator.Key()) - m.keeper.initializeVoteDelegationTracking(ctx, proposalID, voter) + for ; iterator.Valid() && processed < maxVotes; iterator.Next() { + _, voter := types.SplitKeyVote(iterator.Key()) + if !store.Has(types.VoteDelegationsKey(proposalID, voter)) || + !store.Has(types.VoterProposalsKey(voter, proposalID)) { + keeper.initializeVoteDelegationTracking(ctx, proposalID, voter) + } + processed++ } - return nil + + if iterator.Valid() { + store.Set(progressKey, append([]byte(nil), iterator.Key()...)) + return false, processed + } + store.Set(progressKey, []byte{voteDelegationBackfillComplete}) + return true, processed +} + +// IsVoteDelegationBackfillInProgress reports whether a proposal's tracking backfill has started but not finished. +func (keeper Keeper) IsVoteDelegationBackfillInProgress(ctx sdk.Context, proposalID uint64) bool { + progress := ctx.KVStore(keeper.storeKey).Get(types.VoteDelegationBackfillProgressKey(proposalID)) + return progress != nil && !voteDelegationBackfillIsComplete(progress) +} + +func (keeper Keeper) voteNeedsDelegationBackfill(ctx sdk.Context, proposalID uint64) bool { + store := ctx.KVStore(keeper.storeKey) + cutoff := store.Get(types.VoteDelegationBackfillCutoffKey) + if cutoff == nil { + return false + } + if len(cutoff) != 8 { + panic("invalid vote delegation backfill cutoff") + } + if proposalID >= types.GetProposalIDFromBytes(cutoff) { + return false + } + progress := store.Get(types.VoteDelegationBackfillProgressKey(proposalID)) + return !voteDelegationBackfillIsComplete(progress) +} + +func voteDelegationBackfillIsComplete(progress []byte) bool { + return len(progress) == 1 && progress[0] == voteDelegationBackfillComplete } diff --git a/sei-cosmos/x/gov/keeper/migrations_test.go b/sei-cosmos/x/gov/keeper/migrations_test.go index a1b1c6fead..43e592e6cc 100644 --- a/sei-cosmos/x/gov/keeper/migrations_test.go +++ b/sei-cosmos/x/gov/keeper/migrations_test.go @@ -7,12 +7,13 @@ import ( "github.com/stretchr/testify/require" seiapp "github.com/sei-protocol/sei-chain/app" + gov "github.com/sei-protocol/sei-chain/sei-cosmos/x/gov" govkeeper "github.com/sei-protocol/sei-chain/sei-cosmos/x/gov/keeper" govtypes "github.com/sei-protocol/sei-chain/sei-cosmos/x/gov/types" stakingtypes "github.com/sei-protocol/sei-chain/sei-cosmos/x/staking/types" ) -func TestMigrate3to4BackfillsVoteDelegationTracking(t *testing.T) { +func TestMigrate3to4SchedulesBoundedVoteDelegationBackfill(t *testing.T) { app := seiapp.Setup(t, false, false, false) ctx := app.BaseApp.NewContext(false, tmproto.Header{}) addrs, valAddrs := createValidators(t, ctx, app, []int64{5, 5, 5}) @@ -42,21 +43,84 @@ func TestMigrate3to4BackfillsVoteDelegationTracking(t *testing.T) { migrator := govkeeper.NewMigrator(app.GovKeeper) require.NoError(t, migrator.Migrate3to4(ctx)) + require.False(t, app.GovKeeper.IsVoteDelegationBackfillInProgress(ctx, proposal.ProposalId)) for _, voter := range []int{0, 3} { - require.True(t, store.Has(govtypes.VoteDelegationsKey(proposal.ProposalId, addrs[voter]))) - require.True(t, store.Has(govtypes.VoterProposalsKey(addrs[voter], proposal.ProposalId))) + require.False(t, store.Has(govtypes.VoteDelegationsKey(proposal.ProposalId, addrs[voter]))) + require.False(t, store.Has(govtypes.VoterProposalsKey(addrs[voter], proposal.ProposalId))) + } + + newProposal, err := app.GovKeeper.SubmitProposal(ctx, TestProposal) + require.NoError(t, err) + newProposal.Status = govtypes.StatusVotingPeriod + app.GovKeeper.SetProposal(ctx, newProposal) + require.NoError(t, app.GovKeeper.AddVote( + ctx, + newProposal.ProposalId, + addrs[1], + govtypes.NewNonSplitVoteOption(govtypes.OptionAbstain), + )) + backfillComplete, backfilled := app.GovKeeper.BackfillVoteDelegationTracking(ctx, newProposal.ProposalId, 1) + require.True(t, backfillComplete) + require.Zero(t, backfilled) + + backfillComplete, backfilled = app.GovKeeper.BackfillVoteDelegationTracking(ctx, proposal.ProposalId, 0) + require.False(t, backfillComplete) + require.Zero(t, backfilled) + require.True(t, app.GovKeeper.IsVoteDelegationBackfillInProgress(ctx, proposal.ProposalId)) + + complete, processed, _, _, _ := app.GovKeeper.TallyIncremental(ctx, proposal, 1) + require.False(t, complete) + require.Equal(t, 1, processed) + require.False(t, app.GovKeeper.IsTallying(ctx, proposal.ProposalId)) + require.True(t, app.GovKeeper.IsVoteDelegationBackfillInProgress(ctx, proposal.ProposalId)) + tracked := 0 + for _, voter := range []int{0, 3} { + if store.Has(govtypes.VoteDelegationsKey(proposal.ProposalId, addrs[voter])) { + tracked++ + require.True(t, store.Has(govtypes.VoterProposalsKey(addrs[voter], proposal.ProposalId))) + } } + require.Equal(t, 1, tracked) + require.True(t, store.Has(govtypes.VoteDelegationsKey(proposal.ProposalId, addrs[0]))) + require.ErrorIs(t, app.GovKeeper.AddVote( + ctx, + proposal.ProposalId, + addrs[2], + govtypes.NewNonSplitVoteOption(govtypes.OptionYes), + ), govtypes.ErrInactiveProposal) - app.GovKeeper.InitializeTally(ctx, proposal) validator, found := app.StakingKeeper.GetValidator(ctx, valAddrs[0]) require.True(t, found) + snapshotKey := govtypes.VoteDelegationsKey(proposal.ProposalId, addrs[0]) + snapshotBeforeDelegation := append([]byte(nil), store.Get(snapshotKey)...) delegatedTokens := app.StakingKeeper.TokensFromConsensusPower(ctx, 20) + _, err = app.StakingKeeper.Delegate(ctx, addrs[0], delegatedTokens, stakingtypes.Unbonded, validator, true) + require.NoError(t, err) + require.NotEqual(t, snapshotBeforeDelegation, store.Get(snapshotKey)) + + require.NotPanics(t, func() { + _, _, _ = app.GovKeeper.Tally(ctx, proposal) + }) + genesis := gov.ExportGenesis(ctx, app.GovKeeper) + require.Len(t, genesis.Votes, 3) + require.Len(t, genesis.VoteDelegationSnapshots, 3) + + 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.False(t, app.GovKeeper.IsVoteDelegationBackfillInProgress(ctx, proposal.ProposalId)) + for _, voter := range []int{0, 3} { + require.True(t, store.Has(govtypes.VoteDelegationsKey(proposal.ProposalId, addrs[voter]))) + require.True(t, store.Has(govtypes.VoterProposalsKey(addrs[voter], proposal.ProposalId))) + } + _, err = app.StakingKeeper.Delegate(ctx, addrs[3], delegatedTokens, stakingtypes.Unbonded, validator, true) require.NoError(t, err) complete, processed, _, _, tallyResult := app.GovKeeper.TallyIncremental(ctx, proposal, 2) require.True(t, complete) require.Equal(t, 2, processed) - require.True(t, tallyResult.Yes.Equal(app.StakingKeeper.TokensFromConsensusPower(ctx, 5))) + require.True(t, tallyResult.Yes.Equal(app.StakingKeeper.TokensFromConsensusPower(ctx, 25))) require.True(t, tallyResult.No.IsZero()) } diff --git a/sei-cosmos/x/gov/keeper/tally.go b/sei-cosmos/x/gov/keeper/tally.go index 014f9ee4e8..c8a5100953 100644 --- a/sei-cosmos/x/gov/keeper/tally.go +++ b/sei-cosmos/x/gov/keeper/tally.go @@ -60,12 +60,19 @@ func (keeper Keeper) TallyIncremental( progress, found := keeper.getTallyProgress(ctx, proposal.ProposalId) if !found { + backfillComplete, backfilled := keeper.BackfillVoteDelegationTracking(ctx, proposal.ProposalId, maxVotes) + processed = backfilled + if !backfillComplete { + return false, processed, false, false, types.EmptyTallyResult() + } 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) + var tallied int + complete, tallied = keeper.processTallyVotes(ctx, proposal.ProposalId, &progress, maxVotes-processed) + processed += tallied if !complete { keeper.setTallyProgress(ctx, proposal.ProposalId, progress) return false, processed, false, false, types.EmptyTallyResult() @@ -92,6 +99,9 @@ func (keeper Keeper) InitializeTally(ctx sdk.Context, proposal types.Proposal) { } return } + if keeper.voteNeedsDelegationBackfill(ctx, proposal.ProposalId) { + panic("cannot initialize tally while vote delegation backfill is in progress") + } keeper.setTallyProgress(ctx, proposal.ProposalId, keeper.initializeTally(ctx, proposal)) } @@ -234,6 +244,9 @@ func (keeper Keeper) voteDelegations( if bz := store.Get(types.TallyVoteDelegationsKey(proposalID, expedited, voter)); bz != nil { return keeper.unmarshalVoteDelegations(bz) } + if keeper.voteNeedsDelegationBackfill(ctx, proposalID) { + return keeper.snapshotVoteDelegations(ctx, proposalID, voter) + } panic(fmt.Sprintf("missing delegation snapshot for proposal %d voter %s", proposalID, voter)) } diff --git a/sei-cosmos/x/gov/keeper/vote.go b/sei-cosmos/x/gov/keeper/vote.go index ed1d58ddd8..dddd01e8aa 100644 --- a/sei-cosmos/x/gov/keeper/vote.go +++ b/sei-cosmos/x/gov/keeper/vote.go @@ -21,7 +21,10 @@ 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) { + if proposal.VotingEndTime.Before(ctx.BlockTime()) { + return sdkerrors.Wrapf(types.ErrInactiveProposal, "%d", proposalID) + } + if keeper.IsTallying(ctx, proposalID) || keeper.IsVoteDelegationBackfillInProgress(ctx, proposalID) { return sdkerrors.Wrapf(types.ErrInactiveProposal, "%d", proposalID) } diff --git a/sei-cosmos/x/gov/keeper/vote_test.go b/sei-cosmos/x/gov/keeper/vote_test.go index 07900b7ca5..99a0e5d001 100644 --- a/sei-cosmos/x/gov/keeper/vote_test.go +++ b/sei-cosmos/x/gov/keeper/vote_test.go @@ -2,6 +2,7 @@ package keeper_test import ( "testing" + "time" tmproto "github.com/sei-protocol/sei-chain/sei-tendermint/proto/tendermint/types" "github.com/stretchr/testify/require" @@ -92,3 +93,31 @@ func TestVotes(t *testing.T) { require.True(t, votes[1].Options[3].Weight.Equal(sdk.NewDecWithPrec(5, 2))) require.Equal(t, types.OptionEmpty, vote.Option) } + +func TestAddVoteRejectsBlocksAfterVotingEnd(t *testing.T) { + app := seiapp.Setup(t, false, false, false) + ctx := app.BaseApp.NewContext(false, tmproto.Header{}) + addrs := seiapp.AddTestAddrsIncremental(app, ctx, 2, sdk.NewInt(30000000)) + + proposal, err := app.GovKeeper.SubmitProposal(ctx, TestProposal) + require.NoError(t, err) + proposal.Status = types.StatusVotingPeriod + proposal.VotingEndTime = ctx.BlockTime().Add(time.Second) + app.GovKeeper.SetProposal(ctx, proposal) + + atVotingEnd := ctx.WithBlockTime(proposal.VotingEndTime) + require.NoError(t, app.GovKeeper.AddVote( + atVotingEnd, + proposal.ProposalId, + addrs[0], + types.NewNonSplitVoteOption(types.OptionYes), + )) + + afterVotingEnd := ctx.WithBlockTime(proposal.VotingEndTime.Add(time.Second)) + require.ErrorIs(t, app.GovKeeper.AddVote( + afterVotingEnd, + proposal.ProposalId, + addrs[1], + types.NewNonSplitVoteOption(types.OptionYes), + ), types.ErrInactiveProposal) +} diff --git a/sei-cosmos/x/gov/simulation/decoder.go b/sei-cosmos/x/gov/simulation/decoder.go index 004a4a7e3d..975da5776c 100644 --- a/sei-cosmos/x/gov/simulation/decoder.go +++ b/sei-cosmos/x/gov/simulation/decoder.go @@ -52,7 +52,9 @@ func NewDecodeStore(cdc codec.Codec) func(kvA, kvB kv.Pair) string { bytes.Equal(kvA.Key[:1], types.TallyCleanupKeyPrefix), bytes.Equal(kvA.Key[:1], types.VoteDelegationsKeyPrefix), bytes.Equal(kvA.Key[:1], types.TallyVoteDelegationsKeyPrefix), - bytes.Equal(kvA.Key[:1], types.VoterProposalsKeyPrefix): + bytes.Equal(kvA.Key[:1], types.VoterProposalsKeyPrefix), + bytes.Equal(kvA.Key[:1], types.VoteDelegationBackfillCutoffKey), + bytes.Equal(kvA.Key[:1], types.VoteDelegationBackfillProgressKeyPrefix): return fmt.Sprintf("%X\n%X", kvA.Value, kvB.Value) default: diff --git a/sei-cosmos/x/gov/spec/02_state.md b/sei-cosmos/x/gov/spec/02_state.md index 8e2db8364b..3e90bf0827 100644 --- a/sei-cosmos/x/gov/spec/02_state.md +++ b/sei-cosmos/x/gov/spec/02_state.md @@ -232,9 +232,15 @@ vote-record order. Completed tally archives and their delegation snapshots are removed incrementally under the same per-block vote-record budget, with part of that budget reserved so cleanup cannot be starved by unfinished tallies. -The version 4 governance store migration creates delegation snapshots and active-vote -index entries for votes that predate this state. Tally processing treats a missing -snapshot as an invariant failure rather than reading delegations from a later block. +The version 4 governance store migration records the first proposal ID that does not +need delegation-tracking backfill. When an older proposal reaches tallying, its +delegation snapshots and active-vote index entries are created incrementally with a +per-proposal cursor under the same per-block vote-record budget as tallying and +cleanup. New votes are rejected once that backfill starts, and tally accumulation +cannot start until it completes. Votes and proposals created after the upgrade +already have the required tracking data and skip backfill. Read-only tally and export +operations derive a missing snapshot while a proposal still needs backfill; after it +completes, tally processing treats a missing snapshot as an invariant failure. Application-state export serializes all archived and pending votes together with their delegation snapshots, but not the in-progress accumulator. On import, an expired voting diff --git a/sei-cosmos/x/gov/types/keys.go b/sei-cosmos/x/gov/types/keys.go index c4995d84c5..f15bda413e 100644 --- a/sei-cosmos/x/gov/types/keys.go +++ b/sei-cosmos/x/gov/types/keys.go @@ -50,6 +50,10 @@ const ( // - 0x34: Archived voter delegation snapshot // // - 0x35: Active proposal voted on by address +// +// - 0x36: First proposal ID that does not require delegation-tracking backfill +// +// - 0x37: Delegation-tracking backfill cursor var ( ProposalsKeyPrefix = []byte{0x00} ActiveProposalQueuePrefix = []byte{0x01} @@ -66,6 +70,9 @@ var ( VoteDelegationsKeyPrefix = []byte{0x33} TallyVoteDelegationsKeyPrefix = []byte{0x34} VoterProposalsKeyPrefix = []byte{0x35} + + VoteDelegationBackfillCutoffKey = []byte{0x36} + VoteDelegationBackfillProgressKeyPrefix = []byte{0x37} ) var lenTime = len(sdk.FormatTimeBytes(time.Now())) @@ -147,6 +154,11 @@ func TallyProgressKey(proposalID uint64) []byte { return append(TallyProgressKeyPrefix, GetProposalIDBytes(proposalID)...) } +// VoteDelegationBackfillProgressKey returns the key for a proposal's delegation-tracking backfill cursor. +func VoteDelegationBackfillProgressKey(proposalID uint64) []byte { + return append(VoteDelegationBackfillProgressKeyPrefix, 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)) diff --git a/sei-cosmos/x/gov/types/keys_test.go b/sei-cosmos/x/gov/types/keys_test.go index 895f8b4426..01585cc53a 100644 --- a/sei-cosmos/x/gov/types/keys_test.go +++ b/sei-cosmos/x/gov/types/keys_test.go @@ -69,6 +69,8 @@ func TestTallyKeys(t *testing.T) { require.Equal(t, append(append(VoteDelegationsKeyPrefix, GetProposalIDBytes(2)...), address.MustLengthPrefix(addr.Bytes())...), VoteDelegationsKey(2, addr)) require.Equal(t, append(append(append(TallyVoteDelegationsKeyPrefix, GetProposalIDBytes(2)...), byte(1)), address.MustLengthPrefix(addr.Bytes())...), TallyVoteDelegationsKey(2, false, addr)) require.Equal(t, append(append(VoterProposalsKeyPrefix, address.MustLengthPrefix(addr.Bytes())...), GetProposalIDBytes(2)...), VoterProposalsKey(addr, 2)) + require.Equal(t, []byte{0x36}, VoteDelegationBackfillCutoffKey) + require.Equal(t, append(VoteDelegationBackfillProgressKeyPrefix, GetProposalIDBytes(2)...), VoteDelegationBackfillProgressKey(2)) for _, expedited := range []bool{false, true} { proposalID, decodedExpedited := SplitTallyCleanupKey(TallyCleanupKey(2, expedited)) From 2ba1b89177d435df886849b48c35c2742e09552e Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Mon, 31 Aug 2026 12:05:13 +0800 Subject: [PATCH 10/17] fix(gov): preserve historical trace behavior --- sei-cosmos/x/gov/keeper/tally.go | 3 ++ sei-cosmos/x/gov/keeper/vote.go | 53 +++++++++++++++++++--------- sei-cosmos/x/gov/keeper/vote_test.go | 43 ++++++++++++++++++++++ sei-cosmos/x/gov/types/genesis.pb.go | 7 ++-- 4 files changed, 86 insertions(+), 20 deletions(-) diff --git a/sei-cosmos/x/gov/keeper/tally.go b/sei-cosmos/x/gov/keeper/tally.go index c8a5100953..3e856f45ea 100644 --- a/sei-cosmos/x/gov/keeper/tally.go +++ b/sei-cosmos/x/gov/keeper/tally.go @@ -237,6 +237,9 @@ func (keeper Keeper) voteDelegations( vote types.Vote, ) types.VoteDelegationSnapshot { voter := sdk.MustAccAddressFromBech32(vote.Voter) + if !incrementalTallyEnabled(ctx) { + return keeper.snapshotVoteDelegations(ctx, proposalID, voter) + } store := ctx.KVStore(keeper.storeKey) if bz := store.Get(types.VoteDelegationsKey(proposalID, voter)); bz != nil { return keeper.unmarshalVoteDelegations(bz) diff --git a/sei-cosmos/x/gov/keeper/vote.go b/sei-cosmos/x/gov/keeper/vote.go index dddd01e8aa..c9886c4c57 100644 --- a/sei-cosmos/x/gov/keeper/vote.go +++ b/sei-cosmos/x/gov/keeper/vote.go @@ -3,6 +3,8 @@ package keeper import ( "fmt" + "golang.org/x/mod/semver" + "github.com/sei-protocol/sei-chain/sei-cosmos/store/cachekv" "github.com/sei-protocol/sei-chain/sei-cosmos/store/prefix" storetypes "github.com/sei-protocol/sei-chain/sei-cosmos/store/types" @@ -12,6 +14,8 @@ import ( stakingtypes "github.com/sei-protocol/sei-chain/sei-cosmos/x/staking/types" ) +const incrementalTallyUpgrade = "v6.7" + // AddVote adds a vote on a specific proposal func (keeper Keeper) AddVote(ctx sdk.Context, proposalID uint64, voterAddr sdk.AccAddress, options types.WeightedVoteOptions) error { proposal, ok := keeper.GetProposal(ctx, proposalID) @@ -21,11 +25,13 @@ 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 proposal.VotingEndTime.Before(ctx.BlockTime()) { - return sdkerrors.Wrapf(types.ErrInactiveProposal, "%d", proposalID) - } - if keeper.IsTallying(ctx, proposalID) || keeper.IsVoteDelegationBackfillInProgress(ctx, proposalID) { - return sdkerrors.Wrapf(types.ErrInactiveProposal, "%d", proposalID) + if incrementalTallyEnabled(ctx) { + if proposal.VotingEndTime.Before(ctx.BlockTime()) { + return sdkerrors.Wrapf(types.ErrInactiveProposal, "%d", proposalID) + } + if keeper.IsTallying(ctx, proposalID) || keeper.IsVoteDelegationBackfillInProgress(ctx, proposalID) { + return sdkerrors.Wrapf(types.ErrInactiveProposal, "%d", proposalID) + } } for _, option := range options { @@ -118,11 +124,18 @@ func (keeper Keeper) SetVote(ctx sdk.Context, vote types.Vote) { } func (keeper Keeper) initializeVoteDelegationTracking(ctx sdk.Context, proposalID uint64, voter sdk.AccAddress) { + if !incrementalTallyEnabled(ctx) { + return + } ctx.KVStore(keeper.storeKey).Set(types.VoterProposalsKey(voter, proposalID), []byte{1}) snapshot := keeper.snapshotVoteDelegations(ctx, proposalID, voter) keeper.setVoteDelegationSnapshot(ctx, snapshot) } +func incrementalTallyEnabled(ctx sdk.Context) bool { + return !ctx.IsTracing() || semver.Compare(ctx.ClosestUpgradeName(), incrementalTallyUpgrade) >= 0 +} + func (keeper Keeper) snapshotVoteDelegations( ctx sdk.Context, proposalID uint64, @@ -160,6 +173,9 @@ func (keeper Keeper) refreshVoteDelegationSnapshots( voter sdk.AccAddress, excludedValidator sdk.ValAddress, ) { + if !incrementalTallyEnabled(ctx) { + return + } store := ctx.KVStore(keeper.storeKey) prefix := types.VoterProposalsKeyPrefixForAddress(voter) iterator := sdk.KVStorePrefixIterator(store, prefix) @@ -212,19 +228,21 @@ func (keeper Keeper) unmarshalVoteDelegations(bz []byte) types.VoteDelegationSna // IterateAllVotes iterates over the all the stored votes and performs a callback function func (keeper Keeper) IterateAllVotes(ctx sdk.Context, cb func(vote types.Vote) (stop bool)) { store := ctx.KVStore(keeper.storeKey) - progressIterator := sdk.KVStorePrefixIterator(store, types.TallyProgressKeyPrefix) - for ; progressIterator.Valid(); progressIterator.Next() { - proposalID := types.GetProposalIDFromBytes(progressIterator.Key()[len(types.TallyProgressKeyPrefix):]) - progress, found := keeper.getTallyProgress(ctx, proposalID) - if !found { - continue - } - if keeper.iterateVoteStore(prefix.NewStore(store, types.TallyVotesKey(proposalID, progress.Expedited)), cb) { - _ = progressIterator.Close() - return + if incrementalTallyEnabled(ctx) { + progressIterator := sdk.KVStorePrefixIterator(store, types.TallyProgressKeyPrefix) + for ; progressIterator.Valid(); progressIterator.Next() { + proposalID := types.GetProposalIDFromBytes(progressIterator.Key()[len(types.TallyProgressKeyPrefix):]) + progress, found := keeper.getTallyProgress(ctx, proposalID) + if !found { + continue + } + if keeper.iterateVoteStore(prefix.NewStore(store, types.TallyVotesKey(proposalID, progress.Expedited)), cb) { + _ = progressIterator.Close() + return + } } + _ = progressIterator.Close() } - _ = progressIterator.Close() iterator := sdk.KVStorePrefixIterator(store, types.VotesKeyPrefix) @@ -264,6 +282,9 @@ func (keeper Keeper) iterateVoteStore(store storetypes.KVStore, cb func(vote typ func (keeper Keeper) visibleVotesStore(ctx sdk.Context, proposalID uint64) storetypes.KVStore { store := ctx.KVStore(keeper.storeKey) pending := prefix.NewStore(store, types.VotesKey(proposalID)) + if !incrementalTallyEnabled(ctx) { + return pending + } progress, found := keeper.getTallyProgress(ctx, proposalID) if !found { return pending diff --git a/sei-cosmos/x/gov/keeper/vote_test.go b/sei-cosmos/x/gov/keeper/vote_test.go index 99a0e5d001..14c3da628d 100644 --- a/sei-cosmos/x/gov/keeper/vote_test.go +++ b/sei-cosmos/x/gov/keeper/vote_test.go @@ -121,3 +121,46 @@ func TestAddVoteRejectsBlocksAfterVotingEnd(t *testing.T) { types.NewNonSplitVoteOption(types.OptionYes), ), types.ErrInactiveProposal) } + +func TestVoteDelegationTrackingPreservesHistoricalTraces(t *testing.T) { + app := seiapp.Setup(t, false, false, false) + ctx := app.BaseApp.NewContext(false, tmproto.Header{}).WithBlockTime(time.Unix(100, 0)) + addrs, valAddrs := createValidators(t, ctx, app, []int64{5, 5, 5}) + + proposal, err := app.GovKeeper.SubmitProposal(ctx, TestProposal) + require.NoError(t, err) + proposal.Status = types.StatusVotingPeriod + proposal.VotingEndTime = ctx.BlockTime().Add(-time.Second) + app.GovKeeper.SetProposal(ctx, proposal) + + legacyCtx := ctx.WithIsTracing(true).WithClosestUpgradeName("v6.6") + require.NoError(t, app.GovKeeper.AddVote( + legacyCtx, + proposal.ProposalId, + addrs[0], + types.NewNonSplitVoteOption(types.OptionYes), + )) + store := legacyCtx.KVStore(app.GetKey(types.StoreKey)) + require.False(t, store.Has(types.VoterProposalsKey(addrs[0], proposal.ProposalId))) + require.False(t, store.Has(types.VoteDelegationsKey(proposal.ProposalId, addrs[0]))) + require.Len(t, app.GovKeeper.GetAllVotes(legacyCtx), 1) + require.NotPanics(t, func() { + _, _, _ = app.GovKeeper.Tally(legacyCtx, proposal) + }) + + gasBeforeHook := legacyCtx.GasMeter().GasConsumed() + app.GovKeeper.StakingHooks().AfterDelegationModified(legacyCtx, addrs[0], valAddrs[0]) + require.Equal(t, gasBeforeHook, legacyCtx.GasMeter().GasConsumed()) + + proposal.VotingEndTime = ctx.BlockTime().Add(time.Second) + app.GovKeeper.SetProposal(ctx, proposal) + currentCtx := ctx.WithIsTracing(true).WithClosestUpgradeName("v6.7") + require.NoError(t, app.GovKeeper.AddVote( + currentCtx, + proposal.ProposalId, + addrs[0], + types.NewNonSplitVoteOption(types.OptionYes), + )) + require.True(t, store.Has(types.VoterProposalsKey(addrs[0], proposal.ProposalId))) + require.True(t, store.Has(types.VoteDelegationsKey(proposal.ProposalId, addrs[0]))) +} diff --git a/sei-cosmos/x/gov/types/genesis.pb.go b/sei-cosmos/x/gov/types/genesis.pb.go index f7bf78f9d5..e5ca5bfc78 100644 --- a/sei-cosmos/x/gov/types/genesis.pb.go +++ b/sei-cosmos/x/gov/types/genesis.pb.go @@ -5,13 +5,12 @@ package types import ( fmt "fmt" - io "io" - math "math" - math_bits "math/bits" - _ "github.com/gogo/protobuf/gogoproto" proto "github.com/gogo/protobuf/proto" github_com_sei_protocol_sei_chain_sei_cosmos_types "github.com/sei-protocol/sei-chain/sei-cosmos/types" + io "io" + math "math" + math_bits "math/bits" ) // Reference imports to suppress errors if they are not otherwise used. From f3d80e40862a4b53a64e2ff36c45ae04f58137cd Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Mon, 31 Aug 2026 16:32:00 +0800 Subject: [PATCH 11/17] fix(gov): finalize bounded tally processing --- app/app.go | 1 + app/legacyabci/begin_block.go | 6 + evmrpc/simulate.go | 18 + evmrpc/simulate_test.go | 89 ++ evmrpc/tests/regression_test.go | 11 + .../proto/cosmos/gov/v1beta1/genesis.proto | 31 + sei-cosmos/x/gov/abci.go | 128 ++- sei-cosmos/x/gov/abci_test.go | 168 +++- sei-cosmos/x/gov/genesis.go | 59 +- sei-cosmos/x/gov/genesis_test.go | 303 +++++- sei-cosmos/x/gov/keeper/delegation_updates.go | 346 +++++++ .../x/gov/keeper/delegation_updates_test.go | 393 ++++++++ sei-cosmos/x/gov/keeper/deposit.go | 5 + sei-cosmos/x/gov/keeper/deposit_test.go | 42 + sei-cosmos/x/gov/keeper/electorate.go | 371 +++++++ sei-cosmos/x/gov/keeper/electorate_test.go | 187 ++++ sei-cosmos/x/gov/keeper/keeper.go | 22 +- sei-cosmos/x/gov/keeper/migrations.go | 75 +- sei-cosmos/x/gov/keeper/migrations_test.go | 21 +- sei-cosmos/x/gov/keeper/staking_hooks.go | 21 +- sei-cosmos/x/gov/keeper/tally.go | 206 +++- sei-cosmos/x/gov/keeper/tally_test.go | 8 + sei-cosmos/x/gov/keeper/vote.go | 44 +- sei-cosmos/x/gov/keeper/vote_test.go | 86 +- sei-cosmos/x/gov/simulation/decoder.go | 14 +- sei-cosmos/x/gov/spec/01_concepts.md | 6 +- sei-cosmos/x/gov/spec/02_state.md | 60 +- sei-cosmos/x/gov/types/expected_keepers.go | 1 + sei-cosmos/x/gov/types/genesis.go | 189 +++- sei-cosmos/x/gov/types/genesis.pb.go | 904 +++++++++++++++++- sei-cosmos/x/gov/types/genesis_test.go | 85 ++ sei-cosmos/x/gov/types/keys.go | 93 ++ sei-cosmos/x/gov/types/keys_test.go | 5 + sei-cosmos/x/staking/keeper/slash.go | 3 +- sei-cosmos/x/staking/types/slash_context.go | 20 + sei-wasmd/app/app.go | 1 + 36 files changed, 3846 insertions(+), 176 deletions(-) create mode 100644 sei-cosmos/x/gov/keeper/delegation_updates.go create mode 100644 sei-cosmos/x/gov/keeper/delegation_updates_test.go create mode 100644 sei-cosmos/x/gov/keeper/electorate.go create mode 100644 sei-cosmos/x/gov/keeper/electorate_test.go create mode 100644 sei-cosmos/x/staking/types/slash_context.go diff --git a/app/app.go b/app/app.go index d23bbaaafb..10d639ff85 100644 --- a/app/app.go +++ b/app/app.go @@ -848,6 +848,7 @@ func New( DistrKeeper: &app.DistrKeeper, SlashingKeeper: &app.SlashingKeeper, EvidenceKeeper: &app.EvidenceKeeper, + GovKeeper: &app.GovKeeper, StakingKeeper: &app.StakingKeeper, EvmKeeper: &app.EvmKeeper, } diff --git a/app/legacyabci/begin_block.go b/app/legacyabci/begin_block.go index bb24b490b3..03fedc5f99 100644 --- a/app/legacyabci/begin_block.go +++ b/app/legacyabci/begin_block.go @@ -12,6 +12,8 @@ import ( "github.com/sei-protocol/sei-chain/sei-cosmos/x/evidence" evidencekeeper "github.com/sei-protocol/sei-chain/sei-cosmos/x/evidence/keeper" + "github.com/sei-protocol/sei-chain/sei-cosmos/x/gov" + govkeeper "github.com/sei-protocol/sei-chain/sei-cosmos/x/gov/keeper" "github.com/sei-protocol/sei-chain/sei-cosmos/x/slashing" slashingkeeper "github.com/sei-protocol/sei-chain/sei-cosmos/x/slashing/keeper" @@ -30,6 +32,7 @@ type BeginBlockKeepers struct { DistrKeeper *distrkeeper.Keeper SlashingKeeper *slashingkeeper.Keeper EvidenceKeeper *evidencekeeper.Keeper + GovKeeper *govkeeper.Keeper StakingKeeper *stakingkeeper.Keeper EvmKeeper *evmkeeper.Keeper } @@ -48,6 +51,9 @@ func BeginBlock( telemetry.MeasureSince(start, "module", "total_begin_block") }() + if keepers.GovKeeper != nil { + gov.BeginBlocker(ctx, *keepers.GovKeeper) + } keepers.EpochKeeper.BeginBlock(ctx) upgrade.BeginBlocker(*keepers.UpgradeKeeper, ctx) distribution.BeginBlocker(ctx, votes, *keepers.DistrKeeper) diff --git a/evmrpc/simulate.go b/evmrpc/simulate.go index 8e45343163..d67ca6b571 100644 --- a/evmrpc/simulate.go +++ b/evmrpc/simulate.go @@ -33,6 +33,7 @@ import ( "github.com/sei-protocol/sei-chain/sei-cosmos/baseapp" "github.com/sei-protocol/sei-chain/sei-cosmos/client" sdk "github.com/sei-protocol/sei-chain/sei-cosmos/types" + govkeeper "github.com/sei-protocol/sei-chain/sei-cosmos/x/gov/keeper" abci "github.com/sei-protocol/sei-chain/sei-tendermint/abci/types" "github.com/sei-protocol/sei-chain/sei-tendermint/rpc/coretypes" tmtypes "github.com/sei-protocol/sei-chain/sei-tendermint/types" @@ -734,6 +735,10 @@ func (b *Backend) initializeBlock(ctx context.Context, block *ethtypes.Block, ct // iteration can pass it into the SS MVCC skip loops. sdkCtx = sdkCtx.WithContext(ctx) } + if err := b.activateIncrementalTallyForTrace(sdkCtx, blockNumber); err != nil { + release() + return sdk.Context{}, nil, emptyRelease, fmt.Errorf("activate incremental governance tally: %w", err) + } runTraceBeginBlock(sdkCtx, blockNumber, reqBeginBlock.LastCommitInfo.Votes, tmBlock.Block.Evidence.ToABCI(), b.beginBlockKeepers) var nextCtx sdk.Context nextCtx, nextRelease = ctxProvider(sdkCtx.BlockHeight()) @@ -744,6 +749,19 @@ func (b *Backend) initializeBlock(ctx context.Context, block *ethtypes.Block, ct return sdkCtx, tmBlock, release, nil } +func (b *Backend) activateIncrementalTallyForTrace(ctx sdk.Context, height int64) error { + if b.keeper == nil || b.beginBlockKeepers.GovKeeper == nil { + return nil + } + govKeeper := *b.beginBlockKeepers.GovKeeper + if govKeeper.IncrementalTallyEnabled(ctx) || + !b.isV67ActiveAtHeight(height) || + b.isV67ActiveAtHeight(height-1) { + return nil + } + return govkeeper.NewMigrator(govKeeper).Migrate3to4(ctx) +} + // runTraceBeginBlock is the BeginBlock used when reconstructing historical // state for traces. var runTraceBeginBlock = legacyabci.BeginBlock diff --git a/evmrpc/simulate_test.go b/evmrpc/simulate_test.go index 0213f72baa..47092273a2 100644 --- a/evmrpc/simulate_test.go +++ b/evmrpc/simulate_test.go @@ -14,8 +14,10 @@ import ( "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/common/hexutil" "github.com/ethereum/go-ethereum/core" + ethtypes "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/export" "github.com/ethereum/go-ethereum/rpc" + "github.com/ethereum/go-ethereum/trie" "github.com/sei-protocol/sei-chain/app" "github.com/sei-protocol/sei-chain/app/legacyabci" "github.com/sei-protocol/sei-chain/evmrpc" @@ -26,6 +28,7 @@ import ( sdk "github.com/sei-protocol/sei-chain/sei-cosmos/types" txtypes "github.com/sei-protocol/sei-chain/sei-cosmos/types/tx" banktypes "github.com/sei-protocol/sei-chain/sei-cosmos/x/bank/types" + govtypes "github.com/sei-protocol/sei-chain/sei-cosmos/x/gov/types" receipt "github.com/sei-protocol/sei-chain/sei-db/ledger_db/receipt" abci "github.com/sei-protocol/sei-chain/sei-tendermint/abci/types" "github.com/sei-protocol/sei-chain/sei-tendermint/libs/bytes" @@ -1058,6 +1061,92 @@ func (c *fixedBlockClient) Status(_ context.Context) (*coretypes.ResultStatus, e }, nil } +func (c *fixedBlockClient) Validators(context.Context, *int64, *int, *int) (*coretypes.ResultValidators, error) { + return &coretypes.ResultValidators{}, nil +} + +func TestStateAtBlockReplaysIncrementalTallyActivationAndGapBoundary(t *testing.T) { + const activationHeight = int64(200) + + testApp := app.Setup(t, false, false, false) + activationTime := time.Now().UTC().Add(time.Minute) + nextBlockTime := activationTime.Add(10 * time.Second) + baseCtx := testApp.BaseApp.NewContext(false, tenderminttypes.Header{ + Height: activationHeight - 1, + Time: activationTime.Add(-time.Second), + }).WithIsTracing(true).WithClosestUpgradeName("v6.7") + govStore := baseCtx.KVStore(testApp.GetKey(govtypes.StoreKey)) + govStore.Delete(govtypes.IncrementalTallyEnabledKey) + govStore.Delete(govtypes.VoteDelegationBackfillCutoffKey) + govStore.Delete(govtypes.DeadlineBoundaryBlockTimeKey) + + latestCtx := baseCtx.WithIsTracing(false).WithBlockHeight(activationHeight + 1).WithBlockTime(nextBlockTime) + testApp.UpgradeKeeper.SetDone(latestCtx.WithBlockHeight(activationHeight), "v6.7") + primeReceiptStore(t, testApp.EvmKeeper.ReceiptStore(), activationHeight+1) + parentCtx := baseCtx + ctxProvider := func(height int64) sdk.Context { + if height == evmrpc.LatestCtxHeight { + return latestCtx + } + return parentCtx.WithBlockHeight(height) + } + + stateAtBlock := func(height int64, blockTime time.Time) *state.DBImpl { + tmClient := &fixedBlockClient{block: &coretypes.ResultBlock{ + Block: &tmtypes.Block{ + Header: tmtypes.Header{Height: height, Time: blockTime}, + LastCommit: &tmtypes.Commit{Height: height - 1}, + }, + }} + watermarks := evmrpc.NewWatermarkManager(tmClient, ctxProvider, nil, testApp.EvmKeeper.ReceiptStore()) + backend := evmrpc.NewBackend( + ctxProvider, + &testApp.EvmKeeper, + testApp.BeginBlockKeepers, + func(int64) client.TxConfig { return TxConfig }, + tmClient, + &SConfig, + testApp.BaseApp, + testApp.TracerAnteHandler, + evmrpc.NewBlockCache(3000), + &sync.Mutex{}, + watermarks, + ) + block := ethtypes.NewBlock( + ðtypes.Header{Number: big.NewInt(height), Time: uint64(blockTime.Unix()), Difficulty: big.NewInt(0)}, //nolint:gosec + ðtypes.Body{}, + nil, + trie.NewStackTrie(nil), + ) + stateDB, release, err := backend.StateAtBlock(t.Context(), block, 0, nil, true, false) + require.NoError(t, err) + t.Cleanup(release) + return stateDB.(*state.DBImpl) + } + + activationState := stateAtBlock(activationHeight, activationTime) + activationCtx := activationState.Ctx() + require.True(t, testApp.GovKeeper.IncrementalTallyEnabled(activationCtx)) + require.Equal(t, sdk.FormatTimeBytes(activationTime), activationCtx.KVStore(testApp.GetKey(govtypes.StoreKey)).Get(govtypes.DeadlineBoundaryBlockTimeKey)) + cutoff, found := testApp.GovKeeper.GetVoteDelegationBackfillCutoff(activationCtx) + require.True(t, found) + require.Equal(t, uint64(1), cutoff) + + proposal, err := testApp.GovKeeper.SubmitProposal(activationCtx, govtypes.NewTextProposal("trace", "gap", false)) + require.NoError(t, err) + testApp.GovKeeper.RemoveFromInactiveProposalQueue(activationCtx, proposal.ProposalId, proposal.DepositEndTime) + proposal.Status = govtypes.StatusVotingPeriod + proposal.VotingStartTime = activationTime + proposal.VotingEndTime = activationTime.Add(5 * time.Second) + testApp.GovKeeper.SetProposal(activationCtx, proposal) + testApp.GovKeeper.InsertActiveProposalQueue(activationCtx, proposal.ProposalId, proposal.VotingEndTime) + parentCtx = activationCtx + + nextState := stateAtBlock(activationHeight+1, nextBlockTime) + nextStore := nextState.Ctx().KVStore(testApp.GetKey(govtypes.StoreKey)) + require.True(t, nextStore.Has(govtypes.GapTallyBoundaryKey(nextBlockTime))) +} + func TestTraceBlockByNumberUsesCompatDecoderForHistoricalCosmosTx(t *testing.T) { const ( blockHeight = int64(42) diff --git a/evmrpc/tests/regression_test.go b/evmrpc/tests/regression_test.go index 19088e80e7..edfea92439 100644 --- a/evmrpc/tests/regression_test.go +++ b/evmrpc/tests/regression_test.go @@ -5,11 +5,14 @@ import ( "strings" "testing" + "golang.org/x/mod/semver" + "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/common/hexutil" "github.com/sei-protocol/sei-chain/app" "github.com/sei-protocol/sei-chain/evmrpc" sdk "github.com/sei-protocol/sei-chain/sei-cosmos/types" + govtypes "github.com/sei-protocol/sei-chain/sei-cosmos/x/gov/types" tmproto "github.com/sei-protocol/sei-chain/sei-tendermint/proto/tendermint/types" "github.com/stretchr/testify/require" ) @@ -384,6 +387,7 @@ func testTx(t *testing.T, txHash string, version string, expectedGasUsed string, blockHeight := mockStatesFromTxJson(ctx, txHash, a, mc) ctx = setLegacySstoreIfNeeded(ctx, a, version) ctx = withCapturedConsensusParams(ctx, mc, blockHeight) + removeFutureGovernanceActivation(ctx, a, version) return ctx.WithBlockHeight(blockHeight) }) s.Run( @@ -424,6 +428,7 @@ func testBlock( ) ctx = setLegacySstoreIfNeeded(ctx, a, version) ctx = withCapturedConsensusParams(ctx, mc, blockHeight) + removeFutureGovernanceActivation(ctx, a, version) return ctx.WithBlockHeight(blockHeight) }, ) @@ -456,6 +461,12 @@ func setLegacySstoreIfNeeded(ctx sdk.Context, a *app.App, version string) sdk.Co return ctx } +func removeFutureGovernanceActivation(ctx sdk.Context, a *app.App, version string) { + if semver.Compare(version, "v6.7") < 0 { + ctx.KVStore(a.GetKey(govtypes.StoreKey)).Delete(govtypes.IncrementalTallyEnabledKey) + } +} + func isVersionLessOrEqual(version, target string) bool { // Remove 'v' prefix if present if len(version) > 0 && version[0] == 'v' { diff --git a/sei-cosmos/proto/cosmos/gov/v1beta1/genesis.proto b/sei-cosmos/proto/cosmos/gov/v1beta1/genesis.proto index 67f46639e7..797172c5d8 100644 --- a/sei-cosmos/proto/cosmos/gov/v1beta1/genesis.proto +++ b/sei-cosmos/proto/cosmos/gov/v1beta1/genesis.proto @@ -43,6 +43,13 @@ message GenesisState { ]; // vote_delegation_snapshots defines the delegation shares maintained for each vote. repeated VoteDelegationSnapshot vote_delegation_snapshots = 8 [(gogoproto.nullable) = false]; + // tally_electorates defines the frozen electorate for unresolved proposals. + repeated TallyElectorate tally_electorates = 9 [(gogoproto.nullable) = false]; + // vote_delegation_backfill_cutoff defines the first proposal ID created after vote delegation tracking began. + uint64 vote_delegation_backfill_cutoff = 10 [(gogoproto.moretags) = "yaml:\"vote_delegation_backfill_cutoff\""]; + + // modern_tally_round_proposal_ids defines legacy proposals whose converted regular round uses deadline tallying. + repeated uint64 modern_tally_round_proposal_ids = 11 [(gogoproto.moretags) = "yaml:\"modern_tally_round_proposal_ids\""]; } // VoteDelegationSnapshot defines the per-validator delegation shares maintained for a vote. @@ -60,3 +67,27 @@ message VoteDelegation { (gogoproto.nullable) = false ]; } + +// TallyElectorate defines the validator and parameter state used to tally one proposal. +message TallyElectorate { + uint64 proposal_id = 1 [(gogoproto.moretags) = "yaml:\"proposal_id\""]; + string total_bonded_tokens = 2 [ + (gogoproto.customtype) = "github.com/sei-protocol/sei-chain/sei-cosmos/types.Int", + (gogoproto.nullable) = false + ]; + TallyParams tally_params = 3 [(gogoproto.nullable) = false]; + repeated TallyValidator tally_validators = 4 [(gogoproto.nullable) = false]; +} + +// TallyValidator defines a validator's frozen state in a proposal electorate. +message TallyValidator { + string address = 1; + string bonded_tokens = 2 [ + (gogoproto.customtype) = "github.com/sei-protocol/sei-chain/sei-cosmos/types.Int", + (gogoproto.nullable) = false + ]; + string delegator_shares = 3 [ + (gogoproto.customtype) = "github.com/sei-protocol/sei-chain/sei-cosmos/types.Dec", + (gogoproto.nullable) = false + ]; +} diff --git a/sei-cosmos/x/gov/abci.go b/sei-cosmos/x/gov/abci.go index 206bd03525..497f60bc93 100644 --- a/sei-cosmos/x/gov/abci.go +++ b/sei-cosmos/x/gov/abci.go @@ -13,13 +13,18 @@ import ( var logger = seilog.NewLogger("cosmos", "x", "gov") -// MaxVotesProcessedPerBlock is the governance vote-record budget shared by backfill, tallying, and cleanup. +// MaxVotesProcessedPerBlock is the governance record-work budget shared by delegation updates, backfill, tallying, and cleanup. const MaxVotesProcessedPerBlock = 1000 // minTallyCleanupVotesPerBlock reserves part of the budget for completed tally archives. const minTallyCleanupVotesPerBlock = 100 -// EndBlocker expires governance proposals and advances bounded vote tally work. +// BeginBlocker freezes electorates for proposal deadlines strictly between consecutive block times. +func BeginBlocker(ctx sdk.Context, keeper keeper.Keeper) { + keeper.CaptureGapTallyBoundary(ctx) +} + +// EndBlocker expires governance proposals and advances their tally work. func EndBlocker(ctx sdk.Context, keeper keeper.Keeper) { endBlockerStart := time.Now() defer func() { @@ -27,6 +32,11 @@ func EndBlocker(ctx sdk.Context, keeper keeper.Keeper) { // TODO(PLT-414): remove once gov_end_blocker_duration verified telemetry.ModuleMeasureSince(types.ModuleName, endBlockerStart, telemetry.MetricKeyEndBlocker) }() + if !keeper.IncrementalTallyEnabled(ctx) { + legacyEndBlocker(ctx, keeper) + return + } + keeper.CaptureExactTallyBoundary(ctx) // delete inactive proposal from store and its deposits keeper.IterateInactiveProposalsQueue(ctx, ctx.BlockHeader().Time, func(proposal types.Proposal) bool { @@ -56,16 +66,20 @@ func EndBlocker(ctx sdk.Context, keeper keeper.Keeper) { return false }) - remainingVotes := MaxVotesProcessedPerBlock - remainingVotes -= keeper.CleanupTallyVotes(ctx, minTallyCleanupVotesPerBlock) + remainingRecords := MaxVotesProcessedPerBlock + remainingRecords -= keeper.CleanupTallyVotes(ctx, minTallyCleanupVotesPerBlock) + if remainingRecords == 0 { + return + } // 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 - complete, processed, passes, burnDeposits, tallyResults := keeper.TallyIncremental(ctx, proposal, remainingVotes) - remainingVotes -= processed + complete, processed, passes, burnDeposits, tallyResults := keeper.TallyIncremental(ctx, proposal, remainingRecords) + remainingRecords -= processed if !complete { + // Preserve queue order without initializing validator snapshots for unbounded later proposals. return true } @@ -113,14 +127,13 @@ func EndBlocker(ctx sdk.Context, keeper keeper.Keeper) { // The proposal didn't pass after voting period ends if proposal.IsExpedited { // When expedited proposal fails, it is converted to a regular proposal. - // As a result, the voting period is extended. - // Once the regular voting period expires again, the tally is repeated - // according to the regular proposal rules. + // Resume the regular round after its expedited tally completes so that + // bounded tally work does not consume the regular voting window. proposal.IsExpedited = false votingParams := keeper.GetVotingParams(ctx) - proposal.VotingEndTime = proposal.VotingStartTime.Add(votingParams.VotingPeriod) + proposal.VotingEndTime = ctx.BlockTime().Add(votingParams.VotingPeriod - votingParams.ExpeditedVotingPeriod) - keeper.InsertActiveProposalQueue(ctx, proposal.ProposalId, proposal.VotingEndTime) + keeper.InsertActiveProposalQueueForModernTallyRound(ctx, proposal.ProposalId, proposal.VotingEndTime) tagValue = types.AttributeValueExpeditedConverted logMsg = "expedited proposal converted to regular" } else { @@ -154,8 +167,97 @@ func EndBlocker(ctx sdk.Context, keeper keeper.Keeper) { sdk.NewAttribute(types.AttributeKeyProposalResult, tagValue), ), ) - return remainingVotes == 0 + return remainingRecords == 0 + }) + + keeper.CleanupTallyVotes(ctx, remainingRecords) +} + +func legacyEndBlocker(ctx sdk.Context, keeper keeper.Keeper) { + keeper.IterateInactiveProposalsQueue(ctx, ctx.BlockHeader().Time, func(proposal types.Proposal) bool { + keeper.DeleteProposal(ctx, proposal.ProposalId) + keeper.DeleteDeposits(ctx, proposal.ProposalId) + keeper.AfterProposalFailedMinDeposit(ctx, proposal.ProposalId) + + ctx.EventManager().EmitEvent( + sdk.NewEvent( + types.EventTypeInactiveProposal, + sdk.NewAttribute(types.AttributeKeyProposalID, fmt.Sprintf("%d", proposal.ProposalId)), + sdk.NewAttribute(types.AttributeKeyProposalResult, types.AttributeValueProposalDropped), + ), + ) + + logger.Info( + "proposal did not meet minimum deposit; deleted", + "proposal", proposal.ProposalId, + "title", proposal.GetTitle(), + "min_deposit", keeper.GetDepositParams(ctx).MinDeposit.String(), + "min_expedited_deposit", keeper.GetDepositParams(ctx).MinExpeditedDeposit.String(), + "total_deposit", proposal.TotalDeposit.String(), + ) + + return false }) - keeper.CleanupTallyVotes(ctx, remainingVotes) + keeper.IterateActiveProposalsQueue(ctx, ctx.BlockHeader().Time, func(proposal types.Proposal) bool { + var tagValue, logMsg string + passes, burnDeposits, tallyResults := keeper.TallyLegacy(ctx, proposal) + + if !proposal.IsExpedited || passes { + if burnDeposits { + keeper.DeleteDeposits(ctx, proposal.ProposalId) + } else { + keeper.RefundDeposits(ctx, proposal.ProposalId) + } + } + + keeper.RemoveFromActiveProposalQueue(ctx, proposal.ProposalId, proposal.VotingEndTime) + + if passes { + handler := keeper.Router().GetRoute(proposal.ProposalRoute()) + cacheCtx, writeCache := ctx.CacheContext() + err := handler(cacheCtx, proposal.GetContent()) + if err == nil { + proposal.Status = types.StatusPassed + tagValue = types.AttributeValueProposalPassed + logMsg = "passed" + ctx.EventManager().EmitEvents(cacheCtx.EventManager().Events()) + writeCache() + } else { + proposal.Status = types.StatusFailed + tagValue = types.AttributeValueProposalFailed + logMsg = fmt.Sprintf("passed, but failed on execution: %s", err) + } + } else if proposal.IsExpedited { + proposal.IsExpedited = false + proposal.VotingEndTime = proposal.VotingStartTime.Add(keeper.GetVotingParams(ctx).VotingPeriod) + keeper.InsertActiveProposalQueue(ctx, proposal.ProposalId, proposal.VotingEndTime) + tagValue = types.AttributeValueExpeditedConverted + logMsg = "expedited proposal converted to regular" + } else { + proposal.Status = types.StatusRejected + tagValue = types.AttributeValueProposalRejected + logMsg = "rejected" + } + + proposal.FinalTallyResult = tallyResults + keeper.SetProposal(ctx, proposal) + keeper.AfterProposalVotingPeriodEnded(ctx, proposal.ProposalId) + + logger.Info( + "proposal tallied", + "proposal", proposal.ProposalId, + "title", proposal.GetTitle(), + "result", logMsg, + ) + + ctx.EventManager().EmitEvent( + sdk.NewEvent( + types.EventTypeActiveProposal, + sdk.NewAttribute(types.AttributeKeyProposalID, fmt.Sprintf("%d", proposal.ProposalId)), + sdk.NewAttribute(types.AttributeKeyProposalResult, tagValue), + ), + ) + return false + }) } diff --git a/sei-cosmos/x/gov/abci_test.go b/sei-cosmos/x/gov/abci_test.go index 916fed10f3..511eeb58d6 100644 --- a/sei-cosmos/x/gov/abci_test.go +++ b/sei-cosmos/x/gov/abci_test.go @@ -694,22 +694,100 @@ func TestEndBlockerBoundsVoteBackfillTallyAndCleanupWork(t *testing.T) { require.True(t, app.GovKeeper.IsTallying(ctx, proposal.ProposalId)) require.False(t, app.GovKeeper.IsVoteDelegationBackfillInProgress(ctx, proposal.ProposalId)) require.Len(t, app.GovKeeper.GetVotes(ctx, proposal.ProposalId), gov.MaxVotesProcessedPerBlock+1) - require.Len(t, app.GovKeeper.GetArchivedTallyVotes(ctx, proposal.ProposalId, false), 898) + require.Empty(t, app.GovKeeper.GetArchivedTallyVotes(ctx, proposal.ProposalId, false)) require.Empty(t, app.GovKeeper.GetArchivedTallyVotes(ctx, cleanupProposal.ProposalId, false)) 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), gov.MaxVotesProcessedPerBlock+1) + require.Len(t, app.GovKeeper.GetArchivedTallyVotes(ctx, proposal.ProposalId, false), gov.MaxVotesProcessedPerBlock) + + 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), 104) + 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)) } +func TestEndBlockerSharesVoteBudgetAcrossExpiredProposals(t *testing.T) { + app := seiapp.Setup(t, false, false, false) + ctx := app.BaseApp.NewContext(false, tmproto.Header{}) + + firstProposal, err := app.GovKeeper.SubmitProposal(ctx, TestProposal) + require.NoError(t, err) + app.GovKeeper.ActivateVotingPeriod(ctx, firstProposal) + firstProposal, found := app.GovKeeper.GetProposal(ctx, firstProposal.ProposalId) + require.True(t, found) + + secondProposal, err := app.GovKeeper.SubmitProposal(ctx, TestProposal) + require.NoError(t, err) + app.GovKeeper.ActivateVotingPeriod(ctx, secondProposal) + secondProposal, found = app.GovKeeper.GetProposal(ctx, secondProposal.ProposalId) + require.True(t, found) + require.Equal(t, firstProposal.VotingEndTime, secondProposal.VotingEndTime) + + addVotes := func(proposalID uint64, count int) { + for i := 0; i < count; i++ { + addr := make(sdk.AccAddress, 20) + voterID := proposalID*uint64(gov.MaxVotesProcessedPerBlock+1) + uint64(i+1) + binary.BigEndian.PutUint64(addr[12:], voterID) + require.NoError(t, app.GovKeeper.AddVote( + ctx, + proposalID, + addr, + types.NewNonSplitVoteOption(types.OptionYes), + )) + } + } + addVotes(firstProposal.ProposalId, gov.MaxVotesProcessedPerBlock+1) + addVotes(secondProposal.ProposalId, gov.MaxVotesProcessedPerBlock) + + ctx = ctx.WithBlockTime(firstProposal.VotingEndTime) + gov.EndBlocker(ctx, app.GovKeeper) + + firstProposal, found = app.GovKeeper.GetProposal(ctx, firstProposal.ProposalId) + require.True(t, found) + require.Equal(t, types.StatusVotingPeriod, firstProposal.Status) + require.True(t, app.GovKeeper.IsTallying(ctx, firstProposal.ProposalId)) + require.Len(t, app.GovKeeper.GetArchivedTallyVotes(ctx, firstProposal.ProposalId, false), gov.MaxVotesProcessedPerBlock) + + secondProposal, found = app.GovKeeper.GetProposal(ctx, secondProposal.ProposalId) + require.True(t, found) + require.Equal(t, types.StatusVotingPeriod, secondProposal.Status) + require.False(t, app.GovKeeper.IsTallying(ctx, secondProposal.ProposalId)) + require.Empty(t, app.GovKeeper.GetArchivedTallyVotes(ctx, secondProposal.ProposalId, false)) + + gov.EndBlocker(ctx, app.GovKeeper) + + firstProposal, found = app.GovKeeper.GetProposal(ctx, firstProposal.ProposalId) + require.True(t, found) + require.Equal(t, types.StatusRejected, firstProposal.Status) + require.False(t, app.GovKeeper.IsTallying(ctx, firstProposal.ProposalId)) + + secondProposal, found = app.GovKeeper.GetProposal(ctx, secondProposal.ProposalId) + require.True(t, found) + require.Equal(t, types.StatusVotingPeriod, secondProposal.Status) + require.True(t, app.GovKeeper.IsTallying(ctx, secondProposal.ProposalId)) + require.Len(t, app.GovKeeper.GetArchivedTallyVotes(ctx, secondProposal.ProposalId, false), gov.MaxVotesProcessedPerBlock-1) + + gov.EndBlocker(ctx, app.GovKeeper) + + secondProposal, found = app.GovKeeper.GetProposal(ctx, secondProposal.ProposalId) + require.True(t, found) + require.Equal(t, types.StatusRejected, secondProposal.Status) + require.False(t, app.GovKeeper.IsTallying(ctx, secondProposal.ProposalId)) +} + func TestEndBlockerKeepsExpeditedAndRegularTallyArchivesSeparate(t *testing.T) { app := seiapp.Setup(t, false, false, false) ctx := app.BaseApp.NewContext(false, tmproto.Header{}) @@ -734,12 +812,17 @@ func TestEndBlockerKeepsExpeditedAndRegularTallyArchivesSeparate(t *testing.T) { ctx = ctx.WithBlockTime(proposal.VotingEndTime) gov.EndBlocker(ctx, app.GovKeeper) gov.EndBlocker(ctx, app.GovKeeper) + votingParams := app.GovKeeper.GetVotingParams(ctx) + conversionTime := proposal.VotingStartTime.Add(votingParams.VotingPeriod).Add(time.Second) + ctx = ctx.WithBlockTime(conversionTime) 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.False(t, proposal.IsExpedited) + require.Equal(t, conversionTime.Add(votingParams.VotingPeriod-votingParams.ExpeditedVotingPeriod), proposal.VotingEndTime) + require.True(t, proposal.VotingEndTime.After(conversionTime)) require.Len(t, app.GovKeeper.GetArchivedTallyVotes(ctx, proposal.ProposalId, true), 1002) regularVoter := make(sdk.AccAddress, 20) @@ -761,6 +844,87 @@ func TestEndBlockerKeepsExpeditedAndRegularTallyArchivesSeparate(t *testing.T) { require.Len(t, app.GovKeeper.GetArchivedTallyVotes(ctx, proposal.ProposalId, false), 1) } +func TestConvertedLegacyExpeditedProposalUsesDeadlineTally(t *testing.T) { + app := seiapp.Setup(t, false, false, false) + ctx := app.BaseApp.NewContext(false, tmproto.Header{}) + addrs := seiapp.AddTestAddrs(app, ctx, 2, valTokens) + + store := ctx.KVStore(app.GetKey(types.StoreKey)) + store.Delete(types.IncrementalTallyEnabledKey) + store.Delete(types.VoteDelegationBackfillCutoffKey) + store.Delete(types.DeadlineBoundaryBlockTimeKey) + + proposal, err := app.GovKeeper.SubmitProposalWithExpedite(ctx, TestExpeditedProposal, true) + require.NoError(t, err) + app.GovKeeper.ActivateVotingPeriod(ctx, proposal) + proposal, found := app.GovKeeper.GetProposal(ctx, proposal.ProposalId) + require.True(t, found) + + require.NoError(t, govkeeper.NewMigrator(app.GovKeeper).Migrate3to4(ctx)) + ctx = ctx.WithBlockTime(proposal.VotingEndTime) + gov.EndBlocker(ctx, app.GovKeeper) + gov.EndBlocker(ctx, app.GovKeeper) + + proposal, found = app.GovKeeper.GetProposal(ctx, proposal.ProposalId) + require.True(t, found) + require.False(t, proposal.IsExpedited) + require.True(t, app.GovKeeper.IsModernTallyRound(ctx, proposal.ProposalId)) + require.True(t, store.Has(types.ProposalDeadlineKey(proposal.ProposalId, proposal.VotingEndTime))) + + require.NoError(t, app.GovKeeper.AddVote( + ctx, + proposal.ProposalId, + addrs[0], + types.NewNonSplitVoteOption(types.OptionYes), + )) + + deadlineCtx := ctx.WithBlockTime(proposal.VotingEndTime) + app.GovKeeper.CaptureExactTallyBoundary(deadlineCtx) + require.ErrorIs(t, app.GovKeeper.AddVote( + deadlineCtx, + proposal.ProposalId, + addrs[1], + types.NewNonSplitVoteOption(types.OptionNo), + ), types.ErrInactiveProposal) +} + +func TestEndBlockerPreservesLegacyTallyBeforeActivation(t *testing.T) { + app := seiapp.Setup(t, false, false, false) + ctx := app.BaseApp.NewContext(false, tmproto.Header{}) + store := ctx.KVStore(app.GetKey(types.StoreKey)) + store.Delete(types.IncrementalTallyEnabledKey) + + proposal, err := app.GovKeeper.SubmitProposal(ctx, TestProposal) + require.NoError(t, err) + proposal.Status = types.StatusVotingPeriod + proposal.VotingEndTime = ctx.BlockTime() + app.GovKeeper.SetProposal(ctx, proposal) + app.GovKeeper.InsertActiveProposalQueue(ctx, proposal.ProposalId, proposal.VotingEndTime) + + for i := 0; i < gov.MaxVotesProcessedPerBlock+1; i++ { + voter := make(sdk.AccAddress, 20) + binary.BigEndian.PutUint64(voter[12:], uint64(i+1)) + require.NoError(t, app.GovKeeper.AddVote( + ctx, + proposal.ProposalId, + voter, + types.NewNonSplitVoteOption(types.OptionYes), + )) + } + + require.NotPanics(t, func() { + 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.Empty(t, app.GovKeeper.GetVotes(ctx, proposal.ProposalId)) + require.False(t, store.Has(types.TallyProgressKey(proposal.ProposalId))) + archivedVotes := sdk.KVStorePrefixIterator(store, types.TallyVotesKey(proposal.ProposalId, false)) + require.False(t, archivedVotes.Valid()) + require.NoError(t, archivedVotes.Close()) +} + // 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 81d5adff6e..a80f02a495 100644 --- a/sei-cosmos/x/gov/genesis.go +++ b/sei-cosmos/x/gov/genesis.go @@ -10,10 +10,15 @@ import ( // InitGenesis - store genesis parameters func InitGenesis(ctx sdk.Context, ak types.AccountKeeper, bk types.BankKeeper, k keeper.Keeper, data *types.GenesisState) { + k.EnableIncrementalTally(ctx) k.SetProposalID(ctx, data.StartingProposalId) k.SetDepositParams(ctx, data.DepositParams) k.SetVotingParams(ctx, data.VotingParams) k.SetTallyParams(ctx, data.TallyParams) + if data.VoteDelegationBackfillCutoff != 0 { + k.SetVoteDelegationBackfillCutoff(ctx, data.VoteDelegationBackfillCutoff) + } + k.InitializeDeadlineBoundaryClock(ctx) // check if the deposits pool account exists moduleAcc := k.GetGovernanceAccount(ctx) @@ -33,14 +38,36 @@ func InitGenesis(ctx sdk.Context, ak types.AccountKeeper, bk types.BankKeeper, k for _, snapshot := range data.VoteDelegationSnapshots { k.SetVoteDelegationSnapshot(ctx, snapshot) } + proposalsByID := make(map[uint64]types.Proposal, len(data.Proposals)) + for _, proposal := range data.Proposals { + proposalsByID[proposal.ProposalId] = proposal + } + for _, proposalID := range data.ModernTallyRoundProposalIds { + if _, found := proposalsByID[proposalID]; !found { + panic(fmt.Sprintf("modern tally round for proposal %d does not exist", proposalID)) + } + k.SetModernTallyRound(ctx, proposalID) + } + for _, electorate := range data.TallyElectorates { + proposal, found := proposalsByID[electorate.ProposalId] + if !found || proposal.Status != types.StatusVotingPeriod || proposal.VotingEndTime.After(ctx.BlockTime()) { + panic(fmt.Sprintf("tally electorate for proposal %d precedes its voting end time", electorate.ProposalId)) + } + k.SetTallyElectorate(ctx, electorate) + k.CompleteVoteDelegationBackfill(ctx, electorate.ProposalId) + } for _, proposal := range data.Proposals { switch proposal.Status { case types.StatusDepositPeriod: k.InsertInactiveProposalQueue(ctx, proposal.ProposalId, proposal.DepositEndTime) case types.StatusVotingPeriod: - k.InsertActiveProposalQueue(ctx, proposal.ProposalId, proposal.VotingEndTime) - if !proposal.VotingEndTime.After(ctx.BlockTime()) { + if k.IsModernTallyRound(ctx, proposal.ProposalId) { + k.InsertActiveProposalQueueForModernTallyRound(ctx, proposal.ProposalId, proposal.VotingEndTime) + } else { + k.InsertActiveProposalQueue(ctx, proposal.ProposalId, proposal.VotingEndTime) + } + if !proposal.VotingEndTime.After(ctx.BlockTime()) && !k.VoteDelegationBackfillRequired(ctx, proposal.ProposalId) { k.InitializeTally(ctx, proposal) } } @@ -62,6 +89,7 @@ func InitGenesis(ctx sdk.Context, ak types.AccountKeeper, bk types.BankKeeper, k // ExportGenesis - output genesis parameters func ExportGenesis(ctx sdk.Context, k keeper.Keeper) *types.GenesisState { startingProposalID, _ := k.GetProposalID(ctx) + voteDelegationBackfillCutoff, _ := k.GetVoteDelegationBackfillCutoff(ctx) depositParams := k.GetDepositParams(ctx) votingParams := k.GetVotingParams(ctx) tallyParams := k.GetTallyParams(ctx) @@ -70,6 +98,8 @@ func ExportGenesis(ctx sdk.Context, k keeper.Keeper) *types.GenesisState { var proposalsDeposits types.Deposits var proposalsVotes types.Votes voteDelegationSnapshots := make([]types.VoteDelegationSnapshot, 0, len(proposals)) + tallyElectorates := make([]types.TallyElectorate, 0, len(proposals)) + modernTallyRoundProposalIDs := make([]uint64, 0, len(proposals)) for _, proposal := range proposals { deposits := k.GetDeposits(ctx, proposal.ProposalId) proposalsDeposits = append(proposalsDeposits, deposits...) @@ -77,16 +107,25 @@ func ExportGenesis(ctx sdk.Context, k keeper.Keeper) *types.GenesisState { votes := k.GetVotes(ctx, proposal.ProposalId) proposalsVotes = append(proposalsVotes, votes...) voteDelegationSnapshots = append(voteDelegationSnapshots, k.GetVoteDelegationSnapshots(ctx, proposal)...) + if electorate, found := k.ExportTallyElectorate(ctx, proposal); found { + tallyElectorates = append(tallyElectorates, electorate) + } + if k.IsModernTallyRound(ctx, proposal.ProposalId) { + modernTallyRoundProposalIDs = append(modernTallyRoundProposalIDs, proposal.ProposalId) + } } return &types.GenesisState{ - StartingProposalId: startingProposalID, - Deposits: proposalsDeposits, - Votes: proposalsVotes, - Proposals: proposals, - DepositParams: depositParams, - VotingParams: votingParams, - TallyParams: tallyParams, - VoteDelegationSnapshots: voteDelegationSnapshots, + StartingProposalId: startingProposalID, + Deposits: proposalsDeposits, + Votes: proposalsVotes, + Proposals: proposals, + DepositParams: depositParams, + VotingParams: votingParams, + TallyParams: tallyParams, + VoteDelegationSnapshots: voteDelegationSnapshots, + TallyElectorates: tallyElectorates, + VoteDelegationBackfillCutoff: voteDelegationBackfillCutoff, + ModernTallyRoundProposalIds: modernTallyRoundProposalIDs, } } diff --git a/sei-cosmos/x/gov/genesis_test.go b/sei-cosmos/x/gov/genesis_test.go index b4ad98f7cc..3e7bd4ff56 100644 --- a/sei-cosmos/x/gov/genesis_test.go +++ b/sei-cosmos/x/gov/genesis_test.go @@ -5,6 +5,7 @@ import ( "encoding/binary" "encoding/json" "testing" + "time" abci "github.com/sei-protocol/sei-chain/sei-tendermint/abci/types" tmproto "github.com/sei-protocol/sei-chain/sei-tendermint/proto/tendermint/types" @@ -17,7 +18,10 @@ import ( authtypes "github.com/sei-protocol/sei-chain/sei-cosmos/x/auth/types" banktypes "github.com/sei-protocol/sei-chain/sei-cosmos/x/bank/types" "github.com/sei-protocol/sei-chain/sei-cosmos/x/gov" + govkeeper "github.com/sei-protocol/sei-chain/sei-cosmos/x/gov/keeper" "github.com/sei-protocol/sei-chain/sei-cosmos/x/gov/types" + "github.com/sei-protocol/sei-chain/sei-cosmos/x/staking" + stakingtypes "github.com/sei-protocol/sei-chain/sei-cosmos/x/staking/types" ) func TestImportExportQueues(t *testing.T) { @@ -173,6 +177,19 @@ func TestEqualProposals(t *testing.T) { func TestExportGenesisIncludesVotesFromUnfinishedTally(t *testing.T) { app := seiapp.Setup(t, false, false, false) ctx := app.BaseApp.NewContext(false, tmproto.Header{}) + addrs := seiapp.AddTestAddrs(app, ctx, 3, valTokens) + SortAddresses(addrs) + stakingParams := app.StakingKeeper.GetParams(ctx) + stakingParams.MinCommissionRate = sdk.ZeroDec() + app.StakingKeeper.SetParams(ctx, stakingParams) + createValidators( + t, + staking.NewHandler(app.StakingKeeper), + ctx, + seiapp.ConvertAddrsToValAddrs(addrs), + []int64{6, 3, 1}, + ) + staking.EndBlocker(ctx, app.StakingKeeper) proposal, err := app.GovKeeper.SubmitProposal(ctx, TestProposal) require.NoError(t, err) @@ -180,39 +197,75 @@ func TestExportGenesisIncludesVotesFromUnfinishedTally(t *testing.T) { 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)) + for i, option := range []types.VoteOption{types.OptionYes, types.OptionNo, types.OptionAbstain} { require.NoError(t, app.GovKeeper.AddVote( ctx, proposal.ProposalId, - addr, - types.NewNonSplitVoteOption(types.OptionYes), + addrs[i], + types.NewNonSplitVoteOption(option), )) } + ctx = ctx.WithBlockTime(proposal.VotingEndTime) + app.GovKeeper.CaptureExactTallyBoundary(ctx) complete, processed, _, _, _ := app.GovKeeper.TallyIncremental(ctx, proposal, 1) require.False(t, complete) require.Equal(t, 1, processed) + validator, found := app.StakingKeeper.GetValidator(ctx, seiapp.ConvertAddrsToValAddrs(addrs)[0]) + require.True(t, found) + _, err = app.StakingKeeper.Delegate( + ctx, + addrs[0], + app.StakingKeeper.TokensFromConsensusPower(ctx, 20), + stakingtypes.Unbonded, + validator, + true, + ) + require.NoError(t, err) + mutatedTallyParams := app.GovKeeper.GetTallyParams(ctx) + mutatedTallyParams.Threshold = sdk.MustNewDecFromStr("0.90") + mutatedTallyParams.ExpeditedThreshold = sdk.MustNewDecFromStr("0.95") + app.GovKeeper.SetTallyParams(ctx, mutatedTallyParams) + genesis := gov.ExportGenesis(ctx, app.GovKeeper) require.Len(t, genesis.Votes, 3) require.Len(t, genesis.VoteDelegationSnapshots, 3) + require.Len(t, genesis.TallyElectorates, 1) genesisJSON := app.AppCodec().MustMarshalJSON(genesis) var decodedGenesis types.GenesisState app.AppCodec().MustUnmarshalJSON(genesisJSON, &decodedGenesis) require.True(t, genesis.Equal(decodedGenesis)) genesis = &decodedGenesis - importedApp := seiapp.Setup(t, false, false, false) - importedCtx := importedApp.BaseApp.NewContext(false, tmproto.Header{}).WithBlockTime(proposal.VotingEndTime) - gov.InitGenesis( - importedCtx, - importedApp.AccountKeeper, - importedApp.BankKeeper, - importedApp.GovKeeper, - genesis, - ) + authGenesis := auth.ExportGenesis(ctx, app.AccountKeeper) + bankGenesis := app.BankKeeper.ExportGenesis(ctx) + stakingGenesis := staking.ExportGenesis(ctx, app.StakingKeeper) + appGenesis := seiapp.NewDefaultGenesisState(app.AppCodec()) + appGenesis[authtypes.ModuleName] = app.AppCodec().MustMarshalJSON(authGenesis) + appGenesis[banktypes.ModuleName] = app.AppCodec().MustMarshalJSON(bankGenesis) + appGenesis[stakingtypes.ModuleName] = app.AppCodec().MustMarshalJSON(stakingGenesis) + appGenesis[types.ModuleName] = app.AppCodec().MustMarshalJSON(genesis) + stateBytes, err := json.MarshalIndent(appGenesis, "", " ") + require.NoError(t, err) + + complete, processed, sourcePasses, sourceBurnDeposits, sourceTallyResult := app.GovKeeper.TallyIncremental(ctx, proposal, 2) + require.True(t, complete) + require.Equal(t, 2, processed) + require.True(t, sourcePasses) + require.False(t, sourceBurnDeposits) + require.False(t, sourceTallyResult.Equals(types.EmptyTallyResult())) + + db := dbm.NewMemDB() + importedApp := seiapp.SetupWithDB(t, db, true, false, false) + _, err = importedApp.InitChain(&abci.RequestInitChain{ + ConsensusParams: seiapp.DefaultConsensusParams, + AppStateBytes: stateBytes, + Time: proposal.VotingEndTime, + }) + require.NoError(t, err) + importedApp.Commit(context.Background()) + importedCtx := importedApp.BaseApp.NewUncachedContext(false, tmproto.Header{Time: proposal.VotingEndTime}) require.True(t, importedApp.GovKeeper.IsTallying(importedCtx, proposal.ProposalId)) require.Len(t, importedApp.GovKeeper.GetVotes(importedCtx, proposal.ProposalId), 3) @@ -226,4 +279,226 @@ func TestExportGenesisIncludesVotesFromUnfinishedTally(t *testing.T) { types.NewNonSplitVoteOption(types.OptionNo), ) require.ErrorIs(t, err, types.ErrInactiveProposal) + + complete, processed, importedPasses, importedBurnDeposits, importedTallyResult := importedApp.GovKeeper.TallyIncremental( + importedCtx, + proposal, + 3, + ) + require.True(t, complete) + require.Equal(t, 3, processed) + require.Equal(t, sourcePasses, importedPasses) + require.Equal(t, sourceBurnDeposits, importedBurnDeposits) + require.True(t, sourceTallyResult.Equals(importedTallyResult)) +} + +func TestImportExportPreservesUnresolvedLegacyTally(t *testing.T) { + app := seiapp.Setup(t, false, false, false) + ctx := app.BaseApp.NewContext(false, tmproto.Header{}) + voter := seiapp.AddTestAddrs(app, ctx, 1, valTokens)[0] + + 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) + require.NoError(t, app.GovKeeper.AddVote( + ctx, + proposal.ProposalId, + voter, + types.NewNonSplitVoteOption(types.OptionYes), + )) + + store := ctx.KVStore(app.GetKey(types.StoreKey)) + store.Delete(types.VoteDelegationsKey(proposal.ProposalId, voter)) + store.Delete(types.VoterProposalsKey(voter, proposal.ProposalId)) + require.NoError(t, govkeeper.NewMigrator(app.GovKeeper).Migrate3to4(ctx)) + + ctx = ctx.WithBlockTime(proposal.VotingEndTime.Add(time.Second)) + genesis := gov.ExportGenesis(ctx, app.GovKeeper) + require.Equal(t, uint64(2), genesis.VoteDelegationBackfillCutoff) + require.Len(t, genesis.TallyElectorates, 1) + genesisJSON := app.AppCodec().MustMarshalJSON(genesis) + var decodedGenesis types.GenesisState + app.AppCodec().MustUnmarshalJSON(genesisJSON, &decodedGenesis) + require.Equal(t, genesis.VoteDelegationBackfillCutoff, decodedGenesis.VoteDelegationBackfillCutoff) + + authGenesis := auth.ExportGenesis(ctx, app.AccountKeeper) + bankGenesis := app.BankKeeper.ExportGenesis(ctx) + stakingGenesis := staking.ExportGenesis(ctx, app.StakingKeeper) + appGenesis := seiapp.NewDefaultGenesisState(app.AppCodec()) + appGenesis[authtypes.ModuleName] = app.AppCodec().MustMarshalJSON(authGenesis) + appGenesis[banktypes.ModuleName] = app.AppCodec().MustMarshalJSON(bankGenesis) + appGenesis[stakingtypes.ModuleName] = app.AppCodec().MustMarshalJSON(stakingGenesis) + appGenesis[types.ModuleName] = genesisJSON + stateBytes, err := json.Marshal(appGenesis) + require.NoError(t, err) + + importedApp := seiapp.SetupWithDB(t, dbm.NewMemDB(), false, false, false) + _, err = importedApp.InitChain(&abci.RequestInitChain{ + ConsensusParams: seiapp.DefaultConsensusParams, + AppStateBytes: stateBytes, + Time: ctx.BlockTime(), + }) + require.NoError(t, err) + importedApp.Commit(context.Background()) + importedCtx := importedApp.BaseApp.NewUncachedContext(false, tmproto.Header{Time: ctx.BlockTime()}) + + cutoff, found := importedApp.GovKeeper.GetVoteDelegationBackfillCutoff(importedCtx) + require.True(t, found) + require.Equal(t, uint64(2), cutoff) + require.False(t, importedApp.GovKeeper.VoteDelegationBackfillRequired(importedCtx, proposal.ProposalId)) + require.True(t, importedApp.GovKeeper.IsTallying(importedCtx, proposal.ProposalId)) + require.False(t, importedCtx.KVStore(importedApp.GetKey(types.StoreKey)).Has(types.ProposalDeadlineKey(proposal.ProposalId, proposal.VotingEndTime))) +} + +func TestImportExportPreservesModernTallyRound(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) + proposal.Status = types.StatusVotingPeriod + proposal.VotingEndTime = ctx.BlockTime().Add(time.Hour) + app.GovKeeper.SetProposal(ctx, proposal) + app.GovKeeper.SetVoteDelegationBackfillCutoff(ctx, proposal.ProposalId+1) + app.GovKeeper.InsertActiveProposalQueueForModernTallyRound(ctx, proposal.ProposalId, proposal.VotingEndTime) + + genesis := gov.ExportGenesis(ctx, app.GovKeeper) + require.Equal(t, []uint64{proposal.ProposalId}, genesis.ModernTallyRoundProposalIds) + + importedApp := seiapp.Setup(t, false, false, false) + importedCtx := importedApp.BaseApp.NewContext(false, tmproto.Header{Time: ctx.BlockTime()}) + gov.InitGenesis(importedCtx, importedApp.AccountKeeper, importedApp.BankKeeper, importedApp.GovKeeper, genesis) + + store := importedCtx.KVStore(importedApp.GetKey(types.StoreKey)) + require.True(t, importedApp.GovKeeper.IsModernTallyRound(importedCtx, proposal.ProposalId)) + require.True(t, store.Has(types.ProposalDeadlineKey(proposal.ProposalId, proposal.VotingEndTime))) + require.False(t, importedApp.GovKeeper.VoteDelegationBackfillRequired(importedCtx, proposal.ProposalId)) +} + +func TestImportExportCanonicalizesPartialLegacyVoteDelegationBackfill(t *testing.T) { + app := seiapp.Setup(t, false, false, false) + ctx := app.BaseApp.NewContext(false, tmproto.Header{}) + addrs := seiapp.AddTestAddrs(app, ctx, 2, valTokens) + SortAddresses(addrs) + stakingParams := app.StakingKeeper.GetParams(ctx) + stakingParams.MinCommissionRate = sdk.ZeroDec() + app.StakingKeeper.SetParams(ctx, stakingParams) + valAddrs := seiapp.ConvertAddrsToValAddrs(addrs) + createValidators(t, staking.NewHandler(app.StakingKeeper), ctx, valAddrs[:1], []int64{5}) + staking.EndBlocker(ctx, app.StakingKeeper) + + voter := addrs[1] + validator, found := app.StakingKeeper.GetValidator(ctx, valAddrs[0]) + require.True(t, found) + delegatedTokens := app.StakingKeeper.TokensFromConsensusPower(ctx, 10) + _, err := app.StakingKeeper.Delegate(ctx, voter, delegatedTokens, stakingtypes.Unbonded, validator, true) + require.NoError(t, err) + + 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) + + voters := make([]sdk.AccAddress, gov.MaxVotesProcessedPerBlock+1) + voters[0] = voter + for i := 1; i < len(voters); i++ { + voters[i] = make(sdk.AccAddress, 20) + binary.BigEndian.PutUint64(voters[i][12:], uint64(i)) + } + for i, voteVoter := range voters { + option := types.OptionNo + if i == 0 { + option = types.OptionYes + } + require.NoError(t, app.GovKeeper.AddVote( + ctx, + proposal.ProposalId, + voteVoter, + types.NewNonSplitVoteOption(option), + )) + } + + store := ctx.KVStore(app.GetKey(types.StoreKey)) + for _, voteVoter := range voters { + store.Delete(types.VoteDelegationsKey(proposal.ProposalId, voteVoter)) + store.Delete(types.VoterProposalsKey(voteVoter, proposal.ProposalId)) + store.Delete(types.VoteDelegationSnapshotRevisionKey(proposal.ProposalId, voteVoter)) + } + require.NoError(t, govkeeper.NewMigrator(app.GovKeeper).Migrate3to4(ctx)) + + ctx = ctx.WithBlockTime(proposal.VotingEndTime.Add(time.Second)) + complete, processed, _, _, _ := app.GovKeeper.TallyIncremental( + ctx, + proposal, + gov.MaxVotesProcessedPerBlock, + ) + require.False(t, complete) + require.Equal(t, gov.MaxVotesProcessedPerBlock, processed) + require.True(t, app.GovKeeper.IsVoteDelegationBackfillInProgress(ctx, proposal.ProposalId)) + + expectedPasses, expectedBurnDeposits, expectedTallyResult := app.GovKeeper.Tally(ctx, proposal) + genesis := gov.ExportGenesis(ctx, app.GovKeeper) + require.Len(t, genesis.Votes, gov.MaxVotesProcessedPerBlock+1) + require.Len(t, genesis.VoteDelegationSnapshots, gov.MaxVotesProcessedPerBlock+1) + require.Len(t, genesis.TallyElectorates, 1) + + authGenesis := auth.ExportGenesis(ctx, app.AccountKeeper) + bankGenesis := app.BankKeeper.ExportGenesis(ctx) + stakingGenesis := staking.ExportGenesis(ctx, app.StakingKeeper) + appGenesis := seiapp.NewDefaultGenesisState(app.AppCodec()) + appGenesis[authtypes.ModuleName] = app.AppCodec().MustMarshalJSON(authGenesis) + appGenesis[banktypes.ModuleName] = app.AppCodec().MustMarshalJSON(bankGenesis) + appGenesis[stakingtypes.ModuleName] = app.AppCodec().MustMarshalJSON(stakingGenesis) + appGenesis[types.ModuleName] = app.AppCodec().MustMarshalJSON(genesis) + stateBytes, err := json.Marshal(appGenesis) + require.NoError(t, err) + + importedApp := seiapp.SetupWithDB(t, dbm.NewMemDB(), false, false, false) + _, err = importedApp.InitChain(&abci.RequestInitChain{ + ConsensusParams: seiapp.DefaultConsensusParams, + AppStateBytes: stateBytes, + Time: ctx.BlockTime(), + }) + require.NoError(t, err) + importedApp.Commit(context.Background()) + importedCtx := importedApp.BaseApp.NewUncachedContext(false, tmproto.Header{Time: ctx.BlockTime()}) + importedProposal, found := importedApp.GovKeeper.GetProposal(importedCtx, proposal.ProposalId) + require.True(t, found) + require.False(t, importedApp.GovKeeper.VoteDelegationBackfillRequired(importedCtx, proposal.ProposalId)) + require.True(t, importedApp.GovKeeper.IsTallying(importedCtx, proposal.ProposalId)) + + delegation, found := importedApp.StakingKeeper.GetDelegation(importedCtx, voter, valAddrs[0]) + require.True(t, found) + delegation.Shares = delegation.Shares.Add(delegatedTokens.ToDec()) + importedApp.StakingKeeper.SetDelegation(importedCtx, delegation) + importedApp.GovKeeper.StakingHooks().AfterDelegationModified(importedCtx, voter, valAddrs[0]) + importedPasses, importedBurnDeposits, importedTallyResult := importedApp.GovKeeper.Tally(importedCtx, importedProposal) + require.Equal(t, expectedPasses, importedPasses) + require.Equal(t, expectedBurnDeposits, importedBurnDeposits) + require.True(t, expectedTallyResult.Equals(importedTallyResult)) +} + +func TestInitGenesisRejectsFutureTallyElectorate(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) + + genesis := gov.ExportGenesis(ctx, app.GovKeeper) + genesis.TallyElectorates = []types.TallyElectorate{{ + ProposalId: proposal.ProposalId, + TotalBondedTokens: sdk.ZeroInt(), + TallyParams: types.DefaultTallyParams(), + TallyValidators: []types.TallyValidator{}, + }} + + importedApp := seiapp.Setup(t, false, false, false) + importedCtx := importedApp.BaseApp.NewContext(false, tmproto.Header{}) + require.Panics(t, func() { + gov.InitGenesis(importedCtx, importedApp.AccountKeeper, importedApp.BankKeeper, importedApp.GovKeeper, genesis) + }) } diff --git a/sei-cosmos/x/gov/keeper/delegation_updates.go b/sei-cosmos/x/gov/keeper/delegation_updates.go new file mode 100644 index 0000000000..44204ca567 --- /dev/null +++ b/sei-cosmos/x/gov/keeper/delegation_updates.go @@ -0,0 +1,346 @@ +package keeper + +import ( + "encoding/binary" + "encoding/json" + "fmt" + "math" + "time" + + sdk "github.com/sei-protocol/sei-chain/sei-cosmos/types" + "github.com/sei-protocol/sei-chain/sei-cosmos/x/gov/types" +) + +type pendingVoteDelegationUpdate struct { + Voter string `json:"voter"` + Validator string `json:"validator"` + Shares sdk.Dec `json:"shares"` + BlockTime time.Time `json:"block_time"` + Cursor []byte `json:"cursor,omitempty"` +} + +// QueueVoteDelegationUpdate defers one slash-induced delegation change for bounded processing. +func (keeper Keeper) QueueVoteDelegationUpdate( + ctx sdk.Context, + voter sdk.AccAddress, + validator sdk.ValAddress, + shares sdk.Dec, +) { + if !keeper.IncrementalTallyEnabled(ctx) || !keeper.voterHasTrackedProposals(ctx, voter) { + return + } + + sequence := keeper.nextVoteDelegationUpdateSequence(ctx) + update := pendingVoteDelegationUpdate{ + Voter: voter.String(), + Validator: validator.String(), + Shares: shares, + BlockTime: ctx.BlockTime(), + } + store := ctx.KVStore(keeper.storeKey) + store.Set(types.VoteDelegationUpdateKey(sequence), marshalVoteDelegationUpdate(update)) + store.Set(types.VoterVoteDelegationUpdateKey(voter, sequence), []byte{1}) +} + +// ProcessVoteDelegationUpdates applies at most maxUpdates deferred snapshot updates. +func (keeper Keeper) ProcessVoteDelegationUpdates(ctx sdk.Context, maxUpdates int) (complete bool, processed int) { + return keeper.ProcessVoteDelegationUpdatesThrough(ctx, maxUpdates, math.MaxUint64) +} + +// ProcessVoteDelegationUpdatesThrough applies deferred snapshot updates through a sequence. +func (keeper Keeper) ProcessVoteDelegationUpdatesThrough( + ctx sdk.Context, + maxUpdates int, + throughSequence uint64, +) (complete bool, processed int) { + if maxUpdates < 0 { + panic("maximum vote delegation updates cannot be negative") + } + if !keeper.IncrementalTallyEnabled(ctx) { + return true, 0 + } + + store := ctx.KVStore(keeper.storeKey) + iterator := sdk.KVStorePrefixIterator(store, types.VoteDelegationUpdatesKeyPrefix) + defer func() { _ = iterator.Close() }() + + for ; iterator.Valid() && processed < maxUpdates; iterator.Next() { + key := append([]byte(nil), iterator.Key()...) + sequence := voteDelegationUpdateSequenceFromKey(key) + if sequence > throughSequence { + return true, processed + } + update := unmarshalVoteDelegationUpdate(iterator.Value()) + var updateComplete bool + updateComplete, processed = keeper.processVoteDelegationUpdate( + ctx, + sequence, + update, + maxUpdates, + processed, + ) + if !updateComplete { + return false, processed + } + store.Delete(key) + voter := sdk.MustAccAddressFromBech32(update.Voter) + store.Delete(types.VoterVoteDelegationUpdateKey(voter, sequence)) + } + + return !iterator.Valid() || voteDelegationUpdateSequenceFromKey(iterator.Key()) > throughSequence, processed +} + +// HasPendingVoteDelegationUpdates reports whether slash-induced snapshot work remains. +func (keeper Keeper) HasPendingVoteDelegationUpdates(ctx sdk.Context) bool { + if !keeper.IncrementalTallyEnabled(ctx) { + return false + } + iterator := sdk.KVStorePrefixIterator(ctx.KVStore(keeper.storeKey), types.VoteDelegationUpdatesKeyPrefix) + defer func() { _ = iterator.Close() }() + return iterator.Valid() +} + +func (keeper Keeper) voterHasTrackedProposals(ctx sdk.Context, voter sdk.AccAddress) bool { + iterator := sdk.KVStorePrefixIterator( + ctx.KVStore(keeper.storeKey), + types.VoterProposalsKeyPrefixForAddress(voter), + ) + defer func() { _ = iterator.Close() }() + return iterator.Valid() +} + +func (keeper Keeper) nextVoteDelegationUpdateSequence(ctx sdk.Context) uint64 { + store := ctx.KVStore(keeper.storeKey) + sequence := decodeVoteDelegationUpdateSequence(store.Get(types.VoteDelegationUpdateSequenceKey)) + if sequence == math.MaxUint64 { + panic("vote delegation update sequence overflow") + } + sequence++ + store.Set(types.VoteDelegationUpdateSequenceKey, types.GetProposalIDBytes(sequence)) + return sequence +} + +func (keeper Keeper) processVoteDelegationUpdate( + ctx sdk.Context, + sequence uint64, + update pendingVoteDelegationUpdate, + maxUpdates int, + processed int, +) (complete bool, newProcessed int) { + store := ctx.KVStore(keeper.storeKey) + voter := sdk.MustAccAddressFromBech32(update.Voter) + prefix := types.VoterProposalsKeyPrefixForAddress(voter) + start := prefix + if len(update.Cursor) != 0 { + start = sdk.PrefixEndBytes(append(append([]byte(nil), prefix...), update.Cursor...)) + } + iterator := store.Iterator(start, sdk.PrefixEndBytes(prefix)) + defer func() { _ = iterator.Close() }() + + newProcessed = processed + for ; iterator.Valid() && newProcessed < maxUpdates; iterator.Next() { + proposalIDBytes := iterator.Key()[len(prefix):] + if len(proposalIDBytes) != 8 { + panic(fmt.Sprintf("invalid voter proposal key length %d", len(iterator.Key()))) + } + proposalID := types.GetProposalIDFromBytes(proposalIDBytes) + keeper.applyVoteDelegationUpdate(ctx, proposalID, sequence, update) + update.Cursor = append(update.Cursor[:0], proposalIDBytes...) + newProcessed++ + } + + if iterator.Valid() { + store.Set(types.VoteDelegationUpdateKey(sequence), marshalVoteDelegationUpdate(update)) + return false, newProcessed + } + if newProcessed == processed { + newProcessed++ + } + return true, newProcessed +} + +func (keeper Keeper) applyVoteDelegationUpdate( + ctx sdk.Context, + proposalID uint64, + sequence uint64, + update pendingVoteDelegationUpdate, +) { + voter := sdk.MustAccAddressFromBech32(update.Voter) + if keeper.voteDelegationSnapshotRevision(ctx, proposalID, voter) >= sequence { + return + } + + proposal, found := keeper.GetProposal(ctx, proposalID) + if found && keeper.delegationUpdateBelongsToTallyBoundary(ctx, proposal, sequence, update.BlockTime) { + store := ctx.KVStore(keeper.storeKey) + snapshotKey := types.VoteDelegationsKey(proposalID, voter) + bz := store.Get(snapshotKey) + if bz == nil { + panic(fmt.Sprintf("missing delegation snapshot for proposal %d voter %s", proposalID, voter)) + } + snapshot := keeper.unmarshalVoteDelegations(bz) + index := newVoteDelegationSnapshotIndex(snapshot) + index.set(update.Validator, update.Shares) + keeper.storeVoteDelegationSnapshot(ctx, index.snapshot(), sequence) + return + } + keeper.setVoteDelegationSnapshotRevision(ctx, proposalID, voter, sequence) +} + +func (keeper Keeper) applyVoteDelegationSnapshotUpdates( + ctx sdk.Context, + proposalID uint64, + voter sdk.AccAddress, + snapshot types.VoteDelegationSnapshot, +) types.VoteDelegationSnapshot { + index := newVoteDelegationSnapshotIndex(snapshot) + + proposal, found := keeper.GetProposal(ctx, proposalID) + if !found { + return index.snapshot() + } + + revision := keeper.voteDelegationSnapshotRevision(ctx, proposalID, voter) + prefix := types.VoterVoteDelegationUpdatesKeyPrefixForAddress(voter) + start := prefix + if revision != 0 { + start = sdk.PrefixEndBytes(append(append([]byte(nil), prefix...), types.GetProposalIDBytes(revision)...)) + } + store := ctx.KVStore(keeper.storeKey) + iterator := store.Iterator(start, sdk.PrefixEndBytes(prefix)) + defer func() { _ = iterator.Close() }() + for ; iterator.Valid(); iterator.Next() { + sequence := voteDelegationUpdateSequenceFromVoterKey(iterator.Key(), len(prefix)) + bz := store.Get(types.VoteDelegationUpdateKey(sequence)) + if bz == nil { + panic(fmt.Sprintf("missing vote delegation update %d", sequence)) + } + update := unmarshalVoteDelegationUpdate(bz) + if keeper.delegationUpdateBelongsToTallyBoundary(ctx, proposal, sequence, update.BlockTime) { + index.set(update.Validator, update.Shares) + } + } + return index.snapshot() +} + +func (keeper Keeper) delegationUpdateBelongsToTallyBoundary( + ctx sdk.Context, + proposal types.Proposal, + sequence uint64, + updateTime time.Time, +) bool { + if boundarySequence, found := keeper.proposalTallyBoundarySequence(ctx, proposal); found { + return sequence <= boundarySequence + } + if keeper.usesLegacyTallySemantics(ctx, proposal) { + return !keeper.IsTallying(ctx, proposal.ProposalId) + } + return !proposal.VotingEndTime.Before(updateTime) && !keeper.IsTallying(ctx, proposal.ProposalId) +} + +func (keeper Keeper) deleteVoteDelegationSnapshotRevision(ctx sdk.Context, proposalID uint64, voter sdk.AccAddress) { + ctx.KVStore(keeper.storeKey).Delete(types.VoteDelegationSnapshotRevisionKey(proposalID, voter)) +} + +func (keeper Keeper) voteDelegationUpdateSequence(ctx sdk.Context) uint64 { + return decodeVoteDelegationUpdateSequence( + ctx.KVStore(keeper.storeKey).Get(types.VoteDelegationUpdateSequenceKey), + ) +} + +func (keeper Keeper) voteDelegationSnapshotRevision(ctx sdk.Context, proposalID uint64, voter sdk.AccAddress) uint64 { + return decodeVoteDelegationUpdateSequence( + ctx.KVStore(keeper.storeKey).Get(types.VoteDelegationSnapshotRevisionKey(proposalID, voter)), + ) +} + +func (keeper Keeper) setVoteDelegationSnapshotRevision( + ctx sdk.Context, + proposalID uint64, + voter sdk.AccAddress, + revision uint64, +) { + ctx.KVStore(keeper.storeKey).Set( + types.VoteDelegationSnapshotRevisionKey(proposalID, voter), + types.GetProposalIDBytes(revision), + ) +} + +func marshalVoteDelegationUpdate(update pendingVoteDelegationUpdate) []byte { + bz, err := json.Marshal(update) + if err != nil { + panic(fmt.Errorf("marshal vote delegation update: %w", err)) + } + return bz +} + +func unmarshalVoteDelegationUpdate(bz []byte) pendingVoteDelegationUpdate { + var update pendingVoteDelegationUpdate + if err := json.Unmarshal(bz, &update); err != nil { + panic(fmt.Errorf("unmarshal vote delegation update: %w", err)) + } + return update +} + +func decodeVoteDelegationUpdateSequence(bz []byte) uint64 { + if bz == nil { + return 0 + } + if len(bz) != 8 { + panic(fmt.Sprintf("invalid vote delegation update sequence length %d", len(bz))) + } + return binary.BigEndian.Uint64(bz) +} + +func voteDelegationUpdateSequenceFromKey(key []byte) uint64 { + if len(key) != len(types.VoteDelegationUpdatesKeyPrefix)+8 { + panic(fmt.Sprintf("invalid vote delegation update key length %d", len(key))) + } + return binary.BigEndian.Uint64(key[len(types.VoteDelegationUpdatesKeyPrefix):]) +} + +func voteDelegationUpdateSequenceFromVoterKey(key []byte, prefixLength int) uint64 { + if len(key) != prefixLength+8 { + panic(fmt.Sprintf("invalid voter vote delegation update key length %d", len(key))) + } + return binary.BigEndian.Uint64(key[prefixLength:]) +} + +type voteDelegationSnapshotIndex struct { + value types.VoteDelegationSnapshot + positions map[string]int +} + +func newVoteDelegationSnapshotIndex(snapshot types.VoteDelegationSnapshot) *voteDelegationSnapshotIndex { + positions := make(map[string]int, len(snapshot.Delegations)) + for i, delegation := range snapshot.Delegations { + positions[delegation.Validator] = i + } + return &voteDelegationSnapshotIndex{value: snapshot, positions: positions} +} + +func (index *voteDelegationSnapshotIndex) set(validator string, shares sdk.Dec) { + if position, found := index.positions[validator]; found { + index.value.Delegations[position].Shares = shares + return + } + if shares.IsZero() { + return + } + index.positions[validator] = len(index.value.Delegations) + index.value.Delegations = append(index.value.Delegations, types.VoteDelegation{ + Validator: validator, + Shares: shares, + }) +} + +func (index *voteDelegationSnapshotIndex) snapshot() types.VoteDelegationSnapshot { + delegations := index.value.Delegations[:0] + for _, delegation := range index.value.Delegations { + if !delegation.Shares.IsZero() { + delegations = append(delegations, delegation) + } + } + index.value.Delegations = delegations + return index.value +} diff --git a/sei-cosmos/x/gov/keeper/delegation_updates_test.go b/sei-cosmos/x/gov/keeper/delegation_updates_test.go new file mode 100644 index 0000000000..da176cf488 --- /dev/null +++ b/sei-cosmos/x/gov/keeper/delegation_updates_test.go @@ -0,0 +1,393 @@ +package keeper_test + +import ( + "testing" + "time" + + tmproto "github.com/sei-protocol/sei-chain/sei-tendermint/proto/tendermint/types" + "github.com/stretchr/testify/require" + + seiapp "github.com/sei-protocol/sei-chain/app" + sdk "github.com/sei-protocol/sei-chain/sei-cosmos/types" + gov "github.com/sei-protocol/sei-chain/sei-cosmos/x/gov" + govtypes "github.com/sei-protocol/sei-chain/sei-cosmos/x/gov/types" + stakingtypes "github.com/sei-protocol/sei-chain/sei-cosmos/x/staking/types" +) + +func TestSlashDelegationUpdatesAreDeferredAndBounded(t *testing.T) { + app := seiapp.Setup(t, false, false, false) + ctx := app.BaseApp.NewContext(false, tmproto.Header{Time: time.Unix(100, 0)}) + addrs, valAddrs := createValidators(t, ctx, app, []int64{5, 5, 5}) + + proposals := make([]govtypes.Proposal, 2) + for i := range proposals { + proposal, err := app.GovKeeper.SubmitProposal(ctx, TestProposal) + require.NoError(t, err) + proposal.Status = govtypes.StatusVotingPeriod + proposal.VotingEndTime = ctx.BlockTime().Add(time.Hour) + app.GovKeeper.SetProposal(ctx, proposal) + require.NoError(t, app.GovKeeper.AddVote( + ctx, + proposal.ProposalId, + addrs[0], + govtypes.NewNonSplitVoteOption(govtypes.OptionYes), + )) + proposals[i] = proposal + } + + store := ctx.KVStore(app.GetKey(govtypes.StoreKey)) + firstSnapshotKey := govtypes.VoteDelegationsKey(proposals[0].ProposalId, addrs[0]) + secondSnapshotKey := govtypes.VoteDelegationsKey(proposals[1].ProposalId, addrs[0]) + firstSnapshotBefore := append([]byte(nil), store.Get(firstSnapshotKey)...) + secondSnapshotBefore := append([]byte(nil), store.Get(secondSnapshotKey)...) + + delegation, found := app.StakingKeeper.GetDelegation(ctx, addrs[0], valAddrs[0]) + require.True(t, found) + updatedShares := delegation.Shares.Sub(sdk.OneDec()) + delegation.Shares = updatedShares + app.StakingKeeper.SetDelegation(ctx, delegation) + + slashCtx := stakingtypes.WithSlashDelegationModification(ctx) + app.GovKeeper.StakingHooks().AfterDelegationModified(slashCtx, addrs[0], valAddrs[0]) + + require.True(t, app.GovKeeper.HasPendingVoteDelegationUpdates(ctx)) + require.Equal(t, firstSnapshotBefore, store.Get(firstSnapshotKey)) + require.Equal(t, secondSnapshotBefore, store.Get(secondSnapshotKey)) + requireVoteDelegationShares(t, app.GovKeeper.GetVoteDelegationSnapshots(ctx, proposals[0]), valAddrs[0], updatedShares) + requireVoteDelegationShares(t, app.GovKeeper.GetVoteDelegationSnapshots(ctx, proposals[1]), valAddrs[0], updatedShares) + + complete, processed := app.GovKeeper.ProcessVoteDelegationUpdates(ctx, 1) + require.False(t, complete) + require.Equal(t, 1, processed) + require.True(t, app.GovKeeper.HasPendingVoteDelegationUpdates(ctx)) + require.NotEqual(t, firstSnapshotBefore, store.Get(firstSnapshotKey)) + require.Equal(t, secondSnapshotBefore, store.Get(secondSnapshotKey)) + requireVoteDelegationShares( + t, + []govtypes.VoteDelegationSnapshot{decodeVoteDelegationSnapshot(t, store.Get(firstSnapshotKey))}, + valAddrs[0], + updatedShares, + ) + requireVoteDelegationShares(t, app.GovKeeper.GetVoteDelegationSnapshots(ctx, proposals[0]), valAddrs[0], updatedShares) + requireVoteDelegationShares(t, app.GovKeeper.GetVoteDelegationSnapshots(ctx, proposals[1]), valAddrs[0], updatedShares) + + complete, processed = app.GovKeeper.ProcessVoteDelegationUpdates(ctx, 1) + require.True(t, complete) + require.Equal(t, 1, processed) + require.False(t, app.GovKeeper.HasPendingVoteDelegationUpdates(ctx)) + require.NotEqual(t, secondSnapshotBefore, store.Get(secondSnapshotKey)) + requireVoteDelegationShares( + t, + []govtypes.VoteDelegationSnapshot{decodeVoteDelegationSnapshot(t, store.Get(secondSnapshotKey))}, + valAddrs[0], + updatedShares, + ) + + complete, processed, _, _, _ = app.GovKeeper.TallyIncremental(ctx, proposals[0], 1) + require.True(t, complete) + require.Equal(t, 1, processed) + archivedSnapshot := decodeVoteDelegationSnapshot( + t, + store.Get(govtypes.TallyVoteDelegationsKey(proposals[0].ProposalId, false, addrs[0])), + ) + requireVoteDelegationShares(t, []govtypes.VoteDelegationSnapshot{archivedSnapshot}, valAddrs[0], updatedShares) + require.False(t, store.Has(govtypes.VoteDelegationSnapshotRevisionKey(proposals[0].ProposalId, addrs[0]))) +} + +func TestTallySharesRecordBudgetWithCanonicalDelegationUpdates(t *testing.T) { + app := seiapp.Setup(t, false, false, false) + ctx := app.BaseApp.NewContext(false, tmproto.Header{Time: time.Unix(100, 0)}) + addrs, valAddrs := createValidators(t, ctx, app, []int64{5, 5, 5}) + + secondValidator, found := app.StakingKeeper.GetValidator(ctx, valAddrs[1]) + require.True(t, found) + _, err := app.StakingKeeper.Delegate( + ctx, + addrs[0], + app.StakingKeeper.TokensFromConsensusPower(ctx, 1), + stakingtypes.Unbonded, + secondValidator, + true, + ) + require.NoError(t, err) + + proposal := newVotingProposalWithVote(t, ctx, app, addrs[0], ctx.BlockTime().Add(time.Hour)) + store := ctx.KVStore(app.GetKey(govtypes.StoreKey)) + snapshotKey := govtypes.VoteDelegationsKey(proposal.ProposalId, addrs[0]) + originalSnapshot := decodeVoteDelegationSnapshot(t, store.Get(snapshotKey)) + + updatedShares := make(map[string]sdk.Dec, 2) + for _, validator := range valAddrs[:2] { + delegation, found := app.StakingKeeper.GetDelegation(ctx, addrs[0], validator) + require.True(t, found) + delegation.Shares = delegation.Shares.Sub(sdk.OneDec()) + updatedShares[validator.String()] = delegation.Shares + app.StakingKeeper.SetDelegation(ctx, delegation) + app.GovKeeper.StakingHooks().AfterDelegationModified( + stakingtypes.WithSlashDelegationModification(ctx), + addrs[0], + validator, + ) + } + + require.True(t, app.GovKeeper.HasPendingVoteDelegationUpdates(ctx)) + effectiveSnapshots := app.GovKeeper.GetVoteDelegationSnapshots(ctx, proposal) + genesisSnapshots := gov.ExportGenesis(ctx, app.GovKeeper).VoteDelegationSnapshots + for _, validator := range valAddrs[:2] { + requireVoteDelegationShares(t, effectiveSnapshots, validator, updatedShares[validator.String()]) + requireVoteDelegationShares(t, genesisSnapshots, validator, updatedShares[validator.String()]) + } + + complete, processed, _, _, _ := app.GovKeeper.TallyIncremental(ctx, proposal, 1) + require.False(t, complete) + require.Equal(t, 1, processed) + require.True(t, app.GovKeeper.HasPendingVoteDelegationUpdates(ctx)) + partiallyUpdatedSnapshot := decodeVoteDelegationSnapshot(t, store.Get(snapshotKey)) + requireVoteDelegationShares( + t, + []govtypes.VoteDelegationSnapshot{partiallyUpdatedSnapshot}, + valAddrs[0], + updatedShares[valAddrs[0].String()], + ) + requireVoteDelegationShares( + t, + []govtypes.VoteDelegationSnapshot{partiallyUpdatedSnapshot}, + valAddrs[1], + voteDelegationShares(t, originalSnapshot, valAddrs[1]), + ) + + complete, processed, _, _, _ = app.GovKeeper.TallyIncremental(ctx, proposal, 1) + require.False(t, complete) + require.Equal(t, 1, processed) + require.False(t, app.GovKeeper.HasPendingVoteDelegationUpdates(ctx)) + require.True(t, app.GovKeeper.IsTallying(ctx, proposal.ProposalId)) + canonicalSnapshot := decodeVoteDelegationSnapshot(t, store.Get(snapshotKey)) + for _, validator := range valAddrs[:2] { + requireVoteDelegationShares( + t, + []govtypes.VoteDelegationSnapshot{canonicalSnapshot}, + validator, + updatedShares[validator.String()], + ) + } + + complete, processed, _, _, _ = app.GovKeeper.TallyIncremental(ctx, proposal, 1) + require.True(t, complete) + require.Equal(t, 1, processed) + archivedSnapshot := decodeVoteDelegationSnapshot( + t, + store.Get(govtypes.TallyVoteDelegationsKey(proposal.ProposalId, false, addrs[0])), + ) + for _, validator := range valAddrs[:2] { + requireVoteDelegationShares( + t, + []govtypes.VoteDelegationSnapshot{archivedSnapshot}, + validator, + updatedShares[validator.String()], + ) + } + require.False(t, store.Has(govtypes.VoteDelegationSnapshotRevisionKey(proposal.ProposalId, addrs[0]))) +} + +func TestSlashRedelegationDefersVoteSnapshotRefresh(t *testing.T) { + app := seiapp.Setup(t, false, false, false) + ctx := app.BaseApp.NewContext(false, tmproto.Header{Time: time.Unix(100, 0)}) + addrs, valAddrs := createValidators(t, ctx, app, []int64{5, 5, 5}) + proposal := newVotingProposalWithVote(t, ctx, app, addrs[0], ctx.BlockTime().Add(time.Hour)) + + store := ctx.KVStore(app.GetKey(govtypes.StoreKey)) + snapshotKey := govtypes.VoteDelegationsKey(proposal.ProposalId, addrs[0]) + snapshotBefore := append([]byte(nil), store.Get(snapshotKey)...) + delegationBefore, found := app.StakingKeeper.GetDelegation(ctx, addrs[0], valAddrs[0]) + require.True(t, found) + sourceValidator, found := app.StakingKeeper.GetValidator(ctx, valAddrs[1]) + require.True(t, found) + redelegation := stakingtypes.NewRedelegation( + addrs[0], + valAddrs[1], + valAddrs[0], + ctx.BlockHeight(), + ctx.BlockTime().Add(time.Hour), + app.StakingKeeper.TokensFromConsensusPower(ctx, 5), + delegationBefore.Shares, + ) + + app.StakingKeeper.SlashRedelegation(ctx, sourceValidator, redelegation, ctx.BlockHeight(), sdk.NewDecWithPrec(5, 1)) + delegationAfter, found := app.StakingKeeper.GetDelegation(ctx, addrs[0], valAddrs[0]) + require.True(t, found) + require.True(t, delegationAfter.Shares.LT(delegationBefore.Shares)) + require.Equal(t, snapshotBefore, store.Get(snapshotKey)) + require.True(t, app.GovKeeper.HasPendingVoteDelegationUpdates(ctx)) + + complete, processed := app.GovKeeper.ProcessVoteDelegationUpdates(ctx, 1) + require.True(t, complete) + require.Equal(t, 1, processed) + requireVoteDelegationShares( + t, + app.GovKeeper.GetVoteDelegationSnapshots(ctx, proposal), + valAddrs[0], + delegationAfter.Shares, + ) +} + +func TestSlashDelegationRemovalFoldsIntoCanonicalSnapshot(t *testing.T) { + app := seiapp.Setup(t, false, false, false) + ctx := app.BaseApp.NewContext(false, tmproto.Header{Time: time.Unix(100, 0)}) + addrs, valAddrs := createValidators(t, ctx, app, []int64{5, 5, 5}) + proposal := newVotingProposalWithVote(t, ctx, app, addrs[0], ctx.BlockTime().Add(time.Hour)) + + app.GovKeeper.StakingHooks().BeforeDelegationRemoved( + stakingtypes.WithSlashDelegationModification(ctx), + addrs[0], + valAddrs[0], + ) + complete, processed := app.GovKeeper.ProcessVoteDelegationUpdates(ctx, 1) + require.True(t, complete) + require.Equal(t, 1, processed) + + store := ctx.KVStore(app.GetKey(govtypes.StoreKey)) + snapshot := decodeVoteDelegationSnapshot( + t, + store.Get(govtypes.VoteDelegationsKey(proposal.ProposalId, addrs[0])), + ) + requireNoVoteDelegation(t, snapshot, valAddrs[0]) +} + +func TestSynchronousDelegationRefreshSupersedesDeferredSlashUpdate(t *testing.T) { + app := seiapp.Setup(t, false, false, false) + ctx := app.BaseApp.NewContext(false, tmproto.Header{Time: time.Unix(100, 0)}) + addrs, valAddrs := createValidators(t, ctx, app, []int64{5, 5, 5}) + proposal := newVotingProposalWithVote(t, ctx, app, addrs[0], ctx.BlockTime().Add(time.Hour)) + + delegation, found := app.StakingKeeper.GetDelegation(ctx, addrs[0], valAddrs[0]) + require.True(t, found) + delegation.Shares = delegation.Shares.Sub(sdk.OneDec()) + app.StakingKeeper.SetDelegation(ctx, delegation) + app.GovKeeper.StakingHooks().AfterDelegationModified( + stakingtypes.WithSlashDelegationModification(ctx), + addrs[0], + valAddrs[0], + ) + + latestShares := delegation.Shares.Sub(sdk.OneDec()) + delegation.Shares = latestShares + app.StakingKeeper.SetDelegation(ctx, delegation) + app.GovKeeper.StakingHooks().AfterDelegationModified(ctx, addrs[0], valAddrs[0]) + + complete, processed := app.GovKeeper.ProcessVoteDelegationUpdates(ctx, 1) + require.True(t, complete) + require.Equal(t, 1, processed) + requireVoteDelegationShares(t, app.GovKeeper.GetVoteDelegationSnapshots(ctx, proposal), valAddrs[0], latestShares) + store := ctx.KVStore(app.GetKey(govtypes.StoreKey)) + snapshot := decodeVoteDelegationSnapshot( + t, + store.Get(govtypes.VoteDelegationsKey(proposal.ProposalId, addrs[0])), + ) + requireVoteDelegationShares(t, []govtypes.VoteDelegationSnapshot{snapshot}, valAddrs[0], latestShares) +} + +func TestSlashDelegationUpdateHonorsVotingEndTime(t *testing.T) { + for _, tc := range []struct { + name string + offset time.Duration + applyUpdate bool + }{ + {name: "at voting end", offset: 0, applyUpdate: true}, + {name: "after voting end", offset: time.Second, applyUpdate: false}, + } { + t.Run(tc.name, func(t *testing.T) { + app := seiapp.Setup(t, false, false, false) + ctx := app.BaseApp.NewContext(false, tmproto.Header{Time: time.Unix(100, 0)}) + addrs, valAddrs := createValidators(t, ctx, app, []int64{5, 5, 5}) + votingEnd := ctx.BlockTime().Add(time.Minute) + proposal := newVotingProposalWithVote(t, ctx, app, addrs[0], votingEnd) + + delegation, found := app.StakingKeeper.GetDelegation(ctx, addrs[0], valAddrs[0]) + require.True(t, found) + originalShares := delegation.Shares + updatedShares := originalShares.Sub(sdk.OneDec()) + delegation.Shares = updatedShares + app.StakingKeeper.SetDelegation(ctx, delegation) + slashCtx := stakingtypes.WithSlashDelegationModification(ctx.WithBlockTime(votingEnd.Add(tc.offset))) + app.GovKeeper.StakingHooks().AfterDelegationModified(slashCtx, addrs[0], valAddrs[0]) + + complete, processed := app.GovKeeper.ProcessVoteDelegationUpdates(ctx, 1) + require.True(t, complete) + require.Equal(t, 1, processed) + expectedShares := originalShares + if tc.applyUpdate { + expectedShares = updatedShares + } + requireVoteDelegationShares( + t, + app.GovKeeper.GetVoteDelegationSnapshots(ctx, proposal), + valAddrs[0], + expectedShares, + ) + }) + } +} + +func newVotingProposalWithVote( + t *testing.T, + ctx sdk.Context, + app *seiapp.App, + voter sdk.AccAddress, + votingEnd time.Time, +) govtypes.Proposal { + t.Helper() + proposal, err := app.GovKeeper.SubmitProposal(ctx, TestProposal) + require.NoError(t, err) + proposal.Status = govtypes.StatusVotingPeriod + proposal.VotingEndTime = votingEnd + app.GovKeeper.SetProposal(ctx, proposal) + require.NoError(t, app.GovKeeper.AddVote( + ctx, + proposal.ProposalId, + voter, + govtypes.NewNonSplitVoteOption(govtypes.OptionYes), + )) + return proposal +} + +func decodeVoteDelegationSnapshot(t *testing.T, bz []byte) govtypes.VoteDelegationSnapshot { + t.Helper() + var snapshot govtypes.VoteDelegationSnapshot + seiapp.MakeEncodingConfig().Marshaler.MustUnmarshal(bz, &snapshot) + return snapshot +} + +func voteDelegationShares(t *testing.T, snapshot govtypes.VoteDelegationSnapshot, validator sdk.ValAddress) sdk.Dec { + t.Helper() + for _, delegation := range snapshot.Delegations { + if delegation.Validator == validator.String() { + return delegation.Shares + } + } + require.FailNow(t, "validator delegation not found", validator.String()) + return sdk.ZeroDec() +} + +func requireNoVoteDelegation(t *testing.T, snapshot govtypes.VoteDelegationSnapshot, validator sdk.ValAddress) { + t.Helper() + for _, delegation := range snapshot.Delegations { + require.NotEqual(t, validator.String(), delegation.Validator) + } +} + +func requireVoteDelegationShares( + t *testing.T, + snapshots []govtypes.VoteDelegationSnapshot, + validator sdk.ValAddress, + expected sdk.Dec, +) { + t.Helper() + require.Len(t, snapshots, 1) + for _, delegation := range snapshots[0].Delegations { + if delegation.Validator == validator.String() { + require.True(t, delegation.Shares.Equal(expected), "%s != %s", delegation.Shares, expected) + return + } + } + require.Fail(t, "validator delegation not found", validator.String()) +} diff --git a/sei-cosmos/x/gov/keeper/deposit.go b/sei-cosmos/x/gov/keeper/deposit.go index 4705893148..04d40f379a 100644 --- a/sei-cosmos/x/gov/keeper/deposit.go +++ b/sei-cosmos/x/gov/keeper/deposit.go @@ -116,6 +116,11 @@ func (keeper Keeper) AddDeposit(ctx sdk.Context, proposalID uint64, depositorAdd if (proposal.Status != types.StatusDepositPeriod) && (proposal.Status != types.StatusVotingPeriod) { return false, sdkerrors.Wrapf(types.ErrInactiveProposal, "%d", proposalID) } + if keeper.IncrementalTallyEnabled(ctx) && proposal.Status == types.StatusVotingPeriod { + if proposal.VotingEndTime.Before(ctx.BlockTime()) || keeper.voteDelegationSnapshotFrozen(ctx, proposal) { + return false, sdkerrors.Wrapf(types.ErrInactiveProposal, "%d", proposalID) + } + } // update the governance module's account coins pool err := keeper.bankKeeper.SendCoinsFromAccountToModule(ctx, depositorAddr, types.ModuleName, depositAmount) diff --git a/sei-cosmos/x/gov/keeper/deposit_test.go b/sei-cosmos/x/gov/keeper/deposit_test.go index dc48b344eb..b146a4112a 100644 --- a/sei-cosmos/x/gov/keeper/deposit_test.go +++ b/sei-cosmos/x/gov/keeper/deposit_test.go @@ -135,6 +135,48 @@ func TestDeposits(t *testing.T) { } } +func TestAddDepositRejectsBlocksAfterVotingEnd(t *testing.T) { + app := seiapp.Setup(t, false, false, false) + ctx := app.BaseApp.NewContext(false, tmproto.Header{}).WithBlockTime(time.Unix(100, 0)) + depositor := seiapp.AddTestAddrsIncremental(app, ctx, 1, sdk.NewInt(100))[0] + depositAmount := sdk.NewCoins(sdk.NewInt64Coin(sdk.DefaultBondDenom, 1)) + + proposal, err := app.GovKeeper.SubmitProposal(ctx, TestProposal) + require.NoError(t, err) + proposal.Status = types.StatusVotingPeriod + proposal.VotingEndTime = ctx.BlockTime().Add(time.Second) + app.GovKeeper.SetProposal(ctx, proposal) + app.GovKeeper.InsertActiveProposalQueue(ctx, proposal.ProposalId, proposal.VotingEndTime) + + atVotingEnd := ctx.WithBlockTime(proposal.VotingEndTime) + _, err = app.GovKeeper.AddDeposit(atVotingEnd, proposal.ProposalId, depositor, depositAmount) + require.NoError(t, err) + + proposalAtVotingEnd, found := app.GovKeeper.GetProposal(ctx, proposal.ProposalId) + require.True(t, found) + depositorBalanceAtVotingEnd := app.BankKeeper.GetAllBalances(ctx, depositor) + moduleBalanceAtVotingEnd := app.BankKeeper.GetAllBalances(ctx, app.AccountKeeper.GetModuleAddress(types.ModuleName)) + app.GovKeeper.CaptureExactTallyBoundary(atVotingEnd) + _, err = app.GovKeeper.AddDeposit(atVotingEnd, proposal.ProposalId, depositor, depositAmount) + require.ErrorIs(t, err, types.ErrInactiveProposal) + + afterVotingEnd := ctx.WithBlockTime(proposal.VotingEndTime.Add(time.Second)) + _, err = app.GovKeeper.AddDeposit(afterVotingEnd, proposal.ProposalId, depositor, depositAmount) + require.ErrorIs(t, err, types.ErrInactiveProposal) + + proposalAfterVotingEnd, found := app.GovKeeper.GetProposal(ctx, proposal.ProposalId) + require.True(t, found) + require.Equal(t, proposalAtVotingEnd.TotalDeposit, proposalAfterVotingEnd.TotalDeposit) + require.Equal(t, depositorBalanceAtVotingEnd, app.BankKeeper.GetAllBalances(ctx, depositor)) + require.Equal(t, moduleBalanceAtVotingEnd, app.BankKeeper.GetAllBalances(ctx, app.AccountKeeper.GetModuleAddress(types.ModuleName))) + + store := afterVotingEnd.KVStore(app.GetKey(types.StoreKey)) + store.Delete(types.IncrementalTallyEnabledKey) + legacyCtx := afterVotingEnd.WithIsTracing(true).WithClosestUpgradeName("v6.7") + _, err = app.GovKeeper.AddDeposit(legacyCtx, proposal.ProposalId, depositor, depositAmount) + require.NoError(t, err) +} + func TestRefundDepositsLeavesInvalidRecipientPending(t *testing.T) { app := seiapp.Setup(t, false, false, false) ctx := app.BaseApp.NewContext(false, tmproto.Header{}) diff --git a/sei-cosmos/x/gov/keeper/electorate.go b/sei-cosmos/x/gov/keeper/electorate.go new file mode 100644 index 0000000000..75898c9d55 --- /dev/null +++ b/sei-cosmos/x/gov/keeper/electorate.go @@ -0,0 +1,371 @@ +package keeper + +import ( + "encoding/json" + "fmt" + "time" + + 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" +) + +const ( + gapTallyBoundary byte = 'g' + exactTallyBoundary byte = 'e' + proposalTallyBoundary byte = 'p' +) + +type tallyElectorate struct { + TotalBondedTokens sdk.Int `json:"total_bonded_tokens"` + TallyParams types.TallyParams `json:"tally_params"` + Validators []tallyValidator `json:"validators"` +} + +type tallyBoundary struct { + LowerTime time.Time `json:"lower_time"` + UpperTime time.Time `json:"upper_time"` + UpdateSequence uint64 `json:"update_sequence"` + Electorate tallyElectorate `json:"electorate"` +} + +// CaptureGapTallyBoundary freezes one electorate for proposal deadlines between consecutive block times. +func (keeper Keeper) CaptureGapTallyBoundary(ctx sdk.Context) { + if !keeper.IncrementalTallyEnabled(ctx) { + return + } + + store := ctx.KVStore(keeper.storeKey) + previousValue := store.Get(types.DeadlineBoundaryBlockTimeKey) + if previousValue == nil { + return + } + previous := parseBoundaryTime(previousValue) + current := ctx.BlockTime() + if !previous.Before(current) { + return + } + + if keeper.hasProposalDeadlineBetween(ctx, previous, current) { + boundaryID := gapTallyBoundaryID(current) + keeper.setTallyBoundary(ctx, boundaryID, tallyBoundary{ + LowerTime: previous, + UpperTime: current, + UpdateSequence: keeper.voteDelegationUpdateSequence(ctx), + Electorate: keeper.snapshotTallyElectorate(ctx), + }) + store.Set(types.GapTallyBoundaryKey(current), boundaryID) + } + store.Set(types.DeadlineBoundaryBlockTimeKey, sdk.FormatTimeBytes(current)) +} + +// CaptureExactTallyBoundary freezes one electorate for proposal deadlines equal to the current block time. +func (keeper Keeper) CaptureExactTallyBoundary(ctx sdk.Context) { + if !keeper.IncrementalTallyEnabled(ctx) || !keeper.hasProposalDeadlineAt(ctx, ctx.BlockTime()) { + return + } + + store := ctx.KVStore(keeper.storeKey) + indexKey := types.ExactTallyBoundaryKey(ctx.BlockTime()) + if store.Has(indexKey) { + return + } + boundaryID := exactTallyBoundaryID(ctx.BlockTime()) + keeper.setTallyBoundary(ctx, boundaryID, tallyBoundary{ + LowerTime: ctx.BlockTime(), + UpperTime: ctx.BlockTime(), + UpdateSequence: keeper.voteDelegationUpdateSequence(ctx), + Electorate: keeper.snapshotTallyElectorate(ctx), + }) + store.Set(indexKey, boundaryID) +} + +// InitializeDeadlineBoundaryClock records the block time preceding future deadline captures. +func (keeper Keeper) InitializeDeadlineBoundaryClock(ctx sdk.Context) { + if !keeper.IncrementalTallyEnabled(ctx) { + return + } + ctx.KVStore(keeper.storeKey).Set(types.DeadlineBoundaryBlockTimeKey, sdk.FormatTimeBytes(ctx.BlockTime())) +} + +func (keeper Keeper) snapshotTallyElectorate(ctx sdk.Context) tallyElectorate { + electorate := tallyElectorate{ + TotalBondedTokens: keeper.sk.TotalBondedTokens(ctx), + TallyParams: keeper.GetTallyParams(ctx), + Validators: []tallyValidator{}, + } + keeper.sk.IterateBondedValidatorsByPower(ctx, func(_ int64, validator stakingtypes.ValidatorI) bool { + electorate.Validators = append(electorate.Validators, tallyValidator{ + Address: validator.GetOperator().String(), + BondedTokens: validator.GetBondedTokens(), + DelegatorShares: validator.GetDelegatorShares(), + ObservedDelegatorShares: sdk.ZeroDec(), + DelegatorResults: newTallyOptionResults(), + }) + return false + }) + return electorate +} + +func (keeper Keeper) selectTallyBoundary(ctx sdk.Context, proposal types.Proposal) (tallyBoundary, []byte) { + if boundary, boundaryID, found := keeper.getSelectedTallyBoundary(ctx, proposal.ProposalId); found { + return boundary, boundaryID + } + + if !keeper.usesLegacyTallySemantics(ctx, proposal) { + if boundary, boundaryID, found := keeper.getDeadlineTallyBoundary(ctx, proposal.VotingEndTime); found { + keeper.setProposalTallyBoundary(ctx, proposal.ProposalId, boundaryID) + return boundary, boundaryID + } + } + + boundaryID := proposalSpecificTallyBoundaryID(proposal.ProposalId) + boundary := tallyBoundary{ + LowerTime: ctx.BlockTime(), + UpperTime: ctx.BlockTime(), + UpdateSequence: keeper.voteDelegationUpdateSequence(ctx), + Electorate: keeper.snapshotTallyElectorate(ctx), + } + keeper.setTallyBoundary(ctx, boundaryID, boundary) + keeper.setProposalTallyBoundary(ctx, proposal.ProposalId, boundaryID) + return boundary, boundaryID +} + +// ExportTallyElectorate returns the frozen electorate needed to restart a proposal tally. +func (keeper Keeper) ExportTallyElectorate( + ctx sdk.Context, + proposal types.Proposal, +) (types.TallyElectorate, bool) { + if progress, found := keeper.getTallyProgress(ctx, proposal.ProposalId); found { + return tallyElectorateToGenesis(proposal.ProposalId, tallyElectorate{ + TotalBondedTokens: progress.TotalBondedTokens, + TallyParams: progress.TallyParams, + Validators: progress.Validators, + }), true + } + if boundary, _, found := keeper.getSelectedTallyBoundary(ctx, proposal.ProposalId); found { + return tallyElectorateToGenesis(proposal.ProposalId, boundary.Electorate), true + } + if !keeper.usesLegacyTallySemantics(ctx, proposal) { + if boundary, _, found := keeper.getDeadlineTallyBoundary(ctx, proposal.VotingEndTime); found { + return tallyElectorateToGenesis(proposal.ProposalId, boundary.Electorate), true + } + } + if proposal.Status == types.StatusVotingPeriod && !proposal.VotingEndTime.After(ctx.BlockTime()) { + return tallyElectorateToGenesis(proposal.ProposalId, keeper.snapshotTallyElectorate(ctx)), true + } + return types.TallyElectorate{}, false +} + +// SetTallyElectorate stores an imported frozen electorate for a proposal tally. +func (keeper Keeper) SetTallyElectorate(ctx sdk.Context, electorate types.TallyElectorate) { + boundaryID := proposalSpecificTallyBoundaryID(electorate.ProposalId) + keeper.setTallyBoundary(ctx, boundaryID, tallyBoundary{ + LowerTime: ctx.BlockTime(), + UpperTime: ctx.BlockTime(), + UpdateSequence: keeper.voteDelegationUpdateSequence(ctx), + Electorate: tallyElectorateFromGenesis(electorate), + }) + keeper.setProposalTallyBoundary(ctx, electorate.ProposalId, boundaryID) +} + +func (keeper Keeper) getSelectedTallyBoundary( + ctx sdk.Context, + proposalID uint64, +) (tallyBoundary, []byte, bool) { + store := ctx.KVStore(keeper.storeKey) + boundaryID := store.Get(types.ProposalTallyBoundaryKey(proposalID)) + if boundaryID == nil { + return tallyBoundary{}, nil, false + } + boundary, found := keeper.getTallyBoundary(ctx, boundaryID) + if !found { + panic(fmt.Sprintf("missing tally boundary for proposal %d", proposalID)) + } + return boundary, boundaryID, true +} + +func (keeper Keeper) getDeadlineTallyBoundary( + ctx sdk.Context, + endTime time.Time, +) (tallyBoundary, []byte, bool) { + store := ctx.KVStore(keeper.storeKey) + if boundaryID := store.Get(types.ExactTallyBoundaryKey(endTime)); boundaryID != nil { + boundary, found := keeper.getTallyBoundary(ctx, boundaryID) + if !found { + panic(fmt.Sprintf("missing exact tally boundary at %s", endTime)) + } + return boundary, boundaryID, true + } + + start := sdk.PrefixEndBytes(types.GapTallyBoundaryKey(endTime)) + iterator := store.Iterator(start, sdk.PrefixEndBytes(types.GapTallyBoundaryKeyPrefix)) + defer func() { _ = iterator.Close() }() + if !iterator.Valid() { + return tallyBoundary{}, nil, false + } + boundaryID := append([]byte(nil), iterator.Value()...) + boundary, found := keeper.getTallyBoundary(ctx, boundaryID) + if !found { + panic(fmt.Sprintf("missing gap tally boundary for deadline %s", endTime)) + } + if !boundary.LowerTime.Before(endTime) || !endTime.Before(boundary.UpperTime) { + return tallyBoundary{}, nil, false + } + return boundary, boundaryID, true +} + +func (keeper Keeper) proposalTallyBoundarySequence(ctx sdk.Context, proposal types.Proposal) (uint64, bool) { + if boundary, _, found := keeper.getSelectedTallyBoundary(ctx, proposal.ProposalId); found { + return boundary.UpdateSequence, true + } + if keeper.usesLegacyTallySemantics(ctx, proposal) { + return 0, false + } + boundary, _, found := keeper.getDeadlineTallyBoundary(ctx, proposal.VotingEndTime) + if !found { + return 0, false + } + return boundary.UpdateSequence, true +} + +func (keeper Keeper) setProposalTallyBoundary(ctx sdk.Context, proposalID uint64, boundaryID []byte) { + ctx.KVStore(keeper.storeKey).Set(types.ProposalTallyBoundaryKey(proposalID), boundaryID) +} + +func (keeper Keeper) setTallyBoundary(ctx sdk.Context, boundaryID []byte, boundary tallyBoundary) { + bz, err := json.Marshal(boundary) + if err != nil { + panic(fmt.Errorf("marshal tally boundary: %w", err)) + } + ctx.KVStore(keeper.storeKey).Set(types.TallyBoundaryMetaKey(boundaryID), bz) +} + +func (keeper Keeper) getTallyBoundary(ctx sdk.Context, boundaryID []byte) (tallyBoundary, bool) { + bz := ctx.KVStore(keeper.storeKey).Get(types.TallyBoundaryMetaKey(boundaryID)) + if bz == nil { + return tallyBoundary{}, false + } + var boundary tallyBoundary + if err := json.Unmarshal(bz, &boundary); err != nil { + panic(fmt.Errorf("unmarshal tally boundary: %w", err)) + } + return boundary, true +} + +func (keeper Keeper) addProposalDeadline(ctx sdk.Context, proposalID uint64, endTime time.Time) { + if !keeper.IncrementalTallyEnabled(ctx) || keeper.isLegacyProposal(ctx, proposalID) { + return + } + ctx.KVStore(keeper.storeKey).Set(types.ProposalDeadlineKey(proposalID, endTime), []byte{1}) +} + +func (keeper Keeper) addModernProposalDeadline(ctx sdk.Context, proposalID uint64, endTime time.Time) { + if !keeper.IncrementalTallyEnabled(ctx) { + return + } + ctx.KVStore(keeper.storeKey).Set(types.ProposalDeadlineKey(proposalID, endTime), []byte{1}) +} + +func (keeper Keeper) removeProposalDeadline(ctx sdk.Context, proposalID uint64, endTime time.Time) { + if !keeper.IncrementalTallyEnabled(ctx) { + return + } + store := ctx.KVStore(keeper.storeKey) + boundary, boundaryID, found := keeper.getSelectedTallyBoundary(ctx, proposalID) + if !found { + boundary, boundaryID, found = keeper.getDeadlineTallyBoundary(ctx, endTime) + } + store.Delete(types.ProposalDeadlineKey(proposalID, endTime)) + store.Delete(types.ProposalTallyBoundaryKey(proposalID)) + if !found { + return + } + + switch boundaryID[0] { + case proposalTallyBoundary: + store.Delete(types.TallyBoundaryMetaKey(boundaryID)) + case exactTallyBoundary: + if !keeper.hasProposalDeadlineAt(ctx, boundary.UpperTime) { + store.Delete(types.ExactTallyBoundaryKey(boundary.UpperTime)) + store.Delete(types.TallyBoundaryMetaKey(boundaryID)) + } + case gapTallyBoundary: + if !keeper.hasProposalDeadlineBetween(ctx, boundary.LowerTime, boundary.UpperTime) { + store.Delete(types.GapTallyBoundaryKey(boundary.UpperTime)) + store.Delete(types.TallyBoundaryMetaKey(boundaryID)) + } + default: + panic(fmt.Sprintf("unknown tally boundary %q", boundaryID)) + } +} + +func (keeper Keeper) hasProposalDeadlineAt(ctx sdk.Context, endTime time.Time) bool { + prefix := types.ProposalDeadlineByTimeKey(endTime) + iterator := ctx.KVStore(keeper.storeKey).Iterator(prefix, sdk.PrefixEndBytes(prefix)) + defer func() { _ = iterator.Close() }() + return iterator.Valid() +} + +func (keeper Keeper) hasProposalDeadlineBetween(ctx sdk.Context, lowerTime, upperTime time.Time) bool { + start := sdk.PrefixEndBytes(types.ProposalDeadlineByTimeKey(lowerTime)) + end := types.ProposalDeadlineByTimeKey(upperTime) + iterator := ctx.KVStore(keeper.storeKey).Iterator(start, end) + defer func() { _ = iterator.Close() }() + return iterator.Valid() +} + +func gapTallyBoundaryID(upperTime time.Time) []byte { + return append([]byte{gapTallyBoundary}, sdk.FormatTimeBytes(upperTime)...) +} + +func exactTallyBoundaryID(endTime time.Time) []byte { + return append([]byte{exactTallyBoundary}, sdk.FormatTimeBytes(endTime)...) +} + +func proposalSpecificTallyBoundaryID(proposalID uint64) []byte { + return append([]byte{proposalTallyBoundary}, types.GetProposalIDBytes(proposalID)...) +} + +func parseBoundaryTime(value []byte) time.Time { + blockTime, err := sdk.ParseTimeBytes(value) + if err != nil { + panic(fmt.Errorf("parse tally boundary block time: %w", err)) + } + return blockTime +} + +func tallyElectorateToGenesis(proposalID uint64, electorate tallyElectorate) types.TallyElectorate { + validators := make([]types.TallyValidator, 0, len(electorate.Validators)) + for _, validator := range electorate.Validators { + validators = append(validators, types.TallyValidator{ + Address: validator.Address, + BondedTokens: validator.BondedTokens, + DelegatorShares: validator.DelegatorShares, + }) + } + return types.TallyElectorate{ + ProposalId: proposalID, + TotalBondedTokens: electorate.TotalBondedTokens, + TallyParams: electorate.TallyParams, + TallyValidators: validators, + } +} + +func tallyElectorateFromGenesis(electorate types.TallyElectorate) tallyElectorate { + validators := make([]tallyValidator, 0, len(electorate.TallyValidators)) + for _, validator := range electorate.TallyValidators { + validators = append(validators, tallyValidator{ + Address: validator.Address, + BondedTokens: validator.BondedTokens, + DelegatorShares: validator.DelegatorShares, + ObservedDelegatorShares: sdk.ZeroDec(), + DelegatorResults: newTallyOptionResults(), + }) + } + return tallyElectorate{ + TotalBondedTokens: electorate.TotalBondedTokens, + TallyParams: electorate.TallyParams, + Validators: validators, + } +} diff --git a/sei-cosmos/x/gov/keeper/electorate_test.go b/sei-cosmos/x/gov/keeper/electorate_test.go new file mode 100644 index 0000000000..c149fbfb66 --- /dev/null +++ b/sei-cosmos/x/gov/keeper/electorate_test.go @@ -0,0 +1,187 @@ +package keeper_test + +import ( + "testing" + "time" + + tmproto "github.com/sei-protocol/sei-chain/sei-tendermint/proto/tendermint/types" + "github.com/stretchr/testify/require" + + seiapp "github.com/sei-protocol/sei-chain/app" + sdk "github.com/sei-protocol/sei-chain/sei-cosmos/types" + govtypes "github.com/sei-protocol/sei-chain/sei-cosmos/x/gov/types" + stakingtypes "github.com/sei-protocol/sei-chain/sei-cosmos/x/staking/types" +) + +func TestGapTallyBoundaryFreezesElectorateBeforeNextBlockMutations(t *testing.T) { + app := seiapp.Setup(t, false, false, false) + initialTime := time.Unix(100, 0) + ctx := app.BaseApp.NewContext(false, tmproto.Header{Time: initialTime}) + addrs, valAddrs := createValidators(t, ctx, app, []int64{5, 5, 5}) + proposal := createVotingProposalEndingAt(t, ctx, app, initialTime.Add(5*time.Second)) + delegateAndVoteYes(t, ctx, app, proposal.ProposalId, addrs[3], valAddrs[0], 2) + _, _, expected := app.GovKeeper.Tally(ctx, proposal) + + app.GovKeeper.InitializeDeadlineBoundaryClock(ctx) + nextCtx := ctx.WithBlockTime(initialTime.Add(10 * time.Second)) + app.GovKeeper.CaptureGapTallyBoundary(nextCtx) + delegateToValidator(t, nextCtx, app, addrs[3], valAddrs[0], 20) + + complete, processed, _, _, result := app.GovKeeper.TallyIncremental(nextCtx, proposal, 1) + require.True(t, complete) + require.Equal(t, 1, processed) + require.True(t, expected.Equals(result)) +} + +func TestExactTallyBoundaryFreezesBeforeLaterEndBlockMutations(t *testing.T) { + app := seiapp.Setup(t, false, false, false) + blockTime := time.Unix(100, 0) + ctx := app.BaseApp.NewContext(false, tmproto.Header{Time: blockTime}) + addrs, valAddrs := createValidators(t, ctx, app, []int64{5, 5, 5}) + + first := createVotingProposalEndingAt(t, ctx, app, blockTime) + second := createVotingProposalEndingAt(t, ctx, app, blockTime) + delegateAndVoteYes(t, ctx, app, first.ProposalId, addrs[3], valAddrs[0], 2) + require.NoError(t, app.GovKeeper.AddVote( + ctx, + second.ProposalId, + addrs[3], + govtypes.NewNonSplitVoteOption(govtypes.OptionYes), + )) + _, _, expected := app.GovKeeper.Tally(ctx, second) + + app.GovKeeper.CaptureExactTallyBoundary(ctx) + require.Equal(t, 1, countStorePrefix(ctx, app, govtypes.TallyBoundaryMetaKeyPrefix)) + delegateToValidator(t, ctx, app, addrs[3], valAddrs[0], 20) + + complete, processed, _, _, result := app.GovKeeper.TallyIncremental(ctx, first, 1) + require.True(t, complete) + require.Equal(t, 1, processed) + require.True(t, expected.Equals(result)) + app.GovKeeper.RemoveFromActiveProposalQueue(ctx, first.ProposalId, first.VotingEndTime) + require.Equal(t, 1, countStorePrefix(ctx, app, govtypes.TallyBoundaryMetaKeyPrefix)) + + complete, processed, _, _, result = app.GovKeeper.TallyIncremental(ctx, second, 1) + require.True(t, complete) + require.Equal(t, 1, processed) + require.True(t, expected.Equals(result)) + app.GovKeeper.RemoveFromActiveProposalQueue(ctx, second.ProposalId, second.VotingEndTime) + require.Zero(t, countStorePrefix(ctx, app, govtypes.TallyBoundaryMetaKeyPrefix)) +} + +func TestTallyOnlyWaitsForDelegationUpdatesThroughItsBoundary(t *testing.T) { + app := seiapp.Setup(t, false, false, false) + blockTime := time.Unix(100, 0) + ctx := app.BaseApp.NewContext(false, tmproto.Header{Time: blockTime}) + addrs, valAddrs := createValidators(t, ctx, app, []int64{5, 5, 5}) + proposal := createVotingProposalEndingAt(t, ctx, app, blockTime) + delegateAndVoteYes(t, ctx, app, proposal.ProposalId, addrs[3], valAddrs[0], 2) + + firstShares := queueDelegationShareUpdate(t, ctx, app, addrs[3], valAddrs[0], sdk.OneDec()) + app.GovKeeper.CaptureExactTallyBoundary(ctx) + queueDelegationShareUpdate(t, ctx, app, addrs[3], valAddrs[0], sdk.OneDec()) + + complete, processed, _, _, _ := app.GovKeeper.TallyIncremental(ctx, proposal, 2) + require.True(t, complete) + require.Equal(t, 2, processed) + require.True(t, app.GovKeeper.HasPendingVoteDelegationUpdates(ctx)) + archivedSnapshot := decodeVoteDelegationSnapshot( + t, + ctx.KVStore(app.GetKey(govtypes.StoreKey)).Get( + govtypes.TallyVoteDelegationsKey(proposal.ProposalId, false, addrs[3]), + ), + ) + requireVoteDelegationShares( + t, + []govtypes.VoteDelegationSnapshot{archivedSnapshot}, + valAddrs[0], + firstShares, + ) +} + +func createVotingProposalEndingAt( + t *testing.T, + ctx sdk.Context, + app *seiapp.App, + endTime time.Time, +) govtypes.Proposal { + 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) + app.GovKeeper.RemoveFromActiveProposalQueue(ctx, proposal.ProposalId, proposal.VotingEndTime) + proposal.VotingEndTime = endTime + app.GovKeeper.SetProposal(ctx, proposal) + app.GovKeeper.InsertActiveProposalQueue(ctx, proposal.ProposalId, endTime) + return proposal +} + +func delegateAndVoteYes( + t *testing.T, + ctx sdk.Context, + app *seiapp.App, + proposalID uint64, + voter sdk.AccAddress, + validator sdk.ValAddress, + power int64, +) { + delegateToValidator(t, ctx, app, voter, validator, power) + require.NoError(t, app.GovKeeper.AddVote( + ctx, + proposalID, + voter, + govtypes.NewNonSplitVoteOption(govtypes.OptionYes), + )) +} + +func delegateToValidator( + t *testing.T, + ctx sdk.Context, + app *seiapp.App, + delegator sdk.AccAddress, + validatorAddress sdk.ValAddress, + power int64, +) { + validator, found := app.StakingKeeper.GetValidator(ctx, validatorAddress) + require.True(t, found) + _, err := app.StakingKeeper.Delegate( + ctx, + delegator, + app.StakingKeeper.TokensFromConsensusPower(ctx, power), + stakingtypes.Unbonded, + validator, + true, + ) + require.NoError(t, err) +} + +func queueDelegationShareUpdate( + t *testing.T, + ctx sdk.Context, + app *seiapp.App, + delegator sdk.AccAddress, + validator sdk.ValAddress, + delta sdk.Dec, +) sdk.Dec { + delegation, found := app.StakingKeeper.GetDelegation(ctx, delegator, validator) + require.True(t, found) + delegation.Shares = delegation.Shares.Sub(delta) + app.StakingKeeper.SetDelegation(ctx, delegation) + app.GovKeeper.StakingHooks().AfterDelegationModified( + stakingtypes.WithSlashDelegationModification(ctx), + delegator, + validator, + ) + return delegation.Shares +} + +func countStorePrefix(tctx sdk.Context, app *seiapp.App, prefix []byte) int { + iterator := sdk.KVStorePrefixIterator(tctx.KVStore(app.GetKey(govtypes.StoreKey)), prefix) + defer func() { _ = iterator.Close() }() + count := 0 + for ; iterator.Valid(); iterator.Next() { + count++ + } + return count +} diff --git a/sei-cosmos/x/gov/keeper/keeper.go b/sei-cosmos/x/gov/keeper/keeper.go index ab4753e4a2..cd587d2b69 100644 --- a/sei-cosmos/x/gov/keeper/keeper.go +++ b/sei-cosmos/x/gov/keeper/keeper.go @@ -95,15 +95,31 @@ func (keeper Keeper) GetGovernanceAccount(ctx sdk.Context) authtypes.ModuleAccou // InsertActiveProposalQueue inserts a ProposalID into the active proposal queue at endTime func (keeper Keeper) InsertActiveProposalQueue(ctx sdk.Context, proposalID uint64, endTime time.Time) { - store := ctx.KVStore(keeper.storeKey) - bz := types.GetProposalIDBytes(proposalID) - store.Set(types.ActiveProposalQueueKey(proposalID, endTime), bz) + keeper.insertActiveProposalQueue(ctx, proposalID, endTime) + keeper.addProposalDeadline(ctx, proposalID, endTime) +} + +// InsertActiveProposalQueueForModernTallyRound inserts a converted legacy proposal's regular voting round. +func (keeper Keeper) InsertActiveProposalQueueForModernTallyRound(ctx sdk.Context, proposalID uint64, endTime time.Time) { + if !keeper.isLegacyProposal(ctx, proposalID) { + keeper.InsertActiveProposalQueue(ctx, proposalID, endTime) + return + } + keeper.insertActiveProposalQueue(ctx, proposalID, endTime) + keeper.SetModernTallyRound(ctx, proposalID) + keeper.addModernProposalDeadline(ctx, proposalID, endTime) +} + +func (keeper Keeper) insertActiveProposalQueue(ctx sdk.Context, proposalID uint64, endTime time.Time) { + ctx.KVStore(keeper.storeKey).Set(types.ActiveProposalQueueKey(proposalID, endTime), types.GetProposalIDBytes(proposalID)) } // RemoveFromActiveProposalQueue removes a proposalID from the Active Proposal Queue func (keeper Keeper) RemoveFromActiveProposalQueue(ctx sdk.Context, proposalID uint64, endTime time.Time) { store := ctx.KVStore(keeper.storeKey) store.Delete(types.ActiveProposalQueueKey(proposalID, endTime)) + keeper.removeProposalDeadline(ctx, proposalID, endTime) + store.Delete(types.ModernTallyRoundKey(proposalID)) } // InsertInactiveProposalQueue Inserts a ProposalID into the inactive proposal queue at endTime diff --git a/sei-cosmos/x/gov/keeper/migrations.go b/sei-cosmos/x/gov/keeper/migrations.go index 040993be93..e45a8b07e7 100644 --- a/sei-cosmos/x/gov/keeper/migrations.go +++ b/sei-cosmos/x/gov/keeper/migrations.go @@ -34,11 +34,34 @@ func (m Migrator) Migrate3to4(ctx sdk.Context) error { return err } - store := ctx.KVStore(m.keeper.storeKey) - store.Set(types.VoteDelegationBackfillCutoffKey, types.GetProposalIDBytes(nextProposalID)) + m.keeper.SetVoteDelegationBackfillCutoff(ctx, nextProposalID) + m.keeper.EnableIncrementalTally(ctx) + m.keeper.InitializeDeadlineBoundaryClock(ctx) return nil } +// EnableIncrementalTally records that bounded governance tallying is active. +func (keeper Keeper) EnableIncrementalTally(ctx sdk.Context) { + ctx.KVStore(keeper.storeKey).Set(types.IncrementalTallyEnabledKey, []byte{1}) +} + +// SetVoteDelegationBackfillCutoff records the first proposal that does not require delegation-tracking backfill. +func (keeper Keeper) SetVoteDelegationBackfillCutoff(ctx sdk.Context, proposalID uint64) { + ctx.KVStore(keeper.storeKey).Set(types.VoteDelegationBackfillCutoffKey, types.GetProposalIDBytes(proposalID)) +} + +// GetVoteDelegationBackfillCutoff returns the first proposal that does not require delegation-tracking backfill. +func (keeper Keeper) GetVoteDelegationBackfillCutoff(ctx sdk.Context) (uint64, bool) { + cutoff := ctx.KVStore(keeper.storeKey).Get(types.VoteDelegationBackfillCutoffKey) + if cutoff == nil { + return 0, false + } + if len(cutoff) != 8 { + panic("invalid vote delegation backfill cutoff") + } + return types.GetProposalIDFromBytes(cutoff), true +} + // BackfillVoteDelegationTracking initializes tracking for at most maxVotes of a proposal's votes. func (keeper Keeper) BackfillVoteDelegationTracking( ctx sdk.Context, @@ -87,20 +110,50 @@ func (keeper Keeper) IsVoteDelegationBackfillInProgress(ctx sdk.Context, proposa return progress != nil && !voteDelegationBackfillIsComplete(progress) } +// VoteDelegationBackfillRequired reports whether a proposal's votes require delegation-tracking backfill. +func (keeper Keeper) VoteDelegationBackfillRequired(ctx sdk.Context, proposalID uint64) bool { + return keeper.voteNeedsDelegationBackfill(ctx, proposalID) +} + +// CompleteVoteDelegationBackfill marks a legacy proposal's delegation tracking complete. +func (keeper Keeper) CompleteVoteDelegationBackfill(ctx sdk.Context, proposalID uint64) { + if !keeper.isLegacyProposal(ctx, proposalID) || keeper.IsModernTallyRound(ctx, proposalID) { + return + } + ctx.KVStore(keeper.storeKey).Set( + types.VoteDelegationBackfillProgressKey(proposalID), + []byte{voteDelegationBackfillComplete}, + ) +} + func (keeper Keeper) voteNeedsDelegationBackfill(ctx sdk.Context, proposalID uint64) bool { - store := ctx.KVStore(keeper.storeKey) - cutoff := store.Get(types.VoteDelegationBackfillCutoffKey) - if cutoff == nil { + if !keeper.isLegacyProposal(ctx, proposalID) || keeper.IsModernTallyRound(ctx, proposalID) { return false } - if len(cutoff) != 8 { - panic("invalid vote delegation backfill cutoff") - } - if proposalID >= types.GetProposalIDFromBytes(cutoff) { + progress := ctx.KVStore(keeper.storeKey).Get(types.VoteDelegationBackfillProgressKey(proposalID)) + return !voteDelegationBackfillIsComplete(progress) +} + +func (keeper Keeper) isLegacyProposal(ctx sdk.Context, proposalID uint64) bool { + cutoff, found := keeper.GetVoteDelegationBackfillCutoff(ctx) + if !found { return false } - progress := store.Get(types.VoteDelegationBackfillProgressKey(proposalID)) - return !voteDelegationBackfillIsComplete(progress) + return proposalID < cutoff +} + +func (keeper Keeper) usesLegacyTallySemantics(ctx sdk.Context, proposal types.Proposal) bool { + return keeper.isLegacyProposal(ctx, proposal.ProposalId) && !keeper.IsModernTallyRound(ctx, proposal.ProposalId) +} + +// SetModernTallyRound marks a legacy proposal's converted regular round for deadline-based tallying. +func (keeper Keeper) SetModernTallyRound(ctx sdk.Context, proposalID uint64) { + ctx.KVStore(keeper.storeKey).Set(types.ModernTallyRoundKey(proposalID), []byte{1}) +} + +// IsModernTallyRound reports whether a legacy proposal's current round uses deadline-based tallying. +func (keeper Keeper) IsModernTallyRound(ctx sdk.Context, proposalID uint64) bool { + return ctx.KVStore(keeper.storeKey).Has(types.ModernTallyRoundKey(proposalID)) } func voteDelegationBackfillIsComplete(progress []byte) bool { diff --git a/sei-cosmos/x/gov/keeper/migrations_test.go b/sei-cosmos/x/gov/keeper/migrations_test.go index 43e592e6cc..2ce462b2df 100644 --- a/sei-cosmos/x/gov/keeper/migrations_test.go +++ b/sei-cosmos/x/gov/keeper/migrations_test.go @@ -3,6 +3,7 @@ package keeper_test import ( "testing" + sdk "github.com/sei-protocol/sei-chain/sei-cosmos/types" tmproto "github.com/sei-protocol/sei-chain/sei-tendermint/proto/tendermint/types" "github.com/stretchr/testify/require" @@ -36,6 +37,8 @@ func TestMigrate3to4SchedulesBoundedVoteDelegationBackfill(t *testing.T) { )) store := ctx.KVStore(app.GetKey(govtypes.StoreKey)) + store.Delete(govtypes.IncrementalTallyEnabledKey) + store.Delete(govtypes.DeadlineBoundaryBlockTimeKey) for _, voter := range []int{0, 3} { store.Delete(govtypes.VoteDelegationsKey(proposal.ProposalId, addrs[voter])) store.Delete(govtypes.VoterProposalsKey(addrs[voter], proposal.ProposalId)) @@ -43,6 +46,11 @@ func TestMigrate3to4SchedulesBoundedVoteDelegationBackfill(t *testing.T) { migrator := govkeeper.NewMigrator(app.GovKeeper) require.NoError(t, migrator.Migrate3to4(ctx)) + require.True(t, store.Has(govtypes.IncrementalTallyEnabledKey)) + require.Equal(t, sdk.FormatTimeBytes(ctx.BlockTime()), store.Get(govtypes.DeadlineBoundaryBlockTimeKey)) + cutoff, found := app.GovKeeper.GetVoteDelegationBackfillCutoff(ctx) + require.True(t, found) + require.Equal(t, uint64(2), cutoff) require.False(t, app.GovKeeper.IsVoteDelegationBackfillInProgress(ctx, proposal.ProposalId)) for _, voter := range []int{0, 3} { require.False(t, store.Has(govtypes.VoteDelegationsKey(proposal.ProposalId, addrs[voter]))) @@ -104,6 +112,13 @@ func TestMigrate3to4SchedulesBoundedVoteDelegationBackfill(t *testing.T) { genesis := gov.ExportGenesis(ctx, app.GovKeeper) require.Len(t, genesis.Votes, 3) require.Len(t, genesis.VoteDelegationSnapshots, 3) + require.Equal(t, cutoff, genesis.VoteDelegationBackfillCutoff) + + lateDelegatedTokens := app.StakingKeeper.TokensFromConsensusPower(ctx, 10) + validator, found = app.StakingKeeper.GetValidator(ctx, valAddrs[0]) + require.True(t, found) + _, err = app.StakingKeeper.Delegate(ctx, addrs[3], lateDelegatedTokens, stakingtypes.Unbonded, validator, true) + require.NoError(t, err) complete, processed, _, _, _ = app.GovKeeper.TallyIncremental(ctx, proposal, 1) require.False(t, complete) @@ -115,12 +130,14 @@ func TestMigrate3to4SchedulesBoundedVoteDelegationBackfill(t *testing.T) { require.True(t, store.Has(govtypes.VoterProposalsKey(addrs[voter], proposal.ProposalId))) } + validator, found = app.StakingKeeper.GetValidator(ctx, valAddrs[0]) + require.True(t, found) _, err = app.StakingKeeper.Delegate(ctx, addrs[3], delegatedTokens, stakingtypes.Unbonded, validator, true) require.NoError(t, err) complete, processed, _, _, tallyResult := app.GovKeeper.TallyIncremental(ctx, proposal, 2) require.True(t, complete) require.Equal(t, 2, processed) - require.True(t, tallyResult.Yes.Equal(app.StakingKeeper.TokensFromConsensusPower(ctx, 25))) - require.True(t, tallyResult.No.IsZero()) + require.Equal(t, app.StakingKeeper.TokensFromConsensusPower(ctx, 25).String(), tallyResult.Yes.String()) + require.Equal(t, lateDelegatedTokens.String(), tallyResult.No.String()) } diff --git a/sei-cosmos/x/gov/keeper/staking_hooks.go b/sei-cosmos/x/gov/keeper/staking_hooks.go index 59895604d1..b961dd0564 100644 --- a/sei-cosmos/x/gov/keeper/staking_hooks.go +++ b/sei-cosmos/x/gov/keeper/staking_hooks.go @@ -37,6 +37,13 @@ func (hooks StakingHooks) BeforeDelegationRemoved( delegator sdk.AccAddress, validator sdk.ValAddress, ) { + if !hooks.keeper.IncrementalTallyEnabled(ctx) { + return + } + if stakingtypes.IsSlashDelegationModification(ctx) { + hooks.keeper.QueueVoteDelegationUpdate(ctx, delegator, validator, sdk.ZeroDec()) + return + } hooks.keeper.refreshVoteDelegationSnapshots(ctx, delegator, validator) } @@ -44,8 +51,20 @@ func (hooks StakingHooks) BeforeDelegationRemoved( func (hooks StakingHooks) AfterDelegationModified( ctx sdk.Context, delegator sdk.AccAddress, - _ sdk.ValAddress, + validator sdk.ValAddress, ) { + if !hooks.keeper.IncrementalTallyEnabled(ctx) { + return + } + if stakingtypes.IsSlashDelegationModification(ctx) { + delegation, found := hooks.keeper.sk.GetDelegation(ctx, delegator, validator) + if !found { + hooks.keeper.QueueVoteDelegationUpdate(ctx, delegator, validator, sdk.ZeroDec()) + return + } + hooks.keeper.QueueVoteDelegationUpdate(ctx, delegator, validator, delegation.Shares) + return + } hooks.keeper.refreshVoteDelegationSnapshots(ctx, delegator, nil) } diff --git a/sei-cosmos/x/gov/keeper/tally.go b/sei-cosmos/x/gov/keeper/tally.go index 3e856f45ea..8edf650760 100644 --- a/sei-cosmos/x/gov/keeper/tally.go +++ b/sei-cosmos/x/gov/keeper/tally.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" + "github.com/sei-protocol/sei-chain/sei-cosmos/store/prefix" 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" @@ -39,44 +40,177 @@ type tallyValidator struct { // Tally calculates a proposal's result without changing its tally state. func (keeper Keeper) Tally(ctx sdk.Context, proposal types.Proposal) (passes bool, burnDeposits bool, tallyResults types.TallyResult) { - progress := keeper.initializeTally(ctx, proposal) + progress, found := tallyProgress{}, false + if keeper.IncrementalTallyEnabled(ctx) { + progress, found = keeper.getTallyProgress(ctx, proposal.ProposalId) + } + if !found { + progress = keeper.initializeTally(ctx, proposal) + } + validators := progress.validatorMap() - keeper.IterateVotes(ctx, proposal.ProposalId, func(vote types.Vote) bool { + store := ctx.KVStore(keeper.storeKey) + votes := prefix.NewStore(store, types.VotesKey(proposal.ProposalId)) + keeper.iterateVoteStore(votes, func(vote types.Vote) bool { keeper.addVoteToTally(validators, vote, keeper.voteDelegations(ctx, proposal.ProposalId, progress.Expedited, vote)) return false }) return keeper.finishTally(progress) } -// TallyIncremental processes at most maxVotes vote records and persists an unfinished tally. +// TallyLegacy calculates a proposal's result and removes its votes using the legacy tally transition. +func (keeper Keeper) TallyLegacy(ctx sdk.Context, proposal types.Proposal) (passes bool, burnDeposits bool, tallyResults types.TallyResult) { + results := map[types.VoteOption]sdk.Dec{ + types.OptionYes: sdk.ZeroDec(), + types.OptionAbstain: sdk.ZeroDec(), + types.OptionNo: sdk.ZeroDec(), + types.OptionNoWithVeto: sdk.ZeroDec(), + } + totalVotingPower := sdk.ZeroDec() + validators := make(map[string]types.ValidatorGovInfo) + + keeper.sk.IterateBondedValidatorsByPower(ctx, func(_ int64, validator stakingtypes.ValidatorI) bool { + validators[validator.GetOperator().String()] = types.NewValidatorGovInfo( + validator.GetOperator(), + validator.GetBondedTokens(), + validator.GetDelegatorShares(), + sdk.ZeroDec(), + types.WeightedVoteOptions{}, + ) + return false + }) + + keeper.IterateVotes(ctx, proposal.ProposalId, func(vote types.Vote) bool { + voter := sdk.MustAccAddressFromBech32(vote.Voter) + validatorAddress := sdk.ValAddress(voter.Bytes()).String() + if validator, found := validators[validatorAddress]; found { + validator.Vote = vote.Options + validators[validatorAddress] = validator + } + + keeper.sk.IterateDelegations(ctx, voter, func(_ int64, delegation stakingtypes.DelegationI) bool { + validatorAddress := delegation.GetValidatorAddr().String() + validator, found := validators[validatorAddress] + if !found { + return false + } + + validator.DelegatorDeductions = validator.DelegatorDeductions.Add(delegation.GetShares()) + validators[validatorAddress] = validator + votingPower := delegation.GetShares().MulInt(validator.BondedTokens).Quo(validator.DelegatorShares) + for _, option := range vote.Options { + results[option.Option] = results[option.Option].Add(votingPower.Mul(option.Weight)) + } + totalVotingPower = totalVotingPower.Add(votingPower) + return false + }) + + ctx.KVStore(keeper.storeKey).Delete(types.VoteKey(vote.ProposalId, voter)) + return false + }) + + for _, validator := range validators { + if len(validator.Vote) == 0 { + continue + } + + sharesAfterDeductions := validator.DelegatorShares.Sub(validator.DelegatorDeductions) + votingPower := sharesAfterDeductions.MulInt(validator.BondedTokens).Quo(validator.DelegatorShares) + for _, option := range validator.Vote { + results[option.Option] = results[option.Option].Add(votingPower.Mul(option.Weight)) + } + totalVotingPower = totalVotingPower.Add(votingPower) + } + + tallyParams := keeper.GetTallyParams(ctx) + tallyResults = types.NewTallyResultFromMap(results) + if keeper.sk.TotalBondedTokens(ctx).IsZero() { + return false, false, tallyResults + } + + percentVoting := totalVotingPower.Quo(keeper.sk.TotalBondedTokens(ctx).ToDec()) + if percentVoting.LT(tallyParams.GetQuorum(proposal.IsExpedited)) { + return false, true, tallyResults + } + + if totalVotingPower.Sub(results[types.OptionAbstain]).Equal(sdk.ZeroDec()) { + return false, false, tallyResults + } + + if results[types.OptionNoWithVeto].Quo(totalVotingPower).GT(tallyParams.VetoThreshold) { + return false, true, tallyResults + } + + if results[types.OptionYes].Quo(totalVotingPower.Sub(results[types.OptionAbstain])).GT(tallyParams.GetThreshold(proposal.IsExpedited)) { + return true, false, tallyResults + } + + return false, false, tallyResults +} + +// TallyIncremental processes at most maxRecords governance work records after incremental tallying is active. func (keeper Keeper) TallyIncremental( ctx sdk.Context, proposal types.Proposal, - maxVotes int, + maxRecords int, ) (complete bool, processed int, passes bool, burnDeposits bool, tallyResults types.TallyResult) { - if maxVotes < 0 { - panic("maximum votes to tally cannot be negative") + if !keeper.IncrementalTallyEnabled(ctx) { + passes, burnDeposits, tallyResults = keeper.TallyLegacy(ctx, proposal) + return true, 0, passes, burnDeposits, tallyResults + } + if maxRecords < 0 { + panic("maximum governance records to process cannot be negative") } - progress, found := keeper.getTallyProgress(ctx, proposal.ProposalId) - if !found { - backfillComplete, backfilled := keeper.BackfillVoteDelegationTracking(ctx, proposal.ProposalId, maxVotes) - processed = backfilled + boundary, _, boundaryFound := keeper.getSelectedTallyBoundary(ctx, proposal.ProposalId) + if !found && !boundaryFound && keeper.usesLegacyTallySemantics(ctx, proposal) { + backfillComplete, backfilled := keeper.BackfillVoteDelegationTracking(ctx, proposal.ProposalId, maxRecords-processed) + processed += backfilled if !backfillComplete { return false, processed, false, false, types.EmptyTallyResult() } - progress = keeper.initializeTally(ctx, proposal) + } + if !boundaryFound { + if maxRecords == 0 { + return false, processed, false, false, types.EmptyTallyResult() + } + boundary, _ = keeper.selectTallyBoundary(ctx, proposal) + if keeper.usesLegacyTallySemantics(ctx, proposal) { + progress = initializeTallyFromElectorate(boundary.Electorate, proposal.IsExpedited) + keeper.setTallyProgress(ctx, proposal.ProposalId, progress) + return false, maxRecords, false, false, types.EmptyTallyResult() + } + } + updatesComplete, updatesProcessed := keeper.ProcessVoteDelegationUpdatesThrough( + ctx, + maxRecords-processed, + boundary.UpdateSequence, + ) + processed += updatesProcessed + if !updatesComplete { + return false, processed, false, false, types.EmptyTallyResult() + } + + if !found { + progress = initializeTallyFromElectorate(boundary.Electorate, proposal.IsExpedited) } else if progress.Expedited != proposal.IsExpedited { panic(fmt.Sprintf("tally round for proposal %d changed", proposal.ProposalId)) } + if processed == maxRecords { + keeper.setTallyProgress(ctx, proposal.ProposalId, progress) + return false, processed, false, false, types.EmptyTallyResult() + } var tallied int - complete, tallied = keeper.processTallyVotes(ctx, proposal.ProposalId, &progress, maxVotes-processed) + complete, tallied = keeper.processTallyVotes(ctx, proposal.ProposalId, &progress, maxRecords-processed) processed += tallied if !complete { keeper.setTallyProgress(ctx, proposal.ProposalId, progress) return false, processed, false, false, types.EmptyTallyResult() } + if processed == 0 { + processed = 1 + } passes, burnDeposits, tallyResults = keeper.finishTally(progress) keeper.deleteTallyProgress(ctx, proposal.ProposalId) @@ -92,6 +226,9 @@ func (keeper Keeper) IsTallying(ctx sdk.Context, proposalID uint64) bool { // InitializeTally persists a proposal's tally accumulator when one does not exist. func (keeper Keeper) InitializeTally(ctx sdk.Context, proposal types.Proposal) { + if !keeper.IncrementalTallyEnabled(ctx) { + return + } progress, found := keeper.getTallyProgress(ctx, proposal.ProposalId) if found { if progress.Expedited != proposal.IsExpedited { @@ -102,7 +239,8 @@ func (keeper Keeper) InitializeTally(ctx sdk.Context, proposal types.Proposal) { if keeper.voteNeedsDelegationBackfill(ctx, proposal.ProposalId) { panic("cannot initialize tally while vote delegation backfill is in progress") } - keeper.setTallyProgress(ctx, proposal.ProposalId, keeper.initializeTally(ctx, proposal)) + boundary, _ := keeper.selectTallyBoundary(ctx, proposal) + keeper.setTallyProgress(ctx, proposal.ProposalId, initializeTallyFromElectorate(boundary.Electorate, proposal.IsExpedited)) } // CleanupTallyVotes deletes at most maxVotes vote records archived by completed tallies. @@ -139,26 +277,28 @@ func (keeper Keeper) CleanupTallyVotes(ctx sdk.Context, maxVotes int) (deleted i } func (keeper Keeper) initializeTally(ctx sdk.Context, proposal types.Proposal) tallyProgress { - progress := tallyProgress{ + if keeper.IncrementalTallyEnabled(ctx) { + if boundary, _, found := keeper.getSelectedTallyBoundary(ctx, proposal.ProposalId); found { + return initializeTallyFromElectorate(boundary.Electorate, proposal.IsExpedited) + } + if !keeper.usesLegacyTallySemantics(ctx, proposal) { + if boundary, _, found := keeper.getDeadlineTallyBoundary(ctx, proposal.VotingEndTime); found { + return initializeTallyFromElectorate(boundary.Electorate, proposal.IsExpedited) + } + } + } + return initializeTallyFromElectorate(keeper.snapshotTallyElectorate(ctx), proposal.IsExpedited) +} + +func initializeTallyFromElectorate(electorate tallyElectorate, expedited bool) tallyProgress { + return tallyProgress{ Results: newTallyOptionResults(), TotalVotingPower: sdk.ZeroDec(), - TotalBondedTokens: keeper.sk.TotalBondedTokens(ctx), - TallyParams: keeper.GetTallyParams(ctx), - Expedited: proposal.IsExpedited, + TotalBondedTokens: electorate.TotalBondedTokens, + TallyParams: electorate.TallyParams, + Validators: electorate.Validators, + Expedited: expedited, } - - 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(), - ObservedDelegatorShares: sdk.ZeroDec(), - DelegatorResults: newTallyOptionResults(), - }) - return false - }) - - return progress } func (keeper Keeper) processTallyVotes( @@ -200,6 +340,7 @@ func (keeper Keeper) processTallyVotes( store.Delete(key) store.Delete(snapshotKey) store.Delete(types.VoterProposalsKey(voter, proposalID)) + keeper.deleteVoteDelegationSnapshotRevision(ctx, proposalID, voter) progress.Cursor = key processed++ } @@ -237,12 +378,13 @@ func (keeper Keeper) voteDelegations( vote types.Vote, ) types.VoteDelegationSnapshot { voter := sdk.MustAccAddressFromBech32(vote.Voter) - if !incrementalTallyEnabled(ctx) { + if !keeper.IncrementalTallyEnabled(ctx) { return keeper.snapshotVoteDelegations(ctx, proposalID, voter) } store := ctx.KVStore(keeper.storeKey) if bz := store.Get(types.VoteDelegationsKey(proposalID, voter)); bz != nil { - return keeper.unmarshalVoteDelegations(bz) + snapshot := keeper.unmarshalVoteDelegations(bz) + return keeper.applyVoteDelegationSnapshotUpdates(ctx, proposalID, voter, snapshot) } if bz := store.Get(types.TallyVoteDelegationsKey(proposalID, expedited, voter)); bz != nil { return keeper.unmarshalVoteDelegations(bz) diff --git a/sei-cosmos/x/gov/keeper/tally_test.go b/sei-cosmos/x/gov/keeper/tally_test.go index 6281d45c20..6d9e3ca49e 100644 --- a/sei-cosmos/x/gov/keeper/tally_test.go +++ b/sei-cosmos/x/gov/keeper/tally_test.go @@ -2,6 +2,7 @@ package keeper_test import ( "testing" + "time" tmproto "github.com/sei-protocol/sei-chain/sei-tendermint/proto/tendermint/types" "github.com/stretchr/testify/require" @@ -595,10 +596,14 @@ func TestTallyIncrementalIgnoresDelegationsAddedAfterTallyStarts(t *testing.T) { delegatedTokens := app.StakingKeeper.TokensFromConsensusPower(ctx, 20) _, err = app.StakingKeeper.Delegate(ctx, addrs[3], delegatedTokens, stakingtypes.Unbonded, validator, true) require.NoError(t, err) + _, _, queryResult := app.GovKeeper.Tally(ctx, proposal) + require.True(t, queryResult.Yes.Equal(snapshotValidatorTokens)) + require.True(t, queryResult.No.IsZero()) complete, processed, _, _, tallyResult := app.GovKeeper.TallyIncremental(ctx, proposal, 2) require.True(t, complete) require.Equal(t, 2, processed) + require.True(t, queryResult.Equals(tallyResult)) require.True(t, tallyResult.Yes.Equal(snapshotValidatorTokens)) require.True(t, tallyResult.No.IsZero()) totalVotingPower := tallyResult.Yes.Add(tallyResult.Abstain).Add(tallyResult.No).Add(tallyResult.NoWithVeto) @@ -700,7 +705,10 @@ func TestTallyArchivesExpeditedAndRegularRoundsSeparately(t *testing.T) { complete, _, _, _, _ := app.GovKeeper.TallyIncremental(ctx, proposal, 1) require.True(t, complete) + app.GovKeeper.RemoveFromActiveProposalQueue(ctx, proposal.ProposalId, proposal.VotingEndTime) proposal.IsExpedited = false + proposal.VotingEndTime = ctx.BlockTime().Add(time.Second) + app.GovKeeper.InsertActiveProposalQueue(ctx, proposal.ProposalId, proposal.VotingEndTime) app.GovKeeper.SetProposal(ctx, proposal) require.NoError(t, app.GovKeeper.AddVote( ctx, diff --git a/sei-cosmos/x/gov/keeper/vote.go b/sei-cosmos/x/gov/keeper/vote.go index c9886c4c57..7b5772e07d 100644 --- a/sei-cosmos/x/gov/keeper/vote.go +++ b/sei-cosmos/x/gov/keeper/vote.go @@ -3,8 +3,6 @@ package keeper import ( "fmt" - "golang.org/x/mod/semver" - "github.com/sei-protocol/sei-chain/sei-cosmos/store/cachekv" "github.com/sei-protocol/sei-chain/sei-cosmos/store/prefix" storetypes "github.com/sei-protocol/sei-chain/sei-cosmos/store/types" @@ -14,8 +12,6 @@ import ( stakingtypes "github.com/sei-protocol/sei-chain/sei-cosmos/x/staking/types" ) -const incrementalTallyUpgrade = "v6.7" - // AddVote adds a vote on a specific proposal func (keeper Keeper) AddVote(ctx sdk.Context, proposalID uint64, voterAddr sdk.AccAddress, options types.WeightedVoteOptions) error { proposal, ok := keeper.GetProposal(ctx, proposalID) @@ -25,11 +21,11 @@ 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 incrementalTallyEnabled(ctx) { + if keeper.IncrementalTallyEnabled(ctx) { if proposal.VotingEndTime.Before(ctx.BlockTime()) { return sdkerrors.Wrapf(types.ErrInactiveProposal, "%d", proposalID) } - if keeper.IsTallying(ctx, proposalID) || keeper.IsVoteDelegationBackfillInProgress(ctx, proposalID) { + if keeper.voteDelegationSnapshotFrozen(ctx, proposal) || keeper.IsVoteDelegationBackfillInProgress(ctx, proposalID) { return sdkerrors.Wrapf(types.ErrInactiveProposal, "%d", proposalID) } } @@ -124,7 +120,7 @@ func (keeper Keeper) SetVote(ctx sdk.Context, vote types.Vote) { } func (keeper Keeper) initializeVoteDelegationTracking(ctx sdk.Context, proposalID uint64, voter sdk.AccAddress) { - if !incrementalTallyEnabled(ctx) { + if !keeper.IncrementalTallyEnabled(ctx) { return } ctx.KVStore(keeper.storeKey).Set(types.VoterProposalsKey(voter, proposalID), []byte{1}) @@ -132,8 +128,10 @@ func (keeper Keeper) initializeVoteDelegationTracking(ctx sdk.Context, proposalI keeper.setVoteDelegationSnapshot(ctx, snapshot) } -func incrementalTallyEnabled(ctx sdk.Context) bool { - return !ctx.IsTracing() || semver.Compare(ctx.ClosestUpgradeName(), incrementalTallyUpgrade) >= 0 +// IncrementalTallyEnabled reports whether bounded governance tallying is active. +func (keeper Keeper) IncrementalTallyEnabled(ctx sdk.Context) bool { + activationCtx := ctx.WithGasMeter(sdk.NewInfiniteGasMeterWithMultiplier(ctx)).WithTraceMode(ctx.IsTracing()) + return activationCtx.KVStore(keeper.storeKey).Has(types.IncrementalTallyEnabledKey) } func (keeper Keeper) snapshotVoteDelegations( @@ -173,7 +171,7 @@ func (keeper Keeper) refreshVoteDelegationSnapshots( voter sdk.AccAddress, excludedValidator sdk.ValAddress, ) { - if !incrementalTallyEnabled(ctx) { + if !keeper.IncrementalTallyEnabled(ctx) { return } store := ctx.KVStore(keeper.storeKey) @@ -187,7 +185,8 @@ func (keeper Keeper) refreshVoteDelegationSnapshots( snapshot := keeper.snapshotVoteDelegationsExcept(ctx, 0, voter, excludedValidator) for ; iterator.Valid(); iterator.Next() { proposalID := types.GetProposalIDFromBytes(iterator.Key()[len(prefix):]) - if keeper.IsTallying(ctx, proposalID) { + proposal, found := keeper.GetProposal(ctx, proposalID) + if !found || keeper.voteDelegationSnapshotFrozen(ctx, proposal) { continue } snapshot.ProposalId = proposalID @@ -195,10 +194,29 @@ func (keeper Keeper) refreshVoteDelegationSnapshots( } } +func (keeper Keeper) voteDelegationSnapshotFrozen(ctx sdk.Context, proposal types.Proposal) bool { + if _, found := keeper.proposalTallyBoundarySequence(ctx, proposal); found { + return true + } + if keeper.usesLegacyTallySemantics(ctx, proposal) { + return keeper.IsTallying(ctx, proposal.ProposalId) + } + return proposal.VotingEndTime.Before(ctx.BlockTime()) || keeper.IsTallying(ctx, proposal.ProposalId) +} + func (keeper Keeper) setVoteDelegationSnapshot(ctx sdk.Context, snapshot types.VoteDelegationSnapshot) { + keeper.storeVoteDelegationSnapshot(ctx, snapshot, keeper.voteDelegationUpdateSequence(ctx)) +} + +func (keeper Keeper) storeVoteDelegationSnapshot( + ctx sdk.Context, + snapshot types.VoteDelegationSnapshot, + revision uint64, +) { voter := sdk.MustAccAddressFromBech32(snapshot.Voter) bz := keeper.cdc.MustMarshal(&snapshot) ctx.KVStore(keeper.storeKey).Set(types.VoteDelegationsKey(snapshot.ProposalId, voter), bz) + keeper.setVoteDelegationSnapshotRevision(ctx, snapshot.ProposalId, voter, revision) } // SetVoteDelegationSnapshot stores a vote's exported delegation snapshot. @@ -228,7 +246,7 @@ func (keeper Keeper) unmarshalVoteDelegations(bz []byte) types.VoteDelegationSna // IterateAllVotes iterates over the all the stored votes and performs a callback function func (keeper Keeper) IterateAllVotes(ctx sdk.Context, cb func(vote types.Vote) (stop bool)) { store := ctx.KVStore(keeper.storeKey) - if incrementalTallyEnabled(ctx) { + if keeper.IncrementalTallyEnabled(ctx) { progressIterator := sdk.KVStorePrefixIterator(store, types.TallyProgressKeyPrefix) for ; progressIterator.Valid(); progressIterator.Next() { proposalID := types.GetProposalIDFromBytes(progressIterator.Key()[len(types.TallyProgressKeyPrefix):]) @@ -282,7 +300,7 @@ func (keeper Keeper) iterateVoteStore(store storetypes.KVStore, cb func(vote typ func (keeper Keeper) visibleVotesStore(ctx sdk.Context, proposalID uint64) storetypes.KVStore { store := ctx.KVStore(keeper.storeKey) pending := prefix.NewStore(store, types.VotesKey(proposalID)) - if !incrementalTallyEnabled(ctx) { + if !keeper.IncrementalTallyEnabled(ctx) { return pending } progress, found := keeper.getTallyProgress(ctx, proposalID) diff --git a/sei-cosmos/x/gov/keeper/vote_test.go b/sei-cosmos/x/gov/keeper/vote_test.go index 14c3da628d..fb5d065676 100644 --- a/sei-cosmos/x/gov/keeper/vote_test.go +++ b/sei-cosmos/x/gov/keeper/vote_test.go @@ -1,6 +1,7 @@ package keeper_test import ( + "encoding/hex" "testing" "time" @@ -10,6 +11,7 @@ import ( seiapp "github.com/sei-protocol/sei-chain/app" 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" ) func TestVotes(t *testing.T) { @@ -104,6 +106,7 @@ func TestAddVoteRejectsBlocksAfterVotingEnd(t *testing.T) { proposal.Status = types.StatusVotingPeriod proposal.VotingEndTime = ctx.BlockTime().Add(time.Second) app.GovKeeper.SetProposal(ctx, proposal) + app.GovKeeper.InsertActiveProposalQueue(ctx, proposal.ProposalId, proposal.VotingEndTime) atVotingEnd := ctx.WithBlockTime(proposal.VotingEndTime) require.NoError(t, app.GovKeeper.AddVote( @@ -112,6 +115,13 @@ func TestAddVoteRejectsBlocksAfterVotingEnd(t *testing.T) { addrs[0], types.NewNonSplitVoteOption(types.OptionYes), )) + app.GovKeeper.CaptureExactTallyBoundary(atVotingEnd) + require.ErrorIs(t, app.GovKeeper.AddVote( + atVotingEnd, + proposal.ProposalId, + addrs[1], + types.NewNonSplitVoteOption(types.OptionYes), + ), types.ErrInactiveProposal) afterVotingEnd := ctx.WithBlockTime(proposal.VotingEndTime.Add(time.Second)) require.ErrorIs(t, app.GovKeeper.AddVote( @@ -133,14 +143,15 @@ func TestVoteDelegationTrackingPreservesHistoricalTraces(t *testing.T) { proposal.VotingEndTime = ctx.BlockTime().Add(-time.Second) app.GovKeeper.SetProposal(ctx, proposal) - legacyCtx := ctx.WithIsTracing(true).WithClosestUpgradeName("v6.6") + store := ctx.KVStore(app.GetKey(types.StoreKey)) + store.Delete(types.IncrementalTallyEnabledKey) + legacyCtx := ctx.WithIsTracing(true).WithClosestUpgradeName("v6.7") require.NoError(t, app.GovKeeper.AddVote( legacyCtx, proposal.ProposalId, addrs[0], types.NewNonSplitVoteOption(types.OptionYes), )) - store := legacyCtx.KVStore(app.GetKey(types.StoreKey)) require.False(t, store.Has(types.VoterProposalsKey(addrs[0], proposal.ProposalId))) require.False(t, store.Has(types.VoteDelegationsKey(proposal.ProposalId, addrs[0]))) require.Len(t, app.GovKeeper.GetAllVotes(legacyCtx), 1) @@ -151,10 +162,15 @@ func TestVoteDelegationTrackingPreservesHistoricalTraces(t *testing.T) { gasBeforeHook := legacyCtx.GasMeter().GasConsumed() app.GovKeeper.StakingHooks().AfterDelegationModified(legacyCtx, addrs[0], valAddrs[0]) require.Equal(t, gasBeforeHook, legacyCtx.GasMeter().GasConsumed()) + tracer, ok := legacyCtx.StoreTracer().(interface{ Dump() sdk.StoreTraceDump }) + require.True(t, ok) + trace := tracer.Dump() + require.NotContains(t, trace.Modules[types.ModuleName].Has, hex.EncodeToString(types.IncrementalTallyEnabledKey)) proposal.VotingEndTime = ctx.BlockTime().Add(time.Second) app.GovKeeper.SetProposal(ctx, proposal) - currentCtx := ctx.WithIsTracing(true).WithClosestUpgradeName("v6.7") + app.GovKeeper.EnableIncrementalTally(ctx) + currentCtx := ctx.WithIsTracing(true).WithClosestUpgradeName("v6.6") require.NoError(t, app.GovKeeper.AddVote( currentCtx, proposal.ProposalId, @@ -163,4 +179,68 @@ func TestVoteDelegationTrackingPreservesHistoricalTraces(t *testing.T) { )) require.True(t, store.Has(types.VoterProposalsKey(addrs[0], proposal.ProposalId))) require.True(t, store.Has(types.VoteDelegationsKey(proposal.ProposalId, addrs[0]))) + + store.Delete(types.IncrementalTallyEnabledKey) + emptyProposal, err := app.GovKeeper.SubmitProposal(ctx, TestProposal) + require.NoError(t, err) + emptyProposal.Status = types.StatusVotingPeriod + app.GovKeeper.SetProposal(ctx, emptyProposal) + complete, _, _, _, _ := app.GovKeeper.TallyIncremental(legacyCtx, emptyProposal, 1) + require.True(t, complete) + require.False(t, store.Has(types.ProposalTallyBoundaryKey(emptyProposal.ProposalId))) + + initializedProposal, err := app.GovKeeper.SubmitProposal(ctx, TestProposal) + require.NoError(t, err) + initializedProposal.Status = types.StatusVotingPeriod + app.GovKeeper.SetProposal(ctx, initializedProposal) + app.GovKeeper.InitializeTally(legacyCtx, initializedProposal) + require.False(t, store.Has(types.ProposalTallyBoundaryKey(initializedProposal.ProposalId))) + + boundaryIterator := sdk.KVStorePrefixIterator(store, types.TallyBoundaryMetaKeyPrefix) + require.False(t, boundaryIterator.Valid()) + require.NoError(t, boundaryIterator.Close()) +} + +func TestVoteDelegationSnapshotsFreezeAfterVotingEnd(t *testing.T) { + app := seiapp.Setup(t, false, false, false) + ctx := app.BaseApp.NewContext(false, tmproto.Header{}).WithBlockTime(time.Unix(100, 0)) + addrs, valAddrs := createValidators(t, ctx, app, []int64{5, 5, 5}) + + expiredProposal, err := app.GovKeeper.SubmitProposal(ctx, TestProposal) + require.NoError(t, err) + expiredProposal.Status = types.StatusVotingPeriod + expiredProposal.VotingEndTime = ctx.BlockTime().Add(-time.Second) + app.GovKeeper.SetProposal(ctx, expiredProposal) + + activeProposal, err := app.GovKeeper.SubmitProposal(ctx, TestProposal) + require.NoError(t, err) + activeProposal.Status = types.StatusVotingPeriod + activeProposal.VotingEndTime = ctx.BlockTime().Add(time.Second) + app.GovKeeper.SetProposal(ctx, activeProposal) + + voteCtx := ctx.WithBlockTime(ctx.BlockTime().Add(-2 * time.Second)) + for _, proposal := range []types.Proposal{expiredProposal, activeProposal} { + require.NoError(t, app.GovKeeper.AddVote( + voteCtx, + proposal.ProposalId, + addrs[3], + types.NewNonSplitVoteOption(types.OptionYes), + )) + require.False(t, app.GovKeeper.IsTallying(ctx, proposal.ProposalId)) + } + + store := ctx.KVStore(app.GetKey(types.StoreKey)) + expiredSnapshotKey := types.VoteDelegationsKey(expiredProposal.ProposalId, addrs[3]) + activeSnapshotKey := types.VoteDelegationsKey(activeProposal.ProposalId, addrs[3]) + expiredSnapshot := append([]byte(nil), store.Get(expiredSnapshotKey)...) + activeSnapshot := append([]byte(nil), store.Get(activeSnapshotKey)...) + + validator, found := app.StakingKeeper.GetValidator(ctx, valAddrs[0]) + require.True(t, found) + delegatedTokens := app.StakingKeeper.TokensFromConsensusPower(ctx, 20) + _, err = app.StakingKeeper.Delegate(ctx, addrs[3], delegatedTokens, stakingtypes.Unbonded, validator, true) + require.NoError(t, err) + + require.Equal(t, expiredSnapshot, store.Get(expiredSnapshotKey)) + require.NotEqual(t, activeSnapshot, store.Get(activeSnapshotKey)) } diff --git a/sei-cosmos/x/gov/simulation/decoder.go b/sei-cosmos/x/gov/simulation/decoder.go index 975da5776c..8c44d1f4b0 100644 --- a/sei-cosmos/x/gov/simulation/decoder.go +++ b/sei-cosmos/x/gov/simulation/decoder.go @@ -54,7 +54,19 @@ func NewDecodeStore(cdc codec.Codec) func(kvA, kvB kv.Pair) string { bytes.Equal(kvA.Key[:1], types.TallyVoteDelegationsKeyPrefix), bytes.Equal(kvA.Key[:1], types.VoterProposalsKeyPrefix), bytes.Equal(kvA.Key[:1], types.VoteDelegationBackfillCutoffKey), - bytes.Equal(kvA.Key[:1], types.VoteDelegationBackfillProgressKeyPrefix): + bytes.Equal(kvA.Key[:1], types.VoteDelegationBackfillProgressKeyPrefix), + bytes.Equal(kvA.Key[:1], types.VoteDelegationUpdateSequenceKey), + bytes.Equal(kvA.Key[:1], types.VoteDelegationUpdatesKeyPrefix), + bytes.Equal(kvA.Key[:1], types.VoterVoteDelegationUpdatesKeyPrefix), + bytes.Equal(kvA.Key[:1], types.VoteDelegationSnapshotRevisionKeyPrefix), + bytes.Equal(kvA.Key[:1], types.ProposalDeadlineKeyPrefix), + bytes.Equal(kvA.Key[:1], types.DeadlineBoundaryBlockTimeKey), + bytes.Equal(kvA.Key[:1], types.TallyBoundaryMetaKeyPrefix), + bytes.Equal(kvA.Key[:1], types.GapTallyBoundaryKeyPrefix), + bytes.Equal(kvA.Key[:1], types.ExactTallyBoundaryKeyPrefix), + bytes.Equal(kvA.Key[:1], types.ProposalTallyBoundaryKeyPrefix), + bytes.Equal(kvA.Key[:1], types.IncrementalTallyEnabledKey), + bytes.Equal(kvA.Key[:1], types.ModernTallyRoundKeyPrefix): return fmt.Sprintf("%X\n%X", kvA.Value, kvB.Value) default: diff --git a/sei-cosmos/x/gov/spec/01_concepts.md b/sei-cosmos/x/gov/spec/01_concepts.md index a004858b83..7d33dc1af0 100644 --- a/sei-cosmos/x/gov/spec/01_concepts.md +++ b/sei-cosmos/x/gov/spec/01_concepts.md @@ -58,7 +58,7 @@ arbitrary state changes. A proposal can be expedited, making the proposal use shorter voting duration and a higher tally quorum and tally threshold by default. -If an expedited proposal fails to meet the threshold within the scope of shorter voting duration, the expedited proposal is then converted to a regular proposal and resume voting under regular voting conditions. +If an expedited proposal fails to meet the threshold within the shorter voting duration, it is converted to a regular proposal and resumes voting under regular conditions. The regular round receives the remaining configured duration after the expedited tally completes, so tally processing does not consume its voting window. ## Deposit @@ -108,8 +108,8 @@ the moment the vote closes. `Voting period` should always be shorter than ### Expedited Voting period Expedited Proposal will have a shorter `Expedited Voting Period` compared to a regular `Voting Period`. -If the proposal has not passed after the `Expedited Voting Period`, it will be automatically -converted back to a regular proposal and fall back to use `Voting Period` unless the proposal is vetoed. +If the proposal has not passed after the `Expedited Voting Period`, it will be automatically +converted back to a regular proposal and resume for the difference between the regular and expedited voting periods unless the proposal is vetoed. That duration starts after the expedited tally completes. ### Option set diff --git a/sei-cosmos/x/gov/spec/02_state.md b/sei-cosmos/x/gov/spec/02_state.md index 3e90bf0827..f02aadd788 100644 --- a/sei-cosmos/x/gov/spec/02_state.md +++ b/sei-cosmos/x/gov/spec/02_state.md @@ -126,7 +126,7 @@ We will use one KVStore `Governance` to store three mappings: us to query all addresses that voted on the proposal along with their vote by doing a range query on `proposalID:addresses`. - A mapping from `proposalID|'delegations'|address` to the voter's per-validator - delegation shares, maintained until tallying starts. + delegation shares, maintained until the proposal's electorate boundary. For pseudocode purposes, here are the two function we will use to read or write in stores: @@ -219,32 +219,58 @@ And the pseudocode for the `ProposalProcessingQueue`: 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. A vote's per-validator delegation snapshot is -created with the vote and refreshed by staking hooks whenever that voter delegates, -undelegates, or redelegates. The snapshot is frozen when tallying starts, at the same -point as the validator snapshot, and moves with the vote into the tally archive. +export can reconstruct every vote while a tally is unfinished. New votes and deposits +are rejected after the voting period ends. A vote's per-validator delegation snapshot +is created with the vote and refreshed by staking hooks whenever that voter delegates, +undelegates, or redelegates before the proposal's electorate boundary. The boundary +freezes voter shares, bonded-validator tokens and shares, total bonded tokens, and +tally parameters together. Deadlines strictly between consecutive block times use the +state committed before the later block begins; deadlines equal to a block time use the +state at the start of governance `EndBlock`. Proposals sharing a boundary reuse one +validator electorate snapshot. A vote's delegation snapshot moves with the vote into +the tally archive. Delegator results are accumulated per validator from those stored shares, so changes -after tallying starts do not alter the result. If the stored delegation shares exceed +after the boundary do not alter the result. If the stored delegation shares exceed that validator's tally snapshot, every delegator option is scaled by the same factor to fit the snapshotted voting-power budget. This makes the result independent of vote-record order. Completed tally archives and their delegation snapshots are removed incrementally under the same per-block vote-record budget, with part of that budget reserved so cleanup cannot be starved by unfinished tallies. +Delegation changes caused by validator slashing are queued as constant-size updates +instead of rewriting every affected vote snapshot in `BeginBlock`. Before advancing +an affected tally, `EndBlock` folds updates through that proposal's frozen boundary +sequence into canonical vote snapshots under the same record-work budget. Later +updates do not delay or alter the frozen proposal. Read-only tally and export paths +overlay relevant queued updates so they remain consistent while that bounded work is +unfinished. Once an incremental tally has started, tally queries continue from its +persisted accumulator and frozen electorate. + The version 4 governance store migration records the first proposal ID that does not -need delegation-tracking backfill. When an older proposal reaches tallying, its +need delegation-tracking backfill. That cutoff is retained in application-state +exports. When an older proposal reaches tallying, its delegation snapshots and active-vote index entries are created incrementally with a per-proposal cursor under the same per-block vote-record budget as tallying and cleanup. New votes are rejected once that backfill starts, and tally accumulation -cannot start until it completes. Votes and proposals created after the upgrade -already have the required tracking data and skip backfill. Read-only tally and export -operations derive a missing snapshot while a proposal still needs backfill; after it -completes, tally processing treats a missing snapshot as an invariant failure. +cannot start until it completes. Because pre-upgrade votes have no historical +delegation index, these proposals use one tally-start boundary: snapshots already +backfilled continue following staking hooks, later batches read current shares, and +the validator electorate is frozen when the final batch completes. Votes and proposals +created after the upgrade already have the required tracking data, use deadline +boundaries, and skip backfill. Read-only tally and export operations derive a missing +snapshot while a proposal still needs backfill; after it completes, tally processing +treats a missing snapshot as an invariant failure. +When a legacy expedited proposal converts to a regular round, the new round uses a +deadline boundary and is recorded separately so application-state export preserves +that mode. Application-state export serializes all archived and pending votes together with their -delegation snapshots, but not the in-progress accumulator. On import, an expired voting -proposal starts a new tally from those votes, their frozen delegation snapshots, and -the imported validator and governance-parameter state. The accumulator is created -during genesis initialization, so the proposal does not reopen for votes before its -first `EndBlock`. +effective delegation snapshots and frozen electorates. Import canonicalizes unfinished +deadline-boundary tallies by starting them again from all of their votes and frozen +state; it does not preserve the old vote cursor or pending-update cursor. This replay +produces the same final tally even when live staking state or governance parameters +changed after the electorate boundary. Export also canonicalizes an expired legacy +proposal whose backfill is unfinished: it materializes each effective delegation +snapshot, freezes an electorate at export, and import marks that backfill complete. +Unexpired legacy proposals retain their bounded live-backfill semantics. Expired +proposals do not reopen for votes before their first `EndBlock`. diff --git a/sei-cosmos/x/gov/types/expected_keepers.go b/sei-cosmos/x/gov/types/expected_keepers.go index 5c7d2000ca..92c078c1ef 100644 --- a/sei-cosmos/x/gov/types/expected_keepers.go +++ b/sei-cosmos/x/gov/types/expected_keepers.go @@ -25,6 +25,7 @@ type StakingKeeper interface { ctx sdk.Context, delegator sdk.AccAddress, fn func(index int64, delegation stakingtypes.DelegationI) (stop bool), ) + GetDelegation(ctx sdk.Context, delegator sdk.AccAddress, validator sdk.ValAddress) (stakingtypes.Delegation, bool) } // AccountKeeper defines the expected account keeper (noalias) diff --git a/sei-cosmos/x/gov/types/genesis.go b/sei-cosmos/x/gov/types/genesis.go index 52249e4cfb..41807ca358 100644 --- a/sei-cosmos/x/gov/types/genesis.go +++ b/sei-cosmos/x/gov/types/genesis.go @@ -29,13 +29,28 @@ func DefaultGenesisState() *GenesisState { func (data GenesisState) Equal(other GenesisState) bool { return data.StartingProposalId == other.StartingProposalId && + data.VoteDelegationBackfillCutoff == other.VoteDelegationBackfillCutoff && + modernTallyRoundProposalIDsEqual(data.ModernTallyRoundProposalIds, other.ModernTallyRoundProposalIds) && data.Deposits.Equal(other.Deposits) && data.Votes.Equal(other.Votes) && data.Proposals.Equal(other.Proposals) && data.DepositParams.Equal(other.DepositParams) && data.TallyParams.Equal(other.TallyParams) && data.VotingParams.Equal(other.VotingParams) && - voteDelegationSnapshotsEqual(data.VoteDelegationSnapshots, other.VoteDelegationSnapshots) + voteDelegationSnapshotsEqual(data.VoteDelegationSnapshots, other.VoteDelegationSnapshots) && + tallyElectoratesEqual(data.TallyElectorates, other.TallyElectorates) +} + +func modernTallyRoundProposalIDsEqual(a, b []uint64) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true } func voteDelegationSnapshotsEqual(a, b []VoteDelegationSnapshot) bool { @@ -56,6 +71,28 @@ func voteDelegationSnapshotsEqual(a, b []VoteDelegationSnapshot) bool { return true } +func tallyElectoratesEqual(a, b []TallyElectorate) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i].ProposalId != b[i].ProposalId || + !a[i].TotalBondedTokens.Equal(b[i].TotalBondedTokens) || + !a[i].TallyParams.Equal(b[i].TallyParams) || + len(a[i].TallyValidators) != len(b[i].TallyValidators) { + return false + } + for j := range a[i].TallyValidators { + if a[i].TallyValidators[j].Address != b[i].TallyValidators[j].Address || + !a[i].TallyValidators[j].BondedTokens.Equal(b[i].TallyValidators[j].BondedTokens) || + !a[i].TallyValidators[j].DelegatorShares.Equal(b[i].TallyValidators[j].DelegatorShares) { + return false + } + } + } + return true +} + // Empty returns true if a GenesisState is empty func (data GenesisState) Empty() bool { return data.Equal(GenesisState{}) @@ -94,7 +131,157 @@ func ValidateGenesis(data *GenesisState) error { if err := validateVoteDelegationSnapshots(data.Votes, data.VoteDelegationSnapshots); err != nil { return err } + if err := validateTallyElectorates(data.Proposals, data.TallyElectorates); err != nil { + return err + } + if err := validateTallyElectorateVoteSnapshots(data.Votes, data.VoteDelegationSnapshots, data.TallyElectorates); err != nil { + return err + } + if err := validateModernTallyRounds(data.Proposals, data.VoteDelegationBackfillCutoff, data.ModernTallyRoundProposalIds); err != nil { + return err + } + if err := validateModernTallyRoundVoteSnapshots(data.Votes, data.VoteDelegationSnapshots, data.ModernTallyRoundProposalIds); err != nil { + return err + } + + return nil +} + +func validateModernTallyRounds(proposals Proposals, cutoff uint64, proposalIDs []uint64) error { + proposalsByID := make(map[uint64]Proposal, len(proposals)) + for _, proposal := range proposals { + proposalsByID[proposal.ProposalId] = proposal + } + + seen := make(map[uint64]struct{}, len(proposalIDs)) + for _, proposalID := range proposalIDs { + proposal, found := proposalsByID[proposalID] + if !found || proposal.Status != StatusVotingPeriod { + return fmt.Errorf("modern tally round for proposal %d has no voting-period proposal", proposalID) + } + if cutoff == 0 || proposalID >= cutoff { + return fmt.Errorf("modern tally round for proposal %d is not legacy", proposalID) + } + if proposal.IsExpedited { + return fmt.Errorf("modern tally round for proposal %d is expedited", proposalID) + } + if _, found := seen[proposalID]; found { + return fmt.Errorf("duplicate modern tally round for proposal %d", proposalID) + } + seen[proposalID] = struct{}{} + } + return nil +} +func validateModernTallyRoundVoteSnapshots( + votes Votes, + snapshots []VoteDelegationSnapshot, + proposalIDs []uint64, +) error { + modernRounds := make(map[uint64]struct{}, len(proposalIDs)) + for _, proposalID := range proposalIDs { + modernRounds[proposalID] = struct{}{} + } + snapshotKeys := make(map[string]struct{}, len(snapshots)) + for _, snapshot := range snapshots { + snapshotKeys[fmt.Sprintf("%d/%s", snapshot.ProposalId, snapshot.Voter)] = struct{}{} + } + for _, vote := range votes { + if _, found := modernRounds[vote.ProposalId]; !found { + continue + } + key := fmt.Sprintf("%d/%s", vote.ProposalId, vote.Voter) + if _, found := snapshotKeys[key]; !found { + return fmt.Errorf("modern tally round vote %s has no delegation snapshot", key) + } + } + return nil +} + +func validateTallyElectorateVoteSnapshots( + votes Votes, + snapshots []VoteDelegationSnapshot, + electorates []TallyElectorate, +) error { + electorateProposals := make(map[uint64]struct{}, len(electorates)) + for _, electorate := range electorates { + electorateProposals[electorate.ProposalId] = struct{}{} + } + snapshotKeys := make(map[string]struct{}, len(snapshots)) + for _, snapshot := range snapshots { + snapshotKeys[fmt.Sprintf("%d/%s", snapshot.ProposalId, snapshot.Voter)] = struct{}{} + } + for _, vote := range votes { + if _, found := electorateProposals[vote.ProposalId]; !found { + continue + } + key := fmt.Sprintf("%d/%s", vote.ProposalId, vote.Voter) + if _, found := snapshotKeys[key]; !found { + return fmt.Errorf("tally electorate vote %s has no delegation snapshot", key) + } + } + return nil +} + +func validateTallyElectorates(proposals Proposals, electorates []TallyElectorate) error { + votingProposals := make(map[uint64]struct{}, len(proposals)) + for _, proposal := range proposals { + if proposal.Status == StatusVotingPeriod { + votingProposals[proposal.ProposalId] = struct{}{} + } + } + + seenElectorates := make(map[uint64]struct{}, len(electorates)) + for _, electorate := range electorates { + if _, found := votingProposals[electorate.ProposalId]; !found { + return fmt.Errorf("tally electorate for proposal %d has no voting-period proposal", electorate.ProposalId) + } + if _, found := seenElectorates[electorate.ProposalId]; found { + return fmt.Errorf("duplicate tally electorate for proposal %d", electorate.ProposalId) + } + seenElectorates[electorate.ProposalId] = struct{}{} + if electorate.TotalBondedTokens.IsNil() { + return fmt.Errorf("tally electorate total bonded tokens are not initialized") + } + if electorate.TotalBondedTokens.IsNegative() { + return fmt.Errorf("tally electorate total bonded tokens cannot be negative: %s", electorate.TotalBondedTokens) + } + if err := validateTallyParams(electorate.TallyParams); err != nil { + return fmt.Errorf("invalid tally electorate params for proposal %d: %w", electorate.ProposalId, err) + } + + seenValidators := make(map[string]struct{}, len(electorate.TallyValidators)) + validatorTokens := sdk.ZeroInt() + for _, validator := range electorate.TallyValidators { + if _, err := sdk.ValAddressFromBech32(validator.Address); err != nil { + return fmt.Errorf("invalid tally electorate validator %q: %w", validator.Address, err) + } + if _, found := seenValidators[validator.Address]; found { + return fmt.Errorf("duplicate tally electorate validator %q for proposal %d", validator.Address, electorate.ProposalId) + } + seenValidators[validator.Address] = struct{}{} + if validator.BondedTokens.IsNil() { + return fmt.Errorf("tally electorate validator bonded tokens are not initialized") + } + if !validator.BondedTokens.IsPositive() { + return fmt.Errorf("tally electorate validator bonded tokens must be positive: %s", validator.BondedTokens) + } + if validator.DelegatorShares.IsNil() { + return fmt.Errorf("tally electorate validator shares are not initialized") + } + if !validator.DelegatorShares.IsPositive() { + return fmt.Errorf("tally electorate validator shares must be positive: %s", validator.DelegatorShares) + } + validatorTokens = validatorTokens.Add(validator.BondedTokens) + } + if !validatorTokens.Equal(electorate.TotalBondedTokens) { + return fmt.Errorf( + "tally electorate validator tokens %s do not equal total bonded tokens %s", + validatorTokens, + electorate.TotalBondedTokens, + ) + } + } return nil } diff --git a/sei-cosmos/x/gov/types/genesis.pb.go b/sei-cosmos/x/gov/types/genesis.pb.go index e5ca5bfc78..d800b14a03 100644 --- a/sei-cosmos/x/gov/types/genesis.pb.go +++ b/sei-cosmos/x/gov/types/genesis.pb.go @@ -42,6 +42,12 @@ type GenesisState struct { TallyParams TallyParams `protobuf:"bytes,7,opt,name=tally_params,json=tallyParams,proto3" json:"tally_params" yaml:"tally_params"` // vote_delegation_snapshots defines the delegation shares maintained for each vote. VoteDelegationSnapshots []VoteDelegationSnapshot `protobuf:"bytes,8,rep,name=vote_delegation_snapshots,json=voteDelegationSnapshots,proto3" json:"vote_delegation_snapshots"` + // tally_electorates defines the frozen electorate for unresolved proposals. + TallyElectorates []TallyElectorate `protobuf:"bytes,9,rep,name=tally_electorates,json=tallyElectorates,proto3" json:"tally_electorates"` + // vote_delegation_backfill_cutoff defines the first proposal ID created after vote delegation tracking began. + VoteDelegationBackfillCutoff uint64 `protobuf:"varint,10,opt,name=vote_delegation_backfill_cutoff,json=voteDelegationBackfillCutoff,proto3" json:"vote_delegation_backfill_cutoff,omitempty" yaml:"vote_delegation_backfill_cutoff"` + // modern_tally_round_proposal_ids defines legacy proposals whose converted regular round uses deadline tallying. + ModernTallyRoundProposalIds []uint64 `protobuf:"varint,11,rep,packed,name=modern_tally_round_proposal_ids,json=modernTallyRoundProposalIds,proto3" json:"modern_tally_round_proposal_ids,omitempty" yaml:"modern_tally_round_proposal_ids"` } func (m *GenesisState) Reset() { *m = GenesisState{} } @@ -133,6 +139,27 @@ func (m *GenesisState) GetVoteDelegationSnapshots() []VoteDelegationSnapshot { return nil } +func (m *GenesisState) GetTallyElectorates() []TallyElectorate { + if m != nil { + return m.TallyElectorates + } + return nil +} + +func (m *GenesisState) GetVoteDelegationBackfillCutoff() uint64 { + if m != nil { + return m.VoteDelegationBackfillCutoff + } + return 0 +} + +func (m *GenesisState) GetModernTallyRoundProposalIds() []uint64 { + if m != nil { + return m.ModernTallyRoundProposalIds + } + return nil +} + // VoteDelegationSnapshot defines the per-validator delegation shares maintained for a vote. type VoteDelegationSnapshot struct { ProposalId uint64 `protobuf:"varint,1,opt,name=proposal_id,json=proposalId,proto3" json:"proposal_id,omitempty" yaml:"proposal_id"` @@ -240,53 +267,179 @@ func (m *VoteDelegation) GetValidator() string { return "" } +// TallyElectorate defines the validator and parameter state used to tally one proposal. +type TallyElectorate struct { + ProposalId uint64 `protobuf:"varint,1,opt,name=proposal_id,json=proposalId,proto3" json:"proposal_id,omitempty" yaml:"proposal_id"` + TotalBondedTokens github_com_sei_protocol_sei_chain_sei_cosmos_types.Int `protobuf:"bytes,2,opt,name=total_bonded_tokens,json=totalBondedTokens,proto3,customtype=github.com/sei-protocol/sei-chain/sei-cosmos/types.Int" json:"total_bonded_tokens"` + TallyParams TallyParams `protobuf:"bytes,3,opt,name=tally_params,json=tallyParams,proto3" json:"tally_params"` + TallyValidators []TallyValidator `protobuf:"bytes,4,rep,name=tally_validators,json=tallyValidators,proto3" json:"tally_validators"` +} + +func (m *TallyElectorate) Reset() { *m = TallyElectorate{} } +func (m *TallyElectorate) String() string { return proto.CompactTextString(m) } +func (*TallyElectorate) ProtoMessage() {} +func (*TallyElectorate) Descriptor() ([]byte, []int) { + return fileDescriptor_43cd825e0fa7a627, []int{3} +} +func (m *TallyElectorate) XXX_Unmarshal(b []byte) error { + return m.Unmarshal(b) +} +func (m *TallyElectorate) XXX_Marshal(b []byte, deterministic bool) ([]byte, error) { + if deterministic { + return xxx_messageInfo_TallyElectorate.Marshal(b, m, deterministic) + } else { + b = b[:cap(b)] + n, err := m.MarshalToSizedBuffer(b) + if err != nil { + return nil, err + } + return b[:n], nil + } +} +func (m *TallyElectorate) XXX_Merge(src proto.Message) { + xxx_messageInfo_TallyElectorate.Merge(m, src) +} +func (m *TallyElectorate) XXX_Size() int { + return m.Size() +} +func (m *TallyElectorate) XXX_DiscardUnknown() { + xxx_messageInfo_TallyElectorate.DiscardUnknown(m) +} + +var xxx_messageInfo_TallyElectorate proto.InternalMessageInfo + +func (m *TallyElectorate) GetProposalId() uint64 { + if m != nil { + return m.ProposalId + } + return 0 +} + +func (m *TallyElectorate) GetTallyParams() TallyParams { + if m != nil { + return m.TallyParams + } + return TallyParams{} +} + +func (m *TallyElectorate) GetTallyValidators() []TallyValidator { + if m != nil { + return m.TallyValidators + } + return nil +} + +// TallyValidator defines a validator's frozen state in a proposal electorate. +type TallyValidator struct { + Address string `protobuf:"bytes,1,opt,name=address,proto3" json:"address,omitempty"` + BondedTokens github_com_sei_protocol_sei_chain_sei_cosmos_types.Int `protobuf:"bytes,2,opt,name=bonded_tokens,json=bondedTokens,proto3,customtype=github.com/sei-protocol/sei-chain/sei-cosmos/types.Int" json:"bonded_tokens"` + DelegatorShares github_com_sei_protocol_sei_chain_sei_cosmos_types.Dec `protobuf:"bytes,3,opt,name=delegator_shares,json=delegatorShares,proto3,customtype=github.com/sei-protocol/sei-chain/sei-cosmos/types.Dec" json:"delegator_shares"` +} + +func (m *TallyValidator) Reset() { *m = TallyValidator{} } +func (m *TallyValidator) String() string { return proto.CompactTextString(m) } +func (*TallyValidator) ProtoMessage() {} +func (*TallyValidator) Descriptor() ([]byte, []int) { + return fileDescriptor_43cd825e0fa7a627, []int{4} +} +func (m *TallyValidator) XXX_Unmarshal(b []byte) error { + return m.Unmarshal(b) +} +func (m *TallyValidator) XXX_Marshal(b []byte, deterministic bool) ([]byte, error) { + if deterministic { + return xxx_messageInfo_TallyValidator.Marshal(b, m, deterministic) + } else { + b = b[:cap(b)] + n, err := m.MarshalToSizedBuffer(b) + if err != nil { + return nil, err + } + return b[:n], nil + } +} +func (m *TallyValidator) XXX_Merge(src proto.Message) { + xxx_messageInfo_TallyValidator.Merge(m, src) +} +func (m *TallyValidator) XXX_Size() int { + return m.Size() +} +func (m *TallyValidator) XXX_DiscardUnknown() { + xxx_messageInfo_TallyValidator.DiscardUnknown(m) +} + +var xxx_messageInfo_TallyValidator proto.InternalMessageInfo + +func (m *TallyValidator) GetAddress() string { + if m != nil { + return m.Address + } + return "" +} + func init() { proto.RegisterType((*GenesisState)(nil), "cosmos.gov.v1beta1.GenesisState") proto.RegisterType((*VoteDelegationSnapshot)(nil), "cosmos.gov.v1beta1.VoteDelegationSnapshot") proto.RegisterType((*VoteDelegation)(nil), "cosmos.gov.v1beta1.VoteDelegation") + proto.RegisterType((*TallyElectorate)(nil), "cosmos.gov.v1beta1.TallyElectorate") + proto.RegisterType((*TallyValidator)(nil), "cosmos.gov.v1beta1.TallyValidator") } func init() { proto.RegisterFile("cosmos/gov/v1beta1/genesis.proto", fileDescriptor_43cd825e0fa7a627) } var fileDescriptor_43cd825e0fa7a627 = []byte{ - // 590 bytes of a gzipped FileDescriptorProto - 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0x94, 0x94, 0x41, 0x6f, 0xd3, 0x3c, - 0x18, 0xc7, 0x9b, 0x6d, 0xed, 0xdb, 0xba, 0xed, 0xf4, 0x62, 0xca, 0x08, 0x6b, 0x49, 0x42, 0x4e, - 0x15, 0x12, 0xa9, 0x36, 0x24, 0x40, 0x48, 0x70, 0x88, 0x2a, 0xa1, 0x21, 0x21, 0x0d, 0x0f, 0xed, - 0xc0, 0x25, 0x72, 0x13, 0x2b, 0x8d, 0x94, 0xd6, 0x51, 0x6c, 0x22, 0xfa, 0x05, 0x38, 0xf3, 0x39, - 0xb8, 0xf2, 0x25, 0x76, 0xdc, 0x0d, 0xc4, 0xa1, 0xa0, 0xf6, 0x1b, 0xf4, 0x13, 0xa0, 0xd8, 0xce, - 0xda, 0x6a, 0x19, 0x88, 0x9b, 0xfd, 0xe4, 0xff, 0xfc, 0xfe, 0xcf, 0x63, 0x3f, 0x31, 0xb0, 0x7c, - 0xca, 0x26, 0x94, 0x0d, 0x42, 0x9a, 0x0d, 0xb2, 0xa3, 0x11, 0xe1, 0xf8, 0x68, 0x10, 0x92, 0x29, - 0x61, 0x11, 0x73, 0x92, 0x94, 0x72, 0x0a, 0xa1, 0x54, 0x38, 0x21, 0xcd, 0x1c, 0xa5, 0x38, 0xec, - 0x95, 0x65, 0xd1, 0x4c, 0x66, 0x1c, 0x76, 0x42, 0x1a, 0x52, 0xb1, 0x1c, 0xe4, 0x2b, 0x19, 0xb5, - 0xbf, 0x55, 0x41, 0xeb, 0x95, 0x24, 0x9f, 0x71, 0xcc, 0x09, 0x7c, 0x0b, 0x3a, 0x8c, 0xe3, 0x94, - 0x47, 0xd3, 0xd0, 0x4b, 0x52, 0x9a, 0x50, 0x86, 0x63, 0x2f, 0x0a, 0x74, 0xcd, 0xd2, 0xfa, 0x7b, - 0xae, 0xb9, 0x9a, 0x9b, 0xdd, 0x19, 0x9e, 0xc4, 0xcf, 0xed, 0x32, 0x95, 0x8d, 0x60, 0x11, 0x3e, - 0x55, 0xd1, 0x93, 0x00, 0x9e, 0x80, 0x7a, 0x40, 0x12, 0xca, 0x22, 0xce, 0xf4, 0x1d, 0x6b, 0xb7, - 0xdf, 0x3c, 0xee, 0x3a, 0xd7, 0xcb, 0x77, 0x86, 0x52, 0xe3, 0xfe, 0x7f, 0x31, 0x37, 0x2b, 0x5f, - 0x7e, 0x9a, 0x75, 0x15, 0x60, 0xe8, 0x2a, 0x1d, 0xbe, 0x00, 0xd5, 0x8c, 0x72, 0xc2, 0xf4, 0x5d, - 0xc1, 0xd1, 0xcb, 0x38, 0xe7, 0x94, 0x13, 0xb7, 0xad, 0x20, 0xd5, 0x7c, 0xc7, 0x90, 0xcc, 0x82, - 0x6f, 0x40, 0xa3, 0xa8, 0x96, 0xe9, 0x7b, 0x02, 0xd1, 0x2b, 0x43, 0x14, 0xc5, 0xbb, 0xb7, 0x14, - 0xa6, 0x51, 0x44, 0x18, 0x5a, 0x13, 0x60, 0x08, 0xf6, 0x55, 0x65, 0x5e, 0x82, 0x53, 0x3c, 0x61, - 0x7a, 0xd5, 0xd2, 0xfa, 0xcd, 0xe3, 0x07, 0x7f, 0x68, 0xef, 0x54, 0x08, 0xdd, 0xfb, 0x39, 0x78, - 0x35, 0x37, 0xef, 0xc8, 0xc3, 0xdc, 0xc6, 0xd8, 0xa8, 0x1d, 0x6c, 0xaa, 0xa1, 0x0f, 0xda, 0x19, - 0x95, 0x87, 0x2d, 0x7d, 0x6a, 0xc2, 0xc7, 0xba, 0xa1, 0xfd, 0xfc, 0xf8, 0xa5, 0x4d, 0x4f, 0xd9, - 0x74, 0xa4, 0xcd, 0x16, 0xc4, 0x46, 0xad, 0x6c, 0x43, 0x0b, 0x3d, 0xd0, 0xe2, 0x38, 0x8e, 0x67, - 0x85, 0xc7, 0x7f, 0xc2, 0xc3, 0x2c, 0xf3, 0x78, 0x97, 0xeb, 0x94, 0x45, 0x57, 0x59, 0xdc, 0x96, - 0x16, 0x9b, 0x08, 0x1b, 0x35, 0xf9, 0x5a, 0x09, 0x63, 0x70, 0x2f, 0xbf, 0x06, 0x2f, 0x20, 0x31, - 0x09, 0x31, 0x8f, 0xe8, 0xd4, 0x63, 0x53, 0x9c, 0xb0, 0x31, 0xe5, 0x4c, 0xaf, 0x8b, 0xdb, 0x78, - 0x78, 0xd3, 0x85, 0x0e, 0xaf, 0x72, 0xce, 0x54, 0x8a, 0xbb, 0x97, 0x1b, 0xa3, 0xbb, 0x59, 0xe9, - 0x57, 0x66, 0x7f, 0xd5, 0xc0, 0x41, 0x79, 0x26, 0x7c, 0x0a, 0x9a, 0xd7, 0x47, 0xfb, 0x60, 0x35, - 0x37, 0xa1, 0xec, 0x61, 0x6b, 0xa2, 0x41, 0xb2, 0x9e, 0xe4, 0x8e, 0x1c, 0xbf, 0x54, 0xdf, 0xb1, - 0xb4, 0x7e, 0x43, 0x4e, 0x55, 0x0a, 0x5f, 0x83, 0xe6, 0xba, 0xa5, 0x62, 0x34, 0xed, 0xbf, 0x77, - 0xa2, 0x3a, 0xd8, 0x4c, 0xb6, 0x3f, 0x69, 0x60, 0x7f, 0x5b, 0x05, 0x7b, 0xa0, 0x91, 0xe1, 0x38, - 0x0a, 0x30, 0xa7, 0xa9, 0xa8, 0xb5, 0x81, 0xd6, 0x01, 0x78, 0x0e, 0x6a, 0x6c, 0x8c, 0x53, 0xc2, - 0x64, 0x4d, 0xee, 0xcb, 0x9c, 0xf9, 0x63, 0x6e, 0x3e, 0x09, 0x23, 0x3e, 0xfe, 0x30, 0x72, 0x7c, - 0x3a, 0x19, 0x30, 0x12, 0x3d, 0x12, 0xbf, 0xbb, 0x4f, 0x63, 0xb1, 0xf1, 0xc7, 0x38, 0x9a, 0xca, - 0x95, 0x7c, 0x32, 0xf8, 0x2c, 0x21, 0xcc, 0x19, 0x12, 0x1f, 0x29, 0x9a, 0x8b, 0x2e, 0x16, 0x86, - 0x76, 0xb9, 0x30, 0xb4, 0x5f, 0x0b, 0x43, 0xfb, 0xbc, 0x34, 0x2a, 0x97, 0x4b, 0xa3, 0xf2, 0x7d, - 0x69, 0x54, 0xde, 0x3f, 0xfb, 0x27, 0xf2, 0x47, 0xf1, 0x1c, 0x09, 0xfe, 0xa8, 0x26, 0xa4, 0x8f, - 0x7f, 0x07, 0x00, 0x00, 0xff, 0xff, 0x7b, 0x26, 0x3d, 0x35, 0xdf, 0x04, 0x00, 0x00, + // 826 bytes of a gzipped FileDescriptorProto + 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0xb4, 0x56, 0xcd, 0x6e, 0xdb, 0x46, + 0x10, 0x16, 0x2d, 0xff, 0x48, 0x2b, 0xf9, 0x6f, 0xad, 0xba, 0xac, 0xa5, 0x8a, 0x2a, 0x0b, 0x14, + 0x82, 0x81, 0x4a, 0xb0, 0x0b, 0xb4, 0x45, 0x81, 0xf6, 0xc0, 0xba, 0x68, 0x5d, 0xa0, 0x80, 0x4b, + 0x19, 0x3a, 0xe4, 0x42, 0xac, 0xc8, 0x35, 0x45, 0x98, 0xe2, 0x32, 0xdc, 0x35, 0x11, 0xbf, 0x40, + 0xce, 0x39, 0xe4, 0x29, 0x72, 0xcd, 0x4b, 0xf8, 0xe8, 0x63, 0x92, 0x83, 0x12, 0xd8, 0x6f, 0xa0, + 0x63, 0x4e, 0x01, 0x77, 0x97, 0x92, 0x68, 0x4b, 0x76, 0x12, 0x38, 0x37, 0xee, 0xec, 0x37, 0xdf, + 0x37, 0x33, 0x3b, 0x33, 0x20, 0x68, 0xd8, 0x84, 0x0e, 0x08, 0x6d, 0xbb, 0x24, 0x6e, 0xc7, 0x7b, + 0x3d, 0xcc, 0xd0, 0x5e, 0xdb, 0xc5, 0x01, 0xa6, 0x1e, 0x6d, 0x85, 0x11, 0x61, 0x04, 0x42, 0x81, + 0x68, 0xb9, 0x24, 0x6e, 0x49, 0xc4, 0x4e, 0x6d, 0x96, 0x17, 0x89, 0x85, 0xc7, 0x4e, 0xc5, 0x25, + 0x2e, 0xe1, 0x9f, 0xed, 0xe4, 0x4b, 0x58, 0xf5, 0xe7, 0x05, 0x50, 0xfe, 0x5b, 0x30, 0x77, 0x18, + 0x62, 0x18, 0xfe, 0x0f, 0x2a, 0x94, 0xa1, 0x88, 0x79, 0x81, 0x6b, 0x85, 0x11, 0x09, 0x09, 0x45, + 0xbe, 0xe5, 0x39, 0xaa, 0xd2, 0x50, 0x9a, 0x8b, 0x86, 0x36, 0x1a, 0x6a, 0xd5, 0x73, 0x34, 0xf0, + 0x7f, 0xd3, 0x67, 0xa1, 0x74, 0x13, 0xa6, 0xe6, 0x23, 0x69, 0x3d, 0x74, 0xe0, 0x21, 0x28, 0x38, + 0x38, 0x24, 0xd4, 0x63, 0x54, 0x5d, 0x68, 0xe4, 0x9b, 0xa5, 0xfd, 0x6a, 0xeb, 0x76, 0xf8, 0xad, + 0x03, 0x81, 0x31, 0x36, 0x2e, 0x86, 0x5a, 0xee, 0xc5, 0x5b, 0xad, 0x20, 0x0d, 0xd4, 0x1c, 0xbb, + 0xc3, 0xdf, 0xc1, 0x52, 0x4c, 0x18, 0xa6, 0x6a, 0x9e, 0xf3, 0xa8, 0xb3, 0x78, 0xba, 0x84, 0x61, + 0x63, 0x55, 0x92, 0x2c, 0x25, 0x27, 0x6a, 0x0a, 0x2f, 0xf8, 0x1f, 0x28, 0xa6, 0xd1, 0x52, 0x75, + 0x91, 0x53, 0xd4, 0x66, 0x51, 0xa4, 0xc1, 0x1b, 0x9b, 0x92, 0xa6, 0x98, 0x5a, 0xa8, 0x39, 0x61, + 0x80, 0x2e, 0x58, 0x93, 0x91, 0x59, 0x21, 0x8a, 0xd0, 0x80, 0xaa, 0x4b, 0x0d, 0xa5, 0x59, 0xda, + 0xff, 0xee, 0x8e, 0xf4, 0x8e, 0x38, 0xd0, 0xf8, 0x36, 0x21, 0x1e, 0x0d, 0xb5, 0xaf, 0x44, 0x31, + 0xb3, 0x34, 0xba, 0xb9, 0xea, 0x4c, 0xa3, 0xa1, 0x0d, 0x56, 0x63, 0x22, 0x8a, 0x2d, 0x74, 0x96, + 0xb9, 0x4e, 0x63, 0x4e, 0xfa, 0x49, 0xf9, 0x85, 0x4c, 0x4d, 0xca, 0x54, 0x84, 0x4c, 0x86, 0x44, + 0x37, 0xcb, 0xf1, 0x14, 0x16, 0x5a, 0xa0, 0xcc, 0x90, 0xef, 0x9f, 0xa7, 0x1a, 0x2b, 0x5c, 0x43, + 0x9b, 0xa5, 0x71, 0x9c, 0xe0, 0xa4, 0x44, 0x55, 0x4a, 0x6c, 0x09, 0x89, 0x69, 0x0a, 0xdd, 0x2c, + 0xb1, 0x09, 0x12, 0xfa, 0xe0, 0x9b, 0xe4, 0x19, 0x2c, 0x07, 0xfb, 0xd8, 0x45, 0xcc, 0x23, 0x81, + 0x45, 0x03, 0x14, 0xd2, 0x3e, 0x61, 0x54, 0x2d, 0xf0, 0xd7, 0xd8, 0x9d, 0xf7, 0xa0, 0x07, 0x63, + 0x9f, 0x8e, 0x74, 0x31, 0x16, 0x13, 0x61, 0xf3, 0xeb, 0x78, 0xe6, 0x2d, 0x85, 0x5d, 0xb0, 0x29, + 0x62, 0xc1, 0x3e, 0xb6, 0x19, 0x89, 0x50, 0xd2, 0x36, 0x45, 0xae, 0xf2, 0xfd, 0xdc, 0x9c, 0xfe, + 0x1a, 0x63, 0x25, 0xfd, 0x06, 0xcb, 0x9a, 0x29, 0x7c, 0x0c, 0xb4, 0x9b, 0x59, 0xf4, 0x90, 0x7d, + 0x7a, 0xe2, 0xf9, 0xbe, 0x65, 0x9f, 0x31, 0x72, 0x72, 0xa2, 0x02, 0x3e, 0x2b, 0xbb, 0xa3, 0xa1, + 0xf6, 0xc3, 0xb8, 0xee, 0x77, 0x39, 0xe8, 0x66, 0x2d, 0x9b, 0x85, 0x21, 0xef, 0xff, 0xe4, 0xd7, + 0x30, 0x04, 0xda, 0x80, 0x38, 0x38, 0x0a, 0x2c, 0x91, 0x51, 0x44, 0xce, 0x02, 0x67, 0x7a, 0xee, + 0xa8, 0x5a, 0x6a, 0xe4, 0xb3, 0x92, 0xf7, 0x38, 0xe8, 0x66, 0x55, 0x20, 0x78, 0xda, 0x66, 0x72, + 0x3f, 0x99, 0x58, 0xaa, 0xbf, 0x54, 0xc0, 0xf6, 0xec, 0xb2, 0xc3, 0x5f, 0x40, 0xe9, 0xf6, 0x5e, + 0xd8, 0x1e, 0x0d, 0x35, 0x28, 0x84, 0x33, 0xeb, 0x00, 0x84, 0x93, 0x35, 0x50, 0x11, 0xb3, 0x1b, + 0xa9, 0x0b, 0x0d, 0xa5, 0x59, 0x14, 0x23, 0x19, 0xc1, 0x7f, 0x41, 0x69, 0x52, 0x98, 0x74, 0xae, + 0xf5, 0xfb, 0xdb, 0x40, 0xbe, 0xcf, 0xb4, 0xb3, 0xfe, 0x54, 0x01, 0x6b, 0x59, 0x14, 0xac, 0x81, + 0x62, 0x8c, 0x7c, 0xcf, 0x41, 0x8c, 0x44, 0x3c, 0xd6, 0xa2, 0x39, 0x31, 0xc0, 0x2e, 0x58, 0xa6, + 0x7d, 0x14, 0x61, 0x2a, 0x62, 0x32, 0xfe, 0x48, 0x38, 0xdf, 0x0c, 0xb5, 0x9f, 0x5d, 0x8f, 0xf5, + 0xcf, 0x7a, 0x2d, 0x9b, 0x0c, 0xda, 0x14, 0x7b, 0x3f, 0xf2, 0x5d, 0x69, 0x13, 0x9f, 0x1f, 0xec, + 0x3e, 0xf2, 0x02, 0xf1, 0x25, 0xf6, 0x2d, 0x3b, 0x0f, 0x31, 0x6d, 0x1d, 0x60, 0xdb, 0x94, 0x6c, + 0xfa, 0xeb, 0x05, 0xb0, 0x7e, 0xa3, 0x9f, 0x3e, 0xbf, 0x6e, 0x01, 0xd8, 0x62, 0x84, 0x21, 0xdf, + 0xea, 0x91, 0xc0, 0xc1, 0x8e, 0xc5, 0xc8, 0x29, 0x0e, 0x1e, 0x22, 0xe2, 0xc3, 0x80, 0x99, 0x9b, + 0x9c, 0xda, 0xe0, 0xcc, 0xc7, 0x9c, 0x18, 0xfe, 0x73, 0x63, 0x0f, 0xe4, 0x3f, 0x6e, 0x0f, 0xc8, + 0xf7, 0x98, 0x1e, 0xf8, 0x0e, 0x10, 0xe3, 0x63, 0x8d, 0x2b, 0x9e, 0x6e, 0x5d, 0x7d, 0x2e, 0x5b, + 0x37, 0x85, 0x4a, 0xc2, 0x75, 0x96, 0xb1, 0x52, 0xfd, 0xbd, 0x02, 0xd6, 0xb2, 0x48, 0xa8, 0x82, + 0x15, 0xe4, 0x38, 0x11, 0xa6, 0x54, 0x3e, 0x71, 0x7a, 0x4c, 0x16, 0xe7, 0x97, 0xa8, 0x5a, 0xb9, + 0x37, 0x5d, 0x30, 0x0f, 0x6c, 0xc8, 0x2e, 0x24, 0x91, 0x25, 0xfb, 0x29, 0xff, 0x20, 0xfd, 0xb4, + 0x3e, 0xe6, 0xed, 0x70, 0x5a, 0xc3, 0xbc, 0xb8, 0xaa, 0x2b, 0x97, 0x57, 0x75, 0xe5, 0xdd, 0x55, + 0x5d, 0x79, 0x76, 0x5d, 0xcf, 0x5d, 0x5e, 0xd7, 0x73, 0xaf, 0xae, 0xeb, 0xb9, 0x47, 0xbf, 0x7e, + 0x92, 0xc4, 0x13, 0xfe, 0x93, 0xc0, 0x85, 0x7a, 0xcb, 0x1c, 0xfa, 0xd3, 0x87, 0x00, 0x00, 0x00, + 0xff, 0xff, 0x9a, 0x07, 0xad, 0x85, 0x75, 0x08, 0x00, 0x00, } func (m *GenesisState) Marshal() (dAtA []byte, err error) { @@ -309,6 +462,43 @@ func (m *GenesisState) MarshalToSizedBuffer(dAtA []byte) (int, error) { _ = i var l int _ = l + if len(m.ModernTallyRoundProposalIds) > 0 { + dAtA2 := make([]byte, len(m.ModernTallyRoundProposalIds)*10) + var j1 int + for _, num := range m.ModernTallyRoundProposalIds { + for num >= 1<<7 { + dAtA2[j1] = uint8(uint64(num)&0x7f | 0x80) + num >>= 7 + j1++ + } + dAtA2[j1] = uint8(num) + j1++ + } + i -= j1 + copy(dAtA[i:], dAtA2[:j1]) + i = encodeVarintGenesis(dAtA, i, uint64(j1)) + i-- + dAtA[i] = 0x5a + } + if m.VoteDelegationBackfillCutoff != 0 { + i = encodeVarintGenesis(dAtA, i, uint64(m.VoteDelegationBackfillCutoff)) + i-- + dAtA[i] = 0x50 + } + if len(m.TallyElectorates) > 0 { + for iNdEx := len(m.TallyElectorates) - 1; iNdEx >= 0; iNdEx-- { + { + size, err := m.TallyElectorates[iNdEx].MarshalToSizedBuffer(dAtA[:i]) + if err != nil { + return 0, err + } + i -= size + i = encodeVarintGenesis(dAtA, i, uint64(size)) + } + i-- + dAtA[i] = 0x4a + } + } if len(m.VoteDelegationSnapshots) > 0 { for iNdEx := len(m.VoteDelegationSnapshots) - 1; iNdEx >= 0; iNdEx-- { { @@ -492,6 +682,118 @@ func (m *VoteDelegation) MarshalToSizedBuffer(dAtA []byte) (int, error) { return len(dAtA) - i, nil } +func (m *TallyElectorate) Marshal() (dAtA []byte, err error) { + size := m.Size() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBuffer(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *TallyElectorate) MarshalTo(dAtA []byte) (int, error) { + size := m.Size() + return m.MarshalToSizedBuffer(dAtA[:size]) +} + +func (m *TallyElectorate) MarshalToSizedBuffer(dAtA []byte) (int, error) { + i := len(dAtA) + _ = i + var l int + _ = l + if len(m.TallyValidators) > 0 { + for iNdEx := len(m.TallyValidators) - 1; iNdEx >= 0; iNdEx-- { + { + size, err := m.TallyValidators[iNdEx].MarshalToSizedBuffer(dAtA[:i]) + if err != nil { + return 0, err + } + i -= size + i = encodeVarintGenesis(dAtA, i, uint64(size)) + } + i-- + dAtA[i] = 0x22 + } + } + { + size, err := m.TallyParams.MarshalToSizedBuffer(dAtA[:i]) + if err != nil { + return 0, err + } + i -= size + i = encodeVarintGenesis(dAtA, i, uint64(size)) + } + i-- + dAtA[i] = 0x1a + { + size := m.TotalBondedTokens.Size() + i -= size + if _, err := m.TotalBondedTokens.MarshalTo(dAtA[i:]); err != nil { + return 0, err + } + i = encodeVarintGenesis(dAtA, i, uint64(size)) + } + i-- + dAtA[i] = 0x12 + if m.ProposalId != 0 { + i = encodeVarintGenesis(dAtA, i, uint64(m.ProposalId)) + i-- + dAtA[i] = 0x8 + } + return len(dAtA) - i, nil +} + +func (m *TallyValidator) Marshal() (dAtA []byte, err error) { + size := m.Size() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBuffer(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *TallyValidator) MarshalTo(dAtA []byte) (int, error) { + size := m.Size() + return m.MarshalToSizedBuffer(dAtA[:size]) +} + +func (m *TallyValidator) MarshalToSizedBuffer(dAtA []byte) (int, error) { + i := len(dAtA) + _ = i + var l int + _ = l + { + size := m.DelegatorShares.Size() + i -= size + if _, err := m.DelegatorShares.MarshalTo(dAtA[i:]); err != nil { + return 0, err + } + i = encodeVarintGenesis(dAtA, i, uint64(size)) + } + i-- + dAtA[i] = 0x1a + { + size := m.BondedTokens.Size() + i -= size + if _, err := m.BondedTokens.MarshalTo(dAtA[i:]); err != nil { + return 0, err + } + i = encodeVarintGenesis(dAtA, i, uint64(size)) + } + i-- + dAtA[i] = 0x12 + if len(m.Address) > 0 { + i -= len(m.Address) + copy(dAtA[i:], m.Address) + i = encodeVarintGenesis(dAtA, i, uint64(len(m.Address))) + i-- + dAtA[i] = 0xa + } + return len(dAtA) - i, nil +} + func encodeVarintGenesis(dAtA []byte, offset int, v uint64) int { offset -= sovGenesis(v) base := offset @@ -542,6 +844,22 @@ func (m *GenesisState) Size() (n int) { n += 1 + l + sovGenesis(uint64(l)) } } + if len(m.TallyElectorates) > 0 { + for _, e := range m.TallyElectorates { + l = e.Size() + n += 1 + l + sovGenesis(uint64(l)) + } + } + if m.VoteDelegationBackfillCutoff != 0 { + n += 1 + sovGenesis(uint64(m.VoteDelegationBackfillCutoff)) + } + if len(m.ModernTallyRoundProposalIds) > 0 { + l = 0 + for _, e := range m.ModernTallyRoundProposalIds { + l += sovGenesis(uint64(e)) + } + n += 1 + sovGenesis(uint64(l)) + l + } return n } @@ -582,6 +900,45 @@ func (m *VoteDelegation) Size() (n int) { return n } +func (m *TallyElectorate) Size() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + if m.ProposalId != 0 { + n += 1 + sovGenesis(uint64(m.ProposalId)) + } + l = m.TotalBondedTokens.Size() + n += 1 + l + sovGenesis(uint64(l)) + l = m.TallyParams.Size() + n += 1 + l + sovGenesis(uint64(l)) + if len(m.TallyValidators) > 0 { + for _, e := range m.TallyValidators { + l = e.Size() + n += 1 + l + sovGenesis(uint64(l)) + } + } + return n +} + +func (m *TallyValidator) Size() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + l = len(m.Address) + if l > 0 { + n += 1 + l + sovGenesis(uint64(l)) + } + l = m.BondedTokens.Size() + n += 1 + l + sovGenesis(uint64(l)) + l = m.DelegatorShares.Size() + n += 1 + l + sovGenesis(uint64(l)) + return n +} + func sovGenesis(x uint64) (n int) { return (math_bits.Len64(x|1) + 6) / 7 } @@ -871,22 +1228,151 @@ func (m *GenesisState) Unmarshal(dAtA []byte) error { return err } iNdEx = postIndex - default: - iNdEx = preIndex - skippy, err := skipGenesis(dAtA[iNdEx:]) - if err != nil { - return err + case 9: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field TallyElectorates", wireType) } - if (skippy < 0) || (iNdEx+skippy) < 0 { + var msglen int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowGenesis + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + msglen |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + if msglen < 0 { return ErrInvalidLengthGenesis } - if (iNdEx + skippy) > l { + postIndex := iNdEx + msglen + if postIndex < 0 { + return ErrInvalidLengthGenesis + } + if postIndex > l { return io.ErrUnexpectedEOF } - iNdEx += skippy - } - } - + m.TallyElectorates = append(m.TallyElectorates, TallyElectorate{}) + if err := m.TallyElectorates[len(m.TallyElectorates)-1].Unmarshal(dAtA[iNdEx:postIndex]); err != nil { + return err + } + iNdEx = postIndex + case 10: + if wireType != 0 { + return fmt.Errorf("proto: wrong wireType = %d for field VoteDelegationBackfillCutoff", wireType) + } + m.VoteDelegationBackfillCutoff = 0 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowGenesis + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + m.VoteDelegationBackfillCutoff |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + case 11: + if wireType == 0 { + var v uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowGenesis + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + v |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + m.ModernTallyRoundProposalIds = append(m.ModernTallyRoundProposalIds, v) + } else if wireType == 2 { + var packedLen int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowGenesis + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + packedLen |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + if packedLen < 0 { + return ErrInvalidLengthGenesis + } + postIndex := iNdEx + packedLen + if postIndex < 0 { + return ErrInvalidLengthGenesis + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + var elementCount int + var count int + for _, integer := range dAtA[iNdEx:postIndex] { + if integer < 128 { + count++ + } + } + elementCount = count + if elementCount != 0 && len(m.ModernTallyRoundProposalIds) == 0 { + m.ModernTallyRoundProposalIds = make([]uint64, 0, elementCount) + } + for iNdEx < postIndex { + var v uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowGenesis + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + v |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + m.ModernTallyRoundProposalIds = append(m.ModernTallyRoundProposalIds, v) + } + } else { + return fmt.Errorf("proto: wrong wireType = %d for field ModernTallyRoundProposalIds", wireType) + } + default: + iNdEx = preIndex + skippy, err := skipGenesis(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLengthGenesis + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + iNdEx += skippy + } + } + if iNdEx > l { return io.ErrUnexpectedEOF } @@ -1143,6 +1629,326 @@ func (m *VoteDelegation) Unmarshal(dAtA []byte) error { } return nil } +func (m *TallyElectorate) Unmarshal(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowGenesis + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: TallyElectorate: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: TallyElectorate: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 0 { + return fmt.Errorf("proto: wrong wireType = %d for field ProposalId", wireType) + } + m.ProposalId = 0 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowGenesis + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + m.ProposalId |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + case 2: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field TotalBondedTokens", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowGenesis + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLengthGenesis + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLengthGenesis + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + if err := m.TotalBondedTokens.Unmarshal(dAtA[iNdEx:postIndex]); err != nil { + return err + } + iNdEx = postIndex + case 3: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field TallyParams", wireType) + } + var msglen int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowGenesis + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + msglen |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + if msglen < 0 { + return ErrInvalidLengthGenesis + } + postIndex := iNdEx + msglen + if postIndex < 0 { + return ErrInvalidLengthGenesis + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + if err := m.TallyParams.Unmarshal(dAtA[iNdEx:postIndex]); err != nil { + return err + } + iNdEx = postIndex + case 4: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field TallyValidators", wireType) + } + var msglen int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowGenesis + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + msglen |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + if msglen < 0 { + return ErrInvalidLengthGenesis + } + postIndex := iNdEx + msglen + if postIndex < 0 { + return ErrInvalidLengthGenesis + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.TallyValidators = append(m.TallyValidators, TallyValidator{}) + if err := m.TallyValidators[len(m.TallyValidators)-1].Unmarshal(dAtA[iNdEx:postIndex]); err != nil { + return err + } + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skipGenesis(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLengthGenesis + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *TallyValidator) Unmarshal(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowGenesis + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: TallyValidator: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: TallyValidator: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Address", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowGenesis + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLengthGenesis + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLengthGenesis + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Address = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + case 2: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field BondedTokens", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowGenesis + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLengthGenesis + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLengthGenesis + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + if err := m.BondedTokens.Unmarshal(dAtA[iNdEx:postIndex]); err != nil { + return err + } + iNdEx = postIndex + case 3: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field DelegatorShares", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowGenesis + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLengthGenesis + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLengthGenesis + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + if err := m.DelegatorShares.Unmarshal(dAtA[iNdEx:postIndex]); err != nil { + return err + } + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skipGenesis(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLengthGenesis + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} func skipGenesis(dAtA []byte) (n int, err error) { l := len(dAtA) iNdEx := 0 diff --git a/sei-cosmos/x/gov/types/genesis_test.go b/sei-cosmos/x/gov/types/genesis_test.go index cb8d4b8a7a..644f0908ca 100644 --- a/sei-cosmos/x/gov/types/genesis_test.go +++ b/sei-cosmos/x/gov/types/genesis_test.go @@ -1,6 +1,7 @@ package types import ( + "bytes" "testing" sdk "github.com/sei-protocol/sei-chain/sei-cosmos/types" @@ -22,6 +23,76 @@ func TestEqualProposalID(t *testing.T) { require.True(t, state1.Equal(state2)) } +func TestGenesisStateEqualIncludesTallyElectorates(t *testing.T) { + state1 := GenesisState{TallyElectorates: []TallyElectorate{validTallyElectorate(1)}} + state2 := state1 + require.True(t, state1.Equal(state2)) + + state2.TallyElectorates = nil + require.False(t, state1.Equal(state2)) +} + +func TestGenesisStateEqualIncludesVoteDelegationBackfillCutoff(t *testing.T) { + state1 := GenesisState{VoteDelegationBackfillCutoff: 3} + state2 := state1 + require.True(t, state1.Equal(state2)) + + state2.VoteDelegationBackfillCutoff = 4 + require.False(t, state1.Equal(state2)) +} + +func TestGenesisStateEqualIncludesModernTallyRounds(t *testing.T) { + state1 := GenesisState{ModernTallyRoundProposalIds: []uint64{1}} + state2 := state1 + require.True(t, state1.Equal(state2)) + + state2.ModernTallyRoundProposalIds = nil + require.False(t, state1.Equal(state2)) +} + +func TestValidateGenesisModernTallyRounds(t *testing.T) { + state := DefaultGenesisState() + state.VoteDelegationBackfillCutoff = 2 + state.Proposals = Proposals{{ProposalId: 1, Status: StatusVotingPeriod}} + state.ModernTallyRoundProposalIds = []uint64{1} + require.NoError(t, ValidateGenesis(state)) + + state.ModernTallyRoundProposalIds = []uint64{1, 1} + require.ErrorContains(t, ValidateGenesis(state), "duplicate modern tally round") + + state.ModernTallyRoundProposalIds = []uint64{2} + require.ErrorContains(t, ValidateGenesis(state), "has no voting-period proposal") + + state.Proposals[0].IsExpedited = true + state.ModernTallyRoundProposalIds = []uint64{1} + require.ErrorContains(t, ValidateGenesis(state), "is expedited") + + state.Proposals[0].IsExpedited = false + voter := sdk.AccAddress(bytes.Repeat([]byte{1}, 20)) + state.Votes = Votes{NewVote(1, voter, NewNonSplitVoteOption(OptionYes))} + require.ErrorContains(t, ValidateGenesis(state), "modern tally round vote") + + state.VoteDelegationSnapshots = []VoteDelegationSnapshot{{ProposalId: 1, Voter: voter.String()}} + require.NoError(t, ValidateGenesis(state)) +} + +func TestValidateGenesisRequiresSnapshotsForFrozenElectorateVotes(t *testing.T) { + voter := sdk.AccAddress(bytes.Repeat([]byte{1}, 20)) + state := DefaultGenesisState() + state.Proposals = Proposals{{ProposalId: 1, Status: StatusVotingPeriod}} + state.Votes = Votes{NewVote(1, voter, NewNonSplitVoteOption(OptionYes))} + state.TallyElectorates = []TallyElectorate{validTallyElectorate(1)} + + err := ValidateGenesis(state) + require.ErrorContains(t, err, "has no delegation snapshot") + + state.VoteDelegationSnapshots = []VoteDelegationSnapshot{{ + ProposalId: 1, + Voter: voter.String(), + }} + require.NoError(t, ValidateGenesis(state)) +} + func TestGenesisStateEqualIncludesVoteDelegationSnapshots(t *testing.T) { state1 := GenesisState{VoteDelegationSnapshots: []VoteDelegationSnapshot{{ ProposalId: 1, @@ -43,3 +114,17 @@ func TestValidateGenesis(t *testing.T) { require.Error(t, ValidateGenesis(&GenesisState{})) require.Error(t, ValidateGenesis(nil)) } + +func validTallyElectorate(proposalID uint64) TallyElectorate { + validator := sdk.ValAddress(bytes.Repeat([]byte{2}, 20)) + return TallyElectorate{ + ProposalId: proposalID, + TotalBondedTokens: sdk.OneInt(), + TallyParams: DefaultTallyParams(), + TallyValidators: []TallyValidator{{ + Address: validator.String(), + BondedTokens: sdk.OneInt(), + DelegatorShares: sdk.OneDec(), + }}, + } +} diff --git a/sei-cosmos/x/gov/types/keys.go b/sei-cosmos/x/gov/types/keys.go index f15bda413e..2cf7faefa0 100644 --- a/sei-cosmos/x/gov/types/keys.go +++ b/sei-cosmos/x/gov/types/keys.go @@ -54,6 +54,30 @@ const ( // - 0x36: First proposal ID that does not require delegation-tracking backfill // // - 0x37: Delegation-tracking backfill cursor +// +// - 0x38: Latest deferred vote-delegation update sequence +// +// - 0x39: Deferred vote-delegation update +// +// - 0x3A: Deferred update index by voter +// +// - 0x3B: Last applied delegation update sequence +// +// - 0x3C: Proposal deadline awaiting tally completion +// +// - 0x3D: Last block time checked for proposal deadlines +// +// - 0x3E: Frozen tally electorate +// +// - 0x3F: Frozen electorate for deadlines between block times +// +// - 0x40: Frozen electorate for deadlines equal to a block time +// +// - 0x41: Frozen electorate selected for a proposal tally round +// +// - 0x42: Incremental tally activation marker + +// - 0x43: Legacy proposal's post-expedited modern tally round var ( ProposalsKeyPrefix = []byte{0x00} ActiveProposalQueuePrefix = []byte{0x01} @@ -73,6 +97,20 @@ var ( VoteDelegationBackfillCutoffKey = []byte{0x36} VoteDelegationBackfillProgressKeyPrefix = []byte{0x37} + + VoteDelegationUpdateSequenceKey = []byte{0x38} + VoteDelegationUpdatesKeyPrefix = []byte{0x39} + VoterVoteDelegationUpdatesKeyPrefix = []byte{0x3A} + VoteDelegationSnapshotRevisionKeyPrefix = []byte{0x3B} + + ProposalDeadlineKeyPrefix = []byte{0x3C} + DeadlineBoundaryBlockTimeKey = []byte{0x3D} + TallyBoundaryMetaKeyPrefix = []byte{0x3E} + GapTallyBoundaryKeyPrefix = []byte{0x3F} + ExactTallyBoundaryKeyPrefix = []byte{0x40} + ProposalTallyBoundaryKeyPrefix = []byte{0x41} + IncrementalTallyEnabledKey = []byte{0x42} + ModernTallyRoundKeyPrefix = []byte{0x43} ) var lenTime = len(sdk.FormatTimeBytes(time.Now())) @@ -159,6 +197,61 @@ func VoteDelegationBackfillProgressKey(proposalID uint64) []byte { return append(VoteDelegationBackfillProgressKeyPrefix, GetProposalIDBytes(proposalID)...) } +// VoteDelegationUpdateKey returns the key for a deferred delegation snapshot update. +func VoteDelegationUpdateKey(sequence uint64) []byte { + return append(VoteDelegationUpdatesKeyPrefix, GetProposalIDBytes(sequence)...) +} + +// VoterVoteDelegationUpdatesKeyPrefixForAddress returns a voter's deferred-update index prefix. +func VoterVoteDelegationUpdatesKeyPrefixForAddress(voterAddr sdk.AccAddress) []byte { + return append(VoterVoteDelegationUpdatesKeyPrefix, address.MustLengthPrefix(voterAddr.Bytes())...) +} + +// VoterVoteDelegationUpdateKey returns a voter's deferred-update index key. +func VoterVoteDelegationUpdateKey(voterAddr sdk.AccAddress, sequence uint64) []byte { + return append(VoterVoteDelegationUpdatesKeyPrefixForAddress(voterAddr), GetProposalIDBytes(sequence)...) +} + +// VoteDelegationSnapshotRevisionKey returns a vote snapshot's applied-update sequence key. +func VoteDelegationSnapshotRevisionKey(proposalID uint64, voterAddr sdk.AccAddress) []byte { + return append(append(VoteDelegationSnapshotRevisionKeyPrefix, GetProposalIDBytes(proposalID)...), address.MustLengthPrefix(voterAddr.Bytes())...) +} + +// ProposalDeadlineByTimeKey returns the proposal-deadline prefix for an end time. +func ProposalDeadlineByTimeKey(endTime time.Time) []byte { + return append(ProposalDeadlineKeyPrefix, sdk.FormatTimeBytes(endTime)...) +} + +// ProposalDeadlineKey returns the deadline key for one proposal tally round. +func ProposalDeadlineKey(proposalID uint64, endTime time.Time) []byte { + return append(ProposalDeadlineByTimeKey(endTime), GetProposalIDBytes(proposalID)...) +} + +// TallyBoundaryMetaKey returns the frozen electorate key for a boundary identifier. +func TallyBoundaryMetaKey(boundaryID []byte) []byte { + return append(TallyBoundaryMetaKeyPrefix, boundaryID...) +} + +// GapTallyBoundaryKey returns the boundary index for deadlines before a block time. +func GapTallyBoundaryKey(upperTime time.Time) []byte { + return append(GapTallyBoundaryKeyPrefix, sdk.FormatTimeBytes(upperTime)...) +} + +// ExactTallyBoundaryKey returns the boundary index for deadlines equal to a block time. +func ExactTallyBoundaryKey(endTime time.Time) []byte { + return append(ExactTallyBoundaryKeyPrefix, sdk.FormatTimeBytes(endTime)...) +} + +// ProposalTallyBoundaryKey returns the selected-boundary key for a proposal. +func ProposalTallyBoundaryKey(proposalID uint64) []byte { + return append(ProposalTallyBoundaryKeyPrefix, GetProposalIDBytes(proposalID)...) +} + +// ModernTallyRoundKey returns the marker for a post-expedited tally round using deadline semantics. +func ModernTallyRoundKey(proposalID uint64) []byte { + return append(ModernTallyRoundKeyPrefix, 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)) diff --git a/sei-cosmos/x/gov/types/keys_test.go b/sei-cosmos/x/gov/types/keys_test.go index 01585cc53a..d20a944d5a 100644 --- a/sei-cosmos/x/gov/types/keys_test.go +++ b/sei-cosmos/x/gov/types/keys_test.go @@ -71,6 +71,11 @@ func TestTallyKeys(t *testing.T) { require.Equal(t, append(append(VoterProposalsKeyPrefix, address.MustLengthPrefix(addr.Bytes())...), GetProposalIDBytes(2)...), VoterProposalsKey(addr, 2)) require.Equal(t, []byte{0x36}, VoteDelegationBackfillCutoffKey) require.Equal(t, append(VoteDelegationBackfillProgressKeyPrefix, GetProposalIDBytes(2)...), VoteDelegationBackfillProgressKey(2)) + require.Equal(t, append(VoteDelegationUpdatesKeyPrefix, GetProposalIDBytes(3)...), VoteDelegationUpdateKey(3)) + require.Equal(t, append(append(VoterVoteDelegationUpdatesKeyPrefix, address.MustLengthPrefix(addr.Bytes())...), GetProposalIDBytes(3)...), VoterVoteDelegationUpdateKey(addr, 3)) + require.Equal(t, append(append(VoteDelegationSnapshotRevisionKeyPrefix, GetProposalIDBytes(2)...), address.MustLengthPrefix(addr.Bytes())...), VoteDelegationSnapshotRevisionKey(2, addr)) + require.Equal(t, []byte{0x42}, IncrementalTallyEnabledKey) + require.Equal(t, append(ModernTallyRoundKeyPrefix, GetProposalIDBytes(2)...), ModernTallyRoundKey(2)) for _, expedited := range []bool{false, true} { proposalID, decodedExpedited := SplitTallyCleanupKey(TallyCleanupKey(2, expedited)) diff --git a/sei-cosmos/x/staking/keeper/slash.go b/sei-cosmos/x/staking/keeper/slash.go index 49b04c4890..7d350ddd26 100644 --- a/sei-cosmos/x/staking/keeper/slash.go +++ b/sei-cosmos/x/staking/keeper/slash.go @@ -266,7 +266,8 @@ func (k Keeper) SlashRedelegation(ctx sdk.Context, srcValidator types.Validator, sharesToUnbond = delegation.Shares } - tokensToBurn, err := k.Unbond(ctx, delegatorAddress, valDstAddr, sharesToUnbond) + slashCtx := types.WithSlashDelegationModification(ctx) + tokensToBurn, err := k.Unbond(slashCtx, delegatorAddress, valDstAddr, sharesToUnbond) if err != nil { panic(fmt.Errorf("error unbonding delegator: %v", err)) } diff --git a/sei-cosmos/x/staking/types/slash_context.go b/sei-cosmos/x/staking/types/slash_context.go new file mode 100644 index 0000000000..f4bfe5a37f --- /dev/null +++ b/sei-cosmos/x/staking/types/slash_context.go @@ -0,0 +1,20 @@ +package types + +import ( + "context" + + sdk "github.com/sei-protocol/sei-chain/sei-cosmos/types" +) + +type slashDelegationModificationKey struct{} + +// WithSlashDelegationModification marks delegation changes made while applying a validator slash. +func WithSlashDelegationModification(ctx sdk.Context) sdk.Context { + return ctx.WithContext(context.WithValue(ctx.Context(), slashDelegationModificationKey{}, true)) +} + +// IsSlashDelegationModification reports whether a delegation change is part of a validator slash. +func IsSlashDelegationModification(ctx sdk.Context) bool { + marked, _ := ctx.Context().Value(slashDelegationModificationKey{}).(bool) + return marked +} diff --git a/sei-wasmd/app/app.go b/sei-wasmd/app/app.go index 4772c143c8..609948c172 100644 --- a/sei-wasmd/app/app.go +++ b/sei-wasmd/app/app.go @@ -531,6 +531,7 @@ func (app *WasmApp) ProcessProposalHandler(ctx sdk.Context, req *abci.RequestPro } func (app *WasmApp) FinalizeBlocker(ctx sdk.Context, req *abci.RequestFinalizeBlock) (*abci.ResponseFinalizeBlock, error) { + gov.BeginBlocker(ctx, app.govKeeper) distr.BeginBlocker(ctx, []abci.VoteInfo{}, app.distrKeeper) slashing.BeginBlocker(ctx, []abci.VoteInfo{}, app.slashingKeeper) evidence.BeginBlocker(ctx, []abci.Misbehavior{}, app.evidenceKeeper) From 43403f3d544e397d1bde7e40853398d74db1c531 Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Mon, 31 Aug 2026 18:37:48 +0800 Subject: [PATCH 12/17] test(gov): close export test apps --- sei-cosmos/x/gov/genesis_test.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/sei-cosmos/x/gov/genesis_test.go b/sei-cosmos/x/gov/genesis_test.go index 3e7bd4ff56..f7cd676a97 100644 --- a/sei-cosmos/x/gov/genesis_test.go +++ b/sei-cosmos/x/gov/genesis_test.go @@ -379,6 +379,9 @@ func TestImportExportPreservesModernTallyRound(t *testing.T) { func TestImportExportCanonicalizesPartialLegacyVoteDelegationBackfill(t *testing.T) { app := seiapp.Setup(t, false, false, false) + t.Cleanup(func() { + require.NoError(t, app.Close()) + }) ctx := app.BaseApp.NewContext(false, tmproto.Header{}) addrs := seiapp.AddTestAddrs(app, ctx, 2, valTokens) SortAddresses(addrs) @@ -457,6 +460,9 @@ func TestImportExportCanonicalizesPartialLegacyVoteDelegationBackfill(t *testing require.NoError(t, err) importedApp := seiapp.SetupWithDB(t, dbm.NewMemDB(), false, false, false) + t.Cleanup(func() { + require.NoError(t, importedApp.Close()) + }) _, err = importedApp.InitChain(&abci.RequestInitChain{ ConsensusParams: seiapp.DefaultConsensusParams, AppStateBytes: stateBytes, From 7c7d0549bc29f65ad0ca88cf12a70a3d4775988f Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Mon, 31 Aug 2026 20:20:55 +0800 Subject: [PATCH 13/17] test(evmrpc): make simulation limiter checks deterministic --- evmrpc/export_test.go | 16 +++++ evmrpc/simulate_test.go | 145 +++++++++------------------------------- 2 files changed, 46 insertions(+), 115 deletions(-) diff --git a/evmrpc/export_test.go b/evmrpc/export_test.go index f5285e3808..53714dd11f 100644 --- a/evmrpc/export_test.go +++ b/evmrpc/export_test.go @@ -2,6 +2,7 @@ package evmrpc import ( "context" + "errors" "math/big" "sync" @@ -18,6 +19,21 @@ import ( "github.com/sei-protocol/sei-chain/x/evm/keeper" ) +// HoldSimulationSlotsForTest occupies simulation request slots until the returned function is called. +func (s *SimulationAPI) HoldSimulationSlotsForTest(ctx context.Context, slots int64) (func(), error) { + if s.requestLimiter == nil { + return nil, errors.New("simulation request limiter is disabled") + } + if slots <= 0 { + return nil, errors.New("simulation slot count must be positive") + } + if err := s.requestLimiter.Acquire(ctx, slots); err != nil { + return nil, err + } + + return func() { s.requestLimiter.Release(slots) }, nil +} + // RangeQueryWindowBlocksForTest exposes rangeQueryWindowBlocks so integration // tests can assert tryFilterLogsRange's window boundaries without hardcoding // the constant. diff --git a/evmrpc/simulate_test.go b/evmrpc/simulate_test.go index 47092273a2..5328418665 100644 --- a/evmrpc/simulate_test.go +++ b/evmrpc/simulate_test.go @@ -606,6 +606,7 @@ func TestSimulateBackendBlockResolutionCoverage(t *testing.T) { } func TestSimulationAPIRequestLimiter(t *testing.T) { + const maxConcurrentSimulationCalls = 2 type testEnv struct { simAPI *evmrpc.SimulationAPI @@ -632,7 +633,7 @@ func TestSimulationAPIRequestLimiter(t *testing.T) { config := &evmrpc.SimulateConfig{ GasCap: 1000000, EVMTimeout: 5 * time.Second, - MaxConcurrentSimulationCalls: 2, // Small limit to easily trigger rate limiting + MaxConcurrentSimulationCalls: maxConcurrentSimulationCalls, } // Use the existing test app from the global setup @@ -678,9 +679,21 @@ func TestSimulationAPIRequestLimiter(t *testing.T) { args: args, } } + assertRateLimited := func(t *testing.T, tEnv *testEnv, expected string, invoke func() error) { + t.Helper() + release, err := tEnv.simAPI.HoldSimulationSlotsForTest(t.Context(), maxConcurrentSimulationCalls) + require.NoError(t, err) + defer release() + require.EqualError(t, invoke(), expected) + } t.Run("TestEthCallRateLimiting", func(t *testing.T) { tEnv := newTestEnv(t) + assertRateLimited(t, tEnv, "eth_call rejected due to rate limit: server busy", func() error { + _, err := tEnv.simAPI.Call(t.Context(), tEnv.args, nil, nil, nil) + return err + }) + // Test eth_call rate limiting with concurrent requests numRequests := 10 // Much more than the limit of 2 runBurst := func() []error { @@ -721,8 +734,9 @@ func TestSimulationAPIRequestLimiter(t *testing.T) { } } - // With only 2 concurrent slots and 10 requests, we should have rejections - require.Greater(t, rejectedCount, 0, "Should have rejected requests due to rate limiting") + // Calls can serialize before another goroutine holds a slot. The saturated + // call above covers rejection deterministically; this burst covers the + // concurrent response outcomes. require.Greater(t, successCount, 0, "Should have some successful requests") require.Equal(t, numRequests, successCount+rejectedCount, "All requests should be accounted for") @@ -893,122 +907,23 @@ func TestSimulationAPIRequestLimiter(t *testing.T) { }) t.Run("TestDifferentMethodsShareSameLimiter", func(t *testing.T) { - // Test that different simulation methods share the same rate limiter. - // A single burst can occasionally avoid contention on overloaded CI workers, - // so retry a synchronized burst a few times. - const ( - numCallRequests = 20 - numEstimateRequests = 20 - maxAttempts = 5 - ) - totalRequests := numCallRequests + numEstimateRequests - - runMixedBurst := func(tEnv *testEnv) (int, int) { - results := make(chan error, totalRequests) - start := make(chan struct{}) - var wg sync.WaitGroup - - // Start mixed requests and release them at once to maximize contention. - for range numCallRequests { - wg.Go(func() { - <-start - _, err := tEnv.simAPI.Call(t.Context(), tEnv.args, nil, nil, nil) - results <- err - }) - } - for range numEstimateRequests { - wg.Go(func() { - <-start - _, err := tEnv.simAPI.EstimateGas(t.Context(), tEnv.args, nil, nil) - results <- err - }) - } - - close(start) - wg.Wait() - close(results) - - successCount := 0 - rejectedCount := 0 - for err := range results { - if err == nil { - successCount++ - } else if strings.Contains(err.Error(), "rejected due to rate limit: server busy") { - rejectedCount++ - } - } - return successCount, rejectedCount - } - - var ( - lastSuccess int - lastRejected int - attemptsUsed int - observedRejection bool - ) - for attempt := 1; attempt <= maxAttempts; attempt++ { - attemptsUsed = attempt - lastSuccess, lastRejected = runMixedBurst(newTestEnv(t)) - require.Equalf(t, totalRequests, lastSuccess+lastRejected, "All mixed method requests should be accounted for (attempt %d)", attempt) - if lastRejected > 0 { - observedRejection = true - break - } - } - - require.Truef( - t, - observedRejection, - "Different methods should share the same rate limiter (last burst: %d successful, %d rejected)", - lastSuccess, - lastRejected, - ) - t.Logf( - "Mixed methods rate limiting (attempt %d/%d): %d successful, %d rejected out of %d total", - attemptsUsed, - maxAttempts, - lastSuccess, - lastRejected, - totalRequests, - ) + tEnv := newTestEnv(t) + assertRateLimited(t, tEnv, "eth_call rejected due to rate limit: server busy", func() error { + _, err := tEnv.simAPI.Call(t.Context(), tEnv.args, nil, nil, nil) + return err + }) + assertRateLimited(t, tEnv, "eth_estimateGas rejected due to rate limit: server busy", func() error { + _, err := tEnv.simAPI.EstimateGas(t.Context(), tEnv.args, nil, nil) + return err + }) }) t.Run("TestRateLimitErrorFormat", func(t *testing.T) { tEnv := newTestEnv(t) - // Test the error message format by overwhelming the rate limiter - const numRequests = 20 - results := make(chan error, numRequests) - start := make(chan struct{}) - var wg sync.WaitGroup - - // Release all requests at once to reliably saturate the limiter. - for range numRequests { - wg.Go(func() { - <-start - _, err := tEnv.simAPI.Call(t.Context(), tEnv.args, nil, nil, nil) - results <- err - }) - } - close(start) - wg.Wait() - close(results) - - var rateLimitErrors []error - for err := range results { - if err != nil && strings.Contains(err.Error(), "rejected due to rate limit") { - rateLimitErrors = append(rateLimitErrors, err) - } - } - - require.Greater(t, len(rateLimitErrors), 0, "Should have at least one rate limit error") - - // Verify error message format - for _, err := range rateLimitErrors { - require.Contains(t, err.Error(), "eth_call rejected due to rate limit: server busy") - require.Contains(t, err.Error(), "server busy") - } - - t.Logf("Found %d rate limit errors with correct format", len(rateLimitErrors)) + assertRateLimited(t, tEnv, "eth_createAccessList rejected due to rate limit: server busy", func() error { + _, err := tEnv.simAPI.CreateAccessList(t.Context(), tEnv.args, nil) + return err + }) }) } From 09de5a181aa914331566a9a5bc8a763ceb22a5e1 Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Wed, 2 Sep 2026 10:53:00 +0800 Subject: [PATCH 14/17] fix(gov): charge deferred vote tally work --- sei-cosmos/x/gov/keeper/msg_server.go | 14 +++- sei-cosmos/x/gov/keeper/vote.go | 26 +++++-- sei-cosmos/x/gov/keeper/vote_test.go | 104 ++++++++++++++++++++++++++ sei-cosmos/x/gov/types/gas.go | 8 ++ 4 files changed, 143 insertions(+), 9 deletions(-) create mode 100644 sei-cosmos/x/gov/types/gas.go diff --git a/sei-cosmos/x/gov/keeper/msg_server.go b/sei-cosmos/x/gov/keeper/msg_server.go index c2f2763080..95fc851967 100644 --- a/sei-cosmos/x/gov/keeper/msg_server.go +++ b/sei-cosmos/x/gov/keeper/msg_server.go @@ -12,6 +12,14 @@ type msgServer struct { Keeper } +func chargeDeferredVoteTallyGas(ctx sdk.Context, newTallyRecord bool) { + if !newTallyRecord { + return + } + + ctx.GasMeter().ConsumeGas(types.DeferredVoteTallyGas, "governance vote deferred tally") +} + // NewMsgServerImpl returns an implementation of the gov MsgServer interface // for the provided Keeper. func NewMsgServerImpl(keeper Keeper) types.MsgServer { @@ -67,10 +75,11 @@ func (k msgServer) Vote(goCtx context.Context, msg *types.MsgVote) (*types.MsgVo return nil, err } - err = k.AddVote(ctx, msg.ProposalId, accAddr, types.NewNonSplitVoteOption(msg.Option)) + newTallyRecord, err := k.addVote(ctx, msg.ProposalId, accAddr, types.NewNonSplitVoteOption(msg.Option)) if err != nil { return nil, err } + chargeDeferredVoteTallyGas(ctx, newTallyRecord) defer func() { govMetrics.voteTotal.Add(goCtx, 1) @@ -93,10 +102,11 @@ func (k msgServer) VoteWeighted(goCtx context.Context, msg *types.MsgVoteWeighte if accErr != nil { return nil, accErr } - err := k.AddVote(ctx, msg.ProposalId, accAddr, msg.Options) + newTallyRecord, err := k.addVote(ctx, msg.ProposalId, accAddr, msg.Options) if err != nil { return nil, err } + chargeDeferredVoteTallyGas(ctx, newTallyRecord) defer func() { govMetrics.voteTotal.Add(goCtx, 1) diff --git a/sei-cosmos/x/gov/keeper/vote.go b/sei-cosmos/x/gov/keeper/vote.go index 7b5772e07d..a875d88ace 100644 --- a/sei-cosmos/x/gov/keeper/vote.go +++ b/sei-cosmos/x/gov/keeper/vote.go @@ -14,28 +14,40 @@ import ( // AddVote adds a vote on a specific proposal func (keeper Keeper) AddVote(ctx sdk.Context, proposalID uint64, voterAddr sdk.AccAddress, options types.WeightedVoteOptions) error { + _, err := keeper.addVote(ctx, proposalID, voterAddr, options) + return err +} + +func (keeper Keeper) addVote( + ctx sdk.Context, + proposalID uint64, + voterAddr sdk.AccAddress, + options types.WeightedVoteOptions, +) (bool, error) { proposal, ok := keeper.GetProposal(ctx, proposalID) if !ok { - return sdkerrors.Wrapf(types.ErrUnknownProposal, "%d", proposalID) + return false, sdkerrors.Wrapf(types.ErrUnknownProposal, "%d", proposalID) } if proposal.Status != types.StatusVotingPeriod { - return sdkerrors.Wrapf(types.ErrInactiveProposal, "%d", proposalID) + return false, sdkerrors.Wrapf(types.ErrInactiveProposal, "%d", proposalID) } - if keeper.IncrementalTallyEnabled(ctx) { + incrementalTallyEnabled := keeper.IncrementalTallyEnabled(ctx) + if incrementalTallyEnabled { if proposal.VotingEndTime.Before(ctx.BlockTime()) { - return sdkerrors.Wrapf(types.ErrInactiveProposal, "%d", proposalID) + return false, sdkerrors.Wrapf(types.ErrInactiveProposal, "%d", proposalID) } if keeper.voteDelegationSnapshotFrozen(ctx, proposal) || keeper.IsVoteDelegationBackfillInProgress(ctx, proposalID) { - return sdkerrors.Wrapf(types.ErrInactiveProposal, "%d", proposalID) + return false, sdkerrors.Wrapf(types.ErrInactiveProposal, "%d", proposalID) } } for _, option := range options { if !types.ValidWeightedVoteOption(option) { - return sdkerrors.Wrap(types.ErrInvalidVote, option.String()) + return false, sdkerrors.Wrap(types.ErrInvalidVote, option.String()) } } + newTallyRecord := incrementalTallyEnabled && !ctx.KVStore(keeper.storeKey).Has(types.VoteKey(proposalID, voterAddr)) vote := types.NewVote(proposalID, voterAddr, options) keeper.SetVote(ctx, vote) @@ -50,7 +62,7 @@ func (keeper Keeper) AddVote(ctx sdk.Context, proposalID uint64, voterAddr sdk.A ), ) - return nil + return newTallyRecord, nil } // GetAllVotes returns all the votes from the store diff --git a/sei-cosmos/x/gov/keeper/vote_test.go b/sei-cosmos/x/gov/keeper/vote_test.go index fb5d065676..0bc772ded1 100644 --- a/sei-cosmos/x/gov/keeper/vote_test.go +++ b/sei-cosmos/x/gov/keeper/vote_test.go @@ -10,10 +10,114 @@ import ( seiapp "github.com/sei-protocol/sei-chain/app" sdk "github.com/sei-protocol/sei-chain/sei-cosmos/types" + "github.com/sei-protocol/sei-chain/sei-cosmos/x/gov/keeper" "github.com/sei-protocol/sei-chain/sei-cosmos/x/gov/types" stakingtypes "github.com/sei-protocol/sei-chain/sei-cosmos/x/staking/types" ) +func TestMsgServerChargesDeferredVoteTallyGas(t *testing.T) { + tests := []struct { + name string + weighted bool + revote bool + active bool + incrementalTallyEnabled bool + extraGas sdk.Gas + }{ + { + name: "vote", + active: true, + incrementalTallyEnabled: true, + extraGas: types.DeferredVoteTallyGas, + }, + { + name: "weighted vote", + weighted: true, + active: true, + incrementalTallyEnabled: true, + extraGas: types.DeferredVoteTallyGas, + }, + { + name: "revote", + revote: true, + active: true, + incrementalTallyEnabled: true, + extraGas: types.DeferredVoteTallyGas, + }, + { + name: "inactive proposal", + incrementalTallyEnabled: true, + }, + { + name: "feature disabled", + active: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + directGas := voteGasConsumed(t, false, tc.weighted, tc.revote, tc.active, tc.incrementalTallyEnabled) + msgServerGas := voteGasConsumed(t, true, tc.weighted, tc.revote, tc.active, tc.incrementalTallyEnabled) + + require.Equal(t, directGas+tc.extraGas, msgServerGas) + }) + } +} + +func voteGasConsumed( + t *testing.T, + throughMsgServer bool, + weighted bool, + revote bool, + active bool, + incrementalTallyEnabled bool, +) sdk.Gas { + t.Helper() + + app := seiapp.Setup(t, false, false, false) + t.Cleanup(func() { require.NoError(t, app.Close()) }) + ctx := app.BaseApp.NewContext(false, tmproto.Header{}) + voter := seiapp.AddTestAddrsIncremental(app, ctx, 1, sdk.NewInt(30000000))[0] + + proposal, err := app.GovKeeper.SubmitProposal(ctx, TestProposal) + require.NoError(t, err) + if active { + proposal.Status = types.StatusVotingPeriod + app.GovKeeper.SetProposal(ctx, proposal) + } + if !incrementalTallyEnabled { + ctx.KVStore(app.GetKey(types.StoreKey)).Delete(types.IncrementalTallyEnabledKey) + } + + ctx = ctx.WithGasMeter(sdk.NewGasMeter(1_000_000, 1, 1)) + castVote := func(option types.VoteOption) error { + options := types.NewNonSplitVoteOption(option) + if !throughMsgServer { + return app.GovKeeper.AddVote(ctx, proposal.ProposalId, voter, options) + } + + msgServer := keeper.NewMsgServerImpl(app.GovKeeper) + if weighted { + _, err = msgServer.VoteWeighted(sdk.WrapSDKContext(ctx), types.NewMsgVoteWeighted(voter, proposal.ProposalId, options)) + } else { + _, err = msgServer.Vote(sdk.WrapSDKContext(ctx), types.NewMsgVote(voter, proposal.ProposalId, option)) + } + return err + } + + gasBeforeVote := ctx.GasMeter().GasConsumed() + if active { + require.NoError(t, castVote(types.OptionYes)) + if revote { + require.NoError(t, castVote(types.OptionNo)) + } + } else { + require.Error(t, castVote(types.OptionYes)) + } + + return ctx.GasMeter().GasConsumed() - gasBeforeVote +} + func TestVotes(t *testing.T) { app := seiapp.Setup(t, false, false, false) ctx := app.BaseApp.NewContext(false, tmproto.Header{}) diff --git a/sei-cosmos/x/gov/types/gas.go b/sei-cosmos/x/gov/types/gas.go new file mode 100644 index 0000000000..c1db3eeb0e --- /dev/null +++ b/sei-cosmos/x/gov/types/gas.go @@ -0,0 +1,8 @@ +package types + +import sdk "github.com/sei-protocol/sei-chain/sei-cosmos/types" + +const ( + // DeferredVoteTallyGas is charged when a vote adds a governance tally record. + DeferredVoteTallyGas sdk.Gas = 10_000 +) From bb8bc9b9e11f6d660b2e1442aaf0b3380b82bc22 Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Wed, 2 Sep 2026 11:29:50 +0800 Subject: [PATCH 15/17] fix(gov): persist incremental tally state with protobuf --- .../proto/cosmos/gov/v1beta1/state.proto | 87 + sei-cosmos/x/gov/genesis_test.go | 7 + sei-cosmos/x/gov/keeper/delegation_updates.go | 39 +- sei-cosmos/x/gov/keeper/electorate.go | 102 +- sei-cosmos/x/gov/keeper/electorate_test.go | 38 + sei-cosmos/x/gov/keeper/tally.go | 417 +++- sei-cosmos/x/gov/keeper/tally_test.go | 45 +- sei-cosmos/x/gov/simulation/decoder.go | 41 +- sei-cosmos/x/gov/spec/02_state.md | 29 +- sei-cosmos/x/gov/types/genesis.go | 6 +- sei-cosmos/x/gov/types/genesis_test.go | 12 + sei-cosmos/x/gov/types/keys.go | 46 +- sei-cosmos/x/gov/types/keys_test.go | 14 + sei-cosmos/x/gov/types/state.pb.go | 1905 +++++++++++++++++ 14 files changed, 2604 insertions(+), 184 deletions(-) create mode 100644 sei-cosmos/proto/cosmos/gov/v1beta1/state.proto create mode 100644 sei-cosmos/x/gov/types/state.pb.go diff --git a/sei-cosmos/proto/cosmos/gov/v1beta1/state.proto b/sei-cosmos/proto/cosmos/gov/v1beta1/state.proto new file mode 100644 index 0000000000..cb1018e9b7 --- /dev/null +++ b/sei-cosmos/proto/cosmos/gov/v1beta1/state.proto @@ -0,0 +1,87 @@ +syntax = "proto3"; + +package cosmos.gov.v1beta1; + +import "cosmos/gov/v1beta1/genesis.proto"; +import "cosmos/gov/v1beta1/gov.proto"; +import "gogoproto/gogo.proto"; +import "google/protobuf/timestamp.proto"; + +option go_package = "github.com/sei-protocol/sei-chain/sei-cosmos/x/gov/types"; +option (gogoproto.goproto_getters_all) = false; + +// TallyBoundary defines the electorate and update boundary for a tally round. +message TallyBoundary { + google.protobuf.Timestamp lower_time = 1 [ + (gogoproto.stdtime) = true, + (gogoproto.nullable) = false + ]; + google.protobuf.Timestamp upper_time = 2 [ + (gogoproto.stdtime) = true, + (gogoproto.nullable) = false + ]; + uint64 update_sequence = 3; + FrozenTallyElectorate electorate = 4 [(gogoproto.nullable) = false]; +} + +// FrozenTallyElectorate defines the immutable validator and parameter state for a tally round. +message FrozenTallyElectorate { + string total_bonded_tokens = 1 [ + (gogoproto.customtype) = "github.com/sei-protocol/sei-chain/sei-cosmos/types.Int", + (gogoproto.nullable) = false + ]; + TallyParams tally_params = 2 [(gogoproto.nullable) = false]; + repeated TallyValidator validators = 3 [(gogoproto.nullable) = false]; +} + +// TallyProgress defines the mutable cursor for an unfinished tally round. +message TallyProgress { + bytes cursor = 1; + bytes boundary_id = 2; + bool expedited = 3; +} + +// TallyOptionResults defines decimal totals by vote option. +message TallyOptionResults { + string yes = 1 [ + (gogoproto.customtype) = "github.com/sei-protocol/sei-chain/sei-cosmos/types.Dec", + (gogoproto.nullable) = false + ]; + string abstain = 2 [ + (gogoproto.customtype) = "github.com/sei-protocol/sei-chain/sei-cosmos/types.Dec", + (gogoproto.nullable) = false + ]; + string no = 3 [ + (gogoproto.customtype) = "github.com/sei-protocol/sei-chain/sei-cosmos/types.Dec", + (gogoproto.nullable) = false + ]; + string no_with_veto = 4 [ + (gogoproto.customtype) = "github.com/sei-protocol/sei-chain/sei-cosmos/types.Dec", + (gogoproto.nullable) = false + ]; +} + +// TallyValidatorAccumulator defines the mutable tally state for one frozen validator. +message TallyValidatorAccumulator { + string observed_delegator_shares = 1 [ + (gogoproto.customtype) = "github.com/sei-protocol/sei-chain/sei-cosmos/types.Dec", + (gogoproto.nullable) = false + ]; + TallyOptionResults delegator_results = 2 [(gogoproto.nullable) = false]; + repeated WeightedVoteOption vote = 3 [(gogoproto.nullable) = false]; +} + +// VoteDelegationUpdate defines one deferred slash-induced delegation update. +message VoteDelegationUpdate { + string voter = 1; + string validator = 2; + string shares = 3 [ + (gogoproto.customtype) = "github.com/sei-protocol/sei-chain/sei-cosmos/types.Dec", + (gogoproto.nullable) = false + ]; + google.protobuf.Timestamp block_time = 4 [ + (gogoproto.stdtime) = true, + (gogoproto.nullable) = false + ]; + bytes cursor = 5; +} diff --git a/sei-cosmos/x/gov/genesis_test.go b/sei-cosmos/x/gov/genesis_test.go index f7cd676a97..2ed28bcc29 100644 --- a/sei-cosmos/x/gov/genesis_test.go +++ b/sei-cosmos/x/gov/genesis_test.go @@ -212,6 +212,12 @@ func TestExportGenesisIncludesVotesFromUnfinishedTally(t *testing.T) { require.False(t, complete) require.Equal(t, 1, processed) + jailedValidator, found := app.StakingKeeper.GetValidator(ctx, seiapp.ConvertAddrsToValAddrs(addrs)[0]) + require.True(t, found) + jailedConsAddr, err := jailedValidator.GetConsAddr() + require.NoError(t, err) + app.StakingKeeper.Jail(ctx, sdk.ConsAddress(jailedConsAddr.Bytes())) + validator, found := app.StakingKeeper.GetValidator(ctx, seiapp.ConvertAddrsToValAddrs(addrs)[0]) require.True(t, found) _, err = app.StakingKeeper.Delegate( @@ -232,6 +238,7 @@ func TestExportGenesisIncludesVotesFromUnfinishedTally(t *testing.T) { require.Len(t, genesis.Votes, 3) require.Len(t, genesis.VoteDelegationSnapshots, 3) require.Len(t, genesis.TallyElectorates, 1) + require.NoError(t, types.ValidateGenesis(genesis)) genesisJSON := app.AppCodec().MustMarshalJSON(genesis) var decodedGenesis types.GenesisState app.AppCodec().MustUnmarshalJSON(genesisJSON, &decodedGenesis) diff --git a/sei-cosmos/x/gov/keeper/delegation_updates.go b/sei-cosmos/x/gov/keeper/delegation_updates.go index 44204ca567..a9b6ad360d 100644 --- a/sei-cosmos/x/gov/keeper/delegation_updates.go +++ b/sei-cosmos/x/gov/keeper/delegation_updates.go @@ -2,7 +2,6 @@ package keeper import ( "encoding/binary" - "encoding/json" "fmt" "math" "time" @@ -11,14 +10,6 @@ import ( "github.com/sei-protocol/sei-chain/sei-cosmos/x/gov/types" ) -type pendingVoteDelegationUpdate struct { - Voter string `json:"voter"` - Validator string `json:"validator"` - Shares sdk.Dec `json:"shares"` - BlockTime time.Time `json:"block_time"` - Cursor []byte `json:"cursor,omitempty"` -} - // QueueVoteDelegationUpdate defers one slash-induced delegation change for bounded processing. func (keeper Keeper) QueueVoteDelegationUpdate( ctx sdk.Context, @@ -31,14 +22,14 @@ func (keeper Keeper) QueueVoteDelegationUpdate( } sequence := keeper.nextVoteDelegationUpdateSequence(ctx) - update := pendingVoteDelegationUpdate{ + update := types.VoteDelegationUpdate{ Voter: voter.String(), Validator: validator.String(), Shares: shares, BlockTime: ctx.BlockTime(), } store := ctx.KVStore(keeper.storeKey) - store.Set(types.VoteDelegationUpdateKey(sequence), marshalVoteDelegationUpdate(update)) + store.Set(types.VoteDelegationUpdateKey(sequence), keeper.cdc.MustMarshal(&update)) store.Set(types.VoterVoteDelegationUpdateKey(voter, sequence), []byte{1}) } @@ -70,7 +61,7 @@ func (keeper Keeper) ProcessVoteDelegationUpdatesThrough( if sequence > throughSequence { return true, processed } - update := unmarshalVoteDelegationUpdate(iterator.Value()) + update := keeper.unmarshalVoteDelegationUpdate(iterator.Value()) var updateComplete bool updateComplete, processed = keeper.processVoteDelegationUpdate( ctx, @@ -123,7 +114,7 @@ func (keeper Keeper) nextVoteDelegationUpdateSequence(ctx sdk.Context) uint64 { func (keeper Keeper) processVoteDelegationUpdate( ctx sdk.Context, sequence uint64, - update pendingVoteDelegationUpdate, + update types.VoteDelegationUpdate, maxUpdates int, processed int, ) (complete bool, newProcessed int) { @@ -150,7 +141,7 @@ func (keeper Keeper) processVoteDelegationUpdate( } if iterator.Valid() { - store.Set(types.VoteDelegationUpdateKey(sequence), marshalVoteDelegationUpdate(update)) + store.Set(types.VoteDelegationUpdateKey(sequence), keeper.cdc.MustMarshal(&update)) return false, newProcessed } if newProcessed == processed { @@ -163,7 +154,7 @@ func (keeper Keeper) applyVoteDelegationUpdate( ctx sdk.Context, proposalID uint64, sequence uint64, - update pendingVoteDelegationUpdate, + update types.VoteDelegationUpdate, ) { voter := sdk.MustAccAddressFromBech32(update.Voter) if keeper.voteDelegationSnapshotRevision(ctx, proposalID, voter) >= sequence { @@ -215,7 +206,7 @@ func (keeper Keeper) applyVoteDelegationSnapshotUpdates( if bz == nil { panic(fmt.Sprintf("missing vote delegation update %d", sequence)) } - update := unmarshalVoteDelegationUpdate(bz) + update := keeper.unmarshalVoteDelegationUpdate(bz) if keeper.delegationUpdateBelongsToTallyBoundary(ctx, proposal, sequence, update.BlockTime) { index.set(update.Validator, update.Shares) } @@ -266,19 +257,9 @@ func (keeper Keeper) setVoteDelegationSnapshotRevision( ) } -func marshalVoteDelegationUpdate(update pendingVoteDelegationUpdate) []byte { - bz, err := json.Marshal(update) - if err != nil { - panic(fmt.Errorf("marshal vote delegation update: %w", err)) - } - return bz -} - -func unmarshalVoteDelegationUpdate(bz []byte) pendingVoteDelegationUpdate { - var update pendingVoteDelegationUpdate - if err := json.Unmarshal(bz, &update); err != nil { - panic(fmt.Errorf("unmarshal vote delegation update: %w", err)) - } +func (keeper Keeper) unmarshalVoteDelegationUpdate(bz []byte) types.VoteDelegationUpdate { + var update types.VoteDelegationUpdate + keeper.cdc.MustUnmarshal(bz, &update) return update } diff --git a/sei-cosmos/x/gov/keeper/electorate.go b/sei-cosmos/x/gov/keeper/electorate.go index 75898c9d55..f3759b1d76 100644 --- a/sei-cosmos/x/gov/keeper/electorate.go +++ b/sei-cosmos/x/gov/keeper/electorate.go @@ -1,7 +1,6 @@ package keeper import ( - "encoding/json" "fmt" "time" @@ -16,17 +15,11 @@ const ( proposalTallyBoundary byte = 'p' ) -type tallyElectorate struct { - TotalBondedTokens sdk.Int `json:"total_bonded_tokens"` - TallyParams types.TallyParams `json:"tally_params"` - Validators []tallyValidator `json:"validators"` -} - type tallyBoundary struct { - LowerTime time.Time `json:"lower_time"` - UpperTime time.Time `json:"upper_time"` - UpdateSequence uint64 `json:"update_sequence"` - Electorate tallyElectorate `json:"electorate"` + LowerTime time.Time + UpperTime time.Time + UpdateSequence uint64 + Electorate tallyElectorate } // CaptureGapTallyBoundary freezes one electorate for proposal deadlines between consecutive block times. @@ -96,11 +89,9 @@ func (keeper Keeper) snapshotTallyElectorate(ctx sdk.Context) tallyElectorate { } keeper.sk.IterateBondedValidatorsByPower(ctx, func(_ int64, validator stakingtypes.ValidatorI) bool { electorate.Validators = append(electorate.Validators, tallyValidator{ - Address: validator.GetOperator().String(), - BondedTokens: validator.GetBondedTokens(), - DelegatorShares: validator.GetDelegatorShares(), - ObservedDelegatorShares: sdk.ZeroDec(), - DelegatorResults: newTallyOptionResults(), + Address: validator.GetOperator().String(), + BondedTokens: validator.GetBondedTokens(), + DelegatorShares: validator.GetDelegatorShares(), }) return false }) @@ -137,11 +128,7 @@ func (keeper Keeper) ExportTallyElectorate( proposal types.Proposal, ) (types.TallyElectorate, bool) { if progress, found := keeper.getTallyProgress(ctx, proposal.ProposalId); found { - return tallyElectorateToGenesis(proposal.ProposalId, tallyElectorate{ - TotalBondedTokens: progress.TotalBondedTokens, - TallyParams: progress.TallyParams, - Validators: progress.Validators, - }), true + return tallyElectorateToGenesis(proposal.ProposalId, keeper.tallyProgressBoundary(ctx, proposal.ProposalId, progress).Electorate), true } if boundary, _, found := keeper.getSelectedTallyBoundary(ctx, proposal.ProposalId); found { return tallyElectorateToGenesis(proposal.ProposalId, boundary.Electorate), true @@ -234,11 +221,8 @@ func (keeper Keeper) setProposalTallyBoundary(ctx sdk.Context, proposalID uint64 } func (keeper Keeper) setTallyBoundary(ctx sdk.Context, boundaryID []byte, boundary tallyBoundary) { - bz, err := json.Marshal(boundary) - if err != nil { - panic(fmt.Errorf("marshal tally boundary: %w", err)) - } - ctx.KVStore(keeper.storeKey).Set(types.TallyBoundaryMetaKey(boundaryID), bz) + stored := tallyBoundaryToProto(boundary) + ctx.KVStore(keeper.storeKey).Set(types.TallyBoundaryMetaKey(boundaryID), keeper.cdc.MustMarshal(&stored)) } func (keeper Keeper) getTallyBoundary(ctx sdk.Context, boundaryID []byte) (tallyBoundary, bool) { @@ -246,11 +230,9 @@ func (keeper Keeper) getTallyBoundary(ctx sdk.Context, boundaryID []byte) (tally if bz == nil { return tallyBoundary{}, false } - var boundary tallyBoundary - if err := json.Unmarshal(bz, &boundary); err != nil { - panic(fmt.Errorf("unmarshal tally boundary: %w", err)) - } - return boundary, true + var stored types.TallyBoundary + keeper.cdc.MustUnmarshal(bz, &stored) + return tallyBoundaryFromProto(stored), true } func (keeper Keeper) addProposalDeadline(ctx sdk.Context, proposalID uint64, endTime time.Time) { @@ -356,11 +338,59 @@ func tallyElectorateFromGenesis(electorate types.TallyElectorate) tallyElectorat validators := make([]tallyValidator, 0, len(electorate.TallyValidators)) for _, validator := range electorate.TallyValidators { validators = append(validators, tallyValidator{ - Address: validator.Address, - BondedTokens: validator.BondedTokens, - DelegatorShares: validator.DelegatorShares, - ObservedDelegatorShares: sdk.ZeroDec(), - DelegatorResults: newTallyOptionResults(), + Address: validator.Address, + BondedTokens: validator.BondedTokens, + DelegatorShares: validator.DelegatorShares, + }) + } + return tallyElectorate{ + TotalBondedTokens: electorate.TotalBondedTokens, + TallyParams: electorate.TallyParams, + Validators: validators, + } +} + +func tallyBoundaryToProto(boundary tallyBoundary) types.TallyBoundary { + return types.TallyBoundary{ + LowerTime: boundary.LowerTime, + UpperTime: boundary.UpperTime, + UpdateSequence: boundary.UpdateSequence, + Electorate: tallyElectorateToProto(boundary.Electorate), + } +} + +func tallyBoundaryFromProto(boundary types.TallyBoundary) tallyBoundary { + return tallyBoundary{ + LowerTime: boundary.LowerTime, + UpperTime: boundary.UpperTime, + UpdateSequence: boundary.UpdateSequence, + Electorate: tallyElectorateFromProto(boundary.Electorate), + } +} + +func tallyElectorateToProto(electorate tallyElectorate) types.FrozenTallyElectorate { + validators := make([]types.TallyValidator, 0, len(electorate.Validators)) + for _, validator := range electorate.Validators { + validators = append(validators, types.TallyValidator{ + Address: validator.Address, + BondedTokens: validator.BondedTokens, + DelegatorShares: validator.DelegatorShares, + }) + } + return types.FrozenTallyElectorate{ + TotalBondedTokens: electorate.TotalBondedTokens, + TallyParams: electorate.TallyParams, + Validators: validators, + } +} + +func tallyElectorateFromProto(electorate types.FrozenTallyElectorate) tallyElectorate { + validators := make([]tallyValidator, 0, len(electorate.Validators)) + for _, validator := range electorate.Validators { + validators = append(validators, tallyValidator{ + Address: validator.Address, + BondedTokens: validator.BondedTokens, + DelegatorShares: validator.DelegatorShares, }) } return tallyElectorate{ diff --git a/sei-cosmos/x/gov/keeper/electorate_test.go b/sei-cosmos/x/gov/keeper/electorate_test.go index c149fbfb66..087367b90b 100644 --- a/sei-cosmos/x/gov/keeper/electorate_test.go +++ b/sei-cosmos/x/gov/keeper/electorate_test.go @@ -99,6 +99,44 @@ func TestTallyOnlyWaitsForDelegationUpdatesThroughItsBoundary(t *testing.T) { ) } +func TestTallyWaitsForEarlierUnrelatedDelegationUpdates(t *testing.T) { + app := seiapp.Setup(t, false, false, false) + blockTime := time.Unix(100, 0) + ctx := app.BaseApp.NewContext(false, tmproto.Header{Time: blockTime}) + addrs, valAddrs := createValidators(t, ctx, app, []int64{5, 5, 5}) + target := createVotingProposalEndingAt(t, ctx, app, blockTime) + delegateAndVoteYes(t, ctx, app, target.ProposalId, addrs[3], valAddrs[0], 2) + + unrelated := createVotingProposalEndingAt(t, ctx, app, blockTime.Add(time.Hour)) + delegateAndVoteYes(t, ctx, app, unrelated.ProposalId, addrs[4], valAddrs[0], 2) + updatedShares := queueDelegationShareUpdate(t, ctx, app, addrs[4], valAddrs[0], sdk.OneDec()) + app.GovKeeper.CaptureExactTallyBoundary(ctx) + + complete, processed, _, _, _ := app.GovKeeper.TallyIncremental(ctx, target, 1) + require.False(t, complete) + require.Equal(t, 1, processed) + + store := ctx.KVStore(app.GetKey(govtypes.StoreKey)) + require.True(t, store.Has(govtypes.VoteKey(target.ProposalId, addrs[3]))) + require.False(t, store.Has(govtypes.TallyVoteKey(target.ProposalId, false, addrs[3]))) + require.False(t, app.GovKeeper.HasPendingVoteDelegationUpdates(ctx)) + requireVoteDelegationShares( + t, + []govtypes.VoteDelegationSnapshot{decodeVoteDelegationSnapshot( + t, + store.Get(govtypes.VoteDelegationsKey(unrelated.ProposalId, addrs[4])), + )}, + valAddrs[0], + updatedShares, + ) + + complete, processed, _, _, _ = app.GovKeeper.TallyIncremental(ctx, target, 1) + require.True(t, complete) + require.Equal(t, 1, processed) + require.False(t, store.Has(govtypes.VoteKey(target.ProposalId, addrs[3]))) + require.True(t, store.Has(govtypes.TallyVoteKey(target.ProposalId, false, addrs[3]))) +} + func createVotingProposalEndingAt( t *testing.T, ctx sdk.Context, diff --git a/sei-cosmos/x/gov/keeper/tally.go b/sei-cosmos/x/gov/keeper/tally.go index 8edf650760..d5a883d78a 100644 --- a/sei-cosmos/x/gov/keeper/tally.go +++ b/sei-cosmos/x/gov/keeper/tally.go @@ -1,8 +1,9 @@ package keeper import ( - "encoding/json" + "bytes" "fmt" + "sort" "github.com/sei-protocol/sei-chain/sei-cosmos/store/prefix" sdk "github.com/sei-protocol/sei-chain/sei-cosmos/types" @@ -13,29 +14,42 @@ import ( 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"` + Cursor []byte + BoundaryID []byte + Expedited bool } 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"` + Yes sdk.Dec + Abstain sdk.Dec + No sdk.Dec + NoWithVeto sdk.Dec +} + +type tallyElectorate struct { + TotalBondedTokens sdk.Int + TallyParams types.TallyParams + Validators []tallyValidator } type tallyValidator struct { - Address string `json:"address"` - BondedTokens sdk.Int `json:"bonded_tokens"` - DelegatorShares sdk.Dec `json:"delegator_shares"` - ObservedDelegatorShares sdk.Dec `json:"observed_delegator_shares"` - DelegatorResults tallyOptionResults `json:"delegator_results"` - Vote types.WeightedVoteOptions `json:"vote"` + Address string + BondedTokens sdk.Int + DelegatorShares sdk.Dec +} + +type tallyValidatorAccumulator struct { + ObservedDelegatorShares sdk.Dec + DelegatorResults tallyOptionResults + Vote types.WeightedVoteOptions +} + +type tallyState struct { + Electorate tallyElectorate + Validators map[string]tallyValidator + Accumulators map[string]tallyValidatorAccumulator + Changed map[string]struct{} + LoadAccumulators bool } // Tally calculates a proposal's result without changing its tally state. @@ -45,17 +59,25 @@ func (keeper Keeper) Tally(ctx sdk.Context, proposal types.Proposal) (passes boo progress, found = keeper.getTallyProgress(ctx, proposal.ProposalId) } if !found { - progress = keeper.initializeTally(ctx, proposal) + state := newTallyState(keeper.initializeTallyElectorate(ctx, proposal), false) + store := ctx.KVStore(keeper.storeKey) + votes := prefix.NewStore(store, types.VotesKey(proposal.ProposalId)) + keeper.iterateVoteStore(votes, func(vote types.Vote) bool { + keeper.addVoteToTally(ctx, proposal.ProposalId, proposal.IsExpedited, &state, vote, keeper.voteDelegations(ctx, proposal.ProposalId, proposal.IsExpedited, vote)) + return false + }) + return keeper.finishTally(ctx, proposal.ProposalId, proposal.IsExpedited, &state) } - validators := progress.validatorMap() + boundary := keeper.tallyProgressBoundary(ctx, proposal.ProposalId, progress) + state := newTallyState(boundary.Electorate, true) store := ctx.KVStore(keeper.storeKey) votes := prefix.NewStore(store, types.VotesKey(proposal.ProposalId)) keeper.iterateVoteStore(votes, func(vote types.Vote) bool { - keeper.addVoteToTally(validators, vote, keeper.voteDelegations(ctx, proposal.ProposalId, progress.Expedited, vote)) + keeper.addVoteToTally(ctx, proposal.ProposalId, progress.Expedited, &state, vote, keeper.voteDelegations(ctx, proposal.ProposalId, progress.Expedited, vote)) return false }) - return keeper.finishTally(progress) + return keeper.finishTally(ctx, proposal.ProposalId, progress.Expedited, &state) } // TallyLegacy calculates a proposal's result and removes its votes using the legacy tally transition. @@ -162,7 +184,7 @@ func (keeper Keeper) TallyIncremental( panic("maximum governance records to process cannot be negative") } progress, found := keeper.getTallyProgress(ctx, proposal.ProposalId) - boundary, _, boundaryFound := keeper.getSelectedTallyBoundary(ctx, proposal.ProposalId) + boundary, boundaryID, boundaryFound := keeper.getSelectedTallyBoundary(ctx, proposal.ProposalId) if !found && !boundaryFound && keeper.usesLegacyTallySemantics(ctx, proposal) { backfillComplete, backfilled := keeper.BackfillVoteDelegationTracking(ctx, proposal.ProposalId, maxRecords-processed) processed += backfilled @@ -174,9 +196,9 @@ func (keeper Keeper) TallyIncremental( if maxRecords == 0 { return false, processed, false, false, types.EmptyTallyResult() } - boundary, _ = keeper.selectTallyBoundary(ctx, proposal) + boundary, boundaryID = keeper.selectTallyBoundary(ctx, proposal) if keeper.usesLegacyTallySemantics(ctx, proposal) { - progress = initializeTallyFromElectorate(boundary.Electorate, proposal.IsExpedited) + progress = newTallyProgress(boundaryID, proposal.IsExpedited) keeper.setTallyProgress(ctx, proposal.ProposalId, progress) return false, maxRecords, false, false, types.EmptyTallyResult() } @@ -192,19 +214,23 @@ func (keeper Keeper) TallyIncremental( } if !found { - progress = initializeTallyFromElectorate(boundary.Electorate, proposal.IsExpedited) + progress = newTallyProgress(boundaryID, proposal.IsExpedited) } else if progress.Expedited != proposal.IsExpedited { panic(fmt.Sprintf("tally round for proposal %d changed", proposal.ProposalId)) + } else if !bytes.Equal(progress.BoundaryID, boundaryID) { + panic(fmt.Sprintf("tally round for proposal %d changed boundary", proposal.ProposalId)) } if processed == maxRecords { keeper.setTallyProgress(ctx, proposal.ProposalId, progress) return false, processed, false, false, types.EmptyTallyResult() } + state := newTallyState(boundary.Electorate, found) var tallied int - complete, tallied = keeper.processTallyVotes(ctx, proposal.ProposalId, &progress, maxRecords-processed) + complete, tallied = keeper.processTallyVotes(ctx, proposal.ProposalId, &progress, &state, maxRecords-processed) processed += tallied if !complete { + keeper.flushTallyValidatorAccumulators(ctx, proposal.ProposalId, progress.Expedited, &state) keeper.setTallyProgress(ctx, proposal.ProposalId, progress) return false, processed, false, false, types.EmptyTallyResult() } @@ -212,9 +238,10 @@ func (keeper Keeper) TallyIncremental( processed = 1 } - passes, burnDeposits, tallyResults = keeper.finishTally(progress) + passes, burnDeposits, tallyResults = keeper.finishTally(ctx, proposal.ProposalId, progress.Expedited, &state) keeper.deleteTallyProgress(ctx, proposal.ProposalId) keeper.markTallyVotesForCleanup(ctx, proposal.ProposalId, progress.Expedited) + keeper.markTallyValidatorAccumulatorsForCleanup(ctx, proposal.ProposalId, progress.Expedited) return true, processed, passes, burnDeposits, tallyResults } @@ -239,11 +266,11 @@ func (keeper Keeper) InitializeTally(ctx sdk.Context, proposal types.Proposal) { if keeper.voteNeedsDelegationBackfill(ctx, proposal.ProposalId) { panic("cannot initialize tally while vote delegation backfill is in progress") } - boundary, _ := keeper.selectTallyBoundary(ctx, proposal) - keeper.setTallyProgress(ctx, proposal.ProposalId, initializeTallyFromElectorate(boundary.Electorate, proposal.IsExpedited)) + _, boundaryID := keeper.selectTallyBoundary(ctx, proposal) + keeper.setTallyProgress(ctx, proposal.ProposalId, newTallyProgress(boundaryID, proposal.IsExpedited)) } -// CleanupTallyVotes deletes at most maxVotes vote records archived by completed tallies. +// CleanupTallyVotes deletes at most maxVotes records archived by completed tallies. func (keeper Keeper) CleanupTallyVotes(ctx sdk.Context, maxVotes int) (deleted int) { if maxVotes <= 0 { return 0 @@ -273,31 +300,58 @@ func (keeper Keeper) CleanupTallyVotes(ctx sdk.Context, maxVotes int) (deleted i } } + if deleted == maxVotes { + return deleted + } + return deleted + keeper.cleanupTallyValidatorAccumulators(ctx, maxVotes-deleted) +} + +func (keeper Keeper) cleanupTallyValidatorAccumulators(ctx sdk.Context, maxRecords int) (deleted int) { + store := ctx.KVStore(keeper.storeKey) + iterator := sdk.KVStorePrefixIterator(store, types.TallyAccumulatorCleanupKeyPrefix) + defer func() { _ = iterator.Close() }() + + for ; iterator.Valid() && deleted < maxRecords; iterator.Next() { + proposalID, expedited := types.SplitTallyAccumulatorCleanupKey(iterator.Key()) + cursor := decodeCleanupCursor(iterator.Value()) + count, complete, nextCursor := keeper.cleanupProposalTallyValidatorAccumulators( + ctx, + proposalID, + expedited, + maxRecords-deleted, + cursor, + ) + deleted += count + + cleanupKey := types.TallyAccumulatorCleanupKey(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 { +func (keeper Keeper) initializeTallyElectorate(ctx sdk.Context, proposal types.Proposal) tallyElectorate { if keeper.IncrementalTallyEnabled(ctx) { if boundary, _, found := keeper.getSelectedTallyBoundary(ctx, proposal.ProposalId); found { - return initializeTallyFromElectorate(boundary.Electorate, proposal.IsExpedited) + return boundary.Electorate } if !keeper.usesLegacyTallySemantics(ctx, proposal) { if boundary, _, found := keeper.getDeadlineTallyBoundary(ctx, proposal.VotingEndTime); found { - return initializeTallyFromElectorate(boundary.Electorate, proposal.IsExpedited) + return boundary.Electorate } } } - return initializeTallyFromElectorate(keeper.snapshotTallyElectorate(ctx), proposal.IsExpedited) + return keeper.snapshotTallyElectorate(ctx) } -func initializeTallyFromElectorate(electorate tallyElectorate, expedited bool) tallyProgress { +func newTallyProgress(boundaryID []byte, expedited bool) tallyProgress { return tallyProgress{ - Results: newTallyOptionResults(), - TotalVotingPower: sdk.ZeroDec(), - TotalBondedTokens: electorate.TotalBondedTokens, - TallyParams: electorate.TallyParams, - Validators: electorate.Validators, - Expedited: expedited, + BoundaryID: append([]byte(nil), boundaryID...), + Expedited: expedited, } } @@ -305,10 +359,9 @@ func (keeper Keeper) processTallyVotes( ctx sdk.Context, proposalID uint64, progress *tallyProgress, + state *tallyState, maxVotes int, ) (complete bool, processed int) { - validators := progress.validatorMap() - store := ctx.KVStore(keeper.storeKey) votesPrefix := types.VotesKey(proposalID) start := votesPrefix @@ -333,7 +386,7 @@ func (keeper Keeper) processTallyVotes( panic(fmt.Sprintf("missing delegation snapshot for proposal %d voter %s", proposalID, voter)) } snapshot := keeper.unmarshalVoteDelegations(snapshotValue) - keeper.addVoteToTally(validators, vote, snapshot) + keeper.addVoteToTally(ctx, proposalID, progress.Expedited, state, vote, snapshot) store.Set(types.TallyVoteKey(proposalID, progress.Expedited, voter), value) store.Set(types.TallyVoteDelegationsKey(proposalID, progress.Expedited, voter), snapshotValue) @@ -349,25 +402,32 @@ func (keeper Keeper) processTallyVotes( } func (keeper Keeper) addVoteToTally( - validators map[string]*tallyValidator, + ctx sdk.Context, + proposalID uint64, + expedited bool, + state *tallyState, vote types.Vote, snapshot types.VoteDelegationSnapshot, ) { voter := sdk.MustAccAddressFromBech32(vote.Voter) - if validator, ok := validators[sdk.ValAddress(voter.Bytes()).String()]; ok { - validator.Vote = vote.Options + if validator, ok := state.Validators[sdk.ValAddress(voter.Bytes()).String()]; ok { + accumulator := keeper.tallyValidatorAccumulator(ctx, proposalID, expedited, state, validator) + accumulator.Vote = vote.Options + state.setTallyValidatorAccumulator(validator.Address, accumulator) } for _, delegation := range snapshot.Delegations { - validator, ok := validators[delegation.Validator] + validator, ok := state.Validators[delegation.Validator] if !ok || validator.DelegatorShares.IsZero() { continue } + accumulator := keeper.tallyValidatorAccumulator(ctx, proposalID, expedited, state, validator) votingShares := delegation.Shares - validator.ObservedDelegatorShares = validator.ObservedDelegatorShares.Add(votingShares) + accumulator.ObservedDelegatorShares = accumulator.ObservedDelegatorShares.Add(votingShares) votingPower := votingShares.MulInt(validator.BondedTokens).Quo(validator.DelegatorShares) - validator.DelegatorResults.add(vote.Options, votingPower) + accumulator.DelegatorResults.add(vote.Options, votingPower) + state.setTallyValidatorAccumulator(validator.Address, accumulator) } } @@ -395,69 +455,174 @@ func (keeper Keeper) voteDelegations( panic(fmt.Sprintf("missing delegation snapshot for proposal %d voter %s", proposalID, voter)) } -func (progress *tallyProgress) validatorMap() map[string]*tallyValidator { - validators := make(map[string]*tallyValidator, len(progress.Validators)) - for i := range progress.Validators { - validator := &progress.Validators[i] +func newTallyState(electorate tallyElectorate, loadAccumulators bool) tallyState { + validators := make(map[string]tallyValidator, len(electorate.Validators)) + for _, validator := range electorate.Validators { validators[validator.Address] = validator } - return validators + return tallyState{ + Electorate: electorate, + Validators: validators, + Accumulators: make(map[string]tallyValidatorAccumulator), + Changed: make(map[string]struct{}), + LoadAccumulators: loadAccumulators, + } +} + +func (keeper Keeper) tallyProgressBoundary(ctx sdk.Context, proposalID uint64, progress tallyProgress) tallyBoundary { + if len(progress.BoundaryID) == 0 { + panic(fmt.Sprintf("tally progress for proposal %d has no boundary", proposalID)) + } + boundary, found := keeper.getTallyBoundary(ctx, progress.BoundaryID) + if !found { + panic(fmt.Sprintf("missing tally boundary for proposal %d", proposalID)) + } + return boundary +} + +func (keeper Keeper) tallyValidatorAccumulator( + ctx sdk.Context, + proposalID uint64, + expedited bool, + state *tallyState, + validator tallyValidator, +) tallyValidatorAccumulator { + if accumulator, found := state.Accumulators[validator.Address]; found { + return accumulator + } + + accumulator := newTallyValidatorAccumulator() + if state.LoadAccumulators { + validatorAddress, err := sdk.ValAddressFromBech32(validator.Address) + if err != nil { + panic(fmt.Errorf("invalid tally validator %q: %w", validator.Address, err)) + } + bz := ctx.KVStore(keeper.storeKey).Get( + types.TallyValidatorAccumulatorKey(proposalID, expedited, validatorAddress), + ) + if bz != nil { + var stored types.TallyValidatorAccumulator + keeper.cdc.MustUnmarshal(bz, &stored) + accumulator = tallyValidatorAccumulatorFromProto(stored) + } + } + state.Accumulators[validator.Address] = accumulator + return accumulator +} + +func (state *tallyState) setTallyValidatorAccumulator(address string, accumulator tallyValidatorAccumulator) { + state.Accumulators[address] = accumulator + state.Changed[address] = struct{}{} } -func (keeper Keeper) finishTally(progress tallyProgress) (passes bool, burnDeposits bool, tallyResults types.TallyResult) { - for _, validator := range progress.Validators { - progress.addValidatorResults(validator) +func (keeper Keeper) flushTallyValidatorAccumulators( + ctx sdk.Context, + proposalID uint64, + expedited bool, + state *tallyState, +) { + addresses := make([]string, 0, len(state.Changed)) + for address := range state.Changed { + addresses = append(addresses, address) } + sort.Strings(addresses) + + store := ctx.KVStore(keeper.storeKey) + for _, address := range addresses { + validator, found := state.Validators[address] + if !found { + panic(fmt.Sprintf("missing tally validator %q", address)) + } + validatorAddress, err := sdk.ValAddressFromBech32(validator.Address) + if err != nil { + panic(fmt.Errorf("invalid tally validator %q: %w", validator.Address, err)) + } + accumulator := state.Accumulators[address] + encoded := tallyValidatorAccumulatorToProto(accumulator) + store.Set( + types.TallyValidatorAccumulatorKey(proposalID, expedited, validatorAddress), + keeper.cdc.MustMarshal(&encoded), + ) + } +} - tallyResults = progress.Results.tallyResult() - if progress.TotalBondedTokens.IsZero() { +func (keeper Keeper) markTallyValidatorAccumulatorsForCleanup(ctx sdk.Context, proposalID uint64, expedited bool) { + store := ctx.KVStore(keeper.storeKey) + iterator := sdk.KVStorePrefixIterator(store, types.TallyValidatorAccumulatorsKey(proposalID, expedited)) + defer func() { _ = iterator.Close() }() + + if iterator.Valid() { + store.Set(types.TallyAccumulatorCleanupKey(proposalID, expedited), []byte{cleanupCursorUnset}) + } +} + +func (keeper Keeper) finishTally( + ctx sdk.Context, + proposalID uint64, + expedited bool, + state *tallyState, +) (passes bool, burnDeposits bool, tallyResults types.TallyResult) { + results := newTallyOptionResults() + totalVotingPower := sdk.ZeroDec() + for _, validator := range state.Electorate.Validators { + accumulator := keeper.tallyValidatorAccumulator(ctx, proposalID, expedited, state, validator) + totalVotingPower = addValidatorResults(&results, totalVotingPower, validator, accumulator) + } + + tallyResults = results.tallyResult() + if state.Electorate.TotalBondedTokens.IsZero() { return false, false, tallyResults } - percentVoting := progress.TotalVotingPower.Quo(progress.TotalBondedTokens.ToDec()) - if percentVoting.LT(progress.TallyParams.GetQuorum(progress.Expedited)) { + percentVoting := totalVotingPower.Quo(state.Electorate.TotalBondedTokens.ToDec()) + if percentVoting.LT(state.Electorate.TallyParams.GetQuorum(expedited)) { return false, true, tallyResults } - if progress.TotalVotingPower.Sub(progress.Results.Abstain).IsZero() { + if totalVotingPower.Sub(results.Abstain).IsZero() { return false, false, tallyResults } - if progress.Results.NoWithVeto.Quo(progress.TotalVotingPower).GT(progress.TallyParams.VetoThreshold) { + if results.NoWithVeto.Quo(totalVotingPower).GT(state.Electorate.TallyParams.VetoThreshold) { return false, true, tallyResults } - nonAbstainingPower := progress.TotalVotingPower.Sub(progress.Results.Abstain) - if progress.Results.Yes.Quo(nonAbstainingPower).GT(progress.TallyParams.GetThreshold(progress.Expedited)) { + nonAbstainingPower := totalVotingPower.Sub(results.Abstain) + if results.Yes.Quo(nonAbstainingPower).GT(state.Electorate.TallyParams.GetThreshold(expedited)) { return true, false, tallyResults } return false, false, tallyResults } -func (progress *tallyProgress) addValidatorResults(validator tallyValidator) { +func addValidatorResults( + results *tallyOptionResults, + totalVotingPower sdk.Dec, + validator tallyValidator, + accumulator tallyValidatorAccumulator, +) sdk.Dec { if validator.DelegatorShares.IsZero() { - return + return totalVotingPower } - countedDelegatorShares := validator.ObservedDelegatorShares + countedDelegatorShares := accumulator.ObservedDelegatorShares delegatorScale := sdk.OneDec() if countedDelegatorShares.GT(validator.DelegatorShares) { delegatorScale = validator.DelegatorShares.Quo(countedDelegatorShares) countedDelegatorShares = validator.DelegatorShares } - progress.Results.addScaled(validator.DelegatorResults, delegatorScale) + results.addScaled(accumulator.DelegatorResults, delegatorScale) delegatorVotingPower := countedDelegatorShares.MulInt(validator.BondedTokens).Quo(validator.DelegatorShares) - progress.TotalVotingPower = progress.TotalVotingPower.Add(delegatorVotingPower) + totalVotingPower = totalVotingPower.Add(delegatorVotingPower) - if len(validator.Vote) == 0 { - return + if len(accumulator.Vote) == 0 { + return totalVotingPower } validatorShares := validator.DelegatorShares.Sub(countedDelegatorShares) validatorVotingPower := validatorShares.MulInt(validator.BondedTokens).Quo(validator.DelegatorShares) - progress.Results.add(validator.Vote, validatorVotingPower) - progress.TotalVotingPower = progress.TotalVotingPower.Add(validatorVotingPower) + results.add(accumulator.Vote, validatorVotingPower) + return totalVotingPower.Add(validatorVotingPower) } func (results *tallyOptionResults) add(options types.WeightedVoteOptions, votingPower sdk.Dec) { @@ -494,6 +659,14 @@ func newTallyOptionResults() tallyOptionResults { } } +func newTallyValidatorAccumulator() tallyValidatorAccumulator { + return tallyValidatorAccumulator{ + ObservedDelegatorShares: sdk.ZeroDec(), + DelegatorResults: newTallyOptionResults(), + Vote: types.WeightedVoteOptions{}, + } +} + func (results tallyOptionResults) tallyResult() types.TallyResult { return types.NewTallyResult( results.Yes.TruncateInt(), @@ -503,24 +676,70 @@ func (results tallyOptionResults) tallyResult() types.TallyResult { ) } +func tallyOptionResultsToProto(results tallyOptionResults) types.TallyOptionResults { + return types.TallyOptionResults{ + Yes: results.Yes, + Abstain: results.Abstain, + No: results.No, + NoWithVeto: results.NoWithVeto, + } +} + +func tallyOptionResultsFromProto(results types.TallyOptionResults) tallyOptionResults { + return tallyOptionResults{ + Yes: results.Yes, + Abstain: results.Abstain, + No: results.No, + NoWithVeto: results.NoWithVeto, + } +} + +func tallyValidatorAccumulatorToProto(accumulator tallyValidatorAccumulator) types.TallyValidatorAccumulator { + return types.TallyValidatorAccumulator{ + ObservedDelegatorShares: accumulator.ObservedDelegatorShares, + DelegatorResults: tallyOptionResultsToProto(accumulator.DelegatorResults), + Vote: accumulator.Vote, + } +} + +func tallyValidatorAccumulatorFromProto(accumulator types.TallyValidatorAccumulator) tallyValidatorAccumulator { + return tallyValidatorAccumulator{ + ObservedDelegatorShares: accumulator.ObservedDelegatorShares, + DelegatorResults: tallyOptionResultsFromProto(accumulator.DelegatorResults), + Vote: accumulator.Vote, + } +} + +func tallyProgressToProto(progress tallyProgress) types.TallyProgress { + return types.TallyProgress{ + Cursor: progress.Cursor, + BoundaryId: progress.BoundaryID, + Expedited: progress.Expedited, + } +} + +func tallyProgressFromProto(progress types.TallyProgress) tallyProgress { + return tallyProgress{ + Cursor: append([]byte(nil), progress.Cursor...), + BoundaryID: append([]byte(nil), progress.BoundaryId...), + Expedited: progress.Expedited, + } +} + 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 + var stored types.TallyProgress + keeper.cdc.MustUnmarshal(bz, &stored) + return tallyProgressFromProto(stored), 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) + stored := tallyProgressToProto(progress) + ctx.KVStore(keeper.storeKey).Set(types.TallyProgressKey(proposalID), keeper.cdc.MustMarshal(&stored)) } func (keeper Keeper) deleteTallyProgress(ctx sdk.Context, proposalID uint64) { @@ -561,12 +780,34 @@ func (keeper Keeper) cleanupProposalTallyVotes( } complete = !iterator.Valid() - if complete { - store.Delete(types.TallyCleanupKey(proposalID, expedited)) - } return deleted, complete, cursor } +func (keeper Keeper) cleanupProposalTallyValidatorAccumulators( + ctx sdk.Context, + proposalID uint64, + expedited bool, + maxRecords int, + after []byte, +) (deleted int, complete bool, cursor []byte) { + store := ctx.KVStore(keeper.storeKey) + accumulatorsPrefix := types.TallyValidatorAccumulatorsKey(proposalID, expedited) + start := accumulatorsPrefix + if len(after) != 0 { + start = sdk.PrefixEndBytes(after) + } + iterator := store.Iterator(start, sdk.PrefixEndBytes(accumulatorsPrefix)) + defer func() { _ = iterator.Close() }() + + for ; iterator.Valid() && deleted < maxRecords; iterator.Next() { + cursor = append(cursor[:0], iterator.Key()...) + store.Delete(iterator.Key()) + deleted++ + } + + return deleted, !iterator.Valid(), cursor +} + func decodeCleanupCursor(value []byte) []byte { if len(value) == 1 && value[0] == cleanupCursorUnset { return nil diff --git a/sei-cosmos/x/gov/keeper/tally_test.go b/sei-cosmos/x/gov/keeper/tally_test.go index 6d9e3ca49e..ad6415c647 100644 --- a/sei-cosmos/x/gov/keeper/tally_test.go +++ b/sei-cosmos/x/gov/keeper/tally_test.go @@ -557,13 +557,56 @@ func TestTallyIncrementalPersistsProgressAndCleansArchivedVotes(t *testing.T) { 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.Equal(t, 2, app.GovKeeper.CleanupTallyVotes(ctx, 2)) require.Empty(t, app.GovKeeper.GetArchivedTallyVotes(ctx, proposal.ProposalId, false)) + require.Equal(t, 1, app.GovKeeper.CleanupTallyVotes(ctx, 2)) + require.Zero(t, countStorePrefix(ctx, app, types.TallyValidatorAccumulatorsKey(proposal.ProposalId, false))) for _, addr := range addrs[:3] { require.False(t, store.Has(types.TallyVoteDelegationsKey(proposal.ProposalId, false, addr))) } } +func TestTallyProgressPreservesItsFrozenBoundary(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) + + store := ctx.KVStore(app.GetKey(types.StoreKey)) + boundaryID := append([]byte(nil), store.Get(types.ProposalTallyBoundaryKey(proposal.ProposalId))...) + boundaryKey := types.TallyBoundaryMetaKey(boundaryID) + boundaryBefore := append([]byte(nil), store.Get(boundaryKey)...) + var progress types.TallyProgress + app.AppCodec().MustUnmarshal(store.Get(types.TallyProgressKey(proposal.ProposalId)), &progress) + require.Equal(t, boundaryID, progress.BoundaryId) + require.NotEmpty(t, progress.Cursor) + require.NotEmpty(t, boundaryBefore) + require.Equal(t, 1, countStorePrefix(ctx, app, types.TallyValidatorAccumulatorsKey(proposal.ProposalId, false))) + + complete, processed, _, _, _ = app.GovKeeper.TallyIncremental(ctx, proposal, 1) + require.False(t, complete) + require.Equal(t, 1, processed) + require.Equal(t, boundaryBefore, store.Get(boundaryKey)) + require.Equal(t, 2, countStorePrefix(ctx, app, types.TallyValidatorAccumulatorsKey(proposal.ProposalId, false))) +} + func TestTallyIncrementalIgnoresDelegationsAddedAfterTallyStarts(t *testing.T) { app := seiapp.Setup(t, false, false, false) ctx := app.BaseApp.NewContext(false, tmproto.Header{}) diff --git a/sei-cosmos/x/gov/simulation/decoder.go b/sei-cosmos/x/gov/simulation/decoder.go index 8c44d1f4b0..2234fe9b2c 100644 --- a/sei-cosmos/x/gov/simulation/decoder.go +++ b/sei-cosmos/x/gov/simulation/decoder.go @@ -48,25 +48,52 @@ func NewDecodeStore(cdc codec.Codec) func(kvA, kvB kv.Pair) string { 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), - bytes.Equal(kvA.Key[:1], types.VoteDelegationsKeyPrefix), - bytes.Equal(kvA.Key[:1], types.TallyVoteDelegationsKeyPrefix), + case bytes.Equal(kvA.Key[:1], types.TallyProgressKeyPrefix): + var progressA, progressB types.TallyProgress + cdc.MustUnmarshal(kvA.Value, &progressA) + cdc.MustUnmarshal(kvB.Value, &progressB) + return fmt.Sprintf("%v\n%v", progressA, progressB) + + case bytes.Equal(kvA.Key[:1], types.VoteDelegationsKeyPrefix), + bytes.Equal(kvA.Key[:1], types.TallyVoteDelegationsKeyPrefix): + var snapshotA, snapshotB types.VoteDelegationSnapshot + cdc.MustUnmarshal(kvA.Value, &snapshotA) + cdc.MustUnmarshal(kvB.Value, &snapshotB) + return fmt.Sprintf("%v\n%v", snapshotA, snapshotB) + + case bytes.Equal(kvA.Key[:1], types.VoteDelegationUpdatesKeyPrefix): + var updateA, updateB types.VoteDelegationUpdate + cdc.MustUnmarshal(kvA.Value, &updateA) + cdc.MustUnmarshal(kvB.Value, &updateB) + return fmt.Sprintf("%v\n%v", updateA, updateB) + + case bytes.Equal(kvA.Key[:1], types.TallyBoundaryMetaKeyPrefix): + var boundaryA, boundaryB types.TallyBoundary + cdc.MustUnmarshal(kvA.Value, &boundaryA) + cdc.MustUnmarshal(kvB.Value, &boundaryB) + return fmt.Sprintf("%v\n%v", boundaryA, boundaryB) + + case bytes.Equal(kvA.Key[:1], types.TallyValidatorAccumulatorsKeyPrefix): + var accumulatorA, accumulatorB types.TallyValidatorAccumulator + cdc.MustUnmarshal(kvA.Value, &accumulatorA) + cdc.MustUnmarshal(kvB.Value, &accumulatorB) + return fmt.Sprintf("%v\n%v", accumulatorA, accumulatorB) + + case bytes.Equal(kvA.Key[:1], types.TallyCleanupKeyPrefix), bytes.Equal(kvA.Key[:1], types.VoterProposalsKeyPrefix), bytes.Equal(kvA.Key[:1], types.VoteDelegationBackfillCutoffKey), bytes.Equal(kvA.Key[:1], types.VoteDelegationBackfillProgressKeyPrefix), bytes.Equal(kvA.Key[:1], types.VoteDelegationUpdateSequenceKey), - bytes.Equal(kvA.Key[:1], types.VoteDelegationUpdatesKeyPrefix), bytes.Equal(kvA.Key[:1], types.VoterVoteDelegationUpdatesKeyPrefix), bytes.Equal(kvA.Key[:1], types.VoteDelegationSnapshotRevisionKeyPrefix), bytes.Equal(kvA.Key[:1], types.ProposalDeadlineKeyPrefix), bytes.Equal(kvA.Key[:1], types.DeadlineBoundaryBlockTimeKey), - bytes.Equal(kvA.Key[:1], types.TallyBoundaryMetaKeyPrefix), bytes.Equal(kvA.Key[:1], types.GapTallyBoundaryKeyPrefix), bytes.Equal(kvA.Key[:1], types.ExactTallyBoundaryKeyPrefix), bytes.Equal(kvA.Key[:1], types.ProposalTallyBoundaryKeyPrefix), bytes.Equal(kvA.Key[:1], types.IncrementalTallyEnabledKey), - bytes.Equal(kvA.Key[:1], types.ModernTallyRoundKeyPrefix): + bytes.Equal(kvA.Key[:1], types.ModernTallyRoundKeyPrefix), + bytes.Equal(kvA.Key[:1], types.TallyAccumulatorCleanupKeyPrefix): return fmt.Sprintf("%X\n%X", kvA.Value, kvB.Value) default: diff --git a/sei-cosmos/x/gov/spec/02_state.md b/sei-cosmos/x/gov/spec/02_state.md index f02aadd788..e7d41d5a85 100644 --- a/sei-cosmos/x/gov/spec/02_state.md +++ b/sei-cosmos/x/gov/spec/02_state.md @@ -216,13 +216,14 @@ And the pseudocode for the `ProposalProcessingQueue`: ## 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 and deposits -are rejected after the voting period ends. A vote's per-validator delegation snapshot -is created with the vote and refreshed by staking hooks whenever that voter delegates, -undelegates, or redelegates before the proposal's electorate boundary. The boundary +An expired proposal retains a cursor, a snapshot of the bonded validators and tally +parameters, and mutable per-validator tally accumulators 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 and deposits are rejected after the voting period ends. A vote's per-validator +delegation snapshot is created with the vote and refreshed by staking hooks whenever +that voter delegates, undelegates, or redelegates before the proposal's electorate +boundary. The boundary freezes voter shares, bonded-validator tokens and shares, total bonded tokens, and tally parameters together. Deadlines strictly between consecutive block times use the state committed before the later block begins; deadlines equal to a block time use the @@ -239,12 +240,14 @@ budget reserved so cleanup cannot be starved by unfinished tallies. Delegation changes caused by validator slashing are queued as constant-size updates instead of rewriting every affected vote snapshot in `BeginBlock`. Before advancing -an affected tally, `EndBlock` folds updates through that proposal's frozen boundary -sequence into canonical vote snapshots under the same record-work budget. Later -updates do not delay or alter the frozen proposal. Read-only tally and export paths -overlay relevant queued updates so they remain consistent while that bounded work is -unfinished. Once an incremental tally has started, tally queries continue from its -persisted accumulator and frozen electorate. +a tally, `EndBlock` drains the globally ordered update queue through that proposal's +frozen boundary sequence under the same record-work budget. Updates at or before the +boundary can consume that budget even when their voters did not vote on the proposal, +so a slash-update backlog can postpone its vote processing and later expired proposals +in the queue. Updates after the boundary neither delay nor alter the frozen proposal. +Read-only tally and export paths overlay relevant queued updates so they remain +consistent while that bounded work is unfinished. Once an incremental tally has +started, tally queries continue from its persisted accumulator and frozen electorate. The version 4 governance store migration records the first proposal ID that does not need delegation-tracking backfill. That cutoff is retained in application-state diff --git a/sei-cosmos/x/gov/types/genesis.go b/sei-cosmos/x/gov/types/genesis.go index 41807ca358..de35a256db 100644 --- a/sei-cosmos/x/gov/types/genesis.go +++ b/sei-cosmos/x/gov/types/genesis.go @@ -274,9 +274,11 @@ func validateTallyElectorates(proposals Proposals, electorates []TallyElectorate } validatorTokens = validatorTokens.Add(validator.BondedTokens) } - if !validatorTokens.Equal(electorate.TotalBondedTokens) { + // Jailed and otherwise inactive bonded validators remain in the quorum + // denominator but are not part of the frozen active validator set. + if validatorTokens.GT(electorate.TotalBondedTokens) { return fmt.Errorf( - "tally electorate validator tokens %s do not equal total bonded tokens %s", + "tally electorate validator tokens %s exceed total bonded tokens %s", validatorTokens, electorate.TotalBondedTokens, ) diff --git a/sei-cosmos/x/gov/types/genesis_test.go b/sei-cosmos/x/gov/types/genesis_test.go index 644f0908ca..72fc885895 100644 --- a/sei-cosmos/x/gov/types/genesis_test.go +++ b/sei-cosmos/x/gov/types/genesis_test.go @@ -93,6 +93,18 @@ func TestValidateGenesisRequiresSnapshotsForFrozenElectorateVotes(t *testing.T) require.NoError(t, ValidateGenesis(state)) } +func TestValidateGenesisAllowsInactiveBondedStakeInTallyElectorate(t *testing.T) { + state := DefaultGenesisState() + state.Proposals = Proposals{{ProposalId: 1, Status: StatusVotingPeriod}} + state.TallyElectorates = []TallyElectorate{validTallyElectorate(1)} + state.TallyElectorates[0].TotalBondedTokens = sdk.NewInt(2) + + require.NoError(t, ValidateGenesis(state)) + + state.TallyElectorates[0].TotalBondedTokens = sdk.ZeroInt() + require.ErrorContains(t, ValidateGenesis(state), "exceed total bonded tokens") +} + func TestGenesisStateEqualIncludesVoteDelegationSnapshots(t *testing.T) { state1 := GenesisState{VoteDelegationSnapshots: []VoteDelegationSnapshot{{ ProposalId: 1, diff --git a/sei-cosmos/x/gov/types/keys.go b/sei-cosmos/x/gov/types/keys.go index 2cf7faefa0..6401e3c074 100644 --- a/sei-cosmos/x/gov/types/keys.go +++ b/sei-cosmos/x/gov/types/keys.go @@ -78,6 +78,10 @@ const ( // - 0x42: Incremental tally activation marker // - 0x43: Legacy proposal's post-expedited modern tally round + +// - 0x44: Tally validator accumulator + +// - 0x45: Tally validator accumulator cleanup cursor var ( ProposalsKeyPrefix = []byte{0x00} ActiveProposalQueuePrefix = []byte{0x01} @@ -103,14 +107,16 @@ var ( VoterVoteDelegationUpdatesKeyPrefix = []byte{0x3A} VoteDelegationSnapshotRevisionKeyPrefix = []byte{0x3B} - ProposalDeadlineKeyPrefix = []byte{0x3C} - DeadlineBoundaryBlockTimeKey = []byte{0x3D} - TallyBoundaryMetaKeyPrefix = []byte{0x3E} - GapTallyBoundaryKeyPrefix = []byte{0x3F} - ExactTallyBoundaryKeyPrefix = []byte{0x40} - ProposalTallyBoundaryKeyPrefix = []byte{0x41} - IncrementalTallyEnabledKey = []byte{0x42} - ModernTallyRoundKeyPrefix = []byte{0x43} + ProposalDeadlineKeyPrefix = []byte{0x3C} + DeadlineBoundaryBlockTimeKey = []byte{0x3D} + TallyBoundaryMetaKeyPrefix = []byte{0x3E} + GapTallyBoundaryKeyPrefix = []byte{0x3F} + ExactTallyBoundaryKeyPrefix = []byte{0x40} + ProposalTallyBoundaryKeyPrefix = []byte{0x41} + IncrementalTallyEnabledKey = []byte{0x42} + ModernTallyRoundKeyPrefix = []byte{0x43} + TallyValidatorAccumulatorsKeyPrefix = []byte{0x44} + TallyAccumulatorCleanupKeyPrefix = []byte{0x45} ) var lenTime = len(sdk.FormatTimeBytes(time.Now())) @@ -285,6 +291,21 @@ func TallyCleanupKey(proposalID uint64, expedited bool) []byte { return append(append(TallyCleanupKeyPrefix, GetProposalIDBytes(proposalID)...), tallyRound(expedited)) } +// TallyValidatorAccumulatorsKey returns the prefix for mutable validator state in a tally round. +func TallyValidatorAccumulatorsKey(proposalID uint64, expedited bool) []byte { + return append(append(TallyValidatorAccumulatorsKeyPrefix, GetProposalIDBytes(proposalID)...), tallyRound(expedited)) +} + +// TallyValidatorAccumulatorKey returns the key for one validator's mutable tally state. +func TallyValidatorAccumulatorKey(proposalID uint64, expedited bool, validator sdk.ValAddress) []byte { + return append(TallyValidatorAccumulatorsKey(proposalID, expedited), address.MustLengthPrefix(validator.Bytes())...) +} + +// TallyAccumulatorCleanupKey returns the cleanup cursor key for a tally round's validator accumulators. +func TallyAccumulatorCleanupKey(proposalID uint64, expedited bool) []byte { + return append(append(TallyAccumulatorCleanupKeyPrefix, GetProposalIDBytes(proposalID)...), tallyRound(expedited)) +} + func tallyRound(expedited bool) byte { if expedited { return 0 @@ -331,6 +352,15 @@ func SplitTallyCleanupKey(key []byte) (proposalID uint64, expedited bool) { return GetProposalIDFromBytes(key[1:9]), decodeTallyRound(key[9]) } +// SplitTallyAccumulatorCleanupKey returns the proposal and tally round encoded in an accumulator cleanup key. +func SplitTallyAccumulatorCleanupKey(key []byte) (proposalID uint64, expedited bool) { + kv.AssertKeyLength(key, 10) + if key[0] != TallyAccumulatorCleanupKeyPrefix[0] { + panic(fmt.Sprintf("invalid tally accumulator cleanup key prefix %d", key[0])) + } + return GetProposalIDFromBytes(key[1:9]), decodeTallyRound(key[9]) +} + // SplitKeyDeposit split the deposits key and returns the proposal id and depositor address func SplitKeyDeposit(key []byte) (proposalID uint64, depositorAddr sdk.AccAddress) { return splitKeyWithAddress(key) diff --git a/sei-cosmos/x/gov/types/keys_test.go b/sei-cosmos/x/gov/types/keys_test.go index d20a944d5a..51d432c533 100644 --- a/sei-cosmos/x/gov/types/keys_test.go +++ b/sei-cosmos/x/gov/types/keys_test.go @@ -76,11 +76,25 @@ func TestTallyKeys(t *testing.T) { require.Equal(t, append(append(VoteDelegationSnapshotRevisionKeyPrefix, GetProposalIDBytes(2)...), address.MustLengthPrefix(addr.Bytes())...), VoteDelegationSnapshotRevisionKey(2, addr)) require.Equal(t, []byte{0x42}, IncrementalTallyEnabledKey) require.Equal(t, append(ModernTallyRoundKeyPrefix, GetProposalIDBytes(2)...), ModernTallyRoundKey(2)) + require.Equal( + t, + append(append(TallyValidatorAccumulatorsKeyPrefix, GetProposalIDBytes(2)...), byte(1)), + TallyValidatorAccumulatorsKey(2, false), + ) + require.Equal( + t, + append(TallyValidatorAccumulatorsKey(2, false), address.MustLengthPrefix(addr.Bytes())...), + TallyValidatorAccumulatorKey(2, false, sdk.ValAddress(addr)), + ) + require.NotEqual(t, TallyAccumulatorCleanupKey(2, true), TallyAccumulatorCleanupKey(2, false)) for _, expedited := range []bool{false, true} { proposalID, decodedExpedited := SplitTallyCleanupKey(TallyCleanupKey(2, expedited)) require.Equal(t, uint64(2), proposalID) require.Equal(t, expedited, decodedExpedited) + proposalID, decodedExpedited = SplitTallyAccumulatorCleanupKey(TallyAccumulatorCleanupKey(2, expedited)) + require.Equal(t, uint64(2), proposalID) + require.Equal(t, expedited, decodedExpedited) require.Equal( t, TallyVoteDelegationsKey(2, expedited, addr), diff --git a/sei-cosmos/x/gov/types/state.pb.go b/sei-cosmos/x/gov/types/state.pb.go new file mode 100644 index 0000000000..5e36e64576 --- /dev/null +++ b/sei-cosmos/x/gov/types/state.pb.go @@ -0,0 +1,1905 @@ +// Code generated by protoc-gen-gogo. DO NOT EDIT. +// source: cosmos/gov/v1beta1/state.proto + +package types + +import ( + fmt "fmt" + _ "github.com/gogo/protobuf/gogoproto" + proto "github.com/gogo/protobuf/proto" + _ "github.com/gogo/protobuf/types" + github_com_gogo_protobuf_types "github.com/gogo/protobuf/types" + github_com_sei_protocol_sei_chain_sei_cosmos_types "github.com/sei-protocol/sei-chain/sei-cosmos/types" + io "io" + math "math" + math_bits "math/bits" + time "time" +) + +// Reference imports to suppress errors if they are not otherwise used. +var _ = proto.Marshal +var _ = fmt.Errorf +var _ = math.Inf +var _ = time.Kitchen + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the proto package it is being compiled against. +// A compilation error at this line likely means your copy of the +// proto package needs to be updated. +const _ = proto.GoGoProtoPackageIsVersion3 // please upgrade the proto package + +// TallyBoundary defines the electorate and update boundary for a tally round. +type TallyBoundary struct { + LowerTime time.Time `protobuf:"bytes,1,opt,name=lower_time,json=lowerTime,proto3,stdtime" json:"lower_time"` + UpperTime time.Time `protobuf:"bytes,2,opt,name=upper_time,json=upperTime,proto3,stdtime" json:"upper_time"` + UpdateSequence uint64 `protobuf:"varint,3,opt,name=update_sequence,json=updateSequence,proto3" json:"update_sequence,omitempty"` + Electorate FrozenTallyElectorate `protobuf:"bytes,4,opt,name=electorate,proto3" json:"electorate"` +} + +func (m *TallyBoundary) Reset() { *m = TallyBoundary{} } +func (m *TallyBoundary) String() string { return proto.CompactTextString(m) } +func (*TallyBoundary) ProtoMessage() {} +func (*TallyBoundary) Descriptor() ([]byte, []int) { + return fileDescriptor_b559092a754c1c4c, []int{0} +} +func (m *TallyBoundary) XXX_Unmarshal(b []byte) error { + return m.Unmarshal(b) +} +func (m *TallyBoundary) XXX_Marshal(b []byte, deterministic bool) ([]byte, error) { + if deterministic { + return xxx_messageInfo_TallyBoundary.Marshal(b, m, deterministic) + } else { + b = b[:cap(b)] + n, err := m.MarshalToSizedBuffer(b) + if err != nil { + return nil, err + } + return b[:n], nil + } +} +func (m *TallyBoundary) XXX_Merge(src proto.Message) { + xxx_messageInfo_TallyBoundary.Merge(m, src) +} +func (m *TallyBoundary) XXX_Size() int { + return m.Size() +} +func (m *TallyBoundary) XXX_DiscardUnknown() { + xxx_messageInfo_TallyBoundary.DiscardUnknown(m) +} + +var xxx_messageInfo_TallyBoundary proto.InternalMessageInfo + +// FrozenTallyElectorate defines the immutable validator and parameter state for a tally round. +type FrozenTallyElectorate struct { + TotalBondedTokens github_com_sei_protocol_sei_chain_sei_cosmos_types.Int `protobuf:"bytes,1,opt,name=total_bonded_tokens,json=totalBondedTokens,proto3,customtype=github.com/sei-protocol/sei-chain/sei-cosmos/types.Int" json:"total_bonded_tokens"` + TallyParams TallyParams `protobuf:"bytes,2,opt,name=tally_params,json=tallyParams,proto3" json:"tally_params"` + Validators []TallyValidator `protobuf:"bytes,3,rep,name=validators,proto3" json:"validators"` +} + +func (m *FrozenTallyElectorate) Reset() { *m = FrozenTallyElectorate{} } +func (m *FrozenTallyElectorate) String() string { return proto.CompactTextString(m) } +func (*FrozenTallyElectorate) ProtoMessage() {} +func (*FrozenTallyElectorate) Descriptor() ([]byte, []int) { + return fileDescriptor_b559092a754c1c4c, []int{1} +} +func (m *FrozenTallyElectorate) XXX_Unmarshal(b []byte) error { + return m.Unmarshal(b) +} +func (m *FrozenTallyElectorate) XXX_Marshal(b []byte, deterministic bool) ([]byte, error) { + if deterministic { + return xxx_messageInfo_FrozenTallyElectorate.Marshal(b, m, deterministic) + } else { + b = b[:cap(b)] + n, err := m.MarshalToSizedBuffer(b) + if err != nil { + return nil, err + } + return b[:n], nil + } +} +func (m *FrozenTallyElectorate) XXX_Merge(src proto.Message) { + xxx_messageInfo_FrozenTallyElectorate.Merge(m, src) +} +func (m *FrozenTallyElectorate) XXX_Size() int { + return m.Size() +} +func (m *FrozenTallyElectorate) XXX_DiscardUnknown() { + xxx_messageInfo_FrozenTallyElectorate.DiscardUnknown(m) +} + +var xxx_messageInfo_FrozenTallyElectorate proto.InternalMessageInfo + +// TallyProgress defines the mutable cursor for an unfinished tally round. +type TallyProgress struct { + Cursor []byte `protobuf:"bytes,1,opt,name=cursor,proto3" json:"cursor,omitempty"` + BoundaryId []byte `protobuf:"bytes,2,opt,name=boundary_id,json=boundaryId,proto3" json:"boundary_id,omitempty"` + Expedited bool `protobuf:"varint,3,opt,name=expedited,proto3" json:"expedited,omitempty"` +} + +func (m *TallyProgress) Reset() { *m = TallyProgress{} } +func (m *TallyProgress) String() string { return proto.CompactTextString(m) } +func (*TallyProgress) ProtoMessage() {} +func (*TallyProgress) Descriptor() ([]byte, []int) { + return fileDescriptor_b559092a754c1c4c, []int{2} +} +func (m *TallyProgress) XXX_Unmarshal(b []byte) error { + return m.Unmarshal(b) +} +func (m *TallyProgress) XXX_Marshal(b []byte, deterministic bool) ([]byte, error) { + if deterministic { + return xxx_messageInfo_TallyProgress.Marshal(b, m, deterministic) + } else { + b = b[:cap(b)] + n, err := m.MarshalToSizedBuffer(b) + if err != nil { + return nil, err + } + return b[:n], nil + } +} +func (m *TallyProgress) XXX_Merge(src proto.Message) { + xxx_messageInfo_TallyProgress.Merge(m, src) +} +func (m *TallyProgress) XXX_Size() int { + return m.Size() +} +func (m *TallyProgress) XXX_DiscardUnknown() { + xxx_messageInfo_TallyProgress.DiscardUnknown(m) +} + +var xxx_messageInfo_TallyProgress proto.InternalMessageInfo + +// TallyOptionResults defines decimal totals by vote option. +type TallyOptionResults struct { + Yes github_com_sei_protocol_sei_chain_sei_cosmos_types.Dec `protobuf:"bytes,1,opt,name=yes,proto3,customtype=github.com/sei-protocol/sei-chain/sei-cosmos/types.Dec" json:"yes"` + Abstain github_com_sei_protocol_sei_chain_sei_cosmos_types.Dec `protobuf:"bytes,2,opt,name=abstain,proto3,customtype=github.com/sei-protocol/sei-chain/sei-cosmos/types.Dec" json:"abstain"` + No github_com_sei_protocol_sei_chain_sei_cosmos_types.Dec `protobuf:"bytes,3,opt,name=no,proto3,customtype=github.com/sei-protocol/sei-chain/sei-cosmos/types.Dec" json:"no"` + NoWithVeto github_com_sei_protocol_sei_chain_sei_cosmos_types.Dec `protobuf:"bytes,4,opt,name=no_with_veto,json=noWithVeto,proto3,customtype=github.com/sei-protocol/sei-chain/sei-cosmos/types.Dec" json:"no_with_veto"` +} + +func (m *TallyOptionResults) Reset() { *m = TallyOptionResults{} } +func (m *TallyOptionResults) String() string { return proto.CompactTextString(m) } +func (*TallyOptionResults) ProtoMessage() {} +func (*TallyOptionResults) Descriptor() ([]byte, []int) { + return fileDescriptor_b559092a754c1c4c, []int{3} +} +func (m *TallyOptionResults) XXX_Unmarshal(b []byte) error { + return m.Unmarshal(b) +} +func (m *TallyOptionResults) XXX_Marshal(b []byte, deterministic bool) ([]byte, error) { + if deterministic { + return xxx_messageInfo_TallyOptionResults.Marshal(b, m, deterministic) + } else { + b = b[:cap(b)] + n, err := m.MarshalToSizedBuffer(b) + if err != nil { + return nil, err + } + return b[:n], nil + } +} +func (m *TallyOptionResults) XXX_Merge(src proto.Message) { + xxx_messageInfo_TallyOptionResults.Merge(m, src) +} +func (m *TallyOptionResults) XXX_Size() int { + return m.Size() +} +func (m *TallyOptionResults) XXX_DiscardUnknown() { + xxx_messageInfo_TallyOptionResults.DiscardUnknown(m) +} + +var xxx_messageInfo_TallyOptionResults proto.InternalMessageInfo + +// TallyValidatorAccumulator defines the mutable tally state for one frozen validator. +type TallyValidatorAccumulator struct { + ObservedDelegatorShares github_com_sei_protocol_sei_chain_sei_cosmos_types.Dec `protobuf:"bytes,1,opt,name=observed_delegator_shares,json=observedDelegatorShares,proto3,customtype=github.com/sei-protocol/sei-chain/sei-cosmos/types.Dec" json:"observed_delegator_shares"` + DelegatorResults TallyOptionResults `protobuf:"bytes,2,opt,name=delegator_results,json=delegatorResults,proto3" json:"delegator_results"` + Vote []WeightedVoteOption `protobuf:"bytes,3,rep,name=vote,proto3" json:"vote"` +} + +func (m *TallyValidatorAccumulator) Reset() { *m = TallyValidatorAccumulator{} } +func (m *TallyValidatorAccumulator) String() string { return proto.CompactTextString(m) } +func (*TallyValidatorAccumulator) ProtoMessage() {} +func (*TallyValidatorAccumulator) Descriptor() ([]byte, []int) { + return fileDescriptor_b559092a754c1c4c, []int{4} +} +func (m *TallyValidatorAccumulator) XXX_Unmarshal(b []byte) error { + return m.Unmarshal(b) +} +func (m *TallyValidatorAccumulator) XXX_Marshal(b []byte, deterministic bool) ([]byte, error) { + if deterministic { + return xxx_messageInfo_TallyValidatorAccumulator.Marshal(b, m, deterministic) + } else { + b = b[:cap(b)] + n, err := m.MarshalToSizedBuffer(b) + if err != nil { + return nil, err + } + return b[:n], nil + } +} +func (m *TallyValidatorAccumulator) XXX_Merge(src proto.Message) { + xxx_messageInfo_TallyValidatorAccumulator.Merge(m, src) +} +func (m *TallyValidatorAccumulator) XXX_Size() int { + return m.Size() +} +func (m *TallyValidatorAccumulator) XXX_DiscardUnknown() { + xxx_messageInfo_TallyValidatorAccumulator.DiscardUnknown(m) +} + +var xxx_messageInfo_TallyValidatorAccumulator proto.InternalMessageInfo + +// VoteDelegationUpdate defines one deferred slash-induced delegation update. +type VoteDelegationUpdate struct { + Voter string `protobuf:"bytes,1,opt,name=voter,proto3" json:"voter,omitempty"` + Validator string `protobuf:"bytes,2,opt,name=validator,proto3" json:"validator,omitempty"` + Shares github_com_sei_protocol_sei_chain_sei_cosmos_types.Dec `protobuf:"bytes,3,opt,name=shares,proto3,customtype=github.com/sei-protocol/sei-chain/sei-cosmos/types.Dec" json:"shares"` + BlockTime time.Time `protobuf:"bytes,4,opt,name=block_time,json=blockTime,proto3,stdtime" json:"block_time"` + Cursor []byte `protobuf:"bytes,5,opt,name=cursor,proto3" json:"cursor,omitempty"` +} + +func (m *VoteDelegationUpdate) Reset() { *m = VoteDelegationUpdate{} } +func (m *VoteDelegationUpdate) String() string { return proto.CompactTextString(m) } +func (*VoteDelegationUpdate) ProtoMessage() {} +func (*VoteDelegationUpdate) Descriptor() ([]byte, []int) { + return fileDescriptor_b559092a754c1c4c, []int{5} +} +func (m *VoteDelegationUpdate) XXX_Unmarshal(b []byte) error { + return m.Unmarshal(b) +} +func (m *VoteDelegationUpdate) XXX_Marshal(b []byte, deterministic bool) ([]byte, error) { + if deterministic { + return xxx_messageInfo_VoteDelegationUpdate.Marshal(b, m, deterministic) + } else { + b = b[:cap(b)] + n, err := m.MarshalToSizedBuffer(b) + if err != nil { + return nil, err + } + return b[:n], nil + } +} +func (m *VoteDelegationUpdate) XXX_Merge(src proto.Message) { + xxx_messageInfo_VoteDelegationUpdate.Merge(m, src) +} +func (m *VoteDelegationUpdate) XXX_Size() int { + return m.Size() +} +func (m *VoteDelegationUpdate) XXX_DiscardUnknown() { + xxx_messageInfo_VoteDelegationUpdate.DiscardUnknown(m) +} + +var xxx_messageInfo_VoteDelegationUpdate proto.InternalMessageInfo + +func init() { + proto.RegisterType((*TallyBoundary)(nil), "cosmos.gov.v1beta1.TallyBoundary") + proto.RegisterType((*FrozenTallyElectorate)(nil), "cosmos.gov.v1beta1.FrozenTallyElectorate") + proto.RegisterType((*TallyProgress)(nil), "cosmos.gov.v1beta1.TallyProgress") + proto.RegisterType((*TallyOptionResults)(nil), "cosmos.gov.v1beta1.TallyOptionResults") + proto.RegisterType((*TallyValidatorAccumulator)(nil), "cosmos.gov.v1beta1.TallyValidatorAccumulator") + proto.RegisterType((*VoteDelegationUpdate)(nil), "cosmos.gov.v1beta1.VoteDelegationUpdate") +} + +func init() { proto.RegisterFile("cosmos/gov/v1beta1/state.proto", fileDescriptor_b559092a754c1c4c) } + +var fileDescriptor_b559092a754c1c4c = []byte{ + // 759 bytes of a gzipped FileDescriptorProto + 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0xac, 0x95, 0xcd, 0x52, 0xe3, 0x46, + 0x10, 0xc7, 0x2d, 0xdb, 0x10, 0x3c, 0x76, 0x3e, 0x98, 0x90, 0xc4, 0x50, 0x94, 0xec, 0xf2, 0x21, + 0x21, 0x87, 0xc8, 0x05, 0xa9, 0x4a, 0xe5, 0x94, 0x4a, 0x1c, 0x92, 0x82, 0x4b, 0xa0, 0x04, 0x31, + 0x49, 0x2e, 0xca, 0x48, 0x6a, 0x64, 0x15, 0xf2, 0xb4, 0xa2, 0x19, 0x09, 0xcc, 0x53, 0x50, 0x95, + 0xe3, 0xbe, 0xc6, 0x3e, 0x04, 0x47, 0x8e, 0x5b, 0x7b, 0x60, 0x77, 0xe1, 0x0d, 0xf6, 0x05, 0x76, + 0x4b, 0xa3, 0x11, 0x86, 0x5a, 0x73, 0xa0, 0xe0, 0x36, 0xd3, 0xd3, 0xfd, 0x9b, 0xee, 0xd6, 0xbf, + 0x47, 0xc4, 0xf4, 0x50, 0x8c, 0x51, 0xf4, 0x03, 0xcc, 0xfa, 0xd9, 0xba, 0x0b, 0x92, 0xad, 0xf7, + 0x85, 0x64, 0x12, 0xac, 0x38, 0x41, 0x89, 0x94, 0x16, 0xe7, 0x56, 0x80, 0x99, 0xa5, 0xcf, 0x57, + 0xba, 0x33, 0x62, 0x02, 0xe0, 0x20, 0x42, 0x51, 0x44, 0xad, 0xac, 0xce, 0xf2, 0xc0, 0x4c, 0x9f, + 0x2e, 0x05, 0x18, 0xa0, 0x5a, 0xf6, 0xf3, 0x95, 0xb6, 0x76, 0x02, 0xc4, 0x20, 0x82, 0xbe, 0xda, + 0xb9, 0xe9, 0x61, 0x5f, 0x86, 0x63, 0x10, 0x92, 0x8d, 0xe3, 0xc2, 0xa1, 0xf7, 0x7f, 0x95, 0x7c, + 0xbc, 0xcf, 0xa2, 0x68, 0x32, 0xc0, 0x94, 0xfb, 0x2c, 0x99, 0xd0, 0x5f, 0x09, 0x89, 0xf0, 0x18, + 0x12, 0x27, 0x77, 0x6d, 0x1b, 0x5d, 0x63, 0xad, 0xb9, 0xb1, 0x62, 0x15, 0x1c, 0xab, 0xe4, 0x58, + 0xfb, 0x25, 0x67, 0xb0, 0x70, 0x7e, 0xd9, 0xa9, 0x9c, 0xbd, 0xea, 0x18, 0x76, 0x43, 0xc5, 0xe5, + 0x27, 0x39, 0x24, 0x8d, 0xe3, 0x12, 0x52, 0x7d, 0x08, 0x44, 0xc5, 0x29, 0xc8, 0x37, 0xe4, 0xd3, + 0x34, 0xf6, 0x99, 0x04, 0x47, 0xc0, 0x7f, 0x29, 0x70, 0x0f, 0xda, 0xb5, 0xae, 0xb1, 0x56, 0xb7, + 0x3f, 0x29, 0xcc, 0x7b, 0xda, 0x4a, 0x77, 0x08, 0x81, 0x08, 0x3c, 0x89, 0x09, 0x93, 0xd0, 0xae, + 0xab, 0xdb, 0xbe, 0xb5, 0x3e, 0x6c, 0xb2, 0xf5, 0x7b, 0x82, 0xa7, 0xc0, 0x55, 0xbd, 0xbf, 0xdd, + 0x04, 0x0c, 0xea, 0xf9, 0xe5, 0xf6, 0x2d, 0x44, 0xef, 0x59, 0x95, 0x7c, 0x31, 0xd3, 0x97, 0x72, + 0xf2, 0xb9, 0x44, 0xc9, 0x22, 0xc7, 0x45, 0xee, 0x83, 0xef, 0x48, 0x3c, 0x02, 0x2e, 0x54, 0x9b, + 0x1a, 0x83, 0x9f, 0x72, 0xd0, 0xcb, 0xcb, 0xce, 0x0f, 0x41, 0x28, 0x47, 0xa9, 0x6b, 0x79, 0x38, + 0xee, 0x0b, 0x08, 0xbf, 0x53, 0x05, 0x7b, 0x18, 0xa9, 0x8d, 0x37, 0x62, 0x21, 0x2f, 0x56, 0xc5, + 0xf7, 0x94, 0x93, 0x18, 0x84, 0xb5, 0xcd, 0xa5, 0xbd, 0xa8, 0xd0, 0x03, 0x45, 0xde, 0x57, 0x60, + 0xba, 0x45, 0x5a, 0x32, 0x4f, 0xc1, 0x89, 0x59, 0xc2, 0xc6, 0x42, 0xb7, 0xb2, 0x33, 0xab, 0x38, + 0x95, 0xea, 0xae, 0x72, 0xd3, 0x25, 0x35, 0xe5, 0xd4, 0x44, 0xb7, 0x08, 0xc9, 0x58, 0x14, 0xfa, + 0x4c, 0x62, 0x22, 0xda, 0xb5, 0x6e, 0x6d, 0xad, 0xb9, 0xd1, 0xbb, 0x97, 0x33, 0x2c, 0x5d, 0xcb, + 0xee, 0x4c, 0x63, 0x7b, 0x87, 0x5a, 0x32, 0xbb, 0x09, 0x06, 0x09, 0x08, 0x41, 0xbf, 0x24, 0xf3, + 0x5e, 0x9a, 0x08, 0x4c, 0x54, 0x1f, 0x5a, 0xb6, 0xde, 0xd1, 0x0e, 0x69, 0xba, 0x5a, 0x56, 0x4e, + 0xe8, 0xab, 0xdc, 0x5b, 0x36, 0x29, 0x4d, 0xdb, 0x3e, 0x5d, 0x25, 0x0d, 0x38, 0x89, 0xc1, 0x0f, + 0x25, 0xf8, 0xea, 0xdb, 0x2e, 0xd8, 0x53, 0x43, 0xef, 0x6d, 0x95, 0x50, 0x75, 0xd1, 0x4e, 0x2c, + 0x43, 0xe4, 0x36, 0x88, 0x34, 0x92, 0x82, 0xee, 0x92, 0xda, 0x04, 0x9e, 0xa2, 0xe5, 0x9b, 0xe0, + 0xd9, 0x39, 0x8a, 0xfe, 0x45, 0x3e, 0x62, 0xae, 0x90, 0x2c, 0xe4, 0x2a, 0xc7, 0xc7, 0x53, 0x4b, + 0x1c, 0xfd, 0x83, 0x54, 0x39, 0xaa, 0xca, 0x1e, 0x0f, 0xad, 0x72, 0xa4, 0xff, 0x92, 0x16, 0x47, + 0xe7, 0x38, 0x94, 0x23, 0x27, 0x03, 0x89, 0x4a, 0xeb, 0x8f, 0x27, 0x13, 0x8e, 0x07, 0xa1, 0x1c, + 0x0d, 0x41, 0x62, 0xef, 0x79, 0x95, 0x2c, 0xdf, 0x55, 0xc0, 0x2f, 0x9e, 0x97, 0x8e, 0xd3, 0x28, + 0x5f, 0xd2, 0x53, 0xb2, 0x8c, 0xae, 0x80, 0x24, 0x03, 0xdf, 0xf1, 0x21, 0x82, 0x20, 0xb7, 0x3a, + 0x62, 0xc4, 0x92, 0x27, 0xfb, 0x22, 0x5f, 0x95, 0x17, 0x6c, 0x96, 0xfc, 0x3d, 0x85, 0xa7, 0x7f, + 0x93, 0xc5, 0xe9, 0x95, 0x49, 0x21, 0x06, 0x3d, 0x0f, 0x5f, 0xdf, 0xab, 0xe3, 0x3b, 0xd2, 0xd1, + 0x5a, 0xfe, 0xec, 0x06, 0x53, 0x4a, 0xea, 0x67, 0x52, 0xcf, 0x50, 0x82, 0x9e, 0x8a, 0x99, 0xb4, + 0x03, 0x08, 0x83, 0x91, 0x04, 0x7f, 0x88, 0x12, 0x0a, 0xa8, 0xa6, 0xa9, 0xc8, 0xde, 0x3b, 0x83, + 0x2c, 0xe5, 0x47, 0x3a, 0xe9, 0x10, 0xf9, 0x9f, 0xea, 0x8d, 0xa2, 0x4b, 0x64, 0x2e, 0x77, 0x28, + 0x46, 0xa3, 0x61, 0x17, 0x9b, 0x5c, 0xf8, 0x37, 0x03, 0x55, 0x68, 0xce, 0x9e, 0x1a, 0xe8, 0x90, + 0xcc, 0xeb, 0x96, 0x3e, 0x8d, 0x72, 0x34, 0x2d, 0x7f, 0x95, 0xdd, 0x08, 0xbd, 0xa3, 0xe2, 0x55, + 0xae, 0x3f, 0xe4, 0x55, 0x56, 0x71, 0xea, 0x55, 0x9e, 0x0e, 0xfb, 0xdc, 0xed, 0x61, 0x1f, 0x0c, + 0xcf, 0xdf, 0x98, 0x95, 0xf3, 0x2b, 0xd3, 0xb8, 0xb8, 0x32, 0x8d, 0xd7, 0x57, 0xa6, 0x71, 0x76, + 0x6d, 0x56, 0x2e, 0xae, 0xcd, 0xca, 0x8b, 0x6b, 0xb3, 0xf2, 0xcf, 0x8f, 0x0f, 0x4a, 0xfd, 0x44, + 0xfd, 0xe4, 0x54, 0x01, 0xee, 0xbc, 0x72, 0xfd, 0xfe, 0x7d, 0x00, 0x00, 0x00, 0xff, 0xff, 0x2f, + 0x50, 0x22, 0xad, 0x55, 0x07, 0x00, 0x00, +} + +func (m *TallyBoundary) Marshal() (dAtA []byte, err error) { + size := m.Size() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBuffer(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *TallyBoundary) MarshalTo(dAtA []byte) (int, error) { + size := m.Size() + return m.MarshalToSizedBuffer(dAtA[:size]) +} + +func (m *TallyBoundary) MarshalToSizedBuffer(dAtA []byte) (int, error) { + i := len(dAtA) + _ = i + var l int + _ = l + { + size, err := m.Electorate.MarshalToSizedBuffer(dAtA[:i]) + if err != nil { + return 0, err + } + i -= size + i = encodeVarintState(dAtA, i, uint64(size)) + } + i-- + dAtA[i] = 0x22 + if m.UpdateSequence != 0 { + i = encodeVarintState(dAtA, i, uint64(m.UpdateSequence)) + i-- + dAtA[i] = 0x18 + } + n2, err2 := github_com_gogo_protobuf_types.StdTimeMarshalTo(m.UpperTime, dAtA[i-github_com_gogo_protobuf_types.SizeOfStdTime(m.UpperTime):]) + if err2 != nil { + return 0, err2 + } + i -= n2 + i = encodeVarintState(dAtA, i, uint64(n2)) + i-- + dAtA[i] = 0x12 + n3, err3 := github_com_gogo_protobuf_types.StdTimeMarshalTo(m.LowerTime, dAtA[i-github_com_gogo_protobuf_types.SizeOfStdTime(m.LowerTime):]) + if err3 != nil { + return 0, err3 + } + i -= n3 + i = encodeVarintState(dAtA, i, uint64(n3)) + i-- + dAtA[i] = 0xa + return len(dAtA) - i, nil +} + +func (m *FrozenTallyElectorate) Marshal() (dAtA []byte, err error) { + size := m.Size() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBuffer(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *FrozenTallyElectorate) MarshalTo(dAtA []byte) (int, error) { + size := m.Size() + return m.MarshalToSizedBuffer(dAtA[:size]) +} + +func (m *FrozenTallyElectorate) MarshalToSizedBuffer(dAtA []byte) (int, error) { + i := len(dAtA) + _ = i + var l int + _ = l + if len(m.Validators) > 0 { + for iNdEx := len(m.Validators) - 1; iNdEx >= 0; iNdEx-- { + { + size, err := m.Validators[iNdEx].MarshalToSizedBuffer(dAtA[:i]) + if err != nil { + return 0, err + } + i -= size + i = encodeVarintState(dAtA, i, uint64(size)) + } + i-- + dAtA[i] = 0x1a + } + } + { + size, err := m.TallyParams.MarshalToSizedBuffer(dAtA[:i]) + if err != nil { + return 0, err + } + i -= size + i = encodeVarintState(dAtA, i, uint64(size)) + } + i-- + dAtA[i] = 0x12 + { + size := m.TotalBondedTokens.Size() + i -= size + if _, err := m.TotalBondedTokens.MarshalTo(dAtA[i:]); err != nil { + return 0, err + } + i = encodeVarintState(dAtA, i, uint64(size)) + } + i-- + dAtA[i] = 0xa + return len(dAtA) - i, nil +} + +func (m *TallyProgress) Marshal() (dAtA []byte, err error) { + size := m.Size() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBuffer(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *TallyProgress) MarshalTo(dAtA []byte) (int, error) { + size := m.Size() + return m.MarshalToSizedBuffer(dAtA[:size]) +} + +func (m *TallyProgress) MarshalToSizedBuffer(dAtA []byte) (int, error) { + i := len(dAtA) + _ = i + var l int + _ = l + if m.Expedited { + i-- + if m.Expedited { + dAtA[i] = 1 + } else { + dAtA[i] = 0 + } + i-- + dAtA[i] = 0x18 + } + if len(m.BoundaryId) > 0 { + i -= len(m.BoundaryId) + copy(dAtA[i:], m.BoundaryId) + i = encodeVarintState(dAtA, i, uint64(len(m.BoundaryId))) + i-- + dAtA[i] = 0x12 + } + if len(m.Cursor) > 0 { + i -= len(m.Cursor) + copy(dAtA[i:], m.Cursor) + i = encodeVarintState(dAtA, i, uint64(len(m.Cursor))) + i-- + dAtA[i] = 0xa + } + return len(dAtA) - i, nil +} + +func (m *TallyOptionResults) Marshal() (dAtA []byte, err error) { + size := m.Size() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBuffer(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *TallyOptionResults) MarshalTo(dAtA []byte) (int, error) { + size := m.Size() + return m.MarshalToSizedBuffer(dAtA[:size]) +} + +func (m *TallyOptionResults) MarshalToSizedBuffer(dAtA []byte) (int, error) { + i := len(dAtA) + _ = i + var l int + _ = l + { + size := m.NoWithVeto.Size() + i -= size + if _, err := m.NoWithVeto.MarshalTo(dAtA[i:]); err != nil { + return 0, err + } + i = encodeVarintState(dAtA, i, uint64(size)) + } + i-- + dAtA[i] = 0x22 + { + size := m.No.Size() + i -= size + if _, err := m.No.MarshalTo(dAtA[i:]); err != nil { + return 0, err + } + i = encodeVarintState(dAtA, i, uint64(size)) + } + i-- + dAtA[i] = 0x1a + { + size := m.Abstain.Size() + i -= size + if _, err := m.Abstain.MarshalTo(dAtA[i:]); err != nil { + return 0, err + } + i = encodeVarintState(dAtA, i, uint64(size)) + } + i-- + dAtA[i] = 0x12 + { + size := m.Yes.Size() + i -= size + if _, err := m.Yes.MarshalTo(dAtA[i:]); err != nil { + return 0, err + } + i = encodeVarintState(dAtA, i, uint64(size)) + } + i-- + dAtA[i] = 0xa + return len(dAtA) - i, nil +} + +func (m *TallyValidatorAccumulator) Marshal() (dAtA []byte, err error) { + size := m.Size() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBuffer(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *TallyValidatorAccumulator) MarshalTo(dAtA []byte) (int, error) { + size := m.Size() + return m.MarshalToSizedBuffer(dAtA[:size]) +} + +func (m *TallyValidatorAccumulator) MarshalToSizedBuffer(dAtA []byte) (int, error) { + i := len(dAtA) + _ = i + var l int + _ = l + if len(m.Vote) > 0 { + for iNdEx := len(m.Vote) - 1; iNdEx >= 0; iNdEx-- { + { + size, err := m.Vote[iNdEx].MarshalToSizedBuffer(dAtA[:i]) + if err != nil { + return 0, err + } + i -= size + i = encodeVarintState(dAtA, i, uint64(size)) + } + i-- + dAtA[i] = 0x1a + } + } + { + size, err := m.DelegatorResults.MarshalToSizedBuffer(dAtA[:i]) + if err != nil { + return 0, err + } + i -= size + i = encodeVarintState(dAtA, i, uint64(size)) + } + i-- + dAtA[i] = 0x12 + { + size := m.ObservedDelegatorShares.Size() + i -= size + if _, err := m.ObservedDelegatorShares.MarshalTo(dAtA[i:]); err != nil { + return 0, err + } + i = encodeVarintState(dAtA, i, uint64(size)) + } + i-- + dAtA[i] = 0xa + return len(dAtA) - i, nil +} + +func (m *VoteDelegationUpdate) Marshal() (dAtA []byte, err error) { + size := m.Size() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBuffer(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *VoteDelegationUpdate) MarshalTo(dAtA []byte) (int, error) { + size := m.Size() + return m.MarshalToSizedBuffer(dAtA[:size]) +} + +func (m *VoteDelegationUpdate) MarshalToSizedBuffer(dAtA []byte) (int, error) { + i := len(dAtA) + _ = i + var l int + _ = l + if len(m.Cursor) > 0 { + i -= len(m.Cursor) + copy(dAtA[i:], m.Cursor) + i = encodeVarintState(dAtA, i, uint64(len(m.Cursor))) + i-- + dAtA[i] = 0x2a + } + n6, err6 := github_com_gogo_protobuf_types.StdTimeMarshalTo(m.BlockTime, dAtA[i-github_com_gogo_protobuf_types.SizeOfStdTime(m.BlockTime):]) + if err6 != nil { + return 0, err6 + } + i -= n6 + i = encodeVarintState(dAtA, i, uint64(n6)) + i-- + dAtA[i] = 0x22 + { + size := m.Shares.Size() + i -= size + if _, err := m.Shares.MarshalTo(dAtA[i:]); err != nil { + return 0, err + } + i = encodeVarintState(dAtA, i, uint64(size)) + } + i-- + dAtA[i] = 0x1a + if len(m.Validator) > 0 { + i -= len(m.Validator) + copy(dAtA[i:], m.Validator) + i = encodeVarintState(dAtA, i, uint64(len(m.Validator))) + i-- + dAtA[i] = 0x12 + } + if len(m.Voter) > 0 { + i -= len(m.Voter) + copy(dAtA[i:], m.Voter) + i = encodeVarintState(dAtA, i, uint64(len(m.Voter))) + i-- + dAtA[i] = 0xa + } + return len(dAtA) - i, nil +} + +func encodeVarintState(dAtA []byte, offset int, v uint64) int { + offset -= sovState(v) + base := offset + for v >= 1<<7 { + dAtA[offset] = uint8(v&0x7f | 0x80) + v >>= 7 + offset++ + } + dAtA[offset] = uint8(v) + return base +} +func (m *TallyBoundary) Size() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + l = github_com_gogo_protobuf_types.SizeOfStdTime(m.LowerTime) + n += 1 + l + sovState(uint64(l)) + l = github_com_gogo_protobuf_types.SizeOfStdTime(m.UpperTime) + n += 1 + l + sovState(uint64(l)) + if m.UpdateSequence != 0 { + n += 1 + sovState(uint64(m.UpdateSequence)) + } + l = m.Electorate.Size() + n += 1 + l + sovState(uint64(l)) + return n +} + +func (m *FrozenTallyElectorate) Size() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + l = m.TotalBondedTokens.Size() + n += 1 + l + sovState(uint64(l)) + l = m.TallyParams.Size() + n += 1 + l + sovState(uint64(l)) + if len(m.Validators) > 0 { + for _, e := range m.Validators { + l = e.Size() + n += 1 + l + sovState(uint64(l)) + } + } + return n +} + +func (m *TallyProgress) Size() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + l = len(m.Cursor) + if l > 0 { + n += 1 + l + sovState(uint64(l)) + } + l = len(m.BoundaryId) + if l > 0 { + n += 1 + l + sovState(uint64(l)) + } + if m.Expedited { + n += 2 + } + return n +} + +func (m *TallyOptionResults) Size() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + l = m.Yes.Size() + n += 1 + l + sovState(uint64(l)) + l = m.Abstain.Size() + n += 1 + l + sovState(uint64(l)) + l = m.No.Size() + n += 1 + l + sovState(uint64(l)) + l = m.NoWithVeto.Size() + n += 1 + l + sovState(uint64(l)) + return n +} + +func (m *TallyValidatorAccumulator) Size() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + l = m.ObservedDelegatorShares.Size() + n += 1 + l + sovState(uint64(l)) + l = m.DelegatorResults.Size() + n += 1 + l + sovState(uint64(l)) + if len(m.Vote) > 0 { + for _, e := range m.Vote { + l = e.Size() + n += 1 + l + sovState(uint64(l)) + } + } + return n +} + +func (m *VoteDelegationUpdate) Size() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + l = len(m.Voter) + if l > 0 { + n += 1 + l + sovState(uint64(l)) + } + l = len(m.Validator) + if l > 0 { + n += 1 + l + sovState(uint64(l)) + } + l = m.Shares.Size() + n += 1 + l + sovState(uint64(l)) + l = github_com_gogo_protobuf_types.SizeOfStdTime(m.BlockTime) + n += 1 + l + sovState(uint64(l)) + l = len(m.Cursor) + if l > 0 { + n += 1 + l + sovState(uint64(l)) + } + return n +} + +func sovState(x uint64) (n int) { + return (math_bits.Len64(x|1) + 6) / 7 +} +func sozState(x uint64) (n int) { + return sovState(uint64((x << 1) ^ uint64((int64(x) >> 63)))) +} +func (m *TallyBoundary) Unmarshal(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowState + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: TallyBoundary: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: TallyBoundary: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field LowerTime", wireType) + } + var msglen int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowState + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + msglen |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + if msglen < 0 { + return ErrInvalidLengthState + } + postIndex := iNdEx + msglen + if postIndex < 0 { + return ErrInvalidLengthState + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + if err := github_com_gogo_protobuf_types.StdTimeUnmarshal(&m.LowerTime, dAtA[iNdEx:postIndex]); err != nil { + return err + } + iNdEx = postIndex + case 2: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field UpperTime", wireType) + } + var msglen int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowState + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + msglen |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + if msglen < 0 { + return ErrInvalidLengthState + } + postIndex := iNdEx + msglen + if postIndex < 0 { + return ErrInvalidLengthState + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + if err := github_com_gogo_protobuf_types.StdTimeUnmarshal(&m.UpperTime, dAtA[iNdEx:postIndex]); err != nil { + return err + } + iNdEx = postIndex + case 3: + if wireType != 0 { + return fmt.Errorf("proto: wrong wireType = %d for field UpdateSequence", wireType) + } + m.UpdateSequence = 0 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowState + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + m.UpdateSequence |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + case 4: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Electorate", wireType) + } + var msglen int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowState + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + msglen |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + if msglen < 0 { + return ErrInvalidLengthState + } + postIndex := iNdEx + msglen + if postIndex < 0 { + return ErrInvalidLengthState + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + if err := m.Electorate.Unmarshal(dAtA[iNdEx:postIndex]); err != nil { + return err + } + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skipState(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLengthState + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *FrozenTallyElectorate) Unmarshal(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowState + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: FrozenTallyElectorate: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: FrozenTallyElectorate: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field TotalBondedTokens", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowState + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLengthState + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLengthState + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + if err := m.TotalBondedTokens.Unmarshal(dAtA[iNdEx:postIndex]); err != nil { + return err + } + iNdEx = postIndex + case 2: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field TallyParams", wireType) + } + var msglen int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowState + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + msglen |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + if msglen < 0 { + return ErrInvalidLengthState + } + postIndex := iNdEx + msglen + if postIndex < 0 { + return ErrInvalidLengthState + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + if err := m.TallyParams.Unmarshal(dAtA[iNdEx:postIndex]); err != nil { + return err + } + iNdEx = postIndex + case 3: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Validators", wireType) + } + var msglen int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowState + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + msglen |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + if msglen < 0 { + return ErrInvalidLengthState + } + postIndex := iNdEx + msglen + if postIndex < 0 { + return ErrInvalidLengthState + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Validators = append(m.Validators, TallyValidator{}) + if err := m.Validators[len(m.Validators)-1].Unmarshal(dAtA[iNdEx:postIndex]); err != nil { + return err + } + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skipState(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLengthState + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *TallyProgress) Unmarshal(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowState + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: TallyProgress: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: TallyProgress: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Cursor", wireType) + } + var byteLen int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowState + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + byteLen |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + if byteLen < 0 { + return ErrInvalidLengthState + } + postIndex := iNdEx + byteLen + if postIndex < 0 { + return ErrInvalidLengthState + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Cursor = append(m.Cursor[:0], dAtA[iNdEx:postIndex]...) + if m.Cursor == nil { + m.Cursor = []byte{} + } + iNdEx = postIndex + case 2: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field BoundaryId", wireType) + } + var byteLen int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowState + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + byteLen |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + if byteLen < 0 { + return ErrInvalidLengthState + } + postIndex := iNdEx + byteLen + if postIndex < 0 { + return ErrInvalidLengthState + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.BoundaryId = append(m.BoundaryId[:0], dAtA[iNdEx:postIndex]...) + if m.BoundaryId == nil { + m.BoundaryId = []byte{} + } + iNdEx = postIndex + case 3: + if wireType != 0 { + return fmt.Errorf("proto: wrong wireType = %d for field Expedited", wireType) + } + var v int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowState + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + v |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + m.Expedited = bool(v != 0) + default: + iNdEx = preIndex + skippy, err := skipState(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLengthState + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *TallyOptionResults) Unmarshal(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowState + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: TallyOptionResults: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: TallyOptionResults: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Yes", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowState + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLengthState + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLengthState + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + if err := m.Yes.Unmarshal(dAtA[iNdEx:postIndex]); err != nil { + return err + } + iNdEx = postIndex + case 2: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Abstain", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowState + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLengthState + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLengthState + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + if err := m.Abstain.Unmarshal(dAtA[iNdEx:postIndex]); err != nil { + return err + } + iNdEx = postIndex + case 3: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field No", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowState + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLengthState + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLengthState + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + if err := m.No.Unmarshal(dAtA[iNdEx:postIndex]); err != nil { + return err + } + iNdEx = postIndex + case 4: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field NoWithVeto", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowState + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLengthState + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLengthState + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + if err := m.NoWithVeto.Unmarshal(dAtA[iNdEx:postIndex]); err != nil { + return err + } + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skipState(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLengthState + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *TallyValidatorAccumulator) Unmarshal(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowState + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: TallyValidatorAccumulator: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: TallyValidatorAccumulator: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field ObservedDelegatorShares", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowState + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLengthState + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLengthState + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + if err := m.ObservedDelegatorShares.Unmarshal(dAtA[iNdEx:postIndex]); err != nil { + return err + } + iNdEx = postIndex + case 2: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field DelegatorResults", wireType) + } + var msglen int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowState + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + msglen |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + if msglen < 0 { + return ErrInvalidLengthState + } + postIndex := iNdEx + msglen + if postIndex < 0 { + return ErrInvalidLengthState + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + if err := m.DelegatorResults.Unmarshal(dAtA[iNdEx:postIndex]); err != nil { + return err + } + iNdEx = postIndex + case 3: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Vote", wireType) + } + var msglen int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowState + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + msglen |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + if msglen < 0 { + return ErrInvalidLengthState + } + postIndex := iNdEx + msglen + if postIndex < 0 { + return ErrInvalidLengthState + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Vote = append(m.Vote, WeightedVoteOption{}) + if err := m.Vote[len(m.Vote)-1].Unmarshal(dAtA[iNdEx:postIndex]); err != nil { + return err + } + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skipState(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLengthState + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *VoteDelegationUpdate) Unmarshal(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowState + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: VoteDelegationUpdate: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: VoteDelegationUpdate: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Voter", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowState + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLengthState + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLengthState + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Voter = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + case 2: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Validator", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowState + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLengthState + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLengthState + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Validator = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + case 3: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Shares", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowState + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLengthState + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLengthState + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + if err := m.Shares.Unmarshal(dAtA[iNdEx:postIndex]); err != nil { + return err + } + iNdEx = postIndex + case 4: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field BlockTime", wireType) + } + var msglen int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowState + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + msglen |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + if msglen < 0 { + return ErrInvalidLengthState + } + postIndex := iNdEx + msglen + if postIndex < 0 { + return ErrInvalidLengthState + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + if err := github_com_gogo_protobuf_types.StdTimeUnmarshal(&m.BlockTime, dAtA[iNdEx:postIndex]); err != nil { + return err + } + iNdEx = postIndex + case 5: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Cursor", wireType) + } + var byteLen int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowState + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + byteLen |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + if byteLen < 0 { + return ErrInvalidLengthState + } + postIndex := iNdEx + byteLen + if postIndex < 0 { + return ErrInvalidLengthState + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Cursor = append(m.Cursor[:0], dAtA[iNdEx:postIndex]...) + if m.Cursor == nil { + m.Cursor = []byte{} + } + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skipState(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLengthState + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func skipState(dAtA []byte) (n int, err error) { + l := len(dAtA) + iNdEx := 0 + depth := 0 + for iNdEx < l { + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return 0, ErrIntOverflowState + } + if iNdEx >= l { + return 0, io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= (uint64(b) & 0x7F) << shift + if b < 0x80 { + break + } + } + wireType := int(wire & 0x7) + switch wireType { + case 0: + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return 0, ErrIntOverflowState + } + if iNdEx >= l { + return 0, io.ErrUnexpectedEOF + } + iNdEx++ + if dAtA[iNdEx-1] < 0x80 { + break + } + } + case 1: + iNdEx += 8 + case 2: + var length int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return 0, ErrIntOverflowState + } + if iNdEx >= l { + return 0, io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + length |= (int(b) & 0x7F) << shift + if b < 0x80 { + break + } + } + if length < 0 { + return 0, ErrInvalidLengthState + } + iNdEx += length + case 3: + depth++ + case 4: + if depth == 0 { + return 0, ErrUnexpectedEndOfGroupState + } + depth-- + case 5: + iNdEx += 4 + default: + return 0, fmt.Errorf("proto: illegal wireType %d", wireType) + } + if iNdEx < 0 { + return 0, ErrInvalidLengthState + } + if depth == 0 { + return iNdEx, nil + } + } + return 0, io.ErrUnexpectedEOF +} + +var ( + ErrInvalidLengthState = fmt.Errorf("proto: negative length found during unmarshaling") + ErrIntOverflowState = fmt.Errorf("proto: integer overflow") + ErrUnexpectedEndOfGroupState = fmt.Errorf("proto: unexpected end of group") +) From 4fa5c80c4cf2e6c0d251de88ee95111d430ea306 Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Fri, 4 Sep 2026 11:10:15 +0800 Subject: [PATCH 16/17] fix(gov): validate vote delegation snapshot shares --- sei-cosmos/x/gov/types/genesis.go | 3 +++ sei-cosmos/x/gov/types/genesis_test.go | 17 +++++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/sei-cosmos/x/gov/types/genesis.go b/sei-cosmos/x/gov/types/genesis.go index de35a256db..d4fedf72bc 100644 --- a/sei-cosmos/x/gov/types/genesis.go +++ b/sei-cosmos/x/gov/types/genesis.go @@ -312,6 +312,9 @@ func validateVoteDelegationSnapshots(votes Votes, snapshots []VoteDelegationSnap if _, err := sdk.ValAddressFromBech32(delegation.Validator); err != nil { return fmt.Errorf("invalid vote delegation snapshot validator %q: %w", delegation.Validator, err) } + if delegation.Shares.IsNil() { + return fmt.Errorf("vote delegation snapshot shares are not initialized") + } if !delegation.Shares.IsPositive() { return fmt.Errorf("vote delegation snapshot shares must be positive: %s", delegation.Shares) } diff --git a/sei-cosmos/x/gov/types/genesis_test.go b/sei-cosmos/x/gov/types/genesis_test.go index 72fc885895..7461d49633 100644 --- a/sei-cosmos/x/gov/types/genesis_test.go +++ b/sei-cosmos/x/gov/types/genesis_test.go @@ -93,6 +93,23 @@ func TestValidateGenesisRequiresSnapshotsForFrozenElectorateVotes(t *testing.T) require.NoError(t, ValidateGenesis(state)) } +func TestValidateGenesisRejectsUninitializedVoteDelegationSnapshotShares(t *testing.T) { + voter := sdk.AccAddress(bytes.Repeat([]byte{1}, 20)) + validator := sdk.ValAddress(bytes.Repeat([]byte{2}, 20)) + state := DefaultGenesisState() + state.Proposals = Proposals{{ProposalId: 1, Status: StatusVotingPeriod}} + state.Votes = Votes{NewVote(1, voter, NewNonSplitVoteOption(OptionYes))} + state.VoteDelegationSnapshots = []VoteDelegationSnapshot{{ + ProposalId: 1, + Voter: voter.String(), + Delegations: []VoteDelegation{{ + Validator: validator.String(), + }}, + }} + + require.ErrorContains(t, ValidateGenesis(state), "vote delegation snapshot shares are not initialized") +} + func TestValidateGenesisAllowsInactiveBondedStakeInTallyElectorate(t *testing.T) { state := DefaultGenesisState() state.Proposals = Proposals{{ProposalId: 1, Status: StatusVotingPeriod}} From 3351ce56fc8cff74595fd9653c2970c676cb05c1 Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Fri, 4 Sep 2026 15:17:50 +0800 Subject: [PATCH 17/17] fix(evmrpc): activate tally replay at v6.8 --- evmrpc/simulate.go | 9 +++++++-- evmrpc/simulate_test.go | 36 ++++++++++++++++++++------------- evmrpc/tests/regression_test.go | 2 +- 3 files changed, 30 insertions(+), 17 deletions(-) diff --git a/evmrpc/simulate.go b/evmrpc/simulate.go index d67ca6b571..70c2a8b371 100644 --- a/evmrpc/simulate.go +++ b/evmrpc/simulate.go @@ -313,6 +313,11 @@ func (b *Backend) isV67ActiveAtHeight(height int64) bool { return b.keeper.UpgradeKeeper().IsUpgradeActiveAtHeight(ctx, "v6.7", height) } +func (b *Backend) isV68ActiveAtHeight(height int64) bool { + ctx := b.ctxProvider(LatestCtxHeight).WithGasMeter(sdk.NewInfiniteGasMeter(1, 1)) + return b.keeper.UpgradeKeeper().IsUpgradeActiveAtHeight(ctx, "v6.8", height) +} + func (b *Backend) SetTraceContextProvider(provider TraceContextProvider) { if provider != nil { b.traceCtxProvider = provider @@ -755,8 +760,8 @@ func (b *Backend) activateIncrementalTallyForTrace(ctx sdk.Context, height int64 } govKeeper := *b.beginBlockKeepers.GovKeeper if govKeeper.IncrementalTallyEnabled(ctx) || - !b.isV67ActiveAtHeight(height) || - b.isV67ActiveAtHeight(height-1) { + !b.isV68ActiveAtHeight(height) || + b.isV68ActiveAtHeight(height-1) { return nil } return govkeeper.NewMigrator(govKeeper).Migrate3to4(ctx) diff --git a/evmrpc/simulate_test.go b/evmrpc/simulate_test.go index 5328418665..a89bffbce3 100644 --- a/evmrpc/simulate_test.go +++ b/evmrpc/simulate_test.go @@ -981,23 +981,28 @@ func (c *fixedBlockClient) Validators(context.Context, *int64, *int, *int) (*cor } func TestStateAtBlockReplaysIncrementalTallyActivationAndGapBoundary(t *testing.T) { - const activationHeight = int64(200) + const ( + v67UpgradeHeight = int64(200) + v68UpgradeHeight = int64(201) + ) testApp := app.Setup(t, false, false, false) - activationTime := time.Now().UTC().Add(time.Minute) - nextBlockTime := activationTime.Add(10 * time.Second) + v67UpgradeTime := time.Now().UTC().Add(time.Minute) + v68UpgradeTime := v67UpgradeTime.Add(time.Second) + nextBlockTime := v68UpgradeTime.Add(10 * time.Second) baseCtx := testApp.BaseApp.NewContext(false, tenderminttypes.Header{ - Height: activationHeight - 1, - Time: activationTime.Add(-time.Second), - }).WithIsTracing(true).WithClosestUpgradeName("v6.7") + Height: v67UpgradeHeight - 1, + Time: v67UpgradeTime.Add(-time.Second), + }).WithIsTracing(true).WithClosestUpgradeName("v6.8") govStore := baseCtx.KVStore(testApp.GetKey(govtypes.StoreKey)) govStore.Delete(govtypes.IncrementalTallyEnabledKey) govStore.Delete(govtypes.VoteDelegationBackfillCutoffKey) govStore.Delete(govtypes.DeadlineBoundaryBlockTimeKey) - latestCtx := baseCtx.WithIsTracing(false).WithBlockHeight(activationHeight + 1).WithBlockTime(nextBlockTime) - testApp.UpgradeKeeper.SetDone(latestCtx.WithBlockHeight(activationHeight), "v6.7") - primeReceiptStore(t, testApp.EvmKeeper.ReceiptStore(), activationHeight+1) + latestCtx := baseCtx.WithIsTracing(false).WithBlockHeight(v68UpgradeHeight + 1).WithBlockTime(nextBlockTime) + testApp.UpgradeKeeper.SetDone(latestCtx.WithBlockHeight(v67UpgradeHeight), "v6.7") + testApp.UpgradeKeeper.SetDone(latestCtx.WithBlockHeight(v68UpgradeHeight), "v6.8") + primeReceiptStore(t, testApp.EvmKeeper.ReceiptStore(), v68UpgradeHeight+1) parentCtx := baseCtx ctxProvider := func(height int64) sdk.Context { if height == evmrpc.LatestCtxHeight { @@ -1039,10 +1044,13 @@ func TestStateAtBlockReplaysIncrementalTallyActivationAndGapBoundary(t *testing. return stateDB.(*state.DBImpl) } - activationState := stateAtBlock(activationHeight, activationTime) + v67State := stateAtBlock(v67UpgradeHeight, v67UpgradeTime) + require.False(t, testApp.GovKeeper.IncrementalTallyEnabled(v67State.Ctx())) + + activationState := stateAtBlock(v68UpgradeHeight, v68UpgradeTime) activationCtx := activationState.Ctx() require.True(t, testApp.GovKeeper.IncrementalTallyEnabled(activationCtx)) - require.Equal(t, sdk.FormatTimeBytes(activationTime), activationCtx.KVStore(testApp.GetKey(govtypes.StoreKey)).Get(govtypes.DeadlineBoundaryBlockTimeKey)) + require.Equal(t, sdk.FormatTimeBytes(v68UpgradeTime), activationCtx.KVStore(testApp.GetKey(govtypes.StoreKey)).Get(govtypes.DeadlineBoundaryBlockTimeKey)) cutoff, found := testApp.GovKeeper.GetVoteDelegationBackfillCutoff(activationCtx) require.True(t, found) require.Equal(t, uint64(1), cutoff) @@ -1051,13 +1059,13 @@ func TestStateAtBlockReplaysIncrementalTallyActivationAndGapBoundary(t *testing. require.NoError(t, err) testApp.GovKeeper.RemoveFromInactiveProposalQueue(activationCtx, proposal.ProposalId, proposal.DepositEndTime) proposal.Status = govtypes.StatusVotingPeriod - proposal.VotingStartTime = activationTime - proposal.VotingEndTime = activationTime.Add(5 * time.Second) + proposal.VotingStartTime = v68UpgradeTime + proposal.VotingEndTime = v68UpgradeTime.Add(5 * time.Second) testApp.GovKeeper.SetProposal(activationCtx, proposal) testApp.GovKeeper.InsertActiveProposalQueue(activationCtx, proposal.ProposalId, proposal.VotingEndTime) parentCtx = activationCtx - nextState := stateAtBlock(activationHeight+1, nextBlockTime) + nextState := stateAtBlock(v68UpgradeHeight+1, nextBlockTime) nextStore := nextState.Ctx().KVStore(testApp.GetKey(govtypes.StoreKey)) require.True(t, nextStore.Has(govtypes.GapTallyBoundaryKey(nextBlockTime))) } diff --git a/evmrpc/tests/regression_test.go b/evmrpc/tests/regression_test.go index edfea92439..3a0d9b23c4 100644 --- a/evmrpc/tests/regression_test.go +++ b/evmrpc/tests/regression_test.go @@ -462,7 +462,7 @@ func setLegacySstoreIfNeeded(ctx sdk.Context, a *app.App, version string) sdk.Co } func removeFutureGovernanceActivation(ctx sdk.Context, a *app.App, version string) { - if semver.Compare(version, "v6.7") < 0 { + if semver.Compare(version, "v6.8") < 0 { ctx.KVStore(a.GetKey(govtypes.StoreKey)).Delete(govtypes.IncrementalTallyEnabledKey) } }