ERC-8107: ENS Trust Registry for Agent Coordination

I wanted to follow up on my earlier question about ENSv2 compatibility, especially after some really useful feedback from Spap over on the ENS forum.

One thing this process keeps reminding me is that owning an ENS name and being able to manage it aren’t necessarily the same thing.

Spap tested our assumptions against deployed ENS contracts and found a couple of interesting edge cases.

For example, an unemancipated wrapped subname can still have an owner after expiry. And during the 90-day .eth grace period, the holder can still be recognised as the owner even though they can’t perform certain management operations.

That raises an interesting question for ERC-8107: should someone lose the authority to express trust just because they temporarily lose the ability to manage their ENS name?

I’m not convinced they should, particularly when we’re talking about withdrawing trust or responding to a compromised agent.

There’s also the ENSv2 migration question.

Something worth understanding is that migrating a name doesn’t automatically erase its associated trust relationships. Existing endorsements and denials can remain relevant even when the original controller can no longer issue new attestations or manage those records.

This becomes especially interesting when you have autonomous agents operating under ENS subnames. Migrating a parent name can eventually affect the ownership and resolver records of the agents beneath it, particularly through the Graveyard cleanup process.

So where have we landed?

For now, I think the cleaner approach is to keep ERC-8107 focused on ENSv1 and let an ENSv2-native successor registry define its own authority and resolution rules.

We could build adapters to support both versions, but that introduces another layer of complexity and another place where we could misunderstand who actually has authority.

I’d rather get that boundary right than rush compatibility.

This also connects to something we’ve been exploring with ERC-8001. Coordination authority and trust are related, but they’re not interchangeable. ERC-8001 deals with what participants have agreed to do together. ERC-8107 helps establish whose judgment a coordinator is willing to rely on.

ENS provides the identity underneath both.

We’re incorporating the ENS lifecycle findings into the next ERC-8107 update, and I think the standard will be stronger for it.

Thanks again to Spap and everyone who’s taken the time to challenge the assumptions. These conversations are exactly why I wanted to bring the work to the wider ENS community.

For anyone following along, here’s the discussion:

1 Like

Two updates to ERC-8107: PR #2067 has been merged, and a follow-up, PR #2068, is open as a draft.

#2067 (Merged): Denial handling and robustness

This PR incorporates the changes discussed in this thread:

  • setDenial and clearDenial are now separate functions. Clearing a denial is checked against the trustor’s nonce.

  • The specification explicitly defines effective denial as getDenial(A, B, 0) || getDenial(A, B, s).

  • Callers clearing a denial should raise the nonce floor before reviewing.

  • The robustness fixes are included.

@Revinand, this also includes the clarification on concurrent signing from comment #19.

An attestation signed by the trustor after raising the nonce floor, but held back until after the denial is cleared, can still take effect. That’s a fresh authorisation by the controller.

Callers who want to prevent this SHOULD pause or serialise signing until the clearing transaction has executed.

The ITrustRegistry interface ID is now 0x94f0ee34.

#2068 (Draft): ENS lifecycle corrections

Spap tested controllerOf against the deployed ENS contracts and shared the findings in the ENS forum discussion.

Those tests revealed that several of our statements about expiry needed correcting. I reproduced the findings on mainnet and Sepolia forks.

The key corrections are:

  • Expired wrapped names do not always lose authority. Only names with PARENT_CANNOT_CONTROL burned do. An unemancipated subname retains its holder after expiry.

  • Wrapped .eth names retain authority during the 90-day grace period, even though ENS no longer permits the holder to manage the name during that period.

  • ENSv2 migration behaves as the specification described, with two additional details:

    • An approved operator can migrate a name, potentially freezing its owner’s trust state.

    • The Graveyard’s clear operation ends a lapsed holder’s authority over an expired unwrapped name.

The contract behaviour, interface, and interface ID remain unchanged.

One recommendation using SHOULD has also been corrected: agents should use wrapped .eth names or emancipated subnames, rather than just any wrapped name.

One open question: Authority during the grace period

ERC-8107 currently ties authority to ENS ownership, not ENS management permissions.

This means authority continues throughout the grace period, allowing holders to withdraw endorsements, issue denials, and invalidate nonces when those protections may matter most.

