Abstract
For years, most smart contract interactions on Ethereum forced users to perform the so-called "Blind Signing", meaning they signed the raw hex data for their transactions without any hope of understanding this raw data. ERC-7730 solves this issue by introducing a human-readable calldata formatting mechanism. However, the ERC-7730 does not specify the mechanism by which the formatting descriptor files are distributed to their consumers.
This ERC defines an on-chain protocol-agnostic registry of clear signing formatting descriptors. The registry maintains a mapping of structured context key IDs, derived from ERC-7730 context binding information, to ERC-7730 descriptor files and their attestation signatures.
Descriptor files and attestations form a large data bundle and are not written on-chain. Instead, they are served via URIs that may reference any content-addressed scheme (ipfs://, ar://, bzz://, magnet:, etc.) and serve as a suggested retrieval mechanism for the wallet.
All descriptors are accompanied by a standard ERC-8176 EAS off-chain attestation and can carry further attestations in vendor-specific formats. The registry itself creates no attestations and depends on no external attestation protocol, leaving attestation production and verification entirely to attesters and wallets.
Motivation
ERC-7730 defines a JSON descriptor format that enriches smart contract calls and EIP-712 messages with human-readable display metadata. Each descriptor carries a context section that binds it to specific contracts or EIP-712 domains and serves as a key for the descriptor lookup when displaying the actual signature requests to the users.
The maintenance and distribution of descriptors' registry is an important component of the Clear Signing solution and would greatly benefit from being standardized across wallets without compromising on security or decentralization.
-
On-chain context-keyed discovery. Wallets can trustlessly find which descriptor applies to a given contract interaction or signature request without querying any off-chain repository service. This improves the reliability and accessibility of the Clear Signing descriptor discovery process.
-
Interoperable attestations. A shared registry of standard ERC-8176 attestations allows any wallet to consume other attesters' work, while vendors with special needs, such as device-native encodings or alternative signature schemes, supply them in-place without splitting the ecosystem.
-
Storage and protocol-agnostic descriptor resolution. Attesters are free to serve the descriptors via IPFS, Swarm, Git, HTTP, BitTorrent, or any other content-addressed scheme. This removes any bias towards any currently dominating protocol, which might become obsolete during the lifetime of the Clear Signing ERC, which is supposed to be indefinite.
-
Revocation and upgrade path. An on-chain mechanism for an attester to replace their active attestation with either a newer descriptor version or a revocation message, without waiting on any independent-review-based central registry. This removes the bottleneck on the speed of issuing a revocation.
-
Coordination base layer for vendors to extend. Vendors may build their software and hardware products utilizing off-chain services built on top of the on-chain registry, while the on-chain registry serves as a common interface for all wallets, a coordination layer for the attestation protocol, and a baseline for the emergent multi-attester economic system.
Specification
Definitions
| Term | Definition |
|---|---|
| descriptor | An ERC-7730 JSON file as defined in ERC-7730 |
| descriptorHash | The ERC-8176 descriptor hash: keccak256 of the fully resolved canonicalized descriptor |
| contextKeyId | A bytes32 value canonically derived from an ERC-7730 context binding, used as the registry lookup key |
| contextTypeTag | A bytes32 equal to keccak256("erc7730.context.<typename>"), namespacing a context key ID derivation rule |
| attestationFormatId | A bytes32 equal to keccak256("erc7730.attestation.<format>"), namespacing the shape of an off-chain attestation artifact |
| attestation set | All attestations registered for a descriptor in one DescriptorInfo — at most one per format ID — active together and identified by a single attestationSetId |
| attestationSetId | The identifier of an attestation set: the sole member's attestationId for a single-attestation set, keccak256(abi.encode(descriptorHash, descriptorSchemaMajor, attestationIds)) otherwise |
| descriptorSchemaMajor | The MAJOR version of the ERC-7730 descriptor schema a descriptor's $schema key declares, used as an opaque non-zero uint256 component of the active record key |
| active record | The single attestationSetId an attester currently has active for a given (contextKeyId, descriptorSchemaMajor) key, together with the descriptor hash it attests to |
| attester | An address on whose behalf an attestation has been registered |
| attestationId | The attester-chosen identifier of one attestation: a standard EAS off-chain attestation UID for the ERC-8176 baseline format; an opaque identifier for vendor formats |
| MirrorList | A string[] of retrieval URIs for a descriptor file or a signed attestation blob, stored once globally and referenced by its content hash |
| mirrorListId | keccak256(abi.encode(uris)) — the content hash identifying a MirrorList |
Descriptor hashes are computed exactly as defined in ERC-8176 "Descriptor Hash Computation".
Current Tag Constants
CONTEXT_TAG_CONTRACT = keccak256("erc7730.context.contract")
CONTEXT_TAG_EIP712_DEP = keccak256("erc7730.context.eip712.deployment")
CONTEXT_TAG_EIP712_DS = keccak256("erc7730.context.eip712.domainseparator")
CONTEXT_TAG_FACTORY = keccak256("erc7730.context.factory")Current Attestation Format Tags
ATTESTATION_FORMAT_EAS_OFFCHAIN = keccak256("erc7730.attestation.eas.offchain")
ATTESTATION_FORMAT_ML_DSA = keccak256("erc7730.attestation.pq.mldsa")Attestation format tags follow the same namespacing scheme as context tags, declaring the shape of an off-chain attestation artifact.
They are carried as the opaque attestationFormatId member of each AttestationIdentifier.
ATTESTATION_FORMAT_EAS_OFFCHAIN is the ERC-8176 EAS off-chain attestation format every attestation set SHOULD contain.
ATTESTATION_FORMAT_ML_DSA names one example vendor format — a post-quantum ML-DSA signed attestation.
It is included to show the tag namespace is purely declarative and the registry never reads format tags — they only serve as a namespace for the wallets that verify attestations.
Context Key ID Derivation
The Context Key ID keys are computed as:
contextKeyId = keccak256(abi.encode(contextTypeTag, param1, param2, …))The contextTypeTag prefix namespaces keys so that different context binding types cannot collide regardless of parameter values.
This scheme is forward-extensible: future additions of new context binding types in ERC-7730 define new tag strings and parameter lists. The registry contract requires no upgrade when a new context tag is introduced.
A descriptor covering N deployments MUST be registered under N context key IDs — one per deployment entry.
abi.encode is type-sensitive, so the parameter types below are normative: every parameter occupies a full 32-byte word with Solidity's standard padding.
| ERC-7730 context type | params in abi.encode |
|---|---|
context.contract.deployments | uint256 chainId, address contract |
context.contract.factory | uint256 chainId, address factoryAddress, bytes32 deployEventTopic |
context.eip712.deployments | uint256 chainId, address verifyingContract |
context.eip712.domainSeparator | bytes32 domainSeparator |
Schema major versioning
ERC-7730 versions its descriptor schema. The descriptor's schema declares the exact version it is authored against, and a MAJOR version bump signals a breaking schema change that older wallet implementations cannot process.
Each registered descriptor declares the MAJOR version of its $schema as DescriptorInfo.descriptorSchemaMajor. The registry keeps one active record per (contextKeyId, descriptorSchemaMajor) key, so descriptors of different schema MAJORs never displace each other. During a breaking-version migration, attesters register the new MAJOR descriptors alongside the old ones, and wallets on old firmware keep resolving the old version.
Only the MAJOR version is a registry concept. MINOR and PATCH releases are backward compatible by definition, so updating to one is an ordinary revoke-and-replace of the same active record, and older wallets keep processing the result.
The registry never interprets descriptorSchemaMajor — it is an opaque key component, exactly like a context key ID.
A wallet passes the schema MAJORs it implements to resolveDescriptors. When the same (attester, contextKeyId) resolves under several implemented MAJORs, the wallet SHOULD prefer the highest one.
Data Model
The registry maintains the following mappings with a known storage layout:
// The attestation set ID currently active for the given attester, context key ID and
// schema MAJOR. Atomically replaced on each registration call; records of
// different schema MAJORs never displace each other.
mapping(address attester => mapping(bytes32 contextKeyId => mapping(uint256 descriptorSchemaMajor => bytes32))) activeAttestationSetIds;
// Write-once metadata of an attestation set.
struct AttestationSetDetails {
bytes32 descriptorHash; // the attested descriptor hash
uint256 descriptorSchemaMajor; // the declared schema MAJOR; opaque to the registry
}
mapping(address attester => mapping(bytes32 attestationSetId => AttestationSetDetails)) attestationSetDetails;
// The members of an attestation set — attestation IDs paired with their format IDs —
// stored once together with the set details so views can return and format-filter them.
mapping(address attester => mapping(bytes32 attestationSetId => AttestationIdentifier[])) attestationSetContents;
// The timestamp at which 'attester' revoked the given ID — an attestation set ID or
// an individual attestation ID; both live in one namespace — or 0 if never revoked.
mapping(address attester => mapping(bytes32 attestationId => uint64)) revokedAt;
// Per-attester pointer to the MirrorList for a given descriptor.
mapping(address attester => mapping(bytes32 descriptorHash => bytes32)) descriptorMirrorListIds;
// Per-attester pointer to the MirrorList for a given attestation set.
mapping(address attester => mapping(bytes32 attestationSetId => bytes32)) attestationMirrorListIds;
// Global store of MirrorLists, written once per unique URI set.
// A "mirror list" is an array of URIs for fetching the descriptor file or an
// off-chain attestation blob, supplied by the given attester.
// Key = keccak256(abi.encode(uris)).
mapping(bytes32 mirrorListId => string[]) mirrorLists;
// EIP-712 nonce shared by all relayed calls (registration batches, revocation batches,
// MirrorList updates and profile updates).
mapping(address attester => uint256) nonces;
// Self-declared profile document URI per attester ("business card").
// Display-only metadata, never trust input; empty when unset.
mapping(address attester => string) attesterProfileURIs;Interface
The full normative interface is provided in IClearSigningRegistry.sol.
interface IClearSigningRegistry {
function createAttestations(address attester, DescriptorInfo[] calldata descriptors, bytes32 descriptorMirrorListId, bytes32 attestationMirrorListId, bytes calldata signature) external;
function publishMirrorLists(string[][] calldata uriLists) external;
function revokeAttestations(address attester, RevocationEntry[] calldata revocations, bytes calldata signature) external;
function getRevocationTimestamp(address attester, bytes32 attestationId) external view returns (uint64 timestamp);
function resolveDescriptors(address[] calldata attesters, bytes32[] calldata contextKeyIds, uint256[] calldata descriptorSchemaMajors, bytes32[] calldata attestationFormatIds, string[] calldata allowedPrefixes) external view returns (ResolvedDescriptor[] memory resolved);
function getMirrorListById(bytes32 mirrorListId, string[] calldata allowedPrefixes) external view returns (string[] memory uris);
function getNonce(address attester) external view returns (uint256 nonce);
function invalidateNonce() external;
function updateDescriptorMirrorList(address attester, bytes32[] calldata descriptorHashes, bytes32 descriptorMirrorListId, bytes calldata signature) external;
function updateAttestationMirrorList(address attester, bytes32[] calldata attestationSetIds, bytes32 attestationMirrorListId, bytes calldata signature) external;
function setAttesterProfileURI(address attester, string calldata profileURI, bytes calldata signature) external;
function getAttesterProfileURI(address attester) external view returns (string memory profileURI);
}createAttestations
The registry's write function. It registers a batch of descriptors backed by attester-signed attestations in a single transaction.
Replacing an active attestation set requires a prior, separate revokeAttestations call for the displaced set.
The attester produces the signed attestation artifacts locally, stores them wherever it chooses, and hands the registry the pre-computed attestation identifiers together with an attestation MirrorList — the same kind of retrieval-URI list used for descriptors, pointed at signed attestation files.
All attestations of a descriptor are declared in one attestationIds array of AttestationIdentifier entries — an attester-chosen attestationId plus a namespaced attestationFormatId, at most one entry per format ID — and form one attestation set whose members are active together. Every set SHOULD contain a standard EIP-712-signed ERC-8176 EAS off-chain attestation (ATTESTATION_FORMAT_EAS_OFFCHAIN): the compatibility baseline every wallet can verify. The registry does not enforce this — it is fully format-agnostic.
The registry derives an attestation set ID per descriptor:
- a single-attestation set uses the sole member's own
attestationId, so in the common single-EAS case the set ID is the ERC-8176 attestation UID itself; - a larger set uses
keccak256(abi.encode(descriptorHash, descriptorSchemaMajor, attestationIds))— a content hash committing to the descriptor identity and the full member list.
The set ID is the value stored in the active record for each (contextKeyId, descriptorSchemaMajor) key. It also keys the set's entry in the attestation MirrorList's index file (see "MirrorList entry resolution").
Registering a descriptor whose derived set ID is already registered re-activates the existing set for the given context key IDs, provided the stored record matches the incoming descriptor exactly. This is how an attester makes an existing descriptor discoverable under more context key IDs. A revoked set ID can never be registered again.
Both the batch's shared descriptor MirrorList (descriptorMirrorListId) and its shared attestation MirrorList (attestationMirrorListId) are given as a plain bytes32 — the content hash returned by an earlier publishMirrorLists call. Both MUST already be published; the call reverts with UnknownMirrorList otherwise.
function createAttestations(
address attester,
DescriptorInfo[] calldata descriptors,
bytes32 descriptorMirrorListId,
bytes32 attestationMirrorListId,
bytes calldata signature
) external;The registry does not validate any attestation's signature or content.
A revoked ID — set or member — is consumed forever, and a replacement attestation for the exact same descriptor MUST carry a fresh attestation ID.
When the attester already has a different active set for any of the supplied (contextKeyId, descriptorSchemaMajor) keys, the displaced set ID MUST already be recorded as revoked — via a prior, separate revokeAttestations call — or the call reverts with MissingRevocation.
A caller that wants both revocation and attestation steps to land on-chain atomically MUST batch them itself in a single transaction.
A record already pointing at the set being registered is left untouched, so a re-activation batch may list existing context key IDs alongside new ones.
Wallets MUST verify the fetched attestation per ERC-8176 "Verifying an Attestation" — schema, descriptor hash match, and revocation status — before trusting an active attestation record.
Registration authorization
Unless msg.sender == attester, the registry requires signature: an EIP-712 signature by the attester over the structure
ClearSigningRegistrationBatch(
DescriptorInfo[] descriptors,
bytes32 descriptorMirrorListId,
bytes32 attestationMirrorListId,
uint256 nonce
)
AttestationIdentifier(
bytes32 attestationId,
bytes32 attestationFormatId
)
DescriptorInfo(
bytes32 descriptorHash,
uint256 descriptorSchemaMajor,
bytes32[] contextKeyIds,
AttestationIdentifier[] attestationIds
)The signature is verified via ECDSA recovery for EOA attesters and via ERC-1271 isValidSignature for smart contract controlled attesters.
revokeAttestations(address attester, RevocationEntry[] revocations, bytes signature)
Revokes every entry's ID under attester and clears the entry's contextKeyIds wherever they still point to it, combining revocation and cleanup into a single transaction. The affected schema MAJOR is read from the set's stored metadata. An attester can revoke attestations directly, independent of any registration batch.
An entry's contextKeyIds may be empty for a revoke-only entry that skips clearing.
An entry names either an attestation set ID — withdrawing the whole release and clearing its active records — or an individual attestation ID, which records the member's revocation timestamp. In the latter case the clearing is a natural no-op, since active records only ever hold set IDs (see "Attestation revocation semantics" in Security Considerations).
A context key ID whose active set has since moved on to a different attestation set ID (raced by an intervening registration) is silently skipped rather than reverting the whole call.
Unless msg.sender == attester, the registry requires a valid EIP-712 signature by the attester over
ClearSigningRevocationBatch(
RevocationEntry[] revocations,
uint256 nonce
)
RevocationEntry(
bytes32 attestationId,
bytes32[] contextKeyIds
)Emergency revocation therefore never requires the attester to hold ETH or to attach a descriptor registration: a single signature handed to any relayer revokes a batch of attestations and clears their context key IDs.
invalidateNonce()
Consumes the caller's current EIP-712 nonce without executing anything, cancelling any outstanding signature over that nonce. Since self-submitted calls do not consume nonces, this is the mechanism for an attester to withdraw a signed but not yet relayed batch they no longer want executed.
getRevocationTimestamp(address attester, bytes32 attestationId) returns (uint64)
Returns the timestamp at which attester revoked attestationId, or 0 if never revoked.
Revoking an already-revoked ID keeps the original timestamp.
resolveDescriptors()
The primary wallet-facing query entry point: resolves all active attestation sets for the given attesters across the given context key IDs and schema MAJORs in a single call, optionally filtering the returned set members by format ID.
For every active (attester, contextKeyId, descriptorSchemaMajor) record the returned ResolvedDescriptor structure carries all the necessary data: the context key ID, the descriptor hash, the schema MAJOR it was found under, the attestationSetId, the descriptor MirrorList contents, the attestation MirrorList contents, and one ResolvedAttestation per set member matching the attestationFormatIds filter — each with its attester, attestation ID, format ID and revocation timestamp.
attestationFormatIds narrows the returned members to formats the caller can verify; an empty array returns every member. A resolved descriptor is returned even if every one of its attestations is filtered out. attesters, contextKeyIds and descriptorSchemaMajors are lookup keys, not filters — an empty array yields no results.
A vendor backend's lookup is therefore a single view call: passing the vendor's own format tag in attestationFormatIds returns exactly its device-native rendition of every resolved descriptor.
Results are ordered by attesters first, then contextKeyIds, then descriptorSchemaMajors.
Keys with no active record produce no entry.
URI prefix filtering
Both resolveDescriptors and getMirrorListById take an allowedPrefixes parameter: a list of raw string prefixes, e.g. ["ipfs:", "https:"].
A URI is returned only if it starts with at least one of the prefixes; an empty array means "no filter, return everything".
Note: the prefixes are plain string prefixes. A caller can equally pass "data:application/json;base64," to ask only for base64 inline JSON, or a scheme plus content-hash prefix to match a specific document. The contract has no notion of "scheme", it only performs prefix comparison.
An active attestation set is included in resolved based on whether it exists, not on whether any of its URIs survive the filter, so a caller still sees descriptorHash and attestationSetId for it even if none of its mirrors matched the requested prefixes.
publishMirrorLists(string[][] uriLists)
Stores a batch of retrieval URI lists on-chain, each under its content hash keccak256(abi.encode(uris)). The IDs are content-derived, so callers compute them locally rather than reading them back.
A first publication emits MirrorListPublished carrying the full URI list.
A MirrorList whose content has already been published is not re-written and emits no event, and identical URI sets occupy storage exactly once regardless of how many descriptors or attesters reference them.
Any address may publish MirrorLists, and publication may be done independently of attestation: a pinning service, mirror operator, or any other party can maintain the published lists that attesters may reference by ID.
getMirrorListById(bytes32 mirrorListId, string[] allowedPrefixes)
Returns the URI list for a given MirrorList ID, filtered by allowedPrefixes (see "URI prefix filtering" above; an empty array returns the full list). Returns an empty array if the ID is unknown.
updateDescriptorMirrorList() and updateAttestationMirrorList()
Allows an attester to update the MirrorList for existing descriptors or attestation sets without requiring a new attestation or registration batch.
function updateDescriptorMirrorList(
address attester,
bytes32[] calldata descriptorHashes,
bytes32 descriptorMirrorListId,
bytes calldata signature
) external;
function updateAttestationMirrorList(
address attester,
bytes32[] calldata attestationSetIds,
bytes32 attestationMirrorListId,
bytes calldata signature
) external;The target MirrorList id MUST already be published via publishMirrorLists and the call reverts with UnknownMirrorList otherwise.
The registry rejects any key the attester never registered — the update functions rotate existing pointers and never create orphaned ones.
The EIP-712 signature (if msg.sender != attester) covers:
DescriptorMirrorListUpdate(
bytes32[] descriptorHashes,
bytes32 descriptorMirrorListId,
uint256 nonce
)
AttestationMirrorListUpdate(
bytes32[] attestationSetIds,
bytes32 attestationMirrorListId,
uint256 nonce
)setAttesterProfileURI(address attester, string profileURI, bytes signature) and getAttesterProfileURI(address attester)
An attester MAY publish a profile — a "business card" — as a single URI pointing at a self-hosted JSON document. The registry stores only the pointer: the contract stays schema-free, and profile edits never require re-attestation.
An empty profileURI clears the profile. Updates emit AttesterProfileUpdated and are relayable like every other attester action — unless msg.sender == attester, the registry requires a valid EIP-712 signature by the attester over
AttesterProfileUpdate(
string profileURI,
uint256 nonce
)The profile document is a JSON object. Version 1, defined by this ERC, has three REQUIRED members; a consumer MUST treat a document missing any of them as invalid and render nothing from it:
version— the integer1. Consumers MUST NOT attribute meaning to a document declaring a version they do not implement.attesters— an array of0x-prefixed hexadecimal attester addresses this profile covers, compared case-insensitively. Before attributing a profile to an attester, a consumer MUST verify that the attester's address appears in this array: without this back-reference, any address could point its profile URI at someone else's legitimate document and borrow their identity. One document MAY serve several attester addresses run by the same operator.name— a human-readable display name. Self-declared — see "Attester profiles are self-declared display data" in Security Considerations before rendering it.
All other members are OPTIONAL, and consumers MUST ignore members they do not recognize. RECOMMENDED members:
url— the attester's website.registryRepository— the profile's registry-domain payload: the attester's canonical full-descriptor collection (a git repository, an IPFS folder, an ENS-named location), letting the on-chain registry point back at the off-chain distribution channel it indexes.securityContact— a URI for security disclosures.keyRotationPolicy— a URI describing the attester's key rotation procedures.
{
"version": 1,
"attesters": ["0x00000000000000000000000000000000DeaDBeef"],
"name": "Example Attester Inc.",
"url": "https://attester.example.com",
"registryRepository": "https://github.com/example/erc7730-descriptors",
"securityContact": "mailto:security@attester.example.com",
"keyRotationPolicy": "https://attester.example.com/erc7730-key-rotation"
}Verification (bidirectional domain binding). The on-chain pointer is authenticated in one direction only: the attester's key vouches for the URI. Profile data is never trust input; attester profiles are self-declared display data, as discussed in the Security Considerations section.
MirrorLists
A MirrorList is a string[] of retrieval URIs for a descriptor file or a signed attestation blob.
URIs may use any content-addressed scheme: ipfs://, ar://, bzz://, magnet:, etc.
The list is identified by keccak256(abi.encode(uris)) and stored once globally in the registry.
MirrorLists are published via publishMirrorLists and referenced by ID thereafter; each attester's per-descriptor state is a single 32-byte pointer to the content-addressed list.
Retrieval via the published URIs is never the source of trust.
Wallets MUST recompute the retrieved descriptor's descriptorHash per ERC-8176 and verify that it equals the registered descriptorHash before using the descriptor, and MUST verify a retrieved attestation blob against its registered attestation ID.
MirrorList entry resolution
Each MirrorList entry — descriptor or attestation alike — resolves to one of two things:
- The target content directly: an ERC-7730 descriptor JSON file, or a signed attestation blob. This is the default case.
- A shared multi-file container whose entry point is an
index.jsonmanifest mapping content keys —descriptorHashfor descriptors,attestationSetIdfor attestation blobs — to the specific member within that container. An attestation set's entry maps each member's format ID to its blob, so one key resolves every rendition of the release.
A wallet's resolution algorithm is uniform regardless of transport:
- Fetch the MirrorList URI.
- If the result validates as the expected content — the descriptor hash matches
descriptorHash, or the blob is a validly signed attestation for the expected attestation ID — use it directly. - Otherwise, if the result parses as an
index.jsonmanifest, look up the expected key (descriptorHashorattestationSetId) and fetch the member it points to.
This makes shared containers viable for batches: an attester bulk-publishing signed attestations for many attestation sets or descriptors for many hashes can point them all at one container URI keyed by an index.json, so a single deduplicated MirrorList entry serves arbitrarily many content items.
Events and state reconstruction
Every write to wallet-facing registry state is accompanied by an event carrying the written values, so a consumer following the registry's event log from contract deployment can reconstruct the complete wallet-facing state.
| State | Reconstructed from |
|---|---|
Active attestation sets (activeAttestationSetIds) | AttestationUpdated |
Set metadata (attestationSetDetails) | AttestationRegistered |
Set contents (attestationSetContents) | AttestationRegistered |
Revocation timestamps (revokedAt) | AttestationRevoked |
Global MirrorList store (mirrorLists) | MirrorListPublished |
| Descriptor MirrorList pointers | DescriptorMirrorListUpdated |
| Attestation MirrorList pointers | AttestationMirrorListUpdated |
Attester profile URIs (attesterProfileURIs) | AttesterProfileUpdated |
Rationale
Self-contained revocation
Depending on an external protocol for attestation lifecycle management ties the registry's trust assumptions to that protocol's design choices. EAS's delegated attestation flow verifies signatures via plain ECDSA recovery only, and EAS does not store the original signature past attestation creation. A wallet therefore cannot re-verify an EAS-backed on-chain attestation without trusting an RPC provider or running a consensus light client. Neither property is under this registry's control.
Revocation is therefore tracked natively: revokeAttestations and getRevocationTimestamp are the registry's own bookkeeping. Revocation and cleanup are a single atomic step within revokeAttestations itself — it is the only function that revokes anything; see "No atomic revoke-and-replace, no inline MirrorList publish" below for why createAttestations does not also revoke.
No expiration mechanism
The registry supports only explicit revocation. An attestation is either active or revoked; there is no time-based decay.
Expiration requires a trusted clock. A hardware wallet with no clock and no network connection cannot evaluate a deadline at all: that takes an external trusted time source, or a block.timestamp read through an untrusted RPC channel. Nothing on-chain changes when a deadline passes — no state write, no event — so every consumer has to re-derive expiration on every use.
Revocation needs no input beyond the state view a wallet already needs to use the registry at all. A stale view hides a recent revocation, but it equally hides every other recent registry update; revocation checking adds no new trust ingredient and no new staleness window. Revocation is also one-way, so evidence that an ID is revoked stays conclusive forever, however old.
An attester who wants to time-box an attestation's validity can encode that inside the signed artifact itself. Wallets that do have a trusted clock enforce it client-side; the registry has no opinion on it.
Version-agnostic registry with one opaque version dimension
The registry stores no schema version data beyond the descriptorSchemaMajor key component. Version integrity needs nothing on-chain — the $schema key is part of the descriptor content covered by descriptorHash — and MINOR/PATCH releases are backward compatible, so the registry never sees them.
The descriptorSchemaMajor key dimension exists for one reason: breaking MAJOR releases need a migration window in which two renditions of the same descriptor are discoverable under the same context. Wallet firmware — hardware wallets especially — adopts new schema versions over years, not weeks. The extra key dimension lets both renditions live side by side at near-zero marginal cost, since a breaking schema release produces new descriptor files, new hashes and new attestations regardless.
An explicit, typed, queryable dimension costs one struct field and one query parameter. Folding the version into the context tags (erc7730.context.contract.v3) would fork the derivation table per MAJOR and hide the version from storage, queries and events. Keeping it in the retrieval layer would break single-call resolution.
Content-addressed MirrorLists
With tens of thousands of descriptors and dozens of attesters, sharing identical retrieval endpoints may save a lot of on-chain storage.
Changing an attester's active MirrorList for a descriptor requires calling updateDescriptorMirrorList with a new, already-published MirrorList id and a valid EIP-712 signature from the attester.
This decouples the storage endpoint pointer from the descriptor attestation, allowing attesters to rotate or update broken infrastructure links (e.g., changing IPFS gateways) instantly for thousands of descriptors without re-attesting the exact same descriptor hashes. The on-chain verifiable record is maintained via the EIP-712 signature.
MirrorList URI schemes
The baseline schemes for MirrorList entries are ipfs: and https:; http:, git:, ftp:, ar: (Arweave) and data: are optional.
URI matching happens on-chain through the allowedPrefixes parameter, so no client-side rule about skipping unrecognized schemes is needed. An unrecognized scheme is not among the prefixes a wallet sends, so it is filtered out before it ever leaves the contract.
A MirrorList entry can be an RFC-2397 data: URI carrying the full ERC-7730 descriptor JSON inline — e.g. data:application/json;base64,<base64 content> — making the descriptor fully available on-chain with no external retrieval. It suits a few top-priority protocols rather than the default entry type, and the on-chain prefix filtering is what makes mixing large data: entries into an otherwise normal MirrorList safe: callers that do not ask for data: never pay to receive it.
No new content-verification logic is needed for any scheme. Wallets hash fetched descriptor content and compare it against the attested descriptorHash regardless of transport, which covers data: URIs the same as https: or ipfs: ones.
Attestation sets and the derived set ID
All attestations of a descriptor are registered as one set behind a single derived ID. Every rendition shares one active record, one MirrorList pointer and one displacement rule, while the per-member attestationFormatId keeps each rendition individually discoverable and revocable.
The set ID is derived by the registry rather than chosen by the attester, so it commits to what it names. A single-attestation set — the common case — reuses the sole member's own attestation ID, and wallets address the set directly by the ERC-8176 UID. A multi-member set is identified by keccak256(abi.encode(descriptorHash, descriptorSchemaMajor, attestationIds)), binding the descriptor identity and the full member list into the identifier.
A single-attestation set consequently shares its revocation record with its sole member: displacing the set marks the attestation itself revoked, which matches the withdrawal semantics wallets need anyway.
A shared attestation baseline with vendor extensions
Backing every descriptor with a standard EAS off-chain attestation (ATTESTATION_FORMAT_EAS_OFFCHAIN) preserves the EAS ecosystem's EIP-712 format, UID scheme, ERC-8176 schema and tooling, without any EAS write call or its roughly 200,000 gas cost per attestation. This shared baseline keeps the ecosystem one commons instead of per-vendor islands: any wallet can verify any attester's attestation with one shared procedure. The contract itself never reads a format tag, so it needs no upgrade as formats evolve; a wallet simply refuses attestation sets that lack a format it can verify.
Vendor-specific needs are served by further members of the same set. A hardware wallet that cannot parse JSON on-device signs its own device-native binary encoding of a descriptor and registers the resulting attestation ID in the descriptor's set under a vendor-specific format tag (see "Current Attestation Format Tags"). Such an attestation gets the registry's full discovery, mirroring and revocation machinery, and costs nothing beyond minting a new tag off-chain. Set members are peers sharing one active record, so a vendor rendition cannot crowd out the baseline other wallets depend on.
Vendor formats are also where signature schemes evolve. ECDSA won't necessarily be a safe assumption forever; a quantum computer breaking secp256k1 is the obvious case. An attester can attach a post-quantum-signed rendition — ATTESTATION_FORMAT_ML_DSA is one example tag for this, see "Current Attestation Format Tags" — under its own format tag today, with no registry change, and wallets that recognize the tag verify it accordingly. The baseline format itself is fixed by this ERC: replacing it — for example, once a post-quantum attestation standard supersedes the EAS baseline — is an explicit revision of this specification.
The registry performs no verification of any attestation's signature or content. Wallets verify the fetched blob anyway, so a registry-side check would add gas cost without adding trust.
Curated, vendor-specific consumption
The registry's value does not require every wallet to consume it directly, nor universal coverage of the long tail of contracts. A hardware wallet vendor with device or bandwidth constraints can operate its own backend instead: it periodically queries resolveDescriptors for a curated subset of high-volume protocols, converts the descriptors into its device-friendly encoding registered under its own format tag, and ships only that subset to its devices.
The event-sourcing guarantee (see "Events and state reconstruction") makes such wrapping a first-class consumption model: a vendor backend, an indexer, or a snapshot generator maintains a complete, verifiable copy of the registry by following logs alone, and anyone can deterministically regenerate the same mirror from the same chain.
Attester profile as a single URI pointer
The registry stores no profile content on-chain — only the pointer. A registry whose purpose is preventing misleading human-readable displays should not itself hold self-declared display strings: a free-text name field becomes impersonation bait the moment any consumer renders it, and an on-chain profile schema cannot evolve once the contract is deployed. Keeping the content in a document behind a single URI leaves the contract schema-free, and a self-hosted document supports the bidirectional domain binding check — a verification mechanism a bare on-chain string cannot have.
The profile does not reuse the MirrorList machinery either: a profile is one mutable document per attester, not deduplicated content-addressed data shared across attesters, and pinning it by content hash would make every profile edit an on-chain ceremony for data whose freshness matters more than its immutability.
Identity and branding are ENS's job, not this registry's. The profile URI exists for the registry-domain pointers (canonical descriptor repository, security contact, key rotation policy) and for attesters who do not want an ENS dependency.
Registration gas costs
The cost of populating the registry is dominated by MirrorList storage and per-context bookkeeping, not by attestation creation — the registry never writes an attestation itself.
A descriptor with two context key IDs costs roughly 90,000 gas once its descriptor MirrorList is already published: a single bytes32 set ID write per context key ID, plus a once-per-set record (descriptor hash, schema major, set contents, and attestation MirrorList pointer) instead of any per-context URI string storage.
Four properties keep this cost manageable and predictable:
-
Registrations are batched: any number of descriptors share one registration transaction and one registration signature, amortizing per-transaction overhead. MirrorList publication is a separate transaction, but a single
publishMirrorListscall likewise batches any number of lists. -
Subsequent attesters attesting to an already-registered descriptor skip the MirrorList storage entirely (publication is idempotent), reducing their cost further.
-
The attestation MirrorList is content-addressed and deduplicated across every context key ID and descriptor that shares it; a batch reusing an already-published attestation MirrorList supplies only its
idand pays no publication cost at all. -
Updating the MirrorList for existing descriptors uses the
updateDescriptorMirrorListfunction, bypassing re-registration entirely, making infrastructure rotations virtually free.
These figures are order-of-magnitude estimates only: actual costs depend on URI lengths, the number of deployments per descriptor, and prevailing gas prices.
Mainnet-only deployment
The registry is deployed on Ethereum mainnet only.
Context Key IDs encode chainId, so a single registry can serve wallets operating on any EVM chain.
Backwards Compatibility
This ERC introduces a new contract interface and makes no modifications to ERC-7730 or ERC-8176.
ERC-7730 descriptor files require no changes. The context section fields used for context key ID derivation are already present in all compliant descriptors.
Reference Implementation
- IClearSigningRegistry.sol — the normative interface
- the reference implementation, with a Hardhat/Viem smoke test, is maintained at
github.com/forshtat/onchain-clear-signing-erc7730-registry
The current reference implementation is not audited and is intended for specification clarity only. Production contracts will undergo an independent security review before deployment.
Security Considerations
Malicious registration
Any address can call createAttestations with their own attestation.
Wallets only query active attestations for their configured trustedAttesters.
An unknown attacker's address will never appear in a trusted list, so their attestations are invisible to wallets.
Attester key compromise
If a trusted attester's key is compromised, an attacker can create a malicious attestation and call createAttestations.
Attesters are expected to use multisig accounts or governance contracts rather than EOAs. Contract accounts work identically to EOAs everywhere in this contract: relayed signatures verify via ERC-1271, and direct calls are plain msg.sender-authenticated.
Wallet vendors are expected to publish their trusted attester addresses and key rotation procedures. Attesters can publish their own security contact and key rotation policy via their attester profile (see setAttesterProfileURI).
Attestation IDs and set IDs are write-once
The registry keeps every recorded meaning write-once. A revoked ID — attestation set or individual member, even one revoked without ever being registered — is consumed forever under that attester.
An already-registered set ID is accepted again only as a re-activation, and only when the stored record — descriptor hash, schema MAJOR and full member list — matches the incoming descriptor exactly. Once wallets have seen an ID, its meaning can never silently change, and a revoked ID can never come back to life. A replacement attestation, even for the exact same descriptor, carries a fresh attestation ID.
An individual attestation ID can appear in more than one set: the same EAS attestation reused when a vendor rendition is added later produces a new two-member set alongside the original singleton. An active record points at the set ID, never at a member.
Schema major is declared, not verified
DescriptorInfo.descriptorSchemaMajor is attester-declared metadata, exactly like contextKeyIds and each member's attestationFormatId: the registry cannot verify it against the off-chain descriptor's actual $schema key. An attester could — maliciously or mistakenly — register a descriptor under the wrong version.
Wallets reject a fetched descriptor whose $schema MAJOR differs from the key it was resolved under, exactly as they reject a descriptor whose context section does not match the transaction. A mis-declared version results in the descriptor being discarded rather than misinterpreted.
Attestation revocation semantics
Revocation is recorded natively by the registry, always under the real attester's address. A direct revokeAttestations call is authenticated by msg.sender; a relayed batch and a displacing registration batch are backed by the attester's verified signature. There is no unauthenticated revocation path.
A RevocationEntry clears exactly the context key IDs it names. An omitted context key ID stays stale — still pointing at the now-revoked set ID — until a later registration replaces it or another revocation call names it. This staleness is cosmetic: every resolveDescriptors entry exposes revocation status, and wallets reject entries whose set ID is revoked regardless of whether the record was cleared.
Revoking an attestation set is the attester's withdrawal of the whole release: wallets treat every member of a revoked set as revoked, regardless of the members' own revocation records. Revoking an individual member flags only that rendition; the set stays active. For a single-attestation set the two coincide, as the set shares its ID with its sole member.
Attestation format is not verified content
An attestation's attestationFormatId labels the artifact; it does not validate it. Declaring ATTESTATION_FORMAT_EAS_OFFCHAIN does not cause the registry to check that the fetched blob is actually a well-formed ERC-8176 EAS off-chain attestation, and a vendor tag does not exempt the attester from producing a genuinely verifiable artifact.
Wallets and vendor backends verify the fetched blob with the procedure appropriate to its declared format: UID recomputation, signature and schema checks per ERC-8176 for the baseline format, the vendor's own procedure otherwise. An unrecognized attestationFormatId is unverifiable and gets rejected like any other attestation blob that cannot be validated.
Attester profiles are self-declared display data
An attester profile is unverified self-declaration: anyone can register any address and point its profile at a document claiming any name. Profile data is never trust input — wallets select attesters exclusively by their configured trusted address lists.
Two checks bound the damage a fabricated profile can do. The attesters back-reference (see setAttesterProfileURI) is verified before attributing a profile to an address, closing the identity-borrowing attack where an attacker points their profile URI at a reputable operator's genuine document. Beyond that, consumers render profile data only for attesters they already trust, or mark it verified only when the bidirectional domain binding holds. A self-declared "Ledger" string shown next to an attestation is precisely the class of misleading display this registry exists to prevent.
Incorrect context key ID bindings
The registry cannot verify contextKeyIds against the off-chain JSON descriptor, so an attester could — maliciously or mistakenly — associate a descriptor for contract A under the context key ID for contract B.
Wallet-side validation of the descriptor's context section against the actual transaction (see above) ensures such a mis-binding results in the descriptor being rejected rather than misleading formatting being shown.
signature commits to the exact descriptors, context key IDs, and both MirrorList pointers for the whole batch in one signed structure. There is no partial signature a relayer could observe and re-bind to different context key IDs or retrieval endpoints.
Copyright
Copyright and related rights waived via CC0.
