ERC-8107 - ENS Trust Registry for Agent Coordination

Created 2025-12-16
Status Review
Category ERC
Type Standards Track
Authors
Requires

Abstract

This ERC defines a Trust Registry that enables agents to establish and query transitive trust relationships using ENS names as identifiers. Trust is expressed at four levels (Unknown, None, Marginal, Full) and propagates along certification paths, adopting the trust levels and path model of the OpenPGP web of trust described in RFC 9580, which obsoletes RFC 4880. Unlike OpenPGP, the required path verification checks a single path against a minimum per-edge trust level; accumulating several marginal attestations is left to an OPTIONAL extension.

The registry serves as the trust and delegation module anticipated by ERC-8001, enabling coordinators to gate participation based on trust graph proximity. An agent is considered valid from a coordinator's perspective if a verified trust path exists from the gate's gatekeeper to the agent that satisfies the gate's parameters, and the gatekeeper has not denied any agent on that path.

The registry keeps two independent judgements per relationship. An endorsement (a trust level) is the trustor's own vouching for the trustee, and withdrawing it removes only that edge. A denial is the trustor's refusal to rely on the trustee at all, which overrides endorsements by other agents within the trustor's own trust decisions. Endorsements are signed by the name controller and may be withdrawn by its approved operators; denials are managed by the name controller alone.

This standard specifies trust attestation structures, the path verification algorithm, ENS integration semantics, and ERC-8001 coordination hooks.

Motivation

ERC-8001 defines minimal primitives for multi-party agent coordination and defers everything above them, including reputation, to modules. Its Security Considerations name the gap directly:

"Equivocation: A participant can sign conflicting intents. Mitigate with module-level slashing or reputation."

and its Motivation sets the boundary:

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

This ERC provides that trust and delegation module. Before coordinating, agents need answers to:

  1. "Should I include this agent in my coordination?" — Participant selection
  2. "Can I trust this agent's judgement about other agents?" — Transitive trust
  3. "How do I update trust based on coordination outcomes?" — Trust maintenance

Why Web of Trust?

The web of trust model, standardised for OpenPGP in RFC 9580 (which obsoletes RFC 4880) and proven over 25+ years of deployment, solves the bootstrap problem: how do you establish trust with unknown agents without a centralised registrar?

OpenPGP Concept This Standard
Public key ENS name
Key signing Trust attestation
Owner trust levels TrustLevel enum
Key validity Agent validity for coordination
Certification path Trust chain through agents

This standard adopts OpenPGP's trust levels and certification paths, but not its validity calculation. OpenPGP treats a key as valid once it carries one fully trusted signature or enough marginally trusted ones. The required verifyPath instead checks one path, and every edge on it must meet minEdgeTrust. Marginal accumulation is available only through the OPTIONAL validateAgent extension; see "Divergence from OpenPGP".

Why ENS?

ENS provides a battle-tested, finalised identity layer:

Using ENS avoids dependency on draft identity standards while remaining compatible with future standards through adapter patterns.

Deployment note: This standard requires access to an ENS registry. On Ethereum mainnet, use the canonical ENS deployment. On other networks, use network-specific ENS deployments or bridges. Implementations also take the network's NameWrapper address, or the zero address where no NameWrapper is deployed. CCIP-Read is a client-side mechanism and cannot be used for on-chain validation, so agents relying on on-chain identity gates need an on-chain resolver.

Identity Continuity

ENS names are the identity. When an ENS name is transferred, the new owner inherits existing trust relationships where that name is the trustee, and can manage trust where that name is the trustor. Gates name their gatekeeper and anchors the same way, so a gate follows those names rather than their controllers. Short attestation expiries bound part of the exposure this creates; the Specification states the requirements under Identity Continuity.

Specification

The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "NOT RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in RFC 2119 and RFC 8174.

Overview

This ERC specifies:

Trust Levels

Implementations MUST use the canonical enum:

enum TrustLevel {
   Unknown,   // 0: No trust relationship established
   None,      // 1: Endorsement withdrawn
   Marginal,  // 2: Partial trust — accepted on an edge when minEdgeTrust <= Marginal
   Full       // 3: Complete trust — satisfies any minEdgeTrust
}

Semantic definitions:

Level Meaning Validation Contribution
Unknown Default state; no data about agent Cannot contribute to validation
None The trustor has withdrawn its endorsement Voids an edge whose effective level it is. Does not affect paths that do not use that edge
Marginal Agent generally trustworthy Contributes to validation when minEdgeTrust <= Marginal
Full Agent's judgement equals own verification Always contributes to validation

Level transitions:

Attestations grant trust; revocations withdraw it. Each level has exactly one way to reach it:

setTrust therefore MUST reject attestations whose level is Unknown or None, and withdrawal is not permanent: a later attestation with a higher nonce restores trust from None to Marginal or Full.

No level transition changes the denial flag, and changing the denial flag changes no level (see Denial).

Denial

The registry represents three distinct intentions of a trustor A towards a trustee B in a scope s:

Intention Meaning Operation
Endorsement A trusts B setTrust, setTrustBatch (level Marginal or Full)
Withdrawal A no longer endorses B itself revokeTrust, revokeTrustBatch (level None)
Denial A refuses to rely on B, even when other agents endorse B setDenial, cleared by clearDenial

Each trust record therefore holds a boolean denial flag alongside its level. The flag and the level are independent:

Withdrawal does not imply denial, and denial does not imply withdrawal:

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

Denial is not subject to scope fallback. Writing Deny(A, B, s) for the flag stored at scope s, the effective denial in scope s is:

EffectiveDeny(A, B, s) = Deny(A, B, bytes32(0)) ∨ Deny(A, B, s)

At universal scope this reduces to Deny(A, B, bytes32(0)). A scoped flag that is false never overrides a universal flag that is true. Path verification applies effective denial to every edge and, from the validator, to every later node on the path (see Validator denial).

Clearing a denial reveals the stored endorsement exactly as it stands: Full or Marginal applies again, None and Unknown still void the edge, and a scoped denial still applies after a universal one is cleared. An attestation submitted while a denial is in force updates the stored endorsement but does not clear the denial.

Because a clear reveals whatever is stored when it executes, clearDenial takes the trustor's nonce as the caller observed it during review, and reverts if any attestation has landed since. That check cannot stop an attestation signed earlier from landing after the clear, where it applies immediately. Closing that case depends on raising the nonce floor before the review (see clearDenial).

ENS Integration

The Trust Registry uses ENS namehashes as agent identifiers.

// ENS namehash computation (per ERC-137)
bytes32 node = keccak256(abi.encodePacked(
   keccak256(abi.encodePacked(bytes32(0), keccak256("eth"))),
   keccak256("alice")
));
// node = namehash("alice.eth")

External Interfaces

The registry reads three external contracts:

interface IENS {
   function owner(bytes32 node) external view returns (address);
   function resolver(bytes32 node) external view returns (address);
   function isApprovedForAll(address owner, address operator) external view returns (bool);
}

/// @dev NameWrapper is an ERC-1155; the token holder is the real name controller
interface INameWrapper {
   function ownerOf(uint256 id) external view returns (address);
   function isApprovedForAll(address owner, address operator) external view returns (bool);
}

interface IAddrResolver {
   function addr(bytes32 node) external view returns (address payable);
}

Name Controller Resolution

ens.owner(node) returns the registry controller. For wrapped names that controller is the NameWrapper contract rather than the party actually controlling the name. NameWrapper does not implement ERC-1271, so treating it as the signing authority would leave every wrapped name unable to attest.

Implementations MUST resolve the controller as follows:

function controllerOf(bytes32 node) internal view returns (address) {
   address owner = ens.owner(node);
   if (owner == address(0)) return address(0);

   // Wrapped name: the ERC-1155 holder is the real controller
   if (nameWrapper != address(0) && owner == nameWrapper) {
      try INameWrapper(nameWrapper).ownerOf(uint256(node)) returns (address wrapped) {
         return wrapped; // address(0) once the wrapped name expires
      } catch {
         return address(0);
      }
   }

   return owner;
}

Wherever this standard refers to "the ENS name owner", it means controllerOf(node).

Implementations MUST pin the nameWrapper address at deployment, and MUST NOT allow it to be changed afterwards: a registry pointed at a hostile contract in that slot would let it claim control of every wrapped name. Implementations deployed on networks with no NameWrapper deployment MUST set nameWrapper to the zero address, which disables unwrapping.

Agent Address Resolution

ERC-8001 identifies participants by address (address[] participants), while this registry identifies agents by ENS namehash. The binding between the two is the node's forward address record (ERC-137):

/// @dev Gas forwarded to a node's resolver
uint256 internal constant RESOLVE_GAS = 50_000;

function resolveAgent(bytes32 node) public view returns (address) {
   address resolver = ens.resolver(node);
   if (resolver == address(0)) return address(0);

   // Low-level, gas-capped, bounded-copy call: a hostile resolver can neither make
   // resolution revert nor consume more than RESOLVE_GAS
   bytes memory data = abi.encodeWithSelector(IAddrResolver.addr.selector, node);
   bool ok;
   uint256 word;
   uint256 size;
   assembly ("memory-safe") {
      ok := staticcall(RESOLVE_GAS, resolver, add(data, 0x20), mload(data), 0, 0x20)
      size := returndatasize()
      word := mload(0)
   }
   if (!ok || size < 0x20 || word >> 160 != 0) return address(0);
   return address(uint160(word));
}

An address a is bound to node n if and only if resolveAgent(n) == a and a != address(0). No separate registration step is required: the addr record is already the controller's authoritative statement of which address the name denotes.

Resolution is a verification primitive, not a search. Callers supply the node as the terminal element of a TrustPath and the registry confirms that it resolves to the participant address. This standard does not use reverse resolution, which is self-asserted and would require on-chain string handling to verify.

resolveAgent MUST NOT revert because of the resolver's behaviour. It MUST return the zero address when the node has no resolver, or when the call to addr reverts, runs out of gas, returns fewer than 32 bytes, or returns a first word with any of its upper 96 bits set. Return data beyond the first 32 bytes MUST be ignored. Implementations MUST bound the gas forwarded to the resolver; the reference implementation forwards 50,000.

Nodes served by an off-chain resolver (CCIP-Read) cannot be resolved during on-chain validation: addr reverts and resolveAgent returns the zero address. Agents that need to pass on-chain identity gates MUST publish an on-chain addr record, served by a resolver that answers addr within the gas bound.

Identity Continuity

The ENS name, not the key behind it, is the identity. When a name is transferred, the new controller inherits every attestation naming it as trustee, and gains authority over every attestation and every denial in which it is the trustor.

The same holds for gates. A gate's gatekeeperNode and requiredAnchors identify ENS names, not their controllers. When control of one of those names changes, its role in the gate passes to the new controller, and the gate configuration does not change. Coordinators SHOULD monitor control changes for the gatekeeper and anchor nodes of their gates, and SHOULD re-evaluate the affected gates when one occurs. See Gatekeeper and Anchor Names.

Attestations covering high-stakes scopes SHOULD carry short expiries (RECOMMENDED: 90 days maximum), so that a transfer cannot carry inherited trust forward indefinitely. Agents SHOULD monitor control changes on ENS names they trust, and re-evaluate trust when a trusted name changes hands. No single event stream reports every change of control; see Monitoring Control Changes.

Agents SHOULD use wrapped names, whose authority ends when their registration expires. Gatekeepers SHOULD prefer wrapped names for gatekeeper and anchor nodes. An unwrapped name keeps its authority after it expires; see Expired Unwrapped Names. NameWrapper fuses can also stop a name changing hands, at a long-term cost; see Name Locking with Fuses.

