-
Notifications
You must be signed in to change notification settings - Fork 11.8k
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
Governor: Sequential Proposal Ids #5290
base: master
Are you sure you want to change the base?
Changes from 17 commits
a25d139
a4ee283
e34a8b9
3cf1b80
9063080
8755aa6
c52f001
d9f3477
b063a61
19dfdf8
185b6a3
cb91e76
5ff56da
f0e9fb5
becc77a
8ffad72
3e49fb9
41d49c4
f8cfe02
76d57a2
74bdb35
3206080
c174090
9786e76
df711fe
a04b47f
2de3660
da959cd
e037708
fdea2cc
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
--- | ||
'openzeppelin-solidity': minor | ||
--- | ||
|
||
`GovernorSequentialProposalId`: Adds a `Governor` extension that sequentially numbers proposal ids instead of using the hash. |
Original file line number | Diff line number | Diff line change | ||||
---|---|---|---|---|---|---|
@@ -0,0 +1,46 @@ | ||||||
// SPDX-License-Identifier: MIT | ||||||
|
||||||
pragma solidity ^0.8.20; | ||||||
|
||||||
import {Governor} from "../Governor.sol"; | ||||||
|
||||||
abstract contract GovernorSequentialProposalId is Governor { | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We're missing NatSpec here |
||||||
uint256 private _proposalCount; | ||||||
mapping(uint256 proposalHash => uint256 proposalId) private _proposalIds; | ||||||
|
||||||
function getProposalId( | ||||||
address[] memory targets, | ||||||
uint256[] memory values, | ||||||
bytes[] memory calldatas, | ||||||
bytes32 descriptionHash | ||||||
) public view virtual override returns (uint256) { | ||||||
uint256 proposalHash = hashProposal(targets, values, calldatas, descriptionHash); | ||||||
uint256 storedProposalId = _proposalIds[proposalHash]; | ||||||
if (storedProposalId == 0) revert GovernorNonexistentProposal(0); | ||||||
return storedProposalId; | ||||||
} | ||||||
|
||||||
function _propose( | ||||||
arr00 marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||
address[] memory targets, | ||||||
uint256[] memory values, | ||||||
bytes[] memory calldatas, | ||||||
string memory description, | ||||||
address proposer | ||||||
) internal virtual override returns (uint256) { | ||||||
uint256 proposalHash = hashProposal(targets, values, calldatas, keccak256(bytes(description))); | ||||||
_proposalIds[proposalHash] = ++_proposalCount; | ||||||
|
||||||
return super._propose(targets, values, calldatas, description, proposer); | ||||||
arr00 marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||
} | ||||||
|
||||||
/** | ||||||
* @dev Internal function to set the sequential proposal ID for the next proposal. This is helpful for transitioning from another governing system. | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I would add a big caveat about decreasing the proposal count. I'd consider enforcing it in a require statement too, not sure if there are significant downsides to that. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The use-case that @arr00 metionned that someone may set the count to type(uint256).max to (temporarily) disable proposing until the correct number is set. IMO that usecase (prevent any proposal from being submitted could be more cleanly done using the proposal threshold). This is the require I proposed: #5290 (comment) There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Would like to get @wildmolasses thoughts here. I'm leaning towards pushing the above approach of just using the proposal threshold. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yeah I'd keep that use case separate. But I do think it's a concern if setting the count to the max uint permanently blocks the governor from functioning... So this is why I'm not all in on enforcing that the update is an increase. A safer thing would be to allow increases of some reasonable size (like uint32), it seems arbitrary but I think it works. |
||||||
*/ | ||||||
function _setProposalCount(uint256 newProposalCount) internal virtual { | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I assume the purpose of including this function is to allow DAOs that are upgrading to set an initial proposal count in their initializers. If this is the case, I think this should be documented somewhere There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I would be in favor of changing this function's name to indicate better what to do with it. In this case, it suggests developers that it should be used within an initializer.
Suggested change
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. As of now the function is only usable when the proposal count is 0 (one time use), but if there are legitimate use-cases for using it beyond that we would open it up to allow any increase in proposal count. Keeping this phrasing enables that to be a non-breaking change. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes. Given it's a one-time use, I think "initialize" is a good name. The use case I was thinking of is the following:
I don't see any other legitimate use case to open up the function either. In this case, I think it makes sense to make it more explicit. |
||||||
_proposalCount = newProposalCount; | ||||||
} | ||||||
|
||||||
function proposalCount() public view virtual returns (uint256) { | ||||||
arr00 marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||
return _proposalCount; | ||||||
} | ||||||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,39 @@ | ||
// SPDX-License-Identifier: MIT | ||
|
||
pragma solidity ^0.8.20; | ||
|
||
import {Governor} from "../../governance/Governor.sol"; | ||
import {GovernorSettings} from "../../governance/extensions/GovernorSettings.sol"; | ||
import {GovernorCountingSimple} from "../../governance/extensions/GovernorCountingSimple.sol"; | ||
import {GovernorVotesQuorumFraction} from "../../governance/extensions/GovernorVotesQuorumFraction.sol"; | ||
import {GovernorSequentialProposalId} from "../../governance/extensions/GovernorSequentialProposalId.sol"; | ||
|
||
abstract contract GovernorSequentialProposalIdMock is | ||
GovernorSettings, | ||
GovernorVotesQuorumFraction, | ||
GovernorCountingSimple, | ||
GovernorSequentialProposalId | ||
{ | ||
function proposalThreshold() public view override(Governor, GovernorSettings) returns (uint256) { | ||
return super.proposalThreshold(); | ||
} | ||
|
||
function getProposalId( | ||
address[] memory targets, | ||
uint256[] memory values, | ||
bytes[] memory calldatas, | ||
bytes32 descriptionHash | ||
) public view virtual override(Governor, GovernorSequentialProposalId) returns (uint256) { | ||
return super.getProposalId(targets, values, calldatas, descriptionHash); | ||
} | ||
|
||
function _propose( | ||
address[] memory targets, | ||
uint256[] memory values, | ||
bytes[] memory calldatas, | ||
string memory description, | ||
address proposer | ||
) internal virtual override(Governor, GovernorSequentialProposalId) returns (uint256 proposalId) { | ||
return super._propose(targets, values, calldatas, description, proposer); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,186 @@ | ||
const { ethers } = require('hardhat'); | ||
const { expect } = require('chai'); | ||
const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); | ||
const { anyValue } = require('@nomicfoundation/hardhat-chai-matchers/withArgs'); | ||
|
||
const { GovernorHelper } = require('../../helpers/governance'); | ||
const { VoteType } = require('../../helpers/enums'); | ||
const iterate = require('../../helpers/iterate'); | ||
|
||
const TOKENS = [ | ||
{ Token: '$ERC20Votes', mode: 'blocknumber' }, | ||
{ Token: '$ERC20VotesTimestampMock', mode: 'timestamp' }, | ||
]; | ||
|
||
const name = 'OZ-Governor'; | ||
const version = '1'; | ||
const tokenName = 'MockToken'; | ||
const tokenSymbol = 'MTKN'; | ||
const tokenSupply = ethers.parseEther('100'); | ||
const votingDelay = 4n; | ||
const votingPeriod = 16n; | ||
const value = ethers.parseEther('1'); | ||
|
||
async function deployToken(contractName) { | ||
try { | ||
return await ethers.deployContract(contractName, [tokenName, tokenSymbol, tokenName, version]); | ||
} catch (error) { | ||
if (error.message == 'incorrect number of arguments to constructor') { | ||
// ERC20VotesLegacyMock has a different construction that uses version='1' by default. | ||
return ethers.deployContract(contractName, [tokenName, tokenSymbol, tokenName]); | ||
} | ||
throw error; | ||
} | ||
} | ||
|
||
describe('GovernorSequentialProposalId', function () { | ||
for (const { Token, mode } of TOKENS) { | ||
const fixture = async () => { | ||
const [owner, proposer, voter1, voter2, voter3, voter4, userEOA] = await ethers.getSigners(); | ||
const receiver = await ethers.deployContract('CallReceiverMock'); | ||
|
||
const token = await deployToken(Token, [tokenName, tokenSymbol, version]); | ||
const mock = await ethers.deployContract('$GovernorSequentialProposalIdMock', [ | ||
name, // name | ||
votingDelay, // initialVotingDelay | ||
votingPeriod, // initialVotingPeriod | ||
0n, // initialProposalThreshold | ||
token, // tokenAddress | ||
10n, // quorumNumeratorValue | ||
]); | ||
|
||
await owner.sendTransaction({ to: mock, value }); | ||
await token.$_mint(owner, tokenSupply); | ||
|
||
const helper = new GovernorHelper(mock, mode); | ||
await helper.connect(owner).delegate({ token: token, to: voter1, value: ethers.parseEther('10') }); | ||
await helper.connect(owner).delegate({ token: token, to: voter2, value: ethers.parseEther('7') }); | ||
await helper.connect(owner).delegate({ token: token, to: voter3, value: ethers.parseEther('5') }); | ||
await helper.connect(owner).delegate({ token: token, to: voter4, value: ethers.parseEther('2') }); | ||
|
||
return { | ||
owner, | ||
proposer, | ||
voter1, | ||
voter2, | ||
voter3, | ||
voter4, | ||
userEOA, | ||
receiver, | ||
token, | ||
mock, | ||
helper, | ||
}; | ||
}; | ||
|
||
describe(`using ${Token}`, function () { | ||
beforeEach(async function () { | ||
Object.assign(this, await loadFixture(fixture)); | ||
|
||
this.proposal = this.helper.setProposal( | ||
[ | ||
{ | ||
target: this.receiver.target, | ||
data: this.receiver.interface.encodeFunctionData('mockFunction'), | ||
value, | ||
}, | ||
], | ||
'<proposal description>', | ||
); | ||
}); | ||
|
||
it('sequential proposal ids', async function () { | ||
for (const i of iterate.range(1, 10)) { | ||
this.proposal.description = `<proposal description #${i}>`; | ||
|
||
expect(this.mock.hashProposal(...this.proposal.shortProposal)).to.eventually.equal(this.proposal.hash); | ||
await expect(this.mock.getProposalId(...this.proposal.shortProposal)).revertedWithCustomError( | ||
this.mock, | ||
'GovernorNonexistentProposal', | ||
); | ||
expect(this.mock.proposalCount()).to.eventually.equal(i - 1); | ||
|
||
await expect(this.helper.connect(this.proposer).propose()) | ||
.to.emit(this.mock, 'ProposalCreated') | ||
.withArgs( | ||
i, | ||
this.proposer, | ||
this.proposal.targets, | ||
this.proposal.values, | ||
this.proposal.signatures, | ||
this.proposal.data, | ||
anyValue, | ||
anyValue, | ||
this.proposal.description, | ||
); | ||
|
||
expect(this.mock.hashProposal(...this.proposal.shortProposal)).to.eventually.equal(this.proposal.hash); | ||
expect(this.mock.getProposalId(...this.proposal.shortProposal)).to.eventually.equal(i); | ||
expect(this.mock.proposalCount()).to.eventually.equal(i); | ||
} | ||
}); | ||
|
||
it('sequential proposal ids with offset start', async function () { | ||
const offset = 69420; | ||
await this.mock.$_setProposalCount(offset); | ||
|
||
for (const i of iterate.range(offset + 1, offset + 10)) { | ||
this.proposal.description = `<proposal description #${i}>`; | ||
|
||
expect(this.mock.hashProposal(...this.proposal.shortProposal)).to.eventually.equal(this.proposal.hash); | ||
await expect(this.mock.getProposalId(...this.proposal.shortProposal)).revertedWithCustomError( | ||
this.mock, | ||
'GovernorNonexistentProposal', | ||
); | ||
expect(this.mock.proposalCount()).to.eventually.equal(i - 1); | ||
|
||
await expect(this.helper.connect(this.proposer).propose()) | ||
.to.emit(this.mock, 'ProposalCreated') | ||
.withArgs( | ||
i, | ||
this.proposer, | ||
this.proposal.targets, | ||
this.proposal.values, | ||
this.proposal.signatures, | ||
this.proposal.data, | ||
anyValue, | ||
anyValue, | ||
this.proposal.description, | ||
); | ||
|
||
expect(this.mock.hashProposal(...this.proposal.shortProposal)).to.eventually.equal(this.proposal.hash); | ||
expect(this.mock.getProposalId(...this.proposal.shortProposal)).to.eventually.equal(i); | ||
expect(this.mock.proposalCount()).to.eventually.equal(i); | ||
} | ||
}); | ||
|
||
it('nominal workflow', async function () { | ||
await this.helper.connect(this.proposer).propose(); | ||
await this.helper.waitForSnapshot(); | ||
|
||
await expect(this.mock.connect(this.voter1).castVote(1, VoteType.For)) | ||
.to.emit(this.mock, 'VoteCast') | ||
.withArgs(this.voter1, 1, VoteType.For, ethers.parseEther('10'), ''); | ||
|
||
await expect(this.mock.connect(this.voter2).castVote(1, VoteType.For)) | ||
.to.emit(this.mock, 'VoteCast') | ||
.withArgs(this.voter2, 1, VoteType.For, ethers.parseEther('7'), ''); | ||
|
||
await expect(this.mock.connect(this.voter3).castVote(1, VoteType.For)) | ||
.to.emit(this.mock, 'VoteCast') | ||
.withArgs(this.voter3, 1, VoteType.For, ethers.parseEther('5'), ''); | ||
|
||
await expect(this.mock.connect(this.voter4).castVote(1, VoteType.Abstain)) | ||
.to.emit(this.mock, 'VoteCast') | ||
.withArgs(this.voter4, 1, VoteType.Abstain, ethers.parseEther('2'), ''); | ||
|
||
await this.helper.waitForDeadline(); | ||
|
||
expect(this.helper.execute()) | ||
.to.eventually.emit(this.mock, 'ProposalExecuted') | ||
.withArgs(1) | ||
.emit(this.receiver, 'MockFunctionCalled'); | ||
}); | ||
}); | ||
} | ||
}); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think we're missing another check to mention the new
getProposalId
function