Repository navigation
Conversation
Signed-off-by: GHkrishna <krishna@parity.io>
Signed-off-by: GHkrishna <krishna@parity.io>
…n issue Signed-off-by: GHkrishna <krishna@parity.io>
Signed-off-by: GHkrishna <krishna@parity.io>
Signed-off-by: GHkrishna <krishna@parity.io>
Signed-off-by: GHkrishna <krishna@parity.io>
Signed-off-by: GHkrishna <krishna@parity.io>
Signed-off-by: GHkrishna <krishna@parity.io>
Signed-off-by: GHkrishna <krishna@parity.io>
Signed-off-by: GHkrishna <krishna@parity.io>
Signed-off-by: GHkrishna <krishna@parity.io>
Contributor
CI Summary
|
GHkrishna
requested review from
re-gius
and removed request for
sphamjoli and
waytwotall
September 14, 2026 03:54
Signed-off-by: GHkrishna <krishna@parity.io>
Signed-off-by: GHkrishna <krishna@parity.io>
Signed-off-by: GHkrishna <krishna@parity.io>
re-gius
requested changes
Sep 14, 2026
re-gius
left a comment
Collaborator
There was a problem hiding this comment.
I believe the architecture makes sense, so I am generally in favor of this change. However, I would postpone it to R2 for the following reasons:
- In this R1 setting where the owner still retains significant power, a single owner-signed call can make any new contract trusted as governance. This does not extend significantly the vulnerability surface, since the owner already has unlimited upgrading power, but a registry set operations is definitely less-watched than a proxy upgrade, which is itself more silent than a root referendum (however shady the referendum call may be). This makes a critical change to the root gateway far less visible than a dangerous root call.
- It makes sense to have this audited and tested end-to-end for R2, once we move all the main power to root. Now we still have significant ownership powers to potentially address a mischievous root call (which is unlikely)
- R2 may let us build a better final solution if any change to the polkadot-sdk lands in the meantime
Contributor
Author
|
Closing as not required, refer #300 comments for the alternate fix |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
What
Four contracts gated governance on
SystemUtils.originIsRoot(), which is transaction scoped: true in every frame of a Root-origin transaction, including a direct Root caller. Any contract that a Root dispatch happened to reach, inherits full DotNS governance authority while holding no DotNS key.This PR introduces
DotnsRootGateway, a non-upgradeable contract that Root dispatches to directly and moves all four gates to an address comparison against it, via a newGovernanceAuthlibrary.No external selector changes. No storage-layout changes. No
polkadot-sdkchanges. (Relation to polkadot-sdk added below ni the notes section)Why
Root on Asset Hub is chain-wide. It is dispatched to fix a stuck contract for an unrelated project, run a migration, push a remediation — decided by voters who have never heard of DotNS. DotNS had bound its admin gate to that credential.
A referendum passes to call
FooRecovery.sweep()and return locked funds to users. That contract also contains:Both hit
require(SystemUtils.originIsRoot(), ...). The transaction was Root-dispatched, so both succeed. There is no exploit primitive, no reentrancy, no overflow, no clever calldata. The attacker calls the public function and it works; all the difficulty is in being in the stack. Nothing in the referendum shows it: the dispatch isRevive.call { dest: 0xF00…, data: sweep() }and DotNS appears nowhere. A confused access failure, not a governance failure.Severity is
medium, not worse, because no path inside DotNS reaches an attacker-controlled address (the mint is_mint, not_safeMint; the only low-level call is a refund on the priced path), so it needs a Root dispatch aimed at code governance does not control. The free-mint limb additionally needs a live commitment inside a two-sided window with a matchingpricingVersion, at a moment the attacker does not schedule.14 entry points sat on that one predicate:
DotnsRegistrarController.registerReservedPopRules.setShortNamesEnabledDotnsNameWhitelistgrantName,grantNames,revokeName,setReserved,setWindow,accept,reject,setMaxClaimants,setMaxReasonBytes,setMaxGrantBatchDotnsPopControllerreserveLiteName,reserveBaseName,reserveBaseNameOnly,registerBaseNameDotnsPopController._onlyRootwas the only one that reasoned about stack scope at all, and its invariant was outbound only, "nothing reachable from anonlyRootentrypoint may call a user controlled address". The code follows that. The inbound calls were missed.Why simply 'callerIsRoot' cannot be used
callerIsRootis the right primitive and does not work for these contracts. The System precompile dispatches it asenv.caller_is_root(true), resolvingframes().nth(2), the caller of the contract asking. Adelegatecallcallspush_frame, so it occupies a real frame:nth(2)callerIsRootNone→ falls back toStack::origin= RootNone→ RootRow 2 is every DotNS contract, so
callerIsRootreads false even on a direct one-hop Root dispatch.msg.senderis not a fallback either: the delegate frame's caller is Root, which has no address, so the PVMcallersyscall traps. Note these are two different failure modes: one returns false, one reverts. That leftoriginIsRoot, which answers truthfully but answers a broader question.Upstream will not fix this. #12051 "callerIsRoot doesnt survive proxy delegatecalls" (head branch
dotns/system-precompile) was an attempt by @sphamjoli to makecallerIsRootwalk delegate-only frames; the PR was never merged though. #12281 addedoriginIsRootinstead. We need to have that in a longer tem.The fix
contracts/governance/DotnsRootGateway.solnon-upgradeable, no storage, no owner, immutable registry pointer:It is the one frame shape where
callerIsRootis answerable, and it forwards by regularCALL, so downstreammsg.senderis a readable address naming it exactly.All four gates now go through one library:
GovernanceAuthowns the fail closed guard for an unwired key, so there is a single place to audit rather than four copies to keep in sync.SystemUtils.originIsRoot()is removed, not deprecated.Consequences to review
1. A third owner-level route.
DotnsProtocolRegistry.setisonlyOwner, so the owner can pointROOT_GATEWAYat a contract it controls. This changes nothing in practice. The owner authorises every UUPS upgrade and could always ship an implementation with no gate, butDEPLOYMENTS.mdlisted two owner-level routes and now correctly lists three.2. The gateway must be the direct
destof the Root dispatch. Anything between Root and it (Multicall3, another forwarder) occupies the framecallerIsRootinspects and the check reads false. That is whyexecutebatches internally. Substrate-levelUtility.batchof separateRevive.calls is fine, each starts its own stack.3. Non-upgradeable is load-bearing, and it is still replaceable. A proxy in front of the gateway would compile, deploy, and silently brick every governance entry point. Rotation is
protocolRegistry.set(ROOT_GATEWAY, newOne). The registry re-direction is the actual upgrade path.4. Registry membership is not authority, so registering the gateway grants it nothing.
LabelStoregates onStoreAuth.isStoreWriter(registrar / registrar controllers / registry), andStoreAuth.sol:11-18explicitly rejectsisRegisteredAddressas a gate, naming the registered-forwarder case.Migration order
DotnsRootGatewayROOT_GATEWAYin the protocol registryReversed, the governance surface reverts until the key is added. Recoverable; the zero-address guard in
GovernanceAuthmakes it fail loudly rather than silently comparing againstaddress(0).Type
Scope
Related Issues
Fixes
#300
Checklist
Code
forge buildpassesforge testpassesTesting
Security
selfdestructordelegatecallDocumentation
Governance dispatch section added in
DEPLOYMENTS.mdwith the wrappedcastrecipe, and two corrections that described a guarantee the Root branch never delivered:registerReserved"requires a grant namingregistration.owner", and "no single key can grant a name".CONTRIBUTING.mdrecords the deploy-stage requirement (new contracts must be deployed from a stage inscripts/deploy/run.sh, notDotnsDeployer.s.sol, whichbuild-genesis.shnever runs) and the gateway as the one sanctioned exception to the no-immutable-peers rule.Breaking Changes
Breaking changes:
Operational, not ABI. Every external selector, event, storage slot and deployed address on existing contracts is unchanged, and the public paths (
commit,register,registerReservedas a grantee) are exactly identical.how a governance call is dispatched changes as below:
before
After (Root calls the gateway, which forwards that same inner call):
Anything that builds a Root dispatch must be updated: governance runbooks, referendum-prep scripts, and any
sudohelper indotns-cli. Tooling that only reads, registers, or resolves names needs no change. The deployment manifest gains aDotnsRootGatewayentry at deploy time.How to test
Notes
Naming: we already use "gateway" for the PoP path. The README calls DotnsPopController "the personhood gateway". DotnsRootGateway overloads that. The old RootGatewayDispatcher used "gateway" for the Root path too, so there's precedent either way. If anyone feels strongly, DotnsRootDispatcher works and it's a cheap rename now. Once it's deployed the address is in the manifest and renaming means a redeploy.
Left out on purpose: requiring a grant on the governance branch of registerReserved. Worth doing at some point, since it clears the stranded-grant case at DotnsRegistrarController.sol:309-315, but it doesn't fix this and on its own it would have made things worse. grantName sits behind the same gate and does more: an attacker inside a governance transaction just grants themselves the name and registers it later through the normal path, whenever they like, with no commitment window to line up. Patching only registerReserved makes the attack easier, not harder.
Separate upstream change: We can file callerIsRootThroughDelegate() against polkadot-sdk, a new precompile method with #12051, Spha's semantics. If that lands and Asset Hub picks it up, the gateway goes away and SystemUtils changes by one line. That'might take some time though.
Before deploy: commit the updated deployments//.json once the gateway is deployed, same as any new contract.