Signature Authority

Trust attestations MUST be signed by an address with signing authority for the ENS name.

Signing authority is limited to:

Transaction submission is governed by the following authority model. "Approved operator" means an address for which isApprovedForAll returns true on whichever contract holds the name, per canSubmitRevocation.

Operation Controller Approved operator Any address
setTrust, setTrustBatch signs no MAY submit a valid signature
revokeTrust, revokeTrustBatch MAY call MAY call no
setDenial, clearDenial MAY call MUST NOT no
invalidateNonces MAY call MUST NOT no
setIdentityGate, removeIdentityGate keyed by msg.sender; no ENS authority involved

Approved operators MAY perform withdrawal because its effect is bounded to the trustor's own endorsement of one trustee, in the scopes named in the call. A withdrawal removes an edge; it cannot stop the trustee being reached through other agents.

Approved operators MUST NOT call setDenial or clearDenial. A denial overrides every other agent's endorsement within the trustor's own trust decisions, so setting one decides whose judgement the trustor accepts, and clearing one can readmit an agent the controller excluded. That is the same authority as signing an attestation, and it stays with the controller. This standard adds no separate delegation mechanism for it: a controller that wants to delegate denial can use a contract controller.

Approved operators MUST NOT call invalidateNonces. Its blast radius is trustor-wide: raising the nonce floor invalidates every attestation the trustor has signed but not yet submitted, for every trustee and every scope. isApprovedForAll is a broad, long-lived approval granted for ENS name management, and treating it as authority to void an agent's entire outstanding attestation set is an escalation the name owner did not knowingly grant.

This separation ensures:

These restrictions are only as strong as the ENS ownership model beneath them. In the ENS registry, an address approved through isApprovedForAll can change a name's owner, and in the NameWrapper it can transfer the name's token unless CANNOT_TRANSFER is burned. Such an operator can make itself the controller, and with it acquire every controller-only power above. The restrictions stop an approval from being used as controller authority in place; they do not stop an operator with transfer authority from taking control of the name. Trustors SHOULD grant isApprovedForAll only to parties they would trust with control of the name.

Implementations MUST attempt ECDSA recovery before ERC-1271, and MUST reject malleable (high-s) signatures. A controller that has code is not necessarily a contract account: an EOA with an EIP-7702 delegation has code, and its delegate need not implement isValidSignature, so routing every controller with code straight to ERC-1271 would reject that EOA's valid signatures.

An ERC-1271 call that reverts, returns fewer than 32 bytes, or returns a first word other than the magic value 0x1626ba7e left-aligned with zero padding MUST be treated as a failed signature, so setTrust reverts with InvalidSignature rather than with the controller's revert data or an empty revert.

/// @dev Verify signature - signing authority is the name controller only
function verifySignature(
   bytes32 node,
   bytes32 digest,
   bytes calldata signature
) internal view returns (bool) {
   address controller = controllerOf(node);
   if (controller == address(0)) return false;

   // ECDSA first: covers plain EOAs and EIP-7702 delegated EOAs
   (address recovered, ECDSA.RecoverError err, ) = ECDSA.tryRecover(digest, signature);
   if (err == ECDSA.RecoverError.NoError && recovered == controller) return true;

   // ERC-1271 fallback for any controller with code
   if (controller.code.length == 0) return false;
   // Low-level call with a returndata length check: a high-level call would revert
   // with empty data (not InvalidSignature) when the controller's code returns
   // nothing, e.g. an EIP-7702 delegate with an empty fallback
   return SignatureChecker.isValidERC1271SignatureNow(controller, digest, signature);
}

/// @dev Check if caller can submit a revokeTrust transaction
function canSubmitRevocation(bytes32 node, address caller) internal view returns (bool) {
   address controller = controllerOf(node);
   if (controller == address(0)) return false;
   if (caller == controller) return true;

   // Approvals live on whichever contract actually holds the name
   if (nameWrapper != address(0) && ens.owner(node) == nameWrapper) {
      return INameWrapper(nameWrapper).isApprovedForAll(controller, caller);
   }

   return ens.isApprovedForAll(controller, caller);
}

A wrapped name whose registration has expired resolves to address(0) and therefore has no signing authority. This applies to wrapped names only: an expired unwrapped name keeps its registry owner, and with it its signing authority, until the name is re-registered and reclaimed (see Expired Unwrapped Names).

EIP-712 Domain

Implementations MUST use the following EIP-712 domain:

EIP712Domain(
   name: "TrustRegistry",
   version: "1",
   chainId: block.chainid,
   verifyingContract: address(this)
)

Implementations SHOULD expose the domain via ERC-5267.

Primary Types

struct TrustAttestation {
   bytes32 trustorNode;       // ENS namehash of trustor
   bytes32 trusteeNode;       // ENS namehash of trustee
   TrustLevel level;          // Trust level assigned
   bytes32 scope;             // Scope restriction; bytes32(0) = universal
   uint64 expiry;             // Unix timestamp; 0 = no expiry
   uint64 nonce;              // Per-trustor monotonic nonce
}

struct ValidationParams {
   uint8 maxPathLength;       // Maximum trust chain depth (1-10)
   TrustLevel minEdgeTrust;   // Minimum trust level required on each edge
   bytes32 scope;             // Scope to verify against; bytes32(0) = universal
   bool enforceExpiry;        // Check expiry on all chain elements
   bytes32[] requiredAnchors; // Path MUST traverse at least one anchor; empty = no requirement
}

struct TrustPath {
   bytes32[] nodes;           // [validator, ...intermediaries..., target]
}

Path length definition: Path length is the number of edges (trust relationships) in the path. A direct trust relationship has path length 1. A path [A, B, C] has length 2.

Default Validation Parameters

When not specified, implementations SHOULD use:

ValidationParams({
   maxPathLength: 5,
   minEdgeTrust: TrustLevel.Marginal,
   scope: bytes32(0),
   enforceExpiry: true,
   requiredAnchors: new bytes32[](0)
})

Validation Parameters Constraints

Implementations MUST reject ValidationParams where:

Condition Error
maxPathLength == 0 or maxPathLength > 10 InvalidMaxPathLength
minEdgeTrust == TrustLevel.Unknown or minEdgeTrust == TrustLevel.None InvalidMinEdgeTrust
requiredAnchors.length > 10 TooManyRequiredAnchors

Both verifyPath and setIdentityGate MUST apply these constraints, through a shared routine, before the parameters are used.

Typed Data Hashes

bytes32 constant TRUST_ATTESTATION_TYPEHASH = keccak256(
   "TrustAttestation(bytes32 trustorNode,bytes32 trusteeNode,uint8 level,bytes32 scope,uint64 expiry,uint64 nonce)"
);

function _structHash(TrustAttestation calldata att) internal pure returns (bytes32) {
   return keccak256(abi.encode(
      TRUST_ATTESTATION_TYPEHASH,
      att.trustorNode,
      att.trusteeNode,
      uint8(att.level),
      att.scope,
      att.expiry,
      att.nonce
   ));
}

The signed digest is the EIP-712 typed data hash of _structHash(att) under the domain above.

Interface

Implementations MUST expose the following interface:

interface ITrustRegistry is IERC165 {
   // ═══════════════════════════════════════════════════════════════════
   // Events
   // ═══════════════════════════════════════════════════════════════════

   /// @notice Emitted when trust is set or updated
   /// @dev `nonce` is the attestation's nonce, so indexers can order attestations
   ///      from logs alone
   event TrustSet(
      bytes32 indexed trustorNode,
      bytes32 indexed trusteeNode,
      TrustLevel level,
      bytes32 indexed scope,
      uint64 expiry,
      uint64 nonce
   );

   /// @notice Emitted when trust is explicitly revoked
   event TrustRevoked(
      bytes32 indexed trustorNode,
      bytes32 indexed trusteeNode,
      bytes32 indexed scope,
      bytes32 reasonCode
   );

   /// @notice Emitted when a denial flag changes
   /// @dev Emitted only on a change, so the latest event per
   ///      (trustorNode, trusteeNode, scope) reconstructs the stored flag
   event DenialSet(
      bytes32 indexed trustorNode,
      bytes32 indexed trusteeNode,
      bytes32 indexed scope,
      bool denied,
      bytes32 reasonCode
   );

   /// @notice Emitted when a trustor invalidates outstanding attestations
   event NoncesInvalidated(bytes32 indexed trustorNode, uint64 newNonce);

   /// @notice Emitted when an identity gate is configured
   /// @dev Carries the complete ValidationParams so indexers can reconstruct gate
   ///      configuration from logs alone
   event IdentityGateSet(
      address indexed coordinator,
      bytes32 indexed coordinationType,
      bytes32 indexed gatekeeperNode,
      uint8 maxPathLength,
      TrustLevel minEdgeTrust,
      bytes32 scope,
      bool enforceExpiry,
      bytes32[] requiredAnchors
   );

   /// @notice Emitted when an identity gate is removed
   event IdentityGateRemoved(address indexed coordinator, bytes32 indexed coordinationType);

   // ═══════════════════════════════════════════════════════════════════
   // Trust Management
   // ═══════════════════════════════════════════════════════════════════

   /// @notice Set trust level for another agent in a specific scope
   /// @dev Signature MUST be from the name controller (controllerOf), or validate via
   ///      ERC-1271 when that controller is a contract.
   ///      `level` MUST be Marginal or Full; withdrawal goes through revokeTrust.
   ///      MUST NOT change the denial flag.
   ///      `nonce` MUST exceed the current nonce by at most 2**32.
   /// @param attestation The trust attestation
   /// @param signature EIP-712 signature from the trustor's name controller (controllerOf)
   function setTrust(
      TrustAttestation calldata attestation,
      bytes calldata signature
   ) external;

   /// @notice Batch set multiple trust relationships
   /// @dev All attestations MUST share the same trustorNode, and each MUST be signed by
   ///      the trustor's name controller (controllerOf).
   /// @param attestations Array of trust attestations
   /// @param signatures Corresponding signatures
   function setTrustBatch(
      TrustAttestation[] calldata attestations,
      bytes[] calldata signatures
   ) external;

   /// @notice Withdraw the trustor's endorsement (level None) in one scope
   /// @dev Caller MUST be the name controller (controllerOf) or an approved operator.
   ///      Prior trust is NOT required: a trustor may preemptively withdraw in a scope
   ///      it never granted. MUST NOT change the denial flag.
   /// @param trustorNode The trustor's ENS namehash
   /// @param trusteeNode The agent to revoke trust from
   /// @param scope The scope to revoke trust in
   /// @param reasonCode Reason code for revocation
   function revokeTrust(
      bytes32 trustorNode,
      bytes32 trusteeNode,
      bytes32 scope,
      bytes32 reasonCode
   ) external;

   /// @notice Withdraw the trustor's endorsement across several scopes in one transaction
   /// @dev Caller MUST be the name controller (controllerOf) or an approved operator.
   ///      Scopes are supplied by the caller; this standard does not enumerate them
   ///      on-chain. MUST NOT change the denial flag.
   /// @param trustorNode The trustor's ENS namehash
   /// @param trusteeNode The agent to revoke trust from
   /// @param scopes The scopes to revoke trust in
   /// @param reasonCode Reason code for revocation
   function revokeTrustBatch(
      bytes32 trustorNode,
      bytes32 trusteeNode,
      bytes32[] calldata scopes,
      bytes32 reasonCode
   ) external;