But it raises an important question:

Should a holder in the grace period also be permitted to issue new endorsements?

Under the current model, they can.

Preventing this would require treating the issuance of new endorsements differently from other authority-bearing operations during that lifecycle state.

I lean towards retaining one consistent authority rule, but I’d welcome other perspectives before Last Call.

PR #2068 will remain a draft so the community can review and comment. Once that discussion concludes, I’ll resume moving toward Last Call.

1 Like

Thanks @KBryan, the concurrent-signing wording addresses my concern.

On the grace-period question, I would keep one controller authority rule and allow new endorsements during grace.

For a wrapped .eth name, ownerOf still returns the holder during grace while canModifyName is false. ERC-8107 deliberately follows ownership. Blocking new endorsements while allowing withdrawal, denial, clearing and nonce invalidation would add an operation-specific lifecycle rule. clearDenial can itself restore trust, which complicates a split based on keeping only protective actions.

That restriction would also be uneven across name forms: the registry cannot determine an unwrapped .eth name’s registrar expiry from its namehash alone.

Grace signals a possible lapse, but persistence across controller changes already exists. An endorsement created before an ordinary transfer can survive it too. I would manage that risk through bounded expiries with enforcement enabled, monitoring control and expiry and re-evaluating gates after control changes.

The draft’s grace-end expiry recommendations preserve the authority rule. I would keep the distinction explicit: they guide signers and coordinators; they do not automatically change on-chain acceptance or path validity.

2 Likes

Thanks, agreed on all points. The draft already keeps the single authority rule and accepts endorsements during grace. Commit 3406c01 makes the distinction explicit: the grace-end expiry caps are guidance for signers and coordinators and do not change on-chain acceptance or path validity.

  • Wrapped Name Lifecycle: now also recommends that coordinators validate with enforceExpiry, monitor the trustor’s control and registrar expiry, and re-evaluate gates after control changes.
  • Rationale: notes that clearDenial can restore trust, so a protective-only split wouldn’t be clean, and that persistence across controller changes already applies to ordinary transfers.

Text only: no behaviour, ABI or interface ID change.

2 Likes

PR #2080 is ready for review. It follows #2077 (merged as a370db5d) and is the last hardening pass I plan before proposing Last Call. The status stays Review.

The core interface is unchanged. ITrustRegistry stays at 0x94f0ee34. The EIP-712 domain, the typehash, signature authority, operator permissions, controllerOf, expiry handling and the storage layout are all unchanged. There is one new on-chain behaviour: an identity gate that could never admit anyone is now rejected when it is configured.

Normative changes

  • The optional extension is withdrawn. ITrustRegistryExtended (0x93e686a3: getTrustees, getTrustors, validateAgent, pathExists, validateParticipant) was advertised as an ERC-8107 extension, but it never said how its functions apply expiry, withdrawal, denial or scope fallback, and pathExists took no ValidationParams. Two implementations could return different answers for the same state, so I removed it rather than patched it. The reference implementation never implemented it, and I know of no deployment that reports it. The spec now assigns 0x93e686a3 no meaning. A short non-normative note says that graph discovery or trust aggregation could be specified separately in future, and what such a spec would have to define. Path verification still does no marginal accumulation, as before.
  • Gates that can never pass are rejected. Only intermediary nodes satisfy requiredAnchors, so a gate with anchors and maxPathLength == 1 rejects every participant. setIdentityGate now reverts with UnsatisfiableAnchors(uint8) for that configuration. It is a new Gate Constraints subsection and row 4 of the Revert Order table. The check applies to gates only: verifyPath called directly with the same parameters still returns false, and the spec says so normatively.

