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.
This standard specifies trust attestation structures, the path verification algorithm, ENS integration semantics, and ERC-8001 coordination hooks.
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:
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".
ENS provides a battle-tested, finalised identity layer:
owner() and isApprovedForAll()alice.agents.eth not 0x742d...)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.
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.
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.
This ERC specifies:
ITrustRegistry interface, discoverable via ERC-165Implementations MUST use the canonical enum:
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 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:
Marginal and Full are reached only by a valid attestation with a higher nonceNone is reached only by revokeTrust or revokeTrustBatchUnknown is the default and cannot be reassigned; a relationship, once created, is
never removed from storagesetTrust 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.
The Trust Registry uses ENS namehashes as agent identifiers.
The registry reads three external contracts:
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:
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.
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):
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.
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.
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.
Trust attestations MUST be signed by an address with signing authority for the ENS name.
Signing authority is limited to:
controllerOf(node), which unwraps NameWrapper names), ORTransaction 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:
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.
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).
Implementations MUST use the following EIP-712 domain:
Implementations SHOULD expose the domain via ERC-5267.
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.
When not specified, implementations SHOULD use:
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.
The signed digest is the EIP-712 typed data hash of _structHash(att)
under the domain above.
Implementations MUST expose the following interface:
The following functions are OPTIONAL. Implementations MAY include them but they are not required for compliance:
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.
setTrustsetTrust MUST revert, in the order given under Revert Order, if:
attestation.trustorNode == attestation.trusteeNode (self-trust prohibited)attestation.level is Unknown or None (distrust is expressed by revocation, not attestation)attestation.expiry != 0 && attestation.expiry <= block.timestamptrustorNode does not exist (ens.owner(trustorNode) is the zero address)attestation.nonce <= getNonce(attestation.trustorNode)attestation.nonce > getNonce(attestation.trustorNode) + 2**32, with the same
overflow-safe bound as invalidateNonces. The error is
NonceJumpTooLarge(attestation.nonce, bound)controllerOf returns the zero address for an expired wrapped nameIf valid:
(trustorNode, trusteeNode, scope)getNonce(trustorNode) MUST return the attestation's nonceTrustSet MUST be emittedsetTrustBatchsetTrustBatch MUST revert, in the order given under Revert Order, if:
attestations.length != signatures.lengthattestations is emptytrustorNode than the first attestationsetTrust validationThe batch-level conditions apply to the whole batch before any attestation is checked individually.
revokeTrustrevokeTrust sets explicit distrust. It is the only way to reach TrustLevel.None.
revokeTrust MUST revert if:
trustorNodeIf valid:
None0, so getTrust returns (None, 0) for every
revoked recordTrustRevoked MUST be emittedA 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.
A revocation at bytes32(0) voids the edge in every scope. A revocation 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).
Revocation does not invalidate attestations the trustor has already signed but not
yet submitted. See revokeTrustBatch and invalidateNonces.
revokeTrustBatchrevokeTrustBatch applies revokeTrust to several scopes for the same
(trustorNode, trusteeNode) pair in a single transaction.
revokeTrustBatch MUST revert if:
trustorNodescopes is emptyIf valid, for each listed scope:
None0TrustRevoked MUST be emittedBecause 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.
invalidateNoncesNonces 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:
trustorNode (approved operators are not
sufficient; see Signature Authority)newNonce <= getNonce(trustorNode)newNonce > getNonce(trustorNode) + 2**32, evaluated without overflow: when that sum
exceeds type(uint64).max, the bound is type(uint64).max. The error is
NonceJumpTooLarge(newNonce, bound)If valid:
getNonce(trustorNode) MUST return newNonceNoncesInvalidated MUST be emittedEvery 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 fully quarantine a compromised or misbehaving counterparty, a trustor SHOULD revoke
it at universal scope and call invalidateNonces with a value above any nonce it has
ever signed. Revocation alone leaves a pre-signed universal attestation 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 AlgorithmverifyPath 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.
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:
"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:
None, the edge is None: universal distrust voids the
edge in every scopeparams.scope (its level is not Unknown), that
record applies, whether or not it has expiredAn expired scoped record therefore never falls back to universal trust: with
enforceExpiry set, the edge fails.
setIdentityGateGates 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:
(msg.sender, coordinationType) and marked enabledIdentityGateSet MUST be emittedremoveIdentityGateremoveIdentityGate MUST revert with GateNotFound if no enabled gate exists at
(msg.sender, coordinationType).
If valid:
IdentityGateRemoved MUST be emittedvalidateParticipantWithPathThis 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.
validateParticipantWithPath MUST return false if:
path.nodes.length < 2path.nodes[0] != gatekeeperNodepath.nodes[path.nodes.length - 1] != participantNodeverifyPath(path, params) returns falseIt 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?".
validateParticipantAddressThis 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.
validateParticipantAddress MUST return false if:
participant == address(0)path.nodes.length < 2path.nodes[0] != gatekeeperNoderesolveAgent(path.nodes[path.nodes.length - 1]) != participantverifyPath(path, params) returns falseIt 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.
Implementations MUST revert with these errors:
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 |
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 |
For TrustRevoked 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 |
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") |
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.
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.
A trustor may have different trust levels for the same trustee in different contexts. For example:
bob.eth fully for DeFi coordinationbob.eth marginally for gamingMaking 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, and universal distrust overrides every scope.
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.
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:
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.
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 pathsminEdgeTrust: Marginal — Accept marginal trust (default)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.
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.
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.
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.
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.
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:
setTrust. Path verification is unaffected, since it reads
stored relationships rather than nonces.ITrustRegistry interface identifier.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.
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.
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.
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.
A trustor that revokes a counterparty as COMPROMISED means it everywhere. If universal
distrust applied only where no scoped record existed, every scoped grant the trustor had
ever made to that counterparty would keep admitting it, and the trustor would have to
find and revoke each one before the revocation took full effect.
Making universal None override every scope gives the trustor one call that holds in
every scope, including scopes it has forgotten and scoped attestations signed earlier
but submitted later. 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 revocation is complete.
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.
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.
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.
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 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.
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. 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.
A test suite covering the normative requirements of this document is provided in
tests/TrustRegistry.t.sol. It exercises:
setTrust rejects self-trust, stale nonces, already-expired attestations, and signatures from any address other than the name controllersetTrust rejects attestations whose level is Unknown or None, and a later attestation restores trust after a revocationisValidSignature, and through ERC-1271 when its delegate validates a non-ECDSA credentialownerOf reverts yields no controller, so the name cannot attestTrustSet carries the attestation's nonce, for single attestations and for each item in a batch(trustor, trustee, scope) records are independentrevokeTrust and revokeTrustBatchrevokeTrustBatch distrusts every listed scope, including scopes that were never granted, leaves unlisted scopes untouched, and emits one TrustRevoked per listed scopeinvalidateNonces closes it: after raising the floor, the pre-signed attestation is rejected and the distrust holdsinvalidateNonces call can raise the floor by more than 2^32; the largest allowed jump succeeds and can be repeated, and near type(uint64).max the bound caps rather than wrappinginvalidateNoncesmaxPathLengthminEdgeTrust rejects marginal edges when Full is requiredenforceExpiry is set and ignored when it is notrequiredAnchors 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 anchorValidationParams outside the stated constraints are rejectedsupportsInterface returns true for IERC165 and ITrustRegistry, and false for ITrustRegistryExtended, 0xffffffff, and unrelated identifierssupportsInterface stays within the 30,000 gas budgetGateNotFound when no gate is enabled, including when the gate is read under the wrong coordinator address and after it has been removedvalidateParticipantWithPath rejects paths that do not start at the gatekeeper or do not terminate at the named participantvalidateParticipantAddress 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| 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.
An attacker can create many ENS names and establish mutual trust between them.
Protocol-level mitigations:
ValidationParams.requiredAnchors forces paths through established community nodesmaxPathLength: 2 requires close proximity to validatorsminEdgeTrust: Full rejects marginal trust pathsApplication-level mitigations:
Attackers may attempt to position themselves in many trust paths.
Mitigations:
minEdgeTrust: Full for high-value coordinationIf an ENS name's controller is compromised:
Mitigations:
TrustSet eventsRevocation 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.
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.
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:
bob.eth full trust at nonce 10, and hands it to
a relayer that has not yet submitted itbob.ethMitigations:
invalidateNonces with a value above every nonce ever signed when quarantining a
counterparty, in addition to revoking it at universal scope. This call requires the
name controller; an approved operator cannot make itexpiry values, which bound how long a stray signature stays usable even
if the nonce floor is never raisedNote 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.
When an ENS name is transferred:
Mitigations:
Transfer eventsAgent 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:
AddrChanged events on nodes that appear in trust pathsA 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.
For wrapped names the controller is the NameWrapper token holder, recovered via
ownerOf(uint256(node)). Two consequences follow:
address(0) and loses signing authority
immediately, without any action by counterpartiesThe 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.
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 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 with no Transfer by
the previous holder to signal it.
Mitigations:
address(0) and loses
authority immediatelyProtocol-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.
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.
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?".
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.
Trust relationships may become stale if agents don't update them.
Mitigations:
enforceExpiry: true in validation parametersexpiry values on attestations (90 days maximum for high-stakes)TrustSet event timestamps off-chainThis standard assumes off-chain indexers compute trust paths. Malicious indexers could:
verifyPath)Mitigations:
Copyright and related rights waived via CC0.