   /// @notice Deny an agent within the trustor's own decisions in one scope
   /// @dev Caller MUST be the name controller (controllerOf); approved operators MUST
   ///      NOT call this. A denied agent fails every edge from the trustor, and every
   ///      path the trustor evaluates as validator, whatever its endorsements.
   ///      MUST NOT change the trust level, expiry or any other stored field.
   ///      Denying an agent already denied in that scope succeeds with no event.
   /// @param trustorNode The trustor's ENS namehash
   /// @param trusteeNode The agent to deny
   /// @param scope The scope of the denial; bytes32(0) applies in every scope
   /// @param reasonCode Reason code for the denial
   function setDenial(
      bytes32 trustorNode,
      bytes32 trusteeNode,
      bytes32 scope,
      bytes32 reasonCode
   ) external;

   /// @notice Clear the trustor's denial of an agent in one scope, provided no
   ///         attestation by the trustor has landed since the caller reviewed it
   /// @dev Caller MUST be the name controller (controllerOf); approved operators MUST
   ///      NOT call this. MUST revert with NonceChanged unless
   ///      getNonce(trustorNode) == expectedNonce, so endorsements written after the
   ///      caller's review cannot take effect through the clear.
   ///      It cannot stop an attestation signed earlier from landing after the
   ///      clear: callers SHOULD raise the floor with invalidateNonces before
   ///      reading expectedNonce, and SHOULD pause signing until the clear
   ///      has executed.
   ///      MUST NOT change the trust level, expiry or any other stored field.
   ///      Clearing a denial that is not set succeeds with no event.
   /// @param trustorNode The trustor's ENS namehash
   /// @param trusteeNode The agent to reinstate
   /// @param scope The scope of the denial to clear
   /// @param expectedNonce The trustor's nonce at the time of review
   /// @param reasonCode Reason code for the change
   function clearDenial(
      bytes32 trustorNode,
      bytes32 trusteeNode,
      bytes32 scope,
      uint64 expectedNonce,
      bytes32 reasonCode
   ) external;

   /// @notice Invalidate every outstanding attestation below a nonce
   /// @dev Caller MUST be the name controller; approved operators MUST NOT call this.
   ///      It voids every outstanding attestation across all trustees and scopes.
   ///      Revocation alone does NOT invalidate attestations the trustor already signed
   ///      but has not yet submitted; this does.
   /// @param trustorNode The trustor's ENS namehash
   /// @param newNonce The new nonce floor; MUST exceed the current nonce by at most 2**32
   function invalidateNonces(bytes32 trustorNode, uint64 newNonce) external;

   /// @notice Get trust record between two agents in a specific scope
   /// @param trustorNode The trusting agent
   /// @param trusteeNode The trusted agent
   /// @param scope The trust scope (bytes32(0) for universal)
   /// @return level Current trust level
   /// @return expiry Expiration timestamp (0 = never)
   function getTrust(
      bytes32 trustorNode,
      bytes32 trusteeNode,
      bytes32 scope
   ) external view returns (TrustLevel level, uint64 expiry);

   /// @notice Get the denial flag stored for exactly one scope
   /// @dev Returns the stored flag, not the effective denial: a universal denial is
   ///      not reflected in the result for a named scope. The effective denial is
   ///      getDenial(A, B, bytes32(0)) || getDenial(A, B, scope)
   /// @param trustorNode The trusting agent
   /// @param trusteeNode The denied agent
   /// @param scope The scope (bytes32(0) for universal)
   /// @return Whether the flag is set for that scope
   function getDenial(
      bytes32 trustorNode,
      bytes32 trusteeNode,
      bytes32 scope
   ) external view returns (bool);

   /// @notice Get current nonce for a trustor
   /// @param trustorNode The agent's ENS namehash
   /// @return Current nonce value
   function getNonce(bytes32 trustorNode) external view returns (uint64);

   // ═══════════════════════════════════════════════════════════════════
   // Path Verification
   // ═══════════════════════════════════════════════════════════════════

   /// @notice Verify a pre-computed trust path
   /// @dev Returns true only if every edge check, the validator denial check
   ///      AND the requiredAnchors constraint are satisfied. There is no partial
   ///      success.
   /// @param path The trust path to verify
   /// @param params Validation parameters
   /// @return valid Whether the path satisfies all validation requirements
   function verifyPath(
      TrustPath calldata path,
      ValidationParams calldata params
   ) external view returns (bool valid);

   // ═══════════════════════════════════════════════════════════════════
   // ERC-8001 Integration
   // ═══════════════════════════════════════════════════════════════════

   /// @notice Set the identity gate for one of the caller's coordination types
   /// @dev Stored under `(msg.sender, coordinationType)`. Callers own their own
   ///      namespace, so no cross-caller authorisation is needed and well-known
   ///      coordination type constants cannot be squatted.
   /// @param coordinationType The ERC-8001 coordination type
   /// @param gatekeeperNode Agent whose trust graph gates entry
   /// @param params Validation parameters for the gate
   function setIdentityGate(
      bytes32 coordinationType,
      bytes32 gatekeeperNode,
      ValidationParams calldata params
   ) external;

   /// @notice Remove the caller's identity gate for a coordination type
   /// @param coordinationType The ERC-8001 coordination type
   function removeIdentityGate(bytes32 coordinationType) external;

   /// @notice Get identity gate configuration
   /// @param coordinator The address that registered the gate
   /// @param coordinationType The ERC-8001 coordination type
   /// @return gatekeeperNode The gatekeeper agent
   /// @return params Validation parameters
   /// @return enabled Whether the gate is active
   function getIdentityGate(
      address coordinator,
      bytes32 coordinationType
   ) external view returns (
      bytes32 gatekeeperNode,
      ValidationParams memory params,
      bool enabled
   );

   /// @notice Resolve the agent address bound to an ENS node
   /// @param node The agent's ENS namehash
   /// @return agent The node's forward address record, or address(0) if unset
   function resolveAgent(bytes32 node) external view returns (address agent);

   /// @notice Validate a participant identified by ENS node
   /// @param coordinator The address that registered the gate
   /// @param coordinationType The ERC-8001 coordination type
   /// @param participantNode The agent being gated
   /// @param path Pre-computed trust path from gatekeeper to participantNode
   /// @return isValid Whether participant passes the gate
   function validateParticipantWithPath(
      address coordinator,
      bytes32 coordinationType,
      bytes32 participantNode,
      TrustPath calldata path
   ) external view returns (bool isValid);

   /// @notice Validate an ERC-8001 participant identified by address
   /// @dev The terminal node of `path` MUST resolve to `participant`. This is the
   ///      hook an ERC-8001 coordinator calls for each entry in `participants`.
   /// @param coordinator The address that registered the gate
   /// @param coordinationType The ERC-8001 coordination type
   /// @param participant The participant address taken from the ERC-8001 intent
   /// @param path Pre-computed trust path from gatekeeper to the participant's node
   /// @return isValid Whether participant passes the gate
   function validateParticipantAddress(
      address coordinator,
      bytes32 coordinationType,
      address participant,
      TrustPath calldata path
   ) external view returns (bool isValid);
}

OPTIONAL Interface Extensions

The following functions are OPTIONAL. Implementations MAY include them but they are not required for compliance:

/// @title ENS Trust Registry - OPTIONAL Extensions
/// @notice Convenience and on-chain-search functions. NOT required for ERC-8107 compliance.
/// @dev On-chain graph traversal is expensive and DoS-prone; prefer off-chain path
///      computation with `verifyPath`. Provided for completeness only.
interface ITrustRegistryExtended is ITrustRegistry {
   /// @notice Get agents trusted by a given agent (paginated)
   /// @dev OPTIONAL - useful for indexing but not required
   function getTrustees(
      bytes32 trustorNode,
      TrustLevel minLevel,
      bytes32 scope,
      uint256 offset,
      uint256 limit
   ) external view returns (bytes32[] memory trustees, uint256 total);

   /// @notice Get agents that trust a given agent (paginated)
   /// @dev OPTIONAL - useful for indexing but not required
   function getTrustors(
      bytes32 trusteeNode,
      TrustLevel minLevel,
      bytes32 scope,
      uint256 offset,
      uint256 limit
   ) external view returns (bytes32[] memory trustors, uint256 total);

   /// @notice Validate an agent through on-chain graph traversal
   /// @dev OPTIONAL - expensive, prefer off-chain computation with verifyPath
   /// @param validatorNode The validating agent's perspective
   /// @param targetNode The agent to validate
   /// @param params Validation parameters
   /// @param marginalThreshold Number of marginal attestations required (for accumulation)
   /// @param fullThreshold Number of full attestations required
   function validateAgent(
      bytes32 validatorNode,
      bytes32 targetNode,
      ValidationParams calldata params,
      uint8 marginalThreshold,
      uint8 fullThreshold
   ) external view returns (
      bool isValid,
      uint8 pathLength,
      uint8 marginalCount,
      uint8 fullCount
   );

   /// @notice Check if any trust path exists
   /// @dev OPTIONAL - expensive, prefer off-chain computation
   function pathExists(
      bytes32 fromNode,
      bytes32 toNode,
      uint8 maxDepth
   ) external view returns (bool exists, uint8 depth);

   /// @notice Validate participant without pre-computed path
   /// @dev OPTIONAL - expensive, prefer validateParticipantWithPath
   function validateParticipant(
      address coordinator,
      bytes32 coordinationType,
      bytes32 participantNode,
      uint8 marginalThreshold,
      uint8 fullThreshold
   ) external view returns (bool isValid);
}

Interface Detection

Implementations MUST implement ERC-165 and MUST return true from supportsInterface for:

Interface Identifier
IERC165 0x01ffc9a7
ITrustRegistry 0x94f0ee34

An implementation that also provides the OPTIONAL extensions MUST return true for ITrustRegistryExtended (0x93e686a3); one that does not MUST return false. This is the mechanism by which a caller determines whether on-chain graph search is available before attempting it.

Per ERC-165, supportsInterface MUST return false for 0xffffffff and MUST use at most 30,000 gas.

ITrustRegistry's identifier is the XOR of the selectors of the seventeen functions declared in this document, excluding the inherited supportsInterface, matching Solidity's type(ITrustRegistry).interfaceId. ITrustRegistryExtended's identifier covers only the five extension functions, likewise excluding everything it inherits.

Adding, removing, or changing the signature of any function in ITrustRegistry changes its identifier. Implementations tracking a draft revision of this standard SHOULD recompute rather than hard-code the constant.

Earlier drafts had two other identifiers, and implementations of this revision MUST NOT report true for either:

See Deployments of Earlier Drafts.

Semantics

setTrust

setTrust MUST revert, in the order given under Revert Order, if:

If valid:

An attestation is accepted while the trustee is denied. It updates the stored endorsement, which takes effect only when the denial is cleared.

setTrustBatch

setTrustBatch MUST revert, in the order given under Revert Order, if:

The batch-level conditions apply to the whole batch before any attestation is checked individually.

revokeTrust

revokeTrust withdraws the trustor's endorsement. It is the only way to reach TrustLevel.None.

revokeTrust MUST revert if:

If valid:

A prior trust relationship is not required. A trustor MAY withdraw in a scope it never granted. Such a preemptive withdrawal is a tombstone: it blocks the universal fallback in that scope, and so blocks paths that would otherwise route through the edge there. Callers that want to distinguish withdrawing an existing endorsement from a preemptive tombstone MUST read getTrust first; the registry does not make that distinction.

A withdrawal at bytes32(0) voids the trustor's edge to the trustee in every scope, overriding positive scoped records for the same pair. A withdrawal in any other scope affects that scope only. In both cases only the named record is written, so getTrust for another scope still returns that scope's stored record; the override is applied during path verification (see Scope fallback semantics).

Withdrawal removes an edge and nothing else. It does not affect paths that do not use that edge: if other agents the trustor relies on still endorse the trustee, paths through them remain valid, including paths the trustor evaluates as validator. A universal withdrawal concerns one trustor and trustee pair; it is not a denylist. A trustor that refuses to rely on the trustee at all MUST use setDenial. The reason code does not change this: a withdrawal recorded as COMPROMISED is still only a withdrawal.

Withdrawal does not invalidate attestations the trustor has already signed but not yet submitted. See revokeTrustBatch and invalidateNonces.

revokeTrustBatch

revokeTrustBatch applies revokeTrust to several scopes for the same (trustorNode, trusteeNode) pair in a single transaction.

revokeTrustBatch MUST revert if:

If valid, for each listed scope:

Because trust is keyed by (trustorNode, trusteeNode, scope), a trustor that has granted trust in several scopes must name each one. Implementations MUST NOT enumerate scopes on-chain; callers derive the list from TrustSet logs, which is the same off-chain indexing this standard already assumes for path computation.

setDenial

setDenial denies trusteeNode within the trustor's decisions in scope.

setDenial MUST revert, in the order given under Revert Order, if:

If valid and the stored flag is false:

If the stored flag is already true, setDenial MUST succeed without changing storage and MUST NOT emit DenialSet. setDenial takes no nonce: denying is never blocked by other activity on the trustor's name.

clearDenial

clearDenial clears the trustor's denial of trusteeNode in scope, on condition that the endorsements it would reveal are the ones the caller reviewed.

clearDenial MUST revert, in the order given under Revert Order, if:

The nonce check applies whether or not the flag is set. Every attestation that is stored raises the trustor's nonce, as does invalidateNonces, so an unchanged nonce means no endorsement by the trustor, for any trustee and in any scope, has been written since the caller read it. Withdrawals do not raise the nonce, but can only reduce trust.

If valid and the stored flag is true:

If the stored flag is already false, clearDenial MUST succeed without changing storage and MUST NOT emit DenialSet. Every DenialSet event from either function therefore records a change, and the latest event for a key gives its stored flag.

The nonce check stops an attestation that lands before the clear from taking effect through it. It cannot stop one that lands after: an attestation signed above the current nonce stays submittable, and once the denial is cleared it applies as soon as it is stored. A caller SHOULD therefore clear in this order:

  1. If the trustor may have signed attestations that are not yet stored, call invalidateNonces with a floor above every nonce the trustor has signed
  2. After that call has executed, read getNonce(trustorNode), review the stored endorsements, and pass the nonce read to clearDenial

After step 1 no attestation signed earlier can be stored, either before the clear or after it, so the endorsements reviewed are the ones the clear reveals. Attestations still in flight then fail with NonceTooLow rather than making the clear revert. The nonce check still catches an attestation the trustor signs after step 1 if it is stored before the clear. One signed after step 1 and held until after the clear passes the check and applies once stored. That is fresh authorisation by the controller, not a stale signature, so no further mechanism is specified. A caller that must not let unreviewed attestations take effect right after reinstatement SHOULD pause or serialise the trustor's signing from step 1 until the clear has executed, especially when different components handle signing and recovery. If clearDenial reverts with NonceChanged, the caller SHOULD review again before retrying with the new nonce.

Prior trust is not required: a trustor MAY deny an agent it never endorsed. A universal denial applies in every scope, and no scoped flag can lift it. Denial has no expiry; it holds until the controller clears it.

getDenial

getDenial MUST return the denial flag stored for exactly (trustorNode, trusteeNode, scope). It MUST NOT return the effective denial: after a universal denial, getDenial for a named scope returns that scope's own flag. This matches getTrust, which also returns stored data, and lets clients inspect each scope's flag.

Callers derive the effective denial from two reads:

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

A scoped flag that is false never overrides a universal flag that is true. To lift an effective denial in scope s, both the universal flag and the flag for s have to be cleared.

invalidateNonces

Nonces are the only ordering this standard has between a signed attestation and a later revocation. An attestation carries no signing timestamp, so an attestation signed at nonce N remains submittable until the trustor's nonce reaches N, regardless of what has been revoked in the meantime.

invalidateNonces lets a trustor raise that floor.

invalidateNonces MUST revert if:

If valid:

Every attestation signed with a nonce at or below newNonce becomes permanently unusable, whichever trustee or scope it names.

Trustors SHOULD issue nonces sequentially (getNonce(trustorNode) + 1) so that the highest outstanding nonce is always known. Signing far ahead of the current nonce creates attestations that stay submittable indefinitely; setTrust rejects an attestation more than 2^32 above the current nonce.

To quarantine a compromised or misbehaving counterparty, a trustor SHOULD:

  1. Set denial at universal scope. The counterparty then fails every edge from the trustor and every path the trustor evaluates as validator, so gates whose gatekeeper is the trustor reject it even while other agents still vouch for it. Gates whose gatekeeper is any other agent are unaffected.
  2. Invalidate outstanding nonces, with a floor above every nonce the trustor has signed. This is trustor-wide: it voids the trustor's outstanding attestations for every trustee and scope, not only the counterparty's. Attestations signed with a nonce above the chosen floor remain submittable.
  3. Investigate and recover: once step 2 has executed, read the trustor's nonce, review the stored endorsements, withdraw those that should not return, then call clearDenial with the nonce read if the counterparty is reinstated.

The denial does not depend on step 2: a pre-signed attestation submitted later updates the stored endorsement but cannot lift the denial. Step 2 matters for recovery, since clearing the denial reveals whatever endorsement is stored at that time, including one that a stray signature wrote while the denial was in force. The nonce check in clearDenial depends on step 2. Without it, the check rejects a clear when an attestation signed earlier lands first, but not when that attestation is held back and submitted after the clear, where it applies immediately. Reading the nonce after step 2 closes both orderings and leaves the check to cover only attestations the trustor signs after step 2.

Every step requires the name controller. An approved operator can withdraw endorsements, but cannot deny, clear a denial, or invalidate nonces.

This procedure assumes the trustor's own controller is sound. If the trustor's controller is itself compromised, denial is not sufficient, because the same controller can clear it; see Key Compromise.

verifyPath — Path Verification Algorithm

verifyPath validates a pre-computed trust path.

verifyPath returns a single verdict. It MUST return false unless every requirement is satisfied, including the requiredAnchors constraint. A path whose edges all verify but which does not traverse a required anchor is not valid, and implementations MUST NOT report it as such.

Parameter validation: verifyPath MUST reject ValidationParams that violate the Validation Parameters Constraints before using them, through the same routine setIdentityGate uses. This is a correctness requirement, not a courtesy: with minEdgeTrust == TrustLevel.Unknown, the per-edge comparison level < minEdgeTrust can never be true, so an unvalidated call would report a path with no trust at all as valid.

validateParticipantWithPath and validateParticipantAddress read parameters from a stored gate rather than from the caller. Those parameters were validated when setIdentityGate stored them, so those functions MAY skip revalidation, as the pseudocode below does by passing validateParams = false; they MUST NOT accept parameters from any other source.

Algorithm:

The external functions are thin wrappers around internal helpers that take memory arguments: _verifyPath, _getTrust (the stored record, as getTrust returns it) and _getIdentityGate (the stored gate, as getIdentityGate returns it). The validateParams flag is false only for parameters read from a stored gate.

function verifyPath(
   TrustPath calldata path,
   ValidationParams calldata params
) external view returns (bool valid) {
   return _verifyPath(path, params, true);
}

function _verifyPath(
   TrustPath memory path,
   ValidationParams memory params,
   bool validateParams
) internal view returns (bool) {
   // Parameters are validated BEFORE they are used. Skipping this would let
   // minEdgeTrust == Unknown make every edge comparison vacuously pass.
   if (validateParams) requireValidParams(params);

   // Path must have at least 2 nodes (validator and target)
   if (path.nodes.length < 2) return false;

   // Path length constraint (edges = nodes - 1)
   if (path.nodes.length - 1 > params.maxPathLength) return false;

   // Nodes MUST be distinct; a repeated node inflates length without adding trust.
   // maxPathLength caps this at 11 nodes, so the quadratic scan is bounded.
   for (uint256 i = 0; i < path.nodes.length; i++) {
      for (uint256 j = i + 1; j < path.nodes.length; j++) {
         if (path.nodes[i] == path.nodes[j]) return false;
      }
   }

   // The validator's denial overrides any indirect endorsement.
   // k = 1 is already covered by the first edge check.
   bytes32 validator = path.nodes[0];
   for (uint256 k = 2; k < path.nodes.length; ++k) {
      (, , bool validatorDenies) = _edgeTrust(validator, path.nodes[k], params.scope);
      if (validatorDenies) return false;
   }

   // Track anchor satisfaction
   bool foundAnchor = params.requiredAnchors.length == 0;

   // Verify each edge
   for (uint256 i = 0; i < path.nodes.length - 1; i++) {
      // Universal None, then a stored scoped record, then universal trust
      (TrustLevel level, uint64 expiry, bool denied) =
         _edgeTrust(path.nodes[i], path.nodes[i + 1], params.scope);

      // Denial voids the edge whatever its endorsement
      if (denied) return false;

      // Edge must meet minimum trust level
      if (level < params.minEdgeTrust) return false;

      // None explicitly voids (even if minEdgeTrust is somehow None)
      if (level == TrustLevel.None) return false;

      // Expiry check
      if (params.enforceExpiry && expiry != 0 && expiry <= block.timestamp) {
         return false;
      }

      // Anchor check (intermediate nodes only, not first or last)
      if (!foundAnchor && i > 0) {
         for (uint256 j = 0; j < params.requiredAnchors.length; j++) {
            if (path.nodes[i] == params.requiredAnchors[j]) {
               foundAnchor = true;
               break;
            }
         }
      }
   }

   // Anchors are part of the verdict, not a separate advisory signal
   return foundAnchor;
}

Node uniqueness: A path MUST NOT contain the same node twice. Repeated nodes cannot manufacture trust, since every edge is still checked, but they inflate path length and serve no purpose. maxPathLength bounds a path at 11 nodes, so the duplicate scan is at most 55 comparisons.

Edge denial: verifyPath MUST return false if, for any edge nodes[i] → nodes[i+1], EffectiveDeny(nodes[i], nodes[i+1], params.scope) holds (see Denial). This applies whatever endorsement the edge stores, whether or not enforceExpiry is set, and whatever minEdgeTrust is.

Validator denial: verifyPath MUST return false if, for any node nodes[k] with k >= 2, EffectiveDeny(nodes[0], nodes[k], params.scope) holds. For k = 1 the edge denial check already applies.

The validator check is per validator, not global. It applies only to paths evaluated with the denying agent as nodes[0]. A denial held by any other node on the path voids the path only through the edge check on that node's own outgoing edge. The validator check reads denial only: the validator's trust levels for nodes beyond the first, including None and Unknown, do not void the path.

Formal statement. Write a path as P = [P0, …, Pn] with n ≥ 1, write s for params.scope, write L(A, B, s) and x(A, B, s) for the level and expiry chosen by the Scope fallback semantics, and write D(A, B, s) for EffectiveDeny(A, B, s). A path that has distinct nodes, satisfies n ≤ maxPathLength, and meets the anchor requirement is valid if and only if both of the following hold:

Endorsement:  ∀ i ∈ [0, n):  L(Pi, Pi+1, s) ≥ minEdgeTrust
                             ∧ ¬(enforceExpiry ∧ x(Pi, Pi+1, s) ≠ 0 ∧ x(Pi, Pi+1, s) ≤ now)

Denial:       ∀ i ∈ [0, n):  ¬D(Pi, Pi+1, s)
              ∀ k ∈ [2, n]:  ¬D(P0, Pk, s)

Together the two denial lines give ¬D(P0, Pk, s) for every k ∈ [1, n]. The two checks read different state under different authority. The endorsement check reads levels, which are written by signed attestations and by withdrawals that approved operators may submit. The denial check reads denial flags, which only the name controller can write. A withdrawal therefore changes only the endorsement check, and only for the edge it names.

Anchor semantics: Only intermediary nodes (indices 1 through nodes.length - 2) can satisfy requiredAnchors. The validator and the target are excluded, so a direct edge [A, B] can never satisfy a non-empty requiredAnchors. Callers that want anchors to be advisory MUST pass an empty requiredAnchors array rather than inspecting a partial result.

Scope fallback semantics:

"Universal" below means the record stored at bytes32(0). When params.scope is bytes32(0), the universal record applies. Otherwise, implementations MUST determine each edge's trust as follows:

  1. If the universal level is None, the edge is None: universal withdrawal voids the edge in every scope
  2. Otherwise, if a record is stored at params.scope (its level is not Unknown), that record applies, whether or not it has expired
  3. Otherwise, the universal record applies

An expired scoped record therefore never falls back to universal trust: with enforceExpiry set, the edge fails.

Denial does not use these fallback rules. _edgeTrust returns the effective denial alongside the level and expiry, read from the same records. The reference implementation packs the level, expiry, write time and denial flag of each record into one storage slot, so the edge denial check needs no storage read beyond those the fallback rules already make. The validator denial check reuses the same function for the pair (nodes[0], nodes[k]) and ignores its level and expiry.

function _edgeTrust(bytes32 from, bytes32 to, bytes32 scope)
   internal view returns (TrustLevel level, uint64 expiry, bool denied)
{
   TrustRecord storage u = _trust[from][to][bytes32(0)];

   // Universal denial holds in every scope, so no scoped record can lift it
   if (scope == bytes32(0) || u.denied) return (u.level, u.expiry, u.denied);

   TrustRecord storage s = _trust[from][to][scope];

   // 1. Universal None voids the endorsement in every scope
   if (u.level == TrustLevel.None) return (TrustLevel.None, u.expiry, s.denied);

   // 2. Any stored scoped record is authoritative, expired or not
   if (s.level != TrustLevel.Unknown) return (s.level, s.expiry, s.denied);

   // 3. Otherwise universal applies
   return (u.level, u.expiry, s.denied);
}

TrustRecord and _trust are the reference implementation's storage. Implementations MAY store records differently, provided _edgeTrust returns the same values.

setIdentityGate

Gates are stored under the key (msg.sender, coordinationType). A caller can only create, replace, or remove gates within its own namespace, so no cross-caller authorisation check is required and two callers can never collide on the same coordinationType.

setIdentityGate MUST revert if params violates the Validation Parameters Constraints.

setIdentityGate MUST NOT require the caller to control gatekeeperNode. Naming a gatekeeper only reads that agent's public attestations; it neither modifies them nor makes any claim on the gatekeeper's behalf.

If valid:

removeIdentityGate

removeIdentityGate MUST revert with GateNotFound if no enabled gate exists at (msg.sender, coordinationType).

If valid:

validateParticipantWithPath

This function gates ERC-8001 coordination participation for a participant identified by ENS node.

The caller names the agent being gated. Implementations MUST bind the result to that agent by requiring the path to terminate at participantNode; a path that merely originates at the gatekeeper proves nothing about the participant.

function validateParticipantWithPath(
   address coordinator,
   bytes32 coordinationType,
   bytes32 participantNode,
   TrustPath calldata path
) external view returns (bool isValid) {
   (bytes32 gatekeeperNode, ValidationParams memory params, bool enabled) =
               _getIdentityGate(coordinator, coordinationType);

   // No gate = misconfiguration, not open participation
   if (!enabled) revert GateNotFound(coordinationType);

   if (path.nodes.length < 2) return false;

   // Path MUST start at the gatekeeper...
   if (path.nodes[0] != gatekeeperNode) return false;

   // ...and MUST terminate at the participant being gated
   if (path.nodes[path.nodes.length - 1] != participantNode) return false;

   return _verifyPath(path, params, false);
}

validateParticipantWithPath MUST return false if:

It MUST revert with GateNotFound when no gate is enabled at (coordinator, coordinationType), before any other check. An unconfigured gate is a misconfiguration, not open participation; see "Why Do Unconfigured Gates Revert?".

validateParticipantAddress

This is the hook an ERC-8001 coordinator calls for each entry in an intent's participants array. It differs from validateParticipantWithPath only in how the participant is identified: by address rather than by node.

function validateParticipantAddress(
   address coordinator,
   bytes32 coordinationType,
   address participant,
   TrustPath calldata path
) external view returns (bool isValid) {
   (bytes32 gatekeeperNode, ValidationParams memory params, bool enabled) =
               _getIdentityGate(coordinator, coordinationType);

   // No gate = misconfiguration, not open participation
   if (!enabled) revert GateNotFound(coordinationType);

   if (participant == address(0)) return false;
   if (path.nodes.length < 2) return false;
   if (path.nodes[0] != gatekeeperNode) return false;

   // The terminal node MUST be bound to the participant address
   if (resolveAgent(path.nodes[path.nodes.length - 1]) != participant) return false;

   return _verifyPath(path, params, false);
}

validateParticipantAddress MUST return false if:

It MUST revert with GateNotFound when no gate is enabled at (coordinator, coordinationType), before any other check.

Because an unresolvable node yields address(0), the participant == address(0) check also prevents a node with no addr record from matching a zero participant address.

A coordinator gating a full ERC-8001 intent calls this once per entry in participants, supplying one pre-computed path per participant.

Errors

Implementations MUST revert with these errors:

error SelfTrustProhibited();
error InvalidAttestationLevel(TrustLevel level);
error NonceTooLow(uint64 provided, uint64 required);
error AttestationExpired(uint64 expiry, uint64 currentTime);
error InvalidSignature();
error NotAuthorized(bytes32 node, address actor);
error ENSNameNotFound(bytes32 node);
error GateNotFound(bytes32 coordinationType);
error InvalidMaxPathLength(uint8 provided);
error InvalidMinEdgeTrust(TrustLevel provided);
error TooManyRequiredAnchors(uint256 provided);
error BatchLengthMismatch();
error EmptyBatch();
error BatchTrustorMismatch();
error BatchNonceNotIncreasing();
error EmptyScopeList();
error NonceJumpTooLarge(uint64 provided, uint64 max);
error NonceChanged(uint64 expected, uint64 current);

NonceTooLow.required is getNonce(trustorNode) + 1, saturating at type(uint64).max. Once a trustor's floor reaches type(uint64).max no nonce is accepted, and both setTrust and invalidateNonces revert with NonceTooLow(provided, type(uint64).max) rather than an arithmetic panic.

Revert Order

Implementations MUST evaluate conditions in the order listed and MUST revert with the first error that applies.

Function Order Condition Error
setTrust 1 trustorNode == trusteeNode SelfTrustProhibited
setTrust 2 level is Unknown or None InvalidAttestationLevel
setTrust 3 expiry != 0 && expiry <= block.timestamp AttestationExpired
setTrust 4 ens.owner(trustorNode) == address(0) ENSNameNotFound
setTrust 5 nonce <= getNonce(trustorNode) NonceTooLow
setTrust 6 nonce > getNonce(trustorNode) + 2**32, capped at type(uint64).max NonceJumpTooLarge
setTrust 7 signature fails, including controllerOf returning zero for an expired wrapped name InvalidSignature
setTrustBatch 1 lengths differ BatchLengthMismatch
setTrustBatch 2 empty array EmptyBatch
setTrustBatch 3 any trustorNode differs from the first BatchTrustorMismatch
setTrustBatch 4 nonces not strictly increasing BatchNonceNotIncreasing
setTrustBatch 5 per item, the setTrust checks in order as above
revokeTrust 1 caller not controller or approved operator NotAuthorized
revokeTrustBatch 1 caller not controller or approved operator NotAuthorized
revokeTrustBatch 2 scopes empty EmptyScopeList
setDenial 1 caller not controller NotAuthorized
setDenial 2 trustorNode == trusteeNode SelfTrustProhibited
clearDenial 1 caller not controller NotAuthorized
clearDenial 2 trustorNode == trusteeNode SelfTrustProhibited
clearDenial 3 getNonce(trustorNode) != expectedNonce NonceChanged
invalidateNonces 1 caller not controller NotAuthorized
invalidateNonces 2 newNonce <= getNonce(trustorNode) NonceTooLow
invalidateNonces 3 newNonce > getNonce(trustorNode) + 2**32, capped at type(uint64).max NonceJumpTooLarge
setIdentityGate, verifyPath 1–3 the Validation Parameters Constraints, in table order InvalidMaxPathLength, InvalidMinEdgeTrust, TooManyRequiredAnchors
removeIdentityGate 1 no enabled gate GateNotFound
validateParticipantWithPath, validateParticipantAddress 1 no enabled gate GateNotFound

Recommended Reason Codes

For TrustRevoked and DenialSet events, the following reason codes are RECOMMENDED:

Reason Code Value Meaning
Unspecified bytes32(0) No specific reason
Misbehaviour keccak256("MISBEHAVIOR") Agent acted improperly
Compromised keccak256("COMPROMISED") Key or account compromised
Inactive keccak256("INACTIVE") Agent no longer active
Transfer keccak256("TRANSFER") ENS name transferred

Reason codes are informational and do not change the effect of an operation. Every withdrawal sets the level to None and nothing else: a withdrawal recorded as COMPROMISED does not deny the agent, and paths that avoid the withdrawn edge stay valid. Where exclusion is intended, it MUST be expressed with setDenial. clearDenial MAY use bytes32(0).

Recommended Scopes

For interoperability, the following scope values are RECOMMENDED:

Scope Value Use Case
Universal bytes32(0) Trust applies to all contexts
DeFi keccak256("DEFI") DeFi coordination
Gaming keccak256("GAMING") Gaming/metaverse
MEV keccak256("MEV") MEV protection
Commerce keccak256("COMMERCE") Agentic commerce

Recommended Coordination Types

For ERC-8001 identity gates:

Coordination Type Value
MEV Coordination keccak256("MEV_COORDINATION")
DeFi Yield keccak256("DEFI_YIELD")
Gaming Match keccak256("GAMING_MATCH")
Commerce Escrow keccak256("COMMERCE_ESCROW")

Rationale

Why ENS Instead of a New Identity System?

ENS is finalised ERC-137, battle-tested, and widely adopted. Creating a new identity system would:

ENS provides everything needed: stable identifiers, ownership semantics, and extensibility.

Relationship to Token-Based Agent Identity Registries

Some agent identity registries represent each agent as an ERC-721 token, and attach reputation and validation records to that token rather than to a name. This registry does not depend on any of them, but it can consume such an identity through an adapter. For example, a protocol can issue an ENS subname for each agent token, keep the subname's controller in step with the token's holder, and point its addr record at the agent's address. The agent then attests, is attested to, and passes identity gates through that name exactly as any other ENS-named agent does.

