Skip to content
Closed
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
4 changes: 3 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,9 @@ Example query paths. Each row starts from a small set of known contracts; every

Any new contract address that other contracts need to read must be looked up through `DotnsProtocolRegistry` at the point of use. Do not hardcode it in a constructor, store it in an `immutable`, or expose a one-off `setX(address)` setter. The protocol registry is the only address a contract may hold directly; everything else is fetched on demand so rotation is a single `protocolRegistry.set(KEY, newAddress)` call with no upgrade.

If you are adding a new contract category, add a `bytes32` key for it in `DotnsConstants.sol`, wire it up in `WireDeployments.s.sol`, and list the contract and its interface in `.github/abi-contracts.txt` so their ABIs ship in the release artifact. Read it the same way every existing contract does.
If you are adding a new contract category, add a `bytes32` key for it in `DotnsConstants.sol`, deploy it from one of the stages in `scripts/deploy/run.sh` (not from `DotnsDeployer.s.sol`, which is not in that list and which `scripts/genesis/build-genesis.sh` therefore never runs), wire it up in `WireDeployments.s.sol`, and list the contract and its interface in `.github/abi-contracts.txt` so their ABIs ship in the release artifact. Read it the same way every existing contract does.

There is exactly one sanctioned exception, `DotnsRootGateway`. It is a non-upgradeable leaf that holds the protocol registry in an `immutable`, because it must not sit behind a proxy: `ISystem.callerIsRoot` resolves the caller two frames below the precompile, and a delegatecall frame displaces the one it inspects. The rule the exception preserves rather than breaks is the point of the rule — the immutable it holds *is* the registry, so every sibling it touches is still resolved on demand, and the gateway itself is rotated by `protocolRegistry.set(ROOT_GATEWAY, ...)` rather than by an upgrade.

Bad — the registrar address is frozen at construction, so rotating it needs an upgrade:

Expand Down
43 changes: 31 additions & 12 deletions DEPLOYMENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -283,40 +283,59 @@ If the deployment was intended to update a public environment, update the addres

