Skip to content

Latest commit

 

History

History
751 lines (598 loc) · 45.3 KB

File metadata and controls

751 lines (598 loc) · 45.3 KB

AGENTS.md - AI Agent Guide

This file provides context for AI coding assistants working on this codebase.

Project Overview

This is a TypeScript implementation of ERC-7730 - Structured Data Clear Signing Format. It transforms Ethereum transaction calldata and EIP-712 typed data into human-readable display models for wallet clear signing UIs.

Architecture

src/
├── index.ts                    # Public API: format(), formatTypedData(), formatEip5792Batch()
├── types.ts                    # TypeScript interfaces and types
├── utils.ts                    # Crypto & formatting utilities
├── descriptor.ts               # Shared descriptor logic: binding checks, path resolution, field merging
├── fields.ts                   # Field processing pipeline: applyFieldFormats() loop and field groups
├── 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, 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
└── github-registry-index.ts    # In-memory index built from the GitHub registry file tree

Module responsibilities

  • descriptor.ts — Shared descriptor utilities used by both calldata and EIP-712 paths: descriptor binding checks (isCalldataDescriptorBoundTo, isEip712DescriptorBoundTo), path resolution (resolveTransactionPath, resolveTypedDataPath), value conversion (ArgumentValue, BytesSliceValue, toArgumentValue, argumentValueToBytes, argumentValueEquals), format-to-type mapping (fieldTypeForFormat), field/definition merging (mergeDefinitions, resolveFieldValue), metadata resolution (resolveMetadataValue), and template interpolation (interpolateTemplate). Defines the BaseResolvePath (returns ArgumentValue) and ResolvePath (returns ArgumentValue | BytesSliceValue) type aliases.

  • fields.ts — The field processing pipeline. Primary entry point is applyFieldFormats(), which iterates format fields, merges definitions, resolves values, and renders each field. Handles field groups with array iteration (group-level and child-level patterns, sequential and bundled modes) as well as struct groups (group path points to a static tuple, children resolved relative to it; always emitted as a DisplayFieldGroup, with the group's label passed through as-is — may be undefined). Delegates individual field rendering to formatters.ts. Contains byte slice support: parseByteSlice, applyByteSlice, buildSliceResolvePath (wraps a BaseResolvePath to handle slice paths transparently), and bytesSliceToArgumentValue (converts BytesSliceValue to ArgumentValue using the field format's expected type). Also contains encryption support: parsePlaintextType (canonical Solidity type → FieldType plus the type's maximum byte width) and decryptFieldValue (calls the wallet's resolveDecryptedValue, rejects a plaintext too wide for its declared type, and re-interprets the returned bytes via bytesSliceToFieldType). When decryption fails, processSingleField substitutes DEFAULT_ENCRYPTED_PLACEHOLDER / the descriptor's fallbackLabel for the renderField call, so the fallback flows through the normal DisplayField path.

  • formatters.ts — Individual format handlers dispatched by renderField(). Includes formatRaw, formatTimestamp, renderTokenAmount, formatNftName, formatDuration, formatUnit, formatAddressName, formatTokenTicker, formatChainId, formatNativeAmount, resolveEnumLabel, isSenderAddress, isNativeCurrencyAddress, etc. Also defines FieldFormatOptions and RenderFieldResult types. Handlers are exported for unit testing. Most format handlers delegate $.metadata.* path resolution to the resolvePath closure rather than calling resolveMetadataValue directly — only resolveEnumLabel uses it since it needs an object value that toArgumentValue cannot represent.

  • calldata.ts — Everything specific to calldata formatting. Contains the top-level formatCalldata() entry point, function signature parsing (parseFunctionSignatureKey), selector-to-format lookup (findFormatBySelector), and a unified recursive ABI decoder (decodeArgumentsdecodeComponents/decodeValue) supporting all ABI types: static and dynamic tuples, dynamic arrays (T[]), fixed-size arrays (T[k]), nested arrays, bytes/string, and bytesN. All parsing/decoding internals are module-private.

  • eip712.ts — Everything specific to EIP-712 typed data formatting. Contains the top-level formatEip712() entry point, encodeType computation and matching (findFormatSpec, computeEncodeType), message value navigation (getMessageValue), and referenced-struct collection (collectReferencedTypes). Exports computeEncodeType and 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 <dir>/sigs/<name>.eip155-1-<checksummedAttester>.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 consts in bundled/erc20.ts and bundled/erc721.ts). Exports buildBundledTokenDescriptor(standard, chainId, address), which clones the template and injects context.contract.deployments so the result passes the deployment-binding check. Used by resolveCalldataDescriptor for the trusted-token fallback. The descriptors are committed as TS rather than imported JSON — see the gotcha below.

