hypercerts-org / hypercerts-org/hypercerts-lexicon
Support EVM-key attestations in `signatures[]` via `link.evm`
Nobody has claimed this yet.
- Dominant language
- TypeScript
- Stars
- 12
- Forks
- 8
- Avg merge
- 1h 9m
- Merged PRs (30d)
- 3
Description
Summary
PR #170 (prematurely merged in 478f5be, will be resubmitted) added optional cryptographic signatures[] arrays to every record lexicon, conforming to Nick Gerakines' ATProtocol Attestation Specification at badge.blue. The signing path that landed is the spec-canonical one: signatures verify against a public key resolved from the signer's DID document (did:plc / W3C-DID).
That path doesn't cover a use case the project clearly cares about: a user signing a Hypercerts record with the same EVM key they used to attest a DID↔wallet binding via app.certified.link.evm. This issue tracks closing that gap.
Why this matters
The PR's framing of link.evm explicitly opened the door for this: the EIP-712 proof field on link.evm proves wallet consent (the EVM key holder agreed to the binding); the signatures[] array proves record provenance (this record was minted by a known party). Both are useful, and they don't conflict. But provenance-by-EVM-key only works if a verifier can actually verify an EVM-keyed attestation, and the current verification path can't.
If we don't close this gap:
- Users who have already linked an EVM wallet (the natural Hypercerts demographic) can't reuse that key to sign records.
- Platform operators wanting to attest records via familiar EVM tooling (MetaMask, WalletConnect, hardware wallets) have no path.
- The "two orthogonal integrity primitives" story we documented on
link.evmis half-implemented.
What's blocking direct reuse
Three independent issues:
1. Verification architecture mismatch
The spec uses what we'll call Model A: verify against a published pubkey. Input is a did: reference resolvable to a DID document containing a verificationMethod with publicKeyMultibase. The multicodec prefix on the multibase blob picks the curve (0xE701 = K-256, 0x1200 = P-256; badge.blue additionally lists P-384). Verifier runs ECDSA_Verify(pubkey, hash(message), r, s) and returns a boolean. The signature is r || s; no recovery byte is needed because the pubkey is already known.
EVM uses Model B: recover the signer's identity from the signature itself. Input is an address (a 20-byte keccak hash of the pubkey), not a pubkey. ecrecover(hash(message), r, s, v) reconstructs one of two candidate pubkeys from (r, s) and derives its address. The recovery byte v (0 or 1, sometimes encoded as 27/28) disambiguates which of the two valid candidate points on the curve to pick. Verifier compares the recovered address to the known address. Standard EVM signatures are 65 bytes: r || s || v.
Both are full-strength ECDSA. The difference is where the pubkey comes from — resolved (Model A) versus recovered (Model B). A verifier supporting EVM keys needs a second code path implementing Model B, distinct from the spec's Model A.
2. Signature shape (the v byte is orthogonal to low-S normalization)
The spec mandates low-S normalization (BIP-0062) and is silent on recovery bytes. Low-S normalization solves signature malleability (without it, both (r, s) and (r, n − s) are valid for the same message+key pair). The v byte solves a different problem — disambiguating between the two candidate pubkeys that ecrecover could produce from a given (r, s). They address orthogonal concerns and are not substitutes.
Because the spec doesn't mention v, two coherent design choices exist for storing EVM-key attestations:
- Store 64 bytes (drop
v); EVM-aware verifier tries both candidate addresses and accepts a match against either. Costs one extraecrecoverper verification. Not weaker — the security depends on whether one of the two candidate addresses matches the known address, not on whichvhappens to be on the wire. - Store 65 bytes (
r || s || v) — wire-valid against oursignature: byteslexicon field (no length constraint), and what EVM tooling produces natively. Spec verifiers running pure Model A wouldn't recognise the extra byte, but they wouldn't be the audience for EVM-keyed attestations anyway. - Store 65 bytes via a sibling def — e.g.
app.certified.signature.defs#evmInline— that is explicitly Model-B-shaped and carriesvin a distinct field. Cleaner schema-level discriminator; adds a new lexicon def.
3. The signed payload differs
The spec says: ECDSA-sign the bare 36-byte CIDv1 of the record (with $sig repository binding inserted). Standard ECDSA libraries SHA-256-hash this input internally before signing.
EVM wallets (MetaMask, Coinbase Wallet, hardware wallets, WalletConnect) cannot sign arbitrary 36-byte payloads. The only modern, safe wallet-signing primitive is eth_signTypedData_v4, which signs keccak256("\x19\x01" || domainSeparator || hashStruct(typedMessage)) — an EIP-712 envelope around typed structured data.
So an EVM-key attestation can't sign the bare CID. It has to sign an EIP-712 wrapper containing the CID — e.g. a typed struct { recordCID: bytes32, repositoryDID: string } under a domain like HypercertsAttestation v1. That's a different signature target from the spec's bare-CID rule, and a verifier would need to know how to reconstruct the EIP-712 hash to verify.
The existing EIP-712 signature inside a link.evm record can not be re-used: it signs {did, evmAddress, chainId, timestamp, nonce} — a wallet-ownership assertion that says nothing about any specific Hypercerts record. A Hypercerts attestation by the same EVM key needs its own separately-produced EIP-712 signature; only the address is shared between the two.
Verifier flow (proposed)
For an EVM-key attestation entry in signatures[]:
- Read the inline signature's
keyfield. By some convention it indicates "this is an EVM-key attestation" — see "Design questions" below for options. - Resolve to the signer's EVM address (e.g. fetch the
link.evmrecord at the indicated URI and read itsaddressfield; or parse adid:pkh:eip155:DID directly). - Reconstruct the EIP-712 hash from the record's CID + the agreed-upon domain/struct.
ecrecover(eip712Hash, r, s, v)→ candidate address. Ifvnot stored, try bothv ∈ {0, 1}and accept either match.- Compare against the address from step 2. If equal, signature is valid.
This is a controlled divergence from the spec — Model A is what the spec describes, Model B becomes a second branch the verifier needs to know about. Worth proposing back upstream to Nick (e.g. as a did:pkh profile or an EVM-attestation variant), but we shouldn't block on that.
Design questions to settle before implementation
- Discriminator strategy. How does a verifier know to route to the EVM-key path?
did:pkh:eip155:1:0x…in thekeyfield (most spec-aligned; needsdid:pkhresolver).- A
link.evmstrongRef embedded in thekeyfield or a sibling field (most Hypercerts-native; couples the two records). - A separate
signature.defs#evmInlinevariant in the array union (cleanest schema-level signal; adds a lexicon def).
- Byte layout. Store 64 bytes (drop
v, try-both at verify) or 65 bytes (keepv, single recovery). See "Signature shape" above. - EIP-712 domain. Project-wide constant or per-record? What are the typed fields? Likely needs at minimum
recordCIDandrepositoryDIDto mirror the$sigbinding from Model A. - Forward compatibility. Does the new path also accommodate ERC-1271 (smart contract wallet signatures) and ERC-6492 (pre-deploy smart contract wallets), which
link.evm'sprooffield is already open to?
Acceptance criteria
A future implementer should:
- Decide on the discriminator strategy (one of the three above; document the trade-offs).
- Add the EVM-attestation variant (whether as a sibling def, a
did:pkhconvention, or otherwise) toapp.certified.signature.*. - Define the EIP-712 typed-data structure for record attestation (domain, struct shape, type hash).
- Add an end-to-end test (mirroring the existing
tests/validate-signing-example.test.tspattern) that signs a record with aviemaccount, packages the signature into asignatures[]entry, and verifies it viaecrecoveragainst the signer'slink.evmaddress. - Update README and the
building-with-hypercerts-lexiconsskill with an EVM-key signing example sitting alongside the existingdid:plcexample. - Decide whether to propose the extension back to Nick Gerakines' spec.
Out of scope for this issue
- ERC-1271 / ERC-6492 (smart-contract-wallet) attestations. Worth supporting eventually but distinct mechanics; raise separately if pursued.
- Verifying EVM signatures from chains other than mainnet. The
chainIdstory for attestation signatures is its own design question.
References
- PR #170 — landed the spec-canonical
did:plcsigning path; merge commit 478f5be. - ATProtocol Attestation Specification (badge.blue) — primary spec home, including the interactive browser-based demo and pointer to the Rust reference implementation.
- Spec mirror on Tangled — earlier revision; lists only P-256 and K-256.
- Accompanying blog post by Nick Gerakines — background and motivation.
app.certified.link.evmlexicon — the EIP-712 wallet-ownership integrity primitive this issue proposes to complement.- did:pkh method spec — relevant for option 1 of the discriminator strategy.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Assessment
This issue has not been assessed yet.