From e2841c9e7c50a3f9082aa3dfcf08d8d6f0ee1ba4 Mon Sep 17 00:00:00 2001 From: Siphamandla Mjoli Date: Thu, 10 Sep 2026 01:05:44 +0200 Subject: [PATCH 1/4] feat: issue PoP lite usernames as subnames under numeric containers A lite username `alice.01` is registered as `alice` beneath the numeric container `01` rather than as one atomic label. The container is minted on first use, owned by and soulbound to the PoP controller, and the username is a subnode the person owns in the registry. Re-issuing an existing lite username reverts LiteNameAlreadyIssued. IDotnsRegistry.SubnodeRecord gains a persist flag so the registry writes only the ownership and resolver record and leaves the LabelStore write to the caller. A SubnodeUtils library holds the subname registration and the shared node derivation. DotnsPopLens and DotnsReverseResolver read a lite name's ownership through the registry and derive its node by splitting on the separator, so lite names enumerate and reverse-resolve. The full-person path and the public commit-reveal path are unchanged. --- contracts/registrars/DotnsPopController.sol | 102 +++++++++---- contracts/registrars/DotnsPopLens.sol | 122 +++++++++------ contracts/registrars/IDotnsPopController.sol | 45 +++--- contracts/registry/DotnsRegistry.sol | 10 +- contracts/registry/IDotnsRegistry.sol | 10 +- contracts/resolvers/DotnsReverseResolver.sol | 40 +++-- contracts/utils/StringUtils.sol | 56 ++++++- contracts/utils/SubnodeUtils.sol | 118 +++++++++++++++ test/base/BaseDotns.t.sol | 14 ++ test/fuzz/registry/DotnsRegistryFuzz.t.sol | 18 ++- test/intergration/BasicDotns.reverts.t.sol | 12 +- test/intergration/BasicDotns.t.sol | 6 +- test/intergration/PopLifecycleFlow.t.sol | 47 +++--- .../DotnsPopControllerInvariant.t.sol | 59 +++----- .../registrar/PopControllerHandler.t.sol | 94 +----------- .../registry/DotnsRegistryInvariant.t.sol | 3 +- test/invariant/registry/RegistryHandler.t.sol | 6 +- test/unit/registrar/DotnsPopController.t.sol | 108 ++++++++------ test/unit/registry/DotnsRegistry.t.sol | 140 +++++++++++++++--- 19 files changed, 656 insertions(+), 354 deletions(-) create mode 100644 contracts/utils/SubnodeUtils.sol diff --git a/contracts/registrars/DotnsPopController.sol b/contracts/registrars/DotnsPopController.sol index dcf7b13a0..8338bda78 100644 --- a/contracts/registrars/DotnsPopController.sol +++ b/contracts/registrars/DotnsPopController.sol @@ -22,19 +22,21 @@ import {IStoreFactory} from "../store/IStoreFactory.sol"; import {ILabelStore} from "../store/ILabelStore.sol"; import {LabelUtils} from "../utils/LabelUtils.sol"; import {RegistrationUtils} from "../utils/RegistrationUtils.sol"; +import {SubnodeUtils} from "../utils/SubnodeUtils.sol"; +import {IDotnsRegistry} from "../registry/IDotnsRegistry.sol"; import {StringUtils} from "../utils/StringUtils.sol"; import {DotnsConstants} from "../utils/DotnsConstants.sol"; import {SystemUtils} from "../utils/SystemUtils.sol"; /// @title DotnsPopController /// @notice Dedicated PoP controller orchestrating lite-person and full-person username -/// issuance on behalf of the PoP gateway pallet. +/// issuance on behalf of the PoP gateway. /// @dev Lives behind its own UUPS proxy with its own storage. Registered on `DotnsRegistrar` /// via `addController`, which is how multiple controllers coexist on the same registrar /// without interfering with each other. /// /// Enforcement: -/// Personhood is attested off-chain by the gateway pallet before the call reaches this +/// Personhood is attested off-chain by the gateway before the call reaches this /// contract, so the on-chain personhood precompile is not re-queried on the gateway path. /// Every base-label mint path still calls @custom:function IPopRules.classifyName to reject /// governance-reserved labels (@custom:reverts InvalidBaseLabel on the base path, @@ -118,7 +120,7 @@ contract DotnsPopController is mapping(bytes32 labelhash => string baseLabel) internal _reservedBaseLabel; /// @notice Duration (in seconds) after which a reservation entry is considered expired. - /// @dev Mirrors `pallet_resources::UsernameReservationDuration`. Configurable by + /// @dev Sets the reservation duration, configurable by /// governance via `setReservationDuration`. uint64 public override reservationDuration; @@ -128,8 +130,8 @@ contract DotnsPopController is EnumerableSet.AddressSet private _pendingClaimUsers; /// @notice Per-user pile of deferred names awaiting a `LabelStore`. - /// @dev The Root gateway origin cannot deploy a `LabelStore` (contract creation is forbidden - /// from Root), so deferred names accumulate here until a signed-origin + /// @dev The mint origin cannot deploy a `LabelStore`, so deferred names accumulate here until a + /// signed-origin /// @custom:function settlePendingClaims deploys the store and writes the stashed labels. Each /// entry's deadline is measured from its own `mintedAt` against `reservationDuration`. mapping(address user => PendingClaim[] queue) internal _pendingClaimQueue; @@ -145,7 +147,7 @@ contract DotnsPopController is /// @dev Reserved storage space to allow for layout changes in future upgrades. uint256[50] private __gap; - /// @notice Restricts calls to a substrate Root origin. + /// @notice Restricts calls to a Root origin. modifier onlyRoot() { _onlyRoot(); _; @@ -221,9 +223,9 @@ contract DotnsPopController is /// of @custom:function reserveBaseName. /// @dev Gateway attestation is the authority for personhood on this path; the on-chain /// precompile is not consulted. The label is stored in the `stem.NN` form the gateway sends, - /// which is the form People Chain holds, so no normalisation happens here. The shape check + /// which is the canonical form of the name, so no normalisation happens here. The shape check /// runs before classification so a malformed label reverts - /// @custom:reverts InvalidLiteLabel, which the gateway pallet decodes by selector; letting + /// @custom:reverts InvalidLiteLabel, which the gateway decodes by selector; letting /// `classifyName` catch it instead would surface an undecodable PopRules string. /// Takes the @custom:struct LiteRegistration struct directly so both call sites pass the same /// payload shape: the typed entrypoint forwards its own `params`, the `reserveBaseName` @@ -299,10 +301,10 @@ contract DotnsPopController is if (link.kind == LinkKind.LiteUsername) { require(link.liteLabel.isLitePersonLabel(), InvalidLiteLabel()); (liteLabelhash, liteNode) = _validateLiteLabel(link.liteLabel); - IDotnsRegistrar registrar = _registrar(); + // A lite username is a subnode, so its owner lives in the registry record rather than + // the registrar's ERC-721 ledger. require( - registrar.exists(uint256(liteNode)) && registrar.ownerOf(uint256(liteNode)) == user, - LiteLabelNotOwnedByUser(user, liteLabelhash) + _registry().owner(liteNode) == user, LiteLabelNotOwnedByUser(user, liteLabelhash) ); chatKeyToPersist = _popResolver().chatKey(liteNode); } else { @@ -402,7 +404,11 @@ contract DotnsPopController is returns (address) { bytes32 labelhash = LabelUtils.labelhashMemory(label); - bytes32 node = LabelUtils.namehashUnder(protocolRegistry.tldNode(), labelhash); + // A lite label settled its ownership as a subnode, so its store entry keys the same + // hierarchical node; a full label keys the second-level node under the TLD. + bytes32 node = label.isLitePersonLabelMemory() + ? _liteSubnode(label) + : LabelUtils.namehashUnder(protocolRegistry.tldNode(), labelhash); if (store == address(0)) { store = factory.deployLabelStoreFor(user); } @@ -563,8 +569,8 @@ contract DotnsPopController is /// @dev The mint + forward-registry pair is delegated to /// @custom:function RegistrationUtils.registerAndStore so this flow and the public /// commit-reveal flow share exactly one implementation of that sequence. The label is - /// passed empty so the registrar does not deploy a `LabelStore`; substrate Root cannot - /// run the `LabelStore` constructor under `pallet-revive`. PoP-flow per-name records + /// passed empty so the registrar does not deploy a `LabelStore`; the mint origin cannot + /// run the `LabelStore` constructor. PoP-flow per-name records /// (chat key, lite link) are persisted eagerly on @custom:contract IDotnsPopResolver /// here, before the label is written, so the resolver carries the full identity record /// from mint time regardless of whether the owner already has a `LabelStore`. The Store @@ -584,15 +590,35 @@ contract DotnsPopController is { _popIssued[label] = true; - RegistrationUtils.registerAndStore( - RegistrationUtils.RegistrationContext({ - protocolRegistry: protocolRegistry, - user: user, - label: "", - labelhash: labelhash, - node: node - }) - ); + // A lite username is a subname under its numeric container, so it takes the subnode path + // and never mints a token. A full-person name is a tokenised second-level registration and + // keeps the shared token triad untouched. `persist` is false because the store write is + // deferred to the pending-claim queue below and the user syncs it later. + if (label.isLitePersonLabelMemory()) { + // A lite name is issued once. Its subnode already existing means a duplicate issuance, + // which would rehome the identity and overwrite its records, so it is rejected. + require(!_registry().recordExists(node), LiteNameAlreadyIssued()); + (string memory stem, string memory suffix) = label.splitLiteLabel(); + SubnodeUtils.registerSubname( + SubnodeUtils.SubnameContext({ + protocolRegistry: protocolRegistry, + parentLabel: suffix, + subLabel: stem, + owner: user, + persist: false + }) + ); + } else { + RegistrationUtils.registerAndStore( + RegistrationUtils.RegistrationContext({ + protocolRegistry: protocolRegistry, + user: user, + label: "", + labelhash: labelhash, + node: node + }) + ); + } if (chatKeyBytes.length != 0 || liteLabelhash != bytes32(0)) { IDotnsPopResolver resolver = _popResolver(); @@ -629,7 +655,7 @@ contract DotnsPopController is } /// @notice Appends a deferred binding for `user` and adds them to the enumeration set. - /// @dev The Root gateway origin cannot deploy the user's `LabelStore`, so deferred names pile + /// @dev The mint origin cannot deploy the user's `LabelStore`, so deferred names pile /// up in `_pendingClaimQueue` until a signed-origin @custom:function settlePendingClaims /// writes them. Adding the user to the set is idempotent, so repeat stashes keep a single /// enumeration entry. Emits @custom:emits PendingClaimStashed. @@ -763,7 +789,10 @@ contract DotnsPopController is /// @notice Validates a lite-person `stem.NN` label and derives `(labelhash, node)`. /// @dev The stem is lowercase letters only, so this rejects a stem carrying a digit or a - /// hyphen before any node is derived. + /// hyphen before any node is derived. `node` is the hierarchical subnode `stem` under the + /// numeric container `NN`, the node a resolver reaches by walking the dotted name, and + /// `labelhash` stays the keccak of the whole label so it is a stable text identifier for events + /// and the reservation queue. function _validateLiteLabel(string memory liteLabel) internal view @@ -771,7 +800,19 @@ contract DotnsPopController is { require(liteLabel.isLitePersonLabelMemory(), InvalidLiteLabel()); labelhash = LabelUtils.labelhashMemory(liteLabel); - node = LabelUtils.namehashUnder(protocolRegistry.tldNode(), labelhash); + node = _liteSubnode(liteLabel); + } + + /// @notice Derives the hierarchical subnode for a lite label `.`. + /// @dev Splits at the separator and walks `suffix.tld` then `stem` under it, so a lite name + /// resolves as `stem` beneath its numeric container rather than as a hash of the whole label. + /// Shared by @custom:function _validateLiteLabel and pending-claim settlement so every lite + /// consumer agrees on one node. + /// @param liteLabel Lite label held in memory, e.g. `alice.01`. + /// @return subnode Namehash of `stem` under `suffix.tld`. + function _liteSubnode(string memory liteLabel) internal view returns (bytes32 subnode) { + (string memory stem, string memory suffix) = liteLabel.splitLiteLabel(); + subnode = SubnodeUtils.subnodeOf(protocolRegistry.tldNode(), suffix, stem); } /// @notice Validates a base (full-person) label and derives `(labelhash, node)`. @@ -782,7 +823,7 @@ contract DotnsPopController is view returns (bytes32 labelhash, bytes32 node) { - // Letters only, matching `BaseLabel::is_valid_person` in the gateway pallet: a + // Letters only, matching the gateway's full-person label rule: a // full-person label is a name a person chose, so it admits no digits and no hyphens. // Classification does not cover this on its own, since a suffixed label with nine or // more characters lands on NoStatus and would otherwise pass. @@ -846,6 +887,11 @@ contract DotnsPopController is return IDotnsRegistrar(protocolRegistry.get(DotnsConstants.REGISTRAR)); } + /// @notice Resolves the registry via the protocol registry. + function _registry() internal view returns (IDotnsRegistry) { + return IDotnsRegistry(protocolRegistry.get(DotnsConstants.REGISTRY)); + } + /// @notice Writes the new head of the queue into PopRules so the public commit-reveal flow /// rejects registrations of this base name for anyone other than `newHead`. /// @dev Callers guarantee `newHead` is non-zero (the queue holds a live entry) and that @@ -869,7 +915,7 @@ contract DotnsPopController is delete _reservedBaseLabel[labelhash]; } - /// @notice Internal check enforcing a substrate Root origin. + /// @notice Internal check enforcing a Root origin. /// @dev Authorises a call when @custom:function SystemUtils.originIsRoot is true, and /// reverts with NotRoot otherwise. `msg.sender` is deliberately not consulted: a /// Root origin has no account behind it, so reading `msg.sender` traps. That holds diff --git a/contracts/registrars/DotnsPopLens.sol b/contracts/registrars/DotnsPopLens.sol index 84e123362..6efa9d6c1 100644 --- a/contracts/registrars/DotnsPopLens.sol +++ b/contracts/registrars/DotnsPopLens.sol @@ -5,11 +5,13 @@ import {IDotnsPopLens} from "./IDotnsPopLens.sol"; import {IDotnsPopController} from "./IDotnsPopController.sol"; import {IDotnsRegistrar} from "./IDotnsRegistrar.sol"; import {IDotnsProtocolRegistry} from "../registry/IDotnsProtocolRegistry.sol"; +import {IDotnsRegistry} from "../registry/IDotnsRegistry.sol"; import {IDotnsPopResolver} from "../resolvers/IDotnsPopResolver.sol"; import {IPopRules} from "../pop/IPopRules.sol"; import {IStoreFactory} from "../store/IStoreFactory.sol"; import {ILabelStore} from "../store/ILabelStore.sol"; import {LabelUtils} from "../utils/LabelUtils.sol"; +import {SubnodeUtils} from "../utils/SubnodeUtils.sol"; import {StringUtils} from "../utils/StringUtils.sol"; import {DotnsConstants} from "../utils/DotnsConstants.sol"; @@ -17,11 +19,13 @@ import {DotnsConstants} from "../utils/DotnsConstants.sol"; /// @notice Read-only view over PoP identity data. /// @dev Stateless beyond the protocol registry it holds, and never mints or settles. It composes /// each field from the contract that owns it: names from the owner's `LabelStore` and the -/// controller's pending queue, ownership from the registrar, chat keys and links from the PoP -/// resolver, and label classification from PopRules. Living outside the controller keeps the -/// controller within the contract-size limit and keeps the registrar the single source of -/// ownership truth. Deployed as a plain contract through the CREATE3 factory, so its address is -/// deterministic and it can be redeployed on a read change without touching stored state. +/// controller's pending queue, ownership from the registry, chat keys and links from the PoP +/// resolver, and label classification from PopRules. The registry is the single ownership +/// authority: it delegates a tokenised name to the registrar and owns a subname directly, so a +/// lite username, which is a subname, resolves the same way as a full-person name. Living outside +/// the controller keeps the controller within the contract-size limit. Deployed as a plain +/// contract through the CREATE3 factory, so its address is deterministic and it can be redeployed +/// on a read change without touching stored state. /// @custom:security-contact admin@parity.io contract DotnsPopLens is IDotnsPopLens { using StringUtils for *; @@ -81,10 +85,12 @@ contract DotnsPopLens is IDotnsPopLens { /// @inheritdoc IDotnsPopLens function nameDetail(string calldata name) external view override returns (NameDetail memory) { - (bytes32 labelhash, bytes32 node) = LabelUtils.deriveNode(_protocolRegistry.tldNode(), name); - NameDetail memory detail = _detail(node); + NameDetail memory detail = _detail(_nodeOf(name)); + // The caller holds the label, so supply it when the name exists but the node alone could + // not recover it (a subname). An unknown name keeps its empty label. + if (detail.exists && bytes(detail.label).length == 0) detail.label = name; // Holding the label means holding its labelhash, so the lite-to-full link resolves here. - detail.fullClaim = _popResolver().fullClaim(labelhash); + detail.fullClaim = _popResolver().fullClaim(LabelUtils.labelhash(name)); return detail; } @@ -108,18 +114,17 @@ contract DotnsPopLens is IDotnsPopLens { } /// @notice Whether `label` belongs in the lite listing (`wantLite`) or the full listing. - /// @dev Two questions, two signals, and a third guard the caller already applied. Whether a - /// name is an identity at all is provenance, so each listing is gated on + /// @dev Two questions and one guard the caller already applied. Whether a name is an identity + /// at all is provenance, so each listing is gated on /// @custom:function IDotnsPopController.isPopIssued: characters alone would admit a public /// registration spelled `joseph42`, which reads as a full-person name and is not one. Which - /// kind of identity it is, lite or full, is spelling: the gateway issues a lite name with - /// its separator and a full-person name without one, and provenance cannot tell them apart - /// because it covers both. A subname is excluded before either signal is read: the callers - /// keep only nodes the registrar says `user` owns, and a subname lives in the registry with - /// no token behind it. That matters because provenance is keyed by text, so a subname - /// rendering as `joseph.42` would otherwise borrow the answer belonging to the whole label. - /// So the two listings together cover the names the gateway issued and `user` holds, one - /// kind each, rather than everything the account holds. + /// kind of identity it is, lite or full, is spelling: a lite name carries its separator and a + /// full-person name does not, and provenance covers both. A lite name is a subname and a + /// full-person name is a tokenised second-level name, and the callers resolve ownership through + /// the registry, which covers both, so both listings reach their names. Provenance is keyed by + /// text, so a subname a `user` created under a name they own does not enter a listing unless + /// the controller issued it. The two listings together cover the names the gateway issued and + /// `user` holds, one kind each, rather than everything the account holds. function _belongsToListing(string memory label, bool wantLite) internal view returns (bool) { if (!_controller().isPopIssued(label)) return false; return wantLite ? label.isLitePersonLabelMemory() : label.isSingleLabelMemory(); @@ -131,8 +136,7 @@ contract DotnsPopLens is IDotnsPopLens { /// entry already written into the store by a sibling flow is skipped so it is not counted /// twice. function _countNames(address user, bool wantLite) internal view returns (uint256 count) { - IDotnsRegistrar registrar = _registrar(); - bytes32 tldNode = _protocolRegistry.tldNode(); + string memory tld = _protocolRegistry.tld(); address store = _storeFactory().getLabelStore(user); if (store != address(0)) { @@ -140,8 +144,11 @@ contract DotnsPopLens is IDotnsPopLens { uint256 stored = labelStore.getLabelCount(); for (uint256 i; i < stored; ++i) { bytes32 node = labelStore.getLabelhashAt(i); - if (!_ownedBy(registrar, node, user)) continue; - if (_belongsToListing(registrar.labelOf(uint256(node)), wantLite)) ++count; + if (!_ownedBy(node, user)) continue; + if (_belongsToListing(LabelUtils.stripTld(tld, labelStore.getLabelAt(i)), wantLite)) + { + ++count; + } } } @@ -150,9 +157,9 @@ contract DotnsPopLens is IDotnsPopLens { for (uint256 j; j < pending; ++j) { string memory label = queue[j].label; if (!_belongsToListing(label, wantLite)) continue; - bytes32 node = LabelUtils.namehashUnder(tldNode, LabelUtils.labelhashMemory(label)); + bytes32 node = _nodeOf(label); if (store != address(0) && ILabelStore(store).isLocked(node)) continue; - if (_ownedBy(registrar, node, user)) ++count; + if (_ownedBy(node, user)) ++count; } } @@ -175,8 +182,7 @@ contract DotnsPopLens is IDotnsPopLens { Name[] memory page = new Name[](limit); if (limit == 0) return page; - IDotnsRegistrar registrar = _registrar(); - bytes32 tldNode = _protocolRegistry.tldNode(); + string memory tld = _protocolRegistry.tld(); address store = _storeFactory().getLabelStore(user); uint256 filled; @@ -187,8 +193,8 @@ contract DotnsPopLens is IDotnsPopLens { uint256 stored = labelStore.getLabelCount(); for (uint256 i; i < stored && filled < limit; ++i) { bytes32 node = labelStore.getLabelhashAt(i); - if (!_ownedBy(registrar, node, user)) continue; - string memory label = registrar.labelOf(uint256(node)); + if (!_ownedBy(node, user)) continue; + string memory label = LabelUtils.stripTld(tld, labelStore.getLabelAt(i)); if (!_belongsToListing(label, wantLite)) continue; if (seen++ < offset) continue; page[filled++] = Name({node: node, label: label, settled: true, deadline: 0}); @@ -201,9 +207,9 @@ contract DotnsPopLens is IDotnsPopLens { for (uint256 j; j < pending && filled < limit; ++j) { string memory label = queue[j].label; if (!_belongsToListing(label, wantLite)) continue; - bytes32 node = LabelUtils.namehashUnder(tldNode, LabelUtils.labelhashMemory(label)); + bytes32 node = _nodeOf(label); if (store != address(0) && ILabelStore(store).isLocked(node)) continue; - if (!_ownedBy(registrar, node, user)) continue; + if (!_ownedBy(node, user)) continue; if (seen++ < offset) continue; page[filled++] = Name({ node: node, label: label, settled: false, deadline: queue[j].mintedAt + duration @@ -217,19 +223,12 @@ contract DotnsPopLens is IDotnsPopLens { } } - /// @notice Whether `node` is a minted name currently owned by `user`. - /// @dev Guards the `ownerOf` call with `exists` so a missing token returns false rather than - /// reverting, keeping the listing reads total. - function _ownedBy( - IDotnsRegistrar registrar, - bytes32 node, - address user - ) - internal - view - returns (bool) - { - return registrar.exists(uint256(node)) && registrar.ownerOf(uint256(node)) == user; + /// @notice Whether `node` is a name currently owned by `user`. + /// @dev Reads the registry, which is the single ownership authority for both a tokenised name + /// (it delegates to the registrar) and a subname (an explicit record owner). A node with no + /// record returns the zero address, so a missing name yields false and the read stays total. + function _ownedBy(bytes32 node, address user) internal view returns (bool) { + return _registry().owner(node) == user; } /// @notice Gathers a name's record from the registrar, PoP resolver, and PopRules. @@ -239,14 +238,22 @@ contract DotnsPopLens is IDotnsPopLens { /// label shape and is skipped for an empty label. function _detail(bytes32 node) internal view returns (NameDetail memory detail) { detail.node = node; - IDotnsRegistrar registrar = _registrar(); - if (registrar.exists(uint256(node))) { - address owner = registrar.ownerOf(uint256(node)); + address owner = _registry().owner(node); + if (owner != address(0)) { detail.exists = true; detail.owner = owner; - detail.label = registrar.labelOf(uint256(node)); address store = _storeFactory().getLabelStore(owner); - detail.settled = store != address(0) && ILabelStore(store).isLocked(node); + bool settled = store != address(0) && ILabelStore(store).isLocked(node); + detail.settled = settled; + // A tokenised name carries its label on the registrar; a subname does not, so its + // label is read back from the owner's store once settled. A pending subname has no + // recoverable label from the node alone. + if (_registrar().exists(uint256(node))) { + detail.label = _registrar().labelOf(uint256(node)); + } else if (settled) { + detail.label = + LabelUtils.stripTld(_protocolRegistry.tld(), ILabelStore(store).getLabel(node)); + } } if (bytes(detail.label).length != 0) { // Every mint path validates the label, so a stored label always classifies; the try @@ -283,6 +290,25 @@ contract DotnsPopLens is IDotnsPopLens { return IDotnsRegistrar(_protocolRegistry.get(DotnsConstants.REGISTRAR)); } + /// @notice Resolves the registry via the protocol registry. + function _registry() internal view returns (IDotnsRegistry) { + return IDotnsRegistry(_protocolRegistry.get(DotnsConstants.REGISTRY)); + } + + /// @notice Derives the node a name resolves to, whether tokenised or a lite subname. + /// @dev A lite name is `stem` beneath its numeric container, so it hashes as a subnode; any + /// other name hashes as a second-level label under the TLD. + /// @param label Bare label without the TLD, e.g. `alice` or `alice.01`. + /// @return node The node the name resolves to. + function _nodeOf(string memory label) internal view returns (bytes32 node) { + bytes32 tldNode = _protocolRegistry.tldNode(); + if (label.isLitePersonLabelMemory()) { + (string memory stem, string memory suffix) = label.splitLiteLabel(); + return SubnodeUtils.subnodeOf(tldNode, suffix, stem); + } + node = LabelUtils.namehashUnder(tldNode, LabelUtils.labelhashMemory(label)); + } + /// @notice Resolves the store factory via the protocol registry. function _storeFactory() internal view returns (IStoreFactory) { return IStoreFactory(_protocolRegistry.get(DotnsConstants.STORE_FACTORY)); diff --git a/contracts/registrars/IDotnsPopController.sol b/contracts/registrars/IDotnsPopController.sol index 48c7d1c47..704ca92fa 100644 --- a/contracts/registrars/IDotnsPopController.sol +++ b/contracts/registrars/IDotnsPopController.sol @@ -5,7 +5,7 @@ import {IDotnsController} from "./IDotnsController.sol"; /// @title IDotnsPopController /// @notice Interface for the dedicated PoP controller orchestrating lite-person and full-person -/// username issuance on behalf of the PoP gateway pallet. +/// username issuance on behalf of the PoP gateway. /// @dev Deliberately disjoint from @custom:contract IDotnsRegistrarController. The two /// controllers coexist on @custom:contract DotnsRegistrar via its multi-controller affordance /// and neither imports the other. Collision handling reduces to the registrar's ERC721 @@ -18,9 +18,9 @@ import {IDotnsController} from "./IDotnsController.sol"; /// `liteLabel` of a `LinkKind.LiteUsername` link) are a stem of lowercase ASCII letters, a /// separator, then exactly two digits (e.g. `joseph.42`) per /// @custom:function StringUtils.isLitePersonLabel. The stem is stricter than a DNS label -/// because People Chain restricts the name a person chooses to letters; a stem short enough to +/// because the name a person chooses is restricted to letters; a stem short enough to /// be governance-reserved is rejected by classification, not by the shape. The label is stored in -/// the form the gateway sends, which is the form People Chain holds, so nothing here +/// the form the gateway sends, which is the canonical form of the name, so nothing here /// normalises it. /// Full-person usernames (the `label` of @custom:function registerBaseName and the /// optional `reservedBaseLabel` of @custom:function reserveBaseName) are lowercase ASCII @@ -198,8 +198,7 @@ interface IDotnsPopController is IDotnsController { /// @param newHead Address now holding the head slot. event ReservationHeadAdvanced(bytes32 indexed labelhash, address indexed newHead); - /// @notice Thrown when a gated entrypoint is reached without a substrate - /// Root origin. + /// @notice Thrown when a gated entrypoint is reached without a Root origin. /// @dev Carries no caller parameter: a Root origin has no account to report, /// and reading `msg.sender` under one traps. error NotRoot(); @@ -214,6 +213,11 @@ interface IDotnsPopController is IDotnsController { /// queued reservation could never be redeemed at mint time. error BaseNameAlreadyRegistered(); + /// @notice Thrown when a lite username is issued again while its subname already exists. + /// @dev A lite name is issued once; re-issuing it would rehome the identity to a new owner and + /// overwrite its records, so an existing subname is rejected rather than reassigned. + error LiteNameAlreadyIssued(); + /// @notice Thrown when a supplied chat key is non-empty and not exactly 65 bytes long. /// @dev Mirrors the resolver's `InvalidChatKeyLength` so the controller surfaces a /// controller-local error before the mint runs. @@ -249,7 +253,7 @@ interface IDotnsPopController is IDotnsController { /// @notice Registers a lite-person username on behalf of the supplied user /// and optionally enqueues a reservation for a base name they intend to /// claim as a full person later. - /// @dev Callable only under a substrate Root origin (otherwise @custom:reverts NotRoot). The + /// @dev Callable only under a Root origin (otherwise @custom:reverts NotRoot). The /// lite leg validates the `stem.NN` shape and requires the label to classify outside the /// governance-reserved tier (otherwise @custom:reverts InvalidLiteLabel), and rejects a /// supplied chat key whose length is neither zero nor `CHAT_KEY_LENGTH` @@ -278,7 +282,7 @@ interface IDotnsPopController is IDotnsController { function reserveBaseName(BaseReservation calldata params) external; /// @notice Enqueues only the full/base-name reservation for a user. - /// @dev Callable only under a substrate Root origin (otherwise @custom:reverts NotRoot). + /// @dev Callable only under a Root origin (otherwise @custom:reverts NotRoot). /// This is the second step of the split /// gateway flow: @custom:function reserveLiteName mints the lite username first, then this /// function reserves the full/base label in a separate transaction so proof-size stays below @@ -293,11 +297,12 @@ interface IDotnsPopController is IDotnsController { /// @notice Registers a lite-person username on behalf of the supplied /// user without touching the base-name reservation queue. - /// @dev Callable only under a substrate Root origin (otherwise @custom:reverts NotRoot). The + /// @dev Callable only under a Root origin (otherwise @custom:reverts NotRoot). The /// supplied label must satisfy the `stem.NN` shape and must classify outside the /// governance-reserved tier (otherwise @custom:reverts InvalidLiteLabel); a supplied chat /// key whose length is neither zero nor `CHAT_KEY_LENGTH` reverts - /// @custom:reverts InvalidChatKey before mint and resolver writes run. On a warm-path mint + /// @custom:reverts InvalidChatKey before mint and resolver writes run. A username that has + /// already been issued reverts @custom:reverts LiteNameAlreadyIssued. On a warm-path mint /// @custom:emits LiteNameReserved and @custom:emits NameRegistered. On a cold-path /// mint @custom:emits LiteNameReserved and @custom:emits PendingClaimStashed, with /// @custom:emits NameRegistered deferred to @custom:function settlePendingClaims when the @@ -306,20 +311,20 @@ interface IDotnsPopController is IDotnsController { /// @param params Registration request; see @custom:struct LiteRegistration. function reserveLiteName(LiteRegistration calldata params) external; - /// @notice Whether this controller minted the whole-label reading of `label`. - /// @dev Keyed by text, so it answers about an interpretation rather than about a node: a - /// true answer covers `joseph.42` taken as one label, and says nothing about a subname - /// `joseph` under `42`, which renders as the same text. Both can exist at once, so a caller - /// holding a node must also check that node is `namehash(tldNode, keccak(label))` before - /// reading this answer as being about what it holds; node identity is what names the - /// object. Set at mint and never cleared, so it is unaffected by a name later becoming - /// transferable; the soulbound flag is a transfer rule and cannot stand in for it. + /// @notice Whether this controller issued `label` as a PoP identity. + /// @dev Keyed by text, so it answers about a name rather than about a node. A lite label is + /// issued as a subname (`joseph` beneath its numeric container `42`) and a full-person label as + /// a second-level name, so a caller holding a node must check that the node is the one `label` + /// resolves to under those rules before reading this answer as being about what it holds; node + /// identity is what names the object. Set at mint and never cleared, so it is unaffected by a + /// name later becoming transferable; the soulbound flag is a transfer rule and cannot stand in + /// for it. /// @param label Bare label without the TLD, for example `joseph.42`. - /// @return issued True when this controller minted `label`. + /// @return issued True when this controller issued `label`. function isPopIssued(string calldata label) external view returns (bool issued); /// @notice Registers a full-person username on behalf of the supplied user. - /// @dev Callable only under a substrate Root origin (otherwise @custom:reverts NotRoot). The + /// @dev Callable only under a Root origin (otherwise @custom:reverts NotRoot). The /// base label must be a letters-only person label, and therefore a true base label, /// (otherwise @custom:reverts InvalidBaseLabel), and the label must not /// classify as governance-reserved (otherwise @custom:reverts InvalidBaseLabel). The @@ -440,7 +445,7 @@ interface IDotnsPopController is IDotnsController { /// @notice Settles up to `limit` of a user's pending claims, writing each stashed label into /// the user's `LabelStore` and deploying that store when the user has none yet. /// @dev Permissionless: any caller may settle any user's claims and bears the full cost, - /// including the `LabelStore` storage deposit, which `pallet-revive` charges to the + /// including the `LabelStore` storage deposit, which is charged to the /// transaction signer. Settlement is never destructive: the name is already minted, so this /// only completes the deferred label write. Each settled entry is removed from the queue and /// the user leaves the pending-claim enumeration set once their queue empties. At most diff --git a/contracts/registry/DotnsRegistry.sol b/contracts/registry/DotnsRegistry.sol index bb8c84d8a..e646deffc 100644 --- a/contracts/registry/DotnsRegistry.sol +++ b/contracts/registry/DotnsRegistry.sol @@ -99,16 +99,18 @@ contract DotnsRegistry is Initializable, UUPSUpgradeable, OwnableUpgradeable, ID emit NewResolver(subnode, reverseResolver); } - if (newOwner != previousOwner) { + if (record.persist && newOwner != previousOwner) { string memory fullName = string.concat(subLabel, ".", parentLabel, protocolRegistry.tld()); _writeSubnodeToStore(newOwner, subnode, fullName); } } else { records[subnode] = Record({owner: newOwner, resolver: reverseResolver, exists: true}); - string memory fullName = - string.concat(subLabel, ".", parentLabel, protocolRegistry.tld()); - _writeSubnodeToStore(newOwner, subnode, fullName); + if (record.persist) { + string memory fullName = + string.concat(subLabel, ".", parentLabel, protocolRegistry.tld()); + _writeSubnodeToStore(newOwner, subnode, fullName); + } } emit NewOwner(parentNode, labelhash, newOwner); diff --git a/contracts/registry/IDotnsRegistry.sol b/contracts/registry/IDotnsRegistry.sol index e3d1d6be6..c655611d7 100644 --- a/contracts/registry/IDotnsRegistry.sol +++ b/contracts/registry/IDotnsRegistry.sol @@ -12,11 +12,15 @@ interface IDotnsRegistry { /// @param subLabel Human readable subnode label e.g "alice". /// @param parentLabel Canonical parent name without the TLD suffix e.g. bob or child.bob. /// @param owner Address to assign as owner of the created subnode. + /// @param persist Whether to index the subnode into the owner's `LabelStore`, deploying it on + /// demand. When false the ownership and resolver record is still written, but the store + /// is left untouched and the caller writes the label into the store separately. struct SubnodeRecord { bytes32 parentNode; string subLabel; string parentLabel; address owner; + bool persist; } /// @notice Record describing the state of a node. @@ -83,8 +87,10 @@ interface IDotnsRegistry { /// contracts are keyed by node and are not cleared by this function; downstream /// consumers should gate resolver reads on current ownership). Indexes the subnode /// under the new owner's `LabelStore` keyed by the namehashed `subnode` so off-chain - /// consumers can enumerate names per address. Emits @custom:emits NewOwner on each - /// successful assignment. + /// consumers can enumerate names per address. Indexing into the owner's `LabelStore` is + /// governed by `record.persist` (see @custom:struct SubnodeRecord); the ownership and + /// resolver record is written either way. Emits @custom:emits NewOwner on each successful + /// assignment. function setSubnodeOwner(SubnodeRecord calldata record) external returns (bytes32 subnode); /// @notice Sets the resolver for an existing subnode. diff --git a/contracts/resolvers/DotnsReverseResolver.sol b/contracts/resolvers/DotnsReverseResolver.sol index 3d3beefff..720a4dc9b 100644 --- a/contracts/resolvers/DotnsReverseResolver.sol +++ b/contracts/resolvers/DotnsReverseResolver.sol @@ -9,11 +9,13 @@ import { import { ERC165Upgradeable } from "@openzeppelin/contracts-upgradeable/utils/introspection/ERC165Upgradeable.sol"; -import {IERC721} from "@openzeppelin/contracts/token/ERC721/IERC721.sol"; import {IDotnsReverseResolver} from "./IDotnsReverseResolver.sol"; import {IDotnsProtocolRegistry} from "../registry/IDotnsProtocolRegistry.sol"; +import {IDotnsRegistry} from "../registry/IDotnsRegistry.sol"; import {DotnsConstants} from "../utils/DotnsConstants.sol"; import {LabelUtils} from "../utils/LabelUtils.sol"; +import {SubnodeUtils} from "../utils/SubnodeUtils.sol"; +import {StringUtils} from "../utils/StringUtils.sol"; /// @title Dotns Reverse Resolver /// @notice Resolves an address to its associated name under the network TLD. @@ -71,11 +73,8 @@ contract DotnsReverseResolver is /// @inheritdoc IDotnsReverseResolver function claimReverseRecord(string calldata label) external override { - bytes32 labelhash = LabelUtils.labelhash(label); - uint256 tokenId = uint256(LabelUtils.namehashUnder(protocolRegistry.tldNode(), labelhash)); - - IERC721 registrar = IERC721(protocolRegistry.get(DotnsConstants.REGISTRAR)); - require(registrar.ownerOf(tokenId) == msg.sender, NotNameOwner(msg.sender, tokenId)); + bytes32 node = _nodeOf(label); + require(_registry().owner(node) == msg.sender, NotNameOwner(msg.sender, uint256(node))); string memory fullName = string.concat(label, protocolRegistry.tld()); reverseNames[msg.sender] = fullName; @@ -92,16 +91,29 @@ contract DotnsReverseResolver is string memory label = LabelUtils.stripTld(protocolRegistry.tld(), stored); if (bytes(label).length == 0) return ""; - bytes32 labelhash = LabelUtils.labelhashMemory(label); - uint256 tokenId = uint256(LabelUtils.namehashUnder(protocolRegistry.tldNode(), labelhash)); + if (_registry().owner(_nodeOf(label)) != addr) return ""; + return stored; + } - IERC721 registrar = IERC721(protocolRegistry.get(DotnsConstants.REGISTRAR)); - try registrar.ownerOf(tokenId) returns (address currentOwner) { - if (currentOwner != addr) return ""; - return stored; - } catch { - return ""; + /// @notice Resolves the node a name maps to, whether tokenised or a lite subname. + /// @dev A lite name is `stem` beneath its numeric container, so it hashes as a subnode; any + /// other name hashes as a second-level label under the TLD. Ownership of either is read + /// through the registry, which delegates a tokenised name to the registrar and holds a + /// subname directly. + /// @param label Bare label without the TLD, e.g. `alice` or `alice.01`. + /// @return node The node the name resolves to. + function _nodeOf(string memory label) internal view returns (bytes32 node) { + bytes32 tldNode = protocolRegistry.tldNode(); + if (StringUtils.isLitePersonLabelMemory(label)) { + (string memory stem, string memory suffix) = StringUtils.splitLiteLabel(label); + return SubnodeUtils.subnodeOf(tldNode, suffix, stem); } + node = LabelUtils.namehashUnder(tldNode, LabelUtils.labelhashMemory(label)); + } + + /// @notice Resolves the registry via the protocol registry. + function _registry() internal view returns (IDotnsRegistry) { + return IDotnsRegistry(protocolRegistry.get(DotnsConstants.REGISTRY)); } /// @inheritdoc ERC165Upgradeable diff --git a/contracts/utils/StringUtils.sol b/contracts/utils/StringUtils.sol index 2b251336d..741691448 100644 --- a/contracts/utils/StringUtils.sol +++ b/contracts/utils/StringUtils.sol @@ -14,8 +14,8 @@ library StringUtils { /// @notice Number of digits in a lite-person PoP label's suffix. /// @dev The count the gateway emits, and an exact requirement here: the separator sits at a - /// fixed offset from the end, so a one or three digit suffix is rejected. The pallet - /// reads its own constant as a minimum, so widening it there does not widen this. + /// fixed offset from the end, so a one or three digit suffix is rejected. The gateway + /// reads its own minimum, so widening it there does not widen this. uint256 internal constant LITE_SUFFIX_DIGITS = 2; /// @notice Maximum number of octets in a single DNS label. @@ -83,9 +83,9 @@ library StringUtils { /// @dev A lite-person label is a stem of lowercase ASCII letters, one /// @custom:constant LABEL_SEPARATOR, then exactly /// @custom:constant LITE_SUFFIX_DIGITS digits (e.g. `joseph.42`). Letters only, - /// because the stem is the name a person chose and People Chain restricts that to - /// letters. How short a stem may be is policy rather than format, so it is left to the - /// governance-reserved band in @custom:function IPopRules.classifyName. + /// because the stem is the name a person chose, which is restricted to letters. How short + /// a stem may be is policy rather than format, so it is left to the governance-reserved band in + /// @custom:function IPopRules.classifyName. /// It is the only label shape in DotNS permitted to carry a separator, which is what /// reserves the dotted space to the gateway. A digit suffix is not exclusive: an /// ordinary label may end in digits, but it is measured as written and so classifies by @@ -116,8 +116,8 @@ library StringUtils { uint256 length = raw.length; // One stem letter, the separator, then the digits is the shortest accepted shape. The // stem is not bounded below here: how short a name may be is policy, and PopRules - // already holds it as the governance-reserved band. Mirroring People Chain's - // `MinUsernameLength` would duplicate that and drift when the runtime changes it. + // already holds it as the governance-reserved band. Enforcing a minimum here would + // duplicate the governance-reserved band and drift from it. if (length < LITE_SUFFIX_DIGITS + 2) return false; // Fixing the separator's position is what enforces the exact digit count: a third @@ -141,7 +141,7 @@ library StringUtils { } /// @notice Validates that `value` is a name a person chose: lowercase ASCII letters only. - /// @dev Mirrors `BaseLabel::is_valid_person` on the gateway pallet, which admits no digits + /// @dev Matches the gateway.s full-person label rule, which admits no digits /// and no hyphens, so a label outside this shape cannot have been issued. Stricter /// than @custom:function isSingleLabel, and it is the same rule /// @custom:function isLitePersonLabel applies to a lite stem. How short a name may be @@ -173,6 +173,46 @@ library StringUtils { return true; } + /// @notice Splits a lite-person label `.` into its stem and digit suffix. + /// @dev Splits at the first @custom:constant LABEL_SEPARATOR. A lite label carries exactly one + /// separator, so the caller is expected to have run @custom:function + /// isLitePersonLabelMemory first; a label with no separator returns the whole input as the stem + /// and an empty + /// suffix, which the caller's later label checks reject. + /// @param value Lite label held in memory, for example `alice.01`. + /// @return stem The label before the separator, for example `alice`. + /// @return suffix The digit suffix after the separator, for example `01`. + function splitLiteLabel(string memory value) + internal + pure + returns (string memory stem, string memory suffix) + { + bytes memory raw = bytes(value); + uint256 length = raw.length; + + uint256 separator = length; + for (uint256 i; i < length; ++i) { + if (raw[i] == LABEL_SEPARATOR) { + separator = i; + break; + } + } + + bytes memory stemBytes = new bytes(separator); + for (uint256 i; i < separator; ++i) { + stemBytes[i] = raw[i]; + } + + uint256 suffixLength = separator == length ? 0 : length - separator - 1; + bytes memory suffixBytes = new bytes(suffixLength); + for (uint256 i; i < suffixLength; ++i) { + suffixBytes[i] = raw[separator + 1 + i]; + } + + stem = string(stemBytes); + suffix = string(suffixBytes); + } + /// @notice Validates that `s` is a dot-separated path of canonical DNS labels. /// @dev Each segment between dots must satisfy @custom:function isSingleLabel. Empty /// segments (leading, trailing, or consecutive dots) fail. Used when diff --git a/contracts/utils/SubnodeUtils.sol b/contracts/utils/SubnodeUtils.sol new file mode 100644 index 000000000..666198bdc --- /dev/null +++ b/contracts/utils/SubnodeUtils.sol @@ -0,0 +1,118 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.34; + +import {IDotnsRegistry} from "../registry/IDotnsRegistry.sol"; +import {IDotnsRegistrar} from "../registrars/IDotnsRegistrar.sol"; +import {IDotnsProtocolRegistry} from "../registry/IDotnsProtocolRegistry.sol"; +import {LabelUtils} from "./LabelUtils.sol"; +import {DotnsConstants} from "./DotnsConstants.sol"; + +/// @title DotNS Subnode Utilities Library +/// @notice General-purpose helpers for registering names that live as subnodes of another name, +/// rather than as tokenised second-level registrations. +/// @dev A subname has no token: its ownership lives in the registry record, not in the registrar's +/// ERC-721 ledger. So it is registered through @custom:function IDotnsRegistry.setSubnodeOwner +/// here, rather than through the tokenised mint triad of @custom:contract RegistrationUtils. +/// @custom:security-contact admin@parity.io +library SubnodeUtils { + /// @notice Inputs describing a single subname registration. + /// @dev Passed as a struct so call sites name each field rather than thread a positional + /// argument list, mirroring @custom:struct RegistrationUtils.RegistrationContext. + /// @param protocolRegistry Protocol-level address registry used to resolve the registry and + /// TLD. + /// @param parentLabel Second-level parent label, e.g. `01`. + /// @param subLabel Subname label to register, e.g. `alice`. + /// @param owner Address to record as the subname owner. + /// @param persist Whether the registry should index the subnode into the owner's `LabelStore`. + struct SubnameContext { + IDotnsProtocolRegistry protocolRegistry; + string parentLabel; + string subLabel; + address owner; + bool persist; + } + + /// @notice Derives the subnode `subLabel.parentLabel.tld`. + /// @dev The single source of truth for how a two-level name maps to a node: walk `parentLabel` + /// under `tldNode`, then `subLabel` under that. Consumers that need the node without + /// writing it (readers, validators) call this so they agree with the write path. + /// @param tldNode The TLD node. + /// @param parentLabel Second-level parent label, e.g. `01`. + /// @param subLabel Subname label, e.g. `alice`. + /// @return subnode Namehash of `subLabel` under `parentLabel.tld`. + function subnodeOf( + bytes32 tldNode, + string memory parentLabel, + string memory subLabel + ) + internal + pure + returns (bytes32 subnode) + { + bytes32 parentNode = + LabelUtils.namehashUnder(tldNode, LabelUtils.labelhashMemory(parentLabel)); + subnode = LabelUtils.namehashUnder(parentNode, LabelUtils.labelhashMemory(subLabel)); + } + + /// @notice Registers `subLabel` beneath the second-level name `parentLabel`, minting the parent + /// if it does not exist yet. + /// @dev Derives the parent node `parentLabel.tld`; when no name is registered there yet it is + /// minted through the registrar with the calling contract as owner, so the caller holds + /// the parent authority @custom:function IDotnsRegistry.setSubnodeOwner requires. The + /// calling contract must therefore be a registrar controller, otherwise the registrar + /// @custom:reverts NotController. When a name already exists at the parent it must be + /// owned by the caller, otherwise @custom:reverts NotAuthorised, so a name someone else holds + /// is + /// never treated as the caller's parent. Ownership of the subname is then recorded through + /// @custom:function IDotnsRegistry.setSubnodeOwner. `persist` is forwarded to the + /// registry: when false the ownership and resolver record is written but the owner's + /// `LabelStore` is + /// not, and the caller writes the label into the store separately. + /// @dev The parent is owned by the calling contract's address and is soulbound, so it cannot be + /// moved. A caller that migrates to a new address rather than upgrading in place strands + /// every parent it minted and can no longer register subnames beneath them; the caller + /// must upgrade in place, or hold parents under an owner whose address is stable across + /// migrations. + /// @dev `parentLabel` is a single label registered directly under the TLD, so the parent node + /// is derived as `namehash(tldNode, keccak(parentLabel))`; deeper parents are out of scope for + /// this helper. + /// @param context Subname registration inputs. See @custom:struct SubnameContext. + /// @return subnode Namehash of the registered subname. + function registerSubname(SubnameContext memory context) internal returns (bytes32 subnode) { + IDotnsProtocolRegistry protocolRegistry = context.protocolRegistry; + + bytes32 parentNode = LabelUtils.namehashUnder( + protocolRegistry.tldNode(), LabelUtils.labelhashMemory(context.parentLabel) + ); + + IDotnsRegistrar registrar = IDotnsRegistrar(protocolRegistry.get(DotnsConstants.REGISTRAR)); + IDotnsRegistry registry = IDotnsRegistry(protocolRegistry.get(DotnsConstants.REGISTRY)); + + // Mint the parent on first use, owned by the caller, and pass an empty label so no + // `LabelStore` is written for it. When it already exists it must both belong to the caller + // and be soulbound, otherwise a name someone else registered, or one transferred to the + // caller, would be treated as this caller's parent; both are checked locally rather than + // assumed from an out-of-contract gate. Subsequent subnames under a parent the caller + // already owns skip straight to the subnode write. + if (!registrar.exists(uint256(parentNode))) { + registrar.register(uint256(parentNode), address(this), ""); + registry.setOwner(parentNode, address(this)); + } else { + require( + registry.owner(parentNode) == address(this) + && registrar.isSoulbound(uint256(parentNode)), + IDotnsRegistry.NotAuthorised() + ); + } + + subnode = registry.setSubnodeOwner( + IDotnsRegistry.SubnodeRecord({ + parentNode: parentNode, + subLabel: context.subLabel, + parentLabel: context.parentLabel, + owner: context.owner, + persist: context.persist + }) + ); + } +} diff --git a/test/base/BaseDotns.t.sol b/test/base/BaseDotns.t.sol index 77c0214a4..a2a1cea88 100644 --- a/test/base/BaseDotns.t.sol +++ b/test/base/BaseDotns.t.sol @@ -34,6 +34,7 @@ import {DotnsNameEscrow} from "../../contracts/escrow/DotnsNameEscrow.sol"; import {DotnsNameWhitelist} from "../../contracts/whitelist/DotnsNameWhitelist.sol"; import {DotnsConstants} from "../../contracts/utils/DotnsConstants.sol"; import {LabelUtils} from "../../contracts/utils/LabelUtils.sol"; +import {StringUtils} from "../../contracts/utils/StringUtils.sol"; import {ISystem} from "../../contracts/external/revive/ISystem.sol"; import {IPersonhood} from "../../contracts/external/personhood/IPersonhood.sol"; import {Upgrades} from "openzeppelin-foundry-upgrades/Upgrades.sol"; @@ -413,6 +414,19 @@ abstract contract BaseDotns is Test { node = LabelUtils.namehashUnder(_tldNode(), LabelUtils.labelhashMemory(label)); } + /// @notice Computes the hierarchical subnode for a lite label `.`. + /// @dev A lite username is `stem` beneath the numeric container `suffix.tld`, so its node is + /// `namehash(namehash(tldNode, keccak(suffix)), keccak(stem))`, not a hash of the whole + /// label. Mirrors @custom:function DotnsPopController._liteSubnode. + /// @param liteLabel Lite label, e.g. `michael.01`. + /// @return node The subnode identifier. + function _liteNodeOf(string memory liteLabel) internal pure returns (bytes32 node) { + (string memory stem, string memory suffix) = StringUtils.splitLiteLabel(liteLabel); + bytes32 parentNode = + LabelUtils.namehashUnder(_tldNode(), LabelUtils.labelhashMemory(suffix)); + node = LabelUtils.namehashUnder(parentNode, LabelUtils.labelhashMemory(stem)); + } + /// @notice Returns a valid 65-byte chat key seeded with `seed`. /// @dev Format mimics the uncompressed secp256k1 encoding (1 prefix byte + 32 X + 32 Y) /// so the resolver's length guard is satisfied. diff --git a/test/fuzz/registry/DotnsRegistryFuzz.t.sol b/test/fuzz/registry/DotnsRegistryFuzz.t.sol index b1871236f..9ea8e7b78 100644 --- a/test/fuzz/registry/DotnsRegistryFuzz.t.sol +++ b/test/fuzz/registry/DotnsRegistryFuzz.t.sol @@ -18,7 +18,11 @@ contract DotnsRegistryFuzzTest is BaseDotns { bytes32 parentNode = _register(parentLabel, ed, IPopRules.PopStatus.NoStatus); IDotnsRegistry.SubnodeRecord memory subnodeRecord = IDotnsRegistry.SubnodeRecord({ - parentNode: parentNode, subLabel: "sub", parentLabel: parentLabel, owner: leonardo + parentNode: parentNode, + subLabel: "sub", + parentLabel: parentLabel, + owner: leonardo, + persist: true }); vm.prank(ed); @@ -36,7 +40,11 @@ contract DotnsRegistryFuzzTest is BaseDotns { bytes32 parentNode = _register(parentLabel, ed, IPopRules.PopStatus.NoStatus); IDotnsRegistry.SubnodeRecord memory subnodeRecord = IDotnsRegistry.SubnodeRecord({ - parentNode: parentNode, subLabel: "app", parentLabel: parentLabel, owner: leonardo + parentNode: parentNode, + subLabel: "app", + parentLabel: parentLabel, + owner: leonardo, + persist: true }); vm.prank(ed); @@ -59,7 +67,11 @@ contract DotnsRegistryFuzzTest is BaseDotns { bytes32 parentNode = _register(parentLabel, ed, IPopRules.PopStatus.NoStatus); IDotnsRegistry.SubnodeRecord memory subnodeRecord = IDotnsRegistry.SubnodeRecord({ - parentNode: parentNode, subLabel: "api", parentLabel: parentLabel, owner: leonardo + parentNode: parentNode, + subLabel: "api", + parentLabel: parentLabel, + owner: leonardo, + persist: true }); vm.prank(ed); diff --git a/test/intergration/BasicDotns.reverts.t.sol b/test/intergration/BasicDotns.reverts.t.sol index e1a019cbf..e436bbf77 100644 --- a/test/intergration/BasicDotns.reverts.t.sol +++ b/test/intergration/BasicDotns.reverts.t.sol @@ -60,7 +60,11 @@ contract BasicDotnsIntegrationReverts is BaseDotns { bytes32 parentNode = _namehash(dotNode, keccak256(bytes(NAME_POPFULL))); IDotnsRegistry.SubnodeRecord memory subnodeRecord = IDotnsRegistry.SubnodeRecord({ - parentNode: parentNode, subLabel: "blog", parentLabel: NAME_POPFULL, owner: attacker + parentNode: parentNode, + subLabel: "blog", + parentLabel: NAME_POPFULL, + owner: attacker, + persist: true }); vm.startPrank(attacker); @@ -77,7 +81,11 @@ contract BasicDotnsIntegrationReverts is BaseDotns { bytes32 parentNode = _namehash(dotNode, keccak256(bytes(NAME_POPFULL))); IDotnsRegistry.SubnodeRecord memory subnodeRecord = IDotnsRegistry.SubnodeRecord({ - parentNode: parentNode, subLabel: "blog", parentLabel: NAME_POPFULL, owner: parentOwner + parentNode: parentNode, + subLabel: "blog", + parentLabel: NAME_POPFULL, + owner: parentOwner, + persist: true }); vm.startPrank(parentOwner); diff --git a/test/intergration/BasicDotns.t.sol b/test/intergration/BasicDotns.t.sol index 8cdfa755a..ac068afd4 100644 --- a/test/intergration/BasicDotns.t.sol +++ b/test/intergration/BasicDotns.t.sol @@ -281,7 +281,11 @@ contract BasicDotnsIntegration is BaseDotns { returns (bytes32 subnode) { IDotnsRegistry.SubnodeRecord memory subnodeRecord = IDotnsRegistry.SubnodeRecord({ - parentNode: parentNode, subLabel: subLabel, parentLabel: parentLabel, owner: subOwner + parentNode: parentNode, + subLabel: subLabel, + parentLabel: parentLabel, + owner: subOwner, + persist: true }); vm.startPrank(parentOwner); diff --git a/test/intergration/PopLifecycleFlow.t.sol b/test/intergration/PopLifecycleFlow.t.sol index 74bb13ded..8c6daf07f 100644 --- a/test/intergration/PopLifecycleFlow.t.sol +++ b/test/intergration/PopLifecycleFlow.t.sol @@ -95,8 +95,9 @@ contract PopLifecycleFlow is BaseDotns { bytes32 reassignedSubnode = _setSubnode(ed, fullNode, SUB_LABEL, FULL_LABEL, tiago); assertEq(reassignedSubnode, subnode); assertEq(dotnsRegistry.owner(subnode), tiago); - // The lite token is also gateway-minted and equally soulbound. - assertTrue(dotnsRegistrar.isSoulbound(uint256(_nodeOf(LITE_LABEL)))); + // The lite name is a subname owned in the registry, not a transferable token. + assertEq(dotnsRegistry.owner(_liteNodeOf(LITE_LABEL)), ed); + assertFalse(dotnsRegistrar.exists(uint256(_liteNodeOf(LITE_LABEL)))); } function test_cold_gateway_reserve_then_user_settles_pending_claim() public { @@ -108,8 +109,8 @@ contract PopLifecycleFlow is BaseDotns { }) ); - bytes32 liteNode = _nodeOf(LITE_LABEL); - assertEq(IERC721(address(dotnsRegistrar)).ownerOf(uint256(liteNode)), ed); + bytes32 liteNode = _liteNodeOf(LITE_LABEL); + assertEq(dotnsRegistry.owner(liteNode), ed); assertEq(dotnsRegistry.owner(liteNode), ed); assertEq(storeFactory.getLabelStore(ed), address(0)); // Chat key is persisted eagerly on the resolver at reserve time; only the @@ -153,7 +154,7 @@ contract PopLifecycleFlow is BaseDotns { address store = storeFactory.getLabelStore(ed); assertEq( - ILabelStore(store).getLabel(_nodeOf(LITE_LABEL)), + ILabelStore(store).getLabel(_liteNodeOf(LITE_LABEL)), string.concat(LITE_LABEL, protocolRegistry.tld()) ); @@ -168,10 +169,10 @@ contract PopLifecycleFlow is BaseDotns { // The user is warm now, so the second reservation writes straight into the store. assertEq( - ILabelStore(store).getLabel(_nodeOf(secondLabel)), + ILabelStore(store).getLabel(_liteNodeOf(secondLabel)), string.concat(secondLabel, protocolRegistry.tld()) ); - assertEq(dotnsPopResolver.chatKey(_nodeOf(secondLabel)), secondKey); + assertEq(dotnsPopResolver.chatKey(_liteNodeOf(secondLabel)), secondKey); assertGt(firstMintedAt, 0); assertEq(dotnsPopController.pendingClaimCountOf(ed), 0); assertEq(dotnsPopController.pendingClaimUserCount(), 0); @@ -185,15 +186,13 @@ contract PopLifecycleFlow is BaseDotns { }) ); - uint256 tokenId = uint256(_nodeOf(LITE_LABEL)); - assertTrue(dotnsRegistrar.isSoulbound(tokenId)); - // The gateway name is soulbound while its claim is still pending, so it cannot be moved - // out of the beneficiary's wallet before settlement. This is the path the issue closes: - // a pre-claim transfer previously escaped tier pricing entirely. - vm.expectRevert(abi.encodeWithSelector(IDotnsRegistrar.NameSoulbound.selector, tokenId)); - vm.prank(ed); - dotnsRegistrar.transferFrom(ed, tiago, tokenId); - assertEq(IERC721(address(dotnsRegistrar)).ownerOf(tokenId), ed); + // The lite name is a subname owned in the registry, not a transferable ERC-721 token, so it + // cannot be moved out of the beneficiary's wallet before settlement: there is no token to + // transfer and the owner holds no reassignment primitive. This is the path the issue + // closes: a pre-claim transfer previously escaped tier pricing entirely. + bytes32 liteNode = _liteNodeOf(LITE_LABEL); + assertEq(dotnsRegistry.owner(liteNode), ed); + assertFalse(dotnsRegistrar.exists(uint256(liteNode))); // The pending claim is keyed by the original user and still settles into their store. IDotnsPopController.PendingClaim[] memory pending = @@ -205,7 +204,7 @@ contract PopLifecycleFlow is BaseDotns { dotnsPopController.settlePendingClaims(ed, type(uint256).max); address edStore = storeFactory.getLabelStore(ed); assertTrue(edStore != address(0)); - bytes32 node = _nodeOf(LITE_LABEL); + bytes32 node = _liteNodeOf(LITE_LABEL); assertEq( ILabelStore(edStore).getLabel(node), string.concat(LITE_LABEL, protocolRegistry.tld()) ); @@ -223,7 +222,7 @@ contract PopLifecycleFlow is BaseDotns { // Permissionless settlement from a stranger address: age never drops the claim, so the // store is deployed for the beneficiary and the label is written and readable. - bytes32 liteNode = _nodeOf(LITE_LABEL); + bytes32 liteNode = _liteNodeOf(LITE_LABEL); vm.prank(makeAddr("settler")); dotnsPopController.settlePendingClaims(ed, type(uint256).max); @@ -244,8 +243,8 @@ contract PopLifecycleFlow is BaseDotns { }) ); - bytes32 liteNode = _nodeOf(LITE_LABEL); - assertEq(IERC721(address(dotnsRegistrar)).ownerOf(uint256(liteNode)), ed); + bytes32 liteNode = _liteNodeOf(LITE_LABEL); + assertEq(dotnsRegistry.owner(liteNode), ed); _grantPopFull(ed); @@ -254,7 +253,7 @@ contract PopLifecycleFlow is BaseDotns { bytes32 fullNode = _nodeOf(popfullLabel); assertEq(IERC721(address(dotnsRegistrar)).ownerOf(uint256(fullNode)), ed); - assertEq(IERC721(address(dotnsRegistrar)).ownerOf(uint256(liteNode)), ed); + assertEq(dotnsRegistry.owner(liteNode), ed); } /// @notice Mints the lite label for `user` then claims the full label against it. @@ -293,7 +292,11 @@ contract PopLifecycleFlow is BaseDotns { returns (bytes32 subnode) { IDotnsRegistry.SubnodeRecord memory record = IDotnsRegistry.SubnodeRecord({ - parentNode: parentNode, subLabel: subLabel, parentLabel: parentLabel, owner: subOwner + parentNode: parentNode, + subLabel: subLabel, + parentLabel: parentLabel, + owner: subOwner, + persist: true }); vm.prank(parentOwner); diff --git a/test/invariant/registrar/DotnsPopControllerInvariant.t.sol b/test/invariant/registrar/DotnsPopControllerInvariant.t.sol index b3b02697e..e7715e235 100644 --- a/test/invariant/registrar/DotnsPopControllerInvariant.t.sol +++ b/test/invariant/registrar/DotnsPopControllerInvariant.t.sol @@ -37,7 +37,7 @@ contract DotnsPopControllerInvariant is BaseDotns { ); targetContract(address(handler)); - bytes4[] memory selectors = new bytes4[](12); + bytes4[] memory selectors = new bytes4[](11); selectors[0] = handler.reserve.selector; selectors[1] = handler.relinquish.selector; selectors[2] = handler.expire.selector; @@ -49,7 +49,6 @@ contract DotnsPopControllerInvariant is BaseDotns { selectors[8] = handler.publicRegister.selector; selectors[9] = handler.attemptTransfer.selector; selectors[10] = handler.createSubname.selector; - selectors[11] = handler.createRivalSubname.selector; targetSelector(FuzzSelector({addr: address(handler), selectors: selectors})); _seedCoverage(); @@ -74,8 +73,6 @@ contract DotnsPopControllerInvariant is BaseDotns { handler.publicRegister(1, 1); handler.attemptTransfer(handler.mintedLiteTokenCount(), 0); - handler.createRivalSubname(0, 0); - // Sub-labels come from a seed and most are not valid DNS labels, so walk seeds until // one is accepted under a full-person parent. for (uint256 seed; seed < 64 && handler.subnodeCreatedCount() == 0; ++seed) { @@ -91,38 +88,12 @@ contract DotnsPopControllerInvariant is BaseDotns { assertGt(handler.publicLabelCount(), 0, "campaign took no label publicly"); assertGt(handler.transferSuccessCount(), 0, "campaign moved no name"); assertGt(handler.subnodeCreatedCount(), 0, "campaign created no subname"); - assertGt(handler.rivalSubnodeCount(), 0, "campaign built no rival hierarchy"); } - /// @notice The two readings of a lite name's text stay distinct, and the text-keyed signal - /// answers for the whole-label one. - /// @dev `michael.01` is one label to the gateway and `michael` under `01` to the registry, - /// and both render as the same text. `isPopIssued` is keyed by that text, so a true - /// answer proves the whole-label reading was issued and says nothing about the rival - /// standing beside it: the two coexist here, which is exactly why the node is what - /// names the object. The first assertion pins that the nodes never converge; the - /// soulbound flag is node-keyed, so it does discriminate between them, and is asserted - /// in both directions. - function invariant_rival_hierarchy_never_passes_for_a_person() public view { - uint256 n = handler.rivalSubnodeCount(); - for (uint256 i = 0; i < n; i++) { - string memory text = handler.rivalTexts(i); - bytes32 rival = handler.rivalSubnodes(i); - bytes32 person = _nodeOf(text); - - assertTrue(rival != person, "rival hierarchy reached the person's node"); - assertTrue(dotnsPopController.isPopIssued(text), "person lost their provenance"); - assertTrue(dotnsRegistrar.isSoulbound(uint256(person)), "person's name is unlocked"); - assertFalse(dotnsRegistrar.isSoulbound(uint256(rival)), "rival reads as gateway-minted"); - } - } - - /// @notice A subname never lands on a name the gateway issued. - /// @dev The two readings of `joseph.42`, one whole label or `joseph` beneath `42`, are what - /// the separated form has to keep apart. The registry derives a parent's node by - /// splitting the path on the separator, so a lite name's own node is unreachable as a - /// parent and no subname can be created under one at all; the second assertion pins - /// that, and the first pins that no subnode collides with an issued name either way. + /// @notice A user-created subname never collides with a name the gateway issued. + /// @dev A gateway full-person name is a second-level node and a gateway lite name is a subname + /// of its numeric container; a subname a user builds under a name they own must land on + /// neither, or a user could reach a gateway-issued node. function invariant_subnames_never_reach_a_gateway_node() public view { uint256 subnodeCount = handler.subnodeCreatedCount(); uint256 gatewayCount = handler.gatewayLabelCount(); @@ -130,14 +101,10 @@ contract DotnsPopControllerInvariant is BaseDotns { for (uint256 i = 0; i < subnodeCount; i++) { bytes32 subnode = handler.subnodesCreated(i); for (uint256 j = 0; j < gatewayCount; j++) { - assertTrue( - subnode != _nodeOf(handler.gatewayLabelsSeen(j)), - "subnode collided with a gateway name" - ); + string memory label = handler.gatewayLabelsSeen(j); + bytes32 gatewayNode = _carriesSeparator(label) ? _liteNodeOf(label) : _nodeOf(label); + assertTrue(subnode != gatewayNode, "subnode collided with a gateway name"); } - assertFalse( - _carriesSeparator(handler.subnameParents(i)), "subname created under a lite name" - ); } } @@ -181,7 +148,15 @@ contract DotnsPopControllerInvariant is BaseDotns { for (uint256 i = 0; i < gatewayCount; i++) { string memory label = handler.gatewayLabelsSeen(i); assertFalse(handler.isPublicLabel(label), "gateway label taken publicly"); - assertTrue(dotnsRegistrar.isSoulbound(uint256(_nodeOf(label))), "gateway name free"); + if (_carriesSeparator(label)) { + // A lite name is a subname owned in the registry, non-transferable because there is + // no token behind it, not through the soulbound flag. + bytes32 node = _liteNodeOf(label); + assertTrue(dotnsRegistry.owner(node) != address(0), "gateway lite name unowned"); + assertFalse(dotnsRegistrar.exists(uint256(node)), "gateway lite name is a token"); + } else { + assertTrue(dotnsRegistrar.isSoulbound(uint256(_nodeOf(label))), "gateway name free"); + } } } diff --git a/test/invariant/registrar/PopControllerHandler.t.sol b/test/invariant/registrar/PopControllerHandler.t.sol index 2927572c5..d57cfd630 100644 --- a/test/invariant/registrar/PopControllerHandler.t.sol +++ b/test/invariant/registrar/PopControllerHandler.t.sol @@ -34,7 +34,7 @@ contract PopControllerHandler is Test { DotnsRegistrar public immutable REGISTRAR; /// @notice Pricing and classification, read to quote a public registration. IPopRules public immutable POP_RULES; - /// @notice The hierarchical registry, where a subname is the rival reading of a dotted text. + /// @notice The hierarchical registry, where a lite name lives as a subname of its container. IDotnsRegistry public immutable REGISTRY; /// @notice Node hash of the suite's TLD, injected from the deployed protocol registry. /// @dev Keeps the handler rooted at the same TLD the protocol under test uses, without a @@ -103,16 +103,6 @@ contract PopControllerHandler is Test { /// @notice Count of subname attempts the registry rejected. uint256 public subnameRejectedCount; - /// @notice Subnodes built as the rival reading of a lite name: its stem under its suffix. - /// @dev `michael.01` is one label to the gateway and `michael` beneath `01` here. Both - /// display as the same text, so this is the shape a subname holder would use to pass - /// for a person. - bytes32[] public rivalSubnodes; - /// @notice The lite label each rival subnode displays as (same index). - string[] public rivalTexts; - /// @notice Count of rival-hierarchy attempts the registry rejected. - uint256 public rivalRejectedCount; - /// @notice Nodes minted through the public path, for the transfer action to move. uint256[] public publicTokenIds; @@ -488,12 +478,10 @@ contract PopControllerHandler is Test { /// @notice Creates an arbitrary subname under a gateway-issued name. /// @dev Interleaves subname creation with gateway mints so no subnode can quietly land on an - /// issued name. A lite parent never gets this far: the registry derives a parent's node - /// by splitting the path on the separator, so `joseph.42` as a parent label resolves to - /// the hierarchy rather than to the node the gateway minted, and the call reverts. The - /// rival reading of a lite name is built by @custom:function createRivalSubname. The - /// subnode owner comes from `publicActors` so a subname never deposits a `LabelStore` - /// on a gateway actor. + /// issued name. A lite parent is skipped: this derives the parent node by hashing the + /// whole label under the TLD, which is not a node the gateway minted for a lite name, so + /// the existence check returns early. The subnode owner comes from `publicActors` so a + /// subname never deposits a `LabelStore` on a gateway actor. function createSubname(uint256 parentIndex, uint256 subLabelSeed, uint256 toIndex) external { uint256 n = gatewayLabelsSeen.length; if (n == 0) return; @@ -508,7 +496,8 @@ contract PopControllerHandler is Test { parentNode: parentNode, subLabel: _buildSubLabel(subLabelSeed), parentLabel: parentLabel, - owner: publicActors[toIndex % publicActors.length] + owner: publicActors[toIndex % publicActors.length], + persist: true }); vm.prank(parentOwner); @@ -520,75 +509,6 @@ contract PopControllerHandler is Test { } } - /// @notice Builds the rival hierarchy for a lite name: its stem as a subname of its suffix. - /// @dev The two readings of `michael.01` are a single label and `michael` under `01`, and - /// they display identically once the TLD is appended. The suffix parent is - /// governance-only on every production entry point, so it is minted straight from an - /// authorised controller: the point is to stand the rival hierarchy up and let the - /// invariant show that the two nodes never converge, and that the text-keyed answer - /// belongs to the whole-label reading rather than to whichever object shares its - /// text. - function createRivalSubname(uint256 liteIndex, uint256 toIndex) external { - uint256 n = priorLiteLabels.length; - if (n == 0) return; - - string memory liteLabel = priorLiteLabels[liteIndex % n]; - (string memory stem, string memory suffix) = _splitLite(liteLabel); - - bytes32 parentNode = LabelUtils.namehashUnder(TLD_NODE, LabelUtils.labelhashMemory(suffix)); - address parentOwner = publicActors[toIndex % publicActors.length]; - if (REGISTRAR.exists(uint256(parentNode))) { - parentOwner = REGISTRAR.ownerOf(uint256(parentNode)); - } else { - vm.startPrank(address(PUBLIC_CONTROLLER)); - REGISTRAR.register(uint256(parentNode), parentOwner, ""); - REGISTRY.setOwner(parentNode, parentOwner); - vm.stopPrank(); - } - - IDotnsRegistry.SubnodeRecord memory record = IDotnsRegistry.SubnodeRecord({ - parentNode: parentNode, - subLabel: stem, - parentLabel: suffix, - owner: publicActors[(toIndex + 1) % publicActors.length] - }); - - vm.prank(parentOwner); - try REGISTRY.setSubnodeOwner(record) returns (bytes32 created) { - rivalSubnodes.push(created); - rivalTexts.push(liteLabel); - } catch { - ++rivalRejectedCount; - } - } - - /// @notice Splits a lite label into its stem and its allocated suffix. - function _splitLite(string memory liteLabel) - internal - pure - returns (string memory stem, string memory suffix) - { - bytes memory raw = bytes(liteLabel); - uint256 separator = raw.length - StringUtils.LITE_SUFFIX_DIGITS - 1; - - bytes memory stemBytes = new bytes(separator); - for (uint256 i; i < separator; ++i) { - stemBytes[i] = raw[i]; - } - - bytes memory suffixBytes = new bytes(StringUtils.LITE_SUFFIX_DIGITS); - for (uint256 i; i < StringUtils.LITE_SUFFIX_DIGITS; ++i) { - suffixBytes[i] = raw[separator + 1 + i]; - } - - return (string(stemBytes), string(suffixBytes)); - } - - /// @notice Number of rival-hierarchy subnodes created. - function rivalSubnodeCount() external view returns (uint256) { - return rivalSubnodes.length; - } - /// @notice Builds a sub-label from an alphabet that includes the separator and digits. /// @dev Lengths of one to eight cover a plain label, one carrying a separator, an all-digit /// label, and a hyphen in any position. diff --git a/test/invariant/registry/DotnsRegistryInvariant.t.sol b/test/invariant/registry/DotnsRegistryInvariant.t.sol index e0e886482..3d90c3d20 100644 --- a/test/invariant/registry/DotnsRegistryInvariant.t.sol +++ b/test/invariant/registry/DotnsRegistryInvariant.t.sol @@ -140,7 +140,8 @@ contract DotnsRegistryInvariantTest is BaseDotns { parentNode: parentNode, subLabel: handler.subnodeLabelAt(i), parentLabel: parentLabel, - owner: parentOwner + owner: parentOwner, + persist: true }); vm.prank(parentOwner); diff --git a/test/invariant/registry/RegistryHandler.t.sol b/test/invariant/registry/RegistryHandler.t.sol index 30cf34304..5d95f71e1 100644 --- a/test/invariant/registry/RegistryHandler.t.sol +++ b/test/invariant/registry/RegistryHandler.t.sol @@ -196,7 +196,8 @@ contract RegistryHandler is Test { parentNode: parentNode, subLabel: _subnodeLabels[index], parentLabel: parentLabel, - owner: newOwner + owner: newOwner, + persist: true }); vm.prank(parentOwner); @@ -315,7 +316,8 @@ contract RegistryHandler is Test { parentNode: parentNode, subLabel: subLabel, parentLabel: parentLabel, - owner: subnodeOwner + owner: subnodeOwner, + persist: true }); bytes memory subLabelBytes = bytes(subLabel); diff --git a/test/unit/registrar/DotnsPopController.t.sol b/test/unit/registrar/DotnsPopController.t.sol index d930019a7..d61e47f4a 100644 --- a/test/unit/registrar/DotnsPopController.t.sol +++ b/test/unit/registrar/DotnsPopController.t.sol @@ -29,8 +29,9 @@ contract DotnsPopControllerTests is BaseDotns { _reservePop(ed, LITE_LABEL_A, chatKey, ""); - bytes32 node = _nodeOf(LITE_LABEL_A); - assertEq(IERC721(address(dotnsRegistrar)).ownerOf(uint256(node)), ed); + // A lite username is a subnode under its numeric container, not a tokenised name, so its + // ownership lives in the registry record rather than the registrar's ERC-721 ledger. + bytes32 node = _liteNodeOf(LITE_LABEL_A); assertEq(dotnsRegistry.owner(node), ed); assertEq(dotnsPopResolver.chatKey(node), chatKey); } @@ -376,7 +377,7 @@ contract DotnsPopControllerTests is BaseDotns { IDotnsPopController.FullRegistration({label: "michael", user: tiago, link: link}) ); - assertEq(IERC721(address(dotnsRegistrar)).ownerOf(uint256(_nodeOf(LITE_LABEL_A))), ed); + assertEq(dotnsRegistry.owner(_liteNodeOf(LITE_LABEL_A)), ed); assertEq(IERC721(address(dotnsRegistrar)).ownerOf(uint256(_nodeOf("michael"))), tiago); } @@ -385,7 +386,7 @@ contract DotnsPopControllerTests is BaseDotns { _reservePop(ed, LITE_LABEL_A, _validChatKey(0x01), ""); _commitAndRegister("longnamebob01", tiago, true); - assertEq(IERC721(address(dotnsRegistrar)).ownerOf(uint256(_nodeOf(LITE_LABEL_A))), ed); + assertEq(dotnsRegistry.owner(_liteNodeOf(LITE_LABEL_A)), ed); assertEq(IERC721(address(dotnsRegistrar)).ownerOf(uint256(_nodeOf("longnamebob01"))), tiago); } @@ -448,16 +449,14 @@ contract DotnsPopControllerTests is BaseDotns { assertEq(IERC721(address(dotnsRegistrar)).ownerOf(uint256(_nodeOf("longnamebob01"))), ed); } - function test_second_pop_lite_mint_of_same_label_reverts_at_registrar() public { + function test_second_pop_lite_mint_of_same_label_reverts() public { _grantPopFull(ed); _reservePop(ed, LITE_LABEL_A, _validChatKey(0xaa), ""); + // Re-issuing a lite name is rejected at the controller before any registry write, so a + // duplicate dispatch cannot rehome the identity or overwrite its records. _grantPopFull(tiago); - vm.expectRevert( - abi.encodeWithSelector( - IDotnsRegistrar.NameNotAvailable.selector, uint256(_nodeOf(LITE_LABEL_A)) - ) - ); + vm.expectRevert(IDotnsPopController.LiteNameAlreadyIssued.selector); _rootReserveBaseName( IDotnsPopController.BaseReservation({ lite: IDotnsPopController.LiteRegistration({ @@ -473,7 +472,7 @@ contract DotnsPopControllerTests is BaseDotns { /// `michael.01` as a parent label resolves to `michael` beneath `01` and never to the /// node the gateway minted. The holder of a lite name therefore has no subname tree, /// and no caller can graft one onto their identity. - function test_lite_name_cannot_host_a_subname() public { + function test_lite_name_owner_can_host_a_subname() public { _grantPopLite(ed); _rootReserveLiteName( IDotnsPopController.LiteRegistration({ @@ -481,16 +480,19 @@ contract DotnsPopControllerTests is BaseDotns { }) ); + // A lite name is a subname the owner controls, so they can host their own subnames beneath + // it, such as a device name `phone.michael.01`. IDotnsRegistry.SubnodeRecord memory subnodeRecord = IDotnsRegistry.SubnodeRecord({ - parentNode: _nodeOf(LITE_LABEL_A), - subLabel: "blog", + parentNode: _liteNodeOf(LITE_LABEL_A), + subLabel: "phone", parentLabel: LITE_LABEL_A, - owner: ed + owner: ed, + persist: true }); vm.prank(ed); - vm.expectRevert(IDotnsRegistry.ParentLabelMismatch.selector); - dotnsRegistry.setSubnodeOwner(subnodeRecord); + bytes32 subnode = dotnsRegistry.setSubnodeOwner(subnodeRecord); + assertEq(dotnsRegistry.owner(subnode), ed); } /// @notice A public registration blocks the gateway from the same label, and leaves no @@ -564,7 +566,11 @@ contract DotnsPopControllerTests is BaseDotns { bytes32 parentNode = _nodeOf(BASE_LABEL_A); IDotnsRegistry.SubnodeRecord memory subnodeRecord = IDotnsRegistry.SubnodeRecord({ - parentNode: parentNode, subLabel: "app", parentLabel: BASE_LABEL_A, owner: leonardo + parentNode: parentNode, + subLabel: "app", + parentLabel: BASE_LABEL_A, + owner: leonardo, + persist: true }); vm.prank(ed); @@ -582,7 +588,11 @@ contract DotnsPopControllerTests is BaseDotns { bytes32 parentNode = _nodeOf(BASE_LABEL_A); IDotnsRegistry.SubnodeRecord memory subnodeRecord = IDotnsRegistry.SubnodeRecord({ - parentNode: parentNode, subLabel: "app", parentLabel: BASE_LABEL_A, owner: tiago + parentNode: parentNode, + subLabel: "app", + parentLabel: BASE_LABEL_A, + owner: tiago, + persist: true }); vm.prank(tiago); @@ -883,7 +893,7 @@ contract DotnsPopControllerTests is BaseDotns { }) ); - assertEq(IERC721(address(dotnsRegistrar)).ownerOf(uint256(_nodeOf("stephen.01"))), fresh); + assertEq(dotnsRegistry.owner(_liteNodeOf("stephen.01")), fresh); } function test_reserveLiteName_reverts_for_non_lite_format() public { @@ -932,7 +942,7 @@ contract DotnsPopControllerTests is BaseDotns { }) ); - assertEq(IERC721(address(dotnsRegistrar)).ownerOf(uint256(_nodeOf("andrewsays.01"))), ed); + assertEq(dotnsRegistry.owner(_liteNodeOf("andrewsays.01")), ed); } /// @notice A public registration is not an identity and appears in neither listing. @@ -1039,11 +1049,14 @@ contract DotnsPopControllerTests is BaseDotns { }) ); - bytes32 wholeLabelNode = _nodeOf("michael.01"); - assertEq(IERC721(address(dotnsRegistrar)).ownerOf(uint256(wholeLabelNode)), ed); + // The name is a subname under its numeric container, owned in the registry record. + bytes32 subnamePathNode = _liteNodeOf("michael.01"); + assertEq(dotnsRegistry.owner(subnamePathNode), ed); - bytes32 subnamePathNode = _namehash(_nodeOf("01"), keccak256(bytes("michael"))); + // The whole-label reading is a different node and is never minted as a token. + bytes32 wholeLabelNode = _nodeOf("michael.01"); assertTrue(wholeLabelNode != subnamePathNode, "whole label and subname path differ"); + assertFalse(dotnsRegistrar.exists(uint256(wholeLabelNode))); } function test_isPopIssued_is_set_at_mint() public { @@ -1124,7 +1137,7 @@ contract DotnsPopControllerTests is BaseDotns { }) ); - assertEq(IERC721(address(dotnsRegistrar)).ownerOf(uint256(_nodeOf(LITE_LABEL_A))), ed); + assertEq(dotnsRegistry.owner(_liteNodeOf(LITE_LABEL_A)), ed); (bool reserved, address holder) = dotnsPopController.isReservedForClaim(BASE_LABEL_A); assertTrue(reserved); @@ -1140,7 +1153,7 @@ contract DotnsPopControllerTests is BaseDotns { }) ); - assertEq(IERC721(address(dotnsRegistrar)).ownerOf(uint256(_nodeOf(LITE_LABEL_A))), ed); + assertEq(dotnsRegistry.owner(_liteNodeOf(LITE_LABEL_A)), ed); assertFalse(dotnsRegistrar.exists(uint256(_nodeOf(BASE_LABEL_A)))); _rootReserveBaseNameOnly( @@ -1244,7 +1257,8 @@ contract DotnsPopControllerTests is BaseDotns { address store = storeFactory.getLabelStore(ed); assertEq(store, expectedStore); assertEq( - ILabelStore(store).getLabel(_nodeOf(LITE_LABEL_A)), string.concat(LITE_LABEL_A, ".dot") + ILabelStore(store).getLabel(_liteNodeOf(LITE_LABEL_A)), + string.concat(LITE_LABEL_A, ".dot") ); assertEq(dotnsPopController.pendingClaimCountOf(ed), 0); } @@ -1263,7 +1277,8 @@ contract DotnsPopControllerTests is BaseDotns { address store = storeFactory.getLabelStore(ed); assertTrue(store != address(0)); assertEq( - ILabelStore(store).getLabel(_nodeOf(LITE_LABEL_A)), string.concat(LITE_LABEL_A, ".dot") + ILabelStore(store).getLabel(_liteNodeOf(LITE_LABEL_A)), + string.concat(LITE_LABEL_A, ".dot") ); assertEq(dotnsPopController.pendingClaimCountOf(ed), 0); } @@ -1283,7 +1298,7 @@ contract DotnsPopControllerTests is BaseDotns { address store = storeFactory.getLabelStore(ed); assertTrue(store != address(0)); assertEq( - ILabelStore(store).getLabel(_nodeOf(LITE_LABEL_A)), + ILabelStore(store).getLabel(_liteNodeOf(LITE_LABEL_A)), string.concat(LITE_LABEL_A, protocolRegistry.tld()) ); assertEq(dotnsPopController.pendingClaimCountOf(ed), 0); @@ -1309,7 +1324,8 @@ contract DotnsPopControllerTests is BaseDotns { address store = storeFactory.getLabelStore(ed); assertTrue(store != address(0)); assertEq( - ILabelStore(store).getLabel(_nodeOf(LITE_LABEL_A)), string.concat(LITE_LABEL_A, ".dot") + ILabelStore(store).getLabel(_liteNodeOf(LITE_LABEL_A)), + string.concat(LITE_LABEL_A, ".dot") ); assertEq(dotnsPopController.pendingClaimCountOf(ed), 0); } @@ -1341,7 +1357,7 @@ contract DotnsPopControllerTests is BaseDotns { vm.prank(ed); dotnsPopController.settlePendingClaims(ed, type(uint256).max); - bytes32 node = _nodeOf(LITE_LABEL_A); + bytes32 node = _liteNodeOf(LITE_LABEL_A); assertEq(dotnsPopResolver.chatKey(node), chatKey); } @@ -1374,8 +1390,8 @@ contract DotnsPopControllerTests is BaseDotns { }) ); - bytes32 node = _nodeOf(LITE_LABEL_A); - assertEq(IERC721(address(dotnsRegistrar)).ownerOf(uint256(node)), ed); + bytes32 node = _liteNodeOf(LITE_LABEL_A); + assertEq(dotnsRegistry.owner(node), ed); assertEq(storeFactory.getLabelStore(ed), address(0)); // Chat key is now persisted eagerly on the resolver at reserve time, even when // the user has no LabelStore yet. @@ -1403,7 +1419,7 @@ contract DotnsPopControllerTests is BaseDotns { address store = storeFactory.getLabelStore(ed); assertTrue(store != address(0)); - bytes32 node = _nodeOf(LITE_LABEL_A); + bytes32 node = _liteNodeOf(LITE_LABEL_A); assertEq( ILabelStore(store).getLabel(node), string.concat(LITE_LABEL_A, protocolRegistry.tld()) ); @@ -1510,7 +1526,7 @@ contract DotnsPopControllerTests is BaseDotns { address store = storeFactory.getLabelStore(ed); assertEq(store, expectedStore); assertEq( - ILabelStore(store).getLabel(_nodeOf(LITE_LABEL_A)), + ILabelStore(store).getLabel(_liteNodeOf(LITE_LABEL_A)), string.concat(LITE_LABEL_A, protocolRegistry.tld()) ); assertEq(dotnsPopController.pendingClaimCountOf(ed), 0); @@ -1536,7 +1552,7 @@ contract DotnsPopControllerTests is BaseDotns { address store = storeFactory.getLabelStore(ed); assertTrue(store != address(0)); assertEq( - ILabelStore(store).getLabel(_nodeOf(LITE_LABEL_A)), + ILabelStore(store).getLabel(_liteNodeOf(LITE_LABEL_A)), string.concat(LITE_LABEL_A, protocolRegistry.tld()) ); assertEq(dotnsPopController.pendingClaimCountOf(ed), 0); @@ -1572,11 +1588,11 @@ contract DotnsPopControllerTests is BaseDotns { address store = storeFactory.getLabelStore(ed); assertTrue(store != address(0)); assertEq( - ILabelStore(store).getLabel(_nodeOf(LITE_LABEL_A)), + ILabelStore(store).getLabel(_liteNodeOf(LITE_LABEL_A)), string.concat(LITE_LABEL_A, protocolRegistry.tld()) ); assertEq( - ILabelStore(store).getLabel(_nodeOf(LITE_LABEL_B)), + ILabelStore(store).getLabel(_liteNodeOf(LITE_LABEL_B)), string.concat(LITE_LABEL_B, protocolRegistry.tld()) ); assertEq(dotnsPopController.pendingClaimCountOf(ed), 0); @@ -1623,7 +1639,7 @@ contract DotnsPopControllerTests is BaseDotns { address store = storeFactory.getLabelStore(ed); assertTrue(store != address(0)); assertEq( - ILabelStore(store).getLabel(_nodeOf(LITE_LABEL_A)), + ILabelStore(store).getLabel(_liteNodeOf(LITE_LABEL_A)), string.concat(LITE_LABEL_A, protocolRegistry.tld()) ); assertEq( @@ -1704,7 +1720,7 @@ contract DotnsPopControllerTests is BaseDotns { address store = storeFactory.getLabelStore(ed); assertTrue(store != address(0)); assertEq( - ILabelStore(store).getLabel(_nodeOf(LITE_LABEL_A)), + ILabelStore(store).getLabel(_liteNodeOf(LITE_LABEL_A)), string.concat(LITE_LABEL_A, protocolRegistry.tld()) ); assertEq(dotnsPopController.pendingClaimCountOf(ed), 0); @@ -1774,7 +1790,7 @@ contract DotnsPopControllerTests is BaseDotns { vm.prank(ed); dotnsPopController.settlePendingClaims(ed, type(uint256).max); - bytes32 node = _nodeOf(LITE_LABEL_A); + bytes32 node = _liteNodeOf(LITE_LABEL_A); address store = storeFactory.getLabelStore(ed); assertTrue(store != address(0)); assertEq( @@ -1803,7 +1819,7 @@ contract DotnsPopControllerTests is BaseDotns { }) ); - bytes32 node = _nodeOf(LITE_LABEL_B); + bytes32 node = _liteNodeOf(LITE_LABEL_B); assertEq( ILabelStore(store).getLabel(node), string.concat(LITE_LABEL_B, protocolRegistry.tld()) ); @@ -1884,7 +1900,7 @@ contract DotnsPopControllerTests is BaseDotns { IDotnsPopLens.Name[] memory edLite = dotnsPopLens.liteNamesOf(ed, 0, type(uint256).max); assertEq(edLite.length, 1); - assertEq(edLite[0].node, _nodeOf(LITE_LABEL_A)); + assertEq(edLite[0].node, _liteNodeOf(LITE_LABEL_A)); assertEq(edLite[0].label, LITE_LABEL_A); assertTrue(edLite[0].settled); assertEq(edLite[0].deadline, 0); @@ -1901,7 +1917,7 @@ contract DotnsPopControllerTests is BaseDotns { IDotnsPopLens.Name[] memory leoLite = dotnsPopLens.liteNamesOf(leonardo, 0, type(uint256).max); assertEq(leoLite.length, 1); - assertEq(leoLite[0].node, _nodeOf(LITE_LABEL_C)); + assertEq(leoLite[0].node, _liteNodeOf(LITE_LABEL_C)); assertEq(leoLite[0].label, LITE_LABEL_C); assertFalse(leoLite[0].settled); assertGt(leoLite[0].deadline, 0); @@ -1950,12 +1966,12 @@ contract DotnsPopControllerTests is BaseDotns { IDotnsPopLens.Name[] memory edLite = dotnsPopLens.liteNamesOf(ed, 0, type(uint256).max); assertEq(edLite.length, 1); - assertFalse(_namesContainNode(edLite, _nodeOf(LITE_LABEL_C))); + assertFalse(_namesContainNode(edLite, _liteNodeOf(LITE_LABEL_C))); IDotnsPopLens.Name[] memory tiagoLite = dotnsPopLens.liteNamesOf(tiago, 0, type(uint256).max); assertEq(tiagoLite.length, 1); - assertFalse(_namesContainNode(tiagoLite, _nodeOf(LITE_LABEL_A))); + assertFalse(_namesContainNode(tiagoLite, _liteNodeOf(LITE_LABEL_A))); } function test_nameDetail_and_nameDetailByNode_report_record() public { @@ -1990,7 +2006,7 @@ contract DotnsPopControllerTests is BaseDotns { _grantPopFull(leonardo); _reservePop(leonardo, LITE_LABEL_C, _validChatKey(0xbb), ""); IDotnsPopLens.NameDetail memory coldByNode = - dotnsPopLens.nameDetailByNode(_nodeOf(LITE_LABEL_C)); + dotnsPopLens.nameDetailByNode(_liteNodeOf(LITE_LABEL_C)); assertTrue(coldByNode.exists); assertEq(coldByNode.fullClaim, bytes32(0)); diff --git a/test/unit/registry/DotnsRegistry.t.sol b/test/unit/registry/DotnsRegistry.t.sol index c2455fbe9..667a519a4 100644 --- a/test/unit/registry/DotnsRegistry.t.sol +++ b/test/unit/registry/DotnsRegistry.t.sol @@ -53,7 +53,11 @@ contract DotnsRegistryTests is BaseDotns { vm.expectRevert(IDotnsRegistry.InvalidLabel.selector); dotnsRegistry.setSubnodeOwner( IDotnsRegistry.SubnodeRecord({ - parentNode: parentNode, subLabel: "ali.ce", parentLabel: parentLabel, owner: ed + parentNode: parentNode, + subLabel: "ali.ce", + parentLabel: parentLabel, + owner: ed, + persist: true }) ); @@ -89,7 +93,11 @@ contract DotnsRegistryTests is BaseDotns { vm.prank(owner); bytes32 subnode = dotnsRegistry.setSubnodeOwner( IDotnsRegistry.SubnodeRecord({ - parentNode: twoDigitNode, subLabel: "michael", parentLabel: "01", owner: ed + parentNode: twoDigitNode, + subLabel: "michael", + parentLabel: "01", + owner: ed, + persist: true }) ); @@ -111,7 +119,11 @@ contract DotnsRegistryTests is BaseDotns { bytes32 expectedSubnode = _namehash(parentNode, subLabelHash); IDotnsRegistry.SubnodeRecord memory subnodeRecord = IDotnsRegistry.SubnodeRecord({ - parentNode: parentNode, subLabel: subLabel, parentLabel: parentLabel, owner: ed + parentNode: parentNode, + subLabel: subLabel, + parentLabel: parentLabel, + owner: ed, + persist: true }); vm.expectEmit(true, true, false, true, address(dotnsRegistry)); @@ -137,7 +149,11 @@ contract DotnsRegistryTests is BaseDotns { address newResolver = makeAddr("resolver"); IDotnsRegistry.SubnodeRecord memory subnodeRecord = IDotnsRegistry.SubnodeRecord({ - parentNode: parentNode, subLabel: subLabel, parentLabel: parentLabel, owner: ed + parentNode: parentNode, + subLabel: subLabel, + parentLabel: parentLabel, + owner: ed, + persist: true }); vm.startPrank(owner); @@ -164,7 +180,11 @@ contract DotnsRegistryTests is BaseDotns { address newResolver = makeAddr("resolver"); IDotnsRegistry.SubnodeRecord memory subnodeRecord = IDotnsRegistry.SubnodeRecord({ - parentNode: parentNode, subLabel: subLabel, parentLabel: parentLabel, owner: ed + parentNode: parentNode, + subLabel: subLabel, + parentLabel: parentLabel, + owner: ed, + persist: true }); vm.startPrank(owner); @@ -186,7 +206,11 @@ contract DotnsRegistryTests is BaseDotns { bytes32 parentNode = _register(parentLabel, owner, IPopRules.PopStatus.NoStatus); IDotnsRegistry.SubnodeRecord memory childRecord = IDotnsRegistry.SubnodeRecord({ - parentNode: parentNode, subLabel: "child", parentLabel: parentLabel, owner: ed + parentNode: parentNode, + subLabel: "child", + parentLabel: parentLabel, + owner: ed, + persist: true }); vm.prank(owner); @@ -194,7 +218,11 @@ contract DotnsRegistryTests is BaseDotns { string memory nestedParentLabel = string.concat("child.", parentLabel); IDotnsRegistry.SubnodeRecord memory leafRecord = IDotnsRegistry.SubnodeRecord({ - parentNode: childNode, subLabel: "leaf", parentLabel: nestedParentLabel, owner: tiago + parentNode: childNode, + subLabel: "leaf", + parentLabel: nestedParentLabel, + owner: tiago, + persist: true }); vm.prank(ed); @@ -217,7 +245,11 @@ contract DotnsRegistryTests is BaseDotns { bytes32 expectedChildNode = _namehash(parentNode, childLabelHash); IDotnsRegistry.SubnodeRecord memory subnodeRecord = IDotnsRegistry.SubnodeRecord({ - parentNode: parentNode, subLabel: childLabel, parentLabel: parentLabel, owner: tiago + parentNode: parentNode, + subLabel: childLabel, + parentLabel: parentLabel, + owner: tiago, + persist: true }); vm.startPrank(ed); @@ -235,7 +267,11 @@ contract DotnsRegistryTests is BaseDotns { bytes32 parentNode = _register(parentLabel, ed, IPopRules.PopStatus.PopFull); IDotnsRegistry.SubnodeRecord memory subnodeRecord = IDotnsRegistry.SubnodeRecord({ - parentNode: parentNode, subLabel: "docs", parentLabel: "parity", owner: leonardo + parentNode: parentNode, + subLabel: "docs", + parentLabel: "parity", + owner: leonardo, + persist: true }); vm.prank(ed); @@ -248,7 +284,11 @@ contract DotnsRegistryTests is BaseDotns { bytes32 parentNode = _register(parentLabel, owner, IPopRules.PopStatus.NoStatus); IDotnsRegistry.SubnodeRecord memory subnodeRecord = IDotnsRegistry.SubnodeRecord({ - parentNode: parentNode, subLabel: "docs.api", parentLabel: parentLabel, owner: ed + parentNode: parentNode, + subLabel: "docs.api", + parentLabel: parentLabel, + owner: ed, + persist: true }); vm.prank(owner); @@ -261,7 +301,7 @@ contract DotnsRegistryTests is BaseDotns { bytes32 parentNode = _register(parentLabel, owner, IPopRules.PopStatus.NoStatus); IDotnsRegistry.SubnodeRecord memory subnodeRecord = IDotnsRegistry.SubnodeRecord({ - parentNode: parentNode, subLabel: "", parentLabel: parentLabel, owner: ed + parentNode: parentNode, subLabel: "", parentLabel: parentLabel, owner: ed, persist: true }); vm.prank(owner); @@ -274,7 +314,11 @@ contract DotnsRegistryTests is BaseDotns { bytes32 parentNode = _register(parentLabel, owner, IPopRules.PopStatus.NoStatus); IDotnsRegistry.SubnodeRecord memory subnodeRecord = IDotnsRegistry.SubnodeRecord({ - parentNode: parentNode, subLabel: "Docs", parentLabel: parentLabel, owner: ed + parentNode: parentNode, + subLabel: "Docs", + parentLabel: parentLabel, + owner: ed, + persist: true }); vm.prank(owner); @@ -287,7 +331,11 @@ contract DotnsRegistryTests is BaseDotns { bytes32 parentNode = _register(parentLabel, owner, IPopRules.PopStatus.NoStatus); IDotnsRegistry.SubnodeRecord memory subnodeRecord = IDotnsRegistry.SubnodeRecord({ - parentNode: parentNode, subLabel: "docs", parentLabel: "Parentnode10", owner: ed + parentNode: parentNode, + subLabel: "docs", + parentLabel: "Parentnode10", + owner: ed, + persist: true }); vm.prank(owner); @@ -300,7 +348,11 @@ contract DotnsRegistryTests is BaseDotns { bytes32 parentNode = _register(parentLabel, ed, IPopRules.PopStatus.NoStatus); IDotnsRegistry.SubnodeRecord memory subnodeRecord = IDotnsRegistry.SubnodeRecord({ - parentNode: parentNode, subLabel: "blog", parentLabel: parentLabel, owner: leonardo + parentNode: parentNode, + subLabel: "blog", + parentLabel: parentLabel, + owner: leonardo, + persist: true }); vm.prank(ed); @@ -320,7 +372,11 @@ contract DotnsRegistryTests is BaseDotns { bytes32 parentNode = _register(parentLabel, ed, IPopRules.PopStatus.NoStatus); IDotnsRegistry.SubnodeRecord memory subnodeRecord = IDotnsRegistry.SubnodeRecord({ - parentNode: parentNode, subLabel: "app", parentLabel: parentLabel, owner: leonardo + parentNode: parentNode, + subLabel: "app", + parentLabel: parentLabel, + owner: leonardo, + persist: true }); vm.prank(ed); @@ -344,7 +400,11 @@ contract DotnsRegistryTests is BaseDotns { bytes32 parentNode = _register(parentLabel, ed, IPopRules.PopStatus.NoStatus); IDotnsRegistry.SubnodeRecord memory subnodeRecord = IDotnsRegistry.SubnodeRecord({ - parentNode: parentNode, subLabel: "docs", parentLabel: parentLabel, owner: leonardo + parentNode: parentNode, + subLabel: "docs", + parentLabel: parentLabel, + owner: leonardo, + persist: true }); vm.prank(ed); @@ -368,7 +428,11 @@ contract DotnsRegistryTests is BaseDotns { bytes32 parentNode = _register(parentLabel, ed, IPopRules.PopStatus.NoStatus); IDotnsRegistry.SubnodeRecord memory subnodeRecord = IDotnsRegistry.SubnodeRecord({ - parentNode: parentNode, subLabel: "api", parentLabel: parentLabel, owner: leonardo + parentNode: parentNode, + subLabel: "api", + parentLabel: parentLabel, + owner: leonardo, + persist: true }); vm.prank(ed); @@ -386,7 +450,11 @@ contract DotnsRegistryTests is BaseDotns { bytes32 parentNode = _register(parentLabel, ed, IPopRules.PopStatus.NoStatus); IDotnsRegistry.SubnodeRecord memory subnodeRecord = IDotnsRegistry.SubnodeRecord({ - parentNode: parentNode, subLabel: "mail", parentLabel: parentLabel, owner: leonardo + parentNode: parentNode, + subLabel: "mail", + parentLabel: parentLabel, + owner: leonardo, + persist: true }); vm.prank(ed); @@ -408,7 +476,11 @@ contract DotnsRegistryTests is BaseDotns { bytes32 parentNode = _register(parentLabel, ed, IPopRules.PopStatus.NoStatus); IDotnsRegistry.SubnodeRecord memory subnodeRecord = IDotnsRegistry.SubnodeRecord({ - parentNode: parentNode, subLabel: "web", parentLabel: parentLabel, owner: leonardo + parentNode: parentNode, + subLabel: "web", + parentLabel: parentLabel, + owner: leonardo, + persist: true }); vm.prank(ed); @@ -436,11 +508,19 @@ contract DotnsRegistryTests is BaseDotns { string memory subLabel = "app"; IDotnsRegistry.SubnodeRecord memory recordA = IDotnsRegistry.SubnodeRecord({ - parentNode: parentNodeA, subLabel: subLabel, parentLabel: parentLabelA, owner: ed + parentNode: parentNodeA, + subLabel: subLabel, + parentLabel: parentLabelA, + owner: ed, + persist: true }); IDotnsRegistry.SubnodeRecord memory recordB = IDotnsRegistry.SubnodeRecord({ - parentNode: parentNodeB, subLabel: subLabel, parentLabel: parentLabelB, owner: ed + parentNode: parentNodeB, + subLabel: subLabel, + parentLabel: parentLabelB, + owner: ed, + persist: true }); vm.startPrank(owner); @@ -457,7 +537,11 @@ contract DotnsRegistryTests is BaseDotns { bytes32 parentNode = _register(parentLabel, ed, IPopRules.PopStatus.NoStatus); IDotnsRegistry.SubnodeRecord memory subnodeRecord = IDotnsRegistry.SubnodeRecord({ - parentNode: parentNode, subLabel: "api", parentLabel: parentLabel, owner: leonardo + parentNode: parentNode, + subLabel: "api", + parentLabel: parentLabel, + owner: leonardo, + persist: true }); vm.prank(ed); @@ -483,7 +567,11 @@ contract DotnsRegistryTests is BaseDotns { bytes32 parentNode = _register(parentLabel, ed, IPopRules.PopStatus.NoStatus); IDotnsRegistry.SubnodeRecord memory subnodeRecord = IDotnsRegistry.SubnodeRecord({ - parentNode: parentNode, subLabel: "web", parentLabel: parentLabel, owner: leonardo + parentNode: parentNode, + subLabel: "web", + parentLabel: parentLabel, + owner: leonardo, + persist: true }); vm.prank(ed); @@ -508,7 +596,11 @@ contract DotnsRegistryTests is BaseDotns { bytes32 parentNode = _register(parentLabel, ed, IPopRules.PopStatus.NoStatus); IDotnsRegistry.SubnodeRecord memory subnodeRecord = IDotnsRegistry.SubnodeRecord({ - parentNode: parentNode, subLabel: "mail", parentLabel: parentLabel, owner: leonardo + parentNode: parentNode, + subLabel: "mail", + parentLabel: parentLabel, + owner: leonardo, + persist: true }); vm.prank(ed); From 3138e626243c844e765659565412c23bb09717aa Mon Sep 17 00:00:00 2001 From: Siphamandla Mjoli Date: Thu, 10 Sep 2026 01:48:56 +0200 Subject: [PATCH 2/4] test: derive PoP lite fuzz nodes from the numeric container hierarchy The lite fuzz suite resolved a username as an atomic label under the top level and read its ownership from the registrar. Derive the node under the numeric container and read ownership from the registry, matching how a lite username is now issued. --- test/fuzz/registrar/DotnsPopControllerFuzz.t.sol | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/test/fuzz/registrar/DotnsPopControllerFuzz.t.sol b/test/fuzz/registrar/DotnsPopControllerFuzz.t.sol index dee9d5b59..996939001 100644 --- a/test/fuzz/registrar/DotnsPopControllerFuzz.t.sol +++ b/test/fuzz/registrar/DotnsPopControllerFuzz.t.sol @@ -36,7 +36,7 @@ contract DotnsPopControllerFuzz is BaseDotns { _reservePop(ed, label, "", ""); - assertEq(IERC721(address(dotnsRegistrar)).ownerOf(uint256(_nodeOf(label))), ed); + assertEq(dotnsRegistry.owner(_liteNodeOf(label)), ed); } function testFuzz_reserveBaseName_persists_chat_key_exact_bytes( @@ -54,7 +54,7 @@ contract DotnsPopControllerFuzz is BaseDotns { bytes memory chatKey = useKey ? _validChatKey(keySeed) : bytes(""); _reservePop(ed, label, chatKey, ""); - bytes32 node = _nodeOf(label); + bytes32 node = _liteNodeOf(label); if (chatKey.length == 0) { assertEq(dotnsPopResolver.chatKey(node).length, 0); } else { @@ -164,7 +164,7 @@ contract DotnsPopControllerFuzz is BaseDotns { assertEq(storeFactory.getLabelStore(ed), address(0)); // Chat key is persisted eagerly on the resolver at reserve time, even though // the LabelStore write is deferred to settlement on the cold path. - bytes32 node = _nodeOf(label); + bytes32 node = _liteNodeOf(label); assertEq(dotnsPopResolver.chatKey(node), chatKey); } @@ -186,7 +186,7 @@ contract DotnsPopControllerFuzz is BaseDotns { vm.prank(ed); dotnsPopController.settlePendingClaims(ed, type(uint256).max); - bytes32 node = _nodeOf(label); + bytes32 node = _liteNodeOf(label); address store = storeFactory.getLabelStore(ed); assertTrue(store != address(0)); assertEq(ILabelStore(store).getLabel(node), string.concat(label, protocolRegistry.tld())); @@ -228,7 +228,7 @@ contract DotnsPopControllerFuzz is BaseDotns { assertEq(settledCount, 1); assertFalse(moreRemaining); - bytes32 node = _nodeOf(LITE_LABEL_A); + bytes32 node = _liteNodeOf(LITE_LABEL_A); address store = storeFactory.getLabelStore(ed); assertTrue(store != address(0)); assertEq( From 4d9e1b59806de676c48f5289297d2a91f0248c13 Mon Sep 17 00:00:00 2001 From: Siphamandla Mjoli Date: Thu, 10 Sep 2026 16:21:48 +0200 Subject: [PATCH 3/4] Make the subname issuer generic and bind lite names to their subnode SubnodeUtils reuses a parent on ownership alone rather than requiring it be soulbound, so it is a general-purpose subname issuer; the container stays soulbound because the controller mints it that way. One `liteSubnodeOf` derives a lite name's node, shared by the controller, lens, and reverse resolver. The controller takes the subnode from the registry write, so the record and the chat-key and store writes land on one node. The lens binds each store row to its own text and classifies a cold-path lite name from the caller's label, so a pending subname reports its tier before it settles. The settlement invariant checks a lite name as a registry subname, and new tests cover the container lifecycle, a lite reverse claim, cold-path classification, and the store-row provenance guard. Stale atomic-label NatSpec is corrected. --- contracts/registrars/DotnsPopController.sol | 11 +- contracts/registrars/DotnsPopLens.sol | 49 +++++--- contracts/registrars/IDotnsPopController.sol | 6 +- contracts/resolvers/DotnsReverseResolver.sol | 3 +- contracts/resolvers/IDotnsReverseResolver.sol | 12 +- contracts/utils/StringUtils.sol | 2 +- contracts/utils/SubnodeUtils.sol | 46 ++++--- .../DotnsPopControllerInvariant.t.sol | 70 +++++++---- .../registrar/PopControllerHandler.t.sol | 16 +-- test/unit/registrar/DotnsPopController.t.sol | 117 +++++++++++++++++- test/unit/registry/DotnsRegistry.t.sol | 21 ++-- test/unit/resolver/DotnsReverseResolver.t.sol | 19 ++- 12 files changed, 277 insertions(+), 95 deletions(-) diff --git a/contracts/registrars/DotnsPopController.sol b/contracts/registrars/DotnsPopController.sol index 8338bda78..8a3beb2dc 100644 --- a/contracts/registrars/DotnsPopController.sol +++ b/contracts/registrars/DotnsPopController.sol @@ -599,7 +599,10 @@ contract DotnsPopController is // which would rehome the identity and overwrite its records, so it is rejected. require(!_registry().recordExists(node), LiteNameAlreadyIssued()); (string memory stem, string memory suffix) = label.splitLiteLabel(); - SubnodeUtils.registerSubname( + // Take the node from the registry write itself, so the chat-key and store writes below + // land on exactly the node the record was created at rather than a separately derived + // one that could drift from it. + node = SubnodeUtils.registerSubname( SubnodeUtils.SubnameContext({ protocolRegistry: protocolRegistry, parentLabel: suffix, @@ -646,7 +649,8 @@ contract DotnsPopController is /// user whose store was pre-populated under the same `node` (e.g. by a sibling protocol /// flow) can still settle their pending claim without bricking on `LabelAlreadyExists`. /// @param store Owner's `LabelStore` proxy. - /// @param node `namehash(labelhash)` for the entry. + /// @param node The name's node. A lite label resolves to its stem beneath its numeric + /// container, so this is not always `namehash(tldNode, keccak(label))` for the whole label. /// @param label Bare label without the TLD, which is appended on write. A lite label /// carries its separator, so this is not always a single DNS label. function _writeRecord(address store, bytes32 node, string memory label) internal { @@ -811,8 +815,7 @@ contract DotnsPopController is /// @param liteLabel Lite label held in memory, e.g. `alice.01`. /// @return subnode Namehash of `stem` under `suffix.tld`. function _liteSubnode(string memory liteLabel) internal view returns (bytes32 subnode) { - (string memory stem, string memory suffix) = liteLabel.splitLiteLabel(); - subnode = SubnodeUtils.subnodeOf(protocolRegistry.tldNode(), suffix, stem); + subnode = SubnodeUtils.liteSubnodeOf(protocolRegistry.tldNode(), liteLabel); } /// @notice Validates a base (full-person) label and derives `(labelhash, node)`. diff --git a/contracts/registrars/DotnsPopLens.sol b/contracts/registrars/DotnsPopLens.sol index 6efa9d6c1..76ba105cb 100644 --- a/contracts/registrars/DotnsPopLens.sol +++ b/contracts/registrars/DotnsPopLens.sol @@ -85,10 +85,10 @@ contract DotnsPopLens is IDotnsPopLens { /// @inheritdoc IDotnsPopLens function nameDetail(string calldata name) external view override returns (NameDetail memory) { - NameDetail memory detail = _detail(_nodeOf(name)); - // The caller holds the label, so supply it when the name exists but the node alone could - // not recover it (a subname). An unknown name keeps its empty label. - if (detail.exists && bytes(detail.label).length == 0) detail.label = name; + // The caller holds the label, so it is passed in: a pending subname cannot recover its + // label from the node alone, and classification must see the label before the detail is + // returned. + NameDetail memory detail = _detail(_nodeOf(name), name); // Holding the label means holding its labelhash, so the lite-to-full link resolves here. detail.fullClaim = _popResolver().fullClaim(LabelUtils.labelhash(name)); return detail; @@ -96,7 +96,9 @@ contract DotnsPopLens is IDotnsPopLens { /// @inheritdoc IDotnsPopLens function nameDetailByNode(bytes32 node) external view override returns (NameDetail memory) { - NameDetail memory detail = _detail(node); + // No label is supplied: the node cannot recover a pending subname's label, so it stays + // empty. + NameDetail memory detail = _detail(node, ""); // The node cannot be inverted to a labelhash, so `fullClaim` resolves only when the label // is independently recoverable (a settled name whose label the registrar returns). if (bytes(detail.label).length != 0) { @@ -145,10 +147,11 @@ contract DotnsPopLens is IDotnsPopLens { for (uint256 i; i < stored; ++i) { bytes32 node = labelStore.getLabelhashAt(i); if (!_ownedBy(node, user)) continue; - if (_belongsToListing(LabelUtils.stripTld(tld, labelStore.getLabelAt(i)), wantLite)) - { - ++count; - } + string memory label = LabelUtils.stripTld(tld, labelStore.getLabelAt(i)); + // The store keys ownership by node and provenance by text separately; bind them so + // a row whose key is not its own text's node is neither counted nor listed. + if (node != _nodeOf(label)) continue; + if (_belongsToListing(label, wantLite)) ++count; } } @@ -195,6 +198,9 @@ contract DotnsPopLens is IDotnsPopLens { bytes32 node = labelStore.getLabelhashAt(i); if (!_ownedBy(node, user)) continue; string memory label = LabelUtils.stripTld(tld, labelStore.getLabelAt(i)); + // Bind the row's node key to its own text, so a row whose key is not its text's + // node is neither counted nor listed. + if (node != _nodeOf(label)) continue; if (!_belongsToListing(label, wantLite)) continue; if (seen++ < offset) continue; page[filled++] = Name({node: node, label: label, settled: true, deadline: 0}); @@ -234,9 +240,20 @@ contract DotnsPopLens is IDotnsPopLens { /// @notice Gathers a name's record from the registrar, PoP resolver, and PopRules. /// @dev Reads defensively so an unminted or unsettled name yields zeroed fields instead of /// reverting. `fullClaim` is left for the caller because it needs the labelhash, which is - /// recoverable from the label string but not from the node alone. `tier` classifies the - /// label shape and is skipped for an empty label. - function _detail(bytes32 node) internal view returns (NameDetail memory detail) { + /// recoverable from the label string but not from the node alone. `tier` classifies the label + /// shape, so `knownLabel` supplies the label for a pending subname the node cannot recover, + /// letting classification run before the detail is returned; it is ignored when the label is + /// otherwise recoverable, and an empty `knownLabel` leaves an unrecoverable label unclassified. + /// @param node The name's node. + /// @param knownLabel Label the caller already holds, used only when the node cannot recover it. + function _detail( + bytes32 node, + string memory knownLabel + ) + internal + view + returns (NameDetail memory detail) + { detail.node = node; address owner = _registry().owner(node); if (owner != address(0)) { @@ -247,12 +264,15 @@ contract DotnsPopLens is IDotnsPopLens { detail.settled = settled; // A tokenised name carries its label on the registrar; a subname does not, so its // label is read back from the owner's store once settled. A pending subname has no - // recoverable label from the node alone. + // recoverable label from the node alone, so it is taken from `knownLabel` when the + // caller supplied one. if (_registrar().exists(uint256(node))) { detail.label = _registrar().labelOf(uint256(node)); } else if (settled) { detail.label = LabelUtils.stripTld(_protocolRegistry.tld(), ILabelStore(store).getLabel(node)); + } else if (bytes(knownLabel).length != 0) { + detail.label = knownLabel; } } if (bytes(detail.label).length != 0) { @@ -303,8 +323,7 @@ contract DotnsPopLens is IDotnsPopLens { function _nodeOf(string memory label) internal view returns (bytes32 node) { bytes32 tldNode = _protocolRegistry.tldNode(); if (label.isLitePersonLabelMemory()) { - (string memory stem, string memory suffix) = label.splitLiteLabel(); - return SubnodeUtils.subnodeOf(tldNode, suffix, stem); + return SubnodeUtils.liteSubnodeOf(tldNode, label); } node = LabelUtils.namehashUnder(tldNode, LabelUtils.labelhashMemory(label)); } diff --git a/contracts/registrars/IDotnsPopController.sol b/contracts/registrars/IDotnsPopController.sol index 704ca92fa..cff38f80e 100644 --- a/contracts/registrars/IDotnsPopController.sol +++ b/contracts/registrars/IDotnsPopController.sol @@ -8,8 +8,10 @@ import {IDotnsController} from "./IDotnsController.sol"; /// username issuance on behalf of the PoP gateway. /// @dev Deliberately disjoint from @custom:contract IDotnsRegistrarController. The two /// controllers coexist on @custom:contract DotnsRegistrar via its multi-controller affordance -/// and neither imports the other. Collision handling reduces to the registrar's ERC721 -/// availability check (first-to-mint wins). Reservation queuing for `reservedBaseLabel` +/// and neither imports the other. A full-person username collides through the registrar's ERC721 +/// availability check (first-to-mint wins); a lite username is not a token, so it collides through +/// @custom:function IDotnsRegistry.recordExists at its stem-under-container node +/// (@custom:reverts LiteNameAlreadyIssued). Reservation queuing for `reservedBaseLabel` /// mirrors its live head into PopRules, so a queued stem also blocks the public /// commit-reveal flow, which reads that slot when it prices a name. /// diff --git a/contracts/resolvers/DotnsReverseResolver.sol b/contracts/resolvers/DotnsReverseResolver.sol index 720a4dc9b..f94e1fc15 100644 --- a/contracts/resolvers/DotnsReverseResolver.sol +++ b/contracts/resolvers/DotnsReverseResolver.sol @@ -105,8 +105,7 @@ contract DotnsReverseResolver is function _nodeOf(string memory label) internal view returns (bytes32 node) { bytes32 tldNode = protocolRegistry.tldNode(); if (StringUtils.isLitePersonLabelMemory(label)) { - (string memory stem, string memory suffix) = StringUtils.splitLiteLabel(label); - return SubnodeUtils.subnodeOf(tldNode, suffix, stem); + return SubnodeUtils.liteSubnodeOf(tldNode, label); } node = LabelUtils.namehashUnder(tldNode, LabelUtils.labelhashMemory(label)); } diff --git a/contracts/resolvers/IDotnsReverseResolver.sol b/contracts/resolvers/IDotnsReverseResolver.sol index 3920624b4..453934834 100644 --- a/contracts/resolvers/IDotnsReverseResolver.sol +++ b/contracts/resolvers/IDotnsReverseResolver.sol @@ -5,7 +5,7 @@ pragma solidity ^0.8.34; /// @notice Interface for writing and reading reverse name records for addresses. /// @dev Reverse records bind to an EOA rather than a registry node. Two write paths exist: /// a registrar-only setter used by the controller during reserved registration, and a -/// self-service claim path callable by the current NFT owner. Reads are fail-closed: +/// self-service claim path callable by the current name owner. Reads are fail-closed: /// if the stored record no longer maps to a name owned by the address, @custom:function nameOf /// returns the empty string. /// @custom:security-contact admin@parity.io @@ -16,8 +16,9 @@ interface IDotnsReverseResolver { /// @notice Thrown when a caller attempts to claim a reverse record for a name they do not own. /// @param caller The address attempting the claim. - /// @param tokenId The token identifier derived from the claimed label. - error NotNameOwner(address caller, uint256 tokenId); + /// @param node The claimed name's node: a token id for a tokenised name, and the + /// stem-under-container subnode for a lite name. + error NotNameOwner(address caller, uint256 node); /// @notice Emitted when a name is associated with an address. /// @param addr The address for which the reverse name is being set. @@ -33,8 +34,9 @@ interface IDotnsReverseResolver { function setReverseName(address addr, string calldata name) external; /// @notice Self-service claim: associates `msg.sender` with `