From 8a5dabf51d7c4247448b973e3978e15b6f522d98 Mon Sep 17 00:00:00 2001 From: Siphamandla Mjoli Date: Tue, 18 Aug 2026 17:26:33 +0100 Subject: [PATCH] chore: initial commit --- contracts/utils/DotnsConstants.sol | 8 + contracts/whitelist/DotnsNameWhitelist.sol | 303 +++++++++++++ contracts/whitelist/IDotnsNameWhitelist.sol | 224 ++++++++++ scripts/deploy/DotnsDeployer.s.sol | 28 ++ .../whitelist/DotnsNameWhitelistFuzz.t.sol | 136 ++++++ .../DotnsNameWhitelistInvariant.t.sol | 92 ++++ .../whitelist/WhitelistHandler.t.sol | 96 ++++ test/unit/whitelist/DotnsNameWhitelist.t.sol | 412 ++++++++++++++++++ 8 files changed, 1299 insertions(+) create mode 100644 contracts/whitelist/DotnsNameWhitelist.sol create mode 100644 contracts/whitelist/IDotnsNameWhitelist.sol create mode 100644 test/fuzz/whitelist/DotnsNameWhitelistFuzz.t.sol create mode 100644 test/invariant/whitelist/DotnsNameWhitelistInvariant.t.sol create mode 100644 test/invariant/whitelist/WhitelistHandler.t.sol create mode 100644 test/unit/whitelist/DotnsNameWhitelist.t.sol diff --git a/contracts/utils/DotnsConstants.sol b/contracts/utils/DotnsConstants.sol index d535936d..5e57cfc6 100644 --- a/contracts/utils/DotnsConstants.sol +++ b/contracts/utils/DotnsConstants.sol @@ -140,4 +140,12 @@ library DotnsConstants { /// the protocol registry. /// forge-lint: disable-next-line(unsafe-typecast) bytes32 internal constant POP_GATEWAY = bytes32("popGateway"); + + /// @notice Well-known key for the pre-launch name whitelist that binds a label to the + /// one address permitted to register it. + /// @dev Role: authority for label-bound registration grants. Both the public and PoP + /// controllers resolve it here and read it at mint time; the whitelist stores the + /// grants, the controllers only read them. + /// forge-lint: disable-next-line(unsafe-typecast) + bytes32 internal constant NAME_WHITELIST = bytes32("nameWhitelist"); } diff --git a/contracts/whitelist/DotnsNameWhitelist.sol b/contracts/whitelist/DotnsNameWhitelist.sol new file mode 100644 index 00000000..d489318a --- /dev/null +++ b/contracts/whitelist/DotnsNameWhitelist.sol @@ -0,0 +1,303 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.34; + +import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; +import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; +import {EnumerableSet} from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; + +import {DotnsRoleManager} from "../access/DotnsRoleManager.sol"; +import {IDotnsNameWhitelist} from "./IDotnsNameWhitelist.sol"; +import {IDotnsProtocolRegistry} from "../registry/IDotnsProtocolRegistry.sol"; +import {LabelUtils} from "../utils/LabelUtils.sol"; +import {StringUtils} from "../utils/StringUtils.sol"; +import {DotnsConstants} from "../utils/DotnsConstants.sol"; + +/// @title DotnsNameWhitelist +/// @notice Pre-launch name whitelist that binds a name to the single address permitted to +/// register it, tracking each name from request to decision. +/// @dev Lives behind its own UUPS proxy with its own storage. Callers pass bare labels only; the +/// contract derives the node from the label and the TLD held in the protocol registry, the +/// same derivation the controllers use, so a caller can never supply a mismatched hash. Each +/// entry keeps its label, request and decision timestamps, and status, and the node set is +/// enumerable, so the whitelist is reviewable on-chain. Requests are user-facing; accepting, +/// rejecting, direct granting, batch granting and revoking are operator or owner actions +/// through the inherited @custom:contract DotnsRoleManager, with the owner appointing and +/// removing @custom:function DotnsConstants.WHITELIST_OPERATOR_ROLE holders and keeping +/// super-user access. The public and PoP controllers read the whitelist at mint time and +/// never write to it. Entries are keyed by the node under the active TLD, which the +/// deployment holds immutable for the whitelist's lifetime; a TLD change would strand +/// existing entries under their old node. +/// @custom:security-contact admin@parity.io +contract DotnsNameWhitelist is + Initializable, + UUPSUpgradeable, + DotnsRoleManager, + IDotnsNameWhitelist +{ + using StringUtils for string; + using EnumerableSet for EnumerableSet.Bytes32Set; + + /// @notice Protocol-level address registry for all DotNS contracts. + IDotnsProtocolRegistry public protocolRegistry; + + /// @notice Entries keyed by the label's namehash under the active TLD. + mapping(bytes32 node => Grant grant) private _grants; + + /// @notice Nodes with a live entry, kept enumerable so the whitelist can be reviewed. + EnumerableSet.Bytes32Set private _grantedNodes; + + /// @notice Timestamp requests start being accepted. + uint64 private _requestOpen; + + /// @notice Timestamp requests stop being accepted. + uint64 private _requestClose; + + /// @dev Reserved storage space to allow for layout changes in the future. + uint256[50] private __gap; + + /// @notice Restricts a call to an operator or the owner. + modifier onlyOperatorOrOwner() { + _checkRoleOrOwner(DotnsConstants.WHITELIST_OPERATOR_ROLE); + _; + } + + /// @notice Restricts a call to a registrar controller resolved through the registry. + modifier onlyController() { + require( + msg.sender == protocolRegistry.get(DotnsConstants.CONTROLLER) + || msg.sender == protocolRegistry.get(DotnsConstants.POP_CONTROLLER), + NotController(msg.sender) + ); + _; + } + + /// @custom:oz-upgrades-unsafe-allow constructor + constructor() { + _disableInitializers(); + } + + /// @notice Initialises the whitelist. + /// @dev Callable once through the UUPS proxy; direct calls on the implementation revert with + /// @custom:reverts InvalidInitialization. Sets the deployer as owner and wires the + /// protocol registry the node derivation reads the TLD from. + /// @param registry Protocol registry all DotNS contracts resolve through. + function initialize(IDotnsProtocolRegistry registry) external initializer { + __Ownable_init(msg.sender); + _dotnsRoleManagerInit(); + protocolRegistry = registry; + } + + /// @inheritdoc IDotnsNameWhitelist + function setWindow(uint64 startsIn, uint64 duration) external override onlyOwner { + require(duration > 0, BadWindow()); + uint64 openAt = uint64(block.timestamp) + startsIn; + uint64 closeAt = openAt + duration; + _requestOpen = openAt; + _requestClose = closeAt; + emit WindowSet(openAt, closeAt); + } + + /// @inheritdoc IDotnsNameWhitelist + function requestName(string calldata label) external override { + require(_isWindowOpen(), WindowClosed()); + bytes32 node = _validateNew(label); + _grants[node] = Grant({ + grantee: msg.sender, + requestedAt: uint64(block.timestamp), + status: GrantStatus.Requested, + decidedAt: 0, + label: label + }); + _grantedNodes.add(node); + emit NameRequested(node, msg.sender, label); + } + + /// @inheritdoc IDotnsNameWhitelist + function accept(string calldata label) external override onlyOperatorOrOwner { + (bytes32 node, address grantee) = _decide(label, GrantStatus.Accepted); + emit NameAccepted(node, grantee, label); + } + + /// @inheritdoc IDotnsNameWhitelist + function reject(string calldata label) external override onlyOperatorOrOwner { + (bytes32 node, address grantee) = _decide(label, GrantStatus.Rejected); + emit NameRejected(node, grantee, label); + } + + /// @inheritdoc IDotnsNameWhitelist + function grantName( + string calldata label, + address grantee + ) + external + override + onlyOperatorOrOwner + { + _grant(label, grantee); + } + + /// @inheritdoc IDotnsNameWhitelist + function grantNames( + string[] calldata labels, + address grantee + ) + external + override + onlyOperatorOrOwner + { + for (uint256 i = 0; i < labels.length; i++) { + _grant(labels[i], grantee); + } + } + + /// @inheritdoc IDotnsNameWhitelist + function revokeName(string calldata label) external override onlyOperatorOrOwner { + bytes32 node = _nodeOf(label); + Grant storage grant = _grants[node]; + require(grant.status != GrantStatus.None, NotGranted(node)); + address grantee = grant.grantee; + _clear(node); + emit NameRevoked(node, grantee, label); + } + + /// @inheritdoc IDotnsNameWhitelist + function consume(string calldata label, address registrant) external override onlyController { + bytes32 node = _nodeOf(label); + Grant storage grant = _grants[node]; + require( + grant.status == GrantStatus.Accepted && grant.grantee == registrant, + NotGrantee(registrant, node) + ); + _clear(node); + emit NameConsumed(node, registrant, label); + } + + /// @inheritdoc IDotnsNameWhitelist + function granteeOf(string calldata label) external view override returns (address grantee) { + Grant storage grant = _grants[_nodeOf(label)]; + return grant.status == GrantStatus.Accepted ? grant.grantee : address(0); + } + + /// @inheritdoc IDotnsNameWhitelist + function isGrantedTo( + string calldata label, + address account + ) + external + view + override + returns (bool granted) + { + Grant storage grant = _grants[_nodeOf(label)]; + return + account != address(0) && grant.status == GrantStatus.Accepted + && grant.grantee == account; + } + + /// @inheritdoc IDotnsNameWhitelist + function grantOf(string calldata label) external view override returns (Grant memory grant) { + return _grants[_nodeOf(label)]; + } + + /// @inheritdoc IDotnsNameWhitelist + function grantCount() external view override returns (uint256 count) { + return _grantedNodes.length(); + } + + /// @inheritdoc IDotnsNameWhitelist + function grants( + uint256 offset, + uint256 limit + ) + external + view + override + returns (Grant[] memory page) + { + uint256 total = _grantedNodes.length(); + if (offset >= total) { + return new Grant[](0); + } + + uint256 available = total - offset; + uint256 count = limit < available ? limit : available; + + page = new Grant[](count); + for (uint256 i; i < count; ++i) { + page[i] = _grants[_grantedNodes.at(offset + i)]; + } + } + + /// @inheritdoc IDotnsNameWhitelist + function window() external view override returns (uint64 openAt, uint64 closeAt) { + return (_requestOpen, _requestClose); + } + + /// @inheritdoc IDotnsNameWhitelist + function isWindowOpen() external view override returns (bool open) { + return _isWindowOpen(); + } + + /// @notice Writes an `Accepted` entry for `grantee`, rejecting a name that already exists. + function _grant(string calldata label, address grantee) internal { + require(grantee != address(0), ZeroGrantee()); + bytes32 node = _validateNew(label); + uint64 nowTimestamp = uint64(block.timestamp); + _grants[node] = Grant({ + grantee: grantee, + requestedAt: nowTimestamp, + status: GrantStatus.Accepted, + decidedAt: nowTimestamp, + label: label + }); + _grantedNodes.add(node); + emit NameAccepted(node, grantee, label); + } + + /// @notice Moves a pending request to a terminal decision and stamps the decision time. + function _decide( + string calldata label, + GrantStatus decision + ) + internal + returns (bytes32 node, address grantee) + { + node = _nodeOf(label); + Grant storage grant = _grants[node]; + require(grant.status == GrantStatus.Requested, NotRequested(node)); + grant.status = decision; + grant.decidedAt = uint64(block.timestamp); + grantee = grant.grantee; + } + + /// @notice Validates a canonical, unused label and returns its node. + function _validateNew(string calldata label) internal view returns (bytes32 node) { + require(label.isSingleLabel(), InvalidLabel()); + node = _nodeOf(label); + require(_grants[node].status == GrantStatus.None, AlreadyExists(node)); + } + + /// @notice Derives the namehash of `label` under the active TLD read from the registry. + function _nodeOf(string calldata label) internal view returns (bytes32 node) { + (, node) = LabelUtils.deriveNode(protocolRegistry.tldNode(), label); + } + + /// @notice Returns whether the current time is within the open window. + function _isWindowOpen() internal view returns (bool open) { + return block.timestamp >= _requestOpen && block.timestamp < _requestClose; + } + + /// @notice Removes an entry from both the map and the enumerable set. + function _clear(bytes32 node) internal { + delete _grants[node]; + _grantedNodes.remove(node); + } + + /// @inheritdoc DotnsRoleManager + function _isSupportedRole(bytes32 role) internal pure override returns (bool supported) { + return role == DotnsConstants.WHITELIST_OPERATOR_ROLE; + } + + /// @notice Restricts upgrades to the owner. + function _authorizeUpgrade(address newImplementation) internal override onlyOwner {} +} diff --git a/contracts/whitelist/IDotnsNameWhitelist.sol b/contracts/whitelist/IDotnsNameWhitelist.sol new file mode 100644 index 00000000..73ae4ea6 --- /dev/null +++ b/contracts/whitelist/IDotnsNameWhitelist.sol @@ -0,0 +1,224 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.34; + +/// @title IDotnsNameWhitelist +/// @notice Interface for the pre-launch name whitelist that binds a name to the single address +/// permitted to register it, tracking each name from request to decision. +/// @dev The contract never accepts a caller-supplied hash. Every entry point takes the bare +/// label and derives the node itself from the TLD held in the protocol registry, the same +/// derivation the controllers use, so a malformed or mismatched hash cannot be smuggled in. +/// Every entry keeps its bare label, request and decision timestamps, and status, and the +/// node set is enumerable, so the whole whitelist is reviewable on-chain and by event log. +/// Operator appointment and removal, and upgrades, are owner-gated through +/// @custom:contract DotnsRoleManager. +/// @custom:security-contact admin@parity.io +interface IDotnsNameWhitelist { + /// @notice Lifecycle status of a whitelist entry. + /// @dev `None` is the zero-value default of an absent entry, so a missing node reads as `None` + /// rather than as a live status. `Accepted` is the only status the controllers admit for + /// registration; `Requested` and `Rejected` do not reserve the name. + enum GrantStatus { + None, + Requested, + Accepted, + Rejected + } + + /// @notice A whitelist entry and its request-to-decision lifecycle. + /// @dev `grantee`, `requestedAt` and `status` co-locate in one storage slot (20 + 8 + 1 + /// bytes); `decidedAt` spills to the next; the dynamic `label` is stored separately. + /// @param grantee Address permitted to register the name once accepted. + /// @param requestedAt Timestamp the entry was requested. + /// @param status Lifecycle status; see GrantStatus. + /// @param decidedAt Timestamp the entry was accepted or rejected; zero while `Requested`. + /// @param label Bare label, kept for on-chain review. + struct Grant { + address grantee; + uint64 requestedAt; + GrantStatus status; + uint64 decidedAt; + string label; + } + + /// @notice Emitted when a name is requested. + /// @param node Namehash of the label under the active TLD. + /// @param grantee Address that requested the name. + /// @param label Bare label requested. + event NameRequested(bytes32 indexed node, address indexed grantee, string label); + + /// @notice Emitted when a request is accepted, including an operator direct grant. + /// @param node Namehash of the label under the active TLD. + /// @param grantee Address permitted to register the name. + /// @param label Bare label accepted. + event NameAccepted(bytes32 indexed node, address indexed grantee, string label); + + /// @notice Emitted when a request is rejected. + /// @param node Namehash of the label under the active TLD. + /// @param grantee Address whose request was rejected. + /// @param label Bare label rejected. + event NameRejected(bytes32 indexed node, address indexed grantee, string label); + + /// @notice Emitted when an entry is cleared. + /// @param node Namehash of the label under the active TLD. + /// @param grantee Address whose entry was cleared. + /// @param label Bare label cleared. + event NameRevoked(bytes32 indexed node, address indexed grantee, string label); + + /// @notice Emitted when a grantee registers their name and the entry is consumed. + /// @param node Namehash of the label under the active TLD. + /// @param grantee Address that registered the name. + /// @param label Bare label consumed. + event NameConsumed(bytes32 indexed node, address indexed grantee, string label); + + /// @notice Emitted when the request window is set. + /// @param openAt Timestamp requests start being accepted. + /// @param closeAt Timestamp requests stop being accepted. + event WindowSet(uint64 openAt, uint64 closeAt); + + /// @notice Thrown when a grant is issued to the zero address. + error ZeroGrantee(); + + /// @notice Thrown when a label is not a canonical single DNS label. + error InvalidLabel(); + + /// @notice Thrown when requesting or granting a name that already has a live entry. + /// @param node Namehash of the label under the active TLD. + error AlreadyExists(bytes32 node); + + /// @notice Thrown when accepting or rejecting a name that is not in the `Requested` status. + /// @param node Namehash of the label under the active TLD. + error NotRequested(bytes32 node); + + /// @notice Thrown when clearing a name that holds no entry. + /// @param node Namehash of the label under the active TLD. + error NotGranted(bytes32 node); + + /// @notice Thrown when `consume` is called by any address other than a registrar controller. + /// @param caller Rejected caller. + error NotController(address caller); + + /// @notice Thrown when `consume` is called for a name not accepted for the registrant. + /// @param registrant Address attempting to register the name. + /// @param node Namehash of the label under the active TLD. + error NotGrantee(address registrant, bytes32 node); + + /// @notice Thrown when the request window is set with a zero duration. + error BadWindow(); + + /// @notice Thrown when a request is made outside the open window. + error WindowClosed(); + + /// @notice Sets the request window relative to the current time. + /// @dev Restricted to the owner. The window opens at `block.timestamp + startsIn` and stays + /// open for `duration`, so it can never open in the past. Reverts with + /// @custom:reverts BadWindow when `duration` is zero. Emits @custom:emits WindowSet with + /// the resolved absolute timestamps. + /// @param startsIn Seconds from now until requests start being accepted. + /// @param duration Seconds the window stays open. + function setWindow(uint64 startsIn, uint64 duration) external; + + /// @notice Requests `label` for the caller. + /// @dev Records a `Requested` entry bound to the caller. Reverts with + /// @custom:reverts WindowClosed outside the open window, with + /// @custom:reverts AlreadyExists when the name already has a live entry, and with + /// @custom:reverts InvalidLabel when `label` is not a canonical single label. Emits + /// @custom:emits NameRequested. + /// @param label Bare label to request. + function requestName(string calldata label) external; + + /// @notice Accepts the pending request on `label`. + /// @dev Restricted to an operator or the owner. Moves a `Requested` entry to `Accepted` and + /// stamps the decision. Reverts with @custom:reverts NotRequested when the name is not + /// pending. Emits @custom:emits NameAccepted. + /// @param label Bare label to accept. + function accept(string calldata label) external; + + /// @notice Rejects the pending request on `label`. + /// @dev Restricted to an operator or the owner. Moves a `Requested` entry to `Rejected` and + /// stamps the decision; the entry is kept for review. Reverts with + /// @custom:reverts NotRequested when the name is not pending. Emits + /// @custom:emits NameRejected. + /// @param label Bare label to reject. + function reject(string calldata label) external; + + /// @notice Grants `label` to `grantee` directly, without a prior request. + /// @dev Restricted to an operator or the owner, and independent of the request window by + /// design, so operators can provision names whether or not requests are open. Writes an + /// `Accepted` entry with the request and decision timestamps set to now, for provisioning + /// names to a chosen address. + /// Reverts with @custom:reverts AlreadyExists when the name already has a live entry, + /// with @custom:reverts ZeroGrantee on a zero grantee, and with + /// @custom:reverts InvalidLabel when `label` is not a canonical single label. Emits + /// @custom:emits NameAccepted. + /// @param label Bare label to grant. + /// @param grantee Address permitted to register the name. + function grantName(string calldata label, address grantee) external; + + /// @notice Grants several labels to one `grantee` directly. + /// @dev Restricted to an operator or the owner. Applies the same rules as + /// @custom:function grantName to each entry. + /// @param labels Bare labels to grant. + /// @param grantee Address permitted to register each name. + function grantNames(string[] calldata labels, address grantee) external; + + /// @notice Clears the entry on `label`, whatever its status. + /// @dev Restricted to an operator or the owner. Reverts with @custom:reverts NotGranted when + /// the name holds no entry. Emits @custom:emits NameRevoked. + /// @param label Bare label to clear. + function revokeName(string calldata label) external; + + /// @notice Removes the accepted grant on `label` as `registrant` registers it. + /// @dev Restricted to the registrar controllers resolved through the protocol registry, so + /// the entry is consumed exactly when its grantee registers the name. Reverts with + /// @custom:reverts NotController for any other caller and @custom:reverts NotGrantee when + /// `label` is not accepted for `registrant`. Emits @custom:emits NameConsumed. + /// @param label Bare label being registered. + /// @param registrant Address registering the name. + function consume(string calldata label, address registrant) external; + + /// @notice Returns the address `label` is accepted for, or the zero address otherwise. + /// @dev Non-zero only for an `Accepted` entry, so a pending or rejected name does not reserve. + /// @param label Bare label to look up. + /// @return grantee Address permitted to register the name. + function granteeOf(string calldata label) external view returns (address grantee); + + /// @notice Returns whether `account` holds an accepted grant for `label`. + /// @dev The pair check the controllers use to admit a registrant. False for the zero address. + /// @param label Bare label to look up. + /// @param account Address to test against the grant. + /// @return granted True when `account` is the accepted grantee. + function isGrantedTo( + string calldata label, + address account + ) + external + view + returns (bool granted); + + /// @notice Returns the full entry for `label`, including status and timestamps. + /// @param label Bare label to look up. + /// @return grant The stored entry; a zeroed struct with `None` status when absent. + function grantOf(string calldata label) external view returns (Grant memory grant); + + /// @notice Returns the number of entries, of any status. + /// @return count Entry count. + function grantCount() external view returns (uint256 count); + + /// @notice Returns a page of entries for review. + /// @dev Reads the canonical offset and limit window. An `offset` at or beyond + /// @custom:function grantCount returns an empty page; `limit` is clamped to the + /// remaining entries. Iteration order is not stable across revokes. + /// @param offset Index of the first entry to return. + /// @param limit Maximum number of entries to return. + /// @return page Entries in the window. + function grants(uint256 offset, uint256 limit) external view returns (Grant[] memory page); + + /// @notice Returns the request window. + /// @return openAt Timestamp requests start being accepted. + /// @return closeAt Timestamp requests stop being accepted. + function window() external view returns (uint64 openAt, uint64 closeAt); + + /// @notice Returns whether requests are currently accepted. + /// @return open True when the current time is within the window. + function isWindowOpen() external view returns (bool open); +} diff --git a/scripts/deploy/DotnsDeployer.s.sol b/scripts/deploy/DotnsDeployer.s.sol index 7cc9442d..cebf5b2a 100644 --- a/scripts/deploy/DotnsDeployer.s.sol +++ b/scripts/deploy/DotnsDeployer.s.sol @@ -8,6 +8,7 @@ import {PopRules} from "../../contracts/pop/PopRules.sol"; import {DotnsRegistrar} from "../../contracts/registrars/DotnsRegistrar.sol"; import {DotnsRegistrarController} from "../../contracts/registrars/DotnsRegistrarController.sol"; import {DotnsPopController} from "../../contracts/registrars/DotnsPopController.sol"; +import {DotnsNameWhitelist} from "../../contracts/whitelist/DotnsNameWhitelist.sol"; import {DotnsNameEscrow} from "../../contracts/escrow/DotnsNameEscrow.sol"; import {IDotnsController} from "../../contracts/registrars/IDotnsController.sol"; import {DotnsRegistry} from "../../contracts/registry/DotnsRegistry.sol"; @@ -61,6 +62,7 @@ contract DotnsDeployer is BaseDeployer { DotnsPopResolver public dotnsPopResolver; DotnsRegistrarController public dotnsRegistrarController; DotnsPopController public dotnsPopController; + DotnsNameWhitelist public dotnsNameWhitelist; DotnsNameEscrow public dotnsNameEscrow; DotnsProtocolRegistry public protocolRegistry; @@ -80,6 +82,7 @@ contract DotnsDeployer is BaseDeployer { address nameEscrow; address popResolver; address popController; + address nameWhitelist; } /// @notice Deploys the full DotNS contract set, wires the protocol registry, @@ -125,6 +128,7 @@ contract DotnsDeployer is BaseDeployer { _deployRegistrarController(OWNER, deployment.protocolRegistry); deployment.popResolver = _deployPopResolver(OWNER, deployment.protocolRegistry); deployment.popController = _deployPopController(OWNER, deployment.protocolRegistry); + deployment.nameWhitelist = _deployNameWhitelist(OWNER, deployment.protocolRegistry); _authoriseControllers(OWNER, deployment); _wireProtocolRegistryKeys(OWNER, deployment); @@ -353,6 +357,24 @@ contract DotnsDeployer is BaseDeployer { dotnsPopController = DotnsPopController(proxy); } + function _deployNameWhitelist( + address owner, + address protocolRegistryProxy + ) + internal + returns (address proxy) + { + proxy = _broadcastDeployUups( + owner, + "DotnsNameWhitelist.sol:DotnsNameWhitelist", + abi.encodeCall( + DotnsNameWhitelist.initialize, (IDotnsProtocolRegistry(protocolRegistryProxy)) + ), + "DotnsNameWhitelist" + ); + dotnsNameWhitelist = DotnsNameWhitelist(proxy); + } + function _authoriseControllers(address owner, Deployment memory deployment) internal { vm.startBroadcast(owner); dotnsRegistrar.addController(IDotnsController(deployment.registrarController)); @@ -373,6 +395,7 @@ contract DotnsDeployer is BaseDeployer { protocolRegistry.set(DotnsConstants.NAME_ESCROW, deployment.nameEscrow); protocolRegistry.set(DotnsConstants.POP_CONTROLLER, deployment.popController); protocolRegistry.set(DotnsConstants.POP_RESOLVER, deployment.popResolver); + protocolRegistry.set(DotnsConstants.NAME_WHITELIST, deployment.nameWhitelist); vm.stopBroadcast(); console.log("Protocol registry keys set"); } @@ -551,6 +574,11 @@ contract DotnsDeployer is BaseDeployer { expected, "PopResolver: not wired" ); + _assertPointer( + address(DotnsNameWhitelist(deployment.nameWhitelist).protocolRegistry()), + expected, + "NameWhitelist: not wired" + ); } function _assertPointer(address actual, address expected, string memory label) internal pure { diff --git a/test/fuzz/whitelist/DotnsNameWhitelistFuzz.t.sol b/test/fuzz/whitelist/DotnsNameWhitelistFuzz.t.sol new file mode 100644 index 00000000..203a5754 --- /dev/null +++ b/test/fuzz/whitelist/DotnsNameWhitelistFuzz.t.sol @@ -0,0 +1,136 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.34; + +import {BaseDotns} from "../../base/BaseDotns.t.sol"; +import {DotnsNameWhitelist} from "../../../contracts/whitelist/DotnsNameWhitelist.sol"; +import {IDotnsNameWhitelist} from "../../../contracts/whitelist/IDotnsNameWhitelist.sol"; +import {IDotnsProtocolRegistry} from "../../../contracts/registry/IDotnsProtocolRegistry.sol"; +import {DotnsConstants} from "../../../contracts/utils/DotnsConstants.sol"; +import {StringUtils} from "../../../contracts/utils/StringUtils.sol"; +import {Upgrades} from "openzeppelin-foundry-upgrades/Upgrades.sol"; + +/// @title DotnsNameWhitelist fuzz tests +/// @notice Exercises the grant and lifecycle paths over fuzzed labels, addresses and windows. +contract DotnsNameWhitelistFuzz is BaseDotns { + DotnsNameWhitelist internal whitelist; + + function setUp() public override { + super.setUp(); + vm.startPrank(owner); + whitelist = DotnsNameWhitelist( + Upgrades.deployUUPSProxy( + "DotnsNameWhitelist.sol:DotnsNameWhitelist", + abi.encodeCall( + DotnsNameWhitelist.initialize, + (IDotnsProtocolRegistry(address(protocolRegistry))) + ) + ) + ); + whitelist.setWindow(0, 365 days); + vm.stopPrank(); + } + + /// @notice Builds a canonical single label from a fuzz seed. + function _label(uint256 seed) internal pure returns (string memory) { + uint256 value = seed % 100; + string memory suffix = value < 10 + ? string.concat("0", StringUtils.uintToString(value)) + : StringUtils.uintToString(value); + return string.concat("fuzzname", suffix); + } + + function testFuzz_grantName_reserves_only_the_intended_account( + uint256 seed, + address grantee, + address other + ) + public + { + vm.assume(grantee != address(0)); + vm.assume(other != address(0) && other != grantee); + string memory label = _label(seed); + + vm.prank(owner); + whitelist.grantName(label, grantee); + + assertEq(whitelist.granteeOf(label), grantee); + assertTrue(whitelist.isGrantedTo(label, grantee)); + assertFalse(whitelist.isGrantedTo(label, other)); + } + + function testFuzz_request_then_accept_reserves_requester(uint256 seed) public { + string memory label = _label(seed); + + vm.prank(ed); + whitelist.requestName(label); + assertEq(whitelist.granteeOf(label), address(0)); + + vm.prank(owner); + whitelist.accept(label); + assertEq(whitelist.granteeOf(label), ed); + } + + function testFuzz_reject_never_reserves(uint256 seed) public { + string memory label = _label(seed); + + vm.prank(ed); + whitelist.requestName(label); + vm.prank(owner); + whitelist.reject(label); + + assertEq(whitelist.granteeOf(label), address(0)); + assertEq( + uint256(whitelist.grantOf(label).status), + uint256(IDotnsNameWhitelist.GrantStatus.Rejected) + ); + } + + function testFuzz_grantName_reverts_on_duplicate(uint256 seed, address a, address b) public { + vm.assume(a != address(0) && b != address(0) && a != b); + string memory label = _label(seed); + + vm.prank(owner); + whitelist.grantName(label, a); + + vm.expectRevert( + abi.encodeWithSelector(IDotnsNameWhitelist.AlreadyExists.selector, _nodeOf(label)) + ); + vm.prank(owner); + whitelist.grantName(label, b); + } + + function testFuzz_requestName_reverts_before_window_opens( + uint256 seed, + uint64 startsIn + ) + public + { + startsIn = uint64(bound(uint256(startsIn), 1 days, 3650 days)); + string memory label = _label(seed); + + vm.prank(owner); + whitelist.setWindow(startsIn, 1 days); + + vm.expectRevert(IDotnsNameWhitelist.WindowClosed.selector); + vm.prank(ed); + whitelist.requestName(label); + } + + function testFuzz_requestName_reverts_after_window_closes( + uint256 seed, + uint64 duration + ) + public + { + duration = uint64(bound(uint256(duration), 1, 3650 days)); + string memory label = _label(seed); + + vm.prank(owner); + whitelist.setWindow(0, duration); + vm.warp(block.timestamp + duration); + + vm.expectRevert(IDotnsNameWhitelist.WindowClosed.selector); + vm.prank(ed); + whitelist.requestName(label); + } +} diff --git a/test/invariant/whitelist/DotnsNameWhitelistInvariant.t.sol b/test/invariant/whitelist/DotnsNameWhitelistInvariant.t.sol new file mode 100644 index 00000000..23782b80 --- /dev/null +++ b/test/invariant/whitelist/DotnsNameWhitelistInvariant.t.sol @@ -0,0 +1,92 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.34; + +import {BaseDotns} from "../../base/BaseDotns.t.sol"; +import {WhitelistHandler} from "./WhitelistHandler.t.sol"; +import {DotnsNameWhitelist} from "../../../contracts/whitelist/DotnsNameWhitelist.sol"; +import {IDotnsNameWhitelist} from "../../../contracts/whitelist/IDotnsNameWhitelist.sol"; +import {IDotnsProtocolRegistry} from "../../../contracts/registry/IDotnsProtocolRegistry.sol"; +import {DotnsConstants} from "../../../contracts/utils/DotnsConstants.sol"; +import {Upgrades} from "openzeppelin-foundry-upgrades/Upgrades.sol"; + +/// @title DotnsNameWhitelist invariants +/// @notice Drives the whitelist through random lifecycle sequences and asserts the review and +/// reservation guarantees hold at every step. +contract DotnsNameWhitelistInvariant is BaseDotns { + DotnsNameWhitelist internal whitelist; + WhitelistHandler internal handler; + + function setUp() public override { + super.setUp(); + + vm.startPrank(owner); + whitelist = DotnsNameWhitelist( + Upgrades.deployUUPSProxy( + "DotnsNameWhitelist.sol:DotnsNameWhitelist", + abi.encodeCall( + DotnsNameWhitelist.initialize, + (IDotnsProtocolRegistry(address(protocolRegistry))) + ) + ) + ); + whitelist.setWindow(0, 3650 days); + vm.stopPrank(); + + address[] memory actors = new address[](4); + for (uint256 i; i < 4; ++i) { + actors[i] = makeAddr(string.concat("wlActor", vm.toString(i))); + } + + handler = new WhitelistHandler( + whitelist, owner, protocolRegistry.get(DotnsConstants.CONTROLLER), actors + ); + targetContract(address(handler)); + + bytes4[] memory selectors = new bytes4[](6); + selectors[0] = handler.request.selector; + selectors[1] = handler.accept.selector; + selectors[2] = handler.reject.selector; + selectors[3] = handler.grant.selector; + selectors[4] = handler.revoke.selector; + selectors[5] = handler.consume.selector; + targetSelector(FuzzSelector({addr: address(handler), selectors: selectors})); + } + + /// @notice Every entry the paged getter returns is live, so `_grants` and `_grantedNodes` + /// never drift apart across grant, revoke and consume. + function invariant_pagination_returns_only_live_entries() public view { + uint256 count = whitelist.grantCount(); + IDotnsNameWhitelist.Grant[] memory page = whitelist.grants(0, count == 0 ? 1 : count); + assertEq(page.length, count); + for (uint256 i; i < page.length; ++i) { + assertTrue(page[i].status != IDotnsNameWhitelist.GrantStatus.None); + assertTrue(page[i].grantee != address(0)); + } + } + + /// @notice A name reserves an address only while it is `Accepted`. + function invariant_granteeOf_only_when_accepted() public view { + uint256 seen = handler.labelsSeenCount(); + for (uint256 i; i < seen; ++i) { + string memory label = handler.labelsSeen(i); + if (whitelist.granteeOf(label) != address(0)) { + assertEq( + uint256(whitelist.grantOf(label).status), + uint256(IDotnsNameWhitelist.GrantStatus.Accepted) + ); + } + } + } + + /// @notice Any live entry has a non-zero grantee and a request timestamp. + function invariant_live_entry_is_well_formed() public view { + uint256 seen = handler.labelsSeenCount(); + for (uint256 i; i < seen; ++i) { + IDotnsNameWhitelist.Grant memory grant = whitelist.grantOf(handler.labelsSeen(i)); + if (grant.status != IDotnsNameWhitelist.GrantStatus.None) { + assertTrue(grant.grantee != address(0)); + assertGt(grant.requestedAt, 0); + } + } + } +} diff --git a/test/invariant/whitelist/WhitelistHandler.t.sol b/test/invariant/whitelist/WhitelistHandler.t.sol new file mode 100644 index 00000000..7a1d7e40 --- /dev/null +++ b/test/invariant/whitelist/WhitelistHandler.t.sol @@ -0,0 +1,96 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.34; + +import {Test} from "forge-std/Test.sol"; +import {DotnsNameWhitelist} from "../../../contracts/whitelist/DotnsNameWhitelist.sol"; + +/// @title WhitelistHandler +/// @notice Drives the whitelist through its lifecycle for the invariant suite, cycling a fixed +/// actor and label set and swallowing expected reverts so the fuzzer keeps exploring. +contract WhitelistHandler is Test { + DotnsNameWhitelist public immutable WHITELIST; + address public immutable OWNER; + address public immutable CONTROLLER; + + address[] internal _actors; + string[] internal _labels; + string[] public labelsSeen; + mapping(bytes32 node => bool tracked) internal _trackedNodes; + + constructor( + DotnsNameWhitelist whitelist, + address owner, + address controller, + address[] memory actors + ) { + WHITELIST = whitelist; + OWNER = owner; + CONTROLLER = controller; + _actors = actors; + _labels.push("alicebob"); + _labels.push("wonderla"); + _labels.push("carolboy"); + _labels.push("danielle"); + } + + function labelsSeenCount() external view returns (uint256 count) { + return labelsSeen.length; + } + + function request(uint256 actorSeed, uint256 labelSeed) external { + string memory label = _label(labelSeed); + vm.prank(_actor(actorSeed)); + try WHITELIST.requestName(label) { + _track(label); + } catch {} + } + + function accept(uint256 labelSeed) external { + vm.prank(OWNER); + try WHITELIST.accept(_label(labelSeed)) {} catch {} + } + + function reject(uint256 labelSeed) external { + vm.prank(OWNER); + try WHITELIST.reject(_label(labelSeed)) {} catch {} + } + + function grant(uint256 actorSeed, uint256 labelSeed) external { + string memory label = _label(labelSeed); + vm.prank(OWNER); + try WHITELIST.grantName(label, _actor(actorSeed)) { + _track(label); + } catch {} + } + + function revoke(uint256 labelSeed) external { + vm.prank(OWNER); + try WHITELIST.revokeName(_label(labelSeed)) {} catch {} + } + + function consume(uint256 labelSeed) external { + string memory label = _label(labelSeed); + address grantee = WHITELIST.granteeOf(label); + if (grantee == address(0)) { + return; + } + vm.prank(CONTROLLER); + try WHITELIST.consume(label, grantee) {} catch {} + } + + function _actor(uint256 seed) internal view returns (address actor) { + return _actors[seed % _actors.length]; + } + + function _label(uint256 seed) internal view returns (string memory label) { + return _labels[seed % _labels.length]; + } + + function _track(string memory label) internal { + bytes32 node = keccak256(bytes(label)); + if (!_trackedNodes[node]) { + _trackedNodes[node] = true; + labelsSeen.push(label); + } + } +} diff --git a/test/unit/whitelist/DotnsNameWhitelist.t.sol b/test/unit/whitelist/DotnsNameWhitelist.t.sol new file mode 100644 index 00000000..6a5da706 --- /dev/null +++ b/test/unit/whitelist/DotnsNameWhitelist.t.sol @@ -0,0 +1,412 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.34; + +import {BaseDotns} from "../../base/BaseDotns.t.sol"; +import {DotnsNameWhitelist} from "../../../contracts/whitelist/DotnsNameWhitelist.sol"; +import {IDotnsNameWhitelist} from "../../../contracts/whitelist/IDotnsNameWhitelist.sol"; +import {IDotnsRoleManager} from "../../../contracts/access/IDotnsRoleManager.sol"; +import {IDotnsProtocolRegistry} from "../../../contracts/registry/IDotnsProtocolRegistry.sol"; +import {DotnsConstants} from "../../../contracts/utils/DotnsConstants.sol"; +import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; +import { + OwnableUpgradeable +} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; +import {Upgrades} from "openzeppelin-foundry-upgrades/Upgrades.sol"; + +/// @title DotnsNameWhitelist unit tests +/// @notice Covers the request-to-decision lifecycle, access control, the request window, the +/// controller-only consume hook, and the review views. +contract DotnsNameWhitelistTests is BaseDotns { + DotnsNameWhitelist internal whitelist; + address internal operator; + + function setUp() public override { + super.setUp(); + operator = _createUser("operator"); + + vm.startPrank(owner); + whitelist = DotnsNameWhitelist( + Upgrades.deployUUPSProxy( + "DotnsNameWhitelist.sol:DotnsNameWhitelist", + abi.encodeCall( + DotnsNameWhitelist.initialize, + (IDotnsProtocolRegistry(address(protocolRegistry))) + ) + ) + ); + whitelist.setRole(DotnsConstants.WHITELIST_OPERATOR_ROLE, operator, true); + whitelist.setWindow(0, 30 days); + vm.stopPrank(); + + vm.label(address(whitelist), "DotnsNameWhitelist"); + } + + function _request(address who, string memory label) internal { + vm.prank(who); + whitelist.requestName(label); + } + + function test_requestName_records_and_emits() public { + bytes32 node = _nodeOf(BASE_LABEL_A); + vm.expectEmit(true, true, false, true, address(whitelist)); + emit IDotnsNameWhitelist.NameRequested(node, ed, BASE_LABEL_A); + _request(ed, BASE_LABEL_A); + + IDotnsNameWhitelist.Grant memory grant = whitelist.grantOf(BASE_LABEL_A); + assertEq(uint256(grant.status), uint256(IDotnsNameWhitelist.GrantStatus.Requested)); + assertEq(grant.grantee, ed); + assertEq(grant.requestedAt, uint64(block.timestamp)); + assertEq(grant.decidedAt, 0); + assertEq(grant.label, BASE_LABEL_A); + assertEq(whitelist.grantCount(), 1); + assertEq(whitelist.granteeOf(BASE_LABEL_A), address(0)); + } + + function test_requestName_reverts_when_already_exists() public { + _request(ed, BASE_LABEL_A); + vm.expectRevert( + abi.encodeWithSelector( + IDotnsNameWhitelist.AlreadyExists.selector, _nodeOf(BASE_LABEL_A) + ) + ); + _request(tiago, BASE_LABEL_A); + } + + function test_requestName_reverts_after_reject() public { + _request(ed, BASE_LABEL_A); + vm.prank(operator); + whitelist.reject(BASE_LABEL_A); + vm.expectRevert( + abi.encodeWithSelector( + IDotnsNameWhitelist.AlreadyExists.selector, _nodeOf(BASE_LABEL_A) + ) + ); + _request(ed, BASE_LABEL_A); + } + + function test_requestName_reverts_for_non_canonical_label() public { + vm.expectRevert(IDotnsNameWhitelist.InvalidLabel.selector); + _request(ed, "bad.label"); + } + + function test_requestName_reverts_before_and_after_window() public { + vm.prank(owner); + whitelist.setWindow(1 days, 1 days); + + vm.expectRevert(IDotnsNameWhitelist.WindowClosed.selector); + _request(ed, BASE_LABEL_A); + + vm.warp(block.timestamp + 3 days); + vm.expectRevert(IDotnsNameWhitelist.WindowClosed.selector); + _request(ed, BASE_LABEL_A); + } + + function test_accept_by_operator_reserves_and_stamps() public { + _request(ed, BASE_LABEL_A); + bytes32 node = _nodeOf(BASE_LABEL_A); + + vm.expectEmit(true, true, false, true, address(whitelist)); + emit IDotnsNameWhitelist.NameAccepted(node, ed, BASE_LABEL_A); + vm.prank(operator); + whitelist.accept(BASE_LABEL_A); + + assertEq(whitelist.granteeOf(BASE_LABEL_A), ed); + assertTrue(whitelist.isGrantedTo(BASE_LABEL_A, ed)); + assertEq(whitelist.grantOf(BASE_LABEL_A).decidedAt, uint64(block.timestamp)); + } + + function test_accept_reverts_when_not_requested() public { + vm.expectRevert( + abi.encodeWithSelector(IDotnsNameWhitelist.NotRequested.selector, _nodeOf(BASE_LABEL_A)) + ); + vm.prank(operator); + whitelist.accept(BASE_LABEL_A); + } + + function test_accept_reverts_for_unauthorised_caller() public { + _request(ed, BASE_LABEL_A); + vm.expectRevert( + abi.encodeWithSelector( + IDotnsRoleManager.NotRoleOrOwner.selector, + tiago, + DotnsConstants.WHITELIST_OPERATOR_ROLE + ) + ); + vm.prank(tiago); + whitelist.accept(BASE_LABEL_A); + } + + function test_reject_records_and_emits() public { + _request(ed, BASE_LABEL_A); + bytes32 node = _nodeOf(BASE_LABEL_A); + + vm.expectEmit(true, true, false, true, address(whitelist)); + emit IDotnsNameWhitelist.NameRejected(node, ed, BASE_LABEL_A); + vm.prank(operator); + whitelist.reject(BASE_LABEL_A); + + IDotnsNameWhitelist.Grant memory grant = whitelist.grantOf(BASE_LABEL_A); + assertEq(uint256(grant.status), uint256(IDotnsNameWhitelist.GrantStatus.Rejected)); + assertEq(grant.decidedAt, uint64(block.timestamp)); + assertEq(whitelist.granteeOf(BASE_LABEL_A), address(0)); + assertEq(whitelist.grantCount(), 1); + } + + function test_reject_reverts_when_not_requested() public { + vm.expectRevert( + abi.encodeWithSelector(IDotnsNameWhitelist.NotRequested.selector, _nodeOf(BASE_LABEL_A)) + ); + vm.prank(operator); + whitelist.reject(BASE_LABEL_A); + } + + function test_grantName_direct_by_owner() public { + bytes32 node = _nodeOf(BASE_LABEL_A); + uint64 nowTimestamp = uint64(block.timestamp); + + vm.expectEmit(true, true, false, true, address(whitelist)); + emit IDotnsNameWhitelist.NameAccepted(node, ed, BASE_LABEL_A); + vm.prank(owner); + whitelist.grantName(BASE_LABEL_A, ed); + + assertEq(whitelist.granteeOf(BASE_LABEL_A), ed); + IDotnsNameWhitelist.Grant memory grant = whitelist.grantOf(BASE_LABEL_A); + assertEq(uint256(grant.status), uint256(IDotnsNameWhitelist.GrantStatus.Accepted)); + assertEq(grant.requestedAt, nowTimestamp); + assertEq(grant.decidedAt, nowTimestamp); + assertEq(grant.label, BASE_LABEL_A); + } + + function test_grantName_reverts_for_zero_grantee() public { + vm.expectRevert(IDotnsNameWhitelist.ZeroGrantee.selector); + vm.prank(operator); + whitelist.grantName(BASE_LABEL_A, address(0)); + } + + function test_grantName_reverts_for_non_canonical_label() public { + vm.expectRevert(IDotnsNameWhitelist.InvalidLabel.selector); + vm.prank(operator); + whitelist.grantName("bad.label", ed); + } + + function test_grantName_reverts_when_already_exists() public { + vm.prank(operator); + whitelist.grantName(BASE_LABEL_A, ed); + vm.expectRevert( + abi.encodeWithSelector( + IDotnsNameWhitelist.AlreadyExists.selector, _nodeOf(BASE_LABEL_A) + ) + ); + vm.prank(operator); + whitelist.grantName(BASE_LABEL_A, tiago); + } + + function test_grantName_reverts_for_unauthorised_caller() public { + vm.expectRevert( + abi.encodeWithSelector( + IDotnsRoleManager.NotRoleOrOwner.selector, + tiago, + DotnsConstants.WHITELIST_OPERATOR_ROLE + ) + ); + vm.prank(tiago); + whitelist.grantName(BASE_LABEL_A, ed); + } + + function test_grantNames_batch_grants_each() public { + string[] memory labels = new string[](2); + labels[0] = BASE_LABEL_A; + labels[1] = BASE_LABEL_B; + vm.prank(operator); + whitelist.grantNames(labels, ed); + + assertEq(whitelist.granteeOf(BASE_LABEL_A), ed); + assertEq(whitelist.granteeOf(BASE_LABEL_B), ed); + assertEq(whitelist.grantCount(), 2); + } + + function test_grantNames_reverts_on_duplicate_label() public { + string[] memory labels = new string[](2); + labels[0] = BASE_LABEL_A; + labels[1] = BASE_LABEL_A; + vm.expectRevert( + abi.encodeWithSelector( + IDotnsNameWhitelist.AlreadyExists.selector, _nodeOf(BASE_LABEL_A) + ) + ); + vm.prank(operator); + whitelist.grantNames(labels, ed); + } + + function test_revokeName_clears_entry() public { + vm.prank(operator); + whitelist.grantName(BASE_LABEL_A, ed); + bytes32 node = _nodeOf(BASE_LABEL_A); + + vm.expectEmit(true, true, false, true, address(whitelist)); + emit IDotnsNameWhitelist.NameRevoked(node, ed, BASE_LABEL_A); + vm.prank(operator); + whitelist.revokeName(BASE_LABEL_A); + + assertEq(whitelist.grantCount(), 0); + assertEq( + uint256(whitelist.grantOf(BASE_LABEL_A).status), + uint256(IDotnsNameWhitelist.GrantStatus.None) + ); + } + + function test_revokeName_reverts_when_absent() public { + vm.expectRevert( + abi.encodeWithSelector(IDotnsNameWhitelist.NotGranted.selector, _nodeOf(BASE_LABEL_A)) + ); + vm.prank(operator); + whitelist.revokeName(BASE_LABEL_A); + } + + function test_consume_by_public_controller_removes_grant() public { + vm.prank(operator); + whitelist.grantName(BASE_LABEL_A, ed); + bytes32 node = _nodeOf(BASE_LABEL_A); + + vm.expectEmit(true, true, false, true, address(whitelist)); + emit IDotnsNameWhitelist.NameConsumed(node, ed, BASE_LABEL_A); + vm.prank(address(dotnsRegistrarController)); + whitelist.consume(BASE_LABEL_A, ed); + + assertEq(whitelist.grantCount(), 0); + } + + function test_consume_by_pop_controller_removes_grant() public { + vm.prank(operator); + whitelist.grantName(BASE_LABEL_A, ed); + vm.prank(address(dotnsPopController)); + whitelist.consume(BASE_LABEL_A, ed); + assertEq(whitelist.grantCount(), 0); + } + + function test_consume_reverts_for_non_controller() public { + vm.prank(operator); + whitelist.grantName(BASE_LABEL_A, ed); + vm.expectRevert(abi.encodeWithSelector(IDotnsNameWhitelist.NotController.selector, ed)); + vm.prank(ed); + whitelist.consume(BASE_LABEL_A, ed); + } + + function test_consume_reverts_for_wrong_registrant() public { + vm.prank(operator); + whitelist.grantName(BASE_LABEL_A, ed); + vm.expectRevert( + abi.encodeWithSelector( + IDotnsNameWhitelist.NotGrantee.selector, tiago, _nodeOf(BASE_LABEL_A) + ) + ); + vm.prank(address(dotnsRegistrarController)); + whitelist.consume(BASE_LABEL_A, tiago); + } + + function test_consume_reverts_when_only_requested() public { + _request(ed, BASE_LABEL_A); + vm.expectRevert( + abi.encodeWithSelector( + IDotnsNameWhitelist.NotGrantee.selector, ed, _nodeOf(BASE_LABEL_A) + ) + ); + vm.prank(address(dotnsRegistrarController)); + whitelist.consume(BASE_LABEL_A, ed); + } + + function test_consume_reverts_after_reject() public { + _request(ed, BASE_LABEL_A); + vm.prank(operator); + whitelist.reject(BASE_LABEL_A); + vm.expectRevert( + abi.encodeWithSelector( + IDotnsNameWhitelist.NotGrantee.selector, ed, _nodeOf(BASE_LABEL_A) + ) + ); + vm.prank(address(dotnsRegistrarController)); + whitelist.consume(BASE_LABEL_A, ed); + } + + function test_full_lifecycle_request_accept_consume() public { + _request(ed, BASE_LABEL_A); + vm.prank(operator); + whitelist.accept(BASE_LABEL_A); + assertTrue(whitelist.isGrantedTo(BASE_LABEL_A, ed)); + + vm.prank(address(dotnsRegistrarController)); + whitelist.consume(BASE_LABEL_A, ed); + + assertEq(whitelist.grantCount(), 0); + assertEq( + uint256(whitelist.grantOf(BASE_LABEL_A).status), + uint256(IDotnsNameWhitelist.GrantStatus.None) + ); + } + + function test_setWindow_sets_and_emits() public { + uint64 startsIn = 1 days; + uint64 duration = 5 days; + uint64 openAt = uint64(block.timestamp) + startsIn; + uint64 closeAt = openAt + duration; + + vm.expectEmit(false, false, false, true, address(whitelist)); + emit IDotnsNameWhitelist.WindowSet(openAt, closeAt); + vm.prank(owner); + whitelist.setWindow(startsIn, duration); + + (uint64 gotOpen, uint64 gotClose) = whitelist.window(); + assertEq(gotOpen, openAt); + assertEq(gotClose, closeAt); + } + + function test_isWindowOpen_tracks_the_window() public { + uint64 openAt = uint64(block.timestamp) + 1 days; + uint64 closeAt = openAt + 1 days; + vm.prank(owner); + whitelist.setWindow(1 days, 1 days); + + assertFalse(whitelist.isWindowOpen()); + + vm.warp(openAt); + assertTrue(whitelist.isWindowOpen()); + + vm.warp(closeAt); + assertFalse(whitelist.isWindowOpen()); + } + + function test_setWindow_reverts_for_zero_duration() public { + vm.expectRevert(IDotnsNameWhitelist.BadWindow.selector); + vm.prank(owner); + whitelist.setWindow(1 days, 0); + } + + function test_setWindow_reverts_for_non_owner() public { + vm.expectRevert( + abi.encodeWithSelector(OwnableUpgradeable.OwnableUnauthorizedAccount.selector, operator) + ); + vm.prank(operator); + whitelist.setWindow(0, 1 days); + } + + function test_initialize_reverts_on_second_call() public { + vm.expectRevert(Initializable.InvalidInitialization.selector); + whitelist.initialize(IDotnsProtocolRegistry(address(protocolRegistry))); + } + + function test_grants_pagination_boundaries() public { + string[] memory labels = new string[](3); + labels[0] = BASE_LABEL_A; + labels[1] = BASE_LABEL_B; + labels[2] = BASE_LABEL_C; + vm.prank(operator); + whitelist.grantNames(labels, ed); + + assertEq(whitelist.grantCount(), 3); + assertEq(whitelist.grants(3, 10).length, 0); + assertEq(whitelist.grants(2, 10).length, 1); + assertEq(whitelist.grants(0, 0).length, 0); + assertEq(whitelist.grants(1, 1).length, 1); + assertEq(whitelist.grants(0, 100).length, 3); + } +}