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.
A descriptor file MAY cover a single function instead of a whole contract, so that a memory-limited device verifies only the descriptors it uses. For this, the registry record of a contract points at a function index listing the contract's per-function descriptors, each with its own attestations.
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.
-
Replacement and withdrawal. An attester replaces or withdraws their record at a context in a single transaction, without waiting on any independent-review-based central registry. Revoking an attestation is left to the attestation format, and for the EAS baseline it happens on EAS itself.
-
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.
-
Per-function verification on constrained devices. A hardware wallet with little memory can receive, hash and verify the attestation of just the function descriptor a transaction needs, instead of a whole-contract descriptor whose size is not bounded by the device.
Specification
Definitions
| Term | Definition |
|---|---|
| descriptor | An ERC-7730 JSON file as defined in ERC-7730. It MAY cover a single function — see function descriptor |
| descriptorHash | The ERC-8176 descriptor hash: keccak256 of the fully resolved canonicalized descriptor (or, for a function index, of the canonicalized function-index file) |
| 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 |
| descriptorSchemaMajor | The MAJOR version of the ERC-7730 descriptor schema a descriptor's $schema key declares, carried by each release as an opaque non-zero uint64 |
| record | What an attester has written for one context: a list of releases and two MirrorList IDs (descriptors, attestations). Each (attester, context) pair has at most one |
| release | One (descriptorHash, descriptorSchemaMajor) pair of a record: the descriptor a wallet supporting that schema MAJOR should use |
| attester | An address that writes records. Every write is by msg.sender |
| 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 index | An index.json container resolving a shared MirrorList entry into per-descriptorHash members — see "Index file schemas and versioning" |
| attestation index | An index.json container resolving a shared MirrorList entry into per-descriptorHash, per-attestationFormatId members — see "Index file schemas and versioning" |
| function descriptor | A self-contained descriptor covering exactly one function or EIP-712 primary type, as defined in ERC-8176 "Function Descriptors" |
| functionSelector | The raw 4-byte ABI function selector, used unpadded as a function index's methods lookup key — see "Function Indexes" |
| typeHash | The EIP-712 type hash of a message's primary type, a component of EIP-712 context key IDs and a function index's messages lookup key — see "Function Indexes" |
| function index | A JSON file, one per release of a contract's descriptors, mapping each function selector or message type hash to its function descriptor and attestations — see "Function Indexes" and "Index file schemas and versioning" |
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.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 attestationFormatId in an attester's declared formats and as the keys of the attestation index.
ATTESTATION_FORMAT_EAS_OFFCHAIN is the ERC-8176 EAS off-chain attestation format every record's attestations SHOULD include.
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. For EIP-712 contexts it is one per deployment and per primary type it covers.
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, bytes32 typeHash |
context.eip712.domainSeparator | bytes32 domainSeparator, bytes32 typeHash |
typeHash is the EIP-712 type hash of the message's primary type, keccak256(encodeType(primaryType)), which includes the referenced struct types. deployEventTopic is the topic0 of the factory's deploy event: the keccak256 of its canonical signature. Finding the factory of a given contract is up to the wallet.
Function Indexes
A function index lets a contract's per-function descriptors be found from the contract-level contextKeyId. Contract calls and typed messages are resolved differently — a wallet always knows which kind of request it is handling before it looks anything up — so a function index keys them in two separate maps rather than one unified key space. It is a JSON object:
{
"$schema": "https://eips.ethereum.org/assets/erc-8283/erc8283-function-index-v3.0.0-next.schema.json",
"methods": {
"0xa9059cbb": {
"descriptorHash": "0x7c3a…d6e7f",
"descriptorUris": ["ipfs://bafy…/transfer.json"],
"attestations": [
{
"attestationId": "0x4f0e…d6e7f",
"attestationFormatId": "0x9b2c…eas0f",
"uris": ["ipfs://bafy…/transfer.attestation.json"]
}
]
}
},
"messages": {
"0x8b73c3c6…": {
"descriptorHash": "0x…",
"descriptorUris": ["ipfs://bafy…/permit.json"],
"attestations": []
}
}
}methods is keyed by lowercase, 0x-prefixed functionSelector; messages by lowercase, 0x-prefixed typeHash. Each entry names one function descriptor by its descriptorHash, the URIs it can be fetched from, and its attestations with their format IDs and URIs. $schema names the function index schema the file conforms to, and is also what tells a function index from an ERC-7730 descriptor, whose $schema names an erc7730-v<VERSION> schema. The schema's version is locked to the ERC-7730 descriptor schema version it is paired with — see "Index file schemas and versioning" for the normative JSON Schema and the full versioning policy, shared with the descriptor and attestation indexes.
EIP-712 messages are normally resolved directly through their per-type context key IDs (see "Context Key ID Derivation"), which need no index. The messages map is the alternative for attesters that prefer a single record per contract. When one attester has both a per-type record and a function index entry for the same message, a wallet MUST use the per-type record.
A function index is registered like any other descriptor: its hash — keccak256 of the RFC 8785 (JCS) serialization of the function index — is the descriptorHash of a release, and the descriptor MirrorList entries resolve to it. It carries an ordinary ERC-8176 attestation whose data is the function index's hash.
A function index is itself just a lookup table, never a trust root:
- Function attestations are not listed in the registry. They are referenced from the function index.
- A wallet MUST recompute the function index's hash and verify it equals the release's
descriptorHash, and MUST verify every function descriptor it uses against that function's own attestation per ERC-8176. A device verifies a function descriptor and its attestation without ever seeing the function index. - A function index is retired by writing a newer record, or by revoking its attestation like any other descriptor's (see "Revocation"). Revoking the attestation of the function index retires the whole release.
Per-function resolution
To resolve the descriptor of one function, a wallet:
- derives the contract-level
contextKeyIdfrom the transaction and callsresolveRecords; - picks the release matching the schema MAJOR it implements (see "Schema major versioning");
- fetches the function index and verifies its hash against the release's
descriptorHash, and its attestation per ERC-8176, including the attestation's revocation status; - branches on the request kind and looks up the transaction's function selector in
methods, or the message's type hash inmessages; fetches the function descriptor and its attestation, and verifies them per ERC-8176 — including that the descriptor's embedded context matches the transaction, and recomputing the descriptor's owndescriptorHashas that verification already requires; - checks the revocation status of the function descriptor's attestation by the rules of its format — for the ERC-8176 baseline, a query to the canonical EAS contract (see "Revocation") — and rejects the attestation if it was revoked.
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.
A record carries one release per schema MAJOR it supports. Each release declares the MAJOR version of its descriptor's $schema as schemaMajor, and a wallet uses the release matching the MAJOR it implements. During a breaking-version migration, attesters write the new MAJOR descriptors into the same record alongside the old ones, and wallets on old firmware keep resolving the old version. Because a write replaces the whole record, an attester that updates one MAJOR resends the others, or they stop being served.
Only the MAJOR version is a registry concept. MINOR and PATCH releases are backward compatible by definition, so updating to one is an ordinary replacement of the same release, and older wallets keep processing the result.
The registry does not interpret schemaMajor. It only requires the releases of a record to be non-zero and strictly ascending, so that no MAJOR appears twice.
When a record has releases for several MAJORs a wallet implements, the wallet SHOULD prefer the highest one.
Data Model
The registry maintains the following state:
struct DescriptorRelease {
bytes32 descriptorHash; // the declared descriptor hash; not verified by the registry
uint64 schemaMajor; // the declared schema MAJOR; non-zero
}
struct DescriptorDetails {
DescriptorRelease[] releases; // strictly ascending by schemaMajor, never empty
bytes32 mirrorListId; // MirrorList of the descriptors index file, shared by all releases
}
struct AttestationDetails {
bytes32 mirrorListId; // MirrorList of the attestations index file
}
struct RegistrationRecord {
DescriptorDetails descriptorDetails;
AttestationDetails attestationDetails;
}
// Records, stored once under the hash of their ABI-encoded content and shared by every
// context that points to them. Never deleted.
mapping(bytes32 recordId => RegistrationRecord) records;
// The record currently active for the given attester and context, zero if none.
// Rewritten by 'writeRecords' and cleared by 'deleteRecords'.
mapping(address attester => mapping(bytes32 contextKeyId => bytes32 recordId)) recordIds;
// Global store of MirrorLists, written once per unique URI set.
// A "mirror list" is an array of URIs for fetching a descriptors index file or an
// attestations index file. Key = keccak256(abi.encode(uris)).
mapping(bytes32 mirrorListId => string[]) mirrorLists;
struct AttestationFormatSettings {
bytes32 attestationFormatId; // keccak256("erc7730.attestation.<format>")
address revocationController; // optional IRevocationController, address(0) for none
}
struct AttesterSettings {
string profileURI; // self-declared display data, empty when unset
AttestationFormatSettings[] attestationFormats;
}
// Self-declared settings per attester. Replaced as a whole.
mapping(address attester => AttesterSettings) attesterSettings;A record ID is an internal key: it appears in no event and in no view.
Interface
The full normative interface is provided in IClearSigningRegistry.sol.
interface IClearSigningRegistry {
function version() external view returns (string memory);
function writeRecords(bytes32[][] calldata contextKeyIds, RegistrationRecord[] calldata registrationRecords) external;
function deleteRecords(bytes32[] calldata contextKeyIds) external;
function resolveRecords(address[] calldata attesters, bytes32[] calldata contextKeyIds) external view returns (ResolvedRecord[] memory resolved);
function publishMirrorLists(string[][] calldata uriLists) external;
function getMirrorListById(bytes32 mirrorListId) external view returns (string[] memory uris);
function updateAttesterSettings(AttesterSettings calldata settings) external;
function getAttesterSettings(address attester) external view returns (AttesterSettings memory settings);
}Every write is made by msg.sender, who is the attester. There are no relayed calls, signatures or nonces: an attester that cannot send transactions directly, such as a multisig or a governance contract, calls the registry through its own execution path.
writeRecords
The registry's write function. It writes a batch of records for the caller in a single transaction: registrationRecords[i] is written for every context key ID in contextKeyIds[i].
The attester produces the signed attestation artifacts locally and stores them wherever it chooses. A record points at them through an attestation MirrorList — the same kind of retrieval-URI list used for descriptors, pointed at an attestations index file. Every record SHOULD reference at least one standard ERC-8176 EAS off-chain attestation (ATTESTATION_FORMAT_EAS_OFFCHAIN): the compatibility baseline every wallet can verify. The registry is fully format-agnostic and does not validate any attestation's signature or content, nor the declared descriptor hash.
Each attester has at most one record per context. Writing a record replaces the previous one at that context as a whole, including every release it held: to update one schema MAJOR, resend the other releases too, or they stop being served. Replacement needs no prior step and leaves no trace of the displaced record in the registry. To stop serving a record without a replacement, use deleteRecords.
A record MUST have at least one release; every release MUST have a non-zero descriptorHash; and the schemaMajor values MUST be strictly ascending starting above zero. Both MirrorLists referenced by a record MUST already be published via publishMirrorLists. The call reverts with EmptyRecords, ArrayLengthMismatch, EmptyContextKeyIds, EmptyReleases, ZeroDescriptorHash, SchemaMajorsNotAscending or UnknownMirrorList otherwise.
A record is stored once under the keccak256 hash of its ABI-encoded content and shared by every context that points to it; a record that is already stored is not written again.
For every context written, the registry emits RecordWritten with the full record, followed by one DescriptorReleased per release, so that a descriptor hash can be searched for by its topic.
Wallets MUST verify the fetched attestation per ERC-8176 "Verifying an Attestation" — schema, descriptor hash match and expiration — and check its revocation (see "Revocation").
deleteRecords
Deletes the caller's record at every listed context. A context without a record does not revert, and still emits RecordDeleted. An empty array reverts with EmptyContextKeyIds. Deleting a record is not revocation (see "Revocation").
resolveRecords
The primary wallet-facing query entry point: returns the records of the given attesters at the given contexts in a single call. attesters and contextKeyIds are lookup keys, not filters — an empty array yields no results.
For every (attester, contextKeyId) pair that has a record, the returned ResolvedRecord carries the attester, the context key ID, the record's releases, the contents of the descriptor MirrorList (descriptorUrls) and the contents of the attestation MirrorList (attestationUrls). Pairs with no record produce no entry. Results are ordered by attesters first, then contextKeyIds.
The registry applies no filters: it returns every release and every URI. A wallet picks the release for the schema MAJOR it implements, keeps the URI schemes it can fetch, and may consult the attester's declared formats and revocation controllers (see getAttesterSettings).
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. An empty list reverts with EmptyMirrorList.
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 records or attesters reference them.
Any address may publish MirrorLists, and publication may be done independently of any record: a pinning service, mirror operator, or any other party can maintain the published lists that attesters may reference by ID.
MirrorLists are immutable and are stored permanently for every record. Changing the URL requires rewriting every record that uses it. If the location of the contents may need to change, point to a stable URL that can redirect to a correct location using a compatible network protocol.
getMirrorListById(bytes32 mirrorListId)
Returns the URI list for a given MirrorList ID. Returns an empty array if the ID is unknown.
updateAttesterSettings(AttesterSettings settings) and getAttesterSettings(address attester)
An attester declares its settings in a single call: a profile URI and the attestation formats it uses, each with an optional revocation controller (see "Revocation"). The call replaces all of the caller's previous settings, so empty fields clear them, and emits AttesterSettingsUpdated. getAttesterSettings returns an attester's current settings, or empty settings if it never set any.
The registry checks nothing in the settings and never calls a revocation controller. The declared formats and their controllers are functional hints that wallets MAY act on, for example to skip attesters that issue no format they can verify.
The profile is a "business card": 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.
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.
Revocation
The registry holds no revocation state. Revocation is the attestation format's job, and removing or replacing a record is not revocation: neither leaves a timestamp, and either can be undone by writing the same record again.
For the ERC-8176 baseline format, revocation lives in the canonical EAS contract on Ethereum mainnet. A wallet SHOULD query it directly, regardless of what an attester declares, by calling getRevokeOffchain(attester, uid) with the attester that signed the attestation.
An attester MAY declare, per attestation format, a revocationController in its settings (see updateAttesterSettings): a contract wallets MAY ask whether an attestation was revoked.
interface IRevocationController {
function getRevokeOffchain(address revoker, bytes32 data) external view returns (uint64 timestamp);
}The signature deliberately matches getRevokeOffchain of the Ethereum Attestation Service, so the canonical EAS contract is itself a valid controller for the EAS off-chain format. data is the format-specific identifier of the attestation: for the EAS off-chain format, the attestation UID. A non-zero timestamp means revoked.
The registry never calls a controller and does not interpret data. A format other than the EAS off-chain one MUST define what data means and where revocation is answered. A controller is a hint chosen and changeable by the attester, so wallets that use it extend the trust they already place in that attester and nothing more. An attestation format with no controller declared is unrevocable.
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 record holds two 32-byte MirrorList IDs.
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 by the rules of its format, including that it attests the release's descriptorHash.
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 thedescriptorHashof a release to the specific member within that container. An attestation entry maps each rendition'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 of the expecteddescriptorHash— use it directly. - Otherwise, if the result parses as an
index.jsonmanifest, look up the expecteddescriptorHashand fetch the member it points to.
This makes shared containers viable for batches: an attester bulk-publishing signed attestations 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.
Served descriptors and includes
A descriptor MirrorList URL serves either the fully resolved descriptor, or one whose relative includes resolve against that URL per RFC 3986. A URL that cannot resolve them — a bare IPFS file CID, a magnet: URI — MUST serve the resolved file. The attested descriptorHash is always that of the fully resolved descriptor, so both forms verify against the same hash and the same attestation.
Wallets MUST support both forms. Absolute includes are not supported. A wallet that cannot resolve an include, or that finds one it does not support, tries the next URL and otherwise treats the descriptor as unverifiable. Wallets SHOULD bound the depth, the number and the total size of the includes they fetch, and MUST detect cycles.
Index file schemas and versioning
index.json conforms to one of two normative JSON Schemas, published alongside this ERC:
erc8283-descriptor-index-v<VERSION>.schema.json— a descriptor index:{ "$schema": "<schema URI>", "entries": { "<descriptorHash>": "<absolute URI>" } }.erc8283-attestation-index-v<VERSION>.schema.json— an attestation index:{ "$schema": "<schema URI>", "entries": { "<descriptorHash>": { "<attestationFormatId>": "<absolute URI>" } } }.
In every index file, $schema identifies the schema the file conforms to by a URI whose last path segment is that schema's file name, such as …/erc8283-descriptor-index-v3.0.0-next.schema.json. A consumer MUST treat a file whose $schema names a version it does not implement as invalid. No other kind field distinguishes a descriptor index from an attestation index: a wallet always already knows which one it is resolving from its own call context — descriptorUrls or attestationUrls — never ambiguously. A function index is registered as a descriptor and shares that slot, so the wallet tells it from an ERC-7730 descriptor by its $schema.
A function index (see "Function Indexes") conforms to a third, separate schema — erc8283-function-index-v<VERSION>.schema.json — since its lookup key and entry shape are both different in kind from the other two: it is the registered descriptorHash itself (not a shared MirrorList container), keyed by functionSelector/typeHash rather than by content hash, and each entry carries its own nested attestations array rather than a single URI.
Versioning is locked 1:1 to the ERC-7730 descriptor schema version of the identical tag, not an independently-numbered ERC-8283 concept, for all three schemas. Whatever version tag ERC-7730 publishes (erc7730-v<VERSION>[-rc.N|-next].schema.json — see ERC-7730 "Versioning"), all three index schemas are published under the identical tag: erc8283-descriptor-index-v<VERSION>[-rc.N|-next].schema.json, erc8283-attestation-index-v<VERSION>[-rc.N|-next].schema.json and erc8283-function-index-v<VERSION>[-rc.N|-next].schema.json. This ERC commits to publishing a matching set of schema releases every time ERC-7730 does, even when an index format's own content is unchanged, so integrators track exactly one version number across all four specs instead of four independently drifting ones. See "Index schema versioning is pinned to ERC-7730" in Rationale.
Every URI value inside an index.json entry, or inside a function index's descriptorUris/uris, MUST use the identical URI scheme as the URI that was used to successfully fetch that file itself — not merely a scheme from the allowed set, the same scheme. A file fetched via ipfs://X/index.json MUST contain only ipfs://... entries; one fetched via https:// MUST contain only https:// entries. This closes the class of failure where a file resolves in one retrieval environment but its members do not — authors are responsible for keeping every scheme in a file internally consistent with how that file itself is reachable, and a wallet MAY reject a file containing a foreign scheme without attempting to fetch it.
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, ordered by block and log index, can reconstruct the complete wallet-facing state.
event MirrorListPublished(bytes32 indexed mirrorListId, string[] uris);
event RecordWritten(address indexed attester, bytes32 indexed contextKeyId, RegistrationRecord record);
event DescriptorReleased(address indexed attester, bytes32 indexed contextKeyId, bytes32 indexed descriptorHash, uint64 schemaMajor);
event RecordDeleted(address indexed attester, bytes32 indexed contextKeyId);
event AttesterSettingsUpdated(address indexed attester, AttesterSettings settings);| State | Reconstructed from |
|---|---|
Global MirrorList store (mirrorLists) | MirrorListPublished |
Records and their contexts (recordIds) | RecordWritten, cleared by RecordDeleted |
Attester settings (attesterSettings) | AttesterSettingsUpdated |
DescriptorReleased repeats data of RecordWritten and is not needed for reconstruction: it exists so that a descriptor hash can be searched for by its topic.
Rationale
Revocation lives with the attestation
The attester is the only party that can revoke, and the attester can already remove or replace its own record in a single transaction. What a registry-side revocation would add is a durable, ordered statement that a specific attestation is void, for copies of descriptors and attestations that a wallet obtained or cached without going through the registry. That statement already exists where it matters: the baseline format is an EAS attestation, and EAS provides revocation for it natively and on the canonical contract that every verifying wallet already queries.
Duplicating that state in the registry would give a second, independently writable revocation channel for the same attestation, which an attester could update on one and forget on the other. Keeping revocation out of the registry also keeps it format-agnostic: a vendor format defines its own revocation, and the optional revocationController of its declared format gives wallets a place to ask. The registry never calls it.
Per-function descriptors and function indexes
Registering one descriptor per function under per-function context keys needs no change to the registry, but its gas and calldata grow linearly with the number of functions and every upgrade rewrites every function. Measured with the reference implementation, for a contract with two deployments and one EAS attestation per descriptor, writeRecords cost about 202,000 gas for one whole-contract descriptor, but about 905,000 for 5 functions, 3.5 million for 20 and 10.6 million for 60.
A function index keeps the on-chain record at one descriptor per contract release, so the registration cost does not depend on the number of functions, while each function is still attested individually and verified on its own by a constrained device. The per-function attestations are signed off-chain, which costs no gas, and are reused across releases for functions that did not change.
No expiration mechanism
The registry supports no expiration. 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.
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 schemaMajor of each release. 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 schemaMajor of a release 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. Carrying one release per MAJOR in the same record 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 per release. 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.
MirrorLists are immutable and keyed by their content, so changing the location of the contents means publishing a new list and rewriting every record that uses the old one. The registry is not a dynamic DNS table: attesters that expect their endpoints to move should point to a stable URL they control and can redirect, and keep the MirrorList itself unchanged.
MirrorList URI schemes
The baseline schemes for MirrorList entries are ipfs: and https:; http:, git:, ftp:, ar: (Arweave) and data: are optional.
The registry applies no filters, so a wallet drops the schemes it does not recognize or cannot fetch after resolving. 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. Because every caller of resolveRecords receives every URI of the lists it resolves, a large inline entry is paid for by every reader; it suits a few top-priority protocols rather than the default entry type.
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.
Index schema versioning is pinned to ERC-7730
The descriptor index, attestation index and function index schemas (see "Index file schemas and versioning") could each have carried their own independent version counter, the way the attester profile document does (version: 1). Pinning all three 1:1 to ERC-7730's own schema version instead is a deliberate extra maintenance commitment — republishing an index schema under a new tag even when its content did not change — in exchange for collapsing what would otherwise be four independently drifting version numbers (ERC-7730's descriptor schema, and the three index formats) into one. An integrator building against "ERC-7730 v3" descriptors already knows which index schemas pair with them, with no combinatorial version-matching table to maintain on either side.
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 records whose attestation index lacks a format it can verify.
Vendor-specific needs are served by further renditions in the same attestation index. A hardware wallet that cannot parse JSON on-device signs its own device-native binary encoding of a descriptor and lists the resulting attestation under a vendor-specific format tag (see "Current Attestation Format Tags"). Such an attestation gets the registry's full discovery and mirroring machinery, and is revoked by the rules of its own format (see "Revocation"). It costs nothing beyond minting a new tag off-chain. Renditions are peers under one 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 resolveRecords 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 settings
An attester's declared formats and revocation controllers are functional hints, not registry rules: the registry checks records against neither. They let a wallet skip attesters that issue no format it can verify, and find where a non-baseline format answers revocation. An attester that strays from its own declaration can simply be ignored by wallets. Settings are replaced as a whole, so that updating one field means resending the others.
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 per-record storage and per-context bookkeeping, not by attestation creation — the registry never writes an attestation itself.
Measured with the reference implementation, with both MirrorLists already published: a new record with one release at one context costs about 172,000 gas, and about 63,000 each time an already-stored record is pointed at one more context. A record with two contexts costs about 202,000.
Four properties keep this cost manageable and predictable:
-
Registrations are batched: any number of records share one transaction, amortizing per-transaction overhead. MirrorList publication is a separate transaction, but a single
publishMirrorListscall likewise batches any number of lists. -
A record is stored once under its content hash and each context holds only a pointer, so adding a context to an existing record costs a pointer write plus events.
-
MirrorLists are content-addressed and deduplicated across every record and attester that shares them; a record reusing an already-published list supplies only its ID and pays no publication cost at all.
-
A batch that repeats a record already stored does not write it again.
These figures are order-of-magnitude estimates only: actual costs depend on URI lengths, the number of releases and contexts per record, 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.
It assumes that ERC-7730 permits a descriptor to cover a single function or EIP-712 primary type, and that ERC-8176 defines function descriptors and attestations over them. 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 test suite, is maintained at
github.com/forshtat/onchain-clear-signing-erc7730-registry erc8283-descriptor-index-v3.0.0-next.schema.json— the descriptor index schemaerc8283-attestation-index-v3.0.0-next.schema.json— the attestation index schemaerc8283-function-index-v3.0.0-next.schema.json— the function index schema
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 writeRecords with its own attestations.
Wallets only query the records of 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 writeRecords or deleteRecords.
Attesters are expected to use multisig accounts or governance contracts rather than EOAs. Contract accounts work identically to EOAs: every write is authenticated by msg.sender, so a multisig or governance contract writes through its own execution path.
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 updateAttesterSettings).
Records are replaceable content
A record is immutable content under its hash, but what a context points to is rewritten by its attester at any time, and a deleted context can be restored by writing the same record again. Wallets treat the registry as the attester's current statement, not as a log of revocations — see "Revocation".
Schema major is declared, not verified
The schemaMajor of a release is attester-declared metadata, exactly like the context key IDs and the declared attestation formats: 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.
Revocation is not recorded by the registry
The registry cannot be used to learn that an attestation was revoked: deleteRecords and replacement leave no timestamp, and either can be reversed by writing the record again. A wallet that obtained a descriptor and its attestation outside the registry, or cached them, MUST check revocation by the rules of the attestation format, and SHOULD query the canonical EAS contract directly for the baseline format.
A revocationController is declared by the attester and can be changed or removed at any time. It is a hint, never a guarantee, and an attestation format with none declared is unrevocable. A compromised attester key can rewrite or delete every record of that attester as well as sign new attestations; the registry offers no key rotation, and a contract-account attester rotates its signers inside the account.
Function indexes are not trust roots
A function index is fetched from untrusted URIs and lists where to find each function's descriptor and attestations. A wallet verifies the function index only against the registered descriptorHash, and then verifies each function descriptor against its own attestation and embedded context. A malicious mirror that serves a different function index fails the hash check; one that serves a different function descriptor or attestation fails ERC-8176 verification.
Replacing a function index does not revoke the function descriptors nested inside it. A stale function descriptor remains valid until its own attestation is revoked, so an attester that replaces or removes a function MUST revoke that function's attestation under the rules of its format.
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 updateAttesterSettings) 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.
Copyright
Copyright and related rights waived via CC0.
