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);