Key Data Flow

  1. Transaction formatting:

    format(tx, opts?)
    → resolveCalldataDescriptor(tx.chainId, tx.to, opts?.descriptorResolverOptions)
        → createResolver(options) — for "github" without an explicit index,
          calls fetchPrebuiltRegistryIndex() up front
        → 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
        → applyFieldFormats() (from fields.ts) renders each field
    → returns DisplayModel
    
  2. EIP-712 formatting:

    formatTypedData(typedData, opts?)
    → resolveTypedDataDescriptor(typedData, opts?.descriptorResolverOptions)
        → createResolver(options)
        → 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
    → returns DisplayModel
    
  3. EIP-5792 batch formatting:

    formatEip5792Batch(batch, opts?)
    → for each call in batch.calls:
        → skip with BATCH_VALUE_TRANSFER warning if call.data is absent
        → skip with BATCH_CONTRACT_CREATION warning if call.to is absent
        → format({ chainId, to, data, value, from }, opts)
    → join interpolatedIntent strings with " and "
        (or emit BATCH_INTERPOLATION_INCOMPLETE if any call lacks one)
    → returns BatchDisplayModel
    

Both calldata and EIP-712 paths share the same field processing pipeline in fields.ts via applyFieldFormats(). Each builds a BaseResolvePath closure that handles @., $., #., and bare path resolution for its domain (calldata args vs. EIP-712 message fields). applyFieldFormats() internally wraps it with buildSliceResolvePath to handle byte slice paths (e.g. srcToken.[-20:]).

Trusted Token Descriptors (bundled ERC-20 / ERC-721)

descriptorResolverOptions.trustedTokens (type TrustedTokens, declared on the shared BaseResolverOptions, so both the GitHub and custom variants carry it) is an optional wallet-provided data list keyed chainId → tokenAddress → standard ("erc20" | "erc721"; address keys may be lowercase or EIP-55 checksummed — lookupTrustedToken tries both). It is the fallback after registry resolution: resolveCalldataDescriptor first looks up the registry index by (chainId, to); when the index has a path, its resolution result is returned as-is (the descriptor wins even if none of its display.formats match the calldata — in which case formatCalldata later reports NO_FORMAT_MATCH — and an includes failure surfaces its own warning). Only when the index has no path does the resolver consult options.trustedTokens[chainId][to]; if a standard is listed, buildBundledTokenDescriptor (in bundled-descriptors.ts) produces a deployment-bound ERC-20 / ERC-721 template descriptor. The resolver never parses the calldata.

The wallet must tag each token's standard — the ERC-20 and ERC-721 approve / transferFrom functions share identical 4-byte selectors with different semantics (ERC-20 value → tokenAmount vs. ERC-721 tokenId → nftName), 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: GitHubResolverOptions (type: "github") for the built-in registry, or CustomResolverOptions (type: "custom") that wraps any pre-built DescriptorResolver. The filesystem resolver (in @ethereum-sourcify/clear-signing/filesystem) is one such custom resolver, shipped from a separate entry point so it stays out of browser bundles.

GitHubResolverOptions

Fetches descriptors lazily from the Ethereum clear-signing registry. This is the default when no options are specified.

const opts: FormatOptions = {
  descriptorResolverOptions: {
    type: "github",
    githubSource: {
      repo: "ethereum/clear-signing-erc7730-registry", // optional, default
      ref: "master", // optional, default
    },
    index: myPrebuiltIndex, // optional: skip the prebuilt-index fetch
  },
};

