Skip to content
2 changes: 2 additions & 0 deletions DEPLOYMENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,8 @@ Before deploying to a live network, diff its manifest against `deployments/expec

Reserved registration is gated on `DotnsNameWhitelist`. A grant binds one label to one beneficiary address and is single use: `registerReserved` requires a grant naming `registration.owner`, spends it on the mint, and refuses a second attempt. See the [README economics section](./README.md#economics) for what a grant does and does not confer; this section covers the mechanics.

`registerReserved` also needs `nameEscrow` registered, and reverts with `EscrowNotConfigured` without it. The mint charges nothing, but it seeds a zero-amount escrow release position so the granted name stays inside the normal release lifecycle; a name minted without one could never be released and would leave circulation permanently. For the same reason the path reclaims from escrow instead of minting when the escrow still holds the token, which is how a released reserved-tier label is granted again.

**Every admin action on the whitelist is a substrate Root dispatch.** Granting, revoking, accepting, rejecting, reserving, setting the request window and retuning the caps all require it. No signed account can do any of them, the contract owner included: the owner's authority is deployment and upgrade, not allocation. A signed call reverts with `NotGovernance`. There is no operator role and no address allowlist.

That is the point of the design. As a security measure, no single key can grant a name; a grant costs a referendum, or on a test network a sudo-dispatched Root call.
Expand Down
9 changes: 6 additions & 3 deletions DEPLOYMENT_CHECKLIST.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,9 +151,12 @@ Confirm these keys are present:
- [ ] `DotnsPopLens`

The wiring stage already asserts every protocol-registry binding, so a green deploy means they
are set. One is worth confirming by hand, because it is the only key whose absence surfaces to
users rather than to the pipeline: with `nameWhitelist` unset, `registerReserved` reverts
`WhitelistNotConfigured` for every caller, Root included.
are set. Two are worth confirming by hand, because their absence surfaces to users rather than
to the pipeline. With `nameWhitelist` unset, `registerReserved` reverts `WhitelistNotConfigured`
for every grant-backed caller; a Root dispatch skips the whitelist read and is unaffected. With
`nameEscrow` unset it reverts `EscrowNotConfigured` for every caller, Root included, because the
mint seeds the zero-amount release position that keeps a granted name inside the release
lifecycle.

```bash
cast call "$PROTOCOL_REGISTRY" "get(bytes32)(address)" \
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ Both are stamped at release time, so a governance change never moves the clocks
- `redeem` returns the token and moves no value. The position keeps its recipient, asset and amount, so the deposit stays locked and the name lands back in its exact pre-release state, releasable again later on a fresh pair of clocks. This is the undo for an accidental release.
- `withdraw` credits the deposit and forfeits the right to redeem. A holder paid for the name cannot also take it back, otherwise they would hold a name no deposit backs.

Once `redeemableUntil` passes, `reclaim` is permissionless through the ordinary commit-reveal path, whether or not the previous holder ever withdrew. If the position still holds value, reclaim settles it: the amount is credited to the previous holder's pull-payment balance and stays claimable with no deadline. The value follows the departing holder; the name does not wait for them. Cross-paid registrations seed zero-amount positions, so those names have nothing to withdraw and nothing to settle.
Once `redeemableUntil` passes, `reclaim` is permissionless through the ordinary commit-reveal path, whether or not the previous holder ever withdrew. If the position still holds value, reclaim settles it: the amount is credited to the previous holder's pull-payment balance and stays claimable with no deadline. The value follows the departing holder; the name does not wait for them. Cross-paid registrations and grant-backed reserved registrations seed zero-amount positions, so those names have nothing to withdraw and nothing to settle, but they move through release, redeem and reclaim like any other name.

Gateway-issued (soulbound) names never enter this lifecycle. Releasing a name transfers it into escrow custody, and a soulbound name reverts on any transfer, so it holds no position and has no release, redeem, or reclaim path. It stays with the person who earned it.

Expand Down Expand Up @@ -237,6 +237,8 @@ Name grants are the exception path for users or organisations that need to regis

A grant is deliberately narrow. It names one label rather than permitting any available name, it names the address the name must mint to, and it is spent by that mint, so it cannot seed a second registration. The gate reads the intended owner rather than the caller, so a relayer may submit the registration on the beneficiary's behalf; the name still lands on the beneficiary. The reserved path writes no reverse record, because the submitter is not necessarily the beneficiary and setReverseName overwrites unconditionally; the owner claims their own primary afterwards through claimReverseRecord.

