This commit is contained in:
dexorder
2024-10-17 02:42:28 -04:00
commit 25def69c66
878 changed files with 112489 additions and 0 deletions

View File

@@ -0,0 +1,55 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {Test} from "@forge-std/Test.sol";
import {Strings} from "@openzeppelin/contracts/utils/Strings.sol";
import {Governor} from "@openzeppelin/contracts/governance/Governor.sol";
contract GovernorInternalTest is Test, Governor {
constructor() Governor("") {}
function testValidDescriptionForProposer(string memory description, address proposer, bool includeProposer) public {
if (includeProposer) {
description = string.concat(description, "#proposer=", Strings.toHexString(proposer));
}
assertTrue(_isValidDescriptionForProposer(proposer, description));
}
function testInvalidDescriptionForProposer(
string memory description,
address commitProposer,
address actualProposer
) public {
vm.assume(commitProposer != actualProposer);
description = string.concat(description, "#proposer=", Strings.toHexString(commitProposer));
assertFalse(_isValidDescriptionForProposer(actualProposer, description));
}
// We don't need to truly implement implement the missing functions because we are just testing
// internal helpers.
function clock() public pure override returns (uint48) {}
// solhint-disable-next-line func-name-mixedcase
function CLOCK_MODE() public pure override returns (string memory) {}
// solhint-disable-next-line func-name-mixedcase
function COUNTING_MODE() public pure virtual override returns (string memory) {}
function votingDelay() public pure virtual override returns (uint256) {}
function votingPeriod() public pure virtual override returns (uint256) {}
function quorum(uint256) public pure virtual override returns (uint256) {}
function hasVoted(uint256, address) public pure virtual override returns (bool) {}
function _quorumReached(uint256) internal pure virtual override returns (bool) {}
function _voteSucceeded(uint256) internal pure virtual override returns (bool) {}
function _getVotes(address, uint256, bytes memory) internal pure virtual override returns (uint256) {}
function _countVote(uint256, address, uint8, uint256, bytes memory) internal virtual override {}
}

View File

