ERC-8107: ENS Trust Registry for Agent Coordination

Summary

Web of trust validation using ENS names for ERC-8001 multi-party coordination.

Abstract

This ERC defines a Trust Registry where agents establish and query transitive trust relationships using ENS names as identifiers. Trust propagates through signature chains following the GnuPG web of trust model, enabling coordinators to gate participation based on trust graph proximity.

Motivation

ERC-8001 defines minimal primitives for multi-party agent coordination but explicitly defers reputation to modules:

“Privacy, thresholds, bonding, and cross-chain are left to modules.”

This ERC provides that reputation module.

Key Design Decisions

Why ENS?

  • Final standard (ERC-137)
  • Battle-tested identity layer
  • Built-in ownership semantics
  • No dependency on draft standards

Why Web of Trust?

  • Proven model (25+ years in GnuPG)
  • Decentralized — no central registrar
  • Agents can automate trust propagation

Trust Levels

Level Meaning
Unknown No relationship
None Explicitly distrusted
Marginal Partial trust — multiple required
Full Complete trust — single attestation sufficient

Links

Feedback welcome on the trust validation algorithm and ERC-8001 integration approach.

3 Likes

ERC-8107 has been in Review for a while, and feedback has led to a round of revisions. This post summarises what changed and why, before I ask for Last Call. The changes are in #2049 (merged) and #2050 (open). No function signatures changed, so the ITrustRegistry interface ID is still 0x42b46ef5.

One behaviour change: unconfigured gates now fail closed

validateParticipantWithPath and validateParticipantAddress used to return true when no gate was enabled for (coordinator, coordinationType). That made a mistyped coordination type, or a coordinator address taken from the wrong deployment, admit every participant while looking like a working gate. Both functions now revert with GateNotFound. A revert was chosen over return false so that a missing gate cannot be mistaken for an untrusted participant. Coordinators that want open participation for a coordination type should not call the hook for it. See “Why Do Unconfigured Gates Revert?”.

Corrections and clarifications

  • invalidateNonces authority. The NatSpec in the specification’s interface block said approved operators could call it. That contradicted the Signature Authority table and the Semantics section, both of which say controller-only. The .sol interface was already correct, and the specification now matches it.
  • OpenPGP. The earlier text said trust follows the OpenPGP model, and that marginal trust needs “multiple” attestations. That overstated it. ERC-8107 adopts OpenPGP’s trust levels and certification paths, but not its validity calculation. The required verifyPath checks a single path, and every edge on it must meet minEdgeTrust. Combining several marginal attestations is available only through the OPTIONAL validateAgent extension. The Abstract, the “Why Web of Trust?” section and the TrustLevel comments now say so. (The opening post above predates this and uses the older wording.)
  • Nonce model. “Why a Single Per-Trustor Nonce?” now considers three alternatives separately: unordered bitmaps, per-(trustor, trustee, scope) nonces, and a hybrid of a global floor plus a per-trustee floor. The hybrid would allow targeted quarantine. It is not adopted because it adds a second nonce dimension for every signer, requires a new interface ID, and does not help when the trustor’s own key is compromised. The specification also now states that invalidateNonces voids only attestations that are signed but not yet submitted. Stored relationships are untouched.
  • NameWrapper pinning. The new section “Why Pin the NameWrapper?” explains which names a successor wrapper would affect. Names wrapped in the current NameWrapper, and unwrapped names, keep working. Only names wrapped in a successor lose signing authority. Supporting a successor means deploying a new registry, and trustors would re-attest against it, because the EIP-712 domain includes verifyingContract. A governance-updatable slot was rejected because whoever held the update right would control every wrapped name.

Deliberately unchanged

  • Resolver fluidity. Participant addresses still come from the node’s live addr record. Pinning an address in each attestation would conflict with the goal of stable identifiers that survive key rotation. It would also change the typehash for every edge, when only a path’s terminal edge is resolved. See Security Considerations → Resolver Trust.
  • Indexer query API. verifyPath already makes indexers untrusted for correctness, and IdentityGateSet carries enough to rebuild gates from logs alone. What remains is a liveness risk, covered under Off-Chain Path Computation. A standard query API would suit a companion proposal better than this ERC, and I would welcome collaborators on one.