How it works:

  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<caip10, path> — keyed by context.contract.deployments[].{chainId, address}
    • typedDataIndex: Record<caip10, Record<primaryType, TypedDataIndexEntry[]>> — 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.
  3. After the index lookup yields a path, the resolver's fetchDescriptor(path) closure fetches and parses the descriptor file; includes are merged automatically.

Fallback indexer: createGitHubRegistryIndex(source?) walks every descriptor file via the GitHub Git Trees API and indexes them in-process. It's significantly slower than fetchPrebuiltRegistryIndex but useful when the prebuilt indexes are stale, missing descriptors, or when pointing at a fork that doesn't publish them.

Known limitation — EIP-712 indexing:

ERC-7730 defines several ways to identify an EIP-712 descriptor: deployments (chain + address array), domain (key-value domain match), and domainSeparator (pre-computed hash). The index only keys on context.eip712.deployments, because the other forms cannot be cheaply pre-indexed without access to a live domain. Descriptors that rely solely on domain or domainSeparator for binding will not be discoverable through the GitHub index.

CustomResolverOptions and the filesystem resolver

{ type: "custom", resolver } plugs any DescriptorResolver into the format pipeline. The library ships one such resolver out of the box: the filesystem resolver, which lives in src/filesystem.ts and is exported from the @ethereum-sourcify/clear-signing/filesystem subpath. It is Node-only (uses node:fs/promises) and intentionally separated from the main entry so consumers that never touch it don't pull a Node builtin into their browser bundles.

import { createFilesystemResolver } from "@ethereum-sourcify/clear-signing/filesystem";

const opts: FormatOptions = {
  descriptorResolverOptions: {
    type: "custom",
    resolver: createFilesystemResolver({
      index: myIndex,
      descriptorDirectory: "./descriptors",
    }),
  },
};

Any other source (in-memory map, custom HTTP endpoint, ...) is implemented by satisfying the DescriptorResolver shape directly and wrapping it in { type: "custom", resolver }.

GitHub client module (github-registry-client.ts)

Pure I/O layer with no caching. Exports:

  • fetchRegistryFilePaths(source) — returns repo-relative paths of all descriptor files
  • fetchRegistryFile(path, source) — fetches and parses a single descriptor file
  • DEFAULT_REPO / DEFAULT_REF constants live in github-registry-index.ts

GitHub index module (github-registry-index.ts)

  • fetchPrebuiltRegistryIndex(source?) — async; fetches the registry's index.calldata.json + index.eip712.json and returns the merged RegistryIndex. Cached per (repo, ref). Used by DescriptorResolver as the default index source.
  • createGitHubRegistryIndex(source?) — async factory; walks all descriptor files and builds a RegistryIndex in-process. Fallback for when the prebuilt indexes are missing entries or unavailable.

Descriptor Includes & Merging

ERC-7730 descriptors may reference another descriptor file via a top-level includes field containing a relative path. DescriptorResolver automatically fetches and merges the included file before returning the descriptor. Includes may be chained — each included descriptor is resolved with its own includes before being merged upward. A cyclic include chain returns a CYCLIC_INCLUDES warning rather than throwing.

resolveCalldataDescriptor / resolveTypedDataDescriptor return Promise<{ descriptor } | { warning }>. The warning is NO_DESCRIPTOR when nothing is indexed for the lookup key, or CYCLIC_INCLUDES when the include chain self-references. Fetch errors from the underlying descriptor I/O still throw — only resolution-logic failures surface as warnings.

The merge is implemented in mergeDescriptors(including, included) (internal to resolver.ts) and follows the EIP-7730 spec:

  • General keys: the including descriptor's value wins; nested objects are deep-merged recursively.
  • display.formats[*].fields arrays: merged by path value — fields from the including descriptor override matching entries in the included descriptor, and new path values are appended.
  • includes key: dropped from the merged result.

Include path resolution uses new URL(relative, base) in the resolver.

Important Concepts

Descriptors (EIP-7730)

