Skip to content

Commit

Permalink
feat(x/gov): add late quorum voting period extension (#12)
Browse files Browse the repository at this point in the history
  • Loading branch information
tbruyelle authored Sep 19, 2024
1 parent 1d342cc commit 1862d11
Show file tree
Hide file tree
Showing 20 changed files with 1,612 additions and 178 deletions.
27 changes: 27 additions & 0 deletions proto/atomone/gov/v1/gov.proto
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,21 @@ message Vote {
string metadata = 5;
}

// QuorumCheckQueueEntry defines a quorum check queue entry.
message QuorumCheckQueueEntry {
// quorum_timeout_time is the time after which quorum checks start happening
// and voting period is extended if proposal reaches quorum.
google.protobuf.Timestamp quorum_timeout_time = 1 [(gogoproto.stdtime) = true];

// quorum_check_count is the number of times quorum will be checked.
// This is a snapshot of the parameter value with the same name when the
// proposal is initially added to the queue.
uint64 quorum_check_count = 2;

// quorum_checks_done is the number of quorum checks that have been done.
uint64 quorum_checks_done = 3;
}

// DepositParams defines the params for deposits on governance proposals.
message DepositParams {
// Minimum deposit for a proposal to enter voting period.
Expand Down Expand Up @@ -245,4 +260,16 @@ message Params {

// Minimum proportion of Yes votes for a Law proposal to pass. Default value: 0.9.
string law_threshold = 19 [(cosmos_proto.scalar) = "cosmos.Dec"];

// Duration of time after a proposal enters the voting period, during which quorum
// must be achieved to not incur in a voting period extension.
google.protobuf.Duration quorum_timeout = 20 [(gogoproto.stdduration) = true];

// Duration that expresses the maximum amount of time by which a proposal voting period
// can be extended.
google.protobuf.Duration max_voting_period_extension = 21 [(gogoproto.stdduration) = true];

// Number of times a proposal should be checked for quorum after the quorum timeout
// has elapsed. Used to compute the amount of time in between quorum checks.
uint64 quorum_check_count = 22;
}
1 change: 1 addition & 0 deletions tests/e2e/genesis.go
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ func modifyGenesis(path, moniker, amountStr string, addrAll []sdk.AccAddress, de
amendmentsQuorum.String(), amendmentsThreshold.String(), lawQuorum.String(), lawThreshold.String(),
sdk.ZeroDec().String(),
false, false, govv1.DefaultMinDepositRatio.String(),
govv1.DefaultQuorumTimeout, govv1.DefaultMaxVotingPeriodExtension, govv1.DefaultQuorumCheckCount,
),
)
govGenStateBz, err := cdc.MarshalJSON(govGenState)
Expand Down
73 changes: 73 additions & 0 deletions x/gov/abci.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,79 @@ func EndBlocker(ctx sdk.Context, keeper *keeper.Keeper) {
return false
})

