You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1279 lines
45 KiB
1279 lines
45 KiB
const { ethers } = require('hardhat');
|
|
const { expect } = require('chai');
|
|
const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers');
|
|
const { PANIC_CODES } = require('@nomicfoundation/hardhat-chai-matchers/panic');
|
|
|
|
const { GovernorHelper } = require('../helpers/governance');
|
|
const { OperationState } = require('../helpers/enums');
|
|
const time = require('../helpers/time');
|
|
|
|
const { shouldSupportInterfaces } = require('../utils/introspection/SupportsInterface.behavior');
|
|
|
|
const salt = '0x025e7b0be353a74631ad648c667493c0e1cd31caa4cc2d3520fdc171ea0cc726'; // a random value
|
|
|
|
const MINDELAY = time.duration.days(1);
|
|
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 getAddress = obj => obj.address ?? obj.target ?? obj;
|
|
|
|
function genOperation(target, value, data, predecessor, salt) {
|
|
const id = ethers.keccak256(
|
|
ethers.AbiCoder.defaultAbiCoder().encode(
|
|
['address', 'uint256', 'bytes', 'uint256', 'bytes32'],
|
|
[getAddress(target), value, data, predecessor, salt],
|
|
),
|
|
);
|
|
return { id, target, value, data, predecessor, salt };
|
|
}
|
|
|
|
function genOperationBatch(targets, values, payloads, predecessor, salt) {
|
|
const id = ethers.keccak256(
|
|
ethers.AbiCoder.defaultAbiCoder().encode(
|
|
['address[]', 'uint256[]', 'bytes[]', 'uint256', 'bytes32'],
|
|
[targets.map(getAddress), values, payloads, predecessor, salt],
|
|
),
|
|
);
|
|
return { id, targets, values, payloads, predecessor, salt };
|
|
}
|
|
|
|
async function fixture() {
|
|
const [admin, proposer, canceller, executor, other] = await ethers.getSigners();
|
|
|
|
const mock = await ethers.deployContract('TimelockController', [MINDELAY, [proposer], [executor], admin]);
|
|
const callreceivermock = await ethers.deployContract('CallReceiverMock');
|
|
const implementation2 = await ethers.deployContract('Implementation2');
|
|
|
|
expect(await mock.hasRole(CANCELLER_ROLE, proposer)).to.be.true;
|
|
await mock.connect(admin).revokeRole(CANCELLER_ROLE, proposer);
|
|
await mock.connect(admin).grantRole(CANCELLER_ROLE, canceller);
|
|
|
|
return {
|
|
admin,
|
|
proposer,
|
|
canceller,
|
|
executor,
|
|
other,
|
|
mock,
|
|
callreceivermock,
|
|
implementation2,
|
|
};
|
|
}
|
|
|
|
describe('TimelockController', function () {
|
|
beforeEach(async function () {
|
|
Object.assign(this, await loadFixture(fixture));
|
|
});
|
|
|
|
shouldSupportInterfaces(['ERC1155Receiver']);
|
|
|
|
it('initial state', async function () {
|
|
expect(await this.mock.getMinDelay()).to.equal(MINDELAY);
|
|
|
|
expect(await this.mock.DEFAULT_ADMIN_ROLE()).to.equal(DEFAULT_ADMIN_ROLE);
|
|
expect(await this.mock.PROPOSER_ROLE()).to.equal(PROPOSER_ROLE);
|
|
expect(await this.mock.EXECUTOR_ROLE()).to.equal(EXECUTOR_ROLE);
|
|
expect(await this.mock.CANCELLER_ROLE()).to.equal(CANCELLER_ROLE);
|
|
|
|
expect(
|
|
await Promise.all(
|
|
[PROPOSER_ROLE, CANCELLER_ROLE, EXECUTOR_ROLE].map(role => this.mock.hasRole(role, this.proposer)),
|
|
),
|
|
).to.deep.equal([true, false, false]);
|
|
|
|
expect(
|
|
await Promise.all(
|
|
[PROPOSER_ROLE, CANCELLER_ROLE, EXECUTOR_ROLE].map(role => this.mock.hasRole(role, this.canceller)),
|
|
),
|
|
).to.deep.equal([false, true, false]);
|
|
|
|
expect(
|
|
await Promise.all(
|
|
[PROPOSER_ROLE, CANCELLER_ROLE, EXECUTOR_ROLE].map(role => this.mock.hasRole(role, this.executor)),
|
|
),
|
|
).to.deep.equal([false, false, true]);
|
|
});
|
|
|
|
it('optional admin', async function () {
|
|
const mock = await ethers.deployContract('TimelockController', [
|
|
MINDELAY,
|
|
[this.proposer],
|
|
[this.executor],
|
|
ethers.ZeroAddress,
|
|
]);
|
|
expect(await mock.hasRole(DEFAULT_ADMIN_ROLE, this.admin)).to.be.false;
|
|
expect(await mock.hasRole(DEFAULT_ADMIN_ROLE, mock.target)).to.be.true;
|
|
});
|
|
|
|
describe('methods', function () {
|
|
describe('operation hashing', function () {
|
|
it('hashOperation', async function () {
|
|
this.operation = genOperation(
|
|
'0x29cebefe301c6ce1bb36b58654fea275e1cacc83',
|
|
'0xf94fdd6e21da21d2',
|
|
'0xa3bc5104',
|
|
'0xba41db3be0a9929145cfe480bd0f1f003689104d275ae912099f925df424ef94',
|
|
'0x60d9109846ab510ed75c15f979ae366a8a2ace11d34ba9788c13ac296db50e6e',
|
|
);
|
|
expect(
|
|
await this.mock.hashOperation(
|
|
this.operation.target,
|
|
this.operation.value,
|
|
this.operation.data,
|
|
this.operation.predecessor,
|
|
this.operation.salt,
|
|
),
|
|
).to.equal(this.operation.id);
|
|
});
|
|
|
|
it('hashOperationBatch', async function () {
|
|
this.operation = genOperationBatch(
|
|
Array(8).fill('0x2d5f21620e56531c1d59c2df9b8e95d129571f71'),
|
|
Array(8).fill('0x2b993cfce932ccee'),
|
|
Array(8).fill('0xcf51966b'),
|
|
'0xce8f45069cc71d25f71ba05062de1a3974f9849b004de64a70998bca9d29c2e7',
|
|
'0x8952d74c110f72bfe5accdf828c74d53a7dfb71235dfa8a1e8c75d8576b372ff',
|
|
);
|
|
expect(
|
|
await this.mock.hashOperationBatch(
|
|
this.operation.targets,
|
|
this.operation.values,
|
|
this.operation.payloads,
|
|
this.operation.predecessor,
|
|
this.operation.salt,
|
|
),
|
|
).to.equal(this.operation.id);
|
|
});
|
|
});
|
|
describe('simple', function () {
|
|
describe('schedule', function () {
|
|
beforeEach(async function () {
|
|
this.operation = genOperation(
|
|
'0x31754f590B97fD975Eb86938f18Cc304E264D2F2',
|
|
0n,
|
|
'0x3bf92ccc',
|
|
ethers.ZeroHash,
|
|
salt,
|
|
);
|
|
});
|
|
|
|
it('proposer can schedule', async function () {
|
|
const tx = await this.mock
|
|
.connect(this.proposer)
|
|
.schedule(
|
|
this.operation.target,
|
|
this.operation.value,
|
|
this.operation.data,
|
|
this.operation.predecessor,
|
|
this.operation.salt,
|
|
MINDELAY,
|
|
);
|
|
|
|
await expect(tx)
|
|
.to.emit(this.mock, 'CallScheduled')
|
|
.withArgs(
|
|
this.operation.id,
|
|
0n,
|
|
this.operation.target,
|
|
this.operation.value,
|
|
this.operation.data,
|
|
this.operation.predecessor,
|
|
MINDELAY,
|
|
)
|
|
.to.emit(this.mock, 'CallSalt')
|
|
.withArgs(this.operation.id, this.operation.salt);
|
|
|
|
expect(await this.mock.getTimestamp(this.operation.id)).to.equal(
|
|
(await time.clockFromReceipt.timestamp(tx)) + MINDELAY,
|
|
);
|
|
});
|
|
|
|
it('prevent overwriting active operation', async function () {
|
|
await this.mock
|
|
.connect(this.proposer)
|
|
.schedule(
|
|
this.operation.target,
|
|
this.operation.value,
|
|
this.operation.data,
|
|
this.operation.predecessor,
|
|
this.operation.salt,
|
|
MINDELAY,
|
|
);
|
|
|
|
await expect(
|
|
this.mock
|
|
.connect(this.proposer)
|
|
.schedule(
|
|
this.operation.target,
|
|
this.operation.value,
|
|
this.operation.data,
|
|
this.operation.predecessor,
|
|
this.operation.salt,
|
|
MINDELAY,
|
|
),
|
|
)
|
|
.to.be.revertedWithCustomError(this.mock, 'TimelockUnexpectedOperationState')
|
|
.withArgs(this.operation.id, GovernorHelper.proposalStatesToBitMap(OperationState.Unset));
|
|
});
|
|
|
|
it('prevent non-proposer from committing', async function () {
|
|
await expect(
|
|
this.mock
|
|
.connect(this.other)
|
|
.schedule(
|
|
this.operation.target,
|
|
this.operation.value,
|
|
this.operation.data,
|
|
this.operation.predecessor,
|
|
this.operation.salt,
|
|
MINDELAY,
|
|
),
|
|
)
|
|
.to.be.revertedWithCustomError(this.mock, 'AccessControlUnauthorizedAccount')
|
|
.withArgs(this.other, PROPOSER_ROLE);
|
|
});
|
|
|
|
it('enforce minimum delay', async function () {
|
|
await expect(
|
|
this.mock
|
|
.connect(this.proposer)
|
|
.schedule(
|
|
this.operation.target,
|
|
this.operation.value,
|
|
this.operation.data,
|
|
this.operation.predecessor,
|
|
this.operation.salt,
|
|
MINDELAY - 1n,
|
|
),
|
|
)
|
|
.to.be.revertedWithCustomError(this.mock, 'TimelockInsufficientDelay')
|
|
.withArgs(MINDELAY - 1n, MINDELAY);
|
|
});
|
|
|
|
it('schedule operation with salt zero', async function () {
|
|
await expect(
|
|
this.mock
|
|
.connect(this.proposer)
|
|
.schedule(
|
|
this.operation.target,
|
|
this.operation.value,
|
|
this.operation.data,
|
|
this.operation.predecessor,
|
|
ethers.ZeroHash,
|
|
MINDELAY,
|
|
),
|
|
).to.not.emit(this.mock, 'CallSalt');
|
|
});
|
|
});
|
|
|
|
describe('execute', function () {
|
|
beforeEach(async function () {
|
|
this.operation = genOperation(
|
|
'0xAe22104DCD970750610E6FE15E623468A98b15f7',
|
|
0n,
|
|
'0x13e414de',
|
|
ethers.ZeroHash,
|
|
'0xc1059ed2dc130227aa1d1d539ac94c641306905c020436c636e19e3fab56fc7f',
|
|
);
|
|
});
|
|
|
|
it('revert if operation is not scheduled', async function () {
|
|
await expect(
|
|
this.mock
|
|
.connect(this.executor)
|
|
.execute(
|
|
this.operation.target,
|
|
this.operation.value,
|
|
this.operation.data,
|
|
this.operation.predecessor,
|
|
this.operation.salt,
|
|
),
|
|
)
|
|
.to.be.revertedWithCustomError(this.mock, 'TimelockUnexpectedOperationState')
|
|
.withArgs(this.operation.id, GovernorHelper.proposalStatesToBitMap(OperationState.Ready));
|
|
});
|
|
|
|
describe('with scheduled operation', function () {
|
|
beforeEach(async function () {
|
|
await this.mock
|
|
.connect(this.proposer)
|
|
.schedule(
|
|
this.operation.target,
|
|
this.operation.value,
|
|
this.operation.data,
|
|
this.operation.predecessor,
|
|
this.operation.salt,
|
|
MINDELAY,
|
|
);
|
|
});
|
|
|
|
it('revert if execution comes too early 1/2', async function () {
|
|
await expect(
|
|
this.mock
|
|
.connect(this.executor)
|
|
.execute(
|
|
this.operation.target,
|
|
this.operation.value,
|
|
this.operation.data,
|
|
this.operation.predecessor,
|
|
this.operation.salt,
|
|
),
|
|
)
|
|
.to.be.revertedWithCustomError(this.mock, 'TimelockUnexpectedOperationState')
|
|
.withArgs(this.operation.id, GovernorHelper.proposalStatesToBitMap(OperationState.Ready));
|
|
});
|
|
|
|
it('revert if execution comes too early 2/2', async function () {
|
|
// -1 is too tight, test sometime fails
|
|
await this.mock.getTimestamp(this.operation.id).then(clock => time.increaseTo.timestamp(clock - 5n));
|
|
|
|
await expect(
|
|
this.mock
|
|
.connect(this.executor)
|
|
.execute(
|
|
this.operation.target,
|
|
this.operation.value,
|
|
this.operation.data,
|
|
this.operation.predecessor,
|
|
this.operation.salt,
|
|
),
|
|
)
|
|
.to.be.revertedWithCustomError(this.mock, 'TimelockUnexpectedOperationState')
|
|
.withArgs(this.operation.id, GovernorHelper.proposalStatesToBitMap(OperationState.Ready));
|
|
});
|
|
|
|
describe('on time', function () {
|
|
beforeEach(async function () {
|
|
await this.mock.getTimestamp(this.operation.id).then(time.increaseTo.timestamp);
|
|
});
|
|
|
|
it('executor can reveal', async function () {
|
|
await expect(
|
|
this.mock
|
|
.connect(this.executor)
|
|
.execute(
|
|
this.operation.target,
|
|
this.operation.value,
|
|
this.operation.data,
|
|
this.operation.predecessor,
|
|
this.operation.salt,
|
|
),
|
|
)
|
|
.to.emit(this.mock, 'CallExecuted')
|
|
.withArgs(this.operation.id, 0n, this.operation.target, this.operation.value, this.operation.data);
|
|
});
|
|
|
|
it('prevent non-executor from revealing', async function () {
|
|
await expect(
|
|
this.mock
|
|
.connect(this.other)
|
|
.execute(
|
|
this.operation.target,
|
|
this.operation.value,
|
|
this.operation.data,
|
|
this.operation.predecessor,
|
|
this.operation.salt,
|
|
),
|
|
)
|
|
.to.be.revertedWithCustomError(this.mock, 'AccessControlUnauthorizedAccount')
|
|
.withArgs(this.other, EXECUTOR_ROLE);
|
|
});
|
|
|
|
it('prevents reentrancy execution', async function () {
|
|
// Create operation
|
|
const reentrant = await ethers.deployContract('$TimelockReentrant');
|
|
const reentrantOperation = genOperation(
|
|
reentrant,
|
|
0n,
|
|
reentrant.interface.encodeFunctionData('reenter'),
|
|
ethers.ZeroHash,
|
|
salt,
|
|
);
|
|
|
|
// Schedule so it can be executed
|
|
await this.mock
|
|
.connect(this.proposer)
|
|
.schedule(
|
|
reentrantOperation.target,
|
|
reentrantOperation.value,
|
|
reentrantOperation.data,
|
|
reentrantOperation.predecessor,
|
|
reentrantOperation.salt,
|
|
MINDELAY,
|
|
);
|
|
|
|
// Advance on time to make the operation executable
|
|
await this.mock.getTimestamp(reentrantOperation.id).then(time.increaseTo.timestamp);
|
|
|
|
// Grant executor role to the reentrant contract
|
|
await this.mock.connect(this.admin).grantRole(EXECUTOR_ROLE, reentrant);
|
|
|
|
// Prepare reenter
|
|
const data = this.mock.interface.encodeFunctionData('execute', [
|
|
getAddress(reentrantOperation.target),
|
|
reentrantOperation.value,
|
|
reentrantOperation.data,
|
|
reentrantOperation.predecessor,
|
|
reentrantOperation.salt,
|
|
]);
|
|
await reentrant.enableRentrancy(this.mock, data);
|
|
|
|
// Expect to fail
|
|
await expect(
|
|
this.mock
|
|
.connect(this.executor)
|
|
.execute(
|
|
reentrantOperation.target,
|
|
reentrantOperation.value,
|
|
reentrantOperation.data,
|
|
reentrantOperation.predecessor,
|
|
reentrantOperation.salt,
|
|
),
|
|
)
|
|
.to.be.revertedWithCustomError(this.mock, 'TimelockUnexpectedOperationState')
|
|
.withArgs(reentrantOperation.id, GovernorHelper.proposalStatesToBitMap(OperationState.Ready));
|
|
|
|
// Disable reentrancy
|
|
await reentrant.disableReentrancy();
|
|
const nonReentrantOperation = reentrantOperation; // Not anymore
|
|
|
|
// Try again successfully
|
|
await expect(
|
|
this.mock
|
|
.connect(this.executor)
|
|
.execute(
|
|
nonReentrantOperation.target,
|
|
nonReentrantOperation.value,
|
|
nonReentrantOperation.data,
|
|
nonReentrantOperation.predecessor,
|
|
nonReentrantOperation.salt,
|
|
),
|
|
)
|
|
.to.emit(this.mock, 'CallExecuted')
|
|
.withArgs(
|
|
nonReentrantOperation.id,
|
|
0n,
|
|
getAddress(nonReentrantOperation),
|
|
nonReentrantOperation.value,
|
|
nonReentrantOperation.data,
|
|
);
|
|
});
|
|
});
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('batch', function () {
|
|
describe('schedule', function () {
|
|
beforeEach(async function () {
|
|
this.operation = genOperationBatch(
|
|
Array(8).fill('0xEd912250835c812D4516BBD80BdaEA1bB63a293C'),
|
|
Array(8).fill(0n),
|
|
Array(8).fill('0x2fcb7a88'),
|
|
ethers.ZeroHash,
|
|
'0x6cf9d042ade5de78bed9ffd075eb4b2a4f6b1736932c2dc8af517d6e066f51f5',
|
|
);
|
|
});
|
|
|
|
it('proposer can schedule', async function () {
|
|
const tx = this.mock
|
|
.connect(this.proposer)
|
|
.scheduleBatch(
|
|
this.operation.targets,
|
|
this.operation.values,
|
|
this.operation.payloads,
|
|
this.operation.predecessor,
|
|
this.operation.salt,
|
|
MINDELAY,
|
|
);
|
|
for (const i in this.operation.targets) {
|
|
await expect(tx)
|
|
.to.emit(this.mock, 'CallScheduled')
|
|
.withArgs(
|
|
this.operation.id,
|
|
i,
|
|
getAddress(this.operation.targets[i]),
|
|
this.operation.values[i],
|
|
this.operation.payloads[i],
|
|
this.operation.predecessor,
|
|
MINDELAY,
|
|
)
|
|
.to.emit(this.mock, 'CallSalt')
|
|
.withArgs(this.operation.id, this.operation.salt);
|
|
}
|
|
|
|
expect(await this.mock.getTimestamp(this.operation.id)).to.equal(
|
|
(await time.clockFromReceipt.timestamp(tx)) + MINDELAY,
|
|
);
|
|
});
|
|
|
|
it('prevent overwriting active operation', async function () {
|
|
await this.mock
|
|
.connect(this.proposer)
|
|
.scheduleBatch(
|
|
this.operation.targets,
|
|
this.operation.values,
|
|
this.operation.payloads,
|
|
this.operation.predecessor,
|
|
this.operation.salt,
|
|
MINDELAY,
|
|
);
|
|
|
|
await expect(
|
|
this.mock
|
|
.connect(this.proposer)
|
|
.scheduleBatch(
|
|
this.operation.targets,
|
|
this.operation.values,
|
|
this.operation.payloads,
|
|
this.operation.predecessor,
|
|
this.operation.salt,
|
|
MINDELAY,
|
|
),
|
|
)
|
|
.to.be.revertedWithCustomError(this.mock, 'TimelockUnexpectedOperationState')
|
|
.withArgs(this.operation.id, GovernorHelper.proposalStatesToBitMap(OperationState.Unset));
|
|
});
|
|
|
|
it('length of batch parameter must match #1', async function () {
|
|
await expect(
|
|
this.mock
|
|
.connect(this.proposer)
|
|
.scheduleBatch(
|
|
this.operation.targets,
|
|
[],
|
|
this.operation.payloads,
|
|
this.operation.predecessor,
|
|
this.operation.salt,
|
|
MINDELAY,
|
|
),
|
|
)
|
|
.to.be.revertedWithCustomError(this.mock, 'TimelockInvalidOperationLength')
|
|
.withArgs(this.operation.targets.length, this.operation.payloads.length, 0n);
|
|
});
|
|
|
|
it('length of batch parameter must match #1', async function () {
|
|
await expect(
|
|
this.mock
|
|
.connect(this.proposer)
|
|
.scheduleBatch(
|
|
this.operation.targets,
|
|
this.operation.values,
|
|
[],
|
|
this.operation.predecessor,
|
|
this.operation.salt,
|
|
MINDELAY,
|
|
),
|
|
)
|
|
.to.be.revertedWithCustomError(this.mock, 'TimelockInvalidOperationLength')
|
|
.withArgs(this.operation.targets.length, 0n, this.operation.payloads.length);
|
|
});
|
|
|
|
it('prevent non-proposer from committing', async function () {
|
|
await expect(
|
|
this.mock
|
|
.connect(this.other)
|
|
.scheduleBatch(
|
|
this.operation.targets,
|
|
this.operation.values,
|
|
this.operation.payloads,
|
|
this.operation.predecessor,
|
|
this.operation.salt,
|
|
MINDELAY,
|
|
),
|
|
)
|
|
.to.be.revertedWithCustomError(this.mock, 'AccessControlUnauthorizedAccount')
|
|
.withArgs(this.other, PROPOSER_ROLE);
|
|
});
|
|
|
|
it('enforce minimum delay', async function () {
|
|
await expect(
|
|
this.mock
|
|
.connect(this.proposer)
|
|
.scheduleBatch(
|
|
this.operation.targets,
|
|
this.operation.values,
|
|
this.operation.payloads,
|
|
this.operation.predecessor,
|
|
this.operation.salt,
|
|
MINDELAY - 1n,
|
|
),
|
|
)
|
|
.to.be.revertedWithCustomError(this.mock, 'TimelockInsufficientDelay')
|
|
.withArgs(MINDELAY - 1n, MINDELAY);
|
|
});
|
|
});
|
|
|
|
describe('execute', function () {
|
|
beforeEach(async function () {
|
|
this.operation = genOperationBatch(
|
|
Array(8).fill('0x76E53CcEb05131Ef5248553bEBDb8F70536830b1'),
|
|
Array(8).fill(0n),
|
|
Array(8).fill('0x58a60f63'),
|
|
ethers.ZeroHash,
|
|
'0x9545eeabc7a7586689191f78a5532443698538e54211b5bd4d7dc0fc0102b5c7',
|
|
);
|
|
});
|
|
|
|
it('revert if operation is not scheduled', async function () {
|
|
await expect(
|
|
this.mock
|
|
.connect(this.executor)
|
|
.executeBatch(
|
|
this.operation.targets,
|
|
this.operation.values,
|
|
this.operation.payloads,
|
|
this.operation.predecessor,
|
|
this.operation.salt,
|
|
),
|
|
)
|
|
.to.be.revertedWithCustomError(this.mock, 'TimelockUnexpectedOperationState')
|
|
.withArgs(this.operation.id, GovernorHelper.proposalStatesToBitMap(OperationState.Ready));
|
|
});
|
|
|
|
describe('with scheduled operation', function () {
|
|
beforeEach(async function () {
|
|
await this.mock
|
|
.connect(this.proposer)
|
|
.scheduleBatch(
|
|
this.operation.targets,
|
|
this.operation.values,
|
|
this.operation.payloads,
|
|
this.operation.predecessor,
|
|
this.operation.salt,
|
|
MINDELAY,
|
|
);
|
|
});
|
|
|
|
it('revert if execution comes too early 1/2', async function () {
|
|
await expect(
|
|
this.mock
|
|
.connect(this.executor)
|
|
.executeBatch(
|
|
this.operation.targets,
|
|
this.operation.values,
|
|
this.operation.payloads,
|
|
this.operation.predecessor,
|
|
this.operation.salt,
|
|
),
|
|
)
|
|
.to.be.revertedWithCustomError(this.mock, 'TimelockUnexpectedOperationState')
|
|
.withArgs(this.operation.id, GovernorHelper.proposalStatesToBitMap(OperationState.Ready));
|
|
});
|
|
|
|
it('revert if execution comes too early 2/2', async function () {
|
|
// -1 is to tight, test sometime fails
|
|
await this.mock.getTimestamp(this.operation.id).then(clock => time.increaseTo.timestamp(clock - 5n));
|
|
|
|
await expect(
|
|
this.mock
|
|
.connect(this.executor)
|
|
.executeBatch(
|
|
this.operation.targets,
|
|
this.operation.values,
|
|
this.operation.payloads,
|
|
this.operation.predecessor,
|
|
this.operation.salt,
|
|
),
|
|
)
|
|
.to.be.revertedWithCustomError(this.mock, 'TimelockUnexpectedOperationState')
|
|
.withArgs(this.operation.id, GovernorHelper.proposalStatesToBitMap(OperationState.Ready));
|
|
});
|
|
|
|
describe('on time', function () {
|
|
beforeEach(async function () {
|
|
await this.mock.getTimestamp(this.operation.id).then(time.increaseTo.timestamp);
|
|
});
|
|
|
|
it('executor can reveal', async function () {
|
|
const tx = this.mock
|
|
.connect(this.executor)
|
|
.executeBatch(
|
|
this.operation.targets,
|
|
this.operation.values,
|
|
this.operation.payloads,
|
|
this.operation.predecessor,
|
|
this.operation.salt,
|
|
);
|
|
for (const i in this.operation.targets) {
|
|
await expect(tx)
|
|
.to.emit(this.mock, 'CallExecuted')
|
|
.withArgs(
|
|
this.operation.id,
|
|
i,
|
|
this.operation.targets[i],
|
|
this.operation.values[i],
|
|
this.operation.payloads[i],
|
|
);
|
|
}
|
|
});
|
|
|
|
it('prevent non-executor from revealing', async function () {
|
|
await expect(
|
|
this.mock
|
|
.connect(this.other)
|
|
.executeBatch(
|
|
this.operation.targets,
|
|
this.operation.values,
|
|
this.operation.payloads,
|
|
this.operation.predecessor,
|
|
this.operation.salt,
|
|
),
|
|
)
|
|
.to.be.revertedWithCustomError(this.mock, 'AccessControlUnauthorizedAccount')
|
|
.withArgs(this.other, EXECUTOR_ROLE);
|
|
});
|
|
|
|
it('length mismatch #1', async function () {
|
|
await expect(
|
|
this.mock
|
|
.connect(this.executor)
|
|
.executeBatch(
|
|
[],
|
|
this.operation.values,
|
|
this.operation.payloads,
|
|
this.operation.predecessor,
|
|
this.operation.salt,
|
|
),
|
|
)
|
|
.to.be.revertedWithCustomError(this.mock, 'TimelockInvalidOperationLength')
|
|
.withArgs(0, this.operation.payloads.length, this.operation.values.length);
|
|
});
|
|
|
|
it('length mismatch #2', async function () {
|
|
await expect(
|
|
this.mock
|
|
.connect(this.executor)
|
|
.executeBatch(
|
|
this.operation.targets,
|
|
[],
|
|
this.operation.payloads,
|
|
this.operation.predecessor,
|
|
this.operation.salt,
|
|
),
|
|
)
|
|
.to.be.revertedWithCustomError(this.mock, 'TimelockInvalidOperationLength')
|
|
.withArgs(this.operation.targets.length, this.operation.payloads.length, 0n);
|
|
});
|
|
|
|
it('length mismatch #3', async function () {
|
|
await expect(
|
|
this.mock
|
|
.connect(this.executor)
|
|
.executeBatch(
|
|
this.operation.targets,
|
|
this.operation.values,
|
|
[],
|
|
this.operation.predecessor,
|
|
this.operation.salt,
|
|
),
|
|
)
|
|
.to.be.revertedWithCustomError(this.mock, 'TimelockInvalidOperationLength')
|
|
.withArgs(this.operation.targets.length, 0n, this.operation.values.length);
|
|
});
|
|
|
|
it('prevents reentrancy execution', async function () {
|
|
// Create operation
|
|
const reentrant = await ethers.deployContract('$TimelockReentrant');
|
|
const reentrantBatchOperation = genOperationBatch(
|
|
[reentrant],
|
|
[0n],
|
|
[reentrant.interface.encodeFunctionData('reenter')],
|
|
ethers.ZeroHash,
|
|
salt,
|
|
);
|
|
|
|
// Schedule so it can be executed
|
|
await this.mock
|
|
.connect(this.proposer)
|
|
.scheduleBatch(
|
|
reentrantBatchOperation.targets,
|
|
reentrantBatchOperation.values,
|
|
reentrantBatchOperation.payloads,
|
|
reentrantBatchOperation.predecessor,
|
|
reentrantBatchOperation.salt,
|
|
MINDELAY,
|
|
);
|
|
|
|
// Advance on time to make the operation executable
|
|
await this.mock.getTimestamp(reentrantBatchOperation.id).then(time.increaseTo.timestamp);
|
|
|
|
// Grant executor role to the reentrant contract
|
|
await this.mock.connect(this.admin).grantRole(EXECUTOR_ROLE, reentrant);
|
|
|
|
// Prepare reenter
|
|
const data = this.mock.interface.encodeFunctionData('executeBatch', [
|
|
reentrantBatchOperation.targets.map(getAddress),
|
|
reentrantBatchOperation.values,
|
|
reentrantBatchOperation.payloads,
|
|
reentrantBatchOperation.predecessor,
|
|
reentrantBatchOperation.salt,
|
|
]);
|
|
await reentrant.enableRentrancy(this.mock, data);
|
|
|
|
// Expect to fail
|
|
await expect(
|
|
this.mock
|
|
.connect(this.executor)
|
|
.executeBatch(
|
|
reentrantBatchOperation.targets,
|
|
reentrantBatchOperation.values,
|
|
reentrantBatchOperation.payloads,
|
|
reentrantBatchOperation.predecessor,
|
|
reentrantBatchOperation.salt,
|
|
),
|
|
)
|
|
.to.be.revertedWithCustomError(this.mock, 'TimelockUnexpectedOperationState')
|
|
.withArgs(reentrantBatchOperation.id, GovernorHelper.proposalStatesToBitMap(OperationState.Ready));
|
|
|
|
// Disable reentrancy
|
|
await reentrant.disableReentrancy();
|
|
const nonReentrantBatchOperation = reentrantBatchOperation; // Not anymore
|
|
|
|
// Try again successfully
|
|
const tx = this.mock
|
|
.connect(this.executor)
|
|
.executeBatch(
|
|
nonReentrantBatchOperation.targets,
|
|
nonReentrantBatchOperation.values,
|
|
nonReentrantBatchOperation.payloads,
|
|
nonReentrantBatchOperation.predecessor,
|
|
nonReentrantBatchOperation.salt,
|
|
);
|
|
for (const i in nonReentrantBatchOperation.targets) {
|
|
await expect(tx)
|
|
.to.emit(this.mock, 'CallExecuted')
|
|
.withArgs(
|
|
nonReentrantBatchOperation.id,
|
|
i,
|
|
nonReentrantBatchOperation.targets[i],
|
|
nonReentrantBatchOperation.values[i],
|
|
nonReentrantBatchOperation.payloads[i],
|
|
);
|
|
}
|
|
});
|
|
});
|
|
});
|
|
|
|
it('partial execution', async function () {
|
|
const operation = genOperationBatch(
|
|
[this.callreceivermock, this.callreceivermock, this.callreceivermock],
|
|
[0n, 0n, 0n],
|
|
[
|
|
this.callreceivermock.interface.encodeFunctionData('mockFunction'),
|
|
this.callreceivermock.interface.encodeFunctionData('mockFunctionRevertsNoReason'),
|
|
this.callreceivermock.interface.encodeFunctionData('mockFunction'),
|
|
],
|
|
ethers.ZeroHash,
|
|
'0x8ac04aa0d6d66b8812fb41d39638d37af0a9ab11da507afd65c509f8ed079d3e',
|
|
);
|
|
|
|
await this.mock
|
|
.connect(this.proposer)
|
|
.scheduleBatch(
|
|
operation.targets,
|
|
operation.values,
|
|
operation.payloads,
|
|
operation.predecessor,
|
|
operation.salt,
|
|
MINDELAY,
|
|
);
|
|
|
|
await this.mock.getTimestamp(operation.id).then(time.increaseTo.timestamp);
|
|
|
|
await expect(
|
|
this.mock
|
|
.connect(this.executor)
|
|
.executeBatch(
|
|
operation.targets,
|
|
operation.values,
|
|
operation.payloads,
|
|
operation.predecessor,
|
|
operation.salt,
|
|
),
|
|
).to.be.revertedWithCustomError(this.mock, 'FailedCall');
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('cancel', function () {
|
|
beforeEach(async function () {
|
|
this.operation = genOperation(
|
|
'0xC6837c44AA376dbe1d2709F13879E040CAb653ca',
|
|
0n,
|
|
'0x296e58dd',
|
|
ethers.ZeroHash,
|
|
'0xa2485763600634800df9fc9646fb2c112cf98649c55f63dd1d9c7d13a64399d9',
|
|
);
|
|
await this.mock
|
|
.connect(this.proposer)
|
|
.schedule(
|
|
this.operation.target,
|
|
this.operation.value,
|
|
this.operation.data,
|
|
this.operation.predecessor,
|
|
this.operation.salt,
|
|
MINDELAY,
|
|
);
|
|
});
|
|
|
|
it('canceller can cancel', async function () {
|
|
await expect(this.mock.connect(this.canceller).cancel(this.operation.id))
|
|
.to.emit(this.mock, 'Cancelled')
|
|
.withArgs(this.operation.id);
|
|
});
|
|
|
|
it('cannot cancel invalid operation', async function () {
|
|
await expect(this.mock.connect(this.canceller).cancel(ethers.ZeroHash))
|
|
.to.be.revertedWithCustomError(this.mock, 'TimelockUnexpectedOperationState')
|
|
.withArgs(
|
|
ethers.ZeroHash,
|
|
GovernorHelper.proposalStatesToBitMap([OperationState.Waiting, OperationState.Ready]),
|
|
);
|
|
});
|
|
|
|
it('prevent non-canceller from canceling', async function () {
|
|
await expect(this.mock.connect(this.other).cancel(this.operation.id))
|
|
.to.be.revertedWithCustomError(this.mock, 'AccessControlUnauthorizedAccount')
|
|
.withArgs(this.other, CANCELLER_ROLE);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('maintenance', function () {
|
|
it('prevent unauthorized maintenance', async function () {
|
|
await expect(this.mock.connect(this.other).updateDelay(0n))
|
|
.to.be.revertedWithCustomError(this.mock, 'TimelockUnauthorizedCaller')
|
|
.withArgs(this.other);
|
|
});
|
|
|
|
it('timelock scheduled maintenance', async function () {
|
|
const newDelay = time.duration.hours(6);
|
|
const operation = genOperation(
|
|
this.mock,
|
|
0n,
|
|
this.mock.interface.encodeFunctionData('updateDelay', [newDelay]),
|
|
ethers.ZeroHash,
|
|
'0xf8e775b2c5f4d66fb5c7fa800f35ef518c262b6014b3c0aee6ea21bff157f108',
|
|
);
|
|
|
|
await this.mock
|
|
.connect(this.proposer)
|
|
.schedule(operation.target, operation.value, operation.data, operation.predecessor, operation.salt, MINDELAY);
|
|
|
|
await this.mock.getTimestamp(operation.id).then(time.increaseTo.timestamp);
|
|
|
|
await expect(
|
|
this.mock
|
|
.connect(this.executor)
|
|
.execute(operation.target, operation.value, operation.data, operation.predecessor, operation.salt),
|
|
)
|
|
.to.emit(this.mock, 'MinDelayChange')
|
|
.withArgs(MINDELAY, newDelay);
|
|
|
|
expect(await this.mock.getMinDelay()).to.equal(newDelay);
|
|
});
|
|
});
|
|
|
|
describe('dependency', function () {
|
|
beforeEach(async function () {
|
|
this.operation1 = genOperation(
|
|
'0xdE66bD4c97304200A95aE0AadA32d6d01A867E39',
|
|
0n,
|
|
'0x01dc731a',
|
|
ethers.ZeroHash,
|
|
'0x64e932133c7677402ead2926f86205e2ca4686aebecf5a8077627092b9bb2feb',
|
|
);
|
|
this.operation2 = genOperation(
|
|
'0x3c7944a3F1ee7fc8c5A5134ba7c79D11c3A1FCa3',
|
|
0n,
|
|
'0x8f531849',
|
|
this.operation1.id,
|
|
'0x036e1311cac523f9548e6461e29fb1f8f9196b91910a41711ea22f5de48df07d',
|
|
);
|
|
await this.mock
|
|
.connect(this.proposer)
|
|
.schedule(
|
|
this.operation1.target,
|
|
this.operation1.value,
|
|
this.operation1.data,
|
|
this.operation1.predecessor,
|
|
this.operation1.salt,
|
|
MINDELAY,
|
|
);
|
|
await this.mock
|
|
.connect(this.proposer)
|
|
.schedule(
|
|
this.operation2.target,
|
|
this.operation2.value,
|
|
this.operation2.data,
|
|
this.operation2.predecessor,
|
|
this.operation2.salt,
|
|
MINDELAY,
|
|
);
|
|
|
|
await this.mock.getTimestamp(this.operation2.id).then(time.increaseTo.timestamp);
|
|
});
|
|
|
|
it('cannot execute before dependency', async function () {
|
|
await expect(
|
|
this.mock
|
|
.connect(this.executor)
|
|
.execute(
|
|
this.operation2.target,
|
|
this.operation2.value,
|
|
this.operation2.data,
|
|
this.operation2.predecessor,
|
|
this.operation2.salt,
|
|
),
|
|
)
|
|
.to.be.revertedWithCustomError(this.mock, 'TimelockUnexecutedPredecessor')
|
|
.withArgs(this.operation1.id);
|
|
});
|
|
|
|
it('can execute after dependency', async function () {
|
|
await this.mock
|
|
.connect(this.executor)
|
|
.execute(
|
|
this.operation1.target,
|
|
this.operation1.value,
|
|
this.operation1.data,
|
|
this.operation1.predecessor,
|
|
this.operation1.salt,
|
|
);
|
|
await this.mock
|
|
.connect(this.executor)
|
|
.execute(
|
|
this.operation2.target,
|
|
this.operation2.value,
|
|
this.operation2.data,
|
|
this.operation2.predecessor,
|
|
this.operation2.salt,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('usage scenario', function () {
|
|
this.timeout(10000);
|
|
|
|
it('call', async function () {
|
|
const operation = genOperation(
|
|
this.implementation2,
|
|
0n,
|
|
this.implementation2.interface.encodeFunctionData('setValue', [42n]),
|
|
ethers.ZeroHash,
|
|
'0x8043596363daefc89977b25f9d9b4d06c3910959ef0c4d213557a903e1b555e2',
|
|
);
|
|
|
|
await this.mock
|
|
.connect(this.proposer)
|
|
.schedule(operation.target, operation.value, operation.data, operation.predecessor, operation.salt, MINDELAY);
|
|
|
|
await this.mock.getTimestamp(operation.id).then(time.increaseTo.timestamp);
|
|
|
|
await this.mock
|
|
.connect(this.executor)
|
|
.execute(operation.target, operation.value, operation.data, operation.predecessor, operation.salt);
|
|
|
|
expect(await this.implementation2.getValue()).to.equal(42n);
|
|
});
|
|
|
|
it('call reverting', async function () {
|
|
const operation = genOperation(
|
|
this.callreceivermock,
|
|
0n,
|
|
this.callreceivermock.interface.encodeFunctionData('mockFunctionRevertsNoReason'),
|
|
ethers.ZeroHash,
|
|
'0xb1b1b276fdf1a28d1e00537ea73b04d56639128b08063c1a2f70a52e38cba693',
|
|
);
|
|
|
|
await this.mock
|
|
.connect(this.proposer)
|
|
.schedule(operation.target, operation.value, operation.data, operation.predecessor, operation.salt, MINDELAY);
|
|
|
|
await this.mock.getTimestamp(operation.id).then(time.increaseTo.timestamp);
|
|
|
|
await expect(
|
|
this.mock
|
|
.connect(this.executor)
|
|
.execute(operation.target, operation.value, operation.data, operation.predecessor, operation.salt),
|
|
).to.be.revertedWithCustomError(this.mock, 'FailedCall');
|
|
});
|
|
|
|
it('call throw', async function () {
|
|
const operation = genOperation(
|
|
this.callreceivermock,
|
|
0n,
|
|
this.callreceivermock.interface.encodeFunctionData('mockFunctionThrows'),
|
|
ethers.ZeroHash,
|
|
'0xe5ca79f295fc8327ee8a765fe19afb58f4a0cbc5053642bfdd7e73bc68e0fc67',
|
|
);
|
|
|
|
await this.mock
|
|
.connect(this.proposer)
|
|
.schedule(operation.target, operation.value, operation.data, operation.predecessor, operation.salt, MINDELAY);
|
|
|
|
await this.mock.getTimestamp(operation.id).then(time.increaseTo.timestamp);
|
|
|
|
// Targeted function reverts with a panic code (0x1) + the timelock bubble the panic code
|
|
await expect(
|
|
this.mock
|
|
.connect(this.executor)
|
|
.execute(operation.target, operation.value, operation.data, operation.predecessor, operation.salt),
|
|
).to.be.revertedWithPanic(PANIC_CODES.ASSERTION_ERROR);
|
|
});
|
|
|
|
it('call out of gas', async function () {
|
|
const operation = genOperation(
|
|
this.callreceivermock,
|
|
0n,
|
|
this.callreceivermock.interface.encodeFunctionData('mockFunctionOutOfGas'),
|
|
ethers.ZeroHash,
|
|
'0xf3274ce7c394c5b629d5215723563a744b817e1730cca5587c567099a14578fd',
|
|
);
|
|
|
|
await this.mock
|
|
.connect(this.proposer)
|
|
.schedule(operation.target, operation.value, operation.data, operation.predecessor, operation.salt, MINDELAY);
|
|
|
|
await this.mock.getTimestamp(operation.id).then(time.increaseTo.timestamp);
|
|
|
|
await expect(
|
|
this.mock
|
|
.connect(this.executor)
|
|
.execute(operation.target, operation.value, operation.data, operation.predecessor, operation.salt, {
|
|
gasLimit: '100000',
|
|
}),
|
|
).to.be.revertedWithCustomError(this.mock, 'FailedCall');
|
|
});
|
|
|
|
it('call payable with eth', async function () {
|
|
const operation = genOperation(
|
|
this.callreceivermock,
|
|
1,
|
|
this.callreceivermock.interface.encodeFunctionData('mockFunction'),
|
|
ethers.ZeroHash,
|
|
'0x5ab73cd33477dcd36c1e05e28362719d0ed59a7b9ff14939de63a43073dc1f44',
|
|
);
|
|
|
|
await this.mock
|
|
.connect(this.proposer)
|
|
.schedule(operation.target, operation.value, operation.data, operation.predecessor, operation.salt, MINDELAY);
|
|
|
|
await this.mock.getTimestamp(operation.id).then(time.increaseTo.timestamp);
|
|
|
|
expect(await ethers.provider.getBalance(this.mock)).to.equal(0n);
|
|
expect(await ethers.provider.getBalance(this.callreceivermock)).to.equal(0n);
|
|
|
|
await this.mock
|
|
.connect(this.executor)
|
|
.execute(operation.target, operation.value, operation.data, operation.predecessor, operation.salt, {
|
|
value: 1,
|
|
});
|
|
|
|
expect(await ethers.provider.getBalance(this.mock)).to.equal(0n);
|
|
expect(await ethers.provider.getBalance(this.callreceivermock)).to.equal(1n);
|
|
});
|
|
|
|
it('call nonpayable with eth', async function () {
|
|
const operation = genOperation(
|
|
this.callreceivermock,
|
|
1,
|
|
this.callreceivermock.interface.encodeFunctionData('mockFunctionNonPayable'),
|
|
ethers.ZeroHash,
|
|
'0xb78edbd920c7867f187e5aa6294ae5a656cfbf0dea1ccdca3751b740d0f2bdf8',
|
|
);
|
|
|
|
await this.mock
|
|
.connect(this.proposer)
|
|
.schedule(operation.target, operation.value, operation.data, operation.predecessor, operation.salt, MINDELAY);
|
|
|
|
await this.mock.getTimestamp(operation.id).then(time.increaseTo.timestamp);
|
|
|
|
expect(await ethers.provider.getBalance(this.mock)).to.equal(0n);
|
|
expect(await ethers.provider.getBalance(this.callreceivermock)).to.equal(0n);
|
|
|
|
await expect(
|
|
this.mock
|
|
.connect(this.executor)
|
|
.execute(operation.target, operation.value, operation.data, operation.predecessor, operation.salt),
|
|
).to.be.revertedWithCustomError(this.mock, 'FailedCall');
|
|
|
|
expect(await ethers.provider.getBalance(this.mock)).to.equal(0n);
|
|
expect(await ethers.provider.getBalance(this.callreceivermock)).to.equal(0n);
|
|
});
|
|
|
|
it('call reverting with eth', async function () {
|
|
const operation = genOperation(
|
|
this.callreceivermock,
|
|
1,
|
|
this.callreceivermock.interface.encodeFunctionData('mockFunctionRevertsNoReason'),
|
|
ethers.ZeroHash,
|
|
'0xdedb4563ef0095db01d81d3f2decf57cf83e4a72aa792af14c43a792b56f4de6',
|
|
);
|
|
|
|
await this.mock
|
|
.connect(this.proposer)
|
|
.schedule(operation.target, operation.value, operation.data, operation.predecessor, operation.salt, MINDELAY);
|
|
|
|
await this.mock.getTimestamp(operation.id).then(time.increaseTo.timestamp);
|
|
|
|
expect(await ethers.provider.getBalance(this.mock)).to.equal(0n);
|
|
expect(await ethers.provider.getBalance(this.callreceivermock)).to.equal(0n);
|
|
|
|
await expect(
|
|
this.mock
|
|
.connect(this.executor)
|
|
.execute(operation.target, operation.value, operation.data, operation.predecessor, operation.salt),
|
|
).to.be.revertedWithCustomError(this.mock, 'FailedCall');
|
|
|
|
expect(await ethers.provider.getBalance(this.mock)).to.equal(0n);
|
|
expect(await ethers.provider.getBalance(this.callreceivermock)).to.equal(0n);
|
|
});
|
|
});
|
|
|
|
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.other, tokenId);
|
|
});
|
|
|
|
it('can receive an ERC721 safeTransfer', async function () {
|
|
await this.token.connect(this.other).safeTransferFrom(this.other, 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.other, Object.keys(tokenIds), Object.values(tokenIds), '0x');
|
|
});
|
|
|
|
it('can receive ERC1155 safeTransfer', async function () {
|
|
await this.token.connect(this.other).safeTransferFrom(
|
|
this.other,
|
|
this.mock,
|
|
...Object.entries(tokenIds)[0n], // id + amount
|
|
'0x',
|
|
);
|
|
});
|
|
|
|
it('can receive ERC1155 safeBatchTransfer', async function () {
|
|
await this.token
|
|
.connect(this.other)
|
|
.safeBatchTransferFrom(this.other, this.mock, Object.keys(tokenIds), Object.values(tokenIds), '0x');
|
|
});
|
|
});
|
|
});
|
|
});
|
|
|