diff --git a/AGENTS.md b/AGENTS.md index 07fe881..6f32e12 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,21 @@ 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; 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 + `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 +117,12 @@ 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). 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 @@ -117,6 +139,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 +186,68 @@ 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 `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). 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 `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 + 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 +276,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. @@ -443,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.** @@ -506,6 +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, 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 @@ -541,6 +635,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) @@ -606,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. @@ -613,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 fdb964d..2706247 100644 --- a/GUIDE.md +++ b/GUIDE.md @@ -44,14 +44,51 @@ 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. + +[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): + +```typescript +const descriptorResolverOptions = { + type: "github", + index, + attestations: { + // Attester addresses from the registry's auditors/ directory, + // lowercase or EIP-55 checksummed. + trustedAttesters: ["0x3846c3A30E62075Fa916216b35EF04B8F53931f6"], + }, +}; +``` + +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 +// 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"; + }, +}; +``` + +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. + +## 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"; @@ -64,15 +101,24 @@ const trustedTokens: TrustedTokens = { }, }; -const descriptorResolverOptions = { type: "github", index, trustedTokens }; +const descriptorResolverOptions = { + type: "github", + index, + attestations, + trustedTokens, +}; ``` -## 4. Build the `ExternalDataProvider` +The attestation policy from §3 does not apply to this list: 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, …). 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 @@ -149,15 +195,19 @@ 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 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 §3 + trustedTokens, // from §4 }, externalDataProvider, }; @@ -169,7 +219,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 +231,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 +287,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 +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), `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` (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 3c06be1..114fdb1 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. @@ -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,6 +310,43 @@ const result = await format(tx, { }); ``` +### Attestations (ERC-8176) + +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. + +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, { + descriptorResolverOptions: { + type: "github", + index, + attestations: { + // Auditor addresses your wallet trusts — see the registry's auditors/ directory. + trustedAttesters: ["0x3846c3A30E62075Fa916216b35EF04B8F53931f6"], + }, + }, + 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), + }, + }, +}); +``` + +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`. + +**Without an `attestations` policy, the library uses registry descriptors without any review check. Use that mode for testing only.** + +Notes: + +- 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. 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..5a2cc40 --- /dev/null +++ b/src/attestations.ts @@ -0,0 +1,365 @@ +/** + * ERC-8176 descriptor attestations. + * + * Auditors attest ERC-7730 descriptors with EAS offchain attestations over a + * canonical `bytes32 descriptorHash` schema. This module implements the + * 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. + */ + +import { secp256k1 } from "@noble/curves/secp256k1"; +import type { + ChainClient, + Descriptor, + EcdsaSignature, + OffchainAttestation, + OffchainAttestationMessage, + TypedDataDomain, +} from "./types.js"; +import { + bigIntToBytes, + bytesToHex, + bytesToUnsignedBigInt, + coerceBigInt, + concatBytes, + hexToBytes, + keccak256, + normalizeAddress, + parseChainId, + selectorForSignature, + 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; + +/** `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; + +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`. + */ +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`; +} + +/** 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 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, and + * 6. the recomputed offchain attestation UID matches the declared one. + */ +export function verifyAttestation( + attestation: OffchainAttestation, + descriptorHash: string, +): VerifiedAttestation { + const { domain, message, signature } = attestation.sig ?? {}; + if (!domain || !message || !signature) { + throw new Error( + "malformed attestation: sig.domain, sig.message, or sig.signature is missing", + ); + } + + if (message.version !== OFFCHAIN_ATTESTATION_VERSION) { + throw new Error( + `unsupported offchain attestation version ${String(message.version)}`, + ); + } + if ( + typeof message.schema !== "string" || + message.schema.toLowerCase() !== ERC8176_SCHEMA_UID + ) { + throw new Error( + `schema ${String(message.schema)} is not the canonical ERC-8176 schema`, + ); + } + if ( + typeof message.data !== "string" || + message.data.toLowerCase() !== descriptorHash.toLowerCase() + ) { + 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" || + parseChainId(domain.chainId) !== EAS_MAINNET_CHAIN_ID || + typeof domain.verifyingContract !== "string" || + normalizeAddress(domain.verifyingContract) !== EAS_MAINNET_ADDRESS + ) { + throw new Error( + "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" + ) { + throw new Error("malformed attestation: message fields are missing"); + } + if ( + expirationTime !== 0n && + expirationTime <= BigInt(Math.floor(Date.now() / 1000)) + ) { + throw new Error(`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 { + throw new Error("malformed attestation: invalid signature or encoding"); + } + + const { signer } = attestation; + if ( + signer !== undefined && + (typeof signer !== "string" || + normalizeAddress(signer) !== normalizeAddress(attester)) + ) { + throw new Error( + `signature recovers ${attester}, not the declared signer ${String(signer)}`, + ); + } + + const declaredUid = attestation.sig?.uid; + if ( + declaredUid !== undefined && + (typeof declaredUid !== "string" || declaredUid.toLowerCase() !== uid) + ) { + throw new Error( + `declared attestation uid ${String(declaredUid)} does not match the computed uid ${uid}`, + ); + } + + return { attester, uid }; +} + +/** + * 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. */ +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: TypedDataDomain, + 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: EcdsaSignature, +): 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 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, + 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/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/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/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 9522524..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, @@ -54,6 +54,10 @@ export { resolveTypedDataDescriptor, mergeDescriptors, } from "./resolver.js"; +export { + attestationPathForDescriptor, + computeDescriptorHash, +} from "./attestations.js"; /** EIP-712 utility helpers. */ export const eip712 = { @@ -83,6 +87,7 @@ export async function format( tx.chainId, tx.to, opts?.descriptorResolverOptions, + opts?.externalDataProvider?.chainClient, ); if ("warning" in result) { @@ -226,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 { @@ -244,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 7757ea3..afa4b36 100644 --- a/src/resolver.ts +++ b/src/resolver.ts @@ -1,16 +1,26 @@ 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, + isAttestationRevoked, + verifyAttestation, +} from "./attestations.js"; import type { + AttestationOptions, + ChainClient, CustomResolverOptions, Descriptor, DescriptorResolver, GitHubResolverOptions, GitHubSource, + OffchainAttestation, TokenStandard, TrustedTokens, TypedData, @@ -18,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"; @@ -56,6 +67,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, }; } } @@ -64,22 +80,41 @@ 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. + * 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. + * of a matching trusted token, a token descriptor is generated on the fly. The + * 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 = 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, + chainClient, + ); + } // No registry descriptor. Check if a trusted token matches. const standard = lookupTrustedToken(options?.trustedTokens, chainId, to); @@ -117,15 +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, - * 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); } @@ -143,11 +188,103 @@ 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); - return resolveWithIncludes(resolver, match.path); + const resolved = await resolveWithIncludes(resolver, match.path); + return applyAttestationPolicy( + resolver, + match.path, + resolved, + options?.attestations, + chainClient, + ); +} + +/** + * 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. + * + * `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( + "ATTESTATION_OPTIONS_INCOMPLETE", + "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; + + let verified: ReturnType; + try { + verified = verifyAttestation(attestation, descriptorHash); + } catch (error) { + failures.push( + `${attester}: ${error instanceof Error ? error.message : String(error)}`, + ); + continue; + } + if (normalizeAddress(verified.attester) !== normalizeAddress(attester)) { + failures.push( + `${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; + } + + 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..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; } @@ -105,7 +105,9 @@ export type WarningCode = | "BATCH_CONTRACT_CREATION" | "BATCH_INTERPOLATION_INCOMPLETE" | "BATCH_EMPTY" - | "CYCLIC_INCLUDES"; + | "CYCLIC_INCLUDES" + | "NO_TRUSTED_ATTESTATION" + | "ATTESTATION_OPTIONS_INCOMPLETE"; /** Warning from formatting. */ export interface Warning { @@ -361,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", ...) @@ -465,6 +502,23 @@ export interface FormatOptions { // resolvedImplementationAddress?: string; } +/** + * 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. + */ + trustedAttesters: string[]; +} + /** Token standards for descriptor generation. */ export type TokenStandard = "erc20" | "erc721"; @@ -474,6 +528,22 @@ export interface TrustedTokens { } export interface BaseResolverOptions { + /** + * 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. 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. + */ + 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. @@ -521,6 +591,20 @@ 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. + * + * Required when {@link AttestationOptions} policy is set. + */ + fetchAttestation?: ( + descriptorPath: string, + attester: string, + ) => Promise; } export interface RegistryIndex { @@ -574,6 +658,51 @@ export interface GitHubSource { ref: 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; +} + +/** An Ethereum secp256k1 ECDSA signature: `r`, `s`, and the recovery id `v`. */ +export interface EcdsaSignature { + 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?: TypedDataDomain; + primaryType?: string; + types?: Record; + message?: OffchainAttestationMessage; + signature?: EcdsaSignature; +} + +/** + * An EAS offchain attestation, as stored in the registry's `sigs/` + * directories per ERC-8176. + */ +export interface OffchainAttestation { + sig?: OffchainAttestationSig; + signer?: string; +} + export type DescriptorFieldFormatType = | "raw" | "amount" diff --git a/src/utils.ts b/src/utils.ts index 96c12e0..0ad2cc0 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -37,13 +37,34 @@ 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); +/** 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 bytes; + return new Uint8Array(bytes); } /** Convert hex string to bytes. */ @@ -80,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++) { @@ -148,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); } @@ -176,12 +212,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; } @@ -205,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 new file mode 100644 index 0000000..a06d060 --- /dev/null +++ b/test/attestations/attestations.spec.ts @@ -0,0 +1,912 @@ +/** + * 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, +} from "../../src/index.js"; +import { + isAttestationRevoked, + verifyAttestation, +} from "../../src/attestations.js"; +import type { + AttestationOptions, + ChainClient, + Descriptor, + DescriptorResolver, + DisplayModel, + ExternalDataProvider, + FormatOptions, + OffchainAttestation, + OffchainAttestationMessage, + TrustedTokens, + TypedDataDomain, +} from "../../src/types.js"; +import { + 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 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; +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: TypedDataDomain = { + 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: TypedDataDomain, + 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: TypedDataDomain = 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", () => { + expect(verifyAttestation(registryAttestation, descriptorHash)).toEqual({ + attester: CYFRIN_ATTESTER, + uid: registryAttestation.sig?.uid, + }); + }); + + it("verifies a generated attestation with a bounded expiration", () => { + const attestation = signAttestation( + attestMessage(descriptorHash, { expirationTime: FAR_FUTURE }), + ); + expect(verifyAttestation(attestation, descriptorHash)).toEqual({ + attester: TEST_ATTESTER, + uid: attestation.sig?.uid, + }); + }); + + 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; + expect(verifyAttestation(attestation, descriptorHash)).toEqual({ + attester: TEST_ATTESTER, + uid, + }); + }); + + 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("throws on an expired attestation", () => { + const attestation = signAttestation( + attestMessage(descriptorHash, { expirationTime: "1000000000" }), + ); + expect(() => verifyAttestation(attestation, descriptorHash)).toThrow( + "attestation expired at 1000000000", + ); + }); + + it("throws on a non-canonical schema", () => { + const attestation = signAttestation( + attestMessage(descriptorHash, { schema: "0x" + "ab".repeat(32) }), + ); + expect(() => verifyAttestation(attestation, descriptorHash)).toThrow( + "not the canonical ERC-8176 schema", + ); + }); + + it("throws when the attested hash differs from the computed hash", () => { + const otherHash = "0x" + "cd".repeat(32); + const attestation = signAttestation(attestMessage(otherHash)); + expect(() => verifyAttestation(attestation, descriptorHash)).toThrow( + `attested descriptor hash ${otherHash} does not match`, + ); + }); + + it("throws on an unsupported offchain attestation version", () => { + const attestation = signAttestation( + attestMessage(descriptorHash, { version: 1 }), + ); + expect(() => verifyAttestation(attestation, descriptorHash)).toThrow( + "unsupported offchain attestation version 1", + ); + }); + + it("throws on a domain that is not the canonical EAS contract on mainnet", () => { + const attestation = signAttestation(attestMessage(descriptorHash), { + ...EAS_DOMAIN, + chainId: "11155111", + }); + expect(() => verifyAttestation(attestation, descriptorHash)).toThrow( + "canonical EAS contract", + ); + }); + + 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); + expect(() => verifyAttestation(attestation, descriptorHash)).toThrow( + `not the declared signer ${TEST_ATTESTER}`, + ); + }); + + it("throws on a tampered uid", () => { + const attestation = signAttestation(attestMessage(descriptorHash)); + assert(attestation.sig); + attestation.sig.uid = "0x" + "ee".repeat(32); + 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("reports a non-zero revocation timestamp as revoked", async () => { + expect(await isAttestationRevoked(revoked, CYFRIN_ATTESTER, uid)).toBe( + true, + ); + }); + + 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"); + }); +}); + +// --------------------------------------------------------------------------- +// 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 + +/** + * 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, + { + calldataDescriptorFiles: [ + { chainId: CHAIN_ID, address: USDT, file: "calldata-usdt.json" }, + ], + }, + chainClient ? { ...externalData, chainClient } : 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] }, revoked), + ); + expect(result.warnings?.[0].code).toBe("NO_TRUSTED_ATTESTATION"); + expect(result.warnings?.[0].message).toContain("revoked"); + expect(result.rawCalldataFallback?.selector).toBe("0xa9059cbb"); + }); + + 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] }, { call }), + ); + assertTetherTransfer(result); + 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 ATTESTATION_OPTIONS_INCOMPLETE 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, chainClient: notRevoked }, + }); + assert(result.warnings); + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0].code).toBe("ATTESTATION_OPTIONS_INCOMPLETE"); + expect(result.warnings[0].message).toContain("fetchAttestation"); + 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 — + // even without a chainClient. + 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] }, + }, + externalDataProvider: { chainClient: notRevoked }, + }, + ); + + 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] }, + }, + externalDataProvider: { chainClient: notRevoked }, + }, + ); + + 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(utf8ToBytes(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] }, + }, + 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] }, + }, + 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/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/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/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/); + }); +}); 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);