diff --git a/contracts/access/access_control/AccessControlDefaultAdminRules.sol b/contracts/access/access_control/AccessControlDefaultAdminRules.sol new file mode 100644 index 000000000..5907736a7 --- /dev/null +++ b/contracts/access/access_control/AccessControlDefaultAdminRules.sol @@ -0,0 +1,117 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.20; + +import { AccessControl } from './AccessControl.sol'; +import { _AccessControl } from './_AccessControl.sol'; +import { IAccessControlDefaultAdminRules } from './IAccessControlDefaultAdminRules.sol'; +import { _AccessControlDefaultAdminRules } from './_AccessControlDefaultAdminRules.sol'; + +/** + * @title Role-based access control system with default admin rules + * @dev derived from https://github.com/OpenZeppelin/openzeppelin-contracts (MIT license) + */ +abstract contract AccessControlDefaultAdminRules is + IAccessControlDefaultAdminRules, + _AccessControlDefaultAdminRules, + AccessControl +{ + /** + * @inheritdoc IAccessControlDefaultAdminRules + */ + function defaultAdmin() external view returns (address) { + return _defaultAdmin(); + } + + /** + * @inheritdoc IAccessControlDefaultAdminRules + */ + function pendingDefaultAdmin() + external + view + returns (address newAdmin, uint48 acceptSchedule) + { + return _pendingDefaultAdmin(); + } + + /** + * @inheritdoc IAccessControlDefaultAdminRules + */ + function defaultAdminDelay() external view returns (uint48) { + return _defaultAdminDelay(); + } + + /** + * @inheritdoc IAccessControlDefaultAdminRules + */ + function pendingDefaultAdminDelay() + external + view + returns (uint48 newDelay, uint48 effectSchedule) + { + return _pendingDefaultAdminDelay(); + } + + /** + * @inheritdoc IAccessControlDefaultAdminRules + */ + function beginDefaultAdminTransfer(address newAdmin) external { + _beginDefaultAdminTransfer(newAdmin); + } + + /** + * @inheritdoc IAccessControlDefaultAdminRules + */ + function cancelDefaultAdminTransfer() external { + _cancelDefaultAdminTransfer(); + } + + /** + * @inheritdoc IAccessControlDefaultAdminRules + */ + function acceptDefaultAdminTransfer() external { + _acceptDefaultAdminTransfer(); + } + + /** + * @inheritdoc IAccessControlDefaultAdminRules + */ + function changeDefaultAdminDelay(uint48 newDelay) external { + _changeDefaultAdminDelay(newDelay); + } + + /** + * @inheritdoc IAccessControlDefaultAdminRules + */ + function rollbackDefaultAdminDelay() external { + _rollbackDefaultAdminDelay(); + } + + /** + * @inheritdoc IAccessControlDefaultAdminRules + */ + function defaultAdminDelayIncreaseWait() external view returns (uint48) { + return _defaultAdminDelayIncreaseWait(); + } + + function _grantRole( + bytes32 role, + address account + ) internal override(_AccessControl, _AccessControlDefaultAdminRules) { + super._grantRole(role, account); + } + + function _revokeRole( + bytes32 role, + address account + ) internal override(_AccessControl, _AccessControlDefaultAdminRules) { + super._revokeRole(role, account); + } + + function _setRoleAdmin( + bytes32 role, + bytes32 adminRole + ) internal override(_AccessControl, _AccessControlDefaultAdminRules) { + super._setRoleAdmin(role, adminRole); + } +} diff --git a/contracts/access/access_control/IAccessControlDefaultAdminRules.sol b/contracts/access/access_control/IAccessControlDefaultAdminRules.sol new file mode 100644 index 000000000..cdb9f09b6 --- /dev/null +++ b/contracts/access/access_control/IAccessControlDefaultAdminRules.sol @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.20; + +import { IAccessControl } from './IAccessControl.sol'; +import { _IAccessControlDefaultAdminRules } from './_IAccessControlDefaultAdminRules.sol'; + +/** + * @title AccessControlDefaultAdminRules interface + */ +interface IAccessControlDefaultAdminRules is + _IAccessControlDefaultAdminRules, + IAccessControl +{ + /** + * @notice query default admin + * @return defaultAdmin the default admin + */ + function defaultAdmin() external view returns (address); + + /** + * @notice query pending default admin + * @return newAdmin pending default admin + * @return acceptSchedule acceptance schedule + */ + function pendingDefaultAdmin() + external + view + returns (address newAdmin, uint48 acceptSchedule); + + /** + * @notice query default admin delay + * @return defaultAdminDelay default admin delay + */ + function defaultAdminDelay() external view returns (uint48); + + /** + * @notice query pending default admin delay + * @return newDelay default admin delay + * @return effectSchedule effect schedule + */ + function pendingDefaultAdminDelay() + external + view + returns (uint48 newDelay, uint48 effectSchedule); + + /** + * @notice start a default admin transfer + * @param newAdmin new admin + */ + function beginDefaultAdminTransfer(address newAdmin) external; + + /** + * @notice cancel a default admin transfer + */ + function cancelDefaultAdminTransfer() external; + + /** + * @notice accept a default admin transfer + */ + function acceptDefaultAdminTransfer() external; + + /** + * @notice change a default admin delay + * @param newDelay new delay + */ + function changeDefaultAdminDelay(uint48 newDelay) external; + + /** + * @notice roll back a default admin delay + */ + function rollbackDefaultAdminDelay() external; + + /** + * @notice query default admin delay wait + * @return wait wait + */ + function defaultAdminDelayIncreaseWait() external view returns (uint48); +} diff --git a/contracts/access/access_control/_AccessControlDefaultAdminRules.sol b/contracts/access/access_control/_AccessControlDefaultAdminRules.sol new file mode 100644 index 000000000..7a299f0c9 --- /dev/null +++ b/contracts/access/access_control/_AccessControlDefaultAdminRules.sol @@ -0,0 +1,311 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.20; + +import { Math } from '../../utils/Math.sol'; +import { AccessControlStorage } from '../../storage/AccessControlStorage.sol'; +import { SafeCast } from '../../utils/SafeCast.sol'; +import { _AccessControl } from './_AccessControl.sol'; +import { _IAccessControlDefaultAdminRules } from './_IAccessControlDefaultAdminRules.sol'; + +/** + * @title Role-based access control system with default admin rules + * @dev derived from https://github.com/OpenZeppelin/openzeppelin-contracts (MIT license) + */ +abstract contract _AccessControlDefaultAdminRules is + _IAccessControlDefaultAdminRules, + _AccessControl +{ + /** + * @notice query default admin + * @return defaultAdmin the default admin + */ + function _defaultAdmin() + internal + view + virtual + returns (address defaultAdmin) + { + defaultAdmin = AccessControlStorage + .layout(AccessControlStorage.DEFAULT_STORAGE_SLOT) + .currentDefaultAdmin; + } + + /** + * @notice query pending default admin + * @return newAdmin pending default admin + * @return acceptSchedule acceptance schedule + */ + function _pendingDefaultAdmin() + internal + view + virtual + returns (address newAdmin, uint48 acceptSchedule) + { + AccessControlStorage.Layout storage $ = AccessControlStorage.layout( + AccessControlStorage.DEFAULT_STORAGE_SLOT + ); + + return ($.pendingDefaultAdmin, $.pendingDefaultAdminSchedule); + } + + /** + * @notice query default admin delay + * @return defaultAdminDelay default admin delay + */ + function _defaultAdminDelay() + internal + view + virtual + returns (uint48 defaultAdminDelay) + { + AccessControlStorage.Layout storage $ = AccessControlStorage.layout( + AccessControlStorage.DEFAULT_STORAGE_SLOT + ); + + defaultAdminDelay = (_isScheduleSet($.pendingDelaySchedule) && + _hasSchedulePassed($.pendingDelaySchedule)) + ? $.pendingDelay + : $.currentDelay; + } + + /** + * @notice query pending default admin delay + * @return newDelay default admin delay + * @return effectSchedule effect schedule + */ + function _pendingDefaultAdminDelay() + internal + view + virtual + returns (uint48 newDelay, uint48 effectSchedule) + { + AccessControlStorage.Layout storage $ = AccessControlStorage.layout( + AccessControlStorage.DEFAULT_STORAGE_SLOT + ); + effectSchedule = $.pendingDelaySchedule; + return + (_isScheduleSet(effectSchedule) && + !_hasSchedulePassed(effectSchedule)) + ? ($.pendingDelay, effectSchedule) + : (0, 0); + } + + /** + * @notice assign role to given account + * @param role role to assign + * @param account recipient of role assignment + */ + function _grantRole( + bytes32 role, + address account + ) internal virtual override { + if (role == AccessControlStorage.DEFAULT_ADMIN_ROLE) { + if (_defaultAdmin() != address(0)) { + revert AccessControlEnforcedDefaultAdminRules(); + } + AccessControlStorage + .layout(AccessControlStorage.DEFAULT_STORAGE_SLOT) + .currentDefaultAdmin = account; + } + super._grantRole(role, account); + } + + /** + * @notice unassign role from given account + * @param role role to unassign + * @param account account unassigned + */ + function _revokeRole( + bytes32 role, + address account + ) internal virtual override { + if ( + role == AccessControlStorage.DEFAULT_ADMIN_ROLE && + account == _defaultAdmin() + ) { + delete AccessControlStorage + .layout(AccessControlStorage.DEFAULT_STORAGE_SLOT) + .currentDefaultAdmin; + } + super._revokeRole(role, account); + } + + /** + * @notice set role as admin role + * @param role role to set + * @param adminRole admin role to set + */ + function _setRoleAdmin( + bytes32 role, + bytes32 adminRole + ) internal virtual override { + if (role == AccessControlStorage.DEFAULT_ADMIN_ROLE) { + revert AccessControlEnforcedDefaultAdminRules(); + } + super._setRoleAdmin(role, adminRole); + } + + /** + * @notice start a default admin transfer + * @param newAdmin new admin + */ + function _beginDefaultAdminTransfer( + address newAdmin + ) internal virtual onlyRole(AccessControlStorage.DEFAULT_ADMIN_ROLE) { + uint48 newSchedule = uint48(block.timestamp) + _defaultAdminDelay(); + _setPendingDefaultAdmin(newAdmin, newSchedule); + emit DefaultAdminTransferScheduled(newAdmin, newSchedule); + } + + /** + * @notice cancel a default admin transfer + */ + function _cancelDefaultAdminTransfer() + internal + virtual + onlyRole(AccessControlStorage.DEFAULT_ADMIN_ROLE) + { + _setPendingDefaultAdmin(address(0), 0); + } + + /** + * @notice accept a default admin transfer + */ + function _acceptDefaultAdminTransfer() internal virtual { + AccessControlStorage.Layout storage $ = AccessControlStorage.layout( + AccessControlStorage.DEFAULT_STORAGE_SLOT + ); + (address newAdmin, uint48 schedule) = _pendingDefaultAdmin(); + if (_msgSender() != newAdmin) { + revert AccessControlInvalidDefaultAdmin(_msgSender()); + } + + if (!_isScheduleSet(schedule) || !_hasSchedulePassed(schedule)) { + revert AccessControlEnforcedDefaultAdminDelay(schedule); + } + _revokeRole(AccessControlStorage.DEFAULT_ADMIN_ROLE, _defaultAdmin()); + _grantRole(AccessControlStorage.DEFAULT_ADMIN_ROLE, newAdmin); + delete $.pendingDefaultAdmin; + delete $.pendingDefaultAdminSchedule; + } + + /** + * @notice change a default admin delay + * @param newDelay new delay + */ + function _changeDefaultAdminDelay( + uint48 newDelay + ) internal virtual onlyRole(AccessControlStorage.DEFAULT_ADMIN_ROLE) { + uint48 newSchedule = uint48(block.timestamp) + + _delayChangeWait(newDelay); + _setPendingDelay(newDelay, newSchedule); + emit DefaultAdminDelayChangeScheduled(newDelay, newSchedule); + } + + /** + * @notice roll back a default admin delay + */ + function _rollbackDefaultAdminDelay() + internal + virtual + onlyRole(AccessControlStorage.DEFAULT_ADMIN_ROLE) + { + _setPendingDelay(0, 0); + } + + /** + * @notice query the wait before new delay becomes default admin delay + * @param newDelay new delay + * @return wait wait + */ + function _delayChangeWait( + uint48 newDelay + ) internal view virtual returns (uint48) { + uint48 currentDelay = _defaultAdminDelay(); + return + newDelay > currentDelay + ? uint48(Math.min(newDelay, _defaultAdminDelayIncreaseWait())) + : currentDelay - newDelay; + } + + /** + * @notice set pending default admin + * @param newAdmin new admin + * @param newSchedule new schedule + */ + function _setPendingDefaultAdmin( + address newAdmin, + uint48 newSchedule + ) internal virtual { + AccessControlStorage.Layout storage $ = AccessControlStorage.layout( + AccessControlStorage.DEFAULT_STORAGE_SLOT + ); + (, uint48 oldSchedule) = _pendingDefaultAdmin(); + + $.pendingDefaultAdmin = newAdmin; + $.pendingDefaultAdminSchedule = newSchedule; + + // An `oldSchedule` from `pendingDefaultAdmin()` is only set if it hasn't been accepted. + if (_isScheduleSet(oldSchedule)) { + // Emit for implicit cancellations when another default admin was scheduled. + emit DefaultAdminTransferCanceled(); + } + } + + /** + * @notice set pending delay + * @param newDelay new delay + * @param newSchedule new schedule + */ + function _setPendingDelay( + uint48 newDelay, + uint48 newSchedule + ) internal virtual { + AccessControlStorage.Layout storage $ = AccessControlStorage.layout( + AccessControlStorage.DEFAULT_STORAGE_SLOT + ); + + if (_isScheduleSet($.pendingDelaySchedule)) { + if (_hasSchedulePassed($.pendingDelaySchedule)) { + // Materialize a virtual delay + $.currentDelay = $.pendingDelay; + } else { + // Emit for implicit cancellations when another delay was scheduled. + emit DefaultAdminDelayChangeCanceled(); + } + } + + $.pendingDelay = newDelay; + $.pendingDelaySchedule = newSchedule; + } + + /** + * @notice query default admin delay wait + * @return wait wait + */ + function _defaultAdminDelayIncreaseWait() + internal + pure + virtual + returns (uint48) + { + return 5 days; + } + + /** + * @notice query if schedule is set + * @return isSet is set + */ + function _isScheduleSet(uint48 schedule) private pure returns (bool) { + return schedule != 0; + } + + /** + * @notice query if schedule is passed + * @return isPassed is passed + */ + function _hasSchedulePassed(uint48 schedule) private view returns (bool) { + return schedule < block.timestamp; + } +} diff --git a/contracts/access/access_control/_IAccessControlDefaultAdminRules.sol b/contracts/access/access_control/_IAccessControlDefaultAdminRules.sol new file mode 100644 index 000000000..da14741eb --- /dev/null +++ b/contracts/access/access_control/_IAccessControlDefaultAdminRules.sol @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.20; + +import { _IAccessControl } from './_IAccessControl.sol'; + +/** + * @title Partial IAccessControlDefaultAdminRules interface needed by internal functions + */ +interface _IAccessControlDefaultAdminRules is _IAccessControl { + error AccessControlInvalidDefaultAdmin(address defaultAdmin); + + error AccessControlEnforcedDefaultAdminRules(); + + error AccessControlEnforcedDefaultAdminDelay(uint48 schedule); + + event DefaultAdminTransferScheduled( + address indexed newAdmin, + uint48 acceptSchedule + ); + + event DefaultAdminTransferCanceled(); + + event DefaultAdminDelayChangeScheduled( + uint48 newDelay, + uint48 effectSchedule + ); + + event DefaultAdminDelayChangeCanceled(); +} diff --git a/contracts/storage/AccessControlStorage.sol b/contracts/storage/AccessControlStorage.sol index dc169ac3d..26b6ace7d 100644 --- a/contracts/storage/AccessControlStorage.sol +++ b/contracts/storage/AccessControlStorage.sol @@ -15,6 +15,12 @@ library AccessControlStorage { */ struct Layout { mapping(bytes32 roleId => RoleData roleData) roles; + address pendingDefaultAdmin; + uint48 pendingDefaultAdminSchedule; + uint48 currentDelay; + address currentDefaultAdmin; + uint48 pendingDelay; + uint48 pendingDelaySchedule; } bytes32 internal constant DEFAULT_ADMIN_ROLE = 0x00; diff --git a/test/access/AccessControlDefaultAdminRules.ts b/test/access/AccessControlDefaultAdminRules.ts new file mode 100644 index 000000000..1e65b5725 --- /dev/null +++ b/test/access/AccessControlDefaultAdminRules.ts @@ -0,0 +1,211 @@ +import { SignerWithAddress } from '@nomicfoundation/hardhat-ethers/signers'; +import { + $AccessControlDefaultAdminRules, + $AccessControlDefaultAdminRules__factory, +} from '@solidstate/typechain-types'; +import { expect } from 'chai'; +import { ethers } from 'hardhat'; + +const DEFAULT_ADMIN_ROLE = ethers.ZeroHash; + +describe('AccessControlDefaultAdminRules', () => { + let defaultAdmin: SignerWithAddress; + let nonAdmin: SignerWithAddress; + let nonAdmin2: SignerWithAddress; + let nonAdmin3: SignerWithAddress; + let instance: $AccessControlDefaultAdminRules; + const oneDayInSeconds = 86400; + + before(async () => { + [defaultAdmin, nonAdmin, nonAdmin2, nonAdmin3] = await ethers.getSigners(); + }); + + beforeEach(async () => { + instance = await new $AccessControlDefaultAdminRules__factory( + defaultAdmin, + ).deploy(); + + await instance.$_setRole( + DEFAULT_ADMIN_ROLE, + await defaultAdmin.getAddress(), + true, + ); + }); + + describe('#grantRole(bytes32,address)', () => { + it('allows the default admin to grant roles', async () => { + await instance + .connect(defaultAdmin) + .grantRole(ethers.id('ROLE'), nonAdmin.address); + expect( + await instance.hasRole(ethers.id('ROLE'), nonAdmin.address), + ).to.equal(true); + }); + + describe('reverts if', () => { + it('sender is not default admin', async () => { + await expect( + instance + .connect(nonAdmin) + .grantRole(ethers.id('ROLE'), nonAdmin.address), + ) + .to.be.revertedWithCustomError( + instance, + 'AccessControl__Unauthorized', + ) + .withArgs(DEFAULT_ADMIN_ROLE, await nonAdmin.getAddress()); + }); + }); + }); + + describe('#revokeRole(bytes32,address)', () => { + it('allows the default admin to revoke roles', async () => { + await instance + .connect(defaultAdmin) + .grantRole(ethers.id('ROLE'), nonAdmin.address); + await instance + .connect(defaultAdmin) + .revokeRole(ethers.id('ROLE'), nonAdmin.address); + expect( + await instance.hasRole(ethers.id('ROLE'), nonAdmin.address), + ).to.equal(false); + }); + + describe('reverts if', () => { + it('sender is not default admin', async () => { + await expect( + instance + .connect(nonAdmin) + .grantRole(ethers.id('ROLE'), nonAdmin.address), + ) + .to.be.revertedWithCustomError( + instance, + 'AccessControl__Unauthorized', + ) + .withArgs(DEFAULT_ADMIN_ROLE, await nonAdmin.getAddress()); + }); + }); + }); + + describe('#beginDefaultAdminTransfer(address,uint256)', () => { + it('starts the transfer process', async () => { + await instance.beginDefaultAdminTransfer(nonAdmin.address); + + const transfer = await instance.pendingDefaultAdmin(); + expect(transfer.newAdmin).to.equal(nonAdmin.address); + expect(transfer.acceptSchedule).to.be.gt(0); // Ensure end time is set + }); + + describe('reverts if', () => { + it('sender is not default admin', async () => { + await expect( + instance + .connect(nonAdmin) + .beginDefaultAdminTransfer(nonAdmin2.address), + ) + .to.be.revertedWithCustomError( + instance, + 'AccessControl__Unauthorized', + ) + .withArgs(DEFAULT_ADMIN_ROLE, await nonAdmin.getAddress()); + }); + }); + }); + + describe('#cancelDefaultAdminTransfer()', () => { + it('cancels the transfer process', async () => { + await instance.beginDefaultAdminTransfer(nonAdmin.address); + await instance.cancelDefaultAdminTransfer(); + + const transfer = await instance.pendingDefaultAdmin(); + expect(transfer.newAdmin).to.equal(ethers.ZeroAddress); + expect(transfer.acceptSchedule).to.equal(0); + }); + + describe('reverts if', () => { + it('sender is not current or pending default admin', async () => { + await instance.beginDefaultAdminTransfer(nonAdmin.address); + + await expect(instance.connect(nonAdmin2).cancelDefaultAdminTransfer()) + .to.be.revertedWithCustomError( + instance, + 'AccessControl__Unauthorized', + ) + .withArgs(DEFAULT_ADMIN_ROLE, await nonAdmin2.getAddress()); + }); + }); + }); + + describe('#acceptDefaultAdminTransfer()', () => { + it('completes the transfer process', async () => { + await instance.changeDefaultAdminDelay(oneDayInSeconds); + await ethers.provider.send('evm_increaseTime', [oneDayInSeconds]); + await ethers.provider.send('evm_mine', []); + await instance.beginDefaultAdminTransfer(nonAdmin.address); + await ethers.provider.send('evm_increaseTime', [oneDayInSeconds]); + await ethers.provider.send('evm_mine', []); + await instance.connect(nonAdmin).acceptDefaultAdminTransfer(); + + expect(await instance.defaultAdmin()).to.equal(nonAdmin.address); + }); + + describe('reverts if', () => { + it('transfer delay has not passed', async () => { + await instance.changeDefaultAdminDelay(oneDayInSeconds); + await ethers.provider.send('evm_increaseTime', [oneDayInSeconds]); + await ethers.provider.send('evm_mine', []); + + await instance.beginDefaultAdminTransfer(nonAdmin.address); + await expect( + instance.connect(nonAdmin).acceptDefaultAdminTransfer(), + ).to.be.revertedWithCustomError( + instance, + 'AccessControlEnforcedDefaultAdminDelay', + ); + }); + }); + }); + + describe('#changeDefaultAdminDelay(uint256)', () => { + it('changes the default admin delay', async () => { + const newDelay = oneDayInSeconds; + await instance.changeDefaultAdminDelay(newDelay); + await ethers.provider.send('evm_increaseTime', [oneDayInSeconds + 1]); // We wait for the newDelay to be set as the new delay + await ethers.provider.send('evm_mine', []); + + expect(await instance.defaultAdminDelay()).to.equal(newDelay); + }); + + describe('reverts if', () => { + it('sender is not default admin', async () => { + await expect(instance.connect(nonAdmin).changeDefaultAdminDelay(2000)) + .to.be.revertedWithCustomError( + instance, + 'AccessControl__Unauthorized', + ) + .withArgs(DEFAULT_ADMIN_ROLE, await nonAdmin.getAddress()); + }); + }); + }); + + describe('#rollbackDefaultAdminDelay()', () => { + it('rolls back the default admin delay to previous value', async () => { + const initialDelay = await instance.defaultAdminDelay(); + await instance.changeDefaultAdminDelay(2000); + await instance.rollbackDefaultAdminDelay(); + + expect(await instance.defaultAdminDelay()).to.equal(initialDelay); + }); + + describe('reverts if', () => { + it('sender is not default admin', async () => { + await expect(instance.connect(nonAdmin).rollbackDefaultAdminDelay()) + .to.be.revertedWithCustomError( + instance, + 'AccessControl__Unauthorized', + ) + .withArgs(DEFAULT_ADMIN_ROLE, await nonAdmin.getAddress()); + }); + }); + }); +});