Clear signing descriptor (ERC-7730)
Turn a smart contract into a reviewed, validated ERC-7730 descriptor plus a ready-to-open PR to the registry, so its calls and signed messages render as plain language in supporting wallets instead of a wall of hex.
ERC-7730 is chain-agnostic. This works for any EVM chain and any contract, not just one project.
The authoritative spec and build guide is the Ethereum Foundation's https://clearsigning.org/build/ and the ERC-7730 EIP. Defer to them for schema questions; this skill is the agent-runnable workflow on top of that standard.
Inputs to collect first
Ask for whatever is missing before starting:
- Contract address and chain id.
- Owner / protocol name (used in
metadata.owner and the registry folder).
- An RPC URL for the chain (for on-chain verification).
- The ABI, or a way to get it: a verified contract on the chain's explorer, or the project's source.
- Whether to open the PR or stop at a draft. Default is to stop at a draft (see step 9).
Procedure
1. Check the registry FIRST (the highest-leverage step)
Most well-known protocols already have a descriptor and are only missing a chain. When that is the case the change is one line, not a new file, and it merges easily.
- Search for the contract address and the protocol name:
scripts/find-in-registry.sh <address-or-name> <path-to-registry-clone>. No local clone? Use the GitHub contents API to list registry/ and grep the raw files for the address (gh api ...). Unauthenticated GitHub code search is blocked, so use the contents API or an authenticated gh search code, not a plain curl to the search endpoint.
- If a descriptor exists and only the chain is missing, add
{ "chainId": <id>, "address": "<addr>" } to that file's context.contract.deployments array and stop. The display formats are chain-agnostic and carry over unchanged. This is exactly how Permit2 and Morpho reached new chains.
- Only author a new descriptor if none exists.
2. Verify the address on-chain (never trust an address you were handed)
- First get the candidate address from the protocol's own deployment source: their docs, an addresses file in their GitHub, or their API. Do not assume a same-address-on-every-chain vanity address is deployed on your chain; it often is not.
scripts/verify-address.sh <chainId> <address> <rpcUrl> [eip712Name] [eip712Version].
- It confirms there is bytecode at the address, then matches the live
DOMAIN_SEPARATOR() against the EIP-712 domain shapes in use (2-field with no name like Morpho, 3-field with a name like Permit2, 4-field with name and version like most tokens) and reports which matched. The 2-field check runs even with no name, so pass the name and version when you have them. A match is proof the address is the contract you think it is.
- Many contracts (routers, factories) have no
DOMAIN_SEPARATOR(). Then confirm identity by calling a known view function from the ABI and checking it returns a sane value: a router's factory() or WETH9(), or an owner(). Bytecode present plus a sensible view response is enough.
3. Generate (only when authoring from scratch)
First get the ABI. From Etherscan's V2 multichain API, addressed by chain id (works for any supported chain, needs ETHERSCAN_API_KEY):
export ETHERSCAN_API_KEY=<your key> # export it; an inline KEY=val prefix won't expand inside the URL
curl -s "https://api.etherscan.io/v2/api?chainid=<id>&module=contract&action=getsourcecode&address=<addr>&apikey=$ETHERSCAN_API_KEY" | jq -r '.result[0].ABI' > abi.json
Confirm it is verified (status:1); if getsourcecode shows a proxy, fetch the implementation's ABI. Etherscan V2 does not serve every chain and may reject newer ones with "Missing/Invalid API Key"; if the contract is unverified or the chain is not covered, get the ABI from the project's verified source or repo instead.
Then generate, minding two traps:
COLUMNS=10000 uvx erc7730 generate --chain-id <id> --address <addr> --abi ./abi.json --owner "<Owner>" > calldata-<Name>.json
COLUMNS=10000 is required. generate pretty-prints and wraps long lines, which corrupts the JSON it writes; a wide terminal prevents it. Then confirm it parses (jq . calldata-<Name>.json); if it still does not, the wrapping bit anyway, so widen COLUMNS further or strip the stray newlines inside string values.
- Name the file with the
calldata- (or eip712-) prefix from the start, or lint will refuse it.
--owner may not fill metadata.owner, and $schema may come out null. Set both yourself. The owner picks the registry folder registry/<owner>/, so identify the real protocol from the verified source on the explorer (contract name, NatSpec @title, imported packages) or the project's site, not from the contract's code style. If you add metadata.info, it requires a url (keep it short, Ledger truncates past ~26 chars).
4. Write human-readable intents and labels (the part that needs judgment)
This is what makes a descriptor good. For each function or signed message:
intent: the action in plain language, like "Approve USDC", "Supply collateral", "Swap exact tokens". Keep it 30 characters or fewer; Ledger devices truncate longer text.
- Label every field a user should see: which parameter is the token, the amount, the spender, the recipient, the deadline.
- Pick the right format:
tokenAmount for amounts (set tokenPath when the token address is another field in the same call), addressName for addresses (it needs params, e.g. { "types": ["token"] }; a bare addressName fails lint), amount for native value, a percentage for rates, raw only as a last resort.
- You do not have to render every field. To omit an opaque one (callback
data, fee tiers, sqrtPriceLimit), just leave it out of fields. In v2 a left-out field is a lint warning, not an error, so it does not block. Do NOT add an excluded key: that is a v1 concept and the v2 schema rejects unknown keys, which is a hard lint error.
- Mark the fields that matter
"visible": "always".
Base every label on real contract semantics. Read the ABI parameter names and any NatSpec. If a parameter's meaning is unclear, look at the source or ask. Do not guess.
5. Know the gotchas
- Inline ABI: embed
context.contract.abi to make the descriptor self-contained. The lint "could not fetch ABI" warning (no ETHERSCAN_API_KEY) is harmless and appears on any chain.
- EIP-712 domains vary: read the exact domain from the contract. Most use
name, version, chainId, verifyingContract; some omit version (Permit2) or name (Morpho). Get it right or the signed message will not match.
- Nested or arbitrary calldata cannot be statically decoded: multicall,
batch, router execute, connector call/batch, permit-with-data. Cover the functions that decode cleanly and state plainly which you left out. Never ship a descriptor that renders a half-empty screen and call it done.
6. Validate
uvx erc7730 lint <file> (the file needs the calldata-/eip712- prefix or lint refuses it). What counts as an error vs noise: "could not fetch ABI" (no ETHERSCAN_API_KEY) and "Missing display field" / "Missing display format" are warnings, not errors. The last two just mean you chose not to render a field or a whole function (fine for opaque params and nested-calldata functions you bounded out); silence them by leaving the field out, never by adding an excluded key. Setting ETHERSCAN_API_KEY clears the fetch warning and lets lint validate your fields against the ABI. Re-check that every intent is 30 characters or fewer.
7. Add reference tests (v2 format)
Tests let wallet vendors verify the descriptor renders correctly, and registry CI runs them against both the TypeScript and Rust libraries. Use the v2 format only. The old tests/ folder with an expectedTexts array is deprecated, and maintainers will ask you to convert it before they review (this is exactly what happened on the Permit2-on-Monad PR).
- Location:
registry/<owner>/testsv2/<descriptor-name>.tests.json, mirroring the descriptor filename with a .tests.json suffix. NOT the old tests/ folder.
- Header:
"$schema": "../../../specs/erc7730-tests-v2.schema.json" and "descriptor": "../<descriptor-name>.json".
dataProvider.tokens maps each token address a test touches (LOWERCASED) to { "decimals", "name", "symbol" }, so tokenAmount fields resolve to a symbol instead of ???.
- Each test needs a unique
description; for EIP-712 the full data typed-data object (types, primaryType, domain, message); for calldata an unsigned rawTx plus optional txHash; and an expected block.
expected is { "intent", "owner", "fields": [{ "label", "value" }] }. The values are the exact rendered strings the runner compares against, so they must match precisely: amounts formatted by the token's decimals (e.g. 2500000000 at 6 decimals renders as 2500 USDC), dates as YYYY-MM-DD HH:MM:SSZ in UTC, addresses in full checksummed form. A tokenAmount only renders "Unlimited" when the descriptor field sets both threshold and message; without them a max value renders as its full (huge) number, so pick a realistic amount for the test.
- Fastest way to get the
values right without a device: copy an existing passing test for the same message or function type in the registry and change only the domain/message data. The rendering is format-driven, so identical formats plus identical amount/decimals produce identical output regardless of chain or address. Adding a chain to an existing descriptor is usually one new test case alongside the existing ones.
- Validate: the file must pass
specs/erc7730-tests-v2.schema.json (e.g. a quick jsonschema check). Delete any old-format tests/<descriptor-name>.tests.json you are replacing.
8. Preview (optional)
lint is the real gate. To eyeball the render, use the Sourcify live preview. (uvx erc7730 calldata needs real sample calldata bytes as input, so skip it unless you have an actual transaction to decode.)
9. PR step (default: stop at a draft)
- The file belongs at
registry/<owner>/<calldata|eip712>-<Name>.json.
- DEFAULT: do NOT open the PR. Output the validated file, the target path, and the exact
git/gh commands, and tell the user to open the PR from an account tied to the contract owner (maintainers may ask for proof of ownership).
- Only if the user explicitly opts in to opening the PR: show the full diff, confirm, then open it.
- Why: descriptors are owned by the protocol, ownership is checked at review, and auto-opening agent PRs burdens registry maintainers. A human gate and quality beat speed here.
Quality bar
- Prefer the minimal change: adding a chain to an existing descriptor beats a new file every time.
- One descriptor reviewed by someone who knows the contract beats ten machine-guessed ones.
- If you bounded coverage (skipped nested calldata, omitted a function), say so out loud.
Reference examples
The registry is the best reference. Look at registry/uniswap/ for EIP-712 and multi-chain deployments, registry/morpho/ for a calldata descriptor with nested structs, and any eip712-*.json for signed-message descriptors. Match the conventions and the $schema path of the folder you write into.
1---2name: clear-signing-helper3description: Create or extend an ERC-7730 clear-signing descriptor for a smart contract on any EVM chain, verify it on-chain, validate it, and prepare a PR to the ethereum/clear-signing-erc7730-registry. Use when someone wants their contract's transactions or signed messages to render as human-readable text in wallets (Ledger and others) instead of raw hex, wants to add clear signing, write an ERC-7730 descriptor, or add their chain or contract to the clear signing registry.4---56# Clear signing descriptor (ERC-7730)78Turn a smart contract into a reviewed, validated ERC-7730 descriptor plus a ready-to-open PR to the registry, so its calls and signed messages render as plain language in supporting wallets instead of a wall of hex.910ERC-7730 is chain-agnostic. This works for any EVM chain and any contract, not just one project.1112The authoritative spec and build guide is the Ethereum Foundation's https://clearsigning.org/build/ and the ERC-7730 EIP. Defer to them for schema questions; this skill is the agent-runnable workflow on top of that standard.1314## Inputs to collect first1516Ask for whatever is missing before starting:17- Contract address and chain id.18- Owner / protocol name (used in `metadata.owner` and the registry folder).19- An RPC URL for the chain (for on-chain verification).20- The ABI, or a way to get it: a verified contract on the chain's explorer, or the project's source.21- Whether to open the PR or stop at a draft. Default is to stop at a draft (see step 9).2223## Procedure2425### 1. Check the registry FIRST (the highest-leverage step)26Most well-known protocols already have a descriptor and are only missing a chain. When that is the case the change is one line, not a new file, and it merges easily.27- Search for the contract address and the protocol name: `scripts/find-in-registry.sh <address-or-name> <path-to-registry-clone>`. No local clone? Use the GitHub contents API to list `registry/` and grep the raw files for the address (`gh api ...`). Unauthenticated GitHub code search is blocked, so use the contents API or an authenticated `gh search code`, not a plain `curl` to the search endpoint.28- If a descriptor exists and only the chain is missing, add `{ "chainId": <id>, "address": "<addr>" }` to that file's `context.contract.deployments` array and stop. The display formats are chain-agnostic and carry over unchanged. This is exactly how Permit2 and Morpho reached new chains.29- Only author a new descriptor if none exists.3031### 2. Verify the address on-chain (never trust an address you were handed)32- First get the candidate address from the protocol's own deployment source: their docs, an addresses file in their GitHub, or their API. Do not assume a same-address-on-every-chain vanity address is deployed on your chain; it often is not.33- `scripts/verify-address.sh <chainId> <address> <rpcUrl> [eip712Name] [eip712Version]`.34- It confirms there is bytecode at the address, then matches the live `DOMAIN_SEPARATOR()` against the EIP-712 domain shapes in use (2-field with no name like Morpho, 3-field with a name like Permit2, 4-field with name and version like most tokens) and reports which matched. The 2-field check runs even with no name, so pass the name and version when you have them. A match is proof the address is the contract you think it is.35- Many contracts (routers, factories) have no `DOMAIN_SEPARATOR()`. Then confirm identity by calling a known view function from the ABI and checking it returns a sane value: a router's `factory()` or `WETH9()`, or an `owner()`. Bytecode present plus a sensible view response is enough.3637### 3. Generate (only when authoring from scratch)38First get the ABI. From Etherscan's V2 multichain API, addressed by chain id (works for any supported chain, needs `ETHERSCAN_API_KEY`):39```40export ETHERSCAN_API_KEY=<your key> # export it; an inline KEY=val prefix won't expand inside the URL41curl -s "https://api.etherscan.io/v2/api?chainid=<id>&module=contract&action=getsourcecode&address=<addr>&apikey=$ETHERSCAN_API_KEY" | jq -r '.result[0].ABI' > abi.json42```43Confirm it is verified (`status:1`); if `getsourcecode` shows a proxy, fetch the implementation's ABI. Etherscan V2 does not serve every chain and may reject newer ones with "Missing/Invalid API Key"; if the contract is unverified or the chain is not covered, get the ABI from the project's verified source or repo instead.4445Then generate, minding two traps:46```47COLUMNS=10000 uvx erc7730 generate --chain-id <id> --address <addr> --abi ./abi.json --owner "<Owner>" > calldata-<Name>.json48```49- `COLUMNS=10000` is required. `generate` pretty-prints and wraps long lines, which corrupts the JSON it writes; a wide terminal prevents it. Then confirm it parses (`jq . calldata-<Name>.json`); if it still does not, the wrapping bit anyway, so widen `COLUMNS` further or strip the stray newlines inside string values.50- Name the file with the `calldata-` (or `eip712-`) prefix from the start, or `lint` will refuse it.51- `--owner` may not fill `metadata.owner`, and `$schema` may come out null. Set both yourself. The owner picks the registry folder `registry/<owner>/`, so identify the real protocol from the verified source on the explorer (contract name, NatSpec `@title`, imported packages) or the project's site, not from the contract's code style. If you add `metadata.info`, it requires a `url` (keep it short, Ledger truncates past ~26 chars).5253### 4. Write human-readable intents and labels (the part that needs judgment)54This is what makes a descriptor good. For each function or signed message:55- `intent`: the action in plain language, like "Approve USDC", "Supply collateral", "Swap exact tokens". Keep it 30 characters or fewer; Ledger devices truncate longer text.56- Label every field a user should see: which parameter is the token, the amount, the spender, the recipient, the deadline.57- Pick the right format: `tokenAmount` for amounts (set `tokenPath` when the token address is another field in the same call), `addressName` for addresses (it needs `params`, e.g. `{ "types": ["token"] }`; a bare `addressName` fails lint), `amount` for native value, a percentage for rates, `raw` only as a last resort.58- You do not have to render every field. To omit an opaque one (callback `data`, fee tiers, `sqrtPriceLimit`), just leave it out of `fields`. In v2 a left-out field is a lint *warning*, not an error, so it does not block. Do NOT add an `excluded` key: that is a v1 concept and the v2 schema rejects unknown keys, which is a hard lint error.59- Mark the fields that matter `"visible": "always"`.60Base every label on real contract semantics. Read the ABI parameter names and any NatSpec. If a parameter's meaning is unclear, look at the source or ask. Do not guess.6162### 5. Know the gotchas63- Inline ABI: embed `context.contract.abi` to make the descriptor self-contained. The lint "could not fetch ABI" warning (no `ETHERSCAN_API_KEY`) is harmless and appears on any chain.64- EIP-712 domains vary: read the exact domain from the contract. Most use `name, version, chainId, verifyingContract`; some omit `version` (Permit2) or `name` (Morpho). Get it right or the signed message will not match.65- Nested or arbitrary calldata cannot be statically decoded: multicall, `batch`, router `execute`, connector `call`/`batch`, permit-with-`data`. Cover the functions that decode cleanly and state plainly which you left out. Never ship a descriptor that renders a half-empty screen and call it done.6667### 6. Validate68`uvx erc7730 lint <file>` (the file needs the `calldata-`/`eip712-` prefix or lint refuses it). What counts as an error vs noise: "could not fetch ABI" (no `ETHERSCAN_API_KEY`) and "Missing display field" / "Missing display format" are warnings, not errors. The last two just mean you chose not to render a field or a whole function (fine for opaque params and nested-calldata functions you bounded out); silence them by leaving the field out, never by adding an `excluded` key. Setting `ETHERSCAN_API_KEY` clears the fetch warning and lets lint validate your fields against the ABI. Re-check that every intent is 30 characters or fewer.6970### 7. Add reference tests (v2 format)71Tests let wallet vendors verify the descriptor renders correctly, and registry CI runs them against both the TypeScript and Rust libraries. Use the v2 format only. The old `tests/` folder with an `expectedTexts` array is deprecated, and maintainers will ask you to convert it before they review (this is exactly what happened on the Permit2-on-Monad PR).72- Location: `registry/<owner>/testsv2/<descriptor-name>.tests.json`, mirroring the descriptor filename with a `.tests.json` suffix. NOT the old `tests/` folder.73- Header: `"$schema": "../../../specs/erc7730-tests-v2.schema.json"` and `"descriptor": "../<descriptor-name>.json"`.74- `dataProvider.tokens` maps each token address a test touches (LOWERCASED) to `{ "decimals", "name", "symbol" }`, so `tokenAmount` fields resolve to a symbol instead of `???`.75- Each test needs a unique `description`; for EIP-712 the full `data` typed-data object (`types`, `primaryType`, `domain`, `message`); for calldata an unsigned `rawTx` plus optional `txHash`; and an `expected` block.76- `expected` is `{ "intent", "owner", "fields": [{ "label", "value" }] }`. The `value`s are the exact rendered strings the runner compares against, so they must match precisely: amounts formatted by the token's decimals (e.g. `2500000000` at 6 decimals renders as `2500 USDC`), dates as `YYYY-MM-DD HH:MM:SSZ` in UTC, addresses in full checksummed form. A `tokenAmount` only renders "Unlimited" when the descriptor field sets both `threshold` and `message`; without them a max value renders as its full (huge) number, so pick a realistic amount for the test.77- Fastest way to get the `value`s right without a device: copy an existing passing test for the same message or function type in the registry and change only the domain/message data. The rendering is format-driven, so identical formats plus identical amount/decimals produce identical output regardless of chain or address. Adding a chain to an existing descriptor is usually one new test case alongside the existing ones.78- Validate: the file must pass `specs/erc7730-tests-v2.schema.json` (e.g. a quick `jsonschema` check). Delete any old-format `tests/<descriptor-name>.tests.json` you are replacing.7980### 8. Preview (optional)81`lint` is the real gate. To eyeball the render, use the Sourcify live preview. (`uvx erc7730 calldata` needs real sample calldata bytes as input, so skip it unless you have an actual transaction to decode.)8283### 9. PR step (default: stop at a draft)84- The file belongs at `registry/<owner>/<calldata|eip712>-<Name>.json`.85- DEFAULT: do NOT open the PR. Output the validated file, the target path, and the exact `git`/`gh` commands, and tell the user to open the PR from an account tied to the contract owner (maintainers may ask for proof of ownership).86- Only if the user explicitly opts in to opening the PR: show the full diff, confirm, then open it.87- Why: descriptors are owned by the protocol, ownership is checked at review, and auto-opening agent PRs burdens registry maintainers. A human gate and quality beat speed here.8889## Quality bar90- Prefer the minimal change: adding a chain to an existing descriptor beats a new file every time.91- One descriptor reviewed by someone who knows the contract beats ten machine-guessed ones.92- If you bounded coverage (skipped nested calldata, omitted a function), say so out loud.9394## Reference examples95The registry is the best reference. Look at `registry/uniswap/` for EIP-712 and multi-chain deployments, `registry/morpho/` for a calldata descriptor with nested structs, and any `eip712-*.json` for signed-message descriptors. Match the conventions and the `$schema` path of the folder you write into.