## Name grants (whitelisting)

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.
Reserved registration is gated on `DotnsNameWhitelist`. A grant binds one label to one beneficiary address and is single use: on the public branch `registerReserved` requires a grant naming `registration.owner`, spends it on the mint, and refuses a second attempt. A governance mint arriving through the Root gateway skips both the grant check and the spend, so that governance can issue a withheld label without first granting it to itself; that branch is reachable only from the single address registered under `ROOT_GATEWAY`. See the [README economics section](./README.md#economics) for what a grant does and does not confer; this section covers the mechanics.

**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.
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. "Root dispatch" here means a dispatch whose `dest` is the `ROOT_GATEWAY` contract, and the gates admit that address alone. See [Governance dispatch](#governance-dispatch).

Two owner-level routes reach the same outcome and are **not** closed by this. `DotnsProtocolRegistry.set` is `onlyOwner`, so the owner can point the `nameWhitelist` key at a contract whose `isGrantedTo` returns true for everything. `DotnsRegistrar.addController` is `onlyOwner`, and a controller can mint any available name directly, without the whitelist at all. Treat the guarantee here as covering the whitelist's own admin surface, not the protocol as a whole, until upgrade authority and deploy-time ownership are settled.
Three owner-level routes reach the same outcome and are **not** closed by this. `DotnsProtocolRegistry.set` is `onlyOwner`, so the owner can point the `nameWhitelist` key at a contract whose `isGrantedTo` returns true for everything, or point `rootGateway` at a contract it controls and call the governance surface through it. `DotnsRegistrar.addController` is `onlyOwner`, and a controller can mint any available name directly, without the whitelist at all. None of these is new: the owner authorises every UUPS upgrade, so it could always replace an implementation with one that has no gate. Treat the guarantee here as covering the whitelist's own admin surface, not the protocol as a whole, until upgrade authority and deploy-time ownership are settled.

The controller carries no roles either. It reads grants and consumes them, so `setRole` on the controller reverts with `UnsupportedRole`.

### Dispatching a grant
### Governance dispatch

The dispatch is a Substrate extrinsic with the contract call nested inside it:
Every governance call goes through `DotnsRootGateway`, the address registered under `ROOT_GATEWAY`. It is the `dest` of the `Revive.call`; the call you actually want is wrapped inside its `execute`:

```
Root origin
└─ Revive.call { dest: <DotnsNameWhitelist H160>, value: 0, data: <EVM calldata> }
└─ grantName(string,address)
└─ Revive.call { dest: <DotnsRootGateway H160>, value: 0, data: <EVM calldata> }
└─ execute(address[],bytes[])
└─ [<DotnsNameWhitelist H160>], [grantName(string,address)]
```

`cast` can be used for the innermost layer to encode `data`:
`cast` encodes the inner payloads:

```bash
# grant one label to one beneficiary
cast calldata "grantName(string,address)" "$LABEL" "$ADDRESS"
INNER=$(cast calldata "grantName(string,address)" "$LABEL" "$ADDRESS")

# grant several labels to one beneficiary, up to maxGrantBatch
cast calldata "grantNames(string[],address)" '["alpha01","beta02"]' "$ADDRESS"
INNER=$(cast calldata "grantNames(string[],address)" '["alpha01","beta02"]' "$ADDRESS")

# release an unspent grant
cast calldata "revokeName(string)" "$LABEL"
INNER=$(cast calldata "revokeName(string)" "$LABEL")
```

Building the `Revive.call` around that hex and dispatching it as Root is done on the Substrate side. Wrap it in `Sudo.sudo` on a test network; put it up as a referendum on a production one. Both produce the same Root origin the contract checks, so the same `data` works either way.
then wrap them for the gateway:

```bash
cast calldata "execute(address[],bytes[])" "[$WHITELIST]" "[$INNER]"
```

`execute` takes positionally matched arrays, so several governance actions ride in one dispatch and either all apply or none do:

```bash
cast calldata "execute(address[],bytes[])" \
"[$WHITELIST,$POP_RULES]" \
"[$GRANT_CALLDATA,$SHORT_NAMES_CALLDATA]"
```

Building the `Revive.call` around that hex and dispatching it as Root is done on the Substrate side. Wrap it in `Sudo.sudo` on a test network; put it up as a referendum on a production one. Both produce the same Root origin the gateway checks, so the same `data` works either way.

**The gateway must be the direct `dest` of the dispatch.** It authorises with `ISystem.callerIsRoot`, which resolves the caller two frames below the precompile. Any contract sitting between Root and the gateway — `Multicall3`, or another forwarder — occupies that frame and the check reads `false`, reverting with `NotRoot`. Batch through `execute` instead of through `Multicall3`. A Substrate-level `Utility.batch` of several separate `Revive.call`s is fine, since each one starts its own call stack.

Targets must be registered in the protocol registry; anything else reverts with `TargetNotProtocol`. The gateway holds no storage, has no owner, and is deliberately not upgradeable: a proxy would insert a delegatecall frame and break `callerIsRoot`, bricking the whole governance surface. Replacing it means deploying a new one and repointing `ROOT_GATEWAY`, which is an `onlyOwner` call on the protocol registry.

The gas and storage-deposit limits belong to the `Revive.call` extrinsic rather than the contract call. Dry-run the call to size them rather than guessing, as an underestimate fails the whole dispatch.

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,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 under a substrate Root origin, which the controller verifies itself by reading `originIsRoot` from the revive System precompile.
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 by governance, which reaches them through `DotnsRootGateway`: the controller checks that `msg.sender` is the address registered under the `rootGateway` key rather than inspecting the transaction's origin.

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 Down
30 changes: 24 additions & 6 deletions contracts/external/revive/ISystem.sol
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,31 @@ pragma solidity ^0.8.34;
/// rather than mirrored wholesale, so the audit surface stays small
/// and any upstream change is a deliberate review event.
interface ISystem {
/// Returning true iff the immediate caller's substrate origin is `Root`.
/// Reverts on a `RuntimeOrigin::Signed(_)` or non-Root origin.
/// Checks whether the caller of the contract calling this function is root.
///
/// @dev Returns `false` on a signed origin; it does not revert.
///
/// Frame caveat, and the reason this cannot be used from a DotNS contract directly: the
/// check resolves the caller two frames below the precompile, and a `delegatecall`
/// occupies a frame of its own. A UUPS implementation therefore reads `false` here even
/// on a direct one-hop Root dispatch, because the frame at that depth is the proxy's own
/// rather than Root. Only a contract Root calls directly, with no proxy in front of it,
/// reads `true`. See @custom:contract DotnsRootGateway, which exists to be that shape.
function callerIsRoot() external view returns (bool);

/// Returning true iff the transaction-level substrate origin is `Root`.
/// @dev Reads the stack origin rather than the immediate caller, so it holds through a UUPS
/// proxy's delegatecall frame where `callerIsRoot` returns false. Returns false, rather
/// than reverting, on a non-Root origin.
/// Checks whether the origin of the whole call stack is root.
///
/// @dev Unlike `callerIsRoot`, this does not require the immediate caller to be the origin: it
/// returns `true` whenever the top-level dispatch was made with a root origin, regardless
/// of how many contract or delegate-call frames separate the precompile from that
/// dispatch. This is the analogue of `tx.origin == ROOT` and is intended for upgradeable
/// proxy patterns where root authority needs to flow through intermediate frames. Returns
/// false, rather than reverting, on a non-Root origin.
///
/// NOT AN AUTHORISATION PRIMITIVE. Because it holds in every frame of a Root transaction,
/// any contract reached during that transaction reads `true` here, including code the
/// Root dispatch did not intend to trust. It answers "was this transaction
/// Root-dispatched", not "is Root my caller". DotNS gates on the latter, through
/// @custom:contract DotnsRootGateway.
function originIsRoot() external view returns (bool);
}
82 changes: 82 additions & 0 deletions contracts/governance/DotnsRootGateway.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.34;

import {IDotnsProtocolRegistry} from "../registry/IDotnsProtocolRegistry.sol";
import {IDotnsRootGateway} from "./IDotnsRootGateway.sol";
import {SystemUtils} from "../utils/SystemUtils.sol";

/// @title DotnsRootGateway
/// @notice Sole authorised caller of the governance surface on the registrar controller,
/// `PopRules`, `DotnsNameWhitelist` and `DotnsPopController`.
/// @dev MUST NOT be deployed behind a proxy, and MUST NOT be made upgradeable. The entire value of
/// this contract is that it is the frame shape in which `ISystem.callerIsRoot` reads true: a
/// plain contract that Root calls directly. A `delegatecall` frame, or any contract sitting
/// between Root and this one, displaces the frame the precompile inspects and the check reads
/// false. There is deliberately no owner, no initialiser and no mutable storage.
///
/// `msg.sender` is never read here. This contract is called by Root, which has no account, so
/// the PVM `caller` syscall would trap. Authority comes from `callerIsRoot` alone.
///
/// Registration under `ROOT_GATEWAY` makes `IDotnsProtocolRegistry.isRegisteredAddress` true
/// for this address. That confers nothing on its own: store writes are gated by
/// @custom:contract StoreAuth, which admits the registrar, the registrar's controllers and
/// the registry, and deliberately rejects registry membership as authority.
///
/// Forwards no value. `execute` is not `payable` and makes every call with zero value, so a
/// governance entry point that is made `payable` later would be reachable from here but
/// never funded. Add value plumbing deliberately if that day comes; do not assume it works.
/// @custom:security-contact admin@parity.io
contract DotnsRootGateway is IDotnsRootGateway {
/// @notice Protocol-level address registry used to validate forwarding targets.
/// @dev Immutable and set at construction. The repository's rule that contracts resolve
/// siblings through the registry rather than an immutable peer address is satisfied: the
/// immutable here *is* the registry, which remains the indirection layer. This contract
/// is a non-upgradeable leaf, so it holds no storage to rewire.
IDotnsProtocolRegistry public immutable protocolRegistry;

/// @param registry Protocol-level address registry.
constructor(IDotnsProtocolRegistry registry) {
require(address(registry) != address(0), InvalidRegistry());
protocolRegistry = registry;
}

/// @inheritdoc IDotnsRootGateway
function execute(address[] calldata targets, bytes[] calldata payloads) external override {
// Asks "is Root my caller", not "was this transaction Root-dispatched". It is answerable
// here, and only here, because this contract is not behind a proxy: `callerIsRoot`
// resolves the caller two frames below the precompile, and with no delegatecall frame in
// the path that resolution falls through to the transaction origin.
//
// This is also what makes the function non-reentrant without a guard. A callee that calls
// back into `execute` puts its own frame at the depth the precompile inspects, so the
// check reads false and the re-entry reverts. Do not add a reentrancy guard on the
// assumption that re-entry is otherwise possible, and do not weaken this check without
// reinstating that property some other way.
require(SystemUtils.callerIsRoot(), NotRoot());
require(targets.length == payloads.length, LengthMismatch());
require(targets.length != 0, EmptyBatch());

for (uint256 i = 0; i < targets.length; i++) {
address target = targets[i];
bytes calldata payload = payloads[i];

// Confine the gateway to the protocol. Root can dispatch anywhere on its own; what it
// must not be able to do is borrow this contract's identity to reach code outside the
// set, since downstream gates authorise on exactly that identity.
require(protocolRegistry.isRegisteredAddress(target), TargetNotProtocol(target));

emit RootCallForwarded(target, payload.length >= 4 ? bytes4(payload[:4]) : bytes4(0));

// A regular CALL, not a delegatecall: the callee must observe this contract as
// `msg.sender`, which is the whole authorisation mechanism.
(bool ok, bytes memory returndata) = target.call(payload);
if (!ok) {
// Bubble the callee's revert data unchanged so governance sees the real error
// rather than a generic gateway failure.
assembly ("memory-safe") {
revert(add(returndata, 0x20), mload(returndata))
}
}
}
}
}
Loading
Loading