@@ -0,0 +1,992 @@
const { ethers } = require('hardhat');
const { expect } = require('chai');
const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers');
const { GovernorHelper } = require('../helpers/governance');
const { getDomain, Ballot } = require('../helpers/eip712');
const { ProposalState, VoteType } = require('../helpers/enums');
const time = require('../helpers/time');
const { shouldSupportInterfaces } = require('../utils/introspection/SupportsInterface.behavior');
const { shouldBehaveLikeERC6372 } = require('./utils/ERC6372.behavior');
const TOKENS = [
{ Token: '$ERC20Votes', mode: 'blocknumber' },
{ Token: '$ERC20VotesTimestampMock', mode: 'timestamp' },
{ Token: '$ERC20VotesLegacyMock', mode: 'blocknumber' },
];
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');
const signBallot = account => (contract, message) =>
getDomain(contract).then(domain => account.signTypedData(domain, { Ballot }, message));
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('Governor', 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('$GovernorMock', [
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));
// initiate fresh proposal
this.proposal = this.helper.setProposal(
[
{
target: this.receiver.target,
data: this.receiver.interface.encodeFunctionData('mockFunction'),
value,
},
],
'<proposal description>',
);
});
shouldSupportInterfaces(['ERC1155Receiver', 'Governor']);
shouldBehaveLikeERC6372(mode);
it('deployment check', async function () {
expect(await this.mock.name()).to.equal(name);
expect(await this.mock.token()).to.equal(this.token);
expect(await this.mock.votingDelay()).to.equal(votingDelay);
expect(await this.mock.votingPeriod()).to.equal(votingPeriod);
expect(await this.mock.quorum(0)).to.equal(0n);
expect(await this.mock.COUNTING_MODE()).to.equal('support=bravo&quorum=for,abstain');
});
it('nominal workflow', async function () {
// Before
expect(await this.mock.proposalProposer(this.proposal.id)).to.equal(ethers.ZeroAddress);
expect(await this.mock.hasVoted(this.proposal.id, this.owner)).to.be.false;
expect(await this.mock.hasVoted(this.proposal.id, this.voter1)).to.be.false;
expect(await this.mock.hasVoted(this.proposal.id, this.voter2)).to.be.false;
expect(await ethers.provider.getBalance(this.mock)).to.equal(value);
expect(await ethers.provider.getBalance(this.receiver)).to.equal(0n);
expect(await this.mock.proposalEta(this.proposal.id)).to.equal(0n);
expect(await this.mock.proposalNeedsQueuing(this.proposal.id)).to.be.false;
// Run proposal
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(
this.proposal.id,
this.proposer,
this.proposal.targets,
this.proposal.values,
this.proposal.signatures,
this.proposal.data,
timepoint + votingDelay,
timepoint + votingDelay + votingPeriod,
this.proposal.description,
);
await this.helper.waitForSnapshot();
await expect(this.helper.connect(this.voter1).vote({ support: VoteType.For, reason: 'This is nice' }))
.to.emit(this.mock, 'VoteCast')
.withArgs(this.voter1, this.proposal.id, VoteType.For, ethers.parseEther('10'), 'This is nice');
await expect(this.helper.connect(this.voter2).vote({ support: VoteType.For }))
.to.emit(this.mock, 'VoteCast')
.withArgs(this.voter2, this.proposal.id, VoteType.For, ethers.parseEther('7'), '');
await expect(this.helper.connect(this.voter3).vote({ support: VoteType.Against }))
.to.emit(this.mock, 'VoteCast')
.withArgs(this.voter3, this.proposal.id, VoteType.Against, ethers.parseEther('5'), '');
await expect(this.helper.connect(this.voter4).vote({ support: VoteType.Abstain }))
.to.emit(this.mock, 'VoteCast')
.withArgs(this.voter4, this.proposal.id, VoteType.Abstain, ethers.parseEther('2'), '');
await this.helper.waitForDeadline();
const txExecute = await this.helper.execute();
await expect(txExecute).to.emit(this.mock, 'ProposalExecuted').withArgs(this.proposal.id);
await expect(txExecute).to.emit(this.receiver, 'MockFunctionCalled');
// After
expect(await this.mock.proposalProposer(this.proposal.id)).to.equal(this.proposer);
expect(await this.mock.hasVoted(this.proposal.id, this.owner)).to.be.false;
expect(await this.mock.hasVoted(this.proposal.id, this.voter1)).to.be.true;
expect(await this.mock.hasVoted(this.proposal.id, this.voter2)).to.be.true;
expect(await ethers.provider.getBalance(this.mock)).to.equal(0n);
expect(await ethers.provider.getBalance(this.receiver)).to.equal(value);
expect(await this.mock.proposalEta(this.proposal.id)).to.equal(0n);
expect(await this.mock.proposalNeedsQueuing(this.proposal.id)).to.be.false;
});
it('send ethers', async function () {
this.helper.setProposal(
[
{
target: this.userEOA.address,
value,
},
],
'<proposal description>',
);
// Run proposal
await expect(async () => {
await this.helper.propose();
await this.helper.waitForSnapshot();
await this.helper.connect(this.voter1).vote({ support: VoteType.For });
await this.helper.waitForDeadline();
return this.helper.execute();
}).to.changeEtherBalances([this.mock, this.userEOA], [-value, value]);
});
describe('vote with signature', function () {
it('votes with an EOA signature', async function () {
await this.token.connect(this.voter1).delegate(this.userEOA);
const nonce = await this.mock.nonces(this.userEOA);
// Run proposal
await this.helper.propose();
await this.helper.waitForSnapshot();
await expect(
this.helper.vote({
support: VoteType.For,
voter: this.userEOA.address,
nonce,
signature: signBallot(this.userEOA),
}),
)
.to.emit(this.mock, 'VoteCast')
.withArgs(this.userEOA, this.proposal.id, VoteType.For, ethers.parseEther('10'), '');
await this.helper.waitForDeadline();
await this.helper.execute();
// After
expect(await this.mock.hasVoted(this.proposal.id, this.userEOA)).to.be.true;
expect(await this.mock.nonces(this.userEOA)).to.equal(nonce + 1n);
});
it('votes with a valid EIP-1271 signature', async function () {
const wallet = await ethers.deployContract('ERC1271WalletMock', [this.userEOA]);
await this.token.connect(this.voter1).delegate(wallet);
const nonce = await this.mock.nonces(this.userEOA);
// Run proposal
await this.helper.propose();
await this.helper.waitForSnapshot();
await expect(
this.helper.vote({
support: VoteType.For,
voter: wallet.target,
nonce,
signature: signBallot(this.userEOA),
}),
)
.to.emit(this.mock, 'VoteCast')
.withArgs(wallet, this.proposal.id, VoteType.For, ethers.parseEther('10'), '');
await this.helper.waitForDeadline();
await this.helper.execute();
// After
expect(await this.mock.hasVoted(this.proposal.id, wallet)).to.be.true;
expect(await this.mock.nonces(wallet)).to.equal(nonce + 1n);
});
afterEach('no other votes are cast', async function () {
expect(await this.mock.hasVoted(this.proposal.id, this.owner)).to.be.false;
expect(await this.mock.hasVoted(this.proposal.id, this.voter1)).to.be.false;
expect(await this.mock.hasVoted(this.proposal.id, this.voter2)).to.be.false;
});
});
describe('should revert', function () {
describe('on propose', function () {
it('if proposal already exists', async function () {
await this.helper.propose();
await expect(this.helper.propose())
.to.be.revertedWithCustomError(this.mock, 'GovernorUnexpectedProposalState')
.withArgs(this.proposal.id, ProposalState.Pending, ethers.ZeroHash);
});
it('if proposer has below threshold votes', async function () {
const votes = ethers.parseEther('10');
const threshold = ethers.parseEther('1000');
await this.mock.$_setProposalThreshold(threshold);
await expect(this.helper.connect(this.voter1).propose())
.to.be.revertedWithCustomError(this.mock, 'GovernorInsufficientProposerVotes')
.withArgs(this.voter1, votes, threshold);
});
});
describe('on vote', function () {
it('if proposal does not exist', async function () {
await expect(this.helper.connect(this.voter1).vote({ support: VoteType.For }))
.to.be.revertedWithCustomError(this.mock, 'GovernorNonexistentProposal')
.withArgs(this.proposal.id);
});
it('if voting has not started', async function () {
await this.helper.propose();
await expect(this.helper.connect(this.voter1).vote({ support: VoteType.For }))
.to.be.revertedWithCustomError(this.mock, 'GovernorUnexpectedProposalState')
.withArgs(
this.proposal.id,
ProposalState.Pending,
GovernorHelper.proposalStatesToBitMap([ProposalState.Active]),
);
});
it('if support value is invalid', async function () {
await this.helper.propose();
await this.helper.waitForSnapshot();
await expect(this.helper.vote({ support: 255 })).to.be.revertedWithCustomError(
this.mock,
'GovernorInvalidVoteType',
);
});
it('if vote was already casted', async function () {
await this.helper.propose();
await this.helper.waitForSnapshot();
await this.helper.connect(this.voter1).vote({ support: VoteType.For });
await expect(this.helper.connect(this.voter1).vote({ support: VoteType.For }))
.to.be.revertedWithCustomError(this.mock, 'GovernorAlreadyCastVote')
.withArgs(this.voter1);
});
it('if voting is over', async function () {
await this.helper.propose();
await this.helper.waitForDeadline();
await expect(this.helper.connect(this.voter1).vote({ support: VoteType.For }))
.to.be.revertedWithCustomError(this.mock, 'GovernorUnexpectedProposalState')
.withArgs(
this.proposal.id,
ProposalState.Defeated,
GovernorHelper.proposalStatesToBitMap([ProposalState.Active]),
);
});
});
describe('on vote by signature', function () {
beforeEach(async function () {
await this.token.connect(this.voter1).delegate(this.userEOA);
// Run proposal
await this.helper.propose();
await this.helper.waitForSnapshot();
});
it('if signature does not match signer', async function () {
const nonce = await this.mock.nonces(this.userEOA);
function tamper(str, index, mask) {
const arrayStr = ethers.getBytes(str);
arrayStr[index] ^= mask;
return ethers.hexlify(arrayStr);
}
const voteParams = {
support: VoteType.For,
voter: this.userEOA.address,
nonce,
signature: (...args) => signBallot(this.userEOA)(...args).then(sig => tamper(sig, 42, 0xff)),
};
await expect(this.helper.vote(voteParams))
.to.be.revertedWithCustomError(this.mock, 'GovernorInvalidSignature')
.withArgs(voteParams.voter);
});
it('if vote nonce is incorrect', async function () {
const nonce = await this.mock.nonces(this.userEOA);
const voteParams = {
support: VoteType.For,
voter: this.userEOA.address,
nonce: nonce + 1n,
signature: signBallot(this.userEOA),
};
await expect(this.helper.vote(voteParams))
.to.be.revertedWithCustomError(this.mock, 'GovernorInvalidSignature')
.withArgs(voteParams.voter);
});
});
describe('on queue', function () {
it('always', async function () {
await this.helper.connect(this.proposer).propose();
await this.helper.waitForSnapshot();
await this.helper.connect(this.voter1).vote({ support: VoteType.For });
await this.helper.waitForDeadline();
await expect(this.helper.queue()).to.be.revertedWithCustomError(this.mock, 'GovernorQueueNotImplemented');
});
});
describe('on execute', function () {
it('if proposal does not exist', async function () {
await expect(this.helper.execute())
.to.be.revertedWithCustomError(this.mock, 'GovernorNonexistentProposal')
.withArgs(this.proposal.id);
});
it('if quorum is not reached', async function () {
await this.helper.propose();
await this.helper.waitForSnapshot();
await this.helper.connect(this.voter3).vote({ support: VoteType.For });
await expect(this.helper.execute())
.to.be.revertedWithCustomError(this.mock, 'GovernorUnexpectedProposalState')
.withArgs(
this.proposal.id,
ProposalState.Active,
GovernorHelper.proposalStatesToBitMap([ProposalState.Succeeded, ProposalState.Queued]),
);
});
it('if score not reached', async function () {
await this.helper.propose();
await this.helper.waitForSnapshot();
await this.helper.connect(this.voter1).vote({ support: VoteType.Against });
await expect(this.helper.execute())
.to.be.revertedWithCustomError(this.mock, 'GovernorUnexpectedProposalState')
.withArgs(
this.proposal.id,
ProposalState.Active,
GovernorHelper.proposalStatesToBitMap([ProposalState.Succeeded, ProposalState.Queued]),
);
});
it('if voting is not over', async function () {
await this.helper.propose();
await this.helper.waitForSnapshot();
await this.helper.connect(this.voter1).vote({ support: VoteType.For });
await expect(this.helper.execute())
.to.be.revertedWithCustomError(this.mock, 'GovernorUnexpectedProposalState')
.withArgs(
this.proposal.id,
ProposalState.Active,
GovernorHelper.proposalStatesToBitMap([ProposalState.Succeeded, ProposalState.Queued]),
);
});
it('if receiver revert without reason', async function () {
this.helper.setProposal(
[
{
target: this.receiver.target,
data: this.receiver.interface.encodeFunctionData('mockFunctionRevertsNoReason'),
},
],
'<proposal description>',
);
await this.helper.propose();
await this.helper.waitForSnapshot();
await this.helper.connect(this.voter1).vote({ support: VoteType.For });
await this.helper.waitForDeadline();
await expect(this.helper.execute()).to.be.revertedWithCustomError(this.mock, 'FailedCall');
});
it('if receiver revert with reason', async function () {
this.helper.setProposal(
[
{
target: this.receiver.target,
data: this.receiver.interface.encodeFunctionData('mockFunctionRevertsReason'),
},
],
'<proposal description>',
);
await this.helper.propose();
await this.helper.waitForSnapshot();
await this.helper.connect(this.voter1).vote({ support: VoteType.For });
await this.helper.waitForDeadline();
await expect(this.helper.execute()).to.be.revertedWith('CallReceiverMock: reverting');
});
it('if proposal was already executed', async function () {
await this.helper.propose();
await this.helper.waitForSnapshot();
await this.helper.connect(this.voter1).vote({ support: VoteType.For });
await this.helper.waitForDeadline();
await this.helper.execute();
await expect(this.helper.execute())
.to.be.revertedWithCustomError(this.mock, 'GovernorUnexpectedProposalState')
.withArgs(
this.proposal.id,
ProposalState.Executed,
GovernorHelper.proposalStatesToBitMap([ProposalState.Succeeded, ProposalState.Queued]),
);
});
});
});
describe('state', function () {
it('Unset', async function () {
await expect(this.mock.state(this.proposal.id))
.to.be.revertedWithCustomError(this.mock, 'GovernorNonexistentProposal')
.withArgs(this.proposal.id);
});
it('Pending & Active', async function () {
await this.helper.propose();
expect(await this.mock.state(this.proposal.id)).to.equal(ProposalState.Pending);
await this.helper.waitForSnapshot();
expect(await this.mock.state(this.proposal.id)).to.equal(ProposalState.Pending);
await this.helper.waitForSnapshot(1n);
expect(await this.mock.state(this.proposal.id)).to.equal(ProposalState.Active);
});
it('Defeated', async function () {
await this.helper.propose();
await this.helper.waitForDeadline();
expect(await this.mock.state(this.proposal.id)).to.equal(ProposalState.Active);
await this.helper.waitForDeadline(1n);
expect(await this.mock.state(this.proposal.id)).to.equal(ProposalState.Defeated);
});
it('Succeeded', async function () {
await this.helper.propose();
await this.helper.waitForSnapshot();
await this.helper.connect(this.voter1).vote({ support: VoteType.For });
await this.helper.waitForDeadline();
expect(await this.mock.state(this.proposal.id)).to.equal(ProposalState.Active);
await this.helper.waitForDeadline(1n);
expect(await this.mock.state(this.proposal.id)).to.equal(ProposalState.Succeeded);
});
it('Executed', async function () {
await this.helper.propose();
await this.helper.waitForSnapshot();
await this.helper.connect(this.voter1).vote({ support: VoteType.For });
await this.helper.waitForDeadline();
await this.helper.execute();
expect(await this.mock.state(this.proposal.id)).to.equal(ProposalState.Executed);
});
});
describe('cancel', function () {
describe('internal', function () {
it('before proposal', async function () {
await expect(this.helper.cancel('internal'))
.to.be.revertedWithCustomError(this.mock, 'GovernorNonexistentProposal')
.withArgs(this.proposal.id);
});
it('after proposal', async function () {
await this.helper.propose();
await this.helper.cancel('internal');
expect(await this.mock.state(this.proposal.id)).to.equal(ProposalState.Canceled);
await this.helper.waitForSnapshot();
await expect(this.helper.connect(this.voter1).vote({ support: VoteType.For }))
.to.be.revertedWithCustomError(this.mock, 'GovernorUnexpectedProposalState')
.withArgs(
this.proposal.id,
ProposalState.Canceled,
GovernorHelper.proposalStatesToBitMap([ProposalState.Active]),
);
});
it('after vote', async function () {
await this.helper.propose();
await this.helper.waitForSnapshot();
await this.helper.connect(this.voter1).vote({ support: VoteType.For });
await this.helper.cancel('internal');
expect(await this.mock.state(this.proposal.id)).to.equal(ProposalState.Canceled);
await this.helper.waitForDeadline();
await expect(this.helper.execute())
.to.be.revertedWithCustomError(this.mock, 'GovernorUnexpectedProposalState')
.withArgs(
this.proposal.id,
ProposalState.Canceled,
GovernorHelper.proposalStatesToBitMap([ProposalState.Succeeded, ProposalState.Queued]),
);
});
it('after deadline', async function () {
await this.helper.propose();
await this.helper.waitForSnapshot();
await this.helper.connect(this.voter1).vote({ support: VoteType.For });
await this.helper.waitForDeadline();
await this.helper.cancel('internal');
expect(await this.mock.state(this.proposal.id)).to.equal(ProposalState.Canceled);
await expect(this.helper.execute())
.to.be.revertedWithCustomError(this.mock, 'GovernorUnexpectedProposalState')
.withArgs(
this.proposal.id,
ProposalState.Canceled,
GovernorHelper.proposalStatesToBitMap([ProposalState.Succeeded, ProposalState.Queued]),
);
});
it('after execution', async function () {
await this.helper.propose();
await this.helper.waitForSnapshot();
await this.helper.connect(this.voter1).vote({ support: VoteType.For });
await this.helper.waitForDeadline();
await this.helper.execute();
await expect(this.helper.cancel('internal'))
.to.be.revertedWithCustomError(this.mock, 'GovernorUnexpectedProposalState')
.withArgs(
this.proposal.id,
ProposalState.Executed,
GovernorHelper.proposalStatesToBitMap(
[ProposalState.Canceled, ProposalState.Expired, ProposalState.Executed],
{ inverted: true },
),
);
});
});
describe('public', function () {
it('before proposal', async function () {
await expect(this.helper.cancel('external'))
.to.be.revertedWithCustomError(this.mock, 'GovernorNonexistentProposal')
.withArgs(this.proposal.id);
});
it('after proposal', async function () {
await this.helper.propose();
await this.helper.cancel('external');
});
it('after proposal - restricted to proposer', async function () {
await this.helper.connect(this.proposer).propose();
await expect(this.helper.connect(this.owner).cancel('external'))
.to.be.revertedWithCustomError(this.mock, 'GovernorOnlyProposer')
.withArgs(this.owner);
});
it('after vote started', async function () {
await this.helper.propose();
await this.helper.waitForSnapshot(1n); // snapshot + 1 block
await expect(this.helper.cancel('external'))
.to.be.revertedWithCustomError(this.mock, 'GovernorUnexpectedProposalState')
.withArgs(
this.proposal.id,
ProposalState.Active,
GovernorHelper.proposalStatesToBitMap([ProposalState.Pending]),
);
});
it('after vote', async function () {
await this.helper.propose();
await this.helper.waitForSnapshot();
await this.helper.connect(this.voter1).vote({ support: VoteType.For });
await expect(this.helper.cancel('external'))
.to.be.revertedWithCustomError(this.mock, 'GovernorUnexpectedProposalState')
.withArgs(
this.proposal.id,
ProposalState.Active,
GovernorHelper.proposalStatesToBitMap([ProposalState.Pending]),
);
});
it('after deadline', async function () {
await this.helper.propose();
await this.helper.waitForSnapshot();
await this.helper.connect(this.voter1).vote({ support: VoteType.For });
await this.helper.waitForDeadline();
await expect(this.helper.cancel('external'))
.to.be.revertedWithCustomError(this.mock, 'GovernorUnexpectedProposalState')
.withArgs(
this.proposal.id,
ProposalState.Succeeded,
GovernorHelper.proposalStatesToBitMap([ProposalState.Pending]),
);
});
it('after execution', async function () {
await this.helper.propose();
await this.helper.waitForSnapshot();
await this.helper.connect(this.voter1).vote({ support: VoteType.For });
await this.helper.waitForDeadline();
await this.helper.execute();
await expect(this.helper.cancel('external'))
.to.be.revertedWithCustomError(this.mock, 'GovernorUnexpectedProposalState')
.withArgs(
this.proposal.id,
ProposalState.Executed,
GovernorHelper.proposalStatesToBitMap([ProposalState.Pending]),
);
});
});
});
describe('proposal length', function () {
it('empty', async function () {
this.helper.setProposal([], '<proposal description>');
await expect(this.helper.propose())
.to.be.revertedWithCustomError(this.mock, 'GovernorInvalidProposalLength')
.withArgs(0, 0, 0);
});
it('mismatch #1', async function () {
this.helper.setProposal(
{
targets: [],
values: [0n],
data: [this.receiver.interface.encodeFunctionData('mockFunction')],
},
'<proposal description>',
);
await expect(this.helper.propose())
.to.be.revertedWithCustomError(this.mock, 'GovernorInvalidProposalLength')
.withArgs(0, 1, 1);
});
it('mismatch #2', async function () {
this.helper.setProposal(
{
targets: [this.receiver.target],
values: [],
data: [this.receiver.interface.encodeFunctionData('mockFunction')],
},
'<proposal description>',
);
await expect(this.helper.propose())
.to.be.revertedWithCustomError(this.mock, 'GovernorInvalidProposalLength')
.withArgs(1, 1, 0);
});
it('mismatch #3', async function () {
this.helper.setProposal(
{
targets: [this.receiver.target],
values: [0n],
data: [],
},
'<proposal description>',
);
await expect(this.helper.propose())
.to.be.revertedWithCustomError(this.mock, 'GovernorInvalidProposalLength')
.withArgs(1, 0, 1);
});
});
describe('frontrun protection using description suffix', function () {
function shouldPropose() {
it('proposer can propose', async function () {
const txPropose = await this.helper.connect(this.proposer).propose();
await expect(txPropose)
.to.emit(this.mock, 'ProposalCreated')
.withArgs(
this.proposal.id,
this.proposer,
this.proposal.targets,
this.proposal.values,
this.proposal.signatures,
this.proposal.data,
(await time.clockFromReceipt[mode](txPropose)) + votingDelay,
(await time.clockFromReceipt[mode](txPropose)) + votingDelay + votingPeriod,
this.proposal.description,
);
});
it('someone else can propose', async function () {
const txPropose = await this.helper.connect(this.voter1).propose();
await expect(txPropose)
.to.emit(this.mock, 'ProposalCreated')
.withArgs(
this.proposal.id,
this.voter1,
this.proposal.targets,
this.proposal.values,
this.proposal.signatures,
this.proposal.data,
(await time.clockFromReceipt[mode](txPropose)) + votingDelay,
(await time.clockFromReceipt[mode](txPropose)) + votingDelay + votingPeriod,
this.proposal.description,
);
});
}
describe('without protection', function () {
describe('without suffix', function () {
shouldPropose();
});
describe('with different suffix', function () {
beforeEach(function () {
this.proposal = this.helper.setProposal(
[
{
target: this.receiver.target,
data: this.receiver.interface.encodeFunctionData('mockFunction'),
value,
},
],
`<proposal description>#wrong-suffix=${this.proposer}`,
);
});
shouldPropose();
});
describe('with proposer suffix but bad address part', function () {
beforeEach(function () {
this.proposal = this.helper.setProposal(
[
{
target: this.receiver.target,
data: this.receiver.interface.encodeFunctionData('mockFunction'),
value,
},
],
`<proposal description>#proposer=0x3C44CdDdB6a900fa2b585dd299e03d12FA429XYZ`, // XYZ are not a valid hex char
);
});
shouldPropose();
});
});
describe('with protection via proposer suffix', function () {
beforeEach(function () {
this.proposal = this.helper.setProposal(
[
{
target: this.receiver.target,
data: this.receiver.interface.encodeFunctionData('mockFunction'),
value,
},
],
`<proposal description>#proposer=${this.proposer}`,
);
});
shouldPropose();
});
});
describe('onlyGovernance updates', function () {
it('setVotingDelay is protected', async function () {
await expect(this.mock.connect(this.owner).setVotingDelay(0n))
.to.be.revertedWithCustomError(this.mock, 'GovernorOnlyExecutor')
.withArgs(this.owner);
});
it('setVotingPeriod is protected', async function () {
await expect(this.mock.connect(this.owner).setVotingPeriod(32n))
.to.be.revertedWithCustomError(this.mock, 'GovernorOnlyExecutor')
.withArgs(this.owner);
});
it('setProposalThreshold is protected', async function () {
await expect(this.mock.connect(this.owner).setProposalThreshold(1_000_000_000_000_000_000n))
.to.be.revertedWithCustomError(this.mock, 'GovernorOnlyExecutor')
.withArgs(this.owner);
});
it('can setVotingDelay through governance', async function () {
this.helper.setProposal(
[
{
target: this.mock.target,
data: this.mock.interface.encodeFunctionData('setVotingDelay', [0n]),
},
],
'<proposal description>',
);
await this.helper.propose();
await this.helper.waitForSnapshot();
await this.helper.connect(this.voter1).vote({ support: VoteType.For });
await this.helper.waitForDeadline();
await expect(this.helper.execute()).to.emit(this.mock, 'VotingDelaySet').withArgs(4n, 0n);
expect(await this.mock.votingDelay()).to.equal(0n);
});
it('can setVotingPeriod through governance', async function () {
this.helper.setProposal(
[
{
target: this.mock.target,
data: this.mock.interface.encodeFunctionData('setVotingPeriod', [32n]),
},
],
'<proposal description>',
);
await this.helper.propose();
await this.helper.waitForSnapshot();
await this.helper.connect(this.voter1).vote({ support: VoteType.For });
await this.helper.waitForDeadline();
await expect(this.helper.execute()).to.emit(this.mock, 'VotingPeriodSet').withArgs(16n, 32n);
expect(await this.mock.votingPeriod()).to.equal(32n);
});
it('cannot setVotingPeriod to 0 through governance', async function () {
const votingPeriod = 0n;
this.helper.setProposal(
[
{
target: this.mock.target,
data: this.mock.interface.encodeFunctionData('setVotingPeriod', [votingPeriod]),
},
],
'<proposal description>',
);
await this.helper.propose();
await this.helper.waitForSnapshot();
await this.helper.connect(this.voter1).vote({ support: VoteType.For });
await this.helper.waitForDeadline();
await expect(this.helper.execute())
.to.be.revertedWithCustomError(this.mock, 'GovernorInvalidVotingPeriod')
.withArgs(votingPeriod);
});
it('can setProposalThreshold to 0 through governance', async function () {
this.helper.setProposal(
[
{
target: this.mock.target,
data: this.mock.interface.encodeFunctionData('setProposalThreshold', [1_000_000_000_000_000_000n]),
},
],
'<proposal description>',
);
await this.helper.propose();
await this.helper.waitForSnapshot();
await this.helper.connect(this.voter1).vote({ support: VoteType.For });
await this.helper.waitForDeadline();
await expect(this.helper.execute())
.to.emit(this.mock, 'ProposalThresholdSet')
.withArgs(0n, 1_000_000_000_000_000_000n);
expect(await this.mock.proposalThreshold()).to.equal(1_000_000_000_000_000_000n);
});
});
describe('safe receive', function () {
describe('ERC721', function () {
const tokenId = 1n;
beforeEach(async function () {
this.token = await ethers.deployContract('$ERC721', ['Non Fungible Token', 'NFT']);
await this.token.$_mint(this.owner, tokenId);
});
it('can receive an ERC721 safeTransfer', async function () {
await this.token.connect(this.owner).safeTransferFrom(this.owner, this.mock, tokenId);
});
});
describe('ERC1155', function () {
const tokenIds = {
1: 1000n,
2: 2000n,
3: 3000n,
};
beforeEach(async function () {
this.token = await ethers.deployContract('$ERC1155', ['https://token-cdn-domain/{id}.json']);
await this.token.$_mintBatch(this.owner, Object.keys(tokenIds), Object.values(tokenIds), '0x');
});
it('can receive ERC1155 safeTransfer', async function () {
await this.token.connect(this.owner).safeTransferFrom(
this.owner,
this.mock,
...Object.entries(tokenIds)[0], // id + amount
'0x',
);
});
it('can receive ERC1155 safeBatchTransfer', async function () {
await this.token
.connect(this.owner)
.safeBatchTransferFrom(this.owner, this.mock, Object.keys(tokenIds), Object.values(tokenIds), '0x');
});
});
});
});
}
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,131 @@
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 TOKENS = [
{ Token: '$ERC721Votes', mode: 'blocknumber' },
{ Token: '$ERC721VotesTimestampMock', mode: 'timestamp' },
];
const name = 'OZ-Governor';
const version = '1';
const tokenName = 'MockNFToken';
const tokenSymbol = 'MTKN';
const NFT0 = 0n;
const NFT1 = 1n;
const NFT2 = 2n;
const NFT3 = 3n;
const NFT4 = 4n;
const votingDelay = 4n;
const votingPeriod = 16n;
const value = ethers.parseEther('1');
describe('GovernorERC721', function () {
for (const { Token, mode } of TOKENS) {
const fixture = async () => {
const [owner, voter1, voter2, voter3, voter4] = await ethers.getSigners();
const receiver = await ethers.deployContract('CallReceiverMock');
const token = await ethers.deployContract(Token, [tokenName, tokenSymbol, version]);
const mock = await ethers.deployContract('$GovernorMock', [
name, // name
votingDelay, // initialVotingDelay
votingPeriod, // initialVotingPeriod
0n, // initialProposalThreshold
token, // tokenAddress
10n, // quorumNumeratorValue
]);
await owner.sendTransaction({ to: mock, value });
await Promise.all([NFT0, NFT1, NFT2, NFT3, NFT4].map(tokenId => token.$_mint(owner, tokenId)));
const helper = new GovernorHelper(mock, mode);
await helper.connect(owner).delegate({ token, to: voter1, tokenId: NFT0 });
await helper.connect(owner).delegate({ token, to: voter2, tokenId: NFT1 });
await helper.connect(owner).delegate({ token, to: voter2, tokenId: NFT2 });
await helper.connect(owner).delegate({ token, to: voter3, tokenId: NFT3 });
await helper.connect(owner).delegate({ token, to: voter4, tokenId: NFT4 });
return {
owner,
voter1,
voter2,
voter3,
voter4,
receiver,
token,
mock,
helper,
};
};
describe(`using ${Token}`, function () {
beforeEach(async function () {
Object.assign(this, await loadFixture(fixture));
// initiate fresh proposal
this.proposal = this.helper.setProposal(
[
{
target: this.receiver.target,
data: this.receiver.interface.encodeFunctionData('mockFunction'),
value,
},
],
'<proposal description>',
);
});
it('deployment check', async function () {
expect(await this.mock.name()).to.equal(name);
expect(await this.mock.token()).to.equal(this.token);
expect(await this.mock.votingDelay()).to.equal(votingDelay);
expect(await this.mock.votingPeriod()).to.equal(votingPeriod);
expect(await this.mock.quorum(0n)).to.equal(0n);
expect(await this.token.getVotes(this.voter1)).to.equal(1n); // NFT0
expect(await this.token.getVotes(this.voter2)).to.equal(2n); // NFT1 & NFT2
expect(await this.token.getVotes(this.voter3)).to.equal(1n); // NFT3
expect(await this.token.getVotes(this.voter4)).to.equal(1n); // NFT4
});
it('voting with ERC721 token', async function () {
await this.helper.propose();
await this.helper.waitForSnapshot();
await expect(this.helper.connect(this.voter1).vote({ support: VoteType.For }))
.to.emit(this.mock, 'VoteCast')
.withArgs(this.voter1, this.proposal.id, VoteType.For, 1n, '');
await expect(this.helper.connect(this.voter2).vote({ support: VoteType.For }))
.to.emit(this.mock, 'VoteCast')
.withArgs(this.voter2, this.proposal.id, VoteType.For, 2n, '');
await expect(this.helper.connect(this.voter3).vote({ support: VoteType.Against }))
.to.emit(this.mock, 'VoteCast')
.withArgs(this.voter3, this.proposal.id, VoteType.Against, 1n, '');
await expect(this.helper.connect(this.voter4).vote({ support: VoteType.Abstain }))
.to.emit(this.mock, 'VoteCast')
.withArgs(this.voter4, this.proposal.id, VoteType.Abstain, 1n, '');
await this.helper.waitForDeadline();
await this.helper.execute();
expect(await this.mock.hasVoted(this.proposal.id, this.owner)).to.be.false;
expect(await this.mock.hasVoted(this.proposal.id, this.voter1)).to.be.true;
expect(await this.mock.hasVoted(this.proposal.id, this.voter2)).to.be.true;
expect(await this.mock.hasVoted(this.proposal.id, this.voter3)).to.be.true;
expect(await this.mock.hasVoted(this.proposal.id, this.voter4)).to.be.true;
expect(await this.mock.proposalVotes(this.proposal.id)).to.deep.equal([
1n, // againstVotes
3n, // forVotes
1n, // abstainVotes
]);
});
});
}
});

