Skip to content
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

Add a governance extension that implements super quorum #5492

Open
wants to merge 3 commits into
base: master
Choose a base branch
from

Conversation

0xmichalis
Copy link
Contributor

@0xmichalis 0xmichalis commented Feb 7, 2025

PR Checklist

  • Tests
  • Documentation
  • Changeset entry (run npx changeset add)

Copy link

changeset-bot bot commented Feb 7, 2025

🦋 Changeset detected

Latest commit: e13dc10

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
openzeppelin-solidity Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@0xmichalis 0xmichalis force-pushed the governance-super-quorum branch 7 times, most recently from 9073169 to 5875b15 Compare February 8, 2025 16:54
@0xmichalis 0xmichalis force-pushed the governance-super-quorum branch from 5875b15 to f52dcd6 Compare February 8, 2025 16:56
@0xmichalis 0xmichalis marked this pull request as ready for review February 8, 2025 17:07
* - A counting module must implement {quorum}, {_quorumReached}, {_voteSucceeded} and {_countVote}
* - A voting module must implement {_getVotes}
* - A counting module must implement {proposalVotes}, {_quorumReached}, {_voteSucceeded} and {_countVote}
* - A voting module must implement {_getVotes} and {quorum}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

{quorum} should not be described here IMO. Its like votingDelay or votingPeriod. The IGovernor documentation marks it as @notice module:user-config

Suggested change
* - A voting module must implement {_getVotes} and {quorum}
* - A voting module must implement {_getVotes}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated. I've added the rest of the virtual function in the third bullet point but I was also considering to remove that bullet point altogether as it can easily go out of sync.