// fetch proposals that are due to be checked for quorum
keeper.IterateQuorumCheckQueue(ctx, ctx.BlockTime(),
func(proposal v1.Proposal, endTime time.Time, quorumCheckEntry v1.QuorumCheckQueueEntry) bool {
params := keeper.GetParams(ctx)
// remove from queue
keeper.RemoveFromQuorumCheckQueue(ctx, proposal.Id, endTime)
// check if proposal passed quorum
quorum, err := keeper.HasReachedQuorum(ctx, proposal)
if err != nil {
return false
}
logMsg := "proposal did not pass quorum after timeout, but was removed from quorum check queue"
tagValue := types.AttributeValueProposalQuorumNotMet

if quorum {
logMsg = "proposal passed quorum before timeout, vote period was not extended"
tagValue = types.AttributeValueProposalQuorumMet
if quorumCheckEntry.QuorumChecksDone > 0 {
// proposal passed quorum after timeout, extend voting period.
// canonically, we consider the first quorum check to be "right after" the quorum timeout has elapsed,
// so if quorum is reached at the first check, we don't extend the voting period.
endTime := ctx.BlockTime().Add(*params.MaxVotingPeriodExtension)
logMsg = fmt.Sprintf("proposal passed quorum after timeout, but vote end %s is already after %s", proposal.VotingEndTime, endTime)
if endTime.After(*proposal.VotingEndTime) {
logMsg = fmt.Sprintf("proposal passed quorum after timeout, vote end was extended from %s to %s", proposal.VotingEndTime, endTime)
// Update ActiveProposalsQueue with new VotingEndTime
keeper.RemoveFromActiveProposalQueue(ctx, proposal.Id, *proposal.VotingEndTime)
proposal.VotingEndTime = &endTime
keeper.InsertActiveProposalQueue(ctx, proposal.Id, *proposal.VotingEndTime)
keeper.SetProposal(ctx, proposal)
}
}
} else if quorumCheckEntry.QuorumChecksDone < quorumCheckEntry.QuorumCheckCount && proposal.VotingEndTime.After(ctx.BlockTime()) {
// proposal did not pass quorum and is still active, add back to queue with updated time key and counter.
// compute time interval between quorum checks
quorumCheckPeriod := proposal.VotingEndTime.Sub(*quorumCheckEntry.QuorumTimeoutTime)
t := quorumCheckPeriod / time.Duration(quorumCheckEntry.QuorumCheckCount)
// find time for next quorum check
nextQuorumCheckTime := endTime.Add(t)
if !nextQuorumCheckTime.After(ctx.BlockTime()) {
// next quorum check time is in the past, so add enough time intervals to get to the next quorum check time in the future.
d := ctx.BlockTime().Sub(nextQuorumCheckTime)
n := d / t
nextQuorumCheckTime = nextQuorumCheckTime.Add(t * (n + 1))
}
if nextQuorumCheckTime.After(*proposal.VotingEndTime) {
// next quorum check time is after the voting period ends, so adjust it to be equal to the voting period end time
nextQuorumCheckTime = *proposal.VotingEndTime
}
quorumCheckEntry.QuorumChecksDone++
keeper.InsertQuorumCheckQueue(ctx, proposal.Id, nextQuorumCheckTime, quorumCheckEntry)

logMsg = fmt.Sprintf("proposal did not pass quorum after timeout, next check happening at %s", nextQuorumCheckTime)
}

logger.Info(
"proposal quorum check",
"proposal", proposal.Id,
"title", proposal.Title,
"results", logMsg,
)

ctx.EventManager().EmitEvent(
sdk.NewEvent(
types.EventTypeQuorumCheck,
sdk.NewAttribute(types.AttributeKeyProposalID, fmt.Sprintf("%d", proposal.Id)),
sdk.NewAttribute(types.AttributeKeyProposalResult, tagValue),
),
)

return false
})

