Rust library for ERC-7730 v2 clear signing — decodes and formats contract calldata and EIP-712 messages for human-readable display. UniFFI bindings (Kotlin + Swift) are implemented in the same crate via a stateless FFI wrapper.
- Cargo workspace root at
/ - Single crate:
crates/clear-signing/ - Local Swift package manifest:
Package.swift - iOS demo app:
wallet/Wallet.xcodeproj
cargo build # Build
cargo test # Run default tests (49 unit + 101 integration)
cargo clippy # Lint
cargo fmt --check # Format checkUniFFI checks and binding generation:
cargo check -p clear-signing --features uniffi,github-registry
cargo test -p clear-signing --features uniffi,github-registry # 49 unit tests + 101 integration
cargo clippy -p clear-signing --all-targets --features uniffi,github-registry -- -D warnings
./scripts/generate_uniffi_bindings.sh
./scripts/build-xcframework.sh
swift package resolve
swift package describeGenerated binding outputs:
bindings/kotlin/uniffi/clear_signing/clear_signing.ktbindings/swift/clear_signing.swiftbindings/swift/clearSigningFFI.hbindings/swift/clearSigningFFI.modulemaptarget/ios/libclear_signing.xcframework
Repository policy:
bindings/swift/is kept in-repo for SPM consumption.bindings/kotlin/is generated locally and gitignored.- XCFramework is generated locally (not committed) and consumed by local
Package.swift. - Local Swift package and
walletapp deployment baseline is iOS 14+. - XCFramework header/modulemap staging is namespaced (
Headers/clearSigningFFI/module.modulemap) to avoid collisions with other Rust XCFrameworks.
- Rust 2021 edition
thiserrorfor error types,serdefor serialization- No
.unwrap()in library code — useResultand? - All public API re-exported from
lib.rs - Signature-based decoding: function signatures parsed from descriptor format keys, no ABI JSON needed
Files implementing ERC-7730 spec behavior (engine.rs, eip712.rs, decoder.rs, merge.rs, types/display.rs, types/context.rs, types/metadata.rs) are guarded by 33 spec compliance tests + 33 integration tests.
Rules when editing these files:
- Run
cargo testafter every edit to a spec-critical file — full suite takes <1s. - Do not change behavior adjacent to your task. If a refactor touches formatting logic, path resolution, or field rendering beyond the task scope — confirm with the user first.
- If making a test pass requires changing behavior other tests depend on, explain the tradeoff BEFORE implementing. Do not modify spec compliance tests without explicit approval.
- For ambiguous spec behavior, reference https://eips.ethereum.org/EIPS/eip-7730 — flag ambiguity rather than guessing.
Spec conformance is the non-negotiable release gate for parity work.
Rules when editing engine.rs, eip712.rs, types/display.rs, or shared formatting helpers:
- Do not weaken, bypass, or silently relax existing spec-compliance behavior. Keep current spec assertions passing unchanged unless the user explicitly approves a spec behavior change.
- For behavior shared by calldata and EIP-712, treat calldata as the reference only after the behavior is confirmed spec-safe by the existing spec tests or the ERC-7730 spec text.
- If current calldata behavior and the spec appear to disagree, stop and surface the tradeoff instead of copying the behavior into
eip712.rs. - Any change to field rendering, visibility, interpolation, token amount formatting, map lookup, or nested calldata handling must consider both calldata and typed-data effects in the same review.
- Add or update parity coverage in
crates/clear-signing/tests/spec_compliance.rsfor shared calldata/EIP-712 behavior. Do not rewrite spec-compliance assertions just to make parity work pass without explicit approval.
Shared types in lib.rs:
TransactionContext { chain_id, to, calldata, value, from, implementation_address }— transaction parameters bundled into a single struct;implementation_addressfor proxy contracts (descriptor matching uses this instead ofto)
Entry points in lib.rs:
resolve_descriptors_for_tx(tx, source)— resolve all descriptors needed for a transaction including nested calldata; walksFieldFormat::Calldatafields to find inner callees and recursively resolves their descriptors; returns[outer, inner1, ...]for use withformat_calldataformat_calldata(descriptors, tx, data_provider)— format calldata with pre-resolved descriptors; outer descriptor matched by chain_id + tx.to (or implementation_address for proxies); remaining descriptors for nested calldata (Safe/4337); single-element slice = simple caseformat_typed_data(descriptors, data, data_provider)— format EIP-712 typed data with pre-resolved descriptors; outer descriptor matched by chain_id + verifying_contractmerge_descriptors(including_json, included_json)— merge two descriptor JSON strings forincludesmechanism; including file wins on conflicts, field arrays merge bypath
UniFFI FFI exports in src/uniffi_compat/mod.rs:
clear_signing_resolve_descriptor(chain_id, address)— resolve descriptor JSON from GitHub registry; returnsOption<String>(requiresgithub-registryfeature)clear_signing_resolve_descriptors_for_tx(transaction, data_provider)— resolve all descriptors for a transaction including nested calldata; auto-detects proxy contracts viadata_provider.get_implementation_address(); returns descriptor JSON strings in dependency order (requiresgithub-registryfeature)clear_signing_format_calldata(descriptors_json, transaction, data_provider)— format calldata with pre-resolved descriptors; auto-detects proxies viadata_provider.get_implementation_address()for descriptor matchingclear_signing_format_typed_data(descriptors_json, typed_data_json, data_provider)— format EIP-712 typed data with pre-resolved descriptorsclear_signing_merge_descriptors(including_json, included_json)— merge two descriptor JSONs forincludesmechanism
UniFFI FFI records:
TransactionInput { chain_id, to, calldata_hex, value_hex, from_address }— FFI-safe transaction inputTokenMetaFfi { symbol, decimals, name }— FFI-safe token metadata (used byDataProviderFfireturn type)
UniFFI FFI traits:
DataProviderFfi— wallet-implemented trait for token metadata, ENS/local name resolution, NFT collection names, and proxy detection (get_implementation_address); methods are synchronous across FFI boundary
Local Swift package product:
ClearSigning(binary target + Swift wrapper target)
| Module | Key Types | Purpose |
|---|---|---|
engine.rs |
DisplayModel, DisplayEntry (Item/Group/Nested), DisplayItem |
Main formatting pipeline + nested calldata |
decoder.rs |
FunctionSignature, ParamType, ArgumentValue |
Calldata decoding from function signatures |
eip712.rs |
TypedData, TypedDataDomain |
EIP-712 typed data support |
resolver/ |
DescriptorSource (trait), ResolvedDescriptor, StaticSource, GitHubRegistrySource, resolve_descriptors_for_tx |
Descriptor resolution facade + split source, registry, typed-selection, and nested-resolution submodules |
token.rs |
TokenSource (trait), TokenMeta |
Token metadata trait — resolution is fully the wallet's responsibility via DataProviderFfi |
merge.rs |
merge_descriptor_values, merge_descriptors |
JSON-level descriptor merge for includes mechanism |
address_book.rs |
AddressBook |
Address → label resolution from descriptor metadata |
uniffi_compat/ |
TransactionInput, TokenMetaFfi, FfiError, DataProviderFfi (trait), exported FFI functions |
Stateless UniFFI wrapper layer |
types/ |
Descriptor, DescriptorContext, DescriptorDisplay, DisplayField, FieldFormat, VisibleRule |
Descriptor, display, context, metadata types |
error.rs |
Error, DecodeError, ResolveError |
Unified error hierarchy |
scripts/build-xcframework.sh |
XCFramework build + namespaced modulemap staging | iOS packaging for local SPM |
wallet/ |
SwiftUI smoke-test app | Minimal consumer of local ClearSigning package |
Keep resolver work split by responsibility.
Rules when editing crates/clear-signing/src/resolver/:
- Keep typed outer-descriptor selection centralized in
typed_selection; do not duplicatedomain/domainSeparator/ exactencodeTypematching in callers. - Keep registry/index loading, HTTP fetch, and cache behavior in
github_registry; do not mix transport concerns with typed applicability logic. - Keep recursive nested calldata walking in
nested_resolution; do not move graph traversal back into source/index code. - Keep
resolver/mod.rsas a thin facade and re-export layer, not a new implementation dumping ground. - Structural resolver refactors must preserve current behavior and tests unless the user explicitly approves a semantic change.
The library supports v2 registry descriptor features:
- Named parameter paths:
"path": "amount"resolved by parameter name from signature {paramName}interpolation: v2 intent syntax (alongside v1${path})- Threshold/message:
"threshold": "$.metadata.constants.max"+"message": "All"for max-amount display $refenum resolution:"$ref": "$.metadata.enums.interestRateMode"- Container values:
@.value,@.from,@.to,@.chainIdinjected as synthetic arguments - Graceful degradation: Unknown selectors return raw preview instead of errors
duration/unitformatters: Seconds → human-readable, numeric + unit symbolFieldFormat::Calldata: Nested calldata decoding (SafeexecTransaction, ERC-4337 UserOps) — recursive rendering withDisplayEntry::Nested(carriesownerfrom the inner descriptor'smetadata.ownerwhen resolved;Noneon raw/fallback),calleePath/amountPath/spenderPathparams, depth limit of 3- Batch operations (
wallet_sendCalls): Handled wallet-side per spec — wallet callsformat_calldata()per inner call, joinsinterpolatedIntentstrings with " and ". No batch splitting in the engine. @.container value priority: Paths with@.prefix prefer container values over same-named function params (search from end)- Duplicate selector rejection: Wallets MUST reject descriptors with multiple keys sharing the same selector (spec normative MUST)
- Signed integer handling:
inttypes use two's complement →BigIntfor correct negative display valuefield on DisplayField: Literal constant values as alternative topathseparatorfield: Custom separator for array-typed valuesinteroperableAddressNameformat: ERC-7930 stub with fallback toaddressNamedateencoding:"blockheight"encoding shows block number instead of timestampselectorPath/chainIdPath: Cross-field selector and chain ID resolution for nested calldatadomainSeparator: EIP-712 context field validated during typed-data formatting- Factory context:
factoryobject withdeployEventanddeployments - EIP-712 shared-format parity: Shared formatting behavior is expected to match calldata semantics for all supported EIP-712 format types, and spec-compliance tests are the guardrail for that parity
- EIP-712 AddressName: Full senderAddress, sources, local/ENS resolution (parity with calldata)
- Array slice syntax:
[start:end]in both calldata paths and EIP-712 paths - Unit SI prefix:
prefix: trueenables k/M/G/T notation - Maps
keyPath: Cross-field key resolution for map lookups excludedpaths: Deprecated v1 field now functional in rendering- Intent as object:
intentcan be string or{"label": "..."}object - Interpolation escape sequences:
{{and}}produce literal braces - Encryption params:
schemeandplaintextTypefields (parsing only) - EIP-712 domain completeness:
version,chainId,saltfields on descriptor domain includesmechanism: Descriptor inheritance via"includes": "./base.json"— JSON-level merge, field arrays merge bypath, nested includes with depth limit 3,GitHubRegistrySourceresolves automatically- Proxy detection: FFI layer auto-detects proxy contracts (EIP-1967, Safe slot 0) via
DataProviderFfi.get_implementation_address()— retries descriptor resolution with implementation address when direct lookup fails; wallet implements the RPC storage reads
Optional features:
github-registry: async HTTP descriptor fetching viaGitHubRegistrySource(addsreqwestdependency; requires tokio runtime)GitHubRegistrySource::from_registry(base_url)requires the split v3 registry filesindex.calldata.jsonandindex.eip712.json- Default registry:
https://raw.githubusercontent.com/ethereum/clear-signing-erc7730-registry/master(official EF registry) - Registry source is cached via
tokio::sync::OnceCellin FFI layer — index fetched once per process - UniFFI async exports use
#[uniffi::export(async_runtime = "tokio")];uniffidep requiresfeatures = ["tokio"]
check-descriptor(.claude/skills/check-descriptor/): Validates ERC-7730 descriptor function signatures against on-chain contract ABIs via Etherscan. Trigger with/check-descriptor <path-or-url>or phrases like "check this descriptor", "validate descriptor against on-chain". Handles proxy contracts automatically.
ETHERSCAN_API_KEY: Available in.envat repo root. Load with[ -f .env ] && export $(grep -v '^#' .env | xargs 2>/dev/null)before calling Etherscan. Use the V2 API:https://api.etherscan.io/v2/api?chainid={id}&...
- Phase 3: Descriptor validation
- Phase 4: Packaging/distribution for existing UniFFI bindings (Swift XCFramework/SPM + Kotlin AAR/Maven)
- Phase 5: CI pipeline