JSON files that define how to display contract interactions:

  • context.contract.deployments — chain/address bindings
  • display.formats — per-function display rules with field formatting. Keys are the full function signatures including parameter names, e.g. "approve(address spender,uint256 value)". These keys are the sole source of function selector computation and calldata decoding — no separate ABI field is needed or used.
  • metadata — constants, owner info, etc.

context.contract.abi is deprecated and removed from the current ERC-7730 spec. The library ignores it. Function descriptors are derived entirely from display.formats keys via parseFunctionSignatureKey() in calldata.ts.

required and excluded arrays on format entries are also legacy and not part of the current spec. Do not add them to DescriptorFormatSpec.

Function signature key rules (from spec):

  • Keys MUST include parameter names: "transfer(address to,uint256 value)" not "transfer(address,uint256)"
  • Commas MUST NOT be followed by spaces
  • Exactly one space between type and parameter name
  • Only canonical Solidity types (uint256, not uint)

EIP-712 Descriptor Keys

Current ERC-7730 spec:

  • display.formats keys are the full EIP-712 encodeType string, e.g. "PermitSingle(PermitDetails details,address spender,uint256 sigDeadline)PermitDetails(address token,uint160 amount,uint48 expiration,uint48 nonce)".
  • context.eip712.schemas is deprecated — do not add to new descriptors.
  • context.eip712.deployments and context.eip712.domain are the correct binding mechanisms.

eip712.ts supports both formats: tries encodeType match first, falls back to bare primary type name.

Field Formats

Supported: raw, amount, tokenAmount, nftName, date, duration, unit, enum, addressName, tokenTicker, chainId, calldata

Not yet implemented: interoperableAddressName

Any field, regardless of format, may additionally carry an encryption annotation — see Encrypted Fields. It is orthogonal to the format: the value is decrypted first, then rendered by the format above.

Spec-compliance notes:

  • All numeric formats (date, tokenAmount, amount, enum, duration, nftName, chainId) accept both uint and int field types.
  • enum additionally accepts bool values ("true"/"false"), resolving the label case-insensitively so capitalized keys like { "True": ..., "False": ... } match (e.g. ERC-721 setApprovalForAll).
  • date format supports params.encoding of "timestamp" (unix seconds) and "blockheight" (resolved via ExternalDataProvider.resolveBlockTimestamp). Falls back to raw with UNKNOWN_ENCODING warning for missing or unsupported encodings.
  • tokenAmount supports optional chainId/chainIdPath params to override the container chain ID for cross-chain scenarios (same as tokenTicker).
  • tokenAmount message defaults to "Unlimited" when params.threshold is set but params.message is omitted.
  • nftName resolves collection name via ExternalDataProvider.resolveNftCollectionName(chainId, address).
  • tokenTicker accepts only address type; supports optional chainId/chainIdPath params to override the container chain ID for cross-chain scenarios.
  • chainId converts an integer chain ID to a human-readable chain name via ExternalDataProvider.resolveChainInfo. Falls back to raw with UNKNOWN_CHAIN warning when resolution fails.
  • amount displays a value as native currency using ExternalDataProvider.resolveChainInfo for decimals and ticker. Falls back to raw with UNKNOWN_CHAIN warning when resolution fails.
  • tokenAmount with nativeCurrencyAddress also resolves native currency metadata via resolveChainInfo.
  • addressName supports the senderAddress param: when the field value matches a senderAddress, it displays "Sender" and substitutes rawAddress with @.from. Checked via isSenderAddress().
  • resolveLocalName and resolveEnsName receive acceptedTypes?: DescriptorAddressType[] (from params.types). The parameter is absent when the descriptor defines no types. Callers should check membership with acceptedTypes?.includes(...). The library emits ADDRESS_TYPE_MISMATCH when the resolver returns typeMatch: false.
  • calldata formats a nested function call, recursing through format() and returning the inner DisplayModel on DisplayField.embeddedCalldata (with its callee and chainId). Accepts only bytes. Resolves the target via callee/calleePath and the chain via chainId/chainIdPath, defaulting to the container's chain. Optional selector/selectorPath is prepended to the value when the field carries bare arguments; amount/amountPath and spender/spenderPath become the inner tx's value and from. Falls back to raw with FORMAT_PARAM_RESOLUTION_ERROR (unresolvable callee/chainId param), CONTAINER_MISSING_CHAIN_ID, or EMBEDDED_CALLDATA_NOT_SUPPORTED (the caller supplied no nested formatter — index.ts always does, so this only surfaces when formatCalldata/formatEip712 are driven directly).
  • Raw address rendering always uses EIP-55 checksum format (not lowercase hex).

Encrypted Fields

A field may carry an encryption annotation (DescriptorFieldEncryption: { scheme, plaintextType, fallbackLabel }) marking its value as encrypted. Decryption is delegated entirely to the wallet and is optional — schemes generally need a live connection, a user signature, and an access-control check. fhevm is the only scheme ERC-7730 currently defines; the wallet-side integration is documented in DECRYPTION.md and kept out of the library.

The raw encrypted value is always reported on DisplayField.rawEncryptedValue, decrypted or not — the spec RECOMMENDS wallets show it next to the placeholder when decryption is unavailable. It is captured before argValue is replaced by the plaintext.

processSingleField decrypts before visibility evaluation and rendering, so ifNotIn/mustMatch rules and the format handler all see the plaintext. On success the plaintext replaces the field's ArgumentValue and the regular format renders it (e.g. tokenAmount"1 cUSDC").

Failures split by kind:

  • DECRYPTION_FAILED — the value is real but unavailable (no provider, no chainId, wallet returned null, malformed hex). Recoverable: the field still renders, as fallbackLabel or the generic [Encrypted] placeholder when the descriptor declares none, carrying the warning rather than being dropped. The ciphertext is never the display value; it travels on rawEncryptedValue.
  • INVALID_DESCRIPTOR — the encryption annotation itself is malformed (missing scheme/plaintextType, or a non-canonical plaintextType). A descriptor bug, not a decryption outcome, so it is fatal for the whole format, consistent with the other INVALID_DESCRIPTOR checks in processSingleField.

Coercion is driven by the descriptor's declared plaintextType, never by the returned value's shape. toArgumentValue must not be used here: it has no bigint case (returns undefined), and its shape inference would misread a bytes20 plaintext as an address. decryptFieldValue instead maps plaintextTypeFieldType and reuses bytesSliceToFieldType, mirroring how byte slices are coerced by expected type. It also passes the declared width, which matters for signed intN: the wallet chose the byte length, so inferring the sign bit from it would read a minimally-encoded positive (2000xc8) as negative. Byte slices and ABI words pass no width — there the length is the value's width.

Wallet contract (ExternalDataProvider.resolveDecryptedValue):

  • Receives (chainId, encryptedValue, { scheme, contractAddress }). encryptedValue is 0x-hex of the raw field bytes; contractAddress is the container's @.to (optional — an EIP-712 domain may declare no verifyingContract).
  • plaintextType is deliberately not passed: the wallet has no use for it. Decryption yields bytes; interpreting them is this library's job. Passing it would only invite the wallet to coerce, duplicating work the library already does from the descriptor.
  • Returns DecryptedValueResult { value: string } — 0x-hex of the plaintext's big-endian bytes only, never bigint/boolean. One encoding for every scheme and type, with no hex-vs-text ambiguity.
  • Returns null when it cannot decrypt (unsupported scheme, denied signature, no access) → DECRYPTION_FAILED. A malformed (non-hex) value is treated the same way: it is a failed decryption, not a distinct condition.

scheme is typed as DescriptorFieldEncryptionScheme, a union of the schemes ERC-7730 defines (currently just "fhevm"), so wallets get exhaustiveness checking when dispatching. Add new schemes there as the spec defines them. The library itself never inspects the value — it only passes it to the wallet, which decides what it can decrypt.

Token Resolution

Token metadata is resolved entirely via ExternalDataProvider.resolveToken(chainId, address). There is no embedded token registry. When resolveToken is absent or returns null, the library emits a UNKNOWN_TOKEN warning and falls back to the raw value.

Chain Info Resolution

