diff --git a/src/access/AccessControlTemporal/AccessControlTemporalFacet.sol b/src/access/AccessControlTemporal/AccessControlTemporalFacet.sol new file mode 100644 index 00000000..29a90c13 --- /dev/null +++ b/src/access/AccessControlTemporal/AccessControlTemporalFacet.sol @@ -0,0 +1,178 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +contract AccessControlTemporalFacet { + /// @notice Event emitted when a role is granted with an expiry timestamp. + /// @param _role The role that was granted. + /// @param _account The account that was granted the role. + /// @param _expiresAt The timestamp when the role expires. + /// @param _sender The account that granted the role. + event RoleGrantedWithExpiry( + bytes32 indexed _role, address indexed _account, uint256 _expiresAt, address indexed _sender + ); + + /// @notice Event emitted when a temporal role is revoked. + /// @param _role The role that was revoked. + /// @param _account The account from which the role was revoked. + /// @param _sender The account that revoked the role. + event TemporalRoleRevoked(bytes32 indexed _role, address indexed _account, address indexed _sender); + + /// @notice Thrown when the account does not have a specific role. + /// @param _role The role that the account does not have. + /// @param _account The account that does not have the role. + error AccessControlUnauthorizedAccount(address _account, bytes32 _role); + + /// @notice Thrown when a role has expired. + /// @param _role The role that has expired. + /// @param _account The account whose role has expired. + error AccessControlRoleExpired(bytes32 _role, address _account); + + /// @notice Storage slot identifier for AccessControl (reused to access roles). + bytes32 constant ACCESS_CONTROL_STORAGE_POSITION = keccak256("compose.accesscontrol"); + + /// @notice Storage slot identifier for Temporal functionality. + bytes32 constant TEMPORAL_STORAGE_POSITION = keccak256("compose.accesscontrol.temporal"); + + /// @notice Storage struct for AccessControl (reused struct definition). + /// @dev Must match the struct definition in AccessControlFacet. + /// @custom:storage-location erc8042:compose.accesscontrol + struct AccessControlStorage { + mapping(address account => mapping(bytes32 role => bool hasRole)) hasRole; + mapping(bytes32 role => bytes32 adminRole) adminRole; + } + + /// @notice Storage struct for AccessControlTemporal. + /// @custom:storage-location erc8042:compose.accesscontrol.temporal + struct AccessControlTemporalStorage { + mapping(address account => mapping(bytes32 role => uint256 expiryTimestamp)) roleExpiry; + } + + /// @notice Returns the storage for AccessControl. + /// @return s The AccessControl storage struct. + function getAccessControlStorage() internal pure returns (AccessControlStorage storage s) { + bytes32 position = ACCESS_CONTROL_STORAGE_POSITION; + assembly { + s.slot := position + } + } + + /// @notice Returns the storage for AccessControlTemporal. + /// @return s The AccessControlTemporal storage struct. + function getStorage() internal pure returns (AccessControlTemporalStorage storage s) { + bytes32 position = TEMPORAL_STORAGE_POSITION; + assembly { + s.slot := position + } + } + + /// @notice Returns the expiry timestamp for a role assignment. + /// @param _role The role to check. + /// @param _account The account to check. + /// @return The expiry timestamp, or 0 if no expiry is set. + function getRoleExpiry(bytes32 _role, address _account) external view returns (uint256) { + return getStorage().roleExpiry[_account][_role]; + } + + /// @notice Checks if a role assignment has expired. + /// @param _role The role to check. + /// @param _account The account to check. + /// @return True if the role has expired or doesn't exist, false if still valid. + function isRoleExpired(bytes32 _role, address _account) external view returns (bool) { + AccessControlStorage storage acs = getAccessControlStorage(); + AccessControlTemporalStorage storage s = getStorage(); + uint256 expiry = s.roleExpiry[_account][_role]; + + // If no expiry set (0), role is valid if account has it + if (expiry == 0) { + return !acs.hasRole[_account][_role]; + } + + // Role is expired if current time is past expiry + return block.timestamp >= expiry; + } + + /// @notice Grants a role to an account with an expiry timestamp. + /// @param _role The role to grant. + /// @param _account The account to grant the role to. + /// @param _expiresAt The timestamp when the role should expire (must be in the future). + /// @dev Only the admin of the role can grant it with expiry. + /// Emits a {RoleGrantedWithExpiry} event. + /// @custom:error AccessControlUnauthorizedAccount If the caller is not the admin of the role. + function grantRoleWithExpiry(bytes32 _role, address _account, uint256 _expiresAt) external { + AccessControlStorage storage acs = getAccessControlStorage(); + AccessControlTemporalStorage storage s = getStorage(); + bytes32 adminRole = acs.adminRole[_role]; + + // Check if the caller is the admin of the role. + if (!acs.hasRole[msg.sender][adminRole]) { + revert AccessControlUnauthorizedAccount(msg.sender, adminRole); + } + + // Require expiry is in the future + if (_expiresAt <= block.timestamp) { + revert AccessControlRoleExpired(_role, _account); + } + + // Grant the role + bool _hasRole = acs.hasRole[_account][_role]; + if (!_hasRole) { + acs.hasRole[_account][_role] = true; + } + + // Set expiry timestamp + s.roleExpiry[_account][_role] = _expiresAt; + emit RoleGrantedWithExpiry(_role, _account, _expiresAt, msg.sender); + } + + /// @notice Revokes a temporal role from an account. + /// @param _role The role to revoke. + /// @param _account The account to revoke the role from. + /// @dev Only the admin of the role can revoke it. + /// Emits a {TemporalRoleRevoked} event. + /// @custom:error AccessControlUnauthorizedAccount If the caller is not the admin of the role. + function revokeTemporalRole(bytes32 _role, address _account) external { + AccessControlStorage storage acs = getAccessControlStorage(); + AccessControlTemporalStorage storage s = getStorage(); + bytes32 adminRole = acs.adminRole[_role]; + + // Check if the caller is the admin of the role. + if (!acs.hasRole[msg.sender][adminRole]) { + revert AccessControlUnauthorizedAccount(msg.sender, adminRole); + } + + // Revoke the role + bool _hasRole = acs.hasRole[_account][_role]; + + // Only revoke if the role is currently granted + if (_hasRole) { + // Revoke the role from AccessControl storage + acs.hasRole[_account][_role] = false; + + // Clear expiry timestamp + s.roleExpiry[_account][_role] = 0; + + emit TemporalRoleRevoked(_role, _account, msg.sender); + } + } + + /// @notice Checks if an account has a valid (non-expired) role. + /// @param _role The role to check. + /// @param _account The account to check the role for. + /// @custom:error AccessControlUnauthorizedAccount If the account does not have the role. + /// @custom:error AccessControlRoleExpired If the role has expired. + function requireValidRole(bytes32 _role, address _account) external view { + AccessControlStorage storage acs = getAccessControlStorage(); + AccessControlTemporalStorage storage s = getStorage(); + + // Check if account has the role + if (!acs.hasRole[_account][_role]) { + revert AccessControlUnauthorizedAccount(_account, _role); + } + + // Check if role has expired + uint256 expiry = s.roleExpiry[_account][_role]; + if (expiry > 0 && block.timestamp >= expiry) { + revert AccessControlRoleExpired(_role, _account); + } + } +} diff --git a/src/access/AccessControlTemporal/LibAccessControlTemporal.sol b/src/access/AccessControlTemporal/LibAccessControlTemporal.sol new file mode 100644 index 00000000..6e577277 --- /dev/null +++ b/src/access/AccessControlTemporal/LibAccessControlTemporal.sol @@ -0,0 +1,160 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +library LibAccessControlTemporal { + /// @notice Event emitted when a role is granted with an expiry timestamp. + /// @param _role The role that was granted. + /// @param _account The account that was granted the role. + /// @param _expiresAt The timestamp when the role expires. + /// @param _sender The account that granted the role. + event RoleGrantedWithExpiry( + bytes32 indexed _role, address indexed _account, uint256 _expiresAt, address indexed _sender + ); + + /// @notice Event emitted when a temporal role is revoked. + /// @param _role The role that was revoked. + /// @param _account The account from which the role was revoked. + /// @param _sender The account that revoked the role. + event TemporalRoleRevoked(bytes32 indexed _role, address indexed _account, address indexed _sender); + + /// @notice Thrown when the account does not have a specific role. + /// @param _role The role that the account does not have. + /// @param _account The account that does not have the role. + error AccessControlUnauthorizedAccount(address _account, bytes32 _role); + + /// @notice Thrown when a role has expired. + /// @param _role The role that has expired. + /// @param _account The account whose role has expired. + error AccessControlRoleExpired(bytes32 _role, address _account); + + /// @notice Storage slot identifier for AccessControl (reused to access roles). + bytes32 constant ACCESS_CONTROL_STORAGE_POSITION = keccak256("compose.accesscontrol"); + + /// @notice Storage slot identifier for Temporal functionality. + bytes32 constant TEMPORAL_STORAGE_POSITION = keccak256("compose.accesscontrol.temporal"); + + /// @notice Storage struct for AccessControl (reused struct definition). + /// @dev Must match the struct definition in AccessControlFacet. + /// @custom:storage-location erc8042:compose.accesscontrol + struct AccessControlStorage { + mapping(address account => mapping(bytes32 role => bool hasRole)) hasRole; + mapping(bytes32 role => bytes32 adminRole) adminRole; + } + + /// @notice Storage struct for AccessControlTemporal. + /// @custom:storage-location erc8042:compose.accesscontrol.temporal + struct AccessControlTemporalStorage { + mapping(address account => mapping(bytes32 role => uint256 expiryTimestamp)) roleExpiry; + } + + /// @notice Returns the storage for AccessControl. + /// @return s The AccessControl storage struct. + function getAccessControlStorage() internal pure returns (AccessControlStorage storage s) { + bytes32 position = ACCESS_CONTROL_STORAGE_POSITION; + assembly { + s.slot := position + } + } + + /// @notice Returns the storage for AccessControlTemporal. + /// @return s The AccessControlTemporal storage struct. + function getStorage() internal pure returns (AccessControlTemporalStorage storage s) { + bytes32 position = TEMPORAL_STORAGE_POSITION; + assembly { + s.slot := position + } + } + + /// @notice function to get the expiry timestamp for a role assignment. + /// @param _role The role to check. + /// @param _account The account to check. + /// @return The expiry timestamp, or 0 if no expiry is set. + function getRoleExpiry(bytes32 _role, address _account) internal view returns (uint256) { + AccessControlTemporalStorage storage s = getStorage(); + return s.roleExpiry[_account][_role]; + } + + /// @notice function to check if a role assignment has expired. + /// @param _role The role to check. + /// @param _account The account to check. + /// @return True if the role has expired or doesn't exist, false if still valid. + function isRoleExpired(bytes32 _role, address _account) internal view returns (bool) { + AccessControlStorage storage acs = getAccessControlStorage(); + AccessControlTemporalStorage storage s = getStorage(); + uint256 expiry = s.roleExpiry[_account][_role]; + + // If no expiry set (0), role is valid if account has it + if (expiry == 0) { + return !acs.hasRole[_account][_role]; + } + + // Role is expired if current time is past expiry + return block.timestamp >= expiry; + } + + /// @notice function to grant a role with an expiry timestamp. + /// @param _role The role to grant. + /// @param _account The account to grant the role to. + /// @param _expiresAt The timestamp when the role should expire. + /// @return True if the role was granted, false otherwise. + function grantRoleWithExpiry(bytes32 _role, address _account, uint256 _expiresAt) internal returns (bool) { + AccessControlStorage storage acs = getAccessControlStorage(); + AccessControlTemporalStorage storage s = getStorage(); + + // Grant the role + bool _hasRole = acs.hasRole[_account][_role]; + if (!_hasRole) { + acs.hasRole[_account][_role] = true; + } + + // Set expiry timestamp + s.roleExpiry[_account][_role] = _expiresAt; + emit RoleGrantedWithExpiry(_role, _account, _expiresAt, msg.sender); + + return true; + } + + /// @notice function to revoke a temporal role. + /// @param _role The role to revoke. + /// @param _account The account to revoke the role from. + /// @return True if the role was revoked, false otherwise. + function revokeTemporalRole(bytes32 _role, address _account) internal returns (bool) { + AccessControlStorage storage acs = getAccessControlStorage(); + AccessControlTemporalStorage storage s = getStorage(); + + bool _hasRole = acs.hasRole[_account][_role]; + if (!_hasRole) { + return false; + } + + acs.hasRole[_account][_role] = false; + + // Clear expiry timestamp only when the role existed + s.roleExpiry[_account][_role] = 0; + + emit TemporalRoleRevoked(_role, _account, msg.sender); + + return true; + } + + /// @notice function to check if an account has a valid (non-expired) role. + /// @param _role The role to check. + /// @param _account The account to check the role for. + /// @custom:error AccessControlUnauthorizedAccount If the account does not have the role. + /// @custom:error AccessControlRoleExpired If the role has expired. + function requireValidRole(bytes32 _role, address _account) internal view { + AccessControlStorage storage acs = getAccessControlStorage(); + AccessControlTemporalStorage storage s = getStorage(); + + // Check if account has the role + if (!acs.hasRole[_account][_role]) { + revert AccessControlUnauthorizedAccount(_account, _role); + } + + // Check if role has expired + uint256 expiry = s.roleExpiry[_account][_role]; + if (expiry > 0 && block.timestamp >= expiry) { + revert AccessControlRoleExpired(_role, _account); + } + } +} diff --git a/test/access/AccessControlTemporal/AccessControlTemporalFacet.t.sol b/test/access/AccessControlTemporal/AccessControlTemporalFacet.t.sol new file mode 100644 index 00000000..a8fef112 --- /dev/null +++ b/test/access/AccessControlTemporal/AccessControlTemporalFacet.t.sol @@ -0,0 +1,319 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +import {Test, console2} from "forge-std/Test.sol"; +import {AccessControlTemporalFacet} from "../../../src/access/AccessControlTemporal/AccessControlTemporalFacet.sol"; +import {AccessControlFacet} from "../../../src/access/AccessControl/AccessControlFacet.sol"; +import {AccessControlTemporalFacetHarness} from "./harnesses/AccessControlTemporalFacetHarness.sol"; +import {AccessControlFacetHarness} from "../AccessControl/harnesses/AccessControlFacetHarness.sol"; + +contract AccessControlTemporalFacetTest is Test { + AccessControlTemporalFacetHarness public temporalFacet; + AccessControlFacetHarness public accessControl; + + // Test addresses + address ADMIN = makeAddr("admin"); + address ALICE = makeAddr("alice"); + address BOB = makeAddr("bob"); + + // Test roles + bytes32 constant DEFAULT_ADMIN_ROLE = 0x00; + bytes32 constant MINTER_ROLE = keccak256("MINTER_ROLE"); + + // Events + event RoleGrantedWithExpiry( + bytes32 indexed role, address indexed account, uint256 expiresAt, address indexed sender + ); + event TemporalRoleRevoked(bytes32 indexed role, address indexed account, address indexed sender); + + function setUp() public { + // Initialize AccessControl first (shared storage) + accessControl = new AccessControlFacetHarness(); + accessControl.initialize(ADMIN); + + // Initialize TemporalFacet (uses same AccessControl storage) + temporalFacet = new AccessControlTemporalFacetHarness(); + temporalFacet.initialize(ADMIN); + } + + // ============================================ + // GetRoleExpiry Tests + // ============================================ + + function test_GetRoleExpiry_ReturnsZeroForNonExistentRole() public view { + assertEq(temporalFacet.getRoleExpiry(MINTER_ROLE, ALICE), 0); + } + + function test_GetRoleExpiry_ReturnsExpiryWhenSet() public { + uint256 expiry = block.timestamp + 7 days; + + vm.prank(ADMIN); + temporalFacet.grantRoleWithExpiry(MINTER_ROLE, ALICE, expiry); + + assertEq(temporalFacet.getRoleExpiry(MINTER_ROLE, ALICE), expiry); + assertEq(temporalFacet.getStorageExpiry(ALICE, MINTER_ROLE), expiry); + } + + // ============================================ + // IsRoleExpired Tests + // ============================================ + + function test_IsRoleExpired_ReturnsTrueForNoRole() public view { + assertTrue(temporalFacet.isRoleExpired(MINTER_ROLE, ALICE)); + } + + function test_IsRoleExpired_ReturnsFalseForFutureExpiry() public { + uint256 expiry = block.timestamp + 7 days; + + vm.prank(ADMIN); + temporalFacet.grantRoleWithExpiry(MINTER_ROLE, ALICE, expiry); + + assertFalse(temporalFacet.isRoleExpired(MINTER_ROLE, ALICE)); + } + + function test_IsRoleExpired_ReturnsTrueForPastExpiry() public { + uint256 expiry = block.timestamp + 7 days; + + vm.prank(ADMIN); + temporalFacet.grantRoleWithExpiry(MINTER_ROLE, ALICE, expiry); + + // Fast forward past expiry + vm.warp(expiry + 1); + + assertTrue(temporalFacet.isRoleExpired(MINTER_ROLE, ALICE)); + } + + function test_IsRoleExpired_ReturnsTrueAtExactExpiry() public { + uint256 expiry = block.timestamp + 7 days; + + vm.prank(ADMIN); + temporalFacet.grantRoleWithExpiry(MINTER_ROLE, ALICE, expiry); + + // Set time to exactly expiry + vm.warp(expiry); + + // At exact expiry time, role should be expired + assertTrue(temporalFacet.isRoleExpired(MINTER_ROLE, ALICE)); + } + + // ============================================ + // GrantRoleWithExpiry Tests + // ============================================ + + function test_GrantRoleWithExpiry_SucceedsWithFutureExpiry() public { + uint256 expiry = block.timestamp + 7 days; + + vm.expectEmit(true, true, true, true); + emit RoleGrantedWithExpiry(MINTER_ROLE, ALICE, expiry, ADMIN); + + vm.prank(ADMIN); + temporalFacet.grantRoleWithExpiry(MINTER_ROLE, ALICE, expiry); + + assertEq(temporalFacet.getRoleExpiry(MINTER_ROLE, ALICE), expiry); + } + + function test_GrantRoleWithExpiry_CanUpdateExpiry() public { + uint256 expiry1 = block.timestamp + 7 days; + uint256 expiry2 = block.timestamp + 14 days; + + vm.startPrank(ADMIN); + temporalFacet.grantRoleWithExpiry(MINTER_ROLE, ALICE, expiry1); + assertEq(temporalFacet.getRoleExpiry(MINTER_ROLE, ALICE), expiry1); + + temporalFacet.grantRoleWithExpiry(MINTER_ROLE, ALICE, expiry2); + assertEq(temporalFacet.getRoleExpiry(MINTER_ROLE, ALICE), expiry2); + vm.stopPrank(); + } + + function test_RevertWhen_GrantRoleWithExpiry_PastExpiry() public { + uint256 pastExpiry = block.timestamp - 1; + + vm.expectRevert( + abi.encodeWithSelector(AccessControlTemporalFacet.AccessControlRoleExpired.selector, MINTER_ROLE, ALICE) + ); + vm.prank(ADMIN); + temporalFacet.grantRoleWithExpiry(MINTER_ROLE, ALICE, pastExpiry); + } + + function test_RevertWhen_GrantRoleWithExpiry_CurrentTimestamp() public { + uint256 currentTime = block.timestamp; + + vm.expectRevert( + abi.encodeWithSelector(AccessControlTemporalFacet.AccessControlRoleExpired.selector, MINTER_ROLE, ALICE) + ); + vm.prank(ADMIN); + temporalFacet.grantRoleWithExpiry(MINTER_ROLE, ALICE, currentTime); + } + + function test_RevertWhen_GrantRoleWithExpiry_CallerIsNotAdmin() public { + uint256 expiry = block.timestamp + 7 days; + + vm.expectRevert( + abi.encodeWithSelector( + AccessControlTemporalFacet.AccessControlUnauthorizedAccount.selector, ALICE, DEFAULT_ADMIN_ROLE + ) + ); + vm.prank(ALICE); + temporalFacet.grantRoleWithExpiry(MINTER_ROLE, BOB, expiry); + } + + // ============================================ + // RevokeTemporalRole Tests + // ============================================ + + function test_RevokeTemporalRole_Succeeds() public { + uint256 expiry = block.timestamp + 7 days; + + vm.startPrank(ADMIN); + temporalFacet.grantRoleWithExpiry(MINTER_ROLE, ALICE, expiry); + assertEq(temporalFacet.getRoleExpiry(MINTER_ROLE, ALICE), expiry); + vm.stopPrank(); + + vm.expectEmit(true, true, true, true); + emit TemporalRoleRevoked(MINTER_ROLE, ALICE, ADMIN); + + vm.prank(ADMIN); + temporalFacet.revokeTemporalRole(MINTER_ROLE, ALICE); + + assertEq(temporalFacet.getRoleExpiry(MINTER_ROLE, ALICE), 0); + assertFalse(accessControl.hasRole(MINTER_ROLE, ALICE)); + } + + function test_RevokeTemporalRole_ClearExpiry() public { + uint256 expiry = block.timestamp + 7 days; + + vm.startPrank(ADMIN); + temporalFacet.grantRoleWithExpiry(MINTER_ROLE, ALICE, expiry); + temporalFacet.revokeTemporalRole(MINTER_ROLE, ALICE); + vm.stopPrank(); + + assertEq(temporalFacet.getRoleExpiry(MINTER_ROLE, ALICE), 0); + } + + function test_RevokeTemporalRole_NoExistingRole_DoesNothing() public { + vm.prank(ADMIN); + temporalFacet.revokeTemporalRole(MINTER_ROLE, ALICE); + + // Ensure no state changes + assertFalse(accessControl.hasRole(MINTER_ROLE, ALICE)); + assertEq(temporalFacet.getRoleExpiry(MINTER_ROLE, ALICE), 0); + } + + function test_RevertWhen_RevokeTemporalRole_CallerIsNotAdmin() public { + uint256 expiry = block.timestamp + 7 days; + + vm.prank(ADMIN); + temporalFacet.grantRoleWithExpiry(MINTER_ROLE, ALICE, expiry); + + vm.expectRevert( + abi.encodeWithSelector( + AccessControlTemporalFacet.AccessControlUnauthorizedAccount.selector, BOB, DEFAULT_ADMIN_ROLE + ) + ); + vm.prank(BOB); + temporalFacet.revokeTemporalRole(MINTER_ROLE, ALICE); + } + + // ============================================ + // RequireValidRole Tests + // ============================================ + + function test_RequireValidRole_PassesWithValidExpiry() public { + uint256 expiry = block.timestamp + 7 days; + + vm.prank(ADMIN); + temporalFacet.grantRoleWithExpiry(MINTER_ROLE, ALICE, expiry); + + // Should not revert + temporalFacet.requireValidRole(MINTER_ROLE, ALICE); + } + + function test_RequireValidRole_PassesWithoutExpiry() public { + // Grant role without expiry using harness (direct storage manipulation) + temporalFacet.forceGrantRole(MINTER_ROLE, ALICE); + + // Should not revert (no expiry set means valid) + temporalFacet.requireValidRole(MINTER_ROLE, ALICE); + } + + function test_RevertWhen_RequireValidRole_Expired() public { + uint256 expiry = block.timestamp + 7 days; + + vm.prank(ADMIN); + temporalFacet.grantRoleWithExpiry(MINTER_ROLE, ALICE, expiry); + + // Fast forward past expiry + vm.warp(expiry + 1); + + vm.expectRevert( + abi.encodeWithSelector(AccessControlTemporalFacet.AccessControlRoleExpired.selector, MINTER_ROLE, ALICE) + ); + temporalFacet.requireValidRole(MINTER_ROLE, ALICE); + } + + function test_RevertWhen_RequireValidRole_NoRole() public { + vm.expectRevert( + abi.encodeWithSelector( + AccessControlTemporalFacet.AccessControlUnauthorizedAccount.selector, ALICE, MINTER_ROLE + ) + ); + temporalFacet.requireValidRole(MINTER_ROLE, ALICE); + } + + function test_RequireValidRole_AfterRevoke() public { + uint256 expiry = block.timestamp + 7 days; + + vm.startPrank(ADMIN); + temporalFacet.grantRoleWithExpiry(MINTER_ROLE, ALICE, expiry); + temporalFacet.revokeTemporalRole(MINTER_ROLE, ALICE); + vm.stopPrank(); + + vm.expectRevert( + abi.encodeWithSelector( + AccessControlTemporalFacet.AccessControlUnauthorizedAccount.selector, ALICE, MINTER_ROLE + ) + ); + temporalFacet.requireValidRole(MINTER_ROLE, ALICE); + } + + // ============================================ + // Scenario Tests + // ============================================ + + function test_Scenario_TemporaryContractorAccess() public { + uint256 contractEnd = block.timestamp + 30 days; + + // Grant contractor role that expires in 30 days + vm.prank(ADMIN); + temporalFacet.grantRoleWithExpiry(MINTER_ROLE, ALICE, contractEnd); + + // Verify access works now + temporalFacet.requireValidRole(MINTER_ROLE, ALICE); + + // Fast forward past contract end + vm.warp(contractEnd + 1); + + // Access should be denied + vm.expectRevert( + abi.encodeWithSelector(AccessControlTemporalFacet.AccessControlRoleExpired.selector, MINTER_ROLE, ALICE) + ); + temporalFacet.requireValidRole(MINTER_ROLE, ALICE); + } + + function test_Scenario_ExtendExpiryBeforeExpiration() public { + uint256 initialExpiry = block.timestamp + 7 days; + uint256 newExpiry = block.timestamp + 14 days; + + vm.startPrank(ADMIN); + temporalFacet.grantRoleWithExpiry(MINTER_ROLE, ALICE, initialExpiry); + assertEq(temporalFacet.getRoleExpiry(MINTER_ROLE, ALICE), initialExpiry); + + // Extend before expiration + temporalFacet.grantRoleWithExpiry(MINTER_ROLE, ALICE, newExpiry); + assertEq(temporalFacet.getRoleExpiry(MINTER_ROLE, ALICE), newExpiry); + vm.stopPrank(); + + // Access should still work + temporalFacet.requireValidRole(MINTER_ROLE, ALICE); + } +} diff --git a/test/access/AccessControlTemporal/LibAccessControlTemporal.t.sol b/test/access/AccessControlTemporal/LibAccessControlTemporal.t.sol new file mode 100644 index 00000000..e690ea46 --- /dev/null +++ b/test/access/AccessControlTemporal/LibAccessControlTemporal.t.sol @@ -0,0 +1,276 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +import {Test, console2} from "forge-std/Test.sol"; +import {LibAccessControlTemporal} from "../../../src/access/AccessControlTemporal/LibAccessControlTemporal.sol"; +import {LibAccessControl} from "../../../src/access/AccessControl/LibAccessControl.sol"; +import {LibAccessControlTemporalHarness} from "./harnesses/LibAccessControlTemporalHarness.sol"; +import {LibAccessControlHarness} from "../AccessControl/harnesses/LibAccessControlHarness.sol"; + +contract LibAccessControlTemporalTest is Test { + LibAccessControlTemporalHarness public harness; + LibAccessControlHarness public accessControl; + + // Test addresses + address ADMIN = makeAddr("admin"); + address ALICE = makeAddr("alice"); + address BOB = makeAddr("bob"); + + // Test roles + bytes32 constant DEFAULT_ADMIN_ROLE = 0x00; + bytes32 constant MINTER_ROLE = keccak256("MINTER_ROLE"); + + // Events + event RoleGrantedWithExpiry( + bytes32 indexed role, address indexed account, uint256 expiresAt, address indexed sender + ); + + function setUp() public { + // Initialize AccessControl first (shared storage) + accessControl = new LibAccessControlHarness(); + accessControl.initialize(ADMIN); + + // Initialize Temporal harness (uses same AccessControl storage) + harness = new LibAccessControlTemporalHarness(); + harness.initialize(ADMIN); + } + + // ============================================ + // GetRoleExpiry Tests + // ============================================ + + function test_GetRoleExpiry_ReturnsZeroForNonExistentRole() public view { + assertEq(harness.getRoleExpiry(MINTER_ROLE, ALICE), 0); + } + + function test_GetRoleExpiry_ReturnsExpiryWhenSet() public { + uint256 expiry = block.timestamp + 7 days; + + vm.prank(address(harness)); + harness.grantRoleWithExpiry(MINTER_ROLE, ALICE, expiry); + + assertEq(harness.getRoleExpiry(MINTER_ROLE, ALICE), expiry); + assertEq(harness.getStorageExpiry(ALICE, MINTER_ROLE), expiry); + } + + // ============================================ + // IsRoleExpired Tests + // ============================================ + + function test_IsRoleExpired_ReturnsTrueForNoRole() public view { + assertTrue(harness.isRoleExpired(MINTER_ROLE, ALICE)); + } + + function test_IsRoleExpired_ReturnsFalseForFutureExpiry() public { + uint256 expiry = block.timestamp + 7 days; + + vm.prank(address(harness)); + harness.grantRoleWithExpiry(MINTER_ROLE, ALICE, expiry); + + assertFalse(harness.isRoleExpired(MINTER_ROLE, ALICE)); + } + + function test_IsRoleExpired_ReturnsTrueForPastExpiry() public { + uint256 expiry = block.timestamp + 7 days; + + vm.prank(address(harness)); + harness.grantRoleWithExpiry(MINTER_ROLE, ALICE, expiry); + + // Fast forward past expiry + vm.warp(expiry + 1); + + assertTrue(harness.isRoleExpired(MINTER_ROLE, ALICE)); + } + + function test_IsRoleExpired_ReturnsTrueAtExactExpiry() public { + uint256 expiry = block.timestamp + 7 days; + + vm.prank(address(harness)); + harness.grantRoleWithExpiry(MINTER_ROLE, ALICE, expiry); + + // Set time to exactly expiry + vm.warp(expiry); + + // At exact expiry time, role should be expired + assertTrue(harness.isRoleExpired(MINTER_ROLE, ALICE)); + } + + // ============================================ + // GrantRoleWithExpiry Tests + // ============================================ + + function test_GrantRoleWithExpiry_GrantsRoleAndSetsExpiry() public { + uint256 expiry = block.timestamp + 7 days; + + vm.expectEmit(true, true, true, true); + emit RoleGrantedWithExpiry(MINTER_ROLE, ALICE, expiry, address(harness)); + + vm.prank(address(harness)); + bool granted = harness.grantRoleWithExpiry(MINTER_ROLE, ALICE, expiry); + + assertTrue(granted); + assertEq(harness.getRoleExpiry(MINTER_ROLE, ALICE), expiry); + } + + function test_GrantRoleWithExpiry_CanUpdateExpiry() public { + uint256 expiry1 = block.timestamp + 7 days; + uint256 expiry2 = block.timestamp + 14 days; + + vm.startPrank(address(harness)); + harness.grantRoleWithExpiry(MINTER_ROLE, ALICE, expiry1); + assertEq(harness.getRoleExpiry(MINTER_ROLE, ALICE), expiry1); + + harness.grantRoleWithExpiry(MINTER_ROLE, ALICE, expiry2); + assertEq(harness.getRoleExpiry(MINTER_ROLE, ALICE), expiry2); + vm.stopPrank(); + } + + // ============================================ + // RevokeTemporalRole Tests + // ============================================ + + function test_RevokeTemporalRole_RevokesRoleAndClearsExpiry() public { + uint256 expiry = block.timestamp + 7 days; + + vm.startPrank(address(harness)); + harness.grantRoleWithExpiry(MINTER_ROLE, ALICE, expiry); + assertEq(harness.getRoleExpiry(MINTER_ROLE, ALICE), expiry); + vm.stopPrank(); + + vm.prank(address(harness)); + bool revoked = harness.revokeTemporalRole(MINTER_ROLE, ALICE); + + assertTrue(revoked); + assertEq(harness.getRoleExpiry(MINTER_ROLE, ALICE), 0); + assertFalse(accessControl.hasRole(MINTER_ROLE, ALICE)); + } + + function test_RevokeTemporalRole_ReturnsFalseWhenNoRole() public { + vm.prank(address(harness)); + bool revoked = harness.revokeTemporalRole(MINTER_ROLE, ALICE); + + assertFalse(revoked); + assertEq(harness.getRoleExpiry(MINTER_ROLE, ALICE), 0); + } + + // ============================================ + // RequireValidRole Tests + // ============================================ + + function test_RequireValidRole_PassesWithValidExpiry() public { + uint256 expiry = block.timestamp + 7 days; + + vm.prank(address(harness)); + harness.grantRoleWithExpiry(MINTER_ROLE, ALICE, expiry); + + // Should not revert + harness.requireValidRole(MINTER_ROLE, ALICE); + } + + function test_RequireValidRole_PassesWithoutExpiry() public { + // Grant role without expiry + harness.forceGrantRole(MINTER_ROLE, ALICE); + + // Should not revert (no expiry set means valid) + harness.requireValidRole(MINTER_ROLE, ALICE); + } + + function test_RevertWhen_RequireValidRole_Expired() public { + uint256 expiry = block.timestamp + 7 days; + + vm.prank(address(harness)); + harness.grantRoleWithExpiry(MINTER_ROLE, ALICE, expiry); + + // Fast forward past expiry + vm.warp(expiry + 1); + + vm.expectRevert( + abi.encodeWithSelector(LibAccessControlTemporal.AccessControlRoleExpired.selector, MINTER_ROLE, ALICE) + ); + harness.requireValidRole(MINTER_ROLE, ALICE); + } + + function test_RevertWhen_RequireValidRole_NoRole() public { + vm.expectRevert( + abi.encodeWithSelector( + LibAccessControlTemporal.AccessControlUnauthorizedAccount.selector, ALICE, MINTER_ROLE + ) + ); + harness.requireValidRole(MINTER_ROLE, ALICE); + } + + function test_RequireValidRole_AfterRevoke() public { + uint256 expiry = block.timestamp + 7 days; + + vm.startPrank(address(harness)); + harness.grantRoleWithExpiry(MINTER_ROLE, ALICE, expiry); + harness.revokeTemporalRole(MINTER_ROLE, ALICE); + vm.stopPrank(); + + vm.expectRevert( + abi.encodeWithSelector( + LibAccessControlTemporal.AccessControlUnauthorizedAccount.selector, ALICE, MINTER_ROLE + ) + ); + harness.requireValidRole(MINTER_ROLE, ALICE); + } + + // ============================================ + // Storage Consistency Tests + // ============================================ + + function test_StorageConsistency_GrantRoleWithExpiry() public { + uint256 expiry = block.timestamp + 7 days; + + vm.prank(address(harness)); + harness.grantRoleWithExpiry(MINTER_ROLE, ALICE, expiry); + + assertEq(harness.getRoleExpiry(MINTER_ROLE, ALICE), expiry); + assertEq(harness.getStorageExpiry(ALICE, MINTER_ROLE), expiry); + } + + function test_StorageConsistency_RevokeTemporalRole() public { + uint256 expiry = block.timestamp + 7 days; + + vm.startPrank(address(harness)); + harness.grantRoleWithExpiry(MINTER_ROLE, ALICE, expiry); + assertEq(harness.getStorageExpiry(ALICE, MINTER_ROLE), expiry); + + harness.revokeTemporalRole(MINTER_ROLE, ALICE); + assertEq(harness.getStorageExpiry(ALICE, MINTER_ROLE), 0); + vm.stopPrank(); + } + + // ============================================ + // Fuzz Tests + // ============================================ + + function testFuzz_GrantRoleWithExpiry_AlwaysSetsExpiry(address account, bytes32 role, uint256 expiryOffset) public { + vm.assume(account != address(0)); + vm.assume(expiryOffset > 0); // Must be in the future + vm.assume(expiryOffset <= 365 days); // Reasonable expiry window + uint256 expiry = block.timestamp + expiryOffset; + + vm.prank(address(harness)); + harness.grantRoleWithExpiry(role, account, expiry); + + assertEq(harness.getRoleExpiry(role, account), expiry); + } + + function testFuzz_IsRoleExpired_ConsistentWithExpiry(address account, bytes32 role, uint256 expiryOffset) public { + vm.assume(account != address(0)); + vm.assume(expiryOffset > 0); // Must be in the future + vm.assume(expiryOffset <= 365 days); + uint256 expiry = block.timestamp + expiryOffset; + + vm.prank(address(harness)); + harness.grantRoleWithExpiry(role, account, expiry); + + // Before expiry + assertFalse(harness.isRoleExpired(role, account)); + + // After expiry + vm.warp(expiry + 1); + assertTrue(harness.isRoleExpired(role, account)); + } +} diff --git a/test/access/AccessControlTemporal/harnesses/AccessControlTemporalFacetHarness.sol b/test/access/AccessControlTemporal/harnesses/AccessControlTemporalFacetHarness.sol new file mode 100644 index 00000000..c76908df --- /dev/null +++ b/test/access/AccessControlTemporal/harnesses/AccessControlTemporalFacetHarness.sol @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +import {AccessControlTemporalFacet} from "../../../../src/access/AccessControlTemporal/AccessControlTemporalFacet.sol"; + +/// @title AccessControlTemporalFacet Test Harness +/// @notice Extends AccessControlTemporalFacet with initialization and test-specific functions +contract AccessControlTemporalFacetHarness is AccessControlTemporalFacet { + /// @notice Initialize the DEFAULT_ADMIN_ROLE for testing + /// @dev This function is only for testing purposes + function initialize(address _admin) external { + AccessControlStorage storage acs = getAccessControlStorage(); + bytes32 DEFAULT_ADMIN_ROLE = 0x00; + acs.hasRole[_admin][DEFAULT_ADMIN_ROLE] = true; + } + + /// @notice Force grant a role without any checks (for testing edge cases) + /// @dev This bypasses all access control for testing purposes + function forceGrantRole(bytes32 _role, address _account) external { + AccessControlStorage storage acs = getAccessControlStorage(); + acs.hasRole[_account][_role] = true; + } + + /// @notice Force set the admin role for a role without any checks + /// @dev This bypasses all access control for testing purposes + function forceSetRoleAdmin(bytes32 _role, bytes32 _adminRole) external { + AccessControlStorage storage acs = getAccessControlStorage(); + acs.adminRole[_role] = _adminRole; + } + + /// @notice Get the raw storage roleExpiry value (for testing storage consistency) + function getStorageExpiry(address _account, bytes32 _role) external view returns (uint256) { + return getStorage().roleExpiry[_account][_role]; + } +} diff --git a/test/access/AccessControlTemporal/harnesses/LibAccessControlTemporalHarness.sol b/test/access/AccessControlTemporal/harnesses/LibAccessControlTemporalHarness.sol new file mode 100644 index 00000000..a9fd1b51 --- /dev/null +++ b/test/access/AccessControlTemporal/harnesses/LibAccessControlTemporalHarness.sol @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +import {LibAccessControlTemporal} from "../../../../src/access/AccessControlTemporal/LibAccessControlTemporal.sol"; + +/// @title LibAccessControlTemporal Test Harness +/// @notice Exposes internal LibAccessControlTemporal functions as external for testing +contract LibAccessControlTemporalHarness { + /// @notice Initialize roles for testing + /// @param _account The account to grant the default admin role to + function initialize(address _account) external { + LibAccessControlTemporal.AccessControlStorage storage acs = LibAccessControlTemporal.getAccessControlStorage(); + bytes32 DEFAULT_ADMIN_ROLE = 0x00; + acs.hasRole[_account][DEFAULT_ADMIN_ROLE] = true; + } + + /// @notice Get the expiry timestamp for a role assignment + function getRoleExpiry(bytes32 _role, address _account) external view returns (uint256) { + return LibAccessControlTemporal.getRoleExpiry(_role, _account); + } + + /// @notice Check if a role assignment has expired + function isRoleExpired(bytes32 _role, address _account) external view returns (bool) { + return LibAccessControlTemporal.isRoleExpired(_role, _account); + } + + /// @notice Grant a role with an expiry timestamp + function grantRoleWithExpiry(bytes32 _role, address _account, uint256 _expiresAt) external returns (bool) { + return LibAccessControlTemporal.grantRoleWithExpiry(_role, _account, _expiresAt); + } + + /// @notice Revoke a temporal role + function revokeTemporalRole(bytes32 _role, address _account) external returns (bool) { + return LibAccessControlTemporal.revokeTemporalRole(_role, _account); + } + + /// @notice Require that an account has a valid (non-expired) role + function requireValidRole(bytes32 _role, address _account) external view { + LibAccessControlTemporal.requireValidRole(_role, _account); + } + + /// @notice Force grant a role without any checks (for testing edge cases) + function forceGrantRole(bytes32 _role, address _account) external { + LibAccessControlTemporal.AccessControlStorage storage acs = LibAccessControlTemporal.getAccessControlStorage(); + acs.hasRole[_account][_role] = true; + } + + /// @notice Force set the admin role without checks (for testing edge cases) + function forceSetRoleAdmin(bytes32 _role, bytes32 _adminRole) external { + LibAccessControlTemporal.AccessControlStorage storage acs = LibAccessControlTemporal.getAccessControlStorage(); + acs.adminRole[_role] = _adminRole; + } + + /// @notice Get the raw storage roleExpiry value (for testing storage consistency) + function getStorageExpiry(address _account, bytes32 _role) external view returns (uint256) { + return LibAccessControlTemporal.getStorage().roleExpiry[_account][_role]; + } +}