View File

@@ -0,0 +1,185 @@
const { ethers } = require('hardhat');
const { expect } = require('chai');
const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers');
const { GovernorHelper } = require('../../helpers/governance');
const { ProposalState, 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 lateQuorumVoteExtension = 8n;
const quorum = ethers.parseEther('1');
const value = ethers.parseEther('1');
describe('GovernorPreventLateQuorum', function () {
for (const { Token, mode } of TOKENS) {
const fixture = async () => {
const [owner, proposer, voter1, voter2, voter3, voter4] = await ethers.getSigners();
const receiver = await ethers.deployContract('CallReceiverMock');
const token = await ethers.deployContract(Token, [tokenName, tokenSymbol, version]);
const mock = await ethers.deployContract('$GovernorPreventLateQuorumMock', [
name, // name
votingDelay, // initialVotingDelay
votingPeriod, // initialVotingPeriod
0n, // initialProposalThreshold
token, // tokenAddress
lateQuorumVoteExtension,
quorum,
]);
await owner.sendTransaction({ to: mock, value });
await token.$_mint(owner, tokenSupply);
const helper = new GovernorHelper(mock, mode);
await helper.connect(owner).delegate({ token, to: voter1, value: ethers.parseEther('10') });
await helper.connect(owner).delegate({ token, to: voter2, value: ethers.parseEther('7') });
await helper.connect(owner).delegate({ token, to: voter3, value: ethers.parseEther('5') });
await helper.connect(owner).delegate({ token, to: voter4, value: ethers.parseEther('2') });
return { owner, proposer, voter1, voter2, voter3, voter4, receiver, token, mock, helper };
};
describe(`using ${Token}`, function () {
beforeEach(async function () {
Object.assign(this, await loadFixture(fixture));
// initiate fresh proposal
this.proposal = this.helper.setProposal(
[
{
target: this.receiver.target,
data: this.receiver.interface.encodeFunctionData('mockFunction'),
value,
},
],
'<proposal description>',
);
});
it('deployment check', async function () {
expect(await this.mock.name()).to.equal(name);
expect(await this.mock.token()).to.equal(this.token);
expect(await this.mock.votingDelay()).to.equal(votingDelay);
expect(await this.mock.votingPeriod()).to.equal(votingPeriod);
expect(await this.mock.quorum(0)).to.equal(quorum);
expect(await this.mock.lateQuorumVoteExtension()).to.equal(lateQuorumVoteExtension);
});
it('nominal workflow unaffected', async function () {
const txPropose = await this.helper.connect(this.proposer).propose();
await this.helper.waitForSnapshot();
await this.helper.connect(this.voter1).vote({ support: VoteType.For });
await this.helper.connect(this.voter2).vote({ support: VoteType.For });
await this.helper.connect(this.voter3).vote({ support: VoteType.Against });
await this.helper.connect(this.voter4).vote({ support: VoteType.Abstain });
await this.helper.waitForDeadline();
await this.helper.execute();
expect(await this.mock.hasVoted(this.proposal.id, this.owner)).to.be.false;
expect(await this.mock.hasVoted(this.proposal.id, this.voter1)).to.be.true;
expect(await this.mock.hasVoted(this.proposal.id, this.voter2)).to.be.true;
expect(await this.mock.hasVoted(this.proposal.id, this.voter3)).to.be.true;
expect(await this.mock.hasVoted(this.proposal.id, this.voter4)).to.be.true;
expect(await this.mock.proposalVotes(this.proposal.id)).to.deep.equal([
ethers.parseEther('5'), // againstVotes
ethers.parseEther('17'), // forVotes
ethers.parseEther('2'), // abstainVotes
]);
const voteStart = (await time.clockFromReceipt[mode](txPropose)) + votingDelay;
const voteEnd = (await time.clockFromReceipt[mode](txPropose)) + votingDelay + votingPeriod;
expect(await this.mock.proposalSnapshot(this.proposal.id)).to.equal(voteStart);
expect(await this.mock.proposalDeadline(this.proposal.id)).to.equal(voteEnd);
await expect(txPropose)
.to.emit(this.mock, 'ProposalCreated')
.withArgs(
this.proposal.id,
this.proposer,
this.proposal.targets,
this.proposal.values,
this.proposal.signatures,
this.proposal.data,
voteStart,
voteEnd,
this.proposal.description,
);
});
it('Delay is extended to prevent last minute take-over', async function () {
const txPropose = await this.helper.connect(this.proposer).propose();
// compute original schedule
const snapshotTimepoint = (await time.clockFromReceipt[mode](txPropose)) + votingDelay;
const deadlineTimepoint = (await time.clockFromReceipt[mode](txPropose)) + votingDelay + votingPeriod;
expect(await this.mock.proposalSnapshot(this.proposal.id)).to.equal(snapshotTimepoint);
expect(await this.mock.proposalDeadline(this.proposal.id)).to.equal(deadlineTimepoint);
// wait for the last minute to vote
await this.helper.waitForDeadline(-1n);
const txVote = await this.helper.connect(this.voter2).vote({ support: VoteType.For });
// cannot execute yet
expect(await this.mock.state(this.proposal.id)).to.equal(ProposalState.Active);
// compute new extended schedule
const extendedDeadline = (await time.clockFromReceipt[mode](txVote)) + lateQuorumVoteExtension;
expect(await this.mock.proposalSnapshot(this.proposal.id)).to.equal(snapshotTimepoint);
expect(await this.mock.proposalDeadline(this.proposal.id)).to.equal(extendedDeadline);
// still possible to vote
await this.helper.connect(this.voter1).vote({ support: VoteType.Against });
await this.helper.waitForDeadline();
expect(await this.mock.state(this.proposal.id)).to.equal(ProposalState.Active);
await this.helper.waitForDeadline(1n);
expect(await this.mock.state(this.proposal.id)).to.equal(ProposalState.Defeated);
// check extension event
await expect(txVote).to.emit(this.mock, 'ProposalExtended').withArgs(this.proposal.id, extendedDeadline);
});
describe('onlyGovernance updates', function () {
it('setLateQuorumVoteExtension is protected', async function () {
await expect(this.mock.connect(this.owner).setLateQuorumVoteExtension(0n))
.to.be.revertedWithCustomError(this.mock, 'GovernorOnlyExecutor')
.withArgs(this.owner);
});
it('can setLateQuorumVoteExtension through governance', async function () {
this.helper.setProposal(
[
{
target: this.mock.target,
data: this.mock.interface.encodeFunctionData('setLateQuorumVoteExtension', [0n]),
},
],
'<proposal description>',
);
await this.helper.propose();
await this.helper.waitForSnapshot();
await this.helper.connect(this.voter1).vote({ support: VoteType.For });
await this.helper.waitForDeadline();
await expect(this.helper.execute())
.to.emit(this.mock, 'LateQuorumVoteExtensionSet')
.withArgs(lateQuorumVoteExtension, 0n);
expect(await this.mock.lateQuorumVoteExtension()).to.equal(0n);
});
});
});
}
});

View File

@@ -0,0 +1,155 @@
const { ethers } = require('hardhat');
const { expect } = require('chai');
const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers');
const { anyValue } = require('@nomicfoundation/hardhat-chai-matchers/withArgs');
const { PANIC_CODES } = require('@nomicfoundation/hardhat-chai-matchers/panic');
const { GovernorHelper, timelockSalt } = require('../../helpers/governance');
const { VoteType } = require('../../helpers/enums');
const TOKENS = [
{ Token: '$ERC20Votes', mode: 'blocknumber' },
{ Token: '$ERC20VotesTimestampMock', mode: 'timestamp' },
];
const DEFAULT_ADMIN_ROLE = ethers.ZeroHash;
const PROPOSER_ROLE = ethers.id('PROPOSER_ROLE');
const EXECUTOR_ROLE = ethers.id('EXECUTOR_ROLE');
const CANCELLER_ROLE = ethers.id('CANCELLER_ROLE');
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');
const delay = 3600n;
describe('GovernorStorage', function () {
for (const { Token, mode } of TOKENS) {
const fixture = async () => {
const [deployer, owner, proposer, voter1, voter2, voter3, voter4] = await ethers.getSigners();
const receiver = await ethers.deployContract('CallReceiverMock');
const token = await ethers.deployContract(Token, [tokenName, tokenSymbol, version]);
const timelock = await ethers.deployContract('TimelockController', [delay, [], [], deployer]);
const mock = await ethers.deployContract('$GovernorStorageMock', [
name,
votingDelay,
votingPeriod,
0n,
timelock,
token,
0n,
]);
await owner.sendTransaction({ to: timelock, value });
await token.$_mint(owner, tokenSupply);
await timelock.grantRole(PROPOSER_ROLE, mock);
await timelock.grantRole(PROPOSER_ROLE, owner);
await timelock.grantRole(CANCELLER_ROLE, mock);
await timelock.grantRole(CANCELLER_ROLE, owner);
await timelock.grantRole(EXECUTOR_ROLE, ethers.ZeroAddress);
await timelock.revokeRole(DEFAULT_ADMIN_ROLE, deployer);
const helper = new GovernorHelper(mock, mode);
await helper.connect(owner).delegate({ token, to: voter1, value: ethers.parseEther('10') });
await helper.connect(owner).delegate({ token, to: voter2, value: ethers.parseEther('7') });
await helper.connect(owner).delegate({ token, to: voter3, value: ethers.parseEther('5') });
await helper.connect(owner).delegate({ token, to: voter4, value: ethers.parseEther('2') });
return { deployer, owner, proposer, voter1, voter2, voter3, voter4, receiver, token, timelock, mock, helper };
};
describe(`using ${Token}`, function () {
beforeEach(async function () {
Object.assign(this, await loadFixture(fixture));
// initiate fresh proposal
this.proposal = this.helper.setProposal(
[
{
target: this.receiver.target,
data: this.receiver.interface.encodeFunctionData('mockFunction'),
value,
},
],
'<proposal description>',
);
this.proposal.timelockid = await this.timelock.hashOperationBatch(
...this.proposal.shortProposal.slice(0, 3),
ethers.ZeroHash,
timelockSalt(this.mock.target, this.proposal.shortProposal[3]),
);
});
describe('proposal indexing', function () {
it('before propose', async function () {
expect(await this.mock.proposalCount()).to.equal(0n);
await expect(this.mock.proposalDetailsAt(0n)).to.be.revertedWithPanic(PANIC_CODES.ARRAY_ACCESS_OUT_OF_BOUNDS);
await expect(this.mock.proposalDetails(this.proposal.id))
.to.be.revertedWithCustomError(this.mock, 'GovernorNonexistentProposal')
.withArgs(this.proposal.id);
});
it('after propose', async function () {
await this.helper.propose();
expect(await this.mock.proposalCount()).to.equal(1n);
expect(await this.mock.proposalDetailsAt(0n)).to.deep.equal([
this.proposal.id,
this.proposal.targets,
this.proposal.values,
this.proposal.data,
this.proposal.descriptionHash,
]);
expect(await this.mock.proposalDetails(this.proposal.id)).to.deep.equal([
this.proposal.targets,
this.proposal.values,
this.proposal.data,
this.proposal.descriptionHash,
]);
});
});
it('queue and execute by id', async function () {
await this.helper.propose();
await this.helper.waitForSnapshot();
await this.helper.connect(this.voter1).vote({ support: VoteType.For });
await this.helper.connect(this.voter2).vote({ support: VoteType.For });
await this.helper.connect(this.voter3).vote({ support: VoteType.Against });
await this.helper.connect(this.voter4).vote({ support: VoteType.Abstain });
await this.helper.waitForDeadline();
await expect(this.mock.queue(this.proposal.id))
.to.emit(this.mock, 'ProposalQueued')
.withArgs(this.proposal.id, anyValue)
.to.emit(this.timelock, 'CallScheduled')
.withArgs(this.proposal.timelockid, ...Array(6).fill(anyValue))
.to.emit(this.timelock, 'CallSalt')
.withArgs(this.proposal.timelockid, anyValue);
await this.helper.waitForEta();
await expect(this.mock.execute(this.proposal.id))
.to.emit(this.mock, 'ProposalExecuted')
.withArgs(this.proposal.id)
.to.emit(this.timelock, 'CallExecuted')
.withArgs(this.proposal.timelockid, ...Array(4).fill(anyValue))
.to.emit(this.receiver, 'MockFunctionCalled');
});
it('cancel by id', async function () {
await this.helper.connect(this.proposer).propose();
await expect(this.mock.connect(this.proposer).cancel(this.proposal.id))
.to.emit(this.mock, 'ProposalCanceled')
.withArgs(this.proposal.id);
});
});
}
});

View File

@@ -0,0 +1,864 @@
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 { hashOperation } = require('../../helpers/access-manager');
const { max } = require('../../helpers/math');
const { selector } = require('../../helpers/methods');
const { ProposalState, VoteType } = require('../../helpers/enums');
const time = require('../../helpers/time');
function prepareOperation({ sender, target, value = 0n, data = '0x' }) {
return {
id: hashOperation(sender, target, data),
operation: { target, value, data },
selector: data.slice(0, 10).padEnd(10, '0'),
};
}
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');
describe('GovernorTimelockAccess', function () {
for (const { Token, mode } of TOKENS) {
const fixture = async () => {
const [admin, voter1, voter2, voter3, voter4, other] = await ethers.getSigners();
const manager = await ethers.deployContract('$AccessManager', [admin]);
const receiver = await ethers.deployContract('$AccessManagedTarget', [manager]);
const token = await ethers.deployContract(Token, [tokenName, tokenSymbol, version]);
const mock = await ethers.deployContract('$GovernorTimelockAccessMock', [
name,
votingDelay,
votingPeriod,
0n,
manager,
0n,
token,
0n,
]);
await admin.sendTransaction({ to: mock, value });
await token.$_mint(admin, tokenSupply);
const helper = new GovernorHelper(mock, mode);
await helper.connect(admin).delegate({ token, to: voter1, value: ethers.parseEther('10') });
await helper.connect(admin).delegate({ token, to: voter2, value: ethers.parseEther('7') });
await helper.connect(admin).delegate({ token, to: voter3, value: ethers.parseEther('5') });
await helper.connect(admin).delegate({ token, to: voter4, value: ethers.parseEther('2') });
return { admin, voter1, voter2, voter3, voter4, other, manager, receiver, token, mock, helper };
};
describe(`using ${Token}`, function () {
beforeEach(async function () {
Object.assign(this, await loadFixture(fixture));
// restricted proposal
this.restricted = prepareOperation({
sender: this.mock.target,
target: this.receiver.target,
data: this.receiver.interface.encodeFunctionData('fnRestricted'),
});
this.unrestricted = prepareOperation({
sender: this.mock.target,
target: this.receiver.target,
data: this.receiver.interface.encodeFunctionData('fnUnrestricted'),
});
this.fallback = prepareOperation({
sender: this.mock.target,
target: this.receiver.target,
data: '0x1234',
});
});
it('accepts ether transfers', async function () {
await this.admin.sendTransaction({ to: this.mock, value: 1n });
});
it('post deployment check', async function () {
expect(await this.mock.name()).to.equal(name);
expect(await this.mock.token()).to.equal(this.token);
expect(await this.mock.votingDelay()).to.equal(votingDelay);
expect(await this.mock.votingPeriod()).to.equal(votingPeriod);
expect(await this.mock.quorum(0n)).to.equal(0n);
expect(await this.mock.accessManager()).to.equal(this.manager);
});
it('sets base delay (seconds)', async function () {
const baseDelay = time.duration.hours(10n);
// Only through governance
await expect(this.mock.connect(this.voter1).setBaseDelaySeconds(baseDelay))
.to.be.revertedWithCustomError(this.mock, 'GovernorOnlyExecutor')
.withArgs(this.voter1);
this.proposal = await this.helper.setProposal(
[
{
target: this.mock.target,
data: this.mock.interface.encodeFunctionData('setBaseDelaySeconds', [baseDelay]),
},
],
'descr',
);
await this.helper.propose();
await this.helper.waitForSnapshot();
await this.helper.connect(this.voter1).vote({ support: VoteType.For });
await this.helper.waitForDeadline();
await expect(this.helper.execute()).to.emit(this.mock, 'BaseDelaySet').withArgs(0n, baseDelay);
expect(await this.mock.baseDelaySeconds()).to.equal(baseDelay);
});
it('sets access manager ignored', async function () {
const selectors = ['0x12345678', '0x87654321', '0xabcdef01'];
// Only through governance
await expect(this.mock.connect(this.voter1).setAccessManagerIgnored(this.other, selectors, true))
.to.be.revertedWithCustomError(this.mock, 'GovernorOnlyExecutor')
.withArgs(this.voter1);
// Ignore
await this.helper.setProposal(
[
{
target: this.mock.target,
data: this.mock.interface.encodeFunctionData('setAccessManagerIgnored', [
this.other.address,
selectors,
true,
]),
},
],
'descr',
);
await this.helper.propose();
await this.helper.waitForSnapshot();
await this.helper.connect(this.voter1).vote({ support: VoteType.For });
await this.helper.waitForDeadline();
const ignoreReceipt = this.helper.execute();
for (const selector of selectors) {
await expect(ignoreReceipt)
.to.emit(this.mock, 'AccessManagerIgnoredSet')
.withArgs(this.other, selector, true);
expect(await this.mock.isAccessManagerIgnored(this.other, selector)).to.be.true;
}
// Unignore
await this.helper.setProposal(
[
{
target: this.mock.target,
data: this.mock.interface.encodeFunctionData('setAccessManagerIgnored', [
this.other.address,
selectors,
false,
]),
},
],
'descr',
);
await this.helper.propose();
await this.helper.waitForSnapshot();
await this.helper.connect(this.voter1).vote({ support: VoteType.For });
await this.helper.waitForDeadline();
const unignoreReceipt = this.helper.execute();
for (const selector of selectors) {
await expect(unignoreReceipt)
.to.emit(this.mock, 'AccessManagerIgnoredSet')
.withArgs(this.other, selector, false);
expect(await this.mock.isAccessManagerIgnored(this.other, selector)).to.be.false;
}
});
it('sets access manager ignored when target is the governor', async function () {
const selectors = ['0x12345678', '0x87654321', '0xabcdef01'];
await this.helper.setProposal(
[
{
target: this.mock.target,
data: this.mock.interface.encodeFunctionData('setAccessManagerIgnored', [
this.mock.target,
selectors,
true,
]),
},
],
'descr',
);
await this.helper.propose();
await this.helper.waitForSnapshot();
await this.helper.connect(this.voter1).vote({ support: VoteType.For });
await this.helper.waitForDeadline();
const tx = this.helper.execute();
for (const selector of selectors) {
await expect(tx).to.emit(this.mock, 'AccessManagerIgnoredSet').withArgs(this.mock, selector, true);
expect(await this.mock.isAccessManagerIgnored(this.mock, selector)).to.be.true;
}
});
it('does not need to queue proposals with no delay', async function () {
const roleId = 1n;
const executionDelay = 0n;
const baseDelay = 0n;
// Set execution delay
await this.manager.connect(this.admin).setTargetFunctionRole(this.receiver, [this.restricted.selector], roleId);
await this.manager.connect(this.admin).grantRole(roleId, this.mock, executionDelay);
// Set base delay
await this.mock.$_setBaseDelaySeconds(baseDelay);
await this.helper.setProposal([this.restricted.operation], 'descr');
await this.helper.propose();
expect(await this.mock.proposalNeedsQueuing(this.helper.currentProposal.id)).to.be.false;
});
it('needs to queue proposals with any delay', async function () {
const roleId = 1n;
const delays = [
[time.duration.hours(1n), time.duration.hours(2n)],
[time.duration.hours(2n), time.duration.hours(1n)],
];
for (const [executionDelay, baseDelay] of delays) {
// Set execution delay
await this.manager
.connect(this.admin)
.setTargetFunctionRole(this.receiver, [this.restricted.selector], roleId);
await this.manager.connect(this.admin).grantRole(roleId, this.mock, executionDelay);
// Set base delay
await this.mock.$_setBaseDelaySeconds(baseDelay);
await this.helper.setProposal(
[this.restricted.operation],
`executionDelay=${executionDelay.toString()}}baseDelay=${baseDelay.toString()}}`,
);
await this.helper.propose();
expect(await this.mock.proposalNeedsQueuing(this.helper.currentProposal.id)).to.be.true;
}
});
describe('execution plan', function () {
it('returns plan for delayed operations', async function () {
const roleId = 1n;
const delays = [
[time.duration.hours(1n), time.duration.hours(2n)],
[time.duration.hours(2n), time.duration.hours(1n)],
];
for (const [executionDelay, baseDelay] of delays) {
// Set execution delay
await this.manager
.connect(this.admin)
.setTargetFunctionRole(this.receiver, [this.restricted.selector], roleId);
await this.manager.connect(this.admin).grantRole(roleId, this.mock, executionDelay);
// Set base delay
await this.mock.$_setBaseDelaySeconds(baseDelay);
this.proposal = await this.helper.setProposal(
[this.restricted.operation],
`executionDelay=${executionDelay.toString()}}baseDelay=${baseDelay.toString()}}`,
);
await this.helper.propose();
expect(await this.mock.proposalExecutionPlan(this.proposal.id)).to.deep.equal([
max(baseDelay, executionDelay),
[true],
[true],
]);
}
});
it('returns plan for not delayed operations', async function () {
const roleId = 1n;
const executionDelay = 0n;
const baseDelay = 0n;
// Set execution delay
await this.manager
.connect(this.admin)
.setTargetFunctionRole(this.receiver, [this.restricted.selector], roleId);
await this.manager.connect(this.admin).grantRole(roleId, this.mock, executionDelay);
// Set base delay
await this.mock.$_setBaseDelaySeconds(baseDelay);
this.proposal = await this.helper.setProposal([this.restricted.operation], `descr`);
await this.helper.propose();
expect(await this.mock.proposalExecutionPlan(this.proposal.id)).to.deep.equal([0n, [true], [false]]);
});
it('returns plan for an operation ignoring the manager', async function () {
await this.mock.$_setAccessManagerIgnored(this.receiver, this.restricted.selector, true);
const roleId = 1n;
const delays = [
[time.duration.hours(1n), time.duration.hours(2n)],
[time.duration.hours(2n), time.duration.hours(1n)],
];
for (const [executionDelay, baseDelay] of delays) {
// Set execution delay
await this.manager
.connect(this.admin)
.setTargetFunctionRole(this.receiver, [this.restricted.selector], roleId);
await this.manager.connect(this.admin).grantRole(roleId, this.mock, executionDelay);
// Set base delay
await this.mock.$_setBaseDelaySeconds(baseDelay);
this.proposal = await this.helper.setProposal(
[this.restricted.operation],
`executionDelay=${executionDelay.toString()}}baseDelay=${baseDelay.toString()}}`,
);
await this.helper.propose();
expect(await this.mock.proposalExecutionPlan(this.proposal.id)).to.deep.equal([
baseDelay,
[false],
[false],
]);
}
});
});
describe('base delay only', function () {
for (const [delay, queue] of [
[0, true],
[0, false],
[1000, true],
]) {
it(`delay ${delay}, ${queue ? 'with' : 'without'} queuing`, async function () {
await this.mock.$_setBaseDelaySeconds(delay);
this.proposal = await this.helper.setProposal([this.unrestricted.operation], 'descr');
await this.helper.propose();
await this.helper.waitForSnapshot();
await this.helper.connect(this.voter1).vote({ support: VoteType.For });
await this.helper.waitForDeadline();
if (await this.mock.proposalNeedsQueuing(this.proposal.id)) {
expect(await this.helper.queue())
.to.emit(this.mock, 'ProposalQueued')
.withArgs(this.proposal.id, anyValue);
}
if (delay > 0) {
await this.helper.waitForEta();
}
await expect(this.helper.execute())
.to.emit(this.mock, 'ProposalExecuted')
.withArgs(this.proposal.id)
.to.emit(this.receiver, 'CalledUnrestricted');
});
}
});
it('reverts when an operation is executed before eta', async function () {
const delay = time.duration.hours(2n);
await this.mock.$_setBaseDelaySeconds(delay);
this.proposal = await this.helper.setProposal([this.unrestricted.operation], 'descr');
await this.helper.propose();
await this.helper.waitForSnapshot();
await this.helper.connect(this.voter1).vote({ support: VoteType.For });
await this.helper.waitForDeadline();
await this.helper.queue();
await expect(this.helper.execute())
.to.be.revertedWithCustomError(this.mock, 'GovernorUnmetDelay')
.withArgs(this.proposal.id, await this.mock.proposalEta(this.proposal.id));
});
it('reverts with a proposal including multiple operations but one of those was cancelled in the manager', async function () {
const delay = time.duration.hours(2n);
const roleId = 1n;
await this.manager.connect(this.admin).setTargetFunctionRole(this.receiver, [this.restricted.selector], roleId);
await this.manager.connect(this.admin).grantRole(roleId, this.mock, delay);
// Set proposals
const original = new GovernorHelper(this.mock, mode);
await original.setProposal([this.restricted.operation, this.unrestricted.operation], 'descr');
// Go through all the governance process
await original.propose();
await original.waitForSnapshot();
await original.connect(this.voter1).vote({ support: VoteType.For });
await original.waitForDeadline();
await original.queue();
await original.waitForEta();
// Suddenly cancel one of the proposed operations in the manager
await this.manager
.connect(this.admin)
.cancel(this.mock, this.restricted.operation.target, this.restricted.operation.data);
// Reschedule the same operation in a different proposal to avoid "AccessManagerNotScheduled" error
const rescheduled = new GovernorHelper(this.mock, mode);
await rescheduled.setProposal([this.restricted.operation], 'descr');
await rescheduled.propose();
await rescheduled.waitForSnapshot();
await rescheduled.connect(this.voter1).vote({ support: VoteType.For });
await rescheduled.waitForDeadline();
await rescheduled.queue(); // This will schedule it again in the manager
await rescheduled.waitForEta();
// Attempt to execute
await expect(original.execute())
.to.be.revertedWithCustomError(this.mock, 'GovernorMismatchedNonce')
.withArgs(original.currentProposal.id, 1, 2);
});
it('single operation with access manager delay', async function () {
const delay = 1000n;
const roleId = 1n;
await this.manager.connect(this.admin).setTargetFunctionRole(this.receiver, [this.restricted.selector], roleId);
await this.manager.connect(this.admin).grantRole(roleId, this.mock, delay);
this.proposal = await this.helper.setProposal([this.restricted.operation], 'descr');
await this.helper.propose();
await this.helper.waitForSnapshot();
await this.helper.connect(this.voter1).vote({ support: VoteType.For });
await this.helper.waitForDeadline();
const txQueue = await this.helper.queue();
await this.helper.waitForEta();
const txExecute = await this.helper.execute();
await expect(txQueue)
.to.emit(this.mock, 'ProposalQueued')
.withArgs(this.proposal.id, anyValue)
.to.emit(this.manager, 'OperationScheduled')
.withArgs(
this.restricted.id,
1n,
(await time.clockFromReceipt.timestamp(txQueue)) + delay,
this.mock.target,
this.restricted.operation.target,
this.restricted.operation.data,
);
await expect(txExecute)
.to.emit(this.mock, 'ProposalExecuted')
.withArgs(this.proposal.id)
.to.emit(this.manager, 'OperationExecuted')
.withArgs(this.restricted.id, 1n)
.to.emit(this.receiver, 'CalledRestricted');
});
it('bundle of varied operations', async function () {
const managerDelay = 1000n;
const roleId = 1n;
const baseDelay = managerDelay * 2n;
await this.mock.$_setBaseDelaySeconds(baseDelay);
await this.manager.connect(this.admin).setTargetFunctionRole(this.receiver, [this.restricted.selector], roleId);
await this.manager.connect(this.admin).grantRole(roleId, this.mock, managerDelay);
this.proposal = await this.helper.setProposal(
[this.restricted.operation, this.unrestricted.operation, this.fallback.operation],
'descr',
);
await this.helper.propose();
await this.helper.waitForSnapshot();
await this.helper.connect(this.voter1).vote({ support: VoteType.For });
await this.helper.waitForDeadline();
const txQueue = await this.helper.queue();
await this.helper.waitForEta();
const txExecute = await this.helper.execute();
await expect(txQueue)
.to.emit(this.mock, 'ProposalQueued')
.withArgs(this.proposal.id, anyValue)
.to.emit(this.manager, 'OperationScheduled')
.withArgs(
this.restricted.id,
1n,
(await time.clockFromReceipt.timestamp(txQueue)) + baseDelay,
this.mock.target,
this.restricted.operation.target,
this.restricted.operation.data,
);
await expect(txExecute)
.to.emit(this.mock, 'ProposalExecuted')
.withArgs(this.proposal.id)
.to.emit(this.manager, 'OperationExecuted')
.withArgs(this.restricted.id, 1n)
.to.emit(this.receiver, 'CalledRestricted')
.to.emit(this.receiver, 'CalledUnrestricted')
.to.emit(this.receiver, 'CalledFallback');
});
describe('cancel', function () {
const delay = 1000n;
const roleId = 1n;
beforeEach(async function () {
await this.manager
.connect(this.admin)
.setTargetFunctionRole(this.receiver, [this.restricted.selector], roleId);
await this.manager.connect(this.admin).grantRole(roleId, this.mock, delay);
});
it('cancels restricted with delay after queue (internal)', async function () {
this.proposal = await this.helper.setProposal([this.restricted.operation], 'descr');
await this.helper.propose();
await this.helper.waitForSnapshot();
await this.helper.connect(this.voter1).vote({ support: VoteType.For });
await this.helper.waitForDeadline();
await this.helper.queue();
await expect(this.helper.cancel('internal'))
.to.emit(this.mock, 'ProposalCanceled')
.withArgs(this.proposal.id)
.to.emit(this.manager, 'OperationCanceled')
.withArgs(this.restricted.id, 1n);
await this.helper.waitForEta();
await expect(this.helper.execute())
.to.be.revertedWithCustomError(this.mock, 'GovernorUnexpectedProposalState')
.withArgs(
this.proposal.id,
ProposalState.Canceled,
GovernorHelper.proposalStatesToBitMap([ProposalState.Succeeded, ProposalState.Queued]),
);
});
it('cancels restricted with queueing if the same operation is part of a more recent proposal (internal)', async function () {
// Set proposals
const original = new GovernorHelper(this.mock, mode);
await original.setProposal([this.restricted.operation], 'descr');
// Go through all the governance process
await original.propose();
await original.waitForSnapshot();
await original.connect(this.voter1).vote({ support: VoteType.For });
await original.waitForDeadline();
await original.queue();
// Cancel the operation in the manager
await this.manager
.connect(this.admin)
.cancel(this.mock, this.restricted.operation.target, this.restricted.operation.data);
// Another proposal is added with the same operation
const rescheduled = new GovernorHelper(this.mock, mode);
await rescheduled.setProposal([this.restricted.operation], 'another descr');
// Queue the new proposal
await rescheduled.propose();
await rescheduled.waitForSnapshot();
await rescheduled.connect(this.voter1).vote({ support: VoteType.For });
await rescheduled.waitForDeadline();
await rescheduled.queue(); // This will schedule it again in the manager
// Cancel
const eta = await this.mock.proposalEta(rescheduled.currentProposal.id);
await expect(original.cancel('internal'))
.to.emit(this.mock, 'ProposalCanceled')
.withArgs(original.currentProposal.id);
await time.clock.timestamp().then(clock => time.increaseTo.timestamp(max(clock + 1n, eta)));
await expect(original.execute())
.to.be.revertedWithCustomError(this.mock, 'GovernorUnexpectedProposalState')
.withArgs(
original.currentProposal.id,
ProposalState.Canceled,
GovernorHelper.proposalStatesToBitMap([ProposalState.Succeeded, ProposalState.Queued]),
);
});
it('cancels unrestricted with queueing (internal)', async function () {
this.proposal = await this.helper.setProposal([this.unrestricted.operation], 'descr');
await this.helper.propose();
await this.helper.waitForSnapshot();
await this.helper.connect(this.voter1).vote({ support: VoteType.For });
await this.helper.waitForDeadline();
await this.helper.queue();
const eta = await this.mock.proposalEta(this.proposal.id);
await expect(this.helper.cancel('internal'))
.to.emit(this.mock, 'ProposalCanceled')
.withArgs(this.proposal.id);
await time.clock.timestamp().then(clock => time.increaseTo.timestamp(max(clock + 1n, eta)));
await expect(this.helper.execute())
.to.be.revertedWithCustomError(this.mock, 'GovernorUnexpectedProposalState')
.withArgs(
this.proposal.id,
ProposalState.Canceled,
GovernorHelper.proposalStatesToBitMap([ProposalState.Succeeded, ProposalState.Queued]),
);
});
it('cancels unrestricted without queueing (internal)', async function () {
this.proposal = await this.helper.setProposal([this.unrestricted.operation], 'descr');
await this.helper.propose();
await this.helper.waitForSnapshot();
await this.helper.connect(this.voter1).vote({ support: VoteType.For });
await this.helper.waitForDeadline();
await expect(this.helper.cancel('internal'))
.to.emit(this.mock, 'ProposalCanceled')
.withArgs(this.proposal.id);
await expect(this.helper.execute())
.to.be.revertedWithCustomError(this.mock, 'GovernorUnexpectedProposalState')
.withArgs(
this.proposal.id,
ProposalState.Canceled,
GovernorHelper.proposalStatesToBitMap([ProposalState.Succeeded, ProposalState.Queued]),
);
});
it('cancels calls already canceled by guardian', async function () {
const operationA = { target: this.receiver.target, data: this.restricted.selector + '00' };
const operationB = { target: this.receiver.target, data: this.restricted.selector + '01' };
const operationC = { target: this.receiver.target, data: this.restricted.selector + '02' };
const operationAId = hashOperation(this.mock.target, operationA.target, operationA.data);
const operationBId = hashOperation(this.mock.target, operationB.target, operationB.data);
const proposal1 = new GovernorHelper(this.mock, mode);
const proposal2 = new GovernorHelper(this.mock, mode);
proposal1.setProposal([operationA, operationB], 'proposal A+B');
proposal2.setProposal([operationA, operationC], 'proposal A+C');
for (const p of [proposal1, proposal2]) {
await p.propose();
await p.waitForSnapshot();
await p.connect(this.voter1).vote({ support: VoteType.For });
await p.waitForDeadline();
}
// Can queue the first proposal
await proposal1.queue();
// Cannot queue the second proposal: operation A already scheduled with delay
await expect(proposal2.queue())
.to.be.revertedWithCustomError(this.manager, 'AccessManagerAlreadyScheduled')
.withArgs(operationAId);
// Admin cancels operation B on the manager
await this.manager.connect(this.admin).cancel(this.mock, operationB.target, operationB.data);
// Still cannot queue the second proposal: operation A already scheduled with delay
await expect(proposal2.queue())
.to.be.revertedWithCustomError(this.manager, 'AccessManagerAlreadyScheduled')
.withArgs(operationAId);
await proposal1.waitForEta();
// Cannot execute first proposal: operation B has been canceled
await expect(proposal1.execute())
.to.be.revertedWithCustomError(this.manager, 'AccessManagerNotScheduled')
.withArgs(operationBId);
// Cancel the first proposal to release operation A
await proposal1.cancel('internal');
// can finally queue the second proposal
await proposal2.queue();
await proposal2.waitForEta();
// Can execute second proposal
await proposal2.execute();
});
});
describe('ignore AccessManager', function () {
it('defaults', async function () {
expect(await this.mock.isAccessManagerIgnored(this.receiver, this.restricted.selector)).to.be.false;
expect(await this.mock.isAccessManagerIgnored(this.mock, '0x12341234')).to.be.true;
});
it('internal setter', async function () {
await expect(this.mock.$_setAccessManagerIgnored(this.receiver, this.restricted.selector, true))
.to.emit(this.mock, 'AccessManagerIgnoredSet')
.withArgs(this.receiver, this.restricted.selector, true);
expect(await this.mock.isAccessManagerIgnored(this.receiver, this.restricted.selector)).to.be.true;
await expect(this.mock.$_setAccessManagerIgnored(this.mock, '0x12341234', false))
.to.emit(this.mock, 'AccessManagerIgnoredSet')
.withArgs(this.mock, '0x12341234', false);
expect(await this.mock.isAccessManagerIgnored(this.mock, '0x12341234')).to.be.false;
});
it('external setter', async function () {
const setAccessManagerIgnored = (...args) =>
this.mock.interface.encodeFunctionData('setAccessManagerIgnored', args);
await this.helper.setProposal(
[
{
target: this.mock.target,
data: setAccessManagerIgnored(
this.receiver.target,
[this.restricted.selector, this.unrestricted.selector],
true,
),
},
{
target: this.mock.target,
data: setAccessManagerIgnored(this.mock.target, ['0x12341234', '0x67896789'], false),
},
],
'descr',
);
await this.helper.propose();
await this.helper.waitForSnapshot();
await this.helper.connect(this.voter1).vote({ support: VoteType.For });
await this.helper.waitForDeadline();
await expect(this.helper.execute()).to.emit(this.mock, 'AccessManagerIgnoredSet');
expect(await this.mock.isAccessManagerIgnored(this.receiver, this.restricted.selector)).to.be.true;
expect(await this.mock.isAccessManagerIgnored(this.receiver, this.unrestricted.selector)).to.be.true;
expect(await this.mock.isAccessManagerIgnored(this.mock, '0x12341234')).to.be.false;
expect(await this.mock.isAccessManagerIgnored(this.mock, '0x67896789')).to.be.false;
});
it('locked function', async function () {
const setAccessManagerIgnored = selector('setAccessManagerIgnored(address,bytes4[],bool)');
await expect(
this.mock.$_setAccessManagerIgnored(this.mock, setAccessManagerIgnored, true),
).to.be.revertedWithCustomError(this.mock, 'GovernorLockedIgnore');
await this.mock.$_setAccessManagerIgnored(this.receiver, setAccessManagerIgnored, true);
});
it('ignores access manager', async function () {
const amount = 100n;
const target = this.token.target;
const data = this.token.interface.encodeFunctionData('transfer', [this.voter4.address, amount]);
const selector = data.slice(0, 10);
await this.token.$_mint(this.mock, amount);
const roleId = 1n;
await this.manager.connect(this.admin).setTargetFunctionRole(target, [selector], roleId);
await this.manager.connect(this.admin).grantRole(roleId, this.mock, 0);
await this.helper.setProposal([{ target, data }], 'descr #1');
await this.helper.propose();
await this.helper.waitForSnapshot();
await this.helper.connect(this.voter1).vote({ support: VoteType.For });
await this.helper.waitForDeadline();
await expect(this.helper.execute())
.to.be.revertedWithCustomError(this.token, 'ERC20InsufficientBalance')
.withArgs(this.manager, 0n, amount);
await this.mock.$_setAccessManagerIgnored(target, selector, true);
await this.helper.setProposal([{ target, data }], 'descr #2');
await this.helper.propose();
await this.helper.waitForSnapshot();
await this.helper.connect(this.voter1).vote({ support: VoteType.For });
await this.helper.waitForDeadline();
await expect(this.helper.execute()).to.emit(this.token, 'Transfer').withArgs(this.mock, this.voter4, amount);
});
});
describe('operating on an Ownable contract', function () {
const method = selector('$_checkOwner()');
beforeEach(async function () {
this.ownable = await ethers.deployContract('$Ownable', [this.manager]);
this.operation = {
target: this.ownable.target,
data: this.ownable.interface.encodeFunctionData('$_checkOwner'),
};
});
it('succeeds with delay', async function () {
const roleId = 1n;
const executionDelay = time.duration.hours(2n);
const baseDelay = time.duration.hours(1n);
// Set execution delay
await this.manager.connect(this.admin).setTargetFunctionRole(this.ownable, [method], roleId);
await this.manager.connect(this.admin).grantRole(roleId, this.mock, executionDelay);
// Set base delay
await this.mock.$_setBaseDelaySeconds(baseDelay);
await this.helper.setProposal([this.operation], `descr`);
await this.helper.propose();
await this.helper.waitForSnapshot();
await this.helper.connect(this.voter1).vote({ support: VoteType.For });
await this.helper.waitForDeadline();
await this.helper.queue();
await this.helper.waitForEta();
await this.helper.execute(); // Don't revert
});
it('succeeds without delay', async function () {
const roleId = 1n;
const executionDelay = 0n;
const baseDelay = 0n;
// Set execution delay
await this.manager.connect(this.admin).setTargetFunctionRole(this.ownable, [method], roleId);
await this.manager.connect(this.admin).grantRole(roleId, this.mock, executionDelay);
// Set base delay
await this.mock.$_setBaseDelaySeconds(baseDelay);
await this.helper.setProposal([this.operation], `descr`);
await this.helper.propose();
await this.helper.waitForSnapshot();
await this.helper.connect(this.voter1).vote({ support: VoteType.For });
await this.helper.waitForDeadline();
await this.helper.execute(); // Don't revert
});
});
});
}
});

View File

@@ -0,0 +1,448 @@
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 { ProposalState, 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');
const defaultDelay = time.duration.days(2n);
describe('GovernorTimelockCompound', function () {
for (const { Token, mode } of TOKENS) {
const fixture = async () => {
const [deployer, owner, voter1, voter2, voter3, voter4, other] = await ethers.getSigners();
const receiver = await ethers.deployContract('CallReceiverMock');
const token = await ethers.deployContract(Token, [tokenName, tokenSymbol, version]);
const predictGovernor = await deployer
.getNonce()
.then(nonce => ethers.getCreateAddress({ from: deployer.address, nonce: nonce + 1 }));
const timelock = await ethers.deployContract('CompTimelock', [predictGovernor, defaultDelay]);
const mock = await ethers.deployContract('$GovernorTimelockCompoundMock', [
name,
votingDelay,
votingPeriod,
0n,
timelock,
token,
0n,
]);
await owner.sendTransaction({ to: timelock, value });
await token.$_mint(owner, tokenSupply);
const helper = new GovernorHelper(mock, mode);
await helper.connect(owner).delegate({ token, to: voter1, value: ethers.parseEther('10') });
await helper.connect(owner).delegate({ token, to: voter2, value: ethers.parseEther('7') });
await helper.connect(owner).delegate({ token, to: voter3, value: ethers.parseEther('5') });
await helper.connect(owner).delegate({ token, to: voter4, value: ethers.parseEther('2') });
return { deployer, owner, voter1, voter2, voter3, voter4, other, receiver, token, mock, timelock, helper };
};
describe(`using ${Token}`, function () {
beforeEach(async function () {
Object.assign(this, await loadFixture(fixture));
// default proposal
this.proposal = this.helper.setProposal(
[
{
target: this.receiver.target,
value,
data: this.receiver.interface.encodeFunctionData('mockFunction'),
},
],
'<proposal description>',
);
});
it("doesn't accept ether transfers", async function () {
await expect(this.owner.sendTransaction({ to: this.mock, value: 1n })).to.be.revertedWithCustomError(
this.mock,
'GovernorDisabledDeposit',
);
});
it('post deployment check', async function () {
expect(await this.mock.name()).to.equal(name);
expect(await this.mock.token()).to.equal(this.token);
expect(await this.mock.votingDelay()).to.equal(votingDelay);
expect(await this.mock.votingPeriod()).to.equal(votingPeriod);
expect(await this.mock.quorum(0n)).to.equal(0n);
expect(await this.mock.timelock()).to.equal(this.timelock);
expect(await this.timelock.admin()).to.equal(this.mock);
});
it('nominal', async function () {
expect(await this.mock.proposalEta(this.proposal.id)).to.equal(0n);
expect(await this.mock.proposalNeedsQueuing(this.proposal.id)).to.be.true;
await this.helper.propose();
await this.helper.waitForSnapshot();
await this.helper.connect(this.voter1).vote({ support: VoteType.For });
await this.helper.connect(this.voter2).vote({ support: VoteType.For });
await this.helper.connect(this.voter3).vote({ support: VoteType.Against });
await this.helper.connect(this.voter4).vote({ support: VoteType.Abstain });
await this.helper.waitForDeadline();
const txQueue = await this.helper.queue();
const eta = (await time.clockFromReceipt.timestamp(txQueue)) + defaultDelay;
expect(await this.mock.proposalEta(this.proposal.id)).to.equal(eta);
expect(await this.mock.proposalNeedsQueuing(this.proposal.id)).to.be.true;
await this.helper.waitForEta();
const txExecute = await this.helper.execute();
await expect(txQueue)
.to.emit(this.mock, 'ProposalQueued')
.withArgs(this.proposal.id, eta)
.to.emit(this.timelock, 'QueueTransaction')
.withArgs(...Array(5).fill(anyValue), eta);
await expect(txExecute)
.to.emit(this.mock, 'ProposalExecuted')
.withArgs(this.proposal.id)
.to.emit(this.timelock, 'ExecuteTransaction')
.withArgs(...Array(5).fill(anyValue), eta)
.to.emit(this.receiver, 'MockFunctionCalled');
});
describe('should revert', function () {
describe('on queue', function () {
it('if already queued', async function () {
await this.helper.propose();
await this.helper.waitForSnapshot();
await this.helper.connect(this.voter1).vote({ support: VoteType.For });
await this.helper.waitForDeadline();
await this.helper.queue();
await expect(this.helper.queue())
.to.be.revertedWithCustomError(this.mock, 'GovernorUnexpectedProposalState')
.withArgs(
this.proposal.id,
ProposalState.Queued,
GovernorHelper.proposalStatesToBitMap([ProposalState.Succeeded]),
);
});
it('if proposal contains duplicate calls', async function () {
const action = {
target: this.token.target,
data: this.token.interface.encodeFunctionData('approve', [this.receiver.target, ethers.MaxUint256]),
};
const { id } = this.helper.setProposal([action, action], '<proposal description>');
await this.helper.propose();
await this.helper.waitForSnapshot();
await this.helper.connect(this.voter1).vote({ support: VoteType.For });
await this.helper.waitForDeadline();
await expect(this.helper.queue())
.to.be.revertedWithCustomError(this.mock, 'GovernorAlreadyQueuedProposal')
.withArgs(id);
await expect(this.helper.execute())
.to.be.revertedWithCustomError(this.mock, 'GovernorNotQueuedProposal')
.withArgs(id);
});
});
describe('on execute', function () {
it('if not queued', async function () {
await this.helper.propose();
await this.helper.waitForSnapshot();
await this.helper.connect(this.voter1).vote({ support: VoteType.For });
await this.helper.waitForDeadline(1n);
expect(await this.mock.state(this.proposal.id)).to.equal(ProposalState.Succeeded);
await expect(this.helper.execute())
.to.be.revertedWithCustomError(this.mock, 'GovernorNotQueuedProposal')
.withArgs(this.proposal.id);
});
it('if too early', async function () {
await this.helper.propose();
await this.helper.waitForSnapshot();
await this.helper.connect(this.voter1).vote({ support: VoteType.For });
await this.helper.waitForDeadline();
await this.helper.queue();
expect(await this.mock.state(this.proposal.id)).to.equal(ProposalState.Queued);
await expect(this.helper.execute()).to.be.rejectedWith(
"Timelock::executeTransaction: Transaction hasn't surpassed time lock",
);
});
it('if too late', async function () {
await this.helper.propose();
await this.helper.waitForSnapshot();
await this.helper.connect(this.voter1).vote({ support: VoteType.For });
await this.helper.waitForDeadline();
await this.helper.queue();
await this.helper.waitForEta(time.duration.days(30));
expect(await this.mock.state(this.proposal.id)).to.equal(ProposalState.Expired);
await expect(this.helper.execute())
.to.be.revertedWithCustomError(this.mock, 'GovernorUnexpectedProposalState')
.withArgs(
this.proposal.id,
ProposalState.Expired,
GovernorHelper.proposalStatesToBitMap([ProposalState.Succeeded, ProposalState.Queued]),
);
});
it('if already executed', async function () {
await this.helper.propose();
await this.helper.waitForSnapshot();
await this.helper.connect(this.voter1).vote({ support: VoteType.For });
await this.helper.waitForDeadline();
await this.helper.queue();
await this.helper.waitForEta();
await this.helper.execute();
await expect(this.helper.execute())
.to.be.revertedWithCustomError(this.mock, 'GovernorUnexpectedProposalState')
.withArgs(
this.proposal.id,
ProposalState.Executed,
GovernorHelper.proposalStatesToBitMap([ProposalState.Succeeded, ProposalState.Queued]),
);
});
});
describe('on safe receive', function () {
describe('ERC721', function () {
const tokenId = 1n;
beforeEach(async function () {
this.token = await ethers.deployContract('$ERC721', ['Non Fungible Token', 'NFT']);
await this.token.$_mint(this.owner, tokenId);
});
it("can't receive an ERC721 safeTransfer", async function () {
await expect(
this.token.connect(this.owner).safeTransferFrom(this.owner, this.mock, tokenId),
).to.be.revertedWithCustomError(this.mock, 'GovernorDisabledDeposit');
});
});
describe('ERC1155', function () {
const tokenIds = {
1: 1000n,
2: 2000n,
3: 3000n,
};
beforeEach(async function () {
this.token = await ethers.deployContract('$ERC1155', ['https://token-cdn-domain/{id}.json']);
await this.token.$_mintBatch(this.owner, Object.keys(tokenIds), Object.values(tokenIds), '0x');
});
it("can't receive ERC1155 safeTransfer", async function () {
await expect(
this.token.connect(this.owner).safeTransferFrom(
this.owner,
this.mock,
...Object.entries(tokenIds)[0], // id + amount
'0x',
),
).to.be.revertedWithCustomError(this.mock, 'GovernorDisabledDeposit');
});
it("can't receive ERC1155 safeBatchTransfer", async function () {
await expect(
this.token
.connect(this.owner)
.safeBatchTransferFrom(this.owner, this.mock, Object.keys(tokenIds), Object.values(tokenIds), '0x'),
).to.be.revertedWithCustomError(this.mock, 'GovernorDisabledDeposit');
});
});
});
});
describe('cancel', function () {
it('cancel before queue prevents scheduling', async function () {
await this.helper.propose();
await this.helper.waitForSnapshot();
await this.helper.connect(this.voter1).vote({ support: VoteType.For });
await this.helper.waitForDeadline();
await expect(this.helper.cancel('internal'))
.to.emit(this.mock, 'ProposalCanceled')
.withArgs(this.proposal.id);
expect(await this.mock.state(this.proposal.id)).to.equal(ProposalState.Canceled);
await expect(this.helper.queue())
.to.be.revertedWithCustomError(this.mock, 'GovernorUnexpectedProposalState')
.withArgs(
this.proposal.id,
ProposalState.Canceled,
GovernorHelper.proposalStatesToBitMap([ProposalState.Succeeded]),
);
});
it('cancel after queue prevents executing', async function () {
await this.helper.propose();
await this.helper.waitForSnapshot();
await this.helper.connect(this.voter1).vote({ support: VoteType.For });
await this.helper.waitForDeadline();
await this.helper.queue();
await expect(this.helper.cancel('internal'))
.to.emit(this.mock, 'ProposalCanceled')
.withArgs(this.proposal.id);
expect(await this.mock.state(this.proposal.id)).to.equal(ProposalState.Canceled);
await expect(this.helper.execute())
.to.be.revertedWithCustomError(this.mock, 'GovernorUnexpectedProposalState')
.withArgs(
this.proposal.id,
ProposalState.Canceled,
GovernorHelper.proposalStatesToBitMap([ProposalState.Succeeded, ProposalState.Queued]),
);
});
});
describe('onlyGovernance', function () {
describe('relay', function () {
beforeEach(async function () {
await this.token.$_mint(this.mock, 1);
});
it('is protected', async function () {
await expect(
this.mock
.connect(this.owner)
.relay(this.token, 0, this.token.interface.encodeFunctionData('transfer', [this.other.address, 1n])),
)
.to.be.revertedWithCustomError(this.mock, 'GovernorOnlyExecutor')
.withArgs(this.owner);
});
it('can be executed through governance', async function () {
this.helper.setProposal(
[
{
target: this.mock.target,
data: this.mock.interface.encodeFunctionData('relay', [
this.token.target,
0n,
this.token.interface.encodeFunctionData('transfer', [this.other.address, 1n]),
]),
},
],
'<proposal description>',
);
await this.helper.propose();
await this.helper.waitForSnapshot();
await this.helper.connect(this.voter1).vote({ support: VoteType.For });
await this.helper.waitForDeadline();
await this.helper.queue();
await this.helper.waitForEta();
const txExecute = this.helper.execute();
await expect(txExecute).to.changeTokenBalances(this.token, [this.mock, this.other], [-1n, 1n]);
await expect(txExecute).to.emit(this.token, 'Transfer').withArgs(this.mock, this.other, 1n);
});
});
describe('updateTimelock', function () {
beforeEach(async function () {
this.newTimelock = await ethers.deployContract('CompTimelock', [this.mock, time.duration.days(7n)]);
});
it('is protected', async function () {
await expect(this.mock.connect(this.owner).updateTimelock(this.newTimelock))
.to.be.revertedWithCustomError(this.mock, 'GovernorOnlyExecutor')
.withArgs(this.owner);
});
it('can be executed through governance to', async function () {
this.helper.setProposal(
[
{
target: this.timelock.target,
data: this.timelock.interface.encodeFunctionData('setPendingAdmin', [this.owner.address]),
},
{
target: this.mock.target,
data: this.mock.interface.encodeFunctionData('updateTimelock', [this.newTimelock.target]),
},
],
'<proposal description>',
);
await this.helper.propose();
await this.helper.waitForSnapshot();
await this.helper.connect(this.voter1).vote({ support: VoteType.For });
await this.helper.waitForDeadline();
await this.helper.queue();
await this.helper.waitForEta();
await expect(this.helper.execute())
.to.emit(this.mock, 'TimelockChange')
.withArgs(this.timelock, this.newTimelock);
expect(await this.mock.timelock()).to.equal(this.newTimelock);
});
});
it('can transfer timelock to new governor', async function () {
const newGovernor = await ethers.deployContract('$GovernorTimelockCompoundMock', [
name,
8n,
32n,
0n,
this.timelock,
this.token,
0n,
]);
this.helper.setProposal(
[
{
target: this.timelock.target,
data: this.timelock.interface.encodeFunctionData('setPendingAdmin', [newGovernor.target]),
},
],
'<proposal description>',
);
await this.helper.propose();
await this.helper.waitForSnapshot();
await this.helper.connect(this.voter1).vote({ support: VoteType.For });
await this.helper.waitForDeadline();
await this.helper.queue();
await this.helper.waitForEta();
await expect(this.helper.execute()).to.emit(this.timelock, 'NewPendingAdmin').withArgs(newGovernor);
await newGovernor.__acceptAdmin();
expect(await this.timelock.admin()).to.equal(newGovernor);
});
});
});
}
});

View File

@@ -0,0 +1,504 @@
const { ethers } = require('hardhat');
const { expect } = require('chai');
const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers');
const { anyValue } = require('@nomicfoundation/hardhat-chai-matchers/withArgs');
const { PANIC_CODES } = require('@nomicfoundation/hardhat-chai-matchers/panic');
const { GovernorHelper, timelockSalt } = require('../../helpers/governance');
const { OperationState, ProposalState, VoteType } = require('../../helpers/enums');
const time = require('../../helpers/time');
const TOKENS = [
{ Token: '$ERC20Votes', mode: 'blocknumber' },
{ Token: '$ERC20VotesTimestampMock', mode: 'timestamp' },
];
const DEFAULT_ADMIN_ROLE = ethers.ZeroHash;
const PROPOSER_ROLE = ethers.id('PROPOSER_ROLE');
const EXECUTOR_ROLE = ethers.id('EXECUTOR_ROLE');
const CANCELLER_ROLE = ethers.id('CANCELLER_ROLE');
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');
const delay = time.duration.hours(1n);
describe('GovernorTimelockControl', function () {
for (const { Token, mode } of TOKENS) {
const fixture = async () => {
const [deployer, owner, voter1, voter2, voter3, voter4, other] = await ethers.getSigners();
const receiver = await ethers.deployContract('CallReceiverMock');
const token = await ethers.deployContract(Token, [tokenName, tokenSymbol, version]);
const timelock = await ethers.deployContract('TimelockController', [delay, [], [], deployer]);
const mock = await ethers.deployContract('$GovernorTimelockControlMock', [
name,
votingDelay,
votingPeriod,
0n,
timelock,
token,
0n,
]);
await owner.sendTransaction({ to: timelock, value });
await token.$_mint(owner, tokenSupply);
await timelock.grantRole(PROPOSER_ROLE, mock);
await timelock.grantRole(PROPOSER_ROLE, owner);
await timelock.grantRole(CANCELLER_ROLE, mock);
await timelock.grantRole(CANCELLER_ROLE, owner);
await timelock.grantRole(EXECUTOR_ROLE, ethers.ZeroAddress);
await timelock.revokeRole(DEFAULT_ADMIN_ROLE, deployer);
const helper = new GovernorHelper(mock, mode);
await helper.connect(owner).delegate({ token, to: voter1, value: ethers.parseEther('10') });
await helper.connect(owner).delegate({ token, to: voter2, value: ethers.parseEther('7') });
await helper.connect(owner).delegate({ token, to: voter3, value: ethers.parseEther('5') });
await helper.connect(owner).delegate({ token, to: voter4, value: ethers.parseEther('2') });
return { deployer, owner, voter1, voter2, voter3, voter4, other, receiver, token, mock, timelock, helper };
};
describe(`using ${Token}`, function () {
beforeEach(async function () {
Object.assign(this, await loadFixture(fixture));
// default proposal
this.proposal = this.helper.setProposal(
[
{
target: this.receiver.target,
value,
data: this.receiver.interface.encodeFunctionData('mockFunction'),
},
],
'<proposal description>',
);
this.proposal.timelockid = await this.timelock.hashOperationBatch(
...this.proposal.shortProposal.slice(0, 3),
ethers.ZeroHash,
timelockSalt(this.mock.target, this.proposal.shortProposal[3]),
);
});
it("doesn't accept ether transfers", async function () {
await expect(this.owner.sendTransaction({ to: this.mock, value: 1n })).to.be.revertedWithCustomError(
this.mock,
'GovernorDisabledDeposit',
);
});
it('post deployment check', async function () {
expect(await this.mock.name()).to.equal(name);
expect(await this.mock.token()).to.equal(this.token);
expect(await this.mock.votingDelay()).to.equal(votingDelay);
expect(await this.mock.votingPeriod()).to.equal(votingPeriod);
expect(await this.mock.quorum(0n)).to.equal(0n);
expect(await this.mock.timelock()).to.equal(this.timelock);
});
it('nominal', async function () {
expect(await this.mock.proposalEta(this.proposal.id)).to.equal(0n);
expect(await this.mock.proposalNeedsQueuing(this.proposal.id)).to.be.true;
await this.helper.propose();
await this.helper.waitForSnapshot();
await this.helper.connect(this.voter1).vote({ support: VoteType.For });
await this.helper.connect(this.voter2).vote({ support: VoteType.For });
await this.helper.connect(this.voter3).vote({ support: VoteType.Against });
await this.helper.connect(this.voter4).vote({ support: VoteType.Abstain });
await this.helper.waitForDeadline();
expect(await this.mock.proposalNeedsQueuing(this.proposal.id)).to.be.true;
const txQueue = await this.helper.queue();
const eta = (await time.clockFromReceipt.timestamp(txQueue)) + delay;
expect(await this.mock.proposalEta(this.proposal.id)).to.equal(eta);
await this.helper.waitForEta();
const txExecute = this.helper.execute();
await expect(txQueue)
.to.emit(this.mock, 'ProposalQueued')
.withArgs(this.proposal.id, anyValue)
.to.emit(this.timelock, 'CallScheduled')
.withArgs(this.proposal.timelockid, ...Array(6).fill(anyValue))
.to.emit(this.timelock, 'CallSalt')
.withArgs(this.proposal.timelockid, anyValue);
await expect(txExecute)
.to.emit(this.mock, 'ProposalExecuted')
.withArgs(this.proposal.id)
.to.emit(this.timelock, 'CallExecuted')
.withArgs(this.proposal.timelockid, ...Array(4).fill(anyValue))
.to.emit(this.receiver, 'MockFunctionCalled');
});
describe('should revert', function () {
describe('on queue', function () {
it('if already queued', async function () {
await this.helper.propose();
await this.helper.waitForSnapshot();
await this.helper.connect(this.voter1).vote({ support: VoteType.For });
await this.helper.waitForDeadline();
await this.helper.queue();
await expect(this.helper.queue())
.to.be.revertedWithCustomError(this.mock, 'GovernorUnexpectedProposalState')
.withArgs(
this.proposal.id,
ProposalState.Queued,
GovernorHelper.proposalStatesToBitMap([ProposalState.Succeeded]),
);
});
});
describe('on execute', function () {
it('if not queued', async function () {
await this.helper.propose();
await this.helper.waitForSnapshot();
await this.helper.connect(this.voter1).vote({ support: VoteType.For });
await this.helper.waitForDeadline(1n);
expect(await this.mock.state(this.proposal.id)).to.equal(ProposalState.Succeeded);
await expect(this.helper.execute())
.to.be.revertedWithCustomError(this.timelock, 'TimelockUnexpectedOperationState')
.withArgs(this.proposal.timelockid, GovernorHelper.proposalStatesToBitMap(OperationState.Ready));
});
it('if too early', async function () {
await this.helper.propose();
await this.helper.waitForSnapshot();
await this.helper.connect(this.voter1).vote({ support: VoteType.For });
await this.helper.waitForDeadline();
await this.helper.queue();
expect(await this.mock.state(this.proposal.id)).to.equal(ProposalState.Queued);
await expect(this.helper.execute())
.to.be.revertedWithCustomError(this.timelock, 'TimelockUnexpectedOperationState')
.withArgs(this.proposal.timelockid, GovernorHelper.proposalStatesToBitMap(OperationState.Ready));
});
it('if already executed', async function () {
await this.helper.propose();
await this.helper.waitForSnapshot();
await this.helper.connect(this.voter1).vote({ support: VoteType.For });
await this.helper.waitForDeadline();
await this.helper.queue();
await this.helper.waitForEta();
await this.helper.execute();
await expect(this.helper.execute())
.to.be.revertedWithCustomError(this.mock, 'GovernorUnexpectedProposalState')
.withArgs(
this.proposal.id,
ProposalState.Executed,
GovernorHelper.proposalStatesToBitMap([ProposalState.Succeeded, ProposalState.Queued]),
);
});
it('if already executed by another proposer', async function () {
await this.helper.propose();
await this.helper.waitForSnapshot();
await this.helper.connect(this.voter1).vote({ support: VoteType.For });
await this.helper.waitForDeadline();
await this.helper.queue();
await this.helper.waitForEta();
await this.timelock.executeBatch(
...this.proposal.shortProposal.slice(0, 3),
ethers.ZeroHash,
timelockSalt(this.mock.target, this.proposal.shortProposal[3]),
);
await expect(this.helper.execute())
.to.be.revertedWithCustomError(this.mock, 'GovernorUnexpectedProposalState')
.withArgs(
this.proposal.id,
ProposalState.Executed,
GovernorHelper.proposalStatesToBitMap([ProposalState.Succeeded, ProposalState.Queued]),
);
});
});
});
describe('cancel', function () {
it('cancel before queue prevents scheduling', async function () {
await this.helper.propose();
await this.helper.waitForSnapshot();
await this.helper.connect(this.voter1).vote({ support: VoteType.For });
await this.helper.waitForDeadline();
await expect(this.helper.cancel('internal'))
.to.emit(this.mock, 'ProposalCanceled')
.withArgs(this.proposal.id);
expect(await this.mock.state(this.proposal.id)).to.equal(ProposalState.Canceled);
await expect(this.helper.queue())
.to.be.revertedWithCustomError(this.mock, 'GovernorUnexpectedProposalState')
.withArgs(
this.proposal.id,
ProposalState.Canceled,
GovernorHelper.proposalStatesToBitMap([ProposalState.Succeeded]),
);
});
it('cancel after queue prevents executing', async function () {
await this.helper.propose();
await this.helper.waitForSnapshot();
await this.helper.connect(this.voter1).vote({ support: VoteType.For });
await this.helper.waitForDeadline();
await this.helper.queue();
await expect(this.helper.cancel('internal'))
.to.emit(this.mock, 'ProposalCanceled')
.withArgs(this.proposal.id);
expect(await this.mock.state(this.proposal.id)).to.equal(ProposalState.Canceled);
await expect(this.helper.execute())
.to.be.revertedWithCustomError(this.mock, 'GovernorUnexpectedProposalState')
.withArgs(
this.proposal.id,
ProposalState.Canceled,
GovernorHelper.proposalStatesToBitMap([ProposalState.Succeeded, ProposalState.Queued]),
);
});
it('cancel on timelock is reflected on governor', async function () {
await this.helper.propose();
await this.helper.waitForSnapshot();
await this.helper.connect(this.voter1).vote({ support: VoteType.For });
await this.helper.waitForDeadline();
await this.helper.queue();
expect(await this.mock.state(this.proposal.id)).to.equal(ProposalState.Queued);
await expect(this.timelock.connect(this.owner).cancel(this.proposal.timelockid))
.to.emit(this.timelock, 'Cancelled')
.withArgs(this.proposal.timelockid);
expect(await this.mock.state(this.proposal.id)).to.equal(ProposalState.Canceled);
});
});
describe('onlyGovernance', function () {
describe('relay', function () {
beforeEach(async function () {
await this.token.$_mint(this.mock, 1);
});
it('is protected', async function () {
await expect(
this.mock
.connect(this.owner)
.relay(this.token, 0n, this.token.interface.encodeFunctionData('transfer', [this.other.address, 1n])),
)
.to.be.revertedWithCustomError(this.mock, 'GovernorOnlyExecutor')
.withArgs(this.owner);
});
it('can be executed through governance', async function () {
this.helper.setProposal(
[
{
target: this.mock.target,
data: this.mock.interface.encodeFunctionData('relay', [
this.token.target,
0n,
this.token.interface.encodeFunctionData('transfer', [this.other.address, 1n]),
]),
},
],
'<proposal description>',
);
await this.helper.propose();
await this.helper.waitForSnapshot();
await this.helper.connect(this.voter1).vote({ support: VoteType.For });
await this.helper.waitForDeadline();
await this.helper.queue();
await this.helper.waitForEta();
const txExecute = await this.helper.execute();
await expect(txExecute).to.changeTokenBalances(this.token, [this.mock, this.other], [-1n, 1n]);
await expect(txExecute).to.emit(this.token, 'Transfer').withArgs(this.mock, this.other, 1n);
});
it('is payable and can transfer eth to EOA', async function () {
const t2g = 128n; // timelock to governor
const g2o = 100n; // governor to eoa (other)
this.helper.setProposal(
[
{
target: this.mock.target,
value: t2g,
data: this.mock.interface.encodeFunctionData('relay', [this.other.address, g2o, '0x']),
},
],
'<proposal description>',
);
await this.helper.propose();
await this.helper.waitForSnapshot();
await this.helper.connect(this.voter1).vote({ support: VoteType.For });
await this.helper.waitForDeadline();
await this.helper.queue();
await this.helper.waitForEta();
await expect(this.helper.execute()).to.changeEtherBalances(
[this.timelock, this.mock, this.other],
[-t2g, t2g - g2o, g2o],
);
});
it('protected against other proposers', async function () {
const call = [
this.mock,
0n,
this.mock.interface.encodeFunctionData('relay', [ethers.ZeroAddress, 0n, '0x']),
ethers.ZeroHash,
ethers.ZeroHash,
];
await this.timelock.connect(this.owner).schedule(...call, delay);
await time.increaseBy.timestamp(delay);
// Error bubbled up from Governor
await expect(this.timelock.connect(this.owner).execute(...call)).to.be.revertedWithPanic(
PANIC_CODES.POP_ON_EMPTY_ARRAY,
);
});
});
describe('updateTimelock', function () {
beforeEach(async function () {
this.newTimelock = await ethers.deployContract('TimelockController', [
delay,
[this.mock],
[this.mock],
ethers.ZeroAddress,
]);
});
it('is protected', async function () {
await expect(this.mock.connect(this.owner).updateTimelock(this.newTimelock))
.to.be.revertedWithCustomError(this.mock, 'GovernorOnlyExecutor')
.withArgs(this.owner);
});
it('can be executed through governance to', async function () {
this.helper.setProposal(
[
{
target: this.mock.target,
data: this.mock.interface.encodeFunctionData('updateTimelock', [this.newTimelock.target]),
},
],
'<proposal description>',
);
await this.helper.propose();
await this.helper.waitForSnapshot();
await this.helper.connect(this.voter1).vote({ support: VoteType.For });
await this.helper.waitForDeadline();
await this.helper.queue();
await this.helper.waitForEta();
await expect(this.helper.execute())
.to.emit(this.mock, 'TimelockChange')
.withArgs(this.timelock, this.newTimelock);
expect(await this.mock.timelock()).to.equal(this.newTimelock);
});
});
describe('on safe receive', function () {
describe('ERC721', function () {
const tokenId = 1n;
beforeEach(async function () {
this.token = await ethers.deployContract('$ERC721', ['Non Fungible Token', 'NFT']);
await this.token.$_mint(this.owner, tokenId);
});
it("can't receive an ERC721 safeTransfer", async function () {
await expect(
this.token.connect(this.owner).safeTransferFrom(this.owner, this.mock, tokenId),
).to.be.revertedWithCustomError(this.mock, 'GovernorDisabledDeposit');
});
});
describe('ERC1155', function () {
const tokenIds = {
1: 1000n,
2: 2000n,
3: 3000n,
};
beforeEach(async function () {
this.token = await ethers.deployContract('$ERC1155', ['https://token-cdn-domain/{id}.json']);
await this.token.$_mintBatch(this.owner, Object.keys(tokenIds), Object.values(tokenIds), '0x');
});
it("can't receive ERC1155 safeTransfer", async function () {
await expect(
this.token.connect(this.owner).safeTransferFrom(
this.owner,
this.mock,
...Object.entries(tokenIds)[0], // id + amount
'0x',
),
).to.be.revertedWithCustomError(this.mock, 'GovernorDisabledDeposit');
});
it("can't receive ERC1155 safeBatchTransfer", async function () {
await expect(
this.token
.connect(this.owner)
.safeBatchTransferFrom(this.owner, this.mock, Object.keys(tokenIds), Object.values(tokenIds), '0x'),
).to.be.revertedWithCustomError(this.mock, 'GovernorDisabledDeposit');
});
});
});
});
it('clear queue of pending governor calls', async function () {
this.helper.setProposal(
[
{
target: this.mock.target,
data: this.mock.interface.encodeFunctionData('nonGovernanceFunction'),
},
],
'<proposal description>',
);
await this.helper.propose();
await this.helper.waitForSnapshot();
await this.helper.connect(this.voter1).vote({ support: VoteType.For });
await this.helper.waitForDeadline();
await this.helper.queue();
await this.helper.waitForEta();
await this.helper.execute();
// This path clears _governanceCall as part of the afterExecute call,
// but we have not way to check that the cleanup actually happened other
// then coverage reports.
});
});
}
});

View File

@@ -0,0 +1,165 @@
const { ethers } = require('hardhat');
const { expect } = require('chai');
const { loadFixture, mine } = require('@nomicfoundation/hardhat-network-helpers');
const { GovernorHelper } = require('../../helpers/governance');
const { ProposalState, 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 ratio = 8n; // percents
const newRatio = 6n; // percents
const votingDelay = 4n;
const votingPeriod = 16n;
const value = ethers.parseEther('1');
describe('GovernorVotesQuorumFraction', function () {
for (const { Token, mode } of TOKENS) {
const fixture = async () => {
const [owner, voter1, voter2, voter3, voter4] = await ethers.getSigners();
const receiver = await ethers.deployContract('CallReceiverMock');
const token = await ethers.deployContract(Token, [tokenName, tokenSymbol, version]);
const mock = await ethers.deployContract('$GovernorMock', [name, votingDelay, votingPeriod, 0n, token, ratio]);
await owner.sendTransaction({ to: mock, value });
await token.$_mint(owner, tokenSupply);
const helper = new GovernorHelper(mock, mode);
await helper.connect(owner).delegate({ token, to: voter1, value: ethers.parseEther('10') });
await helper.connect(owner).delegate({ token, to: voter2, value: ethers.parseEther('7') });
await helper.connect(owner).delegate({ token, to: voter3, value: ethers.parseEther('5') });
await helper.connect(owner).delegate({ token, to: voter4, value: ethers.parseEther('2') });
return { owner, voter1, voter2, voter3, voter4, receiver, token, mock, helper };
};
describe(`using ${Token}`, function () {
beforeEach(async function () {
Object.assign(this, await loadFixture(fixture));
// default proposal
this.proposal = this.helper.setProposal(
[
{
target: this.receiver.target,
value,
data: this.receiver.interface.encodeFunctionData('mockFunction'),
},
],
'<proposal description>',
);
});
it('deployment check', async function () {
expect(await this.mock.name()).to.equal(name);
expect(await this.mock.token()).to.equal(this.token);
expect(await this.mock.votingDelay()).to.equal(votingDelay);
expect(await this.mock.votingPeriod()).to.equal(votingPeriod);
expect(await this.mock.quorum(0)).to.equal(0n);
expect(await this.mock.quorumNumerator()).to.equal(ratio);
expect(await this.mock.quorumDenominator()).to.equal(100n);
expect(await time.clock[mode]().then(clock => this.mock.quorum(clock - 1n))).to.equal(
(tokenSupply * ratio) / 100n,
);
});
it('quroum reached', async function () {
await this.helper.propose();
await this.helper.waitForSnapshot();
await this.helper.connect(this.voter1).vote({ support: VoteType.For });
await this.helper.waitForDeadline();
await this.helper.execute();
});
it('quroum not reached', async function () {
await this.helper.propose();
await this.helper.waitForSnapshot();
await this.helper.connect(this.voter2).vote({ support: VoteType.For });
await this.helper.waitForDeadline();
await expect(this.helper.execute())
.to.be.revertedWithCustomError(this.mock, 'GovernorUnexpectedProposalState')
.withArgs(
this.proposal.id,
ProposalState.Defeated,
GovernorHelper.proposalStatesToBitMap([ProposalState.Succeeded, ProposalState.Queued]),
);
});
describe('onlyGovernance updates', function () {
it('updateQuorumNumerator is protected', async function () {
await expect(this.mock.connect(this.owner).updateQuorumNumerator(newRatio))
.to.be.revertedWithCustomError(this.mock, 'GovernorOnlyExecutor')
.withArgs(this.owner);
});
it('can updateQuorumNumerator through governance', async function () {
this.helper.setProposal(
[
{
target: this.mock.target,
data: this.mock.interface.encodeFunctionData('updateQuorumNumerator', [newRatio]),
},
],
'<proposal description>',
);
await this.helper.propose();
await this.helper.waitForSnapshot();
await this.helper.connect(this.voter1).vote({ support: VoteType.For });
await this.helper.waitForDeadline();
await expect(this.helper.execute()).to.emit(this.mock, 'QuorumNumeratorUpdated').withArgs(ratio, newRatio);
expect(await this.mock.quorumNumerator()).to.equal(newRatio);
expect(await this.mock.quorumDenominator()).to.equal(100n);
// it takes one block for the new quorum to take effect
expect(await time.clock[mode]().then(blockNumber => this.mock.quorum(blockNumber - 1n))).to.equal(
(tokenSupply * ratio) / 100n,
);
await mine();
expect(await time.clock[mode]().then(blockNumber => this.mock.quorum(blockNumber - 1n))).to.equal(
(tokenSupply * newRatio) / 100n,
);
});
it('cannot updateQuorumNumerator over the maximum', async function () {
const quorumNumerator = 101n;
this.helper.setProposal(
[
{
target: this.mock.target,
data: this.mock.interface.encodeFunctionData('updateQuorumNumerator', [quorumNumerator]),
},
],
'<proposal description>',
);
await this.helper.propose();
await this.helper.waitForSnapshot();
await this.helper.connect(this.voter1).vote({ support: VoteType.For });
await this.helper.waitForDeadline();
const quorumDenominator = await this.mock.quorumDenominator();
await expect(this.helper.execute())
.to.be.revertedWithCustomError(this.mock, 'GovernorInvalidQuorumFraction')
.withArgs(quorumNumerator, quorumDenominator);
});
});
});
}
});

View File

@@ -0,0 +1,245 @@
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 { getDomain, ExtendedBallot } = require('../../helpers/eip712');
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');
const params = {
decoded: [42n, 'These are my params'],
encoded: ethers.AbiCoder.defaultAbiCoder().encode(['uint256', 'string'], [42n, 'These are my params']),
};
describe('GovernorWithParams', function () {
for (const { Token, mode } of TOKENS) {
const fixture = async () => {
const [owner, proposer, voter1, voter2, voter3, voter4, other] = await ethers.getSigners();
const receiver = await ethers.deployContract('CallReceiverMock');
const token = await ethers.deployContract(Token, [tokenName, tokenSymbol, version]);
const mock = await ethers.deployContract('$GovernorWithParamsMock', [name, token]);
await owner.sendTransaction({ to: mock, value });
await token.$_mint(owner, tokenSupply);
const helper = new GovernorHelper(mock, mode);
await helper.connect(owner).delegate({ token, to: voter1, value: ethers.parseEther('10') });
await helper.connect(owner).delegate({ token, to: voter2, value: ethers.parseEther('7') });
await helper.connect(owner).delegate({ token, to: voter3, value: ethers.parseEther('5') });
await helper.connect(owner).delegate({ token, to: voter4, value: ethers.parseEther('2') });
return { owner, proposer, voter1, voter2, voter3, voter4, other, receiver, token, mock, helper };
};
describe(`using ${Token}`, function () {
beforeEach(async function () {
Object.assign(this, await loadFixture(fixture));
// default proposal
this.proposal = this.helper.setProposal(
[
{
target: this.receiver.target,
value,
data: this.receiver.interface.encodeFunctionData('mockFunction'),
},
],
'<proposal description>',
);
});
it('deployment check', async function () {
expect(await this.mock.name()).to.equal(name);
expect(await this.mock.token()).to.equal(this.token);
expect(await this.mock.votingDelay()).to.equal(votingDelay);
expect(await this.mock.votingPeriod()).to.equal(votingPeriod);
});
it('nominal is unaffected', async function () {
await this.helper.connect(this.proposer).propose();
await this.helper.waitForSnapshot();
await this.helper.connect(this.voter1).vote({ support: VoteType.For, reason: 'This is nice' });
await this.helper.connect(this.voter2).vote({ support: VoteType.For });
await this.helper.connect(this.voter3).vote({ support: VoteType.Against });
await this.helper.connect(this.voter4).vote({ support: VoteType.Abstain });
await this.helper.waitForDeadline();
await this.helper.execute();
expect(await this.mock.hasVoted(this.proposal.id, this.owner)).to.be.false;
expect(await this.mock.hasVoted(this.proposal.id, this.voter1)).to.be.true;
expect(await this.mock.hasVoted(this.proposal.id, this.voter2)).to.be.true;
expect(await ethers.provider.getBalance(this.mock)).to.equal(0n);
expect(await ethers.provider.getBalance(this.receiver)).to.equal(value);
});
it('Voting with params is properly supported', async function () {
await this.helper.connect(this.proposer).propose();
await this.helper.waitForSnapshot();
const weight = ethers.parseEther('7') - params.decoded[0];
await expect(
this.helper.connect(this.voter2).vote({
support: VoteType.For,
reason: 'no particular reason',
params: params.encoded,
}),
)
.to.emit(this.mock, 'CountParams')
.withArgs(...params.decoded)
.to.emit(this.mock, 'VoteCastWithParams')
.withArgs(
this.voter2.address,
this.proposal.id,
VoteType.For,
weight,
'no particular reason',
params.encoded,
);
expect(await this.mock.proposalVotes(this.proposal.id)).to.deep.equal([0n, weight, 0n]);
});
describe('voting by signature', function () {
it('supports EOA signatures', async function () {
await this.token.connect(this.voter2).delegate(this.other);
// Run proposal
await this.helper.propose();
await this.helper.waitForSnapshot();
// Prepare vote
const weight = ethers.parseEther('7') - params.decoded[0];
const nonce = await this.mock.nonces(this.other);
const data = {
proposalId: this.proposal.id,
support: VoteType.For,
voter: this.other.address,
nonce,
reason: 'no particular reason',
params: params.encoded,
signature: (contract, message) =>
getDomain(contract).then(domain => this.other.signTypedData(domain, { ExtendedBallot }, message)),
};
// Vote
await expect(this.helper.vote(data))
.to.emit(this.mock, 'CountParams')
.withArgs(...params.decoded)
.to.emit(this.mock, 'VoteCastWithParams')
.withArgs(data.voter, data.proposalId, data.support, weight, data.reason, data.params);
expect(await this.mock.proposalVotes(this.proposal.id)).to.deep.equal([0n, weight, 0n]);
expect(await this.mock.nonces(this.other)).to.equal(nonce + 1n);
});
it('supports EIP-1271 signature signatures', async function () {
const wallet = await ethers.deployContract('ERC1271WalletMock', [this.other]);
await this.token.connect(this.voter2).delegate(wallet);
// Run proposal
await this.helper.propose();
await this.helper.waitForSnapshot();
// Prepare vote
const weight = ethers.parseEther('7') - params.decoded[0];
const nonce = await this.mock.nonces(this.other);
const data = {
proposalId: this.proposal.id,
support: VoteType.For,
voter: wallet.target,
nonce,
reason: 'no particular reason',
params: params.encoded,
signature: (contract, message) =>
getDomain(contract).then(domain => this.other.signTypedData(domain, { ExtendedBallot }, message)),
};
// Vote
await expect(this.helper.vote(data))
.to.emit(this.mock, 'CountParams')
.withArgs(...params.decoded)
.to.emit(this.mock, 'VoteCastWithParams')
.withArgs(data.voter, data.proposalId, data.support, weight, data.reason, data.params);
expect(await this.mock.proposalVotes(this.proposal.id)).to.deep.equal([0n, weight, 0n]);
expect(await this.mock.nonces(wallet)).to.equal(nonce + 1n);
});
it('reverts if signature does not match signer', async function () {
await this.token.connect(this.voter2).delegate(this.other);
// Run proposal
await this.helper.propose();
await this.helper.waitForSnapshot();
// Prepare vote
const nonce = await this.mock.nonces(this.other);
const data = {
proposalId: this.proposal.id,
support: VoteType.For,
voter: this.other.address,
nonce,
reason: 'no particular reason',
params: params.encoded,
// tampered signature
signature: (contract, message) =>
getDomain(contract)
.then(domain => this.other.signTypedData(domain, { ExtendedBallot }, message))
.then(signature => {
const tamperedSig = ethers.toBeArray(signature);
tamperedSig[42] ^= 0xff;
return ethers.hexlify(tamperedSig);
}),
};
// Vote
await expect(this.helper.vote(data))
.to.be.revertedWithCustomError(this.mock, 'GovernorInvalidSignature')
.withArgs(data.voter);
});
it('reverts if vote nonce is incorrect', async function () {
await this.token.connect(this.voter2).delegate(this.other);
// Run proposal
await this.helper.propose();
await this.helper.waitForSnapshot();
// Prepare vote
const nonce = await this.mock.nonces(this.other);
const data = {
proposalId: this.proposal.id,
support: VoteType.For,
voter: this.other.address,
nonce: nonce + 1n,
reason: 'no particular reason',
params: params.encoded,
signature: (contract, message) =>
getDomain(contract).then(domain => this.other.signTypedData(domain, { ExtendedBallot }, message)),
};
// Vote
await expect(this.helper.vote(data))
.to.be.revertedWithCustomError(this.mock, 'GovernorInvalidSignature')
.withArgs(data.voter);
});
});
});
}
});

View File

@@ -0,0 +1,25 @@
const { expect } = require('chai');
const time = require('../../helpers/time');
function shouldBehaveLikeERC6372(mode = 'blocknumber') {
describe('should implement ERC-6372', function () {
beforeEach(async function () {
this.mock = this.mock ?? this.token ?? this.votes;
});
it('clock is correct', async function () {
expect(await this.mock.clock()).to.equal(await time.clock[mode]());
});
it('CLOCK_MODE is correct', async function () {
const params = new URLSearchParams(await this.mock.CLOCK_MODE());
expect(params.get('mode')).to.equal(mode);
expect(params.get('from')).to.equal(mode == 'blocknumber' ? 'default' : null);
});
});
}
module.exports = {
shouldBehaveLikeERC6372,
};

View File

@@ -0,0 +1,325 @@
const { ethers } = require('hardhat');
const { expect } = require('chai');
const { mine } = require('@nomicfoundation/hardhat-network-helpers');
const { getDomain, Delegation } = require('../../helpers/eip712');
const time = require('../../helpers/time');
const { shouldBehaveLikeERC6372 } = require('./ERC6372.behavior');
function shouldBehaveLikeVotes(tokens, { mode = 'blocknumber', fungible = true }) {
beforeEach(async function () {
[this.delegator, this.delegatee, this.alice, this.bob, this.other] = this.accounts;
this.domain = await getDomain(this.votes);
});
shouldBehaveLikeERC6372(mode);
const getWeight = token => (fungible ? token : 1n);
describe('run votes workflow', function () {
it('initial nonce is 0', async function () {
expect(await this.votes.nonces(this.alice)).to.equal(0n);
});
describe('delegation with signature', function () {
const token = tokens[0];
it('delegation without tokens', async function () {
expect(await this.votes.delegates(this.alice)).to.equal(ethers.ZeroAddress);
await expect(this.votes.connect(this.alice).delegate(this.alice))
.to.emit(this.votes, 'DelegateChanged')
.withArgs(this.alice, ethers.ZeroAddress, this.alice)
.to.not.emit(this.votes, 'DelegateVotesChanged');
expect(await this.votes.delegates(this.alice)).to.equal(this.alice);
});
it('delegation with tokens', async function () {
await this.votes.$_mint(this.alice, token);
const weight = getWeight(token);
expect(await this.votes.delegates(this.alice)).to.equal(ethers.ZeroAddress);
const tx = await this.votes.connect(this.alice).delegate(this.alice);
const timepoint = await time.clockFromReceipt[mode](tx);
await expect(tx)
.to.emit(this.votes, 'DelegateChanged')
.withArgs(this.alice, ethers.ZeroAddress, this.alice)
.to.emit(this.votes, 'DelegateVotesChanged')
.withArgs(this.alice, 0n, weight);
expect(await this.votes.delegates(this.alice)).to.equal(this.alice);
expect(await this.votes.getVotes(this.alice)).to.equal(weight);
expect(await this.votes.getPastVotes(this.alice, timepoint - 1n)).to.equal(0n);
await mine();
expect(await this.votes.getPastVotes(this.alice, timepoint)).to.equal(weight);
});
it('delegation update', async function () {
await this.votes.connect(this.alice).delegate(this.alice);
await this.votes.$_mint(this.alice, token);
const weight = getWeight(token);
expect(await this.votes.delegates(this.alice)).to.equal(this.alice);
expect(await this.votes.getVotes(this.alice)).to.equal(weight);
expect(await this.votes.getVotes(this.bob)).to.equal(0);
const tx = await this.votes.connect(this.alice).delegate(this.bob);
const timepoint = await time.clockFromReceipt[mode](tx);
await expect(tx)
.to.emit(this.votes, 'DelegateChanged')
.withArgs(this.alice, this.alice, this.bob)
.to.emit(this.votes, 'DelegateVotesChanged')
.withArgs(this.alice, weight, 0)
.to.emit(this.votes, 'DelegateVotesChanged')
.withArgs(this.bob, 0, weight);
expect(await this.votes.delegates(this.alice)).to.equal(this.bob);
expect(await this.votes.getVotes(this.alice)).to.equal(0n);
expect(await this.votes.getVotes(this.bob)).to.equal(weight);
expect(await this.votes.getPastVotes(this.alice, timepoint - 1n)).to.equal(weight);
expect(await this.votes.getPastVotes(this.bob, timepoint - 1n)).to.equal(0n);
await mine();
expect(await this.votes.getPastVotes(this.alice, timepoint)).to.equal(0n);
expect(await this.votes.getPastVotes(this.bob, timepoint)).to.equal(weight);
});
describe('with signature', function () {
const nonce = 0n;
it('accept signed delegation', async function () {
await this.votes.$_mint(this.delegator, token);
const weight = getWeight(token);
const { r, s, v } = await this.delegator
.signTypedData(
this.domain,
{ Delegation },
{
delegatee: this.delegatee.address,
nonce,
expiry: ethers.MaxUint256,
},
)
.then(ethers.Signature.from);
expect(await this.votes.delegates(this.delegator)).to.equal(ethers.ZeroAddress);
const tx = await this.votes.delegateBySig(this.delegatee, nonce, ethers.MaxUint256, v, r, s);
const timepoint = await time.clockFromReceipt[mode](tx);
await expect(tx)
.to.emit(this.votes, 'DelegateChanged')
.withArgs(this.delegator, ethers.ZeroAddress, this.delegatee)
.to.emit(this.votes, 'DelegateVotesChanged')
.withArgs(this.delegatee, 0, weight);
expect(await this.votes.delegates(this.delegator.address)).to.equal(this.delegatee);
expect(await this.votes.getVotes(this.delegator.address)).to.equal(0n);
expect(await this.votes.getVotes(this.delegatee)).to.equal(weight);
expect(await this.votes.getPastVotes(this.delegatee, timepoint - 1n)).to.equal(0n);
await mine();
expect(await this.votes.getPastVotes(this.delegatee, timepoint)).to.equal(weight);
});
it('rejects reused signature', async function () {
const { r, s, v } = await this.delegator
.signTypedData(
this.domain,
{ Delegation },
{
delegatee: this.delegatee.address,
nonce,
expiry: ethers.MaxUint256,
},
)
.then(ethers.Signature.from);
await this.votes.delegateBySig(this.delegatee, nonce, ethers.MaxUint256, v, r, s);
await expect(this.votes.delegateBySig(this.delegatee, nonce, ethers.MaxUint256, v, r, s))
.to.be.revertedWithCustomError(this.votes, 'InvalidAccountNonce')
.withArgs(this.delegator, nonce + 1n);
});
it('rejects bad delegatee', async function () {
const { r, s, v } = await this.delegator
.signTypedData(
this.domain,
{ Delegation },
{
delegatee: this.delegatee.address,
nonce,
expiry: ethers.MaxUint256,
},
)
.then(ethers.Signature.from);
const tx = await this.votes.delegateBySig(this.other, nonce, ethers.MaxUint256, v, r, s);
const receipt = await tx.wait();
const [delegateChanged] = receipt.logs.filter(
log => this.votes.interface.parseLog(log)?.name === 'DelegateChanged',
);
const { args } = this.votes.interface.parseLog(delegateChanged);
expect(args.delegator).to.not.be.equal(this.delegator);
expect(args.fromDelegate).to.equal(ethers.ZeroAddress);
expect(args.toDelegate).to.equal(this.other);
});
it('rejects bad nonce', async function () {
const { r, s, v } = await this.delegator
.signTypedData(
this.domain,
{ Delegation },
{
delegatee: this.delegatee.address,
nonce: nonce + 1n,
expiry: ethers.MaxUint256,
},
)
.then(ethers.Signature.from);
await expect(this.votes.delegateBySig(this.delegatee, nonce + 1n, ethers.MaxUint256, v, r, s))
.to.be.revertedWithCustomError(this.votes, 'InvalidAccountNonce')
.withArgs(this.delegator, 0);
});
it('rejects expired permit', async function () {
const expiry = (await time.clock.timestamp()) - 1n;
const { r, s, v } = await this.delegator
.signTypedData(
this.domain,
{ Delegation },
{
delegatee: this.delegatee.address,
nonce,
expiry,
},
)
.then(ethers.Signature.from);
await expect(this.votes.delegateBySig(this.delegatee, nonce, expiry, v, r, s))
.to.be.revertedWithCustomError(this.votes, 'VotesExpiredSignature')
.withArgs(expiry);
});
});
});
describe('getPastTotalSupply', function () {
beforeEach(async function () {
await this.votes.connect(this.alice).delegate(this.alice);
});
it('reverts if block number >= current block', async function () {
const timepoint = 5e10;
const clock = await this.votes.clock();
await expect(this.votes.getPastTotalSupply(timepoint))
.to.be.revertedWithCustomError(this.votes, 'ERC5805FutureLookup')
.withArgs(timepoint, clock);
});
it('returns 0 if there are no checkpoints', async function () {
expect(await this.votes.getPastTotalSupply(0n)).to.equal(0n);
});
it('returns the correct checkpointed total supply', async function () {
const weight = tokens.map(token => getWeight(token));
// t0 = mint #0
const t0 = await this.votes.$_mint(this.alice, tokens[0]);
await mine();
// t1 = mint #1
const t1 = await this.votes.$_mint(this.alice, tokens[1]);
await mine();
// t2 = burn #1
const t2 = await this.votes.$_burn(...(fungible ? [this.alice] : []), tokens[1]);
await mine();
// t3 = mint #2
const t3 = await this.votes.$_mint(this.alice, tokens[2]);
await mine();
// t4 = burn #0
const t4 = await this.votes.$_burn(...(fungible ? [this.alice] : []), tokens[0]);
await mine();
// t5 = burn #2
const t5 = await this.votes.$_burn(...(fungible ? [this.alice] : []), tokens[2]);
await mine();
t0.timepoint = await time.clockFromReceipt[mode](t0);
t1.timepoint = await time.clockFromReceipt[mode](t1);
t2.timepoint = await time.clockFromReceipt[mode](t2);
t3.timepoint = await time.clockFromReceipt[mode](t3);
t4.timepoint = await time.clockFromReceipt[mode](t4);
t5.timepoint = await time.clockFromReceipt[mode](t5);
expect(await this.votes.getPastTotalSupply(t0.timepoint - 1n)).to.equal(0);
expect(await this.votes.getPastTotalSupply(t0.timepoint)).to.equal(weight[0]);
expect(await this.votes.getPastTotalSupply(t0.timepoint + 1n)).to.equal(weight[0]);
expect(await this.votes.getPastTotalSupply(t1.timepoint)).to.equal(weight[0] + weight[1]);
expect(await this.votes.getPastTotalSupply(t1.timepoint + 1n)).to.equal(weight[0] + weight[1]);
expect(await this.votes.getPastTotalSupply(t2.timepoint)).to.equal(weight[0]);
expect(await this.votes.getPastTotalSupply(t2.timepoint + 1n)).to.equal(weight[0]);
expect(await this.votes.getPastTotalSupply(t3.timepoint)).to.equal(weight[0] + weight[2]);
expect(await this.votes.getPastTotalSupply(t3.timepoint + 1n)).to.equal(weight[0] + weight[2]);
expect(await this.votes.getPastTotalSupply(t4.timepoint)).to.equal(weight[2]);
expect(await this.votes.getPastTotalSupply(t4.timepoint + 1n)).to.equal(weight[2]);
expect(await this.votes.getPastTotalSupply(t5.timepoint)).to.equal(0);
await expect(this.votes.getPastTotalSupply(t5.timepoint + 1n))
.to.be.revertedWithCustomError(this.votes, 'ERC5805FutureLookup')
.withArgs(t5.timepoint + 1n, t5.timepoint + 1n);
});
});
// The following tests are an adaptation of
// https://github.com/compound-finance/compound-protocol/blob/master/tests/Governance/CompTest.js.
describe('Compound test suite', function () {
beforeEach(async function () {
await this.votes.$_mint(this.alice, tokens[0]);
await this.votes.$_mint(this.alice, tokens[1]);
await this.votes.$_mint(this.alice, tokens[2]);
});
describe('getPastVotes', function () {
it('reverts if block number >= current block', async function () {
const clock = await this.votes.clock();
const timepoint = 5e10; // far in the future
await expect(this.votes.getPastVotes(this.bob, timepoint))
.to.be.revertedWithCustomError(this.votes, 'ERC5805FutureLookup')
.withArgs(timepoint, clock);
});
it('returns 0 if there are no checkpoints', async function () {
expect(await this.votes.getPastVotes(this.bob, 0n)).to.equal(0n);
});
it('returns the latest block if >= last checkpoint block', async function () {
const delegate = await this.votes.connect(this.alice).delegate(this.bob);
const timepoint = await time.clockFromReceipt[mode](delegate);
await mine(2);
const latest = await this.votes.getVotes(this.bob);
expect(await this.votes.getPastVotes(this.bob, timepoint)).to.equal(latest);
expect(await this.votes.getPastVotes(this.bob, timepoint + 1n)).to.equal(latest);
});
it('returns zero if < first checkpoint block', async function () {
await mine();
const delegate = await this.votes.connect(this.alice).delegate(this.bob);
const timepoint = await time.clockFromReceipt[mode](delegate);
await mine(2);
expect(await this.votes.getPastVotes(this.bob, timepoint - 1n)).to.equal(0n);
});
});
});
});
}
module.exports = {
shouldBehaveLikeVotes,
};

View File

@@ -0,0 +1,102 @@
const { ethers } = require('hardhat');
const { expect } = require('chai');
const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers');
const { sum } = require('../../helpers/math');
const { zip } = require('../../helpers/iterate');
const time = require('../../helpers/time');
const { shouldBehaveLikeVotes } = require('./Votes.behavior');
const MODES = {
blocknumber: '$VotesMock',
timestamp: '$VotesTimestampMock',
};
const AMOUNTS = [ethers.parseEther('10000000'), 10n, 20n];
describe('Votes', function () {
for (const [mode, artifact] of Object.entries(MODES)) {
const fixture = async () => {
const accounts = await ethers.getSigners();
const amounts = Object.fromEntries(
zip(
accounts.slice(0, AMOUNTS.length).map(({ address }) => address),
AMOUNTS,
),
);
const name = 'My Vote';
const version = '1';
const votes = await ethers.deployContract(artifact, [name, version]);
return { accounts, amounts, votes, name, version };
};
describe(`vote with ${mode}`, function () {
beforeEach(async function () {
Object.assign(this, await loadFixture(fixture));
});
shouldBehaveLikeVotes(AMOUNTS, { mode, fungible: true });
it('starts with zero votes', async function () {
expect(await this.votes.getTotalSupply()).to.equal(0n);
});
describe('performs voting operations', function () {
beforeEach(async function () {
this.txs = [];
for (const [account, amount] of Object.entries(this.amounts)) {
this.txs.push(await this.votes.$_mint(account, amount));
}
});
it('reverts if block number >= current block', async function () {
const lastTxTimepoint = await time.clockFromReceipt[mode](this.txs.at(-1));
const clock = await this.votes.clock();
await expect(this.votes.getPastTotalSupply(lastTxTimepoint))
.to.be.revertedWithCustomError(this.votes, 'ERC5805FutureLookup')
.withArgs(lastTxTimepoint, clock);
});
it('delegates', async function () {
expect(await this.votes.getVotes(this.accounts[0])).to.equal(0n);
expect(await this.votes.getVotes(this.accounts[1])).to.equal(0n);
expect(await this.votes.delegates(this.accounts[0])).to.equal(ethers.ZeroAddress);
expect(await this.votes.delegates(this.accounts[1])).to.equal(ethers.ZeroAddress);
await this.votes.delegate(this.accounts[0], ethers.Typed.address(this.accounts[0]));
expect(await this.votes.getVotes(this.accounts[0])).to.equal(this.amounts[this.accounts[0].address]);
expect(await this.votes.getVotes(this.accounts[1])).to.equal(0n);
expect(await this.votes.delegates(this.accounts[0])).to.equal(this.accounts[0]);
expect(await this.votes.delegates(this.accounts[1])).to.equal(ethers.ZeroAddress);
await this.votes.delegate(this.accounts[1], ethers.Typed.address(this.accounts[0]));
expect(await this.votes.getVotes(this.accounts[0])).to.equal(
this.amounts[this.accounts[0].address] + this.amounts[this.accounts[1].address],
);
expect(await this.votes.getVotes(this.accounts[1])).to.equal(0n);
expect(await this.votes.delegates(this.accounts[0])).to.equal(this.accounts[0]);
expect(await this.votes.delegates(this.accounts[1])).to.equal(this.accounts[0]);
});
it('cross delegates', async function () {
await this.votes.delegate(this.accounts[0], ethers.Typed.address(this.accounts[1]));
await this.votes.delegate(this.accounts[1], ethers.Typed.address(this.accounts[0]));
expect(await this.votes.getVotes(this.accounts[0])).to.equal(this.amounts[this.accounts[1].address]);
expect(await this.votes.getVotes(this.accounts[1])).to.equal(this.amounts[this.accounts[0].address]);
});
it('returns total amount of votes', async function () {
const totalSupply = sum(...Object.values(this.amounts));
expect(await this.votes.getTotalSupply()).to.equal(totalSupply);
});
});
});
}
});