Skip to content

feat(x/gov): dynamic quorum #135

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 24 commits into from
Jun 16, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
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
29 changes: 29 additions & 0 deletions app/upgrades/v3/upgrades.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
package v3

import (
"fmt"

sdk "github.com/cosmos/cosmos-sdk/types"
"github.com/cosmos/cosmos-sdk/types/module"
upgradetypes "github.com/cosmos/cosmos-sdk/x/upgrade/types"

"github.com/atomone-hub/atomone/app/keepers"
govkeeper "github.com/atomone-hub/atomone/x/gov/keeper"
v1 "github.com/atomone-hub/atomone/x/gov/types/v1"
)

// CreateUpgradeHandler returns a upgrade handler for AtomOne v3
Expand All @@ -22,7 +26,32 @@ func CreateUpgradeHandler(
if err != nil {
return vm, err
}
if err := initGovDynamicQuorum(ctx, keepers.GovKeeper); err != nil {
return vm, err
}
ctx.Logger().Info("Upgrade complete")
return vm, nil
}
}

// initGovDynamicQuorum initialized the gov module for the dynamic quorum
// features, which means setting the new parameters min/max quorums and the
// participation ema.
func initGovDynamicQuorum(ctx sdk.Context, govKeeper *govkeeper.Keeper) error {
ctx.Logger().Info("Initializing gov module for dynamic quorum...")
params := govKeeper.GetParams(ctx)
defaultParams := v1.DefaultParams()
params.QuorumRange = defaultParams.QuorumRange
params.ConstitutionAmendmentQuorumRange = defaultParams.ConstitutionAmendmentQuorumRange
params.LawQuorumRange = defaultParams.LawQuorumRange
if err := govKeeper.SetParams(ctx, params); err != nil {
return fmt.Errorf("set gov params: %w", err)
}
// NOTE(tb): Disregarding whales' votes, the current participation is less than 12%
initParticipationEma := sdk.NewDecWithPrec(12, 2)
govKeeper.SetParticipationEMA(ctx, initParticipationEma)
govKeeper.SetConstitutionAmendmentParticipationEMA(ctx, initParticipationEma)
govKeeper.SetLawParticipationEMA(ctx, initParticipationEma)
ctx.Logger().Info("Gov module initialized for dynamic quorum")
return nil
}
4 changes: 4 additions & 0 deletions docs/architecture/adr-004-nakamoto-bonus.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# ADR 004: Nakamoto Bonus

## Changelog

- 19 May 2025: Initial version

## Status

DRAFT
Expand Down
121 changes: 121 additions & 0 deletions docs/architecture/adr-005-dynamic-quorum.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
# ADR 5: Dynamic Quorum

## Changelog

- 08 June 2025: Initial version

## Status

