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

View File

@@ -0,0 +1,3 @@
## Testing
Unit test are critical to OpenZeppelin Contracts. They help ensure code quality and mitigate against security vulnerabilities. The directory structure within the `/test` directory corresponds to the `/contracts` directory.

View File

@@ -0,0 +1,874 @@
const { ethers } = require('hardhat');
const { expect } = require('chai');
const time = require('../helpers/time');
const { shouldSupportInterfaces } = require('../utils/introspection/SupportsInterface.behavior');
const DEFAULT_ADMIN_ROLE = ethers.ZeroHash;
const ROLE = ethers.id('ROLE');
const OTHER_ROLE = ethers.id('OTHER_ROLE');
function shouldBehaveLikeAccessControl() {
beforeEach(async function () {
[this.authorized, this.other, this.otherAdmin] = this.accounts;
});
shouldSupportInterfaces(['AccessControl']);
describe('default admin', function () {
it('deployer has default admin role', async function () {
expect(await this.mock.hasRole(DEFAULT_ADMIN_ROLE, this.defaultAdmin)).to.be.true;
});
it("other roles's admin is the default admin role", async function () {
expect(await this.mock.getRoleAdmin(ROLE)).to.equal(DEFAULT_ADMIN_ROLE);
});
it("default admin role's admin is itself", async function () {
expect(await this.mock.getRoleAdmin(DEFAULT_ADMIN_ROLE)).to.equal(DEFAULT_ADMIN_ROLE);
});
});
describe('granting', function () {
beforeEach(async function () {
await this.mock.connect(this.defaultAdmin).grantRole(ROLE, this.authorized);
});
it('non-admin cannot grant role to other accounts', async function () {
await expect(this.mock.connect(this.other).grantRole(ROLE, this.authorized))
.to.be.revertedWithCustomError(this.mock, 'AccessControlUnauthorizedAccount')
.withArgs(this.other, DEFAULT_ADMIN_ROLE);
});
it('accounts can be granted a role multiple times', async function () {
await this.mock.connect(this.defaultAdmin).grantRole(ROLE, this.authorized);
await expect(this.mock.connect(this.defaultAdmin).grantRole(ROLE, this.authorized)).to.not.emit(
this.mock,
'RoleGranted',
);
});
});
describe('revoking', function () {
it('roles that are not had can be revoked', async function () {
expect(await this.mock.hasRole(ROLE, this.authorized)).to.be.false;
await expect(this.mock.connect(this.defaultAdmin).revokeRole(ROLE, this.authorized)).to.not.emit(
this.mock,
'RoleRevoked',
);
});
describe('with granted role', function () {
beforeEach(async function () {
await this.mock.connect(this.defaultAdmin).grantRole(ROLE, this.authorized);
});
it('admin can revoke role', async function () {
await expect(this.mock.connect(this.defaultAdmin).revokeRole(ROLE, this.authorized))
.to.emit(this.mock, 'RoleRevoked')
.withArgs(ROLE, this.authorized, this.defaultAdmin);
expect(await this.mock.hasRole(ROLE, this.authorized)).to.be.false;
});
it('non-admin cannot revoke role', async function () {
await expect(this.mock.connect(this.other).revokeRole(ROLE, this.authorized))
.to.be.revertedWithCustomError(this.mock, 'AccessControlUnauthorizedAccount')
.withArgs(this.other, DEFAULT_ADMIN_ROLE);
});
it('a role can be revoked multiple times', async function () {
await this.mock.connect(this.defaultAdmin).revokeRole(ROLE, this.authorized);
await expect(this.mock.connect(this.defaultAdmin).revokeRole(ROLE, this.authorized)).to.not.emit(
this.mock,
'RoleRevoked',
);
});
});
});
describe('renouncing', function () {
it('roles that are not had can be renounced', async function () {
await expect(this.mock.connect(this.authorized).renounceRole(ROLE, this.authorized)).to.not.emit(
this.mock,
'RoleRevoked',
);
});
describe('with granted role', function () {
beforeEach(async function () {
await this.mock.connect(this.defaultAdmin).grantRole(ROLE, this.authorized);
});
it('bearer can renounce role', async function () {
await expect(this.mock.connect(this.authorized).renounceRole(ROLE, this.authorized))
.to.emit(this.mock, 'RoleRevoked')
.withArgs(ROLE, this.authorized, this.authorized);
expect(await this.mock.hasRole(ROLE, this.authorized)).to.be.false;
});
it('only the sender can renounce their roles', async function () {
await expect(
this.mock.connect(this.defaultAdmin).renounceRole(ROLE, this.authorized),
).to.be.revertedWithCustomError(this.mock, 'AccessControlBadConfirmation');
});
it('a role can be renounced multiple times', async function () {
await this.mock.connect(this.authorized).renounceRole(ROLE, this.authorized);
await expect(this.mock.connect(this.authorized).renounceRole(ROLE, this.authorized)).not.to.emit(
this.mock,
'RoleRevoked',
);
});
});
});
describe('setting role admin', function () {
beforeEach(async function () {
await expect(this.mock.$_setRoleAdmin(ROLE, OTHER_ROLE))
.to.emit(this.mock, 'RoleAdminChanged')
.withArgs(ROLE, DEFAULT_ADMIN_ROLE, OTHER_ROLE);
await this.mock.connect(this.defaultAdmin).grantRole(OTHER_ROLE, this.otherAdmin);
});
it("a role's admin role can be changed", async function () {
expect(await this.mock.getRoleAdmin(ROLE)).to.equal(OTHER_ROLE);
});
it('the new admin can grant roles', async function () {
await expect(this.mock.connect(this.otherAdmin).grantRole(ROLE, this.authorized))
.to.emit(this.mock, 'RoleGranted')
.withArgs(ROLE, this.authorized, this.otherAdmin);
});
it('the new admin can revoke roles', async function () {
await this.mock.connect(this.otherAdmin).grantRole(ROLE, this.authorized);
await expect(this.mock.connect(this.otherAdmin).revokeRole(ROLE, this.authorized))
.to.emit(this.mock, 'RoleRevoked')
.withArgs(ROLE, this.authorized, this.otherAdmin);
});
it("a role's previous admins no longer grant roles", async function () {
await expect(this.mock.connect(this.defaultAdmin).grantRole(ROLE, this.authorized))
.to.be.revertedWithCustomError(this.mock, 'AccessControlUnauthorizedAccount')
.withArgs(this.defaultAdmin, OTHER_ROLE);
});
it("a role's previous admins no longer revoke roles", async function () {
await expect(this.mock.connect(this.defaultAdmin).revokeRole(ROLE, this.authorized))
.to.be.revertedWithCustomError(this.mock, 'AccessControlUnauthorizedAccount')
.withArgs(this.defaultAdmin, OTHER_ROLE);
});
});
describe('onlyRole modifier', function () {
beforeEach(async function () {
await this.mock.connect(this.defaultAdmin).grantRole(ROLE, this.authorized);
});
it('do not revert if sender has role', async function () {
await this.mock.connect(this.authorized).$_checkRole(ROLE);
});
it("revert if sender doesn't have role #1", async function () {
await expect(this.mock.connect(this.other).$_checkRole(ROLE))
.to.be.revertedWithCustomError(this.mock, 'AccessControlUnauthorizedAccount')
.withArgs(this.other, ROLE);
});
it("revert if sender doesn't have role #2", async function () {
await expect(this.mock.connect(this.authorized).$_checkRole(OTHER_ROLE))
.to.be.revertedWithCustomError(this.mock, 'AccessControlUnauthorizedAccount')
.withArgs(this.authorized, OTHER_ROLE);
});
});
describe('internal functions', function () {
describe('_grantRole', function () {
it('return true if the account does not have the role', async function () {
await expect(this.mock.$_grantRole(ROLE, this.authorized))
.to.emit(this.mock, 'return$_grantRole')
.withArgs(true);
});
it('return false if the account has the role', async function () {
await this.mock.$_grantRole(ROLE, this.authorized);
await expect(this.mock.$_grantRole(ROLE, this.authorized))
.to.emit(this.mock, 'return$_grantRole')
.withArgs(false);
});
});
describe('_revokeRole', function () {
it('return true if the account has the role', async function () {
await this.mock.$_grantRole(ROLE, this.authorized);
await expect(this.mock.$_revokeRole(ROLE, this.authorized))
.to.emit(this.mock, 'return$_revokeRole')
.withArgs(true);
});
it('return false if the account does not have the role', async function () {
await expect(this.mock.$_revokeRole(ROLE, this.authorized))
.to.emit(this.mock, 'return$_revokeRole')
.withArgs(false);
});
});
});
}
function shouldBehaveLikeAccessControlEnumerable() {
beforeEach(async function () {
[this.authorized, this.other, this.otherAdmin, this.otherAuthorized] = this.accounts;
});
shouldSupportInterfaces(['AccessControlEnumerable']);
describe('enumerating', function () {
it('role bearers can be enumerated', async function () {
await this.mock.connect(this.defaultAdmin).grantRole(ROLE, this.authorized);
await this.mock.connect(this.defaultAdmin).grantRole(ROLE, this.other);
await this.mock.connect(this.defaultAdmin).grantRole(ROLE, this.otherAuthorized);
await this.mock.connect(this.defaultAdmin).revokeRole(ROLE, this.other);
const expectedMembers = [this.authorized.address, this.otherAuthorized.address];
const memberCount = await this.mock.getRoleMemberCount(ROLE);
const members = [];
for (let i = 0; i < memberCount; ++i) {
members.push(await this.mock.getRoleMember(ROLE, i));
}
expect(memberCount).to.equal(expectedMembers.length);
expect(members).to.deep.equal(expectedMembers);
expect(await this.mock.getRoleMembers(ROLE)).to.deep.equal(expectedMembers);
});
it('role enumeration should be in sync after renounceRole call', async function () {
expect(await this.mock.getRoleMemberCount(ROLE)).to.equal(0);
await this.mock.connect(this.defaultAdmin).grantRole(ROLE, this.defaultAdmin);
expect(await this.mock.getRoleMemberCount(ROLE)).to.equal(1);
await this.mock.connect(this.defaultAdmin).renounceRole(ROLE, this.defaultAdmin);
expect(await this.mock.getRoleMemberCount(ROLE)).to.equal(0);
});
});
}
function shouldBehaveLikeAccessControlDefaultAdminRules() {
shouldSupportInterfaces(['AccessControlDefaultAdminRules']);
beforeEach(async function () {
[this.newDefaultAdmin, this.other] = this.accounts;
});
for (const getter of ['owner', 'defaultAdmin']) {
describe(`${getter}()`, function () {
it('has a default set to the initial default admin', async function () {
const value = await this.mock[getter]();
expect(value).to.equal(this.defaultAdmin);
expect(await this.mock.hasRole(DEFAULT_ADMIN_ROLE, value)).to.be.true;
});
it('changes if the default admin changes', async function () {
// Starts an admin transfer
await this.mock.connect(this.defaultAdmin).beginDefaultAdminTransfer(this.newDefaultAdmin);
// Wait for acceptance
await time.increaseBy.timestamp(this.delay + 1n, false);
await this.mock.connect(this.newDefaultAdmin).acceptDefaultAdminTransfer();
const value = await this.mock[getter]();
expect(value).to.equal(this.newDefaultAdmin);
});
});
}
describe('pendingDefaultAdmin()', function () {
it('returns 0 if no pending default admin transfer', async function () {
const { newAdmin, schedule } = await this.mock.pendingDefaultAdmin();
expect(newAdmin).to.equal(ethers.ZeroAddress);
expect(schedule).to.equal(0);
});
describe('when there is a scheduled default admin transfer', function () {
beforeEach('begins admin transfer', async function () {
await this.mock.connect(this.defaultAdmin).beginDefaultAdminTransfer(this.newDefaultAdmin);
});
for (const [fromSchedule, tag] of [
[-1n, 'before'],
[0n, 'exactly when'],
[1n, 'after'],
]) {
it(`returns pending admin and schedule ${tag} it passes if not accepted`, async function () {
// Wait until schedule + fromSchedule
const { schedule: firstSchedule } = await this.mock.pendingDefaultAdmin();
await time.increaseTo.timestamp(firstSchedule + fromSchedule);
const { newAdmin, schedule } = await this.mock.pendingDefaultAdmin();
expect(newAdmin).to.equal(this.newDefaultAdmin);
expect(schedule).to.equal(firstSchedule);
});
}
it('returns 0 after schedule passes and the transfer was accepted', async function () {
// Wait after schedule
const { schedule: firstSchedule } = await this.mock.pendingDefaultAdmin();
await time.increaseTo.timestamp(firstSchedule + 1n, false);
// Accepts
await this.mock.connect(this.newDefaultAdmin).acceptDefaultAdminTransfer();
const { newAdmin, schedule } = await this.mock.pendingDefaultAdmin();
expect(newAdmin).to.equal(ethers.ZeroAddress);
expect(schedule).to.equal(0);
});
});
});
describe('defaultAdminDelay()', function () {
it('returns the current delay', async function () {
expect(await this.mock.defaultAdminDelay()).to.equal(this.delay);
});
describe('when there is a scheduled delay change', function () {
const newDelay = 0x1337n; // Any change
beforeEach('begins delay change', async function () {
await this.mock.connect(this.defaultAdmin).changeDefaultAdminDelay(newDelay);
});
for (const [fromSchedule, tag, expectNew, delayTag] of [
[-1n, 'before', false, 'old'],
[0n, 'exactly when', false, 'old'],
[1n, 'after', true, 'new'],
]) {
it(`returns ${delayTag} delay ${tag} delay schedule passes`, async function () {
// Wait until schedule + fromSchedule
const { schedule } = await this.mock.pendingDefaultAdminDelay();
await time.increaseTo.timestamp(schedule + fromSchedule);
const currentDelay = await this.mock.defaultAdminDelay();
expect(currentDelay).to.equal(expectNew ? newDelay : this.delay);
});
}
});
});
describe('pendingDefaultAdminDelay()', function () {
it('returns 0 if not set', async function () {
const { newDelay, schedule } = await this.mock.pendingDefaultAdminDelay();
expect(newDelay).to.equal(0);
expect(schedule).to.equal(0);
});
describe('when there is a scheduled delay change', function () {
const newDelay = 0x1337n; // Any change
beforeEach('begins admin transfer', async function () {
await this.mock.connect(this.defaultAdmin).changeDefaultAdminDelay(newDelay);
});
for (const [fromSchedule, tag, expectedDelay, delayTag, expectZeroSchedule] of [
[-1n, 'before', newDelay, 'new'],
[0n, 'exactly when', newDelay, 'new'],
[1n, 'after', 0, 'zero', true],
]) {
it(`returns ${delayTag} delay ${tag} delay schedule passes`, async function () {
// Wait until schedule + fromSchedule
const { schedule: firstSchedule } = await this.mock.pendingDefaultAdminDelay();
await time.increaseTo.timestamp(firstSchedule + fromSchedule);
const { newDelay, schedule } = await this.mock.pendingDefaultAdminDelay();
expect(newDelay).to.equal(expectedDelay);
expect(schedule).to.equal(expectZeroSchedule ? 0 : firstSchedule);
});
}
});
});
describe('defaultAdminDelayIncreaseWait()', function () {
it('should return 5 days (default)', async function () {
expect(await this.mock.defaultAdminDelayIncreaseWait()).to.equal(time.duration.days(5));
});
});
it('should revert if granting default admin role', async function () {
await expect(
this.mock.connect(this.defaultAdmin).grantRole(DEFAULT_ADMIN_ROLE, this.defaultAdmin),
).to.be.revertedWithCustomError(this.mock, 'AccessControlEnforcedDefaultAdminRules');
});
it('should revert if revoking default admin role', async function () {
await expect(
this.mock.connect(this.defaultAdmin).revokeRole(DEFAULT_ADMIN_ROLE, this.defaultAdmin),
).to.be.revertedWithCustomError(this.mock, 'AccessControlEnforcedDefaultAdminRules');
});
it("should revert if defaultAdmin's admin is changed", async function () {
await expect(this.mock.$_setRoleAdmin(DEFAULT_ADMIN_ROLE, OTHER_ROLE)).to.be.revertedWithCustomError(
this.mock,
'AccessControlEnforcedDefaultAdminRules',
);
});
it('should not grant the default admin role twice', async function () {
await expect(this.mock.$_grantRole(DEFAULT_ADMIN_ROLE, this.defaultAdmin)).to.be.revertedWithCustomError(
this.mock,
'AccessControlEnforcedDefaultAdminRules',
);
});
describe('begins a default admin transfer', function () {
it('reverts if called by non default admin accounts', async function () {
await expect(this.mock.connect(this.other).beginDefaultAdminTransfer(this.newDefaultAdmin))
.to.be.revertedWithCustomError(this.mock, 'AccessControlUnauthorizedAccount')
.withArgs(this.other, DEFAULT_ADMIN_ROLE);
});
describe('when there is no pending delay nor pending admin transfer', function () {
it('should set pending default admin and schedule', async function () {
const nextBlockTimestamp = (await time.clock.timestamp()) + 1n;
const acceptSchedule = nextBlockTimestamp + this.delay;
await time.increaseTo.timestamp(nextBlockTimestamp, false); // set timestamp but don't mine the block yet
await expect(this.mock.connect(this.defaultAdmin).beginDefaultAdminTransfer(this.newDefaultAdmin))
.to.emit(this.mock, 'DefaultAdminTransferScheduled')
.withArgs(this.newDefaultAdmin, acceptSchedule);
const { newAdmin, schedule } = await this.mock.pendingDefaultAdmin();
expect(newAdmin).to.equal(this.newDefaultAdmin);
expect(schedule).to.equal(acceptSchedule);
});
});
describe('when there is a pending admin transfer', function () {
beforeEach('sets a pending default admin transfer', async function () {
await this.mock.connect(this.defaultAdmin).beginDefaultAdminTransfer(this.newDefaultAdmin);
this.acceptSchedule = (await time.clock.timestamp()) + this.delay;
});
for (const [fromSchedule, tag] of [
[-1n, 'before'],
[0n, 'exactly when'],
[1n, 'after'],
]) {
it(`should be able to begin a transfer again ${tag} acceptSchedule passes`, async function () {
// Wait until schedule + fromSchedule
await time.increaseTo.timestamp(this.acceptSchedule + fromSchedule, false);
// defaultAdmin changes its mind and begin again to another address
await expect(this.mock.connect(this.defaultAdmin).beginDefaultAdminTransfer(this.other)).to.emit(
this.mock,
'DefaultAdminTransferCanceled', // Cancellation is always emitted since it was never accepted
);
const newSchedule = (await time.clock.timestamp()) + this.delay;
const { newAdmin, schedule } = await this.mock.pendingDefaultAdmin();
expect(newAdmin).to.equal(this.other);
expect(schedule).to.equal(newSchedule);
});
}
it('should not emit a cancellation event if the new default admin accepted', async function () {
// Wait until the acceptSchedule has passed
await time.increaseTo.timestamp(this.acceptSchedule + 1n, false);
// Accept and restart
await this.mock.connect(this.newDefaultAdmin).acceptDefaultAdminTransfer();
await expect(this.mock.connect(this.newDefaultAdmin).beginDefaultAdminTransfer(this.other)).to.not.emit(
this.mock,
'DefaultAdminTransferCanceled',
);
});
});
describe('when there is a pending delay', function () {
const newDelay = time.duration.hours(3);
beforeEach('schedule a delay change', async function () {
await this.mock.connect(this.defaultAdmin).changeDefaultAdminDelay(newDelay);
({ schedule: this.effectSchedule } = await this.mock.pendingDefaultAdminDelay());
});
for (const [fromSchedule, schedulePassed, expectNewDelay] of [
[-1n, 'before', false],
[0n, 'exactly when', false],
[1n, 'after', true],
]) {
it(`should set the ${
expectNewDelay ? 'new' : 'old'
} delay and apply it to next default admin transfer schedule ${schedulePassed} effectSchedule passed`, async function () {
// Wait until the expected fromSchedule time
const nextBlockTimestamp = this.effectSchedule + fromSchedule;
await time.increaseTo.timestamp(nextBlockTimestamp, false);
// Start the new default admin transfer and get its schedule
const expectedDelay = expectNewDelay ? newDelay : this.delay;
const expectedAcceptSchedule = nextBlockTimestamp + expectedDelay;
await expect(this.mock.connect(this.defaultAdmin).beginDefaultAdminTransfer(this.newDefaultAdmin))
.to.emit(this.mock, 'DefaultAdminTransferScheduled')
.withArgs(this.newDefaultAdmin, expectedAcceptSchedule);
// Check that the schedule corresponds with the new delay
const { newAdmin, schedule: transferSchedule } = await this.mock.pendingDefaultAdmin();
expect(newAdmin).to.equal(this.newDefaultAdmin);
expect(transferSchedule).to.equal(expectedAcceptSchedule);
});
}
});
});
describe('accepts transfer admin', function () {
beforeEach(async function () {
await this.mock.connect(this.defaultAdmin).beginDefaultAdminTransfer(this.newDefaultAdmin);
this.acceptSchedule = (await time.clock.timestamp()) + this.delay;
});
it('should revert if caller is not pending default admin', async function () {
await time.increaseTo.timestamp(this.acceptSchedule + 1n, false);
await expect(this.mock.connect(this.other).acceptDefaultAdminTransfer())
.to.be.revertedWithCustomError(this.mock, 'AccessControlInvalidDefaultAdmin')
.withArgs(this.other);
});
describe('when caller is pending default admin and delay has passed', function () {
beforeEach(async function () {
await time.increaseTo.timestamp(this.acceptSchedule + 1n, false);
});
it('accepts a transfer and changes default admin', async function () {
// Emit events
await expect(this.mock.connect(this.newDefaultAdmin).acceptDefaultAdminTransfer())
.to.emit(this.mock, 'RoleRevoked')
.withArgs(DEFAULT_ADMIN_ROLE, this.defaultAdmin, this.newDefaultAdmin)
.to.emit(this.mock, 'RoleGranted')
.withArgs(DEFAULT_ADMIN_ROLE, this.newDefaultAdmin, this.newDefaultAdmin);
// Storage changes
expect(await this.mock.hasRole(DEFAULT_ADMIN_ROLE, this.defaultAdmin)).to.be.false;
expect(await this.mock.hasRole(DEFAULT_ADMIN_ROLE, this.newDefaultAdmin)).to.be.true;
expect(await this.mock.owner()).to.equal(this.newDefaultAdmin);
// Resets pending default admin and schedule
const { newAdmin, schedule } = await this.mock.pendingDefaultAdmin();
expect(newAdmin).to.equal(ethers.ZeroAddress);
expect(schedule).to.equal(0);
});
});
describe('schedule not passed', function () {
for (const [fromSchedule, tag] of [
[-1n, 'less'],
[0n, 'equal'],
]) {
it(`should revert if block.timestamp is ${tag} to schedule`, async function () {
await time.increaseTo.timestamp(this.acceptSchedule + fromSchedule, false);
await expect(this.mock.connect(this.newDefaultAdmin).acceptDefaultAdminTransfer())
.to.be.revertedWithCustomError(this.mock, 'AccessControlEnforcedDefaultAdminDelay')
.withArgs(this.acceptSchedule);
});
}
});
});
describe('cancels a default admin transfer', function () {
it('reverts if called by non default admin accounts', async function () {
await expect(this.mock.connect(this.other).cancelDefaultAdminTransfer())
.to.be.revertedWithCustomError(this.mock, 'AccessControlUnauthorizedAccount')
.withArgs(this.other, DEFAULT_ADMIN_ROLE);
});
describe('when there is a pending default admin transfer', function () {
beforeEach(async function () {
await this.mock.connect(this.defaultAdmin).beginDefaultAdminTransfer(this.newDefaultAdmin);
this.acceptSchedule = (await time.clock.timestamp()) + this.delay;
});
for (const [fromSchedule, tag] of [
[-1n, 'before'],
[0n, 'exactly when'],
[1n, 'after'],
]) {
it(`resets pending default admin and schedule ${tag} transfer schedule passes`, async function () {
// Advance until passed delay
await time.increaseTo.timestamp(this.acceptSchedule + fromSchedule, false);
await expect(this.mock.connect(this.defaultAdmin).cancelDefaultAdminTransfer()).to.emit(
this.mock,
'DefaultAdminTransferCanceled',
);
const { newAdmin, schedule } = await this.mock.pendingDefaultAdmin();
expect(newAdmin).to.equal(ethers.ZeroAddress);
expect(schedule).to.equal(0);
});
}
it('should revert if the previous default admin tries to accept', async function () {
await this.mock.connect(this.defaultAdmin).cancelDefaultAdminTransfer();
// Advance until passed delay
await time.increaseTo.timestamp(this.acceptSchedule + 1n, false);
// Previous pending default admin should not be able to accept after cancellation.
await expect(this.mock.connect(this.newDefaultAdmin).acceptDefaultAdminTransfer())
.to.be.revertedWithCustomError(this.mock, 'AccessControlInvalidDefaultAdmin')
.withArgs(this.newDefaultAdmin);
});
});
describe('when there is no pending default admin transfer', function () {
it('should succeed without changes', async function () {
await expect(this.mock.connect(this.defaultAdmin).cancelDefaultAdminTransfer()).to.not.emit(
this.mock,
'DefaultAdminTransferCanceled',
);
const { newAdmin, schedule } = await this.mock.pendingDefaultAdmin();
expect(newAdmin).to.equal(ethers.ZeroAddress);
expect(schedule).to.equal(0);
});
});
});
describe('renounces admin', function () {
beforeEach(async function () {
await this.mock.connect(this.defaultAdmin).beginDefaultAdminTransfer(ethers.ZeroAddress);
this.expectedSchedule = (await time.clock.timestamp()) + this.delay;
});
it('reverts if caller is not default admin', async function () {
await time.increaseBy.timestamp(this.delay + 1n, false);
await expect(
this.mock.connect(this.defaultAdmin).renounceRole(DEFAULT_ADMIN_ROLE, this.other),
).to.be.revertedWithCustomError(this.mock, 'AccessControlBadConfirmation');
});
it("renouncing the admin role when not an admin doesn't affect the schedule", async function () {
await time.increaseBy.timestamp(this.delay + 1n, false);
await this.mock.connect(this.other).renounceRole(DEFAULT_ADMIN_ROLE, this.other);
const { newAdmin, schedule } = await this.mock.pendingDefaultAdmin();
expect(newAdmin).to.equal(ethers.ZeroAddress);
expect(schedule).to.equal(this.expectedSchedule);
});
it('keeps defaultAdmin consistent with hasRole if another non-defaultAdmin user renounces the DEFAULT_ADMIN_ROLE', async function () {
await time.increaseBy.timestamp(this.delay + 1n, false);
// This passes because it's a noop
await this.mock.connect(this.other).renounceRole(DEFAULT_ADMIN_ROLE, this.other);
expect(await this.mock.hasRole(DEFAULT_ADMIN_ROLE, this.defaultAdmin)).to.be.true;
expect(await this.mock.defaultAdmin()).to.equal(this.defaultAdmin);
});
it('renounces role', async function () {
await time.increaseBy.timestamp(this.delay + 1n, false);
await expect(this.mock.connect(this.defaultAdmin).renounceRole(DEFAULT_ADMIN_ROLE, this.defaultAdmin))
.to.emit(this.mock, 'RoleRevoked')
.withArgs(DEFAULT_ADMIN_ROLE, this.defaultAdmin, this.defaultAdmin);
expect(await this.mock.hasRole(DEFAULT_ADMIN_ROLE, this.defaultAdmin)).to.be.false;
expect(await this.mock.defaultAdmin()).to.equal(ethers.ZeroAddress);
expect(await this.mock.owner()).to.equal(ethers.ZeroAddress);
const { newAdmin, schedule } = await this.mock.pendingDefaultAdmin();
expect(newAdmin).to.equal(ethers.ZeroAddress);
expect(schedule).to.equal(0);
});
it('allows to recover access using the internal _grantRole', async function () {
await time.increaseBy.timestamp(this.delay + 1n, false);
await this.mock.connect(this.defaultAdmin).renounceRole(DEFAULT_ADMIN_ROLE, this.defaultAdmin);
await expect(this.mock.connect(this.defaultAdmin).$_grantRole(DEFAULT_ADMIN_ROLE, this.other))
.to.emit(this.mock, 'RoleGranted')
.withArgs(DEFAULT_ADMIN_ROLE, this.other, this.defaultAdmin);
});
describe('schedule not passed', function () {
for (const [fromSchedule, tag] of [
[-1n, 'less'],
[0n, 'equal'],
]) {
it(`reverts if block.timestamp is ${tag} to schedule`, async function () {
await time.increaseBy.timestamp(this.delay + fromSchedule, false);
await expect(this.mock.connect(this.defaultAdmin).renounceRole(DEFAULT_ADMIN_ROLE, this.defaultAdmin))
.to.be.revertedWithCustomError(this.mock, 'AccessControlEnforcedDefaultAdminDelay')
.withArgs(this.expectedSchedule);
});
}
});
});
describe('changes delay', function () {
it('reverts if called by non default admin accounts', async function () {
await expect(this.mock.connect(this.other).changeDefaultAdminDelay(time.duration.hours(4)))
.to.be.revertedWithCustomError(this.mock, 'AccessControlUnauthorizedAccount')
.withArgs(this.other, DEFAULT_ADMIN_ROLE);
});
for (const [delayDifference, delayChangeType] of [
[time.duration.hours(-1), 'decreased'],
[time.duration.hours(1), 'increased'],
[time.duration.days(5), 'increased to more than 5 days'],
]) {
describe(`when the delay is ${delayChangeType}`, function () {
beforeEach(function () {
this.newDefaultAdminDelay = this.delay + delayDifference;
});
it('begins the delay change to the new delay', async function () {
// Calculate expected values
const capWait = await this.mock.defaultAdminDelayIncreaseWait();
const minWait = capWait < this.newDefaultAdminDelay ? capWait : this.newDefaultAdminDelay;
const changeDelay =
this.newDefaultAdminDelay <= this.delay ? this.delay - this.newDefaultAdminDelay : minWait;
const nextBlockTimestamp = (await time.clock.timestamp()) + 1n;
const effectSchedule = nextBlockTimestamp + changeDelay;
await time.increaseTo.timestamp(nextBlockTimestamp, false);
// Begins the change
await expect(this.mock.connect(this.defaultAdmin).changeDefaultAdminDelay(this.newDefaultAdminDelay))
.to.emit(this.mock, 'DefaultAdminDelayChangeScheduled')
.withArgs(this.newDefaultAdminDelay, effectSchedule);
// Assert
const { newDelay, schedule } = await this.mock.pendingDefaultAdminDelay();
expect(newDelay).to.equal(this.newDefaultAdminDelay);
expect(schedule).to.equal(effectSchedule);
});
describe('scheduling again', function () {
beforeEach('schedule once', async function () {
await this.mock.connect(this.defaultAdmin).changeDefaultAdminDelay(this.newDefaultAdminDelay);
});
for (const [fromSchedule, tag] of [
[-1n, 'before'],
[0n, 'exactly when'],
[1n, 'after'],
]) {
const passed = fromSchedule > 0;
it(`succeeds ${tag} the delay schedule passes`, async function () {
// Wait until schedule + fromSchedule
const { schedule: firstSchedule } = await this.mock.pendingDefaultAdminDelay();
const nextBlockTimestamp = firstSchedule + fromSchedule;
await time.increaseTo.timestamp(nextBlockTimestamp, false);
// Calculate expected values
const anotherNewDefaultAdminDelay = this.newDefaultAdminDelay + time.duration.hours(2);
const capWait = await this.mock.defaultAdminDelayIncreaseWait();
const minWait = capWait < anotherNewDefaultAdminDelay ? capWait : anotherNewDefaultAdminDelay;
const effectSchedule = nextBlockTimestamp + minWait;
// Default admin changes its mind and begins another delay change
await expect(this.mock.connect(this.defaultAdmin).changeDefaultAdminDelay(anotherNewDefaultAdminDelay))
.to.emit(this.mock, 'DefaultAdminDelayChangeScheduled')
.withArgs(anotherNewDefaultAdminDelay, effectSchedule);
// Assert
const { newDelay, schedule } = await this.mock.pendingDefaultAdminDelay();
expect(newDelay).to.equal(anotherNewDefaultAdminDelay);
expect(schedule).to.equal(effectSchedule);
});
const emit = passed ? 'not emit' : 'emit';
it(`should ${emit} a cancellation event ${tag} the delay schedule passes`, async function () {
// Wait until schedule + fromSchedule
const { schedule: firstSchedule } = await this.mock.pendingDefaultAdminDelay();
await time.increaseTo.timestamp(firstSchedule + fromSchedule, false);
// Default admin changes its mind and begins another delay change
const anotherNewDefaultAdminDelay = this.newDefaultAdminDelay + time.duration.hours(2);
const expected = expect(
this.mock.connect(this.defaultAdmin).changeDefaultAdminDelay(anotherNewDefaultAdminDelay),
);
if (passed) {
await expected.to.not.emit(this.mock, 'DefaultAdminDelayChangeCanceled');
} else {
await expected.to.emit(this.mock, 'DefaultAdminDelayChangeCanceled');
}
});
}
});
});
}
});
describe('rollbacks a delay change', function () {
it('reverts if called by non default admin accounts', async function () {
await expect(this.mock.connect(this.other).rollbackDefaultAdminDelay())
.to.be.revertedWithCustomError(this.mock, 'AccessControlUnauthorizedAccount')
.withArgs(this.other, DEFAULT_ADMIN_ROLE);
});
describe('when there is a pending delay', function () {
beforeEach('set pending delay', async function () {
await this.mock.connect(this.defaultAdmin).changeDefaultAdminDelay(time.duration.hours(12));
});
for (const [fromSchedule, tag] of [
[-1n, 'before'],
[0n, 'exactly when'],
[1n, 'after'],
]) {
const passed = fromSchedule > 0;
it(`resets pending delay and schedule ${tag} delay change schedule passes`, async function () {
// Wait until schedule + fromSchedule
const { schedule: firstSchedule } = await this.mock.pendingDefaultAdminDelay();
await time.increaseTo.timestamp(firstSchedule + fromSchedule, false);
await this.mock.connect(this.defaultAdmin).rollbackDefaultAdminDelay();
const { newDelay, schedule } = await this.mock.pendingDefaultAdminDelay();
expect(newDelay).to.equal(0);
expect(schedule).to.equal(0);
});
const emit = passed ? 'not emit' : 'emit';
it(`should ${emit} a cancellation event ${tag} the delay schedule passes`, async function () {
// Wait until schedule + fromSchedule
const { schedule: firstSchedule } = await this.mock.pendingDefaultAdminDelay();
await time.increaseTo.timestamp(firstSchedule + fromSchedule, false);
const expected = expect(this.mock.connect(this.defaultAdmin).rollbackDefaultAdminDelay());
if (passed) {
await expected.to.not.emit(this.mock, 'DefaultAdminDelayChangeCanceled');
} else {
await expected.to.emit(this.mock, 'DefaultAdminDelayChangeCanceled');
}
});
}
});
describe('when there is no pending delay', function () {
it('succeeds without changes', async function () {
await this.mock.connect(this.defaultAdmin).rollbackDefaultAdminDelay();
const { newDelay, schedule } = await this.mock.pendingDefaultAdminDelay();
expect(newDelay).to.equal(0);
expect(schedule).to.equal(0);
});
});
});
}
module.exports = {
DEFAULT_ADMIN_ROLE,
shouldBehaveLikeAccessControl,
shouldBehaveLikeAccessControlEnumerable,
shouldBehaveLikeAccessControlDefaultAdminRules,
};

View File

@@ -0,0 +1,19 @@
const { ethers } = require('hardhat');
const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers');
const { DEFAULT_ADMIN_ROLE, shouldBehaveLikeAccessControl } = require('./AccessControl.behavior');
async function fixture() {
const [defaultAdmin, ...accounts] = await ethers.getSigners();
const mock = await ethers.deployContract('$AccessControl');
await mock.$_grantRole(DEFAULT_ADMIN_ROLE, defaultAdmin);
return { mock, defaultAdmin, accounts };
}
describe('AccessControl', function () {
beforeEach(async function () {
Object.assign(this, await loadFixture(fixture));
});
shouldBehaveLikeAccessControl();
});

View File

@@ -0,0 +1,79 @@
const { ethers } = require('hardhat');
const { expect } = require('chai');
const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers');
async function fixture() {
const [owner, other] = await ethers.getSigners();
const ownable = await ethers.deployContract('$Ownable', [owner]);
return { owner, other, ownable };
}
describe('Ownable', function () {
beforeEach(async function () {
Object.assign(this, await loadFixture(fixture));
});
it('emits ownership transfer events during construction', async function () {
await expect(this.ownable.deploymentTransaction())
.to.emit(this.ownable, 'OwnershipTransferred')
.withArgs(ethers.ZeroAddress, this.owner);
});
it('rejects zero address for initialOwner', async function () {
await expect(ethers.deployContract('$Ownable', [ethers.ZeroAddress]))
.to.be.revertedWithCustomError({ interface: this.ownable.interface }, 'OwnableInvalidOwner')
.withArgs(ethers.ZeroAddress);
});
it('has an owner', async function () {
expect(await this.ownable.owner()).to.equal(this.owner);
});
describe('transfer ownership', function () {
it('changes owner after transfer', async function () {
await expect(this.ownable.connect(this.owner).transferOwnership(this.other))
.to.emit(this.ownable, 'OwnershipTransferred')
.withArgs(this.owner, this.other);
expect(await this.ownable.owner()).to.equal(this.other);
});
it('prevents non-owners from transferring', async function () {
await expect(this.ownable.connect(this.other).transferOwnership(this.other))
.to.be.revertedWithCustomError(this.ownable, 'OwnableUnauthorizedAccount')
.withArgs(this.other);
});
it('guards ownership against stuck state', async function () {
await expect(this.ownable.connect(this.owner).transferOwnership(ethers.ZeroAddress))
.to.be.revertedWithCustomError(this.ownable, 'OwnableInvalidOwner')
.withArgs(ethers.ZeroAddress);
});
});
describe('renounce ownership', function () {
it('loses ownership after renouncement', async function () {
await expect(this.ownable.connect(this.owner).renounceOwnership())
.to.emit(this.ownable, 'OwnershipTransferred')
.withArgs(this.owner, ethers.ZeroAddress);
expect(await this.ownable.owner()).to.equal(ethers.ZeroAddress);
});
it('prevents non-owners from renouncement', async function () {
await expect(this.ownable.connect(this.other).renounceOwnership())
.to.be.revertedWithCustomError(this.ownable, 'OwnableUnauthorizedAccount')
.withArgs(this.other);
});
it('allows to recover access using the internal _transferOwnership', async function () {
await this.ownable.connect(this.owner).renounceOwnership();
await expect(this.ownable.$_transferOwnership(this.other))
.to.emit(this.ownable, 'OwnershipTransferred')
.withArgs(ethers.ZeroAddress, this.other);
expect(await this.ownable.owner()).to.equal(this.other);
});
});
});

View File

@@ -0,0 +1,85 @@
const { ethers } = require('hardhat');
const { expect } = require('chai');
const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers');
async function fixture() {
const [owner, accountA, accountB] = await ethers.getSigners();
const ownable2Step = await ethers.deployContract('$Ownable2Step', [owner]);
return {
ownable2Step,
owner,
accountA,
accountB,
};
}
describe('Ownable2Step', function () {
beforeEach(async function () {
Object.assign(this, await loadFixture(fixture));
});
describe('transfer ownership', function () {
it('starting a transfer does not change owner', async function () {
await expect(this.ownable2Step.connect(this.owner).transferOwnership(this.accountA))
.to.emit(this.ownable2Step, 'OwnershipTransferStarted')
.withArgs(this.owner, this.accountA);
expect(await this.ownable2Step.owner()).to.equal(this.owner);
expect(await this.ownable2Step.pendingOwner()).to.equal(this.accountA);
});
it('changes owner after transfer', async function () {
await this.ownable2Step.connect(this.owner).transferOwnership(this.accountA);
await expect(this.ownable2Step.connect(this.accountA).acceptOwnership())
.to.emit(this.ownable2Step, 'OwnershipTransferred')
.withArgs(this.owner, this.accountA);
expect(await this.ownable2Step.owner()).to.equal(this.accountA);
expect(await this.ownable2Step.pendingOwner()).to.equal(ethers.ZeroAddress);
});
it('guards transfer against invalid user', async function () {
await this.ownable2Step.connect(this.owner).transferOwnership(this.accountA);
await expect(this.ownable2Step.connect(this.accountB).acceptOwnership())
.to.be.revertedWithCustomError(this.ownable2Step, 'OwnableUnauthorizedAccount')
.withArgs(this.accountB);
});
});
describe('renouncing ownership', function () {
it('changes owner after renouncing ownership', async function () {
await expect(this.ownable2Step.connect(this.owner).renounceOwnership())
.to.emit(this.ownable2Step, 'OwnershipTransferred')
.withArgs(this.owner, ethers.ZeroAddress);
// If renounceOwnership is removed from parent an alternative is needed ...
// without it is difficult to cleanly renounce with the two step process
// see: https://github.com/OpenZeppelin/openzeppelin-contracts/pull/3620#discussion_r957930388
expect(await this.ownable2Step.owner()).to.equal(ethers.ZeroAddress);
});
it('pending owner resets after renouncing ownership', async function () {
await this.ownable2Step.connect(this.owner).transferOwnership(this.accountA);
expect(await this.ownable2Step.pendingOwner()).to.equal(this.accountA);
await this.ownable2Step.connect(this.owner).renounceOwnership();
expect(await this.ownable2Step.pendingOwner()).to.equal(ethers.ZeroAddress);
await expect(this.ownable2Step.connect(this.accountA).acceptOwnership())
.to.be.revertedWithCustomError(this.ownable2Step, 'OwnableUnauthorizedAccount')
.withArgs(this.accountA);
});
it('allows to recover access using the internal _transferOwnership', async function () {
await this.ownable2Step.connect(this.owner).renounceOwnership();
await expect(this.ownable2Step.$_transferOwnership(this.accountA))
.to.emit(this.ownable2Step, 'OwnershipTransferred')
.withArgs(ethers.ZeroAddress, this.accountA);
expect(await this.ownable2Step.owner()).to.equal(this.accountA);
});
});
});

View File

@@ -0,0 +1,32 @@
const { ethers } = require('hardhat');
const { expect } = require('chai');
const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers');
const time = require('../../helpers/time');
const {
shouldBehaveLikeAccessControl,
shouldBehaveLikeAccessControlDefaultAdminRules,
} = require('../AccessControl.behavior');
async function fixture() {
const delay = time.duration.hours(10);
const [defaultAdmin, ...accounts] = await ethers.getSigners();
const mock = await ethers.deployContract('$AccessControlDefaultAdminRules', [delay, defaultAdmin]);
return { mock, defaultAdmin, delay, accounts };
}
describe('AccessControlDefaultAdminRules', function () {
beforeEach(async function () {
Object.assign(this, await loadFixture(fixture));
});
it('initial admin not zero', async function () {
await expect(ethers.deployContract('$AccessControlDefaultAdminRules', [this.delay, ethers.ZeroAddress]))
.to.be.revertedWithCustomError(this.mock, 'AccessControlInvalidDefaultAdmin')
.withArgs(ethers.ZeroAddress);
});
shouldBehaveLikeAccessControl();
shouldBehaveLikeAccessControlDefaultAdminRules();
});

View File

@@ -0,0 +1,24 @@
const { ethers } = require('hardhat');
const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers');
const {
DEFAULT_ADMIN_ROLE,
shouldBehaveLikeAccessControl,
shouldBehaveLikeAccessControlEnumerable,
} = require('../AccessControl.behavior');
async function fixture() {
const [defaultAdmin, ...accounts] = await ethers.getSigners();
const mock = await ethers.deployContract('$AccessControlEnumerable');
await mock.$_grantRole(DEFAULT_ADMIN_ROLE, defaultAdmin);
return { mock, defaultAdmin, accounts };
}
describe('AccessControlEnumerable', function () {
beforeEach(async function () {
Object.assign(this, await loadFixture(fixture));
});
shouldBehaveLikeAccessControl();
shouldBehaveLikeAccessControlEnumerable();
});

View File

@@ -0,0 +1,146 @@
const { ethers } = require('hardhat');
const { expect } = require('chai');
const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers');
const { impersonate } = require('../../helpers/account');
const time = require('../../helpers/time');
async function fixture() {
const [admin, roleMember, other] = await ethers.getSigners();
const authority = await ethers.deployContract('$AccessManager', [admin]);
const managed = await ethers.deployContract('$AccessManagedTarget', [authority]);
const anotherAuthority = await ethers.deployContract('$AccessManager', [admin]);
const authorityObserveIsConsuming = await ethers.deployContract('$AuthorityObserveIsConsuming');
await impersonate(authority.target);
const authorityAsSigner = await ethers.getSigner(authority.target);
return {
roleMember,
other,
authorityAsSigner,
authority,
managed,
authorityObserveIsConsuming,
anotherAuthority,
};
}
describe('AccessManaged', function () {
beforeEach(async function () {
Object.assign(this, await loadFixture(fixture));
});
it('sets authority and emits AuthorityUpdated event during construction', async function () {
await expect(this.managed.deploymentTransaction())
.to.emit(this.managed, 'AuthorityUpdated')
.withArgs(this.authority);
});
describe('restricted modifier', function () {
beforeEach(async function () {
this.selector = this.managed.fnRestricted.getFragment().selector;
this.role = 42n;
await this.authority.$_setTargetFunctionRole(this.managed, this.selector, this.role);
await this.authority.$_grantRole(this.role, this.roleMember, 0, 0);
});
it('succeeds when role is granted without execution delay', async function () {
await this.managed.connect(this.roleMember)[this.selector]();
});
it('reverts when role is not granted', async function () {
await expect(this.managed.connect(this.other)[this.selector]())
.to.be.revertedWithCustomError(this.managed, 'AccessManagedUnauthorized')
.withArgs(this.other);
});
it('panics in short calldata', async function () {
// We avoid adding the `restricted` modifier to the fallback function because other tests may depend on it
// being accessible without restrictions. We check for the internal `_checkCanCall` instead.
await expect(this.managed.$_checkCanCall(this.roleMember, '0x1234')).to.be.reverted;
});
describe('when role is granted with execution delay', function () {
beforeEach(async function () {
const executionDelay = 911n;
await this.authority.$_grantRole(this.role, this.roleMember, 0, executionDelay);
});
it('reverts if the operation is not scheduled', async function () {
const fn = this.managed.interface.getFunction(this.selector);
const calldata = this.managed.interface.encodeFunctionData(fn, []);
const opId = await this.authority.hashOperation(this.roleMember, this.managed, calldata);
await expect(this.managed.connect(this.roleMember)[this.selector]())
.to.be.revertedWithCustomError(this.authority, 'AccessManagerNotScheduled')
.withArgs(opId);
});
it('succeeds if the operation is scheduled', async function () {
// Arguments
const delay = time.duration.hours(12);
const fn = this.managed.interface.getFunction(this.selector);
const calldata = this.managed.interface.encodeFunctionData(fn, []);
// Schedule
const scheduledAt = (await time.clock.timestamp()) + 1n;
const when = scheduledAt + delay;
await time.increaseTo.timestamp(scheduledAt, false);
await this.authority.connect(this.roleMember).schedule(this.managed, calldata, when);
// Set execution date
await time.increaseTo.timestamp(when, false);
// Shouldn't revert
await this.managed.connect(this.roleMember)[this.selector]();
});
});
});
describe('setAuthority', function () {
it('reverts if the caller is not the authority', async function () {
await expect(this.managed.connect(this.other).setAuthority(this.other))
.to.be.revertedWithCustomError(this.managed, 'AccessManagedUnauthorized')
.withArgs(this.other);
});
it('reverts if the new authority is not a valid authority', async function () {
await expect(this.managed.connect(this.authorityAsSigner).setAuthority(this.other))
.to.be.revertedWithCustomError(this.managed, 'AccessManagedInvalidAuthority')
.withArgs(this.other);
});
it('sets authority and emits AuthorityUpdated event', async function () {
await expect(this.managed.connect(this.authorityAsSigner).setAuthority(this.anotherAuthority))
.to.emit(this.managed, 'AuthorityUpdated')
.withArgs(this.anotherAuthority);
expect(await this.managed.authority()).to.equal(this.anotherAuthority);
});
});
describe('isConsumingScheduledOp', function () {
beforeEach(async function () {
await this.managed.connect(this.authorityAsSigner).setAuthority(this.authorityObserveIsConsuming);
});
it('returns bytes4(0) when not consuming operation', async function () {
expect(await this.managed.isConsumingScheduledOp()).to.equal('0x00000000');
});
it('returns isConsumingScheduledOp selector when consuming operation', async function () {
const isConsumingScheduledOp = this.managed.interface.getFunction('isConsumingScheduledOp()');
const fnRestricted = this.managed.fnRestricted.getFragment();
await expect(this.managed.connect(this.other).fnRestricted())
.to.emit(this.authorityObserveIsConsuming, 'ConsumeScheduledOpCalled')
.withArgs(
this.other,
this.managed.interface.encodeFunctionData(fnRestricted, []),
isConsumingScheduledOp.selector,
);
});
});
});

View File

@@ -0,0 +1,201 @@
const { expect } = require('chai');
const {
LIKE_COMMON_IS_EXECUTING,
LIKE_COMMON_GET_ACCESS,
LIKE_COMMON_SCHEDULABLE,
testAsSchedulableOperation,
testAsRestrictedOperation,
testAsDelayedOperation,
testAsCanCall,
testAsHasRole,
} = require('./AccessManager.predicate');
// ============ ADMIN OPERATION ============
/**
* @requires this.{manager,roles,calldata,role}
*/
function shouldBehaveLikeDelayedAdminOperation() {
const getAccessPath = LIKE_COMMON_GET_ACCESS;
testAsDelayedOperation.mineDelay = true;
getAccessPath.requiredRoleIsGranted.roleGrantingIsDelayed.callerHasAnExecutionDelay.afterGrantDelay =
testAsDelayedOperation;
getAccessPath.requiredRoleIsGranted.roleGrantingIsNotDelayed.callerHasAnExecutionDelay = function () {
beforeEach('set execution delay', async function () {
this.scheduleIn = this.executionDelay; // For testAsDelayedOperation
});
testAsSchedulableOperation(LIKE_COMMON_SCHEDULABLE);
};
beforeEach('set target as manager', function () {
this.target = this.manager;
});
testAsRestrictedOperation({
callerIsTheManager: LIKE_COMMON_IS_EXECUTING,
callerIsNotTheManager() {
testAsHasRole({
publicRoleIsRequired() {
it('reverts as AccessManagerUnauthorizedAccount', async function () {
await expect(this.caller.sendTransaction({ to: this.target, data: this.calldata }))
.to.be.revertedWithCustomError(this.target, 'AccessManagerUnauthorizedAccount')
.withArgs(
this.caller,
this.roles.ADMIN.id, // Although PUBLIC is required, target function role doesn't apply to admin ops
);
});
},
specificRoleIsRequired: getAccessPath,
});
},
});
}
/**
* @requires this.{manager,roles,calldata,role}
*/
function shouldBehaveLikeNotDelayedAdminOperation() {
const getAccessPath = LIKE_COMMON_GET_ACCESS;
function testScheduleOperation(mineDelay) {
return function self() {
self.mineDelay = mineDelay;
beforeEach('set execution delay', async function () {
this.scheduleIn = this.executionDelay; // For testAsSchedulableOperation
});
testAsSchedulableOperation(LIKE_COMMON_SCHEDULABLE);
};
}
getAccessPath.requiredRoleIsGranted.roleGrantingIsDelayed.callerHasAnExecutionDelay.afterGrantDelay =
testScheduleOperation(true);
getAccessPath.requiredRoleIsGranted.roleGrantingIsNotDelayed.callerHasAnExecutionDelay = testScheduleOperation(false);
beforeEach('set target as manager', function () {
this.target = this.manager;
});
testAsRestrictedOperation({
callerIsTheManager: LIKE_COMMON_IS_EXECUTING,
callerIsNotTheManager() {
testAsHasRole({
publicRoleIsRequired() {
it('reverts as AccessManagerUnauthorizedAccount', async function () {
await expect(this.caller.sendTransaction({ to: this.target, data: this.calldata }))
.to.be.revertedWithCustomError(this.target, 'AccessManagerUnauthorizedAccount')
.withArgs(
this.caller,
this.roles.ADMIN.id, // Although PUBLIC_ROLE is required, admin ops are not subject to target function roles
);
});
},
specificRoleIsRequired: getAccessPath,
});
},
});
}
/**
* @requires this.{manager,roles,calldata,role}
*/
function shouldBehaveLikeRoleAdminOperation(roleAdmin) {
const getAccessPath = LIKE_COMMON_GET_ACCESS;
function afterGrantDelay() {
afterGrantDelay.mineDelay = true;
beforeEach('set execution delay', async function () {
this.scheduleIn = this.executionDelay; // For testAsSchedulableOperation
});
testAsSchedulableOperation(LIKE_COMMON_SCHEDULABLE);
}
getAccessPath.requiredRoleIsGranted.roleGrantingIsDelayed.callerHasAnExecutionDelay.afterGrantDelay = afterGrantDelay;
getAccessPath.requiredRoleIsGranted.roleGrantingIsNotDelayed.callerHasAnExecutionDelay = afterGrantDelay;
beforeEach('set target as manager', function () {
this.target = this.manager;
});
testAsRestrictedOperation({
callerIsTheManager: LIKE_COMMON_IS_EXECUTING,
callerIsNotTheManager() {
testAsHasRole({
publicRoleIsRequired() {
it('reverts as AccessManagerUnauthorizedAccount', async function () {
await expect(this.caller.sendTransaction({ to: this.target, data: this.calldata }))
.to.be.revertedWithCustomError(this.target, 'AccessManagerUnauthorizedAccount')
.withArgs(this.caller, roleAdmin);
});
},
specificRoleIsRequired: getAccessPath,
});
},
});
}
// ============ RESTRICTED OPERATION ============
/**
* @requires this.{manager,roles,calldata,role}
*/
function shouldBehaveLikeAManagedRestrictedOperation() {
function revertUnauthorized() {
it('reverts as AccessManagedUnauthorized', async function () {
await expect(this.caller.sendTransaction({ to: this.target, data: this.calldata }))
.to.be.revertedWithCustomError(this.target, 'AccessManagedUnauthorized')
.withArgs(this.caller);
});
}
const getAccessPath = LIKE_COMMON_GET_ACCESS;
getAccessPath.requiredRoleIsGranted.roleGrantingIsDelayed.callerHasAnExecutionDelay.beforeGrantDelay =
revertUnauthorized;
getAccessPath.requiredRoleIsGranted.roleGrantingIsDelayed.callerHasNoExecutionDelay.beforeGrantDelay =
revertUnauthorized;
getAccessPath.requiredRoleIsNotGranted = revertUnauthorized;
function testScheduleOperation(mineDelay) {
return function self() {
self.mineDelay = mineDelay;
beforeEach('sets execution delay', async function () {
this.scheduleIn = this.executionDelay; // For testAsSchedulableOperation
});
testAsSchedulableOperation(LIKE_COMMON_SCHEDULABLE);
};
}
getAccessPath.requiredRoleIsGranted.roleGrantingIsDelayed.callerHasAnExecutionDelay.afterGrantDelay =
testScheduleOperation(true);
getAccessPath.requiredRoleIsGranted.roleGrantingIsNotDelayed.callerHasAnExecutionDelay = testScheduleOperation(false);
const isExecutingPath = LIKE_COMMON_IS_EXECUTING;
isExecutingPath.notExecuting = revertUnauthorized;
testAsCanCall({
closed: revertUnauthorized,
open: {
callerIsTheManager: isExecutingPath,
callerIsNotTheManager: {
publicRoleIsRequired() {
it('succeeds called directly', async function () {
await this.caller.sendTransaction({ to: this.target, data: this.calldata });
});
it('succeeds via execute', async function () {
await this.manager.connect(this.caller).execute(this.target, this.calldata);
});
},
specificRoleIsRequired: getAccessPath,
},
},
});
}
module.exports = {
shouldBehaveLikeDelayedAdminOperation,
shouldBehaveLikeNotDelayedAdminOperation,
shouldBehaveLikeRoleAdminOperation,
shouldBehaveLikeAManagedRestrictedOperation,
};

View File

@@ -0,0 +1,456 @@
const { ethers } = require('hardhat');
const { expect } = require('chai');
const { setStorageAt } = require('@nomicfoundation/hardhat-network-helpers');
const { EXECUTION_ID_STORAGE_SLOT, EXPIRATION, prepareOperation } = require('../../helpers/access-manager');
const { impersonate } = require('../../helpers/account');
const time = require('../../helpers/time');
// ============ COMMON PREDICATES ============
const LIKE_COMMON_IS_EXECUTING = {
executing() {
it('succeeds', async function () {
await this.caller.sendTransaction({ to: this.target, data: this.calldata });
});
},
notExecuting() {
it('reverts as AccessManagerUnauthorizedAccount', async function () {
await expect(this.caller.sendTransaction({ to: this.target, data: this.calldata }))
.to.be.revertedWithCustomError(this.manager, 'AccessManagerUnauthorizedAccount')
.withArgs(this.caller, this.role.id);
});
},
};
const LIKE_COMMON_GET_ACCESS = {
requiredRoleIsGranted: {
roleGrantingIsDelayed: {
callerHasAnExecutionDelay: {
beforeGrantDelay() {
it('reverts as AccessManagerUnauthorizedAccount', async function () {
await expect(this.caller.sendTransaction({ to: this.target, data: this.calldata }))
.to.be.revertedWithCustomError(this.manager, 'AccessManagerUnauthorizedAccount')
.withArgs(this.caller, this.role.id);
});
},
afterGrantDelay: undefined, // Diverges if there's an operation delay or not
},
callerHasNoExecutionDelay: {
beforeGrantDelay() {
it('reverts as AccessManagerUnauthorizedAccount', async function () {
await expect(this.caller.sendTransaction({ to: this.target, data: this.calldata }))
.to.be.revertedWithCustomError(this.manager, 'AccessManagerUnauthorizedAccount')
.withArgs(this.caller, this.role.id);
});
},
afterGrantDelay() {
it('succeeds called directly', async function () {
await this.caller.sendTransaction({ to: this.target, data: this.calldata });
});
it('succeeds via execute', async function () {
await this.manager.connect(this.caller).execute(this.target, this.calldata);
});
},
},
},
roleGrantingIsNotDelayed: {
callerHasAnExecutionDelay: undefined, // Diverges if there's an operation to schedule or not
callerHasNoExecutionDelay() {
it('succeeds called directly', async function () {
await this.caller.sendTransaction({ to: this.target, data: this.calldata });
});
it('succeeds via execute', async function () {
await this.manager.connect(this.caller).execute(this.target, this.calldata);
});
},
},
},
requiredRoleIsNotGranted() {
it('reverts as AccessManagerUnauthorizedAccount', async function () {
await expect(this.caller.sendTransaction({ to: this.target, data: this.calldata }))
.to.be.revertedWithCustomError(this.manager, 'AccessManagerUnauthorizedAccount')
.withArgs(this.caller, this.role.id);
});
},
};
const LIKE_COMMON_SCHEDULABLE = {
scheduled: {
before() {
it('reverts as AccessManagerNotReady', async function () {
await expect(this.caller.sendTransaction({ to: this.target, data: this.calldata }))
.to.be.revertedWithCustomError(this.manager, 'AccessManagerNotReady')
.withArgs(this.operationId);
});
},
after() {
it('succeeds called directly', async function () {
await this.caller.sendTransaction({ to: this.target, data: this.calldata });
});
it('succeeds via execute', async function () {
await this.manager.connect(this.caller).execute(this.target, this.calldata);
});
},
expired() {
it('reverts as AccessManagerExpired', async function () {
await expect(this.caller.sendTransaction({ to: this.target, data: this.calldata }))
.to.be.revertedWithCustomError(this.manager, 'AccessManagerExpired')
.withArgs(this.operationId);
});
},
},
notScheduled() {
it('reverts as AccessManagerNotScheduled', async function () {
await expect(this.caller.sendTransaction({ to: this.target, data: this.calldata }))
.to.be.revertedWithCustomError(this.manager, 'AccessManagerNotScheduled')
.withArgs(this.operationId);
});
},
};
// ============ MODE ============
/**
* @requires this.{manager,target}
*/
function testAsClosable({ closed, open }) {
describe('when the manager is closed', function () {
beforeEach('close', async function () {
await this.manager.$_setTargetClosed(this.target, true);
});
closed();
});
describe('when the manager is open', function () {
beforeEach('open', async function () {
await this.manager.$_setTargetClosed(this.target, false);
});
open();
});
}
// ============ DELAY ============
/**
* @requires this.{delay}
*/
function testAsDelay(type, { before, after }) {
beforeEach('define timestamp when delay takes effect', async function () {
const timestamp = await time.clock.timestamp();
this.delayEffect = timestamp + this.delay;
});
describe(`when ${type} delay has not taken effect yet`, function () {
beforeEach(`set next block timestamp before ${type} takes effect`, async function () {
await time.increaseTo.timestamp(this.delayEffect - 1n, !!before.mineDelay);
});
before();
});
describe(`when ${type} delay has taken effect`, function () {
beforeEach(`set next block timestamp when ${type} takes effect`, async function () {
await time.increaseTo.timestamp(this.delayEffect, !!after.mineDelay);
});
after();
});
}
// ============ OPERATION ============
/**
* @requires this.{manager,scheduleIn,caller,target,calldata}
*/
function testAsSchedulableOperation({ scheduled: { before, after, expired }, notScheduled }) {
describe('when operation is scheduled', function () {
beforeEach('schedule operation', async function () {
if (this.caller.target) {
await impersonate(this.caller.target);
this.caller = await ethers.getSigner(this.caller.target);
}
const { operationId, schedule } = await prepareOperation(this.manager, {
caller: this.caller,
target: this.target,
calldata: this.calldata,
delay: this.scheduleIn,
});
await schedule();
this.operationId = operationId;
});
describe('when operation is not ready for execution', function () {
beforeEach('set next block time before operation is ready', async function () {
this.scheduledAt = await time.clock.timestamp();
const schedule = await this.manager.getSchedule(this.operationId);
await time.increaseTo.timestamp(schedule - 1n, !!before.mineDelay);
});
before();
});
describe('when operation is ready for execution', function () {
beforeEach('set next block time when operation is ready for execution', async function () {
this.scheduledAt = await time.clock.timestamp();
const schedule = await this.manager.getSchedule(this.operationId);
await time.increaseTo.timestamp(schedule, !!after.mineDelay);
});
after();
});
describe('when operation has expired', function () {
beforeEach('set next block time when operation expired', async function () {
this.scheduledAt = await time.clock.timestamp();
const schedule = await this.manager.getSchedule(this.operationId);
await time.increaseTo.timestamp(schedule + EXPIRATION, !!expired.mineDelay);
});
expired();
});
});
describe('when operation is not scheduled', function () {
beforeEach('set expected operationId', async function () {
this.operationId = await this.manager.hashOperation(this.caller, this.target, this.calldata);
// Assert operation is not scheduled
expect(await this.manager.getSchedule(this.operationId)).to.equal(0n);
});
notScheduled();
});
}
/**
* @requires this.{manager,roles,target,calldata}
*/
function testAsRestrictedOperation({ callerIsTheManager: { executing, notExecuting }, callerIsNotTheManager }) {
describe('when the call comes from the manager (msg.sender == manager)', function () {
beforeEach('define caller as manager', async function () {
this.caller = this.manager;
if (this.caller.target) {
await impersonate(this.caller.target);
this.caller = await ethers.getSigner(this.caller.target);
}
});
describe('when _executionId is in storage for target and selector', function () {
beforeEach('set _executionId flag from calldata and target', async function () {
const executionId = ethers.keccak256(
ethers.AbiCoder.defaultAbiCoder().encode(
['address', 'bytes4'],
[this.target.target, this.calldata.substring(0, 10)],
),
);
await setStorageAt(this.manager.target, EXECUTION_ID_STORAGE_SLOT, executionId);
});
executing();
});
describe('when _executionId does not match target and selector', notExecuting);
});
describe('when the call does not come from the manager (msg.sender != manager)', function () {
beforeEach('define non manager caller', function () {
this.caller = this.roles.SOME.members[0];
});
callerIsNotTheManager();
});
}
/**
* @requires this.{manager,scheduleIn,caller,target,calldata,executionDelay}
*/
function testAsDelayedOperation() {
describe('with operation delay', function () {
describe('when operation delay is greater than execution delay', function () {
beforeEach('set operation delay', async function () {
this.operationDelay = this.executionDelay + time.duration.hours(1);
await this.manager.$_setTargetAdminDelay(this.target, this.operationDelay);
this.scheduleIn = this.operationDelay; // For testAsSchedulableOperation
});
testAsSchedulableOperation(LIKE_COMMON_SCHEDULABLE);
});
describe('when operation delay is shorter than execution delay', function () {
beforeEach('set operation delay', async function () {
this.operationDelay = this.executionDelay - time.duration.hours(1);
await this.manager.$_setTargetAdminDelay(this.target, this.operationDelay);
this.scheduleIn = this.executionDelay; // For testAsSchedulableOperation
});
testAsSchedulableOperation(LIKE_COMMON_SCHEDULABLE);
});
});
describe('without operation delay', function () {
beforeEach('set operation delay', async function () {
this.operationDelay = 0n;
await this.manager.$_setTargetAdminDelay(this.target, this.operationDelay);
this.scheduleIn = this.executionDelay; // For testAsSchedulableOperation
});
testAsSchedulableOperation(LIKE_COMMON_SCHEDULABLE);
});
}
// ============ METHOD ============
/**
* @requires this.{manager,roles,role,target,calldata}
*/
function testAsCanCall({
closed,
open: {
callerIsTheManager,
callerIsNotTheManager: { publicRoleIsRequired, specificRoleIsRequired },
},
}) {
testAsClosable({
closed,
open() {
testAsRestrictedOperation({
callerIsTheManager,
callerIsNotTheManager() {
testAsHasRole({
publicRoleIsRequired,
specificRoleIsRequired,
});
},
});
},
});
}
/**
* @requires this.{target,calldata,roles,role}
*/
function testAsHasRole({ publicRoleIsRequired, specificRoleIsRequired }) {
describe('when the function requires the caller to be granted with the PUBLIC_ROLE', function () {
beforeEach('set target function role as PUBLIC_ROLE', async function () {
this.role = this.roles.PUBLIC;
await this.manager
.connect(this.roles.ADMIN.members[0])
.$_setTargetFunctionRole(this.target, this.calldata.substring(0, 10), this.role.id);
});
publicRoleIsRequired();
});
describe('when the function requires the caller to be granted with a role other than PUBLIC_ROLE', function () {
beforeEach('set target function role as PUBLIC_ROLE', async function () {
await this.manager
.connect(this.roles.ADMIN.members[0])
.$_setTargetFunctionRole(this.target, this.calldata.substring(0, 10), this.role.id);
});
testAsGetAccess(specificRoleIsRequired);
});
}
/**
* @requires this.{manager,role,caller}
*/
function testAsGetAccess({
requiredRoleIsGranted: {
roleGrantingIsDelayed: {
// Because both grant and execution delay are set within the same $_grantRole call
// it's not possible to create a set of tests that diverge between grant and execution delay.
// Therefore, the testAsDelay arguments are renamed for clarity:
// before => beforeGrantDelay
// after => afterGrantDelay
callerHasAnExecutionDelay: { beforeGrantDelay: case1, afterGrantDelay: case2 },
callerHasNoExecutionDelay: { beforeGrantDelay: case3, afterGrantDelay: case4 },
},
roleGrantingIsNotDelayed: { callerHasAnExecutionDelay: case5, callerHasNoExecutionDelay: case6 },
},
requiredRoleIsNotGranted,
}) {
describe('when the required role is granted to the caller', function () {
describe('when role granting is delayed', function () {
beforeEach('define delay', function () {
this.grantDelay = time.duration.minutes(3);
this.delay = this.grantDelay; // For testAsDelay
});
describe('when caller has an execution delay', function () {
beforeEach('set role and delay', async function () {
this.executionDelay = time.duration.hours(10);
this.delay = this.grantDelay;
await this.manager.$_grantRole(this.role.id, this.caller, this.grantDelay, this.executionDelay);
});
testAsDelay('grant', { before: case1, after: case2 });
});
describe('when caller has no execution delay', function () {
beforeEach('set role and delay', async function () {
this.executionDelay = 0n;
await this.manager.$_grantRole(this.role.id, this.caller, this.grantDelay, this.executionDelay);
});
testAsDelay('grant', { before: case3, after: case4 });
});
});
describe('when role granting is not delayed', function () {
beforeEach('define delay', function () {
this.grantDelay = 0n;
});
describe('when caller has an execution delay', function () {
beforeEach('set role and delay', async function () {
this.executionDelay = time.duration.hours(10);
await this.manager.$_grantRole(this.role.id, this.caller, this.grantDelay, this.executionDelay);
});
case5();
});
describe('when caller has no execution delay', function () {
beforeEach('set role and delay', async function () {
this.executionDelay = 0n;
await this.manager.$_grantRole(this.role.id, this.caller, this.grantDelay, this.executionDelay);
});
case6();
});
});
});
describe('when role is not granted', function () {
// Because this helper can be composed with other helpers, it's possible
// that role has been set already by another helper.
// Although this is highly unlikely, we check for it here to avoid false positives.
beforeEach('assert role is unset', async function () {
const { since } = await this.manager.getAccess(this.role.id, this.caller);
expect(since).to.equal(0n);
});
requiredRoleIsNotGranted();
});
}
module.exports = {
LIKE_COMMON_IS_EXECUTING,
LIKE_COMMON_GET_ACCESS,
LIKE_COMMON_SCHEDULABLE,
testAsClosable,
testAsDelay,
testAsSchedulableOperation,
testAsRestrictedOperation,
testAsDelayedOperation,
testAsCanCall,
testAsHasRole,
testAsGetAccess,
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,102 @@
const { ethers } = require('hardhat');
const { expect } = require('chai');
const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers');
async function fixture() {
const [user, other] = await ethers.getSigners();
const mock = await ethers.deployContract('$AuthorityUtils');
const notAuthorityMock = await ethers.deployContract('NotAuthorityMock');
const authorityNoDelayMock = await ethers.deployContract('AuthorityNoDelayMock');
const authorityDelayMock = await ethers.deployContract('AuthorityDelayMock');
const authorityNoResponse = await ethers.deployContract('AuthorityNoResponse');
return {
user,
other,
mock,
notAuthorityMock,
authorityNoDelayMock,
authorityDelayMock,
authorityNoResponse,
};
}
describe('AuthorityUtils', function () {
beforeEach(async function () {
Object.assign(this, await loadFixture(fixture));
});
describe('canCallWithDelay', function () {
describe('when authority does not have a canCall function', function () {
beforeEach(async function () {
this.authority = this.notAuthorityMock;
});
it('returns (immediate = 0, delay = 0)', async function () {
const { immediate, delay } = await this.mock.$canCallWithDelay(
this.authority,
this.user,
this.other,
'0x12345678',
);
expect(immediate).to.be.false;
expect(delay).to.equal(0n);
});
});
describe('when authority has no delay', function () {
beforeEach(async function () {
this.authority = this.authorityNoDelayMock;
this.immediate = true;
await this.authority._setImmediate(this.immediate);
});
it('returns (immediate, delay = 0)', async function () {
const { immediate, delay } = await this.mock.$canCallWithDelay(
this.authority,
this.user,
this.other,
'0x12345678',
);
expect(immediate).to.equal(this.immediate);
expect(delay).to.equal(0n);
});
});
describe('when authority replies with a delay', function () {
beforeEach(async function () {
this.authority = this.authorityDelayMock;
});
for (const immediate of [true, false]) {
for (const delay of [0n, 42n]) {
it(`returns (immediate=${immediate}, delay=${delay})`, async function () {
await this.authority._setImmediate(immediate);
await this.authority._setDelay(delay);
const result = await this.mock.$canCallWithDelay(this.authority, this.user, this.other, '0x12345678');
expect(result.immediate).to.equal(immediate);
expect(result.delay).to.equal(delay);
});
}
}
});
describe('when authority replies with empty data', function () {
beforeEach(async function () {
this.authority = this.authorityNoResponse;
});
it('returns (immediate = 0, delay = 0)', async function () {
const { immediate, delay } = await this.mock.$canCallWithDelay(
this.authority,
this.user,
this.other,
'0x12345678',
);
expect(immediate).to.be.false;
expect(delay).to.equal(0n);
});
});
});
});

View File

@@ -0,0 +1,87 @@
const { ethers } = require('hardhat');
const { expect } = require('chai');
const time = require('../helpers/time');
async function envSetup(mock, beneficiary, token) {
return {
eth: {
checkRelease: async (tx, amount) => {
await expect(tx).to.changeEtherBalances([mock, beneficiary], [-amount, amount]);
},
setupFailure: async () => {
const beneficiaryMock = await ethers.deployContract('EtherReceiverMock');
await beneficiaryMock.setAcceptEther(false);
await mock.connect(beneficiary).transferOwnership(beneficiaryMock);
return { args: [], error: [mock, 'FailedCall'] };
},
releasedEvent: 'EtherReleased',
args: [],
},
token: {
checkRelease: async (tx, amount) => {
await expect(tx).to.emit(token, 'Transfer').withArgs(mock, beneficiary, amount);
await expect(tx).to.changeTokenBalances(token, [mock, beneficiary], [-amount, amount]);
},
setupFailure: async () => {
const pausableToken = await ethers.deployContract('$ERC20Pausable', ['Name', 'Symbol']);
await pausableToken.$_pause();
return {
args: [ethers.Typed.address(pausableToken)],
error: [pausableToken, 'EnforcedPause'],
};
},
releasedEvent: 'ERC20Released',
args: [ethers.Typed.address(token)],
},
};
}
function shouldBehaveLikeVesting() {
it('check vesting schedule', async function () {
for (const timestamp of this.schedule) {
await time.increaseTo.timestamp(timestamp);
const vesting = this.vestingFn(timestamp);
expect(await this.mock.vestedAmount(...this.args, timestamp)).to.equal(vesting);
expect(await this.mock.releasable(...this.args)).to.equal(vesting);
}
});
it('execute vesting schedule', async function () {
let released = 0n;
{
const tx = await this.mock.release(...this.args);
await expect(tx)
.to.emit(this.mock, this.releasedEvent)
.withArgs(...this.args, 0);
await this.checkRelease(tx, 0n);
}
for (const timestamp of this.schedule) {
await time.increaseTo.timestamp(timestamp, false);
const vested = this.vestingFn(timestamp);
const tx = await this.mock.release(...this.args);
await expect(tx).to.emit(this.mock, this.releasedEvent);
await this.checkRelease(tx, vested - released);
released = vested;
}
});
it('should revert on transaction failure', async function () {
const { args, error } = await this.setupFailure();
for (const timestamp of this.schedule) {
await time.increaseTo.timestamp(timestamp);
await expect(this.mock.release(...args)).to.be.revertedWithCustomError(...error);
}
});
}
module.exports = {
envSetup,
shouldBehaveLikeVesting,
};

View File

@@ -0,0 +1,65 @@
const { ethers } = require('hardhat');
const { expect } = require('chai');
const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers');
const { min } = require('../helpers/math');
const time = require('../helpers/time');
const { envSetup, shouldBehaveLikeVesting } = require('./VestingWallet.behavior');
async function fixture() {
const amount = ethers.parseEther('100');
const duration = time.duration.years(4);
const start = (await time.clock.timestamp()) + time.duration.hours(1);
const [sender, beneficiary] = await ethers.getSigners();
const mock = await ethers.deployContract('VestingWallet', [beneficiary, start, duration]);
const token = await ethers.deployContract('$ERC20', ['Name', 'Symbol']);
await token.$_mint(mock, amount);
await sender.sendTransaction({ to: mock, value: amount });
const env = await envSetup(mock, beneficiary, token);
const schedule = Array.from({ length: 64 }, (_, i) => (BigInt(i) * duration) / 60n + start);
const vestingFn = timestamp => min(amount, (amount * (timestamp - start)) / duration);
return { mock, duration, start, beneficiary, schedule, vestingFn, env };
}
describe('VestingWallet', function () {
beforeEach(async function () {
Object.assign(this, await loadFixture(fixture));
});
it('rejects zero address for beneficiary', async function () {
await expect(ethers.deployContract('VestingWallet', [ethers.ZeroAddress, this.start, this.duration]))
.revertedWithCustomError(this.mock, 'OwnableInvalidOwner')
.withArgs(ethers.ZeroAddress);
});
it('check vesting contract', async function () {
expect(await this.mock.owner()).to.equal(this.beneficiary);
expect(await this.mock.start()).to.equal(this.start);
expect(await this.mock.duration()).to.equal(this.duration);
expect(await this.mock.end()).to.equal(this.start + this.duration);
});
describe('vesting schedule', function () {
describe('Eth vesting', function () {
beforeEach(async function () {
Object.assign(this, this.env.eth);
});
shouldBehaveLikeVesting();
});
describe('ERC20 vesting', function () {
beforeEach(async function () {
Object.assign(this, this.env.token);
});
shouldBehaveLikeVesting();
});
});
});

View File

@@ -0,0 +1,70 @@
const { ethers } = require('hardhat');
const { expect } = require('chai');
const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers');
const { min } = require('../helpers/math');
const time = require('../helpers/time');
const { envSetup, shouldBehaveLikeVesting } = require('./VestingWallet.behavior');
async function fixture() {
const amount = ethers.parseEther('100');
const duration = time.duration.years(4);
const start = (await time.clock.timestamp()) + time.duration.hours(1);
const cliffDuration = time.duration.years(1);
const cliff = start + cliffDuration;
const [sender, beneficiary] = await ethers.getSigners();
const mock = await ethers.deployContract('$VestingWalletCliff', [beneficiary, start, duration, cliffDuration]);
const token = await ethers.deployContract('$ERC20', ['Name', 'Symbol']);
await token.$_mint(mock, amount);
await sender.sendTransaction({ to: mock, value: amount });
const env = await envSetup(mock, beneficiary, token);
const schedule = Array.from({ length: 64 }, (_, i) => (BigInt(i) * duration) / 60n + start);
const vestingFn = timestamp => min(amount, timestamp < cliff ? 0n : (amount * (timestamp - start)) / duration);
return { mock, duration, start, beneficiary, cliff, schedule, vestingFn, env };
}
describe('VestingWalletCliff', function () {
beforeEach(async function () {
Object.assign(this, await loadFixture(fixture));
});
it('rejects a larger cliff than vesting duration', async function () {
await expect(
ethers.deployContract('$VestingWalletCliff', [this.beneficiary, this.start, this.duration, this.duration + 1n]),
)
.revertedWithCustomError(this.mock, 'InvalidCliffDuration')
.withArgs(this.duration + 1n, this.duration);
});
it('check vesting contract', async function () {
expect(await this.mock.owner()).to.equal(this.beneficiary);
expect(await this.mock.start()).to.equal(this.start);
expect(await this.mock.duration()).to.equal(this.duration);
expect(await this.mock.end()).to.equal(this.start + this.duration);
expect(await this.mock.cliff()).to.equal(this.cliff);
});
describe('vesting schedule', function () {
describe('Eth vesting', function () {
beforeEach(async function () {
Object.assign(this, this.env.eth);
});
shouldBehaveLikeVesting();
});
describe('ERC20 vesting', function () {
beforeEach(async function () {
Object.assign(this, this.env.token);
});
shouldBehaveLikeVesting();
});
});
});

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,85 @@
const { ethers } = require('hardhat');
const { MAX_UINT64 } = require('./constants');
const time = require('./time');
const { upgradeableSlot } = require('./storage');
function buildBaseRoles() {
const roles = {
ADMIN: {
id: 0n,
},
SOME_ADMIN: {
id: 17n,
},
SOME_GUARDIAN: {
id: 35n,
},
SOME: {
id: 42n,
},
PUBLIC: {
id: MAX_UINT64,
},
};
// Names
Object.entries(roles).forEach(([name, role]) => (role.name = name));
// Defaults
for (const role of Object.keys(roles)) {
roles[role].admin = roles.ADMIN;
roles[role].guardian = roles.ADMIN;
}
// Admins
roles.SOME.admin = roles.SOME_ADMIN;
// Guardians
roles.SOME.guardian = roles.SOME_GUARDIAN;
return roles;
}
const formatAccess = access => [access[0], access[1].toString()];
const MINSETBACK = time.duration.days(5);
const EXPIRATION = time.duration.weeks(1);
const EXECUTION_ID_STORAGE_SLOT = upgradeableSlot('AccessManager', 3n);
const CONSUMING_SCHEDULE_STORAGE_SLOT = upgradeableSlot('AccessManaged', 0n);
/**
* @requires this.{manager, caller, target, calldata}
*/
async function prepareOperation(manager, { caller, target, calldata, delay }) {
const scheduledAt = (await time.clock.timestamp()) + 1n;
await time.increaseTo.timestamp(scheduledAt, false); // Fix next block timestamp for predictability
return {
schedule: () => manager.connect(caller).schedule(target, calldata, scheduledAt + delay),
scheduledAt,
operationId: hashOperation(caller, target, calldata),
};
}
const lazyGetAddress = addressable => addressable.address ?? addressable.target ?? addressable;
const hashOperation = (caller, target, data) =>
ethers.keccak256(
ethers.AbiCoder.defaultAbiCoder().encode(
['address', 'address', 'bytes'],
[lazyGetAddress(caller), lazyGetAddress(target), data],
),
);
module.exports = {
buildBaseRoles,
formatAccess,
MINSETBACK,
EXPIRATION,
EXECUTION_ID_STORAGE_SLOT,
CONSUMING_SCHEDULE_STORAGE_SLOT,
prepareOperation,
hashOperation,
};

View File

@@ -0,0 +1,14 @@
const { ethers } = require('hardhat');
const { impersonateAccount, setBalance } = require('@nomicfoundation/hardhat-network-helpers');
// Hardhat default balance
const DEFAULT_BALANCE = 10000n * ethers.WeiPerEther;
const impersonate = (account, balance = DEFAULT_BALANCE) =>
impersonateAccount(account)
.then(() => setBalance(account, balance))
.then(() => ethers.getSigner(account));
module.exports = {
impersonate,
};

View File

@@ -0,0 +1,4 @@
module.exports = {
MAX_UINT48: 2n ** 48n - 1n,
MAX_UINT64: 2n ** 64n - 1n,
};

View File

@@ -0,0 +1,52 @@
const { mapValues } = require('./iterate');
const formatType = schema => Object.entries(schema).map(([name, type]) => ({ name, type }));
module.exports = mapValues(
{
EIP712Domain: {
name: 'string',
version: 'string',
chainId: 'uint256',
verifyingContract: 'address',
salt: 'bytes32',
},
Permit: {
owner: 'address',
spender: 'address',
value: 'uint256',
nonce: 'uint256',
deadline: 'uint256',
},
Ballot: {
proposalId: 'uint256',
support: 'uint8',
voter: 'address',
nonce: 'uint256',
},
ExtendedBallot: {
proposalId: 'uint256',
support: 'uint8',
voter: 'address',
nonce: 'uint256',
reason: 'string',
params: 'bytes',
},
Delegation: {
delegatee: 'address',
nonce: 'uint256',
expiry: 'uint256',
},
ForwardRequest: {
from: 'address',
to: 'address',
value: 'uint256',
gas: 'uint256',
nonce: 'uint256',
deadline: 'uint48',
data: 'bytes',
},
},
formatType,
);
module.exports.formatType = formatType;

View File

@@ -0,0 +1,45 @@
const { ethers } = require('hardhat');
const types = require('./eip712-types');
async function getDomain(contract) {
const { fields, name, version, chainId, verifyingContract, salt, extensions } = await contract.eip712Domain();
if (extensions.length > 0) {
throw Error('Extensions not implemented');
}
const domain = {
name,
version,
chainId,
verifyingContract,
salt,
};
for (const [i, { name }] of types.EIP712Domain.entries()) {
if (!(fields & (1 << i))) {
delete domain[name];
}
}
return domain;
}
function domainType(domain) {
return types.EIP712Domain.filter(({ name }) => domain[name] !== undefined);
}
function hashTypedData(domain, structHash) {
return ethers.solidityPackedKeccak256(
['bytes', 'bytes32', 'bytes32'],
['0x1901', ethers.TypedDataEncoder.hashDomain(domain), structHash],
);
}
module.exports = {
getDomain,
domainType,
domainSeparator: ethers.TypedDataEncoder.hashDomain,
hashTypedData,
...types,
};

View File

@@ -0,0 +1,12 @@
function Enum(...options) {
return Object.fromEntries(options.map((key, i) => [key, BigInt(i)]));
}
module.exports = {
Enum,
ProposalState: Enum('Pending', 'Active', 'Canceled', 'Defeated', 'Succeeded', 'Queued', 'Expired', 'Executed'),
VoteType: Enum('Against', 'For', 'Abstain'),
Rounding: Enum('Floor', 'Ceil', 'Trunc', 'Expand'),
OperationState: Enum('Unset', 'Waiting', 'Ready', 'Done'),
RevertType: Enum('None', 'RevertWithoutMessage', 'RevertWithMessage', 'RevertWithCustomError', 'Panic'),
};

View File

@@ -0,0 +1,198 @@
const { ethers } = require('hardhat');
const { ProposalState } = require('./enums');
const { unique } = require('./iterate');
const time = require('./time');
const timelockSalt = (address, descriptionHash) =>
ethers.toBeHex((ethers.toBigInt(address) << 96n) ^ ethers.toBigInt(descriptionHash), 32);
class GovernorHelper {
constructor(governor, mode = 'blocknumber') {
this.governor = governor;
this.mode = mode;
}
connect(account) {
this.governor = this.governor.connect(account);
return this;
}
/// Setter and getters
/**
* Specify a proposal either as
* 1) an array of objects [{ target, value, data }]
* 2) an object of arrays { targets: [], values: [], data: [] }
*/
setProposal(actions, description) {
if (Array.isArray(actions)) {
this.targets = actions.map(a => a.target);
this.values = actions.map(a => a.value || 0n);
this.data = actions.map(a => a.data || '0x');
} else {
({ targets: this.targets, values: this.values, data: this.data } = actions);
}
this.description = description;
return this;
}
get id() {
return ethers.keccak256(
ethers.AbiCoder.defaultAbiCoder().encode(['address[]', 'uint256[]', 'bytes[]', 'bytes32'], this.shortProposal),
);
}
// used for checking events
get signatures() {
return this.data.map(() => '');
}
get descriptionHash() {
return ethers.id(this.description);
}
// condensed version for queueing end executing
get shortProposal() {
return [this.targets, this.values, this.data, this.descriptionHash];
}
// full version for proposing
get fullProposal() {
return [this.targets, this.values, this.data, this.description];
}
get currentProposal() {
return this;
}
/// Proposal lifecycle
delegate(delegation) {
return Promise.all([
delegation.token.connect(delegation.to).delegate(delegation.to),
delegation.value === undefined ||
delegation.token.connect(this.governor.runner).transfer(delegation.to, delegation.value),
delegation.tokenId === undefined ||
delegation.token
.ownerOf(delegation.tokenId)
.then(owner =>
delegation.token.connect(this.governor.runner).transferFrom(owner, delegation.to, delegation.tokenId),
),
]);
}
propose() {
return this.governor.propose(...this.fullProposal);
}
queue() {
return this.governor.queue(...this.shortProposal);
}
execute() {
return this.governor.execute(...this.shortProposal);
}
cancel(visibility = 'external') {
switch (visibility) {
case 'external':
return this.governor.cancel(...this.shortProposal);
case 'internal':
return this.governor.$_cancel(...this.shortProposal);
default:
throw new Error(`unsupported visibility "${visibility}"`);
}
}
async vote(vote = {}) {
let method = 'castVote'; // default
let args = [this.id, vote.support]; // base
if (vote.signature) {
const sign = await vote.signature(this.governor, this.forgeMessage(vote));
if (vote.params || vote.reason) {
method = 'castVoteWithReasonAndParamsBySig';
args.push(vote.voter, vote.reason ?? '', vote.params ?? '0x', sign);
} else {
method = 'castVoteBySig';
args.push(vote.voter, sign);
}
} else if (vote.params) {
method = 'castVoteWithReasonAndParams';
args.push(vote.reason ?? '', vote.params);
} else if (vote.reason) {
method = 'castVoteWithReason';
args.push(vote.reason);
}
return await this.governor[method](...args);
}
/// Clock helpers
async waitForSnapshot(offset = 0n) {
const timepoint = await this.governor.proposalSnapshot(this.id);
return time.increaseTo[this.mode](timepoint + offset);
}
async waitForDeadline(offset = 0n) {
const timepoint = await this.governor.proposalDeadline(this.id);
return time.increaseTo[this.mode](timepoint + offset);
}
async waitForEta(offset = 0n) {
const timestamp = await this.governor.proposalEta(this.id);
return time.increaseTo.timestamp(timestamp + offset);
}
/// Other helpers
forgeMessage(vote = {}) {
const message = { proposalId: this.id, support: vote.support, voter: vote.voter, nonce: vote.nonce };
if (vote.params || vote.reason) {
message.reason = vote.reason ?? '';
message.params = vote.params ?? '0x';
}
return message;
}
/**
* Encodes a list ProposalStates into a bytes32 representation where each bit enabled corresponds to
* the underlying position in the `ProposalState` enum. For example:
*
* 0x000...10000
* ^^^^^^------ ...
* ^----- Succeeded
* ^---- Defeated
* ^--- Canceled
* ^-- Active
* ^- Pending
*/
static proposalStatesToBitMap(proposalStates, options = {}) {
if (!Array.isArray(proposalStates)) {
proposalStates = [proposalStates];
}
const statesCount = ethers.toBigInt(Object.keys(ProposalState).length);
let result = 0n;
for (const state of unique(proposalStates)) {
if (state < 0n || state >= statesCount) {
expect.fail(`ProposalState ${state} out of possible states (0...${statesCount}-1)`);
} else {
result |= 1n << state;
}
}
if (options.inverted) {
const mask = 2n ** statesCount - 1n;
result = result ^ mask;
}
return ethers.toBeHex(result, 32);
}
}
module.exports = {
GovernorHelper,
timelockSalt,
};

View File

@@ -0,0 +1,36 @@
module.exports = {
// ================================================= Array helpers =================================================
// Cut an array into an array of sized-length arrays
// Example: chunk([1,2,3,4,5,6,7,8], 3) → [[1,2,3],[4,5,6],[7,8]]
chunk: (array, size = 1) =>
Array.from({ length: Math.ceil(array.length / size) }, (_, i) => array.slice(i * size, i * size + size)),
// Cartesian cross product of an array of arrays
// Example: product([1,2],[a,b,c],[true]) → [[1,a,true],[1,b,true],[1,c,true],[2,a,true],[2,b,true],[2,c,true]]
product: (...arrays) => arrays.reduce((a, b) => a.flatMap(ai => b.map(bi => [...ai, bi])), [[]]),
// Range from start to end in increment
// Example: range(17,42,7) → [17,24,31,38]
range: (start, stop = undefined, step = 1) => {
if (!stop) {
stop = start;
start = 0;
}
return start < stop ? Array.from({ length: Math.ceil((stop - start) / step) }, (_, i) => start + i * step) : [];
},
// Unique elements, with an optional getter function
// Example: unique([1,1,2,3,4,8,1,3,8,13,42]) → [1,2,3,4,8,13,42]
unique: (array, op = x => x) => array.filter((obj, i) => array.findIndex(entry => op(obj) === op(entry)) === i),
// Zip arrays together. If some arrays are smaller, undefined is used as a filler.
// Example: zip([1,2],[a,b,c],[true]) → [[1,a,true],[2,b,undefined],[undefined,c,undefined]]
zip: (...args) => Array.from({ length: Math.max(...args.map(arg => arg.length)) }, (_, i) => args.map(arg => arg[i])),
// ================================================ Object helpers =================================================
// Create a new object by mapping the values through a function, keeping the keys
// Example: mapValues({a:1,b:2,c:3}, x => x**2) → {a:1,b:4,c:9}
mapValues: (obj, fn) => Object.fromEntries(Object.entries(obj).map(([k, v]) => [k, fn(v)])),
};

View File

@@ -0,0 +1,33 @@
// Array of number or bigint
const max = (...values) => values.slice(1).reduce((x, y) => (x > y ? x : y), values.at(0));
const min = (...values) => values.slice(1).reduce((x, y) => (x < y ? x : y), values.at(0));
const sum = (...values) => values.slice(1).reduce((x, y) => x + y, values.at(0));
// Computes modexp without BigInt overflow for large numbers
function modExp(b, e, m) {
let result = 1n;
// If e is a power of two, modexp can be calculated as:
// for (let result = b, i = 0; i < log2(e); i++) result = modexp(result, 2, m)
//
// Given any natural number can be written in terms of powers of 2 (i.e. binary)
// then modexp can be calculated for any e, by multiplying b**i for all i where
// binary(e)[i] is 1 (i.e. a power of two).
for (let base = b % m; e > 0n; base = base ** 2n % m) {
// Least significant bit is 1
if (e % 2n == 1n) {
result = (result * base) % m;
}
e /= 2n; // Binary pop
}
return result;
}
module.exports = {
min,
max,
sum,
modExp,
};

View File

@@ -0,0 +1,14 @@
const { ethers } = require('hardhat');
const selector = signature => ethers.FunctionFragment.from(signature).selector;
const interfaceId = signatures =>
ethers.toBeHex(
signatures.reduce((acc, signature) => acc ^ ethers.toBigInt(selector(signature)), 0n),
4,
);
module.exports = {
selector,
interfaceId,
};

View File

@@ -0,0 +1,19 @@
const { ethers } = require('hardhat');
const generators = {
address: () => ethers.Wallet.createRandom().address,
bytes32: () => ethers.hexlify(ethers.randomBytes(32)),
uint256: () => ethers.toBigInt(ethers.randomBytes(32)),
int256: () => ethers.toBigInt(ethers.randomBytes(32)) + ethers.MinInt256,
hexBytes: length => ethers.hexlify(ethers.randomBytes(length)),
};
generators.address.zero = ethers.ZeroAddress;
generators.bytes32.zero = ethers.ZeroHash;
generators.uint256.zero = 0n;
generators.int256.zero = 0n;
generators.hexBytes.zero = '0x';
module.exports = {
generators,
};

View File

@@ -0,0 +1,48 @@
const { ethers } = require('hardhat');
const { setStorageAt } = require('@nomicfoundation/hardhat-network-helpers');
const ImplementationLabel = 'eip1967.proxy.implementation';
const AdminLabel = 'eip1967.proxy.admin';
const BeaconLabel = 'eip1967.proxy.beacon';
const erc1967Slot = label => ethers.toBeHex(ethers.toBigInt(ethers.id(label)) - 1n);
const erc7201Slot = label => ethers.toBeHex(ethers.toBigInt(ethers.keccak256(erc1967Slot(label))) & ~0xffn);
const erc7201format = contractName => `openzeppelin.storage.${contractName}`;
const getSlot = (address, slot) =>
ethers.provider.getStorage(address, ethers.isBytesLike(slot) ? slot : erc1967Slot(slot));
const setSlot = (address, slot, value) =>
Promise.all([
ethers.isAddressable(address) ? address.getAddress() : Promise.resolve(address),
ethers.isAddressable(value) ? value.getAddress() : Promise.resolve(value),
]).then(([address, value]) => setStorageAt(address, ethers.isBytesLike(slot) ? slot : erc1967Slot(slot), value));
const getAddressInSlot = (address, slot) =>
getSlot(address, slot).then(slotValue => ethers.AbiCoder.defaultAbiCoder().decode(['address'], slotValue)[0]);
const upgradeableSlot = (contractName, offset) => {
try {
// Try to get the artifact paths, will throw if it doesn't exist
artifacts._getArtifactPathSync(`${contractName}Upgradeable`);
return offset + ethers.toBigInt(erc7201Slot(erc7201format(contractName)));
} catch (_) {
return offset;
}
};
module.exports = {
ImplementationLabel,
AdminLabel,
BeaconLabel,
ImplementationSlot: erc1967Slot(ImplementationLabel),
AdminSlot: erc1967Slot(AdminLabel),
BeaconSlot: erc1967Slot(BeaconLabel),
erc1967Slot,
erc7201Slot,
erc7201format,
setSlot,
getSlot,
getAddressInSlot,
upgradeableSlot,
};

View File

@@ -0,0 +1,5 @@
module.exports = {
// Capitalize the first char of a string
// Example: capitalize('uint256') → 'Uint256'
capitalize: str => str.charAt(0).toUpperCase() + str.slice(1),
};

View File

@@ -0,0 +1,30 @@
const { ethers } = require('hardhat');
const { time, mine, mineUpTo } = require('@nomicfoundation/hardhat-network-helpers');
const { mapValues } = require('./iterate');
const clock = {
blocknumber: () => time.latestBlock().then(ethers.toBigInt),
timestamp: () => time.latest().then(ethers.toBigInt),
};
const clockFromReceipt = {
blocknumber: receipt => Promise.resolve(ethers.toBigInt(receipt.blockNumber)),
timestamp: receipt => ethers.provider.getBlock(receipt.blockNumber).then(block => ethers.toBigInt(block.timestamp)),
};
const increaseBy = {
blockNumber: mine,
timestamp: (delay, mine = true) =>
time.latest().then(clock => increaseTo.timestamp(clock + ethers.toNumber(delay), mine)),
};
const increaseTo = {
blocknumber: mineUpTo,
timestamp: (to, mine = true) => (mine ? time.increaseTo(to) : time.setNextBlockTimestamp(to)),
};
const duration = mapValues(time.duration, fn => n => ethers.toBigInt(fn(ethers.toNumber(n))));
module.exports = {
clock,
clockFromReceipt,
increaseBy,
increaseTo,
duration,
};

View File

@@ -0,0 +1,29 @@
const { network } = require('hardhat');
const { expect } = require('chai');
const { mine } = require('@nomicfoundation/hardhat-network-helpers');
const { unique } = require('./iterate');
async function batchInBlock(txs) {
try {
// disable auto-mining
await network.provider.send('evm_setAutomine', [false]);
// send all transactions
const responses = await Promise.all(txs.map(fn => fn()));
// mine one block
await mine();
// fetch receipts
const receipts = await Promise.all(responses.map(response => response.wait()));
// Sanity check, all tx should be in the same block
expect(unique(receipts.map(receipt => receipt.blockNumber))).to.have.lengthOf(1);
// return responses
return receipts;
} finally {
// enable auto-mining
await network.provider.send('evm_setAutomine', [true]);
}
}
module.exports = {
batchInBlock,
};

View File

@@ -0,0 +1,133 @@
const { ethers } = require('hardhat');
const { expect } = require('chai');
const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers');
const { impersonate } = require('../helpers/account');
const { getDomain, ForwardRequest } = require('../helpers/eip712');
const { MAX_UINT48 } = require('../helpers/constants');
const { shouldBehaveLikeRegularContext } = require('../utils/Context.behavior');
async function fixture() {
const [sender, other] = await ethers.getSigners();
const forwarder = await ethers.deployContract('ERC2771Forwarder', []);
const forwarderAsSigner = await impersonate(forwarder.target);
const context = await ethers.deployContract('ERC2771ContextMock', [forwarder]);
const domain = await getDomain(forwarder);
const types = { ForwardRequest };
return { sender, other, forwarder, forwarderAsSigner, context, domain, types };
}
describe('ERC2771Context', function () {
beforeEach(async function () {
Object.assign(this, await loadFixture(fixture));
});
it('recognize trusted forwarder', async function () {
expect(await this.context.isTrustedForwarder(this.forwarder)).to.be.true;
});
it('returns the trusted forwarder', async function () {
expect(await this.context.trustedForwarder()).to.equal(this.forwarder);
});
describe('when called directly', function () {
shouldBehaveLikeRegularContext();
});
describe('when receiving a relayed call', function () {
describe('msgSender', function () {
it('returns the relayed transaction original sender', async function () {
const nonce = await this.forwarder.nonces(this.sender);
const data = this.context.interface.encodeFunctionData('msgSender');
const req = {
from: await this.sender.getAddress(),
to: await this.context.getAddress(),
value: 0n,
data,
gas: 100000n,
nonce,
deadline: MAX_UINT48,
};
req.signature = await this.sender.signTypedData(this.domain, this.types, req);
expect(await this.forwarder.verify(req)).to.be.true;
await expect(this.forwarder.execute(req)).to.emit(this.context, 'Sender').withArgs(this.sender);
});
it('returns the original sender when calldata length is less than 20 bytes (address length)', async function () {
// The forwarder doesn't produce calls with calldata length less than 20 bytes so `this.forwarderAsSigner` is used instead.
await expect(this.context.connect(this.forwarderAsSigner).msgSender())
.to.emit(this.context, 'Sender')
.withArgs(this.forwarder);
});
});
describe('msgData', function () {
it('returns the relayed transaction original data', async function () {
const args = [42n, 'OpenZeppelin'];
const nonce = await this.forwarder.nonces(this.sender);
const data = this.context.interface.encodeFunctionData('msgData', args);
const req = {
from: await this.sender.getAddress(),
to: await this.context.getAddress(),
value: 0n,
data,
gas: 100000n,
nonce,
deadline: MAX_UINT48,
};
req.signature = this.sender.signTypedData(this.domain, this.types, req);
expect(await this.forwarder.verify(req)).to.be.true;
await expect(this.forwarder.execute(req))
.to.emit(this.context, 'Data')
.withArgs(data, ...args);
});
});
it('returns the full original data when calldata length is less than 20 bytes (address length)', async function () {
const data = this.context.interface.encodeFunctionData('msgDataShort');
// The forwarder doesn't produce calls with calldata length less than 20 bytes so `this.forwarderAsSigner` is used instead.
await expect(await this.context.connect(this.forwarderAsSigner).msgDataShort())
.to.emit(this.context, 'DataShort')
.withArgs(data);
});
});
it('multicall poison attack', async function () {
const nonce = await this.forwarder.nonces(this.sender);
const data = this.context.interface.encodeFunctionData('multicall', [
[
// poisonned call to 'msgSender()'
ethers.concat([this.context.interface.encodeFunctionData('msgSender'), this.other.address]),
],
]);
const req = {
from: await this.sender.getAddress(),
to: await this.context.getAddress(),
value: 0n,
data,
gas: 100000n,
nonce,
deadline: MAX_UINT48,
};
req.signature = await this.sender.signTypedData(this.domain, this.types, req);
expect(await this.forwarder.verify(req)).to.be.true;
await expect(this.forwarder.execute(req)).to.emit(this.context, 'Sender').withArgs(this.sender);
});
});

View File

@@ -0,0 +1,165 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {Test} from "@forge-std/Test.sol";
import {ERC2771Forwarder} from "@openzeppelin/contracts/metatx/ERC2771Forwarder.sol";
import {CallReceiverMockTrustingForwarder, CallReceiverMock} from "@openzeppelin/contracts/mocks/CallReceiverMock.sol";
struct ForwardRequest {
address from;
address to;
uint256 value;
uint256 gas;
uint256 nonce;
uint48 deadline;
bytes data;
}
contract ERC2771ForwarderMock is ERC2771Forwarder {
constructor(string memory name) ERC2771Forwarder(name) {}
function structHash(ForwardRequest calldata request) external view returns (bytes32) {
return
_hashTypedDataV4(
keccak256(
abi.encode(
_FORWARD_REQUEST_TYPEHASH,
request.from,
request.to,
request.value,
request.gas,
request.nonce,
request.deadline,
keccak256(request.data)
)
)
);
}
}
contract ERC2771ForwarderTest is Test {
ERC2771ForwarderMock internal _erc2771Forwarder;
CallReceiverMockTrustingForwarder internal _receiver;
uint256 internal _signerPrivateKey;
uint256 internal _relayerPrivateKey;
address internal _signer;
address internal _relayer;
uint256 internal constant _MAX_ETHER = 10_000_000; // To avoid overflow
function setUp() public {
_erc2771Forwarder = new ERC2771ForwarderMock("ERC2771Forwarder");
_receiver = new CallReceiverMockTrustingForwarder(address(_erc2771Forwarder));
_signerPrivateKey = 0xA11CE;
_relayerPrivateKey = 0xB0B;
_signer = vm.addr(_signerPrivateKey);
_relayer = vm.addr(_relayerPrivateKey);
}
function _forgeRequestData(
uint256 value,
uint256 nonce,
uint48 deadline,
bytes memory data
) private view returns (ERC2771Forwarder.ForwardRequestData memory) {
ForwardRequest memory request = ForwardRequest({
from: _signer,
to: address(_receiver),
value: value,
gas: 30000,
nonce: nonce,
deadline: deadline,
data: data
});
bytes32 digest = _erc2771Forwarder.structHash(request);
(uint8 v, bytes32 r, bytes32 s) = vm.sign(_signerPrivateKey, digest);
bytes memory signature = abi.encodePacked(r, s, v);
return
ERC2771Forwarder.ForwardRequestData({
from: request.from,
to: request.to,
value: request.value,
gas: request.gas,
deadline: request.deadline,
data: request.data,
signature: signature
});
}
function testExecuteAvoidsETHStuck(uint256 initialBalance, uint256 value, bool targetReverts) public {
initialBalance = bound(initialBalance, 0, _MAX_ETHER);
value = bound(value, 0, _MAX_ETHER);
vm.deal(address(_erc2771Forwarder), initialBalance);
uint256 nonce = _erc2771Forwarder.nonces(_signer);
vm.deal(address(this), value);
ERC2771Forwarder.ForwardRequestData memory requestData = _forgeRequestData({
value: value,
nonce: nonce,
deadline: uint48(block.timestamp + 1),
data: targetReverts
? abi.encodeCall(CallReceiverMock.mockFunctionRevertsNoReason, ())
: abi.encodeCall(CallReceiverMock.mockFunction, ())
});
if (targetReverts) {
vm.expectRevert();
}
_erc2771Forwarder.execute{value: value}(requestData);
assertEq(address(_erc2771Forwarder).balance, initialBalance);
}
function testExecuteBatchAvoidsETHStuck(uint256 initialBalance, uint256 batchSize, uint256 value) public {
batchSize = bound(batchSize, 1, 10);
initialBalance = bound(initialBalance, 0, _MAX_ETHER);
value = bound(value, 0, _MAX_ETHER);
vm.deal(address(_erc2771Forwarder), initialBalance);
uint256 nonce = _erc2771Forwarder.nonces(_signer);
ERC2771Forwarder.ForwardRequestData[] memory batchRequestDatas = new ERC2771Forwarder.ForwardRequestData[](
batchSize
);
uint256 expectedRefund;
for (uint256 i = 0; i < batchSize; ++i) {
bytes memory data;
bool succeed = uint256(keccak256(abi.encodePacked(initialBalance, i))) % 2 == 0;
if (succeed) {
data = abi.encodeCall(CallReceiverMock.mockFunction, ());
} else {
expectedRefund += value;
data = abi.encodeCall(CallReceiverMock.mockFunctionRevertsNoReason, ());
}
batchRequestDatas[i] = _forgeRequestData({
value: value,
nonce: nonce + i,
deadline: uint48(block.timestamp + 1),
data: data
});
}
address payable refundReceiver = payable(address(0xebe));
uint256 totalValue = value * batchSize;
vm.deal(address(this), totalValue);
_erc2771Forwarder.executeBatch{value: totalValue}(batchRequestDatas, refundReceiver);
assertEq(address(_erc2771Forwarder).balance, initialBalance);
assertEq(refundReceiver.balance, expectedRefund);
}
}

View File

@@ -0,0 +1,461 @@
const { ethers } = require('hardhat');
const { expect } = require('chai');
const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers');
const { getDomain, ForwardRequest } = require('../helpers/eip712');
const { sum } = require('../helpers/math');
const time = require('../helpers/time');
async function fixture() {
const [sender, refundReceiver, another, ...accounts] = await ethers.getSigners();
const forwarder = await ethers.deployContract('ERC2771Forwarder', ['ERC2771Forwarder']);
const receiver = await ethers.deployContract('CallReceiverMockTrustingForwarder', [forwarder]);
const domain = await getDomain(forwarder);
const types = { ForwardRequest };
const forgeRequest = async (override = {}, signer = sender) => {
const req = {
from: await signer.getAddress(),
to: await receiver.getAddress(),
value: 0n,
data: receiver.interface.encodeFunctionData('mockFunction'),
gas: 100000n,
deadline: (await time.clock.timestamp()) + 60n,
nonce: await forwarder.nonces(sender),
...override,
};
req.signature = await signer.signTypedData(domain, types, req);
return req;
};
const estimateRequest = request =>
ethers.provider.estimateGas({
from: forwarder,
to: request.to,
data: ethers.solidityPacked(['bytes', 'address'], [request.data, request.from]),
value: request.value,
gasLimit: request.gas,
});
return {
sender,
refundReceiver,
another,
accounts,
forwarder,
receiver,
forgeRequest,
estimateRequest,
domain,
types,
};
}
// values or function to tamper with a signed request.
const tamperedValues = {
from: ethers.Wallet.createRandom().address,
to: ethers.Wallet.createRandom().address,
value: ethers.parseEther('0.5'),
data: '0x1742',
signature: s => {
const t = ethers.toBeArray(s);
t[42] ^= 0xff;
return ethers.hexlify(t);
},
};
describe('ERC2771Forwarder', function () {
beforeEach(async function () {
Object.assign(this, await loadFixture(fixture));
});
describe('verify', function () {
describe('with valid signature', function () {
it('returns true without altering the nonce', async function () {
const request = await this.forgeRequest();
expect(await this.forwarder.nonces(request.from)).to.equal(request.nonce);
expect(await this.forwarder.verify(request)).to.be.true;
expect(await this.forwarder.nonces(request.from)).to.equal(request.nonce);
});
});
describe('with tampered values', function () {
for (const [key, value] of Object.entries(tamperedValues)) {
it(`returns false with tampered ${key}`, async function () {
const request = await this.forgeRequest();
request[key] = typeof value == 'function' ? value(request[key]) : value;
expect(await this.forwarder.verify(request)).to.be.false;
});
}
it('returns false with valid signature for non-current nonce', async function () {
const request = await this.forgeRequest({ nonce: 1337n });
expect(await this.forwarder.verify(request)).to.be.false;
});
it('returns false with valid signature for expired deadline', async function () {
const request = await this.forgeRequest({ deadline: (await time.clock.timestamp()) - 1n });
expect(await this.forwarder.verify(request)).to.be.false;
});
});
});
describe('execute', function () {
describe('with valid requests', function () {
it('emits an event and consumes nonce for a successful request', async function () {
const request = await this.forgeRequest();
expect(await this.forwarder.nonces(request.from)).to.equal(request.nonce);
await expect(this.forwarder.execute(request))
.to.emit(this.receiver, 'MockFunctionCalled')
.to.emit(this.forwarder, 'ExecutedForwardRequest')
.withArgs(request.from, request.nonce, true);
expect(await this.forwarder.nonces(request.from)).to.equal(request.nonce + 1n);
});
it('reverts with an unsuccessful request', async function () {
const request = await this.forgeRequest({
data: this.receiver.interface.encodeFunctionData('mockFunctionRevertsNoReason'),
});
await expect(this.forwarder.execute(request)).to.be.revertedWithCustomError(this.forwarder, 'FailedCall');
});
});
describe('with tampered request', function () {
for (const [key, value] of Object.entries(tamperedValues)) {
it(`reverts with tampered ${key}`, async function () {
const request = await this.forgeRequest();
request[key] = typeof value == 'function' ? value(request[key]) : value;
const promise = this.forwarder.execute(request, { value: key == 'value' ? value : 0 });
if (key != 'to') {
await expect(promise)
.to.be.revertedWithCustomError(this.forwarder, 'ERC2771ForwarderInvalidSigner')
.withArgs(ethers.verifyTypedData(this.domain, this.types, request, request.signature), request.from);
} else {
await expect(promise)
.to.be.revertedWithCustomError(this.forwarder, 'ERC2771UntrustfulTarget')
.withArgs(request.to, this.forwarder);
}
});
}
it('reverts with valid signature for non-current nonce', async function () {
const request = await this.forgeRequest();
// consume nonce
await this.forwarder.execute(request);
// nonce has changed
await expect(this.forwarder.execute(request))
.to.be.revertedWithCustomError(this.forwarder, 'ERC2771ForwarderInvalidSigner')
.withArgs(
ethers.verifyTypedData(
this.domain,
this.types,
{ ...request, nonce: request.nonce + 1n },
request.signature,
),
request.from,
);
});
it('reverts with valid signature for expired deadline', async function () {
const request = await this.forgeRequest({ deadline: (await time.clock.timestamp()) - 1n });
await expect(this.forwarder.execute(request))
.to.be.revertedWithCustomError(this.forwarder, 'ERC2771ForwarderExpiredRequest')
.withArgs(request.deadline);
});
it('reverts with valid signature but mismatched value', async function () {
const request = await this.forgeRequest({ value: 100n });
await expect(this.forwarder.execute(request))
.to.be.revertedWithCustomError(this.forwarder, 'ERC2771ForwarderMismatchedValue')
.withArgs(request.value, 0n);
});
});
it('bubbles out of gas', async function () {
const request = await this.forgeRequest({
data: this.receiver.interface.encodeFunctionData('mockFunctionOutOfGas'),
gas: 1_000_000n,
});
const gasLimit = 100_000n;
await expect(this.forwarder.execute(request, { gasLimit })).to.be.revertedWithoutReason();
const { gasUsed } = await ethers.provider
.getBlock('latest')
.then(block => block.getTransaction(0))
.then(tx => ethers.provider.getTransactionReceipt(tx.hash));
expect(gasUsed).to.equal(gasLimit);
});
it('bubbles out of gas forced by the relayer', async function () {
const request = await this.forgeRequest();
// If there's an incentive behind executing requests, a malicious relayer could grief
// the forwarder by executing requests and providing a top-level call gas limit that
// is too low to successfully finish the request after the 63/64 rule.
// We set the baseline to the gas limit consumed by a successful request if it was executed
// normally. Note this includes the 21000 buffer that also the relayer will be charged to
// start a request execution.
const estimate = await this.estimateRequest(request);
// Because the relayer call consumes gas until the `CALL` opcode, the gas left after failing
// the subcall won't enough to finish the top level call (after testing), so we add a
// moderated buffer.
const gasLimit = estimate + 2_000n;
// The subcall out of gas should be caught by the contract and then bubbled up consuming
// the available gas with an `invalid` opcode.
await expect(this.forwarder.execute(request, { gasLimit })).to.be.revertedWithoutReason();
const { gasUsed } = await ethers.provider
.getBlock('latest')
.then(block => block.getTransaction(0))
.then(tx => ethers.provider.getTransactionReceipt(tx.hash));
// We assert that indeed the gas was totally consumed.
expect(gasUsed).to.equal(gasLimit);
});
});
describe('executeBatch', function () {
const requestsValue = requests => sum(...requests.map(request => request.value));
const requestCount = 3;
const idx = 1; // index that will be tampered with
beforeEach(async function () {
this.forgeRequests = override =>
Promise.all(this.accounts.slice(0, requestCount).map(signer => this.forgeRequest(override, signer)));
this.requests = await this.forgeRequests({ value: 10n });
this.value = requestsValue(this.requests);
});
describe('with valid requests', function () {
it('sanity', async function () {
for (const request of this.requests) {
expect(await this.forwarder.verify(request)).to.be.true;
}
});
it('emits events', async function () {
const receipt = this.forwarder.executeBatch(this.requests, this.another, { value: this.value });
for (const request of this.requests) {
await expect(receipt)
.to.emit(this.receiver, 'MockFunctionCalled')
.to.emit(this.forwarder, 'ExecutedForwardRequest')
.withArgs(request.from, request.nonce, true);
}
});
it('increase nonces', async function () {
await this.forwarder.executeBatch(this.requests, this.another, { value: this.value });
for (const request of this.requests) {
expect(await this.forwarder.nonces(request.from)).to.equal(request.nonce + 1n);
}
});
});
describe('with tampered requests', function () {
it('reverts with mismatched value', async function () {
// tamper value of one of the request + resign
this.requests[idx] = await this.forgeRequest({ value: 100n }, this.accounts[1]);
await expect(this.forwarder.executeBatch(this.requests, this.another, { value: this.value }))
.to.be.revertedWithCustomError(this.forwarder, 'ERC2771ForwarderMismatchedValue')
.withArgs(requestsValue(this.requests), this.value);
});
describe('when the refund receiver is the zero address', function () {
beforeEach(function () {
this.refundReceiver = ethers.ZeroAddress;
});
for (const [key, value] of Object.entries(tamperedValues)) {
it(`reverts with at least one tampered request ${key}`, async function () {
this.requests[idx][key] = typeof value == 'function' ? value(this.requests[idx][key]) : value;
const promise = this.forwarder.executeBatch(this.requests, this.refundReceiver, { value: this.value });
if (key != 'to') {
await expect(promise)
.to.be.revertedWithCustomError(this.forwarder, 'ERC2771ForwarderInvalidSigner')
.withArgs(
ethers.verifyTypedData(this.domain, this.types, this.requests[idx], this.requests[idx].signature),
this.requests[idx].from,
);
} else {
await expect(promise)
.to.be.revertedWithCustomError(this.forwarder, 'ERC2771UntrustfulTarget')
.withArgs(this.requests[idx].to, this.forwarder);
}
});
}
it('reverts with at least one valid signature for non-current nonce', async function () {
// Execute first a request
await this.forwarder.execute(this.requests[idx], { value: this.requests[idx].value });
// And then fail due to an already used nonce
await expect(this.forwarder.executeBatch(this.requests, this.refundReceiver, { value: this.value }))
.to.be.revertedWithCustomError(this.forwarder, 'ERC2771ForwarderInvalidSigner')
.withArgs(
ethers.verifyTypedData(
this.domain,
this.types,
{ ...this.requests[idx], nonce: this.requests[idx].nonce + 1n },
this.requests[idx].signature,
),
this.requests[idx].from,
);
});
it('reverts with at least one valid signature for expired deadline', async function () {
this.requests[idx] = await this.forgeRequest(
{ ...this.requests[idx], deadline: (await time.clock.timestamp()) - 1n },
this.accounts[1],
);
await expect(this.forwarder.executeBatch(this.requests, this.refundReceiver, { value: this.amount }))
.to.be.revertedWithCustomError(this.forwarder, 'ERC2771ForwarderExpiredRequest')
.withArgs(this.requests[idx].deadline);
});
});
describe('when the refund receiver is a known address', function () {
beforeEach(async function () {
this.initialRefundReceiverBalance = await ethers.provider.getBalance(this.refundReceiver);
this.initialTamperedRequestNonce = await this.forwarder.nonces(this.requests[idx].from);
});
for (const [key, value] of Object.entries(tamperedValues)) {
it(`ignores a request with tampered ${key} and refunds its value`, async function () {
this.requests[idx][key] = typeof value == 'function' ? value(this.requests[idx][key]) : value;
const events = await this.forwarder
.executeBatch(this.requests, this.refundReceiver, { value: requestsValue(this.requests) })
.then(tx => tx.wait())
.then(receipt =>
receipt.logs.filter(
log => log?.fragment?.type == 'event' && log?.fragment?.name == 'ExecutedForwardRequest',
),
);
expect(events).to.have.lengthOf(this.requests.length - 1);
});
}
it('ignores a request with a valid signature for non-current nonce', async function () {
// Execute first a request
await this.forwarder.execute(this.requests[idx], { value: this.requests[idx].value });
this.initialTamperedRequestNonce++; // Should be already incremented by the individual `execute`
// And then ignore the same request in a batch due to an already used nonce
const events = await this.forwarder
.executeBatch(this.requests, this.refundReceiver, { value: this.value })
.then(tx => tx.wait())
.then(receipt =>
receipt.logs.filter(
log => log?.fragment?.type == 'event' && log?.fragment?.name == 'ExecutedForwardRequest',
),
);
expect(events).to.have.lengthOf(this.requests.length - 1);
});
it('ignores a request with a valid signature for expired deadline', async function () {
this.requests[idx] = await this.forgeRequest(
{ ...this.requests[idx], deadline: (await time.clock.timestamp()) - 1n },
this.accounts[1],
);
const events = await this.forwarder
.executeBatch(this.requests, this.refundReceiver, { value: this.value })
.then(tx => tx.wait())
.then(receipt =>
receipt.logs.filter(
log => log?.fragment?.type == 'event' && log?.fragment?.name == 'ExecutedForwardRequest',
),
);
expect(events).to.have.lengthOf(this.requests.length - 1);
});
afterEach(async function () {
// The invalid request value was refunded
expect(await ethers.provider.getBalance(this.refundReceiver)).to.equal(
this.initialRefundReceiverBalance + this.requests[idx].value,
);
// The invalid request from's nonce was not incremented
expect(await this.forwarder.nonces(this.requests[idx].from)).to.equal(this.initialTamperedRequestNonce);
});
});
it('bubbles out of gas', async function () {
this.requests[idx] = await this.forgeRequest({
data: this.receiver.interface.encodeFunctionData('mockFunctionOutOfGas'),
gas: 1_000_000n,
});
const gasLimit = 300_000n;
await expect(
this.forwarder.executeBatch(this.requests, ethers.ZeroAddress, {
gasLimit,
value: requestsValue(this.requests),
}),
).to.be.revertedWithoutReason();
const { gasUsed } = await ethers.provider
.getBlock('latest')
.then(block => block.getTransaction(0))
.then(tx => ethers.provider.getTransactionReceipt(tx.hash));
expect(gasUsed).to.equal(gasLimit);
});
it('bubbles out of gas forced by the relayer', async function () {
// Similarly to the single execute, a malicious relayer could grief requests.
// We estimate until the selected request as if they were executed normally
const estimate = await Promise.all(this.requests.slice(0, idx + 1).map(this.estimateRequest)).then(gas =>
sum(...gas),
);
// We add a Buffer to account for all the gas that's used before the selected call.
// Note is slightly bigger because the selected request is not the index 0 and it affects
// the buffer needed.
const gasLimit = estimate + 10_000n;
// The subcall out of gas should be caught by the contract and then bubbled up consuming
// the available gas with an `invalid` opcode.
await expect(
this.forwarder.executeBatch(this.requests, ethers.ZeroAddress, {
gasLimit,
value: requestsValue(this.requests),
}),
).to.be.revertedWithoutReason();
const { gasUsed } = await ethers.provider
.getBlock('latest')
.then(block => block.getTransaction(0))
.then(tx => ethers.provider.getTransactionReceipt(tx.hash));
// We assert that indeed the gas was totally consumed.
expect(gasUsed).to.equal(gasLimit);
});
});
});
});

View File

@@ -0,0 +1,160 @@
const { ethers } = require('hardhat');
const { expect } = require('chai');
module.exports = function shouldBehaveLikeClone() {
const assertProxyInitialization = function ({ value, balance }) {
it('initializes the proxy', async function () {
const dummy = await ethers.getContractAt('DummyImplementation', this.proxy);
expect(await dummy.value()).to.equal(value);
});
it('has expected balance', async function () {
expect(await ethers.provider.getBalance(this.proxy)).to.equal(balance);
});
};
describe('construct with value', function () {
const value = 10n;
it('factory has enough balance', async function () {
await this.deployer.sendTransaction({ to: this.factory, value });
const instance = await this.createClone({ deployValue: value });
await expect(instance.deploymentTransaction()).to.changeEtherBalances([this.factory, instance], [-value, value]);
expect(await ethers.provider.getBalance(instance)).to.equal(value);
});
it('factory does not have enough balance', async function () {
await expect(this.createClone({ deployValue: value }))
.to.be.revertedWithCustomError(this.factory, 'InsufficientBalance')
.withArgs(0n, value);
});
});
describe('initialization without parameters', function () {
describe('non payable', function () {
const expectedInitializedValue = 10n;
beforeEach(async function () {
this.initializeData = await this.implementation.interface.encodeFunctionData('initializeNonPayable');
});
describe('when not sending balance', function () {
beforeEach('creating proxy', async function () {
this.proxy = await this.createClone({ initData: this.initializeData });
});
assertProxyInitialization({
value: expectedInitializedValue,
balance: 0,
});
});
describe('when sending some balance', function () {
const value = 10n ** 6n;
it('reverts', async function () {
await expect(this.createClone({ initData: this.initializeData, initValue: value })).to.be.reverted;
});
});
});
describe('payable', function () {
const expectedInitializedValue = 100n;
beforeEach(async function () {
this.initializeData = await this.implementation.interface.encodeFunctionData('initializePayable');
});
describe('when not sending balance', function () {
beforeEach('creating proxy', async function () {
this.proxy = await this.createClone({ initData: this.initializeData });
});
assertProxyInitialization({
value: expectedInitializedValue,
balance: 0,
});
});
describe('when sending some balance', function () {
const value = 10n ** 6n;
beforeEach('creating proxy', async function () {
this.proxy = await this.createClone({ initData: this.initializeData, initValue: value });
});
assertProxyInitialization({
value: expectedInitializedValue,
balance: value,
});
});
});
});
describe('initialization with parameters', function () {
describe('non payable', function () {
const expectedInitializedValue = 10n;
beforeEach(async function () {
this.initializeData = await this.implementation.interface.encodeFunctionData('initializeNonPayableWithValue', [
expectedInitializedValue,
]);
});
describe('when not sending balance', function () {
beforeEach('creating proxy', async function () {
this.proxy = await this.createClone({ initData: this.initializeData });
});
assertProxyInitialization({
value: expectedInitializedValue,
balance: 0,
});
});
describe('when sending some balance', function () {
const value = 10n ** 6n;
it('reverts', async function () {
await expect(this.createClone({ initData: this.initializeData, initValue: value })).to.be.reverted;
});
});
});
describe('payable', function () {
const expectedInitializedValue = 42n;
beforeEach(function () {
this.initializeData = this.implementation.interface.encodeFunctionData('initializePayableWithValue', [
expectedInitializedValue,
]);
});
describe('when not sending balance', function () {
beforeEach('creating proxy', async function () {
this.proxy = await this.createClone({ initData: this.initializeData });
});
assertProxyInitialization({
value: expectedInitializedValue,
balance: 0,
});
});
describe('when sending some balance', function () {
const value = 10n ** 6n;
beforeEach('creating proxy', async function () {
this.proxy = await this.createClone({ initData: this.initializeData, initValue: value });
});
assertProxyInitialization({
value: expectedInitializedValue,
balance: value,
});
});
});
});
};

View File

@@ -0,0 +1,18 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {Test} from "@forge-std/Test.sol";
import {Clones} from "@openzeppelin/contracts/proxy/Clones.sol";
contract ClonesTest is Test {
function testPredictDeterministicAddressSpillage(address implementation, bytes32 salt) public {
address predicted = Clones.predictDeterministicAddress(implementation, salt);
bytes32 spillage;
/// @solidity memory-safe-assembly
assembly {
spillage := and(predicted, 0xffffffffffffffffffffffff0000000000000000000000000000000000000000)
}
assertEq(spillage, bytes32(0));
}
}

View File

@@ -0,0 +1,95 @@
const { ethers } = require('hardhat');
const { expect } = require('chai');
const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers');
const shouldBehaveLikeClone = require('./Clones.behaviour');
async function fixture() {
const [deployer] = await ethers.getSigners();
const factory = await ethers.deployContract('$Clones');
const implementation = await ethers.deployContract('DummyImplementation');
const newClone = async (opts = {}) => {
const clone = await factory.$clone.staticCall(implementation).then(address => implementation.attach(address));
const tx = await (opts.deployValue
? factory.$clone(implementation, ethers.Typed.uint256(opts.deployValue))
: factory.$clone(implementation));
if (opts.initData || opts.initValue) {
await deployer.sendTransaction({ to: clone, value: opts.initValue ?? 0n, data: opts.initData ?? '0x' });
}
return Object.assign(clone, { deploymentTransaction: () => tx });
};
const newCloneDeterministic = async (opts = {}) => {
const salt = opts.salt ?? ethers.randomBytes(32);
const clone = await factory.$cloneDeterministic
.staticCall(implementation, salt)
.then(address => implementation.attach(address));
const tx = await (opts.deployValue
? factory.$cloneDeterministic(implementation, salt, ethers.Typed.uint256(opts.deployValue))
: factory.$cloneDeterministic(implementation, salt));
if (opts.initData || opts.initValue) {
await deployer.sendTransaction({ to: clone, value: opts.initValue ?? 0n, data: opts.initData ?? '0x' });
}
return Object.assign(clone, { deploymentTransaction: () => tx });
};
return { deployer, factory, implementation, newClone, newCloneDeterministic };
}
describe('Clones', function () {
beforeEach(async function () {
Object.assign(this, await loadFixture(fixture));
});
describe('clone', function () {
beforeEach(async function () {
this.createClone = this.newClone;
});
shouldBehaveLikeClone();
});
describe('cloneDeterministic', function () {
beforeEach(async function () {
this.createClone = this.newCloneDeterministic;
});
shouldBehaveLikeClone();
it('revert if address already used', async function () {
const salt = ethers.randomBytes(32);
// deploy once
await expect(this.factory.$cloneDeterministic(this.implementation, salt)).to.emit(
this.factory,
'return$cloneDeterministic_address_bytes32',
);
// deploy twice
await expect(this.factory.$cloneDeterministic(this.implementation, salt)).to.be.revertedWithCustomError(
this.factory,
'FailedDeployment',
);
});
it('address prediction', async function () {
const salt = ethers.randomBytes(32);
const creationCode = ethers.concat([
'0x3d602d80600a3d3981f3363d3d373d3d3d363d73',
this.implementation.target,
'0x5af43d82803e903d91602b57fd5bf3',
]);
const predicted = await this.factory.$predictDeterministicAddress(this.implementation, salt);
const expected = ethers.getCreate2Address(this.factory.target, salt, ethers.keccak256(creationCode));
expect(predicted).to.equal(expected);
await expect(this.factory.$cloneDeterministic(this.implementation, salt))
.to.emit(this.factory, 'return$cloneDeterministic_address_bytes32')
.withArgs(predicted);
});
});
});

View File

@@ -0,0 +1,23 @@
const { ethers } = require('hardhat');
const shouldBehaveLikeProxy = require('../Proxy.behaviour');
const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers');
const fixture = async () => {
const [nonContractAddress] = await ethers.getSigners();
const implementation = await ethers.deployContract('DummyImplementation');
const createProxy = (implementation, initData, opts) =>
ethers.deployContract('ERC1967Proxy', [implementation, initData], opts);
return { nonContractAddress, implementation, createProxy };
};
describe('ERC1967Proxy', function () {
beforeEach(async function () {
Object.assign(this, await loadFixture(fixture));
});
shouldBehaveLikeProxy();
});

View File

@@ -0,0 +1,162 @@
const { ethers } = require('hardhat');
const { expect } = require('chai');
const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers');
const { getAddressInSlot, setSlot, ImplementationSlot, AdminSlot, BeaconSlot } = require('../../helpers/storage');
async function fixture() {
const [, admin, anotherAccount] = await ethers.getSigners();
const utils = await ethers.deployContract('$ERC1967Utils');
const v1 = await ethers.deployContract('DummyImplementation');
const v2 = await ethers.deployContract('CallReceiverMock');
return { admin, anotherAccount, utils, v1, v2 };
}
describe('ERC1967Utils', function () {
beforeEach('setup', async function () {
Object.assign(this, await loadFixture(fixture));
});
describe('IMPLEMENTATION_SLOT', function () {
beforeEach('set v1 implementation', async function () {
await setSlot(this.utils, ImplementationSlot, this.v1);
});
describe('getImplementation', function () {
it('returns current implementation and matches implementation slot value', async function () {
expect(await this.utils.$getImplementation()).to.equal(this.v1);
expect(await getAddressInSlot(this.utils, ImplementationSlot)).to.equal(this.v1);
});
});
describe('upgradeToAndCall', function () {
it('sets implementation in storage and emits event', async function () {
const newImplementation = this.v2;
const tx = await this.utils.$upgradeToAndCall(newImplementation, '0x');
expect(await getAddressInSlot(this.utils, ImplementationSlot)).to.equal(newImplementation);
await expect(tx).to.emit(this.utils, 'Upgraded').withArgs(newImplementation);
});
it('reverts when implementation does not contain code', async function () {
await expect(this.utils.$upgradeToAndCall(this.anotherAccount, '0x'))
.to.be.revertedWithCustomError(this.utils, 'ERC1967InvalidImplementation')
.withArgs(this.anotherAccount);
});
describe('when data is empty', function () {
it('reverts when value is sent', async function () {
await expect(this.utils.$upgradeToAndCall(this.v2, '0x', { value: 1 })).to.be.revertedWithCustomError(
this.utils,
'ERC1967NonPayable',
);
});
});
describe('when data is not empty', function () {
it('delegates a call to the new implementation', async function () {
const initializeData = this.v2.interface.encodeFunctionData('mockFunction');
const tx = await this.utils.$upgradeToAndCall(this.v2, initializeData);
await expect(tx).to.emit(await ethers.getContractAt('CallReceiverMock', this.utils), 'MockFunctionCalled');
});
});
});
});
describe('ADMIN_SLOT', function () {
beforeEach('set admin', async function () {
await setSlot(this.utils, AdminSlot, this.admin);
});
describe('getAdmin', function () {
it('returns current admin and matches admin slot value', async function () {
expect(await this.utils.$getAdmin()).to.equal(this.admin);
expect(await getAddressInSlot(this.utils, AdminSlot)).to.equal(this.admin);
});
});
describe('changeAdmin', function () {
it('sets admin in storage and emits event', async function () {
const newAdmin = this.anotherAccount;
const tx = await this.utils.$changeAdmin(newAdmin);
expect(await getAddressInSlot(this.utils, AdminSlot)).to.equal(newAdmin);
await expect(tx).to.emit(this.utils, 'AdminChanged').withArgs(this.admin, newAdmin);
});
it('reverts when setting the address zero as admin', async function () {
await expect(this.utils.$changeAdmin(ethers.ZeroAddress))
.to.be.revertedWithCustomError(this.utils, 'ERC1967InvalidAdmin')
.withArgs(ethers.ZeroAddress);
});
});
});
describe('BEACON_SLOT', function () {
beforeEach('set beacon', async function () {
this.beacon = await ethers.deployContract('UpgradeableBeaconMock', [this.v1]);
await setSlot(this.utils, BeaconSlot, this.beacon);
});
describe('getBeacon', function () {
it('returns current beacon and matches beacon slot value', async function () {
expect(await this.utils.$getBeacon()).to.equal(this.beacon);
expect(await getAddressInSlot(this.utils, BeaconSlot)).to.equal(this.beacon);
});
});
describe('upgradeBeaconToAndCall', function () {
it('sets beacon in storage and emits event', async function () {
const newBeacon = await ethers.deployContract('UpgradeableBeaconMock', [this.v2]);
const tx = await this.utils.$upgradeBeaconToAndCall(newBeacon, '0x');
expect(await getAddressInSlot(this.utils, BeaconSlot)).to.equal(newBeacon);
await expect(tx).to.emit(this.utils, 'BeaconUpgraded').withArgs(newBeacon);
});
it('reverts when beacon does not contain code', async function () {
await expect(this.utils.$upgradeBeaconToAndCall(this.anotherAccount, '0x'))
.to.be.revertedWithCustomError(this.utils, 'ERC1967InvalidBeacon')
.withArgs(this.anotherAccount);
});
it("reverts when beacon's implementation does not contain code", async function () {
const newBeacon = await ethers.deployContract('UpgradeableBeaconMock', [this.anotherAccount]);
await expect(this.utils.$upgradeBeaconToAndCall(newBeacon, '0x'))
.to.be.revertedWithCustomError(this.utils, 'ERC1967InvalidImplementation')
.withArgs(this.anotherAccount);
});
describe('when data is empty', function () {
it('reverts when value is sent', async function () {
const newBeacon = await ethers.deployContract('UpgradeableBeaconMock', [this.v2]);
await expect(this.utils.$upgradeBeaconToAndCall(newBeacon, '0x', { value: 1 })).to.be.revertedWithCustomError(
this.utils,
'ERC1967NonPayable',
);
});
});
describe('when data is not empty', function () {
it('delegates a call to the new implementation', async function () {
const initializeData = this.v2.interface.encodeFunctionData('mockFunction');
const newBeacon = await ethers.deployContract('UpgradeableBeaconMock', [this.v2]);
const tx = await this.utils.$upgradeBeaconToAndCall(newBeacon, initializeData);
await expect(tx).to.emit(await ethers.getContractAt('CallReceiverMock', this.utils), 'MockFunctionCalled');
});
});
describe('reentrant beacon implementation() call', function () {
it('sees the new beacon implementation', async function () {
const newBeacon = await ethers.deployContract('UpgradeableBeaconReentrantMock');
await expect(this.utils.$upgradeBeaconToAndCall(newBeacon, '0x'))
.to.be.revertedWithCustomError(newBeacon, 'BeaconProxyBeaconSlotAddress')
.withArgs(newBeacon);
});
});
});
});
});

View File

@@ -0,0 +1,185 @@
const { ethers } = require('hardhat');
const { expect } = require('chai');
const { getAddressInSlot, ImplementationSlot } = require('../helpers/storage');
module.exports = function shouldBehaveLikeProxy() {
it('cannot be initialized with a non-contract address', async function () {
const initializeData = '0x';
const contractFactory = await ethers.getContractFactory('ERC1967Proxy');
await expect(this.createProxy(this.nonContractAddress, initializeData))
.to.be.revertedWithCustomError(contractFactory, 'ERC1967InvalidImplementation')
.withArgs(this.nonContractAddress);
});
const assertProxyInitialization = function ({ value, balance }) {
it('sets the implementation address', async function () {
expect(await getAddressInSlot(this.proxy, ImplementationSlot)).to.equal(this.implementation);
});
it('initializes the proxy', async function () {
const dummy = this.implementation.attach(this.proxy);
expect(await dummy.value()).to.equal(value);
});
it('has expected balance', async function () {
expect(await ethers.provider.getBalance(this.proxy)).to.equal(balance);
});
};
describe('without initialization', function () {
const initializeData = '0x';
describe('when not sending balance', function () {
beforeEach('creating proxy', async function () {
this.proxy = await this.createProxy(this.implementation, initializeData);
});
assertProxyInitialization({ value: 0n, balance: 0n });
});
describe('when sending some balance', function () {
const value = 10n ** 5n;
it('reverts', async function () {
await expect(this.createProxy(this.implementation, initializeData, { value })).to.be.reverted;
});
});
});
describe('initialization without parameters', function () {
describe('non payable', function () {
const expectedInitializedValue = 10n;
beforeEach(function () {
this.initializeData = this.implementation.interface.encodeFunctionData('initializeNonPayable');
});
describe('when not sending balance', function () {
beforeEach('creating proxy', async function () {
this.proxy = await this.createProxy(this.implementation, this.initializeData);
});
assertProxyInitialization({
value: expectedInitializedValue,
balance: 0n,
});
});
describe('when sending some balance', function () {
const value = 10n ** 5n;
it('reverts', async function () {
await expect(this.createProxy(this.implementation, this.initializeData, { value })).to.be.reverted;
});
});
});
describe('payable', function () {
const expectedInitializedValue = 100n;
beforeEach(function () {
this.initializeData = this.implementation.interface.encodeFunctionData('initializePayable');
});
describe('when not sending balance', function () {
beforeEach('creating proxy', async function () {
this.proxy = await this.createProxy(this.implementation, this.initializeData);
});
assertProxyInitialization({
value: expectedInitializedValue,
balance: 0n,
});
});
describe('when sending some balance', function () {
const value = 10e5;
beforeEach('creating proxy', async function () {
this.proxy = await this.createProxy(this.implementation, this.initializeData, { value });
});
assertProxyInitialization({
value: expectedInitializedValue,
balance: value,
});
});
});
});
describe('initialization with parameters', function () {
describe('non payable', function () {
const expectedInitializedValue = 10n;
beforeEach(function () {
this.initializeData = this.implementation.interface.encodeFunctionData('initializeNonPayableWithValue', [
expectedInitializedValue,
]);
});
describe('when not sending balance', function () {
beforeEach('creating proxy', async function () {
this.proxy = await this.createProxy(this.implementation, this.initializeData);
});
assertProxyInitialization({
value: expectedInitializedValue,
balance: 0,
});
});
describe('when sending some balance', function () {
const value = 10e5;
it('reverts', async function () {
await expect(this.createProxy(this.implementation, this.initializeData, { value })).to.be.reverted;
});
});
});
describe('payable', function () {
const expectedInitializedValue = 42n;
beforeEach(function () {
this.initializeData = this.implementation.interface.encodeFunctionData('initializePayableWithValue', [
expectedInitializedValue,
]);
});
describe('when not sending balance', function () {
beforeEach('creating proxy', async function () {
this.proxy = await this.createProxy(this.implementation, this.initializeData);
});
assertProxyInitialization({
value: expectedInitializedValue,
balance: 0n,
});
});
describe('when sending some balance', function () {
const value = 10n ** 5n;
beforeEach('creating proxy', async function () {
this.proxy = await this.createProxy(this.implementation, this.initializeData, { value });
});
assertProxyInitialization({
value: expectedInitializedValue,
balance: value,
});
});
});
describe('reverting initialization', function () {
beforeEach(function () {
this.initializeData = this.implementation.interface.encodeFunctionData('reverts');
});
it('reverts', async function () {
await expect(this.createProxy(this.implementation, this.initializeData)).to.be.reverted;
});
});
});
};

View File

@@ -0,0 +1,141 @@
const { ethers } = require('hardhat');
const { expect } = require('chai');
const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers');
const { getAddressInSlot, BeaconSlot } = require('../../helpers/storage');
async function fixture() {
const [admin, other] = await ethers.getSigners();
const v1 = await ethers.deployContract('DummyImplementation');
const v2 = await ethers.deployContract('DummyImplementationV2');
const factory = await ethers.getContractFactory('BeaconProxy');
const beacon = await ethers.deployContract('UpgradeableBeacon', [v1, admin]);
const newBeaconProxy = (beacon, data, opts = {}) => factory.deploy(beacon, data, opts);
return { admin, other, factory, beacon, v1, v2, newBeaconProxy };
}
describe('BeaconProxy', function () {
beforeEach(async function () {
Object.assign(this, await loadFixture(fixture));
});
describe('bad beacon is not accepted', function () {
it('non-contract beacon', async function () {
const notBeacon = this.other;
await expect(this.newBeaconProxy(notBeacon, '0x'))
.to.be.revertedWithCustomError(this.factory, 'ERC1967InvalidBeacon')
.withArgs(notBeacon);
});
it('non-compliant beacon', async function () {
const badBeacon = await ethers.deployContract('BadBeaconNoImpl');
// BadBeaconNoImpl does not provide `implementation()` has no fallback.
// This causes ERC1967Utils._setBeacon to revert.
await expect(this.newBeaconProxy(badBeacon, '0x')).to.be.revertedWithoutReason();
});
it('non-contract implementation', async function () {
const badBeacon = await ethers.deployContract('BadBeaconNotContract');
await expect(this.newBeaconProxy(badBeacon, '0x'))
.to.be.revertedWithCustomError(this.factory, 'ERC1967InvalidImplementation')
.withArgs(await badBeacon.implementation());
});
});
describe('initialization', function () {
async function assertInitialized({ value, balance }) {
const beaconAddress = await getAddressInSlot(this.proxy, BeaconSlot);
expect(beaconAddress).to.equal(this.beacon);
const dummy = this.v1.attach(this.proxy);
expect(await dummy.value()).to.equal(value);
expect(await ethers.provider.getBalance(this.proxy)).to.equal(balance);
}
it('no initialization', async function () {
this.proxy = await this.newBeaconProxy(this.beacon, '0x');
await assertInitialized.bind(this)({ value: 0n, balance: 0n });
});
it('non-payable initialization', async function () {
const value = 55n;
const data = this.v1.interface.encodeFunctionData('initializeNonPayableWithValue', [value]);
this.proxy = await this.newBeaconProxy(this.beacon, data);
await assertInitialized.bind(this)({ value, balance: 0n });
});
it('payable initialization', async function () {
const value = 55n;
const data = this.v1.interface.encodeFunctionData('initializePayableWithValue', [value]);
const balance = 100n;
this.proxy = await this.newBeaconProxy(this.beacon, data, { value: balance });
await assertInitialized.bind(this)({ value, balance });
});
it('reverting initialization due to value', async function () {
await expect(this.newBeaconProxy(this.beacon, '0x', { value: 1n })).to.be.revertedWithCustomError(
this.factory,
'ERC1967NonPayable',
);
});
it('reverting initialization function', async function () {
const data = this.v1.interface.encodeFunctionData('reverts');
await expect(this.newBeaconProxy(this.beacon, data)).to.be.revertedWith('DummyImplementation reverted');
});
});
describe('upgrade', function () {
it('upgrade a proxy by upgrading its beacon', async function () {
const value = 10n;
const data = this.v1.interface.encodeFunctionData('initializeNonPayableWithValue', [value]);
const proxy = await this.newBeaconProxy(this.beacon, data).then(instance => this.v1.attach(instance));
// test initial values
expect(await proxy.value()).to.equal(value);
// test initial version
expect(await proxy.version()).to.equal('V1');
// upgrade beacon
await this.beacon.connect(this.admin).upgradeTo(this.v2);
// test upgraded version
expect(await proxy.version()).to.equal('V2');
});
it('upgrade 2 proxies by upgrading shared beacon', async function () {
const value1 = 10n;
const data1 = this.v1.interface.encodeFunctionData('initializeNonPayableWithValue', [value1]);
const proxy1 = await this.newBeaconProxy(this.beacon, data1).then(instance => this.v1.attach(instance));
const value2 = 42n;
const data2 = this.v1.interface.encodeFunctionData('initializeNonPayableWithValue', [value2]);
const proxy2 = await this.newBeaconProxy(this.beacon, data2).then(instance => this.v1.attach(instance));
// test initial values
expect(await proxy1.value()).to.equal(value1);
expect(await proxy2.value()).to.equal(value2);
// test initial version
expect(await proxy1.version()).to.equal('V1');
expect(await proxy2.version()).to.equal('V1');
// upgrade beacon
await this.beacon.connect(this.admin).upgradeTo(this.v2);
// test upgraded version
expect(await proxy1.version()).to.equal('V2');
expect(await proxy2.version()).to.equal('V2');
});
});
});

View File

@@ -0,0 +1,55 @@
const { ethers } = require('hardhat');
const { expect } = require('chai');
const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers');
async function fixture() {
const [admin, other] = await ethers.getSigners();
const v1 = await ethers.deployContract('Implementation1');
const v2 = await ethers.deployContract('Implementation2');
const beacon = await ethers.deployContract('UpgradeableBeacon', [v1, admin]);
return { admin, other, beacon, v1, v2 };
}
describe('UpgradeableBeacon', function () {
beforeEach(async function () {
Object.assign(this, await loadFixture(fixture));
});
it('cannot be created with non-contract implementation', async function () {
await expect(ethers.deployContract('UpgradeableBeacon', [this.other, this.admin]))
.to.be.revertedWithCustomError(this.beacon, 'BeaconInvalidImplementation')
.withArgs(this.other);
});
describe('once deployed', function () {
it('emits Upgraded event to the first implementation', async function () {
await expect(this.beacon.deploymentTransaction()).to.emit(this.beacon, 'Upgraded').withArgs(this.v1);
});
it('returns implementation', async function () {
expect(await this.beacon.implementation()).to.equal(this.v1);
});
it('can be upgraded by the admin', async function () {
await expect(this.beacon.connect(this.admin).upgradeTo(this.v2))
.to.emit(this.beacon, 'Upgraded')
.withArgs(this.v2);
expect(await this.beacon.implementation()).to.equal(this.v2);
});
it('cannot be upgraded to a non-contract', async function () {
await expect(this.beacon.connect(this.admin).upgradeTo(this.other))
.to.be.revertedWithCustomError(this.beacon, 'BeaconInvalidImplementation')
.withArgs(this.other);
});
it('cannot be upgraded by other account', async function () {
await expect(this.beacon.connect(this.other).upgradeTo(this.v2))
.to.be.revertedWithCustomError(this.beacon, 'OwnableUnauthorizedAccount')
.withArgs(this.other);
});
});
});

View File

@@ -0,0 +1,82 @@
const { ethers } = require('hardhat');
const { expect } = require('chai');
const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers');
const { getAddressInSlot, ImplementationSlot } = require('../../helpers/storage');
async function fixture() {
const [admin, other] = await ethers.getSigners();
const v1 = await ethers.deployContract('DummyImplementation');
const v2 = await ethers.deployContract('DummyImplementationV2');
const proxy = await ethers
.deployContract('TransparentUpgradeableProxy', [v1, admin, '0x'])
.then(instance => ethers.getContractAt('ITransparentUpgradeableProxy', instance));
const proxyAdmin = await ethers.getContractAt(
'ProxyAdmin',
ethers.getCreateAddress({ from: proxy.target, nonce: 1n }),
);
return { admin, other, v1, v2, proxy, proxyAdmin };
}
describe('ProxyAdmin', function () {
beforeEach(async function () {
Object.assign(this, await loadFixture(fixture));
});
it('has an owner', async function () {
expect(await this.proxyAdmin.owner()).to.equal(this.admin);
});
it('has an interface version', async function () {
expect(await this.proxyAdmin.UPGRADE_INTERFACE_VERSION()).to.equal('5.0.0');
});
describe('without data', function () {
describe('with unauthorized account', function () {
it('fails to upgrade', async function () {
await expect(this.proxyAdmin.connect(this.other).upgradeAndCall(this.proxy, this.v2, '0x'))
.to.be.revertedWithCustomError(this.proxyAdmin, 'OwnableUnauthorizedAccount')
.withArgs(this.other);
});
});
describe('with authorized account', function () {
it('upgrades implementation', async function () {
await this.proxyAdmin.connect(this.admin).upgradeAndCall(this.proxy, this.v2, '0x');
expect(await getAddressInSlot(this.proxy, ImplementationSlot)).to.equal(this.v2);
});
});
});
describe('with data', function () {
describe('with unauthorized account', function () {
it('fails to upgrade', async function () {
const data = this.v1.interface.encodeFunctionData('initializeNonPayableWithValue', [1337n]);
await expect(this.proxyAdmin.connect(this.other).upgradeAndCall(this.proxy, this.v2, data))
.to.be.revertedWithCustomError(this.proxyAdmin, 'OwnableUnauthorizedAccount')
.withArgs(this.other);
});
});
describe('with authorized account', function () {
describe('with invalid callData', function () {
it('fails to upgrade', async function () {
const data = '0x12345678';
await expect(this.proxyAdmin.connect(this.admin).upgradeAndCall(this.proxy, this.v2, data)).to.be.reverted;
});
});
describe('with valid callData', function () {
it('upgrades implementation', async function () {
const data = this.v2.interface.encodeFunctionData('initializeNonPayableWithValue', [1337n]);
await this.proxyAdmin.connect(this.admin).upgradeAndCall(this.proxy, this.v2, data);
expect(await getAddressInSlot(this.proxy, ImplementationSlot)).to.equal(this.v2);
});
});
});
});
});

View File

@@ -0,0 +1,357 @@
const { ethers } = require('hardhat');
const { expect } = require('chai');
const { impersonate } = require('../../helpers/account');
const { getAddressInSlot, ImplementationSlot, AdminSlot } = require('../../helpers/storage');
// createProxy, initialOwner, accounts
module.exports = function shouldBehaveLikeTransparentUpgradeableProxy() {
before(async function () {
const implementationV0 = await ethers.deployContract('DummyImplementation');
const implementationV1 = await ethers.deployContract('DummyImplementation');
const createProxyWithImpersonatedProxyAdmin = async (logic, initData, opts = undefined) => {
const [proxy, tx] = await this.createProxy(logic, initData, opts).then(instance =>
Promise.all([ethers.getContractAt('ITransparentUpgradeableProxy', instance), instance.deploymentTransaction()]),
);
const proxyAdmin = await ethers.getContractAt(
'ProxyAdmin',
ethers.getCreateAddress({ from: proxy.target, nonce: 1n }),
);
const proxyAdminAsSigner = await proxyAdmin.getAddress().then(impersonate);
return {
instance: logic.attach(proxy.target), // attaching proxy directly works well for everything except for event resolution
proxy,
proxyAdmin,
proxyAdminAsSigner,
tx,
};
};
Object.assign(this, {
implementationV0,
implementationV1,
createProxyWithImpersonatedProxyAdmin,
});
});
beforeEach(async function () {
Object.assign(this, await this.createProxyWithImpersonatedProxyAdmin(this.implementationV0, '0x'));
});
describe('implementation', function () {
it('returns the current implementation address', async function () {
expect(await getAddressInSlot(this.proxy, ImplementationSlot)).to.equal(this.implementationV0);
});
it('delegates to the implementation', async function () {
expect(await this.instance.get()).to.be.true;
});
});
describe('proxy admin', function () {
it('emits AdminChanged event during construction', async function () {
await expect(this.tx).to.emit(this.proxy, 'AdminChanged').withArgs(ethers.ZeroAddress, this.proxyAdmin);
});
it('sets the proxy admin in storage with the correct initial owner', async function () {
expect(await getAddressInSlot(this.proxy, AdminSlot)).to.equal(this.proxyAdmin);
expect(await this.proxyAdmin.owner()).to.equal(this.owner);
});
it('can overwrite the admin by the implementation', async function () {
await this.instance.unsafeOverrideAdmin(this.other);
const ERC1967AdminSlotValue = await getAddressInSlot(this.proxy, AdminSlot);
expect(ERC1967AdminSlotValue).to.equal(this.other);
expect(ERC1967AdminSlotValue).to.not.equal(this.proxyAdmin);
// Still allows previous admin to execute admin operations
await expect(this.proxy.connect(this.proxyAdminAsSigner).upgradeToAndCall(this.implementationV1, '0x'))
.to.emit(this.proxy, 'Upgraded')
.withArgs(this.implementationV1);
});
});
describe('upgradeToAndCall', function () {
describe('without migrations', function () {
beforeEach(async function () {
this.behavior = await ethers.deployContract('InitializableMock');
});
describe('when the call does not fail', function () {
beforeEach(function () {
this.initializeData = this.behavior.interface.encodeFunctionData('initializeWithX', [42n]);
});
describe('when the sender is the admin', function () {
const value = 10n ** 5n;
beforeEach(async function () {
this.tx = await this.proxy
.connect(this.proxyAdminAsSigner)
.upgradeToAndCall(this.behavior, this.initializeData, {
value,
});
});
it('upgrades to the requested implementation', async function () {
expect(await getAddressInSlot(this.proxy, ImplementationSlot)).to.equal(this.behavior);
});
it('emits an event', async function () {
await expect(this.tx).to.emit(this.proxy, 'Upgraded').withArgs(this.behavior);
});
it('calls the initializer function', async function () {
expect(await this.behavior.attach(this.proxy).x()).to.equal(42n);
});
it('sends given value to the proxy', async function () {
expect(await ethers.provider.getBalance(this.proxy)).to.equal(value);
});
it('uses the storage of the proxy', async function () {
// storage layout should look as follows:
// - 0: Initializable storage ++ initializerRan ++ onlyInitializingRan
// - 1: x
expect(await ethers.provider.getStorage(this.proxy, 1n)).to.equal(42n);
});
});
describe('when the sender is not the admin', function () {
it('reverts', async function () {
await expect(this.proxy.connect(this.other).upgradeToAndCall(this.behavior, this.initializeData)).to.be
.reverted;
});
});
});
describe('when the call does fail', function () {
beforeEach(function () {
this.initializeData = this.behavior.interface.encodeFunctionData('fail');
});
it('reverts', async function () {
await expect(this.proxy.connect(this.proxyAdminAsSigner).upgradeToAndCall(this.behavior, this.initializeData))
.to.be.reverted;
});
});
});
describe('with migrations', function () {
describe('when the sender is the admin', function () {
const value = 10n ** 5n;
describe('when upgrading to V1', function () {
beforeEach(async function () {
this.behaviorV1 = await ethers.deployContract('MigratableMockV1');
const v1MigrationData = this.behaviorV1.interface.encodeFunctionData('initialize', [42n]);
this.balancePreviousV1 = await ethers.provider.getBalance(this.proxy);
this.tx = await this.proxy
.connect(this.proxyAdminAsSigner)
.upgradeToAndCall(this.behaviorV1, v1MigrationData, {
value,
});
});
it('upgrades to the requested version and emits an event', async function () {
expect(await getAddressInSlot(this.proxy, ImplementationSlot)).to.equal(this.behaviorV1);
await expect(this.tx).to.emit(this.proxy, 'Upgraded').withArgs(this.behaviorV1);
});
it("calls the 'initialize' function and sends given value to the proxy", async function () {
expect(await this.behaviorV1.attach(this.proxy).x()).to.equal(42n);
expect(await ethers.provider.getBalance(this.proxy)).to.equal(this.balancePreviousV1 + value);
});
describe('when upgrading to V2', function () {
beforeEach(async function () {
this.behaviorV2 = await ethers.deployContract('MigratableMockV2');
const v2MigrationData = this.behaviorV2.interface.encodeFunctionData('migrate', [10n, 42n]);
this.balancePreviousV2 = await ethers.provider.getBalance(this.proxy);
this.tx = await this.proxy
.connect(this.proxyAdminAsSigner)
.upgradeToAndCall(this.behaviorV2, v2MigrationData, {
value,
});
});
it('upgrades to the requested version and emits an event', async function () {
expect(await getAddressInSlot(this.proxy, ImplementationSlot)).to.equal(this.behaviorV2);
await expect(this.tx).to.emit(this.proxy, 'Upgraded').withArgs(this.behaviorV2);
});
it("calls the 'migrate' function and sends given value to the proxy", async function () {
expect(await this.behaviorV2.attach(this.proxy).x()).to.equal(10n);
expect(await this.behaviorV2.attach(this.proxy).y()).to.equal(42n);
expect(await ethers.provider.getBalance(this.proxy)).to.equal(this.balancePreviousV2 + value);
});
describe('when upgrading to V3', function () {
beforeEach(async function () {
this.behaviorV3 = await ethers.deployContract('MigratableMockV3');
const v3MigrationData = this.behaviorV3.interface.encodeFunctionData('migrate()');
this.balancePreviousV3 = await ethers.provider.getBalance(this.proxy);
this.tx = await this.proxy
.connect(this.proxyAdminAsSigner)
.upgradeToAndCall(this.behaviorV3, v3MigrationData, {
value,
});
});
it('upgrades to the requested version and emits an event', async function () {
expect(await getAddressInSlot(this.proxy, ImplementationSlot)).to.equal(this.behaviorV3);
await expect(this.tx).to.emit(this.proxy, 'Upgraded').withArgs(this.behaviorV3);
});
it("calls the 'migrate' function and sends given value to the proxy", async function () {
expect(await this.behaviorV3.attach(this.proxy).x()).to.equal(42n);
expect(await this.behaviorV3.attach(this.proxy).y()).to.equal(10n);
expect(await ethers.provider.getBalance(this.proxy)).to.equal(this.balancePreviousV3 + value);
});
});
});
});
});
describe('when the sender is not the admin', function () {
it('reverts', async function () {
const behaviorV1 = await ethers.deployContract('MigratableMockV1');
const v1MigrationData = behaviorV1.interface.encodeFunctionData('initialize', [42n]);
await expect(this.proxy.connect(this.other).upgradeToAndCall(behaviorV1, v1MigrationData)).to.be.reverted;
});
});
});
});
describe('transparent proxy', function () {
beforeEach('creating proxy', async function () {
this.clashingImplV0 = await ethers.deployContract('ClashingImplementation');
this.clashingImplV1 = await ethers.deployContract('ClashingImplementation');
Object.assign(this, await this.createProxyWithImpersonatedProxyAdmin(this.clashingImplV0, '0x'));
});
it('proxy admin cannot call delegated functions', async function () {
const interface = await ethers.getContractFactory('TransparentUpgradeableProxy');
await expect(this.instance.connect(this.proxyAdminAsSigner).delegatedFunction()).to.be.revertedWithCustomError(
interface,
'ProxyDeniedAdminAccess',
);
});
describe('when function names clash', function () {
it('executes the proxy function if the sender is the admin', async function () {
await expect(this.proxy.connect(this.proxyAdminAsSigner).upgradeToAndCall(this.clashingImplV1, '0x'))
.to.emit(this.proxy, 'Upgraded')
.withArgs(this.clashingImplV1);
});
it('delegates the call to implementation when sender is not the admin', async function () {
await expect(this.proxy.connect(this.other).upgradeToAndCall(this.clashingImplV1, '0x'))
.to.emit(this.instance, 'ClashingImplementationCall')
.to.not.emit(this.proxy, 'Upgraded');
});
});
});
describe('regression', function () {
const initializeData = '0x';
it('should add new function', async function () {
const impl1 = await ethers.deployContract('Implementation1');
const impl2 = await ethers.deployContract('Implementation2');
const { instance, proxy, proxyAdminAsSigner } = await this.createProxyWithImpersonatedProxyAdmin(
impl1,
initializeData,
);
await instance.setValue(42n);
// `getValue` is not available in impl1
await expect(impl2.attach(instance).getValue()).to.be.reverted;
// do upgrade
await proxy.connect(proxyAdminAsSigner).upgradeToAndCall(impl2, '0x');
// `getValue` is available in impl2
expect(await impl2.attach(instance).getValue()).to.equal(42n);
});
it('should remove function', async function () {
const impl1 = await ethers.deployContract('Implementation1');
const impl2 = await ethers.deployContract('Implementation2');
const { instance, proxy, proxyAdminAsSigner } = await this.createProxyWithImpersonatedProxyAdmin(
impl2,
initializeData,
);
await instance.setValue(42n);
// `getValue` is available in impl2
expect(await impl2.attach(instance).getValue()).to.equal(42n);
// do downgrade
await proxy.connect(proxyAdminAsSigner).upgradeToAndCall(impl1, '0x');
// `getValue` is not available in impl1
await expect(impl2.attach(instance).getValue()).to.be.reverted;
});
it('should change function signature', async function () {
const impl1 = await ethers.deployContract('Implementation1');
const impl3 = await ethers.deployContract('Implementation3');
const { instance, proxy, proxyAdminAsSigner } = await this.createProxyWithImpersonatedProxyAdmin(
impl1,
initializeData,
);
await instance.setValue(42n);
await proxy.connect(proxyAdminAsSigner).upgradeToAndCall(impl3, '0x');
expect(await impl3.attach(instance).getValue(8n)).to.equal(50n);
});
it('should add fallback function', async function () {
const impl1 = await ethers.deployContract('Implementation1');
const impl4 = await ethers.deployContract('Implementation4');
const { instance, proxy, proxyAdminAsSigner } = await this.createProxyWithImpersonatedProxyAdmin(
impl1,
initializeData,
);
await proxy.connect(proxyAdminAsSigner).upgradeToAndCall(impl4, '0x');
await this.other.sendTransaction({ to: proxy });
expect(await impl4.attach(instance).getValue()).to.equal(1n);
});
it('should remove fallback function', async function () {
const impl2 = await ethers.deployContract('Implementation2');
const impl4 = await ethers.deployContract('Implementation4');
const { instance, proxy, proxyAdminAsSigner } = await this.createProxyWithImpersonatedProxyAdmin(
impl4,
initializeData,
);
await proxy.connect(proxyAdminAsSigner).upgradeToAndCall(impl2, '0x');
await expect(this.other.sendTransaction({ to: proxy })).to.be.reverted;
expect(await impl2.attach(instance).getValue()).to.equal(0n);
});
});
};

View File

@@ -0,0 +1,28 @@
const { ethers } = require('hardhat');
const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers');
const shouldBehaveLikeProxy = require('../Proxy.behaviour');
const shouldBehaveLikeTransparentUpgradeableProxy = require('./TransparentUpgradeableProxy.behaviour');
async function fixture() {
const [owner, other, ...accounts] = await ethers.getSigners();
const implementation = await ethers.deployContract('DummyImplementation');
const createProxy = function (logic, initData, opts = undefined) {
return ethers.deployContract('TransparentUpgradeableProxy', [logic, owner, initData], opts);
};
return { nonContractAddress: owner, owner, other, accounts, implementation, createProxy };
}
describe('TransparentUpgradeableProxy', function () {
beforeEach(async function () {
Object.assign(this, await loadFixture(fixture));
});
shouldBehaveLikeProxy();
// createProxy, owner, otherAccounts
shouldBehaveLikeTransparentUpgradeableProxy();
});

View File

@@ -0,0 +1,216 @@
const { ethers } = require('hardhat');
const { expect } = require('chai');
const { MAX_UINT64 } = require('../../helpers/constants');
describe('Initializable', function () {
describe('basic testing without inheritance', function () {
beforeEach('deploying', async function () {
this.mock = await ethers.deployContract('InitializableMock');
});
describe('before initialize', function () {
it('initializer has not run', async function () {
expect(await this.mock.initializerRan()).to.be.false;
});
it('_initializing returns false before initialization', async function () {
expect(await this.mock.isInitializing()).to.be.false;
});
});
describe('after initialize', function () {
beforeEach('initializing', async function () {
await this.mock.initialize();
});
it('initializer has run', async function () {
expect(await this.mock.initializerRan()).to.be.true;
});
it('_initializing returns false after initialization', async function () {
expect(await this.mock.isInitializing()).to.be.false;
});
it('initializer does not run again', async function () {
await expect(this.mock.initialize()).to.be.revertedWithCustomError(this.mock, 'InvalidInitialization');
});
});
describe('nested under an initializer', function () {
it('initializer modifier reverts', async function () {
await expect(this.mock.initializerNested()).to.be.revertedWithCustomError(this.mock, 'InvalidInitialization');
});
it('onlyInitializing modifier succeeds', async function () {
await this.mock.onlyInitializingNested();
expect(await this.mock.onlyInitializingRan()).to.be.true;
});
});
it('cannot call onlyInitializable function outside the scope of an initializable function', async function () {
await expect(this.mock.initializeOnlyInitializing()).to.be.revertedWithCustomError(this.mock, 'NotInitializing');
});
});
it('nested initializer can run during construction', async function () {
const mock = await ethers.deployContract('ConstructorInitializableMock');
expect(await mock.initializerRan()).to.be.true;
expect(await mock.onlyInitializingRan()).to.be.true;
});
it('multiple constructor levels can be initializers', async function () {
const mock = await ethers.deployContract('ChildConstructorInitializableMock');
expect(await mock.initializerRan()).to.be.true;
expect(await mock.childInitializerRan()).to.be.true;
expect(await mock.onlyInitializingRan()).to.be.true;
});
describe('reinitialization', function () {
beforeEach('deploying', async function () {
this.mock = await ethers.deployContract('ReinitializerMock');
});
it('can reinitialize', async function () {
expect(await this.mock.counter()).to.equal(0n);
await this.mock.initialize();
expect(await this.mock.counter()).to.equal(1n);
await this.mock.reinitialize(2);
expect(await this.mock.counter()).to.equal(2n);
await this.mock.reinitialize(3);
expect(await this.mock.counter()).to.equal(3n);
});
it('can jump multiple steps', async function () {
expect(await this.mock.counter()).to.equal(0n);
await this.mock.initialize();
expect(await this.mock.counter()).to.equal(1n);
await this.mock.reinitialize(128);
expect(await this.mock.counter()).to.equal(2n);
});
it('cannot nest reinitializers', async function () {
expect(await this.mock.counter()).to.equal(0n);
await expect(this.mock.nestedReinitialize(2, 2)).to.be.revertedWithCustomError(
this.mock,
'InvalidInitialization',
);
await expect(this.mock.nestedReinitialize(2, 3)).to.be.revertedWithCustomError(
this.mock,
'InvalidInitialization',
);
await expect(this.mock.nestedReinitialize(3, 2)).to.be.revertedWithCustomError(
this.mock,
'InvalidInitialization',
);
});
it('can chain reinitializers', async function () {
expect(await this.mock.counter()).to.equal(0n);
await this.mock.chainReinitialize(2, 3);
expect(await this.mock.counter()).to.equal(2n);
});
it('_getInitializedVersion returns right version', async function () {
await this.mock.initialize();
expect(await this.mock.getInitializedVersion()).to.equal(1n);
await this.mock.reinitialize(12);
expect(await this.mock.getInitializedVersion()).to.equal(12n);
});
describe('contract locking', function () {
it('prevents initialization', async function () {
await this.mock.disableInitializers();
await expect(this.mock.initialize()).to.be.revertedWithCustomError(this.mock, 'InvalidInitialization');
});
it('prevents re-initialization', async function () {
await this.mock.disableInitializers();
await expect(this.mock.reinitialize(255n)).to.be.revertedWithCustomError(this.mock, 'InvalidInitialization');
});
it('can lock contract after initialization', async function () {
await this.mock.initialize();
await this.mock.disableInitializers();
await expect(this.mock.reinitialize(255n)).to.be.revertedWithCustomError(this.mock, 'InvalidInitialization');
});
});
});
describe('events', function () {
it('constructor initialization emits event', async function () {
const mock = await ethers.deployContract('ConstructorInitializableMock');
await expect(mock.deploymentTransaction()).to.emit(mock, 'Initialized').withArgs(1n);
});
it('initialization emits event', async function () {
const mock = await ethers.deployContract('ReinitializerMock');
await expect(mock.initialize()).to.emit(mock, 'Initialized').withArgs(1n);
});
it('reinitialization emits event', async function () {
const mock = await ethers.deployContract('ReinitializerMock');
await expect(mock.reinitialize(128)).to.emit(mock, 'Initialized').withArgs(128n);
});
it('chained reinitialization emits multiple events', async function () {
const mock = await ethers.deployContract('ReinitializerMock');
await expect(mock.chainReinitialize(2, 3))
.to.emit(mock, 'Initialized')
.withArgs(2n)
.to.emit(mock, 'Initialized')
.withArgs(3n);
});
});
describe('complex testing with inheritance', function () {
const mother = 12n;
const gramps = '56';
const father = 34n;
const child = 78n;
beforeEach('deploying', async function () {
this.mock = await ethers.deployContract('SampleChild');
await this.mock.initialize(mother, gramps, father, child);
});
it('initializes human', async function () {
expect(await this.mock.isHuman()).to.be.true;
});
it('initializes mother', async function () {
expect(await this.mock.mother()).to.equal(mother);
});
it('initializes gramps', async function () {
expect(await this.mock.gramps()).to.equal(gramps);
});
it('initializes father', async function () {
expect(await this.mock.father()).to.equal(father);
});
it('initializes child', async function () {
expect(await this.mock.child()).to.equal(child);
});
});
describe('disabling initialization', function () {
it('old and new patterns in bad sequence', async function () {
const DisableBad1 = await ethers.getContractFactory('DisableBad1');
await expect(DisableBad1.deploy()).to.be.revertedWithCustomError(DisableBad1, 'InvalidInitialization');
const DisableBad2 = await ethers.getContractFactory('DisableBad2');
await expect(DisableBad2.deploy()).to.be.revertedWithCustomError(DisableBad2, 'InvalidInitialization');
});
it('old and new patterns in good sequence', async function () {
const ok = await ethers.deployContract('DisableOk');
await expect(ok.deploymentTransaction())
.to.emit(ok, 'Initialized')
.withArgs(1n)
.to.emit(ok, 'Initialized')
.withArgs(MAX_UINT64);
});
});
});

View File

@@ -0,0 +1,120 @@
const { ethers } = require('hardhat');
const { expect } = require('chai');
const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers');
const { getAddressInSlot, ImplementationSlot } = require('../../helpers/storage');
async function fixture() {
const implInitial = await ethers.deployContract('UUPSUpgradeableMock');
const implUpgradeOk = await ethers.deployContract('UUPSUpgradeableMock');
const implUpgradeUnsafe = await ethers.deployContract('UUPSUpgradeableUnsafeMock');
const implUpgradeNonUUPS = await ethers.deployContract('NonUpgradeableMock');
const implUnsupportedUUID = await ethers.deployContract('UUPSUnsupportedProxiableUUID');
// Used for testing non ERC1967 compliant proxies (clones are proxies that don't use the ERC1967 implementation slot)
const cloneFactory = await ethers.deployContract('$Clones');
const instance = await ethers
.deployContract('ERC1967Proxy', [implInitial, '0x'])
.then(proxy => implInitial.attach(proxy.target));
return {
implInitial,
implUpgradeOk,
implUpgradeUnsafe,
implUpgradeNonUUPS,
implUnsupportedUUID,
cloneFactory,
instance,
};
}
describe('UUPSUpgradeable', function () {
beforeEach(async function () {
Object.assign(this, await loadFixture(fixture));
});
it('has an interface version', async function () {
expect(await this.instance.UPGRADE_INTERFACE_VERSION()).to.equal('5.0.0');
});
it('upgrade to upgradeable implementation', async function () {
await expect(this.instance.upgradeToAndCall(this.implUpgradeOk, '0x'))
.to.emit(this.instance, 'Upgraded')
.withArgs(this.implUpgradeOk);
expect(await getAddressInSlot(this.instance, ImplementationSlot)).to.equal(this.implUpgradeOk);
});
it('upgrade to upgradeable implementation with call', async function () {
expect(await this.instance.current()).to.equal(0n);
await expect(
this.instance.upgradeToAndCall(this.implUpgradeOk, this.implUpgradeOk.interface.encodeFunctionData('increment')),
)
.to.emit(this.instance, 'Upgraded')
.withArgs(this.implUpgradeOk);
expect(await getAddressInSlot(this.instance, ImplementationSlot)).to.equal(this.implUpgradeOk);
expect(await this.instance.current()).to.equal(1n);
});
it('calling upgradeTo on the implementation reverts', async function () {
await expect(this.implInitial.upgradeToAndCall(this.implUpgradeOk, '0x')).to.be.revertedWithCustomError(
this.implInitial,
'UUPSUnauthorizedCallContext',
);
});
it('calling upgradeToAndCall on the implementation reverts', async function () {
await expect(
this.implInitial.upgradeToAndCall(
this.implUpgradeOk,
this.implUpgradeOk.interface.encodeFunctionData('increment'),
),
).to.be.revertedWithCustomError(this.implUpgradeOk, 'UUPSUnauthorizedCallContext');
});
it('calling upgradeToAndCall from a contract that is not an ERC1967 proxy (with the right implementation) reverts', async function () {
const instance = await this.cloneFactory.$clone
.staticCall(this.implUpgradeOk)
.then(address => this.implInitial.attach(address));
await this.cloneFactory.$clone(this.implUpgradeOk);
await expect(instance.upgradeToAndCall(this.implUpgradeUnsafe, '0x')).to.be.revertedWithCustomError(
instance,
'UUPSUnauthorizedCallContext',
);
});
it('rejects upgrading to an unsupported UUID', async function () {
await expect(this.instance.upgradeToAndCall(this.implUnsupportedUUID, '0x'))
.to.be.revertedWithCustomError(this.instance, 'UUPSUnsupportedProxiableUUID')
.withArgs(ethers.id('invalid UUID'));
});
it('upgrade to and unsafe upgradeable implementation', async function () {
await expect(this.instance.upgradeToAndCall(this.implUpgradeUnsafe, '0x'))
.to.emit(this.instance, 'Upgraded')
.withArgs(this.implUpgradeUnsafe);
expect(await getAddressInSlot(this.instance, ImplementationSlot)).to.equal(this.implUpgradeUnsafe);
});
// delegate to a non existing upgradeTo function causes a low level revert
it('reject upgrade to non uups implementation', async function () {
await expect(this.instance.upgradeToAndCall(this.implUpgradeNonUUPS, '0x'))
.to.be.revertedWithCustomError(this.instance, 'ERC1967InvalidImplementation')
.withArgs(this.implUpgradeNonUUPS);
});
it('reject proxy address as implementation', async function () {
const otherInstance = await ethers
.deployContract('ERC1967Proxy', [this.implInitial, '0x'])
.then(proxy => this.implInitial.attach(proxy.target));
await expect(this.instance.upgradeToAndCall(otherInstance, '0x'))
.to.be.revertedWithCustomError(this.instance, 'ERC1967InvalidImplementation')
.withArgs(otherInstance);
});
});

View File

@@ -0,0 +1,27 @@
const { ethers } = require('hardhat');
const { expect } = require('chai');
const { loadFixture, mine } = require('@nomicfoundation/hardhat-network-helpers');
async function fixture() {
return {};
}
describe('Environment sanity', function () {
beforeEach(async function () {
Object.assign(this, await loadFixture(fixture));
});
describe('snapshot', function () {
let blockNumberBefore;
it('cache and mine', async function () {
blockNumberBefore = await ethers.provider.getBlockNumber();
await mine();
expect(await ethers.provider.getBlockNumber()).to.equal(blockNumberBefore + 1);
});
it('check snapshot', async function () {
expect(await ethers.provider.getBlockNumber()).to.equal(blockNumberBefore);
});
});
});

View File

@@ -0,0 +1,763 @@
const { ethers } = require('hardhat');
const { expect } = require('chai');
const { anyValue } = require('@nomicfoundation/hardhat-chai-matchers/withArgs');
const { RevertType } = require('../../helpers/enums');
const { shouldSupportInterfaces } = require('../../utils/introspection/SupportsInterface.behavior');
function shouldBehaveLikeERC1155() {
const firstTokenId = 1n;
const secondTokenId = 2n;
const unknownTokenId = 3n;
const firstTokenValue = 1000n;
const secondTokenValue = 2000n;
const RECEIVER_SINGLE_MAGIC_VALUE = '0xf23a6e61';
const RECEIVER_BATCH_MAGIC_VALUE = '0xbc197c81';
beforeEach(async function () {
[this.recipient, this.proxy, this.alice, this.bruce] = this.otherAccounts;
});
describe('like an ERC1155', function () {
describe('balanceOf', function () {
it('should return 0 when queried about the zero address', async function () {
expect(await this.token.balanceOf(ethers.ZeroAddress, firstTokenId)).to.equal(0n);
});
describe("when accounts don't own tokens", function () {
it('returns zero for given addresses', async function () {
expect(await this.token.balanceOf(this.alice, firstTokenId)).to.equal(0n);
expect(await this.token.balanceOf(this.bruce, secondTokenId)).to.equal(0n);
expect(await this.token.balanceOf(this.alice, unknownTokenId)).to.equal(0n);
});
});
describe('when accounts own some tokens', function () {
beforeEach(async function () {
await this.token.$_mint(this.alice, firstTokenId, firstTokenValue, '0x');
await this.token.$_mint(this.bruce, secondTokenId, secondTokenValue, '0x');
});
it('returns the amount of tokens owned by the given addresses', async function () {
expect(await this.token.balanceOf(this.alice, firstTokenId)).to.equal(firstTokenValue);
expect(await this.token.balanceOf(this.bruce, secondTokenId)).to.equal(secondTokenValue);
expect(await this.token.balanceOf(this.alice, unknownTokenId)).to.equal(0n);
});
});
});
describe('balanceOfBatch', function () {
it("reverts when input arrays don't match up", async function () {
const accounts1 = [this.alice, this.bruce, this.alice, this.bruce];
const ids1 = [firstTokenId, secondTokenId, unknownTokenId];
await expect(this.token.balanceOfBatch(accounts1, ids1))
.to.be.revertedWithCustomError(this.token, 'ERC1155InvalidArrayLength')
.withArgs(ids1.length, accounts1.length);
const accounts2 = [this.alice, this.bruce];
const ids2 = [firstTokenId, secondTokenId, unknownTokenId];
await expect(this.token.balanceOfBatch(accounts2, ids2))
.to.be.revertedWithCustomError(this.token, 'ERC1155InvalidArrayLength')
.withArgs(ids2.length, accounts2.length);
});
it('should return 0 as the balance when one of the addresses is the zero address', async function () {
const result = await this.token.balanceOfBatch(
[this.alice, this.bruce, ethers.ZeroAddress],
[firstTokenId, secondTokenId, unknownTokenId],
);
expect(result).to.deep.equal([0n, 0n, 0n]);
});
describe("when accounts don't own tokens", function () {
it('returns zeros for each account', async function () {
const result = await this.token.balanceOfBatch(
[this.alice, this.bruce, this.alice],
[firstTokenId, secondTokenId, unknownTokenId],
);
expect(result).to.deep.equal([0n, 0n, 0n]);
});
});
describe('when accounts own some tokens', function () {
beforeEach(async function () {
await this.token.$_mint(this.alice, firstTokenId, firstTokenValue, '0x');
await this.token.$_mint(this.bruce, secondTokenId, secondTokenValue, '0x');
});
it('returns amounts owned by each account in order passed', async function () {
const result = await this.token.balanceOfBatch(
[this.bruce, this.alice, this.alice],
[secondTokenId, firstTokenId, unknownTokenId],
);
expect(result).to.deep.equal([secondTokenValue, firstTokenValue, 0n]);
});
it('returns multiple times the balance of the same address when asked', async function () {
const result = await this.token.balanceOfBatch(
[this.alice, this.bruce, this.alice],
[firstTokenId, secondTokenId, firstTokenId],
);
expect(result).to.deep.equal([firstTokenValue, secondTokenValue, firstTokenValue]);
});
});
});
describe('setApprovalForAll', function () {
beforeEach(async function () {
this.tx = await this.token.connect(this.holder).setApprovalForAll(this.proxy, true);
});
it('sets approval status which can be queried via isApprovedForAll', async function () {
expect(await this.token.isApprovedForAll(this.holder, this.proxy)).to.be.true;
});
it('emits an ApprovalForAll log', async function () {
await expect(this.tx).to.emit(this.token, 'ApprovalForAll').withArgs(this.holder, this.proxy, true);
});
it('can unset approval for an operator', async function () {
await this.token.connect(this.holder).setApprovalForAll(this.proxy, false);
expect(await this.token.isApprovedForAll(this.holder, this.proxy)).to.be.false;
});
it('reverts if attempting to approve zero address as an operator', async function () {
await expect(this.token.connect(this.holder).setApprovalForAll(ethers.ZeroAddress, true))
.to.be.revertedWithCustomError(this.token, 'ERC1155InvalidOperator')
.withArgs(ethers.ZeroAddress);
});
});
describe('safeTransferFrom', function () {
beforeEach(async function () {
await this.token.$_mint(this.holder, firstTokenId, firstTokenValue, '0x');
await this.token.$_mint(this.holder, secondTokenId, secondTokenValue, '0x');
});
it('reverts when transferring more than balance', async function () {
await expect(
this.token
.connect(this.holder)
.safeTransferFrom(this.holder, this.recipient, firstTokenId, firstTokenValue + 1n, '0x'),
)
.to.be.revertedWithCustomError(this.token, 'ERC1155InsufficientBalance')
.withArgs(this.holder, firstTokenValue, firstTokenValue + 1n, firstTokenId);
});
it('reverts when transferring to zero address', async function () {
await expect(
this.token
.connect(this.holder)
.safeTransferFrom(this.holder, ethers.ZeroAddress, firstTokenId, firstTokenValue, '0x'),
)
.to.be.revertedWithCustomError(this.token, 'ERC1155InvalidReceiver')
.withArgs(ethers.ZeroAddress);
});
function transferWasSuccessful() {
it('debits transferred balance from sender', async function () {
expect(await this.token.balanceOf(this.args.from, this.args.id)).to.equal(0n);
});
it('credits transferred balance to receiver', async function () {
expect(await this.token.balanceOf(this.args.to, this.args.id)).to.equal(this.args.value);
});
it('emits a TransferSingle log', async function () {
await expect(this.tx)
.to.emit(this.token, 'TransferSingle')
.withArgs(this.args.operator, this.args.from, this.args.to, this.args.id, this.args.value);
});
}
describe('when called by the holder', function () {
beforeEach(async function () {
this.args = {
operator: this.holder,
from: this.holder,
to: this.recipient,
id: firstTokenId,
value: firstTokenValue,
data: '0x',
};
this.tx = await this.token
.connect(this.args.operator)
.safeTransferFrom(this.args.from, this.args.to, this.args.id, this.args.value, this.args.data);
});
transferWasSuccessful();
it('preserves existing balances which are not transferred by holder', async function () {
expect(await this.token.balanceOf(this.holder, secondTokenId)).to.equal(secondTokenValue);
expect(await this.token.balanceOf(this.recipient, secondTokenId)).to.equal(0n);
});
});
describe('when called by an operator on behalf of the holder', function () {
describe('when operator is not approved by holder', function () {
beforeEach(async function () {
await this.token.connect(this.holder).setApprovalForAll(this.proxy, false);
});
it('reverts', async function () {
await expect(
this.token
.connect(this.proxy)
.safeTransferFrom(this.holder, this.recipient, firstTokenId, firstTokenValue, '0x'),
)
.to.be.revertedWithCustomError(this.token, 'ERC1155MissingApprovalForAll')
.withArgs(this.proxy, this.holder);
});
});
describe('when operator is approved by holder', function () {
beforeEach(async function () {
await this.token.connect(this.holder).setApprovalForAll(this.proxy, true);
this.args = {
operator: this.proxy,
from: this.holder,
to: this.recipient,
id: firstTokenId,
value: firstTokenValue,
data: '0x',
};
this.tx = await this.token
.connect(this.args.operator)
.safeTransferFrom(this.args.from, this.args.to, this.args.id, this.args.value, this.args.data);
});
transferWasSuccessful();
it("preserves operator's balances not involved in the transfer", async function () {
expect(await this.token.balanceOf(this.proxy, firstTokenId)).to.equal(0n);
expect(await this.token.balanceOf(this.proxy, secondTokenId)).to.equal(0n);
});
});
});
describe('when sending to a valid receiver', function () {
beforeEach(async function () {
this.receiver = await ethers.deployContract('$ERC1155ReceiverMock', [
RECEIVER_SINGLE_MAGIC_VALUE,
RECEIVER_BATCH_MAGIC_VALUE,
RevertType.None,
]);
});
describe('without data', function () {
beforeEach(async function () {
this.args = {
operator: this.holder,
from: this.holder,
to: this.receiver,
id: firstTokenId,
value: firstTokenValue,
data: '0x',
};
this.tx = await this.token
.connect(this.args.operator)
.safeTransferFrom(this.args.from, this.args.to, this.args.id, this.args.value, this.args.data);
});
transferWasSuccessful();
it('calls onERC1155Received', async function () {
await expect(this.tx)
.to.emit(this.receiver, 'Received')
.withArgs(this.args.operator, this.args.from, this.args.id, this.args.value, this.args.data, anyValue);
});
});
describe('with data', function () {
beforeEach(async function () {
this.args = {
operator: this.holder,
from: this.holder,
to: this.receiver,
id: firstTokenId,
value: firstTokenValue,
data: '0xf00dd00d',
};
this.tx = await this.token
.connect(this.args.operator)
.safeTransferFrom(this.args.from, this.args.to, this.args.id, this.args.value, this.args.data);
});
transferWasSuccessful();
it('calls onERC1155Received', async function () {
await expect(this.tx)
.to.emit(this.receiver, 'Received')
.withArgs(this.args.operator, this.args.from, this.args.id, this.args.value, this.args.data, anyValue);
});
});
});
describe('to a receiver contract returning unexpected value', function () {
it('reverts', async function () {
const receiver = await ethers.deployContract('$ERC1155ReceiverMock', [
'0x00c0ffee',
RECEIVER_BATCH_MAGIC_VALUE,
RevertType.None,
]);
await expect(
this.token
.connect(this.holder)
.safeTransferFrom(this.holder, receiver, firstTokenId, firstTokenValue, '0x'),
)
.to.be.revertedWithCustomError(this.token, 'ERC1155InvalidReceiver')
.withArgs(receiver);
});
});
describe('to a receiver contract that reverts', function () {
describe('with a revert string', function () {
it('reverts', async function () {
const receiver = await ethers.deployContract('$ERC1155ReceiverMock', [
RECEIVER_SINGLE_MAGIC_VALUE,
RECEIVER_BATCH_MAGIC_VALUE,
RevertType.RevertWithMessage,
]);
await expect(
this.token
.connect(this.holder)
.safeTransferFrom(this.holder, receiver, firstTokenId, firstTokenValue, '0x'),
).to.be.revertedWith('ERC1155ReceiverMock: reverting on receive');
});
});
describe('without a revert string', function () {
it('reverts', async function () {
const receiver = await ethers.deployContract('$ERC1155ReceiverMock', [
RECEIVER_SINGLE_MAGIC_VALUE,
RECEIVER_BATCH_MAGIC_VALUE,
RevertType.RevertWithoutMessage,
]);
await expect(
this.token
.connect(this.holder)
.safeTransferFrom(this.holder, receiver, firstTokenId, firstTokenValue, '0x'),
)
.to.be.revertedWithCustomError(this.token, 'ERC1155InvalidReceiver')
.withArgs(receiver);
});
});
describe('with a custom error', function () {
it('reverts', async function () {
const receiver = await ethers.deployContract('$ERC1155ReceiverMock', [
RECEIVER_SINGLE_MAGIC_VALUE,
RECEIVER_BATCH_MAGIC_VALUE,
RevertType.RevertWithCustomError,
]);
await expect(
this.token
.connect(this.holder)
.safeTransferFrom(this.holder, receiver, firstTokenId, firstTokenValue, '0x'),
)
.to.be.revertedWithCustomError(receiver, 'CustomError')
.withArgs(RECEIVER_SINGLE_MAGIC_VALUE);
});
});
describe('with a panic', function () {
it('reverts', async function () {
const receiver = await ethers.deployContract('$ERC1155ReceiverMock', [
RECEIVER_SINGLE_MAGIC_VALUE,
RECEIVER_BATCH_MAGIC_VALUE,
RevertType.Panic,
]);
await expect(
this.token
.connect(this.holder)
.safeTransferFrom(this.holder, receiver, firstTokenId, firstTokenValue, '0x'),
).to.be.revertedWithPanic();
});
});
});
describe('to a contract that does not implement the required function', function () {
it('reverts', async function () {
const invalidReceiver = this.token;
await expect(
this.token
.connect(this.holder)
.safeTransferFrom(this.holder, invalidReceiver, firstTokenId, firstTokenValue, '0x'),
)
.to.be.revertedWithCustomError(this.token, 'ERC1155InvalidReceiver')
.withArgs(invalidReceiver);
});
});
});
describe('safeBatchTransferFrom', function () {
beforeEach(async function () {
await this.token.$_mint(this.holder, firstTokenId, firstTokenValue, '0x');
await this.token.$_mint(this.holder, secondTokenId, secondTokenValue, '0x');
});
it('reverts when transferring value more than any of balances', async function () {
await expect(
this.token
.connect(this.holder)
.safeBatchTransferFrom(
this.holder,
this.recipient,
[firstTokenId, secondTokenId],
[firstTokenValue, secondTokenValue + 1n],
'0x',
),
)
.to.be.revertedWithCustomError(this.token, 'ERC1155InsufficientBalance')
.withArgs(this.holder, secondTokenValue, secondTokenValue + 1n, secondTokenId);
});
it("reverts when ids array length doesn't match values array length", async function () {
const ids1 = [firstTokenId];
const tokenValues1 = [firstTokenValue, secondTokenValue];
await expect(
this.token.connect(this.holder).safeBatchTransferFrom(this.holder, this.recipient, ids1, tokenValues1, '0x'),
)
.to.be.revertedWithCustomError(this.token, 'ERC1155InvalidArrayLength')
.withArgs(ids1.length, tokenValues1.length);
const ids2 = [firstTokenId, secondTokenId];
const tokenValues2 = [firstTokenValue];
await expect(
this.token.connect(this.holder).safeBatchTransferFrom(this.holder, this.recipient, ids2, tokenValues2, '0x'),
)
.to.be.revertedWithCustomError(this.token, 'ERC1155InvalidArrayLength')
.withArgs(ids2.length, tokenValues2.length);
});
it('reverts when transferring to zero address', async function () {
await expect(
this.token
.connect(this.holder)
.safeBatchTransferFrom(
this.holder,
ethers.ZeroAddress,
[firstTokenId, secondTokenId],
[firstTokenValue, secondTokenValue],
'0x',
),
)
.to.be.revertedWithCustomError(this.token, 'ERC1155InvalidReceiver')
.withArgs(ethers.ZeroAddress);
});
it('reverts when transferring from zero address', async function () {
await expect(
this.token.$_safeBatchTransferFrom(ethers.ZeroAddress, this.holder, [firstTokenId], [firstTokenValue], '0x'),
)
.to.be.revertedWithCustomError(this.token, 'ERC1155InvalidSender')
.withArgs(ethers.ZeroAddress);
});
function batchTransferWasSuccessful() {
it('debits transferred balances from sender', async function () {
const newBalances = await this.token.balanceOfBatch(
this.args.ids.map(() => this.args.from),
this.args.ids,
);
expect(newBalances).to.deep.equal(this.args.ids.map(() => 0n));
});
it('credits transferred balances to receiver', async function () {
const newBalances = await this.token.balanceOfBatch(
this.args.ids.map(() => this.args.to),
this.args.ids,
);
expect(newBalances).to.deep.equal(this.args.values);
});
it('emits a TransferBatch log', async function () {
await expect(this.tx)
.to.emit(this.token, 'TransferBatch')
.withArgs(this.args.operator, this.args.from, this.args.to, this.args.ids, this.args.values);
});
}
describe('when called by the holder', function () {
beforeEach(async function () {
this.args = {
operator: this.holder,
from: this.holder,
to: this.recipient,
ids: [firstTokenId, secondTokenId],
values: [firstTokenValue, secondTokenValue],
data: '0x',
};
this.tx = await this.token
.connect(this.args.operator)
.safeBatchTransferFrom(this.args.from, this.args.to, this.args.ids, this.args.values, this.args.data);
});
batchTransferWasSuccessful();
});
describe('when called by an operator on behalf of the holder', function () {
describe('when operator is not approved by holder', function () {
beforeEach(async function () {
await this.token.connect(this.holder).setApprovalForAll(this.proxy, false);
});
it('reverts', async function () {
await expect(
this.token
.connect(this.proxy)
.safeBatchTransferFrom(
this.holder,
this.recipient,
[firstTokenId, secondTokenId],
[firstTokenValue, secondTokenValue],
'0x',
),
)
.to.be.revertedWithCustomError(this.token, 'ERC1155MissingApprovalForAll')
.withArgs(this.proxy, this.holder);
});
});
describe('when operator is approved by holder', function () {
beforeEach(async function () {
await this.token.connect(this.holder).setApprovalForAll(this.proxy, true);
this.args = {
operator: this.proxy,
from: this.holder,
to: this.recipient,
ids: [firstTokenId, secondTokenId],
values: [firstTokenValue, secondTokenValue],
data: '0x',
};
this.tx = await this.token
.connect(this.args.operator)
.safeBatchTransferFrom(this.args.from, this.args.to, this.args.ids, this.args.values, this.args.data);
});
batchTransferWasSuccessful();
it("preserves operator's balances not involved in the transfer", async function () {
expect(await this.token.balanceOf(this.proxy, firstTokenId)).to.equal(0n);
expect(await this.token.balanceOf(this.proxy, secondTokenId)).to.equal(0n);
});
});
});
describe('when sending to a valid receiver', function () {
beforeEach(async function () {
this.receiver = await ethers.deployContract('$ERC1155ReceiverMock', [
RECEIVER_SINGLE_MAGIC_VALUE,
RECEIVER_BATCH_MAGIC_VALUE,
RevertType.None,
]);
});
describe('without data', function () {
beforeEach(async function () {
this.args = {
operator: this.holder,
from: this.holder,
to: this.receiver,
ids: [firstTokenId, secondTokenId],
values: [firstTokenValue, secondTokenValue],
data: '0x',
};
this.tx = await this.token
.connect(this.args.operator)
.safeBatchTransferFrom(this.args.from, this.args.to, this.args.ids, this.args.values, this.args.data);
});
batchTransferWasSuccessful();
it('calls onERC1155BatchReceived', async function () {
await expect(this.tx)
.to.emit(this.receiver, 'BatchReceived')
.withArgs(this.holder, this.holder, this.args.ids, this.args.values, this.args.data, anyValue);
});
});
describe('with data', function () {
beforeEach(async function () {
this.args = {
operator: this.holder,
from: this.holder,
to: this.receiver,
ids: [firstTokenId, secondTokenId],
values: [firstTokenValue, secondTokenValue],
data: '0xf00dd00d',
};
this.tx = await this.token
.connect(this.args.operator)
.safeBatchTransferFrom(this.args.from, this.args.to, this.args.ids, this.args.values, this.args.data);
});
batchTransferWasSuccessful();
it('calls onERC1155Received', async function () {
await expect(this.tx)
.to.emit(this.receiver, 'BatchReceived')
.withArgs(this.holder, this.holder, this.args.ids, this.args.values, this.args.data, anyValue);
});
});
});
describe('to a receiver contract returning unexpected value', function () {
it('reverts', async function () {
const receiver = await ethers.deployContract('$ERC1155ReceiverMock', [
RECEIVER_SINGLE_MAGIC_VALUE,
RECEIVER_SINGLE_MAGIC_VALUE,
RevertType.None,
]);
await expect(
this.token
.connect(this.holder)
.safeBatchTransferFrom(
this.holder,
receiver,
[firstTokenId, secondTokenId],
[firstTokenValue, secondTokenValue],
'0x',
),
)
.to.be.revertedWithCustomError(this.token, 'ERC1155InvalidReceiver')
.withArgs(receiver);
});
});
describe('to a receiver contract that reverts', function () {
describe('with a revert string', function () {
it('reverts', async function () {
const receiver = await ethers.deployContract('$ERC1155ReceiverMock', [
RECEIVER_SINGLE_MAGIC_VALUE,
RECEIVER_BATCH_MAGIC_VALUE,
RevertType.RevertWithMessage,
]);
await expect(
this.token
.connect(this.holder)
.safeBatchTransferFrom(
this.holder,
receiver,
[firstTokenId, secondTokenId],
[firstTokenValue, secondTokenValue],
'0x',
),
).to.be.revertedWith('ERC1155ReceiverMock: reverting on batch receive');
});
});
describe('without a revert string', function () {
it('reverts', async function () {
const receiver = await ethers.deployContract('$ERC1155ReceiverMock', [
RECEIVER_SINGLE_MAGIC_VALUE,
RECEIVER_BATCH_MAGIC_VALUE,
RevertType.RevertWithoutMessage,
]);
await expect(
this.token
.connect(this.holder)
.safeBatchTransferFrom(
this.holder,
receiver,
[firstTokenId, secondTokenId],
[firstTokenValue, secondTokenValue],
'0x',
),
)
.to.be.revertedWithCustomError(this.token, 'ERC1155InvalidReceiver')
.withArgs(receiver);
});
});
describe('with a custom error', function () {
it('reverts', async function () {
const receiver = await ethers.deployContract('$ERC1155ReceiverMock', [
RECEIVER_SINGLE_MAGIC_VALUE,
RECEIVER_BATCH_MAGIC_VALUE,
RevertType.RevertWithCustomError,
]);
await expect(
this.token
.connect(this.holder)
.safeBatchTransferFrom(
this.holder,
receiver,
[firstTokenId, secondTokenId],
[firstTokenValue, secondTokenValue],
'0x',
),
)
.to.be.revertedWithCustomError(receiver, 'CustomError')
.withArgs(RECEIVER_SINGLE_MAGIC_VALUE);
});
});
describe('with a panic', function () {
it('reverts', async function () {
const receiver = await ethers.deployContract('$ERC1155ReceiverMock', [
RECEIVER_SINGLE_MAGIC_VALUE,
RECEIVER_BATCH_MAGIC_VALUE,
RevertType.Panic,
]);
await expect(
this.token
.connect(this.holder)
.safeBatchTransferFrom(
this.holder,
receiver,
[firstTokenId, secondTokenId],
[firstTokenValue, secondTokenValue],
'0x',
),
).to.be.revertedWithPanic();
});
});
});
describe('to a contract that does not implement the required function', function () {
it('reverts', async function () {
const invalidReceiver = this.token;
await expect(
this.token
.connect(this.holder)
.safeBatchTransferFrom(
this.holder,
invalidReceiver,
[firstTokenId, secondTokenId],
[firstTokenValue, secondTokenValue],
'0x',
),
)
.to.be.revertedWithCustomError(this.token, 'ERC1155InvalidReceiver')
.withArgs(invalidReceiver);
});
});
});
shouldSupportInterfaces(['ERC1155', 'ERC1155MetadataURI']);
});
}
module.exports = {
shouldBehaveLikeERC1155,
};

View File

@@ -0,0 +1,213 @@
const { ethers } = require('hardhat');
const { expect } = require('chai');
const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers');
const { zip } = require('../../helpers/iterate');
const { shouldBehaveLikeERC1155 } = require('./ERC1155.behavior');
const initialURI = 'https://token-cdn-domain/{id}.json';
async function fixture() {
const [operator, holder, ...otherAccounts] = await ethers.getSigners();
const token = await ethers.deployContract('$ERC1155', [initialURI]);
return { token, operator, holder, otherAccounts };
}
describe('ERC1155', function () {
beforeEach(async function () {
Object.assign(this, await loadFixture(fixture));
});
shouldBehaveLikeERC1155();
describe('internal functions', function () {
const tokenId = 1990n;
const mintValue = 9001n;
const burnValue = 3000n;
const tokenBatchIds = [2000n, 2010n, 2020n];
const mintValues = [5000n, 10000n, 42195n];
const burnValues = [5000n, 9001n, 195n];
const data = '0x12345678';
describe('_mint', function () {
it('reverts with a zero destination address', async function () {
await expect(this.token.$_mint(ethers.ZeroAddress, tokenId, mintValue, data))
.to.be.revertedWithCustomError(this.token, 'ERC1155InvalidReceiver')
.withArgs(ethers.ZeroAddress);
});
describe('with minted tokens', function () {
beforeEach(async function () {
this.tx = await this.token.connect(this.operator).$_mint(this.holder, tokenId, mintValue, data);
});
it('emits a TransferSingle event', async function () {
await expect(this.tx)
.to.emit(this.token, 'TransferSingle')
.withArgs(this.operator, ethers.ZeroAddress, this.holder, tokenId, mintValue);
});
it('credits the minted token value', async function () {
expect(await this.token.balanceOf(this.holder, tokenId)).to.equal(mintValue);
});
});
});
describe('_mintBatch', function () {
it('reverts with a zero destination address', async function () {
await expect(this.token.$_mintBatch(ethers.ZeroAddress, tokenBatchIds, mintValues, data))
.to.be.revertedWithCustomError(this.token, 'ERC1155InvalidReceiver')
.withArgs(ethers.ZeroAddress);
});
it('reverts if length of inputs do not match', async function () {
await expect(this.token.$_mintBatch(this.holder, tokenBatchIds, mintValues.slice(1), data))
.to.be.revertedWithCustomError(this.token, 'ERC1155InvalidArrayLength')
.withArgs(tokenBatchIds.length, mintValues.length - 1);
await expect(this.token.$_mintBatch(this.holder, tokenBatchIds.slice(1), mintValues, data))
.to.be.revertedWithCustomError(this.token, 'ERC1155InvalidArrayLength')
.withArgs(tokenBatchIds.length - 1, mintValues.length);
});
describe('with minted batch of tokens', function () {
beforeEach(async function () {
this.tx = await this.token.connect(this.operator).$_mintBatch(this.holder, tokenBatchIds, mintValues, data);
});
it('emits a TransferBatch event', async function () {
await expect(this.tx)
.to.emit(this.token, 'TransferBatch')
.withArgs(this.operator, ethers.ZeroAddress, this.holder, tokenBatchIds, mintValues);
});
it('credits the minted batch of tokens', async function () {
const holderBatchBalances = await this.token.balanceOfBatch(
tokenBatchIds.map(() => this.holder),
tokenBatchIds,
);
expect(holderBatchBalances).to.deep.equal(mintValues);
});
});
});
describe('_burn', function () {
it("reverts when burning the zero account's tokens", async function () {
await expect(this.token.$_burn(ethers.ZeroAddress, tokenId, mintValue))
.to.be.revertedWithCustomError(this.token, 'ERC1155InvalidSender')
.withArgs(ethers.ZeroAddress);
});
it('reverts when burning a non-existent token id', async function () {
await expect(this.token.$_burn(this.holder, tokenId, mintValue))
.to.be.revertedWithCustomError(this.token, 'ERC1155InsufficientBalance')
.withArgs(this.holder, 0, mintValue, tokenId);
});
it('reverts when burning more than available tokens', async function () {
await this.token.connect(this.operator).$_mint(this.holder, tokenId, mintValue, data);
await expect(this.token.$_burn(this.holder, tokenId, mintValue + 1n))
.to.be.revertedWithCustomError(this.token, 'ERC1155InsufficientBalance')
.withArgs(this.holder, mintValue, mintValue + 1n, tokenId);
});
describe('with minted-then-burnt tokens', function () {
beforeEach(async function () {
await this.token.$_mint(this.holder, tokenId, mintValue, data);
this.tx = await this.token.connect(this.operator).$_burn(this.holder, tokenId, burnValue);
});
it('emits a TransferSingle event', async function () {
await expect(this.tx)
.to.emit(this.token, 'TransferSingle')
.withArgs(this.operator, this.holder, ethers.ZeroAddress, tokenId, burnValue);
});
it('accounts for both minting and burning', async function () {
expect(await this.token.balanceOf(this.holder, tokenId)).to.equal(mintValue - burnValue);
});
});
});
describe('_burnBatch', function () {
it("reverts when burning the zero account's tokens", async function () {
await expect(this.token.$_burnBatch(ethers.ZeroAddress, tokenBatchIds, burnValues))
.to.be.revertedWithCustomError(this.token, 'ERC1155InvalidSender')
.withArgs(ethers.ZeroAddress);
});
it('reverts if length of inputs do not match', async function () {
await expect(this.token.$_burnBatch(this.holder, tokenBatchIds, burnValues.slice(1)))
.to.be.revertedWithCustomError(this.token, 'ERC1155InvalidArrayLength')
.withArgs(tokenBatchIds.length, burnValues.length - 1);
await expect(this.token.$_burnBatch(this.holder, tokenBatchIds.slice(1), burnValues))
.to.be.revertedWithCustomError(this.token, 'ERC1155InvalidArrayLength')
.withArgs(tokenBatchIds.length - 1, burnValues.length);
});
it('reverts when burning a non-existent token id', async function () {
await expect(this.token.$_burnBatch(this.holder, tokenBatchIds, burnValues))
.to.be.revertedWithCustomError(this.token, 'ERC1155InsufficientBalance')
.withArgs(this.holder, 0, burnValues[0], tokenBatchIds[0]);
});
describe('with minted-then-burnt tokens', function () {
beforeEach(async function () {
await this.token.$_mintBatch(this.holder, tokenBatchIds, mintValues, data);
this.tx = await this.token.connect(this.operator).$_burnBatch(this.holder, tokenBatchIds, burnValues);
});
it('emits a TransferBatch event', async function () {
await expect(this.tx)
.to.emit(this.token, 'TransferBatch')
.withArgs(this.operator, this.holder, ethers.ZeroAddress, tokenBatchIds, burnValues);
});
it('accounts for both minting and burning', async function () {
const holderBatchBalances = await this.token.balanceOfBatch(
tokenBatchIds.map(() => this.holder),
tokenBatchIds,
);
expect(holderBatchBalances).to.deep.equal(
zip(mintValues, burnValues).map(([mintValue, burnValue]) => mintValue - burnValue),
);
});
});
});
});
describe('ERC1155MetadataURI', function () {
const firstTokenID = 42n;
const secondTokenID = 1337n;
it('emits no URI event in constructor', async function () {
await expect(this.token.deploymentTransaction()).to.not.emit(this.token, 'URI');
});
it('sets the initial URI for all token types', async function () {
expect(await this.token.uri(firstTokenID)).to.equal(initialURI);
expect(await this.token.uri(secondTokenID)).to.equal(initialURI);
});
describe('_setURI', function () {
const newURI = 'https://token-cdn-domain/{locale}/{id}.json';
it('emits no URI event', async function () {
await expect(this.token.$_setURI(newURI)).to.not.emit(this.token, 'URI');
});
it('sets the new URI for all token types', async function () {
await this.token.$_setURI(newURI);
expect(await this.token.uri(firstTokenID)).to.equal(newURI);
expect(await this.token.uri(secondTokenID)).to.equal(newURI);
});
});
});
});

View File

@@ -0,0 +1,66 @@
const { ethers } = require('hardhat');
const { expect } = require('chai');
const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers');
const ids = [42n, 1137n];
const values = [3000n, 9902n];
async function fixture() {
const [holder, operator, other] = await ethers.getSigners();
const token = await ethers.deployContract('$ERC1155Burnable', ['https://token-cdn-domain/{id}.json']);
await token.$_mint(holder, ids[0], values[0], '0x');
await token.$_mint(holder, ids[1], values[1], '0x');
return { token, holder, operator, other };
}
describe('ERC1155Burnable', function () {
beforeEach(async function () {
Object.assign(this, await loadFixture(fixture));
});
describe('burn', function () {
it('holder can burn their tokens', async function () {
await this.token.connect(this.holder).burn(this.holder, ids[0], values[0] - 1n);
expect(await this.token.balanceOf(this.holder, ids[0])).to.equal(1n);
});
it("approved operators can burn the holder's tokens", async function () {
await this.token.connect(this.holder).setApprovalForAll(this.operator, true);
await this.token.connect(this.operator).burn(this.holder, ids[0], values[0] - 1n);
expect(await this.token.balanceOf(this.holder, ids[0])).to.equal(1n);
});
it("unapproved accounts cannot burn the holder's tokens", async function () {
await expect(this.token.connect(this.other).burn(this.holder, ids[0], values[0] - 1n))
.to.be.revertedWithCustomError(this.token, 'ERC1155MissingApprovalForAll')
.withArgs(this.other, this.holder);
});
});
describe('burnBatch', function () {
it('holder can burn their tokens', async function () {
await this.token.connect(this.holder).burnBatch(this.holder, ids, [values[0] - 1n, values[1] - 2n]);
expect(await this.token.balanceOf(this.holder, ids[0])).to.equal(1n);
expect(await this.token.balanceOf(this.holder, ids[1])).to.equal(2n);
});
it("approved operators can burn the holder's tokens", async function () {
await this.token.connect(this.holder).setApprovalForAll(this.operator, true);
await this.token.connect(this.operator).burnBatch(this.holder, ids, [values[0] - 1n, values[1] - 2n]);
expect(await this.token.balanceOf(this.holder, ids[0])).to.equal(1n);
expect(await this.token.balanceOf(this.holder, ids[1])).to.equal(2n);
});
it("unapproved accounts cannot burn the holder's tokens", async function () {
await expect(this.token.connect(this.other).burnBatch(this.holder, ids, [values[0] - 1n, values[1] - 2n]))
.to.be.revertedWithCustomError(this.token, 'ERC1155MissingApprovalForAll')
.withArgs(this.other, this.holder);
});
});
});

View File

@@ -0,0 +1,105 @@
const { ethers } = require('hardhat');
const { expect } = require('chai');
const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers');
async function fixture() {
const [holder, operator, receiver, other] = await ethers.getSigners();
const token = await ethers.deployContract('$ERC1155Pausable', ['https://token-cdn-domain/{id}.json']);
return { token, holder, operator, receiver, other };
}
describe('ERC1155Pausable', function () {
const firstTokenId = 37n;
const firstTokenValue = 42n;
const secondTokenId = 19842n;
const secondTokenValue = 23n;
beforeEach(async function () {
Object.assign(this, await loadFixture(fixture));
});
describe('when token is paused', function () {
beforeEach(async function () {
await this.token.connect(this.holder).setApprovalForAll(this.operator, true);
await this.token.$_mint(this.holder, firstTokenId, firstTokenValue, '0x');
await this.token.$_pause();
});
it('reverts when trying to safeTransferFrom from holder', async function () {
await expect(
this.token
.connect(this.holder)
.safeTransferFrom(this.holder, this.receiver, firstTokenId, firstTokenValue, '0x'),
).to.be.revertedWithCustomError(this.token, 'EnforcedPause');
});
it('reverts when trying to safeTransferFrom from operator', async function () {
await expect(
this.token
.connect(this.operator)
.safeTransferFrom(this.holder, this.receiver, firstTokenId, firstTokenValue, '0x'),
).to.be.revertedWithCustomError(this.token, 'EnforcedPause');
});
it('reverts when trying to safeBatchTransferFrom from holder', async function () {
await expect(
this.token
.connect(this.holder)
.safeBatchTransferFrom(this.holder, this.receiver, [firstTokenId], [firstTokenValue], '0x'),
).to.be.revertedWithCustomError(this.token, 'EnforcedPause');
});
it('reverts when trying to safeBatchTransferFrom from operator', async function () {
await expect(
this.token
.connect(this.operator)
.safeBatchTransferFrom(this.holder, this.receiver, [firstTokenId], [firstTokenValue], '0x'),
).to.be.revertedWithCustomError(this.token, 'EnforcedPause');
});
it('reverts when trying to mint', async function () {
await expect(this.token.$_mint(this.holder, secondTokenId, secondTokenValue, '0x')).to.be.revertedWithCustomError(
this.token,
'EnforcedPause',
);
});
it('reverts when trying to mintBatch', async function () {
await expect(
this.token.$_mintBatch(this.holder, [secondTokenId], [secondTokenValue], '0x'),
).to.be.revertedWithCustomError(this.token, 'EnforcedPause');
});
it('reverts when trying to burn', async function () {
await expect(this.token.$_burn(this.holder, firstTokenId, firstTokenValue)).to.be.revertedWithCustomError(
this.token,
'EnforcedPause',
);
});
it('reverts when trying to burnBatch', async function () {
await expect(
this.token.$_burnBatch(this.holder, [firstTokenId], [firstTokenValue]),
).to.be.revertedWithCustomError(this.token, 'EnforcedPause');
});
describe('setApprovalForAll', function () {
it('approves an operator', async function () {
await this.token.connect(this.holder).setApprovalForAll(this.other, true);
expect(await this.token.isApprovedForAll(this.holder, this.other)).to.be.true;
});
});
describe('balanceOf', function () {
it('returns the token value owned by the given address', async function () {
expect(await this.token.balanceOf(this.holder, firstTokenId)).to.equal(firstTokenValue);
});
});
describe('isApprovedForAll', function () {
it('returns the approval of the operator', async function () {
expect(await this.token.isApprovedForAll(this.holder, this.operator)).to.be.true;
});
});
});
});

View File

@@ -0,0 +1,119 @@
const { ethers } = require('hardhat');
const { expect } = require('chai');
const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers');
async function fixture() {
const [holder] = await ethers.getSigners();
const token = await ethers.deployContract('$ERC1155Supply', ['https://token-cdn-domain/{id}.json']);
return { token, holder };
}
describe('ERC1155Supply', function () {
const firstTokenId = 37n;
const firstTokenValue = 42n;
const secondTokenId = 19842n;
const secondTokenValue = 23n;
beforeEach(async function () {
Object.assign(this, await loadFixture(fixture));
});
describe('before mint', function () {
it('exist', async function () {
expect(await this.token.exists(firstTokenId)).to.be.false;
});
it('totalSupply', async function () {
expect(await this.token.totalSupply(ethers.Typed.uint256(firstTokenId))).to.equal(0n);
expect(await this.token.totalSupply()).to.equal(0n);
});
});
describe('after mint', function () {
describe('single', function () {
beforeEach(async function () {
await this.token.$_mint(this.holder, firstTokenId, firstTokenValue, '0x');
});
it('exist', async function () {
expect(await this.token.exists(firstTokenId)).to.be.true;
});
it('totalSupply', async function () {
expect(await this.token.totalSupply(ethers.Typed.uint256(firstTokenId))).to.equal(firstTokenValue);
expect(await this.token.totalSupply()).to.equal(firstTokenValue);
});
});
describe('batch', function () {
beforeEach(async function () {
await this.token.$_mintBatch(
this.holder,
[firstTokenId, secondTokenId],
[firstTokenValue, secondTokenValue],
'0x',
);
});
it('exist', async function () {
expect(await this.token.exists(firstTokenId)).to.be.true;
expect(await this.token.exists(secondTokenId)).to.be.true;
});
it('totalSupply', async function () {
expect(await this.token.totalSupply(ethers.Typed.uint256(firstTokenId))).to.equal(firstTokenValue);
expect(await this.token.totalSupply(ethers.Typed.uint256(secondTokenId))).to.equal(secondTokenValue);
expect(await this.token.totalSupply()).to.equal(firstTokenValue + secondTokenValue);
});
});
});
describe('after burn', function () {
describe('single', function () {
beforeEach(async function () {
await this.token.$_mint(this.holder, firstTokenId, firstTokenValue, '0x');
await this.token.$_burn(this.holder, firstTokenId, firstTokenValue);
});
it('exist', async function () {
expect(await this.token.exists(firstTokenId)).to.be.false;
});
it('totalSupply', async function () {
expect(await this.token.totalSupply(ethers.Typed.uint256(firstTokenId))).to.equal(0n);
expect(await this.token.totalSupply()).to.equal(0n);
});
});
describe('batch', function () {
beforeEach(async function () {
await this.token.$_mintBatch(
this.holder,
[firstTokenId, secondTokenId],
[firstTokenValue, secondTokenValue],
'0x',
);
await this.token.$_burnBatch(this.holder, [firstTokenId, secondTokenId], [firstTokenValue, secondTokenValue]);
});
it('exist', async function () {
expect(await this.token.exists(firstTokenId)).to.be.false;
expect(await this.token.exists(secondTokenId)).to.be.false;
});
it('totalSupply', async function () {
expect(await this.token.totalSupply(ethers.Typed.uint256(firstTokenId))).to.equal(0n);
expect(await this.token.totalSupply(ethers.Typed.uint256(secondTokenId))).to.equal(0n);
expect(await this.token.totalSupply()).to.equal(0n);
});
});
});
describe('other', function () {
it('supply unaffected by no-op', async function () {
await this.token.$_update(ethers.ZeroAddress, ethers.ZeroAddress, [firstTokenId], [firstTokenValue]);
expect(await this.token.totalSupply(ethers.Typed.uint256(firstTokenId))).to.equal(0n);
expect(await this.token.totalSupply()).to.equal(0n);
});
});
});

View File

@@ -0,0 +1,70 @@
const { ethers } = require('hardhat');
const { expect } = require('chai');
const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers');
const erc1155Uri = 'https://token.com/nfts/';
const baseUri = 'https://token.com/';
const tokenId = 1n;
const value = 3000n;
describe('ERC1155URIStorage', function () {
describe('with base uri set', function () {
async function fixture() {
const [holder] = await ethers.getSigners();
const token = await ethers.deployContract('$ERC1155URIStorage', [erc1155Uri]);
await token.$_setBaseURI(baseUri);
await token.$_mint(holder, tokenId, value, '0x');
return { token, holder };
}
beforeEach(async function () {
Object.assign(this, await loadFixture(fixture));
});
it('can request the token uri, returning the erc1155 uri if no token uri was set', async function () {
expect(await this.token.uri(tokenId)).to.equal(erc1155Uri);
});
it('can request the token uri, returning the concatenated uri if a token uri was set', async function () {
const tokenUri = '1234/';
const expectedUri = `${baseUri}${tokenUri}`;
await expect(this.token.$_setURI(ethers.Typed.uint256(tokenId), tokenUri))
.to.emit(this.token, 'URI')
.withArgs(expectedUri, tokenId);
expect(await this.token.uri(tokenId)).to.equal(expectedUri);
});
});
describe('with base uri set to the empty string', function () {
async function fixture() {
const [holder] = await ethers.getSigners();
const token = await ethers.deployContract('$ERC1155URIStorage', ['']);
await token.$_mint(holder, tokenId, value, '0x');
return { token, holder };
}
beforeEach(async function () {
Object.assign(this, await loadFixture(fixture));
});
it('can request the token uri, returning an empty string if no token uri was set', async function () {
expect(await this.token.uri(tokenId)).to.equal('');
});
it('can request the token uri, returning the token uri if a token uri was set', async function () {
const tokenUri = 'ipfs://1234/';
await expect(this.token.$_setURI(ethers.Typed.uint256(tokenId), tokenUri))
.to.emit(this.token, 'URI')
.withArgs(tokenUri, tokenId);
expect(await this.token.uri(tokenId)).to.equal(tokenUri);
});
});
});

View File

@@ -0,0 +1,56 @@
const { ethers } = require('hardhat');
const { expect } = require('chai');
const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers');
const { shouldSupportInterfaces } = require('../../../utils/introspection/SupportsInterface.behavior');
const ids = [1n, 2n, 3n];
const values = [1000n, 2000n, 3000n];
const data = '0x12345678';
async function fixture() {
const [owner] = await ethers.getSigners();
const token = await ethers.deployContract('$ERC1155', ['https://token-cdn-domain/{id}.json']);
const mock = await ethers.deployContract('$ERC1155Holder');
await token.$_mintBatch(owner, ids, values, '0x');
return { owner, token, mock };
}
describe('ERC1155Holder', function () {
beforeEach(async function () {
Object.assign(this, await loadFixture(fixture));
});
shouldSupportInterfaces(['ERC1155Receiver']);
it('receives ERC1155 tokens from a single ID', async function () {
await this.token.connect(this.owner).safeTransferFrom(this.owner, this.mock, ids[0], values[0], data);
expect(await this.token.balanceOf(this.mock, ids[0])).to.equal(values[0]);
for (let i = 1; i < ids.length; i++) {
expect(await this.token.balanceOf(this.mock, ids[i])).to.equal(0n);
}
});
it('receives ERC1155 tokens from a multiple IDs', async function () {
expect(
await this.token.balanceOfBatch(
ids.map(() => this.mock),
ids,
),
).to.deep.equal(ids.map(() => 0n));
await this.token.connect(this.owner).safeBatchTransferFrom(this.owner, this.mock, ids, values, data);
expect(
await this.token.balanceOfBatch(
ids.map(() => this.mock),
ids,
),
).to.deep.equal(values);
});
});

View File

@@ -0,0 +1,299 @@
const { ethers } = require('hardhat');
const { expect } = require('chai');
const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers');
const { RevertType } = require('../../../helpers/enums');
const { PANIC_CODES } = require('@nomicfoundation/hardhat-chai-matchers/panic');
const firstTokenId = 1n;
const secondTokenId = 2n;
const firstTokenValue = 1000n;
const secondTokenValue = 1000n;
const RECEIVER_SINGLE_MAGIC_VALUE = '0xf23a6e61';
const RECEIVER_BATCH_MAGIC_VALUE = '0xbc197c81';
const deployReceiver = (
revertType,
returnValueSingle = RECEIVER_SINGLE_MAGIC_VALUE,
returnValueBatched = RECEIVER_BATCH_MAGIC_VALUE,
) => ethers.deployContract('$ERC1155ReceiverMock', [returnValueSingle, returnValueBatched, revertType]);
const fixture = async () => {
const [eoa, operator, owner] = await ethers.getSigners();
const utils = await ethers.deployContract('$ERC1155Utils');
const receivers = {
correct: await deployReceiver(RevertType.None),
invalid: await deployReceiver(RevertType.None, '0xdeadbeef', '0xdeadbeef'),
message: await deployReceiver(RevertType.RevertWithMessage),
empty: await deployReceiver(RevertType.RevertWithoutMessage),
customError: await deployReceiver(RevertType.RevertWithCustomError),
panic: await deployReceiver(RevertType.Panic),
nonReceiver: await ethers.deployContract('CallReceiverMock'),
eoa,
};
return { operator, owner, utils, receivers };
};
describe('ERC1155Utils', function () {
beforeEach(async function () {
Object.assign(this, await loadFixture(fixture));
});
describe('onERC1155Received', function () {
it('succeeds when called by an EOA', async function () {
await expect(
this.utils.$checkOnERC1155Received(
this.operator,
this.owner,
this.receivers.eoa,
firstTokenId,
firstTokenValue,
'0x',
),
).to.not.be.reverted;
});
it('succeeds when data is passed', async function () {
const data = '0x12345678';
await expect(
this.utils.$checkOnERC1155Received(
this.operator,
this.owner,
this.receivers.correct,
firstTokenId,
firstTokenValue,
data,
),
).to.not.be.reverted;
});
it('succeeds when data is empty', async function () {
await expect(
this.utils.$checkOnERC1155Received(
this.operator,
this.owner,
this.receivers.correct,
firstTokenId,
firstTokenValue,
'0x',
),
).to.not.be.reverted;
});
it('reverts when receiver returns invalid value', async function () {
await expect(
this.utils.$checkOnERC1155Received(
this.operator,
this.owner,
this.receivers.invalid,
firstTokenId,
firstTokenValue,
'0x',
),
)
.to.be.revertedWithCustomError(this.utils, 'ERC1155InvalidReceiver')
.withArgs(this.receivers.invalid);
});
it('reverts when receiver reverts with message', async function () {
await expect(
this.utils.$checkOnERC1155Received(
this.operator,
this.owner,
this.receivers.message,
firstTokenId,
firstTokenValue,
'0x',
),
).to.be.revertedWith('ERC1155ReceiverMock: reverting on receive');
});
it('reverts when receiver reverts without message', async function () {
await expect(
this.utils.$checkOnERC1155Received(
this.operator,
this.owner,
this.receivers.empty,
firstTokenId,
firstTokenValue,
'0x',
),
)
.to.be.revertedWithCustomError(this.utils, 'ERC1155InvalidReceiver')
.withArgs(this.receivers.empty);
});
it('reverts when receiver reverts with custom error', async function () {
await expect(
this.utils.$checkOnERC1155Received(
this.operator,
this.owner,
this.receivers.customError,
firstTokenId,
firstTokenValue,
'0x',
),
)
.to.be.revertedWithCustomError(this.receivers.customError, 'CustomError')
.withArgs(RECEIVER_SINGLE_MAGIC_VALUE);
});
it('reverts when receiver panics', async function () {
await expect(
this.utils.$checkOnERC1155Received(
this.operator,
this.owner,
this.receivers.panic,
firstTokenId,
firstTokenValue,
'0x',
),
).to.be.revertedWithPanic(PANIC_CODES.DIVISION_BY_ZERO);
});
it('reverts when receiver does not implement onERC1155Received', async function () {
await expect(
this.utils.$checkOnERC1155Received(
this.operator,
this.owner,
this.receivers.nonReceiver,
firstTokenId,
firstTokenValue,
'0x',
),
)
.to.be.revertedWithCustomError(this.utils, 'ERC1155InvalidReceiver')
.withArgs(this.receivers.nonReceiver);
});
});
describe('onERC1155BatchReceived', function () {
it('succeeds when called by an EOA', async function () {
await expect(
this.utils.$checkOnERC1155BatchReceived(
this.operator,
this.owner,
this.receivers.eoa,
[firstTokenId, secondTokenId],
[firstTokenValue, secondTokenValue],
'0x',
),
).to.not.be.reverted;
});
it('succeeds when data is passed', async function () {
const data = '0x12345678';
await expect(
this.utils.$checkOnERC1155BatchReceived(
this.operator,
this.owner,
this.receivers.correct,
[firstTokenId, secondTokenId],
[firstTokenValue, secondTokenValue],
data,
),
).to.not.be.reverted;
});
it('succeeds when data is empty', async function () {
await expect(
this.utils.$checkOnERC1155BatchReceived(
this.operator,
this.owner,
this.receivers.correct,
[firstTokenId, secondTokenId],
[firstTokenValue, secondTokenValue],
'0x',
),
).to.not.be.reverted;
});
it('reverts when receiver returns invalid value', async function () {
await expect(
this.utils.$checkOnERC1155BatchReceived(
this.operator,
this.owner,
this.receivers.invalid,
[firstTokenId, secondTokenId],
[firstTokenValue, secondTokenValue],
'0x',
),
)
.to.be.revertedWithCustomError(this.utils, 'ERC1155InvalidReceiver')
.withArgs(this.receivers.invalid);
});
it('reverts when receiver reverts with message', async function () {
await expect(
this.utils.$checkOnERC1155BatchReceived(
this.operator,
this.owner,
this.receivers.message,
[firstTokenId, secondTokenId],
[firstTokenValue, secondTokenValue],
'0x',
),
).to.be.revertedWith('ERC1155ReceiverMock: reverting on batch receive');
});
it('reverts when receiver reverts without message', async function () {
await expect(
this.utils.$checkOnERC1155BatchReceived(
this.operator,
this.owner,
this.receivers.empty,
[firstTokenId, secondTokenId],
[firstTokenValue, secondTokenValue],
'0x',
),
)
.to.be.revertedWithCustomError(this.utils, 'ERC1155InvalidReceiver')
.withArgs(this.receivers.empty);
});
it('reverts when receiver reverts with custom error', async function () {
await expect(
this.utils.$checkOnERC1155BatchReceived(
this.operator,
this.owner,
this.receivers.customError,
[firstTokenId, secondTokenId],
[firstTokenValue, secondTokenValue],
'0x',
),
)
.to.be.revertedWithCustomError(this.receivers.customError, 'CustomError')
.withArgs(RECEIVER_SINGLE_MAGIC_VALUE);
});
it('reverts when receiver panics', async function () {
await expect(
this.utils.$checkOnERC1155BatchReceived(
this.operator,
this.owner,
this.receivers.panic,
[firstTokenId, secondTokenId],
[firstTokenValue, secondTokenValue],
'0x',
),
).to.be.revertedWithPanic(PANIC_CODES.DIVISION_BY_ZERO);
});
it('reverts when receiver does not implement onERC1155BatchReceived', async function () {
await expect(
this.utils.$checkOnERC1155BatchReceived(
this.operator,
this.owner,
this.receivers.nonReceiver,
[firstTokenId, secondTokenId],
[firstTokenValue, secondTokenValue],
'0x',
),
)
.to.be.revertedWithCustomError(this.utils, 'ERC1155InvalidReceiver')
.withArgs(this.receivers.nonReceiver);
});
});
});

View File

@@ -0,0 +1,260 @@
const { ethers } = require('hardhat');
const { expect } = require('chai');
function shouldBehaveLikeERC20(initialSupply, opts = {}) {
const { forcedApproval } = opts;
beforeEach(async function () {
[this.holder, this.recipient, this.other] = this.accounts;
});
it('total supply: returns the total token value', async function () {
expect(await this.token.totalSupply()).to.equal(initialSupply);
});
describe('balanceOf', function () {
it('returns zero when the requested account has no tokens', async function () {
expect(await this.token.balanceOf(this.other)).to.equal(0n);
});
it('returns the total token value when the requested account has some tokens', async function () {
expect(await this.token.balanceOf(this.holder)).to.equal(initialSupply);
});
});
describe('transfer', function () {
beforeEach(function () {
this.transfer = (from, to, value) => this.token.connect(from).transfer(to, value);
});
shouldBehaveLikeERC20Transfer(initialSupply);
});
describe('transfer from', function () {
describe('when the token owner is not the zero address', function () {
describe('when the recipient is not the zero address', function () {
describe('when the spender has enough allowance', function () {
beforeEach(async function () {
await this.token.connect(this.holder).approve(this.recipient, initialSupply);
});
describe('when the token owner has enough balance', function () {
const value = initialSupply;
beforeEach(async function () {
this.tx = await this.token.connect(this.recipient).transferFrom(this.holder, this.other, value);
});
it('transfers the requested value', async function () {
await expect(this.tx).to.changeTokenBalances(this.token, [this.holder, this.other], [-value, value]);
});
it('decreases the spender allowance', async function () {
expect(await this.token.allowance(this.holder, this.recipient)).to.equal(0n);
});
it('emits a transfer event', async function () {
await expect(this.tx).to.emit(this.token, 'Transfer').withArgs(this.holder, this.other, value);
});
if (forcedApproval) {
it('emits an approval event', async function () {
await expect(this.tx)
.to.emit(this.token, 'Approval')
.withArgs(
this.holder.address,
this.recipient.address,
await this.token.allowance(this.holder, this.recipient),
);
});
} else {
it('does not emit an approval event', async function () {
await expect(this.tx).to.not.emit(this.token, 'Approval');
});
}
});
it('reverts when the token owner does not have enough balance', async function () {
const value = initialSupply;
await this.token.connect(this.holder).transfer(this.other, 1n);
await expect(this.token.connect(this.recipient).transferFrom(this.holder, this.other, value))
.to.revertedWithCustomError(this.token, 'ERC20InsufficientBalance')
.withArgs(this.holder, value - 1n, value);
});
});
describe('when the spender does not have enough allowance', function () {
const allowance = initialSupply - 1n;
beforeEach(async function () {
await this.token.connect(this.holder).approve(this.recipient, allowance);
});
it('reverts when the token owner has enough balance', async function () {
const value = initialSupply;
await expect(this.token.connect(this.recipient).transferFrom(this.holder, this.other, value))
.to.be.revertedWithCustomError(this.token, 'ERC20InsufficientAllowance')
.withArgs(this.recipient, allowance, value);
});
it('reverts when the token owner does not have enough balance', async function () {
const value = allowance;
await this.token.connect(this.holder).transfer(this.other, 2);
await expect(this.token.connect(this.recipient).transferFrom(this.holder, this.other, value))
.to.be.revertedWithCustomError(this.token, 'ERC20InsufficientBalance')
.withArgs(this.holder, value - 1n, value);
});
});
describe('when the spender has unlimited allowance', function () {
beforeEach(async function () {
await this.token.connect(this.holder).approve(this.recipient, ethers.MaxUint256);
this.tx = await this.token.connect(this.recipient).transferFrom(this.holder, this.other, 1n);
});
it('does not decrease the spender allowance', async function () {
expect(await this.token.allowance(this.holder, this.recipient)).to.equal(ethers.MaxUint256);
});
it('does not emit an approval event', async function () {
await expect(this.tx).to.not.emit(this.token, 'Approval');
});
});
});
it('reverts when the recipient is the zero address', async function () {
const value = initialSupply;
await this.token.connect(this.holder).approve(this.recipient, value);
await expect(this.token.connect(this.recipient).transferFrom(this.holder, ethers.ZeroAddress, value))
.to.be.revertedWithCustomError(this.token, 'ERC20InvalidReceiver')
.withArgs(ethers.ZeroAddress);
});
});
it('reverts when the token owner is the zero address', async function () {
const value = 0n;
await expect(this.token.connect(this.recipient).transferFrom(ethers.ZeroAddress, this.recipient, value))
.to.be.revertedWithCustomError(this.token, 'ERC20InvalidApprover')
.withArgs(ethers.ZeroAddress);
});
});
describe('approve', function () {
beforeEach(function () {
this.approve = (owner, spender, value) => this.token.connect(owner).approve(spender, value);
});
shouldBehaveLikeERC20Approve(initialSupply);
});
}
function shouldBehaveLikeERC20Transfer(balance) {
describe('when the recipient is not the zero address', function () {
it('reverts when the sender does not have enough balance', async function () {
const value = balance + 1n;
await expect(this.transfer(this.holder, this.recipient, value))
.to.be.revertedWithCustomError(this.token, 'ERC20InsufficientBalance')
.withArgs(this.holder, balance, value);
});
describe('when the sender transfers all balance', function () {
const value = balance;
beforeEach(async function () {
this.tx = await this.transfer(this.holder, this.recipient, value);
});
it('transfers the requested value', async function () {
await expect(this.tx).to.changeTokenBalances(this.token, [this.holder, this.recipient], [-value, value]);
});
it('emits a transfer event', async function () {
await expect(this.tx).to.emit(this.token, 'Transfer').withArgs(this.holder, this.recipient, value);
});
});
describe('when the sender transfers zero tokens', function () {
const value = 0n;
beforeEach(async function () {
this.tx = await this.transfer(this.holder, this.recipient, value);
});
it('transfers the requested value', async function () {
await expect(this.tx).to.changeTokenBalances(this.token, [this.holder, this.recipient], [0n, 0n]);
});
it('emits a transfer event', async function () {
await expect(this.tx).to.emit(this.token, 'Transfer').withArgs(this.holder, this.recipient, value);
});
});
});
it('reverts when the recipient is the zero address', async function () {
await expect(this.transfer(this.holder, ethers.ZeroAddress, balance))
.to.be.revertedWithCustomError(this.token, 'ERC20InvalidReceiver')
.withArgs(ethers.ZeroAddress);
});
}
function shouldBehaveLikeERC20Approve(supply) {
describe('when the spender is not the zero address', function () {
describe('when the sender has enough balance', function () {
const value = supply;
it('emits an approval event', async function () {
await expect(this.approve(this.holder, this.recipient, value))
.to.emit(this.token, 'Approval')
.withArgs(this.holder, this.recipient, value);
});
it('approves the requested value when there was no approved value before', async function () {
await this.approve(this.holder, this.recipient, value);
expect(await this.token.allowance(this.holder, this.recipient)).to.equal(value);
});
it('approves the requested value and replaces the previous one when the spender had an approved value', async function () {
await this.approve(this.holder, this.recipient, 1n);
await this.approve(this.holder, this.recipient, value);
expect(await this.token.allowance(this.holder, this.recipient)).to.equal(value);
});
});
describe('when the sender does not have enough balance', function () {
const value = supply + 1n;
it('emits an approval event', async function () {
await expect(this.approve(this.holder, this.recipient, value))
.to.emit(this.token, 'Approval')
.withArgs(this.holder, this.recipient, value);
});
it('approves the requested value when there was no approved value before', async function () {
await this.approve(this.holder, this.recipient, value);
expect(await this.token.allowance(this.holder, this.recipient)).to.equal(value);
});
it('approves the requested value and replaces the previous one when the spender had an approved value', async function () {
await this.approve(this.holder, this.recipient, 1n);
await this.approve(this.holder, this.recipient, value);
expect(await this.token.allowance(this.holder, this.recipient)).to.equal(value);
});
});
});
it('reverts when the spender is the zero address', async function () {
await expect(this.approve(this.holder, ethers.ZeroAddress, supply))
.to.be.revertedWithCustomError(this.token, `ERC20InvalidSpender`)
.withArgs(ethers.ZeroAddress);
});
}
module.exports = {
shouldBehaveLikeERC20,
shouldBehaveLikeERC20Transfer,
shouldBehaveLikeERC20Approve,
};

View File

@@ -0,0 +1,199 @@
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 {
shouldBehaveLikeERC20,
shouldBehaveLikeERC20Transfer,
shouldBehaveLikeERC20Approve,
} = require('./ERC20.behavior');
const TOKENS = [{ Token: '$ERC20' }, { Token: '$ERC20ApprovalMock', forcedApproval: true }];
const name = 'My Token';
const symbol = 'MTKN';
const initialSupply = 100n;
describe('ERC20', function () {
for (const { Token, forcedApproval } of TOKENS) {
describe(Token, function () {
const fixture = async () => {
// this.accounts is used by shouldBehaveLikeERC20
const accounts = await ethers.getSigners();
const [holder, recipient] = accounts;
const token = await ethers.deployContract(Token, [name, symbol]);
await token.$_mint(holder, initialSupply);
return { accounts, holder, recipient, token };
};
beforeEach(async function () {
Object.assign(this, await loadFixture(fixture));
});
shouldBehaveLikeERC20(initialSupply, { forcedApproval });
it('has a name', async function () {
expect(await this.token.name()).to.equal(name);
});
it('has a symbol', async function () {
expect(await this.token.symbol()).to.equal(symbol);
});
it('has 18 decimals', async function () {
expect(await this.token.decimals()).to.equal(18n);
});
describe('_mint', function () {
const value = 50n;
it('rejects a null account', async function () {
await expect(this.token.$_mint(ethers.ZeroAddress, value))
.to.be.revertedWithCustomError(this.token, 'ERC20InvalidReceiver')
.withArgs(ethers.ZeroAddress);
});
it('rejects overflow', async function () {
await expect(this.token.$_mint(this.recipient, ethers.MaxUint256)).to.be.revertedWithPanic(
PANIC_CODES.ARITHMETIC_UNDER_OR_OVERFLOW,
);
});
describe('for a non zero account', function () {
beforeEach('minting', async function () {
this.tx = await this.token.$_mint(this.recipient, value);
});
it('increments totalSupply', async function () {
await expect(await this.token.totalSupply()).to.equal(initialSupply + value);
});
it('increments recipient balance', async function () {
await expect(this.tx).to.changeTokenBalance(this.token, this.recipient, value);
});
it('emits Transfer event', async function () {
await expect(this.tx).to.emit(this.token, 'Transfer').withArgs(ethers.ZeroAddress, this.recipient, value);
});
});
});
describe('_burn', function () {
it('rejects a null account', async function () {
await expect(this.token.$_burn(ethers.ZeroAddress, 1n))
.to.be.revertedWithCustomError(this.token, 'ERC20InvalidSender')
.withArgs(ethers.ZeroAddress);
});
describe('for a non zero account', function () {
it('rejects burning more than balance', async function () {
await expect(this.token.$_burn(this.holder, initialSupply + 1n))
.to.be.revertedWithCustomError(this.token, 'ERC20InsufficientBalance')
.withArgs(this.holder, initialSupply, initialSupply + 1n);
});
const describeBurn = function (description, value) {
describe(description, function () {
beforeEach('burning', async function () {
this.tx = await this.token.$_burn(this.holder, value);
});
it('decrements totalSupply', async function () {
expect(await this.token.totalSupply()).to.equal(initialSupply - value);
});
it('decrements holder balance', async function () {
await expect(this.tx).to.changeTokenBalance(this.token, this.holder, -value);
});
it('emits Transfer event', async function () {
await expect(this.tx).to.emit(this.token, 'Transfer').withArgs(this.holder, ethers.ZeroAddress, value);
});
});
};
describeBurn('for entire balance', initialSupply);
describeBurn('for less value than balance', initialSupply - 1n);
});
});
describe('_update', function () {
const value = 1n;
beforeEach(async function () {
this.totalSupply = await this.token.totalSupply();
});
it('from is the zero address', async function () {
const tx = await this.token.$_update(ethers.ZeroAddress, this.holder, value);
await expect(tx).to.emit(this.token, 'Transfer').withArgs(ethers.ZeroAddress, this.holder, value);
expect(await this.token.totalSupply()).to.equal(this.totalSupply + value);
await expect(tx).to.changeTokenBalance(this.token, this.holder, value);
});
it('to is the zero address', async function () {
const tx = await this.token.$_update(this.holder, ethers.ZeroAddress, value);
await expect(tx).to.emit(this.token, 'Transfer').withArgs(this.holder, ethers.ZeroAddress, value);
expect(await this.token.totalSupply()).to.equal(this.totalSupply - value);
await expect(tx).to.changeTokenBalance(this.token, this.holder, -value);
});
describe('from and to are the same address', function () {
it('zero address', async function () {
const tx = await this.token.$_update(ethers.ZeroAddress, ethers.ZeroAddress, value);
await expect(tx).to.emit(this.token, 'Transfer').withArgs(ethers.ZeroAddress, ethers.ZeroAddress, value);
expect(await this.token.totalSupply()).to.equal(this.totalSupply);
await expect(tx).to.changeTokenBalance(this.token, ethers.ZeroAddress, 0n);
});
describe('non zero address', function () {
it('reverts without balance', async function () {
await expect(this.token.$_update(this.recipient, this.recipient, value))
.to.be.revertedWithCustomError(this.token, 'ERC20InsufficientBalance')
.withArgs(this.recipient, 0n, value);
});
it('executes with balance', async function () {
const tx = await this.token.$_update(this.holder, this.holder, value);
await expect(tx).to.changeTokenBalance(this.token, this.holder, 0n);
await expect(tx).to.emit(this.token, 'Transfer').withArgs(this.holder, this.holder, value);
});
});
});
});
describe('_transfer', function () {
beforeEach(function () {
this.transfer = this.token.$_transfer;
});
shouldBehaveLikeERC20Transfer(initialSupply);
it('reverts when the sender is the zero address', async function () {
await expect(this.token.$_transfer(ethers.ZeroAddress, this.recipient, initialSupply))
.to.be.revertedWithCustomError(this.token, 'ERC20InvalidSender')
.withArgs(ethers.ZeroAddress);
});
});
describe('_approve', function () {
beforeEach(function () {
this.approve = this.token.$_approve;
});
shouldBehaveLikeERC20Approve(initialSupply);
it('reverts when the owner is the zero address', async function () {
await expect(this.token.$_approve(ethers.ZeroAddress, this.recipient, initialSupply))
.to.be.revertedWithCustomError(this.token, 'ERC20InvalidApprover')
.withArgs(ethers.ZeroAddress);
});
});
});
}
});

View File

@@ -0,0 +1,370 @@
const { ethers } = require('hardhat');
const { expect } = require('chai');
const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers');
const {
shouldBehaveLikeERC20,
shouldBehaveLikeERC20Transfer,
shouldBehaveLikeERC20Approve,
} = require('../ERC20.behavior.js');
const { shouldSupportInterfaces } = require('../../../utils/introspection/SupportsInterface.behavior');
const { RevertType } = require('../../../helpers/enums.js');
const name = 'My Token';
const symbol = 'MTKN';
const value = 1000n;
const data = '0x123456';
async function fixture() {
// this.accounts is used by shouldBehaveLikeERC20
const accounts = await ethers.getSigners();
const [holder, other] = accounts;
const receiver = await ethers.deployContract('ERC1363ReceiverMock');
const spender = await ethers.deployContract('ERC1363SpenderMock');
const token = await ethers.deployContract('$ERC1363', [name, symbol]);
await token.$_mint(holder, value);
return {
accounts,
holder,
other,
token,
receiver,
spender,
selectors: {
onTransferReceived: receiver.interface.getFunction('onTransferReceived(address,address,uint256,bytes)').selector,
onApprovalReceived: spender.interface.getFunction('onApprovalReceived(address,uint256,bytes)').selector,
},
};
}
describe('ERC1363', function () {
beforeEach(async function () {
Object.assign(this, await loadFixture(fixture));
});
shouldSupportInterfaces(['ERC165', 'ERC1363']);
shouldBehaveLikeERC20(value);
describe('transferAndCall', function () {
describe('as a transfer', function () {
beforeEach(async function () {
this.recipient = this.receiver;
this.transfer = (holder, ...rest) =>
this.token.connect(holder).getFunction('transferAndCall(address,uint256)')(...rest);
});
shouldBehaveLikeERC20Transfer(value);
});
it('reverts transferring to an EOA', async function () {
await expect(this.token.connect(this.holder).getFunction('transferAndCall(address,uint256)')(this.other, value))
.to.be.revertedWithCustomError(this.token, 'ERC1363InvalidReceiver')
.withArgs(this.other.address);
});
it('succeeds without data', async function () {
await expect(
this.token.connect(this.holder).getFunction('transferAndCall(address,uint256)')(this.receiver, value),
)
.to.emit(this.token, 'Transfer')
.withArgs(this.holder.address, this.receiver.target, value)
.to.emit(this.receiver, 'Received')
.withArgs(this.holder.address, this.holder.address, value, '0x');
});
it('succeeds with data', async function () {
await expect(
this.token.connect(this.holder).getFunction('transferAndCall(address,uint256,bytes)')(
this.receiver,
value,
data,
),
)
.to.emit(this.token, 'Transfer')
.withArgs(this.holder.address, this.receiver.target, value)
.to.emit(this.receiver, 'Received')
.withArgs(this.holder.address, this.holder.address, value, data);
});
it('reverts with reverting hook (without reason)', async function () {
await this.receiver.setUp(this.selectors.onTransferReceived, RevertType.RevertWithoutMessage);
await expect(
this.token.connect(this.holder).getFunction('transferAndCall(address,uint256,bytes)')(
this.receiver,
value,
data,
),
)
.to.be.revertedWithCustomError(this.token, 'ERC1363InvalidReceiver')
.withArgs(this.receiver.target);
});
it('reverts with reverting hook (with reason)', async function () {
await this.receiver.setUp(this.selectors.onTransferReceived, RevertType.RevertWithMessage);
await expect(
this.token.connect(this.holder).getFunction('transferAndCall(address,uint256,bytes)')(
this.receiver,
value,
data,
),
).to.be.revertedWith('ERC1363ReceiverMock: reverting');
});
it('reverts with reverting hook (with custom error)', async function () {
const reason = '0x12345678';
await this.receiver.setUp(reason, RevertType.RevertWithCustomError);
await expect(
this.token.connect(this.holder).getFunction('transferAndCall(address,uint256,bytes)')(
this.receiver,
value,
data,
),
)
.to.be.revertedWithCustomError(this.receiver, 'CustomError')
.withArgs(reason);
});
it('panics with reverting hook (with panic)', async function () {
await this.receiver.setUp(this.selectors.onTransferReceived, RevertType.Panic);
await expect(
this.token.connect(this.holder).getFunction('transferAndCall(address,uint256,bytes)')(
this.receiver,
value,
data,
),
).to.be.revertedWithPanic();
});
it('reverts with bad return value', async function () {
await this.receiver.setUp('0x12345678', RevertType.None);
await expect(
this.token.connect(this.holder).getFunction('transferAndCall(address,uint256,bytes)')(
this.receiver,
value,
data,
),
)
.to.be.revertedWithCustomError(this.token, 'ERC1363InvalidReceiver')
.withArgs(this.receiver.target);
});
});
describe('transferFromAndCall', function () {
beforeEach(async function () {
await this.token.connect(this.holder).approve(this.other, ethers.MaxUint256);
});
describe('as a transfer', function () {
beforeEach(async function () {
this.recipient = this.receiver;
this.transfer = this.token.connect(this.other).getFunction('transferFromAndCall(address,address,uint256)');
});
shouldBehaveLikeERC20Transfer(value);
});
it('reverts transferring to an EOA', async function () {
await expect(
this.token.connect(this.other).getFunction('transferFromAndCall(address,address,uint256)')(
this.holder,
this.other,
value,
),
)
.to.be.revertedWithCustomError(this.token, 'ERC1363InvalidReceiver')
.withArgs(this.other.address);
});
it('succeeds without data', async function () {
await expect(
this.token.connect(this.other).getFunction('transferFromAndCall(address,address,uint256)')(
this.holder,
this.receiver,
value,
),
)
.to.emit(this.token, 'Transfer')
.withArgs(this.holder.address, this.receiver.target, value)
.to.emit(this.receiver, 'Received')
.withArgs(this.other.address, this.holder.address, value, '0x');
});
it('succeeds with data', async function () {
await expect(
this.token.connect(this.other).getFunction('transferFromAndCall(address,address,uint256,bytes)')(
this.holder,
this.receiver,
value,
data,
),
)
.to.emit(this.token, 'Transfer')
.withArgs(this.holder.address, this.receiver.target, value)
.to.emit(this.receiver, 'Received')
.withArgs(this.other.address, this.holder.address, value, data);
});
it('reverts with reverting hook (without reason)', async function () {
await this.receiver.setUp(this.selectors.onTransferReceived, RevertType.RevertWithoutMessage);
await expect(
this.token.connect(this.other).getFunction('transferFromAndCall(address,address,uint256,bytes)')(
this.holder,
this.receiver,
value,
data,
),
)
.to.be.revertedWithCustomError(this.token, 'ERC1363InvalidReceiver')
.withArgs(this.receiver.target);
});
it('reverts with reverting hook (with reason)', async function () {
await this.receiver.setUp(this.selectors.onTransferReceived, RevertType.RevertWithMessage);
await expect(
this.token.connect(this.other).getFunction('transferFromAndCall(address,address,uint256,bytes)')(
this.holder,
this.receiver,
value,
data,
),
).to.be.revertedWith('ERC1363ReceiverMock: reverting');
});
it('reverts with reverting hook (with custom error)', async function () {
const reason = '0x12345678';
await this.receiver.setUp(reason, RevertType.RevertWithCustomError);
await expect(
this.token.connect(this.other).getFunction('transferFromAndCall(address,address,uint256,bytes)')(
this.holder,
this.receiver,
value,
data,
),
)
.to.be.revertedWithCustomError(this.receiver, 'CustomError')
.withArgs(reason);
});
it('panics with reverting hook (with panic)', async function () {
await this.receiver.setUp(this.selectors.onTransferReceived, RevertType.Panic);
await expect(
this.token.connect(this.other).getFunction('transferFromAndCall(address,address,uint256,bytes)')(
this.holder,
this.receiver,
value,
data,
),
).to.be.revertedWithPanic();
});
it('reverts with bad return value', async function () {
await this.receiver.setUp('0x12345678', RevertType.None);
await expect(
this.token.connect(this.other).getFunction('transferFromAndCall(address,address,uint256,bytes)')(
this.holder,
this.receiver,
value,
data,
),
)
.to.be.revertedWithCustomError(this.token, 'ERC1363InvalidReceiver')
.withArgs(this.receiver.target);
});
});
describe('approveAndCall', function () {
describe('as an approval', function () {
beforeEach(async function () {
this.recipient = this.spender;
this.approve = (holder, ...rest) =>
this.token.connect(holder).getFunction('approveAndCall(address,uint256)')(...rest);
});
shouldBehaveLikeERC20Approve(value);
});
it('reverts approving an EOA', async function () {
await expect(this.token.connect(this.holder).getFunction('approveAndCall(address,uint256)')(this.other, value))
.to.be.revertedWithCustomError(this.token, 'ERC1363InvalidSpender')
.withArgs(this.other.address);
});
it('succeeds without data', async function () {
await expect(this.token.connect(this.holder).getFunction('approveAndCall(address,uint256)')(this.spender, value))
.to.emit(this.token, 'Approval')
.withArgs(this.holder.address, this.spender.target, value)
.to.emit(this.spender, 'Approved')
.withArgs(this.holder.address, value, '0x');
});
it('succeeds with data', async function () {
await expect(
this.token.connect(this.holder).getFunction('approveAndCall(address,uint256,bytes)')(this.spender, value, data),
)
.to.emit(this.token, 'Approval')
.withArgs(this.holder.address, this.spender.target, value)
.to.emit(this.spender, 'Approved')
.withArgs(this.holder.address, value, data);
});
it('with reverting hook (without reason)', async function () {
await this.spender.setUp(this.selectors.onApprovalReceived, RevertType.RevertWithoutMessage);
await expect(
this.token.connect(this.holder).getFunction('approveAndCall(address,uint256,bytes)')(this.spender, value, data),
)
.to.be.revertedWithCustomError(this.token, 'ERC1363InvalidSpender')
.withArgs(this.spender.target);
});
it('reverts with reverting hook (with reason)', async function () {
await this.spender.setUp(this.selectors.onApprovalReceived, RevertType.RevertWithMessage);
await expect(
this.token.connect(this.holder).getFunction('approveAndCall(address,uint256,bytes)')(this.spender, value, data),
).to.be.revertedWith('ERC1363SpenderMock: reverting');
});
it('reverts with reverting hook (with custom error)', async function () {
const reason = '0x12345678';
await this.spender.setUp(reason, RevertType.RevertWithCustomError);
await expect(
this.token.connect(this.holder).getFunction('approveAndCall(address,uint256,bytes)')(this.spender, value, data),
)
.to.be.revertedWithCustomError(this.spender, 'CustomError')
.withArgs(reason);
});
it('panics with reverting hook (with panic)', async function () {
await this.spender.setUp(this.selectors.onApprovalReceived, RevertType.Panic);
await expect(
this.token.connect(this.holder).getFunction('approveAndCall(address,uint256,bytes)')(this.spender, value, data),
).to.be.revertedWithPanic();
});
it('reverts with bad return value', async function () {
await this.spender.setUp('0x12345678', RevertType.None);
await expect(
this.token.connect(this.holder).getFunction('approveAndCall(address,uint256,bytes)')(this.spender, value, data),
)
.to.be.revertedWithCustomError(this.token, 'ERC1363InvalidSpender')
.withArgs(this.spender.target);
});
});
});

View File

@@ -0,0 +1,105 @@
const { ethers } = require('hardhat');
const { expect } = require('chai');
const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers');
const name = 'My Token';
const symbol = 'MTKN';
const initialBalance = 1000n;
async function fixture() {
const [owner, burner] = await ethers.getSigners();
const token = await ethers.deployContract('$ERC20Burnable', [name, symbol], owner);
await token.$_mint(owner, initialBalance);
return { owner, burner, token, initialBalance };
}
describe('ERC20Burnable', function () {
beforeEach(async function () {
Object.assign(this, await loadFixture(fixture));
});
describe('burn', function () {
it('reverts if not enough balance', async function () {
const value = this.initialBalance + 1n;
await expect(this.token.connect(this.owner).burn(value))
.to.be.revertedWithCustomError(this.token, 'ERC20InsufficientBalance')
.withArgs(this.owner, this.initialBalance, value);
});
describe('on success', function () {
for (const { title, value } of [
{ title: 'for a zero value', value: 0n },
{ title: 'for a non-zero value', value: 100n },
]) {
describe(title, function () {
beforeEach(async function () {
this.tx = await this.token.connect(this.owner).burn(value);
});
it('burns the requested value', async function () {
await expect(this.tx).to.changeTokenBalance(this.token, this.owner, -value);
});
it('emits a transfer event', async function () {
await expect(this.tx).to.emit(this.token, 'Transfer').withArgs(this.owner, ethers.ZeroAddress, value);
});
});
}
});
});
describe('burnFrom', function () {
describe('reverts', function () {
it('if not enough balance', async function () {
const value = this.initialBalance + 1n;
await this.token.connect(this.owner).approve(this.burner, value);
await expect(this.token.connect(this.burner).burnFrom(this.owner, value))
.to.be.revertedWithCustomError(this.token, 'ERC20InsufficientBalance')
.withArgs(this.owner, this.initialBalance, value);
});
it('if not enough allowance', async function () {
const allowance = 100n;
await this.token.connect(this.owner).approve(this.burner, allowance);
await expect(this.token.connect(this.burner).burnFrom(this.owner, allowance + 1n))
.to.be.revertedWithCustomError(this.token, 'ERC20InsufficientAllowance')
.withArgs(this.burner, allowance, allowance + 1n);
});
});
describe('on success', function () {
for (const { title, value } of [
{ title: 'for a zero value', value: 0n },
{ title: 'for a non-zero value', value: 100n },
]) {
describe(title, function () {
const originalAllowance = value * 3n;
beforeEach(async function () {
await this.token.connect(this.owner).approve(this.burner, originalAllowance);
this.tx = await this.token.connect(this.burner).burnFrom(this.owner, value);
});
it('burns the requested value', async function () {
await expect(this.tx).to.changeTokenBalance(this.token, this.owner, -value);
});
it('decrements allowance', async function () {
expect(await this.token.allowance(this.owner, this.burner)).to.equal(originalAllowance - value);
});
it('emits a transfer event', async function () {
await expect(this.tx).to.emit(this.token, 'Transfer').withArgs(this.owner, ethers.ZeroAddress, value);
});
});
}
});
});
});

View File

@@ -0,0 +1,55 @@
const { ethers } = require('hardhat');
const { expect } = require('chai');
const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers');
const name = 'My Token';
const symbol = 'MTKN';
const cap = 1000n;
async function fixture() {
const [user] = await ethers.getSigners();
const token = await ethers.deployContract('$ERC20Capped', [name, symbol, cap]);
return { user, token, cap };
}
describe('ERC20Capped', function () {
beforeEach(async function () {
Object.assign(this, await loadFixture(fixture));
});
it('requires a non-zero cap', async function () {
const ERC20Capped = await ethers.getContractFactory('$ERC20Capped');
await expect(ERC20Capped.deploy(name, symbol, 0))
.to.be.revertedWithCustomError(ERC20Capped, 'ERC20InvalidCap')
.withArgs(0);
});
describe('capped token', function () {
it('starts with the correct cap', async function () {
expect(await this.token.cap()).to.equal(this.cap);
});
it('mints when value is less than cap', async function () {
const value = this.cap - 1n;
await this.token.$_mint(this.user, value);
expect(await this.token.totalSupply()).to.equal(value);
});
it('fails to mint if the value exceeds the cap', async function () {
await this.token.$_mint(this.user, this.cap - 1n);
await expect(this.token.$_mint(this.user, 2))
.to.be.revertedWithCustomError(this.token, 'ERC20ExceededCap')
.withArgs(this.cap + 1n, this.cap);
});
it('fails to mint after cap is reached', async function () {
await this.token.$_mint(this.user, this.cap);
await expect(this.token.$_mint(this.user, 1))
.to.be.revertedWithCustomError(this.token, 'ERC20ExceededCap')
.withArgs(this.cap + 1n, this.cap);
});
});
});

View File

@@ -0,0 +1,164 @@
const { ethers } = require('hardhat');
const { expect } = require('chai');
const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers');
const name = 'My Token';
const symbol = 'MTKN';
const initialSupply = 100n;
const loanValue = 10_000_000_000_000n;
async function fixture() {
const [holder, other] = await ethers.getSigners();
const token = await ethers.deployContract('$ERC20FlashMintMock', [name, symbol]);
await token.$_mint(holder, initialSupply);
return { holder, other, token };
}
describe('ERC20FlashMint', function () {
beforeEach(async function () {
Object.assign(this, await loadFixture(fixture));
});
describe('maxFlashLoan', function () {
it('token match', async function () {
expect(await this.token.maxFlashLoan(this.token)).to.equal(ethers.MaxUint256 - initialSupply);
});
it('token mismatch', async function () {
expect(await this.token.maxFlashLoan(ethers.ZeroAddress)).to.equal(0n);
});
});
describe('flashFee', function () {
it('token match', async function () {
expect(await this.token.flashFee(this.token, loanValue)).to.equal(0n);
});
it('token mismatch', async function () {
await expect(this.token.flashFee(ethers.ZeroAddress, loanValue))
.to.be.revertedWithCustomError(this.token, 'ERC3156UnsupportedToken')
.withArgs(ethers.ZeroAddress);
});
});
describe('flashFeeReceiver', function () {
it('default receiver', async function () {
expect(await this.token.$_flashFeeReceiver()).to.equal(ethers.ZeroAddress);
});
});
describe('flashLoan', function () {
it('success', async function () {
const receiver = await ethers.deployContract('ERC3156FlashBorrowerMock', [true, true]);
const tx = await this.token.flashLoan(receiver, this.token, loanValue, '0x');
await expect(tx)
.to.emit(this.token, 'Transfer')
.withArgs(ethers.ZeroAddress, receiver, loanValue)
.to.emit(this.token, 'Transfer')
.withArgs(receiver, ethers.ZeroAddress, loanValue)
.to.emit(receiver, 'BalanceOf')
.withArgs(this.token, receiver, loanValue)
.to.emit(receiver, 'TotalSupply')
.withArgs(this.token, initialSupply + loanValue);
await expect(tx).to.changeTokenBalance(this.token, receiver, 0);
expect(await this.token.totalSupply()).to.equal(initialSupply);
expect(await this.token.allowance(receiver, this.token)).to.equal(0n);
});
it('missing return value', async function () {
const receiver = await ethers.deployContract('ERC3156FlashBorrowerMock', [false, true]);
await expect(this.token.flashLoan(receiver, this.token, loanValue, '0x'))
.to.be.revertedWithCustomError(this.token, 'ERC3156InvalidReceiver')
.withArgs(receiver);
});
it('missing approval', async function () {
const receiver = await ethers.deployContract('ERC3156FlashBorrowerMock', [true, false]);
await expect(this.token.flashLoan(receiver, this.token, loanValue, '0x'))
.to.be.revertedWithCustomError(this.token, 'ERC20InsufficientAllowance')
.withArgs(this.token, 0, loanValue);
});
it('unavailable funds', async function () {
const receiver = await ethers.deployContract('ERC3156FlashBorrowerMock', [true, true]);
const data = this.token.interface.encodeFunctionData('transfer', [this.other.address, 10]);
await expect(this.token.flashLoan(receiver, this.token, loanValue, data))
.to.be.revertedWithCustomError(this.token, 'ERC20InsufficientBalance')
.withArgs(receiver, loanValue - 10n, loanValue);
});
it('more than maxFlashLoan', async function () {
const receiver = await ethers.deployContract('ERC3156FlashBorrowerMock', [true, true]);
const data = this.token.interface.encodeFunctionData('transfer', [this.other.address, 10]);
await expect(this.token.flashLoan(receiver, this.token, ethers.MaxUint256, data))
.to.be.revertedWithCustomError(this.token, 'ERC3156ExceededMaxLoan')
.withArgs(ethers.MaxUint256 - initialSupply);
});
describe('custom flash fee & custom fee receiver', function () {
const receiverInitialBalance = 200_000n;
const flashFee = 5_000n;
beforeEach('init receiver balance & set flash fee', async function () {
this.receiver = await ethers.deployContract('ERC3156FlashBorrowerMock', [true, true]);
const tx = await this.token.$_mint(this.receiver, receiverInitialBalance);
await expect(tx)
.to.emit(this.token, 'Transfer')
.withArgs(ethers.ZeroAddress, this.receiver, receiverInitialBalance);
await expect(tx).to.changeTokenBalance(this.token, this.receiver, receiverInitialBalance);
await this.token.setFlashFee(flashFee);
expect(await this.token.flashFee(this.token, loanValue)).to.equal(flashFee);
});
it('default flash fee receiver', async function () {
const tx = await this.token.flashLoan(this.receiver, this.token, loanValue, '0x');
await expect(tx)
.to.emit(this.token, 'Transfer')
.withArgs(ethers.ZeroAddress, this.receiver, loanValue)
.to.emit(this.token, 'Transfer')
.withArgs(this.receiver, ethers.ZeroAddress, loanValue + flashFee)
.to.emit(this.receiver, 'BalanceOf')
.withArgs(this.token, this.receiver, receiverInitialBalance + loanValue)
.to.emit(this.receiver, 'TotalSupply')
.withArgs(this.token, initialSupply + receiverInitialBalance + loanValue);
await expect(tx).to.changeTokenBalances(this.token, [this.receiver, ethers.ZeroAddress], [-flashFee, 0]);
expect(await this.token.totalSupply()).to.equal(initialSupply + receiverInitialBalance - flashFee);
expect(await this.token.allowance(this.receiver, this.token)).to.equal(0n);
});
it('custom flash fee receiver', async function () {
const flashFeeReceiverAddress = this.other;
await this.token.setFlashFeeReceiver(flashFeeReceiverAddress);
expect(await this.token.$_flashFeeReceiver()).to.equal(flashFeeReceiverAddress);
const tx = await this.token.flashLoan(this.receiver, this.token, loanValue, '0x');
await expect(tx)
.to.emit(this.token, 'Transfer')
.withArgs(ethers.ZeroAddress, this.receiver, loanValue)
.to.emit(this.token, 'Transfer')
.withArgs(this.receiver, ethers.ZeroAddress, loanValue)
.to.emit(this.token, 'Transfer')
.withArgs(this.receiver, flashFeeReceiverAddress, flashFee)
.to.emit(this.receiver, 'BalanceOf')
.withArgs(this.token, this.receiver, receiverInitialBalance + loanValue)
.to.emit(this.receiver, 'TotalSupply')
.withArgs(this.token, initialSupply + receiverInitialBalance + loanValue);
await expect(tx).to.changeTokenBalances(
this.token,
[this.receiver, flashFeeReceiverAddress],
[-flashFee, flashFee],
);
expect(await this.token.totalSupply()).to.equal(initialSupply + receiverInitialBalance);
expect(await this.token.allowance(this.receiver, flashFeeReceiverAddress)).to.equal(0n);
});
});
});
});

View File

@@ -0,0 +1,129 @@
const { ethers } = require('hardhat');
const { expect } = require('chai');
const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers');
const name = 'My Token';
const symbol = 'MTKN';
const initialSupply = 100n;
async function fixture() {
const [holder, recipient, approved] = await ethers.getSigners();
const token = await ethers.deployContract('$ERC20Pausable', [name, symbol]);
await token.$_mint(holder, initialSupply);
return { holder, recipient, approved, token };
}
describe('ERC20Pausable', function () {
beforeEach(async function () {
Object.assign(this, await loadFixture(fixture));
});
describe('pausable token', function () {
describe('transfer', function () {
it('allows to transfer when unpaused', async function () {
await expect(this.token.connect(this.holder).transfer(this.recipient, initialSupply)).to.changeTokenBalances(
this.token,
[this.holder, this.recipient],
[-initialSupply, initialSupply],
);
});
it('allows to transfer when paused and then unpaused', async function () {
await this.token.$_pause();
await this.token.$_unpause();
await expect(this.token.connect(this.holder).transfer(this.recipient, initialSupply)).to.changeTokenBalances(
this.token,
[this.holder, this.recipient],
[-initialSupply, initialSupply],
);
});
it('reverts when trying to transfer when paused', async function () {
await this.token.$_pause();
await expect(
this.token.connect(this.holder).transfer(this.recipient, initialSupply),
).to.be.revertedWithCustomError(this.token, 'EnforcedPause');
});
});
describe('transfer from', function () {
const allowance = 40n;
beforeEach(async function () {
await this.token.connect(this.holder).approve(this.approved, allowance);
});
it('allows to transfer from when unpaused', async function () {
await expect(
this.token.connect(this.approved).transferFrom(this.holder, this.recipient, allowance),
).to.changeTokenBalances(this.token, [this.holder, this.recipient], [-allowance, allowance]);
});
it('allows to transfer when paused and then unpaused', async function () {
await this.token.$_pause();
await this.token.$_unpause();
await expect(
this.token.connect(this.approved).transferFrom(this.holder, this.recipient, allowance),
).to.changeTokenBalances(this.token, [this.holder, this.recipient], [-allowance, allowance]);
});
it('reverts when trying to transfer from when paused', async function () {
await this.token.$_pause();
await expect(
this.token.connect(this.approved).transferFrom(this.holder, this.recipient, allowance),
).to.be.revertedWithCustomError(this.token, 'EnforcedPause');
});
});
describe('mint', function () {
const value = 42n;
it('allows to mint when unpaused', async function () {
await expect(this.token.$_mint(this.recipient, value)).to.changeTokenBalance(this.token, this.recipient, value);
});
it('allows to mint when paused and then unpaused', async function () {
await this.token.$_pause();
await this.token.$_unpause();
await expect(this.token.$_mint(this.recipient, value)).to.changeTokenBalance(this.token, this.recipient, value);
});
it('reverts when trying to mint when paused', async function () {
await this.token.$_pause();
await expect(this.token.$_mint(this.recipient, value)).to.be.revertedWithCustomError(
this.token,
'EnforcedPause',
);
});
});
describe('burn', function () {
const value = 42n;
it('allows to burn when unpaused', async function () {
await expect(this.token.$_burn(this.holder, value)).to.changeTokenBalance(this.token, this.holder, -value);
});
it('allows to burn when paused and then unpaused', async function () {
await this.token.$_pause();
await this.token.$_unpause();
await expect(this.token.$_burn(this.holder, value)).to.changeTokenBalance(this.token, this.holder, -value);
});
it('reverts when trying to burn when paused', async function () {
await this.token.$_pause();
await expect(this.token.$_burn(this.holder, value)).to.be.revertedWithCustomError(this.token, 'EnforcedPause');
});
});
});
});

View File

@@ -0,0 +1,109 @@
const { ethers } = require('hardhat');
const { expect } = require('chai');
const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers');
const { getDomain, domainSeparator, Permit } = require('../../../helpers/eip712');
const time = require('../../../helpers/time');
const name = 'My Token';
const symbol = 'MTKN';
const initialSupply = 100n;
async function fixture() {
const [holder, spender, owner, other] = await ethers.getSigners();
const token = await ethers.deployContract('$ERC20Permit', [name, symbol, name]);
await token.$_mint(holder, initialSupply);
return {
holder,
spender,
owner,
other,
token,
};
}
describe('ERC20Permit', function () {
beforeEach(async function () {
Object.assign(this, await loadFixture(fixture));
});
it('initial nonce is 0', async function () {
expect(await this.token.nonces(this.holder)).to.equal(0n);
});
it('domain separator', async function () {
expect(await this.token.DOMAIN_SEPARATOR()).to.equal(await getDomain(this.token).then(domainSeparator));
});
describe('permit', function () {
const value = 42n;
const nonce = 0n;
const maxDeadline = ethers.MaxUint256;
beforeEach(function () {
this.buildData = (contract, deadline = maxDeadline) =>
getDomain(contract).then(domain => ({
domain,
types: { Permit },
message: {
owner: this.owner.address,
spender: this.spender.address,
value,
nonce,
deadline,
},
}));
});
it('accepts owner signature', async function () {
const { v, r, s } = await this.buildData(this.token)
.then(({ domain, types, message }) => this.owner.signTypedData(domain, types, message))
.then(ethers.Signature.from);
await this.token.permit(this.owner, this.spender, value, maxDeadline, v, r, s);
expect(await this.token.nonces(this.owner)).to.equal(1n);
expect(await this.token.allowance(this.owner, this.spender)).to.equal(value);
});
it('rejects reused signature', async function () {
const { v, r, s, serialized } = await this.buildData(this.token)
.then(({ domain, types, message }) => this.owner.signTypedData(domain, types, message))
.then(ethers.Signature.from);
await this.token.permit(this.owner, this.spender, value, maxDeadline, v, r, s);
const recovered = await this.buildData(this.token).then(({ domain, types, message }) =>
ethers.verifyTypedData(domain, types, { ...message, nonce: nonce + 1n, deadline: maxDeadline }, serialized),
);
await expect(this.token.permit(this.owner, this.spender, value, maxDeadline, v, r, s))
.to.be.revertedWithCustomError(this.token, 'ERC2612InvalidSigner')
.withArgs(recovered, this.owner);
});
it('rejects other signature', async function () {
const { v, r, s } = await this.buildData(this.token)
.then(({ domain, types, message }) => this.other.signTypedData(domain, types, message))
.then(ethers.Signature.from);
await expect(this.token.permit(this.owner, this.spender, value, maxDeadline, v, r, s))
.to.be.revertedWithCustomError(this.token, 'ERC2612InvalidSigner')
.withArgs(this.other, this.owner);
});
it('rejects expired permit', async function () {
const deadline = (await time.clock.timestamp()) - time.duration.weeks(1);
const { v, r, s } = await this.buildData(this.token, deadline)
.then(({ domain, types, message }) => this.owner.signTypedData(domain, types, message))
.then(ethers.Signature.from);
await expect(this.token.permit(this.owner, this.spender, value, deadline, v, r, s))
.to.be.revertedWithCustomError(this.token, 'ERC2612ExpiredSignature')
.withArgs(deadline);
});
});
});

View File

@@ -0,0 +1,546 @@
const { ethers } = require('hardhat');
const { expect } = require('chai');
const { loadFixture, mine } = require('@nomicfoundation/hardhat-network-helpers');
const { getDomain, Delegation } = require('../../../helpers/eip712');
const { batchInBlock } = require('../../../helpers/txpool');
const time = require('../../../helpers/time');
const { shouldBehaveLikeVotes } = require('../../../governance/utils/Votes.behavior');
const TOKENS = [
{ Token: '$ERC20Votes', mode: 'blocknumber' },
{ Token: '$ERC20VotesTimestampMock', mode: 'timestamp' },
];
const name = 'My Token';
const symbol = 'MTKN';
const version = '1';
const supply = ethers.parseEther('10000000');
describe('ERC20Votes', function () {
for (const { Token, mode } of TOKENS) {
const fixture = async () => {
// accounts is required by shouldBehaveLikeVotes
const accounts = await ethers.getSigners();
const [holder, recipient, delegatee, other1, other2] = accounts;
const token = await ethers.deployContract(Token, [name, symbol, name, version]);
const domain = await getDomain(token);
return { accounts, holder, recipient, delegatee, other1, other2, token, domain };
};
describe(`vote with ${mode}`, function () {
beforeEach(async function () {
Object.assign(this, await loadFixture(fixture));
this.votes = this.token;
});
// includes ERC6372 behavior check
shouldBehaveLikeVotes([1, 17, 42], { mode, fungible: true });
it('initial nonce is 0', async function () {
expect(await this.token.nonces(this.holder)).to.equal(0n);
});
it('minting restriction', async function () {
const value = 2n ** 208n;
await expect(this.token.$_mint(this.holder, value))
.to.be.revertedWithCustomError(this.token, 'ERC20ExceededSafeSupply')
.withArgs(value, value - 1n);
});
it('recent checkpoints', async function () {
await this.token.connect(this.holder).delegate(this.holder);
for (let i = 0; i < 6; i++) {
await this.token.$_mint(this.holder, 1n);
}
const timepoint = await time.clock[mode]();
expect(await this.token.numCheckpoints(this.holder)).to.equal(6n);
// recent
expect(await this.token.getPastVotes(this.holder, timepoint - 1n)).to.equal(5n);
// non-recent
expect(await this.token.getPastVotes(this.holder, timepoint - 6n)).to.equal(0n);
});
describe('set delegation', function () {
describe('call', function () {
it('delegation with balance', async function () {
await this.token.$_mint(this.holder, supply);
expect(await this.token.delegates(this.holder)).to.equal(ethers.ZeroAddress);
const tx = await this.token.connect(this.holder).delegate(this.holder);
const timepoint = await time.clockFromReceipt[mode](tx);
await expect(tx)
.to.emit(this.token, 'DelegateChanged')
.withArgs(this.holder, ethers.ZeroAddress, this.holder)
.to.emit(this.token, 'DelegateVotesChanged')
.withArgs(this.holder, 0n, supply);
expect(await this.token.delegates(this.holder)).to.equal(this.holder);
expect(await this.token.getVotes(this.holder)).to.equal(supply);
expect(await this.token.getPastVotes(this.holder, timepoint - 1n)).to.equal(0n);
await mine();
expect(await this.token.getPastVotes(this.holder, timepoint)).to.equal(supply);
});
it('delegation without balance', async function () {
expect(await this.token.delegates(this.holder)).to.equal(ethers.ZeroAddress);
await expect(this.token.connect(this.holder).delegate(this.holder))
.to.emit(this.token, 'DelegateChanged')
.withArgs(this.holder, ethers.ZeroAddress, this.holder)
.to.not.emit(this.token, 'DelegateVotesChanged');
expect(await this.token.delegates(this.holder)).to.equal(this.holder);
});
});
describe('with signature', function () {
const nonce = 0n;
beforeEach(async function () {
await this.token.$_mint(this.holder, supply);
});
it('accept signed delegation', async function () {
const { r, s, v } = await this.holder
.signTypedData(
this.domain,
{ Delegation },
{
delegatee: this.holder.address,
nonce,
expiry: ethers.MaxUint256,
},
)
.then(ethers.Signature.from);
expect(await this.token.delegates(this.holder)).to.equal(ethers.ZeroAddress);
const tx = await this.token.delegateBySig(this.holder, nonce, ethers.MaxUint256, v, r, s);
const timepoint = await time.clockFromReceipt[mode](tx);
await expect(tx)
.to.emit(this.token, 'DelegateChanged')
.withArgs(this.holder, ethers.ZeroAddress, this.holder)
.to.emit(this.token, 'DelegateVotesChanged')
.withArgs(this.holder, 0n, supply);
expect(await this.token.delegates(this.holder)).to.equal(this.holder);
expect(await this.token.getVotes(this.holder)).to.equal(supply);
expect(await this.token.getPastVotes(this.holder, timepoint - 1n)).to.equal(0n);
await mine();
expect(await this.token.getPastVotes(this.holder, timepoint)).to.equal(supply);
});
it('rejects reused signature', async function () {
const { r, s, v } = await this.holder
.signTypedData(
this.domain,
{ Delegation },
{
delegatee: this.holder.address,
nonce,
expiry: ethers.MaxUint256,
},
)
.then(ethers.Signature.from);
await this.token.delegateBySig(this.holder, nonce, ethers.MaxUint256, v, r, s);
await expect(this.token.delegateBySig(this.holder, nonce, ethers.MaxUint256, v, r, s))
.to.be.revertedWithCustomError(this.token, 'InvalidAccountNonce')
.withArgs(this.holder, nonce + 1n);
});
it('rejects bad delegatee', async function () {
const { r, s, v } = await this.holder
.signTypedData(
this.domain,
{ Delegation },
{
delegatee: this.holder.address,
nonce,
expiry: ethers.MaxUint256,
},
)
.then(ethers.Signature.from);
const tx = await this.token.delegateBySig(this.delegatee, nonce, ethers.MaxUint256, v, r, s);
const { args } = await tx
.wait()
.then(receipt => receipt.logs.find(event => event.fragment.name == 'DelegateChanged'));
expect(args[0]).to.not.equal(this.holder);
expect(args[1]).to.equal(ethers.ZeroAddress);
expect(args[2]).to.equal(this.delegatee);
});
it('rejects bad nonce', async function () {
const { r, s, v, serialized } = await this.holder
.signTypedData(
this.domain,
{ Delegation },
{
delegatee: this.holder.address,
nonce,
expiry: ethers.MaxUint256,
},
)
.then(ethers.Signature.from);
const recovered = ethers.verifyTypedData(
this.domain,
{ Delegation },
{
delegatee: this.holder.address,
nonce: nonce + 1n,
expiry: ethers.MaxUint256,
},
serialized,
);
await expect(this.token.delegateBySig(this.holder, nonce + 1n, ethers.MaxUint256, v, r, s))
.to.be.revertedWithCustomError(this.token, 'InvalidAccountNonce')
.withArgs(recovered, nonce);
});
it('rejects expired permit', async function () {
const expiry = (await time.clock.timestamp()) - time.duration.weeks(1);
const { r, s, v } = await this.holder
.signTypedData(
this.domain,
{ Delegation },
{
delegatee: this.holder.address,
nonce,
expiry,
},
)
.then(ethers.Signature.from);
await expect(this.token.delegateBySig(this.holder, nonce, expiry, v, r, s))
.to.be.revertedWithCustomError(this.token, 'VotesExpiredSignature')
.withArgs(expiry);
});
});
});
describe('change delegation', function () {
beforeEach(async function () {
await this.token.$_mint(this.holder, supply);
await this.token.connect(this.holder).delegate(this.holder);
});
it('call', async function () {
expect(await this.token.delegates(this.holder)).to.equal(this.holder);
const tx = await this.token.connect(this.holder).delegate(this.delegatee);
const timepoint = await time.clockFromReceipt[mode](tx);
await expect(tx)
.to.emit(this.token, 'DelegateChanged')
.withArgs(this.holder, this.holder, this.delegatee)
.to.emit(this.token, 'DelegateVotesChanged')
.withArgs(this.holder, supply, 0n)
.to.emit(this.token, 'DelegateVotesChanged')
.withArgs(this.delegatee, 0n, supply);
expect(await this.token.delegates(this.holder)).to.equal(this.delegatee);
expect(await this.token.getVotes(this.holder)).to.equal(0n);
expect(await this.token.getVotes(this.delegatee)).to.equal(supply);
expect(await this.token.getPastVotes(this.holder, timepoint - 1n)).to.equal(supply);
expect(await this.token.getPastVotes(this.delegatee, timepoint - 1n)).to.equal(0n);
await mine();
expect(await this.token.getPastVotes(this.holder, timepoint)).to.equal(0n);
expect(await this.token.getPastVotes(this.delegatee, timepoint)).to.equal(supply);
});
});
describe('transfers', function () {
beforeEach(async function () {
await this.token.$_mint(this.holder, supply);
});
it('no delegation', async function () {
await expect(this.token.connect(this.holder).transfer(this.recipient, 1n))
.to.emit(this.token, 'Transfer')
.withArgs(this.holder, this.recipient, 1n)
.to.not.emit(this.token, 'DelegateVotesChanged');
this.holderVotes = 0n;
this.recipientVotes = 0n;
});
it('sender delegation', async function () {
await this.token.connect(this.holder).delegate(this.holder);
const tx = await this.token.connect(this.holder).transfer(this.recipient, 1n);
await expect(tx)
.to.emit(this.token, 'Transfer')
.withArgs(this.holder, this.recipient, 1n)
.to.emit(this.token, 'DelegateVotesChanged')
.withArgs(this.holder, supply, supply - 1n);
const { logs } = await tx.wait();
const { index } = logs.find(event => event.fragment.name == 'DelegateVotesChanged');
for (const event of logs.filter(event => event.fragment.name == 'Transfer')) {
expect(event.index).to.lt(index);
}
this.holderVotes = supply - 1n;
this.recipientVotes = 0n;
});
it('receiver delegation', async function () {
await this.token.connect(this.recipient).delegate(this.recipient);
const tx = await this.token.connect(this.holder).transfer(this.recipient, 1n);
await expect(tx)
.to.emit(this.token, 'Transfer')
.withArgs(this.holder, this.recipient, 1n)
.to.emit(this.token, 'DelegateVotesChanged')
.withArgs(this.recipient, 0n, 1n);
const { logs } = await tx.wait();
const { index } = logs.find(event => event.fragment.name == 'DelegateVotesChanged');
for (const event of logs.filter(event => event.fragment.name == 'Transfer')) {
expect(event.index).to.lt(index);
}
this.holderVotes = 0n;
this.recipientVotes = 1n;
});
it('full delegation', async function () {
await this.token.connect(this.holder).delegate(this.holder);
await this.token.connect(this.recipient).delegate(this.recipient);
const tx = await this.token.connect(this.holder).transfer(this.recipient, 1n);
await expect(tx)
.to.emit(this.token, 'Transfer')
.withArgs(this.holder, this.recipient, 1n)
.to.emit(this.token, 'DelegateVotesChanged')
.withArgs(this.holder, supply, supply - 1n)
.to.emit(this.token, 'DelegateVotesChanged')
.withArgs(this.recipient, 0n, 1n);
const { logs } = await tx.wait();
const { index } = logs.find(event => event.fragment.name == 'DelegateVotesChanged');
for (const event of logs.filter(event => event.fragment.name == 'Transfer')) {
expect(event.index).to.lt(index);
}
this.holderVotes = supply - 1n;
this.recipientVotes = 1n;
});
afterEach(async function () {
expect(await this.token.getVotes(this.holder)).to.equal(this.holderVotes);
expect(await this.token.getVotes(this.recipient)).to.equal(this.recipientVotes);
// need to advance 2 blocks to see the effect of a transfer on "getPastVotes"
const timepoint = await time.clock[mode]();
await mine();
expect(await this.token.getPastVotes(this.holder, timepoint)).to.equal(this.holderVotes);
expect(await this.token.getPastVotes(this.recipient, timepoint)).to.equal(this.recipientVotes);
});
});
// The following tests are a 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.token.$_mint(this.holder, supply);
});
describe('balanceOf', function () {
it('grants to initial account', async function () {
expect(await this.token.balanceOf(this.holder)).to.equal(supply);
});
});
describe('numCheckpoints', function () {
it('returns the number of checkpoints for a delegate', async function () {
await this.token.connect(this.holder).transfer(this.recipient, 100n); //give an account a few tokens for readability
expect(await this.token.numCheckpoints(this.other1)).to.equal(0n);
const t1 = await this.token.connect(this.recipient).delegate(this.other1);
t1.timepoint = await time.clockFromReceipt[mode](t1);
expect(await this.token.numCheckpoints(this.other1)).to.equal(1n);
const t2 = await this.token.connect(this.recipient).transfer(this.other2, 10);
t2.timepoint = await time.clockFromReceipt[mode](t2);
expect(await this.token.numCheckpoints(this.other1)).to.equal(2n);
const t3 = await this.token.connect(this.recipient).transfer(this.other2, 10);
t3.timepoint = await time.clockFromReceipt[mode](t3);
expect(await this.token.numCheckpoints(this.other1)).to.equal(3n);
const t4 = await this.token.connect(this.holder).transfer(this.recipient, 20);
t4.timepoint = await time.clockFromReceipt[mode](t4);
expect(await this.token.numCheckpoints(this.other1)).to.equal(4n);
expect(await this.token.checkpoints(this.other1, 0n)).to.deep.equal([t1.timepoint, 100n]);
expect(await this.token.checkpoints(this.other1, 1n)).to.deep.equal([t2.timepoint, 90n]);
expect(await this.token.checkpoints(this.other1, 2n)).to.deep.equal([t3.timepoint, 80n]);
expect(await this.token.checkpoints(this.other1, 3n)).to.deep.equal([t4.timepoint, 100n]);
await mine();
expect(await this.token.getPastVotes(this.other1, t1.timepoint)).to.equal(100n);
expect(await this.token.getPastVotes(this.other1, t2.timepoint)).to.equal(90n);
expect(await this.token.getPastVotes(this.other1, t3.timepoint)).to.equal(80n);
expect(await this.token.getPastVotes(this.other1, t4.timepoint)).to.equal(100n);
});
it('does not add more than one checkpoint in a block', async function () {
await this.token.connect(this.holder).transfer(this.recipient, 100n);
expect(await this.token.numCheckpoints(this.other1)).to.equal(0n);
const [t1, t2, t3] = await batchInBlock([
() => this.token.connect(this.recipient).delegate(this.other1, { gasLimit: 200000 }),
() => this.token.connect(this.recipient).transfer(this.other2, 10n, { gasLimit: 200000 }),
() => this.token.connect(this.recipient).transfer(this.other2, 10n, { gasLimit: 200000 }),
]);
t1.timepoint = await time.clockFromReceipt[mode](t1);
t2.timepoint = await time.clockFromReceipt[mode](t2);
t3.timepoint = await time.clockFromReceipt[mode](t3);
expect(await this.token.numCheckpoints(this.other1)).to.equal(1);
expect(await this.token.checkpoints(this.other1, 0n)).to.be.deep.equal([t1.timepoint, 80n]);
const t4 = await this.token.connect(this.holder).transfer(this.recipient, 20n);
t4.timepoint = await time.clockFromReceipt[mode](t4);
expect(await this.token.numCheckpoints(this.other1)).to.equal(2n);
expect(await this.token.checkpoints(this.other1, 1n)).to.be.deep.equal([t4.timepoint, 100n]);
});
});
describe('getPastVotes', function () {
it('reverts if block number >= current block', async function () {
const clock = await this.token.clock();
await expect(this.token.getPastVotes(this.other1, 50_000_000_000n))
.to.be.revertedWithCustomError(this.token, 'ERC5805FutureLookup')
.withArgs(50_000_000_000n, clock);
});
it('returns 0 if there are no checkpoints', async function () {
expect(await this.token.getPastVotes(this.other1, 0n)).to.equal(0n);
});
it('returns the latest block if >= last checkpoint block', async function () {
const tx = await this.token.connect(this.holder).delegate(this.other1);
const timepoint = await time.clockFromReceipt[mode](tx);
await mine(2);
expect(await this.token.getPastVotes(this.other1, timepoint)).to.equal(supply);
expect(await this.token.getPastVotes(this.other1, timepoint + 1n)).to.equal(supply);
});
it('returns zero if < first checkpoint block', async function () {
await mine();
const tx = await this.token.connect(this.holder).delegate(this.other1);
const timepoint = await time.clockFromReceipt[mode](tx);
await mine(2);
expect(await this.token.getPastVotes(this.other1, timepoint - 1n)).to.equal(0n);
expect(await this.token.getPastVotes(this.other1, timepoint + 1n)).to.equal(supply);
});
it('generally returns the voting balance at the appropriate checkpoint', async function () {
const t1 = await this.token.connect(this.holder).delegate(this.other1);
await mine(2);
const t2 = await this.token.connect(this.holder).transfer(this.other2, 10);
await mine(2);
const t3 = await this.token.connect(this.holder).transfer(this.other2, 10);
await mine(2);
const t4 = await this.token.connect(this.other2).transfer(this.holder, 20);
await mine(2);
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);
expect(await this.token.getPastVotes(this.other1, t1.timepoint - 1n)).to.equal(0n);
expect(await this.token.getPastVotes(this.other1, t1.timepoint)).to.equal(supply);
expect(await this.token.getPastVotes(this.other1, t1.timepoint + 1n)).to.equal(supply);
expect(await this.token.getPastVotes(this.other1, t2.timepoint)).to.equal(supply - 10n);
expect(await this.token.getPastVotes(this.other1, t2.timepoint + 1n)).to.equal(supply - 10n);
expect(await this.token.getPastVotes(this.other1, t3.timepoint)).to.equal(supply - 20n);
expect(await this.token.getPastVotes(this.other1, t3.timepoint + 1n)).to.equal(supply - 20n);
expect(await this.token.getPastVotes(this.other1, t4.timepoint)).to.equal(supply);
expect(await this.token.getPastVotes(this.other1, t4.timepoint + 1n)).to.equal(supply);
});
});
});
describe('getPastTotalSupply', function () {
beforeEach(async function () {
await this.token.connect(this.holder).delegate(this.holder);
});
it('reverts if block number >= current block', async function () {
const clock = await this.token.clock();
await expect(this.token.getPastTotalSupply(50_000_000_000n))
.to.be.revertedWithCustomError(this.token, 'ERC5805FutureLookup')
.withArgs(50_000_000_000n, clock);
});
it('returns 0 if there are no checkpoints', async function () {
expect(await this.token.getPastTotalSupply(0n)).to.equal(0n);
});
it('returns the latest block if >= last checkpoint block', async function () {
const tx = await this.token.$_mint(this.holder, supply);
const timepoint = await time.clockFromReceipt[mode](tx);
await mine(2);
expect(await this.token.getPastTotalSupply(timepoint)).to.equal(supply);
expect(await this.token.getPastTotalSupply(timepoint + 1n)).to.equal(supply);
});
it('returns zero if < first checkpoint block', async function () {
await mine();
const tx = await this.token.$_mint(this.holder, supply);
const timepoint = await time.clockFromReceipt[mode](tx);
await mine(2);
expect(await this.token.getPastTotalSupply(timepoint - 1n)).to.equal(0n);
expect(await this.token.getPastTotalSupply(timepoint + 1n)).to.equal(supply);
});
it('generally returns the voting balance at the appropriate checkpoint', async function () {
const t1 = await this.token.$_mint(this.holder, supply);
await mine(2);
const t2 = await this.token.$_burn(this.holder, 10n);
await mine(2);
const t3 = await this.token.$_burn(this.holder, 10n);
await mine(2);
const t4 = await this.token.$_mint(this.holder, 20n);
await mine(2);
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);
expect(await this.token.getPastTotalSupply(t1.timepoint - 1n)).to.equal(0n);
expect(await this.token.getPastTotalSupply(t1.timepoint)).to.equal(supply);
expect(await this.token.getPastTotalSupply(t1.timepoint + 1n)).to.equal(supply);
expect(await this.token.getPastTotalSupply(t2.timepoint)).to.equal(supply - 10n);
expect(await this.token.getPastTotalSupply(t2.timepoint + 1n)).to.equal(supply - 10n);
expect(await this.token.getPastTotalSupply(t3.timepoint)).to.equal(supply - 20n);
expect(await this.token.getPastTotalSupply(t3.timepoint + 1n)).to.equal(supply - 20n);
expect(await this.token.getPastTotalSupply(t4.timepoint)).to.equal(supply);
expect(await this.token.getPastTotalSupply(t4.timepoint + 1n)).to.equal(supply);
});
});
});
}
});

View File

@@ -0,0 +1,203 @@
const { ethers } = require('hardhat');
const { expect } = require('chai');
const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers');
const { shouldBehaveLikeERC20 } = require('../ERC20.behavior');
const name = 'My Token';
const symbol = 'MTKN';
const decimals = 9n;
const initialSupply = 100n;
async function fixture() {
// this.accounts is used by shouldBehaveLikeERC20
const accounts = await ethers.getSigners();
const [holder, recipient, other] = accounts;
const underlying = await ethers.deployContract('$ERC20DecimalsMock', [name, symbol, decimals]);
await underlying.$_mint(holder, initialSupply);
const token = await ethers.deployContract('$ERC20Wrapper', [`Wrapped ${name}`, `W${symbol}`, underlying]);
return { accounts, holder, recipient, other, underlying, token };
}
describe('ERC20Wrapper', function () {
beforeEach(async function () {
Object.assign(this, await loadFixture(fixture));
});
afterEach('Underlying balance', async function () {
expect(await this.underlying.balanceOf(this.token)).to.equal(await this.token.totalSupply());
});
it('has a name', async function () {
expect(await this.token.name()).to.equal(`Wrapped ${name}`);
});
it('has a symbol', async function () {
expect(await this.token.symbol()).to.equal(`W${symbol}`);
});
it('has the same decimals as the underlying token', async function () {
expect(await this.token.decimals()).to.equal(decimals);
});
it('decimals default back to 18 if token has no metadata', async function () {
const noDecimals = await ethers.deployContract('CallReceiverMock');
const token = await ethers.deployContract('$ERC20Wrapper', [`Wrapped ${name}`, `W${symbol}`, noDecimals]);
expect(await token.decimals()).to.equal(18n);
});
it('has underlying', async function () {
expect(await this.token.underlying()).to.equal(this.underlying);
});
describe('deposit', function () {
it('executes with approval', async function () {
await this.underlying.connect(this.holder).approve(this.token, initialSupply);
const tx = await this.token.connect(this.holder).depositFor(this.holder, initialSupply);
await expect(tx)
.to.emit(this.underlying, 'Transfer')
.withArgs(this.holder, this.token, initialSupply)
.to.emit(this.token, 'Transfer')
.withArgs(ethers.ZeroAddress, this.holder, initialSupply);
await expect(tx).to.changeTokenBalances(
this.underlying,
[this.holder, this.token],
[-initialSupply, initialSupply],
);
await expect(tx).to.changeTokenBalance(this.token, this.holder, initialSupply);
});
it('reverts when missing approval', async function () {
await expect(this.token.connect(this.holder).depositFor(this.holder, initialSupply))
.to.be.revertedWithCustomError(this.underlying, 'ERC20InsufficientAllowance')
.withArgs(this.token, 0, initialSupply);
});
it('reverts when inssuficient balance', async function () {
await this.underlying.connect(this.holder).approve(this.token, ethers.MaxUint256);
await expect(this.token.connect(this.holder).depositFor(this.holder, ethers.MaxUint256))
.to.be.revertedWithCustomError(this.underlying, 'ERC20InsufficientBalance')
.withArgs(this.holder, initialSupply, ethers.MaxUint256);
});
it('deposits to other account', async function () {
await this.underlying.connect(this.holder).approve(this.token, initialSupply);
const tx = await this.token.connect(this.holder).depositFor(this.recipient, initialSupply);
await expect(tx)
.to.emit(this.underlying, 'Transfer')
.withArgs(this.holder, this.token.target, initialSupply)
.to.emit(this.token, 'Transfer')
.withArgs(ethers.ZeroAddress, this.recipient, initialSupply);
await expect(tx).to.changeTokenBalances(
this.underlying,
[this.holder, this.token],
[-initialSupply, initialSupply],
);
await expect(tx).to.changeTokenBalances(this.token, [this.holder, this.recipient], [0, initialSupply]);
});
it('reverts minting to the wrapper contract', async function () {
await this.underlying.connect(this.holder).approve(this.token, ethers.MaxUint256);
await expect(this.token.connect(this.holder).depositFor(this.token, ethers.MaxUint256))
.to.be.revertedWithCustomError(this.token, 'ERC20InvalidReceiver')
.withArgs(this.token);
});
});
describe('withdraw', function () {
beforeEach(async function () {
await this.underlying.connect(this.holder).approve(this.token, initialSupply);
await this.token.connect(this.holder).depositFor(this.holder, initialSupply);
});
it('reverts when inssuficient balance', async function () {
await expect(this.token.connect(this.holder).withdrawTo(this.holder, ethers.MaxInt256))
.to.be.revertedWithCustomError(this.token, 'ERC20InsufficientBalance')
.withArgs(this.holder, initialSupply, ethers.MaxInt256);
});
it('executes when operation is valid', async function () {
const value = 42n;
const tx = await this.token.connect(this.holder).withdrawTo(this.holder, value);
await expect(tx)
.to.emit(this.underlying, 'Transfer')
.withArgs(this.token.target, this.holder, value)
.to.emit(this.token, 'Transfer')
.withArgs(this.holder, ethers.ZeroAddress, value);
await expect(tx).to.changeTokenBalances(this.underlying, [this.token, this.holder], [-value, value]);
await expect(tx).to.changeTokenBalance(this.token, this.holder, -value);
});
it('entire balance', async function () {
const tx = await this.token.connect(this.holder).withdrawTo(this.holder, initialSupply);
await expect(tx)
.to.emit(this.underlying, 'Transfer')
.withArgs(this.token.target, this.holder, initialSupply)
.to.emit(this.token, 'Transfer')
.withArgs(this.holder, ethers.ZeroAddress, initialSupply);
await expect(tx).to.changeTokenBalances(
this.underlying,
[this.token, this.holder],
[-initialSupply, initialSupply],
);
await expect(tx).to.changeTokenBalance(this.token, this.holder, -initialSupply);
});
it('to other account', async function () {
const tx = await this.token.connect(this.holder).withdrawTo(this.recipient, initialSupply);
await expect(tx)
.to.emit(this.underlying, 'Transfer')
.withArgs(this.token, this.recipient, initialSupply)
.to.emit(this.token, 'Transfer')
.withArgs(this.holder, ethers.ZeroAddress, initialSupply);
await expect(tx).to.changeTokenBalances(
this.underlying,
[this.token, this.holder, this.recipient],
[-initialSupply, 0, initialSupply],
);
await expect(tx).to.changeTokenBalance(this.token, this.holder, -initialSupply);
});
it('reverts withdrawing to the wrapper contract', async function () {
await expect(this.token.connect(this.holder).withdrawTo(this.token, initialSupply))
.to.be.revertedWithCustomError(this.token, 'ERC20InvalidReceiver')
.withArgs(this.token);
});
});
describe('recover', function () {
it('nothing to recover', async function () {
await this.underlying.connect(this.holder).approve(this.token, initialSupply);
await this.token.connect(this.holder).depositFor(this.holder, initialSupply);
const tx = await this.token.$_recover(this.recipient);
await expect(tx).to.emit(this.token, 'Transfer').withArgs(ethers.ZeroAddress, this.recipient, 0n);
await expect(tx).to.changeTokenBalance(this.token, this.recipient, 0);
});
it('something to recover', async function () {
await this.underlying.connect(this.holder).transfer(this.token, initialSupply);
const tx = await this.token.$_recover(this.recipient);
await expect(tx).to.emit(this.token, 'Transfer').withArgs(ethers.ZeroAddress, this.recipient, initialSupply);
await expect(tx).to.changeTokenBalance(this.token, this.recipient, initialSupply);
});
});
describe('erc20 behaviour', function () {
beforeEach(async function () {
await this.underlying.connect(this.holder).approve(this.token, initialSupply);
await this.token.connect(this.holder).depositFor(this.holder, initialSupply);
});
shouldBehaveLikeERC20(initialSupply);
});
});

View File

@@ -0,0 +1,41 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {ERC4626Test} from "erc4626-tests/ERC4626.test.sol";
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {ERC4626} from "@openzeppelin/contracts/token/ERC20/extensions/ERC4626.sol";
import {ERC20Mock} from "@openzeppelin/contracts/mocks/token/ERC20Mock.sol";
import {ERC4626Mock} from "@openzeppelin/contracts/mocks/token/ERC4626Mock.sol";
import {ERC4626OffsetMock} from "@openzeppelin/contracts/mocks/token/ERC4626OffsetMock.sol";
contract ERC4626VaultOffsetMock is ERC4626OffsetMock {
constructor(
ERC20 underlying_,
uint8 offset_
) ERC20("My Token Vault", "MTKNV") ERC4626(underlying_) ERC4626OffsetMock(offset_) {}
}
contract ERC4626StdTest is ERC4626Test {
ERC20 private _underlying = new ERC20Mock();
function setUp() public override {
_underlying_ = address(_underlying);
_vault_ = address(new ERC4626Mock(_underlying_));
_delta_ = 0;
_vaultMayBeEmpty = true;
_unlimitedAmount = true;
}
/**
* @dev Check the case where calculated `decimals` value overflows the `uint8` type.
*/
function testFuzzDecimalsOverflow(uint8 offset) public {
/// @dev Remember that the `_underlying` exhibits a `decimals` value of 18.
offset = uint8(bound(uint256(offset), 238, uint256(type(uint8).max)));
ERC4626VaultOffsetMock erc4626VaultOffsetMock = new ERC4626VaultOffsetMock(_underlying, offset);
vm.expectRevert();
erc4626VaultOffsetMock.decimals();
}
}

View File

@@ -0,0 +1,888 @@
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 { Enum } = require('../../../helpers/enums');
const name = 'My Token';
const symbol = 'MTKN';
const decimals = 18n;
async function fixture() {
const [holder, recipient, spender, other, ...accounts] = await ethers.getSigners();
return { holder, recipient, spender, other, accounts };
}
describe('ERC4626', function () {
beforeEach(async function () {
Object.assign(this, await loadFixture(fixture));
});
it('inherit decimals if from asset', async function () {
for (const decimals of [0n, 9n, 12n, 18n, 36n]) {
const token = await ethers.deployContract('$ERC20DecimalsMock', ['', '', decimals]);
const vault = await ethers.deployContract('$ERC4626', ['', '', token]);
expect(await vault.decimals()).to.equal(decimals);
}
});
it('asset has not yet been created', async function () {
const vault = await ethers.deployContract('$ERC4626', ['', '', this.other.address]);
expect(await vault.decimals()).to.equal(decimals);
});
it('underlying excess decimals', async function () {
const token = await ethers.deployContract('$ERC20ExcessDecimalsMock');
const vault = await ethers.deployContract('$ERC4626', ['', '', token]);
expect(await vault.decimals()).to.equal(decimals);
});
it('decimals overflow', async function () {
for (const offset of [243n, 250n, 255n]) {
const token = await ethers.deployContract('$ERC20DecimalsMock', ['', '', decimals]);
const vault = await ethers.deployContract('$ERC4626OffsetMock', ['', '', token, offset]);
await expect(vault.decimals()).to.be.revertedWithPanic(PANIC_CODES.ARITHMETIC_UNDER_OR_OVERFLOW);
}
});
describe('reentrancy', function () {
const reenterType = Enum('No', 'Before', 'After');
const value = 1_000_000_000_000_000_000n;
const reenterValue = 1_000_000_000n;
beforeEach(async function () {
// Use offset 1 so the rate is not 1:1 and we can't possibly confuse assets and shares
const token = await ethers.deployContract('$ERC20Reentrant');
const vault = await ethers.deployContract('$ERC4626OffsetMock', ['', '', token, 1n]);
// Funds and approval for tests
await token.$_mint(this.holder, value);
await token.$_mint(this.other, value);
await token.$_approve(this.holder, vault, ethers.MaxUint256);
await token.$_approve(this.other, vault, ethers.MaxUint256);
await token.$_approve(token, vault, ethers.MaxUint256);
Object.assign(this, { token, vault });
});
// During a `_deposit`, the vault does `transferFrom(depositor, vault, assets)` -> `_mint(receiver, shares)`
// such that a reentrancy BEFORE the transfer guarantees the price is kept the same.
// If the order of transfer -> mint is changed to mint -> transfer, the reentrancy could be triggered on an
// intermediate state in which the ratio of assets/shares has been decreased (more shares than assets).
it('correct share price is observed during reentrancy before deposit', async function () {
// mint token for deposit
await this.token.$_mint(this.token, reenterValue);
// Schedules a reentrancy from the token contract
await this.token.scheduleReenter(
reenterType.Before,
this.vault,
this.vault.interface.encodeFunctionData('deposit', [reenterValue, this.holder.address]),
);
// Initial share price
const sharesForDeposit = await this.vault.previewDeposit(value);
const sharesForReenter = await this.vault.previewDeposit(reenterValue);
await expect(this.vault.connect(this.holder).deposit(value, this.holder))
// Deposit normally, reentering before the internal `_update`
.to.emit(this.vault, 'Deposit')
.withArgs(this.holder, this.holder, value, sharesForDeposit)
// Reentrant deposit event → uses the same price
.to.emit(this.vault, 'Deposit')
.withArgs(this.token, this.holder, reenterValue, sharesForReenter);
// Assert prices is kept
expect(await this.vault.previewDeposit(value)).to.equal(sharesForDeposit);
});
// During a `_withdraw`, the vault does `_burn(owner, shares)` -> `transfer(receiver, assets)`
// such that a reentrancy AFTER the transfer guarantees the price is kept the same.
// If the order of burn -> transfer is changed to transfer -> burn, the reentrancy could be triggered on an
// intermediate state in which the ratio of shares/assets has been decreased (more assets than shares).
it('correct share price is observed during reentrancy after withdraw', async function () {
// Deposit into the vault: holder gets `value` share, token.address gets `reenterValue` shares
await this.vault.connect(this.holder).deposit(value, this.holder);
await this.vault.connect(this.other).deposit(reenterValue, this.token);
// Schedules a reentrancy from the token contract
await this.token.scheduleReenter(
reenterType.After,
this.vault,
this.vault.interface.encodeFunctionData('withdraw', [reenterValue, this.holder.address, this.token.target]),
);
// Initial share price
const sharesForWithdraw = await this.vault.previewWithdraw(value);
const sharesForReenter = await this.vault.previewWithdraw(reenterValue);
// Do withdraw normally, triggering the _afterTokenTransfer hook
await expect(this.vault.connect(this.holder).withdraw(value, this.holder, this.holder))
// Main withdraw event
.to.emit(this.vault, 'Withdraw')
.withArgs(this.holder, this.holder, this.holder, value, sharesForWithdraw)
// Reentrant withdraw event → uses the same price
.to.emit(this.vault, 'Withdraw')
.withArgs(this.token, this.holder, this.token, reenterValue, sharesForReenter);
// Assert price is kept
expect(await this.vault.previewWithdraw(value)).to.equal(sharesForWithdraw);
});
// Donate newly minted tokens to the vault during the reentracy causes the share price to increase.
// Still, the deposit that trigger the reentracy is not affected and get the previewed price.
// Further deposits will get a different price (getting fewer shares for the same value of assets)
it('share price change during reentracy does not affect deposit', async function () {
// Schedules a reentrancy from the token contract that mess up the share price
await this.token.scheduleReenter(
reenterType.Before,
this.token,
this.token.interface.encodeFunctionData('$_mint', [this.vault.target, reenterValue]),
);
// Price before
const sharesBefore = await this.vault.previewDeposit(value);
// Deposit, reentering before the internal `_update`
await expect(this.vault.connect(this.holder).deposit(value, this.holder))
// Price is as previewed
.to.emit(this.vault, 'Deposit')
.withArgs(this.holder, this.holder, value, sharesBefore);
// Price was modified during reentrancy
expect(await this.vault.previewDeposit(value)).to.lt(sharesBefore);
});
// Burn some tokens from the vault during the reentracy causes the share price to drop.
// Still, the withdraw that trigger the reentracy is not affected and get the previewed price.
// Further withdraw will get a different price (needing more shares for the same value of assets)
it('share price change during reentracy does not affect withdraw', async function () {
await this.vault.connect(this.holder).deposit(value, this.holder);
await this.vault.connect(this.other).deposit(value, this.other);
// Schedules a reentrancy from the token contract that mess up the share price
await this.token.scheduleReenter(
reenterType.After,
this.token,
this.token.interface.encodeFunctionData('$_burn', [this.vault.target, reenterValue]),
);
// Price before
const sharesBefore = await this.vault.previewWithdraw(value);
// Withdraw, triggering the _afterTokenTransfer hook
await expect(this.vault.connect(this.holder).withdraw(value, this.holder, this.holder))
// Price is as previewed
.to.emit(this.vault, 'Withdraw')
.withArgs(this.holder, this.holder, this.holder, value, sharesBefore);
// Price was modified during reentrancy
expect(await this.vault.previewWithdraw(value)).to.gt(sharesBefore);
});
});
describe('limits', function () {
beforeEach(async function () {
const token = await ethers.deployContract('$ERC20DecimalsMock', [name, symbol, decimals]);
const vault = await ethers.deployContract('$ERC4626LimitsMock', ['', '', token]);
Object.assign(this, { token, vault });
});
it('reverts on deposit() above max deposit', async function () {
const maxDeposit = await this.vault.maxDeposit(this.holder);
await expect(this.vault.connect(this.holder).deposit(maxDeposit + 1n, this.recipient))
.to.be.revertedWithCustomError(this.vault, 'ERC4626ExceededMaxDeposit')
.withArgs(this.recipient, maxDeposit + 1n, maxDeposit);
});
it('reverts on mint() above max mint', async function () {
const maxMint = await this.vault.maxMint(this.holder);
await expect(this.vault.connect(this.holder).mint(maxMint + 1n, this.recipient))
.to.be.revertedWithCustomError(this.vault, 'ERC4626ExceededMaxMint')
.withArgs(this.recipient, maxMint + 1n, maxMint);
});
it('reverts on withdraw() above max withdraw', async function () {
const maxWithdraw = await this.vault.maxWithdraw(this.holder);
await expect(this.vault.connect(this.holder).withdraw(maxWithdraw + 1n, this.recipient, this.holder))
.to.be.revertedWithCustomError(this.vault, 'ERC4626ExceededMaxWithdraw')
.withArgs(this.holder, maxWithdraw + 1n, maxWithdraw);
});
it('reverts on redeem() above max redeem', async function () {
const maxRedeem = await this.vault.maxRedeem(this.holder);
await expect(this.vault.connect(this.holder).redeem(maxRedeem + 1n, this.recipient, this.holder))
.to.be.revertedWithCustomError(this.vault, 'ERC4626ExceededMaxRedeem')
.withArgs(this.holder, maxRedeem + 1n, maxRedeem);
});
});
for (const offset of [0n, 6n, 18n]) {
const parseToken = token => token * 10n ** decimals;
const parseShare = share => share * 10n ** (decimals + offset);
const virtualAssets = 1n;
const virtualShares = 10n ** offset;
describe(`offset: ${offset}`, function () {
beforeEach(async function () {
const token = await ethers.deployContract('$ERC20DecimalsMock', [name, symbol, decimals]);
const vault = await ethers.deployContract('$ERC4626OffsetMock', [name + ' Vault', symbol + 'V', token, offset]);
await token.$_mint(this.holder, ethers.MaxUint256 / 2n); // 50% of maximum
await token.$_approve(this.holder, vault, ethers.MaxUint256);
await vault.$_approve(this.holder, this.spender, ethers.MaxUint256);
Object.assign(this, { token, vault });
});
it('metadata', async function () {
expect(await this.vault.name()).to.equal(name + ' Vault');
expect(await this.vault.symbol()).to.equal(symbol + 'V');
expect(await this.vault.decimals()).to.equal(decimals + offset);
expect(await this.vault.asset()).to.equal(this.token);
});
describe('empty vault: no assets & no shares', function () {
it('status', async function () {
expect(await this.vault.totalAssets()).to.equal(0n);
});
it('deposit', async function () {
expect(await this.vault.maxDeposit(this.holder)).to.equal(ethers.MaxUint256);
expect(await this.vault.previewDeposit(parseToken(1n))).to.equal(parseShare(1n));
const tx = this.vault.connect(this.holder).deposit(parseToken(1n), this.recipient);
await expect(tx).to.changeTokenBalances(
this.token,
[this.holder, this.vault],
[-parseToken(1n), parseToken(1n)],
);
await expect(tx).to.changeTokenBalance(this.vault, this.recipient, parseShare(1n));
await expect(tx)
.to.emit(this.token, 'Transfer')
.withArgs(this.holder, this.vault, parseToken(1n))
.to.emit(this.vault, 'Transfer')
.withArgs(ethers.ZeroAddress, this.recipient, parseShare(1n))
.to.emit(this.vault, 'Deposit')
.withArgs(this.holder, this.recipient, parseToken(1n), parseShare(1n));
});
it('mint', async function () {
expect(await this.vault.maxMint(this.holder)).to.equal(ethers.MaxUint256);
expect(await this.vault.previewMint(parseShare(1n))).to.equal(parseToken(1n));
const tx = this.vault.connect(this.holder).mint(parseShare(1n), this.recipient);
await expect(tx).to.changeTokenBalances(
this.token,
[this.holder, this.vault],
[-parseToken(1n), parseToken(1n)],
);
await expect(tx).to.changeTokenBalance(this.vault, this.recipient, parseShare(1n));
await expect(tx)
.to.emit(this.token, 'Transfer')
.withArgs(this.holder, this.vault, parseToken(1n))
.to.emit(this.vault, 'Transfer')
.withArgs(ethers.ZeroAddress, this.recipient, parseShare(1n))
.to.emit(this.vault, 'Deposit')
.withArgs(this.holder, this.recipient, parseToken(1n), parseShare(1n));
});
it('withdraw', async function () {
expect(await this.vault.maxWithdraw(this.holder)).to.equal(0n);
expect(await this.vault.previewWithdraw(0n)).to.equal(0n);
const tx = this.vault.connect(this.holder).withdraw(0n, this.recipient, this.holder);
await expect(tx).to.changeTokenBalances(this.token, [this.vault, this.recipient], [0n, 0n]);
await expect(tx).to.changeTokenBalance(this.vault, this.holder, 0n);
await expect(tx)
.to.emit(this.token, 'Transfer')
.withArgs(this.vault, this.recipient, 0n)
.to.emit(this.vault, 'Transfer')
.withArgs(this.holder, ethers.ZeroAddress, 0n)
.to.emit(this.vault, 'Withdraw')
.withArgs(this.holder, this.recipient, this.holder, 0n, 0n);
});
it('redeem', async function () {
expect(await this.vault.maxRedeem(this.holder)).to.equal(0n);
expect(await this.vault.previewRedeem(0n)).to.equal(0n);
const tx = this.vault.connect(this.holder).redeem(0n, this.recipient, this.holder);
await expect(tx).to.changeTokenBalances(this.token, [this.vault, this.recipient], [0n, 0n]);
await expect(tx).to.changeTokenBalance(this.vault, this.holder, 0n);
await expect(tx)
.to.emit(this.token, 'Transfer')
.withArgs(this.vault, this.recipient, 0n)
.to.emit(this.vault, 'Transfer')
.withArgs(this.holder, ethers.ZeroAddress, 0n)
.to.emit(this.vault, 'Withdraw')
.withArgs(this.holder, this.recipient, this.holder, 0n, 0n);
});
});
describe('inflation attack: offset price by direct deposit of assets', function () {
beforeEach(async function () {
// Donate 1 token to the vault to offset the price
await this.token.$_mint(this.vault, parseToken(1n));
});
it('status', async function () {
expect(await this.vault.totalSupply()).to.equal(0n);
expect(await this.vault.totalAssets()).to.equal(parseToken(1n));
});
/**
* | offset | deposited assets | redeemable assets |
* |--------|----------------------|----------------------|
* | 0 | 1.000000000000000000 | 0. |
* | 6 | 1.000000000000000000 | 0.999999000000000000 |
* | 18 | 1.000000000000000000 | 0.999999999999999999 |
*
* Attack is possible, but made difficult by the offset. For the attack to be successful
* the attacker needs to frontrun a deposit 10**offset times bigger than what the victim
* was trying to deposit
*/
it('deposit', async function () {
const effectiveAssets = (await this.vault.totalAssets()) + virtualAssets;
const effectiveShares = (await this.vault.totalSupply()) + virtualShares;
const depositAssets = parseToken(1n);
const expectedShares = (depositAssets * effectiveShares) / effectiveAssets;
expect(await this.vault.maxDeposit(this.holder)).to.equal(ethers.MaxUint256);
expect(await this.vault.previewDeposit(depositAssets)).to.equal(expectedShares);
const tx = this.vault.connect(this.holder).deposit(depositAssets, this.recipient);
await expect(tx).to.changeTokenBalances(
this.token,
[this.holder, this.vault],
[-depositAssets, depositAssets],
);
await expect(tx).to.changeTokenBalance(this.vault, this.recipient, expectedShares);
await expect(tx)
.to.emit(this.token, 'Transfer')
.withArgs(this.holder, this.vault, depositAssets)
.to.emit(this.vault, 'Transfer')
.withArgs(ethers.ZeroAddress, this.recipient, expectedShares)
.to.emit(this.vault, 'Deposit')
.withArgs(this.holder, this.recipient, depositAssets, expectedShares);
});
/**
* | offset | deposited assets | redeemable assets |
* |--------|----------------------|----------------------|
* | 0 | 1000000000000000001. | 1000000000000000001. |
* | 6 | 1000000000000000001. | 1000000000000000001. |
* | 18 | 1000000000000000001. | 1000000000000000001. |
*
* Using mint protects against inflation attack, but makes minting shares very expensive.
* The ER20 allowance for the underlying asset is needed to protect the user from (too)
* large deposits.
*/
it('mint', async function () {
const effectiveAssets = (await this.vault.totalAssets()) + virtualAssets;
const effectiveShares = (await this.vault.totalSupply()) + virtualShares;
const mintShares = parseShare(1n);
const expectedAssets = (mintShares * effectiveAssets) / effectiveShares;
expect(await this.vault.maxMint(this.holder)).to.equal(ethers.MaxUint256);
expect(await this.vault.previewMint(mintShares)).to.equal(expectedAssets);
const tx = this.vault.connect(this.holder).mint(mintShares, this.recipient);
await expect(tx).to.changeTokenBalances(
this.token,
[this.holder, this.vault],
[-expectedAssets, expectedAssets],
);
await expect(tx).to.changeTokenBalance(this.vault, this.recipient, mintShares);
await expect(tx)
.to.emit(this.token, 'Transfer')
.withArgs(this.holder, this.vault, expectedAssets)
.to.emit(this.vault, 'Transfer')
.withArgs(ethers.ZeroAddress, this.recipient, mintShares)
.to.emit(this.vault, 'Deposit')
.withArgs(this.holder, this.recipient, expectedAssets, mintShares);
});
it('withdraw', async function () {
expect(await this.vault.maxWithdraw(this.holder)).to.equal(0n);
expect(await this.vault.previewWithdraw(0n)).to.equal(0n);
const tx = this.vault.connect(this.holder).withdraw(0n, this.recipient, this.holder);
await expect(tx).to.changeTokenBalances(this.token, [this.vault, this.recipient], [0n, 0n]);
await expect(tx).to.changeTokenBalance(this.vault, this.holder, 0n);
await expect(tx)
.to.emit(this.token, 'Transfer')
.withArgs(this.vault, this.recipient, 0n)
.to.emit(this.vault, 'Transfer')
.withArgs(this.holder, ethers.ZeroAddress, 0n)
.to.emit(this.vault, 'Withdraw')
.withArgs(this.holder, this.recipient, this.holder, 0n, 0n);
});
it('redeem', async function () {
expect(await this.vault.maxRedeem(this.holder)).to.equal(0n);
expect(await this.vault.previewRedeem(0n)).to.equal(0n);
const tx = this.vault.connect(this.holder).redeem(0n, this.recipient, this.holder);
await expect(tx).to.changeTokenBalances(this.token, [this.vault, this.recipient], [0n, 0n]);
await expect(tx).to.changeTokenBalance(this.vault, this.holder, 0n);
await expect(tx)
.to.emit(this.token, 'Transfer')
.withArgs(this.vault, this.recipient, 0n)
.to.emit(this.vault, 'Transfer')
.withArgs(this.holder, ethers.ZeroAddress, 0n)
.to.emit(this.vault, 'Withdraw')
.withArgs(this.holder, this.recipient, this.holder, 0n, 0n);
});
});
describe('full vault: assets & shares', function () {
beforeEach(async function () {
// Add 1 token of underlying asset and 100 shares to the vault
await this.token.$_mint(this.vault, parseToken(1n));
await this.vault.$_mint(this.holder, parseShare(100n));
});
it('status', async function () {
expect(await this.vault.totalSupply()).to.equal(parseShare(100n));
expect(await this.vault.totalAssets()).to.equal(parseToken(1n));
});
/**
* | offset | deposited assets | redeemable assets |
* |--------|--------------------- |----------------------|
* | 0 | 1.000000000000000000 | 0.999999999999999999 |
* | 6 | 1.000000000000000000 | 0.999999999999999999 |
* | 18 | 1.000000000000000000 | 0.999999999999999999 |
*
* Virtual shares & assets captures part of the value
*/
it('deposit', async function () {
const effectiveAssets = (await this.vault.totalAssets()) + virtualAssets;
const effectiveShares = (await this.vault.totalSupply()) + virtualShares;
const depositAssets = parseToken(1n);
const expectedShares = (depositAssets * effectiveShares) / effectiveAssets;
expect(await this.vault.maxDeposit(this.holder)).to.equal(ethers.MaxUint256);
expect(await this.vault.previewDeposit(depositAssets)).to.equal(expectedShares);
const tx = this.vault.connect(this.holder).deposit(depositAssets, this.recipient);
await expect(tx).to.changeTokenBalances(
this.token,
[this.holder, this.vault],
[-depositAssets, depositAssets],
);
await expect(tx).to.changeTokenBalance(this.vault, this.recipient, expectedShares);
await expect(tx)
.to.emit(this.token, 'Transfer')
.withArgs(this.holder, this.vault, depositAssets)
.to.emit(this.vault, 'Transfer')
.withArgs(ethers.ZeroAddress, this.recipient, expectedShares)
.to.emit(this.vault, 'Deposit')
.withArgs(this.holder, this.recipient, depositAssets, expectedShares);
});
/**
* | offset | deposited assets | redeemable assets |
* |--------|--------------------- |----------------------|
* | 0 | 0.010000000000000001 | 0.010000000000000000 |
* | 6 | 0.010000000000000001 | 0.010000000000000000 |
* | 18 | 0.010000000000000001 | 0.010000000000000000 |
*
* Virtual shares & assets captures part of the value
*/
it('mint', async function () {
const effectiveAssets = (await this.vault.totalAssets()) + virtualAssets;
const effectiveShares = (await this.vault.totalSupply()) + virtualShares;
const mintShares = parseShare(1n);
const expectedAssets = (mintShares * effectiveAssets) / effectiveShares + 1n; // add for the rounding
expect(await this.vault.maxMint(this.holder)).to.equal(ethers.MaxUint256);
expect(await this.vault.previewMint(mintShares)).to.equal(expectedAssets);
const tx = this.vault.connect(this.holder).mint(mintShares, this.recipient);
await expect(tx).to.changeTokenBalances(
this.token,
[this.holder, this.vault],
[-expectedAssets, expectedAssets],
);
await expect(tx).to.changeTokenBalance(this.vault, this.recipient, mintShares);
await expect(tx)
.to.emit(this.token, 'Transfer')
.withArgs(this.holder, this.vault, expectedAssets)
.to.emit(this.vault, 'Transfer')
.withArgs(ethers.ZeroAddress, this.recipient, mintShares)
.to.emit(this.vault, 'Deposit')
.withArgs(this.holder, this.recipient, expectedAssets, mintShares);
});
it('withdraw', async function () {
const effectiveAssets = (await this.vault.totalAssets()) + virtualAssets;
const effectiveShares = (await this.vault.totalSupply()) + virtualShares;
const withdrawAssets = parseToken(1n);
const expectedShares = (withdrawAssets * effectiveShares) / effectiveAssets + 1n; // add for the rounding
expect(await this.vault.maxWithdraw(this.holder)).to.equal(withdrawAssets);
expect(await this.vault.previewWithdraw(withdrawAssets)).to.equal(expectedShares);
const tx = this.vault.connect(this.holder).withdraw(withdrawAssets, this.recipient, this.holder);
await expect(tx).to.changeTokenBalances(
this.token,
[this.vault, this.recipient],
[-withdrawAssets, withdrawAssets],
);
await expect(tx).to.changeTokenBalance(this.vault, this.holder, -expectedShares);
await expect(tx)
.to.emit(this.token, 'Transfer')
.withArgs(this.vault, this.recipient, withdrawAssets)
.to.emit(this.vault, 'Transfer')
.withArgs(this.holder, ethers.ZeroAddress, expectedShares)
.to.emit(this.vault, 'Withdraw')
.withArgs(this.holder, this.recipient, this.holder, withdrawAssets, expectedShares);
});
it('withdraw with approval', async function () {
const assets = await this.vault.previewWithdraw(parseToken(1n));
await expect(this.vault.connect(this.other).withdraw(parseToken(1n), this.recipient, this.holder))
.to.be.revertedWithCustomError(this.vault, 'ERC20InsufficientAllowance')
.withArgs(this.other, 0n, assets);
await expect(this.vault.connect(this.spender).withdraw(parseToken(1n), this.recipient, this.holder)).to.not.be
.reverted;
});
it('redeem', async function () {
const effectiveAssets = (await this.vault.totalAssets()) + virtualAssets;
const effectiveShares = (await this.vault.totalSupply()) + virtualShares;
const redeemShares = parseShare(100n);
const expectedAssets = (redeemShares * effectiveAssets) / effectiveShares;
expect(await this.vault.maxRedeem(this.holder)).to.equal(redeemShares);
expect(await this.vault.previewRedeem(redeemShares)).to.equal(expectedAssets);
const tx = this.vault.connect(this.holder).redeem(redeemShares, this.recipient, this.holder);
await expect(tx).to.changeTokenBalances(
this.token,
[this.vault, this.recipient],
[-expectedAssets, expectedAssets],
);
await expect(tx).to.changeTokenBalance(this.vault, this.holder, -redeemShares);
await expect(tx)
.to.emit(this.token, 'Transfer')
.withArgs(this.vault, this.recipient, expectedAssets)
.to.emit(this.vault, 'Transfer')
.withArgs(this.holder, ethers.ZeroAddress, redeemShares)
.to.emit(this.vault, 'Withdraw')
.withArgs(this.holder, this.recipient, this.holder, expectedAssets, redeemShares);
});
it('redeem with approval', async function () {
await expect(this.vault.connect(this.other).redeem(parseShare(100n), this.recipient, this.holder))
.to.be.revertedWithCustomError(this.vault, 'ERC20InsufficientAllowance')
.withArgs(this.other, 0n, parseShare(100n));
await expect(this.vault.connect(this.spender).redeem(parseShare(100n), this.recipient, this.holder)).to.not.be
.reverted;
});
});
});
}
describe('ERC4626Fees', function () {
const feeBasisPoints = 500n; // 5%
const valueWithoutFees = 10_000n;
const fees = (valueWithoutFees * feeBasisPoints) / 10_000n;
const valueWithFees = valueWithoutFees + fees;
describe('input fees', function () {
beforeEach(async function () {
const token = await ethers.deployContract('$ERC20DecimalsMock', [name, symbol, 18n]);
const vault = await ethers.deployContract('$ERC4626FeesMock', [
'',
'',
token,
feeBasisPoints,
this.other,
0n,
ethers.ZeroAddress,
]);
await token.$_mint(this.holder, ethers.MaxUint256 / 2n);
await token.$_approve(this.holder, vault, ethers.MaxUint256 / 2n);
Object.assign(this, { token, vault });
});
it('deposit', async function () {
expect(await this.vault.previewDeposit(valueWithFees)).to.equal(valueWithoutFees);
this.tx = this.vault.connect(this.holder).deposit(valueWithFees, this.recipient);
});
it('mint', async function () {
expect(await this.vault.previewMint(valueWithoutFees)).to.equal(valueWithFees);
this.tx = this.vault.connect(this.holder).mint(valueWithoutFees, this.recipient);
});
afterEach(async function () {
await expect(this.tx).to.changeTokenBalances(
this.token,
[this.holder, this.vault, this.other],
[-valueWithFees, valueWithoutFees, fees],
);
await expect(this.tx).to.changeTokenBalance(this.vault, this.recipient, valueWithoutFees);
await expect(this.tx)
// get total
.to.emit(this.token, 'Transfer')
.withArgs(this.holder, this.vault, valueWithFees)
// redirect fees
.to.emit(this.token, 'Transfer')
.withArgs(this.vault, this.other, fees)
// mint shares
.to.emit(this.vault, 'Transfer')
.withArgs(ethers.ZeroAddress, this.recipient, valueWithoutFees)
// deposit event
.to.emit(this.vault, 'Deposit')
.withArgs(this.holder, this.recipient, valueWithFees, valueWithoutFees);
});
});
describe('output fees', function () {
beforeEach(async function () {
const token = await ethers.deployContract('$ERC20DecimalsMock', [name, symbol, 18n]);
const vault = await ethers.deployContract('$ERC4626FeesMock', [
'',
'',
token,
0n,
ethers.ZeroAddress,
feeBasisPoints,
this.other,
]);
await token.$_mint(vault, ethers.MaxUint256 / 2n);
await vault.$_mint(this.holder, ethers.MaxUint256 / 2n);
Object.assign(this, { token, vault });
});
it('redeem', async function () {
expect(await this.vault.previewRedeem(valueWithFees)).to.equal(valueWithoutFees);
this.tx = this.vault.connect(this.holder).redeem(valueWithFees, this.recipient, this.holder);
});
it('withdraw', async function () {
expect(await this.vault.previewWithdraw(valueWithoutFees)).to.equal(valueWithFees);
this.tx = this.vault.connect(this.holder).withdraw(valueWithoutFees, this.recipient, this.holder);
});
afterEach(async function () {
await expect(this.tx).to.changeTokenBalances(
this.token,
[this.vault, this.recipient, this.other],
[-valueWithFees, valueWithoutFees, fees],
);
await expect(this.tx).to.changeTokenBalance(this.vault, this.holder, -valueWithFees);
await expect(this.tx)
// withdraw principal
.to.emit(this.token, 'Transfer')
.withArgs(this.vault, this.recipient, valueWithoutFees)
// redirect fees
.to.emit(this.token, 'Transfer')
.withArgs(this.vault, this.other, fees)
// mint shares
.to.emit(this.vault, 'Transfer')
.withArgs(this.holder, ethers.ZeroAddress, valueWithFees)
// withdraw event
.to.emit(this.vault, 'Withdraw')
.withArgs(this.holder, this.recipient, this.holder, valueWithoutFees, valueWithFees);
});
});
});
/// Scenario inspired by solmate ERC4626 tests:
/// https://github.com/transmissions11/solmate/blob/main/src/test/ERC4626.t.sol
it('multiple mint, deposit, redeem & withdrawal', async function () {
// test designed with both asset using similar decimals
const [alice, bruce] = this.accounts;
const token = await ethers.deployContract('$ERC20DecimalsMock', [name, symbol, 18n]);
const vault = await ethers.deployContract('$ERC4626', ['', '', token]);
await token.$_mint(alice, 4000n);
await token.$_mint(bruce, 7001n);
await token.connect(alice).approve(vault, 4000n);
await token.connect(bruce).approve(vault, 7001n);
// 1. Alice mints 2000 shares (costs 2000 tokens)
await expect(vault.connect(alice).mint(2000n, alice))
.to.emit(token, 'Transfer')
.withArgs(alice, vault, 2000n)
.to.emit(vault, 'Transfer')
.withArgs(ethers.ZeroAddress, alice, 2000n);
expect(await vault.previewDeposit(2000n)).to.equal(2000n);
expect(await vault.balanceOf(alice)).to.equal(2000n);
expect(await vault.balanceOf(bruce)).to.equal(0n);
expect(await vault.convertToAssets(await vault.balanceOf(alice))).to.equal(2000n);
expect(await vault.convertToAssets(await vault.balanceOf(bruce))).to.equal(0n);
expect(await vault.convertToShares(await token.balanceOf(vault))).to.equal(2000n);
expect(await vault.totalSupply()).to.equal(2000n);
expect(await vault.totalAssets()).to.equal(2000n);
// 2. Bruce deposits 4000 tokens (mints 4000 shares)
await expect(vault.connect(bruce).mint(4000n, bruce))
.to.emit(token, 'Transfer')
.withArgs(bruce, vault, 4000n)
.to.emit(vault, 'Transfer')
.withArgs(ethers.ZeroAddress, bruce, 4000n);
expect(await vault.previewDeposit(4000n)).to.equal(4000n);
expect(await vault.balanceOf(alice)).to.equal(2000n);
expect(await vault.balanceOf(bruce)).to.equal(4000n);
expect(await vault.convertToAssets(await vault.balanceOf(alice))).to.equal(2000n);
expect(await vault.convertToAssets(await vault.balanceOf(bruce))).to.equal(4000n);
expect(await vault.convertToShares(await token.balanceOf(vault))).to.equal(6000n);
expect(await vault.totalSupply()).to.equal(6000n);
expect(await vault.totalAssets()).to.equal(6000n);
// 3. Vault mutates by +3000 tokens (simulated yield returned from strategy)
await token.$_mint(vault, 3000n);
expect(await vault.balanceOf(alice)).to.equal(2000n);
expect(await vault.balanceOf(bruce)).to.equal(4000n);
expect(await vault.convertToAssets(await vault.balanceOf(alice))).to.equal(2999n); // used to be 3000, but virtual assets/shares captures part of the yield
expect(await vault.convertToAssets(await vault.balanceOf(bruce))).to.equal(5999n); // used to be 6000, but virtual assets/shares captures part of the yield
expect(await vault.convertToShares(await token.balanceOf(vault))).to.equal(6000n);
expect(await vault.totalSupply()).to.equal(6000n);
expect(await vault.totalAssets()).to.equal(9000n);
// 4. Alice deposits 2000 tokens (mints 1333 shares)
await expect(vault.connect(alice).deposit(2000n, alice))
.to.emit(token, 'Transfer')
.withArgs(alice, vault, 2000n)
.to.emit(vault, 'Transfer')
.withArgs(ethers.ZeroAddress, alice, 1333n);
expect(await vault.balanceOf(alice)).to.equal(3333n);
expect(await vault.balanceOf(bruce)).to.equal(4000n);
expect(await vault.convertToAssets(await vault.balanceOf(alice))).to.equal(4999n);
expect(await vault.convertToAssets(await vault.balanceOf(bruce))).to.equal(6000n);
expect(await vault.convertToShares(await token.balanceOf(vault))).to.equal(7333n);
expect(await vault.totalSupply()).to.equal(7333n);
expect(await vault.totalAssets()).to.equal(11000n);
// 5. Bruce mints 2000 shares (costs 3001 assets)
// NOTE: Bruce's assets spent got rounded towards infinity
// NOTE: Alices's vault assets got rounded towards infinity
await expect(vault.connect(bruce).mint(2000n, bruce))
.to.emit(token, 'Transfer')
.withArgs(bruce, vault, 3000n)
.to.emit(vault, 'Transfer')
.withArgs(ethers.ZeroAddress, bruce, 2000n);
expect(await vault.balanceOf(alice)).to.equal(3333n);
expect(await vault.balanceOf(bruce)).to.equal(6000n);
expect(await vault.convertToAssets(await vault.balanceOf(alice))).to.equal(4999n); // used to be 5000
expect(await vault.convertToAssets(await vault.balanceOf(bruce))).to.equal(9000n);
expect(await vault.convertToShares(await token.balanceOf(vault))).to.equal(9333n);
expect(await vault.totalSupply()).to.equal(9333n);
expect(await vault.totalAssets()).to.equal(14000n); // used to be 14001
// 6. Vault mutates by +3000 tokens
// NOTE: Vault holds 17001 tokens, but sum of assetsOf() is 17000.
await token.$_mint(vault, 3000n);
expect(await vault.balanceOf(alice)).to.equal(3333n);
expect(await vault.balanceOf(bruce)).to.equal(6000n);
expect(await vault.convertToAssets(await vault.balanceOf(alice))).to.equal(6070n); // used to be 6071
expect(await vault.convertToAssets(await vault.balanceOf(bruce))).to.equal(10928n); // used to be 10929
expect(await vault.convertToShares(await token.balanceOf(vault))).to.equal(9333n);
expect(await vault.totalSupply()).to.equal(9333n);
expect(await vault.totalAssets()).to.equal(17000n); // used to be 17001
// 7. Alice redeem 1333 shares (2428 assets)
await expect(vault.connect(alice).redeem(1333n, alice, alice))
.to.emit(vault, 'Transfer')
.withArgs(alice, ethers.ZeroAddress, 1333n)
.to.emit(token, 'Transfer')
.withArgs(vault, alice, 2427n); // used to be 2428
expect(await vault.balanceOf(alice)).to.equal(2000n);
expect(await vault.balanceOf(bruce)).to.equal(6000n);
expect(await vault.convertToAssets(await vault.balanceOf(alice))).to.equal(3643n);
expect(await vault.convertToAssets(await vault.balanceOf(bruce))).to.equal(10929n);
expect(await vault.convertToShares(await token.balanceOf(vault))).to.equal(8000n);
expect(await vault.totalSupply()).to.equal(8000n);
expect(await vault.totalAssets()).to.equal(14573n);
// 8. Bruce withdraws 2929 assets (1608 shares)
await expect(vault.connect(bruce).withdraw(2929n, bruce, bruce))
.to.emit(vault, 'Transfer')
.withArgs(bruce, ethers.ZeroAddress, 1608n)
.to.emit(token, 'Transfer')
.withArgs(vault, bruce, 2929n);
expect(await vault.balanceOf(alice)).to.equal(2000n);
expect(await vault.balanceOf(bruce)).to.equal(4392n);
expect(await vault.convertToAssets(await vault.balanceOf(alice))).to.equal(3643n);
expect(await vault.convertToAssets(await vault.balanceOf(bruce))).to.equal(8000n);
expect(await vault.convertToShares(await token.balanceOf(vault))).to.equal(6392n);
expect(await vault.totalSupply()).to.equal(6392n);
expect(await vault.totalAssets()).to.equal(11644n);
// 9. Alice withdraws 3643 assets (2000 shares)
// NOTE: Bruce's assets have been rounded back towards infinity
await expect(vault.connect(alice).withdraw(3643n, alice, alice))
.to.emit(vault, 'Transfer')
.withArgs(alice, ethers.ZeroAddress, 2000n)
.to.emit(token, 'Transfer')
.withArgs(vault, alice, 3643n);
expect(await vault.balanceOf(alice)).to.equal(0n);
expect(await vault.balanceOf(bruce)).to.equal(4392n);
expect(await vault.convertToAssets(await vault.balanceOf(alice))).to.equal(0n);
expect(await vault.convertToAssets(await vault.balanceOf(bruce))).to.equal(8000n); // used to be 8001
expect(await vault.convertToShares(await token.balanceOf(vault))).to.equal(4392n);
expect(await vault.totalSupply()).to.equal(4392n);
expect(await vault.totalAssets()).to.equal(8001n);
// 10. Bruce redeem 4392 shares (8001 tokens)
await expect(vault.connect(bruce).redeem(4392n, bruce, bruce))
.to.emit(vault, 'Transfer')
.withArgs(bruce, ethers.ZeroAddress, 4392n)
.to.emit(token, 'Transfer')
.withArgs(vault, bruce, 8000n); // used to be 8001
expect(await vault.balanceOf(alice)).to.equal(0n);
expect(await vault.balanceOf(bruce)).to.equal(0n);
expect(await vault.convertToAssets(await vault.balanceOf(alice))).to.equal(0n);
expect(await vault.convertToAssets(await vault.balanceOf(bruce))).to.equal(0n);
expect(await vault.convertToShares(await token.balanceOf(vault))).to.equal(0n);
expect(await vault.totalSupply()).to.equal(0n);
expect(await vault.totalAssets()).to.equal(1n); // used to be 0
});
});

View File

@@ -0,0 +1,427 @@
const { ethers } = require('hardhat');
const { expect } = require('chai');
const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers');
const name = 'ERC20Mock';
const symbol = 'ERC20Mock';
const value = 100n;
const data = '0x12345678';
async function fixture() {
const [hasNoCode, owner, receiver, spender, other] = await ethers.getSigners();
const mock = await ethers.deployContract('$SafeERC20');
const erc20ReturnFalseMock = await ethers.deployContract('$ERC20ReturnFalseMock', [name, symbol]);
const erc20ReturnTrueMock = await ethers.deployContract('$ERC20', [name, symbol]); // default implementation returns true
const erc20NoReturnMock = await ethers.deployContract('$ERC20NoReturnMock', [name, symbol]);
const erc20ForceApproveMock = await ethers.deployContract('$ERC20ForceApproveMock', [name, symbol]);
const erc1363Mock = await ethers.deployContract('$ERC1363', [name, symbol]);
const erc1363ReturnFalseOnErc20Mock = await ethers.deployContract('$ERC1363ReturnFalseOnERC20Mock', [name, symbol]);
const erc1363ReturnFalseMock = await ethers.deployContract('$ERC1363ReturnFalseMock', [name, symbol]);
const erc1363NoReturnMock = await ethers.deployContract('$ERC1363NoReturnMock', [name, symbol]);
const erc1363ForceApproveMock = await ethers.deployContract('$ERC1363ForceApproveMock', [name, symbol]);
const erc1363Receiver = await ethers.deployContract('$ERC1363ReceiverMock');
const erc1363Spender = await ethers.deployContract('$ERC1363SpenderMock');
return {
hasNoCode,
owner,
receiver,
spender,
other,
mock,
erc20ReturnFalseMock,
erc20ReturnTrueMock,
erc20NoReturnMock,
erc20ForceApproveMock,
erc1363Mock,
erc1363ReturnFalseOnErc20Mock,
erc1363ReturnFalseMock,
erc1363NoReturnMock,
erc1363ForceApproveMock,
erc1363Receiver,
erc1363Spender,
};
}
describe('SafeERC20', function () {
before(async function () {
Object.assign(this, await loadFixture(fixture));
});
describe('with address that has no contract code', function () {
beforeEach(async function () {
this.token = this.hasNoCode;
});
it('reverts on transfer', async function () {
await expect(this.mock.$safeTransfer(this.token, this.receiver, 0n))
.to.be.revertedWithCustomError(this.mock, 'AddressEmptyCode')
.withArgs(this.token);
});
it('reverts on transferFrom', async function () {
await expect(this.mock.$safeTransferFrom(this.token, this.mock, this.receiver, 0n))
.to.be.revertedWithCustomError(this.mock, 'AddressEmptyCode')
.withArgs(this.token);
});
it('reverts on increaseAllowance', async function () {
// Call to 'token.allowance' does not return any data, resulting in a decoding error (revert without reason)
await expect(this.mock.$safeIncreaseAllowance(this.token, this.spender, 0n)).to.be.revertedWithoutReason();
});
it('reverts on decreaseAllowance', async function () {
// Call to 'token.allowance' does not return any data, resulting in a decoding error (revert without reason)
await expect(this.mock.$safeDecreaseAllowance(this.token, this.spender, 0n)).to.be.revertedWithoutReason();
});
it('reverts on forceApprove', async function () {
await expect(this.mock.$forceApprove(this.token, this.spender, 0n))
.to.be.revertedWithCustomError(this.mock, 'AddressEmptyCode')
.withArgs(this.token);
});
});
describe('with token that returns false on all calls', function () {
beforeEach(async function () {
this.token = this.erc20ReturnFalseMock;
});
it('reverts on transfer', async function () {
await expect(this.mock.$safeTransfer(this.token, this.receiver, 0n))
.to.be.revertedWithCustomError(this.mock, 'SafeERC20FailedOperation')
.withArgs(this.token);
});
it('reverts on transferFrom', async function () {
await expect(this.mock.$safeTransferFrom(this.token, this.mock, this.receiver, 0n))
.to.be.revertedWithCustomError(this.mock, 'SafeERC20FailedOperation')
.withArgs(this.token);
});
it('reverts on increaseAllowance', async function () {
await expect(this.mock.$safeIncreaseAllowance(this.token, this.spender, 0n))
.to.be.revertedWithCustomError(this.mock, 'SafeERC20FailedOperation')
.withArgs(this.token);
});
it('reverts on decreaseAllowance', async function () {
await expect(this.mock.$safeDecreaseAllowance(this.token, this.spender, 0n))
.to.be.revertedWithCustomError(this.mock, 'SafeERC20FailedOperation')
.withArgs(this.token);
});
it('reverts on forceApprove', async function () {
await expect(this.mock.$forceApprove(this.token, this.spender, 0n))
.to.be.revertedWithCustomError(this.mock, 'SafeERC20FailedOperation')
.withArgs(this.token);
});
});
describe('with token that returns true on all calls', function () {
beforeEach(async function () {
this.token = this.erc20ReturnTrueMock;
});
shouldOnlyRevertOnErrors();
});
describe('with token that returns no boolean values', function () {
beforeEach(async function () {
this.token = this.erc20NoReturnMock;
});
shouldOnlyRevertOnErrors();
});
describe('with usdt approval behaviour', function () {
beforeEach(async function () {
this.token = this.erc20ForceApproveMock;
});
describe('with initial approval', function () {
beforeEach(async function () {
await this.token.$_approve(this.mock, this.spender, 100n);
});
it('safeIncreaseAllowance works', async function () {
await this.mock.$safeIncreaseAllowance(this.token, this.spender, 10n);
expect(await this.token.allowance(this.mock, this.spender)).to.equal(110n);
});
it('safeDecreaseAllowance works', async function () {
await this.mock.$safeDecreaseAllowance(this.token, this.spender, 10n);
expect(await this.token.allowance(this.mock, this.spender)).to.equal(90n);
});
it('forceApprove works', async function () {
await this.mock.$forceApprove(this.token, this.spender, 200n);
expect(await this.token.allowance(this.mock, this.spender)).to.equal(200n);
});
});
});
describe('with standard ERC1363', function () {
beforeEach(async function () {
this.token = this.erc1363Mock;
});
shouldOnlyRevertOnErrors();
describe('transferAndCall', function () {
it('cannot transferAndCall to an EOA directly', async function () {
await this.token.$_mint(this.owner, 100n);
await expect(this.token.connect(this.owner).transferAndCall(this.receiver, value, ethers.Typed.bytes(data)))
.to.be.revertedWithCustomError(this.token, 'ERC1363InvalidReceiver')
.withArgs(this.receiver);
});
it('can transferAndCall to an EOA using helper', async function () {
await this.token.$_mint(this.mock, value);
await expect(this.mock.$transferAndCallRelaxed(this.token, this.receiver, value, data))
.to.emit(this.token, 'Transfer')
.withArgs(this.mock, this.receiver, value);
});
it('can transferAndCall to an ERC1363Receiver using helper', async function () {
await this.token.$_mint(this.mock, value);
await expect(this.mock.$transferAndCallRelaxed(this.token, this.erc1363Receiver, value, data))
.to.emit(this.token, 'Transfer')
.withArgs(this.mock, this.erc1363Receiver, value)
.to.emit(this.erc1363Receiver, 'Received')
.withArgs(this.mock, this.mock, value, data);
});
});
describe('transferFromAndCall', function () {
it('can transferFromAndCall to an EOA using helper', async function () {
await this.token.$_mint(this.owner, value);
await this.token.$_approve(this.owner, this.mock, ethers.MaxUint256);
await expect(this.mock.$transferFromAndCallRelaxed(this.token, this.owner, this.receiver, value, data))
.to.emit(this.token, 'Transfer')
.withArgs(this.owner, this.receiver, value);
});
it('can transferFromAndCall to an ERC1363Receiver using helper', async function () {
await this.token.$_mint(this.owner, value);
await this.token.$_approve(this.owner, this.mock, ethers.MaxUint256);
await expect(this.mock.$transferFromAndCallRelaxed(this.token, this.owner, this.erc1363Receiver, value, data))
.to.emit(this.token, 'Transfer')
.withArgs(this.owner, this.erc1363Receiver, value)
.to.emit(this.erc1363Receiver, 'Received')
.withArgs(this.mock, this.owner, value, data);
});
});
describe('approveAndCall', function () {
it('can approveAndCall to an EOA using helper', async function () {
await expect(this.mock.$approveAndCallRelaxed(this.token, this.receiver, value, data))
.to.emit(this.token, 'Approval')
.withArgs(this.mock, this.receiver, value);
});
it('can approveAndCall to an ERC1363Spender using helper', async function () {
await expect(this.mock.$approveAndCallRelaxed(this.token, this.erc1363Spender, value, data))
.to.emit(this.token, 'Approval')
.withArgs(this.mock, this.erc1363Spender, value)
.to.emit(this.erc1363Spender, 'Approved')
.withArgs(this.mock, value, data);
});
});
});
describe('with ERC1363 that returns false on all ERC20 calls', function () {
beforeEach(async function () {
this.token = this.erc1363ReturnFalseOnErc20Mock;
});
it('reverts on transferAndCallRelaxed', async function () {
await expect(this.mock.$transferAndCallRelaxed(this.token, this.erc1363Receiver, 0n, data))
.to.be.revertedWithCustomError(this.token, 'ERC1363TransferFailed')
.withArgs(this.erc1363Receiver, 0n);
});
it('reverts on transferFromAndCallRelaxed', async function () {
await expect(this.mock.$transferFromAndCallRelaxed(this.token, this.mock, this.erc1363Receiver, 0n, data))
.to.be.revertedWithCustomError(this.token, 'ERC1363TransferFromFailed')
.withArgs(this.mock, this.erc1363Receiver, 0n);
});
it('reverts on approveAndCallRelaxed', async function () {
await expect(this.mock.$approveAndCallRelaxed(this.token, this.erc1363Spender, 0n, data))
.to.be.revertedWithCustomError(this.token, 'ERC1363ApproveFailed')
.withArgs(this.erc1363Spender, 0n);
});
});
describe('with ERC1363 that returns false on all ERC1363 calls', function () {
beforeEach(async function () {
this.token = this.erc1363ReturnFalseMock;
});
it('reverts on transferAndCallRelaxed', async function () {
await expect(this.mock.$transferAndCallRelaxed(this.token, this.erc1363Receiver, 0n, data))
.to.be.revertedWithCustomError(this.mock, 'SafeERC20FailedOperation')
.withArgs(this.token);
});
it('reverts on transferFromAndCallRelaxed', async function () {
await expect(this.mock.$transferFromAndCallRelaxed(this.token, this.mock, this.erc1363Receiver, 0n, data))
.to.be.revertedWithCustomError(this.mock, 'SafeERC20FailedOperation')
.withArgs(this.token);
});
it('reverts on approveAndCallRelaxed', async function () {
await expect(this.mock.$approveAndCallRelaxed(this.token, this.erc1363Spender, 0n, data))
.to.be.revertedWithCustomError(this.mock, 'SafeERC20FailedOperation')
.withArgs(this.token);
});
});
describe('with ERC1363 that returns no boolean values', function () {
beforeEach(async function () {
this.token = this.erc1363NoReturnMock;
});
it('reverts on transferAndCallRelaxed', async function () {
await expect(
this.mock.$transferAndCallRelaxed(this.token, this.erc1363Receiver, 0n, data),
).to.be.revertedWithoutReason();
});
it('reverts on transferFromAndCallRelaxed', async function () {
await expect(
this.mock.$transferFromAndCallRelaxed(this.token, this.mock, this.erc1363Receiver, 0n, data),
).to.be.revertedWithoutReason();
});
it('reverts on approveAndCallRelaxed', async function () {
await expect(
this.mock.$approveAndCallRelaxed(this.token, this.erc1363Spender, 0n, data),
).to.be.revertedWithoutReason();
});
});
describe('with ERC1363 with usdt approval behaviour', function () {
beforeEach(async function () {
this.token = this.erc1363ForceApproveMock;
});
describe('without initial approval', function () {
it('approveAndCallRelaxed works when recipient is an EOA', async function () {
await this.mock.$approveAndCallRelaxed(this.token, this.spender, 10n, data);
expect(await this.token.allowance(this.mock, this.spender)).to.equal(10n);
});
it('approveAndCallRelaxed works when recipient is a contract', async function () {
await this.mock.$approveAndCallRelaxed(this.token, this.erc1363Spender, 10n, data);
expect(await this.token.allowance(this.mock, this.erc1363Spender)).to.equal(10n);
});
});
describe('with initial approval', function () {
it('approveAndCallRelaxed works when recipient is an EOA', async function () {
await this.token.$_approve(this.mock, this.spender, 100n);
await this.mock.$approveAndCallRelaxed(this.token, this.spender, 10n, data);
expect(await this.token.allowance(this.mock, this.spender)).to.equal(10n);
});
it('approveAndCallRelaxed reverts when recipient is a contract', async function () {
await this.token.$_approve(this.mock, this.erc1363Spender, 100n);
await expect(this.mock.$approveAndCallRelaxed(this.token, this.erc1363Spender, 10n, data)).to.be.revertedWith(
'USDT approval failure',
);
});
});
});
});
function shouldOnlyRevertOnErrors() {
describe('transfers', function () {
beforeEach(async function () {
await this.token.$_mint(this.owner, 100n);
await this.token.$_mint(this.mock, 100n);
await this.token.$_approve(this.owner, this.mock, ethers.MaxUint256);
});
it("doesn't revert on transfer", async function () {
await expect(this.mock.$safeTransfer(this.token, this.receiver, 10n))
.to.emit(this.token, 'Transfer')
.withArgs(this.mock, this.receiver, 10n);
});
it("doesn't revert on transferFrom", async function () {
await expect(this.mock.$safeTransferFrom(this.token, this.owner, this.receiver, 10n))
.to.emit(this.token, 'Transfer')
.withArgs(this.owner, this.receiver, 10n);
});
});
describe('approvals', function () {
describe('with zero allowance', function () {
beforeEach(async function () {
await this.token.$_approve(this.mock, this.spender, 0n);
});
it("doesn't revert when force approving a non-zero allowance", async function () {
await this.mock.$forceApprove(this.token, this.spender, 100n);
expect(await this.token.allowance(this.mock, this.spender)).to.equal(100n);
});
it("doesn't revert when force approving a zero allowance", async function () {
await this.mock.$forceApprove(this.token, this.spender, 0n);
expect(await this.token.allowance(this.mock, this.spender)).to.equal(0n);
});
it("doesn't revert when increasing the allowance", async function () {
await this.mock.$safeIncreaseAllowance(this.token, this.spender, 10n);
expect(await this.token.allowance(this.mock, this.spender)).to.equal(10n);
});
it('reverts when decreasing the allowance', async function () {
await expect(this.mock.$safeDecreaseAllowance(this.token, this.spender, 10n))
.to.be.revertedWithCustomError(this.mock, 'SafeERC20FailedDecreaseAllowance')
.withArgs(this.spender, 0n, 10n);
});
});
describe('with non-zero allowance', function () {
beforeEach(async function () {
await this.token.$_approve(this.mock, this.spender, 100n);
});
it("doesn't revert when force approving a non-zero allowance", async function () {
await this.mock.$forceApprove(this.token, this.spender, 20n);
expect(await this.token.allowance(this.mock, this.spender)).to.equal(20n);
});
it("doesn't revert when force approving a zero allowance", async function () {
await this.mock.$forceApprove(this.token, this.spender, 0n);
expect(await this.token.allowance(this.mock, this.spender)).to.equal(0n);
});
it("doesn't revert when increasing the allowance", async function () {
await this.mock.$safeIncreaseAllowance(this.token, this.spender, 10n);
expect(await this.token.allowance(this.mock, this.spender)).to.equal(110n);
});
it("doesn't revert when decreasing the allowance to a positive value", async function () {
await this.mock.$safeDecreaseAllowance(this.token, this.spender, 50n);
expect(await this.token.allowance(this.mock, this.spender)).to.equal(50n);
});
it('reverts when decreasing the allowance to a negative value', async function () {
await expect(this.mock.$safeDecreaseAllowance(this.token, this.spender, 200n))
.to.be.revertedWithCustomError(this.mock, 'SafeERC20FailedDecreaseAllowance')
.withArgs(this.spender, 100n, 200n);
});
});
});
}

View File

@@ -0,0 +1,972 @@
const { ethers } = require('hardhat');
const { expect } = require('chai');
const { PANIC_CODES } = require('@nomicfoundation/hardhat-chai-matchers/panic');
const { anyValue } = require('@nomicfoundation/hardhat-chai-matchers/withArgs');
const { shouldSupportInterfaces } = require('../../utils/introspection/SupportsInterface.behavior');
const { RevertType } = require('../../helpers/enums');
const firstTokenId = 5042n;
const secondTokenId = 79217n;
const nonExistentTokenId = 13n;
const fourthTokenId = 4n;
const baseURI = 'https://api.example.com/v1/';
const RECEIVER_MAGIC_VALUE = '0x150b7a02';
function shouldBehaveLikeERC721() {
beforeEach(async function () {
const [owner, newOwner, approved, operator, other] = this.accounts;
Object.assign(this, { owner, newOwner, approved, operator, other });
});
shouldSupportInterfaces(['ERC721']);
describe('with minted tokens', function () {
beforeEach(async function () {
await this.token.$_mint(this.owner, firstTokenId);
await this.token.$_mint(this.owner, secondTokenId);
this.to = this.other;
});
describe('balanceOf', function () {
describe('when the given address owns some tokens', function () {
it('returns the amount of tokens owned by the given address', async function () {
expect(await this.token.balanceOf(this.owner)).to.equal(2n);
});
});
describe('when the given address does not own any tokens', function () {
it('returns 0', async function () {
expect(await this.token.balanceOf(this.other)).to.equal(0n);
});
});
describe('when querying the zero address', function () {
it('throws', async function () {
await expect(this.token.balanceOf(ethers.ZeroAddress))
.to.be.revertedWithCustomError(this.token, 'ERC721InvalidOwner')
.withArgs(ethers.ZeroAddress);
});
});
});
describe('ownerOf', function () {
describe('when the given token ID was tracked by this token', function () {
const tokenId = firstTokenId;
it('returns the owner of the given token ID', async function () {
expect(await this.token.ownerOf(tokenId)).to.equal(this.owner);
});
});
describe('when the given token ID was not tracked by this token', function () {
const tokenId = nonExistentTokenId;
it('reverts', async function () {
await expect(this.token.ownerOf(tokenId))
.to.be.revertedWithCustomError(this.token, 'ERC721NonexistentToken')
.withArgs(tokenId);
});
});
});
describe('transfers', function () {
const tokenId = firstTokenId;
const data = '0x42';
beforeEach(async function () {
await this.token.connect(this.owner).approve(this.approved, tokenId);
await this.token.connect(this.owner).setApprovalForAll(this.operator, true);
});
const transferWasSuccessful = () => {
it('transfers the ownership of the given token ID to the given address', async function () {
await this.tx();
expect(await this.token.ownerOf(tokenId)).to.equal(this.to);
});
it('emits a Transfer event', async function () {
await expect(this.tx()).to.emit(this.token, 'Transfer').withArgs(this.owner, this.to, tokenId);
});
it('clears the approval for the token ID with no event', async function () {
await expect(this.tx()).to.not.emit(this.token, 'Approval');
expect(await this.token.getApproved(tokenId)).to.equal(ethers.ZeroAddress);
});
it('adjusts owners balances', async function () {
const balanceBefore = await this.token.balanceOf(this.owner);
await this.tx();
expect(await this.token.balanceOf(this.owner)).to.equal(balanceBefore - 1n);
});
it('adjusts owners tokens by index', async function () {
if (!this.token.tokenOfOwnerByIndex) return;
await this.tx();
expect(await this.token.tokenOfOwnerByIndex(this.to, 0n)).to.equal(tokenId);
expect(await this.token.tokenOfOwnerByIndex(this.owner, 0n)).to.not.equal(tokenId);
});
};
const shouldTransferTokensByUsers = function (fragment, opts = {}) {
describe('when called by the owner', function () {
beforeEach(async function () {
this.tx = () =>
this.token.connect(this.owner)[fragment](this.owner, this.to, tokenId, ...(opts.extra ?? []));
});
transferWasSuccessful();
});
describe('when called by the approved individual', function () {
beforeEach(async function () {
this.tx = () =>
this.token.connect(this.approved)[fragment](this.owner, this.to, tokenId, ...(opts.extra ?? []));
});
transferWasSuccessful();
});
describe('when called by the operator', function () {
beforeEach(async function () {
this.tx = () =>
this.token.connect(this.operator)[fragment](this.owner, this.to, tokenId, ...(opts.extra ?? []));
});
transferWasSuccessful();
});
describe('when called by the owner without an approved user', function () {
beforeEach(async function () {
await this.token.connect(this.owner).approve(ethers.ZeroAddress, tokenId);
this.tx = () =>
this.token.connect(this.operator)[fragment](this.owner, this.to, tokenId, ...(opts.extra ?? []));
});
transferWasSuccessful();
});
describe('when sent to the owner', function () {
beforeEach(async function () {
this.tx = () =>
this.token.connect(this.owner)[fragment](this.owner, this.owner, tokenId, ...(opts.extra ?? []));
});
it('keeps ownership of the token', async function () {
await this.tx();
expect(await this.token.ownerOf(tokenId)).to.equal(this.owner);
});
it('clears the approval for the token ID', async function () {
await this.tx();
expect(await this.token.getApproved(tokenId)).to.equal(ethers.ZeroAddress);
});
it('emits only a transfer event', async function () {
await expect(this.tx()).to.emit(this.token, 'Transfer').withArgs(this.owner, this.owner, tokenId);
});
it('keeps the owner balance', async function () {
const balanceBefore = await this.token.balanceOf(this.owner);
await this.tx();
expect(await this.token.balanceOf(this.owner)).to.equal(balanceBefore);
});
it('keeps same tokens by index', async function () {
if (!this.token.tokenOfOwnerByIndex) return;
expect(await Promise.all([0n, 1n].map(i => this.token.tokenOfOwnerByIndex(this.owner, i)))).to.have.members(
[firstTokenId, secondTokenId],
);
});
});
describe('when the address of the previous owner is incorrect', function () {
it('reverts', async function () {
await expect(
this.token.connect(this.owner)[fragment](this.other, this.other, tokenId, ...(opts.extra ?? [])),
)
.to.be.revertedWithCustomError(this.token, 'ERC721IncorrectOwner')
.withArgs(this.other, tokenId, this.owner);
});
});
describe('when the sender is not authorized for the token id', function () {
if (opts.unrestricted) {
it('does not revert', async function () {
await this.token.connect(this.other)[fragment](this.owner, this.other, tokenId, ...(opts.extra ?? []));
});
} else {
it('reverts', async function () {
await expect(
this.token.connect(this.other)[fragment](this.owner, this.other, tokenId, ...(opts.extra ?? [])),
)
.to.be.revertedWithCustomError(this.token, 'ERC721InsufficientApproval')
.withArgs(this.other, tokenId);
});
}
});
describe('when the given token ID does not exist', function () {
it('reverts', async function () {
await expect(
this.token
.connect(this.owner)
[fragment](this.owner, this.other, nonExistentTokenId, ...(opts.extra ?? [])),
)
.to.be.revertedWithCustomError(this.token, 'ERC721NonexistentToken')
.withArgs(nonExistentTokenId);
});
});
describe('when the address to transfer the token to is the zero address', function () {
it('reverts', async function () {
await expect(
this.token.connect(this.owner)[fragment](this.owner, ethers.ZeroAddress, tokenId, ...(opts.extra ?? [])),
)
.to.be.revertedWithCustomError(this.token, 'ERC721InvalidReceiver')
.withArgs(ethers.ZeroAddress);
});
});
};
const shouldTransferSafely = function (fragment, data, opts = {}) {
// sanity
it('function exists', async function () {
expect(this.token.interface.hasFunction(fragment)).to.be.true;
});
describe('to a user account', function () {
shouldTransferTokensByUsers(fragment, opts);
});
describe('to a valid receiver contract', function () {
beforeEach(async function () {
this.to = await ethers.deployContract('ERC721ReceiverMock', [RECEIVER_MAGIC_VALUE, RevertType.None]);
});
shouldTransferTokensByUsers(fragment, opts);
it('calls onERC721Received', async function () {
await expect(this.token.connect(this.owner)[fragment](this.owner, this.to, tokenId, ...(opts.extra ?? [])))
.to.emit(this.to, 'Received')
.withArgs(this.owner, this.owner, tokenId, data, anyValue);
});
it('calls onERC721Received from approved', async function () {
await expect(
this.token.connect(this.approved)[fragment](this.owner, this.to, tokenId, ...(opts.extra ?? [])),
)
.to.emit(this.to, 'Received')
.withArgs(this.approved, this.owner, tokenId, data, anyValue);
});
describe('with an invalid token id', function () {
it('reverts', async function () {
await expect(
this.token
.connect(this.approved)
[fragment](this.owner, this.to, nonExistentTokenId, ...(opts.extra ?? [])),
)
.to.be.revertedWithCustomError(this.token, 'ERC721NonexistentToken')
.withArgs(nonExistentTokenId);
});
});
});
};
for (const { fnName, opts } of [
{ fnName: 'transferFrom', opts: {} },
{ fnName: '$_transfer', opts: { unrestricted: true } },
]) {
describe(`via ${fnName}`, function () {
shouldTransferTokensByUsers(fnName, opts);
});
}
for (const { fnName, opts } of [
{ fnName: 'safeTransferFrom', opts: {} },
{ fnName: '$_safeTransfer', opts: { unrestricted: true } },
]) {
describe(`via ${fnName}`, function () {
describe('with data', function () {
shouldTransferSafely(fnName, data, { ...opts, extra: [ethers.Typed.bytes(data)] });
});
describe('without data', function () {
shouldTransferSafely(fnName, '0x', opts);
});
describe('to a receiver contract returning unexpected value', function () {
it('reverts', async function () {
const invalidReceiver = await ethers.deployContract('ERC721ReceiverMock', [
'0xdeadbeef',
RevertType.None,
]);
await expect(this.token.connect(this.owner)[fnName](this.owner, invalidReceiver, tokenId))
.to.be.revertedWithCustomError(this.token, 'ERC721InvalidReceiver')
.withArgs(invalidReceiver);
});
});
describe('to a receiver contract that reverts with message', function () {
it('reverts', async function () {
const revertingReceiver = await ethers.deployContract('ERC721ReceiverMock', [
RECEIVER_MAGIC_VALUE,
RevertType.RevertWithMessage,
]);
await expect(
this.token.connect(this.owner)[fnName](this.owner, revertingReceiver, tokenId),
).to.be.revertedWith('ERC721ReceiverMock: reverting');
});
});
describe('to a receiver contract that reverts without message', function () {
it('reverts', async function () {
const revertingReceiver = await ethers.deployContract('ERC721ReceiverMock', [
RECEIVER_MAGIC_VALUE,
RevertType.RevertWithoutMessage,
]);
await expect(this.token.connect(this.owner)[fnName](this.owner, revertingReceiver, tokenId))
.to.be.revertedWithCustomError(this.token, 'ERC721InvalidReceiver')
.withArgs(revertingReceiver);
});
});
describe('to a receiver contract that reverts with custom error', function () {
it('reverts', async function () {
const revertingReceiver = await ethers.deployContract('ERC721ReceiverMock', [
RECEIVER_MAGIC_VALUE,
RevertType.RevertWithCustomError,
]);
await expect(this.token.connect(this.owner)[fnName](this.owner, revertingReceiver, tokenId))
.to.be.revertedWithCustomError(revertingReceiver, 'CustomError')
.withArgs(RECEIVER_MAGIC_VALUE);
});
});
describe('to a receiver contract that panics', function () {
it('reverts', async function () {
const revertingReceiver = await ethers.deployContract('ERC721ReceiverMock', [
RECEIVER_MAGIC_VALUE,
RevertType.Panic,
]);
await expect(
this.token.connect(this.owner)[fnName](this.owner, revertingReceiver, tokenId),
).to.be.revertedWithPanic(PANIC_CODES.DIVISION_BY_ZERO);
});
});
describe('to a contract that does not implement the required function', function () {
it('reverts', async function () {
const nonReceiver = await ethers.deployContract('CallReceiverMock');
await expect(this.token.connect(this.owner)[fnName](this.owner, nonReceiver, tokenId))
.to.be.revertedWithCustomError(this.token, 'ERC721InvalidReceiver')
.withArgs(nonReceiver);
});
});
});
}
});
describe('safe mint', function () {
const tokenId = fourthTokenId;
const data = '0x42';
describe('via safeMint', function () {
// regular minting is tested in ERC721Mintable.test.js and others
it('calls onERC721Received — with data', async function () {
const receiver = await ethers.deployContract('ERC721ReceiverMock', [RECEIVER_MAGIC_VALUE, RevertType.None]);
await expect(await this.token.$_safeMint(receiver, tokenId, ethers.Typed.bytes(data)))
.to.emit(receiver, 'Received')
.withArgs(anyValue, ethers.ZeroAddress, tokenId, data, anyValue);
});
it('calls onERC721Received — without data', async function () {
const receiver = await ethers.deployContract('ERC721ReceiverMock', [RECEIVER_MAGIC_VALUE, RevertType.None]);
await expect(await this.token.$_safeMint(receiver, tokenId))
.to.emit(receiver, 'Received')
.withArgs(anyValue, ethers.ZeroAddress, tokenId, '0x', anyValue);
});
describe('to a receiver contract returning unexpected value', function () {
it('reverts', async function () {
const invalidReceiver = await ethers.deployContract('ERC721ReceiverMock', ['0xdeadbeef', RevertType.None]);
await expect(this.token.$_safeMint(invalidReceiver, tokenId))
.to.be.revertedWithCustomError(this.token, 'ERC721InvalidReceiver')
.withArgs(invalidReceiver);
});
});
describe('to a receiver contract that reverts with message', function () {
it('reverts', async function () {
const revertingReceiver = await ethers.deployContract('ERC721ReceiverMock', [
RECEIVER_MAGIC_VALUE,
RevertType.RevertWithMessage,
]);
await expect(this.token.$_safeMint(revertingReceiver, tokenId)).to.be.revertedWith(
'ERC721ReceiverMock: reverting',
);
});
});
describe('to a receiver contract that reverts without message', function () {
it('reverts', async function () {
const revertingReceiver = await ethers.deployContract('ERC721ReceiverMock', [
RECEIVER_MAGIC_VALUE,
RevertType.RevertWithoutMessage,
]);
await expect(this.token.$_safeMint(revertingReceiver, tokenId))
.to.be.revertedWithCustomError(this.token, 'ERC721InvalidReceiver')
.withArgs(revertingReceiver);
});
});
describe('to a receiver contract that reverts with custom error', function () {
it('reverts', async function () {
const revertingReceiver = await ethers.deployContract('ERC721ReceiverMock', [
RECEIVER_MAGIC_VALUE,
RevertType.RevertWithCustomError,
]);
await expect(this.token.$_safeMint(revertingReceiver, tokenId))
.to.be.revertedWithCustomError(revertingReceiver, 'CustomError')
.withArgs(RECEIVER_MAGIC_VALUE);
});
});
describe('to a receiver contract that panics', function () {
it('reverts', async function () {
const revertingReceiver = await ethers.deployContract('ERC721ReceiverMock', [
RECEIVER_MAGIC_VALUE,
RevertType.Panic,
]);
await expect(this.token.$_safeMint(revertingReceiver, tokenId)).to.be.revertedWithPanic(
PANIC_CODES.DIVISION_BY_ZERO,
);
});
});
describe('to a contract that does not implement the required function', function () {
it('reverts', async function () {
const nonReceiver = await ethers.deployContract('CallReceiverMock');
await expect(this.token.$_safeMint(nonReceiver, tokenId))
.to.be.revertedWithCustomError(this.token, 'ERC721InvalidReceiver')
.withArgs(nonReceiver);
});
});
});
});
describe('approve', function () {
const tokenId = firstTokenId;
const itClearsApproval = function () {
it('clears approval for the token', async function () {
expect(await this.token.getApproved(tokenId)).to.equal(ethers.ZeroAddress);
});
};
const itApproves = function () {
it('sets the approval for the target address', async function () {
expect(await this.token.getApproved(tokenId)).to.equal(this.approved ?? this.approved);
});
};
const itEmitsApprovalEvent = function () {
it('emits an approval event', async function () {
await expect(this.tx)
.to.emit(this.token, 'Approval')
.withArgs(this.owner, this.approved ?? this.approved, tokenId);
});
};
describe('when clearing approval', function () {
describe('when there was no prior approval', function () {
beforeEach(async function () {
this.approved = ethers.ZeroAddress;
this.tx = await this.token.connect(this.owner).approve(this.approved, tokenId);
});
itClearsApproval();
itEmitsApprovalEvent();
});
describe('when there was a prior approval', function () {
beforeEach(async function () {
await this.token.connect(this.owner).approve(this.other, tokenId);
this.approved = ethers.ZeroAddress;
this.tx = await this.token.connect(this.owner).approve(this.approved, tokenId);
});
itClearsApproval();
itEmitsApprovalEvent();
});
});
describe('when approving a non-zero address', function () {
describe('when there was no prior approval', function () {
beforeEach(async function () {
this.tx = await this.token.connect(this.owner).approve(this.approved, tokenId);
});
itApproves();
itEmitsApprovalEvent();
});
describe('when there was a prior approval to the same address', function () {
beforeEach(async function () {
await this.token.connect(this.owner).approve(this.approved, tokenId);
this.tx = await this.token.connect(this.owner).approve(this.approved, tokenId);
});
itApproves();
itEmitsApprovalEvent();
});
describe('when there was a prior approval to a different address', function () {
beforeEach(async function () {
await this.token.connect(this.owner).approve(this.other, tokenId);
this.tx = await this.token.connect(this.owner).approve(this.approved, tokenId);
});
itApproves();
itEmitsApprovalEvent();
});
});
describe('when the sender does not own the given token ID', function () {
it('reverts', async function () {
await expect(this.token.connect(this.other).approve(this.approved, tokenId))
.to.be.revertedWithCustomError(this.token, 'ERC721InvalidApprover')
.withArgs(this.other);
});
});
describe('when the sender is approved for the given token ID', function () {
it('reverts', async function () {
await this.token.connect(this.owner).approve(this.approved, tokenId);
await expect(this.token.connect(this.approved).approve(this.other, tokenId))
.to.be.revertedWithCustomError(this.token, 'ERC721InvalidApprover')
.withArgs(this.approved);
});
});
describe('when the sender is an operator', function () {
beforeEach(async function () {
await this.token.connect(this.owner).setApprovalForAll(this.operator, true);
this.tx = await this.token.connect(this.operator).approve(this.approved, tokenId);
});
itApproves();
itEmitsApprovalEvent();
});
describe('when the given token ID does not exist', function () {
it('reverts', async function () {
await expect(this.token.connect(this.operator).approve(this.approved, nonExistentTokenId))
.to.be.revertedWithCustomError(this.token, 'ERC721NonexistentToken')
.withArgs(nonExistentTokenId);
});
});
});
describe('setApprovalForAll', function () {
describe('when the operator willing to approve is not the owner', function () {
describe('when there is no operator approval set by the sender', function () {
it('approves the operator', async function () {
await this.token.connect(this.owner).setApprovalForAll(this.operator, true);
expect(await this.token.isApprovedForAll(this.owner, this.operator)).to.be.true;
});
it('emits an approval event', async function () {
await expect(this.token.connect(this.owner).setApprovalForAll(this.operator, true))
.to.emit(this.token, 'ApprovalForAll')
.withArgs(this.owner, this.operator, true);
});
});
describe('when the operator was set as not approved', function () {
beforeEach(async function () {
await this.token.connect(this.owner).setApprovalForAll(this.operator, false);
});
it('approves the operator', async function () {
await this.token.connect(this.owner).setApprovalForAll(this.operator, true);
expect(await this.token.isApprovedForAll(this.owner, this.operator)).to.be.true;
});
it('emits an approval event', async function () {
await expect(this.token.connect(this.owner).setApprovalForAll(this.operator, true))
.to.emit(this.token, 'ApprovalForAll')
.withArgs(this.owner, this.operator, true);
});
it('can unset the operator approval', async function () {
await this.token.connect(this.owner).setApprovalForAll(this.operator, false);
expect(await this.token.isApprovedForAll(this.owner, this.operator)).to.be.false;
});
});
describe('when the operator was already approved', function () {
beforeEach(async function () {
await this.token.connect(this.owner).setApprovalForAll(this.operator, true);
});
it('keeps the approval to the given address', async function () {
await this.token.connect(this.owner).setApprovalForAll(this.operator, true);
expect(await this.token.isApprovedForAll(this.owner, this.operator)).to.be.true;
});
it('emits an approval event', async function () {
await expect(this.token.connect(this.owner).setApprovalForAll(this.operator, true))
.to.emit(this.token, 'ApprovalForAll')
.withArgs(this.owner, this.operator, true);
});
});
});
describe('when the operator is address zero', function () {
it('reverts', async function () {
await expect(this.token.connect(this.owner).setApprovalForAll(ethers.ZeroAddress, true))
.to.be.revertedWithCustomError(this.token, 'ERC721InvalidOperator')
.withArgs(ethers.ZeroAddress);
});
});
});
describe('getApproved', function () {
describe('when token is not minted', function () {
it('reverts', async function () {
await expect(this.token.getApproved(nonExistentTokenId))
.to.be.revertedWithCustomError(this.token, 'ERC721NonexistentToken')
.withArgs(nonExistentTokenId);
});
});
describe('when token has been minted ', function () {
it('should return the zero address', async function () {
expect(await this.token.getApproved(firstTokenId)).to.equal(ethers.ZeroAddress);
});
describe('when account has been approved', function () {
beforeEach(async function () {
await this.token.connect(this.owner).approve(this.approved, firstTokenId);
});
it('returns approved account', async function () {
expect(await this.token.getApproved(firstTokenId)).to.equal(this.approved);
});
});
});
});
});
describe('_mint(address, uint256)', function () {
it('reverts with a null destination address', async function () {
await expect(this.token.$_mint(ethers.ZeroAddress, firstTokenId))
.to.be.revertedWithCustomError(this.token, 'ERC721InvalidReceiver')
.withArgs(ethers.ZeroAddress);
});
describe('with minted token', function () {
beforeEach(async function () {
this.tx = await this.token.$_mint(this.owner, firstTokenId);
});
it('emits a Transfer event', async function () {
await expect(this.tx).to.emit(this.token, 'Transfer').withArgs(ethers.ZeroAddress, this.owner, firstTokenId);
});
it('creates the token', async function () {
expect(await this.token.balanceOf(this.owner)).to.equal(1n);
expect(await this.token.ownerOf(firstTokenId)).to.equal(this.owner);
});
it('reverts when adding a token id that already exists', async function () {
await expect(this.token.$_mint(this.owner, firstTokenId))
.to.be.revertedWithCustomError(this.token, 'ERC721InvalidSender')
.withArgs(ethers.ZeroAddress);
});
});
});
describe('_burn', function () {
it('reverts when burning a non-existent token id', async function () {
await expect(this.token.$_burn(nonExistentTokenId))
.to.be.revertedWithCustomError(this.token, 'ERC721NonexistentToken')
.withArgs(nonExistentTokenId);
});
describe('with minted tokens', function () {
beforeEach(async function () {
await this.token.$_mint(this.owner, firstTokenId);
await this.token.$_mint(this.owner, secondTokenId);
});
describe('with burnt token', function () {
beforeEach(async function () {
this.tx = await this.token.$_burn(firstTokenId);
});
it('emits a Transfer event', async function () {
await expect(this.tx).to.emit(this.token, 'Transfer').withArgs(this.owner, ethers.ZeroAddress, firstTokenId);
});
it('deletes the token', async function () {
expect(await this.token.balanceOf(this.owner)).to.equal(1n);
await expect(this.token.ownerOf(firstTokenId))
.to.be.revertedWithCustomError(this.token, 'ERC721NonexistentToken')
.withArgs(firstTokenId);
});
it('reverts when burning a token id that has been deleted', async function () {
await expect(this.token.$_burn(firstTokenId))
.to.be.revertedWithCustomError(this.token, 'ERC721NonexistentToken')
.withArgs(firstTokenId);
});
});
});
});
}
function shouldBehaveLikeERC721Enumerable() {
beforeEach(async function () {
const [owner, newOwner, approved, operator, other] = this.accounts;
Object.assign(this, { owner, newOwner, approved, operator, other });
});
shouldSupportInterfaces(['ERC721Enumerable']);
describe('with minted tokens', function () {
beforeEach(async function () {
await this.token.$_mint(this.owner, firstTokenId);
await this.token.$_mint(this.owner, secondTokenId);
this.to = this.other;
});
describe('totalSupply', function () {
it('returns total token supply', async function () {
expect(await this.token.totalSupply()).to.equal(2n);
});
});
describe('tokenOfOwnerByIndex', function () {
describe('when the given index is lower than the amount of tokens owned by the given address', function () {
it('returns the token ID placed at the given index', async function () {
expect(await this.token.tokenOfOwnerByIndex(this.owner, 0n)).to.equal(firstTokenId);
});
});
describe('when the index is greater than or equal to the total tokens owned by the given address', function () {
it('reverts', async function () {
await expect(this.token.tokenOfOwnerByIndex(this.owner, 2n))
.to.be.revertedWithCustomError(this.token, 'ERC721OutOfBoundsIndex')
.withArgs(this.owner, 2n);
});
});
describe('when the given address does not own any token', function () {
it('reverts', async function () {
await expect(this.token.tokenOfOwnerByIndex(this.other, 0n))
.to.be.revertedWithCustomError(this.token, 'ERC721OutOfBoundsIndex')
.withArgs(this.other, 0n);
});
});
describe('after transferring all tokens to another user', function () {
beforeEach(async function () {
await this.token.connect(this.owner).transferFrom(this.owner, this.other, firstTokenId);
await this.token.connect(this.owner).transferFrom(this.owner, this.other, secondTokenId);
});
it('returns correct token IDs for target', async function () {
expect(await this.token.balanceOf(this.other)).to.equal(2n);
expect(await Promise.all([0n, 1n].map(i => this.token.tokenOfOwnerByIndex(this.other, i)))).to.have.members([
firstTokenId,
secondTokenId,
]);
});
it('returns empty collection for original owner', async function () {
expect(await this.token.balanceOf(this.owner)).to.equal(0n);
await expect(this.token.tokenOfOwnerByIndex(this.owner, 0n))
.to.be.revertedWithCustomError(this.token, 'ERC721OutOfBoundsIndex')
.withArgs(this.owner, 0n);
});
});
});
describe('tokenByIndex', function () {
it('returns all tokens', async function () {
expect(await Promise.all([0n, 1n].map(i => this.token.tokenByIndex(i)))).to.have.members([
firstTokenId,
secondTokenId,
]);
});
it('reverts if index is greater than supply', async function () {
await expect(this.token.tokenByIndex(2n))
.to.be.revertedWithCustomError(this.token, 'ERC721OutOfBoundsIndex')
.withArgs(ethers.ZeroAddress, 2n);
});
for (const tokenId of [firstTokenId, secondTokenId]) {
it(`returns all tokens after burning token ${tokenId} and minting new tokens`, async function () {
const newTokenId = 300n;
const anotherNewTokenId = 400n;
await this.token.$_burn(tokenId);
await this.token.$_mint(this.newOwner, newTokenId);
await this.token.$_mint(this.newOwner, anotherNewTokenId);
expect(await this.token.totalSupply()).to.equal(3n);
expect(await Promise.all([0n, 1n, 2n].map(i => this.token.tokenByIndex(i))))
.to.have.members([firstTokenId, secondTokenId, newTokenId, anotherNewTokenId].filter(x => x !== tokenId))
.to.not.include(tokenId);
});
}
});
});
describe('_mint(address, uint256)', function () {
it('reverts with a null destination address', async function () {
await expect(this.token.$_mint(ethers.ZeroAddress, firstTokenId))
.to.be.revertedWithCustomError(this.token, 'ERC721InvalidReceiver')
.withArgs(ethers.ZeroAddress);
});
describe('with minted token', function () {
beforeEach(async function () {
await this.token.$_mint(this.owner, firstTokenId);
});
it('adjusts owner tokens by index', async function () {
expect(await this.token.tokenOfOwnerByIndex(this.owner, 0n)).to.equal(firstTokenId);
});
it('adjusts all tokens list', async function () {
expect(await this.token.tokenByIndex(0n)).to.equal(firstTokenId);
});
});
});
describe('_burn', function () {
it('reverts when burning a non-existent token id', async function () {
await expect(this.token.$_burn(firstTokenId))
.to.be.revertedWithCustomError(this.token, 'ERC721NonexistentToken')
.withArgs(firstTokenId);
});
describe('with minted tokens', function () {
beforeEach(async function () {
await this.token.$_mint(this.owner, firstTokenId);
await this.token.$_mint(this.owner, secondTokenId);
});
describe('with burnt token', function () {
beforeEach(async function () {
await this.token.$_burn(firstTokenId);
});
it('removes that token from the token list of the owner', async function () {
expect(await this.token.tokenOfOwnerByIndex(this.owner, 0n)).to.equal(secondTokenId);
});
it('adjusts all tokens list', async function () {
expect(await this.token.tokenByIndex(0n)).to.equal(secondTokenId);
});
it('burns all tokens', async function () {
await this.token.$_burn(secondTokenId);
expect(await this.token.totalSupply()).to.equal(0n);
await expect(this.token.tokenByIndex(0n))
.to.be.revertedWithCustomError(this.token, 'ERC721OutOfBoundsIndex')
.withArgs(ethers.ZeroAddress, 0n);
});
});
});
});
}
function shouldBehaveLikeERC721Metadata(name, symbol) {
shouldSupportInterfaces(['ERC721Metadata']);
describe('metadata', function () {
it('has a name', async function () {
expect(await this.token.name()).to.equal(name);
});
it('has a symbol', async function () {
expect(await this.token.symbol()).to.equal(symbol);
});
describe('token URI', function () {
beforeEach(async function () {
await this.token.$_mint(this.owner, firstTokenId);
});
it('return empty string by default', async function () {
expect(await this.token.tokenURI(firstTokenId)).to.equal('');
});
it('reverts when queried for non existent token id', async function () {
await expect(this.token.tokenURI(nonExistentTokenId))
.to.be.revertedWithCustomError(this.token, 'ERC721NonexistentToken')
.withArgs(nonExistentTokenId);
});
describe('base URI', function () {
beforeEach(function () {
if (!this.token.interface.hasFunction('setBaseURI')) {
this.skip();
}
});
it('base URI can be set', async function () {
await this.token.setBaseURI(baseURI);
expect(await this.token.baseURI()).to.equal(baseURI);
});
it('base URI is added as a prefix to the token URI', async function () {
await this.token.setBaseURI(baseURI);
expect(await this.token.tokenURI(firstTokenId)).to.equal(baseURI + firstTokenId.toString());
});
it('token URI can be changed by changing the base URI', async function () {
await this.token.setBaseURI(baseURI);
const newBaseURI = 'https://api.example.com/v2/';
await this.token.setBaseURI(newBaseURI);
expect(await this.token.tokenURI(firstTokenId)).to.equal(newBaseURI + firstTokenId.toString());
});
});
});
});
}
module.exports = {
shouldBehaveLikeERC721,
shouldBehaveLikeERC721Enumerable,
shouldBehaveLikeERC721Metadata,
};

View File

@@ -0,0 +1,23 @@
const { ethers } = require('hardhat');
const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers');
const { shouldBehaveLikeERC721, shouldBehaveLikeERC721Metadata } = require('./ERC721.behavior');
const name = 'Non Fungible Token';
const symbol = 'NFT';
async function fixture() {
return {
accounts: await ethers.getSigners(),
token: await ethers.deployContract('$ERC721', [name, symbol]),
};
}
describe('ERC721', function () {
beforeEach(async function () {
Object.assign(this, await loadFixture(fixture));
});
shouldBehaveLikeERC721();
shouldBehaveLikeERC721Metadata(name, symbol);
});

View File

@@ -0,0 +1,28 @@
const { ethers } = require('hardhat');
const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers');
const {
shouldBehaveLikeERC721,
shouldBehaveLikeERC721Metadata,
shouldBehaveLikeERC721Enumerable,
} = require('./ERC721.behavior');
const name = 'Non Fungible Token';
const symbol = 'NFT';
async function fixture() {
return {
accounts: await ethers.getSigners(),
token: await ethers.deployContract('$ERC721Enumerable', [name, symbol]),
};
}
describe('ERC721', function () {
beforeEach(async function () {
Object.assign(this, await loadFixture(fixture));
});
shouldBehaveLikeERC721();
shouldBehaveLikeERC721Metadata(name, symbol);
shouldBehaveLikeERC721Enumerable();
});

View File

@@ -0,0 +1,77 @@
const { ethers } = require('hardhat');
const { expect } = require('chai');
const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers');
const name = 'Non Fungible Token';
const symbol = 'NFT';
const tokenId = 1n;
const otherTokenId = 2n;
const unknownTokenId = 3n;
async function fixture() {
const [owner, approved, another] = await ethers.getSigners();
const token = await ethers.deployContract('$ERC721Burnable', [name, symbol]);
return { owner, approved, another, token };
}
describe('ERC721Burnable', function () {
beforeEach(async function () {
Object.assign(this, await loadFixture(fixture));
});
describe('like a burnable ERC721', function () {
beforeEach(async function () {
await this.token.$_mint(this.owner, tokenId);
await this.token.$_mint(this.owner, otherTokenId);
});
describe('burn', function () {
describe('when successful', function () {
it('emits a burn event, burns the given token ID and adjusts the balance of the owner', async function () {
const balanceBefore = await this.token.balanceOf(this.owner);
await expect(this.token.connect(this.owner).burn(tokenId))
.to.emit(this.token, 'Transfer')
.withArgs(this.owner, ethers.ZeroAddress, tokenId);
await expect(this.token.ownerOf(tokenId))
.to.be.revertedWithCustomError(this.token, 'ERC721NonexistentToken')
.withArgs(tokenId);
expect(await this.token.balanceOf(this.owner)).to.equal(balanceBefore - 1n);
});
});
describe('when there is a previous approval burned', function () {
beforeEach(async function () {
await this.token.connect(this.owner).approve(this.approved, tokenId);
await this.token.connect(this.owner).burn(tokenId);
});
describe('getApproved', function () {
it('reverts', async function () {
await expect(this.token.getApproved(tokenId))
.to.be.revertedWithCustomError(this.token, 'ERC721NonexistentToken')
.withArgs(tokenId);
});
});
});
describe('when there is no previous approval burned', function () {
it('reverts', async function () {
await expect(this.token.connect(this.another).burn(tokenId))
.to.be.revertedWithCustomError(this.token, 'ERC721InsufficientApproval')
.withArgs(this.another, tokenId);
});
});
describe('when the given token ID was not tracked by this contract', function () {
it('reverts', async function () {
await expect(this.token.connect(this.owner).burn(unknownTokenId))
.to.be.revertedWithCustomError(this.token, 'ERC721NonexistentToken')
.withArgs(unknownTokenId);
});
});
});
});
});

View File

@@ -0,0 +1,181 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
// solhint-disable func-name-mixedcase
import {ERC721} from "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import {ERC721Consecutive} from "@openzeppelin/contracts/token/ERC721/extensions/ERC721Consecutive.sol";
import {Test, StdUtils} from "@forge-std/Test.sol";
function toSingleton(address account) pure returns (address[] memory) {
address[] memory accounts = new address[](1);
accounts[0] = account;
return accounts;
}
contract ERC721ConsecutiveTarget is StdUtils, ERC721Consecutive {
uint96 private immutable _offset;
uint256 public totalMinted = 0;
constructor(address[] memory receivers, uint256[] memory batches, uint256 startingId) ERC721("", "") {
_offset = uint96(startingId);
for (uint256 i = 0; i < batches.length; i++) {
address receiver = receivers[i % receivers.length];
uint96 batchSize = uint96(bound(batches[i], 0, _maxBatchSize()));
_mintConsecutive(receiver, batchSize);
totalMinted += batchSize;
}
}
function burn(uint256 tokenId) public {
_burn(tokenId);
}
function _firstConsecutiveId() internal view virtual override returns (uint96) {
return _offset;
}
}
contract ERC721ConsecutiveTest is Test {
function test_balance(address receiver, uint256[] calldata batches, uint96 startingId) public {
vm.assume(receiver != address(0));
uint256 startingTokenId = bound(startingId, 0, 5000);
ERC721ConsecutiveTarget token = new ERC721ConsecutiveTarget(toSingleton(receiver), batches, startingTokenId);
assertEq(token.balanceOf(receiver), token.totalMinted());
}
function test_ownership(
address receiver,
uint256[] calldata batches,
uint256[2] calldata unboundedTokenId,
uint96 startingId
) public {
vm.assume(receiver != address(0));
uint256 startingTokenId = bound(startingId, 0, 5000);
ERC721ConsecutiveTarget token = new ERC721ConsecutiveTarget(toSingleton(receiver), batches, startingTokenId);
if (token.totalMinted() > 0) {
uint256 validTokenId = bound(
unboundedTokenId[0],
startingTokenId,
startingTokenId + token.totalMinted() - 1
);
assertEq(token.ownerOf(validTokenId), receiver);
}
uint256 invalidTokenId = bound(
unboundedTokenId[1],
startingTokenId + token.totalMinted(),
startingTokenId + token.totalMinted() + 1
);
vm.expectRevert();
token.ownerOf(invalidTokenId);
}
function test_burn(
address receiver,
uint256[] calldata batches,
uint256 unboundedTokenId,
uint96 startingId
) public {
vm.assume(receiver != address(0));
uint256 startingTokenId = bound(startingId, 0, 5000);
ERC721ConsecutiveTarget token = new ERC721ConsecutiveTarget(toSingleton(receiver), batches, startingTokenId);
// only test if we minted at least one token
uint256 supply = token.totalMinted();
vm.assume(supply > 0);
// burn a token in [0; supply[
uint256 tokenId = bound(unboundedTokenId, startingTokenId, startingTokenId + supply - 1);
token.burn(tokenId);
// balance should have decreased
assertEq(token.balanceOf(receiver), supply - 1);
// token should be burnt
vm.expectRevert();
token.ownerOf(tokenId);
}
function test_transfer(
address[2] calldata accounts,
uint256[2] calldata unboundedBatches,
uint256[2] calldata unboundedTokenId,
uint96 startingId
) public {
vm.assume(accounts[0] != address(0));
vm.assume(accounts[1] != address(0));
vm.assume(accounts[0] != accounts[1]);
uint256 startingTokenId = bound(startingId, 1, 5000);
address[] memory receivers = new address[](2);
receivers[0] = accounts[0];
receivers[1] = accounts[1];
// We assume _maxBatchSize is 5000 (the default). This test will break otherwise.
uint256[] memory batches = new uint256[](2);
batches[0] = bound(unboundedBatches[0], startingTokenId, 5000);
batches[1] = bound(unboundedBatches[1], startingTokenId, 5000);
ERC721ConsecutiveTarget token = new ERC721ConsecutiveTarget(receivers, batches, startingTokenId);
uint256 tokenId0 = bound(unboundedTokenId[0], startingTokenId, batches[0]);
uint256 tokenId1 = bound(unboundedTokenId[1], startingTokenId, batches[1]) + batches[0];
assertEq(token.ownerOf(tokenId0), accounts[0]);
assertEq(token.ownerOf(tokenId1), accounts[1]);
assertEq(token.balanceOf(accounts[0]), batches[0]);
assertEq(token.balanceOf(accounts[1]), batches[1]);
vm.prank(accounts[0]);
token.transferFrom(accounts[0], accounts[1], tokenId0);
assertEq(token.ownerOf(tokenId0), accounts[1]);
assertEq(token.ownerOf(tokenId1), accounts[1]);
assertEq(token.balanceOf(accounts[0]), batches[0] - 1);
assertEq(token.balanceOf(accounts[1]), batches[1] + 1);
vm.prank(accounts[1]);
token.transferFrom(accounts[1], accounts[0], tokenId1);
assertEq(token.ownerOf(tokenId0), accounts[1]);
assertEq(token.ownerOf(tokenId1), accounts[0]);
assertEq(token.balanceOf(accounts[0]), batches[0]);
assertEq(token.balanceOf(accounts[1]), batches[1]);
}
function test_start_consecutive_id(
address receiver,
uint256[2] calldata unboundedBatches,
uint256[2] calldata unboundedTokenId,
uint96 startingId
) public {
vm.assume(receiver != address(0));
uint256 startingTokenId = bound(startingId, 1, 5000);
// We assume _maxBatchSize is 5000 (the default). This test will break otherwise.
uint256[] memory batches = new uint256[](2);
batches[0] = bound(unboundedBatches[0], startingTokenId, 5000);
batches[1] = bound(unboundedBatches[1], startingTokenId, 5000);
ERC721ConsecutiveTarget token = new ERC721ConsecutiveTarget(toSingleton(receiver), batches, startingTokenId);
uint256 tokenId0 = bound(unboundedTokenId[0], startingTokenId, batches[0]);
uint256 tokenId1 = bound(unboundedTokenId[1], startingTokenId, batches[1]);
assertEq(token.ownerOf(tokenId0), receiver);
assertEq(token.ownerOf(tokenId1), receiver);
assertEq(token.balanceOf(receiver), batches[0] + batches[1]);
}
}

View File

@@ -0,0 +1,236 @@
const { ethers } = require('hardhat');
const { expect } = require('chai');
const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers');
const { sum } = require('../../../helpers/math');
const name = 'Non Fungible Token';
const symbol = 'NFT';
describe('ERC721Consecutive', function () {
for (const offset of [0n, 1n, 42n]) {
describe(`with offset ${offset}`, function () {
async function fixture() {
const accounts = await ethers.getSigners();
const [alice, bruce, chris, receiver] = accounts;
const batches = [
{ receiver: alice, amount: 0n },
{ receiver: alice, amount: 1n },
{ receiver: alice, amount: 2n },
{ receiver: bruce, amount: 5n },
{ receiver: chris, amount: 0n },
{ receiver: alice, amount: 7n },
];
const delegates = [alice, chris];
const token = await ethers.deployContract('$ERC721ConsecutiveMock', [
name,
symbol,
offset,
delegates,
batches.map(({ receiver }) => receiver),
batches.map(({ amount }) => amount),
]);
return { accounts, alice, bruce, chris, receiver, batches, delegates, token };
}
beforeEach(async function () {
Object.assign(this, await loadFixture(fixture));
});
describe('minting during construction', function () {
it('events are emitted at construction', async function () {
let first = offset;
for (const batch of this.batches) {
if (batch.amount > 0) {
await expect(this.token.deploymentTransaction())
.to.emit(this.token, 'ConsecutiveTransfer')
.withArgs(
first /* fromTokenId */,
first + batch.amount - 1n /* toTokenId */,
ethers.ZeroAddress /* fromAddress */,
batch.receiver /* toAddress */,
);
} else {
// ".to.not.emit" only looks at event name, and doesn't check the parameters
}
first += batch.amount;
}
});
it('ownership is set', async function () {
const owners = [
...Array(Number(offset)).fill(ethers.ZeroAddress),
...this.batches.flatMap(({ receiver, amount }) => Array(Number(amount)).fill(receiver.address)),
];
for (const tokenId in owners) {
if (owners[tokenId] != ethers.ZeroAddress) {
expect(await this.token.ownerOf(tokenId)).to.equal(owners[tokenId]);
}
}
});
it('balance & voting power are set', async function () {
for (const account of this.accounts) {
const balance =
sum(...this.batches.filter(({ receiver }) => receiver === account).map(({ amount }) => amount)) ?? 0n;
expect(await this.token.balanceOf(account)).to.equal(balance);
// If not delegated at construction, check before + do delegation
if (!this.delegates.includes(account)) {
expect(await this.token.getVotes(account)).to.equal(0n);
await this.token.connect(account).delegate(account);
}
// At this point all accounts should have delegated
expect(await this.token.getVotes(account)).to.equal(balance);
}
});
it('reverts on consecutive minting to the zero address', async function () {
await expect(
ethers.deployContract('$ERC721ConsecutiveMock', [
name,
symbol,
offset,
this.delegates,
[ethers.ZeroAddress],
[10],
]),
)
.to.be.revertedWithCustomError(this.token, 'ERC721InvalidReceiver')
.withArgs(ethers.ZeroAddress);
});
});
describe('minting after construction', function () {
it('consecutive minting is not possible after construction', async function () {
await expect(this.token.$_mintConsecutive(this.alice, 10)).to.be.revertedWithCustomError(
this.token,
'ERC721ForbiddenBatchMint',
);
});
it('simple minting is possible after construction', async function () {
const tokenId = sum(...this.batches.map(b => b.amount)) + offset;
await expect(this.token.ownerOf(tokenId))
.to.be.revertedWithCustomError(this.token, 'ERC721NonexistentToken')
.withArgs(tokenId);
await expect(this.token.$_mint(this.alice, tokenId))
.to.emit(this.token, 'Transfer')
.withArgs(ethers.ZeroAddress, this.alice, tokenId);
});
it('cannot mint a token that has been batched minted', async function () {
const tokenId = sum(...this.batches.map(b => b.amount)) + offset - 1n;
expect(await this.token.ownerOf(tokenId)).to.not.equal(ethers.ZeroAddress);
await expect(this.token.$_mint(this.alice, tokenId))
.to.be.revertedWithCustomError(this.token, 'ERC721InvalidSender')
.withArgs(ethers.ZeroAddress);
});
});
describe('ERC721 behavior', function () {
const tokenId = offset + 1n;
it('core takes over ownership on transfer', async function () {
await this.token.connect(this.alice).transferFrom(this.alice, this.receiver, tokenId);
expect(await this.token.ownerOf(tokenId)).to.equal(this.receiver);
});
it('tokens can be burned and re-minted #1', async function () {
await expect(this.token.connect(this.alice).$_burn(tokenId))
.to.emit(this.token, 'Transfer')
.withArgs(this.alice, ethers.ZeroAddress, tokenId);
await expect(this.token.ownerOf(tokenId))
.to.be.revertedWithCustomError(this.token, 'ERC721NonexistentToken')
.withArgs(tokenId);
await expect(this.token.$_mint(this.bruce, tokenId))
.to.emit(this.token, 'Transfer')
.withArgs(ethers.ZeroAddress, this.bruce, tokenId);
expect(await this.token.ownerOf(tokenId)).to.equal(this.bruce);
});
it('tokens can be burned and re-minted #2', async function () {
const tokenId = sum(...this.batches.map(({ amount }) => amount)) + offset;
await expect(this.token.ownerOf(tokenId))
.to.be.revertedWithCustomError(this.token, 'ERC721NonexistentToken')
.withArgs(tokenId);
// mint
await expect(this.token.$_mint(this.alice, tokenId))
.to.emit(this.token, 'Transfer')
.withArgs(ethers.ZeroAddress, this.alice, tokenId);
expect(await this.token.ownerOf(tokenId)).to.equal(this.alice);
// burn
await expect(await this.token.$_burn(tokenId))
.to.emit(this.token, 'Transfer')
.withArgs(this.alice, ethers.ZeroAddress, tokenId);
await expect(this.token.ownerOf(tokenId))
.to.be.revertedWithCustomError(this.token, 'ERC721NonexistentToken')
.withArgs(tokenId);
// re-mint
await expect(this.token.$_mint(this.bruce, tokenId))
.to.emit(this.token, 'Transfer')
.withArgs(ethers.ZeroAddress, this.bruce, tokenId);
expect(await this.token.ownerOf(tokenId)).to.equal(this.bruce);
});
});
});
}
describe('invalid use', function () {
const receiver = ethers.Wallet.createRandom();
it('cannot mint a batch larger than 5000', async function () {
const { interface } = await ethers.getContractFactory('$ERC721ConsecutiveMock');
await expect(ethers.deployContract('$ERC721ConsecutiveMock', [name, symbol, 0, [], [receiver], [5001n]]))
.to.be.revertedWithCustomError({ interface }, 'ERC721ExceededMaxBatchMint')
.withArgs(5001n, 5000n);
});
it('cannot use single minting during construction', async function () {
const { interface } = await ethers.getContractFactory('$ERC721ConsecutiveNoConstructorMintMock');
await expect(
ethers.deployContract('$ERC721ConsecutiveNoConstructorMintMock', [name, symbol]),
).to.be.revertedWithCustomError({ interface }, 'ERC721ForbiddenMint');
});
it('cannot use single minting during construction', async function () {
const { interface } = await ethers.getContractFactory('$ERC721ConsecutiveNoConstructorMintMock');
await expect(
ethers.deployContract('$ERC721ConsecutiveNoConstructorMintMock', [name, symbol]),
).to.be.revertedWithCustomError({ interface }, 'ERC721ForbiddenMint');
});
it('consecutive mint not compatible with enumerability', async function () {
const { interface } = await ethers.getContractFactory('$ERC721ConsecutiveEnumerableMock');
await expect(
ethers.deployContract('$ERC721ConsecutiveEnumerableMock', [name, symbol, [receiver], [100n]]),
).to.be.revertedWithCustomError({ interface }, 'ERC721EnumerableForbiddenBatchMint');
});
});
});

View File

@@ -0,0 +1,81 @@
const { ethers } = require('hardhat');
const { expect } = require('chai');
const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers');
const name = 'Non Fungible Token';
const symbol = 'NFT';
const tokenId = 1n;
const otherTokenId = 2n;
const data = ethers.Typed.bytes('0x42');
async function fixture() {
const [owner, receiver, operator] = await ethers.getSigners();
const token = await ethers.deployContract('$ERC721Pausable', [name, symbol]);
return { owner, receiver, operator, token };
}
describe('ERC721Pausable', function () {
beforeEach(async function () {
Object.assign(this, await loadFixture(fixture));
});
describe('when token is paused', function () {
beforeEach(async function () {
await this.token.$_mint(this.owner, tokenId);
await this.token.$_pause();
});
it('reverts when trying to transferFrom', async function () {
await expect(
this.token.connect(this.owner).transferFrom(this.owner, this.receiver, tokenId),
).to.be.revertedWithCustomError(this.token, 'EnforcedPause');
});
it('reverts when trying to safeTransferFrom', async function () {
await expect(
this.token.connect(this.owner).safeTransferFrom(this.owner, this.receiver, tokenId),
).to.be.revertedWithCustomError(this.token, 'EnforcedPause');
});
it('reverts when trying to safeTransferFrom with data', async function () {
await expect(
this.token.connect(this.owner).safeTransferFrom(this.owner, this.receiver, tokenId, data),
).to.be.revertedWithCustomError(this.token, 'EnforcedPause');
});
it('reverts when trying to mint', async function () {
await expect(this.token.$_mint(this.receiver, otherTokenId)).to.be.revertedWithCustomError(
this.token,
'EnforcedPause',
);
});
it('reverts when trying to burn', async function () {
await expect(this.token.$_burn(tokenId)).to.be.revertedWithCustomError(this.token, 'EnforcedPause');
});
describe('getApproved', function () {
it('returns approved address', async function () {
expect(await this.token.getApproved(tokenId)).to.equal(ethers.ZeroAddress);
});
});
describe('balanceOf', function () {
it('returns the amount of tokens owned by the given address', async function () {
expect(await this.token.balanceOf(this.owner)).to.equal(1n);
});
});
describe('ownerOf', function () {
it('returns the amount of tokens owned by the given address', async function () {
expect(await this.token.ownerOf(tokenId)).to.equal(this.owner);
});
});
describe('isApprovedForAll', function () {
it('returns the approval of the operator', async function () {
expect(await this.token.isApprovedForAll(this.owner, this.operator)).to.be.false;
});
});
});
});

View File

@@ -0,0 +1,57 @@
const { ethers } = require('hardhat');
const { expect } = require('chai');
const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers');
const { shouldBehaveLikeERC2981 } = require('../../common/ERC2981.behavior');
const name = 'Non Fungible Token';
const symbol = 'NFT';
const tokenId1 = 1n;
const tokenId2 = 2n;
const royalty = 200n;
const salePrice = 1000n;
async function fixture() {
const [account1, account2, recipient] = await ethers.getSigners();
const token = await ethers.deployContract('$ERC721Royalty', [name, symbol]);
await token.$_mint(account1, tokenId1);
await token.$_mint(account1, tokenId2);
return { account1, account2, recipient, token };
}
describe('ERC721Royalty', function () {
beforeEach(async function () {
Object.assign(
this,
await loadFixture(fixture),
{ tokenId1, tokenId2, royalty, salePrice }, // set for behavior tests
);
});
describe('token specific functions', function () {
beforeEach(async function () {
await this.token.$_setTokenRoyalty(tokenId1, this.recipient, royalty);
});
it('royalty information are kept during burn and re-mint', async function () {
await this.token.$_burn(tokenId1);
expect(await this.token.royaltyInfo(tokenId1, salePrice)).to.deep.equal([
this.recipient.address,
(salePrice * royalty) / 10000n,
]);
await this.token.$_mint(this.account2, tokenId1);
expect(await this.token.royaltyInfo(tokenId1, salePrice)).to.deep.equal([
this.recipient.address,
(salePrice * royalty) / 10000n,
]);
});
});
shouldBehaveLikeERC2981();
});

View File

@@ -0,0 +1,121 @@
const { ethers } = require('hardhat');
const { expect } = require('chai');
const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers');
const { shouldSupportInterfaces } = require('../../../utils/introspection/SupportsInterface.behavior');
const name = 'Non Fungible Token';
const symbol = 'NFT';
const baseURI = 'https://api.example.com/v1/';
const otherBaseURI = 'https://api.example.com/v2/';
const sampleUri = 'mock://mytoken';
const tokenId = 1n;
const nonExistentTokenId = 2n;
async function fixture() {
const [owner] = await ethers.getSigners();
const token = await ethers.deployContract('$ERC721URIStorageMock', [name, symbol]);
return { owner, token };
}
describe('ERC721URIStorage', function () {
beforeEach(async function () {
Object.assign(this, await loadFixture(fixture));
});
shouldSupportInterfaces(['0x49064906']);
describe('token URI', function () {
beforeEach(async function () {
await this.token.$_mint(this.owner, tokenId);
});
it('it is empty by default', async function () {
expect(await this.token.tokenURI(tokenId)).to.equal('');
});
it('reverts when queried for non existent token id', async function () {
await expect(this.token.tokenURI(nonExistentTokenId))
.to.be.revertedWithCustomError(this.token, 'ERC721NonexistentToken')
.withArgs(nonExistentTokenId);
});
it('can be set for a token id', async function () {
await this.token.$_setTokenURI(tokenId, sampleUri);
expect(await this.token.tokenURI(tokenId)).to.equal(sampleUri);
});
it('setting the uri emits an event', async function () {
await expect(this.token.$_setTokenURI(tokenId, sampleUri))
.to.emit(this.token, 'MetadataUpdate')
.withArgs(tokenId);
});
it('setting the uri for non existent token id is allowed', async function () {
await expect(await this.token.$_setTokenURI(nonExistentTokenId, sampleUri))
.to.emit(this.token, 'MetadataUpdate')
.withArgs(nonExistentTokenId);
// value will be accessible after mint
await this.token.$_mint(this.owner, nonExistentTokenId);
expect(await this.token.tokenURI(nonExistentTokenId)).to.equal(sampleUri);
});
it('base URI can be set', async function () {
await this.token.setBaseURI(baseURI);
expect(await this.token.$_baseURI()).to.equal(baseURI);
});
it('base URI is added as a prefix to the token URI', async function () {
await this.token.setBaseURI(baseURI);
await this.token.$_setTokenURI(tokenId, sampleUri);
expect(await this.token.tokenURI(tokenId)).to.equal(baseURI + sampleUri);
});
it('token URI can be changed by changing the base URI', async function () {
await this.token.setBaseURI(baseURI);
await this.token.$_setTokenURI(tokenId, sampleUri);
await this.token.setBaseURI(otherBaseURI);
expect(await this.token.tokenURI(tokenId)).to.equal(otherBaseURI + sampleUri);
});
it('tokenId is appended to base URI for tokens with no URI', async function () {
await this.token.setBaseURI(baseURI);
expect(await this.token.tokenURI(tokenId)).to.equal(baseURI + tokenId);
});
it('tokens without URI can be burnt ', async function () {
await this.token.$_burn(tokenId);
await expect(this.token.tokenURI(tokenId))
.to.be.revertedWithCustomError(this.token, 'ERC721NonexistentToken')
.withArgs(tokenId);
});
it('tokens with URI can be burnt ', async function () {
await this.token.$_setTokenURI(tokenId, sampleUri);
await this.token.$_burn(tokenId);
await expect(this.token.tokenURI(tokenId))
.to.be.revertedWithCustomError(this.token, 'ERC721NonexistentToken')
.withArgs(tokenId);
});
it('tokens URI is kept if token is burnt and reminted ', async function () {
await this.token.$_setTokenURI(tokenId, sampleUri);
await this.token.$_burn(tokenId);
await expect(this.token.tokenURI(tokenId))
.to.be.revertedWithCustomError(this.token, 'ERC721NonexistentToken')
.withArgs(tokenId);
await this.token.$_mint(this.owner, tokenId);
expect(await this.token.tokenURI(tokenId)).to.equal(sampleUri);
});
});
});

View File

@@ -0,0 +1,194 @@
const { ethers } = require('hardhat');
const { expect } = require('chai');
const { loadFixture, mine } = require('@nomicfoundation/hardhat-network-helpers');
const time = require('../../../helpers/time');
const { shouldBehaveLikeVotes } = require('../../../governance/utils/Votes.behavior');
const TOKENS = [
{ Token: '$ERC721Votes', mode: 'blocknumber' },
// no timestamp mode for ERC721Votes yet
];
const name = 'My Vote';
const symbol = 'MTKN';
const version = '1';
const tokens = [ethers.parseEther('10000000'), 10n, 20n, 30n];
describe('ERC721Votes', function () {
for (const { Token, mode } of TOKENS) {
const fixture = async () => {
// accounts is required by shouldBehaveLikeVotes
const accounts = await ethers.getSigners();
const [holder, recipient, other1, other2] = accounts;
const token = await ethers.deployContract(Token, [name, symbol, name, version]);
return { accounts, holder, recipient, other1, other2, token };
};
describe(`vote with ${mode}`, function () {
beforeEach(async function () {
Object.assign(this, await loadFixture(fixture));
this.votes = this.token;
});
// includes ERC6372 behavior check
shouldBehaveLikeVotes(tokens, { mode, fungible: false });
describe('balanceOf', function () {
beforeEach(async function () {
await this.votes.$_mint(this.holder, tokens[0]);
await this.votes.$_mint(this.holder, tokens[1]);
await this.votes.$_mint(this.holder, tokens[2]);
await this.votes.$_mint(this.holder, tokens[3]);
});
it('grants to initial account', async function () {
expect(await this.votes.balanceOf(this.holder)).to.equal(4n);
});
});
describe('transfers', function () {
beforeEach(async function () {
await this.votes.$_mint(this.holder, tokens[0]);
});
it('no delegation', async function () {
await expect(this.votes.connect(this.holder).transferFrom(this.holder, this.recipient, tokens[0]))
.to.emit(this.token, 'Transfer')
.withArgs(this.holder, this.recipient, tokens[0])
.to.not.emit(this.token, 'DelegateVotesChanged');
this.holderVotes = 0n;
this.recipientVotes = 0n;
});
it('sender delegation', async function () {
await this.votes.connect(this.holder).delegate(this.holder);
const tx = await this.votes.connect(this.holder).transferFrom(this.holder, this.recipient, tokens[0]);
await expect(tx)
.to.emit(this.token, 'Transfer')
.withArgs(this.holder, this.recipient, tokens[0])
.to.emit(this.token, 'DelegateVotesChanged')
.withArgs(this.holder, 1n, 0n);
const { logs } = await tx.wait();
const { index } = logs.find(event => event.fragment.name == 'DelegateVotesChanged');
for (const event of logs.filter(event => event.fragment.name == 'Transfer')) {
expect(event.index).to.lt(index);
}
this.holderVotes = 0n;
this.recipientVotes = 0n;
});
it('receiver delegation', async function () {
await this.votes.connect(this.recipient).delegate(this.recipient);
const tx = await this.votes.connect(this.holder).transferFrom(this.holder, this.recipient, tokens[0]);
await expect(tx)
.to.emit(this.token, 'Transfer')
.withArgs(this.holder, this.recipient, tokens[0])
.to.emit(this.token, 'DelegateVotesChanged')
.withArgs(this.recipient, 0n, 1n);
const { logs } = await tx.wait();
const { index } = logs.find(event => event.fragment.name == 'DelegateVotesChanged');
for (const event of logs.filter(event => event.fragment.name == 'Transfer')) {
expect(event.index).to.lt(index);
}
this.holderVotes = 0n;
this.recipientVotes = 1n;
});
it('full delegation', async function () {
await this.votes.connect(this.holder).delegate(this.holder);
await this.votes.connect(this.recipient).delegate(this.recipient);
const tx = await this.votes.connect(this.holder).transferFrom(this.holder, this.recipient, tokens[0]);
await expect(tx)
.to.emit(this.token, 'Transfer')
.withArgs(this.holder, this.recipient, tokens[0])
.to.emit(this.token, 'DelegateVotesChanged')
.withArgs(this.holder, 1n, 0n)
.to.emit(this.token, 'DelegateVotesChanged')
.withArgs(this.recipient, 0n, 1n);
const { logs } = await tx.wait();
const { index } = logs.find(event => event.fragment.name == 'DelegateVotesChanged');
for (const event of logs.filter(event => event.fragment.name == 'Transfer')) {
expect(event.index).to.lt(index);
}
this.holderVotes = 0;
this.recipientVotes = 1n;
});
it('returns the same total supply on transfers', async function () {
await this.votes.connect(this.holder).delegate(this.holder);
const tx = await this.votes.connect(this.holder).transferFrom(this.holder, this.recipient, tokens[0]);
const timepoint = await time.clockFromReceipt[mode](tx);
await mine(2);
expect(await this.votes.getPastTotalSupply(timepoint - 1n)).to.equal(1n);
expect(await this.votes.getPastTotalSupply(timepoint + 1n)).to.equal(1n);
this.holderVotes = 0n;
this.recipientVotes = 0n;
});
it('generally returns the voting balance at the appropriate checkpoint', async function () {
await this.votes.$_mint(this.holder, tokens[1]);
await this.votes.$_mint(this.holder, tokens[2]);
await this.votes.$_mint(this.holder, tokens[3]);
const total = await this.votes.balanceOf(this.holder);
const t1 = await this.votes.connect(this.holder).delegate(this.other1);
await mine(2);
const t2 = await this.votes.connect(this.holder).transferFrom(this.holder, this.other2, tokens[0]);
await mine(2);
const t3 = await this.votes.connect(this.holder).transferFrom(this.holder, this.other2, tokens[2]);
await mine(2);
const t4 = await this.votes.connect(this.other2).transferFrom(this.other2, this.holder, tokens[2]);
await mine(2);
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);
expect(await this.votes.getPastVotes(this.other1, t1.timepoint - 1n)).to.equal(0n);
expect(await this.votes.getPastVotes(this.other1, t1.timepoint)).to.equal(total);
expect(await this.votes.getPastVotes(this.other1, t1.timepoint + 1n)).to.equal(total);
expect(await this.votes.getPastVotes(this.other1, t2.timepoint)).to.equal(3n);
expect(await this.votes.getPastVotes(this.other1, t2.timepoint + 1n)).to.equal(3n);
expect(await this.votes.getPastVotes(this.other1, t3.timepoint)).to.equal(2n);
expect(await this.votes.getPastVotes(this.other1, t3.timepoint + 1n)).to.equal(2n);
expect(await this.votes.getPastVotes(this.other1, t4.timepoint)).to.equal('3');
expect(await this.votes.getPastVotes(this.other1, t4.timepoint + 1n)).to.equal(3n);
this.holderVotes = 0n;
this.recipientVotes = 0n;
});
afterEach(async function () {
expect(await this.votes.getVotes(this.holder)).to.equal(this.holderVotes);
expect(await this.votes.getVotes(this.recipient)).to.equal(this.recipientVotes);
// need to advance 2 blocks to see the effect of a transfer on "getPastVotes"
const timepoint = await time.clock[mode]();
await mine();
expect(await this.votes.getPastVotes(this.holder, timepoint)).to.equal(this.holderVotes);
expect(await this.votes.getPastVotes(this.recipient, timepoint)).to.equal(this.recipientVotes);
});
});
});
}
});

View File

@@ -0,0 +1,201 @@
const { ethers } = require('hardhat');
const { expect } = require('chai');
const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers');
const { shouldBehaveLikeERC721 } = require('../ERC721.behavior');
const name = 'Non Fungible Token';
const symbol = 'NFT';
const tokenId = 1n;
const otherTokenId = 2n;
async function fixture() {
const accounts = await ethers.getSigners();
const [owner, approved, other] = accounts;
const underlying = await ethers.deployContract('$ERC721', [name, symbol]);
await underlying.$_safeMint(owner, tokenId);
await underlying.$_safeMint(owner, otherTokenId);
const token = await ethers.deployContract('$ERC721Wrapper', [`Wrapped ${name}`, `W${symbol}`, underlying]);
return { accounts, owner, approved, other, underlying, token };
}
describe('ERC721Wrapper', function () {
beforeEach(async function () {
Object.assign(this, await loadFixture(fixture));
});
it('has a name', async function () {
expect(await this.token.name()).to.equal(`Wrapped ${name}`);
});
it('has a symbol', async function () {
expect(await this.token.symbol()).to.equal(`W${symbol}`);
});
it('has underlying', async function () {
expect(await this.token.underlying()).to.equal(this.underlying);
});
describe('depositFor', function () {
it('works with token approval', async function () {
await this.underlying.connect(this.owner).approve(this.token, tokenId);
await expect(this.token.connect(this.owner).depositFor(this.owner, [tokenId]))
.to.emit(this.underlying, 'Transfer')
.withArgs(this.owner, this.token, tokenId)
.to.emit(this.token, 'Transfer')
.withArgs(ethers.ZeroAddress, this.owner, tokenId);
});
it('works with approval for all', async function () {
await this.underlying.connect(this.owner).setApprovalForAll(this.token, true);
await expect(this.token.connect(this.owner).depositFor(this.owner, [tokenId]))
.to.emit(this.underlying, 'Transfer')
.withArgs(this.owner, this.token, tokenId)
.to.emit(this.token, 'Transfer')
.withArgs(ethers.ZeroAddress, this.owner, tokenId);
});
it('works sending to another account', async function () {
await this.underlying.connect(this.owner).approve(this.token, tokenId);
await expect(this.token.connect(this.owner).depositFor(this.other, [tokenId]))
.to.emit(this.underlying, 'Transfer')
.withArgs(this.owner, this.token, tokenId)
.to.emit(this.token, 'Transfer')
.withArgs(ethers.ZeroAddress, this.other, tokenId);
});
it('works with multiple tokens', async function () {
await this.underlying.connect(this.owner).approve(this.token, tokenId);
await this.underlying.connect(this.owner).approve(this.token, otherTokenId);
await expect(this.token.connect(this.owner).depositFor(this.owner, [tokenId, otherTokenId]))
.to.emit(this.underlying, 'Transfer')
.withArgs(this.owner, this.token, tokenId)
.to.emit(this.token, 'Transfer')
.withArgs(ethers.ZeroAddress, this.owner, tokenId)
.to.emit(this.underlying, 'Transfer')
.withArgs(this.owner, this.token, otherTokenId)
.to.emit(this.token, 'Transfer')
.withArgs(ethers.ZeroAddress, this.owner, otherTokenId);
});
it('reverts with missing approval', async function () {
await expect(this.token.connect(this.owner).depositFor(this.owner, [tokenId]))
.to.be.revertedWithCustomError(this.token, 'ERC721InsufficientApproval')
.withArgs(this.token, tokenId);
});
});
describe('withdrawTo', function () {
beforeEach(async function () {
await this.underlying.connect(this.owner).approve(this.token, tokenId);
await this.token.connect(this.owner).depositFor(this.owner, [tokenId]);
});
it('works for an owner', async function () {
await expect(this.token.connect(this.owner).withdrawTo(this.owner, [tokenId]))
.to.emit(this.underlying, 'Transfer')
.withArgs(this.token, this.owner, tokenId)
.to.emit(this.token, 'Transfer')
.withArgs(this.owner, ethers.ZeroAddress, tokenId);
});
it('works for an approved', async function () {
await this.token.connect(this.owner).approve(this.approved, tokenId);
await expect(this.token.connect(this.approved).withdrawTo(this.owner, [tokenId]))
.to.emit(this.underlying, 'Transfer')
.withArgs(this.token, this.owner, tokenId)
.to.emit(this.token, 'Transfer')
.withArgs(this.owner, ethers.ZeroAddress, tokenId);
});
it('works for an approved for all', async function () {
await this.token.connect(this.owner).setApprovalForAll(this.approved, true);
await expect(this.token.connect(this.approved).withdrawTo(this.owner, [tokenId]))
.to.emit(this.underlying, 'Transfer')
.withArgs(this.token, this.owner, tokenId)
.to.emit(this.token, 'Transfer')
.withArgs(this.owner, ethers.ZeroAddress, tokenId);
});
it("doesn't work for a non-owner nor approved", async function () {
await expect(this.token.connect(this.other).withdrawTo(this.owner, [tokenId]))
.to.be.revertedWithCustomError(this.token, 'ERC721InsufficientApproval')
.withArgs(this.other, tokenId);
});
it('works with multiple tokens', async function () {
await this.underlying.connect(this.owner).approve(this.token, otherTokenId);
await this.token.connect(this.owner).depositFor(this.owner, [otherTokenId]);
await expect(this.token.connect(this.owner).withdrawTo(this.owner, [tokenId, otherTokenId]))
.to.emit(this.underlying, 'Transfer')
.withArgs(this.token, this.owner, tokenId)
.to.emit(this.underlying, 'Transfer')
.withArgs(this.token, this.owner, tokenId)
.to.emit(this.token, 'Transfer')
.withArgs(this.owner, ethers.ZeroAddress, tokenId)
.to.emit(this.token, 'Transfer')
.withArgs(this.owner, ethers.ZeroAddress, tokenId);
});
it('works to another account', async function () {
await expect(this.token.connect(this.owner).withdrawTo(this.other, [tokenId]))
.to.emit(this.underlying, 'Transfer')
.withArgs(this.token, this.other, tokenId)
.to.emit(this.token, 'Transfer')
.withArgs(this.owner, ethers.ZeroAddress, tokenId);
});
});
describe('onERC721Received', function () {
it('only allows calls from underlying', async function () {
await expect(
this.token.connect(this.other).onERC721Received(
this.owner,
this.token,
tokenId,
this.other.address, // Correct data
),
)
.to.be.revertedWithCustomError(this.token, 'ERC721UnsupportedToken')
.withArgs(this.other);
});
it('mints a token to from', async function () {
await expect(this.underlying.connect(this.owner).safeTransferFrom(this.owner, this.token, tokenId))
.to.emit(this.token, 'Transfer')
.withArgs(ethers.ZeroAddress, this.owner, tokenId);
});
});
describe('_recover', function () {
it('works if there is something to recover', async function () {
// Should use `transferFrom` to avoid `onERC721Received` minting
await this.underlying.connect(this.owner).transferFrom(this.owner, this.token, tokenId);
await expect(this.token.$_recover(this.other, tokenId))
.to.emit(this.token, 'Transfer')
.withArgs(ethers.ZeroAddress, this.other, tokenId);
});
it('reverts if there is nothing to recover', async function () {
const holder = await this.underlying.ownerOf(tokenId);
await expect(this.token.$_recover(holder, tokenId))
.to.be.revertedWithCustomError(this.token, 'ERC721IncorrectOwner')
.withArgs(this.token, tokenId, holder);
});
});
describe('ERC712 behavior', function () {
shouldBehaveLikeERC721();
});
});

View File

@@ -0,0 +1,20 @@
const { ethers } = require('hardhat');
const { expect } = require('chai');
const name = 'Non Fungible Token';
const symbol = 'NFT';
const tokenId = 1n;
describe('ERC721Holder', function () {
it('receives an ERC721 token', async function () {
const [owner] = await ethers.getSigners();
const token = await ethers.deployContract('$ERC721', [name, symbol]);
await token.$_mint(owner, tokenId);
const receiver = await ethers.deployContract('$ERC721Holder');
await token.connect(owner).safeTransferFrom(owner, receiver, tokenId);
expect(await token.ownerOf(tokenId)).to.equal(receiver);
});
});

View File

@@ -0,0 +1,94 @@
const { ethers } = require('hardhat');
const { expect } = require('chai');
const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers');
const { RevertType } = require('../../../helpers/enums');
const { PANIC_CODES } = require('@nomicfoundation/hardhat-chai-matchers/panic');
const tokenId = 1n;
const RECEIVER_MAGIC_VALUE = '0x150b7a02';
const deployReceiver = (revertType, returnValue = RECEIVER_MAGIC_VALUE) =>
ethers.deployContract('$ERC721ReceiverMock', [returnValue, revertType]);
const fixture = async () => {
const [eoa, operator, owner] = await ethers.getSigners();
const utils = await ethers.deployContract('$ERC721Utils');
const receivers = {
correct: await deployReceiver(RevertType.None),
invalid: await deployReceiver(RevertType.None, '0xdeadbeef'),
message: await deployReceiver(RevertType.RevertWithMessage),
empty: await deployReceiver(RevertType.RevertWithoutMessage),
customError: await deployReceiver(RevertType.RevertWithCustomError),
panic: await deployReceiver(RevertType.Panic),
nonReceiver: await ethers.deployContract('CallReceiverMock'),
eoa,
};
return { operator, owner, utils, receivers };
};
describe('ERC721Utils', function () {
beforeEach(async function () {
Object.assign(this, await loadFixture(fixture));
});
describe('onERC721Received', function () {
it('succeeds when called by an EOA', async function () {
await expect(this.utils.$checkOnERC721Received(this.operator, this.owner, this.receivers.eoa, tokenId, '0x')).to
.not.be.reverted;
});
it('succeeds when data is passed', async function () {
const data = '0x12345678';
await expect(this.utils.$checkOnERC721Received(this.operator, this.owner, this.receivers.correct, tokenId, data))
.to.not.be.reverted;
});
it('succeeds when data is empty', async function () {
await expect(this.utils.$checkOnERC721Received(this.operator, this.owner, this.receivers.correct, tokenId, '0x'))
.to.not.be.reverted;
});
it('reverts when receiver returns invalid value', async function () {
await expect(this.utils.$checkOnERC721Received(this.operator, this.owner, this.receivers.invalid, tokenId, '0x'))
.to.be.revertedWithCustomError(this.utils, 'ERC721InvalidReceiver')
.withArgs(this.receivers.invalid);
});
it('reverts when receiver reverts with message', async function () {
await expect(
this.utils.$checkOnERC721Received(this.operator, this.owner, this.receivers.message, tokenId, '0x'),
).to.be.revertedWith('ERC721ReceiverMock: reverting');
});
it('reverts when receiver reverts without message', async function () {
await expect(this.utils.$checkOnERC721Received(this.operator, this.owner, this.receivers.empty, tokenId, '0x'))
.to.be.revertedWithCustomError(this.utils, 'ERC721InvalidReceiver')
.withArgs(this.receivers.empty);
});
it('reverts when receiver reverts with custom error', async function () {
await expect(
this.utils.$checkOnERC721Received(this.operator, this.owner, this.receivers.customError, tokenId, '0x'),
)
.to.be.revertedWithCustomError(this.receivers.customError, 'CustomError')
.withArgs(RECEIVER_MAGIC_VALUE);
});
it('reverts when receiver panics', async function () {
await expect(
this.utils.$checkOnERC721Received(this.operator, this.owner, this.receivers.panic, tokenId, '0x'),
).to.be.revertedWithPanic(PANIC_CODES.DIVISION_BY_ZERO);
});
it('reverts when receiver does not implement onERC721Received', async function () {
await expect(
this.utils.$checkOnERC721Received(this.operator, this.owner, this.receivers.nonReceiver, tokenId, '0x'),
)
.to.be.revertedWithCustomError(this.utils, 'ERC721InvalidReceiver')
.withArgs(this.receivers.nonReceiver);
});
});
});

View File

@@ -0,0 +1,152 @@
const { ethers } = require('hardhat');
const { expect } = require('chai');
const { shouldSupportInterfaces } = require('../../utils/introspection/SupportsInterface.behavior');
function shouldBehaveLikeERC2981() {
const royaltyFraction = 10n;
shouldSupportInterfaces(['ERC2981']);
describe('default royalty', function () {
beforeEach(async function () {
await this.token.$_setDefaultRoyalty(this.account1, royaltyFraction);
});
it('checks royalty is set', async function () {
expect(await this.token.royaltyInfo(this.tokenId1, this.salePrice)).to.deep.equal([
this.account1.address,
(this.salePrice * royaltyFraction) / 10_000n,
]);
});
it('updates royalty amount', async function () {
const newFraction = 25n;
await this.token.$_setDefaultRoyalty(this.account1, newFraction);
expect(await this.token.royaltyInfo(this.tokenId1, this.salePrice)).to.deep.equal([
this.account1.address,
(this.salePrice * newFraction) / 10_000n,
]);
});
it('holds same royalty value for different tokens', async function () {
const newFraction = 20n;
await this.token.$_setDefaultRoyalty(this.account1, newFraction);
expect(await this.token.royaltyInfo(this.tokenId1, this.salePrice)).to.deep.equal(
await this.token.royaltyInfo(this.tokenId2, this.salePrice),
);
});
it('Remove royalty information', async function () {
const newValue = 0n;
await this.token.$_deleteDefaultRoyalty();
expect(await this.token.royaltyInfo(this.tokenId1, this.salePrice)).to.deep.equal([ethers.ZeroAddress, newValue]);
expect(await this.token.royaltyInfo(this.tokenId2, this.salePrice)).to.deep.equal([ethers.ZeroAddress, newValue]);
});
it('reverts if invalid parameters', async function () {
const royaltyDenominator = await this.token.$_feeDenominator();
await expect(this.token.$_setDefaultRoyalty(ethers.ZeroAddress, royaltyFraction))
.to.be.revertedWithCustomError(this.token, 'ERC2981InvalidDefaultRoyaltyReceiver')
.withArgs(ethers.ZeroAddress);
const anotherRoyaltyFraction = 11000n;
await expect(this.token.$_setDefaultRoyalty(this.account1, anotherRoyaltyFraction))
.to.be.revertedWithCustomError(this.token, 'ERC2981InvalidDefaultRoyalty')
.withArgs(anotherRoyaltyFraction, royaltyDenominator);
});
});
describe('token based royalty', function () {
beforeEach(async function () {
await this.token.$_setTokenRoyalty(this.tokenId1, this.account1, royaltyFraction);
});
it('updates royalty amount', async function () {
const newFraction = 25n;
expect(await this.token.royaltyInfo(this.tokenId1, this.salePrice)).to.deep.equal([
this.account1.address,
(this.salePrice * royaltyFraction) / 10_000n,
]);
await this.token.$_setTokenRoyalty(this.tokenId1, this.account1, newFraction);
expect(await this.token.royaltyInfo(this.tokenId1, this.salePrice)).to.deep.equal([
this.account1.address,
(this.salePrice * newFraction) / 10_000n,
]);
});
it('holds different values for different tokens', async function () {
const newFraction = 20n;
await this.token.$_setTokenRoyalty(this.tokenId2, this.account1, newFraction);
expect(await this.token.royaltyInfo(this.tokenId1, this.salePrice)).to.not.deep.equal(
await this.token.royaltyInfo(this.tokenId2, this.salePrice),
);
});
it('reverts if invalid parameters', async function () {
const royaltyDenominator = await this.token.$_feeDenominator();
await expect(this.token.$_setTokenRoyalty(this.tokenId1, ethers.ZeroAddress, royaltyFraction))
.to.be.revertedWithCustomError(this.token, 'ERC2981InvalidTokenRoyaltyReceiver')
.withArgs(this.tokenId1, ethers.ZeroAddress);
const anotherRoyaltyFraction = 11000n;
await expect(this.token.$_setTokenRoyalty(this.tokenId1, this.account1, anotherRoyaltyFraction))
.to.be.revertedWithCustomError(this.token, 'ERC2981InvalidTokenRoyalty')
.withArgs(this.tokenId1, anotherRoyaltyFraction, royaltyDenominator);
});
it('can reset token after setting royalty', async function () {
const newFraction = 30n;
await this.token.$_setTokenRoyalty(this.tokenId1, this.account2, newFraction);
// Tokens must have own information
expect(await this.token.royaltyInfo(this.tokenId1, this.salePrice)).to.deep.equal([
this.account2.address,
(this.salePrice * newFraction) / 10_000n,
]);
await this.token.$_setTokenRoyalty(this.tokenId2, this.account1, 0n);
// Token must not share default information
expect(await this.token.royaltyInfo(this.tokenId2, this.salePrice)).to.deep.equal([this.account1.address, 0n]);
});
it('can hold default and token royalty information', async function () {
const newFraction = 30n;
await this.token.$_setTokenRoyalty(this.tokenId2, this.account2, newFraction);
// Tokens must not have same values
expect(await this.token.royaltyInfo(this.tokenId1, this.salePrice)).to.not.deep.equal([
this.account2.address,
(this.salePrice * newFraction) / 10_000n,
]);
// Updated token must have new values
expect(await this.token.royaltyInfo(this.tokenId2, this.salePrice)).to.deep.equal([
this.account2.address,
(this.salePrice * newFraction) / 10_000n,
]);
});
});
}
module.exports = {
shouldBehaveLikeERC2981,
};

View File

@@ -0,0 +1,280 @@
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 coder = ethers.AbiCoder.defaultAbiCoder();
async function fixture() {
const [recipient, other] = await ethers.getSigners();
const mock = await ethers.deployContract('$Address');
const target = await ethers.deployContract('CallReceiverMock');
const targetEther = await ethers.deployContract('EtherReceiverMock');
return { recipient, other, mock, target, targetEther };
}
describe('Address', function () {
beforeEach(async function () {
Object.assign(this, await loadFixture(fixture));
});
describe('sendValue', function () {
describe('when sender contract has no funds', function () {
it('sends 0 wei', async function () {
await expect(this.mock.$sendValue(this.other, 0n)).to.changeEtherBalance(this.recipient, 0n);
});
it('reverts when sending non-zero amounts', async function () {
await expect(this.mock.$sendValue(this.other, 1n))
.to.be.revertedWithCustomError(this.mock, 'InsufficientBalance')
.withArgs(0n, 1n);
});
});
describe('when sender contract has funds', function () {
const funds = ethers.parseEther('1');
beforeEach(async function () {
await this.other.sendTransaction({ to: this.mock, value: funds });
});
describe('with EOA recipient', function () {
it('sends 0 wei', async function () {
await expect(this.mock.$sendValue(this.recipient, 0n)).to.changeEtherBalance(this.recipient, 0n);
});
it('sends non-zero amounts', async function () {
await expect(this.mock.$sendValue(this.recipient, funds - 1n)).to.changeEtherBalance(
this.recipient,
funds - 1n,
);
});
it('sends the whole balance', async function () {
await expect(this.mock.$sendValue(this.recipient, funds)).to.changeEtherBalance(this.recipient, funds);
expect(await ethers.provider.getBalance(this.mock)).to.equal(0n);
});
it('reverts when sending more than the balance', async function () {
await expect(this.mock.$sendValue(this.recipient, funds + 1n))
.to.be.revertedWithCustomError(this.mock, 'InsufficientBalance')
.withArgs(funds, funds + 1n);
});
});
describe('with contract recipient', function () {
it('sends funds', async function () {
await this.targetEther.setAcceptEther(true);
await expect(this.mock.$sendValue(this.targetEther, funds)).to.changeEtherBalance(this.targetEther, funds);
});
it('reverts on recipient revert', async function () {
await this.targetEther.setAcceptEther(false);
await expect(this.mock.$sendValue(this.targetEther, funds)).to.be.revertedWithCustomError(
this.mock,
'FailedCall',
);
});
});
});
});
describe('functionCall', function () {
describe('with valid contract receiver', function () {
it('calls the requested function', async function () {
const call = this.target.interface.encodeFunctionData('mockFunction');
await expect(this.mock.$functionCall(this.target, call))
.to.emit(this.target, 'MockFunctionCalled')
.to.emit(this.mock, 'return$functionCall')
.withArgs(coder.encode(['string'], ['0x1234']));
});
it('calls the requested empty return function', async function () {
const call = this.target.interface.encodeFunctionData('mockFunctionEmptyReturn');
await expect(this.mock.$functionCall(this.target, call)).to.emit(this.target, 'MockFunctionCalled');
});
it('reverts when the called function reverts with no reason', async function () {
const call = this.target.interface.encodeFunctionData('mockFunctionRevertsNoReason');
await expect(this.mock.$functionCall(this.target, call)).to.be.revertedWithCustomError(this.mock, 'FailedCall');
});
it('reverts when the called function reverts, bubbling up the revert reason', async function () {
const call = this.target.interface.encodeFunctionData('mockFunctionRevertsReason');
await expect(this.mock.$functionCall(this.target, call)).to.be.revertedWith('CallReceiverMock: reverting');
});
it('reverts when the called function runs out of gas', async function () {
const call = this.target.interface.encodeFunctionData('mockFunctionOutOfGas');
await expect(this.mock.$functionCall(this.target, call, { gasLimit: 120_000n })).to.be.revertedWithCustomError(
this.mock,
'FailedCall',
);
});
it('reverts when the called function throws', async function () {
const call = this.target.interface.encodeFunctionData('mockFunctionThrows');
await expect(this.mock.$functionCall(this.target, call)).to.be.revertedWithPanic(PANIC_CODES.ASSERTION_ERROR);
});
it('reverts when function does not exist', async function () {
const interface = new ethers.Interface(['function mockFunctionDoesNotExist()']);
const call = interface.encodeFunctionData('mockFunctionDoesNotExist');
await expect(this.mock.$functionCall(this.target, call)).to.be.revertedWithCustomError(this.mock, 'FailedCall');
});
});
describe('with non-contract receiver', function () {
it('reverts when address is not a contract', async function () {
const call = this.target.interface.encodeFunctionData('mockFunction');
await expect(this.mock.$functionCall(this.recipient, call))
.to.be.revertedWithCustomError(this.mock, 'AddressEmptyCode')
.withArgs(this.recipient);
});
});
});
describe('functionCallWithValue', function () {
describe('with zero value', function () {
it('calls the requested function', async function () {
const call = this.target.interface.encodeFunctionData('mockFunction');
await expect(this.mock.$functionCallWithValue(this.target, call, 0n))
.to.emit(this.target, 'MockFunctionCalled')
.to.emit(this.mock, 'return$functionCallWithValue')
.withArgs(coder.encode(['string'], ['0x1234']));
});
});
describe('with non-zero value', function () {
const value = ethers.parseEther('1.2');
it('reverts if insufficient sender balance', async function () {
const call = this.target.interface.encodeFunctionData('mockFunction');
await expect(this.mock.$functionCallWithValue(this.target, call, value))
.to.be.revertedWithCustomError(this.mock, 'InsufficientBalance')
.withArgs(0n, value);
});
it('calls the requested function with existing value', async function () {
await this.other.sendTransaction({ to: this.mock, value });
const call = this.target.interface.encodeFunctionData('mockFunction');
const tx = await this.mock.$functionCallWithValue(this.target, call, value);
await expect(tx).to.changeEtherBalance(this.target, value);
await expect(tx)
.to.emit(this.target, 'MockFunctionCalled')
.to.emit(this.mock, 'return$functionCallWithValue')
.withArgs(coder.encode(['string'], ['0x1234']));
});
it('calls the requested function with transaction funds', async function () {
expect(await ethers.provider.getBalance(this.mock)).to.equal(0n);
const call = this.target.interface.encodeFunctionData('mockFunction');
const tx = await this.mock.connect(this.other).$functionCallWithValue(this.target, call, value, { value });
await expect(tx).to.changeEtherBalance(this.target, value);
await expect(tx)
.to.emit(this.target, 'MockFunctionCalled')
.to.emit(this.mock, 'return$functionCallWithValue')
.withArgs(coder.encode(['string'], ['0x1234']));
});
it('reverts when calling non-payable functions', async function () {
await this.other.sendTransaction({ to: this.mock, value });
const call = this.target.interface.encodeFunctionData('mockFunctionNonPayable');
await expect(this.mock.$functionCallWithValue(this.target, call, value)).to.be.revertedWithCustomError(
this.mock,
'FailedCall',
);
});
});
});
describe('functionStaticCall', function () {
it('calls the requested function', async function () {
const call = this.target.interface.encodeFunctionData('mockStaticFunction');
expect(await this.mock.$functionStaticCall(this.target, call)).to.equal(coder.encode(['string'], ['0x1234']));
});
it('reverts on a non-static function', async function () {
const call = this.target.interface.encodeFunctionData('mockFunction');
await expect(this.mock.$functionStaticCall(this.target, call)).to.be.revertedWithCustomError(
this.mock,
'FailedCall',
);
});
it('bubbles up revert reason', async function () {
const call = this.target.interface.encodeFunctionData('mockFunctionRevertsReason');
await expect(this.mock.$functionStaticCall(this.target, call)).to.be.revertedWith('CallReceiverMock: reverting');
});
it('reverts when address is not a contract', async function () {
const call = this.target.interface.encodeFunctionData('mockFunction');
await expect(this.mock.$functionStaticCall(this.recipient, call))
.to.be.revertedWithCustomError(this.mock, 'AddressEmptyCode')
.withArgs(this.recipient);
});
});
describe('functionDelegateCall', function () {
it('delegate calls the requested function', async function () {
const slot = ethers.hexlify(ethers.randomBytes(32));
const value = ethers.hexlify(ethers.randomBytes(32));
const call = this.target.interface.encodeFunctionData('mockFunctionWritesStorage', [slot, value]);
expect(await ethers.provider.getStorage(this.mock, slot)).to.equal(ethers.ZeroHash);
await expect(await this.mock.$functionDelegateCall(this.target, call))
.to.emit(this.mock, 'return$functionDelegateCall')
.withArgs(coder.encode(['string'], ['0x1234']));
expect(await ethers.provider.getStorage(this.mock, slot)).to.equal(value);
});
it('bubbles up revert reason', async function () {
const call = this.target.interface.encodeFunctionData('mockFunctionRevertsReason');
await expect(this.mock.$functionDelegateCall(this.target, call)).to.be.revertedWith(
'CallReceiverMock: reverting',
);
});
it('reverts when address is not a contract', async function () {
const call = this.target.interface.encodeFunctionData('mockFunction');
await expect(this.mock.$functionDelegateCall(this.recipient, call))
.to.be.revertedWithCustomError(this.mock, 'AddressEmptyCode')
.withArgs(this.recipient);
});
});
describe('verifyCallResult', function () {
it('returns returndata on success', async function () {
const returndata = '0x123abc';
expect(await this.mock.$verifyCallResult(true, returndata)).to.equal(returndata);
});
});
});

View File

@@ -0,0 +1,15 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {Test} from "@forge-std/Test.sol";
import {Arrays} from "@openzeppelin/contracts/utils/Arrays.sol";
contract ArraysTest is Test {
function testSort(uint256[] memory values) public {
Arrays.sort(values);
for (uint256 i = 1; i < values.length; ++i) {
assertLe(values[i - 1], values[i]);
}
}
}

View File

@@ -0,0 +1,223 @@
const { ethers } = require('hardhat');
const { expect } = require('chai');
const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers');
const { generators } = require('../helpers/random');
const { capitalize } = require('../../scripts/helpers');
const { TYPES } = require('../../scripts/generate/templates/Arrays.opts');
// See https://en.cppreference.com/w/cpp/algorithm/lower_bound
const lowerBound = (array, value) => {
const i = array.findIndex(element => value <= element);
return i == -1 ? array.length : i;
};
// See https://en.cppreference.com/w/cpp/algorithm/upper_bound
const upperBound = (array, value) => {
const i = array.findIndex(element => value < element);
return i == -1 ? array.length : i;
};
const bigintSign = x => (x > 0n ? 1 : x < 0n ? -1 : 0);
const comparator = (a, b) => bigintSign(ethers.toBigInt(a) - ethers.toBigInt(b));
const hasDuplicates = array => array.some((v, i) => array.indexOf(v) != i);
describe('Arrays', function () {
const fixture = async () => {
return { mock: await ethers.deployContract('$Arrays') };
};
beforeEach(async function () {
Object.assign(this, await loadFixture(fixture));
});
describe('search', function () {
for (const [title, { array, tests }] of Object.entries({
'Even number of elements': {
array: [11n, 12n, 13n, 14n, 15n, 16n, 17n, 18n, 19n, 20n],
tests: {
'basic case': 16n,
'first element': 11n,
'last element': 20n,
'searched value is over the upper boundary': 32n,
'searched value is under the lower boundary': 2n,
},
},
'Odd number of elements': {
array: [11n, 12n, 13n, 14n, 15n, 16n, 17n, 18n, 19n, 20n, 21n],
tests: {
'basic case': 16n,
'first element': 11n,
'last element': 21n,
'searched value is over the upper boundary': 32n,
'searched value is under the lower boundary': 2n,
},
},
'Array with gap': {
array: [11n, 12n, 13n, 14n, 15n, 20n, 21n, 22n, 23n, 24n],
tests: {
'search value in gap': 17n,
},
},
'Array with duplicated elements': {
array: [0n, 10n, 10n, 10n, 10n, 10n, 10n, 10n, 20n],
tests: {
'search value is duplicated': 10n,
},
},
'Array with duplicated first element': {
array: [10n, 10n, 10n, 10n, 10n, 10n, 10n, 20n],
tests: {
'search value is duplicated first element': 10n,
},
},
'Array with duplicated last element': {
array: [0n, 10n, 10n, 10n, 10n, 10n, 10n, 10n],
tests: {
'search value is duplicated last element': 10n,
},
},
'Empty array': {
array: [],
tests: {
'always returns 0 for empty array': 10n,
},
},
})) {
describe(title, function () {
const fixture = async () => {
return { instance: await ethers.deployContract('Uint256ArraysMock', [array]) };
};
beforeEach(async function () {
Object.assign(this, await loadFixture(fixture));
});
for (const [name, input] of Object.entries(tests)) {
describe(name, function () {
it('[deprecated] findUpperBound', async function () {
// findUpperBound does not support duplicated
if (hasDuplicates(array)) {
expect(await this.instance.findUpperBound(input)).to.equal(upperBound(array, input) - 1);
} else {
expect(await this.instance.findUpperBound(input)).to.equal(lowerBound(array, input));
}
});
it('lowerBound', async function () {
expect(await this.instance.lowerBound(input)).to.equal(lowerBound(array, input));
expect(await this.instance.lowerBoundMemory(array, input)).to.equal(lowerBound(array, input));
});
it('upperBound', async function () {
expect(await this.instance.upperBound(input)).to.equal(upperBound(array, input));
expect(await this.instance.upperBoundMemory(array, input)).to.equal(upperBound(array, input));
});
});
}
});
}
});
for (const type of TYPES) {
const elements = Array.from({ length: 10 }, generators[type]);
describe(type, function () {
const fixture = async () => {
return { instance: await ethers.deployContract(`${capitalize(type)}ArraysMock`, [elements]) };
};
beforeEach(async function () {
Object.assign(this, await loadFixture(fixture));
});
describe('sort', function () {
for (const length of [0, 1, 2, 8, 32, 128]) {
describe(`${type}[] of length ${length}`, function () {
beforeEach(async function () {
this.array = Array.from({ length }, generators[type]);
});
afterEach(async function () {
const expected = Array.from(this.array).sort(comparator);
const reversed = Array.from(expected).reverse();
expect(await this.instance.sort(this.array)).to.deep.equal(expected);
expect(await this.instance.sortReverse(this.array)).to.deep.equal(reversed);
});
it('sort array', async function () {
// nothing to do here, beforeEach and afterEach already take care of everything.
});
if (length > 1) {
it('sort array for identical elements', async function () {
// duplicate the first value to all elements
this.array.fill(this.array.at(0));
});
it('sort already sorted array', async function () {
// pre-sort the elements
this.array.sort(comparator);
});
it('sort reversed array', async function () {
// pre-sort in reverse order
this.array.sort(comparator).reverse();
});
it('sort almost sorted array', async function () {
// pre-sort + rotate (move the last element to the front) for an almost sorted effect
this.array.sort(comparator);
this.array.unshift(this.array.pop());
});
}
});
}
});
describe('unsafeAccess', function () {
describe('storage', function () {
for (const i in elements) {
it(`unsafeAccess within bounds #${i}`, async function () {
expect(await this.instance.unsafeAccess(i)).to.equal(elements[i]);
});
}
it('unsafeAccess outside bounds', async function () {
await expect(this.instance.unsafeAccess(elements.length)).to.not.be.rejected;
});
it('unsafeSetLength changes the length or the array', async function () {
const newLength = generators.uint256();
expect(await this.instance.length()).to.equal(elements.length);
await expect(this.instance.unsafeSetLength(newLength)).to.not.be.rejected;
expect(await this.instance.length()).to.equal(newLength);
});
});
describe('memory', function () {
const fragment = `$unsafeMemoryAccess(${type}[] arr, uint256 pos)`;
for (const i in elements) {
it(`unsafeMemoryAccess within bounds #${i}`, async function () {
expect(await this.mock[fragment](elements, i)).to.equal(elements[i]);
});
}
it('unsafeMemoryAccess outside bounds', async function () {
await expect(this.mock[fragment](elements, elements.length)).to.not.be.rejected;
});
it('unsafeMemoryAccess loop around', async function () {
for (let i = 251n; i < 256n; ++i) {
expect(await this.mock[fragment](elements, 2n ** i - 1n)).to.equal(BigInt(elements.length));
expect(await this.mock[fragment](elements, 2n ** i + 0n)).to.equal(elements[0]);
expect(await this.mock[fragment](elements, 2n ** i + 1n)).to.equal(elements[1]);
}
});
});
});
});
}
});

View File

@@ -0,0 +1,34 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {Test} from "@forge-std/Test.sol";
import {Base64} from "@openzeppelin/contracts/utils/Base64.sol";
contract Base64Test is Test {
function testEncode(bytes memory input) external {
assertEq(Base64.encode(input), vm.toBase64(input));
}
function testEncodeURL(bytes memory input) external {
assertEq(Base64.encodeURL(input), _removePadding(vm.toBase64URL(input)));
}
function _removePadding(string memory inputStr) internal pure returns (string memory) {
bytes memory input = bytes(inputStr);
bytes memory output;
for (uint256 i = 0; i < input.length; ++i) {
if (input[input.length - i - 1] != 0x3d) {
output = new bytes(input.length - i);
break;
}
}
for (uint256 i = 0; i < output.length; ++i) {
output[i] = input[i];
}
return string(output);
}
}

Some files were not shown because too many files have changed in this diff Show More