Chain metadata (name, native currency) is resolved via ExternalDataProvider.resolveChainInfo(chainId). This is used by the chainId format (to display chain names), the amount format (to display native currency amounts with correct decimals and ticker), and the tokenAmount format when nativeCurrencyAddress matches. There is no embedded chain registry. When resolveChainInfo is absent or returns null, the library emits an UNKNOWN_CHAIN warning and falls back to the raw value.

Address Name Resolution

Address names are resolved via ExternalDataProvider.resolveLocalName and/or resolveEnsName. Which sources are consulted is controlled by field.params.sources in the descriptor ("local", "ens"). When resolution fails, the library returns the checksum address with an UNKNOWN_ADDRESS warning.

There is no built-in address book. All name resolution is delegated to the wallet.

Path Resolution

ERC-7730 defines multiple path prefixes:

Prefix Meaning
(none) Calldata argument name / EIP-712 message field name
#. Absolute structured data root (equivalent to bare name)
@. Container path (transaction or typed data metadata)
$.metadata.* Descriptor metadata field

EVM Transaction container paths (@. prefix)

Path Value
@.from Sender address (tx.from)
@.value Native currency value (tx.value)
@.to Destination contract address (tx.to)
@.chainId Chain ID (tx.chainId)

EIP-712 typed data container paths (@. prefix)

Path Value
@.from Signer account address (typedData.account)
@.to Verifying contract (typedData.domain.verifyingContract)
@.chainId Domain chain ID (typedData.domain.chainId)

Container paths are resolved by resolveTransactionPath() and resolveTypedDataPath() in descriptor.ts.

TypedDataDomain.chainId is number | stringeth_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.

Common Tasks

Adding a new field format

  1. Add a new case to renderField() switch in src/formatters.ts
  2. Implement the format handler as a module-private function in the same file
  3. Add the new WarningCode value to types.ts if the format can emit new warnings

Testing with a custom descriptor

Most spec tests use the filesystem resolver via buildFilesystemResolverOpts in test/utils.ts (descriptors live alongside the test as JSON files). For tests that need to mock the GitHub fetch path, pass GitHubResolverOptions with a manually built RegistryIndex and mock fetch:

const index: RegistryIndex = {
  calldataIndex: { "eip155:1:0xcontract...": "path/to/descriptor.json" },
  typedDataIndex: {},
};
const opts: FormatOptions = {
  descriptorResolverOptions: { type: "github", index },
};

Before Committing

Always run npm run fix before committing to auto-fix lint and formatting issues.

When changing the public API, input/output types, descriptor resolver options, warning codes, or the ExternalDataProvider interface, check whether GUIDE.md (the wallet integration guide) and README.md need updating too — they contain example code and type signatures that can drift out of sync with the source. If the change touches encryption, check DECRYPTION.md as well.

Keep scheme-specific integration detail (SDK calls, protocol internals) in DECRYPTION.mdsrc/, README.md, and GUIDE.md stay scheme-agnostic and describe only the resolveDecryptedValue contract.

Pull Requests

Never include a "Test plan" section in PR descriptions. Keep descriptions to a short bulleted summary of the change.

Testing

npm test              # Run all tests
npm run test:watch    # Watch mode

