Skip to content

Instantly share code, notes, and snippets.

@kuzdogan
Last active September 6, 2026 19:22
Show Gist options
  • Select an option

  • Save kuzdogan/d07974040f505460e48c55e3badcece5 to your computer and use it in GitHub Desktop.

Select an option

Save kuzdogan/d07974040f505460e48c55e3badcece5 to your computer and use it in GitHub Desktop.
Clear Signing Onchain Registry Design Explainer

Clear Signing Onchain Registry Design Explainer

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 a MirrorList.
    • Mirrors are not attributed. Anyone can publish a MirrorList

Key points

Context Key ID

We identify each contract with the contextKeyId:

contextKeyId = keccak256(abi.encode(
    keccak256("erc7730.context.contract"),   // binding-type tag
    uint256(1),                              // chainId
    address(0xE592427A0AEce92De3Edee1F18E0157C05861564)
))

Attestations

Attestation

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

Attestation Set

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 2

Mirror Lists

A MirrorList is a list of URI strings that can hold an offchain .json:

mapping(bytes32 mirrorListId => string[]) mirrorLists

MirrorLists 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
    }
  ]
}

Namespacing

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.

Summary

In the end the only trust is on the attester key. Given a contract to clear sign and a trusted attester key:

  1. Ask the registry "Where can I find the descriptors and attestations for this contract?" (resolveDescriptors)
  2. Check if the attestation is revoked on the registry
  3. Check if the attestation is revoked onchain on EAS
  4. Resolve and find the full attestation from a mirror
  5. Check if the attestation signature is valid
  6. Check if the attestation.descriptorHash matches to registry.descriptorHash
  7. Resolve and find the descriptor from a mirror
  8. Check if the resolved descriptor blob's hash matches the attestation.descriptorHash
  9. Check the descriptor's context against the transaction
  10. Use the descriptor to decode

Example

User is signing a transaction. Three facts:

  • chainId: 1
  • contract address: 0xE592427A0AEce92De3Edee1F18E0157C05861564
  • trusted attester: 0xBf01daF454dce008d3E2bfD47d5e186F71477253

Step 1

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)
// = 0x959bd901638d8ee1e76dea1a698a13e183a561d825b897316e65191d38bc109b

Step 2

Resolve 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?
)

Step 3

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 } ]
}

Step 4

Check that the record is live.

The response carries a revokedAt for every attestation it returns. Zero means live.

"attestations": [ {, "revokedAt": 0 } ]   // live

The wallet also asks about the set itself:

getRevocationTimestamp(0xBf01…7253, attestationSetId)   // expect 0

Both must read 0. Revocation is one-way, so a non-zero value is conclusive forever.

Step 5

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:

  1. Fetch the URI.
  2. If the bytes are the content you expect, use them.
  3. Otherwise, parse the bytes as an index.json manifest, 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.

Step 6

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.

Step 7

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.

Step 8

Hash the file and compare.

keccak256(descriptor)
// computed  0x0d7fad…d8a0
// expected  0x0d7fad…d8a0   OK

Three operations, in this order: Resolve every includes reference. Serialize with RFC 8785. Then hash.

Step 9

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

Step 10

Render.

Swap 1,000 USDC for at least 0.31 ETH on Uniswap V3

Notes

  • 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)
@PrisionMike

PrisionMike commented Sep 6, 2026

Copy link
Copy Markdown

Given a trustedAttester and a contextKeyID why am I getting the attestations as a list in ResolvedDescriptor ? What would the other attestations imply?

@PrisionMike

Copy link
Copy Markdown

Step 6 feels a little unclear:

  1. Why is schema a hash string?
  2. recovered signer == attestor?
  3. Against: on chain 1?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment