From 5f9be44f86e642bb2bbbd8763b84db8831eb524b Mon Sep 17 00:00:00 2001 From: Siphamandla Mjoli Date: Wed, 9 Sep 2026 00:55:36 +0200 Subject: [PATCH 01/25] chore: initial commit --- contracts/access/DotnsRoleManagerOld.sol | 101 +++ contracts/access/IDotnsRoleManagerOld.sol | 32 + contracts/pop/IPopRulesOld.sol | 344 +++++++ contracts/pop/PopRulesOld.sol | 606 +++++++++++++ contracts/registrars/DotnsPopController.sol | 5 +- .../registrars/DotnsPopControllerOld.sol | 856 ++++++++++++++++++ contracts/registrars/DotnsRegistrar.sol | 6 +- .../registrars/DotnsRegistrarController.sol | 28 +- .../DotnsRegistrarControllerOld.sol | 481 ++++++++++ contracts/registrars/DotnsRegistrarOld.sol | 431 +++++++++ .../registrars/IDotnsPopControllerOld.sol | 497 ++++++++++ .../IDotnsRegistrarControllerOld.sol | 194 ++++ contracts/registrars/IDotnsRegistrarOld.sol | 172 ++++ contracts/store/ILabelStoreOld.sol | 124 +++ contracts/store/LabelStoreOld.sol | 187 ++++ contracts/whitelist/DotnsNameWhitelist.sol | 18 + contracts/whitelist/DotnsNameWhitelistOld.sol | 535 +++++++++++ .../whitelist/IDotnsNameWhitelistOld.sol | 374 ++++++++ package.json | 1 + scripts/deploy/UpgradeLabelStore.s.sol | 86 ++ scripts/deploy/UpgradeNameWhitelist.s.sol | 63 ++ scripts/deploy/UpgradePopController.s.sol | 63 ++ scripts/deploy/UpgradePopRules.s.sol | 62 ++ scripts/deploy/UpgradeRegistrar.s.sol | 62 ++ .../deploy/UpgradeRegistrarController.s.sol | 65 ++ scripts/shell/fork-tests.sh | 44 + test/fork/UpgradeLabelStore.t.sol | 159 ++++ test/fork/UpgradeNameWhitelist.t.sol | 159 ++++ test/fork/UpgradePopController.t.sol | 182 ++++ test/fork/UpgradePopRules.t.sol | 157 ++++ test/fork/UpgradeRegistrar.t.sol | 153 ++++ test/fork/UpgradeRegistrarController.t.sol | 152 ++++ 32 files changed, 6393 insertions(+), 6 deletions(-) create mode 100644 contracts/access/DotnsRoleManagerOld.sol create mode 100644 contracts/access/IDotnsRoleManagerOld.sol create mode 100644 contracts/pop/IPopRulesOld.sol create mode 100644 contracts/pop/PopRulesOld.sol create mode 100644 contracts/registrars/DotnsPopControllerOld.sol create mode 100644 contracts/registrars/DotnsRegistrarControllerOld.sol create mode 100644 contracts/registrars/DotnsRegistrarOld.sol create mode 100644 contracts/registrars/IDotnsPopControllerOld.sol create mode 100644 contracts/registrars/IDotnsRegistrarControllerOld.sol create mode 100644 contracts/registrars/IDotnsRegistrarOld.sol create mode 100644 contracts/store/ILabelStoreOld.sol create mode 100644 contracts/store/LabelStoreOld.sol create mode 100644 contracts/whitelist/DotnsNameWhitelistOld.sol create mode 100644 contracts/whitelist/IDotnsNameWhitelistOld.sol create mode 100644 scripts/deploy/UpgradeLabelStore.s.sol create mode 100644 scripts/deploy/UpgradeNameWhitelist.s.sol create mode 100644 scripts/deploy/UpgradePopController.s.sol create mode 100644 scripts/deploy/UpgradePopRules.s.sol create mode 100644 scripts/deploy/UpgradeRegistrar.s.sol create mode 100644 scripts/deploy/UpgradeRegistrarController.s.sol create mode 100755 scripts/shell/fork-tests.sh create mode 100644 test/fork/UpgradeLabelStore.t.sol create mode 100644 test/fork/UpgradeNameWhitelist.t.sol create mode 100644 test/fork/UpgradePopController.t.sol create mode 100644 test/fork/UpgradePopRules.t.sol create mode 100644 test/fork/UpgradeRegistrar.t.sol create mode 100644 test/fork/UpgradeRegistrarController.t.sol diff --git a/contracts/access/DotnsRoleManagerOld.sol b/contracts/access/DotnsRoleManagerOld.sol new file mode 100644 index 000000000..356c1532d --- /dev/null +++ b/contracts/access/DotnsRoleManagerOld.sol @@ -0,0 +1,101 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.34; + +import {IAccessControl} from "@openzeppelin/contracts/access/IAccessControl.sol"; +import { + AccessControlUpgradeable +} from "@openzeppelin/contracts-upgradeable/access/AccessControlUpgradeable.sol"; +import { + OwnableUpgradeable +} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; +import {IDotnsRoleManagerOld} from "./IDotnsRoleManagerOld.sol"; + +/// @title Dotns Role Manager +/// @notice Shared owner-administered role layer for DotNS contracts with operational roles. +/// @dev Consuming contracts define their supported role set in @custom:function _isSupportedRole. +/// The owner remains the only account that can grant or revoke roles; role holders receive +/// only the operational permissions each consuming contract explicitly gates with +/// @custom:function _checkRoleOrOwner. +/// @custom:security-contact admin@parity.io +abstract contract DotnsRoleManagerOld is + AccessControlUpgradeable, + OwnableUpgradeable, + IDotnsRoleManagerOld +{ + /// @notice Initialises the OpenZeppelin access-control state for consuming contracts. + /// @dev Must be called during the consuming contract initialiser. + function _dotnsRoleManagerInit() internal onlyInitializing { + __AccessControl_init(); + } + + /// @inheritdoc IDotnsRoleManagerOld + function setRole(bytes32 role, address account, bool enabled) external override onlyOwner { + _setRole(role, account, enabled); + } + + /// @inheritdoc IAccessControl + function grantRole( + bytes32 role, + address account + ) + public + override(AccessControlUpgradeable, IAccessControl) + onlyOwner + { + _setRole(role, account, true); + } + + /// @inheritdoc IAccessControl + function revokeRole( + bytes32 role, + address account + ) + public + override(AccessControlUpgradeable, IAccessControl) + onlyOwner + { + _setRole(role, account, false); + } + + /// @inheritdoc AccessControlUpgradeable + function supportsInterface(bytes4 interfaceId) + public + view + virtual + override(AccessControlUpgradeable) + returns (bool supported) + { + return interfaceId == type(IDotnsRoleManagerOld).interfaceId + || super.supportsInterface(interfaceId); + } + + /// @notice Reverts unless the caller is the owner or holds `role`. + /// @dev Consuming contracts use this for operational paths where the owner keeps super-user + /// access and role holders receive a narrower permission; any other caller is rejected + /// with @custom:reverts NotRoleOrOwner. + function _checkRoleOrOwner(bytes32 role) internal view { + address caller = _msgSender(); + require(caller == owner() || hasRole(role, caller), NotRoleOrOwner(caller, role)); + } + + /// @notice Grants or revokes a supported role for `account`. + /// @dev `role` must be recognised by the consuming contract (otherwise + /// @custom:reverts UnsupportedRole) and `account` must be non-zero (otherwise + /// @custom:reverts InvalidRoleAccount). Delegates to OpenZeppelin's `_grantRole` or + /// `_revokeRole`, which emit @custom:emits IAccessControl.RoleGranted on grant and + /// @custom:emits IAccessControl.RoleRevoked on revoke. + function _setRole(bytes32 role, address account, bool enabled) internal { + require(_isSupportedRole(role), UnsupportedRole(role)); + require(account != address(0), InvalidRoleAccount(account)); + + if (enabled) { + _grantRole(role, account); + } else { + _revokeRole(role, account); + } + } + + /// @notice Returns whether `role` is recognised by the consuming contract. + /// @dev Implemented by each consuming contract so unsupported role identifiers fail closed. + function _isSupportedRole(bytes32 role) internal view virtual returns (bool supported); +} diff --git a/contracts/access/IDotnsRoleManagerOld.sol b/contracts/access/IDotnsRoleManagerOld.sol new file mode 100644 index 000000000..030ed7158 --- /dev/null +++ b/contracts/access/IDotnsRoleManagerOld.sol @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.34; + +import {IAccessControl} from "@openzeppelin/contracts/access/IAccessControl.sol"; + +/// @title DotNS Role Manager +/// @notice Shared owner-administered role API for DotNS contracts with operational roles. +/// @dev Role identifiers are declared in `DotnsConstants`. Ownership remains the source of +/// super-user authority: the owner grants and revokes supported roles, while role holders +/// receive only the operational permissions each consuming contract recognises. +/// @custom:security-contact admin@parity.io +interface IDotnsRoleManagerOld is IAccessControl { + /// @notice Thrown when a caller is neither the contract owner nor a holder of `role`. + error NotRoleOrOwner(address caller, bytes32 role); + + /// @notice Thrown when role management is attempted for a role the contract does not use. + error UnsupportedRole(bytes32 role); + + /// @notice Thrown when role management is attempted for the zero address. + error InvalidRoleAccount(address account); + + /// @notice Grants or revokes an operational role. + /// @dev Only the owner can manage roles (otherwise @custom:reverts OwnableUnauthorizedAccount); + /// `role` must be one of the roles recognised by the consuming contract (otherwise + /// @custom:reverts UnsupportedRole); `account` must not be the zero address (otherwise + /// @custom:reverts InvalidRoleAccount). Emits @custom:emits IAccessControl.RoleGranted on + /// grant and @custom:emits IAccessControl.RoleRevoked on revoke. + /// @param role Role identifier declared in `DotnsConstants`. + /// @param account Account whose role membership is updated. + /// @param enabled Whether the role should be granted or revoked. + function setRole(bytes32 role, address account, bool enabled) external; +} diff --git a/contracts/pop/IPopRulesOld.sol b/contracts/pop/IPopRulesOld.sol new file mode 100644 index 000000000..da045f2f7 --- /dev/null +++ b/contracts/pop/IPopRulesOld.sol @@ -0,0 +1,344 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.34; + +/// @title Proof of Personhood Rules for Dotns +/// @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..043b570f4 --- /dev/null +++ b/contracts/pop/PopRulesOld.sol @@ -0,0 +1,606 @@ +// 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 +/// @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 dcf7b13a0..155aba26a 100644 --- a/contracts/registrars/DotnsPopController.sol +++ b/contracts/registrars/DotnsPopController.sol @@ -142,8 +142,9 @@ contract DotnsPopController is /// boundary, which is the question it is asking. mapping(string label => bool issued) internal _popIssued; - /// @dev Reserved storage space to allow for layout changes in future upgrades. - uint256[50] private __gap; + /// @dev Reserved storage space to allow for layout changes in future upgrades. The + /// `_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. modifier onlyRoot() { diff --git a/contracts/registrars/DotnsPopControllerOld.sol b/contracts/registrars/DotnsPopControllerOld.sol new file mode 100644 index 000000000..1f18154d7 --- /dev/null +++ b/contracts/registrars/DotnsPopControllerOld.sol @@ -0,0 +1,856 @@ +// 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 +/// @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/DotnsRegistrar.sol b/contracts/registrars/DotnsRegistrar.sol index 3f413c122..082c92960 100644 --- a/contracts/registrars/DotnsRegistrar.sol +++ b/contracts/registrars/DotnsRegistrar.sol @@ -61,8 +61,10 @@ contract DotnsRegistrar is /// @custom:function quoteTransferFee. mapping(uint256 tokenId => bool soulbound) private _soulbound; - /// @dev Reserved storage space to allow for layout changes in the future. - uint256[50] private __gap; + /// @dev Reserved storage space to allow for layout changes in the future. `_soulbound` occupies + /// one reserved slot, so the gap holds 49 slots and the contract keeps a fixed 51-slot + /// footprint. + uint256[49] private __gap; /// @notice Restricts function access to authorised controllers. modifier onlyController() { diff --git a/contracts/registrars/DotnsRegistrarController.sol b/contracts/registrars/DotnsRegistrarController.sol index 86e87937d..adf083676 100644 --- a/contracts/registrars/DotnsRegistrarController.sol +++ b/contracts/registrars/DotnsRegistrarController.sol @@ -50,6 +50,24 @@ contract DotnsRegistrarController is using StringUtils for *; using StoreUtils for IStoreFactory; + /// @notice One role's membership and its admin role. + /// @dev Member of the reserved AccessControl namespace, unused because gating is Root only. + /// @param hasRole Whether an account holds the role. + /// @param adminRole Admin role that manages the role. + struct RoleData { + mapping(address account => bool) hasRole; + bytes32 adminRole; + } + + /// @notice Reserved OpenZeppelin access-control namespace held at its ERC-7201 slot. + /// @dev Declared and left unused so the namespace stays present in the layout. Its slot derives + /// from the label, disjoint from the sequential slots below, so it consumes none of them. + /// @param _roles Role data keyed by role identifier. + /// @custom:storage-location erc7201:openzeppelin.storage.AccessControl + struct AccessControlStorage { + mapping(bytes32 role => RoleData) _roles; + } + /// @notice Upper bound for commitment validity to cap storage griefing risk. uint256 public constant MAX_ALLOWED_COMMITMENT_AGE = 7 days; @@ -69,11 +87,17 @@ contract DotnsRegistrarController is /// from this stamp. mapping(bytes32 hash => uint256 version) public committedPricingVersion; + /// @dev Reserved slot held so the sequential storage layout stays fixed across the in-place + /// upgrade. Unused: name eligibility lives in @custom:contract DotnsNameWhitelist. + /// @custom:oz-renamed-from whiteList + mapping(address account => bool retained) private __whiteListSlot; + /// @notice Protocol-level address registry for all DotNS contracts. IDotnsProtocolRegistry public protocolRegistry; - /// @dev Reserved storage space to allow for layout changes in the future. - uint256[50] private __gap; + /// @dev Reserved storage space to allow for layout changes in the future. The retained + /// whitelist slot above holds one slot, so the gap holds 49 to keep the footprint fixed. + uint256[49] private __gap; /// @custom:oz-upgrades-unsafe-allow constructor constructor() { diff --git a/contracts/registrars/DotnsRegistrarControllerOld.sol b/contracts/registrars/DotnsRegistrarControllerOld.sol new file mode 100644 index 000000000..a6ed7b226 --- /dev/null +++ b/contracts/registrars/DotnsRegistrarControllerOld.sol @@ -0,0 +1,481 @@ +// 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 {DotnsRoleManagerOld} from "../access/DotnsRoleManagerOld.sol"; +import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol"; +import {ReentrancyGuardTransient} from "@openzeppelin/contracts/utils/ReentrancyGuardTransient.sol"; + +import {IDotnsRegistrar} from "./IDotnsRegistrar.sol"; +import {IDotnsReverseResolver} from "../resolvers/IDotnsReverseResolver.sol"; +import {IPopRules} from "../pop/IPopRules.sol"; +import {IDotnsCostModelRegistry} from "../pop/IDotnsCostModelRegistry.sol"; +import {StringUtils} from "../utils/StringUtils.sol"; +import {IDotnsRegistrarControllerOld} from "./IDotnsRegistrarControllerOld.sol"; +import {IDotnsNameEscrow} from "../escrow/IDotnsNameEscrow.sol"; +import {IStoreFactory} from "../store/IStoreFactory.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 {RegistrationUtils} from "../utils/RegistrationUtils.sol"; +import {StoreUtils} from "../utils/StoreUtils.sol"; + +/// @title Dotns Registrar Controller Old +/// @notice Allocates top-level labels using a commit reveal scheme. +/// @dev Pre-upgrade snapshot the layout diff compares the current implementation against. +/// Orchestrates allocation, PoP validation, pricing enforcement, forward registry wiring, default +/// reverse resolution, and immutable store writing. +/// +/// Tokenisation: the minted ERC721 tokenId is `uint256(node)`, where +/// `node = namehash(tldNode, labelhash)`. The registry stores a sentinel owner +/// (`address(0)`) for tokenised nodes and derives ownership from the ERC721 registrar for +/// authorisation. +/// @dev PR-scoped. This snapshot is deleted before merge with the paired upgrade slice per the +/// upgrade-PR workflow in CONTRIBUTING.md. +/// @custom:security-contact admin@parity.io +contract DotnsRegistrarControllerOld is + Initializable, + UUPSUpgradeable, + DotnsRoleManagerOld, + ReentrancyGuardTransient, + IDotnsRegistrarControllerOld +{ + using StringUtils for *; + using StoreUtils for IStoreFactory; + + /// @notice Upper bound for commitment validity to cap storage griefing risk. + uint256 public constant MAX_ALLOWED_COMMITMENT_AGE = 7 days; + + /// @notice Role identifier gating whitelist operators for the reserved pipeline. + /// @dev Declared locally so the snapshot compiles once the shared identifier is retired from + /// `DotnsConstants`; a constant occupies no storage slot, so the layout is unaffected. + bytes32 private constant WHITELIST_OPERATOR_ROLE = keccak256("DOTNS_WHITELIST_OPERATOR_ROLE"); + + /// @notice Minimum age a commitment must reach before reveal. + uint256 public minCommitmentAge; + + /// @notice Maximum age after which a commitment expires. + uint256 public maxCommitmentAge; + + /// @notice Stores Mapping of commitment hashes to timestamp committed. + mapping(bytes32 hash => uint256 timestamp) public commitments; + + /// @notice Cost-model version stamped on a commitment at commit time. + /// @dev Recorded from the registry's current version when `commit` runs, so the reveal can bind + /// a registration to the version that was current then. A caller cannot commit against an + /// arbitrary earlier, cheaper version: the reveal rejects a `pricingVersion` that differs + /// from this stamp. + mapping(bytes32 hash => uint256 version) public committedPricingVersion; + + /// @notice Whitelist for addresses allowed to call `registerReserved`. + mapping(address user => bool isWhiteListed) public whiteList; + + /// @notice Protocol-level address registry for all DotNS contracts. + IDotnsProtocolRegistry public protocolRegistry; + + /// @dev Reserved storage space to allow for layout changes in the future. + uint256[49] private __gap; + + /// @notice Restricts calls to whitelisted addresses or the owner. + /// @dev Used to gate `registerReserved`, which allows registering reserved names without + /// PoP checks or payment. Necessary so the owner (or a whitelisted operator) can seed + /// reserved names on behalf of users who are already known and verified and do not need + /// PoP checks. + modifier onlyWhiteListedOrOwner() { + _onlyWhiteListedOrOwner(); + _; + } + + /// @notice Restricts calls to the owner or a whitelist operator role holder. + modifier onlyWhitelistOperatorOrOwner() { + _checkRoleOrOwner(WHITELIST_OPERATOR_ROLE); + _; + } + + /// @custom:oz-upgrades-unsafe-allow constructor + constructor() { + _disableInitializers(); + } + + /// @notice Initialises the registrar controller. + /// @dev Callable once through the UUPS proxy; direct calls on the implementation revert + /// with @custom:reverts InvalidInitialization, and any nested call outside an active + /// initialiser scope reverts with @custom:reverts NotInitializing. Validates the + /// commitment window bounds: `minAge` must be strictly positive (otherwise + /// @custom:reverts MinCommitmentAgeZero) so a reveal cannot land in the same block as + /// its commit; `maxAge` must exceed `minAge` (otherwise + /// @custom:reverts MaxCommitmentAgeTooLow) and must stay within + /// `MAX_ALLOWED_COMMITMENT_AGE` (otherwise @custom:reverts MaxCommitmentAgeTooHigh) before + /// wiring the protocol registry. + function initialize( + IDotnsProtocolRegistry registry, + uint256 minAge, + uint256 maxAge + ) + external + initializer + { + __Ownable_init(msg.sender); + _dotnsRoleManagerInit(); + + require(minAge > 0, MinCommitmentAgeZero()); + require(maxAge > minAge, MaxCommitmentAgeTooLow()); + require(maxAge <= MAX_ALLOWED_COMMITMENT_AGE, MaxCommitmentAgeTooHigh()); + + protocolRegistry = registry; + + minCommitmentAge = minAge; + maxCommitmentAge = maxAge; + } + + /// @inheritdoc IDotnsRegistrarControllerOld + function available(string calldata label) public view override returns (bool) { + bytes32 node; + (, node) = _validatedLabelNode(label); + IDotnsRegistrar registrar = IDotnsRegistrar(protocolRegistry.get(DotnsConstants.REGISTRAR)); + return registrar.available(uint256(node)); + } + + /// @inheritdoc IDotnsRegistrarControllerOld + function makeCommitment(Registration calldata registration) + public + pure + override + returns (bytes32 commitment) + { + commitment = keccak256( + abi.encode( + registration.label, + registration.owner, + registration.secret, + registration.reserved, + registration.maxPrice, + registration.pricingVersion + ) + ); + } + + /// @inheritdoc IDotnsRegistrarControllerOld + function commit(bytes32 commitment) external override { + uint256 prior = commitments[commitment]; + require( + prior == 0 || prior + maxCommitmentAge <= block.timestamp, + UnexpiredCommitmentExists(commitment) + ); + + commitments[commitment] = block.timestamp; + committedPricingVersion[commitment] = _currentPricingVersion(); + emit NameCommitted(commitment); + } + + /// @notice Reads the cost model's current version through the protocol registry. + /// @dev Resolved at commit time so the stamp binds the version live then, not at reveal. + /// @return pricingVersion The current cost-model version. + function _currentPricingVersion() internal view returns (uint256 pricingVersion) { + return + IDotnsCostModelRegistry(protocolRegistry.get(DotnsConstants.COST_MODEL)) + .currentVersion(); + } + + /// @inheritdoc IDotnsRegistrarControllerOld + function register(Registration calldata registration) external payable override nonReentrant { + (IDotnsRegistrar registrar, bytes32 labelhash, bytes32 node) = + _requireAvailableLabel(registration.label); + _consumeCommitment(registration); + + address escrow = _escrow(); + IPopRules rules = IPopRules(protocolRegistry.get(DotnsConstants.POP_RULES)); + + uint256 tokenId = uint256(node); + bool isReclaim = registrar.exists(tokenId); + + string memory stem = rules.stripDigits(registration.label); + bool stemCanonical = stem.isSingleLabelMemory(); + // Reclaim hands the name back from a prior occupant who may hold a sibling-controller's + // stem reservation; clear it so the new registrant's stem reserve starts fresh. Non-reclaim + // paths intentionally leave an existing same-owner reservation in place so a sibling + // controller (e.g. the PoP queue head stamp) retains the slot's `controller` field through + // the refresh in `_writeReservation`. Replacing the slot from this controller would brick + // the sibling's release/advance paths. + if (stemCanonical && isReclaim) { + (address reservationOwner,) = rules.getBaseNameReservation(stem); + address expectedOwner = + IDotnsNameEscrow(payable(escrow)).getReleasePosition(tokenId).recipient; + if (reservationOwner != address(0) && reservationOwner == expectedOwner) { + rules.releaseReservationForReclaim(stem, expectedOwner); + } + } + + bool isDirect = msg.sender == registration.owner; + IPopRules.PriceWithMeta memory priced; + if (isDirect) { + priced = rules.priceWithCheckAtVersion( + registration.label, registration.owner, registration.pricingVersion + ); + } else { + priced = rules.priceWithoutCheckAtVersion( + registration.label, registration.owner, registration.pricingVersion + ); + if (priced.status == IPopRules.PopStatus.Reserved) { + (IPopRules.PopStatus required,) = rules.classifyName(registration.label); + if (required == IPopRules.PopStatus.Reserved) { + revert IPopRules.GovernanceReserved(registration.label); + } + revert IPopRules.NameReserved(registration.label); + } + require( + priced.userStatus >= priced.status, + IPopRules.OwnerStatusInsufficient( + registration.label, priced.userStatus, priced.status + ) + ); + } + + uint256 totalCharged = priced.price; + require( + totalCharged <= registration.maxPrice, + PriceExceedsMax(registration.label, totalCharged, registration.maxPrice) + ); + require(msg.value >= totalCharged, InsufficientValue()); + + IDotnsReverseResolver reverse; + bool setReverseRecord; + if (registration.reserved && isDirect) { + reverse = IDotnsReverseResolver(protocolRegistry.get(DotnsConstants.REVERSE_RESOLVER)); + setReverseRecord = bytes(reverse.nameOf(registration.owner)).length == 0; + } + + _completeRegistration( + registration, labelhash, node, priced.price, setReverseRecord, reverse, isReclaim + ); + + if (isReclaim) { + IDotnsNameEscrow(payable(escrow)).reclaim(tokenId, registration.owner); + // Reclaim hands the NFT to the new holder; rewrite the registry record so the prior + // owner's resolver pointer cannot follow the name. Must run after `escrow.reclaim` + // so the registry's `ownerOf` check sees the new holder, not the escrow. + IDotnsRegistry(protocolRegistry.get(DotnsConstants.REGISTRY)) + .setOwner(node, registration.owner); + } + + _settleEscrow(escrow, tokenId, registration.owner, isDirect, totalCharged); + + if ( + priced.status == IPopRules.PopStatus.PopLite + && priced.userStatus == IPopRules.PopStatus.PopLite && stemCanonical + ) { + rules.reserveBaseName(stem, registration.owner); + } + + if (msg.value > totalCharged) { + uint256 refund = msg.value - totalCharged; + (bool ok,) = payable(msg.sender).call{value: refund}(""); + if (ok) { + emit OverpaymentRefunded(msg.sender, refund); + } else { + IDotnsNameEscrow(payable(escrow)).creditOverpayment{value: refund}(msg.sender); + } + } + } + + /// @notice Settles every escrow side-effect of a successful registration. + /// @dev Extracted to keep `register` under the stack-depth ceiling. On a direct + /// registration the full `chargeAmount` lands in the refundable deposit position + /// keyed to `nameOwner`. On a cross-payer registration the deposit position is + /// seeded with a zero amount so the release lifecycle stays reachable, and the same + /// `chargeAmount` routes to the protocol fee pot via `depositProtocolFee` keyed to + /// `msg.sender` as the payer. + function _settleEscrow( + address escrow, + uint256 tokenId, + address nameOwner, + bool isDirect, + uint256 chargeAmount + ) + internal + { + uint256 depositAmount = isDirect ? chargeAmount : 0; + IDotnsNameEscrow(payable(escrow)).deposit{value: depositAmount}( + IDotnsNameEscrow.DepositParams({ + tokenId: tokenId, asset: address(0), amount: depositAmount, recipient: nameOwner + }) + ); + + if (!isDirect && chargeAmount > 0) { + IDotnsNameEscrow(payable(escrow)).depositProtocolFee{value: chargeAmount}( + IDotnsNameEscrow.ProtocolFeeDepositParams({ + tokenId: tokenId, payer: msg.sender, recipient: nameOwner + }) + ); + } + } + + /// @inheritdoc IDotnsRegistrarControllerOld + function isWhiteListed(address who) external view override returns (bool) { + return whiteList[who]; + } + + /// @inheritdoc IDotnsRegistrarControllerOld + function whiteListAddress( + address who, + bool whiteListStatus + ) + external + override + onlyWhitelistOperatorOrOwner + { + whiteList[who] = whiteListStatus; + emit WhiteListed(who, whiteListStatus); + } + + /// @inheritdoc IDotnsRegistrarControllerOld + function registerReserved(Registration calldata registration) + external + override + onlyWhiteListedOrOwner + nonReentrant + { + (, bytes32 labelhash, bytes32 node) = _requireAvailableLabel(registration.label); + _consumeCommitment(registration); + + IDotnsReverseResolver reverse = + IDotnsReverseResolver(protocolRegistry.get(DotnsConstants.REVERSE_RESOLVER)); + _completeRegistration(registration, labelhash, node, 0, true, reverse, false); + } + + /// @inheritdoc IERC165 + function supportsInterface(bytes4 interfaceId) + public + view + override(DotnsRoleManagerOld, IERC165) + returns (bool) + { + return interfaceId == type(IDotnsRegistrarControllerOld).interfaceId + || super.supportsInterface(interfaceId); + } + + /// @notice Validates label shape and derives `(labelhash, node)`. + /// @dev Delegates hashing to @custom:contract LabelUtils so the assembly sequence lives in + /// exactly one place across the codebase. Error ownership stays on this interface: shape + /// violations revert with `InvalidLabel()`; labels below the minimum length revert with + /// `LabelTooShort(label)` so off-chain consumers can distinguish "shape-valid but below + /// the policy minimum" from "shape-valid but already minted". + function _validatedLabelNode(string calldata label) + internal + view + returns (bytes32 labelhash, bytes32 node) + { + require(label.isSingleLabel(), InvalidLabel()); + require(bytes(label).length >= 3, LabelTooShort(label)); + (labelhash, node) = LabelUtils.deriveNode(protocolRegistry.tldNode(), label); + } + + function _requireAvailableLabel(string calldata label) + internal + view + returns (IDotnsRegistrar registrar, bytes32 labelhash, bytes32 node) + { + (labelhash, node) = _validatedLabelNode(label); + registrar = IDotnsRegistrar(protocolRegistry.get(DotnsConstants.REGISTRAR)); + require(registrar.available(uint256(node)), NameNotAvailable(label)); + } + + function _consumeCommitment(Registration calldata registration) internal { + bytes32 commitment = makeCommitment(registration); + uint256 committedAt = commitments[commitment]; + + require(committedAt != 0, CommitmentNotFound(commitment)); + require( + committedAt + minCommitmentAge <= block.timestamp, + CommitmentTooNew(commitment, committedAt + minCommitmentAge, block.timestamp) + ); + require( + committedAt + maxCommitmentAge > block.timestamp, + CommitmentTooOld(commitment, committedAt + maxCommitmentAge, block.timestamp) + ); + + uint256 stamped = committedPricingVersion[commitment]; + require( + registration.pricingVersion == stamped, + IDotnsCostModelRegistry.PricingVersionMismatch(stamped, registration.pricingVersion) + ); + + delete commitments[commitment]; + delete committedPricingVersion[commitment]; + } + + /// @notice Completes a commit-reveal registration: mints (or skips when reclaiming), + /// wires forward registry, optionally sets the reverse record, and writes the owner's + /// Store. + /// @dev On a fresh mint the triad of mint + forward-registry + store-write is delegated + /// to @custom:function RegistrationUtils.registerAndStore, the single canonical implementation + /// shared across every DotNS registration flow. On a reclaim the mint step is skipped (the + /// escrow has already moved custody) and only the registry wiring and store write run. + /// Reverse-record setting and the priced-registration event stay here because they are + /// commit-reveal-specific policy. + function _completeRegistration( + Registration calldata registration, + bytes32 labelhash, + bytes32 node, + uint256 baseCost, + bool setReverseRecord, + IDotnsReverseResolver reverse, + bool isReclaim + ) + internal + { + address labelStore; + if (!isReclaim) { + labelStore = RegistrationUtils.registerAndStore( + RegistrationUtils.RegistrationContext({ + protocolRegistry: protocolRegistry, + user: registration.owner, + label: registration.label, + labelhash: labelhash, + node: node + }) + ); + } else { + // Registry reset on reclaim is deferred until after `escrow.reclaim` runs (see + // @custom:function register) so the registry's `ownerOf` check sees the new holder. + IStoreFactory factory = + IStoreFactory(protocolRegistry.get(DotnsConstants.STORE_FACTORY)); + string memory fullName = string.concat(registration.label, protocolRegistry.tld()); + labelStore = factory.writeLabel(registration.owner, node, fullName); + } + + if (setReverseRecord) { + reverse.setReverseName( + registration.owner, string.concat(registration.label, protocolRegistry.tld()) + ); + } + + emit NameRegistered(registration.label, labelhash, registration.owner, baseCost, labelStore); + } + + /// @notice Returns the configured name escrow from the protocol registry. + function _escrow() internal view returns (address escrow) { + escrow = protocolRegistry.get(DotnsConstants.NAME_ESCROW); + require(escrow != address(0), EscrowNotConfigured()); + } + + /// @notice Returns implementation version. + /// @return versionString Current version string. + function version() external pure virtual returns (string memory versionString) { + versionString = "1.0.0"; + } + + /// @notice Internal check enforcing whitelist-or-owner access. + function _onlyWhiteListedOrOwner() internal view { + require(whiteList[msg.sender] || msg.sender == owner(), NotWhiteListedOrOwner(msg.sender)); + } + + function _isSupportedRole(bytes32 role) internal view override returns (bool supported) { + return role == WHITELIST_OPERATOR_ROLE; + } + + /// @inheritdoc UUPSUpgradeable + function _authorizeUpgrade(address newImplementation) internal override onlyOwner {} +} diff --git a/contracts/registrars/DotnsRegistrarOld.sol b/contracts/registrars/DotnsRegistrarOld.sol new file mode 100644 index 000000000..d3c7dfdb7 --- /dev/null +++ b/contracts/registrars/DotnsRegistrarOld.sol @@ -0,0 +1,431 @@ +// 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 { + ERC721Upgradeable +} from "@openzeppelin/contracts-upgradeable/token/ERC721/ERC721Upgradeable.sol"; + +import {IDotnsRegistrarOld} from "./IDotnsRegistrarOld.sol"; +import {IDotnsController} from "./IDotnsController.sol"; +import {IDotnsProtocolRegistry} from "../registry/IDotnsProtocolRegistry.sol"; + +import {IStoreFactory} from "../store/IStoreFactory.sol"; +import {ILabelStore} from "../store/ILabelStore.sol"; +import {StoreUtils} from "../utils/StoreUtils.sol"; +import {LabelUtils} from "../utils/LabelUtils.sol"; +import {StringUtils} from "../utils/StringUtils.sol"; +import {IDotnsNameEscrow} from "../escrow/IDotnsNameEscrow.sol"; +import {IPopRules} from "../pop/IPopRules.sol"; +import {DotnsConstants} from "../utils/DotnsConstants.sol"; + +/// @title Dotns Registrar +/// @notice ERC721-backed registrar implementing permanent name ownership. +/// @dev Deliberately policy-free. Transfers are supported to allow ownership changes without +/// registry hooks, and the registrar itself does not encode pricing, reservations, or PoP +/// gating; those live in the controllers and @custom:contract IPopRules. The fee-on-transfer hook +/// in `_update` is a thin enforcement layer that consults the escrow. +/// @custom:security-contact admin@parity.io +contract DotnsRegistrarOld is + Initializable, + UUPSUpgradeable, + OwnableUpgradeable, + ERC721Upgradeable, + IDotnsRegistrarOld +{ + using StoreUtils for IStoreFactory; + using StringUtils for *; + + /// @notice Mapping of authorised controllers. + /// @dev Controllers may call `register`. Keyed by the shared baseline @custom:contract + /// IDotnsController interface so the registrar doesn't depend on any specific controller shape. + /// Commit-reveal, PoP, and future controllers coexist here so long as they implement the + /// baseline interface. + /// @custom:oz-retyped-from mapping(IDotnsRegistrarController => bool) + mapping(IDotnsController controller => bool exists) public controllers; + + /// @notice Protocol-level address registry for all DotNS contracts. + /// @dev Used to resolve sibling contract addresses (store factory, controller, registry) + /// without storing individual references. + IDotnsProtocolRegistry public protocolRegistry; + + /// @dev Reserved storage space to allow for layout changes in the future. + uint256[50] private __gap; + + /// @notice Restricts function access to authorised controllers. + modifier onlyController() { + _onlyController(); + _; + } + + /// @custom:oz-upgrades-unsafe-allow constructor + constructor() { + _disableInitializers(); + } + + /// @notice Initialises the registrar. + /// @dev Uses OpenZeppelin upgradeable initialisers and is callable once through the UUPS + /// proxy; direct calls on the implementation revert with @custom:reverts InvalidInitialization + /// because `_disableInitializers` runs in the constructor, and any nested call outside an + /// active initialiser scope reverts with @custom:reverts NotInitializing. + function initialize( + string calldata name, + string calldata symbol, + IDotnsProtocolRegistry registry + ) + external + initializer + { + require(address(registry) != address(0), ProtocolRegistryRequired()); + __Ownable_init(msg.sender); + __ERC721_init(name, symbol); + protocolRegistry = registry; + } + + /// @inheritdoc IDotnsRegistrarOld + function addController(IDotnsController controller) external onlyOwner { + controllers[controller] = true; + emit ControllerAdded(controller); + } + + /// @inheritdoc IDotnsRegistrarOld + function removeController(IDotnsController controller) external onlyOwner { + controllers[controller] = false; + emit ControllerRemoved(controller); + } + + /// @inheritdoc IDotnsRegistrarOld + function available(uint256 id) public view override returns (bool isAvailable) { + address holder = _ownerOf(id); + if (holder == address(0)) return true; + + address escrow = protocolRegistry.get(DotnsConstants.NAME_ESCROW); + if (holder != escrow) return false; + + // Escrow custody on its own does not mean registrable: a released name inside its redeem + // window still belongs to its previous holder. The escrow owns that lifecycle and is asked + // directly, so availability here and reclaimability there cannot drift apart and start + // advertising names whose registration would revert. + return IDotnsNameEscrow(payable(escrow)).isReclaimable(id); + } + + /// @inheritdoc IDotnsRegistrarOld + function register( + uint256 id, + address owner, + string calldata label + ) + external + override + onlyController + { + // `available` returns true both for unminted ids and for ids currently held by escrow + // (so the controller can route through `escrow.reclaim`). `register` only handles the + // fresh-mint branch; the escrow-held branch must use the reclaim path and is rejected + // here with the typed error so callers do not see OZ's `ERC721InvalidSender(0)`. + require(!_exists(id), NameNotAvailable(id)); + require(owner != protocolRegistry.get(DotnsConstants.NAME_ESCROW), InvalidOwner()); + // Empty labels are an intentional gateway-cold path (substrate Root cannot deploy a + // `LabelStore` under `pallet-revive`, so the controller stashes a pending claim and the + // user settles via @custom:function IDotnsPopController.claimLabelStore later). Non-empty + // labels must still be canonical so the transfer-floor lookup in `_quoteTransferFee` + // cannot brick the token by reverting on a malformed stem. + require(bytes(label).length == 0 || label.isSingleLabel(), InvalidLabel()); + _mint(owner, id); + if (bytes(label).length != 0) _writeOwnerLabel(owner, id, label); + emit NameRegistered(id, owner); + } + + /// @inheritdoc IDotnsRegistrarOld + function labelOf(uint256 tokenId) external view override returns (string memory) { + address holder = _ownerOf(tokenId); + if (holder == address(0)) return ""; + return LabelUtils.stripTld(protocolRegistry.tld(), _readLabel(tokenId, holder)); + } + + /// @inheritdoc IDotnsRegistrarOld + function quoteTransferFee( + uint256 tokenId, + address to + ) + external + view + override + returns (uint256 requiredFee) + { + require(to != address(0), ERC721InvalidReceiver(address(0))); + + address from = ownerOf(tokenId); + (,, requiredFee) = _quoteTransferFee(from, to, tokenId); + } + + /// @inheritdoc IDotnsRegistrarOld + function transferFrom( + address from, + address to, + uint256 tokenId + ) + public + payable + override(ERC721Upgradeable, IDotnsRegistrarOld) + { + super.transferFrom(from, to, tokenId); + } + + /// @inheritdoc IDotnsRegistrarOld + function safeTransferFrom( + address from, + address to, + uint256 tokenId + ) + public + payable + override(ERC721Upgradeable, IDotnsRegistrarOld) + { + super.safeTransferFrom(from, to, tokenId, ""); + } + + /// @inheritdoc IDotnsRegistrarOld + function safeTransferFrom( + address from, + address to, + uint256 tokenId, + bytes memory data + ) + public + payable + override(ERC721Upgradeable, IDotnsRegistrarOld) + { + super.safeTransferFrom(from, to, tokenId, data); + } + + /// @notice Returns implementation version. + /// @return versionString Current version string. + function version() external pure virtual returns (string memory versionString) { + versionString = "1.0.0"; + } + + /// @inheritdoc IDotnsRegistrarOld + function exists(uint256 tokenId) external view override returns (bool tokenExists) { + tokenExists = _exists(tokenId); + } + + /// @notice Checks whether a token ID exists. + function _exists(uint256 tokenId) internal view returns (bool) { + return _ownerOf(tokenId) != address(0); + } + + /// @notice Internal function to check for controller access. + function _onlyController() internal view { + require(controllers[IDotnsController(msg.sender)], NotController(msg.sender)); + } + + /// @inheritdoc ERC721Upgradeable + function _update( + address to, + uint256 tokenId, + address auth + ) + internal + override + returns (address from) + { + from = super._update(to, tokenId, auth); + + // Mints and self-transfers carry no economic event. Reject any attached value on those + // paths because nothing forwards it onward, which would otherwise trap the funds in this + // contract permanently (no `receive`, no rescue path). + if (from == address(0) || from == to) { + require(msg.value == 0, UnexpectedValue()); + return from; + } + + // Resolve every registry-sourced dependency once and thread it into the helpers so a + // single transfer pays one external lookup per key rather than three. + IDotnsProtocolRegistry registry = protocolRegistry; + address escrow = registry.get(DotnsConstants.NAME_ESCROW); + require(escrow != address(0), EscrowNotConfigured()); + IStoreFactory factory = IStoreFactory(registry.get(DotnsConstants.STORE_FACTORY)); + + bool isEscrowTouching = to == escrow || from == escrow; + // Skip mirroring on escrow-touching paths: release deposits the NFT into custody where + // a `LabelStore` would be wasted and reclaim hands it back to a fresh-mint controller + // that writes the label through its own flow. + if (!isEscrowTouching) { + _syncRecipientStore(factory, to, from, tokenId); + } + + (uint256 transferFee, uint256 requiredFee) = + _quoteTransferFeeFor(registry, factory, isEscrowTouching, from, to, tokenId); + if (requiredFee != 0) { + require(msg.value >= requiredFee, TransferFeeRequired(tokenId, to, requiredFee)); + } + + // Deposits follow the NFT, not the depositor: every transfer that moves a name off the + // prior position recipient rebinds the escrow position to the new holder so the locked + // deposit (when funded) and the lifecycle marker (when zero-amount) both travel with the + // name. Escrow-touching transfers are excluded because the escrow is mid-call and its + // non-reentrancy guard would reject a re-entry; release/reclaim manage the position + // directly. + bool positionSyncNeeded; + if (!isEscrowTouching) { + IDotnsNameEscrow.ReleasePosition memory position = + IDotnsNameEscrow(payable(escrow)).getReleasePosition(tokenId); + positionSyncNeeded = position.recipient != address(0) && to != position.recipient; + } + + if (requiredFee == 0 && msg.value == 0 && !positionSyncNeeded) { + return from; + } + + IDotnsNameEscrow(payable(escrow)).chargeTransferFee{value: msg.value}( + IDotnsNameEscrow.ChargeTransferFeeParams({ + tokenId: tokenId, transferFee: transferFee, payer: msg.sender, to: to + }) + ); + + return from; + } + + /// @notice Mirrors the sender's label entry into the recipient's `LabelStore`. + function _syncRecipientStore( + IStoreFactory factory, + address to, + address from, + uint256 tokenId + ) + internal + { + string memory fullName = _readLabelFor(factory, tokenId, from); + if (bytes(fullName).length == 0) { + // Sender has no label entry for the token (typical of gateway-cold PoP mints). + // Nothing to mirror, so do not deploy a recipient store; downstream writes are + // demand-deploy through `StoreUtils.ensureLabelStore`. + return; + } + factory.writeLabel(to, bytes32(tokenId), fullName); + } + + /// @notice Reads the full name (`label.tld`) for `tokenId` from `holder`'s `LabelStore` using + /// a caller-supplied factory. + function _readLabelFor( + IStoreFactory factory, + uint256 tokenId, + address holder + ) + private + view + returns (string memory fullName) + { + address store = factory.getLabelStore(holder); + if (store == address(0)) return ""; + return ILabelStore(store).getLabel(bytes32(tokenId)); + } + + /// @notice Reads the full name for `tokenId` from `holder`'s `LabelStore` via fresh lookups. + /// @dev Used by external view functions where caching the factory is not yet established; + /// the hot transfer path uses @custom:function _readLabelFor with a cached factory. + function _readLabel( + uint256 tokenId, + address holder + ) + private + view + returns (string memory fullName) + { + return _readLabelFor(_storeFactory(), tokenId, holder); + } + + /// @notice Resolves the configured name escrow address from the protocol registry. + function _escrow() private view returns (address escrow) { + escrow = protocolRegistry.get(DotnsConstants.NAME_ESCROW); + } + + /// @notice Resolves the configured PoP rules contract from the protocol registry. + function _popRules() private view returns (IPopRules rules) { + rules = IPopRules(protocolRegistry.get(DotnsConstants.POP_RULES)); + } + + /// @notice Resolves the configured store factory from the protocol registry. + function _storeFactory() private view returns (IStoreFactory factory) { + factory = IStoreFactory(protocolRegistry.get(DotnsConstants.STORE_FACTORY)); + } + + /// @notice Writes the canonical full name into `owner`'s `LabelStore` keyed by + /// `bytes32(tokenId)`. + /// @dev Caller (@custom:function register) is responsible for short-circuiting on empty label; + /// the factory is a protocol-critical dependency and is assumed non-zero (a zero return from + /// the registry would have already broken every other call site). + function _writeOwnerLabel(address owner, uint256 tokenId, string calldata label) private { + _storeFactory() + .writeLabel(owner, bytes32(tokenId), string.concat(label, protocolRegistry.tld())); + } + + /// @notice Quotes the friction fee required for a transfer. + /// @dev Required fee is the name's own price returned by @custom:function + /// PopRules.transferFloor. It is paid by the sender on every downward or cross-reach transfer + /// and settles to the + /// protocol fee pot. Any prior deposit travels with the NFT: the escrow rebinds the position to + /// the new holder rather than refunding the sender, so transferring a funded name forfeits the + /// locked deposit to the recipient. Self-transfers and escrow-touching transfers return zero. + function _quoteTransferFee( + address from, + address to, + uint256 tokenId + ) + private + view + returns (address escrow, uint256 transferFee, uint256 requiredFee) + { + if (from == to) return (address(0), 0, 0); + + IDotnsProtocolRegistry registry = protocolRegistry; + escrow = registry.get(DotnsConstants.NAME_ESCROW); + require(escrow != address(0), EscrowNotConfigured()); + + bool isEscrowTouching = to == escrow || from == escrow; + IStoreFactory factory = IStoreFactory(registry.get(DotnsConstants.STORE_FACTORY)); + (transferFee, requiredFee) = + _quoteTransferFeeFor(registry, factory, isEscrowTouching, from, to, tokenId); + } + + /// @notice Quotes the transfer floor reusing a caller-cached registry and store factory. + /// @dev Hot-path variant used by @custom:function _update. Returns `(0, 0)` for any + /// escrow-touching move or when the sender holds no label entry; otherwise reads the canonical + /// label and delegates to @custom:function PopRules.transferFloor. + function _quoteTransferFeeFor( + IDotnsProtocolRegistry registry, + IStoreFactory factory, + bool isEscrowTouching, + address from, + address to, + uint256 tokenId + ) + private + view + returns (uint256 transferFee, uint256 requiredFee) + { + if (isEscrowTouching) return (0, 0); + + string memory fullName = _readLabelFor(factory, tokenId, from); + // No label means there is no label-derived price to charge against; treat as a zero-fee + // move (typical of gateway-cold PoP mints that have not yet claimed a `LabelStore`). + if (bytes(fullName).length == 0) return (0, 0); + // A stored full name always carries the registry TLD suffix, so an empty strip means the + // name is malformed for this registry (a wrong or missing suffix); fail loudly rather than + // mis-pricing the move as zero-fee. + string memory label = LabelUtils.stripTld(registry.tld(), fullName); + require(bytes(label).length != 0, InvalidLabel()); + + transferFee = + IPopRules(registry.get(DotnsConstants.POP_RULES)).transferFloor(label, from, to); + requiredFee = transferFee; + } + + /// @inheritdoc UUPSUpgradeable + function _authorizeUpgrade(address newImplementation) internal override onlyOwner {} +} diff --git a/contracts/registrars/IDotnsPopControllerOld.sol b/contracts/registrars/IDotnsPopControllerOld.sol new file mode 100644 index 000000000..841b1443b --- /dev/null +++ b/contracts/registrars/IDotnsPopControllerOld.sol @@ -0,0 +1,497 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.34; + +import {IDotnsController} from "./IDotnsController.sol"; + +/// @title IDotnsPopControllerOld +/// @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/registrars/IDotnsRegistrarControllerOld.sol b/contracts/registrars/IDotnsRegistrarControllerOld.sol new file mode 100644 index 000000000..a7cc2ea91 --- /dev/null +++ b/contracts/registrars/IDotnsRegistrarControllerOld.sol @@ -0,0 +1,194 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.34; + +import {IDotnsController} from "./IDotnsController.sol"; +import {IPopRules} from "../pop/IPopRules.sol"; + +/// @title Dotns Registrar Controller Old +/// @notice Interface for registering top-level labels using a commit reveal scheme. +/// @dev Pre-upgrade snapshot of the controller interface, pinned so the layout diff has a +/// compilable predecessor for the public surface. Defines allocation only; forward resolution, +/// reverse lookup, pricing mechanics, PoP validation, and store writing are handled by external +/// contracts. Users commit a hash of registration parameters and, after a minimum delay, reveal +/// the same parameters to register. Implementations write the successfully registered name into +/// the user's Store to create an immutable on-chain record that doubles as a quick lookup for all +/// names registered. +/// @dev PR-scoped. This snapshot is deleted before merge with the paired upgrade slice per the +/// upgrade-PR workflow in CONTRIBUTING.md. +/// @custom:security-contact admin@parity.io +interface IDotnsRegistrarControllerOld is IDotnsController { + /// @notice Parameters used to generate and reveal a commitment. + /// @dev All fields must match exactly between commitment and reveal. + /// @param label Label being registered (e.g. "alice"). + /// @param owner Beneficiary the registered name is minted to. + /// @param secret Caller-chosen entropy that hides the registration intent in the commit + /// hash; revealed verbatim at registration time. + /// @param reserved True when the registration flows through the whitelisted reserved + /// pipeline (`registerReserved`); false for the standard public flow (`register`). + /// @param maxPrice Ceiling in wei the caller accepts for this registration; a reveal charged + /// above it reverts, closing the gap between the price at commit and the price at reveal. + /// @param pricingVersion Cost-model version the caller committed to; the reveal prices the name + /// at this version, so a model change between commit and reveal leaves the amount unchanged. It + /// must equal the version current when `commit` ran, which that call stamps on the commitment; + /// a reveal whose `pricingVersion` differs reverts, so the caller cannot bind an earlier, + /// cheaper version. + struct Registration { + string label; + address owner; + bytes32 secret; + bool reserved; + uint256 maxPrice; + uint256 pricingVersion; + } + + /// @notice Emitted when a commitment is submitted. + event NameCommitted(bytes32 indexed commitment); + + /// @notice Emitted when a name is successfully registered. + /// @param baseCost The price returned by the oracle for this registration. + /// @param store The Store instance used to persist an immutable registration record. + event NameRegistered( + string indexed label, + bytes32 indexed labelhash, + address indexed owner, + uint256 baseCost, + address store + ); + + /// @notice Emitted when an address is added to or removed from the whitelist. + event WhiteListed(address indexed who, bool indexed whiteListStatus); + + /// @notice Emitted when overpayment is refunded to the payer at registration entry. + event OverpaymentRefunded(address indexed payer, uint256 amount); + + /// @notice Thrown when the caller is not whitelisted or the owner. + error NotWhiteListedOrOwner(address caller); + + /// @notice Thrown when an unexpired commitment already exists. + error UnexpiredCommitmentExists(bytes32 commitment); + + /// @notice Thrown when revealing a commitment that does not exist. + error CommitmentNotFound(bytes32 commitment); + + /// @notice Thrown when a commitment is revealed before the minimum age. + error CommitmentTooNew(bytes32 commitment, uint256 minTime, uint256 currentTime); + + /// @notice Thrown when a commitment has expired. + error CommitmentTooOld(bytes32 commitment, uint256 maxTime, uint256 currentTime); + + /// @notice Thrown when attempting to register an unavailable name. + error NameNotAvailable(string label); + + /// @notice Thrown when a label is below the minimum-length policy. + /// @dev Distinct from @custom:reverts NameNotAvailable so off-chain consumers can tell a + /// too-short label apart from a name that is already minted. + /// @param label Caller-supplied label that failed the minimum-length policy. + error LabelTooShort(string label); + + /// @notice Thrown when a label is not a canonical lowercase ASCII DNS label. + error InvalidLabel(); + + /// @notice Thrown when supplied payment is insufficient. + error InsufficientValue(); + + /// @notice Thrown when the total charge exceeds the ceiling the caller committed to. + /// @param label Label whose charge exceeded the ceiling. + /// @param charged Total charge computed at reveal. + /// @param maxPrice Ceiling the caller committed to. + error PriceExceedsMax(string label, uint256 charged, uint256 maxPrice); + + /// @notice Thrown when escrow is not configured in the protocol registry. + error EscrowNotConfigured(); + + /// @notice Thrown when min commitment age is zero, which would allow same-block + /// commit-reveal and defeat the front-running guard. + error MinCommitmentAgeZero(); + + /// @notice Thrown when max commitment age is invalid (must be > minCommitmentAge). + error MaxCommitmentAgeTooLow(); + + /// @notice Thrown when max commitment age is invalid (exceeds implementation limit). + error MaxCommitmentAgeTooHigh(); + + /// @notice Returns whether a label is available for registration. + /// @dev Validates the canonical DNS-label shape (otherwise @custom:reverts InvalidLabel) + /// and rejects labels below the minimum-length policy with + /// @custom:reverts LabelTooShort before checking ERC721 availability on the registrar. + function available(string calldata label) external view returns (bool isAvailable); + + /// @notice Computes the commitment hash for a registration. + /// @dev Uses `abi.encode` so the variable-width `label` is length-prefixed and the boundary + /// between `label` and the fixed-width `owner`, `secret`, `reserved`, `maxPrice`, and + /// `pricingVersion` fields is unambiguous, binding the commitment to the exact tuple. The + /// price ceiling and cost-model version are part of that tuple, so neither can be altered + /// between commit and reveal. + function makeCommitment(Registration calldata registration) + external + pure + returns (bytes32 commitment); + + /// @notice Submits a commitment for a future registration. + /// @dev Idempotent over expiry: re-committing an unexpired hash reverts with + /// @custom:reverts UnexpiredCommitmentExists (front-running guard); a hash whose stored + /// timestamp has passed `maxCommitmentAge` overwrites the slot so storage cannot be + /// permanently griefed. The expiry boundary is inclusive on the commit side + /// (`committedAt + maxCommitmentAge <= block.timestamp` overwrites) and exclusive on the + /// reveal side (`register` rejects at the same instant with @custom:reverts + /// CommitmentTooOld), so the slot is overwritable from exactly the timestamp at which + /// reveal begins rejecting it. Stamps the cost model's current version on the commitment, so + /// the reveal binds to the version live now and rejects a `pricingVersion` bound to an earlier + /// one with @custom:reverts PricingVersionMismatch. Emits @custom:emits NameCommitted on + /// success. + function commit(bytes32 commitment) external; + + /// @notice Registers a name after the commitment delay. + /// @dev Validates the label shape (otherwise @custom:reverts InvalidLabel), rejects labels + /// below the minimum length policy (@custom:reverts LabelTooShort), and ERC721 availability + /// (otherwise @custom:reverts NameNotAvailable), then consumes the prior commitment, which + /// fails with @custom:reverts CommitmentNotFound when no commitment exists for the supplied + /// registration, @custom:reverts CommitmentTooNew before `minCommitmentAge`, and + /// @custom:reverts CommitmentTooOld past `maxCommitmentAge`, and finally resolves the + /// configured escrow address from the protocol registry (otherwise + /// @custom:reverts EscrowNotConfigured). Splits on direct vs cross-payer at + /// `msg.sender == registration.owner`. The direct path runs `priceWithCheck` (personhood + /// + reservation gate) and routes the charge to a refundable escrow deposit owned by + /// `registration.owner`. The cross-payer path skips the personhood revert in + /// `priceWithCheck` but applies it directly via @custom:reverts OwnerStatusInsufficient + /// when the owner's recorded tier does not meet the label's required tier, and still + /// rejects governance-reserved labels with @custom:reverts GovernanceReserved and live + /// cross-user stem reservations with @custom:reverts NameReserved. The cross-payer charge is + /// the owner-side registration price; the path applies no separate transfer friction. The + /// charge routes to the escrow protocol fee pot while seeding a zero-amount deposit slot so + /// the release lifecycle stays reachable. The reveal prices the name at the committed + /// `pricingVersion`, so a model change between commit and reveal leaves the amount unchanged, + /// and rejects a total charge above the committed ceiling with @custom:reverts PriceExceedsMax + /// before checking payment. The caller must supply at least the charge + /// (otherwise @custom:reverts InsufficientValue); any overpayment is pushed back to + /// `msg.sender` inline and, on failure, credited to the escrow's pull-payment ledger so + /// contract receivers cannot block registration. Emits @custom:emits OverpaymentRefunded + /// on the inline branch, the escrow's own @custom:emits OverpaymentRefunded on the pull + /// fallback, and @custom:emits NameRegistered on success. + function register(Registration calldata registration) external payable; + + /// @notice Registers a name after the commitment delay. + /// @dev Whitelisted issuance path used to seed reserved labels at zero base cost: skips the + /// PoP price check and the escrow deposit, but reuses the same commit-reveal pipeline so + /// the same anti-front-running guarantees apply. Restricted to whitelisted callers and the + /// owner (otherwise @custom:reverts NotWhiteListedOrOwner). Validates the label shape + /// (otherwise @custom:reverts InvalidLabel) and ERC721 availability (otherwise + /// @custom:reverts NameNotAvailable), then consumes the prior commitment, which fails with + /// @custom:reverts CommitmentNotFound, @custom:reverts CommitmentTooNew, or + /// @custom:reverts CommitmentTooOld under the same conditions as @custom:function register. + /// Emits + /// @custom:emits NameRegistered on success. + function registerReserved(Registration calldata registration) external; + + /// @notice Checks if the given address is whitelisted to call `registerReserved`. + function isWhiteListed(address who) external view returns (bool isWhiteListed); + + /// @notice Adds or removes an address from the whitelist for `registerReserved`. + /// @dev Callable by the owner or an account holding `DotnsConstants.WHITELIST_OPERATOR_ROLE`; + /// any other caller reverts with @custom:reverts IDotnsRoleManagerOld.NotRoleOrOwner. Emits + /// @custom:emits WhiteListed on success. + function whiteListAddress(address who, bool whiteListStatus) external; +} diff --git a/contracts/registrars/IDotnsRegistrarOld.sol b/contracts/registrars/IDotnsRegistrarOld.sol new file mode 100644 index 000000000..0539a179c --- /dev/null +++ b/contracts/registrars/IDotnsRegistrarOld.sol @@ -0,0 +1,172 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.34; + +import {IERC721} from "@openzeppelin/contracts-upgradeable/token/ERC721/ERC721Upgradeable.sol"; +import {IDotnsController} from "./IDotnsController.sol"; + +/// @title Dotns Registrar +/// @notice ERC721-backed ownership for DotNS names with controller-gated registration. +/// @dev Intentionally minimal and policy-free. Provides ERC721 ownership for registered name +/// token IDs and controller-gated registration; pricing, PoP enforcement, and flow-specific +/// policy live in the controllers. +/// @custom:security-contact admin@parity.io +interface IDotnsRegistrarOld is IERC721 { + /// @notice Thrown when a name is already registered. + error NameNotAvailable(uint256 tokenId); + + /// @notice Thrown when the caller is not an authorised controller. + error NotController(address caller); + + /// @notice Thrown when the protocol registry has no escrow address configured. + error EscrowNotConfigured(); + + /// @notice Thrown when a standard ERC721 transfer is attempted but the recipient + /// tier requires a non-zero transfer fee and the caller forwarded no `msg.value`. + error TransferFeeRequired(uint256 tokenId, address to, uint256 requiredFee); + + /// @notice Thrown when @custom:function initialize is called with the zero address as + /// the protocol registry. + error ProtocolRegistryRequired(); + + /// @notice Thrown when a mint, burn, or self-transfer carries `msg.value`. None of those + /// paths forward value onward, so attached value would be permanently trapped. + error UnexpectedValue(); + + /// @notice Thrown when @custom:function register is called with the escrow address as + /// `owner`, which would mint directly into escrow custody with no @custom:struct + /// ReleasePosition recorded. + error InvalidOwner(); + + /// @notice Thrown when @custom:function register receives an empty or non-canonical + /// label. + error InvalidLabel(); + + /// @notice Emitted when a name is registered. + event NameRegistered(uint256 indexed id, address indexed owner); + + /// @notice Emitted when a controller is added. + /// @dev Typed as the shared baseline @custom:contract IDotnsController so the commit-reveal + /// controller and the PoP controller (and any future controller) all fit the same signature + /// without + /// the registrar depending on any specific controller interface. + event ControllerAdded(IDotnsController indexed controller); + + /// @notice Emitted when a controller is removed. + event ControllerRemoved(IDotnsController indexed controller); + + /// @notice Returns whether a registration call may proceed for `id`. + /// @dev Signals two distinct paths to the controller. Returns `true` when the owner slot is + /// empty (a fresh @custom:function register call may mint) OR when the current owner is + /// the configured escrow and the released position's redeem window has elapsed (the + /// controller must then route through @custom:function IDotnsNameEscrow.reclaim instead of + /// @custom:function register, because `register` calls `_mint` which rejects existing + /// tokens). All other holders return `false`. The controller distinguishes the two `true` + /// cases via @custom:function exists. + /// Escrow custody inside the redeem window returns `false`: that window belongs to the + /// previous holder, who may still @custom:function IDotnsNameEscrow.redeem the name, and + /// reclaim would revert until it elapses. Clients wanting the exact moment a released name + /// becomes registrable should read `redeemableUntil` from + /// @custom:function IDotnsNameEscrow.getReleasePosition. + function available(uint256 id) external view returns (bool isAvailable); + + /// @notice Registers a name permanently. + /// @dev Permanence is by construction: there is no `expire`, `renew`, or `release` path on + /// the registrar. Custody only moves via ERC721 transfers (which the registrar polices via + /// the fee-on-transfer hook) or via escrow reclaim. Restricted to authorised controllers + /// (otherwise @custom:reverts NotController) and rejects ids that are not available + /// (otherwise @custom:reverts NameNotAvailable). Emits @custom:emits NameRegistered on + /// success. + /// @param label The human-readable label string (e.g. "alice"). + function register(uint256 id, address owner, string calldata label) external; + + /// @notice Returns whether a given token id has been minted. + function exists(uint256 tokenId) external view returns (bool tokenExists); + + /// @notice Adds an authorised controller. + /// @dev Typed against the baseline `IDotnsController` (not a concrete subtype) so a single + /// authorisation surface accepts every controller flavour (commit-reveal, PoP gateway, + /// future variants) without per-flavour setters. Owner-gated (otherwise + /// @custom:reverts OwnableUnauthorizedAccount); emits @custom:emits ControllerAdded on + /// success. + function addController(IDotnsController controller) external; + + /// @notice Removes an authorised controller. + /// @dev Mirrors the @custom:function addController baseline-typed signature so any registered + /// controller can + /// be revoked through the same entry point. Owner-gated (otherwise + /// @custom:reverts OwnableUnauthorizedAccount); emits @custom:emits ControllerRemoved on + /// success. + function removeController(IDotnsController controller) external; + + /// @notice Returns whether `controller` is currently authorised to call + /// @custom:function register. + /// @param controller Candidate controller. + /// @return authorised True when `controller` was added via @custom:function addController and + /// has not been removed. + function controllers(IDotnsController controller) external view returns (bool authorised); + + /// @notice Returns the human-readable label a token was registered with. + /// @dev Canonical state source for the label string; any client that holds a node or + /// tokenId can resolve the original label in one view call without scanning registration + /// events. Returns the empty string when the token does not exist. + function labelOf(uint256 tokenId) external view returns (string memory label); + + /// @notice Quotes the additional native fee required to transfer a token to `to`. + /// @dev Returns the fee from @custom:function PopRules.transferFloor: the name's own price + /// as the maximum of (i) the reach component charged when the recipient does not meet + /// the label's required tier and (ii) the downgrade component charged when the + /// recipient tier is strictly below the sender tier. Self-transfers and + /// escrow-touching transfers (release into escrow, reclaim out of escrow) return + /// zero. A token whose sender has no stored label also returns zero because there + /// is no label-derived price to charge against; this covers gateway-cold PoP mints + /// (the controller passes an empty label to @custom:function register so substrate + /// Root does not have to deploy a `LabelStore`) until the user settles via + /// @custom:function IDotnsPopController.claimLabelStore. Because settlement writes + /// the label into the original claimant's store, a transfer that happens before + /// settlement leaves the recipient with no label entry and the zero-fee branch + /// persists for that token under all future holders. A token registered with no + /// label that is moved off-chain prior to settlement therefore carries no PoP-tier + /// transfer friction. Off-chain consumers integrating PoP mints should treat + /// @custom:function claimLabelStore as a prerequisite for accurate transfer-time + /// pricing on gateway-issued names. Rejects a zero `to` with + /// @custom:reverts ERC721InvalidReceiver, an unminted `tokenId` with + /// @custom:reverts ERC721NonexistentToken via the underlying `ownerOf`, and requires + /// the protocol registry to have an escrow configured (otherwise + /// @custom:reverts EscrowNotConfigured). Returns zero when the protocol registry + /// has no `STORE_FACTORY` configured because no label-derived price is reachable. + function quoteTransferFee( + uint256 tokenId, + address to + ) + external + view + returns (uint256 requiredFee); + + /// @inheritdoc IERC721 + /// @dev The registrar's `_update` hook consults @custom:function PopRules.transferFloor + /// to compute the required transfer fee; if the caller does not forward at least that + /// amount as `msg.value`, the transfer reverts with @custom:reverts TransferFeeRequired. + /// The `payable` modifier on every transfer overload exists so the fee can be forwarded + /// in the same call. + function safeTransferFrom( + address from, + address to, + uint256 tokenId, + bytes calldata data + ) + external + payable + override; + + /// @inheritdoc IERC721 + /// @dev Subject to the same fee-on-transfer gate as the four-argument overload; reverts with + /// @custom:reverts TransferFeeRequired when the recipient owes a non-zero transfer fee and + /// the caller has not forwarded it as `msg.value`. + function safeTransferFrom(address from, address to, uint256 tokenId) external payable override; + + /// @inheritdoc IERC721 + /// @dev Subject to the same fee-on-transfer gate as the safe overloads; reverts with + /// @custom:reverts TransferFeeRequired when the recipient owes a non-zero transfer fee and + /// the caller has not forwarded it as `msg.value`. + function transferFrom(address from, address to, uint256 tokenId) external payable override; +} diff --git a/contracts/store/ILabelStoreOld.sol b/contracts/store/ILabelStoreOld.sol new file mode 100644 index 000000000..0f3c50336 --- /dev/null +++ b/contracts/store/ILabelStoreOld.sol @@ -0,0 +1,124 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.34; + +import {IDotnsStore} from "./IDotnsStore.sol"; + +/// @title ILabelStoreOld +/// @notice Interface for the per-user DotNS label store. +/// @dev The `LabelStore` is the protocol-managed half of the per-user storage pair: +/// write-only by addresses registered in the protocol registry, read-only by +/// everyone else, and permanently locked per `labelhash` on first write. It +/// holds registration records only; every other per-name category (reverse, +/// content, forward address, chat key, lite link) lives on a dedicated +/// resolver, not here. +/// @dev PR-scoped pinned snapshot: the upgrade layout diff compares the new `LabelStore` +/// implementation against @custom:contract LabelStoreOld, which implements this interface. +/// Removed before merge with the paired upgrade script and fork test per CONTRIBUTING.md. +/// @custom:security-contact admin@parity.io +interface ILabelStoreOld is IDotnsStore { + /// @notice Emitted when a label is stored for the first (and only) time under a given + /// labelhash. @param owner The user this store is bound to. + /// @param labelhash The labelhash key. + /// @param label The stored label string (typically the full name, e.g. "alice.dot"). + event LabelStored(address indexed owner, bytes32 indexed labelhash, string label); + + /// @notice Thrown when a caller that is not currently protocol-registered attempts a write. + /// @param caller The msg.sender that failed the `isRegisteredAddress` check. + error NotAuthorised(address caller); + + /// @notice Thrown when `initialize` is called with a zero user address. + /// @param user The invalid user argument. + error InvalidUser(address user); + + /// @notice Thrown when `initialize` is called with a zero protocol registry address. + /// @param protocolRegistry The invalid registry argument. + error InvalidProtocolRegistry(address protocolRegistry); + + /// @notice Thrown when `storeLabel` is called with a zero labelhash. + /// @param labelhash The invalid labelhash argument. + error InvalidLabel(bytes32 labelhash); + + /// @notice Thrown when `storeLabel` is called for a labelhash already present in the index. + /// @dev Labels are write-once and permanently locked on first store, so any second write + /// for the same labelhash fails with this error regardless of caller or session. + /// @param labelhash The conflicting labelhash. + error LabelAlreadyExists(bytes32 labelhash); + + /// @notice Initialises the store, binding it permanently to `user_` and `protocolRegistry_`. + /// @dev Callable exactly once via `Initializable`; both parameters are immutable post-call. + /// `user_` must be non-zero, otherwise @custom:reverts InvalidUser. + /// `protocolRegistry_` must be non-zero, otherwise @custom:reverts + /// InvalidProtocolRegistry. @param user_ The user this store is bound to forever. + /// @param protocolRegistry_ The protocol registry used to authorise writers. + function initialize(address user_, address protocolRegistry_) external; + + /// @notice Records a label under `labelhash` and locks the slot permanently. + /// @dev Gated to addresses currently registered in the protocol registry, otherwise + /// @custom:reverts NotAuthorised. `labelhash` must be non-zero, otherwise + /// @custom:reverts InvalidLabel. The slot must not already hold an entry, otherwise + /// @custom:reverts LabelAlreadyExists; the write is permanent so any second call + /// reverts. Emits @custom:emits LabelStored on the single successful write. + /// @param labelhash The labelhash key. + /// @param label The label string to store. + function storeLabel(bytes32 labelhash, string calldata label) external; + + /// @notice Returns the protocol registry this store queries for write authorisation. + /// @return protocolRegistry_ The registry address. + function protocolRegistry() external view returns (address protocolRegistry_); + + /// @notice Returns true iff a label has been stored under `labelhash`. + /// @param labelhash The labelhash to check. + /// @return exists True iff the slot holds a label. + function hasLabel(bytes32 labelhash) external view returns (bool exists); + + /// @notice Returns true iff the slot for `labelhash` is permanently locked. + /// @dev Always equal to `hasLabel` in the current design; exposed explicitly so future + /// implementations behind the beacon can distinguish "stored" from "locked" if needed. + /// @param labelhash The labelhash to check. + /// @return locked True iff the slot is locked. + function isLocked(bytes32 labelhash) external view returns (bool locked); + + /// @notice Returns the stored label for `labelhash`, or the empty string if none. + /// @param labelhash The labelhash to look up. + /// @return label The stored label string. + function getLabel(bytes32 labelhash) external view returns (string memory label); + + /// @notice Returns the total number of labels ever stored. + /// @return count Current length of the insertion-order list. + function getLabelCount() external view returns (uint256 count); + + /// @notice Returns the human-readable label at the given insertion-order index. + /// @dev Primary read for "give me my names"; does not require the caller to know any + /// labelhash. For the underlying labelhash key see @custom:function getLabelhashAt. + /// @param index Zero-based index into the insertion-order list. + /// @return label The stored label string at `index`. + function getLabelAt(uint256 index) external view returns (string memory label); + + /// @notice Returns the labelhash at the given insertion-order index. + /// @param index Zero-based index into the insertion-order list. + /// @return labelhash The labelhash at `index`. + function getLabelhashAt(uint256 index) external view returns (bytes32 labelhash); + + /// @notice Paginated read returning just the stored labels, in insertion order. + /// @dev Primary bulk read for "give me all my names". Callers never need to touch + /// labelhashes. Length is `min(limit, getLabelCount() - offset)`; + /// `offset >= getLabelCount()` returns an empty array (not a revert). + /// @param offset Start index. + /// @param limit Maximum entries to return. + /// @return labels Slice of label strings. + function getLabels(uint256 offset, uint256 limit) external view returns (string[] memory labels); + + /// @notice Paginated read over the labelhash keys, in insertion order. + /// @dev Advanced read for callers that need the raw labelhash keys. Symmetric with + /// @custom:function getLabels; same indices map to the same entries. + /// @param offset Start index. + /// @param limit Maximum entries to return. + /// @return labelhashes Slice of labelhash keys. + function getLabelhashes( + uint256 offset, + uint256 limit + ) + external + view + returns (bytes32[] memory labelhashes); +} diff --git a/contracts/store/LabelStoreOld.sol b/contracts/store/LabelStoreOld.sol new file mode 100644 index 000000000..0ca2defd5 --- /dev/null +++ b/contracts/store/LabelStoreOld.sol @@ -0,0 +1,187 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.34; + +import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; + +import {IDotnsStore} from "./IDotnsStore.sol"; +import {ILabelStoreOld} from "./ILabelStoreOld.sol"; +import {IDotnsProtocolRegistry} from "../registry/IDotnsProtocolRegistry.sol"; + +/// @title LabelStoreOld +/// @notice Permanent per-user DotNS label store. +/// @dev One instance per user, deployed as a `BeaconProxy` by `StoreFactory` during registration. +/// Bound to its user forever: `_owner` and `_protocolRegistry` are set once at `initialize` +/// and never mutate. Writes are gated to addresses currently registered in +/// `DotnsProtocolRegistry` (`isRegisteredAddress`); every labelhash is single-write and +/// permanently locked on first use. +/// @dev Labels-only by invariant: this store holds registration records only. Every other +/// per-name category (reverse, content, forward address, chat key, lite link) lives on a +/// dedicated resolver, never here. +/// @dev Storage collision: the `BeaconProxy` stores the beacon address at EIP-1967 slot +/// `keccak256("eip1967.proxy.beacon") - 1`, which is non-sequential and cannot collide +/// with this contract's sequential storage slots. +/// @dev PR-scoped pinned snapshot: the upgrade layout diff compares the new `LabelStore` +/// implementation against this contract. Removed before merge with the paired upgrade +/// script and fork test per CONTRIBUTING.md. +/// @custom:security-contact admin@parity.io +contract LabelStoreOld is Initializable, ILabelStoreOld { + /// @dev Permanent user this store belongs to. Set in `initialize`. + address private _owner; + + /// @dev Canonical DotNS protocol registry. Set in `initialize`. + address private _protocolRegistry; + + /// @dev labelhash => stored label string. + mapping(bytes32 labelhash => string label) private _labels; + + /// @dev Insertion-order list of all stored labelhashes. Append-only. + bytes32[] private _labelList; + + /// @dev labelhash => 1-indexed position in `_labelList` (zero means "not present"). + /// Doubles as the permanent-lock sentinel: a non-zero index proves the label was written and + /// the contract has no deletion path, so the index is also the locked flag. + mapping(bytes32 labelhash => uint256 indexPlusOne) private _labelIndex; + + /// @dev Reserved storage space to allow for layout changes in future beacon upgrades. + // forge-lint: disable-next-line(mixed-case-variable) + uint256[50] private __gap; + + /// @notice Restricts writes to protocol-registered addresses only. + modifier onlyAuthorisedProtocol() { + _onlyAuthorisedProtocol(); + _; + } + + /// @custom:oz-upgrades-unsafe-allow constructor + constructor() { + _disableInitializers(); + } + + /// @inheritdoc ILabelStoreOld + function initialize(address user_, address protocolRegistry_) external override initializer { + require(user_ != address(0), InvalidUser(user_)); + require(protocolRegistry_ != address(0), InvalidProtocolRegistry(protocolRegistry_)); + _owner = user_; + _protocolRegistry = protocolRegistry_; + } + + /// @inheritdoc ILabelStoreOld + function storeLabel( + bytes32 labelhash, + string calldata label + ) + external + override + onlyAuthorisedProtocol + { + require(labelhash != bytes32(0), InvalidLabel(labelhash)); + require(_labelIndex[labelhash] == 0, LabelAlreadyExists(labelhash)); + + // Cache `length + 1` before `push` so the post-push length SLOAD is avoided; the value is + // also the 1-indexed position we are about to write. + uint256 newIndex = _labelList.length + 1; + _labels[labelhash] = label; + _labelList.push(labelhash); + _labelIndex[labelhash] = newIndex; + + emit LabelStored(_owner, labelhash, label); + } + + /// @inheritdoc IDotnsStore + function owner() external view override returns (address owner_) { + return _owner; + } + + /// @inheritdoc ILabelStoreOld + function protocolRegistry() external view override returns (address protocolRegistry_) { + return _protocolRegistry; + } + + /// @inheritdoc ILabelStoreOld + function hasLabel(bytes32 labelhash) external view override returns (bool exists) { + return _labelIndex[labelhash] != 0; + } + + /// @inheritdoc ILabelStoreOld + function isLocked(bytes32 labelhash) external view override returns (bool locked) { + return _labelIndex[labelhash] != 0; + } + + /// @inheritdoc ILabelStoreOld + function getLabel(bytes32 labelhash) external view override returns (string memory label) { + return _labels[labelhash]; + } + + /// @inheritdoc ILabelStoreOld + function getLabelCount() external view override returns (uint256 count) { + return _labelList.length; + } + + /// @inheritdoc ILabelStoreOld + function getLabelAt(uint256 index) external view override returns (string memory label) { + return _labels[_labelList[index]]; + } + + /// @inheritdoc ILabelStoreOld + function getLabelhashAt(uint256 index) external view override returns (bytes32 labelhash) { + return _labelList[index]; + } + + /// @inheritdoc ILabelStoreOld + function getLabels( + uint256 offset, + uint256 limit + ) + external + view + override + returns (string[] memory labels) + { + uint256 total = _labelList.length; + if (offset >= total) return new string[](0); + + uint256 available = total - offset; + uint256 count = limit < available ? limit : available; + + labels = new string[](count); + for (uint256 i; i < count; ++i) { + labels[i] = _labels[_labelList[offset + i]]; + } + } + + /// @inheritdoc ILabelStoreOld + function getLabelhashes( + uint256 offset, + uint256 limit + ) + external + view + override + returns (bytes32[] memory labelhashes) + { + uint256 total = _labelList.length; + if (offset >= total) return new bytes32[](0); + + uint256 available = total - offset; + uint256 count = limit < available ? limit : available; + + labelhashes = new bytes32[](count); + for (uint256 i; i < count; ++i) { + labelhashes[i] = _labelList[offset + i]; + } + } + + /// @notice Returns implementation version. + /// @return versionString Current version string. + function version() external pure virtual returns (string memory versionString) { + versionString = "1.0.0"; + } + + /// @notice Internal authorisation check deferred from the `onlyAuthorisedProtocol` modifier. + function _onlyAuthorisedProtocol() internal view { + require( + IDotnsProtocolRegistry(_protocolRegistry).isRegisteredAddress(msg.sender), + NotAuthorised(msg.sender) + ); + } +} diff --git a/contracts/whitelist/DotnsNameWhitelist.sol b/contracts/whitelist/DotnsNameWhitelist.sol index 842238bfb..c715eee31 100644 --- a/contracts/whitelist/DotnsNameWhitelist.sol +++ b/contracts/whitelist/DotnsNameWhitelist.sol @@ -48,6 +48,24 @@ contract DotnsNameWhitelist is using EnumerableSet for EnumerableSet.AddressSet; using EnumerableSet for EnumerableSet.Bytes32Set; + /// @notice One role's membership and its admin role. + /// @dev Member of the reserved AccessControl namespace, unused because gating is Root only. + /// @param hasRole Whether an account holds the role. + /// @param adminRole Admin role that manages the role. + struct RoleData { + mapping(address account => bool) hasRole; + bytes32 adminRole; + } + + /// @notice Reserved OpenZeppelin access-control namespace held at its ERC-7201 slot. + /// @dev Declared and left unused so the namespace stays present in the layout. Its slot derives + /// from the label, disjoint from the sequential slots below, so it consumes none of them. + /// @param _roles Role data keyed by role identifier. + /// @custom:storage-location erc7201:openzeppelin.storage.AccessControl + struct AccessControlStorage { + mapping(bytes32 role => RoleData) _roles; + } + /// @notice Protocol-level address registry for all DotNS contracts. IDotnsProtocolRegistry public protocolRegistry; diff --git a/contracts/whitelist/DotnsNameWhitelistOld.sol b/contracts/whitelist/DotnsNameWhitelistOld.sol new file mode 100644 index 000000000..902895724 --- /dev/null +++ b/contracts/whitelist/DotnsNameWhitelistOld.sol @@ -0,0 +1,535 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.34; + +import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; +import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; +import {EnumerableSet} from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; + +import {DotnsRoleManagerOld} from "../access/DotnsRoleManagerOld.sol"; +import {IDotnsNameWhitelistOld} from "./IDotnsNameWhitelistOld.sol"; +import {IDotnsProtocolRegistry} from "../registry/IDotnsProtocolRegistry.sol"; +import {LabelUtils} from "../utils/LabelUtils.sol"; +import {StringUtils} from "../utils/StringUtils.sol"; +import {DotnsConstants} from "../utils/DotnsConstants.sol"; +import {SystemUtils} from "../utils/SystemUtils.sol"; + +/// @title DotnsNameWhitelist +/// @notice Pre-launch name whitelist. A name is Open until governance reserves it or a claim is +/// accepted for it. Several beneficiaries may claim the same Open name, each with a +/// reason, and governance accepts one as the winner. +/// @dev Lives behind its own UUPS proxy with its own storage. Callers pass bare labels only; the +/// contract derives the node from the label and the TLD in the protocol registry, so a +/// caller cannot supply a mismatched hash. Claims are keyed by the beneficiary `user`, not +/// the submitter, so a relayer or a cross-chain sovereign account can submit on a user's +/// behalf and the name binds to that user. All state is on-chain and queryable through views; +/// no event indexing is required. A name holds at most `maxClaimants` live claims, which +/// bounds the loop that clears them on resolution. Resolving a name deletes its claims, +/// refunding their storage deposit, so only reserved or won names persist. Governance is Root +/// or the owner. Substrate Root has no address, so the governance gates check +/// `SystemUtils.originIsRoot`, which is true through the proxy's delegatecall frame, before +/// reading `msg.sender`. Operators are signed role holders +/// for day-to-day approvals; the public and PoP controllers hold only the `consume` hook. +/// Entries are keyed by the node under the active TLD, which the deployment holds immutable +/// for the whitelist's lifetime. +/// @custom:security-contact admin@parity.io +contract DotnsNameWhitelistOld is + Initializable, + UUPSUpgradeable, + DotnsRoleManagerOld, + IDotnsNameWhitelistOld +{ + using StringUtils for string; + using EnumerableSet for EnumerableSet.AddressSet; + using EnumerableSet for EnumerableSet.Bytes32Set; + + /// @notice Operator role identifier this snapshot recognises for its operational gates. + /// @dev Pinned here so the snapshot compiles against the current `DotnsConstants`, which no + /// longer declares the role. The value matches the identifier the deployed proxy stored. + bytes32 private constant WHITELIST_OPERATOR_ROLE = keccak256("DOTNS_WHITELIST_OPERATOR_ROLE"); + + /// @notice Protocol-level address registry for all DotNS contracts. + IDotnsProtocolRegistry public protocolRegistry; + + /// @notice Live-claim cap per name, tunable by governance within + /// `DotnsConstants.WHITELIST_MAX_CLAIMANTS_LIMIT`. + uint16 public maxClaimants; + + /// @notice Cap on labels per `grantNames` call, tunable by governance within + /// `DotnsConstants.WHITELIST_MAX_GRANT_BATCH_LIMIT`. + uint16 public maxGrantBatch; + + /// @notice Reason byte cap, tunable by governance within + /// `DotnsConstants.WHITELIST_MAX_REASON_LIMIT`. + uint256 public maxReasonBytes; + + /// @notice Resolved state per name. + mapping(bytes32 node => NameRecord record) private _names; + + /// @notice Claims per name, keyed by beneficiary. + mapping(bytes32 node => mapping(address user => Claim claim)) private _claims; + + /// @notice Beneficiaries with a live claim per name. + mapping(bytes32 node => EnumerableSet.AddressSet claimants) private _claimants; + + /// @notice Names holding reserved, claimed or claim-holding state, kept enumerable for review. + EnumerableSet.Bytes32Set private _activeNodes; + + /// @notice Timestamp requests start being accepted. + uint64 private _requestOpen; + + /// @notice Timestamp requests stop being accepted. + uint64 private _requestClose; + + /// @dev Reserved storage space to allow for layout changes in the future. + uint256[50] private __gap; + + /// @notice Restricts a call to Root or the owner. + /// @dev Checks Root first so `msg.sender`, which traps under a Root origin, is read only for a + /// signed caller. + modifier onlyGovernance() { + if (!SystemUtils.originIsRoot()) { + _checkOwner(); + } + _; + } + + /// @notice Restricts a call to Root, the owner, or an operator. + modifier onlyOperatorOrGovernance() { + if (!SystemUtils.originIsRoot()) { + _checkRoleOrOwner(WHITELIST_OPERATOR_ROLE); + } + _; + } + + /// @notice Restricts a call to a registrar controller resolved through the registry. + modifier onlyController() { + require( + msg.sender == protocolRegistry.get(DotnsConstants.CONTROLLER) + || msg.sender == protocolRegistry.get(DotnsConstants.POP_CONTROLLER), + NotController(msg.sender) + ); + _; + } + + /// @custom:oz-upgrades-unsafe-allow constructor + constructor() { + _disableInitializers(); + } + + /// @notice Initialises the whitelist. + /// @dev Callable once through the UUPS proxy; direct calls on the implementation + /// @custom:reverts InvalidInitialization. Sets the deployer as owner and wires the + /// protocol registry the node derivation reads the TLD from. + /// @param registry Protocol registry all DotNS contracts resolve through. + function initialize(IDotnsProtocolRegistry registry) external initializer { + __ERC165_init(); + __Ownable_init(msg.sender); + _dotnsRoleManagerInit(); + protocolRegistry = registry; + maxClaimants = DotnsConstants.WHITELIST_DEFAULT_MAX_CLAIMANTS; + maxGrantBatch = DotnsConstants.WHITELIST_DEFAULT_MAX_GRANT_BATCH; + maxReasonBytes = DotnsConstants.WHITELIST_DEFAULT_MAX_REASON_BYTES; + } + + /// @inheritdoc IDotnsNameWhitelistOld + function setOperator(address account, bool enabled) external override onlyGovernance { + _setRole(WHITELIST_OPERATOR_ROLE, account, enabled); + } + + /// @inheritdoc IDotnsNameWhitelistOld + function setMaxClaimants(uint16 newMax) external override onlyGovernance { + require( + newMax > 0 && newMax <= DotnsConstants.WHITELIST_MAX_CLAIMANTS_LIMIT, + MaxClaimantsOutOfRange() + ); + maxClaimants = newMax; + emit MaxClaimantsSet(newMax); + } + + /// @inheritdoc IDotnsNameWhitelistOld + function setMaxReasonBytes(uint256 newMax) external override onlyGovernance { + require( + newMax > 0 && newMax <= DotnsConstants.WHITELIST_MAX_REASON_LIMIT, + MaxReasonBytesOutOfRange() + ); + maxReasonBytes = newMax; + emit MaxReasonBytesSet(newMax); + } + + /// @inheritdoc IDotnsNameWhitelistOld + function setMaxGrantBatch(uint16 newMax) external override onlyGovernance { + require( + newMax > 0 && newMax <= DotnsConstants.WHITELIST_MAX_GRANT_BATCH_LIMIT, + MaxGrantBatchOutOfRange() + ); + maxGrantBatch = newMax; + emit MaxGrantBatchSet(newMax); + } + + /// @inheritdoc IDotnsNameWhitelistOld + function requestName( + string calldata label, + string calldata reason, + address user + ) + external + override + { + require(_isWindowOpen(), WindowClosed()); + require(user != address(0), ZeroUser()); + require(bytes(reason).length <= maxReasonBytes, ReasonTooLong()); + require(label.isSingleLabel(), InvalidLabel()); + + bytes32 node = _nodeOf(label); + require(_names[node].status == NameStatus.Open, NameNotOpen(node)); + require(_claims[node][user].status == ClaimStatus.None, AlreadyClaimed(node, user)); + require(_claimants[node].length() < maxClaimants, TooManyClaimants(node)); + + _claims[node][user] = Claim({ + user: user, + status: ClaimStatus.Requested, + requestedAt: uint64(block.timestamp), + submitter: msg.sender, + reason: reason + }); + _claimants[node].add(user); + _activate(node, label); + emit NameRequested(node, user, label, reason); + } + + /// @inheritdoc IDotnsNameWhitelistOld + function accept( + string calldata label, + address user + ) + external + override + onlyOperatorOrGovernance + { + bytes32 node = _nodeOf(label); + require(_claims[node][user].status == ClaimStatus.Requested, NotRequested(node, user)); + emit NameAccepted(node, user, label); + _settle(node, user, label); + } + + /// @inheritdoc IDotnsNameWhitelistOld + function reject( + string calldata label, + address user + ) + external + override + onlyOperatorOrGovernance + { + bytes32 node = _nodeOf(label); + Claim storage claim = _claims[node][user]; + require(claim.status == ClaimStatus.Requested, NotRequested(node, user)); + // Free the claimant slot either way. Keep a sticky Rejected record only for a self-filed + // claim, so the beneficiary cannot re-request; a claim filed on their behalf is + // deleted and never binds them. + _claimants[node].remove(user); + if (claim.submitter == user) { + claim.status = ClaimStatus.Rejected; + } else { + delete _claims[node][user]; + } + emit NameRejected(node, user, label); + _deactivate(node); + } + + /// @inheritdoc IDotnsNameWhitelistOld + function grantName( + string calldata label, + address user + ) + external + override + onlyOperatorOrGovernance + { + _grant(label, user); + } + + /// @inheritdoc IDotnsNameWhitelistOld + function grantNames( + string[] calldata labels, + address user + ) + external + override + onlyOperatorOrGovernance + { + require(labels.length <= maxGrantBatch, TooManyLabels()); + for (uint256 i = 0; i < labels.length; i++) { + _grant(labels[i], user); + } + } + + /// @inheritdoc IDotnsNameWhitelistOld + function revokeName(string calldata label) external override onlyGovernance { + bytes32 node = _nodeOf(label); + NameRecord storage record = _names[node]; + require( + record.status == NameStatus.Claimed || _claimants[node].length() != 0, + NothingToRevoke(node) + ); + address winner = record.winner; + _clearClaimants(node, address(0), label); + record.status = NameStatus.Open; + record.winner = address(0); + emit NameRevoked(node, winner, label); + _deactivate(node); + } + + /// @inheritdoc IDotnsNameWhitelistOld + function setReserved(string calldata label, bool reserved) external override onlyGovernance { + require(label.isSingleLabel(), InvalidLabel()); + bytes32 node = _nodeOf(label); + NameRecord storage record = _names[node]; + if (reserved) { + require(record.status == NameStatus.Open, NameNotOpen(node)); + // Clear any pending claims the way a grant does, so a permissionless requestName + // cannot force governance to revokeName before it can reserve. + _clearClaimants(node, address(0), label); + record.status = NameStatus.Reserved; + _activate(node, label); + emit NameReserved(node, label); + } else { + require(record.status == NameStatus.Reserved, NotReserved(node)); + record.status = NameStatus.Open; + emit NameUnreserved(node, label); + _deactivate(node); + } + } + + /// @inheritdoc IDotnsNameWhitelistOld + function consume(string calldata label, address registrant) external override onlyController { + bytes32 node = _nodeOf(label); + NameRecord storage record = _names[node]; + require( + record.status == NameStatus.Claimed && record.winner == registrant, + NotWinner(registrant, node) + ); + record.status = NameStatus.Open; + record.winner = address(0); + emit NameConsumed(node, registrant, label); + _deactivate(node); + } + + /// @inheritdoc IDotnsNameWhitelistOld + function setWindow(uint64 startsIn, uint64 duration) external override onlyGovernance { + require(duration > 0, BadWindow()); + uint64 openAt = uint64(block.timestamp) + startsIn; + uint64 closeAt = openAt + duration; + _requestOpen = openAt; + _requestClose = closeAt; + emit WindowSet(openAt, closeAt); + } + + /// @inheritdoc IDotnsNameWhitelistOld + function statusOf(string calldata label) external view override returns (NameStatus status) { + return _names[_nodeOf(label)].status; + } + + /// @inheritdoc IDotnsNameWhitelistOld + function isReserved(string calldata label) external view override returns (bool reserved) { + return _names[_nodeOf(label)].status == NameStatus.Reserved; + } + + /// @inheritdoc IDotnsNameWhitelistOld + function granteeOf(string calldata label) external view override returns (address winner) { + NameRecord storage record = _names[_nodeOf(label)]; + return record.status == NameStatus.Claimed ? record.winner : address(0); + } + + /// @inheritdoc IDotnsNameWhitelistOld + function isGrantedTo( + string calldata label, + address account + ) + external + view + override + returns (bool granted) + { + NameRecord storage record = _names[_nodeOf(label)]; + return + account != address(0) && record.status == NameStatus.Claimed && record.winner == account; + } + + /// @inheritdoc IDotnsNameWhitelistOld + function claimOf( + string calldata label, + address user + ) + external + view + override + returns (Claim memory claim) + { + return _claims[_nodeOf(label)][user]; + } + + /// @inheritdoc IDotnsNameWhitelistOld + function claimantCount(string calldata label) external view override returns (uint256 count) { + return _claimants[_nodeOf(label)].length(); + } + + /// @inheritdoc IDotnsNameWhitelistOld + function claims( + string calldata label, + uint256 offset, + uint256 limit + ) + external + view + override + returns (Claim[] memory page) + { + bytes32 node = _nodeOf(label); + EnumerableSet.AddressSet storage set = _claimants[node]; + uint256 total = set.length(); + if (offset >= total) { + return new Claim[](0); + } + uint256 available = total - offset; + uint256 count = limit < available ? limit : available; + page = new Claim[](count); + for (uint256 i; i < count; ++i) { + page[i] = _claims[node][set.at(offset + i)]; + } + } + + /// @inheritdoc IDotnsNameWhitelistOld + function nameCount() external view override returns (uint256 count) { + return _activeNodes.length(); + } + + /// @inheritdoc IDotnsNameWhitelistOld + function names( + uint256 offset, + uint256 limit + ) + external + view + override + returns (NameView[] memory page) + { + uint256 total = _activeNodes.length(); + if (offset >= total) { + return new NameView[](0); + } + uint256 available = total - offset; + uint256 count = limit < available ? limit : available; + page = new NameView[](count); + for (uint256 i; i < count; ++i) { + bytes32 node = _activeNodes.at(offset + i); + NameRecord storage record = _names[node]; + page[i] = NameView({ + node: node, label: record.label, status: record.status, winner: record.winner + }); + } + } + + /// @inheritdoc IDotnsNameWhitelistOld + function window() external view override returns (uint64 openAt, uint64 closeAt) { + return (_requestOpen, _requestClose); + } + + /// @inheritdoc IDotnsNameWhitelistOld + function isWindowOpen() external view override returns (bool open) { + return _isWindowOpen(); + } + + /// @inheritdoc DotnsRoleManagerOld + function supportsInterface(bytes4 interfaceId) + public + view + override(DotnsRoleManagerOld) + returns (bool supported) + { + return interfaceId == type(IDotnsNameWhitelistOld).interfaceId + || super.supportsInterface(interfaceId); + } + + /// @notice Grants `label` to `user` directly, clearing any pending claims. + /// @param label Bare label to grant. + /// @param user Beneficiary the name binds to. + function _grant(string calldata label, address user) internal { + require(user != address(0), ZeroUser()); + require(label.isSingleLabel(), InvalidLabel()); + bytes32 node = _nodeOf(label); + require(_names[node].status == NameStatus.Open, NameNotOpen(node)); + emit NameAccepted(node, user, label); + _settle(node, user, label); + } + + /// @notice Marks a name claimed for `winner` and clears its claims, rejecting the losers. + /// @param node Namehash of the label under the active TLD. + /// @param winner Beneficiary the name binds to. + /// @param label Bare label, stored for review. + function _settle(bytes32 node, address winner, string calldata label) internal { + NameRecord storage record = _names[node]; + record.status = NameStatus.Claimed; + record.winner = winner; + _activate(node, label); + _clearClaimants(node, winner, label); + } + + /// @notice Deletes every claim on a name, rejecting each claimant that is not `winner`. + /// @param node Namehash of the label under the active TLD. + /// @param winner Claimant spared a rejection event; the zero address rejects every claimant. + /// @param label Bare label emitted with each rejection. + function _clearClaimants(bytes32 node, address winner, string calldata label) internal { + address[] memory current = _claimants[node].values(); + for (uint256 i; i < current.length; ++i) { + address claimant = current[i]; + delete _claims[node][claimant]; + _claimants[node].remove(claimant); + if (claimant != winner) { + emit NameRejected(node, claimant, label); + } + } + } + + /// @notice Records a name as active and stores its label the first time it is seen. + /// @param node Namehash of the label under the active TLD. + /// @param label Bare label stored on first activation. + function _activate(bytes32 node, string calldata label) internal { + NameRecord storage record = _names[node]; + if (bytes(record.label).length == 0) { + record.label = label; + } + _activeNodes.add(node); + } + + /// @notice Drops a name from the active set once it is Open with no claims. + /// @param node Namehash of the label under the active TLD. + function _deactivate(bytes32 node) internal { + NameRecord storage record = _names[node]; + if (record.status == NameStatus.Open && _claimants[node].length() == 0) { + _activeNodes.remove(node); + delete record.label; + } + } + + /// @notice Derives the namehash of `label` under the active TLD read from the registry. + /// @param label Bare label to hash. + /// @return node Namehash of the label under the active TLD. + function _nodeOf(string calldata label) internal view returns (bytes32 node) { + (, node) = LabelUtils.deriveNode(protocolRegistry.tldNode(), label); + } + + /// @notice Returns whether the current time is within the open window. + /// @return open True when the current time is within the window. + function _isWindowOpen() internal view returns (bool open) { + return block.timestamp >= _requestOpen && block.timestamp < _requestClose; + } + + /// @inheritdoc DotnsRoleManagerOld + function _isSupportedRole(bytes32 role) internal pure override returns (bool supported) { + return role == WHITELIST_OPERATOR_ROLE; + } + + /// @inheritdoc UUPSUpgradeable + function _authorizeUpgrade(address newImplementation) internal override onlyOwner {} +} diff --git a/contracts/whitelist/IDotnsNameWhitelistOld.sol b/contracts/whitelist/IDotnsNameWhitelistOld.sol new file mode 100644 index 000000000..42a85dd94 --- /dev/null +++ b/contracts/whitelist/IDotnsNameWhitelistOld.sol @@ -0,0 +1,374 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.34; + +/// @title IDotnsNameWhitelist +/// @notice Interface for the pre-launch name whitelist. A name is Open until governance either +/// reserves it or a claim is accepted for it. Several beneficiaries may claim the same +/// Open name, each with a reason, and governance accepts one as the winner. +/// @dev Callers never supply a hash. Every entry point takes the bare label and derives the node +/// from the label and the TLD in the protocol registry, so a caller cannot supply a +/// mismatched hash. Claims are keyed by the beneficiary `user`, not the submitter, so a +/// relayer or a cross-chain sovereign account can submit a claim on a user's behalf and the +/// name still binds to that user. All state is on-chain and queryable through views; no event +/// indexing is required. Governance is Root or the owner. Substrate Root has no address, so +/// the governance gates check `originIsRoot` before reading `msg.sender`. Operators are signed +/// role holders for day-to-day approvals; the controllers hold only the `consume` hook. +/// @custom:security-contact admin@parity.io +interface IDotnsNameWhitelistOld { + /// @notice Status of a name. + /// @dev `Open` is the zero-value default: claimable, not reserved, not won. `Reserved` is + /// withheld by governance. `Claimed` has a single winner. + enum NameStatus { + Open, + Reserved, + Claimed + } + + /// @notice Status of a single claim on a name. + /// @dev `None` is the zero-value default of an absent claim. `Rejected` is sticky: it is kept + /// only when the beneficiary filed the claim themselves, so they cannot re-request; a + /// claim filed on their behalf is deleted on rejection and does not bind them. + enum ClaimStatus { + None, + Requested, + Rejected + } + + /// @notice A claim by one beneficiary on one name. + /// @dev `user`, `status` and `requestedAt` co-locate in one storage slot; `submitter` takes the + /// next, and the dynamic `reason` is stored separately. + /// @param user Beneficiary the name would bind to if this claim wins. + /// @param status Claim status; see ClaimStatus. + /// @param requestedAt Timestamp the claim was made. + /// @param submitter Address that filed the claim, which may differ from the beneficiary. + /// @param reason Free-text justification for the claim. + struct Claim { + address user; + ClaimStatus status; + uint64 requestedAt; + address submitter; + string reason; + } + + /// @notice A name and its resolved state, for review. + /// @param node Namehash of the label under the active TLD. + /// @param label Bare label. + /// @param status Name status; see NameStatus. + /// @param winner Winning beneficiary when `Claimed`, otherwise the zero address. + struct NameView { + bytes32 node; + string label; + NameStatus status; + address winner; + } + + /// @notice Stored resolved state of a name. + /// @dev `status` and `winner` are ordered first so the 1-byte enum and 20-byte address share + /// one storage slot; the dynamic `label` is stored separately. + /// @param status Name status; see NameStatus. + /// @param winner Winning beneficiary when `Claimed`, otherwise the zero address. + /// @param label Bare label, kept so reserved and claimed names are reviewable. + struct NameRecord { + NameStatus status; + address winner; + string label; + } + + /// @notice Emitted when a beneficiary claims a name. + event NameRequested(bytes32 indexed node, address indexed user, string label, string reason); + + /// @notice Emitted when a claim wins a name, including an operator direct grant. + event NameAccepted(bytes32 indexed node, address indexed user, string label); + + /// @notice Emitted when a claim is cleared without winning. + event NameRejected(bytes32 indexed node, address indexed user, string label); + + /// @notice Emitted when a name is reset to Open by governance. + event NameRevoked(bytes32 indexed node, address indexed winner, string label); + + /// @notice Emitted when a winner registers the name and its entry is consumed. + event NameConsumed(bytes32 indexed node, address indexed user, string label); + + /// @notice Emitted when governance withholds a name from claiming. + event NameReserved(bytes32 indexed node, string label); + + /// @notice Emitted when governance releases a reserved name back to Open. + event NameUnreserved(bytes32 indexed node, string label); + + /// @notice Emitted when the request window is set. + /// @param openAt Timestamp requests start being accepted. + /// @param closeAt Timestamp requests stop being accepted. + event WindowSet(uint64 openAt, uint64 closeAt); + + /// @notice Emitted when the live-claim cap is set. + /// @param maxClaimants New per-name claim cap. + event MaxClaimantsSet(uint16 maxClaimants); + + /// @notice Emitted when the reason byte cap is set. + /// @param maxReasonBytes New reason byte cap. + event MaxReasonBytesSet(uint256 maxReasonBytes); + + /// @notice Emitted when the grant-batch cap is set. + /// @param maxGrantBatch New `grantNames` batch cap. + event MaxGrantBatchSet(uint16 maxGrantBatch); + + /// @notice Thrown when a claim names the zero-address beneficiary. + error ZeroUser(); + + /// @notice Thrown when a label is not a canonical single DNS label. + error InvalidLabel(); + + /// @notice Thrown when a reason exceeds `maxReasonBytes`. + error ReasonTooLong(); + + /// @notice Thrown when a name is not Open and the action requires it. + /// @param node Namehash of the label under the active TLD. + error NameNotOpen(bytes32 node); + + /// @notice Thrown when `user` already holds a claim on the name. + /// @param node Namehash of the label under the active TLD. + /// @param user Beneficiary already holding a claim. + error AlreadyClaimed(bytes32 node, address user); + + /// @notice Thrown when a name already holds `maxClaimants` claims. + /// @param node Namehash of the label under the active TLD. + error TooManyClaimants(bytes32 node); + + /// @notice Thrown when the claim cap is set to zero or above + /// `DotnsConstants.WHITELIST_MAX_CLAIMANTS_LIMIT`. + error MaxClaimantsOutOfRange(); + + /// @notice Thrown when the reason cap is set to zero or above + /// `DotnsConstants.WHITELIST_MAX_REASON_LIMIT`. + error MaxReasonBytesOutOfRange(); + + /// @notice Thrown when the grant-batch cap is set to zero or above + /// `DotnsConstants.WHITELIST_MAX_GRANT_BATCH_LIMIT`. + error MaxGrantBatchOutOfRange(); + + /// @notice Thrown when a claim is not in the `Requested` status. + /// @param node Namehash of the label under the active TLD. + /// @param user Beneficiary whose claim was expected to be pending. + error NotRequested(bytes32 node, address user); + + /// @notice Thrown when releasing a name that is not reserved. + /// @param node Namehash of the label under the active TLD. + error NotReserved(bytes32 node); + + /// @notice Thrown when revoking a name that is not Claimed and holds no claims. + /// @param node Namehash of the label under the active TLD. + error NothingToRevoke(bytes32 node); + + /// @notice Thrown when `consume` is called by any address other than a registrar controller. + /// @param caller Rejected caller. + error NotController(address caller); + + /// @notice Thrown when `consume` is called for a name not won by the registrant. + /// @param registrant Address attempting to register the name. + /// @param node Namehash of the label under the active TLD. + error NotWinner(address registrant, bytes32 node); + + /// @notice Thrown when the request window is set with a zero duration. + error BadWindow(); + + /// @notice Thrown when a claim is made outside the open window. + error WindowClosed(); + + /// @notice Thrown when `grantNames` is passed more than `maxGrantBatch` labels. + error TooManyLabels(); + + /// @notice Claims `label` for `user`. + /// @dev Permissionless within the window; the submitter may differ from `user`. Requires the + /// name Open, the window open, `user` non-zero, a canonical label, `user` without an + /// existing claim, and fewer than `maxClaimants` claims on the name. + /// @custom:reverts WindowClosed, @custom:reverts NameNotOpen, @custom:reverts ZeroUser, + /// @custom:reverts InvalidLabel, @custom:reverts ReasonTooLong, + /// @custom:reverts AlreadyClaimed, or @custom:reverts TooManyClaimants. + /// @custom:emits NameRequested. + /// @param label Bare label to claim. + /// @param reason Free-text justification, at most `maxReasonBytes` bytes. + /// @param user Beneficiary the name binds to if this claim wins. + function requestName(string calldata label, string calldata reason, address user) external; + + /// @notice Accepts `user`'s claim as the winner of `label`. + /// @dev Restricted to an operator, the owner, or Root. Requires `user`'s claim `Requested`. + /// Sets the name `Claimed` with `user` the winner and clears every claim on the name, rejecting + /// the losers. @custom:reverts NotRequested. @custom:emits NameAccepted for the winner and + /// @custom:emits NameRejected for each loser. + /// @param label Bare label to resolve. + /// @param user Beneficiary whose claim wins. + function accept(string calldata label, address user) external; + + /// @notice Rejects `user`'s pending claim on `label` without resolving the name. + /// @dev Restricted to an operator, the owner, or Root. Requires the claim `Requested`. + /// @custom:reverts NotRequested. @custom:emits NameRejected. + /// @param label Bare label. + /// @param user Beneficiary whose claim is rejected. + function reject(string calldata label, address user) external; + + /// @notice Grants `label` to `user` directly, without a prior claim. + /// @dev Restricted to an operator, the owner, or Root. Requires the name Open, `user` non-zero + /// and a canonical label. Sets the name `Claimed` with `user` the winner and clears any pending + /// claims. @custom:reverts NameNotOpen, @custom:reverts ZeroUser or + /// @custom:reverts InvalidLabel. @custom:emits NameAccepted, and + /// @custom:emits NameRejected for each cleared claim. + /// @param label Bare label to grant. + /// @param user Beneficiary the name binds to. + function grantName(string calldata label, address user) external; + + /// @notice Grants several labels to one `user` directly. + /// @dev Restricted to an operator, the owner, or Root. Applies @custom:function grantName to + /// each, at most `maxGrantBatch` labels per call. + /// @custom:reverts TooManyLabels when `labels` exceeds the batch cap. + /// @param labels Bare labels to grant. + /// @param user Beneficiary each name binds to. + function grantNames(string[] calldata labels, address user) external; + + /// @notice Resets `label` to Open, clearing any winner and claims. + /// @dev Restricted to an operator, the owner, or Root. Resolves a Claimed or claim-holding + /// name; a Reserved name is released through @custom:function setReserved, not here. + /// @custom:reverts NothingToRevoke when the name is not Claimed and holds no claims. + /// @custom:emits NameRevoked, and @custom:emits NameRejected for each cleared claim. + /// @param label Bare label to reset. + function revokeName(string calldata label) external; + + /// @notice Reserves or releases `label`. + /// @dev Restricted to Root or the owner. Reserving requires the name Open and clears any + /// pending claims, rejecting each; releasing requires it `Reserved`. @custom:reverts + /// NameNotOpen or @custom:reverts NotReserved. @custom:emits NameReserved or @custom:emits + /// NameUnreserved. @param label Bare label. + /// @param reserved True to reserve, false to release. + function setReserved(string calldata label, bool reserved) external; + + /// @notice Removes the win on `label` as `registrant` registers it. + /// @dev Restricted to the registrar controllers resolved through the protocol registry. Resets + /// the name to Open. @custom:reverts NotController for any other caller and + /// @custom:reverts NotWinner when `label` is not won by `registrant`. + /// @custom:emits NameConsumed. + /// @param label Bare label being registered. + /// @param registrant Address registering the name. + function consume(string calldata label, address registrant) external; + + /// @notice Sets the request window relative to the current time. + /// @dev Restricted to Root or the owner. Opens at `block.timestamp + startsIn` for `duration`. + /// @custom:reverts BadWindow when `duration` is zero. @custom:emits WindowSet. + /// @param startsIn Seconds from now until requests start being accepted. + /// @param duration Seconds the window stays open. + function setWindow(uint64 startsIn, uint64 duration) external; + + /// @notice Grants or revokes the operator role for `account`. + /// @dev Restricted to Root or the owner. Root has no address, so governance uses this rather + /// than the owner-only role-admin path. @custom:emits IAccessControl.RoleGranted on grant + /// and @custom:emits IAccessControl.RoleRevoked on revoke. + /// @param account Address whose operator role is changed. + /// @param enabled True to grant, false to revoke. + function setOperator(address account, bool enabled) external; + + /// @notice Sets the live-claim cap per name. + /// @dev Restricted to Root or the owner. The cap is bounded by + /// `DotnsConstants.WHITELIST_MAX_CLAIMANTS_LIMIT`, which bounds the resolution clear-loop. + /// @custom:reverts MaxClaimantsOutOfRange when `newMax` is zero or above the ceiling. + /// @custom:emits MaxClaimantsSet. + /// @param newMax New per-name claim cap. + function setMaxClaimants(uint16 newMax) external; + + /// @notice Sets the reason byte cap. + /// @dev Restricted to Root or the owner, bounded by + /// `DotnsConstants.WHITELIST_MAX_REASON_LIMIT`. @custom:reverts MaxReasonBytesOutOfRange when + /// `newMax` is zero or above the ceiling. @custom:emits MaxReasonBytesSet. + /// @param newMax New reason byte cap. + function setMaxReasonBytes(uint256 newMax) external; + + /// @notice Sets the cap on labels per `grantNames` call. + /// @dev Restricted to Root or the owner, bounded by + /// `DotnsConstants.WHITELIST_MAX_GRANT_BATCH_LIMIT`. @custom:reverts MaxGrantBatchOutOfRange + /// when `newMax` is zero or above the ceiling. @custom:emits MaxGrantBatchSet. + /// @param newMax New batch cap. + function setMaxGrantBatch(uint16 newMax) external; + + /// @notice Returns the live-claim cap per name. + /// @return cap Current per-name claim cap. + function maxClaimants() external view returns (uint16 cap); + + /// @notice Returns the reason byte cap. + /// @return cap Current reason byte cap. + function maxReasonBytes() external view returns (uint256 cap); + + /// @notice Returns the cap on labels per `grantNames` call. + /// @return cap Current batch cap. + function maxGrantBatch() external view returns (uint16 cap); + + /// @notice Returns the status of `label`. + /// @param label Bare label to look up. + /// @return status Name status; see NameStatus. + function statusOf(string calldata label) external view returns (NameStatus status); + + /// @notice Returns whether `label` is reserved. + /// @param label Bare label to look up. + /// @return reserved True when the name is `Reserved`. + function isReserved(string calldata label) external view returns (bool reserved); + + /// @notice Returns the winner of `label`, or the zero address when not `Claimed`. + /// @param label Bare label to look up. + /// @return winner Winning beneficiary. + function granteeOf(string calldata label) external view returns (address winner); + + /// @notice Returns whether `account` won `label`. + /// @dev The pair check the controllers use to admit a registrant. False for the zero address. + /// @param label Bare label to look up. + /// @param account Address to test against the winner. + /// @return granted True when `account` is the winner. + function isGrantedTo( + string calldata label, + address account + ) + external + view + returns (bool granted); + + /// @notice Returns `user`'s claim on `label`. + /// @param label Bare label to look up. + /// @param user Beneficiary to look up. + /// @return claim The stored claim; a zeroed struct with `None` status when absent. + function claimOf(string calldata label, address user) external view returns (Claim memory claim); + + /// @notice Returns the number of live claims on `label`. + /// @param label Bare label to look up. + /// @return count Live claim count. + function claimantCount(string calldata label) external view returns (uint256 count); + + /// @notice Returns a page of claims on `label` for review. + /// @dev Reads the canonical offset and limit window. + /// @param label Bare label to look up. + /// @param offset Index of the first claim. + /// @param limit Maximum number of claims to return. + /// @return page Claims in the window. + function claims( + string calldata label, + uint256 offset, + uint256 limit + ) + external + view + returns (Claim[] memory page); + + /// @notice Returns the number of names with reserved, claimed or claim-holding state. + /// @return count Active name count. + function nameCount() external view returns (uint256 count); + + /// @notice Returns a page of active names for review. + /// @dev Reads the canonical offset and limit window. Iteration order is not stable. + /// @param offset Index of the first name. + /// @param limit Maximum number of names to return. + /// @return page Names in the window. + function names(uint256 offset, uint256 limit) external view returns (NameView[] memory page); + + /// @notice Returns the request window. + /// @return openAt Timestamp requests start being accepted. + /// @return closeAt Timestamp requests stop being accepted. + function window() external view returns (uint64 openAt, uint64 closeAt); + + /// @notice Returns whether requests are currently accepted. + /// @return open True when the current time is within the window. + function isWindowOpen() external view returns (bool open); +} diff --git a/package.json b/package.json index eccc3d23b..be6b78204 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "type": "module", "scripts": { "test": "forge test -vvvvv", + "test:fork": "./scripts/shell/fork-tests.sh", "deploy": "./scripts/deploy/run.sh", "deploy:anvil": "forge clean && forge build && ./scripts/deploy/run.sh", "deploy:testnet": "./scripts/deploy/run.sh '--timeout 1000'", diff --git a/scripts/deploy/UpgradeLabelStore.s.sol b/scripts/deploy/UpgradeLabelStore.s.sol new file mode 100644 index 000000000..00e47844f --- /dev/null +++ b/scripts/deploy/UpgradeLabelStore.s.sol @@ -0,0 +1,86 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.34; + +import {console} from "forge-std/Script.sol"; +import {Options} from "openzeppelin-foundry-upgrades/Options.sol"; +import {Upgrades} from "openzeppelin-foundry-upgrades/Upgrades.sol"; + +import {BaseDeployer} from "./BaseDeployer.s.sol"; +import {IStoreFactory} from "../../contracts/store/IStoreFactory.sol"; + +/// @title UpgradeLabelStore +/// @notice Upgrades the deployed LabelStore implementation for every beacon-proxy store at once. +/// Resolves the store factory from the on-disk manifest, diffs the new storage layout +/// against the pinned @custom:contract LabelStoreOld snapshot, and rotates the shared +/// beacon only when the diff and every unsafe-pattern check pass. +/// @dev The LabelStore proxies are `BeaconProxy` instances behind one `UpgradeableBeacon` owned by +/// the store factory, so the swap is a single call to +/// @custom:function IStoreFactory.upgradeLabelStoreImplementation rather than a per-proxy +/// upgrade. The factory is the beacon owner, so the upgrade broadcasts from the factory +/// owner and the factory delegates the beacon rotation. +/// @dev PR-scoped. This script, the `LabelStoreOld` snapshot it references, and the paired +/// `test/fork/UpgradeLabelStore.t.sol` are deleted before merge per the upgrade-PR workflow +/// in CONTRIBUTING.md. The storage-layout reference is always supplied, so the layout diff is +/// mandatory: there is no environment switch that turns it off, and the run fails closed if a +/// slot moves, shrinks, or changes type. +/// @custom:security-contact admin@parity.io +contract UpgradeLabelStore is BaseDeployer { + /// @notice Pre-upgrade snapshot the layout diff compares the new implementation against. + /// @dev Source-file route, not a stored build-info directory, so the snapshot is versioned + /// beside the implementation and rebuilt by `forge build`. Deleted before merge. + string internal constant REFERENCE_CONTRACT = "LabelStoreOld.sol:LabelStoreOld"; + + /// @notice Fully-qualified artefact for the current LabelStore implementation. + string internal constant LABEL_STORE_ARTEFACT = "LabelStore.sol:LabelStore"; + + /// @notice Manifest label the store factory is recorded under. + string internal constant STORE_FACTORY_LABEL = "StoreFactory"; + + /// @notice Reads the manifest, resolves the store factory, and upgrades the label beacon as + /// `msg.sender`. + /// @dev `msg.sender` must own the store factory, otherwise the `onlyOwner` gate on + /// @custom:function IStoreFactory.upgradeLabelStoreImplementation reverts. + function run() external { + address owner = msg.sender; + vm.label(owner, "OWNER"); + + initDeployment(networkFolder(), vm.toString(block.chainid)); + + address factory = _readAddress(STORE_FACTORY_LABEL); + _upgradeLabelStore(owner, factory); + + console.log("=== UpgradeLabelStore complete ==="); + } + + /// @notice Deploys the current LabelStore implementation and swaps the shared beacon under + /// `owner`. + /// @dev The fail-closed layout diff runs first through + /// @custom:function Upgrades.validateUpgrade against @custom:contract LabelStoreOld; an + /// incompatible layout aborts the run before anything deploys. No `unsafeSkipAllChecks` + /// or `unsafeAllow` override is set. The new implementation is then deployed and the + /// factory rotates the beacon for every existing and future proxy in one call. No + /// initialiser data is passed: existing proxies keep the state they already hold and the + /// new implementation adds no storage that needs seeding. + /// @param owner Account that owns the store factory and broadcasts the upgrade. + /// @param factory Store factory address resolved from the manifest. + /// @return newImplementation Address of the freshly deployed LabelStore implementation. + function _upgradeLabelStore( + address owner, + address factory + ) + internal + returns (address newImplementation) + { + Options memory opts; + opts.referenceContract = REFERENCE_CONTRACT; + + Upgrades.validateUpgrade(LABEL_STORE_ARTEFACT, opts); + + vm.startBroadcast(owner); + newImplementation = Upgrades.deployImplementation(LABEL_STORE_ARTEFACT, opts); + IStoreFactory(factory).upgradeLabelStoreImplementation(newImplementation); + vm.stopBroadcast(); + + console.log(" upgraded LabelStore implementation to", newImplementation); + } +} diff --git a/scripts/deploy/UpgradeNameWhitelist.s.sol b/scripts/deploy/UpgradeNameWhitelist.s.sol new file mode 100644 index 000000000..ca2ebc271 --- /dev/null +++ b/scripts/deploy/UpgradeNameWhitelist.s.sol @@ -0,0 +1,63 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.34; + +import {console} from "forge-std/Script.sol"; +import {Options} from "openzeppelin-foundry-upgrades/Options.sol"; +import {Upgrades} from "openzeppelin-foundry-upgrades/Upgrades.sol"; + +import {BaseDeployer} from "./BaseDeployer.s.sol"; + +/// @title UpgradeNameWhitelist +/// @notice Upgrades the deployed DotnsNameWhitelist proxy to the current implementation. Resolves +/// the proxy from the on-disk manifest, diffs the new storage layout against the pinned +/// @custom:contract DotnsNameWhitelistOld snapshot, and swaps the implementation only when +/// the diff and every unsafe-pattern check pass. +/// @dev PR-scoped. This script, the `DotnsNameWhitelistOld` snapshot it references, and the paired +/// `test/fork/UpgradeNameWhitelist.t.sol` are deleted before merge per the upgrade-PR +/// workflow in CONTRIBUTING.md. The storage-layout reference is always supplied, so the +/// layout diff is mandatory: there is no environment switch that turns it off, and the run +/// fails closed if a slot moves, shrinks, or changes type. +/// @custom:security-contact admin@parity.io +contract UpgradeNameWhitelist is BaseDeployer { + /// @notice Pre-upgrade snapshot the layout diff compares the new implementation against. + /// @dev Source-file route, not a stored build-info directory, so the snapshot is versioned + /// beside the implementation and rebuilt by `forge build`. Deleted before merge. + string internal constant REFERENCE_CONTRACT = "DotnsNameWhitelistOld.sol:DotnsNameWhitelistOld"; + + /// @notice Manifest label the whitelist proxy is recorded under. + string internal constant WHITELIST_LABEL = "DotnsNameWhitelist"; + + /// @notice Reads the manifest, resolves the whitelist proxy, and upgrades it as `msg.sender`. + /// @dev `msg.sender` must own the proxy, otherwise the `_authorizeUpgrade` owner gate reverts. + function run() external { + address owner = msg.sender; + vm.label(owner, "OWNER"); + + initDeployment(networkFolder(), vm.toString(block.chainid)); + + address proxy = _readAddress(WHITELIST_LABEL); + _upgradeNameWhitelist(owner, proxy); + + console.log("=== UpgradeNameWhitelist complete ==="); + } + + /// @notice Upgrades `proxy` to the current `DotnsNameWhitelist` implementation under `owner`. + /// @dev The layout diff runs inside `Upgrades.upgradeProxy` before the implementation swap. No + /// `unsafeSkipAllChecks` or `unsafeAllow` override is set, so an incompatible layout + /// aborts the run rather than corrupting state. An empty upgrade call is passed because + /// the new implementation adds no storage that needs seeding: the governance surface + /// narrows to substrate Root and the tunables, active names, and claims already stored on + /// the proxy keep their slots. + /// @param owner Account that owns the proxy and broadcasts the upgrade. + /// @param proxy Whitelist proxy address resolved from the manifest. + function _upgradeNameWhitelist(address owner, address proxy) internal { + Options memory opts; + opts.referenceContract = REFERENCE_CONTRACT; + + vm.startBroadcast(owner); + Upgrades.upgradeProxy(proxy, "DotnsNameWhitelist.sol:DotnsNameWhitelist", "", opts); + vm.stopBroadcast(); + + console.log(" upgraded DotnsNameWhitelist proxy", proxy); + } +} diff --git a/scripts/deploy/UpgradePopController.s.sol b/scripts/deploy/UpgradePopController.s.sol new file mode 100644 index 000000000..b529b4676 --- /dev/null +++ b/scripts/deploy/UpgradePopController.s.sol @@ -0,0 +1,63 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.34; + +import {console} from "forge-std/Script.sol"; +import {Options} from "openzeppelin-foundry-upgrades/Options.sol"; +import {Upgrades} from "openzeppelin-foundry-upgrades/Upgrades.sol"; + +import {BaseDeployer} from "./BaseDeployer.s.sol"; + +/// @title UpgradePopController +/// @notice Upgrades the deployed DotnsPopController proxy to the current implementation. Resolves +/// the proxy from the on-disk manifest, diffs the new storage layout against the pinned +/// @custom:contract DotnsPopControllerOld snapshot, and swaps the implementation only when +/// the diff and every unsafe-pattern check pass. +/// @dev PR-scoped. This script, the `DotnsPopControllerOld` snapshot it references, and the paired +/// `test/fork/UpgradePopController.t.sol` are deleted before merge per the upgrade-PR workflow +/// in CONTRIBUTING.md. The storage-layout reference is always supplied, so the layout diff is +/// mandatory: there is no environment switch that turns it off, and the run fails closed if a +/// slot moves, shrinks, or changes type. +/// @custom:security-contact admin@parity.io +contract UpgradePopController is BaseDeployer { + /// @notice Pre-upgrade snapshot the layout diff compares the new implementation against. + /// @dev Source-file route, not a stored build-info directory, so the snapshot is versioned + /// beside the implementation and rebuilt by `forge build`. Deleted before merge. + string internal constant REFERENCE_CONTRACT = "DotnsPopControllerOld.sol:DotnsPopControllerOld"; + + /// @notice Manifest label the PoP controller proxy is recorded under. + string internal constant POP_CONTROLLER_LABEL = "DotnsPopController"; + + /// @notice Reads the manifest, resolves the PoP controller proxy, and upgrades it as + /// `msg.sender`. + /// @dev `msg.sender` must own the proxy, otherwise the `_authorizeUpgrade` owner gate reverts. + function run() external { + address owner = msg.sender; + vm.label(owner, "OWNER"); + + initDeployment(networkFolder(), vm.toString(block.chainid)); + + address proxy = _readAddress(POP_CONTROLLER_LABEL); + _upgradePopController(owner, proxy); + + console.log("=== UpgradePopController complete ==="); + } + + /// @notice Upgrades `proxy` to the current `DotnsPopController` implementation under `owner`. + /// @dev The layout diff runs inside `Upgrades.upgradeProxy` before the implementation swap. No + /// `unsafeSkipAllChecks` or `unsafeAllow` override is set, so an incompatible layout + /// aborts the run rather than corrupting state. An empty upgrade call is passed because the new + /// implementation seeds no storage: `_popIssued` defaults to false for every label, which + /// is the correct provenance for names issued before the upgrade. + /// @param owner Account that owns the proxy and broadcasts the upgrade. + /// @param proxy PoP controller proxy address resolved from the manifest. + function _upgradePopController(address owner, address proxy) internal { + Options memory opts; + opts.referenceContract = REFERENCE_CONTRACT; + + vm.startBroadcast(owner); + Upgrades.upgradeProxy(proxy, "DotnsPopController.sol:DotnsPopController", "", opts); + vm.stopBroadcast(); + + console.log(" upgraded DotnsPopController proxy", proxy); + } +} diff --git a/scripts/deploy/UpgradePopRules.s.sol b/scripts/deploy/UpgradePopRules.s.sol new file mode 100644 index 000000000..0d4079794 --- /dev/null +++ b/scripts/deploy/UpgradePopRules.s.sol @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.34; + +import {console} from "forge-std/Script.sol"; +import {Options} from "openzeppelin-foundry-upgrades/Options.sol"; +import {Upgrades} from "openzeppelin-foundry-upgrades/Upgrades.sol"; + +import {BaseDeployer} from "./BaseDeployer.s.sol"; + +/// @title UpgradePopRules +/// @notice Upgrades the deployed PopRules proxy to the current implementation. Resolves the proxy +/// from the on-disk manifest, diffs the new storage layout against the pinned +/// @custom:contract PopRulesOld snapshot, and swaps the implementation only when the diff +/// and every unsafe-pattern check pass. +/// @dev PR-scoped. This script, the `PopRulesOld` snapshot it references, and the paired +/// `test/fork/UpgradePopRules.t.sol` are deleted before merge per the upgrade-PR workflow in +/// CONTRIBUTING.md. The storage-layout reference is always supplied, so the layout diff is +/// mandatory: there is no environment switch that turns it off, and the run fails closed if a +/// slot moves, shrinks, or changes type. +/// @custom:security-contact admin@parity.io +contract UpgradePopRules is BaseDeployer { + /// @notice Pre-upgrade snapshot the layout diff compares the new implementation against. + /// @dev Source-file route, not a stored build-info directory, so the snapshot is versioned + /// beside the implementation and rebuilt by `forge build`. Deleted before merge. + string internal constant REFERENCE_CONTRACT = "PopRulesOld.sol:PopRulesOld"; + + /// @notice Manifest label the PopRules proxy is recorded under. + string internal constant POP_RULES_LABEL = "PopRules"; + + /// @notice Reads the manifest, resolves the PopRules proxy, and upgrades it as `msg.sender`. + /// @dev `msg.sender` must own the proxy, otherwise the `_authorizeUpgrade` owner gate reverts. + function run() external { + address owner = msg.sender; + vm.label(owner, "OWNER"); + + initDeployment(networkFolder(), vm.toString(block.chainid)); + + address proxy = _readAddress(POP_RULES_LABEL); + _upgradePopRules(owner, proxy); + + console.log("=== UpgradePopRules complete ==="); + } + + /// @notice Upgrades `proxy` to the current `PopRules` implementation under `owner`. + /// @dev The layout diff runs inside `Upgrades.upgradeProxy` before the implementation swap. No + /// `unsafeSkipAllChecks` or `unsafeAllow` override is set, so an incompatible layout + /// aborts the run rather than corrupting state. An empty upgrade call is passed because the new + /// implementation adds no storage that needs seeding: the classification and pricing + /// change is logic-only, and `shortNamesEnabled` keeps whatever value the live proxy holds. + /// @param owner Account that owns the proxy and broadcasts the upgrade. + /// @param proxy PopRules proxy address resolved from the manifest. + function _upgradePopRules(address owner, address proxy) internal { + Options memory opts; + opts.referenceContract = REFERENCE_CONTRACT; + + vm.startBroadcast(owner); + Upgrades.upgradeProxy(proxy, "PopRules.sol:PopRules", "", opts); + vm.stopBroadcast(); + + console.log(" upgraded PopRules proxy", proxy); + } +} diff --git a/scripts/deploy/UpgradeRegistrar.s.sol b/scripts/deploy/UpgradeRegistrar.s.sol new file mode 100644 index 000000000..36a7662c8 --- /dev/null +++ b/scripts/deploy/UpgradeRegistrar.s.sol @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.34; + +import {console} from "forge-std/Script.sol"; +import {Options} from "openzeppelin-foundry-upgrades/Options.sol"; +import {Upgrades} from "openzeppelin-foundry-upgrades/Upgrades.sol"; + +import {BaseDeployer} from "./BaseDeployer.s.sol"; + +/// @title UpgradeRegistrar +/// @notice Upgrades the deployed DotnsRegistrar proxy to the current implementation. Resolves the +/// proxy from the on-disk manifest, diffs the new storage layout against the pinned +/// @custom:contract DotnsRegistrarOld snapshot, and swaps the implementation only when the +/// diff and every unsafe-pattern check pass. +/// @dev PR-scoped. This script, the `DotnsRegistrarOld` snapshot it references, and the paired +/// `test/fork/UpgradeRegistrar.t.sol` are deleted before merge per the upgrade-PR workflow in +/// CONTRIBUTING.md. The storage-layout reference is always supplied, so the layout diff is +/// mandatory: there is no environment switch that turns it off, and the run fails closed if a +/// slot moves, shrinks, or changes type. +/// @custom:security-contact admin@parity.io +contract UpgradeRegistrar is BaseDeployer { + /// @notice Pre-upgrade snapshot the layout diff compares the new implementation against. + /// @dev Source-file route, not a stored build-info directory, so the snapshot is versioned + /// beside the implementation and rebuilt by `forge build`. Deleted before merge. + string internal constant REFERENCE_CONTRACT = "DotnsRegistrarOld.sol:DotnsRegistrarOld"; + + /// @notice Manifest label the registrar proxy is recorded under. + string internal constant REGISTRAR_LABEL = "DotnsRegistrar"; + + /// @notice Reads the manifest, resolves the registrar proxy, and upgrades it as `msg.sender`. + /// @dev `msg.sender` must own the proxy, otherwise the `_authorizeUpgrade` owner gate reverts. + function run() external { + address owner = msg.sender; + vm.label(owner, "OWNER"); + + initDeployment(networkFolder(), vm.toString(block.chainid)); + + address proxy = _readAddress(REGISTRAR_LABEL); + _upgradeRegistrar(owner, proxy); + + console.log("=== UpgradeRegistrar complete ==="); + } + + /// @notice Upgrades `proxy` to the current `DotnsRegistrar` implementation under `owner`. + /// @dev The layout diff runs inside `Upgrades.upgradeProxy` before the implementation swap. No + /// `unsafeSkipAllChecks` or `unsafeAllow` override is set, so an incompatible layout + /// aborts the run rather than corrupting state. An empty upgrade call is passed because the new + /// implementation adds no storage that needs seeding: `_soulbound` defaults to false for + /// every existing token, which is the correct state for names minted before the upgrade. + /// @param owner Account that owns the proxy and broadcasts the upgrade. + /// @param proxy Registrar proxy address resolved from the manifest. + function _upgradeRegistrar(address owner, address proxy) internal { + Options memory opts; + opts.referenceContract = REFERENCE_CONTRACT; + + vm.startBroadcast(owner); + Upgrades.upgradeProxy(proxy, "DotnsRegistrar.sol:DotnsRegistrar", "", opts); + vm.stopBroadcast(); + + console.log(" upgraded DotnsRegistrar proxy", proxy); + } +} diff --git a/scripts/deploy/UpgradeRegistrarController.s.sol b/scripts/deploy/UpgradeRegistrarController.s.sol new file mode 100644 index 000000000..b2002377a --- /dev/null +++ b/scripts/deploy/UpgradeRegistrarController.s.sol @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.34; + +import {console} from "forge-std/Script.sol"; +import {Options} from "openzeppelin-foundry-upgrades/Options.sol"; +import {Upgrades} from "openzeppelin-foundry-upgrades/Upgrades.sol"; + +import {BaseDeployer} from "./BaseDeployer.s.sol"; + +/// @title UpgradeRegistrarController +/// @notice Upgrades the deployed DotnsRegistrarController proxy to the current implementation. +/// @dev Resolves the proxy from the on-disk manifest, diffs the new storage layout against the +/// pinned @custom:contract DotnsRegistrarControllerOld snapshot, and swaps the implementation +/// only when the diff and every unsafe-pattern check pass. PR-scoped: this script, the +/// `DotnsRegistrarControllerOld` snapshot it references, and the paired +/// `test/fork/UpgradeRegistrarController.t.sol` are deleted before merge per the upgrade-PR +/// workflow in CONTRIBUTING.md. The storage-layout reference is always supplied, so the +/// layout diff is mandatory: there is no environment switch that turns it off, and the run +/// fails closed if a slot moves, shrinks, or changes type. +/// @custom:security-contact admin@parity.io +contract UpgradeRegistrarController is BaseDeployer { + /// @notice Pre-upgrade snapshot the layout diff compares the new implementation against. + /// @dev Source-file route, not a stored build-info directory, so the snapshot is versioned + /// beside the implementation and rebuilt by `forge build`. Deleted before merge. + string internal constant REFERENCE_CONTRACT = + "DotnsRegistrarControllerOld.sol:DotnsRegistrarControllerOld"; + + /// @notice Manifest label the registrar controller proxy is recorded under. + string internal constant CONTROLLER_LABEL = "DotnsRegistrarController"; + + /// @notice Reads the manifest, resolves the controller proxy, and upgrades it as `msg.sender`. + /// @dev `msg.sender` must own the proxy, otherwise the `_authorizeUpgrade` owner gate reverts. + function run() external { + address owner = msg.sender; + vm.label(owner, "OWNER"); + + initDeployment(networkFolder(), vm.toString(block.chainid)); + + address proxy = _readAddress(CONTROLLER_LABEL); + _upgradeRegistrarController(owner, proxy); + + console.log("=== UpgradeRegistrarController complete ==="); + } + + /// @notice Upgrades `proxy` to the current `DotnsRegistrarController` implementation under + /// `owner`. + /// @dev The layout diff runs inside `Upgrades.upgradeProxy` before the implementation swap. No + /// `unsafeSkipAllChecks` or `unsafeAllow` override is set, so an incompatible layout + /// aborts the run rather than corrupting state. An empty upgrade call is passed because the new + /// implementation adds no storage that needs seeding. + /// @param owner Account that owns the proxy and broadcasts the upgrade. + /// @param proxy Registrar controller proxy address resolved from the manifest. + function _upgradeRegistrarController(address owner, address proxy) internal { + Options memory opts; + opts.referenceContract = REFERENCE_CONTRACT; + + vm.startBroadcast(owner); + Upgrades.upgradeProxy( + proxy, "DotnsRegistrarController.sol:DotnsRegistrarController", "", opts + ); + vm.stopBroadcast(); + + console.log(" upgraded DotnsRegistrarController proxy", proxy); + } +} diff --git a/scripts/shell/fork-tests.sh b/scripts/shell/fork-tests.sh new file mode 100755 index 000000000..14919b503 --- /dev/null +++ b/scripts/shell/fork-tests.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Single-command fork-test runner. Brings up the local revive ETH-RPC adapter (or +# reuses one already answering on the RPC url), waits for it to be healthy, does a +# clean build, then runs the test/fork/** suite against it. Fork tests validate each +# upgrade script against live Paseo Asset Hub state; see CONTRIBUTING.md (Upgrade-PR +# workflow) and DEPLOYMENTS.md (Local ETH-RPC adapter). Between upgrade PRs test/fork +# is empty and the suite runs zero tests, which is a pass. +# +# Usage: +# bun run test:fork # default verbosity (-vvv) +# bun run test:fork -- -vvvvv # pass extra forge args through +# RPC_URL=http://127.0.0.1:8545 bun run test:fork + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$ROOT" + +RPC_URL="${RPC_URL:-http://127.0.0.1:8545}" + +adapter_is_up() { + curl -sf -X POST -H 'Content-Type: application/json' \ + --data '{"jsonrpc":"2.0","method":"eth_chainId","params":[],"id":1}' \ + "$RPC_URL" > /dev/null 2>&1 +} + +if adapter_is_up; then + echo "fork-tests: reusing the ETH-RPC adapter already answering on $RPC_URL" +else + echo "fork-tests: starting the eth-rpc adapter (docker compose up --build -d eth-rpc)" + docker compose up --build -d eth-rpc + scripts/shell/wait-for-eth-rpc.sh "$RPC_URL" +fi + +# The OpenZeppelin upgrade validator reads Foundry build-info, and a stale incremental +# build trips it with "Found multiple contracts with name ...". Start from a clean +# build so every upgrade script's storage-layout diff resolves against fresh artefacts. +echo "fork-tests: forge clean" +forge clean + +echo "fork-tests: running test/fork/** against $RPC_URL" +forge test --match-path 'test/fork/**' "${@:--vvv}" + +echo "fork-tests: done. Stop the adapter with: docker compose down" diff --git a/test/fork/UpgradeLabelStore.t.sol b/test/fork/UpgradeLabelStore.t.sol new file mode 100644 index 000000000..682ba337c --- /dev/null +++ b/test/fork/UpgradeLabelStore.t.sol @@ -0,0 +1,159 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.34; + +import {Test} from "forge-std/Test.sol"; +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; +import {IBeacon} from "@openzeppelin/contracts/proxy/beacon/IBeacon.sol"; + +import {ILabelStore} from "../../contracts/store/ILabelStore.sol"; +import {IStoreFactory} from "../../contracts/store/IStoreFactory.sol"; +import {IDotnsProtocolRegistry} from "../../contracts/registry/IDotnsProtocolRegistry.sol"; +import {DotnsConstants} from "../../contracts/utils/DotnsConstants.sol"; +import {UpgradeLabelStore} from "../../scripts/deploy/UpgradeLabelStore.s.sol"; + +/// @title UpgradeLabelStoreHarness +/// @notice Exposes the upgrade script's internal beacon-swap path so the fork test drives the exact +/// code the production run executes, including the fail-closed storage-layout diff. +/// @dev Mirrors the deploy-harness pattern: forward to the script internal rather than +/// re-implement the upgrade, so the test tracks the production path one-to-one. +contract UpgradeLabelStoreHarness is UpgradeLabelStore { + /// @notice Upgrades the label beacon owned by `factory` under `owner` through the script's + /// `_upgradeLabelStore`. + /// @param owner Account that owns the store factory and broadcasts the upgrade. + /// @param factory Store factory that owns the label beacon. + /// @return newImplementation Address of the freshly deployed LabelStore implementation. + function upgradeLabelStore( + address owner, + address factory + ) + external + returns (address newImplementation) + { + newImplementation = _upgradeLabelStore(owner, factory); + } +} + +/// @title UpgradeLabelStoreForkTest +/// @notice Pairs one-to-one with `scripts/deploy/UpgradeLabelStore.s.sol`. Forks the live Paseo +/// Asset Hub through the ETH-RPC adapter, rotates the shared LabelStore beacon with the +/// script, and proves an existing store proxy keeps its stored labels and that the new +/// write-authorisation gate is live afterwards. +/// @dev PR-scoped: deleted before merge with the upgrade script and the `LabelStoreOld` snapshot. +/// Requires the local adapter on `paseo_local`; between upgrade PRs `test/fork/` is empty, so +/// the suite is skipped by default with `--no-match-path 'test/fork/**'`. +/// @custom:security-contact admin@parity.io +contract UpgradeLabelStoreForkTest is Test { + /// @notice Manifest recording the live deployment addresses this fork resolves against. + string internal constant MANIFEST_PATH = "deployments/paseo-assethub/420420417.json"; + + /// @notice A representative label persisted before the upgrade and read back after it. + string internal constant SEED_LABEL = "alice.paseo"; + + /// @notice Drives the script's beacon-swap path against the live factory. + UpgradeLabelStoreHarness internal upgrader; + + /// @notice The deployed store factory that owns the label beacon. + IStoreFactory internal storeFactory; + + /// @notice The deployed protocol registry the stores authorise writers through. + IDotnsProtocolRegistry internal protocolRegistry; + + /// @notice Factory owner, impersonated to authorise the beacon swap. + address internal factoryOwner; + + /// @notice The registrar, a store writer impersonated to seed and write labels. + address internal registrar; + + /// @notice The name escrow: registered in the protocol registry but not a store writer, used + /// to prove the new authorisation gate rejects a merely-registered address. + address internal nameEscrow; + + /// @notice Forks Paseo and resolves the live factory, registry, and writer addresses. + function setUp() public { + vm.createSelectFork(vm.rpcUrl("paseo_local")); + + string memory manifest = vm.readFile(MANIFEST_PATH); + storeFactory = IStoreFactory(vm.parseJsonAddress(manifest, ".StoreFactory")); + protocolRegistry = + IDotnsProtocolRegistry(vm.parseJsonAddress(manifest, ".DotnsProtocolRegistry")); + + factoryOwner = Ownable(address(storeFactory)).owner(); + registrar = protocolRegistry.get(DotnsConstants.REGISTRAR); + nameEscrow = protocolRegistry.get(DotnsConstants.NAME_ESCROW); + + upgrader = new UpgradeLabelStoreHarness(); + } + + /// @notice The beacon swap keeps an existing store's stored label readable and leaves the + /// registrar-driven write path working on the new implementation. + function test_upgrade_preservesStoredLabelAndKeepsWritesWorking() public { + address user = makeAddr("labelStoreUser.writes"); + address store = _deployStoreFor(user); + + bytes32 seedHash = keccak256("dotns.fork.labelstore.seed"); + vm.prank(registrar); + ILabelStore(store).storeLabel(seedHash, SEED_LABEL); + assertEq(ILabelStore(store).getLabel(seedHash), SEED_LABEL, "pre-upgrade: label stored"); + + address beacon = storeFactory.labelStoreBeacon(); + address implBefore = IBeacon(beacon).implementation(); + + address newImplementation = upgrader.upgradeLabelStore(factoryOwner, address(storeFactory)); + + // The beacon now serves the freshly deployed implementation to every proxy. + assertEq( + IBeacon(beacon).implementation(), + newImplementation, + "post-upgrade: beacon serves the new implementation" + ); + assertTrue(newImplementation != implBefore, "post-upgrade: implementation changed"); + + // The proxy is the same address and still resolves through the factory mapping. + assertEq(storeFactory.getLabelStore(user), store, "post-upgrade: proxy address unchanged"); + assertEq(ILabelStore(store).owner(), user, "post-upgrade: store owner preserved"); + assertEq( + ILabelStore(store).getLabel(seedHash), + SEED_LABEL, + "post-upgrade: stored label survives the beacon swap" + ); + + // P0: the registrar can still write a fresh label on the upgraded implementation. + bytes32 postHash = keccak256("dotns.fork.labelstore.post"); + vm.prank(registrar); + ILabelStore(store).storeLabel(postHash, "bob.paseo"); + assertEq( + ILabelStore(store).getLabel(postHash), "bob.paseo", "post-upgrade: registrar writes" + ); + } + + /// @notice After the upgrade, a merely-registered address that is not a store writer is + /// rejected, proving the `StoreAuth.isStoreWriter` gate is the live authorisation. + function test_upgrade_activatesStoreAuthGate() public { + address user = makeAddr("labelStoreUser.gate"); + address store = _deployStoreFor(user); + + upgrader.upgradeLabelStore(factoryOwner, address(storeFactory)); + + // The name escrow is registered in the protocol registry yet is neither the registrar, a + // controller, nor the registry, so the live gate must reject its write. + bytes32 gateHash = keccak256("dotns.fork.labelstore.gate"); + vm.prank(nameEscrow); + vm.expectRevert(abi.encodeWithSelector(ILabelStore.NotAuthorised.selector, nameEscrow)); + ILabelStore(store).storeLabel(gateHash, "mallory.paseo"); + } + + /// @notice Resolves `user`'s existing LabelStore proxy, deploying one through the registrar + /// when the fork has none yet. + /// @dev The proxy is a `BeaconProxy` created before the upgrade, so it is the existing store + /// the beacon swap must keep intact. `deployLabelStoreFor` is gated to the owner or a + /// store writer, so the registrar drives it. + /// @param user The user the store binds to. + /// @return store The resolved or freshly deployed store proxy. + function _deployStoreFor(address user) internal returns (address store) { + store = storeFactory.getLabelStore(user); + if (store == address(0)) { + vm.prank(registrar); + store = storeFactory.deployLabelStoreFor(user); + } + } +} diff --git a/test/fork/UpgradeNameWhitelist.t.sol b/test/fork/UpgradeNameWhitelist.t.sol new file mode 100644 index 000000000..2b672f9d8 --- /dev/null +++ b/test/fork/UpgradeNameWhitelist.t.sol @@ -0,0 +1,159 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.34; + +import {Test} from "forge-std/Test.sol"; +import { + OwnableUpgradeable +} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; + +import {DotnsNameWhitelist} from "../../contracts/whitelist/DotnsNameWhitelist.sol"; +import {IDotnsNameWhitelist} from "../../contracts/whitelist/IDotnsNameWhitelist.sol"; +import {IDotnsProtocolRegistry} from "../../contracts/registry/IDotnsProtocolRegistry.sol"; +import {DotnsConstants} from "../../contracts/utils/DotnsConstants.sol"; +import {ISystem} from "../../contracts/external/revive/ISystem.sol"; +import {UpgradeNameWhitelist} from "../../scripts/deploy/UpgradeNameWhitelist.s.sol"; + +/// @title UpgradeNameWhitelistHarness +/// @notice Exposes the upgrade script's internal upgrade path so the fork test drives the exact +/// code the production run executes, including the fail-closed storage-layout diff. +/// @dev Mirrors the `UpgradeRegistrarHarness` pattern: forward to the script internal rather than +/// re-implement the upgrade, so the test tracks the production path one-to-one. +contract UpgradeNameWhitelistHarness is UpgradeNameWhitelist { + /// @notice Upgrades `proxy` under `owner` through the script's `_upgradeNameWhitelist`. + function upgradeNameWhitelist(address owner, address proxy) external { + _upgradeNameWhitelist(owner, proxy); + } +} + +/// @title UpgradeNameWhitelistForkTest +/// @notice Pairs one-to-one with `scripts/deploy/UpgradeNameWhitelist.s.sol`. Forks the live Paseo +/// Asset Hub through the ETH-RPC adapter, upgrades the deployed whitelist proxy with the +/// script, and re-runs the whitelist's P0 paths against real on-chain state to prove the +/// swap preserves stored names and keeps governance grants, reservations, and the +/// controller consume hook working. +/// @dev PR-scoped: deleted before merge with the upgrade script and the `DotnsNameWhitelistOld` +/// snapshot. Requires the local adapter on `paseo_local`. The whole admin surface is +/// substrate Root, so each governance action mocks `ISystem.originIsRoot` to true through the +/// System precompile the whitelist reads. +/// @custom:security-contact admin@parity.io +contract UpgradeNameWhitelistForkTest is Test { + /// @notice Manifest recording the live deployment addresses this fork resolves against. + string internal constant MANIFEST_PATH = "deployments/paseo-assethub/420420417.json"; + + /// @notice Drives the script's upgrade path against the live proxy. + UpgradeNameWhitelistHarness internal upgrader; + + /// @notice The deployed whitelist proxy under upgrade. + DotnsNameWhitelist internal whitelist; + + /// @notice The deployed protocol registry the whitelist resolves the TLD and controllers + /// through. + IDotnsProtocolRegistry internal protocolRegistry; + + /// @notice Proxy owner, impersonated to authorise the upgrade. + address internal whitelistOwner; + + /// @notice The deployed commit-reveal controller, the caller the consume hook admits. + address internal controller; + + /// @notice Beneficiary accounts for the grant and consume paths. + address internal alice; + address internal bob; + + /// @notice Forks Paseo, resolves the live addresses, and readies the upgrade harness. + function setUp() public { + vm.createSelectFork(vm.rpcUrl("paseo_local")); + + string memory manifest = vm.readFile(MANIFEST_PATH); + whitelist = DotnsNameWhitelist(vm.parseJsonAddress(manifest, ".DotnsNameWhitelist")); + protocolRegistry = + IDotnsProtocolRegistry(vm.parseJsonAddress(manifest, ".DotnsProtocolRegistry")); + whitelistOwner = OwnableUpgradeable(address(whitelist)).owner(); + controller = protocolRegistry.get(DotnsConstants.CONTROLLER); + + upgrader = new UpgradeNameWhitelistHarness(); + + alice = makeAddr("alice"); + bob = makeAddr("bob"); + } + + /// @notice The upgrade preserves a name reserved before the swap and keeps a post-upgrade + /// governance grant resolving to its winner. + function test_upgrade_preservesStateAndKeepsGovernanceP0Working() public { + // Seed a reservation on the pre-upgrade implementation under a Root origin. + _mockRoot(true); + whitelist.setReserved("forkseedname", true); + _clearRoot(); + assertTrue(whitelist.isReserved("forkseedname"), "pre-upgrade: seed name is reserved"); + + address proxy = address(whitelist); + address registryBefore = address(whitelist.protocolRegistry()); + + upgrader.upgradeNameWhitelist(whitelistOwner, proxy); + + assertEq(address(whitelist), proxy, "upgrade keeps the same proxy address"); + assertTrue(whitelist.isReserved("forkseedname"), "post-upgrade: reservation preserved"); + assertEq( + address(whitelist.protocolRegistry()), + registryBefore, + "post-upgrade: protocol registry pointer preserved" + ); + + // P0: a governance grant still binds a name to its winner on the upgraded implementation. + _mockRoot(true); + whitelist.grantName("forkgrantname", alice); + _clearRoot(); + assertEq( + whitelist.granteeOf("forkgrantname"), alice, "post-upgrade: grant binds the winner" + ); + assertTrue( + whitelist.isGrantedTo("forkgrantname", alice), + "post-upgrade: winner is granted the name" + ); + } + + /// @notice After the upgrade the admin surface is substrate Root only, so a non-Root governance + /// call reverts with the fail-closed gate, and the controller consume hook still clears + /// a winner. + function test_upgrade_governanceIsRootGatedAndConsumeStillClears() public { + upgrader.upgradeNameWhitelist(whitelistOwner, address(whitelist)); + + // New surface: a governance action from a non-Root origin fails closed with the Root gate. + // The owner holds no allocation authority, only upgrade authority. + _mockRoot(false); + vm.prank(whitelistOwner); + vm.expectRevert(IDotnsNameWhitelist.NotGovernance.selector); + whitelist.setReserved("forkgatedname", true); + _clearRoot(); + + // P0: a granted name is still cleared by the registrar controller through consume. + _mockRoot(true); + whitelist.grantName("forkconsumename", bob); + _clearRoot(); + assertEq(whitelist.granteeOf("forkconsumename"), bob, "grant binds the winner"); + + vm.prank(controller); + whitelist.consume("forkconsumename", bob); + assertEq( + uint256(whitelist.statusOf("forkconsumename")), + uint256(IDotnsNameWhitelist.NameStatus.Open), + "consume resets the name to Open" + ); + assertEq(whitelist.granteeOf("forkconsumename"), address(0), "consume clears the winner"); + } + + /// @notice Mocks revive's System precompile so `originIsRoot` returns true, driving the + /// whitelist's Root-only governance gate. + function _mockRoot(bool root) internal { + vm.mockCall( + DotnsConstants.REVIVE_SYSTEM, + abi.encodeWithSelector(ISystem.originIsRoot.selector), + abi.encode(root) + ); + } + + /// @notice Clears the `originIsRoot` mock so later calls read the real precompile result. + function _clearRoot() internal { + vm.clearMockedCalls(); + } +} diff --git a/test/fork/UpgradePopController.t.sol b/test/fork/UpgradePopController.t.sol new file mode 100644 index 000000000..67ed05e17 --- /dev/null +++ b/test/fork/UpgradePopController.t.sol @@ -0,0 +1,182 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.34; + +import {Test} from "forge-std/Test.sol"; +import { + OwnableUpgradeable +} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; + +import {DotnsPopController} from "../../contracts/registrars/DotnsPopController.sol"; +import {IDotnsPopController} from "../../contracts/registrars/IDotnsPopController.sol"; +import {IDotnsRegistrar} from "../../contracts/registrars/IDotnsRegistrar.sol"; +import {IDotnsController} from "../../contracts/registrars/IDotnsController.sol"; +import {IDotnsProtocolRegistry} from "../../contracts/registry/IDotnsProtocolRegistry.sol"; +import {ISystem} from "../../contracts/external/revive/ISystem.sol"; +import {DotnsConstants} from "../../contracts/utils/DotnsConstants.sol"; +import {UpgradePopController} from "../../scripts/deploy/UpgradePopController.s.sol"; + +/// @title UpgradePopControllerHarness +/// @notice Exposes the upgrade script's internal upgrade path so the fork test drives the exact +/// code the production run executes, including the fail-closed storage-layout diff. +/// @dev Mirrors the `DeterministicDeploymentHarness` pattern: forward to the script internal +/// rather than re-implement the upgrade, so the test tracks the production path one-to-one. +contract UpgradePopControllerHarness is UpgradePopController { + /// @notice Upgrades `proxy` under `owner` through the script's `_upgradePopController`. + function upgradePopController(address owner, address proxy) external { + _upgradePopController(owner, proxy); + } +} + +/// @title UpgradePopControllerForkTest +/// @notice Pairs one-to-one with `scripts/deploy/UpgradePopController.s.sol`. Forks the live Paseo +/// Asset Hub through the ETH-RPC adapter, upgrades the deployed PoP controller proxy with +/// the script, and re-runs the controller's reservation path against real on-chain state to +/// prove the swap preserves ownership and queue state and keeps issuance working. +/// @dev PR-scoped: deleted before merge with the upgrade script and the `DotnsPopControllerOld` +/// snapshot. Requires the local adapter on `paseo_local`; between upgrade PRs `test/fork/` is +/// empty, so the suite is skipped by default with `--no-match-path 'test/fork/**'`. +/// +/// The live siblings are not upgraded here, so the reservation path is exercised with +/// base-name (letters-only) labels, which the deployed `PopRules` classifies without change. +/// The dotted lite-person flow depends on a matching `PopRules` upgrade and is therefore out +/// of scope for a controller-only fork run. +/// @custom:security-contact admin@parity.io +contract UpgradePopControllerForkTest is Test { + /// @notice Manifest recording the live deployment addresses this fork resolves against. + string internal constant MANIFEST_PATH = "deployments/paseo-assethub/420420417.json"; + + /// @notice Registry key the PoP gateway address is recorded under on the deployed controller. + /// @dev The pre-upgrade implementation gates its entrypoints on this address; the call is made + /// as the gateway to seed state through the real code path. + bytes32 internal constant POP_GATEWAY = bytes32("popGateway"); + + /// @notice Base label reserved to prove queue state survives the swap and stays writable. + /// @dev Lowercase letters only and long enough to classify outside the governance-reserved + /// tier, so the reservation path accepts it as a base name. + string internal constant BASE_LABEL = "zqxwvutsrq"; + + /// @notice A never-issued label, used to prove the new `isPopIssued` surface answers. + string internal constant UNISSUED_LABEL = "neverissued"; + + /// @notice Drives the script's upgrade path against the live proxy. + UpgradePopControllerHarness internal upgrader; + + /// @notice The deployed PoP controller proxy under upgrade. + DotnsPopController internal popController; + + /// @notice The deployed protocol registry the controller resolves siblings through. + IDotnsProtocolRegistry internal protocolRegistry; + + /// @notice The deployed registrar the controller reserves and mints against. + IDotnsRegistrar internal registrar; + + /// @notice PoP controller proxy owner, impersonated to authorise the upgrade. + address internal popControllerOwner; + + /// @notice Registrar owner, impersonated to authorise the controller on the registrar. + address internal registrarOwner; + + /// @notice The configured PoP gateway, impersonated to seed state on the pre-upgrade code path. + address internal gateway; + + /// @notice Beneficiary accounts for the reservation paths. + address internal alice; + address internal bob; + + /// @notice Forks Paseo, resolves the live addresses, authorises the controller, and mocks Root. + function setUp() public { + vm.createSelectFork(vm.rpcUrl("paseo_local")); + + string memory manifest = vm.readFile(MANIFEST_PATH); + popController = DotnsPopController(vm.parseJsonAddress(manifest, ".DotnsPopController")); + protocolRegistry = + IDotnsProtocolRegistry(vm.parseJsonAddress(manifest, ".DotnsProtocolRegistry")); + registrar = IDotnsRegistrar(vm.parseJsonAddress(manifest, ".DotnsRegistrar")); + popControllerOwner = OwnableUpgradeable(address(popController)).owner(); + registrarOwner = OwnableUpgradeable(address(registrar)).owner(); + gateway = protocolRegistry.get(POP_GATEWAY); + + upgrader = new UpgradePopControllerHarness(); + + alice = makeAddr("alice"); + bob = makeAddr("bob"); + + // Reserving on `PopRules` gates on the caller being a registrar-authorised controller. + // Re-asserting the live PoP controller is a no-op when it is already registered and keeps + // the test independent of the exact wiring state of the fork. + vm.prank(registrarOwner); + registrar.addController(IDotnsController(address(popController))); + + // The upgraded implementation gates its entrypoints on `ISystem.originIsRoot`. The + // precompile is not part of the fork state, so the origin is mocked to Root for the calls + // made after the swap. The pre-upgrade implementation gates on the gateway address instead, + // so the seed call below is made as the gateway rather than relying on this mock. + vm.mockCall( + DotnsConstants.REVIVE_SYSTEM, + abi.encodeWithSelector(ISystem.originIsRoot.selector), + abi.encode(true) + ); + } + + /// @notice The upgrade preserves ownership, sibling wiring, and reservation state on the real + /// proxy, and exposes the new `isPopIssued` surface. + function test_upgrade_preservesStateAndExposesNewSurface() public { + // Seed a base-name reservation on the pre-upgrade implementation so its survival across the + // implementation swap is observable. The deployed code path gates on the gateway address. + vm.prank(gateway); + popController.reserveBaseNameOnly( + IDotnsPopController.BaseNameReservation({user: bob, reservedBaseLabel: BASE_LABEL}) + ); + IDotnsPopController.UserReservation memory seeded = popController.userReservation(bob); + assertTrue(seeded.labelhash != bytes32(0), "pre-upgrade: bob holds a reservation"); + + address proxy = address(popController); + address registryBefore = address(popController.protocolRegistry()); + uint64 durationBefore = popController.reservationDuration(); + + upgrader.upgradePopController(popControllerOwner, proxy); + + assertEq(address(popController), proxy, "upgrade keeps the same proxy address"); + assertEq( + address(popController.protocolRegistry()), + registryBefore, + "post-upgrade: protocol registry pointer preserved" + ); + assertEq( + popController.reservationDuration(), + durationBefore, + "post-upgrade: reservation duration preserved" + ); + + // The reservation queued before the upgrade is still live and still keyed to bob. + IDotnsPopController.UserReservation memory kept = popController.userReservation(bob); + assertEq(kept.labelhash, seeded.labelhash, "post-upgrade: reservation labelhash preserved"); + (bool reserved, address holder) = popController.isReservedForClaim(BASE_LABEL); + assertTrue(reserved, "post-upgrade: reservation still live at the queue head"); + assertEq(holder, bob, "post-upgrade: reservation still held by bob"); + + // The new surface is callable and reports the honest answer for a label never issued. + assertFalse( + popController.isPopIssued(UNISSUED_LABEL), + "post-upgrade: an unissued label reports false" + ); + } + + /// @notice After the upgrade, the Root-gated base-name reservation path still mutates queue + /// state and syncs the reservation to the queue head. + function test_upgrade_keepsBaseReservationWorkingUnderRoot() public { + upgrader.upgradePopController(popControllerOwner, address(popController)); + + // P0: a real reservation still writes queue state on the upgraded implementation under a + // mocked Root origin, and syncs the head so the label reads back as reserved for alice. + popController.reserveBaseNameOnly( + IDotnsPopController.BaseNameReservation({user: alice, reservedBaseLabel: BASE_LABEL}) + ); + + IDotnsPopController.UserReservation memory res = popController.userReservation(alice); + assertTrue(res.labelhash != bytes32(0), "post-upgrade: alice holds a fresh reservation"); + (bool reserved, address holder) = popController.isReservedForClaim(BASE_LABEL); + assertTrue(reserved, "post-upgrade: the fresh reservation is live at the queue head"); + assertEq(holder, alice, "post-upgrade: the fresh reservation is held by alice"); + } +} diff --git a/test/fork/UpgradePopRules.t.sol b/test/fork/UpgradePopRules.t.sol new file mode 100644 index 000000000..b174d427c --- /dev/null +++ b/test/fork/UpgradePopRules.t.sol @@ -0,0 +1,157 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.34; + +import {Test} from "forge-std/Test.sol"; +import { + OwnableUpgradeable +} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; + +import {PopRules} from "../../contracts/pop/PopRules.sol"; +import {IPopRules} from "../../contracts/pop/IPopRules.sol"; +import {IDotnsProtocolRegistry} from "../../contracts/registry/IDotnsProtocolRegistry.sol"; +import {IPersonhood} from "../../contracts/external/personhood/IPersonhood.sol"; +import {ISystem} from "../../contracts/external/revive/ISystem.sol"; +import {DotnsConstants} from "../../contracts/utils/DotnsConstants.sol"; +import {UpgradePopRules} from "../../scripts/deploy/UpgradePopRules.s.sol"; + +/// @title UpgradePopRulesHarness +/// @notice Exposes the upgrade script's internal upgrade path so the fork test drives the exact +/// code the production run executes, including the fail-closed storage-layout diff. +/// @dev Mirrors the registrar harness pattern: forward to the script internal rather than +/// re-implement the upgrade, so the test tracks the production path one-to-one. +contract UpgradePopRulesHarness is UpgradePopRules { + /// @notice Upgrades `proxy` under `owner` through the script's `_upgradePopRules`. + function upgradePopRules(address owner, address proxy) external { + _upgradePopRules(owner, proxy); + } +} + +/// @title UpgradePopRulesForkTest +/// @notice Pairs one-to-one with `scripts/deploy/UpgradePopRules.s.sol`. Forks the live Paseo Asset +/// Hub through the ETH-RPC adapter, upgrades the deployed PopRules proxy with the script, +/// and re-runs the oracle's P0 paths against real on-chain state. Proves the swap keeps the +/// proxy, its owner, and the registry pointer, keeps classification and pricing reads +/// answering, and keeps the Root-gated short-name lever working. +/// @dev PR-scoped: deleted before merge with the upgrade script and the `PopRulesOld` snapshot. +/// Requires the local adapter on `paseo_local`; between upgrade PRs `test/fork/` is empty, so +/// the suite is skipped by default with `--no-match-path 'test/fork/**'`. +/// @custom:security-contact admin@parity.io +contract UpgradePopRulesForkTest is Test { + /// @notice Manifest recording the live deployment addresses this fork resolves against. + string internal constant MANIFEST_PATH = "deployments/paseo-assethub/420420417.json"; + + /// @notice A plain nine-character label priced and classified as open to every caller. + string internal constant OPEN_LABEL = "alicexyzz"; + + /// @notice A plain six-character label that sits in the governed short-name band. + string internal constant SHORT_LABEL = "aliced"; + + /// @notice Drives the script's upgrade path against the live proxy. + UpgradePopRulesHarness internal upgrader; + + /// @notice The deployed PopRules proxy under upgrade. + PopRules internal popRules; + + /// @notice The deployed protocol registry the oracle resolves siblings through. + IDotnsProtocolRegistry internal protocolRegistry; + + /// @notice Proxy owner, impersonated to authorise the upgrade. + address internal popRulesOwner; + + /// @notice An unverified account used for the pricing preview reads. + address internal user; + + /// @notice Forks Paseo and resolves the live PopRules proxy, its owner, and the registry. + function setUp() public { + vm.createSelectFork(vm.rpcUrl("paseo_local")); + + string memory manifest = vm.readFile(MANIFEST_PATH); + popRules = PopRules(vm.parseJsonAddress(manifest, ".PopRules")); + protocolRegistry = + IDotnsProtocolRegistry(vm.parseJsonAddress(manifest, ".DotnsProtocolRegistry")); + popRulesOwner = OwnableUpgradeable(address(popRules)).owner(); + + upgrader = new UpgradePopRulesHarness(); + + user = makeAddr("user"); + } + + /// @notice Mocks the personhood precompile so every account reads as unverified, keeping the + /// pricing preview deterministic on any fork state. + function _mockNoPersonhood() internal { + vm.mockCall( + DotnsConstants.PERSONHOOD, + abi.encodeWithSelector(IPersonhood.personhoodStatus.selector), + abi.encode(IPersonhood.PersonhoodInfo({status: 0, contextAlias: bytes32(0)})) + ); + } + + /// @notice The upgrade keeps the proxy, the registry pointer, and the classification and + /// pricing reads intact, and installs the Root gate on the short-name lever. + function test_upgrade_preservesStateAndKeepsPricingWorking() public { + // Seed representative reads on the pre-upgrade implementation. + address registryBefore = address(popRules.protocolRegistry()); + uint256 priceBefore = popRules.price(OPEN_LABEL); + (IPopRules.PopStatus statusBefore, string memory messageBefore) = + popRules.classifyName(OPEN_LABEL); + + address proxy = address(popRules); + upgrader.upgradePopRules(popRulesOwner, proxy); + + assertEq(address(popRules), proxy, "upgrade keeps the same proxy address"); + assertEq( + address(popRules.protocolRegistry()), + registryBefore, + "post-upgrade: protocol registry pointer preserved" + ); + + // P0: pricing and classification still answer sensibly, unchanged for a plain open label. + assertEq(popRules.price(OPEN_LABEL), priceBefore, "post-upgrade: price preserved"); + (IPopRules.PopStatus statusAfter, string memory messageAfter) = + popRules.classifyName(OPEN_LABEL); + assertEq( + uint256(statusAfter), uint256(statusBefore), "post-upgrade: classification preserved" + ); + assertEq(uint256(statusAfter), uint256(IPopRules.PopStatus.NoStatus), "open label is open"); + assertEq(messageAfter, messageBefore, "post-upgrade: classification message preserved"); + + // New surface: the short-name lever is now gated on a Root origin. A non-Root origin + // reverts with the typed error rather than the pre-upgrade owner gate. + vm.mockCall( + DotnsConstants.REVIVE_SYSTEM, + abi.encodeWithSelector(ISystem.originIsRoot.selector), + abi.encode(false) + ); + vm.prank(popRulesOwner); + vm.expectRevert(IPopRules.NotRoot.selector); + popRules.setShortNamesEnabled(true); + } + + /// @notice After the upgrade, a Root origin opens the short-name market and the public pricing + /// preview transitions from reverting to returning for a short label. + function test_upgrade_rootOpensShortNameMarket() public { + upgrader.upgradePopRules(popRulesOwner, address(popRules)); + _mockNoPersonhood(); + + // Closed by default: the public preview rejects a short label. + assertFalse(popRules.shortNamesEnabled(), "short names closed after upgrade"); + vm.expectRevert( + abi.encodeWithSelector(IPopRules.PopError.selector, "Short names are not for sale") + ); + popRules.priceWithoutCheck(SHORT_LABEL, user); + + // A Root origin flips the lever. + vm.mockCall( + DotnsConstants.REVIVE_SYSTEM, + abi.encodeWithSelector(ISystem.originIsRoot.selector), + abi.encode(true) + ); + vm.prank(user); + popRules.setShortNamesEnabled(true); + assertTrue(popRules.shortNamesEnabled(), "Root opened the short-name market"); + + // P0: the public preview now returns a price for the same short label. + IPopRules.PriceWithMeta memory metadata = popRules.priceWithoutCheck(SHORT_LABEL, user); + assertEq(popRules.price(SHORT_LABEL), metadata.price, "preview price matches the curve"); + } +} diff --git a/test/fork/UpgradeRegistrar.t.sol b/test/fork/UpgradeRegistrar.t.sol new file mode 100644 index 000000000..db0934c8e --- /dev/null +++ b/test/fork/UpgradeRegistrar.t.sol @@ -0,0 +1,153 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.34; + +import {Test} from "forge-std/Test.sol"; +import { + OwnableUpgradeable +} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; + +import {DotnsRegistrar} from "../../contracts/registrars/DotnsRegistrar.sol"; +import {IDotnsRegistrar} from "../../contracts/registrars/IDotnsRegistrar.sol"; +import {IDotnsController} from "../../contracts/registrars/IDotnsController.sol"; +import {IDotnsProtocolRegistry} from "../../contracts/registry/IDotnsProtocolRegistry.sol"; +import {DotnsConstants} from "../../contracts/utils/DotnsConstants.sol"; +import {UpgradeRegistrar} from "../../scripts/deploy/UpgradeRegistrar.s.sol"; + +/// @title UpgradeRegistrarHarness +/// @notice Exposes the upgrade script's internal upgrade path so the fork test drives the exact +/// code the production run executes, including the fail-closed storage-layout diff. +/// @dev Mirrors the `DeterministicDeploymentHarness` pattern: forward to the script internal +/// rather than re-implement the upgrade, so the test tracks the production path one-to-one. +contract UpgradeRegistrarHarness is UpgradeRegistrar { + /// @notice Upgrades `proxy` under `owner` through the script's `_upgradeRegistrar`. + function upgradeRegistrar(address owner, address proxy) external { + _upgradeRegistrar(owner, proxy); + } +} + +/// @title UpgradeRegistrarForkTest +/// @notice Pairs one-to-one with `scripts/deploy/UpgradeRegistrar.s.sol`. Forks the live Paseo +/// Asset Hub through the ETH-RPC adapter, upgrades the deployed registrar proxy with the +/// script, and re-runs the registrar's P0 paths against real on-chain state to prove the +/// swap preserves ownership and keeps registration, transfer, and soulbound gating working. +/// @dev PR-scoped: deleted before merge with the upgrade script and the `DotnsRegistrarOld` +/// snapshot. Requires the local adapter on `paseo_local`; between upgrade PRs `test/fork/` is +/// empty, so the suite is skipped by default with `--no-match-path 'test/fork/**'`. +/// @custom:security-contact admin@parity.io +contract UpgradeRegistrarForkTest is Test { + /// @notice Manifest recording the live deployment addresses this fork resolves against. + string internal constant MANIFEST_PATH = "deployments/paseo-assethub/420420417.json"; + + /// @notice Drives the script's upgrade path against the live proxy. + UpgradeRegistrarHarness internal upgrader; + + /// @notice The deployed registrar proxy under upgrade. + DotnsRegistrar internal registrar; + + /// @notice The deployed protocol registry the registrar resolves siblings through. + IDotnsProtocolRegistry internal protocolRegistry; + + /// @notice Proxy owner, impersonated to authorise the upgrade and controller writes. + address internal registrarOwner; + + /// @notice The deployed commit-reveal controller, impersonated to mint public names. + address internal registrarController; + + /// @notice The deployed PoP controller, impersonated to mint soulbound names. + address internal popController; + + /// @notice Recipient accounts for the transfer paths. + address internal alice; + address internal bob; + + /// @notice Forks Paseo, resolves the live addresses, and guarantees both controllers are set. + function setUp() public { + vm.createSelectFork(vm.rpcUrl("paseo_local")); + + string memory manifest = vm.readFile(MANIFEST_PATH); + registrar = DotnsRegistrar(vm.parseJsonAddress(manifest, ".DotnsRegistrar")); + protocolRegistry = + IDotnsProtocolRegistry(vm.parseJsonAddress(manifest, ".DotnsProtocolRegistry")); + registrarController = vm.parseJsonAddress(manifest, ".DotnsRegistrarController"); + popController = vm.parseJsonAddress(manifest, ".DotnsPopController"); + registrarOwner = OwnableUpgradeable(address(registrar)).owner(); + + upgrader = new UpgradeRegistrarHarness(); + + alice = makeAddr("alice"); + bob = makeAddr("bob"); + vm.deal(alice, 100 ether); + vm.deal(bob, 100 ether); + + // The mint paths call through the registrar's controller set. Re-asserting the two live + // controllers is a no-op when they are already registered and keeps the test independent of + // the exact wiring state of the fork. + vm.startPrank(registrarOwner); + registrar.addController(IDotnsController(registrarController)); + registrar.addController(IDotnsController(popController)); + vm.stopPrank(); + } + + /// @notice The upgrade preserves ownership state on the real proxy and keeps minting and + /// transferring public names working. + function test_upgrade_preservesStateAndKeepsCoreP0Working() public { + uint256 seedToken = uint256(keccak256("dotns.fork.upgrade.seed")); + + // Seed ownership on the pre-upgrade implementation. An empty label takes the gateway-cold + // mint path, so the seed does not depend on a `LabelStore` deploy. + vm.prank(registrarController); + registrar.register(seedToken, alice, ""); + assertEq(registrar.ownerOf(seedToken), alice, "pre-upgrade: alice owns the seed name"); + + address proxy = address(registrar); + address registryBefore = address(registrar.protocolRegistry()); + + upgrader.upgradeRegistrar(registrarOwner, proxy); + + assertEq(address(registrar), proxy, "upgrade keeps the same proxy address"); + assertEq(registrar.ownerOf(seedToken), alice, "post-upgrade: ownership preserved"); + assertEq( + address(registrar.protocolRegistry()), + registryBefore, + "post-upgrade: protocol registry pointer preserved" + ); + assertFalse( + registrar.isSoulbound(seedToken), + "post-upgrade: a name minted before the upgrade is not soulbound" + ); + + // P0: minting still works on the upgraded implementation. + uint256 postToken = uint256(keccak256("dotns.fork.upgrade.post")); + vm.prank(registrarController); + registrar.register(postToken, bob, ""); + assertEq(registrar.ownerOf(postToken), bob, "post-upgrade: registration still mints"); + + // P0: a public name is still transferable. The empty-label seed carries no transfer floor. + uint256 fee = registrar.quoteTransferFee(seedToken, bob); + vm.prank(alice); + registrar.transferFrom{value: fee}(alice, bob, seedToken); + assertEq(registrar.ownerOf(seedToken), bob, "post-upgrade: a public name still transfers"); + } + + /// @notice After the upgrade, a name minted through the PoP controller is soulbound and every + /// transfer path reverts. + function test_upgrade_enablesSoulboundGatingForGatewayMints() public { + upgrader.upgradeRegistrar(registrarOwner, address(registrar)); + + uint256 gatewayToken = uint256(keccak256("dotns.fork.upgrade.gateway")); + vm.prank(popController); + registrar.register(gatewayToken, alice, ""); + assertTrue(registrar.isSoulbound(gatewayToken), "gateway mint is soulbound"); + + vm.prank(alice); + vm.expectRevert( + abi.encodeWithSelector(IDotnsRegistrar.NameSoulbound.selector, gatewayToken) + ); + registrar.transferFrom(alice, bob, gatewayToken); + + vm.expectRevert( + abi.encodeWithSelector(IDotnsRegistrar.NameSoulbound.selector, gatewayToken) + ); + registrar.quoteTransferFee(gatewayToken, bob); + } +} diff --git a/test/fork/UpgradeRegistrarController.t.sol b/test/fork/UpgradeRegistrarController.t.sol new file mode 100644 index 000000000..2bc3cabe4 --- /dev/null +++ b/test/fork/UpgradeRegistrarController.t.sol @@ -0,0 +1,152 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.34; + +import {Test} from "forge-std/Test.sol"; +import { + OwnableUpgradeable +} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; + +import {DotnsRegistrarController} from "../../contracts/registrars/DotnsRegistrarController.sol"; +import {IDotnsRegistrarController} from "../../contracts/registrars/IDotnsRegistrarController.sol"; +import {IDotnsRegistrar} from "../../contracts/registrars/IDotnsRegistrar.sol"; +import {IDotnsProtocolRegistry} from "../../contracts/registry/IDotnsProtocolRegistry.sol"; +import {IDotnsCostModelRegistry} from "../../contracts/pop/IDotnsCostModelRegistry.sol"; +import {ISystem} from "../../contracts/external/revive/ISystem.sol"; +import {DotnsConstants} from "../../contracts/utils/DotnsConstants.sol"; +import {LabelUtils} from "../../contracts/utils/LabelUtils.sol"; +import {UpgradeRegistrarController} from "../../scripts/deploy/UpgradeRegistrarController.s.sol"; + +/// @title UpgradeRegistrarControllerHarness +/// @notice Exposes the upgrade script's internal upgrade path so the fork test drives the exact +/// code the production run executes, including the fail-closed storage-layout diff. +/// @dev Mirrors the reference `UpgradeRegistrarHarness` pattern: forward to the script internal +/// rather than re-implement the upgrade, so the test tracks the production path one-to-one. +contract UpgradeRegistrarControllerHarness is UpgradeRegistrarController { + /// @notice Upgrades `proxy` under `owner` through the script's internal upgrade path. + function upgradeRegistrarController(address owner, address proxy) external { + _upgradeRegistrarController(owner, proxy); + } +} + +/// @title UpgradeRegistrarControllerForkTest +/// @notice Pairs one-to-one with `scripts/deploy/UpgradeRegistrarController.s.sol`. Forks the live +/// Paseo Asset Hub through the ETH-RPC adapter, upgrades the deployed controller proxy with +/// the script, and re-runs the controller's commit-reveal P0 against real on-chain state to +/// prove the swap preserves ownership, the protocol registry pointer, the commitment window +/// bounds, and a commitment made on the pre-upgrade implementation. +/// @dev PR-scoped: deleted before merge with the upgrade script and the +/// `DotnsRegistrarControllerOld` snapshot. Requires the local adapter on `paseo_local`; between +/// upgrade PRs `test/fork/` is +/// empty, so the suite is skipped by default with `--no-match-path 'test/fork/**'`. +/// @custom:security-contact admin@parity.io +contract UpgradeRegistrarControllerForkTest is Test { + /// @notice Manifest recording the live deployment addresses this fork resolves against. + string internal constant MANIFEST_PATH = "deployments/paseo-assethub/420420417.json"; + + /// @notice Drives the script's upgrade path against the live proxy. + UpgradeRegistrarControllerHarness internal upgrader; + + /// @notice The deployed registrar controller proxy under upgrade. + DotnsRegistrarController internal controller; + + /// @notice The deployed protocol registry the controller resolves siblings through. + IDotnsProtocolRegistry internal protocolRegistry; + + /// @notice The deployed registrar the controller mints names on. + IDotnsRegistrar internal registrar; + + /// @notice Proxy owner, impersonated to authorise the upgrade. + address internal controllerOwner; + + /// @notice Beneficiary the reserved commit-reveal flow mints to. + address internal alice; + + /// @notice Forks Paseo and resolves the live addresses the P0 flow drives against. + function setUp() public { + vm.createSelectFork(vm.rpcUrl("paseo_local")); + + string memory manifest = vm.readFile(MANIFEST_PATH); + controller = + DotnsRegistrarController(vm.parseJsonAddress(manifest, ".DotnsRegistrarController")); + protocolRegistry = + IDotnsProtocolRegistry(vm.parseJsonAddress(manifest, ".DotnsProtocolRegistry")); + registrar = IDotnsRegistrar(vm.parseJsonAddress(manifest, ".DotnsRegistrar")); + controllerOwner = OwnableUpgradeable(address(controller)).owner(); + + upgrader = new UpgradeRegistrarControllerHarness(); + + alice = makeAddr("alice"); + vm.deal(alice, 100 ether); + } + + /// @notice The upgrade preserves the proxy address, the protocol registry pointer, and the + /// commitment window bounds, and a commitment submitted on the pre-upgrade + /// implementation still reveals into a mint on the upgraded implementation. + function test_upgrade_preservesStateAndKeepsCommitRevealWorking() public { + // Build a Root-issuable reserved registration. Root skips the whitelist grant and the PoP + // price check, so the flow exercises the full commit -> wait -> reveal -> mint path without + // seeding pricing or personhood on the fork. + IDotnsRegistrarController.Registration memory reg = IDotnsRegistrarController.Registration({ + label: "forkupgradectrl", + owner: alice, + secret: keccak256("dotns.fork.upgrade.controller.secret"), + reserved: false, + maxPrice: 0, + pricingVersion: _currentPricingVersion() + }); + + assertTrue(controller.available(reg.label), "pre-upgrade: label is available"); + + // Commit on the pre-upgrade implementation. This stamps the pricing version into the + // commitment slot, so the post-upgrade reveal proves the commitment storage survived. + bytes32 commitment = controller.makeCommitment(reg); + controller.commit(commitment); + assertEq(controller.commitments(commitment), block.timestamp, "pre-upgrade: commit stored"); + + address proxy = address(controller); + address registryBefore = address(controller.protocolRegistry()); + uint256 minAgeBefore = controller.minCommitmentAge(); + uint256 maxAgeBefore = controller.maxCommitmentAge(); + + upgrader.upgradeRegistrarController(controllerOwner, proxy); + + // (a) proxy address unchanged. + assertEq(address(controller), proxy, "upgrade keeps the same proxy address"); + // (b) key storage pointer preserved. + assertEq( + address(controller.protocolRegistry()), + registryBefore, + "post-upgrade: protocol registry pointer preserved" + ); + // (c) commitment window bounds preserved across the layout change. + assertEq(controller.minCommitmentAge(), minAgeBefore, "post-upgrade: minCommitmentAge kept"); + assertEq(controller.maxCommitmentAge(), maxAgeBefore, "post-upgrade: maxCommitmentAge kept"); + // The commitment stored before the upgrade is still readable at the same slot. + assertGt(controller.commitments(commitment), 0, "post-upgrade: commitment preserved"); + + // (d) P0: reveal the pre-upgrade commitment through the new registerReserved surface under + // a substrate Root origin and confirm the name mints to the beneficiary. + vm.warp(block.timestamp + minAgeBefore + 1); + vm.mockCall( + DotnsConstants.REVIVE_SYSTEM, + abi.encodeWithSelector(ISystem.originIsRoot.selector), + abi.encode(true) + ); + + controller.registerReserved(reg); + + bytes32 node = LabelUtils.namehashUnder( + protocolRegistry.tldNode(), LabelUtils.labelhashMemory(reg.label) + ); + assertEq( + registrar.ownerOf(uint256(node)), alice, "post-upgrade: reserved mint reaches owner" + ); + assertFalse(controller.available(reg.label), "post-upgrade: label is no longer available"); + } + + /// @notice Resolves the cost model's current version through the protocol registry. + function _currentPricingVersion() internal view returns (uint256 version) { + version = IDotnsCostModelRegistry(protocolRegistry.get(DotnsConstants.COST_MODEL)) + .currentVersion(); + } +} From 78896ef7bb06808737d76775f0bef98217b505e1 Mon Sep 17 00:00:00 2001 From: Siphamandla Mjoli Date: Wed, 9 Sep 2026 01:09:13 +0200 Subject: [PATCH 02/25] ci: report upgrade fork tests as their own CI Summary shard --- .github/workflows/push_checking.yml | 33 +++++++++++++++-------------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/.github/workflows/push_checking.yml b/.github/workflows/push_checking.yml index 43b76d1a6..eb93f461f 100644 --- a/.github/workflows/push_checking.yml +++ b/.github/workflows/push_checking.yml @@ -48,6 +48,9 @@ jobs: - id: invariant label: Contract Tests (Invariant) forge_args: --match-contract Invariant --no-match-path 'test/fork/**' + - id: fork + label: Upgrade Fork Tests + forge_args: --match-path 'test/fork/**' steps: - uses: actions/checkout@v4 with: @@ -78,11 +81,11 @@ jobs: - run: bun install - # Only the unit-fuzz job needs the fork adapter, and only when fork tests - # actually exist in the tree. Fork tests are PR-scoped, so `test/fork/` is - # empty on master and `hashFiles` returns '' (step skipped, no docker build). + # Only the fork shard needs the adapter, and only when fork tests actually + # exist in the tree. Fork tests are PR-scoped, so `test/fork/` is empty on + # master and `hashFiles` returns '' (step skipped, no docker build). - name: Start revive-eth-rpc (paseo_local fork target) - if: matrix.kind.id == 'unit-fuzz' && hashFiles('test/fork/**') != '' + if: matrix.kind.id == 'fork' && hashFiles('test/fork/**') != '' run: | docker compose up -d --build bash scripts/shell/wait-for-eth-rpc.sh @@ -96,6 +99,14 @@ jobs: FOUNDRY_INVARIANT_GAS_REPORT_SAMPLES: "1000" run: | set +e + # The fork shard is scheduled on every PR but only has work when an + # upgrade PR ships fork tests. With none in the tree it reports Skipped + # so the CI Summary row is truthful rather than a hollow pass. + if [ "${{ matrix.kind.id }}" = "fork" ] && [ -z "$(ls test/fork/*.t.sol 2>/dev/null)" ]; then + echo "result=Skipped - no fork tests in tree" >> "$GITHUB_OUTPUT" + echo "has_details=false" >> "$GITHUB_OUTPUT" + exit 0 + fi # Remove any prior build-info before `forge build` so the JSON the OZ # validator (vm.ffi inside upgrade tests) reads is the complete one # this build emits. Defence-in-depth: on a fresh `ubuntu-latest` @@ -122,18 +133,8 @@ jobs: exit 1 fi - # Fork tests run against the revive-eth-rpc adapter started above, only - # when present. The preceding `forge build` already produced full build-info - # for the OZ upgrade validator, so this reuses it. - - name: Run fork tests - if: matrix.kind.id == 'unit-fuzz' && hashFiles('test/fork/**') != '' - env: - FOUNDRY_DISABLE_NIGHTLY_WARNING: "1" - FOUNDRY_PROFILE: "ci" - run: forge test -vv --match-path 'test/fork/**' - - name: Tear down revive-eth-rpc - if: always() && matrix.kind.id == 'unit-fuzz' && hashFiles('test/fork/**') != '' + if: always() && matrix.kind.id == 'fork' && hashFiles('test/fork/**') != '' run: docker compose down --volumes --remove-orphans - name: Stage matrix-kind output for the aggregator @@ -267,7 +268,7 @@ jobs: } } - const order = ['4naly3er Analysis', 'Slither Analysis', 'Contract Tests (Unit + Fuzz)', 'Contract Tests (Invariant)', 'Gas Report', 'Coverage', 'Documentation', 'Format & Lint', 'File Validation', 'Deploy Contracts', 'PR Title', 'Labels']; + const order = ['4naly3er Analysis', 'Slither Analysis', 'Contract Tests (Unit + Fuzz)', 'Contract Tests (Invariant)', 'Upgrade Fork Tests', 'Gas Report', 'Coverage', 'Documentation', 'Format & Lint', 'File Validation', 'Deploy Contracts', 'PR Title', 'Labels']; const sortedKeys = Object.keys(rows).sort((a, b) => (order.indexOf(a) === -1 ? 999 : order.indexOf(a)) - (order.indexOf(b) === -1 ? 999 : order.indexOf(b))); let table = `| Check | Result |\n|:------|:-------|\n`; for (const key of sortedKeys) table += `| ${key} | ${rows[key]} |\n`; From 9bf7069155b7574aeacb7733f8110f29f6147d97 Mon Sep 17 00:00:00 2001 From: Siphamandla Mjoli Date: Wed, 9 Sep 2026 02:22:18 +0200 Subject: [PATCH 03/25] ci: run jobs on the parity xl runner and isolate the upgrade fork job --- .github/actionlint.yaml | 9 ++ .github/workflows/4naly3er.yml | 4 +- .github/workflows/contract-coverage.yml | 4 +- .github/workflows/deploy-contracts.yml | 2 +- .github/workflows/deploy-docs.yml | 4 +- .github/workflows/format-lint-product.yml | 2 +- .github/workflows/gas-report.yml | 6 +- .github/workflows/genesis-extractor-test.yml | 2 +- .github/workflows/issue-add-to-project.yml | 2 +- .github/workflows/issue-auto-label.yml | 2 +- .github/workflows/pr-title.yml | 4 +- .github/workflows/publish-prerelease.yml | 2 +- .github/workflows/publish-release.yml | 2 +- .github/workflows/push_checking.yml | 134 ++++++++++++++---- .github/workflows/release-metadata.yml | 2 +- .github/workflows/secret-scan.yml | 2 +- .github/workflows/slither.yaml | 4 +- .github/workflows/validate-files.yml | 2 +- CONTRIBUTING.md | 18 +++ contracts/access/DotnsRoleManagerOld.sol | 2 + contracts/access/IDotnsRoleManagerOld.sol | 2 + contracts/pop/IPopRulesOld.sol | 2 + contracts/pop/PopRulesOld.sol | 2 + .../registrars/DotnsPopControllerOld.sol | 2 + contracts/registrars/DotnsRegistrarOld.sol | 2 + .../registrars/IDotnsPopControllerOld.sol | 2 + contracts/registrars/IDotnsRegistrarOld.sol | 2 + contracts/whitelist/DotnsNameWhitelistOld.sol | 2 + .../whitelist/IDotnsNameWhitelistOld.sol | 2 + scripts/deploy/UpgradeLabelStore.s.sol | 8 ++ scripts/deploy/UpgradeNameWhitelist.s.sol | 8 ++ scripts/deploy/UpgradePopController.s.sol | 8 ++ scripts/deploy/UpgradePopRules.s.sol | 8 ++ scripts/deploy/UpgradeRegistrar.s.sol | 8 ++ .../deploy/UpgradeRegistrarController.s.sol | 8 ++ scripts/deploy/upgrade.sh | 34 +++++ test/fork/UpgradeNameWhitelist.t.sol | 33 +++-- test/fork/UpgradePopController.t.sol | 38 ++++- test/fork/UpgradePopRules.t.sol | 41 +++++- test/fork/UpgradeRegistrar.t.sol | 91 ++++++++++-- 40 files changed, 435 insertions(+), 77 deletions(-) create mode 100644 .github/actionlint.yaml create mode 100755 scripts/deploy/upgrade.sh diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml new file mode 100644 index 000000000..9ad2a231b --- /dev/null +++ b/.github/actionlint.yaml @@ -0,0 +1,9 @@ +# actionlint configuration. Declares the self-hosted runner labels this repo +# dispatches to so actionlint does not flag them as unknown; without this it +# only knows the GitHub-hosted labels and rejects every `runs-on` that targets +# the Parity fleet. +self-hosted-runner: + labels: + - parity-int-ubuntu + - parity-int-ubuntu-l + - parity-int-ubuntu-xl diff --git a/.github/workflows/4naly3er.yml b/.github/workflows/4naly3er.yml index 1a03275b5..5552b3c4b 100644 --- a/.github/workflows/4naly3er.yml +++ b/.github/workflows/4naly3er.yml @@ -28,7 +28,7 @@ concurrency: jobs: analyze: - runs-on: ubuntu-latest + runs-on: parity-int-ubuntu-xl if: github.event.action != 'closed' steps: - uses: actions/checkout@v4 @@ -184,7 +184,7 @@ jobs: else await github.rest.issues.createComment({ owner, repo, issue_number, body }); cleanup: - runs-on: ubuntu-latest + runs-on: parity-int-ubuntu-xl if: github.event.action == 'closed' steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/contract-coverage.yml b/.github/workflows/contract-coverage.yml index f1842a6d2..1ed160661 100644 --- a/.github/workflows/contract-coverage.yml +++ b/.github/workflows/contract-coverage.yml @@ -28,7 +28,7 @@ concurrency: jobs: coverage: - runs-on: ubuntu-latest + runs-on: parity-int-ubuntu-xl if: github.event.action != 'closed' steps: - uses: actions/checkout@v4 @@ -232,7 +232,7 @@ jobs: } cleanup: - runs-on: ubuntu-latest + runs-on: parity-int-ubuntu-xl if: github.event.action == 'closed' steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/deploy-contracts.yml b/.github/workflows/deploy-contracts.yml index fc20fe8b9..9e040e030 100644 --- a/.github/workflows/deploy-contracts.yml +++ b/.github/workflows/deploy-contracts.yml @@ -21,7 +21,7 @@ concurrency: jobs: deploy: - runs-on: ubuntu-latest + runs-on: parity-int-ubuntu-xl # Shared across steps. ACCOUNT_* is anvil test account 7 (public test keys, # never valid on a real network) used to run the pipeline. FACTORY_DEPLOYER is diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml index 915aed202..a7593f7a7 100644 --- a/.github/workflows/deploy-docs.yml +++ b/.github/workflows/deploy-docs.yml @@ -32,7 +32,7 @@ concurrency: jobs: build: - runs-on: ubuntu-latest + runs-on: parity-int-ubuntu-xl if: github.event.action != 'closed' steps: - uses: actions/checkout@v4 @@ -230,7 +230,7 @@ jobs: run: exit 1 cleanup: - runs-on: ubuntu-latest + runs-on: parity-int-ubuntu-xl if: github.event.action == 'closed' steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/format-lint-product.yml b/.github/workflows/format-lint-product.yml index 60c8e1ae5..34534e143 100644 --- a/.github/workflows/format-lint-product.yml +++ b/.github/workflows/format-lint-product.yml @@ -11,7 +11,7 @@ permissions: jobs: check: - runs-on: ubuntu-latest + runs-on: parity-int-ubuntu-xl steps: - uses: actions/checkout@v4 with: diff --git a/.github/workflows/gas-report.yml b/.github/workflows/gas-report.yml index bf47a9b8d..b8fca0e2d 100644 --- a/.github/workflows/gas-report.yml +++ b/.github/workflows/gas-report.yml @@ -34,7 +34,7 @@ jobs: generate: name: Gas Report (${{ matrix.target.label }}) if: github.event.action != 'closed' - runs-on: ubuntu-latest + runs-on: parity-int-ubuntu-xl strategy: # Each target runs on its own runner so the PR-branch and master-branch # gas reports do not have to share one machine's memory budget. The @@ -167,7 +167,7 @@ jobs: name: Gas Report Diff needs: generate if: always() && github.event_name == 'pull_request' && github.event.action != 'closed' - runs-on: ubuntu-latest + runs-on: parity-int-ubuntu-xl steps: - uses: actions/checkout@v4 @@ -366,7 +366,7 @@ jobs: } cleanup: - runs-on: ubuntu-latest + runs-on: parity-int-ubuntu-xl if: github.event.action == 'closed' steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/genesis-extractor-test.yml b/.github/workflows/genesis-extractor-test.yml index d5c795dda..0c33010d2 100644 --- a/.github/workflows/genesis-extractor-test.yml +++ b/.github/workflows/genesis-extractor-test.yml @@ -15,7 +15,7 @@ permissions: jobs: genesis-extractor-test: - runs-on: ubuntu-latest + runs-on: parity-int-ubuntu-xl steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/issue-add-to-project.yml b/.github/workflows/issue-add-to-project.yml index dfbac4605..d69b1bce9 100644 --- a/.github/workflows/issue-add-to-project.yml +++ b/.github/workflows/issue-add-to-project.yml @@ -6,7 +6,7 @@ on: jobs: add-to-project: - runs-on: ubuntu-latest + runs-on: parity-int-ubuntu-xl steps: - uses: actions/add-to-project@v1.0.2 with: diff --git a/.github/workflows/issue-auto-label.yml b/.github/workflows/issue-auto-label.yml index 32355d6cb..aaa04832a 100644 --- a/.github/workflows/issue-auto-label.yml +++ b/.github/workflows/issue-auto-label.yml @@ -10,7 +10,7 @@ permissions: jobs: label: - runs-on: ubuntu-latest + runs-on: parity-int-ubuntu-xl steps: - name: Extract labels from form body id: extract diff --git a/.github/workflows/pr-title.yml b/.github/workflows/pr-title.yml index 9632d30b2..6d25638b5 100644 --- a/.github/workflows/pr-title.yml +++ b/.github/workflows/pr-title.yml @@ -12,7 +12,7 @@ permissions: jobs: title: name: PR Title - runs-on: ubuntu-latest + runs-on: parity-int-ubuntu-xl steps: - name: Check PR title id: title @@ -122,7 +122,7 @@ jobs: labels: name: Labels - runs-on: ubuntu-latest + runs-on: parity-int-ubuntu-xl steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/publish-prerelease.yml b/.github/workflows/publish-prerelease.yml index 3d7f33888..596fc8bae 100644 --- a/.github/workflows/publish-prerelease.yml +++ b/.github/workflows/publish-prerelease.yml @@ -22,7 +22,7 @@ concurrency: jobs: beta-release: - runs-on: ubuntu-latest + runs-on: parity-int-ubuntu-xl # Baked into the genesis registry, so it decides which networks the artifact suits. # Job-level: five steps name the file derived from it and must not disagree. env: diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index a4682dc0d..866579328 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -22,7 +22,7 @@ concurrency: jobs: release: - runs-on: ubuntu-latest + runs-on: parity-int-ubuntu-xl # Baked into the genesis registry, so it decides which networks the artifact suits. # Job-level: five steps name the file derived from it and must not disagree. env: diff --git a/.github/workflows/push_checking.yml b/.github/workflows/push_checking.yml index eb93f461f..5a0af30f5 100644 --- a/.github/workflows/push_checking.yml +++ b/.github/workflows/push_checking.yml @@ -34,7 +34,7 @@ jobs: test: if: github.event.action != 'closed' name: ${{ matrix.kind.label }} - runs-on: ubuntu-latest + runs-on: parity-int-ubuntu-xl strategy: # Each matrix kind runs on its own runner so neither has to hold the # other's peak memory. Don't fail-fast: surfacing both shards' results @@ -48,9 +48,6 @@ jobs: - id: invariant label: Contract Tests (Invariant) forge_args: --match-contract Invariant --no-match-path 'test/fork/**' - - id: fork - label: Upgrade Fork Tests - forge_args: --match-path 'test/fork/**' steps: - uses: actions/checkout@v4 with: @@ -81,15 +78,6 @@ jobs: - run: bun install - # Only the fork shard needs the adapter, and only when fork tests actually - # exist in the tree. Fork tests are PR-scoped, so `test/fork/` is empty on - # master and `hashFiles` returns '' (step skipped, no docker build). - - name: Start revive-eth-rpc (paseo_local fork target) - if: matrix.kind.id == 'fork' && hashFiles('test/fork/**') != '' - run: | - docker compose up -d --build - bash scripts/shell/wait-for-eth-rpc.sh - - name: Run tests id: run env: @@ -99,14 +87,6 @@ jobs: FOUNDRY_INVARIANT_GAS_REPORT_SAMPLES: "1000" run: | set +e - # The fork shard is scheduled on every PR but only has work when an - # upgrade PR ships fork tests. With none in the tree it reports Skipped - # so the CI Summary row is truthful rather than a hollow pass. - if [ "${{ matrix.kind.id }}" = "fork" ] && [ -z "$(ls test/fork/*.t.sol 2>/dev/null)" ]; then - echo "result=Skipped - no fork tests in tree" >> "$GITHUB_OUTPUT" - echo "has_details=false" >> "$GITHUB_OUTPUT" - exit 0 - fi # Remove any prior build-info before `forge build` so the JSON the OZ # validator (vm.ffi inside upgrade tests) reads is the complete one # this build emits. Defence-in-depth: on a fresh `ubuntu-latest` @@ -133,10 +113,6 @@ jobs: exit 1 fi - - name: Tear down revive-eth-rpc - if: always() && matrix.kind.id == 'fork' && hashFiles('test/fork/**') != '' - run: docker compose down --volumes --remove-orphans - - name: Stage matrix-kind output for the aggregator if: always() run: | @@ -164,11 +140,113 @@ jobs: - if: steps.run.outputs.result && contains(steps.run.outputs.result, 'Failed') run: exit 1 + # Fork tests validate the PR-scoped upgrade scripts against live Paseo Asset Hub + # state through the ETH-RPC adapter. They exist only during an upgrade PR, so a + # cheap detect job decides whether the heavy fork runner is provisioned at all: + # nothing sets up on a PR without `test/fork/**`. + detect-fork: + if: github.event.action != 'closed' + runs-on: ubuntu-latest + outputs: + has_fork: ${{ steps.detect.outputs.has_fork }} + steps: + - uses: actions/checkout@v4 + - id: detect + run: | + if ls test/fork/*.t.sol > /dev/null 2>&1; then + echo "has_fork=true" >> "$GITHUB_OUTPUT" + else + echo "has_fork=false" >> "$GITHUB_OUTPUT" + fi + + fork: + name: Upgrade Fork Tests + needs: detect-fork + if: github.event.action != 'closed' && needs.detect-fork.outputs.has_fork == 'true' + # The fork job builds the revive ETH-RPC adapter image and runs the upgrade + # suite, so it is kept off the shared test runner and onto the Parity XL runner. + runs-on: parity-int-ubuntu-xl + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - uses: ./.github/actions/setup-foundry + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: "1.2.6" + no-cache: true + + - uses: actions/setup-node@v4 + with: + node-version: "20" + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - uses: actions/cache@v4 + with: + path: node_modules + key: bun-${{ hashFiles('bun.lock') }} + restore-keys: bun- + + - run: bun install + + # Reuse the repository's docker compose eth-rpc service as the paseo_local + # fork target. + - name: Start revive-eth-rpc (paseo_local fork target) + run: | + docker compose up -d --build + bash scripts/shell/wait-for-eth-rpc.sh + + - name: Run fork tests + id: fork + env: + FOUNDRY_DISABLE_NIGHTLY_WARNING: "1" + FOUNDRY_PROFILE: "ci" + run: | + set +e + # A clean build-info keeps the OZ upgrade validator (vm.ffi) from reading + # a partial JSON on a warm self-hosted runner. + rm -rf out/build-info + forge test -vv --match-path 'test/fork/**' 2>&1 | tee fork.log + FORK=${PIPESTATUS[0]} + if [ "$FORK" -eq 0 ]; then + echo "result=Passed" >> "$GITHUB_OUTPUT" + else + echo "result=Failed" >> "$GITHUB_OUTPUT" + fi + exit "$FORK" + + - name: Tear down revive-eth-rpc + if: always() + run: docker compose down --volumes --remove-orphans + + # Report the outcome as its own CI Summary row through a shard artifact the + # report job renders. `always()` so a failed run still uploads the Failed row + # and the fork log for the reviewer. + - name: Stage upgrade-fork output for the aggregator + if: always() + run: | + mkdir -p forkshard + printf 'Upgrade Fork Tests|%s|false\n' \ + "${{ steps.fork.outputs.result || 'Failed' }}" > forkshard/result.txt + [ -f fork.log ] && cp fork.log forkshard/fork.log || true + + - uses: actions/upload-artifact@v4 + if: always() + with: + name: test-shard-fork + path: forkshard + retention-days: 7 + report: name: Report Test Results - needs: test + needs: [test, fork] if: always() && github.event_name == 'pull_request' && github.event.action != 'closed' - runs-on: ubuntu-latest + runs-on: parity-int-ubuntu-xl steps: - uses: actions/checkout@v4 @@ -290,7 +368,7 @@ jobs: else await github.rest.issues.createComment({ owner, repo, issue_number, body }); cleanup: - runs-on: ubuntu-latest + runs-on: parity-int-ubuntu-xl if: github.event.action == 'closed' steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/release-metadata.yml b/.github/workflows/release-metadata.yml index d0ff5577d..f9f62e457 100644 --- a/.github/workflows/release-metadata.yml +++ b/.github/workflows/release-metadata.yml @@ -18,7 +18,7 @@ permissions: jobs: validate: - runs-on: ubuntu-latest + runs-on: parity-int-ubuntu-xl steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/secret-scan.yml b/.github/workflows/secret-scan.yml index 6654e7cab..2b05c0f8d 100644 --- a/.github/workflows/secret-scan.yml +++ b/.github/workflows/secret-scan.yml @@ -24,7 +24,7 @@ env: jobs: scan: - runs-on: ubuntu-latest + runs-on: parity-int-ubuntu-xl steps: - uses: actions/checkout@v4 with: diff --git a/.github/workflows/slither.yaml b/.github/workflows/slither.yaml index e77d642a7..41841c3bb 100644 --- a/.github/workflows/slither.yaml +++ b/.github/workflows/slither.yaml @@ -28,7 +28,7 @@ concurrency: jobs: analyze: - runs-on: ubuntu-latest + runs-on: parity-int-ubuntu-xl if: github.event.action != 'closed' steps: - uses: actions/checkout@v4 @@ -163,7 +163,7 @@ jobs: else await github.rest.issues.createComment({ owner, repo, issue_number, body }); cleanup: - runs-on: ubuntu-latest + runs-on: parity-int-ubuntu-xl if: github.event.action == 'closed' steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/validate-files.yml b/.github/workflows/validate-files.yml index 54bd0808c..c1ac16c54 100644 --- a/.github/workflows/validate-files.yml +++ b/.github/workflows/validate-files.yml @@ -21,7 +21,7 @@ concurrency: jobs: validate: name: File Validation - runs-on: ubuntu-latest + runs-on: parity-int-ubuntu-xl steps: - uses: actions/checkout@v4 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6d9051183..5b0a8e6c3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -259,16 +259,34 @@ The `Old.sol` convention has a fixed shape. For a contract `Foo.sol` declaring ` **`Old.sol` snapshots are PR-scoped and must never land on `master`.** They exist only for the upgrade PR that introduces them, so CI and local `forge build` can diff the new layout against the pre-upgrade layout. **Before the PR merges, every `Old.sol` (and every matching `I*Old.sol`) must be deleted, along with the `referenceContract` wiring in the upgrade script.** Once the upgrade is live, the "old" layout is the on-chain deployment, not a file in the repository; keeping the snapshot around after merge would create a phantom contract that future diffs would treat as real code. Reviewers should refuse any PR that ships `Old.sol` files to `master`. +### The upgrade script + +Each upgraded proxy has one `Upgrade.s.sol` under `scripts/deploy/`, paired with its fork test. The script resolves the target proxy from the on-disk manifest, runs the layout diff against the `Old.sol` snapshot, and swaps the implementation through `Upgrades.upgradeProxy`. A beacon-backed store rotates its shared beacon through the factory's upgrade entrypoint after `Upgrades.validateUpgrade`, rather than a per-proxy call. The `referenceContract` is always supplied, so the layout diff is mandatory and fails closed; there is no environment switch that turns it off. + +The script asserts the broadcaster owns the proxy, or for a beacon the factory that owns it, before the swap, so a wrong signer fails fast with a clear message rather than reverting inside the upgrade call. It passes no initialiser data unless the new implementation adds storage that needs seeding. + ### Fork tests Fork tests are upgrade-PR scoped. They live in `test/fork/` for the duration of an upgrade PR, paired 1:1 with the upgrade script under `scripts/deploy/`. They run against a local Paseo Asset Hub fork via the ETH-RPC adapter described in the README's deployment note, and they are deleted alongside the upgrade script and the matching `Old.sol` snapshots before merge. Between upgrade PRs the directory is empty. +Each fork test forks live Asset Hub state, seeds or reads real on-chain state through the deployed implementation, runs the upgrade script, and asserts that state and every P0 path survive on the new implementation. Assertions exercise the real flows rather than bare mints, so a layout regression in a live slot fails the test. The `Old.sol` snapshot reproduces the layout of the implementation currently deployed on-chain, and the fork test is what confirms it: a snapshot that diverged from the live implementation makes the preserved-state assertions fail. + While a fork test is in flight, skip it with: ```bash forge test --no-match-path 'test/fork/**' ``` +### Broadcasting the upgrade + +Broadcast one upgrade at a time with `scripts/deploy/upgrade.sh`, which is permanent tooling and stays on `master`: + +```bash +SCRIPT=UpgradeRegistrar ACCOUNT_NAME= RPC_URL= ./scripts/deploy/upgrade.sh +``` + +It resolves the deployer account and reuses the shared forge flags (`--legacy`, `--slow`, and the gas limit matching the block gas limit), so an upgrade broadcast cannot drift from the deploy pipeline. The simulation is never skipped. + ### Cleanup checklist before merging an upgrade PR 1. Delete the upgrade script under `scripts/deploy/`. diff --git a/contracts/access/DotnsRoleManagerOld.sol b/contracts/access/DotnsRoleManagerOld.sol index 356c1532d..d77cecfd8 100644 --- a/contracts/access/DotnsRoleManagerOld.sol +++ b/contracts/access/DotnsRoleManagerOld.sol @@ -11,6 +11,8 @@ import { import {IDotnsRoleManagerOld} from "./IDotnsRoleManagerOld.sol"; /// @title Dotns Role Manager +/// @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 Shared owner-administered role layer for DotNS contracts with operational roles. /// @dev Consuming contracts define their supported role set in @custom:function _isSupportedRole. /// The owner remains the only account that can grant or revoke roles; role holders receive diff --git a/contracts/access/IDotnsRoleManagerOld.sol b/contracts/access/IDotnsRoleManagerOld.sol index 030ed7158..e8f10c895 100644 --- a/contracts/access/IDotnsRoleManagerOld.sol +++ b/contracts/access/IDotnsRoleManagerOld.sol @@ -4,6 +4,8 @@ pragma solidity ^0.8.34; import {IAccessControl} from "@openzeppelin/contracts/access/IAccessControl.sol"; /// @title DotNS Role Manager +/// @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 Shared owner-administered role API for DotNS contracts with operational roles. /// @dev Role identifiers are declared in `DotnsConstants`. Ownership remains the source of /// super-user authority: the owner grants and revokes supported roles, while role holders diff --git a/contracts/pop/IPopRulesOld.sol b/contracts/pop/IPopRulesOld.sol index da045f2f7..be42a98ae 100644 --- a/contracts/pop/IPopRulesOld.sol +++ b/contracts/pop/IPopRulesOld.sol @@ -2,6 +2,8 @@ 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 diff --git a/contracts/pop/PopRulesOld.sol b/contracts/pop/PopRulesOld.sol index 043b570f4..1f3370ee2 100644 --- a/contracts/pop/PopRulesOld.sol +++ b/contracts/pop/PopRulesOld.sol @@ -19,6 +19,8 @@ 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), diff --git a/contracts/registrars/DotnsPopControllerOld.sol b/contracts/registrars/DotnsPopControllerOld.sol index 1f18154d7..42c4a4b5d 100644 --- a/contracts/registrars/DotnsPopControllerOld.sol +++ b/contracts/registrars/DotnsPopControllerOld.sol @@ -27,6 +27,8 @@ 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` diff --git a/contracts/registrars/DotnsRegistrarOld.sol b/contracts/registrars/DotnsRegistrarOld.sol index d3c7dfdb7..587eb7ad6 100644 --- a/contracts/registrars/DotnsRegistrarOld.sol +++ b/contracts/registrars/DotnsRegistrarOld.sol @@ -24,6 +24,8 @@ import {IPopRules} from "../pop/IPopRules.sol"; import {DotnsConstants} from "../utils/DotnsConstants.sol"; /// @title Dotns Registrar +/// @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 ERC721-backed registrar implementing permanent name ownership. /// @dev Deliberately policy-free. Transfers are supported to allow ownership changes without /// registry hooks, and the registrar itself does not encode pricing, reservations, or PoP diff --git a/contracts/registrars/IDotnsPopControllerOld.sol b/contracts/registrars/IDotnsPopControllerOld.sol index 841b1443b..d02cbbcc3 100644 --- a/contracts/registrars/IDotnsPopControllerOld.sol +++ b/contracts/registrars/IDotnsPopControllerOld.sol @@ -4,6 +4,8 @@ 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 diff --git a/contracts/registrars/IDotnsRegistrarOld.sol b/contracts/registrars/IDotnsRegistrarOld.sol index 0539a179c..a3c5c470a 100644 --- a/contracts/registrars/IDotnsRegistrarOld.sol +++ b/contracts/registrars/IDotnsRegistrarOld.sol @@ -5,6 +5,8 @@ import {IERC721} from "@openzeppelin/contracts-upgradeable/token/ERC721/ERC721Up import {IDotnsController} from "./IDotnsController.sol"; /// @title Dotns Registrar +/// @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 ERC721-backed ownership for DotNS names with controller-gated registration. /// @dev Intentionally minimal and policy-free. Provides ERC721 ownership for registered name /// token IDs and controller-gated registration; pricing, PoP enforcement, and flow-specific diff --git a/contracts/whitelist/DotnsNameWhitelistOld.sol b/contracts/whitelist/DotnsNameWhitelistOld.sol index 902895724..bca42fa5b 100644 --- a/contracts/whitelist/DotnsNameWhitelistOld.sol +++ b/contracts/whitelist/DotnsNameWhitelistOld.sol @@ -14,6 +14,8 @@ import {DotnsConstants} from "../utils/DotnsConstants.sol"; import {SystemUtils} from "../utils/SystemUtils.sol"; /// @title DotnsNameWhitelist +/// @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 Pre-launch name whitelist. A name is Open until governance reserves it or a claim is /// accepted for it. Several beneficiaries may claim the same Open name, each with a /// reason, and governance accepts one as the winner. diff --git a/contracts/whitelist/IDotnsNameWhitelistOld.sol b/contracts/whitelist/IDotnsNameWhitelistOld.sol index 42a85dd94..99bbf0cb2 100644 --- a/contracts/whitelist/IDotnsNameWhitelistOld.sol +++ b/contracts/whitelist/IDotnsNameWhitelistOld.sol @@ -2,6 +2,8 @@ pragma solidity ^0.8.34; /// @title IDotnsNameWhitelist +/// @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 pre-launch name whitelist. A name is Open until governance either /// reserves it or a claim is accepted for it. Several beneficiaries may claim the same /// Open name, each with a reason, and governance accepts one as the winner. diff --git a/scripts/deploy/UpgradeLabelStore.s.sol b/scripts/deploy/UpgradeLabelStore.s.sol index 00e47844f..fe4265559 100644 --- a/scripts/deploy/UpgradeLabelStore.s.sol +++ b/scripts/deploy/UpgradeLabelStore.s.sol @@ -6,6 +6,9 @@ import {Options} from "openzeppelin-foundry-upgrades/Options.sol"; import {Upgrades} from "openzeppelin-foundry-upgrades/Upgrades.sol"; import {BaseDeployer} from "./BaseDeployer.s.sol"; +import { + OwnableUpgradeable +} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; import {IStoreFactory} from "../../contracts/store/IStoreFactory.sol"; /// @title UpgradeLabelStore @@ -71,6 +74,11 @@ contract UpgradeLabelStore is BaseDeployer { internal returns (address newImplementation) { + require( + owner == OwnableUpgradeable(factory).owner(), + "UpgradeLabelStore: broadcaster is not the store factory owner" + ); + Options memory opts; opts.referenceContract = REFERENCE_CONTRACT; diff --git a/scripts/deploy/UpgradeNameWhitelist.s.sol b/scripts/deploy/UpgradeNameWhitelist.s.sol index ca2ebc271..d98aee683 100644 --- a/scripts/deploy/UpgradeNameWhitelist.s.sol +++ b/scripts/deploy/UpgradeNameWhitelist.s.sol @@ -6,6 +6,9 @@ import {Options} from "openzeppelin-foundry-upgrades/Options.sol"; import {Upgrades} from "openzeppelin-foundry-upgrades/Upgrades.sol"; import {BaseDeployer} from "./BaseDeployer.s.sol"; +import { + OwnableUpgradeable +} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; /// @title UpgradeNameWhitelist /// @notice Upgrades the deployed DotnsNameWhitelist proxy to the current implementation. Resolves @@ -51,6 +54,11 @@ contract UpgradeNameWhitelist is BaseDeployer { /// @param owner Account that owns the proxy and broadcasts the upgrade. /// @param proxy Whitelist proxy address resolved from the manifest. function _upgradeNameWhitelist(address owner, address proxy) internal { + require( + owner == OwnableUpgradeable(proxy).owner(), + "UpgradeNameWhitelist: broadcaster is not the proxy owner" + ); + Options memory opts; opts.referenceContract = REFERENCE_CONTRACT; diff --git a/scripts/deploy/UpgradePopController.s.sol b/scripts/deploy/UpgradePopController.s.sol index b529b4676..fd35cc672 100644 --- a/scripts/deploy/UpgradePopController.s.sol +++ b/scripts/deploy/UpgradePopController.s.sol @@ -6,6 +6,9 @@ import {Options} from "openzeppelin-foundry-upgrades/Options.sol"; import {Upgrades} from "openzeppelin-foundry-upgrades/Upgrades.sol"; import {BaseDeployer} from "./BaseDeployer.s.sol"; +import { + OwnableUpgradeable +} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; /// @title UpgradePopController /// @notice Upgrades the deployed DotnsPopController proxy to the current implementation. Resolves @@ -51,6 +54,11 @@ contract UpgradePopController is BaseDeployer { /// @param owner Account that owns the proxy and broadcasts the upgrade. /// @param proxy PoP controller proxy address resolved from the manifest. function _upgradePopController(address owner, address proxy) internal { + require( + owner == OwnableUpgradeable(proxy).owner(), + "UpgradePopController: broadcaster is not the proxy owner" + ); + Options memory opts; opts.referenceContract = REFERENCE_CONTRACT; diff --git a/scripts/deploy/UpgradePopRules.s.sol b/scripts/deploy/UpgradePopRules.s.sol index 0d4079794..da5f4a7e7 100644 --- a/scripts/deploy/UpgradePopRules.s.sol +++ b/scripts/deploy/UpgradePopRules.s.sol @@ -6,6 +6,9 @@ import {Options} from "openzeppelin-foundry-upgrades/Options.sol"; import {Upgrades} from "openzeppelin-foundry-upgrades/Upgrades.sol"; import {BaseDeployer} from "./BaseDeployer.s.sol"; +import { + OwnableUpgradeable +} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; /// @title UpgradePopRules /// @notice Upgrades the deployed PopRules proxy to the current implementation. Resolves the proxy @@ -50,6 +53,11 @@ contract UpgradePopRules is BaseDeployer { /// @param owner Account that owns the proxy and broadcasts the upgrade. /// @param proxy PopRules proxy address resolved from the manifest. function _upgradePopRules(address owner, address proxy) internal { + require( + owner == OwnableUpgradeable(proxy).owner(), + "UpgradePopRules: broadcaster is not the proxy owner" + ); + Options memory opts; opts.referenceContract = REFERENCE_CONTRACT; diff --git a/scripts/deploy/UpgradeRegistrar.s.sol b/scripts/deploy/UpgradeRegistrar.s.sol index 36a7662c8..f7e14c5b1 100644 --- a/scripts/deploy/UpgradeRegistrar.s.sol +++ b/scripts/deploy/UpgradeRegistrar.s.sol @@ -6,6 +6,9 @@ import {Options} from "openzeppelin-foundry-upgrades/Options.sol"; import {Upgrades} from "openzeppelin-foundry-upgrades/Upgrades.sol"; import {BaseDeployer} from "./BaseDeployer.s.sol"; +import { + OwnableUpgradeable +} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; /// @title UpgradeRegistrar /// @notice Upgrades the deployed DotnsRegistrar proxy to the current implementation. Resolves the @@ -50,6 +53,11 @@ contract UpgradeRegistrar is BaseDeployer { /// @param owner Account that owns the proxy and broadcasts the upgrade. /// @param proxy Registrar proxy address resolved from the manifest. function _upgradeRegistrar(address owner, address proxy) internal { + require( + owner == OwnableUpgradeable(proxy).owner(), + "UpgradeRegistrar: broadcaster is not the proxy owner" + ); + Options memory opts; opts.referenceContract = REFERENCE_CONTRACT; diff --git a/scripts/deploy/UpgradeRegistrarController.s.sol b/scripts/deploy/UpgradeRegistrarController.s.sol index b2002377a..65e777008 100644 --- a/scripts/deploy/UpgradeRegistrarController.s.sol +++ b/scripts/deploy/UpgradeRegistrarController.s.sol @@ -6,6 +6,9 @@ import {Options} from "openzeppelin-foundry-upgrades/Options.sol"; import {Upgrades} from "openzeppelin-foundry-upgrades/Upgrades.sol"; import {BaseDeployer} from "./BaseDeployer.s.sol"; +import { + OwnableUpgradeable +} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; /// @title UpgradeRegistrarController /// @notice Upgrades the deployed DotnsRegistrarController proxy to the current implementation. @@ -51,6 +54,11 @@ contract UpgradeRegistrarController is BaseDeployer { /// @param owner Account that owns the proxy and broadcasts the upgrade. /// @param proxy Registrar controller proxy address resolved from the manifest. function _upgradeRegistrarController(address owner, address proxy) internal { + require( + owner == OwnableUpgradeable(proxy).owner(), + "UpgradeRegistrarController: broadcaster is not the proxy owner" + ); + Options memory opts; opts.referenceContract = REFERENCE_CONTRACT; diff --git a/scripts/deploy/upgrade.sh b/scripts/deploy/upgrade.sh new file mode 100755 index 000000000..cddac14ed --- /dev/null +++ b/scripts/deploy/upgrade.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Broadcasts a single in-place upgrade script against the resolved deployer +# account. It reuses the shared forge flags from _account.sh (legacy, slow, and +# the gas limit matching the block gas limit) so an upgrade broadcast cannot +# drift from the deploy pipeline. The upgrade script resolves its target proxy +# from the on-disk manifest and runs the OpenZeppelin layout diff before the +# swap; the simulation is never skipped. +# +# Usage: +# SCRIPT=UpgradeRegistrar ACCOUNT_NAME= RPC_URL= ./scripts/deploy/upgrade.sh +# +# SCRIPT Upgrade script name, for example UpgradeRegistrar. +# ACCOUNT_NAME Foundry keystore account, passed to forge as --account. +# RPC_URL Network RPC alias or URL, same meaning as the deploy runner. +# DEPLOYMENT_NETWORK Optional manifest subdirectory override, same meaning as +# the deploy runner. + +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +cd "$ROOT" + +SCRIPT="${SCRIPT:?set SCRIPT to an upgrade script name, for example UpgradeRegistrar}" + +# shellcheck source=scripts/deploy/_account.sh +. "$(dirname "$0")/_account.sh" + +# Exported only when set so the forge script resolves the same manifest folder the +# deploy runner does; sourcing .env does not auto-export. +if [ -n "${DEPLOYMENT_NETWORK:-}" ]; then + export DEPLOYMENT_NETWORK +fi + +forge script "scripts/deploy/${SCRIPT}.s.sol:${SCRIPT}" "${FORGE_DEPLOY_ARGS[@]}" -vvvvv diff --git a/test/fork/UpgradeNameWhitelist.t.sol b/test/fork/UpgradeNameWhitelist.t.sol index 2b672f9d8..d29d3c63e 100644 --- a/test/fork/UpgradeNameWhitelist.t.sol +++ b/test/fork/UpgradeNameWhitelist.t.sol @@ -77,14 +77,19 @@ contract UpgradeNameWhitelistForkTest is Test { bob = makeAddr("bob"); } - /// @notice The upgrade preserves a name reserved before the swap and keeps a post-upgrade - /// governance grant resolving to its winner. + /// @notice The upgrade preserves both a name reserved and a governance grant seeded before the + /// swap, and still binds a fresh grant to its winner on the upgraded implementation. function test_upgrade_preservesStateAndKeepsGovernanceP0Working() public { - // Seed a reservation on the pre-upgrade implementation under a Root origin. + // Seed a reservation and a governance grant on the pre-upgrade implementation under a Root + // origin, so both a reservation slot and a name record cross the swap. _mockRoot(true); whitelist.setReserved("forkseedname", true); + whitelist.grantName("forkseedgrant", alice); _clearRoot(); assertTrue(whitelist.isReserved("forkseedname"), "pre-upgrade: seed name is reserved"); + assertEq( + whitelist.granteeOf("forkseedgrant"), alice, "pre-upgrade: seed grant binds the winner" + ); address proxy = address(whitelist); address registryBefore = address(whitelist.protocolRegistry()); @@ -93,22 +98,34 @@ contract UpgradeNameWhitelistForkTest is Test { assertEq(address(whitelist), proxy, "upgrade keeps the same proxy address"); assertTrue(whitelist.isReserved("forkseedname"), "post-upgrade: reservation preserved"); + // The grant seeded on the old implementation still resolves to its winner on the new one, + // proving the name record survived the swap rather than the new logic recomputing it. + assertEq( + whitelist.granteeOf("forkseedgrant"), + alice, + "post-upgrade: seed grant preserved" + ); + assertTrue( + whitelist.isGrantedTo("forkseedgrant", alice), + "post-upgrade: seed winner is still granted the name" + ); assertEq( address(whitelist.protocolRegistry()), registryBefore, "post-upgrade: protocol registry pointer preserved" ); - // P0: a governance grant still binds a name to its winner on the upgraded implementation. + // P0: a fresh governance grant still binds a name to its winner on the upgraded + // implementation. _mockRoot(true); - whitelist.grantName("forkgrantname", alice); + whitelist.grantName("forkgrantname", bob); _clearRoot(); assertEq( - whitelist.granteeOf("forkgrantname"), alice, "post-upgrade: grant binds the winner" + whitelist.granteeOf("forkgrantname"), bob, "post-upgrade: fresh grant binds the winner" ); assertTrue( - whitelist.isGrantedTo("forkgrantname", alice), - "post-upgrade: winner is granted the name" + whitelist.isGrantedTo("forkgrantname", bob), + "post-upgrade: fresh winner is granted the name" ); } diff --git a/test/fork/UpgradePopController.t.sol b/test/fork/UpgradePopController.t.sol index 67ed05e17..e9d1b1293 100644 --- a/test/fork/UpgradePopController.t.sol +++ b/test/fork/UpgradePopController.t.sol @@ -55,9 +55,19 @@ contract UpgradePopControllerForkTest is Test { /// tier, so the reservation path accepts it as a base name. string internal constant BASE_LABEL = "zqxwvutsrq"; - /// @notice A never-issued label, used to prove the new `isPopIssued` surface answers. + /// @notice A never-issued label, used to prove the new `isPopIssued` surface answers false. string internal constant UNISSUED_LABEL = "neverissued"; + /// @notice A base label registered through the upgraded controller to prove `isPopIssued` + /// answers true once a label is genuinely issued. + /// @dev Letters only and long enough to classify outside the governance-reserved tier, so the + /// deployed `PopRules` accepts it as a base name without change. The base path is used + /// rather than the dotted lite path because the live siblings are not upgraded here: the + /// live `PopRules` still rejects the lite separator, so a dotted lite mint reverts before + /// `_popIssued` is written. A base registration exercises the same issuance write through + /// the label form the live classifier already admits. + string internal constant ISSUED_BASE_LABEL = "qzwxrvtsplk"; + /// @notice Drives the script's upgrade path against the live proxy. UpgradePopControllerHarness internal upgrader; @@ -119,7 +129,8 @@ contract UpgradePopControllerForkTest is Test { } /// @notice The upgrade preserves ownership, sibling wiring, and reservation state on the real - /// proxy, and exposes the new `isPopIssued` surface. + /// proxy, and exposes an `isPopIssued` surface that reports false for an unissued label + /// and true for a label the upgraded controller genuinely issues. function test_upgrade_preservesStateAndExposesNewSurface() public { // Seed a base-name reservation on the pre-upgrade implementation so its survival across the // implementation swap is observable. The deployed code path gates on the gateway address. @@ -160,6 +171,29 @@ contract UpgradePopControllerForkTest is Test { popController.isPopIssued(UNISSUED_LABEL), "post-upgrade: an unissued label reports false" ); + + // Drive a real issuance through the upgraded controller so a concrete label is genuinely + // marked issued, then confirm the new surface reports it. The base registration runs under + // the mocked Root origin with no chat key and no lite link, against a fresh beneficiary + // with no store, so it exercises the real `_popIssued` write and stashes a pending claim + // without touching the resolver or deploying a store. + assertFalse( + popController.isPopIssued(ISSUED_BASE_LABEL), + "pre-issue: the base label is not yet issued" + ); + popController.registerBaseName( + IDotnsPopController.FullRegistration({ + label: ISSUED_BASE_LABEL, + user: alice, + link: IDotnsPopController.Link({ + kind: IDotnsPopController.LinkKind.None, liteLabel: "", chatKey: "" + }) + }) + ); + assertTrue( + popController.isPopIssued(ISSUED_BASE_LABEL), + "post-upgrade: a genuinely issued label reports true" + ); } /// @notice After the upgrade, the Root-gated base-name reservation path still mutates queue diff --git a/test/fork/UpgradePopRules.t.sol b/test/fork/UpgradePopRules.t.sol index b174d427c..4e0d1e7cd 100644 --- a/test/fork/UpgradePopRules.t.sol +++ b/test/fork/UpgradePopRules.t.sol @@ -9,6 +9,7 @@ import { import {PopRules} from "../../contracts/pop/PopRules.sol"; import {IPopRules} from "../../contracts/pop/IPopRules.sol"; import {IDotnsProtocolRegistry} from "../../contracts/registry/IDotnsProtocolRegistry.sol"; +import {IDotnsRegistrar} from "../../contracts/registrars/IDotnsRegistrar.sol"; import {IPersonhood} from "../../contracts/external/personhood/IPersonhood.sol"; import {ISystem} from "../../contracts/external/revive/ISystem.sol"; import {DotnsConstants} from "../../contracts/utils/DotnsConstants.sol"; @@ -46,6 +47,9 @@ contract UpgradePopRulesForkTest is Test { /// @notice A plain six-character label that sits in the governed short-name band. string internal constant SHORT_LABEL = "aliced"; + /// @notice A six-character stem seeded into the reservation mapping before the upgrade. + string internal constant RESERVED_STEM = "alicez"; + /// @notice Drives the script's upgrade path against the live proxy. UpgradePopRulesHarness internal upgrader; @@ -86,8 +90,9 @@ contract UpgradePopRulesForkTest is Test { ); } - /// @notice The upgrade keeps the proxy, the registry pointer, and the classification and - /// pricing reads intact, and installs the Root gate on the short-name lever. + /// @notice The upgrade keeps the proxy, the registry pointer, a reservation held in the + /// oracle's own storage, and the classification and pricing reads intact, and installs + /// the Root gate on the short-name lever. function test_upgrade_preservesStateAndKeepsPricingWorking() public { // Seed representative reads on the pre-upgrade implementation. address registryBefore = address(popRules.protocolRegistry()); @@ -95,6 +100,26 @@ contract UpgradePopRulesForkTest is Test { (IPopRules.PopStatus statusBefore, string memory messageBefore) = popRules.classifyName(OPEN_LABEL); + // Seed a live reservation into the oracle's own `reservations` mapping through the + // registry-gated `reserveBaseName` on the pre-upgrade implementation. The registrar's + // controller check is mocked so a chosen controller clears the gate; the write itself runs + // the deployed code and lands in real storage at the mapping's computed slot. The label + // derived pricing and classification reads above are identical before and after the swap + // whatever the oracle holds, so this reservation is the piece that proves the swap + // preserves PopRules' own mutable state rather than merely recomputing from the label. + address reservationOwner = makeAddr("reservationOwner"); + vm.mockCall( + protocolRegistry.get(DotnsConstants.REGISTRAR), + abi.encodeWithSelector(IDotnsRegistrar.controllers.selector), + abi.encode(true) + ); + vm.prank(makeAddr("controller")); + popRules.reserveBaseName(RESERVED_STEM, reservationOwner); + (bool reservedBefore, address reservedOwnerBefore, uint64 reservedExpiryBefore) = + popRules.isBaseNameReserved(RESERVED_STEM); + assertTrue(reservedBefore, "reservation live before upgrade"); + assertEq(reservedOwnerBefore, reservationOwner, "seeded reservation owner"); + address proxy = address(popRules); upgrader.upgradePopRules(popRulesOwner, proxy); @@ -105,6 +130,18 @@ contract UpgradePopRulesForkTest is Test { "post-upgrade: protocol registry pointer preserved" ); + // The mapping slot survives the swap: the same stem resolves to the same owner and expiry + // when read back through the new implementation. + (bool reservedAfter, address reservedOwnerAfter, uint64 reservedExpiryAfter) = + popRules.isBaseNameReserved(RESERVED_STEM); + assertTrue(reservedAfter, "post-upgrade: reservation still live"); + assertEq( + reservedOwnerAfter, reservedOwnerBefore, "post-upgrade: reservation owner preserved" + ); + assertEq( + reservedExpiryAfter, reservedExpiryBefore, "post-upgrade: reservation expiry preserved" + ); + // P0: pricing and classification still answer sensibly, unchanged for a plain open label. assertEq(popRules.price(OPEN_LABEL), priceBefore, "post-upgrade: price preserved"); (IPopRules.PopStatus statusAfter, string memory messageAfter) = diff --git a/test/fork/UpgradeRegistrar.t.sol b/test/fork/UpgradeRegistrar.t.sol index db0934c8e..1c2b48418 100644 --- a/test/fork/UpgradeRegistrar.t.sol +++ b/test/fork/UpgradeRegistrar.t.sol @@ -10,6 +10,7 @@ import {DotnsRegistrar} from "../../contracts/registrars/DotnsRegistrar.sol"; import {IDotnsRegistrar} from "../../contracts/registrars/IDotnsRegistrar.sol"; import {IDotnsController} from "../../contracts/registrars/IDotnsController.sol"; import {IDotnsProtocolRegistry} from "../../contracts/registry/IDotnsProtocolRegistry.sol"; +import {IPersonhood} from "../../contracts/external/personhood/IPersonhood.sol"; import {DotnsConstants} from "../../contracts/utils/DotnsConstants.sol"; import {UpgradeRegistrar} from "../../scripts/deploy/UpgradeRegistrar.s.sol"; @@ -56,11 +57,21 @@ contract UpgradeRegistrarForkTest is Test { /// @notice The deployed PoP controller, impersonated to mint soulbound names. address internal popController; + /// @notice A single label whose base length lands in the PoP-gated band, so its transfer floor + /// is the real `BASE_DEPOSIT` rather than zero. + /// @dev Eight lowercase letters classify as a `PopFull` name that an unverified recipient + /// cannot reach, so `quoteTransferFee` prices the move at the name's own deposit. + string internal constant SEED_LABEL = "seedname"; + + /// @notice A second PoP-gated single label minted after the upgrade to prove real registration + /// still writes a label through the store factory. + string internal constant POST_LABEL = "postname"; + /// @notice Recipient accounts for the transfer paths. address internal alice; address internal bob; - /// @notice Forks Paseo, resolves the live addresses, and guarantees both controllers are set. + /// @notice Forks Paseo, resolves the live addresses, and funds the transfer recipients. function setUp() public { vm.createSelectFork(vm.rpcUrl("paseo_local")); @@ -79,13 +90,22 @@ contract UpgradeRegistrarForkTest is Test { vm.deal(alice, 100 ether); vm.deal(bob, 100 ether); - // The mint paths call through the registrar's controller set. Re-asserting the two live - // controllers is a no-op when they are already registered and keeps the test independent of - // the exact wiring state of the fork. - vm.startPrank(registrarOwner); - registrar.addController(IDotnsController(registrarController)); - registrar.addController(IDotnsController(popController)); - vm.stopPrank(); + // The mint paths run through the live controller set rather than a freshly added one, so + // the test reads the wiring the deployment left in place instead of masking it. The upgrade + // assertions below prove that wiring survives the implementation swap. + } + + /// @notice Mocks the personhood precompile so every account reads as unverified. + /// @dev The substrate personhood precompile carries no bytecode on the EVM fork, so a + /// transfer-floor read reverts against live state. Pinning both parties to `NoStatus` + /// keeps the fee math real: a `PopFull` name an unverified recipient cannot reach prices + /// the move at the name's own deposit. + function _mockNoPersonhood() internal { + vm.mockCall( + DotnsConstants.PERSONHOOD, + abi.encodeWithSelector(IPersonhood.personhoodStatus.selector), + abi.encode(IPersonhood.PersonhoodInfo({status: 0, contextAlias: bytes32(0)})) + ); } /// @notice The upgrade preserves ownership state on the real proxy and keeps minting and @@ -93,11 +113,21 @@ contract UpgradeRegistrarForkTest is Test { function test_upgrade_preservesStateAndKeepsCoreP0Working() public { uint256 seedToken = uint256(keccak256("dotns.fork.upgrade.seed")); - // Seed ownership on the pre-upgrade implementation. An empty label takes the gateway-cold - // mint path, so the seed does not depend on a `LabelStore` deploy. + // The seed mint proves the live registrar already accepts its commit-reveal controller, so + // the test reads the deployment's wiring rather than a controller it added itself. + assertTrue( + registrar.controllers(IDotnsController(registrarController)), + "pre-upgrade: the live registrar controller is wired" + ); + + // Seed ownership on the pre-upgrade implementation with a real single label, so `register` + // writes the owner's label through the store factory and the token carries a real name. vm.prank(registrarController); - registrar.register(seedToken, alice, ""); + registrar.register(seedToken, alice, SEED_LABEL); assertEq(registrar.ownerOf(seedToken), alice, "pre-upgrade: alice owns the seed name"); + assertEq( + registrar.labelOf(seedToken), SEED_LABEL, "pre-upgrade: the seed carries its label" + ); address proxy = address(registrar); address registryBefore = address(registrar.protocolRegistry()); @@ -106,6 +136,9 @@ contract UpgradeRegistrarForkTest is Test { assertEq(address(registrar), proxy, "upgrade keeps the same proxy address"); assertEq(registrar.ownerOf(seedToken), alice, "post-upgrade: ownership preserved"); + assertEq( + registrar.labelOf(seedToken), SEED_LABEL, "post-upgrade: the seed label is preserved" + ); assertEq( address(registrar.protocolRegistry()), registryBefore, @@ -116,17 +149,39 @@ contract UpgradeRegistrarForkTest is Test { "post-upgrade: a name minted before the upgrade is not soulbound" ); - // P0: minting still works on the upgraded implementation. + // The upgrade preserves the `controllers` mapping: both live controllers stay authorised + // across the implementation swap without the test re-adding either. + assertTrue( + registrar.controllers(IDotnsController(registrarController)), + "post-upgrade: the registrar controller mapping survives the swap" + ); + assertTrue( + registrar.controllers(IDotnsController(popController)), + "post-upgrade: the PoP controller mapping survives the swap" + ); + + // P0: minting a real single label still works on the upgraded implementation, so the store + // write path runs post-swap. uint256 postToken = uint256(keccak256("dotns.fork.upgrade.post")); vm.prank(registrarController); - registrar.register(postToken, bob, ""); + registrar.register(postToken, bob, POST_LABEL); assertEq(registrar.ownerOf(postToken), bob, "post-upgrade: registration still mints"); + assertEq( + registrar.labelOf(postToken), POST_LABEL, "post-upgrade: the mint carries its label" + ); - // P0: a public name is still transferable. The empty-label seed carries no transfer floor. + // P0: a public name is still transferable through the transfer-floor path. The PoP-gated + // seed prices a move to an unverified recipient at its own deposit, so the sender pays a + // real fee and the escrow settles it rather than taking the zero-fee early return. + _mockNoPersonhood(); uint256 fee = registrar.quoteTransferFee(seedToken, bob); + assertGt(fee, 0, "post-upgrade: a PoP-gated name quotes a real transfer fee"); vm.prank(alice); registrar.transferFrom{value: fee}(alice, bob, seedToken); assertEq(registrar.ownerOf(seedToken), bob, "post-upgrade: a public name still transfers"); + assertEq( + registrar.labelOf(seedToken), SEED_LABEL, "post-upgrade: the label follows the transfer" + ); } /// @notice After the upgrade, a name minted through the PoP controller is soulbound and every @@ -134,6 +189,14 @@ contract UpgradeRegistrarForkTest is Test { function test_upgrade_enablesSoulboundGatingForGatewayMints() public { upgrader.upgradeRegistrar(registrarOwner, address(registrar)); + // The PoP controller mints through the live wiring the upgrade preserved. + assertTrue( + registrar.controllers(IDotnsController(popController)), + "post-upgrade: the PoP controller mapping survives the swap" + ); + + // The gateway-cold path stashes a pending label, so a soulbound mint takes an empty label + // and never touches the store. uint256 gatewayToken = uint256(keccak256("dotns.fork.upgrade.gateway")); vm.prank(popController); registrar.register(gatewayToken, alice, ""); From 075de7e39a18443e11875cd611c0cca17f4e8cef Mon Sep 17 00:00:00 2001 From: Siphamandla Mjoli Date: Wed, 9 Sep 2026 02:55:01 +0200 Subject: [PATCH 04/25] chore: revert CI runners to ubuntu-latest and remove the upgrade-PR scaffolding --- .github/actionlint.yaml | 9 - .github/workflows/4naly3er.yml | 4 +- .github/workflows/contract-coverage.yml | 4 +- .github/workflows/deploy-contracts.yml | 2 +- .github/workflows/deploy-docs.yml | 4 +- .github/workflows/format-lint-product.yml | 2 +- .github/workflows/gas-report.yml | 6 +- .github/workflows/genesis-extractor-test.yml | 2 +- .github/workflows/issue-add-to-project.yml | 2 +- .github/workflows/issue-auto-label.yml | 2 +- .github/workflows/pr-title.yml | 4 +- .github/workflows/publish-prerelease.yml | 2 +- .github/workflows/publish-release.yml | 2 +- .github/workflows/push_checking.yml | 8 +- .github/workflows/release-metadata.yml | 2 +- .github/workflows/secret-scan.yml | 2 +- .github/workflows/slither.yaml | 4 +- .github/workflows/validate-files.yml | 2 +- contracts/access/DotnsRoleManagerOld.sol | 103 --- contracts/access/IDotnsRoleManagerOld.sol | 34 - contracts/pop/IPopRulesOld.sol | 346 ------- contracts/pop/PopRulesOld.sol | 608 ------------- .../registrars/DotnsPopControllerOld.sol | 858 ------------------ .../DotnsRegistrarControllerOld.sol | 481 ---------- contracts/registrars/DotnsRegistrarOld.sol | 433 --------- .../registrars/IDotnsPopControllerOld.sol | 499 ---------- .../IDotnsRegistrarControllerOld.sol | 194 ---- contracts/registrars/IDotnsRegistrarOld.sol | 174 ---- contracts/store/ILabelStoreOld.sol | 124 --- contracts/store/LabelStoreOld.sol | 187 ---- contracts/whitelist/DotnsNameWhitelistOld.sol | 537 ----------- .../whitelist/IDotnsNameWhitelistOld.sol | 376 -------- scripts/deploy/UpgradeLabelStore.s.sol | 94 -- scripts/deploy/UpgradeNameWhitelist.s.sol | 71 -- scripts/deploy/UpgradePopController.s.sol | 71 -- scripts/deploy/UpgradePopRules.s.sol | 70 -- scripts/deploy/UpgradeRegistrar.s.sol | 70 -- .../deploy/UpgradeRegistrarController.s.sol | 73 -- test/fork/UpgradeLabelStore.t.sol | 159 ---- test/fork/UpgradeNameWhitelist.t.sol | 176 ---- test/fork/UpgradePopController.t.sol | 216 ----- test/fork/UpgradePopRules.t.sol | 194 ---- test/fork/UpgradeRegistrar.t.sol | 216 ----- test/fork/UpgradeRegistrarController.t.sol | 152 ---- 44 files changed, 27 insertions(+), 6552 deletions(-) delete mode 100644 .github/actionlint.yaml delete mode 100644 contracts/access/DotnsRoleManagerOld.sol delete mode 100644 contracts/access/IDotnsRoleManagerOld.sol delete mode 100644 contracts/pop/IPopRulesOld.sol delete mode 100644 contracts/pop/PopRulesOld.sol delete mode 100644 contracts/registrars/DotnsPopControllerOld.sol delete mode 100644 contracts/registrars/DotnsRegistrarControllerOld.sol delete mode 100644 contracts/registrars/DotnsRegistrarOld.sol delete mode 100644 contracts/registrars/IDotnsPopControllerOld.sol delete mode 100644 contracts/registrars/IDotnsRegistrarControllerOld.sol delete mode 100644 contracts/registrars/IDotnsRegistrarOld.sol delete mode 100644 contracts/store/ILabelStoreOld.sol delete mode 100644 contracts/store/LabelStoreOld.sol delete mode 100644 contracts/whitelist/DotnsNameWhitelistOld.sol delete mode 100644 contracts/whitelist/IDotnsNameWhitelistOld.sol delete mode 100644 scripts/deploy/UpgradeLabelStore.s.sol delete mode 100644 scripts/deploy/UpgradeNameWhitelist.s.sol delete mode 100644 scripts/deploy/UpgradePopController.s.sol delete mode 100644 scripts/deploy/UpgradePopRules.s.sol delete mode 100644 scripts/deploy/UpgradeRegistrar.s.sol delete mode 100644 scripts/deploy/UpgradeRegistrarController.s.sol delete mode 100644 test/fork/UpgradeLabelStore.t.sol delete mode 100644 test/fork/UpgradeNameWhitelist.t.sol delete mode 100644 test/fork/UpgradePopController.t.sol delete mode 100644 test/fork/UpgradePopRules.t.sol delete mode 100644 test/fork/UpgradeRegistrar.t.sol delete mode 100644 test/fork/UpgradeRegistrarController.t.sol diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml deleted file mode 100644 index 9ad2a231b..000000000 --- a/.github/actionlint.yaml +++ /dev/null @@ -1,9 +0,0 @@ -# actionlint configuration. Declares the self-hosted runner labels this repo -# dispatches to so actionlint does not flag them as unknown; without this it -# only knows the GitHub-hosted labels and rejects every `runs-on` that targets -# the Parity fleet. -self-hosted-runner: - labels: - - parity-int-ubuntu - - parity-int-ubuntu-l - - parity-int-ubuntu-xl diff --git a/.github/workflows/4naly3er.yml b/.github/workflows/4naly3er.yml index 5552b3c4b..1a03275b5 100644 --- a/.github/workflows/4naly3er.yml +++ b/.github/workflows/4naly3er.yml @@ -28,7 +28,7 @@ concurrency: jobs: analyze: - runs-on: parity-int-ubuntu-xl + runs-on: ubuntu-latest if: github.event.action != 'closed' steps: - uses: actions/checkout@v4 @@ -184,7 +184,7 @@ jobs: else await github.rest.issues.createComment({ owner, repo, issue_number, body }); cleanup: - runs-on: parity-int-ubuntu-xl + runs-on: ubuntu-latest if: github.event.action == 'closed' steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/contract-coverage.yml b/.github/workflows/contract-coverage.yml index 1ed160661..f1842a6d2 100644 --- a/.github/workflows/contract-coverage.yml +++ b/.github/workflows/contract-coverage.yml @@ -28,7 +28,7 @@ concurrency: jobs: coverage: - runs-on: parity-int-ubuntu-xl + runs-on: ubuntu-latest if: github.event.action != 'closed' steps: - uses: actions/checkout@v4 @@ -232,7 +232,7 @@ jobs: } cleanup: - runs-on: parity-int-ubuntu-xl + runs-on: ubuntu-latest if: github.event.action == 'closed' steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/deploy-contracts.yml b/.github/workflows/deploy-contracts.yml index 9e040e030..fc20fe8b9 100644 --- a/.github/workflows/deploy-contracts.yml +++ b/.github/workflows/deploy-contracts.yml @@ -21,7 +21,7 @@ concurrency: jobs: deploy: - runs-on: parity-int-ubuntu-xl + runs-on: ubuntu-latest # Shared across steps. ACCOUNT_* is anvil test account 7 (public test keys, # never valid on a real network) used to run the pipeline. FACTORY_DEPLOYER is diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml index a7593f7a7..915aed202 100644 --- a/.github/workflows/deploy-docs.yml +++ b/.github/workflows/deploy-docs.yml @@ -32,7 +32,7 @@ concurrency: jobs: build: - runs-on: parity-int-ubuntu-xl + runs-on: ubuntu-latest if: github.event.action != 'closed' steps: - uses: actions/checkout@v4 @@ -230,7 +230,7 @@ jobs: run: exit 1 cleanup: - runs-on: parity-int-ubuntu-xl + runs-on: ubuntu-latest if: github.event.action == 'closed' steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/format-lint-product.yml b/.github/workflows/format-lint-product.yml index 34534e143..60c8e1ae5 100644 --- a/.github/workflows/format-lint-product.yml +++ b/.github/workflows/format-lint-product.yml @@ -11,7 +11,7 @@ permissions: jobs: check: - runs-on: parity-int-ubuntu-xl + runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: diff --git a/.github/workflows/gas-report.yml b/.github/workflows/gas-report.yml index b8fca0e2d..bf47a9b8d 100644 --- a/.github/workflows/gas-report.yml +++ b/.github/workflows/gas-report.yml @@ -34,7 +34,7 @@ jobs: generate: name: Gas Report (${{ matrix.target.label }}) if: github.event.action != 'closed' - runs-on: parity-int-ubuntu-xl + runs-on: ubuntu-latest strategy: # Each target runs on its own runner so the PR-branch and master-branch # gas reports do not have to share one machine's memory budget. The @@ -167,7 +167,7 @@ jobs: name: Gas Report Diff needs: generate if: always() && github.event_name == 'pull_request' && github.event.action != 'closed' - runs-on: parity-int-ubuntu-xl + runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -366,7 +366,7 @@ jobs: } cleanup: - runs-on: parity-int-ubuntu-xl + runs-on: ubuntu-latest if: github.event.action == 'closed' steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/genesis-extractor-test.yml b/.github/workflows/genesis-extractor-test.yml index 0c33010d2..d5c795dda 100644 --- a/.github/workflows/genesis-extractor-test.yml +++ b/.github/workflows/genesis-extractor-test.yml @@ -15,7 +15,7 @@ permissions: jobs: genesis-extractor-test: - runs-on: parity-int-ubuntu-xl + runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/issue-add-to-project.yml b/.github/workflows/issue-add-to-project.yml index d69b1bce9..dfbac4605 100644 --- a/.github/workflows/issue-add-to-project.yml +++ b/.github/workflows/issue-add-to-project.yml @@ -6,7 +6,7 @@ on: jobs: add-to-project: - runs-on: parity-int-ubuntu-xl + runs-on: ubuntu-latest steps: - uses: actions/add-to-project@v1.0.2 with: diff --git a/.github/workflows/issue-auto-label.yml b/.github/workflows/issue-auto-label.yml index aaa04832a..32355d6cb 100644 --- a/.github/workflows/issue-auto-label.yml +++ b/.github/workflows/issue-auto-label.yml @@ -10,7 +10,7 @@ permissions: jobs: label: - runs-on: parity-int-ubuntu-xl + runs-on: ubuntu-latest steps: - name: Extract labels from form body id: extract diff --git a/.github/workflows/pr-title.yml b/.github/workflows/pr-title.yml index 6d25638b5..9632d30b2 100644 --- a/.github/workflows/pr-title.yml +++ b/.github/workflows/pr-title.yml @@ -12,7 +12,7 @@ permissions: jobs: title: name: PR Title - runs-on: parity-int-ubuntu-xl + runs-on: ubuntu-latest steps: - name: Check PR title id: title @@ -122,7 +122,7 @@ jobs: labels: name: Labels - runs-on: parity-int-ubuntu-xl + runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/publish-prerelease.yml b/.github/workflows/publish-prerelease.yml index 596fc8bae..3d7f33888 100644 --- a/.github/workflows/publish-prerelease.yml +++ b/.github/workflows/publish-prerelease.yml @@ -22,7 +22,7 @@ concurrency: jobs: beta-release: - runs-on: parity-int-ubuntu-xl + runs-on: ubuntu-latest # Baked into the genesis registry, so it decides which networks the artifact suits. # Job-level: five steps name the file derived from it and must not disagree. env: diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index 866579328..a4682dc0d 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -22,7 +22,7 @@ concurrency: jobs: release: - runs-on: parity-int-ubuntu-xl + runs-on: ubuntu-latest # Baked into the genesis registry, so it decides which networks the artifact suits. # Job-level: five steps name the file derived from it and must not disagree. env: diff --git a/.github/workflows/push_checking.yml b/.github/workflows/push_checking.yml index 5a0af30f5..468f490d4 100644 --- a/.github/workflows/push_checking.yml +++ b/.github/workflows/push_checking.yml @@ -34,7 +34,7 @@ jobs: test: if: github.event.action != 'closed' name: ${{ matrix.kind.label }} - runs-on: parity-int-ubuntu-xl + runs-on: ubuntu-latest strategy: # Each matrix kind runs on its own runner so neither has to hold the # other's peak memory. Don't fail-fast: surfacing both shards' results @@ -165,7 +165,7 @@ jobs: if: github.event.action != 'closed' && needs.detect-fork.outputs.has_fork == 'true' # The fork job builds the revive ETH-RPC adapter image and runs the upgrade # suite, so it is kept off the shared test runner and onto the Parity XL runner. - runs-on: parity-int-ubuntu-xl + runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: @@ -246,7 +246,7 @@ jobs: name: Report Test Results needs: [test, fork] if: always() && github.event_name == 'pull_request' && github.event.action != 'closed' - runs-on: parity-int-ubuntu-xl + runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -368,7 +368,7 @@ jobs: else await github.rest.issues.createComment({ owner, repo, issue_number, body }); cleanup: - runs-on: parity-int-ubuntu-xl + runs-on: ubuntu-latest if: github.event.action == 'closed' steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/release-metadata.yml b/.github/workflows/release-metadata.yml index f9f62e457..d0ff5577d 100644 --- a/.github/workflows/release-metadata.yml +++ b/.github/workflows/release-metadata.yml @@ -18,7 +18,7 @@ permissions: jobs: validate: - runs-on: parity-int-ubuntu-xl + runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/secret-scan.yml b/.github/workflows/secret-scan.yml index 2b05c0f8d..6654e7cab 100644 --- a/.github/workflows/secret-scan.yml +++ b/.github/workflows/secret-scan.yml @@ -24,7 +24,7 @@ env: jobs: scan: - runs-on: parity-int-ubuntu-xl + runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: diff --git a/.github/workflows/slither.yaml b/.github/workflows/slither.yaml index 41841c3bb..e77d642a7 100644 --- a/.github/workflows/slither.yaml +++ b/.github/workflows/slither.yaml @@ -28,7 +28,7 @@ concurrency: jobs: analyze: - runs-on: parity-int-ubuntu-xl + runs-on: ubuntu-latest if: github.event.action != 'closed' steps: - uses: actions/checkout@v4 @@ -163,7 +163,7 @@ jobs: else await github.rest.issues.createComment({ owner, repo, issue_number, body }); cleanup: - runs-on: parity-int-ubuntu-xl + runs-on: ubuntu-latest if: github.event.action == 'closed' steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/validate-files.yml b/.github/workflows/validate-files.yml index c1ac16c54..54bd0808c 100644 --- a/.github/workflows/validate-files.yml +++ b/.github/workflows/validate-files.yml @@ -21,7 +21,7 @@ concurrency: jobs: validate: name: File Validation - runs-on: parity-int-ubuntu-xl + runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 diff --git a/contracts/access/DotnsRoleManagerOld.sol b/contracts/access/DotnsRoleManagerOld.sol deleted file mode 100644 index d77cecfd8..000000000 --- a/contracts/access/DotnsRoleManagerOld.sol +++ /dev/null @@ -1,103 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.34; - -import {IAccessControl} from "@openzeppelin/contracts/access/IAccessControl.sol"; -import { - AccessControlUpgradeable -} from "@openzeppelin/contracts-upgradeable/access/AccessControlUpgradeable.sol"; -import { - OwnableUpgradeable -} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; -import {IDotnsRoleManagerOld} from "./IDotnsRoleManagerOld.sol"; - -/// @title Dotns Role Manager -/// @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 Shared owner-administered role layer for DotNS contracts with operational roles. -/// @dev Consuming contracts define their supported role set in @custom:function _isSupportedRole. -/// The owner remains the only account that can grant or revoke roles; role holders receive -/// only the operational permissions each consuming contract explicitly gates with -/// @custom:function _checkRoleOrOwner. -/// @custom:security-contact admin@parity.io -abstract contract DotnsRoleManagerOld is - AccessControlUpgradeable, - OwnableUpgradeable, - IDotnsRoleManagerOld -{ - /// @notice Initialises the OpenZeppelin access-control state for consuming contracts. - /// @dev Must be called during the consuming contract initialiser. - function _dotnsRoleManagerInit() internal onlyInitializing { - __AccessControl_init(); - } - - /// @inheritdoc IDotnsRoleManagerOld - function setRole(bytes32 role, address account, bool enabled) external override onlyOwner { - _setRole(role, account, enabled); - } - - /// @inheritdoc IAccessControl - function grantRole( - bytes32 role, - address account - ) - public - override(AccessControlUpgradeable, IAccessControl) - onlyOwner - { - _setRole(role, account, true); - } - - /// @inheritdoc IAccessControl - function revokeRole( - bytes32 role, - address account - ) - public - override(AccessControlUpgradeable, IAccessControl) - onlyOwner - { - _setRole(role, account, false); - } - - /// @inheritdoc AccessControlUpgradeable - function supportsInterface(bytes4 interfaceId) - public - view - virtual - override(AccessControlUpgradeable) - returns (bool supported) - { - return interfaceId == type(IDotnsRoleManagerOld).interfaceId - || super.supportsInterface(interfaceId); - } - - /// @notice Reverts unless the caller is the owner or holds `role`. - /// @dev Consuming contracts use this for operational paths where the owner keeps super-user - /// access and role holders receive a narrower permission; any other caller is rejected - /// with @custom:reverts NotRoleOrOwner. - function _checkRoleOrOwner(bytes32 role) internal view { - address caller = _msgSender(); - require(caller == owner() || hasRole(role, caller), NotRoleOrOwner(caller, role)); - } - - /// @notice Grants or revokes a supported role for `account`. - /// @dev `role` must be recognised by the consuming contract (otherwise - /// @custom:reverts UnsupportedRole) and `account` must be non-zero (otherwise - /// @custom:reverts InvalidRoleAccount). Delegates to OpenZeppelin's `_grantRole` or - /// `_revokeRole`, which emit @custom:emits IAccessControl.RoleGranted on grant and - /// @custom:emits IAccessControl.RoleRevoked on revoke. - function _setRole(bytes32 role, address account, bool enabled) internal { - require(_isSupportedRole(role), UnsupportedRole(role)); - require(account != address(0), InvalidRoleAccount(account)); - - if (enabled) { - _grantRole(role, account); - } else { - _revokeRole(role, account); - } - } - - /// @notice Returns whether `role` is recognised by the consuming contract. - /// @dev Implemented by each consuming contract so unsupported role identifiers fail closed. - function _isSupportedRole(bytes32 role) internal view virtual returns (bool supported); -} diff --git a/contracts/access/IDotnsRoleManagerOld.sol b/contracts/access/IDotnsRoleManagerOld.sol deleted file mode 100644 index e8f10c895..000000000 --- a/contracts/access/IDotnsRoleManagerOld.sol +++ /dev/null @@ -1,34 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.34; - -import {IAccessControl} from "@openzeppelin/contracts/access/IAccessControl.sol"; - -/// @title DotNS Role Manager -/// @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 Shared owner-administered role API for DotNS contracts with operational roles. -/// @dev Role identifiers are declared in `DotnsConstants`. Ownership remains the source of -/// super-user authority: the owner grants and revokes supported roles, while role holders -/// receive only the operational permissions each consuming contract recognises. -/// @custom:security-contact admin@parity.io -interface IDotnsRoleManagerOld is IAccessControl { - /// @notice Thrown when a caller is neither the contract owner nor a holder of `role`. - error NotRoleOrOwner(address caller, bytes32 role); - - /// @notice Thrown when role management is attempted for a role the contract does not use. - error UnsupportedRole(bytes32 role); - - /// @notice Thrown when role management is attempted for the zero address. - error InvalidRoleAccount(address account); - - /// @notice Grants or revokes an operational role. - /// @dev Only the owner can manage roles (otherwise @custom:reverts OwnableUnauthorizedAccount); - /// `role` must be one of the roles recognised by the consuming contract (otherwise - /// @custom:reverts UnsupportedRole); `account` must not be the zero address (otherwise - /// @custom:reverts InvalidRoleAccount). Emits @custom:emits IAccessControl.RoleGranted on - /// grant and @custom:emits IAccessControl.RoleRevoked on revoke. - /// @param role Role identifier declared in `DotnsConstants`. - /// @param account Account whose role membership is updated. - /// @param enabled Whether the role should be granted or revoked. - function setRole(bytes32 role, address account, bool enabled) external; -} diff --git a/contracts/pop/IPopRulesOld.sol b/contracts/pop/IPopRulesOld.sol deleted file mode 100644 index be42a98ae..000000000 --- a/contracts/pop/IPopRulesOld.sol +++ /dev/null @@ -1,346 +0,0 @@ -// 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 deleted file mode 100644 index 1f3370ee2..000000000 --- a/contracts/pop/PopRulesOld.sol +++ /dev/null @@ -1,608 +0,0 @@ -// 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/DotnsPopControllerOld.sol b/contracts/registrars/DotnsPopControllerOld.sol deleted file mode 100644 index 42c4a4b5d..000000000 --- a/contracts/registrars/DotnsPopControllerOld.sol +++ /dev/null @@ -1,858 +0,0 @@ -// 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/DotnsRegistrarControllerOld.sol b/contracts/registrars/DotnsRegistrarControllerOld.sol deleted file mode 100644 index a6ed7b226..000000000 --- a/contracts/registrars/DotnsRegistrarControllerOld.sol +++ /dev/null @@ -1,481 +0,0 @@ -// 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 {DotnsRoleManagerOld} from "../access/DotnsRoleManagerOld.sol"; -import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol"; -import {ReentrancyGuardTransient} from "@openzeppelin/contracts/utils/ReentrancyGuardTransient.sol"; - -import {IDotnsRegistrar} from "./IDotnsRegistrar.sol"; -import {IDotnsReverseResolver} from "../resolvers/IDotnsReverseResolver.sol"; -import {IPopRules} from "../pop/IPopRules.sol"; -import {IDotnsCostModelRegistry} from "../pop/IDotnsCostModelRegistry.sol"; -import {StringUtils} from "../utils/StringUtils.sol"; -import {IDotnsRegistrarControllerOld} from "./IDotnsRegistrarControllerOld.sol"; -import {IDotnsNameEscrow} from "../escrow/IDotnsNameEscrow.sol"; -import {IStoreFactory} from "../store/IStoreFactory.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 {RegistrationUtils} from "../utils/RegistrationUtils.sol"; -import {StoreUtils} from "../utils/StoreUtils.sol"; - -/// @title Dotns Registrar Controller Old -/// @notice Allocates top-level labels using a commit reveal scheme. -/// @dev Pre-upgrade snapshot the layout diff compares the current implementation against. -/// Orchestrates allocation, PoP validation, pricing enforcement, forward registry wiring, default -/// reverse resolution, and immutable store writing. -/// -/// Tokenisation: the minted ERC721 tokenId is `uint256(node)`, where -/// `node = namehash(tldNode, labelhash)`. The registry stores a sentinel owner -/// (`address(0)`) for tokenised nodes and derives ownership from the ERC721 registrar for -/// authorisation. -/// @dev PR-scoped. This snapshot is deleted before merge with the paired upgrade slice per the -/// upgrade-PR workflow in CONTRIBUTING.md. -/// @custom:security-contact admin@parity.io -contract DotnsRegistrarControllerOld is - Initializable, - UUPSUpgradeable, - DotnsRoleManagerOld, - ReentrancyGuardTransient, - IDotnsRegistrarControllerOld -{ - using StringUtils for *; - using StoreUtils for IStoreFactory; - - /// @notice Upper bound for commitment validity to cap storage griefing risk. - uint256 public constant MAX_ALLOWED_COMMITMENT_AGE = 7 days; - - /// @notice Role identifier gating whitelist operators for the reserved pipeline. - /// @dev Declared locally so the snapshot compiles once the shared identifier is retired from - /// `DotnsConstants`; a constant occupies no storage slot, so the layout is unaffected. - bytes32 private constant WHITELIST_OPERATOR_ROLE = keccak256("DOTNS_WHITELIST_OPERATOR_ROLE"); - - /// @notice Minimum age a commitment must reach before reveal. - uint256 public minCommitmentAge; - - /// @notice Maximum age after which a commitment expires. - uint256 public maxCommitmentAge; - - /// @notice Stores Mapping of commitment hashes to timestamp committed. - mapping(bytes32 hash => uint256 timestamp) public commitments; - - /// @notice Cost-model version stamped on a commitment at commit time. - /// @dev Recorded from the registry's current version when `commit` runs, so the reveal can bind - /// a registration to the version that was current then. A caller cannot commit against an - /// arbitrary earlier, cheaper version: the reveal rejects a `pricingVersion` that differs - /// from this stamp. - mapping(bytes32 hash => uint256 version) public committedPricingVersion; - - /// @notice Whitelist for addresses allowed to call `registerReserved`. - mapping(address user => bool isWhiteListed) public whiteList; - - /// @notice Protocol-level address registry for all DotNS contracts. - IDotnsProtocolRegistry public protocolRegistry; - - /// @dev Reserved storage space to allow for layout changes in the future. - uint256[49] private __gap; - - /// @notice Restricts calls to whitelisted addresses or the owner. - /// @dev Used to gate `registerReserved`, which allows registering reserved names without - /// PoP checks or payment. Necessary so the owner (or a whitelisted operator) can seed - /// reserved names on behalf of users who are already known and verified and do not need - /// PoP checks. - modifier onlyWhiteListedOrOwner() { - _onlyWhiteListedOrOwner(); - _; - } - - /// @notice Restricts calls to the owner or a whitelist operator role holder. - modifier onlyWhitelistOperatorOrOwner() { - _checkRoleOrOwner(WHITELIST_OPERATOR_ROLE); - _; - } - - /// @custom:oz-upgrades-unsafe-allow constructor - constructor() { - _disableInitializers(); - } - - /// @notice Initialises the registrar controller. - /// @dev Callable once through the UUPS proxy; direct calls on the implementation revert - /// with @custom:reverts InvalidInitialization, and any nested call outside an active - /// initialiser scope reverts with @custom:reverts NotInitializing. Validates the - /// commitment window bounds: `minAge` must be strictly positive (otherwise - /// @custom:reverts MinCommitmentAgeZero) so a reveal cannot land in the same block as - /// its commit; `maxAge` must exceed `minAge` (otherwise - /// @custom:reverts MaxCommitmentAgeTooLow) and must stay within - /// `MAX_ALLOWED_COMMITMENT_AGE` (otherwise @custom:reverts MaxCommitmentAgeTooHigh) before - /// wiring the protocol registry. - function initialize( - IDotnsProtocolRegistry registry, - uint256 minAge, - uint256 maxAge - ) - external - initializer - { - __Ownable_init(msg.sender); - _dotnsRoleManagerInit(); - - require(minAge > 0, MinCommitmentAgeZero()); - require(maxAge > minAge, MaxCommitmentAgeTooLow()); - require(maxAge <= MAX_ALLOWED_COMMITMENT_AGE, MaxCommitmentAgeTooHigh()); - - protocolRegistry = registry; - - minCommitmentAge = minAge; - maxCommitmentAge = maxAge; - } - - /// @inheritdoc IDotnsRegistrarControllerOld - function available(string calldata label) public view override returns (bool) { - bytes32 node; - (, node) = _validatedLabelNode(label); - IDotnsRegistrar registrar = IDotnsRegistrar(protocolRegistry.get(DotnsConstants.REGISTRAR)); - return registrar.available(uint256(node)); - } - - /// @inheritdoc IDotnsRegistrarControllerOld - function makeCommitment(Registration calldata registration) - public - pure - override - returns (bytes32 commitment) - { - commitment = keccak256( - abi.encode( - registration.label, - registration.owner, - registration.secret, - registration.reserved, - registration.maxPrice, - registration.pricingVersion - ) - ); - } - - /// @inheritdoc IDotnsRegistrarControllerOld - function commit(bytes32 commitment) external override { - uint256 prior = commitments[commitment]; - require( - prior == 0 || prior + maxCommitmentAge <= block.timestamp, - UnexpiredCommitmentExists(commitment) - ); - - commitments[commitment] = block.timestamp; - committedPricingVersion[commitment] = _currentPricingVersion(); - emit NameCommitted(commitment); - } - - /// @notice Reads the cost model's current version through the protocol registry. - /// @dev Resolved at commit time so the stamp binds the version live then, not at reveal. - /// @return pricingVersion The current cost-model version. - function _currentPricingVersion() internal view returns (uint256 pricingVersion) { - return - IDotnsCostModelRegistry(protocolRegistry.get(DotnsConstants.COST_MODEL)) - .currentVersion(); - } - - /// @inheritdoc IDotnsRegistrarControllerOld - function register(Registration calldata registration) external payable override nonReentrant { - (IDotnsRegistrar registrar, bytes32 labelhash, bytes32 node) = - _requireAvailableLabel(registration.label); - _consumeCommitment(registration); - - address escrow = _escrow(); - IPopRules rules = IPopRules(protocolRegistry.get(DotnsConstants.POP_RULES)); - - uint256 tokenId = uint256(node); - bool isReclaim = registrar.exists(tokenId); - - string memory stem = rules.stripDigits(registration.label); - bool stemCanonical = stem.isSingleLabelMemory(); - // Reclaim hands the name back from a prior occupant who may hold a sibling-controller's - // stem reservation; clear it so the new registrant's stem reserve starts fresh. Non-reclaim - // paths intentionally leave an existing same-owner reservation in place so a sibling - // controller (e.g. the PoP queue head stamp) retains the slot's `controller` field through - // the refresh in `_writeReservation`. Replacing the slot from this controller would brick - // the sibling's release/advance paths. - if (stemCanonical && isReclaim) { - (address reservationOwner,) = rules.getBaseNameReservation(stem); - address expectedOwner = - IDotnsNameEscrow(payable(escrow)).getReleasePosition(tokenId).recipient; - if (reservationOwner != address(0) && reservationOwner == expectedOwner) { - rules.releaseReservationForReclaim(stem, expectedOwner); - } - } - - bool isDirect = msg.sender == registration.owner; - IPopRules.PriceWithMeta memory priced; - if (isDirect) { - priced = rules.priceWithCheckAtVersion( - registration.label, registration.owner, registration.pricingVersion - ); - } else { - priced = rules.priceWithoutCheckAtVersion( - registration.label, registration.owner, registration.pricingVersion - ); - if (priced.status == IPopRules.PopStatus.Reserved) { - (IPopRules.PopStatus required,) = rules.classifyName(registration.label); - if (required == IPopRules.PopStatus.Reserved) { - revert IPopRules.GovernanceReserved(registration.label); - } - revert IPopRules.NameReserved(registration.label); - } - require( - priced.userStatus >= priced.status, - IPopRules.OwnerStatusInsufficient( - registration.label, priced.userStatus, priced.status - ) - ); - } - - uint256 totalCharged = priced.price; - require( - totalCharged <= registration.maxPrice, - PriceExceedsMax(registration.label, totalCharged, registration.maxPrice) - ); - require(msg.value >= totalCharged, InsufficientValue()); - - IDotnsReverseResolver reverse; - bool setReverseRecord; - if (registration.reserved && isDirect) { - reverse = IDotnsReverseResolver(protocolRegistry.get(DotnsConstants.REVERSE_RESOLVER)); - setReverseRecord = bytes(reverse.nameOf(registration.owner)).length == 0; - } - - _completeRegistration( - registration, labelhash, node, priced.price, setReverseRecord, reverse, isReclaim - ); - - if (isReclaim) { - IDotnsNameEscrow(payable(escrow)).reclaim(tokenId, registration.owner); - // Reclaim hands the NFT to the new holder; rewrite the registry record so the prior - // owner's resolver pointer cannot follow the name. Must run after `escrow.reclaim` - // so the registry's `ownerOf` check sees the new holder, not the escrow. - IDotnsRegistry(protocolRegistry.get(DotnsConstants.REGISTRY)) - .setOwner(node, registration.owner); - } - - _settleEscrow(escrow, tokenId, registration.owner, isDirect, totalCharged); - - if ( - priced.status == IPopRules.PopStatus.PopLite - && priced.userStatus == IPopRules.PopStatus.PopLite && stemCanonical - ) { - rules.reserveBaseName(stem, registration.owner); - } - - if (msg.value > totalCharged) { - uint256 refund = msg.value - totalCharged; - (bool ok,) = payable(msg.sender).call{value: refund}(""); - if (ok) { - emit OverpaymentRefunded(msg.sender, refund); - } else { - IDotnsNameEscrow(payable(escrow)).creditOverpayment{value: refund}(msg.sender); - } - } - } - - /// @notice Settles every escrow side-effect of a successful registration. - /// @dev Extracted to keep `register` under the stack-depth ceiling. On a direct - /// registration the full `chargeAmount` lands in the refundable deposit position - /// keyed to `nameOwner`. On a cross-payer registration the deposit position is - /// seeded with a zero amount so the release lifecycle stays reachable, and the same - /// `chargeAmount` routes to the protocol fee pot via `depositProtocolFee` keyed to - /// `msg.sender` as the payer. - function _settleEscrow( - address escrow, - uint256 tokenId, - address nameOwner, - bool isDirect, - uint256 chargeAmount - ) - internal - { - uint256 depositAmount = isDirect ? chargeAmount : 0; - IDotnsNameEscrow(payable(escrow)).deposit{value: depositAmount}( - IDotnsNameEscrow.DepositParams({ - tokenId: tokenId, asset: address(0), amount: depositAmount, recipient: nameOwner - }) - ); - - if (!isDirect && chargeAmount > 0) { - IDotnsNameEscrow(payable(escrow)).depositProtocolFee{value: chargeAmount}( - IDotnsNameEscrow.ProtocolFeeDepositParams({ - tokenId: tokenId, payer: msg.sender, recipient: nameOwner - }) - ); - } - } - - /// @inheritdoc IDotnsRegistrarControllerOld - function isWhiteListed(address who) external view override returns (bool) { - return whiteList[who]; - } - - /// @inheritdoc IDotnsRegistrarControllerOld - function whiteListAddress( - address who, - bool whiteListStatus - ) - external - override - onlyWhitelistOperatorOrOwner - { - whiteList[who] = whiteListStatus; - emit WhiteListed(who, whiteListStatus); - } - - /// @inheritdoc IDotnsRegistrarControllerOld - function registerReserved(Registration calldata registration) - external - override - onlyWhiteListedOrOwner - nonReentrant - { - (, bytes32 labelhash, bytes32 node) = _requireAvailableLabel(registration.label); - _consumeCommitment(registration); - - IDotnsReverseResolver reverse = - IDotnsReverseResolver(protocolRegistry.get(DotnsConstants.REVERSE_RESOLVER)); - _completeRegistration(registration, labelhash, node, 0, true, reverse, false); - } - - /// @inheritdoc IERC165 - function supportsInterface(bytes4 interfaceId) - public - view - override(DotnsRoleManagerOld, IERC165) - returns (bool) - { - return interfaceId == type(IDotnsRegistrarControllerOld).interfaceId - || super.supportsInterface(interfaceId); - } - - /// @notice Validates label shape and derives `(labelhash, node)`. - /// @dev Delegates hashing to @custom:contract LabelUtils so the assembly sequence lives in - /// exactly one place across the codebase. Error ownership stays on this interface: shape - /// violations revert with `InvalidLabel()`; labels below the minimum length revert with - /// `LabelTooShort(label)` so off-chain consumers can distinguish "shape-valid but below - /// the policy minimum" from "shape-valid but already minted". - function _validatedLabelNode(string calldata label) - internal - view - returns (bytes32 labelhash, bytes32 node) - { - require(label.isSingleLabel(), InvalidLabel()); - require(bytes(label).length >= 3, LabelTooShort(label)); - (labelhash, node) = LabelUtils.deriveNode(protocolRegistry.tldNode(), label); - } - - function _requireAvailableLabel(string calldata label) - internal - view - returns (IDotnsRegistrar registrar, bytes32 labelhash, bytes32 node) - { - (labelhash, node) = _validatedLabelNode(label); - registrar = IDotnsRegistrar(protocolRegistry.get(DotnsConstants.REGISTRAR)); - require(registrar.available(uint256(node)), NameNotAvailable(label)); - } - - function _consumeCommitment(Registration calldata registration) internal { - bytes32 commitment = makeCommitment(registration); - uint256 committedAt = commitments[commitment]; - - require(committedAt != 0, CommitmentNotFound(commitment)); - require( - committedAt + minCommitmentAge <= block.timestamp, - CommitmentTooNew(commitment, committedAt + minCommitmentAge, block.timestamp) - ); - require( - committedAt + maxCommitmentAge > block.timestamp, - CommitmentTooOld(commitment, committedAt + maxCommitmentAge, block.timestamp) - ); - - uint256 stamped = committedPricingVersion[commitment]; - require( - registration.pricingVersion == stamped, - IDotnsCostModelRegistry.PricingVersionMismatch(stamped, registration.pricingVersion) - ); - - delete commitments[commitment]; - delete committedPricingVersion[commitment]; - } - - /// @notice Completes a commit-reveal registration: mints (or skips when reclaiming), - /// wires forward registry, optionally sets the reverse record, and writes the owner's - /// Store. - /// @dev On a fresh mint the triad of mint + forward-registry + store-write is delegated - /// to @custom:function RegistrationUtils.registerAndStore, the single canonical implementation - /// shared across every DotNS registration flow. On a reclaim the mint step is skipped (the - /// escrow has already moved custody) and only the registry wiring and store write run. - /// Reverse-record setting and the priced-registration event stay here because they are - /// commit-reveal-specific policy. - function _completeRegistration( - Registration calldata registration, - bytes32 labelhash, - bytes32 node, - uint256 baseCost, - bool setReverseRecord, - IDotnsReverseResolver reverse, - bool isReclaim - ) - internal - { - address labelStore; - if (!isReclaim) { - labelStore = RegistrationUtils.registerAndStore( - RegistrationUtils.RegistrationContext({ - protocolRegistry: protocolRegistry, - user: registration.owner, - label: registration.label, - labelhash: labelhash, - node: node - }) - ); - } else { - // Registry reset on reclaim is deferred until after `escrow.reclaim` runs (see - // @custom:function register) so the registry's `ownerOf` check sees the new holder. - IStoreFactory factory = - IStoreFactory(protocolRegistry.get(DotnsConstants.STORE_FACTORY)); - string memory fullName = string.concat(registration.label, protocolRegistry.tld()); - labelStore = factory.writeLabel(registration.owner, node, fullName); - } - - if (setReverseRecord) { - reverse.setReverseName( - registration.owner, string.concat(registration.label, protocolRegistry.tld()) - ); - } - - emit NameRegistered(registration.label, labelhash, registration.owner, baseCost, labelStore); - } - - /// @notice Returns the configured name escrow from the protocol registry. - function _escrow() internal view returns (address escrow) { - escrow = protocolRegistry.get(DotnsConstants.NAME_ESCROW); - require(escrow != address(0), EscrowNotConfigured()); - } - - /// @notice Returns implementation version. - /// @return versionString Current version string. - function version() external pure virtual returns (string memory versionString) { - versionString = "1.0.0"; - } - - /// @notice Internal check enforcing whitelist-or-owner access. - function _onlyWhiteListedOrOwner() internal view { - require(whiteList[msg.sender] || msg.sender == owner(), NotWhiteListedOrOwner(msg.sender)); - } - - function _isSupportedRole(bytes32 role) internal view override returns (bool supported) { - return role == WHITELIST_OPERATOR_ROLE; - } - - /// @inheritdoc UUPSUpgradeable - function _authorizeUpgrade(address newImplementation) internal override onlyOwner {} -} diff --git a/contracts/registrars/DotnsRegistrarOld.sol b/contracts/registrars/DotnsRegistrarOld.sol deleted file mode 100644 index 587eb7ad6..000000000 --- a/contracts/registrars/DotnsRegistrarOld.sol +++ /dev/null @@ -1,433 +0,0 @@ -// 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 { - ERC721Upgradeable -} from "@openzeppelin/contracts-upgradeable/token/ERC721/ERC721Upgradeable.sol"; - -import {IDotnsRegistrarOld} from "./IDotnsRegistrarOld.sol"; -import {IDotnsController} from "./IDotnsController.sol"; -import {IDotnsProtocolRegistry} from "../registry/IDotnsProtocolRegistry.sol"; - -import {IStoreFactory} from "../store/IStoreFactory.sol"; -import {ILabelStore} from "../store/ILabelStore.sol"; -import {StoreUtils} from "../utils/StoreUtils.sol"; -import {LabelUtils} from "../utils/LabelUtils.sol"; -import {StringUtils} from "../utils/StringUtils.sol"; -import {IDotnsNameEscrow} from "../escrow/IDotnsNameEscrow.sol"; -import {IPopRules} from "../pop/IPopRules.sol"; -import {DotnsConstants} from "../utils/DotnsConstants.sol"; - -/// @title Dotns Registrar -/// @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 ERC721-backed registrar implementing permanent name ownership. -/// @dev Deliberately policy-free. Transfers are supported to allow ownership changes without -/// registry hooks, and the registrar itself does not encode pricing, reservations, or PoP -/// gating; those live in the controllers and @custom:contract IPopRules. The fee-on-transfer hook -/// in `_update` is a thin enforcement layer that consults the escrow. -/// @custom:security-contact admin@parity.io -contract DotnsRegistrarOld is - Initializable, - UUPSUpgradeable, - OwnableUpgradeable, - ERC721Upgradeable, - IDotnsRegistrarOld -{ - using StoreUtils for IStoreFactory; - using StringUtils for *; - - /// @notice Mapping of authorised controllers. - /// @dev Controllers may call `register`. Keyed by the shared baseline @custom:contract - /// IDotnsController interface so the registrar doesn't depend on any specific controller shape. - /// Commit-reveal, PoP, and future controllers coexist here so long as they implement the - /// baseline interface. - /// @custom:oz-retyped-from mapping(IDotnsRegistrarController => bool) - mapping(IDotnsController controller => bool exists) public controllers; - - /// @notice Protocol-level address registry for all DotNS contracts. - /// @dev Used to resolve sibling contract addresses (store factory, controller, registry) - /// without storing individual references. - IDotnsProtocolRegistry public protocolRegistry; - - /// @dev Reserved storage space to allow for layout changes in the future. - uint256[50] private __gap; - - /// @notice Restricts function access to authorised controllers. - modifier onlyController() { - _onlyController(); - _; - } - - /// @custom:oz-upgrades-unsafe-allow constructor - constructor() { - _disableInitializers(); - } - - /// @notice Initialises the registrar. - /// @dev Uses OpenZeppelin upgradeable initialisers and is callable once through the UUPS - /// proxy; direct calls on the implementation revert with @custom:reverts InvalidInitialization - /// because `_disableInitializers` runs in the constructor, and any nested call outside an - /// active initialiser scope reverts with @custom:reverts NotInitializing. - function initialize( - string calldata name, - string calldata symbol, - IDotnsProtocolRegistry registry - ) - external - initializer - { - require(address(registry) != address(0), ProtocolRegistryRequired()); - __Ownable_init(msg.sender); - __ERC721_init(name, symbol); - protocolRegistry = registry; - } - - /// @inheritdoc IDotnsRegistrarOld - function addController(IDotnsController controller) external onlyOwner { - controllers[controller] = true; - emit ControllerAdded(controller); - } - - /// @inheritdoc IDotnsRegistrarOld - function removeController(IDotnsController controller) external onlyOwner { - controllers[controller] = false; - emit ControllerRemoved(controller); - } - - /// @inheritdoc IDotnsRegistrarOld - function available(uint256 id) public view override returns (bool isAvailable) { - address holder = _ownerOf(id); - if (holder == address(0)) return true; - - address escrow = protocolRegistry.get(DotnsConstants.NAME_ESCROW); - if (holder != escrow) return false; - - // Escrow custody on its own does not mean registrable: a released name inside its redeem - // window still belongs to its previous holder. The escrow owns that lifecycle and is asked - // directly, so availability here and reclaimability there cannot drift apart and start - // advertising names whose registration would revert. - return IDotnsNameEscrow(payable(escrow)).isReclaimable(id); - } - - /// @inheritdoc IDotnsRegistrarOld - function register( - uint256 id, - address owner, - string calldata label - ) - external - override - onlyController - { - // `available` returns true both for unminted ids and for ids currently held by escrow - // (so the controller can route through `escrow.reclaim`). `register` only handles the - // fresh-mint branch; the escrow-held branch must use the reclaim path and is rejected - // here with the typed error so callers do not see OZ's `ERC721InvalidSender(0)`. - require(!_exists(id), NameNotAvailable(id)); - require(owner != protocolRegistry.get(DotnsConstants.NAME_ESCROW), InvalidOwner()); - // Empty labels are an intentional gateway-cold path (substrate Root cannot deploy a - // `LabelStore` under `pallet-revive`, so the controller stashes a pending claim and the - // user settles via @custom:function IDotnsPopController.claimLabelStore later). Non-empty - // labels must still be canonical so the transfer-floor lookup in `_quoteTransferFee` - // cannot brick the token by reverting on a malformed stem. - require(bytes(label).length == 0 || label.isSingleLabel(), InvalidLabel()); - _mint(owner, id); - if (bytes(label).length != 0) _writeOwnerLabel(owner, id, label); - emit NameRegistered(id, owner); - } - - /// @inheritdoc IDotnsRegistrarOld - function labelOf(uint256 tokenId) external view override returns (string memory) { - address holder = _ownerOf(tokenId); - if (holder == address(0)) return ""; - return LabelUtils.stripTld(protocolRegistry.tld(), _readLabel(tokenId, holder)); - } - - /// @inheritdoc IDotnsRegistrarOld - function quoteTransferFee( - uint256 tokenId, - address to - ) - external - view - override - returns (uint256 requiredFee) - { - require(to != address(0), ERC721InvalidReceiver(address(0))); - - address from = ownerOf(tokenId); - (,, requiredFee) = _quoteTransferFee(from, to, tokenId); - } - - /// @inheritdoc IDotnsRegistrarOld - function transferFrom( - address from, - address to, - uint256 tokenId - ) - public - payable - override(ERC721Upgradeable, IDotnsRegistrarOld) - { - super.transferFrom(from, to, tokenId); - } - - /// @inheritdoc IDotnsRegistrarOld - function safeTransferFrom( - address from, - address to, - uint256 tokenId - ) - public - payable - override(ERC721Upgradeable, IDotnsRegistrarOld) - { - super.safeTransferFrom(from, to, tokenId, ""); - } - - /// @inheritdoc IDotnsRegistrarOld - function safeTransferFrom( - address from, - address to, - uint256 tokenId, - bytes memory data - ) - public - payable - override(ERC721Upgradeable, IDotnsRegistrarOld) - { - super.safeTransferFrom(from, to, tokenId, data); - } - - /// @notice Returns implementation version. - /// @return versionString Current version string. - function version() external pure virtual returns (string memory versionString) { - versionString = "1.0.0"; - } - - /// @inheritdoc IDotnsRegistrarOld - function exists(uint256 tokenId) external view override returns (bool tokenExists) { - tokenExists = _exists(tokenId); - } - - /// @notice Checks whether a token ID exists. - function _exists(uint256 tokenId) internal view returns (bool) { - return _ownerOf(tokenId) != address(0); - } - - /// @notice Internal function to check for controller access. - function _onlyController() internal view { - require(controllers[IDotnsController(msg.sender)], NotController(msg.sender)); - } - - /// @inheritdoc ERC721Upgradeable - function _update( - address to, - uint256 tokenId, - address auth - ) - internal - override - returns (address from) - { - from = super._update(to, tokenId, auth); - - // Mints and self-transfers carry no economic event. Reject any attached value on those - // paths because nothing forwards it onward, which would otherwise trap the funds in this - // contract permanently (no `receive`, no rescue path). - if (from == address(0) || from == to) { - require(msg.value == 0, UnexpectedValue()); - return from; - } - - // Resolve every registry-sourced dependency once and thread it into the helpers so a - // single transfer pays one external lookup per key rather than three. - IDotnsProtocolRegistry registry = protocolRegistry; - address escrow = registry.get(DotnsConstants.NAME_ESCROW); - require(escrow != address(0), EscrowNotConfigured()); - IStoreFactory factory = IStoreFactory(registry.get(DotnsConstants.STORE_FACTORY)); - - bool isEscrowTouching = to == escrow || from == escrow; - // Skip mirroring on escrow-touching paths: release deposits the NFT into custody where - // a `LabelStore` would be wasted and reclaim hands it back to a fresh-mint controller - // that writes the label through its own flow. - if (!isEscrowTouching) { - _syncRecipientStore(factory, to, from, tokenId); - } - - (uint256 transferFee, uint256 requiredFee) = - _quoteTransferFeeFor(registry, factory, isEscrowTouching, from, to, tokenId); - if (requiredFee != 0) { - require(msg.value >= requiredFee, TransferFeeRequired(tokenId, to, requiredFee)); - } - - // Deposits follow the NFT, not the depositor: every transfer that moves a name off the - // prior position recipient rebinds the escrow position to the new holder so the locked - // deposit (when funded) and the lifecycle marker (when zero-amount) both travel with the - // name. Escrow-touching transfers are excluded because the escrow is mid-call and its - // non-reentrancy guard would reject a re-entry; release/reclaim manage the position - // directly. - bool positionSyncNeeded; - if (!isEscrowTouching) { - IDotnsNameEscrow.ReleasePosition memory position = - IDotnsNameEscrow(payable(escrow)).getReleasePosition(tokenId); - positionSyncNeeded = position.recipient != address(0) && to != position.recipient; - } - - if (requiredFee == 0 && msg.value == 0 && !positionSyncNeeded) { - return from; - } - - IDotnsNameEscrow(payable(escrow)).chargeTransferFee{value: msg.value}( - IDotnsNameEscrow.ChargeTransferFeeParams({ - tokenId: tokenId, transferFee: transferFee, payer: msg.sender, to: to - }) - ); - - return from; - } - - /// @notice Mirrors the sender's label entry into the recipient's `LabelStore`. - function _syncRecipientStore( - IStoreFactory factory, - address to, - address from, - uint256 tokenId - ) - internal - { - string memory fullName = _readLabelFor(factory, tokenId, from); - if (bytes(fullName).length == 0) { - // Sender has no label entry for the token (typical of gateway-cold PoP mints). - // Nothing to mirror, so do not deploy a recipient store; downstream writes are - // demand-deploy through `StoreUtils.ensureLabelStore`. - return; - } - factory.writeLabel(to, bytes32(tokenId), fullName); - } - - /// @notice Reads the full name (`label.tld`) for `tokenId` from `holder`'s `LabelStore` using - /// a caller-supplied factory. - function _readLabelFor( - IStoreFactory factory, - uint256 tokenId, - address holder - ) - private - view - returns (string memory fullName) - { - address store = factory.getLabelStore(holder); - if (store == address(0)) return ""; - return ILabelStore(store).getLabel(bytes32(tokenId)); - } - - /// @notice Reads the full name for `tokenId` from `holder`'s `LabelStore` via fresh lookups. - /// @dev Used by external view functions where caching the factory is not yet established; - /// the hot transfer path uses @custom:function _readLabelFor with a cached factory. - function _readLabel( - uint256 tokenId, - address holder - ) - private - view - returns (string memory fullName) - { - return _readLabelFor(_storeFactory(), tokenId, holder); - } - - /// @notice Resolves the configured name escrow address from the protocol registry. - function _escrow() private view returns (address escrow) { - escrow = protocolRegistry.get(DotnsConstants.NAME_ESCROW); - } - - /// @notice Resolves the configured PoP rules contract from the protocol registry. - function _popRules() private view returns (IPopRules rules) { - rules = IPopRules(protocolRegistry.get(DotnsConstants.POP_RULES)); - } - - /// @notice Resolves the configured store factory from the protocol registry. - function _storeFactory() private view returns (IStoreFactory factory) { - factory = IStoreFactory(protocolRegistry.get(DotnsConstants.STORE_FACTORY)); - } - - /// @notice Writes the canonical full name into `owner`'s `LabelStore` keyed by - /// `bytes32(tokenId)`. - /// @dev Caller (@custom:function register) is responsible for short-circuiting on empty label; - /// the factory is a protocol-critical dependency and is assumed non-zero (a zero return from - /// the registry would have already broken every other call site). - function _writeOwnerLabel(address owner, uint256 tokenId, string calldata label) private { - _storeFactory() - .writeLabel(owner, bytes32(tokenId), string.concat(label, protocolRegistry.tld())); - } - - /// @notice Quotes the friction fee required for a transfer. - /// @dev Required fee is the name's own price returned by @custom:function - /// PopRules.transferFloor. It is paid by the sender on every downward or cross-reach transfer - /// and settles to the - /// protocol fee pot. Any prior deposit travels with the NFT: the escrow rebinds the position to - /// the new holder rather than refunding the sender, so transferring a funded name forfeits the - /// locked deposit to the recipient. Self-transfers and escrow-touching transfers return zero. - function _quoteTransferFee( - address from, - address to, - uint256 tokenId - ) - private - view - returns (address escrow, uint256 transferFee, uint256 requiredFee) - { - if (from == to) return (address(0), 0, 0); - - IDotnsProtocolRegistry registry = protocolRegistry; - escrow = registry.get(DotnsConstants.NAME_ESCROW); - require(escrow != address(0), EscrowNotConfigured()); - - bool isEscrowTouching = to == escrow || from == escrow; - IStoreFactory factory = IStoreFactory(registry.get(DotnsConstants.STORE_FACTORY)); - (transferFee, requiredFee) = - _quoteTransferFeeFor(registry, factory, isEscrowTouching, from, to, tokenId); - } - - /// @notice Quotes the transfer floor reusing a caller-cached registry and store factory. - /// @dev Hot-path variant used by @custom:function _update. Returns `(0, 0)` for any - /// escrow-touching move or when the sender holds no label entry; otherwise reads the canonical - /// label and delegates to @custom:function PopRules.transferFloor. - function _quoteTransferFeeFor( - IDotnsProtocolRegistry registry, - IStoreFactory factory, - bool isEscrowTouching, - address from, - address to, - uint256 tokenId - ) - private - view - returns (uint256 transferFee, uint256 requiredFee) - { - if (isEscrowTouching) return (0, 0); - - string memory fullName = _readLabelFor(factory, tokenId, from); - // No label means there is no label-derived price to charge against; treat as a zero-fee - // move (typical of gateway-cold PoP mints that have not yet claimed a `LabelStore`). - if (bytes(fullName).length == 0) return (0, 0); - // A stored full name always carries the registry TLD suffix, so an empty strip means the - // name is malformed for this registry (a wrong or missing suffix); fail loudly rather than - // mis-pricing the move as zero-fee. - string memory label = LabelUtils.stripTld(registry.tld(), fullName); - require(bytes(label).length != 0, InvalidLabel()); - - transferFee = - IPopRules(registry.get(DotnsConstants.POP_RULES)).transferFloor(label, from, to); - requiredFee = transferFee; - } - - /// @inheritdoc UUPSUpgradeable - function _authorizeUpgrade(address newImplementation) internal override onlyOwner {} -} diff --git a/contracts/registrars/IDotnsPopControllerOld.sol b/contracts/registrars/IDotnsPopControllerOld.sol deleted file mode 100644 index d02cbbcc3..000000000 --- a/contracts/registrars/IDotnsPopControllerOld.sol +++ /dev/null @@ -1,499 +0,0 @@ -// 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/registrars/IDotnsRegistrarControllerOld.sol b/contracts/registrars/IDotnsRegistrarControllerOld.sol deleted file mode 100644 index a7cc2ea91..000000000 --- a/contracts/registrars/IDotnsRegistrarControllerOld.sol +++ /dev/null @@ -1,194 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.34; - -import {IDotnsController} from "./IDotnsController.sol"; -import {IPopRules} from "../pop/IPopRules.sol"; - -/// @title Dotns Registrar Controller Old -/// @notice Interface for registering top-level labels using a commit reveal scheme. -/// @dev Pre-upgrade snapshot of the controller interface, pinned so the layout diff has a -/// compilable predecessor for the public surface. Defines allocation only; forward resolution, -/// reverse lookup, pricing mechanics, PoP validation, and store writing are handled by external -/// contracts. Users commit a hash of registration parameters and, after a minimum delay, reveal -/// the same parameters to register. Implementations write the successfully registered name into -/// the user's Store to create an immutable on-chain record that doubles as a quick lookup for all -/// names registered. -/// @dev PR-scoped. This snapshot is deleted before merge with the paired upgrade slice per the -/// upgrade-PR workflow in CONTRIBUTING.md. -/// @custom:security-contact admin@parity.io -interface IDotnsRegistrarControllerOld is IDotnsController { - /// @notice Parameters used to generate and reveal a commitment. - /// @dev All fields must match exactly between commitment and reveal. - /// @param label Label being registered (e.g. "alice"). - /// @param owner Beneficiary the registered name is minted to. - /// @param secret Caller-chosen entropy that hides the registration intent in the commit - /// hash; revealed verbatim at registration time. - /// @param reserved True when the registration flows through the whitelisted reserved - /// pipeline (`registerReserved`); false for the standard public flow (`register`). - /// @param maxPrice Ceiling in wei the caller accepts for this registration; a reveal charged - /// above it reverts, closing the gap between the price at commit and the price at reveal. - /// @param pricingVersion Cost-model version the caller committed to; the reveal prices the name - /// at this version, so a model change between commit and reveal leaves the amount unchanged. It - /// must equal the version current when `commit` ran, which that call stamps on the commitment; - /// a reveal whose `pricingVersion` differs reverts, so the caller cannot bind an earlier, - /// cheaper version. - struct Registration { - string label; - address owner; - bytes32 secret; - bool reserved; - uint256 maxPrice; - uint256 pricingVersion; - } - - /// @notice Emitted when a commitment is submitted. - event NameCommitted(bytes32 indexed commitment); - - /// @notice Emitted when a name is successfully registered. - /// @param baseCost The price returned by the oracle for this registration. - /// @param store The Store instance used to persist an immutable registration record. - event NameRegistered( - string indexed label, - bytes32 indexed labelhash, - address indexed owner, - uint256 baseCost, - address store - ); - - /// @notice Emitted when an address is added to or removed from the whitelist. - event WhiteListed(address indexed who, bool indexed whiteListStatus); - - /// @notice Emitted when overpayment is refunded to the payer at registration entry. - event OverpaymentRefunded(address indexed payer, uint256 amount); - - /// @notice Thrown when the caller is not whitelisted or the owner. - error NotWhiteListedOrOwner(address caller); - - /// @notice Thrown when an unexpired commitment already exists. - error UnexpiredCommitmentExists(bytes32 commitment); - - /// @notice Thrown when revealing a commitment that does not exist. - error CommitmentNotFound(bytes32 commitment); - - /// @notice Thrown when a commitment is revealed before the minimum age. - error CommitmentTooNew(bytes32 commitment, uint256 minTime, uint256 currentTime); - - /// @notice Thrown when a commitment has expired. - error CommitmentTooOld(bytes32 commitment, uint256 maxTime, uint256 currentTime); - - /// @notice Thrown when attempting to register an unavailable name. - error NameNotAvailable(string label); - - /// @notice Thrown when a label is below the minimum-length policy. - /// @dev Distinct from @custom:reverts NameNotAvailable so off-chain consumers can tell a - /// too-short label apart from a name that is already minted. - /// @param label Caller-supplied label that failed the minimum-length policy. - error LabelTooShort(string label); - - /// @notice Thrown when a label is not a canonical lowercase ASCII DNS label. - error InvalidLabel(); - - /// @notice Thrown when supplied payment is insufficient. - error InsufficientValue(); - - /// @notice Thrown when the total charge exceeds the ceiling the caller committed to. - /// @param label Label whose charge exceeded the ceiling. - /// @param charged Total charge computed at reveal. - /// @param maxPrice Ceiling the caller committed to. - error PriceExceedsMax(string label, uint256 charged, uint256 maxPrice); - - /// @notice Thrown when escrow is not configured in the protocol registry. - error EscrowNotConfigured(); - - /// @notice Thrown when min commitment age is zero, which would allow same-block - /// commit-reveal and defeat the front-running guard. - error MinCommitmentAgeZero(); - - /// @notice Thrown when max commitment age is invalid (must be > minCommitmentAge). - error MaxCommitmentAgeTooLow(); - - /// @notice Thrown when max commitment age is invalid (exceeds implementation limit). - error MaxCommitmentAgeTooHigh(); - - /// @notice Returns whether a label is available for registration. - /// @dev Validates the canonical DNS-label shape (otherwise @custom:reverts InvalidLabel) - /// and rejects labels below the minimum-length policy with - /// @custom:reverts LabelTooShort before checking ERC721 availability on the registrar. - function available(string calldata label) external view returns (bool isAvailable); - - /// @notice Computes the commitment hash for a registration. - /// @dev Uses `abi.encode` so the variable-width `label` is length-prefixed and the boundary - /// between `label` and the fixed-width `owner`, `secret`, `reserved`, `maxPrice`, and - /// `pricingVersion` fields is unambiguous, binding the commitment to the exact tuple. The - /// price ceiling and cost-model version are part of that tuple, so neither can be altered - /// between commit and reveal. - function makeCommitment(Registration calldata registration) - external - pure - returns (bytes32 commitment); - - /// @notice Submits a commitment for a future registration. - /// @dev Idempotent over expiry: re-committing an unexpired hash reverts with - /// @custom:reverts UnexpiredCommitmentExists (front-running guard); a hash whose stored - /// timestamp has passed `maxCommitmentAge` overwrites the slot so storage cannot be - /// permanently griefed. The expiry boundary is inclusive on the commit side - /// (`committedAt + maxCommitmentAge <= block.timestamp` overwrites) and exclusive on the - /// reveal side (`register` rejects at the same instant with @custom:reverts - /// CommitmentTooOld), so the slot is overwritable from exactly the timestamp at which - /// reveal begins rejecting it. Stamps the cost model's current version on the commitment, so - /// the reveal binds to the version live now and rejects a `pricingVersion` bound to an earlier - /// one with @custom:reverts PricingVersionMismatch. Emits @custom:emits NameCommitted on - /// success. - function commit(bytes32 commitment) external; - - /// @notice Registers a name after the commitment delay. - /// @dev Validates the label shape (otherwise @custom:reverts InvalidLabel), rejects labels - /// below the minimum length policy (@custom:reverts LabelTooShort), and ERC721 availability - /// (otherwise @custom:reverts NameNotAvailable), then consumes the prior commitment, which - /// fails with @custom:reverts CommitmentNotFound when no commitment exists for the supplied - /// registration, @custom:reverts CommitmentTooNew before `minCommitmentAge`, and - /// @custom:reverts CommitmentTooOld past `maxCommitmentAge`, and finally resolves the - /// configured escrow address from the protocol registry (otherwise - /// @custom:reverts EscrowNotConfigured). Splits on direct vs cross-payer at - /// `msg.sender == registration.owner`. The direct path runs `priceWithCheck` (personhood - /// + reservation gate) and routes the charge to a refundable escrow deposit owned by - /// `registration.owner`. The cross-payer path skips the personhood revert in - /// `priceWithCheck` but applies it directly via @custom:reverts OwnerStatusInsufficient - /// when the owner's recorded tier does not meet the label's required tier, and still - /// rejects governance-reserved labels with @custom:reverts GovernanceReserved and live - /// cross-user stem reservations with @custom:reverts NameReserved. The cross-payer charge is - /// the owner-side registration price; the path applies no separate transfer friction. The - /// charge routes to the escrow protocol fee pot while seeding a zero-amount deposit slot so - /// the release lifecycle stays reachable. The reveal prices the name at the committed - /// `pricingVersion`, so a model change between commit and reveal leaves the amount unchanged, - /// and rejects a total charge above the committed ceiling with @custom:reverts PriceExceedsMax - /// before checking payment. The caller must supply at least the charge - /// (otherwise @custom:reverts InsufficientValue); any overpayment is pushed back to - /// `msg.sender` inline and, on failure, credited to the escrow's pull-payment ledger so - /// contract receivers cannot block registration. Emits @custom:emits OverpaymentRefunded - /// on the inline branch, the escrow's own @custom:emits OverpaymentRefunded on the pull - /// fallback, and @custom:emits NameRegistered on success. - function register(Registration calldata registration) external payable; - - /// @notice Registers a name after the commitment delay. - /// @dev Whitelisted issuance path used to seed reserved labels at zero base cost: skips the - /// PoP price check and the escrow deposit, but reuses the same commit-reveal pipeline so - /// the same anti-front-running guarantees apply. Restricted to whitelisted callers and the - /// owner (otherwise @custom:reverts NotWhiteListedOrOwner). Validates the label shape - /// (otherwise @custom:reverts InvalidLabel) and ERC721 availability (otherwise - /// @custom:reverts NameNotAvailable), then consumes the prior commitment, which fails with - /// @custom:reverts CommitmentNotFound, @custom:reverts CommitmentTooNew, or - /// @custom:reverts CommitmentTooOld under the same conditions as @custom:function register. - /// Emits - /// @custom:emits NameRegistered on success. - function registerReserved(Registration calldata registration) external; - - /// @notice Checks if the given address is whitelisted to call `registerReserved`. - function isWhiteListed(address who) external view returns (bool isWhiteListed); - - /// @notice Adds or removes an address from the whitelist for `registerReserved`. - /// @dev Callable by the owner or an account holding `DotnsConstants.WHITELIST_OPERATOR_ROLE`; - /// any other caller reverts with @custom:reverts IDotnsRoleManagerOld.NotRoleOrOwner. Emits - /// @custom:emits WhiteListed on success. - function whiteListAddress(address who, bool whiteListStatus) external; -} diff --git a/contracts/registrars/IDotnsRegistrarOld.sol b/contracts/registrars/IDotnsRegistrarOld.sol deleted file mode 100644 index a3c5c470a..000000000 --- a/contracts/registrars/IDotnsRegistrarOld.sol +++ /dev/null @@ -1,174 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.34; - -import {IERC721} from "@openzeppelin/contracts-upgradeable/token/ERC721/ERC721Upgradeable.sol"; -import {IDotnsController} from "./IDotnsController.sol"; - -/// @title Dotns Registrar -/// @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 ERC721-backed ownership for DotNS names with controller-gated registration. -/// @dev Intentionally minimal and policy-free. Provides ERC721 ownership for registered name -/// token IDs and controller-gated registration; pricing, PoP enforcement, and flow-specific -/// policy live in the controllers. -/// @custom:security-contact admin@parity.io -interface IDotnsRegistrarOld is IERC721 { - /// @notice Thrown when a name is already registered. - error NameNotAvailable(uint256 tokenId); - - /// @notice Thrown when the caller is not an authorised controller. - error NotController(address caller); - - /// @notice Thrown when the protocol registry has no escrow address configured. - error EscrowNotConfigured(); - - /// @notice Thrown when a standard ERC721 transfer is attempted but the recipient - /// tier requires a non-zero transfer fee and the caller forwarded no `msg.value`. - error TransferFeeRequired(uint256 tokenId, address to, uint256 requiredFee); - - /// @notice Thrown when @custom:function initialize is called with the zero address as - /// the protocol registry. - error ProtocolRegistryRequired(); - - /// @notice Thrown when a mint, burn, or self-transfer carries `msg.value`. None of those - /// paths forward value onward, so attached value would be permanently trapped. - error UnexpectedValue(); - - /// @notice Thrown when @custom:function register is called with the escrow address as - /// `owner`, which would mint directly into escrow custody with no @custom:struct - /// ReleasePosition recorded. - error InvalidOwner(); - - /// @notice Thrown when @custom:function register receives an empty or non-canonical - /// label. - error InvalidLabel(); - - /// @notice Emitted when a name is registered. - event NameRegistered(uint256 indexed id, address indexed owner); - - /// @notice Emitted when a controller is added. - /// @dev Typed as the shared baseline @custom:contract IDotnsController so the commit-reveal - /// controller and the PoP controller (and any future controller) all fit the same signature - /// without - /// the registrar depending on any specific controller interface. - event ControllerAdded(IDotnsController indexed controller); - - /// @notice Emitted when a controller is removed. - event ControllerRemoved(IDotnsController indexed controller); - - /// @notice Returns whether a registration call may proceed for `id`. - /// @dev Signals two distinct paths to the controller. Returns `true` when the owner slot is - /// empty (a fresh @custom:function register call may mint) OR when the current owner is - /// the configured escrow and the released position's redeem window has elapsed (the - /// controller must then route through @custom:function IDotnsNameEscrow.reclaim instead of - /// @custom:function register, because `register` calls `_mint` which rejects existing - /// tokens). All other holders return `false`. The controller distinguishes the two `true` - /// cases via @custom:function exists. - /// Escrow custody inside the redeem window returns `false`: that window belongs to the - /// previous holder, who may still @custom:function IDotnsNameEscrow.redeem the name, and - /// reclaim would revert until it elapses. Clients wanting the exact moment a released name - /// becomes registrable should read `redeemableUntil` from - /// @custom:function IDotnsNameEscrow.getReleasePosition. - function available(uint256 id) external view returns (bool isAvailable); - - /// @notice Registers a name permanently. - /// @dev Permanence is by construction: there is no `expire`, `renew`, or `release` path on - /// the registrar. Custody only moves via ERC721 transfers (which the registrar polices via - /// the fee-on-transfer hook) or via escrow reclaim. Restricted to authorised controllers - /// (otherwise @custom:reverts NotController) and rejects ids that are not available - /// (otherwise @custom:reverts NameNotAvailable). Emits @custom:emits NameRegistered on - /// success. - /// @param label The human-readable label string (e.g. "alice"). - function register(uint256 id, address owner, string calldata label) external; - - /// @notice Returns whether a given token id has been minted. - function exists(uint256 tokenId) external view returns (bool tokenExists); - - /// @notice Adds an authorised controller. - /// @dev Typed against the baseline `IDotnsController` (not a concrete subtype) so a single - /// authorisation surface accepts every controller flavour (commit-reveal, PoP gateway, - /// future variants) without per-flavour setters. Owner-gated (otherwise - /// @custom:reverts OwnableUnauthorizedAccount); emits @custom:emits ControllerAdded on - /// success. - function addController(IDotnsController controller) external; - - /// @notice Removes an authorised controller. - /// @dev Mirrors the @custom:function addController baseline-typed signature so any registered - /// controller can - /// be revoked through the same entry point. Owner-gated (otherwise - /// @custom:reverts OwnableUnauthorizedAccount); emits @custom:emits ControllerRemoved on - /// success. - function removeController(IDotnsController controller) external; - - /// @notice Returns whether `controller` is currently authorised to call - /// @custom:function register. - /// @param controller Candidate controller. - /// @return authorised True when `controller` was added via @custom:function addController and - /// has not been removed. - function controllers(IDotnsController controller) external view returns (bool authorised); - - /// @notice Returns the human-readable label a token was registered with. - /// @dev Canonical state source for the label string; any client that holds a node or - /// tokenId can resolve the original label in one view call without scanning registration - /// events. Returns the empty string when the token does not exist. - function labelOf(uint256 tokenId) external view returns (string memory label); - - /// @notice Quotes the additional native fee required to transfer a token to `to`. - /// @dev Returns the fee from @custom:function PopRules.transferFloor: the name's own price - /// as the maximum of (i) the reach component charged when the recipient does not meet - /// the label's required tier and (ii) the downgrade component charged when the - /// recipient tier is strictly below the sender tier. Self-transfers and - /// escrow-touching transfers (release into escrow, reclaim out of escrow) return - /// zero. A token whose sender has no stored label also returns zero because there - /// is no label-derived price to charge against; this covers gateway-cold PoP mints - /// (the controller passes an empty label to @custom:function register so substrate - /// Root does not have to deploy a `LabelStore`) until the user settles via - /// @custom:function IDotnsPopController.claimLabelStore. Because settlement writes - /// the label into the original claimant's store, a transfer that happens before - /// settlement leaves the recipient with no label entry and the zero-fee branch - /// persists for that token under all future holders. A token registered with no - /// label that is moved off-chain prior to settlement therefore carries no PoP-tier - /// transfer friction. Off-chain consumers integrating PoP mints should treat - /// @custom:function claimLabelStore as a prerequisite for accurate transfer-time - /// pricing on gateway-issued names. Rejects a zero `to` with - /// @custom:reverts ERC721InvalidReceiver, an unminted `tokenId` with - /// @custom:reverts ERC721NonexistentToken via the underlying `ownerOf`, and requires - /// the protocol registry to have an escrow configured (otherwise - /// @custom:reverts EscrowNotConfigured). Returns zero when the protocol registry - /// has no `STORE_FACTORY` configured because no label-derived price is reachable. - function quoteTransferFee( - uint256 tokenId, - address to - ) - external - view - returns (uint256 requiredFee); - - /// @inheritdoc IERC721 - /// @dev The registrar's `_update` hook consults @custom:function PopRules.transferFloor - /// to compute the required transfer fee; if the caller does not forward at least that - /// amount as `msg.value`, the transfer reverts with @custom:reverts TransferFeeRequired. - /// The `payable` modifier on every transfer overload exists so the fee can be forwarded - /// in the same call. - function safeTransferFrom( - address from, - address to, - uint256 tokenId, - bytes calldata data - ) - external - payable - override; - - /// @inheritdoc IERC721 - /// @dev Subject to the same fee-on-transfer gate as the four-argument overload; reverts with - /// @custom:reverts TransferFeeRequired when the recipient owes a non-zero transfer fee and - /// the caller has not forwarded it as `msg.value`. - function safeTransferFrom(address from, address to, uint256 tokenId) external payable override; - - /// @inheritdoc IERC721 - /// @dev Subject to the same fee-on-transfer gate as the safe overloads; reverts with - /// @custom:reverts TransferFeeRequired when the recipient owes a non-zero transfer fee and - /// the caller has not forwarded it as `msg.value`. - function transferFrom(address from, address to, uint256 tokenId) external payable override; -} diff --git a/contracts/store/ILabelStoreOld.sol b/contracts/store/ILabelStoreOld.sol deleted file mode 100644 index 0f3c50336..000000000 --- a/contracts/store/ILabelStoreOld.sol +++ /dev/null @@ -1,124 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.34; - -import {IDotnsStore} from "./IDotnsStore.sol"; - -/// @title ILabelStoreOld -/// @notice Interface for the per-user DotNS label store. -/// @dev The `LabelStore` is the protocol-managed half of the per-user storage pair: -/// write-only by addresses registered in the protocol registry, read-only by -/// everyone else, and permanently locked per `labelhash` on first write. It -/// holds registration records only; every other per-name category (reverse, -/// content, forward address, chat key, lite link) lives on a dedicated -/// resolver, not here. -/// @dev PR-scoped pinned snapshot: the upgrade layout diff compares the new `LabelStore` -/// implementation against @custom:contract LabelStoreOld, which implements this interface. -/// Removed before merge with the paired upgrade script and fork test per CONTRIBUTING.md. -/// @custom:security-contact admin@parity.io -interface ILabelStoreOld is IDotnsStore { - /// @notice Emitted when a label is stored for the first (and only) time under a given - /// labelhash. @param owner The user this store is bound to. - /// @param labelhash The labelhash key. - /// @param label The stored label string (typically the full name, e.g. "alice.dot"). - event LabelStored(address indexed owner, bytes32 indexed labelhash, string label); - - /// @notice Thrown when a caller that is not currently protocol-registered attempts a write. - /// @param caller The msg.sender that failed the `isRegisteredAddress` check. - error NotAuthorised(address caller); - - /// @notice Thrown when `initialize` is called with a zero user address. - /// @param user The invalid user argument. - error InvalidUser(address user); - - /// @notice Thrown when `initialize` is called with a zero protocol registry address. - /// @param protocolRegistry The invalid registry argument. - error InvalidProtocolRegistry(address protocolRegistry); - - /// @notice Thrown when `storeLabel` is called with a zero labelhash. - /// @param labelhash The invalid labelhash argument. - error InvalidLabel(bytes32 labelhash); - - /// @notice Thrown when `storeLabel` is called for a labelhash already present in the index. - /// @dev Labels are write-once and permanently locked on first store, so any second write - /// for the same labelhash fails with this error regardless of caller or session. - /// @param labelhash The conflicting labelhash. - error LabelAlreadyExists(bytes32 labelhash); - - /// @notice Initialises the store, binding it permanently to `user_` and `protocolRegistry_`. - /// @dev Callable exactly once via `Initializable`; both parameters are immutable post-call. - /// `user_` must be non-zero, otherwise @custom:reverts InvalidUser. - /// `protocolRegistry_` must be non-zero, otherwise @custom:reverts - /// InvalidProtocolRegistry. @param user_ The user this store is bound to forever. - /// @param protocolRegistry_ The protocol registry used to authorise writers. - function initialize(address user_, address protocolRegistry_) external; - - /// @notice Records a label under `labelhash` and locks the slot permanently. - /// @dev Gated to addresses currently registered in the protocol registry, otherwise - /// @custom:reverts NotAuthorised. `labelhash` must be non-zero, otherwise - /// @custom:reverts InvalidLabel. The slot must not already hold an entry, otherwise - /// @custom:reverts LabelAlreadyExists; the write is permanent so any second call - /// reverts. Emits @custom:emits LabelStored on the single successful write. - /// @param labelhash The labelhash key. - /// @param label The label string to store. - function storeLabel(bytes32 labelhash, string calldata label) external; - - /// @notice Returns the protocol registry this store queries for write authorisation. - /// @return protocolRegistry_ The registry address. - function protocolRegistry() external view returns (address protocolRegistry_); - - /// @notice Returns true iff a label has been stored under `labelhash`. - /// @param labelhash The labelhash to check. - /// @return exists True iff the slot holds a label. - function hasLabel(bytes32 labelhash) external view returns (bool exists); - - /// @notice Returns true iff the slot for `labelhash` is permanently locked. - /// @dev Always equal to `hasLabel` in the current design; exposed explicitly so future - /// implementations behind the beacon can distinguish "stored" from "locked" if needed. - /// @param labelhash The labelhash to check. - /// @return locked True iff the slot is locked. - function isLocked(bytes32 labelhash) external view returns (bool locked); - - /// @notice Returns the stored label for `labelhash`, or the empty string if none. - /// @param labelhash The labelhash to look up. - /// @return label The stored label string. - function getLabel(bytes32 labelhash) external view returns (string memory label); - - /// @notice Returns the total number of labels ever stored. - /// @return count Current length of the insertion-order list. - function getLabelCount() external view returns (uint256 count); - - /// @notice Returns the human-readable label at the given insertion-order index. - /// @dev Primary read for "give me my names"; does not require the caller to know any - /// labelhash. For the underlying labelhash key see @custom:function getLabelhashAt. - /// @param index Zero-based index into the insertion-order list. - /// @return label The stored label string at `index`. - function getLabelAt(uint256 index) external view returns (string memory label); - - /// @notice Returns the labelhash at the given insertion-order index. - /// @param index Zero-based index into the insertion-order list. - /// @return labelhash The labelhash at `index`. - function getLabelhashAt(uint256 index) external view returns (bytes32 labelhash); - - /// @notice Paginated read returning just the stored labels, in insertion order. - /// @dev Primary bulk read for "give me all my names". Callers never need to touch - /// labelhashes. Length is `min(limit, getLabelCount() - offset)`; - /// `offset >= getLabelCount()` returns an empty array (not a revert). - /// @param offset Start index. - /// @param limit Maximum entries to return. - /// @return labels Slice of label strings. - function getLabels(uint256 offset, uint256 limit) external view returns (string[] memory labels); - - /// @notice Paginated read over the labelhash keys, in insertion order. - /// @dev Advanced read for callers that need the raw labelhash keys. Symmetric with - /// @custom:function getLabels; same indices map to the same entries. - /// @param offset Start index. - /// @param limit Maximum entries to return. - /// @return labelhashes Slice of labelhash keys. - function getLabelhashes( - uint256 offset, - uint256 limit - ) - external - view - returns (bytes32[] memory labelhashes); -} diff --git a/contracts/store/LabelStoreOld.sol b/contracts/store/LabelStoreOld.sol deleted file mode 100644 index 0ca2defd5..000000000 --- a/contracts/store/LabelStoreOld.sol +++ /dev/null @@ -1,187 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.34; - -import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; - -import {IDotnsStore} from "./IDotnsStore.sol"; -import {ILabelStoreOld} from "./ILabelStoreOld.sol"; -import {IDotnsProtocolRegistry} from "../registry/IDotnsProtocolRegistry.sol"; - -/// @title LabelStoreOld -/// @notice Permanent per-user DotNS label store. -/// @dev One instance per user, deployed as a `BeaconProxy` by `StoreFactory` during registration. -/// Bound to its user forever: `_owner` and `_protocolRegistry` are set once at `initialize` -/// and never mutate. Writes are gated to addresses currently registered in -/// `DotnsProtocolRegistry` (`isRegisteredAddress`); every labelhash is single-write and -/// permanently locked on first use. -/// @dev Labels-only by invariant: this store holds registration records only. Every other -/// per-name category (reverse, content, forward address, chat key, lite link) lives on a -/// dedicated resolver, never here. -/// @dev Storage collision: the `BeaconProxy` stores the beacon address at EIP-1967 slot -/// `keccak256("eip1967.proxy.beacon") - 1`, which is non-sequential and cannot collide -/// with this contract's sequential storage slots. -/// @dev PR-scoped pinned snapshot: the upgrade layout diff compares the new `LabelStore` -/// implementation against this contract. Removed before merge with the paired upgrade -/// script and fork test per CONTRIBUTING.md. -/// @custom:security-contact admin@parity.io -contract LabelStoreOld is Initializable, ILabelStoreOld { - /// @dev Permanent user this store belongs to. Set in `initialize`. - address private _owner; - - /// @dev Canonical DotNS protocol registry. Set in `initialize`. - address private _protocolRegistry; - - /// @dev labelhash => stored label string. - mapping(bytes32 labelhash => string label) private _labels; - - /// @dev Insertion-order list of all stored labelhashes. Append-only. - bytes32[] private _labelList; - - /// @dev labelhash => 1-indexed position in `_labelList` (zero means "not present"). - /// Doubles as the permanent-lock sentinel: a non-zero index proves the label was written and - /// the contract has no deletion path, so the index is also the locked flag. - mapping(bytes32 labelhash => uint256 indexPlusOne) private _labelIndex; - - /// @dev Reserved storage space to allow for layout changes in future beacon upgrades. - // forge-lint: disable-next-line(mixed-case-variable) - uint256[50] private __gap; - - /// @notice Restricts writes to protocol-registered addresses only. - modifier onlyAuthorisedProtocol() { - _onlyAuthorisedProtocol(); - _; - } - - /// @custom:oz-upgrades-unsafe-allow constructor - constructor() { - _disableInitializers(); - } - - /// @inheritdoc ILabelStoreOld - function initialize(address user_, address protocolRegistry_) external override initializer { - require(user_ != address(0), InvalidUser(user_)); - require(protocolRegistry_ != address(0), InvalidProtocolRegistry(protocolRegistry_)); - _owner = user_; - _protocolRegistry = protocolRegistry_; - } - - /// @inheritdoc ILabelStoreOld - function storeLabel( - bytes32 labelhash, - string calldata label - ) - external - override - onlyAuthorisedProtocol - { - require(labelhash != bytes32(0), InvalidLabel(labelhash)); - require(_labelIndex[labelhash] == 0, LabelAlreadyExists(labelhash)); - - // Cache `length + 1` before `push` so the post-push length SLOAD is avoided; the value is - // also the 1-indexed position we are about to write. - uint256 newIndex = _labelList.length + 1; - _labels[labelhash] = label; - _labelList.push(labelhash); - _labelIndex[labelhash] = newIndex; - - emit LabelStored(_owner, labelhash, label); - } - - /// @inheritdoc IDotnsStore - function owner() external view override returns (address owner_) { - return _owner; - } - - /// @inheritdoc ILabelStoreOld - function protocolRegistry() external view override returns (address protocolRegistry_) { - return _protocolRegistry; - } - - /// @inheritdoc ILabelStoreOld - function hasLabel(bytes32 labelhash) external view override returns (bool exists) { - return _labelIndex[labelhash] != 0; - } - - /// @inheritdoc ILabelStoreOld - function isLocked(bytes32 labelhash) external view override returns (bool locked) { - return _labelIndex[labelhash] != 0; - } - - /// @inheritdoc ILabelStoreOld - function getLabel(bytes32 labelhash) external view override returns (string memory label) { - return _labels[labelhash]; - } - - /// @inheritdoc ILabelStoreOld - function getLabelCount() external view override returns (uint256 count) { - return _labelList.length; - } - - /// @inheritdoc ILabelStoreOld - function getLabelAt(uint256 index) external view override returns (string memory label) { - return _labels[_labelList[index]]; - } - - /// @inheritdoc ILabelStoreOld - function getLabelhashAt(uint256 index) external view override returns (bytes32 labelhash) { - return _labelList[index]; - } - - /// @inheritdoc ILabelStoreOld - function getLabels( - uint256 offset, - uint256 limit - ) - external - view - override - returns (string[] memory labels) - { - uint256 total = _labelList.length; - if (offset >= total) return new string[](0); - - uint256 available = total - offset; - uint256 count = limit < available ? limit : available; - - labels = new string[](count); - for (uint256 i; i < count; ++i) { - labels[i] = _labels[_labelList[offset + i]]; - } - } - - /// @inheritdoc ILabelStoreOld - function getLabelhashes( - uint256 offset, - uint256 limit - ) - external - view - override - returns (bytes32[] memory labelhashes) - { - uint256 total = _labelList.length; - if (offset >= total) return new bytes32[](0); - - uint256 available = total - offset; - uint256 count = limit < available ? limit : available; - - labelhashes = new bytes32[](count); - for (uint256 i; i < count; ++i) { - labelhashes[i] = _labelList[offset + i]; - } - } - - /// @notice Returns implementation version. - /// @return versionString Current version string. - function version() external pure virtual returns (string memory versionString) { - versionString = "1.0.0"; - } - - /// @notice Internal authorisation check deferred from the `onlyAuthorisedProtocol` modifier. - function _onlyAuthorisedProtocol() internal view { - require( - IDotnsProtocolRegistry(_protocolRegistry).isRegisteredAddress(msg.sender), - NotAuthorised(msg.sender) - ); - } -} diff --git a/contracts/whitelist/DotnsNameWhitelistOld.sol b/contracts/whitelist/DotnsNameWhitelistOld.sol deleted file mode 100644 index bca42fa5b..000000000 --- a/contracts/whitelist/DotnsNameWhitelistOld.sol +++ /dev/null @@ -1,537 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.34; - -import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; -import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; -import {EnumerableSet} from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; - -import {DotnsRoleManagerOld} from "../access/DotnsRoleManagerOld.sol"; -import {IDotnsNameWhitelistOld} from "./IDotnsNameWhitelistOld.sol"; -import {IDotnsProtocolRegistry} from "../registry/IDotnsProtocolRegistry.sol"; -import {LabelUtils} from "../utils/LabelUtils.sol"; -import {StringUtils} from "../utils/StringUtils.sol"; -import {DotnsConstants} from "../utils/DotnsConstants.sol"; -import {SystemUtils} from "../utils/SystemUtils.sol"; - -/// @title DotnsNameWhitelist -/// @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 Pre-launch name whitelist. A name is Open until governance reserves it or a claim is -/// accepted for it. Several beneficiaries may claim the same Open name, each with a -/// reason, and governance accepts one as the winner. -/// @dev Lives behind its own UUPS proxy with its own storage. Callers pass bare labels only; the -/// contract derives the node from the label and the TLD in the protocol registry, so a -/// caller cannot supply a mismatched hash. Claims are keyed by the beneficiary `user`, not -/// the submitter, so a relayer or a cross-chain sovereign account can submit on a user's -/// behalf and the name binds to that user. All state is on-chain and queryable through views; -/// no event indexing is required. A name holds at most `maxClaimants` live claims, which -/// bounds the loop that clears them on resolution. Resolving a name deletes its claims, -/// refunding their storage deposit, so only reserved or won names persist. Governance is Root -/// or the owner. Substrate Root has no address, so the governance gates check -/// `SystemUtils.originIsRoot`, which is true through the proxy's delegatecall frame, before -/// reading `msg.sender`. Operators are signed role holders -/// for day-to-day approvals; the public and PoP controllers hold only the `consume` hook. -/// Entries are keyed by the node under the active TLD, which the deployment holds immutable -/// for the whitelist's lifetime. -/// @custom:security-contact admin@parity.io -contract DotnsNameWhitelistOld is - Initializable, - UUPSUpgradeable, - DotnsRoleManagerOld, - IDotnsNameWhitelistOld -{ - using StringUtils for string; - using EnumerableSet for EnumerableSet.AddressSet; - using EnumerableSet for EnumerableSet.Bytes32Set; - - /// @notice Operator role identifier this snapshot recognises for its operational gates. - /// @dev Pinned here so the snapshot compiles against the current `DotnsConstants`, which no - /// longer declares the role. The value matches the identifier the deployed proxy stored. - bytes32 private constant WHITELIST_OPERATOR_ROLE = keccak256("DOTNS_WHITELIST_OPERATOR_ROLE"); - - /// @notice Protocol-level address registry for all DotNS contracts. - IDotnsProtocolRegistry public protocolRegistry; - - /// @notice Live-claim cap per name, tunable by governance within - /// `DotnsConstants.WHITELIST_MAX_CLAIMANTS_LIMIT`. - uint16 public maxClaimants; - - /// @notice Cap on labels per `grantNames` call, tunable by governance within - /// `DotnsConstants.WHITELIST_MAX_GRANT_BATCH_LIMIT`. - uint16 public maxGrantBatch; - - /// @notice Reason byte cap, tunable by governance within - /// `DotnsConstants.WHITELIST_MAX_REASON_LIMIT`. - uint256 public maxReasonBytes; - - /// @notice Resolved state per name. - mapping(bytes32 node => NameRecord record) private _names; - - /// @notice Claims per name, keyed by beneficiary. - mapping(bytes32 node => mapping(address user => Claim claim)) private _claims; - - /// @notice Beneficiaries with a live claim per name. - mapping(bytes32 node => EnumerableSet.AddressSet claimants) private _claimants; - - /// @notice Names holding reserved, claimed or claim-holding state, kept enumerable for review. - EnumerableSet.Bytes32Set private _activeNodes; - - /// @notice Timestamp requests start being accepted. - uint64 private _requestOpen; - - /// @notice Timestamp requests stop being accepted. - uint64 private _requestClose; - - /// @dev Reserved storage space to allow for layout changes in the future. - uint256[50] private __gap; - - /// @notice Restricts a call to Root or the owner. - /// @dev Checks Root first so `msg.sender`, which traps under a Root origin, is read only for a - /// signed caller. - modifier onlyGovernance() { - if (!SystemUtils.originIsRoot()) { - _checkOwner(); - } - _; - } - - /// @notice Restricts a call to Root, the owner, or an operator. - modifier onlyOperatorOrGovernance() { - if (!SystemUtils.originIsRoot()) { - _checkRoleOrOwner(WHITELIST_OPERATOR_ROLE); - } - _; - } - - /// @notice Restricts a call to a registrar controller resolved through the registry. - modifier onlyController() { - require( - msg.sender == protocolRegistry.get(DotnsConstants.CONTROLLER) - || msg.sender == protocolRegistry.get(DotnsConstants.POP_CONTROLLER), - NotController(msg.sender) - ); - _; - } - - /// @custom:oz-upgrades-unsafe-allow constructor - constructor() { - _disableInitializers(); - } - - /// @notice Initialises the whitelist. - /// @dev Callable once through the UUPS proxy; direct calls on the implementation - /// @custom:reverts InvalidInitialization. Sets the deployer as owner and wires the - /// protocol registry the node derivation reads the TLD from. - /// @param registry Protocol registry all DotNS contracts resolve through. - function initialize(IDotnsProtocolRegistry registry) external initializer { - __ERC165_init(); - __Ownable_init(msg.sender); - _dotnsRoleManagerInit(); - protocolRegistry = registry; - maxClaimants = DotnsConstants.WHITELIST_DEFAULT_MAX_CLAIMANTS; - maxGrantBatch = DotnsConstants.WHITELIST_DEFAULT_MAX_GRANT_BATCH; - maxReasonBytes = DotnsConstants.WHITELIST_DEFAULT_MAX_REASON_BYTES; - } - - /// @inheritdoc IDotnsNameWhitelistOld - function setOperator(address account, bool enabled) external override onlyGovernance { - _setRole(WHITELIST_OPERATOR_ROLE, account, enabled); - } - - /// @inheritdoc IDotnsNameWhitelistOld - function setMaxClaimants(uint16 newMax) external override onlyGovernance { - require( - newMax > 0 && newMax <= DotnsConstants.WHITELIST_MAX_CLAIMANTS_LIMIT, - MaxClaimantsOutOfRange() - ); - maxClaimants = newMax; - emit MaxClaimantsSet(newMax); - } - - /// @inheritdoc IDotnsNameWhitelistOld - function setMaxReasonBytes(uint256 newMax) external override onlyGovernance { - require( - newMax > 0 && newMax <= DotnsConstants.WHITELIST_MAX_REASON_LIMIT, - MaxReasonBytesOutOfRange() - ); - maxReasonBytes = newMax; - emit MaxReasonBytesSet(newMax); - } - - /// @inheritdoc IDotnsNameWhitelistOld - function setMaxGrantBatch(uint16 newMax) external override onlyGovernance { - require( - newMax > 0 && newMax <= DotnsConstants.WHITELIST_MAX_GRANT_BATCH_LIMIT, - MaxGrantBatchOutOfRange() - ); - maxGrantBatch = newMax; - emit MaxGrantBatchSet(newMax); - } - - /// @inheritdoc IDotnsNameWhitelistOld - function requestName( - string calldata label, - string calldata reason, - address user - ) - external - override - { - require(_isWindowOpen(), WindowClosed()); - require(user != address(0), ZeroUser()); - require(bytes(reason).length <= maxReasonBytes, ReasonTooLong()); - require(label.isSingleLabel(), InvalidLabel()); - - bytes32 node = _nodeOf(label); - require(_names[node].status == NameStatus.Open, NameNotOpen(node)); - require(_claims[node][user].status == ClaimStatus.None, AlreadyClaimed(node, user)); - require(_claimants[node].length() < maxClaimants, TooManyClaimants(node)); - - _claims[node][user] = Claim({ - user: user, - status: ClaimStatus.Requested, - requestedAt: uint64(block.timestamp), - submitter: msg.sender, - reason: reason - }); - _claimants[node].add(user); - _activate(node, label); - emit NameRequested(node, user, label, reason); - } - - /// @inheritdoc IDotnsNameWhitelistOld - function accept( - string calldata label, - address user - ) - external - override - onlyOperatorOrGovernance - { - bytes32 node = _nodeOf(label); - require(_claims[node][user].status == ClaimStatus.Requested, NotRequested(node, user)); - emit NameAccepted(node, user, label); - _settle(node, user, label); - } - - /// @inheritdoc IDotnsNameWhitelistOld - function reject( - string calldata label, - address user - ) - external - override - onlyOperatorOrGovernance - { - bytes32 node = _nodeOf(label); - Claim storage claim = _claims[node][user]; - require(claim.status == ClaimStatus.Requested, NotRequested(node, user)); - // Free the claimant slot either way. Keep a sticky Rejected record only for a self-filed - // claim, so the beneficiary cannot re-request; a claim filed on their behalf is - // deleted and never binds them. - _claimants[node].remove(user); - if (claim.submitter == user) { - claim.status = ClaimStatus.Rejected; - } else { - delete _claims[node][user]; - } - emit NameRejected(node, user, label); - _deactivate(node); - } - - /// @inheritdoc IDotnsNameWhitelistOld - function grantName( - string calldata label, - address user - ) - external - override - onlyOperatorOrGovernance - { - _grant(label, user); - } - - /// @inheritdoc IDotnsNameWhitelistOld - function grantNames( - string[] calldata labels, - address user - ) - external - override - onlyOperatorOrGovernance - { - require(labels.length <= maxGrantBatch, TooManyLabels()); - for (uint256 i = 0; i < labels.length; i++) { - _grant(labels[i], user); - } - } - - /// @inheritdoc IDotnsNameWhitelistOld - function revokeName(string calldata label) external override onlyGovernance { - bytes32 node = _nodeOf(label); - NameRecord storage record = _names[node]; - require( - record.status == NameStatus.Claimed || _claimants[node].length() != 0, - NothingToRevoke(node) - ); - address winner = record.winner; - _clearClaimants(node, address(0), label); - record.status = NameStatus.Open; - record.winner = address(0); - emit NameRevoked(node, winner, label); - _deactivate(node); - } - - /// @inheritdoc IDotnsNameWhitelistOld - function setReserved(string calldata label, bool reserved) external override onlyGovernance { - require(label.isSingleLabel(), InvalidLabel()); - bytes32 node = _nodeOf(label); - NameRecord storage record = _names[node]; - if (reserved) { - require(record.status == NameStatus.Open, NameNotOpen(node)); - // Clear any pending claims the way a grant does, so a permissionless requestName - // cannot force governance to revokeName before it can reserve. - _clearClaimants(node, address(0), label); - record.status = NameStatus.Reserved; - _activate(node, label); - emit NameReserved(node, label); - } else { - require(record.status == NameStatus.Reserved, NotReserved(node)); - record.status = NameStatus.Open; - emit NameUnreserved(node, label); - _deactivate(node); - } - } - - /// @inheritdoc IDotnsNameWhitelistOld - function consume(string calldata label, address registrant) external override onlyController { - bytes32 node = _nodeOf(label); - NameRecord storage record = _names[node]; - require( - record.status == NameStatus.Claimed && record.winner == registrant, - NotWinner(registrant, node) - ); - record.status = NameStatus.Open; - record.winner = address(0); - emit NameConsumed(node, registrant, label); - _deactivate(node); - } - - /// @inheritdoc IDotnsNameWhitelistOld - function setWindow(uint64 startsIn, uint64 duration) external override onlyGovernance { - require(duration > 0, BadWindow()); - uint64 openAt = uint64(block.timestamp) + startsIn; - uint64 closeAt = openAt + duration; - _requestOpen = openAt; - _requestClose = closeAt; - emit WindowSet(openAt, closeAt); - } - - /// @inheritdoc IDotnsNameWhitelistOld - function statusOf(string calldata label) external view override returns (NameStatus status) { - return _names[_nodeOf(label)].status; - } - - /// @inheritdoc IDotnsNameWhitelistOld - function isReserved(string calldata label) external view override returns (bool reserved) { - return _names[_nodeOf(label)].status == NameStatus.Reserved; - } - - /// @inheritdoc IDotnsNameWhitelistOld - function granteeOf(string calldata label) external view override returns (address winner) { - NameRecord storage record = _names[_nodeOf(label)]; - return record.status == NameStatus.Claimed ? record.winner : address(0); - } - - /// @inheritdoc IDotnsNameWhitelistOld - function isGrantedTo( - string calldata label, - address account - ) - external - view - override - returns (bool granted) - { - NameRecord storage record = _names[_nodeOf(label)]; - return - account != address(0) && record.status == NameStatus.Claimed && record.winner == account; - } - - /// @inheritdoc IDotnsNameWhitelistOld - function claimOf( - string calldata label, - address user - ) - external - view - override - returns (Claim memory claim) - { - return _claims[_nodeOf(label)][user]; - } - - /// @inheritdoc IDotnsNameWhitelistOld - function claimantCount(string calldata label) external view override returns (uint256 count) { - return _claimants[_nodeOf(label)].length(); - } - - /// @inheritdoc IDotnsNameWhitelistOld - function claims( - string calldata label, - uint256 offset, - uint256 limit - ) - external - view - override - returns (Claim[] memory page) - { - bytes32 node = _nodeOf(label); - EnumerableSet.AddressSet storage set = _claimants[node]; - uint256 total = set.length(); - if (offset >= total) { - return new Claim[](0); - } - uint256 available = total - offset; - uint256 count = limit < available ? limit : available; - page = new Claim[](count); - for (uint256 i; i < count; ++i) { - page[i] = _claims[node][set.at(offset + i)]; - } - } - - /// @inheritdoc IDotnsNameWhitelistOld - function nameCount() external view override returns (uint256 count) { - return _activeNodes.length(); - } - - /// @inheritdoc IDotnsNameWhitelistOld - function names( - uint256 offset, - uint256 limit - ) - external - view - override - returns (NameView[] memory page) - { - uint256 total = _activeNodes.length(); - if (offset >= total) { - return new NameView[](0); - } - uint256 available = total - offset; - uint256 count = limit < available ? limit : available; - page = new NameView[](count); - for (uint256 i; i < count; ++i) { - bytes32 node = _activeNodes.at(offset + i); - NameRecord storage record = _names[node]; - page[i] = NameView({ - node: node, label: record.label, status: record.status, winner: record.winner - }); - } - } - - /// @inheritdoc IDotnsNameWhitelistOld - function window() external view override returns (uint64 openAt, uint64 closeAt) { - return (_requestOpen, _requestClose); - } - - /// @inheritdoc IDotnsNameWhitelistOld - function isWindowOpen() external view override returns (bool open) { - return _isWindowOpen(); - } - - /// @inheritdoc DotnsRoleManagerOld - function supportsInterface(bytes4 interfaceId) - public - view - override(DotnsRoleManagerOld) - returns (bool supported) - { - return interfaceId == type(IDotnsNameWhitelistOld).interfaceId - || super.supportsInterface(interfaceId); - } - - /// @notice Grants `label` to `user` directly, clearing any pending claims. - /// @param label Bare label to grant. - /// @param user Beneficiary the name binds to. - function _grant(string calldata label, address user) internal { - require(user != address(0), ZeroUser()); - require(label.isSingleLabel(), InvalidLabel()); - bytes32 node = _nodeOf(label); - require(_names[node].status == NameStatus.Open, NameNotOpen(node)); - emit NameAccepted(node, user, label); - _settle(node, user, label); - } - - /// @notice Marks a name claimed for `winner` and clears its claims, rejecting the losers. - /// @param node Namehash of the label under the active TLD. - /// @param winner Beneficiary the name binds to. - /// @param label Bare label, stored for review. - function _settle(bytes32 node, address winner, string calldata label) internal { - NameRecord storage record = _names[node]; - record.status = NameStatus.Claimed; - record.winner = winner; - _activate(node, label); - _clearClaimants(node, winner, label); - } - - /// @notice Deletes every claim on a name, rejecting each claimant that is not `winner`. - /// @param node Namehash of the label under the active TLD. - /// @param winner Claimant spared a rejection event; the zero address rejects every claimant. - /// @param label Bare label emitted with each rejection. - function _clearClaimants(bytes32 node, address winner, string calldata label) internal { - address[] memory current = _claimants[node].values(); - for (uint256 i; i < current.length; ++i) { - address claimant = current[i]; - delete _claims[node][claimant]; - _claimants[node].remove(claimant); - if (claimant != winner) { - emit NameRejected(node, claimant, label); - } - } - } - - /// @notice Records a name as active and stores its label the first time it is seen. - /// @param node Namehash of the label under the active TLD. - /// @param label Bare label stored on first activation. - function _activate(bytes32 node, string calldata label) internal { - NameRecord storage record = _names[node]; - if (bytes(record.label).length == 0) { - record.label = label; - } - _activeNodes.add(node); - } - - /// @notice Drops a name from the active set once it is Open with no claims. - /// @param node Namehash of the label under the active TLD. - function _deactivate(bytes32 node) internal { - NameRecord storage record = _names[node]; - if (record.status == NameStatus.Open && _claimants[node].length() == 0) { - _activeNodes.remove(node); - delete record.label; - } - } - - /// @notice Derives the namehash of `label` under the active TLD read from the registry. - /// @param label Bare label to hash. - /// @return node Namehash of the label under the active TLD. - function _nodeOf(string calldata label) internal view returns (bytes32 node) { - (, node) = LabelUtils.deriveNode(protocolRegistry.tldNode(), label); - } - - /// @notice Returns whether the current time is within the open window. - /// @return open True when the current time is within the window. - function _isWindowOpen() internal view returns (bool open) { - return block.timestamp >= _requestOpen && block.timestamp < _requestClose; - } - - /// @inheritdoc DotnsRoleManagerOld - function _isSupportedRole(bytes32 role) internal pure override returns (bool supported) { - return role == WHITELIST_OPERATOR_ROLE; - } - - /// @inheritdoc UUPSUpgradeable - function _authorizeUpgrade(address newImplementation) internal override onlyOwner {} -} diff --git a/contracts/whitelist/IDotnsNameWhitelistOld.sol b/contracts/whitelist/IDotnsNameWhitelistOld.sol deleted file mode 100644 index 99bbf0cb2..000000000 --- a/contracts/whitelist/IDotnsNameWhitelistOld.sol +++ /dev/null @@ -1,376 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.34; - -/// @title IDotnsNameWhitelist -/// @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 pre-launch name whitelist. A name is Open until governance either -/// reserves it or a claim is accepted for it. Several beneficiaries may claim the same -/// Open name, each with a reason, and governance accepts one as the winner. -/// @dev Callers never supply a hash. Every entry point takes the bare label and derives the node -/// from the label and the TLD in the protocol registry, so a caller cannot supply a -/// mismatched hash. Claims are keyed by the beneficiary `user`, not the submitter, so a -/// relayer or a cross-chain sovereign account can submit a claim on a user's behalf and the -/// name still binds to that user. All state is on-chain and queryable through views; no event -/// indexing is required. Governance is Root or the owner. Substrate Root has no address, so -/// the governance gates check `originIsRoot` before reading `msg.sender`. Operators are signed -/// role holders for day-to-day approvals; the controllers hold only the `consume` hook. -/// @custom:security-contact admin@parity.io -interface IDotnsNameWhitelistOld { - /// @notice Status of a name. - /// @dev `Open` is the zero-value default: claimable, not reserved, not won. `Reserved` is - /// withheld by governance. `Claimed` has a single winner. - enum NameStatus { - Open, - Reserved, - Claimed - } - - /// @notice Status of a single claim on a name. - /// @dev `None` is the zero-value default of an absent claim. `Rejected` is sticky: it is kept - /// only when the beneficiary filed the claim themselves, so they cannot re-request; a - /// claim filed on their behalf is deleted on rejection and does not bind them. - enum ClaimStatus { - None, - Requested, - Rejected - } - - /// @notice A claim by one beneficiary on one name. - /// @dev `user`, `status` and `requestedAt` co-locate in one storage slot; `submitter` takes the - /// next, and the dynamic `reason` is stored separately. - /// @param user Beneficiary the name would bind to if this claim wins. - /// @param status Claim status; see ClaimStatus. - /// @param requestedAt Timestamp the claim was made. - /// @param submitter Address that filed the claim, which may differ from the beneficiary. - /// @param reason Free-text justification for the claim. - struct Claim { - address user; - ClaimStatus status; - uint64 requestedAt; - address submitter; - string reason; - } - - /// @notice A name and its resolved state, for review. - /// @param node Namehash of the label under the active TLD. - /// @param label Bare label. - /// @param status Name status; see NameStatus. - /// @param winner Winning beneficiary when `Claimed`, otherwise the zero address. - struct NameView { - bytes32 node; - string label; - NameStatus status; - address winner; - } - - /// @notice Stored resolved state of a name. - /// @dev `status` and `winner` are ordered first so the 1-byte enum and 20-byte address share - /// one storage slot; the dynamic `label` is stored separately. - /// @param status Name status; see NameStatus. - /// @param winner Winning beneficiary when `Claimed`, otherwise the zero address. - /// @param label Bare label, kept so reserved and claimed names are reviewable. - struct NameRecord { - NameStatus status; - address winner; - string label; - } - - /// @notice Emitted when a beneficiary claims a name. - event NameRequested(bytes32 indexed node, address indexed user, string label, string reason); - - /// @notice Emitted when a claim wins a name, including an operator direct grant. - event NameAccepted(bytes32 indexed node, address indexed user, string label); - - /// @notice Emitted when a claim is cleared without winning. - event NameRejected(bytes32 indexed node, address indexed user, string label); - - /// @notice Emitted when a name is reset to Open by governance. - event NameRevoked(bytes32 indexed node, address indexed winner, string label); - - /// @notice Emitted when a winner registers the name and its entry is consumed. - event NameConsumed(bytes32 indexed node, address indexed user, string label); - - /// @notice Emitted when governance withholds a name from claiming. - event NameReserved(bytes32 indexed node, string label); - - /// @notice Emitted when governance releases a reserved name back to Open. - event NameUnreserved(bytes32 indexed node, string label); - - /// @notice Emitted when the request window is set. - /// @param openAt Timestamp requests start being accepted. - /// @param closeAt Timestamp requests stop being accepted. - event WindowSet(uint64 openAt, uint64 closeAt); - - /// @notice Emitted when the live-claim cap is set. - /// @param maxClaimants New per-name claim cap. - event MaxClaimantsSet(uint16 maxClaimants); - - /// @notice Emitted when the reason byte cap is set. - /// @param maxReasonBytes New reason byte cap. - event MaxReasonBytesSet(uint256 maxReasonBytes); - - /// @notice Emitted when the grant-batch cap is set. - /// @param maxGrantBatch New `grantNames` batch cap. - event MaxGrantBatchSet(uint16 maxGrantBatch); - - /// @notice Thrown when a claim names the zero-address beneficiary. - error ZeroUser(); - - /// @notice Thrown when a label is not a canonical single DNS label. - error InvalidLabel(); - - /// @notice Thrown when a reason exceeds `maxReasonBytes`. - error ReasonTooLong(); - - /// @notice Thrown when a name is not Open and the action requires it. - /// @param node Namehash of the label under the active TLD. - error NameNotOpen(bytes32 node); - - /// @notice Thrown when `user` already holds a claim on the name. - /// @param node Namehash of the label under the active TLD. - /// @param user Beneficiary already holding a claim. - error AlreadyClaimed(bytes32 node, address user); - - /// @notice Thrown when a name already holds `maxClaimants` claims. - /// @param node Namehash of the label under the active TLD. - error TooManyClaimants(bytes32 node); - - /// @notice Thrown when the claim cap is set to zero or above - /// `DotnsConstants.WHITELIST_MAX_CLAIMANTS_LIMIT`. - error MaxClaimantsOutOfRange(); - - /// @notice Thrown when the reason cap is set to zero or above - /// `DotnsConstants.WHITELIST_MAX_REASON_LIMIT`. - error MaxReasonBytesOutOfRange(); - - /// @notice Thrown when the grant-batch cap is set to zero or above - /// `DotnsConstants.WHITELIST_MAX_GRANT_BATCH_LIMIT`. - error MaxGrantBatchOutOfRange(); - - /// @notice Thrown when a claim is not in the `Requested` status. - /// @param node Namehash of the label under the active TLD. - /// @param user Beneficiary whose claim was expected to be pending. - error NotRequested(bytes32 node, address user); - - /// @notice Thrown when releasing a name that is not reserved. - /// @param node Namehash of the label under the active TLD. - error NotReserved(bytes32 node); - - /// @notice Thrown when revoking a name that is not Claimed and holds no claims. - /// @param node Namehash of the label under the active TLD. - error NothingToRevoke(bytes32 node); - - /// @notice Thrown when `consume` is called by any address other than a registrar controller. - /// @param caller Rejected caller. - error NotController(address caller); - - /// @notice Thrown when `consume` is called for a name not won by the registrant. - /// @param registrant Address attempting to register the name. - /// @param node Namehash of the label under the active TLD. - error NotWinner(address registrant, bytes32 node); - - /// @notice Thrown when the request window is set with a zero duration. - error BadWindow(); - - /// @notice Thrown when a claim is made outside the open window. - error WindowClosed(); - - /// @notice Thrown when `grantNames` is passed more than `maxGrantBatch` labels. - error TooManyLabels(); - - /// @notice Claims `label` for `user`. - /// @dev Permissionless within the window; the submitter may differ from `user`. Requires the - /// name Open, the window open, `user` non-zero, a canonical label, `user` without an - /// existing claim, and fewer than `maxClaimants` claims on the name. - /// @custom:reverts WindowClosed, @custom:reverts NameNotOpen, @custom:reverts ZeroUser, - /// @custom:reverts InvalidLabel, @custom:reverts ReasonTooLong, - /// @custom:reverts AlreadyClaimed, or @custom:reverts TooManyClaimants. - /// @custom:emits NameRequested. - /// @param label Bare label to claim. - /// @param reason Free-text justification, at most `maxReasonBytes` bytes. - /// @param user Beneficiary the name binds to if this claim wins. - function requestName(string calldata label, string calldata reason, address user) external; - - /// @notice Accepts `user`'s claim as the winner of `label`. - /// @dev Restricted to an operator, the owner, or Root. Requires `user`'s claim `Requested`. - /// Sets the name `Claimed` with `user` the winner and clears every claim on the name, rejecting - /// the losers. @custom:reverts NotRequested. @custom:emits NameAccepted for the winner and - /// @custom:emits NameRejected for each loser. - /// @param label Bare label to resolve. - /// @param user Beneficiary whose claim wins. - function accept(string calldata label, address user) external; - - /// @notice Rejects `user`'s pending claim on `label` without resolving the name. - /// @dev Restricted to an operator, the owner, or Root. Requires the claim `Requested`. - /// @custom:reverts NotRequested. @custom:emits NameRejected. - /// @param label Bare label. - /// @param user Beneficiary whose claim is rejected. - function reject(string calldata label, address user) external; - - /// @notice Grants `label` to `user` directly, without a prior claim. - /// @dev Restricted to an operator, the owner, or Root. Requires the name Open, `user` non-zero - /// and a canonical label. Sets the name `Claimed` with `user` the winner and clears any pending - /// claims. @custom:reverts NameNotOpen, @custom:reverts ZeroUser or - /// @custom:reverts InvalidLabel. @custom:emits NameAccepted, and - /// @custom:emits NameRejected for each cleared claim. - /// @param label Bare label to grant. - /// @param user Beneficiary the name binds to. - function grantName(string calldata label, address user) external; - - /// @notice Grants several labels to one `user` directly. - /// @dev Restricted to an operator, the owner, or Root. Applies @custom:function grantName to - /// each, at most `maxGrantBatch` labels per call. - /// @custom:reverts TooManyLabels when `labels` exceeds the batch cap. - /// @param labels Bare labels to grant. - /// @param user Beneficiary each name binds to. - function grantNames(string[] calldata labels, address user) external; - - /// @notice Resets `label` to Open, clearing any winner and claims. - /// @dev Restricted to an operator, the owner, or Root. Resolves a Claimed or claim-holding - /// name; a Reserved name is released through @custom:function setReserved, not here. - /// @custom:reverts NothingToRevoke when the name is not Claimed and holds no claims. - /// @custom:emits NameRevoked, and @custom:emits NameRejected for each cleared claim. - /// @param label Bare label to reset. - function revokeName(string calldata label) external; - - /// @notice Reserves or releases `label`. - /// @dev Restricted to Root or the owner. Reserving requires the name Open and clears any - /// pending claims, rejecting each; releasing requires it `Reserved`. @custom:reverts - /// NameNotOpen or @custom:reverts NotReserved. @custom:emits NameReserved or @custom:emits - /// NameUnreserved. @param label Bare label. - /// @param reserved True to reserve, false to release. - function setReserved(string calldata label, bool reserved) external; - - /// @notice Removes the win on `label` as `registrant` registers it. - /// @dev Restricted to the registrar controllers resolved through the protocol registry. Resets - /// the name to Open. @custom:reverts NotController for any other caller and - /// @custom:reverts NotWinner when `label` is not won by `registrant`. - /// @custom:emits NameConsumed. - /// @param label Bare label being registered. - /// @param registrant Address registering the name. - function consume(string calldata label, address registrant) external; - - /// @notice Sets the request window relative to the current time. - /// @dev Restricted to Root or the owner. Opens at `block.timestamp + startsIn` for `duration`. - /// @custom:reverts BadWindow when `duration` is zero. @custom:emits WindowSet. - /// @param startsIn Seconds from now until requests start being accepted. - /// @param duration Seconds the window stays open. - function setWindow(uint64 startsIn, uint64 duration) external; - - /// @notice Grants or revokes the operator role for `account`. - /// @dev Restricted to Root or the owner. Root has no address, so governance uses this rather - /// than the owner-only role-admin path. @custom:emits IAccessControl.RoleGranted on grant - /// and @custom:emits IAccessControl.RoleRevoked on revoke. - /// @param account Address whose operator role is changed. - /// @param enabled True to grant, false to revoke. - function setOperator(address account, bool enabled) external; - - /// @notice Sets the live-claim cap per name. - /// @dev Restricted to Root or the owner. The cap is bounded by - /// `DotnsConstants.WHITELIST_MAX_CLAIMANTS_LIMIT`, which bounds the resolution clear-loop. - /// @custom:reverts MaxClaimantsOutOfRange when `newMax` is zero or above the ceiling. - /// @custom:emits MaxClaimantsSet. - /// @param newMax New per-name claim cap. - function setMaxClaimants(uint16 newMax) external; - - /// @notice Sets the reason byte cap. - /// @dev Restricted to Root or the owner, bounded by - /// `DotnsConstants.WHITELIST_MAX_REASON_LIMIT`. @custom:reverts MaxReasonBytesOutOfRange when - /// `newMax` is zero or above the ceiling. @custom:emits MaxReasonBytesSet. - /// @param newMax New reason byte cap. - function setMaxReasonBytes(uint256 newMax) external; - - /// @notice Sets the cap on labels per `grantNames` call. - /// @dev Restricted to Root or the owner, bounded by - /// `DotnsConstants.WHITELIST_MAX_GRANT_BATCH_LIMIT`. @custom:reverts MaxGrantBatchOutOfRange - /// when `newMax` is zero or above the ceiling. @custom:emits MaxGrantBatchSet. - /// @param newMax New batch cap. - function setMaxGrantBatch(uint16 newMax) external; - - /// @notice Returns the live-claim cap per name. - /// @return cap Current per-name claim cap. - function maxClaimants() external view returns (uint16 cap); - - /// @notice Returns the reason byte cap. - /// @return cap Current reason byte cap. - function maxReasonBytes() external view returns (uint256 cap); - - /// @notice Returns the cap on labels per `grantNames` call. - /// @return cap Current batch cap. - function maxGrantBatch() external view returns (uint16 cap); - - /// @notice Returns the status of `label`. - /// @param label Bare label to look up. - /// @return status Name status; see NameStatus. - function statusOf(string calldata label) external view returns (NameStatus status); - - /// @notice Returns whether `label` is reserved. - /// @param label Bare label to look up. - /// @return reserved True when the name is `Reserved`. - function isReserved(string calldata label) external view returns (bool reserved); - - /// @notice Returns the winner of `label`, or the zero address when not `Claimed`. - /// @param label Bare label to look up. - /// @return winner Winning beneficiary. - function granteeOf(string calldata label) external view returns (address winner); - - /// @notice Returns whether `account` won `label`. - /// @dev The pair check the controllers use to admit a registrant. False for the zero address. - /// @param label Bare label to look up. - /// @param account Address to test against the winner. - /// @return granted True when `account` is the winner. - function isGrantedTo( - string calldata label, - address account - ) - external - view - returns (bool granted); - - /// @notice Returns `user`'s claim on `label`. - /// @param label Bare label to look up. - /// @param user Beneficiary to look up. - /// @return claim The stored claim; a zeroed struct with `None` status when absent. - function claimOf(string calldata label, address user) external view returns (Claim memory claim); - - /// @notice Returns the number of live claims on `label`. - /// @param label Bare label to look up. - /// @return count Live claim count. - function claimantCount(string calldata label) external view returns (uint256 count); - - /// @notice Returns a page of claims on `label` for review. - /// @dev Reads the canonical offset and limit window. - /// @param label Bare label to look up. - /// @param offset Index of the first claim. - /// @param limit Maximum number of claims to return. - /// @return page Claims in the window. - function claims( - string calldata label, - uint256 offset, - uint256 limit - ) - external - view - returns (Claim[] memory page); - - /// @notice Returns the number of names with reserved, claimed or claim-holding state. - /// @return count Active name count. - function nameCount() external view returns (uint256 count); - - /// @notice Returns a page of active names for review. - /// @dev Reads the canonical offset and limit window. Iteration order is not stable. - /// @param offset Index of the first name. - /// @param limit Maximum number of names to return. - /// @return page Names in the window. - function names(uint256 offset, uint256 limit) external view returns (NameView[] memory page); - - /// @notice Returns the request window. - /// @return openAt Timestamp requests start being accepted. - /// @return closeAt Timestamp requests stop being accepted. - function window() external view returns (uint64 openAt, uint64 closeAt); - - /// @notice Returns whether requests are currently accepted. - /// @return open True when the current time is within the window. - function isWindowOpen() external view returns (bool open); -} diff --git a/scripts/deploy/UpgradeLabelStore.s.sol b/scripts/deploy/UpgradeLabelStore.s.sol deleted file mode 100644 index fe4265559..000000000 --- a/scripts/deploy/UpgradeLabelStore.s.sol +++ /dev/null @@ -1,94 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.34; - -import {console} from "forge-std/Script.sol"; -import {Options} from "openzeppelin-foundry-upgrades/Options.sol"; -import {Upgrades} from "openzeppelin-foundry-upgrades/Upgrades.sol"; - -import {BaseDeployer} from "./BaseDeployer.s.sol"; -import { - OwnableUpgradeable -} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; -import {IStoreFactory} from "../../contracts/store/IStoreFactory.sol"; - -/// @title UpgradeLabelStore -/// @notice Upgrades the deployed LabelStore implementation for every beacon-proxy store at once. -/// Resolves the store factory from the on-disk manifest, diffs the new storage layout -/// against the pinned @custom:contract LabelStoreOld snapshot, and rotates the shared -/// beacon only when the diff and every unsafe-pattern check pass. -/// @dev The LabelStore proxies are `BeaconProxy` instances behind one `UpgradeableBeacon` owned by -/// the store factory, so the swap is a single call to -/// @custom:function IStoreFactory.upgradeLabelStoreImplementation rather than a per-proxy -/// upgrade. The factory is the beacon owner, so the upgrade broadcasts from the factory -/// owner and the factory delegates the beacon rotation. -/// @dev PR-scoped. This script, the `LabelStoreOld` snapshot it references, and the paired -/// `test/fork/UpgradeLabelStore.t.sol` are deleted before merge per the upgrade-PR workflow -/// in CONTRIBUTING.md. The storage-layout reference is always supplied, so the layout diff is -/// mandatory: there is no environment switch that turns it off, and the run fails closed if a -/// slot moves, shrinks, or changes type. -/// @custom:security-contact admin@parity.io -contract UpgradeLabelStore is BaseDeployer { - /// @notice Pre-upgrade snapshot the layout diff compares the new implementation against. - /// @dev Source-file route, not a stored build-info directory, so the snapshot is versioned - /// beside the implementation and rebuilt by `forge build`. Deleted before merge. - string internal constant REFERENCE_CONTRACT = "LabelStoreOld.sol:LabelStoreOld"; - - /// @notice Fully-qualified artefact for the current LabelStore implementation. - string internal constant LABEL_STORE_ARTEFACT = "LabelStore.sol:LabelStore"; - - /// @notice Manifest label the store factory is recorded under. - string internal constant STORE_FACTORY_LABEL = "StoreFactory"; - - /// @notice Reads the manifest, resolves the store factory, and upgrades the label beacon as - /// `msg.sender`. - /// @dev `msg.sender` must own the store factory, otherwise the `onlyOwner` gate on - /// @custom:function IStoreFactory.upgradeLabelStoreImplementation reverts. - function run() external { - address owner = msg.sender; - vm.label(owner, "OWNER"); - - initDeployment(networkFolder(), vm.toString(block.chainid)); - - address factory = _readAddress(STORE_FACTORY_LABEL); - _upgradeLabelStore(owner, factory); - - console.log("=== UpgradeLabelStore complete ==="); - } - - /// @notice Deploys the current LabelStore implementation and swaps the shared beacon under - /// `owner`. - /// @dev The fail-closed layout diff runs first through - /// @custom:function Upgrades.validateUpgrade against @custom:contract LabelStoreOld; an - /// incompatible layout aborts the run before anything deploys. No `unsafeSkipAllChecks` - /// or `unsafeAllow` override is set. The new implementation is then deployed and the - /// factory rotates the beacon for every existing and future proxy in one call. No - /// initialiser data is passed: existing proxies keep the state they already hold and the - /// new implementation adds no storage that needs seeding. - /// @param owner Account that owns the store factory and broadcasts the upgrade. - /// @param factory Store factory address resolved from the manifest. - /// @return newImplementation Address of the freshly deployed LabelStore implementation. - function _upgradeLabelStore( - address owner, - address factory - ) - internal - returns (address newImplementation) - { - require( - owner == OwnableUpgradeable(factory).owner(), - "UpgradeLabelStore: broadcaster is not the store factory owner" - ); - - Options memory opts; - opts.referenceContract = REFERENCE_CONTRACT; - - Upgrades.validateUpgrade(LABEL_STORE_ARTEFACT, opts); - - vm.startBroadcast(owner); - newImplementation = Upgrades.deployImplementation(LABEL_STORE_ARTEFACT, opts); - IStoreFactory(factory).upgradeLabelStoreImplementation(newImplementation); - vm.stopBroadcast(); - - console.log(" upgraded LabelStore implementation to", newImplementation); - } -} diff --git a/scripts/deploy/UpgradeNameWhitelist.s.sol b/scripts/deploy/UpgradeNameWhitelist.s.sol deleted file mode 100644 index d98aee683..000000000 --- a/scripts/deploy/UpgradeNameWhitelist.s.sol +++ /dev/null @@ -1,71 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.34; - -import {console} from "forge-std/Script.sol"; -import {Options} from "openzeppelin-foundry-upgrades/Options.sol"; -import {Upgrades} from "openzeppelin-foundry-upgrades/Upgrades.sol"; - -import {BaseDeployer} from "./BaseDeployer.s.sol"; -import { - OwnableUpgradeable -} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; - -/// @title UpgradeNameWhitelist -/// @notice Upgrades the deployed DotnsNameWhitelist proxy to the current implementation. Resolves -/// the proxy from the on-disk manifest, diffs the new storage layout against the pinned -/// @custom:contract DotnsNameWhitelistOld snapshot, and swaps the implementation only when -/// the diff and every unsafe-pattern check pass. -/// @dev PR-scoped. This script, the `DotnsNameWhitelistOld` snapshot it references, and the paired -/// `test/fork/UpgradeNameWhitelist.t.sol` are deleted before merge per the upgrade-PR -/// workflow in CONTRIBUTING.md. The storage-layout reference is always supplied, so the -/// layout diff is mandatory: there is no environment switch that turns it off, and the run -/// fails closed if a slot moves, shrinks, or changes type. -/// @custom:security-contact admin@parity.io -contract UpgradeNameWhitelist is BaseDeployer { - /// @notice Pre-upgrade snapshot the layout diff compares the new implementation against. - /// @dev Source-file route, not a stored build-info directory, so the snapshot is versioned - /// beside the implementation and rebuilt by `forge build`. Deleted before merge. - string internal constant REFERENCE_CONTRACT = "DotnsNameWhitelistOld.sol:DotnsNameWhitelistOld"; - - /// @notice Manifest label the whitelist proxy is recorded under. - string internal constant WHITELIST_LABEL = "DotnsNameWhitelist"; - - /// @notice Reads the manifest, resolves the whitelist proxy, and upgrades it as `msg.sender`. - /// @dev `msg.sender` must own the proxy, otherwise the `_authorizeUpgrade` owner gate reverts. - function run() external { - address owner = msg.sender; - vm.label(owner, "OWNER"); - - initDeployment(networkFolder(), vm.toString(block.chainid)); - - address proxy = _readAddress(WHITELIST_LABEL); - _upgradeNameWhitelist(owner, proxy); - - console.log("=== UpgradeNameWhitelist complete ==="); - } - - /// @notice Upgrades `proxy` to the current `DotnsNameWhitelist` implementation under `owner`. - /// @dev The layout diff runs inside `Upgrades.upgradeProxy` before the implementation swap. No - /// `unsafeSkipAllChecks` or `unsafeAllow` override is set, so an incompatible layout - /// aborts the run rather than corrupting state. An empty upgrade call is passed because - /// the new implementation adds no storage that needs seeding: the governance surface - /// narrows to substrate Root and the tunables, active names, and claims already stored on - /// the proxy keep their slots. - /// @param owner Account that owns the proxy and broadcasts the upgrade. - /// @param proxy Whitelist proxy address resolved from the manifest. - function _upgradeNameWhitelist(address owner, address proxy) internal { - require( - owner == OwnableUpgradeable(proxy).owner(), - "UpgradeNameWhitelist: broadcaster is not the proxy owner" - ); - - Options memory opts; - opts.referenceContract = REFERENCE_CONTRACT; - - vm.startBroadcast(owner); - Upgrades.upgradeProxy(proxy, "DotnsNameWhitelist.sol:DotnsNameWhitelist", "", opts); - vm.stopBroadcast(); - - console.log(" upgraded DotnsNameWhitelist proxy", proxy); - } -} diff --git a/scripts/deploy/UpgradePopController.s.sol b/scripts/deploy/UpgradePopController.s.sol deleted file mode 100644 index fd35cc672..000000000 --- a/scripts/deploy/UpgradePopController.s.sol +++ /dev/null @@ -1,71 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.34; - -import {console} from "forge-std/Script.sol"; -import {Options} from "openzeppelin-foundry-upgrades/Options.sol"; -import {Upgrades} from "openzeppelin-foundry-upgrades/Upgrades.sol"; - -import {BaseDeployer} from "./BaseDeployer.s.sol"; -import { - OwnableUpgradeable -} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; - -/// @title UpgradePopController -/// @notice Upgrades the deployed DotnsPopController proxy to the current implementation. Resolves -/// the proxy from the on-disk manifest, diffs the new storage layout against the pinned -/// @custom:contract DotnsPopControllerOld snapshot, and swaps the implementation only when -/// the diff and every unsafe-pattern check pass. -/// @dev PR-scoped. This script, the `DotnsPopControllerOld` snapshot it references, and the paired -/// `test/fork/UpgradePopController.t.sol` are deleted before merge per the upgrade-PR workflow -/// in CONTRIBUTING.md. The storage-layout reference is always supplied, so the layout diff is -/// mandatory: there is no environment switch that turns it off, and the run fails closed if a -/// slot moves, shrinks, or changes type. -/// @custom:security-contact admin@parity.io -contract UpgradePopController is BaseDeployer { - /// @notice Pre-upgrade snapshot the layout diff compares the new implementation against. - /// @dev Source-file route, not a stored build-info directory, so the snapshot is versioned - /// beside the implementation and rebuilt by `forge build`. Deleted before merge. - string internal constant REFERENCE_CONTRACT = "DotnsPopControllerOld.sol:DotnsPopControllerOld"; - - /// @notice Manifest label the PoP controller proxy is recorded under. - string internal constant POP_CONTROLLER_LABEL = "DotnsPopController"; - - /// @notice Reads the manifest, resolves the PoP controller proxy, and upgrades it as - /// `msg.sender`. - /// @dev `msg.sender` must own the proxy, otherwise the `_authorizeUpgrade` owner gate reverts. - function run() external { - address owner = msg.sender; - vm.label(owner, "OWNER"); - - initDeployment(networkFolder(), vm.toString(block.chainid)); - - address proxy = _readAddress(POP_CONTROLLER_LABEL); - _upgradePopController(owner, proxy); - - console.log("=== UpgradePopController complete ==="); - } - - /// @notice Upgrades `proxy` to the current `DotnsPopController` implementation under `owner`. - /// @dev The layout diff runs inside `Upgrades.upgradeProxy` before the implementation swap. No - /// `unsafeSkipAllChecks` or `unsafeAllow` override is set, so an incompatible layout - /// aborts the run rather than corrupting state. An empty upgrade call is passed because the new - /// implementation seeds no storage: `_popIssued` defaults to false for every label, which - /// is the correct provenance for names issued before the upgrade. - /// @param owner Account that owns the proxy and broadcasts the upgrade. - /// @param proxy PoP controller proxy address resolved from the manifest. - function _upgradePopController(address owner, address proxy) internal { - require( - owner == OwnableUpgradeable(proxy).owner(), - "UpgradePopController: broadcaster is not the proxy owner" - ); - - Options memory opts; - opts.referenceContract = REFERENCE_CONTRACT; - - vm.startBroadcast(owner); - Upgrades.upgradeProxy(proxy, "DotnsPopController.sol:DotnsPopController", "", opts); - vm.stopBroadcast(); - - console.log(" upgraded DotnsPopController proxy", proxy); - } -} diff --git a/scripts/deploy/UpgradePopRules.s.sol b/scripts/deploy/UpgradePopRules.s.sol deleted file mode 100644 index da5f4a7e7..000000000 --- a/scripts/deploy/UpgradePopRules.s.sol +++ /dev/null @@ -1,70 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.34; - -import {console} from "forge-std/Script.sol"; -import {Options} from "openzeppelin-foundry-upgrades/Options.sol"; -import {Upgrades} from "openzeppelin-foundry-upgrades/Upgrades.sol"; - -import {BaseDeployer} from "./BaseDeployer.s.sol"; -import { - OwnableUpgradeable -} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; - -/// @title UpgradePopRules -/// @notice Upgrades the deployed PopRules proxy to the current implementation. Resolves the proxy -/// from the on-disk manifest, diffs the new storage layout against the pinned -/// @custom:contract PopRulesOld snapshot, and swaps the implementation only when the diff -/// and every unsafe-pattern check pass. -/// @dev PR-scoped. This script, the `PopRulesOld` snapshot it references, and the paired -/// `test/fork/UpgradePopRules.t.sol` are deleted before merge per the upgrade-PR workflow in -/// CONTRIBUTING.md. The storage-layout reference is always supplied, so the layout diff is -/// mandatory: there is no environment switch that turns it off, and the run fails closed if a -/// slot moves, shrinks, or changes type. -/// @custom:security-contact admin@parity.io -contract UpgradePopRules is BaseDeployer { - /// @notice Pre-upgrade snapshot the layout diff compares the new implementation against. - /// @dev Source-file route, not a stored build-info directory, so the snapshot is versioned - /// beside the implementation and rebuilt by `forge build`. Deleted before merge. - string internal constant REFERENCE_CONTRACT = "PopRulesOld.sol:PopRulesOld"; - - /// @notice Manifest label the PopRules proxy is recorded under. - string internal constant POP_RULES_LABEL = "PopRules"; - - /// @notice Reads the manifest, resolves the PopRules proxy, and upgrades it as `msg.sender`. - /// @dev `msg.sender` must own the proxy, otherwise the `_authorizeUpgrade` owner gate reverts. - function run() external { - address owner = msg.sender; - vm.label(owner, "OWNER"); - - initDeployment(networkFolder(), vm.toString(block.chainid)); - - address proxy = _readAddress(POP_RULES_LABEL); - _upgradePopRules(owner, proxy); - - console.log("=== UpgradePopRules complete ==="); - } - - /// @notice Upgrades `proxy` to the current `PopRules` implementation under `owner`. - /// @dev The layout diff runs inside `Upgrades.upgradeProxy` before the implementation swap. No - /// `unsafeSkipAllChecks` or `unsafeAllow` override is set, so an incompatible layout - /// aborts the run rather than corrupting state. An empty upgrade call is passed because the new - /// implementation adds no storage that needs seeding: the classification and pricing - /// change is logic-only, and `shortNamesEnabled` keeps whatever value the live proxy holds. - /// @param owner Account that owns the proxy and broadcasts the upgrade. - /// @param proxy PopRules proxy address resolved from the manifest. - function _upgradePopRules(address owner, address proxy) internal { - require( - owner == OwnableUpgradeable(proxy).owner(), - "UpgradePopRules: broadcaster is not the proxy owner" - ); - - Options memory opts; - opts.referenceContract = REFERENCE_CONTRACT; - - vm.startBroadcast(owner); - Upgrades.upgradeProxy(proxy, "PopRules.sol:PopRules", "", opts); - vm.stopBroadcast(); - - console.log(" upgraded PopRules proxy", proxy); - } -} diff --git a/scripts/deploy/UpgradeRegistrar.s.sol b/scripts/deploy/UpgradeRegistrar.s.sol deleted file mode 100644 index f7e14c5b1..000000000 --- a/scripts/deploy/UpgradeRegistrar.s.sol +++ /dev/null @@ -1,70 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.34; - -import {console} from "forge-std/Script.sol"; -import {Options} from "openzeppelin-foundry-upgrades/Options.sol"; -import {Upgrades} from "openzeppelin-foundry-upgrades/Upgrades.sol"; - -import {BaseDeployer} from "./BaseDeployer.s.sol"; -import { - OwnableUpgradeable -} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; - -/// @title UpgradeRegistrar -/// @notice Upgrades the deployed DotnsRegistrar proxy to the current implementation. Resolves the -/// proxy from the on-disk manifest, diffs the new storage layout against the pinned -/// @custom:contract DotnsRegistrarOld snapshot, and swaps the implementation only when the -/// diff and every unsafe-pattern check pass. -/// @dev PR-scoped. This script, the `DotnsRegistrarOld` snapshot it references, and the paired -/// `test/fork/UpgradeRegistrar.t.sol` are deleted before merge per the upgrade-PR workflow in -/// CONTRIBUTING.md. The storage-layout reference is always supplied, so the layout diff is -/// mandatory: there is no environment switch that turns it off, and the run fails closed if a -/// slot moves, shrinks, or changes type. -/// @custom:security-contact admin@parity.io -contract UpgradeRegistrar is BaseDeployer { - /// @notice Pre-upgrade snapshot the layout diff compares the new implementation against. - /// @dev Source-file route, not a stored build-info directory, so the snapshot is versioned - /// beside the implementation and rebuilt by `forge build`. Deleted before merge. - string internal constant REFERENCE_CONTRACT = "DotnsRegistrarOld.sol:DotnsRegistrarOld"; - - /// @notice Manifest label the registrar proxy is recorded under. - string internal constant REGISTRAR_LABEL = "DotnsRegistrar"; - - /// @notice Reads the manifest, resolves the registrar proxy, and upgrades it as `msg.sender`. - /// @dev `msg.sender` must own the proxy, otherwise the `_authorizeUpgrade` owner gate reverts. - function run() external { - address owner = msg.sender; - vm.label(owner, "OWNER"); - - initDeployment(networkFolder(), vm.toString(block.chainid)); - - address proxy = _readAddress(REGISTRAR_LABEL); - _upgradeRegistrar(owner, proxy); - - console.log("=== UpgradeRegistrar complete ==="); - } - - /// @notice Upgrades `proxy` to the current `DotnsRegistrar` implementation under `owner`. - /// @dev The layout diff runs inside `Upgrades.upgradeProxy` before the implementation swap. No - /// `unsafeSkipAllChecks` or `unsafeAllow` override is set, so an incompatible layout - /// aborts the run rather than corrupting state. An empty upgrade call is passed because the new - /// implementation adds no storage that needs seeding: `_soulbound` defaults to false for - /// every existing token, which is the correct state for names minted before the upgrade. - /// @param owner Account that owns the proxy and broadcasts the upgrade. - /// @param proxy Registrar proxy address resolved from the manifest. - function _upgradeRegistrar(address owner, address proxy) internal { - require( - owner == OwnableUpgradeable(proxy).owner(), - "UpgradeRegistrar: broadcaster is not the proxy owner" - ); - - Options memory opts; - opts.referenceContract = REFERENCE_CONTRACT; - - vm.startBroadcast(owner); - Upgrades.upgradeProxy(proxy, "DotnsRegistrar.sol:DotnsRegistrar", "", opts); - vm.stopBroadcast(); - - console.log(" upgraded DotnsRegistrar proxy", proxy); - } -} diff --git a/scripts/deploy/UpgradeRegistrarController.s.sol b/scripts/deploy/UpgradeRegistrarController.s.sol deleted file mode 100644 index 65e777008..000000000 --- a/scripts/deploy/UpgradeRegistrarController.s.sol +++ /dev/null @@ -1,73 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.34; - -import {console} from "forge-std/Script.sol"; -import {Options} from "openzeppelin-foundry-upgrades/Options.sol"; -import {Upgrades} from "openzeppelin-foundry-upgrades/Upgrades.sol"; - -import {BaseDeployer} from "./BaseDeployer.s.sol"; -import { - OwnableUpgradeable -} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; - -/// @title UpgradeRegistrarController -/// @notice Upgrades the deployed DotnsRegistrarController proxy to the current implementation. -/// @dev Resolves the proxy from the on-disk manifest, diffs the new storage layout against the -/// pinned @custom:contract DotnsRegistrarControllerOld snapshot, and swaps the implementation -/// only when the diff and every unsafe-pattern check pass. PR-scoped: this script, the -/// `DotnsRegistrarControllerOld` snapshot it references, and the paired -/// `test/fork/UpgradeRegistrarController.t.sol` are deleted before merge per the upgrade-PR -/// workflow in CONTRIBUTING.md. The storage-layout reference is always supplied, so the -/// layout diff is mandatory: there is no environment switch that turns it off, and the run -/// fails closed if a slot moves, shrinks, or changes type. -/// @custom:security-contact admin@parity.io -contract UpgradeRegistrarController is BaseDeployer { - /// @notice Pre-upgrade snapshot the layout diff compares the new implementation against. - /// @dev Source-file route, not a stored build-info directory, so the snapshot is versioned - /// beside the implementation and rebuilt by `forge build`. Deleted before merge. - string internal constant REFERENCE_CONTRACT = - "DotnsRegistrarControllerOld.sol:DotnsRegistrarControllerOld"; - - /// @notice Manifest label the registrar controller proxy is recorded under. - string internal constant CONTROLLER_LABEL = "DotnsRegistrarController"; - - /// @notice Reads the manifest, resolves the controller proxy, and upgrades it as `msg.sender`. - /// @dev `msg.sender` must own the proxy, otherwise the `_authorizeUpgrade` owner gate reverts. - function run() external { - address owner = msg.sender; - vm.label(owner, "OWNER"); - - initDeployment(networkFolder(), vm.toString(block.chainid)); - - address proxy = _readAddress(CONTROLLER_LABEL); - _upgradeRegistrarController(owner, proxy); - - console.log("=== UpgradeRegistrarController complete ==="); - } - - /// @notice Upgrades `proxy` to the current `DotnsRegistrarController` implementation under - /// `owner`. - /// @dev The layout diff runs inside `Upgrades.upgradeProxy` before the implementation swap. No - /// `unsafeSkipAllChecks` or `unsafeAllow` override is set, so an incompatible layout - /// aborts the run rather than corrupting state. An empty upgrade call is passed because the new - /// implementation adds no storage that needs seeding. - /// @param owner Account that owns the proxy and broadcasts the upgrade. - /// @param proxy Registrar controller proxy address resolved from the manifest. - function _upgradeRegistrarController(address owner, address proxy) internal { - require( - owner == OwnableUpgradeable(proxy).owner(), - "UpgradeRegistrarController: broadcaster is not the proxy owner" - ); - - Options memory opts; - opts.referenceContract = REFERENCE_CONTRACT; - - vm.startBroadcast(owner); - Upgrades.upgradeProxy( - proxy, "DotnsRegistrarController.sol:DotnsRegistrarController", "", opts - ); - vm.stopBroadcast(); - - console.log(" upgraded DotnsRegistrarController proxy", proxy); - } -} diff --git a/test/fork/UpgradeLabelStore.t.sol b/test/fork/UpgradeLabelStore.t.sol deleted file mode 100644 index 682ba337c..000000000 --- a/test/fork/UpgradeLabelStore.t.sol +++ /dev/null @@ -1,159 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.34; - -import {Test} from "forge-std/Test.sol"; -import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; -import {IBeacon} from "@openzeppelin/contracts/proxy/beacon/IBeacon.sol"; - -import {ILabelStore} from "../../contracts/store/ILabelStore.sol"; -import {IStoreFactory} from "../../contracts/store/IStoreFactory.sol"; -import {IDotnsProtocolRegistry} from "../../contracts/registry/IDotnsProtocolRegistry.sol"; -import {DotnsConstants} from "../../contracts/utils/DotnsConstants.sol"; -import {UpgradeLabelStore} from "../../scripts/deploy/UpgradeLabelStore.s.sol"; - -/// @title UpgradeLabelStoreHarness -/// @notice Exposes the upgrade script's internal beacon-swap path so the fork test drives the exact -/// code the production run executes, including the fail-closed storage-layout diff. -/// @dev Mirrors the deploy-harness pattern: forward to the script internal rather than -/// re-implement the upgrade, so the test tracks the production path one-to-one. -contract UpgradeLabelStoreHarness is UpgradeLabelStore { - /// @notice Upgrades the label beacon owned by `factory` under `owner` through the script's - /// `_upgradeLabelStore`. - /// @param owner Account that owns the store factory and broadcasts the upgrade. - /// @param factory Store factory that owns the label beacon. - /// @return newImplementation Address of the freshly deployed LabelStore implementation. - function upgradeLabelStore( - address owner, - address factory - ) - external - returns (address newImplementation) - { - newImplementation = _upgradeLabelStore(owner, factory); - } -} - -/// @title UpgradeLabelStoreForkTest -/// @notice Pairs one-to-one with `scripts/deploy/UpgradeLabelStore.s.sol`. Forks the live Paseo -/// Asset Hub through the ETH-RPC adapter, rotates the shared LabelStore beacon with the -/// script, and proves an existing store proxy keeps its stored labels and that the new -/// write-authorisation gate is live afterwards. -/// @dev PR-scoped: deleted before merge with the upgrade script and the `LabelStoreOld` snapshot. -/// Requires the local adapter on `paseo_local`; between upgrade PRs `test/fork/` is empty, so -/// the suite is skipped by default with `--no-match-path 'test/fork/**'`. -/// @custom:security-contact admin@parity.io -contract UpgradeLabelStoreForkTest is Test { - /// @notice Manifest recording the live deployment addresses this fork resolves against. - string internal constant MANIFEST_PATH = "deployments/paseo-assethub/420420417.json"; - - /// @notice A representative label persisted before the upgrade and read back after it. - string internal constant SEED_LABEL = "alice.paseo"; - - /// @notice Drives the script's beacon-swap path against the live factory. - UpgradeLabelStoreHarness internal upgrader; - - /// @notice The deployed store factory that owns the label beacon. - IStoreFactory internal storeFactory; - - /// @notice The deployed protocol registry the stores authorise writers through. - IDotnsProtocolRegistry internal protocolRegistry; - - /// @notice Factory owner, impersonated to authorise the beacon swap. - address internal factoryOwner; - - /// @notice The registrar, a store writer impersonated to seed and write labels. - address internal registrar; - - /// @notice The name escrow: registered in the protocol registry but not a store writer, used - /// to prove the new authorisation gate rejects a merely-registered address. - address internal nameEscrow; - - /// @notice Forks Paseo and resolves the live factory, registry, and writer addresses. - function setUp() public { - vm.createSelectFork(vm.rpcUrl("paseo_local")); - - string memory manifest = vm.readFile(MANIFEST_PATH); - storeFactory = IStoreFactory(vm.parseJsonAddress(manifest, ".StoreFactory")); - protocolRegistry = - IDotnsProtocolRegistry(vm.parseJsonAddress(manifest, ".DotnsProtocolRegistry")); - - factoryOwner = Ownable(address(storeFactory)).owner(); - registrar = protocolRegistry.get(DotnsConstants.REGISTRAR); - nameEscrow = protocolRegistry.get(DotnsConstants.NAME_ESCROW); - - upgrader = new UpgradeLabelStoreHarness(); - } - - /// @notice The beacon swap keeps an existing store's stored label readable and leaves the - /// registrar-driven write path working on the new implementation. - function test_upgrade_preservesStoredLabelAndKeepsWritesWorking() public { - address user = makeAddr("labelStoreUser.writes"); - address store = _deployStoreFor(user); - - bytes32 seedHash = keccak256("dotns.fork.labelstore.seed"); - vm.prank(registrar); - ILabelStore(store).storeLabel(seedHash, SEED_LABEL); - assertEq(ILabelStore(store).getLabel(seedHash), SEED_LABEL, "pre-upgrade: label stored"); - - address beacon = storeFactory.labelStoreBeacon(); - address implBefore = IBeacon(beacon).implementation(); - - address newImplementation = upgrader.upgradeLabelStore(factoryOwner, address(storeFactory)); - - // The beacon now serves the freshly deployed implementation to every proxy. - assertEq( - IBeacon(beacon).implementation(), - newImplementation, - "post-upgrade: beacon serves the new implementation" - ); - assertTrue(newImplementation != implBefore, "post-upgrade: implementation changed"); - - // The proxy is the same address and still resolves through the factory mapping. - assertEq(storeFactory.getLabelStore(user), store, "post-upgrade: proxy address unchanged"); - assertEq(ILabelStore(store).owner(), user, "post-upgrade: store owner preserved"); - assertEq( - ILabelStore(store).getLabel(seedHash), - SEED_LABEL, - "post-upgrade: stored label survives the beacon swap" - ); - - // P0: the registrar can still write a fresh label on the upgraded implementation. - bytes32 postHash = keccak256("dotns.fork.labelstore.post"); - vm.prank(registrar); - ILabelStore(store).storeLabel(postHash, "bob.paseo"); - assertEq( - ILabelStore(store).getLabel(postHash), "bob.paseo", "post-upgrade: registrar writes" - ); - } - - /// @notice After the upgrade, a merely-registered address that is not a store writer is - /// rejected, proving the `StoreAuth.isStoreWriter` gate is the live authorisation. - function test_upgrade_activatesStoreAuthGate() public { - address user = makeAddr("labelStoreUser.gate"); - address store = _deployStoreFor(user); - - upgrader.upgradeLabelStore(factoryOwner, address(storeFactory)); - - // The name escrow is registered in the protocol registry yet is neither the registrar, a - // controller, nor the registry, so the live gate must reject its write. - bytes32 gateHash = keccak256("dotns.fork.labelstore.gate"); - vm.prank(nameEscrow); - vm.expectRevert(abi.encodeWithSelector(ILabelStore.NotAuthorised.selector, nameEscrow)); - ILabelStore(store).storeLabel(gateHash, "mallory.paseo"); - } - - /// @notice Resolves `user`'s existing LabelStore proxy, deploying one through the registrar - /// when the fork has none yet. - /// @dev The proxy is a `BeaconProxy` created before the upgrade, so it is the existing store - /// the beacon swap must keep intact. `deployLabelStoreFor` is gated to the owner or a - /// store writer, so the registrar drives it. - /// @param user The user the store binds to. - /// @return store The resolved or freshly deployed store proxy. - function _deployStoreFor(address user) internal returns (address store) { - store = storeFactory.getLabelStore(user); - if (store == address(0)) { - vm.prank(registrar); - store = storeFactory.deployLabelStoreFor(user); - } - } -} diff --git a/test/fork/UpgradeNameWhitelist.t.sol b/test/fork/UpgradeNameWhitelist.t.sol deleted file mode 100644 index d29d3c63e..000000000 --- a/test/fork/UpgradeNameWhitelist.t.sol +++ /dev/null @@ -1,176 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.34; - -import {Test} from "forge-std/Test.sol"; -import { - OwnableUpgradeable -} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; - -import {DotnsNameWhitelist} from "../../contracts/whitelist/DotnsNameWhitelist.sol"; -import {IDotnsNameWhitelist} from "../../contracts/whitelist/IDotnsNameWhitelist.sol"; -import {IDotnsProtocolRegistry} from "../../contracts/registry/IDotnsProtocolRegistry.sol"; -import {DotnsConstants} from "../../contracts/utils/DotnsConstants.sol"; -import {ISystem} from "../../contracts/external/revive/ISystem.sol"; -import {UpgradeNameWhitelist} from "../../scripts/deploy/UpgradeNameWhitelist.s.sol"; - -/// @title UpgradeNameWhitelistHarness -/// @notice Exposes the upgrade script's internal upgrade path so the fork test drives the exact -/// code the production run executes, including the fail-closed storage-layout diff. -/// @dev Mirrors the `UpgradeRegistrarHarness` pattern: forward to the script internal rather than -/// re-implement the upgrade, so the test tracks the production path one-to-one. -contract UpgradeNameWhitelistHarness is UpgradeNameWhitelist { - /// @notice Upgrades `proxy` under `owner` through the script's `_upgradeNameWhitelist`. - function upgradeNameWhitelist(address owner, address proxy) external { - _upgradeNameWhitelist(owner, proxy); - } -} - -/// @title UpgradeNameWhitelistForkTest -/// @notice Pairs one-to-one with `scripts/deploy/UpgradeNameWhitelist.s.sol`. Forks the live Paseo -/// Asset Hub through the ETH-RPC adapter, upgrades the deployed whitelist proxy with the -/// script, and re-runs the whitelist's P0 paths against real on-chain state to prove the -/// swap preserves stored names and keeps governance grants, reservations, and the -/// controller consume hook working. -/// @dev PR-scoped: deleted before merge with the upgrade script and the `DotnsNameWhitelistOld` -/// snapshot. Requires the local adapter on `paseo_local`. The whole admin surface is -/// substrate Root, so each governance action mocks `ISystem.originIsRoot` to true through the -/// System precompile the whitelist reads. -/// @custom:security-contact admin@parity.io -contract UpgradeNameWhitelistForkTest is Test { - /// @notice Manifest recording the live deployment addresses this fork resolves against. - string internal constant MANIFEST_PATH = "deployments/paseo-assethub/420420417.json"; - - /// @notice Drives the script's upgrade path against the live proxy. - UpgradeNameWhitelistHarness internal upgrader; - - /// @notice The deployed whitelist proxy under upgrade. - DotnsNameWhitelist internal whitelist; - - /// @notice The deployed protocol registry the whitelist resolves the TLD and controllers - /// through. - IDotnsProtocolRegistry internal protocolRegistry; - - /// @notice Proxy owner, impersonated to authorise the upgrade. - address internal whitelistOwner; - - /// @notice The deployed commit-reveal controller, the caller the consume hook admits. - address internal controller; - - /// @notice Beneficiary accounts for the grant and consume paths. - address internal alice; - address internal bob; - - /// @notice Forks Paseo, resolves the live addresses, and readies the upgrade harness. - function setUp() public { - vm.createSelectFork(vm.rpcUrl("paseo_local")); - - string memory manifest = vm.readFile(MANIFEST_PATH); - whitelist = DotnsNameWhitelist(vm.parseJsonAddress(manifest, ".DotnsNameWhitelist")); - protocolRegistry = - IDotnsProtocolRegistry(vm.parseJsonAddress(manifest, ".DotnsProtocolRegistry")); - whitelistOwner = OwnableUpgradeable(address(whitelist)).owner(); - controller = protocolRegistry.get(DotnsConstants.CONTROLLER); - - upgrader = new UpgradeNameWhitelistHarness(); - - alice = makeAddr("alice"); - bob = makeAddr("bob"); - } - - /// @notice The upgrade preserves both a name reserved and a governance grant seeded before the - /// swap, and still binds a fresh grant to its winner on the upgraded implementation. - function test_upgrade_preservesStateAndKeepsGovernanceP0Working() public { - // Seed a reservation and a governance grant on the pre-upgrade implementation under a Root - // origin, so both a reservation slot and a name record cross the swap. - _mockRoot(true); - whitelist.setReserved("forkseedname", true); - whitelist.grantName("forkseedgrant", alice); - _clearRoot(); - assertTrue(whitelist.isReserved("forkseedname"), "pre-upgrade: seed name is reserved"); - assertEq( - whitelist.granteeOf("forkseedgrant"), alice, "pre-upgrade: seed grant binds the winner" - ); - - address proxy = address(whitelist); - address registryBefore = address(whitelist.protocolRegistry()); - - upgrader.upgradeNameWhitelist(whitelistOwner, proxy); - - assertEq(address(whitelist), proxy, "upgrade keeps the same proxy address"); - assertTrue(whitelist.isReserved("forkseedname"), "post-upgrade: reservation preserved"); - // The grant seeded on the old implementation still resolves to its winner on the new one, - // proving the name record survived the swap rather than the new logic recomputing it. - assertEq( - whitelist.granteeOf("forkseedgrant"), - alice, - "post-upgrade: seed grant preserved" - ); - assertTrue( - whitelist.isGrantedTo("forkseedgrant", alice), - "post-upgrade: seed winner is still granted the name" - ); - assertEq( - address(whitelist.protocolRegistry()), - registryBefore, - "post-upgrade: protocol registry pointer preserved" - ); - - // P0: a fresh governance grant still binds a name to its winner on the upgraded - // implementation. - _mockRoot(true); - whitelist.grantName("forkgrantname", bob); - _clearRoot(); - assertEq( - whitelist.granteeOf("forkgrantname"), bob, "post-upgrade: fresh grant binds the winner" - ); - assertTrue( - whitelist.isGrantedTo("forkgrantname", bob), - "post-upgrade: fresh winner is granted the name" - ); - } - - /// @notice After the upgrade the admin surface is substrate Root only, so a non-Root governance - /// call reverts with the fail-closed gate, and the controller consume hook still clears - /// a winner. - function test_upgrade_governanceIsRootGatedAndConsumeStillClears() public { - upgrader.upgradeNameWhitelist(whitelistOwner, address(whitelist)); - - // New surface: a governance action from a non-Root origin fails closed with the Root gate. - // The owner holds no allocation authority, only upgrade authority. - _mockRoot(false); - vm.prank(whitelistOwner); - vm.expectRevert(IDotnsNameWhitelist.NotGovernance.selector); - whitelist.setReserved("forkgatedname", true); - _clearRoot(); - - // P0: a granted name is still cleared by the registrar controller through consume. - _mockRoot(true); - whitelist.grantName("forkconsumename", bob); - _clearRoot(); - assertEq(whitelist.granteeOf("forkconsumename"), bob, "grant binds the winner"); - - vm.prank(controller); - whitelist.consume("forkconsumename", bob); - assertEq( - uint256(whitelist.statusOf("forkconsumename")), - uint256(IDotnsNameWhitelist.NameStatus.Open), - "consume resets the name to Open" - ); - assertEq(whitelist.granteeOf("forkconsumename"), address(0), "consume clears the winner"); - } - - /// @notice Mocks revive's System precompile so `originIsRoot` returns true, driving the - /// whitelist's Root-only governance gate. - function _mockRoot(bool root) internal { - vm.mockCall( - DotnsConstants.REVIVE_SYSTEM, - abi.encodeWithSelector(ISystem.originIsRoot.selector), - abi.encode(root) - ); - } - - /// @notice Clears the `originIsRoot` mock so later calls read the real precompile result. - function _clearRoot() internal { - vm.clearMockedCalls(); - } -} diff --git a/test/fork/UpgradePopController.t.sol b/test/fork/UpgradePopController.t.sol deleted file mode 100644 index e9d1b1293..000000000 --- a/test/fork/UpgradePopController.t.sol +++ /dev/null @@ -1,216 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.34; - -import {Test} from "forge-std/Test.sol"; -import { - OwnableUpgradeable -} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; - -import {DotnsPopController} from "../../contracts/registrars/DotnsPopController.sol"; -import {IDotnsPopController} from "../../contracts/registrars/IDotnsPopController.sol"; -import {IDotnsRegistrar} from "../../contracts/registrars/IDotnsRegistrar.sol"; -import {IDotnsController} from "../../contracts/registrars/IDotnsController.sol"; -import {IDotnsProtocolRegistry} from "../../contracts/registry/IDotnsProtocolRegistry.sol"; -import {ISystem} from "../../contracts/external/revive/ISystem.sol"; -import {DotnsConstants} from "../../contracts/utils/DotnsConstants.sol"; -import {UpgradePopController} from "../../scripts/deploy/UpgradePopController.s.sol"; - -/// @title UpgradePopControllerHarness -/// @notice Exposes the upgrade script's internal upgrade path so the fork test drives the exact -/// code the production run executes, including the fail-closed storage-layout diff. -/// @dev Mirrors the `DeterministicDeploymentHarness` pattern: forward to the script internal -/// rather than re-implement the upgrade, so the test tracks the production path one-to-one. -contract UpgradePopControllerHarness is UpgradePopController { - /// @notice Upgrades `proxy` under `owner` through the script's `_upgradePopController`. - function upgradePopController(address owner, address proxy) external { - _upgradePopController(owner, proxy); - } -} - -/// @title UpgradePopControllerForkTest -/// @notice Pairs one-to-one with `scripts/deploy/UpgradePopController.s.sol`. Forks the live Paseo -/// Asset Hub through the ETH-RPC adapter, upgrades the deployed PoP controller proxy with -/// the script, and re-runs the controller's reservation path against real on-chain state to -/// prove the swap preserves ownership and queue state and keeps issuance working. -/// @dev PR-scoped: deleted before merge with the upgrade script and the `DotnsPopControllerOld` -/// snapshot. Requires the local adapter on `paseo_local`; between upgrade PRs `test/fork/` is -/// empty, so the suite is skipped by default with `--no-match-path 'test/fork/**'`. -/// -/// The live siblings are not upgraded here, so the reservation path is exercised with -/// base-name (letters-only) labels, which the deployed `PopRules` classifies without change. -/// The dotted lite-person flow depends on a matching `PopRules` upgrade and is therefore out -/// of scope for a controller-only fork run. -/// @custom:security-contact admin@parity.io -contract UpgradePopControllerForkTest is Test { - /// @notice Manifest recording the live deployment addresses this fork resolves against. - string internal constant MANIFEST_PATH = "deployments/paseo-assethub/420420417.json"; - - /// @notice Registry key the PoP gateway address is recorded under on the deployed controller. - /// @dev The pre-upgrade implementation gates its entrypoints on this address; the call is made - /// as the gateway to seed state through the real code path. - bytes32 internal constant POP_GATEWAY = bytes32("popGateway"); - - /// @notice Base label reserved to prove queue state survives the swap and stays writable. - /// @dev Lowercase letters only and long enough to classify outside the governance-reserved - /// tier, so the reservation path accepts it as a base name. - string internal constant BASE_LABEL = "zqxwvutsrq"; - - /// @notice A never-issued label, used to prove the new `isPopIssued` surface answers false. - string internal constant UNISSUED_LABEL = "neverissued"; - - /// @notice A base label registered through the upgraded controller to prove `isPopIssued` - /// answers true once a label is genuinely issued. - /// @dev Letters only and long enough to classify outside the governance-reserved tier, so the - /// deployed `PopRules` accepts it as a base name without change. The base path is used - /// rather than the dotted lite path because the live siblings are not upgraded here: the - /// live `PopRules` still rejects the lite separator, so a dotted lite mint reverts before - /// `_popIssued` is written. A base registration exercises the same issuance write through - /// the label form the live classifier already admits. - string internal constant ISSUED_BASE_LABEL = "qzwxrvtsplk"; - - /// @notice Drives the script's upgrade path against the live proxy. - UpgradePopControllerHarness internal upgrader; - - /// @notice The deployed PoP controller proxy under upgrade. - DotnsPopController internal popController; - - /// @notice The deployed protocol registry the controller resolves siblings through. - IDotnsProtocolRegistry internal protocolRegistry; - - /// @notice The deployed registrar the controller reserves and mints against. - IDotnsRegistrar internal registrar; - - /// @notice PoP controller proxy owner, impersonated to authorise the upgrade. - address internal popControllerOwner; - - /// @notice Registrar owner, impersonated to authorise the controller on the registrar. - address internal registrarOwner; - - /// @notice The configured PoP gateway, impersonated to seed state on the pre-upgrade code path. - address internal gateway; - - /// @notice Beneficiary accounts for the reservation paths. - address internal alice; - address internal bob; - - /// @notice Forks Paseo, resolves the live addresses, authorises the controller, and mocks Root. - function setUp() public { - vm.createSelectFork(vm.rpcUrl("paseo_local")); - - string memory manifest = vm.readFile(MANIFEST_PATH); - popController = DotnsPopController(vm.parseJsonAddress(manifest, ".DotnsPopController")); - protocolRegistry = - IDotnsProtocolRegistry(vm.parseJsonAddress(manifest, ".DotnsProtocolRegistry")); - registrar = IDotnsRegistrar(vm.parseJsonAddress(manifest, ".DotnsRegistrar")); - popControllerOwner = OwnableUpgradeable(address(popController)).owner(); - registrarOwner = OwnableUpgradeable(address(registrar)).owner(); - gateway = protocolRegistry.get(POP_GATEWAY); - - upgrader = new UpgradePopControllerHarness(); - - alice = makeAddr("alice"); - bob = makeAddr("bob"); - - // Reserving on `PopRules` gates on the caller being a registrar-authorised controller. - // Re-asserting the live PoP controller is a no-op when it is already registered and keeps - // the test independent of the exact wiring state of the fork. - vm.prank(registrarOwner); - registrar.addController(IDotnsController(address(popController))); - - // The upgraded implementation gates its entrypoints on `ISystem.originIsRoot`. The - // precompile is not part of the fork state, so the origin is mocked to Root for the calls - // made after the swap. The pre-upgrade implementation gates on the gateway address instead, - // so the seed call below is made as the gateway rather than relying on this mock. - vm.mockCall( - DotnsConstants.REVIVE_SYSTEM, - abi.encodeWithSelector(ISystem.originIsRoot.selector), - abi.encode(true) - ); - } - - /// @notice The upgrade preserves ownership, sibling wiring, and reservation state on the real - /// proxy, and exposes an `isPopIssued` surface that reports false for an unissued label - /// and true for a label the upgraded controller genuinely issues. - function test_upgrade_preservesStateAndExposesNewSurface() public { - // Seed a base-name reservation on the pre-upgrade implementation so its survival across the - // implementation swap is observable. The deployed code path gates on the gateway address. - vm.prank(gateway); - popController.reserveBaseNameOnly( - IDotnsPopController.BaseNameReservation({user: bob, reservedBaseLabel: BASE_LABEL}) - ); - IDotnsPopController.UserReservation memory seeded = popController.userReservation(bob); - assertTrue(seeded.labelhash != bytes32(0), "pre-upgrade: bob holds a reservation"); - - address proxy = address(popController); - address registryBefore = address(popController.protocolRegistry()); - uint64 durationBefore = popController.reservationDuration(); - - upgrader.upgradePopController(popControllerOwner, proxy); - - assertEq(address(popController), proxy, "upgrade keeps the same proxy address"); - assertEq( - address(popController.protocolRegistry()), - registryBefore, - "post-upgrade: protocol registry pointer preserved" - ); - assertEq( - popController.reservationDuration(), - durationBefore, - "post-upgrade: reservation duration preserved" - ); - - // The reservation queued before the upgrade is still live and still keyed to bob. - IDotnsPopController.UserReservation memory kept = popController.userReservation(bob); - assertEq(kept.labelhash, seeded.labelhash, "post-upgrade: reservation labelhash preserved"); - (bool reserved, address holder) = popController.isReservedForClaim(BASE_LABEL); - assertTrue(reserved, "post-upgrade: reservation still live at the queue head"); - assertEq(holder, bob, "post-upgrade: reservation still held by bob"); - - // The new surface is callable and reports the honest answer for a label never issued. - assertFalse( - popController.isPopIssued(UNISSUED_LABEL), - "post-upgrade: an unissued label reports false" - ); - - // Drive a real issuance through the upgraded controller so a concrete label is genuinely - // marked issued, then confirm the new surface reports it. The base registration runs under - // the mocked Root origin with no chat key and no lite link, against a fresh beneficiary - // with no store, so it exercises the real `_popIssued` write and stashes a pending claim - // without touching the resolver or deploying a store. - assertFalse( - popController.isPopIssued(ISSUED_BASE_LABEL), - "pre-issue: the base label is not yet issued" - ); - popController.registerBaseName( - IDotnsPopController.FullRegistration({ - label: ISSUED_BASE_LABEL, - user: alice, - link: IDotnsPopController.Link({ - kind: IDotnsPopController.LinkKind.None, liteLabel: "", chatKey: "" - }) - }) - ); - assertTrue( - popController.isPopIssued(ISSUED_BASE_LABEL), - "post-upgrade: a genuinely issued label reports true" - ); - } - - /// @notice After the upgrade, the Root-gated base-name reservation path still mutates queue - /// state and syncs the reservation to the queue head. - function test_upgrade_keepsBaseReservationWorkingUnderRoot() public { - upgrader.upgradePopController(popControllerOwner, address(popController)); - - // P0: a real reservation still writes queue state on the upgraded implementation under a - // mocked Root origin, and syncs the head so the label reads back as reserved for alice. - popController.reserveBaseNameOnly( - IDotnsPopController.BaseNameReservation({user: alice, reservedBaseLabel: BASE_LABEL}) - ); - - IDotnsPopController.UserReservation memory res = popController.userReservation(alice); - assertTrue(res.labelhash != bytes32(0), "post-upgrade: alice holds a fresh reservation"); - (bool reserved, address holder) = popController.isReservedForClaim(BASE_LABEL); - assertTrue(reserved, "post-upgrade: the fresh reservation is live at the queue head"); - assertEq(holder, alice, "post-upgrade: the fresh reservation is held by alice"); - } -} diff --git a/test/fork/UpgradePopRules.t.sol b/test/fork/UpgradePopRules.t.sol deleted file mode 100644 index 4e0d1e7cd..000000000 --- a/test/fork/UpgradePopRules.t.sol +++ /dev/null @@ -1,194 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.34; - -import {Test} from "forge-std/Test.sol"; -import { - OwnableUpgradeable -} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; - -import {PopRules} from "../../contracts/pop/PopRules.sol"; -import {IPopRules} from "../../contracts/pop/IPopRules.sol"; -import {IDotnsProtocolRegistry} from "../../contracts/registry/IDotnsProtocolRegistry.sol"; -import {IDotnsRegistrar} from "../../contracts/registrars/IDotnsRegistrar.sol"; -import {IPersonhood} from "../../contracts/external/personhood/IPersonhood.sol"; -import {ISystem} from "../../contracts/external/revive/ISystem.sol"; -import {DotnsConstants} from "../../contracts/utils/DotnsConstants.sol"; -import {UpgradePopRules} from "../../scripts/deploy/UpgradePopRules.s.sol"; - -/// @title UpgradePopRulesHarness -/// @notice Exposes the upgrade script's internal upgrade path so the fork test drives the exact -/// code the production run executes, including the fail-closed storage-layout diff. -/// @dev Mirrors the registrar harness pattern: forward to the script internal rather than -/// re-implement the upgrade, so the test tracks the production path one-to-one. -contract UpgradePopRulesHarness is UpgradePopRules { - /// @notice Upgrades `proxy` under `owner` through the script's `_upgradePopRules`. - function upgradePopRules(address owner, address proxy) external { - _upgradePopRules(owner, proxy); - } -} - -/// @title UpgradePopRulesForkTest -/// @notice Pairs one-to-one with `scripts/deploy/UpgradePopRules.s.sol`. Forks the live Paseo Asset -/// Hub through the ETH-RPC adapter, upgrades the deployed PopRules proxy with the script, -/// and re-runs the oracle's P0 paths against real on-chain state. Proves the swap keeps the -/// proxy, its owner, and the registry pointer, keeps classification and pricing reads -/// answering, and keeps the Root-gated short-name lever working. -/// @dev PR-scoped: deleted before merge with the upgrade script and the `PopRulesOld` snapshot. -/// Requires the local adapter on `paseo_local`; between upgrade PRs `test/fork/` is empty, so -/// the suite is skipped by default with `--no-match-path 'test/fork/**'`. -/// @custom:security-contact admin@parity.io -contract UpgradePopRulesForkTest is Test { - /// @notice Manifest recording the live deployment addresses this fork resolves against. - string internal constant MANIFEST_PATH = "deployments/paseo-assethub/420420417.json"; - - /// @notice A plain nine-character label priced and classified as open to every caller. - string internal constant OPEN_LABEL = "alicexyzz"; - - /// @notice A plain six-character label that sits in the governed short-name band. - string internal constant SHORT_LABEL = "aliced"; - - /// @notice A six-character stem seeded into the reservation mapping before the upgrade. - string internal constant RESERVED_STEM = "alicez"; - - /// @notice Drives the script's upgrade path against the live proxy. - UpgradePopRulesHarness internal upgrader; - - /// @notice The deployed PopRules proxy under upgrade. - PopRules internal popRules; - - /// @notice The deployed protocol registry the oracle resolves siblings through. - IDotnsProtocolRegistry internal protocolRegistry; - - /// @notice Proxy owner, impersonated to authorise the upgrade. - address internal popRulesOwner; - - /// @notice An unverified account used for the pricing preview reads. - address internal user; - - /// @notice Forks Paseo and resolves the live PopRules proxy, its owner, and the registry. - function setUp() public { - vm.createSelectFork(vm.rpcUrl("paseo_local")); - - string memory manifest = vm.readFile(MANIFEST_PATH); - popRules = PopRules(vm.parseJsonAddress(manifest, ".PopRules")); - protocolRegistry = - IDotnsProtocolRegistry(vm.parseJsonAddress(manifest, ".DotnsProtocolRegistry")); - popRulesOwner = OwnableUpgradeable(address(popRules)).owner(); - - upgrader = new UpgradePopRulesHarness(); - - user = makeAddr("user"); - } - - /// @notice Mocks the personhood precompile so every account reads as unverified, keeping the - /// pricing preview deterministic on any fork state. - function _mockNoPersonhood() internal { - vm.mockCall( - DotnsConstants.PERSONHOOD, - abi.encodeWithSelector(IPersonhood.personhoodStatus.selector), - abi.encode(IPersonhood.PersonhoodInfo({status: 0, contextAlias: bytes32(0)})) - ); - } - - /// @notice The upgrade keeps the proxy, the registry pointer, a reservation held in the - /// oracle's own storage, and the classification and pricing reads intact, and installs - /// the Root gate on the short-name lever. - function test_upgrade_preservesStateAndKeepsPricingWorking() public { - // Seed representative reads on the pre-upgrade implementation. - address registryBefore = address(popRules.protocolRegistry()); - uint256 priceBefore = popRules.price(OPEN_LABEL); - (IPopRules.PopStatus statusBefore, string memory messageBefore) = - popRules.classifyName(OPEN_LABEL); - - // Seed a live reservation into the oracle's own `reservations` mapping through the - // registry-gated `reserveBaseName` on the pre-upgrade implementation. The registrar's - // controller check is mocked so a chosen controller clears the gate; the write itself runs - // the deployed code and lands in real storage at the mapping's computed slot. The label - // derived pricing and classification reads above are identical before and after the swap - // whatever the oracle holds, so this reservation is the piece that proves the swap - // preserves PopRules' own mutable state rather than merely recomputing from the label. - address reservationOwner = makeAddr("reservationOwner"); - vm.mockCall( - protocolRegistry.get(DotnsConstants.REGISTRAR), - abi.encodeWithSelector(IDotnsRegistrar.controllers.selector), - abi.encode(true) - ); - vm.prank(makeAddr("controller")); - popRules.reserveBaseName(RESERVED_STEM, reservationOwner); - (bool reservedBefore, address reservedOwnerBefore, uint64 reservedExpiryBefore) = - popRules.isBaseNameReserved(RESERVED_STEM); - assertTrue(reservedBefore, "reservation live before upgrade"); - assertEq(reservedOwnerBefore, reservationOwner, "seeded reservation owner"); - - address proxy = address(popRules); - upgrader.upgradePopRules(popRulesOwner, proxy); - - assertEq(address(popRules), proxy, "upgrade keeps the same proxy address"); - assertEq( - address(popRules.protocolRegistry()), - registryBefore, - "post-upgrade: protocol registry pointer preserved" - ); - - // The mapping slot survives the swap: the same stem resolves to the same owner and expiry - // when read back through the new implementation. - (bool reservedAfter, address reservedOwnerAfter, uint64 reservedExpiryAfter) = - popRules.isBaseNameReserved(RESERVED_STEM); - assertTrue(reservedAfter, "post-upgrade: reservation still live"); - assertEq( - reservedOwnerAfter, reservedOwnerBefore, "post-upgrade: reservation owner preserved" - ); - assertEq( - reservedExpiryAfter, reservedExpiryBefore, "post-upgrade: reservation expiry preserved" - ); - - // P0: pricing and classification still answer sensibly, unchanged for a plain open label. - assertEq(popRules.price(OPEN_LABEL), priceBefore, "post-upgrade: price preserved"); - (IPopRules.PopStatus statusAfter, string memory messageAfter) = - popRules.classifyName(OPEN_LABEL); - assertEq( - uint256(statusAfter), uint256(statusBefore), "post-upgrade: classification preserved" - ); - assertEq(uint256(statusAfter), uint256(IPopRules.PopStatus.NoStatus), "open label is open"); - assertEq(messageAfter, messageBefore, "post-upgrade: classification message preserved"); - - // New surface: the short-name lever is now gated on a Root origin. A non-Root origin - // reverts with the typed error rather than the pre-upgrade owner gate. - vm.mockCall( - DotnsConstants.REVIVE_SYSTEM, - abi.encodeWithSelector(ISystem.originIsRoot.selector), - abi.encode(false) - ); - vm.prank(popRulesOwner); - vm.expectRevert(IPopRules.NotRoot.selector); - popRules.setShortNamesEnabled(true); - } - - /// @notice After the upgrade, a Root origin opens the short-name market and the public pricing - /// preview transitions from reverting to returning for a short label. - function test_upgrade_rootOpensShortNameMarket() public { - upgrader.upgradePopRules(popRulesOwner, address(popRules)); - _mockNoPersonhood(); - - // Closed by default: the public preview rejects a short label. - assertFalse(popRules.shortNamesEnabled(), "short names closed after upgrade"); - vm.expectRevert( - abi.encodeWithSelector(IPopRules.PopError.selector, "Short names are not for sale") - ); - popRules.priceWithoutCheck(SHORT_LABEL, user); - - // A Root origin flips the lever. - vm.mockCall( - DotnsConstants.REVIVE_SYSTEM, - abi.encodeWithSelector(ISystem.originIsRoot.selector), - abi.encode(true) - ); - vm.prank(user); - popRules.setShortNamesEnabled(true); - assertTrue(popRules.shortNamesEnabled(), "Root opened the short-name market"); - - // P0: the public preview now returns a price for the same short label. - IPopRules.PriceWithMeta memory metadata = popRules.priceWithoutCheck(SHORT_LABEL, user); - assertEq(popRules.price(SHORT_LABEL), metadata.price, "preview price matches the curve"); - } -} diff --git a/test/fork/UpgradeRegistrar.t.sol b/test/fork/UpgradeRegistrar.t.sol deleted file mode 100644 index 1c2b48418..000000000 --- a/test/fork/UpgradeRegistrar.t.sol +++ /dev/null @@ -1,216 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.34; - -import {Test} from "forge-std/Test.sol"; -import { - OwnableUpgradeable -} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; - -import {DotnsRegistrar} from "../../contracts/registrars/DotnsRegistrar.sol"; -import {IDotnsRegistrar} from "../../contracts/registrars/IDotnsRegistrar.sol"; -import {IDotnsController} from "../../contracts/registrars/IDotnsController.sol"; -import {IDotnsProtocolRegistry} from "../../contracts/registry/IDotnsProtocolRegistry.sol"; -import {IPersonhood} from "../../contracts/external/personhood/IPersonhood.sol"; -import {DotnsConstants} from "../../contracts/utils/DotnsConstants.sol"; -import {UpgradeRegistrar} from "../../scripts/deploy/UpgradeRegistrar.s.sol"; - -/// @title UpgradeRegistrarHarness -/// @notice Exposes the upgrade script's internal upgrade path so the fork test drives the exact -/// code the production run executes, including the fail-closed storage-layout diff. -/// @dev Mirrors the `DeterministicDeploymentHarness` pattern: forward to the script internal -/// rather than re-implement the upgrade, so the test tracks the production path one-to-one. -contract UpgradeRegistrarHarness is UpgradeRegistrar { - /// @notice Upgrades `proxy` under `owner` through the script's `_upgradeRegistrar`. - function upgradeRegistrar(address owner, address proxy) external { - _upgradeRegistrar(owner, proxy); - } -} - -/// @title UpgradeRegistrarForkTest -/// @notice Pairs one-to-one with `scripts/deploy/UpgradeRegistrar.s.sol`. Forks the live Paseo -/// Asset Hub through the ETH-RPC adapter, upgrades the deployed registrar proxy with the -/// script, and re-runs the registrar's P0 paths against real on-chain state to prove the -/// swap preserves ownership and keeps registration, transfer, and soulbound gating working. -/// @dev PR-scoped: deleted before merge with the upgrade script and the `DotnsRegistrarOld` -/// snapshot. Requires the local adapter on `paseo_local`; between upgrade PRs `test/fork/` is -/// empty, so the suite is skipped by default with `--no-match-path 'test/fork/**'`. -/// @custom:security-contact admin@parity.io -contract UpgradeRegistrarForkTest is Test { - /// @notice Manifest recording the live deployment addresses this fork resolves against. - string internal constant MANIFEST_PATH = "deployments/paseo-assethub/420420417.json"; - - /// @notice Drives the script's upgrade path against the live proxy. - UpgradeRegistrarHarness internal upgrader; - - /// @notice The deployed registrar proxy under upgrade. - DotnsRegistrar internal registrar; - - /// @notice The deployed protocol registry the registrar resolves siblings through. - IDotnsProtocolRegistry internal protocolRegistry; - - /// @notice Proxy owner, impersonated to authorise the upgrade and controller writes. - address internal registrarOwner; - - /// @notice The deployed commit-reveal controller, impersonated to mint public names. - address internal registrarController; - - /// @notice The deployed PoP controller, impersonated to mint soulbound names. - address internal popController; - - /// @notice A single label whose base length lands in the PoP-gated band, so its transfer floor - /// is the real `BASE_DEPOSIT` rather than zero. - /// @dev Eight lowercase letters classify as a `PopFull` name that an unverified recipient - /// cannot reach, so `quoteTransferFee` prices the move at the name's own deposit. - string internal constant SEED_LABEL = "seedname"; - - /// @notice A second PoP-gated single label minted after the upgrade to prove real registration - /// still writes a label through the store factory. - string internal constant POST_LABEL = "postname"; - - /// @notice Recipient accounts for the transfer paths. - address internal alice; - address internal bob; - - /// @notice Forks Paseo, resolves the live addresses, and funds the transfer recipients. - function setUp() public { - vm.createSelectFork(vm.rpcUrl("paseo_local")); - - string memory manifest = vm.readFile(MANIFEST_PATH); - registrar = DotnsRegistrar(vm.parseJsonAddress(manifest, ".DotnsRegistrar")); - protocolRegistry = - IDotnsProtocolRegistry(vm.parseJsonAddress(manifest, ".DotnsProtocolRegistry")); - registrarController = vm.parseJsonAddress(manifest, ".DotnsRegistrarController"); - popController = vm.parseJsonAddress(manifest, ".DotnsPopController"); - registrarOwner = OwnableUpgradeable(address(registrar)).owner(); - - upgrader = new UpgradeRegistrarHarness(); - - alice = makeAddr("alice"); - bob = makeAddr("bob"); - vm.deal(alice, 100 ether); - vm.deal(bob, 100 ether); - - // The mint paths run through the live controller set rather than a freshly added one, so - // the test reads the wiring the deployment left in place instead of masking it. The upgrade - // assertions below prove that wiring survives the implementation swap. - } - - /// @notice Mocks the personhood precompile so every account reads as unverified. - /// @dev The substrate personhood precompile carries no bytecode on the EVM fork, so a - /// transfer-floor read reverts against live state. Pinning both parties to `NoStatus` - /// keeps the fee math real: a `PopFull` name an unverified recipient cannot reach prices - /// the move at the name's own deposit. - function _mockNoPersonhood() internal { - vm.mockCall( - DotnsConstants.PERSONHOOD, - abi.encodeWithSelector(IPersonhood.personhoodStatus.selector), - abi.encode(IPersonhood.PersonhoodInfo({status: 0, contextAlias: bytes32(0)})) - ); - } - - /// @notice The upgrade preserves ownership state on the real proxy and keeps minting and - /// transferring public names working. - function test_upgrade_preservesStateAndKeepsCoreP0Working() public { - uint256 seedToken = uint256(keccak256("dotns.fork.upgrade.seed")); - - // The seed mint proves the live registrar already accepts its commit-reveal controller, so - // the test reads the deployment's wiring rather than a controller it added itself. - assertTrue( - registrar.controllers(IDotnsController(registrarController)), - "pre-upgrade: the live registrar controller is wired" - ); - - // Seed ownership on the pre-upgrade implementation with a real single label, so `register` - // writes the owner's label through the store factory and the token carries a real name. - vm.prank(registrarController); - registrar.register(seedToken, alice, SEED_LABEL); - assertEq(registrar.ownerOf(seedToken), alice, "pre-upgrade: alice owns the seed name"); - assertEq( - registrar.labelOf(seedToken), SEED_LABEL, "pre-upgrade: the seed carries its label" - ); - - address proxy = address(registrar); - address registryBefore = address(registrar.protocolRegistry()); - - upgrader.upgradeRegistrar(registrarOwner, proxy); - - assertEq(address(registrar), proxy, "upgrade keeps the same proxy address"); - assertEq(registrar.ownerOf(seedToken), alice, "post-upgrade: ownership preserved"); - assertEq( - registrar.labelOf(seedToken), SEED_LABEL, "post-upgrade: the seed label is preserved" - ); - assertEq( - address(registrar.protocolRegistry()), - registryBefore, - "post-upgrade: protocol registry pointer preserved" - ); - assertFalse( - registrar.isSoulbound(seedToken), - "post-upgrade: a name minted before the upgrade is not soulbound" - ); - - // The upgrade preserves the `controllers` mapping: both live controllers stay authorised - // across the implementation swap without the test re-adding either. - assertTrue( - registrar.controllers(IDotnsController(registrarController)), - "post-upgrade: the registrar controller mapping survives the swap" - ); - assertTrue( - registrar.controllers(IDotnsController(popController)), - "post-upgrade: the PoP controller mapping survives the swap" - ); - - // P0: minting a real single label still works on the upgraded implementation, so the store - // write path runs post-swap. - uint256 postToken = uint256(keccak256("dotns.fork.upgrade.post")); - vm.prank(registrarController); - registrar.register(postToken, bob, POST_LABEL); - assertEq(registrar.ownerOf(postToken), bob, "post-upgrade: registration still mints"); - assertEq( - registrar.labelOf(postToken), POST_LABEL, "post-upgrade: the mint carries its label" - ); - - // P0: a public name is still transferable through the transfer-floor path. The PoP-gated - // seed prices a move to an unverified recipient at its own deposit, so the sender pays a - // real fee and the escrow settles it rather than taking the zero-fee early return. - _mockNoPersonhood(); - uint256 fee = registrar.quoteTransferFee(seedToken, bob); - assertGt(fee, 0, "post-upgrade: a PoP-gated name quotes a real transfer fee"); - vm.prank(alice); - registrar.transferFrom{value: fee}(alice, bob, seedToken); - assertEq(registrar.ownerOf(seedToken), bob, "post-upgrade: a public name still transfers"); - assertEq( - registrar.labelOf(seedToken), SEED_LABEL, "post-upgrade: the label follows the transfer" - ); - } - - /// @notice After the upgrade, a name minted through the PoP controller is soulbound and every - /// transfer path reverts. - function test_upgrade_enablesSoulboundGatingForGatewayMints() public { - upgrader.upgradeRegistrar(registrarOwner, address(registrar)); - - // The PoP controller mints through the live wiring the upgrade preserved. - assertTrue( - registrar.controllers(IDotnsController(popController)), - "post-upgrade: the PoP controller mapping survives the swap" - ); - - // The gateway-cold path stashes a pending label, so a soulbound mint takes an empty label - // and never touches the store. - uint256 gatewayToken = uint256(keccak256("dotns.fork.upgrade.gateway")); - vm.prank(popController); - registrar.register(gatewayToken, alice, ""); - assertTrue(registrar.isSoulbound(gatewayToken), "gateway mint is soulbound"); - - vm.prank(alice); - vm.expectRevert( - abi.encodeWithSelector(IDotnsRegistrar.NameSoulbound.selector, gatewayToken) - ); - registrar.transferFrom(alice, bob, gatewayToken); - - vm.expectRevert( - abi.encodeWithSelector(IDotnsRegistrar.NameSoulbound.selector, gatewayToken) - ); - registrar.quoteTransferFee(gatewayToken, bob); - } -} diff --git a/test/fork/UpgradeRegistrarController.t.sol b/test/fork/UpgradeRegistrarController.t.sol deleted file mode 100644 index 2bc3cabe4..000000000 --- a/test/fork/UpgradeRegistrarController.t.sol +++ /dev/null @@ -1,152 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.34; - -import {Test} from "forge-std/Test.sol"; -import { - OwnableUpgradeable -} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; - -import {DotnsRegistrarController} from "../../contracts/registrars/DotnsRegistrarController.sol"; -import {IDotnsRegistrarController} from "../../contracts/registrars/IDotnsRegistrarController.sol"; -import {IDotnsRegistrar} from "../../contracts/registrars/IDotnsRegistrar.sol"; -import {IDotnsProtocolRegistry} from "../../contracts/registry/IDotnsProtocolRegistry.sol"; -import {IDotnsCostModelRegistry} from "../../contracts/pop/IDotnsCostModelRegistry.sol"; -import {ISystem} from "../../contracts/external/revive/ISystem.sol"; -import {DotnsConstants} from "../../contracts/utils/DotnsConstants.sol"; -import {LabelUtils} from "../../contracts/utils/LabelUtils.sol"; -import {UpgradeRegistrarController} from "../../scripts/deploy/UpgradeRegistrarController.s.sol"; - -/// @title UpgradeRegistrarControllerHarness -/// @notice Exposes the upgrade script's internal upgrade path so the fork test drives the exact -/// code the production run executes, including the fail-closed storage-layout diff. -/// @dev Mirrors the reference `UpgradeRegistrarHarness` pattern: forward to the script internal -/// rather than re-implement the upgrade, so the test tracks the production path one-to-one. -contract UpgradeRegistrarControllerHarness is UpgradeRegistrarController { - /// @notice Upgrades `proxy` under `owner` through the script's internal upgrade path. - function upgradeRegistrarController(address owner, address proxy) external { - _upgradeRegistrarController(owner, proxy); - } -} - -/// @title UpgradeRegistrarControllerForkTest -/// @notice Pairs one-to-one with `scripts/deploy/UpgradeRegistrarController.s.sol`. Forks the live -/// Paseo Asset Hub through the ETH-RPC adapter, upgrades the deployed controller proxy with -/// the script, and re-runs the controller's commit-reveal P0 against real on-chain state to -/// prove the swap preserves ownership, the protocol registry pointer, the commitment window -/// bounds, and a commitment made on the pre-upgrade implementation. -/// @dev PR-scoped: deleted before merge with the upgrade script and the -/// `DotnsRegistrarControllerOld` snapshot. Requires the local adapter on `paseo_local`; between -/// upgrade PRs `test/fork/` is -/// empty, so the suite is skipped by default with `--no-match-path 'test/fork/**'`. -/// @custom:security-contact admin@parity.io -contract UpgradeRegistrarControllerForkTest is Test { - /// @notice Manifest recording the live deployment addresses this fork resolves against. - string internal constant MANIFEST_PATH = "deployments/paseo-assethub/420420417.json"; - - /// @notice Drives the script's upgrade path against the live proxy. - UpgradeRegistrarControllerHarness internal upgrader; - - /// @notice The deployed registrar controller proxy under upgrade. - DotnsRegistrarController internal controller; - - /// @notice The deployed protocol registry the controller resolves siblings through. - IDotnsProtocolRegistry internal protocolRegistry; - - /// @notice The deployed registrar the controller mints names on. - IDotnsRegistrar internal registrar; - - /// @notice Proxy owner, impersonated to authorise the upgrade. - address internal controllerOwner; - - /// @notice Beneficiary the reserved commit-reveal flow mints to. - address internal alice; - - /// @notice Forks Paseo and resolves the live addresses the P0 flow drives against. - function setUp() public { - vm.createSelectFork(vm.rpcUrl("paseo_local")); - - string memory manifest = vm.readFile(MANIFEST_PATH); - controller = - DotnsRegistrarController(vm.parseJsonAddress(manifest, ".DotnsRegistrarController")); - protocolRegistry = - IDotnsProtocolRegistry(vm.parseJsonAddress(manifest, ".DotnsProtocolRegistry")); - registrar = IDotnsRegistrar(vm.parseJsonAddress(manifest, ".DotnsRegistrar")); - controllerOwner = OwnableUpgradeable(address(controller)).owner(); - - upgrader = new UpgradeRegistrarControllerHarness(); - - alice = makeAddr("alice"); - vm.deal(alice, 100 ether); - } - - /// @notice The upgrade preserves the proxy address, the protocol registry pointer, and the - /// commitment window bounds, and a commitment submitted on the pre-upgrade - /// implementation still reveals into a mint on the upgraded implementation. - function test_upgrade_preservesStateAndKeepsCommitRevealWorking() public { - // Build a Root-issuable reserved registration. Root skips the whitelist grant and the PoP - // price check, so the flow exercises the full commit -> wait -> reveal -> mint path without - // seeding pricing or personhood on the fork. - IDotnsRegistrarController.Registration memory reg = IDotnsRegistrarController.Registration({ - label: "forkupgradectrl", - owner: alice, - secret: keccak256("dotns.fork.upgrade.controller.secret"), - reserved: false, - maxPrice: 0, - pricingVersion: _currentPricingVersion() - }); - - assertTrue(controller.available(reg.label), "pre-upgrade: label is available"); - - // Commit on the pre-upgrade implementation. This stamps the pricing version into the - // commitment slot, so the post-upgrade reveal proves the commitment storage survived. - bytes32 commitment = controller.makeCommitment(reg); - controller.commit(commitment); - assertEq(controller.commitments(commitment), block.timestamp, "pre-upgrade: commit stored"); - - address proxy = address(controller); - address registryBefore = address(controller.protocolRegistry()); - uint256 minAgeBefore = controller.minCommitmentAge(); - uint256 maxAgeBefore = controller.maxCommitmentAge(); - - upgrader.upgradeRegistrarController(controllerOwner, proxy); - - // (a) proxy address unchanged. - assertEq(address(controller), proxy, "upgrade keeps the same proxy address"); - // (b) key storage pointer preserved. - assertEq( - address(controller.protocolRegistry()), - registryBefore, - "post-upgrade: protocol registry pointer preserved" - ); - // (c) commitment window bounds preserved across the layout change. - assertEq(controller.minCommitmentAge(), minAgeBefore, "post-upgrade: minCommitmentAge kept"); - assertEq(controller.maxCommitmentAge(), maxAgeBefore, "post-upgrade: maxCommitmentAge kept"); - // The commitment stored before the upgrade is still readable at the same slot. - assertGt(controller.commitments(commitment), 0, "post-upgrade: commitment preserved"); - - // (d) P0: reveal the pre-upgrade commitment through the new registerReserved surface under - // a substrate Root origin and confirm the name mints to the beneficiary. - vm.warp(block.timestamp + minAgeBefore + 1); - vm.mockCall( - DotnsConstants.REVIVE_SYSTEM, - abi.encodeWithSelector(ISystem.originIsRoot.selector), - abi.encode(true) - ); - - controller.registerReserved(reg); - - bytes32 node = LabelUtils.namehashUnder( - protocolRegistry.tldNode(), LabelUtils.labelhashMemory(reg.label) - ); - assertEq( - registrar.ownerOf(uint256(node)), alice, "post-upgrade: reserved mint reaches owner" - ); - assertFalse(controller.available(reg.label), "post-upgrade: label is no longer available"); - } - - /// @notice Resolves the cost model's current version through the protocol registry. - function _currentPricingVersion() internal view returns (uint256 version) { - version = IDotnsCostModelRegistry(protocolRegistry.get(DotnsConstants.COST_MODEL)) - .currentVersion(); - } -} From f1fe9bc4d8e1364090424ed5425be5b188e6f3b6 Mon Sep 17 00:00:00 2001 From: Siphamandla Mjoli Date: Wed, 9 Sep 2026 02:59:36 +0200 Subject: [PATCH 05/25] chore: revert CI to ubuntu-latest and remove the upgrade-PR scaffolding --- CONTRIBUTING.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5b0a8e6c3..ae42129c4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -271,6 +271,8 @@ Fork tests are upgrade-PR scoped. They live in `test/fork/` for the duration of Each fork test forks live Asset Hub state, seeds or reads real on-chain state through the deployed implementation, runs the upgrade script, and asserts that state and every P0 path survive on the new implementation. Assertions exercise the real flows rather than bare mints, so a layout regression in a live slot fails the test. The `Old.sol` snapshot reproduces the layout of the implementation currently deployed on-chain, and the fork test is what confirms it: a snapshot that diverged from the live implementation makes the preserved-state assertions fail. +CI wires this in automatically, so an upgrade PR adds fork tests without touching any workflow. The `push_checking` workflow detects `test/fork/**`: when fork tests are present it brings up the ETH-RPC adapter and runs them on a dedicated job that reports an `Upgrade Fork Tests` row in the CI summary; when the directory is empty that job is skipped and no adapter starts. Locally, run the same suite with `bun run test:fork`. + While a fork test is in flight, skip it with: ```bash From ddce6f56f916dfdaba8090fdd624bb9adeaa34c3 Mon Sep 17 00:00:00 2001 From: giuseppere Date: Thu, 17 Sep 2026 17:27:03 +0200 Subject: [PATCH 06/25] Snapshot the deployed implementations, and check them against the chain The `*Old.sol` snapshots on this branch described implementations that were replaced in place months ago. All four built to different bytecode from what Paseo Asset Hub Next is running, and nothing noticed: the OpenZeppelin layout diff compares the new implementation against whatever the snapshot happens to be, so a stale reference produces an honest diff of the wrong pair, and a change that lives in calldata leaves no trace in a layout at all. Regenerate all of them from the build that is actually deployed (c8520046), and prove it. `scripts/shell/verify-snapshots.sh` builds each snapshot and compares runtime bytecode against the implementation behind the proxy, masking only `UUPSUpgradeable.__self` and the trailing CBOR metadata. All twelve upgraded proxies match byte for byte. `fork-tests.sh` runs it before the suite, so the check cannot be skipped by anyone running the tests the normal way. The snapshot set is larger than the twelve contracts because master has moved 26 contract files since the deployed build. A snapshot that imports the current tree stops reproducing the deployed bytecode, and one that imports a snapshot hands a renamed type to a signature expecting the current one. The set is therefore closed over both: everything reachable that changed, plus everything that reaches one of those. Sixteen unchanged interfaces and libraries stay shared, unrenamed. CI now runs on pull requests into `spha/**`. It previously triggered on master alone, so a PR into this branch got lint and nothing else, which is how the stale snapshots reached review in the first place. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/push_checking.yml | 8 +- contracts/escrow/DotnsNameEscrowOld.sol | 819 ++++++++++++++++++ contracts/pop/IDotnsCostModelRegistryOld.sol | 87 ++ contracts/pop/IDotnsPricingOld.sol | 32 + contracts/pop/IPopRulesOld.sol | 346 -------- contracts/pop/PopRulesOld.sol | 190 ++-- .../registrars/DotnsPopControllerOld.sol | 332 ++++--- .../DotnsRegistrarControllerOld.sol | 482 +++++++++++ contracts/registrars/DotnsRegistrarOld.sol | 473 ++++++++++ .../registrars/IDotnsPopControllerOld.sol | 499 ----------- contracts/registrars/IDotnsRegistrarOld.sol | 190 ++++ .../registry/DotnsProtocolRegistryOld.sol | 112 +++ contracts/registry/DotnsRegistryOld.sol | 40 +- .../registry/IDotnsProtocolRegistryOld.sol | 51 ++ contracts/registry/IDotnsRegistryOld.sol | 19 +- .../resolvers/DotnsContentResolverOld.sol | 151 ++++ contracts/resolvers/DotnsPopResolverOld.sol | 151 ++++ contracts/resolvers/DotnsResolverOld.sol | 99 +++ .../resolvers/DotnsReverseResolverOld.sol | 51 +- contracts/store/IStoreFactoryOld.sol | 157 ++++ contracts/utils/DotnsConstantsOld.sol | 192 ++++ contracts/utils/RegistrationUtilsOld.sol | 97 +++ contracts/utils/StoreUtilsOld.sol | 9 +- contracts/utils/SubnodeUtilsOld.sol | 134 +++ contracts/utils/SystemUtilsOld.sol | 21 + contracts/whitelist/DotnsNameWhitelistOld.sol | 512 +++++++++++ scripts/shell/fork-tests.sh | 6 + scripts/shell/verify-snapshots.sh | 144 +++ 28 files changed, 4306 insertions(+), 1098 deletions(-) create mode 100644 contracts/escrow/DotnsNameEscrowOld.sol create mode 100644 contracts/pop/IDotnsCostModelRegistryOld.sol create mode 100644 contracts/pop/IDotnsPricingOld.sol delete mode 100644 contracts/pop/IPopRulesOld.sol create mode 100644 contracts/registrars/DotnsRegistrarControllerOld.sol create mode 100644 contracts/registrars/DotnsRegistrarOld.sol delete mode 100644 contracts/registrars/IDotnsPopControllerOld.sol create mode 100644 contracts/registrars/IDotnsRegistrarOld.sol create mode 100644 contracts/registry/DotnsProtocolRegistryOld.sol create mode 100644 contracts/registry/IDotnsProtocolRegistryOld.sol create mode 100644 contracts/resolvers/DotnsContentResolverOld.sol create mode 100644 contracts/resolvers/DotnsPopResolverOld.sol create mode 100644 contracts/resolvers/DotnsResolverOld.sol create mode 100644 contracts/store/IStoreFactoryOld.sol create mode 100644 contracts/utils/DotnsConstantsOld.sol create mode 100644 contracts/utils/RegistrationUtilsOld.sol create mode 100644 contracts/utils/SubnodeUtilsOld.sol create mode 100644 contracts/utils/SystemUtilsOld.sol create mode 100644 contracts/whitelist/DotnsNameWhitelistOld.sol create mode 100755 scripts/shell/verify-snapshots.sh diff --git a/.github/workflows/push_checking.yml b/.github/workflows/push_checking.yml index 468f490d4..196c83ef7 100644 --- a/.github/workflows/push_checking.yml +++ b/.github/workflows/push_checking.yml @@ -12,11 +12,15 @@ name: Run Solidity Tests # inside a `pull_request_target` job would let a malicious PR exfiltrate # repo secrets. Re-review carefully if this trigger surface widens. on: + # `spha/**` covers the long-lived upgrade branches, which are never merged to + # master and therefore never get tested by the master triggers alone. Without + # them a PR into an upgrade branch runs lint and nothing else, which is how a + # storage snapshot that no longer matched the live chain reached review. pull_request: - branches: [master] + branches: [master, "spha/**"] paths: ["contracts/**", "test/**", "**.sol"] push: - branches: [master] + branches: [master, "spha/**"] paths: ["contracts/**", "test/**", "**.sol"] pull_request_target: types: [closed] diff --git a/contracts/escrow/DotnsNameEscrowOld.sol b/contracts/escrow/DotnsNameEscrowOld.sol new file mode 100644 index 000000000..cfc70c71e --- /dev/null +++ b/contracts/escrow/DotnsNameEscrowOld.sol @@ -0,0 +1,819 @@ +// 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 {ReentrancyGuardTransient} from "@openzeppelin/contracts/utils/ReentrancyGuardTransient.sol"; +import { + ERC165Upgradeable +} from "@openzeppelin/contracts-upgradeable/utils/introspection/ERC165Upgradeable.sol"; +import {IERC721Receiver} from "@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol"; +import {IDotnsNameEscrow} from "./IDotnsNameEscrow.sol"; +import {IDotnsRegistrarOld} from "../registrars/IDotnsRegistrarOld.sol"; +import {IDotnsProtocolRegistryOld} from "../registry/IDotnsProtocolRegistryOld.sol"; +import {DotnsConstantsOld} from "../utils/DotnsConstantsOld.sol"; + +/// @title Dotns Name Escrow +/// @notice Holds refundable deposits for registered names and manages the release/reclaim +/// lifecycle. @custom:security-contact admin@parity.io +contract DotnsNameEscrowOld is + Initializable, + UUPSUpgradeable, + OwnableUpgradeable, + ReentrancyGuardTransient, + ERC165Upgradeable, + IERC721Receiver, + IDotnsNameEscrow +{ + /// @notice Maximum page size for releasedTokens pagination. + uint256 public constant MAX_RELEASED_PAGE_SIZE = 200; + + /// @notice Maximum page size for pendingRefunds pagination and batch claims. + uint256 public constant MAX_REFUND_PAGE_SIZE = 200; + + /// @notice Upper bound on the configurable release-cooldown. + /// @dev The cooldown gates only the release-to-withdraw delay, not the long-lived deposit lock + /// and not the reclaim boundary (see `redeemWindow`), so it is intentionally kept short. + /// Capping at one hour also keeps the cast to `uint64` well below the saturation point at + /// every plausible block timestamp. + uint256 public constant MAX_COOLDOWN = 1 hours; + + /// @notice Upper bound on the configurable redeem window. + /// @dev The redeem window is a different quantity from the cooldown: it is the period after + /// release in which only the previous holder may act, and it gates reclaim rather than + /// withdrawal. The bound limits how long policy can hold a released name out of + /// circulation, and keeps the cast to `uint64` in release well below saturation. + uint256 public constant MAX_REDEEM_WINDOW = 30 days; + + /// @notice Lower bound on the configurable redeem window. + /// @dev A window short enough to elapse before its holder can plausibly notice the release + /// offers no protection at all, and one of zero length turns every release into an + /// immediate hand-off to whoever is watching. The floor keeps the window long enough to + /// span a holder being asleep or away for a day, so the guarantee survives any setting + /// the owner is able to choose. + uint256 public constant MIN_REDEEM_WINDOW = 1 days; + + /// @notice The protocol registry for resolving sibling contract addresses. + IDotnsProtocolRegistryOld public protocolRegistry; + + /// @notice Delay after release before the deposit withdrawal may be credited. + /// @dev Forces a delay between `release` and `withdraw`. It does not bound reclaim: the + /// release-to-reclaim boundary is `redeemWindow`, a separate and longer quantity. Also + /// supplies the per-entry clock for time-locked refund credits, which is why raising it + /// would slow every refund path and not just the deposit one. + uint256 public cooldown; + + /// @notice Total amount of a specific asset reserved across all positions. + /// @dev Keyed by asset so future ERC20 support can track per-token liabilities independently; + /// `address(0)` represents the native token and is the only asset currently accepted. + mapping(address asset => uint256 amount) public tokenReserved; + + /// @notice Per-token escrow position storing recipient, amount, lifecycle flags and cooldown. + mapping(uint256 tokenId => ReleasePosition position) private _positions; + + /// @notice Ordered set of tokens currently in escrow custody, used for paginated enumeration. + uint256[] private _releasedTokens; + + /// @notice Reverse lookup into `_releasedTokens` (one-based) for O(1) remove-by-swap. + mapping(uint256 tokenId => uint256 indexPlusOne) private _releasedIndexPlusOne; + + /// @notice Cumulative balance of non-refundable protocol fees; only accumulates. + /// @dev Credited by cross-paid registration fees and transfer fees. Never debited: protocol + /// fees do not back refunds, which draw solely on the per-asset reserve. + uint256 public protocolFees; + + /// @notice Pull-payment ledger storing each recipient's claimable refund balance. + /// @dev Per-recipient isolation ensures a failing or reentrant receiver cannot block other + /// users' withdrawals. Used as the fallback path for registration overpayments whose + /// direct push back to `msg.sender` failed (because the caller is a contract that + /// rejects incoming value). + mapping(address recipient => uint256 amount) private _pendingWithdrawals; + + /// @notice Time-locked refund ledger keyed by entryId. + /// @dev Every credit allocates a fresh entryId so per-entry cooldowns are independent and + /// drip-feed credits cannot reset an existing entry's clock. + mapping(uint256 entryId => RefundEntry entry) private _refundEntries; + + /// @notice Per-recipient list of pending entryIds for paginated enumeration and batch claim. + mapping(address recipient => uint256[] entryIds) private _entriesByRecipient; + + /// @notice Reverse lookup into `_entriesByRecipient` (one-based) for O(1) remove-by-swap. + mapping(uint256 entryId => uint256 indexPlusOne) private _entryIndexPlusOne; + + /// @notice Monotonic counter assigning entryIds to new refund credits. + uint256 private _nextEntryId; + + /// @notice Period after release during which only the previous holder may act. + /// @dev Distinct from `cooldown`. Inside this window the holder may `redeem` the name back + /// and nobody else may take it (`available` reports false); once it elapses `reclaim` + /// becomes permissionless and any unwithdrawn deposit is credited to the recipient + /// rather than stranded. + uint256 public redeemWindow; + + /// @dev Reserved storage space to allow for layout changes in future upgrades. A variable + /// appended above must shrink this array by the same number of slots, so an upgrade never + /// moves the slots of anything already stored. + uint256[50] private __gap; + + /// @notice Restricts calls to the configured registrar controller. + modifier onlyController() { + _onlyController(); + _; + } + + /// @notice Restricts calls to the configured registrar from the protocol registry. + modifier onlyRegistrar() { + _onlyRegistrar(); + _; + } + + /// @custom:oz-upgrades-unsafe-allow constructor + constructor() { + _disableInitializers(); + } + + /// @notice Initialises the name escrow. + /// @dev Runs once behind the proxy; subsequent calls trigger @custom:reverts + /// InvalidInitialization via the `initializer` modifier. `registry` must be non-zero, + /// otherwise @custom:reverts InvalidAsset; `cooldownSeconds` is forwarded to + /// @custom:function updateCooldown, which rejects a zero value (@custom:reverts + /// InvalidCooldown) and any value above @custom:constant MAX_COOLDOWN (@custom:reverts + /// CooldownTooLong), and emits @custom:emits CooldownUpdated as part of seeding the + /// initial cooldown. `redeemWindowSeconds` is forwarded to @custom:function + /// updateRedeemWindow, which rejects any value below @custom:constant MIN_REDEEM_WINDOW + /// (@custom:reverts RedeemWindowTooShort) or above @custom:constant MAX_REDEEM_WINDOW + /// (@custom:reverts RedeemWindowTooLong), and emits @custom:emits RedeemWindowUpdated. + /// @param registry Protocol registry used to resolve registrar and controller addresses. + /// @param cooldownSeconds Delay after release before the deposit withdrawal may be credited. + /// @param redeemWindowSeconds Period after release in which only the previous holder may act. + function initialize( + IDotnsProtocolRegistryOld registry, + uint256 cooldownSeconds, + uint256 redeemWindowSeconds + ) + external + initializer + { + require(address(registry) != address(0), InvalidAsset()); + + __Ownable_init(msg.sender); + __ERC165_init(); + + protocolRegistry = registry; + updateCooldown(cooldownSeconds); + updateRedeemWindow(redeemWindowSeconds); + } + + /// @inheritdoc IDotnsNameEscrow + function updateCooldown(uint256 newCooldown) public override onlyOwner { + require(newCooldown != 0, InvalidCooldown()); + require(newCooldown <= MAX_COOLDOWN, CooldownTooLong(newCooldown, MAX_COOLDOWN)); + + uint256 currentCooldown = cooldown; + cooldown = newCooldown; + + emit CooldownUpdated(currentCooldown, newCooldown); + } + + /// @inheritdoc IDotnsNameEscrow + function updateRedeemWindow(uint256 newRedeemWindow) public override onlyOwner { + require( + newRedeemWindow >= MIN_REDEEM_WINDOW, + RedeemWindowTooShort(newRedeemWindow, MIN_REDEEM_WINDOW) + ); + require( + newRedeemWindow <= MAX_REDEEM_WINDOW, + RedeemWindowTooLong(newRedeemWindow, MAX_REDEEM_WINDOW) + ); + + uint256 currentRedeemWindow = redeemWindow; + redeemWindow = newRedeemWindow; + + emit RedeemWindowUpdated(currentRedeemWindow, newRedeemWindow); + } + + /// @inheritdoc IDotnsNameEscrow + function getReleasePosition(uint256 tokenId) + external + view + override + returns (ReleasePosition memory position) + { + position = _positions[tokenId]; + } + + /// @inheritdoc IDotnsNameEscrow + function releasedTokenCount() external view override returns (uint256 count) { + count = _releasedTokens.length; + } + + /// @inheritdoc IDotnsNameEscrow + function reserves(address asset) external view returns (uint256 amount) { + amount = tokenReserved[asset]; + } + + /// @inheritdoc IDotnsNameEscrow + function releasedTokens( + uint256 start, + uint256 limit + ) + external + view + override + returns (uint256[] memory tokenIds) + { + require(limit != 0 && limit <= MAX_RELEASED_PAGE_SIZE, InvalidPageSize(limit)); + + uint256 length = _releasedTokens.length; + if (start >= length) return new uint256[](0); + + uint256 end = start + limit; + if (end > length) end = length; + + tokenIds = new uint256[](end - start); + uint256 outIndex = 0; + for (uint256 i = start; i < end; ++i) { + tokenIds[outIndex] = _releasedTokens[i]; + ++outIndex; + } + } + + /// @inheritdoc IDotnsNameEscrow + function deposit(DepositParams calldata params) external payable override onlyController { + // Reject mismatched amount/msg.value so callers cannot under-fund a position. + require(msg.value == params.amount, InvalidAmount()); + // Only native deposits are currently supported; ERC20 support can be added in a + // future upgrade by relaxing this check and routing transfers via SafeERC20. + require(params.asset == address(0), AssetNotSupported(params.asset)); + require(params.recipient != address(0), InvalidRecipient()); + + ReleasePosition storage position = _positions[params.tokenId]; + + // Use `recipient` as the "is this slot funded?" sentinel so zero-amount + // positions (seeded by cross-paid registrations, which pay a fee rather than a deposit) + // still count as present and cannot be re-seeded with a different recipient. + require(position.recipient == address(0), PositionAlreadyFunded(params.tokenId)); + require(!position.released, AlreadyReleased(params.tokenId)); + + position.asset = params.asset; + position.amount = params.amount; + position.recipient = params.recipient; + + tokenReserved[position.asset] += params.amount; + + emit NativeDepositRecorded(params.tokenId, params.amount); + } + + /// @inheritdoc IDotnsNameEscrow + function creditOverpayment(address recipient) external payable override onlyController { + require(recipient != address(0), InvalidRecipient()); + require(msg.value != 0, InvalidAmount()); + _pendingWithdrawals[recipient] += msg.value; + emit OverpaymentRefunded(recipient, msg.value); + } + + /// @inheritdoc IDotnsNameEscrow + function depositProtocolFee(ProtocolFeeDepositParams calldata params) + external + payable + override + onlyController + { + require(msg.value > 0, InvalidAmount()); + + protocolFees += msg.value; + + emit CrossTierFeePaid( + params.tokenId, + params.payer, + params.recipient, + msg.value, + /* isRegistration */ + true + ); + } + + /// @inheritdoc IDotnsNameEscrow + function chargeTransferFee(ChargeTransferFeeParams calldata params) + external + payable + override + onlyRegistrar + returns (uint256 charged) + { + ReleasePosition storage position = _positions[params.tokenId]; + // Released positions are mid-lifecycle in escrow custody and must not be rebound; the + // recipient is the original releaser who will claim the refund. The canonical transfer + // path never reaches here for released tokens (`_update` short-circuits on escrow-touching + // transfers) but the guard hardens the contract against a divergent registrar. + require(!position.released, AlreadyReleased(params.tokenId)); + + address priorRecipient = position.recipient; + + uint256 fee = params.transferFee; + require(msg.value >= fee, InsufficientValue()); + + // Deposits follow the NFT, not the depositor. When the position is funded the locked + // deposit travels with the name; when it is a zero-amount lifecycle marker the marker + // travels with it. In both cases the position is rebound to the new holder so only + // the current holder can later release into escrow and claim the refund. + if (priorRecipient != address(0) && params.to != priorRecipient) { + position.recipient = params.to; + } + + charged = fee; + + if (fee > 0) { + protocolFees += fee; + emit CrossTierFeePaid( + params.tokenId, + params.payer, + params.to, + fee, + /* isRegistration */ + false + ); + } + + uint256 overpayment = msg.value - fee; + if (overpayment > 0) { + _creditRefund(params.payer, overpayment, params.tokenId); + } + } + + /// @inheritdoc IDotnsNameEscrow + function release(uint256 tokenId) external override nonReentrant { + IDotnsRegistrarOld registrar = _registrar(); + + address currentOwner = registrar.ownerOf(tokenId); + + ReleasePosition storage position = _positions[tokenId]; + // Recipient is the canonical "is this position present?" sentinel; zero-amount positions + // seeded for cross-paid registrations are still releasable so every minted name has a + // reachable lifecycle. + require(position.recipient != address(0), DepositNotConfigured(tokenId)); + require(!position.released, AlreadyReleased(tokenId)); + + // Position recipient mirrors the current NFT holder (rebound on every transfer), so the + // holder gate collapses to a single equality check. Approved operators cannot release on + // behalf of the holder because the recipient field is keyed to the holder, not to any + // approval set; this keeps the deposit refund flow tied to the on-chain owner. + require( + msg.sender == currentOwner && msg.sender == position.recipient, + NotRefundRecipient(msg.sender, tokenId) + ); + + bool approvedForEscrow = registrar.getApproved(tokenId) == address(this) + || registrar.isApprovedForAll(currentOwner, address(this)); + + require(approvedForEscrow, EscrowNotApproved(tokenId)); + + // Fail closed on an unseeded window rather than stamping `redeemableUntil` at the current + // timestamp, which would collapse the holder's exclusive redeem phase to zero length and + // open permissionless reclaim the instant the name is released. Only reachable on a proxy + // upgraded without pairing the upgrade with `updateRedeemWindow`. + uint256 currentRedeemWindow = redeemWindow; + require(currentRedeemWindow != 0, RedeemWindowNotConfigured()); + + // Snapshot the position fields once into stack locals so the trailing event emit reuses + // them without three extra warm SLOADs after the state mutation. Both casts to `uint64` are + // safe because `cooldown` and `redeemWindow` are bounded by @custom:constant MAX_COOLDOWN + // and @custom:constant MAX_REDEEM_WINDOW respectively. + address asset = position.asset; + uint256 amount = position.amount; + // forge-lint: disable-next-line(unsafe-typecast) + uint64 availableAt = uint64(block.timestamp + cooldown); + // forge-lint: disable-next-line(unsafe-typecast) + uint64 redeemUntil = uint64(block.timestamp + currentRedeemWindow); + + position.withdrawAvailableAt = availableAt; + position.redeemableUntil = redeemUntil; + position.released = true; + + registrar.safeTransferFrom(currentOwner, address(this), tokenId); + + _addReleasedToken(tokenId); + + emit NameReleased(tokenId, msg.sender, asset, amount, availableAt, redeemUntil); + } + + /// @inheritdoc IDotnsNameEscrow + function withdraw(uint256 tokenId) external override nonReentrant { + ReleasePosition storage position = _positions[tokenId]; + + require(position.released, NotReleased(tokenId)); + require(!position.claimed, AlreadyClaimed(tokenId)); + require(position.recipient == msg.sender, NotRefundRecipient(msg.sender, tokenId)); + require( + block.timestamp >= position.withdrawAvailableAt, + WithdrawalTooEarly(tokenId, position.withdrawAvailableAt, block.timestamp) + ); + + _settleDeposit(position, tokenId, msg.sender); + } + + /// @notice Moves a position's outstanding deposit onto the recipient's pull-payment balance. + /// @dev Shared by @custom:function withdraw, where the recipient pulls the deposit themselves, + /// and by @custom:function reclaim, where a third party takes the name and the deposit is + /// settled on the departing holder's behalf. Both credit the same ledger and neither + /// transfers value, so the accounting is identical and lives here once. The per-asset + /// `tokenReserved` pool backs the refund in full, and @custom:reverts InsufficientFunds + /// when it cannot cover the amount owed. Emits @custom:emits RefundWithdrawn. + /// A zero-amount position is a no-op: it writes nothing and emits nothing, which keeps the + /// free-registration lifecycle free of meaningless ledger entries and events. + /// @param position Storage pointer to the position being settled. + /// @param recipient Address credited with the deposit. Always the position recipient. + function _settleDeposit( + ReleasePosition storage position, + uint256 tokenId, + address recipient + ) + private + { + uint256 owed = position.amount; + address asset = position.asset; + + // Nothing to settle: return before touching `claimed`. That flag is what `redeem` reads to + // decide whether the holder has already been paid for the name, so setting it here would + // make a zero-amount `withdraw`, which pays nothing and emits nothing, silently forfeit + // the holder's right to recover their own name for no consideration at all. Free PopFull + // and PopLite registrations seed exactly these positions, and `withdraw` is the step the + // old contract required before a name could be recycled, so that is a path holders will + // take. + if (owed == 0) return; + + // Effects: from here the deposit really is being handed over, so the flag is set. + position.claimed = true; + + // The per-asset reserve backs every refundable deposit; protocol fees are non-refundable + // and never cover a refund. + require( + tokenReserved[asset] >= owed, InsufficientFunds(tokenId, owed, tokenReserved[asset]) + ); + + position.amount = 0; + tokenReserved[asset] -= owed; + + _pendingWithdrawals[recipient] += owed; + + emit RefundWithdrawn(tokenId, recipient, asset, owed); + } + + /// @inheritdoc IDotnsNameEscrow + function claimWithdrawal() external override nonReentrant returns (uint256 amount) { + amount = _pendingWithdrawals[msg.sender]; + require(amount > 0, NoPendingWithdrawal()); + + // Effects before interaction. + _pendingWithdrawals[msg.sender] = 0; + + (bool ok,) = payable(msg.sender).call{value: amount}(""); + // tokenId is not meaningful here since a single pending balance can aggregate + // multiple positions; surface 0 to keep the existing error shape. + require(ok, RefundFailed(0)); + + emit WithdrawalClaimed(msg.sender, amount); + } + + /// @inheritdoc IDotnsNameEscrow + function pendingWithdrawal(address recipient) external view override returns (uint256 amount) { + amount = _pendingWithdrawals[recipient]; + } + + /// @inheritdoc IDotnsNameEscrow + function claimRefund(uint256 entryId) external override nonReentrant returns (uint256 amount) { + // Storage pointer over memory copy: only the fields we actually need are SLOAD-ed. + RefundEntry storage entry = _refundEntries[entryId]; + amount = entry.amount; + // Check existence first so a deleted (already-claimed or unknown) entry surfaces a clear + // `NoSuchRefundEntry` rather than the recipient-mismatch revert that would otherwise fire + // against the zero-address sentinel. + require(amount > 0, NoSuchRefundEntry(entryId)); + uint256 entryTokenId = entry.tokenId; + require(entry.recipient == msg.sender, NotRefundRecipient(msg.sender, entryTokenId)); + require(block.timestamp >= entry.availableAt, RefundLocked(entryId, entry.availableAt)); + + _removeRefundEntry(entryId, msg.sender); + + (bool ok,) = payable(msg.sender).call{value: amount}(""); + require(ok, RefundFailed(entryTokenId)); + + emit RefundClaimed(msg.sender, entryId, amount); + } + + /// @inheritdoc IDotnsNameEscrow + function claimRefundsBatch(uint256[] calldata entryIds) + external + override + nonReentrant + returns (uint256 totalAmount) + { + uint256 length = entryIds.length; + require(length > 0 && length <= MAX_REFUND_PAGE_SIZE, InvalidPageSize(length)); + + for (uint256 i; i < length; ++i) { + uint256 entryId = entryIds[i]; + RefundEntry storage entry = _refundEntries[entryId]; + uint256 amount = entry.amount; + require(amount > 0, NoSuchRefundEntry(entryId)); + require(entry.recipient == msg.sender, NotRefundRecipient(msg.sender, entry.tokenId)); + require(block.timestamp >= entry.availableAt, RefundLocked(entryId, entry.availableAt)); + + totalAmount += amount; + _removeRefundEntry(entryId, msg.sender); + + emit RefundClaimed(msg.sender, entryId, amount); + } + + (bool ok,) = payable(msg.sender).call{value: totalAmount}(""); + require(ok, RefundFailed(0)); + } + + /// @inheritdoc IDotnsNameEscrow + function pendingRefundCount(address recipient) external view override returns (uint256 count) { + count = _entriesByRecipient[recipient].length; + } + + /// @inheritdoc IDotnsNameEscrow + function pendingRefundIds( + address recipient, + uint256 offset, + uint256 limit + ) + external + view + override + returns (uint256[] memory entryIds) + { + require(limit > 0 && limit <= MAX_REFUND_PAGE_SIZE, InvalidPageSize(limit)); + + uint256[] storage all = _entriesByRecipient[recipient]; + uint256 total = all.length; + if (offset >= total) return new uint256[](0); + + uint256 end = offset + limit; + if (end > total) end = total; + + entryIds = new uint256[](end - offset); + for (uint256 i = 0; i < entryIds.length; ++i) { + entryIds[i] = all[offset + i]; + } + } + + /// @inheritdoc IDotnsNameEscrow + function pendingRefunds( + address recipient, + uint256 offset, + uint256 limit + ) + external + view + override + returns (uint256[] memory entryIds, RefundEntry[] memory entries) + { + require(limit > 0 && limit <= MAX_REFUND_PAGE_SIZE, InvalidPageSize(limit)); + + uint256[] storage all = _entriesByRecipient[recipient]; + uint256 total = all.length; + if (offset >= total) { + return (new uint256[](0), new RefundEntry[](0)); + } + + uint256 end = offset + limit; + if (end > total) end = total; + + uint256 count = end - offset; + entryIds = new uint256[](count); + entries = new RefundEntry[](count); + for (uint256 i = 0; i < count; ++i) { + uint256 entryId = all[offset + i]; + entryIds[i] = entryId; + entries[i] = _refundEntries[entryId]; + } + } + + /// @inheritdoc IDotnsNameEscrow + function refundEntry(uint256 entryId) + external + view + override + returns (RefundEntry memory entry) + { + entry = _refundEntries[entryId]; + } + + /// @notice Internal helper: allocate a new entryId and credit a refund to `recipient`. + /// @dev Assigns the next monotonic entryId, stores the entry, appends to the recipient's + /// enumeration array, and emits @custom:emits RefundCredited. The cooldown is read from the + /// configured `cooldown` storage value; @custom:constant MAX_COOLDOWN bounds it so the cast to + /// `uint64` cannot truncate for any plausible block timestamp. + function _creditRefund( + address recipient, + uint256 amount, + uint256 tokenId + ) + internal + returns (uint256 entryId) + { + require(recipient != address(0), InvalidRecipient()); + require(amount > 0, InvalidAmount()); + + entryId = ++_nextEntryId; + // forge-lint: disable-next-line(unsafe-typecast) + uint64 availableAt = uint64(block.timestamp + cooldown); + + _refundEntries[entryId] = RefundEntry({ + recipient: recipient, amount: amount, availableAt: availableAt, tokenId: tokenId + }); + + uint256[] storage list = _entriesByRecipient[recipient]; + list.push(entryId); + _entryIndexPlusOne[entryId] = list.length; + + emit RefundCredited(recipient, entryId, amount, availableAt, tokenId); + } + + /// @notice Internal helper: delete a refund entry and swap-pop its slot in the recipient's + /// enumeration array. + function _removeRefundEntry(uint256 entryId, address recipient) internal { + uint256 indexPlusOne = _entryIndexPlusOne[entryId]; + // Caller is expected to have validated existence already; defensive check kept cheap. + if (indexPlusOne == 0) return; + + uint256 index = indexPlusOne - 1; + uint256[] storage list = _entriesByRecipient[recipient]; + uint256 lastIndex = list.length - 1; + + if (index != lastIndex) { + uint256 movedEntryId = list[lastIndex]; + list[index] = movedEntryId; + _entryIndexPlusOne[movedEntryId] = indexPlusOne; + } + list.pop(); + + delete _entryIndexPlusOne[entryId]; + delete _refundEntries[entryId]; + } + + /// @inheritdoc IDotnsNameEscrow + function reclaim( + uint256 tokenId, + address newOwner + ) + external + override + onlyController + nonReentrant + { + require(isReclaimable(tokenId), NotReclaimable(tokenId)); + + ReleasePosition storage position = _positions[tokenId]; + address previousRecipient = position.recipient; + + // Settle before deleting: the departing holder keeps their claim on the deposit even though + // they are losing the name. `_settleDeposit` is a no-op for a zero-amount position and for + // one already withdrawn, so the common paths cost nothing extra. + _settleDeposit(position, tokenId, previousRecipient); + + delete _positions[tokenId]; + _removeReleasedToken(tokenId); + + _registrar().safeTransferFrom(address(this), newOwner, tokenId); + + emit NameReclaimed(tokenId, previousRecipient, newOwner); + } + + /// @inheritdoc IDotnsNameEscrow + /// @dev `public` rather than `external` so `reclaim` can gate on it without a self-call, which + /// is what keeps the condition in one place instead of two. + function isReclaimable(uint256 tokenId) public view override returns (bool reclaimable) { + ReleasePosition storage position = _positions[tokenId]; + + // The gate is the elapsed redeem window, not the `claimed` flag. Gating on `claimed` would + // make recyclability depend on the previous holder choosing to withdraw, which strands the + // name whenever they have no reason to: a zero-amount position has nothing to collect, so + // "never withdraws" is the default rather than the exception. The window bounds the wait + // instead, and reclaim settles any unwithdrawn value rather than holding it hostage. + // + // Lifecycle state only. `reclaim` also settles the deposit, which can in principle revert + // `InsufficientFunds` when the reserved balance cannot cover the amount owed, so a true + // answer here is a claim about the window rather than a guarantee + // that the call is funded. The two coincide because `tokenReserved` is by construction the + // exact sum of live position amounts: only `deposit` credits it, and only `_settleDeposit` + // debits it, by exactly the amount it zeroes. `invariant_reserves_match_positions` holds + // that construction, and `invariant_reclaimable_positions_are_fundable` asserts the + // implication directly, so a change that broke the coincidence would fail the suite rather + // than surface as a name advertised and then unregisterable. + reclaimable = position.released && block.timestamp >= position.redeemableUntil; + } + + /// @inheritdoc IDotnsNameEscrow + function redeem(uint256 tokenId) external override nonReentrant { + ReleasePosition storage position = _positions[tokenId]; + + require(position.recipient == msg.sender, NotRefundRecipient(msg.sender, tokenId)); + // One error for the whole state predicate: unreleased, already withdrawn, or past the + // window are all simply "not redeemable" from the caller's point of view, and collapsing + // them avoids leaking a three-way state machine into the revert surface. + require( + position.released && !position.claimed && block.timestamp < position.redeemableUntil, + NotRedeemable(tokenId) + ); + + // Restore the pre-release state and nothing more. Recipient, asset and amount are left + // untouched so the deposit stays locked against the name; clearing the clocks means a later + // release starts a fresh pair rather than inheriting stale deadlines. + position.released = false; + position.withdrawAvailableAt = 0; + position.redeemableUntil = 0; + + _removeReleasedToken(tokenId); + + _registrar().safeTransferFrom(address(this), msg.sender, tokenId); + + emit NameRedeemed(tokenId, msg.sender); + } + + /// @inheritdoc IERC721Receiver + function onERC721Received( + address, + address, + uint256 tokenId, + bytes calldata + ) + external + view + override + returns (bytes4 selector) + { + require(msg.sender == address(_registrar()), NotAcceptedTransfer(msg.sender)); + // Only accept transfers that this contract itself initiated via `release`. A holder calling + // `registrar.safeTransferFrom(holder, escrow, tokenId)` directly would otherwise land the + // NFT in custody with no `released` position, leaving the token (and any prior deposit) + // permanently unreachable through `withdraw` / `reclaim`. + require(_positions[tokenId].released, UnsolicitedDeposit(tokenId)); + selector = IERC721Receiver.onERC721Received.selector; + } + + /// @inheritdoc ERC165Upgradeable + function supportsInterface(bytes4 interfaceId) public view override returns (bool supported) { + supported = interfaceId == type(IDotnsNameEscrow).interfaceId + || interfaceId == type(IERC721Receiver).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 Returns the configured registrar from the protocol registry. + function _registrar() internal view returns (IDotnsRegistrarOld registrar) { + registrar = IDotnsRegistrarOld(protocolRegistry.get(DotnsConstantsOld.REGISTRAR)); + } + + /// @notice Restricts calls to the configured controller from the protocol registry. + function _onlyController() internal view { + address controller = protocolRegistry.get(DotnsConstantsOld.CONTROLLER); + require(msg.sender == controller, NotController(msg.sender)); + } + + /// @notice Restricts calls to the configured registrar from the protocol registry. + function _onlyRegistrar() internal view { + address registrar = protocolRegistry.get(DotnsConstantsOld.REGISTRAR); + require(msg.sender == registrar, NotRegistrar(msg.sender)); + } + + /// @notice Adds a token to the released-token set if absent. + function _addReleasedToken(uint256 tokenId) internal { + if (_releasedIndexPlusOne[tokenId] != 0) return; + + _releasedTokens.push(tokenId); + _releasedIndexPlusOne[tokenId] = _releasedTokens.length; + } + + /// @notice Removes a token from the released-token set if present. + function _removeReleasedToken(uint256 tokenId) internal { + uint256 indexPlusOne = _releasedIndexPlusOne[tokenId]; + if (indexPlusOne == 0) return; + + uint256 index = indexPlusOne - 1; + uint256 lastIndex = _releasedTokens.length - 1; + + if (index != lastIndex) { + uint256 lastTokenId = _releasedTokens[lastIndex]; + _releasedTokens[index] = lastTokenId; + _releasedIndexPlusOne[lastTokenId] = index + 1; + } + + _releasedTokens.pop(); + delete _releasedIndexPlusOne[tokenId]; + } + + /// @inheritdoc UUPSUpgradeable + function _authorizeUpgrade(address newImplementation) internal override onlyOwner {} +} diff --git a/contracts/pop/IDotnsCostModelRegistryOld.sol b/contracts/pop/IDotnsCostModelRegistryOld.sol new file mode 100644 index 000000000..5d1d689a0 --- /dev/null +++ b/contracts/pop/IDotnsCostModelRegistryOld.sol @@ -0,0 +1,87 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.34; + +import {IDotnsPricingOld} from "./IDotnsPricingOld.sol"; + +/// @title DotNS Cost Model Registry +/// @notice Holds every cost model the protocol has run and names the current one. +/// @dev The address registered under `DotnsConstantsOld.COST_MODEL` points here, set once and never +/// repointed. Changing the live curve registers a new model, which adds its version and moves +/// the current pointer. Prior models stay live and priceable by version, so a registration +/// committed against an earlier curve settles at the amount it committed to. +/// @custom:security-contact admin@parity.io +interface IDotnsCostModelRegistryOld { + /// @notice Emitted when a model is registered and becomes current. + /// @param version The model's version identifier. + /// @param model The model address now serving that version. + event CostModelRegistered(uint256 indexed version, address indexed model); + + /// @notice Emitted when the current version is pointed at an already-registered model. + /// @param version The version now serving fresh pricing. + event CurrentModelSet(uint256 indexed version); + + /// @notice Thrown when registering a model whose version is already held. + /// @param version The version already registered. + error AlreadyRegistered(uint256 version); + + /// @notice Thrown when pricing against a version that was never registered. + /// @param version The version with no registered model. + error UnknownVersion(uint256 version); + + /// @notice Thrown when registering a model whose version is zero, which is the sentinel for + /// an unregistered version and so cannot name a real model. + error ZeroVersion(); + + /// @notice Thrown when a registration reveals at a different version than it committed to. + /// @dev Raised where a commit-reveal flow binds a version at commit and checks it at reveal, so + /// the version a name prices at cannot move after the commitment is made. + /// @param committed The version bound when the commitment was made. + /// @param revealed The version supplied at reveal. + error PricingVersionMismatch(uint256 committed, uint256 revealed); + + /// @notice Registers a model and makes it current. + /// @dev Owner-only. Keys the model by its own `version`, so a version can be registered once; + /// a repeat triggers @custom:reverts AlreadyRegistered. Moves the current pointer to the + /// new version and emits @custom:emits CostModelRegistered. + /// @param model The cost model to register. + function register(IDotnsPricingOld model) external; + + /// @notice Points the current version at an already-registered model. + /// @dev Owner-only. Reverts to a previously registered version without redeploying it, so + /// governance can roll fresh pricing back to an earlier curve. @custom:reverts + /// UnknownVersion when no model is registered for `version`. Emits @custom:emits + /// CurrentModelSet. + /// @param version The already-registered version to make current. + function setCurrentVersion(uint256 version) external; + + /// @notice Returns the model registered for a version, or the zero address when none. + /// @param version The version to look up. + /// @return model The model registered for that version. + function modelOf(uint256 version) external view returns (IDotnsPricingOld model); + + /// @notice Returns the version currently serving fresh pricing. + /// @return version The current version identifier. + function currentVersion() external view returns (uint256 version); + + /// @notice Returns the current model. + /// @return model The model serving the current version. + function current() external view returns (IDotnsPricingOld model); + + /// @notice Prices a base length at the current version. + /// @param baseLength Digit-stripped length of the label being priced. + /// @return weiPrice Registration cost in wei at the current version. + function priceForBaseLength(uint256 baseLength) external view returns (uint256 weiPrice); + + /// @notice Prices a base length at a specific version. + /// @dev @custom:reverts UnknownVersion when no model is registered for `version`. + /// @param version The version to price against. + /// @param baseLength Digit-stripped length of the label being priced. + /// @return weiPrice Registration cost in wei at that version. + function priceForBaseLengthAtVersion( + uint256 version, + uint256 baseLength + ) + external + view + returns (uint256 weiPrice); +} diff --git a/contracts/pop/IDotnsPricingOld.sol b/contracts/pop/IDotnsPricingOld.sol new file mode 100644 index 000000000..ad1717da0 --- /dev/null +++ b/contracts/pop/IDotnsPricingOld.sol @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.34; + +/// @title DotNS Pricing Cost Model +/// @notice Prices a registration from the base length of its label alone. +/// @dev The seam between name policy and the wei amount a registration costs. `PopRulesOld` and the +/// public commit-reveal controller keep the classification, reservation, and tier rules; the +/// model owns only the amount for a given base length, so the curve can be swapped by +/// registering a new model under `DotnsConstantsOld.COST_MODEL` without touching either. Only the +/// base length crosses the seam: the model reads no personhood band or `PopStatus`. The public +/// controller prices NoStatus deposits through this same path, so the model carries no PoP +/// name. +/// @custom:security-contact admin@parity.io +interface IDotnsPricingOld { + /// @notice Thrown when a model constructor parameter breaks a pricing invariant. + /// @param reason Human-readable explanation of the failed invariant. + error PricingError(string reason); + + /// @notice Returns the registration cost in wei for a label of the given base length. + /// @dev Pure amount lookup: the caller supplies the digit-stripped base length and the model + /// returns the curve value for it. Runs on the ERC721 transfer floor read, so it stays a + /// view with no state writes. + /// @param baseLength Digit-stripped length of the label being priced. + /// @return weiPrice Registration cost in wei for that base length. + function priceForBaseLength(uint256 baseLength) external view returns (uint256 weiPrice); + + /// @notice Returns a stable identifier for this model and its parameters. + /// @dev Changes when the model shape or its parameters change, so clients and telemetry can + /// tell one live curve from another. Not consulted on the pricing path. + /// @return modelVersion Identifier derived from the model form and its parameters. + function version() external view returns (uint256 modelVersion); +} diff --git a/contracts/pop/IPopRulesOld.sol b/contracts/pop/IPopRulesOld.sol deleted file mode 100644 index be42a98ae..000000000 --- a/contracts/pop/IPopRulesOld.sol +++ /dev/null @@ -1,346 +0,0 @@ -// 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 index 1f3370ee2..0d431f5a5 100644 --- a/contracts/pop/PopRulesOld.sol +++ b/contracts/pop/PopRulesOld.sol @@ -10,27 +10,28 @@ 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 {SystemUtilsOld} from "../utils/SystemUtilsOld.sol"; +import {IPopRules} from "./IPopRules.sol"; +import {IDotnsCostModelRegistryOld} from "./IDotnsCostModelRegistryOld.sol"; +import {IDotnsProtocolRegistryOld} from "../registry/IDotnsProtocolRegistryOld.sol"; import {IDotnsController} from "../registrars/IDotnsController.sol"; -import {DotnsRegistrar} from "../registrars/DotnsRegistrar.sol"; -import {DotnsConstants} from "../utils/DotnsConstants.sol"; +import {DotnsRegistrarOld} from "../registrars/DotnsRegistrarOld.sol"; +import {DotnsConstantsOld} from "../utils/DotnsConstantsOld.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. +/// @dev Tiers are set by base length. Every label is measured as written, except a lite label, +/// whose separator and allocated digits are not part of the name the candidate chose, so +/// `joseph.42` measures six and `joseph42` measures eight. +/// Base lengths <= 5 are governance-reserved, base lengths 6-8 require PopFull, and base +/// lengths >= 9 are open to any caller as NoStatus. PopLite is the separated form alone: a +/// digit suffix on an ordinary label says nothing about personhood. /// 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 +/// model registered under `DotnsConstantsOld.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 +/// paid path until Root sets `shortNamesEnabled`; the gateway and registerReserved do /// not consult it. /// @custom:security-contact admin@parity.io contract PopRulesOld is @@ -38,18 +39,18 @@ contract PopRulesOld is UUPSUpgradeable, OwnableUpgradeable, ERC165Upgradeable, - IPopRulesOld + IPopRules { using StringUtils for *; - /// @notice Active reservations keyed by digit-stripped base name. + /// @notice Active reservations keyed by stem. 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; + IDotnsProtocolRegistryOld public protocolRegistry; /// @notice Whether the public paid path may register names shorter than nine characters. /// Closed by default; only governance opens it. @@ -72,32 +73,36 @@ contract PopRulesOld is /// @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. + /// registered under `DotnsConstantsOld.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 { + function initialize(IDotnsProtocolRegistryOld registry) public initializer { __Ownable_init(msg.sender); __ERC165_init(); protocolRegistry = registry; } - /// @inheritdoc IPopRulesOld - function setShortNamesEnabled(bool enabled) external override onlyOwner { + /// @inheritdoc IPopRules + function setShortNamesEnabled(bool enabled) external override { + // Opening the short-name band to the public path is a governance decision, so it is gated + // on a substrate Root origin rather than the owner. `msg.sender` is deliberately not read: + // a Root origin has no account behind it, so reading it would trap. + require(SystemUtilsOld.originIsRoot(), NotRoot()); shortNamesEnabled = enabled; emit ShortNamesEnabledUpdated(enabled); } - /// @inheritdoc IPopRulesOld + /// @inheritdoc IPopRules function classifyName(string calldata name) external pure override returns (PopStatus requirement, string memory message) { - _requireCanonicalLabel(name); + _requireLabel(name); (requirement, message,) = _classifyValidatedName(name); } - /// @inheritdoc IPopRulesOld + /// @inheritdoc IPopRules function reserveBaseName( string calldata stem, address userAddress @@ -106,7 +111,7 @@ contract PopRulesOld is override onlyRegistry { - _requireCanonicalLabel(stem); + _requireStem(stem); uint256 stemLength = bytes(stem).length; require( stemLength >= 6 && stemLength <= 8 && _countTrailingDigits(stem) == 0, @@ -115,38 +120,38 @@ contract PopRulesOld is _writeReservation(stem, userAddress); } - /// @inheritdoc IPopRulesOld + /// @inheritdoc IPopRules function isBaseName(string calldata baseName) external pure override returns (bool isBase) { - _requireCanonicalLabel(baseName); + _requireLabel(baseName); uint256 digits = _countTrailingDigits(baseName); return digits == 0; } - /// @inheritdoc IPopRulesOld + /// @inheritdoc IPopRules function getBaseNameReservation(string calldata baseName) external view override returns (address reservationOwner, uint64 expiryTimestamp) { - _requireCanonicalLabel(baseName); + _requireStem(baseName); Reservation memory reserved = reservations[baseName]; return (reserved.owner, reserved.expires); } - /// @inheritdoc IPopRulesOld + /// @inheritdoc IPopRules function isBaseNameReserved(string calldata baseName) external view override returns (bool isReserved, address reservationOwner, uint64 expiryTimestamp) { - _requireCanonicalLabel(baseName); + _requireStem(baseName); Reservation memory reservation = reservations[baseName]; return (_isLive(reservation), reservation.owner, reservation.expires); } - /// @inheritdoc IPopRulesOld + /// @inheritdoc IPopRules function priceWithCheck( string calldata name, address userAddress @@ -159,7 +164,7 @@ contract PopRulesOld is return _priceWithCheck(name, userAddress, false, 0); } - /// @inheritdoc IPopRulesOld + /// @inheritdoc IPopRules function priceWithCheckAtVersion( string calldata name, address userAddress, @@ -173,7 +178,7 @@ contract PopRulesOld is return _priceWithCheck(name, userAddress, true, pricingVersionValue); } - /// @inheritdoc IPopRulesOld + /// @inheritdoc IPopRules function priceWithoutCheck( string calldata name, address userAddress @@ -186,7 +191,7 @@ contract PopRulesOld is return _priceWithoutCheck(name, userAddress, false, 0); } - /// @inheritdoc IPopRulesOld + /// @inheritdoc IPopRules function priceWithoutCheckAtVersion( string calldata name, address userAddress, @@ -214,7 +219,7 @@ contract PopRulesOld is view returns (PriceWithMeta memory metadata) { - _requireCanonicalLabel(name); + _requireLabel(name); _enforceReservationRules(name, userAddress); (PopStatus requiredStatus, string memory classification, uint256 baseLength) = @@ -249,7 +254,7 @@ contract PopRulesOld is view returns (PriceWithMeta memory metadata) { - _requireCanonicalLabel(name); + _requireLabel(name); (PopStatus requiredStatus, string memory classification, uint256 baseLength) = _classifyValidatedName(name); @@ -268,24 +273,24 @@ contract PopRulesOld is if (_isLive(reservation) && reservation.owner != userAddress) { metadata.message = "Base name reserved for original Lite registrant"; - metadata.status = IPopRulesOld.PopStatus.Reserved; + metadata.status = IPopRules.PopStatus.Reserved; } return metadata; } - /// @inheritdoc IPopRulesOld + /// @inheritdoc IPopRules function price(string calldata name) external view override returns (uint256) { - _requireCanonicalLabel(name); + _requireLabel(name); return _priceValidatedName(_validatedBaseLength(name)); } - /// @inheritdoc IPopRulesOld + /// @inheritdoc IPopRules function pricingVersion() external view override returns (uint256 modelVersion) { return _costModelRegistry().currentVersion(); } - /// @inheritdoc IPopRulesOld + /// @inheritdoc IPopRules function transferFloor( string calldata name, address from, @@ -296,7 +301,7 @@ contract PopRulesOld is override returns (uint256 floor) { - _requireCanonicalLabel(name); + _requireLabel(name); if (from == to) return 0; (PopStatus required,, uint256 baseLength) = _classifyValidatedName(name); uint256 ownPrice = _priceValidatedName(baseLength); @@ -312,7 +317,7 @@ contract PopRulesOld is return reachComponent > downgradeComponent ? reachComponent : downgradeComponent; } - /// @inheritdoc IPopRulesOld + /// @inheritdoc IPopRules function personhoodOf(address account) external view override returns (PopStatus tier) { return _personhoodTier(account); } @@ -325,8 +330,8 @@ contract PopRulesOld is /// 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); + IPersonhood.PersonhoodInfo memory info = IPersonhood(DotnsConstantsOld.PERSONHOOD) + .personhoodStatus(account, DotnsConstantsOld.PERSONHOOD_CONTEXT); if (info.status == 2) return PopStatus.PopFull; if (info.status == 1) return PopStatus.PopLite; return PopStatus.NoStatus; @@ -367,13 +372,13 @@ contract PopRulesOld is return _costModelRegistry().priceForBaseLengthAtVersion(pricingVersionValue, baseLength); } - /// @notice Resolves the cost-model registry registered under `DotnsConstants.COST_MODEL`. + /// @notice Resolves the cost-model registry registered under `DotnsConstantsOld.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); + function _costModelRegistry() private view returns (IDotnsCostModelRegistryOld registry) { + address configured = protocolRegistry.get(DotnsConstantsOld.COST_MODEL); require(configured != address(0), PopError("Cost model not configured")); - return IDotnsCostModelRegistry(configured); + return IDotnsCostModelRegistryOld(configured); } /// @notice Reverts a public paid registration of a base length below nine while the short-name @@ -386,17 +391,26 @@ contract PopRulesOld is 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. + /// @notice Index one past `name`'s stem: a lite label without its suffix, or the whole of + /// any other label. + /// @dev Only a lite label has a suffix to remove. The gateway allocates those two digits to + /// tell apart people who chose the same stem, so removing them recovers what the + /// candidate actually picked. No such allocation stands behind the digits in an + /// ordinary label, where they are part of the name: `web3` is a four-character word, + /// not `web` with a counter. + function _stemEnd(string calldata name) private pure returns (uint256 stemEnd) { + stemEnd = bytes(name).length; + if (!name.isLitePersonLabel()) return stemEnd; + return stemEnd - StringUtils.LITE_SUFFIX_DIGITS - 1; + } + + /// @notice The base length that pricing and classification both use to place a name in its + /// band, which is the length of the name's stem. + /// @dev Every label is measured as written, except a lite label, whose allocated suffix is + /// not part of the name the candidate chose. So `web3` and `blink182` are measured + /// whole and no digit count is privileged or rejected. 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; + return _stemEnd(name); } /// @notice Enforces base-name reservation rules. @@ -437,13 +451,16 @@ contract PopRulesOld is } } - /// @notice Strips trailing digits from a name. + /// @notice Returns `name`'s stem: a lite label without its allocated suffix, or any other + /// label verbatim. + /// @dev The reservation key. Because only a lite label is shortened, `joseph.42` contends + /// with `joseph` while `joseph42` is an unrelated name and contends with nothing. /// @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); + uint256 endPosition = _stemEnd(name); - // No trailing digits to strip: return the input verbatim and skip the manual copy. + // No suffix to strip: return the input verbatim and skip the manual copy. if (endPosition == bytesName.length) return name; bytes memory output = new bytes(endPosition); @@ -460,25 +477,44 @@ contract PopRulesOld is 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) { + // PopLite is the gateway's separated form. Digits in an ordinary label say nothing + // about personhood, so such a label sits in the band its length earns. + if (name.isLitePersonLabel()) { 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. + // Base length >= 9 is open to any caller, and is reached by a lite label whose stem is + // nine or more through the gateway. 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")); + /// @notice Requires `stem` to be a canonical DNS label, carrying no separator. + /// @dev Reservation keys are stems, so a separator here is a caller error rather than a lite + /// name. A digit suffix passes this check, because a DNS label admits digits; the entry + /// points that write a reservation reject one themselves. + /// @custom:function _requireLabel is the guard for full labels. + function _requireStem(string calldata stem) internal pure { + require(stem.isSingleLabel(), PopError("Name must be lowercase ASCII DNS label")); + } + + /// @notice Requires `name` to be a label DotNS can issue: a canonical DNS label, or a lite + /// label carrying its separator. + /// @dev The union is the full set of issuable labels, so a near miss such as `alice.4` or + /// `a.b.42` still reverts. @custom:function _requireStem is the stricter guard for + /// reservation keys, which never carry a separator. + function _requireLabel(string calldata name) internal pure { + require( + name.isSingleLabel() || name.isLitePersonLabel(), + PopError("Name must be a lowercase ASCII DNS label or a lite label") + ); } /// @inheritdoc ERC165Upgradeable @@ -489,7 +525,7 @@ contract PopRulesOld is override returns (bool supported) { - return interfaceId == type(IPopRulesOld).interfaceId || super.supportsInterface(interfaceId); + return interfaceId == type(IPopRules).interfaceId || super.supportsInterface(interfaceId); } /// @inheritdoc UUPSUpgradeable @@ -502,11 +538,11 @@ contract PopRulesOld is /// @notice Ensures the caller is any controller authorised on the registrar. function _onlyRegistry() internal view { - DotnsRegistrar registrar = DotnsRegistrar(protocolRegistry.get(DotnsConstants.REGISTRAR)); + DotnsRegistrarOld registrar = DotnsRegistrarOld(protocolRegistry.get(DotnsConstantsOld.REGISTRAR)); require(registrar.controllers(IDotnsController(msg.sender)), NotRegistry()); } - /// @inheritdoc IPopRulesOld + /// @inheritdoc IPopRules function reserveBaseNameForPop( string calldata stem, address userAddress @@ -515,7 +551,7 @@ contract PopRulesOld is override onlyRegistry { - _requireCanonicalLabel(stem); + _requireStem(stem); require( _countTrailingDigits(stem) == 0, PopError("Reservation stem must have no trailing digits") @@ -523,15 +559,15 @@ contract PopRulesOld is _writeReservation(stem, userAddress); } - /// @inheritdoc IPopRulesOld + /// @inheritdoc IPopRules function stripDigits(string calldata name) external pure override returns (string memory stem) { - _requireCanonicalLabel(name); + _requireLabel(name); return _stripDigits(name); } - /// @inheritdoc IPopRulesOld + /// @inheritdoc IPopRules function releaseBaseName(string calldata stem) external override onlyRegistry { - _requireCanonicalLabel(stem); + _requireStem(stem); require( _countTrailingDigits(stem) == 0, PopError("Reservation stem must have no trailing digits") @@ -551,7 +587,7 @@ contract PopRulesOld is emit BaseNameReleased(stem); } - /// @inheritdoc IPopRulesOld + /// @inheritdoc IPopRules function releaseReservationForReclaim( string calldata stem, address expectedOwner @@ -560,7 +596,7 @@ contract PopRulesOld is override onlyRegistry { - _requireCanonicalLabel(stem); + _requireStem(stem); require( _countTrailingDigits(stem) == 0, PopError("Reservation stem must have no trailing digits") diff --git a/contracts/registrars/DotnsPopControllerOld.sol b/contracts/registrars/DotnsPopControllerOld.sol index 42c4a4b5d..d6bca3a2f 100644 --- a/contracts/registrars/DotnsPopControllerOld.sol +++ b/contracts/registrars/DotnsPopControllerOld.sol @@ -13,30 +13,30 @@ 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 {IDotnsPopController} from "./IDotnsPopController.sol"; +import {IDotnsRegistrarOld} from "./IDotnsRegistrarOld.sol"; +import {IDotnsProtocolRegistryOld} from "../registry/IDotnsProtocolRegistryOld.sol"; import {IDotnsPopResolver} from "../resolvers/IDotnsPopResolver.sol"; import {IPopRules} from "../pop/IPopRules.sol"; -import {IStoreFactory} from "../store/IStoreFactory.sol"; +import {IStoreFactoryOld} from "../store/IStoreFactoryOld.sol"; import {ILabelStore} from "../store/ILabelStore.sol"; import {LabelUtils} from "../utils/LabelUtils.sol"; -import {RegistrationUtils} from "../utils/RegistrationUtils.sol"; +import {RegistrationUtilsOld} from "../utils/RegistrationUtilsOld.sol"; +import {SubnodeUtilsOld} from "../utils/SubnodeUtilsOld.sol"; +import {IDotnsRegistryOld} from "../registry/IDotnsRegistryOld.sol"; import {StringUtils} from "../utils/StringUtils.sol"; -import {DotnsConstants} from "../utils/DotnsConstants.sol"; -import {SystemUtils} from "../utils/SystemUtils.sol"; +import {DotnsConstantsOld} from "../utils/DotnsConstantsOld.sol"; +import {SystemUtilsOld} from "../utils/SystemUtilsOld.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` +/// issuance on behalf of the PoP gateway. +/// @dev Lives behind its own UUPS proxy with its own storage. Registered on `DotnsRegistrarOld` /// 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, @@ -49,22 +49,24 @@ import {SystemUtils} from "../utils/SystemUtils.sol"; /// 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. +/// (1) Lite-person labels (`stem.NN`) occupy a namespace the public path cannot reach: the +/// separator is legal only on a lite label, and the public path rejects it, so no public +/// registration can spell one. A digit suffix is not exclusive, but an ordinary label carrying +/// one is measured as written and so is simply a different name. The two flows therefore cannot +/// contend for the same label. This holds of labels the contracts minted, not of an arbitrary +/// string: a subname stored under a digit-only parent reads the same way, which is why +/// provenance is published through @custom:function isPopIssued rather than inferred. /// (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 +/// gateway reservations without ever importing this contract. PopRulesOld 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 +/// forward-registry + store-write triad via @custom:contract RegistrationUtilsOld; 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. @@ -74,7 +76,7 @@ contract DotnsPopControllerOld is UUPSUpgradeable, OwnableUpgradeable, ERC165Upgradeable, - IDotnsPopControllerOld + IDotnsPopController { using StringUtils for *; using EnumerableSet for EnumerableSet.AddressSet; @@ -95,7 +97,7 @@ contract DotnsPopControllerOld is uint256 private constant CHAT_KEY_LENGTH = 65; /// @notice Protocol-level address registry for all DotNS contracts. - IDotnsProtocolRegistry public protocolRegistry; + IDotnsProtocolRegistryOld public protocolRegistry; /// @notice Per-label queue metadata (head/tail pointers). mapping(bytes32 labelhash => ReservationQueueMeta meta) internal _reservationMeta; @@ -109,16 +111,16 @@ contract DotnsPopControllerOld is /// 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 + /// @notice Remembers the base-label string for each reserved labelhash so the PopRulesOld + /// sync path can address the reservation by its original string form (PopRulesOld 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; + /// only to bridge the queue's `bytes32` key space to PopRulesOld' `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 + /// @dev Sets the reservation duration, configurable by /// governance via `setReservationDuration`. uint64 public override reservationDuration; @@ -128,16 +130,25 @@ contract DotnsPopControllerOld 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; - /// @dev Reserved storage space to allow for layout changes in future upgrades. - uint256[50] private __gap; + /// @notice Labels this controller minted, keyed by the bare label without the TLD. + /// @dev Provenance, not a transfer rule: written once at mint and never cleared, so it + /// stays true if a name later becomes transferable. Keyed by the label text rather + /// than the node because a reader holding only `joseph.42` cannot derive the node + /// without first deciding whether the separator is part of the label or a subname + /// boundary, which is the question it is asking. + mapping(string label => bool issued) internal _popIssued; - /// @notice Restricts calls to a substrate Root origin. + /// @dev Reserved storage space to allow for layout changes in future upgrades. The + /// `_popIssued` mapping consumes one of the reserved slots, so the gap holds 49. + uint256[49] private __gap; + + /// @notice Restricts calls to a Root origin. modifier onlyRoot() { _onlyRoot(); _; @@ -155,7 +166,7 @@ contract DotnsPopControllerOld is /// Emits @custom:emits ReservationDurationSet so indexers observe the initial value /// through the same event the setter uses later. function initialize( - IDotnsProtocolRegistry registry, + IDotnsProtocolRegistryOld registry, uint64 reservationDuration_ ) external @@ -172,12 +183,17 @@ contract DotnsPopControllerOld is emit ReservationDurationSet(reservationDuration_); } - /// @inheritdoc IDotnsPopControllerOld + /// @inheritdoc IDotnsPopController + function isPopIssued(string calldata label) external view override returns (bool issued) { + return _popIssued[label]; + } + + /// @inheritdoc IDotnsPopController function reserveLiteName(LiteRegistration calldata params) external override onlyRoot { _reserveLite(_popRules(), params); } - /// @inheritdoc IDotnsPopControllerOld + /// @inheritdoc IDotnsPopController function reserveBaseName(BaseReservation calldata params) external override onlyRoot { IPopRules rules = _popRules(); bytes32 reservedHash; @@ -195,7 +211,7 @@ contract DotnsPopControllerOld is } } - /// @inheritdoc IDotnsPopControllerOld + /// @inheritdoc IDotnsPopController function reserveBaseNameOnly(BaseNameReservation calldata params) external override onlyRoot { IPopRules rules = _popRules(); (bytes32 reservedHash,) = _validateReservableBaseLabel(rules, params.reservedBaseLabel); @@ -207,37 +223,40 @@ contract DotnsPopControllerOld is /// @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`. + /// precompile is not consulted. The label is stored in the `stem.NN` form the gateway sends, + /// 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 decodes by selector; letting + /// `classifyName` catch it instead would surface an undecodable PopRulesOld 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` + /// 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); + (IPopRules.PopStatus required,) = rules.classifyName(params.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); + (bytes32 labelhash, bytes32 node) = _validateLiteLabel(params.liteLabel); _completeGatewayRegistration( - params.user, liteLabel, labelhash, node, params.chatKey, bytes32(0) + params.user, params.liteLabel, labelhash, node, params.chatKey, bytes32(0) ); - emit LiteNameReserved(labelhash, params.user, liteLabel); + emit LiteNameReserved(labelhash, params.user, params.liteLabel); } - /// @inheritdoc IDotnsPopControllerOld + /// @inheritdoc IDotnsPopController function registerBaseName(FullRegistration calldata params) external override onlyRoot { Link calldata link = params.link; address user = params.user; string calldata label = params.label; + (bytes32 labelhash, bytes32 node) = _validateBaseLabel(label); + IPopRules rules = _popRules(); (IPopRules.PopStatus required,) = rules.classifyName(label); require( @@ -245,16 +264,13 @@ contract DotnsPopControllerOld is 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`. + // PopRulesOld slot via head-advance, any remaining live slot was written by a sibling + // controller. Reject when held by another user so PopRulesOld stays the single + // cross-flow authority in both directions; the public flow reads this slot through + // `priceWithCheck` and writes none of its own. (bool slotLive, address slotOwner,) = rules.isBaseNameReserved(label); require(!slotLive || slotOwner == user, NotHolder(user, labelhash)); @@ -285,12 +301,11 @@ contract DotnsPopControllerOld is 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(); + (liteLabelhash, liteNode) = _validateLiteLabel(link.liteLabel); + // 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 { @@ -310,13 +325,13 @@ contract DotnsPopControllerOld is } } - /// @inheritdoc IDotnsPopControllerOld + /// @inheritdoc IDotnsPopController function expireReservation(string calldata reservedBaseLabel) external override { (bytes32 labelhash,) = _validateBaseLabel(reservedBaseLabel); _advanceExpiredHead(labelhash); } - /// @inheritdoc IDotnsPopControllerOld + /// @inheritdoc IDotnsPopController function relinquishReservation() external override { UserReservation memory userRes = _userReservations[msg.sender]; require(userRes.labelhash != bytes32(0), NoActiveReservation(msg.sender)); @@ -324,12 +339,12 @@ contract DotnsPopControllerOld is emit ReservationRelinquished(userRes.labelhash, msg.sender); } - /// @inheritdoc IDotnsPopControllerOld + /// @inheritdoc IDotnsPopController function claimLabelStore() external override returns (bool moreRemaining) { - (, moreRemaining) = _settlePending(msg.sender, DotnsConstants.MAX_PAGE_SIZE); + (, moreRemaining) = _settlePending(msg.sender, DotnsConstantsOld.MAX_PAGE_SIZE); } - /// @inheritdoc IDotnsPopControllerOld + /// @inheritdoc IDotnsPopController function settlePendingClaims( address user, uint256 limit @@ -352,7 +367,7 @@ contract DotnsPopControllerOld is internal returns (uint256 settledCount, bool moreRemaining) { - IStoreFactory factory = _storeFactory(); + IStoreFactoryOld factory = _storeFactory(); address store = factory.getLabelStore(user); PendingClaim[] storage queue = _pendingClaimQueue[user]; @@ -381,7 +396,7 @@ contract DotnsPopControllerOld is /// 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, + IStoreFactoryOld factory, address store, address user, string memory label @@ -390,7 +405,11 @@ contract DotnsPopControllerOld 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); } @@ -400,7 +419,7 @@ contract DotnsPopControllerOld is return store; } - /// @inheritdoc IDotnsPopControllerOld + /// @inheritdoc IDotnsPopController function isReservedForClaim(string calldata reservedBaseLabel) external view @@ -418,14 +437,14 @@ contract DotnsPopControllerOld is return (true, head.owner); } - /// @inheritdoc IDotnsPopControllerOld + /// @inheritdoc IDotnsPopController function setReservationDuration(uint64 duration) external override onlyOwner { require(duration >= MIN_RESERVATION_DURATION, ReservationDurationTooLow(duration)); reservationDuration = duration; emit ReservationDurationSet(duration); } - /// @inheritdoc IDotnsPopControllerOld + /// @inheritdoc IDotnsPopController function reservationMeta(bytes32 labelhash) external view @@ -436,7 +455,7 @@ contract DotnsPopControllerOld is return (meta.head, meta.tail); } - /// @inheritdoc IDotnsPopControllerOld + /// @inheritdoc IDotnsPopController function reservationEntry( bytes32 labelhash, uint64 index @@ -450,7 +469,7 @@ contract DotnsPopControllerOld is return (entry.owner, entry.joinedAt); } - /// @inheritdoc IDotnsPopControllerOld + /// @inheritdoc IDotnsPopController function userReservation(address user) external view @@ -460,7 +479,7 @@ contract DotnsPopControllerOld is return _userReservations[user]; } - /// @inheritdoc IDotnsPopControllerOld + /// @inheritdoc IDotnsPopController function pendingClaims( address user, uint256 offset, @@ -477,7 +496,7 @@ contract DotnsPopControllerOld is uint256 available = total - offset; uint256 count = limit < available ? limit : available; - if (count > DotnsConstants.MAX_PAGE_SIZE) count = DotnsConstants.MAX_PAGE_SIZE; + if (count > DotnsConstantsOld.MAX_PAGE_SIZE) count = DotnsConstantsOld.MAX_PAGE_SIZE; claims = new PendingClaim[](count); for (uint256 i; i < count; ++i) { @@ -485,17 +504,17 @@ contract DotnsPopControllerOld is } } - /// @inheritdoc IDotnsPopControllerOld + /// @inheritdoc IDotnsPopController function pendingClaimCountOf(address user) external view override returns (uint256 count) { return _pendingClaimQueue[user].length; } - /// @inheritdoc IDotnsPopControllerOld + /// @inheritdoc IDotnsPopController function pendingClaimUserCount() external view override returns (uint256 count) { return _pendingClaimUsers.length(); } - /// @inheritdoc IDotnsPopControllerOld + /// @inheritdoc IDotnsPopController function pendingClaimUsers( uint256 offset, uint256 limit @@ -510,7 +529,7 @@ contract DotnsPopControllerOld is uint256 available = total - offset; uint256 count = limit < available ? limit : available; - if (count > DotnsConstants.MAX_PAGE_SIZE) count = DotnsConstants.MAX_PAGE_SIZE; + if (count > DotnsConstantsOld.MAX_PAGE_SIZE) count = DotnsConstantsOld.MAX_PAGE_SIZE; users = new address[](count); for (uint256 i; i < count; ++i) { @@ -518,7 +537,7 @@ contract DotnsPopControllerOld is } } - /// @inheritdoc IDotnsPopControllerOld + /// @inheritdoc IDotnsPopController function reservedBaseLabelOf(bytes32 labelhash) external view @@ -535,7 +554,7 @@ contract DotnsPopControllerOld is override(ERC165Upgradeable, IERC165) returns (bool) { - return interfaceId == type(IDotnsPopControllerOld).interfaceId + return interfaceId == type(IDotnsPopController).interfaceId || super.supportsInterface(interfaceId); } @@ -549,10 +568,10 @@ contract DotnsPopControllerOld is /// 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 + /// @custom:function RegistrationUtilsOld.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 @@ -570,15 +589,40 @@ contract DotnsPopControllerOld is ) internal { - RegistrationUtils.registerAndStore( - RegistrationUtils.RegistrationContext({ - protocolRegistry: protocolRegistry, - user: user, - label: "", - labelhash: labelhash, - node: node - }) - ); + _popIssued[label] = true; + + // 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 = SubnodeUtilsOld.registerSubname( + SubnodeUtilsOld.SubnameContext({ + protocolRegistry: protocolRegistry, + parentLabel: suffix, + subLabel: stem, + owner: user, + persist: false + }) + ); + } else { + RegistrationUtilsOld.registerAndStore( + RegistrationUtilsOld.RegistrationContext({ + protocolRegistry: protocolRegistry, + user: user, + label: "", + labelhash: labelhash, + node: node + }) + ); + } if (chatKeyBytes.length != 0 || liteLabelhash != bytes32(0)) { IDotnsPopResolver resolver = _popResolver(); @@ -606,15 +650,17 @@ contract DotnsPopControllerOld 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 label Bare DNS label (no TLD); the TLD is appended on write. + /// @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 { 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 + /// @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. @@ -635,7 +681,7 @@ contract DotnsPopControllerOld is /// @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 + /// base name on PopRulesOld 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( @@ -669,9 +715,9 @@ contract DotnsPopControllerOld is } /// @notice Wipes the entire reservation queue for `labelhash` and releases the - /// corresponding PopRules reservation. + /// corresponding PopRulesOld 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 + /// per-user tracking state is cleared, and PopRulesOld 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 { @@ -689,8 +735,8 @@ contract DotnsPopControllerOld is /// @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 + /// is deleted AND the PopRulesOld base-name slot is released, so the public commit-reveal + /// flow can register the label again. When a new live head emerges, PopRulesOld 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 { @@ -727,7 +773,7 @@ contract DotnsPopControllerOld is /// @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 + /// freshly-deleted slot, and its `head != meta.head` branch fires the PopRulesOld 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 { @@ -746,7 +792,12 @@ contract DotnsPopControllerOld is } } - /// @notice Validates a lite-person `NAMEXX` label and derives `(labelhash, node)`. + /// @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. `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 @@ -754,28 +805,45 @@ contract DotnsPopControllerOld is { require(liteLabel.isLitePersonLabelMemory(), InvalidLiteLabel()); labelhash = LabelUtils.labelhashMemory(liteLabel); - node = LabelUtils.namehashUnder(protocolRegistry.tldNode(), labelhash); + node = _liteSubnode(liteLabel); } - /// @notice Validates a base (full-person) DNS label and derives `(labelhash, node)`. + /// @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 = SubnodeUtilsOld.liteSubnodeOf(protocolRegistry.tldNode(), liteLabel); + } + + /// @notice Validates a base (full-person) label and derives `(labelhash, node)`. + /// @dev Letters only, so this is stricter than a DNS label: a hyphen or an interior digit + /// is rejected here even though @custom:function StringUtils.isSingleLabel would admit it. function _validateBaseLabel(string calldata baseLabel) internal view returns (bytes32 labelhash, bytes32 node) { - require(baseLabel.isSingleLabel(), InvalidBaseLabel()); + // 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. + require(baseLabel.isPersonLabel(), 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. + /// label must classify outside the governance-reserved tier and be a base name, be a + /// letters-only person 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 hold the stem, and so every lite name built + /// on it, 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 @@ -784,12 +852,13 @@ contract DotnsPopControllerOld is view returns (bytes32 labelhash, bytes32 node) { + (labelhash, node) = _validateBaseLabel(baseLabel); + (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()); } @@ -804,29 +873,34 @@ contract DotnsPopControllerOld is /// @notice Resolves the PoP resolver via the protocol registry. function _popResolver() internal view returns (IDotnsPopResolver) { - return IDotnsPopResolver(protocolRegistry.get(DotnsConstants.POP_RESOLVER)); + return IDotnsPopResolver(protocolRegistry.get(DotnsConstantsOld.POP_RESOLVER)); } - /// @notice Resolves the PopRules contract via the protocol registry. + /// @notice Resolves the PopRulesOld contract via the protocol registry. function _popRules() internal view returns (IPopRules) { - return IPopRules(protocolRegistry.get(DotnsConstants.POP_RULES)); + return IPopRules(protocolRegistry.get(DotnsConstantsOld.POP_RULES)); } /// @notice Resolves the Store factory via the protocol registry. - function _storeFactory() internal view returns (IStoreFactory) { - return IStoreFactory(protocolRegistry.get(DotnsConstants.STORE_FACTORY)); + function _storeFactory() internal view returns (IStoreFactoryOld) { + return IStoreFactoryOld(protocolRegistry.get(DotnsConstantsOld.STORE_FACTORY)); } /// @notice Resolves the registrar via the protocol registry. - function _registrar() internal view returns (IDotnsRegistrar) { - return IDotnsRegistrar(protocolRegistry.get(DotnsConstants.REGISTRAR)); + function _registrar() internal view returns (IDotnsRegistrarOld) { + return IDotnsRegistrarOld(protocolRegistry.get(DotnsConstantsOld.REGISTRAR)); + } + + /// @notice Resolves the registry via the protocol registry. + function _registry() internal view returns (IDotnsRegistryOld) { + return IDotnsRegistryOld(protocolRegistry.get(DotnsConstantsOld.REGISTRY)); } - /// @notice Writes the new head of the queue into PopRules so the public commit-reveal flow + /// @notice Writes the new head of the queue into PopRulesOld 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 + /// write the slot). The release-then-reserve pair satisfies PopRulesOld' ownership gate on /// `reserveBaseNameForPop`. function _syncPopRulesToHead(bytes32 labelhash, address newHead) internal { string memory baseLabel = _reservedBaseLabel[labelhash]; @@ -836,7 +910,7 @@ contract DotnsPopControllerOld is emit ReservationHeadAdvanced(labelhash, newHead); } - /// @notice Clears the PopRules slot and the local label bookkeeping when the queue empties + /// @notice Clears the PopRulesOld 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]; @@ -845,12 +919,20 @@ contract DotnsPopControllerOld is delete _reservedBaseLabel[labelhash]; } - /// @notice Internal check enforcing a substrate Root origin. - /// @dev Authorises a call when @custom:function SystemUtils.originIsRoot is true, and + /// @notice Internal check enforcing a Root origin. + /// @dev Authorises a call when @custom:function SystemUtilsOld.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. + /// Root origin has no account behind it, so reading `msg.sender` traps. That holds + /// for this frame and any delegatecall sharing it; a nested call sees the calling + /// contract as its sender and reads normally. + /// + /// The check also holds for the whole Root transaction rather than the entry frame + /// alone, so nothing reachable from an onlyRoot entrypoint may call a + /// user-controlled address: such a callee could re-enter a gated function and still + /// pass. Every call out of this contract goes to a protocol contract resolved + /// through the registry. function _onlyRoot() internal view { - require(SystemUtils.originIsRoot(), NotRoot()); + require(SystemUtilsOld.originIsRoot(), NotRoot()); } /// @inheritdoc UUPSUpgradeable diff --git a/contracts/registrars/DotnsRegistrarControllerOld.sol b/contracts/registrars/DotnsRegistrarControllerOld.sol new file mode 100644 index 000000000..23afb9243 --- /dev/null +++ b/contracts/registrars/DotnsRegistrarControllerOld.sol @@ -0,0 +1,482 @@ +// 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 {ReentrancyGuardTransient} from "@openzeppelin/contracts/utils/ReentrancyGuardTransient.sol"; + +import {IDotnsRegistrarOld} from "./IDotnsRegistrarOld.sol"; +import {IDotnsReverseResolver} from "../resolvers/IDotnsReverseResolver.sol"; +import {IPopRules} from "../pop/IPopRules.sol"; +import {IDotnsCostModelRegistryOld} from "../pop/IDotnsCostModelRegistryOld.sol"; +import {StringUtils} from "../utils/StringUtils.sol"; +import {IDotnsRegistrarController} from "./IDotnsRegistrarController.sol"; +import {IDotnsNameEscrow} from "../escrow/IDotnsNameEscrow.sol"; +import {IDotnsNameWhitelist} from "../whitelist/IDotnsNameWhitelist.sol"; +import {IStoreFactoryOld} from "../store/IStoreFactoryOld.sol"; +import {IDotnsProtocolRegistryOld} from "../registry/IDotnsProtocolRegistryOld.sol"; +import {IDotnsRegistryOld} from "../registry/IDotnsRegistryOld.sol"; +import {DotnsConstantsOld} from "../utils/DotnsConstantsOld.sol"; +import {LabelUtils} from "../utils/LabelUtils.sol"; +import {RegistrationUtilsOld} from "../utils/RegistrationUtilsOld.sol"; +import {StoreUtilsOld} from "../utils/StoreUtilsOld.sol"; +import {SystemUtilsOld} from "../utils/SystemUtilsOld.sol"; + +/// @title Dotns Registrar Controller +/// @notice Allocates top-level labels using a commit reveal scheme. +/// @dev Orchestrates allocation, PoP validation, pricing enforcement, forward registry +/// wiring, default reverse resolution, and immutable store writing. +/// +/// Tokenisation: the minted ERC721 tokenId is `uint256(node)`, where +/// `node = namehash(tldNode, labelhash)`. The registry stores a sentinel owner +/// (`address(0)`) for tokenised nodes and derives ownership from the ERC721 registrar for +/// authorisation. +/// @custom:security-contact admin@parity.io +contract DotnsRegistrarControllerOld is + Initializable, + UUPSUpgradeable, + OwnableUpgradeable, + ERC165Upgradeable, + ReentrancyGuardTransient, + IDotnsRegistrarController +{ + using StringUtils for *; + using StoreUtilsOld for IStoreFactoryOld; + + /// @notice One role's membership and its admin role. + /// @dev Member of the reserved AccessControl namespace, unused because gating is Root only. + /// @param hasRole Whether an account holds the role. + /// @param adminRole Admin role that manages the role. + struct RoleData { + mapping(address account => bool) hasRole; + bytes32 adminRole; + } + + /// @notice Reserved OpenZeppelin access-control namespace held at its ERC-7201 slot. + /// @dev Declared and left unused so the namespace stays present in the layout. Its slot derives + /// from the label, disjoint from the sequential slots below, so it consumes none of them. + /// @param _roles Role data keyed by role identifier. + /// @custom:storage-location erc7201:openzeppelin.storage.AccessControl + struct AccessControlStorage { + mapping(bytes32 role => RoleData) _roles; + } + + /// @notice Upper bound for commitment validity to cap storage griefing risk. + uint256 public constant MAX_ALLOWED_COMMITMENT_AGE = 7 days; + + /// @notice Minimum age a commitment must reach before reveal. + uint256 public minCommitmentAge; + + /// @notice Maximum age after which a commitment expires. + uint256 public maxCommitmentAge; + + /// @notice Stores Mapping of commitment hashes to timestamp committed. + mapping(bytes32 hash => uint256 timestamp) public commitments; + + /// @notice Cost-model version stamped on a commitment at commit time. + /// @dev Recorded from the registry's current version when `commit` runs, so the reveal can bind + /// a registration to the version that was current then. A caller cannot commit against an + /// arbitrary earlier, cheaper version: the reveal rejects a `pricingVersion` that differs + /// from this stamp. + mapping(bytes32 hash => uint256 version) public committedPricingVersion; + + /// @dev Reserved slot held so the sequential storage layout stays fixed across the in-place + /// upgrade. Unused: name eligibility lives in @custom:contract DotnsNameWhitelistOld. + /// @custom:oz-renamed-from whiteList + mapping(address account => bool retained) private __whiteListSlot; + + /// @notice Protocol-level address registry for all DotNS contracts. + IDotnsProtocolRegistryOld public protocolRegistry; + + /// @dev Reserved storage space to allow for layout changes in the future. The retained + /// whitelist slot above holds one slot, so the gap holds 49 to keep the footprint fixed. + uint256[49] private __gap; + + /// @custom:oz-upgrades-unsafe-allow constructor + constructor() { + _disableInitializers(); + } + + /// @notice Initialises the registrar controller. + /// @dev Callable once through the UUPS proxy; direct calls on the implementation revert + /// with @custom:reverts InvalidInitialization, and any nested call outside an active + /// initialiser scope reverts with @custom:reverts NotInitializing. Validates the + /// commitment window bounds: `minAge` must be strictly positive (otherwise + /// @custom:reverts MinCommitmentAgeZero) so a reveal cannot land in the same block as + /// its commit; `maxAge` must exceed `minAge` (otherwise + /// @custom:reverts MaxCommitmentAgeTooLow) and must stay within + /// `MAX_ALLOWED_COMMITMENT_AGE` (otherwise @custom:reverts MaxCommitmentAgeTooHigh) before + /// wiring the protocol registry. + function initialize( + IDotnsProtocolRegistryOld registry, + uint256 minAge, + uint256 maxAge + ) + external + initializer + { + __ERC165_init(); + __Ownable_init(msg.sender); + + require(minAge > 0, MinCommitmentAgeZero()); + require(maxAge > minAge, MaxCommitmentAgeTooLow()); + require(maxAge <= MAX_ALLOWED_COMMITMENT_AGE, MaxCommitmentAgeTooHigh()); + + protocolRegistry = registry; + + minCommitmentAge = minAge; + maxCommitmentAge = maxAge; + } + + /// @inheritdoc IDotnsRegistrarController + function available(string calldata label) public view override returns (bool) { + bytes32 node; + (, node) = _validatedLabelNode(label); + IDotnsRegistrarOld registrar = IDotnsRegistrarOld(protocolRegistry.get(DotnsConstantsOld.REGISTRAR)); + return registrar.available(uint256(node)); + } + + /// @inheritdoc IDotnsRegistrarController + function makeCommitment(Registration calldata registration) + public + pure + override + returns (bytes32 commitment) + { + commitment = keccak256( + abi.encode( + registration.label, + registration.owner, + registration.secret, + registration.reserved, + registration.maxPrice, + registration.pricingVersion + ) + ); + } + + /// @inheritdoc IDotnsRegistrarController + function commit(bytes32 commitment) external override { + uint256 prior = commitments[commitment]; + require( + prior == 0 || prior + maxCommitmentAge <= block.timestamp, + UnexpiredCommitmentExists(commitment) + ); + + commitments[commitment] = block.timestamp; + committedPricingVersion[commitment] = _currentPricingVersion(); + emit NameCommitted(commitment); + } + + /// @notice Reads the cost model's current version through the protocol registry. + /// @dev Resolved at commit time so the stamp binds the version live then, not at reveal. + /// @return pricingVersion The current cost-model version. + function _currentPricingVersion() internal view returns (uint256 pricingVersion) { + return + IDotnsCostModelRegistryOld(protocolRegistry.get(DotnsConstantsOld.COST_MODEL)) + .currentVersion(); + } + + /// @inheritdoc IDotnsRegistrarController + function register(Registration calldata registration) external payable override nonReentrant { + (IDotnsRegistrarOld registrar, bytes32 labelhash, bytes32 node) = + _requireAvailableLabel(registration.label); + _consumeCommitment(registration); + + address escrow = _escrow(); + IPopRules rules = IPopRules(protocolRegistry.get(DotnsConstantsOld.POP_RULES)); + + uint256 tokenId = uint256(node); + bool isReclaim = registrar.exists(tokenId); + + string memory stem = rules.stripDigits(registration.label); + bool stemCanonical = stem.isSingleLabelMemory(); + // Reclaim hands the name back from a prior occupant who may hold a sibling-controller's + // stem reservation, which is garbage once the name moves on, so clear it. Non-reclaim + // paths intentionally leave an existing reservation in place: the slot belongs to the + // sibling controller that wrote it (e.g. the PoP queue head stamp), and clearing it from + // here would brick that controller's release and advance paths. + if (stemCanonical && isReclaim) { + (address reservationOwner,) = rules.getBaseNameReservation(stem); + address expectedOwner = + IDotnsNameEscrow(payable(escrow)).getReleasePosition(tokenId).recipient; + if (reservationOwner != address(0) && reservationOwner == expectedOwner) { + rules.releaseReservationForReclaim(stem, expectedOwner); + } + } + + bool isDirect = msg.sender == registration.owner; + IPopRules.PriceWithMeta memory priced; + if (isDirect) { + priced = rules.priceWithCheckAtVersion( + registration.label, registration.owner, registration.pricingVersion + ); + } else { + priced = rules.priceWithoutCheckAtVersion( + registration.label, registration.owner, registration.pricingVersion + ); + if (priced.status == IPopRules.PopStatus.Reserved) { + (IPopRules.PopStatus required,) = rules.classifyName(registration.label); + if (required == IPopRules.PopStatus.Reserved) { + revert IPopRules.GovernanceReserved(registration.label); + } + revert IPopRules.NameReserved(registration.label); + } + require( + priced.userStatus >= priced.status, + IPopRules.OwnerStatusInsufficient( + registration.label, priced.userStatus, priced.status + ) + ); + } + + uint256 totalCharged = priced.price; + require( + totalCharged <= registration.maxPrice, + PriceExceedsMax(registration.label, totalCharged, registration.maxPrice) + ); + require(msg.value >= totalCharged, InsufficientValue()); + + IDotnsReverseResolver reverse; + bool setReverseRecord; + if (registration.reserved && isDirect) { + reverse = IDotnsReverseResolver(protocolRegistry.get(DotnsConstantsOld.REVERSE_RESOLVER)); + setReverseRecord = bytes(reverse.nameOf(registration.owner)).length == 0; + } + + _completeRegistration( + registration, labelhash, node, priced.price, setReverseRecord, reverse, isReclaim + ); + + if (isReclaim) { + IDotnsNameEscrow(payable(escrow)).reclaim(tokenId, registration.owner); + // Reclaim hands the NFT to the new holder; rewrite the registry record so the prior + // owner's resolver pointer cannot follow the name. Must run after `escrow.reclaim` + // so the registry's `ownerOf` check sees the new holder, not the escrow. + IDotnsRegistryOld(protocolRegistry.get(DotnsConstantsOld.REGISTRY)) + .setOwner(node, registration.owner); + } + + _settleEscrow(escrow, tokenId, registration.owner, isDirect, totalCharged); + + if (msg.value > totalCharged) { + uint256 refund = msg.value - totalCharged; + (bool ok,) = payable(msg.sender).call{value: refund}(""); + if (ok) { + emit OverpaymentRefunded(msg.sender, refund); + } else { + IDotnsNameEscrow(payable(escrow)).creditOverpayment{value: refund}(msg.sender); + } + } + } + + /// @notice Settles every escrow side-effect of a successful registration. + /// @dev Extracted to keep `register` under the stack-depth ceiling. On a direct + /// registration the full `chargeAmount` lands in the refundable deposit position + /// keyed to `nameOwner`. On a cross-payer registration the deposit position is + /// seeded with a zero amount so the release lifecycle stays reachable, and the same + /// `chargeAmount` routes to the protocol fee pot via `depositProtocolFee` keyed to + /// `msg.sender` as the payer. + function _settleEscrow( + address escrow, + uint256 tokenId, + address nameOwner, + bool isDirect, + uint256 chargeAmount + ) + internal + { + uint256 depositAmount = isDirect ? chargeAmount : 0; + IDotnsNameEscrow(payable(escrow)).deposit{value: depositAmount}( + IDotnsNameEscrow.DepositParams({ + tokenId: tokenId, asset: address(0), amount: depositAmount, recipient: nameOwner + }) + ); + + if (!isDirect && chargeAmount > 0) { + IDotnsNameEscrow(payable(escrow)).depositProtocolFee{value: chargeAmount}( + IDotnsNameEscrow.ProtocolFeeDepositParams({ + tokenId: tokenId, payer: msg.sender, recipient: nameOwner + }) + ); + } + } + + /// @inheritdoc IDotnsRegistrarController + function registerReserved(Registration calldata registration) external override nonReentrant { + // Read Root once, up front. Everything below must stay callable under a substrate Root + // origin, which has no account, so no branch may read `msg.sender`: the grant is checked + // against `registration.owner`, the commitment is keyed on its own hash, and the mint + // targets the owner. + bool isRoot = SystemUtilsOld.originIsRoot(); + IDotnsNameWhitelist whitelist; + if (!isRoot) { + whitelist = _nameWhitelist(); + require( + whitelist.isGrantedTo(registration.label, registration.owner), + NameNotGranted(registration.label, registration.owner) + ); + } + + (, bytes32 labelhash, bytes32 node) = _requireAvailableLabel(registration.label); + _consumeCommitment(registration); + + // Spend the grant before minting so a grant in the wrong state fails before any name is + // issued. Root skips it: a governance mint must not consume a grant held by someone else. + // + // A consequence worth knowing: if Root mints a label that is `Claimed` or `Reserved` on + // the whitelist, that record survives the mint. The beneficiary's own `registerReserved` + // then fails `NameNotAvailable`, and the node stays in the whitelist's active set until + // governance calls `revokeName`. Nothing is lost, but the grant is stranded. + if (!isRoot) { + whitelist.consume(registration.label, registration.owner); + } + + // No reverse record. `setReverseName` overwrites unconditionally, and the gate above lets + // anyone submit for the beneficiary, so writing here would let a third party relabel + // another address. The owner claims their own record through `claimReverseRecord`, which + // checks ownership and writes only their own key. + _completeRegistration( + registration, labelhash, node, 0, false, IDotnsReverseResolver(address(0)), false + ); + } + + /// @inheritdoc IERC165 + function supportsInterface(bytes4 interfaceId) + public + view + override(ERC165Upgradeable, IERC165) + returns (bool) + { + return interfaceId == type(IDotnsRegistrarController).interfaceId + || super.supportsInterface(interfaceId); + } + + /// @notice Validates label shape and derives `(labelhash, node)`. + /// @dev Delegates hashing to @custom:contract LabelUtils so the assembly sequence lives in + /// exactly one place across the codebase. Error ownership stays on this interface: shape + /// violations revert with `InvalidLabel()`; labels below the minimum length revert with + /// `LabelTooShort(label)` so off-chain consumers can distinguish "shape-valid but below + /// the policy minimum" from "shape-valid but already minted". + function _validatedLabelNode(string calldata label) + internal + view + returns (bytes32 labelhash, bytes32 node) + { + require(label.isSingleLabel(), InvalidLabel()); + require(bytes(label).length >= 3, LabelTooShort(label)); + (labelhash, node) = LabelUtils.deriveNode(protocolRegistry.tldNode(), label); + } + + function _requireAvailableLabel(string calldata label) + internal + view + returns (IDotnsRegistrarOld registrar, bytes32 labelhash, bytes32 node) + { + (labelhash, node) = _validatedLabelNode(label); + registrar = IDotnsRegistrarOld(protocolRegistry.get(DotnsConstantsOld.REGISTRAR)); + require(registrar.available(uint256(node)), NameNotAvailable(label)); + } + + function _consumeCommitment(Registration calldata registration) internal { + bytes32 commitment = makeCommitment(registration); + uint256 committedAt = commitments[commitment]; + + require(committedAt != 0, CommitmentNotFound(commitment)); + require( + committedAt + minCommitmentAge <= block.timestamp, + CommitmentTooNew(commitment, committedAt + minCommitmentAge, block.timestamp) + ); + require( + committedAt + maxCommitmentAge > block.timestamp, + CommitmentTooOld(commitment, committedAt + maxCommitmentAge, block.timestamp) + ); + + uint256 stamped = committedPricingVersion[commitment]; + require( + registration.pricingVersion == stamped, + IDotnsCostModelRegistryOld.PricingVersionMismatch(stamped, registration.pricingVersion) + ); + + delete commitments[commitment]; + delete committedPricingVersion[commitment]; + } + + /// @notice Completes a commit-reveal registration: mints (or skips when reclaiming), + /// wires forward registry, optionally sets the reverse record, and writes the owner's + /// Store. + /// @dev On a fresh mint the triad of mint + forward-registry + store-write is delegated + /// to @custom:function RegistrationUtilsOld.registerAndStore, the single canonical implementation + /// shared across every DotNS registration flow. On a reclaim the mint step is skipped (the + /// escrow has already moved custody) and only the registry wiring and store write run. + /// Reverse-record setting and the priced-registration event stay here because they are + /// commit-reveal-specific policy. + function _completeRegistration( + Registration calldata registration, + bytes32 labelhash, + bytes32 node, + uint256 baseCost, + bool setReverseRecord, + IDotnsReverseResolver reverse, + bool isReclaim + ) + internal + { + address labelStore; + if (!isReclaim) { + labelStore = RegistrationUtilsOld.registerAndStore( + RegistrationUtilsOld.RegistrationContext({ + protocolRegistry: protocolRegistry, + user: registration.owner, + label: registration.label, + labelhash: labelhash, + node: node + }) + ); + } else { + // Registry reset on reclaim is deferred until after `escrow.reclaim` runs (see + // @custom:function register) so the registry's `ownerOf` check sees the new holder. + IStoreFactoryOld factory = + IStoreFactoryOld(protocolRegistry.get(DotnsConstantsOld.STORE_FACTORY)); + string memory fullName = string.concat(registration.label, protocolRegistry.tld()); + labelStore = factory.writeLabel(registration.owner, node, fullName); + } + + if (setReverseRecord) { + reverse.setReverseName( + registration.owner, string.concat(registration.label, protocolRegistry.tld()) + ); + } + + emit NameRegistered(registration.label, labelhash, registration.owner, baseCost, labelStore); + } + + /// @notice Returns the configured name escrow from the protocol registry. + function _escrow() internal view returns (address escrow) { + escrow = protocolRegistry.get(DotnsConstantsOld.NAME_ESCROW); + require(escrow != address(0), EscrowNotConfigured()); + } + + /// @notice Returns implementation version. + /// @return versionString Current version string. + function version() external pure virtual returns (string memory versionString) { + versionString = "1.0.0"; + } + + /// @notice Returns the configured name whitelist from the protocol registry. + function _nameWhitelist() internal view returns (IDotnsNameWhitelist whitelist) { + address configured = protocolRegistry.get(DotnsConstantsOld.NAME_WHITELIST); + require(configured != address(0), WhitelistNotConfigured()); + whitelist = IDotnsNameWhitelist(configured); + } + + /// @inheritdoc UUPSUpgradeable + function _authorizeUpgrade(address newImplementation) internal override onlyOwner {} +} diff --git a/contracts/registrars/DotnsRegistrarOld.sol b/contracts/registrars/DotnsRegistrarOld.sol new file mode 100644 index 000000000..508f9898b --- /dev/null +++ b/contracts/registrars/DotnsRegistrarOld.sol @@ -0,0 +1,473 @@ +// 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 { + ERC721Upgradeable +} from "@openzeppelin/contracts-upgradeable/token/ERC721/ERC721Upgradeable.sol"; + +import {IDotnsRegistrarOld} from "./IDotnsRegistrarOld.sol"; +import {IDotnsController} from "./IDotnsController.sol"; +import {IDotnsProtocolRegistryOld} from "../registry/IDotnsProtocolRegistryOld.sol"; + +import {IStoreFactoryOld} from "../store/IStoreFactoryOld.sol"; +import {ILabelStore} from "../store/ILabelStore.sol"; +import {StoreUtilsOld} from "../utils/StoreUtilsOld.sol"; +import {LabelUtils} from "../utils/LabelUtils.sol"; +import {StringUtils} from "../utils/StringUtils.sol"; +import {IDotnsNameEscrow} from "../escrow/IDotnsNameEscrow.sol"; +import {IPopRules} from "../pop/IPopRules.sol"; +import {DotnsConstantsOld} from "../utils/DotnsConstantsOld.sol"; + +/// @title Dotns Registrar +/// @notice ERC721-backed registrar implementing permanent name ownership. +/// @dev Deliberately policy-free on pricing, reservations, and PoP gating; those live in the +/// controllers and @custom:contract IPopRules. The registrar owns transferability itself: publicly +/// registered names transfer freely, while names minted through the PoP gateway are soulbound and +/// revert on transfer. The `_update` hook enforces both the soulbound gate and the fee-on-transfer +/// settlement that consults the escrow. +/// @custom:security-contact admin@parity.io +contract DotnsRegistrarOld is + Initializable, + UUPSUpgradeable, + OwnableUpgradeable, + ERC721Upgradeable, + IDotnsRegistrarOld +{ + using StoreUtilsOld for IStoreFactoryOld; + using StringUtils for *; + + /// @notice Mapping of authorised controllers. + /// @dev Controllers may call `register`. Keyed by the shared baseline @custom:contract + /// IDotnsController interface so the registrar doesn't depend on any specific controller shape. + /// Commit-reveal, PoP, and future controllers coexist here so long as they implement the + /// baseline interface. + /// @custom:oz-retyped-from mapping(IDotnsRegistrarController => bool) + mapping(IDotnsController controller => bool exists) public controllers; + + /// @notice Protocol-level address registry for all DotNS contracts. + /// @dev Used to resolve sibling contract addresses (store factory, controller, registry) + /// without storing individual references. + IDotnsProtocolRegistryOld public protocolRegistry; + + /// @notice Marks a token as soulbound: minted through the PoP gateway and non-transferable. + /// @dev Set at mint by @custom:function register when the caller is the address registered + /// under `DotnsConstantsOld.POP_CONTROLLER`. Write-once and never cleared: a name's soulbound + /// state is fixed at registration. Read by the `_update` transfer gate and by + /// @custom:function quoteTransferFee. + mapping(uint256 tokenId => bool soulbound) private _soulbound; + + /// @dev Reserved storage space to allow for layout changes in the future. `_soulbound` occupies + /// one reserved slot, so the gap holds 49 slots and the contract keeps a fixed 51-slot + /// footprint. + uint256[49] private __gap; + + /// @notice Restricts function access to authorised controllers. + modifier onlyController() { + _onlyController(); + _; + } + + /// @custom:oz-upgrades-unsafe-allow constructor + constructor() { + _disableInitializers(); + } + + /// @notice Initialises the registrar. + /// @dev Uses OpenZeppelin upgradeable initialisers and is callable once through the UUPS + /// proxy; direct calls on the implementation revert with @custom:reverts InvalidInitialization + /// because `_disableInitializers` runs in the constructor, and any nested call outside an + /// active initialiser scope reverts with @custom:reverts NotInitializing. + function initialize( + string calldata name, + string calldata symbol, + IDotnsProtocolRegistryOld registry + ) + external + initializer + { + require(address(registry) != address(0), ProtocolRegistryRequired()); + __Ownable_init(msg.sender); + __ERC721_init(name, symbol); + protocolRegistry = registry; + } + + /// @inheritdoc IDotnsRegistrarOld + function addController(IDotnsController controller) external onlyOwner { + controllers[controller] = true; + emit ControllerAdded(controller); + } + + /// @inheritdoc IDotnsRegistrarOld + function removeController(IDotnsController controller) external onlyOwner { + controllers[controller] = false; + emit ControllerRemoved(controller); + } + + /// @inheritdoc IDotnsRegistrarOld + function available(uint256 id) public view override returns (bool isAvailable) { + address holder = _ownerOf(id); + if (holder == address(0)) return true; + + address escrow = protocolRegistry.get(DotnsConstantsOld.NAME_ESCROW); + if (holder != escrow) return false; + + // Escrow custody on its own does not mean registrable: a released name inside its redeem + // window still belongs to its previous holder. The escrow owns that lifecycle and is asked + // directly, so availability here and reclaimability there cannot drift apart and start + // advertising names whose registration would revert. + return IDotnsNameEscrow(payable(escrow)).isReclaimable(id); + } + + /// @inheritdoc IDotnsRegistrarOld + function register( + uint256 id, + address owner, + string calldata label + ) + external + override + onlyController + { + // `available` returns true both for unminted ids and for ids currently held by escrow + // (so the controller can route through `escrow.reclaim`). `register` only handles the + // fresh-mint branch; the escrow-held branch must use the reclaim path and is rejected + // here with the typed error so callers do not see OZ's `ERC721InvalidSender(0)`. + require(!_exists(id), NameNotAvailable(id)); + require(owner != protocolRegistry.get(DotnsConstantsOld.NAME_ESCROW), InvalidOwner()); + // Empty labels are an intentional gateway-cold path (substrate Root cannot deploy a + // `LabelStore` under `pallet-revive`, so the controller stashes a pending claim and the + // user settles via @custom:function IDotnsPopController.claimLabelStore later). Non-empty + // labels must still be canonical so the transfer-floor lookup in `_quoteTransferFee` + // cannot brick the token by reverting on a malformed stem. + require(bytes(label).length == 0 || label.isSingleLabel(), InvalidLabel()); + _mint(owner, id); + // Provenance is verified here rather than trusted from a caller-supplied flag: only the + // canonical PoP controller mints soulbound names, so a compromised or buggy peer controller + // cannot lock a public name and the PoP controller cannot mint an unlocked one. Written + // only on the true branch to leave the public path free of a redundant zero write. + bool soulbound = msg.sender == protocolRegistry.get(DotnsConstantsOld.POP_CONTROLLER); + if (soulbound) _soulbound[id] = true; + if (bytes(label).length != 0) _writeOwnerLabel(owner, id, label); + emit NameRegistered(id, owner, soulbound); + } + + /// @inheritdoc IDotnsRegistrarOld + function labelOf(uint256 tokenId) external view override returns (string memory) { + address holder = _ownerOf(tokenId); + if (holder == address(0)) return ""; + return LabelUtils.stripTld(protocolRegistry.tld(), _readLabel(tokenId, holder)); + } + + /// @inheritdoc IDotnsRegistrarOld + function quoteTransferFee( + uint256 tokenId, + address to + ) + external + view + override + returns (uint256 requiredFee) + { + require(to != address(0), ERC721InvalidReceiver(address(0))); + // A soulbound name cannot be transferred, so it has no transfer price. Revert rather than + // return zero: a zero here would read as "transferable, no fee" to integrators while any + // real transfer reverts in `_update`. + require(!_soulbound[tokenId], NameSoulbound(tokenId)); + + address from = ownerOf(tokenId); + (,, requiredFee) = _quoteTransferFee(from, to, tokenId); + } + + /// @inheritdoc IDotnsRegistrarOld + function transferFrom( + address from, + address to, + uint256 tokenId + ) + public + payable + override(ERC721Upgradeable, IDotnsRegistrarOld) + { + super.transferFrom(from, to, tokenId); + } + + /// @inheritdoc IDotnsRegistrarOld + function safeTransferFrom( + address from, + address to, + uint256 tokenId + ) + public + payable + override(ERC721Upgradeable, IDotnsRegistrarOld) + { + super.safeTransferFrom(from, to, tokenId, ""); + } + + /// @inheritdoc IDotnsRegistrarOld + function safeTransferFrom( + address from, + address to, + uint256 tokenId, + bytes memory data + ) + public + payable + override(ERC721Upgradeable, IDotnsRegistrarOld) + { + super.safeTransferFrom(from, to, tokenId, data); + } + + /// @notice Returns implementation version. + /// @return versionString Current version string. + function version() external pure virtual returns (string memory versionString) { + versionString = "1.0.0"; + } + + /// @inheritdoc IDotnsRegistrarOld + function exists(uint256 tokenId) external view override returns (bool tokenExists) { + tokenExists = _exists(tokenId); + } + + /// @inheritdoc IDotnsRegistrarOld + function isSoulbound(uint256 tokenId) external view override returns (bool soulbound) { + soulbound = _soulbound[tokenId]; + } + + /// @notice Checks whether a token ID exists. + function _exists(uint256 tokenId) internal view returns (bool) { + return _ownerOf(tokenId) != address(0); + } + + /// @notice Internal function to check for controller access. + function _onlyController() internal view { + require(controllers[IDotnsController(msg.sender)], NotController(msg.sender)); + } + + /// @inheritdoc ERC721Upgradeable + function _update( + address to, + uint256 tokenId, + address auth + ) + internal + override + returns (address from) + { + from = super._update(to, tokenId, auth); + + // Mints carry no economic event and must not be blocked: the soulbound flag is written + // after `_mint`, so a mint reaches here before the flag exists. Reject any attached value + // because nothing forwards it onward (no `receive`, no rescue path). + if (from == address(0)) { + require(msg.value == 0, UnexpectedValue()); + return from; + } + + // Soulbound names are non-transferable, including a move to the sender's own address, which + // keeps this in step with @custom:function quoteTransferFee and the interface contract. It + // reverts rather than returning, unwinding the ownership move `super._update` has already + // made, and sits before any escrow or store lookup so a soulbound token is rejected even + // when the escrow is unconfigured, blocking every custody move including release into + // escrow. + require(!_soulbound[tokenId], NameSoulbound(tokenId)); + + // Self-transfers of a transferable name carry no economic event. Reject attached value for + // the same trapped-funds reason as the mint path above. + if (from == to) { + require(msg.value == 0, UnexpectedValue()); + return from; + } + + // Resolve every registry-sourced dependency once and thread it into the helpers so a + // single transfer pays one external lookup per key rather than three. + IDotnsProtocolRegistryOld registry = protocolRegistry; + address escrow = registry.get(DotnsConstantsOld.NAME_ESCROW); + require(escrow != address(0), EscrowNotConfigured()); + IStoreFactoryOld factory = IStoreFactoryOld(registry.get(DotnsConstantsOld.STORE_FACTORY)); + + bool isEscrowTouching = to == escrow || from == escrow; + // Skip mirroring on escrow-touching paths: release deposits the NFT into custody where + // a `LabelStore` would be wasted and reclaim hands it back to a fresh-mint controller + // that writes the label through its own flow. + if (!isEscrowTouching) { + _syncRecipientStore(factory, to, from, tokenId); + } + + (uint256 transferFee, uint256 requiredFee) = + _quoteTransferFeeFor(registry, factory, isEscrowTouching, from, to, tokenId); + if (requiredFee != 0) { + require(msg.value >= requiredFee, TransferFeeRequired(tokenId, to, requiredFee)); + } + + // Deposits follow the NFT, not the depositor: every transfer that moves a name off the + // prior position recipient rebinds the escrow position to the new holder so the locked + // deposit (when funded) and the lifecycle marker (when zero-amount) both travel with the + // name. Escrow-touching transfers are excluded because the escrow is mid-call and its + // non-reentrancy guard would reject a re-entry; release/reclaim manage the position + // directly. + bool positionSyncNeeded; + if (!isEscrowTouching) { + IDotnsNameEscrow.ReleasePosition memory position = + IDotnsNameEscrow(payable(escrow)).getReleasePosition(tokenId); + positionSyncNeeded = position.recipient != address(0) && to != position.recipient; + } + + if (requiredFee == 0 && msg.value == 0 && !positionSyncNeeded) { + return from; + } + + IDotnsNameEscrow(payable(escrow)).chargeTransferFee{value: msg.value}( + IDotnsNameEscrow.ChargeTransferFeeParams({ + tokenId: tokenId, transferFee: transferFee, payer: msg.sender, to: to + }) + ); + + return from; + } + + /// @notice Mirrors the sender's label entry into the recipient's `LabelStore`. + function _syncRecipientStore( + IStoreFactoryOld factory, + address to, + address from, + uint256 tokenId + ) + internal + { + string memory fullName = _readLabelFor(factory, tokenId, from); + if (bytes(fullName).length == 0) { + // Defensive: the sender holds no label entry for the token. Gateway mints reach this + // only at mint time, and a gateway name is soulbound so it never transfers; a public + // name always carries a label. Nothing to mirror, so do not deploy a recipient store; + // downstream writes are demand-deploy through `StoreUtilsOld.ensureLabelStore`. + return; + } + factory.writeLabel(to, bytes32(tokenId), fullName); + } + + /// @notice Reads the full name (`label.tld`) for `tokenId` from `holder`'s `LabelStore` using + /// a caller-supplied factory. + function _readLabelFor( + IStoreFactoryOld factory, + uint256 tokenId, + address holder + ) + private + view + returns (string memory fullName) + { + address store = factory.getLabelStore(holder); + if (store == address(0)) return ""; + return ILabelStore(store).getLabel(bytes32(tokenId)); + } + + /// @notice Reads the full name for `tokenId` from `holder`'s `LabelStore` via fresh lookups. + /// @dev Used by external view functions where caching the factory is not yet established; + /// the hot transfer path uses @custom:function _readLabelFor with a cached factory. + function _readLabel( + uint256 tokenId, + address holder + ) + private + view + returns (string memory fullName) + { + return _readLabelFor(_storeFactory(), tokenId, holder); + } + + /// @notice Resolves the configured name escrow address from the protocol registry. + function _escrow() private view returns (address escrow) { + escrow = protocolRegistry.get(DotnsConstantsOld.NAME_ESCROW); + } + + /// @notice Resolves the configured PoP rules contract from the protocol registry. + function _popRules() private view returns (IPopRules rules) { + rules = IPopRules(protocolRegistry.get(DotnsConstantsOld.POP_RULES)); + } + + /// @notice Resolves the configured store factory from the protocol registry. + function _storeFactory() private view returns (IStoreFactoryOld factory) { + factory = IStoreFactoryOld(protocolRegistry.get(DotnsConstantsOld.STORE_FACTORY)); + } + + /// @notice Writes the canonical full name into `owner`'s `LabelStore` keyed by + /// `bytes32(tokenId)`. + /// @dev Caller (@custom:function register) is responsible for short-circuiting on empty label; + /// the factory is a protocol-critical dependency and is assumed non-zero (a zero return from + /// the registry would have already broken every other call site). + function _writeOwnerLabel(address owner, uint256 tokenId, string calldata label) private { + _storeFactory() + .writeLabel(owner, bytes32(tokenId), string.concat(label, protocolRegistry.tld())); + } + + /// @notice Quotes the friction fee required for a transfer. + /// @dev Required fee is the name's own price returned by @custom:function + /// PopRulesOld.transferFloor. It is paid by the sender on every downward or cross-reach transfer + /// and settles to the + /// protocol fee pot. Any prior deposit travels with the NFT: the escrow rebinds the position to + /// the new holder rather than refunding the sender, so transferring a funded name forfeits the + /// locked deposit to the recipient. Self-transfers and escrow-touching transfers return zero. + function _quoteTransferFee( + address from, + address to, + uint256 tokenId + ) + private + view + returns (address escrow, uint256 transferFee, uint256 requiredFee) + { + if (from == to) return (address(0), 0, 0); + + IDotnsProtocolRegistryOld registry = protocolRegistry; + escrow = registry.get(DotnsConstantsOld.NAME_ESCROW); + require(escrow != address(0), EscrowNotConfigured()); + + bool isEscrowTouching = to == escrow || from == escrow; + IStoreFactoryOld factory = IStoreFactoryOld(registry.get(DotnsConstantsOld.STORE_FACTORY)); + (transferFee, requiredFee) = + _quoteTransferFeeFor(registry, factory, isEscrowTouching, from, to, tokenId); + } + + /// @notice Quotes the transfer floor reusing a caller-cached registry and store factory. + /// @dev Hot-path variant used by @custom:function _update. Returns `(0, 0)` for any + /// escrow-touching move or when the sender holds no label entry; otherwise reads the canonical + /// label and delegates to @custom:function PopRulesOld.transferFloor. + function _quoteTransferFeeFor( + IDotnsProtocolRegistryOld registry, + IStoreFactoryOld factory, + bool isEscrowTouching, + address from, + address to, + uint256 tokenId + ) + private + view + returns (uint256 transferFee, uint256 requiredFee) + { + if (isEscrowTouching) return (0, 0); + + string memory fullName = _readLabelFor(factory, tokenId, from); + // No label means there is no label-derived price to charge against; treat as a zero-fee + // move. This is defensive: a gateway name is soulbound and reverts before reaching here, + // and a public name always carries a label, so no reachable transfer hits this branch. + if (bytes(fullName).length == 0) return (0, 0); + // A stored full name always carries the registry TLD suffix, so an empty strip means the + // name is malformed for this registry (a wrong or missing suffix); fail loudly rather than + // mis-pricing the move as zero-fee. + string memory label = LabelUtils.stripTld(registry.tld(), fullName); + require(bytes(label).length != 0, InvalidLabel()); + + transferFee = + IPopRules(registry.get(DotnsConstantsOld.POP_RULES)).transferFloor(label, from, to); + requiredFee = transferFee; + } + + /// @inheritdoc UUPSUpgradeable + function _authorizeUpgrade(address newImplementation) internal override onlyOwner {} +} diff --git a/contracts/registrars/IDotnsPopControllerOld.sol b/contracts/registrars/IDotnsPopControllerOld.sol deleted file mode 100644 index d02cbbcc3..000000000 --- a/contracts/registrars/IDotnsPopControllerOld.sol +++ /dev/null @@ -1,499 +0,0 @@ -// 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/registrars/IDotnsRegistrarOld.sol b/contracts/registrars/IDotnsRegistrarOld.sol new file mode 100644 index 000000000..d6b120146 --- /dev/null +++ b/contracts/registrars/IDotnsRegistrarOld.sol @@ -0,0 +1,190 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.34; + +import {IERC721} from "@openzeppelin/contracts-upgradeable/token/ERC721/ERC721Upgradeable.sol"; +import {IDotnsController} from "./IDotnsController.sol"; + +/// @title Dotns Registrar +/// @notice ERC721-backed ownership for DotNS names with controller-gated registration. +/// @dev Intentionally minimal and policy-free. Provides ERC721 ownership for registered name +/// token IDs and controller-gated registration; pricing, PoP enforcement, and flow-specific +/// policy live in the controllers. +/// @custom:security-contact admin@parity.io +interface IDotnsRegistrarOld is IERC721 { + /// @notice Thrown when a name is already registered. + error NameNotAvailable(uint256 tokenId); + + /// @notice Thrown when the caller is not an authorised controller. + error NotController(address caller); + + /// @notice Thrown when the protocol registry has no escrow address configured. + error EscrowNotConfigured(); + + /// @notice Thrown when a standard ERC721 transfer is attempted but the recipient + /// tier requires a non-zero transfer fee and the caller forwarded no `msg.value`. + error TransferFeeRequired(uint256 tokenId, address to, uint256 requiredFee); + + /// @notice Thrown when @custom:function initialize is called with the zero address as + /// the protocol registry. + error ProtocolRegistryRequired(); + + /// @notice Thrown when a mint, burn, or self-transfer carries `msg.value`. None of those + /// paths forward value onward, so attached value would be permanently trapped. + error UnexpectedValue(); + + /// @notice Thrown when @custom:function register is called with the escrow address as + /// `owner`, which would mint directly into escrow custody with no @custom:struct + /// ReleasePosition recorded. + error InvalidOwner(); + + /// @notice Thrown when @custom:function register receives an empty or non-canonical + /// label. + error InvalidLabel(); + + /// @notice Thrown when a transfer or a transfer-fee quote targets a soulbound name. + /// @dev Soulbound names are minted through the PoP gateway and are permanently + /// non-transferable. Raised by the `_update` transfer gate and by + /// @custom:function quoteTransferFee. + error NameSoulbound(uint256 tokenId); + + /// @notice Emitted when a name is registered. + /// @param id The token id (namehash node) that was minted. + /// @param owner The address that received the name. + /// @param soulbound True when the name is soulbound: PoP-gateway minted and non-transferable. + /// Lets indexers classify registrations without a per-token @custom:function isSoulbound read. + event NameRegistered(uint256 indexed id, address indexed owner, bool soulbound); + + /// @notice Emitted when a controller is added. + /// @dev Typed as the shared baseline @custom:contract IDotnsController so the commit-reveal + /// controller and the PoP controller (and any future controller) all fit the same signature + /// without + /// the registrar depending on any specific controller interface. + event ControllerAdded(IDotnsController indexed controller); + + /// @notice Emitted when a controller is removed. + event ControllerRemoved(IDotnsController indexed controller); + + /// @notice Returns whether a registration call may proceed for `id`. + /// @dev Signals two distinct paths to the controller. Returns `true` when the owner slot is + /// empty (a fresh @custom:function register call may mint) OR when the current owner is + /// the configured escrow and the released position's redeem window has elapsed (the + /// controller must then route through @custom:function IDotnsNameEscrow.reclaim instead of + /// @custom:function register, because `register` calls `_mint` which rejects existing + /// tokens). All other holders return `false`. The controller distinguishes the two `true` + /// cases via @custom:function exists. + /// Escrow custody inside the redeem window returns `false`: that window belongs to the + /// previous holder, who may still @custom:function IDotnsNameEscrow.redeem the name, and + /// reclaim would revert until it elapses. Clients wanting the exact moment a released name + /// becomes registrable should read `redeemableUntil` from + /// @custom:function IDotnsNameEscrow.getReleasePosition. + function available(uint256 id) external view returns (bool isAvailable); + + /// @notice Registers a name permanently. + /// @dev Permanence is by construction: there is no `expire`, `renew`, or `release` path on + /// the registrar. Custody only moves via ERC721 transfers (which the registrar polices via + /// the fee-on-transfer hook) or via escrow reclaim. Restricted to authorised controllers + /// (otherwise @custom:reverts NotController) and rejects ids that are not available + /// (otherwise @custom:reverts NameNotAvailable). Emits @custom:emits NameRegistered on + /// success. + /// @dev When the caller is the address registered under `DotnsConstantsOld.POP_CONTROLLER`, the + /// name is marked soulbound and becomes permanently non-transferable (see + /// @custom:function isSoulbound). Provenance is read from the protocol registry at mint time, + /// so no other authorised controller can mint a soulbound name and the PoP controller cannot + /// mint an unlocked one. Public registrations from any other controller stay transferable. + /// @param label The human-readable label string (e.g. "alice"). + function register(uint256 id, address owner, string calldata label) external; + + /// @notice Returns whether a token is soulbound (PoP-gateway minted and non-transferable). + /// @dev Durable on-chain marker set once at mint by @custom:function register and never + /// cleared. A `true` result means every transfer overload reverts with + /// @custom:reverts NameSoulbound and @custom:function quoteTransferFee reverts likewise. + /// @param tokenId The name's token id. + /// @return soulbound True when the name was minted through the PoP gateway. + function isSoulbound(uint256 tokenId) external view returns (bool soulbound); + + /// @notice Returns whether a given token id has been minted. + function exists(uint256 tokenId) external view returns (bool tokenExists); + + /// @notice Adds an authorised controller. + /// @dev Typed against the baseline `IDotnsController` (not a concrete subtype) so a single + /// authorisation surface accepts every controller flavour (commit-reveal, PoP gateway, + /// future variants) without per-flavour setters. Owner-gated (otherwise + /// @custom:reverts OwnableUnauthorizedAccount); emits @custom:emits ControllerAdded on + /// success. + function addController(IDotnsController controller) external; + + /// @notice Removes an authorised controller. + /// @dev Mirrors the @custom:function addController baseline-typed signature so any registered + /// controller can + /// be revoked through the same entry point. Owner-gated (otherwise + /// @custom:reverts OwnableUnauthorizedAccount); emits @custom:emits ControllerRemoved on + /// success. + function removeController(IDotnsController controller) external; + + /// @notice Returns whether `controller` is currently authorised to call + /// @custom:function register. + /// @param controller Candidate controller. + /// @return authorised True when `controller` was added via @custom:function addController and + /// has not been removed. + function controllers(IDotnsController controller) external view returns (bool authorised); + + /// @notice Returns the human-readable label a token was registered with. + /// @dev Canonical state source for the label string; any client that holds a node or + /// tokenId can resolve the original label in one view call without scanning registration + /// events. Returns the empty string when the token does not exist. + function labelOf(uint256 tokenId) external view returns (string memory label); + + /// @notice Quotes the additional native fee required to transfer a token to `to`. + /// @dev Returns the fee from @custom:function PopRulesOld.transferFloor: the name's own price + /// as the maximum of (i) the reach component charged when the recipient does not meet + /// the label's required tier and (ii) the downgrade component charged when the + /// recipient tier is strictly below the sender tier. Self-transfers and + /// escrow-touching transfers (release into escrow, reclaim out of escrow) return + /// zero. A token whose sender has no stored label also returns zero because there + /// is no label-derived price to charge against. Soulbound names are non-transferable + /// and have no transfer price, so a soulbound `tokenId` reverts with + /// @custom:reverts NameSoulbound rather than returning zero. Rejects a zero `to` with + /// @custom:reverts ERC721InvalidReceiver, an unminted `tokenId` with + /// @custom:reverts ERC721NonexistentToken via the underlying `ownerOf`, and requires + /// the protocol registry to have an escrow configured (otherwise + /// @custom:reverts EscrowNotConfigured). Returns zero when the protocol registry + /// has no `STORE_FACTORY` configured because no label-derived price is reachable. + function quoteTransferFee( + uint256 tokenId, + address to + ) + external + view + returns (uint256 requiredFee); + + /// @inheritdoc IERC721 + /// @dev The registrar's `_update` hook consults @custom:function PopRulesOld.transferFloor + /// to compute the required transfer fee; if the caller does not forward at least that + /// amount as `msg.value`, the transfer reverts with @custom:reverts TransferFeeRequired. + /// A soulbound name is non-transferable and reverts with @custom:reverts NameSoulbound. + /// The `payable` modifier on every transfer overload exists so the fee can be forwarded + /// in the same call. + function safeTransferFrom( + address from, + address to, + uint256 tokenId, + bytes calldata data + ) + external + payable + override; + + /// @inheritdoc IERC721 + /// @dev Subject to the same fee-on-transfer gate as the four-argument overload; reverts with + /// @custom:reverts TransferFeeRequired when the recipient owes a non-zero transfer fee and + /// the caller has not forwarded it as `msg.value`, and with @custom:reverts NameSoulbound when + /// the token is soulbound. + function safeTransferFrom(address from, address to, uint256 tokenId) external payable override; + + /// @inheritdoc IERC721 + /// @dev Subject to the same fee-on-transfer gate as the safe overloads; reverts with + /// @custom:reverts TransferFeeRequired when the recipient owes a non-zero transfer fee and + /// the caller has not forwarded it as `msg.value`, and with @custom:reverts NameSoulbound when + /// the token is soulbound. + function transferFrom(address from, address to, uint256 tokenId) external payable override; +} diff --git a/contracts/registry/DotnsProtocolRegistryOld.sol b/contracts/registry/DotnsProtocolRegistryOld.sol new file mode 100644 index 000000000..35f91d2ee --- /dev/null +++ b/contracts/registry/DotnsProtocolRegistryOld.sol @@ -0,0 +1,112 @@ +// 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 {IDotnsProtocolRegistryOld} from "./IDotnsProtocolRegistryOld.sol"; +import {LabelUtils} from "../utils/LabelUtils.sol"; +import {StringUtils} from "../utils/StringUtils.sol"; + +/// @title Dotns Protocol Registry +/// @author Parity +/// @notice Upgradeable address registry for all DotNS protocol contracts, and the authority for +/// the network's top-level domain. +/// @dev Single source of truth for sibling-contract lookups. All siblings resolve each other via +/// well-known `bytes32` constants in `DotnsConstantsOld` rather than holding direct addresses, +/// so an upgrade or rewire only mutates this contract. The TLD node and suffix are set once +/// at initialisation and read live by every consumer, so a network runs one TLD without +/// recompiling its contracts. +/// @custom:security-contact admin@parity.io +contract DotnsProtocolRegistryOld is + Initializable, + UUPSUpgradeable, + OwnableUpgradeable, + IDotnsProtocolRegistryOld +{ + using StringUtils for string; + + /// @notice Address stored for each well-known protocol key. + mapping(bytes32 key => address addr) private _addresses; + + /// @notice Reference count per address, incremented for every key it is registered under. + /// @dev Lets `isRegisteredAddress` answer in O(1) and survive a contract being mapped to + /// multiple keys without being treated as deregistered when only one key is rewired. + mapping(address addr => uint256 refcount) private _registeredRefcount; + + /// @notice Namehash of the TLD node, `namehash(0, keccak256(bytes(tldLabel)))`. + bytes32 private _tldNode; + + /// @notice TLD suffix including the leading dot, e.g. `.dot`. + string private _tld; + + uint256[50] private __gap; + + /// @custom:oz-upgrades-unsafe-allow constructor + constructor() { + _disableInitializers(); + } + + /// @notice Initialises the protocol registry and fixes the network's TLD. + /// @dev Callable exactly once via `Initializable`, otherwise + /// @custom:reverts InvalidInitialization. Sets the deployer as owner. `tldLabel` is the + /// bare label without a dot (e.g. `dot`, `paseo`); it must be a single DNS label, + /// otherwise @custom:reverts InvalidTld. The TLD is fixed here because changing it after + /// names exist would reroot every node. + /// @param tldLabel Bare TLD label, without the leading dot. + function initialize(string calldata tldLabel) external initializer { + __Ownable_init(msg.sender); + + require(tldLabel.isSingleLabel(), InvalidTld()); + _tldNode = LabelUtils.namehashUnder(bytes32(0), LabelUtils.labelhash(tldLabel)); + _tld = string.concat(".", tldLabel); + } + + /// @inheritdoc IDotnsProtocolRegistryOld + function get(bytes32 key) external view override returns (address addr) { + return _addresses[key]; + } + + /// @inheritdoc IDotnsProtocolRegistryOld + function set(bytes32 key, address addr) external override onlyOwner { + require(addr != address(0), ZeroAddress()); + + address previousAddress = _addresses[key]; + if (previousAddress == addr) return; + + if (previousAddress != address(0)) { + --_registeredRefcount[previousAddress]; + } + ++_registeredRefcount[addr]; + + _addresses[key] = addr; + emit AddressUpdated(key, addr); + } + + /// @inheritdoc IDotnsProtocolRegistryOld + function isRegisteredAddress(address addr) external view override returns (bool registered) { + return addr != address(0) && _registeredRefcount[addr] > 0; + } + + /// @inheritdoc IDotnsProtocolRegistryOld + function tldNode() external view override returns (bytes32 node) { + return _tldNode; + } + + /// @inheritdoc IDotnsProtocolRegistryOld + function tld() external view override returns (string memory suffix) { + return _tld; + } + + /// @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/DotnsRegistryOld.sol b/contracts/registry/DotnsRegistryOld.sol index 1e0702f8f..7942adc78 100644 --- a/contracts/registry/DotnsRegistryOld.sol +++ b/contracts/registry/DotnsRegistryOld.sol @@ -8,29 +8,29 @@ import { } 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 {IDotnsRegistrarOld} from "../registrars/IDotnsRegistrarOld.sol"; +import {IStoreFactoryOld} from "../store/IStoreFactoryOld.sol"; import {StoreUtilsOld} from "../utils/StoreUtilsOld.sol"; import {LabelUtils} from "../utils/LabelUtils.sol"; -import {IDotnsProtocolRegistry} from "./IDotnsProtocolRegistry.sol"; +import {IDotnsProtocolRegistryOld} from "./IDotnsProtocolRegistryOld.sol"; import {StringUtils} from "../utils/StringUtils.sol"; -import {DotnsConstants} from "../utils/DotnsConstants.sol"; +import {DotnsConstantsOld} from "../utils/DotnsConstantsOld.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`. +/// `IDotnsRegistrarOld.ownerOf`; subnodes carry an explicit owner address in `records`. /// @custom:security-contact admin@parity.io contract DotnsRegistryOld is Initializable, UUPSUpgradeable, OwnableUpgradeable, IDotnsRegistryOld { - using StoreUtilsOld for IStoreFactory; + using StoreUtilsOld for IStoreFactoryOld; 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; + IDotnsProtocolRegistryOld public protocolRegistry; uint256[50] private __gap; @@ -56,7 +56,7 @@ contract DotnsRegistryOld is Initializable, UUPSUpgradeable, OwnableUpgradeable, /// @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 { + function initialize(IDotnsProtocolRegistryOld registry) external initializer { __Ownable_init(msg.sender); require(address(registry) != address(0), NotAllowed()); @@ -84,7 +84,7 @@ contract DotnsRegistryOld is Initializable, UUPSUpgradeable, OwnableUpgradeable, subnode = LabelUtils.namehashUnder(parentNode, labelhash); Record storage existing = records[subnode]; - address reverseResolver = protocolRegistry.get(DotnsConstants.REVERSE_RESOLVER); + address reverseResolver = protocolRegistry.get(DotnsConstantsOld.REVERSE_RESOLVER); if (existing.exists) { address previousOwner = existing.owner; // Reset the resolver pointer on reassignment so the prior subnode owner's resolver @@ -99,16 +99,18 @@ contract DotnsRegistryOld is Initializable, UUPSUpgradeable, OwnableUpgradeable, 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); @@ -117,7 +119,7 @@ contract DotnsRegistryOld is Initializable, UUPSUpgradeable, OwnableUpgradeable, /// @inheritdoc IDotnsRegistryOld function setOwner(bytes32 node, address newOwner) external override onlyRegistrarController { require(newOwner != address(0), NotAllowed()); - IDotnsRegistrar registrar = IDotnsRegistrar(protocolRegistry.get(DotnsConstants.REGISTRAR)); + IDotnsRegistrarOld registrar = IDotnsRegistrarOld(protocolRegistry.get(DotnsConstantsOld.REGISTRAR)); require(registrar.ownerOf(uint256(node)) == newOwner, NotAuthorised()); // The resolver pointer is reset to the default reverse resolver on every call to this @@ -128,7 +130,7 @@ contract DotnsRegistryOld is Initializable, UUPSUpgradeable, OwnableUpgradeable, // 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), + resolver: protocolRegistry.get(DotnsConstantsOld.REVERSE_RESOLVER), exists: true }); @@ -171,7 +173,7 @@ contract DotnsRegistryOld is Initializable, UUPSUpgradeable, OwnableUpgradeable, address storedOwner = record.owner; if (storedOwner != address(0)) return storedOwner; if (!record.exists) return address(0); - IDotnsRegistrar registrar = IDotnsRegistrar(protocolRegistry.get(DotnsConstants.REGISTRAR)); + IDotnsRegistrarOld registrar = IDotnsRegistrarOld(protocolRegistry.get(DotnsConstantsOld.REGISTRAR)); return registrar.ownerOf(uint256(node)); } @@ -208,7 +210,7 @@ contract DotnsRegistryOld is Initializable, UUPSUpgradeable, OwnableUpgradeable, ) internal { - IStoreFactory factory = IStoreFactory(protocolRegistry.get(DotnsConstants.STORE_FACTORY)); + IStoreFactoryOld factory = IStoreFactoryOld(protocolRegistry.get(DotnsConstantsOld.STORE_FACTORY)); factory.writeLabel(storeOwner, node, fullName); } @@ -272,7 +274,7 @@ contract DotnsRegistryOld is Initializable, UUPSUpgradeable, OwnableUpgradeable, if (!record.exists) return false; - IDotnsRegistrar registrar = IDotnsRegistrar(protocolRegistry.get(DotnsConstants.REGISTRAR)); + IDotnsRegistrarOld registrar = IDotnsRegistrarOld(protocolRegistry.get(DotnsConstantsOld.REGISTRAR)); uint256 tokenId = uint256(node); address tokenOwner = registrar.ownerOf(tokenId); if (account == tokenOwner) return true; @@ -288,7 +290,7 @@ contract DotnsRegistryOld is Initializable, UUPSUpgradeable, OwnableUpgradeable, /// 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)); + IDotnsRegistrarOld registrar = IDotnsRegistrarOld(protocolRegistry.get(DotnsConstantsOld.REGISTRAR)); require(registrar.controllers(IDotnsController(msg.sender)), NotAuthorised()); } diff --git a/contracts/registry/IDotnsProtocolRegistryOld.sol b/contracts/registry/IDotnsProtocolRegistryOld.sol new file mode 100644 index 000000000..16c4eb847 --- /dev/null +++ b/contracts/registry/IDotnsProtocolRegistryOld.sol @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.34; + +/// @title IDotnsProtocolRegistryOld +/// @author Parity +/// @notice Interface for the DotNS protocol-level address registry. +/// @dev Single source of truth for sibling lookups. Contracts resolve each other via well-known +/// `bytes32` constants in `DotnsConstantsOld` so an upgrade or rewire only mutates the +/// registry, never the consumers. The registry also holds the network's top-level domain, +/// so every consumer reads one TLD rather than compiling its own. +/// @custom:security-contact admin@parity.io +interface IDotnsProtocolRegistryOld { + /// @notice Emitted when a protocol address is set or updated. + event AddressUpdated(bytes32 indexed key, address indexed addr); + + /// @notice Thrown when a zero address is provided where one is not allowed. + error ZeroAddress(); + + /// @notice Thrown when the TLD label supplied at initialisation is not a single DNS label. + error InvalidTld(); + + /// @notice Returns the address stored for a given key. + /// @dev Returns `address(0)` when the key is unset; callers must validate when non-zero is + /// required. + function get(bytes32 key) external view returns (address addr); + + /// @notice Sets or updates the address for a given key. + /// @dev Owner-restricted, otherwise @custom:reverts OwnableUnauthorizedAccount. `addr` + /// must be non-zero, otherwise @custom:reverts ZeroAddress. Idempotent when the new + /// value matches the stored one (no event emitted in that case). Maintains a + /// per-address refcount so the same contract can occupy multiple keys without losing + /// its registered status until every key is rewired. Emits + /// @custom:emits AddressUpdated on each effective change. + function set(bytes32 key, address addr) external; + + /// @notice Returns true iff `addr` is currently registered under at least one well-known key. + /// @dev O(1) refcount-backed lookup. Canonical peer-trust check consumed by `LabelStore` + /// writes and `StoreFactory` deploys; only addresses governance has actively + /// registered return true. Treats `address(0)` as never registered regardless of + /// refcount. + function isRegisteredAddress(address addr) external view returns (bool registered); + + /// @notice Returns the namehash of the network's TLD node. + /// @dev `namehash(0, keccak256(bytes(tldLabel)))`, fixed at initialisation. Consumers use it + /// as the root parent when deriving a name's node. + function tldNode() external view returns (bytes32 node); + + /// @notice Returns the network's TLD suffix, including the leading dot (e.g. `.dot`). + /// @dev Fixed at initialisation. Consumers append it when rendering a label as a full name. + function tld() external view returns (string memory suffix); +} diff --git a/contracts/registry/IDotnsRegistryOld.sol b/contracts/registry/IDotnsRegistryOld.sol index 7750d7295..15a7fc79d 100644 --- a/contracts/registry/IDotnsRegistryOld.sol +++ b/contracts/registry/IDotnsRegistryOld.sol @@ -12,11 +12,20 @@ interface IDotnsRegistryOld { /// @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 IDotnsRegistryOld { /// 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. @@ -113,7 +124,7 @@ interface IDotnsRegistryOld { /// 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 + /// delegate to `IDotnsRegistrarOld.ownerOf` and ERC-721 transfers remain authoritative. Emits /// @custom:emits NodeTransferred on success. function setOwner(bytes32 node, address newOwner) external; @@ -129,7 +140,7 @@ interface IDotnsRegistryOld { /// @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))`. + /// back to `IDotnsRegistrarOld.ownerOf(uint256(node))`. function owner(bytes32 node) external view returns (address); /// @notice Returns the resolver of a node. diff --git a/contracts/resolvers/DotnsContentResolverOld.sol b/contracts/resolvers/DotnsContentResolverOld.sol new file mode 100644 index 000000000..d71e28ed0 --- /dev/null +++ b/contracts/resolvers/DotnsContentResolverOld.sol @@ -0,0 +1,151 @@ +// 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 {IDotnsRegistryOld} from "../registry/IDotnsRegistryOld.sol"; +import {IDotnsContentResolver} from "./IDotnsContentResolver.sol"; +import {IDotnsProtocolRegistryOld} from "../registry/IDotnsProtocolRegistryOld.sol"; +import {DotnsConstantsOld} from "../utils/DotnsConstantsOld.sol"; + +/// @title Dotns Content Resolver +/// @notice Implements `IDotnsContentResolver` interface with content hash, text records, and +/// operator approvals. +/// @dev Writes are gated on the registry's authorisation for the node (owner or registrar-level +/// approval) or on a resolver-local operator the owner has approved, rather than on a +/// privileged writer address. Content records are user-managed metadata, so write authority +/// follows the node owner across transfers and honours the same delegates the registry +/// recognises. +/// @custom:security-contact admin@parity.io +contract DotnsContentResolverOld is + Initializable, + UUPSUpgradeable, + OwnableUpgradeable, + ERC165Upgradeable, + IDotnsContentResolver +{ + /// @notice Stores all content hash mappings + mapping(bytes32 node => bytes contentHash) private contenthashes; + + /// @notice Stores all text records + mapping(bytes32 node => mapping(string key => string value)) private textRecords; + + /// @notice Store all approval mapping + mapping(address owner => mapping(address operator => bool approved)) private operators; + + /// @notice Protocol-level address registry for all DotNS contracts. + IDotnsProtocolRegistryOld 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; + + /// @custom:oz-upgrades-unsafe-allow constructor + constructor() { + _disableInitializers(); + } + + /// @notice Initialises the content resolver. + /// @dev Runs once through the UUPS 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(IDotnsProtocolRegistryOld registry) external initializer { + __Ownable_init(msg.sender); + __ERC165_init(); + protocolRegistry = registry; + } + + /// @inheritdoc IDotnsContentResolver + function setContenthash(bytes32 node, bytes calldata hash) external override { + _requireNodeOwnerOrOperator(node); + contenthashes[node] = hash; + emit ContentHashUpdated(node, hash); + } + + /// @inheritdoc IDotnsContentResolver + function contenthash(bytes32 node) external view override returns (bytes memory hash) { + return contenthashes[node]; + } + + /// @inheritdoc IDotnsContentResolver + function setText(bytes32 node, string calldata key, string calldata value) external override { + _requireNodeOwnerOrOperator(node); + textRecords[node][key] = value; + emit TextUpdated(node, key, value); + } + + /// @inheritdoc IDotnsContentResolver + function text( + bytes32 node, + string calldata key + ) + external + view + override + returns (string memory value) + { + return textRecords[node][key]; + } + + /// @inheritdoc IDotnsContentResolver + function setApprovalForAll(address operator, bool approved) external override { + operators[msg.sender][operator] = approved; + emit ApprovalForAll(msg.sender, operator, approved); + } + + /// @inheritdoc IDotnsContentResolver + function isApprovedForAll( + address owner, + address operator + ) + external + view + override + returns (bool) + { + return operators[owner][operator]; + } + + /// @notice Ensures the caller may write records for `node`. + /// @dev Authority is granted to the node owner, to a resolver-local operator the owner has + /// approved for all of their records, or to any address the registry deems authorised + /// for the node. Delegating through the registry means a single registrar-level + /// approval (ERC-721 owner / approved / operator-for-all) also confers record-write + /// authority, while the resolver-local operator mapping remains a narrower record-only + /// delegation that grants no power over ownership or transfers. The cheap owner and + /// local-operator checks run before the cross-contract registry call. + /// @param node Node identifier. + function _requireNodeOwnerOrOperator(bytes32 node) internal view { + IDotnsRegistryOld _registry = IDotnsRegistryOld(protocolRegistry.get(DotnsConstantsOld.REGISTRY)); + address nodeOwner = _registry.owner(node); + require( + msg.sender == nodeOwner || operators[nodeOwner][msg.sender] + || _registry.isAuthorised(node, msg.sender), + NotAuthorised(node, 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 ERC165Upgradeable + function supportsInterface(bytes4 interfaceId) public view override returns (bool) { + return interfaceId == type(IDotnsContentResolver).interfaceId + || super.supportsInterface(interfaceId); + } + + /// @inheritdoc UUPSUpgradeable + function _authorizeUpgrade(address newImplementation) internal override onlyOwner {} +} diff --git a/contracts/resolvers/DotnsPopResolverOld.sol b/contracts/resolvers/DotnsPopResolverOld.sol new file mode 100644 index 000000000..490a92472 --- /dev/null +++ b/contracts/resolvers/DotnsPopResolverOld.sol @@ -0,0 +1,151 @@ +// 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 {IDotnsPopResolver} from "./IDotnsPopResolver.sol"; +import {IDotnsProtocolRegistryOld} from "../registry/IDotnsProtocolRegistryOld.sol"; +import {DotnsConstantsOld} from "../utils/DotnsConstantsOld.sol"; + +/// @title DotnsPopResolverOld +/// @notice Per-node resolver holding records produced by the PoP username flow. +/// @dev Writes are gated on the protocol-registered `POP_CONTROLLER` rather +/// than on node ownership. PoP records are issued by the gateway as part +/// of identity issuance, not curated by the holder, so authority lives +/// with the controller and not the user. +/// @custom:security-contact admin@parity.io +contract DotnsPopResolverOld is + Initializable, + UUPSUpgradeable, + OwnableUpgradeable, + ERC165Upgradeable, + IDotnsPopResolver +{ + /// @notice Protocol-level address registry used to resolve the authorised writer. + IDotnsProtocolRegistryOld public protocolRegistry; + + /// @notice Stored chat-key bytes keyed by node. + mapping(bytes32 node => bytes chatKey) private _chatKeys; + + /// @notice Stored lite-person labelhash keyed by full-person node. + /// @dev Forward direction (full => lite): maps a full-person node to the + /// labelhash of the lite username it was claimed from. + mapping(bytes32 fullNode => bytes32 liteLabelhash) private _liteLinks; + + /// @notice Reverse index mapping a lite labelhash to the full-person node + /// it was promoted to. + /// @dev Written alongside `_liteLinks` on every claim so consumers that look + /// up by lite username resolve the full name without scanning events. + /// Zero when the lite label has never been linked to a full claim. + mapping(bytes32 liteLabelhash => bytes32 fullNode) private _fullClaims; + + /// @dev Reserved storage space to allow for layout changes in the future. + uint256[50] private __gap; + + /// @notice Restricts writes to the address registered as `POP_CONTROLLER`. + modifier onlyPopController() { + _onlyPopController(); + _; + } + + /// @custom:oz-upgrades-unsafe-allow constructor + constructor() { + _disableInitializers(); + } + + /// @notice Initialises the PoP resolver. + /// @dev Called once through the UUPS proxy; `_disableInitializers` on the implementation + /// makes direct calls revert and any repeat call on the proxy reverts with + /// @custom:reverts InvalidInitialization. The registry pointer is the only storage this + /// setup needs because the authorised writer is resolved dynamically through + /// `POP_CONTROLLER`. 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 for writer resolution. + function initialize(IDotnsProtocolRegistryOld registry) external initializer { + __Ownable_init(msg.sender); + __ERC165_init(); + protocolRegistry = registry; + } + + /// @inheritdoc IDotnsPopResolver + function setChatKey( + bytes32 node, + bytes calldata chatKeyBytes + ) + external + override + onlyPopController + { + require(chatKeyBytes.length == 65, InvalidChatKeyLength(chatKeyBytes.length)); + _chatKeys[node] = chatKeyBytes; + emit ChatKeyUpdated(node, chatKeyBytes); + } + + /// @inheritdoc IDotnsPopResolver + function setLiteLink( + bytes32 fullNode, + bytes32 liteLabelhash + ) + external + override + onlyPopController + { + bytes32 oldLite = _liteLinks[fullNode]; + bytes32 oldFull = _fullClaims[liteLabelhash]; + if (oldLite != bytes32(0) && oldLite != liteLabelhash) { + delete _fullClaims[oldLite]; + } + if (oldFull != bytes32(0) && oldFull != fullNode) { + delete _liteLinks[oldFull]; + } + _liteLinks[fullNode] = liteLabelhash; + _fullClaims[liteLabelhash] = fullNode; + emit LiteLinkUpdated(fullNode, liteLabelhash); + } + + /// @inheritdoc IDotnsPopResolver + function chatKey(bytes32 node) external view override returns (bytes memory) { + return _chatKeys[node]; + } + + /// @inheritdoc IDotnsPopResolver + function liteLink(bytes32 fullNode) external view override returns (bytes32) { + return _liteLinks[fullNode]; + } + + /// @inheritdoc IDotnsPopResolver + function fullClaim(bytes32 liteLabelhash) external view override returns (bytes32) { + return _fullClaims[liteLabelhash]; + } + + /// @notice Returns implementation version. + /// @dev Bumped on every upgrade. Used by deployment scripts as a + /// post-upgrade assertion target. + /// @return versionString Current version string. + function version() external pure virtual returns (string memory versionString) { + versionString = "1.0.0"; + } + + /// @inheritdoc ERC165Upgradeable + function supportsInterface(bytes4 interfaceId) public view override returns (bool) { + return + interfaceId == type(IDotnsPopResolver).interfaceId + || super.supportsInterface(interfaceId); + } + + /// @notice Internal check enforcing PoP-controller-only access. + function _onlyPopController() internal view { + address popController = protocolRegistry.get(DotnsConstantsOld.POP_CONTROLLER); + require(msg.sender == popController, NotPopController(msg.sender)); + } + + /// @inheritdoc UUPSUpgradeable + function _authorizeUpgrade(address newImplementation) internal override onlyOwner {} +} diff --git a/contracts/resolvers/DotnsResolverOld.sol b/contracts/resolvers/DotnsResolverOld.sol new file mode 100644 index 000000000..3d9d69e7c --- /dev/null +++ b/contracts/resolvers/DotnsResolverOld.sol @@ -0,0 +1,99 @@ +// 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 {IDotnsResolver} from "./IDotnsResolver.sol"; +import {IDotnsRegistryOld} from "../registry/IDotnsRegistryOld.sol"; +import {IDotnsProtocolRegistryOld} from "../registry/IDotnsProtocolRegistryOld.sol"; +import {DotnsConstantsOld} from "../utils/DotnsConstantsOld.sol"; + +/// @title Dotns Resolver +/// @notice Stores forward-resolution address records for DotNS nodes +/// @dev Writes are gated on node ownership in the forward registry, not on a +/// privileged writer address. Address records describe where a name points +/// and only the current node owner has the authority to set that target. +/// @custom:security-contact admin@parity.io +contract DotnsResolverOld is + Initializable, + UUPSUpgradeable, + OwnableUpgradeable, + ERC165Upgradeable, + IDotnsResolver +{ + /// @notice Protocol-level address registry for all DotNS contracts. + IDotnsProtocolRegistryOld 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 Node => resolved address. + mapping(bytes32 node => address owner) private addresses; + + /// @notice Restricts access to the owner of `node` as recorded in the registry. + /// @param node Node identifier. + modifier onlyNodeOwner(bytes32 node) { + _onlyNodeOwner(node); + _; + } + + /// @custom:oz-upgrades-unsafe-allow constructor + constructor() { + _disableInitializers(); + } + + /// @notice Initialises the resolver. + /// @dev Runs once through the UUPS 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(IDotnsProtocolRegistryOld registry) external initializer { + __Ownable_init(msg.sender); + __ERC165_init(); + protocolRegistry = registry; + } + + /// @inheritdoc IDotnsResolver + function setAddress(bytes32 node, address value) external override onlyNodeOwner(node) { + addresses[node] = value; + emit AddressSet(node, value); + } + + /// @inheritdoc IDotnsResolver + function addressOf(bytes32 node) external view override returns (address value) { + return addresses[node]; + } + + /// @inheritdoc ERC165Upgradeable + function supportsInterface(bytes4 interfaceId) public view override returns (bool) { + return + interfaceId == type(IDotnsResolver).interfaceId || super.supportsInterface(interfaceId); + } + + /// @notice Internal ownership check for a registry node. + /// @dev Resolves the registry lazily through `protocolRegistry` so a registry + /// upgrade or rewire is picked up automatically without a resolver upgrade. + /// @param node Node identifier. + function _onlyNodeOwner(bytes32 node) internal view { + IDotnsRegistryOld _registry = IDotnsRegistryOld(protocolRegistry.get(DotnsConstantsOld.REGISTRY)); + require(_registry.owner(node) == msg.sender, NotAuthorised(node, 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/DotnsReverseResolverOld.sol b/contracts/resolvers/DotnsReverseResolverOld.sol index 54e862a5a..11fbeb698 100644 --- a/contracts/resolvers/DotnsReverseResolverOld.sol +++ b/contracts/resolvers/DotnsReverseResolverOld.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 {DotnsConstants} from "../utils/DotnsConstants.sol"; +import {IDotnsProtocolRegistryOld} from "../registry/IDotnsProtocolRegistryOld.sol"; +import {IDotnsRegistryOld} from "../registry/IDotnsRegistryOld.sol"; +import {DotnsConstantsOld} from "../utils/DotnsConstantsOld.sol"; import {LabelUtils} from "../utils/LabelUtils.sol"; +import {SubnodeUtilsOld} from "../utils/SubnodeUtilsOld.sol"; +import {StringUtils} from "../utils/StringUtils.sol"; /// @title Dotns Reverse Resolver /// @notice Resolves an address to its associated name under the network TLD. @@ -34,7 +36,7 @@ contract DotnsReverseResolverOld is mapping(address owner => string name) private reverseNames; /// @notice Protocol-level address registry for all DotNS contracts. - IDotnsProtocolRegistry public protocolRegistry; + IDotnsProtocolRegistryOld public protocolRegistry; /// @dev Reserved storage space to allow for layout changes in the future. // forge-lint: disable-next-line(mixed-case-variable) @@ -57,7 +59,7 @@ contract DotnsReverseResolverOld is /// `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 { + function initialize(IDotnsProtocolRegistryOld registry) external initializer { __Ownable_init(msg.sender); __ERC165_init(); protocolRegistry = registry; @@ -71,11 +73,8 @@ contract DotnsReverseResolverOld 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 DotnsReverseResolverOld 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 SubnodeUtilsOld.liteSubnodeOf(tldNode, label); } + node = LabelUtils.namehashUnder(tldNode, LabelUtils.labelhashMemory(label)); + } + + /// @notice Resolves the registry via the protocol registry. + function _registry() internal view returns (IDotnsRegistryOld) { + return IDotnsRegistryOld(protocolRegistry.get(DotnsConstantsOld.REGISTRY)); } /// @inheritdoc ERC165Upgradeable @@ -117,8 +128,8 @@ contract DotnsReverseResolverOld is /// @notice Internal check enforcing registrar-only access. function _onlyRegistrar() internal view { - address controller = protocolRegistry.get(DotnsConstants.CONTROLLER); - address registrar = protocolRegistry.get(DotnsConstants.REGISTRAR); + address controller = protocolRegistry.get(DotnsConstantsOld.CONTROLLER); + address registrar = protocolRegistry.get(DotnsConstantsOld.REGISTRAR); require( msg.sender == controller || msg.sender == registrar, NotRegistrarController(msg.sender) ); diff --git a/contracts/store/IStoreFactoryOld.sol b/contracts/store/IStoreFactoryOld.sol new file mode 100644 index 000000000..690925391 --- /dev/null +++ b/contracts/store/IStoreFactoryOld.sol @@ -0,0 +1,157 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.34; + +/// @title IStoreFactoryOld +/// @notice Interface for the DotNS per-user store factory. +/// @dev Owns two `UpgradeableBeacon` instances; one for `LabelStore` (protocol-managed), +/// one for `UserStore` (user-claimed). Each user may acquire at most one of each, +/// forever. There is no transfer, no redeploy, no additional store type. +/// @custom:security-contact admin@parity.io +interface IStoreFactoryOld { + /// @notice Emitted when a `LabelStore` beacon-proxy is deployed for `user`. + /// @param user The user the store is bound to. + /// @param store The deployed store address. + event LabelStoreDeployed(address indexed user, address indexed store); + + /// @notice Emitted when `user` claims their `UserStore` beacon-proxy. + /// @param user The user the store is bound to. + /// @param store The deployed store address. + event UserStoreClaimed(address indexed user, address indexed store); + + /// @notice Emitted when the `LabelStore` implementation behind the label beacon is upgraded. + /// @param newImplementation The new implementation address. + event LabelStoreImplementationUpgraded(address indexed newImplementation); + + /// @notice Emitted when the `UserStore` implementation behind the user beacon is upgraded. + /// @param newImplementation The new implementation address. + event UserStoreImplementationUpgraded(address indexed newImplementation); + + /// @notice Thrown when attempting to deploy or claim a store that already exists. + /// @param user The user for whom the store exists. + /// @param existingStore The already-deployed store address. + error AlreadyDeployed(address user, address existingStore); + + /// @notice Thrown when a zero user address is supplied. + /// @param user The invalid user argument. + error InvalidUser(address user); + + /// @notice Thrown when a zero protocol registry address is supplied to the constructor. + /// @param protocolRegistry The invalid registry argument. + error InvalidProtocolRegistry(address protocolRegistry); + + /// @notice Thrown when a zero implementation address is supplied to the constructor or an + /// upgrade. @param implementation The invalid implementation argument. + error InvalidImplementation(address implementation); + + /// @notice Thrown when an unauthorised address attempts to deploy a label store. + /// @param caller The unauthorised msg.sender. + error NotAuthorised(address caller); + + /// @notice Thrown when a freshly deployed proxy does not report the expected owner. + error ImplementationBindingMismatch(); + + /// @notice Returns the `UpgradeableBeacon` address backing all `LabelStore` proxies. + /// @return beacon Address of the beacon contract. + function labelStoreBeacon() external view returns (address beacon); + + /// @notice Returns the `UpgradeableBeacon` address backing all `UserStore` proxies. + /// @return beacon Address of the beacon contract. + function userStoreBeacon() external view returns (address beacon); + + /// @notice Returns the protocol registry address used for writer authorisation. + /// @return registry Address of the protocol registry. + function protocolRegistry() external view returns (address registry); + + /// @notice Deploys a `LabelStore` beacon-proxy bound to `user`. + /// @dev Callable by the factory owner or a component named in + /// @custom:function StoreAuth.isStoreWriter; any other caller + /// @custom:reverts NotAuthorised. `user` must be non-zero, + /// otherwise @custom:reverts InvalidUser. The user must not already have a + /// `LabelStore`, otherwise @custom:reverts AlreadyDeployed. After deployment the + /// freshly initialised proxy must report `user` as its owner, otherwise + /// @custom:reverts ImplementationBindingMismatch. Emits + /// @custom:emits LabelStoreDeployed on success. + /// @param user The user the store is bound to forever. + /// @return store The deployed store address. + function deployLabelStoreFor(address user) external returns (address store); + + /// @notice Returns the `LabelStore` address bound to `user`, or the zero address if none. + /// @param user The user to look up. + /// @return store The bound store address, or zero. + function getLabelStore(address user) external view returns (address store); + + /// @notice Returns the total number of `LabelStore` proxies ever deployed. + /// @return count Length of the deployment list. + function getLabelStoreCount() external view returns (uint256 count); + + /// @notice Paginated enumeration over every `LabelStore` proxy ever deployed. + /// @dev Insertion order of `deployLabelStoreFor` calls. `offset >= getLabelStoreCount()` + /// returns an empty array; result length is `min(limit, count - offset)`. + /// @param offset Start index. + /// @param limit Maximum entries to return. + /// @return stores Slice of label-store addresses. + function getLabelStores( + uint256 offset, + uint256 limit + ) + external + view + returns (address[] memory stores); + + /// @notice Upgrades the `LabelStore` implementation for every existing and future proxy. + /// @dev Callable by the factory owner only, otherwise + /// @custom:reverts OwnableUnauthorizedAccount. `newImplementation` must be non-zero, + /// otherwise @custom:reverts InvalidImplementation. The candidate is sentinel-probed by + /// calling `ILabelStore.protocolRegistry` on it before the beacon is rotated; if the + /// address does not implement that selector the probe reverts and the upgrade does not + /// land (deliberate fail-fast guard, no named error). Delegates to + /// `UpgradeableBeacon.upgradeTo` and emits + /// @custom:emits LabelStoreImplementationUpgraded on success. + /// @param newImplementation The new implementation address. + function upgradeLabelStoreImplementation(address newImplementation) external; + + /// @notice Caller claims their `UserStore` beacon-proxy. + /// @dev Self-claim only; `_owner` on the resulting store is always `msg.sender`, + /// regardless of who pays gas. One store per caller, forever: a caller who already + /// has a `UserStore` @custom:reverts AlreadyDeployed. After deployment the freshly + /// initialised proxy must report `msg.sender` as its owner, otherwise + /// @custom:reverts ImplementationBindingMismatch. Emits + /// @custom:emits UserStoreClaimed on success. + /// @return store The deployed store address. + function claimUserStore() external returns (address store); + + /// @notice Returns the `UserStore` address bound to `user`, or the zero address if none. + /// @param user The user to look up. + /// @return store The bound store address, or zero. + function getUserStore(address user) external view returns (address store); + + /// @notice Returns the total number of `UserStore` proxies ever claimed. + /// @return count Length of the claim list. + function getUserStoreCount() external view returns (uint256 count); + + /// @notice Paginated enumeration over every `UserStore` proxy ever claimed. + /// @dev Insertion order of `claimUserStore` calls. `offset >= getUserStoreCount()` + /// returns an empty array; result length is `min(limit, count - offset)`. + /// @param offset Start index. + /// @param limit Maximum entries to return. + /// @return stores Slice of user-store addresses. + function getUserStores( + uint256 offset, + uint256 limit + ) + external + view + returns (address[] memory stores); + + /// @notice Upgrades the `UserStore` implementation for every existing and future proxy. + /// @dev Callable by the factory owner only, otherwise + /// @custom:reverts OwnableUnauthorizedAccount. `newImplementation` must be non-zero, + /// otherwise @custom:reverts InvalidImplementation. The candidate is sentinel-probed by + /// calling `IUserStore.getKeyCount` on it before the beacon is rotated; if the address + /// does not implement that selector the probe reverts and the upgrade does not land + /// (deliberate fail-fast guard, no named error). Delegates to + /// `UpgradeableBeacon.upgradeTo` and emits + /// @custom:emits UserStoreImplementationUpgraded on success. + /// @param newImplementation The new implementation address. + function upgradeUserStoreImplementation(address newImplementation) external; +} diff --git a/contracts/utils/DotnsConstantsOld.sol b/contracts/utils/DotnsConstantsOld.sol new file mode 100644 index 000000000..fb61d5f01 --- /dev/null +++ b/contracts/utils/DotnsConstantsOld.sol @@ -0,0 +1,192 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.34; + +/// @title DotNS Constants +/// @notice Protocol-level invariants shared across DotNS contracts. +/// @dev Centralises the well-known protocol-registry keys that every contract uses to discover +/// its siblings (registrar, controller, registry, resolvers, etc.). Each key is a +/// role address resolved at call time, so rotating an implementation is a +/// single `set` on the protocol registry without redeploying consumers. The TLD is not a +/// constant here: it is set per network on the protocol registry and read by every consumer. +/// @custom:security-contact admin@parity.io +library DotnsConstantsOld { + /// @notice Address of revive's System precompile, exposed by every revive runtime + /// that opts the precompile in. + /// @dev Mirrors the upstream `SYSTEM_ADDR` constant in + /// `substrate/frame/revive/uapi/sol/ISystem.sol`. Consumed by + /// `DotnsPopControllerOld` and `DotnsNameWhitelistOld` to authenticate + /// Root-origin dispatches via `ISystem.originIsRoot()`. + address internal constant REVIVE_SYSTEM = address(0x0900); + + /// @notice Address of the Proof-of-Personhood precompile backed by the + /// alias-accounts pallet on Asset Hub. + /// @dev Consumed by `PopRulesOld` to read each account's personhood tier + /// (`None` / `Lite` / `Full`) and the dotns-scoped `contextAlias`. + address internal constant PERSONHOOD = address(0x000000000000000000000000000000000a010000); + + /// @notice Application identifier passed to @custom:function IPersonhood.personhoodStatus. + /// @dev Fixed per project so the same person receives a stable, dotns-only + /// `contextAlias` and no cross-application linkability is exposed. + /// forge-lint: disable-next-line(unsafe-typecast) + bytes32 internal constant PERSONHOOD_CONTEXT = bytes32("dotns"); + + /// @notice Launch deposit passed into the `DotnsFlatPricing` constructor. + /// @dev 10 DOT under revive's 18-decimal Asset Hub convention. A new amount is a fresh model + /// deployment registered under @custom:constant COST_MODEL, so this constant seeds the + /// model rather than being read afterwards. Single source of truth for deploy scripts and + /// tests so the seed cannot drift between call sites. + uint256 internal constant BASE_DEPOSIT = 10 ether; + + /// @notice Price floor F passed into the `DotnsScarcityPricing` candidate's constructor. + /// @dev Below `BASE_DEPOSIT` so that curve falls above nine characters. Seeds the candidate + /// constructor; a new floor is a fresh model deployment. + uint256 internal constant MIN_PRICE = 0.1 ether; + + /// @notice Well-known key for the cost model pricing registrations by base length. + /// @dev Role: single authority for the wei amount a registration costs. `PopRulesOld` resolves + /// it here on every pricing read, so swapping the model is one `set` on the protocol + /// registry without redeploying `PopRulesOld` or its consumers. + /// forge-lint: disable-next-line(unsafe-typecast) + bytes32 internal constant COST_MODEL = bytes32("costModel"); + + /// @notice Default release cooldown seeded on `DotnsNameEscrowOld.initialize`. + /// @dev Single source of truth for deploy scripts and tests so the value cannot drift between + /// call sites. Bounded on-chain by `DotnsNameEscrowOld.MAX_COOLDOWN`. Live deployments rotate + /// the runtime value via `updateCooldown` rather than rebuilding consumers. + uint256 internal constant ESCROW_COOLDOWN = 15 minutes; + + /// @notice Default redeem window seeded on `DotnsNameEscrowOld.initialize`. + /// @dev Single source of truth for deploy scripts and tests. Bounded on-chain by + /// `DotnsNameEscrowOld.MAX_REDEEM_WINDOW`. Live deployments rotate the runtime value via + /// `updateRedeemWindow`. + uint256 internal constant ESCROW_REDEEM_WINDOW = 1 days; + /// @notice Maximum entries a paginated view returns in a single page. + /// @dev Shared ceiling for paginated reads: a view clamps its returned array to this figure, + /// and callers page through larger sets with `offset`. + uint256 internal constant MAX_PAGE_SIZE = 200; + + /// @notice Default per-name live-claim cap the name whitelist starts with. + /// @dev Governance retunes it on the whitelist within `WHITELIST_MAX_CLAIMANTS_LIMIT`. + uint16 internal constant WHITELIST_DEFAULT_MAX_CLAIMANTS = 64; + + /// @notice Default claim-reason byte cap the name whitelist starts with. + /// @dev Governance retunes it on the whitelist within `WHITELIST_MAX_REASON_LIMIT`. + uint256 internal constant WHITELIST_DEFAULT_MAX_REASON_BYTES = 256; + + /// @notice Upper bound on the whitelist live-claim cap. Caps the claim clear-loop below the + /// block gas limit. + uint16 internal constant WHITELIST_MAX_CLAIMANTS_LIMIT = 128; + + /// @notice Upper bound on the whitelist reason byte cap. + uint256 internal constant WHITELIST_MAX_REASON_LIMIT = 256; + + /// @notice Default cap on labels granted in one `grantNames` call. + /// @dev Governance retunes it on the whitelist within `WHITELIST_MAX_GRANT_BATCH_LIMIT`. + uint16 internal constant WHITELIST_DEFAULT_MAX_GRANT_BATCH = 100; + + /// @notice Upper bound on the `grantNames` batch cap. Bounds one call below the block gas + /// limit. + uint16 internal constant WHITELIST_MAX_GRANT_BATCH_LIMIT = 256; + + /// @notice Well-known key for the ERC721 registrar backing name ownership. + /// @dev Role: token-of-record for registered names. Mints, burns, and tracks the + /// `tokenId => label` mapping consumed by the forward registry on + /// transfer. + /// forge-lint: disable-next-line(unsafe-typecast) + bytes32 internal constant REGISTRAR = bytes32("registrar"); + + /// @notice Well-known key for the registrar controller orchestrating commit-reveal + /// registration. @dev Role: commit-reveal entry point for the public registration flow. + /// Calls `register` on the registrar after pricing and validation. + /// forge-lint: disable-next-line(unsafe-typecast) + bytes32 internal constant CONTROLLER = bytes32("controller"); + + /// @notice Well-known key for the forward registry storing node ownership and resolver. + /// @dev Role: source of truth for `(node => owner, resolver)`. Read by every + /// resolver gate that defers authority to the node owner. + /// forge-lint: disable-next-line(unsafe-typecast) + bytes32 internal constant REGISTRY = bytes32("registry"); + + /// @notice Well-known key for the reverse resolver for address-to-name mapping. + /// @dev Role: stores `address => name` reverse records. Writer is the + /// registrar/controller, not the address holder. + /// forge-lint: disable-next-line(unsafe-typecast) + bytes32 internal constant REVERSE_RESOLVER = bytes32("reverseResolver"); + + /// @notice Well-known key for the PoP oracle enforcing eligibility and pricing. + /// @dev Role: arbiter of PoP cross-flow priority and pricing. Consulted by + /// both the public commit-reveal controller and the PoP controller. + /// forge-lint: disable-next-line(unsafe-typecast) + bytes32 internal constant POP_RULES = bytes32("popRules"); + + /// @notice Well-known key for the factory deploying per-user Store instances. + /// @dev Role: deploy-on-demand provisioning of user `LabelStore` proxies and + /// authorisation gate for protocol writes into them. + /// forge-lint: disable-next-line(unsafe-typecast) + bytes32 internal constant STORE_FACTORY = bytes32("storeFactory"); + + /// @notice Well-known key for the forward resolver storing address records. + /// @dev Role: `node => address` records. Writes gated on node ownership. + /// forge-lint: disable-next-line(unsafe-typecast) + bytes32 internal constant RESOLVER = bytes32("resolver"); + + /// @notice Well-known key for the content resolver storing content hashes and text records. + /// @dev Role: `node => contenthash`/`text` records and ERC721-style operator + /// approvals. Writes gated on node ownership or operator approval. + /// forge-lint: disable-next-line(unsafe-typecast) + bytes32 internal constant CONTENT_RESOLVER = bytes32("contentResolver"); + + /// @notice Well-known key for the dedicated PoP controller orchestrating lite/full-person + /// username issuance on behalf of the PoP gateway. + /// @dev Kept distinct from `CONTROLLER` (commit-reveal public controller) so the + /// two can coexist per `DotnsRegistrarOld`'s multi-controller affordance. + /// forge-lint: disable-next-line(unsafe-typecast) + bytes32 internal constant POP_CONTROLLER = bytes32("popController"); + + /// @notice Well-known key for the PoP resolver holding per-name records produced + /// by the PoP username flow (chat keys, lite => full links). + /// @dev Role: `node => chatKey` and bidirectional `lite <=> full` link index. + /// Writer is the `POP_CONTROLLER`, not the node owner. + /// forge-lint: disable-next-line(unsafe-typecast) + bytes32 internal constant POP_RESOLVER = bytes32("popResolver"); + + /// @notice Well-known key for the read-only lens over PoP identity data. + /// @dev Role: off-chain query surface. Composes the account name listings, the per-name + /// record, and the account summary from the controller, registrar, store factory, PoP + /// resolver, and PopRulesOld. Holds no authority and is consumed by clients, not by other + /// contracts. + /// forge-lint: disable-next-line(unsafe-typecast) + bytes32 internal constant POP_LENS = bytes32("popLens"); + + /// @notice Well-known key for the name escrow holding refundable deposits and + /// driving the release lifecycle for registered names. + /// @dev Role: custodial vault for registration deposits and the state machine + /// that drives the name release lifecycle. + /// forge-lint: disable-next-line(unsafe-typecast) + bytes32 internal constant NAME_ESCROW = bytes32("nameEscrow"); + + /// @notice Well-known key for the generic Multicall3 batching helper. + /// @dev Role: unauthorised arbitrary-target multicall utility used by + /// clients and tooling. Target contracts still enforce their own + /// permissions and observe Multicall3 as `msg.sender`. + /// forge-lint: disable-next-line(unsafe-typecast) + bytes32 internal constant MULTICALL3 = bytes32("multicall3"); + + /// @notice Well-known key for the CREATE3 factory backing the deterministic + /// deploy pipeline. + /// @dev Role: permissionless CREATE3 deployer. The first deploy stage + /// bootstraps the factory, records it under this key, and every later + /// stage resolves it from here, so deterministic addresses never depend + /// on an environment variable. + /// forge-lint: disable-next-line(unsafe-typecast) + bytes32 internal constant CREATE3_FACTORY = bytes32("create3Factory"); + + /// @notice Well-known key for the pre-launch name whitelist that binds a label to the + /// one address permitted to register it. + /// @dev Role: authority for label-bound registration grants. The public controller resolves + /// it here and reads it on the reserved path, requiring a grant naming the intended owner + /// unless the dispatch is Root, and consuming the grant on a successful mint. The PoP + /// controller does not consult it. + /// forge-lint: disable-next-line(unsafe-typecast) + bytes32 internal constant NAME_WHITELIST = bytes32("nameWhitelist"); +} diff --git a/contracts/utils/RegistrationUtilsOld.sol b/contracts/utils/RegistrationUtilsOld.sol new file mode 100644 index 000000000..cd9760192 --- /dev/null +++ b/contracts/utils/RegistrationUtilsOld.sol @@ -0,0 +1,97 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.34; + +import {IDotnsRegistrarOld} from "../registrars/IDotnsRegistrarOld.sol"; +import {IDotnsRegistryOld} from "../registry/IDotnsRegistryOld.sol"; +import {IDotnsProtocolRegistryOld} from "../registry/IDotnsProtocolRegistryOld.sol"; +import {IStoreFactoryOld} from "../store/IStoreFactoryOld.sol"; +import {StoreUtilsOld} from "./StoreUtilsOld.sol"; +import {DotnsConstantsOld} from "./DotnsConstantsOld.sol"; + +/// @title DotNS Registration Utilities Library +/// @notice Single canonical implementation of the "mint + forward-registry + store-write" +/// triad used by every DotNS registration flow. +/// @dev Exists so that every controller (public commit-reveal, PoP gateway, future +/// privileged flows) calls the same sequence. Without this library each controller +/// re-implements the sequence, and the implementations drift. +/// @dev Scope: this library is deliberately minimal. It only performs the steps +/// that every registration flow needs regardless of policy: +/// 1. Mint the ERC721 name token on the base registrar. +/// 2. Write the forward registry entry (node => owner + default resolver). +/// 3. Resolve the per-user `LabelStore` (deploying on demand) so the caller +/// can pass it back. The registrar writes the labels-only store entry +/// internally. +/// Flow-specific concerns (pricing, reverse-record setting, chat-key persistence, +/// reservation queue mutation) stay inside the calling controller. +/// @custom:security-contact admin@parity.io +library RegistrationUtilsOld { + using StoreUtilsOld for IStoreFactoryOld; + + /// @notice Inputs describing a single name registration. + /// @dev Passed as a struct so callers do not have to thread a growing positional + /// argument list, and so future additions (e.g. a subname parent node) can + /// be made additively without breaking call sites. + /// @param protocolRegistry The protocol-level address registry for sibling lookups. + /// @param user Address receiving the name. + /// @param label Human-readable label (without the TLD). + /// @param labelhash `keccak256(bytes(label))`. + /// @param node `namehash(tldNode, labelhash)`. + struct RegistrationContext { + IDotnsProtocolRegistryOld protocolRegistry; + address user; + string label; + bytes32 labelhash; + bytes32 node; + } + + /// @notice Resolved sibling contracts for a registration call. + /// @dev Held as a struct internally so the helper can pass a single value to the + /// downstream steps rather than three separate locals. Never returned to + /// callers; kept in memory for the lifetime of one `registerAndStore`. + struct Siblings { + IDotnsRegistrarOld registrar; + IDotnsRegistryOld registry; + IStoreFactoryOld storeFactory; + } + + /// @notice Performs the canonical mint + forward-registry + store-write sequence. + /// @dev Callable by any authorised controller. Emits no events; each controller + /// emits its own flow-level event after this returns, so behavioural drift + /// between flows stays contained at the emission layer rather than at the + /// underlying state-transition layer. Store authorisation is resolved against the + /// protocol registry on every write (@custom:function StoreAuth.isStoreWriter), so no + /// per-store allowlist bookkeeping is needed here. + /// @dev The registrar writes the `LabelStore` entry directly inside `register` + /// so this helper deliberately does not call `StoreUtilsOld.writeLabel`. + /// Doing it twice would deploy or touch the store on every flow and + /// could conflict with the registrar's locked-entry semantics. + /// @param context Registration inputs. See @custom:struct RegistrationContext. + /// @return labelStore The resolved or newly deployed `LabelStore` address for `context.user`. + function registerAndStore(RegistrationContext memory context) + internal + returns (address labelStore) + { + Siblings memory siblings = _resolveSiblings(context.protocolRegistry); + + siblings.registrar.register(uint256(context.node), context.user, context.label); + siblings.registry.setOwner(context.node, context.user); + + labelStore = siblings.storeFactory.getLabelStore(context.user); + } + + /// @notice Resolves sibling contracts via the protocol registry. + /// @dev Exists so that resolution is one round-trip through a single helper and + /// not duplicated inline at every call site. If protocol-registry key + /// conventions change, the change lands here. + function _resolveSiblings(IDotnsProtocolRegistryOld protocolRegistry) + private + view + returns (Siblings memory siblings) + { + siblings = Siblings({ + registrar: IDotnsRegistrarOld(protocolRegistry.get(DotnsConstantsOld.REGISTRAR)), + registry: IDotnsRegistryOld(protocolRegistry.get(DotnsConstantsOld.REGISTRY)), + storeFactory: IStoreFactoryOld(protocolRegistry.get(DotnsConstantsOld.STORE_FACTORY)) + }); + } +} diff --git a/contracts/utils/StoreUtilsOld.sol b/contracts/utils/StoreUtilsOld.sol index 4a8b6ac0c..cbdf8fe61 100644 --- a/contracts/utils/StoreUtilsOld.sol +++ b/contracts/utils/StoreUtilsOld.sol @@ -2,16 +2,13 @@ pragma solidity ^0.8.34; import {ILabelStore} from "../store/ILabelStore.sol"; -import {IStoreFactory} from "../store/IStoreFactory.sol"; +import {IStoreFactoryOld} from "../store/IStoreFactoryOld.sol"; /// @title DotNS Store Utilities Library /// @notice Canonical helpers for protocol writes into per-user `LabelStore` instances. /// @dev One auth rule, one write path. Every DotNS consumer (controller, registrar, /// registry, PoP controller) funnels label writes through `writeLabel` so /// authorisation and deploy-on-first-use semantics are identical across flows. -/// @dev PR-scoped snapshot of the `StoreUtils` library as deployed on chain, kept only so the -/// `*Old.sol` implementation snapshots compile after master removed `writeLabel` (#301). -/// Deleted before merge with the other upgrade artefacts per CONTRIBUTING.md. /// @custom:security-contact admin@parity.io library StoreUtilsOld { /// @notice Returns the `LabelStore` for `user`, deploying one via the factory if absent. @@ -22,7 +19,7 @@ library StoreUtilsOld { /// @param factory The store factory. /// @param user The user whose label store is being resolved. /// @return store The resolved or newly deployed store address. - function ensureLabelStore(IStoreFactory factory, address user) + function ensureLabelStore(IStoreFactoryOld factory, address user) internal returns (address store) { @@ -44,7 +41,7 @@ library StoreUtilsOld { /// @param label The label string (typically the full name, e.g. "alice.dot"). /// @return store The resolved or newly deployed store address. function writeLabel( - IStoreFactory factory, + IStoreFactoryOld factory, address user, bytes32 labelhash, string memory label diff --git a/contracts/utils/SubnodeUtilsOld.sol b/contracts/utils/SubnodeUtilsOld.sol new file mode 100644 index 000000000..d53a2a632 --- /dev/null +++ b/contracts/utils/SubnodeUtilsOld.sol @@ -0,0 +1,134 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.34; + +import {IDotnsRegistryOld} from "../registry/IDotnsRegistryOld.sol"; +import {IDotnsRegistrarOld} from "../registrars/IDotnsRegistrarOld.sol"; +import {IDotnsProtocolRegistryOld} from "../registry/IDotnsProtocolRegistryOld.sol"; +import {LabelUtils} from "./LabelUtils.sol"; +import {StringUtils} from "./StringUtils.sol"; +import {DotnsConstantsOld} from "./DotnsConstantsOld.sol"; + +/// @title DotNS Subnode Utilities Library +/// @notice General-purpose helpers for registering names that live as subnodes of another name, +/// rather than as tokenised second-level registrations. +/// @dev A subname has no token: its ownership lives in the registry record, not in the registrar's +/// ERC-721 ledger. So it is registered through @custom:function IDotnsRegistryOld.setSubnodeOwner +/// here, rather than through the tokenised mint triad of @custom:contract RegistrationUtilsOld. +/// @custom:security-contact admin@parity.io +library SubnodeUtilsOld { + /// @notice Inputs describing a single subname registration. + /// @dev Passed as a struct so call sites name each field rather than thread a positional + /// argument list, mirroring @custom:struct RegistrationUtilsOld.RegistrationContext. + /// @param protocolRegistry Protocol-level address registry used to resolve the registry and + /// TLD. + /// @param parentLabel Second-level parent label, e.g. `01`. + /// @param subLabel Subname label to register, e.g. `alice`. + /// @param owner Address to record as the subname owner. + /// @param persist Whether the registry should index the subnode into the owner's `LabelStore`. + struct SubnameContext { + IDotnsProtocolRegistryOld protocolRegistry; + string parentLabel; + string subLabel; + address owner; + bool persist; + } + + /// @notice Derives the subnode `subLabel.parentLabel.tld`. + /// @dev The single source of truth for how a two-level name maps to a node: walk `parentLabel` + /// under `tldNode`, then `subLabel` under that. Consumers that need the node without + /// writing it (readers, validators) call this so they agree with the write path. + /// @param tldNode The TLD node. + /// @param parentLabel Second-level parent label, e.g. `01`. + /// @param subLabel Subname label, e.g. `alice`. + /// @return subnode Namehash of `subLabel` under `parentLabel.tld`. + function subnodeOf( + bytes32 tldNode, + string memory parentLabel, + string memory subLabel + ) + internal + pure + returns (bytes32 subnode) + { + bytes32 parentNode = + LabelUtils.namehashUnder(tldNode, LabelUtils.labelhashMemory(parentLabel)); + subnode = LabelUtils.namehashUnder(parentNode, LabelUtils.labelhashMemory(subLabel)); + } + + /// @notice Derives the subnode for a lite label `.`, splitting it on the + /// separator first. + /// @dev The single place a lite label is turned into a node, shared by the write path and every + /// reader of a lite name so the issuer and its readers agree on where a lite name lives. + /// Callers gate on @custom:function StringUtils.isLitePersonLabelMemory beforehand, so the + /// label is known to carry the separator this splits on. + /// @param tldNode The TLD node. + /// @param liteLabel Lite label, e.g. `alice.01`. + /// @return subnode Namehash of the stem beneath its numeric container beneath the TLD. + function liteSubnodeOf( + bytes32 tldNode, + string memory liteLabel + ) + internal + pure + returns (bytes32 subnode) + { + (string memory stem, string memory suffix) = StringUtils.splitLiteLabel(liteLabel); + subnode = subnodeOf(tldNode, suffix, stem); + } + + /// @notice Registers `subLabel` beneath the second-level name `parentLabel`, minting the parent + /// if it does not exist yet. + /// @dev Derives the parent node `parentLabel.tld`; when no name is registered there yet it is + /// minted through the registrar with the calling contract as owner, so the caller holds + /// the parent authority @custom:function IDotnsRegistryOld.setSubnodeOwner requires. The + /// calling contract must therefore be a registrar controller, otherwise the registrar + /// @custom:reverts NotController. When a name already exists at the parent it must be + /// owned by the caller, otherwise @custom:reverts NotAuthorised, so a name someone else holds + /// is + /// never treated as the caller's parent. Ownership of the subname is then recorded through + /// @custom:function IDotnsRegistryOld.setSubnodeOwner. `persist` is forwarded to the + /// registry: when false the ownership and resolver record is written but the owner's + /// `LabelStore` is + /// not, and the caller writes the label into the store separately. + /// @dev The parent is owned by the calling contract's address. A caller that migrates to a new + /// address rather than upgrading in place strands every parent it minted and can no longer + /// register subnames beneath them; the caller must upgrade in place, or hold parents under + /// an owner whose address is stable across migrations. + /// @dev `parentLabel` is a single label registered directly under the TLD, so the parent node + /// is derived as `namehash(tldNode, keccak(parentLabel))`; deeper parents are out of scope for + /// this helper. + /// @param context Subname registration inputs. See @custom:struct SubnameContext. + /// @return subnode Namehash of the registered subname. + function registerSubname(SubnameContext memory context) internal returns (bytes32 subnode) { + IDotnsProtocolRegistryOld protocolRegistry = context.protocolRegistry; + + bytes32 parentNode = LabelUtils.namehashUnder( + protocolRegistry.tldNode(), LabelUtils.labelhashMemory(context.parentLabel) + ); + + IDotnsRegistrarOld registrar = IDotnsRegistrarOld(protocolRegistry.get(DotnsConstantsOld.REGISTRAR)); + IDotnsRegistryOld registry = IDotnsRegistryOld(protocolRegistry.get(DotnsConstantsOld.REGISTRY)); + + // Mint the parent on first use, owned by the caller, and pass an empty label so no + // `LabelStore` is written for it. When it already exists it must belong to the caller, + // otherwise a name someone else registered would be treated as this caller's parent, so the + // ownership is checked locally rather than assumed from an out-of-contract gate. Subsequent + // subnames under a parent the caller already owns skip straight to the subnode write. + if (!registrar.exists(uint256(parentNode))) { + registrar.register(uint256(parentNode), address(this), ""); + registry.setOwner(parentNode, address(this)); + } else { + require(registry.owner(parentNode) == address(this), IDotnsRegistryOld.NotAuthorised()); + } + + subnode = registry.setSubnodeOwner( + IDotnsRegistryOld.SubnodeRecord({ + parentNode: parentNode, + subLabel: context.subLabel, + parentLabel: context.parentLabel, + owner: context.owner, + persist: context.persist + }) + ); + } +} diff --git a/contracts/utils/SystemUtilsOld.sol b/contracts/utils/SystemUtilsOld.sol new file mode 100644 index 000000000..131e6d3ff --- /dev/null +++ b/contracts/utils/SystemUtilsOld.sol @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.34; + +import {ISystem} from "../external/revive/ISystem.sol"; +import {DotnsConstantsOld} from "./DotnsConstantsOld.sol"; + +/// @title SystemUtilsOld +/// @notice Shared access to revive's System precompile for DotNS contracts. +/// @dev Canonical wrapper around `ISystem` at `DotnsConstantsOld.REVIVE_SYSTEM`, so the precompile +/// address and interface are wired in one place rather than duplicated per consumer. +/// @custom:security-contact admin@parity.io +library SystemUtilsOld { + /// @notice Returns whether the transaction-level origin is substrate Root. + /// @dev Reads the stack origin through `ISystem.originIsRoot`, which holds through a UUPS + /// proxy's delegatecall frame where `callerIsRoot` returns false, and returns false + /// rather than reverting on a non-Root origin. + /// @return root True when the transaction origin is Root. + function originIsRoot() internal view returns (bool root) { + return ISystem(DotnsConstantsOld.REVIVE_SYSTEM).originIsRoot(); + } +} diff --git a/contracts/whitelist/DotnsNameWhitelistOld.sol b/contracts/whitelist/DotnsNameWhitelistOld.sol new file mode 100644 index 000000000..3bcfa248d --- /dev/null +++ b/contracts/whitelist/DotnsNameWhitelistOld.sol @@ -0,0 +1,512 @@ +// 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 {EnumerableSet} from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; + +import {IDotnsNameWhitelist} from "./IDotnsNameWhitelist.sol"; +import {IDotnsProtocolRegistryOld} from "../registry/IDotnsProtocolRegistryOld.sol"; +import {LabelUtils} from "../utils/LabelUtils.sol"; +import {StringUtils} from "../utils/StringUtils.sol"; +import {DotnsConstantsOld} from "../utils/DotnsConstantsOld.sol"; +import {SystemUtilsOld} from "../utils/SystemUtilsOld.sol"; + +/// @title DotnsNameWhitelistOld +/// @notice Pre-launch name whitelist. A name is Open until governance reserves it or a claim is +/// accepted for it. Several beneficiaries may claim the same Open name, each with a +/// reason, and governance accepts one as the winner. +/// @dev Lives behind its own UUPS proxy with its own storage. Callers pass bare labels only; the +/// contract derives the node from the label and the TLD in the protocol registry, so a +/// caller cannot supply a mismatched hash. Claims are keyed by the beneficiary `user`, not +/// the submitter, so a relayer or a cross-chain sovereign account can submit on a user's +/// behalf and the name binds to that user. All state is on-chain and queryable through views; +/// no event indexing is required. A name holds at most `maxClaimants` live claims, which +/// bounds the loop that clears them on resolution. Resolving a name deletes its claims, +/// refunding their storage deposit, so only reserved or won names persist. The entire admin +/// surface is substrate Root: `SystemUtilsOld.originIsRoot` is true through the proxy's +/// delegatecall frame, and no gate reads `msg.sender`, so Root's lack of an address is not a +/// problem. No signed account grants, revokes, reserves, or retunes a cap; the owner's +/// authority is upgrade only. The public and PoP controllers hold only the `consume` hook. +/// Entries are keyed by the node under the active TLD, which the deployment holds immutable +/// for the whitelist's lifetime. +/// @custom:security-contact admin@parity.io +contract DotnsNameWhitelistOld is + Initializable, + UUPSUpgradeable, + OwnableUpgradeable, + ERC165Upgradeable, + IDotnsNameWhitelist +{ + using StringUtils for string; + using EnumerableSet for EnumerableSet.AddressSet; + using EnumerableSet for EnumerableSet.Bytes32Set; + + /// @notice One role's membership and its admin role. + /// @dev Member of the reserved AccessControl namespace, unused because gating is Root only. + /// @param hasRole Whether an account holds the role. + /// @param adminRole Admin role that manages the role. + struct RoleData { + mapping(address account => bool) hasRole; + bytes32 adminRole; + } + + /// @notice Reserved OpenZeppelin access-control namespace held at its ERC-7201 slot. + /// @dev Declared and left unused so the namespace stays present in the layout. Its slot derives + /// from the label, disjoint from the sequential slots below, so it consumes none of them. + /// @param _roles Role data keyed by role identifier. + /// @custom:storage-location erc7201:openzeppelin.storage.AccessControl + struct AccessControlStorage { + mapping(bytes32 role => RoleData) _roles; + } + + /// @notice Protocol-level address registry for all DotNS contracts. + IDotnsProtocolRegistryOld public protocolRegistry; + + /// @notice Live-claim cap per name, tunable by governance within + /// `DotnsConstantsOld.WHITELIST_MAX_CLAIMANTS_LIMIT`. + uint16 public maxClaimants; + + /// @notice Cap on labels per `grantNames` call, tunable by governance within + /// `DotnsConstantsOld.WHITELIST_MAX_GRANT_BATCH_LIMIT`. + uint16 public maxGrantBatch; + + /// @notice Reason byte cap, tunable by governance within + /// `DotnsConstantsOld.WHITELIST_MAX_REASON_LIMIT`. + uint256 public maxReasonBytes; + + /// @notice Resolved state per name. + mapping(bytes32 node => NameRecord record) private _names; + + /// @notice Claims per name, keyed by beneficiary. + mapping(bytes32 node => mapping(address user => Claim claim)) private _claims; + + /// @notice Beneficiaries with a live claim per name. + mapping(bytes32 node => EnumerableSet.AddressSet claimants) private _claimants; + + /// @notice Names holding reserved, claimed or claim-holding state, kept enumerable for review. + EnumerableSet.Bytes32Set private _activeNodes; + + /// @notice Timestamp requests start being accepted. + uint64 private _requestOpen; + + /// @notice Timestamp requests stop being accepted. + uint64 private _requestClose; + + /// @dev Reserved storage space to allow for layout changes in the future. + uint256[50] private __gap; + + /// @notice Restricts a call to a substrate Root dispatch. + /// @dev The whole admin surface is governance-only: no key grants, revokes, reserves, or + /// retunes a cap. The owner's authority is deployment and upgrade, not allocation, so no + /// signed account can hand out a name. `msg.sender` is never read here, which is also what + /// keeps every gated entry point callable under a Root origin, since Root has no account. + modifier onlyGovernance() { + _onlyGovernance(); + _; + } + + /// @notice Restricts a call to a registrar controller resolved through the registry. + modifier onlyController() { + require( + msg.sender == protocolRegistry.get(DotnsConstantsOld.CONTROLLER) + || msg.sender == protocolRegistry.get(DotnsConstantsOld.POP_CONTROLLER), + NotController(msg.sender) + ); + _; + } + + /// @notice Internal check enforcing the substrate Root gate. + function _onlyGovernance() internal view { + require(SystemUtilsOld.originIsRoot(), NotGovernance()); + } + + /// @custom:oz-upgrades-unsafe-allow constructor + constructor() { + _disableInitializers(); + } + + /// @notice Initialises the whitelist. + /// @dev Callable once through the UUPS proxy; direct calls on the implementation + /// @custom:reverts InvalidInitialization. Sets the deployer as owner and wires the + /// protocol registry the node derivation reads the TLD from. + /// @param registry Protocol registry all DotNS contracts resolve through. + function initialize(IDotnsProtocolRegistryOld registry) external initializer { + __ERC165_init(); + __Ownable_init(msg.sender); + protocolRegistry = registry; + maxClaimants = DotnsConstantsOld.WHITELIST_DEFAULT_MAX_CLAIMANTS; + maxGrantBatch = DotnsConstantsOld.WHITELIST_DEFAULT_MAX_GRANT_BATCH; + maxReasonBytes = DotnsConstantsOld.WHITELIST_DEFAULT_MAX_REASON_BYTES; + } + + /// @inheritdoc IDotnsNameWhitelist + function setMaxClaimants(uint16 newMax) external override onlyGovernance { + require( + newMax > 0 && newMax <= DotnsConstantsOld.WHITELIST_MAX_CLAIMANTS_LIMIT, + MaxClaimantsOutOfRange() + ); + maxClaimants = newMax; + emit MaxClaimantsSet(newMax); + } + + /// @inheritdoc IDotnsNameWhitelist + function setMaxReasonBytes(uint256 newMax) external override onlyGovernance { + require( + newMax > 0 && newMax <= DotnsConstantsOld.WHITELIST_MAX_REASON_LIMIT, + MaxReasonBytesOutOfRange() + ); + maxReasonBytes = newMax; + emit MaxReasonBytesSet(newMax); + } + + /// @inheritdoc IDotnsNameWhitelist + function setMaxGrantBatch(uint16 newMax) external override onlyGovernance { + require( + newMax > 0 && newMax <= DotnsConstantsOld.WHITELIST_MAX_GRANT_BATCH_LIMIT, + MaxGrantBatchOutOfRange() + ); + maxGrantBatch = newMax; + emit MaxGrantBatchSet(newMax); + } + + /// @inheritdoc IDotnsNameWhitelist + function requestName( + string calldata label, + string calldata reason, + address user + ) + external + override + { + require(_isWindowOpen(), WindowClosed()); + require(user != address(0), ZeroUser()); + require(bytes(reason).length <= maxReasonBytes, ReasonTooLong()); + require(label.isSingleLabel(), InvalidLabel()); + + bytes32 node = _nodeOf(label); + require(_names[node].status == NameStatus.Open, NameNotOpen(node)); + require(_claims[node][user].status == ClaimStatus.None, AlreadyClaimed(node, user)); + require(_claimants[node].length() < maxClaimants, TooManyClaimants(node)); + + _claims[node][user] = Claim({ + user: user, + status: ClaimStatus.Requested, + requestedAt: uint64(block.timestamp), + submitter: msg.sender, + reason: reason + }); + _claimants[node].add(user); + _activate(node, label); + emit NameRequested(node, user, label, reason); + } + + /// @inheritdoc IDotnsNameWhitelist + function accept(string calldata label, address user) external override onlyGovernance { + bytes32 node = _nodeOf(label); + require(_claims[node][user].status == ClaimStatus.Requested, NotRequested(node, user)); + emit NameAccepted(node, user, label); + _settle(node, user, label); + } + + /// @inheritdoc IDotnsNameWhitelist + function reject(string calldata label, address user) external override onlyGovernance { + bytes32 node = _nodeOf(label); + Claim storage claim = _claims[node][user]; + require(claim.status == ClaimStatus.Requested, NotRequested(node, user)); + // Free the claimant slot either way. Keep a sticky Rejected record only for a self-filed + // claim, so the beneficiary cannot re-request; a claim filed on their behalf is + // deleted and never binds them. + _claimants[node].remove(user); + if (claim.submitter == user) { + claim.status = ClaimStatus.Rejected; + } else { + delete _claims[node][user]; + } + emit NameRejected(node, user, label); + _deactivate(node); + } + + /// @inheritdoc IDotnsNameWhitelist + function grantName(string calldata label, address user) external override onlyGovernance { + _grant(label, user); + } + + /// @inheritdoc IDotnsNameWhitelist + function grantNames(string[] calldata labels, address user) external override onlyGovernance { + require(labels.length <= maxGrantBatch, TooManyLabels()); + for (uint256 i = 0; i < labels.length; i++) { + _grant(labels[i], user); + } + } + + /// @inheritdoc IDotnsNameWhitelist + function revokeName(string calldata label) external override onlyGovernance { + bytes32 node = _nodeOf(label); + NameRecord storage record = _names[node]; + require( + record.status == NameStatus.Claimed || _claimants[node].length() != 0, + NothingToRevoke(node) + ); + address winner = record.winner; + _clearClaimants(node, address(0), label); + record.status = NameStatus.Open; + record.winner = address(0); + emit NameRevoked(node, winner, label); + _deactivate(node); + } + + /// @inheritdoc IDotnsNameWhitelist + function setReserved(string calldata label, bool reserved) external override onlyGovernance { + require(label.isSingleLabel(), InvalidLabel()); + bytes32 node = _nodeOf(label); + NameRecord storage record = _names[node]; + if (reserved) { + require(record.status == NameStatus.Open, NameNotOpen(node)); + // Clear any pending claims the way a grant does, so a permissionless requestName + // cannot force governance to revokeName before it can reserve. + _clearClaimants(node, address(0), label); + record.status = NameStatus.Reserved; + _activate(node, label); + emit NameReserved(node, label); + } else { + require(record.status == NameStatus.Reserved, NotReserved(node)); + record.status = NameStatus.Open; + emit NameUnreserved(node, label); + _deactivate(node); + } + } + + /// @inheritdoc IDotnsNameWhitelist + function consume(string calldata label, address registrant) external override onlyController { + bytes32 node = _nodeOf(label); + NameRecord storage record = _names[node]; + require( + record.status == NameStatus.Claimed && record.winner == registrant, + NotWinner(registrant, node) + ); + record.status = NameStatus.Open; + record.winner = address(0); + emit NameConsumed(node, registrant, label); + _deactivate(node); + } + + /// @inheritdoc IDotnsNameWhitelist + function setWindow(uint64 startsIn, uint64 duration) external override onlyGovernance { + require(duration > 0, BadWindow()); + uint64 openAt = uint64(block.timestamp) + startsIn; + uint64 closeAt = openAt + duration; + _requestOpen = openAt; + _requestClose = closeAt; + emit WindowSet(openAt, closeAt); + } + + /// @inheritdoc IDotnsNameWhitelist + function statusOf(string calldata label) external view override returns (NameStatus status) { + return _names[_nodeOf(label)].status; + } + + /// @inheritdoc IDotnsNameWhitelist + function isReserved(string calldata label) external view override returns (bool reserved) { + return _names[_nodeOf(label)].status == NameStatus.Reserved; + } + + /// @inheritdoc IDotnsNameWhitelist + function granteeOf(string calldata label) external view override returns (address winner) { + NameRecord storage record = _names[_nodeOf(label)]; + return record.status == NameStatus.Claimed ? record.winner : address(0); + } + + /// @inheritdoc IDotnsNameWhitelist + function isGrantedTo( + string calldata label, + address account + ) + external + view + override + returns (bool granted) + { + NameRecord storage record = _names[_nodeOf(label)]; + return + account != address(0) && record.status == NameStatus.Claimed && record.winner == account; + } + + /// @inheritdoc IDotnsNameWhitelist + function claimOf( + string calldata label, + address user + ) + external + view + override + returns (Claim memory claim) + { + return _claims[_nodeOf(label)][user]; + } + + /// @inheritdoc IDotnsNameWhitelist + function claimantCount(string calldata label) external view override returns (uint256 count) { + return _claimants[_nodeOf(label)].length(); + } + + /// @inheritdoc IDotnsNameWhitelist + function claims( + string calldata label, + uint256 offset, + uint256 limit + ) + external + view + override + returns (Claim[] memory page) + { + bytes32 node = _nodeOf(label); + EnumerableSet.AddressSet storage set = _claimants[node]; + uint256 total = set.length(); + if (offset >= total) { + return new Claim[](0); + } + uint256 available = total - offset; + uint256 count = limit < available ? limit : available; + page = new Claim[](count); + for (uint256 i; i < count; ++i) { + page[i] = _claims[node][set.at(offset + i)]; + } + } + + /// @inheritdoc IDotnsNameWhitelist + function nameCount() external view override returns (uint256 count) { + return _activeNodes.length(); + } + + /// @inheritdoc IDotnsNameWhitelist + function names( + uint256 offset, + uint256 limit + ) + external + view + override + returns (NameView[] memory page) + { + uint256 total = _activeNodes.length(); + if (offset >= total) { + return new NameView[](0); + } + uint256 available = total - offset; + uint256 count = limit < available ? limit : available; + page = new NameView[](count); + for (uint256 i; i < count; ++i) { + bytes32 node = _activeNodes.at(offset + i); + NameRecord storage record = _names[node]; + page[i] = NameView({ + node: node, label: record.label, status: record.status, winner: record.winner + }); + } + } + + /// @inheritdoc IDotnsNameWhitelist + function window() external view override returns (uint64 openAt, uint64 closeAt) { + return (_requestOpen, _requestClose); + } + + /// @inheritdoc IDotnsNameWhitelist + function isWindowOpen() external view override returns (bool open) { + return _isWindowOpen(); + } + + /// @inheritdoc ERC165Upgradeable + function supportsInterface(bytes4 interfaceId) + public + view + override(ERC165Upgradeable) + returns (bool supported) + { + return interfaceId == type(IDotnsNameWhitelist).interfaceId + || super.supportsInterface(interfaceId); + } + + /// @notice Grants `label` to `user` directly, clearing any pending claims. + /// @param label Bare label to grant. + /// @param user Beneficiary the name binds to. + function _grant(string calldata label, address user) internal { + require(user != address(0), ZeroUser()); + require(label.isSingleLabel(), InvalidLabel()); + bytes32 node = _nodeOf(label); + require(_names[node].status == NameStatus.Open, NameNotOpen(node)); + emit NameAccepted(node, user, label); + _settle(node, user, label); + } + + /// @notice Marks a name claimed for `winner` and clears its claims, rejecting the losers. + /// @param node Namehash of the label under the active TLD. + /// @param winner Beneficiary the name binds to. + /// @param label Bare label, stored for review. + function _settle(bytes32 node, address winner, string calldata label) internal { + NameRecord storage record = _names[node]; + record.status = NameStatus.Claimed; + record.winner = winner; + _activate(node, label); + _clearClaimants(node, winner, label); + } + + /// @notice Deletes every claim on a name, rejecting each claimant that is not `winner`. + /// @param node Namehash of the label under the active TLD. + /// @param winner Claimant spared a rejection event; the zero address rejects every claimant. + /// @param label Bare label emitted with each rejection. + function _clearClaimants(bytes32 node, address winner, string calldata label) internal { + address[] memory current = _claimants[node].values(); + for (uint256 i; i < current.length; ++i) { + address claimant = current[i]; + delete _claims[node][claimant]; + _claimants[node].remove(claimant); + if (claimant != winner) { + emit NameRejected(node, claimant, label); + } + } + } + + /// @notice Records a name as active and stores its label the first time it is seen. + /// @param node Namehash of the label under the active TLD. + /// @param label Bare label stored on first activation. + function _activate(bytes32 node, string calldata label) internal { + NameRecord storage record = _names[node]; + if (bytes(record.label).length == 0) { + record.label = label; + } + _activeNodes.add(node); + } + + /// @notice Drops a name from the active set once it is Open with no claims. + /// @param node Namehash of the label under the active TLD. + function _deactivate(bytes32 node) internal { + NameRecord storage record = _names[node]; + if (record.status == NameStatus.Open && _claimants[node].length() == 0) { + _activeNodes.remove(node); + delete record.label; + } + } + + /// @notice Derives the namehash of `label` under the active TLD read from the registry. + /// @param label Bare label to hash. + /// @return node Namehash of the label under the active TLD. + function _nodeOf(string calldata label) internal view returns (bytes32 node) { + (, node) = LabelUtils.deriveNode(protocolRegistry.tldNode(), label); + } + + /// @notice Returns whether the current time is within the open window. + /// @return open True when the current time is within the window. + function _isWindowOpen() internal view returns (bool open) { + return block.timestamp >= _requestOpen && block.timestamp < _requestClose; + } + + /// @inheritdoc UUPSUpgradeable + function _authorizeUpgrade(address newImplementation) internal override onlyOwner {} +} diff --git a/scripts/shell/fork-tests.sh b/scripts/shell/fork-tests.sh index 14919b503..a5b61b4f2 100755 --- a/scripts/shell/fork-tests.sh +++ b/scripts/shell/fork-tests.sh @@ -38,6 +38,12 @@ fi echo "fork-tests: forge clean" forge clean +# A layout diff is only meaningful when the snapshot it diffs against is the code that is +# actually deployed, and nothing in the build can check that. Do it here, before the suite +# runs, so a drifted snapshot fails loudly instead of passing quietly. +echo "fork-tests: verifying snapshots against deployed bytecode" +scripts/shell/verify-snapshots.sh + echo "fork-tests: running test/fork/** against $RPC_URL" forge test --match-path 'test/fork/**' "${@:--vvv}" diff --git a/scripts/shell/verify-snapshots.sh b/scripts/shell/verify-snapshots.sh new file mode 100755 index 000000000..3afd8bcc0 --- /dev/null +++ b/scripts/shell/verify-snapshots.sh @@ -0,0 +1,144 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Checks every `*Old.sol` snapshot against the implementation actually deployed on the +# target network, by building the snapshot and comparing runtime bytecode. +# +# Why this exists. The OpenZeppelin validator diffs the new implementation's storage +# layout against the snapshot, so a snapshot that has drifted away from the live code +# makes every layout check meaningless while still passing: the diff is honest about +# two contracts that are not the pair being upgraded. Nothing in the build can notice, +# and a change that lives in calldata rather than storage leaves no trace in the layout +# at all. In September 2026 four snapshots on this branch described implementations +# that had been replaced in place months earlier, and the whole toolchain was green. +# +# The comparison masks two things that differ legitimately between an honest build and +# the chain: `UUPSUpgradeable.__self`, an immutable holding the implementation's own +# address, and the trailing CBOR metadata, which encodes compiler and source hashes. +# Everything else must match byte for byte. +# +# Usage: +# scripts/shell/verify-snapshots.sh # against RPC_URL +# RPC_URL=https://eth-rpc-paseo-next.polkadot.io ... +# DOTNS_NETWORK=paseo-assethub scripts/shell/verify-snapshots.sh + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$ROOT" + +RPC_URL="${RPC_URL:-http://127.0.0.1:8545}" +NETWORK="${DOTNS_NETWORK:-paseo-assethub}" + +shopt -s nullglob +snapshots=(contracts/*/*Old.sol) +if [ ${#snapshots[@]} -eq 0 ]; then + echo "verify-snapshots: no *Old.sol snapshots present, nothing to check" + exit 0 +fi + +chain_id_hex="$(curl -sf -X POST -H 'Content-Type: application/json' \ + --data '{"jsonrpc":"2.0","method":"eth_chainId","params":[],"id":1}' "$RPC_URL" \ + | python3 -c 'import sys,json; print(json.load(sys.stdin)["result"])')" +chain_id="$((chain_id_hex))" + +manifest="deployments/${NETWORK}/${chain_id}.json" +if [ ! -f "$manifest" ]; then + echo "verify-snapshots: no manifest at $manifest for chain $chain_id" >&2 + exit 1 +fi + +echo "verify-snapshots: $manifest against $RPC_URL (chain $chain_id)" + +forge build >/dev/null + +RPC_URL="$RPC_URL" MANIFEST="$manifest" python3 - "${snapshots[@]}" <<'PY' +import json, os, sys, urllib.request + +RPC = os.environ["RPC_URL"] +manifest = json.load(open(os.environ["MANIFEST"])) +# EIP-1967 implementation slot. +SLOT = "0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc" + + +def rpc(method, params): + req = urllib.request.Request( + RPC, + data=json.dumps({"jsonrpc": "2.0", "id": 1, "method": method, "params": params}).encode(), + # Some public gateways reject urllib's default agent outright. + headers={"content-type": "application/json", "user-agent": "dotns-verify-snapshots"}, + ) + return json.load(urllib.request.urlopen(req, timeout=60)).get("result") + + +def strip_metadata(code): + # The last two bytes give the CBOR metadata length; drop that trailing section. + if len(code) < 2: + return bytes(code) + return bytes(code[: -(int.from_bytes(code[-2:], "big") + 2)]) + + +def deployed(name): + """Runtime code behind `name`: the implementation if it is a proxy, else the account.""" + addr = manifest[name] + impl = rpc("eth_getStorageAt", [addr, SLOT, "latest"]) + impl = "0x" + impl[-40:] + code = bytes.fromhex((rpc("eth_getCode", [impl, "latest"]) or "0x")[2:]) + if code: + return impl, code + return addr, bytes.fromhex((rpc("eth_getCode", [addr, "latest"]) or "0x")[2:]) + + +failures, checked, skipped = [], 0, [] +for path in sys.argv[1:]: + snapshot = os.path.basename(path)[:-4] # DotnsRegistryOld + subject = snapshot[:-3] # DotnsRegistry + if subject not in manifest: + # Interfaces, libraries and helper snapshots have no deployed counterpart of + # their own; they are pulled in so the contract snapshots compile. + skipped.append(snapshot) + continue + + artefact = f"out/{snapshot}.sol/{snapshot}.json" + if not os.path.exists(artefact): + failures.append(f"{snapshot}: no build artefact at {artefact}") + continue + + art = json.load(open(artefact)) + built = bytearray(bytes.fromhex(art["deployedBytecode"]["object"][2:])) + impl, live = deployed(subject) + live = bytearray(live) + checked += 1 + + if len(built) != len(live): + failures.append( + f"{snapshot}: {len(built)} bytes built against {len(live)} deployed at {impl}" + ) + continue + + for refs in art["deployedBytecode"].get("immutableReferences", {}).values(): + for r in refs: + start, length = r["start"], r["length"] + built[start:start + length] = b"\0" * length + live[start:start + length] = b"\0" * length + + if strip_metadata(built) != strip_metadata(live): + failures.append(f"{snapshot}: same length but different code from {impl}") + else: + print(f" ok {snapshot} == {subject} at {impl}") + +if skipped: + print(f" -- {len(skipped)} snapshot(s) with no deployed counterpart: {', '.join(skipped)}") + +if failures: + print("\nverify-snapshots: FAILED", file=sys.stderr) + for f in failures: + print(f" {f}", file=sys.stderr) + print( + "\nA snapshot must reproduce the implementation currently deployed. Rebuild it from\n" + "the source that was deployed, not from master and not from the tag: `git log` the\n" + "proxy's upgrade history, or bisect builds against the deployed bytecode.", + file=sys.stderr, + ) + sys.exit(1) + +print(f"\nverify-snapshots: {checked} snapshot(s) reproduce the deployed bytecode") +PY From c34bb4d185c02038510789db9dab583470e7e368 Mon Sep 17 00:00:00 2001 From: giuseppere Date: Thu, 17 Sep 2026 17:39:04 +0200 Subject: [PATCH 07/25] Run the layout diff for every upgraded proxy in ordinary CI, and fix the gap it caught The storage-layout diff only ran inside an upgrade script, which meant it only ran when someone brought up the ETH-RPC adapter and ran the fork suite. Lift it into a plain unit test, one case per proxy, so every push answers the question the upgrade actually turns on: would `upgradeProxy` accept this layout change. It found one on its first run. `DotnsProtocolRegistry` appends `_expectedCodehash` and `_protocolVersion` for the declarations added in #304, but left `__gap` at 50, so the gap starts two slots later than on the deployed proxy and the contract's footprint grows. The validator rejects it, and any field appended after the gap in a later release would land on a slot the live proxy does not expect. Sized to 48, which keeps the 54-slot footprint. This is a fifth storage-layout correction of the same family as the four already on this branch, and like those it stays here per the branch policy. The fix moves no bytecode: a gap reserves slots and emits no code, so the implementation still builds byte-identical to the v0.8.0 tag. The release-drift finding is unchanged, and `DotnsRegistrarController` remains the only proxy whose deployed code will differ from the tag. Co-Authored-By: Claude Opus 5 (1M context) --- contracts/registry/DotnsProtocolRegistry.sol | 8 +- test/unit/upgrade/LayoutCompatibility.t.sol | 114 +++++++++++++++++++ 2 files changed, 121 insertions(+), 1 deletion(-) create mode 100644 test/unit/upgrade/LayoutCompatibility.t.sol diff --git a/contracts/registry/DotnsProtocolRegistry.sol b/contracts/registry/DotnsProtocolRegistry.sol index 4af2517c4..fdae168ac 100644 --- a/contracts/registry/DotnsProtocolRegistry.sol +++ b/contracts/registry/DotnsProtocolRegistry.sol @@ -49,7 +49,13 @@ contract DotnsProtocolRegistry is /// @notice Release tag the network was last declared to run, bare semver (e.g. `0.8.0`). string private _protocolVersion; - uint256[50] private __gap; + /// @dev Reserved storage space to allow for layout changes in future upgrades. The declaration + /// fields above consume two of the reserved slots, so the gap holds 48 and the contract + /// keeps the 54-slot footprint the deployed proxy already uses. Sized this way rather than + /// left at 50 because the gap would otherwise start two slots later than on chain, which + /// the storage-layout diff rejects and which would put any future appended field on a slot + /// the live proxy does not expect. + uint256[48] private __gap; /// @custom:oz-upgrades-unsafe-allow constructor constructor() { diff --git a/test/unit/upgrade/LayoutCompatibility.t.sol b/test/unit/upgrade/LayoutCompatibility.t.sol new file mode 100644 index 000000000..2a1631b8d --- /dev/null +++ b/test/unit/upgrade/LayoutCompatibility.t.sol @@ -0,0 +1,114 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.34; + +import {Test} from "forge-std/Test.sol"; +import {Options} from "openzeppelin-foundry-upgrades/Options.sol"; +import {Upgrades} from "openzeppelin-foundry-upgrades/Upgrades.sol"; + +/// @title LayoutCompatibilityTests +/// @notice Runs the OpenZeppelin storage-layout diff for every proxy this branch upgrades, +/// against the snapshot of the implementation deployed on chain. +/// @dev This is the cheap half of the upgrade check and it needs no fork, so it runs in ordinary +/// CI on every push rather than only when someone brings up the ETH-RPC adapter. It answers +/// one question per proxy: would `Upgrades.upgradeProxy` accept this layout change. It cannot +/// answer whether the snapshot is the deployed code, which has no on-chain counterpart in a +/// layout at all; `scripts/shell/verify-snapshots.sh` answers that by comparing bytecode, and +/// the two together are what make an upgrade script trustworthy. +/// @custom:security-contact admin@parity.io +contract LayoutCompatibilityTests is Test { + /// @notice Asserts the new implementation is layout-compatible with the deployed one. + /// @dev `validateUpgrade` runs the same diff `upgradeProxy` runs, and reverts with the + /// offending slot when it fails, so a regression names the field rather than the pair. + /// @param newContract Artefact of the implementation being upgraded to. + /// @param referenceContract Artefact of the snapshot of what is deployed. + function _assertCompatible( + string memory newContract, + string memory referenceContract + ) + internal + { + Options memory opts; + opts.referenceContract = referenceContract; + Upgrades.validateUpgrade(newContract, opts); + } + + function test_registry_layout_is_compatible() public { + _assertCompatible( + "DotnsRegistry.sol:DotnsRegistry", "DotnsRegistryOld.sol:DotnsRegistryOld" + ); + } + + function test_protocolRegistry_layout_is_compatible() public { + // The one proxy that genuinely adds storage: #304 appends `_protocolVersion` and + // `_expectedCodehash` and shrinks the gap to match, which is an append, not a move. + _assertCompatible( + "DotnsProtocolRegistry.sol:DotnsProtocolRegistry", + "DotnsProtocolRegistryOld.sol:DotnsProtocolRegistryOld" + ); + } + + function test_registrar_layout_is_compatible() public { + _assertCompatible( + "DotnsRegistrar.sol:DotnsRegistrar", "DotnsRegistrarOld.sol:DotnsRegistrarOld" + ); + } + + function test_registrarController_layout_is_compatible() public { + // The retained `__whiteListSlot` placeholder is what keeps `protocolRegistry` on the slot + // the live proxy uses. Without it this assertion is what fails. + _assertCompatible( + "DotnsRegistrarController.sol:DotnsRegistrarController", + "DotnsRegistrarControllerOld.sol:DotnsRegistrarControllerOld" + ); + } + + function test_popController_layout_is_compatible() public { + _assertCompatible( + "DotnsPopController.sol:DotnsPopController", + "DotnsPopControllerOld.sol:DotnsPopControllerOld" + ); + } + + function test_popRules_layout_is_compatible() public { + _assertCompatible("PopRules.sol:PopRules", "PopRulesOld.sol:PopRulesOld"); + } + + function test_nameEscrow_layout_is_compatible() public { + _assertCompatible( + "DotnsNameEscrow.sol:DotnsNameEscrow", "DotnsNameEscrowOld.sol:DotnsNameEscrowOld" + ); + } + + function test_nameWhitelist_layout_is_compatible() public { + _assertCompatible( + "DotnsNameWhitelist.sol:DotnsNameWhitelist", + "DotnsNameWhitelistOld.sol:DotnsNameWhitelistOld" + ); + } + + function test_resolver_layout_is_compatible() public { + _assertCompatible( + "DotnsResolver.sol:DotnsResolver", "DotnsResolverOld.sol:DotnsResolverOld" + ); + } + + function test_reverseResolver_layout_is_compatible() public { + _assertCompatible( + "DotnsReverseResolver.sol:DotnsReverseResolver", + "DotnsReverseResolverOld.sol:DotnsReverseResolverOld" + ); + } + + function test_contentResolver_layout_is_compatible() public { + _assertCompatible( + "DotnsContentResolver.sol:DotnsContentResolver", + "DotnsContentResolverOld.sol:DotnsContentResolverOld" + ); + } + + function test_popResolver_layout_is_compatible() public { + _assertCompatible( + "DotnsPopResolver.sol:DotnsPopResolver", "DotnsPopResolverOld.sol:DotnsPopResolverOld" + ); + } +} From 29b5d0cd4292b937d2e7e099653d401e24e910ce Mon Sep 17 00:00:00 2001 From: giuseppere Date: Fri, 18 Sep 2026 14:11:04 +0200 Subject: [PATCH 08/25] Add the upgrade scripts, their fork tests, and the store-factory migration Thirteen scripts and thirteen fork tests, paired one to one. Twelve swap a proxy in place; the thirteenth migrates the store factory, which cannot be swapped because what is deployed is a plain contract from an earlier release and the current one puts the factory behind a proxy at a different address. Each fork test reads live state through the deployed implementation, runs the script's own internal path rather than reimplementing the upgrade, and reads the same state back. Each asserts the implementation address actually changed, without which a swap that silently did nothing would satisfy every state-preservation assertion. The assertions are per contract: the registry drives the new deferral gate against the live controller set, which a unit test cannot reach; the registrar controller reads back the protocol registry pointer that the retained slot exists to keep in place; the escrow asserts its redeem window is both unchanged and non-zero, that being the field an upgrade has dropped before. `StoreFactoryMigrator` closes the migration without a release. It is the shipped factory with four fields widened and a one-shot owner-only import, swapped into the proxy for a single transaction and swapped back out, so the deployment ends on the shipped implementation with the bindings in place. Both directions are layout-diffed. The holder list it needs cannot be read from the factory: the mapping has no enumeration, and the list it does keep holds store addresses, which do not know which user they belong to. Only `LabelStoreDeployed` carries the pairing, so `scripts/shell/store-holders.sh` replays it and reconciles the result against the factory's own count. It refuses to print a list it cannot account for, which matters because the public gateway answers log queries with an empty result rather than an error: a naive replay against it looks like it worked and is empty. The count also moves, from 57 to 58 while this was being written, so the list is read immediately before broadcasting and never reused. `DeclareRelease` extends `WireDeployments` rather than restating its key list, so a release that adds a key cannot be declared on a fresh deploy and silently skipped on every upgrade. Docs carry the parts that are not obvious from the code. CONTRIBUTING now says the artefacts stay on a branch that never merges, and that a snapshot is of the deployed code rather than of the previous release. DEPLOYMENTS and a folder README record why this network's chain id does not identify it, why its gateway's empty log results are a trap, and why `verify --tag` will report one key as drift permanently. The README no longer claims a lite name's node cannot collide with a subname, which stopped being true when lite names became subnames. The note stayed out of the manifest deliberately: the deploy pipeline parses every key there as an address, so a text field fails it on first read. Co-Authored-By: Claude Opus 5 (1M context) --- CONTRIBUTING.md | 6 + DEPLOYMENTS.md | 38 +++ README.md | 2 +- contracts/pop/IDotnsPricingOld.sol | 4 +- contracts/pop/PopRulesOld.sol | 3 +- .../DotnsRegistrarControllerOld.sol | 16 +- contracts/registrars/DotnsRegistrarOld.sol | 4 +- contracts/registry/DotnsRegistryOld.sol | 15 +- contracts/registry/IDotnsRegistryOld.sol | 3 +- .../resolvers/DotnsContentResolverOld.sol | 3 +- contracts/resolvers/DotnsResolverOld.sol | 3 +- contracts/store/StoreFactoryMigrator.sol | 311 ++++++++++++++++++ contracts/utils/DotnsConstantsOld.sol | 4 +- contracts/utils/StoreUtilsOld.sol | 5 +- contracts/utils/SubnodeUtilsOld.sol | 11 +- deployments/paseo-assethub/README.md | 31 ++ scripts/deploy/DeclareRelease.s.sol | 47 +++ scripts/deploy/MigrateStoreFactory.s.sol | 156 +++++++++ scripts/deploy/RedeployPopLens.s.sol | 63 ---- scripts/deploy/UpgradeContentResolver.s.sol | 76 +++++ scripts/deploy/UpgradeNameEscrow.s.sol | 75 +++++ scripts/deploy/UpgradeNameWhitelist.s.sol | 75 +++++ scripts/deploy/UpgradePopController.s.sol | 34 +- scripts/deploy/UpgradePopResolver.s.sol | 75 +++++ scripts/deploy/UpgradePopRules.s.sol | 30 +- scripts/deploy/UpgradeProtocolRegistry.s.sol | 77 +++++ scripts/deploy/UpgradeRegistrar.s.sol | 75 +++++ .../deploy/UpgradeRegistrarController.s.sol | 78 +++++ scripts/deploy/UpgradeRegistry.s.sol | 31 +- scripts/deploy/UpgradeResolver.s.sol | 75 +++++ scripts/deploy/UpgradeReverseResolver.s.sol | 39 ++- scripts/shell/store-holders.sh | 83 +++++ test/fork/BaseUpgradeFork.t.sol | 82 +++++ test/fork/MigrateStoreFactory.t.sol | 183 +++++++++++ test/fork/PopNumericNamespace.t.sol | 162 --------- test/fork/RedeployPopLens.t.sol | 69 ---- test/fork/UpgradeContentResolver.t.sol | 70 ++++ test/fork/UpgradeNameEscrow.t.sol | 71 ++++ test/fork/UpgradeNameWhitelist.t.sol | 67 ++++ test/fork/UpgradePopController.t.sol | 208 +++--------- test/fork/UpgradePopResolver.t.sol | 70 ++++ test/fork/UpgradePopRules.t.sol | 208 +++--------- test/fork/UpgradeProtocolRegistry.t.sol | 87 +++++ test/fork/UpgradeRegistrar.t.sol | 73 ++++ test/fork/UpgradeRegistrarController.t.sol | 76 +++++ test/fork/UpgradeRegistry.t.sol | 112 ++++--- test/fork/UpgradeResolver.t.sol | 70 ++++ test/fork/UpgradeReverseResolver.t.sol | 98 +++--- test/unit/store/StoreFactoryMigrator.t.sol | 136 ++++++++ test/unit/upgrade/LayoutCompatibility.t.sol | 15 + 50 files changed, 2573 insertions(+), 832 deletions(-) create mode 100644 contracts/store/StoreFactoryMigrator.sol create mode 100644 deployments/paseo-assethub/README.md create mode 100644 scripts/deploy/DeclareRelease.s.sol create mode 100644 scripts/deploy/MigrateStoreFactory.s.sol delete mode 100644 scripts/deploy/RedeployPopLens.s.sol create mode 100644 scripts/deploy/UpgradeContentResolver.s.sol create mode 100644 scripts/deploy/UpgradeNameEscrow.s.sol create mode 100644 scripts/deploy/UpgradeNameWhitelist.s.sol create mode 100644 scripts/deploy/UpgradePopResolver.s.sol create mode 100644 scripts/deploy/UpgradeProtocolRegistry.s.sol create mode 100644 scripts/deploy/UpgradeRegistrar.s.sol create mode 100644 scripts/deploy/UpgradeRegistrarController.s.sol create mode 100644 scripts/deploy/UpgradeResolver.s.sol create mode 100755 scripts/shell/store-holders.sh create mode 100644 test/fork/BaseUpgradeFork.t.sol create mode 100644 test/fork/MigrateStoreFactory.t.sol delete mode 100644 test/fork/PopNumericNamespace.t.sol delete mode 100644 test/fork/RedeployPopLens.t.sol create mode 100644 test/fork/UpgradeContentResolver.t.sol create mode 100644 test/fork/UpgradeNameEscrow.t.sol create mode 100644 test/fork/UpgradeNameWhitelist.t.sol create mode 100644 test/fork/UpgradePopResolver.t.sol create mode 100644 test/fork/UpgradeProtocolRegistry.t.sol create mode 100644 test/fork/UpgradeRegistrar.t.sol create mode 100644 test/fork/UpgradeRegistrarController.t.sol create mode 100644 test/fork/UpgradeResolver.t.sol create mode 100644 test/unit/store/StoreFactoryMigrator.t.sol diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bf59fa5c1..15ad6c1bf 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -249,6 +249,8 @@ git config core.hooksPath .githooks The conventions below apply specifically to PRs that upgrade an already-deployed proxy. They are scoped to the lifetime of the PR and must be removed before merge; the cleanup checklist at the end of this section is the gate reviewers enforce. +**Long-lived upgrade branches are the exception, and the rest of this section reads differently on one.** A branch under `spha/` holds the tooling for a network that is upgraded in place and is never merged to `master`: `master` flows into it, never back. The reason the artefacts have to be deleted is that they must not reach `master`, and on a branch that never merges they cannot. Deleting them there would throw away the only record of what was deployed, and the starting point for the next round, in exchange for nothing. So on such a branch the snapshots, upgrade scripts and fork tests stay, and the cleanup checklist below applies to the ordinary case: an upgrade PR that is going to `master`. + ### Storage-collision checks **Storage-layout safety is non-negotiable on every deploy and upgrade path.** The OpenZeppelin validator runs end-to-end on every proxy: on upgrades it diffs the new implementation's storage layout against a pinned `Old.sol` reference snapshot and fails the build if a slot moves, shrinks, or changes type; on fresh deploys it catches unsafe-upgrade-incompatible patterns (constructors, state-variable assignments and immutables in the implementation, `selfdestruct`, raw `delegatecall`, external library linking, missing initialisers, and so on) that would only surface as a bug the first time a future upgrade is attempted. **No deploy or upgrade script in this repository passes `unsafeSkipAllChecks` or any `unsafeAllow` override, and adding one is not on the table. If validation fails, fix the contract, not the script.** @@ -261,6 +263,10 @@ The `Old.sol` convention has a fixed shape. For a contract `Foo.sol` declaring ` **`Old.sol` snapshots are PR-scoped and must never land on `master`.** They exist only for the upgrade PR that introduces them, so CI and local `forge build` can diff the new layout against the pre-upgrade layout. **Before the PR merges, every `Old.sol` (and every matching `I*Old.sol`) must be deleted, along with the `referenceContract` wiring in the upgrade script.** Once the upgrade is live, the "old" layout is the on-chain deployment, not a file in the repository; keeping the snapshot around after merge would create a phantom contract that future diffs would treat as real code. Reviewers should refuse any PR that ships `Old.sol` files to `master`. +**A snapshot is of the code that is deployed, which is not always the previous release.** A proxy upgraded in place since its last release runs code no tag describes, so a snapshot taken from the tag is a snapshot of something that has not executed on that network for months. Nothing in the build can notice: the layout diff compares whatever pair it is given and reports honestly on the wrong one, and a change that lives in calldata leaves no trace in a layout at all. `scripts/shell/verify-snapshots.sh` is what closes this. It builds every snapshot and compares runtime bytecode against the implementation behind the proxy, masking only `UUPSUpgradeable.__self` and the trailing metadata, and `fork-tests.sh` runs it before the suite. Treat a snapshot that has not been through it as unverified, whatever the layout diff says. + +Where `master` has moved on since the deployed build, the snapshot set is larger than the contracts being upgraded. A snapshot that imports the current tree stops reproducing the deployed bytecode, and one that imports a snapshot hands a renamed type to a signature expecting the current one. The set has to be closed over both: everything reachable that changed, plus everything that reaches one of those. Unchanged interfaces and libraries stay shared and unrenamed. + ### The upgrade script Each upgraded proxy has one `Upgrade.s.sol` under `scripts/deploy/`, paired with its fork test. The script resolves the target proxy from the on-disk manifest, runs the layout diff against the `Old.sol` snapshot, and swaps the implementation through `Upgrades.upgradeProxy`. A beacon-backed store rotates its shared beacon through the factory's upgrade entrypoint after `Upgrades.validateUpgrade`, rather than a per-proxy call. The `referenceContract` is always supplied, so the layout diff is mandatory and fails closed; there is no environment switch that turns it off. diff --git a/DEPLOYMENTS.md b/DEPLOYMENTS.md index 1fd1c98ed..602f5313a 100644 --- a/DEPLOYMENTS.md +++ b/DEPLOYMENTS.md @@ -488,4 +488,42 @@ Every network deployed through the shared CREATE3 factory lands on the same addr Each release also publishes the same addresses as `deployments.json`, attached to the release and at the root of `dotns-abis-.zip`, for consumers outside this repository. See [`RELEASE_ARTIFACTS.md`](./RELEASE_ARTIFACTS.md). +### Paseo Asset Hub Next, and why its manifest folder is ambiguous + +`deployments/paseo-assethub/420420417.json` is Paseo Asset Hub Next, reached at +`https://eth-rpc-paseo-next.polkadot.io`. Chain id 420420417 is shared with other Paseo-style +environments, including the public Polkadot Hub TestNet gateway, which answers on that id and has +no code at any of these addresses. Point the fork adapter at the wrong one and every address +resolves and every call reverts for reasons that look like anything but the real cause. + +The public gateway also answers `eth_getLogs` with an empty result for every range rather than an +error, so anything built from a log replay against it looks like it worked and is empty. Use an +archive node, or Blockscout at `https://blockscout-paseo-next.polkadot.io`. + +### The deployed code is not always the code in a release + +This network's proxies are upgraded in place from the `spha/registrar-upgrade` branch, which is +never merged to `master`, so no release tag describes what they run. Two consequences that look +like faults and are not: + +- `verify --tag` reports the `registrarController` key as drift, permanently. The deployed + implementation carries a retained storage slot that keeps `protocolRegistry` where the live + proxy has it; an implementation built from the tag reads that field as the zero address and + bricks the contract, so the branch build is the only deployable one. Every other key verifies. + A second drifting key is a real finding. +- `protocolVersion()` names the release the deployment tracks, and is accurate for every contract + but that one, which runs a superset differing only in storage-slot constants. + +Before broadcasting, re-run `scripts/shell/verify-snapshots.sh` against the network. It compares +every snapshot with the implementation actually deployed, which is the only check that catches a +snapshot describing code that stopped running months ago. + +### Lite usernames issued before the numeric namespace + +Lite usernames issued before that upgrade were recorded as atomic labels under the top level. The +current code addresses a lite name at the container-then-stem node instead, so a pre-upgrade lite +name is not read by the new path, and its subname node is free to be issued afresh. That overwrite +is accepted: the numeric namespace is the intended shape. Nothing reads the old records, and they +are not migrated. + Prefer reading an address from the protocol registry at runtime. Every consumer contract exposes `protocolRegistry`, and the registry resolves each well-known key in `DotnsConstants`, so one known address is enough to reach the rest and the chain stays the authority. diff --git a/README.md b/README.md index 3787b6d09..3c69c90d8 100644 --- a/README.md +++ b/README.md @@ -153,7 +153,7 @@ Dedicated controller for the Proof-of-Personhood gateway flow. Lives behind its Today the Pop gateway does not write a standalone user-status mapping. It materialises the PoP flow through gateway-issued labels, PoP resolver records, and reservation queue state; user tier checks for public pricing still come from the personhood precompile/context read. -The first, reserveBaseName, mints a lite-person username to a user. The gateway-facing input is a stem.suffix shape: a stem of lowercase ASCII letters, exactly one dot, then exactly two digits (for example michal.03). The stem is stricter than a DNS label, because it is the name a person chose and People Chain restricts that to letters: no digits, no hyphens, no uppercase. The same rule applies to a full-person name, which is the other name a person chooses, so `alice-bob` and `micha3l` are ordinary public names but cannot be issued as identities. How short a stem may be is not part of the shape; that is the governance-reserved band, applied by classification. The label is stored, minted and shown in that form, which is the form People Chain holds and the gateway pallet sends, so nothing is normalised at this boundary. Inputs with more than one dot, no dot, a non-digit suffix, or a suffix length other than two digits are rejected. The node is the hash of the whole string, so it can never collide with a subname built from the same characters. The stem is not limited to the 6 to 8 of the PopLite tier: a longer one is accepted, and a lite username whose stem is nine letters or more classifies as NoStatus for public pricing, so its lite status is an issuance property rather than an economic tier. A stem of five letters or fewer classifies as Reserved and is rejected on this path, which is the same governance gate that applies to short flat names. The call also persists the user's chat key on the PoP resolver and optionally enqueues a reservation for a full-person base name the user intends to claim later. +The first, reserveBaseName, mints a lite-person username to a user. The gateway-facing input is a stem.suffix shape: a stem of lowercase ASCII letters, exactly one dot, then exactly two digits (for example michal.03). The stem is stricter than a DNS label, because it is the name a person chose and People Chain restricts that to letters: no digits, no hyphens, no uppercase. The same rule applies to a full-person name, which is the other name a person chooses, so `alice-bob` and `micha3l` are ordinary public names but cannot be issued as identities. How short a stem may be is not part of the shape; that is the governance-reserved band, applied by classification. The label is stored, minted and shown in that form, which is the form People Chain holds and the gateway pallet sends, so nothing is normalised at this boundary. Inputs with more than one dot, no dot, a non-digit suffix, or a suffix length other than two digits are rejected. The name is issued as a subname: `michal.03` is the label `michal` under the numeric container `03`, so its node is the namehash of that pair rather than the hash of the whole string. A subname someone else creates under the same container at the same label is therefore the same node, which is why the container is owned by the controller and deferred writes under it are restricted to registered controllers. The stem is not limited to the 6 to 8 of the PopLite tier: a longer one is accepted, and a lite username whose stem is nine letters or more classifies as NoStatus for public pricing, so its lite status is an issuance property rather than an economic tier. A stem of five letters or fewer classifies as Reserved and is rejected on this path, which is the same governance gate that applies to short flat names. The call also persists the user's chat key on the PoP resolver and optionally enqueues a reservation for a full-person base name the user intends to claim later. The second, registerBaseName, mints a full-person username. The label is lowercase ASCII letters only, the same rule the lite stem follows, so a hyphen or an interior digit is rejected even though both are valid in a public name. Whether the call is a claim against a prior lite reservation or a fresh standalone registration is derived from on-chain reservation state; the caller does not choose. The link argument selects the chat-key source: inherit from a prior lite label, or accept a fresh one in the payload. When inheriting, the call also writes the liteLink (full => lite) and fullClaim (lite => full) records on the PoP resolver in the same transaction so downstream consumers can resolve either direction without scanning events. diff --git a/contracts/pop/IDotnsPricingOld.sol b/contracts/pop/IDotnsPricingOld.sol index ad1717da0..39b61094b 100644 --- a/contracts/pop/IDotnsPricingOld.sol +++ b/contracts/pop/IDotnsPricingOld.sol @@ -6,8 +6,8 @@ pragma solidity ^0.8.34; /// @dev The seam between name policy and the wei amount a registration costs. `PopRulesOld` and the /// public commit-reveal controller keep the classification, reservation, and tier rules; the /// model owns only the amount for a given base length, so the curve can be swapped by -/// registering a new model under `DotnsConstantsOld.COST_MODEL` without touching either. Only the -/// base length crosses the seam: the model reads no personhood band or `PopStatus`. The public +/// registering a new model under `DotnsConstantsOld.COST_MODEL` without touching either. Only +/// the base length crosses the seam: the model reads no personhood band or `PopStatus`. The public /// controller prices NoStatus deposits through this same path, so the model carries no PoP /// name. /// @custom:security-contact admin@parity.io diff --git a/contracts/pop/PopRulesOld.sol b/contracts/pop/PopRulesOld.sol index 0d431f5a5..de528afd4 100644 --- a/contracts/pop/PopRulesOld.sol +++ b/contracts/pop/PopRulesOld.sol @@ -538,7 +538,8 @@ contract PopRulesOld is /// @notice Ensures the caller is any controller authorised on the registrar. function _onlyRegistry() internal view { - DotnsRegistrarOld registrar = DotnsRegistrarOld(protocolRegistry.get(DotnsConstantsOld.REGISTRAR)); + DotnsRegistrarOld registrar = + DotnsRegistrarOld(protocolRegistry.get(DotnsConstantsOld.REGISTRAR)); require(registrar.controllers(IDotnsController(msg.sender)), NotRegistry()); } diff --git a/contracts/registrars/DotnsRegistrarControllerOld.sol b/contracts/registrars/DotnsRegistrarControllerOld.sol index 23afb9243..afaeae211 100644 --- a/contracts/registrars/DotnsRegistrarControllerOld.sol +++ b/contracts/registrars/DotnsRegistrarControllerOld.sol @@ -139,7 +139,8 @@ contract DotnsRegistrarControllerOld is function available(string calldata label) public view override returns (bool) { bytes32 node; (, node) = _validatedLabelNode(label); - IDotnsRegistrarOld registrar = IDotnsRegistrarOld(protocolRegistry.get(DotnsConstantsOld.REGISTRAR)); + IDotnsRegistrarOld registrar = + IDotnsRegistrarOld(protocolRegistry.get(DotnsConstantsOld.REGISTRAR)); return registrar.available(uint256(node)); } @@ -179,9 +180,8 @@ contract DotnsRegistrarControllerOld is /// @dev Resolved at commit time so the stamp binds the version live then, not at reveal. /// @return pricingVersion The current cost-model version. function _currentPricingVersion() internal view returns (uint256 pricingVersion) { - return - IDotnsCostModelRegistryOld(protocolRegistry.get(DotnsConstantsOld.COST_MODEL)) - .currentVersion(); + return IDotnsCostModelRegistryOld(protocolRegistry.get(DotnsConstantsOld.COST_MODEL)) + .currentVersion(); } /// @inheritdoc IDotnsRegistrarController @@ -247,7 +247,8 @@ contract DotnsRegistrarControllerOld is IDotnsReverseResolver reverse; bool setReverseRecord; if (registration.reserved && isDirect) { - reverse = IDotnsReverseResolver(protocolRegistry.get(DotnsConstantsOld.REVERSE_RESOLVER)); + reverse = + IDotnsReverseResolver(protocolRegistry.get(DotnsConstantsOld.REVERSE_RESOLVER)); setReverseRecord = bytes(reverse.nameOf(registration.owner)).length == 0; } @@ -413,8 +414,9 @@ contract DotnsRegistrarControllerOld is /// wires forward registry, optionally sets the reverse record, and writes the owner's /// Store. /// @dev On a fresh mint the triad of mint + forward-registry + store-write is delegated - /// to @custom:function RegistrationUtilsOld.registerAndStore, the single canonical implementation - /// shared across every DotNS registration flow. On a reclaim the mint step is skipped (the + /// to @custom:function RegistrationUtilsOld.registerAndStore, the single canonical + /// implementation shared across every DotNS registration flow. On a reclaim the mint step is + /// skipped (the /// escrow has already moved custody) and only the registry wiring and store write run. /// Reverse-record setting and the priced-registration event stay here because they are /// commit-reveal-specific policy. diff --git a/contracts/registrars/DotnsRegistrarOld.sol b/contracts/registrars/DotnsRegistrarOld.sol index 508f9898b..d0391ca7d 100644 --- a/contracts/registrars/DotnsRegistrarOld.sol +++ b/contracts/registrars/DotnsRegistrarOld.sol @@ -408,8 +408,8 @@ contract DotnsRegistrarOld is /// @notice Quotes the friction fee required for a transfer. /// @dev Required fee is the name's own price returned by @custom:function - /// PopRulesOld.transferFloor. It is paid by the sender on every downward or cross-reach transfer - /// and settles to the + /// PopRulesOld.transferFloor. It is paid by the sender on every downward or cross-reach + /// transfer and settles to the /// protocol fee pot. Any prior deposit travels with the NFT: the escrow rebinds the position to /// the new holder rather than refunding the sender, so transferring a funded name forfeits the /// locked deposit to the recipient. Self-transfers and escrow-touching transfers return zero. diff --git a/contracts/registry/DotnsRegistryOld.sol b/contracts/registry/DotnsRegistryOld.sol index 7942adc78..0acef37cc 100644 --- a/contracts/registry/DotnsRegistryOld.sol +++ b/contracts/registry/DotnsRegistryOld.sol @@ -119,7 +119,8 @@ contract DotnsRegistryOld is Initializable, UUPSUpgradeable, OwnableUpgradeable, /// @inheritdoc IDotnsRegistryOld function setOwner(bytes32 node, address newOwner) external override onlyRegistrarController { require(newOwner != address(0), NotAllowed()); - IDotnsRegistrarOld registrar = IDotnsRegistrarOld(protocolRegistry.get(DotnsConstantsOld.REGISTRAR)); + IDotnsRegistrarOld registrar = + IDotnsRegistrarOld(protocolRegistry.get(DotnsConstantsOld.REGISTRAR)); require(registrar.ownerOf(uint256(node)) == newOwner, NotAuthorised()); // The resolver pointer is reset to the default reverse resolver on every call to this @@ -173,7 +174,8 @@ contract DotnsRegistryOld is Initializable, UUPSUpgradeable, OwnableUpgradeable, address storedOwner = record.owner; if (storedOwner != address(0)) return storedOwner; if (!record.exists) return address(0); - IDotnsRegistrarOld registrar = IDotnsRegistrarOld(protocolRegistry.get(DotnsConstantsOld.REGISTRAR)); + IDotnsRegistrarOld registrar = + IDotnsRegistrarOld(protocolRegistry.get(DotnsConstantsOld.REGISTRAR)); return registrar.ownerOf(uint256(node)); } @@ -210,7 +212,8 @@ contract DotnsRegistryOld is Initializable, UUPSUpgradeable, OwnableUpgradeable, ) internal { - IStoreFactoryOld factory = IStoreFactoryOld(protocolRegistry.get(DotnsConstantsOld.STORE_FACTORY)); + IStoreFactoryOld factory = + IStoreFactoryOld(protocolRegistry.get(DotnsConstantsOld.STORE_FACTORY)); factory.writeLabel(storeOwner, node, fullName); } @@ -274,7 +277,8 @@ contract DotnsRegistryOld is Initializable, UUPSUpgradeable, OwnableUpgradeable, if (!record.exists) return false; - IDotnsRegistrarOld registrar = IDotnsRegistrarOld(protocolRegistry.get(DotnsConstantsOld.REGISTRAR)); + IDotnsRegistrarOld registrar = + IDotnsRegistrarOld(protocolRegistry.get(DotnsConstantsOld.REGISTRAR)); uint256 tokenId = uint256(node); address tokenOwner = registrar.ownerOf(tokenId); if (account == tokenOwner) return true; @@ -290,7 +294,8 @@ contract DotnsRegistryOld is Initializable, UUPSUpgradeable, OwnableUpgradeable, /// in one place and lets commit-reveal and PoP controllers coexist without registry /// reconfiguration on each addition. function _onlyRegistrarController() internal view { - IDotnsRegistrarOld registrar = IDotnsRegistrarOld(protocolRegistry.get(DotnsConstantsOld.REGISTRAR)); + IDotnsRegistrarOld registrar = + IDotnsRegistrarOld(protocolRegistry.get(DotnsConstantsOld.REGISTRAR)); require(registrar.controllers(IDotnsController(msg.sender)), NotAuthorised()); } diff --git a/contracts/registry/IDotnsRegistryOld.sol b/contracts/registry/IDotnsRegistryOld.sol index 15a7fc79d..892ae3edb 100644 --- a/contracts/registry/IDotnsRegistryOld.sol +++ b/contracts/registry/IDotnsRegistryOld.sol @@ -124,7 +124,8 @@ interface IDotnsRegistryOld { /// 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 `IDotnsRegistrarOld.ownerOf` and ERC-721 transfers remain authoritative. Emits + /// delegate to `IDotnsRegistrarOld.ownerOf` and ERC-721 transfers remain authoritative. + /// Emits /// @custom:emits NodeTransferred on success. function setOwner(bytes32 node, address newOwner) external; diff --git a/contracts/resolvers/DotnsContentResolverOld.sol b/contracts/resolvers/DotnsContentResolverOld.sol index d71e28ed0..2a59ba308 100644 --- a/contracts/resolvers/DotnsContentResolverOld.sol +++ b/contracts/resolvers/DotnsContentResolverOld.sol @@ -125,7 +125,8 @@ contract DotnsContentResolverOld is /// local-operator checks run before the cross-contract registry call. /// @param node Node identifier. function _requireNodeOwnerOrOperator(bytes32 node) internal view { - IDotnsRegistryOld _registry = IDotnsRegistryOld(protocolRegistry.get(DotnsConstantsOld.REGISTRY)); + IDotnsRegistryOld _registry = + IDotnsRegistryOld(protocolRegistry.get(DotnsConstantsOld.REGISTRY)); address nodeOwner = _registry.owner(node); require( msg.sender == nodeOwner || operators[nodeOwner][msg.sender] diff --git a/contracts/resolvers/DotnsResolverOld.sol b/contracts/resolvers/DotnsResolverOld.sol index 3d9d69e7c..59661989e 100644 --- a/contracts/resolvers/DotnsResolverOld.sol +++ b/contracts/resolvers/DotnsResolverOld.sol @@ -84,7 +84,8 @@ contract DotnsResolverOld is /// upgrade or rewire is picked up automatically without a resolver upgrade. /// @param node Node identifier. function _onlyNodeOwner(bytes32 node) internal view { - IDotnsRegistryOld _registry = IDotnsRegistryOld(protocolRegistry.get(DotnsConstantsOld.REGISTRY)); + IDotnsRegistryOld _registry = + IDotnsRegistryOld(protocolRegistry.get(DotnsConstantsOld.REGISTRY)); require(_registry.owner(node) == msg.sender, NotAuthorised(node, msg.sender)); } diff --git a/contracts/store/StoreFactoryMigrator.sol b/contracts/store/StoreFactoryMigrator.sol new file mode 100644 index 000000000..3ba6771b7 --- /dev/null +++ b/contracts/store/StoreFactoryMigrator.sol @@ -0,0 +1,311 @@ +// 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 {BeaconProxy} from "@openzeppelin/contracts/proxy/beacon/BeaconProxy.sol"; +import {UpgradeableBeacon} from "@openzeppelin/contracts/proxy/beacon/UpgradeableBeacon.sol"; + +import {IStoreFactory} from "./IStoreFactory.sol"; +import {IDotnsStore} from "./IDotnsStore.sol"; +import {ILabelStore} from "./ILabelStore.sol"; +import {IUserStore} from "./IUserStore.sol"; +import {LabelStore} from "./LabelStore.sol"; +import {UserStore} from "./UserStore.sol"; +import {IDotnsProtocolRegistry} from "../registry/IDotnsProtocolRegistry.sol"; +import {StoreAuth} from "../utils/StoreAuth.sol"; + +/// @title StoreFactoryMigrator +/// @notice The shipped `StoreFactory`, with the per-user bindings reachable and a one-shot import +/// that copies them from the factory a network used before this one. +/// @dev PR-scoped migration tooling, upgraded into the proxy for a single transaction and +/// upgraded straight back out. It exists because a `StoreFactory` cannot be moved: the +/// bindings are proxy storage and the shipped contract offers no way to write one except by +/// deploying a new store, so a network that re-points `STORE_FACTORY` at a fresh factory +/// starts with an empty directory and hands every existing user a second, empty store the +/// next time one is needed. +/// +/// The contract body is the shipped factory verbatim, with four fields widened from +/// `private` to `internal` and `importStores` added. Keeping it a copy rather than a +/// subclass or a set of raw slot writes is what makes the layout identical by construction: +/// the upgrade in and the upgrade back both diff against a layout that cannot have drifted. +/// +/// What it does not do: the imported stores stay on the beacons the old factory minted, and +/// those beacons answer to the old factory. Their implementations remain upgradeable there, +/// by the same owner, and are not reachable from this factory's beacons. A `BeaconProxy` +/// holds its beacon address in an immutable, so no migration can change that. +/// @custom:security-contact admin@parity.io +contract StoreFactoryMigrator is Initializable, UUPSUpgradeable, OwnableUpgradeable, IStoreFactory { + /// @notice Beacon backing every `LabelStore` proxy. + /// @dev Public getter name is interface-constrained by @custom:contract IStoreFactory. + address public override labelStoreBeacon; + + /// @notice Beacon backing every `UserStore` proxy. + /// @dev Public getter name is interface-constrained by @custom:contract IStoreFactory. + address public override userStoreBeacon; + + /// @notice Protocol registry used to authorise `deployLabelStoreFor` callers. + /// @dev Public getter name is interface-constrained by @custom:contract IStoreFactory. + address public override protocolRegistry; + + /// @dev user => their permanent `LabelStore`. Set once per user, forever. + mapping(address user => address store) internal _labelStores; + + /// @dev user => their permanent `UserStore`. Set once per user, forever. + mapping(address user => address store) internal _userStores; + + /// @dev Insertion-order list of every `LabelStore` proxy ever deployed. Append-only. + address[] internal _labelStoreList; + + /// @dev Insertion-order list of every `UserStore` proxy ever claimed. Append-only. + address[] internal _userStoreList; + + /// @dev Reserved storage space to allow for layout changes in future upgrades. + uint256[50] private __gap; + + /// @notice A binding was adopted from the previous factory. + /// @param user Address the store belongs to. + /// @param store The `LabelStore` now bound to `user` on this factory. + event StoresImported(address indexed user, address indexed store); + + /// @notice The supplied list does not have the length the old factory reports. + /// @dev Guards the one failure the loop cannot see: a list that silently omits users, which + /// would leave them unbound and hand them an empty store on their next registration. + /// @param expected Count the old factory reports. + /// @param supplied Length of the list passed in. + error ImportCountMismatch(uint256 expected, uint256 supplied); + + /// @notice Restricts `deployLabelStoreFor` to the owner or a component named in + /// @custom:function StoreAuth.isStoreWriter. + modifier onlyOwnerOrProtocol() { + _onlyOwnerOrProtocol(); + _; + } + + /// @custom:oz-upgrades-unsafe-allow constructor + constructor() { + _disableInitializers(); + } + + /// @notice Initialises the factory together with both store implementations and beacons. + /// @dev Callable exactly once via `Initializable`, otherwise + /// @custom:reverts InvalidInitialization. A single initialiser call wires everything: + /// - Deploys a fresh `LabelStore` implementation. + /// - Deploys a fresh `UserStore` implementation. + /// - Constructs both `UpgradeableBeacon` instances, owned by `address(this)`, which + /// under the proxy is the proxy itself, so `upgrade*Implementation` can delegate to + /// `beacon.upgradeTo` and the beacons outlive any implementation swap. + /// The implementations are deployed here rather than accepted as parameters, so the call + /// carries no ordering dependency on a prior deploy and exposes no argument through which + /// a mismatched implementation could reach a beacon. `protocolRegistry_` must be + /// non-zero, otherwise @custom:reverts InvalidProtocolRegistry. + /// @param initialOwner Account that owns this factory and can upgrade it and the store + /// implementations. + /// @param protocolRegistry_ The protocol registry for writer auth on label stores. + function initialize(address initialOwner, address protocolRegistry_) external initializer { + __Ownable_init(initialOwner); + + require(protocolRegistry_ != address(0), InvalidProtocolRegistry(protocolRegistry_)); + // Probing the registry rejects a wrong address here rather than at the first store deploy, + // and is what catches the two address arguments being passed the wrong way round. + IDotnsProtocolRegistry(protocolRegistry_).isRegisteredAddress(address(0)); + + protocolRegistry = protocolRegistry_; + labelStoreBeacon = address(new UpgradeableBeacon(address(new LabelStore()), address(this))); + userStoreBeacon = address(new UpgradeableBeacon(address(new UserStore()), address(this))); + } + + /// @inheritdoc IStoreFactory + function deployLabelStoreFor(address user) + external + override + onlyOwnerOrProtocol + returns (address store) + { + require(user != address(0), InvalidUser(user)); + require(_labelStores[user] == address(0), AlreadyDeployed(user, _labelStores[user])); + + bytes memory initData = abi.encodeCall(ILabelStore.initialize, (user, protocolRegistry)); + store = address(new BeaconProxy(labelStoreBeacon, initData)); + require(IDotnsStore(store).owner() == user, ImplementationBindingMismatch()); + _labelStores[user] = store; + _labelStoreList.push(store); + + emit LabelStoreDeployed(user, store); + } + + /// @inheritdoc IStoreFactory + function getLabelStore(address user) external view override returns (address store) { + return _labelStores[user]; + } + + /// @inheritdoc IStoreFactory + function getLabelStoreCount() external view override returns (uint256 count) { + return _labelStoreList.length; + } + + /// @inheritdoc IStoreFactory + function getLabelStores( + uint256 offset, + uint256 limit + ) + external + view + override + returns (address[] memory stores) + { + stores = _paginateAddresses(_labelStoreList, offset, limit); + } + + /// @inheritdoc IStoreFactory + function upgradeLabelStoreImplementation(address newImplementation) + external + override + onlyOwner + { + require(newImplementation != address(0), InvalidImplementation(newImplementation)); + ILabelStore(newImplementation).protocolRegistry(); + UpgradeableBeacon(labelStoreBeacon).upgradeTo(newImplementation); + emit LabelStoreImplementationUpgraded(newImplementation); + } + + /// @inheritdoc IStoreFactory + function claimUserStore() external override returns (address store) { + require( + _userStores[msg.sender] == address(0), + AlreadyDeployed(msg.sender, _userStores[msg.sender]) + ); + + bytes memory initData = + abi.encodeCall(IUserStore.initialize, (msg.sender, protocolRegistry)); + store = address(new BeaconProxy(userStoreBeacon, initData)); + require(IDotnsStore(store).owner() == msg.sender, ImplementationBindingMismatch()); + _userStores[msg.sender] = store; + _userStoreList.push(store); + + emit UserStoreClaimed(msg.sender, store); + } + + /// @inheritdoc IStoreFactory + function getUserStore(address user) external view override returns (address store) { + return _userStores[user]; + } + + /// @inheritdoc IStoreFactory + function getUserStoreCount() external view override returns (uint256 count) { + return _userStoreList.length; + } + + /// @inheritdoc IStoreFactory + function getUserStores( + uint256 offset, + uint256 limit + ) + external + view + override + returns (address[] memory stores) + { + stores = _paginateAddresses(_userStoreList, offset, limit); + } + + /// @inheritdoc IStoreFactory + function upgradeUserStoreImplementation(address newImplementation) external override onlyOwner { + require(newImplementation != address(0), InvalidImplementation(newImplementation)); + IUserStore(newImplementation).protocolRegistry(); + UpgradeableBeacon(userStoreBeacon).upgradeTo(newImplementation); + emit UserStoreImplementationUpgraded(newImplementation); + } + + /// @notice Returns the release this network declares it runs, read live from the protocol + /// registry so every DotNS contract reports one synchronised value. + /// @dev Mirror of `IDotnsProtocolRegistry.protocolVersion`, kept under the historical + /// `version()` selector for ABI compatibility. It reports the network's declaration, + /// not this contract's build; per-contract identity is the codehash declared on the + /// registry. + /// @return versionString Declared release as bare semver, empty when never declared. + function version() external view virtual returns (string memory versionString) { + versionString = IDotnsProtocolRegistry(protocolRegistry).protocolVersion(); + } + + /// @inheritdoc UUPSUpgradeable + function _authorizeUpgrade(address newImplementation) internal override onlyOwner {} + + /// @notice Internal authorisation check deferred from the `onlyOwnerOrProtocol` modifier. + function _onlyOwnerOrProtocol() internal view { + if (msg.sender == owner()) return; + require(StoreAuth.isStoreWriter(protocolRegistry, msg.sender), NotAuthorised(msg.sender)); + } + + /// @notice Copies the per-user `LabelStore` bindings of `oldFactory` into this factory. + /// @dev One-shot and owner-only. Each user is bound at most once, here as everywhere else: a + /// user who already holds a binding on this factory is rejected rather than repointed, + /// so a second run with an overlapping list fails loudly instead of rewriting history. + /// `users` is supplied by the caller rather than read from `oldFactory`, because the + /// deployed factory exposes no enumeration; the operator reads its store list out of + /// storage and passes it in, and the length check below is what catches a short list. + /// + /// `UserStore` bindings are deliberately not imported. They are claimed by users + /// themselves and the network being migrated from has none; a future migration that does + /// have them needs this function extended rather than reused. + /// @param oldFactory Factory whose bindings are being adopted. + /// @param users Every address holding a `LabelStore` on `oldFactory`, in any order. + /// @param expectedCount Number of bindings `oldFactory` reports, asserted against `users`. + function importStores( + address oldFactory, + address[] calldata users, + uint256 expectedCount + ) + external + onlyOwner + { + require(oldFactory != address(0), InvalidUser(oldFactory)); + require(users.length == expectedCount, ImportCountMismatch(expectedCount, users.length)); + + for (uint256 i; i < users.length; ++i) { + address user = users[i]; + require(user != address(0), InvalidUser(user)); + + address existing = _labelStores[user]; + require(existing == address(0), AlreadyDeployed(user, existing)); + + address store = IStoreFactory(oldFactory).getLabelStore(user); + require(store != address(0), InvalidUser(user)); + + _labelStores[user] = store; + _labelStoreList.push(store); + + emit StoresImported(user, store); + } + } + + /// @notice Shared pagination helper used by `getLabelStores` and `getUserStores`. + /// @dev Single canonical slicer so both enumerations bound-check and copy identically. + /// @param source Storage array to slice. + /// @param offset Start index. + /// @param limit Maximum entries to return. + /// @return slice Result slice; empty when `offset >= source.length`. + function _paginateAddresses( + address[] storage source, + uint256 offset, + uint256 limit + ) + internal + view + returns (address[] memory slice) + { + uint256 total = source.length; + if (offset >= total) return new address[](0); + + uint256 available = total - offset; + uint256 count = limit < available ? limit : available; + + slice = new address[](count); + for (uint256 i; i < count; ++i) { + slice[i] = source[offset + i]; + } + } +} diff --git a/contracts/utils/DotnsConstantsOld.sol b/contracts/utils/DotnsConstantsOld.sol index fb61d5f01..e6bd98606 100644 --- a/contracts/utils/DotnsConstantsOld.sol +++ b/contracts/utils/DotnsConstantsOld.sol @@ -51,8 +51,8 @@ library DotnsConstantsOld { /// @notice Default release cooldown seeded on `DotnsNameEscrowOld.initialize`. /// @dev Single source of truth for deploy scripts and tests so the value cannot drift between - /// call sites. Bounded on-chain by `DotnsNameEscrowOld.MAX_COOLDOWN`. Live deployments rotate - /// the runtime value via `updateCooldown` rather than rebuilding consumers. + /// call sites. Bounded on-chain by `DotnsNameEscrowOld.MAX_COOLDOWN`. Live deployments + /// rotate the runtime value via `updateCooldown` rather than rebuilding consumers. uint256 internal constant ESCROW_COOLDOWN = 15 minutes; /// @notice Default redeem window seeded on `DotnsNameEscrowOld.initialize`. diff --git a/contracts/utils/StoreUtilsOld.sol b/contracts/utils/StoreUtilsOld.sol index cbdf8fe61..2e1bbb3b1 100644 --- a/contracts/utils/StoreUtilsOld.sol +++ b/contracts/utils/StoreUtilsOld.sol @@ -19,7 +19,10 @@ library StoreUtilsOld { /// @param factory The store factory. /// @param user The user whose label store is being resolved. /// @return store The resolved or newly deployed store address. - function ensureLabelStore(IStoreFactoryOld factory, address user) + function ensureLabelStore( + IStoreFactoryOld factory, + address user + ) internal returns (address store) { diff --git a/contracts/utils/SubnodeUtilsOld.sol b/contracts/utils/SubnodeUtilsOld.sol index d53a2a632..2f380731d 100644 --- a/contracts/utils/SubnodeUtilsOld.sol +++ b/contracts/utils/SubnodeUtilsOld.sol @@ -12,8 +12,9 @@ import {DotnsConstantsOld} from "./DotnsConstantsOld.sol"; /// @notice General-purpose helpers for registering names that live as subnodes of another name, /// rather than as tokenised second-level registrations. /// @dev A subname has no token: its ownership lives in the registry record, not in the registrar's -/// ERC-721 ledger. So it is registered through @custom:function IDotnsRegistryOld.setSubnodeOwner -/// here, rather than through the tokenised mint triad of @custom:contract RegistrationUtilsOld. +/// ERC-721 ledger. So it is registered through @custom:function +/// IDotnsRegistryOld.setSubnodeOwner here, rather than through the tokenised mint triad of +/// @custom:contract RegistrationUtilsOld. /// @custom:security-contact admin@parity.io library SubnodeUtilsOld { /// @notice Inputs describing a single subname registration. @@ -106,8 +107,10 @@ library SubnodeUtilsOld { protocolRegistry.tldNode(), LabelUtils.labelhashMemory(context.parentLabel) ); - IDotnsRegistrarOld registrar = IDotnsRegistrarOld(protocolRegistry.get(DotnsConstantsOld.REGISTRAR)); - IDotnsRegistryOld registry = IDotnsRegistryOld(protocolRegistry.get(DotnsConstantsOld.REGISTRY)); + IDotnsRegistrarOld registrar = + IDotnsRegistrarOld(protocolRegistry.get(DotnsConstantsOld.REGISTRAR)); + IDotnsRegistryOld registry = + IDotnsRegistryOld(protocolRegistry.get(DotnsConstantsOld.REGISTRY)); // Mint the parent on first use, owned by the caller, and pass an empty label so no // `LabelStore` is written for it. When it already exists it must belong to the caller, diff --git a/deployments/paseo-assethub/README.md b/deployments/paseo-assethub/README.md new file mode 100644 index 000000000..65c9d8417 --- /dev/null +++ b/deployments/paseo-assethub/README.md @@ -0,0 +1,31 @@ +# `paseo-assethub` + +`420420417.json` is **Paseo Asset Hub Next**, reached at `https://eth-rpc-paseo-next.polkadot.io`. + +The note lives here rather than in the manifest because the manifest is address-only by +contract: `BaseDeployer.initDeployment` parses every key in it as an address, and so does +`release-metadata.mjs`. A text field added there fails the deploy pipeline on its first read. + +## The chain id does not identify the network + +Chain id 420420417 is shared with other Paseo-style environments, including the public Polkadot +Hub TestNet gateway at `https://services.polkadothub-rpc.com/testnet`, which answers on that id +and has no code at any of these addresses. An adapter or fork pointed at the wrong one resolves +every address here and finds all of them empty, so calls revert for reasons that look like +anything but the real cause. The fork tests assert code is present at each address they resolve, +which turns that into a clear failure instead of a confusing one. + +That gateway also answers `eth_getLogs` with an empty result for every range rather than an +error, so anything built from a log replay against it looks like it worked and is empty. Use an +archive node, or Blockscout at `https://blockscout-paseo-next.polkadot.io`. + +## What is deployed here is not a release + +These proxies are upgraded in place from `spha/registrar-upgrade`, which is never merged to +`master`, so no release tag describes the code they run. `DEPLOYMENTS.md` has the detail; the +short version is that `verify --tag` reports the `registrarController` key as drift permanently +and by design, every other key verifies, and a second drifting key is a real finding. + +Before broadcasting anything against this network, run `scripts/shell/verify-snapshots.sh` +against it. It is the only check that catches a snapshot describing an implementation that was +replaced in place and stopped running months ago. diff --git a/scripts/deploy/DeclareRelease.s.sol b/scripts/deploy/DeclareRelease.s.sol new file mode 100644 index 000000000..6455520b3 --- /dev/null +++ b/scripts/deploy/DeclareRelease.s.sol @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.34; + +import {console} from "forge-std/Script.sol"; + +import {WireDeployments} from "./WireDeployments.s.sol"; + +/// @title DeclareRelease +/// @notice Re-declares, on chain, what code each well-known key is expected to execute and which +/// release the network runs. Run once, after every implementation swap has verified. +/// @dev The declaration half of `WireDeployments`, without the wiring half. A fresh deploy wires +/// the keys and declares in one pass; an in-place upgrade moves the code behind keys that are +/// already wired, so it needs the declarations refreshed and nothing else. Extending the +/// pipeline rather than restating its key list is deliberate: a release that adds a key would +/// otherwise be declared by a fresh deploy and silently skipped by every upgrade, and the +/// resulting gap reads as drift with no way to tell it from an unauthorised swap. +/// +/// The codehashes come from the chain, not from the build. Declaring from artefacts would +/// let a swap that silently did not happen be papered over by a declaration saying it did. +/// +/// Ordering carries the same rule the checklist states for any network: the version is a +/// claim about the whole deployment, so it is written last and only once every key has been +/// declared. A run abandoned half way leaves the previous version standing, which clients +/// read as an older network rather than as a false new one. +/// @custom:security-contact admin@parity.io +contract DeclareRelease is WireDeployments { + /// @notice Declares every key's codehash, verifies the deployment, then declares the release. + /// @dev `DOTNS_RELEASE_TAG` is the bare semver the registry stores, for example `0.8.0`. It is + /// read before anything is broadcast, so a run that could not declare its release at the + /// end fails before it has written any of the codehashes. + function declare() external { + address owner = msg.sender; + vm.label(owner, "OWNER"); + + string memory releaseTag = vm.envString("DOTNS_RELEASE_TAG"); + + initDeployment(networkFolder(), vm.toString(block.chainid)); + + Addresses memory addr = _loadAddresses(); + + _declareCodeIdentity(owner, addr); + _verifyDeployment(addr, owner); + _declareProtocolVersion(owner, addr, releaseTag); + + console.log("=== DeclareRelease complete ==="); + } +} diff --git a/scripts/deploy/MigrateStoreFactory.s.sol b/scripts/deploy/MigrateStoreFactory.s.sol new file mode 100644 index 000000000..cdbb09d36 --- /dev/null +++ b/scripts/deploy/MigrateStoreFactory.s.sol @@ -0,0 +1,156 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.34; + +import {console} from "forge-std/Script.sol"; +import {Options} from "openzeppelin-foundry-upgrades/Options.sol"; +import {Upgrades} from "openzeppelin-foundry-upgrades/Upgrades.sol"; + +import {BaseDeployer} from "./BaseDeployer.s.sol"; +import {DotnsConstants} from "../../contracts/utils/DotnsConstants.sol"; +import {IStoreFactory} from "../../contracts/store/IStoreFactory.sol"; +import {StoreFactoryMigrator} from "../../contracts/store/StoreFactoryMigrator.sol"; +import {IDotnsProtocolRegistry} from "../../contracts/registry/IDotnsProtocolRegistry.sol"; +import { + OwnableUpgradeable +} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; + +/// @title MigrateStoreFactory +/// @notice Moves the per-user `LabelStore` bindings onto the current `StoreFactory` proxy and +/// points the `storeFactory` key at it. +/// @dev The one contract in the upgrade that cannot be swapped in place. The deployed factory is +/// a plain contract from an earlier release, not a proxy, so there is nothing to upgrade; +/// the current release puts the factory behind its own proxy at a different address. Every +/// store lookup goes through whatever `STORE_FACTORY` resolves to, so re-pointing the key at +/// an empty factory would hand each existing user a second, empty store the next time one +/// was needed, and drop their labels out of per-address enumeration. Their names are +/// untouched either way: ownership lives in the registry, not here. +/// +/// Sequence, all under the proxy owner: +/// +/// 1. upgrade the proxy to `StoreFactoryMigrator`, calling `importStores` in the same +/// transaction, so the proxy is never left sitting on migration tooling between two +/// broadcasts; +/// 2. upgrade it back to the shipped `StoreFactory`; +/// 3. re-point the `storeFactory` key and declare the new codehash together, because the +/// checklist treats an unpaired rewire as drift. +/// +/// Both upgrades supply a layout reference and no unsafe override, so each direction is +/// diffed. The migrator is the shipped factory with four fields widened and one entrypoint +/// added, so both diffs are no-ops that still have to pass. +/// +/// What this does not do: the imported stores keep the beacons the old factory minted, and +/// those beacons answer to the old factory. Their code stays upgradeable there, by the same +/// owner. A `BeaconProxy` holds its beacon in an immutable, so no migration can move them. +/// +/// The user list is supplied rather than read from the chain: the deployed factory has no +/// enumeration function, so an operator reads its store list out of storage and passes it +/// in. `expectedCount` is read from the old factory here and asserted inside `importStores`, +/// which is what catches a list that silently omits users. +/// @custom:security-contact admin@parity.io +contract MigrateStoreFactory is BaseDeployer { + /// @notice Manifest label the current `StoreFactory` proxy is recorded under. + string internal constant STORE_FACTORY_LABEL = "StoreFactory"; + + /// @notice Manifest label the protocol registry is recorded under. + string internal constant PROTOCOL_REGISTRY_LABEL = "DotnsProtocolRegistry"; + + /// @notice Imports the bindings, restores the shipped implementation, and rewires the key. + /// @dev `DOTNS_OLD_STORE_FACTORY` is the factory being migrated from. `DOTNS_STORE_USERS` is + /// a comma-separated list of every address holding a `LabelStore` on it. + function run() external { + address owner = msg.sender; + vm.label(owner, "OWNER"); + + initDeployment(networkFolder(), vm.toString(block.chainid)); + + address proxy = _readAddress(STORE_FACTORY_LABEL); + address oldFactory = vm.envAddress("DOTNS_OLD_STORE_FACTORY"); + address[] memory users = vm.envAddress("DOTNS_STORE_USERS", ","); + + require( + owner == OwnableUpgradeable(proxy).owner(), + "MigrateStoreFactory: broadcaster is not the proxy owner" + ); + require( + oldFactory != proxy, "MigrateStoreFactory: old and new factory are the same address" + ); + + uint256 expectedCount = IStoreFactory(oldFactory).getLabelStoreCount(); + console.log(" importing", expectedCount, "bindings from", oldFactory); + + _importBindings(owner, proxy, oldFactory, users, expectedCount); + _restoreShippedImplementation(owner, proxy); + _rewireKey(owner, proxy); + + console.log("=== MigrateStoreFactory complete ==="); + } + + /// @notice Swaps in the migrator and imports in one transaction. + /// @dev `upgradeToAndCall` runs the import as a delegatecall from the proxy, so `msg.sender` + /// is preserved and the `onlyOwner` gate on `importStores` is satisfied by the + /// broadcaster rather than by the proxy calling itself. + /// @param owner Account that owns the proxy and broadcasts. + /// @param proxy The `StoreFactory` proxy being migrated into. + /// @param oldFactory Factory whose bindings are adopted. + /// @param users Every address holding a `LabelStore` on `oldFactory`. + /// @param expectedCount Binding count read from `oldFactory`. + function _importBindings( + address owner, + address proxy, + address oldFactory, + address[] memory users, + uint256 expectedCount + ) + internal + { + Options memory opts; + opts.referenceContract = "StoreFactory.sol:StoreFactory"; + + vm.startBroadcast(owner); + Upgrades.upgradeProxy( + proxy, + "StoreFactoryMigrator.sol:StoreFactoryMigrator", + abi.encodeCall(StoreFactoryMigrator.importStores, (oldFactory, users, expectedCount)), + opts + ); + vm.stopBroadcast(); + + console.log(" imported bindings into", proxy); + } + + /// @notice Returns the proxy to the shipped implementation. + /// @dev Left on the migrator, the deployment would be running tooling that no release + /// describes, and the codehash declared in step three would be the tooling's. + /// @param owner Account that owns the proxy and broadcasts. + /// @param proxy The `StoreFactory` proxy. + function _restoreShippedImplementation(address owner, address proxy) internal { + Options memory opts; + opts.referenceContract = "StoreFactoryMigrator.sol:StoreFactoryMigrator"; + + vm.startBroadcast(owner); + Upgrades.upgradeProxy(proxy, "StoreFactory.sol:StoreFactory", "", opts); + vm.stopBroadcast(); + + console.log(" restored the shipped StoreFactory implementation"); + } + + /// @notice Points the `storeFactory` key at the proxy and declares its codehash. + /// @dev Paired on purpose. `DEPLOYMENT_CHECKLIST.md` treats a rewire without a matching + /// declaration as drift, indistinguishable from an unauthorised swap. + /// @param owner Account that owns the protocol registry and broadcasts. + /// @param proxy The `StoreFactory` proxy the key should resolve to. + function _rewireKey(address owner, address proxy) internal { + IDotnsProtocolRegistry registry = + IDotnsProtocolRegistry(_readAddress(PROTOCOL_REGISTRY_LABEL)); + + address implementation = + address(uint160(uint256(vm.load(proxy, ERC1967_IMPLEMENTATION_SLOT)))); + + vm.startBroadcast(owner); + registry.set(DotnsConstants.STORE_FACTORY, proxy); + registry.setExpectedCodehash(DotnsConstants.STORE_FACTORY, implementation.codehash); + vm.stopBroadcast(); + + console.log(" storeFactory key now resolves to", proxy); + } +} diff --git a/scripts/deploy/RedeployPopLens.s.sol b/scripts/deploy/RedeployPopLens.s.sol deleted file mode 100644 index 51d9f07fc..000000000 --- a/scripts/deploy/RedeployPopLens.s.sol +++ /dev/null @@ -1,63 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.34; - -import {console} from "forge-std/Script.sol"; - -import {BaseDeployer} from "./BaseDeployer.s.sol"; -import {DotnsPopLens} from "../../contracts/registrars/DotnsPopLens.sol"; -import {IDotnsProtocolRegistry} from "../../contracts/registry/IDotnsProtocolRegistry.sol"; -import {DotnsConstants} from "../../contracts/utils/DotnsConstants.sol"; -import { - OwnableUpgradeable -} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; - -/// @title RedeployPopLens -/// @notice Redeploys the DotnsPopLens read helper and repoints the protocol registry `popLens` key -/// at the new instance. The lens is immutable and holds no state, so it is replaced rather -/// than upgraded: it reads a lite name at its hierarchical node, which the previous -/// implementation did not, and nothing on chain calls it, so the swap only affects -/// off-chain enumeration. -/// @dev PR-scoped. This script and the paired `test/fork/RedeployPopLens.t.sol` are deleted before -/// merge per the upgrade-PR workflow in CONTRIBUTING.md. There is no proxy and so no storage -/// layout to diff: the lens binds the protocol registry in its constructor and derives every -/// answer from a live registry read. -/// @custom:security-contact admin@parity.io -contract RedeployPopLens is BaseDeployer { - /// @notice Manifest label the protocol registry proxy is recorded under. - string internal constant PROTOCOL_REGISTRY_LABEL = "DotnsProtocolRegistry"; - - /// @notice Reads the manifest, resolves the protocol registry, and redeploys the lens as - /// `msg.sender`. - /// @dev `msg.sender` must own the protocol registry, otherwise the `set` owner gate reverts. - function run() external { - address owner = msg.sender; - vm.label(owner, "OWNER"); - - initDeployment(networkFolder(), vm.toString(block.chainid)); - - address registry = _readAddress(PROTOCOL_REGISTRY_LABEL); - _redeployPopLens(owner, registry); - - console.log("=== RedeployPopLens complete ==="); - } - - /// @notice Deploys a fresh lens bound to `registry` and repoints the `popLens` key at it. - /// @dev A plain deploy is used rather than the deterministic factory so the replacement lands - /// at a fresh address instead of colliding with the live lens at its salted address. The - /// registry `set` is owner gated, so the broadcaster must own the protocol registry. - /// @param owner Account that owns the protocol registry and broadcasts the redeploy. - /// @param registry Protocol registry proxy address resolved from the manifest. - function _redeployPopLens(address owner, address registry) internal { - require( - owner == OwnableUpgradeable(registry).owner(), - "RedeployPopLens: broadcaster is not the protocol registry owner" - ); - - vm.startBroadcast(owner); - DotnsPopLens lens = new DotnsPopLens(IDotnsProtocolRegistry(registry)); - IDotnsProtocolRegistry(registry).set(DotnsConstants.POP_LENS, address(lens)); - vm.stopBroadcast(); - - console.log(" redeployed DotnsPopLens", address(lens)); - } -} diff --git a/scripts/deploy/UpgradeContentResolver.s.sol b/scripts/deploy/UpgradeContentResolver.s.sol new file mode 100644 index 000000000..3434c4d3a --- /dev/null +++ b/scripts/deploy/UpgradeContentResolver.s.sol @@ -0,0 +1,76 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.34; + +import {console} from "forge-std/Script.sol"; +import {Options} from "openzeppelin-foundry-upgrades/Options.sol"; +import {Upgrades} from "openzeppelin-foundry-upgrades/Upgrades.sol"; + +import {BaseDeployer} from "./BaseDeployer.s.sol"; +import { + OwnableUpgradeable +} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; + +/// @title UpgradeContentResolver +/// @notice Upgrades the deployed DotnsContentResolver proxy to the current implementation. Resolves +/// the proxy from the on-disk manifest, diffs the new storage layout against the pinned +/// @custom:contract DotnsContentResolverOld snapshot, and swaps the implementation only +/// when the diff and every unsafe-pattern check pass. +/// @dev The swap carries no behavioural change of its own: `version()` stops returning a hardcoded +/// string and reads the protocol registry's declaration instead. +/// +/// The snapshot is the implementation deployed on chain, not the previous release: these +/// proxies were upgraded in place after their last release, so a snapshot taken from a tag +/// would describe code that has not run on this network for months. +/// `scripts/shell/verify-snapshots.sh` is what holds that property, by building the snapshot +/// and comparing it against the chain. The layout diff cannot: it compares whatever pair it +/// is given, and is blind to a change that lives in calldata. +/// @custom:security-contact admin@parity.io +contract UpgradeContentResolver is BaseDeployer { + /// @notice Pre-upgrade snapshot the layout diff compares the new implementation against. + /// @dev Source-file route, not a stored build-info directory, so the snapshot is versioned + /// beside the implementation and rebuilt by `forge build`. + string internal constant REFERENCE_CONTRACT = + "DotnsContentResolverOld.sol:DotnsContentResolverOld"; + + /// @notice Manifest label the DotnsContentResolver proxy is recorded under. + string internal constant CONTENT_RESOLVER_LABEL = "DotnsContentResolver"; + + /// @notice Reads the manifest, resolves the DotnsContentResolver proxy, and upgrades it as + /// `msg.sender`. + /// @dev `msg.sender` must own the proxy, otherwise the `_authorizeUpgrade` owner gate reverts. + function run() external { + address owner = msg.sender; + vm.label(owner, "OWNER"); + + initDeployment(networkFolder(), vm.toString(block.chainid)); + + address proxy = _readAddress(CONTENT_RESOLVER_LABEL); + _upgradeContentResolver(owner, proxy); + + console.log("=== UpgradeContentResolver complete ==="); + } + + /// @notice Upgrades `proxy` to the current `DotnsContentResolver` implementation under `owner`. + /// @dev The layout diff runs inside `Upgrades.upgradeProxy` before the implementation swap. No + /// `unsafeSkipAllChecks` or `unsafeAllow` override is set, so an incompatible layout + /// aborts the run rather than corrupting state. An empty upgrade call is passed because + /// the new implementation seeds no storage of its own; where a release declares anything + /// on chain, `DeclareRelease.s.sol` does it once, after every swap has verified. + /// @param owner Account that owns the proxy and broadcasts the upgrade. + /// @param proxy DotnsContentResolver proxy address resolved from the manifest. + function _upgradeContentResolver(address owner, address proxy) internal { + require( + owner == OwnableUpgradeable(proxy).owner(), + "UpgradeContentResolver: broadcaster is not the proxy owner" + ); + + Options memory opts; + opts.referenceContract = REFERENCE_CONTRACT; + + vm.startBroadcast(owner); + Upgrades.upgradeProxy(proxy, "DotnsContentResolver.sol:DotnsContentResolver", "", opts); + vm.stopBroadcast(); + + console.log(" upgraded DotnsContentResolver proxy", proxy); + } +} diff --git a/scripts/deploy/UpgradeNameEscrow.s.sol b/scripts/deploy/UpgradeNameEscrow.s.sol new file mode 100644 index 000000000..5c413f58c --- /dev/null +++ b/scripts/deploy/UpgradeNameEscrow.s.sol @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.34; + +import {console} from "forge-std/Script.sol"; +import {Options} from "openzeppelin-foundry-upgrades/Options.sol"; +import {Upgrades} from "openzeppelin-foundry-upgrades/Upgrades.sol"; + +import {BaseDeployer} from "./BaseDeployer.s.sol"; +import { + OwnableUpgradeable +} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; + +/// @title UpgradeNameEscrow +/// @notice Upgrades the deployed DotnsNameEscrow proxy to the current implementation. Resolves the +/// proxy from the on-disk manifest, diffs the new storage layout against the pinned +/// @custom:contract DotnsNameEscrowOld snapshot, and swaps the implementation only when the +/// diff and every unsafe-pattern check pass. +/// @dev The swap splits the cooldown and redeem-window setters into an internal and an owner-gated +/// external half, so the initialiser seeds them without passing through the owner gate. +/// +/// The snapshot is the implementation deployed on chain, not the previous release: these +/// proxies were upgraded in place after their last release, so a snapshot taken from a tag +/// would describe code that has not run on this network for months. +/// `scripts/shell/verify-snapshots.sh` is what holds that property, by building the snapshot +/// and comparing it against the chain. The layout diff cannot: it compares whatever pair it +/// is given, and is blind to a change that lives in calldata. +/// @custom:security-contact admin@parity.io +contract UpgradeNameEscrow is BaseDeployer { + /// @notice Pre-upgrade snapshot the layout diff compares the new implementation against. + /// @dev Source-file route, not a stored build-info directory, so the snapshot is versioned + /// beside the implementation and rebuilt by `forge build`. + string internal constant REFERENCE_CONTRACT = "DotnsNameEscrowOld.sol:DotnsNameEscrowOld"; + + /// @notice Manifest label the DotnsNameEscrow proxy is recorded under. + string internal constant NAME_ESCROW_LABEL = "DotnsNameEscrow"; + + /// @notice Reads the manifest, resolves the DotnsNameEscrow proxy, and upgrades it as + /// `msg.sender`. + /// @dev `msg.sender` must own the proxy, otherwise the `_authorizeUpgrade` owner gate reverts. + function run() external { + address owner = msg.sender; + vm.label(owner, "OWNER"); + + initDeployment(networkFolder(), vm.toString(block.chainid)); + + address proxy = _readAddress(NAME_ESCROW_LABEL); + _upgradeNameEscrow(owner, proxy); + + console.log("=== UpgradeNameEscrow complete ==="); + } + + /// @notice Upgrades `proxy` to the current `DotnsNameEscrow` implementation under `owner`. + /// @dev The layout diff runs inside `Upgrades.upgradeProxy` before the implementation swap. No + /// `unsafeSkipAllChecks` or `unsafeAllow` override is set, so an incompatible layout + /// aborts the run rather than corrupting state. An empty upgrade call is passed because + /// the new implementation seeds no storage of its own; where a release declares anything + /// on chain, `DeclareRelease.s.sol` does it once, after every swap has verified. + /// @param owner Account that owns the proxy and broadcasts the upgrade. + /// @param proxy DotnsNameEscrow proxy address resolved from the manifest. + function _upgradeNameEscrow(address owner, address proxy) internal { + require( + owner == OwnableUpgradeable(proxy).owner(), + "UpgradeNameEscrow: broadcaster is not the proxy owner" + ); + + Options memory opts; + opts.referenceContract = REFERENCE_CONTRACT; + + vm.startBroadcast(owner); + Upgrades.upgradeProxy(proxy, "DotnsNameEscrow.sol:DotnsNameEscrow", "", opts); + vm.stopBroadcast(); + + console.log(" upgraded DotnsNameEscrow proxy", proxy); + } +} diff --git a/scripts/deploy/UpgradeNameWhitelist.s.sol b/scripts/deploy/UpgradeNameWhitelist.s.sol new file mode 100644 index 000000000..16b6b1b87 --- /dev/null +++ b/scripts/deploy/UpgradeNameWhitelist.s.sol @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.34; + +import {console} from "forge-std/Script.sol"; +import {Options} from "openzeppelin-foundry-upgrades/Options.sol"; +import {Upgrades} from "openzeppelin-foundry-upgrades/Upgrades.sol"; + +import {BaseDeployer} from "./BaseDeployer.s.sol"; +import { + OwnableUpgradeable +} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; + +/// @title UpgradeNameWhitelist +/// @notice Upgrades the deployed DotnsNameWhitelist proxy to the current implementation. Resolves +/// the proxy from the on-disk manifest, diffs the new storage layout against the pinned +/// @custom:contract DotnsNameWhitelistOld snapshot, and swaps the implementation only when +/// the diff and every unsafe-pattern check pass. +/// @dev The swap changes only `initialize`, which does not run on an upgrade. It is swapped for +/// parity, so the deployment is one release throughout rather than one contract behind. +/// +/// The snapshot is the implementation deployed on chain, not the previous release: these +/// proxies were upgraded in place after their last release, so a snapshot taken from a tag +/// would describe code that has not run on this network for months. +/// `scripts/shell/verify-snapshots.sh` is what holds that property, by building the snapshot +/// and comparing it against the chain. The layout diff cannot: it compares whatever pair it +/// is given, and is blind to a change that lives in calldata. +/// @custom:security-contact admin@parity.io +contract UpgradeNameWhitelist is BaseDeployer { + /// @notice Pre-upgrade snapshot the layout diff compares the new implementation against. + /// @dev Source-file route, not a stored build-info directory, so the snapshot is versioned + /// beside the implementation and rebuilt by `forge build`. + string internal constant REFERENCE_CONTRACT = "DotnsNameWhitelistOld.sol:DotnsNameWhitelistOld"; + + /// @notice Manifest label the DotnsNameWhitelist proxy is recorded under. + string internal constant NAME_WHITELIST_LABEL = "DotnsNameWhitelist"; + + /// @notice Reads the manifest, resolves the DotnsNameWhitelist proxy, and upgrades it as + /// `msg.sender`. + /// @dev `msg.sender` must own the proxy, otherwise the `_authorizeUpgrade` owner gate reverts. + function run() external { + address owner = msg.sender; + vm.label(owner, "OWNER"); + + initDeployment(networkFolder(), vm.toString(block.chainid)); + + address proxy = _readAddress(NAME_WHITELIST_LABEL); + _upgradeNameWhitelist(owner, proxy); + + console.log("=== UpgradeNameWhitelist complete ==="); + } + + /// @notice Upgrades `proxy` to the current `DotnsNameWhitelist` implementation under `owner`. + /// @dev The layout diff runs inside `Upgrades.upgradeProxy` before the implementation swap. No + /// `unsafeSkipAllChecks` or `unsafeAllow` override is set, so an incompatible layout + /// aborts the run rather than corrupting state. An empty upgrade call is passed because + /// the new implementation seeds no storage of its own; where a release declares anything + /// on chain, `DeclareRelease.s.sol` does it once, after every swap has verified. + /// @param owner Account that owns the proxy and broadcasts the upgrade. + /// @param proxy DotnsNameWhitelist proxy address resolved from the manifest. + function _upgradeNameWhitelist(address owner, address proxy) internal { + require( + owner == OwnableUpgradeable(proxy).owner(), + "UpgradeNameWhitelist: broadcaster is not the proxy owner" + ); + + Options memory opts; + opts.referenceContract = REFERENCE_CONTRACT; + + vm.startBroadcast(owner); + Upgrades.upgradeProxy(proxy, "DotnsNameWhitelist.sol:DotnsNameWhitelist", "", opts); + vm.stopBroadcast(); + + console.log(" upgraded DotnsNameWhitelist proxy", proxy); + } +} diff --git a/scripts/deploy/UpgradePopController.s.sol b/scripts/deploy/UpgradePopController.s.sol index fd35cc672..36c24bb14 100644 --- a/scripts/deploy/UpgradePopController.s.sol +++ b/scripts/deploy/UpgradePopController.s.sol @@ -12,26 +12,30 @@ import { /// @title UpgradePopController /// @notice Upgrades the deployed DotnsPopController proxy to the current implementation. Resolves -/// the proxy from the on-disk manifest, diffs the new storage layout against the pinned +/// the proxy from the on-disk manifest, diffs the new storage layout against the pinned /// @custom:contract DotnsPopControllerOld snapshot, and swaps the implementation only when -/// the diff and every unsafe-pattern check pass. -/// @dev PR-scoped. This script, the `DotnsPopControllerOld` snapshot it references, and the paired -/// `test/fork/UpgradePopController.t.sol` are deleted before merge per the upgrade-PR workflow -/// in CONTRIBUTING.md. The storage-layout reference is always supplied, so the layout diff is -/// mandatory: there is no environment switch that turns it off, and the run fails closed if a -/// slot moves, shrinks, or changes type. +/// the diff and every unsafe-pattern check pass. +/// @dev The swap carries no behavioural change of its own: `version()` stops returning a hardcoded +/// string and reads the protocol registry's declaration instead. +/// +/// The snapshot is the implementation deployed on chain, not the previous release: these +/// proxies were upgraded in place after their last release, so a snapshot taken from a tag +/// would describe code that has not run on this network for months. +/// `scripts/shell/verify-snapshots.sh` is what holds that property, by building the snapshot +/// and comparing it against the chain. The layout diff cannot: it compares whatever pair it +/// is given, and is blind to a change that lives in calldata. /// @custom:security-contact admin@parity.io contract UpgradePopController is BaseDeployer { /// @notice Pre-upgrade snapshot the layout diff compares the new implementation against. /// @dev Source-file route, not a stored build-info directory, so the snapshot is versioned - /// beside the implementation and rebuilt by `forge build`. Deleted before merge. + /// beside the implementation and rebuilt by `forge build`. string internal constant REFERENCE_CONTRACT = "DotnsPopControllerOld.sol:DotnsPopControllerOld"; - /// @notice Manifest label the PoP controller proxy is recorded under. + /// @notice Manifest label the DotnsPopController proxy is recorded under. string internal constant POP_CONTROLLER_LABEL = "DotnsPopController"; - /// @notice Reads the manifest, resolves the PoP controller proxy, and upgrades it as - /// `msg.sender`. + /// @notice Reads the manifest, resolves the DotnsPopController proxy, and upgrades it as + /// `msg.sender`. /// @dev `msg.sender` must own the proxy, otherwise the `_authorizeUpgrade` owner gate reverts. function run() external { address owner = msg.sender; @@ -48,11 +52,11 @@ contract UpgradePopController is BaseDeployer { /// @notice Upgrades `proxy` to the current `DotnsPopController` implementation under `owner`. /// @dev The layout diff runs inside `Upgrades.upgradeProxy` before the implementation swap. No /// `unsafeSkipAllChecks` or `unsafeAllow` override is set, so an incompatible layout - /// aborts the run rather than corrupting state. An empty upgrade call is passed because the new - /// implementation seeds no storage: `_popIssued` defaults to false for every label, which - /// is the correct provenance for names issued before the upgrade. + /// aborts the run rather than corrupting state. An empty upgrade call is passed because + /// the new implementation seeds no storage of its own; where a release declares anything + /// on chain, `DeclareRelease.s.sol` does it once, after every swap has verified. /// @param owner Account that owns the proxy and broadcasts the upgrade. - /// @param proxy PoP controller proxy address resolved from the manifest. + /// @param proxy DotnsPopController proxy address resolved from the manifest. function _upgradePopController(address owner, address proxy) internal { require( owner == OwnableUpgradeable(proxy).owner(), diff --git a/scripts/deploy/UpgradePopResolver.s.sol b/scripts/deploy/UpgradePopResolver.s.sol new file mode 100644 index 000000000..0c721a439 --- /dev/null +++ b/scripts/deploy/UpgradePopResolver.s.sol @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.34; + +import {console} from "forge-std/Script.sol"; +import {Options} from "openzeppelin-foundry-upgrades/Options.sol"; +import {Upgrades} from "openzeppelin-foundry-upgrades/Upgrades.sol"; + +import {BaseDeployer} from "./BaseDeployer.s.sol"; +import { + OwnableUpgradeable +} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; + +/// @title UpgradePopResolver +/// @notice Upgrades the deployed DotnsPopResolver proxy to the current implementation. Resolves the +/// proxy from the on-disk manifest, diffs the new storage layout against the pinned +/// @custom:contract DotnsPopResolverOld snapshot, and swaps the implementation only when +/// the diff and every unsafe-pattern check pass. +/// @dev The swap carries no behavioural change of its own: `version()` stops returning a hardcoded +/// string and reads the protocol registry's declaration instead. +/// +/// The snapshot is the implementation deployed on chain, not the previous release: these +/// proxies were upgraded in place after their last release, so a snapshot taken from a tag +/// would describe code that has not run on this network for months. +/// `scripts/shell/verify-snapshots.sh` is what holds that property, by building the snapshot +/// and comparing it against the chain. The layout diff cannot: it compares whatever pair it +/// is given, and is blind to a change that lives in calldata. +/// @custom:security-contact admin@parity.io +contract UpgradePopResolver is BaseDeployer { + /// @notice Pre-upgrade snapshot the layout diff compares the new implementation against. + /// @dev Source-file route, not a stored build-info directory, so the snapshot is versioned + /// beside the implementation and rebuilt by `forge build`. + string internal constant REFERENCE_CONTRACT = "DotnsPopResolverOld.sol:DotnsPopResolverOld"; + + /// @notice Manifest label the DotnsPopResolver proxy is recorded under. + string internal constant POP_RESOLVER_LABEL = "DotnsPopResolver"; + + /// @notice Reads the manifest, resolves the DotnsPopResolver proxy, and upgrades it as + /// `msg.sender`. + /// @dev `msg.sender` must own the proxy, otherwise the `_authorizeUpgrade` owner gate reverts. + function run() external { + address owner = msg.sender; + vm.label(owner, "OWNER"); + + initDeployment(networkFolder(), vm.toString(block.chainid)); + + address proxy = _readAddress(POP_RESOLVER_LABEL); + _upgradePopResolver(owner, proxy); + + console.log("=== UpgradePopResolver complete ==="); + } + + /// @notice Upgrades `proxy` to the current `DotnsPopResolver` implementation under `owner`. + /// @dev The layout diff runs inside `Upgrades.upgradeProxy` before the implementation swap. No + /// `unsafeSkipAllChecks` or `unsafeAllow` override is set, so an incompatible layout + /// aborts the run rather than corrupting state. An empty upgrade call is passed because + /// the new implementation seeds no storage of its own; where a release declares anything + /// on chain, `DeclareRelease.s.sol` does it once, after every swap has verified. + /// @param owner Account that owns the proxy and broadcasts the upgrade. + /// @param proxy DotnsPopResolver proxy address resolved from the manifest. + function _upgradePopResolver(address owner, address proxy) internal { + require( + owner == OwnableUpgradeable(proxy).owner(), + "UpgradePopResolver: broadcaster is not the proxy owner" + ); + + Options memory opts; + opts.referenceContract = REFERENCE_CONTRACT; + + vm.startBroadcast(owner); + Upgrades.upgradeProxy(proxy, "DotnsPopResolver.sol:DotnsPopResolver", "", opts); + vm.stopBroadcast(); + + console.log(" upgraded DotnsPopResolver proxy", proxy); + } +} diff --git a/scripts/deploy/UpgradePopRules.s.sol b/scripts/deploy/UpgradePopRules.s.sol index da5f4a7e7..a03cc2d20 100644 --- a/scripts/deploy/UpgradePopRules.s.sol +++ b/scripts/deploy/UpgradePopRules.s.sol @@ -11,20 +11,24 @@ import { } from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; /// @title UpgradePopRules -/// @notice Upgrades the deployed PopRules proxy to the current implementation. Resolves the proxy -/// from the on-disk manifest, diffs the new storage layout against the pinned -/// @custom:contract PopRulesOld snapshot, and swaps the implementation only when the diff -/// and every unsafe-pattern check pass. -/// @dev PR-scoped. This script, the `PopRulesOld` snapshot it references, and the paired -/// `test/fork/UpgradePopRules.t.sol` are deleted before merge per the upgrade-PR workflow in -/// CONTRIBUTING.md. The storage-layout reference is always supplied, so the layout diff is -/// mandatory: there is no environment switch that turns it off, and the run fails closed if a -/// slot moves, shrinks, or changes type. +/// @notice Upgrades the deployed PopRules proxy to the current implementation. Resolves the +/// proxy from the on-disk manifest, diffs the new storage layout against the pinned +/// @custom:contract PopRulesOld snapshot, and swaps the implementation only when the +/// diff and every unsafe-pattern check pass. +/// @dev The swap carries no behavioural change of its own: `version()` stops returning a hardcoded +/// string and reads the protocol registry's declaration instead. +/// +/// The snapshot is the implementation deployed on chain, not the previous release: these +/// proxies were upgraded in place after their last release, so a snapshot taken from a tag +/// would describe code that has not run on this network for months. +/// `scripts/shell/verify-snapshots.sh` is what holds that property, by building the snapshot +/// and comparing it against the chain. The layout diff cannot: it compares whatever pair it +/// is given, and is blind to a change that lives in calldata. /// @custom:security-contact admin@parity.io contract UpgradePopRules is BaseDeployer { /// @notice Pre-upgrade snapshot the layout diff compares the new implementation against. /// @dev Source-file route, not a stored build-info directory, so the snapshot is versioned - /// beside the implementation and rebuilt by `forge build`. Deleted before merge. + /// beside the implementation and rebuilt by `forge build`. string internal constant REFERENCE_CONTRACT = "PopRulesOld.sol:PopRulesOld"; /// @notice Manifest label the PopRules proxy is recorded under. @@ -47,9 +51,9 @@ contract UpgradePopRules is BaseDeployer { /// @notice Upgrades `proxy` to the current `PopRules` implementation under `owner`. /// @dev The layout diff runs inside `Upgrades.upgradeProxy` before the implementation swap. No /// `unsafeSkipAllChecks` or `unsafeAllow` override is set, so an incompatible layout - /// aborts the run rather than corrupting state. An empty upgrade call is passed because the new - /// implementation adds no storage that needs seeding: the classification and pricing - /// change is logic-only, and `shortNamesEnabled` keeps whatever value the live proxy holds. + /// aborts the run rather than corrupting state. An empty upgrade call is passed because + /// the new implementation seeds no storage of its own; where a release declares anything + /// on chain, `DeclareRelease.s.sol` does it once, after every swap has verified. /// @param owner Account that owns the proxy and broadcasts the upgrade. /// @param proxy PopRules proxy address resolved from the manifest. function _upgradePopRules(address owner, address proxy) internal { diff --git a/scripts/deploy/UpgradeProtocolRegistry.s.sol b/scripts/deploy/UpgradeProtocolRegistry.s.sol new file mode 100644 index 000000000..dada4cd0a --- /dev/null +++ b/scripts/deploy/UpgradeProtocolRegistry.s.sol @@ -0,0 +1,77 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.34; + +import {console} from "forge-std/Script.sol"; +import {Options} from "openzeppelin-foundry-upgrades/Options.sol"; +import {Upgrades} from "openzeppelin-foundry-upgrades/Upgrades.sol"; + +import {BaseDeployer} from "./BaseDeployer.s.sol"; +import { + OwnableUpgradeable +} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; + +/// @title UpgradeProtocolRegistry +/// @notice Upgrades the deployed DotnsProtocolRegistry proxy to the current implementation. +/// Resolves the proxy from the on-disk manifest, diffs the new storage layout against the pinned +/// @custom:contract DotnsProtocolRegistryOld snapshot, and swaps the implementation only +/// when the diff and every unsafe-pattern check pass. +/// @dev The swap adds the on-chain declarations: `protocolVersion`, `expectedCodehash` and their +/// owner-only setters, which every other contract's `version()` then reads through. +/// +/// The snapshot is the implementation deployed on chain, not the previous release: these +/// proxies were upgraded in place after their last release, so a snapshot taken from a tag +/// would describe code that has not run on this network for months. +/// `scripts/shell/verify-snapshots.sh` is what holds that property, by building the snapshot +/// and comparing it against the chain. The layout diff cannot: it compares whatever pair it +/// is given, and is blind to a change that lives in calldata. +/// @custom:security-contact admin@parity.io +contract UpgradeProtocolRegistry is BaseDeployer { + /// @notice Pre-upgrade snapshot the layout diff compares the new implementation against. + /// @dev Source-file route, not a stored build-info directory, so the snapshot is versioned + /// beside the implementation and rebuilt by `forge build`. + string internal constant REFERENCE_CONTRACT = + "DotnsProtocolRegistryOld.sol:DotnsProtocolRegistryOld"; + + /// @notice Manifest label the DotnsProtocolRegistry proxy is recorded under. + string internal constant PROTOCOL_REGISTRY_LABEL = "DotnsProtocolRegistry"; + + /// @notice Reads the manifest, resolves the DotnsProtocolRegistry proxy, and upgrades it as + /// `msg.sender`. + /// @dev `msg.sender` must own the proxy, otherwise the `_authorizeUpgrade` owner gate reverts. + function run() external { + address owner = msg.sender; + vm.label(owner, "OWNER"); + + initDeployment(networkFolder(), vm.toString(block.chainid)); + + address proxy = _readAddress(PROTOCOL_REGISTRY_LABEL); + _upgradeProtocolRegistry(owner, proxy); + + console.log("=== UpgradeProtocolRegistry complete ==="); + } + + /// @notice Upgrades `proxy` to the current `DotnsProtocolRegistry` implementation under + /// `owner`. + /// @dev The layout diff runs inside `Upgrades.upgradeProxy` before the implementation swap. No + /// `unsafeSkipAllChecks` or `unsafeAllow` override is set, so an incompatible layout + /// aborts the run rather than corrupting state. An empty upgrade call is passed because + /// the new implementation seeds no storage of its own; where a release declares anything + /// on chain, `DeclareRelease.s.sol` does it once, after every swap has verified. + /// @param owner Account that owns the proxy and broadcasts the upgrade. + /// @param proxy DotnsProtocolRegistry proxy address resolved from the manifest. + function _upgradeProtocolRegistry(address owner, address proxy) internal { + require( + owner == OwnableUpgradeable(proxy).owner(), + "UpgradeProtocolRegistry: broadcaster is not the proxy owner" + ); + + Options memory opts; + opts.referenceContract = REFERENCE_CONTRACT; + + vm.startBroadcast(owner); + Upgrades.upgradeProxy(proxy, "DotnsProtocolRegistry.sol:DotnsProtocolRegistry", "", opts); + vm.stopBroadcast(); + + console.log(" upgraded DotnsProtocolRegistry proxy", proxy); + } +} diff --git a/scripts/deploy/UpgradeRegistrar.s.sol b/scripts/deploy/UpgradeRegistrar.s.sol new file mode 100644 index 000000000..5742f0f69 --- /dev/null +++ b/scripts/deploy/UpgradeRegistrar.s.sol @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.34; + +import {console} from "forge-std/Script.sol"; +import {Options} from "openzeppelin-foundry-upgrades/Options.sol"; +import {Upgrades} from "openzeppelin-foundry-upgrades/Upgrades.sol"; + +import {BaseDeployer} from "./BaseDeployer.s.sol"; +import { + OwnableUpgradeable +} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; + +/// @title UpgradeRegistrar +/// @notice Upgrades the deployed DotnsRegistrar proxy to the current implementation. Resolves the +/// proxy from the on-disk manifest, diffs the new storage layout against the pinned +/// @custom:contract DotnsRegistrarOld snapshot, and swaps the implementation only when the +/// diff and every unsafe-pattern check pass. +/// @dev The swap rejects an unsolicited transfer into the escrow, and moves the label write to +/// `StoreUtils.writeNewLabel`. +/// +/// The snapshot is the implementation deployed on chain, not the previous release: these +/// proxies were upgraded in place after their last release, so a snapshot taken from a tag +/// would describe code that has not run on this network for months. +/// `scripts/shell/verify-snapshots.sh` is what holds that property, by building the snapshot +/// and comparing it against the chain. The layout diff cannot: it compares whatever pair it +/// is given, and is blind to a change that lives in calldata. +/// @custom:security-contact admin@parity.io +contract UpgradeRegistrar is BaseDeployer { + /// @notice Pre-upgrade snapshot the layout diff compares the new implementation against. + /// @dev Source-file route, not a stored build-info directory, so the snapshot is versioned + /// beside the implementation and rebuilt by `forge build`. + string internal constant REFERENCE_CONTRACT = "DotnsRegistrarOld.sol:DotnsRegistrarOld"; + + /// @notice Manifest label the DotnsRegistrar proxy is recorded under. + string internal constant REGISTRAR_LABEL = "DotnsRegistrar"; + + /// @notice Reads the manifest, resolves the DotnsRegistrar proxy, and upgrades it as + /// `msg.sender`. + /// @dev `msg.sender` must own the proxy, otherwise the `_authorizeUpgrade` owner gate reverts. + function run() external { + address owner = msg.sender; + vm.label(owner, "OWNER"); + + initDeployment(networkFolder(), vm.toString(block.chainid)); + + address proxy = _readAddress(REGISTRAR_LABEL); + _upgradeRegistrar(owner, proxy); + + console.log("=== UpgradeRegistrar complete ==="); + } + + /// @notice Upgrades `proxy` to the current `DotnsRegistrar` implementation under `owner`. + /// @dev The layout diff runs inside `Upgrades.upgradeProxy` before the implementation swap. No + /// `unsafeSkipAllChecks` or `unsafeAllow` override is set, so an incompatible layout + /// aborts the run rather than corrupting state. An empty upgrade call is passed because + /// the new implementation seeds no storage of its own; where a release declares anything + /// on chain, `DeclareRelease.s.sol` does it once, after every swap has verified. + /// @param owner Account that owns the proxy and broadcasts the upgrade. + /// @param proxy DotnsRegistrar proxy address resolved from the manifest. + function _upgradeRegistrar(address owner, address proxy) internal { + require( + owner == OwnableUpgradeable(proxy).owner(), + "UpgradeRegistrar: broadcaster is not the proxy owner" + ); + + Options memory opts; + opts.referenceContract = REFERENCE_CONTRACT; + + vm.startBroadcast(owner); + Upgrades.upgradeProxy(proxy, "DotnsRegistrar.sol:DotnsRegistrar", "", opts); + vm.stopBroadcast(); + + console.log(" upgraded DotnsRegistrar proxy", proxy); + } +} diff --git a/scripts/deploy/UpgradeRegistrarController.s.sol b/scripts/deploy/UpgradeRegistrarController.s.sol new file mode 100644 index 000000000..42c2aaf34 --- /dev/null +++ b/scripts/deploy/UpgradeRegistrarController.s.sol @@ -0,0 +1,78 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.34; + +import {console} from "forge-std/Script.sol"; +import {Options} from "openzeppelin-foundry-upgrades/Options.sol"; +import {Upgrades} from "openzeppelin-foundry-upgrades/Upgrades.sol"; + +import {BaseDeployer} from "./BaseDeployer.s.sol"; +import { + OwnableUpgradeable +} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; + +/// @title UpgradeRegistrarController +/// @notice Upgrades the deployed DotnsRegistrarController proxy to the current implementation. +/// Resolves the proxy from the on-disk manifest, diffs the new storage layout against the pinned +/// @custom:contract DotnsRegistrarControllerOld snapshot, and swaps the implementation only +/// when the diff and every unsafe-pattern check pass. +/// @dev The swap moves the label write to `StoreUtils.writeNewLabel`. +/// +/// The snapshot is the implementation deployed on chain, not the previous release: these +/// proxies were upgraded in place after their last release, so a snapshot taken from a tag +/// would describe code that has not run on this network for months. +/// `scripts/shell/verify-snapshots.sh` is what holds that property, by building the snapshot +/// and comparing it against the chain. The layout diff cannot: it compares whatever pair it +/// is given, and is blind to a change that lives in calldata. +/// @custom:security-contact admin@parity.io +contract UpgradeRegistrarController is BaseDeployer { + /// @notice Pre-upgrade snapshot the layout diff compares the new implementation against. + /// @dev Source-file route, not a stored build-info directory, so the snapshot is versioned + /// beside the implementation and rebuilt by `forge build`. + string internal constant REFERENCE_CONTRACT = + "DotnsRegistrarControllerOld.sol:DotnsRegistrarControllerOld"; + + /// @notice Manifest label the DotnsRegistrarController proxy is recorded under. + string internal constant REGISTRAR_CONTROLLER_LABEL = "DotnsRegistrarController"; + + /// @notice Reads the manifest, resolves the DotnsRegistrarController proxy, and upgrades it as + /// `msg.sender`. + /// @dev `msg.sender` must own the proxy, otherwise the `_authorizeUpgrade` owner gate reverts. + function run() external { + address owner = msg.sender; + vm.label(owner, "OWNER"); + + initDeployment(networkFolder(), vm.toString(block.chainid)); + + address proxy = _readAddress(REGISTRAR_CONTROLLER_LABEL); + _upgradeRegistrarController(owner, proxy); + + console.log("=== UpgradeRegistrarController complete ==="); + } + + /// @notice Upgrades `proxy` to the current `DotnsRegistrarController` implementation under + /// `owner`. + /// @dev The layout diff runs inside `Upgrades.upgradeProxy` before the implementation swap. No + /// `unsafeSkipAllChecks` or `unsafeAllow` override is set, so an incompatible layout + /// aborts the run rather than corrupting state. An empty upgrade call is passed because + /// the new implementation seeds no storage of its own; where a release declares anything + /// on chain, `DeclareRelease.s.sol` does it once, after every swap has verified. + /// @param owner Account that owns the proxy and broadcasts the upgrade. + /// @param proxy DotnsRegistrarController proxy address resolved from the manifest. + function _upgradeRegistrarController(address owner, address proxy) internal { + require( + owner == OwnableUpgradeable(proxy).owner(), + "UpgradeRegistrarController: broadcaster is not the proxy owner" + ); + + Options memory opts; + opts.referenceContract = REFERENCE_CONTRACT; + + vm.startBroadcast(owner); + Upgrades.upgradeProxy( + proxy, "DotnsRegistrarController.sol:DotnsRegistrarController", "", opts + ); + vm.stopBroadcast(); + + console.log(" upgraded DotnsRegistrarController proxy", proxy); + } +} diff --git a/scripts/deploy/UpgradeRegistry.s.sol b/scripts/deploy/UpgradeRegistry.s.sol index 896e75333..0daebcf87 100644 --- a/scripts/deploy/UpgradeRegistry.s.sol +++ b/scripts/deploy/UpgradeRegistry.s.sol @@ -15,22 +15,28 @@ import { /// proxy from the on-disk manifest, diffs the new storage layout against the pinned /// @custom:contract DotnsRegistryOld snapshot, and swaps the implementation only when the /// diff and every unsafe-pattern check pass. -/// @dev PR-scoped. This script, the `DotnsRegistryOld` snapshot it references, and the paired -/// `test/fork/UpgradeRegistry.t.sol` are deleted before merge per the upgrade-PR workflow in -/// CONTRIBUTING.md. The storage-layout reference is always supplied, so the layout diff is -/// mandatory: there is no environment switch that turns it off, and the run fails closed if a -/// slot moves, shrinks, or changes type. +/// @dev The swap adds the controller gate on a deferred store write (`persist` false), and moves +/// the store write to `StoreUtils.writeNewLabel`, which refuses to overwrite a locked label with a +/// different one instead of silently skipping it. +/// +/// The snapshot is the implementation deployed on chain, not the previous release: these +/// proxies were upgraded in place after their last release, so a snapshot taken from a tag +/// would describe code that has not run on this network for months. +/// `scripts/shell/verify-snapshots.sh` is what holds that property, by building the snapshot +/// and comparing it against the chain. The layout diff cannot: it compares whatever pair it +/// is given, and is blind to a change that lives in calldata. /// @custom:security-contact admin@parity.io contract UpgradeRegistry is BaseDeployer { /// @notice Pre-upgrade snapshot the layout diff compares the new implementation against. /// @dev Source-file route, not a stored build-info directory, so the snapshot is versioned - /// beside the implementation and rebuilt by `forge build`. Deleted before merge. + /// beside the implementation and rebuilt by `forge build`. string internal constant REFERENCE_CONTRACT = "DotnsRegistryOld.sol:DotnsRegistryOld"; - /// @notice Manifest label the registry proxy is recorded under. + /// @notice Manifest label the DotnsRegistry proxy is recorded under. string internal constant REGISTRY_LABEL = "DotnsRegistry"; - /// @notice Reads the manifest, resolves the registry proxy, and upgrades it as `msg.sender`. + /// @notice Reads the manifest, resolves the DotnsRegistry proxy, and upgrades it as + /// `msg.sender`. /// @dev `msg.sender` must own the proxy, otherwise the `_authorizeUpgrade` owner gate reverts. function run() external { address owner = msg.sender; @@ -47,12 +53,11 @@ contract UpgradeRegistry is BaseDeployer { /// @notice Upgrades `proxy` to the current `DotnsRegistry` implementation under `owner`. /// @dev The layout diff runs inside `Upgrades.upgradeProxy` before the implementation swap. No /// `unsafeSkipAllChecks` or `unsafeAllow` override is set, so an incompatible layout - /// aborts the run rather than corrupting state. An empty upgrade call is passed because the new - /// implementation seeds no storage: the `persist` field it adds lives on the calldata - /// `SubnodeRecord`, not in a storage slot, so every existing record and gap slot is - /// preserved unchanged. + /// aborts the run rather than corrupting state. An empty upgrade call is passed because + /// the new implementation seeds no storage of its own; where a release declares anything + /// on chain, `DeclareRelease.s.sol` does it once, after every swap has verified. /// @param owner Account that owns the proxy and broadcasts the upgrade. - /// @param proxy Registry proxy address resolved from the manifest. + /// @param proxy DotnsRegistry proxy address resolved from the manifest. function _upgradeRegistry(address owner, address proxy) internal { require( owner == OwnableUpgradeable(proxy).owner(), diff --git a/scripts/deploy/UpgradeResolver.s.sol b/scripts/deploy/UpgradeResolver.s.sol new file mode 100644 index 000000000..a14907dc0 --- /dev/null +++ b/scripts/deploy/UpgradeResolver.s.sol @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.34; + +import {console} from "forge-std/Script.sol"; +import {Options} from "openzeppelin-foundry-upgrades/Options.sol"; +import {Upgrades} from "openzeppelin-foundry-upgrades/Upgrades.sol"; + +import {BaseDeployer} from "./BaseDeployer.s.sol"; +import { + OwnableUpgradeable +} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; + +/// @title UpgradeResolver +/// @notice Upgrades the deployed DotnsResolver proxy to the current implementation. Resolves the +/// proxy from the on-disk manifest, diffs the new storage layout against the pinned +/// @custom:contract DotnsResolverOld snapshot, and swaps the implementation only when the +/// diff and every unsafe-pattern check pass. +/// @dev The swap carries no behavioural change of its own: `version()` stops returning a hardcoded +/// string and reads the protocol registry's declaration instead. +/// +/// The snapshot is the implementation deployed on chain, not the previous release: these +/// proxies were upgraded in place after their last release, so a snapshot taken from a tag +/// would describe code that has not run on this network for months. +/// `scripts/shell/verify-snapshots.sh` is what holds that property, by building the snapshot +/// and comparing it against the chain. The layout diff cannot: it compares whatever pair it +/// is given, and is blind to a change that lives in calldata. +/// @custom:security-contact admin@parity.io +contract UpgradeResolver is BaseDeployer { + /// @notice Pre-upgrade snapshot the layout diff compares the new implementation against. + /// @dev Source-file route, not a stored build-info directory, so the snapshot is versioned + /// beside the implementation and rebuilt by `forge build`. + string internal constant REFERENCE_CONTRACT = "DotnsResolverOld.sol:DotnsResolverOld"; + + /// @notice Manifest label the DotnsResolver proxy is recorded under. + string internal constant RESOLVER_LABEL = "DotnsResolver"; + + /// @notice Reads the manifest, resolves the DotnsResolver proxy, and upgrades it as + /// `msg.sender`. + /// @dev `msg.sender` must own the proxy, otherwise the `_authorizeUpgrade` owner gate reverts. + function run() external { + address owner = msg.sender; + vm.label(owner, "OWNER"); + + initDeployment(networkFolder(), vm.toString(block.chainid)); + + address proxy = _readAddress(RESOLVER_LABEL); + _upgradeResolver(owner, proxy); + + console.log("=== UpgradeResolver complete ==="); + } + + /// @notice Upgrades `proxy` to the current `DotnsResolver` implementation under `owner`. + /// @dev The layout diff runs inside `Upgrades.upgradeProxy` before the implementation swap. No + /// `unsafeSkipAllChecks` or `unsafeAllow` override is set, so an incompatible layout + /// aborts the run rather than corrupting state. An empty upgrade call is passed because + /// the new implementation seeds no storage of its own; where a release declares anything + /// on chain, `DeclareRelease.s.sol` does it once, after every swap has verified. + /// @param owner Account that owns the proxy and broadcasts the upgrade. + /// @param proxy DotnsResolver proxy address resolved from the manifest. + function _upgradeResolver(address owner, address proxy) internal { + require( + owner == OwnableUpgradeable(proxy).owner(), + "UpgradeResolver: broadcaster is not the proxy owner" + ); + + Options memory opts; + opts.referenceContract = REFERENCE_CONTRACT; + + vm.startBroadcast(owner); + Upgrades.upgradeProxy(proxy, "DotnsResolver.sol:DotnsResolver", "", opts); + vm.stopBroadcast(); + + console.log(" upgraded DotnsResolver proxy", proxy); + } +} diff --git a/scripts/deploy/UpgradeReverseResolver.s.sol b/scripts/deploy/UpgradeReverseResolver.s.sol index a59afc47e..1edcd7e8c 100644 --- a/scripts/deploy/UpgradeReverseResolver.s.sol +++ b/scripts/deploy/UpgradeReverseResolver.s.sol @@ -11,28 +11,32 @@ import { } from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; /// @title UpgradeReverseResolver -/// @notice Upgrades the deployed DotnsReverseResolver proxy to the current implementation. It -/// resolves the proxy from the on-disk manifest, diffs the new storage layout against the -/// pinned @custom:contract DotnsReverseResolverOld snapshot, and swaps the implementation -/// only when the diff and every unsafe-pattern check pass. -/// @dev PR-scoped. This script, the `DotnsReverseResolverOld` snapshot, and the paired fork test -/// `test/fork/UpgradeReverseResolver.t.sol` are deleted before merge per the upgrade-PR -/// workflow in CONTRIBUTING.md. The storage-layout reference is always supplied, so the diff -/// is mandatory: there is no switch that turns it off, and the run fails closed if a slot -/// moves, shrinks, or changes type. +/// @notice Upgrades the deployed DotnsReverseResolver proxy to the current implementation. Resolves +/// the proxy from the on-disk manifest, diffs the new storage layout against the pinned +/// @custom:contract DotnsReverseResolverOld snapshot, and swaps the implementation only +/// when the diff and every unsafe-pattern check pass. +/// @dev The swap carries no behavioural change of its own: `version()` stops returning a hardcoded +/// string and reads the protocol registry's declaration instead. +/// +/// The snapshot is the implementation deployed on chain, not the previous release: these +/// proxies were upgraded in place after their last release, so a snapshot taken from a tag +/// would describe code that has not run on this network for months. +/// `scripts/shell/verify-snapshots.sh` is what holds that property, by building the snapshot +/// and comparing it against the chain. The layout diff cannot: it compares whatever pair it +/// is given, and is blind to a change that lives in calldata. /// @custom:security-contact admin@parity.io contract UpgradeReverseResolver is BaseDeployer { /// @notice Pre-upgrade snapshot the layout diff compares the new implementation against. /// @dev Source-file route, not a stored build-info directory, so the snapshot is versioned - /// beside the implementation and rebuilt by `forge build`. Deleted before merge. + /// beside the implementation and rebuilt by `forge build`. string internal constant REFERENCE_CONTRACT = "DotnsReverseResolverOld.sol:DotnsReverseResolverOld"; - /// @notice Manifest label the reverse resolver proxy is recorded under. + /// @notice Manifest label the DotnsReverseResolver proxy is recorded under. string internal constant REVERSE_RESOLVER_LABEL = "DotnsReverseResolver"; - /// @notice Reads the manifest, resolves the reverse resolver proxy, and upgrades it as - /// `msg.sender`. + /// @notice Reads the manifest, resolves the DotnsReverseResolver proxy, and upgrades it as + /// `msg.sender`. /// @dev `msg.sender` must own the proxy, otherwise the `_authorizeUpgrade` owner gate reverts. function run() external { address owner = msg.sender; @@ -49,12 +53,11 @@ contract UpgradeReverseResolver is BaseDeployer { /// @notice Upgrades `proxy` to the current `DotnsReverseResolver` implementation under `owner`. /// @dev The layout diff runs inside `Upgrades.upgradeProxy` before the implementation swap. No /// `unsafeSkipAllChecks` or `unsafeAllow` override is set, so an incompatible layout - /// aborts the run rather than corrupting state. An empty upgrade call is passed because the new - /// implementation seeds no storage: it reads a lite name's owner through the registry at - /// the hierarchical node and adds no storage slot, so every existing reverse record is - /// preserved unchanged. + /// aborts the run rather than corrupting state. An empty upgrade call is passed because + /// the new implementation seeds no storage of its own; where a release declares anything + /// on chain, `DeclareRelease.s.sol` does it once, after every swap has verified. /// @param owner Account that owns the proxy and broadcasts the upgrade. - /// @param proxy Reverse resolver proxy address resolved from the manifest. + /// @param proxy DotnsReverseResolver proxy address resolved from the manifest. function _upgradeReverseResolver(address owner, address proxy) internal { require( owner == OwnableUpgradeable(proxy).owner(), diff --git a/scripts/shell/store-holders.sh b/scripts/shell/store-holders.sh new file mode 100755 index 000000000..9b3cf1b42 --- /dev/null +++ b/scripts/shell/store-holders.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Prints every address holding a `LabelStore` on a deployed factory, as the comma-separated +# list `MigrateStoreFactory.s.sol` expects in `DOTNS_STORE_USERS`. +# +# The list has to be built off chain. The deployed factory keeps a `user => store` mapping and +# an append-only list of store addresses, and neither can be walked: the mapping has no +# enumeration, and the list holds stores, which do not know which user they belong to. What does +# carry the pairing is `LabelStoreDeployed(address indexed user, address indexed store)`, emitted +# on every deployment, so the holders are recovered by replaying that log. +# +# Read it immediately before broadcasting the migration, never from an earlier run. The factory +# is live and the count moves: it went from 57 to 58 during a single afternoon of preparing this +# upgrade. `importStores` asserts the list against the factory's own count and reverts on a short +# one, so a stale list fails the migration rather than silently omitting users, but re-reading is +# what avoids the wasted broadcast. +# +# Point RPC_URL at an archive node or an indexer, not at the public gateway. As of September 2026 +# `https://eth-rpc-paseo-next.polkadot.io` answers `eth_getLogs` with an empty result for every +# range rather than an error, so a replay against it recovers nothing while looking like it +# worked. That is the failure the count check below exists to turn into a stop, and it is why +# this script refuses to print a list it cannot reconcile. Blockscout at +# `https://blockscout-paseo-next.polkadot.io` indexes the same chain and serves the logs. +# +# Usage: +# RPC_URL=... scripts/shell/store-holders.sh 0x709A027F446a9e2a4BB9cb9a9c754435b19e32B7 +# DOTNS_STORE_USERS="$(RPC_URL=... scripts/shell/store-holders.sh 0x...)" + +FACTORY="${1:?usage: store-holders.sh }" +RPC_URL="${RPC_URL:-http://127.0.0.1:8545}" +FROM_BLOCK="${FROM_BLOCK:-0}" + +# keccak("LabelStoreDeployed(address,address)") +TOPIC=0x6294914f6f12fb260c6b69d8a5435317a9318b45790f0b19b42cdd06708fcdea + +logs="$(curl -sf -X POST -H 'Content-Type: application/json' --data "$(cat < Date: Fri, 18 Sep 2026 15:03:56 +0200 Subject: [PATCH 09/25] Close the review findings on the store-factory migration Four things an AI review caught, the first of which was mine and the worst. The migration was built on a false premise. I claimed the deployed factory had no enumeration and that stores did not know their user, and wrote a log-replay helper, a count-reconciliation guard and two fork tests around that. Both claims are false: `getLabelStores(0, n)` answers on the live factory, and `IDotnsStore.owner()` on a live store round-trips back through `getLabelStore`. `importStores` now takes only the old factory and reads the count, the list and each store's owner on chain, checking every pairing back through the mapping before it writes. `store-holders.sh` is deleted. That also closes a race the old shape carried. The set was read off chain and passed in, so a store created between the read and the import was silently left unbound, and its holder would be handed an empty store on their next registration. The count on this network moved while this branch was being written. Reading inside the transaction removes the window, and a test covers a binding created after the decision to migrate. The script could not have been broadcast. It read the destination from the manifest key `StoreFactory`, which still names the factory being migrated from, and nothing deployed the replacement: the CREATE3 address for it is empty on chain. Meanwhile the fork test quietly deployed the proxy itself, so the two told different stories and the test passed. `MigrateStoreFactory` now deploys the replacement through the pipeline's own helper, which also adopts one a previous run left behind, and records it and its beacons in the manifest. The success condition was never asserted. After a real migration every mint resolves `STORE_FACTORY` through the protocol registry and calls `ensureLabelStore`; nothing exercised that path. The fork test now upgrades the protocol registry first, because the rewire declares a codehash that entrypoint does not have yet, runs all four legs, and asserts a live holder resolves to the store they already had with no extra store deployed. Two smaller ones. `verify-snapshots.sh` ran only from `fork-tests.sh`, and CI calls `forge test` directly, so the check this branch leans on was absent from the one place reviewers see; it now runs in the fork job. `UserStore` added `_protocolRegistry` without shrinking its gap, the same defect already fixed on the protocol registry; it is harmless at zero user stores and would not stay harmless through a beacon rotation. `docs/PASEO-V080-RUNBOOK.md` writes down the broadcast order. Nothing in the scripts enforces it and they are independently runnable by design, so the three dependencies that fix the order live there: registry first because every `version()` reads through it, the migration before the declaration because it rewires a key the declaration records, and the declaration last because it claims something about the whole deployment. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/push_checking.yml | 13 +- DEPLOYMENTS.md | 18 +- contracts/store/StoreFactoryMigrator.sol | 81 +++++--- contracts/store/UserStore.sol | 8 +- deployments/paseo-assethub/README.md | 9 +- docs/PASEO-V080-RUNBOOK.md | 106 ++++++++++ scripts/deploy/MigrateStoreFactory.s.sol | 193 +++++++++++------- scripts/shell/store-holders.sh | 83 -------- test/fork/MigrateStoreFactory.t.sol | 224 ++++++++++----------- test/unit/store/StoreFactoryMigrator.t.sol | 98 ++++----- 10 files changed, 477 insertions(+), 356 deletions(-) create mode 100644 docs/PASEO-V080-RUNBOOK.md delete mode 100755 scripts/shell/store-holders.sh diff --git a/.github/workflows/push_checking.yml b/.github/workflows/push_checking.yml index 196c83ef7..66cb26d6b 100644 --- a/.github/workflows/push_checking.yml +++ b/.github/workflows/push_checking.yml @@ -215,7 +215,18 @@ jobs: # A clean build-info keeps the OZ upgrade validator (vm.ffi) from reading # a partial JSON on a warm self-hosted runner. rm -rf out/build-info - forge test -vv --match-path 'test/fork/**' 2>&1 | tee fork.log + # The layout diff compares whatever pair it is handed, so it stays green when a + # snapshot has drifted away from the implementation actually deployed, and a + # calldata-only change leaves no trace in a layout at all. This is the only check + # that catches that, and it belongs here as well as in `fork-tests.sh`: CI runs + # `forge test` directly, so a local-only guard protects nobody reviewing a PR. + scripts/shell/verify-snapshots.sh 2>&1 | tee -a fork.log + SNAPSHOTS=${PIPESTATUS[0]} + if [ "$SNAPSHOTS" -ne 0 ]; then + echo "result=Failed (snapshots)" >> "$GITHUB_OUTPUT" + exit 1 + fi + forge test -vv --match-path 'test/fork/**' 2>&1 | tee -a fork.log FORK=${PIPESTATUS[0]} if [ "$FORK" -eq 0 ]; then echo "result=Passed" >> "$GITHUB_OUTPUT" diff --git a/DEPLOYMENTS.md b/DEPLOYMENTS.md index 602f5313a..27d9b3d00 100644 --- a/DEPLOYMENTS.md +++ b/DEPLOYMENTS.md @@ -496,9 +496,21 @@ environments, including the public Polkadot Hub TestNet gateway, which answers o no code at any of these addresses. Point the fork adapter at the wrong one and every address resolves and every call reverts for reasons that look like anything but the real cause. -The public gateway also answers `eth_getLogs` with an empty result for every range rather than an -error, so anything built from a log replay against it looks like it worked and is empty. Use an -archive node, or Blockscout at `https://blockscout-paseo-next.polkadot.io`. +The public gateway also answers `eth_getLogs` with an empty result for every range, not an error, +so anything derived from a log replay against it looks like it worked and is empty. Nothing in +this repository depends on that: the store migration reads its holders from the factory's own +`getLabelStores` and each store's `owner`. Worth knowing before reaching for logs to answer a +question about this network. Use an archive node, or Blockscout at +`https://blockscout-paseo-next.polkadot.io`. + +### Broadcast order + +The in-place upgrade to v0.8.0 has a fixed order, and nothing in the scripts enforces it: the +protocol registry has to go first because every other contract's `version()` reads through it, +the store migration after that because it rewires a key the declaration records, and the +declaration last because it is a claim about the whole deployment. +[`docs/PASEO-V080-RUNBOOK.md`](./docs/PASEO-V080-RUNBOOK.md) is the step list, with what to check +after each one. ### The deployed code is not always the code in a release diff --git a/contracts/store/StoreFactoryMigrator.sol b/contracts/store/StoreFactoryMigrator.sol index 3ba6771b7..313626260 100644 --- a/contracts/store/StoreFactoryMigrator.sol +++ b/contracts/store/StoreFactoryMigrator.sol @@ -71,12 +71,21 @@ contract StoreFactoryMigrator is Initializable, UUPSUpgradeable, OwnableUpgradea /// @param store The `LabelStore` now bound to `user` on this factory. event StoresImported(address indexed user, address indexed store); - /// @notice The supplied list does not have the length the old factory reports. - /// @dev Guards the one failure the loop cannot see: a list that silently omits users, which - /// would leave them unbound and hand them an empty store on their next registration. + /// @notice The old factory's store list does not have the length the factory reports. + /// @dev Reading the count and the list are two calls, so they can disagree: a truncated + /// enumeration would import a prefix and leave the rest unbound, and unbound users are + /// handed an empty store on their next registration rather than the one they have. /// @param expected Count the old factory reports. - /// @param supplied Length of the list passed in. - error ImportCountMismatch(uint256 expected, uint256 supplied); + /// @param actual Number of entries actually seen. + error ImportCountMismatch(uint256 expected, uint256 actual); + + /// @notice A store's owner is not the user the old factory has it bound to. + /// @dev The store list and the per-user mapping are separate state. Importing on the store's + /// word alone would let a store whose owner no longer matches the mapping bind a user + /// the old factory does not consider its holder. + /// @param user Owner the store reports. + /// @param store The store in the old factory's list. + error ImportBindingMismatch(address user, address store); /// @notice Restricts `deployLabelStoreFor` to the owner or a component named in /// @custom:function StoreAuth.isStoreWriter. @@ -241,45 +250,59 @@ contract StoreFactoryMigrator is Initializable, UUPSUpgradeable, OwnableUpgradea } /// @notice Copies the per-user `LabelStore` bindings of `oldFactory` into this factory. - /// @dev One-shot and owner-only. Each user is bound at most once, here as everywhere else: a - /// user who already holds a binding on this factory is rejected rather than repointed, - /// so a second run with an overlapping list fails loudly instead of rewriting history. - /// `users` is supplied by the caller rather than read from `oldFactory`, because the - /// deployed factory exposes no enumeration; the operator reads its store list out of - /// storage and passes it in, and the length check below is what catches a short list. + /// @dev Owner-only, and reads everything it needs from `oldFactory` itself: the count, the + /// store list, and each store's owner. Nothing is supplied by the caller, so there is no + /// window between an operator reading the set and this executing. That window is not + /// hypothetical: the count on the network being migrated moved while this was being + /// written, and a list captured a moment early imports every entry it holds, rewires, + /// and leaves the newest holder to be handed a second empty store on their next + /// registration. + /// + /// Each binding is checked back through `getLabelStore` before it is written. A store's + /// `owner` is the user it was deployed for, and the factory's mapping is the authority + /// on that pairing; requiring the two to agree rejects a store whose owner has been + /// changed out from under the mapping, which is the one shape that would bind a user to + /// a store the old factory does not consider theirs. + /// + /// Bindings are permanent here as everywhere else in the factory, so a user already + /// bound is rejected instead of repointed. That makes a second call over an overlapping + /// set fail loudly instead of rewriting history. /// /// `UserStore` bindings are deliberately not imported. They are claimed by users - /// themselves and the network being migrated from has none; a future migration that does - /// have them needs this function extended rather than reused. + /// themselves and the network being migrated from has none; a migration that does have + /// them needs this extended, not reused. /// @param oldFactory Factory whose bindings are being adopted. - /// @param users Every address holding a `LabelStore` on `oldFactory`, in any order. - /// @param expectedCount Number of bindings `oldFactory` reports, asserted against `users`. - function importStores( - address oldFactory, - address[] calldata users, - uint256 expectedCount - ) - external - onlyOwner - { + function importStores(address oldFactory) external onlyOwner { require(oldFactory != address(0), InvalidUser(oldFactory)); - require(users.length == expectedCount, ImportCountMismatch(expectedCount, users.length)); - for (uint256 i; i < users.length; ++i) { - address user = users[i]; + uint256 total = IStoreFactory(oldFactory).getLabelStoreCount(); + address[] memory stores = IStoreFactory(oldFactory).getLabelStores(0, total); + require(stores.length == total, ImportCountMismatch(total, stores.length)); + + for (uint256 i; i < total; ++i) { + address store = stores[i]; + require(store != address(0), InvalidImplementation(store)); + + address user = IDotnsStore(store).owner(); require(user != address(0), InvalidUser(user)); + require( + IStoreFactory(oldFactory).getLabelStore(user) == store, + ImportBindingMismatch(user, store) + ); address existing = _labelStores[user]; require(existing == address(0), AlreadyDeployed(user, existing)); - address store = IStoreFactory(oldFactory).getLabelStore(user); - require(store != address(0), InvalidUser(user)); - _labelStores[user] = store; _labelStoreList.push(store); emit StoresImported(user, store); } + + // Every binding the old factory reports is now held here. Asserted after the loop as + // well as before it, because the two counts are read from different places: a mismatch + // means a store appeared in the list twice, or the list disagreed with the mapping. + require(_labelStoreList.length == total, ImportCountMismatch(total, _labelStoreList.length)); } /// @notice Shared pagination helper used by `getLabelStores` and `getUserStores`. diff --git a/contracts/store/UserStore.sol b/contracts/store/UserStore.sol index 3a2c30010..fb9fbb227 100644 --- a/contracts/store/UserStore.sol +++ b/contracts/store/UserStore.sol @@ -41,8 +41,14 @@ contract UserStore is Initializable, IUserStore { address private _protocolRegistry; /// @dev Reserved storage space to allow for layout changes in future beacon upgrades. + /// `_protocolRegistry` consumes one of the reserved slots, so the gap holds 49 and the + /// contract keeps the footprint the stores behind the deployed beacon already use. + /// Sized this way rather than left at 50 because the gap would otherwise start one slot + /// later than on those stores, which makes a beacon rotation onto this implementation + /// put every later appended field on a slot they do not expect. `LabelStore` needs no + /// such change: it added no storage in this release. // forge-lint: disable-next-line(mixed-case-variable) - uint256[50] private __gap; + uint256[49] private __gap; /// @notice Restricts writes to the bound owner. modifier onlyOwner() { diff --git a/deployments/paseo-assethub/README.md b/deployments/paseo-assethub/README.md index 65c9d8417..2d126b16c 100644 --- a/deployments/paseo-assethub/README.md +++ b/deployments/paseo-assethub/README.md @@ -15,9 +15,12 @@ every address here and finds all of them empty, so calls revert for reasons that anything but the real cause. The fork tests assert code is present at each address they resolve, which turns that into a clear failure instead of a confusing one. -That gateway also answers `eth_getLogs` with an empty result for every range rather than an -error, so anything built from a log replay against it looks like it worked and is empty. Use an -archive node, or Blockscout at `https://blockscout-paseo-next.polkadot.io`. +That gateway also answers `eth_getLogs` with an empty result for every range, not an error, +so anything derived from a log replay against it looks like it worked and is empty. Nothing in +this repository depends on that: the store migration reads its holders from the factory's own +`getLabelStores` and each store's `owner`. Worth knowing before reaching for logs to answer a +question about this network. Use an archive node, or Blockscout at +`https://blockscout-paseo-next.polkadot.io`. ## What is deployed here is not a release diff --git a/docs/PASEO-V080-RUNBOOK.md b/docs/PASEO-V080-RUNBOOK.md new file mode 100644 index 000000000..d41e49f2c --- /dev/null +++ b/docs/PASEO-V080-RUNBOOK.md @@ -0,0 +1,106 @@ +# Paseo Asset Hub Next: broadcast order for the v0.8.0 in-place upgrade + +Every step is one `scripts/deploy/upgrade.sh` invocation under the deployment owner, and the +order is not a preference. Three dependencies make it the only order that works: + +- **The protocol registry goes first.** Every upgraded contract's `version()` reads + `protocolRegistry.protocolVersion()`, and `MigrateStoreFactory` calls `setExpectedCodehash`. + Neither entrypoint exists on the implementation the network starts on, so anything that runs + before the registry swap either reverts or reads a contract that cannot answer. +- **The store migration goes after the registry and before the declaration.** It rewires the + `storeFactory` key, and the declaration records the codehash of whatever that key resolves to. + Declaring first would record the old factory and then quietly stop being true. +- **The declaration goes last.** It is a claim about the whole deployment. Made early, an + abandoned run leaves a false claim standing; made last, the same abandoned run leaves the + previous declaration, which clients read as an older network. + +Nothing in the scripts enforces this. Each is independently runnable by design, so the ordering +lives here and in the fork tests that reproduce it. + +## Before anything is broadcast + +```bash +RPC_URL=https://eth-rpc-paseo-next.polkadot.io scripts/shell/verify-snapshots.sh +bun run test:fork +``` + +The first is the check that matters most and takes seconds. Every `*Old.sol` snapshot must +reproduce the implementation currently deployed; a snapshot that has drifted makes every layout +diff meaningless while leaving the build green. Re-run it on the day, not from an earlier run: +these proxies are upgraded in place, so a swap landing in between changes the answer. + +Confirm the broadcaster owns the proxies. All of them, and the deployed store factory, answer to +the same account; each script asserts this before it swaps anything, so a wrong signer fails fast +instead of reverting inside an upgrade call. + +## Order + +| # | Script | Why here | +| --- | --- | --- | +| 1 | `UpgradeProtocolRegistry` | Adds `protocolVersion` and `setExpectedCodehash`, which steps 2 to 14 depend on. | +| 2 | `UpgradeRegistry` | Adds the deferred-write gate. Reads `registrar.controllers` live. | +| 3 | `UpgradeRegistrar` | Holds the controller authorisations the gate reads. | +| 4 | `UpgradeRegistrarController` | Carries the retained slot that keeps `protocolRegistry` readable. | +| 5 | `UpgradePopController` | | +| 6 | `UpgradePopRules` | | +| 7 | `UpgradeNameEscrow` | Custodies deposits; check `redeemWindow()` is non-zero afterwards. | +| 8 | `UpgradeNameWhitelist` | | +| 9 | `UpgradeResolver` | | +| 10 | `UpgradeReverseResolver` | | +| 11 | `UpgradeContentResolver` | | +| 12 | `UpgradePopResolver` | | +| 13 | `MigrateStoreFactory` | Deploys the replacement, imports the bindings, rewires the key. | +| 14 | `DeclareRelease` | Declares every codehash, then the release. Last, always. | + +Steps 5 to 12 have no dependency on each other and can go in any order among themselves. + +## After each swap + +```bash +cast implementation --rpc-url +``` + +The new implementation's masked bytecode should equal this branch's build. `verify-snapshots.sh` +compares snapshots against the chain, so it is not the tool for this; the check here is that the +swap put the implementation you built where you expected it. + +## After step 13 + +The migration is the only step that moves user state rather than swapping code. Confirm, for a +holder the old factory had: + +``` +protocolRegistry.get(storeFactory) -> the replacement proxy +replacement.getLabelStore() -> the store they already had +replacement.getLabelStoreCount() -> the old factory's count +``` + +`test_after_rewire_a_live_holder_resolves_to_their_existing_store` asserts exactly this on a +fork, so a surprise here means the fork and the chain have diverged since it last ran. + +The old factory keeps its beacons, and the imported stores still point at them. **Do not discard +the old factory**: it is the only contract that can upgrade the implementations behind those +stores, and it answers to the same owner. A `BeaconProxy` holds its beacon in an immutable, so no +migration can move them. + +## After step 14 + +```bash +verify --network paseo-assethub --rpc https://eth-rpc-paseo-next.polkadot.io --tag v0.8.0 +``` + +This does not come back clean, and must not be made to. Expected: every key verifies except +`registrarController`, reported as drift because its deployed code carries the retained slot that +keeps `protocolRegistry` where the live proxy has it, and no release tag describes that build. A +tag build reads the field as the zero address and bricks the contract, so the branch build is the +only deployable one. A second drifting key is a real finding. `DEPLOYMENTS.md` has the detail. + +## If a step fails + +Stop. The upgrades are independent, so a failure leaves the contracts already swapped on the new +code and the rest on the old, which is a running network: `version()` answers from the registry +for the swapped ones, and the declaration has not been made, so nothing on chain over-claims. + +Fix the cause and resume from the failed step. Do not skip ahead to `DeclareRelease` to tidy up; +declaring a release the deployment does not fully run is the one state the declarations cannot +represent. diff --git a/scripts/deploy/MigrateStoreFactory.s.sol b/scripts/deploy/MigrateStoreFactory.s.sol index cdbb09d36..bdd2c78b4 100644 --- a/scripts/deploy/MigrateStoreFactory.s.sol +++ b/scripts/deploy/MigrateStoreFactory.s.sol @@ -8,6 +8,7 @@ import {Upgrades} from "openzeppelin-foundry-upgrades/Upgrades.sol"; import {BaseDeployer} from "./BaseDeployer.s.sol"; import {DotnsConstants} from "../../contracts/utils/DotnsConstants.sol"; import {IStoreFactory} from "../../contracts/store/IStoreFactory.sol"; +import {StoreFactory} from "../../contracts/store/StoreFactory.sol"; import {StoreFactoryMigrator} from "../../contracts/store/StoreFactoryMigrator.sol"; import {IDotnsProtocolRegistry} from "../../contracts/registry/IDotnsProtocolRegistry.sol"; import { @@ -15,142 +16,198 @@ import { } from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; /// @title MigrateStoreFactory -/// @notice Moves the per-user `LabelStore` bindings onto the current `StoreFactory` proxy and -/// points the `storeFactory` key at it. -/// @dev The one contract in the upgrade that cannot be swapped in place. The deployed factory is -/// a plain contract from an earlier release, not a proxy, so there is nothing to upgrade; -/// the current release puts the factory behind its own proxy at a different address. Every -/// store lookup goes through whatever `STORE_FACTORY` resolves to, so re-pointing the key at -/// an empty factory would hand each existing user a second, empty store the next time one -/// was needed, and drop their labels out of per-address enumeration. Their names are -/// untouched either way: ownership lives in the registry, not here. +/// @notice Deploys the current `StoreFactory` behind its own proxy, carries the per-user +/// `LabelStore` bindings over from the factory the network runs today, and points the +/// `storeFactory` key at the result. +/// @dev The one contract in the upgrade that cannot be swapped in place. What is deployed is a +/// plain contract from an earlier release, so there is no proxy to upgrade; the current +/// release puts the factory behind one at a different address. Every store lookup goes +/// through whatever `STORE_FACTORY` resolves to, so re-pointing the key at an empty factory +/// would hand each existing holder a second, empty store the next time one was needed and +/// drop their labels out of per-address enumeration. Names are untouched either way: +/// ownership lives in the registry, not here. +/// +/// Self-contained on purpose. The manifest's `StoreFactory` entry is the factory the network +/// is running, which is the truth before this script and the thing being migrated from; the +/// replacement does not exist until this deploys it. An earlier draft read the destination +/// from that same entry, which meant the script could only run after some other step had +/// already deployed the proxy and rewritten the manifest, and no such step existed. /// /// Sequence, all under the proxy owner: /// -/// 1. upgrade the proxy to `StoreFactoryMigrator`, calling `importStores` in the same -/// transaction, so the proxy is never left sitting on migration tooling between two -/// broadcasts; -/// 2. upgrade it back to the shipped `StoreFactory`; -/// 3. re-point the `storeFactory` key and declare the new codehash together, because the -/// checklist treats an unpaired rewire as drift. +/// 1. deploy the replacement through the pipeline's CREATE3 helper, which lands it on its +/// deterministic address and adopts it if a previous run got that far; +/// 2. upgrade it to `StoreFactoryMigrator`, calling `importStores` in the same transaction +/// so the proxy is never left on migration tooling between two broadcasts; +/// 3. upgrade it back to the shipped `StoreFactory`; +/// 4. re-point the `storeFactory` key and declare the new codehash together, because the +/// checklist treats an unpaired rewire as drift; +/// 5. write the new factory and its beacons into the manifest. /// -/// Both upgrades supply a layout reference and no unsafe override, so each direction is -/// diffed. The migrator is the shipped factory with four fields widened and one entrypoint -/// added, so both diffs are no-ops that still have to pass. +/// Run it after the protocol registry swap. Step 4 calls `setExpectedCodehash`, which does +/// not exist on the registry implementation the network starts on. /// /// What this does not do: the imported stores keep the beacons the old factory minted, and /// those beacons answer to the old factory. Their code stays upgradeable there, by the same -/// owner. A `BeaconProxy` holds its beacon in an immutable, so no migration can move them. -/// -/// The user list is supplied rather than read from the chain: the deployed factory has no -/// enumeration function, so an operator reads its store list out of storage and passes it -/// in. `expectedCount` is read from the old factory here and asserted inside `importStores`, -/// which is what catches a list that silently omits users. +/// owner, which is why the old factory must not be discarded. A `BeaconProxy` holds its +/// beacon in an immutable, so no migration can move them. /// @custom:security-contact admin@parity.io contract MigrateStoreFactory is BaseDeployer { - /// @notice Manifest label the current `StoreFactory` proxy is recorded under. + /// @notice Manifest label the store factory is recorded under, before and after. string internal constant STORE_FACTORY_LABEL = "StoreFactory"; /// @notice Manifest label the protocol registry is recorded under. string internal constant PROTOCOL_REGISTRY_LABEL = "DotnsProtocolRegistry"; - /// @notice Imports the bindings, restores the shipped implementation, and rewires the key. - /// @dev `DOTNS_OLD_STORE_FACTORY` is the factory being migrated from. `DOTNS_STORE_USERS` is - /// a comma-separated list of every address holding a `LabelStore` on it. + /// @notice Deploys the replacement, imports the bindings, rewires the key, saves the manifest. + /// @dev `DOTNS_OLD_STORE_FACTORY` is optional. When set it is asserted against the manifest, + /// so an operator who believes they are migrating from a particular factory finds out + /// here if the manifest disagrees, before anything is broadcast. function run() external { address owner = msg.sender; vm.label(owner, "OWNER"); initDeployment(networkFolder(), vm.toString(block.chainid)); - address proxy = _readAddress(STORE_FACTORY_LABEL); - address oldFactory = vm.envAddress("DOTNS_OLD_STORE_FACTORY"); - address[] memory users = vm.envAddress("DOTNS_STORE_USERS", ","); + address oldFactory = _readAddress(STORE_FACTORY_LABEL); + address protocolRegistry = _readAddress(PROTOCOL_REGISTRY_LABEL); + address expected = vm.envOr("DOTNS_OLD_STORE_FACTORY", address(0)); require( - owner == OwnableUpgradeable(proxy).owner(), - "MigrateStoreFactory: broadcaster is not the proxy owner" + expected == address(0) || expected == oldFactory, + "MigrateStoreFactory: DOTNS_OLD_STORE_FACTORY does not match the manifest" ); require( - oldFactory != proxy, "MigrateStoreFactory: old and new factory are the same address" + owner == OwnableUpgradeable(oldFactory).owner(), + "MigrateStoreFactory: broadcaster does not own the deployed factory" ); - uint256 expectedCount = IStoreFactory(oldFactory).getLabelStoreCount(); - console.log(" importing", expectedCount, "bindings from", oldFactory); + console.log(" migrating from", oldFactory); + console.log(" bindings to carry", IStoreFactory(oldFactory).getLabelStoreCount()); - _importBindings(owner, proxy, oldFactory, users, expectedCount); - _restoreShippedImplementation(owner, proxy); - _rewireKey(owner, proxy); + address replacement = _deployReplacement(owner, protocolRegistry); + require( + replacement != oldFactory, + "MigrateStoreFactory: replacement resolved to the deployed factory" + ); + + _importBindings(owner, replacement, oldFactory); + _restoreShippedImplementation(owner, replacement); + _rewireKey(owner, replacement, protocolRegistry); + _recordManifest(replacement); + + saveDeployments(); console.log("=== MigrateStoreFactory complete ==="); } + /// @notice Deploys the replacement proxy, or adopts one a previous run left behind. + /// @dev The pipeline's own helper, so the replacement lands on the same deterministic address + /// a fresh deploy would give it and is checked the same way. Adoption is what makes the + /// script resumable: an interrupted run can be repeated without stranding a proxy. + /// @param owner Account that owns the deployment and broadcasts. + /// @param protocolRegistry Registry the new factory is initialised against. + /// @return replacement Address of the replacement proxy. + function _deployReplacement( + address owner, + address protocolRegistry + ) + internal + returns (address replacement) + { + replacement = _broadcastDeployUups( + owner, + "StoreFactory.sol:StoreFactory", + abi.encodeCall(StoreFactory.initialize, (owner, protocolRegistry)), + STORE_FACTORY_LABEL + ); + + // The beacons are minted by the initialiser, so they exist only once the proxy does. + // Asserted here because everything downstream reads through them. + _verifyStoreImplementations(replacement, protocolRegistry); + console.log(" replacement factory at", replacement); + } + /// @notice Swaps in the migrator and imports in one transaction. /// @dev `upgradeToAndCall` runs the import as a delegatecall from the proxy, so `msg.sender` /// is preserved and the `onlyOwner` gate on `importStores` is satisfied by the - /// broadcaster rather than by the proxy calling itself. + /// broadcaster. The import reads the holders from `oldFactory` itself, so nothing about + /// the set is captured before this transaction runs. /// @param owner Account that owns the proxy and broadcasts. - /// @param proxy The `StoreFactory` proxy being migrated into. + /// @param replacement The proxy being migrated into. /// @param oldFactory Factory whose bindings are adopted. - /// @param users Every address holding a `LabelStore` on `oldFactory`. - /// @param expectedCount Binding count read from `oldFactory`. - function _importBindings( - address owner, - address proxy, - address oldFactory, - address[] memory users, - uint256 expectedCount - ) - internal - { + function _importBindings(address owner, address replacement, address oldFactory) internal { Options memory opts; opts.referenceContract = "StoreFactory.sol:StoreFactory"; vm.startBroadcast(owner); Upgrades.upgradeProxy( - proxy, + replacement, "StoreFactoryMigrator.sol:StoreFactoryMigrator", - abi.encodeCall(StoreFactoryMigrator.importStores, (oldFactory, users, expectedCount)), + abi.encodeCall(StoreFactoryMigrator.importStores, (oldFactory)), opts ); vm.stopBroadcast(); - console.log(" imported bindings into", proxy); + require( + IStoreFactory(replacement).getLabelStoreCount() + == IStoreFactory(oldFactory).getLabelStoreCount(), + "MigrateStoreFactory: binding counts disagree after the import" + ); + console.log(" imported bindings into", replacement); } /// @notice Returns the proxy to the shipped implementation. /// @dev Left on the migrator, the deployment would be running tooling that no release - /// describes, and the codehash declared in step three would be the tooling's. + /// describes, and the codehash declared below would be the tooling's. /// @param owner Account that owns the proxy and broadcasts. - /// @param proxy The `StoreFactory` proxy. - function _restoreShippedImplementation(address owner, address proxy) internal { + /// @param replacement The proxy to restore. + function _restoreShippedImplementation(address owner, address replacement) internal { Options memory opts; opts.referenceContract = "StoreFactoryMigrator.sol:StoreFactoryMigrator"; vm.startBroadcast(owner); - Upgrades.upgradeProxy(proxy, "StoreFactory.sol:StoreFactory", "", opts); + Upgrades.upgradeProxy(replacement, "StoreFactory.sol:StoreFactory", "", opts); vm.stopBroadcast(); console.log(" restored the shipped StoreFactory implementation"); } - /// @notice Points the `storeFactory` key at the proxy and declares its codehash. + /// @notice Points the `storeFactory` key at the replacement and declares its codehash. /// @dev Paired on purpose. `DEPLOYMENT_CHECKLIST.md` treats a rewire without a matching /// declaration as drift, indistinguishable from an unauthorised swap. /// @param owner Account that owns the protocol registry and broadcasts. - /// @param proxy The `StoreFactory` proxy the key should resolve to. - function _rewireKey(address owner, address proxy) internal { - IDotnsProtocolRegistry registry = - IDotnsProtocolRegistry(_readAddress(PROTOCOL_REGISTRY_LABEL)); - + /// @param replacement The proxy the key should resolve to. + /// @param protocolRegistry The protocol registry holding the key. + function _rewireKey(address owner, address replacement, address protocolRegistry) internal { + IDotnsProtocolRegistry registry = IDotnsProtocolRegistry(protocolRegistry); address implementation = - address(uint160(uint256(vm.load(proxy, ERC1967_IMPLEMENTATION_SLOT)))); + address(uint160(uint256(vm.load(replacement, ERC1967_IMPLEMENTATION_SLOT)))); vm.startBroadcast(owner); - registry.set(DotnsConstants.STORE_FACTORY, proxy); + registry.set(DotnsConstants.STORE_FACTORY, replacement); registry.setExpectedCodehash(DotnsConstants.STORE_FACTORY, implementation.codehash); vm.stopBroadcast(); - console.log(" storeFactory key now resolves to", proxy); + require( + registry.get(DotnsConstants.STORE_FACTORY) == replacement, + "MigrateStoreFactory: storeFactory key did not take" + ); + console.log(" storeFactory key now resolves to", replacement); + } + + /// @notice Records the replacement and its beacons in the manifest. + /// @dev The beacons move with the factory: the replacement's initialiser mints its own, and + /// the old ones stay behind owned by the old factory. Leaving the old beacon addresses + /// in the manifest would point every later tool at beacons this factory cannot upgrade. + /// @param replacement The proxy now serving the `storeFactory` key. + function _recordManifest(address replacement) internal { + address labelBeacon = IStoreFactory(replacement).labelStoreBeacon(); + address userBeacon = IStoreFactory(replacement).userStoreBeacon(); + + vm.label(labelBeacon, "LabelStoreBeacon"); + vm.label(userBeacon, "UserStoreBeacon"); + logDeployment("LabelStoreBeacon", labelBeacon); + logDeployment("UserStoreBeacon", userBeacon); } } diff --git a/scripts/shell/store-holders.sh b/scripts/shell/store-holders.sh deleted file mode 100755 index 9b3cf1b42..000000000 --- a/scripts/shell/store-holders.sh +++ /dev/null @@ -1,83 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# Prints every address holding a `LabelStore` on a deployed factory, as the comma-separated -# list `MigrateStoreFactory.s.sol` expects in `DOTNS_STORE_USERS`. -# -# The list has to be built off chain. The deployed factory keeps a `user => store` mapping and -# an append-only list of store addresses, and neither can be walked: the mapping has no -# enumeration, and the list holds stores, which do not know which user they belong to. What does -# carry the pairing is `LabelStoreDeployed(address indexed user, address indexed store)`, emitted -# on every deployment, so the holders are recovered by replaying that log. -# -# Read it immediately before broadcasting the migration, never from an earlier run. The factory -# is live and the count moves: it went from 57 to 58 during a single afternoon of preparing this -# upgrade. `importStores` asserts the list against the factory's own count and reverts on a short -# one, so a stale list fails the migration rather than silently omitting users, but re-reading is -# what avoids the wasted broadcast. -# -# Point RPC_URL at an archive node or an indexer, not at the public gateway. As of September 2026 -# `https://eth-rpc-paseo-next.polkadot.io` answers `eth_getLogs` with an empty result for every -# range rather than an error, so a replay against it recovers nothing while looking like it -# worked. That is the failure the count check below exists to turn into a stop, and it is why -# this script refuses to print a list it cannot reconcile. Blockscout at -# `https://blockscout-paseo-next.polkadot.io` indexes the same chain and serves the logs. -# -# Usage: -# RPC_URL=... scripts/shell/store-holders.sh 0x709A027F446a9e2a4BB9cb9a9c754435b19e32B7 -# DOTNS_STORE_USERS="$(RPC_URL=... scripts/shell/store-holders.sh 0x...)" - -FACTORY="${1:?usage: store-holders.sh }" -RPC_URL="${RPC_URL:-http://127.0.0.1:8545}" -FROM_BLOCK="${FROM_BLOCK:-0}" - -# keccak("LabelStoreDeployed(address,address)") -TOPIC=0x6294914f6f12fb260c6b69d8a5435317a9318b45790f0b19b42cdd06708fcdea - -logs="$(curl -sf -X POST -H 'Content-Type: application/json' --data "$(cat < Date: Fri, 18 Sep 2026 15:16:36 +0200 Subject: [PATCH 10/25] Make the migration's deploy leg reachable, and stop it erasing the old factory Three findings from the second review, all correct. The fork test could not have run the deploy leg it claims to exercise. `_deployReplacement` reaches `_create3Factory()`, which falls back to reading `DotnsProtocolRegistry` out of the manifest, and the manifest is only populated by `initDeployment`, which `run` calls and the harness does not. The test would have failed at CREATE3 resolution before reaching its assertion, and the unit tests cannot see that. `_deployReplacement` now adopts the CREATE3 factory from the protocol registry it was already handed, so the leg depends on no ambient state and production and the test run the same path. The script erased the only durable pointer to the factory it migrates away from. The deploy reuses the `StoreFactory` label and the replacement mints its own beacons, so after a successful run all three manifest entries described the new deployment, while the imported stores stayed on the old beacons. Those beacons answer to the old factory, and it is the only contract that can ever rotate the implementation behind those stores. The outgoing addresses are now written first, as `StoreFactoryLegacy`, `LabelStoreBeaconLegacy` and `UserStoreBeaconLegacy`, and the runbook says which one a later rotation goes through. The resume claim was too strong. Adoption covers a run that died between the deploy and the import, and nothing else: once the import has landed a second run reverts on the first user it tries to bind, and if the proxy is still on the migrator the adopt is refused, because the deployer requires the occupant to delegate to the implementation that run deployed. The NatSpec said the step was resumable; it now says what is actually true, and the runbook marks step 13 as the one step to inspect and continue rather than re-run. Co-Authored-By: Claude Opus 5 (1M context) --- docs/PASEO-V080-RUNBOOK.md | 25 ++++++++++++++++ scripts/deploy/MigrateStoreFactory.s.sol | 36 ++++++++++++++++++++++-- 2 files changed, 59 insertions(+), 2 deletions(-) diff --git a/docs/PASEO-V080-RUNBOOK.md b/docs/PASEO-V080-RUNBOOK.md index d41e49f2c..c5a670bc2 100644 --- a/docs/PASEO-V080-RUNBOOK.md +++ b/docs/PASEO-V080-RUNBOOK.md @@ -83,6 +83,20 @@ the old factory**: it is the only contract that can upgrade the implementations stores, and it answers to the same owner. A `BeaconProxy` holds its beacon in an immutable, so no migration can move them. +The replacement takes the `StoreFactory` label and mints its own beacons, so after this step the +manifest's usual three entries all describe the new deployment. The outgoing addresses are +written first, under keys that do not move: + +| Key | What it is | +| --- | --- | +| `StoreFactoryLegacy` | The factory the imported stores were created by. | +| `LabelStoreBeaconLegacy` | Beacon behind every imported `LabelStore`. | +| `UserStoreBeaconLegacy` | Its user-store counterpart. | + +A `LabelStore` implementation rotation for the existing holders goes through +`StoreFactoryLegacy`, not through the new factory. The new factory's beacons serve only stores +created after this migration. + ## After step 14 ```bash @@ -104,3 +118,14 @@ for the swapped ones, and the declaration has not been made, so nothing on chain Fix the cause and resume from the failed step. Do not skip ahead to `DeclareRelease` to tidy up; declaring a release the deployment does not fully run is the one state the declarations cannot represent. + +**Step 13 is the exception: do not re-run it.** The other twelve are idempotent, and re-running +one that failed is safe. The migration is not. Its deploy leg adopts an existing proxy, which +covers a failure between the deploy and the import and nothing else. Once the import has landed, +a second run reverts on the first user it tries to bind, because bindings here are permanent. And +if the run died while the proxy was still on the migrator, the adopt is refused outright, since +the deployer requires the occupant to delegate to the implementation that run deployed. + +So a step 13 failure is inspected, not retried. Read the proxy's implementation slot and its +`getLabelStoreCount`, work out which of the four legs completed, and continue from there by hand. +The legs are separate internals for that reason. diff --git a/scripts/deploy/MigrateStoreFactory.s.sol b/scripts/deploy/MigrateStoreFactory.s.sol index bdd2c78b4..f39ad4b07 100644 --- a/scripts/deploy/MigrateStoreFactory.s.sol +++ b/scripts/deploy/MigrateStoreFactory.s.sol @@ -85,6 +85,10 @@ contract MigrateStoreFactory is BaseDeployer { console.log(" migrating from", oldFactory); console.log(" bindings to carry", IStoreFactory(oldFactory).getLabelStoreCount()); + // Written before the deploy, which reuses the `StoreFactory` label and would otherwise + // leave nothing pointing at the factory being replaced. + _recordOutgoing(oldFactory); + address replacement = _deployReplacement(owner, protocolRegistry); require( replacement != oldFactory, @@ -103,8 +107,13 @@ contract MigrateStoreFactory is BaseDeployer { /// @notice Deploys the replacement proxy, or adopts one a previous run left behind. /// @dev The pipeline's own helper, so the replacement lands on the same deterministic address - /// a fresh deploy would give it and is checked the same way. Adoption is what makes the - /// script resumable: an interrupted run can be repeated without stranding a proxy. + /// a fresh deploy would give it and is checked the same way. Adoption covers one case + /// only: a run that died after this step and before the import. It does not make the + /// script re-runnable in general. Once the import has landed a second run reverts on the + /// first user it tries to bind, and if the proxy is still on the migrator the adopt + /// itself is refused, because the helper requires the occupant to delegate to the + /// implementation this run deployed. A failure after this point is inspected and + /// continued from, never restarted. /// @param owner Account that owns the deployment and broadcasts. /// @param protocolRegistry Registry the new factory is initialised against. /// @return replacement Address of the replacement proxy. @@ -115,6 +124,14 @@ contract MigrateStoreFactory is BaseDeployer { internal returns (address replacement) { + // Adopt the CREATE3 factory from the registry that was passed in. Left unset, the + // deployer falls back to resolving it out of the manifest, which only works once + // `initDeployment` has been called: that makes this leg unreachable from anything but + // `run`, including the fork test that is supposed to be exercising the same path. + _setCreate3Factory( + IDotnsProtocolRegistry(protocolRegistry).get(DotnsConstants.CREATE3_FACTORY) + ); + replacement = _broadcastDeployUups( owner, "StoreFactory.sol:StoreFactory", @@ -196,6 +213,21 @@ contract MigrateStoreFactory is BaseDeployer { console.log(" storeFactory key now resolves to", replacement); } + /// @notice Records the outgoing factory and its beacons under their own manifest keys. + /// @dev The replacement takes the `StoreFactory` label and mints its own beacons, so after + /// this migration the manifest's usual three entries all name the new deployment. The + /// old factory cannot simply be forgotten: the stores it created hold their beacon + /// address in an immutable, so those 58 proxies stay on its beacons, and it is the only + /// contract that can ever rotate their implementation. Losing its address from the + /// manifest would leave that upgrade path reachable only by reading an old commit. + /// @param oldFactory The factory being migrated away from. + function _recordOutgoing(address oldFactory) internal { + logDeployment("StoreFactoryLegacy", oldFactory); + logDeployment("LabelStoreBeaconLegacy", IStoreFactory(oldFactory).labelStoreBeacon()); + logDeployment("UserStoreBeaconLegacy", IStoreFactory(oldFactory).userStoreBeacon()); + console.log(" recorded the outgoing factory as StoreFactoryLegacy"); + } + /// @notice Records the replacement and its beacons in the manifest. /// @dev The beacons move with the factory: the replacement's initialiser mints its own, and /// the old ones stay behind owned by the old factory. Leaving the old beacon addresses From e2da0583b3a0173d415e92be209ff701c75ca9b5 Mon Sep 17 00:00:00 2001 From: giuseppere Date: Fri, 18 Sep 2026 15:51:21 +0200 Subject: [PATCH 11/25] Point the upgrade-branch references at dev/testnet-upgrades Prepares the rename of `spha/registrar-upgrade`. GitHub retargets open pull requests and leaves a redirect, but it does not touch file contents, so these four references would have gone stale silently. The CI trigger is the one that matters. It matched `spha/**`, and a renamed branch stops matching, which puts pull requests into it back to running lint and nothing else. That is the gap that let a storage snapshot describing a replaced implementation reach review in the first place. Now matched as `dev/**`, by prefix rather than by name, so the next long-lived branch is covered the day it is created instead of the day someone remembers this file. The other three are prose: CONTRIBUTING on why the artefacts stay on a branch that never merges, and DEPLOYMENTS and the manifest folder README on why this network's deployed code matches no release tag. Land this before the rename, not after. Nothing here touches `contracts/**`, `test/**` or `**.sol`, so the test workflow's path filter skips this commit under either branch name and no window opens either way. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/push_checking.yml | 8 +++++--- CONTRIBUTING.md | 2 +- DEPLOYMENTS.md | 2 +- deployments/paseo-assethub/README.md | 2 +- 4 files changed, 8 insertions(+), 6 deletions(-) diff --git a/.github/workflows/push_checking.yml b/.github/workflows/push_checking.yml index 66cb26d6b..eec0cc96d 100644 --- a/.github/workflows/push_checking.yml +++ b/.github/workflows/push_checking.yml @@ -12,15 +12,17 @@ name: Run Solidity Tests # inside a `pull_request_target` job would let a malicious PR exfiltrate # repo secrets. Re-review carefully if this trigger surface widens. on: - # `spha/**` covers the long-lived upgrade branches, which are never merged to + # `dev/**` covers the long-lived upgrade branches, which are never merged to # master and therefore never get tested by the master triggers alone. Without # them a PR into an upgrade branch runs lint and nothing else, which is how a # storage snapshot that no longer matched the live chain reached review. + # Matched by prefix, not by name, so the next such branch is covered the day + # it is created instead of the day someone remembers this file. pull_request: - branches: [master, "spha/**"] + branches: [master, "dev/**"] paths: ["contracts/**", "test/**", "**.sol"] push: - branches: [master, "spha/**"] + branches: [master, "dev/**"] paths: ["contracts/**", "test/**", "**.sol"] pull_request_target: types: [closed] diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 15ad6c1bf..7410be5a1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -249,7 +249,7 @@ git config core.hooksPath .githooks The conventions below apply specifically to PRs that upgrade an already-deployed proxy. They are scoped to the lifetime of the PR and must be removed before merge; the cleanup checklist at the end of this section is the gate reviewers enforce. -**Long-lived upgrade branches are the exception, and the rest of this section reads differently on one.** A branch under `spha/` holds the tooling for a network that is upgraded in place and is never merged to `master`: `master` flows into it, never back. The reason the artefacts have to be deleted is that they must not reach `master`, and on a branch that never merges they cannot. Deleting them there would throw away the only record of what was deployed, and the starting point for the next round, in exchange for nothing. So on such a branch the snapshots, upgrade scripts and fork tests stay, and the cleanup checklist below applies to the ordinary case: an upgrade PR that is going to `master`. +**Long-lived upgrade branches are the exception, and the rest of this section reads differently on one.** A branch under `dev/` holds the tooling for a network that is upgraded in place and is never merged to `master`: `master` flows into it, never back. The reason the artefacts have to be deleted is that they must not reach `master`, and on a branch that never merges they cannot. Deleting them there would throw away the only record of what was deployed, and the starting point for the next round, in exchange for nothing. So on such a branch the snapshots, upgrade scripts and fork tests stay, and the cleanup checklist below applies to the ordinary case: an upgrade PR that is going to `master`. ### Storage-collision checks diff --git a/DEPLOYMENTS.md b/DEPLOYMENTS.md index 27d9b3d00..82f7daba1 100644 --- a/DEPLOYMENTS.md +++ b/DEPLOYMENTS.md @@ -514,7 +514,7 @@ after each one. ### The deployed code is not always the code in a release -This network's proxies are upgraded in place from the `spha/registrar-upgrade` branch, which is +This network's proxies are upgraded in place from the `dev/testnet-upgrades` branch, which is never merged to `master`, so no release tag describes what they run. Two consequences that look like faults and are not: diff --git a/deployments/paseo-assethub/README.md b/deployments/paseo-assethub/README.md index 2d126b16c..96f3b5234 100644 --- a/deployments/paseo-assethub/README.md +++ b/deployments/paseo-assethub/README.md @@ -24,7 +24,7 @@ question about this network. Use an archive node, or Blockscout at ## What is deployed here is not a release -These proxies are upgraded in place from `spha/registrar-upgrade`, which is never merged to +These proxies are upgraded in place from `dev/testnet-upgrades`, which is never merged to `master`, so no release tag describes the code they run. `DEPLOYMENTS.md` has the detail; the short version is that `verify --tag` reports the `registrarController` key as drift permanently and by design, every other key verifies, and a second drifting key is a real finding. From 3d76ad969f9b5da4e3bedb2a20bd391ace38fd8a Mon Sep 17 00:00:00 2001 From: giuseppere Date: Fri, 18 Sep 2026 16:20:54 +0200 Subject: [PATCH 12/25] Wire the key the declaration would have failed on, and run the fork suite `DeclareRelease` would have aborted on Paseo Asset Hub Next, at step 14, after every swap and the store migration had already been broadcast. The registry's self-reference key resolves to the zero address there, and `_verifyDeployment` asserts it matches the manifest. Sixteen of the seventeen keys are wired and agree; that one is a hole a fresh deploy never has, left by a network wired before the key existed. `_wireMissingKeys` sets keys that are unset and leaves every other key exactly as it is. A key pointing somewhere unexpected still fails verification, because that is drift, and repairing it here would make the check that follows tautological and hide the thing it exists to surface. The fork suite has now been run, for the first time. All 15 tests pass against live state, including the three for the store migration, which could not have run at all before the CREATE3 resolution fix in the previous commit: they would have died resolving the factory out of a manifest the harness never loaded. They went unrun because they needed Docker. The adapter only translates for the same node the hosted endpoint already fronts, so `PASEO_FORK_RPC` now overrides the fork endpoint and the whole suite runs in under a minute with no container. The default stays on the local alias, so CI and anyone mid-deployment are not silently pointed at a public gateway. `fork-tests.sh` and the runbook carry the invocation. Co-Authored-By: Claude Opus 5 (1M context) --- docs/PASEO-V080-RUNBOOK.md | 8 +++++++- scripts/deploy/DeclareRelease.s.sol | 30 +++++++++++++++++++++++++++++ scripts/shell/fork-tests.sh | 10 ++++++++++ test/fork/BaseUpgradeFork.t.sol | 15 ++++++++++++++- 4 files changed, 61 insertions(+), 2 deletions(-) diff --git a/docs/PASEO-V080-RUNBOOK.md b/docs/PASEO-V080-RUNBOOK.md index c5a670bc2..2f060bbc3 100644 --- a/docs/PASEO-V080-RUNBOOK.md +++ b/docs/PASEO-V080-RUNBOOK.md @@ -21,9 +21,15 @@ lives here and in the fork tests that reproduce it. ```bash RPC_URL=https://eth-rpc-paseo-next.polkadot.io scripts/shell/verify-snapshots.sh -bun run test:fork + +PASEO_FORK_RPC=https://eth-rpc-paseo-next.polkadot.io \ + RPC_URL=https://eth-rpc-paseo-next.polkadot.io scripts/shell/fork-tests.sh ``` +The second form needs no Docker. `bun run test:fork` brings up the local ETH-RPC adapter, which +only translates for the same node the hosted endpoint already fronts, so both exercise the same +state. Use whichever is available; the suite is 15 tests and takes under a minute either way. + The first is the check that matters most and takes seconds. Every `*Old.sol` snapshot must reproduce the implementation currently deployed; a snapshot that has drifted makes every layout diff meaningless while leaving the build green. Re-run it on the day, not from an earlier run: diff --git a/scripts/deploy/DeclareRelease.s.sol b/scripts/deploy/DeclareRelease.s.sol index 6455520b3..1c9bd2815 100644 --- a/scripts/deploy/DeclareRelease.s.sol +++ b/scripts/deploy/DeclareRelease.s.sol @@ -4,6 +4,7 @@ pragma solidity ^0.8.34; import {console} from "forge-std/Script.sol"; import {WireDeployments} from "./WireDeployments.s.sol"; +import {IDotnsProtocolRegistry} from "../../contracts/registry/IDotnsProtocolRegistry.sol"; /// @title DeclareRelease /// @notice Re-declares, on chain, what code each well-known key is expected to execute and which @@ -38,10 +39,39 @@ contract DeclareRelease is WireDeployments { Addresses memory addr = _loadAddresses(); + _wireMissingKeys(owner, addr); _declareCodeIdentity(owner, addr); _verifyDeployment(addr, owner); _declareProtocolVersion(owner, addr, releaseTag); console.log("=== DeclareRelease complete ==="); } + + /// @notice Sets any registry key that is still unset, and leaves the rest alone. + /// @dev A network wired before a key existed carries a hole that a fresh deploy never has, + /// and `_verifyDeployment` fails on it at the last step of an upgrade, after every swap + /// has already been broadcast. Paseo Asset Hub Next has exactly one: `protocolRegistry`, + /// the registry's self-reference, which resolves to the zero address there. The key + /// exists so the registry's own implementation has a declared codehash to drift from; + /// consumers bootstrap from the manifest address, so nothing is broken by its absence + /// until something tries to declare against it. + /// + /// Only unset keys are written. A key pointing somewhere unexpected is left exactly as + /// it is, so `_verifyDeployment` still fails on it: that is drift, and repairing it here + /// would make the verification that follows tautological and hide the thing it exists to + /// surface. + /// @param owner Account that owns the registry and broadcasts. + /// @param addr Deployment addresses read from the manifest. + function _wireMissingKeys(address owner, Addresses memory addr) internal { + IDotnsProtocolRegistry registry = IDotnsProtocolRegistry(addr.protocolRegistry); + RegistryEntry[] memory entries = _registryEntries(addr); + + for (uint256 i; i < entries.length; ++i) { + if (registry.get(entries[i].key) != address(0)) continue; + + vm.broadcast(owner); + registry.set(entries[i].key, entries[i].target); + console.log(" wired missing key", entries[i].label, entries[i].target); + } + } } diff --git a/scripts/shell/fork-tests.sh b/scripts/shell/fork-tests.sh index a5b61b4f2..eef4b01df 100755 --- a/scripts/shell/fork-tests.sh +++ b/scripts/shell/fork-tests.sh @@ -12,6 +12,16 @@ set -euo pipefail # bun run test:fork # default verbosity (-vvv) # bun run test:fork -- -vvvvv # pass extra forge args through # RPC_URL=http://127.0.0.1:8545 bun run test:fork +# +# Without Docker: the adapter only translates for the same node the hosted endpoint already +# fronts, so the suite can be run straight against that instead. This skips the adapter +# entirely, including the snapshot check's own RPC: +# +# PASEO_FORK_RPC=https://eth-rpc-paseo-next.polkadot.io \ +# RPC_URL=https://eth-rpc-paseo-next.polkadot.io scripts/shell/fork-tests.sh +# +# The default stays local on purpose, so CI and anyone mid-deployment are not silently +# pointed at a public gateway. ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" cd "$ROOT" diff --git a/test/fork/BaseUpgradeFork.t.sol b/test/fork/BaseUpgradeFork.t.sol index 845eedcb7..d2479e7bb 100644 --- a/test/fork/BaseUpgradeFork.t.sol +++ b/test/fork/BaseUpgradeFork.t.sol @@ -35,10 +35,23 @@ abstract contract BaseUpgradeFork is Test { string internal manifest; function setUp() public virtual { - vm.createSelectFork(vm.rpcUrl("paseo_local")); + vm.createSelectFork(_forkUrl()); manifest = vm.readFile(MANIFEST_PATH); } + /// @notice The endpoint these tests fork from. + /// @dev Defaults to the `paseo_local` alias, which is the ETH-RPC adapter `fork-tests.sh` + /// brings up under Docker. `PASEO_FORK_RPC` overrides it, because requiring Docker is + /// why this suite went unrun for as long as it did: the adapter only translates for the + /// same node the hosted endpoint already fronts, so a run against that endpoint + /// exercises the same state. Keep the default local, so CI and anyone mid-deployment + /// are not silently pointed at a public gateway. + /// @return url Endpoint to fork from. + function _forkUrl() internal view returns (string memory url) { + url = vm.envOr("PASEO_FORK_RPC", string("")); + if (bytes(url).length == 0) url = vm.rpcUrl("paseo_local"); + } + /// @notice Resolves `label` from the manifest and asserts something is deployed there. /// @dev The assertion is the useful half. A fork pointed at a network that never ran this /// deployment resolves every address and finds them all empty, and without this the From 25999014afac85282bbd665548473cf9ed73600a Mon Sep 17 00:00:00 2001 From: giuseppere Date: Mon, 21 Sep 2026 12:55:49 +0200 Subject: [PATCH 13/25] Rotate ownership to a fresh key, as step 0 of the runbook The deployment key's custody includes a laptop belonging to someone who has left, so it is treated as exposed. Rotation was queued as a follow-up for after v0.8.0; an exposed owner key makes that ordering backwards, and it is now the step everything else waits on. Rotation, not redeployment, because ownership is a storage field: every address, registry key, host destination, SDK pin and codehash declaration is untouched, and the only thing that changes is which key the onlyOwner gates answer to. `RotateOwnership.s.sol` transfers the fourteen owned contracts, probed on chain rather than assumed: twelve proxies, the cost-model registry, and the store factory, plus the outgoing factory once the migration has recorded it. The lens, the pricing helper and Multicall3 expose no owner in the deployed release, and the CREATE3 factory answers to a different key on purpose. The inventory is checked from both ends. Contracts that must be owned hard-fail on any surprise, every transfer is verified by readback because the one-step variant makes a wrong destination permanent, and the run ends by scanning the whole manifest for anything owner-answering that the lists missed, so a contract added by a later release stops the rotation instead of surviving it. Re-running after a partial failure skips what already moved, which is what lets the old key finish an interrupted run; a completed one re-runs as a loud no-op. The fork test proves the three facts the step exists for, against live state: every contract answers to the fresh key at its unchanged address, the old key can no longer drive an upgrade script, and the fresh key can. The script parses the manifest it reads itself instead of the deployer's ambient field, which is the lesson from the migration's unreachable deploy leg applied before the test could find it again. The runbook gains the step and its aftermath: swap the signer before step 1, sweep the old account's gas, and delete both stored copies of the old key, this repository's and the organisation's, which the deployment records show control the same account. Co-Authored-By: Claude Fable 5 --- docs/PASEO-V080-RUNBOOK.md | 35 ++++ scripts/deploy/RotateOwnership.s.sol | 253 +++++++++++++++++++++++++++ test/fork/RotateOwnership.t.sol | 128 ++++++++++++++ 3 files changed, 416 insertions(+) create mode 100644 scripts/deploy/RotateOwnership.s.sol create mode 100644 test/fork/RotateOwnership.t.sol diff --git a/docs/PASEO-V080-RUNBOOK.md b/docs/PASEO-V080-RUNBOOK.md index 2f060bbc3..e9437cdc0 100644 --- a/docs/PASEO-V080-RUNBOOK.md +++ b/docs/PASEO-V080-RUNBOOK.md @@ -43,6 +43,7 @@ instead of reverting inside an upgrade call. | # | Script | Why here | | --- | --- | --- | +| 0 | `RotateOwnership` | Moves every contract to a fresh key before anything else is broadcast. | | 1 | `UpgradeProtocolRegistry` | Adds `protocolVersion` and `setExpectedCodehash`, which steps 2 to 14 depend on. | | 2 | `UpgradeRegistry` | Adds the deferred-write gate. Reads `registrar.controllers` live. | | 3 | `UpgradeRegistrar` | Holds the controller authorisations the gate reads. | @@ -60,6 +61,38 @@ instead of reverting inside an upgrade call. Steps 5 to 12 have no dependency on each other and can go in any order among themselves. +## Step 0, and why it is first + +The deployment key's custody includes a laptop belonging to someone who has left, so the key is +treated as exposed. Rotation is one broadcast, individually verifiable, and doing it first means +the long upgrade window is not spent hoping an exposed key stays unused. Addresses do not move: +ownership is a storage field, so hosts, manifests, the SDK pins and the codehash declarations are +all untouched. Redeployment would buy nothing rotation does not. + +The step is the old key's last act, and everything after it is broadcast by the new one: + +1. Generate the new key with clean custody and fund its account with gas. +2. Run `RotateOwnership` as the old key, with `DOTNS_NEW_OWNER` set to the new account. The + script hard-fails if any expected contract answers to a surprise owner, refuses to rotate to + the broadcaster itself, verifies every transfer by readback, and ends by scanning the whole + manifest for anything owner-answering that its inventory missed. +3. Swap the signer: replace the key in the CI environment (or the local keystore) with the new + one. Until this happens, steps 1 to 14 fail their owner assertions, loudly and harmlessly. +4. Sweep the old account's gas balance to the new one with a plain `cast send`. +5. Delete every stored copy of the old key: `DOTNS_ADMIN_KEY` on this repository, and the + organisation-level `DEPLOYER_KEY`, which the deployment records show controls the same + account. An org owner has to do the second. + +A partial failure is finished by running the script again with the same inputs: contracts that +already moved are skipped, so the old key can complete an interrupted rotation. Once everything +has moved, a re-run is a loud no-op. `test/fork/RotateOwnership.t.sol` proves the rotation, the +old key's lockout, the new key's ability to upgrade, and the no-op re-run, against live state. + +One thing rotation does not cover: the CREATE3 factory deployer key cannot be rotated in any +meaningful sense, since the factory address is a historical function of it. On this chain the +slots are spent and it grants nothing; on future chains the deploy pipeline's occupancy checks +are the protection. + ## After each swap ```bash @@ -125,6 +158,8 @@ Fix the cause and resume from the failed step. Do not skip ahead to `DeclareRele declaring a release the deployment does not fully run is the one state the declarations cannot represent. +Step 0 is re-runnable by design, see its section. + **Step 13 is the exception: do not re-run it.** The other twelve are idempotent, and re-running one that failed is safe. The migration is not. Its deploy leg adopts an existing proxy, which covers a failure between the deploy and the import and nothing else. Once the import has landed, diff --git a/scripts/deploy/RotateOwnership.s.sol b/scripts/deploy/RotateOwnership.s.sol new file mode 100644 index 000000000..f40045197 --- /dev/null +++ b/scripts/deploy/RotateOwnership.s.sol @@ -0,0 +1,253 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.34; + +import {console} from "forge-std/Script.sol"; + +import {BaseDeployer} from "./BaseDeployer.s.sol"; + +/// @notice The one slice of Ownable this script needs, so it works identically on the UUPS +/// proxies and on the plain contracts without importing either hierarchy. +interface IOwnable { + function owner() external view returns (address); + function transferOwnership(address newOwner) external; +} + +/// @title RotateOwnership +/// @notice Transfers ownership of every DotNS contract to a fresh key. Step 0 of the upgrade +/// runbook, and the step that exists because key custody, not code, is the current risk: +/// the deployment key has lived on a laptop belonging to someone who has left. +/// @dev Addresses do not move. Ownership is a storage field on each contract, so consumers, +/// hosts, manifests and the codehash declarations are all untouched; the only thing that +/// changes is which key the `onlyOwner` gates answer to. This is the property that makes +/// rotation sufficient and a redeploy unnecessary. +/// +/// Broadcast by the current owner, its last act. Everything that follows in the runbook is +/// broadcast by the new key, so after this lands, the CI environment secret is replaced and +/// the old secret deleted, wherever copies exist. Rotation does nothing retroactive: until +/// it executes, the old key can do everything, and afterwards it can do nothing but be +/// recognised. +/// +/// The inventory is checked from both ends, because a rotation that silently misses a +/// contract leaves a door open behind a report that says all doors are closed. Contracts +/// that must be owned by the broadcaster hard-fail on any surprise, contracts known to +/// carry no owner or someone else's are named as such, and every remaining manifest entry +/// is probed: one that answers `owner()` with the broadcaster fails the run, because it +/// belongs on a list and is not there. +/// +/// Re-runnable after a partial failure: a contract already answering to the new owner is +/// skipped, so the old key can finish what an interrupted run started. Once every contract +/// has moved, the old key can no longer run this at all, which is the point. +/// +/// Two things this deliberately does not cover. The old account's gas balance is swept by +/// hand (see the runbook): a native transfer does not belong in an ownership script. And +/// `Create3Factory` is left alone: its owner is the factory deployer, a different account, +/// and its deploy surface is permissionless anyway, so there is nothing an owner rotation +/// would protect. +/// @custom:security-contact admin@parity.io +contract RotateOwnership is BaseDeployer { + /// @notice Reads the new owner, resolves the inventory, and rotates as `msg.sender`. + /// @dev `DOTNS_NEW_OWNER` is the fresh key's address. Refused when zero or when equal to the + /// broadcaster, because rotating to the compromised key is the one outcome worse than + /// not rotating. + function run() external { + address current = msg.sender; + vm.label(current, "CURRENT_OWNER"); + + initDeployment(networkFolder(), vm.toString(block.chainid)); + + address newOwner = vm.envAddress("DOTNS_NEW_OWNER"); + require(newOwner != address(0), "RotateOwnership: DOTNS_NEW_OWNER is unset or zero"); + require( + newOwner != current, + "RotateOwnership: new owner equals the current one; nothing would rotate" + ); + + _rotateEverything(current, newOwner); + + console.log("=== RotateOwnership complete ==="); + } + + /// @notice Labels whose owner MUST be the broadcaster. Any surprise here aborts the run. + /// @dev Everything the live chain answers `owner()` for with the deployment key, probed on + /// 2026-09-21: the twelve owned proxies, the cost-model registry, and the store factory + /// the manifest names. Before the store migration that label is the deployed plain + /// factory; after it, the replacement proxy, with the outgoing factory recorded as + /// `StoreFactoryLegacy` and picked up by the conditional below. + function _mustRotate() internal pure returns (string[14] memory labels) { + labels = [ + "DotnsProtocolRegistry", + "DotnsRegistry", + "DotnsRegistrar", + "DotnsRegistrarController", + "DotnsPopController", + "PopRules", + "DotnsNameEscrow", + "DotnsNameWhitelist", + "DotnsResolver", + "DotnsReverseResolver", + "DotnsContentResolver", + "DotnsPopResolver", + "DotnsCostModelRegistry", + "StoreFactory" + ]; + } + + /// @notice Labels that are expected to answer to someone else, or to no one. + /// @dev `Create3Factory` answers to the factory deployer, a different key. `Multicall3`, + /// the lens and the pricing helper expose no `owner()` in the deployed release, and + /// `_seed` is a manifest placeholder. Named so the completeness scan can insist that + /// everything else is accounted for explicitly, and so a later release that gives one + /// of these an owner fails the scan instead of slipping through. + function _leaveAlone() internal pure returns (string[5] memory labels) { + labels = ["Create3Factory", "Multicall3", "_seed", "DotnsPopLens", "DotnsFlatPricing"]; + } + + /// @notice Rotates the inventory and then proves the manifest holds nothing unaccounted for. + /// @param current The broadcaster, owner of everything being rotated. + /// @param newOwner The fresh key taking over. + function _rotateEverything(address current, address newOwner) internal { + string memory manifest = vm.readFile( + string.concat("deployments/", networkFolder(), "/", vm.toString(block.chainid), ".json") + ); + + string[14] memory must = _mustRotate(); + for (uint256 i; i < must.length; ++i) { + // Parsed from the file read above, not through the deployer's manifest field: that + // field is only populated by `initDeployment`, and this internal is also driven by + // the fork harness, which calls it directly. Ambient state is how the migration's + // deploy leg was unreachable from its own test; not again. + _rotateOne( + must[i], + vm.parseJsonAddress(manifest, string.concat(".", must[i])), + current, + newOwner, + true + ); + } + + // The outgoing factory exists in the manifest only once the store migration has run. + // It keeps answering to the owner because it is the only contract able to rotate the + // beacons behind the pre-migration stores, so it moves with everything else. + if (vm.keyExistsJson(manifest, ".StoreFactoryLegacy")) { + _rotateOne( + "StoreFactoryLegacy", + vm.parseJsonAddress(manifest, ".StoreFactoryLegacy"), + current, + newOwner, + true + ); + } + + _requireNothingLeftBehind(manifest, current); + } + + /// @notice Rotates one contract, or explains precisely why it did not. + /// @dev A contract already answering to `newOwner` is a completed step of an earlier run and + /// is skipped, which is what makes the script re-runnable after a partial failure. The + /// readback after the transfer is not decoration: `transferOwnership` here is the + /// one-step variant, so a wrong destination is permanent, and the require is the last + /// moment a mistake is a revert instead of a fact. + /// @param label Manifest name, for the logs and the failure messages. + /// @param target The contract. + /// @param current The broadcaster. + /// @param newOwner The fresh key. + /// @param mustOwn Whether a surprise owner aborts the run. + function _rotateOne( + string memory label, + address target, + address current, + address newOwner, + bool mustOwn + ) + internal + { + (bool answers, address owner) = _tryOwner(target); + + if (!answers) { + require(!mustOwn, string.concat("RotateOwnership: ", label, " does not answer owner()")); + console.log(" skipped (no owner surface)", label, target); + return; + } + if (owner == newOwner) { + console.log(" already rotated", label, target); + return; + } + if (owner != current) { + require( + !mustOwn, + string.concat("RotateOwnership: ", label, " is owned by neither key involved") + ); + console.log(" skipped (owned elsewhere)", label, target); + return; + } + + vm.broadcast(current); + IOwnable(target).transferOwnership(newOwner); + + require( + IOwnable(target).owner() == newOwner, + string.concat("RotateOwnership: ", label, " readback does not show the new owner") + ); + console.log(" rotated", label, target); + } + + /// @notice Fails the run if any manifest entry outside the three lists answers to the owner. + /// @dev The lists above are a claim about the deployment's shape. This is the check that the + /// claim is complete: a contract added to the manifest by a later release, owned by the + /// deployment key and missing from the lists, stops the rotation here instead of + /// surviving it silently. + /// @param manifest Raw manifest JSON. + /// @param current The broadcaster. + function _requireNothingLeftBehind(string memory manifest, address current) internal view { + string[] memory names = vm.parseJsonKeys(manifest, "$"); + for (uint256 i; i < names.length; ++i) { + if (_handled(names[i])) continue; + + (bool answers, address owner) = + _tryOwner(vm.parseJsonAddress(manifest, string.concat(".", names[i]))); + require( + !answers || owner != current, + string.concat( + "RotateOwnership: ", + names[i], + " is owned by the key being rotated away from but is in no inventory list" + ) + ); + } + } + + /// @notice Whether `name` is covered by one of the three lists. + function _handled(string memory name) internal pure returns (bool covered) { + bytes32 h = keccak256(bytes(name)); + string[14] memory must = _mustRotate(); + for (uint256 i; i < must.length; ++i) { + if (keccak256(bytes(must[i])) == h) return true; + } + string[5] memory alone = _leaveAlone(); + for (uint256 i; i < alone.length; ++i) { + if (keccak256(bytes(alone[i])) == h) return true; + } + if (h == keccak256(bytes("StoreFactoryLegacy"))) return true; + if (h == keccak256(bytes("LabelStoreBeacon")) || h == keccak256(bytes("UserStoreBeacon"))) { + // Owned by their factory, which rotates above; a beacon answering to an EOA would be + // drift, and the factory checks in the deploy pipeline are where that is caught. + return true; + } + if ( + h == keccak256(bytes("LabelStoreBeaconLegacy")) + || h == keccak256(bytes("UserStoreBeaconLegacy")) + ) return true; + } + + /// @notice `owner()` as a probe that cannot revert the caller. + /// @param target Contract to ask. + /// @return answers Whether the call returned a decodable address. + /// @return owner The owner when it did. + function _tryOwner(address target) internal view returns (bool answers, address owner) { + (bool ok, bytes memory data) = + target.staticcall(abi.encodeWithSelector(IOwnable.owner.selector)); + if (!ok || data.length != 32) return (false, address(0)); + owner = abi.decode(data, (address)); + answers = true; + } +} diff --git a/test/fork/RotateOwnership.t.sol b/test/fork/RotateOwnership.t.sol new file mode 100644 index 000000000..17dafb763 --- /dev/null +++ b/test/fork/RotateOwnership.t.sol @@ -0,0 +1,128 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.34; + +import {BaseUpgradeFork} from "./BaseUpgradeFork.t.sol"; +import {RotateOwnership, IOwnable} from "../../scripts/deploy/RotateOwnership.s.sol"; +import {UpgradeRegistryHarness} from "./UpgradeRegistry.t.sol"; + +/// @title RotateOwnershipHarness +/// @notice Exposes the rotation script's internal path so the test drives the code the +/// production run executes, inventory checks included. +contract RotateOwnershipHarness is RotateOwnership { + /// @notice Rotates everything from `current` to `newOwner` through the script's own internal. + function rotate(address current, address newOwner) external { + _rotateEverything(current, newOwner); + } +} + +/// @title RotateOwnershipForkTest +/// @notice Pairs one-to-one with `scripts/deploy/RotateOwnership.s.sol`. Rotates the live +/// deployment's ownership on a fork and proves the property the step exists for: the old +/// key loses the ability to act, the new key gains it, and no address moves. +/// @dev Step 0 of the runbook, so this runs against the pre-upgrade chain state on purpose: +/// rotation is broadcast before any implementation is swapped. +/// @custom:security-contact admin@parity.io +contract RotateOwnershipForkTest is BaseUpgradeFork { + /// @notice The contracts the script must rotate, resolved from the manifest in setUp. + /// @dev Kept as labels so a mismatch against the script's own list fails by name. + string[14] internal labels = [ + "DotnsProtocolRegistry", + "DotnsRegistry", + "DotnsRegistrar", + "DotnsRegistrarController", + "DotnsPopController", + "PopRules", + "DotnsNameEscrow", + "DotnsNameWhitelist", + "DotnsResolver", + "DotnsReverseResolver", + "DotnsContentResolver", + "DotnsPopResolver", + "DotnsCostModelRegistry", + "StoreFactory" + ]; + + /// @notice The deployment key, read from the chain, not assumed. + address internal currentOwner; + + /// @notice The fresh key ownership moves to. + address internal freshOwner; + + /// @notice Drives the script's own rotation path. + RotateOwnershipHarness internal rotator; + + function setUp() public override { + super.setUp(); + currentOwner = _ownerOf(_live("DotnsRegistry")); + freshOwner = makeAddr("fresh-owner"); + rotator = new RotateOwnershipHarness(); + } + + /// @notice Every inventoried contract answers to the new key afterwards, at its old address. + /// @dev The address assertions look redundant and are not: "rotation moves no addresses" is + /// the claim that makes rotation preferable to redeployment, so the test states it + /// against the manifest instead of leaving it as prose. + function test_rotation_moves_every_owner_and_no_address() public { + address[] memory targets = new address[](labels.length); + for (uint256 i; i < labels.length; ++i) { + targets[i] = _live(labels[i]); + assertEq( + IOwnable(targets[i]).owner(), + currentOwner, + string.concat("fork precondition: ", labels[i], " answers to the deployment key") + ); + } + + rotator.rotate(currentOwner, freshOwner); + + for (uint256 i; i < labels.length; ++i) { + assertEq( + IOwnable(targets[i]).owner(), + freshOwner, + string.concat(labels[i], " answers to the fresh key") + ); + assertEq( + _live(labels[i]), + targets[i], + string.concat(labels[i], " is still at the address the manifest names") + ); + } + } + + /// @notice After rotation the old key cannot upgrade, and the new key can. + /// @dev The lockout is the security property the step exists for, and the new key working is + /// what makes the runbook's steps 1 to 14 possible afterwards. Both are shown through + /// the registry upgrade script's own path, so what is proven is the exact pair of facts + /// the deployment relies on next. + function test_old_key_is_locked_out_and_new_key_can_upgrade() public { + address registry = _live("DotnsRegistry"); + rotator.rotate(currentOwner, freshOwner); + + UpgradeRegistryHarness upgrader = new UpgradeRegistryHarness(); + + vm.expectRevert(bytes("UpgradeRegistry: broadcaster is not the proxy owner")); + upgrader.upgrade(currentOwner, registry); + + upgrader.upgrade(freshOwner, registry); + assertTrue( + _implementationOf(registry) != address(0), "the fresh key performed a real upgrade" + ); + } + + /// @notice A second run from the old key completes as a no-op instead of failing. + /// @dev Partial-failure recovery: an interrupted rotation is finished by running again, and + /// contracts that already moved are skipped. A full re-run therefore has nothing to do, + /// and proving it does nothing loudly is what makes re-running safe to recommend. + function test_rerun_after_completion_is_a_noop() public { + rotator.rotate(currentOwner, freshOwner); + rotator.rotate(currentOwner, freshOwner); + + for (uint256 i; i < labels.length; ++i) { + assertEq( + IOwnable(_live(labels[i])).owner(), + freshOwner, + string.concat(labels[i], " unchanged by the re-run") + ); + } + } +} From 091fcf0d50f084a87bd924c49286c79c00604083 Mon Sep 17 00:00:00 2001 From: giuseppere Date: Mon, 21 Sep 2026 13:37:51 +0200 Subject: [PATCH 14/25] Broadcast one runbook step per label on the review PR, gated and off master The dispatch button only exists for workflows on the default branch, and nothing that signs with the owner key is going there. A pull_request workflow is taken from the PR itself, so this file lives on the dev branch alone and the open review PR into master becomes the broadcast console: add `run: