From 7269695d6f15f51f823e4d9f18d8432e663274ea Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 08:09:28 +0000 Subject: [PATCH 1/2] Add ERC-8176 descriptor attestation support Wallets can now define which auditors (EAS attesters) they trust and format only through attested descriptors: - New attestations.ts: computeDescriptorHash (RFC 8785 JCS + keccak256 over the includes-resolved descriptor), verifyAttestation (offline EAS offchain attestation verification: canonical schema UID, attested hash, domain pin, expiration, ECDSA recovery, offchain UID recomputation), and the registry sigs/ path convention. - New attestations option on the resolver options: trustedAttesters plus a wallet-delegated checkRevocation callback. Enforced by applyAttestationPolicy in resolver.ts after includes resolution; failure surfaces as NO_TRUSTED_ATTESTATION / ATTESTATIONS_NOT_SUPPORTED. - GitHub and filesystem resolvers implement the optional DescriptorResolver.fetchAttestation; custom resolvers can supply their own. Bundled trusted-token descriptors stay exempt. - Tests against the registry's real Tether USD attestation plus test-key-signed edge cases; docs updated (README, GUIDE, AGENTS). Closes #61 Co-Authored-By: Manuel Wedler --- AGENTS.md | 64 +- GUIDE.md | 80 +- README.md | 37 +- package-lock.json | 16 + package.json | 1 + src/attestations.ts | 348 ++++++++ src/filesystem.ts | 20 +- src/github-registry-client.ts | 18 + src/index.ts | 5 + src/resolver.ts | 114 ++- src/types.ts | 131 ++- src/utils.ts | 51 +- test/attestations/attestations.spec.ts | 788 ++++++++++++++++++ test/attestations/calldata-usdt.json | 68 ++ ...6c3A30E62075Fa916216b35EF04B8F53931f6.json | 70 ++ test/github-registry-client.spec.ts | 46 + 16 files changed, 1835 insertions(+), 22 deletions(-) create mode 100644 src/attestations.ts create mode 100644 test/attestations/attestations.spec.ts create mode 100644 test/attestations/calldata-usdt.json create mode 100644 test/attestations/sigs/calldata-usdt.eip155-1-0x3846c3A30E62075Fa916216b35EF04B8F53931f6.json diff --git a/AGENTS.md b/AGENTS.md index 07fe881..1d2123f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,7 +18,8 @@ src/ ├── formatters.ts # Individual format handlers: renderField(), formatRaw(), etc. ├── calldata.ts # Calldata path: formatCalldata(), signature parsing, ABI decoding ├── eip712.ts # EIP-712 path: formatEip712(), encodeType matching, type resolution -├── resolver.ts # Descriptor lookup, includes resolution, and descriptor merging +├── resolver.ts # Descriptor lookup, includes resolution, descriptor merging, attestation policy +├── attestations.ts # ERC-8176: descriptor hash (JCS), offchain attestation verification, sigs/ paths ├── bundled-descriptors.ts # Bundled ERC-20/721 templates → buildBundledTokenDescriptor() ├── bundled/ # The bundled template descriptors as TS consts (erc20.ts, erc721.ts) ├── github-registry-client.ts # I/O layer: GitHub raw/API URL construction and fetch helpers @@ -81,6 +82,18 @@ src/ `extractPrimaryType` for `resolver.ts` and `github-registry-index.ts`; the rest is module-private. +- **`attestations.ts`** — ERC-8176 descriptor attestations. Exports + `computeDescriptorHash` (keccak256 of the RFC 8785 / JCS canonical JSON of the + includes-resolved descriptor), `verifyAttestation` (offline verification of an + EAS offchain attestation: canonical schema UID, attested hash, EIP-712 domain + pin to the mainnet EAS contract, expiration, ECDSA recovery, offchain UID + recomputation, and the wallet-delegated `checkRevocation` callback), and + `attestationPathForDescriptor` (the registry's + `/sigs/.eip155-1-.json` convention). The JCS + canonicalizer is module-private — for `JSON.parse` output it is exactly + `JSON.stringify` with recursively sorted keys. The trusted-attester policy + loop itself lives in `resolver.ts` (`applyAttestationPolicy`). + - **`bundled-descriptors.ts`** — Bundled ERC-20 / ERC-721 template descriptors (the registry's `calldata-erc20-tokens` / `calldata-erc721-nfts` files, transcribed as TS `const`s in `bundled/erc20.ts` and `bundled/erc721.ts`). Exports @@ -101,6 +114,10 @@ src/ → registry index lookup by (chainId, to); on a miss, if options.trustedTokens tags the contract, return buildBundledTokenDescriptor(standard, chainId, to) + → on an index hit, applyAttestationPolicy() — when options.attestations + is set, the resolved descriptor is only accepted with a valid + attestation from a trusted attester (bundled trusted-token + descriptors are exempt) → calldata.formatCalldata(tx, descriptor, externalDataProvider?) → findFormatBySelector() matches selector to a display.formats entry → decodeArguments() decodes calldata into { values, arrayLengths } maps @@ -117,6 +134,7 @@ src/ → looks up (chainId, verifyingContract, primaryType) in typedDataIndex → disambiguates entries by matching keccak256(encodeType) against each entry's encodeTypeHashes + → applyAttestationPolicy() — same attestation gating as the calldata path → eip712.formatEip712(typedData, descriptor, externalDataProvider?) → findFormatSpec() matches display.formats key via encodeType string → applyFieldFormats() (from fields.ts) renders each field @@ -163,6 +181,46 @@ different semantics (ERC-20 `value` → tokenAmount vs. ERC-721 `tokenId` → nf so the calldata alone cannot disambiguate them. Trust is delegated entirely to the wallet. +## Descriptor Attestations (ERC-8176) + +`descriptorResolverOptions.attestations` (type `AttestationOptions`, declared on +the shared `BaseResolverOptions`) turns on the ERC-8176 review gate: a resolved +registry descriptor is only accepted when one of `trustedAttesters` has a valid +EAS offchain attestation over it. Without the option, descriptors are used +unverified — README and GUIDE mark that mode as testing-only. + +Key mechanics: + +- The gate runs in `applyAttestationPolicy` (`resolver.ts`) **after** includes + resolution, because the ERC defines the descriptor hash over the fully + resolved descriptor: `keccak256(JCS(mergedDescriptor))` (`computeDescriptorHash`). +- Attestations are fetched per trusted attester via the optional + `DescriptorResolver.fetchAttestation(descriptorPath, checksummedAttester)`. + The GitHub resolver builds the registry `sigs/` path + (`attestationPathForDescriptor`) and treats HTTP 404 as "no attestation" + (`fetchOptionalRegistryFile`); the filesystem resolver treats ENOENT the same + way. A custom resolver without `fetchAttestation` fails every gated + resolution with `ATTESTATIONS_NOT_SUPPORTED`. +- `verifyAttestation` (`attestations.ts`) checks: schema is the canonical + ERC-8176 schema UID, attested `data` equals the computed descriptor hash, + EIP-712 domain pins the canonical EAS contract on Ethereum mainnet, + `expirationTime` (0 = never) has not passed, the signature recovers the + attester (EOA only), and the recomputed EAS v2 offchain UID matches the + declared one (a tampered uid would otherwise hide a revocation). Revocation + itself needs chain access, so it is delegated to the wallet via + `AttestationOptions.checkRevocation(attester, uid)` — same pattern as + `ExternalDataProvider`. +- Failure surfaces as a `NO_TRUSTED_ATTESTATION` warning (with per-attester + reasons in the message); `format()` then falls back to `rawCalldataFallback` + exactly like `NO_DESCRIPTOR`. Attestation fetch and `checkRevocation` I/O + errors still throw (→ `DESCRIPTOR_FETCH_ERROR` in `index.ts`), consistent + with descriptor fetching. +- Bundled trusted-token descriptors are **not** gated — `trustedTokens` trust + is already delegated to the wallet. Note the ordering: a registry index hit + that fails the attestation policy does _not_ fall back to `trustedTokens`. +- Only EAS offchain attestation version 2 with EOA signatures is supported. + Onchain attestations and ERC-1271 contract attesters are out of scope. + ## Descriptor Sources `FormatOptions.descriptorResolverOptions` is a discriminated union: @@ -191,7 +249,7 @@ const opts: FormatOptions = { **How it works:** -1. The public `resolveCalldataDescriptor` / `resolveTypedDataDescriptor` accept the same `GitHubResolverOptions | CustomResolverOptions` value that `FormatOptions.descriptorResolverOptions` carries. They build a `DescriptorResolver` (`{ index, fetchDescriptor }`) via the module-private `createResolver`. For `type: "github"` without an explicit `options.index`, `createResolver` calls `fetchPrebuiltRegistryIndex(source)` to fetch `index.calldata.json` and `index.eip712.json` in parallel. For `type: "custom"`, it returns `options.resolver` unchanged. **No internal caching** — every resolve call builds a fresh resolver, so callers should pre-fetch the index once and pass the same `descriptorResolverOptions.index` to every `format()` call. +1. The public `resolveCalldataDescriptor` / `resolveTypedDataDescriptor` accept the same `GitHubResolverOptions | CustomResolverOptions` value that `FormatOptions.descriptorResolverOptions` carries. They build a `DescriptorResolver` (`{ index, fetchDescriptor, fetchAttestation? }`) via the module-private `createResolver`. For `type: "github"` without an explicit `options.index`, `createResolver` calls `fetchPrebuiltRegistryIndex(source)` to fetch `index.calldata.json` and `index.eip712.json` in parallel. For `type: "custom"`, it returns `options.resolver` unchanged. **No internal caching** — every resolve call builds a fresh resolver, so callers should pre-fetch the index once and pass the same `descriptorResolverOptions.index` to every `format()` call. 2. The fetched index has two maps: - `calldataIndex: Record` — keyed by `context.contract.deployments[].{chainId, address}` - `typedDataIndex: Record>` — keyed by `context.eip712.deployments[].{chainId, address}`, then by primary type. Each entry carries the descriptor `path` and the keccak256 hashes of every `encodeType` it declares (`display.formats` keys), so multiple descriptors at the same `(chainId, verifyingContract, primaryType)` triple can be disambiguated at lookup time. @@ -506,6 +564,7 @@ Tests live in `test/`. Current test files: - `test/registry-cases/paraswap/paraswap.spec.ts` — Paraswap AugustusSwapper v6.2: RFQ batch fill (tuple array decoding) + BalancerV2 (dynamic bytes + byte range slices) - `test/registry-cases/zama/zama.spec.ts` — Zama ConfidentialWrapper: fhevm-encrypted `bytes32` amount handle decrypted via `resolveDecryptedValue` and rendered as a tokenAmount, plus plaintext-encoding edge cases (zero-padded ABI word, top-bit-set `uint64`, over-wide value) and both fallback paths — no provider, and a provider that declines - `test/bundled/trusted-tokens.spec.ts` — bundled ERC-20/721 descriptors via `trustedTokens`: standard tagging, selector collision, registry precedence +- `test/attestations/attestations.spec.ts` — ERC-8176 attestations against the registry's real Tether USD descriptor + attestation fixtures: descriptor hashing (JCS known answers), `verifyAttestation` edge cases via test-key-signed attestations (expired, revoked, tampered message/uid, wrong schema/hash/domain/version), and the trusted-attester policy end to end through `format()` / `resolveTypedDataDescriptor` (fallbacks, `ATTESTATIONS_NOT_SUPPORTED`, `trustedTokens` bypass, includes-resolved hashing) ### Test guidelines @@ -541,6 +600,7 @@ Output is ESM with TypeScript declarations. ## Dependencies - `@noble/hashes` — Keccak256 (browser + Node compatible) +- `@noble/curves` — secp256k1 signature recovery for attestation verification (browser + Node compatible) - `typescript` (dev) - `vitest` (dev) diff --git a/GUIDE.md b/GUIDE.md index fdb964d..f27c0f0 100644 --- a/GUIDE.md +++ b/GUIDE.md @@ -67,7 +67,72 @@ const trustedTokens: TrustedTokens = { const descriptorResolverOptions = { type: "github", index, trustedTokens }; ``` -## 4. Build the `ExternalDataProvider` +## 4. Set the attestation policy + +A descriptor controls what the user sees before they sign. A wrong or +malicious descriptor can hide the real effect of a transaction. For this +reason, do not use registry descriptors without a review check in production. + +[ERC-8176](https://github.com/ethereum/ERCs/pull/1576) supplies that check: +auditors review each descriptor and attest it with an EAS offchain +attestation. The registry stores the attestation files next to the descriptor. +The registry's [`auditors/`](https://github.com/ethereum/clear-signing-erc7730-registry/tree/master/auditors) +directory contains the audit guidelines and a profile for each auditor, keyed +by attester address. + +Select the auditors your wallet trusts and set the `attestations` policy on +the resolver options. The library then rejects every registry descriptor that +does not carry a valid attestation from one of them (the result gets a +`NO_TRUSTED_ATTESTATION` warning and, for calldata, the raw fallback): + +```typescript +const descriptorResolverOptions = { + type: "github", + index, + trustedTokens, + attestations: { + // Attester addresses from the registry's auditors/ directory, + // lowercase or EIP-55 checksummed. + trustedAttesters: ["0x3846c3A30E62075Fa916216b35EF04B8F53931f6"], + checkRevocation, + }, +}; +``` + +The library verifies each attestation offline: schema, descriptor hash, +expiration, and EIP-712 signature. Revocation state lives on the EAS contract +on Ethereum mainnet, so the wallet supplies that check — the same delegation +pattern as the `ExternalDataProvider`: + +```typescript +// The canonical EAS contract on Ethereum mainnet. +const EAS_ADDRESS = "0xA1207F3BBa224E2c9c3c6D5aF63D0eb1582Ce587"; + +const checkRevocation = async (attester: string, uid: string) => { + // getRevokeOffchain returns the revocation time, or 0 when not revoked. + const revokedAt = await readContract({ + address: EAS_ADDRESS, + abi: parseAbi([ + "function getRevokeOffchain(address revoker, bytes32 data) view returns (uint64)", + ]), + functionName: "getRevokeOffchain", + args: [attester, uid], + }); + return revokedAt !== 0n; +}; +``` + +When you omit `checkRevocation`, verification runs fully offline and revoked +attestations are not detected. Provide it in production. + +**Testing without attestations.** When you omit the `attestations` option +entirely, the library formats through unreviewed descriptors. Use that mode +only for testing — never in production. + +The `trustedTokens` list from §3 is not affected by the policy: the wallet +already vouches for those contracts directly. + +## 5. Build the `ExternalDataProvider` The library is agnostic about how external data is fetched. To resolve token metadata, address names, NFT collections, block timestamps, and chain info, the wallet supplies an `ExternalDataProvider` — an object of async methods backed by the sources the wallet already has (RPC, token list, address book, …). @@ -151,13 +216,14 @@ const externalDataProvider: ExternalDataProvider = { }, }; -// Combine with the resolver options from §2 and §3 into the FormatOptions object +// Combine with the resolver options from §2–§4 into the FormatOptions object // that's passed to every format call. const opts: FormatOptions = { descriptorResolverOptions: { type: "github", index, // from §2 trustedTokens, // from §3 + attestations, // from §4 }, externalDataProvider, }; @@ -169,7 +235,7 @@ To decrypt encrypted fields (`resolveDecryptedValue`), see [DECRYPTION.md](DECRYPTION.md). It covers the scheme-agnostic contract and `fhevm` (Zama Protocol), the only scheme ERC-7730 currently defines. -## 5. Call the format functions +## 6. Call the format functions Three entry points, all returning `DisplayModel` as the output: @@ -181,7 +247,7 @@ Three entry points, all returning `DisplayModel` as the output: Call them as soon as you have the request and before rendering the confirmation UI — they are async (descriptor fetch + external data resolution). -All three accept the same `opts` object (built in §4) as their optional second argument — reuse it across every call. +All three accept the same `opts` object (built in §5) as their optional second argument — reuse it across every call. ### `format` @@ -237,7 +303,7 @@ const batch: Eip5792Batch = { const display: BatchDisplayModel = await formatEip5792Batch(batch, opts); ``` -## 6. Render the `DisplayModel` +## 7. Render the `DisplayModel` The library returns a `DisplayModel`. Display its values to the user as the confirmation screen. The library never throws — failures surface as `warnings`. @@ -385,10 +451,10 @@ Surface warnings to the user. In most cases it's fine to just display the human- Warnings can appear at two levels: -- **`DisplayModel.warnings`** — affect the whole result. Examples: `NO_DESCRIPTOR` (no descriptor matched the transaction or typed data), `DESCRIPTOR_FETCH_ERROR` (the registry could not be reached), `INTERPOLATION_ERROR` (the interpolated intent template could not be rendered), `INVALID_CALLDATA_HEX` / `CALLDATA_DECODE_ERROR` (the calldata could not be parsed). +- **`DisplayModel.warnings`** — affect the whole result. Examples: `NO_DESCRIPTOR` (no descriptor matched the transaction or typed data), `NO_TRUSTED_ATTESTATION` (a descriptor matched, but no trusted auditor attested it — see §4), `DESCRIPTOR_FETCH_ERROR` (the registry could not be reached), `INTERPOLATION_ERROR` (the interpolated intent template could not be rendered), `INVALID_CALLDATA_HEX` / `CALLDATA_DECODE_ERROR` (the calldata could not be parsed). - **`DisplayField.warning`** / **`DisplayFieldGroup.warning`** — affect a single rendered value or group. Examples: `UNKNOWN_TOKEN` (`resolveToken` returned null), `UNKNOWN_ADDRESS` (no name resolved), `UNKNOWN_CHAIN` (`resolveChainInfo` returned null), `EMPTY_ARRAY` (an array argument was empty). If there is a warning, the field's `value` falls back to a raw representation; consider rendering a per-field badge or warning indicator. -When `DisplayModel.warnings` contains `NO_DESCRIPTOR` (calldata only), the model also carries a `rawCalldataFallback` with the function selector and raw ABI words — show it as a last-resort fallback so the user still sees _something_. +When `DisplayModel.warnings` contains `NO_DESCRIPTOR` or `NO_TRUSTED_ATTESTATION` (calldata only), the model also carries a `rawCalldataFallback` with the function selector and raw ABI words — show it as a last-resort fallback so the user still sees _something_. ### Grouping diff --git a/README.md b/README.md index 3c06be1..9e0c2e1 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ This library transforms raw transaction calldata and EIP-712 typed data into hum Designed to drop into wallet codebases: - Runs on modern browsers, Node.js (≥22), and React Native (ESM + CJS). -- Single runtime dependency: [`@noble/hashes`](https://github.com/paulmillr/noble-hashes). +- Two runtime dependencies: [`@noble/hashes`](https://github.com/paulmillr/noble-hashes) and [`@noble/curves`](https://github.com/paulmillr/noble-curves). - Pure formatting: No RPC client, no token/chain/ENS fetching; external data is delegated to the wallet via [`ExternalDataProvider`](#externaldataprovider). - No internal caching: The caller controls when descriptors and indexes are fetched. @@ -316,6 +316,41 @@ const result = await format(tx, { Trust is delegated entirely to the wallet. The library never decides which contracts are trustworthy. +### Attestations (ERC-8176) + +A descriptor controls what the user sees before they sign, so a wallet must not use unreviewed descriptors. [ERC-8176](https://github.com/ethereum/ERCs/pull/1576) adds a review layer: auditors attest each descriptor with an [EAS](https://attest.org) offchain attestation, and the registry stores the attestation files next to the descriptor (`registry//sigs/`). See the registry's [`auditors/`](https://github.com/ethereum/clear-signing-erc7730-registry/tree/master/auditors) directory for the audit guidelines and the auditor profiles. + +Add an `attestations` policy to `descriptorResolverOptions` to enforce this review layer. The library then accepts a resolved descriptor only when one of your trusted auditors has a valid attestation over it: + +```typescript +const result = await format(tx, { + descriptorResolverOptions: { + type: "github", + index, + attestations: { + // Auditor addresses your wallet trusts — see the registry's auditors/ directory. + trustedAttesters: ["0x3846c3A30E62075Fa916216b35EF04B8F53931f6"], + // Recommended: report revocations from the EAS contract. See GUIDE.md. + checkRevocation: async (attester, uid) => { + /* return true when getRevokeOffchain(attester, uid) is non-zero */ + return false; + }, + }, + }, +}); +``` + +For each resolved descriptor the library computes the ERC-8176 descriptor hash (keccak256 of the RFC 8785 canonical JSON of the includes-resolved descriptor), fetches each trusted attester's attestation from the registry's `sigs/` directory, and verifies it offline: canonical schema UID, attested hash, expiration, EIP-712 signature, and attestation UID. Revocation state lives on-chain, so that check is delegated to the wallet through `checkRevocation`. When no trusted attestation is valid, the result carries a `NO_TRUSTED_ATTESTATION` warning and (for calldata) the `rawCalldataFallback`. + +**Formatting without an `attestations` policy is for testing only.** Without it, the library uses registry descriptors without any review check. Do not ship that configuration in production. + +Notes: + +- Bundled trusted-token descriptors (`trustedTokens`) are not gated — the wallet already vouches for those contracts directly. +- A custom resolver must implement the optional `DescriptorResolver.fetchAttestation` to satisfy an attestation policy; otherwise every resolution fails with `ATTESTATIONS_NOT_SUPPORTED`. The GitHub and filesystem resolvers implement it out of the box. +- The building blocks are exported for custom flows: `computeDescriptorHash(descriptor)`, `verifyAttestation(attestation, descriptorHash, options?)`, and `attestationPathForDescriptor(descriptorPath, attester)`. +- Only EAS offchain attestations (version 2) with EOA signatures are supported. Onchain attestations and ERC-1271 contract attesters need chain access and are out of scope. + ### Display Model All format functions return a `DisplayModel`: diff --git a/package-lock.json b/package-lock.json index ee34b82..3ab04f4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.2.2", "license": "MIT", "dependencies": { + "@noble/curves": "^1.9.7", "@noble/hashes": "^1.7.1" }, "devDependencies": { @@ -703,6 +704,21 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@noble/curves": { + "version": "1.9.7", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz", + "integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@noble/hashes": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", diff --git a/package.json b/package.json index b84e334..1663c6e 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,7 @@ ], "license": "MIT", "dependencies": { + "@noble/curves": "^1.9.7", "@noble/hashes": "^1.7.1" }, "devDependencies": { diff --git a/src/attestations.ts b/src/attestations.ts new file mode 100644 index 0000000..90af68b --- /dev/null +++ b/src/attestations.ts @@ -0,0 +1,348 @@ +/** + * ERC-8176 descriptor attestations. + * + * Auditors attest ERC-7730 descriptors with EAS offchain attestations over a + * canonical `bytes32 descriptorHash` schema. This module implements the + * offline parts of the ERC-8176 verification procedure: descriptor hashing, + * attestation signature verification, and the registry's `sigs/` file + * convention. Revocation state lives on-chain, so checking it is delegated to + * the wallet via `AttestationOptions.checkRevocation`. + * + * See https://github.com/ethereum/ERCs/pull/1576 and the registry's + * `auditors/` directory for the audit process. + */ + +import { secp256k1 } from "@noble/curves/secp256k1"; +import type { + AttestationVerificationResult, + Descriptor, + OffchainAttestation, + OffchainAttestationDomain, + OffchainAttestationMessage, + OffchainAttestationSignature, +} from "./types.js"; +import { + bigIntToBytes, + bytesToHex, + coerceBigInt, + concatBytes, + hexToBytes, + keccak256, + normalizeAddress, + toChecksumAddress, + utf8ToBytes, +} from "./utils.js"; + +/** UID of the canonical ERC-8176 EAS schema (`bytes32 descriptorHash`). */ +const ERC8176_SCHEMA_UID = + "0xe023eef113c1670774801c34b377fdf612dd8a4d2fa92fe382e15bd91fafb5c2"; + +/** The canonical EAS contract the schema is registered on (Ethereum mainnet). */ +const EAS_MAINNET_ADDRESS = "0xa1207f3bba224e2c9c3c6d5af63d0eb1582ce587"; +const EAS_MAINNET_CHAIN_ID = 1; + +/** The EAS offchain attestation version ERC-8176 attestations use. */ +const OFFCHAIN_ATTESTATION_VERSION = 2; + +const EIP712_DOMAIN_TYPE = + "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"; +const ATTEST_TYPE = + "Attest(uint16 version,bytes32 schema,address recipient,uint64 time,uint64 expirationTime,bool revocable,bytes32 refUID,bytes data,bytes32 salt)"; + +/** + * Serialize a JSON value per RFC 8785 (JCS). For values produced by + * `JSON.parse` this is `JSON.stringify` with recursively sorted object keys — + * JCS defines number and string serialization by reference to ECMAScript's + * `JSON.stringify`, and its key order is the UTF-16 code unit order that + * plain string comparison yields. + */ +function canonicalizeJson(value: unknown): string { + if (Array.isArray(value)) { + return `[${value + .map((item) => (item === undefined ? "null" : canonicalizeJson(item))) + .join(",")}]`; + } + if (typeof value === "object" && value !== null) { + const entries = Object.entries(value) + .filter(([, v]) => v !== undefined) + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)); + return `{${entries + .map(([k, v]) => `${JSON.stringify(k)}:${canonicalizeJson(v)}`) + .join(",")}}`; + } + return JSON.stringify(value); +} + +/** + * Compute the ERC-8176 descriptor hash: keccak256 of the RFC 8785 (JCS) + * canonical JSON of the fully resolved descriptor, as a lowercase 0x-hex + * string. + * + * The hash is defined over the descriptor with its `includes` chain already + * resolved and merged — pass the descriptor that + * `resolveCalldataDescriptor` / `resolveTypedDataDescriptor` return, not a + * raw file that still carries an `includes` key. + */ +export function computeDescriptorHash(descriptor: Descriptor): string { + return bytesToHex(keccak256(utf8ToBytes(canonicalizeJson(descriptor)))); +} + +/** + * Path of the attestation file that `attester` publishes for the descriptor + * at `descriptorPath`, following the registry convention + * `/sigs/.eip155-1-.json`. The path is + * relative to the same root as `descriptorPath`. + * + * Accepts a lowercase or EIP-55 checksummed attester address; throws when the + * address is not a valid 20-byte hex address. + */ +export function attestationPathForDescriptor( + descriptorPath: string, + attester: string, +): string { + const address = hexToBytes(attester); + if (address.length !== 20) { + throw new Error(`Invalid attester address '${attester}'`); + } + const lastSlash = descriptorPath.lastIndexOf("/"); + const dir = descriptorPath.slice(0, lastSlash + 1); + const filename = descriptorPath.slice(lastSlash + 1); + const name = filename.endsWith(".json") ? filename.slice(0, -5) : filename; + return `${dir}sigs/${name}.eip155-${EAS_MAINNET_CHAIN_ID}-${toChecksumAddress(address)}.json`; +} + +/** + * Verify a single ERC-8176 offchain attestation against a descriptor hash + * (as computed by {@link computeDescriptorHash}). Follows the ERC's + * verification procedure: + * + * 1. the schema is the canonical ERC-8176 schema, + * 2. the attested data equals `descriptorHash`, + * 3. the EIP-712 domain pins the canonical EAS contract on Ethereum mainnet, + * 4. `expirationTime` (0 = never expires) has not passed, + * 5. the EIP-712 signature recovers the attester, + * 6. the recomputed offchain attestation UID matches the declared one, and + * 7. the attester has not revoked the attestation — checked through + * `options.checkRevocation` when provided, skipped otherwise. + * + * Returns the recovered attester on success. Whether that attester is + * trusted (the ERC's final step) is the caller's decision. + * + * Only EAS offchain attestation version 2 and EOA attesters are supported; + * ERC-1271 contract attesters and onchain attestations need chain access and + * are out of scope for this library. + */ +export async function verifyAttestation( + attestation: OffchainAttestation, + descriptorHash: string, + options?: { + checkRevocation?: (attester: string, uid: string) => Promise; + }, +): Promise { + const { domain, message, signature } = attestation.sig ?? {}; + if (!domain || !message || !signature) { + return { + reason: + "malformed attestation: sig.domain, sig.message, or sig.signature is missing", + }; + } + + if (message.version !== OFFCHAIN_ATTESTATION_VERSION) { + return { + reason: `unsupported offchain attestation version ${String(message.version)}`, + }; + } + if ( + typeof message.schema !== "string" || + message.schema.toLowerCase() !== ERC8176_SCHEMA_UID + ) { + return { + reason: `schema ${String(message.schema)} is not the canonical ERC-8176 schema`, + }; + } + if ( + typeof message.data !== "string" || + message.data.toLowerCase() !== descriptorHash.toLowerCase() + ) { + return { + reason: `attested descriptor hash ${String(message.data)} does not match the computed hash ${descriptorHash}`, + }; + } + if ( + typeof domain.name !== "string" || + typeof domain.version !== "string" || + Number(domain.chainId) !== EAS_MAINNET_CHAIN_ID || + typeof domain.verifyingContract !== "string" || + normalizeAddress(domain.verifyingContract) !== EAS_MAINNET_ADDRESS + ) { + return { + reason: + "attestation domain does not pin the canonical EAS contract on Ethereum mainnet", + }; + } + + const time = coerceBigInt(message.time); + const expirationTime = coerceBigInt(message.expirationTime); + if ( + time === undefined || + expirationTime === undefined || + typeof message.revocable !== "boolean" || + typeof message.recipient !== "string" || + typeof message.refUID !== "string" || + typeof message.salt !== "string" + ) { + return { reason: "malformed attestation: message fields are missing" }; + } + if ( + expirationTime !== 0n && + expirationTime <= BigInt(Math.floor(Date.now() / 1000)) + ) { + return { reason: `attestation expired at ${expirationTime}` }; + } + + let attester: string; + let uid: string; + try { + const digest = computeAttestDigest(domain, message, time, expirationTime); + attester = toChecksumAddress(recoverSigner(digest, signature)); + uid = computeOffchainUid(message, time, expirationTime); + } catch { + return { reason: "malformed attestation: invalid signature or encoding" }; + } + + const { signer } = attestation; + if ( + signer !== undefined && + (typeof signer !== "string" || + normalizeAddress(signer) !== normalizeAddress(attester)) + ) { + return { + reason: `signature recovers ${attester}, not the declared signer ${String(signer)}`, + }; + } + + const declaredUid = attestation.sig?.uid; + if ( + declaredUid !== undefined && + (typeof declaredUid !== "string" || declaredUid.toLowerCase() !== uid) + ) { + return { + reason: `declared attestation uid ${String(declaredUid)} does not match the computed uid ${uid}`, + }; + } + + if ( + options?.checkRevocation && + (await options.checkRevocation(attester, uid)) + ) { + return { reason: `attestation ${uid} was revoked by ${attester}` }; + } + + return { attester }; +} + +/** Decode a bytes32 hex string, throwing when it is not exactly 32 bytes. */ +function bytes32(hex: string): Uint8Array { + const bytes = hexToBytes(hex); + if (bytes.length !== 32) throw new Error("Expected 32 bytes"); + return bytes; +} + +/** ABI-encode an address as a 32-byte word (left-padded with zeros). */ +function addressWord(address: string): Uint8Array { + const bytes = hexToBytes(address); + if (bytes.length !== 20) throw new Error("Expected a 20-byte address"); + const word = new Uint8Array(32); + word.set(bytes, 12); + return word; +} + +/** Compute the EIP-712 signing digest of an EAS `Attest` message. */ +function computeAttestDigest( + domain: OffchainAttestationDomain, + message: OffchainAttestationMessage, + time: bigint, + expirationTime: bigint, +): Uint8Array { + const domainSeparator = keccak256( + concatBytes( + keccak256(utf8ToBytes(EIP712_DOMAIN_TYPE)), + keccak256(utf8ToBytes(domain.name ?? "")), + keccak256(utf8ToBytes(domain.version ?? "")), + bigIntToBytes(BigInt(domain.chainId ?? 0)), + addressWord(domain.verifyingContract ?? ""), + ), + ); + const structHash = keccak256( + concatBytes( + keccak256(utf8ToBytes(ATTEST_TYPE)), + bigIntToBytes(BigInt(message.version ?? 0)), + bytes32(message.schema ?? ""), + addressWord(message.recipient ?? ""), + bigIntToBytes(time), + bigIntToBytes(expirationTime), + bigIntToBytes(message.revocable ? 1n : 0n), + bytes32(message.refUID ?? ""), + keccak256(hexToBytes(message.data ?? "")), + bytes32(message.salt ?? ""), + ), + ); + return keccak256( + concatBytes(Uint8Array.from([0x19, 0x01]), domainSeparator, structHash), + ); +} + +/** Recover the 20-byte signer address of an ECDSA signature over `digest`. */ +function recoverSigner( + digest: Uint8Array, + signature: OffchainAttestationSignature, +): Uint8Array { + const { r, s, v } = signature; + if (typeof r !== "string" || typeof s !== "string" || typeof v !== "number") { + throw new Error("Missing signature components"); + } + const recovery = v >= 27 ? v - 27 : v; + const publicKey = secp256k1.Signature.fromCompact( + concatBytes(bigIntToBytes(BigInt(r)), bigIntToBytes(BigInt(s))), + ) + .addRecoveryBit(recovery) + .recoverPublicKey(digest) + .toRawBytes(false); + return keccak256(publicKey.subarray(1)).slice(-20); +} + +/** + * Recompute the deterministic EAS offchain attestation UID (version 2): + * keccak256 of the packed encoding + * `(uint16 version, bytes utf8(schema), address recipient, address attester, + * uint64 time, uint64 expirationTime, bool revocable, bytes32 refUID, + * bytes data, bytes32 salt, uint32 bump)` with the attester fixed to the + * zero address and bump fixed to 0, matching the EAS SDK. + * + * Recomputing (rather than trusting the declared `sig.uid`) matters for + * revocation: `getRevokeOffchain` is keyed by uid, so a tampered uid would + * otherwise hide an existing revocation. + */ +function computeOffchainUid( + message: OffchainAttestationMessage, + time: bigint, + expirationTime: bigint, +): string { + return bytesToHex( + keccak256( + concatBytes( + bigIntToBytes(BigInt(message.version ?? 0), 2), + utf8ToBytes(message.schema ?? ""), + hexToBytes(message.recipient ?? ""), + new Uint8Array(20), + bigIntToBytes(time, 8), + bigIntToBytes(expirationTime, 8), + Uint8Array.from([message.revocable ? 1 : 0]), + bytes32(message.refUID ?? ""), + hexToBytes(message.data ?? ""), + bytes32(message.salt ?? ""), + bigIntToBytes(0n, 4), + ), + ), + ); +} diff --git a/src/filesystem.ts b/src/filesystem.ts index f79aae8..185090c 100644 --- a/src/filesystem.ts +++ b/src/filesystem.ts @@ -23,7 +23,13 @@ */ import { readFile } from "node:fs/promises"; -import type { Descriptor, DescriptorResolver, RegistryIndex } from "./types.js"; +import { attestationPathForDescriptor } from "./attestations.js"; +import type { + Descriptor, + DescriptorResolver, + OffchainAttestation, + RegistryIndex, +} from "./types.js"; export type { DescriptorResolver }; @@ -59,5 +65,17 @@ export function createFilesystemResolver( const content = await readFile(filePath, "utf-8"); return JSON.parse(content) as Descriptor; }, + fetchAttestation: async (path, attester) => { + const filePath = `${options.descriptorDirectory}/${attestationPathForDescriptor(path, attester)}`; + let content: string; + try { + content = await readFile(filePath, "utf-8"); + } catch (error) { + // A missing sigs/ file means the attester published no attestation. + if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; + throw error; + } + return JSON.parse(content) as OffchainAttestation; + }, }; } diff --git a/src/github-registry-client.ts b/src/github-registry-client.ts index dc83c26..25016aa 100644 --- a/src/github-registry-client.ts +++ b/src/github-registry-client.ts @@ -78,3 +78,21 @@ export async function fetchRegistryFile( const url = `${rawBaseUrl(source)}/${repoRelativePath}`; return fetchJson(url); } + +/** + * Fetches a registry file that may not exist: returns null on HTTP 404, + * throws on any other failure. Used for attestation files — a missing + * `sigs/` file means the attester published no attestation. + */ +export async function fetchOptionalRegistryFile( + repoRelativePath: string, + source: GitHubSource, +): Promise { + const url = `${rawBaseUrl(source)}/${repoRelativePath}`; + const response = await fetch(url); + if (response.status === 404) return null; + if (!response.ok) { + throw new Error(`HTTP ${response.status} fetching ${url}`); + } + return response.json() as Promise; +} diff --git a/src/index.ts b/src/index.ts index 9522524..a849680 100644 --- a/src/index.ts +++ b/src/index.ts @@ -54,6 +54,11 @@ export { resolveTypedDataDescriptor, mergeDescriptors, } from "./resolver.js"; +export { + attestationPathForDescriptor, + computeDescriptorHash, + verifyAttestation, +} from "./attestations.js"; /** EIP-712 utility helpers. */ export const eip712 = { diff --git a/src/resolver.ts b/src/resolver.ts index 7757ea3..d81d00e 100644 --- a/src/resolver.ts +++ b/src/resolver.ts @@ -1,16 +1,24 @@ import { DEFAULT_REPO, DEFAULT_REF, + fetchOptionalRegistryFile, fetchRegistryFile, } from "./github-registry-client.js"; import { computeEncodeType } from "./eip712.js"; import { fetchPrebuiltRegistryIndex } from "./github-registry-index.js"; +import { + attestationPathForDescriptor, + computeDescriptorHash, + verifyAttestation, +} from "./attestations.js"; import type { + AttestationOptions, CustomResolverOptions, Descriptor, DescriptorResolver, GitHubResolverOptions, GitHubSource, + OffchainAttestation, TokenStandard, TrustedTokens, TypedData, @@ -56,6 +64,11 @@ async function createResolver( index, fetchDescriptor: async (path) => (await fetchRegistryFile(path, source)) as Descriptor, + fetchAttestation: async (path, attester) => + (await fetchOptionalRegistryFile( + attestationPathForDescriptor(path, attester), + source, + )) as OffchainAttestation | null, }; } } @@ -65,11 +78,15 @@ async function createResolver( * Resolves a calldata descriptor by `(chainId, contractAddress)`. Returns * a `{ descriptor }` envelope on success, or a `{ warning }` envelope when * resolution fails — `NO_DESCRIPTOR` when nothing is indexed for the pair, - * `CYCLIC_INCLUDES` when the `includes` chain self-references. + * `CYCLIC_INCLUDES` when the `includes` chain self-references, and + * `NO_TRUSTED_ATTESTATION` / `ATTESTATIONS_NOT_SUPPORTED` when an + * `options.attestations` policy is set but not satisfied. * * If no descriptor is indexed for the chain and address, the method also checks * the optional `options.trustedTokens` list for a matching trusted token. In case - * of a matching trusted token, a token descriptor is generated on the fly. + * of a matching trusted token, a token descriptor is generated on the fly. The + * attestation policy does not apply to these bundled descriptors — the wallet + * already vouches for the listed contracts directly. */ export async function resolveCalldataDescriptor( chainId: number, @@ -79,7 +96,15 @@ export async function resolveCalldataDescriptor( const resolver = await createResolver(options); const path = resolver.index.calldataIndex[`eip155:${chainId}:${normalizeAddress(to)}`]; - if (path) return resolveWithIncludes(resolver, path); + if (path) { + const resolved = await resolveWithIncludes(resolver, path); + return applyAttestationPolicy( + resolver, + path, + resolved, + options?.attestations, + ); + } // No registry descriptor. Check if a trusted token matches. const standard = lookupTrustedToken(options?.trustedTokens, chainId, to); @@ -119,7 +144,9 @@ function lookupTrustedToken( * picks the entry whose `encodeTypeHashes` contain the keccak256 hash of * the message's EIP-712 `encodeType` string. Returns `NO_DESCRIPTOR` if no * candidate matches, `CYCLIC_INCLUDES` if the `includes` chain self-references, - * or `{ descriptor }` on success. + * `NO_TRUSTED_ATTESTATION` / `ATTESTATIONS_NOT_SUPPORTED` if an + * `options.attestations` policy is set but not satisfied, or `{ descriptor }` + * on success. */ export async function resolveTypedDataDescriptor( typedData: TypedData, @@ -147,7 +174,84 @@ export async function resolveTypedDataDescriptor( const match = entries.find((e) => e.encodeTypeHashes.includes(hash)); if (!match) return noDescriptorWarning(chainId, verifyingContract); - return resolveWithIncludes(resolver, match.path); + const resolved = await resolveWithIncludes(resolver, match.path); + return applyAttestationPolicy( + resolver, + match.path, + resolved, + options?.attestations, + ); +} + +/** + * Enforces an ERC-8176 attestation policy on a resolved descriptor: the + * descriptor is accepted only when at least one of the policy's + * `trustedAttesters` has a valid attestation over its resolved + * (includes-merged) content. Passes the result through unchanged when no + * policy is set or resolution already failed. + * + * Returns an `ATTESTATIONS_NOT_SUPPORTED` warning when the resolver does not + * implement `fetchAttestation`, and `NO_TRUSTED_ATTESTATION` (with the + * per-attester failure reasons in the message) when no trusted attestation + * verifies. Attestation fetch and `checkRevocation` I/O errors still throw, + * consistent with descriptor fetching. + */ +async function applyAttestationPolicy( + resolver: DescriptorResolver, + path: string, + resolved: ResolveDescriptorResult, + options: AttestationOptions | undefined, +): Promise { + if (!options || "warning" in resolved) return resolved; + + if (!resolver.fetchAttestation) { + return { + warning: warn( + "ATTESTATIONS_NOT_SUPPORTED", + "An attestation policy is set but the descriptor resolver does not implement fetchAttestation", + ), + }; + } + + const descriptorHash = computeDescriptorHash(resolved.descriptor); + const failures: string[] = []; + + for (const trusted of options.trustedAttesters) { + let attester: string; + try { + attester = toChecksumAddress(hexToBytes(trusted)); + } catch { + failures.push(`invalid attester address '${trusted}'`); + continue; + } + + const attestation = await resolver.fetchAttestation(path, attester); + if (!attestation) continue; + + const result = await verifyAttestation(attestation, descriptorHash, { + checkRevocation: options.checkRevocation, + }); + if ("reason" in result) { + failures.push(`${attester}: ${result.reason}`); + continue; + } + if (normalizeAddress(result.attester) !== normalizeAddress(attester)) { + failures.push( + `${attester}: attestation was signed by ${result.attester}`, + ); + continue; + } + + return resolved; + } + + const detail = failures.length > 0 ? ` (${failures.join("; ")})` : ""; + return { + warning: warn( + "NO_TRUSTED_ATTESTATION", + `No valid attestation from a trusted attester found for descriptor '${path}'${detail}`, + ), + }; } function noDescriptorWarning( diff --git a/src/types.ts b/src/types.ts index 5153d07..c3cd563 100644 --- a/src/types.ts +++ b/src/types.ts @@ -105,7 +105,9 @@ export type WarningCode = | "BATCH_CONTRACT_CREATION" | "BATCH_INTERPOLATION_INCOMPLETE" | "BATCH_EMPTY" - | "CYCLIC_INCLUDES"; + | "CYCLIC_INCLUDES" + | "NO_TRUSTED_ATTESTATION" + | "ATTESTATIONS_NOT_SUPPORTED"; /** Warning from formatting. */ export interface Warning { @@ -473,6 +475,33 @@ export interface TrustedTokens { [chainId: number]: { [tokenAddress: string]: TokenStandard }; } +/** + * ERC-8176 attestation policy. Controls which auditors (EAS attesters) the + * wallet trusts to have reviewed descriptors. + */ +export interface AttestationOptions { + /** + * Addresses of the attesters (auditors) whose attestations the wallet + * trusts. A descriptor is only accepted when at least one listed attester + * has a valid attestation over it. + * + * Both lowercase and EIP-55 checksummed addresses are accepted. + */ + trustedAttesters: string[]; + + /** + * Checks whether the attester revoked the attestation. Revocation state + * lives on the canonical EAS contract on Ethereum mainnet + * (0xA1207F3BBa224E2c9c3c6D5aF63D0eb1582Ce587): call + * `getRevokeOffchain(attester, uid)` and return true when the result is + * non-zero (revoked). + * + * When omitted, verification runs fully offline and revocations are not + * detected. Production wallets should provide this check. + */ + checkRevocation?: (attester: string, uid: string) => Promise; +} + export interface BaseResolverOptions { /** * Wallet-provided trusted token list. Used to generate descriptors for tokens @@ -483,6 +512,23 @@ export interface BaseResolverOptions { * Both lowercase and checksummed token addresses are accepted. */ trustedTokens?: TrustedTokens; + + /** + * ERC-8176 attestation policy. When set, a resolved registry descriptor is + * only used when one of the listed trusted attesters has a valid + * attestation over it; otherwise resolution fails with a + * `NO_TRUSTED_ATTESTATION` warning (or `ATTESTATIONS_NOT_SUPPORTED` when + * the resolver cannot fetch attestations). + * + * When omitted, descriptors are used without any attestation check. That + * mode is intended for testing only — production wallets should always set + * an attestation policy. + * + * Bundled trusted-token descriptors (see {@link BaseResolverOptions.trustedTokens}) + * are not subject to this policy: the wallet already vouches for those + * contracts directly. + */ + attestations?: AttestationOptions; } export type GitHubResolverOptions = BaseResolverOptions & { @@ -521,6 +567,24 @@ export type CustomResolverOptions = BaseResolverOptions & { export interface DescriptorResolver { index: RegistryIndex; fetchDescriptor: (path: string) => Promise; + + /** + * Fetches the ERC-8176 offchain attestation issued by `attester` for the + * descriptor at `descriptorPath` (the same path `fetchDescriptor` receives). + * `attester` is passed as an EIP-55 checksummed address. Return `null` when + * the attester published no attestation for the descriptor; throw on I/O + * failures. + * + * Optional — but a resolver without it cannot satisfy an + * {@link AttestationOptions} policy, so every resolution then fails with + * `ATTESTATIONS_NOT_SUPPORTED`. The built-in GitHub and filesystem + * resolvers implement it via the registry's `sigs/` file convention (see + * `attestationPathForDescriptor`). + */ + fetchAttestation?: ( + descriptorPath: string, + attester: string, + ) => Promise; } export interface RegistryIndex { @@ -574,6 +638,71 @@ export interface GitHubSource { ref: string; } +/** EIP-712 domain of an EAS offchain attestation. */ +export interface OffchainAttestationDomain { + name?: string; + version?: string; + chainId?: string | number; + verifyingContract?: string; +} + +/** The signed `Attest` message of an EAS offchain attestation. */ +export interface OffchainAttestationMessage { + version?: number; + /** EAS schema UID (bytes32 hex). */ + schema?: string; + recipient?: string; + /** Unix seconds; EAS serializes uint64 values as decimal strings. */ + time?: string | number; + /** Unix seconds, or 0 for a non-expiring attestation. */ + expirationTime?: string | number; + revocable?: boolean; + refUID?: string; + /** Schema-encoded payload — for ERC-8176 the bytes32 descriptor hash. */ + data?: string; + salt?: string; +} + +/** ECDSA signature components of an EAS offchain attestation. */ +export interface OffchainAttestationSignature { + v?: number; + r?: string; + s?: string; +} + +/** The `sig` envelope of an EAS offchain attestation. */ +export interface OffchainAttestationSig { + version?: number; + /** Deterministic offchain attestation UID (bytes32 hex). */ + uid?: string; + domain?: OffchainAttestationDomain; + primaryType?: string; + types?: Record; + message?: OffchainAttestationMessage; + signature?: OffchainAttestationSignature; +} + +/** + * An EAS offchain attestation, as stored in the registry's `sigs/` + * directories per ERC-8176. Like {@link Descriptor}, all fields are optional: + * attestation files come from external sources with no runtime schema + * validation, so readers must guard every access. + */ +export interface OffchainAttestation { + sig?: OffchainAttestationSig; + /** The attester address the file declares. The signature is authoritative. */ + signer?: string; +} + +/** + * Result of verifying a single offchain attestation: the recovered attester + * address (EIP-55 checksummed) on success, or a human-readable failure + * reason. Whether the attester is trusted is the caller's decision. + */ +export type AttestationVerificationResult = + | { attester: string } + | { reason: string }; + export type DescriptorFieldFormatType = | "raw" | "amount" diff --git a/src/utils.ts b/src/utils.ts index 96c12e0..779249d 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -46,6 +46,47 @@ export function asciiToBytes(str: string): Uint8Array { return bytes; } +/** Encode a string as UTF-8 bytes without relying on TextEncoder (React Native compatible). */ +export function utf8ToBytes(str: string): Uint8Array { + const bytes: number[] = []; + for (const char of str) { + // for..of iterates code points, so surrogate pairs arrive combined; + // an unpaired surrogate is replaced with U+FFFD like TextEncoder does. + let code = char.codePointAt(0) ?? 0; + if (code >= 0xd800 && code <= 0xdfff) code = 0xfffd; + if (code < 0x80) { + bytes.push(code); + } else if (code < 0x800) { + bytes.push(0xc0 | (code >> 6), 0x80 | (code & 0x3f)); + } else if (code < 0x10000) { + bytes.push( + 0xe0 | (code >> 12), + 0x80 | ((code >> 6) & 0x3f), + 0x80 | (code & 0x3f), + ); + } else { + bytes.push( + 0xf0 | (code >> 18), + 0x80 | ((code >> 12) & 0x3f), + 0x80 | ((code >> 6) & 0x3f), + 0x80 | (code & 0x3f), + ); + } + } + return new Uint8Array(bytes); +} + +/** Concatenate multiple Uint8Arrays into one. */ +export function concatBytes(...arrays: Uint8Array[]): Uint8Array { + const result = new Uint8Array(arrays.reduce((sum, a) => sum + a.length, 0)); + let offset = 0; + for (const array of arrays) { + result.set(array, offset); + offset += array.length; + } + return result; +} + /** Convert hex string to bytes. */ export function hexToBytes(hex: string): Uint8Array { const cleaned = hex.startsWith("0x") ? hex.slice(2) : hex; @@ -176,12 +217,12 @@ export function boolToBytes(value: boolean): Uint8Array { return new Uint8Array([value ? 1 : 0]); } -/** Convert a bigint to a 32-byte big-endian Uint8Array (two's complement for negative values). */ -export function bigIntToBytes(value: bigint): Uint8Array { - const bytes = new Uint8Array(32); +/** Convert a bigint to a big-endian Uint8Array of `byteLength` bytes (default 32; two's complement for negative values). */ +export function bigIntToBytes(value: bigint, byteLength = 32): Uint8Array { + const bytes = new Uint8Array(byteLength); let n = value; - if (n < 0n) n = (1n << 256n) + n; - for (let i = 31; i >= 0; i--) { + if (n < 0n) n = (1n << BigInt(byteLength * 8)) + n; + for (let i = byteLength - 1; i >= 0; i--) { bytes[i] = Number(n & 0xffn); n >>= 8n; } diff --git a/test/attestations/attestations.spec.ts b/test/attestations/attestations.spec.ts new file mode 100644 index 0000000..020f5ae --- /dev/null +++ b/test/attestations/attestations.spec.ts @@ -0,0 +1,788 @@ +/** + * ERC-8176 descriptor attestations: descriptor hashing (RFC 8785 JCS + + * keccak256), offchain attestation verification, and the trusted-attester + * policy on the descriptor resolvers. + * + * The fixtures are the registry's Tether USD descriptor (`calldata-usdt.json`) + * and its real attestation from the registry's `sigs/` directory, so the hash + * and signature checks run against production data. Edge cases use + * attestations generated with a test key via an independent mirror of the EAS + * EIP-712 encoding. + */ + +import { readFileSync } from "node:fs"; +import { describe, it, expect, assert, vi } from "vitest"; +import { secp256k1 } from "@noble/curves/secp256k1"; +import { + attestationPathForDescriptor, + computeDescriptorHash, + format, + isFieldGroup, + resolveCalldataDescriptor, + resolveTypedDataDescriptor, + verifyAttestation, +} from "../../src/index.js"; +import type { + AttestationOptions, + Descriptor, + DescriptorResolver, + DisplayModel, + ExternalDataProvider, + FormatOptions, + OffchainAttestation, + OffchainAttestationDomain, + OffchainAttestationMessage, + TrustedTokens, +} from "../../src/types.js"; +import { + asciiToBytes, + bigIntToBytes, + bytesToHex, + concatBytes, + hexToBytes, + keccak256, + toChecksumAddress, + utf8ToBytes, +} from "../../src/utils.js"; +import { buildFilesystemResolverOpts } from "../utils.js"; + +const CHAIN_ID = 1; +const USDT = "0xdAC17F958D2ee523a2206206994597C13D831ec7"; +const CYFRIN_ATTESTER = "0x3846c3A30E62075Fa916216b35EF04B8F53931f6"; +const ALICE = "0x1234567890abcdef1234567890abcdef12345678"; + +const checksum = (addr: string) => toChecksumAddress(hexToBytes(addr)); +const word = (hex: string) => hex.padStart(64, "0"); +const addrWord = (addr: string) => word(addr.slice(2).toLowerCase()); + +const descriptor = JSON.parse( + readFileSync(`${__dirname}/calldata-usdt.json`, "utf-8"), +) as Descriptor; +const registryAttestation = JSON.parse( + readFileSync( + `${__dirname}/sigs/calldata-usdt.eip155-1-${CYFRIN_ATTESTER}.json`, + "utf-8", + ), +) as OffchainAttestation; +const descriptorHash = computeDescriptorHash(descriptor); + +// --------------------------------------------------------------------------- +// Test attestation generation — an independent mirror of the EAS EIP-712 +// encoding and offchain UID derivation, signed with a fixed test key. +// --------------------------------------------------------------------------- + +const SCHEMA_UID = + "0xe023eef113c1670774801c34b377fdf612dd8a4d2fa92fe382e15bd91fafb5c2"; +const ATTEST_TYPE = + "Attest(uint16 version,bytes32 schema,address recipient,uint64 time,uint64 expirationTime,bool revocable,bytes32 refUID,bytes data,bytes32 salt)"; +const EIP712_DOMAIN_TYPE = + "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"; +const EAS_DOMAIN: OffchainAttestationDomain = { + name: "EAS Attestation", + version: "0.26", + chainId: "1", + verifyingContract: "0xA1207F3BBa224E2c9c3c6D5aF63D0eb1582Ce587", +}; +const FAR_FUTURE = "253402300799"; // 9999-12-31T23:59:59Z + +const TEST_PRIVATE_KEY = hexToBytes("0x" + "01".repeat(32)); +const TEST_ATTESTER = toChecksumAddress( + keccak256(secp256k1.getPublicKey(TEST_PRIVATE_KEY, false).subarray(1)).slice( + -20, + ), +); + +function attestMessage( + dataHash: string, + overrides: Partial = {}, +): OffchainAttestationMessage { + return { + version: 2, + schema: SCHEMA_UID, + recipient: "0x0000000000000000000000000000000000000000", + time: "1785288536", + expirationTime: "0", + revocable: true, + refUID: "0x" + "00".repeat(32), + data: dataHash, + salt: "0x" + "11".repeat(32), + ...overrides, + }; +} + +function leftPadAddress(address: string): Uint8Array { + return concatBytes(new Uint8Array(12), hexToBytes(address)); +} + +function attestDigest( + domain: OffchainAttestationDomain, + message: OffchainAttestationMessage, +): Uint8Array { + const domainSeparator = keccak256( + concatBytes( + keccak256(utf8ToBytes(EIP712_DOMAIN_TYPE)), + keccak256(utf8ToBytes(domain.name ?? "")), + keccak256(utf8ToBytes(domain.version ?? "")), + bigIntToBytes(BigInt(domain.chainId ?? 0)), + leftPadAddress(domain.verifyingContract ?? ""), + ), + ); + const structHash = keccak256( + concatBytes( + keccak256(utf8ToBytes(ATTEST_TYPE)), + bigIntToBytes(BigInt(message.version ?? 0)), + hexToBytes(message.schema ?? ""), + leftPadAddress(message.recipient ?? ""), + bigIntToBytes(BigInt(message.time ?? 0)), + bigIntToBytes(BigInt(message.expirationTime ?? 0)), + bigIntToBytes(message.revocable ? 1n : 0n), + hexToBytes(message.refUID ?? ""), + keccak256(hexToBytes(message.data ?? "")), + hexToBytes(message.salt ?? ""), + ), + ); + return keccak256( + concatBytes(Uint8Array.from([0x19, 0x01]), domainSeparator, structHash), + ); +} + +function offchainUid(message: OffchainAttestationMessage): string { + return bytesToHex( + keccak256( + concatBytes( + bigIntToBytes(BigInt(message.version ?? 0), 2), + utf8ToBytes(message.schema ?? ""), + hexToBytes(message.recipient ?? ""), + new Uint8Array(20), // attester placeholder — always zero offchain + bigIntToBytes(BigInt(message.time ?? 0), 8), + bigIntToBytes(BigInt(message.expirationTime ?? 0), 8), + Uint8Array.from([message.revocable ? 1 : 0]), + hexToBytes(message.refUID ?? ""), + hexToBytes(message.data ?? ""), + hexToBytes(message.salt ?? ""), + new Uint8Array(4), // bump + ), + ), + ); +} + +function signAttestation( + message: OffchainAttestationMessage, + domain: OffchainAttestationDomain = EAS_DOMAIN, +): OffchainAttestation { + const signature = secp256k1.sign( + attestDigest(domain, message), + TEST_PRIVATE_KEY, + ); + return { + sig: { + version: 2, + uid: offchainUid(message), + domain, + primaryType: "Attest", + message, + signature: { + v: 27 + (signature.recovery ?? 0), + r: bytesToHex(bigIntToBytes(signature.r)), + s: bytesToHex(bigIntToBytes(signature.s)), + }, + }, + signer: TEST_ATTESTER, + }; +} + +// --------------------------------------------------------------------------- +// computeDescriptorHash +// --------------------------------------------------------------------------- + +describe("computeDescriptorHash", () => { + it("matches the descriptor hash attested in the registry", () => { + expect(descriptorHash).toBe(registryAttestation.sig?.message?.data); + }); + + it("canonicalizes per RFC 8785: sorted keys, JS numbers, UTF-8 strings", () => { + // Known answer computed with an independent JCS implementation. Exercises + // key sorting, number formatting (1e21 → "1e+21"), null/bool literals, + // and 2-, 3-, and 4-byte UTF-8 sequences. + const value = { + b: 2, + a: ["x", 1.5, null, true], + unicode: "Dürer ☺ 𝄞", + nested: { z: 1e21, y: 10000000 }, + } as Descriptor; + expect(computeDescriptorHash(value)).toBe( + "0xfccf25db4416defbd0c33f31039232d791df215ab38e2ebe1e2a761fb4f1ba24", + ); + }); + + it("is independent of object key order", () => { + const reordered = { + display: descriptor.display, + metadata: descriptor.metadata, + context: descriptor.context, + $schema: descriptor.$schema, + } as Descriptor; + expect(computeDescriptorHash(reordered)).toBe(descriptorHash); + }); +}); + +// --------------------------------------------------------------------------- +// attestationPathForDescriptor +// --------------------------------------------------------------------------- + +describe("attestationPathForDescriptor", () => { + it("builds the sigs/ path next to the descriptor", () => { + expect( + attestationPathForDescriptor( + "registry/tether/calldata-usdt.json", + CYFRIN_ATTESTER, + ), + ).toBe( + `registry/tether/sigs/calldata-usdt.eip155-1-${CYFRIN_ATTESTER}.json`, + ); + }); + + it("checksums a lowercase attester address", () => { + expect( + attestationPathForDescriptor( + "calldata-usdt.json", + CYFRIN_ATTESTER.toLowerCase(), + ), + ).toBe(`sigs/calldata-usdt.eip155-1-${CYFRIN_ATTESTER}.json`); + }); + + it("throws on an invalid attester address", () => { + expect(() => + attestationPathForDescriptor("calldata-usdt.json", "0x1234"), + ).toThrow(/Invalid attester address/); + }); +}); + +// --------------------------------------------------------------------------- +// verifyAttestation +// --------------------------------------------------------------------------- + +describe("verifyAttestation", () => { + it("verifies the real registry attestation and recovers the attester", async () => { + const result = await verifyAttestation(registryAttestation, descriptorHash); + expect(result).toEqual({ attester: CYFRIN_ATTESTER }); + }); + + it("verifies a generated attestation with a bounded expiration", async () => { + const attestation = signAttestation( + attestMessage(descriptorHash, { expirationTime: FAR_FUTURE }), + ); + const result = await verifyAttestation(attestation, descriptorHash); + expect(result).toEqual({ attester: TEST_ATTESTER }); + }); + + it("accepts an attestation without the optional uid and signer fields", async () => { + const attestation = signAttestation(attestMessage(descriptorHash)); + delete attestation.sig?.uid; + delete attestation.signer; + const result = await verifyAttestation(attestation, descriptorHash); + expect(result).toEqual({ attester: TEST_ATTESTER }); + }); + + it("passes the recovered attester and recomputed uid to checkRevocation", async () => { + const checkRevocation = vi.fn(async () => false); + const result = await verifyAttestation( + registryAttestation, + descriptorHash, + { + checkRevocation, + }, + ); + expect(result).toEqual({ attester: CYFRIN_ATTESTER }); + expect(checkRevocation).toHaveBeenCalledExactlyOnceWith( + CYFRIN_ATTESTER, + registryAttestation.sig?.uid, + ); + }); + + it("rejects a revoked attestation", async () => { + const result = await verifyAttestation( + registryAttestation, + descriptorHash, + { + checkRevocation: async () => true, + }, + ); + assert("reason" in result); + expect(result.reason).toContain("revoked"); + }); + + it("rejects an expired attestation", async () => { + const attestation = signAttestation( + attestMessage(descriptorHash, { expirationTime: "1000000000" }), + ); + const result = await verifyAttestation(attestation, descriptorHash); + assert("reason" in result); + expect(result.reason).toContain("expired"); + }); + + it("rejects a non-canonical schema", async () => { + const attestation = signAttestation( + attestMessage(descriptorHash, { schema: "0x" + "ab".repeat(32) }), + ); + const result = await verifyAttestation(attestation, descriptorHash); + assert("reason" in result); + expect(result.reason).toContain("not the canonical ERC-8176 schema"); + }); + + it("rejects when the attested hash differs from the computed hash", async () => { + const otherHash = "0x" + "cd".repeat(32); + const attestation = signAttestation(attestMessage(otherHash)); + const result = await verifyAttestation(attestation, descriptorHash); + assert("reason" in result); + expect(result.reason).toContain( + `attested descriptor hash ${otherHash} does not match`, + ); + }); + + it("rejects an unsupported offchain attestation version", async () => { + const attestation = signAttestation( + attestMessage(descriptorHash, { version: 1 }), + ); + const result = await verifyAttestation(attestation, descriptorHash); + assert("reason" in result); + expect(result.reason).toContain( + "unsupported offchain attestation version 1", + ); + }); + + it("rejects a domain that is not the canonical EAS contract on mainnet", async () => { + const attestation = signAttestation(attestMessage(descriptorHash), { + ...EAS_DOMAIN, + chainId: "11155111", + }); + const result = await verifyAttestation(attestation, descriptorHash); + assert("reason" in result); + expect(result.reason).toContain("canonical EAS contract"); + }); + + it("rejects a tampered message (signature recovers a different address)", async () => { + const attestation = signAttestation(attestMessage(descriptorHash)); + assert(attestation.sig?.message); + attestation.sig.message.time = "1785288537"; // +1s after signing + // Keep the uid consistent with the tampered message so the signature + // check is what fails. + attestation.sig.uid = offchainUid(attestation.sig.message); + const result = await verifyAttestation(attestation, descriptorHash); + assert("reason" in result); + expect(result.reason).toContain(`not the declared signer ${TEST_ATTESTER}`); + }); + + it("rejects a tampered uid", async () => { + const attestation = signAttestation(attestMessage(descriptorHash)); + assert(attestation.sig); + attestation.sig.uid = "0x" + "ee".repeat(32); + const result = await verifyAttestation(attestation, descriptorHash); + assert("reason" in result); + expect(result.reason).toContain("does not match the computed uid"); + }); + + it("rejects a structurally incomplete attestation", async () => { + const result = await verifyAttestation( + { signer: TEST_ATTESTER }, + descriptorHash, + ); + assert("reason" in result); + expect(result.reason).toContain("malformed attestation"); + }); +}); + +// --------------------------------------------------------------------------- +// Attestation policy — end to end through format() +// --------------------------------------------------------------------------- + +const externalData: ExternalDataProvider = { + resolveToken: async (chainId, address) => + chainId === CHAIN_ID && address === USDT.toLowerCase() + ? { name: "Tether USD", symbol: "USDT", decimals: 6 } + : null, + resolveLocalName: async (address) => + address.toLowerCase() === ALICE.toLowerCase() + ? { name: "Alice", typeMatch: true } + : null, +}; + +const TRANSFER = "0xa9059cbb" + addrWord(ALICE) + word("f4240"); // 1 USDT + +function tetherOpts(attestations?: AttestationOptions): FormatOptions { + const fsOpts = buildFilesystemResolverOpts( + __dirname, + { + calldataDescriptorFiles: [ + { chainId: CHAIN_ID, address: USDT, file: "calldata-usdt.json" }, + ], + }, + externalData, + ); + if (!attestations) return fsOpts; + const resolverOptions = fsOpts.descriptorResolverOptions; + assert(resolverOptions); + return { + ...fsOpts, + descriptorResolverOptions: { ...resolverOptions, attestations }, + }; +} + +function assertTetherTransfer(result: DisplayModel) { + expect(result.intent).toBe("Send"); + expect(result.interpolatedIntent).toBeUndefined(); + expect(result.rawCalldataFallback).toBeUndefined(); + expect(result.warnings).toBeUndefined(); + expect(result.metadata).toEqual({ + owner: "Tether Limited", + contractName: "Tether USD", + info: { url: "https://tether.to/", deploymentDate: "2017-11-28T12:41:21Z" }, + }); + + assert(result.fields); + expect(result.fields).toHaveLength(2); + + const amount = result.fields[0]; + assert(!isFieldGroup(amount)); + expect(amount.label).toBe("Amount"); + expect(amount.value).toBe("1 USDT"); + expect(amount.fieldType).toBe("uint"); + expect(amount.format).toBe("tokenAmount"); + expect(amount.tokenAddress).toBe(USDT); + expect(amount.rawAddress).toBeUndefined(); + expect(amount.embeddedCalldata).toBeUndefined(); + expect(amount.warning).toBeUndefined(); + + const to = result.fields[1]; + assert(!isFieldGroup(to)); + expect(to.label).toBe("To"); + expect(to.value).toBe("Alice"); + expect(to.fieldType).toBe("address"); + expect(to.format).toBe("addressName"); + expect(to.rawAddress).toBe(checksum(ALICE)); + expect(to.tokenAddress).toBeUndefined(); + expect(to.embeddedCalldata).toBeUndefined(); + expect(to.warning).toBeUndefined(); +} + +describe("attestation policy — format() with the filesystem resolver", () => { + const tx = { chainId: CHAIN_ID, to: USDT, data: TRANSFER }; + + it("formats when a trusted attester has a valid attestation", async () => { + const result = await format( + tx, + tetherOpts({ trustedAttesters: [CYFRIN_ATTESTER] }), + ); + assertTetherTransfer(result); + }); + + it("accepts a lowercase trusted attester address", async () => { + const result = await format( + tx, + tetherOpts({ trustedAttesters: [CYFRIN_ATTESTER.toLowerCase()] }), + ); + assertTetherTransfer(result); + }); + + it("skips trusted attesters without an attestation file", async () => { + const result = await format( + tx, + tetherOpts({ trustedAttesters: [ALICE, CYFRIN_ATTESTER] }), + ); + assertTetherTransfer(result); + }); + + it("formats without any attestation check when no policy is set (testing only)", async () => { + const result = await format(tx, tetherOpts()); + assertTetherTransfer(result); + }); + + it("falls back to raw calldata when no trusted attester has an attestation", async () => { + const result = await format(tx, tetherOpts({ trustedAttesters: [ALICE] })); + expect(result.intent).toBeUndefined(); + expect(result.fields).toBeUndefined(); + expect(result.metadata).toBeUndefined(); + assert(result.warnings); + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0].code).toBe("NO_TRUSTED_ATTESTATION"); + assert(result.rawCalldataFallback); + expect(result.rawCalldataFallback.selector).toBe("0xa9059cbb"); + expect(result.rawCalldataFallback.args).toEqual([ + addrWord(ALICE), + word("f4240"), + ]); + }); + + it("falls back to raw calldata when the trusted attester list is empty", async () => { + const result = await format(tx, tetherOpts({ trustedAttesters: [] })); + expect(result.warnings?.[0].code).toBe("NO_TRUSTED_ATTESTATION"); + expect(result.rawCalldataFallback?.selector).toBe("0xa9059cbb"); + }); + + it("reports an invalid trusted attester address in the warning", async () => { + const result = await format( + tx, + tetherOpts({ trustedAttesters: ["not-an-address"] }), + ); + expect(result.warnings?.[0].code).toBe("NO_TRUSTED_ATTESTATION"); + expect(result.warnings?.[0].message).toContain( + "invalid attester address 'not-an-address'", + ); + }); + + it("rejects the descriptor when the attestation was revoked", async () => { + const result = await format( + tx, + tetherOpts({ + trustedAttesters: [CYFRIN_ATTESTER], + checkRevocation: async () => true, + }), + ); + expect(result.warnings?.[0].code).toBe("NO_TRUSTED_ATTESTATION"); + expect(result.warnings?.[0].message).toContain("revoked"); + expect(result.rawCalldataFallback?.selector).toBe("0xa9059cbb"); + }); + + it("formats when checkRevocation reports the attestation as not revoked", async () => { + const checkRevocation = vi.fn(async () => false); + const result = await format( + tx, + tetherOpts({ trustedAttesters: [CYFRIN_ATTESTER], checkRevocation }), + ); + assertTetherTransfer(result); + expect(checkRevocation).toHaveBeenCalledExactlyOnceWith( + CYFRIN_ATTESTER, + registryAttestation.sig?.uid, + ); + }); + + it("returns ATTESTATIONS_NOT_SUPPORTED for a resolver without fetchAttestation", async () => { + const resolver: DescriptorResolver = { + index: { + calldataIndex: { + [`eip155:${CHAIN_ID}:${USDT.toLowerCase()}`]: "calldata-usdt.json", + }, + typedDataIndex: {}, + }, + fetchDescriptor: async () => descriptor, + }; + const result = await format(tx, { + descriptorResolverOptions: { + type: "custom", + resolver, + attestations: { trustedAttesters: [CYFRIN_ATTESTER] }, + }, + externalDataProvider: externalData, + }); + assert(result.warnings); + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0].code).toBe("ATTESTATIONS_NOT_SUPPORTED"); + expect(result.rawCalldataFallback?.selector).toBe("0xa9059cbb"); + }); + + it("does not gate bundled trusted-token descriptors", async () => { + const trustedTokens: TrustedTokens = { + [CHAIN_ID]: { [USDT.toLowerCase()]: "erc20" }, + }; + const result = await format(tx, { + descriptorResolverOptions: { + type: "github", + index: { calldataIndex: {}, typedDataIndex: {} }, + trustedTokens, + // No attestation exists for the bundled descriptor, but the wallet + // vouches for the token directly, so formatting must still work. + attestations: { trustedAttesters: [CYFRIN_ATTESTER] }, + }, + externalDataProvider: externalData, + }); + expect(result.intent).toBe("Send"); + expect(result.warnings).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// Attestation policy — includes are resolved before hashing +// --------------------------------------------------------------------------- + +describe("attestation policy — includes resolution", () => { + const CONTRACT = "0x9876543210987654321098765432109876543210"; + const including: Descriptor = { + includes: "./common-included.json", + context: { + contract: { + deployments: [{ chainId: CHAIN_ID, address: CONTRACT }], + }, + }, + metadata: { owner: "Including Owner" }, + }; + const included: Descriptor = { + display: { + formats: { + "transfer(address to,uint256 value)": { + intent: "Included Send", + fields: [{ path: "to", label: "Recipient", format: "raw" }], + }, + }, + }, + }; + + function buildResolver( + attestations: Record, + ): DescriptorResolver { + return { + index: { + calldataIndex: { + [`eip155:${CHAIN_ID}:${CONTRACT}`]: "registry/test/calldata-a.json", + }, + typedDataIndex: {}, + }, + fetchDescriptor: async (path) => { + if (path === "registry/test/calldata-a.json") return including; + if (path === "registry/test/common-included.json") return included; + throw new Error(`Unexpected path ${path}`); + }, + fetchAttestation: async (path, attester) => + attestations[attestationPathForDescriptor(path, attester)] ?? null, + }; + } + + it("accepts an attestation over the merged (includes-resolved) descriptor", async () => { + // Resolve once without a policy to obtain the merged descriptor and its hash. + const resolved = await resolveCalldataDescriptor(CHAIN_ID, CONTRACT, { + type: "custom", + resolver: buildResolver({}), + }); + assert("descriptor" in resolved); + expect(resolved.descriptor.includes).toBeUndefined(); + const mergedHash = computeDescriptorHash(resolved.descriptor); + + const attestation = signAttestation(attestMessage(mergedHash)); + const sigsPath = `registry/test/sigs/calldata-a.eip155-1-${TEST_ATTESTER}.json`; + const result = await format( + { + chainId: CHAIN_ID, + to: CONTRACT, + data: "0xa9059cbb" + addrWord(ALICE) + word("1"), + }, + { + descriptorResolverOptions: { + type: "custom", + resolver: buildResolver({ [sigsPath]: attestation }), + attestations: { trustedAttesters: [TEST_ATTESTER] }, + }, + }, + ); + + expect(result.intent).toBe("Included Send"); + expect(result.metadata?.owner).toBe("Including Owner"); + expect(result.warnings).toBeUndefined(); + }); + + it("rejects an attestation over the raw including file", async () => { + const rawHash = computeDescriptorHash(including); + const attestation = signAttestation(attestMessage(rawHash)); + const sigsPath = `registry/test/sigs/calldata-a.eip155-1-${TEST_ATTESTER}.json`; + const result = await format( + { + chainId: CHAIN_ID, + to: CONTRACT, + data: "0xa9059cbb" + addrWord(ALICE) + word("1"), + }, + { + descriptorResolverOptions: { + type: "custom", + resolver: buildResolver({ [sigsPath]: attestation }), + attestations: { trustedAttesters: [TEST_ATTESTER] }, + }, + }, + ); + + assert(result.warnings); + expect(result.warnings[0].code).toBe("NO_TRUSTED_ATTESTATION"); + expect(result.warnings[0].message).toContain( + "does not match the computed hash", + ); + }); +}); + +// --------------------------------------------------------------------------- +// Attestation policy — typed-data resolution +// --------------------------------------------------------------------------- + +describe("attestation policy — resolveTypedDataDescriptor", () => { + const VERIFYING_CONTRACT = "0x5555555555555555555555555555555555555555"; + const ENCODE_TYPE = "Mail(address to)"; + const eip712Descriptor: Descriptor = { + context: { + eip712: { + deployments: [{ chainId: CHAIN_ID, address: VERIFYING_CONTRACT }], + }, + }, + display: { + formats: { + [ENCODE_TYPE]: { + intent: "Send mail", + fields: [{ path: "to", label: "To", format: "raw" }], + }, + }, + }, + }; + const typedData = { + account: ALICE, + domain: { chainId: CHAIN_ID, verifyingContract: VERIFYING_CONTRACT }, + types: { Mail: [{ name: "to", type: "address" }] }, + primaryType: "Mail", + message: { to: ALICE }, + }; + + function buildResolver( + attestations: Record, + ): DescriptorResolver { + return { + index: { + calldataIndex: {}, + typedDataIndex: { + [`eip155:${CHAIN_ID}:${VERIFYING_CONTRACT}`]: { + Mail: [ + { + path: "eip712-mail.json", + encodeTypeHashes: [ + bytesToHex(keccak256(asciiToBytes(ENCODE_TYPE))), + ], + }, + ], + }, + }, + }, + fetchDescriptor: async () => eip712Descriptor, + fetchAttestation: async (path, attester) => + attestations[attestationPathForDescriptor(path, attester)] ?? null, + }; + } + + it("resolves when a trusted attester attested the descriptor", async () => { + const attestation = signAttestation( + attestMessage(computeDescriptorHash(eip712Descriptor)), + ); + const result = await resolveTypedDataDescriptor(typedData, { + type: "custom", + resolver: buildResolver({ + [`sigs/eip712-mail.eip155-1-${TEST_ATTESTER}.json`]: attestation, + }), + attestations: { trustedAttesters: [TEST_ATTESTER] }, + }); + assert("descriptor" in result); + expect(result.descriptor).toEqual(eip712Descriptor); + }); + + it("fails with NO_TRUSTED_ATTESTATION when no attestation exists", async () => { + const result = await resolveTypedDataDescriptor(typedData, { + type: "custom", + resolver: buildResolver({}), + attestations: { trustedAttesters: [TEST_ATTESTER] }, + }); + assert("warning" in result); + expect(result.warning.code).toBe("NO_TRUSTED_ATTESTATION"); + expect(result.warning.message).toContain("eip712-mail.json"); + }); +}); diff --git a/test/attestations/calldata-usdt.json b/test/attestations/calldata-usdt.json new file mode 100644 index 0000000..7143db6 --- /dev/null +++ b/test/attestations/calldata-usdt.json @@ -0,0 +1,68 @@ +{ + "$schema": "../../specs/erc7730-v2.schema.json", + "context": { + "$id": "Tether USD", + "contract": { + "deployments": [ + { + "chainId": 1, + "address": "0xdAC17F958D2ee523a2206206994597C13D831ec7" + }, + { + "chainId": 137, + "address": "0xc2132D05D31c914a87C6611C10748AEb04B58e8F" + } + ] + } + }, + "metadata": { + "owner": "Tether Limited", + "info": { + "url": "https://tether.to/", + "deploymentDate": "2017-11-28T12:41:21Z" + }, + "token": { "ticker": "USDT", "name": "Tether USD", "decimals": 6 }, + "contractName": "Tether USD" + }, + "display": { + "formats": { + "transfer(address _to, uint256 _value)": { + "intent": "Send", + "fields": [ + { + "path": "#._value", + "label": "Amount", + "format": "tokenAmount", + "params": { "tokenPath": "@.to" } + }, + { + "path": "#._to", + "label": "To", + "format": "addressName", + "params": { "types": ["eoa"], "sources": ["local", "ens"] } + } + ] + }, + "approve(address _spender, uint256 _value)": { + "intent": "Approve", + "fields": [ + { + "path": "#._spender", + "label": "Spender", + "format": "addressName", + "params": { "types": ["eoa", "contract"] } + }, + { + "path": "#._value", + "label": "Amount", + "format": "tokenAmount", + "params": { + "tokenPath": "@.to", + "threshold": "0x8000000000000000000000000000000000000000000000000000000000000000" + } + } + ] + } + } + } +} diff --git a/test/attestations/sigs/calldata-usdt.eip155-1-0x3846c3A30E62075Fa916216b35EF04B8F53931f6.json b/test/attestations/sigs/calldata-usdt.eip155-1-0x3846c3A30E62075Fa916216b35EF04B8F53931f6.json new file mode 100644 index 0000000..858ac29 --- /dev/null +++ b/test/attestations/sigs/calldata-usdt.eip155-1-0x3846c3A30E62075Fa916216b35EF04B8F53931f6.json @@ -0,0 +1,70 @@ +{ + "sig": { + "version": 2, + "uid": "0x96556de8936bfbdc5ccfab68b30a81644367b5ec2520ddafb0da2df2e5e19c1f", + "domain": { + "name": "EAS Attestation", + "version": "0.26", + "chainId": "1", + "verifyingContract": "0xA1207F3BBa224E2c9c3c6D5aF63D0eb1582Ce587" + }, + "primaryType": "Attest", + "message": { + "version": 2, + "schema": "0xe023eef113c1670774801c34b377fdf612dd8a4d2fa92fe382e15bd91fafb5c2", + "recipient": "0x0000000000000000000000000000000000000000", + "time": "1785288536", + "expirationTime": "0", + "revocable": true, + "refUID": "0x0000000000000000000000000000000000000000000000000000000000000000", + "data": "0xff2c9c374b68acf8eb76e90afa090088fb94988915dd068c5c10edab360c604a", + "salt": "0xd9c64c5ed0821ad175cd4076d375aa392c3d691c273f5cc6bd46a7e26710cb95" + }, + "types": { + "Attest": [ + { + "name": "version", + "type": "uint16" + }, + { + "name": "schema", + "type": "bytes32" + }, + { + "name": "recipient", + "type": "address" + }, + { + "name": "time", + "type": "uint64" + }, + { + "name": "expirationTime", + "type": "uint64" + }, + { + "name": "revocable", + "type": "bool" + }, + { + "name": "refUID", + "type": "bytes32" + }, + { + "name": "data", + "type": "bytes" + }, + { + "name": "salt", + "type": "bytes32" + } + ] + }, + "signature": { + "v": 28, + "r": "0x520fffd6ca2023853e1ec9bba8638d01f98c35393246971b6b8c6ec2ec69b761", + "s": "0x3da7cdca0dad5e113d6a9060acdbcf90ea697c353a6534f589601c2b05873d9e" + } + }, + "signer": "0x3846c3A30E62075Fa916216b35EF04B8F53931f6" +} diff --git a/test/github-registry-client.spec.ts b/test/github-registry-client.spec.ts index 683514f..d9bcc70 100644 --- a/test/github-registry-client.spec.ts +++ b/test/github-registry-client.spec.ts @@ -4,6 +4,7 @@ import { DEFAULT_REF, fetchRegistryFilePaths, fetchRegistryFile, + fetchOptionalRegistryFile, } from "../src/github-registry-client.js"; // --------------------------------------------------------------------------- @@ -174,3 +175,48 @@ describe("fetchRegistryFile", () => { ).rejects.toThrow(/HTTP 404/); }); }); + +// --------------------------------------------------------------------------- +// fetchOptionalRegistryFile +// --------------------------------------------------------------------------- + +describe("fetchOptionalRegistryFile", () => { + const SIGS_PATH = + "registry/tether/sigs/calldata-usdt.eip155-1-0x3846c3A30E62075Fa916216b35EF04B8F53931f6.json"; + const url = `https://raw.githubusercontent.com/ethereum/clear-signing-erc7730-registry/master/${SIGS_PATH}`; + + it("fetches an existing file by repo-relative path", async () => { + const body = { sig: { version: 2 }, signer: "0x3846" }; + mockFetch(new Map([[url, body]])); + + const result = await fetchOptionalRegistryFile(SIGS_PATH, { + repo: DEFAULT_REPO, + ref: DEFAULT_REF, + }); + expect(result).toEqual(body); + }); + + it("returns null on a 404 response", async () => { + mockFetch(new Map()); + + const result = await fetchOptionalRegistryFile(SIGS_PATH, { + repo: DEFAULT_REPO, + ref: DEFAULT_REF, + }); + expect(result).toBeNull(); + }); + + it("throws on a non-404 error response", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response("Server Error", { status: 500 })), + ); + + await expect( + fetchOptionalRegistryFile(SIGS_PATH, { + repo: DEFAULT_REPO, + ref: DEFAULT_REF, + }), + ).rejects.toThrow(/HTTP 500/); + }); +}); From 5907917d525139d57c94b7d2875fd7dcc7ef324b Mon Sep 17 00:00:00 2001 From: Manuel Wedler Date: Fri, 21 Aug 2026 11:20:32 +0200 Subject: [PATCH 2/2] Read ERC-8176 revocation state through a wallet ChainClient Replace the wallet-implemented checkRevocation callback with a raw eth_call hook (ExternalDataProvider.chainClient). The library now encodes getRevokeOffchain(attester, uid) for the mainnet EAS contract and decodes the result itself; format() passes only the chain client to the resolver layer. A policy without a chain client fails with ATTESTATION_OPTIONS_INCOMPLETE, which also replaces ATTESTATIONS_NOT_SUPPORTED. verifyAttestation is internal now, synchronous, and throws on the first failed check; applyAttestationPolicy catches the error and runs the revocation read afterwards, so transport errors still surface as DESCRIPTOR_FETCH_ERROR. TypedDataDomain.chainId accepts number | string and is normalized with parseChainId; the attestation domain reuses TypedDataDomain. utf8ToBytes replaces asciiToBytes. OffchainAttestationSignature is renamed to EcdsaSignature. Co-Authored-By: Manuel Wedler --- AGENTS.md | 77 +++- GUIDE.md | 120 +++---- README.md | 85 +++-- src/attestations.ts | 165 +++++---- src/descriptor.ts | 29 +- src/eip712.ts | 4 +- src/github-registry-index.ts | 4 +- src/index.ts | 8 +- src/resolver.ts | 87 +++-- src/types.ts | 134 +++---- src/utils.ts | 50 +-- test/attestations/attestations.spec.ts | 332 ++++++++++++------ .../erc7730-test-cases/example-eip712.spec.ts | 24 ++ test/utils.ts | 4 +- 14 files changed, 697 insertions(+), 426 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 1d2123f..6f32e12 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -84,10 +84,13 @@ src/ - **`attestations.ts`** — ERC-8176 descriptor attestations. Exports `computeDescriptorHash` (keccak256 of the RFC 8785 / JCS canonical JSON of the - includes-resolved descriptor), `verifyAttestation` (offline verification of an - EAS offchain attestation: canonical schema UID, attested hash, EIP-712 domain - pin to the mainnet EAS contract, expiration, ECDSA recovery, offchain UID - recomputation, and the wallet-delegated `checkRevocation` callback), and + includes-resolved descriptor), `verifyAttestation` (offline verification of + an EAS offchain attestation: canonical schema UID, attested hash, EIP-712 + domain pin to the mainnet EAS contract, expiration, ECDSA recovery, offchain + UID recomputation; throws on the first failed check and returns + `{ attester, uid }`), `isAttestationRevoked` (the revocation read — + `getRevokeOffchain(attester, uid)` on the mainnet EAS contract through the + wallet's `ChainClient`), and `attestationPathForDescriptor` (the registry's `/sigs/.eip155-1-.json` convention). The JCS canonicalizer is module-private — for `JSON.parse` output it is exactly @@ -117,7 +120,9 @@ src/ → on an index hit, applyAttestationPolicy() — when options.attestations is set, the resolved descriptor is only accepted with a valid attestation from a trusted attester (bundled trusted-token - descriptors are exempt) + descriptors are exempt). The revocation read goes through + opts.externalDataProvider.chainClient, which format() passes to + the resolver as a separate parameter → calldata.formatCalldata(tx, descriptor, externalDataProvider?) → findFormatBySelector() matches selector to a display.formats entry → decodeArguments() decodes calldata into { values, arrayLengths } maps @@ -200,19 +205,41 @@ Key mechanics: (`attestationPathForDescriptor`) and treats HTTP 404 as "no attestation" (`fetchOptionalRegistryFile`); the filesystem resolver treats ENOENT the same way. A custom resolver without `fetchAttestation` fails every gated - resolution with `ATTESTATIONS_NOT_SUPPORTED`. + resolution with `ATTESTATION_OPTIONS_INCOMPLETE`. - `verifyAttestation` (`attestations.ts`) checks: schema is the canonical ERC-8176 schema UID, attested `data` equals the computed descriptor hash, EIP-712 domain pins the canonical EAS contract on Ethereum mainnet, `expirationTime` (0 = never) has not passed, the signature recovers the attester (EOA only), and the recomputed EAS v2 offchain UID matches the - declared one (a tampered uid would otherwise hide a revocation). Revocation - itself needs chain access, so it is delegated to the wallet via - `AttestationOptions.checkRevocation(attester, uid)` — same pattern as - `ExternalDataProvider`. + declared one (a tampered uid would otherwise hide a revocation). It is + synchronous and **throws** a plain `Error` on the first failed check; + `applyAttestationPolicy` catches it and turns the message into a + per-attester failure reason. This is the one place where a thrown error is + a normal outcome rather than an I/O failure — it never escapes the + resolver. +- After a successful verification, `applyAttestationPolicy` reads the + revocation state with `isAttestationRevoked` (`attestations.ts`): + `getRevokeOffchain(attester, uid)` on the canonical EAS contract on Ethereum + mainnet, encoded and decoded in the library and sent through the wallet's + `ChainClient.call(1, { to, data })`. A non-zero word means revoked; a + result that is not exactly 32 bytes throws. The read runs outside the + `try`, so its transport errors propagate as `DESCRIPTOR_FETCH_ERROR`. +- **`ChainClient`** (`types.ts`) is the wallet's raw, read-only RPC access: + `call(chainId, { to, data }) → hex`. It lives on + `ExternalDataProvider.chainClient` next to the semantic resolvers, but it is + a different kind of hook: the library decides what to call and how to decode + it; the wallet only supplies the transport. Today only the revocation read + uses it; any future raw chain read (e.g. logs) belongs on the same object. + The resolver layer never sees the full provider — `format()` / + `formatTypedData()` pass only `externalDataProvider?.chainClient` as the + last parameter of `resolveCalldataDescriptor` / + `resolveTypedDataDescriptor`. Standalone callers pass their own. +- An attestation policy without a `chainClient` also fails with + `ATTESTATION_OPTIONS_INCOMPLETE`. One code covers both setup gaps (missing + `chainClient`, missing `fetchAttestation`); the message names the gap. - Failure surfaces as a `NO_TRUSTED_ATTESTATION` warning (with per-attester reasons in the message); `format()` then falls back to `rawCalldataFallback` - exactly like `NO_DESCRIPTOR`. Attestation fetch and `checkRevocation` I/O + exactly like `NO_DESCRIPTOR`. Attestation fetch and `chainClient` I/O errors still throw (→ `DESCRIPTOR_FETCH_ERROR` in `index.ts`), consistent with descriptor fetching. - Bundled trusted-token descriptors are **not** gated — `trustedTokens` trust @@ -501,6 +528,14 @@ ERC-7730 defines multiple path prefixes: Container paths are resolved by `resolveTransactionPath()` and `resolveTypedDataPath()` in `descriptor.ts`. +`TypedDataDomain.chainId` is `number | string` — `eth_signTypedData_v4` +payloads and EAS attestation files carry it as a decimal or `0x`-hex string. +Every reader normalizes it with `parseChainId()` (`utils.ts`) before use: the +typed-data index key, the deployment binding check, the `@.chainId` container +path, the container chain ID passed to the field pipeline, and the EAS domain +pin in `attestations.ts`. The same type describes the EIP-712 domain of an +offchain attestation (`OffchainAttestationSig.domain`). + ### Warnings Warnings are returned in the `DisplayModel.warnings` array and on individual `DisplayField.warning`. All warning codes are the `WarningCode` string literal union defined in `types.ts`. Use the `warn(code, message)` helper from `utils.ts` to create them. **Never use out-parameters for warnings — always return them in the result object.** @@ -564,7 +599,7 @@ Tests live in `test/`. Current test files: - `test/registry-cases/paraswap/paraswap.spec.ts` — Paraswap AugustusSwapper v6.2: RFQ batch fill (tuple array decoding) + BalancerV2 (dynamic bytes + byte range slices) - `test/registry-cases/zama/zama.spec.ts` — Zama ConfidentialWrapper: fhevm-encrypted `bytes32` amount handle decrypted via `resolveDecryptedValue` and rendered as a tokenAmount, plus plaintext-encoding edge cases (zero-padded ABI word, top-bit-set `uint64`, over-wide value) and both fallback paths — no provider, and a provider that declines - `test/bundled/trusted-tokens.spec.ts` — bundled ERC-20/721 descriptors via `trustedTokens`: standard tagging, selector collision, registry precedence -- `test/attestations/attestations.spec.ts` — ERC-8176 attestations against the registry's real Tether USD descriptor + attestation fixtures: descriptor hashing (JCS known answers), `verifyAttestation` edge cases via test-key-signed attestations (expired, revoked, tampered message/uid, wrong schema/hash/domain/version), and the trusted-attester policy end to end through `format()` / `resolveTypedDataDescriptor` (fallbacks, `ATTESTATIONS_NOT_SUPPORTED`, `trustedTokens` bypass, includes-resolved hashing) +- `test/attestations/attestations.spec.ts` — ERC-8176 attestations against the registry's real Tether USD descriptor + attestation fixtures: descriptor hashing (JCS known answers), `verifyAttestation` edge cases via test-key-signed attestations (expired, tampered message/uid, wrong schema/hash/domain/version — each thrown as an `Error`), `isAttestationRevoked` call encoding and result decoding, and the trusted-attester policy end to end through `format()` / `resolveTypedDataDescriptor` (fallbacks, `ATTESTATION_OPTIONS_INCOMPLETE` for both setup gaps, `chainClient` call encoding and transport errors, `trustedTokens` bypass, includes-resolved hashing) ### Test guidelines @@ -666,6 +701,22 @@ import { formatAmountWithDecimals } from "./utils"; const display = formatAmountWithDecimals(1000000n, 6); // "1" ``` +### Where types live + +`types.ts` holds the public type surface only: everything a consumer can +receive from or pass to an exported function (`index.ts` re-exports all of it +with `export type *`). A type belongs there when it is a parameter or return +type of a public function, a member of a public interface, or a nested part +of one (e.g. `Descriptor` → `DescriptorContext`, `OffchainAttestation` → +`OffchainAttestationMessage`). + +A type that a consumer can never reach stays in the module that owns it — +module-private, or exported for other internal modules only. Examples: +`RenderFieldResult` in `formatters.ts`, `ArgumentValue` in `descriptor.ts`. +Do not add such types to `types.ts`. When an internal type becomes reachable +(a function that returns it gets exported from `index.ts`), move it to +`types.ts` in the same change. + ## Gotchas 1. **JSON imports require assertion:** `import data from './file.json' with { type: 'json' };` — and for that reason the bundled descriptors (`src/bundled/*.ts`) are committed as TypeScript `const`s, **not** imported JSON. Import attributes caused build/tooling trouble; plain TS bundles as code in every target (ESM/CJS/RN/browser) with no `resolveJsonModule`, no esbuild JSON loader, and no import-attribute support required, and gives the objects compile-time `Descriptor` typing. @@ -673,7 +724,7 @@ const display = formatAmountWithDecimals(1000000n, 6); // "1" 3. **Selector matching is case-sensitive** on function names 4. **Address normalization:** Always lowercase for comparisons 5. **Minimize exports:** Only export symbols that are imported by other modules. Keep internal helpers module-private. -6. **Check `utils.ts` before writing helpers:** Always check if `utils.ts` already has a function for what you need (e.g. `hexToBytes`, `bytesToHex`, `bytesToAscii`, `asciiToBytes`, `bigIntToBytes`, `bytesToBigInt`, etc.) before writing a new one — in both `src/` and `test/` files. +6. **Check `utils.ts` before writing helpers:** Always check if `utils.ts` already has a function for what you need (e.g. `hexToBytes`, `bytesToHex`, `bytesToAscii`, `utf8ToBytes`, `concatBytes`, `bigIntToBytes`, `bytesToUnsignedBigInt`, `parseChainId`, etc.) before writing a new one — in both `src/` and `test/` files. 7. **Argument value conversion:** To turn a JS literal (descriptor constant, EIP-712 message value, `ifNotIn`/`mustMatch` candidate) into an `ArgumentValue`, use `toArgumentValue` from `descriptor.ts` — it infers the type from the value shape. Compare two `ArgumentValue`s with `argumentValueEquals` (cross-matches `uint`/`int` via bigint). Prefer these over bespoke per-type matching helpers. ## Descriptor Type — Defensive Programming diff --git a/GUIDE.md b/GUIDE.md index f27c0f0..2706247 100644 --- a/GUIDE.md +++ b/GUIDE.md @@ -44,93 +44,72 @@ Two advanced options you typically won't need: - **`createGitHubRegistryIndex(source?)`** — walks the registry tree and builds the index in-process. Significantly slower than `fetchPrebuiltRegistryIndex` (one fetch per descriptor file). Use when the prebuilt indexes are stale, missing entries, or when pointing at a fork that doesn't publish them. - **Custom resolvers** — anything matching the `DescriptorResolver` shape (in-memory map, custom HTTP endpoint, etc.) can be wrapped in `{ type: "custom", resolver }`. One custom resolver ships in the library: the Node-only filesystem resolver at `@ethereum-sourcify/clear-signing/filesystem`, which loads descriptor JSON files from a local directory. See the README for details. -## 3. Build the trusted token list +## 3. Set the attestation policy -The registry cannot hold a descriptor for every token. To still render plain -ERC-20 and ERC-721 interactions, pass a `trustedTokens` list inside -`descriptorResolverOptions`. It maps `chainId → tokenAddress → standard`. If a -transaction's contract is listed there, the transaction is rendered from a -bundled ERC-20 / ERC-721 template. Only include tokens in this object that you -**trust** to be safe to interact with. +A descriptor controls what the user sees before they sign. A wrong or malicious descriptor can hide the real effect of a transaction. For this reason, do not use registry descriptors that haven't been audited in production. -```typescript -import type { TrustedTokens } from "@ethereum-sourcify/clear-signing"; +[ERC-8176](https://github.com/ethereum/ERCs/pull/1576) supplies that check: auditors review each descriptor and attest it with an EAS offchain attestation. The registry stores the attestation files next to the descriptor. The registry's [`auditors/`](https://github.com/ethereum/clear-signing-erc7730-registry/tree/master/auditors) directory contains the audit guidelines and a profile for each auditor, keyed by attester address. -// Token addresses may be lowercase or EIP-55 checksummed. -const trustedTokens: TrustedTokens = { - 1: { - "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48": "erc20", // USDC - "0xbc4ca0eda7647a8ab7c2061c2e118a18a936f13d": "erc721", // BAYC - }, -}; - -const descriptorResolverOptions = { type: "github", index, trustedTokens }; -``` - -## 4. Set the attestation policy - -A descriptor controls what the user sees before they sign. A wrong or -malicious descriptor can hide the real effect of a transaction. For this -reason, do not use registry descriptors without a review check in production. - -[ERC-8176](https://github.com/ethereum/ERCs/pull/1576) supplies that check: -auditors review each descriptor and attest it with an EAS offchain -attestation. The registry stores the attestation files next to the descriptor. -The registry's [`auditors/`](https://github.com/ethereum/clear-signing-erc7730-registry/tree/master/auditors) -directory contains the audit guidelines and a profile for each auditor, keyed -by attester address. - -Select the auditors your wallet trusts and set the `attestations` policy on -the resolver options. The library then rejects every registry descriptor that -does not carry a valid attestation from one of them (the result gets a -`NO_TRUSTED_ATTESTATION` warning and, for calldata, the raw fallback): +Select the auditors your wallet trusts and set the `attestations` policy on the resolver options. The library then rejects every registry descriptor that does not carry a valid attestation from one of them (the result gets a `NO_TRUSTED_ATTESTATION` warning): ```typescript const descriptorResolverOptions = { type: "github", index, - trustedTokens, attestations: { // Attester addresses from the registry's auditors/ directory, // lowercase or EIP-55 checksummed. trustedAttesters: ["0x3846c3A30E62075Fa916216b35EF04B8F53931f6"], - checkRevocation, }, }; ``` -The library verifies each attestation offline: schema, descriptor hash, -expiration, and EIP-712 signature. Revocation state lives on the EAS contract -on Ethereum mainnet, so the wallet supplies that check — the same delegation -pattern as the `ExternalDataProvider`: +The library verifies each attestation offline: schema, descriptor hash, expiration, and EIP-712 signature. Then it reads the revocation state from the EAS contract on Ethereum mainnet. That read needs an RPC connection, which the wallet supplies as a `ChainClient` on the `ExternalDataProvider` (§5). The client is a raw `eth_call` hook — the library selects the contract, encodes the call, and decodes the result: ```typescript -// The canonical EAS contract on Ethereum mainnet. -const EAS_ADDRESS = "0xA1207F3BBa224E2c9c3c6D5aF63D0eb1582Ce587"; - -const checkRevocation = async (attester: string, uid: string) => { - // getRevokeOffchain returns the revocation time, or 0 when not revoked. - const revokedAt = await readContract({ - address: EAS_ADDRESS, - abi: parseAbi([ - "function getRevokeOffchain(address revoker, bytes32 data) view returns (uint64)", - ]), - functionName: "getRevokeOffchain", - args: [attester, uid], - }); - return revokedAt !== 0n; +// Use the provider library your wallet already has. viem is only an example. +import { createPublicClient, http } from "viem"; +import { mainnet } from "viem/chains"; + +const mainnetClient = createPublicClient({ chain: mainnet, transport: http() }); + +const chainClient: ChainClient = { + call: async (chainId, { to, data }) => { + if (chainId !== mainnet.id) throw new Error(`No RPC for chain ${chainId}`); + const { data: result } = await mainnetClient.call({ to, data }); + return result ?? "0x"; + }, }; ``` -When you omit `checkRevocation`, verification runs fully offline and revoked -attestations are not detected. Provide it in production. +The chain client is required for attestation verification. A transport error from the client surfaces as `DESCRIPTOR_FETCH_ERROR`. -**Testing without attestations.** When you omit the `attestations` option -entirely, the library formats through unreviewed descriptors. Use that mode -only for testing — never in production. +**Testing without attestations.** When you omit the `attestations` option entirely, the library formats through unreviewed descriptors. Use that mode only for testing — never in production. -The `trustedTokens` list from §3 is not affected by the policy: the wallet -already vouches for those contracts directly. +## 4. Build the trusted token list + +The registry cannot hold a descriptor for every token. To still render plain ERC-20 and ERC-721 interactions, pass a `trustedTokens` list inside `descriptorResolverOptions`. It maps `chainId → tokenAddress → standard`. If a transaction's contract is listed there, the transaction is rendered from a bundled ERC-20 / ERC-721 template. Only include tokens in this object that you **trust** to be safe to interact with. + +```typescript +import type { TrustedTokens } from "@ethereum-sourcify/clear-signing"; + +// Token addresses may be lowercase or EIP-55 checksummed. +const trustedTokens: TrustedTokens = { + 1: { + "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48": "erc20", // USDC + "0xbc4ca0eda7647a8ab7c2061c2e118a18a936f13d": "erc721", // BAYC + }, +}; + +const descriptorResolverOptions = { + type: "github", + index, + attestations, + trustedTokens, +}; +``` + +The attestation policy from §3 does not apply to this list: the wallet already vouches for those contracts directly. ## 5. Build the `ExternalDataProvider` @@ -138,6 +117,8 @@ The library is agnostic about how external data is fetched. To resolve token met Every method is optional. If a method is missing or returns `null`, the corresponding field falls back to raw formatting and the `DisplayModel` carries an explanatory warning (e.g. `UNKNOWN_TOKEN`, `UNKNOWN_ADDRESS`, `UNKNOWN_CHAIN`). +The provider also carries the `chainClient` from §3. It is the one non-semantic member: a raw `eth_call` hook for checks the library performs itself against fixed onchain state. Currently only used for reading EAS revocations. + ```typescript const externalDataProvider: ExternalDataProvider = { // Used by `tokenAmount` format. Resolve ERC-20 metadata from your @@ -214,6 +195,9 @@ const externalDataProvider: ExternalDataProvider = { const result: DecryptedValueResult | null = { value: "0x00000000000f4240" }; return result; }, + + // Raw eth_call access for the ERC-8176 revocation check (§3). + chainClient, }; // Combine with the resolver options from §2–§4 into the FormatOptions object @@ -222,8 +206,8 @@ const opts: FormatOptions = { descriptorResolverOptions: { type: "github", index, // from §2 - trustedTokens, // from §3 - attestations, // from §4 + attestations, // from §3 + trustedTokens, // from §4 }, externalDataProvider, }; @@ -451,10 +435,10 @@ Surface warnings to the user. In most cases it's fine to just display the human- Warnings can appear at two levels: -- **`DisplayModel.warnings`** — affect the whole result. Examples: `NO_DESCRIPTOR` (no descriptor matched the transaction or typed data), `NO_TRUSTED_ATTESTATION` (a descriptor matched, but no trusted auditor attested it — see §4), `DESCRIPTOR_FETCH_ERROR` (the registry could not be reached), `INTERPOLATION_ERROR` (the interpolated intent template could not be rendered), `INVALID_CALLDATA_HEX` / `CALLDATA_DECODE_ERROR` (the calldata could not be parsed). +- **`DisplayModel.warnings`** — affect the whole result. Examples: `NO_DESCRIPTOR` (no descriptor matched the transaction or typed data), `NO_TRUSTED_ATTESTATION` (a descriptor matched, but no trusted auditor attested it — see §3), `DESCRIPTOR_FETCH_ERROR` (the registry could not be reached), `INTERPOLATION_ERROR` (the interpolated intent template could not be rendered), `INVALID_CALLDATA_HEX` / `CALLDATA_DECODE_ERROR` (the calldata could not be parsed). - **`DisplayField.warning`** / **`DisplayFieldGroup.warning`** — affect a single rendered value or group. Examples: `UNKNOWN_TOKEN` (`resolveToken` returned null), `UNKNOWN_ADDRESS` (no name resolved), `UNKNOWN_CHAIN` (`resolveChainInfo` returned null), `EMPTY_ARRAY` (an array argument was empty). If there is a warning, the field's `value` falls back to a raw representation; consider rendering a per-field badge or warning indicator. -When `DisplayModel.warnings` contains `NO_DESCRIPTOR` or `NO_TRUSTED_ATTESTATION` (calldata only), the model also carries a `rawCalldataFallback` with the function selector and raw ABI words — show it as a last-resort fallback so the user still sees _something_. +When `DisplayModel.warnings` contains `NO_DESCRIPTOR` or `NO_TRUSTED_ATTESTATION`, the model also carries a `rawCalldataFallback` (calldata only) with the function selector and raw ABI words — show it as a last-resort fallback so the user still sees _something_. ### Grouping diff --git a/README.md b/README.md index 9e0c2e1..114fdb1 100644 --- a/README.md +++ b/README.md @@ -162,6 +162,13 @@ The library delegates all external data resolution to the wallet. The wallet may ```typescript interface ExternalDataProvider { + /** + * Raw chain access for checks the library performs itself. Required when an AttestationOptions policy + * is set: the ERC-8176 revocation check reads the EAS contract on Ethereum + * mainnet. + */ + chainClient?: ChainClient; + /** * Resolution for addressName formats. The wallet should verify whether the * address matches any of the provided accepted types (e.g., "eoa", "contract", ...) @@ -220,6 +227,14 @@ interface ExternalDataProvider { }, ) => Promise; } + +interface ChainClient { + /** Performs eth_call against `chainId` and returns the 0x-hex return data. */ + call: ( + chainId: number, + request: { to: string; data: string }, + ) => Promise; +} ``` ### Descriptor Sources @@ -295,32 +310,11 @@ const result = await format(tx, { }); ``` -### Trusted token lists - -The registry cannot hold a descriptor for every token. To still render plain ERC-20 and ERC-721 interactions (`transfer`, `approve`, `transferFrom`, `safeTransferFrom`, `setApprovalForAll`), add a `trustedTokens` list to `descriptorResolverOptions`. It maps `chainId → tokenAddress → standard` (addresses lowercase or EIP-55 checksummed). When **no registry descriptor resolves** for a transaction's contract, the library looks it up there; if listed, the transaction is rendered from a **bundled ERC-20 / ERC-721 template descriptor** instead of falling back to raw calldata. A registry descriptor always takes precedence. - -```typescript -import type { TrustedTokens } from "@ethereum-sourcify/clear-signing"; - -const trustedTokens: TrustedTokens = { - 1: { - "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48": "erc20", // USDC - "0xbc4ca0eda7647a8ab7c2061c2e118a18a936f13d": "erc721", // BAYC - }, -}; - -const result = await format(tx, { - descriptorResolverOptions: { type: "github", index, trustedTokens }, -}); -``` - -Trust is delegated entirely to the wallet. The library never decides which contracts are trustworthy. - ### Attestations (ERC-8176) -A descriptor controls what the user sees before they sign, so a wallet must not use unreviewed descriptors. [ERC-8176](https://github.com/ethereum/ERCs/pull/1576) adds a review layer: auditors attest each descriptor with an [EAS](https://attest.org) offchain attestation, and the registry stores the attestation files next to the descriptor (`registry//sigs/`). See the registry's [`auditors/`](https://github.com/ethereum/clear-signing-erc7730-registry/tree/master/auditors) directory for the audit guidelines and the auditor profiles. +A descriptor controls what the user sees before they sign. A wallet must not use unreviewed descriptors. [ERC-8176](https://github.com/ethereum/ERCs/pull/1576) adds a review layer: auditors attest each descriptor with an [EAS](https://attest.org) offchain attestation. The registry stores the attestation files next to the descriptor, in `registry//sigs/`. The registry's [`auditors/`](https://github.com/ethereum/clear-signing-erc7730-registry/tree/master/auditors) directory lists the auditors and the audit guidelines. -Add an `attestations` policy to `descriptorResolverOptions` to enforce this review layer. The library then accepts a resolved descriptor only when one of your trusted auditors has a valid attestation over it: +Set an `attestations` policy to enable this check. The library then accepts a descriptor only when one of your trusted auditors has a valid attestation for it: ```typescript const result = await format(tx, { @@ -330,26 +324,49 @@ const result = await format(tx, { attestations: { // Auditor addresses your wallet trusts — see the registry's auditors/ directory. trustedAttesters: ["0x3846c3A30E62075Fa916216b35EF04B8F53931f6"], - // Recommended: report revocations from the EAS contract. See GUIDE.md. - checkRevocation: async (attester, uid) => { - /* return true when getRevokeOffchain(attester, uid) is non-zero */ - return false; - }, + }, + }, + externalDataProvider: { + // Required when `attestations` is set. The library uses this raw eth_call + // access to read the revocation state from the EAS contract on Ethereum + // mainnet. See GUIDE.md. + chainClient: { + call: async (chainId, { to, data }) => rpcEthCall(chainId, to, data), }, }, }); ``` -For each resolved descriptor the library computes the ERC-8176 descriptor hash (keccak256 of the RFC 8785 canonical JSON of the includes-resolved descriptor), fetches each trusted attester's attestation from the registry's `sigs/` directory, and verifies it offline: canonical schema UID, attested hash, expiration, EIP-712 signature, and attestation UID. Revocation state lives on-chain, so that check is delegated to the wallet through `checkRevocation`. When no trusted attestation is valid, the result carries a `NO_TRUSTED_ATTESTATION` warning and (for calldata) the `rawCalldataFallback`. +The library verifies each attestation offline (schema, descriptor hash, expiration, signature, UID). Then it reads the revocation state through `externalDataProvider.chainClient`. When no trusted attestation is valid, the result carries a `NO_TRUSTED_ATTESTATION` warning and, for calldata, the `rawCalldataFallback`. -**Formatting without an `attestations` policy is for testing only.** Without it, the library uses registry descriptors without any review check. Do not ship that configuration in production. +**Without an `attestations` policy, the library uses registry descriptors without any review check. Use that mode for testing only.** Notes: -- Bundled trusted-token descriptors (`trustedTokens`) are not gated — the wallet already vouches for those contracts directly. -- A custom resolver must implement the optional `DescriptorResolver.fetchAttestation` to satisfy an attestation policy; otherwise every resolution fails with `ATTESTATIONS_NOT_SUPPORTED`. The GitHub and filesystem resolvers implement it out of the box. -- The building blocks are exported for custom flows: `computeDescriptorHash(descriptor)`, `verifyAttestation(attestation, descriptorHash, options?)`, and `attestationPathForDescriptor(descriptorPath, attester)`. -- Only EAS offchain attestations (version 2) with EOA signatures are supported. Onchain attestations and ERC-1271 contract attesters need chain access and are out of scope. +- The policy needs `externalDataProvider.chainClient`, and a custom resolver must implement `DescriptorResolver.fetchAttestation`. Otherwise every gated resolution fails with `ATTESTATION_OPTIONS_INCOMPLETE`. +- Bundled trusted-token descriptors (`trustedTokens`) are not gated. +- Only EAS offchain attestations (version 2) with EOA signatures are supported. + +### Trusted token lists + +The registry cannot hold a descriptor for every token. To still render plain ERC-20 and ERC-721 interactions (`transfer`, `approve`, `transferFrom`, `safeTransferFrom`, `setApprovalForAll`), add a `trustedTokens` list to `descriptorResolverOptions`. It maps `chainId → tokenAddress → standard` (addresses lowercase or EIP-55 checksummed). When **no registry descriptor resolves** for a transaction's contract, the library looks it up there; if listed, the transaction is rendered from a **bundled ERC-20 / ERC-721 template descriptor** instead of falling back to raw calldata. A registry descriptor always takes precedence. + +```typescript +import type { TrustedTokens } from "@ethereum-sourcify/clear-signing"; + +const trustedTokens: TrustedTokens = { + 1: { + "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48": "erc20", // USDC + "0xbc4ca0eda7647a8ab7c2061c2e118a18a936f13d": "erc721", // BAYC + }, +}; + +const result = await format(tx, { + descriptorResolverOptions: { type: "github", index, trustedTokens }, +}); +``` + +Trust is delegated entirely to the wallet. The library never decides which contracts are trustworthy. ### Display Model diff --git a/src/attestations.ts b/src/attestations.ts index 90af68b..5a2cc40 100644 --- a/src/attestations.ts +++ b/src/attestations.ts @@ -3,10 +3,9 @@ * * Auditors attest ERC-7730 descriptors with EAS offchain attestations over a * canonical `bytes32 descriptorHash` schema. This module implements the - * offline parts of the ERC-8176 verification procedure: descriptor hashing, - * attestation signature verification, and the registry's `sigs/` file - * convention. Revocation state lives on-chain, so checking it is delegated to - * the wallet via `AttestationOptions.checkRevocation`. + * ERC-8176 verification procedure: descriptor hashing, attestation signature + * verification, the revocation check on the EAS contract (through the + * wallet's `ChainClient`), and the registry's `sigs/` file convention. * * See https://github.com/ethereum/ERCs/pull/1576 and the registry's * `auditors/` directory for the audit process. @@ -14,21 +13,24 @@ import { secp256k1 } from "@noble/curves/secp256k1"; import type { - AttestationVerificationResult, + ChainClient, Descriptor, + EcdsaSignature, OffchainAttestation, - OffchainAttestationDomain, OffchainAttestationMessage, - OffchainAttestationSignature, + TypedDataDomain, } from "./types.js"; import { bigIntToBytes, bytesToHex, + bytesToUnsignedBigInt, coerceBigInt, concatBytes, hexToBytes, keccak256, normalizeAddress, + parseChainId, + selectorForSignature, toChecksumAddress, utf8ToBytes, } from "./utils.js"; @@ -41,6 +43,11 @@ const ERC8176_SCHEMA_UID = const EAS_MAINNET_ADDRESS = "0xa1207f3bba224e2c9c3c6d5af63d0eb1582ce587"; const EAS_MAINNET_CHAIN_ID = 1; +/** `getRevokeOffchain(address revoker, bytes32 data) returns (uint64)`. */ +const GET_REVOKE_OFFCHAIN_SELECTOR = selectorForSignature( + "getRevokeOffchain(address,bytes32)", +); + /** The EAS offchain attestation version ERC-8176 attestations use. */ const OFFCHAIN_ATTESTATION_VERSION = 2; @@ -92,9 +99,6 @@ export function computeDescriptorHash(descriptor: Descriptor): string { * at `descriptorPath`, following the registry convention * `/sigs/.eip155-1-.json`. The path is * relative to the same root as `descriptorPath`. - * - * Accepts a lowercase or EIP-55 checksummed attester address; throws when the - * address is not a valid 20-byte hex address. */ export function attestationPathForDescriptor( descriptorPath: string, @@ -111,74 +115,68 @@ export function attestationPathForDescriptor( return `${dir}sigs/${name}.eip155-${EAS_MAINNET_CHAIN_ID}-${toChecksumAddress(address)}.json`; } +/** The outcome of a successful {@link verifyAttestation}. */ +type VerifiedAttestation = { + /** Recovered attester address, EIP-55 checksummed. */ + attester: string; + /** Recomputed EAS offchain attestation UID (bytes32 hex). */ + uid: string; +}; + /** * Verify a single ERC-8176 offchain attestation against a descriptor hash - * (as computed by {@link computeDescriptorHash}). Follows the ERC's - * verification procedure: + * (as computed by {@link computeDescriptorHash}). Follows the offline steps + * of the ERC's verification procedure: * * 1. the schema is the canonical ERC-8176 schema, * 2. the attested data equals `descriptorHash`, * 3. the EIP-712 domain pins the canonical EAS contract on Ethereum mainnet, * 4. `expirationTime` (0 = never expires) has not passed, - * 5. the EIP-712 signature recovers the attester, - * 6. the recomputed offchain attestation UID matches the declared one, and - * 7. the attester has not revoked the attestation — checked through - * `options.checkRevocation` when provided, skipped otherwise. - * - * Returns the recovered attester on success. Whether that attester is - * trusted (the ERC's final step) is the caller's decision. - * - * Only EAS offchain attestation version 2 and EOA attesters are supported; - * ERC-1271 contract attesters and onchain attestations need chain access and - * are out of scope for this library. + * 5. the EIP-712 signature recovers the attester, and + * 6. the recomputed offchain attestation UID matches the declared one. */ -export async function verifyAttestation( +export function verifyAttestation( attestation: OffchainAttestation, descriptorHash: string, - options?: { - checkRevocation?: (attester: string, uid: string) => Promise; - }, -): Promise { +): VerifiedAttestation { const { domain, message, signature } = attestation.sig ?? {}; if (!domain || !message || !signature) { - return { - reason: - "malformed attestation: sig.domain, sig.message, or sig.signature is missing", - }; + throw new Error( + "malformed attestation: sig.domain, sig.message, or sig.signature is missing", + ); } if (message.version !== OFFCHAIN_ATTESTATION_VERSION) { - return { - reason: `unsupported offchain attestation version ${String(message.version)}`, - }; + throw new Error( + `unsupported offchain attestation version ${String(message.version)}`, + ); } if ( typeof message.schema !== "string" || message.schema.toLowerCase() !== ERC8176_SCHEMA_UID ) { - return { - reason: `schema ${String(message.schema)} is not the canonical ERC-8176 schema`, - }; + throw new Error( + `schema ${String(message.schema)} is not the canonical ERC-8176 schema`, + ); } if ( typeof message.data !== "string" || message.data.toLowerCase() !== descriptorHash.toLowerCase() ) { - return { - reason: `attested descriptor hash ${String(message.data)} does not match the computed hash ${descriptorHash}`, - }; + throw new Error( + `attested descriptor hash ${String(message.data)} does not match the computed hash ${descriptorHash}`, + ); } if ( typeof domain.name !== "string" || typeof domain.version !== "string" || - Number(domain.chainId) !== EAS_MAINNET_CHAIN_ID || + parseChainId(domain.chainId) !== EAS_MAINNET_CHAIN_ID || typeof domain.verifyingContract !== "string" || normalizeAddress(domain.verifyingContract) !== EAS_MAINNET_ADDRESS ) { - return { - reason: - "attestation domain does not pin the canonical EAS contract on Ethereum mainnet", - }; + throw new Error( + "attestation domain does not pin the canonical EAS contract on Ethereum mainnet", + ); } const time = coerceBigInt(message.time); @@ -191,13 +189,13 @@ export async function verifyAttestation( typeof message.refUID !== "string" || typeof message.salt !== "string" ) { - return { reason: "malformed attestation: message fields are missing" }; + throw new Error("malformed attestation: message fields are missing"); } if ( expirationTime !== 0n && expirationTime <= BigInt(Math.floor(Date.now() / 1000)) ) { - return { reason: `attestation expired at ${expirationTime}` }; + throw new Error(`attestation expired at ${expirationTime}`); } let attester: string; @@ -207,7 +205,7 @@ export async function verifyAttestation( attester = toChecksumAddress(recoverSigner(digest, signature)); uid = computeOffchainUid(message, time, expirationTime); } catch { - return { reason: "malformed attestation: invalid signature or encoding" }; + throw new Error("malformed attestation: invalid signature or encoding"); } const { signer } = attestation; @@ -216,9 +214,9 @@ export async function verifyAttestation( (typeof signer !== "string" || normalizeAddress(signer) !== normalizeAddress(attester)) ) { - return { - reason: `signature recovers ${attester}, not the declared signer ${String(signer)}`, - }; + throw new Error( + `signature recovers ${attester}, not the declared signer ${String(signer)}`, + ); } const declaredUid = attestation.sig?.uid; @@ -226,19 +224,44 @@ export async function verifyAttestation( declaredUid !== undefined && (typeof declaredUid !== "string" || declaredUid.toLowerCase() !== uid) ) { - return { - reason: `declared attestation uid ${String(declaredUid)} does not match the computed uid ${uid}`, - }; + throw new Error( + `declared attestation uid ${String(declaredUid)} does not match the computed uid ${uid}`, + ); } - if ( - options?.checkRevocation && - (await options.checkRevocation(attester, uid)) - ) { - return { reason: `attestation ${uid} was revoked by ${attester}` }; - } + return { attester, uid }; +} - return { attester }; +/** + * Read `getRevokeOffchain(attester, uid)` on the canonical EAS contract on + * Ethereum mainnet through `chainClient`. EAS stores the revocation + * timestamp under `(revoker, data)`; a non-zero value means the attester + * revoked the attestation. + */ +export async function isAttestationRevoked( + chainClient: ChainClient, + attester: string, + uid: string, +): Promise { + const data = bytesToHex( + concatBytes( + GET_REVOKE_OFFCHAIN_SELECTOR, + addressWord(attester), + bytes32(uid), + ), + ); + const result = hexToBytes( + await chainClient.call(EAS_MAINNET_CHAIN_ID, { + to: EAS_MAINNET_ADDRESS, + data, + }), + ); + if (result.length !== 32) { + throw new Error( + `Unexpected getRevokeOffchain result of ${result.length} bytes`, + ); + } + return bytesToUnsignedBigInt(result) !== 0n; } /** Decode a bytes32 hex string, throwing when it is not exactly 32 bytes. */ @@ -259,7 +282,7 @@ function addressWord(address: string): Uint8Array { /** Compute the EIP-712 signing digest of an EAS `Attest` message. */ function computeAttestDigest( - domain: OffchainAttestationDomain, + domain: TypedDataDomain, message: OffchainAttestationMessage, time: bigint, expirationTime: bigint, @@ -295,7 +318,7 @@ function computeAttestDigest( /** Recover the 20-byte signer address of an ECDSA signature over `digest`. */ function recoverSigner( digest: Uint8Array, - signature: OffchainAttestationSignature, + signature: EcdsaSignature, ): Uint8Array { const { r, s, v } = signature; if (typeof r !== "string" || typeof s !== "string" || typeof v !== "number") { @@ -312,16 +335,10 @@ function recoverSigner( } /** - * Recompute the deterministic EAS offchain attestation UID (version 2): - * keccak256 of the packed encoding - * `(uint16 version, bytes utf8(schema), address recipient, address attester, - * uint64 time, uint64 expirationTime, bool revocable, bytes32 refUID, - * bytes data, bytes32 salt, uint32 bump)` with the attester fixed to the - * zero address and bump fixed to 0, matching the EAS SDK. - * - * Recomputing (rather than trusting the declared `sig.uid`) matters for - * revocation: `getRevokeOffchain` is keyed by uid, so a tampered uid would - * otherwise hide an existing revocation. + * Recompute the EAS v2 offchain attestation UID: keccak256 of the packed + * message fields with a zero-address attester and bump 0 (as in the EAS SDK). + * The declared `sig.uid` is not trusted: `getRevokeOffchain` is keyed by uid, + * so a tampered uid could hide a revocation. */ function computeOffchainUid( message: OffchainAttestationMessage, diff --git a/src/descriptor.ts b/src/descriptor.ts index 727eade..d5ea0d8 100644 --- a/src/descriptor.ts +++ b/src/descriptor.ts @@ -17,13 +17,14 @@ import type { TypedData, } from "./types.js"; import { - asciiToBytes, bigIntToBytes, boolToBytes, bytesEqual, hexToBytes, isAddressString, normalizeAddress, + parseChainId, + utf8ToBytes, } from "./utils.js"; /** @@ -55,7 +56,8 @@ export function isEip712DescriptorBoundTo( descriptor: Descriptor, typedData: TypedData, ): boolean { - const { chainId, verifyingContract } = typedData.domain; + const chainId = parseChainId(typedData.domain.chainId); + const { verifyingContract } = typedData.domain; const eip712 = descriptor.context?.eip712; // Check deployments @@ -80,7 +82,16 @@ export function isEip712DescriptorBoundTo( const messageDomain = typedData.domain as Record; for (const [key, expected] of Object.entries(domainConstraint)) { const actual = messageDomain[key]; - if (String(actual) !== String(expected)) return false; + if (key === "chainId") { + if ( + parseChainId(actual as number | string | undefined) !== + parseChainId(expected as number | string | undefined) + ) { + return false; + } + } else if (String(actual) !== String(expected)) { + return false; + } } } @@ -219,7 +230,7 @@ export function resolvedToAddress( * - uint/int → 32-byte big-endian (two's complement for negative int) * - address → 20 bytes * - bytes → raw bytes - * - string → ASCII bytes + * - string → UTF-8 bytes * - bool → 1 byte */ export function argumentValueToBytes(value: ArgumentValue): Uint8Array { @@ -232,7 +243,7 @@ export function argumentValueToBytes(value: ArgumentValue): Uint8Array { case "bytes": return value.bytes; case "string": - return asciiToBytes(value.value); + return utf8ToBytes(value.value); case "bool": return boolToBytes(value.value); } @@ -419,9 +430,11 @@ export function resolveTypedDataPath( type: "address", bytes: hexToBytes(typedData.domain.verifyingContract), }; - case "@.chainId": - if (typedData.domain.chainId === undefined) return undefined; - return { type: "uint", value: BigInt(typedData.domain.chainId) }; + case "@.chainId": { + const chainId = parseChainId(typedData.domain.chainId); + if (chainId === undefined) return undefined; + return { type: "uint", value: BigInt(chainId) }; + } default: return undefined; } diff --git a/src/eip712.ts b/src/eip712.ts index d484b11..987a5fa 100644 --- a/src/eip712.ts +++ b/src/eip712.ts @@ -21,7 +21,7 @@ import { resolveTypedDataPath, stripStructuredRootPrefix, } from "./descriptor.js"; -import { warn } from "./utils.js"; +import { parseChainId, warn } from "./utils.js"; import { applyFieldFormats } from "./fields.js"; /** @@ -86,7 +86,7 @@ export async function formatEip712( definitions, resolvePath, getArrayLength, - typedData.domain.chainId, + parseChainId(typedData.domain.chainId), descriptor.metadata, externalDataProvider, formatEmbeddedCalldata, diff --git a/src/github-registry-index.ts b/src/github-registry-index.ts index 69e6e9a..3213e20 100644 --- a/src/github-registry-index.ts +++ b/src/github-registry-index.ts @@ -7,10 +7,10 @@ import { } from "./github-registry-client.js"; import { extractPrimaryType } from "./eip712.js"; import { - asciiToBytes, bytesToHex, keccak256, normalizeAddress, + utf8ToBytes, } from "./utils.js"; /** File names of the prebuilt indexes published in the registry root. */ @@ -98,7 +98,7 @@ function indexDescriptor( for (const encodeTypeStr of Object.keys(formats)) { const primaryType = extractPrimaryType(encodeTypeStr); if (!primaryType) continue; - const hash = bytesToHex(keccak256(asciiToBytes(encodeTypeStr))); + const hash = bytesToHex(keccak256(utf8ToBytes(encodeTypeStr))); const list = hashesByPrimaryType.get(primaryType) ?? []; list.push(hash); hashesByPrimaryType.set(primaryType, list); diff --git a/src/index.ts b/src/index.ts index a849680..401217c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -30,7 +30,7 @@ import { extractPrimaryType, formatEip712, } from "./eip712.js"; -import { warn } from "./utils.js"; +import { parseChainId, warn } from "./utils.js"; import type { DisplayModel, FormatOptions, @@ -57,7 +57,6 @@ export { export { attestationPathForDescriptor, computeDescriptorHash, - verifyAttestation, } from "./attestations.js"; /** EIP-712 utility helpers. */ @@ -88,6 +87,7 @@ export async function format( tx.chainId, tx.to, opts?.descriptorResolverOptions, + opts?.externalDataProvider?.chainClient, ); if ("warning" in result) { @@ -231,7 +231,8 @@ export async function formatTypedData( opts?: FormatOptions, ): Promise { try { - const { chainId, verifyingContract } = typedData.domain; + const chainId = parseChainId(typedData.domain.chainId); + const { verifyingContract } = typedData.domain; if (!chainId || !verifyingContract) { return { @@ -249,6 +250,7 @@ export async function formatTypedData( const result = await resolveTypedDataDescriptor( typedData, opts?.descriptorResolverOptions, + opts?.externalDataProvider?.chainClient, ); if ("warning" in result) { return { warnings: [result.warning] }; diff --git a/src/resolver.ts b/src/resolver.ts index d81d00e..afa4b36 100644 --- a/src/resolver.ts +++ b/src/resolver.ts @@ -9,10 +9,12 @@ import { fetchPrebuiltRegistryIndex } from "./github-registry-index.js"; import { attestationPathForDescriptor, computeDescriptorHash, + isAttestationRevoked, verifyAttestation, } from "./attestations.js"; import type { AttestationOptions, + ChainClient, CustomResolverOptions, Descriptor, DescriptorResolver, @@ -26,12 +28,13 @@ import type { } from "./types.js"; import { buildBundledTokenDescriptor } from "./bundled-descriptors.js"; import { - asciiToBytes, bytesToHex, hexToBytes, keccak256, normalizeAddress, + parseChainId, toChecksumAddress, + utf8ToBytes, warn, } from "./utils.js"; @@ -77,21 +80,27 @@ async function createResolver( /** * Resolves a calldata descriptor by `(chainId, contractAddress)`. Returns * a `{ descriptor }` envelope on success, or a `{ warning }` envelope when - * resolution fails — `NO_DESCRIPTOR` when nothing is indexed for the pair, - * `CYCLIC_INCLUDES` when the `includes` chain self-references, and - * `NO_TRUSTED_ATTESTATION` / `ATTESTATIONS_NOT_SUPPORTED` when an - * `options.attestations` policy is set but not satisfied. + * resolution fails: + * + * - `NO_DESCRIPTOR` — nothing is indexed for the pair. + * - `CYCLIC_INCLUDES` — the `includes` chain self-references. + * - `ATTESTATION_OPTIONS_INCOMPLETE` — an `options.attestations` policy is + * set, but `chainClient` is missing or the resolver has no + * `fetchAttestation`. + * - `NO_TRUSTED_ATTESTATION` — no trusted attester has a valid attestation + * for the descriptor. The policy reads revocation state through + * `chainClient`. * * If no descriptor is indexed for the chain and address, the method also checks * the optional `options.trustedTokens` list for a matching trusted token. In case * of a matching trusted token, a token descriptor is generated on the fly. The - * attestation policy does not apply to these bundled descriptors — the wallet - * already vouches for the listed contracts directly. + * attestation policy does not apply to these bundled descriptors. */ export async function resolveCalldataDescriptor( chainId: number, to: string, options?: GitHubResolverOptions | CustomResolverOptions, + chainClient?: ChainClient, ): Promise { const resolver = await createResolver(options); const path = @@ -103,6 +112,7 @@ export async function resolveCalldataDescriptor( path, resolved, options?.attestations, + chainClient, ); } @@ -142,17 +152,25 @@ function lookupTrustedToken( * * Looks up candidates by `(chainId, verifyingContract, primaryType)`, then * picks the entry whose `encodeTypeHashes` contain the keccak256 hash of - * the message's EIP-712 `encodeType` string. Returns `NO_DESCRIPTOR` if no - * candidate matches, `CYCLIC_INCLUDES` if the `includes` chain self-references, - * `NO_TRUSTED_ATTESTATION` / `ATTESTATIONS_NOT_SUPPORTED` if an - * `options.attestations` policy is set but not satisfied, or `{ descriptor }` - * on success. + * the message's EIP-712 `encodeType` string. Returns a `{ descriptor }` + * envelope on success, or a `{ warning }` envelope when resolution fails: + * + * - `NO_DESCRIPTOR` — no candidate matches. + * - `CYCLIC_INCLUDES` — the `includes` chain self-references. + * - `ATTESTATION_OPTIONS_INCOMPLETE` — an `options.attestations` policy is + * set, but `chainClient` is missing or the resolver has no + * `fetchAttestation`. + * - `NO_TRUSTED_ATTESTATION` — no trusted attester has a valid attestation + * for the descriptor. The policy reads revocation state through + * `chainClient`. */ export async function resolveTypedDataDescriptor( typedData: TypedData, options?: GitHubResolverOptions | CustomResolverOptions, + chainClient?: ChainClient, ): Promise { - const { chainId, verifyingContract } = typedData.domain; + const chainId = parseChainId(typedData.domain.chainId); + const { verifyingContract } = typedData.domain; if (chainId === undefined || !verifyingContract) { return noDescriptorWarning(chainId, verifyingContract); } @@ -170,7 +188,7 @@ export async function resolveTypedDataDescriptor( typedData.types, ); if (!encodeTypeStr) return noDescriptorWarning(chainId, verifyingContract); - const hash = bytesToHex(keccak256(asciiToBytes(encodeTypeStr))); + const hash = bytesToHex(keccak256(utf8ToBytes(encodeTypeStr))); const match = entries.find((e) => e.encodeTypeHashes.includes(hash)); if (!match) return noDescriptorWarning(chainId, verifyingContract); @@ -180,6 +198,7 @@ export async function resolveTypedDataDescriptor( match.path, resolved, options?.attestations, + chainClient, ); } @@ -190,24 +209,32 @@ export async function resolveTypedDataDescriptor( * (includes-merged) content. Passes the result through unchanged when no * policy is set or resolution already failed. * - * Returns an `ATTESTATIONS_NOT_SUPPORTED` warning when the resolver does not - * implement `fetchAttestation`, and `NO_TRUSTED_ATTESTATION` (with the - * per-attester failure reasons in the message) when no trusted attestation - * verifies. Attestation fetch and `checkRevocation` I/O errors still throw, - * consistent with descriptor fetching. + * `verifyAttestation` throws on a failed check; that error is caught here + * and becomes a per-attester failure reason. Attestation fetch and + * `chainClient` I/O errors still throw, consistent with descriptor fetching. */ async function applyAttestationPolicy( resolver: DescriptorResolver, path: string, resolved: ResolveDescriptorResult, options: AttestationOptions | undefined, + chainClient: ChainClient | undefined, ): Promise { if (!options || "warning" in resolved) return resolved; + if (!chainClient) { + return { + warning: warn( + "ATTESTATION_OPTIONS_INCOMPLETE", + "An attestation policy is set but no chainClient is available for the revocation check", + ), + }; + } + if (!resolver.fetchAttestation) { return { warning: warn( - "ATTESTATIONS_NOT_SUPPORTED", + "ATTESTATION_OPTIONS_INCOMPLETE", "An attestation policy is set but the descriptor resolver does not implement fetchAttestation", ), }; @@ -228,19 +255,25 @@ async function applyAttestationPolicy( const attestation = await resolver.fetchAttestation(path, attester); if (!attestation) continue; - const result = await verifyAttestation(attestation, descriptorHash, { - checkRevocation: options.checkRevocation, - }); - if ("reason" in result) { - failures.push(`${attester}: ${result.reason}`); + let verified: ReturnType; + try { + verified = verifyAttestation(attestation, descriptorHash); + } catch (error) { + failures.push( + `${attester}: ${error instanceof Error ? error.message : String(error)}`, + ); continue; } - if (normalizeAddress(result.attester) !== normalizeAddress(attester)) { + if (normalizeAddress(verified.attester) !== normalizeAddress(attester)) { failures.push( - `${attester}: attestation was signed by ${result.attester}`, + `${attester}: attestation was signed by ${verified.attester}`, ); continue; } + if (await isAttestationRevoked(chainClient, attester, verified.uid)) { + failures.push(`${attester}: attestation ${verified.uid} was revoked`); + continue; + } return resolved; } diff --git a/src/types.ts b/src/types.ts index c3cd563..d28bcd9 100644 --- a/src/types.ts +++ b/src/types.ts @@ -54,7 +54,7 @@ export type FieldType = export interface TypedDataDomain { name?: string; version?: string; - chainId?: number; + chainId?: number | string; verifyingContract?: string; salt?: string; } @@ -107,7 +107,7 @@ export type WarningCode = | "BATCH_EMPTY" | "CYCLIC_INCLUDES" | "NO_TRUSTED_ATTESTATION" - | "ATTESTATIONS_NOT_SUPPORTED"; + | "ATTESTATION_OPTIONS_INCOMPLETE"; /** Warning from formatting. */ export interface Warning { @@ -363,8 +363,43 @@ export interface DecryptedValueResult { value: string; } +/** A read-only `eth_call` request. */ +export interface EthCallRequest { + /** Target contract address. */ + to: string; + /** 0x-prefixed hex calldata. */ + data: string; +} + +/** + * Wallet-provided raw chain access. The semantic resolvers on + * {@link ExternalDataProvider} let the wallet serve data from any source. The + * chain client is different: the library selects the contract, encodes the + * call, and decodes the result. The wallet only supplies the RPC connection. + * + * Currently only used to check the ERC-8176 revocation state of an + * attestation on the EAS contract on Ethereum mainnet. + */ +export interface ChainClient { + /** + * Performs `eth_call` against `chainId` and returns the 0x-prefixed hex + * return data. Throw on transport failures and reverts; the library + * handles them. Return the data exactly as the node returns it — including + * `0x` for empty return data — and never substitute a value on failure. + */ + call: (chainId: number, request: EthCallRequest) => Promise; +} + /** Wallet-provided async resolvers for external data needed by the formatter. */ export interface ExternalDataProvider { + /** + * Raw chain access for checks the library performs itself (see + * {@link ChainClient}). Required when an {@link AttestationOptions} policy + * is set: the ERC-8176 revocation check reads the EAS contract on Ethereum + * mainnet. + */ + chainClient?: ChainClient; + /** * Resolution for addressName formats. The wallet should verify whether the * address matches any of the provided accepted types (e.g., "eoa", "contract", ...) @@ -467,68 +502,57 @@ export interface FormatOptions { // resolvedImplementationAddress?: string; } -/** Token standards for descriptor generation. */ -export type TokenStandard = "erc20" | "erc721"; - -/** Nested map: chain ID → token address → token standard. */ -export interface TrustedTokens { - [chainId: number]: { [tokenAddress: string]: TokenStandard }; -} - /** * ERC-8176 attestation policy. Controls which auditors (EAS attesters) the * wallet trusts to have reviewed descriptors. + * + * The policy needs an {@link ExternalDataProvider.chainClient}: the library + * reads the revocation state of each attestation from the EAS contract on + * Ethereum mainnet. */ export interface AttestationOptions { /** * Addresses of the attesters (auditors) whose attestations the wallet * trusts. A descriptor is only accepted when at least one listed attester * has a valid attestation over it. - * - * Both lowercase and EIP-55 checksummed addresses are accepted. */ trustedAttesters: string[]; +} - /** - * Checks whether the attester revoked the attestation. Revocation state - * lives on the canonical EAS contract on Ethereum mainnet - * (0xA1207F3BBa224E2c9c3c6D5aF63D0eb1582Ce587): call - * `getRevokeOffchain(attester, uid)` and return true when the result is - * non-zero (revoked). - * - * When omitted, verification runs fully offline and revocations are not - * detected. Production wallets should provide this check. - */ - checkRevocation?: (attester: string, uid: string) => Promise; +/** Token standards for descriptor generation. */ +export type TokenStandard = "erc20" | "erc721"; + +/** Nested map: chain ID → token address → token standard. */ +export interface TrustedTokens { + [chainId: number]: { [tokenAddress: string]: TokenStandard }; } export interface BaseResolverOptions { - /** - * Wallet-provided trusted token list. Used to generate descriptors for tokens - * on the fly. Standard tokens usually don't have a descriptor in the registry. - * Without this option, the library can't format calls to standard tokens. - * Only wallet-trusted tokens should be included in this object. - * - * Both lowercase and checksummed token addresses are accepted. - */ - trustedTokens?: TrustedTokens; - /** * ERC-8176 attestation policy. When set, a resolved registry descriptor is * only used when one of the listed trusted attesters has a valid * attestation over it; otherwise resolution fails with a - * `NO_TRUSTED_ATTESTATION` warning (or `ATTESTATIONS_NOT_SUPPORTED` when - * the resolver cannot fetch attestations). + * `NO_TRUSTED_ATTESTATION` warning. The revocation check requires an + * {@link ExternalDataProvider.chainClient}. * * When omitted, descriptors are used without any attestation check. That * mode is intended for testing only — production wallets should always set * an attestation policy. * * Bundled trusted-token descriptors (see {@link BaseResolverOptions.trustedTokens}) - * are not subject to this policy: the wallet already vouches for those - * contracts directly. + * are not subject to this policy. */ attestations?: AttestationOptions; + + /** + * Wallet-provided trusted token list. Used to generate descriptors for tokens + * on the fly. Standard tokens usually don't have a descriptor in the registry. + * Without this option, the library can't format calls to standard tokens. + * Only wallet-trusted tokens should be included in this object. + * + * Both lowercase and checksummed token addresses are accepted. + */ + trustedTokens?: TrustedTokens; } export type GitHubResolverOptions = BaseResolverOptions & { @@ -575,11 +599,7 @@ export interface DescriptorResolver { * the attester published no attestation for the descriptor; throw on I/O * failures. * - * Optional — but a resolver without it cannot satisfy an - * {@link AttestationOptions} policy, so every resolution then fails with - * `ATTESTATIONS_NOT_SUPPORTED`. The built-in GitHub and filesystem - * resolvers implement it via the registry's `sigs/` file convention (see - * `attestationPathForDescriptor`). + * Required when {@link AttestationOptions} policy is set. */ fetchAttestation?: ( descriptorPath: string, @@ -638,14 +658,6 @@ export interface GitHubSource { ref: string; } -/** EIP-712 domain of an EAS offchain attestation. */ -export interface OffchainAttestationDomain { - name?: string; - version?: string; - chainId?: string | number; - verifyingContract?: string; -} - /** The signed `Attest` message of an EAS offchain attestation. */ export interface OffchainAttestationMessage { version?: number; @@ -663,8 +675,8 @@ export interface OffchainAttestationMessage { salt?: string; } -/** ECDSA signature components of an EAS offchain attestation. */ -export interface OffchainAttestationSignature { +/** An Ethereum secp256k1 ECDSA signature: `r`, `s`, and the recovery id `v`. */ +export interface EcdsaSignature { v?: number; r?: string; s?: string; @@ -675,34 +687,22 @@ export interface OffchainAttestationSig { version?: number; /** Deterministic offchain attestation UID (bytes32 hex). */ uid?: string; - domain?: OffchainAttestationDomain; + domain?: TypedDataDomain; primaryType?: string; types?: Record; message?: OffchainAttestationMessage; - signature?: OffchainAttestationSignature; + signature?: EcdsaSignature; } /** * An EAS offchain attestation, as stored in the registry's `sigs/` - * directories per ERC-8176. Like {@link Descriptor}, all fields are optional: - * attestation files come from external sources with no runtime schema - * validation, so readers must guard every access. + * directories per ERC-8176. */ export interface OffchainAttestation { sig?: OffchainAttestationSig; - /** The attester address the file declares. The signature is authoritative. */ signer?: string; } -/** - * Result of verifying a single offchain attestation: the recovered attester - * address (EIP-55 checksummed) on success, or a human-readable failure - * reason. Whether the attester is trusted is the caller's decision. - */ -export type AttestationVerificationResult = - | { attester: string } - | { reason: string }; - export type DescriptorFieldFormatType = | "raw" | "amount" diff --git a/src/utils.ts b/src/utils.ts index 779249d..0ad2cc0 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -37,15 +37,6 @@ export function keccak256Str(hex: string): string { return bytesToHex(keccak256(hexToBytes(hex))); } -/** Encode an ASCII string to bytes without relying on TextEncoder (React Native compatible). */ -export function asciiToBytes(str: string): Uint8Array { - const bytes = new Uint8Array(str.length); - for (let i = 0; i < str.length; i++) { - bytes[i] = str.charCodeAt(i); - } - return bytes; -} - /** Encode a string as UTF-8 bytes without relying on TextEncoder (React Native compatible). */ export function utf8ToBytes(str: string): Uint8Array { const bytes: number[] = []; @@ -76,17 +67,6 @@ export function utf8ToBytes(str: string): Uint8Array { return new Uint8Array(bytes); } -/** Concatenate multiple Uint8Arrays into one. */ -export function concatBytes(...arrays: Uint8Array[]): Uint8Array { - const result = new Uint8Array(arrays.reduce((sum, a) => sum + a.length, 0)); - let offset = 0; - for (const array of arrays) { - result.set(array, offset); - offset += array.length; - } - return result; -} - /** Convert hex string to bytes. */ export function hexToBytes(hex: string): Uint8Array { const cleaned = hex.startsWith("0x") ? hex.slice(2) : hex; @@ -121,7 +101,7 @@ export function toChecksumAddress(bytes: Uint8Array): string { } const lower = bytesToHex(bytes).slice(2).toLowerCase(); - const hash = keccak256(asciiToBytes(lower)); + const hash = keccak256(utf8ToBytes(lower)); let result = "0x"; for (let i = 0; i < lower.length; i++) { @@ -189,9 +169,24 @@ export function coerceBigInt(value: unknown): bigint | undefined { return undefined; } +/** Parse a chain ID given as a number or as a decimal / 0x-hex string. */ +export function parseChainId( + value: number | string | undefined, +): number | undefined { + const parsed = coerceBigInt(value); + if ( + parsed === undefined || + parsed < 0n || + parsed > BigInt(Number.MAX_SAFE_INTEGER) + ) { + return undefined; + } + return Number(parsed); +} + /** Compute function selector from signature. */ export function selectorForSignature(signature: string): Uint8Array { - const hash = keccak256(asciiToBytes(signature)); + const hash = keccak256(utf8ToBytes(signature)); return hash.slice(0, 4); } @@ -246,6 +241,17 @@ export function bytesToSignedBigInt(bytes: Uint8Array, bits?: number): bigint { return unsigned & signBit ? unsigned - (1n << bitLen) : unsigned; } +/** Concatenate multiple Uint8Arrays into one. */ +export function concatBytes(...arrays: Uint8Array[]): Uint8Array { + const result = new Uint8Array(arrays.reduce((sum, a) => sum + a.length, 0)); + let offset = 0; + for (const array of arrays) { + result.set(array, offset); + offset += array.length; + } + return result; +} + /** Byte-wise equality check for two Uint8Arrays. */ export function bytesEqual(a: Uint8Array, b: Uint8Array): boolean { if (a.length !== b.length) return false; diff --git a/test/attestations/attestations.spec.ts b/test/attestations/attestations.spec.ts index 020f5ae..a06d060 100644 --- a/test/attestations/attestations.spec.ts +++ b/test/attestations/attestations.spec.ts @@ -20,22 +20,25 @@ import { isFieldGroup, resolveCalldataDescriptor, resolveTypedDataDescriptor, - verifyAttestation, } from "../../src/index.js"; +import { + isAttestationRevoked, + verifyAttestation, +} from "../../src/attestations.js"; import type { AttestationOptions, + ChainClient, Descriptor, DescriptorResolver, DisplayModel, ExternalDataProvider, FormatOptions, OffchainAttestation, - OffchainAttestationDomain, OffchainAttestationMessage, TrustedTokens, + TypedDataDomain, } from "../../src/types.js"; import { - asciiToBytes, bigIntToBytes, bytesToHex, concatBytes, @@ -55,6 +58,15 @@ const checksum = (addr: string) => toChecksumAddress(hexToBytes(addr)); const word = (hex: string) => hex.padStart(64, "0"); const addrWord = (addr: string) => word(addr.slice(2).toLowerCase()); +const EAS_ADDRESS = "0xa1207f3bba224e2c9c3c6d5af63d0eb1582ce587"; +const GET_REVOKE_OFFCHAIN = "0xb469318d"; +const ZERO_WORD = "0x" + "00".repeat(32); + +/** Chain client whose EAS reads report every attestation as not revoked. */ +const notRevoked: ChainClient = { call: async () => ZERO_WORD }; +/** Chain client whose EAS reads report a revocation timestamp. */ +const revoked: ChainClient = { call: async () => "0x" + word("6880b8d8") }; + const descriptor = JSON.parse( readFileSync(`${__dirname}/calldata-usdt.json`, "utf-8"), ) as Descriptor; @@ -77,7 +89,7 @@ const ATTEST_TYPE = "Attest(uint16 version,bytes32 schema,address recipient,uint64 time,uint64 expirationTime,bool revocable,bytes32 refUID,bytes data,bytes32 salt)"; const EIP712_DOMAIN_TYPE = "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"; -const EAS_DOMAIN: OffchainAttestationDomain = { +const EAS_DOMAIN: TypedDataDomain = { name: "EAS Attestation", version: "0.26", chainId: "1", @@ -115,7 +127,7 @@ function leftPadAddress(address: string): Uint8Array { } function attestDigest( - domain: OffchainAttestationDomain, + domain: TypedDataDomain, message: OffchainAttestationMessage, ): Uint8Array { const domainSeparator = keccak256( @@ -168,7 +180,7 @@ function offchainUid(message: OffchainAttestationMessage): string { function signAttestation( message: OffchainAttestationMessage, - domain: OffchainAttestationDomain = EAS_DOMAIN, + domain: TypedDataDomain = EAS_DOMAIN, ): OffchainAttestation { const signature = secp256k1.sign( attestDigest(domain, message), @@ -263,132 +275,146 @@ describe("attestationPathForDescriptor", () => { // --------------------------------------------------------------------------- describe("verifyAttestation", () => { - it("verifies the real registry attestation and recovers the attester", async () => { - const result = await verifyAttestation(registryAttestation, descriptorHash); - expect(result).toEqual({ attester: CYFRIN_ATTESTER }); + it("verifies the real registry attestation and recovers the attester", () => { + expect(verifyAttestation(registryAttestation, descriptorHash)).toEqual({ + attester: CYFRIN_ATTESTER, + uid: registryAttestation.sig?.uid, + }); }); - it("verifies a generated attestation with a bounded expiration", async () => { + it("verifies a generated attestation with a bounded expiration", () => { const attestation = signAttestation( attestMessage(descriptorHash, { expirationTime: FAR_FUTURE }), ); - const result = await verifyAttestation(attestation, descriptorHash); - expect(result).toEqual({ attester: TEST_ATTESTER }); + expect(verifyAttestation(attestation, descriptorHash)).toEqual({ + attester: TEST_ATTESTER, + uid: attestation.sig?.uid, + }); }); - it("accepts an attestation without the optional uid and signer fields", async () => { + it("accepts an attestation without the optional uid and signer fields", () => { const attestation = signAttestation(attestMessage(descriptorHash)); + const uid = attestation.sig?.uid; delete attestation.sig?.uid; delete attestation.signer; - const result = await verifyAttestation(attestation, descriptorHash); - expect(result).toEqual({ attester: TEST_ATTESTER }); - }); - - it("passes the recovered attester and recomputed uid to checkRevocation", async () => { - const checkRevocation = vi.fn(async () => false); - const result = await verifyAttestation( - registryAttestation, - descriptorHash, - { - checkRevocation, - }, - ); - expect(result).toEqual({ attester: CYFRIN_ATTESTER }); - expect(checkRevocation).toHaveBeenCalledExactlyOnceWith( - CYFRIN_ATTESTER, - registryAttestation.sig?.uid, - ); + expect(verifyAttestation(attestation, descriptorHash)).toEqual({ + attester: TEST_ATTESTER, + uid, + }); }); - it("rejects a revoked attestation", async () => { - const result = await verifyAttestation( - registryAttestation, - descriptorHash, - { - checkRevocation: async () => true, - }, - ); - assert("reason" in result); - expect(result.reason).toContain("revoked"); + it("accepts a numeric domain chainId", () => { + const attestation = signAttestation(attestMessage(descriptorHash), { + ...EAS_DOMAIN, + chainId: 1, + }); + expect(verifyAttestation(attestation, descriptorHash)).toEqual({ + attester: TEST_ATTESTER, + uid: attestation.sig?.uid, + }); }); - it("rejects an expired attestation", async () => { + it("throws on an expired attestation", () => { const attestation = signAttestation( attestMessage(descriptorHash, { expirationTime: "1000000000" }), ); - const result = await verifyAttestation(attestation, descriptorHash); - assert("reason" in result); - expect(result.reason).toContain("expired"); + expect(() => verifyAttestation(attestation, descriptorHash)).toThrow( + "attestation expired at 1000000000", + ); }); - it("rejects a non-canonical schema", async () => { + it("throws on a non-canonical schema", () => { const attestation = signAttestation( attestMessage(descriptorHash, { schema: "0x" + "ab".repeat(32) }), ); - const result = await verifyAttestation(attestation, descriptorHash); - assert("reason" in result); - expect(result.reason).toContain("not the canonical ERC-8176 schema"); + expect(() => verifyAttestation(attestation, descriptorHash)).toThrow( + "not the canonical ERC-8176 schema", + ); }); - it("rejects when the attested hash differs from the computed hash", async () => { + it("throws when the attested hash differs from the computed hash", () => { const otherHash = "0x" + "cd".repeat(32); const attestation = signAttestation(attestMessage(otherHash)); - const result = await verifyAttestation(attestation, descriptorHash); - assert("reason" in result); - expect(result.reason).toContain( + expect(() => verifyAttestation(attestation, descriptorHash)).toThrow( `attested descriptor hash ${otherHash} does not match`, ); }); - it("rejects an unsupported offchain attestation version", async () => { + it("throws on an unsupported offchain attestation version", () => { const attestation = signAttestation( attestMessage(descriptorHash, { version: 1 }), ); - const result = await verifyAttestation(attestation, descriptorHash); - assert("reason" in result); - expect(result.reason).toContain( + expect(() => verifyAttestation(attestation, descriptorHash)).toThrow( "unsupported offchain attestation version 1", ); }); - it("rejects a domain that is not the canonical EAS contract on mainnet", async () => { + it("throws on a domain that is not the canonical EAS contract on mainnet", () => { const attestation = signAttestation(attestMessage(descriptorHash), { ...EAS_DOMAIN, chainId: "11155111", }); - const result = await verifyAttestation(attestation, descriptorHash); - assert("reason" in result); - expect(result.reason).toContain("canonical EAS contract"); + expect(() => verifyAttestation(attestation, descriptorHash)).toThrow( + "canonical EAS contract", + ); }); - it("rejects a tampered message (signature recovers a different address)", async () => { + it("throws on a tampered message (signature recovers a different address)", () => { const attestation = signAttestation(attestMessage(descriptorHash)); assert(attestation.sig?.message); attestation.sig.message.time = "1785288537"; // +1s after signing // Keep the uid consistent with the tampered message so the signature // check is what fails. attestation.sig.uid = offchainUid(attestation.sig.message); - const result = await verifyAttestation(attestation, descriptorHash); - assert("reason" in result); - expect(result.reason).toContain(`not the declared signer ${TEST_ATTESTER}`); + expect(() => verifyAttestation(attestation, descriptorHash)).toThrow( + `not the declared signer ${TEST_ATTESTER}`, + ); }); - it("rejects a tampered uid", async () => { + it("throws on a tampered uid", () => { const attestation = signAttestation(attestMessage(descriptorHash)); assert(attestation.sig); attestation.sig.uid = "0x" + "ee".repeat(32); - const result = await verifyAttestation(attestation, descriptorHash); - assert("reason" in result); - expect(result.reason).toContain("does not match the computed uid"); + expect(() => verifyAttestation(attestation, descriptorHash)).toThrow( + "does not match the computed uid", + ); + }); + + it("throws on a structurally incomplete attestation", () => { + expect(() => + verifyAttestation({ signer: TEST_ATTESTER }, descriptorHash), + ).toThrow("malformed attestation"); + }); +}); + +// --------------------------------------------------------------------------- +// isAttestationRevoked +// --------------------------------------------------------------------------- + +describe("isAttestationRevoked", () => { + const uid = registryAttestation.sig?.uid ?? ""; + + it("reads getRevokeOffchain(attester, uid) on the mainnet EAS contract", async () => { + const call = vi.fn(async () => ZERO_WORD); + expect(await isAttestationRevoked({ call }, CYFRIN_ATTESTER, uid)).toBe( + false, + ); + expect(call).toHaveBeenCalledExactlyOnceWith(1, { + to: EAS_ADDRESS, + data: GET_REVOKE_OFFCHAIN + addrWord(CYFRIN_ATTESTER) + uid.slice(2), + }); }); - it("rejects a structurally incomplete attestation", async () => { - const result = await verifyAttestation( - { signer: TEST_ATTESTER }, - descriptorHash, + it("reports a non-zero revocation timestamp as revoked", async () => { + expect(await isAttestationRevoked(revoked, CYFRIN_ATTESTER, uid)).toBe( + true, ); - assert("reason" in result); - expect(result.reason).toContain("malformed attestation"); + }); + + it("throws when the eth_call result is not a 32-byte word", async () => { + await expect( + isAttestationRevoked({ call: async () => "0x" }, CYFRIN_ATTESTER, uid), + ).rejects.toThrow("Unexpected getRevokeOffchain result of 0 bytes"); }); }); @@ -409,7 +435,14 @@ const externalData: ExternalDataProvider = { const TRANSFER = "0xa9059cbb" + addrWord(ALICE) + word("f4240"); // 1 USDT -function tetherOpts(attestations?: AttestationOptions): FormatOptions { +/** + * Build format options for the Tether fixture. `chainClient` defaults to a + * client that reports no revocations; pass `null` to omit it. + */ +function tetherOpts( + attestations?: AttestationOptions, + chainClient: ChainClient | null = notRevoked, +): FormatOptions { const fsOpts = buildFilesystemResolverOpts( __dirname, { @@ -417,7 +450,7 @@ function tetherOpts(attestations?: AttestationOptions): FormatOptions { { chainId: CHAIN_ID, address: USDT, file: "calldata-usdt.json" }, ], }, - externalData, + chainClient ? { ...externalData, chainClient } : externalData, ); if (!attestations) return fsOpts; const resolverOptions = fsOpts.descriptorResolverOptions; @@ -533,30 +566,62 @@ describe("attestation policy — format() with the filesystem resolver", () => { it("rejects the descriptor when the attestation was revoked", async () => { const result = await format( tx, - tetherOpts({ - trustedAttesters: [CYFRIN_ATTESTER], - checkRevocation: async () => true, - }), + tetherOpts({ trustedAttesters: [CYFRIN_ATTESTER] }, revoked), ); expect(result.warnings?.[0].code).toBe("NO_TRUSTED_ATTESTATION"); expect(result.warnings?.[0].message).toContain("revoked"); expect(result.rawCalldataFallback?.selector).toBe("0xa9059cbb"); }); - it("formats when checkRevocation reports the attestation as not revoked", async () => { - const checkRevocation = vi.fn(async () => false); + it("reads the revocation state through the chainClient", async () => { + const call = vi.fn(async () => ZERO_WORD); const result = await format( tx, - tetherOpts({ trustedAttesters: [CYFRIN_ATTESTER], checkRevocation }), + tetherOpts({ trustedAttesters: [CYFRIN_ATTESTER] }, { call }), ); assertTetherTransfer(result); - expect(checkRevocation).toHaveBeenCalledExactlyOnceWith( - CYFRIN_ATTESTER, - registryAttestation.sig?.uid, + assert(registryAttestation.sig?.uid); + expect(call).toHaveBeenCalledExactlyOnceWith(1, { + to: EAS_ADDRESS, + data: + GET_REVOKE_OFFCHAIN + + addrWord(CYFRIN_ATTESTER) + + registryAttestation.sig.uid.slice(2), + }); + }); + + it("returns ATTESTATION_OPTIONS_INCOMPLETE when the provider has no chainClient", async () => { + const result = await format( + tx, + tetherOpts({ trustedAttesters: [CYFRIN_ATTESTER] }, null), ); + assert(result.warnings); + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0].code).toBe("ATTESTATION_OPTIONS_INCOMPLETE"); + expect(result.warnings[0].message).toContain("chainClient"); + expect(result.rawCalldataFallback?.selector).toBe("0xa9059cbb"); + }); + + it("reports a chainClient transport error as DESCRIPTOR_FETCH_ERROR", async () => { + const result = await format( + tx, + tetherOpts( + { trustedAttesters: [CYFRIN_ATTESTER] }, + { + call: async () => { + throw new Error("rpc down"); + }, + }, + ), + ); + assert(result.warnings); + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0].code).toBe("DESCRIPTOR_FETCH_ERROR"); + expect(result.warnings[0].message).toContain("rpc down"); + expect(result.rawCalldataFallback).toBeUndefined(); }); - it("returns ATTESTATIONS_NOT_SUPPORTED for a resolver without fetchAttestation", async () => { + it("returns ATTESTATION_OPTIONS_INCOMPLETE for a resolver without fetchAttestation", async () => { const resolver: DescriptorResolver = { index: { calldataIndex: { @@ -572,11 +637,12 @@ describe("attestation policy — format() with the filesystem resolver", () => { resolver, attestations: { trustedAttesters: [CYFRIN_ATTESTER] }, }, - externalDataProvider: externalData, + externalDataProvider: { ...externalData, chainClient: notRevoked }, }); assert(result.warnings); expect(result.warnings).toHaveLength(1); - expect(result.warnings[0].code).toBe("ATTESTATIONS_NOT_SUPPORTED"); + expect(result.warnings[0].code).toBe("ATTESTATION_OPTIONS_INCOMPLETE"); + expect(result.warnings[0].message).toContain("fetchAttestation"); expect(result.rawCalldataFallback?.selector).toBe("0xa9059cbb"); }); @@ -590,7 +656,8 @@ describe("attestation policy — format() with the filesystem resolver", () => { index: { calldataIndex: {}, typedDataIndex: {} }, trustedTokens, // No attestation exists for the bundled descriptor, but the wallet - // vouches for the token directly, so formatting must still work. + // vouches for the token directly, so formatting must still work — + // even without a chainClient. attestations: { trustedAttesters: [CYFRIN_ATTESTER] }, }, externalDataProvider: externalData, @@ -670,6 +737,7 @@ describe("attestation policy — includes resolution", () => { resolver: buildResolver({ [sigsPath]: attestation }), attestations: { trustedAttesters: [TEST_ATTESTER] }, }, + externalDataProvider: { chainClient: notRevoked }, }, ); @@ -694,6 +762,7 @@ describe("attestation policy — includes resolution", () => { resolver: buildResolver({ [sigsPath]: attestation }), attestations: { trustedAttesters: [TEST_ATTESTER] }, }, + externalDataProvider: { chainClient: notRevoked }, }, ); @@ -747,7 +816,7 @@ describe("attestation policy — resolveTypedDataDescriptor", () => { { path: "eip712-mail.json", encodeTypeHashes: [ - bytesToHex(keccak256(asciiToBytes(ENCODE_TYPE))), + bytesToHex(keccak256(utf8ToBytes(ENCODE_TYPE))), ], }, ], @@ -764,23 +833,78 @@ describe("attestation policy — resolveTypedDataDescriptor", () => { const attestation = signAttestation( attestMessage(computeDescriptorHash(eip712Descriptor)), ); - const result = await resolveTypedDataDescriptor(typedData, { - type: "custom", - resolver: buildResolver({ - [`sigs/eip712-mail.eip155-1-${TEST_ATTESTER}.json`]: attestation, - }), - attestations: { trustedAttesters: [TEST_ATTESTER] }, - }); + const result = await resolveTypedDataDescriptor( + typedData, + { + type: "custom", + resolver: buildResolver({ + [`sigs/eip712-mail.eip155-1-${TEST_ATTESTER}.json`]: attestation, + }), + attestations: { trustedAttesters: [TEST_ATTESTER] }, + }, + notRevoked, + ); assert("descriptor" in result); expect(result.descriptor).toEqual(eip712Descriptor); }); + it("rejects an attestation file signed by an address other than the trusted attester", async () => { + // A valid attestation by the test key, stored under Cyfrin's file name. + const attestation = signAttestation( + attestMessage(computeDescriptorHash(eip712Descriptor)), + ); + const call = vi.fn(async () => ZERO_WORD); + const result = await resolveTypedDataDescriptor( + typedData, + { + type: "custom", + resolver: buildResolver({ + [`sigs/eip712-mail.eip155-1-${CYFRIN_ATTESTER}.json`]: attestation, + }), + attestations: { trustedAttesters: [CYFRIN_ATTESTER] }, + }, + { call }, + ); + assert("warning" in result); + expect(result.warning.code).toBe("NO_TRUSTED_ATTESTATION"); + expect(result.warning.message).toContain( + `${CYFRIN_ATTESTER}: attestation was signed by ${TEST_ATTESTER}`, + ); + expect(call).not.toHaveBeenCalled(); + }); + + it("does not read the chain when an attestation fails verification", async () => { + const call = vi.fn(async () => ZERO_WORD); + const attestation = signAttestation(attestMessage("0x" + "cd".repeat(32))); + const result = await resolveTypedDataDescriptor( + typedData, + { + type: "custom", + resolver: buildResolver({ + [`sigs/eip712-mail.eip155-1-${TEST_ATTESTER}.json`]: attestation, + }), + attestations: { trustedAttesters: [TEST_ATTESTER] }, + }, + { call }, + ); + assert("warning" in result); + expect(result.warning.code).toBe("NO_TRUSTED_ATTESTATION"); + expect(result.warning.message).toContain( + "does not match the computed hash", + ); + expect(call).not.toHaveBeenCalled(); + }); + it("fails with NO_TRUSTED_ATTESTATION when no attestation exists", async () => { - const result = await resolveTypedDataDescriptor(typedData, { - type: "custom", - resolver: buildResolver({}), - attestations: { trustedAttesters: [TEST_ATTESTER] }, - }); + const result = await resolveTypedDataDescriptor( + typedData, + { + type: "custom", + resolver: buildResolver({}), + attestations: { trustedAttesters: [TEST_ATTESTER] }, + }, + notRevoked, + ); assert("warning" in result); expect(result.warning.code).toBe("NO_TRUSTED_ATTESTATION"); expect(result.warning.message).toContain("eip712-mail.json"); diff --git a/test/erc7730-test-cases/example-eip712.spec.ts b/test/erc7730-test-cases/example-eip712.spec.ts index 7aed601..80af712 100644 --- a/test/erc7730-test-cases/example-eip712.spec.ts +++ b/test/erc7730-test-cases/example-eip712.spec.ts @@ -157,6 +157,30 @@ describe("example-eip712.json — PermitSingle", () => { expect(result.warnings).toBeUndefined(); }); + it("accepts domain.chainId as a decimal or 0x-hex string", async () => { + const opts = buildOpts({ resolveToken }); + + for (const chainId of ["1", "0x1"]) { + const data: TypedData = { + ...PERMIT_SINGLE, + domain: { ...PERMIT_SINGLE.domain, chainId }, + }; + const result = await formatTypedData(data, opts); + + expect(result.intent).toBe("Authorize spending of token"); + assert(result.fields); + expect(result.fields).toHaveLength(3); + + // resolveToken only answers for the numeric chain ID, so a resolved + // amount proves the string was normalized before reaching the provider. + const amountField = result.fields[1]; + assert(!isFieldGroup(amountField)); + expect(amountField.value).toBe("1 USDC"); + expect(amountField.warning).toBeUndefined(); + expect(result.warnings).toBeUndefined(); + } + }); + it("returns UNKNOWN_TOKEN warning when token cannot be resolved", async () => { const opts = buildOpts(); diff --git a/test/utils.ts b/test/utils.ts index a6fc10c..b43c1ec 100644 --- a/test/utils.ts +++ b/test/utils.ts @@ -6,7 +6,7 @@ import type { RegistryIndex, TypeMember, } from "../src/types.js"; -import { asciiToBytes, bytesToHex, keccak256 } from "../src/utils.js"; +import { bytesToHex, keccak256, utf8ToBytes } from "../src/utils.js"; /** * Compute the EIP-712 `encodeType` string for a primary type, throwing if @@ -75,7 +75,7 @@ export function buildFilesystemResolverOpts( for (const encodeTypeStr of encodeTypes) { const primaryType = extractPrimaryType(encodeTypeStr); if (!primaryType) continue; - const hash = bytesToHex(keccak256(asciiToBytes(encodeTypeStr))); + const hash = bytesToHex(keccak256(utf8ToBytes(encodeTypeStr))); const list = hashesByPrimaryType.get(primaryType) ?? []; list.push(hash); hashesByPrimaryType.set(primaryType, list);