I updated the reference implementation and the test suite (104 tests) alongside the specification. If you build on ERC-8001 and plan to integrate the gate hook, the fail-closed change is the one to check against your integration. Unless substantive objections come up here, I intend to move ERC-8107 to Last Call.

1 Like

I have opened ethereum/ERCs#2051, which addresses the remaining review findings before ERC-8107 moves to Last Call. It changes the specification, the reference implementation and the test suite together, and the test suite has grown from 104 to 144 tests. The published ERC-165 identifiers are unchanged (0x42b46ef5 core, 0x93e686a3 extension).

Correctness fixes. Signature verification now tries ECDSA before ERC-1271. An EOA with an EIP-7702 delegation has code, so it was previously routed to isValidSignature, and its valid signatures failed whenever the delegate did not implement it. High-s signatures are now explicitly rejected. A universal revocation now voids an edge in every scope: a name revoked as COMPROMISED previously still passed in any scope where it held a scoped grant. This costs one extra storage read per edge when verifying in a named scope, about 2,800 gas per edge in the reference implementation. The specification now also says that expired unwrapped .eth names keep their authority until they are re-registered. The registry cannot detect this, because it sees only the namehash, so the specification recommends wrapped names. Finally, the interface NatSpec no longer contradicts the normative text on who may call what.

Semantic clarifications. A single attestation or invalidateNonces call can no longer raise a name’s nonce floor by more than 2^32. Before this, one call or one signature with type(uint64).max locked the name permanently, including for every future owner. Revocation now always clears the stored expiry. A new Rationale subsection sets out exactly where the standard diverges from the OpenPGP web of trust, and the citation now points to RFC 9580. A new security consideration covers parent-controlled subnames. Unconfigured identity gates keep their fail-closed revert.

Editorial and consistency. The Errors section has a Revert Order table, so every revert is deterministic across implementations, and each row has a test that proves the order. The pseudocode now uses internal helpers, and every Solidity snippet in the document compiles against the reference contract. TrustSet carries the attestation nonce, so indexers can order attestations from logs alone. EIP-3668 is dropped from requires, because the standard does not depend on CCIP-Read. A short Rationale subsection explains how token-based agent identity registries can plug in through an adapter, without this standard depending on them.

Changes that affect implementers

  • verifySignature MUST try ECDSA recovery before ERC-1271, and MUST reject malleable (high-s) signatures.
  • Scope resolution in verifyPath, for a named scope:
    a. Universal None voids the edge.
    b. Otherwise a stored scoped record applies, even when expired, and does not fall back.
    c. Otherwise the universal record applies.
  • revokeTrust and revokeTrustBatch MUST set the stored expiry to 0.
  • setTrust and invalidateNonces MUST revert with the new NonceJumpTooLarge(provided, max) above getNonce + 2**32, capped at type(uint64).max.
  • TrustSet gains a final unindexed uint64 nonce. The event signature changes; the ERC-165 identifiers do not.
  • Reverts MUST follow the new Revert Order table. In particular:
    • setTrust checks expiry before the nonce.
    • ENSNameNotFound keys on ens.owner, so an expired wrapped name reverts with InvalidSignature.
    • setTrustBatch reverts with the new BatchLengthMismatch and EmptyBatch errors.
  • requires no longer lists EIP-3668.
2 Likes

What happens when a gatekeeper explicitly distrusts an agent but an indirect trust path still exists?

Suppose user 1 marks user 3 as None at universal scope. User 1 still trusts user 2 and user 2 trusts user 3. Would the path user 1 → user 2 → user 3 still pass because it never traverses the revoked edge?

If so should identity gates have an explicit rule that the gatekeeper’s direct distrust overrides an indirect endorsement? Otherwise revoking a compromised participant may not actually exclude it.

2 Likes

Could we clarify whether a node’s role in `requiredAnchors` is intended to survive a change of controller?

The spec deliberately lets trust survive ENS transfers. But anchors also provide protocol-level Sybil resistance. If a gate requires auditor.eth, transfering that name leaves the configured namehash and stored trust edges unchanged, while the new controller can issue further attestations. The anchor role therefore passes to the new controller without coordinator action.

Short attestation expiries do not expire the anchor configuration. Preferring wrapped names helps with registration expiry, but wrapping alone does not prevent transfers.

This may only need explicit guidance, without an interface change:

  • Coordinators SHOULD monitor ownership changes for configured anchors, re-evaluate them after a transfer and update the gate if necessary.

  • Applications MUST NOT assume an anchor permanently identifies the controller it had when the gate was created.

An integrator might read auditor.eth as “this auditor”, while the policy follows whoever controls the name.

2 Likes

Thanks, Dmitry. You’re right, and it’s a good catch.

verifyPath only looks at the edges on the path it’s given, so in your example, user 1’s revocation of user 3 never gets checked. [user1, user2, user3] passes. It’s also inconsistent with the spec itself: the Trust Levels table says None voids paths containing the distrusted agent, but the algorithm only applies that to edges on the path.

It’s not just the target either. If a gatekeeper has explicitly distrusted someone, it shouldn’t keep accepting that agent’s judgement just because the path routes around the revoked edge.

So I’m going to fix it in _verifyPath. If the validator (nodes[0]) has an effective None against any later node on the path, the path fails. It’ll use the existing _edgeTrust scope rules, so universal None applies everywhere and scoped None only in its own scope. To be clear, this is per validator: your distrust affects paths you evaluate, not anyone else’s.

One thing worth saying openly. Since Unknown can’t be restored, revoking is the only explicit way to withdraw an endorsement before it expires, and after this change it’ll also exclude that agent from your indirect paths. That’s what the Trust Levels table already says None means, but I’ll spell it out in the Rationale so it’s a deliberate choice rather than a side effect.

No signature changes, so the interface ID stays 0x42b46ef5, but it does change required behaviour. I’ll add the rule, the Rationale, tests and gas numbers, and get it merged before Last Call.

Again, thanks for your review.

1 Like

Thanks, Revinand. Agreed, and I’d say it applies to gatekeeperNode too, maybe more so.

ERC-8107 treats the ENS name as the identity, not whoever controls it. That’s what lets keys rotate without breaking trust, but it also means a gatekeeper or anchor can change hands without the gate configuration changing at all.

The gatekeeper case is the sharper one. A new controller can attest directly from that name, and if the gate doesn’t require an anchor, one attestation is enough to admit someone. Gates with anchors still need a path through one, so they’re better protected.

For anchors, short-lived inbound attestations help, but they don’t fully solve it. The anchor configuration never expires, and attestations with expiry == 0 stay valid until someone updates them.

I don’t think this needs an interface change, but it does need clearer guidance. I’ll state that gatekeeperNode and requiredAnchors identify names, not controllers, and that coordinators SHOULD watch for control changes and re-evaluate their gates when they happen.

On fuses: PARENT_CANNOT_CONTROL, CANNOT_UNWRAP and CANNOT_TRANSFER together do prevent transfer and reassignment, but only until the registration expires. There’s also a catch. ENS currently lists locked names with CANNOT_TRANSFER burned as unable to migrate to ENSv2, and recommends holding off on locking for now. So it’s a real trade-off, not a free win.

I’ll also tidy up the monitoring guidance. “Watch Transfer events” isn’t accurate enough. Reclaim and registration emit NewOwner, and wrapped names change hands through the NameWrapper. It’s more reliable to re-check the effective controller, fuses and expiry after any relevant event.

Thanks again. Given that anchors are part of the Sybil-resistance story, this one matters.

2 Likes

ERC-8107 and ENSv2 migration: a compatibility decision before Last Call

One more architectural question I’d like to raise while ERC-8107 is still in Review.

ERC-8107 currently uses the ENSv1 registry and a NameWrapper address pinned at deployment. That’s deliberate. We wanted the authority boundary to be explicit and difficult to change.

But ENSv2 changes the ownership and resolution model, and its published migration process has consequences for ERC-8107.

