Alert Source Discuss
⚠️ Review Standards Track: ERC

ERC-8107: ENS Trust Registry for Agent Coordination

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

Authors Kwame Bryan (@KBryan)
Created 2025-12-16
Requires EIP-137, EIP-165, EIP-712, EIP-1271, EIP-3668, EIP-5267, EIP-8001

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 through signature chains following the OpenPGP web of trust model described in RFC 4880.

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 sufficient trust paths exist between them.

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 judgment 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 in 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

Why ENS?

ENS provides a battle-tested, finalised identity layer:

  • Stable identifiers that survive key rotation
  • Ownership semantics via owner() and isApprovedForAll()
  • Human readable names (alice.agents.eth not 0x742d...)
  • Subdomain delegation for protocol-issued agent identities

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 (ERC-3668) 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. Short attestation expiries bound 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 and their semantics
  • ENS-indexed trust attestation structures with scope as key
  • EIP-712 typed data for signing attestations
  • ERC-1271 support for contract controllers
  • The ITrustRegistry interface, discoverable via ERC-165
  • Path verification algorithm
  • ERC-8001 integration hooks

Trust Levels

Implementations MUST use the canonical enum:

enum TrustLevel {
   Unknown,   // 0: No trust relationship established
   None,      // 1: Explicitly distrusted
   Marginal,  // 2: Partial trust — multiple required for validation
   Full       // 3: Complete trust — single attestation sufficient
}

Semantic definitions:

Level Meaning Validation Contribution
Unknown Default state; no data about agent Cannot contribute to validation
None Agent known to behave improperly Explicitly excluded; voids trust paths containing this agent
Marginal Agent generally trustworthy Contributes to validation when minEdgeTrust <= Marginal
Full Agent’s judgment equals own verification Always contributes to validation

Level transitions:

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

  • Marginal and Full are reached only by a valid attestation with a higher nonce
  • None is reached only by revokeTrust or revokeTrustBatch
  • Unknown is the default and cannot be reassigned; a relationship, once created, is never removed from storage

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

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):

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

   try IAddrResolver(resolver).addr(node) returns (address payable agent) {
      return agent;
   } catch {
      return address(0);
   }
}

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.

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.

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 in which it is the trustor.

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 Transfer events on ENS names they trust and re-evaluate trust when a trusted name changes hands.

Signature Authority

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

Signing authority is limited to:

  • The ENS name controller (controllerOf(node), which unwraps NameWrapper names), OR
  • For contract controllers: any signer the contract validates via ERC-1271

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
invalidateNonces MAY call MUST NOT no
setIdentityGate, removeIdentityGate keyed by msg.sender; no ENS authority involved    

Approved operators MAY perform relationship revocation because its effect is bounded to one (trustorNode, trusteeNode) pair and the scopes named in the call.

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:

  • Attestations are cryptographically bound to the name controller
  • Routine revocation and transaction submission can be delegated (relayers, operators)
  • Approvals cannot be used to forge signatures
  • Approvals cannot be used to perform trustor-wide invalidation
/// @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); // unwraps NameWrapper names
   if (controller == address(0)) return false;

   // EOA controller
   if (controller.code.length == 0) {
      return ECDSA.recover(digest, signature) == controller;
   }

   // Contract controller - delegate to EIP-1271
   try IERC1271(controller).isValidSignature(digest, signature) returns (bytes4 magic) {
      return magic == IERC1271.isValidSignature.selector;
   } catch {
      return false;
   }
}

/// @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, matching the behaviour of an unregistered name.

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 hashAttestation(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
   ));
}

Interface

Implementations MUST expose the following interface:

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

   /// @notice Emitted when trust is set or updated
   event TrustSet(
      bytes32 indexed trustorNode,
      bytes32 indexed trusteeNode,
      TrustLevel level,
      bytes32 indexed scope,
      uint64 expiry
   );

   /// @notice Emitted when trust is explicitly revoked
   event TrustRevoked(
      bytes32 indexed trustorNode,
      bytes32 indexed trusteeNode,
      bytes32 indexed scope,
      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 ENS owner (EOA) or validate via EIP-1271 (contract)
   /// @param attestation The trust attestation
   /// @param signature EIP-712 signature from trustor's ENS owner
   function setTrust(
      TrustAttestation calldata attestation,
      bytes calldata signature
   ) external;

   /// @notice Batch set multiple trust relationships
   /// @dev All attestations MUST share the same trustorNode
   /// @param attestations Array of trust attestations
   /// @param signatures Corresponding signatures
   function setTrustBatch(
      TrustAttestation[] calldata attestations,
      bytes[] calldata signatures
   ) external;

   /// @notice Revoke trust (sets level to None)
   /// @dev Caller MUST be ENS owner or approved operator
   /// @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 Revoke trust across several scopes in one transaction
   /// @dev Caller MUST be name controller or approved operator. Scopes are supplied
   ///      by the caller; this standard does not enumerate them on-chain.
   /// @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 Invalidate every outstanding attestation below a nonce
   /// @dev Caller MUST be name controller or approved operator. 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
   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 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 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:

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 0x42b46ef5

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 fourteen 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.

Semantics

setTrust

setTrust MUST revert if:

  • attestation.trustorNode == attestation.trusteeNode (self-trust prohibited)
  • attestation.level is Unknown or None (distrust is expressed by revocation, not attestation)
  • attestation.nonce <= getNonce(attestation.trustorNode)
  • attestation.expiry != 0 && attestation.expiry <= block.timestamp
  • The signature does not verify per the Signature Authority section
  • The ENS name for trustorNode does not exist (owner is zero address)

If valid:

  • The trust record MUST be stored, keyed by (trustorNode, trusteeNode, scope)
  • getNonce(trustorNode) MUST return the attestation’s nonce
  • TrustSet MUST be emitted

setTrustBatch

setTrustBatch MUST revert if:

  • attestations.length != signatures.length
  • Any attestation has a different trustorNode than the first attestation
  • Any individual attestation would fail setTrust validation

Nonces within the batch MUST be strictly increasing.

revokeTrust

revokeTrust sets explicit distrust. It is the only way to reach TrustLevel.None.

revokeTrust MUST revert if:

  • Caller is not the name controller or an approved operator for trustorNode

If valid:

  • Trust level MUST be set to None
  • TrustRevoked MUST be emitted
  • The relationship MUST remain in storage (not deleted) to preserve the explicit distrust

A prior trust relationship is not required. A trustor MAY distrust an agent it never trusted, which blocks paths that would otherwise route through that edge. Callers that want to distinguish withdrawing trust from preemptive distrust MUST read getTrust first; the registry does not make that distinction.

Revocation affects exactly one scope. It does not cascade to other scopes, and it 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:

  • Caller is not the name controller or an approved operator for trustorNode
  • scopes is empty

If valid, for each listed scope:

  • Trust level MUST be set to None
  • TrustRevoked MUST be emitted
  • The relationship MUST remain in storage

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.

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:

  • Caller is not the name controller for trustorNode (approved operators are not sufficient; see Signature Authority)
  • newNonce <= getNonce(trustorNode)

If valid:

  • getNonce(trustorNode) MUST return newNonce
  • NoncesInvalidated MUST be emitted

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.

To fully quarantine a compromised or misbehaving counterparty, a trustor SHOULD call revokeTrustBatch for the affected scopes and invalidateNonces with a value above any nonce it has ever signed. Revocation alone leaves pre-signed attestations able to restore trust.

An approved operator can perform the first step but not the second, so a delegated quarantine requires the controller to complete it.

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; they MUST NOT accept parameters from any other source.

Algorithm:

function verifyPath(
   TrustPath calldata path,
   ValidationParams calldata params
) external view returns (bool valid) {
   // Parameters are validated BEFORE they are used. Skipping this would let
   // minEdgeTrust == Unknown make every edge comparison vacuously pass.
   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;
      }
   }

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

   // Verify each edge
   for (uint256 i = 0; i < path.nodes.length - 1; i++) {
      // Try scoped trust first, fall back to universal
      (TrustLevel level, uint64 expiry) = getTrust(
         path.nodes[i],
         path.nodes[i + 1],
         params.scope
      );

      // Fall back to universal scope if scoped trust not found
      if (level == TrustLevel.Unknown && params.scope != bytes32(0)) {
         (level, expiry) = getTrust(
            path.nodes[i],
            path.nodes[i + 1],
            bytes32(0)
         );
      }

      // 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.

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:

When validating an edge, implementations MUST:

  1. First check for trust at the specified params.scope
  2. If not found and params.scope != bytes32(0), check for trust at universal scope bytes32(0)
  3. Universal trust applies to all scopes

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:

  • The gate MUST be stored at (msg.sender, coordinationType) and marked enabled
  • IdentityGateSet MUST be emitted

removeIdentityGate

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

If valid:

  • The gate MUST be disabled
  • IdentityGateRemoved MUST be emitted

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);

   if (!enabled) return true; // No gate = open participation

   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);
}

validateParticipantWithPath MUST return false if:

  • path.nodes.length < 2
  • path.nodes[0] != gatekeeperNode
  • path.nodes[path.nodes.length - 1] != participantNode
  • verifyPath(path, params) returns false

It MUST return true when no gate is enabled at (coordinator, coordinationType). Integrators that require an explicit gate MUST check getIdentityGate for enabled before relying on this function, since an unconfigured coordination type is open by default.

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);

   if (!enabled) return true; // No gate = open participation

   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);
}

validateParticipantAddress MUST return false if:

  • participant == address(0)
  • path.nodes.length < 2
  • path.nodes[0] != gatekeeperNode
  • resolveAgent(path.nodes[path.nodes.length - 1]) != participant
  • verifyPath(path, params) returns false

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 BatchTrustorMismatch();
error BatchNonceNotIncreasing();
error EmptyScopeList();

For TrustRevoked events, the following reason codes are RECOMMENDED:

Reason Code Value Meaning
Unspecified bytes32(0) No specific reason
Misbehavior 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

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

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:

  • Add dependency on draft standards
  • Fragment the identity ecosystem
  • Require new adoption efforts

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

Why Scope as Storage Key?

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

  • Trust bob.eth fully for DeFi coordination
  • Trust bob.eth marginally for gaming

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.

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:

  • Reduces parameter complexity
  • Makes path verification straightforward
  • Leaves accumulation semantics to optional extensions

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:

  • Cryptographic integrity of attestations
  • Operational flexibility for name management
  • Clear security boundaries

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

  • Branching factor can explode with user-controlled adjacency lists
  • Gas costs are unpredictable
  • Attackers can bloat trustee lists

By requiring pre-computed paths, this standard:

  • Keeps on-chain verification O(path length)
  • Pushes search complexity to off-chain indexers where it belongs
  • Enables predictable gas costs

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:

  • minEdgeTrust: Full — Only fully trusted paths
  • minEdgeTrust: Marginal — Accept marginal trust (default)

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 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 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. Per-(trustor, trustee, scope) nonces or an unordered nonce bitmap would both avoid that.

They would also make invalidateNonces impossible. Bulk invalidation needs a single ordered value to raise; with unordered nonces there is no floor, and a trustor responding to a compromise would have to enumerate and burn every outstanding nonce individually — which requires knowing them, which is exactly what a compromised agent does not know.

The serialisation cost is therefore the price of being able to invalidate outstanding attestations at all. 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 Distrust?

Earlier drafts allowed setTrust to carry TrustLevel.None, which meant distrust 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 all distrust 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 preemptively distrust an agent it never trusted — a genuine web of trust need that the earlier asymmetry made impossible to express.

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, which is why the Semantics section states the combined procedure and Security Considerations repeats it.

Why Is invalidateNonces Controller-Only?

Every other delegable operation in this standard is bounded. A revocation 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 carrying out a quarantine can revoke the relationships but cannot complete the nonce invalidation, so the controller must sign that step. That is the correct place to require the controller.

Why Batch Revocation Instead of Revoke-All?

A blanket “revoke every scope” primitive would have to shadow individual records with a revocation epoch, which means an extra storage read on every edge in verifyPath and a getTrust that no longer returns what is stored.

Batch revocation keeps scope enumeration off-chain, where this standard already puts path search for the same reason. 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 granted, and getTrust stays literal.

The residual risk is a trustor that revokes an incomplete list. This is an indexing problem with an off-chain answer, not a reason to make every path verification pay for a blanket flag.

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.

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 ENS deployment, and with deployments that have no NameWrapper. Both wrapped and unwrapped names are supported.

On-chain validation does not rely on CCIP-Read (ERC-3668). 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. 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.

Test Cases

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

Attestation and authority

  • setTrust rejects self-trust, stale nonces, already-expired attestations, and signatures from any address other than the name controller
  • setTrust rejects attestations whose level is Unknown or None, and a later attestation restores trust after a revocation
  • Any address can submit a valid signature, so relayed attestations succeed
  • An ENS operator approval does not confer signing authority
  • Contract controllers are accepted through ERC-1271, and reject signers they do not recognise
  • Wrapped names attest through the unwrapped NameWrapper holder, an expired wrapped name has no authority, and approvals for a wrapped name are read from the NameWrapper rather than the registry
  • Domain separation prevents an attestation from replaying onto a second registry

Scope

  • (trustor, trustee, scope) records are independent
  • Path verification prefers scoped trust and falls back to universal only when no scoped record exists
  • Revocation applies to one scope and leaves other scopes intact
  • Revocation requires no prior trust, and preemptive distrust in a scope blocks the universal fallback for that scope
  • revokeTrustBatch distrusts every listed scope, including scopes that were never granted, leaves unlisted scopes untouched, and emits one TrustRevoked per listed scope

Revocation durability

  • An attestation signed before a revocation remains submittable and restores trust, which is the hazard described under Pre-Signed Attestations
  • invalidateNonces closes it: after raising the floor, the pre-signed attestation is rejected and the distrust holds
  • The floor is trustor-wide, applying to every trustee and scope, and still permits fresh attestations above it

