Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading