Full spec design: ethereum/ERCs#1789 Full spec might be difficult to read but I'd recommend to read it together with an LLM agent
Goal: Discovery entrypoint for attested descriptors.
It declares for a given attester which contracts are attested, and where to find the attestations and the descriptors.
In general there are two parties: Publishers and Attesters. They can be the same entity.
- Attesters attest to individual descriptors, declaring where the attestation and descriptor files reside (ie.
MirrorLists)- Attestations are attributed to attesters
- Publishers operate descriptor and attestation mirrors accessible with different protocols like
http://ipfs://as aMirrorList.- Mirrors are not attributed. Anyone can publish a MirrorList
We identify each contract with the contextKeyId:
contextKeyId = keccak256(abi.encode(
keccak256("erc7730.context.contract"), // binding-type tag
uint256(1), // chainId
address(0xE592427A0AEce92De3Edee1F18E0157C05861564)
))Attestations are done by auditors or any external party over a descriptor. The spec suggests using EAS as the attestation format to attest with an Ethereum address, but different formats are possible. The whole attestation files will be stored and published offchain, behind "Mirror"s (explained below).
To declare an attestation you need the attestation ID (e.g. the EAS ID) and the attestation format EAS_OFFCHAIN
{
attestationId: "0x26af17f708f4920...7bb14739951", // EAS UID
attestationFormatId: EAS_OFFCHAIN
}Where EAS_OFFCHAIN = keccak256("erc7730.attestation.eas.offchain")
This allows other attestation formats LEDGER_V1, ML_DSA...
You declare an attestation set for a given descriptorHash and descriptorSchemaMajor alongside its attestation(s).
Attestation Set Identifier is calculated with:
// single attestation
setId = easUid //"0x26af17f708f4920...7bb14739951"
// multiple attestations, 2 is the descriptorSchemaMajor
setId = keccak256(abi.encode(descHash1, 2, [(uid1, EAS_OFFCHAIN), (uid2, ML_DSA)]))Get the attestation set by attester, contextKeyId, schemaMajor:
activeAttestationSetIds[ATTESTER_ADDRESS][k1][2] --> setId(attester, contextKeyId, schemaMajor) maps to one attestation set
Then get related contents, mapped via its key:
attestationSetDetails[ATTESTER_ADDRESS][setId] --> { descHash1, 2 }
attestationSetContents[ATTESTER_ADDRESS][setId] --> [ (uid1, EAS_OFFCHAIN), (uid2, ML_DSA) ]
// mirrorlists explained below
attestationMirrorListIds[ATTESTER_ADDRESS][setId] --> "0xabc...def" // keccak of ["http://att.cdn.sourcify.dev", ...]
revokedAt[ATTESTER_ADDRESS][setId] --> 0 // the set
revokedAt[ATTESTER_ADDRESS][uid1] --> 0 // attestation 1
revokedAt[ATTESTER_ADDRESS][uid2] --> 0 // attestation 2A MirrorList is a list of URI strings that can hold an offchain .json:
mapping(bytes32 mirrorListId => string[]) mirrorListsMirrorLists are keyed by their hash:
mirrorListId = keccak256(abi.encode(["https://desc.cdn.sourcify.dev", "ipfs://bafy..."]))Anyone can publish a MirrorList
// publish multiple lists at once
publishMirrorLists(string[][] uriLists)Attesters point to MirrorLists when creating attestations and descriptors:
//// Assume these mirrors are published by SOURCIFY
// Descriptor Mirrors
desc_list = ["https://desc.cdn.sourcify.dev", "ipfs://bafy..." ]
publishMirrorLists([desc_list])
D_M = keccak256(abi.encode(desc_list))
// Attestation mirrors
list2 = ["https://att.cdn.sourcify.dev", "ipfs://bafx..." ]
publishMirrorLists([list2])
A_M = keccak256(abi.encode(list2))
// CYFRIN points to mirror lists when creating attestations
createAttestations(
attester: CYFRIN_ATTESTER_ADDRESS,
descriptors: [
{
descriptorHash: descHash1,
descriptorSchemaMajor: 2,
contextKeyIds: [k1, k2], // e.g. mainnet + Arbitrum deployment
attestationIds: [
{ attestationId: uidA, attestationFormatId: EAS_OFFCHAIN },
{ attestationId: uidA2, attestationFormatId: ML_DSA}
]
},
{
descriptorHash: descHash2,
descriptorSchemaMajor: 2,
contextKeyIds: [k3],
attestationIds: [
{ attestationId: uidB, attestationFormatId: EAS_OFFCHAIN }
]
}
],
descriptorMirrorListId: D_M, // <-- "I declare these descriptor blobs are behind these mirrors in this list"
attestationMirrorListId: A_M, // <-- "I declare these attestation blobs are behind these mirrors in this list"
signature: …
);
// Mirrorlists are mapped to attesters + identifiers
descriptorMirrorListIds[CYFRIN_ATTESTER_ADDRESS][descHash1] --> D_M
descriptorMirrorListIds[CYFRIN_ATTESTER_ADDRESS][descHash2] --> D_M
attestationMirrorListIds[CYFRIN_ATTESTER_ADDRESS][setIdA] --> A_M
attestationMirrorListIds[CYFRIN_ATTESTER_ADDRESS][uidB] --> A_M
// Get the attestion set id for this contract k1 by Cyfrin (for schema 2)
activeAttestationSetIds[CYFRIN_ATTESTER_ADDRESS][k1][2] --> setIdA
// Get the descriptor
resolveDescriptors(
attesters: [CYFRIN_ATTESTER_ADDRESS],
contextKeyIds: [k1],
descriptorSchemaMajors: [2],
attestationFormatIds: [EAS_OFFCHAIN],
allowedPrefixes: ["https:"] // only https
)
// response
{
"descriptorHash": "descHash1",
"contextKeyId": "k1",
"descriptorSchemaMajor": 2,
"attestationSetId": "setIdA",
"descriptorMirrorListUris": ["https://desc.cdn.sourcify.dev"],
"attestationMirrorListUris": ["https://att.cdn.sourcify.dev"],
"attestations": [
{
"attester": "CYFRIN_ATTESTER_ADDRESS",
"attestationId": "setIdA",
"attestationFormatId": "EAS_OFFCHAIN",
"revokedAt": 0
}
]
}Every mapping is keyed by attester first.
Each attester writes into their own rows and cannot touch anyone else’s. There is no shared namespace to contend for, and therefore no owner, no admin, and no governance in the contract. The single exception is the global mirror-list store, and that one is deliberate. Anyone can write a mirrorlist.
In the end the only trust is on the attester key. Given a contract to clear sign and a trusted attester key:
- Ask the registry "Where can I find the descriptors and attestations for this contract?" (
resolveDescriptors) - Check if the attestation is revoked on the registry
- Check if the attestation is revoked onchain on EAS
- Resolve and find the full attestation from a mirror
- Check if the attestation signature is valid
- Check if the attestation.descriptorHash matches to registry.descriptorHash
- Resolve and find the descriptor from a mirror
- Check if the resolved descriptor blob's hash matches the attestation.descriptorHash
- Check the descriptor's context against the transaction
- Use the descriptor to decode
User is signing a transaction. Three facts:
- chainId: 1
- contract address:
0xE592427A0AEce92De3Edee1F18E0157C05861564 - trusted attester:
0xBf01daF454dce008d3E2bfD47d5e186F71477253
Wallet builds the context key
CONTEXT_TAG_CONTRACT = keccak256("erc7730.context.contract")
// = 0x5bbeaa35449eddf74ff0ba9ffc8445a53ef73c35e10c7d52e9f39927f5671d0a
const rawKey = abi.encode(
bytes32 0x5bbeaa35...671d0a, // the tag
uint256 1, // the chain ID
address 0xE592427A0AEce92De3Edee1F18E0157C05861564
)
const contextKeyId = keccak256(rawKey)
// = 0x959bd901638d8ee1e76dea1a698a13e183a561d825b897316e65191d38bc109bResolve onchain
bytes32 trustedAttester = 0xBf01daF454dce008d3E2bfD47d5e186F71477253
contextKeyId = 0x959bd901638d8ee1e76dea1a698a13e183a561d825b897316e65191d38bc109b
bytes32 easFormatId = keccak256("erc7730.attestation.eas.offchain")
resolveDescriptors(
attesters: [trustedAttester],
contextKeyIds: [contextKeyId],
schemaMajors: [1, 2], // schema major versions
formatIds: [easFormatId],
allowedPrefixes: ["ipfs:", "https:"] // which methods can you fetch with?
)Read the onchain response ResolvedDescriptor. This would give us the descriptorHash primarily
ResolvedDescriptor {
descriptorHash 0x0d7fad…d8a0
contextKeyId 0x959bd901…bc109b
descriptorSchemaMajor 1
attestationSetId 0x88e12c…09bd
descriptorMirrorListUris [ "ipfs://bafy…",
"https://cdn.attester.example/7730/" ]
attestationMirrorListUris [ "ipfs://bafk…" ]
attestations [ { attester: 0xBf01…7253,
attestationId: 0x88e12c…09bd,
formatId: 0x4dcb3375…0b9f,
revokedAt: 0 } ]
}Check that the record is live.
The response carries a revokedAt for every attestation it returns. Zero means live.
"attestations": [ { …, "revokedAt": 0 } ] // liveThe wallet also asks about the set itself:
getRevocationTimestamp(0xBf01…7253, attestationSetId) // expect 0Both must read 0. Revocation is one-way, so a non-zero value is conclusive forever.
Fetch the attestation blob.
attestationMirrorListUris = [ "ipfs://bafk…" ]A MirrorList URI resolves to one of two things, and the wallet does not know which in advance. So it applies one algorithm:
- Fetch the URI.
- If the bytes are the content you expect, use them.
- Otherwise, parse the bytes as an
index.jsonmanifest, look up your key, and fetch what it points at.
Here ipfs://bafk… is a directory CID, so route 3 applies. The manifest nests two levels:
{
"0x88e12c…09bd": {
"0x4dcb3375…0b9f": "blobs/88e12c-eas.json"
}
}Be aware that the wire format of index.json is not defined in the spec. Relative paths, the version field, and the behaviour on a missing key are all unstated.
Verify the blob.
Four checks, per ERC-8176 "Verifying an Attestation".
| Check | Against | Why it matters |
|---|---|---|
schema |
0xe023eef1…fafb5c2 |
A different schema is a different claim. |
data.descriptorHash |
0x0d7fad…d8a0 from step 3 |
The blob and the registry must name the same file. |
| recovered signer | 0xBf01…7253 |
This is where the trust actually sits. |
EAS revokeOffchain |
on chain 1 | The attester's second revocation channel. |
The third check is the one that matters. Everything before it narrows the question. This one answers it.
The fourth check is separate from step 4. The registry keeps its own revocation record, and EAS keeps its own timestamp log. An attester may write to one and forget the other. So a careful wallet reads both.
The wallet now holds a signed statement from an attester it trusts: the correct descriptor for this contract hashes to 0x0d7fad…d8a0.
Fetch the descriptor.
descriptorMirrorListUris = [
"ipfs://bafy…",
"https://cdn.attester.example/7730/"
]Same algorithm as step 5, with 0x0d7fad…d8a0 as the key. The wallet tries the URIs in order, and the two resolve by different routes.
ipfs://bafy… names one file. Route 2 applies. The CID resolves straight to the descriptor.
https://cdn.attester.example/7730/ names a directory. Route 3 applies. It returns a manifest:
{
"0x0d7fad…d8a0": "descriptors/uniswap-v3-router.json",
"0x41c9ba…6e17": "descriptors/aave-v3-pool.json"
}One URI then serves thousands of descriptors. That is why the container case exists.
Hash the file and compare.
keccak256(descriptor)
// computed 0x0d7fad…d8a0
// expected 0x0d7fad…d8a0 OKThree operations, in this order: Resolve every includes reference. Serialize with RFC 8785. Then hash.
Check the descriptor against the transaction.
| Check | Compares |
|---|---|
context.contract.deployments |
chain 1, address 0xE592…1564 |
$schema MAJOR |
1 against the 1 in the record |
| the function selector | 0x414bf389 against a display.formats key |
Render.
Swap 1,000 USDC for at least 0.31 ETH on Uniswap V3
- It's not possible to "publish" descriptors without an attestation.
- Anyone can attest with their address.
- Anyone can publish a MirrorList. The list is keyed by its content's hash.
- In practice wallets and vendors will likely use their own cache
- Cost: it's still non trivial to publish and update mirrors etc.
- It costs around 0.72 ETH to update 100k descriptors (at 1 gwei/gas)
- To write 100k new descriptors is 21.7 ETH (1 gwei/gas)
Given a
trustedAttesterand acontextKeyIDwhy am I getting theattestationsas a list inResolvedDescriptor? What would the other attestations imply?