dexorder
This commit is contained in:
55
lib_openzeppelin_contracts/test/governance/Governor.t.sol
Normal file
55
lib_openzeppelin_contracts/test/governance/Governor.t.sol
Normal 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 {}
|
||||
}
|
||||
992
lib_openzeppelin_contracts/test/governance/Governor.test.js
Normal file
992
lib_openzeppelin_contracts/test/governance/Governor.test.js
Normal 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
@@ -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
|
||||
]);
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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.
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
102
lib_openzeppelin_contracts/test/governance/utils/Votes.test.js
Normal file
102
lib_openzeppelin_contracts/test/governance/utils/Votes.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user