Tests live in test/. Current test files:

  • test/formatters.spec.ts — unit tests for all field format handlers in formatters.ts
  • test/utils.spec.ts — unit tests for the shared utility functions in utils.ts (signed integer decoding of sign-extended ABI words)
  • test/fields.spec.ts — unit tests for the field processing pipeline (groups, iteration, slices, separators)
  • test/github-registry-client.spec.ts — unit tests for the GitHub client I/O layer
  • test/erc7730-test-cases/example-main.spec.ts — ERC-7730 spec test cases using example-main.json descriptor (co-located in same directory), including EIP-5792 batch formatting tests
  • test/erc7730-test-cases/example-array-iteration.spec.ts — bundled/sequential array iteration tests
  • test/registry-cases/1inch/1inch.spec.ts — 1inch AggregationRouterV6: swap + clipperSwap (byte slice paths)
  • 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/registry-cases/ekubo/ekubo.spec.ts — Ekubo Positions: mintAndDeposit + maybeInitializePool (sign-extended int32 ticks next to a static tuple)
  • 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

  • End-to-end tests use real registry descriptors. Put them in test/registry-cases/<owner>/ with a verbatim copy of a descriptor from the clear-signing registry, and name the spec after the owner. Search the registry for a descriptor that already has the feature under test (GitHub code search on the registry repo works well). Do not write a custom descriptor or add a new test directory layout for it.
  • Unit tests live in test/<module>.spec.ts, named after the src/ module they cover. Do not name a test file after the feature or bug.
  • Be consistent with the style and patterns of existing tests in the same file.
  • Test all properties of the returned DisplayModel and its nested objects: intent, interpolatedIntent, fields, metadata (including owner, contractName, info), rawCalldataFallback, warnings.
  • Test all properties of each DisplayField: label, value, fieldType, format, warning, rawAddress, tokenAddress, embeddedCalldata. Assert that properties not expected to be present are undefined. rawEncryptedValue is deliberately not in that list — only fields carrying an encryption annotation ever set it, so assert it in encryption tests (both on success and on fallback) and leave it out of the others rather than adding undefined checks across the suite. The same applies to separator — only iterated array elements whose descriptor defines a separator ever set it, so assert it (on both the elements that carry it and their siblings that don't) in tests exercising separators only.
  • Test nested DisplayModels (e.g. embeddedCalldata.display) with the same thoroughness. For calldata fields also assert embeddedCalldata.callee and embeddedCalldata.chainId. Extract a helper function (e.g. assertNestedDistribute) when the same nested structure is verified in multiple tests.
  • Each test should verify the actual value, not just that something exists. Use toBe/toEqual with computed expected values rather than loose regex patterns.

Build

npm run build    # Compiles to dist/
npm run clean    # Removes dist/

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)

Code Patterns

Error handling

All errors use plain new Error(message). No custom error classes.

Warnings

import { warn } from "./utils";
// warn() returns a Warning with a typed WarningCode
const w = warn("UNKNOWN_TOKEN", "Token could not be resolved");

All WarningCode values are defined as a string literal union in types.ts.

Functions that produce warnings

Pattern: return warnings in the result object, never via out-parameters.

function doSomething(input): { result: string; warnings: string[] } {
  // ...
}

Field format return type

All individual format handlers in formatters.ts return RenderFieldResult:

type RenderFieldResult = {
  rendered: string;
  warning?: Warning;
  tokenAddress?: string;
  rawAddress?: string;
};

rawAddress is returned by format handlers that deal with addresses (formatRaw for address values, formatAddressName). When present, processSingleField uses it as the DisplayField.rawAddress.

When a field value has the wrong type, use typeMismatch(value, expected) which returns a RenderFieldResult with the raw value and an ARGUMENT_TYPE_MISMATCH warning.

Byte manipulation

import { hexToBytes, bytesToHex } from "./utils";

keccak256 is module-private in utils.ts; use selectorForSignature(canonical) to compute a 4-byte function selector.

BigInt for token amounts

All token amounts use native bigint. Formatting:

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. DescriptorDescriptorContext, OffchainAttestationOffchainAttestationMessage).

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 consts, 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.
  2. All imports need .js extension (ESM requirement)
  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, 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 ArgumentValues with argumentValueEquals (cross-matches uint/int via bigint). Prefer these over bespoke per-type matching helpers.

Descriptor Type — Defensive Programming

All fields in Descriptor and its nested types (DescriptorContext, DescriptorMetadata, DescriptorDisplay, etc.) are optional. This is intentional:

  • Descriptors come from external sources (GitHub registry, user-supplied inline objects) with no runtime schema validation.
  • Making every field optional forces callers to null-check before accessing values, preventing crashes on partial or malformed descriptors.
  • The [key: string]: unknown index signature on Descriptor allows the merge algorithm in resolver.ts to iterate over arbitrary top-level keys while preserving proper types for known fields.

When writing code that reads descriptor fields, always guard: descriptor.context?.contract?.deployments?.forEach(...).

Not Yet Implemented (from EIP-7730 spec)

  • interoperableAddressName format (ERC-7930)