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

Governor Sequential Proposal id #5280

Closed
wants to merge 6 commits into from
Closed
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
19 changes: 14 additions & 5 deletions contracts/governance/Governor.sol
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,15 @@ abstract contract Governor is Context, ERC165, EIP712, Nonces, IGovernor, IERC72
return "1";
}

function _getProposalId(
address[] memory targets,
uint256[] memory values,
bytes[] memory calldatas,
bytes32 descriptionHash
) internal virtual returns (uint256) {
return hashProposal(targets, values, calldatas, descriptionHash);
}

/**
* @dev See {IGovernor-hashProposal}.
*
Expand Down Expand Up @@ -317,7 +326,7 @@ abstract contract Governor is Context, ERC165, EIP712, Nonces, IGovernor, IERC72
string memory description,
address proposer
) internal virtual returns (uint256 proposalId) {
proposalId = hashProposal(targets, values, calldatas, keccak256(bytes(description)));
proposalId = _getProposalId(targets, values, calldatas, keccak256(bytes(description)));

if (targets.length != values.length || targets.length != calldatas.length || targets.length == 0) {
revert GovernorInvalidProposalLength(targets.length, calldatas.length, values.length);
Expand Down Expand Up @@ -358,7 +367,7 @@ abstract contract Governor is Context, ERC165, EIP712, Nonces, IGovernor, IERC72
bytes[] memory calldatas,
bytes32 descriptionHash
) public virtual returns (uint256) {
uint256 proposalId = hashProposal(targets, values, calldatas, descriptionHash);
uint256 proposalId = _getProposalId(targets, values, calldatas, descriptionHash);

_validateStateBitmap(proposalId, _encodeStateBitmap(ProposalState.Succeeded));

Expand Down Expand Up @@ -406,7 +415,7 @@ abstract contract Governor is Context, ERC165, EIP712, Nonces, IGovernor, IERC72
bytes[] memory calldatas,
bytes32 descriptionHash
) public payable virtual returns (uint256) {
uint256 proposalId = hashProposal(targets, values, calldatas, descriptionHash);
uint256 proposalId = _getProposalId(targets, values, calldatas, descriptionHash);

_validateStateBitmap(
proposalId,
Expand Down Expand Up @@ -469,7 +478,7 @@ abstract contract Governor is Context, ERC165, EIP712, Nonces, IGovernor, IERC72
// The proposalId will be recomputed in the `_cancel` call further down. However we need the value before we
// do the internal call, because we need to check the proposal state BEFORE the internal `_cancel` call
// changes it. The `hashProposal` duplication has a cost that is limited, and that we accept.
uint256 proposalId = hashProposal(targets, values, calldatas, descriptionHash);
uint256 proposalId = _getProposalId(targets, values, calldatas, descriptionHash);

// public cancel restrictions (on top of existing _cancel restrictions).
_validateStateBitmap(proposalId, _encodeStateBitmap(ProposalState.Pending));
Expand All @@ -492,7 +501,7 @@ abstract contract Governor is Context, ERC165, EIP712, Nonces, IGovernor, IERC72
bytes[] memory calldatas,
bytes32 descriptionHash
) internal virtual returns (uint256) {
uint256 proposalId = hashProposal(targets, values, calldatas, descriptionHash);
uint256 proposalId = _getProposalId(targets, values, calldatas, descriptionHash);

_validateStateBitmap(
proposalId,
Expand Down
32 changes: 32 additions & 0 deletions contracts/governance/extensions/GovernorSequentialProposalId.sol
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;
Copy link
Contributor

@frangio frangio Nov 4, 2024

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).

}

/**
* @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();

Choose a reason for hiding this comment

The 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 setNextProposalId call will revert. this setNextProposalId call might be part of the upgrade proposal, which would then fail.

we had the same problem. we managed it by initializing nextProposalId to type(uint256).max. then, proposals couldn't be created unless setNextProposalId had been called.

Copy link
Contributor Author

@arr00 arr00 Nov 4, 2024

Choose a reason for hiding this comment

The 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 _setNextProposalId. Also note that _setNextProposalId is an internal function which would need to be called in a bespoke public function, which is where this issue should be addressed. We should add another function to this extension getNextProposalId to make disabling governance until the proposal id is set easier.

_numberOfProposals = nextProposalId - 1;
}
arr00 marked this conversation as resolved.
Show resolved Hide resolved
}
29 changes: 29 additions & 0 deletions contracts/mocks/governance/GovernorSequentialProposalIdMock.sol
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);
}
}
168 changes: 168 additions & 0 deletions test/governance/extensions/GovernorSequentialProposalId.test.js
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');
});
});
}
});
Loading