Keeping the dependency in an adapter means requires lists only finalised standards, and a change to a registry that is still in draft cannot change the behaviour of this one.

Why Scope as Storage Key?

A trustor may have different trust levels for the same trustee in different contexts. For example:

Making scope part of the storage key (trustorNode, trusteeNode, scope) enables this naturally. Universal trust bytes32(0) serves as a fallback when scoped trust is not specified. A universal withdrawal overrides every scope for its trustor and trustee pair, and a universal denial applies in every scope.

Why minEdgeTrust Instead of Marginal/Full Thresholds?

The marginalThreshold and fullThreshold parameters were designed for on-chain graph traversal with marginal accumulation logic. Since on-chain traversal is optional (expensive, DoS-prone), and the core primitive is verifyPath, we need only specify the minimum trust level each edge must have.

This simplification:

For use cases requiring marginal accumulation, the optional validateAgent extension accepts threshold parameters.

Why Separate Signing Authority from Transaction Submission?

ENS approvals (isApprovedForAll) are designed for operators to manage names on behalf of controllers. However, allowing approved operators to forge attestation signatures would break the cryptographic binding between attestations and name controllers.

By restricting signing authority to the name controller (or ERC-1271 for contract controllers) while allowing operators to submit transactions like revokeTrust, we preserve:

Why verifyPath Only (No On-Chain Search)?

On-chain graph traversal is expensive and creates DoS vectors:

By requiring pre-computed paths, this standard:

Implementations are free to add validateAgent and pathExists as optional extensions; the Specification does not require them for compliance.

Why Four Trust Levels?

The four-level model (Unknown, None, Marginal, Full) is proven by GnuPG's 25+ years of use. Finer granularity adds complexity without clear benefit; coarser granularity loses important distinctions.

With minEdgeTrust, applications can choose their security posture:

Denial is a flag beside the level, not a fifth level. A level answers how far the trustor vouches for an agent; denial answers whether the trustor will rely on the agent at all. The two must vary independently: a trustor needs to deny an agent without losing the endorsement it would restore afterwards, and to withdraw an endorsement without denying. A single ordered enum cannot hold both answers at once, and a fifth value would also change the ordering that minEdgeTrust comparisons rely on.

Divergence from OpenPGP

OpenPGP keeps two quantities apart. Validity says whether a key belongs to its claimed holder, and is computed from certifications. Owner trust says how far a user relies on a key holder's certifications, and each user assigns it privately. This standard keeps one quantity, the level on an edge. Where the trustee is an intermediary, the level acts as owner trust: it decides whether the path may continue through that agent. Where the trustee is the target, it acts as validity. The binding between a name and its controller, which OpenPGP validity establishes for keys, comes from ENS ownership rather than from attestations.

The required verifyPath also performs no marginal accumulation. In OpenPGP a key becomes valid with one fully trusted certification or several marginally trusted ones (GnuPG requires three by default). Here a single path is checked and every edge must reach minEdgeTrust, so marginal edges never combine. Accumulation across several paths is left to the OPTIONAL validateAgent extension, whose marginalThreshold and fullThreshold parameters play the role of GnuPG's thresholds.

Both choices keep on-chain verification to one verdict per path at O(path length) cost.

Why Required Anchors?

Sybil attacks are the primary threat to web of trust systems. Required anchors force trust paths to traverse established community nodes (DAOs, protocols, auditors), transforming Sybil resistance from application-layer advice into protocol-level enforcement.

Why Are Gates Keyed by Caller Address?

coordinationType in ERC-8001 is a proposer-chosen identifier such as keccak256("MEV_SANDWICH_COORD_V1"). It carries no namespace of its own, so two unrelated applications can and will choose the same value. A gate registry keyed on coordinationType alone would hand whichever application registered first permanent control of that identifier for everybody else, and the Recommended Coordination Types in this document would be the first values squatted.

Keying gates by (msg.sender, coordinationType) gives every caller a namespace it inherently owns. Squatting becomes impossible, registering a gate requires no ENS name at all, and well-known coordination type constants stay safe to publish.

The alternative of keying by (gatekeeperNode, coordinationType) was rejected because it conflates two distinct roles: the party that sets a policy and the party whose trust graph the policy reads. A coordinator should be able to gate on any agent's public attestations without that agent's involvement.

Why Do Unconfigured Gates Revert?

The integrator supplies both halves of the gate key, (coordinator, coordinationType). A mistyped coordination type, or a coordinator address taken from the wrong deployment, finds no gate. If an unconfigured gate were open, that mistake would admit every participant while behaving exactly like a working gate, and nothing on-chain would show it until an untrusted agent had already joined.

Reverting with GateNotFound makes the misconfiguration fail at the first call. Coordinators that want open participation for some coordination types lose nothing: they do not call the hook for those types. Calling it is a statement that a gate is expected.

Returning false was rejected. It also fails closed, but a missing gate would then be indistinguishable from a participant who is not trusted, so the coordinator and the participant would both be told the participant was at fault. A revert names the real problem and carries the coordination type that was looked up.

The cost is that the hook can no longer be called unconditionally as a generic module. A coordinator that gates only some coordination types has to know which ones, and after removeIdentityGate further calls for that type revert until the coordinator stops making them. Coordinators that need to branch can read getIdentityGate(...).enabled first.

Why Forward Resolution Instead of a Binding Registry?

The addr record is already the name controller's authoritative statement of which address a name denotes. Reusing it means agents that are usable today are usable here with no registration step, no additional storage, and no second source of truth to keep synchronised.

Reverse resolution was rejected: reverse records are self-asserted, verifying one requires a forward-resolution round trip, and doing so on-chain means string handling and namehash computation over untrusted input.

The trade-off is that resolution follows the addr record, so a controller can repoint a trusted name at a new address. This is discussed under Security Considerations, and it is the same exposure the standard already accepts for ENS name transfers.

Why Does Resolution Cap Gas and Never Revert?

A participant chooses its own resolver, and an ERC-8001 coordinator typically validates every participant of an intent in one call. If resolution could revert, or could consume 63/64 of the remaining gas, one participant could abort validation for all the others. Capping the forwarded gas and treating every malformed answer as "no address" makes a hostile resolver cost the caller at most the cap and fail closed for that participant only.

The 50,000 cap is well above what deployed resolvers need. Measured at mainnet block 26,173,410 with all touched state cold, addr(bytes32) needed 3,669 gas on the 2020 PublicResolver, 5,654 on the current PublicResolver, 8,726 on the base.eth resolver and 18,752 on the most expensive resolver sampled, which forwards through several contracts. The cap is not a protocol constant: gas repricing could change these figures, which is why the bound itself is left to implementations.

ERC-1271 checks are not gas-capped. They run only when an attestation is submitted, and a batch carries a single trustor, so a controller that consumes gas can only waste gas for whoever submits its own attestations. Validation never verifies signatures.

Why a Single Per-Trustor Nonce?

A single monotonic nonce per trustor serialises that agent's attestations: signatures must be submitted in ascending order, and a signature is permanently stranded if a higher-nonced one lands first. Three alternatives avoid some or all of that cost, and each was considered separately.

Unordered nonce bitmap. Any unused nonce is accepted, so ordering stops mattering. There is also no floor to raise, so invalidateNonces cannot exist. A trustor responding to a compromise would have to enumerate and burn every outstanding nonce individually. That requires knowing them, which is exactly what a compromised agent does not know.

Ordered nonces per (trustor, trustee, scope). Each relationship has its own floor, so issuing for one relationship never strands another. Invalidation remains possible, but only one record at a time. Quarantining a counterparty means raising a floor for every scope the trustor ever used with it, enumerated off-chain as for revokeTrustBatch, and trustor-wide invalidation means doing that for every trustee. Signers also have to track one counter per relationship rather than one per identity.

Global floor plus per-trustee floor. An attestation would be valid only above both the trustor's global nonce and a floor kept for its (trustor, trustee) pair. This does allow targeted quarantine: a trustor could void the outstanding attestations naming one counterparty without touching any other. Its costs are:

The hybrid was not adopted because the case it improves is narrow. Targeted quarantine helps only when a trustor has outstanding signatures naming the quarantined counterparty and, at the same time, outstanding signatures naming others that it wants to keep. Sequential issuance, which this standard recommends, keeps that window small, and the cost of a trustor-wide floor within it is re-signing the attestations still in flight. That does not justify a second nonce space for every signer and a larger interface for every implementation.

The serialisation cost is therefore the price of a single trustor-wide floor that one call can raise. It is a reasonable trade for a trust registry, where attestations are infrequent and correct revocation matters more than issuance throughput. Agents needing high issuance throughput should batch with setTrustBatch, which consumes a contiguous nonce range in one transaction.

Why Attestations Grant and Revocations Withdraw?

Earlier drafts allowed setTrust to carry TrustLevel.None, which meant None could be reached two ways: by a signed attestation, or by revokeTrust. The two paths had different authorisation models (signature versus caller), emitted different events, and disagreed on whether prior trust was required.

Restricting attestations to Marginal and Full, and routing every withdrawal through revocation, gives each level exactly one path, one event, and one authorisation rule. Revocation no longer requires prior trust, so a trustor can also withdraw preemptively in a scope it never granted, leaving a tombstone that blocks the universal fallback there.

Why Explicit Nonce Invalidation?

Revocation cannot, on its own, undo an attestation that has been signed but not yet submitted. Attestations carry no signing timestamp, so the only ordering available between "signed" and "revoked" is the trustor's nonce, and a revocation does not know what nonces the trustor has already signed.

Advancing the nonce by one during revokeTrust would be security theatre: an attestation signed with nonce 10 while the counter sits at 0 survives any small bump. Only the trustor knows the highest nonce it has issued, so only the trustor can set a floor that actually invalidates its outstanding signatures.

invalidateNonces is therefore a separate, explicit operation. The cost is that quarantining a counterparty is two calls rather than one, a denial and an invalidation, which is why the Semantics section states the combined procedure and Security Considerations repeats it.

Why Is invalidateNonces Controller-Only?

The only operation this standard lets an approved operator perform is bounded. A withdrawal affects one (trustorNode, trusteeNode) pair and the scopes named in the call, so delegating it to an approved operator lets an ops key handle routine incident response without being able to cause harm the controller cannot easily undo.

invalidateNonces is not bounded. Raising the nonce floor permanently voids every attestation the trustor has signed but not yet submitted, across all trustees and all scopes, and no counterparty can restore them. ENS isApprovedForAll is a coarse, long-lived approval granted so an operator can manage a name, and holders frequently grant it to marketplaces and management tools. Reading that approval as authority to void an agent's entire outstanding attestation set would give those tools a power their grantor never contemplated.

Restricting the operation to the controller costs one thing: an operator responding to an incident can withdraw endorsements but cannot complete a quarantine, which needs the controller for both the denial and the nonce invalidation. That is the correct place to require the controller.

Why Is Universal Withdrawal Authoritative?

A trustor that withdraws its endorsement at universal scope means it everywhere. If universal None applied only where no scoped record existed, every scoped grant the trustor had ever made to that trustee would keep its edge alive, and the trustor would have to find and withdraw each one before the withdrawal took full effect.

Making universal None override every scope for the same pair gives the trustor one call that holds in every scope. It covers scopes the trustor has overlooked, and scoped attestations signed earlier but submitted later, which would otherwise silently restore the withdrawn relationship. The override is confined to that one trustor and trustee pair: it removes the trustor's own edge and leaves paths through other agents alone. The cost is one extra storage read per edge when verifying in a named scope: a cold storage read plus its key hashing, about 2,800 gas per edge in the reference implementation, or about 28,000 gas on a 10-edge path. verifyPath is normally called in a view context or once per participant, which makes that cost acceptable for a guarantee that withdrawal is complete.