A granted name moves through the same release lifecycle as any other name. The grant is free, so the mint seeds a zero-amount escrow position rather than a refundable deposit, but the position is what `release` checks, so the holder can release the name and, once the redeem window passes, it becomes reclaimable and available again. A long label that comes back this way can be re-registered by anyone through the ordinary paid path. A reserved-tier label cannot, because `register` refuses it, so it waits for a fresh grant or a direct Root dispatch; either way `registerReserved` reclaims it out of escrow rather than minting, since the token still exists.

A grant is a governance action. `grantName` is Root-only, so on a production network it is proposed and decided on-chain as a referendum, and on a test network it is a sudo-dispatched call. See [Name grants](./DEPLOYMENTS.md#name-grants-whitelisting) for the dispatch.

A grant does not register a name or bypass ownership rules; it only permits the named address to register that one label without a PoP status.
Expand Down
9 changes: 5 additions & 4 deletions contracts/escrow/DotnsNameEscrow.sol
Original file line number Diff line number Diff line change
Expand Up @@ -274,8 +274,9 @@ contract DotnsNameEscrow is
ReleasePosition storage position = _positions[params.tokenId];

// Use `recipient` as the "is this slot funded?" sentinel so zero-amount
// positions (seeded by cross-paid registrations, which pay a fee rather than a deposit)
// still count as present and cannot be re-seeded with a different recipient.
// positions (seeded by cross-paid registrations, which pay a fee rather than a deposit,
// and by grant-backed reserved registrations, which pay nothing) still count as present
// and cannot be re-seeded with a different recipient.
require(position.recipient == address(0), PositionAlreadyFunded(params.tokenId));
require(!position.released, AlreadyReleased(params.tokenId));

Expand Down Expand Up @@ -373,8 +374,8 @@ contract DotnsNameEscrow is

ReleasePosition storage position = _positions[tokenId];
// Recipient is the canonical "is this position present?" sentinel; zero-amount positions
// seeded for cross-paid registrations are still releasable so every minted name has a
// reachable lifecycle.
// seeded for cross-paid and grant-backed reserved registrations are still releasable so
// every minted name has a reachable lifecycle.
require(position.recipient != address(0), DepositNotConfigured(tokenId));
require(!position.released, AlreadyReleased(tokenId));

Expand Down
147 changes: 120 additions & 27 deletions contracts/registrars/DotnsRegistrarController.sol
Original file line number Diff line number Diff line change
Expand Up @@ -175,20 +175,13 @@ contract DotnsRegistrarController is
uint256 tokenId = uint256(node);
bool isReclaim = registrar.exists(tokenId);

string memory baseName = rules.stripDigits(registration.label);
bool baseNameCanonical = baseName.isSingleLabelMemory();
// Reclaim hands the name back from a prior occupant who may hold a sibling-controller's
// base-name reservation, which is garbage once the name moves on, so clear it. Non-reclaim
// paths intentionally leave an existing reservation in place: the slot belongs to the
// sibling controller that wrote it (e.g. the PoP queue head stamp), and clearing it from
// here would brick that controller's release and advance paths.
if (baseNameCanonical && isReclaim) {
(address reservationOwner,) = rules.getBaseNameReservation(baseName);
address expectedOwner =
IDotnsNameEscrow(payable(escrow)).getReleasePosition(tokenId).recipient;
if (reservationOwner != address(0) && reservationOwner == expectedOwner) {
rules.releaseReservationForReclaim(baseName, expectedOwner);
}
// Runs before pricing: the price reads below enforce reservations against the new owner,
// so a slot the previous holder left behind would otherwise block this registration.
// Non-reclaim paths intentionally leave an existing reservation in place: the slot belongs
// to the sibling controller that wrote it (e.g. the PoP queue head stamp), and clearing it
// from here would brick that controller's release and advance paths.
if (isReclaim) {
_clearStaleReservation(rules, escrow, tokenId, registration.label);
}

bool isDirect = msg.sender == registration.owner;
Expand Down Expand Up @@ -235,12 +228,7 @@ contract DotnsRegistrarController is
);

if (isReclaim) {
IDotnsNameEscrow(payable(escrow)).reclaim(tokenId, registration.owner);
// Reclaim hands the NFT to the new holder; rewrite the registry record so the prior
// owner's resolver pointer cannot follow the name. Must run after `escrow.reclaim`
// so the registry's `ownerOf` check sees the new holder, not the escrow.
IDotnsRegistry(protocolRegistry.get(DotnsConstants.REGISTRY))
.setOwner(node, registration.owner);
_reclaimFromEscrow(escrow, tokenId, node, registration.owner);
}

_settleEscrow(escrow, tokenId, registration.owner, isDirect, totalCharged);
Expand Down Expand Up @@ -273,11 +261,7 @@ contract DotnsRegistrarController is
internal
{
uint256 depositAmount = isDirect ? chargeAmount : 0;
IDotnsNameEscrow(payable(escrow)).deposit{value: depositAmount}(
IDotnsNameEscrow.DepositParams({
tokenId: tokenId, asset: address(0), amount: depositAmount, recipient: nameOwner
})
);
_seedReleasePosition(escrow, tokenId, nameOwner, depositAmount);

if (!isDirect && chargeAmount > 0) {
IDotnsNameEscrow(payable(escrow)).depositProtocolFee{value: chargeAmount}(
Expand All @@ -304,7 +288,8 @@ contract DotnsRegistrarController is
);
}

(, bytes32 labelhash, bytes32 node) = _requireAvailableLabel(registration.label);
(IDotnsRegistrar registrar, bytes32 labelhash, bytes32 node) =
_requireAvailableLabel(registration.label);
_consumeCommitment(registration);

// Spend the grant before minting so a grant in the wrong state fails before any name is
Expand All @@ -318,13 +303,39 @@ contract DotnsRegistrarController is
whitelist.consume(registration.label, registration.owner);
}

address escrow = _escrow();
uint256 tokenId = uint256(node);
// `available` is true both for a never-minted label and for one the escrow still holds
// past its redeem window, so a grant can land on either and this path has to serve both:
// `DotnsRegistrar.register` rejects an id the escrow holds, so an existing token is
// reclaimed rather than minted. Reserved-tier labels reach circulation only through this
// function, so a released grant has no other route back out of custody.
bool isReclaim = registrar.exists(tokenId);

// No reverse record. `setReverseName` overwrites unconditionally, and the gate above lets
// anyone submit for the beneficiary, so writing here would let a third party relabel
// another address. The owner claims their own record through `claimReverseRecord`, which
// checks ownership and writes only their own key.
_completeRegistration(
registration, labelhash, node, 0, false, IDotnsReverseResolver(address(0)), false
registration, labelhash, node, 0, false, IDotnsReverseResolver(address(0)), isReclaim
);

if (isReclaim) {
_clearStaleReservation(
IPopRules(protocolRegistry.get(DotnsConstants.POP_RULES)),
escrow,
tokenId,
registration.label
);
_reclaimFromEscrow(escrow, tokenId, node, registration.owner);
}

// Seed the position last, after `reclaim` has deleted whatever the previous holder left
// behind. A grant costs nothing, so there is no deposit to lock, but the position is also
// the lifecycle marker: `release` rejects a token whose position carries no recipient, so
// a name minted without one can never be released, never becomes reclaimable, and never
// returns to circulation.
_seedReleasePosition(escrow, tokenId, registration.owner, 0);
}