When a name migrates, its ENSv1 token moves to the Graveyard. Under the current ERC-8107 authority model, this means:

  • The former controller can no longer issue attestations, revoke trust or invalidate nonces for that name.

  • Existing trust stays in place. A migrated gatekeeper can continue admitting participants through stored paths while losing the ability to revoke its own endorsements or explicitly distrust compromised agents.

  • Relationships with expiry == 0 can remain effective indefinitely.

  • Previously signed but unsubmitted attestations become unusable because their signatures no longer verify against the current controller.

  • Address-based validation normally fails for a migrated terminal name when its ENSv1 resolver is cleared, although some locked names retain their legacy resolver.

There’s also a consequence for agent subnames.

When a parent migrates, its unemancipated subnames may initially remain operational. But the Graveyard’s permissionless clear() function can subsequently remove their legacy resolver records and ownership where the Graveyard controls the namespace.

A protocol using names such as alice.agents.eth could therefore lose its agents’ ENSv1 authority following a third party’s cleanup transaction.

Unmigrated names continue working, but new names registered exclusively through ENSv2 won’t be supported directly by ERC-8107’s existing authority model.

I see two options.

Option 1: Explicitly scope ERC-8107 to ENSv1.

Document these migration behaviours and support ENSv2 through a successor Trust Registry. Existing trust wouldn’t transfer automatically. Trustors would re-attest under the successor’s EIP-712 domain.

This preserves the current authority boundary.

Option 2: Introduce controller and resolver adapters now.

That would let one registry support both versions, but we’d need to map ENSv2’s role-based permissions onto ERC-8107 signing authority.

A mutable adapter would reintroduce the governance and takeover risk that pinning the NameWrapper avoids. An adapter pinned at deployment avoids that, but it would fix our interpretation of ENSv2 authority for that deployment, before ENSv2 is live on mainnet and before that model has settled in practice.

My inclination is Option 1.

It keeps ERC-8107 small and based on deployed ENS interfaces. ENSv2 integration can then be designed against its actual authority model rather than forcing it into ENSv1 semantics.

The trade-off is real. ERC-8107 could reach Final knowing that new ENSv2-only names cannot participate directly. Existing deployments must also handle migration carefully, particularly where non-expiring trust can survive after the trustor loses the ability to revoke it.

Until then, the practical rule is simple: revoke anything you might need to withdraw, and reconfigure dependent gates, before migrating a name that ERC-8107 relies on.

I’ll document this in Backwards Compatibility and Security Considerations before Last Call, alongside regression tests for the migration behaviour.

I’d welcome input from ENS contributors, particularly if any of the migration assumptions above need correction, and from teams planning to onboard large numbers of agents after ENSv2 launches.

Would the community favour a deliberately version-scoped Trust Registry with an explicit successor, or consider cross-version support essential before Final?

ENS migration documentation:

Draft PR for review: Update ERC-8107: validator distrust, gate names and ENSv2 migration by KBryan · Pull Request #2064 · ethereum/ERCs · GitHub

This covers the three points raised above. I’m holding it as a draft rather than merging it straight away, because the first change alters required behaviour, and I’d like more eyes on it. No signatures change, and the interface IDs stay 0x42b46ef5 / 0x93e686a3.

  1. Validator distrust (normative). Thanks @SergeevDmitry. verifyPath now fails if the validator has an effective None against any later node on the path, whether intermediary or target. This applies per validator: other nodes’ distrust only matters through the normal edge check. On a 10-edge path in a named scope, worst case, it costs +52k gas (90,805 → 142,916).
  2. Gatekeeper and anchor names (guidance). Thanks @Revinand. Gates identify names, not controllers, so coordinators SHOULD watch for control changes. The spec now names the events that actually signal one, and treats fuse locking as a trade-off because of ENSv2.
  3. ENSv2 (guidance). This goes with Option 1 from my last post: the standard is scoped to ENSv1, and ENSv2 is left to a successor registry. One correction to that post: a migrated gatekeeper can’t add new distrust, but distrust recorded before migration still applies.

Feedback I’d most like:

  • Revocation now both withdraws an endorsement and excludes the agent, including INACTIVE revocations. Is that acceptable, or do we need a neutral withdrawal?
  • Could ENS contributors check the migration and fuse details?
  • Does anyone think cross-version (ENSv2) support is essential before Final?

Once these have had a fair hearing, I’ll mark the PR ready and ask for Last Call with a new deadline.

