-
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 id #5280
Changes from all commits
a25d139
a4ee283
e34a8b9
3cf1b80
9063080
0521101
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,32 @@ | ||
// SPDX-License-Identifier: MIT | ||
|
||
pragma solidity ^0.8.20; | ||
|
||
import {Governor} from "../Governor.sol"; | ||
|
||
abstract contract GovernorSequentialProposalId is Governor { | ||
error NextProposalIdCanOnlyBeSetOnce(); | ||
|
||
uint256 private _numberOfProposals; | ||
mapping(uint256 proposalHash => uint256 proposalId) private _proposalIds; | ||
|
||
function _getProposalId( | ||
address[] memory targets, | ||
uint256[] memory values, | ||
bytes[] memory calldatas, | ||
bytes32 descriptionHash | ||
) internal virtual override returns (uint256) { | ||
uint256 proposalHash = super._getProposalId(targets, values, calldatas, descriptionHash); | ||
|
||
uint256 storedProposalId = _proposalIds[proposalHash]; | ||
return storedProposalId == 0 ? (_proposalIds[proposalHash] = ++_numberOfProposals) : storedProposalId; | ||
} | ||
|
||
/** | ||
* @dev Internal function to set the sequential proposal ID for the next proposal. This is helpful for transitioning from another governing system. | ||
*/ | ||
function _setNextProposalId(uint256 nextProposalId) internal virtual { | ||
if (_numberOfProposals != 0) revert NextProposalIdCanOnlyBeSetOnce(); | ||
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. IMO this works, but there's a slightly annoying attack surface here. if an attacker can create a proposal on this governor before the upgrade occurs, then any we had the same problem. we managed it by initializing 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. Given that the default flow would be proposal ids starting from 1, we don't want to require calls to |
||
_numberOfProposals = nextProposalId - 1; | ||
} | ||
arr00 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,29 @@ | ||
// 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 | ||
) internal virtual override(Governor, GovernorSequentialProposalId) returns (uint256) { | ||
return super._getProposalId(targets, values, calldatas, descriptionHash); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,168 @@ | ||
const { ethers } = require('hardhat'); | ||
const { expect } = require('chai'); | ||
const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); | ||
|
||
const { GovernorHelper } = require('../../helpers/governance'); | ||
const { VoteType } = require('../../helpers/enums'); | ||
const time = require('../../helpers/time'); | ||
|
||
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 () { | ||
const txPropose = await this.helper.connect(this.proposer).propose(); | ||
const timepoint = await time.clockFromReceipt[mode](txPropose); | ||
|
||
await expect(txPropose) | ||
.to.emit(this.mock, 'ProposalCreated') | ||
.withArgs( | ||
1, | ||
this.proposer, | ||
this.proposal.targets, | ||
this.proposal.values, | ||
this.proposal.signatures, | ||
this.proposal.data, | ||
timepoint + votingDelay, | ||
timepoint + votingDelay + votingPeriod, | ||
this.proposal.description, | ||
); | ||
|
||
this.proposal = this.helper.setProposal( | ||
[ | ||
{ | ||
target: this.receiver.target, | ||
data: this.receiver.interface.encodeFunctionData('mockFunction'), | ||
value, | ||
}, | ||
], | ||
'<proposal description 2>', | ||
); | ||
const txPropose2 = await this.helper.connect(this.proposer).propose(); | ||
const timepoint2 = await time.clockFromReceipt[mode](txPropose2); | ||
|
||
await expect(txPropose2) | ||
.to.emit(this.mock, 'ProposalCreated') | ||
.withArgs( | ||
2, | ||
this.proposer, | ||
this.proposal.targets, | ||
this.proposal.values, | ||
this.proposal.signatures, | ||
this.proposal.data, | ||
timepoint2 + votingDelay, | ||
timepoint2 + votingDelay + votingPeriod, | ||
this.proposal.description, | ||
); | ||
}); | ||
|
||
it('nominal workflow', async function () { | ||
await this.helper.connect(this.proposer).propose(); | ||
let timepoint = await this.mock.proposalSnapshot(1); | ||
await time.increaseTo[mode](timepoint); | ||
|
||
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'), ''); | ||
|
||
timepoint = await this.mock.proposalDeadline(1); | ||
await time.increaseTo[mode](timepoint); | ||
|
||
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.
My idea was that this function should be a view getter, it shouldn't automatically generate a new proposal id (
propose
would do that explicitly), and it should revert if there is no proposal id stored (because the proposal was never created).