/// @inheritdoc IERC165
Expand Down Expand Up @@ -443,6 +454,88 @@ contract DotnsRegistrarController is
require(escrow != address(0), EscrowNotConfigured());
}

/// @notice Clears the base-name reservation that the previous holder of a reclaimed name still
/// owns.
/// @dev Shared by both registration paths so their reclaim clean-up cannot drift apart. The
/// previous holder may own a reservation on the name's base name, written by a sibling
/// controller (e.g. the PoP queue head stamp). Once the name moves on, that slot is
/// garbage, and left alone it blocks the base name for everyone else until it expires.
/// Only a slot owned by the previous holder is cleared; a slot held by anyone else is
/// still theirs and stays. Must run before `escrow.reclaim`, which deletes the position
/// the previous holder is read from. Reads no `msg.sender`, so it is safe under a Root
/// origin: `PopRules` sees this controller as its caller either way.
/// @param rules Configured PopRules.
/// @param escrow Configured name escrow.
/// @param tokenId Token being reclaimed.
/// @param label Label being registered.
function _clearStaleReservation(
IPopRules rules,
address escrow,
uint256 tokenId,
string calldata label
)
internal
{
string memory baseName = rules.stripDigits(label);
if (!baseName.isSingleLabelMemory()) return;

(address reservationOwner,) = rules.getBaseNameReservation(baseName);
address previousHolder =
IDotnsNameEscrow(payable(escrow)).getReleasePosition(tokenId).recipient;
if (reservationOwner != address(0) && reservationOwner == previousHolder) {
rules.releaseReservationForReclaim(baseName, previousHolder);
}
}

