From 7c3f7c784fedf07c3df6555b06b692a4bdfa6574 Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Tue, 25 Aug 2026 13:12:42 +0800 Subject: [PATCH 1/5] 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 d67cd15530cca39fa1de10425e0e012ea04afbcf Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Wed, 26 Aug 2026 15:35:57 +0800 Subject: [PATCH 2/5] 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 10412a555b91eac9555385ce7b2cf9df40894512 Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Wed, 26 Aug 2026 22:06:41 +0800 Subject: [PATCH 3/5] 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 7d860b97f78b19a6615c6d5df2bc3a5e097bee6b Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Thu, 27 Aug 2026 13:06:42 +0800 Subject: [PATCH 4/5] 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 5f9318a8cd1e0e4e4675f5705c54ec6ddd32d3b5 Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Thu, 27 Aug 2026 13:09:34 +0800 Subject: [PATCH 5/5] 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.