dexorder
This commit is contained in:
3
lib_openzeppelin_contracts/test/TESTING.md
Normal file
3
lib_openzeppelin_contracts/test/TESTING.md
Normal 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.
|
||||
874
lib_openzeppelin_contracts/test/access/AccessControl.behavior.js
Normal file
874
lib_openzeppelin_contracts/test/access/AccessControl.behavior.js
Normal 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,
|
||||
};
|
||||
19
lib_openzeppelin_contracts/test/access/AccessControl.test.js
Normal file
19
lib_openzeppelin_contracts/test/access/AccessControl.test.js
Normal 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();
|
||||
});
|
||||
79
lib_openzeppelin_contracts/test/access/Ownable.test.js
Normal file
79
lib_openzeppelin_contracts/test/access/Ownable.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
85
lib_openzeppelin_contracts/test/access/Ownable2Step.test.js
Normal file
85
lib_openzeppelin_contracts/test/access/Ownable2Step.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
2432
lib_openzeppelin_contracts/test/access/manager/AccessManager.test.js
Normal file
2432
lib_openzeppelin_contracts/test/access/manager/AccessManager.test.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
55
lib_openzeppelin_contracts/test/governance/Governor.t.sol
Normal file
55
lib_openzeppelin_contracts/test/governance/Governor.t.sol
Normal file
@@ -0,0 +1,55 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
pragma solidity ^0.8.20;
|
||||
|
||||
import {Test} from "@forge-std/Test.sol";
|
||||
import {Strings} from "@openzeppelin/contracts/utils/Strings.sol";
|
||||
import {Governor} from "@openzeppelin/contracts/governance/Governor.sol";
|
||||
|
||||
contract GovernorInternalTest is Test, Governor {
|
||||
constructor() Governor("") {}
|
||||
|
||||
function testValidDescriptionForProposer(string memory description, address proposer, bool includeProposer) public {
|
||||
if (includeProposer) {
|
||||
description = string.concat(description, "#proposer=", Strings.toHexString(proposer));
|
||||
}
|
||||
assertTrue(_isValidDescriptionForProposer(proposer, description));
|
||||
}
|
||||
|
||||
function testInvalidDescriptionForProposer(
|
||||
string memory description,
|
||||
address commitProposer,
|
||||
address actualProposer
|
||||
) public {
|
||||
vm.assume(commitProposer != actualProposer);
|
||||
description = string.concat(description, "#proposer=", Strings.toHexString(commitProposer));
|
||||
assertFalse(_isValidDescriptionForProposer(actualProposer, description));
|
||||
}
|
||||
|
||||
// We don't need to truly implement implement the missing functions because we are just testing
|
||||
// internal helpers.
|
||||
|
||||
function clock() public pure override returns (uint48) {}
|
||||
|
||||
// solhint-disable-next-line func-name-mixedcase
|
||||
function CLOCK_MODE() public pure override returns (string memory) {}
|
||||
|
||||
// solhint-disable-next-line func-name-mixedcase
|
||||
function COUNTING_MODE() public pure virtual override returns (string memory) {}
|
||||
|
||||
function votingDelay() public pure virtual override returns (uint256) {}
|
||||
|
||||
function votingPeriod() public pure virtual override returns (uint256) {}
|
||||
|
||||
function quorum(uint256) public pure virtual override returns (uint256) {}
|
||||
|
||||
function hasVoted(uint256, address) public pure virtual override returns (bool) {}
|
||||
|
||||
function _quorumReached(uint256) internal pure virtual override returns (bool) {}
|
||||
|
||||
function _voteSucceeded(uint256) internal pure virtual override returns (bool) {}
|
||||
|
||||
function _getVotes(address, uint256, bytes memory) internal pure virtual override returns (uint256) {}
|
||||
|
||||
function _countVote(uint256, address, uint8, uint256, bytes memory) internal virtual override {}
|
||||
}
|
||||
992
lib_openzeppelin_contracts/test/governance/Governor.test.js
Normal file
992
lib_openzeppelin_contracts/test/governance/Governor.test.js
Normal file
@@ -0,0 +1,992 @@
|
||||
const { ethers } = require('hardhat');
|
||||
const { expect } = require('chai');
|
||||
const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers');
|
||||
|
||||
const { GovernorHelper } = require('../helpers/governance');
|
||||
const { getDomain, Ballot } = require('../helpers/eip712');
|
||||
const { ProposalState, VoteType } = require('../helpers/enums');
|
||||
const time = require('../helpers/time');
|
||||
|
||||
const { shouldSupportInterfaces } = require('../utils/introspection/SupportsInterface.behavior');
|
||||
const { shouldBehaveLikeERC6372 } = require('./utils/ERC6372.behavior');
|
||||
|
||||
const TOKENS = [
|
||||
{ Token: '$ERC20Votes', mode: 'blocknumber' },
|
||||
{ Token: '$ERC20VotesTimestampMock', mode: 'timestamp' },
|
||||
{ Token: '$ERC20VotesLegacyMock', mode: 'blocknumber' },
|
||||
];
|
||||
|
||||
const name = 'OZ-Governor';
|
||||
const version = '1';
|
||||
const tokenName = 'MockToken';
|
||||
const tokenSymbol = 'MTKN';
|
||||
const tokenSupply = ethers.parseEther('100');
|
||||
const votingDelay = 4n;
|
||||
const votingPeriod = 16n;
|
||||
const value = ethers.parseEther('1');
|
||||
|
||||
const signBallot = account => (contract, message) =>
|
||||
getDomain(contract).then(domain => account.signTypedData(domain, { Ballot }, message));
|
||||
|
||||
async function deployToken(contractName) {
|
||||
try {
|
||||
return await ethers.deployContract(contractName, [tokenName, tokenSymbol, tokenName, version]);
|
||||
} catch (error) {
|
||||
if (error.message == 'incorrect number of arguments to constructor') {
|
||||
// ERC20VotesLegacyMock has a different construction that uses version='1' by default.
|
||||
return ethers.deployContract(contractName, [tokenName, tokenSymbol, tokenName]);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
describe('Governor', function () {
|
||||
for (const { Token, mode } of TOKENS) {
|
||||
const fixture = async () => {
|
||||
const [owner, proposer, voter1, voter2, voter3, voter4, userEOA] = await ethers.getSigners();
|
||||
const receiver = await ethers.deployContract('CallReceiverMock');
|
||||
|
||||
const token = await deployToken(Token, [tokenName, tokenSymbol, version]);
|
||||
const mock = await ethers.deployContract('$GovernorMock', [
|
||||
name, // name
|
||||
votingDelay, // initialVotingDelay
|
||||
votingPeriod, // initialVotingPeriod
|
||||
0n, // initialProposalThreshold
|
||||
token, // tokenAddress
|
||||
10n, // quorumNumeratorValue
|
||||
]);
|
||||
|
||||
await owner.sendTransaction({ to: mock, value });
|
||||
await token.$_mint(owner, tokenSupply);
|
||||
|
||||
const helper = new GovernorHelper(mock, mode);
|
||||
await helper.connect(owner).delegate({ token: token, to: voter1, value: ethers.parseEther('10') });
|
||||
await helper.connect(owner).delegate({ token: token, to: voter2, value: ethers.parseEther('7') });
|
||||
await helper.connect(owner).delegate({ token: token, to: voter3, value: ethers.parseEther('5') });
|
||||
await helper.connect(owner).delegate({ token: token, to: voter4, value: ethers.parseEther('2') });
|
||||
|
||||
return {
|
||||
owner,
|
||||
proposer,
|
||||
voter1,
|
||||
voter2,
|
||||
voter3,
|
||||
voter4,
|
||||
userEOA,
|
||||
receiver,
|
||||
token,
|
||||
mock,
|
||||
helper,
|
||||
};
|
||||
};
|
||||
|
||||
describe(`using ${Token}`, function () {
|
||||
beforeEach(async function () {
|
||||
Object.assign(this, await loadFixture(fixture));
|
||||
// initiate fresh proposal
|
||||
this.proposal = this.helper.setProposal(
|
||||
[
|
||||
{
|
||||
target: this.receiver.target,
|
||||
data: this.receiver.interface.encodeFunctionData('mockFunction'),
|
||||
value,
|
||||
},
|
||||
],
|
||||
'<proposal description>',
|
||||
);
|
||||
});
|
||||
|
||||
shouldSupportInterfaces(['ERC1155Receiver', 'Governor']);
|
||||
shouldBehaveLikeERC6372(mode);
|
||||
|
||||
it('deployment check', async function () {
|
||||
expect(await this.mock.name()).to.equal(name);
|
||||
expect(await this.mock.token()).to.equal(this.token);
|
||||
expect(await this.mock.votingDelay()).to.equal(votingDelay);
|
||||
expect(await this.mock.votingPeriod()).to.equal(votingPeriod);
|
||||
expect(await this.mock.quorum(0)).to.equal(0n);
|
||||
expect(await this.mock.COUNTING_MODE()).to.equal('support=bravo&quorum=for,abstain');
|
||||
});
|
||||
|
||||
it('nominal workflow', async function () {
|
||||
// Before
|
||||
expect(await this.mock.proposalProposer(this.proposal.id)).to.equal(ethers.ZeroAddress);
|
||||
expect(await this.mock.hasVoted(this.proposal.id, this.owner)).to.be.false;
|
||||
expect(await this.mock.hasVoted(this.proposal.id, this.voter1)).to.be.false;
|
||||
expect(await this.mock.hasVoted(this.proposal.id, this.voter2)).to.be.false;
|
||||
expect(await ethers.provider.getBalance(this.mock)).to.equal(value);
|
||||
expect(await ethers.provider.getBalance(this.receiver)).to.equal(0n);
|
||||
|
||||
expect(await this.mock.proposalEta(this.proposal.id)).to.equal(0n);
|
||||
expect(await this.mock.proposalNeedsQueuing(this.proposal.id)).to.be.false;
|
||||
|
||||
// Run proposal
|
||||
const txPropose = await this.helper.connect(this.proposer).propose();
|
||||
const timepoint = await time.clockFromReceipt[mode](txPropose);
|
||||
|
||||
await expect(txPropose)
|
||||
.to.emit(this.mock, 'ProposalCreated')
|
||||
.withArgs(
|
||||
this.proposal.id,
|
||||
this.proposer,
|
||||
this.proposal.targets,
|
||||
this.proposal.values,
|
||||
this.proposal.signatures,
|
||||
this.proposal.data,
|
||||
timepoint + votingDelay,
|
||||
timepoint + votingDelay + votingPeriod,
|
||||
this.proposal.description,
|
||||
);
|
||||
|
||||
await this.helper.waitForSnapshot();
|
||||
|
||||
await expect(this.helper.connect(this.voter1).vote({ support: VoteType.For, reason: 'This is nice' }))
|
||||
.to.emit(this.mock, 'VoteCast')
|
||||
.withArgs(this.voter1, this.proposal.id, VoteType.For, ethers.parseEther('10'), 'This is nice');
|
||||
|
||||
await expect(this.helper.connect(this.voter2).vote({ support: VoteType.For }))
|
||||
.to.emit(this.mock, 'VoteCast')
|
||||
.withArgs(this.voter2, this.proposal.id, VoteType.For, ethers.parseEther('7'), '');
|
||||
|
||||
await expect(this.helper.connect(this.voter3).vote({ support: VoteType.Against }))
|
||||
.to.emit(this.mock, 'VoteCast')
|
||||
.withArgs(this.voter3, this.proposal.id, VoteType.Against, ethers.parseEther('5'), '');
|
||||
|
||||
await expect(this.helper.connect(this.voter4).vote({ support: VoteType.Abstain }))
|
||||
.to.emit(this.mock, 'VoteCast')
|
||||
.withArgs(this.voter4, this.proposal.id, VoteType.Abstain, ethers.parseEther('2'), '');
|
||||
|
||||
await this.helper.waitForDeadline();
|
||||
|
||||
const txExecute = await this.helper.execute();
|
||||
|
||||
await expect(txExecute).to.emit(this.mock, 'ProposalExecuted').withArgs(this.proposal.id);
|
||||
|
||||
await expect(txExecute).to.emit(this.receiver, 'MockFunctionCalled');
|
||||
|
||||
// After
|
||||
expect(await this.mock.proposalProposer(this.proposal.id)).to.equal(this.proposer);
|
||||
expect(await this.mock.hasVoted(this.proposal.id, this.owner)).to.be.false;
|
||||
expect(await this.mock.hasVoted(this.proposal.id, this.voter1)).to.be.true;
|
||||
expect(await this.mock.hasVoted(this.proposal.id, this.voter2)).to.be.true;
|
||||
expect(await ethers.provider.getBalance(this.mock)).to.equal(0n);
|
||||
expect(await ethers.provider.getBalance(this.receiver)).to.equal(value);
|
||||
|
||||
expect(await this.mock.proposalEta(this.proposal.id)).to.equal(0n);
|
||||
expect(await this.mock.proposalNeedsQueuing(this.proposal.id)).to.be.false;
|
||||
});
|
||||
|
||||
it('send ethers', async function () {
|
||||
this.helper.setProposal(
|
||||
[
|
||||
{
|
||||
target: this.userEOA.address,
|
||||
value,
|
||||
},
|
||||
],
|
||||
'<proposal description>',
|
||||
);
|
||||
|
||||
// Run proposal
|
||||
await expect(async () => {
|
||||
await this.helper.propose();
|
||||
await this.helper.waitForSnapshot();
|
||||
await this.helper.connect(this.voter1).vote({ support: VoteType.For });
|
||||
await this.helper.waitForDeadline();
|
||||
return this.helper.execute();
|
||||
}).to.changeEtherBalances([this.mock, this.userEOA], [-value, value]);
|
||||
});
|
||||
|
||||
describe('vote with signature', function () {
|
||||
it('votes with an EOA signature', async function () {
|
||||
await this.token.connect(this.voter1).delegate(this.userEOA);
|
||||
|
||||
const nonce = await this.mock.nonces(this.userEOA);
|
||||
|
||||
// Run proposal
|
||||
await this.helper.propose();
|
||||
await this.helper.waitForSnapshot();
|
||||
await expect(
|
||||
this.helper.vote({
|
||||
support: VoteType.For,
|
||||
voter: this.userEOA.address,
|
||||
nonce,
|
||||
signature: signBallot(this.userEOA),
|
||||
}),
|
||||
)
|
||||
.to.emit(this.mock, 'VoteCast')
|
||||
.withArgs(this.userEOA, this.proposal.id, VoteType.For, ethers.parseEther('10'), '');
|
||||
|
||||
await this.helper.waitForDeadline();
|
||||
await this.helper.execute();
|
||||
|
||||
// After
|
||||
expect(await this.mock.hasVoted(this.proposal.id, this.userEOA)).to.be.true;
|
||||
expect(await this.mock.nonces(this.userEOA)).to.equal(nonce + 1n);
|
||||
});
|
||||
|
||||
it('votes with a valid EIP-1271 signature', async function () {
|
||||
const wallet = await ethers.deployContract('ERC1271WalletMock', [this.userEOA]);
|
||||
|
||||
await this.token.connect(this.voter1).delegate(wallet);
|
||||
|
||||
const nonce = await this.mock.nonces(this.userEOA);
|
||||
|
||||
// Run proposal
|
||||
await this.helper.propose();
|
||||
await this.helper.waitForSnapshot();
|
||||
await expect(
|
||||
this.helper.vote({
|
||||
support: VoteType.For,
|
||||
voter: wallet.target,
|
||||
nonce,
|
||||
signature: signBallot(this.userEOA),
|
||||
}),
|
||||
)
|
||||
.to.emit(this.mock, 'VoteCast')
|
||||
.withArgs(wallet, this.proposal.id, VoteType.For, ethers.parseEther('10'), '');
|
||||
await this.helper.waitForDeadline();
|
||||
await this.helper.execute();
|
||||
|
||||
// After
|
||||
expect(await this.mock.hasVoted(this.proposal.id, wallet)).to.be.true;
|
||||
expect(await this.mock.nonces(wallet)).to.equal(nonce + 1n);
|
||||
});
|
||||
|
||||
afterEach('no other votes are cast', async function () {
|
||||
expect(await this.mock.hasVoted(this.proposal.id, this.owner)).to.be.false;
|
||||
expect(await this.mock.hasVoted(this.proposal.id, this.voter1)).to.be.false;
|
||||
expect(await this.mock.hasVoted(this.proposal.id, this.voter2)).to.be.false;
|
||||
});
|
||||
});
|
||||
|
||||
describe('should revert', function () {
|
||||
describe('on propose', function () {
|
||||
it('if proposal already exists', async function () {
|
||||
await this.helper.propose();
|
||||
await expect(this.helper.propose())
|
||||
.to.be.revertedWithCustomError(this.mock, 'GovernorUnexpectedProposalState')
|
||||
.withArgs(this.proposal.id, ProposalState.Pending, ethers.ZeroHash);
|
||||
});
|
||||
|
||||
it('if proposer has below threshold votes', async function () {
|
||||
const votes = ethers.parseEther('10');
|
||||
const threshold = ethers.parseEther('1000');
|
||||
await this.mock.$_setProposalThreshold(threshold);
|
||||
await expect(this.helper.connect(this.voter1).propose())
|
||||
.to.be.revertedWithCustomError(this.mock, 'GovernorInsufficientProposerVotes')
|
||||
.withArgs(this.voter1, votes, threshold);
|
||||
});
|
||||
});
|
||||
|
||||
describe('on vote', function () {
|
||||
it('if proposal does not exist', async function () {
|
||||
await expect(this.helper.connect(this.voter1).vote({ support: VoteType.For }))
|
||||
.to.be.revertedWithCustomError(this.mock, 'GovernorNonexistentProposal')
|
||||
.withArgs(this.proposal.id);
|
||||
});
|
||||
|
||||
it('if voting has not started', async function () {
|
||||
await this.helper.propose();
|
||||
await expect(this.helper.connect(this.voter1).vote({ support: VoteType.For }))
|
||||
.to.be.revertedWithCustomError(this.mock, 'GovernorUnexpectedProposalState')
|
||||
.withArgs(
|
||||
this.proposal.id,
|
||||
ProposalState.Pending,
|
||||
GovernorHelper.proposalStatesToBitMap([ProposalState.Active]),
|
||||
);
|
||||
});
|
||||
|
||||
it('if support value is invalid', async function () {
|
||||
await this.helper.propose();
|
||||
await this.helper.waitForSnapshot();
|
||||
await expect(this.helper.vote({ support: 255 })).to.be.revertedWithCustomError(
|
||||
this.mock,
|
||||
'GovernorInvalidVoteType',
|
||||
);
|
||||
});
|
||||
|
||||
it('if vote was already casted', async function () {
|
||||
await this.helper.propose();
|
||||
await this.helper.waitForSnapshot();
|
||||
await this.helper.connect(this.voter1).vote({ support: VoteType.For });
|
||||
await expect(this.helper.connect(this.voter1).vote({ support: VoteType.For }))
|
||||
.to.be.revertedWithCustomError(this.mock, 'GovernorAlreadyCastVote')
|
||||
.withArgs(this.voter1);
|
||||
});
|
||||
|
||||
it('if voting is over', async function () {
|
||||
await this.helper.propose();
|
||||
await this.helper.waitForDeadline();
|
||||
await expect(this.helper.connect(this.voter1).vote({ support: VoteType.For }))
|
||||
.to.be.revertedWithCustomError(this.mock, 'GovernorUnexpectedProposalState')
|
||||
.withArgs(
|
||||
this.proposal.id,
|
||||
ProposalState.Defeated,
|
||||
GovernorHelper.proposalStatesToBitMap([ProposalState.Active]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('on vote by signature', function () {
|
||||
beforeEach(async function () {
|
||||
await this.token.connect(this.voter1).delegate(this.userEOA);
|
||||
|
||||
// Run proposal
|
||||
await this.helper.propose();
|
||||
await this.helper.waitForSnapshot();
|
||||
});
|
||||
|
||||
it('if signature does not match signer', async function () {
|
||||
const nonce = await this.mock.nonces(this.userEOA);
|
||||
|
||||
function tamper(str, index, mask) {
|
||||
const arrayStr = ethers.getBytes(str);
|
||||
arrayStr[index] ^= mask;
|
||||
return ethers.hexlify(arrayStr);
|
||||
}
|
||||
|
||||
const voteParams = {
|
||||
support: VoteType.For,
|
||||
voter: this.userEOA.address,
|
||||
nonce,
|
||||
signature: (...args) => signBallot(this.userEOA)(...args).then(sig => tamper(sig, 42, 0xff)),
|
||||
};
|
||||
|
||||
await expect(this.helper.vote(voteParams))
|
||||
.to.be.revertedWithCustomError(this.mock, 'GovernorInvalidSignature')
|
||||
.withArgs(voteParams.voter);
|
||||
});
|
||||
|
||||
it('if vote nonce is incorrect', async function () {
|
||||
const nonce = await this.mock.nonces(this.userEOA);
|
||||
|
||||
const voteParams = {
|
||||
support: VoteType.For,
|
||||
voter: this.userEOA.address,
|
||||
nonce: nonce + 1n,
|
||||
signature: signBallot(this.userEOA),
|
||||
};
|
||||
|
||||
await expect(this.helper.vote(voteParams))
|
||||
.to.be.revertedWithCustomError(this.mock, 'GovernorInvalidSignature')
|
||||
.withArgs(voteParams.voter);
|
||||
});
|
||||
});
|
||||
|
||||
describe('on queue', function () {
|
||||
it('always', async function () {
|
||||
await this.helper.connect(this.proposer).propose();
|
||||
await this.helper.waitForSnapshot();
|
||||
await this.helper.connect(this.voter1).vote({ support: VoteType.For });
|
||||
await this.helper.waitForDeadline();
|
||||
await expect(this.helper.queue()).to.be.revertedWithCustomError(this.mock, 'GovernorQueueNotImplemented');
|
||||
});
|
||||
});
|
||||
|
||||
describe('on execute', function () {
|
||||
it('if proposal does not exist', async function () {
|
||||
await expect(this.helper.execute())
|
||||
.to.be.revertedWithCustomError(this.mock, 'GovernorNonexistentProposal')
|
||||
.withArgs(this.proposal.id);
|
||||
});
|
||||
|
||||
it('if quorum is not reached', async function () {
|
||||
await this.helper.propose();
|
||||
await this.helper.waitForSnapshot();
|
||||
await this.helper.connect(this.voter3).vote({ support: VoteType.For });
|
||||
await expect(this.helper.execute())
|
||||
.to.be.revertedWithCustomError(this.mock, 'GovernorUnexpectedProposalState')
|
||||
.withArgs(
|
||||
this.proposal.id,
|
||||
ProposalState.Active,
|
||||
GovernorHelper.proposalStatesToBitMap([ProposalState.Succeeded, ProposalState.Queued]),
|
||||
);
|
||||
});
|
||||
|
||||
it('if score not reached', async function () {
|
||||
await this.helper.propose();
|
||||
await this.helper.waitForSnapshot();
|
||||
await this.helper.connect(this.voter1).vote({ support: VoteType.Against });
|
||||
await expect(this.helper.execute())
|
||||
.to.be.revertedWithCustomError(this.mock, 'GovernorUnexpectedProposalState')
|
||||
.withArgs(
|
||||
this.proposal.id,
|
||||
ProposalState.Active,
|
||||
GovernorHelper.proposalStatesToBitMap([ProposalState.Succeeded, ProposalState.Queued]),
|
||||
);
|
||||
});
|
||||
|
||||
it('if voting is not over', async function () {
|
||||
await this.helper.propose();
|
||||
await this.helper.waitForSnapshot();
|
||||
await this.helper.connect(this.voter1).vote({ support: VoteType.For });
|
||||
await expect(this.helper.execute())
|
||||
.to.be.revertedWithCustomError(this.mock, 'GovernorUnexpectedProposalState')
|
||||
.withArgs(
|
||||
this.proposal.id,
|
||||
ProposalState.Active,
|
||||
GovernorHelper.proposalStatesToBitMap([ProposalState.Succeeded, ProposalState.Queued]),
|
||||
);
|
||||
});
|
||||
|
||||
it('if receiver revert without reason', async function () {
|
||||
this.helper.setProposal(
|
||||
[
|
||||
{
|
||||
target: this.receiver.target,
|
||||
data: this.receiver.interface.encodeFunctionData('mockFunctionRevertsNoReason'),
|
||||
},
|
||||
],
|
||||
'<proposal description>',
|
||||
);
|
||||
|
||||
await this.helper.propose();
|
||||
await this.helper.waitForSnapshot();
|
||||
await this.helper.connect(this.voter1).vote({ support: VoteType.For });
|
||||
await this.helper.waitForDeadline();
|
||||
await expect(this.helper.execute()).to.be.revertedWithCustomError(this.mock, 'FailedCall');
|
||||
});
|
||||
|
||||
it('if receiver revert with reason', async function () {
|
||||
this.helper.setProposal(
|
||||
[
|
||||
{
|
||||
target: this.receiver.target,
|
||||
data: this.receiver.interface.encodeFunctionData('mockFunctionRevertsReason'),
|
||||
},
|
||||
],
|
||||
'<proposal description>',
|
||||
);
|
||||
|
||||
await this.helper.propose();
|
||||
await this.helper.waitForSnapshot();
|
||||
await this.helper.connect(this.voter1).vote({ support: VoteType.For });
|
||||
await this.helper.waitForDeadline();
|
||||
await expect(this.helper.execute()).to.be.revertedWith('CallReceiverMock: reverting');
|
||||
});
|
||||
|
||||
it('if proposal was already executed', async function () {
|
||||
await this.helper.propose();
|
||||
await this.helper.waitForSnapshot();
|
||||
await this.helper.connect(this.voter1).vote({ support: VoteType.For });
|
||||
await this.helper.waitForDeadline();
|
||||
await this.helper.execute();
|
||||
await expect(this.helper.execute())
|
||||
.to.be.revertedWithCustomError(this.mock, 'GovernorUnexpectedProposalState')
|
||||
.withArgs(
|
||||
this.proposal.id,
|
||||
ProposalState.Executed,
|
||||
GovernorHelper.proposalStatesToBitMap([ProposalState.Succeeded, ProposalState.Queued]),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('state', function () {
|
||||
it('Unset', async function () {
|
||||
await expect(this.mock.state(this.proposal.id))
|
||||
.to.be.revertedWithCustomError(this.mock, 'GovernorNonexistentProposal')
|
||||
.withArgs(this.proposal.id);
|
||||
});
|
||||
|
||||
it('Pending & Active', async function () {
|
||||
await this.helper.propose();
|
||||
expect(await this.mock.state(this.proposal.id)).to.equal(ProposalState.Pending);
|
||||
await this.helper.waitForSnapshot();
|
||||
expect(await this.mock.state(this.proposal.id)).to.equal(ProposalState.Pending);
|
||||
await this.helper.waitForSnapshot(1n);
|
||||
expect(await this.mock.state(this.proposal.id)).to.equal(ProposalState.Active);
|
||||
});
|
||||
|
||||
it('Defeated', async function () {
|
||||
await this.helper.propose();
|
||||
await this.helper.waitForDeadline();
|
||||
expect(await this.mock.state(this.proposal.id)).to.equal(ProposalState.Active);
|
||||
await this.helper.waitForDeadline(1n);
|
||||
expect(await this.mock.state(this.proposal.id)).to.equal(ProposalState.Defeated);
|
||||
});
|
||||
|
||||
it('Succeeded', async function () {
|
||||
await this.helper.propose();
|
||||
await this.helper.waitForSnapshot();
|
||||
await this.helper.connect(this.voter1).vote({ support: VoteType.For });
|
||||
await this.helper.waitForDeadline();
|
||||
expect(await this.mock.state(this.proposal.id)).to.equal(ProposalState.Active);
|
||||
await this.helper.waitForDeadline(1n);
|
||||
expect(await this.mock.state(this.proposal.id)).to.equal(ProposalState.Succeeded);
|
||||
});
|
||||
|
||||
it('Executed', async function () {
|
||||
await this.helper.propose();
|
||||
await this.helper.waitForSnapshot();
|
||||
await this.helper.connect(this.voter1).vote({ support: VoteType.For });
|
||||
await this.helper.waitForDeadline();
|
||||
await this.helper.execute();
|
||||
expect(await this.mock.state(this.proposal.id)).to.equal(ProposalState.Executed);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cancel', function () {
|
||||
describe('internal', function () {
|
||||
it('before proposal', async function () {
|
||||
await expect(this.helper.cancel('internal'))
|
||||
.to.be.revertedWithCustomError(this.mock, 'GovernorNonexistentProposal')
|
||||
.withArgs(this.proposal.id);
|
||||
});
|
||||
|
||||
it('after proposal', async function () {
|
||||
await this.helper.propose();
|
||||
|
||||
await this.helper.cancel('internal');
|
||||
expect(await this.mock.state(this.proposal.id)).to.equal(ProposalState.Canceled);
|
||||
|
||||
await this.helper.waitForSnapshot();
|
||||
await expect(this.helper.connect(this.voter1).vote({ support: VoteType.For }))
|
||||
.to.be.revertedWithCustomError(this.mock, 'GovernorUnexpectedProposalState')
|
||||
.withArgs(
|
||||
this.proposal.id,
|
||||
ProposalState.Canceled,
|
||||
GovernorHelper.proposalStatesToBitMap([ProposalState.Active]),
|
||||
);
|
||||
});
|
||||
|
||||
it('after vote', async function () {
|
||||
await this.helper.propose();
|
||||
await this.helper.waitForSnapshot();
|
||||
await this.helper.connect(this.voter1).vote({ support: VoteType.For });
|
||||
|
||||
await this.helper.cancel('internal');
|
||||
expect(await this.mock.state(this.proposal.id)).to.equal(ProposalState.Canceled);
|
||||
|
||||
await this.helper.waitForDeadline();
|
||||
await expect(this.helper.execute())
|
||||
.to.be.revertedWithCustomError(this.mock, 'GovernorUnexpectedProposalState')
|
||||
.withArgs(
|
||||
this.proposal.id,
|
||||
ProposalState.Canceled,
|
||||
GovernorHelper.proposalStatesToBitMap([ProposalState.Succeeded, ProposalState.Queued]),
|
||||
);
|
||||
});
|
||||
|
||||
it('after deadline', async function () {
|
||||
await this.helper.propose();
|
||||
await this.helper.waitForSnapshot();
|
||||
await this.helper.connect(this.voter1).vote({ support: VoteType.For });
|
||||
await this.helper.waitForDeadline();
|
||||
|
||||
await this.helper.cancel('internal');
|
||||
expect(await this.mock.state(this.proposal.id)).to.equal(ProposalState.Canceled);
|
||||
|
||||
await expect(this.helper.execute())
|
||||
.to.be.revertedWithCustomError(this.mock, 'GovernorUnexpectedProposalState')
|
||||
.withArgs(
|
||||
this.proposal.id,
|
||||
ProposalState.Canceled,
|
||||
GovernorHelper.proposalStatesToBitMap([ProposalState.Succeeded, ProposalState.Queued]),
|
||||
);
|
||||
});
|
||||
|
||||
it('after execution', async function () {
|
||||
await this.helper.propose();
|
||||
await this.helper.waitForSnapshot();
|
||||
await this.helper.connect(this.voter1).vote({ support: VoteType.For });
|
||||
await this.helper.waitForDeadline();
|
||||
await this.helper.execute();
|
||||
|
||||
await expect(this.helper.cancel('internal'))
|
||||
.to.be.revertedWithCustomError(this.mock, 'GovernorUnexpectedProposalState')
|
||||
.withArgs(
|
||||
this.proposal.id,
|
||||
ProposalState.Executed,
|
||||
GovernorHelper.proposalStatesToBitMap(
|
||||
[ProposalState.Canceled, ProposalState.Expired, ProposalState.Executed],
|
||||
{ inverted: true },
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('public', function () {
|
||||
it('before proposal', async function () {
|
||||
await expect(this.helper.cancel('external'))
|
||||
.to.be.revertedWithCustomError(this.mock, 'GovernorNonexistentProposal')
|
||||
.withArgs(this.proposal.id);
|
||||
});
|
||||
|
||||
it('after proposal', async function () {
|
||||
await this.helper.propose();
|
||||
|
||||
await this.helper.cancel('external');
|
||||
});
|
||||
|
||||
it('after proposal - restricted to proposer', async function () {
|
||||
await this.helper.connect(this.proposer).propose();
|
||||
|
||||
await expect(this.helper.connect(this.owner).cancel('external'))
|
||||
.to.be.revertedWithCustomError(this.mock, 'GovernorOnlyProposer')
|
||||
.withArgs(this.owner);
|
||||
});
|
||||
|
||||
it('after vote started', async function () {
|
||||
await this.helper.propose();
|
||||
await this.helper.waitForSnapshot(1n); // snapshot + 1 block
|
||||
|
||||
await expect(this.helper.cancel('external'))
|
||||
.to.be.revertedWithCustomError(this.mock, 'GovernorUnexpectedProposalState')
|
||||
.withArgs(
|
||||
this.proposal.id,
|
||||
ProposalState.Active,
|
||||
GovernorHelper.proposalStatesToBitMap([ProposalState.Pending]),
|
||||
);
|
||||
});
|
||||
|
||||
it('after vote', async function () {
|
||||
await this.helper.propose();
|
||||
await this.helper.waitForSnapshot();
|
||||
await this.helper.connect(this.voter1).vote({ support: VoteType.For });
|
||||
|
||||
await expect(this.helper.cancel('external'))
|
||||
.to.be.revertedWithCustomError(this.mock, 'GovernorUnexpectedProposalState')
|
||||
.withArgs(
|
||||
this.proposal.id,
|
||||
ProposalState.Active,
|
||||
GovernorHelper.proposalStatesToBitMap([ProposalState.Pending]),
|
||||
);
|
||||
});
|
||||
|
||||
it('after deadline', async function () {
|
||||
await this.helper.propose();
|
||||
await this.helper.waitForSnapshot();
|
||||
await this.helper.connect(this.voter1).vote({ support: VoteType.For });
|
||||
await this.helper.waitForDeadline();
|
||||
|
||||
await expect(this.helper.cancel('external'))
|
||||
.to.be.revertedWithCustomError(this.mock, 'GovernorUnexpectedProposalState')
|
||||
.withArgs(
|
||||
this.proposal.id,
|
||||
ProposalState.Succeeded,
|
||||
GovernorHelper.proposalStatesToBitMap([ProposalState.Pending]),
|
||||
);
|
||||
});
|
||||
|
||||
it('after execution', async function () {
|
||||
await this.helper.propose();
|
||||
await this.helper.waitForSnapshot();
|
||||
await this.helper.connect(this.voter1).vote({ support: VoteType.For });
|
||||
await this.helper.waitForDeadline();
|
||||
await this.helper.execute();
|
||||
|
||||
await expect(this.helper.cancel('external'))
|
||||
.to.be.revertedWithCustomError(this.mock, 'GovernorUnexpectedProposalState')
|
||||
.withArgs(
|
||||
this.proposal.id,
|
||||
ProposalState.Executed,
|
||||
GovernorHelper.proposalStatesToBitMap([ProposalState.Pending]),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('proposal length', function () {
|
||||
it('empty', async function () {
|
||||
this.helper.setProposal([], '<proposal description>');
|
||||
|
||||
await expect(this.helper.propose())
|
||||
.to.be.revertedWithCustomError(this.mock, 'GovernorInvalidProposalLength')
|
||||
.withArgs(0, 0, 0);
|
||||
});
|
||||
|
||||
it('mismatch #1', async function () {
|
||||
this.helper.setProposal(
|
||||
{
|
||||
targets: [],
|
||||
values: [0n],
|
||||
data: [this.receiver.interface.encodeFunctionData('mockFunction')],
|
||||
},
|
||||
'<proposal description>',
|
||||
);
|
||||
await expect(this.helper.propose())
|
||||
.to.be.revertedWithCustomError(this.mock, 'GovernorInvalidProposalLength')
|
||||
.withArgs(0, 1, 1);
|
||||
});
|
||||
|
||||
it('mismatch #2', async function () {
|
||||
this.helper.setProposal(
|
||||
{
|
||||
targets: [this.receiver.target],
|
||||
values: [],
|
||||
data: [this.receiver.interface.encodeFunctionData('mockFunction')],
|
||||
},
|
||||
'<proposal description>',
|
||||
);
|
||||
await expect(this.helper.propose())
|
||||
.to.be.revertedWithCustomError(this.mock, 'GovernorInvalidProposalLength')
|
||||
.withArgs(1, 1, 0);
|
||||
});
|
||||
|
||||
it('mismatch #3', async function () {
|
||||
this.helper.setProposal(
|
||||
{
|
||||
targets: [this.receiver.target],
|
||||
values: [0n],
|
||||
data: [],
|
||||
},
|
||||
'<proposal description>',
|
||||
);
|
||||
await expect(this.helper.propose())
|
||||
.to.be.revertedWithCustomError(this.mock, 'GovernorInvalidProposalLength')
|
||||
.withArgs(1, 0, 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('frontrun protection using description suffix', function () {
|
||||
function shouldPropose() {
|
||||
it('proposer can propose', async function () {
|
||||
const txPropose = await this.helper.connect(this.proposer).propose();
|
||||
|
||||
await expect(txPropose)
|
||||
.to.emit(this.mock, 'ProposalCreated')
|
||||
.withArgs(
|
||||
this.proposal.id,
|
||||
this.proposer,
|
||||
this.proposal.targets,
|
||||
this.proposal.values,
|
||||
this.proposal.signatures,
|
||||
this.proposal.data,
|
||||
(await time.clockFromReceipt[mode](txPropose)) + votingDelay,
|
||||
(await time.clockFromReceipt[mode](txPropose)) + votingDelay + votingPeriod,
|
||||
this.proposal.description,
|
||||
);
|
||||
});
|
||||
|
||||
it('someone else can propose', async function () {
|
||||
const txPropose = await this.helper.connect(this.voter1).propose();
|
||||
|
||||
await expect(txPropose)
|
||||
.to.emit(this.mock, 'ProposalCreated')
|
||||
.withArgs(
|
||||
this.proposal.id,
|
||||
this.voter1,
|
||||
this.proposal.targets,
|
||||
this.proposal.values,
|
||||
this.proposal.signatures,
|
||||
this.proposal.data,
|
||||
(await time.clockFromReceipt[mode](txPropose)) + votingDelay,
|
||||
(await time.clockFromReceipt[mode](txPropose)) + votingDelay + votingPeriod,
|
||||
this.proposal.description,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
describe('without protection', function () {
|
||||
describe('without suffix', function () {
|
||||
shouldPropose();
|
||||
});
|
||||
|
||||
describe('with different suffix', function () {
|
||||
beforeEach(function () {
|
||||
this.proposal = this.helper.setProposal(
|
||||
[
|
||||
{
|
||||
target: this.receiver.target,
|
||||
data: this.receiver.interface.encodeFunctionData('mockFunction'),
|
||||
value,
|
||||
},
|
||||
],
|
||||
`<proposal description>#wrong-suffix=${this.proposer}`,
|
||||
);
|
||||
});
|
||||
|
||||
shouldPropose();
|
||||
});
|
||||
|
||||
describe('with proposer suffix but bad address part', function () {
|
||||
beforeEach(function () {
|
||||
this.proposal = this.helper.setProposal(
|
||||
[
|
||||
{
|
||||
target: this.receiver.target,
|
||||
data: this.receiver.interface.encodeFunctionData('mockFunction'),
|
||||
value,
|
||||
},
|
||||
],
|
||||
`<proposal description>#proposer=0x3C44CdDdB6a900fa2b585dd299e03d12FA429XYZ`, // XYZ are not a valid hex char
|
||||
);
|
||||
});
|
||||
|
||||
shouldPropose();
|
||||
});
|
||||
});
|
||||
|
||||
describe('with protection via proposer suffix', function () {
|
||||
beforeEach(function () {
|
||||
this.proposal = this.helper.setProposal(
|
||||
[
|
||||
{
|
||||
target: this.receiver.target,
|
||||
data: this.receiver.interface.encodeFunctionData('mockFunction'),
|
||||
value,
|
||||
},
|
||||
],
|
||||
`<proposal description>#proposer=${this.proposer}`,
|
||||
);
|
||||
});
|
||||
|
||||
shouldPropose();
|
||||
});
|
||||
});
|
||||
|
||||
describe('onlyGovernance updates', function () {
|
||||
it('setVotingDelay is protected', async function () {
|
||||
await expect(this.mock.connect(this.owner).setVotingDelay(0n))
|
||||
.to.be.revertedWithCustomError(this.mock, 'GovernorOnlyExecutor')
|
||||
.withArgs(this.owner);
|
||||
});
|
||||
|
||||
it('setVotingPeriod is protected', async function () {
|
||||
await expect(this.mock.connect(this.owner).setVotingPeriod(32n))
|
||||
.to.be.revertedWithCustomError(this.mock, 'GovernorOnlyExecutor')
|
||||
.withArgs(this.owner);
|
||||
});
|
||||
|
||||
it('setProposalThreshold is protected', async function () {
|
||||
await expect(this.mock.connect(this.owner).setProposalThreshold(1_000_000_000_000_000_000n))
|
||||
.to.be.revertedWithCustomError(this.mock, 'GovernorOnlyExecutor')
|
||||
.withArgs(this.owner);
|
||||
});
|
||||
|
||||
it('can setVotingDelay through governance', async function () {
|
||||
this.helper.setProposal(
|
||||
[
|
||||
{
|
||||
target: this.mock.target,
|
||||
data: this.mock.interface.encodeFunctionData('setVotingDelay', [0n]),
|
||||
},
|
||||
],
|
||||
'<proposal description>',
|
||||
);
|
||||
|
||||
await this.helper.propose();
|
||||
await this.helper.waitForSnapshot();
|
||||
await this.helper.connect(this.voter1).vote({ support: VoteType.For });
|
||||
await this.helper.waitForDeadline();
|
||||
|
||||
await expect(this.helper.execute()).to.emit(this.mock, 'VotingDelaySet').withArgs(4n, 0n);
|
||||
|
||||
expect(await this.mock.votingDelay()).to.equal(0n);
|
||||
});
|
||||
|
||||
it('can setVotingPeriod through governance', async function () {
|
||||
this.helper.setProposal(
|
||||
[
|
||||
{
|
||||
target: this.mock.target,
|
||||
data: this.mock.interface.encodeFunctionData('setVotingPeriod', [32n]),
|
||||
},
|
||||
],
|
||||
'<proposal description>',
|
||||
);
|
||||
|
||||
await this.helper.propose();
|
||||
await this.helper.waitForSnapshot();
|
||||
await this.helper.connect(this.voter1).vote({ support: VoteType.For });
|
||||
await this.helper.waitForDeadline();
|
||||
|
||||
await expect(this.helper.execute()).to.emit(this.mock, 'VotingPeriodSet').withArgs(16n, 32n);
|
||||
|
||||
expect(await this.mock.votingPeriod()).to.equal(32n);
|
||||
});
|
||||
|
||||
it('cannot setVotingPeriod to 0 through governance', async function () {
|
||||
const votingPeriod = 0n;
|
||||
|
||||
this.helper.setProposal(
|
||||
[
|
||||
{
|
||||
target: this.mock.target,
|
||||
data: this.mock.interface.encodeFunctionData('setVotingPeriod', [votingPeriod]),
|
||||
},
|
||||
],
|
||||
'<proposal description>',
|
||||
);
|
||||
|
||||
await this.helper.propose();
|
||||
await this.helper.waitForSnapshot();
|
||||
await this.helper.connect(this.voter1).vote({ support: VoteType.For });
|
||||
await this.helper.waitForDeadline();
|
||||
|
||||
await expect(this.helper.execute())
|
||||
.to.be.revertedWithCustomError(this.mock, 'GovernorInvalidVotingPeriod')
|
||||
.withArgs(votingPeriod);
|
||||
});
|
||||
|
||||
it('can setProposalThreshold to 0 through governance', async function () {
|
||||
this.helper.setProposal(
|
||||
[
|
||||
{
|
||||
target: this.mock.target,
|
||||
data: this.mock.interface.encodeFunctionData('setProposalThreshold', [1_000_000_000_000_000_000n]),
|
||||
},
|
||||
],
|
||||
'<proposal description>',
|
||||
);
|
||||
|
||||
await this.helper.propose();
|
||||
await this.helper.waitForSnapshot();
|
||||
await this.helper.connect(this.voter1).vote({ support: VoteType.For });
|
||||
await this.helper.waitForDeadline();
|
||||
|
||||
await expect(this.helper.execute())
|
||||
.to.emit(this.mock, 'ProposalThresholdSet')
|
||||
.withArgs(0n, 1_000_000_000_000_000_000n);
|
||||
|
||||
expect(await this.mock.proposalThreshold()).to.equal(1_000_000_000_000_000_000n);
|
||||
});
|
||||
});
|
||||
|
||||
describe('safe receive', function () {
|
||||
describe('ERC721', function () {
|
||||
const tokenId = 1n;
|
||||
|
||||
beforeEach(async function () {
|
||||
this.token = await ethers.deployContract('$ERC721', ['Non Fungible Token', 'NFT']);
|
||||
await this.token.$_mint(this.owner, tokenId);
|
||||
});
|
||||
|
||||
it('can receive an ERC721 safeTransfer', async function () {
|
||||
await this.token.connect(this.owner).safeTransferFrom(this.owner, this.mock, tokenId);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ERC1155', function () {
|
||||
const tokenIds = {
|
||||
1: 1000n,
|
||||
2: 2000n,
|
||||
3: 3000n,
|
||||
};
|
||||
|
||||
beforeEach(async function () {
|
||||
this.token = await ethers.deployContract('$ERC1155', ['https://token-cdn-domain/{id}.json']);
|
||||
await this.token.$_mintBatch(this.owner, Object.keys(tokenIds), Object.values(tokenIds), '0x');
|
||||
});
|
||||
|
||||
it('can receive ERC1155 safeTransfer', async function () {
|
||||
await this.token.connect(this.owner).safeTransferFrom(
|
||||
this.owner,
|
||||
this.mock,
|
||||
...Object.entries(tokenIds)[0], // id + amount
|
||||
'0x',
|
||||
);
|
||||
});
|
||||
|
||||
it('can receive ERC1155 safeBatchTransfer', async function () {
|
||||
await this.token
|
||||
.connect(this.owner)
|
||||
.safeBatchTransferFrom(this.owner, this.mock, Object.keys(tokenIds), Object.values(tokenIds), '0x');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,131 @@
|
||||
const { ethers } = require('hardhat');
|
||||
const { expect } = require('chai');
|
||||
const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers');
|
||||
|
||||
const { GovernorHelper } = require('../../helpers/governance');
|
||||
const { VoteType } = require('../../helpers/enums');
|
||||
|
||||
const TOKENS = [
|
||||
{ Token: '$ERC721Votes', mode: 'blocknumber' },
|
||||
{ Token: '$ERC721VotesTimestampMock', mode: 'timestamp' },
|
||||
];
|
||||
|
||||
const name = 'OZ-Governor';
|
||||
const version = '1';
|
||||
const tokenName = 'MockNFToken';
|
||||
const tokenSymbol = 'MTKN';
|
||||
const NFT0 = 0n;
|
||||
const NFT1 = 1n;
|
||||
const NFT2 = 2n;
|
||||
const NFT3 = 3n;
|
||||
const NFT4 = 4n;
|
||||
const votingDelay = 4n;
|
||||
const votingPeriod = 16n;
|
||||
const value = ethers.parseEther('1');
|
||||
|
||||
describe('GovernorERC721', function () {
|
||||
for (const { Token, mode } of TOKENS) {
|
||||
const fixture = async () => {
|
||||
const [owner, voter1, voter2, voter3, voter4] = await ethers.getSigners();
|
||||
const receiver = await ethers.deployContract('CallReceiverMock');
|
||||
|
||||
const token = await ethers.deployContract(Token, [tokenName, tokenSymbol, version]);
|
||||
const mock = await ethers.deployContract('$GovernorMock', [
|
||||
name, // name
|
||||
votingDelay, // initialVotingDelay
|
||||
votingPeriod, // initialVotingPeriod
|
||||
0n, // initialProposalThreshold
|
||||
token, // tokenAddress
|
||||
10n, // quorumNumeratorValue
|
||||
]);
|
||||
|
||||
await owner.sendTransaction({ to: mock, value });
|
||||
await Promise.all([NFT0, NFT1, NFT2, NFT3, NFT4].map(tokenId => token.$_mint(owner, tokenId)));
|
||||
|
||||
const helper = new GovernorHelper(mock, mode);
|
||||
await helper.connect(owner).delegate({ token, to: voter1, tokenId: NFT0 });
|
||||
await helper.connect(owner).delegate({ token, to: voter2, tokenId: NFT1 });
|
||||
await helper.connect(owner).delegate({ token, to: voter2, tokenId: NFT2 });
|
||||
await helper.connect(owner).delegate({ token, to: voter3, tokenId: NFT3 });
|
||||
await helper.connect(owner).delegate({ token, to: voter4, tokenId: NFT4 });
|
||||
|
||||
return {
|
||||
owner,
|
||||
voter1,
|
||||
voter2,
|
||||
voter3,
|
||||
voter4,
|
||||
receiver,
|
||||
token,
|
||||
mock,
|
||||
helper,
|
||||
};
|
||||
};
|
||||
|
||||
describe(`using ${Token}`, function () {
|
||||
beforeEach(async function () {
|
||||
Object.assign(this, await loadFixture(fixture));
|
||||
// initiate fresh proposal
|
||||
this.proposal = this.helper.setProposal(
|
||||
[
|
||||
{
|
||||
target: this.receiver.target,
|
||||
data: this.receiver.interface.encodeFunctionData('mockFunction'),
|
||||
value,
|
||||
},
|
||||
],
|
||||
'<proposal description>',
|
||||
);
|
||||
});
|
||||
|
||||
it('deployment check', async function () {
|
||||
expect(await this.mock.name()).to.equal(name);
|
||||
expect(await this.mock.token()).to.equal(this.token);
|
||||
expect(await this.mock.votingDelay()).to.equal(votingDelay);
|
||||
expect(await this.mock.votingPeriod()).to.equal(votingPeriod);
|
||||
expect(await this.mock.quorum(0n)).to.equal(0n);
|
||||
|
||||
expect(await this.token.getVotes(this.voter1)).to.equal(1n); // NFT0
|
||||
expect(await this.token.getVotes(this.voter2)).to.equal(2n); // NFT1 & NFT2
|
||||
expect(await this.token.getVotes(this.voter3)).to.equal(1n); // NFT3
|
||||
expect(await this.token.getVotes(this.voter4)).to.equal(1n); // NFT4
|
||||
});
|
||||
|
||||
it('voting with ERC721 token', async function () {
|
||||
await this.helper.propose();
|
||||
await this.helper.waitForSnapshot();
|
||||
|
||||
await expect(this.helper.connect(this.voter1).vote({ support: VoteType.For }))
|
||||
.to.emit(this.mock, 'VoteCast')
|
||||
.withArgs(this.voter1, this.proposal.id, VoteType.For, 1n, '');
|
||||
|
||||
await expect(this.helper.connect(this.voter2).vote({ support: VoteType.For }))
|
||||
.to.emit(this.mock, 'VoteCast')
|
||||
.withArgs(this.voter2, this.proposal.id, VoteType.For, 2n, '');
|
||||
|
||||
await expect(this.helper.connect(this.voter3).vote({ support: VoteType.Against }))
|
||||
.to.emit(this.mock, 'VoteCast')
|
||||
.withArgs(this.voter3, this.proposal.id, VoteType.Against, 1n, '');
|
||||
|
||||
await expect(this.helper.connect(this.voter4).vote({ support: VoteType.Abstain }))
|
||||
.to.emit(this.mock, 'VoteCast')
|
||||
.withArgs(this.voter4, this.proposal.id, VoteType.Abstain, 1n, '');
|
||||
|
||||
await this.helper.waitForDeadline();
|
||||
await this.helper.execute();
|
||||
|
||||
expect(await this.mock.hasVoted(this.proposal.id, this.owner)).to.be.false;
|
||||
expect(await this.mock.hasVoted(this.proposal.id, this.voter1)).to.be.true;
|
||||
expect(await this.mock.hasVoted(this.proposal.id, this.voter2)).to.be.true;
|
||||
expect(await this.mock.hasVoted(this.proposal.id, this.voter3)).to.be.true;
|
||||
expect(await this.mock.hasVoted(this.proposal.id, this.voter4)).to.be.true;
|
||||
|
||||
expect(await this.mock.proposalVotes(this.proposal.id)).to.deep.equal([
|
||||
1n, // againstVotes
|
||||
3n, // forVotes
|
||||
1n, // abstainVotes
|
||||
]);
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,185 @@
|
||||
const { ethers } = require('hardhat');
|
||||
const { expect } = require('chai');
|
||||
const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers');
|
||||
|
||||
const { GovernorHelper } = require('../../helpers/governance');
|
||||
const { ProposalState, VoteType } = require('../../helpers/enums');
|
||||
const time = require('../../helpers/time');
|
||||
|
||||
const TOKENS = [
|
||||
{ Token: '$ERC20Votes', mode: 'blocknumber' },
|
||||
{ Token: '$ERC20VotesTimestampMock', mode: 'timestamp' },
|
||||
];
|
||||
|
||||
const name = 'OZ-Governor';
|
||||
const version = '1';
|
||||
const tokenName = 'MockToken';
|
||||
const tokenSymbol = 'MTKN';
|
||||
const tokenSupply = ethers.parseEther('100');
|
||||
const votingDelay = 4n;
|
||||
const votingPeriod = 16n;
|
||||
const lateQuorumVoteExtension = 8n;
|
||||
const quorum = ethers.parseEther('1');
|
||||
const value = ethers.parseEther('1');
|
||||
|
||||
describe('GovernorPreventLateQuorum', function () {
|
||||
for (const { Token, mode } of TOKENS) {
|
||||
const fixture = async () => {
|
||||
const [owner, proposer, voter1, voter2, voter3, voter4] = await ethers.getSigners();
|
||||
const receiver = await ethers.deployContract('CallReceiverMock');
|
||||
|
||||
const token = await ethers.deployContract(Token, [tokenName, tokenSymbol, version]);
|
||||
const mock = await ethers.deployContract('$GovernorPreventLateQuorumMock', [
|
||||
name, // name
|
||||
votingDelay, // initialVotingDelay
|
||||
votingPeriod, // initialVotingPeriod
|
||||
0n, // initialProposalThreshold
|
||||
token, // tokenAddress
|
||||
lateQuorumVoteExtension,
|
||||
quorum,
|
||||
]);
|
||||
|
||||
await owner.sendTransaction({ to: mock, value });
|
||||
await token.$_mint(owner, tokenSupply);
|
||||
|
||||
const helper = new GovernorHelper(mock, mode);
|
||||
await helper.connect(owner).delegate({ token, to: voter1, value: ethers.parseEther('10') });
|
||||
await helper.connect(owner).delegate({ token, to: voter2, value: ethers.parseEther('7') });
|
||||
await helper.connect(owner).delegate({ token, to: voter3, value: ethers.parseEther('5') });
|
||||
await helper.connect(owner).delegate({ token, to: voter4, value: ethers.parseEther('2') });
|
||||
|
||||
return { owner, proposer, voter1, voter2, voter3, voter4, receiver, token, mock, helper };
|
||||
};
|
||||
|
||||
describe(`using ${Token}`, function () {
|
||||
beforeEach(async function () {
|
||||
Object.assign(this, await loadFixture(fixture));
|
||||
// initiate fresh proposal
|
||||
this.proposal = this.helper.setProposal(
|
||||
[
|
||||
{
|
||||
target: this.receiver.target,
|
||||
data: this.receiver.interface.encodeFunctionData('mockFunction'),
|
||||
value,
|
||||
},
|
||||
],
|
||||
'<proposal description>',
|
||||
);
|
||||
});
|
||||
|
||||
it('deployment check', async function () {
|
||||
expect(await this.mock.name()).to.equal(name);
|
||||
expect(await this.mock.token()).to.equal(this.token);
|
||||
expect(await this.mock.votingDelay()).to.equal(votingDelay);
|
||||
expect(await this.mock.votingPeriod()).to.equal(votingPeriod);
|
||||
expect(await this.mock.quorum(0)).to.equal(quorum);
|
||||
expect(await this.mock.lateQuorumVoteExtension()).to.equal(lateQuorumVoteExtension);
|
||||
});
|
||||
|
||||
it('nominal workflow unaffected', async function () {
|
||||
const txPropose = await this.helper.connect(this.proposer).propose();
|
||||
await this.helper.waitForSnapshot();
|
||||
await this.helper.connect(this.voter1).vote({ support: VoteType.For });
|
||||
await this.helper.connect(this.voter2).vote({ support: VoteType.For });
|
||||
await this.helper.connect(this.voter3).vote({ support: VoteType.Against });
|
||||
await this.helper.connect(this.voter4).vote({ support: VoteType.Abstain });
|
||||
await this.helper.waitForDeadline();
|
||||
await this.helper.execute();
|
||||
|
||||
expect(await this.mock.hasVoted(this.proposal.id, this.owner)).to.be.false;
|
||||
expect(await this.mock.hasVoted(this.proposal.id, this.voter1)).to.be.true;
|
||||
expect(await this.mock.hasVoted(this.proposal.id, this.voter2)).to.be.true;
|
||||
expect(await this.mock.hasVoted(this.proposal.id, this.voter3)).to.be.true;
|
||||
expect(await this.mock.hasVoted(this.proposal.id, this.voter4)).to.be.true;
|
||||
|
||||
expect(await this.mock.proposalVotes(this.proposal.id)).to.deep.equal([
|
||||
ethers.parseEther('5'), // againstVotes
|
||||
ethers.parseEther('17'), // forVotes
|
||||
ethers.parseEther('2'), // abstainVotes
|
||||
]);
|
||||
|
||||
const voteStart = (await time.clockFromReceipt[mode](txPropose)) + votingDelay;
|
||||
const voteEnd = (await time.clockFromReceipt[mode](txPropose)) + votingDelay + votingPeriod;
|
||||
expect(await this.mock.proposalSnapshot(this.proposal.id)).to.equal(voteStart);
|
||||
expect(await this.mock.proposalDeadline(this.proposal.id)).to.equal(voteEnd);
|
||||
|
||||
await expect(txPropose)
|
||||
.to.emit(this.mock, 'ProposalCreated')
|
||||
.withArgs(
|
||||
this.proposal.id,
|
||||
this.proposer,
|
||||
this.proposal.targets,
|
||||
this.proposal.values,
|
||||
this.proposal.signatures,
|
||||
this.proposal.data,
|
||||
voteStart,
|
||||
voteEnd,
|
||||
this.proposal.description,
|
||||
);
|
||||
});
|
||||
|
||||
it('Delay is extended to prevent last minute take-over', async function () {
|
||||
const txPropose = await this.helper.connect(this.proposer).propose();
|
||||
|
||||
// compute original schedule
|
||||
const snapshotTimepoint = (await time.clockFromReceipt[mode](txPropose)) + votingDelay;
|
||||
const deadlineTimepoint = (await time.clockFromReceipt[mode](txPropose)) + votingDelay + votingPeriod;
|
||||
expect(await this.mock.proposalSnapshot(this.proposal.id)).to.equal(snapshotTimepoint);
|
||||
expect(await this.mock.proposalDeadline(this.proposal.id)).to.equal(deadlineTimepoint);
|
||||
// wait for the last minute to vote
|
||||
await this.helper.waitForDeadline(-1n);
|
||||
const txVote = await this.helper.connect(this.voter2).vote({ support: VoteType.For });
|
||||
|
||||
// cannot execute yet
|
||||
expect(await this.mock.state(this.proposal.id)).to.equal(ProposalState.Active);
|
||||
|
||||
// compute new extended schedule
|
||||
const extendedDeadline = (await time.clockFromReceipt[mode](txVote)) + lateQuorumVoteExtension;
|
||||
expect(await this.mock.proposalSnapshot(this.proposal.id)).to.equal(snapshotTimepoint);
|
||||
expect(await this.mock.proposalDeadline(this.proposal.id)).to.equal(extendedDeadline);
|
||||
|
||||
// still possible to vote
|
||||
await this.helper.connect(this.voter1).vote({ support: VoteType.Against });
|
||||
|
||||
await this.helper.waitForDeadline();
|
||||
expect(await this.mock.state(this.proposal.id)).to.equal(ProposalState.Active);
|
||||
await this.helper.waitForDeadline(1n);
|
||||
expect(await this.mock.state(this.proposal.id)).to.equal(ProposalState.Defeated);
|
||||
|
||||
// check extension event
|
||||
await expect(txVote).to.emit(this.mock, 'ProposalExtended').withArgs(this.proposal.id, extendedDeadline);
|
||||
});
|
||||
|
||||
describe('onlyGovernance updates', function () {
|
||||
it('setLateQuorumVoteExtension is protected', async function () {
|
||||
await expect(this.mock.connect(this.owner).setLateQuorumVoteExtension(0n))
|
||||
.to.be.revertedWithCustomError(this.mock, 'GovernorOnlyExecutor')
|
||||
.withArgs(this.owner);
|
||||
});
|
||||
|
||||
it('can setLateQuorumVoteExtension through governance', async function () {
|
||||
this.helper.setProposal(
|
||||
[
|
||||
{
|
||||
target: this.mock.target,
|
||||
data: this.mock.interface.encodeFunctionData('setLateQuorumVoteExtension', [0n]),
|
||||
},
|
||||
],
|
||||
'<proposal description>',
|
||||
);
|
||||
|
||||
await this.helper.propose();
|
||||
await this.helper.waitForSnapshot();
|
||||
await this.helper.connect(this.voter1).vote({ support: VoteType.For });
|
||||
await this.helper.waitForDeadline();
|
||||
|
||||
await expect(this.helper.execute())
|
||||
.to.emit(this.mock, 'LateQuorumVoteExtensionSet')
|
||||
.withArgs(lateQuorumVoteExtension, 0n);
|
||||
|
||||
expect(await this.mock.lateQuorumVoteExtension()).to.equal(0n);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
const { ethers } = require('hardhat');
|
||||
const { expect } = require('chai');
|
||||
const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers');
|
||||
const { anyValue } = require('@nomicfoundation/hardhat-chai-matchers/withArgs');
|
||||
const { PANIC_CODES } = require('@nomicfoundation/hardhat-chai-matchers/panic');
|
||||
|
||||
const { GovernorHelper, timelockSalt } = require('../../helpers/governance');
|
||||
const { VoteType } = require('../../helpers/enums');
|
||||
|
||||
const TOKENS = [
|
||||
{ Token: '$ERC20Votes', mode: 'blocknumber' },
|
||||
{ Token: '$ERC20VotesTimestampMock', mode: 'timestamp' },
|
||||
];
|
||||
|
||||
const DEFAULT_ADMIN_ROLE = ethers.ZeroHash;
|
||||
const PROPOSER_ROLE = ethers.id('PROPOSER_ROLE');
|
||||
const EXECUTOR_ROLE = ethers.id('EXECUTOR_ROLE');
|
||||
const CANCELLER_ROLE = ethers.id('CANCELLER_ROLE');
|
||||
|
||||
const name = 'OZ-Governor';
|
||||
const version = '1';
|
||||
const tokenName = 'MockToken';
|
||||
const tokenSymbol = 'MTKN';
|
||||
const tokenSupply = ethers.parseEther('100');
|
||||
const votingDelay = 4n;
|
||||
const votingPeriod = 16n;
|
||||
const value = ethers.parseEther('1');
|
||||
const delay = 3600n;
|
||||
|
||||
describe('GovernorStorage', function () {
|
||||
for (const { Token, mode } of TOKENS) {
|
||||
const fixture = async () => {
|
||||
const [deployer, owner, proposer, voter1, voter2, voter3, voter4] = await ethers.getSigners();
|
||||
const receiver = await ethers.deployContract('CallReceiverMock');
|
||||
|
||||
const token = await ethers.deployContract(Token, [tokenName, tokenSymbol, version]);
|
||||
const timelock = await ethers.deployContract('TimelockController', [delay, [], [], deployer]);
|
||||
const mock = await ethers.deployContract('$GovernorStorageMock', [
|
||||
name,
|
||||
votingDelay,
|
||||
votingPeriod,
|
||||
0n,
|
||||
timelock,
|
||||
token,
|
||||
0n,
|
||||
]);
|
||||
|
||||
await owner.sendTransaction({ to: timelock, value });
|
||||
await token.$_mint(owner, tokenSupply);
|
||||
await timelock.grantRole(PROPOSER_ROLE, mock);
|
||||
await timelock.grantRole(PROPOSER_ROLE, owner);
|
||||
await timelock.grantRole(CANCELLER_ROLE, mock);
|
||||
await timelock.grantRole(CANCELLER_ROLE, owner);
|
||||
await timelock.grantRole(EXECUTOR_ROLE, ethers.ZeroAddress);
|
||||
await timelock.revokeRole(DEFAULT_ADMIN_ROLE, deployer);
|
||||
|
||||
const helper = new GovernorHelper(mock, mode);
|
||||
await helper.connect(owner).delegate({ token, to: voter1, value: ethers.parseEther('10') });
|
||||
await helper.connect(owner).delegate({ token, to: voter2, value: ethers.parseEther('7') });
|
||||
await helper.connect(owner).delegate({ token, to: voter3, value: ethers.parseEther('5') });
|
||||
await helper.connect(owner).delegate({ token, to: voter4, value: ethers.parseEther('2') });
|
||||
|
||||
return { deployer, owner, proposer, voter1, voter2, voter3, voter4, receiver, token, timelock, mock, helper };
|
||||
};
|
||||
|
||||
describe(`using ${Token}`, function () {
|
||||
beforeEach(async function () {
|
||||
Object.assign(this, await loadFixture(fixture));
|
||||
// initiate fresh proposal
|
||||
this.proposal = this.helper.setProposal(
|
||||
[
|
||||
{
|
||||
target: this.receiver.target,
|
||||
data: this.receiver.interface.encodeFunctionData('mockFunction'),
|
||||
value,
|
||||
},
|
||||
],
|
||||
'<proposal description>',
|
||||
);
|
||||
this.proposal.timelockid = await this.timelock.hashOperationBatch(
|
||||
...this.proposal.shortProposal.slice(0, 3),
|
||||
ethers.ZeroHash,
|
||||
timelockSalt(this.mock.target, this.proposal.shortProposal[3]),
|
||||
);
|
||||
});
|
||||
|
||||
describe('proposal indexing', function () {
|
||||
it('before propose', async function () {
|
||||
expect(await this.mock.proposalCount()).to.equal(0n);
|
||||
|
||||
await expect(this.mock.proposalDetailsAt(0n)).to.be.revertedWithPanic(PANIC_CODES.ARRAY_ACCESS_OUT_OF_BOUNDS);
|
||||
|
||||
await expect(this.mock.proposalDetails(this.proposal.id))
|
||||
.to.be.revertedWithCustomError(this.mock, 'GovernorNonexistentProposal')
|
||||
.withArgs(this.proposal.id);
|
||||
});
|
||||
|
||||
it('after propose', async function () {
|
||||
await this.helper.propose();
|
||||
|
||||
expect(await this.mock.proposalCount()).to.equal(1n);
|
||||
|
||||
expect(await this.mock.proposalDetailsAt(0n)).to.deep.equal([
|
||||
this.proposal.id,
|
||||
this.proposal.targets,
|
||||
this.proposal.values,
|
||||
this.proposal.data,
|
||||
this.proposal.descriptionHash,
|
||||
]);
|
||||
|
||||
expect(await this.mock.proposalDetails(this.proposal.id)).to.deep.equal([
|
||||
this.proposal.targets,
|
||||
this.proposal.values,
|
||||
this.proposal.data,
|
||||
this.proposal.descriptionHash,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it('queue and execute by id', async function () {
|
||||
await this.helper.propose();
|
||||
await this.helper.waitForSnapshot();
|
||||
await this.helper.connect(this.voter1).vote({ support: VoteType.For });
|
||||
await this.helper.connect(this.voter2).vote({ support: VoteType.For });
|
||||
await this.helper.connect(this.voter3).vote({ support: VoteType.Against });
|
||||
await this.helper.connect(this.voter4).vote({ support: VoteType.Abstain });
|
||||
await this.helper.waitForDeadline();
|
||||
|
||||
await expect(this.mock.queue(this.proposal.id))
|
||||
.to.emit(this.mock, 'ProposalQueued')
|
||||
.withArgs(this.proposal.id, anyValue)
|
||||
.to.emit(this.timelock, 'CallScheduled')
|
||||
.withArgs(this.proposal.timelockid, ...Array(6).fill(anyValue))
|
||||
.to.emit(this.timelock, 'CallSalt')
|
||||
.withArgs(this.proposal.timelockid, anyValue);
|
||||
|
||||
await this.helper.waitForEta();
|
||||
|
||||
await expect(this.mock.execute(this.proposal.id))
|
||||
.to.emit(this.mock, 'ProposalExecuted')
|
||||
.withArgs(this.proposal.id)
|
||||
.to.emit(this.timelock, 'CallExecuted')
|
||||
.withArgs(this.proposal.timelockid, ...Array(4).fill(anyValue))
|
||||
.to.emit(this.receiver, 'MockFunctionCalled');
|
||||
});
|
||||
|
||||
it('cancel by id', async function () {
|
||||
await this.helper.connect(this.proposer).propose();
|
||||
await expect(this.mock.connect(this.proposer).cancel(this.proposal.id))
|
||||
.to.emit(this.mock, 'ProposalCanceled')
|
||||
.withArgs(this.proposal.id);
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,864 @@
|
||||
const { ethers } = require('hardhat');
|
||||
const { expect } = require('chai');
|
||||
const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers');
|
||||
const { anyValue } = require('@nomicfoundation/hardhat-chai-matchers/withArgs');
|
||||
|
||||
const { GovernorHelper } = require('../../helpers/governance');
|
||||
const { hashOperation } = require('../../helpers/access-manager');
|
||||
const { max } = require('../../helpers/math');
|
||||
const { selector } = require('../../helpers/methods');
|
||||
const { ProposalState, VoteType } = require('../../helpers/enums');
|
||||
const time = require('../../helpers/time');
|
||||
|
||||
function prepareOperation({ sender, target, value = 0n, data = '0x' }) {
|
||||
return {
|
||||
id: hashOperation(sender, target, data),
|
||||
operation: { target, value, data },
|
||||
selector: data.slice(0, 10).padEnd(10, '0'),
|
||||
};
|
||||
}
|
||||
|
||||
const TOKENS = [
|
||||
{ Token: '$ERC20Votes', mode: 'blocknumber' },
|
||||
{ Token: '$ERC20VotesTimestampMock', mode: 'timestamp' },
|
||||
];
|
||||
|
||||
const name = 'OZ-Governor';
|
||||
const version = '1';
|
||||
const tokenName = 'MockToken';
|
||||
const tokenSymbol = 'MTKN';
|
||||
const tokenSupply = ethers.parseEther('100');
|
||||
const votingDelay = 4n;
|
||||
const votingPeriod = 16n;
|
||||
const value = ethers.parseEther('1');
|
||||
|
||||
describe('GovernorTimelockAccess', function () {
|
||||
for (const { Token, mode } of TOKENS) {
|
||||
const fixture = async () => {
|
||||
const [admin, voter1, voter2, voter3, voter4, other] = await ethers.getSigners();
|
||||
|
||||
const manager = await ethers.deployContract('$AccessManager', [admin]);
|
||||
const receiver = await ethers.deployContract('$AccessManagedTarget', [manager]);
|
||||
|
||||
const token = await ethers.deployContract(Token, [tokenName, tokenSymbol, version]);
|
||||
const mock = await ethers.deployContract('$GovernorTimelockAccessMock', [
|
||||
name,
|
||||
votingDelay,
|
||||
votingPeriod,
|
||||
0n,
|
||||
manager,
|
||||
0n,
|
||||
token,
|
||||
0n,
|
||||
]);
|
||||
|
||||
await admin.sendTransaction({ to: mock, value });
|
||||
await token.$_mint(admin, tokenSupply);
|
||||
|
||||
const helper = new GovernorHelper(mock, mode);
|
||||
await helper.connect(admin).delegate({ token, to: voter1, value: ethers.parseEther('10') });
|
||||
await helper.connect(admin).delegate({ token, to: voter2, value: ethers.parseEther('7') });
|
||||
await helper.connect(admin).delegate({ token, to: voter3, value: ethers.parseEther('5') });
|
||||
await helper.connect(admin).delegate({ token, to: voter4, value: ethers.parseEther('2') });
|
||||
|
||||
return { admin, voter1, voter2, voter3, voter4, other, manager, receiver, token, mock, helper };
|
||||
};
|
||||
|
||||
describe(`using ${Token}`, function () {
|
||||
beforeEach(async function () {
|
||||
Object.assign(this, await loadFixture(fixture));
|
||||
|
||||
// restricted proposal
|
||||
this.restricted = prepareOperation({
|
||||
sender: this.mock.target,
|
||||
target: this.receiver.target,
|
||||
data: this.receiver.interface.encodeFunctionData('fnRestricted'),
|
||||
});
|
||||
|
||||
this.unrestricted = prepareOperation({
|
||||
sender: this.mock.target,
|
||||
target: this.receiver.target,
|
||||
data: this.receiver.interface.encodeFunctionData('fnUnrestricted'),
|
||||
});
|
||||
|
||||
this.fallback = prepareOperation({
|
||||
sender: this.mock.target,
|
||||
target: this.receiver.target,
|
||||
data: '0x1234',
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts ether transfers', async function () {
|
||||
await this.admin.sendTransaction({ to: this.mock, value: 1n });
|
||||
});
|
||||
|
||||
it('post deployment check', async function () {
|
||||
expect(await this.mock.name()).to.equal(name);
|
||||
expect(await this.mock.token()).to.equal(this.token);
|
||||
expect(await this.mock.votingDelay()).to.equal(votingDelay);
|
||||
expect(await this.mock.votingPeriod()).to.equal(votingPeriod);
|
||||
expect(await this.mock.quorum(0n)).to.equal(0n);
|
||||
|
||||
expect(await this.mock.accessManager()).to.equal(this.manager);
|
||||
});
|
||||
|
||||
it('sets base delay (seconds)', async function () {
|
||||
const baseDelay = time.duration.hours(10n);
|
||||
|
||||
// Only through governance
|
||||
await expect(this.mock.connect(this.voter1).setBaseDelaySeconds(baseDelay))
|
||||
.to.be.revertedWithCustomError(this.mock, 'GovernorOnlyExecutor')
|
||||
.withArgs(this.voter1);
|
||||
|
||||
this.proposal = await this.helper.setProposal(
|
||||
[
|
||||
{
|
||||
target: this.mock.target,
|
||||
data: this.mock.interface.encodeFunctionData('setBaseDelaySeconds', [baseDelay]),
|
||||
},
|
||||
],
|
||||
'descr',
|
||||
);
|
||||
|
||||
await this.helper.propose();
|
||||
await this.helper.waitForSnapshot();
|
||||
await this.helper.connect(this.voter1).vote({ support: VoteType.For });
|
||||
await this.helper.waitForDeadline();
|
||||
|
||||
await expect(this.helper.execute()).to.emit(this.mock, 'BaseDelaySet').withArgs(0n, baseDelay);
|
||||
|
||||
expect(await this.mock.baseDelaySeconds()).to.equal(baseDelay);
|
||||
});
|
||||
|
||||
it('sets access manager ignored', async function () {
|
||||
const selectors = ['0x12345678', '0x87654321', '0xabcdef01'];
|
||||
|
||||
// Only through governance
|
||||
await expect(this.mock.connect(this.voter1).setAccessManagerIgnored(this.other, selectors, true))
|
||||
.to.be.revertedWithCustomError(this.mock, 'GovernorOnlyExecutor')
|
||||
.withArgs(this.voter1);
|
||||
|
||||
// Ignore
|
||||
await this.helper.setProposal(
|
||||
[
|
||||
{
|
||||
target: this.mock.target,
|
||||
data: this.mock.interface.encodeFunctionData('setAccessManagerIgnored', [
|
||||
this.other.address,
|
||||
selectors,
|
||||
true,
|
||||
]),
|
||||
},
|
||||
],
|
||||
'descr',
|
||||
);
|
||||
await this.helper.propose();
|
||||
await this.helper.waitForSnapshot();
|
||||
await this.helper.connect(this.voter1).vote({ support: VoteType.For });
|
||||
await this.helper.waitForDeadline();
|
||||
|
||||
const ignoreReceipt = this.helper.execute();
|
||||
for (const selector of selectors) {
|
||||
await expect(ignoreReceipt)
|
||||
.to.emit(this.mock, 'AccessManagerIgnoredSet')
|
||||
.withArgs(this.other, selector, true);
|
||||
expect(await this.mock.isAccessManagerIgnored(this.other, selector)).to.be.true;
|
||||
}
|
||||
|
||||
// Unignore
|
||||
await this.helper.setProposal(
|
||||
[
|
||||
{
|
||||
target: this.mock.target,
|
||||
data: this.mock.interface.encodeFunctionData('setAccessManagerIgnored', [
|
||||
this.other.address,
|
||||
selectors,
|
||||
false,
|
||||
]),
|
||||
},
|
||||
],
|
||||
'descr',
|
||||
);
|
||||
|
||||
await this.helper.propose();
|
||||
await this.helper.waitForSnapshot();
|
||||
await this.helper.connect(this.voter1).vote({ support: VoteType.For });
|
||||
await this.helper.waitForDeadline();
|
||||
|
||||
const unignoreReceipt = this.helper.execute();
|
||||
for (const selector of selectors) {
|
||||
await expect(unignoreReceipt)
|
||||
.to.emit(this.mock, 'AccessManagerIgnoredSet')
|
||||
.withArgs(this.other, selector, false);
|
||||
expect(await this.mock.isAccessManagerIgnored(this.other, selector)).to.be.false;
|
||||
}
|
||||
});
|
||||
|
||||
it('sets access manager ignored when target is the governor', async function () {
|
||||
const selectors = ['0x12345678', '0x87654321', '0xabcdef01'];
|
||||
|
||||
await this.helper.setProposal(
|
||||
[
|
||||
{
|
||||
target: this.mock.target,
|
||||
data: this.mock.interface.encodeFunctionData('setAccessManagerIgnored', [
|
||||
this.mock.target,
|
||||
selectors,
|
||||
true,
|
||||
]),
|
||||
},
|
||||
],
|
||||
'descr',
|
||||
);
|
||||
|
||||
await this.helper.propose();
|
||||
await this.helper.waitForSnapshot();
|
||||
await this.helper.connect(this.voter1).vote({ support: VoteType.For });
|
||||
await this.helper.waitForDeadline();
|
||||
|
||||
const tx = this.helper.execute();
|
||||
for (const selector of selectors) {
|
||||
await expect(tx).to.emit(this.mock, 'AccessManagerIgnoredSet').withArgs(this.mock, selector, true);
|
||||
expect(await this.mock.isAccessManagerIgnored(this.mock, selector)).to.be.true;
|
||||
}
|
||||
});
|
||||
|
||||
it('does not need to queue proposals with no delay', async function () {
|
||||
const roleId = 1n;
|
||||
const executionDelay = 0n;
|
||||
const baseDelay = 0n;
|
||||
|
||||
// Set execution delay
|
||||
await this.manager.connect(this.admin).setTargetFunctionRole(this.receiver, [this.restricted.selector], roleId);
|
||||
await this.manager.connect(this.admin).grantRole(roleId, this.mock, executionDelay);
|
||||
|
||||
// Set base delay
|
||||
await this.mock.$_setBaseDelaySeconds(baseDelay);
|
||||
|
||||
await this.helper.setProposal([this.restricted.operation], 'descr');
|
||||
await this.helper.propose();
|
||||
expect(await this.mock.proposalNeedsQueuing(this.helper.currentProposal.id)).to.be.false;
|
||||
});
|
||||
|
||||
it('needs to queue proposals with any delay', async function () {
|
||||
const roleId = 1n;
|
||||
const delays = [
|
||||
[time.duration.hours(1n), time.duration.hours(2n)],
|
||||
[time.duration.hours(2n), time.duration.hours(1n)],
|
||||
];
|
||||
|
||||
for (const [executionDelay, baseDelay] of delays) {
|
||||
// Set execution delay
|
||||
await this.manager
|
||||
.connect(this.admin)
|
||||
.setTargetFunctionRole(this.receiver, [this.restricted.selector], roleId);
|
||||
await this.manager.connect(this.admin).grantRole(roleId, this.mock, executionDelay);
|
||||
|
||||
// Set base delay
|
||||
await this.mock.$_setBaseDelaySeconds(baseDelay);
|
||||
|
||||
await this.helper.setProposal(
|
||||
[this.restricted.operation],
|
||||
`executionDelay=${executionDelay.toString()}}baseDelay=${baseDelay.toString()}}`,
|
||||
);
|
||||
await this.helper.propose();
|
||||
expect(await this.mock.proposalNeedsQueuing(this.helper.currentProposal.id)).to.be.true;
|
||||
}
|
||||
});
|
||||
|
||||
describe('execution plan', function () {
|
||||
it('returns plan for delayed operations', async function () {
|
||||
const roleId = 1n;
|
||||
const delays = [
|
||||
[time.duration.hours(1n), time.duration.hours(2n)],
|
||||
[time.duration.hours(2n), time.duration.hours(1n)],
|
||||
];
|
||||
|
||||
for (const [executionDelay, baseDelay] of delays) {
|
||||
// Set execution delay
|
||||
await this.manager
|
||||
.connect(this.admin)
|
||||
.setTargetFunctionRole(this.receiver, [this.restricted.selector], roleId);
|
||||
await this.manager.connect(this.admin).grantRole(roleId, this.mock, executionDelay);
|
||||
|
||||
// Set base delay
|
||||
await this.mock.$_setBaseDelaySeconds(baseDelay);
|
||||
|
||||
this.proposal = await this.helper.setProposal(
|
||||
[this.restricted.operation],
|
||||
`executionDelay=${executionDelay.toString()}}baseDelay=${baseDelay.toString()}}`,
|
||||
);
|
||||
await this.helper.propose();
|
||||
|
||||
expect(await this.mock.proposalExecutionPlan(this.proposal.id)).to.deep.equal([
|
||||
max(baseDelay, executionDelay),
|
||||
[true],
|
||||
[true],
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
it('returns plan for not delayed operations', async function () {
|
||||
const roleId = 1n;
|
||||
const executionDelay = 0n;
|
||||
const baseDelay = 0n;
|
||||
|
||||
// Set execution delay
|
||||
await this.manager
|
||||
.connect(this.admin)
|
||||
.setTargetFunctionRole(this.receiver, [this.restricted.selector], roleId);
|
||||
await this.manager.connect(this.admin).grantRole(roleId, this.mock, executionDelay);
|
||||
|
||||
// Set base delay
|
||||
await this.mock.$_setBaseDelaySeconds(baseDelay);
|
||||
|
||||
this.proposal = await this.helper.setProposal([this.restricted.operation], `descr`);
|
||||
await this.helper.propose();
|
||||
|
||||
expect(await this.mock.proposalExecutionPlan(this.proposal.id)).to.deep.equal([0n, [true], [false]]);
|
||||
});
|
||||
|
||||
it('returns plan for an operation ignoring the manager', async function () {
|
||||
await this.mock.$_setAccessManagerIgnored(this.receiver, this.restricted.selector, true);
|
||||
|
||||
const roleId = 1n;
|
||||
const delays = [
|
||||
[time.duration.hours(1n), time.duration.hours(2n)],
|
||||
[time.duration.hours(2n), time.duration.hours(1n)],
|
||||
];
|
||||
|
||||
for (const [executionDelay, baseDelay] of delays) {
|
||||
// Set execution delay
|
||||
await this.manager
|
||||
.connect(this.admin)
|
||||
.setTargetFunctionRole(this.receiver, [this.restricted.selector], roleId);
|
||||
await this.manager.connect(this.admin).grantRole(roleId, this.mock, executionDelay);
|
||||
|
||||
// Set base delay
|
||||
await this.mock.$_setBaseDelaySeconds(baseDelay);
|
||||
|
||||
this.proposal = await this.helper.setProposal(
|
||||
[this.restricted.operation],
|
||||
`executionDelay=${executionDelay.toString()}}baseDelay=${baseDelay.toString()}}`,
|
||||
);
|
||||
await this.helper.propose();
|
||||
|
||||
expect(await this.mock.proposalExecutionPlan(this.proposal.id)).to.deep.equal([
|
||||
baseDelay,
|
||||
[false],
|
||||
[false],
|
||||
]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('base delay only', function () {
|
||||
for (const [delay, queue] of [
|
||||
[0, true],
|
||||
[0, false],
|
||||
[1000, true],
|
||||
]) {
|
||||
it(`delay ${delay}, ${queue ? 'with' : 'without'} queuing`, async function () {
|
||||
await this.mock.$_setBaseDelaySeconds(delay);
|
||||
|
||||
this.proposal = await this.helper.setProposal([this.unrestricted.operation], 'descr');
|
||||
|
||||
await this.helper.propose();
|
||||
await this.helper.waitForSnapshot();
|
||||
await this.helper.connect(this.voter1).vote({ support: VoteType.For });
|
||||
await this.helper.waitForDeadline();
|
||||
if (await this.mock.proposalNeedsQueuing(this.proposal.id)) {
|
||||
expect(await this.helper.queue())
|
||||
.to.emit(this.mock, 'ProposalQueued')
|
||||
.withArgs(this.proposal.id, anyValue);
|
||||
}
|
||||
if (delay > 0) {
|
||||
await this.helper.waitForEta();
|
||||
}
|
||||
await expect(this.helper.execute())
|
||||
.to.emit(this.mock, 'ProposalExecuted')
|
||||
.withArgs(this.proposal.id)
|
||||
.to.emit(this.receiver, 'CalledUnrestricted');
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('reverts when an operation is executed before eta', async function () {
|
||||
const delay = time.duration.hours(2n);
|
||||
await this.mock.$_setBaseDelaySeconds(delay);
|
||||
|
||||
this.proposal = await this.helper.setProposal([this.unrestricted.operation], 'descr');
|
||||
|
||||
await this.helper.propose();
|
||||
await this.helper.waitForSnapshot();
|
||||
await this.helper.connect(this.voter1).vote({ support: VoteType.For });
|
||||
await this.helper.waitForDeadline();
|
||||
await this.helper.queue();
|
||||
await expect(this.helper.execute())
|
||||
.to.be.revertedWithCustomError(this.mock, 'GovernorUnmetDelay')
|
||||
.withArgs(this.proposal.id, await this.mock.proposalEta(this.proposal.id));
|
||||
});
|
||||
|
||||
it('reverts with a proposal including multiple operations but one of those was cancelled in the manager', async function () {
|
||||
const delay = time.duration.hours(2n);
|
||||
const roleId = 1n;
|
||||
|
||||
await this.manager.connect(this.admin).setTargetFunctionRole(this.receiver, [this.restricted.selector], roleId);
|
||||
await this.manager.connect(this.admin).grantRole(roleId, this.mock, delay);
|
||||
|
||||
// Set proposals
|
||||
const original = new GovernorHelper(this.mock, mode);
|
||||
await original.setProposal([this.restricted.operation, this.unrestricted.operation], 'descr');
|
||||
|
||||
// Go through all the governance process
|
||||
await original.propose();
|
||||
await original.waitForSnapshot();
|
||||
await original.connect(this.voter1).vote({ support: VoteType.For });
|
||||
await original.waitForDeadline();
|
||||
await original.queue();
|
||||
await original.waitForEta();
|
||||
|
||||
// Suddenly cancel one of the proposed operations in the manager
|
||||
await this.manager
|
||||
.connect(this.admin)
|
||||
.cancel(this.mock, this.restricted.operation.target, this.restricted.operation.data);
|
||||
|
||||
// Reschedule the same operation in a different proposal to avoid "AccessManagerNotScheduled" error
|
||||
const rescheduled = new GovernorHelper(this.mock, mode);
|
||||
await rescheduled.setProposal([this.restricted.operation], 'descr');
|
||||
await rescheduled.propose();
|
||||
await rescheduled.waitForSnapshot();
|
||||
await rescheduled.connect(this.voter1).vote({ support: VoteType.For });
|
||||
await rescheduled.waitForDeadline();
|
||||
await rescheduled.queue(); // This will schedule it again in the manager
|
||||
await rescheduled.waitForEta();
|
||||
|
||||
// Attempt to execute
|
||||
await expect(original.execute())
|
||||
.to.be.revertedWithCustomError(this.mock, 'GovernorMismatchedNonce')
|
||||
.withArgs(original.currentProposal.id, 1, 2);
|
||||
});
|
||||
|
||||
it('single operation with access manager delay', async function () {
|
||||
const delay = 1000n;
|
||||
const roleId = 1n;
|
||||
|
||||
await this.manager.connect(this.admin).setTargetFunctionRole(this.receiver, [this.restricted.selector], roleId);
|
||||
await this.manager.connect(this.admin).grantRole(roleId, this.mock, delay);
|
||||
|
||||
this.proposal = await this.helper.setProposal([this.restricted.operation], 'descr');
|
||||
|
||||
await this.helper.propose();
|
||||
await this.helper.waitForSnapshot();
|
||||
await this.helper.connect(this.voter1).vote({ support: VoteType.For });
|
||||
await this.helper.waitForDeadline();
|
||||
const txQueue = await this.helper.queue();
|
||||
await this.helper.waitForEta();
|
||||
const txExecute = await this.helper.execute();
|
||||
|
||||
await expect(txQueue)
|
||||
.to.emit(this.mock, 'ProposalQueued')
|
||||
.withArgs(this.proposal.id, anyValue)
|
||||
.to.emit(this.manager, 'OperationScheduled')
|
||||
.withArgs(
|
||||
this.restricted.id,
|
||||
1n,
|
||||
(await time.clockFromReceipt.timestamp(txQueue)) + delay,
|
||||
this.mock.target,
|
||||
this.restricted.operation.target,
|
||||
this.restricted.operation.data,
|
||||
);
|
||||
|
||||
await expect(txExecute)
|
||||
.to.emit(this.mock, 'ProposalExecuted')
|
||||
.withArgs(this.proposal.id)
|
||||
.to.emit(this.manager, 'OperationExecuted')
|
||||
.withArgs(this.restricted.id, 1n)
|
||||
.to.emit(this.receiver, 'CalledRestricted');
|
||||
});
|
||||
|
||||
it('bundle of varied operations', async function () {
|
||||
const managerDelay = 1000n;
|
||||
const roleId = 1n;
|
||||
const baseDelay = managerDelay * 2n;
|
||||
|
||||
await this.mock.$_setBaseDelaySeconds(baseDelay);
|
||||
|
||||
await this.manager.connect(this.admin).setTargetFunctionRole(this.receiver, [this.restricted.selector], roleId);
|
||||
await this.manager.connect(this.admin).grantRole(roleId, this.mock, managerDelay);
|
||||
|
||||
this.proposal = await this.helper.setProposal(
|
||||
[this.restricted.operation, this.unrestricted.operation, this.fallback.operation],
|
||||
'descr',
|
||||
);
|
||||
|
||||
await this.helper.propose();
|
||||
await this.helper.waitForSnapshot();
|
||||
await this.helper.connect(this.voter1).vote({ support: VoteType.For });
|
||||
await this.helper.waitForDeadline();
|
||||
const txQueue = await this.helper.queue();
|
||||
await this.helper.waitForEta();
|
||||
const txExecute = await this.helper.execute();
|
||||
|
||||
await expect(txQueue)
|
||||
.to.emit(this.mock, 'ProposalQueued')
|
||||
.withArgs(this.proposal.id, anyValue)
|
||||
.to.emit(this.manager, 'OperationScheduled')
|
||||
.withArgs(
|
||||
this.restricted.id,
|
||||
1n,
|
||||
(await time.clockFromReceipt.timestamp(txQueue)) + baseDelay,
|
||||
this.mock.target,
|
||||
this.restricted.operation.target,
|
||||
this.restricted.operation.data,
|
||||
);
|
||||
|
||||
await expect(txExecute)
|
||||
.to.emit(this.mock, 'ProposalExecuted')
|
||||
.withArgs(this.proposal.id)
|
||||
.to.emit(this.manager, 'OperationExecuted')
|
||||
.withArgs(this.restricted.id, 1n)
|
||||
.to.emit(this.receiver, 'CalledRestricted')
|
||||
.to.emit(this.receiver, 'CalledUnrestricted')
|
||||
.to.emit(this.receiver, 'CalledFallback');
|
||||
});
|
||||
|
||||
describe('cancel', function () {
|
||||
const delay = 1000n;
|
||||
const roleId = 1n;
|
||||
|
||||
beforeEach(async function () {
|
||||
await this.manager
|
||||
.connect(this.admin)
|
||||
.setTargetFunctionRole(this.receiver, [this.restricted.selector], roleId);
|
||||
await this.manager.connect(this.admin).grantRole(roleId, this.mock, delay);
|
||||
});
|
||||
|
||||
it('cancels restricted with delay after queue (internal)', async function () {
|
||||
this.proposal = await this.helper.setProposal([this.restricted.operation], 'descr');
|
||||
|
||||
await this.helper.propose();
|
||||
await this.helper.waitForSnapshot();
|
||||
await this.helper.connect(this.voter1).vote({ support: VoteType.For });
|
||||
await this.helper.waitForDeadline();
|
||||
await this.helper.queue();
|
||||
|
||||
await expect(this.helper.cancel('internal'))
|
||||
.to.emit(this.mock, 'ProposalCanceled')
|
||||
.withArgs(this.proposal.id)
|
||||
.to.emit(this.manager, 'OperationCanceled')
|
||||
.withArgs(this.restricted.id, 1n);
|
||||
|
||||
await this.helper.waitForEta();
|
||||
|
||||
await expect(this.helper.execute())
|
||||
.to.be.revertedWithCustomError(this.mock, 'GovernorUnexpectedProposalState')
|
||||
.withArgs(
|
||||
this.proposal.id,
|
||||
ProposalState.Canceled,
|
||||
GovernorHelper.proposalStatesToBitMap([ProposalState.Succeeded, ProposalState.Queued]),
|
||||
);
|
||||
});
|
||||
|
||||
it('cancels restricted with queueing if the same operation is part of a more recent proposal (internal)', async function () {
|
||||
// Set proposals
|
||||
const original = new GovernorHelper(this.mock, mode);
|
||||
await original.setProposal([this.restricted.operation], 'descr');
|
||||
|
||||
// Go through all the governance process
|
||||
await original.propose();
|
||||
await original.waitForSnapshot();
|
||||
await original.connect(this.voter1).vote({ support: VoteType.For });
|
||||
await original.waitForDeadline();
|
||||
await original.queue();
|
||||
|
||||
// Cancel the operation in the manager
|
||||
await this.manager
|
||||
.connect(this.admin)
|
||||
.cancel(this.mock, this.restricted.operation.target, this.restricted.operation.data);
|
||||
|
||||
// Another proposal is added with the same operation
|
||||
const rescheduled = new GovernorHelper(this.mock, mode);
|
||||
await rescheduled.setProposal([this.restricted.operation], 'another descr');
|
||||
|
||||
// Queue the new proposal
|
||||
await rescheduled.propose();
|
||||
await rescheduled.waitForSnapshot();
|
||||
await rescheduled.connect(this.voter1).vote({ support: VoteType.For });
|
||||
await rescheduled.waitForDeadline();
|
||||
await rescheduled.queue(); // This will schedule it again in the manager
|
||||
|
||||
// Cancel
|
||||
const eta = await this.mock.proposalEta(rescheduled.currentProposal.id);
|
||||
|
||||
await expect(original.cancel('internal'))
|
||||
.to.emit(this.mock, 'ProposalCanceled')
|
||||
.withArgs(original.currentProposal.id);
|
||||
|
||||
await time.clock.timestamp().then(clock => time.increaseTo.timestamp(max(clock + 1n, eta)));
|
||||
|
||||
await expect(original.execute())
|
||||
.to.be.revertedWithCustomError(this.mock, 'GovernorUnexpectedProposalState')
|
||||
.withArgs(
|
||||
original.currentProposal.id,
|
||||
ProposalState.Canceled,
|
||||
GovernorHelper.proposalStatesToBitMap([ProposalState.Succeeded, ProposalState.Queued]),
|
||||
);
|
||||
});
|
||||
|
||||
it('cancels unrestricted with queueing (internal)', async function () {
|
||||
this.proposal = await this.helper.setProposal([this.unrestricted.operation], 'descr');
|
||||
|
||||
await this.helper.propose();
|
||||
await this.helper.waitForSnapshot();
|
||||
await this.helper.connect(this.voter1).vote({ support: VoteType.For });
|
||||
await this.helper.waitForDeadline();
|
||||
await this.helper.queue();
|
||||
|
||||
const eta = await this.mock.proposalEta(this.proposal.id);
|
||||
|
||||
await expect(this.helper.cancel('internal'))
|
||||
.to.emit(this.mock, 'ProposalCanceled')
|
||||
.withArgs(this.proposal.id);
|
||||
|
||||
await time.clock.timestamp().then(clock => time.increaseTo.timestamp(max(clock + 1n, eta)));
|
||||
|
||||
await expect(this.helper.execute())
|
||||
.to.be.revertedWithCustomError(this.mock, 'GovernorUnexpectedProposalState')
|
||||
.withArgs(
|
||||
this.proposal.id,
|
||||
ProposalState.Canceled,
|
||||
GovernorHelper.proposalStatesToBitMap([ProposalState.Succeeded, ProposalState.Queued]),
|
||||
);
|
||||
});
|
||||
|
||||
it('cancels unrestricted without queueing (internal)', async function () {
|
||||
this.proposal = await this.helper.setProposal([this.unrestricted.operation], 'descr');
|
||||
|
||||
await this.helper.propose();
|
||||
await this.helper.waitForSnapshot();
|
||||
await this.helper.connect(this.voter1).vote({ support: VoteType.For });
|
||||
await this.helper.waitForDeadline();
|
||||
|
||||
await expect(this.helper.cancel('internal'))
|
||||
.to.emit(this.mock, 'ProposalCanceled')
|
||||
.withArgs(this.proposal.id);
|
||||
|
||||
await expect(this.helper.execute())
|
||||
.to.be.revertedWithCustomError(this.mock, 'GovernorUnexpectedProposalState')
|
||||
.withArgs(
|
||||
this.proposal.id,
|
||||
ProposalState.Canceled,
|
||||
GovernorHelper.proposalStatesToBitMap([ProposalState.Succeeded, ProposalState.Queued]),
|
||||
);
|
||||
});
|
||||
|
||||
it('cancels calls already canceled by guardian', async function () {
|
||||
const operationA = { target: this.receiver.target, data: this.restricted.selector + '00' };
|
||||
const operationB = { target: this.receiver.target, data: this.restricted.selector + '01' };
|
||||
const operationC = { target: this.receiver.target, data: this.restricted.selector + '02' };
|
||||
const operationAId = hashOperation(this.mock.target, operationA.target, operationA.data);
|
||||
const operationBId = hashOperation(this.mock.target, operationB.target, operationB.data);
|
||||
|
||||
const proposal1 = new GovernorHelper(this.mock, mode);
|
||||
const proposal2 = new GovernorHelper(this.mock, mode);
|
||||
proposal1.setProposal([operationA, operationB], 'proposal A+B');
|
||||
proposal2.setProposal([operationA, operationC], 'proposal A+C');
|
||||
|
||||
for (const p of [proposal1, proposal2]) {
|
||||
await p.propose();
|
||||
await p.waitForSnapshot();
|
||||
await p.connect(this.voter1).vote({ support: VoteType.For });
|
||||
await p.waitForDeadline();
|
||||
}
|
||||
|
||||
// Can queue the first proposal
|
||||
await proposal1.queue();
|
||||
|
||||
// Cannot queue the second proposal: operation A already scheduled with delay
|
||||
await expect(proposal2.queue())
|
||||
.to.be.revertedWithCustomError(this.manager, 'AccessManagerAlreadyScheduled')
|
||||
.withArgs(operationAId);
|
||||
|
||||
// Admin cancels operation B on the manager
|
||||
await this.manager.connect(this.admin).cancel(this.mock, operationB.target, operationB.data);
|
||||
|
||||
// Still cannot queue the second proposal: operation A already scheduled with delay
|
||||
await expect(proposal2.queue())
|
||||
.to.be.revertedWithCustomError(this.manager, 'AccessManagerAlreadyScheduled')
|
||||
.withArgs(operationAId);
|
||||
|
||||
await proposal1.waitForEta();
|
||||
|
||||
// Cannot execute first proposal: operation B has been canceled
|
||||
await expect(proposal1.execute())
|
||||
.to.be.revertedWithCustomError(this.manager, 'AccessManagerNotScheduled')
|
||||
.withArgs(operationBId);
|
||||
|
||||
// Cancel the first proposal to release operation A
|
||||
await proposal1.cancel('internal');
|
||||
|
||||
// can finally queue the second proposal
|
||||
await proposal2.queue();
|
||||
|
||||
await proposal2.waitForEta();
|
||||
|
||||
// Can execute second proposal
|
||||
await proposal2.execute();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ignore AccessManager', function () {
|
||||
it('defaults', async function () {
|
||||
expect(await this.mock.isAccessManagerIgnored(this.receiver, this.restricted.selector)).to.be.false;
|
||||
expect(await this.mock.isAccessManagerIgnored(this.mock, '0x12341234')).to.be.true;
|
||||
});
|
||||
|
||||
it('internal setter', async function () {
|
||||
await expect(this.mock.$_setAccessManagerIgnored(this.receiver, this.restricted.selector, true))
|
||||
.to.emit(this.mock, 'AccessManagerIgnoredSet')
|
||||
.withArgs(this.receiver, this.restricted.selector, true);
|
||||
|
||||
expect(await this.mock.isAccessManagerIgnored(this.receiver, this.restricted.selector)).to.be.true;
|
||||
|
||||
await expect(this.mock.$_setAccessManagerIgnored(this.mock, '0x12341234', false))
|
||||
.to.emit(this.mock, 'AccessManagerIgnoredSet')
|
||||
.withArgs(this.mock, '0x12341234', false);
|
||||
|
||||
expect(await this.mock.isAccessManagerIgnored(this.mock, '0x12341234')).to.be.false;
|
||||
});
|
||||
|
||||
it('external setter', async function () {
|
||||
const setAccessManagerIgnored = (...args) =>
|
||||
this.mock.interface.encodeFunctionData('setAccessManagerIgnored', args);
|
||||
|
||||
await this.helper.setProposal(
|
||||
[
|
||||
{
|
||||
target: this.mock.target,
|
||||
data: setAccessManagerIgnored(
|
||||
this.receiver.target,
|
||||
[this.restricted.selector, this.unrestricted.selector],
|
||||
true,
|
||||
),
|
||||
},
|
||||
{
|
||||
target: this.mock.target,
|
||||
data: setAccessManagerIgnored(this.mock.target, ['0x12341234', '0x67896789'], false),
|
||||
},
|
||||
],
|
||||
'descr',
|
||||
);
|
||||
|
||||
await this.helper.propose();
|
||||
await this.helper.waitForSnapshot();
|
||||
await this.helper.connect(this.voter1).vote({ support: VoteType.For });
|
||||
await this.helper.waitForDeadline();
|
||||
|
||||
await expect(this.helper.execute()).to.emit(this.mock, 'AccessManagerIgnoredSet');
|
||||
|
||||
expect(await this.mock.isAccessManagerIgnored(this.receiver, this.restricted.selector)).to.be.true;
|
||||
expect(await this.mock.isAccessManagerIgnored(this.receiver, this.unrestricted.selector)).to.be.true;
|
||||
expect(await this.mock.isAccessManagerIgnored(this.mock, '0x12341234')).to.be.false;
|
||||
expect(await this.mock.isAccessManagerIgnored(this.mock, '0x67896789')).to.be.false;
|
||||
});
|
||||
|
||||
it('locked function', async function () {
|
||||
const setAccessManagerIgnored = selector('setAccessManagerIgnored(address,bytes4[],bool)');
|
||||
|
||||
await expect(
|
||||
this.mock.$_setAccessManagerIgnored(this.mock, setAccessManagerIgnored, true),
|
||||
).to.be.revertedWithCustomError(this.mock, 'GovernorLockedIgnore');
|
||||
|
||||
await this.mock.$_setAccessManagerIgnored(this.receiver, setAccessManagerIgnored, true);
|
||||
});
|
||||
|
||||
it('ignores access manager', async function () {
|
||||
const amount = 100n;
|
||||
const target = this.token.target;
|
||||
const data = this.token.interface.encodeFunctionData('transfer', [this.voter4.address, amount]);
|
||||
const selector = data.slice(0, 10);
|
||||
await this.token.$_mint(this.mock, amount);
|
||||
|
||||
const roleId = 1n;
|
||||
await this.manager.connect(this.admin).setTargetFunctionRole(target, [selector], roleId);
|
||||
await this.manager.connect(this.admin).grantRole(roleId, this.mock, 0);
|
||||
|
||||
await this.helper.setProposal([{ target, data }], 'descr #1');
|
||||
await this.helper.propose();
|
||||
await this.helper.waitForSnapshot();
|
||||
await this.helper.connect(this.voter1).vote({ support: VoteType.For });
|
||||
await this.helper.waitForDeadline();
|
||||
|
||||
await expect(this.helper.execute())
|
||||
.to.be.revertedWithCustomError(this.token, 'ERC20InsufficientBalance')
|
||||
.withArgs(this.manager, 0n, amount);
|
||||
|
||||
await this.mock.$_setAccessManagerIgnored(target, selector, true);
|
||||
|
||||
await this.helper.setProposal([{ target, data }], 'descr #2');
|
||||
await this.helper.propose();
|
||||
await this.helper.waitForSnapshot();
|
||||
await this.helper.connect(this.voter1).vote({ support: VoteType.For });
|
||||
await this.helper.waitForDeadline();
|
||||
|
||||
await expect(this.helper.execute()).to.emit(this.token, 'Transfer').withArgs(this.mock, this.voter4, amount);
|
||||
});
|
||||
});
|
||||
|
||||
describe('operating on an Ownable contract', function () {
|
||||
const method = selector('$_checkOwner()');
|
||||
|
||||
beforeEach(async function () {
|
||||
this.ownable = await ethers.deployContract('$Ownable', [this.manager]);
|
||||
this.operation = {
|
||||
target: this.ownable.target,
|
||||
data: this.ownable.interface.encodeFunctionData('$_checkOwner'),
|
||||
};
|
||||
});
|
||||
|
||||
it('succeeds with delay', async function () {
|
||||
const roleId = 1n;
|
||||
const executionDelay = time.duration.hours(2n);
|
||||
const baseDelay = time.duration.hours(1n);
|
||||
|
||||
// Set execution delay
|
||||
await this.manager.connect(this.admin).setTargetFunctionRole(this.ownable, [method], roleId);
|
||||
await this.manager.connect(this.admin).grantRole(roleId, this.mock, executionDelay);
|
||||
|
||||
// Set base delay
|
||||
await this.mock.$_setBaseDelaySeconds(baseDelay);
|
||||
|
||||
await this.helper.setProposal([this.operation], `descr`);
|
||||
await this.helper.propose();
|
||||
await this.helper.waitForSnapshot();
|
||||
await this.helper.connect(this.voter1).vote({ support: VoteType.For });
|
||||
await this.helper.waitForDeadline();
|
||||
await this.helper.queue();
|
||||
await this.helper.waitForEta();
|
||||
await this.helper.execute(); // Don't revert
|
||||
});
|
||||
|
||||
it('succeeds without delay', async function () {
|
||||
const roleId = 1n;
|
||||
const executionDelay = 0n;
|
||||
const baseDelay = 0n;
|
||||
|
||||
// Set execution delay
|
||||
await this.manager.connect(this.admin).setTargetFunctionRole(this.ownable, [method], roleId);
|
||||
await this.manager.connect(this.admin).grantRole(roleId, this.mock, executionDelay);
|
||||
|
||||
// Set base delay
|
||||
await this.mock.$_setBaseDelaySeconds(baseDelay);
|
||||
|
||||
await this.helper.setProposal([this.operation], `descr`);
|
||||
await this.helper.propose();
|
||||
await this.helper.waitForSnapshot();
|
||||
await this.helper.connect(this.voter1).vote({ support: VoteType.For });
|
||||
await this.helper.waitForDeadline();
|
||||
await this.helper.execute(); // Don't revert
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,448 @@
|
||||
const { ethers } = require('hardhat');
|
||||
const { expect } = require('chai');
|
||||
const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers');
|
||||
const { anyValue } = require('@nomicfoundation/hardhat-chai-matchers/withArgs');
|
||||
|
||||
const { GovernorHelper } = require('../../helpers/governance');
|
||||
const { ProposalState, VoteType } = require('../../helpers/enums');
|
||||
const time = require('../../helpers/time');
|
||||
|
||||
const TOKENS = [
|
||||
{ Token: '$ERC20Votes', mode: 'blocknumber' },
|
||||
{ Token: '$ERC20VotesTimestampMock', mode: 'timestamp' },
|
||||
];
|
||||
|
||||
const name = 'OZ-Governor';
|
||||
const version = '1';
|
||||
const tokenName = 'MockToken';
|
||||
const tokenSymbol = 'MTKN';
|
||||
const tokenSupply = ethers.parseEther('100');
|
||||
const votingDelay = 4n;
|
||||
const votingPeriod = 16n;
|
||||
const value = ethers.parseEther('1');
|
||||
const defaultDelay = time.duration.days(2n);
|
||||
|
||||
describe('GovernorTimelockCompound', function () {
|
||||
for (const { Token, mode } of TOKENS) {
|
||||
const fixture = async () => {
|
||||
const [deployer, owner, voter1, voter2, voter3, voter4, other] = await ethers.getSigners();
|
||||
const receiver = await ethers.deployContract('CallReceiverMock');
|
||||
|
||||
const token = await ethers.deployContract(Token, [tokenName, tokenSymbol, version]);
|
||||
const predictGovernor = await deployer
|
||||
.getNonce()
|
||||
.then(nonce => ethers.getCreateAddress({ from: deployer.address, nonce: nonce + 1 }));
|
||||
const timelock = await ethers.deployContract('CompTimelock', [predictGovernor, defaultDelay]);
|
||||
const mock = await ethers.deployContract('$GovernorTimelockCompoundMock', [
|
||||
name,
|
||||
votingDelay,
|
||||
votingPeriod,
|
||||
0n,
|
||||
timelock,
|
||||
token,
|
||||
0n,
|
||||
]);
|
||||
|
||||
await owner.sendTransaction({ to: timelock, value });
|
||||
await token.$_mint(owner, tokenSupply);
|
||||
|
||||
const helper = new GovernorHelper(mock, mode);
|
||||
await helper.connect(owner).delegate({ token, to: voter1, value: ethers.parseEther('10') });
|
||||
await helper.connect(owner).delegate({ token, to: voter2, value: ethers.parseEther('7') });
|
||||
await helper.connect(owner).delegate({ token, to: voter3, value: ethers.parseEther('5') });
|
||||
await helper.connect(owner).delegate({ token, to: voter4, value: ethers.parseEther('2') });
|
||||
|
||||
return { deployer, owner, voter1, voter2, voter3, voter4, other, receiver, token, mock, timelock, helper };
|
||||
};
|
||||
|
||||
describe(`using ${Token}`, function () {
|
||||
beforeEach(async function () {
|
||||
Object.assign(this, await loadFixture(fixture));
|
||||
|
||||
// default proposal
|
||||
this.proposal = this.helper.setProposal(
|
||||
[
|
||||
{
|
||||
target: this.receiver.target,
|
||||
value,
|
||||
data: this.receiver.interface.encodeFunctionData('mockFunction'),
|
||||
},
|
||||
],
|
||||
'<proposal description>',
|
||||
);
|
||||
});
|
||||
|
||||
it("doesn't accept ether transfers", async function () {
|
||||
await expect(this.owner.sendTransaction({ to: this.mock, value: 1n })).to.be.revertedWithCustomError(
|
||||
this.mock,
|
||||
'GovernorDisabledDeposit',
|
||||
);
|
||||
});
|
||||
|
||||
it('post deployment check', async function () {
|
||||
expect(await this.mock.name()).to.equal(name);
|
||||
expect(await this.mock.token()).to.equal(this.token);
|
||||
expect(await this.mock.votingDelay()).to.equal(votingDelay);
|
||||
expect(await this.mock.votingPeriod()).to.equal(votingPeriod);
|
||||
expect(await this.mock.quorum(0n)).to.equal(0n);
|
||||
|
||||
expect(await this.mock.timelock()).to.equal(this.timelock);
|
||||
expect(await this.timelock.admin()).to.equal(this.mock);
|
||||
});
|
||||
|
||||
it('nominal', async function () {
|
||||
expect(await this.mock.proposalEta(this.proposal.id)).to.equal(0n);
|
||||
expect(await this.mock.proposalNeedsQueuing(this.proposal.id)).to.be.true;
|
||||
|
||||
await this.helper.propose();
|
||||
await this.helper.waitForSnapshot();
|
||||
await this.helper.connect(this.voter1).vote({ support: VoteType.For });
|
||||
await this.helper.connect(this.voter2).vote({ support: VoteType.For });
|
||||
await this.helper.connect(this.voter3).vote({ support: VoteType.Against });
|
||||
await this.helper.connect(this.voter4).vote({ support: VoteType.Abstain });
|
||||
await this.helper.waitForDeadline();
|
||||
const txQueue = await this.helper.queue();
|
||||
|
||||
const eta = (await time.clockFromReceipt.timestamp(txQueue)) + defaultDelay;
|
||||
expect(await this.mock.proposalEta(this.proposal.id)).to.equal(eta);
|
||||
expect(await this.mock.proposalNeedsQueuing(this.proposal.id)).to.be.true;
|
||||
|
||||
await this.helper.waitForEta();
|
||||
const txExecute = await this.helper.execute();
|
||||
|
||||
await expect(txQueue)
|
||||
.to.emit(this.mock, 'ProposalQueued')
|
||||
.withArgs(this.proposal.id, eta)
|
||||
.to.emit(this.timelock, 'QueueTransaction')
|
||||
.withArgs(...Array(5).fill(anyValue), eta);
|
||||
|
||||
await expect(txExecute)
|
||||
.to.emit(this.mock, 'ProposalExecuted')
|
||||
.withArgs(this.proposal.id)
|
||||
.to.emit(this.timelock, 'ExecuteTransaction')
|
||||
.withArgs(...Array(5).fill(anyValue), eta)
|
||||
.to.emit(this.receiver, 'MockFunctionCalled');
|
||||
});
|
||||
|
||||
describe('should revert', function () {
|
||||
describe('on queue', function () {
|
||||
it('if already queued', async function () {
|
||||
await this.helper.propose();
|
||||
await this.helper.waitForSnapshot();
|
||||
await this.helper.connect(this.voter1).vote({ support: VoteType.For });
|
||||
await this.helper.waitForDeadline();
|
||||
await this.helper.queue();
|
||||
await expect(this.helper.queue())
|
||||
.to.be.revertedWithCustomError(this.mock, 'GovernorUnexpectedProposalState')
|
||||
.withArgs(
|
||||
this.proposal.id,
|
||||
ProposalState.Queued,
|
||||
GovernorHelper.proposalStatesToBitMap([ProposalState.Succeeded]),
|
||||
);
|
||||
});
|
||||
|
||||
it('if proposal contains duplicate calls', async function () {
|
||||
const action = {
|
||||
target: this.token.target,
|
||||
data: this.token.interface.encodeFunctionData('approve', [this.receiver.target, ethers.MaxUint256]),
|
||||
};
|
||||
const { id } = this.helper.setProposal([action, action], '<proposal description>');
|
||||
|
||||
await this.helper.propose();
|
||||
await this.helper.waitForSnapshot();
|
||||
await this.helper.connect(this.voter1).vote({ support: VoteType.For });
|
||||
await this.helper.waitForDeadline();
|
||||
await expect(this.helper.queue())
|
||||
.to.be.revertedWithCustomError(this.mock, 'GovernorAlreadyQueuedProposal')
|
||||
.withArgs(id);
|
||||
await expect(this.helper.execute())
|
||||
.to.be.revertedWithCustomError(this.mock, 'GovernorNotQueuedProposal')
|
||||
.withArgs(id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('on execute', function () {
|
||||
it('if not queued', async function () {
|
||||
await this.helper.propose();
|
||||
await this.helper.waitForSnapshot();
|
||||
await this.helper.connect(this.voter1).vote({ support: VoteType.For });
|
||||
await this.helper.waitForDeadline(1n);
|
||||
|
||||
expect(await this.mock.state(this.proposal.id)).to.equal(ProposalState.Succeeded);
|
||||
|
||||
await expect(this.helper.execute())
|
||||
.to.be.revertedWithCustomError(this.mock, 'GovernorNotQueuedProposal')
|
||||
.withArgs(this.proposal.id);
|
||||
});
|
||||
|
||||
it('if too early', async function () {
|
||||
await this.helper.propose();
|
||||
await this.helper.waitForSnapshot();
|
||||
await this.helper.connect(this.voter1).vote({ support: VoteType.For });
|
||||
await this.helper.waitForDeadline();
|
||||
await this.helper.queue();
|
||||
|
||||
expect(await this.mock.state(this.proposal.id)).to.equal(ProposalState.Queued);
|
||||
|
||||
await expect(this.helper.execute()).to.be.rejectedWith(
|
||||
"Timelock::executeTransaction: Transaction hasn't surpassed time lock",
|
||||
);
|
||||
});
|
||||
|
||||
it('if too late', async function () {
|
||||
await this.helper.propose();
|
||||
await this.helper.waitForSnapshot();
|
||||
await this.helper.connect(this.voter1).vote({ support: VoteType.For });
|
||||
await this.helper.waitForDeadline();
|
||||
await this.helper.queue();
|
||||
await this.helper.waitForEta(time.duration.days(30));
|
||||
|
||||
expect(await this.mock.state(this.proposal.id)).to.equal(ProposalState.Expired);
|
||||
|
||||
await expect(this.helper.execute())
|
||||
.to.be.revertedWithCustomError(this.mock, 'GovernorUnexpectedProposalState')
|
||||
.withArgs(
|
||||
this.proposal.id,
|
||||
ProposalState.Expired,
|
||||
GovernorHelper.proposalStatesToBitMap([ProposalState.Succeeded, ProposalState.Queued]),
|
||||
);
|
||||
});
|
||||
|
||||
it('if already executed', async function () {
|
||||
await this.helper.propose();
|
||||
await this.helper.waitForSnapshot();
|
||||
await this.helper.connect(this.voter1).vote({ support: VoteType.For });
|
||||
await this.helper.waitForDeadline();
|
||||
await this.helper.queue();
|
||||
await this.helper.waitForEta();
|
||||
await this.helper.execute();
|
||||
|
||||
await expect(this.helper.execute())
|
||||
.to.be.revertedWithCustomError(this.mock, 'GovernorUnexpectedProposalState')
|
||||
.withArgs(
|
||||
this.proposal.id,
|
||||
ProposalState.Executed,
|
||||
GovernorHelper.proposalStatesToBitMap([ProposalState.Succeeded, ProposalState.Queued]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('on safe receive', function () {
|
||||
describe('ERC721', function () {
|
||||
const tokenId = 1n;
|
||||
|
||||
beforeEach(async function () {
|
||||
this.token = await ethers.deployContract('$ERC721', ['Non Fungible Token', 'NFT']);
|
||||
await this.token.$_mint(this.owner, tokenId);
|
||||
});
|
||||
|
||||
it("can't receive an ERC721 safeTransfer", async function () {
|
||||
await expect(
|
||||
this.token.connect(this.owner).safeTransferFrom(this.owner, this.mock, tokenId),
|
||||
).to.be.revertedWithCustomError(this.mock, 'GovernorDisabledDeposit');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ERC1155', function () {
|
||||
const tokenIds = {
|
||||
1: 1000n,
|
||||
2: 2000n,
|
||||
3: 3000n,
|
||||
};
|
||||
|
||||
beforeEach(async function () {
|
||||
this.token = await ethers.deployContract('$ERC1155', ['https://token-cdn-domain/{id}.json']);
|
||||
await this.token.$_mintBatch(this.owner, Object.keys(tokenIds), Object.values(tokenIds), '0x');
|
||||
});
|
||||
|
||||
it("can't receive ERC1155 safeTransfer", async function () {
|
||||
await expect(
|
||||
this.token.connect(this.owner).safeTransferFrom(
|
||||
this.owner,
|
||||
this.mock,
|
||||
...Object.entries(tokenIds)[0], // id + amount
|
||||
'0x',
|
||||
),
|
||||
).to.be.revertedWithCustomError(this.mock, 'GovernorDisabledDeposit');
|
||||
});
|
||||
|
||||
it("can't receive ERC1155 safeBatchTransfer", async function () {
|
||||
await expect(
|
||||
this.token
|
||||
.connect(this.owner)
|
||||
.safeBatchTransferFrom(this.owner, this.mock, Object.keys(tokenIds), Object.values(tokenIds), '0x'),
|
||||
).to.be.revertedWithCustomError(this.mock, 'GovernorDisabledDeposit');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('cancel', function () {
|
||||
it('cancel before queue prevents scheduling', async function () {
|
||||
await this.helper.propose();
|
||||
await this.helper.waitForSnapshot();
|
||||
await this.helper.connect(this.voter1).vote({ support: VoteType.For });
|
||||
await this.helper.waitForDeadline();
|
||||
|
||||
await expect(this.helper.cancel('internal'))
|
||||
.to.emit(this.mock, 'ProposalCanceled')
|
||||
.withArgs(this.proposal.id);
|
||||
|
||||
expect(await this.mock.state(this.proposal.id)).to.equal(ProposalState.Canceled);
|
||||
|
||||
await expect(this.helper.queue())
|
||||
.to.be.revertedWithCustomError(this.mock, 'GovernorUnexpectedProposalState')
|
||||
.withArgs(
|
||||
this.proposal.id,
|
||||
ProposalState.Canceled,
|
||||
GovernorHelper.proposalStatesToBitMap([ProposalState.Succeeded]),
|
||||
);
|
||||
});
|
||||
|
||||
it('cancel after queue prevents executing', async function () {
|
||||
await this.helper.propose();
|
||||
await this.helper.waitForSnapshot();
|
||||
await this.helper.connect(this.voter1).vote({ support: VoteType.For });
|
||||
await this.helper.waitForDeadline();
|
||||
await this.helper.queue();
|
||||
|
||||
await expect(this.helper.cancel('internal'))
|
||||
.to.emit(this.mock, 'ProposalCanceled')
|
||||
.withArgs(this.proposal.id);
|
||||
|
||||
expect(await this.mock.state(this.proposal.id)).to.equal(ProposalState.Canceled);
|
||||
|
||||
await expect(this.helper.execute())
|
||||
.to.be.revertedWithCustomError(this.mock, 'GovernorUnexpectedProposalState')
|
||||
.withArgs(
|
||||
this.proposal.id,
|
||||
ProposalState.Canceled,
|
||||
GovernorHelper.proposalStatesToBitMap([ProposalState.Succeeded, ProposalState.Queued]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('onlyGovernance', function () {
|
||||
describe('relay', function () {
|
||||
beforeEach(async function () {
|
||||
await this.token.$_mint(this.mock, 1);
|
||||
});
|
||||
|
||||
it('is protected', async function () {
|
||||
await expect(
|
||||
this.mock
|
||||
.connect(this.owner)
|
||||
.relay(this.token, 0, this.token.interface.encodeFunctionData('transfer', [this.other.address, 1n])),
|
||||
)
|
||||
.to.be.revertedWithCustomError(this.mock, 'GovernorOnlyExecutor')
|
||||
.withArgs(this.owner);
|
||||
});
|
||||
|
||||
it('can be executed through governance', async function () {
|
||||
this.helper.setProposal(
|
||||
[
|
||||
{
|
||||
target: this.mock.target,
|
||||
data: this.mock.interface.encodeFunctionData('relay', [
|
||||
this.token.target,
|
||||
0n,
|
||||
this.token.interface.encodeFunctionData('transfer', [this.other.address, 1n]),
|
||||
]),
|
||||
},
|
||||
],
|
||||
'<proposal description>',
|
||||
);
|
||||
|
||||
await this.helper.propose();
|
||||
await this.helper.waitForSnapshot();
|
||||
await this.helper.connect(this.voter1).vote({ support: VoteType.For });
|
||||
await this.helper.waitForDeadline();
|
||||
await this.helper.queue();
|
||||
await this.helper.waitForEta();
|
||||
|
||||
const txExecute = this.helper.execute();
|
||||
|
||||
await expect(txExecute).to.changeTokenBalances(this.token, [this.mock, this.other], [-1n, 1n]);
|
||||
|
||||
await expect(txExecute).to.emit(this.token, 'Transfer').withArgs(this.mock, this.other, 1n);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateTimelock', function () {
|
||||
beforeEach(async function () {
|
||||
this.newTimelock = await ethers.deployContract('CompTimelock', [this.mock, time.duration.days(7n)]);
|
||||
});
|
||||
|
||||
it('is protected', async function () {
|
||||
await expect(this.mock.connect(this.owner).updateTimelock(this.newTimelock))
|
||||
.to.be.revertedWithCustomError(this.mock, 'GovernorOnlyExecutor')
|
||||
.withArgs(this.owner);
|
||||
});
|
||||
|
||||
it('can be executed through governance to', async function () {
|
||||
this.helper.setProposal(
|
||||
[
|
||||
{
|
||||
target: this.timelock.target,
|
||||
data: this.timelock.interface.encodeFunctionData('setPendingAdmin', [this.owner.address]),
|
||||
},
|
||||
{
|
||||
target: this.mock.target,
|
||||
data: this.mock.interface.encodeFunctionData('updateTimelock', [this.newTimelock.target]),
|
||||
},
|
||||
],
|
||||
'<proposal description>',
|
||||
);
|
||||
|
||||
await this.helper.propose();
|
||||
await this.helper.waitForSnapshot();
|
||||
await this.helper.connect(this.voter1).vote({ support: VoteType.For });
|
||||
await this.helper.waitForDeadline();
|
||||
await this.helper.queue();
|
||||
await this.helper.waitForEta();
|
||||
|
||||
await expect(this.helper.execute())
|
||||
.to.emit(this.mock, 'TimelockChange')
|
||||
.withArgs(this.timelock, this.newTimelock);
|
||||
|
||||
expect(await this.mock.timelock()).to.equal(this.newTimelock);
|
||||
});
|
||||
});
|
||||
|
||||
it('can transfer timelock to new governor', async function () {
|
||||
const newGovernor = await ethers.deployContract('$GovernorTimelockCompoundMock', [
|
||||
name,
|
||||
8n,
|
||||
32n,
|
||||
0n,
|
||||
this.timelock,
|
||||
this.token,
|
||||
0n,
|
||||
]);
|
||||
|
||||
this.helper.setProposal(
|
||||
[
|
||||
{
|
||||
target: this.timelock.target,
|
||||
data: this.timelock.interface.encodeFunctionData('setPendingAdmin', [newGovernor.target]),
|
||||
},
|
||||
],
|
||||
'<proposal description>',
|
||||
);
|
||||
|
||||
await this.helper.propose();
|
||||
await this.helper.waitForSnapshot();
|
||||
await this.helper.connect(this.voter1).vote({ support: VoteType.For });
|
||||
await this.helper.waitForDeadline();
|
||||
await this.helper.queue();
|
||||
await this.helper.waitForEta();
|
||||
|
||||
await expect(this.helper.execute()).to.emit(this.timelock, 'NewPendingAdmin').withArgs(newGovernor);
|
||||
|
||||
await newGovernor.__acceptAdmin();
|
||||
expect(await this.timelock.admin()).to.equal(newGovernor);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,504 @@
|
||||
const { ethers } = require('hardhat');
|
||||
const { expect } = require('chai');
|
||||
const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers');
|
||||
const { anyValue } = require('@nomicfoundation/hardhat-chai-matchers/withArgs');
|
||||
const { PANIC_CODES } = require('@nomicfoundation/hardhat-chai-matchers/panic');
|
||||
|
||||
const { GovernorHelper, timelockSalt } = require('../../helpers/governance');
|
||||
const { OperationState, ProposalState, VoteType } = require('../../helpers/enums');
|
||||
const time = require('../../helpers/time');
|
||||
|
||||
const TOKENS = [
|
||||
{ Token: '$ERC20Votes', mode: 'blocknumber' },
|
||||
{ Token: '$ERC20VotesTimestampMock', mode: 'timestamp' },
|
||||
];
|
||||
|
||||
const DEFAULT_ADMIN_ROLE = ethers.ZeroHash;
|
||||
const PROPOSER_ROLE = ethers.id('PROPOSER_ROLE');
|
||||
const EXECUTOR_ROLE = ethers.id('EXECUTOR_ROLE');
|
||||
const CANCELLER_ROLE = ethers.id('CANCELLER_ROLE');
|
||||
|
||||
const name = 'OZ-Governor';
|
||||
const version = '1';
|
||||
const tokenName = 'MockToken';
|
||||
const tokenSymbol = 'MTKN';
|
||||
const tokenSupply = ethers.parseEther('100');
|
||||
const votingDelay = 4n;
|
||||
const votingPeriod = 16n;
|
||||
const value = ethers.parseEther('1');
|
||||
const delay = time.duration.hours(1n);
|
||||
|
||||
describe('GovernorTimelockControl', function () {
|
||||
for (const { Token, mode } of TOKENS) {
|
||||
const fixture = async () => {
|
||||
const [deployer, owner, voter1, voter2, voter3, voter4, other] = await ethers.getSigners();
|
||||
const receiver = await ethers.deployContract('CallReceiverMock');
|
||||
|
||||
const token = await ethers.deployContract(Token, [tokenName, tokenSymbol, version]);
|
||||
const timelock = await ethers.deployContract('TimelockController', [delay, [], [], deployer]);
|
||||
const mock = await ethers.deployContract('$GovernorTimelockControlMock', [
|
||||
name,
|
||||
votingDelay,
|
||||
votingPeriod,
|
||||
0n,
|
||||
timelock,
|
||||
token,
|
||||
0n,
|
||||
]);
|
||||
|
||||
await owner.sendTransaction({ to: timelock, value });
|
||||
await token.$_mint(owner, tokenSupply);
|
||||
await timelock.grantRole(PROPOSER_ROLE, mock);
|
||||
await timelock.grantRole(PROPOSER_ROLE, owner);
|
||||
await timelock.grantRole(CANCELLER_ROLE, mock);
|
||||
await timelock.grantRole(CANCELLER_ROLE, owner);
|
||||
await timelock.grantRole(EXECUTOR_ROLE, ethers.ZeroAddress);
|
||||
await timelock.revokeRole(DEFAULT_ADMIN_ROLE, deployer);
|
||||
|
||||
const helper = new GovernorHelper(mock, mode);
|
||||
await helper.connect(owner).delegate({ token, to: voter1, value: ethers.parseEther('10') });
|
||||
await helper.connect(owner).delegate({ token, to: voter2, value: ethers.parseEther('7') });
|
||||
await helper.connect(owner).delegate({ token, to: voter3, value: ethers.parseEther('5') });
|
||||
await helper.connect(owner).delegate({ token, to: voter4, value: ethers.parseEther('2') });
|
||||
|
||||
return { deployer, owner, voter1, voter2, voter3, voter4, other, receiver, token, mock, timelock, helper };
|
||||
};
|
||||
|
||||
describe(`using ${Token}`, function () {
|
||||
beforeEach(async function () {
|
||||
Object.assign(this, await loadFixture(fixture));
|
||||
|
||||
// default proposal
|
||||
this.proposal = this.helper.setProposal(
|
||||
[
|
||||
{
|
||||
target: this.receiver.target,
|
||||
value,
|
||||
data: this.receiver.interface.encodeFunctionData('mockFunction'),
|
||||
},
|
||||
],
|
||||
'<proposal description>',
|
||||
);
|
||||
|
||||
this.proposal.timelockid = await this.timelock.hashOperationBatch(
|
||||
...this.proposal.shortProposal.slice(0, 3),
|
||||
ethers.ZeroHash,
|
||||
timelockSalt(this.mock.target, this.proposal.shortProposal[3]),
|
||||
);
|
||||
});
|
||||
|
||||
it("doesn't accept ether transfers", async function () {
|
||||
await expect(this.owner.sendTransaction({ to: this.mock, value: 1n })).to.be.revertedWithCustomError(
|
||||
this.mock,
|
||||
'GovernorDisabledDeposit',
|
||||
);
|
||||
});
|
||||
|
||||
it('post deployment check', async function () {
|
||||
expect(await this.mock.name()).to.equal(name);
|
||||
expect(await this.mock.token()).to.equal(this.token);
|
||||
expect(await this.mock.votingDelay()).to.equal(votingDelay);
|
||||
expect(await this.mock.votingPeriod()).to.equal(votingPeriod);
|
||||
expect(await this.mock.quorum(0n)).to.equal(0n);
|
||||
|
||||
expect(await this.mock.timelock()).to.equal(this.timelock);
|
||||
});
|
||||
|
||||
it('nominal', async function () {
|
||||
expect(await this.mock.proposalEta(this.proposal.id)).to.equal(0n);
|
||||
expect(await this.mock.proposalNeedsQueuing(this.proposal.id)).to.be.true;
|
||||
|
||||
await this.helper.propose();
|
||||
await this.helper.waitForSnapshot();
|
||||
await this.helper.connect(this.voter1).vote({ support: VoteType.For });
|
||||
await this.helper.connect(this.voter2).vote({ support: VoteType.For });
|
||||
await this.helper.connect(this.voter3).vote({ support: VoteType.Against });
|
||||
await this.helper.connect(this.voter4).vote({ support: VoteType.Abstain });
|
||||
await this.helper.waitForDeadline();
|
||||
|
||||
expect(await this.mock.proposalNeedsQueuing(this.proposal.id)).to.be.true;
|
||||
const txQueue = await this.helper.queue();
|
||||
|
||||
const eta = (await time.clockFromReceipt.timestamp(txQueue)) + delay;
|
||||
expect(await this.mock.proposalEta(this.proposal.id)).to.equal(eta);
|
||||
await this.helper.waitForEta();
|
||||
|
||||
const txExecute = this.helper.execute();
|
||||
|
||||
await expect(txQueue)
|
||||
.to.emit(this.mock, 'ProposalQueued')
|
||||
.withArgs(this.proposal.id, anyValue)
|
||||
.to.emit(this.timelock, 'CallScheduled')
|
||||
.withArgs(this.proposal.timelockid, ...Array(6).fill(anyValue))
|
||||
.to.emit(this.timelock, 'CallSalt')
|
||||
.withArgs(this.proposal.timelockid, anyValue);
|
||||
|
||||
await expect(txExecute)
|
||||
.to.emit(this.mock, 'ProposalExecuted')
|
||||
.withArgs(this.proposal.id)
|
||||
.to.emit(this.timelock, 'CallExecuted')
|
||||
.withArgs(this.proposal.timelockid, ...Array(4).fill(anyValue))
|
||||
.to.emit(this.receiver, 'MockFunctionCalled');
|
||||
});
|
||||
|
||||
describe('should revert', function () {
|
||||
describe('on queue', function () {
|
||||
it('if already queued', async function () {
|
||||
await this.helper.propose();
|
||||
await this.helper.waitForSnapshot();
|
||||
await this.helper.connect(this.voter1).vote({ support: VoteType.For });
|
||||
await this.helper.waitForDeadline();
|
||||
await this.helper.queue();
|
||||
await expect(this.helper.queue())
|
||||
.to.be.revertedWithCustomError(this.mock, 'GovernorUnexpectedProposalState')
|
||||
.withArgs(
|
||||
this.proposal.id,
|
||||
ProposalState.Queued,
|
||||
GovernorHelper.proposalStatesToBitMap([ProposalState.Succeeded]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('on execute', function () {
|
||||
it('if not queued', async function () {
|
||||
await this.helper.propose();
|
||||
await this.helper.waitForSnapshot();
|
||||
await this.helper.connect(this.voter1).vote({ support: VoteType.For });
|
||||
await this.helper.waitForDeadline(1n);
|
||||
|
||||
expect(await this.mock.state(this.proposal.id)).to.equal(ProposalState.Succeeded);
|
||||
|
||||
await expect(this.helper.execute())
|
||||
.to.be.revertedWithCustomError(this.timelock, 'TimelockUnexpectedOperationState')
|
||||
.withArgs(this.proposal.timelockid, GovernorHelper.proposalStatesToBitMap(OperationState.Ready));
|
||||
});
|
||||
|
||||
it('if too early', async function () {
|
||||
await this.helper.propose();
|
||||
await this.helper.waitForSnapshot();
|
||||
await this.helper.connect(this.voter1).vote({ support: VoteType.For });
|
||||
await this.helper.waitForDeadline();
|
||||
await this.helper.queue();
|
||||
|
||||
expect(await this.mock.state(this.proposal.id)).to.equal(ProposalState.Queued);
|
||||
|
||||
await expect(this.helper.execute())
|
||||
.to.be.revertedWithCustomError(this.timelock, 'TimelockUnexpectedOperationState')
|
||||
.withArgs(this.proposal.timelockid, GovernorHelper.proposalStatesToBitMap(OperationState.Ready));
|
||||
});
|
||||
|
||||
it('if already executed', async function () {
|
||||
await this.helper.propose();
|
||||
await this.helper.waitForSnapshot();
|
||||
await this.helper.connect(this.voter1).vote({ support: VoteType.For });
|
||||
await this.helper.waitForDeadline();
|
||||
await this.helper.queue();
|
||||
await this.helper.waitForEta();
|
||||
await this.helper.execute();
|
||||
|
||||
await expect(this.helper.execute())
|
||||
.to.be.revertedWithCustomError(this.mock, 'GovernorUnexpectedProposalState')
|
||||
.withArgs(
|
||||
this.proposal.id,
|
||||
ProposalState.Executed,
|
||||
GovernorHelper.proposalStatesToBitMap([ProposalState.Succeeded, ProposalState.Queued]),
|
||||
);
|
||||
});
|
||||
|
||||
it('if already executed by another proposer', async function () {
|
||||
await this.helper.propose();
|
||||
await this.helper.waitForSnapshot();
|
||||
await this.helper.connect(this.voter1).vote({ support: VoteType.For });
|
||||
await this.helper.waitForDeadline();
|
||||
await this.helper.queue();
|
||||
await this.helper.waitForEta();
|
||||
|
||||
await this.timelock.executeBatch(
|
||||
...this.proposal.shortProposal.slice(0, 3),
|
||||
ethers.ZeroHash,
|
||||
timelockSalt(this.mock.target, this.proposal.shortProposal[3]),
|
||||
);
|
||||
|
||||
await expect(this.helper.execute())
|
||||
.to.be.revertedWithCustomError(this.mock, 'GovernorUnexpectedProposalState')
|
||||
.withArgs(
|
||||
this.proposal.id,
|
||||
ProposalState.Executed,
|
||||
GovernorHelper.proposalStatesToBitMap([ProposalState.Succeeded, ProposalState.Queued]),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('cancel', function () {
|
||||
it('cancel before queue prevents scheduling', async function () {
|
||||
await this.helper.propose();
|
||||
await this.helper.waitForSnapshot();
|
||||
await this.helper.connect(this.voter1).vote({ support: VoteType.For });
|
||||
await this.helper.waitForDeadline();
|
||||
|
||||
await expect(this.helper.cancel('internal'))
|
||||
.to.emit(this.mock, 'ProposalCanceled')
|
||||
.withArgs(this.proposal.id);
|
||||
|
||||
expect(await this.mock.state(this.proposal.id)).to.equal(ProposalState.Canceled);
|
||||
|
||||
await expect(this.helper.queue())
|
||||
.to.be.revertedWithCustomError(this.mock, 'GovernorUnexpectedProposalState')
|
||||
.withArgs(
|
||||
this.proposal.id,
|
||||
ProposalState.Canceled,
|
||||
GovernorHelper.proposalStatesToBitMap([ProposalState.Succeeded]),
|
||||
);
|
||||
});
|
||||
|
||||
it('cancel after queue prevents executing', async function () {
|
||||
await this.helper.propose();
|
||||
await this.helper.waitForSnapshot();
|
||||
await this.helper.connect(this.voter1).vote({ support: VoteType.For });
|
||||
await this.helper.waitForDeadline();
|
||||
await this.helper.queue();
|
||||
|
||||
await expect(this.helper.cancel('internal'))
|
||||
.to.emit(this.mock, 'ProposalCanceled')
|
||||
.withArgs(this.proposal.id);
|
||||
|
||||
expect(await this.mock.state(this.proposal.id)).to.equal(ProposalState.Canceled);
|
||||
|
||||
await expect(this.helper.execute())
|
||||
.to.be.revertedWithCustomError(this.mock, 'GovernorUnexpectedProposalState')
|
||||
.withArgs(
|
||||
this.proposal.id,
|
||||
ProposalState.Canceled,
|
||||
GovernorHelper.proposalStatesToBitMap([ProposalState.Succeeded, ProposalState.Queued]),
|
||||
);
|
||||
});
|
||||
|
||||
it('cancel on timelock is reflected on governor', async function () {
|
||||
await this.helper.propose();
|
||||
await this.helper.waitForSnapshot();
|
||||
await this.helper.connect(this.voter1).vote({ support: VoteType.For });
|
||||
await this.helper.waitForDeadline();
|
||||
await this.helper.queue();
|
||||
|
||||
expect(await this.mock.state(this.proposal.id)).to.equal(ProposalState.Queued);
|
||||
|
||||
await expect(this.timelock.connect(this.owner).cancel(this.proposal.timelockid))
|
||||
.to.emit(this.timelock, 'Cancelled')
|
||||
.withArgs(this.proposal.timelockid);
|
||||
|
||||
expect(await this.mock.state(this.proposal.id)).to.equal(ProposalState.Canceled);
|
||||
});
|
||||
});
|
||||
|
||||
describe('onlyGovernance', function () {
|
||||
describe('relay', function () {
|
||||
beforeEach(async function () {
|
||||
await this.token.$_mint(this.mock, 1);
|
||||
});
|
||||
|
||||
it('is protected', async function () {
|
||||
await expect(
|
||||
this.mock
|
||||
.connect(this.owner)
|
||||
.relay(this.token, 0n, this.token.interface.encodeFunctionData('transfer', [this.other.address, 1n])),
|
||||
)
|
||||
.to.be.revertedWithCustomError(this.mock, 'GovernorOnlyExecutor')
|
||||
.withArgs(this.owner);
|
||||
});
|
||||
|
||||
it('can be executed through governance', async function () {
|
||||
this.helper.setProposal(
|
||||
[
|
||||
{
|
||||
target: this.mock.target,
|
||||
data: this.mock.interface.encodeFunctionData('relay', [
|
||||
this.token.target,
|
||||
0n,
|
||||
this.token.interface.encodeFunctionData('transfer', [this.other.address, 1n]),
|
||||
]),
|
||||
},
|
||||
],
|
||||
'<proposal description>',
|
||||
);
|
||||
|
||||
await this.helper.propose();
|
||||
await this.helper.waitForSnapshot();
|
||||
await this.helper.connect(this.voter1).vote({ support: VoteType.For });
|
||||
await this.helper.waitForDeadline();
|
||||
await this.helper.queue();
|
||||
await this.helper.waitForEta();
|
||||
|
||||
const txExecute = await this.helper.execute();
|
||||
|
||||
await expect(txExecute).to.changeTokenBalances(this.token, [this.mock, this.other], [-1n, 1n]);
|
||||
|
||||
await expect(txExecute).to.emit(this.token, 'Transfer').withArgs(this.mock, this.other, 1n);
|
||||
});
|
||||
|
||||
it('is payable and can transfer eth to EOA', async function () {
|
||||
const t2g = 128n; // timelock to governor
|
||||
const g2o = 100n; // governor to eoa (other)
|
||||
|
||||
this.helper.setProposal(
|
||||
[
|
||||
{
|
||||
target: this.mock.target,
|
||||
value: t2g,
|
||||
data: this.mock.interface.encodeFunctionData('relay', [this.other.address, g2o, '0x']),
|
||||
},
|
||||
],
|
||||
'<proposal description>',
|
||||
);
|
||||
|
||||
await this.helper.propose();
|
||||
await this.helper.waitForSnapshot();
|
||||
await this.helper.connect(this.voter1).vote({ support: VoteType.For });
|
||||
await this.helper.waitForDeadline();
|
||||
await this.helper.queue();
|
||||
await this.helper.waitForEta();
|
||||
|
||||
await expect(this.helper.execute()).to.changeEtherBalances(
|
||||
[this.timelock, this.mock, this.other],
|
||||
[-t2g, t2g - g2o, g2o],
|
||||
);
|
||||
});
|
||||
|
||||
it('protected against other proposers', async function () {
|
||||
const call = [
|
||||
this.mock,
|
||||
0n,
|
||||
this.mock.interface.encodeFunctionData('relay', [ethers.ZeroAddress, 0n, '0x']),
|
||||
ethers.ZeroHash,
|
||||
ethers.ZeroHash,
|
||||
];
|
||||
|
||||
await this.timelock.connect(this.owner).schedule(...call, delay);
|
||||
|
||||
await time.increaseBy.timestamp(delay);
|
||||
|
||||
// Error bubbled up from Governor
|
||||
await expect(this.timelock.connect(this.owner).execute(...call)).to.be.revertedWithPanic(
|
||||
PANIC_CODES.POP_ON_EMPTY_ARRAY,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateTimelock', function () {
|
||||
beforeEach(async function () {
|
||||
this.newTimelock = await ethers.deployContract('TimelockController', [
|
||||
delay,
|
||||
[this.mock],
|
||||
[this.mock],
|
||||
ethers.ZeroAddress,
|
||||
]);
|
||||
});
|
||||
|
||||
it('is protected', async function () {
|
||||
await expect(this.mock.connect(this.owner).updateTimelock(this.newTimelock))
|
||||
.to.be.revertedWithCustomError(this.mock, 'GovernorOnlyExecutor')
|
||||
.withArgs(this.owner);
|
||||
});
|
||||
|
||||
it('can be executed through governance to', async function () {
|
||||
this.helper.setProposal(
|
||||
[
|
||||
{
|
||||
target: this.mock.target,
|
||||
data: this.mock.interface.encodeFunctionData('updateTimelock', [this.newTimelock.target]),
|
||||
},
|
||||
],
|
||||
'<proposal description>',
|
||||
);
|
||||
|
||||
await this.helper.propose();
|
||||
await this.helper.waitForSnapshot();
|
||||
await this.helper.connect(this.voter1).vote({ support: VoteType.For });
|
||||
await this.helper.waitForDeadline();
|
||||
await this.helper.queue();
|
||||
await this.helper.waitForEta();
|
||||
|
||||
await expect(this.helper.execute())
|
||||
.to.emit(this.mock, 'TimelockChange')
|
||||
.withArgs(this.timelock, this.newTimelock);
|
||||
|
||||
expect(await this.mock.timelock()).to.equal(this.newTimelock);
|
||||
});
|
||||
});
|
||||
|
||||
describe('on safe receive', function () {
|
||||
describe('ERC721', function () {
|
||||
const tokenId = 1n;
|
||||
|
||||
beforeEach(async function () {
|
||||
this.token = await ethers.deployContract('$ERC721', ['Non Fungible Token', 'NFT']);
|
||||
await this.token.$_mint(this.owner, tokenId);
|
||||
});
|
||||
|
||||
it("can't receive an ERC721 safeTransfer", async function () {
|
||||
await expect(
|
||||
this.token.connect(this.owner).safeTransferFrom(this.owner, this.mock, tokenId),
|
||||
).to.be.revertedWithCustomError(this.mock, 'GovernorDisabledDeposit');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ERC1155', function () {
|
||||
const tokenIds = {
|
||||
1: 1000n,
|
||||
2: 2000n,
|
||||
3: 3000n,
|
||||
};
|
||||
|
||||
beforeEach(async function () {
|
||||
this.token = await ethers.deployContract('$ERC1155', ['https://token-cdn-domain/{id}.json']);
|
||||
await this.token.$_mintBatch(this.owner, Object.keys(tokenIds), Object.values(tokenIds), '0x');
|
||||
});
|
||||
|
||||
it("can't receive ERC1155 safeTransfer", async function () {
|
||||
await expect(
|
||||
this.token.connect(this.owner).safeTransferFrom(
|
||||
this.owner,
|
||||
this.mock,
|
||||
...Object.entries(tokenIds)[0], // id + amount
|
||||
'0x',
|
||||
),
|
||||
).to.be.revertedWithCustomError(this.mock, 'GovernorDisabledDeposit');
|
||||
});
|
||||
|
||||
it("can't receive ERC1155 safeBatchTransfer", async function () {
|
||||
await expect(
|
||||
this.token
|
||||
.connect(this.owner)
|
||||
.safeBatchTransferFrom(this.owner, this.mock, Object.keys(tokenIds), Object.values(tokenIds), '0x'),
|
||||
).to.be.revertedWithCustomError(this.mock, 'GovernorDisabledDeposit');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('clear queue of pending governor calls', async function () {
|
||||
this.helper.setProposal(
|
||||
[
|
||||
{
|
||||
target: this.mock.target,
|
||||
data: this.mock.interface.encodeFunctionData('nonGovernanceFunction'),
|
||||
},
|
||||
],
|
||||
'<proposal description>',
|
||||
);
|
||||
|
||||
await this.helper.propose();
|
||||
await this.helper.waitForSnapshot();
|
||||
await this.helper.connect(this.voter1).vote({ support: VoteType.For });
|
||||
await this.helper.waitForDeadline();
|
||||
await this.helper.queue();
|
||||
await this.helper.waitForEta();
|
||||
await this.helper.execute();
|
||||
|
||||
// This path clears _governanceCall as part of the afterExecute call,
|
||||
// but we have not way to check that the cleanup actually happened other
|
||||
// then coverage reports.
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
const { ethers } = require('hardhat');
|
||||
const { expect } = require('chai');
|
||||
const { loadFixture, mine } = require('@nomicfoundation/hardhat-network-helpers');
|
||||
|
||||
const { GovernorHelper } = require('../../helpers/governance');
|
||||
const { ProposalState, VoteType } = require('../../helpers/enums');
|
||||
const time = require('../../helpers/time');
|
||||
|
||||
const TOKENS = [
|
||||
{ Token: '$ERC20Votes', mode: 'blocknumber' },
|
||||
{ Token: '$ERC20VotesTimestampMock', mode: 'timestamp' },
|
||||
];
|
||||
|
||||
const name = 'OZ-Governor';
|
||||
const version = '1';
|
||||
const tokenName = 'MockToken';
|
||||
const tokenSymbol = 'MTKN';
|
||||
const tokenSupply = ethers.parseEther('100');
|
||||
const ratio = 8n; // percents
|
||||
const newRatio = 6n; // percents
|
||||
const votingDelay = 4n;
|
||||
const votingPeriod = 16n;
|
||||
const value = ethers.parseEther('1');
|
||||
|
||||
describe('GovernorVotesQuorumFraction', function () {
|
||||
for (const { Token, mode } of TOKENS) {
|
||||
const fixture = async () => {
|
||||
const [owner, voter1, voter2, voter3, voter4] = await ethers.getSigners();
|
||||
|
||||
const receiver = await ethers.deployContract('CallReceiverMock');
|
||||
|
||||
const token = await ethers.deployContract(Token, [tokenName, tokenSymbol, version]);
|
||||
const mock = await ethers.deployContract('$GovernorMock', [name, votingDelay, votingPeriod, 0n, token, ratio]);
|
||||
|
||||
await owner.sendTransaction({ to: mock, value });
|
||||
await token.$_mint(owner, tokenSupply);
|
||||
|
||||
const helper = new GovernorHelper(mock, mode);
|
||||
await helper.connect(owner).delegate({ token, to: voter1, value: ethers.parseEther('10') });
|
||||
await helper.connect(owner).delegate({ token, to: voter2, value: ethers.parseEther('7') });
|
||||
await helper.connect(owner).delegate({ token, to: voter3, value: ethers.parseEther('5') });
|
||||
await helper.connect(owner).delegate({ token, to: voter4, value: ethers.parseEther('2') });
|
||||
|
||||
return { owner, voter1, voter2, voter3, voter4, receiver, token, mock, helper };
|
||||
};
|
||||
|
||||
describe(`using ${Token}`, function () {
|
||||
beforeEach(async function () {
|
||||
Object.assign(this, await loadFixture(fixture));
|
||||
|
||||
// default proposal
|
||||
this.proposal = this.helper.setProposal(
|
||||
[
|
||||
{
|
||||
target: this.receiver.target,
|
||||
value,
|
||||
data: this.receiver.interface.encodeFunctionData('mockFunction'),
|
||||
},
|
||||
],
|
||||
'<proposal description>',
|
||||
);
|
||||
});
|
||||
|
||||
it('deployment check', async function () {
|
||||
expect(await this.mock.name()).to.equal(name);
|
||||
expect(await this.mock.token()).to.equal(this.token);
|
||||
expect(await this.mock.votingDelay()).to.equal(votingDelay);
|
||||
expect(await this.mock.votingPeriod()).to.equal(votingPeriod);
|
||||
expect(await this.mock.quorum(0)).to.equal(0n);
|
||||
expect(await this.mock.quorumNumerator()).to.equal(ratio);
|
||||
expect(await this.mock.quorumDenominator()).to.equal(100n);
|
||||
expect(await time.clock[mode]().then(clock => this.mock.quorum(clock - 1n))).to.equal(
|
||||
(tokenSupply * ratio) / 100n,
|
||||
);
|
||||
});
|
||||
|
||||
it('quroum reached', async function () {
|
||||
await this.helper.propose();
|
||||
await this.helper.waitForSnapshot();
|
||||
await this.helper.connect(this.voter1).vote({ support: VoteType.For });
|
||||
await this.helper.waitForDeadline();
|
||||
await this.helper.execute();
|
||||
});
|
||||
|
||||
it('quroum not reached', async function () {
|
||||
await this.helper.propose();
|
||||
await this.helper.waitForSnapshot();
|
||||
await this.helper.connect(this.voter2).vote({ support: VoteType.For });
|
||||
await this.helper.waitForDeadline();
|
||||
await expect(this.helper.execute())
|
||||
.to.be.revertedWithCustomError(this.mock, 'GovernorUnexpectedProposalState')
|
||||
.withArgs(
|
||||
this.proposal.id,
|
||||
ProposalState.Defeated,
|
||||
GovernorHelper.proposalStatesToBitMap([ProposalState.Succeeded, ProposalState.Queued]),
|
||||
);
|
||||
});
|
||||
|
||||
describe('onlyGovernance updates', function () {
|
||||
it('updateQuorumNumerator is protected', async function () {
|
||||
await expect(this.mock.connect(this.owner).updateQuorumNumerator(newRatio))
|
||||
.to.be.revertedWithCustomError(this.mock, 'GovernorOnlyExecutor')
|
||||
.withArgs(this.owner);
|
||||
});
|
||||
|
||||
it('can updateQuorumNumerator through governance', async function () {
|
||||
this.helper.setProposal(
|
||||
[
|
||||
{
|
||||
target: this.mock.target,
|
||||
data: this.mock.interface.encodeFunctionData('updateQuorumNumerator', [newRatio]),
|
||||
},
|
||||
],
|
||||
'<proposal description>',
|
||||
);
|
||||
|
||||
await this.helper.propose();
|
||||
await this.helper.waitForSnapshot();
|
||||
await this.helper.connect(this.voter1).vote({ support: VoteType.For });
|
||||
await this.helper.waitForDeadline();
|
||||
|
||||
await expect(this.helper.execute()).to.emit(this.mock, 'QuorumNumeratorUpdated').withArgs(ratio, newRatio);
|
||||
|
||||
expect(await this.mock.quorumNumerator()).to.equal(newRatio);
|
||||
expect(await this.mock.quorumDenominator()).to.equal(100n);
|
||||
|
||||
// it takes one block for the new quorum to take effect
|
||||
expect(await time.clock[mode]().then(blockNumber => this.mock.quorum(blockNumber - 1n))).to.equal(
|
||||
(tokenSupply * ratio) / 100n,
|
||||
);
|
||||
|
||||
await mine();
|
||||
|
||||
expect(await time.clock[mode]().then(blockNumber => this.mock.quorum(blockNumber - 1n))).to.equal(
|
||||
(tokenSupply * newRatio) / 100n,
|
||||
);
|
||||
});
|
||||
|
||||
it('cannot updateQuorumNumerator over the maximum', async function () {
|
||||
const quorumNumerator = 101n;
|
||||
this.helper.setProposal(
|
||||
[
|
||||
{
|
||||
target: this.mock.target,
|
||||
data: this.mock.interface.encodeFunctionData('updateQuorumNumerator', [quorumNumerator]),
|
||||
},
|
||||
],
|
||||
'<proposal description>',
|
||||
);
|
||||
|
||||
await this.helper.propose();
|
||||
await this.helper.waitForSnapshot();
|
||||
await this.helper.connect(this.voter1).vote({ support: VoteType.For });
|
||||
await this.helper.waitForDeadline();
|
||||
|
||||
const quorumDenominator = await this.mock.quorumDenominator();
|
||||
|
||||
await expect(this.helper.execute())
|
||||
.to.be.revertedWithCustomError(this.mock, 'GovernorInvalidQuorumFraction')
|
||||
.withArgs(quorumNumerator, quorumDenominator);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,245 @@
|
||||
const { ethers } = require('hardhat');
|
||||
const { expect } = require('chai');
|
||||
const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers');
|
||||
|
||||
const { GovernorHelper } = require('../../helpers/governance');
|
||||
const { VoteType } = require('../../helpers/enums');
|
||||
const { getDomain, ExtendedBallot } = require('../../helpers/eip712');
|
||||
|
||||
const TOKENS = [
|
||||
{ Token: '$ERC20Votes', mode: 'blocknumber' },
|
||||
{ Token: '$ERC20VotesTimestampMock', mode: 'timestamp' },
|
||||
];
|
||||
|
||||
const name = 'OZ-Governor';
|
||||
const version = '1';
|
||||
const tokenName = 'MockToken';
|
||||
const tokenSymbol = 'MTKN';
|
||||
const tokenSupply = ethers.parseEther('100');
|
||||
const votingDelay = 4n;
|
||||
const votingPeriod = 16n;
|
||||
const value = ethers.parseEther('1');
|
||||
|
||||
const params = {
|
||||
decoded: [42n, 'These are my params'],
|
||||
encoded: ethers.AbiCoder.defaultAbiCoder().encode(['uint256', 'string'], [42n, 'These are my params']),
|
||||
};
|
||||
|
||||
describe('GovernorWithParams', function () {
|
||||
for (const { Token, mode } of TOKENS) {
|
||||
const fixture = async () => {
|
||||
const [owner, proposer, voter1, voter2, voter3, voter4, other] = await ethers.getSigners();
|
||||
const receiver = await ethers.deployContract('CallReceiverMock');
|
||||
|
||||
const token = await ethers.deployContract(Token, [tokenName, tokenSymbol, version]);
|
||||
const mock = await ethers.deployContract('$GovernorWithParamsMock', [name, token]);
|
||||
|
||||
await owner.sendTransaction({ to: mock, value });
|
||||
await token.$_mint(owner, tokenSupply);
|
||||
|
||||
const helper = new GovernorHelper(mock, mode);
|
||||
await helper.connect(owner).delegate({ token, to: voter1, value: ethers.parseEther('10') });
|
||||
await helper.connect(owner).delegate({ token, to: voter2, value: ethers.parseEther('7') });
|
||||
await helper.connect(owner).delegate({ token, to: voter3, value: ethers.parseEther('5') });
|
||||
await helper.connect(owner).delegate({ token, to: voter4, value: ethers.parseEther('2') });
|
||||
|
||||
return { owner, proposer, voter1, voter2, voter3, voter4, other, receiver, token, mock, helper };
|
||||
};
|
||||
|
||||
describe(`using ${Token}`, function () {
|
||||
beforeEach(async function () {
|
||||
Object.assign(this, await loadFixture(fixture));
|
||||
|
||||
// default proposal
|
||||
this.proposal = this.helper.setProposal(
|
||||
[
|
||||
{
|
||||
target: this.receiver.target,
|
||||
value,
|
||||
data: this.receiver.interface.encodeFunctionData('mockFunction'),
|
||||
},
|
||||
],
|
||||
'<proposal description>',
|
||||
);
|
||||
});
|
||||
|
||||
it('deployment check', async function () {
|
||||
expect(await this.mock.name()).to.equal(name);
|
||||
expect(await this.mock.token()).to.equal(this.token);
|
||||
expect(await this.mock.votingDelay()).to.equal(votingDelay);
|
||||
expect(await this.mock.votingPeriod()).to.equal(votingPeriod);
|
||||
});
|
||||
|
||||
it('nominal is unaffected', async function () {
|
||||
await this.helper.connect(this.proposer).propose();
|
||||
await this.helper.waitForSnapshot();
|
||||
await this.helper.connect(this.voter1).vote({ support: VoteType.For, reason: 'This is nice' });
|
||||
await this.helper.connect(this.voter2).vote({ support: VoteType.For });
|
||||
await this.helper.connect(this.voter3).vote({ support: VoteType.Against });
|
||||
await this.helper.connect(this.voter4).vote({ support: VoteType.Abstain });
|
||||
await this.helper.waitForDeadline();
|
||||
await this.helper.execute();
|
||||
|
||||
expect(await this.mock.hasVoted(this.proposal.id, this.owner)).to.be.false;
|
||||
expect(await this.mock.hasVoted(this.proposal.id, this.voter1)).to.be.true;
|
||||
expect(await this.mock.hasVoted(this.proposal.id, this.voter2)).to.be.true;
|
||||
expect(await ethers.provider.getBalance(this.mock)).to.equal(0n);
|
||||
expect(await ethers.provider.getBalance(this.receiver)).to.equal(value);
|
||||
});
|
||||
|
||||
it('Voting with params is properly supported', async function () {
|
||||
await this.helper.connect(this.proposer).propose();
|
||||
await this.helper.waitForSnapshot();
|
||||
|
||||
const weight = ethers.parseEther('7') - params.decoded[0];
|
||||
|
||||
await expect(
|
||||
this.helper.connect(this.voter2).vote({
|
||||
support: VoteType.For,
|
||||
reason: 'no particular reason',
|
||||
params: params.encoded,
|
||||
}),
|
||||
)
|
||||
.to.emit(this.mock, 'CountParams')
|
||||
.withArgs(...params.decoded)
|
||||
.to.emit(this.mock, 'VoteCastWithParams')
|
||||
.withArgs(
|
||||
this.voter2.address,
|
||||
this.proposal.id,
|
||||
VoteType.For,
|
||||
weight,
|
||||
'no particular reason',
|
||||
params.encoded,
|
||||
);
|
||||
|
||||
expect(await this.mock.proposalVotes(this.proposal.id)).to.deep.equal([0n, weight, 0n]);
|
||||
});
|
||||
|
||||
describe('voting by signature', function () {
|
||||
it('supports EOA signatures', async function () {
|
||||
await this.token.connect(this.voter2).delegate(this.other);
|
||||
|
||||
// Run proposal
|
||||
await this.helper.propose();
|
||||
await this.helper.waitForSnapshot();
|
||||
|
||||
// Prepare vote
|
||||
const weight = ethers.parseEther('7') - params.decoded[0];
|
||||
const nonce = await this.mock.nonces(this.other);
|
||||
const data = {
|
||||
proposalId: this.proposal.id,
|
||||
support: VoteType.For,
|
||||
voter: this.other.address,
|
||||
nonce,
|
||||
reason: 'no particular reason',
|
||||
params: params.encoded,
|
||||
signature: (contract, message) =>
|
||||
getDomain(contract).then(domain => this.other.signTypedData(domain, { ExtendedBallot }, message)),
|
||||
};
|
||||
|
||||
// Vote
|
||||
await expect(this.helper.vote(data))
|
||||
.to.emit(this.mock, 'CountParams')
|
||||
.withArgs(...params.decoded)
|
||||
.to.emit(this.mock, 'VoteCastWithParams')
|
||||
.withArgs(data.voter, data.proposalId, data.support, weight, data.reason, data.params);
|
||||
|
||||
expect(await this.mock.proposalVotes(this.proposal.id)).to.deep.equal([0n, weight, 0n]);
|
||||
expect(await this.mock.nonces(this.other)).to.equal(nonce + 1n);
|
||||
});
|
||||
|
||||
it('supports EIP-1271 signature signatures', async function () {
|
||||
const wallet = await ethers.deployContract('ERC1271WalletMock', [this.other]);
|
||||
await this.token.connect(this.voter2).delegate(wallet);
|
||||
|
||||
// Run proposal
|
||||
await this.helper.propose();
|
||||
await this.helper.waitForSnapshot();
|
||||
|
||||
// Prepare vote
|
||||
const weight = ethers.parseEther('7') - params.decoded[0];
|
||||
const nonce = await this.mock.nonces(this.other);
|
||||
const data = {
|
||||
proposalId: this.proposal.id,
|
||||
support: VoteType.For,
|
||||
voter: wallet.target,
|
||||
nonce,
|
||||
reason: 'no particular reason',
|
||||
params: params.encoded,
|
||||
signature: (contract, message) =>
|
||||
getDomain(contract).then(domain => this.other.signTypedData(domain, { ExtendedBallot }, message)),
|
||||
};
|
||||
|
||||
// Vote
|
||||
await expect(this.helper.vote(data))
|
||||
.to.emit(this.mock, 'CountParams')
|
||||
.withArgs(...params.decoded)
|
||||
.to.emit(this.mock, 'VoteCastWithParams')
|
||||
.withArgs(data.voter, data.proposalId, data.support, weight, data.reason, data.params);
|
||||
|
||||
expect(await this.mock.proposalVotes(this.proposal.id)).to.deep.equal([0n, weight, 0n]);
|
||||
expect(await this.mock.nonces(wallet)).to.equal(nonce + 1n);
|
||||
});
|
||||
|
||||
it('reverts if signature does not match signer', async function () {
|
||||
await this.token.connect(this.voter2).delegate(this.other);
|
||||
|
||||
// Run proposal
|
||||
await this.helper.propose();
|
||||
await this.helper.waitForSnapshot();
|
||||
|
||||
// Prepare vote
|
||||
const nonce = await this.mock.nonces(this.other);
|
||||
const data = {
|
||||
proposalId: this.proposal.id,
|
||||
support: VoteType.For,
|
||||
voter: this.other.address,
|
||||
nonce,
|
||||
reason: 'no particular reason',
|
||||
params: params.encoded,
|
||||
// tampered signature
|
||||
signature: (contract, message) =>
|
||||
getDomain(contract)
|
||||
.then(domain => this.other.signTypedData(domain, { ExtendedBallot }, message))
|
||||
.then(signature => {
|
||||
const tamperedSig = ethers.toBeArray(signature);
|
||||
tamperedSig[42] ^= 0xff;
|
||||
return ethers.hexlify(tamperedSig);
|
||||
}),
|
||||
};
|
||||
|
||||
// Vote
|
||||
await expect(this.helper.vote(data))
|
||||
.to.be.revertedWithCustomError(this.mock, 'GovernorInvalidSignature')
|
||||
.withArgs(data.voter);
|
||||
});
|
||||
|
||||
it('reverts if vote nonce is incorrect', async function () {
|
||||
await this.token.connect(this.voter2).delegate(this.other);
|
||||
|
||||
// Run proposal
|
||||
await this.helper.propose();
|
||||
await this.helper.waitForSnapshot();
|
||||
|
||||
// Prepare vote
|
||||
const nonce = await this.mock.nonces(this.other);
|
||||
const data = {
|
||||
proposalId: this.proposal.id,
|
||||
support: VoteType.For,
|
||||
voter: this.other.address,
|
||||
nonce: nonce + 1n,
|
||||
reason: 'no particular reason',
|
||||
params: params.encoded,
|
||||
signature: (contract, message) =>
|
||||
getDomain(contract).then(domain => this.other.signTypedData(domain, { ExtendedBallot }, message)),
|
||||
};
|
||||
|
||||
// Vote
|
||||
await expect(this.helper.vote(data))
|
||||
.to.be.revertedWithCustomError(this.mock, 'GovernorInvalidSignature')
|
||||
.withArgs(data.voter);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
const { expect } = require('chai');
|
||||
|
||||
const time = require('../../helpers/time');
|
||||
|
||||
function shouldBehaveLikeERC6372(mode = 'blocknumber') {
|
||||
describe('should implement ERC-6372', function () {
|
||||
beforeEach(async function () {
|
||||
this.mock = this.mock ?? this.token ?? this.votes;
|
||||
});
|
||||
|
||||
it('clock is correct', async function () {
|
||||
expect(await this.mock.clock()).to.equal(await time.clock[mode]());
|
||||
});
|
||||
|
||||
it('CLOCK_MODE is correct', async function () {
|
||||
const params = new URLSearchParams(await this.mock.CLOCK_MODE());
|
||||
expect(params.get('mode')).to.equal(mode);
|
||||
expect(params.get('from')).to.equal(mode == 'blocknumber' ? 'default' : null);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
shouldBehaveLikeERC6372,
|
||||
};
|
||||
@@ -0,0 +1,325 @@
|
||||
const { ethers } = require('hardhat');
|
||||
const { expect } = require('chai');
|
||||
const { mine } = require('@nomicfoundation/hardhat-network-helpers');
|
||||
|
||||
const { getDomain, Delegation } = require('../../helpers/eip712');
|
||||
const time = require('../../helpers/time');
|
||||
|
||||
const { shouldBehaveLikeERC6372 } = require('./ERC6372.behavior');
|
||||
|
||||
function shouldBehaveLikeVotes(tokens, { mode = 'blocknumber', fungible = true }) {
|
||||
beforeEach(async function () {
|
||||
[this.delegator, this.delegatee, this.alice, this.bob, this.other] = this.accounts;
|
||||
this.domain = await getDomain(this.votes);
|
||||
});
|
||||
|
||||
shouldBehaveLikeERC6372(mode);
|
||||
|
||||
const getWeight = token => (fungible ? token : 1n);
|
||||
|
||||
describe('run votes workflow', function () {
|
||||
it('initial nonce is 0', async function () {
|
||||
expect(await this.votes.nonces(this.alice)).to.equal(0n);
|
||||
});
|
||||
|
||||
describe('delegation with signature', function () {
|
||||
const token = tokens[0];
|
||||
|
||||
it('delegation without tokens', async function () {
|
||||
expect(await this.votes.delegates(this.alice)).to.equal(ethers.ZeroAddress);
|
||||
|
||||
await expect(this.votes.connect(this.alice).delegate(this.alice))
|
||||
.to.emit(this.votes, 'DelegateChanged')
|
||||
.withArgs(this.alice, ethers.ZeroAddress, this.alice)
|
||||
.to.not.emit(this.votes, 'DelegateVotesChanged');
|
||||
|
||||
expect(await this.votes.delegates(this.alice)).to.equal(this.alice);
|
||||
});
|
||||
|
||||
it('delegation with tokens', async function () {
|
||||
await this.votes.$_mint(this.alice, token);
|
||||
const weight = getWeight(token);
|
||||
|
||||
expect(await this.votes.delegates(this.alice)).to.equal(ethers.ZeroAddress);
|
||||
|
||||
const tx = await this.votes.connect(this.alice).delegate(this.alice);
|
||||
const timepoint = await time.clockFromReceipt[mode](tx);
|
||||
|
||||
await expect(tx)
|
||||
.to.emit(this.votes, 'DelegateChanged')
|
||||
.withArgs(this.alice, ethers.ZeroAddress, this.alice)
|
||||
.to.emit(this.votes, 'DelegateVotesChanged')
|
||||
.withArgs(this.alice, 0n, weight);
|
||||
|
||||
expect(await this.votes.delegates(this.alice)).to.equal(this.alice);
|
||||
expect(await this.votes.getVotes(this.alice)).to.equal(weight);
|
||||
expect(await this.votes.getPastVotes(this.alice, timepoint - 1n)).to.equal(0n);
|
||||
await mine();
|
||||
expect(await this.votes.getPastVotes(this.alice, timepoint)).to.equal(weight);
|
||||
});
|
||||
|
||||
it('delegation update', async function () {
|
||||
await this.votes.connect(this.alice).delegate(this.alice);
|
||||
await this.votes.$_mint(this.alice, token);
|
||||
const weight = getWeight(token);
|
||||
|
||||
expect(await this.votes.delegates(this.alice)).to.equal(this.alice);
|
||||
expect(await this.votes.getVotes(this.alice)).to.equal(weight);
|
||||
expect(await this.votes.getVotes(this.bob)).to.equal(0);
|
||||
|
||||
const tx = await this.votes.connect(this.alice).delegate(this.bob);
|
||||
const timepoint = await time.clockFromReceipt[mode](tx);
|
||||
|
||||
await expect(tx)
|
||||
.to.emit(this.votes, 'DelegateChanged')
|
||||
.withArgs(this.alice, this.alice, this.bob)
|
||||
.to.emit(this.votes, 'DelegateVotesChanged')
|
||||
.withArgs(this.alice, weight, 0)
|
||||
.to.emit(this.votes, 'DelegateVotesChanged')
|
||||
.withArgs(this.bob, 0, weight);
|
||||
|
||||
expect(await this.votes.delegates(this.alice)).to.equal(this.bob);
|
||||
expect(await this.votes.getVotes(this.alice)).to.equal(0n);
|
||||
expect(await this.votes.getVotes(this.bob)).to.equal(weight);
|
||||
|
||||
expect(await this.votes.getPastVotes(this.alice, timepoint - 1n)).to.equal(weight);
|
||||
expect(await this.votes.getPastVotes(this.bob, timepoint - 1n)).to.equal(0n);
|
||||
await mine();
|
||||
expect(await this.votes.getPastVotes(this.alice, timepoint)).to.equal(0n);
|
||||
expect(await this.votes.getPastVotes(this.bob, timepoint)).to.equal(weight);
|
||||
});
|
||||
|
||||
describe('with signature', function () {
|
||||
const nonce = 0n;
|
||||
|
||||
it('accept signed delegation', async function () {
|
||||
await this.votes.$_mint(this.delegator, token);
|
||||
const weight = getWeight(token);
|
||||
|
||||
const { r, s, v } = await this.delegator
|
||||
.signTypedData(
|
||||
this.domain,
|
||||
{ Delegation },
|
||||
{
|
||||
delegatee: this.delegatee.address,
|
||||
nonce,
|
||||
expiry: ethers.MaxUint256,
|
||||
},
|
||||
)
|
||||
.then(ethers.Signature.from);
|
||||
|
||||
expect(await this.votes.delegates(this.delegator)).to.equal(ethers.ZeroAddress);
|
||||
|
||||
const tx = await this.votes.delegateBySig(this.delegatee, nonce, ethers.MaxUint256, v, r, s);
|
||||
const timepoint = await time.clockFromReceipt[mode](tx);
|
||||
|
||||
await expect(tx)
|
||||
.to.emit(this.votes, 'DelegateChanged')
|
||||
.withArgs(this.delegator, ethers.ZeroAddress, this.delegatee)
|
||||
.to.emit(this.votes, 'DelegateVotesChanged')
|
||||
.withArgs(this.delegatee, 0, weight);
|
||||
|
||||
expect(await this.votes.delegates(this.delegator.address)).to.equal(this.delegatee);
|
||||
expect(await this.votes.getVotes(this.delegator.address)).to.equal(0n);
|
||||
expect(await this.votes.getVotes(this.delegatee)).to.equal(weight);
|
||||
expect(await this.votes.getPastVotes(this.delegatee, timepoint - 1n)).to.equal(0n);
|
||||
await mine();
|
||||
expect(await this.votes.getPastVotes(this.delegatee, timepoint)).to.equal(weight);
|
||||
});
|
||||
|
||||
it('rejects reused signature', async function () {
|
||||
const { r, s, v } = await this.delegator
|
||||
.signTypedData(
|
||||
this.domain,
|
||||
{ Delegation },
|
||||
{
|
||||
delegatee: this.delegatee.address,
|
||||
nonce,
|
||||
expiry: ethers.MaxUint256,
|
||||
},
|
||||
)
|
||||
.then(ethers.Signature.from);
|
||||
|
||||
await this.votes.delegateBySig(this.delegatee, nonce, ethers.MaxUint256, v, r, s);
|
||||
|
||||
await expect(this.votes.delegateBySig(this.delegatee, nonce, ethers.MaxUint256, v, r, s))
|
||||
.to.be.revertedWithCustomError(this.votes, 'InvalidAccountNonce')
|
||||
.withArgs(this.delegator, nonce + 1n);
|
||||
});
|
||||
|
||||
it('rejects bad delegatee', async function () {
|
||||
const { r, s, v } = await this.delegator
|
||||
.signTypedData(
|
||||
this.domain,
|
||||
{ Delegation },
|
||||
{
|
||||
delegatee: this.delegatee.address,
|
||||
nonce,
|
||||
expiry: ethers.MaxUint256,
|
||||
},
|
||||
)
|
||||
.then(ethers.Signature.from);
|
||||
|
||||
const tx = await this.votes.delegateBySig(this.other, nonce, ethers.MaxUint256, v, r, s);
|
||||
const receipt = await tx.wait();
|
||||
|
||||
const [delegateChanged] = receipt.logs.filter(
|
||||
log => this.votes.interface.parseLog(log)?.name === 'DelegateChanged',
|
||||
);
|
||||
const { args } = this.votes.interface.parseLog(delegateChanged);
|
||||
expect(args.delegator).to.not.be.equal(this.delegator);
|
||||
expect(args.fromDelegate).to.equal(ethers.ZeroAddress);
|
||||
expect(args.toDelegate).to.equal(this.other);
|
||||
});
|
||||
|
||||
it('rejects bad nonce', async function () {
|
||||
const { r, s, v } = await this.delegator
|
||||
.signTypedData(
|
||||
this.domain,
|
||||
{ Delegation },
|
||||
{
|
||||
delegatee: this.delegatee.address,
|
||||
nonce: nonce + 1n,
|
||||
expiry: ethers.MaxUint256,
|
||||
},
|
||||
)
|
||||
.then(ethers.Signature.from);
|
||||
|
||||
await expect(this.votes.delegateBySig(this.delegatee, nonce + 1n, ethers.MaxUint256, v, r, s))
|
||||
.to.be.revertedWithCustomError(this.votes, 'InvalidAccountNonce')
|
||||
.withArgs(this.delegator, 0);
|
||||
});
|
||||
|
||||
it('rejects expired permit', async function () {
|
||||
const expiry = (await time.clock.timestamp()) - 1n;
|
||||
const { r, s, v } = await this.delegator
|
||||
.signTypedData(
|
||||
this.domain,
|
||||
{ Delegation },
|
||||
{
|
||||
delegatee: this.delegatee.address,
|
||||
nonce,
|
||||
expiry,
|
||||
},
|
||||
)
|
||||
.then(ethers.Signature.from);
|
||||
|
||||
await expect(this.votes.delegateBySig(this.delegatee, nonce, expiry, v, r, s))
|
||||
.to.be.revertedWithCustomError(this.votes, 'VotesExpiredSignature')
|
||||
.withArgs(expiry);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPastTotalSupply', function () {
|
||||
beforeEach(async function () {
|
||||
await this.votes.connect(this.alice).delegate(this.alice);
|
||||
});
|
||||
|
||||
it('reverts if block number >= current block', async function () {
|
||||
const timepoint = 5e10;
|
||||
const clock = await this.votes.clock();
|
||||
await expect(this.votes.getPastTotalSupply(timepoint))
|
||||
.to.be.revertedWithCustomError(this.votes, 'ERC5805FutureLookup')
|
||||
.withArgs(timepoint, clock);
|
||||
});
|
||||
|
||||
it('returns 0 if there are no checkpoints', async function () {
|
||||
expect(await this.votes.getPastTotalSupply(0n)).to.equal(0n);
|
||||
});
|
||||
|
||||
it('returns the correct checkpointed total supply', async function () {
|
||||
const weight = tokens.map(token => getWeight(token));
|
||||
|
||||
// t0 = mint #0
|
||||
const t0 = await this.votes.$_mint(this.alice, tokens[0]);
|
||||
await mine();
|
||||
// t1 = mint #1
|
||||
const t1 = await this.votes.$_mint(this.alice, tokens[1]);
|
||||
await mine();
|
||||
// t2 = burn #1
|
||||
const t2 = await this.votes.$_burn(...(fungible ? [this.alice] : []), tokens[1]);
|
||||
await mine();
|
||||
// t3 = mint #2
|
||||
const t3 = await this.votes.$_mint(this.alice, tokens[2]);
|
||||
await mine();
|
||||
// t4 = burn #0
|
||||
const t4 = await this.votes.$_burn(...(fungible ? [this.alice] : []), tokens[0]);
|
||||
await mine();
|
||||
// t5 = burn #2
|
||||
const t5 = await this.votes.$_burn(...(fungible ? [this.alice] : []), tokens[2]);
|
||||
await mine();
|
||||
|
||||
t0.timepoint = await time.clockFromReceipt[mode](t0);
|
||||
t1.timepoint = await time.clockFromReceipt[mode](t1);
|
||||
t2.timepoint = await time.clockFromReceipt[mode](t2);
|
||||
t3.timepoint = await time.clockFromReceipt[mode](t3);
|
||||
t4.timepoint = await time.clockFromReceipt[mode](t4);
|
||||
t5.timepoint = await time.clockFromReceipt[mode](t5);
|
||||
|
||||
expect(await this.votes.getPastTotalSupply(t0.timepoint - 1n)).to.equal(0);
|
||||
expect(await this.votes.getPastTotalSupply(t0.timepoint)).to.equal(weight[0]);
|
||||
expect(await this.votes.getPastTotalSupply(t0.timepoint + 1n)).to.equal(weight[0]);
|
||||
expect(await this.votes.getPastTotalSupply(t1.timepoint)).to.equal(weight[0] + weight[1]);
|
||||
expect(await this.votes.getPastTotalSupply(t1.timepoint + 1n)).to.equal(weight[0] + weight[1]);
|
||||
expect(await this.votes.getPastTotalSupply(t2.timepoint)).to.equal(weight[0]);
|
||||
expect(await this.votes.getPastTotalSupply(t2.timepoint + 1n)).to.equal(weight[0]);
|
||||
expect(await this.votes.getPastTotalSupply(t3.timepoint)).to.equal(weight[0] + weight[2]);
|
||||
expect(await this.votes.getPastTotalSupply(t3.timepoint + 1n)).to.equal(weight[0] + weight[2]);
|
||||
expect(await this.votes.getPastTotalSupply(t4.timepoint)).to.equal(weight[2]);
|
||||
expect(await this.votes.getPastTotalSupply(t4.timepoint + 1n)).to.equal(weight[2]);
|
||||
expect(await this.votes.getPastTotalSupply(t5.timepoint)).to.equal(0);
|
||||
await expect(this.votes.getPastTotalSupply(t5.timepoint + 1n))
|
||||
.to.be.revertedWithCustomError(this.votes, 'ERC5805FutureLookup')
|
||||
.withArgs(t5.timepoint + 1n, t5.timepoint + 1n);
|
||||
});
|
||||
});
|
||||
|
||||
// The following tests are an adaptation of
|
||||
// https://github.com/compound-finance/compound-protocol/blob/master/tests/Governance/CompTest.js.
|
||||
describe('Compound test suite', function () {
|
||||
beforeEach(async function () {
|
||||
await this.votes.$_mint(this.alice, tokens[0]);
|
||||
await this.votes.$_mint(this.alice, tokens[1]);
|
||||
await this.votes.$_mint(this.alice, tokens[2]);
|
||||
});
|
||||
|
||||
describe('getPastVotes', function () {
|
||||
it('reverts if block number >= current block', async function () {
|
||||
const clock = await this.votes.clock();
|
||||
const timepoint = 5e10; // far in the future
|
||||
await expect(this.votes.getPastVotes(this.bob, timepoint))
|
||||
.to.be.revertedWithCustomError(this.votes, 'ERC5805FutureLookup')
|
||||
.withArgs(timepoint, clock);
|
||||
});
|
||||
|
||||
it('returns 0 if there are no checkpoints', async function () {
|
||||
expect(await this.votes.getPastVotes(this.bob, 0n)).to.equal(0n);
|
||||
});
|
||||
|
||||
it('returns the latest block if >= last checkpoint block', async function () {
|
||||
const delegate = await this.votes.connect(this.alice).delegate(this.bob);
|
||||
const timepoint = await time.clockFromReceipt[mode](delegate);
|
||||
await mine(2);
|
||||
|
||||
const latest = await this.votes.getVotes(this.bob);
|
||||
expect(await this.votes.getPastVotes(this.bob, timepoint)).to.equal(latest);
|
||||
expect(await this.votes.getPastVotes(this.bob, timepoint + 1n)).to.equal(latest);
|
||||
});
|
||||
|
||||
it('returns zero if < first checkpoint block', async function () {
|
||||
await mine();
|
||||
const delegate = await this.votes.connect(this.alice).delegate(this.bob);
|
||||
const timepoint = await time.clockFromReceipt[mode](delegate);
|
||||
await mine(2);
|
||||
|
||||
expect(await this.votes.getPastVotes(this.bob, timepoint - 1n)).to.equal(0n);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
shouldBehaveLikeVotes,
|
||||
};
|
||||
102
lib_openzeppelin_contracts/test/governance/utils/Votes.test.js
Normal file
102
lib_openzeppelin_contracts/test/governance/utils/Votes.test.js
Normal file
@@ -0,0 +1,102 @@
|
||||
const { ethers } = require('hardhat');
|
||||
const { expect } = require('chai');
|
||||
const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers');
|
||||
|
||||
const { sum } = require('../../helpers/math');
|
||||
const { zip } = require('../../helpers/iterate');
|
||||
const time = require('../../helpers/time');
|
||||
|
||||
const { shouldBehaveLikeVotes } = require('./Votes.behavior');
|
||||
|
||||
const MODES = {
|
||||
blocknumber: '$VotesMock',
|
||||
timestamp: '$VotesTimestampMock',
|
||||
};
|
||||
|
||||
const AMOUNTS = [ethers.parseEther('10000000'), 10n, 20n];
|
||||
|
||||
describe('Votes', function () {
|
||||
for (const [mode, artifact] of Object.entries(MODES)) {
|
||||
const fixture = async () => {
|
||||
const accounts = await ethers.getSigners();
|
||||
|
||||
const amounts = Object.fromEntries(
|
||||
zip(
|
||||
accounts.slice(0, AMOUNTS.length).map(({ address }) => address),
|
||||
AMOUNTS,
|
||||
),
|
||||
);
|
||||
|
||||
const name = 'My Vote';
|
||||
const version = '1';
|
||||
const votes = await ethers.deployContract(artifact, [name, version]);
|
||||
|
||||
return { accounts, amounts, votes, name, version };
|
||||
};
|
||||
|
||||
describe(`vote with ${mode}`, function () {
|
||||
beforeEach(async function () {
|
||||
Object.assign(this, await loadFixture(fixture));
|
||||
});
|
||||
|
||||
shouldBehaveLikeVotes(AMOUNTS, { mode, fungible: true });
|
||||
|
||||
it('starts with zero votes', async function () {
|
||||
expect(await this.votes.getTotalSupply()).to.equal(0n);
|
||||
});
|
||||
|
||||
describe('performs voting operations', function () {
|
||||
beforeEach(async function () {
|
||||
this.txs = [];
|
||||
for (const [account, amount] of Object.entries(this.amounts)) {
|
||||
this.txs.push(await this.votes.$_mint(account, amount));
|
||||
}
|
||||
});
|
||||
|
||||
it('reverts if block number >= current block', async function () {
|
||||
const lastTxTimepoint = await time.clockFromReceipt[mode](this.txs.at(-1));
|
||||
const clock = await this.votes.clock();
|
||||
await expect(this.votes.getPastTotalSupply(lastTxTimepoint))
|
||||
.to.be.revertedWithCustomError(this.votes, 'ERC5805FutureLookup')
|
||||
.withArgs(lastTxTimepoint, clock);
|
||||
});
|
||||
|
||||
it('delegates', async function () {
|
||||
expect(await this.votes.getVotes(this.accounts[0])).to.equal(0n);
|
||||
expect(await this.votes.getVotes(this.accounts[1])).to.equal(0n);
|
||||
expect(await this.votes.delegates(this.accounts[0])).to.equal(ethers.ZeroAddress);
|
||||
expect(await this.votes.delegates(this.accounts[1])).to.equal(ethers.ZeroAddress);
|
||||
|
||||
await this.votes.delegate(this.accounts[0], ethers.Typed.address(this.accounts[0]));
|
||||
|
||||
expect(await this.votes.getVotes(this.accounts[0])).to.equal(this.amounts[this.accounts[0].address]);
|
||||
expect(await this.votes.getVotes(this.accounts[1])).to.equal(0n);
|
||||
expect(await this.votes.delegates(this.accounts[0])).to.equal(this.accounts[0]);
|
||||
expect(await this.votes.delegates(this.accounts[1])).to.equal(ethers.ZeroAddress);
|
||||
|
||||
await this.votes.delegate(this.accounts[1], ethers.Typed.address(this.accounts[0]));
|
||||
|
||||
expect(await this.votes.getVotes(this.accounts[0])).to.equal(
|
||||
this.amounts[this.accounts[0].address] + this.amounts[this.accounts[1].address],
|
||||
);
|
||||
expect(await this.votes.getVotes(this.accounts[1])).to.equal(0n);
|
||||
expect(await this.votes.delegates(this.accounts[0])).to.equal(this.accounts[0]);
|
||||
expect(await this.votes.delegates(this.accounts[1])).to.equal(this.accounts[0]);
|
||||
});
|
||||
|
||||
it('cross delegates', async function () {
|
||||
await this.votes.delegate(this.accounts[0], ethers.Typed.address(this.accounts[1]));
|
||||
await this.votes.delegate(this.accounts[1], ethers.Typed.address(this.accounts[0]));
|
||||
|
||||
expect(await this.votes.getVotes(this.accounts[0])).to.equal(this.amounts[this.accounts[1].address]);
|
||||
expect(await this.votes.getVotes(this.accounts[1])).to.equal(this.amounts[this.accounts[0].address]);
|
||||
});
|
||||
|
||||
it('returns total amount of votes', async function () {
|
||||
const totalSupply = sum(...Object.values(this.amounts));
|
||||
expect(await this.votes.getTotalSupply()).to.equal(totalSupply);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
85
lib_openzeppelin_contracts/test/helpers/access-manager.js
Normal file
85
lib_openzeppelin_contracts/test/helpers/access-manager.js
Normal 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,
|
||||
};
|
||||
14
lib_openzeppelin_contracts/test/helpers/account.js
Normal file
14
lib_openzeppelin_contracts/test/helpers/account.js
Normal 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,
|
||||
};
|
||||
4
lib_openzeppelin_contracts/test/helpers/constants.js
Normal file
4
lib_openzeppelin_contracts/test/helpers/constants.js
Normal file
@@ -0,0 +1,4 @@
|
||||
module.exports = {
|
||||
MAX_UINT48: 2n ** 48n - 1n,
|
||||
MAX_UINT64: 2n ** 64n - 1n,
|
||||
};
|
||||
52
lib_openzeppelin_contracts/test/helpers/eip712-types.js
Normal file
52
lib_openzeppelin_contracts/test/helpers/eip712-types.js
Normal 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;
|
||||
45
lib_openzeppelin_contracts/test/helpers/eip712.js
Normal file
45
lib_openzeppelin_contracts/test/helpers/eip712.js
Normal 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,
|
||||
};
|
||||
12
lib_openzeppelin_contracts/test/helpers/enums.js
Normal file
12
lib_openzeppelin_contracts/test/helpers/enums.js
Normal 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'),
|
||||
};
|
||||
198
lib_openzeppelin_contracts/test/helpers/governance.js
Normal file
198
lib_openzeppelin_contracts/test/helpers/governance.js
Normal 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,
|
||||
};
|
||||
36
lib_openzeppelin_contracts/test/helpers/iterate.js
Normal file
36
lib_openzeppelin_contracts/test/helpers/iterate.js
Normal 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)])),
|
||||
};
|
||||
33
lib_openzeppelin_contracts/test/helpers/math.js
Normal file
33
lib_openzeppelin_contracts/test/helpers/math.js
Normal 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,
|
||||
};
|
||||
14
lib_openzeppelin_contracts/test/helpers/methods.js
Normal file
14
lib_openzeppelin_contracts/test/helpers/methods.js
Normal 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,
|
||||
};
|
||||
19
lib_openzeppelin_contracts/test/helpers/random.js
Normal file
19
lib_openzeppelin_contracts/test/helpers/random.js
Normal 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,
|
||||
};
|
||||
48
lib_openzeppelin_contracts/test/helpers/storage.js
Normal file
48
lib_openzeppelin_contracts/test/helpers/storage.js
Normal 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,
|
||||
};
|
||||
5
lib_openzeppelin_contracts/test/helpers/strings.js
Normal file
5
lib_openzeppelin_contracts/test/helpers/strings.js
Normal 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),
|
||||
};
|
||||
30
lib_openzeppelin_contracts/test/helpers/time.js
Normal file
30
lib_openzeppelin_contracts/test/helpers/time.js
Normal 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,
|
||||
};
|
||||
29
lib_openzeppelin_contracts/test/helpers/txpool.js
Normal file
29
lib_openzeppelin_contracts/test/helpers/txpool.js
Normal 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,
|
||||
};
|
||||
133
lib_openzeppelin_contracts/test/metatx/ERC2771Context.test.js
Normal file
133
lib_openzeppelin_contracts/test/metatx/ERC2771Context.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
165
lib_openzeppelin_contracts/test/metatx/ERC2771Forwarder.t.sol
Normal file
165
lib_openzeppelin_contracts/test/metatx/ERC2771Forwarder.t.sol
Normal 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);
|
||||
}
|
||||
}
|
||||
461
lib_openzeppelin_contracts/test/metatx/ERC2771Forwarder.test.js
Normal file
461
lib_openzeppelin_contracts/test/metatx/ERC2771Forwarder.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
160
lib_openzeppelin_contracts/test/proxy/Clones.behaviour.js
Normal file
160
lib_openzeppelin_contracts/test/proxy/Clones.behaviour.js
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
18
lib_openzeppelin_contracts/test/proxy/Clones.t.sol
Normal file
18
lib_openzeppelin_contracts/test/proxy/Clones.t.sol
Normal 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));
|
||||
}
|
||||
}
|
||||
95
lib_openzeppelin_contracts/test/proxy/Clones.test.js
Normal file
95
lib_openzeppelin_contracts/test/proxy/Clones.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
185
lib_openzeppelin_contracts/test/proxy/Proxy.behaviour.js
Normal file
185
lib_openzeppelin_contracts/test/proxy/Proxy.behaviour.js
Normal 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;
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
141
lib_openzeppelin_contracts/test/proxy/beacon/BeaconProxy.test.js
Normal file
141
lib_openzeppelin_contracts/test/proxy/beacon/BeaconProxy.test.js
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
27
lib_openzeppelin_contracts/test/sanity.test.js
Normal file
27
lib_openzeppelin_contracts/test/sanity.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
213
lib_openzeppelin_contracts/test/token/ERC1155/ERC1155.test.js
Normal file
213
lib_openzeppelin_contracts/test/token/ERC1155/ERC1155.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
260
lib_openzeppelin_contracts/test/token/ERC20/ERC20.behavior.js
Normal file
260
lib_openzeppelin_contracts/test/token/ERC20/ERC20.behavior.js
Normal 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,
|
||||
};
|
||||
199
lib_openzeppelin_contracts/test/token/ERC20/ERC20.test.js
Normal file
199
lib_openzeppelin_contracts/test/token/ERC20/ERC20.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
972
lib_openzeppelin_contracts/test/token/ERC721/ERC721.behavior.js
Normal file
972
lib_openzeppelin_contracts/test/token/ERC721/ERC721.behavior.js
Normal 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,
|
||||
};
|
||||
23
lib_openzeppelin_contracts/test/token/ERC721/ERC721.test.js
Normal file
23
lib_openzeppelin_contracts/test/token/ERC721/ERC721.test.js
Normal 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);
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
152
lib_openzeppelin_contracts/test/token/common/ERC2981.behavior.js
Normal file
152
lib_openzeppelin_contracts/test/token/common/ERC2981.behavior.js
Normal 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,
|
||||
};
|
||||
280
lib_openzeppelin_contracts/test/utils/Address.test.js
Normal file
280
lib_openzeppelin_contracts/test/utils/Address.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
15
lib_openzeppelin_contracts/test/utils/Arrays.t.sol
Normal file
15
lib_openzeppelin_contracts/test/utils/Arrays.t.sol
Normal 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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
223
lib_openzeppelin_contracts/test/utils/Arrays.test.js
Normal file
223
lib_openzeppelin_contracts/test/utils/Arrays.test.js
Normal 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]);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
34
lib_openzeppelin_contracts/test/utils/Base64.t.sol
Normal file
34
lib_openzeppelin_contracts/test/utils/Base64.t.sol
Normal 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
Reference in New Issue
Block a user