Clarifications

  • ENSv2. The ENSv2 descriptions are now marked informative and tied to the Sepolia deployment they were checked against: block 11,906,480, 13 October. At that block, the BaseRegistrar’s only controllers are the Graveyard and a renewal controller with no registration function, so ENSv1 registration is closed there. The spec no longer says what will happen on mainnet, which is not established.
  • Same identifier, different behaviour. Drafts merged between 13 and 16 October also report 0x94f0ee34 and EIP-712 version “1”, but their validateParticipantAddress does not require the terminal node to have a controller. I considered bumping the domain version to “2” to flag this, but decided against it. The EIP-712 version names a signing domain, and the signing domain hasn’t changed. Bumping it would break signers that hard-code “1”. Both values are self-reported anyway, so neither proves behaviour. The spec now says consumers identify a registry by its deployment address and verified source.
  • Editorial. The recommendations on endorsements made during the grace period are now split into trustor-side and coordinator-side lists, with the same substance. The denial-authority sentence has moved from the gas paragraph to the start of the validator-denial rationale. The 10-edge path gas figures were rerun, and the measurement method is now stated.

Tests

  • Reference suite: 227 → 236. The new tests cover the gate constraint, its revert order, a fuzz test showing that only that configuration is newly rejected, and an unchanged verifyPath. I checked that the tests catch the check being removed and catch it being moved into the shared routine. Two tests also pin existing behaviour: on G → T → M, the registry’s verdict is the same after a renewal of T as after another party re-registers it. That is why continuity is checked by the coordinator.
  • Fork suites, not committed: ENSv1 lifecycle on mainnet (18/18) and ENSv2 migration on Sepolia (12/12, including a new check that a CANNOT_TRANSFER name cannot be migrated).

Still open before Last Call: the re-registration continuity checks. @Spap is fork-testing the re-registration scenario independently, and I won’t change continuity semantics until I have those results.

Review welcome. If you build on ITrustRegistry.sol and import ITrustRegistryExtended from it, that import is gone.

1 Like

An update on ERC-8107 PR #2080, which remains open as a draft.

The latest commit is text-only. The contract, interface, storage layout, EIP-712 domain and 0x94f0ee34 are unchanged.

1. Clarifying the continuity check

The previous guidance under Wrapped Name Lifecycle was ambiguous.

Coordinators were told to treat an attestation as expiring at the end of the grace period in force when it was set, “unless the name was renewed before that grace period ended”.

Read literally, a single renewal during grace could lift that boundary permanently.

On a mainnet fork, G→X→M2 still passed that interpretation after X was renewed once, subsequently lapsed, and was registered by someone else.

This was a gap in the coordinator guidance, not the contract. verifyPath behaved as specified throughout.

2. The replacement rule: registration boundary

An attestation becomes ineligible once the BaseRegistrar emits NameRegistered for a .eth second-level name involved as trustor or trustee, after the attestation’s TrustSet.

The rule has several important properties:

  • Renewals: NameRenewed never moves the boundary, regardless of how many renewals occur.

  • Event ordering: Events are ordered by block, transaction index and log index. Coordinators check the label against the node.

  • Unavailable evidence: If a coordinator cannot establish the label or relevant events, it must not rely on the attestation.

  • Transfers: A sale within the same registration does not trigger this rule. Existing guidance on monitoring controller changes still applies.

  • Re-registration: Even if the original holder registers the name again, a new endorsement is required.

3. Testing and independent verification

I tested the rule using a non-normative coordinator-side reference implementation, both locally and on a mainnet fork against the deployed registrar controller and BaseRegistrar.

The fork tests covered:

  • One renewal followed by re-registration.

  • Several renewals followed by re-registration.

  • Several renewals without re-registration.

  • Re-registration by the same holder.

  • Registration and endorsement in the same block.

  • Transfer within an existing registration.

Not yet tested: Wrapped names and the ENSv2 Graveyard.

The Rationale also no longer claims that recording a name’s registrar expiry would reveal an earlier registration on-chain. It would not: both renewal and re-registration can produce a later expiry, so expiry alone cannot distinguish them.

@Spap is going to rerun G→T→M2 with the coordinator check. An independent run against this rule would be particularly valuable, and I’m treating it as provisional until those results are available.

I’ve moved #2080 back to draft to prevent merging before that confirmation.

4. Other text corrections

The same commit also tightens several statements that previously claimed more than the standard guarantees.

  • Gate verdicts are point-in-time. A participant admitted at acceptance remains admitted unless the coordinator checks again. Coordinators whose execution depends on continued eligibility SHOULD re-evaluate the gate in the executing transaction, for example through executeCoordination using paths supplied in executionData. Denial now rejects in every evaluation after the denial, rather than acting “at once”.

  • Upgradeable proxies. For a registry behind an upgradeable proxy, the registry address and a one-time source check are insufficient. Consumers must also track the implementation and who controls upgrades.

  • Gate Constraints. The text now identifies the specific configuration rejected by setIdentityGate, rather than implying it rejects every gate that could never pass.

  • ENS Name Transfer. Stored attestations survive controller changes. Signed but unsubmitted attestations are verified against the current controller.