Path verification

  • Direct and transitive paths, missing edges, paths shorter than two nodes, and paths exceeding maxPathLength
  • minEdgeTrust rejects marginal edges when Full is required
  • Expiry is enforced when enforceExpiry is set and ignored when it is not
  • A revoked edge voids the path
  • Paths containing a repeated node are rejected, even when every individual edge verifies
  • requiredAnchors is part of the verdict: a path whose edges all verify but which traverses no anchor is invalid, and neither the validator nor the target can satisfy an anchor
  • ValidationParams outside the stated constraints are rejected

Interface detection

  • supportsInterface returns true for IERC165 and ITrustRegistry, and false for ITrustRegistryExtended, 0xffffffff, and unrelated identifiers
  • The identifiers published in Interface Detection match Solidity’s computed values, guarding the spec’s literals against interface drift
  • supportsInterface stays within the 30,000 gas budget

Identity gates and ERC-8001 integration

  • Gates are namespaced per coordinator: two coordinators registering the same coordination type hold independent gates, and neither can modify or remove the other’s
  • Registering a gate does not require controlling the named gatekeeper
  • An unconfigured gate is open, including when read under the wrong coordinator address
  • validateParticipantWithPath rejects paths that do not start at the gatekeeper or do not terminate at the named participant
  • validateParticipantAddress rejects a participant address the terminal node does not resolve to, the zero address, nodes with no addr record, and nodes behind an off-chain resolver

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:

  • Required anchors: ValidationParams.requiredAnchors forces paths through established community nodes
  • Short path limits: maxPathLength: 2 requires close proximity to validators
  • High trust requirement: minEdgeTrust: Full rejects marginal trust paths

Application-level mitigations:

  • Weight trust by ENS name age or registration cost
  • Implement additional stake requirements
  • Monitor trust graphs for anomalous patterns off-chain

Trust Graph Manipulation

Attackers may attempt to position themselves in many trust paths.

Mitigations:

  • Monitor trust graphs for anomalous patterns off-chain
  • Use minEdgeTrust: Full for high-value coordination
  • Require multiple independent paths via the optional extensions

Key Compromise

If an ENS name’s controller is compromised:

Mitigations:

  • Monitor for unexpected trust changes via TrustSet events
  • Use short expiries (90 days maximum recommended for high-stakes)
  • ENS name controllers can be rotated, and a wrapped name’s controller can be recovered through the NameWrapper
  • Affected agents can call revokeTrustBatch for every scope in which they trusted the compromised node

Revocation by counterparties is the only complete remedy: a compromised trustor 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.

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:

  • Issue nonces sequentially so the highest outstanding nonce is always known
  • Call invalidateNonces with a value above every nonce ever signed when quarantining a counterparty, in addition to revokeTrustBatch. This call requires the name controller; an approved operator cannot make it
  • Prefer short expiry values, which bound how long a stray signature stays usable even if the nonce floor is never raised

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.

ENS Name Transfer

When an ENS name is transferred:

  • New owner inherits trust where they are the trustee
  • New owner can manage trust where they are the trustor
  • Old attestations signed by old owner remain valid until expiry

Mitigations:

  • Use short expiries for high-stakes trust
  • Monitor ENS Transfer events
  • Re-evaluate trust after transfers

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:

  • Use short expiries so trust must be re-attested after a repoint
  • Monitor AddrChanged events on nodes that appear in trust paths
  • Treat a name’s address record as exactly as trustworthy as the name itself

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.

Wrapped Names

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

  • An expired wrapped name resolves to address(0) and loses signing authority immediately, without any action by counterparties
  • NameWrapper fuses can make a name’s control non-transferable or burn its ability to change the resolver, which agents can treat as a positive trust signal, but this standard does not inspect fuses

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.

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 silently yields an unconfigured gate, and an unconfigured gate is open: validation returns true. Integrators that require an explicit gate need to check getIdentityGate for enabled rather than relying on the validation result alone.

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:

  • Use enforceExpiry: true in validation parameters
  • Set reasonable expiry values on attestations (90 days maximum for high-stakes)
  • Monitor TrustSet event timestamps off-chain

Off-Chain Path Computation

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

  • Return suboptimal paths
  • Omit valid paths
  • Return invalid paths (caught by verifyPath)

Mitigations:

  • Users can run their own indexers
  • Multiple independent indexers provide redundancy
  • Invalid paths are always rejected on-chain

Copyright and related rights waived via CC0.

Citation

Please cite this document as:

Kwame Bryan (@KBryan), "ERC-8107: ENS Trust Registry for Agent Coordination [REVIEW]," Ethereum Improvement Proposals, no. 8107, December 2025. Available: https://eips.ethereum.org/EIPS/eip-8107.