1 Like

I think neutral withdrawal also raises an authorization issue. An operator approved for the gatekeeper’s ENS name can call revokeTrust without prior trust:

revokeTrust(gatekeeper, X, bytes32(0), reasonCode);

Under the proposed rule, this would exclude X from every path starting at that gatekeeper, across all scopes, whether X is the target or an intermediary.

That seems broader than the rationale for operator revocation: a change bounded to one pair that the controller can easily undo. The stored change remains narrow, but it can invalidate indirect paths that never traverse the revoked edge. Undoing it also requires a positive trust statement. Unknown cannot be restored and clearing universal None requires a valid universal Marginal or Full attestation. There is no direct reset to neutrality.

I’d separate neutral withdrawal from explicit distrust and consider making explicit distrust controller-only. Operators could still withdraw endorsements without automatically gaining authority to manage the gatekeeper’s denylist.

The draft already acknowledges the combined semantics. If retained, the operator authorization rationale should acknowledge this exclusion power too. While the ERC is still in Review, separating the two seems cleaner.

Stopping my endorsement of X and rejecting everyone else’s endorsement of X are different decisions. The state model should probably represent both.

1 Like

Thanks, Revinand. This is a really good catch, and I agree with your conclusion.

I think we’ve uncovered an important distinction that the standard needs to make explicit.

Withdrawal ≠ Denial

Withdraw(A, B, s) ⇏ Deny(A, B, s)

Stopping my endorsement of an agent shouldn’t mean rejecting everyone else’s judgement of that agent.

Withdrawal should only affect the relationship between A and B. A universal withdrawal (bytes32(0)) removes A’s endorsement of B across all scopes, while a scoped withdrawal affects only that scope. Neither should invalidate alternative trust paths through other agents.

Explicit denial is different. It means I refuse to trust that agent, even indirectly. Only the current ENS name controller should be able to set or clear denial, not approved operators. Denial would invalidate both direct edges and indirect paths from that validator’s perspective.

I’m going to separate these behaviours before Last Call by introducing setDenial, getDenial and a DenialSet event. This will require recalculating the ERC-165 interface ID, but preserves our existing withdrawal semantics.

It also means revisiting my earlier response to Dmitry. His observation was correct, but the solution needs to respect the authority boundaries you’ve identified here.

I’d rather make this change now than carry the ambiguity into Final.

Really appreciate you pushing on this. I think we’re getting closer to something both simpler to reason about and safer to implement.

1 Like

The separation is now in the draft PR: ethereum/ERCs#2064. It replaces the validator-None rule from my earlier post rather than adding to it.

Three intentions, two pieces of state

Intention Operation Who
Endorse setTrust (Marginal / Full) controller’s signature
Withdraw revokeTrust (None) controller or approved operator
Deny setDenial controller only
  • Withdrawal goes back to the #2051 behaviour. It voids A’s own edge to B and nothing else. Universal None still overrides A’s scoped grants to B, and scoped tombstones still block the universal fallback. Paths through other agents are unaffected.
  • Denial is a separate flag on the same record, so it is independent of the trust level:
    • setTrust and revokeTrust never change the flag, and setDenial changes nothing else.
    • Clearing a denial reveals whatever endorsement is stored, exactly as it is.
    • A positive attestation submitted during a denial updates the endorsement but doesn’t lift the denial.

Path rule. Write D(A, B, s) = Deny(A, B, 0) ∨ Deny(A, B, s). A path [P0 … Pn] fails if:

  • any edge Pi → Pi+1 is denied (this overrides a stored Full), or
  • the validator denies any Pk with k ≥ 2, whether intermediary or target.

Only the validator’s denial vetoes the whole path. Anyone else’s denial matters only on their own outgoing edge. A scoped false never lifts a universal true. Denial also applies whatever minEdgeTrust is and whether or not enforceExpiry is set.

Interface