The override lasts only while the universal record is None. A later universal attestation replaces it, and every positive scoped record for the pair applies again, including scoped grants made long before the withdrawal. A trustor re-endorsing at universal scope SHOULD first review, or withdraw, the scoped grants it does not want to return.

The rule is applied during verification rather than written into each scoped record, so getTrust stays literal: it returns what is stored for the scope asked about.

A stored scoped record that has expired does not fall back to universal trust. Falling back would let a lapsed narrow grant silently widen into whatever universal trust the trustor holds, so the edge fails instead.

Why Separate Withdrawal from Denial?

A trustor can mean two different things when it stops vouching for an agent. It may simply no longer wish to endorse the agent itself, or it may refuse to rely on the agent at all. An earlier draft expressed both with None: a validator's None for any node voided every path that validator evaluated through that node. That had two defects.

This standard keeps the two apart. None withdraws an edge and nothing else, and stays delegable. Denial is a separate flag, written only by the name controller, that does exclude. Each operation is observable on its own, through TrustRevoked and DenialSet, and through getTrust and getDenial.

Why Does a Validator's Denial Override Indirect Paths?

Without the validator check, denial would only remove an edge. Suppose Alice trusts Bob, Bob trusts Carol, and Alice has denied Carol. The path [Alice, Carol] fails, because its only edge is denied. The path [Alice, Bob, Carol] checks only the edges Alice to Bob and Bob to Carol. Both hold, so the path would verify, and Bob's endorsement would override Alice's refusal inside Alice's own trust decision. That contradicts what a denial means.

The check covers intermediaries as well as the target. An intermediary's place on a path is to vouch for the next node, so the path relies on its judgement (see Divergence from OpenPGP). A validator that has denied an agent has rejected that agent's judgement. A path that depends on it is no more acceptable than a path that ends at that agent.

Only the validator's denial counts. Checking every pair of nodes on the path would cost O(n²) storage reads rather than O(n). It would also let third parties veto other validators' decisions: any agent placed on a path could void it by denying a node it was never asked to vouch for. Each agent's denial therefore governs its own outgoing edges, and its own paths as validator.

OpenPGP has no exact counterpart. Revoking a certification there resembles withdrawal. Assigning a key an owner trust of "never" stops its certifications counting, which resembles denial of an intermediary, but it leaves the key itself able to be valid through other certifications. Denial here also rejects the agent as a target.

The edge check reads no additional storage slot, because the flag shares a slot with the level and expiry that the edge check already reads; in the reference implementation it adds a few hundred gas per edge. The validator check costs one _edgeTrust call per node from index 2 onwards, at most 9 on a 10-edge path. Measured in the reference implementation with every storage slot cold, a 10-edge path with no denials costs 146,578 gas in a named scope and 92,191 gas at universal scope, against 90,889 and 62,787 gas for the same path with no denial checks at all. Nearly all of the difference is the validator check, about 6,000 gas per node in a named scope, where each lookup reads the universal and the scoped record cold, and about 3,000 at universal scope, where it reads one.

Trust can be transitive. Denial, when explicitly expressed by the validator, remains authoritative within that validator's own trust decision.

Why Does Clearing a Denial Check the Trustor's Nonce?

Endorsement and denial are stored independently, so an attestation can land while a denial is in force and is revealed when the denial is cleared. Without a check, a controller that reviews the stored endorsement and then clears the denial could be front-run by a relayer submitting another valid attestation, which the clear would then activate unreviewed.

The check uses the trustor's nonce rather than an expected endorsement. Clearing a universal denial reactivates the trustee's records in every scope, which a single expected record cannot describe and an exhaustive list cannot bound. Every stored attestation raises the nonce, so one comparison covers every scope at the cost of one storage read. The cost is false positives: any attestation by the trustor, for any trustee, makes a pending clear revert, and the controller reviews again. Clears are rare, and this fails closed.

The check closes the front-run, not the back-run. An attestation signed before the review and held back until after the clear passes the check, because it has not landed yet, and then applies immediately. Only raising the nonce floor above every signature issued closes that case, which is why clearDenial directs callers to read the nonce after invalidateNonces. Done in that order, attestations still in flight fail instead of causing false positives. Attestations the controller signs after raising the floor are fresh authorisations, and pausing signing until the clear executes is left to the caller. The floor is guidance rather than part of clearDenial: folding it in would void the trustor's outstanding attestations for every other trustee whenever one denial is cleared, and a trustor with nothing outstanding has no need of it.

The check covers only the trustor's own endorsements. When the trustor is the validator, clearing a denial also readmits paths that reach the trustee through other agents, whose endorsements the trustor neither controls nor can pin.

Denying takes no nonce. It is the emergency operation, and it must not fail because an attestation landed first. For the same reason the guard is a separate function rather than an optional argument: an unguarded clear would be the easier call, and the protection would go unused.

Why Is Denial Controller-Only?

Withdrawal changes one edge. Denial changes which agents' endorsements the trustor accepts, as validator and on every edge from it, and clearing a denial readmits an agent the controller excluded. Both directions carry the authority of the trustor's judgement, the same authority as signing an attestation, so both stay with the controller.

Denial reuses the existing controller check rather than adding a role, a signature type or a delegation registry. A controller that needs to delegate it can use a contract controller, which decides for itself who may act. The limit described under Signature Authority still applies: an operator that can transfer the name can become the controller.

Why Batch Revocation Instead of Revoke-All?

Universal revocation already voids an edge in every scope during path verification. revokeTrustBatch covers the remaining case: withdrawing trust in several named scopes while keeping trust elsewhere, with getTrust reporting None in each of them.

A blanket primitive that wrote None into every scope would have to enumerate them on-chain, or shadow individual records with a revocation epoch that getTrust would no longer report literally. Batch revocation keeps scope enumeration off-chain, where this standard already puts path search. A trustor's client reads its own TrustSet logs, derives the scope list, and submits one transaction. On-chain cost stays proportional to the scopes actually named.

Why Unwrap NameWrapper Names?

ens.owner(node) returns the NameWrapper contract for wrapped names. NameWrapper does not implement ERC-1271, so a registry that used the raw registry owner as the signing authority would reject every attestation from a wrapped name, and wrapped names are a large and growing share of .eth registrations.

Unwrapping via ownerOf(uint256(node)) recovers the actual controller and preserves the ERC-1271 path for controllers that are themselves contracts. It also inherits NameWrapper's expiry behaviour for free: an expired wrapped name returns address(0) and therefore has no signing authority.

Why Pin the NameWrapper?

The nameWrapper slot decides who controls every wrapped name. If it could be changed, whoever changed it could point it at a contract whose ownerOf returns an address of their choosing, and from then on sign attestations, revoke trust, set denials and invalidate nonces for every wrapped name in the registry. Pinning the address at deployment removes that takeover path entirely.

The cost falls on a successor wrapper, and only on names that move to it. Names wrapped in the pinned NameWrapper keep working unchanged. A name wrapped in a successor makes ens.owner(node) return the successor contract. That address is not the pinned wrapper, so the registry treats it as a raw controller. Because it does not implement ERC-1271, that name cannot attest until a registry that recognises the successor is deployed. Unwrapped names are unaffected.

Supporting a successor therefore means deploying a new registry. Trust state does not carry over. Stored relationships live in the old registry, and signatures cannot be replayed against the new one, because verifyingContract is part of the EIP-712 domain. A successor registry can bootstrap its graph by re-attestation: trustors sign fresh attestations against the new domain, which their clients can derive from their own TrustSet logs.

A governance-updatable slot was rejected because it moves the takeover path rather than removing it. Whoever holds the update right, whether an owner key, a multisig or a DAO, gains latent control of every wrapped name, and that is a stronger trust assumption than ENS itself asks for. A redeployment is a visible, opt-in migration. A slot update would be a silent change of authority over names whose controllers never agreed to it.

Backwards Compatibility

This ERC introduces new functionality and does not modify existing standards.

ENS Compatibility: Uses standard ENS interfaces only — owner, resolver, and isApprovedForAll on the registry, addr on the resolver (ERC-137), and ownerOf/isApprovedForAll on the NameWrapper. Works with any deployment that exposes these ENSv1 interfaces, and with deployments that have no NameWrapper. Both wrapped and unwrapped names are supported. ENSv2 is covered under ENSv2 Compatibility and Migration.

On-chain validation does not rely on CCIP-Read. Agents whose names are served by an off-chain resolver can still hold and issue trust, but cannot be validated by address, since addr is not readable on-chain for those names.

ERC-8001 Compatibility: Designed as a module. ERC-8001 coordinators can optionally integrate identity gates, calling validateParticipantAddress once per entry in an intent's participants array. Coordinators call the hook only for coordination types they have gated; for any other type it reverts with GateNotFound. No change to ERC-8001 is required, and coordinators that ignore this standard are unaffected.

Wallet Compatibility: Uses EIP-712 signatures, compatible with all major wallets. Supports ERC-1271 for contract wallets and smart accounts.

Deployments of Earlier Drafts

This revision is not backwards compatible with registries built from earlier drafts, although every earlier function keeps its signature. It adds setDenial, clearDenial, getDenial and DenialSet, which changes the ITrustRegistry identifier from 0x42b46ef5 to 0x94f0ee34. It also changes path verification: a path that verified under an earlier draft fails under this revision when any of its edges is denied, or when its validator denies any node on it. Earlier drafts had no way to express that exclusion, so applications that used withdrawal for it have to re-express it as denial. The record layout also gains a field.

A later draft, identified by 0x31f35e83, had a single setDenial(bytes32,bytes32,bytes32,bool,bytes32) that both set and cleared denials. This revision replaces it with setDenial(bytes32,bytes32,bytes32,bytes32), which only denies, and clearDenial, which checks the trustor's nonce, and adds NonceChanged. Registries built from that draft have the same storage layout and path semantics, and differ only in how a denial is cleared.

Deployed registries cannot adopt this revision in place unless they are upgradeable. An application moving to it SHOULD:

ENSv2 Compatibility and Migration

This ERC specifies trust authority and address resolution using ENSv1 registry interfaces and a NameWrapper address pinned at deployment. It does not specify native ENSv2 controller resolution, role-based signing authority, or address resolution.

Under the published ENSv2 migration process, a migrated name's ENSv1 token is transferred to the ENSv2 Graveyard contract. After migration, controllerOf returns the Graveyard. The Graveyard does not implement ERC-1271, so neither the name's new ENSv2 controller nor any approved operator can manage trust for that name through this ERC. ENSv1 resolver records are normally cleared during migration, although some locked names retain their legacy resolver.

Migration does not remove existing trust relationships from the Trust Registry. The required path verification algorithm evaluates stored relationships and does not require each path node to retain an active ENSv1 controller. Previously established trust paths can therefore remain valid after a name migrates, subject to their trust levels, expiry and gate constraints. Address-based validation still fails where the terminal node's resolver was cleared.

A migrated name can no longer withdraw endorsements it has issued, set denials, or clear them. Migration freezes both its outgoing endorsements and its denials: they stay in storage and keep applying, and the former controller loses the authority to change them. Outgoing endorsements persist until they expire, and those with expiry == 0 persist indefinitely. A migrated gatekeeper therefore continues to admit participants through stored paths, cannot exclude further agents, and cannot readmit agents it has denied. Before migrating, agents SHOULD withdraw endorsements they may later need to withdraw, set or clear denials so that the frozen state is the one they intend, and gatekeepers SHOULD reconfigure dependent gates. Conversely, attestations a migrated name signed but never submitted can no longer be submitted, because its signatures no longer verify.

