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,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);
});
});
});
}
});