diff --git a/README.md b/README.md index a526b1c33..0b5f7109f 100644 --- a/README.md +++ b/README.md @@ -101,9 +101,9 @@ Each base label carries a head/tail-indexed reservation queue with a capacity of #### Early testnet quirk: LabelStore deployment -Pop-gateway issuances mint the name and persist its label, but LabelStore deployment is deferred for users who have not yet interacted with the protocol from their own address. The current pallet-revive runtime does not let substrate Root deploy contracts on behalf of an account it does not control, so the per-user LabelStore cannot be created at the moment the gateway writes. The controller stamps a pending-claim entry instead, and the user calls claimLabelStore once from their own address to settle the store. The pending-claim entries have a bounded TTL (expirePendingClaim is permissionless) so the slot frees itself if a user never claims. When the runtime supports root-origin contract deployment, the deferred path collapses to a no-op and the issuance flow becomes one transaction end-to-end. This is a runtime limitation, not a protocol design choice. +Pop-gateway issuances mint the name and persist its label, but LabelStore deployment is deferred for users who have not yet interacted with the protocol from their own address. The current pallet-revive runtime does not let substrate Root deploy contracts on behalf of an account it does not control, so the per-user LabelStore cannot be created at the moment the gateway writes. The controller stamps a pending-claim entry instead, and settlement writes the label into the owner's store, deploying the store on the first write. Settlement is permissionless via settlePendingClaims: the owner settles their own store, or after the claim window anyone settles a given owner's entry and pays the cost. Settlement always writes the label rather than dropping the entry, so a pending name is never stranded. When the runtime supports root-origin contract deployment, the deferred path collapses to a no-op and the issuance flow becomes one transaction end-to-end. This is a runtime limitation, not a protocol design choice. -Operational consequence for transfers: the registrar derives the transfer-floor price by reading the label from the sender's LabelStore. A gateway-issued name held by a user who has not yet called claimLabelStore has no readable label on the sender side, so `_quoteTransferFee` returns zero regardless of the recipient's tier. Until the holder settles their LabelStore, a downward transfer (for example PopFull to NoStatus) does not charge the cross-tier friction it would otherwise owe. Clients that consume gateway-issued names should treat claimLabelStore as a prerequisite for accurate transfer-time pricing, not just for label discovery. +Operational consequence for transfers: the registrar derives the transfer-floor price by reading the label from the sender's LabelStore. A gateway-issued name whose pending claim is not yet settled has no readable label on the sender side, so `_quoteTransferFee` returns zero regardless of the recipient's tier. Until the name is settled into a LabelStore, a downward transfer (for example PopFull to NoStatus) does not charge the cross-tier friction it would otherwise owe. Clients that consume gateway-issued names should treat settlement as a prerequisite for accurate transfer-time pricing, not just for label discovery. ### RootGatewayDispatcher diff --git a/contracts/pop/IPopRules.sol b/contracts/pop/IPopRules.sol index d1d47452c..1a37a7a0d 100644 --- a/contracts/pop/IPopRules.sol +++ b/contracts/pop/IPopRules.sol @@ -83,6 +83,15 @@ interface IPopRules { pure returns (PopStatus requirement, string memory message); + /// @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 Updates the spam-deterrent starting price for NoStatus pricing. /// @dev Owner-only; unauthorised callers trigger @custom:reverts /// OwnableUnauthorizedAccount. `newStartingPrice` must be strictly positive, otherwise diff --git a/contracts/pop/PopRules.sol b/contracts/pop/PopRules.sol index 80de9ab94..da346fb99 100644 --- a/contracts/pop/PopRules.sol +++ b/contracts/pop/PopRules.sol @@ -268,6 +268,11 @@ contract PopRules is return reachComponent > downgradeComponent ? reachComponent : downgradeComponent; } + /// @inheritdoc IPopRules + 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 diff --git a/contracts/registrars/DotnsPopController.sol b/contracts/registrars/DotnsPopController.sol index 0a9206985..4226ee723 100644 --- a/contracts/registrars/DotnsPopController.sol +++ b/contracts/registrars/DotnsPopController.sol @@ -138,7 +138,7 @@ contract DotnsPopController is /// @notice Duration (in seconds) after which a reservation entry is considered expired. /// @dev Mirrors `pallet_resources::UsernameReservationDuration`. Configurable by /// governance via `setReservationDuration`. - uint64 public reservationDuration; + 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 @@ -148,9 +148,8 @@ contract DotnsPopController is /// @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 claimLabelStore deploys the store and settles the whole pile, or - /// @custom:function expirePendingClaim sweeps the lapsed entries. Each entry's expiry is - /// measured from its own `mintedAt`. + /// @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. @@ -376,38 +375,61 @@ contract DotnsPopController is } /// @inheritdoc IDotnsPopController - function claimLabelStore() external override { - _claimLabelStoreFor(msg.sender); + function claimLabelStore() external override returns (bool moreRemaining) { + (, moreRemaining) = _settlePending(msg.sender, DotnsConstants.MAX_PAGE_SIZE); } /// @inheritdoc IDotnsPopController - function claimLabelStoreFor(address user) external override onlyGateway { - _claimLabelStoreFor(user); + function settlePendingClaims( + address user, + uint256 limit + ) + external + override + returns (uint256 settledCount, bool moreRemaining) + { + return _settlePending(user, limit); } - function _claimLabelStoreFor(address user) internal { + /// @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); - uint256 settled; - PendingClaim[] storage queue = _pendingClaimQueue[user]; - uint256 count = queue.length; - for (uint256 i = 0; i < count; ++i) { - if (_isExpired(queue[i].mintedAt)) continue; - store = _settlePendingLabel(factory, store, user, queue[i].label); - ++settled; + 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); } - require(settled != 0, NoPendingClaim(user)); - - _clearPendingClaim(user); + moreRemaining = remaining != 0; + if (!moreRemaining) { + _pendingClaimUsers.remove(user); + } } /// @notice Writes a single pending label into the user's store, deploying the store lazily. - /// @dev Deferring the deploy to the first settled label means a pile of only-lapsed entries - /// never leaves a fresh store behind with nothing written. Returns the (possibly newly - /// deployed) store so the caller threads it through the remaining entries. + /// @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, @@ -423,43 +445,11 @@ contract DotnsPopController is store = factory.deployLabelStoreFor(user); } _writeRecord(store, node, label); - emit PendingClaimSettled(user, labelhash, store); + emit PendingClaimSettled(user, labelhash, store, msg.sender); emit NameRegistered(label, labelhash, user, store); return store; } - /// @inheritdoc IDotnsPopController - function expirePendingClaim(address user) external override { - PendingClaim[] storage queue = _pendingClaimQueue[user]; - require(queue.length != 0, NoPendingClaim(user)); - - bool sweptAny; - - // Swap-and-pop every expired queue entry. The swapped-in tail is re-inspected at the - // same index, so a single pass removes all lapsed entries while preserving the live ones. - uint256 i; - while (i < queue.length) { - if (_isExpired(queue[i].mintedAt)) { - bytes32 labelhash = LabelUtils.labelhashMemory(queue[i].label); - uint256 last = queue.length - 1; - if (i != last) { - queue[i] = queue[last]; - } - queue.pop(); - emit PendingClaimExpired(user, labelhash); - sweptAny = true; - } else { - ++i; - } - } - - require(sweptAny, PendingClaimNotExpired(user)); - - if (queue.length == 0) { - _pendingClaimUsers.remove(user); - } - } - /// @inheritdoc IDotnsPopController function isReservedForClaim(string calldata reservedBaseLabel) external @@ -521,13 +511,33 @@ contract DotnsPopController is } /// @inheritdoc IDotnsPopController - function pendingClaims(address user) + function pendingClaims( + address user, + uint256 offset, + uint256 limit + ) external view override - returns (PendingClaim[] memory claims_) + returns (PendingClaim[] memory claims) { - return _pendingClaimQueue[user]; + 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 IDotnsPopController + function pendingClaimCountOf(address user) external view override returns (uint256 count) { + return _pendingClaimQueue[user].length; } /// @inheritdoc IDotnsPopController @@ -550,6 +560,7 @@ contract DotnsPopController is 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) { @@ -557,6 +568,16 @@ contract DotnsPopController is } } + /// @inheritdoc IDotnsPopController + function reservedBaseLabelOf(bytes32 labelhash) + external + view + override + returns (string memory baseLabel) + { + return _reservedBaseLabel[labelhash]; + } + /// @inheritdoc ERC165Upgradeable function supportsInterface(bytes4 interfaceId) public @@ -587,7 +608,7 @@ contract DotnsPopController is /// 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 claimLabelStore when the user + /// @custom:emits NameRegistered to @custom:function settlePendingClaims when the claim /// settles. function _completeGatewayRegistration( address user, @@ -629,8 +650,8 @@ contract DotnsPopController is } /// @notice Writes a name's label into `store`. - /// @dev Single canonical persistence step shared by the warm gateway path and the - /// user-signed @custom:function claimLabelStore. The store key is `node`, matching + /// @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`. @@ -644,9 +665,9 @@ contract DotnsPopController is /// @notice Appends a deferred binding for `user` and adds them to the enumeration set. /// @dev The Root gateway origin cannot deploy the user's `LabelStore`, so deferred names pile - /// up in `_pendingClaimQueue` until a signed-origin @custom:function claimLabelStore settles - /// them. Adding the user to the set is idempotent, so repeat stashes keep a single enumeration - /// entry. Emits @custom:emits PendingClaimStashed. + /// 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)}) @@ -656,12 +677,6 @@ contract DotnsPopController is emit PendingClaimStashed(user, labelhash, label); } - /// @notice Clears a user's entire pending pile and removes them from the enumeration set. - function _clearPendingClaim(address user) internal { - delete _pendingClaimQueue[user]; - _pendingClaimUsers.remove(user); - } - /// @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; diff --git a/contracts/registrars/DotnsPopLens.sol b/contracts/registrars/DotnsPopLens.sol new file mode 100644 index 000000000..b3000dd39 --- /dev/null +++ b/contracts/registrars/DotnsPopLens.sol @@ -0,0 +1,290 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.34; + +import {IDotnsPopLens} from "./IDotnsPopLens.sol"; +import {IDotnsPopController} from "./IDotnsPopController.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 {StringUtils} from "../utils/StringUtils.sol"; +import {DotnsConstants} from "../utils/DotnsConstants.sol"; + +/// @title DotnsPopLens +/// @notice Read-only view over PoP identity data. +/// @dev Stateless beyond the protocol registry it holds, and never mints or settles. It composes +/// each field from the contract that owns it: names from the owner's `LabelStore` and the +/// controller's pending queue, ownership from the registrar, chat keys and links from the PoP +/// resolver, and label classification from PopRules. Living outside the controller keeps the +/// controller within the contract-size limit and keeps the registrar the single source of +/// ownership truth. Deployed as a plain contract through the CREATE3 factory, so its address is +/// deterministic and it can be redeployed on a read change without touching stored state. +/// @custom:security-contact admin@parity.io +contract DotnsPopLens is IDotnsPopLens { + using StringUtils for *; + + /// @notice Protocol-level address registry used to resolve every sibling contract. + IDotnsProtocolRegistry internal immutable _protocolRegistry; + + /// @notice Binds the lens to the protocol registry it reads through. + /// @param registry Protocol registry resolving the controller, registrar, store factory, + /// PoP resolver, and PopRules. + constructor(IDotnsProtocolRegistry registry) { + _protocolRegistry = registry; + } + + /// @inheritdoc IDotnsPopLens + function protocolRegistry() external view override returns (address registry) { + return address(_protocolRegistry); + } + + /// @inheritdoc IDotnsPopLens + function liteNamesOf( + address user, + uint256 offset, + uint256 limit + ) + external + view + override + returns (Name[] memory names) + { + return _pageNames(user, offset, limit, true); + } + + /// @inheritdoc IDotnsPopLens + function fullNamesOf( + address user, + uint256 offset, + uint256 limit + ) + external + view + override + returns (Name[] memory names) + { + return _pageNames(user, offset, limit, false); + } + + /// @inheritdoc IDotnsPopLens + function liteNameCountOf(address user) external view override returns (uint256 count) { + return _countNames(user, true); + } + + /// @inheritdoc IDotnsPopLens + function fullNameCountOf(address user) external view override returns (uint256 count) { + return _countNames(user, false); + } + + /// @inheritdoc IDotnsPopLens + function nameDetail(string calldata name) external view override returns (NameDetail memory) { + (bytes32 labelhash, bytes32 node) = LabelUtils.deriveNode(_protocolRegistry.tldNode(), name); + NameDetail memory detail = _detail(node); + // Holding the label means holding its labelhash, so the lite-to-full link resolves here. + detail.fullClaim = _popResolver().fullClaim(labelhash); + return detail; + } + + /// @inheritdoc IDotnsPopLens + function nameDetailByNode(bytes32 node) external view override returns (NameDetail memory) { + NameDetail memory detail = _detail(node); + // The node cannot be inverted to a labelhash, so `fullClaim` resolves only when the label + // is independently recoverable (a settled name whose label the registrar returns). + if (bytes(detail.label).length != 0) { + detail.fullClaim = _popResolver().fullClaim(LabelUtils.labelhashMemory(detail.label)); + } + return detail; + } + + /// @inheritdoc IDotnsPopLens + function profileOf(address user) external view override returns (PopProfile memory profile) { + IDotnsPopController controller = _controller(); + profile.hasLabelStore = _storeFactory().getLabelStore(user) != address(0); + profile.pendingClaimCount = controller.pendingClaimCountOf(user); + profile.reservationLabelhash = controller.userReservation(user).labelhash; + } + + /// @notice Whether `label` belongs in the lite listing (`wantLite`) or the full listing. + /// @dev A lite-person label is a single label with two trailing digits; a full-person label + /// is any other single label. The two sets are disjoint and together cover every single + /// label, so one predicate drives both listings. + function _matchesShape(string memory label, bool wantLite) internal pure returns (bool) { + bool lite = label.isLitePersonLabelMemory(); + return wantLite ? lite : (!lite && label.isSingleLabelMemory()); + } + + /// @notice Counts the names currently owned by `user` that match the requested shape. + /// @dev Walks the user's `LabelStore` (settled names) then their pending claims, keeping only + /// shape matches still owned by `user` on the registrar. A pending entry already written into + /// the store by a sibling flow is skipped so it is not counted twice. + function _countNames(address user, bool wantLite) internal view returns (uint256 count) { + IDotnsRegistrar registrar = _registrar(); + bytes32 tldNode = _protocolRegistry.tldNode(); + address store = _storeFactory().getLabelStore(user); + + if (store != address(0)) { + ILabelStore labelStore = ILabelStore(store); + uint256 stored = labelStore.getLabelCount(); + for (uint256 i; i < stored; ++i) { + bytes32 node = labelStore.getLabelhashAt(i); + if (!_ownedBy(registrar, node, user)) continue; + if (_matchesShape(registrar.labelOf(uint256(node)), wantLite)) ++count; + } + } + + IDotnsPopController.PendingClaim[] memory queue = _pendingClaims(user); + uint256 pending = queue.length; + for (uint256 j; j < pending; ++j) { + string memory label = queue[j].label; + if (!_matchesShape(label, wantLite)) continue; + bytes32 node = LabelUtils.namehashUnder(tldNode, LabelUtils.labelhashMemory(label)); + if (store != address(0) && ILabelStore(store).isLocked(node)) continue; + if (_ownedBy(registrar, node, user)) ++count; + } + } + + /// @notice Returns a page of `user`'s owned names matching the requested shape. + /// @dev Same ownership-verified walk as @custom:function _countNames, in the same order + /// (store then pending), skipping the first `offset` matches and returning up to `limit` + /// entries. `limit` is clamped to `DotnsConstants.MAX_PAGE_SIZE` to bound the memory and the + /// scan. + function _pageNames( + address user, + uint256 offset, + uint256 limit, + bool wantLite + ) + internal + view + returns (Name[] memory names) + { + if (limit > DotnsConstants.MAX_PAGE_SIZE) limit = DotnsConstants.MAX_PAGE_SIZE; + Name[] memory page = new Name[](limit); + if (limit == 0) return page; + + IDotnsRegistrar registrar = _registrar(); + bytes32 tldNode = _protocolRegistry.tldNode(); + address store = _storeFactory().getLabelStore(user); + + uint256 filled; + uint256 seen; + + if (store != address(0)) { + ILabelStore labelStore = ILabelStore(store); + uint256 stored = labelStore.getLabelCount(); + for (uint256 i; i < stored && filled < limit; ++i) { + bytes32 node = labelStore.getLabelhashAt(i); + if (!_ownedBy(registrar, node, user)) continue; + string memory label = registrar.labelOf(uint256(node)); + if (!_matchesShape(label, wantLite)) continue; + if (seen++ < offset) continue; + page[filled++] = Name({node: node, label: label, settled: true, deadline: 0}); + } + } + + IDotnsPopController.PendingClaim[] memory queue = _pendingClaims(user); + uint256 pending = queue.length; + uint64 duration = _controller().reservationDuration(); + for (uint256 j; j < pending && filled < limit; ++j) { + string memory label = queue[j].label; + if (!_matchesShape(label, wantLite)) continue; + bytes32 node = LabelUtils.namehashUnder(tldNode, LabelUtils.labelhashMemory(label)); + if (store != address(0) && ILabelStore(store).isLocked(node)) continue; + if (!_ownedBy(registrar, node, user)) continue; + if (seen++ < offset) continue; + page[filled++] = Name({ + node: node, label: label, settled: false, deadline: queue[j].mintedAt + duration + }); + } + + if (filled == limit) return page; + names = new Name[](filled); + for (uint256 k; k < filled; ++k) { + names[k] = page[k]; + } + } + + /// @notice Whether `node` is a minted name currently owned by `user`. + /// @dev Guards the `ownerOf` call with `exists` so a missing token returns false rather than + /// reverting, keeping the listing reads total. + function _ownedBy( + IDotnsRegistrar registrar, + bytes32 node, + address user + ) + internal + view + returns (bool) + { + return registrar.exists(uint256(node)) && registrar.ownerOf(uint256(node)) == user; + } + + /// @notice Gathers a name's record from the registrar, PoP resolver, and PopRules. + /// @dev Reads defensively so an unminted or unsettled name yields zeroed fields instead of + /// reverting. `fullClaim` is left for the caller because it needs the labelhash, which is + /// recoverable from the label string but not from the node alone. `tier` classifies the + /// label shape and is skipped for an empty label. + function _detail(bytes32 node) internal view returns (NameDetail memory detail) { + detail.node = node; + IDotnsRegistrar registrar = _registrar(); + if (registrar.exists(uint256(node))) { + address owner = registrar.ownerOf(uint256(node)); + detail.exists = true; + detail.owner = owner; + detail.label = registrar.labelOf(uint256(node)); + address store = _storeFactory().getLabelStore(owner); + detail.settled = store != address(0) && ILabelStore(store).isLocked(node); + } + if (bytes(detail.label).length != 0) { + // Every mint path validates the label, so a stored label always classifies; the try + // keeps this read total even if a future path ever stores a non-canonical label. + try _popRules().classifyName(detail.label) returns ( + IPopRules.PopStatus tier, string memory + ) { + detail.tier = tier; + } catch {} + } + IDotnsPopResolver resolver = _popResolver(); + detail.chatKey = resolver.chatKey(node); + detail.liteLink = resolver.liteLink(node); + } + + /// @notice Reads a bounded page of `user`'s pending claims from the controller. + /// @dev The listings scan this page in memory; it holds up to `DotnsConstants.MAX_PAGE_SIZE` + /// staged claims, which the reads document as their pending-portion bound. + function _pendingClaims(address user) + internal + view + returns (IDotnsPopController.PendingClaim[] memory claims) + { + return _controller().pendingClaims(user, 0, DotnsConstants.MAX_PAGE_SIZE); + } + + /// @notice Resolves the PoP controller via the protocol registry. + function _controller() internal view returns (IDotnsPopController) { + return IDotnsPopController(_protocolRegistry.get(DotnsConstants.POP_CONTROLLER)); + } + + /// @notice Resolves the registrar via the protocol registry. + function _registrar() internal view returns (IDotnsRegistrar) { + return IDotnsRegistrar(_protocolRegistry.get(DotnsConstants.REGISTRAR)); + } + + /// @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 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)); + } +} diff --git a/contracts/registrars/IDotnsPopController.sol b/contracts/registrars/IDotnsPopController.sol index 0206ae664..8d08be490 100644 --- a/contracts/registrars/IDotnsPopController.sol +++ b/contracts/registrars/IDotnsPopController.sol @@ -70,17 +70,16 @@ interface IDotnsPopController is IDotnsController { } /// @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 user - /// later settles the binding via @custom:function claimLabelStore, 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 claimLabelStore deploys the store and - /// settles every entry at once. Each entry's expiry is measured from its own `mintedAt` - /// against `reservationDuration`. + /// @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 { @@ -117,7 +116,7 @@ interface IDotnsPopController is IDotnsController { /// @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 claimLabelStoreFor or the user fallback @custom:function claimLabelStore. + /// @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 { @@ -172,13 +171,17 @@ interface IDotnsPopController is IDotnsController { /// pending-claim mapping because the user has no store yet. event PendingClaimStashed(address indexed user, bytes32 indexed labelhash, string label); - /// @notice Emitted when a user settles a deferred binding by deploying their - /// `LabelStore` and backfilling the stashed label and chat key. - event PendingClaimSettled(address indexed user, bytes32 indexed labelhash, address store); - - /// @notice Emitted when a deferred binding is reaped because it sat unsettled past - /// `reservationDuration`. - event PendingClaimExpired(address indexed user, bytes32 indexed labelhash); + /// @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. @@ -226,16 +229,6 @@ interface IDotnsPopController is IDotnsController { /// holds the live head-of-queue reservation. error NotHolder(address user, bytes32 labelhash); - /// @notice Thrown when @custom:function claimLabelStore is called by a user with no - /// recorded pending-claim entries. - /// @param user Caller observed by the controller. - error NoPendingClaim(address user); - - /// @notice Thrown when @custom:function expirePendingClaim is invoked but the user holds - /// no entry past its `mintedAt + reservationDuration` deadline. - /// @param user Address whose entries are being inspected. - error PendingClaimNotExpired(address user); - /// @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 @@ -258,10 +251,10 @@ interface IDotnsPopController is IDotnsController { /// 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 emits @custom:emits LiteNameReserved and @custom:emits NameRegistered; - /// on a cold-path mint it emits @custom:emits LiteNameReserved and + /// `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 claimLabelStore when the user settles. The base-name leg only runs + /// @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), @@ -270,10 +263,10 @@ interface IDotnsPopController is IDotnsController { /// `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 (emitting @custom:emits ReservationExpired for each one), + /// 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 (emitting - /// @custom:emits ReservationQueued). The enqueue rejects with @custom:reverts + /// 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 @@ -326,11 +319,11 @@ interface IDotnsPopController is IDotnsController { /// 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 - /// emits @custom:emits LiteNameReserved and @custom:emits NameRegistered. On a cold-path - /// mint emits @custom:emits LiteNameReserved and @custom:emits PendingClaimStashed, with - /// @custom:emits NameRegistered deferred to @custom:function claimLabelStore when the user - /// settles. Cross-chain callers pass the ABI-encoded lite-registration tuple as the call's - /// payload, which Solidity decodes directly. + /// @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; @@ -365,9 +358,9 @@ interface IDotnsPopController is IDotnsController { /// 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 emits @custom:emits BaseNameClaimed; a non-claim silently relinquishes any - /// pending entry the user holds and emits @custom:emits StandaloneNameRegistered. Advancing - /// the queue head past expired entries emits @custom:emits ReservationExpired for each + /// 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 @@ -376,14 +369,14 @@ interface IDotnsPopController is IDotnsController { /// 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). Emits @custom:emits LiteToFullLinked + /// (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 - /// claimLabelStore. Cross-chain callers pass the ABI-encoded full-registration tuple as + /// 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; @@ -410,7 +403,7 @@ interface IDotnsPopController is IDotnsController { /// @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 emits @custom:emits ReservationExpired for every expired entry reaped from the + /// 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. @@ -420,7 +413,7 @@ interface IDotnsPopController is IDotnsController { /// @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 emit @custom:emits ReservationExpired for any + /// head, head advancement may additionally @custom:emits ReservationExpired for any /// stale entries reaped behind it. function relinquishReservation() external; @@ -474,61 +467,92 @@ interface IDotnsPopController is IDotnsController { view returns (UserReservation memory reservation); - /// @notice Settles the caller's deferred bindings by writing every stashed label into - /// the caller's `LabelStore`, deploying the store first if the caller doesn't yet - /// have one. - /// @dev User-signed entrypoint: `pallet-revive` charges any `LabelStore` storage - /// deposit against `msg.sender`'s balance through the runtime's configured deposit - /// backend. This is the only path that can create the store, because the Root gateway - /// origin cannot instantiate contracts. Reverts with @custom:reverts NoPendingClaim when - /// the caller holds no live stashed entries. Reuses any existing `LabelStore` returned by - /// the factory (settling via this controller after a concurrent public-flow mint, or - /// settling twice through this controller, both find a live store and skip deployment), - /// otherwise deploys a fresh store via the protocol-registered factory. Writes each live - /// label keyed by its `node` (namehash), clears the pending-claim entries, and emits - /// @custom:emits PendingClaimSettled and @custom:emits NameRegistered per settled name. - /// Chat-key and lite-link records are not touched here; they are persisted on the PoP - /// resolver at mint time, not at settlement. - function claimLabelStore() external; - - /// @notice Gateway-driven variant of @custom:function claimLabelStore for split workflows. - /// @dev Callable only via the registered PoP gateway. It settles the pending LabelStore claim - /// for `user` without requiring a user transaction. The user-signed - /// @custom:function claimLabelStore remains as a permissioned-by-origin fallback if gateway - /// dispatch fails. - /// @param user Account whose pending claim should be settled. - function claimLabelStoreFor(address user) external; - - /// @notice Permissionlessly reaps a user's deferred bindings that sat unsettled past - /// `reservationDuration`. - /// @dev Permissionless on purpose: anyone (typically a UI or a bot) can poke stale - /// entries so the user's pile cannot grow without bound. Sweeps every expired entry, - /// leaving any still-live ones in place; the user is removed from the enumeration set - /// only when no entries remain. Reverts with @custom:reverts NoPendingClaim when the - /// user holds no entries and with @custom:reverts PendingClaimNotExpired when none of - /// the held entries have lapsed. Emits @custom:emits PendingClaimExpired per swept name. - /// @param user Address whose pending claims are being swept. - function expirePendingClaim(address user) external; - - /// @notice Returns `user`'s pending-claim entries. - /// @dev An empty array means the user has no pending claims. A user accumulates one - /// entry per deferred name until a signed-origin @custom:function claimLabelStore - /// settles them. - /// @param user Account whose pending claims are being read. - /// @return claims Per-user pending-claim entries; see @custom:struct PendingClaim. - function pendingClaims(address user) external view returns (PendingClaim[] memory claims); + /// @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 and fully expired users - /// are removed from the enumeration set so off-chain consumers can page through every - /// stalled user without filtering. + /// @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. + /// 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. diff --git a/contracts/registrars/IDotnsPopLens.sol b/contracts/registrars/IDotnsPopLens.sol new file mode 100644 index 000000000..c0eb62624 --- /dev/null +++ b/contracts/registrars/IDotnsPopLens.sol @@ -0,0 +1,156 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.34; + +import {IPopRules} from "../pop/IPopRules.sol"; + +/// @title IDotnsPopLens +/// @notice Read-only view over PoP identity data, composed from the controller, the registrar, +/// the store factory, the PoP resolver, and PopRules. +/// @dev Holds no state of its own beyond the protocol registry it resolves siblings through, and +/// takes no part in issuance. It exists so the query surface lives outside the controller, which +/// keeps the controller within the contract-size limit and keeps ownership on the registrar. +/// @custom:security-contact admin@parity.io +interface IDotnsPopLens { + /// @notice One row in a per-account name listing: the name and the node used to look it up. + /// @dev Computed on read; not stored. `settled` is false while the name still sits in the + /// temporary pending-claim queue and true once its label is written into a `LabelStore`. + /// `deadline` is the pending settlement deadline (`mintedAt + reservationDuration`) and is + /// zero for a settled name. + /// @param node namehash of the name; the key for chat-key, link, and detail lookups. + /// @param label Full name string. + /// @param settled Whether the label is written into a `LabelStore`. + /// @param deadline Pending settlement deadline, or zero when settled. + struct Name { + bytes32 node; + string label; + bool settled; + uint64 deadline; + } + + /// @notice The full on-chain record for a single name, gathered from the registrar, the PoP + /// resolver, and PopRules in one read. + /// @dev Computed on read; not stored. Never reverts on an unminted or unsettled name: absent + /// fields read as zero or empty. `tier` classifies the label shape (the tier the name + /// requires), not the owner's personhood. `fullClaim` is keyed by the lite labelhash, which + /// cannot be recovered from a node alone, so it is populated by @custom:function nameDetail + /// and left zero by @custom:function nameDetailByNode unless the label is independently + /// resolvable. + /// @param node namehash of the name. + /// @param label Full name string, or empty when the name is unminted or its claim is unsettled. + /// @param owner Current registrar owner, or the zero address when the name does not exist. + /// @param exists Whether the name is minted. + /// @param settled Whether the label is written into the current owner's `LabelStore`. + /// @param tier PopRules classification of the label. + /// @param chatKey Chat-key bytes recorded on the PoP resolver for the node. + /// @param liteLink For a full name, the linked lite labelhash; zero otherwise. + /// @param fullClaim For a lite name, the promoted full node; zero otherwise or when + /// unresolvable from a node. + struct NameDetail { + bytes32 node; + string label; + address owner; + bool exists; + bool settled; + IPopRules.PopStatus tier; + bytes chatKey; + bytes32 liteLink; + bytes32 fullClaim; + } + + /// @notice An account-level summary of PoP state, gathered in one read. + /// @dev Computed on read; not stored, and never reverts. Name counts are excluded because + /// counting scans the account's holdings; read them with @custom:function liteNameCountOf and + /// @custom:function fullNameCountOf when required. The account's personhood tier is read + /// separately via @custom:function IPopRules.personhoodOf, which consults the personhood + /// precompile and so does not belong in this precompile-free summary. + /// @param hasLabelStore Whether the account has a deployed `LabelStore`. + /// @param pendingClaimCount Number of claims still staged in the pending queue. + /// @param reservationLabelhash The base label the account holds a live reservation on, or + /// zero when none. + struct PopProfile { + bool hasLabelStore; + uint256 pendingClaimCount; + bytes32 reservationLabelhash; + } + + /// @notice The protocol registry the lens resolves siblings through. + /// @return registry The protocol registry address. + function protocolRegistry() external view returns (address registry); + + /// @notice Lists the lite-person names currently owned by `user`. + /// @dev Reads the user's `LabelStore` labels and pending claims, keeps the lite-person + /// shaped ones, and re-checks each against `registrar.ownerOf` so a name transferred away + /// drops out and a name transferred in shows under its current owner. Ordering follows the + /// store then the pending queue. An `offset` past the end returns an empty array rather than + /// reverting, and a short return means the slice ended. A gateway name transferred before it + /// settles has its label in no store, so it cannot appear here and is reachable only by node + /// via @custom:function nameDetailByNode. Gas grows with the account's holdings, so call it + /// off-chain. A page holds at most `DotnsConstants.MAX_PAGE_SIZE` entries, and the pending + /// portion covers up to that many staged claims. + /// @param user Account whose lite names are listed. + /// @param offset Start index into the filtered sequence. + /// @param limit Maximum entries to return. + /// @return names Page of the account's lite names; see @custom:struct Name. + function liteNamesOf( + address user, + uint256 offset, + uint256 limit + ) + external + view + returns (Name[] memory names); + + /// @notice Lists the full-person names currently owned by `user`. + /// @dev Same ownership-verified read as @custom:function liteNamesOf, keeping base-shaped + /// labels instead of lite-shaped ones. + /// @param user Account whose full names are listed. + /// @param offset Start index into the filtered sequence. + /// @param limit Maximum entries to return. + /// @return names Page of the account's full names; see @custom:struct Name. + function fullNamesOf( + address user, + uint256 offset, + uint256 limit + ) + external + view + returns (Name[] memory names); + + /// @notice Counts the lite-person names currently owned by `user`. + /// @dev Uses the same ownership-verified read as @custom:function liteNamesOf; counting scans + /// the account's holdings, so gas grows with them. Call it off-chain. + /// @param user Account whose lite names are counted. + /// @return count Number of lite names currently owned. + function liteNameCountOf(address user) external view returns (uint256 count); + + /// @notice Counts the full-person names currently owned by `user`. + /// @dev Uses the same ownership-verified read as @custom:function fullNamesOf; counting scans + /// the account's holdings, so gas grows with them. Call it off-chain. + /// @param user Account whose full names are counted. + /// @return count Number of full names currently owned. + function fullNameCountOf(address user) external view returns (uint256 count); + + /// @notice Returns the full on-chain record for a name given its label string. + /// @dev Resolves the node internally, so a caller holding only the string needs no namehash + /// implementation. Never reverts on an unknown name: absent fields read as zero or empty. + /// This overload can populate `fullClaim` because it holds the label and so its labelhash. + /// @param name Bare DNS label (no TLD). + /// @return detail The name's record; see @custom:struct NameDetail. + function nameDetail(string calldata name) external view returns (NameDetail memory detail); + + /// @notice Returns the full on-chain record for a name given its node. + /// @dev The node cannot be inverted to its labelhash, so `fullClaim` is populated only when + /// the label is independently resolvable from the node and reads zero otherwise; every other + /// field is resolved directly. Never reverts on an unknown node. + /// @param node namehash of the name. + /// @return detail The name's record; see @custom:struct NameDetail. + function nameDetailByNode(bytes32 node) external view returns (NameDetail memory detail); + + /// @notice Returns an account-level summary of a user's PoP state. + /// @dev O(1) facts only; lite and full name counts are read separately via + /// @custom:function liteNameCountOf and @custom:function fullNameCountOf because those scan + /// the account's holdings. Never reverts. + /// @param user Account being summarised. + /// @return profile The account summary; see @custom:struct PopProfile. + function profileOf(address user) external view returns (PopProfile memory profile); +} diff --git a/contracts/utils/DotnsConstants.sol b/contracts/utils/DotnsConstants.sol index 93e280d29..ed8931aaa 100644 --- a/contracts/utils/DotnsConstants.sol +++ b/contracts/utils/DotnsConstants.sol @@ -39,6 +39,11 @@ library DotnsConstants { /// against a new constant. uint256 internal constant RENT_PRICE = 10 ether; + /// @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 Operational role allowed to manage the public controller whitelist. /// @dev Holders can grant or revoke whitelist entries, but cannot upgrade contracts /// or change protocol configuration. @@ -129,6 +134,14 @@ library DotnsConstants { /// 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 PopRules. 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 diff --git a/deployments/paseo-assethub/420420417.json b/deployments/paseo-assethub/420420417.json index 08b85d0ee..86d7e43c9 100644 --- a/deployments/paseo-assethub/420420417.json +++ b/deployments/paseo-assethub/420420417.json @@ -1 +1 @@ -{"Create3Factory":"0x8533c79E058c5a6489CAFeCA86dc600E029D75f5","DotnsContentResolver":"0x7F74D7CD50f5a834270E2ad395a01b01891AB37d","DotnsNameEscrow":"0x4881Afb78e7C908cAe818168B926229D93376520","DotnsNameWhitelist":"0x420166cD67Ca0233094E492a4BbA67045eD7C38C","DotnsPopController":"0xCC932348606cc1f3318cADeC5A5Cd2CA447f8a4b","DotnsPopResolver":"0xDaC984884EcA8Fc44011f1D6C49B27828390A72B","DotnsProtocolRegistry":"0xD19e3D0C97CF501125a04A97405e3e6592fa846E","DotnsRegistrar":"0x4f06E818Ba3d987704fd91cf3d868E4b019106Ab","DotnsRegistrarController":"0xBdaA01bD1bA67d709F2b1fF286Da0d854977EA30","DotnsRegistry":"0xf34054fd76BbF85f216cf9908226D5f0A72E50CA","DotnsResolver":"0xbd1165E549DF96F083c0A16f61590927bC187009","DotnsReverseResolver":"0xee3883d7eB60Ee9BCD7F3bcD8f2f05302A9Cc035","LabelStoreBeacon":"0xb57Ebc2e7085616d4906D1fE49af1cE13f7dffeF","Multicall3":"0xB4468000abD87D3c56cbFBd153161223D7b109e5","PopRules":"0x747B456bE03aec0b42bd85C51513730FBD45DA31","RootGatewayDispatcher":"0xa889CCA3Fb4B07b98a11cc54C10f13dDA20bc3db","StoreFactory":"0x709A027F446a9e2a4BB9cb9a9c754435b19e32B7","UserStoreBeacon":"0xb7C995601679840d36F37E86DB2d7dF30797eC5C","_seed":"0x0000000000000000000000000000000000000000"} +{"Create3Factory":"0x8533c79E058c5a6489CAFeCA86dc600E029D75f5","DotnsContentResolver":"0x7F74D7CD50f5a834270E2ad395a01b01891AB37d","DotnsNameEscrow":"0x4881Afb78e7C908cAe818168B926229D93376520","DotnsNameWhitelist":"0x420166cD67Ca0233094E492a4BbA67045eD7C38C","DotnsPopController":"0xCC932348606cc1f3318cADeC5A5Cd2CA447f8a4b","DotnsPopLens":"0xfe5A45f7fD58D1A6FE09455DB799405b1dcE9411","DotnsPopResolver":"0xDaC984884EcA8Fc44011f1D6C49B27828390A72B","DotnsProtocolRegistry":"0xD19e3D0C97CF501125a04A97405e3e6592fa846E","DotnsRegistrar":"0x4f06E818Ba3d987704fd91cf3d868E4b019106Ab","DotnsRegistrarController":"0xBdaA01bD1bA67d709F2b1fF286Da0d854977EA30","DotnsRegistry":"0xf34054fd76BbF85f216cf9908226D5f0A72E50CA","DotnsResolver":"0xbd1165E549DF96F083c0A16f61590927bC187009","DotnsReverseResolver":"0xee3883d7eB60Ee9BCD7F3bcD8f2f05302A9Cc035","LabelStoreBeacon":"0xb57Ebc2e7085616d4906D1fE49af1cE13f7dffeF","Multicall3":"0xB4468000abD87D3c56cbFBd153161223D7b109e5","PopRules":"0x747B456bE03aec0b42bd85C51513730FBD45DA31","RootGatewayDispatcher":"0xa889CCA3Fb4B07b98a11cc54C10f13dDA20bc3db","StoreFactory":"0x709A027F446a9e2a4BB9cb9a9c754435b19e32B7","UserStoreBeacon":"0xb7C995601679840d36F37E86DB2d7dF30797eC5C","_seed":"0x0000000000000000000000000000000000000000"} diff --git a/scripts/deploy/DeployPopSystem.s.sol b/scripts/deploy/DeployPopSystem.s.sol index 36e0d0da0..4e3ae214a 100644 --- a/scripts/deploy/DeployPopSystem.s.sol +++ b/scripts/deploy/DeployPopSystem.s.sol @@ -31,6 +31,7 @@ contract DeployPopSystem is BaseDeployer { _deployPopResolver(owner, protocolRegistry); address popController = _deployPopController(owner, protocolRegistry); _deployGatewayDispatcher(owner, popController); + _deployPopLens(owner, protocolRegistry); saveDeployments(); @@ -95,4 +96,24 @@ contract DeployPopSystem is BaseDeployer { "RootGatewayDispatcher" ); } + + /// @notice Deploys the read-only PoP lens bound to the protocol registry and records it on + /// the manifest for the wire-up stage to register. + /// @dev A plain CREATE3 deployment, like the dispatcher: the lens holds no state beyond the + /// registry it resolves siblings through, so it needs no proxy. Registry registration is + /// the wire-up stage's job. + /// @param owner Broadcasting account. + /// @param protocolRegistry Protocol registry the lens reads through. + /// @return lens Address of the deployed lens. + function _deployPopLens( + address owner, + address protocolRegistry + ) + internal + returns (address lens) + { + lens = _broadcastDeployCreate3( + owner, "DotnsPopLens.sol:DotnsPopLens", abi.encode(protocolRegistry), "DotnsPopLens" + ); + } } diff --git a/scripts/deploy/DotnsDeployer.s.sol b/scripts/deploy/DotnsDeployer.s.sol index cebf5b2a1..adc522d22 100644 --- a/scripts/deploy/DotnsDeployer.s.sol +++ b/scripts/deploy/DotnsDeployer.s.sol @@ -82,6 +82,7 @@ contract DotnsDeployer is BaseDeployer { address nameEscrow; address popResolver; address popController; + address popLens; address nameWhitelist; } @@ -128,6 +129,7 @@ contract DotnsDeployer is BaseDeployer { _deployRegistrarController(OWNER, deployment.protocolRegistry); deployment.popResolver = _deployPopResolver(OWNER, deployment.protocolRegistry); deployment.popController = _deployPopController(OWNER, deployment.protocolRegistry); + deployment.popLens = _deployPopLens(OWNER, deployment.protocolRegistry); deployment.nameWhitelist = _deployNameWhitelist(OWNER, deployment.protocolRegistry); _authoriseControllers(OWNER, deployment); @@ -357,6 +359,21 @@ contract DotnsDeployer is BaseDeployer { dotnsPopController = DotnsPopController(proxy); } + function _deployPopLens( + address owner, + address protocolRegistryProxy + ) + internal + returns (address lens) + { + lens = _broadcastDeployCreate3( + owner, + "DotnsPopLens.sol:DotnsPopLens", + abi.encode(protocolRegistryProxy), + "DotnsPopLens" + ); + } + function _deployNameWhitelist( address owner, address protocolRegistryProxy @@ -395,6 +412,7 @@ contract DotnsDeployer is BaseDeployer { protocolRegistry.set(DotnsConstants.NAME_ESCROW, deployment.nameEscrow); protocolRegistry.set(DotnsConstants.POP_CONTROLLER, deployment.popController); protocolRegistry.set(DotnsConstants.POP_RESOLVER, deployment.popResolver); + protocolRegistry.set(DotnsConstants.POP_LENS, deployment.popLens); protocolRegistry.set(DotnsConstants.NAME_WHITELIST, deployment.nameWhitelist); vm.stopBroadcast(); console.log("Protocol registry keys set"); @@ -516,6 +534,7 @@ contract DotnsDeployer is BaseDeployer { _assertKey(DotnsConstants.NAME_ESCROW, deployment.nameEscrow, "Key: nameEscrow"); _assertKey(DotnsConstants.POP_CONTROLLER, deployment.popController, "Key: popController"); _assertKey(DotnsConstants.POP_RESOLVER, deployment.popResolver, "Key: popResolver"); + _assertKey(DotnsConstants.POP_LENS, deployment.popLens, "Key: popLens"); } function _assertKey(bytes32 key, address expected, string memory label) internal view { diff --git a/scripts/deploy/WireDeployments.s.sol b/scripts/deploy/WireDeployments.s.sol index 59c5f1d5f..619d1bade 100644 --- a/scripts/deploy/WireDeployments.s.sol +++ b/scripts/deploy/WireDeployments.s.sol @@ -50,6 +50,7 @@ contract WireDeployments is BaseDeployer { address nameWhitelist; address popResolver; address popController; + address popLens; address rootGatewayDispatcher; } @@ -88,6 +89,7 @@ contract WireDeployments is BaseDeployer { addr.nameWhitelist = _readAddress("DotnsNameWhitelist"); addr.popResolver = _readAddress("DotnsPopResolver"); addr.popController = _readAddress("DotnsPopController"); + addr.popLens = _readAddress("DotnsPopLens"); addr.rootGatewayDispatcher = _readAddress("RootGatewayDispatcher"); } @@ -116,6 +118,7 @@ contract WireDeployments is BaseDeployer { registry.set(DotnsConstants.MULTICALL3, addr.multicall3); registry.set(DotnsConstants.POP_CONTROLLER, addr.popController); registry.set(DotnsConstants.POP_RESOLVER, addr.popResolver); + registry.set(DotnsConstants.POP_LENS, addr.popLens); registry.set(DotnsConstants.POP_GATEWAY, addr.rootGatewayDispatcher); vm.stopBroadcast(); console.log("Protocol registry keys set"); @@ -212,6 +215,7 @@ contract WireDeployments is BaseDeployer { registry.get(DotnsConstants.POP_CONTROLLER) == addr.popController, "Key: popController" ); require(registry.get(DotnsConstants.POP_RESOLVER) == addr.popResolver, "Key: popResolver"); + require(registry.get(DotnsConstants.POP_LENS) == addr.popLens, "Key: popLens"); require( registry.get(DotnsConstants.POP_GATEWAY) == addr.rootGatewayDispatcher, "Key: popGateway" diff --git a/test/base/BaseDotns.t.sol b/test/base/BaseDotns.t.sol index e31a1ccfa..03866f3ad 100644 --- a/test/base/BaseDotns.t.sol +++ b/test/base/BaseDotns.t.sol @@ -13,6 +13,8 @@ import { DotnsPopController, IDotnsPopController } from "../../contracts/registrars/DotnsPopController.sol"; +import {DotnsPopLens} from "../../contracts/registrars/DotnsPopLens.sol"; +import {IDotnsPopLens} from "../../contracts/registrars/IDotnsPopLens.sol"; import {RootGatewayDispatcher} from "../../contracts/registrars/RootGatewayDispatcher.sol"; import {IDotnsController} from "../../contracts/registrars/IDotnsController.sol"; import {DotnsRegistry} from "../../contracts/registry/DotnsRegistry.sol"; @@ -85,6 +87,9 @@ abstract contract BaseDotns is Test { /// @notice Deployed PoP controller instance (gateway-driven lite/full issuance). DotnsPopController public dotnsPopController; + /// @notice Deployed PoP lens instance (read-only view over PoP identity data). + DotnsPopLens public dotnsPopLens; + /// @notice Test stand-in for the Root gateway dispatcher. /// @dev Registered on the protocol registry under the PoP gateway key /// during setUp. Tests that exercise gated PoP entrypoints prank as @@ -316,6 +321,12 @@ abstract contract BaseDotns is Test { // coverage lives in test/unit/registrar/RootGatewayDispatcher.t.sol. protocolRegistry.set(DotnsConstants.POP_GATEWAY, popGateway); + // Deploy the read-only lens last, once every sibling key it resolves + // (POP_CONTROLLER, REGISTRAR, STORE_FACTORY, POP_RESOLVER, POP_RULES) is + // set on the registry. + dotnsPopLens = new DotnsPopLens(registry); + vm.label(address(dotnsPopLens), "DotnsPopLens"); + vm.stopPrank(); vm.warp(block.timestamp + 365 days); // Default every account to `None` (NoStatus) on the personhood @@ -475,13 +486,9 @@ abstract contract BaseDotns is Test { reservedBaseLabel: reservedBaseLabel }) ); - IDotnsPopController.PendingClaim[] memory pending = dotnsPopController.pendingClaims(user); - if ( - pending.length != 0 - && pending[0].mintedAt + dotnsPopController.reservationDuration() > block.timestamp - ) { + if (dotnsPopController.pendingClaimCountOf(user) != 0) { vm.prank(user); - dotnsPopController.claimLabelStore(); + dotnsPopController.settlePendingClaims(user, type(uint256).max); } } diff --git a/test/fuzz/registrar/DotnsPopControllerFuzz.t.sol b/test/fuzz/registrar/DotnsPopControllerFuzz.t.sol index 558017451..34eb2a412 100644 --- a/test/fuzz/registrar/DotnsPopControllerFuzz.t.sol +++ b/test/fuzz/registrar/DotnsPopControllerFuzz.t.sol @@ -235,7 +235,7 @@ contract DotnsPopControllerFuzz is BaseDotns { }) ); vm.prank(ed); - dotnsPopController.claimLabelStore(); + dotnsPopController.settlePendingClaims(ed, type(uint256).max); link = IDotnsPopController.Link({ kind: IDotnsPopController.LinkKind.LiteUsername, liteLabel: LITE_LABEL_A_DOTTED, @@ -341,7 +341,8 @@ contract DotnsPopControllerFuzz is BaseDotns { IDotnsPopController.LiteRegistration({liteLabel: label, user: ed, chatKey: chatKey}) ); - IDotnsPopController.PendingClaim[] memory pending = dotnsPopController.pendingClaims(ed); + IDotnsPopController.PendingClaim[] memory pending = + dotnsPopController.pendingClaims(ed, 0, type(uint256).max); assertEq(pending[0].label, label); assertGt(pending[0].mintedAt, 0); assertEq(storeFactory.getLabelStore(ed), address(0)); @@ -351,7 +352,7 @@ contract DotnsPopControllerFuzz is BaseDotns { assertEq(dotnsPopResolver.chatKey(node), chatKey); } - function testFuzz_claimLabelStore_settles_label_and_chat_key_exactly( + function testFuzz_settle_settles_label_and_chat_key_exactly( uint8 suffix, bytes1 keySeed ) @@ -367,17 +368,17 @@ contract DotnsPopControllerFuzz is BaseDotns { ); vm.prank(ed); - dotnsPopController.claimLabelStore(); + dotnsPopController.settlePendingClaims(ed, type(uint256).max); bytes32 node = _nodeOf(label); address store = storeFactory.getLabelStore(ed); assertTrue(store != address(0)); assertEq(ILabelStore(store).getLabel(node), string.concat(label, protocolRegistry.tld())); assertEq(dotnsPopResolver.chatKey(node), chatKey); - assertEq(dotnsPopController.pendingClaims(ed).length, 0); + assertEq(dotnsPopController.pendingClaimCountOf(ed), 0); } - function testFuzz_pendingClaim_expiry_boundary_admits_or_lapses( + function testFuzz_settle_writes_label_regardless_of_age( uint64 duration, uint64 elapsed ) @@ -400,23 +401,23 @@ contract DotnsPopControllerFuzz is BaseDotns { }) ); - uint64 mintedAt = dotnsPopController.pendingClaims(ed)[0].mintedAt; + uint64 mintedAt = dotnsPopController.pendingClaims(ed, 0, 1)[0].mintedAt; vm.warp(uint256(mintedAt) + uint256(elapsed)); - if (elapsed <= duration) { - vm.expectRevert( - abi.encodeWithSelector(IDotnsPopController.PendingClaimNotExpired.selector, ed) - ); - dotnsPopController.expirePendingClaim(ed); - vm.prank(ed); - dotnsPopController.claimLabelStore(); - assertTrue(storeFactory.getLabelStore(ed) != address(0)); - } else { - vm.expectRevert(abi.encodeWithSelector(IDotnsPopController.NoPendingClaim.selector, ed)); - vm.prank(ed); - dotnsPopController.claimLabelStore(); - dotnsPopController.expirePendingClaim(ed); - assertEq(storeFactory.getLabelStore(ed), address(0)); - } + // Stores always settle: settlement writes the label into the store whether or not the + // reservation deadline has passed, so age never strands a claim. + vm.prank(ed); + (uint256 settledCount, bool moreRemaining) = + dotnsPopController.settlePendingClaims(ed, type(uint256).max); + assertEq(settledCount, 1); + assertFalse(moreRemaining); + + bytes32 node = _nodeOf(LITE_LABEL_A); + address store = storeFactory.getLabelStore(ed); + assertTrue(store != address(0)); + assertEq( + ILabelStore(store).getLabel(node), string.concat(LITE_LABEL_A, protocolRegistry.tld()) + ); + assertEq(dotnsPopController.pendingClaimCountOf(ed), 0); } } diff --git a/test/intergration/PopLifecycleFlow.t.sol b/test/intergration/PopLifecycleFlow.t.sol index 5b1dc591a..95ef722c7 100644 --- a/test/intergration/PopLifecycleFlow.t.sol +++ b/test/intergration/PopLifecycleFlow.t.sol @@ -116,12 +116,13 @@ contract PopLifecycleFlow is BaseDotns { // LabelStore write is deferred for cold-path users. assertEq(dotnsPopResolver.chatKey(liteNode), CHAT_KEY); - IDotnsPopController.PendingClaim[] memory pending = dotnsPopController.pendingClaims(ed); + IDotnsPopController.PendingClaim[] memory pending = + dotnsPopController.pendingClaims(ed, 0, type(uint256).max); assertEq(pending[0].label, LITE_LABEL); assertGt(pending[0].mintedAt, 0); vm.prank(ed); - dotnsPopController.claimLabelStore(); + dotnsPopController.settlePendingClaims(ed, type(uint256).max); address store = storeFactory.getLabelStore(ed); assertTrue(store != address(0)); @@ -129,24 +130,33 @@ contract PopLifecycleFlow is BaseDotns { ILabelStore(store).getLabel(liteNode), string.concat(LITE_LABEL, protocolRegistry.tld()) ); assertEq(dotnsPopResolver.chatKey(liteNode), CHAT_KEY); - assertEq(dotnsPopController.pendingClaims(ed).length, 0); + assertEq(dotnsPopController.pendingClaimCountOf(ed), 0); assertEq(dotnsPopController.pendingClaimUserCount(), 0); } - function test_reserve_expire_reserve_cycle_for_same_user() public { + function test_reserve_settle_reserve_cycle_for_same_user() public { _grantPopFull(ed); _gatewayReserveLiteName( IDotnsPopController.LiteRegistration({ liteLabel: LITE_LABEL, user: ed, chatKey: CHAT_KEY }) ); - uint64 firstMintedAt = dotnsPopController.pendingClaims(ed)[0].mintedAt; + uint64 firstMintedAt = dotnsPopController.pendingClaims(ed, 0, 1)[0].mintedAt; vm.warp(block.timestamp + DEFAULT_RESERVATION_DURATION + 1); - dotnsPopController.expirePendingClaim(ed); - assertEq(dotnsPopController.pendingClaims(ed).length, 0); + // Age never drops a claim: settling deploys the store and writes the first label rather + // than discarding it. + vm.prank(ed); + dotnsPopController.settlePendingClaims(ed, type(uint256).max); + assertEq(dotnsPopController.pendingClaimCountOf(ed), 0); assertEq(dotnsPopController.pendingClaimUserCount(), 0); + address store = storeFactory.getLabelStore(ed); + assertEq( + ILabelStore(store).getLabel(_nodeOf(LITE_LABEL)), + string.concat(LITE_LABEL, protocolRegistry.tld()) + ); + string memory secondLabel = "aliceli02"; bytes memory secondKey = hex"04beefcafedeadbeefcafedeadbeefcafedeadbeefcafedeadbeefcafedeadbeefcafedeadbeefcafedeadbeefcafedeadbeefcafedeadbeefcafedeadbeefcafe"; @@ -156,11 +166,15 @@ contract PopLifecycleFlow is BaseDotns { }) ); - IDotnsPopController.PendingClaim[] memory second = dotnsPopController.pendingClaims(ed); - assertEq(second[0].label, secondLabel); + // The user is warm now, so the second reservation writes straight into the store. + assertEq( + ILabelStore(store).getLabel(_nodeOf(secondLabel)), + string.concat(secondLabel, protocolRegistry.tld()) + ); assertEq(dotnsPopResolver.chatKey(_nodeOf(secondLabel)), secondKey); - assertGt(second[0].mintedAt, firstMintedAt); - assertEq(dotnsPopController.pendingClaimUserCount(), 1); + assertGt(firstMintedAt, 0); + assertEq(dotnsPopController.pendingClaimCountOf(ed), 0); + assertEq(dotnsPopController.pendingClaimUserCount(), 0); } function test_transfer_of_token_with_live_pending_claim_does_not_move_claim() public { @@ -181,13 +195,14 @@ contract PopLifecycleFlow is BaseDotns { // the registrar's transfer-sync path because `ed` has no store yet to // copy the label from; this is current `DotnsRegistrar._update` // behaviour and is independent of the pending-claim mapping. - IDotnsPopController.PendingClaim[] memory pending = dotnsPopController.pendingClaims(ed); + IDotnsPopController.PendingClaim[] memory pending = + dotnsPopController.pendingClaims(ed, 0, type(uint256).max); assertEq(pending[0].label, LITE_LABEL); assertGt(pending[0].mintedAt, 0); - assertEq(dotnsPopController.pendingClaims(tiago).length, 0); + assertEq(dotnsPopController.pendingClaimCountOf(tiago), 0); vm.prank(ed); - dotnsPopController.claimLabelStore(); + dotnsPopController.settlePendingClaims(ed, type(uint256).max); address edStore = storeFactory.getLabelStore(ed); assertTrue(edStore != address(0)); bytes32 node = _nodeOf(LITE_LABEL); @@ -196,7 +211,7 @@ contract PopLifecycleFlow is BaseDotns { ); } - function test_lapsed_pending_claim_is_swept_without_deploying_store() public { + function test_lapsed_pending_claim_settles_and_deploys_store() public { _grantPopFull(ed); _gatewayReserveLiteName( IDotnsPopController.LiteRegistration({ @@ -206,12 +221,18 @@ contract PopLifecycleFlow is BaseDotns { vm.warp(block.timestamp + DEFAULT_RESERVATION_DURATION + 1); - // Permissionless sweep from a stranger address. - vm.prank(makeAddr("sweeper")); - dotnsPopController.expirePendingClaim(ed); + // Permissionless settlement from a stranger address: age never drops the claim, so the + // store is deployed for the beneficiary and the label is written and readable. + bytes32 liteNode = _nodeOf(LITE_LABEL); + vm.prank(makeAddr("settler")); + dotnsPopController.settlePendingClaims(ed, type(uint256).max); - assertEq(dotnsPopController.pendingClaims(ed).length, 0); - assertEq(storeFactory.getLabelStore(ed), address(0)); + address store = storeFactory.getLabelStore(ed); + assertTrue(store != address(0)); + assertEq( + ILabelStore(store).getLabel(liteNode), string.concat(LITE_LABEL, protocolRegistry.tld()) + ); + assertEq(dotnsPopController.pendingClaimCountOf(ed), 0); assertEq(dotnsPopController.pendingClaimUserCount(), 0); } diff --git a/test/intergration/StoreIntegration.t.sol b/test/intergration/StoreIntegration.t.sol index 8cb6b0846..c1ba451af 100644 --- a/test/intergration/StoreIntegration.t.sol +++ b/test/intergration/StoreIntegration.t.sol @@ -66,7 +66,7 @@ contract StoreIntegrationTest is BaseDotns { ); vm.prank(ed); - dotnsPopController.claimLabelStore(); + dotnsPopController.settlePendingClaims(ed, type(uint256).max); address storeAddr = storeFactory.getLabelStore(ed); assertTrue(storeAddr != address(0)); diff --git a/test/invariant/registrar/DotnsPopControllerInvariant.t.sol b/test/invariant/registrar/DotnsPopControllerInvariant.t.sol index 13a2b5ab5..cba54edef 100644 --- a/test/invariant/registrar/DotnsPopControllerInvariant.t.sol +++ b/test/invariant/registrar/DotnsPopControllerInvariant.t.sol @@ -38,7 +38,7 @@ contract DotnsPopControllerInvariant is BaseDotns { selectors[4] = handler.claim.selector; selectors[5] = handler.reLink.selector; selectors[6] = handler.settlePendingClaim.selector; - selectors[7] = handler.sweepPendingClaim.selector; + selectors[7] = handler.settlePendingClaimByThirdParty.selector; targetSelector(FuzzSelector({addr: address(handler), selectors: selectors})); } @@ -183,7 +183,7 @@ contract DotnsPopControllerInvariant is BaseDotns { for (uint256 i = 0; i < enumerated.length; i++) { assertGt( - dotnsPopController.pendingClaims(enumerated[i]).length, + dotnsPopController.pendingClaimCountOf(enumerated[i]), 0, "enumerated user has no pending claim" ); @@ -192,7 +192,7 @@ contract DotnsPopControllerInvariant is BaseDotns { uint256 seen = handler.pendingClaimActorsSeenCount(); for (uint256 i = 0; i < seen; i++) { address actor = handler.pendingClaimActorsSeen(i); - if (dotnsPopController.pendingClaims(actor).length == 0) continue; + if (dotnsPopController.pendingClaimCountOf(actor) == 0) continue; bool found; for (uint256 j = 0; j < enumerated.length; j++) { if (enumerated[j] == actor) { @@ -205,8 +205,9 @@ contract DotnsPopControllerInvariant is BaseDotns { } /// @notice A user with a deployed `LabelStore` cannot simultaneously hold a - /// pending claim: settlement deploys the store and clears the - /// entry in the same call, expiry clears without deploying. + /// pending claim: every settlement in this suite drains the whole + /// queue and deploys the store in the same call, and a warm user's + /// later gateway mints write straight into the store without stashing. function invariant_pending_claim_and_label_store_are_mutually_exclusive() public view { IStoreFactory factory = IStoreFactory(address(storeFactory)); uint256 seen = handler.pendingClaimActorsSeenCount(); @@ -214,7 +215,7 @@ contract DotnsPopControllerInvariant is BaseDotns { address actor = handler.pendingClaimActorsSeen(i); if (factory.getLabelStore(actor) == address(0)) continue; assertEq( - dotnsPopController.pendingClaims(actor).length, + dotnsPopController.pendingClaimCountOf(actor), 0, "actor has both store and pending claim" ); @@ -230,30 +231,35 @@ contract DotnsPopControllerInvariant is BaseDotns { assertEq(page.length, count, "count != enumeration length"); } - /// @notice An expired pending-claim entry can always be swept. Asserts that - /// for every tracked actor whose `mintedAt` has lapsed past - /// `reservationDuration`, a permissionless `expirePendingClaim` - /// clears the entry without revert. - /// @dev Cannot mutate state inside an invariant assertion, so the property - /// is asserted indirectly: if the entry is past its deadline, the - /// handler has had opportunities to sweep it during the run; under a - /// sufficient depth the post-state must show such entries cleared. - /// A stronger formulation would require a depth-bounded sweep - /// guarantee, which is out of scope for view-only invariants. - function invariant_no_stuck_lapsed_pending_claims() public view { - uint64 duration = dotnsPopController.reservationDuration(); - if (duration == 0) return; - uint256 seen = handler.pendingClaimActorsSeenCount(); - for (uint256 i = 0; i < seen; i++) { - address actor = handler.pendingClaimActorsSeen(i); + /// @notice Settlement writes labels and never strands a minted name. Every + /// minted token is either settled, with its label readable in the + /// owner's store, or still staged in the owner's pending queue. + /// Age never drops an entry, so a minted name is never left in + /// neither place. + /// @dev The stranded case the old model allowed, a lapsed entry swept out of + /// the queue with nothing written, is now unreachable: settlement always + /// writes the label regardless of the reservation deadline. + function invariant_settled_names_written_and_never_stranded() public view { + uint256 n = handler.mintedLiteTokenCount(); + for (uint256 i = 0; i < n; i++) { + uint256 tokenId = handler.mintedLiteTokenIds(i); + if (!dotnsRegistrar.exists(tokenId)) continue; + + // A settled name reads its label back from the owner's store. + if (bytes(dotnsRegistrar.labelOf(tokenId)).length != 0) continue; + + // Otherwise the name must still be staged in its owner's pending queue. + address nameOwner = dotnsRegistrar.ownerOf(tokenId); IDotnsPopController.PendingClaim[] memory pending = - dotnsPopController.pendingClaims(actor); + dotnsPopController.pendingClaims(nameOwner, 0, type(uint256).max); + bool staged; for (uint256 j = 0; j < pending.length; j++) { - uint256 deadline = uint256(pending[j].mintedAt) + uint256(duration); - assertLe( - block.timestamp, deadline + uint256(duration), "lapsed entry stuck past grace" - ); + if (_nodeOf(pending[j].label) == bytes32(tokenId)) { + staged = true; + break; + } } + assertTrue(staged, "minted name neither settled nor staged"); } } } diff --git a/test/invariant/registrar/PopControllerHandler.t.sol b/test/invariant/registrar/PopControllerHandler.t.sol index d3e0a4918..9e25be520 100644 --- a/test/invariant/registrar/PopControllerHandler.t.sol +++ b/test/invariant/registrar/PopControllerHandler.t.sol @@ -219,7 +219,7 @@ contract PopControllerHandler is Test { // registration below takes the warm path; the pending-claim mechanism // forbids a second stash for the same user. vm.prank(actor); - try CONTROLLER.claimLabelStore() {} catch {} + try CONTROLLER.settlePendingClaims(actor, type(uint256).max) {} catch {} IDotnsPopController.Link memory link = IDotnsPopController.Link({ kind: IDotnsPopController.LinkKind.LiteUsername, liteLabel: liteLabel, chatKey: "" @@ -295,22 +295,28 @@ contract PopControllerHandler is Test { vm.warp(block.timestamp + (secondsForward % (30 days))); } - /// @notice Settles a pending claim for the picked actor. + /// @notice Settles the picked actor's own pending claims. /// @dev The actor signs the call; `pallet-revive` charges the storage - /// deposit against their balance in production. Swallowed reverts - /// cover the no-pending-claim and lapsed-entry branches. + /// deposit against their balance in production. Settlement never reverts + /// on an empty or lapsed queue, so no branch needs swallowing; the + /// try/catch guards only against unrelated dispatch reverts. function settlePendingClaim(uint256 actorIndex) external { address actor = _actor(actorIndex); vm.prank(actor); - try CONTROLLER.claimLabelStore() {} catch {} + try CONTROLLER.settlePendingClaims(actor, type(uint256).max) {} catch {} } - /// @notice Permissionlessly sweeps an expired pending claim for the picked actor. - /// @dev Caller is the handler itself; the entrypoint is permissionless by - /// design so any address can clear a stale slot. - function sweepPendingClaim(uint256 actorIndex) external { + /// @notice Settles the picked actor's pending claims from a different actor. + /// @dev Settlement is permissionless: any account may settle another user's + /// claims and bears the cost. The settler is a distinct actor from the + /// beneficiary so the third-party path is exercised alongside the + /// self-settlement path above. + function settlePendingClaimByThirdParty(uint256 actorIndex, uint256 settlerIndex) external { address actor = _actor(actorIndex); - try CONTROLLER.expirePendingClaim(actor) {} catch {} + address settler = _actor(settlerIndex); + if (settler == actor) settler = _actor(settlerIndex + 1); + vm.prank(settler); + try CONTROLLER.settlePendingClaims(actor, type(uint256).max) {} catch {} } /// @notice Calls `reserveBaseName` through the typed or bytes overload. diff --git a/test/unit/registrar/DotnsPopController.t.sol b/test/unit/registrar/DotnsPopController.t.sol index 48817ca8e..b3952d67d 100644 --- a/test/unit/registrar/DotnsPopController.t.sol +++ b/test/unit/registrar/DotnsPopController.t.sol @@ -3,6 +3,7 @@ pragma solidity ^0.8.34; import {BaseDotns} from "../../base/BaseDotns.t.sol"; import {IDotnsPopController} from "../../../contracts/registrars/IDotnsPopController.sol"; +import {IDotnsPopLens} from "../../../contracts/registrars/IDotnsPopLens.sol"; import {IDotnsRegistrar} from "../../../contracts/registrars/IDotnsRegistrar.sol"; import { IDotnsRegistrarController @@ -10,6 +11,7 @@ import { import {IDotnsRegistry} from "../../../contracts/registry/IDotnsRegistry.sol"; import {IPopRules} from "../../../contracts/pop/IPopRules.sol"; import {ILabelStore} from "../../../contracts/store/ILabelStore.sol"; +import {DotnsConstants} from "../../../contracts/utils/DotnsConstants.sol"; import {IERC721} from "@openzeppelin/contracts/token/ERC721/IERC721.sol"; import {Vm} from "forge-std/Vm.sol"; @@ -984,7 +986,11 @@ contract DotnsPopControllerTests is BaseDotns { assertEq(holder, ed); } - function test_claimLabelStoreFor_reverts_for_non_gateway() public { + function test_third_party_settles_pending_claim_into_user_store() public { + // Settlement is permissionless: a third party who is neither the beneficiary nor the + // gateway can settle a user's pending claim, deploying the user's store and writing the + // stashed label. The settled name lands in the beneficiary's store, and the settlement + // event records the third party as the settler. _grantPopFull(ed); _gatewayReserveLiteName( IDotnsPopController.LiteRegistration({ @@ -992,13 +998,24 @@ contract DotnsPopControllerTests is BaseDotns { }) ); - vm.expectRevert( - abi.encodeWithSelector(IDotnsPopController.NotGateway.selector, address(this)) + bytes32 labelhash = keccak256(bytes(LITE_LABEL_A)); + address expectedStore = + vm.computeCreateAddress(address(storeFactory), vm.getNonce(address(storeFactory))); + + vm.prank(leonardo); + vm.expectEmit(true, true, false, true, address(dotnsPopController)); + emit IDotnsPopController.PendingClaimSettled(ed, labelhash, expectedStore, leonardo); + dotnsPopController.settlePendingClaims(ed, type(uint256).max); + + address store = storeFactory.getLabelStore(ed); + assertEq(store, expectedStore); + assertEq( + ILabelStore(store).getLabel(_nodeOf(LITE_LABEL_A)), string.concat(LITE_LABEL_A, ".dot") ); - dotnsPopController.claimLabelStoreFor(ed); + assertEq(dotnsPopController.pendingClaimCountOf(ed), 0); } - function test_user_claimLabelStore_fallback_still_settles_after_gateway_mint() public { + function test_user_settles_own_pending_claim_after_gateway_mint() public { _grantPopFull(ed); _gatewayReserveLiteName( IDotnsPopController.LiteRegistration({ @@ -1007,17 +1024,38 @@ contract DotnsPopControllerTests is BaseDotns { ); vm.prank(ed); - dotnsPopController.claimLabelStore(); + dotnsPopController.settlePendingClaims(ed, type(uint256).max); address store = storeFactory.getLabelStore(ed); assertTrue(store != address(0)); assertEq( ILabelStore(store).getLabel(_nodeOf(LITE_LABEL_A)), string.concat(LITE_LABEL_A, ".dot") ); - assertEq(dotnsPopController.pendingClaims(ed).length, 0); + assertEq(dotnsPopController.pendingClaimCountOf(ed), 0); } - function test_gateway_can_settle_label_store_for_user() public { + function test_claimLabelStore_settles_callers_own_pending_claim() public { + _grantPopFull(ed); + _gatewayReserveLiteName( + IDotnsPopController.LiteRegistration({ + liteLabel: LITE_LABEL_A, user: ed, chatKey: _validChatKey(0xaa) + }) + ); + + vm.prank(ed); + bool moreRemaining = dotnsPopController.claimLabelStore(); + assertFalse(moreRemaining); + + address store = storeFactory.getLabelStore(ed); + assertTrue(store != address(0)); + assertEq( + ILabelStore(store).getLabel(_nodeOf(LITE_LABEL_A)), + string.concat(LITE_LABEL_A, protocolRegistry.tld()) + ); + assertEq(dotnsPopController.pendingClaimCountOf(ed), 0); + } + + function test_settle_deploys_store_when_user_has_none() public { _grantPopFull(ed); _gatewayReserveLiteName( @@ -1026,17 +1064,20 @@ contract DotnsPopControllerTests is BaseDotns { }) ); - assertEq(dotnsPopController.pendingClaims(ed)[0].label, LITE_LABEL_A); + assertEq(dotnsPopController.pendingClaims(ed, 0, 1)[0].label, LITE_LABEL_A); assertEq(storeFactory.getLabelStore(ed), address(0)); - _dispatchFromRoot(abi.encodeCall(IDotnsPopController.claimLabelStoreFor, (ed))); + (uint256 settledCount, bool moreRemaining) = + dotnsPopController.settlePendingClaims(ed, type(uint256).max); + assertEq(settledCount, 1); + assertFalse(moreRemaining); address store = storeFactory.getLabelStore(ed); assertTrue(store != address(0)); assertEq( ILabelStore(store).getLabel(_nodeOf(LITE_LABEL_A)), string.concat(LITE_LABEL_A, ".dot") ); - assertEq(dotnsPopController.pendingClaims(ed).length, 0); + assertEq(dotnsPopController.pendingClaimCountOf(ed), 0); } function test_registerBaseName_zero_length_label_reverts() public { @@ -1066,7 +1107,7 @@ contract DotnsPopControllerTests is BaseDotns { ); vm.prank(ed); - dotnsPopController.claimLabelStore(); + dotnsPopController.settlePendingClaims(ed, type(uint256).max); bytes32 node = _nodeOf(LITE_LABEL_A); assertEq(dotnsPopResolver.chatKey(node), chatKey); @@ -1166,12 +1207,13 @@ contract DotnsPopControllerTests is BaseDotns { // the user has no LabelStore yet. assertEq(dotnsPopResolver.chatKey(node), chatKey); - IDotnsPopController.PendingClaim[] memory pending = dotnsPopController.pendingClaims(ed); + IDotnsPopController.PendingClaim[] memory pending = + dotnsPopController.pendingClaims(ed, 0, type(uint256).max); assertEq(pending[0].label, LITE_LABEL_A); assertGt(pending[0].mintedAt, 0); } - function test_claimLabelStore_deploys_store_and_writes_label_and_chat_key() public { + function test_settle_deploys_store_and_writes_label_and_chat_key() public { _grantPopFull(ed); bytes memory chatKey = _validChatKey(0x07); @@ -1182,7 +1224,7 @@ contract DotnsPopControllerTests is BaseDotns { ); vm.prank(ed); - dotnsPopController.claimLabelStore(); + dotnsPopController.settlePendingClaims(ed, type(uint256).max); address store = storeFactory.getLabelStore(ed); assertTrue(store != address(0)); @@ -1193,11 +1235,10 @@ contract DotnsPopControllerTests is BaseDotns { ); assertEq(dotnsPopResolver.chatKey(node), chatKey); - IDotnsPopController.PendingClaim[] memory pending = dotnsPopController.pendingClaims(ed); - assertEq(pending.length, 0); + assertEq(dotnsPopController.pendingClaimCountOf(ed), 0); } - function test_claimLabelStore_emits_settled_and_name_registered() public { + function test_settle_emits_settled_and_name_registered() public { _grantPopFull(ed); bytes memory chatKey = _validChatKey(0x03); @@ -1213,34 +1254,64 @@ contract DotnsPopControllerTests is BaseDotns { vm.prank(ed); vm.expectEmit(true, true, false, true, address(dotnsPopController)); - emit IDotnsPopController.PendingClaimSettled(ed, labelhash, expectedStore); + emit IDotnsPopController.PendingClaimSettled(ed, labelhash, expectedStore, ed); vm.expectEmit(true, true, true, true, address(dotnsPopController)); emit IDotnsPopController.NameRegistered(LITE_LABEL_A, labelhash, ed, expectedStore); - dotnsPopController.claimLabelStore(); + dotnsPopController.settlePendingClaims(ed, type(uint256).max); } - function test_revert_claimLabelStore_when_caller_has_no_pending_claim() public { + function test_settlePendingClaims_on_empty_queue_returns_zero() public { + // Settlement is permissionless and non-reverting: a call against a user with no staged + // claims settles nothing and reports an empty queue rather than reverting. vm.prank(ed); - vm.expectRevert(abi.encodeWithSelector(IDotnsPopController.NoPendingClaim.selector, ed)); - dotnsPopController.claimLabelStore(); + (uint256 settledCount, bool moreRemaining) = + dotnsPopController.settlePendingClaims(ed, type(uint256).max); + assertEq(settledCount, 0); + assertFalse(moreRemaining); + assertEq(storeFactory.getLabelStore(ed), address(0)); } - function test_revert_claimLabelStore_when_pending_claim_has_lapsed() public { + function test_settlePendingClaims_bounded_settles_up_to_limit() public { + // A large queue is drained in bounded batches so a single settlement can never exceed the + // block gas limit. Settling with a limit below the queue length reports the residue and a + // follow-up call clears it. _grantPopFull(ed); _gatewayReserveLiteName( IDotnsPopController.LiteRegistration({ - liteLabel: LITE_LABEL_A, user: ed, chatKey: _validChatKey(0x09) + liteLabel: LITE_LABEL_A, user: ed, chatKey: _validChatKey(0x05) + }) + ); + _gatewayReserveLiteName( + IDotnsPopController.LiteRegistration({ + liteLabel: LITE_LABEL_B, user: ed, chatKey: _validChatKey(0x06) + }) + ); + _gatewayReserveLiteName( + IDotnsPopController.LiteRegistration({ + liteLabel: LITE_LABEL_C, user: ed, chatKey: _validChatKey(0x07) }) ); + assertEq(dotnsPopController.pendingClaimCountOf(ed), 3); - vm.warp(block.timestamp + DEFAULT_RESERVATION_DURATION + 1); + vm.prank(ed); + (uint256 firstCount, bool moreAfterFirst) = dotnsPopController.settlePendingClaims(ed, 1); + assertEq(firstCount, 1); + assertTrue(moreAfterFirst); + assertEq(dotnsPopController.pendingClaimCountOf(ed), 2); vm.prank(ed); - vm.expectRevert(abi.encodeWithSelector(IDotnsPopController.NoPendingClaim.selector, ed)); - dotnsPopController.claimLabelStore(); + (uint256 secondCount, bool moreAfterSecond) = + dotnsPopController.settlePendingClaims(ed, type(uint256).max); + assertEq(secondCount, 2); + assertFalse(moreAfterSecond); + assertEq(dotnsPopController.pendingClaimCountOf(ed), 0); } - function test_expirePendingClaim_clears_entry_after_reservation_duration() public { + function test_settle_on_expiry_by_third_party_writes_label() public { + // Age never gates settlement: a claim warped past its reservation deadline still settles + // in full. A third party drives the settlement, the store is deployed for the beneficiary, + // the label is written, the queue empties, the beneficiary leaves the enumeration set, and + // the settler is recorded on the event. _grantPopFull(ed); _gatewayReserveLiteName( IDotnsPopController.LiteRegistration({ @@ -1251,22 +1322,31 @@ contract DotnsPopControllerTests is BaseDotns { vm.warp(block.timestamp + DEFAULT_RESERVATION_DURATION + 1); bytes32 labelhash = keccak256(bytes(LITE_LABEL_A)); + address expectedStore = + vm.computeCreateAddress(address(storeFactory), vm.getNonce(address(storeFactory))); - vm.expectEmit(true, true, false, false, address(dotnsPopController)); - emit IDotnsPopController.PendingClaimExpired(ed, labelhash); - dotnsPopController.expirePendingClaim(ed); + vm.prank(leonardo); + vm.expectEmit(true, true, false, true, address(dotnsPopController)); + emit IDotnsPopController.PendingClaimSettled(ed, labelhash, expectedStore, leonardo); + (uint256 settledCount, bool moreRemaining) = + dotnsPopController.settlePendingClaims(ed, type(uint256).max); + assertEq(settledCount, 1); + assertFalse(moreRemaining); - IDotnsPopController.PendingClaim[] memory pending = dotnsPopController.pendingClaims(ed); - assertEq(pending.length, 0); + address store = storeFactory.getLabelStore(ed); + assertEq(store, expectedStore); + assertEq( + ILabelStore(store).getLabel(_nodeOf(LITE_LABEL_A)), + string.concat(LITE_LABEL_A, protocolRegistry.tld()) + ); + assertEq(dotnsPopController.pendingClaimCountOf(ed), 0); assertEq(dotnsPopController.pendingClaimUserCount(), 0); } - function test_revert_expirePendingClaim_when_user_has_no_pending_claim() public { - vm.expectRevert(abi.encodeWithSelector(IDotnsPopController.NoPendingClaim.selector, ed)); - dotnsPopController.expirePendingClaim(ed); - } - - function test_revert_expirePendingClaim_when_entry_is_still_live() public { + function test_settle_after_reservation_duration_still_writes_label() public { + // The old model dropped lapsed entries; stores now always settle. Warping past the + // reservation duration and settling writes the label into the store rather than + // discarding it. _grantPopFull(ed); _gatewayReserveLiteName( IDotnsPopController.LiteRegistration({ @@ -1274,16 +1354,25 @@ contract DotnsPopControllerTests is BaseDotns { }) ); - vm.expectRevert( - abi.encodeWithSelector(IDotnsPopController.PendingClaimNotExpired.selector, ed) + vm.warp(block.timestamp + DEFAULT_RESERVATION_DURATION + 1); + + vm.prank(ed); + dotnsPopController.settlePendingClaims(ed, type(uint256).max); + + address store = storeFactory.getLabelStore(ed); + assertTrue(store != address(0)); + assertEq( + ILabelStore(store).getLabel(_nodeOf(LITE_LABEL_A)), + string.concat(LITE_LABEL_A, protocolRegistry.tld()) ); - dotnsPopController.expirePendingClaim(ed); + assertEq(dotnsPopController.pendingClaimCountOf(ed), 0); + assertEq(dotnsPopController.pendingClaimUserCount(), 0); } function test_reserveLiteName_piles_second_pending_claim_when_caller_has_no_store() public { // The Root gateway origin cannot deploy a LabelStore, so a store-less user keeps // accumulating deferred names instead of reverting; a single signed-origin - // claimLabelStore settles them all at once. + // settlement writes them all at once. _grantPopFull(ed); _gatewayReserveLiteName( IDotnsPopController.LiteRegistration({ @@ -1296,14 +1385,15 @@ contract DotnsPopControllerTests is BaseDotns { }) ); - IDotnsPopController.PendingClaim[] memory pending = dotnsPopController.pendingClaims(ed); + IDotnsPopController.PendingClaim[] memory pending = + dotnsPopController.pendingClaims(ed, 0, type(uint256).max); assertEq(pending.length, 2); assertEq(pending[0].label, LITE_LABEL_A); assertEq(pending[1].label, LITE_LABEL_B); assertEq(dotnsPopController.pendingClaimUserCount(), 1); vm.prank(ed); - dotnsPopController.claimLabelStore(); + dotnsPopController.settlePendingClaims(ed, type(uint256).max); address store = storeFactory.getLabelStore(ed); assertTrue(store != address(0)); @@ -1315,19 +1405,20 @@ contract DotnsPopControllerTests is BaseDotns { ILabelStore(store).getLabel(_nodeOf(LITE_LABEL_B)), string.concat(LITE_LABEL_B, protocolRegistry.tld()) ); - assertEq(dotnsPopController.pendingClaims(ed).length, 0); + assertEq(dotnsPopController.pendingClaimCountOf(ed), 0); assertEq(dotnsPopController.pendingClaimUserCount(), 0); } function test_pendingClaims_returns_empty_array_for_fresh_user() public view { - assertEq(dotnsPopController.pendingClaims(ed).length, 0); + assertEq(dotnsPopController.pendingClaims(ed, 0, type(uint256).max).length, 0); + assertEq(dotnsPopController.pendingClaimCountOf(ed), 0); } function test_registerBaseName_claim_by_store_less_full_person_piles_then_settles() public { // Regression: a store-less full person reserves a lite name plus a base reservation // (the lite leg stashes a deferred claim because Root cannot deploy the store), then // claims the base name. The base mint stashes a second deferred claim instead of - // reverting; one signed-origin claimLabelStore deploys the store and settles both. + // reverting; one signed-origin settlement deploys the store and settles both. _grantPopFull(ed); _gatewayReserveBaseName( IDotnsPopController.BaseReservation({ @@ -1338,7 +1429,7 @@ contract DotnsPopControllerTests is BaseDotns { }) ); assertEq(storeFactory.getLabelStore(ed), address(0)); - assertEq(dotnsPopController.pendingClaims(ed).length, 1); + assertEq(dotnsPopController.pendingClaimCountOf(ed), 1); _gatewayRegisterBaseName( IDotnsPopController.FullRegistration({ @@ -1346,13 +1437,14 @@ contract DotnsPopControllerTests is BaseDotns { }) ); - IDotnsPopController.PendingClaim[] memory pending = dotnsPopController.pendingClaims(ed); + IDotnsPopController.PendingClaim[] memory pending = + dotnsPopController.pendingClaims(ed, 0, type(uint256).max); assertEq(pending.length, 2); assertEq(pending[0].label, LITE_LABEL_A); assertEq(pending[1].label, BASE_LABEL_A); vm.prank(ed); - dotnsPopController.claimLabelStore(); + dotnsPopController.settlePendingClaims(ed, type(uint256).max); address store = storeFactory.getLabelStore(ed); assertTrue(store != address(0)); @@ -1364,7 +1456,7 @@ contract DotnsPopControllerTests is BaseDotns { ILabelStore(store).getLabel(_nodeOf(BASE_LABEL_A)), string.concat(BASE_LABEL_A, protocolRegistry.tld()) ); - assertEq(dotnsPopController.pendingClaims(ed).length, 0); + assertEq(dotnsPopController.pendingClaimCountOf(ed), 0); } function test_pendingClaimUsers_enumeration_mirrors_stash_and_settle() public { @@ -1397,7 +1489,7 @@ contract DotnsPopControllerTests is BaseDotns { assertTrue(_containsAddress(page, leonardo)); vm.prank(tiago); - dotnsPopController.claimLabelStore(); + dotnsPopController.settlePendingClaims(tiago, type(uint256).max); assertEq(dotnsPopController.pendingClaimUserCount(), 2); address[] memory after_ = dotnsPopController.pendingClaimUsers(0, 10); @@ -1419,7 +1511,9 @@ contract DotnsPopControllerTests is BaseDotns { assertEq(empty.length, 0); } - function test_claimLabelStore_at_exact_expiry_boundary_belongs_to_user() public { + function test_settle_at_exact_expiry_boundary_writes_label() public { + // Age is irrelevant to settlement: at the exact reservation deadline the claim still + // settles and writes its label rather than being treated as forfeit. _grantPopFull(ed); _gatewayReserveLiteName( IDotnsPopController.LiteRegistration({ @@ -1427,20 +1521,24 @@ contract DotnsPopControllerTests is BaseDotns { }) ); - uint64 mintedAt = dotnsPopController.pendingClaims(ed)[0].mintedAt; + uint64 mintedAt = dotnsPopController.pendingClaims(ed, 0, 1)[0].mintedAt; vm.warp(uint256(mintedAt) + uint256(DEFAULT_RESERVATION_DURATION)); - vm.expectRevert( - abi.encodeWithSelector(IDotnsPopController.PendingClaimNotExpired.selector, ed) - ); - dotnsPopController.expirePendingClaim(ed); - vm.prank(ed); - dotnsPopController.claimLabelStore(); - assertEq(dotnsPopController.pendingClaims(ed).length, 0); + dotnsPopController.settlePendingClaims(ed, type(uint256).max); + + address store = storeFactory.getLabelStore(ed); + assertTrue(store != address(0)); + assertEq( + ILabelStore(store).getLabel(_nodeOf(LITE_LABEL_A)), + string.concat(LITE_LABEL_A, protocolRegistry.tld()) + ); + assertEq(dotnsPopController.pendingClaimCountOf(ed), 0); } - function test_claimLabelStore_msg_sender_keyed_other_users_stash_untouched() public { + function test_settle_is_keyed_by_user_arg_other_stash_untouched() public { + // Settlement targets the `user` argument, not the caller: settling for a user with no + // stash is a no-op and does not disturb another user's pending claim. _grantPopFull(ed); bytes memory chatKey = _validChatKey(0x12); _gatewayReserveLiteName( @@ -1450,10 +1548,13 @@ contract DotnsPopControllerTests is BaseDotns { ); vm.prank(tiago); - vm.expectRevert(abi.encodeWithSelector(IDotnsPopController.NoPendingClaim.selector, tiago)); - dotnsPopController.claimLabelStore(); + (uint256 settledCount, bool moreRemaining) = + dotnsPopController.settlePendingClaims(tiago, type(uint256).max); + assertEq(settledCount, 0); + assertFalse(moreRemaining); - IDotnsPopController.PendingClaim[] memory pending = dotnsPopController.pendingClaims(ed); + IDotnsPopController.PendingClaim[] memory pending = + dotnsPopController.pendingClaims(ed, 0, type(uint256).max); assertEq(pending[0].label, LITE_LABEL_A); assertGt(pending[0].mintedAt, 0); assertEq(storeFactory.getLabelStore(ed), address(0)); @@ -1490,40 +1591,14 @@ contract DotnsPopControllerTests is BaseDotns { assertEq(dotnsPopController.pendingClaimUsers(0, 100).length, 3); } - function test_gatewayReserve_pending_claim_lapses_after_minimum_duration() public { - // With the duration floor in place, the smallest configurable expiry window - // is MIN_RESERVATION_DURATION; warping past it still drives the claim into - // the expired-and-reapable state without requiring a zero duration. - uint64 duration = dotnsPopController.MIN_RESERVATION_DURATION(); - vm.prank(owner); - dotnsPopController.setReservationDuration(duration); - - _grantPopFull(ed); - _gatewayReserveLiteName( - IDotnsPopController.LiteRegistration({ - liteLabel: LITE_LABEL_A, user: ed, chatKey: _validChatKey(0x33) - }) - ); - - vm.warp(block.timestamp + uint256(duration) + 1); - - vm.prank(ed); - vm.expectRevert(abi.encodeWithSelector(IDotnsPopController.NoPendingClaim.selector, ed)); - dotnsPopController.claimLabelStore(); - - dotnsPopController.expirePendingClaim(ed); - assertEq(dotnsPopController.pendingClaims(ed).length, 0); - assertEq(storeFactory.getLabelStore(ed), address(0)); - } - - function test_claimLabelStore_with_empty_chat_key_skips_resolver_write() public { + function test_settle_with_empty_chat_key_skips_resolver_write() public { _grantPopFull(ed); _gatewayReserveLiteName( IDotnsPopController.LiteRegistration({liteLabel: LITE_LABEL_A, user: ed, chatKey: ""}) ); vm.prank(ed); - dotnsPopController.claimLabelStore(); + dotnsPopController.settlePendingClaims(ed, type(uint256).max); bytes32 node = _nodeOf(LITE_LABEL_A); address store = storeFactory.getLabelStore(ed); @@ -1534,7 +1609,7 @@ contract DotnsPopControllerTests is BaseDotns { assertEq(dotnsPopResolver.chatKey(node).length, 0); } - function test_gatewayReserve_warm_user_after_claim_writes_directly_without_stashing() public { + function test_gatewayReserve_warm_user_after_settle_writes_directly_without_stashing() public { _grantPopFull(ed); _gatewayReserveLiteName( IDotnsPopController.LiteRegistration({ @@ -1543,7 +1618,7 @@ contract DotnsPopControllerTests is BaseDotns { ); vm.prank(ed); - dotnsPopController.claimLabelStore(); + dotnsPopController.settlePendingClaims(ed, type(uint256).max); address store = storeFactory.getLabelStore(ed); assertTrue(store != address(0)); @@ -1559,7 +1634,7 @@ contract DotnsPopControllerTests is BaseDotns { ILabelStore(store).getLabel(node), string.concat(LITE_LABEL_B, protocolRegistry.tld()) ); assertEq(dotnsPopResolver.chatKey(node), secondChatKey); - assertEq(dotnsPopController.pendingClaims(ed).length, 0); + assertEq(dotnsPopController.pendingClaimCountOf(ed), 0); assertEq(dotnsPopController.pendingClaimUserCount(), 0); } @@ -1614,6 +1689,204 @@ contract DotnsPopControllerTests is BaseDotns { ); } + function test_liteNamesOf_and_fullNamesOf_list_by_shape() public { + // Settled names read back from the store; a pending gateway name reads from the queue with + // a live deadline; the two shapes never cross into each other's list; an untouched account + // returns empty lists and zero counts. + _grantPopFull(ed); + _reservePop(ed, LITE_LABEL_A, _validChatKey(0x01), ""); + _gatewayRegisterBaseName( + IDotnsPopController.FullRegistration({ + label: BASE_LABEL_A, user: ed, link: _linkFresh(_validChatKey(0x02)) + }) + ); + + _grantPopFull(leonardo); + _gatewayReserveLiteName( + IDotnsPopController.LiteRegistration({ + liteLabel: LITE_LABEL_C, user: leonardo, chatKey: _validChatKey(0x03) + }) + ); + + IDotnsPopLens.Name[] memory edLite = dotnsPopLens.liteNamesOf(ed, 0, type(uint256).max); + assertEq(edLite.length, 1); + assertEq(edLite[0].node, _nodeOf(LITE_LABEL_A)); + assertEq(edLite[0].label, LITE_LABEL_A); + assertTrue(edLite[0].settled); + assertEq(edLite[0].deadline, 0); + + IDotnsPopLens.Name[] memory edFull = dotnsPopLens.fullNamesOf(ed, 0, type(uint256).max); + assertEq(edFull.length, 1); + assertEq(edFull[0].node, _nodeOf(BASE_LABEL_A)); + assertEq(edFull[0].label, BASE_LABEL_A); + assertTrue(edFull[0].settled); + + assertEq(dotnsPopLens.liteNameCountOf(ed), 1); + assertEq(dotnsPopLens.fullNameCountOf(ed), 1); + + IDotnsPopLens.Name[] memory leoLite = + dotnsPopLens.liteNamesOf(leonardo, 0, type(uint256).max); + assertEq(leoLite.length, 1); + assertEq(leoLite[0].node, _nodeOf(LITE_LABEL_C)); + assertEq(leoLite[0].label, LITE_LABEL_C); + assertFalse(leoLite[0].settled); + assertGt(leoLite[0].deadline, 0); + assertEq(dotnsPopLens.liteNameCountOf(leonardo), 1); + assertEq(dotnsPopLens.fullNameCountOf(leonardo), 0); + + assertEq(dotnsPopLens.liteNamesOf(tiago, 0, type(uint256).max).length, 0); + assertEq(dotnsPopLens.fullNamesOf(tiago, 0, type(uint256).max).length, 0); + assertEq(dotnsPopLens.liteNameCountOf(tiago), 0); + assertEq(dotnsPopLens.fullNameCountOf(tiago), 0); + } + + function test_liteNamesOf_pagination_slices_and_clamps() public { + _grantPopFull(ed); + _reservePop(ed, LITE_LABEL_A, _validChatKey(0x01), ""); + _gatewayReserveLiteName( + IDotnsPopController.LiteRegistration({ + liteLabel: LITE_LABEL_B, user: ed, chatKey: _validChatKey(0x02) + }) + ); + + assertEq(dotnsPopLens.liteNameCountOf(ed), 2); + + IDotnsPopLens.Name[] memory first = dotnsPopLens.liteNamesOf(ed, 0, 1); + assertEq(first.length, 1); + IDotnsPopLens.Name[] memory second = dotnsPopLens.liteNamesOf(ed, 1, 1); + assertEq(second.length, 1); + assertTrue(first[0].node != second[0].node); + + assertEq(dotnsPopLens.liteNamesOf(ed, 2, 1).length, 0); + + // A limit above the internal page ceiling is clamped rather than reverting; the account + // holds fewer names than the ceiling, so the full set still comes back. + IDotnsPopLens.Name[] memory clamped = + dotnsPopLens.liteNamesOf(ed, 0, DotnsConstants.MAX_PAGE_SIZE + 1); + assertEq(clamped.length, 2); + } + + function test_name_listings_exclude_names_owned_by_others() public { + // The listing re-checks registrar ownership per entry, so a name owned by another account + // never surfaces in this account's list. + _grantPopFull(ed); + _grantPopFull(tiago); + _reservePop(ed, LITE_LABEL_A, _validChatKey(0x01), ""); + _reservePop(tiago, LITE_LABEL_C, _validChatKey(0x02), ""); + + IDotnsPopLens.Name[] memory edLite = dotnsPopLens.liteNamesOf(ed, 0, type(uint256).max); + assertEq(edLite.length, 1); + assertFalse(_namesContainNode(edLite, _nodeOf(LITE_LABEL_C))); + + IDotnsPopLens.Name[] memory tiagoLite = + dotnsPopLens.liteNamesOf(tiago, 0, type(uint256).max); + assertEq(tiagoLite.length, 1); + assertFalse(_namesContainNode(tiagoLite, _nodeOf(LITE_LABEL_A))); + } + + function test_nameDetail_and_nameDetailByNode_report_record() public { + _grantPopFull(ed); + bytes memory liteChatKey = _validChatKey(0xaa); + _reservePop(ed, LITE_LABEL_A, liteChatKey, BASE_LABEL_A); + _gatewayRegisterBaseName( + IDotnsPopController.FullRegistration({ + label: BASE_LABEL_A, user: ed, link: _linkWithLite(LITE_LABEL_A) + }) + ); + + bytes32 fullNode = _nodeOf(BASE_LABEL_A); + IDotnsPopLens.NameDetail memory full = dotnsPopLens.nameDetail(BASE_LABEL_A); + assertEq(full.node, fullNode); + assertEq(full.label, BASE_LABEL_A); + assertEq(full.owner, ed); + assertTrue(full.exists); + assertTrue(full.settled); + assertTrue(full.tier == IPopRules.PopStatus.PopFull); + assertEq(full.chatKey, liteChatKey); + assertEq(full.liteLink, keccak256(bytes(LITE_LABEL_A))); + // A base label is never a lite labelhash, so no promoted node is keyed under it. + assertEq(full.fullClaim, bytes32(0)); + + // Holding the lite label lets nameDetail recover the promoted full node. + IDotnsPopLens.NameDetail memory lite = dotnsPopLens.nameDetail(LITE_LABEL_A); + assertEq(lite.fullClaim, fullNode); + + // A settled lite name that was never promoted carries no full claim, and the by-node + // overload leaves it zero. + _grantPopFull(leonardo); + _reservePop(leonardo, LITE_LABEL_C, _validChatKey(0xbb), ""); + IDotnsPopLens.NameDetail memory coldByNode = + dotnsPopLens.nameDetailByNode(_nodeOf(LITE_LABEL_C)); + assertTrue(coldByNode.exists); + assertEq(coldByNode.fullClaim, bytes32(0)); + + // Unknown name and node never revert and return a zeroed record. + IDotnsPopLens.NameDetail memory unknownName = dotnsPopLens.nameDetail("nothingxx"); + assertFalse(unknownName.exists); + assertEq(unknownName.owner, address(0)); + assertEq(bytes(unknownName.label).length, 0); + assertEq(unknownName.fullClaim, bytes32(0)); + + IDotnsPopLens.NameDetail memory unknownNode = + dotnsPopLens.nameDetailByNode(bytes32(uint256(0xdead))); + assertFalse(unknownNode.exists); + assertEq(unknownNode.owner, address(0)); + assertEq(unknownNode.fullClaim, bytes32(0)); + } + + function test_profileOf_reports_store_pending_and_reservation() public { + // A store-less user with a staged claim, a settled user holding a reservation, and an + // untouched account each report distinct profile facts. + _grantPopFull(leonardo); + _gatewayReserveLiteName( + IDotnsPopController.LiteRegistration({ + liteLabel: LITE_LABEL_C, user: leonardo, chatKey: _validChatKey(0x01) + }) + ); + IDotnsPopLens.PopProfile memory cold = dotnsPopLens.profileOf(leonardo); + assertFalse(cold.hasLabelStore); + assertEq(cold.pendingClaimCount, 1); + assertEq(cold.reservationLabelhash, bytes32(0)); + + _grantPopFull(ed); + _reservePop(ed, LITE_LABEL_A, _validChatKey(0x02), BASE_LABEL_A); + IDotnsPopLens.PopProfile memory warm = dotnsPopLens.profileOf(ed); + assertTrue(warm.hasLabelStore); + assertEq(warm.pendingClaimCount, 0); + assertEq(warm.reservationLabelhash, keccak256(bytes(BASE_LABEL_A))); + + IDotnsPopLens.PopProfile memory empty = dotnsPopLens.profileOf(tiago); + assertFalse(empty.hasLabelStore); + assertEq(empty.pendingClaimCount, 0); + assertEq(empty.reservationLabelhash, bytes32(0)); + } + + function test_reservedBaseLabelOf_returns_label_or_empty() public { + _grantPopFull(ed); + _reservePop(ed, LITE_LABEL_A, _validChatKey(0x01), BASE_LABEL_A); + + assertEq( + dotnsPopController.reservedBaseLabelOf(keccak256(bytes(BASE_LABEL_A))), BASE_LABEL_A + ); + assertEq( + bytes(dotnsPopController.reservedBaseLabelOf(keccak256(bytes("unknownbase")))).length, 0 + ); + } + + function _namesContainNode( + IDotnsPopLens.Name[] memory names, + bytes32 node + ) + internal + pure + returns (bool) + { + for (uint256 i; i < names.length; ++i) { + if (names[i].node == node) return true; + } + return false; + } + function _containsAddress( address[] memory haystack, address needle