From 8a5dabf51d7c4247448b973e3978e15b6f522d98 Mon Sep 17 00:00:00 2001 From: Siphamandla Mjoli Date: Tue, 18 Aug 2026 17:26:33 +0100 Subject: [PATCH 1/5] 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); + } +} From fc3c6436647b5449b7abc684b99853a73bd2bcb0 Mon Sep 17 00:00:00 2001 From: Siphamandla Mjoli Date: Sat, 22 Aug 2026 23:37:06 +0200 Subject: [PATCH 2/5] feat(whitelist): add pre-launch name whitelist --- contracts/external/revive/ISystem.sol | 6 + contracts/utils/DotnsConstants.sol | 23 + contracts/utils/SystemUtils.sol | 21 + contracts/whitelist/DotnsNameWhitelist.sol | 448 ++++++++++++---- contracts/whitelist/IDotnsNameWhitelist.sol | 440 ++++++++++----- docker-compose.yaml | 2 +- test/base/BaseDotns.t.sol | 10 + .../whitelist/DotnsNameWhitelistFuzz.t.sol | 120 ++--- .../DotnsNameWhitelistInvariant.t.sol | 73 +-- .../whitelist/WhitelistHandler.t.sol | 71 ++- test/unit/whitelist/DotnsNameWhitelist.t.sol | 504 ++++++++++++------ 11 files changed, 1165 insertions(+), 553 deletions(-) create mode 100644 contracts/utils/SystemUtils.sol diff --git a/contracts/external/revive/ISystem.sol b/contracts/external/revive/ISystem.sol index 9fbc8ad2..51bb8ad3 100644 --- a/contracts/external/revive/ISystem.sol +++ b/contracts/external/revive/ISystem.sol @@ -13,4 +13,10 @@ interface ISystem { /// Returning true iff the immediate caller's substrate origin is `Root`. /// Reverts on a `RuntimeOrigin::Signed(_)` or non-Root origin. function callerIsRoot() external view returns (bool); + + /// Returning true iff the transaction-level substrate origin is `Root`. + /// @dev Reads the stack origin rather than the immediate caller, so it holds through a UUPS + /// proxy's delegatecall frame where `callerIsRoot` returns false. Returns false, rather + /// than reverting, on a non-Root origin. + function originIsRoot() external view returns (bool); } diff --git a/contracts/utils/DotnsConstants.sol b/contracts/utils/DotnsConstants.sol index 5e57cfc6..93e280d2 100644 --- a/contracts/utils/DotnsConstants.sol +++ b/contracts/utils/DotnsConstants.sol @@ -44,6 +44,29 @@ library DotnsConstants { /// or change protocol configuration. bytes32 internal constant WHITELIST_OPERATOR_ROLE = keccak256("DOTNS_WHITELIST_OPERATOR_ROLE"); + /// @notice Default per-name live-claim cap the name whitelist starts with. + /// @dev Governance retunes it on the whitelist within `WHITELIST_MAX_CLAIMANTS_LIMIT`. + uint16 internal constant WHITELIST_DEFAULT_MAX_CLAIMANTS = 64; + + /// @notice Default claim-reason byte cap the name whitelist starts with. + /// @dev Governance retunes it on the whitelist within `WHITELIST_MAX_REASON_LIMIT`. + uint256 internal constant WHITELIST_DEFAULT_MAX_REASON_BYTES = 256; + + /// @notice Upper bound on the whitelist live-claim cap. Caps the claim clear-loop below the + /// block gas limit. + uint16 internal constant WHITELIST_MAX_CLAIMANTS_LIMIT = 128; + + /// @notice Upper bound on the whitelist reason byte cap. + uint256 internal constant WHITELIST_MAX_REASON_LIMIT = 256; + + /// @notice Default cap on labels granted in one `grantNames` call. + /// @dev Governance retunes it on the whitelist within `WHITELIST_MAX_GRANT_BATCH_LIMIT`. + uint16 internal constant WHITELIST_DEFAULT_MAX_GRANT_BATCH = 100; + + /// @notice Upper bound on the `grantNames` batch cap. Bounds one call below the block gas + /// limit. + uint16 internal constant WHITELIST_MAX_GRANT_BATCH_LIMIT = 256; + /// @notice Well-known key for the ERC721 registrar backing name ownership. /// @dev Role: token-of-record for registered names. Mints, burns, and tracks the /// `tokenId => label` mapping consumed by the forward registry on diff --git a/contracts/utils/SystemUtils.sol b/contracts/utils/SystemUtils.sol new file mode 100644 index 00000000..e7e19189 --- /dev/null +++ b/contracts/utils/SystemUtils.sol @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.34; + +import {ISystem} from "../external/revive/ISystem.sol"; +import {DotnsConstants} from "./DotnsConstants.sol"; + +/// @title SystemUtils +/// @notice Shared access to revive's System precompile for DotNS contracts. +/// @dev Canonical wrapper around `ISystem` at `DotnsConstants.REVIVE_SYSTEM`, so the precompile +/// address and interface are wired in one place rather than duplicated per consumer. +/// @custom:security-contact admin@parity.io +library SystemUtils { + /// @notice Returns whether the transaction-level origin is substrate Root. + /// @dev Reads the stack origin through `ISystem.originIsRoot`, which holds through a UUPS + /// proxy's delegatecall frame where `callerIsRoot` returns false, and returns false + /// rather than reverting on a non-Root origin. + /// @return root True when the transaction origin is Root. + function originIsRoot() internal view returns (bool root) { + return ISystem(DotnsConstants.REVIVE_SYSTEM).originIsRoot(); + } +} diff --git a/contracts/whitelist/DotnsNameWhitelist.sol b/contracts/whitelist/DotnsNameWhitelist.sol index d489318a..2b7983e7 100644 --- a/contracts/whitelist/DotnsNameWhitelist.sol +++ b/contracts/whitelist/DotnsNameWhitelist.sol @@ -11,22 +11,26 @@ import {IDotnsProtocolRegistry} from "../registry/IDotnsProtocolRegistry.sol"; import {LabelUtils} from "../utils/LabelUtils.sol"; import {StringUtils} from "../utils/StringUtils.sol"; import {DotnsConstants} from "../utils/DotnsConstants.sol"; +import {SystemUtils} from "../utils/SystemUtils.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. +/// @notice Pre-launch name whitelist. A name is Open until governance reserves it or a claim is +/// accepted for it. Several beneficiaries may claim the same Open name, each with a +/// reason, and governance accepts one as the winner. /// @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. +/// contract derives the node from the label and the TLD in the protocol registry, so a +/// caller cannot supply a mismatched hash. Claims are keyed by the beneficiary `user`, not +/// the submitter, so a relayer or a cross-chain sovereign account can submit on a user's +/// behalf and the name binds to that user. All state is on-chain and queryable through views; +/// no event indexing is required. A name holds at most `maxClaimants` live claims, which +/// bounds the loop that clears them on resolution. Resolving a name deletes its claims, +/// refunding their storage deposit, so only reserved or won names persist. Governance is Root +/// or the owner. Substrate Root has no address, so the governance gates check +/// `SystemUtils.originIsRoot`, which is true through the proxy's delegatecall frame, before +/// reading `msg.sender`. Operators are signed role holders +/// for day-to-day approvals; the public and PoP controllers hold only the `consume` hook. +/// Entries are keyed by the node under the active TLD, which the deployment holds immutable +/// for the whitelist's lifetime. /// @custom:security-contact admin@parity.io contract DotnsNameWhitelist is Initializable, @@ -35,16 +39,35 @@ contract DotnsNameWhitelist is IDotnsNameWhitelist { using StringUtils for string; + using EnumerableSet for EnumerableSet.AddressSet; 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 Live-claim cap per name, tunable by governance within + /// `DotnsConstants.WHITELIST_MAX_CLAIMANTS_LIMIT`. + uint16 public maxClaimants; - /// @notice Nodes with a live entry, kept enumerable so the whitelist can be reviewed. - EnumerableSet.Bytes32Set private _grantedNodes; + /// @notice Cap on labels per `grantNames` call, tunable by governance within + /// `DotnsConstants.WHITELIST_MAX_GRANT_BATCH_LIMIT`. + uint16 public maxGrantBatch; + + /// @notice Reason byte cap, tunable by governance within + /// `DotnsConstants.WHITELIST_MAX_REASON_LIMIT`. + uint256 public maxReasonBytes; + + /// @notice Resolved state per name. + mapping(bytes32 node => NameRecord record) private _names; + + /// @notice Claims per name, keyed by beneficiary. + mapping(bytes32 node => mapping(address user => Claim claim)) private _claims; + + /// @notice Beneficiaries with a live claim per name. + mapping(bytes32 node => EnumerableSet.AddressSet claimants) private _claimants; + + /// @notice Names holding reserved, claimed or claim-holding state, kept enumerable for review. + EnumerableSet.Bytes32Set private _activeNodes; /// @notice Timestamp requests start being accepted. uint64 private _requestOpen; @@ -55,9 +78,21 @@ contract DotnsNameWhitelist is /// @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 Root or the owner. + /// @dev Checks Root first so `msg.sender`, which traps under a Root origin, is read only for a + /// signed caller. + modifier onlyGovernance() { + if (!SystemUtils.originIsRoot()) { + _checkOwner(); + } + _; + } + + /// @notice Restricts a call to Root, the owner, or an operator. + modifier onlyOperatorOrGovernance() { + if (!SystemUtils.originIsRoot()) { + _checkRoleOrOwner(DotnsConstants.WHITELIST_OPERATOR_ROLE); + } _; } @@ -77,105 +112,217 @@ contract DotnsNameWhitelist is } /// @notice Initialises the whitelist. - /// @dev Callable once through the UUPS proxy; direct calls on the implementation revert with + /// @dev Callable once through the UUPS proxy; direct calls on the implementation /// @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 { + __ERC165_init(); __Ownable_init(msg.sender); _dotnsRoleManagerInit(); protocolRegistry = registry; + maxClaimants = DotnsConstants.WHITELIST_DEFAULT_MAX_CLAIMANTS; + maxGrantBatch = DotnsConstants.WHITELIST_DEFAULT_MAX_GRANT_BATCH; + maxReasonBytes = DotnsConstants.WHITELIST_DEFAULT_MAX_REASON_BYTES; } /// @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); + function setOperator(address account, bool enabled) external override onlyGovernance { + _setRole(DotnsConstants.WHITELIST_OPERATOR_ROLE, account, enabled); + } + + /// @inheritdoc IDotnsNameWhitelist + function setMaxClaimants(uint16 newMax) external override onlyGovernance { + require( + newMax > 0 && newMax <= DotnsConstants.WHITELIST_MAX_CLAIMANTS_LIMIT, + MaxClaimantsOutOfRange() + ); + maxClaimants = newMax; + emit MaxClaimantsSet(newMax); + } + + /// @inheritdoc IDotnsNameWhitelist + function setMaxReasonBytes(uint256 newMax) external override onlyGovernance { + require( + newMax > 0 && newMax <= DotnsConstants.WHITELIST_MAX_REASON_LIMIT, + MaxReasonBytesOutOfRange() + ); + maxReasonBytes = newMax; + emit MaxReasonBytesSet(newMax); + } + + /// @inheritdoc IDotnsNameWhitelist + function setMaxGrantBatch(uint16 newMax) external override onlyGovernance { + require( + newMax > 0 && newMax <= DotnsConstants.WHITELIST_MAX_GRANT_BATCH_LIMIT, + MaxGrantBatchOutOfRange() + ); + maxGrantBatch = newMax; + emit MaxGrantBatchSet(newMax); } /// @inheritdoc IDotnsNameWhitelist - function requestName(string calldata label) external override { + function requestName( + string calldata label, + string calldata reason, + address user + ) + external + override + { require(_isWindowOpen(), WindowClosed()); - bytes32 node = _validateNew(label); - _grants[node] = Grant({ - grantee: msg.sender, + require(user != address(0), ZeroUser()); + require(bytes(reason).length <= maxReasonBytes, ReasonTooLong()); + require(label.isSingleLabel(), InvalidLabel()); + + bytes32 node = _nodeOf(label); + require(_names[node].status == NameStatus.Open, NameNotOpen(node)); + require(_claims[node][user].status == ClaimStatus.None, AlreadyClaimed(node, user)); + require(_claimants[node].length() < maxClaimants, TooManyClaimants(node)); + + _claims[node][user] = Claim({ + user: user, + status: ClaimStatus.Requested, requestedAt: uint64(block.timestamp), - status: GrantStatus.Requested, - decidedAt: 0, - label: label + reason: reason }); - _grantedNodes.add(node); - emit NameRequested(node, msg.sender, label); + _claimants[node].add(user); + _activate(node, label); + emit NameRequested(node, user, label); } /// @inheritdoc IDotnsNameWhitelist - function accept(string calldata label) external override onlyOperatorOrOwner { - (bytes32 node, address grantee) = _decide(label, GrantStatus.Accepted); - emit NameAccepted(node, grantee, label); + function accept( + string calldata label, + address user + ) + external + override + onlyOperatorOrGovernance + { + bytes32 node = _nodeOf(label); + require(_claims[node][user].status == ClaimStatus.Requested, NotRequested(node, user)); + emit NameAccepted(node, user, label); + _settle(node, user, label); } /// @inheritdoc IDotnsNameWhitelist - function reject(string calldata label) external override onlyOperatorOrOwner { - (bytes32 node, address grantee) = _decide(label, GrantStatus.Rejected); - emit NameRejected(node, grantee, label); + function reject( + string calldata label, + address user + ) + external + override + onlyOperatorOrGovernance + { + bytes32 node = _nodeOf(label); + require(_claims[node][user].status == ClaimStatus.Requested, NotRequested(node, user)); + delete _claims[node][user]; + _claimants[node].remove(user); + emit NameRejected(node, user, label); + _deactivate(node); } /// @inheritdoc IDotnsNameWhitelist function grantName( string calldata label, - address grantee + address user ) external override - onlyOperatorOrOwner + onlyOperatorOrGovernance { - _grant(label, grantee); + _grant(label, user); } /// @inheritdoc IDotnsNameWhitelist function grantNames( string[] calldata labels, - address grantee + address user ) external override - onlyOperatorOrOwner + onlyOperatorOrGovernance { + require(labels.length <= maxGrantBatch, TooManyLabels()); for (uint256 i = 0; i < labels.length; i++) { - _grant(labels[i], grantee); + _grant(labels[i], user); } } /// @inheritdoc IDotnsNameWhitelist - function revokeName(string calldata label) external override onlyOperatorOrOwner { + function revokeName(string calldata label) external override onlyOperatorOrGovernance { 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); + NameRecord storage record = _names[node]; + require( + record.status == NameStatus.Claimed || _claimants[node].length() != 0, + NothingToRevoke(node) + ); + address winner = record.winner; + _clearClaimants(node, address(0), label); + record.status = NameStatus.Open; + record.winner = address(0); + emit NameRevoked(node, winner, label); + _deactivate(node); + } + + /// @inheritdoc IDotnsNameWhitelist + function setReserved(string calldata label, bool reserved) external override onlyGovernance { + require(label.isSingleLabel(), InvalidLabel()); + bytes32 node = _nodeOf(label); + NameRecord storage record = _names[node]; + if (reserved) { + require(record.status == NameStatus.Open, NameNotOpen(node)); + require(_claimants[node].length() == 0, HasClaims(node)); + record.status = NameStatus.Reserved; + _activate(node, label); + emit NameReserved(node, label); + } else { + require(record.status == NameStatus.Reserved, NotReserved(node)); + record.status = NameStatus.Open; + emit NameUnreserved(node, label); + _deactivate(node); + } } /// @inheritdoc IDotnsNameWhitelist function consume(string calldata label, address registrant) external override onlyController { bytes32 node = _nodeOf(label); - Grant storage grant = _grants[node]; + NameRecord storage record = _names[node]; require( - grant.status == GrantStatus.Accepted && grant.grantee == registrant, - NotGrantee(registrant, node) + record.status == NameStatus.Claimed && record.winner == registrant, + NotWinner(registrant, node) ); - _clear(node); + record.status = NameStatus.Open; + record.winner = address(0); emit NameConsumed(node, registrant, label); + _deactivate(node); } /// @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); + function setWindow(uint64 startsIn, uint64 duration) external override onlyGovernance { + 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 statusOf(string calldata label) external view override returns (NameStatus status) { + return _names[_nodeOf(label)].status; + } + + /// @inheritdoc IDotnsNameWhitelist + function isReserved(string calldata label) external view override returns (bool reserved) { + return _names[_nodeOf(label)].status == NameStatus.Reserved; + } + + /// @inheritdoc IDotnsNameWhitelist + function granteeOf(string calldata label) external view override returns (address winner) { + NameRecord storage record = _names[_nodeOf(label)]; + return record.status == NameStatus.Claimed ? record.winner : address(0); } /// @inheritdoc IDotnsNameWhitelist @@ -188,43 +335,82 @@ contract DotnsNameWhitelist is override returns (bool granted) { - Grant storage grant = _grants[_nodeOf(label)]; + NameRecord storage record = _names[_nodeOf(label)]; return - account != address(0) && grant.status == GrantStatus.Accepted - && grant.grantee == account; + account != address(0) && record.status == NameStatus.Claimed && record.winner == account; } /// @inheritdoc IDotnsNameWhitelist - function grantOf(string calldata label) external view override returns (Grant memory grant) { - return _grants[_nodeOf(label)]; + function claimOf( + string calldata label, + address user + ) + external + view + override + returns (Claim memory claim) + { + return _claims[_nodeOf(label)][user]; } /// @inheritdoc IDotnsNameWhitelist - function grantCount() external view override returns (uint256 count) { - return _grantedNodes.length(); + function claimantCount(string calldata label) external view override returns (uint256 count) { + return _claimants[_nodeOf(label)].length(); } /// @inheritdoc IDotnsNameWhitelist - function grants( + function claims( + string calldata label, uint256 offset, uint256 limit ) external view override - returns (Grant[] memory page) + returns (Claim[] memory page) { - uint256 total = _grantedNodes.length(); + bytes32 node = _nodeOf(label); + EnumerableSet.AddressSet storage set = _claimants[node]; + uint256 total = set.length(); if (offset >= total) { - return new Grant[](0); + return new Claim[](0); } - uint256 available = total - offset; uint256 count = limit < available ? limit : available; + page = new Claim[](count); + for (uint256 i; i < count; ++i) { + page[i] = _claims[node][set.at(offset + i)]; + } + } + + /// @inheritdoc IDotnsNameWhitelist + function nameCount() external view override returns (uint256 count) { + return _activeNodes.length(); + } - page = new Grant[](count); + /// @inheritdoc IDotnsNameWhitelist + function names( + uint256 offset, + uint256 limit + ) + external + view + override + returns (NameView[] memory page) + { + uint256 total = _activeNodes.length(); + if (offset >= total) { + return new NameView[](0); + } + uint256 available = total - offset; + uint256 count = limit < available ? limit : available; + page = new NameView[](count); for (uint256 i; i < count; ++i) { - page[i] = _grants[_grantedNodes.at(offset + i)]; + bytes32 node = _activeNodes.at(offset + i); + NameRecord storage record = _names[node]; + page[i] = NameView({ + node: node, label: record.label, status: record.status, winner: record.winner + }); } } @@ -238,66 +424,96 @@ contract DotnsNameWhitelist is 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) + /// @inheritdoc DotnsRoleManager + function supportsInterface(bytes4 interfaceId) + public + view + override(DotnsRoleManager) + returns (bool supported) { - 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; + return interfaceId == type(IDotnsNameWhitelist).interfaceId + || super.supportsInterface(interfaceId); } - /// @notice Validates a canonical, unused label and returns its node. - function _validateNew(string calldata label) internal view returns (bytes32 node) { + /// @notice Grants `label` to `user` directly, clearing any pending claims. + /// @param label Bare label to grant. + /// @param user Beneficiary the name binds to. + function _grant(string calldata label, address user) internal { + require(user != address(0), ZeroUser()); require(label.isSingleLabel(), InvalidLabel()); - node = _nodeOf(label); - require(_grants[node].status == GrantStatus.None, AlreadyExists(node)); + bytes32 node = _nodeOf(label); + require(_names[node].status == NameStatus.Open, NameNotOpen(node)); + emit NameAccepted(node, user, label); + _settle(node, user, label); + } + + /// @notice Marks a name claimed for `winner` and clears its claims, rejecting the losers. + /// @param node Namehash of the label under the active TLD. + /// @param winner Beneficiary the name binds to. + /// @param label Bare label, stored for review. + function _settle(bytes32 node, address winner, string calldata label) internal { + NameRecord storage record = _names[node]; + record.status = NameStatus.Claimed; + record.winner = winner; + _activate(node, label); + _clearClaimants(node, winner, label); + } + + /// @notice Deletes every claim on a name, rejecting each claimant that is not `winner`. + /// @param node Namehash of the label under the active TLD. + /// @param winner Claimant spared a rejection event; the zero address rejects every claimant. + /// @param label Bare label emitted with each rejection. + function _clearClaimants(bytes32 node, address winner, string calldata label) internal { + address[] memory current = _claimants[node].values(); + for (uint256 i; i < current.length; ++i) { + address claimant = current[i]; + delete _claims[node][claimant]; + _claimants[node].remove(claimant); + if (claimant != winner) { + emit NameRejected(node, claimant, label); + } + } + } + + /// @notice Records a name as active and stores its label the first time it is seen. + /// @param node Namehash of the label under the active TLD. + /// @param label Bare label stored on first activation. + function _activate(bytes32 node, string calldata label) internal { + NameRecord storage record = _names[node]; + if (bytes(record.label).length == 0) { + record.label = label; + } + _activeNodes.add(node); + } + + /// @notice Drops a name from the active set once it is Open with no claims. + /// @param node Namehash of the label under the active TLD. + function _deactivate(bytes32 node) internal { + NameRecord storage record = _names[node]; + if (record.status == NameStatus.Open && _claimants[node].length() == 0) { + _activeNodes.remove(node); + delete record.label; + } } /// @notice Derives the namehash of `label` under the active TLD read from the registry. + /// @param label Bare label to hash. + /// @return node Namehash of the label under the active TLD. 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. + /// @return open True when the current time is within the 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. + /// @inheritdoc UUPSUpgradeable function _authorizeUpgrade(address newImplementation) internal override onlyOwner {} } diff --git a/contracts/whitelist/IDotnsNameWhitelist.sol b/contracts/whitelist/IDotnsNameWhitelist.sol index 73ae4ea6..0996eb55 100644 --- a/contracts/whitelist/IDotnsNameWhitelist.sol +++ b/contracts/whitelist/IDotnsNameWhitelist.sol @@ -2,191 +2,322 @@ 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. +/// @notice Interface for the pre-launch name whitelist. A name is Open until governance either +/// reserves it or a claim is accepted for it. Several beneficiaries may claim the same +/// Open name, each with a reason, and governance accepts one as the winner. +/// @dev Callers never supply a hash. Every entry point takes the bare label and derives the node +/// from the label and the TLD in the protocol registry, so a caller cannot supply a +/// mismatched hash. Claims are keyed by the beneficiary `user`, not the submitter, so a +/// relayer or a cross-chain sovereign account can submit a claim on a user's behalf and the +/// name still binds to that user. All state is on-chain and queryable through views; no event +/// indexing is required. Governance is Root or the owner. Substrate Root has no address, so +/// the governance gates check `originIsRoot` before reading `msg.sender`. Operators are signed +/// role holders for day-to-day approvals; the controllers hold only the `consume` hook. /// @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 { + /// @notice Status of a name. + /// @dev `Open` is the zero-value default: claimable, not reserved, not won. `Reserved` is + /// withheld by governance. `Claimed` has a single winner. + enum NameStatus { + Open, + Reserved, + Claimed + } + + /// @notice Status of a single claim on a name. + /// @dev `None` is the zero-value default of an absent claim. A claim is deleted when it is + /// rejected, cleared on a win, or consumed, so it never holds a terminal status. + enum ClaimStatus { None, - Requested, - Accepted, - Rejected + Requested } - /// @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; + /// @notice A claim by one beneficiary on one name. + /// @dev `user`, `status` and `requestedAt` co-locate in one storage slot; the dynamic `reason` + /// is stored separately. + /// @param user Beneficiary the name would bind to if this claim wins. + /// @param status Claim status; see ClaimStatus. + /// @param requestedAt Timestamp the claim was made. + /// @param reason Free-text justification for the claim. + struct Claim { + address user; + ClaimStatus status; uint64 requestedAt; - GrantStatus status; - uint64 decidedAt; - string label; + string reason; } - /// @notice Emitted when a name is requested. + /// @notice A name and its resolved state, for review. /// @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); + /// @param label Bare label. + /// @param status Name status; see NameStatus. + /// @param winner Winning beneficiary when `Claimed`, otherwise the zero address. + struct NameView { + bytes32 node; + string label; + NameStatus status; + address winner; + } - /// @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 Stored resolved state of a name. + /// @dev `status` and `winner` are ordered first so the 1-byte enum and 20-byte address share + /// one storage slot; the dynamic `label` is stored separately. + /// @param status Name status; see NameStatus. + /// @param winner Winning beneficiary when `Claimed`, otherwise the zero address. + /// @param label Bare label, kept so reserved and claimed names are reviewable. + struct NameRecord { + NameStatus status; + address winner; + 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 a beneficiary claims a name. + event NameRequested(bytes32 indexed node, address indexed user, 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 claim wins a name, including an operator direct grant. + event NameAccepted(bytes32 indexed node, address indexed user, 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 a claim is cleared without winning. + event NameRejected(bytes32 indexed node, address indexed user, string label); + + /// @notice Emitted when a name is reset to Open by governance. + event NameRevoked(bytes32 indexed node, address indexed winner, string label); + + /// @notice Emitted when a winner registers the name and its entry is consumed. + event NameConsumed(bytes32 indexed node, address indexed user, string label); + + /// @notice Emitted when governance withholds a name from claiming. + event NameReserved(bytes32 indexed node, string label); + + /// @notice Emitted when governance releases a reserved name back to Open. + event NameUnreserved(bytes32 indexed node, 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 Emitted when the live-claim cap is set. + /// @param maxClaimants New per-name claim cap. + event MaxClaimantsSet(uint16 maxClaimants); + + /// @notice Emitted when the reason byte cap is set. + /// @param maxReasonBytes New reason byte cap. + event MaxReasonBytesSet(uint256 maxReasonBytes); + + /// @notice Emitted when the grant-batch cap is set. + /// @param maxGrantBatch New `grantNames` batch cap. + event MaxGrantBatchSet(uint16 maxGrantBatch); + + /// @notice Thrown when a claim names the zero-address beneficiary. + error ZeroUser(); /// @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. + /// @notice Thrown when a reason exceeds `maxReasonBytes`. + error ReasonTooLong(); + + /// @notice Thrown when a name is not Open and the action requires it. + /// @param node Namehash of the label under the active TLD. + error NameNotOpen(bytes32 node); + + /// @notice Thrown when `user` already holds a claim on the name. + /// @param node Namehash of the label under the active TLD. + /// @param user Beneficiary already holding a claim. + error AlreadyClaimed(bytes32 node, address user); + + /// @notice Thrown when a name already holds `maxClaimants` claims. + /// @param node Namehash of the label under the active TLD. + error TooManyClaimants(bytes32 node); + + /// @notice Thrown when the claim cap is set to zero or above + /// `DotnsConstants.WHITELIST_MAX_CLAIMANTS_LIMIT`. + error MaxClaimantsOutOfRange(); + + /// @notice Thrown when the reason cap is set to zero or above + /// `DotnsConstants.WHITELIST_MAX_REASON_LIMIT`. + error MaxReasonBytesOutOfRange(); + + /// @notice Thrown when the grant-batch cap is set to zero or above + /// `DotnsConstants.WHITELIST_MAX_GRANT_BATCH_LIMIT`. + error MaxGrantBatchOutOfRange(); + + /// @notice Thrown when a claim is not in the `Requested` status. + /// @param node Namehash of the label under the active TLD. + /// @param user Beneficiary whose claim was expected to be pending. + error NotRequested(bytes32 node, address user); + + /// @notice Thrown when reserving a name that still holds claims. /// @param node Namehash of the label under the active TLD. - error AlreadyExists(bytes32 node); + error HasClaims(bytes32 node); - /// @notice Thrown when accepting or rejecting a name that is not in the `Requested` status. + /// @notice Thrown when releasing a name that is not reserved. /// @param node Namehash of the label under the active TLD. - error NotRequested(bytes32 node); + error NotReserved(bytes32 node); - /// @notice Thrown when clearing a name that holds no entry. + /// @notice Thrown when revoking a name that is not Claimed and holds no claims. /// @param node Namehash of the label under the active TLD. - error NotGranted(bytes32 node); + error NothingToRevoke(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. + /// @notice Thrown when `consume` is called for a name not won by 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); + error NotWinner(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. + /// @notice Thrown when a claim 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 Thrown when `grantNames` is passed more than `maxGrantBatch` labels. + error TooManyLabels(); - /// @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 + /// @notice Claims `label` for `user`. + /// @dev Permissionless within the window; the submitter may differ from `user`. Requires the + /// name Open, the window open, `user` non-zero, a canonical label, `user` without an + /// existing claim, and fewer than `maxClaimants` claims on the name. + /// @custom:reverts WindowClosed, @custom:reverts NameNotOpen, @custom:reverts ZeroUser, + /// @custom:reverts InvalidLabel, @custom:reverts ReasonTooLong, + /// @custom:reverts AlreadyClaimed, or @custom:reverts TooManyClaimants. /// @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 claim. + /// @param reason Free-text justification, at most `maxReasonBytes` bytes. + /// @param user Beneficiary the name binds to if this claim wins. + function requestName(string calldata label, string calldata reason, address user) external; + + /// @notice Accepts `user`'s claim as the winner of `label`. + /// @dev Restricted to an operator, the owner, or Root. Requires `user`'s claim `Requested`. + /// Sets the name `Claimed` with `user` the winner and clears every claim on the name, rejecting + /// the losers. @custom:reverts NotRequested. @custom:emits NameAccepted for the winner and + /// @custom:emits NameRejected for each loser. + /// @param label Bare label to resolve. + /// @param user Beneficiary whose claim wins. + function accept(string calldata label, address user) external; + + /// @notice Rejects `user`'s pending claim on `label` without resolving the name. + /// @dev Restricted to an operator, the owner, or Root. Requires the claim `Requested`. + /// @custom:reverts NotRequested. @custom:emits NameRejected. + /// @param label Bare label. + /// @param user Beneficiary whose claim is rejected. + function reject(string calldata label, address user) external; + + /// @notice Grants `label` to `user` directly, without a prior claim. + /// @dev Restricted to an operator, the owner, or Root. Requires the name Open, `user` non-zero + /// and a canonical label. Sets the name `Claimed` with `user` the winner and clears any pending + /// claims. @custom:reverts NameNotOpen, @custom:reverts ZeroUser or + /// @custom:reverts InvalidLabel. @custom:emits NameAccepted, and + /// @custom:emits NameRejected for each cleared claim. /// @param label Bare label to grant. - /// @param grantee Address permitted to register the name. - function grantName(string calldata label, address grantee) external; + /// @param user Beneficiary the name binds to. + function grantName(string calldata label, address user) 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. + /// @notice Grants several labels to one `user` directly. + /// @dev Restricted to an operator, the owner, or Root. Applies @custom:function grantName to + /// each, at most `maxGrantBatch` labels per call. + /// @custom:reverts TooManyLabels when `labels` exceeds the batch cap. /// @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. + /// @param user Beneficiary each name binds to. + function grantNames(string[] calldata labels, address user) external; + + /// @notice Resets `label` to Open, clearing any winner and claims. + /// @dev Restricted to an operator, the owner, or Root. Resolves a Claimed or claim-holding + /// name; a Reserved name is released through @custom:function setReserved, not here. + /// @custom:reverts NothingToRevoke when the name is not Claimed and holds no claims. + /// @custom:emits NameRevoked, and @custom:emits NameRejected for each cleared claim. + /// @param label Bare label to reset. 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. + /// @notice Reserves or releases `label`. + /// @dev Restricted to Root or the owner. Reserving requires the name Open with no claims; + /// releasing requires it `Reserved`. @custom:reverts NameNotOpen, @custom:reverts HasClaims or + /// @custom:reverts NotReserved. @custom:emits NameReserved or @custom:emits + /// NameUnreserved. @param label Bare label. + /// @param reserved True to reserve, false to release. + function setReserved(string calldata label, bool reserved) external; + + /// @notice Removes the win on `label` as `registrant` registers it. + /// @dev Restricted to the registrar controllers resolved through the protocol registry. Resets + /// the name to Open. @custom:reverts NotController for any other caller and + /// @custom:reverts NotWinner when `label` is not won by `registrant`. + /// @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. + /// @notice Sets the request window relative to the current time. + /// @dev Restricted to Root or the owner. Opens at `block.timestamp + startsIn` for `duration`. + /// @custom:reverts BadWindow when `duration` is zero. @custom:emits WindowSet. + /// @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 Grants or revokes the operator role for `account`. + /// @dev Restricted to Root or the owner. Root has no address, so governance uses this rather + /// than the owner-only role-admin path. @custom:emits IAccessControl.RoleGranted on grant + /// and @custom:emits IAccessControl.RoleRevoked on revoke. + /// @param account Address whose operator role is changed. + /// @param enabled True to grant, false to revoke. + function setOperator(address account, bool enabled) external; + + /// @notice Sets the live-claim cap per name. + /// @dev Restricted to Root or the owner. The cap is bounded by + /// `DotnsConstants.WHITELIST_MAX_CLAIMANTS_LIMIT`, which bounds the resolution clear-loop. + /// @custom:reverts MaxClaimantsOutOfRange when `newMax` is zero or above the ceiling. + /// @custom:emits MaxClaimantsSet. + /// @param newMax New per-name claim cap. + function setMaxClaimants(uint16 newMax) external; + + /// @notice Sets the reason byte cap. + /// @dev Restricted to Root or the owner, bounded by + /// `DotnsConstants.WHITELIST_MAX_REASON_LIMIT`. @custom:reverts MaxReasonBytesOutOfRange when + /// `newMax` is zero or above the ceiling. @custom:emits MaxReasonBytesSet. + /// @param newMax New reason byte cap. + function setMaxReasonBytes(uint256 newMax) external; + + /// @notice Sets the cap on labels per `grantNames` call. + /// @dev Restricted to Root or the owner, bounded by + /// `DotnsConstants.WHITELIST_MAX_GRANT_BATCH_LIMIT`. @custom:reverts MaxGrantBatchOutOfRange + /// when `newMax` is zero or above the ceiling. @custom:emits MaxGrantBatchSet. + /// @param newMax New batch cap. + function setMaxGrantBatch(uint16 newMax) external; + + /// @notice Returns the live-claim cap per name. + /// @return cap Current per-name claim cap. + function maxClaimants() external view returns (uint16 cap); + + /// @notice Returns the reason byte cap. + /// @return cap Current reason byte cap. + function maxReasonBytes() external view returns (uint256 cap); + + /// @notice Returns the cap on labels per `grantNames` call. + /// @return cap Current batch cap. + function maxGrantBatch() external view returns (uint16 cap); + + /// @notice Returns the status of `label`. + /// @param label Bare label to look up. + /// @return status Name status; see NameStatus. + function statusOf(string calldata label) external view returns (NameStatus status); + + /// @notice Returns whether `label` is reserved. /// @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); + /// @return reserved True when the name is `Reserved`. + function isReserved(string calldata label) external view returns (bool reserved); - /// @notice Returns whether `account` holds an accepted grant for `label`. + /// @notice Returns the winner of `label`, or the zero address when not `Claimed`. + /// @param label Bare label to look up. + /// @return winner Winning beneficiary. + function granteeOf(string calldata label) external view returns (address winner); + + /// @notice Returns whether `account` won `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. + /// @param account Address to test against the winner. + /// @return granted True when `account` is the winner. function isGrantedTo( string calldata label, address account @@ -195,23 +326,42 @@ interface IDotnsNameWhitelist { view returns (bool granted); - /// @notice Returns the full entry for `label`, including status and timestamps. + /// @notice Returns `user`'s claim on `label`. + /// @param label Bare label to look up. + /// @param user Beneficiary to look up. + /// @return claim The stored claim; a zeroed struct with `None` status when absent. + function claimOf(string calldata label, address user) external view returns (Claim memory claim); + + /// @notice Returns the number of live claims on `label`. /// @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); + /// @return count Live claim count. + function claimantCount(string calldata label) external view returns (uint256 count); + + /// @notice Returns a page of claims on `label` for review. + /// @dev Reads the canonical offset and limit window. + /// @param label Bare label to look up. + /// @param offset Index of the first claim. + /// @param limit Maximum number of claims to return. + /// @return page Claims in the window. + function claims( + string calldata label, + uint256 offset, + uint256 limit + ) + external + view + returns (Claim[] memory page); + + /// @notice Returns the number of names with reserved, claimed or claim-holding state. + /// @return count Active name count. + function nameCount() external view returns (uint256 count); + + /// @notice Returns a page of active names for review. + /// @dev Reads the canonical offset and limit window. Iteration order is not stable. + /// @param offset Index of the first name. + /// @param limit Maximum number of names to return. + /// @return page Names in the window. + function names(uint256 offset, uint256 limit) external view returns (NameView[] memory page); /// @notice Returns the request window. /// @return openAt Timestamp requests start being accepted. diff --git a/docker-compose.yaml b/docker-compose.yaml index 3a719bd4..7e21bf01 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -15,7 +15,7 @@ services: - "8545:8545" command: - "--node-rpc-url" - - "wss://paseo-asset-hub-next-rpc.polkadot.io" + - "wss://previewnet.substrate.dev/asset-hub" - "--rpc-port" - "8545" - "--rpc-external" diff --git a/test/base/BaseDotns.t.sol b/test/base/BaseDotns.t.sol index 105ebfa9..e31a1ccf 100644 --- a/test/base/BaseDotns.t.sol +++ b/test/base/BaseDotns.t.sol @@ -338,6 +338,16 @@ abstract contract BaseDotns is Test { ); } + /// @notice Mocks revive's System precompile originIsRoot result. + /// @param returnValue Value to return from `originIsRoot`. + function _mockOriginIsRoot(bool returnValue) internal { + vm.mockCall( + DotnsConstants.REVIVE_SYSTEM, + abi.encodeWithSelector(ISystem.originIsRoot.selector), + abi.encode(returnValue) + ); + } + /// @notice Computes the namehash of `parent` and `labelhash`. /// @dev Thin wrapper around @custom:function LabelUtils.namehashUnder so tests /// do not reimplement the assembly composition. diff --git a/test/fuzz/whitelist/DotnsNameWhitelistFuzz.t.sol b/test/fuzz/whitelist/DotnsNameWhitelistFuzz.t.sol index 203a5754..105be38e 100644 --- a/test/fuzz/whitelist/DotnsNameWhitelistFuzz.t.sol +++ b/test/fuzz/whitelist/DotnsNameWhitelistFuzz.t.sol @@ -5,12 +5,12 @@ 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. +/// @notice Exercises claiming, competing claims, resolution and the reason bound over fuzzed +/// inputs. contract DotnsNameWhitelistFuzz is BaseDotns { DotnsNameWhitelist internal whitelist; @@ -26,11 +26,11 @@ contract DotnsNameWhitelistFuzz is BaseDotns { ) ) ); + _mockOriginIsRoot(false); 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 @@ -39,81 +39,82 @@ contract DotnsNameWhitelistFuzz is BaseDotns { 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); + function testFuzz_requestName_records(uint256 seed, address user) public { + vm.assume(user != address(0)); string memory label = _label(seed); + vm.prank(user); + whitelist.requestName(label, "reason", user); - vm.prank(owner); - whitelist.grantName(label, grantee); - - assertEq(whitelist.granteeOf(label), grantee); - assertTrue(whitelist.isGrantedTo(label, grantee)); - assertFalse(whitelist.isGrantedTo(label, other)); + IDotnsNameWhitelist.Claim memory claim = whitelist.claimOf(label, user); + assertEq(claim.user, user); + assertEq(uint256(claim.status), uint256(IDotnsNameWhitelist.ClaimStatus.Requested)); + assertEq(whitelist.claimantCount(label), 1); } - function testFuzz_request_then_accept_reserves_requester(uint256 seed) public { + function testFuzz_competing_claims_do_not_collide( + uint256 seed, + address first, + address second + ) + public + { + vm.assume(first != address(0) && second != address(0) && first != second); 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); + vm.prank(first); + whitelist.requestName(label, "first", first); + vm.prank(second); + whitelist.requestName(label, "second", second); + assertEq(whitelist.claimantCount(label), 2); } - function testFuzz_reject_never_reserves(uint256 seed) public { + function testFuzz_accept_yields_single_winner( + uint256 seed, + address first, + address second + ) + public + { + vm.assume(first != address(0) && second != address(0) && first != second); string memory label = _label(seed); + vm.prank(first); + whitelist.requestName(label, "first", first); + vm.prank(second); + whitelist.requestName(label, "second", second); - vm.prank(ed); - whitelist.requestName(label); vm.prank(owner); - whitelist.reject(label); + whitelist.accept(label, first); - assertEq(whitelist.granteeOf(label), address(0)); - assertEq( - uint256(whitelist.grantOf(label).status), - uint256(IDotnsNameWhitelist.GrantStatus.Rejected) - ); + assertEq(whitelist.granteeOf(label), first); + assertFalse(whitelist.isGrantedTo(label, second)); + assertEq(whitelist.claimantCount(label), 0); } - function testFuzz_grantName_reverts_on_duplicate(uint256 seed, address a, address b) public { - vm.assume(a != address(0) && b != address(0) && a != b); + function testFuzz_reject_never_reserves(uint256 seed, address user) public { + vm.assume(user != address(0)); string memory label = _label(seed); - + vm.prank(user); + whitelist.requestName(label, "r", user); vm.prank(owner); - whitelist.grantName(label, a); + whitelist.reject(label, user); - vm.expectRevert( - abi.encodeWithSelector(IDotnsNameWhitelist.AlreadyExists.selector, _nodeOf(label)) - ); - vm.prank(owner); - whitelist.grantName(label, b); + assertEq(whitelist.granteeOf(label), address(0)); + assertEq(uint256(whitelist.statusOf(label)), uint256(IDotnsNameWhitelist.NameStatus.Open)); } - function testFuzz_requestName_reverts_before_window_opens( - uint256 seed, - uint64 startsIn - ) - public - { - startsIn = uint64(bound(uint256(startsIn), 1 days, 3650 days)); + function testFuzz_reason_length_bound(uint256 seed, uint256 length) public { + length = bound(length, 0, 512); + string memory reason = string(new bytes(length)); string memory label = _label(seed); - vm.prank(owner); - whitelist.setWindow(startsIn, 1 days); - - vm.expectRevert(IDotnsNameWhitelist.WindowClosed.selector); - vm.prank(ed); - whitelist.requestName(label); + if (length > whitelist.maxReasonBytes()) { + vm.expectRevert(IDotnsNameWhitelist.ReasonTooLong.selector); + vm.prank(ed); + whitelist.requestName(label, reason, ed); + } else { + vm.prank(ed); + whitelist.requestName(label, reason, ed); + assertEq(whitelist.claimOf(label, ed).reason, reason); + } } function testFuzz_requestName_reverts_after_window_closes( @@ -124,13 +125,12 @@ contract DotnsNameWhitelistFuzz is BaseDotns { { 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); + whitelist.requestName(label, "r", ed); } } diff --git a/test/invariant/whitelist/DotnsNameWhitelistInvariant.t.sol b/test/invariant/whitelist/DotnsNameWhitelistInvariant.t.sol index 23782b80..c69d91db 100644 --- a/test/invariant/whitelist/DotnsNameWhitelistInvariant.t.sol +++ b/test/invariant/whitelist/DotnsNameWhitelistInvariant.t.sol @@ -10,8 +10,8 @@ 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. +/// @notice Drives the whitelist through random lifecycle sequences and asserts the name and claim +/// guarantees hold at every step. contract DotnsNameWhitelistInvariant is BaseDotns { DotnsNameWhitelist internal whitelist; WhitelistHandler internal handler; @@ -29,6 +29,7 @@ contract DotnsNameWhitelistInvariant is BaseDotns { ) ) ); + _mockOriginIsRoot(false); whitelist.setWindow(0, 3650 days); vm.stopPrank(); @@ -38,55 +39,61 @@ contract DotnsNameWhitelistInvariant is BaseDotns { } handler = new WhitelistHandler( - whitelist, owner, protocolRegistry.get(DotnsConstants.CONTROLLER), actors + whitelist, + owner, + protocolRegistry.get(DotnsConstants.CONTROLLER), + protocolRegistry.get(DotnsConstants.POP_CONTROLLER), + actors ); targetContract(address(handler)); - bytes4[] memory selectors = new bytes4[](6); + bytes4[] memory selectors = new bytes4[](8); 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; + selectors[5] = handler.setReserved.selector; + selectors[6] = handler.consume.selector; + selectors[7] = handler.tuneMaxClaimants.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 reports a winner exactly when it is `Claimed`. + function invariant_winner_iff_claimed() public view { + uint256 n = handler.labelCount(); + for (uint256 i; i < n; ++i) { + string memory label = handler.labelAt(i); + bool claimed = whitelist.statusOf(label) == IDotnsNameWhitelist.NameStatus.Claimed; + assertEq(whitelist.granteeOf(label) != address(0), claimed); } } - /// @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 A claimed name holds no live claims, and no name exceeds the hard claimant ceiling. + /// @dev Asserts against the ceiling rather than the live `maxClaimants`, since governance may + /// lower the cap below counts admitted under an earlier, higher cap. + function invariant_claim_bounds() public view { + uint256 n = handler.labelCount(); + for (uint256 i; i < n; ++i) { + string memory label = handler.labelAt(i); + uint256 count = whitelist.claimantCount(label); + assertLe(count, DotnsConstants.WHITELIST_MAX_CLAIMANTS_LIMIT); + if (whitelist.statusOf(label) == IDotnsNameWhitelist.NameStatus.Claimed) { + assertEq(count, 0); } } } - /// @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); - } + /// @notice Every active name is reserved, claimed, or holding claims. + function invariant_active_set_is_consistent() public view { + uint256 count = whitelist.nameCount(); + IDotnsNameWhitelist.NameView[] memory page = whitelist.names(0, count == 0 ? 1 : count); + assertEq(page.length, count); + for (uint256 i; i < page.length; ++i) { + IDotnsNameWhitelist.NameView memory entry = page[i]; + bool active = entry.status != IDotnsNameWhitelist.NameStatus.Open + || whitelist.claimantCount(entry.label) != 0; + assertTrue(active); } } } diff --git a/test/invariant/whitelist/WhitelistHandler.t.sol b/test/invariant/whitelist/WhitelistHandler.t.sol index 7a1d7e40..d58814e6 100644 --- a/test/invariant/whitelist/WhitelistHandler.t.sol +++ b/test/invariant/whitelist/WhitelistHandler.t.sol @@ -3,6 +3,8 @@ pragma solidity ^0.8.34; import {Test} from "forge-std/Test.sol"; import {DotnsNameWhitelist} from "../../../contracts/whitelist/DotnsNameWhitelist.sol"; +import {IDotnsNameWhitelist} from "../../../contracts/whitelist/IDotnsNameWhitelist.sol"; +import {DotnsConstants} from "../../../contracts/utils/DotnsConstants.sol"; /// @title WhitelistHandler /// @notice Drives the whitelist through its lifecycle for the invariant suite, cycling a fixed @@ -11,21 +13,22 @@ contract WhitelistHandler is Test { DotnsNameWhitelist public immutable WHITELIST; address public immutable OWNER; address public immutable CONTROLLER; + address public immutable POP_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 popController, address[] memory actors ) { WHITELIST = whitelist; OWNER = owner; CONTROLLER = controller; + POP_CONTROLLER = popController; _actors = actors; _labels.push("alicebob"); _labels.push("wonderla"); @@ -33,34 +36,43 @@ contract WhitelistHandler is Test { _labels.push("danielle"); } - function labelsSeenCount() external view returns (uint256 count) { - return labelsSeen.length; + function labelCount() external view returns (uint256 count) { + return _labels.length; + } + + function labelAt(uint256 index) external view returns (string memory label) { + return _labels[index]; } function request(uint256 actorSeed, uint256 labelSeed) external { - string memory label = _label(labelSeed); - vm.prank(_actor(actorSeed)); - try WHITELIST.requestName(label) { - _track(label); - } catch {} + address user = _actor(actorSeed); + vm.prank(user); + try WHITELIST.requestName(_label(labelSeed), "reason", user) {} catch {} } function accept(uint256 labelSeed) external { + string memory label = _label(labelSeed); + if (WHITELIST.claimantCount(label) == 0) { + return; + } + address user = WHITELIST.claims(label, 0, 1)[0].user; vm.prank(OWNER); - try WHITELIST.accept(_label(labelSeed)) {} catch {} + try WHITELIST.accept(label, user) {} catch {} } function reject(uint256 labelSeed) external { + string memory label = _label(labelSeed); + if (WHITELIST.claimantCount(label) == 0) { + return; + } + address user = WHITELIST.claims(label, 0, 1)[0].user; vm.prank(OWNER); - try WHITELIST.reject(_label(labelSeed)) {} catch {} + try WHITELIST.reject(label, user) {} 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 {} + try WHITELIST.grantName(_label(labelSeed), _actor(actorSeed)) {} catch {} } function revoke(uint256 labelSeed) external { @@ -68,14 +80,25 @@ contract WhitelistHandler is Test { try WHITELIST.revokeName(_label(labelSeed)) {} catch {} } - function consume(uint256 labelSeed) external { + function setReserved(uint256 labelSeed, bool reserved) external { + vm.prank(OWNER); + try WHITELIST.setReserved(_label(labelSeed), reserved) {} catch {} + } + + function consume(uint256 labelSeed, bool viaPop) external { string memory label = _label(labelSeed); - address grantee = WHITELIST.granteeOf(label); - if (grantee == address(0)) { + address winner = WHITELIST.granteeOf(label); + if (winner == address(0)) { return; } - vm.prank(CONTROLLER); - try WHITELIST.consume(label, grantee) {} catch {} + vm.prank(viaPop ? POP_CONTROLLER : CONTROLLER); + try WHITELIST.consume(label, winner) {} catch {} + } + + function tuneMaxClaimants(uint256 seed) external { + uint16 newMax = uint16(1 + (seed % DotnsConstants.WHITELIST_MAX_CLAIMANTS_LIMIT)); + vm.prank(OWNER); + try WHITELIST.setMaxClaimants(newMax) {} catch {} } function _actor(uint256 seed) internal view returns (address actor) { @@ -85,12 +108,4 @@ contract WhitelistHandler is Test { 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 index 6a5da706..ace34540 100644 --- a/test/unit/whitelist/DotnsNameWhitelist.t.sol +++ b/test/unit/whitelist/DotnsNameWhitelist.t.sol @@ -14,12 +14,14 @@ import { 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 +/// @notice Covers claiming, competing claims and resolution, the request window, reservation, the /// controller-only consume hook, and the review views. contract DotnsNameWhitelistTests is BaseDotns { DotnsNameWhitelist internal whitelist; address internal operator; + string internal constant REASON = "the rightful owner of this name"; + function setUp() public override { super.setUp(); operator = _createUser("operator"); @@ -35,15 +37,21 @@ contract DotnsNameWhitelistTests is BaseDotns { ) ); whitelist.setRole(DotnsConstants.WHITELIST_OPERATOR_ROLE, operator, true); + _mockOriginIsRoot(false); 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 _request(address user, string memory label) internal { + vm.prank(user); + whitelist.requestName(label, REASON, user); + } + + function _grant(address user, string memory label) internal { + vm.prank(operator); + whitelist.grantName(label, user); } function test_requestName_records_and_emits() public { @@ -52,75 +60,111 @@ contract DotnsNameWhitelistTests is BaseDotns { 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); + IDotnsNameWhitelist.Claim memory claim = whitelist.claimOf(BASE_LABEL_A, ed); + assertEq(uint256(claim.status), uint256(IDotnsNameWhitelist.ClaimStatus.Requested)); + assertEq(claim.user, ed); + assertEq(claim.requestedAt, uint64(block.timestamp)); + assertEq(claim.reason, REASON); + assertEq(whitelist.claimantCount(BASE_LABEL_A), 1); + assertEq( + uint256(whitelist.statusOf(BASE_LABEL_A)), uint256(IDotnsNameWhitelist.NameStatus.Open) + ); assertEq(whitelist.granteeOf(BASE_LABEL_A), address(0)); } - function test_requestName_reverts_when_already_exists() public { + function test_requestName_allows_competing_claims() public { _request(ed, BASE_LABEL_A); - vm.expectRevert( - abi.encodeWithSelector( - IDotnsNameWhitelist.AlreadyExists.selector, _nodeOf(BASE_LABEL_A) - ) - ); - _request(tiago, BASE_LABEL_A); + vm.prank(tiago); + whitelist.requestName(BASE_LABEL_A, "also me", tiago); + assertEq(whitelist.claimantCount(BASE_LABEL_A), 2); + } + + function test_requestName_submitter_may_differ_from_user() public { + vm.prank(tiago); + whitelist.requestName(BASE_LABEL_A, REASON, ed); + assertEq(whitelist.claimOf(BASE_LABEL_A, ed).user, ed); + assertEq(whitelist.claimantCount(BASE_LABEL_A), 1); } - function test_requestName_reverts_after_reject() public { + function test_requestName_reverts_for_same_user_twice() 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) + IDotnsNameWhitelist.AlreadyClaimed.selector, _nodeOf(BASE_LABEL_A), ed ) ); _request(ed, BASE_LABEL_A); } + function test_requestName_reverts_for_zero_user() public { + vm.expectRevert(IDotnsNameWhitelist.ZeroUser.selector); + vm.prank(ed); + whitelist.requestName(BASE_LABEL_A, REASON, address(0)); + } + + function test_requestName_reverts_for_reason_too_long() public { + string memory long = string(new bytes(whitelist.maxReasonBytes() + 1)); + vm.expectRevert(IDotnsNameWhitelist.ReasonTooLong.selector); + vm.prank(ed); + whitelist.requestName(BASE_LABEL_A, long, ed); + } + function test_requestName_reverts_for_non_canonical_label() public { vm.expectRevert(IDotnsNameWhitelist.InvalidLabel.selector); - _request(ed, "bad.label"); + vm.prank(ed); + whitelist.requestName("bad.label", REASON, ed); } - function test_requestName_reverts_before_and_after_window() public { + function test_requestName_reverts_when_reserved() public { vm.prank(owner); - whitelist.setWindow(1 days, 1 days); - - vm.expectRevert(IDotnsNameWhitelist.WindowClosed.selector); + whitelist.setReserved(BASE_LABEL_A, true); + vm.expectRevert( + abi.encodeWithSelector(IDotnsNameWhitelist.NameNotOpen.selector, _nodeOf(BASE_LABEL_A)) + ); _request(ed, BASE_LABEL_A); + } - vm.warp(block.timestamp + 3 days); + function test_requestName_reverts_outside_window() public { + vm.prank(owner); + whitelist.setWindow(1 days, 1 days); vm.expectRevert(IDotnsNameWhitelist.WindowClosed.selector); _request(ed, BASE_LABEL_A); } - function test_accept_by_operator_reserves_and_stamps() public { + function test_accept_picks_winner_and_rejects_losers() public { _request(ed, BASE_LABEL_A); + vm.prank(tiago); + whitelist.requestName(BASE_LABEL_A, "also me", tiago); bytes32 node = _nodeOf(BASE_LABEL_A); vm.expectEmit(true, true, false, true, address(whitelist)); emit IDotnsNameWhitelist.NameAccepted(node, ed, BASE_LABEL_A); + vm.expectEmit(true, true, false, true, address(whitelist)); + emit IDotnsNameWhitelist.NameRejected(node, tiago, BASE_LABEL_A); vm.prank(operator); - whitelist.accept(BASE_LABEL_A); + whitelist.accept(BASE_LABEL_A, ed); assertEq(whitelist.granteeOf(BASE_LABEL_A), ed); assertTrue(whitelist.isGrantedTo(BASE_LABEL_A, ed)); - assertEq(whitelist.grantOf(BASE_LABEL_A).decidedAt, uint64(block.timestamp)); + assertEq( + uint256(whitelist.statusOf(BASE_LABEL_A)), + uint256(IDotnsNameWhitelist.NameStatus.Claimed) + ); + assertEq(whitelist.claimantCount(BASE_LABEL_A), 0); + assertEq( + uint256(whitelist.claimOf(BASE_LABEL_A, tiago).status), + uint256(IDotnsNameWhitelist.ClaimStatus.None) + ); } function test_accept_reverts_when_not_requested() public { vm.expectRevert( - abi.encodeWithSelector(IDotnsNameWhitelist.NotRequested.selector, _nodeOf(BASE_LABEL_A)) + abi.encodeWithSelector( + IDotnsNameWhitelist.NotRequested.selector, _nodeOf(BASE_LABEL_A), ed + ) ); vm.prank(operator); - whitelist.accept(BASE_LABEL_A); + whitelist.accept(BASE_LABEL_A, ed); } function test_accept_reverts_for_unauthorised_caller() public { @@ -133,228 +177,228 @@ contract DotnsNameWhitelistTests is BaseDotns { ) ); vm.prank(tiago); - whitelist.accept(BASE_LABEL_A); + whitelist.accept(BASE_LABEL_A, ed); } - function test_reject_records_and_emits() public { + function test_reject_clears_single_claim() 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); + whitelist.reject(BASE_LABEL_A, ed); - 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)) + assertEq(whitelist.claimantCount(BASE_LABEL_A), 0); + assertEq(whitelist.nameCount(), 0); + assertEq( + uint256(whitelist.statusOf(BASE_LABEL_A)), uint256(IDotnsNameWhitelist.NameStatus.Open) ); - vm.prank(operator); - whitelist.reject(BASE_LABEL_A); } - function test_grantName_direct_by_owner() public { + function test_grantName_direct() 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); + assertEq( + uint256(whitelist.statusOf(BASE_LABEL_A)), + uint256(IDotnsNameWhitelist.NameStatus.Claimed) + ); } - function test_grantName_reverts_for_zero_grantee() public { - vm.expectRevert(IDotnsNameWhitelist.ZeroGrantee.selector); + function test_grantName_clears_pending_claims() public { + _request(tiago, BASE_LABEL_A); + bytes32 node = _nodeOf(BASE_LABEL_A); + vm.expectEmit(true, true, false, true, address(whitelist)); + emit IDotnsNameWhitelist.NameRejected(node, tiago, BASE_LABEL_A); vm.prank(operator); - whitelist.grantName(BASE_LABEL_A, address(0)); + whitelist.grantName(BASE_LABEL_A, ed); + assertEq(whitelist.granteeOf(BASE_LABEL_A), ed); + assertEq(whitelist.claimantCount(BASE_LABEL_A), 0); } - function test_grantName_reverts_for_non_canonical_label() public { - vm.expectRevert(IDotnsNameWhitelist.InvalidLabel.selector); + function test_grantName_reverts_for_zero_user() public { + vm.expectRevert(IDotnsNameWhitelist.ZeroUser.selector); vm.prank(operator); - whitelist.grantName("bad.label", ed); + whitelist.grantName(BASE_LABEL_A, address(0)); } - function test_grantName_reverts_when_already_exists() public { + function test_grantName_reverts_when_not_open() public { vm.prank(operator); whitelist.grantName(BASE_LABEL_A, ed); vm.expectRevert( - abi.encodeWithSelector( - IDotnsNameWhitelist.AlreadyExists.selector, _nodeOf(BASE_LABEL_A) - ) + abi.encodeWithSelector(IDotnsNameWhitelist.NameNotOpen.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 { + function test_grantNames_batch() 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); + assertEq(whitelist.nameCount(), 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) - ) - ); + function test_grantNames_reverts_above_batch_limit() public { + uint256 aboveBatch = uint256(whitelist.maxGrantBatch()) + 1; + string[] memory labels = new string[](aboveBatch); + vm.expectRevert(IDotnsNameWhitelist.TooManyLabels.selector); vm.prank(operator); whitelist.grantNames(labels, ed); } - function test_revokeName_clears_entry() public { - vm.prank(operator); - whitelist.grantName(BASE_LABEL_A, ed); + function test_revokeName_resets_claimed() public { + _grant(ed, BASE_LABEL_A); 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) + uint256(whitelist.statusOf(BASE_LABEL_A)), uint256(IDotnsNameWhitelist.NameStatus.Open) ); + assertEq(whitelist.nameCount(), 0); } - function test_revokeName_reverts_when_absent() public { + function test_revokeName_reverts_when_nothing_to_revoke() public { vm.expectRevert( - abi.encodeWithSelector(IDotnsNameWhitelist.NotGranted.selector, _nodeOf(BASE_LABEL_A)) + abi.encodeWithSelector( + IDotnsNameWhitelist.NothingToRevoke.selector, _nodeOf(BASE_LABEL_A) + ) ); vm.prank(operator); whitelist.revokeName(BASE_LABEL_A); } - function test_consume_by_public_controller_removes_grant() public { + function test_revokeName_clears_open_name_with_claims() public { + _request(ed, BASE_LABEL_A); + _request(tiago, BASE_LABEL_A); vm.prank(operator); - whitelist.grantName(BASE_LABEL_A, ed); + whitelist.revokeName(BASE_LABEL_A); + assertEq(whitelist.claimantCount(BASE_LABEL_A), 0); + assertEq(whitelist.nameCount(), 0); + } + + function test_revokeName_reverts_on_reserved_name() public { + vm.prank(owner); + whitelist.setReserved(BASE_LABEL_A, true); + vm.expectRevert( + abi.encodeWithSelector( + IDotnsNameWhitelist.NothingToRevoke.selector, _nodeOf(BASE_LABEL_A) + ) + ); + vm.prank(operator); + whitelist.revokeName(BASE_LABEL_A); + assertTrue(whitelist.isReserved(BASE_LABEL_A)); + } + + function test_setReserved_reserve_and_release() public { bytes32 node = _nodeOf(BASE_LABEL_A); + vm.expectEmit(true, false, false, true, address(whitelist)); + emit IDotnsNameWhitelist.NameReserved(node, BASE_LABEL_A); + vm.prank(owner); + whitelist.setReserved(BASE_LABEL_A, true); + assertTrue(whitelist.isReserved(BASE_LABEL_A)); + + vm.expectEmit(true, false, false, true, address(whitelist)); + emit IDotnsNameWhitelist.NameUnreserved(node, BASE_LABEL_A); + vm.prank(owner); + whitelist.setReserved(BASE_LABEL_A, false); + assertFalse(whitelist.isReserved(BASE_LABEL_A)); + assertEq(whitelist.nameCount(), 0); + } + + function test_setReserved_reverts_with_claims() public { + _request(ed, BASE_LABEL_A); + vm.expectRevert( + abi.encodeWithSelector(IDotnsNameWhitelist.HasClaims.selector, _nodeOf(BASE_LABEL_A)) + ); + vm.prank(owner); + whitelist.setReserved(BASE_LABEL_A, true); + } + function test_setReserved_release_reverts_when_not_reserved() public { + vm.expectRevert( + abi.encodeWithSelector(IDotnsNameWhitelist.NotReserved.selector, _nodeOf(BASE_LABEL_A)) + ); + vm.prank(owner); + whitelist.setReserved(BASE_LABEL_A, false); + } + + function test_setReserved_reverts_for_non_owner() public { + vm.expectRevert( + abi.encodeWithSelector(OwnableUpgradeable.OwnableUnauthorizedAccount.selector, operator) + ); + vm.prank(operator); + whitelist.setReserved(BASE_LABEL_A, true); + } + + function test_consume_by_public_controller() public { + _grant(ed, BASE_LABEL_A); + 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); + assertEq( + uint256(whitelist.statusOf(BASE_LABEL_A)), uint256(IDotnsNameWhitelist.NameStatus.Open) + ); + assertEq(whitelist.nameCount(), 0); } - function test_consume_by_pop_controller_removes_grant() public { - vm.prank(operator); - whitelist.grantName(BASE_LABEL_A, ed); + function test_consume_by_pop_controller() public { + _grant(ed, BASE_LABEL_A); vm.prank(address(dotnsPopController)); whitelist.consume(BASE_LABEL_A, ed); - assertEq(whitelist.grantCount(), 0); + assertEq(whitelist.granteeOf(BASE_LABEL_A), address(0)); } function test_consume_reverts_for_non_controller() public { - vm.prank(operator); - whitelist.grantName(BASE_LABEL_A, ed); + _grant(ed, BASE_LABEL_A); 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); + _grant(ed, BASE_LABEL_A); vm.expectRevert( abi.encodeWithSelector( - IDotnsNameWhitelist.NotGrantee.selector, tiago, _nodeOf(BASE_LABEL_A) + IDotnsNameWhitelist.NotWinner.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); + whitelist.accept(BASE_LABEL_A, ed); assertTrue(whitelist.isGrantedTo(BASE_LABEL_A, ed)); - vm.prank(address(dotnsRegistrarController)); whitelist.consume(BASE_LABEL_A, ed); - - assertEq(whitelist.grantCount(), 0); + assertEq(whitelist.nameCount(), 0); assertEq( - uint256(whitelist.grantOf(BASE_LABEL_A).status), - uint256(IDotnsNameWhitelist.GrantStatus.None) + uint256(whitelist.statusOf(BASE_LABEL_A)), uint256(IDotnsNameWhitelist.NameStatus.Open) ); } 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; - + uint64 openAt = uint64(block.timestamp) + 1 days; + uint64 closeAt = openAt + 5 days; vm.expectEmit(false, false, false, true, address(whitelist)); emit IDotnsNameWhitelist.WindowSet(openAt, closeAt); vm.prank(owner); - whitelist.setWindow(startsIn, duration); - + whitelist.setWindow(1 days, 5 days); (uint64 gotOpen, uint64 gotClose) = whitelist.window(); assertEq(gotOpen, openAt); assertEq(gotClose, closeAt); @@ -365,12 +409,9 @@ contract DotnsNameWhitelistTests is BaseDotns { 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()); } @@ -381,32 +422,155 @@ contract DotnsNameWhitelistTests is BaseDotns { whitelist.setWindow(1 days, 0); } - function test_setWindow_reverts_for_non_owner() public { + function test_initialize_reverts_on_second_call() public { + vm.expectRevert(Initializable.InvalidInitialization.selector); + whitelist.initialize(IDotnsProtocolRegistry(address(protocolRegistry))); + } + + function test_claims_pagination() public { + _request(ed, BASE_LABEL_A); + vm.prank(tiago); + whitelist.requestName(BASE_LABEL_A, "b", tiago); + vm.prank(leonardo); + whitelist.requestName(BASE_LABEL_A, "c", leonardo); + + assertEq(whitelist.claimantCount(BASE_LABEL_A), 3); + assertEq(whitelist.claims(BASE_LABEL_A, 3, 10).length, 0); + assertEq(whitelist.claims(BASE_LABEL_A, 2, 10).length, 1); + assertEq(whitelist.claims(BASE_LABEL_A, 0, 0).length, 0); + assertEq(whitelist.claims(BASE_LABEL_A, 0, 100).length, 3); + } + + function test_names_pagination_lists_active() public { + vm.startPrank(owner); + whitelist.grantName(BASE_LABEL_A, ed); + whitelist.setReserved(BASE_LABEL_B, true); + vm.stopPrank(); + + assertEq(whitelist.nameCount(), 2); + IDotnsNameWhitelist.NameView[] memory page = whitelist.names(0, 10); + assertEq(page.length, 2); + } + + function test_initial_caps_are_the_defaults() public view { + assertEq(whitelist.maxClaimants(), DotnsConstants.WHITELIST_DEFAULT_MAX_CLAIMANTS); + assertEq(whitelist.maxReasonBytes(), DotnsConstants.WHITELIST_DEFAULT_MAX_REASON_BYTES); + assertEq(whitelist.maxGrantBatch(), DotnsConstants.WHITELIST_DEFAULT_MAX_GRANT_BATCH); + } + + function test_setMaxClaimants_enforced() public { + vm.expectEmit(false, false, false, true, address(whitelist)); + emit IDotnsNameWhitelist.MaxClaimantsSet(1); + vm.prank(owner); + whitelist.setMaxClaimants(1); + assertEq(whitelist.maxClaimants(), 1); + + _request(ed, BASE_LABEL_A); vm.expectRevert( - abi.encodeWithSelector(OwnableUpgradeable.OwnableUnauthorizedAccount.selector, operator) + abi.encodeWithSelector( + IDotnsNameWhitelist.TooManyClaimants.selector, _nodeOf(BASE_LABEL_A) + ) ); - vm.prank(operator); - whitelist.setWindow(0, 1 days); + vm.prank(tiago); + whitelist.requestName(BASE_LABEL_A, REASON, tiago); } - function test_initialize_reverts_on_second_call() public { - vm.expectRevert(Initializable.InvalidInitialization.selector); - whitelist.initialize(IDotnsProtocolRegistry(address(protocolRegistry))); + function test_setMaxClaimants_reverts_out_of_range() public { + uint16 aboveLimit = DotnsConstants.WHITELIST_MAX_CLAIMANTS_LIMIT + 1; + + vm.expectRevert(IDotnsNameWhitelist.MaxClaimantsOutOfRange.selector); + vm.prank(owner); + whitelist.setMaxClaimants(0); + + vm.expectRevert(IDotnsNameWhitelist.MaxClaimantsOutOfRange.selector); + vm.prank(owner); + whitelist.setMaxClaimants(aboveLimit); + } + + function test_setMaxReasonBytes_enforced() public { + vm.expectEmit(false, false, false, true, address(whitelist)); + emit IDotnsNameWhitelist.MaxReasonBytesSet(4); + vm.prank(owner); + whitelist.setMaxReasonBytes(4); + assertEq(whitelist.maxReasonBytes(), 4); + + vm.expectRevert(IDotnsNameWhitelist.ReasonTooLong.selector); + vm.prank(ed); + whitelist.requestName(BASE_LABEL_A, "toolong", ed); } - function test_grants_pagination_boundaries() public { - string[] memory labels = new string[](3); + function test_setMaxReasonBytes_reverts_out_of_range() public { + uint256 aboveLimit = DotnsConstants.WHITELIST_MAX_REASON_LIMIT + 1; + + vm.expectRevert(IDotnsNameWhitelist.MaxReasonBytesOutOfRange.selector); + vm.prank(owner); + whitelist.setMaxReasonBytes(0); + + vm.expectRevert(IDotnsNameWhitelist.MaxReasonBytesOutOfRange.selector); + vm.prank(owner); + whitelist.setMaxReasonBytes(aboveLimit); + } + + function test_setMaxGrantBatch_enforced() public { + vm.expectEmit(false, false, false, true, address(whitelist)); + emit IDotnsNameWhitelist.MaxGrantBatchSet(1); + vm.prank(owner); + whitelist.setMaxGrantBatch(1); + assertEq(whitelist.maxGrantBatch(), 1); + + string[] memory labels = new string[](2); labels[0] = BASE_LABEL_A; labels[1] = BASE_LABEL_B; - labels[2] = BASE_LABEL_C; + vm.expectRevert(IDotnsNameWhitelist.TooManyLabels.selector); vm.prank(operator); whitelist.grantNames(labels, ed); + } + + function test_setMaxGrantBatch_reverts_out_of_range() public { + uint16 aboveLimit = DotnsConstants.WHITELIST_MAX_GRANT_BATCH_LIMIT + 1; + + vm.expectRevert(IDotnsNameWhitelist.MaxGrantBatchOutOfRange.selector); + vm.prank(owner); + whitelist.setMaxGrantBatch(0); + + vm.expectRevert(IDotnsNameWhitelist.MaxGrantBatchOutOfRange.selector); + vm.prank(owner); + whitelist.setMaxGrantBatch(aboveLimit); + } + + function test_setMaxClaimants_reverts_for_non_owner() public { + vm.expectRevert( + abi.encodeWithSelector(OwnableUpgradeable.OwnableUnauthorizedAccount.selector, operator) + ); + vm.prank(operator); + whitelist.setMaxClaimants(10); + } + + function test_setOperator_by_owner() public { + vm.prank(owner); + whitelist.setOperator(tiago, true); + assertTrue(whitelist.hasRole(DotnsConstants.WHITELIST_OPERATOR_ROLE, tiago)); + } + + function test_governance_root_grants_from_any_caller() public { + // Root has no address, so the gate admits the call regardless of who submits it. + _mockOriginIsRoot(true); + vm.prank(leonardo); + whitelist.grantName(BASE_LABEL_A, ed); + assertEq(whitelist.granteeOf(BASE_LABEL_A), ed); + } + + function test_governance_root_reserves_from_any_caller() public { + _mockOriginIsRoot(true); + vm.prank(leonardo); + whitelist.setReserved(BASE_LABEL_A, true); + assertTrue(whitelist.isReserved(BASE_LABEL_A)); + } - 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); + function test_governance_root_sets_operator_from_any_caller() public { + _mockOriginIsRoot(true); + vm.prank(leonardo); + whitelist.setOperator(tiago, true); + assertTrue(whitelist.hasRole(DotnsConstants.WHITELIST_OPERATOR_ROLE, tiago)); } } From 6a38e97a5a629a9501d6d1ff70480d1eb142a940 Mon Sep 17 00:00:00 2001 From: Siphamandla Mjoli Date: Mon, 24 Aug 2026 13:56:17 +0200 Subject: [PATCH 3/5] chore: address PR comments --- contracts/whitelist/DotnsNameWhitelist.sol | 21 ++++-- contracts/whitelist/IDotnsNameWhitelist.sol | 26 +++---- deployments/paseo-assethub/420420417.json | 2 +- scripts/deploy/DeployPolicy.s.sol | 23 +++++- scripts/deploy/WireDeployments.s.sol | 17 +++++ test/unit/whitelist/DotnsNameWhitelist.t.sol | 73 +++++++++++++++++--- 6 files changed, 132 insertions(+), 30 deletions(-) diff --git a/contracts/whitelist/DotnsNameWhitelist.sol b/contracts/whitelist/DotnsNameWhitelist.sol index 2b7983e7..4f844fa0 100644 --- a/contracts/whitelist/DotnsNameWhitelist.sol +++ b/contracts/whitelist/DotnsNameWhitelist.sol @@ -184,11 +184,12 @@ contract DotnsNameWhitelist is user: user, status: ClaimStatus.Requested, requestedAt: uint64(block.timestamp), + submitter: msg.sender, reason: reason }); _claimants[node].add(user); _activate(node, label); - emit NameRequested(node, user, label); + emit NameRequested(node, user, label, reason); } /// @inheritdoc IDotnsNameWhitelist @@ -216,9 +217,17 @@ contract DotnsNameWhitelist is onlyOperatorOrGovernance { bytes32 node = _nodeOf(label); - require(_claims[node][user].status == ClaimStatus.Requested, NotRequested(node, user)); - delete _claims[node][user]; + Claim storage claim = _claims[node][user]; + require(claim.status == ClaimStatus.Requested, NotRequested(node, user)); + // Free the claimant slot either way. Keep a sticky Rejected record only for a self-filed + // claim, so the beneficiary cannot simply re-request; a claim filed on their behalf is + // deleted and never binds them. _claimants[node].remove(user); + if (claim.submitter == user) { + claim.status = ClaimStatus.Rejected; + } else { + delete _claims[node][user]; + } emit NameRejected(node, user, label); _deactivate(node); } @@ -251,7 +260,7 @@ contract DotnsNameWhitelist is } /// @inheritdoc IDotnsNameWhitelist - function revokeName(string calldata label) external override onlyOperatorOrGovernance { + function revokeName(string calldata label) external override onlyGovernance { bytes32 node = _nodeOf(label); NameRecord storage record = _names[node]; require( @@ -273,7 +282,9 @@ contract DotnsNameWhitelist is NameRecord storage record = _names[node]; if (reserved) { require(record.status == NameStatus.Open, NameNotOpen(node)); - require(_claimants[node].length() == 0, HasClaims(node)); + // Clear any pending claims the way a grant does, so a permissionless requestName + // cannot force governance to revokeName first before it can reserve. + _clearClaimants(node, address(0), label); record.status = NameStatus.Reserved; _activate(node, label); emit NameReserved(node, label); diff --git a/contracts/whitelist/IDotnsNameWhitelist.sol b/contracts/whitelist/IDotnsNameWhitelist.sol index 0996eb55..d478f9dd 100644 --- a/contracts/whitelist/IDotnsNameWhitelist.sol +++ b/contracts/whitelist/IDotnsNameWhitelist.sol @@ -25,24 +25,28 @@ interface IDotnsNameWhitelist { } /// @notice Status of a single claim on a name. - /// @dev `None` is the zero-value default of an absent claim. A claim is deleted when it is - /// rejected, cleared on a win, or consumed, so it never holds a terminal status. + /// @dev `None` is the zero-value default of an absent claim. `Rejected` is sticky: it is kept + /// only when the beneficiary filed the claim themselves, so they cannot re-request; a + /// claim filed on their behalf is deleted on rejection and does not bind them. enum ClaimStatus { None, - Requested + Requested, + Rejected } /// @notice A claim by one beneficiary on one name. - /// @dev `user`, `status` and `requestedAt` co-locate in one storage slot; the dynamic `reason` - /// is stored separately. + /// @dev `user`, `status` and `requestedAt` co-locate in one storage slot; `submitter` takes the + /// next, and the dynamic `reason` is stored separately. /// @param user Beneficiary the name would bind to if this claim wins. /// @param status Claim status; see ClaimStatus. /// @param requestedAt Timestamp the claim was made. + /// @param submitter Address that filed the claim, which may differ from the beneficiary. /// @param reason Free-text justification for the claim. struct Claim { address user; ClaimStatus status; uint64 requestedAt; + address submitter; string reason; } @@ -71,7 +75,7 @@ interface IDotnsNameWhitelist { } /// @notice Emitted when a beneficiary claims a name. - event NameRequested(bytes32 indexed node, address indexed user, string label); + event NameRequested(bytes32 indexed node, address indexed user, string label, string reason); /// @notice Emitted when a claim wins a name, including an operator direct grant. event NameAccepted(bytes32 indexed node, address indexed user, string label); @@ -147,10 +151,6 @@ interface IDotnsNameWhitelist { /// @param user Beneficiary whose claim was expected to be pending. error NotRequested(bytes32 node, address user); - /// @notice Thrown when reserving a name that still holds claims. - /// @param node Namehash of the label under the active TLD. - error HasClaims(bytes32 node); - /// @notice Thrown when releasing a name that is not reserved. /// @param node Namehash of the label under the active TLD. error NotReserved(bytes32 node); @@ -233,9 +233,9 @@ interface IDotnsNameWhitelist { function revokeName(string calldata label) external; /// @notice Reserves or releases `label`. - /// @dev Restricted to Root or the owner. Reserving requires the name Open with no claims; - /// releasing requires it `Reserved`. @custom:reverts NameNotOpen, @custom:reverts HasClaims or - /// @custom:reverts NotReserved. @custom:emits NameReserved or @custom:emits + /// @dev Restricted to Root or the owner. Reserving requires the name Open and clears any + /// pending claims, rejecting each; releasing requires it `Reserved`. @custom:reverts + /// NameNotOpen or @custom:reverts NotReserved. @custom:emits NameReserved or @custom:emits /// NameUnreserved. @param label Bare label. /// @param reserved True to reserve, false to release. function setReserved(string calldata label, bool reserved) external; diff --git a/deployments/paseo-assethub/420420417.json b/deployments/paseo-assethub/420420417.json index 8d82cb61..08b85d0e 100644 --- a/deployments/paseo-assethub/420420417.json +++ b/deployments/paseo-assethub/420420417.json @@ -1 +1 @@ -{"Create3Factory":"0x8533c79E058c5a6489CAFeCA86dc600E029D75f5","DotnsContentResolver":"0x7F74D7CD50f5a834270E2ad395a01b01891AB37d","DotnsNameEscrow":"0x4881Afb78e7C908cAe818168B926229D93376520","DotnsPopController":"0xCC932348606cc1f3318cADeC5A5Cd2CA447f8a4b","DotnsPopResolver":"0xDaC984884EcA8Fc44011f1D6C49B27828390A72B","DotnsProtocolRegistry":"0xD19e3D0C97CF501125a04A97405e3e6592fa846E","DotnsRegistrar":"0x4f06E818Ba3d987704fd91cf3d868E4b019106Ab","DotnsRegistrarController":"0xBdaA01bD1bA67d709F2b1fF286Da0d854977EA30","DotnsRegistry":"0xf34054fd76BbF85f216cf9908226D5f0A72E50CA","DotnsResolver":"0xbd1165E549DF96F083c0A16f61590927bC187009","DotnsReverseResolver":"0xee3883d7eB60Ee9BCD7F3bcD8f2f05302A9Cc035","LabelStoreBeacon":"0xb57Ebc2e7085616d4906D1fE49af1cE13f7dffeF","Multicall3":"0xB4468000abD87D3c56cbFBd153161223D7b109e5","PopRules":"0x747B456bE03aec0b42bd85C51513730FBD45DA31","RootGatewayDispatcher":"0xa889CCA3Fb4B07b98a11cc54C10f13dDA20bc3db","StoreFactory":"0x709A027F446a9e2a4BB9cb9a9c754435b19e32B7","UserStoreBeacon":"0xb7C995601679840d36F37E86DB2d7dF30797eC5C","_seed":"0x0000000000000000000000000000000000000000"} \ No newline at end of file +{"Create3Factory":"0x8533c79E058c5a6489CAFeCA86dc600E029D75f5","DotnsContentResolver":"0x7F74D7CD50f5a834270E2ad395a01b01891AB37d","DotnsNameEscrow":"0x4881Afb78e7C908cAe818168B926229D93376520","DotnsNameWhitelist":"0x420166cD67Ca0233094E492a4BbA67045eD7C38C","DotnsPopController":"0xCC932348606cc1f3318cADeC5A5Cd2CA447f8a4b","DotnsPopResolver":"0xDaC984884EcA8Fc44011f1D6C49B27828390A72B","DotnsProtocolRegistry":"0xD19e3D0C97CF501125a04A97405e3e6592fa846E","DotnsRegistrar":"0x4f06E818Ba3d987704fd91cf3d868E4b019106Ab","DotnsRegistrarController":"0xBdaA01bD1bA67d709F2b1fF286Da0d854977EA30","DotnsRegistry":"0xf34054fd76BbF85f216cf9908226D5f0A72E50CA","DotnsResolver":"0xbd1165E549DF96F083c0A16f61590927bC187009","DotnsReverseResolver":"0xee3883d7eB60Ee9BCD7F3bcD8f2f05302A9Cc035","LabelStoreBeacon":"0xb57Ebc2e7085616d4906D1fE49af1cE13f7dffeF","Multicall3":"0xB4468000abD87D3c56cbFBd153161223D7b109e5","PopRules":"0x747B456bE03aec0b42bd85C51513730FBD45DA31","RootGatewayDispatcher":"0xa889CCA3Fb4B07b98a11cc54C10f13dDA20bc3db","StoreFactory":"0x709A027F446a9e2a4BB9cb9a9c754435b19e32B7","UserStoreBeacon":"0xb7C995601679840d36F37E86DB2d7dF30797eC5C","_seed":"0x0000000000000000000000000000000000000000"} diff --git a/scripts/deploy/DeployPolicy.s.sol b/scripts/deploy/DeployPolicy.s.sol index d1aba6c7..d40d5a37 100644 --- a/scripts/deploy/DeployPolicy.s.sol +++ b/scripts/deploy/DeployPolicy.s.sol @@ -6,11 +6,12 @@ import {BaseDeployer} from "./BaseDeployer.s.sol"; import {DotnsRegistrarController} from "../../contracts/registrars/DotnsRegistrarController.sol"; import {DotnsNameEscrow} from "../../contracts/escrow/DotnsNameEscrow.sol"; +import {DotnsNameWhitelist} from "../../contracts/whitelist/DotnsNameWhitelist.sol"; import {IDotnsProtocolRegistry} from "../../contracts/registry/IDotnsProtocolRegistry.sol"; /// @title DeployPolicy -/// @notice Third stage. Deploys the name escrow and the commit-reveal -/// controller, both of which bind to the protocol registry populated +/// @notice Third stage. Deploys the name escrow, the pre-launch name whitelist, and the +/// commit-reveal controller, all of which bind to the protocol registry populated /// by `DeployCore`. /// @custom:security-contact admin@parity.io contract DeployPolicy is BaseDeployer { @@ -26,6 +27,7 @@ contract DeployPolicy is BaseDeployer { address protocolRegistry = _readAddress("DotnsProtocolRegistry"); _deployNameEscrow(owner, protocolRegistry); + _deployNameWhitelist(owner, protocolRegistry); _deployRegistrarController(owner, protocolRegistry); saveDeployments(); @@ -68,4 +70,21 @@ contract DeployPolicy is BaseDeployer { "DotnsNameEscrow" ); } + + function _deployNameWhitelist( + address owner, + address protocolRegistry + ) + internal + returns (address proxy) + { + proxy = _broadcastDeployUups( + owner, + "DotnsNameWhitelist.sol:DotnsNameWhitelist", + abi.encodeCall( + DotnsNameWhitelist.initialize, (IDotnsProtocolRegistry(protocolRegistry)) + ), + "DotnsNameWhitelist" + ); + } } diff --git a/scripts/deploy/WireDeployments.s.sol b/scripts/deploy/WireDeployments.s.sol index 3ea9562f..4151aac5 100644 --- a/scripts/deploy/WireDeployments.s.sol +++ b/scripts/deploy/WireDeployments.s.sol @@ -8,6 +8,7 @@ import {DotnsRegistrar} from "../../contracts/registrars/DotnsRegistrar.sol"; import {DotnsRegistrarController} from "../../contracts/registrars/DotnsRegistrarController.sol"; import {DotnsPopController} from "../../contracts/registrars/DotnsPopController.sol"; import {DotnsNameEscrow} from "../../contracts/escrow/DotnsNameEscrow.sol"; +import {DotnsNameWhitelist} from "../../contracts/whitelist/DotnsNameWhitelist.sol"; import {IDotnsController} from "../../contracts/registrars/IDotnsController.sol"; import {DotnsRegistry} from "../../contracts/registry/DotnsRegistry.sol"; import {DotnsProtocolRegistry} from "../../contracts/registry/DotnsProtocolRegistry.sol"; @@ -46,6 +47,7 @@ contract WireDeployments is BaseDeployer { address protocolRegistry; address multicall3; address nameEscrow; + address nameWhitelist; address popResolver; address popController; address rootGatewayDispatcher; @@ -83,6 +85,7 @@ contract WireDeployments is BaseDeployer { addr.protocolRegistry = _readAddress("DotnsProtocolRegistry"); addr.multicall3 = _readAddress("Multicall3"); addr.nameEscrow = _readAddress("DotnsNameEscrow"); + addr.nameWhitelist = _readAddress("DotnsNameWhitelist"); addr.popResolver = _readAddress("DotnsPopResolver"); addr.popController = _readAddress("DotnsPopController"); addr.rootGatewayDispatcher = _readAddress("RootGatewayDispatcher"); @@ -109,6 +112,7 @@ contract WireDeployments is BaseDeployer { registry.set(DotnsConstants.POP_RULES, addr.popRules); registry.set(DotnsConstants.STORE_FACTORY, addr.storeFactory); registry.set(DotnsConstants.NAME_ESCROW, addr.nameEscrow); + registry.set(DotnsConstants.NAME_WHITELIST, addr.nameWhitelist); registry.set(DotnsConstants.MULTICALL3, addr.multicall3); registry.set(DotnsConstants.POP_CONTROLLER, addr.popController); registry.set(DotnsConstants.POP_RESOLVER, addr.popResolver); @@ -127,6 +131,7 @@ contract WireDeployments is BaseDeployer { vm.startBroadcast(owner); DotnsRegistrarController(addr.registrarController) .setRole(DotnsConstants.WHITELIST_OPERATOR_ROLE, whitelistOperator, true); + DotnsNameWhitelist(addr.nameWhitelist).setOperator(whitelistOperator, true); vm.stopBroadcast(); console.log("Whitelist operator role granted to", whitelistOperator); } @@ -159,6 +164,10 @@ contract WireDeployments is BaseDeployer { DotnsNameEscrow(payable(addr.nameEscrow)).owner() == expectedOwner, "NameEscrow: wrong owner" ); + require( + DotnsNameWhitelist(addr.nameWhitelist).owner() == expectedOwner, + "NameWhitelist: wrong owner" + ); require( DotnsPopController(addr.popController).owner() == expectedOwner, "PopController: wrong owner" @@ -191,6 +200,9 @@ contract WireDeployments is BaseDeployer { registry.get(DotnsConstants.STORE_FACTORY) == addr.storeFactory, "Key: storeFactory" ); require(registry.get(DotnsConstants.NAME_ESCROW) == addr.nameEscrow, "Key: nameEscrow"); + require( + registry.get(DotnsConstants.NAME_WHITELIST) == addr.nameWhitelist, "Key: nameWhitelist" + ); require(registry.get(DotnsConstants.MULTICALL3) == addr.multicall3, "Key: multicall3"); require( registry.get(DotnsConstants.POP_CONTROLLER) == addr.popController, "Key: popController" @@ -214,6 +226,11 @@ contract WireDeployments is BaseDeployer { .hasRole(DotnsConstants.WHITELIST_OPERATOR_ROLE, whitelistOperator), "WhitelistOperator: role not granted" ); + require( + DotnsNameWhitelist(addr.nameWhitelist) + .hasRole(DotnsConstants.WHITELIST_OPERATOR_ROLE, whitelistOperator), + "NameWhitelist operator: role not granted" + ); console.log("=== Deployment verification complete ==="); } diff --git a/test/unit/whitelist/DotnsNameWhitelist.t.sol b/test/unit/whitelist/DotnsNameWhitelist.t.sol index ace34540..2c11a878 100644 --- a/test/unit/whitelist/DotnsNameWhitelist.t.sol +++ b/test/unit/whitelist/DotnsNameWhitelist.t.sol @@ -57,12 +57,13 @@ contract DotnsNameWhitelistTests is BaseDotns { 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); + emit IDotnsNameWhitelist.NameRequested(node, ed, BASE_LABEL_A, REASON); _request(ed, BASE_LABEL_A); IDotnsNameWhitelist.Claim memory claim = whitelist.claimOf(BASE_LABEL_A, ed); assertEq(uint256(claim.status), uint256(IDotnsNameWhitelist.ClaimStatus.Requested)); assertEq(claim.user, ed); + assertEq(claim.submitter, ed); assertEq(claim.requestedAt, uint64(block.timestamp)); assertEq(claim.reason, REASON); assertEq(whitelist.claimantCount(BASE_LABEL_A), 1); @@ -195,6 +196,44 @@ contract DotnsNameWhitelistTests is BaseDotns { ); } + function test_reject_self_filed_is_sticky() public { + _request(ed, BASE_LABEL_A); + vm.prank(operator); + whitelist.reject(BASE_LABEL_A, ed); + + assertEq( + uint256(whitelist.claimOf(BASE_LABEL_A, ed).status), + uint256(IDotnsNameWhitelist.ClaimStatus.Rejected) + ); + assertEq(whitelist.claimantCount(BASE_LABEL_A), 0); + + vm.expectRevert( + abi.encodeWithSelector( + IDotnsNameWhitelist.AlreadyClaimed.selector, _nodeOf(BASE_LABEL_A), ed + ) + ); + _request(ed, BASE_LABEL_A); + } + + function test_reject_on_behalf_does_not_bind() public { + vm.prank(tiago); + whitelist.requestName(BASE_LABEL_A, REASON, ed); + assertEq(whitelist.claimOf(BASE_LABEL_A, ed).submitter, tiago); + + vm.prank(operator); + whitelist.reject(BASE_LABEL_A, ed); + assertEq( + uint256(whitelist.claimOf(BASE_LABEL_A, ed).status), + uint256(IDotnsNameWhitelist.ClaimStatus.None) + ); + + _request(ed, BASE_LABEL_A); + assertEq( + uint256(whitelist.claimOf(BASE_LABEL_A, ed).status), + uint256(IDotnsNameWhitelist.ClaimStatus.Requested) + ); + } + function test_grantName_direct() public { bytes32 node = _nodeOf(BASE_LABEL_A); vm.expectEmit(true, true, false, true, address(whitelist)); @@ -260,7 +299,7 @@ contract DotnsNameWhitelistTests is BaseDotns { 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); + vm.prank(owner); whitelist.revokeName(BASE_LABEL_A); assertEq( uint256(whitelist.statusOf(BASE_LABEL_A)), uint256(IDotnsNameWhitelist.NameStatus.Open) @@ -268,20 +307,29 @@ contract DotnsNameWhitelistTests is BaseDotns { assertEq(whitelist.nameCount(), 0); } + function test_revokeName_rejects_operator() public { + _grant(ed, BASE_LABEL_A); + vm.prank(operator); + vm.expectRevert( + abi.encodeWithSelector(OwnableUpgradeable.OwnableUnauthorizedAccount.selector, operator) + ); + whitelist.revokeName(BASE_LABEL_A); + } + function test_revokeName_reverts_when_nothing_to_revoke() public { vm.expectRevert( abi.encodeWithSelector( IDotnsNameWhitelist.NothingToRevoke.selector, _nodeOf(BASE_LABEL_A) ) ); - vm.prank(operator); + vm.prank(owner); whitelist.revokeName(BASE_LABEL_A); } function test_revokeName_clears_open_name_with_claims() public { _request(ed, BASE_LABEL_A); _request(tiago, BASE_LABEL_A); - vm.prank(operator); + vm.prank(owner); whitelist.revokeName(BASE_LABEL_A); assertEq(whitelist.claimantCount(BASE_LABEL_A), 0); assertEq(whitelist.nameCount(), 0); @@ -295,7 +343,7 @@ contract DotnsNameWhitelistTests is BaseDotns { IDotnsNameWhitelist.NothingToRevoke.selector, _nodeOf(BASE_LABEL_A) ) ); - vm.prank(operator); + vm.prank(owner); whitelist.revokeName(BASE_LABEL_A); assertTrue(whitelist.isReserved(BASE_LABEL_A)); } @@ -316,13 +364,20 @@ contract DotnsNameWhitelistTests is BaseDotns { assertEq(whitelist.nameCount(), 0); } - function test_setReserved_reverts_with_claims() public { + function test_setReserved_clears_pending_claims() public { + bytes32 node = _nodeOf(BASE_LABEL_A); _request(ed, BASE_LABEL_A); - vm.expectRevert( - abi.encodeWithSelector(IDotnsNameWhitelist.HasClaims.selector, _nodeOf(BASE_LABEL_A)) - ); + vm.expectEmit(true, true, false, true, address(whitelist)); + emit IDotnsNameWhitelist.NameRejected(node, ed, BASE_LABEL_A); vm.prank(owner); whitelist.setReserved(BASE_LABEL_A, true); + + assertTrue(whitelist.isReserved(BASE_LABEL_A)); + assertEq(whitelist.claimantCount(BASE_LABEL_A), 0); + assertEq( + uint256(whitelist.claimOf(BASE_LABEL_A, ed).status), + uint256(IDotnsNameWhitelist.ClaimStatus.None) + ); } function test_setReserved_release_reverts_when_not_reserved() public { From c5a040cfd763944aa5d2544464d2c82dcb014bae Mon Sep 17 00:00:00 2001 From: Siphamandla Mjoli Date: Mon, 24 Aug 2026 14:03:29 +0200 Subject: [PATCH 4/5] fix: natspec --- contracts/whitelist/DotnsNameWhitelist.sol | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/contracts/whitelist/DotnsNameWhitelist.sol b/contracts/whitelist/DotnsNameWhitelist.sol index 4f844fa0..6d261d89 100644 --- a/contracts/whitelist/DotnsNameWhitelist.sol +++ b/contracts/whitelist/DotnsNameWhitelist.sol @@ -220,7 +220,7 @@ contract DotnsNameWhitelist is Claim storage claim = _claims[node][user]; require(claim.status == ClaimStatus.Requested, NotRequested(node, user)); // Free the claimant slot either way. Keep a sticky Rejected record only for a self-filed - // claim, so the beneficiary cannot simply re-request; a claim filed on their behalf is + // claim, so the beneficiary cannot re-request; a claim filed on their behalf is // deleted and never binds them. _claimants[node].remove(user); if (claim.submitter == user) { @@ -283,7 +283,7 @@ contract DotnsNameWhitelist is if (reserved) { require(record.status == NameStatus.Open, NameNotOpen(node)); // Clear any pending claims the way a grant does, so a permissionless requestName - // cannot force governance to revokeName first before it can reserve. + // cannot force governance to revokeName before it can reserve. _clearClaimants(node, address(0), label); record.status = NameStatus.Reserved; _activate(node, label); From 28d1b82fccecd25d3385588644b0b74eb2da2630 Mon Sep 17 00:00:00 2001 From: Siphamandla Mjoli Date: Mon, 24 Aug 2026 14:38:15 +0200 Subject: [PATCH 5/5] fix: whitelist operator --- scripts/deploy/WireDeployments.s.sol | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/scripts/deploy/WireDeployments.s.sol b/scripts/deploy/WireDeployments.s.sol index 4151aac5..59c5f1d5 100644 --- a/scripts/deploy/WireDeployments.s.sol +++ b/scripts/deploy/WireDeployments.s.sol @@ -131,7 +131,11 @@ contract WireDeployments is BaseDeployer { vm.startBroadcast(owner); DotnsRegistrarController(addr.registrarController) .setRole(DotnsConstants.WHITELIST_OPERATOR_ROLE, whitelistOperator, true); - DotnsNameWhitelist(addr.nameWhitelist).setOperator(whitelistOperator, true); + // Grant through setRole rather than the onlyGovernance setOperator: the deploy signs as + // the owner, and setOperator reads the revive System precompile, which is absent on the + // anvil reproduction chain. setRole grants the same WHITELIST_OPERATOR_ROLE. + DotnsNameWhitelist(addr.nameWhitelist) + .setRole(DotnsConstants.WHITELIST_OPERATOR_ROLE, whitelistOperator, true); vm.stopBroadcast(); console.log("Whitelist operator role granted to", whitelistOperator); }