Next steps

The main outstanding item is independent confirmation of the registration-boundary rule before returning #2080 to Review.

Comments are welcome, particularly on the boundary rule and any lifecycle cases we may have missed.

1 Like

An update to #2080, which is still open as a draft. It adds two more commits. They change the spec text and two tests; the contract, the interface, the storage layout, the EIP-712 domain and 0x94f0ee34 are all unchanged.

The continuity check was ambiguous

Under Wrapped Name Lifecycle, coordinators were told to treat an attestation as expiring at the end of the grace period in force when it was set, “unless the name was renewed before that grace period ended”.

Read literally, a single renewal within grace lifts the bound permanently. On a mainnet fork, G→X→M2 still passed that reading after X was renewed once, lapsed, and was registered by someone else.

This was a gap in the guidance, not in the contract: verifyPath behaved as specified throughout.

The replacement rule is a registration boundary

An attestation becomes ineligible once the BaseRegistrar emits NameRegistered for a .eth second-level name it involves, as trustor or trustee, after the attestation’s TrustSet.

  • NameRenewed never moves the boundary, however many renewals there are.

  • Events are ordered by block, transaction index and log index. The coordinator checks the label against the node.

  • A coordinator that can’t establish the label or the events doesn’t rely on the attestation.

  • The rule is about registration, not control. A sale within one registration doesn’t trigger it; the existing guidance on monitoring control changes covers that case.

  • It also covers any name on the path below a .eth second-level name, which counts as registered again whenever that ancestor is, because the ancestor’s next registrant can reassign it.

  • The coordinator checks the TrustSet of the record the path actually uses after scope fallback, not the latest record for the pair in any scope.

  • One deliberate cost: a holder who lets a name lapse and then registers it again has to be endorsed again by every trustor that endorsed it, not only reissue its own endorsements. Trustors should watch their trustees’ registrations.

Testing and evidence

I tested the rule against a non-normative coordinator-side reference, both locally and on a mainnet fork against the deployed registrar controller and BaseRegistrar.

The fork cases were:

  • One renewal, then a re-registration.

  • Several renewals, then a re-registration.

  • Several renewals with no re-registration.

  • Re-registration by the same holder.

  • A registration and an endorsement in the same block.

  • A transfer within a registration.

Wrapped names and the ENSv2 Graveyard haven’t been run yet.

The Rationale also no longer claims that recording a name’s registrar expiry would reveal an earlier registration on-chain. It wouldn’t, because a renewal and a new registration both leave a later expiry.

Independent verification

Spap reran G→T→M2 with their own implementation of the coordinator check as merged in #2077 (ENS forum).

Every re-registered path was rejected, nothing was rejected wrongly at issuance or after a renewal, and the liveness read caught a wrapped name renewed by the current controller.

Those cases don’t include a renewal before re-registration, so they don’t reach the gap above.

I’ve asked for that case against this rule, and I’m treating the rule as provisional until it runs. #2080 stays in draft until then.

Smaller corrections in the same update

These address places where the text claimed more than the standard guarantees:

  • Gate verdicts are point-in-time. A participant admitted at acceptance stays admitted unless the coordinator checks again. Coordinators whose execution depends on it SHOULD re-evaluate the gate in the executing transaction, for example in executeCoordination with the paths in executionData. That re-check reads registry state only. It can’t see the registrar events the continuity check needs, so that check stays off-chain, as close to execution as possible. Denial now “rejects in every evaluation after the denial”, not “at once”.

  • Upgradeable proxies. For a registry behind one, the address plus a one-time source check isn’t enough: consumers track the implementation and whoever can upgrade it.

  • Gate Constraints. The text now names the one configuration setIdentityGate rejects, rather than implying it rejects every gate that can never pass.

  • ENS Name Transfer. Stored attestations survive a change of controller. Signed but unsubmitted ones verify only against the current controller.