function setDenial(bytes32 trustorNode, bytes32 trusteeNode, bytes32 scope, bool denied, bytes32 reasonCode) external;
function getDenial(bytes32 trustorNode, bytes32 trusteeNode, bytes32 scope) external view returns (bool);
event DenialSet(bytes32 indexed trustorNode, bytes32 indexed trusteeNode, bytes32 indexed scope, bool denied, bytes32 reasonCode);
  • setDenial is idempotent: if nothing changes, it emits no event, so DenialSet logs alone reconstruct every flag.
  • getDenial returns the flag stored for that exact scope, like getTrust, not the effective value.
  • ITrustRegistry changes from 0x42b46ef5 to 0x31f35e83. The extension ID is unchanged.

Other changes

  • Quarantine is now: deny, then invalidate nonces, then investigate and recover. Nonce invalidation applies to every trustee, and signatures above the new floor still land. If the trustor’s own controller is compromised, its denial alone doesn’t help, because the attacker can clear it. Denial by counterparties is the real remedy.
  • Operator limit. Signature Authority now says controller-only is only as strong as ENS ownership. An operator approved through isApprovedForAll can usually transfer the name, unless CANNOT_TRANSFER is burned, and so become the controller.
  • ENSv2. Migration freezes both endorsements and denials. They keep applying, and the former controller can no longer change either.
  • Not backwards compatible. Even though the existing signatures are unchanged, registries built from earlier drafts need redeploying and re-attesting.

Cost and tests

  • On a 10-edge path in a named scope, gas goes from 143,000 to 146,578 (+2.5%) compared with the previous draft. The denial flag shares the record’s existing storage slot, so the edge check reads no extra slot.
  • The test suite grew from 160 to 187 tests, all passing:
    • every path case is checked through verifyPath and both gate functions;
    • a fuzz test checks random sequences against a model of the spec;
    • tests read the raw storage slot to confirm that endorsement writes never touch the denial flag.

Thanks @Revinand and @SergeevDmitry. This is a better design than the one I proposed.

Feedback I’d most like:

  1. Does this address both concerns: neutral withdrawal, and operators no longer gaining exclusion power?
  2. Is controller-only the right authority for denial, given that ENS transfer approval undercuts it anyway?
  3. Should getDenial return the stored per-scope flag, or the effective value?

The PR stays a draft until this has had a fair hearing. Again, thanks for your efforts.

1 Like

Yes, I think this addresses the original concern much better. Separating endorsement and denial is cleaner than using None for both withdrawal and distrust.

The distinction is easier to follow: endorsement expresses A’s trust in B, withdrawal stops A endorsing B and denial records A’s refusal to accept B even through indirect paths.

Keeping denial independent of the stored endorsement also makes sense. Clearing a denial leaves the current endorsement intact, including any updates made while denial was active, subject to the usual scope and expiry rules.

On your questions:

  1. Neutral withdrawal and operator exclusion power

    Yes, at the revokeTrust level. An approved operator can still withdraw an endorsement, so paths using that edge can fail. The withdrawal no longer excludes B from otherwise valid indirect paths through other agents. That restores the bounded-authority rationale for allowing operators to call revokeTrust.

  2. Controller-only denial

    I’d keep setDenial controller-only with the ENS ownership limitation stated plainly. If an operator can transfer the name to itself, it can become controller and then set or clear denial. In that case, controller-only provides no effective protection against that operator. The transfer is an auditable ownership change, but visibility alone doesn’t prevent the bypass.

    The distinction still matters where transfer is restricted. An operator can then retain withdrawal authority without gaining denial authority. Keeping the direct permission narrow follows least privilege, even though its protection depends on the underlying ENS ownership rules.

  3. getDenial semantics

    I’d keep getDenial as the flag stored for the exact scope, matching getTrust. Effective denial is derived:

    effectiveDenial(A, B, s) = getDenial(A, B, bytes32(0)) || getDenial(A, B, s)

    Returning the effective value would hide information. A scoped query returning true wouldn’t tell the caller whether the scoped flag, the universal flag or both were set. That distinction is useful for UIs and indexers and for determining which flags must be cleared. If effective denial becomes common enough to warrant a convenience function, I’d add a separately named isDenied or getEffectiveDenial rather than change the raw getter’s meaning.

    I’d keep the formula alongside the getter’s normative semantics, including the explicit rule that scoped false never overrides universal true. Your post already explains this clearly. Putting it there too should help independent implementations agree.