diff --git a/contracts/pop/IPopRulesOld.sol b/contracts/pop/IPopRulesOld.sol new file mode 100644 index 000000000..be42a98ae --- /dev/null +++ b/contracts/pop/IPopRulesOld.sol @@ -0,0 +1,346 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.34; + +/// @title Proof of Personhood Rules for Dotns +/// @dev PR-scoped pre-upgrade snapshot. Deleted before merge with its paired upgrade script and +/// fork test per the upgrade-PR workflow in CONTRIBUTING.md. +/// @notice Proof of personhood interface defining Dotns price calculation, PoP-tier requirements, +/// and base-name reservation rules. +/// @dev Classifies labels into the PoP tier required for registration and exposes reservation +/// metadata. Length <= 5 is reserved for governance; lengths 6-8 require PopFull unless they +/// carry exactly two trailing digits (PopLite, gateway-issued); lengths >= 9 are open to +/// every caller as NoStatus when they carry zero or exactly two trailing digits. Any one-digit +/// suffix, and any suffix longer than two digits, is invalid; internal digits do not affect +/// classification. Reservations are keyed by the digit-stripped stem so `alice` and `alice42` +/// share a slot. +/// +/// Amounts come from the cost model registered under `DotnsConstants.COST_MODEL`, which owns +/// the curve; only the base length crosses that seam. Every caller pays the same amount for a +/// given length; personhood only unlocks the premium band. +/// @custom:security-contact admin@parity.io +interface IPopRulesOld { + /// @notice Proof-of-Personhood eligibility tier. + /// @dev `NoStatus` is the default for unverified users; `PopLite` and `PopFull` are the two + /// personhood tiers; `Reserved` covers both governance-held names and base stems held by + /// another user through the reservation table. + enum PopStatus { + NoStatus, + PopLite, + PopFull, + Reserved + } + + /// @notice Emitted when a base name receives a reservation. + /// @param baseName The digit-stripped label receiving the reservation. + /// @param owner Address obtaining the reservation right. + /// @param expires UNIX timestamp when the reservation expires. + event BaseNameReserved(string indexed baseName, address indexed owner, uint64 expires); + + /// @notice Emitted when the public market for names shorter than nine characters is opened or + /// closed. + /// @dev Owner-only setter @custom:function setShortNamesEnabled. + /// @param enabled Whether names shorter than nine characters may now be bought. + event ShortNamesEnabledUpdated(bool enabled); + + /// @notice Thrown when a name violates PoP-tier or reservation requirements. + /// @param reason Human-readable explanation of the failure condition. + error PopError(string reason); + + /// @notice Thrown when a caller is not an authorised controller on the registrar. + error NotRegistry(); + + /// @notice Thrown when registering a name whose base stem is held as a live reservation by + /// another user. + /// @param label Caller-supplied label whose stem is reserved. + error NameReserved(string label); + + /// @notice Thrown when registering a label that classifies as governance-reserved at the + /// protocol level. + /// @dev Distinct from @custom:reverts NameReserved so off-chain consumers can tell "wait for + /// the holder to relinquish" apart from "this label is permanently held by governance". + /// @param label Caller-supplied label that classifies as governance-reserved. + error GovernanceReserved(string label); + + /// @notice Thrown on the cross-payer path when the owner's recorded PoP tier does not meet the + /// label's required tier. The direct path's `priceWithCheck` covers this same condition via its + /// own revert. + /// @param label Label whose tier requirement was unmet. + /// @param userStatus Owner's recorded tier. + /// @param required Required tier for the label. + error OwnerStatusInsufficient(string label, PopStatus userStatus, PopStatus required); + + /// @notice Bundle returned from metadata-aware pricing queries. + /// @param price Registration cost from the current cost model for the label's base length. + /// @param status Required PoP tier for this name. + /// @param userStatus Current PoP status recorded for the querying user. + /// @param message Human-readable classification description. + struct PriceWithMeta { + uint256 price; + PopStatus status; + PopStatus userStatus; + string message; + } + + /// @notice Reservation metadata for a base name (digits removed). + /// @param owner Address holding exclusive claim rights during the reservation window. + /// @param expires UNIX timestamp when the reservation expires. + /// @param controller Address that wrote the reservation; the only address permitted to release + /// it before expiry. + struct Reservation { + address owner; + uint64 expires; + address controller; + } + + /// @notice Classifies a name into a required PoP tier per DotNS naming rules. + /// @dev Pure; inputs are the label bytes only. Callers use the returned tier to decide which + /// pricing and verification branch applies. Non-canonical labels (anything other than a + /// single lowercase ASCII DNS label) and labels with exactly one or more than two + /// trailing digits both trigger @custom:reverts PopError. + /// @param name The name label being evaluated. + /// @return requirement Required tier for registration. + /// @return message Explanation of the classification result. + function classifyName(string calldata name) + external + pure + returns (PopStatus requirement, string memory message); + + /// @notice Opens or closes the public market for names shorter than nine characters. + /// @dev Owner-only; unauthorised callers trigger @custom:reverts OwnableUnauthorizedAccount. + /// While closed, which is the deploy default, @custom:function priceWithCheck and + /// @custom:function priceWithoutCheck trigger @custom:reverts PopError for a base length + /// below nine, so no public caller buys a short name. The gateway free grant and the + /// registrar's registerReserved path do not read this flag. Emits @custom:emits + /// ShortNamesEnabledUpdated. + /// @param enabled Whether names shorter than nine characters may be bought. + function setShortNamesEnabled(bool enabled) external; + + /// @notice Returns the personhood tier recorded for an account. + /// @dev Reads the account's dotns-scoped tier from the personhood precompile and maps it to a + /// `PopStatus`. This is the direct account-tier read; the same tier otherwise surfaces + /// only as the `userStatus` field of a pricing query. Never returns `Reserved`, so the + /// result is one of `NoStatus`, `PopLite`, or `PopFull`. + /// @param account Address whose tier is read. + /// @return tier The account's personhood tier. + function personhoodOf(address account) external view returns (PopStatus tier); + + /// @notice Creates or refreshes a reservation entry for a PopLite-eligible stem. + /// @dev Commit-reveal reservation path. Only an authorised controller on the registrar may + /// call this, otherwise @custom:reverts NotRegistry. The caller passes the + /// already-stripped stem; the contract enforces stem shape (no trailing digits) and + /// PopLite-eligibility + /// (length in `[6, 8]`), and a non-canonical label or a label outside that shape triggers + /// @custom:reverts PopError. Cross-user collision on a live slot triggers @custom:reverts + /// PopError so the caller cannot silently overwrite another user's reservation; same-user + /// refresh and writes into an empty or expired slot emit @custom:emits BaseNameReserved. + /// @param stem The base label with no trailing digits. + /// @param user The address receiving reservation rights. + function reserveBaseName(string calldata stem, address user) external; + + /// @notice Emitted when a base-name reservation is cleared. + /// @param baseName The base label whose reservation was released. + event BaseNameReleased(string indexed baseName); + + /// @notice Writes or refreshes a reservation for a bare base-name stem. + /// @dev Gateway-driven reservation path used by the PoP controller. Only a controller in the + /// registrar's `controllers` set may call this, otherwise @custom:reverts NotRegistry. + /// Does not apply the lite-format length window that @custom:function reserveBaseName + /// enforces, but does require the input to be canonical and stem-shaped (no trailing + /// digits); a non-canonical or non-stem label triggers @custom:reverts PopError. If the + /// slot is already live and held by a different user, @custom:reverts PopError so the + /// caller's local bookkeeping and PopRules state stay in lockstep; if it is live for the + /// same user, expiry is refreshed to `block.timestamp + MAX_RESERVATION_TIME`. Emits + /// @custom:emits BaseNameReserved on every successful write. + /// @param stem The base label with no trailing digits. + /// @param user The address receiving reservation rights. + function reserveBaseNameForPop(string calldata stem, address user) external; + + /// @notice Clears a reservation for a base-name stem. + /// @dev Only a controller in the registrar's `controllers` set may call this, otherwise + /// @custom:reverts NotRegistry. Non-canonical or non-stem labels trigger + /// @custom:reverts PopError. Live reservations may only be cleared by the same controller + /// that wrote them; another authorised controller attempting to clear a live slot triggers + /// @custom:reverts PopError. Expired reservations may be cleared by any authorised + /// controller as garbage collection. Used by the PoP controller when a reservation is + /// claimed, relinquished, or a queue head promotion leaves the slot empty. Emits + /// @custom:emits BaseNameReleased once the slot is cleared. + /// @param stem The base label whose reservation should be cleared (no trailing digits). + function releaseBaseName(string calldata stem) external; + + /// @notice Clears a reservation when the slot owner matches `expectedOwner`, allowing any + /// registrar-authorised controller (not only the stamping one) to release the slot. + /// @dev Narrower than @custom:function releaseBaseName: callers must prove they know the + /// slot owner, so cross-controller release is gated on a positive match rather than on + /// caller identity. Intended for the public registrar controller's reclaim path, where + /// a prior occupant has handed the name back to escrow and the new registrant needs + /// the cross-flow guard cleared regardless of which controller originally stamped it. + /// Only a registrar-authorised controller may call this (@custom:reverts NotRegistry). + /// Non-canonical or non-stem labels trigger @custom:reverts PopError. A live reservation + /// whose owner does not match `expectedOwner` triggers @custom:reverts PopError; expired + /// reservations are cleared regardless. Emits @custom:emits BaseNameReleased. + /// @param stem The base label whose reservation should be cleared (no trailing digits). + /// @param expectedOwner The address the caller expects to be the current reservation owner. + function releaseReservationForReclaim(string calldata stem, address expectedOwner) external; + + /// @notice Retrieves reservation information for a base name. + /// @dev Raw accessor: returns the stored slot regardless of expiry. Use + /// @custom:function isBaseNameReserved + /// when live-window semantics are needed. Non-canonical labels trigger + /// @custom:reverts PopError. + /// @param baseName The base label without trailing digits. + /// @return owner The address assigned to the reservation. + /// @return expires UNIX timestamp when the reservation expires. + function getBaseNameReservation(string calldata baseName) + external + view + returns (address owner, uint64 expires); + + /// @notice Returns the bare stem of a label, i.e. the label with any trailing ASCII digits + /// removed. + /// @dev Mirrors the normalisation that @custom:function reserveBaseName applies before writing + /// a reservation, so callers can look up or release a reservation by passing the full + /// label without re-implementing the digit-stripping rule. Non-canonical labels + /// trigger @custom:reverts PopError. + /// @param name Full label (with or without trailing digits). + /// @return stem The label with trailing digits removed. + function stripDigits(string calldata name) external pure returns (string memory stem); + + /// @notice Indicates whether a base name is currently reserved. + /// @dev Applies the live-window predicate to the stored slot so an expired reservation reads + /// as free. Non-canonical labels trigger @custom:reverts PopError. + /// @param baseName The base label without trailing digits. + /// @return reservedStatus True if a live reservation is active. + /// @return owner The reservation holder (zero when not reserved). + /// @return expires UNIX timestamp when the reservation expires. + function isBaseNameReserved(string calldata baseName) + external + view + returns (bool reservedStatus, address owner, uint64 expires); + + /// @notice Calculates price with PoP classification and reservation enforcement. + /// @dev Reverting pricing path used by the commit-reveal controller. Price is the scarcity + /// curve for the label's base length and is charged to every caller, verified or not; + /// personhood only unlocks the premium band. Non-canonical + /// labels, a base stem held live by another user, a governance-reserved label, or a + /// `userAddress` whose personhood tier does not meet the label's required tier each + /// trigger @custom:reverts PopError. + /// @param name Domain label. + /// @param userAddress Registering user for the given label. + /// @return metadata Price with PoP requirements and classification. + function priceWithCheck( + string calldata name, + address userAddress + ) + external + view + returns (PriceWithMeta memory metadata); + + /// @notice Calculates price at a specific cost-model version with PoP classification and + /// reservation enforcement. + /// @dev The versioned counterpart of @custom:function priceWithCheck: identical classification, + /// tier gating, and reservation rules, but the amount comes from the model registered for + /// `pricingVersionValue` rather than the current one. The commit-reveal controller prices + /// a reveal at the version bound into its commitment, so a model change between commit and + /// reveal does not move the amount. @custom:reverts UnknownVersion when the version was + /// never registered. + /// @param name Domain label. + /// @param userAddress Registering user for the given label. + /// @param pricingVersionValue Cost-model version to price against. + /// @return metadata Price with PoP requirements and classification. + function priceWithCheckAtVersion( + string calldata name, + address userAddress, + uint256 pricingVersionValue + ) + external + view + returns (PriceWithMeta memory metadata); + + /// @notice Calculates price with PoP classification and reservation metadata, without + /// reverting on conflicts. + /// @dev Non-reverting counterpart to `priceWithCheck`: surfaces the same fields, but reports + /// a `Reserved` status through `metadata` instead of reverting when the base stem is + /// held by another user. Used by front-ends that need to present a price and eligibility + /// preview without forcing a transaction attempt. Governance-reserved names are not + /// rejected here either; the caller decides what to do. Non-canonical labels still + /// trigger @custom:reverts PopError because the input is malformed rather than just + /// contested. + /// @param name Domain label. + /// @param userAddress Registering user for the given label. + /// @return metadata Price with PoP requirements and classification. + function priceWithoutCheck( + string calldata name, + address userAddress + ) + external + view + returns (PriceWithMeta memory metadata); + + /// @notice Calculates price at a specific cost-model version with PoP classification and + /// reservation metadata, without reverting on conflicts. + /// @dev The versioned counterpart of @custom:function priceWithoutCheck: same non-reverting + /// preview behaviour, but the amount comes from the model registered for + /// `pricingVersionValue`. @custom:reverts UnknownVersion when the version was never + /// registered. + /// @param name Domain label. + /// @param userAddress Registering user for the given label. + /// @param pricingVersionValue Cost-model version to price against. + /// @return metadata Price with PoP requirements and classification. + function priceWithoutCheckAtVersion( + string calldata name, + address userAddress, + uint256 pricingVersionValue + ) + external + view + returns (PriceWithMeta memory metadata); + + /// @notice Transfer-time floor: the greater of the recipient-reach component and the + /// sender-tier-downgrade component, each priced at the name's own length. + /// @dev Re-prices the name at its own length on every move: returns the name's curve price when + /// either (i) the recipient does not meet the label's required tier, or (ii) the + /// recipient's personhood tier is strictly below the sender's, and zero when neither + /// holds. Passing a name to a wallet that could never have registered it therefore costs + /// the name's own curve price. The two components overlap on pure + /// tier mismatches, so the function takes their maximum rather than their sum to avoid + /// double-charging. Consumed by @custom:function DotnsRegistrar.quoteTransferFee. + /// Non-canonical labels and labels with exactly one or more than two trailing digits + /// trigger @custom:reverts PopError. + /// @param name Domain label being transferred. + /// @param from Current holder of the name. + /// @param to Incoming holder of the name. + /// @return floor Transfer-time floor in wei: the name's own curve price, or zero. + function transferFloor( + string calldata name, + address from, + address to + ) + external + view + returns (uint256 floor); + + /// @notice Returns whether `name` is a base name under PoP rules. + /// @dev A base name has no trailing digits; lite-person labels always have exactly two + /// trailing digits, so the two spaces are disjoint. Non-canonical labels trigger + /// @custom:reverts PopError. + /// @param name The label to check. + /// @return isBase True when the label has no trailing digits. + function isBaseName(string calldata name) external pure returns (bool isBase); + + /// @notice Calculates registration cost for a label. + /// @dev Prices the label by its base length through the cost model registered under + /// `DotnsConstants.COST_MODEL`. Ignores the caller's personhood status and reservation + /// state. A label whose trailing-digit suffix is neither zero nor exactly two, and any + /// non-canonical label, trigger @custom:reverts PopError. + /// @param name Domain label to price. + /// @return cost Registration cost in wei. + function price(string calldata name) external view returns (uint256 cost); + + /// @notice Returns the current cost-model version. + /// @dev The current version held by the registry under `DotnsConstants.COST_MODEL`. The + /// commit-reveal controller binds it into a commitment and prices the reveal at that + /// version, so a model change between commit and reveal leaves the committed amount + /// unchanged. @custom:reverts PopError when no registry is configured. + /// @return modelVersion Identifier of the current cost model and its parameters. + function pricingVersion() external view returns (uint256 modelVersion); +} diff --git a/contracts/pop/PopRulesOld.sol b/contracts/pop/PopRulesOld.sol new file mode 100644 index 000000000..1f3370ee2 --- /dev/null +++ b/contracts/pop/PopRulesOld.sol @@ -0,0 +1,608 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.34; + +import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; +import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; +import { + OwnableUpgradeable +} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; +import { + ERC165Upgradeable +} from "@openzeppelin/contracts-upgradeable/utils/introspection/ERC165Upgradeable.sol"; +import {StringUtils} from "../utils/StringUtils.sol"; +import {IPopRulesOld} from "./IPopRulesOld.sol"; +import {IDotnsCostModelRegistry} from "./IDotnsCostModelRegistry.sol"; +import {IDotnsProtocolRegistry} from "../registry/IDotnsProtocolRegistry.sol"; +import {IDotnsController} from "../registrars/IDotnsController.sol"; +import {DotnsRegistrar} from "../registrars/DotnsRegistrar.sol"; +import {DotnsConstants} from "../utils/DotnsConstants.sol"; +import {IPersonhood} from "../external/personhood/IPersonhood.sol"; + +/// @title PopRulesOld +/// @dev PR-scoped pre-upgrade snapshot. Deleted before merge with its paired upgrade script and +/// fork test per the upgrade-PR workflow in CONTRIBUTING.md. +/// @notice Implements DotNS classification, cost-model-driven pricing, and base-name reservations. +/// @dev Tiers: base lengths <= 5 are governance-reserved, base lengths 6-8 require PopFull +/// (or PopLite when carrying exactly two trailing digits, for gateway-issued lite names), +/// base lengths >= 9 are open to any caller as NoStatus when they carry zero or exactly two +/// trailing digits. A one-digit suffix and more than two trailing digits are invalid. +/// Every caller pays the same amount for a given base length. The amount comes from the cost +/// model registered under `DotnsConstants.COST_MODEL`, which owns the curve; this contract +/// passes it only the base length and keeps the classification, reservation, and tier rules. +/// Personhood only unlocks the premium band. Base lengths below nine are closed to the public +/// paid path until governance sets `shortNamesEnabled`; the gateway and registerReserved do +/// not consult it. +/// @custom:security-contact admin@parity.io +contract PopRulesOld is + Initializable, + UUPSUpgradeable, + OwnableUpgradeable, + ERC165Upgradeable, + IPopRulesOld +{ + using StringUtils for *; + + /// @notice Active reservations keyed by digit-stripped base name. + mapping(string baseName => Reservation reservation) public reservations; + + /// @notice Maximum time a base name can be reserved. + uint256 public constant MAX_RESERVATION_TIME = 12 weeks; + + /// @notice Protocol-level address registry for all DotNS contracts. + IDotnsProtocolRegistry public protocolRegistry; + + /// @notice Whether the public paid path may register names shorter than nine characters. + /// Closed by default; only governance opens it. + bool public shortNamesEnabled; + + // forge-lint: disable-next-line(mixed-case-variable) + uint256[50] private __gap; + + /// @notice Restricts function to any registry-authorised controller. + modifier onlyRegistry() { + _onlyRegistry(); + _; + } + + /// @custom:oz-upgrades-unsafe-allow constructor + constructor() { + _disableInitializers(); + } + + /// @notice Initialises the oracle (public entry point). + /// @dev Runs once behind the proxy; subsequent calls trigger @custom:reverts + /// InvalidInitialization via the `initializer` modifier. Amounts come from the cost model + /// registered under `DotnsConstants.COST_MODEL`, so no price is seeded here. + /// @param registry Protocol-level address registry used to resolve sibling contracts. + function initialize(IDotnsProtocolRegistry registry) public initializer { + __Ownable_init(msg.sender); + __ERC165_init(); + protocolRegistry = registry; + } + + /// @inheritdoc IPopRulesOld + function setShortNamesEnabled(bool enabled) external override onlyOwner { + shortNamesEnabled = enabled; + emit ShortNamesEnabledUpdated(enabled); + } + + /// @inheritdoc IPopRulesOld + function classifyName(string calldata name) + external + pure + override + returns (PopStatus requirement, string memory message) + { + _requireCanonicalLabel(name); + (requirement, message,) = _classifyValidatedName(name); + } + + /// @inheritdoc IPopRulesOld + function reserveBaseName( + string calldata stem, + address userAddress + ) + external + override + onlyRegistry + { + _requireCanonicalLabel(stem); + uint256 stemLength = bytes(stem).length; + require( + stemLength >= 6 && stemLength <= 8 && _countTrailingDigits(stem) == 0, + PopError("Reservation stem must be 6-8 chars with no trailing digits") + ); + _writeReservation(stem, userAddress); + } + + /// @inheritdoc IPopRulesOld + function isBaseName(string calldata baseName) external pure override returns (bool isBase) { + _requireCanonicalLabel(baseName); + uint256 digits = _countTrailingDigits(baseName); + return digits == 0; + } + + /// @inheritdoc IPopRulesOld + function getBaseNameReservation(string calldata baseName) + external + view + override + returns (address reservationOwner, uint64 expiryTimestamp) + { + _requireCanonicalLabel(baseName); + Reservation memory reserved = reservations[baseName]; + return (reserved.owner, reserved.expires); + } + + /// @inheritdoc IPopRulesOld + function isBaseNameReserved(string calldata baseName) + external + view + override + returns (bool isReserved, address reservationOwner, uint64 expiryTimestamp) + { + _requireCanonicalLabel(baseName); + Reservation memory reservation = reservations[baseName]; + return (_isLive(reservation), reservation.owner, reservation.expires); + } + + /// @inheritdoc IPopRulesOld + function priceWithCheck( + string calldata name, + address userAddress + ) + external + view + override + returns (PriceWithMeta memory metadata) + { + return _priceWithCheck(name, userAddress, false, 0); + } + + /// @inheritdoc IPopRulesOld + function priceWithCheckAtVersion( + string calldata name, + address userAddress, + uint256 pricingVersionValue + ) + external + view + override + returns (PriceWithMeta memory metadata) + { + return _priceWithCheck(name, userAddress, true, pricingVersionValue); + } + + /// @inheritdoc IPopRulesOld + function priceWithoutCheck( + string calldata name, + address userAddress + ) + external + view + override + returns (PriceWithMeta memory metadata) + { + return _priceWithoutCheck(name, userAddress, false, 0); + } + + /// @inheritdoc IPopRulesOld + function priceWithoutCheckAtVersion( + string calldata name, + address userAddress, + uint256 pricingVersionValue + ) + external + view + override + returns (PriceWithMeta memory metadata) + { + return _priceWithoutCheck(name, userAddress, true, pricingVersionValue); + } + + /// @notice Shared body for the reservation-enforcing pricing reads. + /// @dev `atVersion` selects the amount source: the current model when false, the model for + /// `pricingVersionValue` when true. Classification, tier gating, and reservation rules are + /// the same on both paths, so they live here once. + function _priceWithCheck( + string calldata name, + address userAddress, + bool atVersion, + uint256 pricingVersionValue + ) + internal + view + returns (PriceWithMeta memory metadata) + { + _requireCanonicalLabel(name); + _enforceReservationRules(name, userAddress); + + (PopStatus requiredStatus, string memory classification, uint256 baseLength) = + _classifyValidatedName(name); + _requireShortNamesOpen(baseLength); + PopStatus userStatus = _personhoodTier(userAddress); + + metadata.price = atVersion + ? _priceValidatedNameAtVersion(pricingVersionValue, baseLength) + : _priceValidatedName(baseLength); + metadata.status = requiredStatus; + metadata.userStatus = userStatus; + metadata.message = classification; + + require(requiredStatus != PopStatus.Reserved, PopError(classification)); + require(_meetsReach(requiredStatus, userStatus), PopError(classification)); + + return metadata; + } + + /// @notice Shared body for the non-reverting pricing reads. + /// @dev Mirror of @custom:function _priceWithCheck for the front-end preview path: reports a + /// contested reservation through `metadata` rather than reverting. `atVersion` selects the + /// amount source in the same way. + function _priceWithoutCheck( + string calldata name, + address userAddress, + bool atVersion, + uint256 pricingVersionValue + ) + internal + view + returns (PriceWithMeta memory metadata) + { + _requireCanonicalLabel(name); + + (PopStatus requiredStatus, string memory classification, uint256 baseLength) = + _classifyValidatedName(name); + _requireShortNamesOpen(baseLength); + PopStatus userStatus = _personhoodTier(userAddress); + + metadata.price = atVersion + ? _priceValidatedNameAtVersion(pricingVersionValue, baseLength) + : _priceValidatedName(baseLength); + metadata.status = requiredStatus; + metadata.userStatus = userStatus; + metadata.message = classification; + + string memory baseName = _stripDigits(name); + Reservation memory reservation = reservations[baseName]; + + if (_isLive(reservation) && reservation.owner != userAddress) { + metadata.message = "Base name reserved for original Lite registrant"; + metadata.status = IPopRulesOld.PopStatus.Reserved; + } + + return metadata; + } + + /// @inheritdoc IPopRulesOld + function price(string calldata name) external view override returns (uint256) { + _requireCanonicalLabel(name); + return _priceValidatedName(_validatedBaseLength(name)); + } + + /// @inheritdoc IPopRulesOld + function pricingVersion() external view override returns (uint256 modelVersion) { + return _costModelRegistry().currentVersion(); + } + + /// @inheritdoc IPopRulesOld + function transferFloor( + string calldata name, + address from, + address to + ) + external + view + override + returns (uint256 floor) + { + _requireCanonicalLabel(name); + if (from == to) return 0; + (PopStatus required,, uint256 baseLength) = _classifyValidatedName(name); + uint256 ownPrice = _priceValidatedName(baseLength); + + PopStatus toTier = _personhoodTier(to); + uint256 reachComponent = _meetsReach(required, toTier) ? 0 : ownPrice; + + PopStatus fromTier = _personhoodTier(from); + // `_personhoodTier` never returns Reserved, so users are in {NoStatus, PopLite, PopFull} + // and enum comparison reflects tier ordering directly. + uint256 downgradeComponent = toTier < fromTier ? ownPrice : 0; + + return reachComponent > downgradeComponent ? reachComponent : downgradeComponent; + } + + /// @inheritdoc IPopRulesOld + function personhoodOf(address account) external view override returns (PopStatus tier) { + return _personhoodTier(account); + } + + /// @notice Reads `account`'s dotns-scoped personhood tier from the alias-accounts + /// precompile and translates it into a `PopStatus`. + /// @dev Single source of truth so callers cannot read the precompile directly and + /// drift on the status mapping. Tiers are defined incrementally on the + /// precompile side: 0=None, 1=Lite, 2=Full. Anything outside that range + /// collapses to `NoStatus` so a future tier addition fails closed instead of + /// silently being treated as a higher level than it actually is. + function _personhoodTier(address account) private view returns (PopStatus) { + IPersonhood.PersonhoodInfo memory info = IPersonhood(DotnsConstants.PERSONHOOD) + .personhoodStatus(account, DotnsConstants.PERSONHOOD_CONTEXT); + if (info.status == 2) return PopStatus.PopFull; + if (info.status == 1) return PopStatus.PopLite; + return PopStatus.NoStatus; + } + + /// @notice Single canonical "is `userStatus` at reach for `required`?" predicate. + /// @dev Both `priceWithCheck` and `transferFloor` build on this so the tier-eligibility rule + /// lives in exactly one place and the callers cannot disagree about who clears a given label. + /// `_personhoodTier` never returns `Reserved`, so `userStatus` is in `{NoStatus, PopLite, + /// PopFull}` and the enum comparison reflects tier ordering directly. A `Reserved` `required` + /// (governance label) is unreachable by any verified user, so the comparison returns false and + /// the caller charges the friction fee, providing defence-in-depth if a Reserved label ever + /// enters circulation. + function _meetsReach(PopStatus required, PopStatus userStatus) private pure returns (bool) { + return userStatus >= required; + } + + /// @notice Amount for a base length at the current cost-model version. + /// @dev The cost-model registry owns the curve; this contract passes it only the base length. + /// The call is a view because it runs on the ERC721 transfer floor read through + /// @custom:function transferFloor. + function _priceValidatedName(uint256 baseLength) internal view returns (uint256 priceValue) { + return _costModelRegistry().priceForBaseLength(baseLength); + } + + /// @notice Amount for a base length at a specific cost-model version. + /// @dev Prices an in-flight registration at the version it committed to, so a model change + /// between commit and reveal does not move its cost. @custom:reverts UnknownVersion (from + /// the registry) when the version was never registered. + function _priceValidatedNameAtVersion( + uint256 pricingVersionValue, + uint256 baseLength + ) + internal + view + returns (uint256 priceValue) + { + return _costModelRegistry().priceForBaseLengthAtVersion(pricingVersionValue, baseLength); + } + + /// @notice Resolves the cost-model registry registered under `DotnsConstants.COST_MODEL`. + /// @dev @custom:reverts PopError when no registry is configured, so a pricing read fails closed + /// rather than resolving through the zero address. + function _costModelRegistry() private view returns (IDotnsCostModelRegistry registry) { + address configured = protocolRegistry.get(DotnsConstants.COST_MODEL); + require(configured != address(0), PopError("Cost model not configured")); + return IDotnsCostModelRegistry(configured); + } + + /// @notice Reverts a public paid registration of a base length below nine while the short-name + /// market is closed. + /// @dev The one gate both public price reads share. Base lengths of nine and above are always + /// open. @custom:reverts PopError when a base length below nine is priced while + /// `shortNamesEnabled` is false. The gateway and @custom:function registerReserved never + /// reach this, so neither is gated. + function _requireShortNamesOpen(uint256 baseLength) private view { + require(shortNamesEnabled || baseLength >= 9, PopError("Short names are not for sale")); + } + + /// @notice Validates the digit suffix and returns the base length that pricing and + /// classification both use to place a name in its band. + /// @dev A name carries no digit suffix or exactly two digits; any other count triggers + /// @custom:reverts PopError, so a longer suffix cannot slip a name into a shorter band. + function _validatedBaseLength(string calldata name) internal pure returns (uint256 baseLength) { + uint256 trailingDigits = _countTrailingDigits(name); + require( + trailingDigits == 0 || trailingDigits == 2, + PopError("Name must have no digit suffix or exactly 2 digit suffix") + ); + return bytes(name).length - trailingDigits; + } + + /// @notice Enforces base-name reservation rules. + /// @param name Domain label. + /// @param userAddress Registering user. + function _enforceReservationRules(string calldata name, address userAddress) internal view { + string memory baseName = _stripDigits(name); + Reservation memory reservation = reservations[baseName]; + + if (_isLive(reservation)) { + require( + reservation.owner == userAddress, + PopError("Base name reserved for original Lite registrant") + ); + } + } + + /// @notice Returns whether `reservation` is live at `block.timestamp`. + function _isLive(Reservation memory reservation) internal view returns (bool) { + return reservation.owner != address(0) && reservation.expires > block.timestamp; + } + + /// @notice Counts trailing digits in a string. + /// @param label String to analyse. + /// @return digitCount Number of trailing digits. + function _countTrailingDigits(string calldata label) + internal + pure + returns (uint256 digitCount) + { + bytes calldata bytesLabel = bytes(label); + for (uint256 i = bytesLabel.length; i > 0; i--) { + if (bytesLabel[i - 1] >= 0x30 && bytesLabel[i - 1] <= 0x39) { + digitCount++; + } else { + break; + } + } + } + + /// @notice Strips trailing digits from a name. + /// @param name Domain label. + function _stripDigits(string calldata name) internal pure returns (string memory baseName) { + bytes calldata bytesName = bytes(name); + uint256 endPosition = bytesName.length - _countTrailingDigits(name); + + // No trailing digits to strip: return the input verbatim and skip the manual copy. + if (endPosition == bytesName.length) return name; + + bytes memory output = new bytes(endPosition); + for (uint256 i = 0; i < endPosition; i++) { + output[i] = bytesName[i]; + } + + return string(output); + } + + function _classifyValidatedName(string calldata name) + internal + pure + returns (PopStatus requirement, string memory message, uint256 baseLength) + { + baseLength = _validatedBaseLength(name); + uint256 trailingDigits = bytes(name).length - baseLength; + + if (baseLength <= 5) { + return (PopStatus.Reserved, "Reserved for Governance", baseLength); + } + + if (baseLength >= 6 && baseLength <= 8) { + if (trailingDigits == 2) { + return (PopStatus.PopLite, "Requires Lite personhood verification", baseLength); + } + return (PopStatus.PopFull, "Requires Full personhood verification", baseLength); + } + + // Baselength >= 9 is open to any caller with no suffix or the two-digit lite suffix shape. + return (PopStatus.NoStatus, "Available to all", baseLength); + } + + function _requireCanonicalLabel(string calldata name) internal pure { + require(name.isSingleLabel(), PopError("Name must be lowercase ASCII DNS label")); + } + + /// @inheritdoc ERC165Upgradeable + function supportsInterface(bytes4 interfaceId) + public + view + virtual + override + returns (bool supported) + { + return interfaceId == type(IPopRulesOld).interfaceId || super.supportsInterface(interfaceId); + } + + /// @inheritdoc UUPSUpgradeable + function _authorizeUpgrade(address newImplementation) internal override onlyOwner {} + + /// @notice Returns implementation version. + function version() external pure virtual returns (string memory versionString) { + versionString = "1.0.0"; + } + + /// @notice Ensures the caller is any controller authorised on the registrar. + function _onlyRegistry() internal view { + DotnsRegistrar registrar = DotnsRegistrar(protocolRegistry.get(DotnsConstants.REGISTRAR)); + require(registrar.controllers(IDotnsController(msg.sender)), NotRegistry()); + } + + /// @inheritdoc IPopRulesOld + function reserveBaseNameForPop( + string calldata stem, + address userAddress + ) + external + override + onlyRegistry + { + _requireCanonicalLabel(stem); + require( + _countTrailingDigits(stem) == 0, + PopError("Reservation stem must have no trailing digits") + ); + _writeReservation(stem, userAddress); + } + + /// @inheritdoc IPopRulesOld + function stripDigits(string calldata name) external pure override returns (string memory stem) { + _requireCanonicalLabel(name); + return _stripDigits(name); + } + + /// @inheritdoc IPopRulesOld + function releaseBaseName(string calldata stem) external override onlyRegistry { + _requireCanonicalLabel(stem); + require( + _countTrailingDigits(stem) == 0, + PopError("Reservation stem must have no trailing digits") + ); + Reservation memory reservation = reservations[stem]; + // Live reservations can only be cleared by the controller that wrote + // them, so one registrar-authorised controller cannot wipe another's + // active slot. Expired reservations are dead weight and may be cleared + // by any authorised controller as garbage collection. + if (_isLive(reservation)) { + require( + msg.sender == reservation.controller, + PopError("Only reserving controller can release") + ); + } + delete reservations[stem]; + emit BaseNameReleased(stem); + } + + /// @inheritdoc IPopRulesOld + function releaseReservationForReclaim( + string calldata stem, + address expectedOwner + ) + external + override + onlyRegistry + { + _requireCanonicalLabel(stem); + require( + _countTrailingDigits(stem) == 0, + PopError("Reservation stem must have no trailing digits") + ); + Reservation memory reservation = reservations[stem]; + // Cross-controller release is gated on owner match rather than controller match, + // so the public registrar controller can clear a PoP-stamped slot during reclaim + // when the prior occupant is the reservation owner. + if (_isLive(reservation)) { + require(reservation.owner == expectedOwner, PopError("Reservation owner mismatch")); + } + delete reservations[stem]; + emit BaseNameReleased(stem); + } + + /// @notice Internal single-source-of-truth writer for stem reservations. + /// @dev Routes both @custom:function reserveBaseName and @custom:function reserveBaseNameForPop + /// through one path so the cross-user collision semantics stay identical: a live slot held + /// by a different user @custom:reverts PopError, and any other case writes a fresh expiry + /// and emits @custom:emits BaseNameReserved. Same-owner re-reservations refresh the expiry + /// to `block.timestamp + MAX_RESERVATION_TIME`. Callers are responsible for validating + /// `stem` is canonical and stem-shaped (no trailing digits); this helper does no input + /// validation of its own so each public entry can layer additional eligibility checks. + function _writeReservation(string calldata stem, address userAddress) internal { + Reservation memory existing = reservations[stem]; + bool liveSlot = _isLive(existing); + if (liveSlot) { + require(existing.owner == userAddress, PopError("Base name held by another user")); + } + + // `block.timestamp + MAX_RESERVATION_TIME` cannot overflow `uint64`: `MAX_RESERVATION_TIME` + // is bounded (12 weeks, ~7.26e6) and `uint64` saturates at ~5.84e11, a horizon that does + // not arrive until year 2554. + // forge-lint: disable-next-line(unsafe-typecast) + uint64 expiryTime = uint64(block.timestamp + MAX_RESERVATION_TIME); + // Preserve the original stamping `controller` on same-owner refresh so a sibling controller + // tracking the same stem (e.g. the PoP queue head) retains the right to release. Without + // this, a same-user re-reservation through a different controller silently steals the slot + // and bricks the original controller's release/advance/claim paths. + address stampingController = liveSlot ? existing.controller : msg.sender; + reservations[stem] = + Reservation({owner: userAddress, expires: expiryTime, controller: stampingController}); + emit BaseNameReserved(stem, userAddress, expiryTime); + } +} diff --git a/contracts/registrars/DotnsPopController.sol b/contracts/registrars/DotnsPopController.sol index 155aba26a..c23e601d7 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; @@ -146,7 +148,7 @@ contract DotnsPopController is /// `_popIssued` mapping consumes one of the reserved slots, so the gap holds 49. uint256[49] private __gap; - /// @notice Restricts calls to a substrate Root origin. + /// @notice Restricts calls to a Root origin. modifier onlyRoot() { _onlyRoot(); _; @@ -222,9 +224,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` @@ -300,10 +302,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 { @@ -403,7 +405,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); } @@ -564,8 +570,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 @@ -585,15 +591,38 @@ 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(); + // 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, + 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(); @@ -621,7 +650,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 { @@ -630,7 +660,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. @@ -764,7 +794,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 @@ -772,7 +805,18 @@ 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) { + subnode = SubnodeUtils.liteSubnodeOf(protocolRegistry.tldNode(), liteLabel); } /// @notice Validates a base (full-person) label and derives `(labelhash, node)`. @@ -783,7 +827,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. @@ -847,6 +891,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 @@ -870,7 +919,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/DotnsPopControllerOld.sol b/contracts/registrars/DotnsPopControllerOld.sol new file mode 100644 index 000000000..42c4a4b5d --- /dev/null +++ b/contracts/registrars/DotnsPopControllerOld.sol @@ -0,0 +1,858 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.34; + +import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; +import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; +import { + OwnableUpgradeable +} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; +import { + ERC165Upgradeable +} from "@openzeppelin/contracts-upgradeable/utils/introspection/ERC165Upgradeable.sol"; +import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol"; + +import {EnumerableSet} from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; + +import {IDotnsPopControllerOld} from "./IDotnsPopControllerOld.sol"; +import {IDotnsRegistrar} from "./IDotnsRegistrar.sol"; +import {IDotnsProtocolRegistry} from "../registry/IDotnsProtocolRegistry.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 {RegistrationUtils} from "../utils/RegistrationUtils.sol"; +import {StringUtils} from "../utils/StringUtils.sol"; +import {DotnsConstants} from "../utils/DotnsConstants.sol"; +import {SystemUtils} from "../utils/SystemUtils.sol"; + +/// @title DotnsPopControllerOld +/// @dev PR-scoped pre-upgrade snapshot. Deleted before merge with its paired upgrade script and +/// fork test per the upgrade-PR workflow in CONTRIBUTING.md. +/// @notice Dedicated PoP controller orchestrating lite-person and full-person username +/// issuance on behalf of the PoP gateway pallet. +/// @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 +/// 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, +/// @custom:reverts InvalidLiteLabel on the lite path). The lite leg accepts any two-digit lite +/// label whose stem is not governance-reserved, regardless of stem length. Native-token pricing +/// is bypassed entirely; the gateway pays no rent. +/// +/// Decoupling: +/// This contract does not import or call `IDotnsRegistrarController`. The public +/// commit-reveal controller is equally unaware of this one. Cross-flow collision handling +/// relies on two distinct properties, neither of which requires the two controllers to know +/// about each other: +/// (1) Lite-person labels (`NAMEXX`) share the public namespace: they are just DNS labels +/// with exactly two trailing digits. First-to-mint wins at the ERC721 layer, so a lite-user +/// and a public registrant cannot hold the same flat label simultaneously. Keeping one +/// namespace removes the ambiguity downstream tooling (dotli, dweb) would see with a +/// separate separator form. +/// (2) Base-name reservations are synchronised into `IPopRules`. The head of this +/// controller's reservation queue is written through `IPopRules.reserveBaseNameForPop` on +/// every head transition; the slot is cleared through `IPopRules.releaseBaseName` when the +/// queue empties (claim, final relinquish, final expiry). The public commit-reveal +/// controller routes through `IPopRules.priceWithCheck`, which rejects any registration +/// targeting a base-name stem reserved for another user, so the public flow respects +/// gateway reservations without ever importing this contract. PopRules is the single +/// cross-flow authority; the queue here is the intra-PoP ordering layer on top of it. +/// +/// Shared primitives: labelhash / namehash via @custom:contract LabelUtils; the mint + +/// forward-registry + store-write triad via @custom:contract RegistrationUtils; chat-key and +/// lite-to-full link persistence via +/// @custom:contract IDotnsPopResolver. Keeping per-name records on the resolver preserves the +/// "Store = labels only" invariant. +/// @custom:security-contact admin@parity.io +contract DotnsPopControllerOld is + Initializable, + UUPSUpgradeable, + OwnableUpgradeable, + ERC165Upgradeable, + IDotnsPopControllerOld +{ + using StringUtils for *; + using EnumerableSet for EnumerableSet.AddressSet; + + /// @notice Upper bound for the number of simultaneously queued reservations per label. + /// @dev Keeps `expireReservation` gas bounded. + uint16 public constant MAX_RESERVATION_QUEUE = 64; + + /// @notice Minimum value accepted by @custom:function setReservationDuration. + /// @dev Prevents owner misconfiguration from instantly expiring every live queue and + /// pending-claim entry. The actual production duration is governance-tuned higher. + uint64 public constant MIN_RESERVATION_DURATION = 1 hours; + + /// @notice Required byte length for a non-empty chat key. + /// @dev Mirrors @custom:contract IDotnsPopResolver `InvalidChatKeyLength` so the controller + /// can fail closed before the mint instead of bubbling the resolver's revert after partial + /// state has been committed. + uint256 private constant CHAT_KEY_LENGTH = 65; + + /// @notice Protocol-level address registry for all DotNS contracts. + IDotnsProtocolRegistry public protocolRegistry; + + /// @notice Per-label queue metadata (head/tail pointers). + mapping(bytes32 labelhash => ReservationQueueMeta meta) internal _reservationMeta; + + /// @notice Per-label sparse entries keyed by monotonically-increasing index. + mapping(bytes32 labelhash => mapping(uint64 index => ReservationEntry entry)) internal + _reservationEntries; + + /// @notice Single per-user pointer into the reservation queues. + /// @dev Keeps per-user reservation data behind one key and one struct value so callers + /// read both fields in one call instead of two. + mapping(address user => UserReservation reservation) internal _userReservations; + + /// @notice Remembers the base-label string for each reserved labelhash so the PopRules + /// sync path can address the reservation by its original string form (PopRules keys its + /// `reservations` mapping by string). + /// @dev Populated on first enqueue for a label, cleared when the queue empties. Exists + /// only to bridge the queue's `bytes32` key space to PopRules' `string` key space; + /// nothing else reads it. + 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 + /// governance via `setReservationDuration`. + uint64 public override reservationDuration; + + /// @notice Enumeration set of users holding at least one pending claim. + /// @dev Membership equals the set of users with a non-empty queue. Used by + /// `pendingClaimUserCount` and `pendingClaimUsers` for paginated enumeration. + 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 + /// @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; + + /// @dev Reserved storage space to allow for layout changes in future upgrades. + uint256[50] private __gap; + + /// @notice Restricts calls to a substrate Root origin. + modifier onlyRoot() { + _onlyRoot(); + _; + } + + /// @custom:oz-upgrades-unsafe-allow constructor + constructor() { + _disableInitializers(); + } + + /// @notice Initialises the PoP controller. + /// @dev Called once through the UUPS proxy; `_disableInitializers` on the implementation + /// makes direct calls revert with @custom:reverts InvalidInitialization, and any nested + /// call outside an active initialiser scope reverts with @custom:reverts NotInitializing. + /// Emits @custom:emits ReservationDurationSet so indexers observe the initial value + /// through the same event the setter uses later. + function initialize( + IDotnsProtocolRegistry registry, + uint64 reservationDuration_ + ) + external + initializer + { + require( + reservationDuration_ >= MIN_RESERVATION_DURATION, + ReservationDurationTooLow(reservationDuration_) + ); + __Ownable_init(msg.sender); + __ERC165_init(); + protocolRegistry = registry; + reservationDuration = reservationDuration_; + emit ReservationDurationSet(reservationDuration_); + } + + /// @inheritdoc IDotnsPopControllerOld + function reserveLiteName(LiteRegistration calldata params) external override onlyRoot { + _reserveLite(_popRules(), params); + } + + /// @inheritdoc IDotnsPopControllerOld + function reserveBaseName(BaseReservation calldata params) external override onlyRoot { + IPopRules rules = _popRules(); + bytes32 reservedHash; + bool hasReservation = bytes(params.reservedBaseLabel).length != 0; + if (hasReservation) { + (reservedHash,) = _validateReservableBaseLabel(rules, params.reservedBaseLabel); + } + + _reserveLite(rules, params.lite); + + if (hasReservation) { + _advanceExpiredHead(reservedHash); + _removeUserFromQueue(params.lite.user); + _enqueueReservation(rules, reservedHash, params.reservedBaseLabel, params.lite.user); + } + } + + /// @inheritdoc IDotnsPopControllerOld + function reserveBaseNameOnly(BaseNameReservation calldata params) external override onlyRoot { + IPopRules rules = _popRules(); + (bytes32 reservedHash,) = _validateReservableBaseLabel(rules, params.reservedBaseLabel); + _advanceExpiredHead(reservedHash); + _removeUserFromQueue(params.user); + _enqueueReservation(rules, reservedHash, params.reservedBaseLabel, params.user); + } + + /// @notice Lite-only mint shared by @custom:function reserveLiteName and the lite leg + /// of @custom:function reserveBaseName. + /// @dev Gateway attestation is the authority for personhood on this path; the on-chain + /// precompile is not consulted. The dotted-format check accepts only `stem.NN`, then + /// PopRules classification must place the flattened label outside the governance-reserved + /// tier before minting; any non-reserved two-digit lite label is accepted regardless of stem + /// length. 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` entrypoint forwards `params.lite`. + function _reserveLite(IPopRules rules, LiteRegistration calldata params) internal { + require(params.liteLabel.isLitePersonLabel(), InvalidLiteLabel()); + _requireValidChatKey(params.chatKey); + + string memory liteLabel = params.liteLabel; + (IPopRules.PopStatus required,) = rules.classifyName(liteLabel); + // The shape check fixes the suffix, so classification lands on PopLite (stem 6-8), + // NoStatus (stem 9 or more), or Reserved (stem 5 or fewer). Accept the first two; a + // stem short enough to be governance-reserved is not issued from this path. + require(required != IPopRules.PopStatus.Reserved, InvalidLiteLabel()); + (bytes32 labelhash, bytes32 node) = _validateLiteLabel(liteLabel); + + _completeGatewayRegistration( + params.user, liteLabel, labelhash, node, params.chatKey, bytes32(0) + ); + + emit LiteNameReserved(labelhash, params.user, liteLabel); + } + + /// @inheritdoc IDotnsPopControllerOld + function registerBaseName(FullRegistration calldata params) external override onlyRoot { + Link calldata link = params.link; + address user = params.user; + string calldata label = params.label; + + IPopRules rules = _popRules(); + (IPopRules.PopStatus required,) = rules.classifyName(label); + require( + required != IPopRules.PopStatus.Reserved && required != IPopRules.PopStatus.PopLite, + InvalidBaseLabel() + ); + + (bytes32 labelhash, bytes32 node) = _validateBaseLabel(label); + + _advanceExpiredHead(labelhash); + + // Cross-flow guard: after the local queue has had a chance to release its own + // PopRules slot via head-advance, any remaining live slot belongs to a sibling + // controller (the public commit-reveal flow's PopLite-to-PopLite path). Reject + // when held by another user so PopRules is the single cross-flow authority in + // both directions; the public flow already gates on this slot through + // `priceWithCheck`. + (bool slotLive, address slotOwner,) = rules.isBaseNameReserved(label); + require(!slotLive || slotOwner == user, NotHolder(user, labelhash)); + + ReservationQueueMeta memory meta = _reservationMeta[labelhash]; + ReservationEntry memory headEntry = meta.head < meta.tail + ? _reservationEntries[labelhash][meta.head] + : ReservationEntry({owner: address(0), joinedAt: 0}); + bool isClaim = _userReservations[user].labelhash == labelhash && meta.head < meta.tail + && headEntry.owner == user; + + if (!isClaim && meta.head < meta.tail) { + if ( + headEntry.owner != address(0) && headEntry.owner != user + && !_isExpired(headEntry.joinedAt) + ) { + revert NotHolder(user, labelhash); + } + } + + if (isClaim) { + _clearQueue(labelhash); + } else { + _removeUserFromQueue(user); + } + + bytes32 liteLabelhash; + bytes32 liteNode; + bytes memory chatKeyToPersist; + if (link.kind == LinkKind.LiteUsername) { + require(link.liteLabel.isLitePersonLabel(), InvalidLiteLabel()); + string memory liteLabel = link.liteLabel; + (liteLabelhash, liteNode) = _validateLiteLabel(liteLabel); + IDotnsRegistrar registrar = _registrar(); + require( + registrar.exists(uint256(liteNode)) && registrar.ownerOf(uint256(liteNode)) == user, + LiteLabelNotOwnedByUser(user, liteLabelhash) + ); + chatKeyToPersist = _popResolver().chatKey(liteNode); + } else { + _requireValidChatKey(link.chatKey); + chatKeyToPersist = link.chatKey; + } + + _completeGatewayRegistration(user, label, labelhash, node, chatKeyToPersist, liteLabelhash); + + if (isClaim) { + emit BaseNameClaimed(labelhash, user, label); + } else { + emit StandaloneNameRegistered(labelhash, user, label); + } + if (link.kind == LinkKind.LiteUsername) { + emit LiteToFullLinked(labelhash, liteLabelhash); + } + } + + /// @inheritdoc IDotnsPopControllerOld + function expireReservation(string calldata reservedBaseLabel) external override { + (bytes32 labelhash,) = _validateBaseLabel(reservedBaseLabel); + _advanceExpiredHead(labelhash); + } + + /// @inheritdoc IDotnsPopControllerOld + function relinquishReservation() external override { + UserReservation memory userRes = _userReservations[msg.sender]; + require(userRes.labelhash != bytes32(0), NoActiveReservation(msg.sender)); + _removeUserFromQueue(msg.sender); + emit ReservationRelinquished(userRes.labelhash, msg.sender); + } + + /// @inheritdoc IDotnsPopControllerOld + function claimLabelStore() external override returns (bool moreRemaining) { + (, moreRemaining) = _settlePending(msg.sender, DotnsConstants.MAX_PAGE_SIZE); + } + + /// @inheritdoc IDotnsPopControllerOld + function settlePendingClaims( + address user, + uint256 limit + ) + external + override + returns (uint256 settledCount, bool moreRemaining) + { + return _settlePending(user, limit); + } + + /// @notice Shared settlement loop behind @custom:function claimLabelStore and + /// @custom:function settlePendingClaims. + /// @dev Settles up to `limit` of the user's pending claims, deploying the store on the first + /// write, and removes the user from the enumeration set once their queue empties. + function _settlePending( + address user, + uint256 limit + ) + internal + returns (uint256 settledCount, bool moreRemaining) + { + IStoreFactory factory = _storeFactory(); + address store = factory.getLabelStore(user); + + PendingClaim[] storage queue = _pendingClaimQueue[user]; + uint256 remaining = queue.length; + settledCount = limit < remaining ? limit : remaining; + + // Settle from the tail: read the last entry, pop it, then write. Popping the tail removes + // an entry with no storage copy, unlike a swap-from-front. Settlement order does not + // matter to the reads. The pop runs before the external write (deploy + store label), so a + // store or factory that ever gained a callback could not re-enter onto an un-popped queue. + for (uint256 i; i < settledCount; ++i) { + --remaining; + string memory label = queue[remaining].label; + queue.pop(); + store = _settlePendingLabel(factory, store, user, label); + } + + moreRemaining = remaining != 0; + if (!moreRemaining) { + _pendingClaimUsers.remove(user); + } + } + + /// @notice Writes a single pending label into the user's store, deploying the store lazily. + /// @dev The store is created only when there is a label to write, so a caller who settles an + /// empty queue never leaves a fresh store behind with nothing in it. Returns the (possibly + /// newly deployed) store so the caller threads it through the remaining entries. + function _settlePendingLabel( + IStoreFactory factory, + address store, + address user, + string memory label + ) + internal + returns (address) + { + bytes32 labelhash = LabelUtils.labelhashMemory(label); + bytes32 node = LabelUtils.namehashUnder(protocolRegistry.tldNode(), labelhash); + if (store == address(0)) { + store = factory.deployLabelStoreFor(user); + } + _writeRecord(store, node, label); + emit PendingClaimSettled(user, labelhash, store, msg.sender); + emit NameRegistered(label, labelhash, user, store); + return store; + } + + /// @inheritdoc IDotnsPopControllerOld + function isReservedForClaim(string calldata reservedBaseLabel) + external + view + override + returns (bool reserved, address holder) + { + (bytes32 labelhash,) = _validateBaseLabel(reservedBaseLabel); + ReservationQueueMeta memory meta = _reservationMeta[labelhash]; + if (meta.head >= meta.tail) return (false, address(0)); + + ReservationEntry memory head = _reservationEntries[labelhash][meta.head]; + if (head.owner == address(0)) return (false, address(0)); + if (_isExpired(head.joinedAt)) return (false, address(0)); + + return (true, head.owner); + } + + /// @inheritdoc IDotnsPopControllerOld + function setReservationDuration(uint64 duration) external override onlyOwner { + require(duration >= MIN_RESERVATION_DURATION, ReservationDurationTooLow(duration)); + reservationDuration = duration; + emit ReservationDurationSet(duration); + } + + /// @inheritdoc IDotnsPopControllerOld + function reservationMeta(bytes32 labelhash) + external + view + override + returns (uint64 head, uint64 tail) + { + ReservationQueueMeta memory meta = _reservationMeta[labelhash]; + return (meta.head, meta.tail); + } + + /// @inheritdoc IDotnsPopControllerOld + function reservationEntry( + bytes32 labelhash, + uint64 index + ) + external + view + override + returns (address entryOwner, uint64 joinedAt) + { + ReservationEntry memory entry = _reservationEntries[labelhash][index]; + return (entry.owner, entry.joinedAt); + } + + /// @inheritdoc IDotnsPopControllerOld + function userReservation(address user) + external + view + override + returns (UserReservation memory reservation) + { + return _userReservations[user]; + } + + /// @inheritdoc IDotnsPopControllerOld + function pendingClaims( + address user, + uint256 offset, + uint256 limit + ) + external + view + override + returns (PendingClaim[] memory claims) + { + PendingClaim[] storage queue = _pendingClaimQueue[user]; + uint256 total = queue.length; + if (offset >= total) return new PendingClaim[](0); + + uint256 available = total - offset; + uint256 count = limit < available ? limit : available; + if (count > DotnsConstants.MAX_PAGE_SIZE) count = DotnsConstants.MAX_PAGE_SIZE; + + claims = new PendingClaim[](count); + for (uint256 i; i < count; ++i) { + claims[i] = queue[offset + i]; + } + } + + /// @inheritdoc IDotnsPopControllerOld + function pendingClaimCountOf(address user) external view override returns (uint256 count) { + return _pendingClaimQueue[user].length; + } + + /// @inheritdoc IDotnsPopControllerOld + function pendingClaimUserCount() external view override returns (uint256 count) { + return _pendingClaimUsers.length(); + } + + /// @inheritdoc IDotnsPopControllerOld + function pendingClaimUsers( + uint256 offset, + uint256 limit + ) + external + view + override + returns (address[] memory users) + { + uint256 total = _pendingClaimUsers.length(); + if (offset >= total) return new address[](0); + + uint256 available = total - offset; + uint256 count = limit < available ? limit : available; + if (count > DotnsConstants.MAX_PAGE_SIZE) count = DotnsConstants.MAX_PAGE_SIZE; + + users = new address[](count); + for (uint256 i; i < count; ++i) { + users[i] = _pendingClaimUsers.at(offset + i); + } + } + + /// @inheritdoc IDotnsPopControllerOld + function reservedBaseLabelOf(bytes32 labelhash) + external + view + override + returns (string memory baseLabel) + { + return _reservedBaseLabel[labelhash]; + } + + /// @inheritdoc ERC165Upgradeable + function supportsInterface(bytes4 interfaceId) + public + view + override(ERC165Upgradeable, IERC165) + returns (bool) + { + return interfaceId == type(IDotnsPopControllerOld).interfaceId + || super.supportsInterface(interfaceId); + } + + /// @notice Returns implementation version. + /// @return versionString Current version string. + function version() external pure virtual returns (string memory versionString) { + versionString = "1.0.0"; + } + + /// @notice Mints a name, wires forward registry, persists PoP-flow records (chat key, + /// lite link) on the PoP resolver, and either writes the label into the owner's + /// existing `LabelStore` or stashes a pending claim when the owner has none yet. + /// @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 + /// (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 + /// stays labels-only. Warm path emits @custom:emits NameRegistered immediately; the + /// cold path emits @custom:emits PendingClaimStashed at mint and defers + /// @custom:emits NameRegistered to @custom:function settlePendingClaims when the claim + /// settles. + function _completeGatewayRegistration( + address user, + string memory label, + bytes32 labelhash, + bytes32 node, + bytes memory chatKeyBytes, + bytes32 liteLabelhash + ) + internal + { + RegistrationUtils.registerAndStore( + RegistrationUtils.RegistrationContext({ + protocolRegistry: protocolRegistry, + user: user, + label: "", + labelhash: labelhash, + node: node + }) + ); + + if (chatKeyBytes.length != 0 || liteLabelhash != bytes32(0)) { + IDotnsPopResolver resolver = _popResolver(); + if (chatKeyBytes.length != 0) { + resolver.setChatKey(node, chatKeyBytes); + } + if (liteLabelhash != bytes32(0)) { + resolver.setLiteLink(node, liteLabelhash); + } + } + + address store = _storeFactory().getLabelStore(user); + if (store == address(0)) { + _stashPendingClaim(user, label, labelhash); + } else { + _writeRecord(store, node, label); + emit NameRegistered(label, labelhash, user, store); + } + } + + /// @notice Writes a name's label into `store`. + /// @dev Single canonical persistence step shared by the warm gateway path and + /// @custom:function settlePendingClaims. The store key is `node`, matching + /// the registrar's `_writeOwnerLabel` convention. Idempotent on already-locked slots so a + /// 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 label Bare DNS label (no TLD); the TLD is appended on write. + function _writeRecord(address store, bytes32 node, string memory label) internal { + if (ILabelStore(store).isLocked(node)) return; + ILabelStore(store).storeLabel(node, string.concat(label, protocolRegistry.tld())); + } + + /// @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 + /// 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. + function _stashPendingClaim(address user, string memory label, bytes32 labelhash) internal { + _pendingClaimQueue[user].push( + PendingClaim({label: label, mintedAt: uint64(block.timestamp)}) + ); + _pendingClaimUsers.add(user); + + emit PendingClaimStashed(user, labelhash, label); + } + + /// @notice Returns whether a queue entry is expired relative to `block.timestamp`. + function _isExpired(uint64 joinedAt) internal view returns (bool) { + return joinedAt + reservationDuration < block.timestamp; + } + + /// @notice Appends a new reservation entry to the tail of the queue for `labelhash`. + /// @dev Reverts if the queue is full or the user already holds a reservation. When the + /// enqueued entry is the new head of an empty queue, the controller also reserves the + /// base name on PopRules so the public commit-reveal flow sees the reservation through + /// its existing `priceWithCheck` guard. Subsequent waiters only live in the local queue + /// until they are promoted. + function _enqueueReservation( + IPopRules rules, + bytes32 labelhash, + string memory baseLabel, + address user + ) + internal + { + require(_userReservations[user].labelhash == bytes32(0), AlreadyReserved(user, labelhash)); + + ReservationQueueMeta memory meta = _reservationMeta[labelhash]; + require(meta.tail - meta.head < MAX_RESERVATION_QUEUE, QueueFull(labelhash)); + + uint64 index = meta.tail; + bool becomesHead = index == meta.head; + + _reservationEntries[labelhash][index] = + ReservationEntry({owner: user, joinedAt: uint64(block.timestamp)}); + _reservationMeta[labelhash] = ReservationQueueMeta({head: meta.head, tail: index + 1}); + + _userReservations[user] = UserReservation({labelhash: labelhash, index: index}); + + if (becomesHead) { + _reservedBaseLabel[labelhash] = baseLabel; + rules.reserveBaseNameForPop(baseLabel, user); + } + + emit ReservationQueued(labelhash, user, index - meta.head); + } + + /// @notice Wipes the entire reservation queue for `labelhash` and releases the + /// corresponding PopRules reservation. + /// @dev Used when a holder claims their reservation: every waiter is evicted and their + /// per-user tracking state is cleared, and PopRules is told the slot is free so future + /// public registrations are unblocked (the claim itself just minted the name, so there + /// is nothing left to reserve). + function _clearQueue(bytes32 labelhash) internal { + ReservationQueueMeta memory meta = _reservationMeta[labelhash]; + for (uint64 i = meta.head; i < meta.tail; i++) { + ReservationEntry memory entry = _reservationEntries[labelhash][i]; + if (entry.owner != address(0)) { + delete _userReservations[entry.owner]; + } + delete _reservationEntries[labelhash][i]; + } + delete _reservationMeta[labelhash]; + _releasePopRulesSlot(labelhash); + } + + /// @notice Advances the queue head past every expired entry at the head of the queue. + /// @dev Reset semantics matter: when the queue empties (head catches tail), the meta slot + /// is deleted AND the PopRules base-name slot is released, so the public commit-reveal + /// flow can register the label again. When a new live head emerges, PopRules is re-synced + /// to that head so reservations cannot be paid around by another address. Emits + /// @custom:emits ReservationExpired once per expired entry reaped from the head. + function _advanceExpiredHead(bytes32 labelhash) internal { + ReservationQueueMeta memory meta = _reservationMeta[labelhash]; + uint64 head = meta.head; + uint64 tail = meta.tail; + + while (head < tail) { + ReservationEntry memory entry = _reservationEntries[labelhash][head]; + if (entry.owner == address(0)) { + // `owner == 0` implies the slot is fully zero (it can only have arrived here + // via a prior full-slot `delete`), so skip the no-op SSTORE. + head++; + continue; + } + if (!_isExpired(entry.joinedAt)) break; + + delete _userReservations[entry.owner]; + delete _reservationEntries[labelhash][head]; + emit ReservationExpired(labelhash, entry.owner); + head++; + } + + if (head == tail) { + delete _reservationMeta[labelhash]; + _releasePopRulesSlot(labelhash); + } else if (head != meta.head) { + _reservationMeta[labelhash] = ReservationQueueMeta({head: head, tail: tail}); + address newHead = _reservationEntries[labelhash][head].owner; + _syncPopRulesToHead(labelhash, newHead); + } + } + + /// @notice Removes `user` from whichever reservation queue they currently occupy. + /// @dev For a head removal, we delete the entry without bumping `meta.head` and delegate + /// the advance to `_advanceExpiredHead`. Its existing zero-owner skip walks past the + /// freshly-deleted slot, and its `head != meta.head` branch fires the PopRules resync + /// in the one place head promotion is actually handled. Non-head removals leave the + /// queue shape intact, so no advance or resync is needed. + function _removeUserFromQueue(address user) internal { + UserReservation memory userRes = _userReservations[user]; + bytes32 labelhash = userRes.labelhash; + if (labelhash == bytes32(0)) return; + + uint64 entryIndex = userRes.index; + ReservationQueueMeta memory queueMeta = _reservationMeta[labelhash]; + + delete _userReservations[user]; + delete _reservationEntries[labelhash][entryIndex]; + + if (entryIndex == queueMeta.head) { + _advanceExpiredHead(labelhash); + } + } + + /// @notice Validates a lite-person `NAMEXX` label and derives `(labelhash, node)`. + function _validateLiteLabel(string memory liteLabel) + internal + view + returns (bytes32 labelhash, bytes32 node) + { + require(liteLabel.isLitePersonLabelMemory(), InvalidLiteLabel()); + labelhash = LabelUtils.labelhashMemory(liteLabel); + node = LabelUtils.namehashUnder(protocolRegistry.tldNode(), labelhash); + } + + /// @notice Validates a base (full-person) DNS label and derives `(labelhash, node)`. + function _validateBaseLabel(string calldata baseLabel) + internal + view + returns (bytes32 labelhash, bytes32 node) + { + require(baseLabel.isSingleLabel(), InvalidBaseLabel()); + (labelhash, node) = LabelUtils.deriveNode(protocolRegistry.tldNode(), baseLabel); + } + + /// @notice Validates a base label as reservable and returns its hashes. + /// @dev Shared by both reservation entrypoints so the guard cannot drift between them. Runs + /// three checks and reverts on the first failure, before any reservation state is mutated: the + /// label must classify outside the governance-reserved tier and be a base name, be a canonical + /// single label, and have no owner on the registrar. The last check is the fix for a + /// reservation queued over an already-registered name: the queue keys by stem, so such a + /// reservation could never be redeemed yet would lock every two-digit variant of the stem for + /// the full reservation window. `exists` (owner set) mirrors exactly what makes the eventual + /// claim's mint revert, so a label that passes here is one a claim can still register. + function _validateReservableBaseLabel( + IPopRules rules, + string calldata baseLabel + ) + internal + view + returns (bytes32 labelhash, bytes32 node) + { + (IPopRules.PopStatus required,) = rules.classifyName(baseLabel); + require( + required != IPopRules.PopStatus.Reserved && rules.isBaseName(baseLabel), + InvalidBaseLabel() + ); + (labelhash, node) = _validateBaseLabel(baseLabel); + require(!_registrar().exists(uint256(node)), BaseNameAlreadyRegistered()); + } + + /// @notice Reverts when a non-empty chat key is not exactly `CHAT_KEY_LENGTH` bytes. + /// @dev Mirrors the resolver's own length gate so the gateway sees a controller-local + /// `InvalidChatKey` revert before any mint state is written. + function _requireValidChatKey(bytes memory chatKey) internal pure { + require( + chatKey.length == 0 || chatKey.length == CHAT_KEY_LENGTH, InvalidChatKey(chatKey.length) + ); + } + + /// @notice Resolves the PoP resolver via the protocol registry. + function _popResolver() internal view returns (IDotnsPopResolver) { + return IDotnsPopResolver(protocolRegistry.get(DotnsConstants.POP_RESOLVER)); + } + + /// @notice Resolves the PopRules contract via the protocol registry. + function _popRules() internal view returns (IPopRules) { + return IPopRules(protocolRegistry.get(DotnsConstants.POP_RULES)); + } + + /// @notice Resolves the Store factory via the protocol registry. + function _storeFactory() internal view returns (IStoreFactory) { + return IStoreFactory(protocolRegistry.get(DotnsConstants.STORE_FACTORY)); + } + + /// @notice Resolves the registrar via the protocol registry. + function _registrar() internal view returns (IDotnsRegistrar) { + return IDotnsRegistrar(protocolRegistry.get(DotnsConstants.REGISTRAR)); + } + + /// @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 + /// `_reservedBaseLabel[labelhash]` is non-empty (any non-empty queue had its first head + /// write the slot). The release-then-reserve pair satisfies PopRules' ownership gate on + /// `reserveBaseNameForPop`. + function _syncPopRulesToHead(bytes32 labelhash, address newHead) internal { + string memory baseLabel = _reservedBaseLabel[labelhash]; + IPopRules rules = _popRules(); + rules.releaseBaseName(baseLabel); + rules.reserveBaseNameForPop(baseLabel, newHead); + emit ReservationHeadAdvanced(labelhash, newHead); + } + + /// @notice Clears the PopRules slot and the local label bookkeeping when the queue empties + /// (claim, last-relinquish, last-expire). + function _releasePopRulesSlot(bytes32 labelhash) internal { + string memory baseLabel = _reservedBaseLabel[labelhash]; + if (bytes(baseLabel).length == 0) return; + _popRules().releaseBaseName(baseLabel); + delete _reservedBaseLabel[labelhash]; + } + + /// @notice Internal check enforcing a substrate 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 it traps. + function _onlyRoot() internal view { + require(SystemUtils.originIsRoot(), NotRoot()); + } + + /// @inheritdoc UUPSUpgradeable + function _authorizeUpgrade(address newImplementation) internal override onlyOwner {} +} diff --git a/contracts/registrars/DotnsPopLens.sol b/contracts/registrars/DotnsPopLens.sol index 84e123362..76ba105cb 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,16 +85,20 @@ 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); + // 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(labelhash); + detail.fullClaim = _popResolver().fullClaim(LabelUtils.labelhash(name)); return detail; } /// @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) { @@ -108,18 +116,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 +138,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 +146,12 @@ 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; + 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; } } @@ -150,9 +160,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 +185,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 +196,11 @@ 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)); + // 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}); @@ -201,9 +213,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,36 +229,51 @@ 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. /// @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; - 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, 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) { // Every mint path validates the label, so a stored label always classifies; the try @@ -283,6 +310,24 @@ 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()) { + return SubnodeUtils.liteSubnodeOf(tldNode, label); + } + 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..cff38f80e 100644 --- a/contracts/registrars/IDotnsPopController.sol +++ b/contracts/registrars/IDotnsPopController.sol @@ -5,11 +5,13 @@ 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 -/// 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. /// @@ -18,9 +20,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 +200,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 +215,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 +255,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 +284,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 +299,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 +313,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 +447,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/registrars/IDotnsPopControllerOld.sol b/contracts/registrars/IDotnsPopControllerOld.sol new file mode 100644 index 000000000..d02cbbcc3 --- /dev/null +++ b/contracts/registrars/IDotnsPopControllerOld.sol @@ -0,0 +1,499 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.34; + +import {IDotnsController} from "./IDotnsController.sol"; + +/// @title IDotnsPopControllerOld +/// @dev PR-scoped pre-upgrade snapshot. Deleted before merge with its paired upgrade script and +/// fork test per the upgrade-PR workflow in CONTRIBUTING.md. +/// @notice Interface for the dedicated PoP controller orchestrating lite-person and full-person +/// username issuance on behalf of the PoP gateway pallet. +/// @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` is +/// an intra-PoP coordination mechanism only; it does not block public registrations. +/// +/// Label formats: +/// Lite-person usernames (first argument to @custom:function reserveBaseName and the +/// `liteLabel` of a `LinkKind.LiteUsername` link) are DNS labels with exactly two +/// trailing digits (e.g. `alice42`) per @custom:function StringUtils.isLitePersonLabel. +/// The gateway strips any separator before calling so the on-chain label is flat. +/// Full-person usernames (the `label` of @custom:function registerBaseName and the +/// optional `reservedBaseLabel` of @custom:function reserveBaseName) follow the +/// DNS-label rules enforced by @custom:function StringUtils.isSingleLabel (e.g. +/// `alice`). Lite and public registrations share one namespace; first-to-mint wins at +/// the ERC721 layer. Cross-flow priority on the stripped base stem is arbitrated by +/// @custom:function IPopRules.reserveBaseNameForPop. +/// @custom:security-contact admin@parity.io +interface IDotnsPopControllerOld is IDotnsController { + /// @notice Discriminant for the `Link` union supplied to `registerBaseName`. + /// @dev Selects the chat-key source for the full-person username. Orthogonal to whether + /// the registration is a claim or standalone; that is derived from on-chain reservation + /// state. `None` means the caller supplies a fresh chat key in `link.chatKey`. + /// `LiteUsername` means the full-person username is linked to a prior lite-person + /// username (`link.liteLabel`) and inherits its chat key. + enum LinkKind { + None, + LiteUsername + } + + /// @notice Tagged union selecting the chat-key source for a full-person registration. + /// @param liteLabel Lite-person `NAMEXX` label (only read when `kind == LiteUsername`). + /// @param chatKey Chat key bytes (only read when `kind == None`). + struct Link { + LinkKind kind; + string liteLabel; + bytes chatKey; + } + + /// @notice Per-user reservation pointer: which queue the user sits in and where. + /// @param labelhash Non-zero when the user holds a live reservation; zero otherwise. + /// @param index Monotonic queue index, meaningful only when `labelhash` is non-zero. + struct UserReservation { + bytes32 labelhash; + uint64 index; + } + + /// @notice Reservation queue entry: a user and the timestamp they joined the queue. + /// @dev Packs into a single storage slot (20 + 8 bytes). + struct ReservationEntry { + address owner; + uint64 joinedAt; + } + + /// @notice Metadata describing the occupied range of a reservation queue. + /// @dev Uses monotonically increasing indices. Active entries occupy `[head, tail)`; + /// `length = tail - head`. Slots past `head` are deleted as the head advances so + /// garbage never accumulates. + struct ReservationQueueMeta { + uint64 head; + uint64 tail; + } + + /// @notice Deferred per-user binding of a freshly minted name to its `LabelStore`. + /// @dev Recorded by the gateway path when the user has no `LabelStore`. The binding later + /// settles via @custom:function settlePendingClaims, which deploys the store from a signed + /// origin and writes the stashed label. PoP-resolver records (chat key, lite link) are + /// persisted eagerly at mint time on @custom:contract IDotnsPopResolver, not at settlement, + /// so the resolver carries the full identity record regardless of whether the user has + /// settled their Store. A user accumulates one entry per deferred name: the Root gateway path + /// cannot deploy a `LabelStore` (contract creation is forbidden from the Root origin), so it + /// keeps stashing entries until a signed-origin @custom:function settlePendingClaims deploys + /// the store and settles the entries. Each entry's deadline is measured from its own + /// `mintedAt` against `reservationDuration`. + /// @param label Bare DNS label (no TLD); the TLD is appended at settlement time. + /// @param mintedAt Timestamp of the originating mint. + struct PendingClaim { + string label; + uint64 mintedAt; + } + + /// @notice Lite-person registration payload. + /// @dev Single struct so the gateway can ABI-encode one tuple as the cross-chain payload + /// and the contract decodes it directly out of `msg.data`. All fields are required; + /// `chatKey` may be empty bytes to skip the resolver write. + /// @param liteLabel Lite-person `NAMEXX` label being minted. + /// @param user Beneficiary account on this chain. + /// @param chatKey Chat-key bytes persisted on the PoP resolver. Empty leaves the slot unset. + struct LiteRegistration { + string liteLabel; + address user; + bytes chatKey; + } + + /// @notice Lite-person registration combined with an optional base-name reservation. + /// @dev `BaseReservation` is a @custom:struct LiteRegistration plus a base-label reservation + /// slot, expressed as composition rather than duplicated fields so internal helpers can + /// consume the lite leg via `params.lite` without unpacking. The lite leg always runs; + /// the reservation leg only runs when `reservedBaseLabel` is non-empty. + /// @param lite Lite-person registration request; see LiteRegistration. + /// @param reservedBaseLabel Base label to enqueue for a later full-person claim. Empty + /// string skips the reservation leg. + struct BaseReservation { + LiteRegistration lite; + string reservedBaseLabel; + } + + /// @notice Base-name reservation payload for the split gateway flow. + /// @dev This is the reservation-only primitive. The lite username mint is handled by + /// @custom:function reserveLiteName, and LabelStore settlement is handled by + /// @custom:function settlePendingClaims. + /// @param user Beneficiary account that will hold the reservation. + /// @param reservedBaseLabel Base label to enqueue for a later full-person claim. + struct BaseNameReservation { + address user; + string reservedBaseLabel; + } + + /// @notice Full-person registration payload. + /// @param label Base DNS label being minted. + /// @param user Beneficiary account on this chain. + /// @param link Chat-key source for the new entry; see @custom:struct Link. + struct FullRegistration { + string label; + address user; + Link link; + } + + /// @notice Emitted when a lite-person username is registered via the PoP gateway. + event LiteNameReserved(bytes32 indexed labelhash, address indexed user, string label); + + /// @notice Emitted when a full-person username is claimed out of an existing reservation. + event BaseNameClaimed(bytes32 indexed labelhash, address indexed user, string label); + + /// @notice Emitted when a standalone full-person username is registered via the PoP gateway. + event StandaloneNameRegistered(bytes32 indexed labelhash, address indexed user, string label); + + /// @notice Emitted when a reservation entry is added to the queue for a base name. + /// @param position Position in the queue at the time of joining (0 = active holder). + event ReservationQueued( + bytes32 indexed reservedLabelhash, address indexed user, uint64 position + ); + + /// @notice Emitted when a reservation entry is removed due to expiry. + event ReservationExpired(bytes32 indexed reservedLabelhash, address indexed user); + + /// @notice Emitted when a user voluntarily relinquishes their reservation. + event ReservationRelinquished(bytes32 indexed reservedLabelhash, address indexed user); + + /// @notice Emitted when a full-person username is linked to a lite-person username. + event LiteToFullLinked(bytes32 indexed fullLabelhash, bytes32 indexed liteLabelhash); + + /// @notice Emitted when the reservation duration is updated. + event ReservationDurationSet(uint64 duration); + + /// @notice Emitted when a name is successfully registered via the PoP controller. + /// @param store The Store instance used to persist the immutable registration record. + event NameRegistered( + string indexed label, bytes32 indexed labelhash, address indexed owner, address store + ); + + /// @notice Emitted when a gateway-path mint defers its `LabelStore` write into the + /// pending-claim mapping because the user has no store yet. + event PendingClaimStashed(address indexed user, bytes32 indexed labelhash, string label); + + /// @notice Emitted when a pending claim is written into a `LabelStore`. + /// @dev Fires once per settled entry from @custom:function settlePendingClaims. `settledBy` + /// is the caller: it equals `user` for a self-settlement and is any other address for a + /// third-party settlement, so consumers can tell the two apart from the log alone. + /// @param user Account the settled name belongs to. + /// @param labelhash Labelhash of the settled name. + /// @param store The `LabelStore` the label was written into. + /// @param settledBy Caller that performed and paid for the settlement. + event PendingClaimSettled( + address indexed user, bytes32 indexed labelhash, address store, address indexed settledBy + ); + + /// @notice Emitted when a reservation queue's head transitions to a new user, either via + /// expiry of the prior head or via the explicit relinquish path. + /// @param labelhash Base-label hash whose queue head changed. + /// @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. + /// @dev Carries no caller parameter: a Root origin has no account to report, + /// and reading `msg.sender` under one traps. + error NotRoot(); + + /// @notice Thrown when a supplied lite-person label does not match `NAMEXX`. + error InvalidLiteLabel(); + + /// @notice Thrown when a supplied base label is not a canonical DNS label. + error InvalidBaseLabel(); + + /// @notice Thrown when a reserved base label already has an owner on the registrar, so the + /// queued reservation could never be redeemed at mint time. + error BaseNameAlreadyRegistered(); + + /// @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. + /// @param length Caller-supplied chat key length, in bytes. + error InvalidChatKey(uint256 length); + + /// @notice Thrown when a user tries to claim or relinquish a reservation that they do not hold. + error NoActiveReservation(address user); + + /// @notice Thrown when a reservation queue has reached its capacity. + error QueueFull(bytes32 labelhash); + + /// @notice Thrown when attempting to enqueue a user who already has an active reservation. + error AlreadyReserved(address user, bytes32 labelhash); + + /// @notice Thrown when someone tries to mint a base label in standalone mode while another user + /// holds the live head-of-queue reservation. + error NotHolder(address user, bytes32 labelhash); + + /// @notice Thrown when a lite-link inheritance does not match the registrar-side owner + /// of the lite label. + /// @dev Prevents identity hijack by ensuring the registrant on the full-name leg actually + /// holds the prior lite identity whose chat key is being inherited. + /// @param user Registrant supplied by the gateway. + /// @param liteLabelhash Lite label whose ownership did not match. + error LiteLabelNotOwnedByUser(address user, bytes32 liteLabelhash); + + /// @notice Thrown when @custom:function setReservationDuration is called with a value below + /// the protocol minimum. + /// @param duration Caller-supplied duration, in seconds. + error ReservationDurationTooLow(uint64 duration); + + /// @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 + /// lite leg validates the dotted `stem.NN` shape and requires the flattened label to classify + /// as PopLite (otherwise @custom:reverts InvalidLiteLabel), and rejects a + /// supplied chat key whose length is neither zero nor `CHAT_KEY_LENGTH` + /// (otherwise @custom:reverts InvalidChatKey). On a warm-path mint (user already has a + /// `LabelStore`) it @custom:emits LiteNameReserved and @custom:emits NameRegistered; + /// on a cold-path mint it @custom:emits LiteNameReserved and + /// @custom:emits PendingClaimStashed, with @custom:emits NameRegistered deferred to + /// @custom:function settlePendingClaims when the claim settles. The base-name leg only runs + /// when `reservedBaseLabel` is non-empty: it validates the DNS-label shape and requires a + /// true base label with no trailing digits (otherwise @custom:reverts InvalidBaseLabel) and + /// with no owner on the registrar (otherwise @custom:reverts BaseNameAlreadyRegistered), + /// since a name that already has an owner could never be claimed. This validation runs + /// before both the lite mint and any queue mutation, so an already-registered + /// `reservedBaseLabel` aborts the whole call and the candidate receives no lite username + /// either; callers should validate the reserved label before attesting rather than relying + /// on this revert. It then advances the + /// head past expired entries (@custom:emits ReservationExpired for each one), + /// removes the user from any prior queue position so a single user holds at most one live + /// reservation across all labels, and enqueues a fresh entry + /// (@custom:emits ReservationQueued). The enqueue rejects with @custom:reverts + /// AlreadyReserved when the user already holds a reservation that was not cleared by the + /// prior removal and with @custom:reverts QueueFull when the per-label queue has reached + /// `MAX_RESERVATION_QUEUE`. Cross-chain callers pass the ABI-encoded reservation tuple as + /// the call's payload, which Solidity decodes directly. + /// @param params Reservation request; see @custom:struct BaseReservation. + 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). + /// 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 + /// per-call limits. Reverts with @custom:reverts InvalidBaseLabel when the label is empty, + /// non-canonical, digit-suffixed, or governance-reserved, and with + /// @custom:reverts BaseNameAlreadyRegistered when the label already has an owner on the + /// registrar and so could never be claimed. The caller remains agnostic about + /// backend batching; it simply exposes a small retryable primitive. + /// @param params Reservation request; see @custom:struct BaseNameReservation. + function reserveBaseNameOnly(BaseNameReservation calldata params) external; + + /// @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 + /// supplied label must satisfy the dotted `stem.NN` shape and the flattened label must classify + /// as PopLite (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: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 + /// claim settles. Cross-chain callers pass the ABI-encoded lite-registration tuple as the + /// call's payload, which Solidity decodes directly. + /// @param params Registration request; see @custom:struct LiteRegistration. + function reserveLiteName(LiteRegistration calldata params) external; + + /// @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 + /// base label must satisfy the DNS-label shape and be a true base label with no trailing digits + /// (otherwise @custom:reverts InvalidBaseLabel), and the label must not + /// classify as governance-reserved (otherwise @custom:reverts InvalidBaseLabel). The + /// gateway also defers to PopRules as the single cross-flow authority: when PopRules + /// carries a live base-name slot held by another user (stamped by the public commit-reveal + /// flow or this controller's prior queue head), the call reverts @custom:reverts NotHolder + /// before any queue mutation. Two orthogonal axes drive the state machine. The reservation + /// axis treats the user as claiming if and only if they hold the live head-of-queue + /// reservation on the base label: a claim wipes the entire queue, releases the PopRules + /// slot, and @custom:emits BaseNameClaimed; a non-claim silently relinquishes any + /// pending entry the user holds and @custom:emits StandaloneNameRegistered. Advancing + /// the queue head past expired entries @custom:emits ReservationExpired for each + /// one. The chat-key axis selects whether a fresh key is persisted on the resolver or the + /// new entry inherits its key from a prior lite-person username. The fresh-key branch + /// rejects a chat key whose length is neither zero nor `CHAT_KEY_LENGTH` (otherwise + /// @custom:reverts InvalidChatKey). The `LiteUsername` branch validates the lite label's + /// `NAMEXX` shape (otherwise @custom:reverts InvalidLiteLabel), requires the registrant to + /// own the lite token (otherwise @custom:reverts LiteLabelNotOwnedByUser), reads the lite + /// node's chat key from the resolver and copies it across; if the lite node carries no chat + /// key the inherited value is empty and the full node's chat-key write is silently skipped + /// (the `LiteToFullLinked` event still fires). @custom:emits LiteToFullLinked + /// alongside the registration event. On a warm-path mint the event order is + /// @custom:emits NameRegistered first (from the inner mint), then + /// @custom:emits BaseNameClaimed or @custom:emits StandaloneNameRegistered, then + /// @custom:emits LiteToFullLinked when applicable. On a cold-path mint + /// @custom:emits PendingClaimStashed replaces the initial @custom:emits NameRegistered; + /// the deferred @custom:emits NameRegistered fires later from @custom:function + /// settlePendingClaims. Cross-chain callers pass the ABI-encoded full-registration tuple as + /// the call's payload, which Solidity decodes directly. + /// @param params Registration request; see @custom:struct FullRegistration. + function registerBaseName(FullRegistration calldata params) external; + + /// @notice Permissionlessly removes expired entries from the head of a reservation queue. + /// @dev Permissionless on purpose: anyone (typically a UI or a bot) can poke a stale queue + /// so the next live head takes over without waiting for the next gateway call. Validates + /// the DNS-label shape of `reservedBaseLabel` (otherwise @custom:reverts InvalidBaseLabel) + /// and @custom:emits ReservationExpired for every expired entry reaped from the + /// head. Only base-shaped labels (no trailing digits) ever key a reservation queue, so a + /// lite-shaped label still passes the shape check but resolves to an empty queue and the + /// call is a no-op. + function expireReservation(string calldata reservedBaseLabel) external; + + /// @notice Lets the caller voluntarily drop their own active reservation. + /// @dev Reverts with @custom:reverts NoActiveReservation when the caller holds no live + /// reservation. On success the caller's entry is removed from its queue and + /// @custom:emits ReservationRelinquished is emitted; if the removed entry was the queue + /// head, head advancement may additionally @custom:emits ReservationExpired for any + /// stale entries reaped behind it. + function relinquishReservation() external; + + /// @notice Returns whether a label currently has a live reservation at the queue head. + /// @dev Validates the DNS-label shape of `reservedBaseLabel` (otherwise + /// @custom:reverts InvalidBaseLabel) before inspecting the queue. + function isReservedForClaim(string calldata reservedBaseLabel) + external + view + returns (bool reserved, address holder); + + /// @notice Updates the reservation duration used to decide when queue entries expire. + /// @dev Owner-gated (otherwise @custom:reverts OwnableUnauthorizedAccount); emits + /// @custom:emits ReservationDurationSet on success. + function setReservationDuration(uint64 duration) external; + + /// @notice Returns the queue metadata (`head`, `tail`) for `labelhash`. + /// @dev Read-only accessor over the per-label reservation queue. `head == tail` means + /// the queue is empty; active entries occupy `[head, tail)`. Exposed on the interface + /// because invariant tests and off-chain consumers (dotli, dweb) use it to enumerate + /// live queue state without scanning storage. + /// @param labelhash Keccak-256 of the base label whose queue is being read. + /// @return head Index of the live queue head. + /// @return tail Index one past the last queued entry. + function reservationMeta(bytes32 labelhash) external view returns (uint64 head, uint64 tail); + + /// @notice Returns the queue entry at `index` for `labelhash`. + /// @dev Sparse storage: a zero `entryOwner` means the slot was relinquished, expired and + /// reaped, or never written. Callers pair this with @custom:function reservationMeta to walk + /// the live window `[head, tail)`. + /// @param labelhash Keccak-256 of the base label whose queue is being read. + /// @param index Queue index to look up. + /// @return entryOwner Owner of the slot (zero if empty/relinquished). + /// @return joinedAt Timestamp the entry was enqueued (only meaningful when + /// `entryOwner != address(0)`). + function reservationEntry( + bytes32 labelhash, + uint64 index + ) + external + view + returns (address entryOwner, uint64 joinedAt); + + /// @notice Returns `user`'s current reservation pointer. + /// @dev A zero `labelhash` on the returned struct means the user holds no reservation; + /// `index` is meaningful only when `labelhash` is non-zero. + /// @param user Account whose reservation pointer is being read. + /// @return reservation Per-user reservation pointer; see @custom:struct UserReservation. + function userReservation(address user) + external + view + returns (UserReservation memory reservation); + + /// @notice Returns the base label a reservation queue is keyed under. + /// @dev Reverse lookup from the `bytes32` queue key to its label string, so a consumer that + /// observed a queue by labelhash (for example from a reservation event) can recover the + /// human-readable label without holding its preimage. Returns an empty string when no + /// reservation was ever enqueued under `labelhash`. + /// @param labelhash Keccak-256 of the base label. + /// @return baseLabel The base label string, or empty when unknown. + function reservedBaseLabelOf(bytes32 labelhash) external view returns (string memory baseLabel); + + /// @notice Returns the window, in seconds, after which a queue or pending-claim entry lapses. + /// @dev Governance-configurable via @custom:function setReservationDuration. Read by the lens + /// to compute each pending claim's settlement deadline. + /// @return duration Reservation duration in seconds. + function reservationDuration() external view returns (uint64 duration); + + /// @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 + /// 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 + /// `limit` entries are processed so a large queue cannot exceed the block gas limit; + /// `moreRemaining` reports whether entries are left for a follow-up call, and a `limit` of + /// zero settles nothing. Writes are idempotent on an already-locked store slot, so a claim + /// whose label was independently written settles harmlessly. Emits + /// @custom:emits PendingClaimSettled and @custom:emits NameRegistered per settled entry, with + /// `settledBy` set to the caller so a third-party settlement is distinguishable from a + /// self-settlement. + /// @param user Account whose pending claims are settled. + /// @param limit Maximum number of entries to settle in this call. + /// @return settledCount Number of entries settled. + /// @return moreRemaining Whether the user still holds unsettled entries. + function settlePendingClaims( + address user, + uint256 limit + ) + external + returns (uint256 settledCount, bool moreRemaining); + + /// @notice Settles the caller's own pending claims into their `LabelStore`. + /// @dev Convenience for a user settling their own store: equivalent to + /// @custom:function settlePendingClaims with `msg.sender` and a bounded batch. The caller + /// deploys and pays for their store on the first write. Settles at most one bounded batch so + /// the call cannot exceed the block gas limit; `moreRemaining` reports whether the caller + /// still holds unsettled entries, in which case they call again. Emits the same + /// @custom:emits PendingClaimSettled and @custom:emits NameRegistered as + /// @custom:function settlePendingClaims. + /// @return moreRemaining Whether the caller still holds unsettled entries. + function claimLabelStore() external returns (bool moreRemaining); + + /// @notice Returns a paginated slice of a user's pending claims in queue order. + /// @dev An empty array means the user has no pending claims at `offset`. Each entry carries + /// its `mintedAt`; the settlement deadline is `mintedAt + reservationDuration`. An `offset` + /// past the end returns an empty array rather than reverting, and a page holds at most + /// `DotnsConstants.MAX_PAGE_SIZE` entries. + /// @param user Account whose pending claims are read. + /// @param offset Start index into the queue. + /// @param limit Maximum entries to return. + /// @return claims Page of the user's pending claims; see @custom:struct PendingClaim. + function pendingClaims( + address user, + uint256 offset, + uint256 limit + ) + external + view + returns (PendingClaim[] memory claims); + + /// @notice Returns the number of pending claims currently staged for `user`. + /// @param user Account whose pending claims are counted. + /// @return count Number of staged pending claims. + function pendingClaimCountOf(address user) external view returns (uint256 count); + + /// @notice Returns the number of users with at least one live pending claim. + /// @dev Exact live count, not an all-time tally: fully settled users are removed from the + /// enumeration set so off-chain consumers can page through every stalled user without + /// filtering. + /// @return count Number of users currently holding a pending claim. + function pendingClaimUserCount() external view returns (uint256 count); + + /// @notice Returns a paginated slice of users with at least one live pending claim. + /// @dev Pair with @custom:function pendingClaims to read each user's stashed entries. + /// Ordering is not chronological; callers MUST NOT assume `mintedAt` is monotonic + /// across the slice. Returns an empty array when `offset` is past the live count, and a page + /// holds at most `DotnsConstants.MAX_PAGE_SIZE` entries. + /// @param offset Start index. + /// @param limit Maximum entries to return. + /// @return users Slice of users currently holding a pending claim. + function pendingClaimUsers( + uint256 offset, + uint256 limit + ) + external + view + returns (address[] memory users); +} 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/DotnsRegistryOld.sol b/contracts/registry/DotnsRegistryOld.sol new file mode 100644 index 000000000..83301c177 --- /dev/null +++ b/contracts/registry/DotnsRegistryOld.sol @@ -0,0 +1,303 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.34; + +import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; +import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; +import { + OwnableUpgradeable +} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; +import {IDotnsRegistryOld} from "./IDotnsRegistryOld.sol"; +import {IDotnsController} from "../registrars/IDotnsController.sol"; +import {IDotnsRegistrar} from "../registrars/IDotnsRegistrar.sol"; +import {IStoreFactory} from "../store/IStoreFactory.sol"; +import {StoreUtils} from "../utils/StoreUtils.sol"; +import {LabelUtils} from "../utils/LabelUtils.sol"; +import {IDotnsProtocolRegistry} from "./IDotnsProtocolRegistry.sol"; +import {StringUtils} from "../utils/StringUtils.sol"; +import {DotnsConstants} from "../utils/DotnsConstants.sol"; + +/// @title Dotns Registry +/// @author Parity +/// @notice Upgradeable on-chain registry for hierarchical name ownership and resolution. +/// @dev Tokenised second-level nodes store `owner == address(0)` as a sentinel and defer to +/// `IDotnsRegistrar.ownerOf`; subnodes carry an explicit owner address in `records`. +/// @custom:security-contact admin@parity.io +contract DotnsRegistryOld is Initializable, UUPSUpgradeable, OwnableUpgradeable, IDotnsRegistryOld { + using StoreUtils for IStoreFactory; + using StringUtils for *; + + /// @notice Mapping of node identifiers to records. + mapping(bytes32 node => Record record) private records; + + /// @notice Protocol-level address registry for all DotNS contracts. + IDotnsProtocolRegistry public protocolRegistry; + + uint256[50] private __gap; + + /// @notice Restricts access to the current owner of `node`. + modifier authorised(bytes32 node) { + _authorised(node); + _; + } + + /// @notice Restricts access to the configured registrar controller. + modifier onlyRegistrarController() { + _onlyRegistrarController(); + _; + } + + /// @custom:oz-upgrades-unsafe-allow constructor + constructor() { + _disableInitializers(); + } + + /// @notice Initialises the registry. + /// @dev Callable exactly once via `Initializable`, otherwise + /// @custom:reverts InvalidInitialization. `registry` must be non-zero, otherwise + /// @custom:reverts NotAllowed. + /// @param registry Protocol-level address registry used to resolve sibling contracts. + function initialize(IDotnsProtocolRegistry registry) external initializer { + __Ownable_init(msg.sender); + + require(address(registry) != address(0), NotAllowed()); + protocolRegistry = registry; + } + + /// @inheritdoc IDotnsRegistryOld + function setSubnodeOwner(SubnodeRecord calldata record) + external + override + authorised(record.parentNode) + returns (bytes32 subnode) + { + address newOwner = record.owner; + require(newOwner != address(0), NotAllowed()); + + bytes32 parentNode = record.parentNode; + string calldata subLabel = record.subLabel; + string calldata parentLabel = record.parentLabel; + require(subLabel.isSingleLabel(), InvalidLabel()); + require(parentLabel.isNamePath(), ParentLabelMismatch()); + require(_parentNamehash(parentLabel) == parentNode, ParentLabelMismatch()); + + bytes32 labelhash = LabelUtils.labelhash(subLabel); + subnode = LabelUtils.namehashUnder(parentNode, labelhash); + + Record storage existing = records[subnode]; + address reverseResolver = protocolRegistry.get(DotnsConstants.REVERSE_RESOLVER); + if (existing.exists) { + address previousOwner = existing.owner; + // Reset the resolver pointer on reassignment so the prior subnode owner's resolver + // (and any malicious wiring they staged before losing the subnode) cannot be inherited + // by the new holder. Records keyed by `subnode` on other resolver contracts are not + // wiped here; consumers should gate reads on current ownership. Skip the resolver + // write when it already matches the default to avoid a redundant SSTORE on no-op + // refresh paths. + existing.owner = newOwner; + if (existing.resolver != reverseResolver) { + existing.resolver = reverseResolver; + emit NewResolver(subnode, reverseResolver); + } + + if (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); + } + + emit NewOwner(parentNode, labelhash, newOwner); + } + + /// @inheritdoc IDotnsRegistryOld + function setOwner(bytes32 node, address newOwner) external override onlyRegistrarController { + require(newOwner != address(0), NotAllowed()); + IDotnsRegistrar registrar = IDotnsRegistrar(protocolRegistry.get(DotnsConstants.REGISTRAR)); + require(registrar.ownerOf(uint256(node)) == newOwner, NotAuthorised()); + + // The resolver pointer is reset to the default reverse resolver on every call to this + // function, which the controller drives on registration and on reclaim from escrow, so a + // prior owner's resolver cannot follow the name across that recycle. This does not cover a + // secondary-market ERC-721 `transferFrom`: that path does not call the registry, so a name + // sold directly carries the seller's resolver pointer until the buyer overwrites it. + // Owner remains the zero sentinel so reads delegate to the registrar's ERC-721 holder. + records[node] = Record({ + owner: address(0), + resolver: protocolRegistry.get(DotnsConstants.REVERSE_RESOLVER), + exists: true + }); + + emit NodeTransferred(node, newOwner); + } + + /// @inheritdoc IDotnsRegistryOld + function setResolver(bytes32 node, address newResolver) external override authorised(node) { + records[node].resolver = newResolver; + emit NewResolver(node, newResolver); + } + + /// @inheritdoc IDotnsRegistryOld + function setSubnodeResolver(SubnodeResolverRecord calldata record) + external + override + authorised(record.parentNode) + { + string calldata subLabel = record.subLabel; + string calldata parentLabel = record.parentLabel; + require(subLabel.isSingleLabel(), InvalidLabel()); + require(parentLabel.isNamePath(), ParentLabelMismatch()); + require(_parentNamehash(parentLabel) == record.parentNode, ParentLabelMismatch()); + + bytes32 subnode = + LabelUtils.namehashUnder(record.parentNode, LabelUtils.labelhash(subLabel)); + Record storage existing = records[subnode]; + require(existing.exists, NotAuthorised()); + + existing.resolver = record.resolver; + emit NewResolver(subnode, record.resolver); + } + + /// @inheritdoc IDotnsRegistryOld + function owner(bytes32 node) external view override returns (address) { + Record storage record = records[node]; + // Read `owner` first: a non-zero stored owner proves the record exists and is a subnode, + // collapsing the lookup to a single SLOAD. The slower `exists` SLOAD only runs on the + // tokenised-or-missing branch. + address storedOwner = record.owner; + if (storedOwner != address(0)) return storedOwner; + if (!record.exists) return address(0); + IDotnsRegistrar registrar = IDotnsRegistrar(protocolRegistry.get(DotnsConstants.REGISTRAR)); + return registrar.ownerOf(uint256(node)); + } + + /// @inheritdoc IDotnsRegistryOld + function resolver(bytes32 node) external view override returns (address) { + return records[node].resolver; + } + + /// @inheritdoc IDotnsRegistryOld + function recordExists(bytes32 node) external view override returns (bool) { + return records[node].exists; + } + + /// @inheritdoc IDotnsRegistryOld + function isAuthorised( + bytes32 node, + address account + ) + external + view + override + returns (bool authorisedFlag) + { + authorisedFlag = _isAuthorised(node, account); + } + + /// @notice Writes subnode registration to the owner's `LabelStore`. + /// @dev Keys the entry by `node` (full namehash) rather than labelhash so a single store + /// lookup yields the canonical full name without re-walking the parent chain. + function _writeSubnodeToStore( + address storeOwner, + bytes32 node, + string memory fullName + ) + internal + { + IStoreFactory factory = IStoreFactory(protocolRegistry.get(DotnsConstants.STORE_FACTORY)); + factory.writeLabel(storeOwner, node, fullName); + } + + /// @notice Computes the namehash of `parentLabel` rooted at the network's TLD node. + /// @dev Walks the label right-to-left in calldata using memory-safe assembly to avoid the + /// cost of slicing into intermediate `bytes` and to keep gas linear in the label depth. + /// Reads the TLD node from the protocol registry, so it is a view rather than pure. + function _parentNamehash(string calldata parentLabel) internal view returns (bytes32 node) { + bytes calldata labels = bytes(parentLabel); + uint256 end = labels.length; + require(end != 0, ParentLabelMismatch()); + + node = protocolRegistry.tldNode(); + + while (true) { + uint256 start = end; + while (start > 0 && labels[start - 1] != bytes1(0x2e)) { + unchecked { + --start; + } + } + + require(start != end, ParentLabelMismatch()); + + bytes32 labelhash; + assembly ("memory-safe") { + let pointer := mload(0x40) + let len := sub(end, start) + calldatacopy(pointer, add(labels.offset, start), len) + labelhash := keccak256(pointer, len) + mstore(pointer, node) + mstore(add(pointer, 0x20), labelhash) + node := keccak256(pointer, 0x40) + } + + if (start == 0) return node; + end = start - 1; + } + } + + /// @notice Internal authorisation check for node ownership. + /// @dev Reverts with NotAuthorised when `msg.sender` is not authorised for `node`. + function _authorised(bytes32 node) internal view { + require(_isAuthorised(node, msg.sender), NotAuthorised()); + } + + /// @notice Canonical authorisation rule for a node, parameterised by `account`. + /// @dev Honours the sentinel-zero pattern: if the registry has no explicit owner, fall back + /// to the registrar's ERC-721 owner / approved / operator-for-all chain. This is the + /// single source of truth `_authorised` and `isAuthorised` both delegate to. + function _isAuthorised(bytes32 node, address account) internal view returns (bool) { + Record storage record = records[node]; + + // Read `owner` first: a non-zero stored owner means this is a subnode with an explicit + // owner and the existence flag is implied. Skipping the `exists` SLOAD on the common + // subnode path saves one slot read. + address storedOwner = record.owner; + if (storedOwner != address(0)) { + return storedOwner == account; + } + + if (!record.exists) return false; + + IDotnsRegistrar registrar = IDotnsRegistrar(protocolRegistry.get(DotnsConstants.REGISTRAR)); + uint256 tokenId = uint256(node); + address tokenOwner = registrar.ownerOf(tokenId); + if (account == tokenOwner) return true; + // Operator-for-all is the common marketplace / escrow delegation path; check it before + // the single-token approval so the common case terminates on one STATICCALL. + if (registrar.isApprovedForAll(tokenOwner, account)) return true; + return registrar.getApproved(tokenId) == account; + } + + /// @notice Internal check for registrar-authorised controller privileges. + /// @dev The registry trusts every controller the registrar trusts. Routing controller + /// authorisation through the registrar's `controllers` mapping keeps the trust list + /// in one place and lets commit-reveal and PoP controllers coexist without registry + /// reconfiguration on each addition. + function _onlyRegistrarController() internal view { + IDotnsRegistrar registrar = IDotnsRegistrar(protocolRegistry.get(DotnsConstants.REGISTRAR)); + require(registrar.controllers(IDotnsController(msg.sender)), NotAuthorised()); + } + + /// @notice Returns implementation version. + /// @return versionString Current version string. + function version() external pure virtual returns (string memory versionString) { + versionString = "1.0.0"; + } + + /// @inheritdoc UUPSUpgradeable + function _authorizeUpgrade(address newImplementation) internal override onlyOwner {} +} diff --git a/contracts/registry/IDotnsRegistry.sol b/contracts/registry/IDotnsRegistry.sol index e3d1d6be6..45dc6f788 100644 --- a/contracts/registry/IDotnsRegistry.sol +++ b/contracts/registry/IDotnsRegistry.sol @@ -12,11 +12,20 @@ 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. The store write is gated to protocol store writers (the registrar + /// and its controllers), not the name owner, so a deferring writer indexes the label + /// itself by deploying the owner's store and writing to it. The registry writes the + /// store only on creation or on a reassignment to a new owner, so a later same-owner re-call + /// with `persist` true does not backfill it; the authorised writer backfills it + /// directly. struct SubnodeRecord { bytes32 parentNode; string subLabel; string parentLabel; address owner; + bool persist; } /// @notice Record describing the state of a node. @@ -83,8 +92,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/registry/IDotnsRegistryOld.sol b/contracts/registry/IDotnsRegistryOld.sol new file mode 100644 index 000000000..7750d7295 --- /dev/null +++ b/contracts/registry/IDotnsRegistryOld.sol @@ -0,0 +1,152 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.34; + +/// @title IDotnsRegistryOld +/// @author Parity +/// @notice Minimal on-chain registry for hierarchical name ownership and resolution. +/// @dev Tokenised second-level nodes are owned through the registrar (ERC-721); subnodes are +/// owned directly by the address stored in `Record.owner`. +/// @custom:security-contact admin@parity.io +interface IDotnsRegistryOld { + /// @notice Record describing a subnode creation request. + /// @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. + struct SubnodeRecord { + bytes32 parentNode; + string subLabel; + string parentLabel; + address owner; + } + + /// @notice Record describing the state of a node. + /// @param owner Address that owns the node, or address(0) sentinel for tokenised nodes. + /// @param resolver Address of the resolver associated with the node. + /// @param exists Whether the node has been explicitly created. + struct Record { + address owner; + address resolver; + bool exists; + } + + /// @notice Emitted when a new subnode owner is set. + /// @param node Parent node. + /// @param label Labelhash of the created subnode. + event NewOwner(bytes32 indexed node, bytes32 indexed label, address owner); + + /// @notice Emitted when ownership of a node is transferred. + event NodeTransferred(bytes32 indexed node, address owner); + + /// @notice Emitted when a resolver is set or updated. + event NewResolver(bytes32 indexed node, address resolver); + + /// @notice Thrown when an invalid (zero) address is provided. + error NotAllowed(); + + /// @notice Thrown when the caller is not authorised. + error NotAuthorised(); + + /// @notice Thrown when the caller is not the registry controller. + error NotRegistryController(); + + /// @notice Thrown when attempting to create a node that already exists. + error NodeAlreadyOwned(bytes32 node); + + /// @notice Thrown when a sublabel is not a canonical lowercase ASCII DNS label. + error InvalidLabel(); + + /// @notice Thrown when the supplied parent label does not match the parent node. + error ParentLabelMismatch(); + + /// @notice Record describing a subnode resolver update request. + /// @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 resolver Resolver contract address (zero clears). + struct SubnodeResolverRecord { + bytes32 parentNode; + string subLabel; + string parentLabel; + address resolver; + } + + /// @notice Creates or reassigns a subnode and assigns its owner. + /// @dev Callable only by the current owner of `record.parentNode`, otherwise + /// @custom:reverts NotAuthorised. The new owner address must be non-zero, otherwise + /// @custom:reverts NotAllowed. `record.subLabel` must be a single canonical DNS label + /// (otherwise @custom:reverts InvalidLabel) and `record.parentLabel` must be a name + /// path whose namehash matches `record.parentNode` (otherwise + /// @custom:reverts ParentLabelMismatch). Subnodes are parent-sovereign: the current + /// `record.parentNode` owner may reassign or rotate a subnode's resolver at any time + /// without the prior subnode owner's consent. On reassignment the resolver pointer is + /// reset to the protocol-registered default reverse resolver so a prior subnode + /// owner's resolver cannot be inherited by the next holder (records on other resolver + /// 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. + function setSubnodeOwner(SubnodeRecord calldata record) external returns (bytes32 subnode); + + /// @notice Sets the resolver for an existing subnode. + /// @dev Callable only by the current owner of `record.parentNode`, otherwise + /// @custom:reverts NotAuthorised. The subnode owner can still update the resolver + /// directly via `setResolver`. Both entry points emit the same `NewResolver(subnode, + /// ...)` event, so the parent can silently override a subnode owner's chosen resolver: + /// this is the parent-sovereign hierarchy applied to resolution. Off-chain consumers + /// that surface trust signals to subnode owners should treat any resolver rotation as + /// a re-attestation prompt. `record.subLabel` must be a single canonical DNS label + /// (otherwise @custom:reverts InvalidLabel) and `record.parentLabel` must be a name + /// path whose namehash matches `record.parentNode` (otherwise + /// @custom:reverts ParentLabelMismatch). The resulting subnode must already exist, + /// otherwise @custom:reverts NotAuthorised. Emits @custom:emits NewResolver on + /// success. + function setSubnodeResolver(SubnodeResolverRecord calldata record) external; + + /// @notice Creates or resets a node record for a tokenised base registration. + /// @dev Restricted to the registrar's controllers, otherwise @custom:reverts NotAuthorised. + /// `newOwner` must be non-zero (otherwise @custom:reverts NotAllowed) and must match + /// the ERC-721 owner reported by the registrar (otherwise + /// @custom:reverts NotAuthorised). The function is callable both on a fresh + /// registration and on every reclaim from escrow: each call rewrites + /// `records[node].resolver` to the protocol-registered default reverse resolver so a + /// prior owner's resolver pointer (and the records keyed under it) cannot be inherited + /// by the next holder across that recycle. A secondary-market ERC-721 `transferFrom` does + /// not call the registry, so a name sold directly keeps the seller's resolver pointer + /// until the buyer overwrites it. Stores `owner = address(0)` as a sentinel so reads + /// delegate to `IDotnsRegistrar.ownerOf` and ERC-721 transfers remain authoritative. Emits + /// @custom:emits NodeTransferred on success. + function setOwner(bytes32 node, address newOwner) external; + + /// @notice Sets or clears the resolver for a node. + /// @dev Callable only by the current node owner, otherwise @custom:reverts NotAuthorised. + /// For tokenised nodes, authorisation falls back to ERC-721 owner / approved / + /// operator-for-all via the registrar. The registry does not validate + /// `resolverAddr` against any interface or code presence; off-chain consumers must + /// verify resolver shape before trusting reads. Emits @custom:emits NewResolver on + /// success. + /// @param resolverAddr Resolver contract address (zero clears). + function setResolver(bytes32 node, address resolverAddr) external; + + /// @notice Returns the owner of a node. + /// @dev For tokenised nodes the stored owner is the zero sentinel; the implementation falls + /// back to `IDotnsRegistrar.ownerOf(uint256(node))`. + function owner(bytes32 node) external view returns (address); + + /// @notice Returns the resolver of a node. + function resolver(bytes32 node) external view returns (address); + + /// @notice Returns whether a node exists. + function recordExists(bytes32 node) external view returns (bool); + + /// @notice Returns whether `account` is authorised to manage `node`. + /// @dev For subnodes, authority is the explicit stored owner. For tokenised nodes it is the + /// ERC-721 owner, an address approved for the token, or an operator approved for all of + /// the owner's tokens via the registrar. This is the canonical authorisation check the + /// registry enforces on owner-gated entry points; sibling contracts may consult it so a + /// single registrar-level approval delegates management across the protocol. Returns + /// false for a node that does not exist. + /// @param node Node identifier. + /// @param account Address whose authority is being checked. + /// @return authorisedFlag True when `account` may manage `node`. + function isAuthorised(bytes32 node, address account) external view returns (bool authorisedFlag); +} diff --git a/contracts/resolvers/DotnsReverseResolver.sol b/contracts/resolvers/DotnsReverseResolver.sol index 3d3beefff..f94e1fc15 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,28 @@ 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)) { + return SubnodeUtils.liteSubnodeOf(tldNode, label); } + 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/resolvers/DotnsReverseResolverOld.sol b/contracts/resolvers/DotnsReverseResolverOld.sol new file mode 100644 index 000000000..54e862a5a --- /dev/null +++ b/contracts/resolvers/DotnsReverseResolverOld.sol @@ -0,0 +1,135 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.34; + +import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; +import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; +import { + OwnableUpgradeable +} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; +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 {DotnsConstants} from "../utils/DotnsConstants.sol"; +import {LabelUtils} from "../utils/LabelUtils.sol"; + +/// @title Dotns Reverse Resolver +/// @notice Resolves an address to its associated name under the network TLD. +/// @dev Writes are gated on a fixed writer address resolved from the protocol +/// registry (the registrar or its controller), not on node ownership. +/// Reverse records bind to an EOA rather than a registry node, so authority +/// is delegated to the contract that mints names on the user's behalf. +/// @custom:security-contact admin@parity.io +contract DotnsReverseResolverOld is + Initializable, + UUPSUpgradeable, + OwnableUpgradeable, + ERC165Upgradeable, + IDotnsReverseResolver +{ + /// @dev Mapping from address to its reverse name. An empty string indicates + /// that no reverse name is set. + mapping(address owner => string name) private reverseNames; + + /// @notice Protocol-level address registry for all DotNS contracts. + IDotnsProtocolRegistry public protocolRegistry; + + /// @dev Reserved storage space to allow for layout changes in the future. + // forge-lint: disable-next-line(mixed-case-variable) + uint256[50] private __gap; + + /// @notice Restricts access to the configured registrar. + modifier onlyRegistrar() { + _onlyRegistrar(); + _; + } + + /// @custom:oz-upgrades-unsafe-allow constructor + constructor() { + _disableInitializers(); + } + + /// @notice Initialises the reverse resolver. + /// @dev May only be called once per proxy; a repeat call reverts with + /// @custom:reverts InvalidInitialization. Emits @custom:emits OwnershipTransferred when + /// `msg.sender` is recorded as the initial owner and @custom:emits Initialized once + /// setup completes. + /// @param registry Protocol-level address registry used to resolve sibling contracts. + function initialize(IDotnsProtocolRegistry registry) external initializer { + __Ownable_init(msg.sender); + __ERC165_init(); + protocolRegistry = registry; + } + + /// @inheritdoc IDotnsReverseResolver + function setReverseName(address addr, string calldata name) external override onlyRegistrar { + reverseNames[addr] = name; + emit ReverseNameSet(addr, name); + } + + /// @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)); + + string memory fullName = string.concat(label, protocolRegistry.tld()); + reverseNames[msg.sender] = fullName; + emit ReverseNameSet(msg.sender, fullName); + } + + /// @inheritdoc IDotnsReverseResolver + function nameOf(address addr) external view override returns (string memory name) { + string memory stored = reverseNames[addr]; + if (bytes(stored).length == 0) return ""; + + // Strip the TLD suffix and validate against current ownership so a transferred-away + // name never resolves under a stale reverse record. + 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)); + + IERC721 registrar = IERC721(protocolRegistry.get(DotnsConstants.REGISTRAR)); + try registrar.ownerOf(tokenId) returns (address currentOwner) { + if (currentOwner != addr) return ""; + return stored; + } catch { + return ""; + } + } + + /// @inheritdoc ERC165Upgradeable + function supportsInterface(bytes4 interfaceId) + public + view + override(ERC165Upgradeable) + returns (bool supported) + { + return interfaceId == type(IDotnsReverseResolver).interfaceId + || super.supportsInterface(interfaceId); + } + + /// @notice Internal check enforcing registrar-only access. + function _onlyRegistrar() internal view { + address controller = protocolRegistry.get(DotnsConstants.CONTROLLER); + address registrar = protocolRegistry.get(DotnsConstants.REGISTRAR); + require( + msg.sender == controller || msg.sender == registrar, NotRegistrarController(msg.sender) + ); + } + + /// @notice Returns implementation version. + /// @return versionString Current version string. + function version() external pure virtual returns (string memory versionString) { + versionString = "1.0.0"; + } + + /// @inheritdoc UUPSUpgradeable + function _authorizeUpgrade(address newImplementation) internal override onlyOwner {} +} 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 `