Implemented (https://github.com/atomone-hub/atomone/pull/135)

## Abstract

This ADR proposes a mechanism to dynamically adjust the `Quorum` parameter -
i.e. - in the `x/gov` module. This parameter represents the minimum
participation during a voting period required for the vote to be considered
valid.

The quorum is dynamically adjusted after each vote based on the actual
participation. The new quorum is updated using an exponential moving average
[^1] of vote participation, meaning that the current vote’s participation is
weighted more heavily then previous vote participations. The exponential moving
average allows the quorum to react quickly to changes in participation.

The proposed mechanism is comparable to the `quorum adjustement mechanism` used
in Tezos [^2].

## Context

At the time of writing, the `x/gov` module on AtomOne uses a `Quorum` parameter
set to `0.25`. Since AtomOne has removed delegation-based voting [^3] in favor
of *direct voting* for most type of proposals, lower participation with
respect to the total voting power is to be expected. The mechanism proposed in
this ADR allows to find the proper quorum threshold based on actual
participation.

Dynamic quorum works in tandem with the deposit auto-throttler
([ADR-003](https://github.com/atomone-hub/atomone/blob/main/docs/architecture/adr-003-governance-proposal-deposit-auto-throttler.md))
to ensure that governance can proceed smoothly in a *direct voting* scenario.

## Alternatives

The main alternative to dynamic quorum is to reintroduce vote delegations. One
of the main reasons in favor of direct voting is that validators already have a
very specific role, which is foundational for AtomOne, their participation in
the consensus process. For this reason, the delegation of votes to validators
determines a conflation of roles: consensus and governance. An alternative
route, that could also be complementary to the dynamic quorum, is therefore to
separate vote delegations from consensus delegation and reintroduce
delegation-based voting in this scenario.

## Decision

The `Quorum` parameter will be replaced with a variable that is adjusted
dynamically based on previous voting participations. The dynamic quorum is
adjusted after every voting period.

The participation Exponential Moving Average - `pEMA` - is updated according to
the formula:

$$
pEMA_{t+1} = (0.8)pEMA_t + (0.2)p
$$

Where:

- $pEMA_{t+1}$ is the new participation exponential moving average value.
- $pEMA_t$ is the current participation exponential moving average value.
- $p$ is the participation observed during the previous voting period.

The quorum is then computed based on `pEMA` as follows:

$$
Q = Q_{min} + (Q_{max} - Q_{min}) \times pEMA
$$

Where:

- $Q$ is the resulting quorum value.
- $Q_{min}$ is the minimum quorum value allowed.
- $Q_{max}$ is the maximum quorum value allowed.
- $pEMA$ is the updated participation exponential moving average.

### Implementation

The following parameters are added to the `x/gov` module params:

- `MinQuorum` : minimum quorum value required to pass a proposal.
- `MaxQuorum` : maximum quorum value required to pass a proposal.

### Querying the quorum value

Given that `Quorum` is no longer a fixed parameter, a new query endpoint is
also required. The endpoint will allow clients to fetch the current value of
the quorum.

## Consequences

If the participation during the voting period of a proposal is lower than
`pEMA`, and the current dynamic quorum is above `MinQuorum` , the quorum
required for the next proposal will decrease. On the other hand, if the
participation is higher then `pEMA`, and the current dynamic quorum is below
`MaxQuorum` , the quorum required for the next proposal will increase.

### Positive

- The quorum dynamically adjust to reflect voting participation.

### Negative

- Additional computation is required to compute the quorum.

### Neutral

- Increased number of governance parameters.
- Adds a new endpoint to query the `Quorum` value.

## References

- [^1]: [Exponential smoothing](https://en.wikipedia.org/wiki/Exponential_smoothing)
- [^2]: [Tezos, Quorum computation](https://opentezos.com/tezos-basics/governance-on-chain/#quorum-computation)
- [^3]: [AtomOne, Staking and Governance Separation: Introduction of Delegation-less](https://github.com/atomone-hub/genesis/blob/main/GOVERNANCE.md#3-staking-and-governance-separation-introduction-of-delegation-less)
16 changes: 16 additions & 0 deletions proto/atomone/gov/v1/genesis.proto
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ syntax = "proto3";
package atomone.gov.v1;

import "atomone/gov/v1/gov.proto";
import "cosmos_proto/cosmos.proto";

option go_package = "github.com/atomone-hub/atomone/x/gov/types/v1";

Expand Down Expand Up @@ -40,4 +41,19 @@ message GenesisState {

// last updated value for the dynamic min initial deposit
LastMinDeposit last_min_initial_deposit = 11;

// governance participation EMA
// If unset or set to 0, the quorum for the next proposal will be set to the
// params.MinQuorum value.
string participation_ema = 12 [(cosmos_proto.scalar) = "cosmos.Dec"];

// governance participation EMA for constitution amendment proposals.
// If unset or set to 0, the quorum for the next constitution amendment
// proposal will be set to the params.MinConstitutionAmendmentQuorum value.
string constitution_amendment_participation_ema = 13 [(cosmos_proto.scalar) = "cosmos.Dec"];

// governance participation EMA for law proposals.
// If unset or set to 0, the quorum for the next law proposal will be set to
// the params.LawMinQuorum value.
string law_participation_ema = 14 [(cosmos_proto.scalar) = "cosmos.Dec"];
}
29 changes: 23 additions & 6 deletions proto/atomone/gov/v1/gov.proto
Original file line number Diff line number Diff line change
Expand Up @@ -201,19 +201,19 @@ message VotingParams {
message TallyParams {
// Minimum percentage of total stake needed to vote for a result to be
// considered valid.
string quorum = 1 [ (cosmos_proto.scalar) = "cosmos.Dec" ];
string quorum = 1 [ (cosmos_proto.scalar) = "cosmos.Dec", deprecated = true];

// Minimum proportion of Yes votes for proposal to pass. Default value: 2/3.
string threshold = 2 [(cosmos_proto.scalar) = "cosmos.Dec"];

// quorum for constitution amendment proposals
string constitution_amendment_quorum = 3 [(cosmos_proto.scalar) = "cosmos.Dec"];
string constitution_amendment_quorum = 3 [(cosmos_proto.scalar) = "cosmos.Dec", deprecated = true];

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

// quorum for law proposals
string law_quorum = 5 [(cosmos_proto.scalar) = "cosmos.Dec"];
string law_quorum = 5 [(cosmos_proto.scalar) = "cosmos.Dec", deprecated = true];

// Minimum proportion of Yes votes for a Law proposal to pass. Default value: 0.9.
string law_threshold = 6 [(cosmos_proto.scalar) = "cosmos.Dec"];
Expand Down Expand Up @@ -296,7 +296,7 @@ message Params {

// Minimum percentage of total stake needed to vote for a result to be
// considered valid. Default value: 0.25.
string quorum = 4 [(cosmos_proto.scalar) = "cosmos.Dec"];
string quorum = 4 [(cosmos_proto.scalar) = "cosmos.Dec", deprecated = true];

// Minimum proportion of Yes votes for proposal to pass. Default value: 2/3.
string threshold = 5 [(cosmos_proto.scalar) = "cosmos.Dec"];
Expand All @@ -320,13 +320,13 @@ message Params {
string min_deposit_ratio = 15 [ (cosmos_proto.scalar) = "cosmos.Dec" ];

// quorum for constitution amendment proposals
string constitution_amendment_quorum = 16 [(cosmos_proto.scalar) = "cosmos.Dec"];
string constitution_amendment_quorum = 16 [(cosmos_proto.scalar) = "cosmos.Dec", deprecated = true];

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

// quorum for law proposals
string law_quorum = 18 [(cosmos_proto.scalar) = "cosmos.Dec"];
string law_quorum = 18 [(cosmos_proto.scalar) = "cosmos.Dec", deprecated = true];

// Minimum proportion of Yes votes for a Law proposal to pass. Default value: 0.9.
string law_threshold = 19 [(cosmos_proto.scalar) = "cosmos.Dec"];
Expand All @@ -349,4 +349,21 @@ message Params {

// Minimum proportion of No Votes for a proposal deposit to be burnt.
string burn_deposit_no_threshold = 25 [(cosmos_proto.scalar) = "cosmos.Dec"];

// Achievable quorum
QuorumRange quorum_range = 26 [(cosmos_proto.scalar) = "cosmos.Dec"];

// Achievable quorum for constitution amendment proposals
QuorumRange constitution_amendment_quorum_range = 27 [(cosmos_proto.scalar) = "cosmos.Dec"];

// Achievable quorum for law proposals
QuorumRange law_quorum_range = 28 [(cosmos_proto.scalar) = "cosmos.Dec"];
}

message QuorumRange {
// Maximum achievable quorum
string max = 1 [(cosmos_proto.scalar) = "cosmos.Dec"];

// Minimum achievable quorum
string min = 2 [(cosmos_proto.scalar) = "cosmos.Dec"];
}
21 changes: 21 additions & 0 deletions proto/atomone/gov/v1/query.proto
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,11 @@ service Query {
rpc MinInitialDeposit(QueryMinInitialDepositRequest) returns (QueryMinInitialDepositResponse) {
option (google.api.http).get = "/atomone/gov/v1/mininitialdeposit";
}

// Quorum queries the dynamically set quorum.
rpc Quorum(QueryQuorumRequest) returns (QueryQuorumResponse) {
option (google.api.http).get = "/atomone/gov/v1/params/quorum";
}
}

// QueryConstitutionRequest is the request type for the Query/Constitution RPC method
Expand Down Expand Up @@ -241,3 +246,19 @@ message QueryMinInitialDepositResponse {
// min_initial_deposit defines the minimum initial deposit required for a proposal to be submitted.
repeated cosmos.base.v1beta1.Coin min_initial_deposit = 1 [ (gogoproto.nullable) = false];
}

// QueryQuorumRequest is the request type for the Query/Quorum RPC method.
message QueryQuorumRequest {}

// QueryQuorumResponse is the response type for the Query/Quorum RPC method.
message QueryQuorumResponse {
// quorum defines the requested quorum.
string quorum = 1 [ (cosmos_proto.scalar) = "cosmos.Dec" ];

// constitution_amendment_quorum defines the requested quorum for
// constitution amendment proposals.
string constitution_amendment_quorum = 2 [ (cosmos_proto.scalar) = "cosmos.Dec" ];

// law_quorum defines the requested quorum for law proposals.
string law_quorum = 3 [ (cosmos_proto.scalar) = "cosmos.Dec" ];
}
3 changes: 2 additions & 1 deletion proto/atomone/gov/v1beta1/gov.proto
Original file line number Diff line number Diff line change
Expand Up @@ -264,7 +264,8 @@ message TallyParams {
bytes quorum = 1 [
(gogoproto.customtype) = "github.com/cosmos/cosmos-sdk/types.Dec",
(gogoproto.nullable) = false,
(gogoproto.jsontag) = "quorum,omitempty"
(gogoproto.jsontag) = "quorum,omitempty",
deprecated = true
];

// Minimum proportion of Yes votes for proposal to pass. Default value: 2/3.
Expand Down
23 changes: 15 additions & 8 deletions tests/e2e/genesis_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -192,23 +192,27 @@ func modifyGenesis(path, moniker, amountStr string, addrAll []sdk.AccAddress, de
// Modifying gov genesis

// Refactor to separate method
quorum, _ := sdk.NewDecFromStr("0.000000000000000001")
threshold, _ := sdk.NewDecFromStr("0.000000000000000001")
lawQuorum, _ := sdk.NewDecFromStr("0.000000000000000001")
lawThreshold, _ := sdk.NewDecFromStr("0.000000000000000001")
amendmentsQuorum, _ := sdk.NewDecFromStr("0.000000000000000001")
amendmentsThreshold, _ := sdk.NewDecFromStr("0.000000000000000001")
threshold := "0.000000000000000001"
lawThreshold := "0.000000000000000001"
amendmentsThreshold := "0.000000000000000001"
minQuorum := "0.2"
maxQuorum := "0.8"
participationEma := "0.25"
minConstitutionAmendmentQuorum := "0.2"
maxConstitutionAmendmentQuorum := "0.8"
minLawQuorum := "0.2"
maxLawQuorum := "0.8"

maxDepositPeriod := 10 * time.Minute
votingPeriod := 15 * time.Second

govGenState := govv1.NewGenesisState(1,
participationEma, participationEma, participationEma,
govv1.NewParams(
// sdk.NewCoins(sdk.NewCoin(denom, depositAmount.Amount)),
maxDepositPeriod,
votingPeriod,
quorum.String(), threshold.String(),
amendmentsQuorum.String(), amendmentsThreshold.String(), lawQuorum.String(), lawThreshold.String(),
threshold, amendmentsThreshold, lawThreshold,
// sdk.ZeroDec().String(),
false, false, govv1.DefaultMinDepositRatio.String(),
govv1.DefaultQuorumTimeout, govv1.DefaultMaxVotingPeriodExtension, govv1.DefaultQuorumCheckCount,
Expand All @@ -219,6 +223,9 @@ func modifyGenesis(path, moniker, amountStr string, addrAll []sdk.AccAddress, de
govv1.DefaultMinInitialDepositDecreaseSensitivityTargetDistance, govv1.DefaultMinInitialDepositIncreaseRatio.String(),
govv1.DefaultMinInitialDepositDecreaseRatio.String(), govv1.DefaultTargetProposalsInDepositPeriod,
govv1.DefaultBurnDepositNoThreshold.String(),
maxQuorum, minQuorum,
maxConstitutionAmendmentQuorum, minConstitutionAmendmentQuorum,
maxLawQuorum, minLawQuorum,
),
)
govGenState.Constitution = "This is a test constitution"
Expand Down
3 changes: 2 additions & 1 deletion x/gov/abci.go
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ func EndBlocker(ctx sdk.Context, keeper *keeper.Keeper) {
keeper.IterateActiveProposalsQueue(ctx, ctx.BlockHeader().Time, func(proposal v1.Proposal) bool {
var tagValue, logMsg string

passes, burnDeposits, tallyResults := keeper.Tally(ctx, proposal)
passes, burnDeposits, participation, tallyResults := keeper.Tally(ctx, proposal)

if burnDeposits {
keeper.DeleteAndBurnDeposits(ctx, proposal.Id)
Expand Down Expand Up @@ -198,6 +198,7 @@ func EndBlocker(ctx sdk.Context, keeper *keeper.Keeper) {
keeper.SetProposal(ctx, proposal)
keeper.RemoveFromActiveProposalQueue(ctx, proposal.Id, *proposal.VotingEndTime)
keeper.DecrementActiveProposalsNumber(ctx)
keeper.UpdateParticipationEMA(ctx, proposal, participation)

// when proposal become active
keeper.Hooks().AfterProposalVotingPeriodEnded(ctx, proposal.Id)
Expand Down
Loading
Loading