瀏覽代碼

Access Manager (#4416)

Francisco Giordano 2 年之前
父節點
當前提交
bf5786aae0

+ 99 - 0
contracts/access/manager/AccessManaged.sol

@@ -0,0 +1,99 @@
+// SPDX-License-Identifier: MIT
+
+pragma solidity ^0.8.20;
+
+import {IAuthority, safeCanCall} from "./IAuthority.sol";
+import {IManaged} from "./IManaged.sol";
+import {Context} from "../../utils/Context.sol";
+
+/**
+ * @dev This contract module makes available a {restricted} modifier. Functions decorated with this modifier will be
+ * permissioned according to an "authority": a contract like {AccessManager} that follows the {IAuthority} interface,
+ * implementing a policy that allows certain callers to access certain functions.
+ *
+ * IMPORTANT: The `restricted` modifier should never be used on `internal` functions, judiciously used in `public`
+ * functions, and ideally only used in `external` functions. See {restricted}.
+ */
+abstract contract AccessManaged is Context, IManaged {
+    address private _authority;
+
+    /**
+     * @dev Initializes the contract connected to an initial authority.
+     */
+    constructor(address initialAuthority) {
+        _setAuthority(initialAuthority);
+    }
+
+    /**
+     * @dev Restricts access to a function as defined by the connected Authority for this contract and the
+     * caller and selector of the function that entered the contract.
+     *
+     * [IMPORTANT]
+     * ====
+     * In general, this modifier should only be used on `external` functions. It is okay to use it on `public` functions
+     * that are used as external entry points and are not called internally. Unless you know what you're doing, it
+     * should never be used on `internal` functions. Failure to follow these rules can have critical security
+     * implications! This is because the permissions are determined by the function that entered the contract, i.e. the
+     * function at the bottom of the call stack, and not the function where the modifier is visible in the source code.
+     * ====
+     *
+     * [NOTE]
+     * ====
+     * Selector collisions are mitigated by scoping permissions per contract, but some edge cases must be considered:
+     *
+     * * If the https://docs.soliditylang.org/en/latest/contracts.html#receive-ether-function[`receive()`] function is restricted,
+     * any other function with a `0x00000000` selector will share permissions with `receive()`.
+     * * Similarly, if there's no `receive()` function but a `fallback()` instead, the fallback might be called with empty `calldata`,
+     * sharing the `0x00000000` selector permissions as well.
+     * * For any other selector, if the restricted function is set on an upgradeable contract, an upgrade may remove the restricted
+     * function and replace it with a new method whose selector replaces the last one, keeping the previous permissions.
+     * ====
+     */
+    modifier restricted() {
+        _checkCanCall(_msgSender(), address(this), msg.sig);
+        _;
+    }
+
+    /**
+     * @dev Returns the current authority.
+     */
+    function authority() public view virtual returns (address) {
+        return _authority;
+    }
+
+    /**
+     * @dev Transfers control to a new authority. The caller must be the current authority.
+     */
+    function setAuthority(address newAuthority) public virtual {
+        address caller = _msgSender();
+        if (caller != authority()) {
+            revert AccessManagedUnauthorized(caller);
+        }
+        if (newAuthority.code.length == 0) {
+            revert AccessManagedInvalidAuthority(newAuthority);
+        }
+        _setAuthority(newAuthority);
+    }
+
+    /**
+     * @dev Transfers control to a new authority. Internal function with no access restriction.
+     */
+    function _setAuthority(address newAuthority) internal virtual {
+        _authority = newAuthority;
+        emit AuthorityUpdated(newAuthority);
+    }
+
+    /**
+     * @dev Reverts if the caller is not allowed to call the function identified by a selector.
+     */
+    function _checkCanCall(address caller, address target, bytes4 selector) internal view virtual {
+        (bool allowed, uint32 delay) = safeCanCall(authority(), caller, target, selector);
+        if (!allowed) {
+            if (delay > 0) {
+                revert AccessManagedRequiredDelay(caller, delay);
+            } else {
+                revert AccessManagedUnauthorized(caller);
+            }
+        }
+    }
+}

+ 616 - 0
contracts/access/manager/AccessManager.sol

@@ -0,0 +1,616 @@
+// SPDX-License-Identifier: MIT
+
+pragma solidity ^0.8.20;
+
+import {IAccessManager} from "./IAccessManager.sol";
+import {IManaged} from "./IManaged.sol";
+import {IAuthority} from "./IAuthority.sol";
+import {AccessManagedAdapter} from "./utils/AccessManagedAdapter.sol";
+import {Address} from "../../utils/Address.sol";
+import {Context} from "../../utils/Context.sol";
+import {Multicall} from "../../utils/Multicall.sol";
+import {Time} from "../../utils/types/Time.sol";
+
+/**
+ * @dev AccessManager is a central contract to store the permissions of a system.
+ *
+ * The smart contracts under the control of an AccessManager instance will have a set of "restricted" functions, and the
+ * exact details of how access is restricted for each of those functions is configurable by the admins of the instance.
+ * These restrictions are expressed in terms of "groups".
+ *
+ * An AccessManager instance will define a set of groups. Accounts can be added into any number of these groups. Each of
+ * them defines a role, and may confer access to some of the restricted functions in the system, as configured by admins
+ * through the use of {setFunctionAllowedGroup}.
+ *
+ * Note that a function in a target contract may become permissioned in this way only when: 1) said contract is
+ * {AccessManaged} and is connected to this contract as its manager, and 2) said function is decorated with the
+ * `restricted` modifier.
+ *
+ * There is a special group defined by default named "public" which all accounts automatically have.
+ *
+ * Contracts where functions are mapped to groups are said to be in a "custom" mode, but contracts can also be
+ * configured in two special modes: 1) the "open" mode, where all functions are allowed to the "public" group, and 2)
+ * the "closed" mode, where no function is allowed to any group.
+ *
+ * Since all the permissions of the managed system can be modified by the admins of this instance, it is expected that
+ * they will be highly secured (e.g., a multisig or a well-configured DAO).
+ *
+ * NOTE: This contract implements a form of the {IAuthority} interface, but {canCall} has additional return data so it
+ * doesn't inherit `IAuthority`. It is however compatible with the `IAuthority` interface since the first 32 bytes of
+ * the return data are a boolean as expected by that interface.
+ */
+contract AccessManager is Context, Multicall, IAccessManager {
+    using Time for *;
+
+    uint256 public constant ADMIN_GROUP = type(uint256).min; // 0
+    uint256 public constant PUBLIC_GROUP = type(uint256).max; // 2**256-1
+
+    mapping(address target => AccessMode mode) private _contractMode;
+    mapping(address target => mapping(bytes4 selector => uint256 groupId)) private _allowedGroups;
+    mapping(uint256 groupId => Group) private _groups;
+    mapping(bytes32 operationId => uint48 schedule) private _schedules;
+
+    // This should be transcient storage when supported by the EVM.
+    bytes32 private _relayIdentifier;
+
+    /**
+     * @dev Check that the caller has a given permission level (`groupId`). Note that this does NOT consider execution
+     * delays that may be associated to that group.
+     */
+    modifier onlyGroup(uint256 groupId) {
+        address msgsender = _msgSender();
+        if (!hasGroup(groupId, msgsender)) {
+            revert AccessControlUnauthorizedAccount(msgsender, groupId);
+        }
+        _;
+    }
+
+    constructor(address initialAdmin) {
+        // admin is active immediately and without any execution delay.
+        _grantGroup(ADMIN_GROUP, initialAdmin, 0, 0);
+    }
+
+    // =================================================== GETTERS ====================================================
+    /**
+     * @dev Check if an address (`caller`) is authorised to call a given function on a given contract directly (with
+     * no restriction). Additionally, it returns the delay needed to perform the call indirectly through the {schedule}
+     * & {relay} workflow.
+     *
+     * This function is usually called by the targeted contract to control immediate execution of restricted functions.
+     * Therefore we only return true is the call can be performed without any delay. If the call is subject to a delay,
+     * then the function should return false, and the caller should schedule the operation for future execution.
+     *
+     * We may be able to hash the operation, and check if the call was scheduled, but we would not be able to cleanup
+     * the schedule, leaving the possibility of multiple executions. Maybe this function should not be view?
+     *
+     * NOTE: The IAuthority interface does not include the `uint32` delay. This is an extension of that interface that
+     * is backward compatible. Some contract may thus ignore the second return argument. In that case they will fail
+     * to identify the indirect workflow, and will consider call that require a delay to be forbidden.
+     */
+    function canCall(address caller, address target, bytes4 selector) public view virtual returns (bool, uint32) {
+        AccessMode mode = getContractMode(target);
+        if (mode == AccessMode.Open) {
+            return (true, 0);
+        } else if (mode == AccessMode.Closed) {
+            return (false, 0);
+        } else if (caller == address(this)) {
+            // Caller is AccessManager => call was relayed. In that case the relay already checked permissions. We
+            // verify that the call "identifier", which is set during the relay call, is correct.
+            return (_relayIdentifier == keccak256(abi.encodePacked(target, selector)), 0);
+        } else {
+            uint256 groupId = getFunctionAllowedGroup(target, selector);
+            bool inGroup = hasGroup(groupId, caller);
+            uint32 executeDelay = inGroup ? getAccess(groupId, caller).delay.get() : 0;
+            return (inGroup && executeDelay == 0, executeDelay);
+        }
+    }
+
+    /**
+     * @dev Get the mode under which a contract is operating.
+     */
+    function getContractMode(address target) public view virtual returns (AccessMode) {
+        return _contractMode[target];
+    }
+
+    /**
+     * @dev Get the permission level (group) required to call a function. This only applies for contract that are
+     * operating under the `Custom` mode.
+     */
+    function getFunctionAllowedGroup(address target, bytes4 selector) public view virtual returns (uint256) {
+        return _allowedGroups[target][selector];
+    }
+
+    /**
+     * @dev Get the id of the group that acts as an admin for given group.
+     *
+     * The admin permission is required to grant the group, revoke the group and update the execution delay to execute
+     * an operation that is restricted to this group.
+     */
+    function getGroupAdmin(uint256 groupId) public view virtual returns (uint256) {
+        return _groups[groupId].admin;
+    }
+
+    /**
+     * @dev Get the group that acts as a guardian for a given group.
+     *
+     * The guardian permission allows canceling operations that have been scheduled under the group.
+     */
+    function getGroupGuardian(uint256 groupId) public view virtual returns (uint256) {
+        return _groups[groupId].guardian;
+    }
+
+    /**
+     * @dev Get the group current grant delay, that value may change at any point, without an event emitted, following
+     * a call to {setGrantDelay}. Changes to this value, including effect timepoint are notified by the
+     * {GroupGrantDelayChanged} event.
+     */
+    function getGroupGrantDelay(uint256 groupId) public view virtual returns (uint32) {
+        return _groups[groupId].delay.get();
+    }
+
+    /**
+     * @dev Get the access details for a given account in a given group. These details include the timepoint at which
+     * membership becomes active, and the delay applied to all operation by this user that require this permission
+     * level.
+     */
+    function getAccess(uint256 groupId, address account) public view virtual returns (Access memory) {
+        return _groups[groupId].members[account];
+    }
+
+    /**
+     * @dev Check if a given account currently had the permission level corresponding to a given group. Note that this
+     * permission might be associated with a delay. {getAccess} can provide more details.
+     */
+    function hasGroup(uint256 groupId, address account) public view virtual returns (bool) {
+        return groupId == PUBLIC_GROUP || getAccess(groupId, account).since.isSetAndPast(Time.timestamp());
+    }
+
+    // =============================================== GROUP MANAGEMENT ===============================================
+    /**
+     * @dev Give a label to a group, for improved group discoverabily by UIs.
+     *
+     * Emits a {GroupLabel} event.
+     */
+    function labelGroup(uint256 groupId, string calldata label) public virtual onlyGroup(ADMIN_GROUP) {
+        emit GroupLabel(groupId, label);
+    }
+
+    /**
+     * @dev Give permission to an account to execute function restricted to a group. Optionally, a delay can be
+     * enforced for any function call, byt this user, that require this level of permission. This call is only
+     * effective after a grant delay that is specific to the group being granted.
+     *
+     * Requirements:
+     *
+     * - the caller must be in the group's admins
+     *
+     * Emits a {GroupGranted} event
+     */
+    function grantGroup(
+        uint256 groupId,
+        address account,
+        uint32 executionDelay
+    ) public virtual onlyGroup(getGroupAdmin(groupId)) {
+        _grantGroup(groupId, account, getGroupGrantDelay(groupId), executionDelay);
+    }
+
+    /**
+     * @dev Remove an account for a group, with immediate effect.
+     *
+     * Requirements:
+     *
+     * - the caller must be in the group's admins
+     *
+     * Emits a {GroupRevoked} event
+     */
+    function revokeGroup(uint256 groupId, address account) public virtual onlyGroup(getGroupAdmin(groupId)) {
+        _revokeGroup(groupId, account);
+    }
+
+    /**
+     * @dev Renounce group permissions for the calling account, with immediate effect.
+     *
+     * Requirements:
+     *
+     * - the caller must be `callerConfirmation`.
+     *
+     * Emits a {GroupRevoked} event
+     */
+    function renounceGroup(uint256 groupId, address callerConfirmation) public virtual {
+        if (callerConfirmation != _msgSender()) {
+            revert AccessManagerBadConfirmation();
+        }
+        _revokeGroup(groupId, callerConfirmation);
+    }
+
+    /**
+     * @dev Set the execution delay for a given account in a given group. This update is not immediate and follows the
+     * delay rules. For example, If a user currently has a delay of 3 hours, and this is called to reduce that delay to
+     * 1 hour, the new delay will take some time to take effect, enforcing that any operation executed in the 3 hours
+     * that follows this update was indeed scheduled before this update.
+     *
+     * Requirements:
+     *
+     * - the caller must be in the group's admins
+     *
+     * Emits a {GroupExecutionDelayUpdate} event
+     */
+    function setExecuteDelay(
+        uint256 groupId,
+        address account,
+        uint32 newDelay
+    ) public virtual onlyGroup(getGroupAdmin(groupId)) {
+        _setExecuteDelay(groupId, account, newDelay);
+    }
+
+    /**
+     * @dev Change admin group for a given group.
+     *
+     * Requirements:
+     *
+     * - the caller must be a global admin
+     *
+     * Emits a {GroupAdminChanged} event
+     */
+    function setGroupAdmin(uint256 groupId, uint256 admin) public virtual onlyGroup(ADMIN_GROUP) {
+        _setGroupAdmin(groupId, admin);
+    }
+
+    /**
+     * @dev Change guardian group for a given group.
+     *
+     * Requirements:
+     *
+     * - the caller must be a global admin
+     *
+     * Emits a {GroupGuardianChanged} event
+     */
+    function setGroupGuardian(uint256 groupId, uint256 guardian) public virtual onlyGroup(ADMIN_GROUP) {
+        _setGroupGuardian(groupId, guardian);
+    }
+
+    /**
+     * @dev Update the .
+     *
+     * Requirements:
+     *
+     * - the caller must be a global admin
+     *
+     * Emits a {GroupGrantDelayChanged} event
+     */
+    function setGrantDelay(uint256 groupId, uint32 newDelay) public virtual onlyGroup(ADMIN_GROUP) {
+        _setGrantDelay(groupId, newDelay);
+    }
+
+    /**
+     * @dev Internal version of {grantGroup} without access control.
+     *
+     * Emits a {GroupGranted} event
+     */
+    function _grantGroup(uint256 groupId, address account, uint32 grantDelay, uint32 executionDelay) internal virtual {
+        if (groupId == PUBLIC_GROUP) {
+            revert AccessManagerLockedGroup(groupId);
+        } else if (_groups[groupId].members[account].since != 0) {
+            revert AccessManagerAcountAlreadyInGroup(groupId, account);
+        }
+
+        uint48 since = Time.timestamp() + grantDelay;
+        _groups[groupId].members[account] = Access({since: since, delay: executionDelay.toDelay()});
+
+        emit GroupGranted(groupId, account, since, executionDelay);
+    }
+
+    /**
+     * @dev Internal version of {revokeGroup} without access control. This logic is also used by {renounceGroup}.
+     *
+     * Emits a {GroupRevoked} event
+     */
+    function _revokeGroup(uint256 groupId, address account) internal virtual {
+        if (groupId == PUBLIC_GROUP) {
+            revert AccessManagerLockedGroup(groupId);
+        } else if (_groups[groupId].members[account].since == 0) {
+            revert AccessManagerAcountNotInGroup(groupId, account);
+        }
+
+        delete _groups[groupId].members[account];
+
+        emit GroupRevoked(groupId, account);
+    }
+
+    /**
+     * @dev Internal version of {setExecuteDelay} without access control.
+     *
+     * Emits a {GroupExecutionDelayUpdate} event
+     */
+    function _setExecuteDelay(uint256 groupId, address account, uint32 newDuration) internal virtual {
+        if (groupId == PUBLIC_GROUP) {
+            revert AccessManagerLockedGroup(groupId);
+        } else if (_groups[groupId].members[account].since == 0) {
+            revert AccessManagerAcountNotInGroup(groupId, account);
+        }
+
+        Time.Delay newDelay = _groups[groupId].members[account].delay.update(newDuration, 0); // TODO: minsetback ?
+        _groups[groupId].members[account].delay = newDelay;
+
+        (, , uint48 effectPoint) = newDelay.split();
+        emit GroupExecutionDelayUpdate(groupId, account, newDuration, effectPoint);
+    }
+
+    /**
+     * @dev Internal version of {setGroupAdmin} without access control.
+     *
+     * Emits a {GroupAdminChanged} event
+     */
+    function _setGroupAdmin(uint256 groupId, uint256 admin) internal virtual {
+        if (groupId == ADMIN_GROUP || groupId == PUBLIC_GROUP) {
+            revert AccessManagerLockedGroup(groupId);
+        }
+
+        _groups[groupId].admin = admin;
+
+        emit GroupAdminChanged(groupId, admin);
+    }
+
+    /**
+     * @dev Internal version of {setGroupGuardian} without access control.
+     *
+     * Emits a {GroupGuardianChanged} event
+     */
+    function _setGroupGuardian(uint256 groupId, uint256 guardian) internal virtual {
+        if (groupId == ADMIN_GROUP || groupId == PUBLIC_GROUP) {
+            revert AccessManagerLockedGroup(groupId);
+        }
+
+        _groups[groupId].guardian = guardian;
+
+        emit GroupGuardianChanged(groupId, guardian);
+    }
+
+    /**
+     * @dev Internal version of {setGrantDelay} without access control.
+     *
+     * Emits a {GroupGrantDelayChanged} event
+     */
+    function _setGrantDelay(uint256 groupId, uint32 newDelay) internal virtual {
+        if (groupId == PUBLIC_GROUP) {
+            revert AccessManagerLockedGroup(groupId);
+        }
+
+        Time.Delay updated = _groups[groupId].delay.update(newDelay, 0); // TODO: minsetback ?
+        _groups[groupId].delay = updated;
+
+        (, , uint48 effect) = updated.split();
+        emit GroupGrantDelayChanged(groupId, newDelay, effect);
+    }
+
+    // ============================================= FUNCTION MANAGEMENT ==============================================
+    /**
+     * @dev Set the level of permission (`group`) required to call functions identified by the `selectors` in the
+     * `target` contract.
+     *
+     * Requirements:
+     *
+     * - the caller must be a global admin
+     *
+     * Emits a {FunctionAllowedGroupUpdated} event per selector
+     */
+    function setFunctionAllowedGroup(
+        address target,
+        bytes4[] calldata selectors,
+        uint256 groupId
+    ) public virtual onlyGroup(ADMIN_GROUP) {
+        // todo set delay or document risks
+        for (uint256 i = 0; i < selectors.length; ++i) {
+            _setFunctionAllowedGroup(target, selectors[i], groupId);
+        }
+    }
+
+    /**
+     * @dev Internal version of {setFunctionAllowedGroup} without access control.
+     *
+     * Emits a {FunctionAllowedGroupUpdated} event
+     */
+    function _setFunctionAllowedGroup(address target, bytes4 selector, uint256 groupId) internal virtual {
+        _allowedGroups[target][selector] = groupId;
+        emit FunctionAllowedGroupUpdated(target, selector, groupId);
+    }
+
+    // =============================================== MODE MANAGEMENT ================================================
+    /**
+     * @dev Set the operating mode of a contract to Custom. This enables the group mechanism for per-function access
+     * restriction and delay enforcement.
+     *
+     * Requirements:
+     *
+     * - the caller must be a global admin
+     *
+     * Emits a {AccessModeUpdated} event.
+     */
+    function setContractModeCustom(address target) public virtual onlyGroup(ADMIN_GROUP) {
+        // todo set delay or document risks
+        _setContractMode(target, AccessMode.Custom);
+    }
+
+    /**
+     * @dev Set the operating mode of a contract to Open. This allows anyone to call any `restricted()` function with
+     * no delay.
+     *
+     * Requirements:
+     *
+     * - the caller must be a global admin
+     *
+     * Emits a {AccessModeUpdated} event.
+     */
+    function setContractModeOpen(address target) public virtual onlyGroup(ADMIN_GROUP) {
+        // todo set delay or document risks
+        _setContractMode(target, AccessMode.Open);
+    }
+
+    /**
+     * @dev Set the operating mode of a contract to Close. This prevents anyone from calling any `restricted()`
+     * function.
+     *
+     * Requirements:
+     *
+     * - the caller must be a global admin
+     *
+     * Emits a {AccessModeUpdated} event.
+     */
+    function setContractModeClosed(address target) public virtual onlyGroup(ADMIN_GROUP) {
+        // todo set delay or document risks
+        _setContractMode(target, AccessMode.Closed);
+    }
+
+    /**
+     * @dev Set the operating mode of a contract. This is an internal setter with no access restrictions.
+     *
+     * Emits a {AccessModeUpdated} event.
+     */
+    function _setContractMode(address target, AccessMode mode) internal virtual {
+        _contractMode[target] = mode;
+        emit AccessModeUpdated(target, mode);
+    }
+
+    // ============================================== DELAYED OPERATIONS ==============================================
+    /**
+     * @dev Return the timepoint at which a scheduled operation will be ready for execution. This returns 0 if the
+     * operation is not yet scheduled, was executed or was canceled.
+     */
+    function getSchedule(bytes32 id) public view virtual returns (uint48) {
+        return _schedules[id];
+    }
+
+    /**
+     * @dev Schedule a delayed operation, and return the operation identifier.
+     *
+     * Emits a {Scheduled} event.
+     */
+    function schedule(address target, bytes calldata data) public virtual returns (bytes32) {
+        address caller = _msgSender();
+        bytes4 selector = bytes4(data[0:4]);
+
+        // Fetch restriction to that apply to the caller on the targeted function
+        (bool allowed, uint32 setback) = canCall(caller, target, selector);
+
+        // If caller is not authorised, revert
+        if (!allowed && setback == 0) {
+            revert AccessManagerUnauthorizedCall(caller, target, selector);
+        }
+
+        // If caller is authorised, schedule operation
+        bytes32 operationId = _hashOperation(caller, target, data);
+        if (_schedules[operationId] != 0) {
+            revert AccessManagerAlreadyScheduled(operationId);
+        }
+        _schedules[operationId] = Time.timestamp() + setback;
+
+        emit Scheduled(operationId, caller, target, data);
+        return operationId;
+    }
+
+    /**
+     * @dev Execute a function that is delay restricted, provided it was properly scheduled beforehand, or the
+     * execution delay is 0.
+     *
+     * Emits a {Executed} event if the call was scheduled. Unscheduled call (with no delay) do not emit that event.
+     */
+    function relay(address target, bytes calldata data) public payable virtual {
+        relayViaAdapter(target, data, address(0));
+    }
+
+    /**
+     * @dev Execute a function that is delay restricted in the same way as {relay} but through an
+     * {AccessManagedAdapter}.
+     */
+    function relayViaAdapter(address target, bytes calldata data, address adapter) public payable virtual {
+        address caller = _msgSender();
+        bytes4 selector = bytes4(data[0:4]);
+
+        // Fetch restriction to that apply to the caller on the targeted function
+        (bool allowed, uint32 setback) = canCall(caller, target, selector);
+
+        // If caller is not authorised, revert
+        if (!allowed && setback == 0) {
+            revert AccessManagerUnauthorizedCall(caller, target, selector);
+        }
+
+        // If caller is authorised, check operation was scheduled early enough
+        bytes32 operationId = _hashOperation(caller, target, data);
+        uint48 timepoint = _schedules[operationId];
+        if (setback != 0) {
+            if (timepoint == 0) {
+                revert AccessManagerNotScheduled(operationId);
+            } else if (timepoint > Time.timestamp()) {
+                revert AccessManagerNotReady(operationId);
+            }
+        }
+        if (timepoint != 0) {
+            delete _schedules[operationId];
+            emit Executed(operationId);
+        }
+
+        // Mark the target and selector as authorised
+        bytes32 relayIdentifierBefore = _relayIdentifier;
+        _relayIdentifier = keccak256(abi.encodePacked(target, selector));
+
+        if (adapter != address(0)) {
+            // Perform call through adapter
+            AccessManagedAdapter(adapter).relay{value: msg.value}(target, data);
+        } else {
+            // Perform call directly
+            Address.functionCallWithValue(target, data, msg.value);
+        }
+
+        // Reset relay identifier
+        _relayIdentifier = relayIdentifierBefore;
+    }
+
+    /**
+     * @dev Cancel a scheduled (delayed) operation.
+     *
+     * Requirements:
+     *
+     * - the caller must be the proposer, or a guardian of the targeted function
+     *
+     * Emits a {Canceled} event.
+     */
+    function cancel(address caller, address target, bytes calldata data) public virtual {
+        address msgsender = _msgSender();
+        bytes4 selector = bytes4(data[0:4]);
+
+        bytes32 operationId = _hashOperation(caller, target, data);
+        if (_schedules[operationId] == 0) {
+            revert AccessManagerNotScheduled(operationId);
+        } else if (
+            caller != msgsender &&
+            !hasGroup(ADMIN_GROUP, msgsender) &&
+            !hasGroup(getGroupGuardian(getFunctionAllowedGroup(target, selector)), msgsender)
+        ) {
+            // calls can only be canceled by the account that scheduled them, a global admin, or by a guardian of the required group.
+            revert AccessManagerCannotCancel(msgsender, caller, target, selector);
+        }
+
+        delete _schedules[operationId];
+        emit Canceled(operationId);
+    }
+
+    /**
+     * @dev Hashing function for delayed operations
+     */
+    function _hashOperation(address caller, address target, bytes calldata data) private pure returns (bytes32) {
+        return keccak256(abi.encode(caller, target, data));
+    }
+
+    // ==================================================== OTHERS ====================================================
+    /**
+     * @dev Change the AccessManager instance used by a contract that correctly uses this instance.
+     *
+     * Requirements:
+     *
+     * - the caller must be a global admin
+     */
+    function updateAuthority(IManaged target, address newAuthority) public virtual onlyGroup(ADMIN_GROUP) {
+        // todo set delay or document risks
+        target.setAuthority(newAuthority);
+    }
+}

+ 120 - 0
contracts/access/manager/IAccessManager.sol

@@ -0,0 +1,120 @@
+// SPDX-License-Identifier: MIT
+
+pragma solidity ^0.8.20;
+
+import {IManaged} from "./IManaged.sol";
+import {Time} from "../../utils/types/Time.sol";
+
+interface IAccessManager {
+    enum AccessMode {
+        Custom,
+        Closed,
+        Open
+    }
+
+    // Structure that stores the details for a group/account pair. This structures fit into a single slot.
+    struct Access {
+        // Timepoint at which the user gets the permission. If this is either 0, or in the future, the group permission
+        // are not available. Should be checked using {Time-isSetAndPast}
+        uint48 since;
+        // delay for execution. Only applies to restricted() / relay() calls. This does not restrict access to
+        // functions that use the `onlyGroup` modifier.
+        Time.Delay delay;
+    }
+
+    // Structure that stores the details of a group, including:
+    // - the members of the group
+    // - the admin group (that can grant or revoke permissions)
+    // - the guardian group (that can cancel operations targeting functions that need this group
+    // - the grand delay
+    struct Group {
+        mapping(address user => Access access) members;
+        uint256 admin;
+        uint256 guardian;
+        Time.Delay delay; // delay for granting
+    }
+
+    /**
+     * @dev A delay operation was schedule.
+     */
+    event Scheduled(bytes32 operationId, address caller, address target, bytes data);
+
+    /**
+     * @dev A scheduled operation was executed.
+     */
+    event Executed(bytes32 operationId);
+
+    /**
+     * @dev A scheduled operation was canceled.
+     */
+    event Canceled(bytes32 operationId);
+
+    event GroupLabel(uint256 indexed groupId, string label);
+    event GroupGranted(uint256 indexed groupId, address indexed account, uint48 since, uint32 delay);
+    event GroupRevoked(uint256 indexed groupId, address indexed account);
+    event GroupExecutionDelayUpdate(uint256 indexed groupId, address indexed account, uint32 delay, uint48 from);
+    event GroupAdminChanged(uint256 indexed groupId, uint256 indexed admin);
+    event GroupGuardianChanged(uint256 indexed groupId, uint256 indexed guardian);
+    event GroupGrantDelayChanged(uint256 indexed groupId, uint32 delay, uint48 from);
+    event AccessModeUpdated(address indexed target, AccessMode mode);
+    event FunctionAllowedGroupUpdated(address indexed target, bytes4 selector, uint256 indexed groupId);
+
+    error AccessManagerAlreadyScheduled(bytes32 operationId);
+    error AccessManagerNotScheduled(bytes32 operationId);
+    error AccessManagerNotReady(bytes32 operationId);
+    error AccessManagerLockedGroup(uint256 groupId);
+    error AccessManagerAcountAlreadyInGroup(uint256 groupId, address account);
+    error AccessManagerAcountNotInGroup(uint256 groupId, address account);
+    error AccessManagerBadConfirmation();
+    error AccessControlUnauthorizedAccount(address msgsender, uint256 groupId);
+    error AccessManagerUnauthorizedCall(address caller, address target, bytes4 selector);
+    error AccessManagerCannotCancel(address msgsender, address caller, address target, bytes4 selector);
+
+    function canCall(
+        address caller,
+        address target,
+        bytes4 selector
+    ) external view returns (bool allowed, uint32 delay);
+
+    function getContractMode(address target) external view returns (AccessMode);
+
+    function getFunctionAllowedGroup(address target, bytes4 selector) external view returns (uint256);
+
+    function getGroupAdmin(uint256 group) external view returns (uint256);
+
+    function getGroupGuardian(uint256 group) external view returns (uint256);
+
+    function getGroupGrantDelay(uint256 groupId) external view returns (uint32);
+
+    function getAccess(uint256 group, address account) external view returns (Access memory);
+
+    function hasGroup(uint256 group, address account) external view returns (bool);
+
+    function grantGroup(uint256 group, address account, uint32 executionDelay) external;
+
+    function revokeGroup(uint256 group, address account) external;
+
+    function renounceGroup(uint256 group, address callerConfirmation) external;
+
+    function setExecuteDelay(uint256 group, address account, uint32 newDelay) external;
+
+    function setGroupAdmin(uint256 group, uint256 admin) external;
+
+    function setGroupGuardian(uint256 group, uint256 guardian) external;
+
+    function setGrantDelay(uint256 group, uint32 newDelay) external;
+
+    function setContractModeCustom(address target) external;
+
+    function setContractModeOpen(address target) external;
+
+    function setContractModeClosed(address target) external;
+
+    function schedule(address target, bytes calldata data) external returns (bytes32);
+
+    function cancel(address caller, address target, bytes calldata data) external;
+
+    function relay(address target, bytes calldata data) external payable;
+
+    function updateAuthority(IManaged target, address newAuthority) external;
+}

+ 37 - 0
contracts/access/manager/IAuthority.sol

@@ -0,0 +1,37 @@
+// SPDX-License-Identifier: MIT
+
+pragma solidity ^0.8.20;
+
+/**
+ * @dev Standard interface for permissioning originally defined in Dappsys.
+ */
+interface IAuthority {
+    /**
+     * @dev Returns true if the caller can invoke on a target the function identified by a function selector.
+     */
+    function canCall(address caller, address target, bytes4 selector) external view returns (bool allowed);
+}
+
+/**
+ * @dev Since `AccessManager` implements an extended IAuthority interface, invoking `canCall` with backwards compatibility
+ * for the preexisting `IAuthority` interface requires special care to avoid reverting on insufficient return data.
+ * This helper function takes care of invoking `canCall` in a backwards compatible way without reverting.
+ */
+function safeCanCall(
+    address authority,
+    address caller,
+    address target,
+    bytes4 selector
+) view returns (bool allowed, uint32 delay) {
+    (bool success, bytes memory data) = authority.staticcall(
+        abi.encodeCall(IAuthority.canCall, (caller, target, selector))
+    );
+    if (success) {
+        if (data.length >= 0x40) {
+            (allowed, delay) = abi.decode(data, (bool, uint32));
+        } else if (data.length >= 0x20) {
+            allowed = abi.decode(data, (bool));
+        }
+    }
+    return (allowed, delay);
+}

+ 15 - 0
contracts/access/manager/IManaged.sol

@@ -0,0 +1,15 @@
+// SPDX-License-Identifier: MIT
+
+pragma solidity ^0.8.20;
+
+interface IManaged {
+    event AuthorityUpdated(address authority);
+
+    error AccessManagedUnauthorized(address caller);
+    error AccessManagedRequiredDelay(address caller, uint32 delay);
+    error AccessManagedInvalidAuthority(address authority);
+
+    function authority() external view returns (address);
+
+    function setAuthority(address) external;
+}

+ 42 - 0
contracts/access/manager/utils/AccessManagedAdapter.sol

@@ -0,0 +1,42 @@
+// SPDX-License-Identifier: MIT
+
+pragma solidity ^0.8.20;
+
+import {AccessManaged} from "../AccessManaged.sol";
+import {Address} from "../../../utils/Address.sol";
+
+/**
+ * @dev This contract can be used to migrate existing {Ownable} or {AccessControl} contracts into an {AccessManager}
+ * system.
+ *
+ * Ownable contracts can have their ownership transferred to an instance of this adapter. AccessControl contracts can
+ * grant all roles to the adapter, while ideally revoking them from all other accounts. Subsequently, the permissions
+ * for those contracts can be managed centrally and with function granularity in the {AccessManager} instance the
+ * adapter is connected to.
+ *
+ * Permissioned interactions with thus migrated contracts must go through the adapter's {relay} function and will
+ * proceed if the function is allowed for the caller in the AccessManager instance.
+ */
+contract AccessManagedAdapter is AccessManaged {
+    error AccessManagedAdapterUnauthorizedSelfRelay();
+
+    /**
+     * @dev Initializes an adapter connected to an AccessManager instance.
+     */
+    constructor(address initialAuthority) AccessManaged(initialAuthority) {}
+
+    /**
+     * @dev Relays a function call to the target contract. The call will be relayed if the AccessManager allows the
+     * caller access to this function in the target contract, i.e. if the caller is a member of the group that is
+     * allowed for the function.
+     */
+    function relay(address target, bytes calldata data) external payable {
+        if (target == address(this)) {
+            revert AccessManagedAdapterUnauthorizedSelfRelay();
+        }
+
+        _checkCanCall(_msgSender(), target, bytes4(data[0:4]));
+
+        Address.functionCallWithValue(target, data, msg.value);
+    }
+}

+ 211 - 0
contracts/governance/extensions/GovernorTimelock.sol

@@ -0,0 +1,211 @@
+// SPDX-License-Identifier: MIT
+
+pragma solidity ^0.8.20;
+
+import {IGovernorTimelock} from "./IGovernorTimelock.sol";
+import {IGovernor, Governor} from "../Governor.sol";
+import {IManaged} from "../../access/manager/IManaged.sol";
+import {IAuthority, safeCanCall} from "../../access/manager/IAuthority.sol";
+import {IAccessManager} from "../../access/manager/IAccessManager.sol";
+import {Address} from "../../utils/Address.sol";
+import {Math} from "../../utils/math/Math.sol";
+
+/**
+ * @dev TODO
+ *
+ * _Available since v5.0._
+ */
+abstract contract GovernorTimelock is IGovernorTimelock, Governor {
+    struct ExecutionDetail {
+        address authority;
+        uint32 delay;
+    }
+
+    mapping(uint256 => ExecutionDetail[]) private _executionDetails;
+    mapping(uint256 => uint256) private _proposalEta;
+
+    /**
+     * @dev Overridden version of the {Governor-state} function with added support for the `Queued` and `Expired` state.
+     */
+    function state(uint256 proposalId) public view virtual override(IGovernor, Governor) returns (ProposalState) {
+        ProposalState currentState = super.state(proposalId);
+
+        if (currentState == ProposalState.Succeeded && proposalEta(proposalId) != 0) {
+            return ProposalState.Queued;
+        } else {
+            return currentState;
+        }
+    }
+
+    /**
+     * @dev Public accessor to check the eta of a queued proposal.
+     */
+    function proposalEta(uint256 proposalId) public view virtual override returns (uint256) {
+        return _proposalEta[proposalId];
+    }
+
+    /**
+     * @dev Public accessor to check the execution details.
+     */
+    function proposalExecutionDetails(uint256 proposalId) public view returns (ExecutionDetail[] memory) {
+        return _executionDetails[proposalId];
+    }
+
+    /**
+     * @dev See {IGovernor-propose}
+     */
+    function propose(
+        address[] memory targets,
+        uint256[] memory values,
+        bytes[] memory calldatas,
+        string memory description
+    ) public virtual override(IGovernor, Governor) returns (uint256) {
+        uint256 proposalId = super.propose(targets, values, calldatas, description);
+
+        ExecutionDetail[] storage details = _executionDetails[proposalId];
+        for (uint256 i = 0; i < targets.length; ++i) {
+            details.push(_detectExecutionDetails(targets[i], bytes4(calldatas[i])));
+        }
+
+        return proposalId;
+    }
+
+    /**
+     * @dev Function to queue a proposal to the timelock.
+     *
+     * NOTE: execution delay is estimated based on the delay information retrieved in {proposal}. This value may be
+     * off if the delay were updated during the vote.
+     */
+    function queue(
+        address[] memory targets,
+        uint256[] memory values,
+        bytes[] memory calldatas,
+        bytes32 descriptionHash
+    ) public virtual override returns (uint256) {
+        uint256 proposalId = hashProposal(targets, values, calldatas, descriptionHash);
+
+        ProposalState currentState = state(proposalId);
+        if (currentState != ProposalState.Succeeded) {
+            revert GovernorUnexpectedProposalState(
+                proposalId,
+                currentState,
+                _encodeStateBitmap(ProposalState.Succeeded)
+            );
+        }
+
+        ExecutionDetail[] storage details = _executionDetails[proposalId];
+        ExecutionDetail memory detail;
+        uint32 setback = 0;
+
+        for (uint256 i = 0; i < targets.length; ++i) {
+            detail = details[i];
+            if (detail.authority != address(0)) {
+                IAccessManager(detail.authority).schedule(targets[i], calldatas[i]);
+            }
+            setback = uint32(Math.max(setback, detail.delay)); // cast is safe, both parameters are uint32
+        }
+
+        uint256 eta = block.timestamp + setback;
+        _proposalEta[proposalId] = eta;
+
+        return eta;
+    }
+
+    /**
+     * @dev See {IGovernor-_execute}
+     */
+    function _execute(
+        uint256 proposalId,
+        address[] memory targets,
+        uint256[] memory values,
+        bytes[] memory calldatas,
+        bytes32 /*descriptionHash*/
+    ) internal virtual override {
+        ExecutionDetail[] storage details = _executionDetails[proposalId];
+        ExecutionDetail memory detail;
+
+        // TODO: enforce ETA (might include some _defaultDelaySeconds that are not enforced by any authority)
+
+        for (uint256 i = 0; i < targets.length; ++i) {
+            detail = details[i];
+            if (detail.authority != address(0)) {
+                IAccessManager(detail.authority).relay{value: values[i]}(targets[i], calldatas[i]);
+            } else {
+                (bool success, bytes memory returndata) = targets[i].call{value: values[i]}(calldatas[i]);
+                Address.verifyCallResult(success, returndata);
+            }
+        }
+
+        delete _executionDetails[proposalId];
+    }
+
+    /**
+     * @dev See {IGovernor-_cancel}
+     */
+    function _cancel(
+        address[] memory targets,
+        uint256[] memory values,
+        bytes[] memory calldatas,
+        bytes32 descriptionHash
+    ) internal virtual override returns (uint256) {
+        uint256 proposalId = super._cancel(targets, values, calldatas, descriptionHash);
+
+        ExecutionDetail[] storage details = _executionDetails[proposalId];
+        ExecutionDetail memory detail;
+
+        for (uint256 i = 0; i < targets.length; ++i) {
+            detail = details[i];
+            if (detail.authority != address(0)) {
+                IAccessManager(detail.authority).cancel(address(this), targets[i], calldatas[i]);
+            }
+        }
+
+        delete _executionDetails[proposalId];
+
+        return proposalId;
+    }
+
+    /**
+     * @dev Default delay to apply to function calls that are not (scheduled and) executed through an AccessManager.
+     *
+     * NOTE: execution delays are processed by the AccessManager contracts. We expect these to always be in seconds.
+     * Therefore, the default delay that is enforced for calls that don't go through an access manager is also in
+     * seconds, regardless of the Governor's clock mode.
+     */
+    function _defaultDelaySeconds() internal view virtual returns (uint32) {
+        return 0;
+    }
+
+    /**
+     * @dev Check if the execution of a call needs to be performed through an AccessManager and what delay should be
+     * applied to this call.
+     *
+     * Returns { manager: address(0), delay: _defaultDelaySeconds() } if:
+     * - target does not have code
+     * - target does not implement IManaged
+     * - calling canCall on the target's manager returns a 0 delay
+     * - calling canCall on the target's manager reverts
+     * Otherwise (calling canCall on the target's manager returns a non 0 delay), return the address of the
+     * AccessManager to use, and the delay for this call.
+     */
+    function _detectExecutionDetails(address target, bytes4 selector) private view returns (ExecutionDetail memory) {
+        bool success;
+        bytes memory returndata;
+
+        // Get authority
+        (success, returndata) = target.staticcall(abi.encodeCall(IManaged.authority, ()));
+        if (success && returndata.length >= 0x20) {
+            address authority = abi.decode(returndata, (address));
+
+            // Check if governor can call, and try to detect a delay
+            (bool authorized, uint32 delay) = safeCanCall(authority, address(this), target, selector);
+
+            // If direct call is not authorized, and delayed call is possible
+            if (!authorized && delay > 0) {
+                return ExecutionDetail({authority: authority, delay: delay});
+            }
+        }
+
+        return ExecutionDetail({authority: address(0), delay: _defaultDelaySeconds()});
+    }
+}

+ 18 - 0
contracts/mocks/AccessManagedTarget.sol

@@ -0,0 +1,18 @@
+// SPDX-License-Identifier: MIT
+
+pragma solidity ^0.8.20;
+
+import {AccessManaged} from "../access/manager/AccessManaged.sol";
+
+abstract contract AccessManagedTarget is AccessManaged {
+    event CalledRestricted(address caller);
+    event CalledUnrestricted(address caller);
+
+    function fnRestricted() public restricted {
+        emit CalledRestricted(msg.sender);
+    }
+
+    function fnUnrestricted() public {
+        emit CalledUnrestricted(msg.sender);
+    }
+}

+ 131 - 0
contracts/utils/types/Time.sol

@@ -0,0 +1,131 @@
+// SPDX-License-Identifier: MIT
+
+pragma solidity ^0.8.20;
+
+import {Math} from "../math/Math.sol";
+import {SafeCast} from "../math/SafeCast.sol";
+
+/**
+ * @dev This library provides helpers for manipulating time-related objects.
+ *
+ * It uses the following types:
+ * - `uint48` for timepoints
+ * - `uint32` for durations
+ *
+ * While the library doesn't provide specific types for timepoints and duration, it does provide:
+ * - a `Delay` type to represent duration that can be programmed to change value automatically at a given point
+ * - additional helper functions
+ */
+library Time {
+    using Time for *;
+
+    /**
+     * @dev Get the block timestamp as a Timepoint
+     */
+    function timestamp() internal view returns (uint48) {
+        return SafeCast.toUint48(block.timestamp);
+    }
+
+    /**
+     * @dev Get the block number as a Timepoint
+     */
+    function blockNumber() internal view returns (uint48) {
+        return SafeCast.toUint48(block.number);
+    }
+
+    /**
+     * @dev Check if a timepoint is set, and in the past
+     */
+    function isSetAndPast(uint48 timepoint, uint48 ref) internal pure returns (bool) {
+        return timepoint != 0 && timepoint <= ref;
+    }
+
+    // ==================================================== Delay =====================================================
+    /**
+     * @dev A `Delay` is a uint32 duration that can be programmed to change value automatically at a given point in the
+     * future. The "effect" timepoint describes when the transitions happens from the "old" value to the "new" value.
+     * This allows updating the delay applied to some operation while keeping so guarantees.
+     *
+     * In particular, the {update} function guarantees that is the delay is reduced, the old delay still applies for
+     * some time. For example if the delay is currently 7 days to do an upgrade, the admin should not be able to set
+     * the delay to 0 and upgrade immediately. If the admin wants to reduce the delay, the old delay (7 days) should
+     * still apply for some time.
+     *
+     *
+     * The `Delay` type is 128 bits long, and packs the following:
+     * [000:031] uint32 for the current value (duration)
+     * [032:063] uint32 for the pending value (duration)
+     * [064:111] uint48 for the effect date (timepoint)
+     *
+     * NOTE: The {get} and {update} function operate using timestamps. Block number based delays should use the
+     * {getAt} and {updateAt} variants of these functions.
+     */
+    type Delay is uint112;
+
+    /**
+     * @dev Wrap a Duration into a Delay to add the one-step "update in the future" feature
+     */
+    function toDelay(uint32 duration) internal pure returns (Delay) {
+        return Delay.wrap(duration);
+    }
+
+    /**
+     * @dev Get the value the Delay will be at a given timepoint
+     */
+    function getAt(Delay self, uint48 timepoint) internal pure returns (uint32) {
+        (uint32 oldValue, uint32 newValue, uint48 effect) = self.split();
+        return (effect == 0 || effect > timepoint) ? oldValue : newValue;
+    }
+
+    /**
+     * @dev Get the current value.
+     */
+    function get(Delay self) internal view returns (uint32) {
+        return self.getAt(timestamp());
+    }
+
+    /**
+     * @dev Get the pending value, and effect timepoint. If the effect timepoint is 0, then the pending value should
+     * not be considered.
+     */
+    function getPending(Delay self) internal pure returns (uint32, uint48) {
+        (, uint32 newValue, uint48 effect) = self.split();
+        return (newValue, effect);
+    }
+
+    /**
+     * @dev Update a Delay object so that a new duration takes effect at a given timepoint.
+     */
+    function updateAt(Delay self, uint32 newValue, uint48 effect) internal view returns (Delay) {
+        return pack(self.get(), newValue, effect);
+    }
+
+    /**
+     * @dev Update a Delay object so that it takes a new duration after at a timepoint that is automatically computed
+     * to enforce the old delay at the moment of the update.
+     */
+    function update(Delay self, uint32 newValue, uint32 minSetback) internal view returns (Delay) {
+        uint32 value = self.get();
+        uint32 setback = uint32(Math.max(minSetback, value > newValue ? value - newValue : 0));
+        return self.updateAt(newValue, timestamp() + setback);
+    }
+
+    /**
+     * @dev Split a delay into its components: oldValue, newValue and effect (transition timepoint).
+     */
+    function split(Delay self) internal pure returns (uint32, uint32, uint48) {
+        uint112 raw = Delay.unwrap(self);
+        return (
+            uint32(raw), // oldValue
+            uint32(raw >> 32), // newValue
+            uint48(raw >> 64) // effect
+        );
+    }
+
+    /**
+     * @dev pack the components into a Delay object.
+     */
+    function pack(uint32 oldValue, uint32 newValue, uint48 effect) internal pure returns (Delay) {
+        return Delay.wrap(uint112(oldValue) | (uint112(newValue) << 32) | (uint112(effect) << 64));
+    }
+}

+ 7 - 7
package-lock.json

@@ -45,7 +45,7 @@
         "solhint": "^3.3.6",
         "solhint-plugin-openzeppelin": "file:scripts/solhint-custom",
         "solidity-ast": "^0.4.25",
-        "solidity-coverage": "^0.8.0",
+        "solidity-coverage": "^0.8.4",
         "solidity-docgen": "^0.6.0-beta.29",
         "undici": "^5.22.1",
         "web3": "^1.3.0",
@@ -12796,9 +12796,9 @@
       }
     },
     "node_modules/solidity-coverage/node_modules/@solidity-parser/parser": {
-      "version": "0.16.0",
-      "resolved": "https://registry.npmjs.org/@solidity-parser/parser/-/parser-0.16.0.tgz",
-      "integrity": "sha512-ESipEcHyRHg4Np4SqBCfcXwyxxna1DgFVz69bgpLV8vzl/NP1DtcKsJ4dJZXWQhY/Z4J2LeKBiOkOVZn9ct33Q==",
+      "version": "0.16.1",
+      "resolved": "https://registry.npmjs.org/@solidity-parser/parser/-/parser-0.16.1.tgz",
+      "integrity": "sha512-PdhRFNhbTtu3x8Axm0uYpqOy/lODYQK+MlYSgqIsq2L8SFYEHJPHNUiOTAJbDGzNjjr1/n9AcIayxafR/fWmYw==",
       "dev": true,
       "dependencies": {
         "antlr4ts": "^0.5.0-alpha.4"
@@ -25272,9 +25272,9 @@
       },
       "dependencies": {
         "@solidity-parser/parser": {
-          "version": "0.16.0",
-          "resolved": "https://registry.npmjs.org/@solidity-parser/parser/-/parser-0.16.0.tgz",
-          "integrity": "sha512-ESipEcHyRHg4Np4SqBCfcXwyxxna1DgFVz69bgpLV8vzl/NP1DtcKsJ4dJZXWQhY/Z4J2LeKBiOkOVZn9ct33Q==",
+          "version": "0.16.1",
+          "resolved": "https://registry.npmjs.org/@solidity-parser/parser/-/parser-0.16.1.tgz",
+          "integrity": "sha512-PdhRFNhbTtu3x8Axm0uYpqOy/lODYQK+MlYSgqIsq2L8SFYEHJPHNUiOTAJbDGzNjjr1/n9AcIayxafR/fWmYw==",
           "dev": true,
           "requires": {
             "antlr4ts": "^0.5.0-alpha.4"

+ 927 - 0
test/access/manager/AccessManager.test.js

@@ -0,0 +1,927 @@
+const { expectEvent, constants, time } = require('@openzeppelin/test-helpers');
+const { expectRevertCustomError } = require('../../helpers/customError');
+const { AccessMode } = require('../../helpers/enums');
+const { selector } = require('../../helpers/methods');
+const { clockFromReceipt } = require('../../helpers/time');
+
+const AccessManager = artifacts.require('$AccessManager');
+const AccessManagedTarget = artifacts.require('$AccessManagedTarget');
+
+const GROUPS = {
+  ADMIN: web3.utils.toBN(0),
+  SOME_ADMIN: web3.utils.toBN(17),
+  SOME: web3.utils.toBN(42),
+  PUBLIC: constants.MAX_UINT256,
+};
+Object.assign(GROUPS, Object.fromEntries(Object.entries(GROUPS).map(([key, value]) => [value, key])));
+
+const executeDelay = web3.utils.toBN(10);
+const grantDelay = web3.utils.toBN(10);
+
+const MAX_UINT = n => web3.utils.toBN(1).shln(n).subn(1);
+
+const split = delay => ({
+  oldValue: web3.utils.toBN(delay).shrn(0).and(MAX_UINT(32)).toString(),
+  newValue: web3.utils.toBN(delay).shrn(32).and(MAX_UINT(32)).toString(),
+  effect: web3.utils.toBN(delay).shrn(64).and(MAX_UINT(48)).toString(),
+});
+
+contract('AccessManager', function (accounts) {
+  const [admin, manager, member, user, other] = accounts;
+
+  beforeEach(async function () {
+    this.manager = await AccessManager.new(admin);
+    this.target = await AccessManagedTarget.new(this.manager.address);
+
+    // add member to group
+    await this.manager.$_setGroupAdmin(GROUPS.SOME, GROUPS.SOME_ADMIN);
+    await this.manager.$_setGroupGuardian(GROUPS.SOME, GROUPS.SOME_ADMIN);
+    await this.manager.$_grantGroup(GROUPS.SOME_ADMIN, manager, 0, 0);
+    await this.manager.$_grantGroup(GROUPS.SOME, member, 0, 0);
+
+    // helpers for indirect calls
+    this.call = [this.target.address, selector('fnRestricted()')];
+    this.opId = web3.utils.keccak256(
+      web3.eth.abi.encodeParameters(['address', 'address', 'bytes'], [user, ...this.call]),
+    );
+    this.schedule = (opts = {}) => this.manager.schedule(...this.call, { from: user, ...opts });
+    this.relay = (opts = {}) => this.manager.relay(...this.call, { from: user, ...opts });
+    this.cancel = (opts = {}) => this.manager.cancel(user, ...this.call, { from: user, ...opts });
+  });
+
+  it('groups are correctly initialized', async function () {
+    // group admin
+    expect(await this.manager.getGroupAdmin(GROUPS.ADMIN)).to.be.bignumber.equal(GROUPS.ADMIN);
+    expect(await this.manager.getGroupAdmin(GROUPS.SOME_ADMIN)).to.be.bignumber.equal(GROUPS.ADMIN);
+    expect(await this.manager.getGroupAdmin(GROUPS.SOME)).to.be.bignumber.equal(GROUPS.SOME_ADMIN);
+    expect(await this.manager.getGroupAdmin(GROUPS.PUBLIC)).to.be.bignumber.equal(GROUPS.ADMIN);
+    // group guardian
+    expect(await this.manager.getGroupGuardian(GROUPS.ADMIN)).to.be.bignumber.equal(GROUPS.ADMIN);
+    expect(await this.manager.getGroupGuardian(GROUPS.SOME_ADMIN)).to.be.bignumber.equal(GROUPS.ADMIN);
+    expect(await this.manager.getGroupGuardian(GROUPS.SOME)).to.be.bignumber.equal(GROUPS.SOME_ADMIN);
+    expect(await this.manager.getGroupGuardian(GROUPS.PUBLIC)).to.be.bignumber.equal(GROUPS.ADMIN);
+    // group members
+    expect(await this.manager.hasGroup(GROUPS.ADMIN, admin)).to.be.equal(true);
+    expect(await this.manager.hasGroup(GROUPS.ADMIN, manager)).to.be.equal(false);
+    expect(await this.manager.hasGroup(GROUPS.ADMIN, member)).to.be.equal(false);
+    expect(await this.manager.hasGroup(GROUPS.ADMIN, user)).to.be.equal(false);
+    expect(await this.manager.hasGroup(GROUPS.SOME_ADMIN, admin)).to.be.equal(false);
+    expect(await this.manager.hasGroup(GROUPS.SOME_ADMIN, manager)).to.be.equal(true);
+    expect(await this.manager.hasGroup(GROUPS.SOME_ADMIN, member)).to.be.equal(false);
+    expect(await this.manager.hasGroup(GROUPS.SOME_ADMIN, user)).to.be.equal(false);
+    expect(await this.manager.hasGroup(GROUPS.SOME, admin)).to.be.equal(false);
+    expect(await this.manager.hasGroup(GROUPS.SOME, manager)).to.be.equal(false);
+    expect(await this.manager.hasGroup(GROUPS.SOME, member)).to.be.equal(true);
+    expect(await this.manager.hasGroup(GROUPS.SOME, user)).to.be.equal(false);
+    expect(await this.manager.hasGroup(GROUPS.PUBLIC, admin)).to.be.equal(true);
+    expect(await this.manager.hasGroup(GROUPS.PUBLIC, manager)).to.be.equal(true);
+    expect(await this.manager.hasGroup(GROUPS.PUBLIC, member)).to.be.equal(true);
+    expect(await this.manager.hasGroup(GROUPS.PUBLIC, user)).to.be.equal(true);
+  });
+
+  describe('Groups management', function () {
+    describe('label group', function () {
+      it('admin can emit a label event', async function () {
+        expectEvent(await this.manager.labelGroup(GROUPS.SOME, 'Some label', { from: admin }), 'GroupLabel', {
+          groupId: GROUPS.SOME,
+          label: 'Some label',
+        });
+      });
+
+      it('admin can re-emit a label event', async function () {
+        await this.manager.labelGroup(GROUPS.SOME, 'Some label', { from: admin });
+
+        expectEvent(await this.manager.labelGroup(GROUPS.SOME, 'Updated label', { from: admin }), 'GroupLabel', {
+          groupId: GROUPS.SOME,
+          label: 'Updated label',
+        });
+      });
+
+      it('emitting a label is restricted', async function () {
+        await expectRevertCustomError(
+          this.manager.labelGroup(GROUPS.SOME, 'Invalid label', { from: other }),
+          'AccessControlUnauthorizedAccount',
+          [other, GROUPS.ADMIN],
+        );
+      });
+    });
+
+    describe('grand group', function () {
+      describe('without a grant delay', function () {
+        it('without an execute delay', async function () {
+          expect(await this.manager.hasGroup(GROUPS.SOME, user)).to.be.false;
+
+          const { receipt } = await this.manager.grantGroup(GROUPS.SOME, user, 0, { from: manager });
+          const timestamp = await clockFromReceipt.timestamp(receipt).then(web3.utils.toBN);
+          expectEvent(receipt, 'GroupGranted', { groupId: GROUPS.SOME, account: user, since: timestamp, delay: '0' });
+
+          expect(await this.manager.hasGroup(GROUPS.SOME, user)).to.be.true;
+
+          const { delay, since } = await this.manager.getAccess(GROUPS.SOME, user);
+          expect(delay).to.be.bignumber.equal('0');
+          expect(since).to.be.bignumber.equal(timestamp);
+        });
+
+        it('with an execute delay', async function () {
+          expect(await this.manager.hasGroup(GROUPS.SOME, user)).to.be.false;
+
+          const { receipt } = await this.manager.grantGroup(GROUPS.SOME, user, executeDelay, { from: manager });
+          const timestamp = await clockFromReceipt.timestamp(receipt).then(web3.utils.toBN);
+          expectEvent(receipt, 'GroupGranted', {
+            groupId: GROUPS.SOME,
+            account: user,
+            since: timestamp,
+            delay: executeDelay,
+          });
+
+          expect(await this.manager.hasGroup(GROUPS.SOME, user)).to.be.true;
+
+          const { delay, since } = await this.manager.getAccess(GROUPS.SOME, user);
+          expect(delay).to.be.bignumber.equal(executeDelay);
+          expect(since).to.be.bignumber.equal(timestamp);
+        });
+
+        it('to a user that is already in the group', async function () {
+          expect(await this.manager.hasGroup(GROUPS.SOME, member)).to.be.true;
+
+          await expectRevertCustomError(
+            this.manager.grantGroup(GROUPS.SOME, member, 0, { from: manager }),
+            'AccessManagerAcountAlreadyInGroup',
+            [GROUPS.SOME, member],
+          );
+        });
+
+        it('to a user that is scheduled for joining the group', async function () {
+          await this.manager.$_grantGroup(GROUPS.SOME, user, 10, 0); // grant delay 10
+
+          expect(await this.manager.hasGroup(GROUPS.SOME, user)).to.be.false;
+
+          await expectRevertCustomError(
+            this.manager.grantGroup(GROUPS.SOME, user, 0, { from: manager }),
+            'AccessManagerAcountAlreadyInGroup',
+            [GROUPS.SOME, user],
+          );
+        });
+
+        it('grant group is restricted', async function () {
+          await expectRevertCustomError(
+            this.manager.grantGroup(GROUPS.SOME, user, 0, { from: other }),
+            'AccessControlUnauthorizedAccount',
+            [other, GROUPS.SOME_ADMIN],
+          );
+        });
+      });
+
+      describe('with a grant delay', function () {
+        beforeEach(async function () {
+          await this.manager.$_setGrantDelay(GROUPS.SOME, grantDelay);
+        });
+
+        it('granted group is not active immediatly', async function () {
+          const { receipt } = await this.manager.grantGroup(GROUPS.SOME, user, 0, { from: manager });
+          const timestamp = await clockFromReceipt.timestamp(receipt).then(web3.utils.toBN);
+          expectEvent(receipt, 'GroupGranted', {
+            groupId: GROUPS.SOME,
+            account: user,
+            since: timestamp.add(grantDelay),
+            delay: '0',
+          });
+
+          expect(await this.manager.hasGroup(GROUPS.SOME, user)).to.be.false;
+
+          const { delay, since } = await this.manager.getAccess(GROUPS.SOME, user);
+          expect(delay).to.be.bignumber.equal('0');
+          expect(since).to.be.bignumber.equal(timestamp.add(grantDelay));
+        });
+
+        it('granted group is active after the delay', async function () {
+          const { receipt } = await this.manager.grantGroup(GROUPS.SOME, user, 0, { from: manager });
+          const timestamp = await clockFromReceipt.timestamp(receipt).then(web3.utils.toBN);
+          expectEvent(receipt, 'GroupGranted', {
+            groupId: GROUPS.SOME,
+            account: user,
+            since: timestamp.add(grantDelay),
+            delay: '0',
+          });
+
+          await time.increase(grantDelay);
+
+          expect(await this.manager.hasGroup(GROUPS.SOME, user)).to.be.true;
+
+          const { delay, since } = await this.manager.getAccess(GROUPS.SOME, user);
+          expect(delay).to.be.bignumber.equal('0');
+          expect(since).to.be.bignumber.equal(timestamp.add(grantDelay));
+        });
+      });
+    });
+
+    describe('revoke group', function () {
+      it('from a user that is already in the group', async function () {
+        expect(await this.manager.hasGroup(GROUPS.SOME, member)).to.be.true;
+
+        const { receipt } = await this.manager.revokeGroup(GROUPS.SOME, member, { from: manager });
+        expectEvent(receipt, 'GroupRevoked', { groupId: GROUPS.SOME, account: member });
+
+        expect(await this.manager.hasGroup(GROUPS.SOME, member)).to.be.false;
+
+        const { delay, since } = await this.manager.getAccess(GROUPS.SOME, user);
+        expect(delay).to.be.bignumber.equal('0');
+        expect(since).to.be.bignumber.equal('0');
+      });
+
+      it('from a user that is scheduled for joining the group', async function () {
+        await this.manager.$_grantGroup(GROUPS.SOME, user, 10, 0); // grant delay 10
+
+        expect(await this.manager.hasGroup(GROUPS.SOME, user)).to.be.false;
+
+        const { receipt } = await this.manager.revokeGroup(GROUPS.SOME, user, { from: manager });
+        expectEvent(receipt, 'GroupRevoked', { groupId: GROUPS.SOME, account: user });
+
+        expect(await this.manager.hasGroup(GROUPS.SOME, user)).to.be.false;
+
+        const { delay, since } = await this.manager.getAccess(GROUPS.SOME, user);
+        expect(delay).to.be.bignumber.equal('0');
+        expect(since).to.be.bignumber.equal('0');
+      });
+
+      it('from a user that is not in the group', async function () {
+        expect(await this.manager.hasGroup(GROUPS.SOME, user)).to.be.false;
+
+        await expectRevertCustomError(
+          this.manager.revokeGroup(GROUPS.SOME, user, { from: manager }),
+          'AccessManagerAcountNotInGroup',
+          [GROUPS.SOME, user],
+        );
+      });
+
+      it('revoke group is restricted', async function () {
+        await expectRevertCustomError(
+          this.manager.revokeGroup(GROUPS.SOME, member, { from: other }),
+          'AccessControlUnauthorizedAccount',
+          [other, GROUPS.SOME_ADMIN],
+        );
+      });
+    });
+
+    describe('renounce group', function () {
+      it('for a user that is already in the group', async function () {
+        expect(await this.manager.hasGroup(GROUPS.SOME, member)).to.be.true;
+
+        const { receipt } = await this.manager.renounceGroup(GROUPS.SOME, member, { from: member });
+        expectEvent(receipt, 'GroupRevoked', { groupId: GROUPS.SOME, account: member });
+
+        expect(await this.manager.hasGroup(GROUPS.SOME, member)).to.be.false;
+
+        const { delay, since } = await this.manager.getAccess(GROUPS.SOME, member);
+        expect(delay).to.be.bignumber.equal('0');
+        expect(since).to.be.bignumber.equal('0');
+      });
+
+      it('for a user that is schedule for joining the group', async function () {
+        await this.manager.$_grantGroup(GROUPS.SOME, user, 10, 0); // grant delay 10
+
+        expect(await this.manager.hasGroup(GROUPS.SOME, user)).to.be.false;
+
+        const { receipt } = await this.manager.renounceGroup(GROUPS.SOME, user, { from: user });
+        expectEvent(receipt, 'GroupRevoked', { groupId: GROUPS.SOME, account: user });
+
+        expect(await this.manager.hasGroup(GROUPS.SOME, user)).to.be.false;
+
+        const { delay, since } = await this.manager.getAccess(GROUPS.SOME, user);
+        expect(delay).to.be.bignumber.equal('0');
+        expect(since).to.be.bignumber.equal('0');
+      });
+
+      it('for a user that is not in the group', async function () {
+        await expectRevertCustomError(
+          this.manager.renounceGroup(GROUPS.SOME, user, { from: user }),
+          'AccessManagerAcountNotInGroup',
+          [GROUPS.SOME, user],
+        );
+      });
+
+      it('bad user confirmation', async function () {
+        await expectRevertCustomError(
+          this.manager.renounceGroup(GROUPS.SOME, member, { from: user }),
+          'AccessManagerBadConfirmation',
+          [],
+        );
+      });
+    });
+
+    describe('change group admin', function () {
+      it("admin can set any group's admin", async function () {
+        expect(await this.manager.getGroupAdmin(GROUPS.SOME)).to.be.bignumber.equal(GROUPS.SOME_ADMIN);
+
+        const { receipt } = await this.manager.setGroupAdmin(GROUPS.SOME, GROUPS.ADMIN, { from: admin });
+        expectEvent(receipt, 'GroupAdminChanged', { groupId: GROUPS.SOME, admin: GROUPS.ADMIN });
+
+        expect(await this.manager.getGroupAdmin(GROUPS.SOME)).to.be.bignumber.equal(GROUPS.ADMIN);
+      });
+
+      it("seeting a group's admin is restricted", async function () {
+        await expectRevertCustomError(
+          this.manager.setGroupAdmin(GROUPS.SOME, GROUPS.SOME, { from: manager }),
+          'AccessControlUnauthorizedAccount',
+          [manager, GROUPS.ADMIN],
+        );
+      });
+    });
+
+    describe('change group guardian', function () {
+      it("admin can set any group's admin", async function () {
+        expect(await this.manager.getGroupGuardian(GROUPS.SOME)).to.be.bignumber.equal(GROUPS.SOME_ADMIN);
+
+        const { receipt } = await this.manager.setGroupGuardian(GROUPS.SOME, GROUPS.ADMIN, { from: admin });
+        expectEvent(receipt, 'GroupGuardianChanged', { groupId: GROUPS.SOME, guardian: GROUPS.ADMIN });
+
+        expect(await this.manager.getGroupGuardian(GROUPS.SOME)).to.be.bignumber.equal(GROUPS.ADMIN);
+      });
+
+      it("setting a group's admin is restricted", async function () {
+        await expectRevertCustomError(
+          this.manager.setGroupGuardian(GROUPS.SOME, GROUPS.SOME, { from: other }),
+          'AccessControlUnauthorizedAccount',
+          [other, GROUPS.ADMIN],
+        );
+      });
+    });
+
+    describe('change execution delay', function () {
+      it('increassing the delay has immediate effect', async function () {
+        const oldDelay = web3.utils.toBN(10);
+        const newDelay = web3.utils.toBN(100);
+
+        const { receipt: receipt1 } = await this.manager.$_setExecuteDelay(GROUPS.SOME, member, oldDelay);
+        const timestamp1 = await clockFromReceipt.timestamp(receipt1).then(web3.utils.toBN);
+
+        const delayBefore = await this.manager.getAccess(GROUPS.SOME, member).then(([, delay]) => split(delay));
+        expect(delayBefore.oldValue).to.be.bignumber.equal('0');
+        expect(delayBefore.newValue).to.be.bignumber.equal(oldDelay);
+        expect(delayBefore.effect).to.be.bignumber.equal(timestamp1);
+
+        const { receipt: receipt2 } = await this.manager.setExecuteDelay(GROUPS.SOME, member, newDelay, {
+          from: manager,
+        });
+        const timestamp2 = await clockFromReceipt.timestamp(receipt2).then(web3.utils.toBN);
+
+        expectEvent(receipt2, 'GroupExecutionDelayUpdate', {
+          groupId: GROUPS.SOME,
+          account: member,
+          delay: newDelay,
+          from: timestamp2,
+        });
+
+        // immediate effect
+        const delayAfter = await this.manager.getAccess(GROUPS.SOME, member).then(([, delay]) => split(delay));
+        expect(delayAfter.oldValue).to.be.bignumber.equal(oldDelay);
+        expect(delayAfter.newValue).to.be.bignumber.equal(newDelay);
+        expect(delayAfter.effect).to.be.bignumber.equal(timestamp2);
+      });
+
+      it('decreassing the delay takes time', async function () {
+        const oldDelay = web3.utils.toBN(100);
+        const newDelay = web3.utils.toBN(10);
+
+        const { receipt: receipt1 } = await this.manager.$_setExecuteDelay(GROUPS.SOME, member, oldDelay);
+        const timestamp1 = await clockFromReceipt.timestamp(receipt1).then(web3.utils.toBN);
+
+        const delayBefore = await this.manager.getAccess(GROUPS.SOME, member).then(([, delay]) => split(delay));
+        expect(delayBefore.oldValue).to.be.bignumber.equal('0');
+        expect(delayBefore.newValue).to.be.bignumber.equal(oldDelay);
+        expect(delayBefore.effect).to.be.bignumber.equal(timestamp1);
+
+        const { receipt: receipt2 } = await this.manager.setExecuteDelay(GROUPS.SOME, member, newDelay, {
+          from: manager,
+        });
+        const timestamp2 = await clockFromReceipt.timestamp(receipt2).then(web3.utils.toBN);
+
+        expectEvent(receipt2, 'GroupExecutionDelayUpdate', {
+          groupId: GROUPS.SOME,
+          account: member,
+          delay: newDelay,
+          from: timestamp2.add(oldDelay).sub(newDelay),
+        });
+
+        // delayed effect
+        const delayAfter = await this.manager.getAccess(GROUPS.SOME, member).then(([, delay]) => split(delay));
+
+        expect(delayAfter.oldValue).to.be.bignumber.equal(oldDelay);
+        expect(delayAfter.newValue).to.be.bignumber.equal(newDelay);
+        expect(delayAfter.effect).to.be.bignumber.equal(timestamp2.add(oldDelay).sub(newDelay));
+      });
+
+      it('cannot set the delay of a non member', async function () {
+        await expectRevertCustomError(
+          this.manager.setExecuteDelay(GROUPS.SOME, other, executeDelay, { from: manager }),
+          'AccessManagerAcountNotInGroup',
+          [GROUPS.SOME, other],
+        );
+      });
+
+      it('can set a user execution delay during the grant delay', async function () {
+        await this.manager.$_grantGroup(GROUPS.SOME, other, 10, 0);
+
+        const { receipt } = await this.manager.setExecuteDelay(GROUPS.SOME, other, executeDelay, { from: manager });
+        const timestamp = await clockFromReceipt.timestamp(receipt).then(web3.utils.toBN);
+
+        expectEvent(receipt, 'GroupExecutionDelayUpdate', {
+          groupId: GROUPS.SOME,
+          account: other,
+          delay: executeDelay,
+          from: timestamp,
+        });
+      });
+
+      it('changing the execution delay is restricted', async function () {
+        await expectRevertCustomError(
+          this.manager.setExecuteDelay(GROUPS.SOME, member, executeDelay, { from: other }),
+          'AccessControlUnauthorizedAccount',
+          [GROUPS.SOME_ADMIN, other],
+        );
+      });
+    });
+
+    describe('change grant delay', function () {
+      it('increassing the delay has immediate effect', async function () {
+        const oldDelay = web3.utils.toBN(10);
+        const newDelay = web3.utils.toBN(100);
+        await this.manager.$_setGrantDelay(GROUPS.SOME, oldDelay);
+
+        expect(await this.manager.getGroupGrantDelay(GROUPS.SOME)).to.be.bignumber.equal(oldDelay);
+
+        const { receipt } = await this.manager.setGrantDelay(GROUPS.SOME, newDelay, { from: admin });
+        const timestamp = await clockFromReceipt.timestamp(receipt).then(web3.utils.toBN);
+
+        expectEvent(receipt, 'GroupGrantDelayChanged', { groupId: GROUPS.SOME, delay: newDelay, from: timestamp });
+
+        expect(await this.manager.getGroupGrantDelay(GROUPS.SOME)).to.be.bignumber.equal(newDelay);
+      });
+
+      it('increassing the delay has delay effect', async function () {
+        const oldDelay = web3.utils.toBN(100);
+        const newDelay = web3.utils.toBN(10);
+        await this.manager.$_setGrantDelay(GROUPS.SOME, oldDelay);
+
+        expect(await this.manager.getGroupGrantDelay(GROUPS.SOME)).to.be.bignumber.equal(oldDelay);
+
+        const { receipt } = await this.manager.setGrantDelay(GROUPS.SOME, newDelay, { from: admin });
+        const timestamp = await clockFromReceipt.timestamp(receipt).then(web3.utils.toBN);
+
+        expectEvent(receipt, 'GroupGrantDelayChanged', {
+          groupId: GROUPS.SOME,
+          delay: newDelay,
+          from: timestamp.add(oldDelay).sub(newDelay),
+        });
+
+        expect(await this.manager.getGroupGrantDelay(GROUPS.SOME)).to.be.bignumber.equal(oldDelay);
+
+        await time.increase(oldDelay.sub(newDelay));
+
+        expect(await this.manager.getGroupGrantDelay(GROUPS.SOME)).to.be.bignumber.equal(newDelay);
+      });
+
+      it('changing the grant delay is restricted', async function () {
+        await expectRevertCustomError(
+          this.manager.setGrantDelay(GROUPS.SOME, grantDelay, { from: other }),
+          'AccessControlUnauthorizedAccount',
+          [GROUPS.ADMIN, other],
+        );
+      });
+    });
+  });
+
+  describe('Mode management', function () {
+    for (const [modeName, mode] of Object.entries(AccessMode)) {
+      describe(`setContractMode${modeName}`, function () {
+        it('set the mode and emits an event', async function () {
+          // set the target to another mode, so we can check the effects
+          await this.manager.$_setContractMode(
+            this.target.address,
+            Object.values(AccessMode).find(m => m != mode),
+          );
+
+          expect(await this.manager.getContractMode(this.target.address)).to.not.be.bignumber.equal(mode);
+
+          expectEvent(
+            await this.manager[`setContractMode${modeName}`](this.target.address, { from: admin }),
+            'AccessModeUpdated',
+            { target: this.target.address, mode },
+          );
+
+          expect(await this.manager.getContractMode(this.target.address)).to.be.bignumber.equal(mode);
+        });
+
+        it('is restricted', async function () {
+          await expectRevertCustomError(
+            this.manager[`setContractMode${modeName}`](this.target.address, { from: other }),
+            'AccessControlUnauthorizedAccount',
+            [other, GROUPS.ADMIN],
+          );
+        });
+      });
+    }
+  });
+
+  describe('Change function permissions', function () {
+    const sigs = ['someFunction()', 'someOtherFunction(uint256)', 'oneMoreFunction(address,uint8)'].map(selector);
+
+    it('admin can set function allowed group', async function () {
+      for (const sig of sigs) {
+        expect(await this.manager.getFunctionAllowedGroup(this.target.address, sig)).to.be.bignumber.equal(
+          GROUPS.ADMIN,
+        );
+      }
+
+      const { receipt: receipt1 } = await this.manager.setFunctionAllowedGroup(this.target.address, sigs, GROUPS.SOME, {
+        from: admin,
+      });
+
+      for (const sig of sigs) {
+        expectEvent(receipt1, 'FunctionAllowedGroupUpdated', {
+          target: this.target.address,
+          selector: sig,
+          groupId: GROUPS.SOME,
+        });
+        expect(await this.manager.getFunctionAllowedGroup(this.target.address, sig)).to.be.bignumber.equal(GROUPS.SOME);
+      }
+
+      const { receipt: receipt2 } = await this.manager.setFunctionAllowedGroup(
+        this.target.address,
+        [sigs[1]],
+        GROUPS.SOME_ADMIN,
+        { from: admin },
+      );
+      expectEvent(receipt2, 'FunctionAllowedGroupUpdated', {
+        target: this.target.address,
+        selector: sigs[1],
+        groupId: GROUPS.SOME_ADMIN,
+      });
+
+      for (const sig of sigs) {
+        expect(await this.manager.getFunctionAllowedGroup(this.target.address, sig)).to.be.bignumber.equal(
+          sig == sigs[1] ? GROUPS.SOME_ADMIN : GROUPS.SOME,
+        );
+      }
+    });
+
+    it('changing function permissions is restricted', async function () {
+      await expectRevertCustomError(
+        this.manager.setFunctionAllowedGroup(this.target.address, sigs, GROUPS.SOME, { from: other }),
+        'AccessControlUnauthorizedAccount',
+        [other, GROUPS.ADMIN],
+      );
+    });
+  });
+
+  describe('Calling restricted & unrestricted functions', function () {
+    const product = (...arrays) => arrays.reduce((a, b) => a.flatMap(ai => b.map(bi => [ai, bi].flat())));
+
+    for (const [callerOpt, targetOpt] of product(
+      [
+        { groups: [] },
+        { groups: [GROUPS.SOME] },
+        { groups: [GROUPS.SOME], delay: executeDelay },
+        { groups: [GROUPS.SOME, GROUPS.PUBLIC], delay: executeDelay },
+      ],
+      [
+        { mode: AccessMode.Open },
+        { mode: AccessMode.Closed },
+        { mode: AccessMode.Custom, group: GROUPS.ADMIN },
+        { mode: AccessMode.Custom, group: GROUPS.SOME },
+        { mode: AccessMode.Custom, group: GROUPS.PUBLIC },
+      ],
+    )) {
+      const public =
+        targetOpt.mode == AccessMode.Open || (targetOpt.mode == AccessMode.Custom && targetOpt.group == GROUPS.PUBLIC);
+
+      // can we call with a delay ?
+      const indirectSuccess =
+        public || (targetOpt.mode == AccessMode.Custom && callerOpt.groups?.includes(targetOpt.group));
+
+      // can we call without a delay ?
+      const directSuccess =
+        public ||
+        (targetOpt.mode == AccessMode.Custom && callerOpt.groups?.includes(targetOpt.group) && !callerOpt.delay);
+
+      const description = [
+        'Caller in groups',
+        '[' + (callerOpt.groups ?? []).map(groupId => GROUPS[groupId]).join(', ') + ']',
+        callerOpt.delay ? 'with a delay' : 'without a delay',
+        '+',
+        'contract in mode',
+        Object.keys(AccessMode)[targetOpt.mode.toNumber()],
+        targetOpt.mode == AccessMode.Custom ? `(${GROUPS[targetOpt.group]})` : '',
+      ].join(' ');
+
+      describe(description, function () {
+        beforeEach(async function () {
+          // setup
+          await Promise.all([
+            this.manager.$_setContractMode(this.target.address, targetOpt.mode),
+            targetOpt.group &&
+              this.manager.$_setFunctionAllowedGroup(this.target.address, selector('fnRestricted()'), targetOpt.group),
+            targetOpt.group &&
+              this.manager.$_setFunctionAllowedGroup(
+                this.target.address,
+                selector('fnUnrestricted()'),
+                targetOpt.group,
+              ),
+            ...(callerOpt.groups ?? [])
+              .filter(groupId => groupId != GROUPS.PUBLIC)
+              .map(groupId => this.manager.$_grantGroup(groupId, user, 0, callerOpt.delay ?? 0)),
+          ]);
+
+          // post setup checks
+          expect(await this.manager.getContractMode(this.target.address)).to.be.bignumber.equal(targetOpt.mode);
+          if (targetOpt.group) {
+            expect(
+              await this.manager.getFunctionAllowedGroup(this.target.address, selector('fnRestricted()')),
+            ).to.be.bignumber.equal(targetOpt.group);
+            expect(
+              await this.manager.getFunctionAllowedGroup(this.target.address, selector('fnUnrestricted()')),
+            ).to.be.bignumber.equal(targetOpt.group);
+          }
+          for (const groupId of callerOpt.groups ?? []) {
+            const access = await this.manager.getAccess(groupId, user);
+            if (groupId == GROUPS.PUBLIC) {
+              expect(access.since).to.be.bignumber.eq('0');
+              expect(access.delay).to.be.bignumber.eq('0');
+            } else {
+              expect(access.since).to.be.bignumber.gt('0');
+              expect(access.delay).to.be.bignumber.eq(String(callerOpt.delay ?? 0));
+            }
+          }
+        });
+
+        it('canCall', async function () {
+          const result = await this.manager.canCall(user, this.target.address, selector('fnRestricted()'));
+          expect(result[0]).to.be.equal(directSuccess);
+          expect(result[1]).to.be.bignumber.equal(!directSuccess && indirectSuccess ? callerOpt.delay ?? '0' : '0');
+        });
+
+        it('Calling a non restricted function never revert', async function () {
+          expectEvent(await this.target.fnUnrestricted({ from: user }), 'CalledUnrestricted', {
+            caller: user,
+          });
+        });
+
+        it(`Calling a restricted function directly should ${directSuccess ? 'succeed' : 'revert'}`, async function () {
+          const promise = this.target.fnRestricted({ from: user });
+
+          if (directSuccess) {
+            expectEvent(await promise, 'CalledRestricted', { caller: user });
+          } else {
+            await expectRevertCustomError(promise, 'AccessManagedUnauthorized', [user]);
+          }
+        });
+
+        it('Calling indirectly: only relay', async function () {
+          // relay without schedule
+          if (directSuccess) {
+            const { receipt, tx } = await this.relay();
+            expectEvent.notEmitted(receipt, 'Executed', { operationId: this.opId });
+            expectEvent.inTransaction(tx, this.target, 'Calledrestricted', { caller: this.manager.address });
+          } else if (indirectSuccess) {
+            await expectRevertCustomError(this.relay(), 'AccessManagerNotScheduled', [this.opId]);
+          } else {
+            await expectRevertCustomError(this.relay(), 'AccessManagerUnauthorizedCall', [user, ...this.call]);
+          }
+        });
+
+        it('Calling indirectly: schedule and relay', async function () {
+          if (directSuccess || indirectSuccess) {
+            const { receipt } = await this.schedule();
+            const timestamp = await clockFromReceipt.timestamp(receipt).then(web3.utils.toBN);
+
+            expectEvent(receipt, 'Scheduled', {
+              operationId: this.opId,
+              caller: user,
+              target: this.call[0],
+              data: this.call[1],
+            });
+
+            // if can call directly, delay should be 0. Otherwize, the delay should be applied
+            expect(await this.manager.getSchedule(this.opId)).to.be.bignumber.equal(
+              timestamp.add(directSuccess ? web3.utils.toBN(0) : callerOpt.delay),
+            );
+
+            // execute without wait
+            if (directSuccess) {
+              const { receipt, tx } = await this.relay();
+              expectEvent(receipt, 'Executed', { operationId: this.opId });
+              expectEvent.inTransaction(tx, this.target, 'Calledrestricted', { caller: this.manager.address });
+
+              expect(await this.manager.getSchedule(this.opId)).to.be.bignumber.equal('0');
+            } else if (indirectSuccess) {
+              await expectRevertCustomError(this.relay(), 'AccessManagerNotReady', [this.opId]);
+            } else {
+              await expectRevertCustomError(this.relay(), 'AccessManagerUnauthorizedCall', [user, ...this.call]);
+            }
+          } else {
+            await expectRevertCustomError(this.schedule(), 'AccessManagerUnauthorizedCall', [user, ...this.call]);
+          }
+        });
+
+        it('Calling indirectly: schedule wait and relay', async function () {
+          if (directSuccess || indirectSuccess) {
+            const { receipt } = await this.schedule();
+            const timestamp = await clockFromReceipt.timestamp(receipt).then(web3.utils.toBN);
+
+            expectEvent(receipt, 'Scheduled', {
+              operationId: this.opId,
+              caller: user,
+              target: this.call[0],
+              data: this.call[1],
+            });
+
+            // if can call directly, delay should be 0. Otherwize, the delay should be applied
+            expect(await this.manager.getSchedule(this.opId)).to.be.bignumber.equal(
+              timestamp.add(directSuccess ? web3.utils.toBN(0) : callerOpt.delay),
+            );
+
+            // wait
+            await time.increase(callerOpt.delay ?? 0);
+
+            // execute without wait
+            if (directSuccess || indirectSuccess) {
+              const { receipt, tx } = await this.relay();
+              expectEvent(receipt, 'Executed', { operationId: this.opId });
+              expectEvent.inTransaction(tx, this.target, 'Calledrestricted', { caller: this.manager.address });
+
+              expect(await this.manager.getSchedule(this.opId)).to.be.bignumber.equal('0');
+            } else {
+              await expectRevertCustomError(this.relay(), 'AccessManagerUnauthorizedCall', [user, ...this.call]);
+            }
+          } else {
+            await expectRevertCustomError(this.schedule(), 'AccessManagerUnauthorizedCall', [user, ...this.call]);
+          }
+        });
+      });
+    }
+  });
+
+  describe('Indirect execution corner-cases', async function () {
+    beforeEach(async function () {
+      await this.manager.$_setFunctionAllowedGroup(...this.call, GROUPS.SOME);
+      await this.manager.$_grantGroup(GROUPS.SOME, user, 0, executeDelay);
+    });
+
+    it('Checking canCall when caller is the manager depend on the _relayIdentifier', async function () {
+      expect(await this.manager.getContractMode(this.target.address)).to.be.bignumber.equal(AccessMode.Custom);
+
+      const result = await this.manager.canCall(this.manager.address, this.target.address, '0x00000000');
+      expect(result[0]).to.be.false;
+      expect(result[1]).to.be.bignumber.equal('0');
+    });
+
+    it('Cannot execute earlier', async function () {
+      const { receipt } = await this.schedule();
+      const timestamp = await clockFromReceipt.timestamp(receipt).then(web3.utils.toBN);
+
+      expect(await this.manager.getSchedule(this.opId)).to.be.bignumber.equal(timestamp.add(executeDelay));
+
+      // we need to set the clock 2 seconds before the value, because the increaseTo "consumes" the timestamp
+      // and the next transaction will be one after that (see check bellow)
+      await time.increaseTo(timestamp.add(executeDelay).subn(2));
+
+      // too early
+      await expectRevertCustomError(this.relay(), 'AccessManagerNotReady', [this.opId]);
+
+      // the revert happened one second before the execution delay expired
+      expect(await time.latest()).to.be.bignumber.equal(timestamp.add(executeDelay).subn(1));
+
+      // ok
+      await this.relay();
+
+      // the success happened when the delay was reached (earliest possible)
+      expect(await time.latest()).to.be.bignumber.equal(timestamp.add(executeDelay));
+    });
+
+    it('Cannot schedule an already scheduled operation', async function () {
+      const { receipt } = await this.schedule();
+      expectEvent(receipt, 'Scheduled', {
+        operationId: this.opId,
+        caller: user,
+        target: this.call[0],
+        data: this.call[1],
+      });
+
+      await expectRevertCustomError(this.schedule(), 'AccessManagerAlreadyScheduled', [this.opId]);
+    });
+
+    it('Cannot cancel an operation that is not scheduled', async function () {
+      await expectRevertCustomError(this.cancel(), 'AccessManagerNotScheduled', [this.opId]);
+    });
+
+    it('Cannot cancel an operation that is not already relayed', async function () {
+      await this.schedule();
+      await time.increase(executeDelay);
+      await this.relay();
+
+      await expectRevertCustomError(this.cancel(), 'AccessManagerNotScheduled', [this.opId]);
+    });
+
+    it('Scheduler can cancel', async function () {
+      await this.schedule();
+
+      expect(await this.manager.getSchedule(this.opId)).to.not.be.bignumber.equal('0');
+
+      expectEvent(await this.cancel({ from: manager }), 'Canceled', { operationId: this.opId });
+
+      expect(await this.manager.getSchedule(this.opId)).to.be.bignumber.equal('0');
+    });
+
+    it('Guardian can cancel', async function () {
+      await this.schedule();
+
+      expect(await this.manager.getSchedule(this.opId)).to.not.be.bignumber.equal('0');
+
+      expectEvent(await this.cancel({ from: manager }), 'Canceled', { operationId: this.opId });
+
+      expect(await this.manager.getSchedule(this.opId)).to.be.bignumber.equal('0');
+    });
+
+    it('Cancel is restricted', async function () {
+      await this.schedule();
+
+      expect(await this.manager.getSchedule(this.opId)).to.not.be.bignumber.equal('0');
+
+      await expectRevertCustomError(this.cancel({ from: other }), 'AccessManagerCannotCancel', [
+        other,
+        user,
+        ...this.call,
+      ]);
+
+      expect(await this.manager.getSchedule(this.opId)).to.not.be.bignumber.equal('0');
+    });
+
+    it('Can re-schedule after execution', async function () {
+      await this.schedule();
+      await time.increase(executeDelay);
+      await this.relay();
+
+      // reschedule
+      const { receipt } = await this.schedule();
+      expectEvent(receipt, 'Scheduled', {
+        operationId: this.opId,
+        caller: user,
+        target: this.call[0],
+        data: this.call[1],
+      });
+    });
+
+    it('Can re-schedule after cancel', async function () {
+      await this.schedule();
+      await this.cancel();
+
+      // reschedule
+      const { receipt } = await this.schedule();
+      expectEvent(receipt, 'Scheduled', {
+        operationId: this.opId,
+        caller: user,
+        target: this.call[0],
+        data: this.call[1],
+      });
+    });
+  });
+
+  describe('authority update', function () {
+    beforeEach(async function () {
+      this.newManager = await AccessManager.new(admin);
+    });
+
+    it('admin can change authority', async function () {
+      expect(await this.target.authority()).to.be.equal(this.manager.address);
+
+      const { tx } = await this.manager.updateAuthority(this.target.address, this.newManager.address, { from: admin });
+      expectEvent.inTransaction(tx, this.target, 'AuthorityUpdated', { authority: this.newManager.address });
+
+      expect(await this.target.authority()).to.be.equal(this.newManager.address);
+    });
+
+    it('cannot set an address without code as the authority', async function () {
+      await expectRevertCustomError(
+        this.manager.updateAuthority(this.target.address, user, { from: admin }),
+        'AccessManagedInvalidAuthority',
+        [user],
+      );
+    });
+
+    it('updateAuthority is restricted on manager', async function () {
+      await expectRevertCustomError(
+        this.manager.updateAuthority(this.target.address, this.newManager.address, { from: other }),
+        'AccessControlUnauthorizedAccount',
+        [other, GROUPS.ADMIN],
+      );
+    });
+
+    it('setAuthority is restricted on AccessManaged', async function () {
+      await expectRevertCustomError(
+        this.target.setAuthority(this.newManager.address, { from: admin }),
+        'AccessManagedUnauthorized',
+        [admin],
+      );
+    });
+  });
+});

+ 158 - 0
test/access/manager/utils/AccessManagedAdapter.test.js

@@ -0,0 +1,158 @@
+const { constants, time } = require('@openzeppelin/test-helpers');
+const { expectRevertCustomError } = require('../../../helpers/customError');
+const { AccessMode } = require('../../../helpers/enums');
+const { selector } = require('../../../helpers/methods');
+
+const AccessManager = artifacts.require('$AccessManager');
+const AccessManagedAdapter = artifacts.require('AccessManagedAdapter');
+const Ownable = artifacts.require('$Ownable');
+
+const groupId = web3.utils.toBN(1);
+
+contract('AccessManagedAdapter', function (accounts) {
+  const [admin, user, other] = accounts;
+
+  beforeEach(async function () {
+    this.manager = await AccessManager.new(admin);
+    this.adapter = await AccessManagedAdapter.new(this.manager.address);
+    this.ownable = await Ownable.new(this.adapter.address);
+
+    // add user to group
+    await this.manager.$_grantGroup(groupId, user, 0, 0);
+  });
+
+  it('initial state', async function () {
+    expect(await this.adapter.authority()).to.be.equal(this.manager.address);
+    expect(await this.ownable.owner()).to.be.equal(this.adapter.address);
+  });
+
+  describe('Contract is Closed', function () {
+    beforeEach(async function () {
+      await this.manager.$_setContractMode(this.ownable.address, AccessMode.Closed);
+    });
+
+    it('directly call: reverts', async function () {
+      await expectRevertCustomError(this.ownable.$_checkOwner({ from: user }), 'OwnableUnauthorizedAccount', [user]);
+    });
+
+    it('relayed call (with group): reverts', async function () {
+      await expectRevertCustomError(
+        this.adapter.relay(this.ownable.address, selector('$_checkOwner()'), { from: user }),
+        'AccessManagedUnauthorized',
+        [user],
+      );
+    });
+
+    it('relayed call (without group): reverts', async function () {
+      await expectRevertCustomError(
+        this.adapter.relay(this.ownable.address, selector('$_checkOwner()'), { from: other }),
+        'AccessManagedUnauthorized',
+        [other],
+      );
+    });
+  });
+
+  describe('Contract is Open', function () {
+    beforeEach(async function () {
+      await this.manager.$_setContractMode(this.ownable.address, AccessMode.Open);
+    });
+
+    it('directly call: reverts', async function () {
+      await expectRevertCustomError(this.ownable.$_checkOwner({ from: user }), 'OwnableUnauthorizedAccount', [user]);
+    });
+
+    it('relayed call (with group): success', async function () {
+      await this.adapter.relay(this.ownable.address, selector('$_checkOwner()'), { from: user });
+    });
+
+    it('relayed call (without group): success', async function () {
+      await this.adapter.relay(this.ownable.address, selector('$_checkOwner()'), { from: other });
+    });
+  });
+
+  describe('Contract is in Custom mode', function () {
+    beforeEach(async function () {
+      await this.manager.$_setContractMode(this.ownable.address, AccessMode.Custom);
+    });
+
+    describe('function is open to specific group', function () {
+      beforeEach(async function () {
+        await this.manager.$_setFunctionAllowedGroup(this.ownable.address, selector('$_checkOwner()'), groupId);
+      });
+
+      it('directly call: reverts', async function () {
+        await expectRevertCustomError(this.ownable.$_checkOwner({ from: user }), 'OwnableUnauthorizedAccount', [user]);
+      });
+
+      it('relayed call (with group): success', async function () {
+        await this.adapter.relay(this.ownable.address, selector('$_checkOwner()'), { from: user });
+      });
+
+      it('relayed call (without group): reverts', async function () {
+        await expectRevertCustomError(
+          this.adapter.relay(this.ownable.address, selector('$_checkOwner()'), { from: other }),
+          'AccessManagedUnauthorized',
+          [other],
+        );
+      });
+    });
+
+    describe('function is open to public group', function () {
+      beforeEach(async function () {
+        await this.manager.$_setFunctionAllowedGroup(
+          this.ownable.address,
+          selector('$_checkOwner()'),
+          constants.MAX_UINT256,
+        );
+      });
+
+      it('directly call: reverts', async function () {
+        await expectRevertCustomError(this.ownable.$_checkOwner({ from: user }), 'OwnableUnauthorizedAccount', [user]);
+      });
+
+      it('relayed call (with group): success', async function () {
+        await this.adapter.relay(this.ownable.address, selector('$_checkOwner()'), { from: user });
+      });
+
+      it('relayed call (without group): success', async function () {
+        await this.adapter.relay(this.ownable.address, selector('$_checkOwner()'), { from: other });
+      });
+    });
+
+    describe('function is available with execution delay', function () {
+      const delay = 10;
+
+      beforeEach(async function () {
+        await this.manager.$_setExecuteDelay(groupId, user, delay);
+        await this.manager.$_setFunctionAllowedGroup(this.ownable.address, selector('$_checkOwner()'), groupId);
+      });
+
+      it('unscheduled call reverts', async function () {
+        await expectRevertCustomError(
+          this.adapter.relay(this.ownable.address, selector('$_checkOwner()'), { from: user }),
+          'AccessManagedRequiredDelay',
+          [user, delay],
+        );
+      });
+
+      it('scheduled call succeeds', async function () {
+        await this.manager.schedule(this.ownable.address, selector('$_checkOwner()'), { from: user });
+        await time.increase(delay);
+        await this.manager.relayViaAdapter(this.ownable.address, selector('$_checkOwner()'), this.adapter.address, {
+          from: user,
+        });
+      });
+    });
+  });
+
+  it('bubble revert reasons', async function () {
+    const { address } = await Ownable.new(admin);
+    await this.manager.$_setContractMode(address, AccessMode.Open);
+
+    await expectRevertCustomError(
+      this.adapter.relay(address, selector('$_checkOwner()')),
+      'OwnableUnauthorizedAccount',
+      [this.adapter.address],
+    );
+  });
+});

+ 1 - 0
test/helpers/enums.js

@@ -8,4 +8,5 @@ module.exports = {
   VoteType: Enum('Against', 'For', 'Abstain'),
   Rounding: Enum('Floor', 'Ceil', 'Trunc', 'Expand'),
   OperationState: Enum('Unset', 'Waiting', 'Ready', 'Done'),
+  AccessMode: Enum('Custom', 'Closed', 'Open'),
 };

+ 5 - 0
test/helpers/methods.js

@@ -0,0 +1,5 @@
+const { soliditySha3 } = require('web3-utils');
+
+module.exports = {
+  selector: signature => soliditySha3(signature).substring(0, 10),
+};