Unemancipated subnames of a migrated parent can lose their controller and resolver when any party calls the Graveyard's permissionless clear function. Protocols that issue agent subnames SHOULD NOT migrate the parent name while those subnames are in use with this standard.

Coordinators SHOULD monitor migration of gatekeeper, anchor and participant names, and re-evaluate the affected gates. Applications using address-based validation SHOULD account for the loss or retention of legacy resolver records. For high-assurance coordination, time-limited attestations and explicit gate reconfiguration are RECOMMENDED.

New names registered only through ENSv2 are not directly supported by this ERC's ENSv1 interfaces. Supporting ENSv2 requires a separate integration that defines its controller, signing and resolution semantics. A successor Trust Registry may provide such support without changing this ERC's interface. Trust state does not migrate automatically between registries. Existing signatures are bound to their original verifying contract through EIP-712, so establishing trust in a successor registry requires fresh attestations. This is the successor-registry pattern described for a successor wrapper under "Why Pin the NameWrapper?".

Test Cases

A test suite covering the normative requirements of this document is provided in tests/TrustRegistry.t.sol. It exercises:

Attestation and authority

Scope

Withdrawal

Denial

Revocation durability and quarantine

Path verification

ENSv2 migration

Migration is modelled with a mock that holds the name and does not implement isValidSignature, once as the unwrapped registry owner and once as the NameWrapper holder.

Interface detection

Identity gates and ERC-8001 integration

Reference Implementation

File Contents
contracts/ITrustRegistry.sol Canonical types, errors, ITrustRegistry, and the optional ITrustRegistryExtended
contracts/TrustRegistry.sol Reference implementation of the required interface
tests/TrustRegistry.t.sol Test suite covering the normative requirements above

TrustRegistry.sol implements the required ITrustRegistry surface only. The optional extensions are declared in ITrustRegistry.sol but intentionally left unimplemented, since this standard pushes path search to off-chain indexers.

Security Considerations

Sybil Attacks

An attacker can create many ENS names and establish mutual trust between them.

Protocol-level mitigations:

Application-level mitigations:

Trust Graph Manipulation

Attackers may attempt to position themselves in many trust paths.

Mitigations:

Key Compromise

If an ENS name's controller is compromised:

Mitigations:

Two cases need different responses. When a counterparty is compromised, the trustor's own controller is sound, so the trustor denies the counterparty and follows the quarantine procedure under invalidateNonces. When the trustor's own controller is compromised, denial is not sufficient: the attacker holds the same controller and can clear any denial, withdraw endorsements, and sign new attestations. A compromised trustor also cannot undo attestations the attacker has already signed, because the attacker controls the same nonce space. Rotating the controller stops new attestations but does not retract existing ones, nor restore denials the attacker cleared. The complete remedy is denial by counterparties: other agents deny the compromised name, which the attacker cannot clear, since denial is stored under each counterparty's own name.

Neither an attestation nor an invalidateNonces call can raise the nonce floor by more than 2^32. Without a bound, a single call with type(uint64).max, or a single attestation signed with that nonce, would stop the name from ever attesting again. Because the nonce belongs to the name rather than the key, that lockout would pass to every future controller, so an attacker holding a compromised key could make it permanent. 2^32 leaves ample headroom for any real quarantine, and a trustor that has signed further ahead can repeat the call.

Pre-Signed Attestations

An attestation is submittable by anyone holding the signature, at any time, until the trustor's nonce passes it. It carries no signing timestamp, so revoking trust does not retract signatures the trustor issued earlier:

  1. Alice signs an attestation granting bob.eth full trust at nonce 10, and hands it to a relayer that has not yet submitted it
  2. Alice revokes trust in bob.eth
  3. Anyone submits the nonce-10 attestation, restoring full trust

Mitigations:

Note that invalidateNonces is trustor-wide: raising the floor invalidates that trustor's outstanding attestations for every trustee and scope, not only the one being quarantined. This is a deliberate trade-off in favour of failing closed. It reaches only attestations that are signed but not yet submitted: relationships already stored are untouched and continue to verify as before.

ENS Name Transfer

When control of an ENS name changes:

Mitigations:

Gatekeeper and Anchor Names

A gate stores its gatekeeper and its anchors as namehashes. A change of control over one of those names moves its role in the gate to the new controller, and nothing in the gate records the change.

Gatekeeper. A new controller of gatekeeperNode can issue direct attestations from that identity. Where the gate requires no anchor, one qualifying attestation from the gatekeeper to an agent creates a valid direct path, so the new controller can admit any agent it chooses. Gates with a non-empty requiredAnchors reject direct paths, because neither the validator nor the target can satisfy an anchor. Those gates remain bound by their path constraints: the new controller can admit an agent only through a path that traverses an anchor and meets every other parameter. In either case the new controller can also deny, which excludes agents from every path the gate evaluates (see Validator denial), and can clear any denial a previous controller set, readmitting agents the gate had excluded.

Anchor. A new controller of an anchor node inherits every attestation naming the anchor as trustee, and can attest from the anchor to any agent. Paths through the anchor stay valid for as long as the attestations leading to it do. Time-limited inbound attestations bound how long an anchor stays reachable. The anchor configuration itself never expires, and attestations with expiry == 0 remain valid until the trustor replaces or revokes them, so expiry does not bound the exposure in general.

Mitigations:

Monitoring Control Changes

Several provisions of this standard ask agents and coordinators to notice when control of a name changes. No single event reports every such change. How a change appears depends on how the name is held:

After any relevant event, monitors are RECOMMENDED to re-check the name's effective controller (controllerOf), its fuses and its expiry, rather than relying on any single event stream. Expiry emits no event at all.

Resolver Trust

Agent address resolution reads the node's addr record, which the name controller can change at any time. A controller can therefore repoint a trusted name at a different address, and any coordination gated on that name will admit the new address without any trust attestation changing.

Mitigations:

A malicious or buggy resolver can also return arbitrary addresses. Resolvers are chosen by the name controller, so this is contained by the same trust decision: trusting a node means trusting whatever resolver that node points at.

Nodes served by an off-chain resolver (CCIP-Read) cannot be resolved on-chain; resolveAgent returns address(0) and address-identified validation fails closed.

A resolver can also try to disrupt validation rather than misdirect it, by reverting, returning malformed data, or consuming gas. Resolution is gas-capped and never reverts (see Agent Address Resolution), so such a resolver fails closed for its own node and costs the caller at most the cap. Callers that validate many participants in one call SHOULD budget for that cap on each participant.

A controller contract can likewise consume all gas forwarded to isValidSignature. This only affects the party that submits that controller's attestations, which SHOULD simulate a submission before relaying it.

Wrapped Names

For wrapped names the controller is the NameWrapper token holder, recovered via ownerOf(uint256(node)). Two consequences follow:

The NameWrapper address is pinned at deployment (see Name Controller Resolution). A registry pointed at a hostile contract in that slot would let it claim control of every wrapped name.

Expired Unwrapped Names

When an unwrapped .eth second-level name expires, ens.owner(node) keeps returning the lapsed holder until someone re-registers the name and reclaims it in the registry. Until then, controllerOf returns that holder, who can still sign attestations, revoke trust, set or clear denials and invalidate nonces for the name.

The registry cannot detect this. It receives only the namehash, while .eth expiry is recorded by the BaseRegistrar against the labelhash, which cannot be derived from the namehash.

When the name is re-registered, the new registrant inherits every attestation naming it as trustee, and gains trustor authority once it reclaims the name in the registry. This is the transfer exposure described under Identity Continuity, but without any action by the previous holder. The new registration appears as described under Monitoring Control Changes.

Mitigations:

Parent-Controlled Subnames

Protocol-issued subnames such as alice.agents.eth are controlled by their parent as well as their holder. An unwrapped parent, or a wrapped parent whose subname does not have PARENT_CANNOT_CONTROL burned, can reassign the subname at any time. The new controller then inherits every attestation naming the subname as trustee and gains authority over every attestation in which it is the trustor, exactly as on a transfer, but without the holder's consent.

The same applies when such a subname is a gatekeeper or an anchor: the parent can hand its role in every gate that names it to a controller of its choosing. A reassignment emits a registry NewOwner event (see Monitoring Control Changes).

Trustors SHOULD treat such a subname as no more trustworthy than its parent, and SHOULD prefer subnames with PARENT_CANNOT_CONTROL burned. This standard does not inspect fuses, so trustors and gatekeepers check them off-chain. PARENT_CANNOT_CONTROL stops the parent reassigning the subname, but does not stop the holder transferring it; see Name Locking with Fuses.

Name Locking with Fuses

NameWrapper fuses can stop a wrapped name changing hands. PARENT_CANNOT_CONTROL, CANNOT_UNWRAP and CANNOT_TRANSFER together prevent reassignment by the parent and transfer by the holder while they are in effect. CANNOT_UNWRAP must be burned before any other owner-controlled fuse can be. Wrapped .eth second-level names have PARENT_CANNOT_CONTROL burned automatically when wrapped. Fuses end when the wrapped name's registration expires, so expiry still needs monitoring.

Locking has a cost. Under ENS's published migration process, locked names with CANNOT_TRANSFER burned cannot migrate to ENSv2 (see ENSv2 Compatibility and Migration). Locking an identity is therefore a long-term commitment. Applications should weigh that commitment against the protection it gives before locking gatekeeper, anchor or agent names. This standard does not inspect fuses and does not require them.

ENSv2 Migration

A name that migrates to ENSv2 can no longer attest, revoke, set or clear denials, or invalidate nonces through this ERC, while the relationships it issued and received, and the denials it recorded, stay in force. A migrated gatekeeper keeps admitting participants through stored paths, cannot exclude further agents, and cannot readmit agents it denied. Subnames of a migrated parent can lose their controller and resolver. See ENSv2 Compatibility and Migration under Backwards Compatibility.

Identity Gate Namespacing

Gates are keyed by (coordinator, coordinationType). A caller can only affect its own namespace, so no party can squat, overwrite, or remove another's gate.

Consumers therefore have to name the coordinator whose policy they intend to apply. Reading a gate under the wrong coordinator address, or under a mistyped coordination type, finds no enabled gate, and both validation functions revert with GateNotFound rather than returning a verdict. A misconfigured integration fails loudly at its first call instead of silently admitting every participant. See "Why Do Unconfigured Gates Revert?".

Replay Protection

EIP-712 domain binding prevents cross-contract replay. Monotonic nonces prevent replay within the same contract. The chainId in the domain prevents cross-chain replay.

Monotonic nonces stop an attestation from being applied twice, but they do not stop one from being applied late. See Pre-Signed Attestations.

Stale Trust

Trust relationships may become stale if agents don't update them.

Mitigations:

Off-Chain Path Computation

This standard assumes off-chain indexers compute trust paths. Malicious indexers could:

An indexer that follows only consecutive trusted edges also returns paths through nodes the validator has denied, which verifyPath rejects under Validator denial, and paths over denied edges. Indexers therefore have to follow DenialSet as well as TrustSet and TrustRevoked. Indexers SHOULD apply every rule of the path verification algorithm during search, evaluated from the validator's perspective, so that the paths they return verify.

Mitigations:

Copyright

Copyright and related rights waived via CC0.