Comment on lines 840 to 846
/**
* @inheritdoc IGovernor
*/
function proposalVotes(
uint256 proposalId
) public view virtual returns (uint256 againstVotes, uint256 forVotes, uint256 abstainVotes);

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMO we should not add this here. Not all counting systems have will implement this function (there are "grade-based" module that don't use for/against/abstain).

IMO its ok for the super quorum module to not work with them, and to only support counting module that expose that function

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Created a new IGovernorCounting interface and moved this function in it.

Comment on lines 33 to 47
bool superQuorumReached = forVotes >= superQuorum(proposalSnapshot(proposalId));
if (!superQuorumReached) {
return currentState;
}

if (!_voteSucceeded(proposalId)) {
return ProposalState.Defeated;
} else if (proposalEta(proposalId) == 0) {
return ProposalState.Succeeded;
} else {
return ProposalState.Queued;
}
Copy link
Collaborator

@Amxx Amxx Feb 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now that I see that, I see what you mean by "we need to consider proposalEta".


The current implementation comes with nasty side effect to the current implementation the superQuorum that should be document!

Lets say:

  • Quorum: 10
  • SuperQuorum: 30
  • current votes as: 20 for, 35 against

Two person whant to vote for, one with 10 votes, one with 20 votes:

  • case where the 10 votes is included first: votes are now 30 vs 35 -> vote is closed by super quorum AND is a failure
  • case where the 20 votes is included first: votes are now 40 vs 35 -> vote is closed by super quorum AND is a success.

That sensitivity to ordering is not great. But worst than that, it create a situation where voting "For" would cause a proposal to fail (and no further votes can compensate that). That makes it worst than the issue where an against vote would cause quorum to be reached and a vote to pass (in that case, other against vote could still be casted)

I'd argue if status is ACTIVE and super quorum is reached but vote is unsuccessful, then we should not return ProposalState.Defeated but rather ProposalState.Active (votes can continue until deadline)

Maybe there is a similar scenario where the voters vote against that we want to study.


Suggested change
bool superQuorumReached = forVotes >= superQuorum(proposalSnapshot(proposalId));
if (!superQuorumReached) {
return currentState;
}
if (!_voteSucceeded(proposalId)) {
return ProposalState.Defeated;
} else if (proposalEta(proposalId) == 0) {
return ProposalState.Succeeded;
} else {
return ProposalState.Queued;
}
if (forVotes < superQuorum(proposalSnapshot(proposalId) || ! _voteSucceeded(proposalId))) {
return ProposalState.Active;
} else if (proposalEta(proposalId) == 0) {
return ProposalState.Succeeded;
} else {
return ProposalState.Queued;
}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, addressed in the latest commit. I've left a comment in natspec documenting that to avoid prematurely successful proposals, users should make sure to set a high super quorum, happy to discuss alternatives.

test/helpers/roles.js Outdated Show resolved Hide resolved
@0xmichalis 0xmichalis force-pushed the governance-super-quorum branch from f52dcd6 to 4abc00b Compare February 10, 2025 11:21
/**
* @dev Run additional optional quorum numerator validation in proposals.
*/
function _validateQuorumNumerator(uint256 newQuorumNumerator) internal view virtual {}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you could do without this new function, an instead "just" do

function _updateQuorumNumerator(uint256 newQuorumNumerator) internal virtual override {
    if (newQuorumNumerator >= superQuorumNumerator()) {
        revert GovernorInvalidQuorumTooLarge(newQuorumNumerator, superQuorumNumerator_);
    }
    super._updateQuorumNumerator(newQuorumNumerator);
}

in GovernorVotesSuperQuorumFraction.sol

Copy link
Contributor Author

@0xmichalis 0xmichalis Feb 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doesn't work because on GovernorVotesSuperQuorumFraction deploy, the GovernorVotesQuorumFraction constructor is executed first and will fail because it will try to set a quorum higher than the super quorum that has not been initialized yet. I guess if you want to get away with the new virtual function, an alternative would be to update the GovernorVotesQuorumFraction constructor to not use the internal _updateQuorumNumerator function but instead inline its body in the constructor.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Super interresting

I'm only now noticing that you add the _validateQuorumNumerator call to the public function, and not to the internal one. So technically, you could still get to an inconsistent state by caling the internal _updateQuorumNumerator directly.

If we really feel this having a superQuorum lower than the quorum, we would want to avoid that.

Maybe using a different approach:

  • treat superQuorumNumerator = 0 as a special case (~infinity) ?
  • in superQuorumNumerator, do a max with the quorumNumerator value ?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

treat superQuorumNumerator = 0 as a special case

Do you mean that we should only treat this as a special case for the purposes of bootstrapping and not from the POV of an end user? I am not sure what it would mean for them anyway. Iow, something like the following:

function _updateQuorumNumerator(uint256 newQuorumNumerator) internal virtual override {
    uint256 superQuorumNumerator_ = superQuorumNumerator();
    if (superQuorumNumerator_ != 0 && newQuorumNumerator >= superQuorumNumerator_) {
        revert GovernorInvalidQuorumTooLarge(newQuorumNumerator, superQuorumNumerator_);
    }
    super._updateQuorumNumerator(newQuorumNumerator);
}

If we do that, then we don't need to use max in superQuorumNumerator() at all, do we? _updateSuperQuorumNumerator would then ensure that the super quorum is set higher than the quorum and can never be set again to zero.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The two bullet points are two quick ideas I had (mulutally exclusive?)
I'm not sure what the right approach is. It might be somethiong completelly different.

Only things I'm sure of is that is we want to ensure some invariant (superQuorumNumerator() >= quorumNumerator()) we want to make sure that this invariant cannot be broken by calling the internal function.

Ideally I'd like the invariant to always hold, but I understand the bootstrapping is painfull because of constructor ordering.

If we ensure that by the end of the constructor, bootstrapping is done, then the above code might give the right properties.

Copy link
Contributor Author

@0xmichalis 0xmichalis Feb 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see. max on its own does not solve the bootstrapping issue because superQuorumNumerator would return max(0,0) on deploy so newQuorumNumerator >= superQuorumNumerator -> newQuorumNumerator >= 0 would be true, thus revert.

I have simplified by dropping _validateQuorumNumerator in favor of the zero check in e13dc10.

In a previous commit, I had introduced an invariant test that ensures superQuorum is always greater than quorum, though the only functions I am currently fuzzing are the respective setters. Allowing all contract functions slows the test down significantly but we could enable all and reduce fuzz runs to something lower for this specific test as a compromise.

I've also considered whether in general the "super quorum is greater than quorum" invariant is something valuable and honestly I don't feel very strong about it. On the one hand, the lack of it doesn't change anything fundamental in votes and anyway a low super quorum is not recommended in any case. On the other hand, keeping it is good for the extra validation it provides as I doubt anyone would want to set super quorum lower than quorum. Overall I lean slightly towards keeping it but could be convinced to drop it.

@0xmichalis 0xmichalis force-pushed the governance-super-quorum branch from 9bd755f to e13dc10 Compare February 11, 2025 15:44
@@ -78,7 +79,7 @@ abstract contract GovernorCountingOverridable is GovernorVotes {
*/
function proposalVotes(
uint256 proposalId
) public view virtual returns (uint256 againstVotes, uint256 forVotes, uint256 abstainVotes) {
) public view virtual override returns (uint256 againstVotes, uint256 forVotes, uint256 abstainVotes) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This one will keep compiling since overriding interface functions is not required since Solidity 0.8.14.

Suggested change
) public view virtual override returns (uint256 againstVotes, uint256 forVotes, uint256 abstainVotes) {
) public view virtual returns (uint256 againstVotes, uint256 forVotes, uint256 abstainVotes) {

Comment on lines +5 to +6
- `GovernorSuperQuorum`: Add a governance extension to support a super quorum. Proposals that meet the super quorum can be executed earlier than the proposal deadline.
- `GovernorVotesSuperQuorumFraction`: Add a governance extension to enable super quorum with fractional voting.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's split this one into two changesets and remove the - at the beginning, each changeset gets formatted once we release a new version

@@ -47,7 +48,7 @@ abstract contract GovernorCountingSimple is Governor {
*/
function proposalVotes(
uint256 proposalId
) public view virtual returns (uint256 againstVotes, uint256 forVotes, uint256 abstainVotes) {
) public view virtual override returns (uint256 againstVotes, uint256 forVotes, uint256 abstainVotes) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here

Suggested change
) public view virtual override returns (uint256 againstVotes, uint256 forVotes, uint256 abstainVotes) {
) public view virtual returns (uint256 againstVotes, uint256 forVotes, uint256 abstainVotes) {

ProposalState currentState = super.state(proposalId);
if (currentState != ProposalState.Active) return currentState;

(, uint256 forVotes, ) = IGovernorCounting(address(this)).proposalVotes(proposalId);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I understand this function is external in the interface, but the intuition of calling this with proposalVotes ignores that proposalVotes is public in every Governor counting module.

Given the compiler doesn't give an error when you mark as public a function that is external in the interface, I think that should be the default since it's the least disruptive action. Otherwise I'm worried that it replaces the execution environment (i.e. msg.sender and others), which could be relevant.

I'd use the IGovernorCounting interface and:

Suggested change
(, uint256 forVotes, ) = IGovernorCounting(address(this)).proposalVotes(proposalId);
(, uint256 forVotes, ) = proposalVotes(proposalId);

*
* NOTE: The `timepoint` parameter corresponds to the snapshot used for counting vote. This allows to scale the
* quorum depending on values such as the totalSupply of a token at this timepoint (see {ERC20Votes}).
*/
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Following my previous comment, I think this is clearer in regards to what the developer must care and what the consequences are

Suggested change
*/
*
* NOTE: Make sure the value specified for the super quorum is greater than {quorum}, otherwise, it
* may be possible to pass a proposal with less votes than the default quorum.
*/

* earlier than the proposal deadline. Counting modules that want to use this extension must implement
* {IGovernorCounting}.
*
* NOTE: It's up to developers to implement `superQuorum` and validate it against `quorum`.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd move this note to the superQuorum function and make it a bit clear about what to expect. Right now the note does not enable the reader to take a decision.

if (currentState != ProposalState.Active) return currentState;

(, uint256 forVotes, ) = IGovernorCounting(address(this)).proposalVotes(proposalId);
if (forVotes < superQuorum(proposalSnapshot(proposalId)) || !_voteSucceeded(proposalId)) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't be?

Suggested change
if (forVotes < superQuorum(proposalSnapshot(proposalId)) || !_voteSucceeded(proposalId)) {
if (forVotes < superQuorum(proposalSnapshot(proposalId)) || _voteSucceeded(proposalId)) {

*/
function state(uint256 proposalId) public view virtual override returns (ProposalState) {
ProposalState currentState = super.state(proposalId);
if (currentState != ProposalState.Active) return currentState;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel this line is forcing to replicate the logic of calculating Active, Succeeded and Queued down in the function. I would flip the logic like this:

Suggested change
if (currentState != ProposalState.Active) return currentState;
if (currentState == ProposalState.Active) {
// ... rest of the logic
// and some form of conditional return
}
return currentState

This way, the logic from Governor is completely reused.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants