Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .github/abi-contracts.txt
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ DotnsNameEscrow
DotnsPopController
DotnsPopResolver
DotnsRoleManager
RootGatewayDispatcher
DotnsNameWhitelist
DotnsCostModelRegistry
DotnsFlatPricing
Expand Down
1 change: 0 additions & 1 deletion DEPLOYMENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,6 @@ At minimum, confirm:
- The forward, reverse, content, and Pop resolvers are present.
- The escrow address is present.
- StoreFactory and both store beacons are present.
- The RootGatewayDispatcher is present on environments that use the root-dispatch path.
- The escrow's redeem window is non-zero. A zero leaves `release` reverting with `RedeemWindowNotConfigured` for every name on the deployment, so a holder who releases a name by accident has no chance to redeem it back. Any value the setter accepted is already at least `MIN_REDEEM_WINDOW` (1 day), so this check is only ever confirming that the window was configured at all, which is exactly what a proxy upgraded without seeding it would fail.

```bash
Expand Down
1 change: 0 additions & 1 deletion DEPLOYMENT_CHECKLIST.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,6 @@ Confirm these keys are present:
- [ ] `LabelStoreBeacon`
- [ ] `UserStoreBeacon`
- [ ] `Multicall3`
- [ ] `RootGatewayDispatcher` (only on chains that use the root-dispatch path)

Done. ✅

Expand Down
16 changes: 2 additions & 14 deletions KNOWN_ISSUES.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,7 @@ For the security and audit status of the codebase, see [SECURITY.md](./SECURITY.
| :- | :---- | :--- | :------------ |
| 1 | Deferred LabelStore deployment | Runtime | Runtime allows root-origin contract deployment |
| 2 | Transfer fee is zero until the store is settled | Runtime (follows from 1) | The holder calls `claimLabelStore`, or 1 is resolved |
| 3 | Root origin is not propagated through delegatecalls | Runtime | Runtime propagates origin through delegatecalls |
| 4 | No standalone user-status mapping | Current implementation | A dedicated status mapping is added, if ever needed |
| 3 | No standalone user-status mapping | Current implementation | A dedicated status mapping is added, if ever needed |

## 1. Deferred LabelStore deployment

Expand All @@ -35,19 +34,8 @@ The registrar derives the transfer-floor price by reading the label from the sen

See [README → DotnsPopController](./README.md#early-testnet-quirk-labelstore-deployment).

## 3. Root origin is not propagated through delegatecalls

**Type:** runtime limitation.

The substrate Root origin is not propagated through delegatecalls, so a UUPS implementation running inside its proxy's delegatecall frame cannot observe Root authority directly. Gateway calls therefore route through the non-proxy `RootGatewayDispatcher`, which is the direct callee of the Root runtime origin and forwards to the controller via a regular message call only after the Root check passes.

**Workaround:** the `RootGatewayDispatcher` shim restores a frame in which the Root check is meaningful.

**Resolution:** when the runtime propagates origin through delegatecalls, the controller can verify Root from its own frame and the dispatcher becomes unnecessary.

See [README → RootGatewayDispatcher](./README.md#rootgatewaydispatcher).

## 4. No standalone user-status mapping
## 3. No standalone user-status mapping

**Type:** current implementation.

Expand Down
13 changes: 2 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ Commit-reveal controller for the public registration path. A caller first submit

### DotnsPopController

Dedicated controller for the Proof-of-Personhood gateway flow. Lives behind its own UUPS proxy with its own storage and is registered on the registrar via addController alongside the commit-reveal controller. Its gated entry points are callable only from the address resolved through the protocol registry under the POP_GATEWAY key, which is the RootGatewayDispatcher deployed against this controller; the dispatcher is the contract that actually proves substrate Root authority before forwarding here.
Dedicated controller for the Proof-of-Personhood gateway flow. Lives behind its own UUPS proxy with its own storage and is registered on the registrar via addController alongside the commit-reveal controller. Its gated entry points are callable only under a substrate Root origin, which the controller verifies itself by reading `originIsRoot` from the revive System precompile.

Today the Pop gateway does not write a standalone user-status mapping. It materialises the PoP flow through gateway-issued labels, PoP resolver records, and reservation queue state; user tier checks for public pricing still come from the personhood precompile/context read.

Expand All @@ -148,14 +148,6 @@ Pop-gateway issuances mint the name and persist its label, but LabelStore deploy

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

Non-upgradeable shim that translates a substrate Root-origin dispatch into an EVM-observable authority on the PoP controller. The dispatcher is the direct callee of the Root runtime origin, asks the revive System precompile whether its caller is Root, and forwards the calldata to the controller via a regular message call only when that check passes. The forwarded call lands on the controller proxy with the dispatcher as the immediate caller, which the controller authorises against the address registered on the protocol registry under POP_GATEWAY.

Hosting the Root check in a separate, non-proxy contract is what makes it work at all. The revive System precompile is only meaningful in the frame that is the direct callee of Root, and a UUPS implementation runs inside the proxy's delegatecall, so the controller cannot ask the precompile from its own frame. The dispatcher's target is immutable, set at construction to the controller proxy it serves, and the dispatcher holds no storage of its own and never delegatecalls, so it cannot be repurposed as an arbitrary-target proxy. Rotating the dispatcher is a single set call on the protocol registry; the controller picks up the new gateway on its next call without an upgrade.

The dispatcher exists to work around a runtime limitation: the substrate Root origin is not propagated through delegatecalls, so a UUPS implementation running inside its proxy's delegatecall frame cannot observe Root authority directly. Routing gateway calls through the non-proxy dispatcher restores a frame in which the Root check is meaningful. When the runtime propagates origin through delegatecalls, the controller can verify Root from its own frame and the dispatcher becomes unnecessary. This is a runtime limitation, not a protocol design choice.

### DotnsRegistrar

ERC721-backed registrar that mints ownership of label IDs (labelhashes). Minting is restricted to every address in the controllers mapping; the mapping is owner-gated through addController and removeController. Every other contract in the system that needs to check "is this address authorised to drive name state?" consults this mapping rather than keeping a parallel list, which is what lets multiple controllers coexist on the same registrar without per-contract configuration changes.
Expand Down Expand Up @@ -239,7 +231,7 @@ On-chain lookup table mapping well-known bytes32 keys (declared in DotnsConstant

Without it, each contract would store direct addresses to every contract it calls. An upgrade that changes one address would require a separate owner transaction for every contract that references it. The protocol registry reduces this to one: update the key in the registry, and every caller picks up the new address on its next call. The indirection also means a governance-driven rotation of, say, the PoP controller does not break any consumer that has already been deployed.

The registered keys include REGISTRAR, CONTROLLER, REGISTRY, REVERSE_RESOLVER, RESOLVER, CONTENT_RESOLVER, POP_RULES, STORE_FACTORY, POP_CONTROLLER, POP_RESOLVER, NAME_ESCROW, MULTICALL3, and POP_GATEWAY.
The registered keys include REGISTRAR, CONTROLLER, REGISTRY, REVERSE_RESOLVER, RESOLVER, CONTENT_RESOLVER, POP_RULES, STORE_FACTORY, POP_CONTROLLER, POP_RESOLVER, NAME_ESCROW, and MULTICALL3.

### Multicall3

Expand Down Expand Up @@ -280,7 +272,6 @@ The protocol carries a handful of constraints worth knowing before deploying or

- **Deferred LabelStore deployment** (runtime). See [DotnsPopController](#early-testnet-quirk-labelstore-deployment).
- **Transfer fee is zero until the store is settled** (runtime). See [DotnsPopController](#early-testnet-quirk-labelstore-deployment).
- **Root origin is not propagated through delegatecalls** (runtime). See [RootGatewayDispatcher](#rootgatewaydispatcher).
- **No standalone user-status mapping** (current implementation). See [DotnsPopController](#dotnspopcontroller).

## License
Expand Down
2 changes: 1 addition & 1 deletion RELEASE_ARTIFACTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ To check the addresses against a chain:
bun run deployments:verify --network paseo-assethub --rpc <eth-rpc-url>
```

It reads the well-known keys from `DotnsConstants.sol`, resolves each through the protocol registry, and checks that every resolved address is one the manifest records and has code, that every recorded contract is pointed at by some key, and that `RootGatewayDispatcher.TARGET()` is the recorded `DotnsPopController`. The beacons are reported as unverifiable, since nothing in the registry points at them.
It reads the well-known keys from `DotnsConstants.sol`, resolves each through the protocol registry, and checks that every resolved address is one the manifest records and has code, that every recorded contract is pointed at by some key. The beacons are reported as unverifiable, since nothing in the registry points at them.

It compares the two sides as sets, so it does not check that a given key holds the contract you would expect; that pairing is asserted when a deployment is wired. It reads a committed manifest rather than a published asset, so run it from a checkout at the tag.

Expand Down
107 changes: 15 additions & 92 deletions contracts/registrars/DotnsPopController.sol
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {LabelUtils} from "../utils/LabelUtils.sol";
import {RegistrationUtils} from "../utils/RegistrationUtils.sol";
import {StringUtils} from "../utils/StringUtils.sol";
import {DotnsConstants} from "../utils/DotnsConstants.sol";
import {SystemUtils} from "../utils/SystemUtils.sol";

/// @title DotnsPopController
/// @notice Dedicated PoP controller orchestrating lite-person and full-person username
Expand Down Expand Up @@ -91,27 +92,6 @@ contract DotnsPopController is
/// state has been committed.
uint256 private constant CHAT_KEY_LENGTH = 65;

/// @notice Selector for the typed @custom:function reserveLiteName overload.
/// @dev Hard-coded to disambiguate from the `(bytes)` overload at compile time. Must stay
/// in sync with the @custom:struct LiteRegistration field layout.
bytes4 private constant SELECTOR_RESERVE_LITE =
bytes4(keccak256("reserveLiteName((string,address,bytes))"));

/// @notice Selector for the typed @custom:function reserveBaseName overload.
/// @dev `BaseReservation` is `(LiteRegistration, string)` and `LiteRegistration` is
/// `(string,address,bytes)`, hence the nested tuple in the canonical signature.
bytes4 private constant SELECTOR_RESERVE_BASE =
bytes4(keccak256("reserveBaseName(((string,address,bytes),string))"));

/// @notice Selector for the typed reservation-only gateway primitive.
bytes4 private constant SELECTOR_RESERVE_BASE_ONLY =
bytes4(keccak256("reserveBaseNameOnly((address,string))"));

/// @notice Selector for the typed @custom:function registerBaseName overload.
/// @dev `Link` is `(uint8,string,bytes)` because `LinkKind` is an enum.
bytes4 private constant SELECTOR_REGISTER_BASE =
bytes4(keccak256("registerBaseName((string,address,(uint8,string,bytes)))"));

/// @notice Protocol-level address registry for all DotNS contracts.
IDotnsProtocolRegistry public protocolRegistry;

Expand Down Expand Up @@ -155,17 +135,9 @@ contract DotnsPopController is
/// @dev Reserved storage space to allow for layout changes in future upgrades.
uint256[50] private __gap;

/// @notice Restricts calls to the address registered as the PoP gateway
/// on the protocol registry.
/// @dev Authority is delegated wholly to the registered gateway, which is
/// the Root gateway dispatcher. Any caller other than the registered
/// gateway is rejected with NotGateway. The Root-authority check
/// itself lives in the dispatcher because the revive System
/// precompile is only meaningful in the frame that is the direct
/// callee of Root, which is the dispatcher and never this UUPS
/// implementation.
modifier onlyGateway() {
_onlyGateway();
/// @notice Restricts calls to a substrate Root origin.
modifier onlyRoot() {
_onlyRoot();
_;
}

Expand Down Expand Up @@ -199,17 +171,12 @@ contract DotnsPopController is
}