Comments welcome, especially on the boundary rule.

2 Likes

What prevents a name from being re-registered between the off-chain continuity check and transaction execution? The execution-time verifyPath check cannot see that registration boundary and could still accept the stored endorsements.

Checking “as close to execution as possible” narrows the window but does not close it. Could you add a test where re-registration lands immediately before execution in the same block? If that still passes then the spec should explicitly distinguish registry-level eligibility from continuity-checked authorization.

2 Likes

You’re correct, and nothing prevents it. I reproduced that very scenario on a mainnet fork at block 26,194,700 using the deployed registrar controller and BaseRegistrar.

The setup: G→X→M, with X owned by H1. After X expires beyond grace, H2 prepares to re-register it. Both G→X and G→X→M pass the coordinator’s continuity check at block B. In B+1, H2 registers X immediately before execution, which re-evaluates the gate using validateParticipantWithPath.

The result: Both paths still pass, despite the change in ownership and B+1 correctly rechecking the history and marking them ineligible. The same happens if H1 re-registers the name.

The registry is behaving as specified. The issue is that our guidance suggests checking close to execution is sufficient. It narrows the race window but doesn’t eliminate it.

We can close this window for .eth second-level names. The coordinator records each name’s nameExpires at block B and requires those values to remain unchanged at execution. Since re-registration changes the expiry and renewals cannot decrease it, this detects intervening registrations.

The fork tests confirm:

  • Re-registration before execution → reverts.

  • Execution before re-registration in the same block → succeeds.

  • No intervening change → succeeds.

  • Renewal before execution → reverts conservatively; the coordinator can recheck and retry.

Removing the expiry check causes the two tests that depend on it to fail.

There are two limitations: Subnames have no equivalent reliable continuity marker, so that window remains. I also tested unwrapped names only, and wrapped second-level names use the same BaseRegistrar expiry, but still need fork verification.

Lastly, I updated the proposed wording to distinguish registry-level eligibility from continuity-checked authorisation, explain the same-block race, document the expiry pin, and make the remaining limitations explicit.

No contract or interface changes are needed. The text and fork tests are in #2083.

Thanks for pushing on this. It’s exactly the kind of edge case we want to catch.

1 Like

Just a thought. I’m not sure whether this holds under all the registrar edge cases.

Could a deadline bound to the checked snapshot be an alternative to exact nameExpires equality for active second-level .eth names?

At block B the coordinator could set validUntil strictly before the earliest observed expiry across all names in the path. Execution would enforce that deadline alongside the existing registry checks. A renewal would not invalidate the authorization because re-registration cannot occur before the original expiry. The deadline would need to be bound to the validated snapshot rather than supplied freely by the executor. This would exclude already expired names and would not solve subname continuity. Would fork tests for renewal before execution and execution at the deadline help establish whether this is sound? It might avoid unnecessary retries after an ordinary renewal.

1 Like

It holds, and it’s in #2085.

The BaseRegistrar allows a registration only when nameExpires + GRACE_PERIOD < block.timestamp, and nothing lowers the expiry.

Take E as the expiry read at block B. A registration after B then needs a timestamp later than E + grace, and an execution in the same block has the same timestamp.

So the deadline can be E + grace, inclusive, not just “before E”, and that also covers the grace period. It also doesn’t need to exclude expired names.

The coordinator accepts if the deadline holds or the expiry is unchanged, because each one alone rules out a registration since B. E has to be the value read at B, as you said: an expiry read at execution already reflects the registration.

On a mainnet fork at block 26,194,700, with wrapped and unwrapped names:

  • A renewal in the window now executes.

  • Execution at the last second of grace executes.

  • A registration at the first possible second, just before execution in the same block, is rejected.

  • A renewal followed by a lapse and re-registration is also rejected.

  • Moving the deadline one second later fails the tests.

As before, names below a second-level name aren’t covered.

Thanks, this removes the one false alarm the pin had.

1 Like

I had missed that the deadline could include the grace period. Combining it with the unchanged-expiry check also avoids the restriction I suggested for expired names. Thanks for testing the exact boundary and the renewal followed by re-registration case. That addresses my concern about unnecessary retries.