This file provides context for AI coding assistants working on this codebase.
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.
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
-
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 theBaseResolvePath(returnsArgumentValue) andResolvePath(returnsArgumentValue | BytesSliceValue) type aliases. -
fields.ts— The field processing pipeline. Primary entry point isapplyFieldFormats(), 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'slabelpassed through as-is — may be undefined). Delegates individual field rendering toformatters.ts. Contains byte slice support:parseByteSlice,applyByteSlice,buildSliceResolvePath(wraps aBaseResolvePathto handle slice paths transparently), andbytesSliceToArgumentValue(convertsBytesSliceValuetoArgumentValueusing the field format's expected type). Also contains encryption support:parsePlaintextType(canonical Solidity type →FieldTypeplus the type's maximum byte width) anddecryptFieldValue(calls the wallet'sresolveDecryptedValue, rejects a plaintext too wide for its declared type, and re-interprets the returned bytes viabytesSliceToFieldType). When decryption fails,processSingleFieldsubstitutesDEFAULT_ENCRYPTED_PLACEHOLDER/ the descriptor'sfallbackLabelfor therenderFieldcall, so the fallback flows through the normal DisplayField path. -
formatters.ts— Individual format handlers dispatched byrenderField(). IncludesformatRaw,formatTimestamp,renderTokenAmount,formatNftName,formatDuration,formatUnit,formatAddressName,formatTokenTicker,formatChainId,formatNativeAmount,resolveEnumLabel,isSenderAddress,isNativeCurrencyAddress, etc. Also definesFieldFormatOptionsandRenderFieldResulttypes. Handlers are exported for unit testing. Most format handlers delegate$.metadata.*path resolution to theresolvePathclosure rather than callingresolveMetadataValuedirectly — onlyresolveEnumLabeluses it since it needs an object value thattoArgumentValuecannot represent. -
calldata.ts— Everything specific to calldata formatting. Contains the top-levelformatCalldata()entry point, function signature parsing (parseFunctionSignatureKey), selector-to-format lookup (findFormatBySelector), and a unified recursive ABI decoder (decodeArguments→decodeComponents/decodeValue) supporting all ABI types: static and dynamic tuples, dynamic arrays (T[]), fixed-size arrays (T[k]), nested arrays,bytes/string, andbytesN. All parsing/decoding internals are module-private. -
eip712.ts— Everything specific to EIP-712 typed data formatting. Contains the top-levelformatEip712()entry point,encodeTypecomputation and matching (findFormatSpec,computeEncodeType), message value navigation (getMessageValue), and referenced-struct collection (collectReferencedTypes). ExportscomputeEncodeTypeandextractPrimaryTypeforresolver.tsandgithub-registry-index.ts; the rest is module-private. -
attestations.ts— ERC-8176 descriptor attestations. ExportscomputeDescriptorHash(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'sChainClient), andattestationPathForDescriptor(the registry's<dir>/sigs/<name>.eip155-1-<checksummedAttester>.jsonconvention). The JCS canonicalizer is module-private — forJSON.parseoutput it is exactlyJSON.stringifywith recursively sorted keys. The trusted-attester policy loop itself lives inresolver.ts(applyAttestationPolicy). -
bundled-descriptors.ts— Bundled ERC-20 / ERC-721 template descriptors (the registry'scalldata-erc20-tokens/calldata-erc721-nftsfiles, transcribed as TSconsts inbundled/erc20.tsandbundled/erc721.ts). ExportsbuildBundledTokenDescriptor(standard, chainId, address), which clones the template and injectscontext.contract.deploymentsso the result passes the deployment-binding check. Used byresolveCalldataDescriptorfor the trusted-token fallback. The descriptors are committed as TS rather than imported JSON — see the gotcha below.
-
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 -
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 -
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:]).
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.
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 registrysigs/path (attestationPathForDescriptor) and treats HTTP 404 as "no attestation" (fetchOptionalRegistryFile); the filesystem resolver treats ENOENT the same way. A custom resolver withoutfetchAttestationfails every gated resolution withATTESTATION_OPTIONS_INCOMPLETE. verifyAttestation(attestations.ts) checks: schema is the canonical ERC-8176 schema UID, attesteddataequals 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 plainErroron the first failed check;applyAttestationPolicycatches 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,
applyAttestationPolicyreads the revocation state withisAttestationRevoked(attestations.ts):getRevokeOffchain(attester, uid)on the canonical EAS contract on Ethereum mainnet, encoded and decoded in the library and sent through the wallet'sChainClient.call(1, { to, data }). A non-zero word means revoked; a result that is not exactly 32 bytes throws. The read runs outside thetry, so its transport errors propagate asDESCRIPTOR_FETCH_ERROR. ChainClient(types.ts) is the wallet's raw, read-only RPC access:call(chainId, { to, data }) → hex. It lives onExternalDataProvider.chainClientnext 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 onlyexternalDataProvider?.chainClientas the last parameter ofresolveCalldataDescriptor/resolveTypedDataDescriptor. Standalone callers pass their own.- An attestation policy without a
chainClientalso fails withATTESTATION_OPTIONS_INCOMPLETE. One code covers both setup gaps (missingchainClient, missingfetchAttestation); the message names the gap. - Failure surfaces as a
NO_TRUSTED_ATTESTATIONwarning (with per-attester reasons in the message);format()then falls back torawCalldataFallbackexactly likeNO_DESCRIPTOR. Attestation fetch andchainClientI/O errors still throw (→DESCRIPTOR_FETCH_ERRORinindex.ts), consistent with descriptor fetching. - Bundled trusted-token descriptors are not gated —
trustedTokenstrust is already delegated to the wallet. Note the ordering: a registry index hit that fails the attestation policy does not fall back totrustedTokens. - Only EAS offchain attestation version 2 with EOA signatures is supported. Onchain attestations and ERC-1271 contract attesters are out of scope.
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.
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:
- The public
resolveCalldataDescriptor/resolveTypedDataDescriptoraccept the sameGitHubResolverOptions | CustomResolverOptionsvalue thatFormatOptions.descriptorResolverOptionscarries. They build aDescriptorResolver({ index, fetchDescriptor, fetchAttestation? }) via the module-privatecreateResolver. Fortype: "github"without an explicitoptions.index,createResolvercallsfetchPrebuiltRegistryIndex(source)to fetchindex.calldata.jsonandindex.eip712.jsonin parallel. Fortype: "custom", it returnsoptions.resolverunchanged. No internal caching — every resolve call builds a fresh resolver, so callers should pre-fetch the index once and pass the samedescriptorResolverOptions.indexto everyformat()call. - The fetched index has two maps:
calldataIndex: Record<caip10, path>— keyed bycontext.contract.deployments[].{chainId, address}typedDataIndex: Record<caip10, Record<primaryType, TypedDataIndexEntry[]>>— keyed bycontext.eip712.deployments[].{chainId, address}, then by primary type. Each entry carries the descriptorpathand the keccak256 hashes of everyencodeTypeit declares (display.formatskeys), so multiple descriptors at the same(chainId, verifyingContract, primaryType)triple can be disambiguated at lookup time.
- After the index lookup yields a path, the resolver's
fetchDescriptor(path)closure fetches and parses the descriptor file;includesare 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.
{ 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 }.
Pure I/O layer with no caching. Exports:
fetchRegistryFilePaths(source)— returns repo-relative paths of all descriptor filesfetchRegistryFile(path, source)— fetches and parses a single descriptor fileDEFAULT_REPO/DEFAULT_REFconstants live ingithub-registry-index.ts
fetchPrebuiltRegistryIndex(source?)— async; fetches the registry'sindex.calldata.json+index.eip712.jsonand returns the mergedRegistryIndex. Cached per(repo, ref). Used byDescriptorResolveras the default index source.createGitHubRegistryIndex(source?)— async factory; walks all descriptor files and builds aRegistryIndexin-process. Fallback for when the prebuilt indexes are missing entries or unavailable.
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[*].fieldsarrays: merged bypathvalue — fields from the including descriptor override matching entries in the included descriptor, and newpathvalues are appended.includeskey: dropped from the merged result.
Include path resolution uses new URL(relative, base) in the resolver.
JSON files that define how to display contract interactions:
context.contract.deployments— chain/address bindingsdisplay.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, notuint)
Current ERC-7730 spec:
display.formatskeys are the full EIP-712encodeTypestring, e.g."PermitSingle(PermitDetails details,address spender,uint256 sigDeadline)PermitDetails(address token,uint160 amount,uint48 expiration,uint48 nonce)".context.eip712.schemasis deprecated — do not add to new descriptors.context.eip712.deploymentsandcontext.eip712.domainare the correct binding mechanisms.
eip712.ts supports both formats: tries encodeType match first, falls back to bare primary type name.
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 bothuintandintfield types. enumadditionally acceptsboolvalues ("true"/"false"), resolving the label case-insensitively so capitalized keys like{ "True": ..., "False": ... }match (e.g. ERC-721setApprovalForAll).dateformat supportsparams.encodingof"timestamp"(unix seconds) and"blockheight"(resolved viaExternalDataProvider.resolveBlockTimestamp). Falls back to raw withUNKNOWN_ENCODINGwarning for missing or unsupported encodings.tokenAmountsupports optionalchainId/chainIdPathparams to override the container chain ID for cross-chain scenarios (same astokenTicker).tokenAmountmessage defaults to"Unlimited"whenparams.thresholdis set butparams.messageis omitted.nftNameresolves collection name viaExternalDataProvider.resolveNftCollectionName(chainId, address).tokenTickeraccepts onlyaddresstype; supports optionalchainId/chainIdPathparams to override the container chain ID for cross-chain scenarios.chainIdconverts an integer chain ID to a human-readable chain name viaExternalDataProvider.resolveChainInfo. Falls back to raw withUNKNOWN_CHAINwarning when resolution fails.amountdisplays a value as native currency usingExternalDataProvider.resolveChainInfofor decimals and ticker. Falls back to raw withUNKNOWN_CHAINwarning when resolution fails.tokenAmountwithnativeCurrencyAddressalso resolves native currency metadata viaresolveChainInfo.addressNamesupports thesenderAddressparam: when the field value matches asenderAddress, it displays"Sender"and substitutesrawAddresswith@.from. Checked viaisSenderAddress().resolveLocalNameandresolveEnsNamereceiveacceptedTypes?: DescriptorAddressType[](fromparams.types). The parameter is absent when the descriptor defines notypes. Callers should check membership withacceptedTypes?.includes(...). The library emitsADDRESS_TYPE_MISMATCHwhen the resolver returnstypeMatch: false.calldataformats a nested function call, recursing throughformat()and returning the innerDisplayModelonDisplayField.embeddedCalldata(with itscalleeandchainId). Accepts onlybytes. Resolves the target viacallee/calleePathand the chain viachainId/chainIdPath, defaulting to the container's chain. Optionalselector/selectorPathis prepended to the value when the field carries bare arguments;amount/amountPathandspender/spenderPathbecome the inner tx'svalueandfrom. Falls back to raw withFORMAT_PARAM_RESOLUTION_ERROR(unresolvablecallee/chainIdparam),CONTAINER_MISSING_CHAIN_ID, orEMBEDDED_CALLDATA_NOT_SUPPORTED(the caller supplied no nested formatter —index.tsalways does, so this only surfaces whenformatCalldata/formatEip712are driven directly).- Raw address rendering always uses EIP-55 checksum format (not lowercase hex).
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 returnednull, malformed hex). Recoverable: the field still renders, asfallbackLabelor 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 onrawEncryptedValue.INVALID_DESCRIPTOR— theencryptionannotation itself is malformed (missingscheme/plaintextType, or a non-canonicalplaintextType). A descriptor bug, not a decryption outcome, so it is fatal for the whole format, consistent with the otherINVALID_DESCRIPTORchecks inprocessSingleField.
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
plaintextType → FieldType 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 (200 → 0xc8) 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 }).encryptedValueis 0x-hex of the raw field bytes;contractAddressis the container's@.to(optional — an EIP-712 domain may declare noverifyingContract). plaintextTypeis 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, neverbigint/boolean. One encoding for every scheme and type, with no hex-vs-text ambiguity. - Returns
nullwhen it cannot decrypt (unsupported scheme, denied signature, no access) →DECRYPTION_FAILED. A malformed (non-hex)valueis 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 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 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 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.
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 |
| Path | Value |
|---|---|
@.from |
Sender address (tx.from) |
@.value |
Native currency value (tx.value) |
@.to |
Destination contract address (tx.to) |
@.chainId |
Chain ID (tx.chainId) |
| 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 | 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 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.
- Add a new case to
renderField()switch insrc/formatters.ts - Implement the format handler as a module-private function in the same file
- Add the new
WarningCodevalue totypes.tsif the format can emit new warnings
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 },
};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.md — src/, README.md, and GUIDE.md stay
scheme-agnostic and describe only the resolveDecryptedValue contract.
Never include a "Test plan" section in PR descriptions. Keep descriptions to a short bulleted summary of the change.
npm test # Run all tests
npm run test:watch # Watch modeTests live in test/. Current test files:
test/formatters.spec.ts— unit tests for all field format handlers informatters.tstest/utils.spec.ts— unit tests for the shared utility functions inutils.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 layertest/erc7730-test-cases/example-main.spec.ts— ERC-7730 spec test cases usingexample-main.jsondescriptor (co-located in same directory), including EIP-5792 batch formatting teststest/erc7730-test-cases/example-array-iteration.spec.ts— bundled/sequential array iteration teststest/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-encryptedbytes32amount handle decrypted viaresolveDecryptedValueand rendered as a tokenAmount, plus plaintext-encoding edge cases (zero-padded ABI word, top-bit-setuint64, over-wide value) and both fallback paths — no provider, and a provider that declinestest/registry-cases/ekubo/ekubo.spec.ts— Ekubo Positions: mintAndDeposit + maybeInitializePool (sign-extendedint32ticks next to a static tuple)test/bundled/trusted-tokens.spec.ts— bundled ERC-20/721 descriptors viatrustedTokens: standard tagging, selector collision, registry precedencetest/attestations/attestations.spec.ts— ERC-8176 attestations against the registry's real Tether USD descriptor + attestation fixtures: descriptor hashing (JCS known answers),verifyAttestationedge cases via test-key-signed attestations (expired, tampered message/uid, wrong schema/hash/domain/version — each thrown as anError),isAttestationRevokedcall encoding and result decoding, and the trusted-attester policy end to end throughformat()/resolveTypedDataDescriptor(fallbacks,ATTESTATION_OPTIONS_INCOMPLETEfor both setup gaps,chainClientcall encoding and transport errors,trustedTokensbypass, includes-resolved hashing)
- 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 thesrc/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
DisplayModeland its nested objects:intent,interpolatedIntent,fields,metadata(includingowner,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 areundefined.rawEncryptedValueis deliberately not in that list — only fields carrying anencryptionannotation ever set it, so assert it in encryption tests (both on success and on fallback) and leave it out of the others rather than addingundefinedchecks across the suite. The same applies toseparator— only iterated array elements whose descriptor defines aseparatorever 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 assertembeddedCalldata.calleeandembeddedCalldata.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/toEqualwith computed expected values rather than loose regex patterns.
npm run build # Compiles to dist/
npm run clean # Removes dist/Output is ESM with TypeScript declarations.
@noble/hashes— Keccak256 (browser + Node compatible)@noble/curves— secp256k1 signature recovery for attestation verification (browser + Node compatible)typescript(dev)vitest(dev)
All errors use plain new Error(message). No custom error classes.
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.
Pattern: return warnings in the result object, never via out-parameters.
function doSomething(input): { result: string; warnings: string[] } {
// ...
}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.
import { hexToBytes, bytesToHex } from "./utils";keccak256 is module-private in utils.ts; use selectorForSignature(canonical) to compute a 4-byte function selector.
All token amounts use native bigint. Formatting:
import { formatAmountWithDecimals } from "./utils";
const display = formatAmountWithDecimals(1000000n, 6); // "1"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.
- 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 TypeScriptconsts, not imported JSON. Import attributes caused build/tooling trouble; plain TS bundles as code in every target (ESM/CJS/RN/browser) with noresolveJsonModule, no esbuild JSON loader, and no import-attribute support required, and gives the objects compile-timeDescriptortyping. - All imports need
.jsextension (ESM requirement) - Selector matching is case-sensitive on function names
- Address normalization: Always lowercase for comparisons
- Minimize exports: Only export symbols that are imported by other modules. Keep internal helpers module-private.
- Check
utils.tsbefore writing helpers: Always check ifutils.tsalready has a function for what you need (e.g.hexToBytes,bytesToHex,bytesToAscii,utf8ToBytes,concatBytes,bigIntToBytes,bytesToUnsignedBigInt,parseChainId, etc.) before writing a new one — in bothsrc/andtest/files. - Argument value conversion: To turn a JS literal (descriptor constant, EIP-712 message value,
ifNotIn/mustMatchcandidate) into anArgumentValue, usetoArgumentValuefromdescriptor.ts— it infers the type from the value shape. Compare twoArgumentValues withargumentValueEquals(cross-matchesuint/intvia bigint). Prefer these over bespoke per-type matching helpers.
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]: unknownindex signature onDescriptorallows the merge algorithm inresolver.tsto 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(...).
interoperableAddressNameformat (ERC-7930)