/// @inheritdoc IDotnsPopController
function reserveLiteName(LiteRegistration calldata params) external override onlyGateway {
function reserveLiteName(LiteRegistration calldata params) external override onlyRoot {
_reserveLite(_popRules(), params);
}

/// @inheritdoc IDotnsPopController
function reserveLiteName(bytes calldata payload) external override onlyGateway {
_dispatchTyped(SELECTOR_RESERVE_LITE, payload);
}

/// @inheritdoc IDotnsPopController
function reserveBaseName(BaseReservation calldata params) external override onlyGateway {
function reserveBaseName(BaseReservation calldata params) external override onlyRoot {
IPopRules rules = _popRules();
bytes32 reservedHash;
bool hasReservation = bytes(params.reservedBaseLabel).length != 0;
Expand All @@ -227,28 +194,14 @@ contract DotnsPopController is
}

/// @inheritdoc IDotnsPopController
function reserveBaseName(bytes calldata payload) external override onlyGateway {
_dispatchTyped(SELECTOR_RESERVE_BASE, payload);
}

/// @inheritdoc IDotnsPopController
function reserveBaseNameOnly(BaseNameReservation calldata params)
external
override
onlyGateway
{
function reserveBaseNameOnly(BaseNameReservation calldata params) external override onlyRoot {
IPopRules rules = _popRules();
(bytes32 reservedHash,) = _validateReservableBaseLabel(rules, params.reservedBaseLabel);
_advanceExpiredHead(reservedHash);
_removeUserFromQueue(params.user);
_enqueueReservation(rules, reservedHash, params.reservedBaseLabel, params.user);
}

/// @inheritdoc IDotnsPopController
function reserveBaseNameOnly(bytes calldata payload) external override onlyGateway {
_dispatchTyped(SELECTOR_RESERVE_BASE_ONLY, payload);
}

/// @notice Lite-only mint shared by @custom:function reserveLiteName and the lite leg
/// of @custom:function reserveBaseName.
/// @dev Gateway attestation is the authority for personhood on this path; the on-chain
Expand Down Expand Up @@ -278,12 +231,7 @@ contract DotnsPopController is
}

/// @inheritdoc IDotnsPopController
function registerBaseName(bytes calldata payload) external override onlyGateway {
_dispatchTyped(SELECTOR_REGISTER_BASE, payload);
}

/// @inheritdoc IDotnsPopController
function registerBaseName(FullRegistration calldata params) external override onlyGateway {
function registerBaseName(FullRegistration calldata params) external override onlyRoot {
Link calldata link = params.link;
address user = params.user;
string calldata label = params.label;
Expand Down Expand Up @@ -895,38 +843,13 @@ contract DotnsPopController is
delete _reservedBaseLabel[labelhash];
}

/// @notice Internal check enforcing PoP-gateway-only access.
/// @dev Authorises a call when the caller matches the address registered
/// as the PoP gateway on the protocol registry. The dispatcher
/// registered there is responsible for proving substrate Root
/// authority via the revive System precompile; this contract trusts
/// that forwarded calls already carry that authority. Reverts with
/// NotGateway on failure, including when the registry key is unset.
function _onlyGateway() internal view {
address gw = protocolRegistry.get(DotnsConstants.POP_GATEWAY);
require(gw != address(0) && msg.sender == gw, NotGateway(msg.sender));
}

/// @notice Routes a raw cross-chain payload to the typed entrypoint identified by `selector`.
/// @dev Prepends `selector` to `payload` and `delegatecall`s `address(this)` so the typed
/// overload runs in the original call context, making the typed path the single source of
/// truth. The `bytes` payload from the cross-chain caller is already
/// `abi.encode(StructTuple)`, so concatenating `selector` with `payload` is exactly the
/// calldata the typed overload expects. Reverts bubble up byte-for-byte so the caller sees
/// the same error it would have seen on a direct typed call. The delegatecall target is
/// hard-coded to `address(this)` and `selector` is one of three module-private constants
/// pointing at this contract's own typed entrypoints, so storage context is preserved and
/// no external code can run in this contract's frame. @custom:function _onlyGateway runs
/// on both the outer bytes overload and the inner typed overload; both checks read the
/// same registry slot.
/// @custom:oz-upgrades-unsafe-allow delegatecall
function _dispatchTyped(bytes4 selector, bytes calldata payload) private {
(bool ok, bytes memory ret) = address(this).delegatecall(bytes.concat(selector, payload));
if (!ok) {
assembly {
revert(add(ret, 32), mload(ret))
}
}
/// @notice Internal check enforcing a substrate Root origin.
/// @dev Authorises a call when @custom:function SystemUtils.originIsRoot is true, and
/// reverts with NotRoot otherwise. `msg.sender` is deliberately not consulted: a
/// Root origin has no account behind it, so reading `msg.sender` traps. The same
/// applies to anything reachable from an onlyRoot entrypoint.
function _onlyRoot() internal view {
require(SystemUtils.originIsRoot(), NotRoot());

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

originIsRoot() holds for the whole transaction, not just the direct caller, so this check keeps passing in any nested call frame of a Root transaction. As long as nothing reached from an onlyRoot entrypoint calls a user-controlled contract, that is fine. If that ever changes, the callee could re-enter an onlyRoot function and still pass the check, so a nonReentrant on these entrypoints (or a documented invariant that they never call a user-controlled address) would be worth adding. Not blocking.

}

/// @inheritdoc UUPSUpgradeable
Expand Down
Loading
Loading