/// @notice Takes a name out of escrow custody and repoints the forward registry at its new
/// holder.
/// @dev Shared by both registration paths so the ordering constraint lives in one place: the
/// registry write must follow `escrow.reclaim`, because `setOwner` checks `ownerOf` and
/// would otherwise see the escrow rather than `newOwner`. Without the rewrite the prior
/// owner's resolver pointer follows the name to its new holder.
/// @param escrow Configured name escrow.
/// @param tokenId Token being reclaimed, equal to `uint256(node)`.
/// @param node Namehash of the label, as the forward registry keys it.
/// @param newOwner Address taking custody of the name.
function _reclaimFromEscrow(
address escrow,
uint256 tokenId,
bytes32 node,
address newOwner
)
internal
{
IDotnsNameEscrow(payable(escrow)).reclaim(tokenId, newOwner);
IDotnsRegistry(protocolRegistry.get(DotnsConstants.REGISTRY)).setOwner(node, newOwner);
}

/// @notice Opens the escrow release position that every registration path must leave behind.
/// @dev The single construction site for a position, so "every minted name is releasable" is
/// one rule rather than one per path. `amount` is the refundable deposit and may be zero:
/// `DotnsNameEscrow` treats `recipient` as the presence sentinel, so a zero-amount
/// position is still a position. Cross-payer registrations seed zero because the charge
/// goes to the protocol fee pot instead, and grant-backed reserved registrations seed
/// zero because they charge nothing at all. In both cases the position carries no value
/// and exists only so `release`, `redeem` and `reclaim` stay reachable.
/// @param escrow Configured name escrow.
/// @param tokenId Token the position is keyed to.
/// @param recipient Address the deposit (when non-zero) and the lifecycle are keyed to.
/// @param amount Refundable deposit to lock, forwarded as `msg.value`.
function _seedReleasePosition(
address escrow,
uint256 tokenId,
address recipient,
uint256 amount
)
internal
{
IDotnsNameEscrow(payable(escrow)).deposit{value: amount}(
IDotnsNameEscrow.DepositParams({
tokenId: tokenId, asset: address(0), amount: amount, recipient: recipient
})
);
}

/// @notice Returns the release this network declares it runs, read live from the protocol
/// registry so every dotNS contract reports one synchronised value.
/// @dev Mirror of `IDotnsProtocolRegistry.protocolVersion`, kept under the historical
Expand Down
14 changes: 12 additions & 2 deletions contracts/registrars/IDotnsRegistrarController.sol
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,7 @@ interface IDotnsRegistrarController is IDotnsController {
function register(Registration calldata registration) external payable;

/// @notice Registers a granted name after the commitment delay, at zero base cost.
/// @dev Grant-backed issuance path: skips the PoP price check and the escrow deposit, but
/// @dev Grant-backed issuance path: skips the PoP price check and charges no deposit, but
/// reuses the same commit-reveal pipeline so the same anti-front-running guarantees apply.
///
/// Authority is either a substrate Root dispatch or a grant naming `registration.owner` on the
Expand All @@ -193,6 +193,16 @@ interface IDotnsRegistrarController is IDotnsController {
/// (otherwise @custom:reverts NameNotAvailable), then consumes the prior commitment, which
/// fails with @custom:reverts CommitmentNotFound, @custom:reverts CommitmentTooNew, or
/// @custom:reverts CommitmentTooOld under the same conditions as @custom:function register.
/// Emits @custom:emits NameRegistered on success.
///
/// Availability covers both a never-minted label and one the escrow still holds past its
/// redeem window, so this path reclaims from escrow when the token already exists, exactly as
/// @custom:function register does. Like @custom:function register, a reclaim first clears a
/// base-name reservation that the previous holder still owns, so it does not outlive their
/// hold on the name. It then seeds a zero-amount escrow release position keyed
/// to `registration.owner`. The grant is free, so there is nothing to refund, but the position
/// is what makes the name releasable: without it the holder cannot release, the name never
/// becomes reclaimable, and a reserved-tier label leaves circulation permanently. The escrow
/// must therefore be registered under `DotnsConstants.NAME_ESCROW`, otherwise
/// @custom:reverts EscrowNotConfigured. Emits @custom:emits NameRegistered on success.
function registerReserved(Registration calldata registration) external;
}
Loading
Loading