// fetch active proposals whose voting periods have ended (are passed the block time)
keeper.IterateActiveProposalsQueue(ctx, ctx.BlockHeader().Time, func(proposal v1.Proposal) bool {
var tagValue, logMsg string
Expand Down
136 changes: 136 additions & 0 deletions x/gov/abci_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"testing"
"time"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

abci "github.com/cometbft/cometbft/abci/types"
Expand Down Expand Up @@ -387,6 +388,141 @@ func TestEndBlockerProposalHandlerFailed(t *testing.T) {
require.Equal(t, v1.StatusFailed, proposal.Status)
}

func TestEndBlockerQuorumCheck(t *testing.T) {
params := v1.DefaultParams()
params.QuorumCheckCount = 10 // enable quorum check
quorumTimeout := *params.VotingPeriod - time.Hour*8
params.QuorumTimeout = &quorumTimeout
oneHour := time.Hour
testcases := []struct {
name string
// the value of the MaxVotingPeriodExtension param
maxVotingPeriodExtension *time.Duration
// the duration after which the proposal reaches quorum
reachQuorumAfter time.Duration
// the expected status of the proposal after the original voting period has elapsed
expectedStatusAfterVotingPeriod v1.ProposalStatus
// the expected final voting period after the original period has elapsed
// the value would be modified if voting period is extended due to quorum being reached
// after the quorum timeout
expectedVotingPeriod time.Duration
}{
{
name: "reach quorum before timeout: voting period not extended",
maxVotingPeriodExtension: params.MaxVotingPeriodExtension,
reachQuorumAfter: quorumTimeout - time.Hour,
expectedStatusAfterVotingPeriod: v1.StatusPassed,
expectedVotingPeriod: *params.VotingPeriod,
},
{
name: "reach quorum exactly at timeout: voting period not extended",
maxVotingPeriodExtension: params.MaxVotingPeriodExtension,
reachQuorumAfter: quorumTimeout,
expectedStatusAfterVotingPeriod: v1.StatusPassed,
expectedVotingPeriod: *params.VotingPeriod,
},
{
name: "quorum never reached: voting period not extended",
maxVotingPeriodExtension: params.MaxVotingPeriodExtension,
reachQuorumAfter: 0,
expectedStatusAfterVotingPeriod: v1.StatusRejected,
expectedVotingPeriod: *params.VotingPeriod,
},
{
name: "reach quorum after timeout, voting period extended",
maxVotingPeriodExtension: params.MaxVotingPeriodExtension,
reachQuorumAfter: quorumTimeout + time.Hour,
expectedStatusAfterVotingPeriod: v1.StatusVotingPeriod,
expectedVotingPeriod: *params.VotingPeriod + *params.MaxVotingPeriodExtension -
(*params.VotingPeriod - quorumTimeout - time.Hour),
},
{
name: "reach quorum exactly at voting period, voting period extended",
maxVotingPeriodExtension: params.MaxVotingPeriodExtension,
reachQuorumAfter: *params.VotingPeriod,
expectedStatusAfterVotingPeriod: v1.StatusVotingPeriod,
expectedVotingPeriod: *params.VotingPeriod + *params.MaxVotingPeriodExtension,
},
{
name: "reach quorum after timeout but voting period extension too short, voting period not extended",
maxVotingPeriodExtension: &oneHour,
reachQuorumAfter: quorumTimeout + time.Hour,
expectedStatusAfterVotingPeriod: v1.StatusPassed,
expectedVotingPeriod: *params.VotingPeriod,
},
}
for _, tc := range testcases {
t.Run(tc.name, func(t *testing.T) {
suite := createTestSuite(t)
app := suite.App
ctx := app.BaseApp.NewContext(false, tmproto.Header{})
params.MaxVotingPeriodExtension = tc.maxVotingPeriodExtension
err := suite.GovKeeper.SetParams(ctx, params)
require.NoError(t, err)
addrs := simtestutil.AddTestAddrs(suite.BankKeeper, suite.StakingKeeper, ctx, 10, valTokens)
// _, err = app.FinalizeBlock(&abci.RequestFinalizeBlock{
// Height: app.LastBlockHeight() + 1,
// Hash: app.LastCommitID().Hash,
// })
// require.NoError(t, err)
// Create a validator
valAddr := sdk.ValAddress(addrs[0])
stakingMsgSvr := stakingkeeper.NewMsgServerImpl(suite.StakingKeeper)
createValidators(t, stakingMsgSvr, ctx, []sdk.ValAddress{valAddr}, []int64{10})
staking.EndBlocker(ctx, suite.StakingKeeper)
// Create a proposal
govMsgSvr := keeper.NewMsgServerImpl(suite.GovKeeper)
deposit := v1.DefaultMinDepositTokens.ToLegacyDec().Mul(v1.DefaultMinDepositRatio)
newProposalMsg, err := v1.NewMsgSubmitProposal(
[]sdk.Msg{mkTestLegacyContent(t)},
sdk.Coins{sdk.NewCoin(sdk.DefaultBondDenom, deposit.RoundInt())},
addrs[0].String(), "", "Proposal", "description of proposal",
)
require.NoError(t, err)
res, err := govMsgSvr.SubmitProposal(ctx, newProposalMsg)
require.NoError(t, err)
require.NotNil(t, res)
// Activate proposal
newDepositMsg := v1.NewMsgDeposit(addrs[1], res.ProposalId, params.MinDeposit)
res1, err := govMsgSvr.Deposit(ctx, newDepositMsg)
require.NoError(t, err)
require.NotNil(t, res1)
prop, ok := suite.GovKeeper.GetProposal(ctx, res.ProposalId)
require.True(t, ok, "prop not found")

// Call EndBlock until the initial voting period has elapsed
// Tick is one hour
var (
startTime = ctx.BlockTime()
tick = time.Hour
)
for ctx.BlockTime().Sub(startTime) < *params.VotingPeriod {
// Forward in time
newTime := ctx.BlockTime().Add(tick)
ctx = ctx.WithBlockTime(newTime)
if tc.reachQuorumAfter != 0 && newTime.Sub(startTime) >= tc.reachQuorumAfter {
// Set quorum as reached
err := suite.GovKeeper.AddVote(ctx, prop.Id, addrs[0], v1.NewNonSplitVoteOption(v1.OptionYes), "")
require.NoError(t, err)
// Don't go there again
tc.reachQuorumAfter = 0
}

gov.EndBlocker(ctx, suite.GovKeeper)

}

// Assertions
prop, ok = suite.GovKeeper.GetProposal(ctx, prop.Id) // Get fresh prop
if assert.True(t, ok, "prop not found") {
assert.Equal(t, tc.expectedStatusAfterVotingPeriod.String(), prop.Status.String())
assert.Equal(t, tc.expectedVotingPeriod, prop.VotingEndTime.Sub(*prop.VotingStartTime))
assert.False(t, suite.GovKeeper.QuorumCheckQueueIterator(ctx, *prop.VotingStartTime).Valid(), "quorum check queue invalid")
}
})
}
}

func createValidators(t *testing.T, stakingMsgSvr stakingtypes.MsgServer, ctx sdk.Context, addrs []sdk.ValAddress, powerAmt []int64) {
require.True(t, len(addrs) <= len(pubkeys), "Not enough pubkeys specified at top of file.")

Expand Down
48 changes: 48 additions & 0 deletions x/gov/client/testutil/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package testutil

import (
"fmt"
"time"

"github.com/cosmos/cosmos-sdk/client"
"github.com/cosmos/cosmos-sdk/client/flags"
Expand All @@ -10,6 +11,9 @@ import (
sdk "github.com/cosmos/cosmos-sdk/types"

govcli "github.com/atomone-hub/atomone/x/gov/client/cli"
"github.com/atomone-hub/atomone/x/gov/keeper"
"github.com/atomone-hub/atomone/x/gov/types"
v1 "github.com/atomone-hub/atomone/x/gov/types/v1"
)

var commonArgs = []string{
Expand Down Expand Up @@ -59,3 +63,47 @@ func MsgDeposit(clientCtx client.Context, from, id, deposit string, extraArgs ..

return clitestutil.ExecTestCLICmd(clientCtx, govcli.NewCmdDeposit(), args)
}

func HasActiveProposal(ctx sdk.Context, k *keeper.Keeper, id uint64, t time.Time) bool {
it := k.ActiveProposalQueueIterator(ctx, t)
defer it.Close()
for ; it.Valid(); it.Next() {
proposalID, _ := types.SplitActiveProposalQueueKey(it.Key())
if proposalID == id {
return true
}
}
return false
}

func HasInactiveProposal(ctx sdk.Context, k *keeper.Keeper, id uint64, t time.Time) bool {
it := k.InactiveProposalQueueIterator(ctx, t)
defer it.Close()
for ; it.Valid(); it.Next() {
proposalID, _ := types.SplitInactiveProposalQueueKey(it.Key())
if proposalID == id {
return true
}
}
return false
}

func HasQuorumCheck(ctx sdk.Context, k *keeper.Keeper, id uint64, t time.Time) bool {
_, ok := GetQuorumCheckQueueEntry(ctx, k, id, t)
return ok
}

func GetQuorumCheckQueueEntry(ctx sdk.Context, k *keeper.Keeper, id uint64, t time.Time) (v1.QuorumCheckQueueEntry, bool) {
it := k.QuorumCheckQueueIterator(ctx, t)
defer it.Close()
for ; it.Valid(); it.Next() {
proposalID, _ := types.SplitQuorumQueueKey(it.Key())
if proposalID == id {
bz := it.Value()
var q v1.QuorumCheckQueueEntry
err := q.Unmarshal(bz)
return q, err == nil
}
}
return v1.QuorumCheckQueueEntry{}, false
}
23 changes: 23 additions & 0 deletions x/gov/genesis.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,29 @@ func InitGenesis(ctx sdk.Context, ak types.AccountKeeper, bk types.BankKeeper, k
k.InsertActiveProposalQueue(ctx, proposal.Id, *proposal.VotingEndTime)
}
k.SetProposal(ctx, *proposal)

if data.Params.QuorumCheckCount > 0 && proposal.Status == v1.StatusVotingPeriod {
quorumTimeoutTime := proposal.VotingStartTime.Add(*data.Params.QuorumTimeout)
quorumCheckEntry := v1.NewQuorumCheckQueueEntry(quorumTimeoutTime, data.Params.QuorumCheckCount)
quorum := false
if ctx.BlockTime().After(quorumTimeoutTime) {
var err error
quorum, err = k.HasReachedQuorum(ctx, *proposal)
if err != nil {
panic(err)
}
if !quorum {
// since we don't export the state of the quorum check queue, we can't know how many checks were actually
// done. However, in order to trigger a vote time extension, it is enough to have QuorumChecksDone > 0 to
// trigger a vote time extension, so we set it to 1
quorumCheckEntry.QuorumChecksDone = 1
}
}
if !quorum {
k.InsertQuorumCheckQueue(ctx, proposal.Id, quorumTimeoutTime, quorumCheckEntry)
}
}

}

// if account has zero balance it probably means it's not set, so we set it
Expand Down
Loading

0 comments on commit 1862d11

Please sign in to comment.