From b027cf806a7832d0ad6455d93401771a0db591dd Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Mon, 25 May 2026 12:33:39 +0200 Subject: [PATCH 01/10] Add standard ERC-20 descriptor synthesis When the calldata selector is transfer/approve/transferFrom and the wallet's DataProvider returns token metadata for the contract, the resolver synthesizes a single-selector descriptor in memory and skips the registry lookup. Strict short-circuit: matched cases never consult the source, even if it would have returned a richer descriptor. Trust signal stays delegated to the wallet via resolve_token. Proxy contracts split descriptor matching (implementation_address) from token lookup (user-facing tx.to). FFI exported symbols are unchanged -- wallets already implementing resolve_token for format_calldata get the synth path for free. Includes integration tests locking the short-circuit policy, nested Safe execTransaction + EIP-712 wrapping, a proxy-caller path, and an end-to-end fixture harness with seed fixtures + a manual Etherscan generator example. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/clear-signing/Cargo.toml | 5 + .../examples/fetch_erc20_fixtures.rs | 245 +++++++ crates/clear-signing/src/eip712_domain.rs | 12 +- crates/clear-signing/src/error.rs | 8 +- crates/clear-signing/src/resolver/mod.rs | 1 + .../src/resolver/nested_resolution.rs | 90 ++- .../src/resolver/standard_token.rs | 362 ++++++++++ crates/clear-signing/src/uniffi_compat/mod.rs | 23 +- .../tests/contract_name_integration.rs | 4 +- .../standard_token/base-usdc-transfer.json | 17 + .../standard_token/mainnet-usdc-approve.json | 17 + .../standard_token/mainnet-usdc-transfer.json | 17 + .../mainnet-usdc-transferfrom.json | 18 + .../standard_token/mainnet-wbtc-approve.json | 17 + .../standard_token/mainnet-weth-transfer.json | 17 + crates/clear-signing/tests/spec_compliance.rs | 2 +- crates/clear-signing/tests/standard_token.rs | 624 ++++++++++++++++++ .../clear-signing/tests/standard_token_e2e.rs | 180 +++++ 18 files changed, 1623 insertions(+), 36 deletions(-) create mode 100644 crates/clear-signing/examples/fetch_erc20_fixtures.rs create mode 100644 crates/clear-signing/src/resolver/standard_token.rs create mode 100644 crates/clear-signing/tests/fixtures/standard_token/base-usdc-transfer.json create mode 100644 crates/clear-signing/tests/fixtures/standard_token/mainnet-usdc-approve.json create mode 100644 crates/clear-signing/tests/fixtures/standard_token/mainnet-usdc-transfer.json create mode 100644 crates/clear-signing/tests/fixtures/standard_token/mainnet-usdc-transferfrom.json create mode 100644 crates/clear-signing/tests/fixtures/standard_token/mainnet-wbtc-approve.json create mode 100644 crates/clear-signing/tests/fixtures/standard_token/mainnet-weth-transfer.json create mode 100644 crates/clear-signing/tests/standard_token.rs create mode 100644 crates/clear-signing/tests/standard_token_e2e.rs diff --git a/crates/clear-signing/Cargo.toml b/crates/clear-signing/Cargo.toml index 4c46b75..aee9dff 100644 --- a/crates/clear-signing/Cargo.toml +++ b/crates/clear-signing/Cargo.toml @@ -25,6 +25,11 @@ name = "uniffi-bindgen" path = "uniffi-bindgen.rs" required-features = ["uniffi"] +[[example]] +name = "fetch_erc20_fixtures" +path = "examples/fetch_erc20_fixtures.rs" +required-features = ["github-registry"] + [features] default = [] uniffi = ["dep:uniffi", "uniffi/cli"] diff --git a/crates/clear-signing/examples/fetch_erc20_fixtures.rs b/crates/clear-signing/examples/fetch_erc20_fixtures.rs new file mode 100644 index 0000000..32f2805 --- /dev/null +++ b/crates/clear-signing/examples/fetch_erc20_fixtures.rs @@ -0,0 +1,245 @@ +//! Refresh the real-transaction fixture set under +//! `crates/clear-signing/tests/fixtures/standard_token/`. +//! +//! For each tuple in `CURATED`, fetch the transaction via Etherscan V2, run the +//! library's synthesis + format pipeline, and snapshot the rendered output into +//! a JSON fixture committed to the repo. The e2e test then asserts the library +//! still produces the same output on every CI run. +//! +//! Usage: +//! [ -f .env ] && export $(grep -v '^#' .env | xargs 2>/dev/null) +//! cargo run -p clear-signing --example fetch_erc20_fixtures \ +//! --features github-registry + +use std::path::PathBuf; +use std::time::Duration; + +use clear_signing::resolver::StaticSource; +use clear_signing::token::StaticTokenSource; +use clear_signing::{ + format_calldata, resolve_descriptors_for_tx, DisplayEntry, TokenMeta, TransactionContext, +}; +use serde_json::json; + +struct Tuple { + label: &'static str, + chain_id: u64, + token_address: &'static str, + token: TokenSpec, + tx_hash: &'static str, +} + +struct TokenSpec { + symbol: &'static str, + decimals: u8, + name: &'static str, +} + +const CURATED: &[Tuple] = &[ + // Add curated (chain, token, fn, tx) tuples here. Keep the file size small — + // each fixture is committed. The label drives the output filename. + // + // Examples (commented out — fill in real tx hashes manually): + // + // Tuple { + // label: "mainnet-usdt-approve", + // chain_id: 1, + // token_address: "0xdac17f958d2ee523a2206206994597c13d831ec7", + // token: TokenSpec { symbol: "USDT", decimals: 6, name: "Tether USD" }, + // tx_hash: "0x...", + // }, +]; + +#[tokio::main(flavor = "current_thread")] +async fn main() -> Result<(), Box> { + let api_key = std::env::var("ETHERSCAN_API_KEY") + .map_err(|_| "ETHERSCAN_API_KEY is required (load via `.env`)")?; + + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(30)) + .build()?; + + let out_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/standard_token"); + std::fs::create_dir_all(&out_dir)?; + + #[allow(clippy::const_is_empty)] + if CURATED.is_empty() { + eprintln!( + "CURATED is empty — add (chain, token, tx_hash) tuples in examples/fetch_erc20_fixtures.rs" + ); + return Ok(()); + } + + for tuple in CURATED { + println!("Fetching {} (tx {})", tuple.label, tuple.tx_hash); + + let tx_data = fetch_transaction(&client, &api_key, tuple.chain_id, tuple.tx_hash).await?; + + let calldata_bytes = decode_hex(&tx_data.input)?; + if calldata_bytes.len() < 4 { + return Err(format!( + "[{}] calldata too short (got {} bytes)", + tuple.label, + calldata_bytes.len() + ) + .into()); + } + let selector: [u8; 4] = calldata_bytes[..4].try_into().unwrap(); + if !is_standard_erc20_selector(selector) { + return Err(format!( + "[{}] selector 0x{} is not a standard ERC-20 selector", + tuple.label, + hex::encode(selector) + ) + .into()); + } + + let token_meta = TokenMeta { + symbol: tuple.token.symbol.to_string(), + decimals: tuple.token.decimals, + name: tuple.token.name.to_string(), + }; + let mut tokens = StaticTokenSource::new(); + tokens.insert(tuple.chain_id, tuple.token_address, token_meta); + + let value_bytes = if tx_data.value == "0x" || tx_data.value == "0x0" { + None + } else { + Some(decode_hex(&tx_data.value)?) + }; + + let source = StaticSource::new(); + let tx = TransactionContext { + chain_id: tuple.chain_id, + to: tuple.token_address, + calldata: &calldata_bytes, + value: value_bytes.as_deref(), + from: Some(&tx_data.from), + implementation_address: None, + }; + + let descriptors = resolve_descriptors_for_tx(&tx, &source, Some(&tokens)).await?; + if descriptors.is_empty() { + return Err(format!("[{}] synth did not fire", tuple.label).into()); + } + let model = format_calldata(&descriptors, &tx, &tokens).await?; + + let mut fields = Vec::new(); + for entry in &model.entries { + match entry { + DisplayEntry::Item(item) => fields.push(json!({ + "label": item.label, + "value": item.value, + })), + DisplayEntry::Group { items, .. } => { + for item in items { + fields.push(json!({ + "label": item.label, + "value": item.value, + })); + } + } + DisplayEntry::Nested { .. } => { + return Err(format!( + "[{}] unexpected nested entry for direct ERC-20 call", + tuple.label + ) + .into()); + } + } + } + + let fixture = json!({ + "tx_hash": tuple.tx_hash, + "chain_id": tuple.chain_id, + "to": tuple.token_address, + "from": tx_data.from, + "calldata_hex": tx_data.input, + "value_hex": if tx_data.value == "0x" { "0x00".to_string() } else { tx_data.value.clone() }, + "token_meta": { + "symbol": tuple.token.symbol, + "decimals": tuple.token.decimals, + "name": tuple.token.name, + }, + "expected": { + "intent": model.intent, + "interpolated_intent": model.interpolated_intent.clone().unwrap_or_default(), + "fields": fields, + } + }); + + let out_path = out_dir.join(format!("{}.json", tuple.label)); + std::fs::write(&out_path, serde_json::to_string_pretty(&fixture)?)?; + println!(" wrote {}", out_path.display()); + + // Polite throttle — Etherscan's free tier rate-limits to 5 req/s. + tokio::time::sleep(Duration::from_millis(250)).await; + } + + Ok(()) +} + +fn is_standard_erc20_selector(s: [u8; 4]) -> bool { + matches!( + s, + [0xa9, 0x05, 0x9c, 0xbb] | [0x09, 0x5e, 0xa7, 0xb3] | [0x23, 0xb8, 0x72, 0xdd] + ) +} + +fn decode_hex(s: &str) -> Result, Box> { + let trimmed = s + .strip_prefix("0x") + .or_else(|| s.strip_prefix("0X")) + .unwrap_or(s); + if trimmed.is_empty() { + return Ok(Vec::new()); + } + let padded; + let h = if trimmed.len() % 2 != 0 { + padded = format!("0{trimmed}"); + &padded + } else { + trimmed + }; + Ok(hex::decode(h)?) +} + +#[derive(serde::Deserialize)] +struct EtherscanTx { + from: String, + #[allow(dead_code)] + to: Option, + input: String, + value: String, +} + +#[derive(serde::Deserialize)] +struct EtherscanResponse { + result: Option, + #[serde(default)] + error: Option, +} + +#[derive(serde::Deserialize)] +struct EtherscanError { + #[allow(dead_code)] + code: i64, + message: String, +} + +async fn fetch_transaction( + client: &reqwest::Client, + api_key: &str, + chain_id: u64, + tx_hash: &str, +) -> Result> { + let url = format!( + "https://api.etherscan.io/v2/api?chainid={chain_id}&module=proxy&action=eth_getTransactionByHash&txhash={tx_hash}&apikey={api_key}" + ); + let resp: EtherscanResponse = client.get(&url).send().await?.json().await?; + if let Some(err) = resp.error { + return Err(format!("etherscan error: {}", err.message).into()); + } + resp.result + .ok_or_else(|| format!("tx {tx_hash} not found on chain {chain_id}").into()) +} diff --git a/crates/clear-signing/src/eip712_domain.rs b/crates/clear-signing/src/eip712_domain.rs index 63aae72..4341eff 100644 --- a/crates/clear-signing/src/eip712_domain.rs +++ b/crates/clear-signing/src/eip712_domain.rs @@ -687,7 +687,9 @@ mod tests { #[test] fn salt_mismatch() { let descriptor = build_descriptor( - Some(json!({ "salt": "0x1111111111111111111111111111111111111111111111111111111111111111" })), + Some( + json!({ "salt": "0x1111111111111111111111111111111111111111111111111111111111111111" }), + ), None, ); let data = build_typed_data( @@ -701,7 +703,9 @@ mod tests { #[test] fn salt_missing_in_typed_data() { let descriptor = build_descriptor( - Some(json!({ "salt": "0x1111111111111111111111111111111111111111111111111111111111111111" })), + Some( + json!({ "salt": "0x1111111111111111111111111111111111111111111111111111111111111111" }), + ), None, ); let data = build_typed_data(json!({}), json!({})); @@ -712,7 +716,9 @@ mod tests { #[test] fn salt_uppercase_prefix_normalized() { let descriptor = build_descriptor( - Some(json!({ "salt": "0X1111111111111111111111111111111111111111111111111111111111111111" })), + Some( + json!({ "salt": "0X1111111111111111111111111111111111111111111111111111111111111111" }), + ), None, ); let data = build_typed_data( diff --git a/crates/clear-signing/src/error.rs b/crates/clear-signing/src/error.rs index aa9ae1d..1bdd2c2 100644 --- a/crates/clear-signing/src/error.rs +++ b/crates/clear-signing/src/error.rs @@ -299,11 +299,9 @@ mod tests { assert!(ResolveError::RegistryIndexMissing { url: "u".into() } .to_string() .contains("index missing")); - assert!( - ResolveError::RegistryDescriptorMissing { url: "u".into() } - .to_string() - .contains("descriptor missing") - ); + assert!(ResolveError::RegistryDescriptorMissing { url: "u".into() } + .to_string() + .contains("descriptor missing")); assert!(ResolveError::RegistryIo("e".into()) .to_string() .contains("io error")); diff --git a/crates/clear-signing/src/resolver/mod.rs b/crates/clear-signing/src/resolver/mod.rs index 91b21ae..135896b 100644 --- a/crates/clear-signing/src/resolver/mod.rs +++ b/crates/clear-signing/src/resolver/mod.rs @@ -5,6 +5,7 @@ mod nested_resolution; mod source; +mod standard_token; mod typed_selection; #[cfg(feature = "github-registry")] diff --git a/crates/clear-signing/src/resolver/nested_resolution.rs b/crates/clear-signing/src/resolver/nested_resolution.rs index 8613c67..5fa819c 100644 --- a/crates/clear-signing/src/resolver/nested_resolution.rs +++ b/crates/clear-signing/src/resolver/nested_resolution.rs @@ -5,9 +5,11 @@ use std::pin::Pin; use crate::decoder::ArgumentValue; use crate::error::{Error, ResolveError}; use crate::outcome::ResolvedDescriptorResolution; +use crate::provider::DataProvider; use crate::types::display::{DisplayField, FieldFormat}; use super::source::{DescriptorSource, ResolvedDescriptor, TypedDescriptorLookup}; +use super::standard_token; use super::{select_typed_outer_descriptor, TypedOuterSelection}; /// Maximum recursion depth for nested descriptor resolution. @@ -17,14 +19,21 @@ const MAX_RESOLVE_DEPTH: u8 = 3; pub async fn resolve_descriptors_for_tx( tx: &crate::TransactionContext<'_>, source: &dyn DescriptorSource, + data_provider: Option<&dyn DataProvider>, ) -> Result { let mut results = Vec::new(); - let address = tx.implementation_address.unwrap_or(tx.to); + // Descriptor matching: against implementation address for proxies. + // Token-metadata lookup: against the user-facing tx.to, since wallet token + // lists are keyed on the proxy/user-facing address. + let descriptor_address = tx.implementation_address.unwrap_or(tx.to); + let token_lookup_address = tx.to; resolve_recursive( tx.chain_id, - address, + descriptor_address, + token_lookup_address, tx.calldata, source, + data_provider, MAX_RESOLVE_DEPTH, &mut results, ) @@ -36,11 +45,14 @@ pub async fn resolve_descriptors_for_tx( }) } +#[allow(clippy::too_many_arguments)] fn resolve_recursive<'a>( chain_id: u64, - address: &'a str, + descriptor_address: &'a str, + token_lookup_address: &'a str, calldata: &'a [u8], source: &'a dyn DescriptorSource, + data_provider: Option<&'a dyn DataProvider>, depth: u8, results: &'a mut Vec, ) -> Pin> + Send + 'a>> { @@ -49,7 +61,30 @@ fn resolve_recursive<'a>( return Ok(()); } - let resolved = match source.resolve_calldata(chain_id, address).await { + // Standard ERC-20 short-circuit: when the wallet recognizes the contract as a + // known token AND the selector is a standard ERC-20 function, synthesize the + // descriptor in-memory and skip the registry entirely. Token lookup uses the + // user-facing address; the synth descriptor's deployment uses the match + // address so format_calldata pairs it correctly for proxy contracts. + if let Ok(selector) = <[u8; 4]>::try_from(&calldata[..4]) { + if standard_token::is_erc20_selector(selector) { + if let Some(dp) = data_provider { + if let Some(meta) = dp.resolve_token(chain_id, token_lookup_address).await { + if let Some(synth) = standard_token::synthesize_erc20( + chain_id, + descriptor_address, + selector, + &meta, + ) { + results.push(synth); + return Ok(()); + } + } + } + } + } + + let resolved = match source.resolve_calldata(chain_id, descriptor_address).await { Ok(r) => r, Err(ResolveError::NotFound { .. }) => return Ok(()), Err(e) => return Err(e), @@ -105,8 +140,20 @@ fn resolve_recursive<'a>( if let (Some(addr), Some(data)) = (callee, inner_data) { let normalized = crate::engine::normalized_nested_calldata(&data, selector_override); - resolve_recursive(inner_chain, &addr, &normalized, source, depth - 1, results) - .await?; + // For nested calls there's no user-facing/implementation split: + // the inner callee is its own address, used for both descriptor + // matching and token lookup. + resolve_recursive( + inner_chain, + &addr, + &addr, + &normalized, + source, + data_provider, + depth - 1, + results, + ) + .await?; } } @@ -121,6 +168,7 @@ fn resolve_recursive<'a>( pub async fn resolve_descriptors_for_typed_data( typed_data: &crate::eip712::TypedData, source: &dyn DescriptorSource, + data_provider: Option<&dyn DataProvider>, ) -> Result { let mut results = Vec::new(); @@ -212,8 +260,10 @@ pub async fn resolve_descriptors_for_typed_data( resolve_recursive( inner_chain, &callee_addr, + &callee_addr, &normalized, source, + data_provider, MAX_RESOLVE_DEPTH - 1, &mut results, ) @@ -645,7 +695,9 @@ mod tests { implementation_address: None, }; - let descriptors = resolve_descriptors_for_tx(&tx, &source).await.unwrap(); + let descriptors = resolve_descriptors_for_tx(&tx, &source, None) + .await + .unwrap(); assert_eq!(descriptors.len(), 2, "should resolve outer + inner"); assert_eq!(descriptors[0].address, safe_addr.to_lowercase()); @@ -674,7 +726,9 @@ mod tests { implementation_address: None, }; - let descriptors = resolve_descriptors_for_tx(&tx, &source).await.unwrap(); + let descriptors = resolve_descriptors_for_tx(&tx, &source, None) + .await + .unwrap(); assert_eq!( descriptors.len(), @@ -698,7 +752,9 @@ mod tests { implementation_address: None, }; - let descriptors = resolve_descriptors_for_tx(&tx, &source).await.unwrap(); + let descriptors = resolve_descriptors_for_tx(&tx, &source, None) + .await + .unwrap(); assert!(descriptors.is_empty(), "no outer descriptor → empty vec"); } @@ -725,7 +781,9 @@ mod tests { implementation_address: Some(safe_addr), }; - let descriptors = resolve_descriptors_for_tx(&tx, &source).await.unwrap(); + let descriptors = resolve_descriptors_for_tx(&tx, &source, None) + .await + .unwrap(); assert_eq!( descriptors.len(), @@ -766,7 +824,7 @@ mod tests { ), ); - let descriptors = resolve_descriptors_for_typed_data(&typed_data, &source) + let descriptors = resolve_descriptors_for_typed_data(&typed_data, &source, None) .await .expect("resolve"); assert_eq!(descriptors.len(), 1); @@ -795,7 +853,7 @@ mod tests { ), ); - let descriptors = resolve_descriptors_for_typed_data(&typed_data, &source) + let descriptors = resolve_descriptors_for_typed_data(&typed_data, &source, None) .await .expect("resolve"); assert!(descriptors.is_empty()); @@ -826,7 +884,7 @@ mod tests { ), ); - let descriptors = resolve_descriptors_for_typed_data(&typed_data, &source) + let descriptors = resolve_descriptors_for_typed_data(&typed_data, &source, None) .await .expect("resolve"); assert_eq!(descriptors.len(), 1); @@ -852,7 +910,7 @@ mod tests { permit2_descriptor("Candidate B", format_key, None), ); - let err = resolve_descriptors_for_typed_data(&typed_data, &source) + let err = resolve_descriptors_for_typed_data(&typed_data, &source, None) .await .unwrap_err() .to_string(); @@ -866,7 +924,7 @@ mod tests { inner_mode: InnerResolveMode::NotFound, }; - let descriptors = resolve_descriptors_for_typed_data(&nested_typed_data(), &source) + let descriptors = resolve_descriptors_for_typed_data(&nested_typed_data(), &source, None) .await .expect("resolve"); assert_eq!(descriptors.len(), 1); @@ -883,7 +941,7 @@ mod tests { inner_mode: InnerResolveMode::RegistryDescriptorMissing, }; - let err = resolve_descriptors_for_typed_data(&nested_typed_data(), &source) + let err = resolve_descriptors_for_typed_data(&nested_typed_data(), &source, None) .await .expect_err("nested registry error should propagate"); match err { diff --git a/crates/clear-signing/src/resolver/standard_token.rs b/crates/clear-signing/src/resolver/standard_token.rs new file mode 100644 index 0000000..ae1a289 --- /dev/null +++ b/crates/clear-signing/src/resolver/standard_token.rs @@ -0,0 +1,362 @@ +//! Synthesize ERC-7730 descriptors on-the-fly for standard ERC-20 functions +//! when the wallet supplies token metadata. + +use std::collections::HashMap; + +use crate::token::TokenMeta; +use crate::types::context::{ContractContext, ContractInfo, Deployment, DescriptorContext}; +use crate::types::descriptor::Descriptor; +use crate::types::display::{ + DescriptorDisplay, DisplayField, DisplayFormat, FieldFormat, FormatParams, VisibleRule, +}; +use crate::types::metadata::{Metadata, TokenInfo}; + +use super::source::ResolvedDescriptor; + +struct StandardFn { + selector: [u8; 4], + format_key: &'static str, + intent: &'static str, + interpolated_intent: &'static str, + fields: &'static [SynthField], +} + +struct SynthField { + path: &'static str, + label: &'static str, + format: SynthFieldFormat, +} + +#[derive(Clone, Copy)] +enum SynthFieldFormat { + AddressName, + TokenAmount, +} + +const TRANSFER_FIELDS: &[SynthField] = &[ + SynthField { + path: "to", + label: "To", + format: SynthFieldFormat::AddressName, + }, + SynthField { + path: "amount", + label: "Amount", + format: SynthFieldFormat::TokenAmount, + }, +]; + +const APPROVE_FIELDS: &[SynthField] = &[ + SynthField { + path: "spender", + label: "Spender", + format: SynthFieldFormat::AddressName, + }, + SynthField { + path: "amount", + label: "Amount", + format: SynthFieldFormat::TokenAmount, + }, +]; + +const TRANSFER_FROM_FIELDS: &[SynthField] = &[ + SynthField { + path: "from", + label: "From", + format: SynthFieldFormat::AddressName, + }, + SynthField { + path: "to", + label: "To", + format: SynthFieldFormat::AddressName, + }, + SynthField { + path: "amount", + label: "Amount", + format: SynthFieldFormat::TokenAmount, + }, +]; + +const STANDARD_ERC20_FNS: &[StandardFn] = &[ + StandardFn { + selector: [0xa9, 0x05, 0x9c, 0xbb], + format_key: "transfer(address to,uint256 amount)", + intent: "Transfer tokens", + interpolated_intent: "Transfer {amount} to {to}", + fields: TRANSFER_FIELDS, + }, + StandardFn { + selector: [0x09, 0x5e, 0xa7, 0xb3], + format_key: "approve(address spender,uint256 amount)", + intent: "Approve token spending", + interpolated_intent: "Approve {spender} to spend {amount}", + fields: APPROVE_FIELDS, + }, + StandardFn { + selector: [0x23, 0xb8, 0x72, 0xdd], + format_key: "transferFrom(address from,address to,uint256 amount)", + intent: "Transfer tokens", + interpolated_intent: "Transfer {amount} from {from} to {to}", + fields: TRANSFER_FROM_FIELDS, + }, +]; + +/// True if the 4-byte selector is a standard ERC-20 selector handled by [`synthesize_erc20`]. +pub(crate) fn is_erc20_selector(selector: [u8; 4]) -> bool { + STANDARD_ERC20_FNS.iter().any(|f| f.selector == selector) +} + +/// Build a synthetic ERC-7730 descriptor covering a single standard ERC-20 selector, +/// using on-chain metadata supplied by the wallet. +/// +/// Returns `None` if the selector is not a recognized standard ERC-20 function. +pub(crate) fn synthesize_erc20( + chain_id: u64, + address: &str, + selector: [u8; 4], + meta: &TokenMeta, +) -> Option { + let fn_def = STANDARD_ERC20_FNS.iter().find(|f| f.selector == selector)?; + + let display_format = DisplayFormat { + id: None, + intent: Some(serde_json::Value::String(fn_def.intent.to_string())), + interpolated_intent: Some(fn_def.interpolated_intent.to_string()), + fields: fn_def.fields.iter().map(build_field).collect(), + excluded: Vec::new(), + }; + + let mut formats = HashMap::new(); + formats.insert(fn_def.format_key.to_string(), display_format); + + let descriptor = Descriptor { + schema: None, + includes: None, + context: DescriptorContext::Contract(ContractContext { + id: None, + contract: ContractInfo { + deployments: vec![Deployment { + chain_id, + address: address.to_string(), + }], + factory: None, + }, + }), + metadata: Metadata { + owner: None, + info: None, + token: Some(TokenInfo { + name: Some(meta.name.clone()), + ticker: Some(meta.symbol.clone()), + decimals: Some(meta.decimals), + }), + enums: HashMap::new(), + constants: HashMap::new(), + contract_name: Some(meta.name.clone()), + maps: HashMap::new(), + }, + display: DescriptorDisplay { + definitions: HashMap::new(), + formats, + }, + }; + + Some(ResolvedDescriptor { + descriptor, + chain_id, + address: address.to_string(), + }) +} + +fn build_field(spec: &SynthField) -> DisplayField { + let (format, params) = match spec.format { + SynthFieldFormat::AddressName => (FieldFormat::AddressName, None), + SynthFieldFormat::TokenAmount => { + (FieldFormat::TokenAmount, Some(token_amount_params("@.to"))) + } + }; + DisplayField::Simple { + path: Some(spec.path.to_string()), + label: spec.label.to_string(), + value: None, + format: Some(format), + params, + separator: None, + visible: VisibleRule::Always, + } +} + +fn token_amount_params(token_path: &str) -> FormatParams { + FormatParams { + token_path: Some(token_path.to_string()), + token: None, + native_currency_address: None, + chain_id: None, + chain_id_path: None, + enum_path: None, + ref_path: None, + map_reference: None, + threshold: None, + message: None, + base: None, + decimals: None, + prefix: None, + encryption: None, + encoding: None, + selector_path: None, + selector: None, + callee_path: None, + callee: None, + amount_path: None, + amount: None, + spender_path: None, + spender: None, + types: None, + sources: None, + sender_address: None, + collection_path: None, + collection: None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::types::display::intent_as_string; + + fn usdc_meta() -> TokenMeta { + TokenMeta { + symbol: "USDC".to_string(), + decimals: 6, + name: "USD Coin".to_string(), + } + } + + const USDC_ADDR: &str = "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48"; + + fn assert_intent_pair( + resolved: &ResolvedDescriptor, + format_key: &str, + intent: &str, + interpolated: &str, + ) { + let format = resolved + .descriptor + .display + .formats + .get(format_key) + .expect("format key present"); + let intent_value = format.intent.as_ref().expect("intent present"); + assert_eq!(intent_as_string(intent_value), intent); + assert_eq!(format.interpolated_intent.as_deref(), Some(interpolated)); + } + + #[test] + fn synthesize_transfer_carries_both_intent_fields() { + let resolved = synthesize_erc20(1, USDC_ADDR, [0xa9, 0x05, 0x9c, 0xbb], &usdc_meta()) + .expect("transfer synth"); + assert_intent_pair( + &resolved, + "transfer(address to,uint256 amount)", + "Transfer tokens", + "Transfer {amount} to {to}", + ); + } + + #[test] + fn synthesize_approve_carries_both_intent_fields() { + let resolved = synthesize_erc20(1, USDC_ADDR, [0x09, 0x5e, 0xa7, 0xb3], &usdc_meta()) + .expect("approve synth"); + assert_intent_pair( + &resolved, + "approve(address spender,uint256 amount)", + "Approve token spending", + "Approve {spender} to spend {amount}", + ); + } + + #[test] + fn synthesize_transfer_from_carries_both_intent_fields() { + let resolved = synthesize_erc20(1, USDC_ADDR, [0x23, 0xb8, 0x72, 0xdd], &usdc_meta()) + .expect("transferFrom synth"); + assert_intent_pair( + &resolved, + "transferFrom(address from,address to,uint256 amount)", + "Transfer tokens", + "Transfer {amount} from {from} to {to}", + ); + } + + #[test] + fn synthesize_populates_token_metadata() { + let resolved = synthesize_erc20(1, USDC_ADDR, [0xa9, 0x05, 0x9c, 0xbb], &usdc_meta()) + .expect("transfer synth"); + let token = resolved.descriptor.metadata.token.expect("token info"); + assert_eq!(token.ticker.as_deref(), Some("USDC")); + assert_eq!(token.decimals, Some(6)); + assert_eq!(token.name.as_deref(), Some("USD Coin")); + assert_eq!( + resolved.descriptor.metadata.contract_name.as_deref(), + Some("USD Coin") + ); + } + + #[test] + fn synthesize_uses_token_amount_with_at_to_path() { + let resolved = synthesize_erc20(1, USDC_ADDR, [0xa9, 0x05, 0x9c, 0xbb], &usdc_meta()) + .expect("transfer synth"); + let format = resolved + .descriptor + .display + .formats + .get("transfer(address to,uint256 amount)") + .expect("format present"); + // last field is the amount + let DisplayField::Simple { + format: fmt, + params, + path, + .. + } = format.fields.last().unwrap() + else { + panic!("expected Simple field"); + }; + assert!(matches!(fmt, Some(FieldFormat::TokenAmount))); + assert_eq!(path.as_deref(), Some("amount")); + let params = params.as_ref().expect("params present"); + assert_eq!(params.token_path.as_deref(), Some("@.to")); + } + + #[test] + fn synthesize_returns_none_for_unknown_selector() { + let resolved = synthesize_erc20(1, USDC_ADDR, [0xff, 0xff, 0xff, 0xff], &usdc_meta()); + assert!(resolved.is_none()); + } + + #[test] + fn is_erc20_selector_recognizes_standard_three() { + assert!(is_erc20_selector([0xa9, 0x05, 0x9c, 0xbb])); + assert!(is_erc20_selector([0x09, 0x5e, 0xa7, 0xb3])); + assert!(is_erc20_selector([0x23, 0xb8, 0x72, 0xdd])); + assert!(!is_erc20_selector([0x00, 0x00, 0x00, 0x00])); + assert!(!is_erc20_selector([0xd0, 0xe3, 0x0d, 0xb0])); // deposit() + } + + #[test] + fn descriptor_serializes_with_correct_top_level_shape() { + let resolved = synthesize_erc20(1, USDC_ADDR, [0x09, 0x5e, 0xa7, 0xb3], &usdc_meta()) + .expect("approve synth"); + let json = serde_json::to_value(&resolved.descriptor).expect("serialize"); + assert!(json["context"]["contract"]["deployments"][0]["chainId"] == 1); + assert!(json["metadata"]["token"]["ticker"] == "USDC"); + assert_eq!( + json["display"]["formats"]["approve(address spender,uint256 amount)"]["intent"], + "Approve token spending" + ); + assert_eq!( + json["display"]["formats"]["approve(address spender,uint256 amount)"] + ["interpolatedIntent"], + "Approve {spender} to spend {amount}" + ); + } +} diff --git a/crates/clear-signing/src/uniffi_compat/mod.rs b/crates/clear-signing/src/uniffi_compat/mod.rs index 2850d39..aaade9a 100644 --- a/crates/clear-signing/src/uniffi_compat/mod.rs +++ b/crates/clear-signing/src/uniffi_compat/mod.rs @@ -327,11 +327,13 @@ pub async fn clear_signing_resolve_descriptors_for_typed_data( .unwrap_or("0x0000000000000000000000000000000000000000"); let source = get_registry_source().await?; + let provider = DataProviderFfiProxy(Arc::clone(&data_provider)); // Try direct lookup - let mut descriptors = crate::resolver::resolve_descriptors_for_typed_data(&typed_data, source) - .await - .map_err(FormatFailure::from)?; + let mut descriptors = + crate::resolver::resolve_descriptors_for_typed_data(&typed_data, source, Some(&provider)) + .await + .map_err(FormatFailure::from)?; // Proxy detection fallback if matches!(descriptors, ResolvedDescriptorResolution::NotFound) { @@ -340,9 +342,13 @@ pub async fn clear_signing_resolve_descriptors_for_typed_data( if let Some(impl_addr) = impl_addr { let mut proxied = typed_data.clone(); proxied.domain.verifying_contract = Some(impl_addr.clone()); - descriptors = crate::resolver::resolve_descriptors_for_typed_data(&proxied, source) - .await - .map_err(FormatFailure::from)?; + descriptors = crate::resolver::resolve_descriptors_for_typed_data( + &proxied, + source, + Some(&provider), + ) + .await + .map_err(FormatFailure::from)?; } } @@ -375,7 +381,8 @@ pub async fn clear_signing_resolve_descriptors_for_tx( from: transaction.from_address.as_deref(), implementation_address: None, }; - let mut descriptors = crate::resolve_descriptors_for_tx(&tx, source) + let provider = DataProviderFfiProxy(Arc::clone(&data_provider)); + let mut descriptors = crate::resolve_descriptors_for_tx(&tx, source, Some(&provider)) .await .map_err(FormatFailure::from)?; @@ -388,7 +395,7 @@ pub async fn clear_signing_resolve_descriptors_for_tx( implementation_address: Some(impl_addr.as_str()), ..tx }; - descriptors = crate::resolve_descriptors_for_tx(&tx_with_impl, source) + descriptors = crate::resolve_descriptors_for_tx(&tx_with_impl, source, Some(&provider)) .await .map_err(FormatFailure::from)?; } diff --git a/crates/clear-signing/tests/contract_name_integration.rs b/crates/clear-signing/tests/contract_name_integration.rs index a76f738..c6e078f 100644 --- a/crates/clear-signing/tests/contract_name_integration.rs +++ b/crates/clear-signing/tests/contract_name_integration.rs @@ -1,9 +1,7 @@ use clear_signing::eip712::{TypedData, TypedDataDomain, TypedDataField}; use clear_signing::resolver::ResolvedDescriptor; use clear_signing::types::descriptor::Descriptor; -use clear_signing::{ - format_calldata, format_typed_data, EmptyDataProvider, TransactionContext, -}; +use clear_signing::{format_calldata, format_typed_data, EmptyDataProvider, TransactionContext}; const ERC20_DESCRIPTOR_WITH_CONTRACT_NAME: &str = r#"{ "context": { diff --git a/crates/clear-signing/tests/fixtures/standard_token/base-usdc-transfer.json b/crates/clear-signing/tests/fixtures/standard_token/base-usdc-transfer.json new file mode 100644 index 0000000..7cd6e8a --- /dev/null +++ b/crates/clear-signing/tests/fixtures/standard_token/base-usdc-transfer.json @@ -0,0 +1,17 @@ +{ + "tx_hash": "0xseed-base-usdc-transfer-25", + "chain_id": 8453, + "to": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + "from": "0x8eb8a3b98659cce290402893d0123abb75e3ab28", + "calldata_hex": "0xa9059cbb000000000000000000000000abcdef0123456789abcdef0123456789abcdef0100000000000000000000000000000000000000000000000000000000017d7840", + "value_hex": "0x00", + "token_meta": { "symbol": "USDC", "decimals": 6, "name": "USD Coin" }, + "expected": { + "intent": "Transfer tokens", + "interpolated_intent": "Transfer 25 USDC to 0xabCDeF0123456789AbcdEf0123456789aBCDEF01", + "fields": [ + { "label": "To", "value": "0xabCDeF0123456789AbcdEf0123456789aBCDEF01" }, + { "label": "Amount", "value": "25 USDC" } + ] + } +} diff --git a/crates/clear-signing/tests/fixtures/standard_token/mainnet-usdc-approve.json b/crates/clear-signing/tests/fixtures/standard_token/mainnet-usdc-approve.json new file mode 100644 index 0000000..db97ca7 --- /dev/null +++ b/crates/clear-signing/tests/fixtures/standard_token/mainnet-usdc-approve.json @@ -0,0 +1,17 @@ +{ + "tx_hash": "0xseed-approve-100", + "chain_id": 1, + "to": "0xA0b86991C6218b36c1d19D4a2e9Eb0cE3606eB48", + "from": "0x8eb8a3b98659cce290402893d0123abb75e3ab28", + "calldata_hex": "0x095ea7b3000000000000000000000000c6cde7c39eb2f0f0095f41570af89efc2c1ea8280000000000000000000000000000000000000000000000000000000005f5e100", + "value_hex": "0x0", + "token_meta": { "symbol": "USDC", "decimals": 6, "name": "USD Coin" }, + "expected": { + "intent": "Approve token spending", + "interpolated_intent": "Approve 0xC6CDE7C39eB2f0F0095F41570af89eFC2C1Ea828 to spend 100 USDC", + "fields": [ + { "label": "Spender", "value": "0xC6CDE7C39eB2f0F0095F41570af89eFC2C1Ea828" }, + { "label": "Amount", "value": "100 USDC" } + ] + } +} diff --git a/crates/clear-signing/tests/fixtures/standard_token/mainnet-usdc-transfer.json b/crates/clear-signing/tests/fixtures/standard_token/mainnet-usdc-transfer.json new file mode 100644 index 0000000..3065e0c --- /dev/null +++ b/crates/clear-signing/tests/fixtures/standard_token/mainnet-usdc-transfer.json @@ -0,0 +1,17 @@ +{ + "tx_hash": "0xseed-transfer-50", + "chain_id": 1, + "to": "0xA0b86991C6218b36c1d19D4a2e9Eb0cE3606eB48", + "from": "0x8eb8a3b98659cce290402893d0123abb75e3ab28", + "calldata_hex": "0xa9059cbb00000000000000000000000012345678901234567890123456789012345678900000000000000000000000000000000000000000000000000000000002faf080", + "value_hex": "0x00", + "token_meta": { "symbol": "USDC", "decimals": 6, "name": "USD Coin" }, + "expected": { + "intent": "Transfer tokens", + "interpolated_intent": "Transfer 50 USDC to 0x1234567890123456789012345678901234567890", + "fields": [ + { "label": "To", "value": "0x1234567890123456789012345678901234567890" }, + { "label": "Amount", "value": "50 USDC" } + ] + } +} diff --git a/crates/clear-signing/tests/fixtures/standard_token/mainnet-usdc-transferfrom.json b/crates/clear-signing/tests/fixtures/standard_token/mainnet-usdc-transferfrom.json new file mode 100644 index 0000000..ce1c88a --- /dev/null +++ b/crates/clear-signing/tests/fixtures/standard_token/mainnet-usdc-transferfrom.json @@ -0,0 +1,18 @@ +{ + "tx_hash": "0xseed-transferfrom-25", + "chain_id": 1, + "to": "0xA0b86991C6218b36c1d19D4a2e9Eb0cE3606eB48", + "from": "0x8eb8a3b98659cce290402893d0123abb75e3ab28", + "calldata_hex": "0x23b872dd000000000000000000000000aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa000000000000000000000000bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb0000000000000000000000000000000000000000000000000000000001312d00", + "value_hex": "0x00", + "token_meta": { "symbol": "USDC", "decimals": 6, "name": "USD Coin" }, + "expected": { + "intent": "Transfer tokens", + "interpolated_intent": "Transfer 20 USDC from 0xaAaAaAaaAaAaAaaAaAAAAAAAAaaaAaAaAaaAaaAa to 0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB", + "fields": [ + { "label": "From", "value": "0xaAaAaAaaAaAaAaaAaAAAAAAAAaaaAaAaAaaAaaAa" }, + { "label": "To", "value": "0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB" }, + { "label": "Amount", "value": "20 USDC" } + ] + } +} diff --git a/crates/clear-signing/tests/fixtures/standard_token/mainnet-wbtc-approve.json b/crates/clear-signing/tests/fixtures/standard_token/mainnet-wbtc-approve.json new file mode 100644 index 0000000..ead856f --- /dev/null +++ b/crates/clear-signing/tests/fixtures/standard_token/mainnet-wbtc-approve.json @@ -0,0 +1,17 @@ +{ + "tx_hash": "0xseed-wbtc-approve-0p5", + "chain_id": 1, + "to": "0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599", + "from": "0x8eb8a3b98659cce290402893d0123abb75e3ab28", + "calldata_hex": "0x095ea7b3000000000000000000000000c6cde7c39eb2f0f0095f41570af89efc2c1ea8280000000000000000000000000000000000000000000000000000000002faf080", + "value_hex": "0x00", + "token_meta": { "symbol": "WBTC", "decimals": 8, "name": "Wrapped BTC" }, + "expected": { + "intent": "Approve token spending", + "interpolated_intent": "Approve 0xC6CDE7C39eB2f0F0095F41570af89eFC2C1Ea828 to spend 0.5 WBTC", + "fields": [ + { "label": "Spender", "value": "0xC6CDE7C39eB2f0F0095F41570af89eFC2C1Ea828" }, + { "label": "Amount", "value": "0.5 WBTC" } + ] + } +} diff --git a/crates/clear-signing/tests/fixtures/standard_token/mainnet-weth-transfer.json b/crates/clear-signing/tests/fixtures/standard_token/mainnet-weth-transfer.json new file mode 100644 index 0000000..d65c4b2 --- /dev/null +++ b/crates/clear-signing/tests/fixtures/standard_token/mainnet-weth-transfer.json @@ -0,0 +1,17 @@ +{ + "tx_hash": "0xseed-weth-transfer-0p5", + "chain_id": 1, + "to": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", + "from": "0x8eb8a3b98659cce290402893d0123abb75e3ab28", + "calldata_hex": "0xa9059cbb000000000000000000000000123456789012345678901234567890123456789000000000000000000000000000000000000000000000000006f05b59d3b20000", + "value_hex": "0x00", + "token_meta": { "symbol": "WETH", "decimals": 18, "name": "Wrapped Ether" }, + "expected": { + "intent": "Transfer tokens", + "interpolated_intent": "Transfer 0.5 WETH to 0x1234567890123456789012345678901234567890", + "fields": [ + { "label": "To", "value": "0x1234567890123456789012345678901234567890" }, + { "label": "Amount", "value": "0.5 WETH" } + ] + } +} diff --git a/crates/clear-signing/tests/spec_compliance.rs b/crates/clear-signing/tests/spec_compliance.rs index 80aac88..7035efa 100644 --- a/crates/clear-signing/tests/spec_compliance.rs +++ b/crates/clear-signing/tests/spec_compliance.rs @@ -5531,7 +5531,7 @@ async fn test_resolver_finds_nested_descriptor_with_constant_callee_and_chain_id from: None, implementation_address: None, }; - let descriptors = clear_signing::resolve_descriptors_for_tx(&tx, &source) + let descriptors = clear_signing::resolve_descriptors_for_tx(&tx, &source, None) .await .unwrap(); assert_eq!(descriptors.len(), 2); diff --git a/crates/clear-signing/tests/standard_token.rs b/crates/clear-signing/tests/standard_token.rs new file mode 100644 index 0000000..46ad38b --- /dev/null +++ b/crates/clear-signing/tests/standard_token.rs @@ -0,0 +1,624 @@ +//! Integration tests for the standard ERC-20 descriptor synthesis path: +//! - the resolver short-circuits the registry source when both selector and token are known +//! - registry is still consulted when either signal is missing +//! - nested calls (Safe execTransaction) and EIP-712 wrappers also benefit + +use std::future::Future; +use std::pin::Pin; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::Arc; + +use clear_signing::eip712::TypedData; +use clear_signing::resolver::{resolve_descriptors_for_typed_data, StaticSource}; +use clear_signing::token::StaticTokenSource; +use clear_signing::types::descriptor::Descriptor; +use clear_signing::{ + format_calldata, resolve_descriptors_for_tx, DescriptorSource, DisplayEntry, + ResolvedDescriptor, TokenMeta, TransactionContext, TypedDescriptorLookup, +}; + +const USDC_ADDR: &str = "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48"; +const RECIPIENT: &str = "0x1234567890123456789012345678901234567890"; +const SPENDER: &str = "0xC6CDE7C39eb2f0F0095F41570af89eFC2C1Ea828"; + +// --------------------------------------------------------------------------- +// Recording source — counts resolve_calldata invocations +// --------------------------------------------------------------------------- + +struct RecordingSource { + inner: StaticSource, + calldata_calls: Arc, +} + +impl RecordingSource { + fn new() -> Self { + Self { + inner: StaticSource::new(), + calldata_calls: Arc::new(AtomicUsize::new(0)), + } + } + + fn call_counter(&self) -> Arc { + Arc::clone(&self.calldata_calls) + } + + fn add_calldata(&mut self, chain_id: u64, address: &str, descriptor: Descriptor) { + self.inner.add_calldata(chain_id, address, descriptor); + } + + fn add_typed(&mut self, chain_id: u64, address: &str, descriptor: Descriptor) { + self.inner.add_typed(chain_id, address, descriptor); + } +} + +impl DescriptorSource for RecordingSource { + fn resolve_calldata( + &self, + chain_id: u64, + address: &str, + ) -> Pin< + Box< + dyn Future> + + Send + + '_, + >, + > { + self.calldata_calls.fetch_add(1, Ordering::SeqCst); + self.inner.resolve_calldata(chain_id, address) + } + + fn resolve_typed_candidates( + &self, + lookup: TypedDescriptorLookup, + ) -> Pin< + Box< + dyn Future, clear_signing::error::ResolveError>> + + Send + + '_, + >, + > { + self.inner.resolve_typed_candidates(lookup) + } +} + +// --------------------------------------------------------------------------- +// Calldata builders +// --------------------------------------------------------------------------- + +fn address_word(hex_addr: &str) -> Vec { + let hex = hex_addr + .strip_prefix("0x") + .or_else(|| hex_addr.strip_prefix("0X")) + .unwrap_or(hex_addr); + let bytes = hex::decode(hex).expect("hex addr"); + let mut word = vec![0u8; 12]; + word.extend_from_slice(&bytes); + word +} + +fn uint_word(val: u128) -> Vec { + let mut word = vec![0u8; 16]; + word.extend_from_slice(&val.to_be_bytes()); + word +} + +fn pad32(len: usize) -> usize { + len.div_ceil(32) * 32 +} + +fn transfer_calldata(to: &str, amount: u128) -> Vec { + let mut out = vec![0xa9, 0x05, 0x9c, 0xbb]; + out.extend_from_slice(&address_word(to)); + out.extend_from_slice(&uint_word(amount)); + out +} + +fn approve_calldata(spender: &str, amount: u128) -> Vec { + let mut out = vec![0x09, 0x5e, 0xa7, 0xb3]; + out.extend_from_slice(&address_word(spender)); + out.extend_from_slice(&uint_word(amount)); + out +} + +fn exec_transaction_calldata(target: &str, inner: &[u8]) -> Vec { + // execTransaction(address,uint256,bytes,uint8,uint256,uint256,uint256,address,address,bytes) + let selector = clear_signing::decoder::parse_signature( + "execTransaction(address,uint256,bytes,uint8,uint256,uint256,uint256,address,address,bytes)", + ) + .unwrap() + .selector; + + let mut calldata = Vec::new(); + calldata.extend_from_slice(&selector); + calldata.extend_from_slice(&address_word(target)); + calldata.extend_from_slice(&uint_word(0)); + calldata.extend_from_slice(&uint_word(320)); + calldata.extend_from_slice(&uint_word(0)); + calldata.extend_from_slice(&uint_word(0)); + calldata.extend_from_slice(&uint_word(0)); + calldata.extend_from_slice(&uint_word(0)); + calldata.extend_from_slice(&[0u8; 32]); + calldata.extend_from_slice(&[0u8; 32]); + let data_offset = 320 + 32 + pad32(inner.len()); + calldata.extend_from_slice(&uint_word(data_offset as u128)); + calldata.extend_from_slice(&uint_word(inner.len() as u128)); + calldata.extend_from_slice(inner); + let padding = pad32(inner.len()) - inner.len(); + calldata.extend_from_slice(&vec![0u8; padding]); + calldata.extend_from_slice(&uint_word(0)); + calldata +} + +fn safe_descriptor() -> Descriptor { + let path = format!( + "{}/tests/fixtures/common-Safe.json", + env!("CARGO_MANIFEST_DIR") + ); + Descriptor::from_json(&std::fs::read_to_string(path).unwrap()).unwrap() +} + +fn usdc_meta() -> TokenMeta { + TokenMeta { + symbol: "USDC".to_string(), + decimals: 6, + name: "USD Coin".to_string(), + } +} + +fn tokens_with_usdc() -> StaticTokenSource { + let mut tokens = StaticTokenSource::new(); + tokens.insert(1, USDC_ADDR, usdc_meta()); + tokens +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn synth_transfer_renders_through_format_calldata() { + let source = RecordingSource::new(); + let counter = source.call_counter(); + let tokens = tokens_with_usdc(); + + let calldata = transfer_calldata(RECIPIENT, 2_500_000); // 2.5 USDC + let tx = TransactionContext { + chain_id: 1, + to: USDC_ADDR, + calldata: &calldata, + value: None, + from: None, + implementation_address: None, + }; + + let descriptors = resolve_descriptors_for_tx(&tx, &source, Some(&tokens)) + .await + .expect("resolve"); + assert_eq!(counter.load(Ordering::SeqCst), 0, "registry skipped"); + + let model = format_calldata(&descriptors, &tx, &tokens) + .await + .expect("format"); + assert_eq!(model.intent, "Transfer tokens"); + let interpolated = model + .interpolated_intent + .clone() + .expect("interpolated intent"); + assert!( + interpolated.contains("2.5 USDC"), + "expected '2.5 USDC' in '{interpolated}'" + ); + assert!( + interpolated.starts_with("Transfer "), + "expected the synth interpolation template, got '{interpolated}'" + ); +} + +#[tokio::test] +async fn synth_fires_when_proxy_caller_pre_sets_implementation_address() { + // A direct caller pre-populates `implementation_address` for a proxy ERC-20 + // (e.g. they did their own EIP-1967 storage read upstream) and asks for + // descriptors. The wallet's token list is keyed on the user-facing address + // (tx.to), not the implementation. Synth must look up tokens against tx.to + // while the synth descriptor's deployment uses the implementation so + // format_calldata can match it. + let impl_addr = "0x1111111111111111111111111111111111111111"; + let source = RecordingSource::new(); + let counter = source.call_counter(); + let tokens = tokens_with_usdc(); // keyed on USDC_ADDR (the proxy) + + let calldata = approve_calldata(SPENDER, 1_000_000); + let tx = TransactionContext { + chain_id: 1, + to: USDC_ADDR, + calldata: &calldata, + value: None, + from: None, + implementation_address: Some(impl_addr), + }; + + let descriptors = resolve_descriptors_for_tx(&tx, &source, Some(&tokens)) + .await + .expect("resolve"); + + assert_eq!(descriptors.len(), 1, "synth fired"); + assert_eq!(counter.load(Ordering::SeqCst), 0, "registry not consulted"); + let synth = &descriptors[0]; + let deployment_addr = synth + .descriptor + .context + .deployments() + .first() + .map(|d| d.address.clone()) + .expect("deployment present"); + assert_eq!( + deployment_addr.to_lowercase(), + impl_addr.to_lowercase(), + "synth descriptor deploys at the implementation so format_calldata can match" + ); + + // Render through format_calldata to lock the integration end-to-end: + // the descriptor matches against implementation_address and the amount + // resolves via tokenPath: \"@.to\" → user-facing USDC_ADDR. + let model = format_calldata(&descriptors, &tx, &tokens) + .await + .expect("format"); + assert_eq!(model.intent, "Approve token spending"); + let interpolated = model + .interpolated_intent + .clone() + .expect("interpolated intent"); + assert!( + interpolated.contains("1 USDC"), + "expected '1 USDC' in '{interpolated}'" + ); +} + +#[tokio::test] +async fn standard_selector_with_known_token_short_circuits_registry() { + let source = RecordingSource::new(); + let counter = source.call_counter(); + let tokens = tokens_with_usdc(); + + let calldata = approve_calldata(SPENDER, 1_000_000); + let tx = TransactionContext { + chain_id: 1, + to: USDC_ADDR, + calldata: &calldata, + value: None, + from: None, + implementation_address: None, + }; + + let descriptors = resolve_descriptors_for_tx(&tx, &source, Some(&tokens)) + .await + .expect("resolve"); + + assert_eq!(descriptors.len(), 1, "synth produces one descriptor"); + assert_eq!(counter.load(Ordering::SeqCst), 0, "registry not consulted"); + assert_eq!( + descriptors[0] + .descriptor + .display + .formats + .keys() + .next() + .map(String::as_str), + Some("approve(address spender,uint256 amount)") + ); +} + +#[tokio::test] +async fn synth_wins_over_competing_registry_descriptor() { + // Registry has an approve descriptor for the same token; synth should still win. + let competing_json = r#"{ + "context": { + "contract": { + "deployments": [ + { "chainId": 1, "address": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48" } + ] + } + }, + "metadata": { + "owner": "Competing", + "contractName": "Competing USDC", + "enums": {}, "constants": {}, "maps": {} + }, + "display": { + "definitions": {}, + "formats": { + "approve(address spender,uint256 amount)": { + "intent": "Competing approve", + "interpolatedIntent": "Competing approve {spender}", + "fields": [ + { "path": "spender", "label": "Spender", "format": "addressName" }, + { "path": "amount", "label": "Amount", "format": "raw" } + ] + } + } + } + }"#; + let competing = Descriptor::from_json(competing_json).unwrap(); + let mut source = RecordingSource::new(); + source.add_calldata(1, USDC_ADDR, competing); + let counter = source.call_counter(); + let tokens = tokens_with_usdc(); + + let calldata = approve_calldata(SPENDER, 1_000_000); + let tx = TransactionContext { + chain_id: 1, + to: USDC_ADDR, + calldata: &calldata, + value: None, + from: None, + implementation_address: None, + }; + + let descriptors = resolve_descriptors_for_tx(&tx, &source, Some(&tokens)) + .await + .expect("resolve"); + + assert_eq!(descriptors.len(), 1); + assert_eq!(counter.load(Ordering::SeqCst), 0, "registry skipped"); + let format = descriptors[0] + .descriptor + .display + .formats + .get("approve(address spender,uint256 amount)") + .expect("synth format"); + let intent = format.intent.as_ref().expect("intent present"); + assert_eq!( + intent.as_str(), + Some("Approve token spending"), + "synth intent, not competing" + ); +} + +#[tokio::test] +async fn standard_selector_without_provider_falls_through_to_registry() { + let mut source = RecordingSource::new(); + let mut registry = StaticSource::new(); + registry.add_calldata( + 1, + USDC_ADDR, + Descriptor::from_json(include_str!("fixtures/erc20-approve.json")).unwrap(), + ); + // Mirror the registry contents into the recording source + source.add_calldata( + 1, + USDC_ADDR, + Descriptor::from_json(include_str!("fixtures/erc20-approve.json")).unwrap(), + ); + let counter = source.call_counter(); + + let calldata = approve_calldata(SPENDER, 1_000_000); + let tx = TransactionContext { + chain_id: 1, + to: USDC_ADDR, + calldata: &calldata, + value: None, + from: None, + implementation_address: None, + }; + + let descriptors = resolve_descriptors_for_tx(&tx, &source, None) + .await + .expect("resolve"); + + assert_eq!(descriptors.len(), 1); + assert!(counter.load(Ordering::SeqCst) >= 1, "registry called"); +} + +#[tokio::test] +async fn non_standard_selector_with_known_token_uses_registry() { + // WETH-style deposit() selector — not in the standard ERC-20 set. + // Even though wallet knows the token, the synth must not fire. + let mut source = RecordingSource::new(); + let weth_json = r#"{ + "context": { + "contract": { + "deployments": [ + { "chainId": 1, "address": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48" } + ] + } + }, + "metadata": { "owner": "WETH", "enums": {}, "constants": {}, "maps": {} }, + "display": { + "definitions": {}, + "formats": { + "deposit()": { + "intent": "Wrap ETH", + "fields": [] + } + } + } + }"#; + source.add_calldata(1, USDC_ADDR, Descriptor::from_json(weth_json).unwrap()); + let counter = source.call_counter(); + let tokens = tokens_with_usdc(); + + // deposit() selector + let calldata = vec![0xd0, 0xe3, 0x0d, 0xb0]; + let tx = TransactionContext { + chain_id: 1, + to: USDC_ADDR, + calldata: &calldata, + value: None, + from: None, + implementation_address: None, + }; + + let _ = resolve_descriptors_for_tx(&tx, &source, Some(&tokens)) + .await + .expect("resolve"); + assert!( + counter.load(Ordering::SeqCst) >= 1, + "registry called for non-standard selector" + ); +} + +#[tokio::test] +async fn eip712_wrapping_nested_calldata_synthesizes_inner_transfer() { + // EIP-712 message wraps an inner ERC-20 transfer calldata via `format: calldata`. + // Source has the outer EIP-712 descriptor but NOT the inner USDC descriptor. + // Synth should fire for the inner transfer. + let outer_address = "0x0000000000000000000000000000000000000abc"; + let outer_json = format!( + r#"{{ + "context": {{ + "eip712": {{ + "deployments": [{{ "chainId": 1, "address": "{outer_address}" }}], + "domain": {{ "name": "Nested Permit" }} + }} + }}, + "metadata": {{ "owner": "Outer Permit", "enums": {{}}, "constants": {{}}, "maps": {{}} }}, + "display": {{ + "definitions": {{}}, + "formats": {{ + "Permit(address spender,Call call)Call(address to,bytes data)": {{ + "intent": "Outer permit", + "fields": [{{ + "path": "call.data", + "label": "Inner Call", + "format": "calldata", + "params": {{ "calleePath": "call.to" }} + }}] + }} + }} + }} + }}"# + ); + + let mut source = RecordingSource::new(); + source.add_typed( + 1, + outer_address, + Descriptor::from_json(&outer_json).unwrap(), + ); + let counter = source.call_counter(); + let tokens = tokens_with_usdc(); + + let inner_calldata_hex = format!("0x{}", hex::encode(transfer_calldata(RECIPIENT, 1_500_000))); + let typed_data: TypedData = serde_json::from_value(serde_json::json!({ + "types": { + "Permit": [ + { "name": "spender", "type": "address" }, + { "name": "call", "type": "Call" } + ], + "Call": [ + { "name": "to", "type": "address" }, + { "name": "data", "type": "bytes" } + ], + "EIP712Domain": [ + { "name": "name", "type": "string" }, + { "name": "chainId", "type": "uint256" }, + { "name": "verifyingContract", "type": "address" } + ] + }, + "domain": { + "name": "Nested Permit", + "chainId": "1", + "verifyingContract": outer_address + }, + "primaryType": "Permit", + "message": { + "spender": "0x00000000000000000000000000000000000000ff", + "call": { + "to": USDC_ADDR, + "data": inner_calldata_hex, + } + } + })) + .expect("typed data"); + + let descriptors = resolve_descriptors_for_typed_data(&typed_data, &source, Some(&tokens)) + .await + .expect("resolve"); + assert_eq!(descriptors.len(), 2, "outer + synthesized inner"); + assert_eq!( + descriptors[1].address.to_lowercase(), + USDC_ADDR.to_lowercase() + ); + assert!( + descriptors[1] + .descriptor + .display + .formats + .contains_key("transfer(address to,uint256 amount)"), + "inner descriptor is the synthesized transfer" + ); + assert_eq!( + counter.load(Ordering::SeqCst), + 0, + "registry not consulted for the inner call" + ); +} + +#[tokio::test] +async fn nested_safe_exec_transaction_synthesizes_inner_approve() { + let safe_addr = "0xd9Db270c1B5E3Bd161E8c8503c55cEABeE709552"; + let mut source = RecordingSource::new(); + source.add_calldata(1, safe_addr, safe_descriptor()); + // Note: no ERC-20 descriptor in the source — the inner approve must come from synth. + let tokens = tokens_with_usdc(); + + let inner = approve_calldata(SPENDER, 1_000_000); + let outer = exec_transaction_calldata(USDC_ADDR, &inner); + let tx = TransactionContext { + chain_id: 1, + to: safe_addr, + calldata: &outer, + value: None, + from: None, + implementation_address: None, + }; + + let descriptors = resolve_descriptors_for_tx(&tx, &source, Some(&tokens)) + .await + .expect("resolve"); + + assert_eq!( + descriptors.len(), + 2, + "outer Safe + inner synthesized ERC-20" + ); + assert_eq!( + descriptors[0].address.to_lowercase(), + safe_addr.to_lowercase() + ); + assert_eq!( + descriptors[1].address.to_lowercase(), + USDC_ADDR.to_lowercase() + ); + let inner_intent = descriptors[1] + .descriptor + .display + .formats + .get("approve(address spender,uint256 amount)") + .expect("inner approve format") + .intent + .as_ref() + .expect("intent present"); + assert_eq!( + inner_intent.as_str(), + Some("Approve token spending"), + "inner synth carries the standard intent" + ); + + // Render through format_calldata and assert the inner nested entry uses the plain intent. + let model = format_calldata(&descriptors, &tx, &tokens) + .await + .expect("format"); + let nested = model + .entries + .iter() + .find_map(|entry| match entry { + DisplayEntry::Nested { intent, .. } => Some(intent.clone()), + _ => None, + }) + .expect("nested entry present"); + assert_eq!( + nested, "Approve token spending", + "nested rendering uses the plain intent, not the interpolated form" + ); +} diff --git a/crates/clear-signing/tests/standard_token_e2e.rs b/crates/clear-signing/tests/standard_token_e2e.rs new file mode 100644 index 0000000..15d2e35 --- /dev/null +++ b/crates/clear-signing/tests/standard_token_e2e.rs @@ -0,0 +1,180 @@ +//! End-to-end fixture-driven tests for ERC-20 descriptor synthesis. +//! +//! Each fixture under `tests/fixtures/standard_token/` is a hand-built or +//! generator-produced snapshot — the calldata is real ABI-encoded ERC-20 input, +//! but seed fixtures use `0xseed-*` placeholder hashes. Run +//! `cargo run -p clear-signing --example fetch_erc20_fixtures --features github-registry` +//! after populating `CURATED` to replace them with snapshots of real +//! on-chain transactions fetched from Etherscan. The test runs the full +//! library pipeline (resolver + format_calldata) using a `StaticTokenSource` +//! populated from `token_meta` and asserts the rendered output matches the +//! committed `expected` block. + +use std::path::PathBuf; + +use clear_signing::resolver::StaticSource; +use clear_signing::token::StaticTokenSource; +use clear_signing::{ + format_calldata, resolve_descriptors_for_tx, DisplayEntry, TokenMeta, TransactionContext, +}; +use serde::Deserialize; + +#[derive(Deserialize)] +struct Fixture { + chain_id: u64, + to: String, + #[serde(default)] + from: Option, + calldata_hex: String, + #[serde(default)] + value_hex: Option, + token_meta: FixtureTokenMeta, + expected: ExpectedOutput, +} + +#[derive(Deserialize)] +struct FixtureTokenMeta { + symbol: String, + decimals: u8, + name: String, +} + +#[derive(Deserialize)] +struct ExpectedOutput { + intent: String, + interpolated_intent: String, + fields: Vec, +} + +#[derive(Deserialize)] +struct ExpectedField { + label: String, + value: String, +} + +fn fixture_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/standard_token") +} + +fn load_fixtures() -> Vec<(String, Fixture)> { + let dir = fixture_dir(); + let entries = std::fs::read_dir(&dir).unwrap_or_else(|err| { + panic!("read fixtures dir {}: {err}", dir.display()); + }); + + let mut fixtures = Vec::new(); + for entry in entries { + let entry = entry.expect("entry"); + let path = entry.path(); + if path.extension().and_then(|s| s.to_str()) != Some("json") { + continue; + } + let raw = std::fs::read_to_string(&path).expect("read fixture"); + let fixture: Fixture = serde_json::from_str(&raw) + .unwrap_or_else(|err| panic!("parse {}: {err}", path.display())); + fixtures.push(( + path.file_name().unwrap().to_string_lossy().into_owned(), + fixture, + )); + } + fixtures.sort_by(|a, b| a.0.cmp(&b.0)); + fixtures +} + +fn decode_hex(s: &str) -> Vec { + let trimmed = s + .strip_prefix("0x") + .or_else(|| s.strip_prefix("0X")) + .unwrap_or(s); + if trimmed.len() % 2 != 0 { + let padded = format!("0{trimmed}"); + return hex::decode(&padded).expect("hex decode"); + } + hex::decode(trimmed).expect("hex decode") +} + +#[tokio::test] +async fn fixture_outputs_match_committed_expectations() { + let fixtures = load_fixtures(); + assert!(!fixtures.is_empty(), "at least one fixture must be present"); + + for (name, fixture) in fixtures { + let calldata = decode_hex(&fixture.calldata_hex); + let value_bytes = fixture.value_hex.as_deref().map(decode_hex); + let token_meta = TokenMeta { + symbol: fixture.token_meta.symbol.clone(), + decimals: fixture.token_meta.decimals, + name: fixture.token_meta.name.clone(), + }; + + let mut tokens = StaticTokenSource::new(); + tokens.insert(fixture.chain_id, &fixture.to, token_meta); + + let source = StaticSource::new(); + let tx = TransactionContext { + chain_id: fixture.chain_id, + to: &fixture.to, + calldata: &calldata, + value: value_bytes.as_deref(), + from: fixture.from.as_deref(), + implementation_address: None, + }; + + let descriptors = resolve_descriptors_for_tx(&tx, &source, Some(&tokens)) + .await + .unwrap_or_else(|err| panic!("[{name}] resolve: {err}")); + assert_eq!( + descriptors.len(), + 1, + "[{name}] synth produces one descriptor" + ); + + let model = format_calldata(&descriptors, &tx, &tokens) + .await + .unwrap_or_else(|err| panic!("[{name}] format: {err}")); + + assert_eq!( + model.intent, fixture.expected.intent, + "[{name}] intent mismatch" + ); + let interpolated = model + .interpolated_intent + .clone() + .unwrap_or_else(|| panic!("[{name}] missing interpolated intent")); + assert_eq!( + interpolated, fixture.expected.interpolated_intent, + "[{name}] interpolated intent mismatch" + ); + + let mut rendered_fields: Vec<(String, String)> = Vec::new(); + for entry in &model.entries { + match entry { + DisplayEntry::Item(item) => { + rendered_fields.push((item.label.clone(), item.value.clone())); + } + DisplayEntry::Group { items, .. } => { + for item in items { + rendered_fields.push((item.label.clone(), item.value.clone())); + } + } + DisplayEntry::Nested { .. } => { + panic!("[{name}] unexpected nested entry in top-level fixture") + } + } + } + + assert_eq!( + rendered_fields.len(), + fixture.expected.fields.len(), + "[{name}] field count mismatch (rendered={rendered_fields:?})" + ); + for (rendered, expected) in rendered_fields.iter().zip(fixture.expected.fields.iter()) { + assert_eq!(rendered.0, expected.label, "[{name}] field label"); + assert_eq!( + rendered.1, expected.value, + "[{name}] field value for label '{}'", + expected.label + ); + } + } +} From 3829ef74f7111d4282f9c970d3e2ac5f369678ec Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Mon, 25 May 2026 20:11:29 +0200 Subject: [PATCH 02/10] Render synth ERC-20 amounts as "Unlimited" and "Sender" labels Two ERC-7730 spec-defined edge cases the synthesized descriptors didn't cover, both visible on real wallet flows: 1. amount = 2^256 - 1 (the DeFi infinite-approval sentinel) used to render as the full 70-digit decimal expansion. tokenAmount params now carry threshold + message "Unlimited", so the engine renders "Approve {spender} to spend Unlimited USDT" -- matching what a hand-written descriptor for the same function would produce. 2. addressName fields where the address equals tx.from now carry senderAddress: "@.from". Engine renders "Sender" for those, common in delegated transferFrom flows and self-transfers. Adds two unit tests asserting both params are set on every synth, three integration tests pinning the rendered output through format_calldata (including a "max - 1" test that locks the >= boundary), and a fixture matching the exact 1inch-on-Optimism approve from the bug report. Revoke detection for approve(spender, 0) considered and deferred: spec doesn't support conditional intent in a single descriptor entry; the override belongs in the wallet's display layer. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/resolver/standard_token.rs | 148 ++++++++++++++++- .../optimism-usdt-approve-unlimited.json | 17 ++ crates/clear-signing/tests/standard_token.rs | 157 ++++++++++++++++++ 3 files changed, 318 insertions(+), 4 deletions(-) create mode 100644 crates/clear-signing/tests/fixtures/standard_token/optimism-usdt-approve-unlimited.json diff --git a/crates/clear-signing/src/resolver/standard_token.rs b/crates/clear-signing/src/resolver/standard_token.rs index ae1a289..7229489 100644 --- a/crates/clear-signing/src/resolver/standard_token.rs +++ b/crates/clear-signing/src/resolver/standard_token.rs @@ -1,5 +1,17 @@ //! Synthesize ERC-7730 descriptors on-the-fly for standard ERC-20 functions //! when the wallet supplies token metadata. +//! +//! Edge cases handled here: +//! - `amount = 2^256 - 1` (DeFi infinite approval) → `tokenAmount` params carry +//! `threshold` + `message`, engine renders "Unlimited {ticker}". +//! - Address fields equal to `tx.from` → `addressName` params carry +//! `senderAddress: "@.from"`, engine renders "Sender" instead of the address. +//! +//! Considered but deferred to wallet UX: `approve(spender, 0)` renders as +//! "Approve {spender} to spend 0 {ticker}" which is accurate but doesn't +//! signal "you are revoking approval". A spec-compliant single descriptor +//! cannot switch its intent based on amount; the wallet's display layer is +//! the right place to override. use std::collections::HashMap; @@ -7,7 +19,8 @@ use crate::token::TokenMeta; use crate::types::context::{ContractContext, ContractInfo, Deployment, DescriptorContext}; use crate::types::descriptor::Descriptor; use crate::types::display::{ - DescriptorDisplay, DisplayField, DisplayFormat, FieldFormat, FormatParams, VisibleRule, + DescriptorDisplay, DisplayField, DisplayFormat, FieldFormat, FormatParams, SenderAddress, + VisibleRule, }; use crate::types::metadata::{Metadata, TokenInfo}; @@ -170,7 +183,10 @@ pub(crate) fn synthesize_erc20( fn build_field(spec: &SynthField) -> DisplayField { let (format, params) = match spec.format { - SynthFieldFormat::AddressName => (FieldFormat::AddressName, None), + SynthFieldFormat::AddressName => ( + FieldFormat::AddressName, + Some(address_name_params_sender("@.from")), + ), SynthFieldFormat::TokenAmount => { (FieldFormat::TokenAmount, Some(token_amount_params("@.to"))) } @@ -186,9 +202,14 @@ fn build_field(spec: &SynthField) -> DisplayField { } } -fn token_amount_params(token_path: &str) -> FormatParams { +/// uint256 max — sentinel value used by every common DeFi "infinite approval" flow +/// (1inch, Uniswap, OpenSea, Permit2 aggregators). Engine comparison is `>=`, so +/// the exact-max case is included. +const UINT256_MAX_HEX: &str = "0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"; + +fn empty_format_params() -> FormatParams { FormatParams { - token_path: Some(token_path.to_string()), + token_path: None, token: None, native_currency_address: None, chain_id: None, @@ -219,6 +240,22 @@ fn token_amount_params(token_path: &str) -> FormatParams { } } +fn token_amount_params(token_path: &str) -> FormatParams { + FormatParams { + token_path: Some(token_path.to_string()), + threshold: Some(UINT256_MAX_HEX.to_string()), + message: Some("Unlimited".to_string()), + ..empty_format_params() + } +} + +fn address_name_params_sender(sender_path: &str) -> FormatParams { + FormatParams { + sender_address: Some(SenderAddress::Single(sender_path.to_string())), + ..empty_format_params() + } +} + #[cfg(test)] mod tests { use super::*; @@ -327,6 +364,109 @@ mod tests { assert_eq!(params.token_path.as_deref(), Some("@.to")); } + /// Every synth's amount field carries `threshold` + `message` so the + /// engine renders the 2^256-1 "infinite approval" pattern as + /// "Unlimited {ticker}" rather than the 70-digit decimal expansion. + #[test] + fn synthesize_amount_field_carries_threshold_and_message() { + let cases = [ + ( + [0xa9, 0x05, 0x9c, 0xbb], + "transfer(address to,uint256 amount)", + ), + ( + [0x09, 0x5e, 0xa7, 0xb3], + "approve(address spender,uint256 amount)", + ), + ( + [0x23, 0xb8, 0x72, 0xdd], + "transferFrom(address from,address to,uint256 amount)", + ), + ]; + + for (selector, format_key) in cases { + let resolved = synthesize_erc20(1, USDC_ADDR, selector, &usdc_meta()).expect("synth"); + let format = resolved + .descriptor + .display + .formats + .get(format_key) + .expect("format present"); + let DisplayField::Simple { params, .. } = format.fields.last().expect("amount field") + else { + panic!("expected Simple field"); + }; + let params = params.as_ref().expect("params present"); + assert_eq!( + params.threshold.as_deref(), + Some("0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"), + "format_key={format_key}" + ); + assert_eq!( + params.message.as_deref(), + Some("Unlimited"), + "format_key={format_key}" + ); + } + } + + /// Every `addressName` field in every synth carries `senderAddress: "@.from"` + /// so the engine renders "Sender" when the field address equals `tx.from`. + #[test] + fn synthesize_address_fields_carry_sender_address_from() { + let cases = [ + ( + [0xa9, 0x05, 0x9c, 0xbb], + "transfer(address to,uint256 amount)", + ), + ( + [0x09, 0x5e, 0xa7, 0xb3], + "approve(address spender,uint256 amount)", + ), + ( + [0x23, 0xb8, 0x72, 0xdd], + "transferFrom(address from,address to,uint256 amount)", + ), + ]; + + for (selector, format_key) in cases { + let resolved = synthesize_erc20(1, USDC_ADDR, selector, &usdc_meta()).expect("synth"); + let format = resolved + .descriptor + .display + .formats + .get(format_key) + .expect("format present"); + + let mut address_fields_checked = 0; + for field in &format.fields { + let DisplayField::Simple { + format: fmt, + params, + .. + } = field + else { + continue; + }; + if !matches!(fmt, Some(FieldFormat::AddressName)) { + continue; + } + let params = params.as_ref().expect("params present on address field"); + match params.sender_address.as_ref().expect("sender_address set") { + SenderAddress::Single(path) => { + assert_eq!(path.as_str(), "@.from", "format_key={format_key}") + } + SenderAddress::Multiple(_) => panic!("expected Single variant"), + } + address_fields_checked += 1; + } + assert!( + address_fields_checked > 0, + "format {format_key} should have at least one AddressName field" + ); + } + } + #[test] fn synthesize_returns_none_for_unknown_selector() { let resolved = synthesize_erc20(1, USDC_ADDR, [0xff, 0xff, 0xff, 0xff], &usdc_meta()); diff --git a/crates/clear-signing/tests/fixtures/standard_token/optimism-usdt-approve-unlimited.json b/crates/clear-signing/tests/fixtures/standard_token/optimism-usdt-approve-unlimited.json new file mode 100644 index 0000000..46f0a8c --- /dev/null +++ b/crates/clear-signing/tests/fixtures/standard_token/optimism-usdt-approve-unlimited.json @@ -0,0 +1,17 @@ +{ + "tx_hash": "0xseed-approve-unlimited", + "chain_id": 10, + "to": "0x94b008aA00579c1307B0EF2c499aD98a8ce58e58", + "from": "0xbf01daf454dce008d3e2bfd47d5e186f71477253", + "calldata_hex": "0x095ea7b3000000000000000000000000794a61358d6845594f94dc1db02a252b5b4814adffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + "value_hex": "0x0", + "token_meta": { "symbol": "USDT", "decimals": 6, "name": "Tether USD" }, + "expected": { + "intent": "Approve token spending", + "interpolated_intent": "Approve 0x794a61358D6845594F94dc1DB02A252b5b4814aD to spend Unlimited USDT", + "fields": [ + { "label": "Spender", "value": "0x794a61358D6845594F94dc1DB02A252b5b4814aD" }, + { "label": "Amount", "value": "Unlimited USDT" } + ] + } +} diff --git a/crates/clear-signing/tests/standard_token.rs b/crates/clear-signing/tests/standard_token.rs index 46ad38b..e1f6892 100644 --- a/crates/clear-signing/tests/standard_token.rs +++ b/crates/clear-signing/tests/standard_token.rs @@ -120,6 +120,22 @@ fn approve_calldata(spender: &str, amount: u128) -> Vec { out } +/// Build approve calldata with an explicit 32-byte amount — for testing uint256 max etc. +fn approve_calldata_raw_amount(spender: &str, amount_word: [u8; 32]) -> Vec { + let mut out = vec![0x09, 0x5e, 0xa7, 0xb3]; + out.extend_from_slice(&address_word(spender)); + out.extend_from_slice(&amount_word); + out +} + +fn transfer_from_calldata(from: &str, to: &str, amount: u128) -> Vec { + let mut out = vec![0x23, 0xb8, 0x72, 0xdd]; + out.extend_from_slice(&address_word(from)); + out.extend_from_slice(&address_word(to)); + out.extend_from_slice(&uint_word(amount)); + out +} + fn exec_transaction_calldata(target: &str, inner: &[u8]) -> Vec { // execTransaction(address,uint256,bytes,uint8,uint256,uint256,uint256,address,address,bytes) let selector = clear_signing::decoder::parse_signature( @@ -622,3 +638,144 @@ async fn nested_safe_exec_transaction_synthesizes_inner_approve() { "nested rendering uses the plain intent, not the interpolated form" ); } + +// --------------------------------------------------------------------------- +// Edge-case rendering: threshold/message + senderAddress +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn approve_with_uint256_max_renders_unlimited() { + // Standard DeFi "infinite approval" — 1inch, Uniswap, Permit2 aggregators all + // emit approve(spender, 2^256 - 1). The synth's threshold + message wires + // the engine to render this as "Unlimited USDC" rather than the 70-digit + // decimal expansion of the raw amount. + let source = RecordingSource::new(); + let tokens = tokens_with_usdc(); + + let calldata = approve_calldata_raw_amount(SPENDER, [0xff; 32]); + let tx = TransactionContext { + chain_id: 1, + to: USDC_ADDR, + calldata: &calldata, + value: None, + from: None, + implementation_address: None, + }; + + let descriptors = resolve_descriptors_for_tx(&tx, &source, Some(&tokens)) + .await + .expect("resolve"); + let model = format_calldata(&descriptors, &tx, &tokens) + .await + .expect("format"); + let interpolated = model + .interpolated_intent + .clone() + .expect("interpolated intent"); + assert!( + interpolated.ends_with("to spend Unlimited USDC"), + "expected 'to spend Unlimited USDC' suffix, got '{interpolated}'" + ); + assert!( + !interpolated.contains("115792089"), + "the 70-digit decimal must not appear: '{interpolated}'" + ); +} + +#[tokio::test] +async fn approve_with_uint256_max_minus_one_renders_full_amount() { + // Locks the `>= max` semantics: exactly one less than uint256 max should + // NOT trigger the "Unlimited" branch — it renders as the full decimal. + // Prevents a future change from quietly loosening the bound. + let source = RecordingSource::new(); + let tokens = tokens_with_usdc(); + + let mut amount_word = [0xff_u8; 32]; + amount_word[31] = 0xfe; + let calldata = approve_calldata_raw_amount(SPENDER, amount_word); + let tx = TransactionContext { + chain_id: 1, + to: USDC_ADDR, + calldata: &calldata, + value: None, + from: None, + implementation_address: None, + }; + + let descriptors = resolve_descriptors_for_tx(&tx, &source, Some(&tokens)) + .await + .expect("resolve"); + let model = format_calldata(&descriptors, &tx, &tokens) + .await + .expect("format"); + let interpolated = model + .interpolated_intent + .clone() + .expect("interpolated intent"); + assert!( + !interpolated.contains("Unlimited"), + "uint256_max - 1 must NOT trigger Unlimited: '{interpolated}'" + ); + // The full decimal of (2^256 - 1) / 10^6 ends in ".639935" — check the + // synth still rendered a numeric amount via tokenAmount. + assert!( + interpolated.contains("USDC"), + "expected the token ticker in the rendered output: '{interpolated}'" + ); +} + +#[tokio::test] +async fn transfer_from_with_sender_as_from_renders_sender_label() { + // senderAddress: "@.from" on every addressName field makes the engine + // render "Sender" when the field address equals tx.from. Common in + // delegated transferFrom flows where the caller controls the source. + let source = RecordingSource::new(); + let tokens = tokens_with_usdc(); + + // sender_addr is both `tx.from` AND the `from` argument of transferFrom. + let sender_addr = "0xbf01daf454dce008d3e2bfd47d5e186f71477253"; + let calldata = transfer_from_calldata(sender_addr, RECIPIENT, 1_000_000); + let tx = TransactionContext { + chain_id: 1, + to: USDC_ADDR, + calldata: &calldata, + value: None, + from: Some(sender_addr), + implementation_address: None, + }; + + let descriptors = resolve_descriptors_for_tx(&tx, &source, Some(&tokens)) + .await + .expect("resolve"); + let model = format_calldata(&descriptors, &tx, &tokens) + .await + .expect("format"); + + let from_item = model + .entries + .iter() + .find_map(|entry| match entry { + DisplayEntry::Item(item) if item.label == "From" => Some(item.clone()), + _ => None, + }) + .expect("From field present"); + assert_eq!( + from_item.value, "Sender", + "from field matching tx.from should render as 'Sender'" + ); + + // Sanity-check the recipient field DOES still render as a checksummed + // address (it doesn't match tx.from). + let to_item = model + .entries + .iter() + .find_map(|entry| match entry { + DisplayEntry::Item(item) if item.label == "To" => Some(item.clone()), + _ => None, + }) + .expect("To field present"); + assert_ne!( + to_item.value, "Sender", + "to field NOT matching tx.from should render the address" + ); +} From 156542bdfbc7eaad802e7430c8ee4e60ae0de8cd Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Mon, 25 May 2026 20:50:48 +0200 Subject: [PATCH 03/10] Resolve known DeFi contracts to readable names on the wallet The bug-report flow now renders "Approve 0x794a...4814aD to spend Unlimited USDT". That address is Aave V3 Pool on Optimism (verified in the EF 7730 registry). The library's addressName chain already supports this end-to-end -- it was just not populated wallet-side. Wallet: - Add SeedContractStore (mirrors SeedTokenStore) reading a bundled known-contracts.json keyed by eip155:{chain}/contract:{addr}. - Seed ~30 entries copied verbatim from the EF registry covering the wallet's 5 supported chains: Aave V3 Pool, 1inch v5/v6 routers, ParaSwap Augustus V6.2, Permit2, Safe singleton 1.4.1. - Extend WalletMetadataProvider.resolveLocalName: user-self check (preserved) -> known-contracts lookup -> nil. Always-check by contract regardless of the types hint; small enough to stay cheap. - Add LookupKey.contract and ContractMetadata next to the existing token helper. - 5 new XCTests cover known-contract hit, multi-chain keying, wallet-self precedence, unknown-returns-nil, and case-insensitive lookup. All 22 WalletTests pass on iPhone 17 Pro Simulator. - Wire SeedContractStore.swift into both Wallet and WalletTests Sources build phases; bundle known-contracts.json via a new Resources group. Library: - standard_token synth now sets the spec-defined `types` hint on addressName params: ["contract"] on approve.spender (unambiguous), None on transfer.to / transferFrom.from / .to (caller checks all sources). Engine passes it through to resolveLocalName so wallets can route lookups by role; today it's a future-proofing hint. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/resolver/standard_token.rs | 86 +++++++++++- wallet/Wallet.xcodeproj/project.pbxproj | 18 +++ wallet/Wallet/Resources/known-contracts.json | 37 +++++ .../Wallet/Services/SeedContractStore.swift | 27 ++++ wallet/Wallet/Services/TokenMetadata.swift | 10 ++ .../Services/WalletMetadataProvider.swift | 23 +++- .../WalletMetadataProviderTests.swift | 126 ++++++++++++++++++ 7 files changed, 322 insertions(+), 5 deletions(-) create mode 100644 wallet/Wallet/Resources/known-contracts.json create mode 100644 wallet/Wallet/Services/SeedContractStore.swift diff --git a/crates/clear-signing/src/resolver/standard_token.rs b/crates/clear-signing/src/resolver/standard_token.rs index 7229489..f2bc75b 100644 --- a/crates/clear-signing/src/resolver/standard_token.rs +++ b/crates/clear-signing/src/resolver/standard_token.rs @@ -38,6 +38,10 @@ struct SynthField { path: &'static str, label: &'static str, format: SynthFieldFormat, + /// Spec-defined `addressName.types` hint (e.g. `["contract"]`). Passed + /// through to `resolve_local_name` so wallets can route lookups by role. + /// `None` means no hint (wallet checks all sources). + address_types: Option<&'static [&'static str]>, } #[derive(Clone, Copy)] @@ -46,16 +50,20 @@ enum SynthFieldFormat { TokenAmount, } +const CONTRACT_TYPE: &[&str] = &["contract"]; + const TRANSFER_FIELDS: &[SynthField] = &[ SynthField { path: "to", label: "To", format: SynthFieldFormat::AddressName, + address_types: None, }, SynthField { path: "amount", label: "Amount", format: SynthFieldFormat::TokenAmount, + address_types: None, }, ]; @@ -64,11 +72,15 @@ const APPROVE_FIELDS: &[SynthField] = &[ path: "spender", label: "Spender", format: SynthFieldFormat::AddressName, + // Approval targets are unambiguously contracts; hint lets the wallet + // scope the local-name lookup to its known-contracts table. + address_types: Some(CONTRACT_TYPE), }, SynthField { path: "amount", label: "Amount", format: SynthFieldFormat::TokenAmount, + address_types: None, }, ]; @@ -77,16 +89,19 @@ const TRANSFER_FROM_FIELDS: &[SynthField] = &[ path: "from", label: "From", format: SynthFieldFormat::AddressName, + address_types: None, }, SynthField { path: "to", label: "To", format: SynthFieldFormat::AddressName, + address_types: None, }, SynthField { path: "amount", label: "Amount", format: SynthFieldFormat::TokenAmount, + address_types: None, }, ]; @@ -185,7 +200,7 @@ fn build_field(spec: &SynthField) -> DisplayField { let (format, params) = match spec.format { SynthFieldFormat::AddressName => ( FieldFormat::AddressName, - Some(address_name_params_sender("@.from")), + Some(address_name_params("@.from", spec.address_types)), ), SynthFieldFormat::TokenAmount => { (FieldFormat::TokenAmount, Some(token_amount_params("@.to"))) @@ -249,9 +264,10 @@ fn token_amount_params(token_path: &str) -> FormatParams { } } -fn address_name_params_sender(sender_path: &str) -> FormatParams { +fn address_name_params(sender_path: &str, types: Option<&[&str]>) -> FormatParams { FormatParams { sender_address: Some(SenderAddress::Single(sender_path.to_string())), + types: types.map(|t| t.iter().map(|s| s.to_string()).collect()), ..empty_format_params() } } @@ -467,6 +483,72 @@ mod tests { } } + /// `approve.spender` carries `types: ["contract"]` because approval targets + /// are unambiguously contracts. All other address fields leave `types` unset + /// so the wallet checks every source. + #[test] + fn synthesize_address_fields_carry_role_types_hint() { + // approve.spender → ["contract"] + let approve = synthesize_erc20(1, USDC_ADDR, [0x09, 0x5e, 0xa7, 0xb3], &usdc_meta()) + .expect("approve synth"); + let spender_field = approve + .descriptor + .display + .formats + .get("approve(address spender,uint256 amount)") + .and_then(|f| f.fields.first()) + .expect("spender field"); + let DisplayField::Simple { params, .. } = spender_field else { + panic!("expected Simple field"); + }; + let params = params.as_ref().expect("params"); + assert_eq!( + params.types.as_deref(), + Some(vec!["contract".to_string()]).as_deref(), + "approve.spender should carry types: [\"contract\"]" + ); + + // transfer.to and transferFrom.{from,to} → types is None + let other_cases = [ + ( + [0xa9, 0x05, 0x9c, 0xbb], + "transfer(address to,uint256 amount)", + ), + ( + [0x23, 0xb8, 0x72, 0xdd], + "transferFrom(address from,address to,uint256 amount)", + ), + ]; + for (selector, format_key) in other_cases { + let resolved = synthesize_erc20(1, USDC_ADDR, selector, &usdc_meta()).expect("synth"); + let format = resolved + .descriptor + .display + .formats + .get(format_key) + .expect("format"); + for field in &format.fields { + let DisplayField::Simple { + format: fmt, + params, + .. + } = field + else { + continue; + }; + if !matches!(fmt, Some(FieldFormat::AddressName)) { + continue; + } + let params = params.as_ref().expect("params"); + assert!( + params.types.is_none(), + "{format_key} address field should leave types unset, got {:?}", + params.types + ); + } + } + } + #[test] fn synthesize_returns_none_for_unknown_selector() { let resolved = synthesize_erc20(1, USDC_ADDR, [0xff, 0xff, 0xff, 0xff], &usdc_meta()); diff --git a/wallet/Wallet.xcodeproj/project.pbxproj b/wallet/Wallet.xcodeproj/project.pbxproj index 714074b..abb63ec 100644 --- a/wallet/Wallet.xcodeproj/project.pbxproj +++ b/wallet/Wallet.xcodeproj/project.pbxproj @@ -48,6 +48,9 @@ F1000001000000000030 /* ClearSigningService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1000001000000000014 /* ClearSigningService.swift */; }; F1000001000000000031 /* TypedDataDiagnostics.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1000001000000000070 /* TypedDataDiagnostics.swift */; }; F1000001000000000032 /* CalldataDiagnostics.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1000001000000000072 /* CalldataDiagnostics.swift */; }; + E3000001000000000001 /* SeedContractStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3000001000000000011 /* SeedContractStore.swift */; }; + E3000001000000000002 /* SeedContractStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3000001000000000011 /* SeedContractStore.swift */; }; + E3000001000000000003 /* known-contracts.json in Resources */ = {isa = PBXBuildFile; fileRef = E3000001000000000012 /* known-contracts.json */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -80,6 +83,8 @@ F1000001000000000013 /* AlchemyClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlchemyClient.swift; sourceTree = ""; }; F1000001000000000014 /* ENSResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ENSResolver.swift; sourceTree = ""; }; F1000001000000000021 /* TransactionDebugView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransactionDebugView.swift; sourceTree = ""; }; + E3000001000000000011 /* SeedContractStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SeedContractStore.swift; sourceTree = ""; }; + E3000001000000000012 /* known-contracts.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "known-contracts.json"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -135,12 +140,21 @@ D1000001000000000011 /* WalletViewModel.swift */, D1000001000000000020 /* Services */, D1000001000000000021 /* Views */, + E3000001000000000020 /* Resources */, C1000001233456780013 /* Info.plist */, D1000001000000000041 /* Wallet.entitlements */, ); path = Wallet; sourceTree = ""; }; + E3000001000000000020 /* Resources */ = { + isa = PBXGroup; + children = ( + E3000001000000000012 /* known-contracts.json */, + ); + path = Resources; + sourceTree = ""; + }; C100000123345678000F /* Products */ = { isa = PBXGroup; children = ( @@ -165,6 +179,7 @@ F1000001000000000012 /* MetadataCache.swift */, F1000001000000000013 /* AlchemyClient.swift */, F1000001000000000014 /* ENSResolver.swift */, + E3000001000000000011 /* SeedContractStore.swift */, E2000001000000000012 /* tokens.json */, ); path = Services; @@ -292,6 +307,7 @@ files = ( A3000001000000000001 /* Assets.xcassets in Resources */, E2000001000000000002 /* tokens.json in Resources */, + E3000001000000000003 /* known-contracts.json in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -324,6 +340,7 @@ F1000001000000000003 /* MetadataCache.swift in Sources */, F1000001000000000005 /* AlchemyClient.swift in Sources */, F1000001000000000007 /* ENSResolver.swift in Sources */, + E3000001000000000001 /* SeedContractStore.swift in Sources */, D1000001000000000005 /* DisplayModelView.swift in Sources */, D1000001000000000006 /* KeyImportSection.swift in Sources */, D1000001000000000007 /* QRScannerSheet.swift in Sources */, @@ -343,6 +360,7 @@ F1000001000000000004 /* MetadataCache.swift in Sources */, F1000001000000000006 /* AlchemyClient.swift in Sources */, F1000001000000000008 /* ENSResolver.swift in Sources */, + E3000001000000000002 /* SeedContractStore.swift in Sources */, F1000001000000000030 /* ClearSigningService.swift in Sources */, F1000001000000000031 /* TypedDataDiagnostics.swift in Sources */, F1000001000000000032 /* CalldataDiagnostics.swift in Sources */, diff --git a/wallet/Wallet/Resources/known-contracts.json b/wallet/Wallet/Resources/known-contracts.json new file mode 100644 index 0000000..3563dd4 --- /dev/null +++ b/wallet/Wallet/Resources/known-contracts.json @@ -0,0 +1,37 @@ +{ + "eip155:1/contract:0x87870bca3f3fd6335c3f4ce8392d69350b4fa4e2": { "name": "Aave V3 Pool" }, + "eip155:10/contract:0x794a61358d6845594f94dc1db02a252b5b4814ad": { "name": "Aave V3 Pool" }, + "eip155:137/contract:0x794a61358d6845594f94dc1db02a252b5b4814ad": { "name": "Aave V3 Pool" }, + "eip155:8453/contract:0xa238dd80c259a72e81d7e4664a9801593f98d1c5": { "name": "Aave V3 Pool" }, + "eip155:42161/contract:0x794a61358d6845594f94dc1db02a252b5b4814ad": { "name": "Aave V3 Pool" }, + + "eip155:1/contract:0x111111125421ca6dc452d289314280a0f8842a65": { "name": "1inch Aggregation Router V6" }, + "eip155:10/contract:0x111111125421ca6dc452d289314280a0f8842a65": { "name": "1inch Aggregation Router V6" }, + "eip155:137/contract:0x111111125421ca6dc452d289314280a0f8842a65": { "name": "1inch Aggregation Router V6" }, + "eip155:8453/contract:0x111111125421ca6dc452d289314280a0f8842a65": { "name": "1inch Aggregation Router V6" }, + "eip155:42161/contract:0x111111125421ca6dc452d289314280a0f8842a65": { "name": "1inch Aggregation Router V6" }, + + "eip155:1/contract:0x1111111254eeb25477b68fb85ed929f73a960582": { "name": "1inch Aggregation Router V5" }, + "eip155:10/contract:0x1111111254eeb25477b68fb85ed929f73a960582": { "name": "1inch Aggregation Router V5" }, + "eip155:137/contract:0x1111111254eeb25477b68fb85ed929f73a960582": { "name": "1inch Aggregation Router V5" }, + "eip155:8453/contract:0x1111111254eeb25477b68fb85ed929f73a960582": { "name": "1inch Aggregation Router V5" }, + "eip155:42161/contract:0x1111111254eeb25477b68fb85ed929f73a960582": { "name": "1inch Aggregation Router V5" }, + + "eip155:1/contract:0x6a000f20005980200259b80c5102003040001068": { "name": "ParaSwap Augustus Swapper V6.2" }, + "eip155:10/contract:0x6a000f20005980200259b80c5102003040001068": { "name": "ParaSwap Augustus Swapper V6.2" }, + "eip155:137/contract:0x6a000f20005980200259b80c5102003040001068": { "name": "ParaSwap Augustus Swapper V6.2" }, + "eip155:8453/contract:0x6a000f20005980200259b80c5102003040001068": { "name": "ParaSwap Augustus Swapper V6.2" }, + "eip155:42161/contract:0x6a000f20005980200259b80c5102003040001068": { "name": "ParaSwap Augustus Swapper V6.2" }, + + "eip155:1/contract:0x000000000022d473030f116ddee9f6b43ac78ba3": { "name": "Permit2" }, + "eip155:10/contract:0x000000000022d473030f116ddee9f6b43ac78ba3": { "name": "Permit2" }, + "eip155:137/contract:0x000000000022d473030f116ddee9f6b43ac78ba3": { "name": "Permit2" }, + "eip155:8453/contract:0x000000000022d473030f116ddee9f6b43ac78ba3": { "name": "Permit2" }, + "eip155:42161/contract:0x000000000022d473030f116ddee9f6b43ac78ba3": { "name": "Permit2" }, + + "eip155:1/contract:0x41675c099f32341bf84bfc5382af534df5c7461a": { "name": "Safe Singleton 1.4.1" }, + "eip155:10/contract:0x41675c099f32341bf84bfc5382af534df5c7461a": { "name": "Safe Singleton 1.4.1" }, + "eip155:137/contract:0x41675c099f32341bf84bfc5382af534df5c7461a": { "name": "Safe Singleton 1.4.1" }, + "eip155:8453/contract:0x41675c099f32341bf84bfc5382af534df5c7461a": { "name": "Safe Singleton 1.4.1" }, + "eip155:42161/contract:0x41675c099f32341bf84bfc5382af534df5c7461a": { "name": "Safe Singleton 1.4.1" } +} diff --git a/wallet/Wallet/Services/SeedContractStore.swift b/wallet/Wallet/Services/SeedContractStore.swift new file mode 100644 index 0000000..bbcaf38 --- /dev/null +++ b/wallet/Wallet/Services/SeedContractStore.swift @@ -0,0 +1,27 @@ +import Foundation + +/// Bundled lookup of well-known DeFi contract addresses → display names. +/// Mirrors `SeedTokenStore`'s pattern; keys follow `LookupKey.contract`. +struct SeedContractStore { + private let contracts: [String: ContractMetadata] + + init(bundle: Bundle, resourceName: String = "known-contracts", resourceExtension: String = "json") { + let data = bundle.url(forResource: resourceName, withExtension: resourceExtension) + .flatMap { try? Data(contentsOf: $0) } + contracts = Self.decode(from: data) + } + + init(data: Data) { + contracts = Self.decode(from: data) + } + + func contract(chainId: UInt64, address: String) -> ContractMetadata? { + contracts[LookupKey.contract(chainId: chainId, address: address)] + } + + private static func decode(from data: Data?) -> [String: ContractMetadata] { + guard let data else { return [:] } + let decoder = JSONDecoder() + return (try? decoder.decode([String: ContractMetadata].self, from: data)) ?? [:] + } +} diff --git a/wallet/Wallet/Services/TokenMetadata.swift b/wallet/Wallet/Services/TokenMetadata.swift index a6e65d4..42baa21 100644 --- a/wallet/Wallet/Services/TokenMetadata.swift +++ b/wallet/Wallet/Services/TokenMetadata.swift @@ -76,4 +76,14 @@ enum LookupKey { static func tokenKey(chainId: UInt64, address: String) -> String { "eip155:\(chainId)/erc20:\(address.lowercased())" } + + /// Lookup key for known DeFi contracts (matches known-contracts.json schema). + static func contract(chainId: UInt64, address: String) -> String { + "eip155:\(chainId)/contract:\(address.lowercased())" + } +} + +/// Display metadata for a known contract (DeFi protocol address). +struct ContractMetadata: Codable, Equatable { + let name: String } diff --git a/wallet/Wallet/Services/WalletMetadataProvider.swift b/wallet/Wallet/Services/WalletMetadataProvider.swift index d190d3f..8fd1ce2 100644 --- a/wallet/Wallet/Services/WalletMetadataProvider.swift +++ b/wallet/Wallet/Services/WalletMetadataProvider.swift @@ -16,6 +16,7 @@ final class WalletMetadataProvider: DataProviderFfi, @unchecked Sendable { } private let seedTokenStore: SeedTokenStore + private let seedContractStore: SeedContractStore private let memoryCache: InMemoryResolutionCache private let persistentCache: PersistentResolutionCache private let alchemyClient: AlchemyClient? @@ -26,6 +27,7 @@ final class WalletMetadataProvider: DataProviderFfi, @unchecked Sendable { static func live(bundle: Bundle = .main) -> WalletMetadataProvider { WalletMetadataProvider( seedTokenStore: SeedTokenStore(bundle: bundle), + seedContractStore: SeedContractStore(bundle: bundle), memoryCache: InMemoryResolutionCache(), persistentCache: PersistentResolutionCache(userDefaults: .standard), alchemyClient: AppConfig.alchemyAPIKey.map { AlchemyClient(apiKey: $0) }, @@ -37,6 +39,7 @@ final class WalletMetadataProvider: DataProviderFfi, @unchecked Sendable { init( seedTokenStore: SeedTokenStore, + seedContractStore: SeedContractStore = SeedContractStore(data: Data()), memoryCache: InMemoryResolutionCache, persistentCache: PersistentResolutionCache, alchemyClient: AlchemyClient?, @@ -45,6 +48,7 @@ final class WalletMetadataProvider: DataProviderFfi, @unchecked Sendable { now: @escaping () -> Date ) { self.seedTokenStore = seedTokenStore + self.seedContractStore = seedContractStore self.memoryCache = memoryCache self.persistentCache = persistentCache self.alchemyClient = alchemyClient @@ -64,9 +68,22 @@ final class WalletMetadataProvider: DataProviderFfi, @unchecked Sendable { } func resolveLocalName(address: String, chainId: UInt64, types: [String]? = nil) -> String? { - guard let resolved = normalizedAddress(address), - let wallet = normalizedAddress(walletAddressProvider()) else { return nil } - return resolved == wallet ? Self.localWalletName : nil + guard let resolved = normalizedAddress(address) else { return nil } + + // 1. User's own wallet wins over any known-contract entry. + if let wallet = normalizedAddress(walletAddressProvider()), resolved == wallet { + return Self.localWalletName + } + + // 2. Known DeFi contract from the bundled seed. Runs regardless of `types`: + // the library's hint is `["contract"]` for approve.spender but `None` + // for transfer.to / transferFrom.{from,to} — and a transfer to a known + // router should still get labeled. Always-check is cheap (small dict). + if let known = seedContractStore.contract(chainId: chainId, address: resolved) { + return known.name + } + + return nil } func resolveNftCollectionName(collectionAddress: String, chainId: UInt64) -> String? { diff --git a/wallet/WalletTests/WalletMetadataProviderTests.swift b/wallet/WalletTests/WalletMetadataProviderTests.swift index 4868c41..a6eb871 100644 --- a/wallet/WalletTests/WalletMetadataProviderTests.swift +++ b/wallet/WalletTests/WalletMetadataProviderTests.swift @@ -246,6 +246,132 @@ final class WalletMetadataProviderTests: XCTestCase { XCTAssertNil(provider.resolveToken(chainId: 1, address: tokenAddress)) } + // MARK: - Known-contract resolution + + /// Optimism Aave V3 Pool address from the bug report. Locks the exact + /// rendering we promised in the plan ("Aave V3 Pool" on chain 10). + private let optimismAavePool = "0x794a61358d6845594f94dc1db02a252b5b4814ad" + private let mainnetAavePool = "0x87870bca3f3fd6335c3f4ce8392d69350b4fa4e2" + private let oneInchV6Router = "0x111111125421ca6dc452d289314280a0f8842a65" + + private func contractStore( + entries: [String: ContractMetadata] + ) -> SeedContractStore { + let data = try! JSONEncoder().encode(entries) + return SeedContractStore(data: data) + } + + func testResolveLocalNameReturnsKnownContractName() { + let provider = makeProviderWithContractStore( + contractStore( + entries: [ + LookupKey.contract(chainId: 10, address: optimismAavePool): + ContractMetadata(name: "Aave V3 Pool"), + ] + ) + ) + XCTAssertEqual( + provider.resolveLocalName(address: optimismAavePool, chainId: 10), + "Aave V3 Pool" + ) + } + + func testResolveLocalNameKeyedPerChain() { + // Same universal address on two chains — both should resolve via their own key. + let provider = makeProviderWithContractStore( + contractStore( + entries: [ + LookupKey.contract(chainId: 1, address: oneInchV6Router): + ContractMetadata(name: "1inch Aggregation Router V6"), + LookupKey.contract(chainId: 42161, address: oneInchV6Router): + ContractMetadata(name: "1inch Aggregation Router V6"), + ] + ) + ) + XCTAssertEqual( + provider.resolveLocalName(address: oneInchV6Router, chainId: 1), + "1inch Aggregation Router V6" + ) + XCTAssertEqual( + provider.resolveLocalName(address: oneInchV6Router, chainId: 42161), + "1inch Aggregation Router V6" + ) + } + + func testResolveLocalNameWalletWinsOverKnownContract() { + // Pathological-but-instructive setup: the user's wallet address is also + // bundled as a "known contract". The wallet-self check must win, so the + // user never sees their own wallet labeled as a protocol contract. + let conflictAddress = walletAddress + let provider = makeProviderWithContractStore( + contractStore( + entries: [ + LookupKey.contract(chainId: 1, address: conflictAddress): + ContractMetadata(name: "Some Protocol"), + ] + ) + ) + XCTAssertEqual( + provider.resolveLocalName(address: conflictAddress, chainId: 1), + WalletMetadataProvider.localWalletName + ) + } + + func testResolveLocalNameReturnsNilForUnknownContract() { + let provider = makeProviderWithContractStore( + contractStore( + entries: [ + LookupKey.contract(chainId: 10, address: optimismAavePool): + ContractMetadata(name: "Aave V3 Pool"), + ] + ) + ) + // Different address, supported chain. + XCTAssertNil( + provider.resolveLocalName( + address: "0x0000000000000000000000000000000000000042", + chainId: 10 + ) + ) + // Mainnet Aave Pool address, but on the wrong chain. + XCTAssertNil( + provider.resolveLocalName(address: mainnetAavePool, chainId: 10) + ) + } + + func testResolveLocalNameIsCaseInsensitive() { + let provider = makeProviderWithContractStore( + contractStore( + entries: [ + LookupKey.contract(chainId: 10, address: optimismAavePool): + ContractMetadata(name: "Aave V3 Pool"), + ] + ) + ) + // Pass the uppercase / EIP-55 checksummed form; lookup normalizes to + // lowercase before keying. + XCTAssertEqual( + provider.resolveLocalName( + address: "0x794A61358D6845594F94DC1DB02A252B5B4814AD", + chainId: 10 + ), + "Aave V3 Pool" + ) + } + + private func makeProviderWithContractStore(_ store: SeedContractStore) -> WalletMetadataProvider { + WalletMetadataProvider( + seedTokenStore: SeedTokenStore(data: Data("{}".utf8)), + seedContractStore: store, + memoryCache: InMemoryResolutionCache(), + persistentCache: makePersistentCache(name: UUID().uuidString), + alchemyClient: nil, + walletAddressProvider: { self.walletAddress }, + isMainThread: { false }, + now: { Date(timeIntervalSince1970: 1_700_000_000) } + ) + } + func testMainThreadSkipsLiveLookup() { MockURLProtocol.handler = { request in XCTFail("main-thread guard should prevent network lookup: \(String(describing: request.url))") From aebead5871816a7ba795cf49611c69d027d0bc8a Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Mon, 25 May 2026 22:55:06 +0200 Subject: [PATCH 04/10] Tighten synth Unlimited scoping and wallet types-hint gating - Only approve.amount carries threshold+message; transfer/transferFrom render the full decimal at uint256.max. - Synth drops the unilateral addressName.types=["contract"] hint on approve.spender so wallets keep ENS reverse-resolution in scope. - Wallet resolveLocalName now gates the bundled contract-store lookup on types == nil || types.contains("contract") (case-normalized). - Bundle known-contracts.json into the WalletTests target and add a canary that asserts a known entry decodes from the test bundle. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/resolver/standard_token.rs | 133 +++++++++--------- crates/clear-signing/tests/standard_token.rs | 47 +++++++ wallet/Wallet.xcodeproj/project.pbxproj | 2 + .../Services/WalletMetadataProvider.swift | 14 +- .../WalletMetadataProviderTests.swift | 54 +++++++ 5 files changed, 181 insertions(+), 69 deletions(-) diff --git a/crates/clear-signing/src/resolver/standard_token.rs b/crates/clear-signing/src/resolver/standard_token.rs index f2bc75b..668aba7 100644 --- a/crates/clear-signing/src/resolver/standard_token.rs +++ b/crates/clear-signing/src/resolver/standard_token.rs @@ -2,8 +2,10 @@ //! when the wallet supplies token metadata. //! //! Edge cases handled here: -//! - `amount = 2^256 - 1` (DeFi infinite approval) → `tokenAmount` params carry -//! `threshold` + `message`, engine renders "Unlimited {ticker}". +//! - `approve.amount = 2^256 - 1` (DeFi infinite approval) → the approve amount +//! field carries `threshold` + `message`, engine renders "Unlimited {ticker}". +//! `transfer.amount` and `transferFrom.amount` do NOT carry the threshold — +//! a literal cap-valued transfer renders as its full decimal. //! - Address fields equal to `tx.from` → `addressName` params carry //! `senderAddress: "@.from"`, engine renders "Sender" instead of the address. //! @@ -38,32 +40,27 @@ struct SynthField { path: &'static str, label: &'static str, format: SynthFieldFormat, - /// Spec-defined `addressName.types` hint (e.g. `["contract"]`). Passed - /// through to `resolve_local_name` so wallets can route lookups by role. - /// `None` means no hint (wallet checks all sources). - address_types: Option<&'static [&'static str]>, } #[derive(Clone, Copy)] enum SynthFieldFormat { AddressName, TokenAmount, + /// Approve amount only — adds `threshold = uint256.max` + `message = "Unlimited"` + /// so the engine collapses infinite-approval calls. + TokenAmountUnlimited, } -const CONTRACT_TYPE: &[&str] = &["contract"]; - const TRANSFER_FIELDS: &[SynthField] = &[ SynthField { path: "to", label: "To", format: SynthFieldFormat::AddressName, - address_types: None, }, SynthField { path: "amount", label: "Amount", format: SynthFieldFormat::TokenAmount, - address_types: None, }, ]; @@ -72,15 +69,11 @@ const APPROVE_FIELDS: &[SynthField] = &[ path: "spender", label: "Spender", format: SynthFieldFormat::AddressName, - // Approval targets are unambiguously contracts; hint lets the wallet - // scope the local-name lookup to its known-contracts table. - address_types: Some(CONTRACT_TYPE), }, SynthField { path: "amount", label: "Amount", - format: SynthFieldFormat::TokenAmount, - address_types: None, + format: SynthFieldFormat::TokenAmountUnlimited, }, ]; @@ -89,19 +82,16 @@ const TRANSFER_FROM_FIELDS: &[SynthField] = &[ path: "from", label: "From", format: SynthFieldFormat::AddressName, - address_types: None, }, SynthField { path: "to", label: "To", format: SynthFieldFormat::AddressName, - address_types: None, }, SynthField { path: "amount", label: "Amount", format: SynthFieldFormat::TokenAmount, - address_types: None, }, ]; @@ -200,11 +190,15 @@ fn build_field(spec: &SynthField) -> DisplayField { let (format, params) = match spec.format { SynthFieldFormat::AddressName => ( FieldFormat::AddressName, - Some(address_name_params("@.from", spec.address_types)), + Some(address_name_params("@.from")), ), SynthFieldFormat::TokenAmount => { (FieldFormat::TokenAmount, Some(token_amount_params("@.to"))) } + SynthFieldFormat::TokenAmountUnlimited => ( + FieldFormat::TokenAmount, + Some(token_amount_params_unlimited("@.to")), + ), }; DisplayField::Simple { path: Some(spec.path.to_string()), @@ -256,6 +250,13 @@ fn empty_format_params() -> FormatParams { } fn token_amount_params(token_path: &str) -> FormatParams { + FormatParams { + token_path: Some(token_path.to_string()), + ..empty_format_params() + } +} + +fn token_amount_params_unlimited(token_path: &str) -> FormatParams { FormatParams { token_path: Some(token_path.to_string()), threshold: Some(UINT256_MAX_HEX.to_string()), @@ -264,10 +265,9 @@ fn token_amount_params(token_path: &str) -> FormatParams { } } -fn address_name_params(sender_path: &str, types: Option<&[&str]>) -> FormatParams { +fn address_name_params(sender_path: &str) -> FormatParams { FormatParams { sender_address: Some(SenderAddress::Single(sender_path.to_string())), - types: types.map(|t| t.iter().map(|s| s.to_string()).collect()), ..empty_format_params() } } @@ -380,20 +380,40 @@ mod tests { assert_eq!(params.token_path.as_deref(), Some("@.to")); } - /// Every synth's amount field carries `threshold` + `message` so the - /// engine renders the 2^256-1 "infinite approval" pattern as - /// "Unlimited {ticker}" rather than the 70-digit decimal expansion. + /// Only `approve.amount` carries `threshold` + `message`. The engine + /// collapses the 2^256-1 "infinite approval" pattern to "Unlimited {ticker}". #[test] - fn synthesize_amount_field_carries_threshold_and_message() { + fn synthesize_approve_amount_carries_unlimited_threshold() { + let resolved = synthesize_erc20(1, USDC_ADDR, [0x09, 0x5e, 0xa7, 0xb3], &usdc_meta()) + .expect("approve synth"); + let format = resolved + .descriptor + .display + .formats + .get("approve(address spender,uint256 amount)") + .expect("format present"); + let DisplayField::Simple { params, .. } = format.fields.last().expect("amount field") + else { + panic!("expected Simple field"); + }; + let params = params.as_ref().expect("params present"); + assert_eq!( + params.threshold.as_deref(), + Some("0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"), + ); + assert_eq!(params.message.as_deref(), Some("Unlimited")); + } + + /// `transfer.amount` and `transferFrom.amount` must NOT carry `threshold` + /// or `message` — a literal cap-valued transfer renders as the full decimal, + /// not "Unlimited". Only allowances collapse at the cap. + #[test] + fn synthesize_transfer_amounts_omit_threshold_and_message() { let cases = [ ( [0xa9, 0x05, 0x9c, 0xbb], "transfer(address to,uint256 amount)", ), - ( - [0x09, 0x5e, 0xa7, 0xb3], - "approve(address spender,uint256 amount)", - ), ( [0x23, 0xb8, 0x72, 0xdd], "transferFrom(address from,address to,uint256 amount)", @@ -413,15 +433,15 @@ mod tests { panic!("expected Simple field"); }; let params = params.as_ref().expect("params present"); - assert_eq!( - params.threshold.as_deref(), - Some("0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"), - "format_key={format_key}" + assert!( + params.threshold.is_none(), + "{format_key} amount should have no threshold, got {:?}", + params.threshold ); - assert_eq!( - params.message.as_deref(), - Some("Unlimited"), - "format_key={format_key}" + assert!( + params.message.is_none(), + "{format_key} amount should have no message, got {:?}", + params.message ); } } @@ -483,43 +503,28 @@ mod tests { } } - /// `approve.spender` carries `types: ["contract"]` because approval targets - /// are unambiguously contracts. All other address fields leave `types` unset - /// so the wallet checks every source. + /// The synth must NOT unilaterally set `addressName.types`. Real registry + /// descriptors do not set it on `approve.spender` either; a hint forces + /// wallets to route lookups by role and could suppress ENS reverse-resolution. + /// Leaving `types` unset lets the wallet consult every source. #[test] - fn synthesize_address_fields_carry_role_types_hint() { - // approve.spender → ["contract"] - let approve = synthesize_erc20(1, USDC_ADDR, [0x09, 0x5e, 0xa7, 0xb3], &usdc_meta()) - .expect("approve synth"); - let spender_field = approve - .descriptor - .display - .formats - .get("approve(address spender,uint256 amount)") - .and_then(|f| f.fields.first()) - .expect("spender field"); - let DisplayField::Simple { params, .. } = spender_field else { - panic!("expected Simple field"); - }; - let params = params.as_ref().expect("params"); - assert_eq!( - params.types.as_deref(), - Some(vec!["contract".to_string()]).as_deref(), - "approve.spender should carry types: [\"contract\"]" - ); - - // transfer.to and transferFrom.{from,to} → types is None - let other_cases = [ + fn synthesize_address_fields_omit_types_hint() { + let cases = [ ( [0xa9, 0x05, 0x9c, 0xbb], "transfer(address to,uint256 amount)", ), + ( + [0x09, 0x5e, 0xa7, 0xb3], + "approve(address spender,uint256 amount)", + ), ( [0x23, 0xb8, 0x72, 0xdd], "transferFrom(address from,address to,uint256 amount)", ), ]; - for (selector, format_key) in other_cases { + + for (selector, format_key) in cases { let resolved = synthesize_erc20(1, USDC_ADDR, selector, &usdc_meta()).expect("synth"); let format = resolved .descriptor diff --git a/crates/clear-signing/tests/standard_token.rs b/crates/clear-signing/tests/standard_token.rs index e1f6892..ef4ab88 100644 --- a/crates/clear-signing/tests/standard_token.rs +++ b/crates/clear-signing/tests/standard_token.rs @@ -113,6 +113,14 @@ fn transfer_calldata(to: &str, amount: u128) -> Vec { out } +/// Build transfer calldata with an explicit 32-byte amount — for testing uint256 max etc. +fn transfer_calldata_raw_amount(to: &str, amount_word: [u8; 32]) -> Vec { + let mut out = vec![0xa9, 0x05, 0x9c, 0xbb]; + out.extend_from_slice(&address_word(to)); + out.extend_from_slice(&amount_word); + out +} + fn approve_calldata(spender: &str, amount: u128) -> Vec { let mut out = vec![0x09, 0x5e, 0xa7, 0xb3]; out.extend_from_slice(&address_word(spender)); @@ -724,6 +732,45 @@ async fn approve_with_uint256_max_minus_one_renders_full_amount() { ); } +#[tokio::test] +async fn transfer_with_uint256_max_renders_full_amount() { + // Locks the Unlimited scoping: only `approve.amount` collapses at the cap. + // A literal cap-valued transfer (rare, but possible — e.g. an attacker + // crafting calldata to obscure UX) must render as the full decimal, not + // "Unlimited", because `transfer` is not an allowance grant. + let source = RecordingSource::new(); + let tokens = tokens_with_usdc(); + + let calldata = transfer_calldata_raw_amount(SPENDER, [0xff; 32]); + let tx = TransactionContext { + chain_id: 1, + to: USDC_ADDR, + calldata: &calldata, + value: None, + from: None, + implementation_address: None, + }; + + let descriptors = resolve_descriptors_for_tx(&tx, &source, Some(&tokens)) + .await + .expect("resolve"); + let model = format_calldata(&descriptors, &tx, &tokens) + .await + .expect("format"); + let interpolated = model + .interpolated_intent + .clone() + .expect("interpolated intent"); + assert!( + !interpolated.contains("Unlimited"), + "transfer must NOT trigger Unlimited: '{interpolated}'" + ); + assert!( + interpolated.contains("USDC"), + "expected the token ticker in the rendered output: '{interpolated}'" + ); +} + #[tokio::test] async fn transfer_from_with_sender_as_from_renders_sender_label() { // senderAddress: "@.from" on every addressName field makes the engine diff --git a/wallet/Wallet.xcodeproj/project.pbxproj b/wallet/Wallet.xcodeproj/project.pbxproj index abb63ec..826b577 100644 --- a/wallet/Wallet.xcodeproj/project.pbxproj +++ b/wallet/Wallet.xcodeproj/project.pbxproj @@ -51,6 +51,7 @@ E3000001000000000001 /* SeedContractStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3000001000000000011 /* SeedContractStore.swift */; }; E3000001000000000002 /* SeedContractStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3000001000000000011 /* SeedContractStore.swift */; }; E3000001000000000003 /* known-contracts.json in Resources */ = {isa = PBXBuildFile; fileRef = E3000001000000000012 /* known-contracts.json */; }; + E3000001000000000004 /* known-contracts.json in Resources */ = {isa = PBXBuildFile; fileRef = E3000001000000000012 /* known-contracts.json */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -315,6 +316,7 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + E3000001000000000004 /* known-contracts.json in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/wallet/Wallet/Services/WalletMetadataProvider.swift b/wallet/Wallet/Services/WalletMetadataProvider.swift index 8fd1ce2..e4345c2 100644 --- a/wallet/Wallet/Services/WalletMetadataProvider.swift +++ b/wallet/Wallet/Services/WalletMetadataProvider.swift @@ -75,17 +75,21 @@ final class WalletMetadataProvider: DataProviderFfi, @unchecked Sendable { return Self.localWalletName } - // 2. Known DeFi contract from the bundled seed. Runs regardless of `types`: - // the library's hint is `["contract"]` for approve.spender but `None` - // for transfer.to / transferFrom.{from,to} — and a transfer to a known - // router should still get labeled. Always-check is cheap (small dict). - if let known = seedContractStore.contract(chainId: chainId, address: resolved) { + // 2. Known DeFi contract from the bundled seed — gated on the library's + // `types` hint so an explicit EOA-only lookup skips the contract table. + if Self.consultsContractStore(for: types), + let known = seedContractStore.contract(chainId: chainId, address: resolved) { return known.name } return nil } + private static func consultsContractStore(for types: [String]?) -> Bool { + guard let types else { return true } + return types.contains { $0.lowercased() == "contract" } + } + func resolveNftCollectionName(collectionAddress: String, chainId: UInt64) -> String? { lookupNFTCollectionName(chainId: chainId, address: collectionAddress) } diff --git a/wallet/WalletTests/WalletMetadataProviderTests.swift b/wallet/WalletTests/WalletMetadataProviderTests.swift index a6eb871..79a0ea6 100644 --- a/wallet/WalletTests/WalletMetadataProviderTests.swift +++ b/wallet/WalletTests/WalletMetadataProviderTests.swift @@ -359,6 +359,60 @@ final class WalletMetadataProviderTests: XCTestCase { ) } + /// When the library hints `types: ["eoa"]` the lookup must skip the + /// contract-only seed entirely, even if a matching address is present. + func testResolveLocalNameSkipsContractStoreWhenTypesIsEoa() { + let provider = makeProviderWithContractStore( + contractStore( + entries: [ + LookupKey.contract(chainId: 10, address: optimismAavePool): + ContractMetadata(name: "Aave V3 Pool"), + ] + ) + ) + XCTAssertNil( + provider.resolveLocalName( + address: optimismAavePool, + chainId: 10, + types: ["eoa"] + ) + ) + } + + /// When the hint includes "contract" the lookup proceeds — locks the + /// positive direction so future gating changes can't quietly suppress + /// the contract table. + func testResolveLocalNameConsultsContractStoreWhenTypesIncludesContract() { + let provider = makeProviderWithContractStore( + contractStore( + entries: [ + LookupKey.contract(chainId: 10, address: optimismAavePool): + ContractMetadata(name: "Aave V3 Pool"), + ] + ) + ) + XCTAssertEqual( + provider.resolveLocalName( + address: optimismAavePool, + chainId: 10, + types: ["contract"] + ), + "Aave V3 Pool" + ) + } + + /// Canary: the actually-bundled `known-contracts.json` must be packaged + /// with the WalletTests resources and decode cleanly. Catches a rename + /// or a stale `project.pbxproj` Resources phase that would silently turn + /// every contract lookup into a fallthrough. + func testSeedContractStoreLoadsBundledKnownContractsJSON() { + let store = SeedContractStore(bundle: Bundle(for: type(of: self))) + XCTAssertEqual( + store.contract(chainId: 10, address: optimismAavePool)?.name, + "Aave V3 Pool" + ) + } + private func makeProviderWithContractStore(_ store: SeedContractStore) -> WalletMetadataProvider { WalletMetadataProvider( seedTokenStore: SeedTokenStore(data: Data("{}".utf8)), From 9a4849224b57bd91bd270d15817877c8e6417155 Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Wed, 27 May 2026 19:04:26 +0200 Subject: [PATCH 05/10] Use is_multiple_of(2) for hex padding parity clippy::manual_is_multiple_of fires on the new ERC-20 fixture example and e2e test under Rust 1.95+; matches the same cleanup applied to parity checks on main in 51cd5b9. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/clear-signing/examples/fetch_erc20_fixtures.rs | 2 +- crates/clear-signing/tests/standard_token_e2e.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/clear-signing/examples/fetch_erc20_fixtures.rs b/crates/clear-signing/examples/fetch_erc20_fixtures.rs index 32f2805..c51ba00 100644 --- a/crates/clear-signing/examples/fetch_erc20_fixtures.rs +++ b/crates/clear-signing/examples/fetch_erc20_fixtures.rs @@ -195,7 +195,7 @@ fn decode_hex(s: &str) -> Result, Box> { return Ok(Vec::new()); } let padded; - let h = if trimmed.len() % 2 != 0 { + let h = if !trimmed.len().is_multiple_of(2) { padded = format!("0{trimmed}"); &padded } else { diff --git a/crates/clear-signing/tests/standard_token_e2e.rs b/crates/clear-signing/tests/standard_token_e2e.rs index 15d2e35..6578e5e 100644 --- a/crates/clear-signing/tests/standard_token_e2e.rs +++ b/crates/clear-signing/tests/standard_token_e2e.rs @@ -86,7 +86,7 @@ fn decode_hex(s: &str) -> Vec { .strip_prefix("0x") .or_else(|| s.strip_prefix("0X")) .unwrap_or(s); - if trimmed.len() % 2 != 0 { + if !trimmed.len().is_multiple_of(2) { let padded = format!("0{trimmed}"); return hex::decode(&padded).expect("hex decode"); } From 47f55c33b9b9111026d6a5cd9b525230f56dab6b Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Thu, 28 May 2026 09:09:35 +0200 Subject: [PATCH 06/10] Rename FormatFailure.message to .detail to fix Kotlin generation UniFFI's Kotlin generator emits each variant of a thrown sealed-class enum as a nested class extending kotlin.Exception, which exposes a Throwable.message property. A variant field literally named `message` collides with that inherited property, so the generated clear_signing.kt failed to compile and the Android CI job had been red since the FormatFailure type was introduced. Renaming the wire-level field to `detail` removes the collision. The public Swift API stays source-compatible via the existing extension: `var message: String` on FormatFailure now reads from `detail`. The Kotlin wrapper exposes the same surface via `failureMessage`. Updates the React Native demo + regenerated TS bindings accordingly. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../com/clearsigning/ClearSigningClient.kt | 11 ++- .../example/src/components/DebugScreen.tsx | 2 +- .../src/generated/clear_signing.ts | 84 ++++++++++-------- bindings/swift/ClearSigningClient.swift | 14 +-- bindings/swift/clear_signing.swift | 42 ++++----- crates/clear-signing/src/error.rs | 87 +++++++++++++------ crates/clear-signing/src/lib.rs | 8 +- crates/clear-signing/src/uniffi_compat/mod.rs | 10 ++- 8 files changed, 155 insertions(+), 103 deletions(-) diff --git a/android/clear-signing/src/main/kotlin/com/clearsigning/ClearSigningClient.kt b/android/clear-signing/src/main/kotlin/com/clearsigning/ClearSigningClient.kt index c838006..f7935ad 100644 --- a/android/clear-signing/src/main/kotlin/com/clearsigning/ClearSigningClient.kt +++ b/android/clear-signing/src/main/kotlin/com/clearsigning/ClearSigningClient.kt @@ -39,12 +39,15 @@ val DescriptorResolutionOutcome.descriptors: List DescriptorResolutionOutcome.NotFound -> emptyList() } +// `detail` is the wire-level field on each variant; `message` is the +// formatted `Throwable.message` string set by UniFFI. We expose `detail` +// so callers get the original error text without the variant prefix. val FormatFailure.failureMessage: String get() = when (this) { - is FormatFailure.InvalidInput -> message - is FormatFailure.InvalidDescriptor -> message - is FormatFailure.ResolutionFailed -> message - is FormatFailure.Internal -> message + is FormatFailure.InvalidInput -> detail + is FormatFailure.InvalidDescriptor -> detail + is FormatFailure.ResolutionFailed -> detail + is FormatFailure.Internal -> detail } val FormatFailure.retryable: Boolean diff --git a/bindings/react-native/example/src/components/DebugScreen.tsx b/bindings/react-native/example/src/components/DebugScreen.tsx index 0db9f3e..f0b9f9e 100644 --- a/bindings/react-native/example/src/components/DebugScreen.tsx +++ b/bindings/react-native/example/src/components/DebugScreen.tsx @@ -56,7 +56,7 @@ export function DebugScreen() { } catch (e: any) { if (cancelled) return; const inner = e?.inner; - const message = inner?.message ?? e?.message ?? String(e); + const message = inner?.detail ?? e?.message ?? String(e); setResult({kind: 'error', message, retryable: inner?.retryable}); } })(); diff --git a/bindings/react-native/src/generated/clear_signing.ts b/bindings/react-native/src/generated/clear_signing.ts index 4071d11..3b3fef0 100644 --- a/bindings/react-native/src/generated/clear_signing.ts +++ b/bindings/react-native/src/generated/clear_signing.ts @@ -800,7 +800,7 @@ export const DisplayEntry = (() => { type Nested__interface = { tag: DisplayEntry_Tags.Nested; - inner: Readonly<{label: string; intent: string; owner?: string; entries: Array}> + inner: Readonly<{label: string; intent: string; owner: string | undefined; entries: Array}> }; @@ -811,13 +811,23 @@ export const DisplayEntry = (() => { */ readonly [uniffiTypeNameSymbol] = "DisplayEntry"; readonly tag = DisplayEntry_Tags.Nested; - readonly inner: Readonly<{label: string; intent: string; owner?: string; entries: Array}>; - constructor(inner: { label: string, intent: string, owner?: string, entries: Array }) { + readonly inner: Readonly<{label: string; intent: string; owner: string | undefined; entries: Array}>; + constructor(inner: { label: string, intent: string, + /** + * Owner string for the inner call (the inner descriptor's `metadata.owner`), + * when a matching descriptor was found. `None` for raw/fallback frames where + * no inner descriptor matched. + */owner: string | undefined, entries: Array }) { super("DisplayEntry", "Nested"); this.inner = Object.freeze(inner); } - static new(inner: { label: string, intent: string, owner?: string, entries: Array }): Nested_ { + static new(inner: { label: string, intent: string, + /** + * Owner string for the inner call (the inner descriptor's `metadata.owner`), + * when a matching descriptor was found. `None` for raw/fallback frames where + * no inner descriptor matched. + */owner: string | undefined, entries: Array }): Nested_ { return new Nested_(inner); } @@ -982,7 +992,7 @@ export const FormatFailure = (() => { type InvalidInput__interface = { tag: FormatFailure_Tags.InvalidInput; - inner: Readonly<{message: string; retryable: boolean}> + inner: Readonly<{detail: string; retryable: boolean}> }; @@ -993,13 +1003,13 @@ export const FormatFailure = (() => { */ readonly [uniffiTypeNameSymbol] = "FormatFailure"; readonly tag = FormatFailure_Tags.InvalidInput; - readonly inner: Readonly<{message: string; retryable: boolean}>; - constructor(inner: { message: string, retryable: boolean }) { + readonly inner: Readonly<{detail: string; retryable: boolean}>; + constructor(inner: { detail: string, retryable: boolean }) { super("FormatFailure", "InvalidInput"); this.inner = Object.freeze(inner); } - static new(inner: { message: string, retryable: boolean }): InvalidInput_ { + static new(inner: { detail: string, retryable: boolean }): InvalidInput_ { return new InvalidInput_(inner); } @@ -1013,7 +1023,7 @@ export const FormatFailure = (() => { return InvalidInput_.instanceOf(obj); } - static getInner(obj: InvalidInput_): Readonly<{message: string; retryable: boolean}> { + static getInner(obj: InvalidInput_): Readonly<{detail: string; retryable: boolean}> { return obj.inner; } @@ -1022,7 +1032,7 @@ export const FormatFailure = (() => { type InvalidDescriptor__interface = { tag: FormatFailure_Tags.InvalidDescriptor; - inner: Readonly<{message: string; retryable: boolean}> + inner: Readonly<{detail: string; retryable: boolean}> }; @@ -1033,13 +1043,13 @@ export const FormatFailure = (() => { */ readonly [uniffiTypeNameSymbol] = "FormatFailure"; readonly tag = FormatFailure_Tags.InvalidDescriptor; - readonly inner: Readonly<{message: string; retryable: boolean}>; - constructor(inner: { message: string, retryable: boolean }) { + readonly inner: Readonly<{detail: string; retryable: boolean}>; + constructor(inner: { detail: string, retryable: boolean }) { super("FormatFailure", "InvalidDescriptor"); this.inner = Object.freeze(inner); } - static new(inner: { message: string, retryable: boolean }): InvalidDescriptor_ { + static new(inner: { detail: string, retryable: boolean }): InvalidDescriptor_ { return new InvalidDescriptor_(inner); } @@ -1053,7 +1063,7 @@ export const FormatFailure = (() => { return InvalidDescriptor_.instanceOf(obj); } - static getInner(obj: InvalidDescriptor_): Readonly<{message: string; retryable: boolean}> { + static getInner(obj: InvalidDescriptor_): Readonly<{detail: string; retryable: boolean}> { return obj.inner; } @@ -1062,7 +1072,7 @@ export const FormatFailure = (() => { type ResolutionFailed__interface = { tag: FormatFailure_Tags.ResolutionFailed; - inner: Readonly<{message: string; retryable: boolean}> + inner: Readonly<{detail: string; retryable: boolean}> }; @@ -1073,13 +1083,13 @@ export const FormatFailure = (() => { */ readonly [uniffiTypeNameSymbol] = "FormatFailure"; readonly tag = FormatFailure_Tags.ResolutionFailed; - readonly inner: Readonly<{message: string; retryable: boolean}>; - constructor(inner: { message: string, retryable: boolean }) { + readonly inner: Readonly<{detail: string; retryable: boolean}>; + constructor(inner: { detail: string, retryable: boolean }) { super("FormatFailure", "ResolutionFailed"); this.inner = Object.freeze(inner); } - static new(inner: { message: string, retryable: boolean }): ResolutionFailed_ { + static new(inner: { detail: string, retryable: boolean }): ResolutionFailed_ { return new ResolutionFailed_(inner); } @@ -1093,7 +1103,7 @@ export const FormatFailure = (() => { return ResolutionFailed_.instanceOf(obj); } - static getInner(obj: ResolutionFailed_): Readonly<{message: string; retryable: boolean}> { + static getInner(obj: ResolutionFailed_): Readonly<{detail: string; retryable: boolean}> { return obj.inner; } @@ -1102,7 +1112,7 @@ export const FormatFailure = (() => { type Internal__interface = { tag: FormatFailure_Tags.Internal; - inner: Readonly<{message: string; retryable: boolean}> + inner: Readonly<{detail: string; retryable: boolean}> }; @@ -1113,13 +1123,13 @@ export const FormatFailure = (() => { */ readonly [uniffiTypeNameSymbol] = "FormatFailure"; readonly tag = FormatFailure_Tags.Internal; - readonly inner: Readonly<{message: string; retryable: boolean}>; - constructor(inner: { message: string, retryable: boolean }) { + readonly inner: Readonly<{detail: string; retryable: boolean}>; + constructor(inner: { detail: string, retryable: boolean }) { super("FormatFailure", "Internal"); this.inner = Object.freeze(inner); } - static new(inner: { message: string, retryable: boolean }): Internal_ { + static new(inner: { detail: string, retryable: boolean }): Internal_ { return new Internal_(inner); } @@ -1133,7 +1143,7 @@ export const FormatFailure = (() => { return Internal_.instanceOf(obj); } - static getInner(obj: Internal_): Readonly<{message: string; retryable: boolean}> { + static getInner(obj: Internal_): Readonly<{detail: string; retryable: boolean}> { return obj.inner; } @@ -1166,10 +1176,10 @@ const FfiConverterTypeFormatFailure = (() => { class FFIConverter extends AbstractFfiConverterByteArray { read(from: RustBuffer): TypeName { switch (ordinalConverter.read(from)) { - case 1: return new FormatFailure.InvalidInput({message: FfiConverterString.read(from), retryable: FfiConverterBool.read(from) }); - case 2: return new FormatFailure.InvalidDescriptor({message: FfiConverterString.read(from), retryable: FfiConverterBool.read(from) }); - case 3: return new FormatFailure.ResolutionFailed({message: FfiConverterString.read(from), retryable: FfiConverterBool.read(from) }); - case 4: return new FormatFailure.Internal({message: FfiConverterString.read(from), retryable: FfiConverterBool.read(from) }); + case 1: return new FormatFailure.InvalidInput({detail: FfiConverterString.read(from), retryable: FfiConverterBool.read(from) }); + case 2: return new FormatFailure.InvalidDescriptor({detail: FfiConverterString.read(from), retryable: FfiConverterBool.read(from) }); + case 3: return new FormatFailure.ResolutionFailed({detail: FfiConverterString.read(from), retryable: FfiConverterBool.read(from) }); + case 4: return new FormatFailure.Internal({detail: FfiConverterString.read(from), retryable: FfiConverterBool.read(from) }); default: throw new UniffiInternalError.UnexpectedEnumCase(); } } @@ -1178,28 +1188,28 @@ const FfiConverterTypeFormatFailure = (() => { case FormatFailure_Tags.InvalidInput: { ordinalConverter.write(1, into); const inner = value.inner; - FfiConverterString.write(inner.message, into); + FfiConverterString.write(inner.detail, into); FfiConverterBool.write(inner.retryable, into); return; } case FormatFailure_Tags.InvalidDescriptor: { ordinalConverter.write(2, into); const inner = value.inner; - FfiConverterString.write(inner.message, into); + FfiConverterString.write(inner.detail, into); FfiConverterBool.write(inner.retryable, into); return; } case FormatFailure_Tags.ResolutionFailed: { ordinalConverter.write(3, into); const inner = value.inner; - FfiConverterString.write(inner.message, into); + FfiConverterString.write(inner.detail, into); FfiConverterBool.write(inner.retryable, into); return; } case FormatFailure_Tags.Internal: { ordinalConverter.write(4, into); const inner = value.inner; - FfiConverterString.write(inner.message, into); + FfiConverterString.write(inner.detail, into); FfiConverterBool.write(inner.retryable, into); return; } @@ -1213,28 +1223,28 @@ const FfiConverterTypeFormatFailure = (() => { case FormatFailure_Tags.InvalidInput: { const inner = value.inner; let size = ordinalConverter.allocationSize(1); - size += FfiConverterString.allocationSize(inner.message); + size += FfiConverterString.allocationSize(inner.detail); size += FfiConverterBool.allocationSize(inner.retryable); return size; } case FormatFailure_Tags.InvalidDescriptor: { const inner = value.inner; let size = ordinalConverter.allocationSize(2); - size += FfiConverterString.allocationSize(inner.message); + size += FfiConverterString.allocationSize(inner.detail); size += FfiConverterBool.allocationSize(inner.retryable); return size; } case FormatFailure_Tags.ResolutionFailed: { const inner = value.inner; let size = ordinalConverter.allocationSize(3); - size += FfiConverterString.allocationSize(inner.message); + size += FfiConverterString.allocationSize(inner.detail); size += FfiConverterBool.allocationSize(inner.retryable); return size; } case FormatFailure_Tags.Internal: { const inner = value.inner; let size = ordinalConverter.allocationSize(4); - size += FfiConverterString.allocationSize(inner.message); + size += FfiConverterString.allocationSize(inner.detail); size += FfiConverterBool.allocationSize(inner.retryable); return size; } @@ -1940,4 +1950,4 @@ export default Object.freeze({ FfiConverterTypeTokenMetaFfi, FfiConverterTypeTransactionInput, } -}); +}); \ No newline at end of file diff --git a/bindings/swift/ClearSigningClient.swift b/bindings/swift/ClearSigningClient.swift index 6b74deb..6ad4125 100644 --- a/bindings/swift/ClearSigningClient.swift +++ b/bindings/swift/ClearSigningClient.swift @@ -44,13 +44,17 @@ public extension DescriptorResolutionOutcome { } public extension FormatFailure { + // Field is named `detail` in the UniFFI cases (renamed from `message` + // to avoid a `Throwable.message` collision in the generated Kotlin + // bindings). Kept as `.message` here for source compatibility with + // existing Swift callers. var message: String { switch self { - case .InvalidInput(let message, _), - .InvalidDescriptor(let message, _), - .ResolutionFailed(let message, _), - .Internal(let message, _): - return message + case .InvalidInput(let detail, _), + .InvalidDescriptor(let detail, _), + .ResolutionFailed(let detail, _), + .Internal(let detail, _): + return detail } } diff --git a/bindings/swift/clear_signing.swift b/bindings/swift/clear_signing.swift index a14adba..d7c6c54 100644 --- a/bindings/swift/clear_signing.swift +++ b/bindings/swift/clear_signing.swift @@ -13,8 +13,8 @@ import clearSigningFFI fileprivate extension RustBuffer { // Allocate a new buffer, copying the contents of a `UInt8` array. - init(byteArray: [UInt8]) { - let rbuf = byteArray.withUnsafeBufferPointer { ptr in + init(bytes: [UInt8]) { + let rbuf = bytes.withUnsafeBufferPointer { ptr in RustBuffer.from(ptr) } self.init(capacity: rbuf.capacity, len: rbuf.len, data: rbuf.data) @@ -209,7 +209,7 @@ extension FfiConverterRustBuffer { public static func lower(_ value: SwiftType) -> RustBuffer { var writer = createWriter() write(value, into: &writer) - return RustBuffer(byteArray: writer) + return RustBuffer(bytes: writer) } } // An error type for FFI errors. These errors occur at the UniFFI level, not @@ -1398,7 +1398,7 @@ public enum DisplayEntry: Equatable, Hashable { ) case group(label: String, iteration: GroupIteration, items: [DisplayItem] ) - case nested(label: String, intent: String, + case nested(label: String, intent: String, /** * Owner string for the inner call (the inner descriptor's `metadata.owner`), * when a matching descriptor was found. `None` for raw/fallback frames where @@ -1568,13 +1568,13 @@ public enum FormatFailure: Swift.Error, Equatable, Hashable, Foundation.Localize - case InvalidInput(message: String, retryable: Bool + case InvalidInput(detail: String, retryable: Bool ) - case InvalidDescriptor(message: String, retryable: Bool + case InvalidDescriptor(detail: String, retryable: Bool ) - case ResolutionFailed(message: String, retryable: Bool + case ResolutionFailed(detail: String, retryable: Bool ) - case Internal(message: String, retryable: Bool + case Internal(detail: String, retryable: Bool ) @@ -1606,19 +1606,19 @@ public struct FfiConverterTypeFormatFailure: FfiConverterRustBuffer { case 1: return .InvalidInput( - message: try FfiConverterString.read(from: &buf), + detail: try FfiConverterString.read(from: &buf), retryable: try FfiConverterBool.read(from: &buf) ) case 2: return .InvalidDescriptor( - message: try FfiConverterString.read(from: &buf), + detail: try FfiConverterString.read(from: &buf), retryable: try FfiConverterBool.read(from: &buf) ) case 3: return .ResolutionFailed( - message: try FfiConverterString.read(from: &buf), + detail: try FfiConverterString.read(from: &buf), retryable: try FfiConverterBool.read(from: &buf) ) case 4: return .Internal( - message: try FfiConverterString.read(from: &buf), + detail: try FfiConverterString.read(from: &buf), retryable: try FfiConverterBool.read(from: &buf) ) @@ -1633,27 +1633,27 @@ public struct FfiConverterTypeFormatFailure: FfiConverterRustBuffer { - case let .InvalidInput(message,retryable): + case let .InvalidInput(detail,retryable): writeInt(&buf, Int32(1)) - FfiConverterString.write(message, into: &buf) + FfiConverterString.write(detail, into: &buf) FfiConverterBool.write(retryable, into: &buf) - case let .InvalidDescriptor(message,retryable): + case let .InvalidDescriptor(detail,retryable): writeInt(&buf, Int32(2)) - FfiConverterString.write(message, into: &buf) + FfiConverterString.write(detail, into: &buf) FfiConverterBool.write(retryable, into: &buf) - case let .ResolutionFailed(message,retryable): + case let .ResolutionFailed(detail,retryable): writeInt(&buf, Int32(3)) - FfiConverterString.write(message, into: &buf) + FfiConverterString.write(detail, into: &buf) FfiConverterBool.write(retryable, into: &buf) - case let .Internal(message,retryable): + case let .Internal(detail,retryable): writeInt(&buf, Int32(4)) - FfiConverterString.write(message, into: &buf) + FfiConverterString.write(detail, into: &buf) FfiConverterBool.write(retryable, into: &buf) } @@ -2272,4 +2272,4 @@ public func uniffiEnsureClearSigningInitialized() { } } -// swiftlint:enable all +// swiftlint:enable all \ No newline at end of file diff --git a/crates/clear-signing/src/error.rs b/crates/clear-signing/src/error.rs index 1bdd2c2..1383349 100644 --- a/crates/clear-signing/src/error.rs +++ b/crates/clear-signing/src/error.rs @@ -2,20 +2,23 @@ use thiserror::Error; +// Field renamed `message` -> `detail` because UniFFI's Kotlin generator +// emits each variant as a class extending `kotlin.Exception`, which +// already exposes `Throwable.message`; the collision broke Android CI. #[cfg_attr(feature = "uniffi", derive(uniffi::Enum))] #[derive(Debug, Clone, PartialEq, Eq, Error, serde::Serialize)] pub enum FormatFailure { - #[error("invalid input: {message}")] - InvalidInput { message: String, retryable: bool }, + #[error("invalid input: {detail}")] + InvalidInput { detail: String, retryable: bool }, - #[error("invalid descriptor: {message}")] - InvalidDescriptor { message: String, retryable: bool }, + #[error("invalid descriptor: {detail}")] + InvalidDescriptor { detail: String, retryable: bool }, - #[error("resolution failed: {message}")] - ResolutionFailed { message: String, retryable: bool }, + #[error("resolution failed: {detail}")] + ResolutionFailed { detail: String, retryable: bool }, - #[error("internal error: {message}")] - Internal { message: String, retryable: bool }, + #[error("internal error: {detail}")] + Internal { detail: String, retryable: bool }, } /// Unified error type for the ERC-7730 library. @@ -79,20 +82,20 @@ impl From for FormatFailure { fn from(value: Error) -> Self { match value { Error::Decode(err) => Self::InvalidInput { - message: err.to_string(), + detail: err.to_string(), retryable: false, }, Error::Descriptor(message) => Self::InvalidDescriptor { - message, + detail: message, retryable: false, }, Error::Resolve(err) => err.into(), Error::TokenRegistry(message) => Self::ResolutionFailed { - message: format!("token registry error: {message}"), + detail: format!("token registry error: {message}"), retryable: true, }, Error::Render(message) => Self::InvalidDescriptor { - message, + detail: message, retryable: false, }, } @@ -103,23 +106,23 @@ impl From for FormatFailure { fn from(value: ResolveError) -> Self { match value { ResolveError::NotFound { chain_id, address } => Self::InvalidDescriptor { - message: format!("descriptor not found for chain_id={chain_id}, address={address}"), + detail: format!("descriptor not found for chain_id={chain_id}, address={address}"), retryable: false, }, ResolveError::RegistryIndexMissing { url } => Self::ResolutionFailed { - message: format!("registry index missing: {url}"), + detail: format!("registry index missing: {url}"), retryable: true, }, ResolveError::RegistryDescriptorMissing { url } => Self::ResolutionFailed { - message: format!("registry descriptor missing: {url}"), + detail: format!("registry descriptor missing: {url}"), retryable: true, }, ResolveError::RegistryIo(message) => Self::ResolutionFailed { - message: format!("registry io error: {message}"), + detail: format!("registry io error: {message}"), retryable: true, }, ResolveError::Parse(message) => Self::ResolutionFailed { - message: format!("parse error: {message}"), + detail: format!("parse error: {message}"), retryable: false, }, } @@ -137,7 +140,10 @@ mod tests { actual: 2, })); match f { - FormatFailure::InvalidInput { message, retryable } => { + FormatFailure::InvalidInput { + detail: message, + retryable, + } => { assert!(message.contains("calldata too short")); assert!(!retryable); } @@ -149,7 +155,10 @@ mod tests { fn format_failure_from_error_descriptor() { let f = FormatFailure::from(Error::Descriptor("bad".into())); match f { - FormatFailure::InvalidDescriptor { message, retryable } => { + FormatFailure::InvalidDescriptor { + detail: message, + retryable, + } => { assert_eq!(message, "bad"); assert!(!retryable); } @@ -164,7 +173,10 @@ mod tests { address: "0xabc".into(), })); match f { - FormatFailure::InvalidDescriptor { message, retryable } => { + FormatFailure::InvalidDescriptor { + detail: message, + retryable, + } => { assert!(message.contains("chain_id=1")); assert!(message.contains("0xabc")); assert!(!retryable); @@ -177,7 +189,10 @@ mod tests { fn format_failure_from_error_token_registry() { let f = FormatFailure::from(Error::TokenRegistry("rate limit".into())); match f { - FormatFailure::ResolutionFailed { message, retryable } => { + FormatFailure::ResolutionFailed { + detail: message, + retryable, + } => { assert!(message.starts_with("token registry error:")); assert!(message.contains("rate limit")); assert!(retryable); @@ -190,7 +205,10 @@ mod tests { fn format_failure_from_error_render() { let f = FormatFailure::from(Error::Render("nope".into())); match f { - FormatFailure::InvalidDescriptor { message, retryable } => { + FormatFailure::InvalidDescriptor { + detail: message, + retryable, + } => { assert_eq!(message, "nope"); assert!(!retryable); } @@ -205,7 +223,10 @@ mod tests { address: "0xdead".into(), }); match f { - FormatFailure::InvalidDescriptor { message, retryable } => { + FormatFailure::InvalidDescriptor { + detail: message, + retryable, + } => { assert!(message.contains("chain_id=137")); assert!(message.contains("0xdead")); assert!(!retryable); @@ -220,7 +241,10 @@ mod tests { url: "https://example/idx".into(), }); match f { - FormatFailure::ResolutionFailed { message, retryable } => { + FormatFailure::ResolutionFailed { + detail: message, + retryable, + } => { assert!(message.contains("registry index missing")); assert!(message.contains("https://example/idx")); assert!(retryable); @@ -235,7 +259,10 @@ mod tests { url: "https://example/d.json".into(), }); match f { - FormatFailure::ResolutionFailed { message, retryable } => { + FormatFailure::ResolutionFailed { + detail: message, + retryable, + } => { assert!(message.contains("registry descriptor missing")); assert!(retryable); } @@ -247,7 +274,10 @@ mod tests { fn format_failure_from_resolve_io() { let f = FormatFailure::from(ResolveError::RegistryIo("timeout".into())); match f { - FormatFailure::ResolutionFailed { message, retryable } => { + FormatFailure::ResolutionFailed { + detail: message, + retryable, + } => { assert!(message.contains("registry io error")); assert!(message.contains("timeout")); assert!(retryable); @@ -260,7 +290,10 @@ mod tests { fn format_failure_from_resolve_parse() { let f = FormatFailure::from(ResolveError::Parse("bad json".into())); match f { - FormatFailure::ResolutionFailed { message, retryable } => { + FormatFailure::ResolutionFailed { + detail: message, + retryable, + } => { assert!(message.contains("parse error")); assert!(message.contains("bad json")); assert!(!retryable); diff --git a/crates/clear-signing/src/lib.rs b/crates/clear-signing/src/lib.rs index e5e66de..5d7e176 100644 --- a/crates/clear-signing/src/lib.rs +++ b/crates/clear-signing/src/lib.rs @@ -73,7 +73,7 @@ pub async fn format_calldata( ) -> Result { if tx.calldata.len() < 4 { return Err(FormatFailure::InvalidInput { - message: error::DecodeError::CalldataTooShort { + detail: error::DecodeError::CalldataTooShort { expected: 4, actual: tx.calldata.len(), } @@ -107,7 +107,7 @@ pub async fn format_calldata( )); } return Err(FormatFailure::InvalidDescriptor { - message: format!( + detail: format!( "no outer descriptor matches chain_id={} address={}", tx.chain_id, match_address ), @@ -139,7 +139,7 @@ pub async fn format_calldata( // Decode calldata using the parsed signature let mut decoded = decoder::decode_calldata(&sig, tx.calldata).map_err(|err| FormatFailure::InvalidInput { - message: err.to_string(), + detail: err.to_string(), retryable: false, })?; @@ -342,7 +342,7 @@ pub async fn format_typed_data( )); } return Err(FormatFailure::InvalidDescriptor { - message, + detail: message, retryable: false, }); } diff --git a/crates/clear-signing/src/uniffi_compat/mod.rs b/crates/clear-signing/src/uniffi_compat/mod.rs index 58e8546..21b2294 100644 --- a/crates/clear-signing/src/uniffi_compat/mod.rs +++ b/crates/clear-signing/src/uniffi_compat/mod.rs @@ -26,7 +26,7 @@ async fn get_registry_source() -> Result<&'static GitHubRegistrySource, FormatFa GitHubRegistrySource::from_registry(DEFAULT_REGISTRY_URL) .await .map_err(|e| FormatFailure::ResolutionFailed { - message: format!("failed to initialize registry: {e}"), + detail: format!("failed to initialize registry: {e}"), retryable: true, }) }) @@ -488,14 +488,14 @@ fn resolved_descriptor_json_outcome( fn invalid_input(message: String) -> FormatFailure { FormatFailure::InvalidInput { - message, + detail: message, retryable: false, } } fn invalid_descriptor(message: String) -> FormatFailure { FormatFailure::InvalidDescriptor { - message, + detail: message, retryable: false, } } @@ -1157,7 +1157,9 @@ mod tests { .expect_err("duplicate selectors must surface the real error"); match err { - FormatFailure::InvalidDescriptor { message, .. } => { + FormatFailure::InvalidDescriptor { + detail: message, .. + } => { assert!( message.contains("duplicate selectors"), "expected duplicate-selector message, got: {message}" From 21e6776fc78b53cdf2ee19abadf68e9772464f04 Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Thu, 28 May 2026 09:25:48 +0200 Subject: [PATCH 07/10] Drop duplicate JNA dependency from Android POM script MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pom.withXml block appended a second top-level element to the release POM. Maven only allows one, so publishReleasePublicationToMavenLocal failed validation and the Android CI job stayed red even after the FormatFailure Kotlin fix unblocked the compile step. `from components.release` already emits JNA with aar from `api 'net.java.dev.jna:jna:5.17.0@aar'`. The manual entry was also adding android, but JNA does not publish an android-classified artifact — the AGP-emitted entry is the correct one. Verified locally that generatePomFileForReleasePublication now produces a single, valid block. Co-Authored-By: Claude Opus 4.7 (1M context) --- android/clear-signing/build.gradle | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/android/clear-signing/build.gradle b/android/clear-signing/build.gradle index fe01610..7e2cac9 100644 --- a/android/clear-signing/build.gradle +++ b/android/clear-signing/build.gradle @@ -52,16 +52,6 @@ afterEvaluate { groupId = 'com.github.llbartekll' artifactId = 'clear-signing' version = project.findProperty('version') ?: '0.0.0' - - pom.withXml { - def deps = asNode().appendNode('dependencies') - def jna = deps.appendNode('dependency') - jna.appendNode('groupId', 'net.java.dev.jna') - jna.appendNode('artifactId', 'jna') - jna.appendNode('version', '5.17.0') - jna.appendNode('classifier', 'android') - jna.appendNode('type', 'aar') - } } } } From 15a75de22687c97d4eb290f524005247a6ea2e25 Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Thu, 28 May 2026 09:38:30 +0200 Subject: [PATCH 08/10] Use clearSigningVersion property for Android publish `findProperty('version')` returned Gradle's default project version ("unspecified") rather than null, so the previous `?: '0.0.0'` fallback never triggered. The library was being published as `com.github.llbartekll:clear-signing:unspecified` while the consumer-smoke app looks up `clear-signing:0.0.0`, so its debugRuntimeClasspath resolution failed and the Android CI job stayed red after the POM duplication fix. Switching to `clearSigningVersion` (the property the consumer already reads from its gradle.properties) lets both ends agree on the default without a CLI override, and still allows the release workflow to pass an explicit version when needed. Co-Authored-By: Claude Opus 4.7 (1M context) --- android/clear-signing/build.gradle | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/android/clear-signing/build.gradle b/android/clear-signing/build.gradle index 7e2cac9..b0d8818 100644 --- a/android/clear-signing/build.gradle +++ b/android/clear-signing/build.gradle @@ -51,7 +51,10 @@ afterEvaluate { from components.release groupId = 'com.github.llbartekll' artifactId = 'clear-signing' - version = project.findProperty('version') ?: '0.0.0' + // Match the consumer-side property name. `findProperty('version')` + // returns Gradle's default project version ("unspecified"), not + // null, so the previous `?:` fallback never triggered. + version = project.findProperty('clearSigningVersion') ?: '0.0.0' } } } From 64b421e65014ddcb531f0eb4d96bf41634536fd4 Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Thu, 28 May 2026 11:37:46 +0200 Subject: [PATCH 09/10] Update integration docs for FormatFailure.detail rename Reflect the renamed wire-level field on FormatFailure variants (message -> detail) across the Kotlin, Swift, and React Native integration guides: - Variant signatures now show `detail` instead of `message`. - Kotlin example switched to `failure.failureMessage` (the extension returns the original detail; `Throwable.message` carries a formatted summary). - Swift example destructures the case's `detail` and notes the ClearSigningClient extension exposes `var message`/`var retryable` for source compatibility. - React Native example reads `e.inner.detail`. Adds a note to the Kotlin local-publish steps documenting the clearSigningVersion property (default 0.0.0, matches the consumer smoke app). Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/kotlin-integration.md | 20 ++++++++++++-------- docs/react-native-integration.md | 8 ++++---- docs/swift-integration.md | 18 ++++++++++-------- 3 files changed, 26 insertions(+), 20 deletions(-) diff --git a/docs/kotlin-integration.md b/docs/kotlin-integration.md index f153d0c..013f25d 100644 --- a/docs/kotlin-integration.md +++ b/docs/kotlin-integration.md @@ -77,6 +77,8 @@ cd android ./gradlew :clear-signing:assembleRelease :clear-signing:publishReleasePublicationToMavenLocal ``` +The publication is coordinate `com.github.llbartekll:clear-signing:0.0.0` by default. Pass `-PclearSigningVersion=X.Y.Z` to override the version; the consumer-smoke app reads the same property from its `gradle.properties`. + The repo also contains a smoke consumer at [android-consumer-smoke/app/src/main/java/com/clearsigning/smoke/Smoke.kt](../android-consumer-smoke/app/src/main/java/com/clearsigning/smoke/Smoke.kt) that references the client and provider types. ## Integration Flow @@ -301,10 +303,12 @@ Fields: Kotlin client methods can throw `FormatFailure`. Cases: -- `FormatFailure.InvalidInput(message, retryable)` -- `FormatFailure.InvalidDescriptor(message, retryable)` -- `FormatFailure.ResolutionFailed(message, retryable)` -- `FormatFailure.Internal(message, retryable)` +- `FormatFailure.InvalidInput(detail, retryable)` +- `FormatFailure.InvalidDescriptor(detail, retryable)` +- `FormatFailure.ResolutionFailed(detail, retryable)` +- `FormatFailure.Internal(detail, retryable)` + +Each variant carries `detail: String` (the underlying error text) and `retryable: Boolean`. The `failureMessage` extension on `FormatFailure` returns `detail` regardless of variant. `Throwable.message` is also set (it carries a formatted `detail=…, retryable=…` summary) — prefer `failureMessage` for user-facing surfaces. Example: @@ -314,10 +318,10 @@ try { // handle outcome } catch (failure: FormatFailure) { when (failure) { - is FormatFailure.InvalidInput -> showBlockingError(failure.message) - is FormatFailure.ResolutionFailed -> showResolutionError(failure.message, failure.retryable) - is FormatFailure.InvalidDescriptor -> showBlockingError(failure.message) - is FormatFailure.Internal -> showBlockingError(failure.message) + is FormatFailure.InvalidInput -> showBlockingError(failure.failureMessage) + is FormatFailure.ResolutionFailed -> showResolutionError(failure.failureMessage, failure.retryable) + is FormatFailure.InvalidDescriptor -> showBlockingError(failure.failureMessage) + is FormatFailure.Internal -> showBlockingError(failure.failureMessage) } } ``` diff --git a/docs/react-native-integration.md b/docs/react-native-integration.md index 6e400e1..421f25c 100644 --- a/docs/react-native-integration.md +++ b/docs/react-native-integration.md @@ -372,7 +372,7 @@ type TokenMetaFfi = { Async entrypoints reject with `FormatFailure`. Catch and discriminate by `tag`. -Variants (each `inner: { message: string, retryable: boolean }`): +Variants (each `inner: { detail: string, retryable: boolean }`): - `FormatFailure_Tags.InvalidInput` - `FormatFailure_Tags.InvalidDescriptor` - `FormatFailure_Tags.ResolutionFailed` @@ -386,11 +386,11 @@ try { // handle outcome } catch (e: any) { if (e?.tag === FormatFailure_Tags.ResolutionFailed) { - showResolutionError(e.inner.message, e.inner.retryable); + showResolutionError(e.inner.detail, e.inner.retryable); } else if (e?.tag === FormatFailure_Tags.InvalidInput) { - showBlockingError(e.inner.message); + showBlockingError(e.inner.detail); } else { - showBlockingError(e?.message ?? String(e)); + showBlockingError(e?.inner?.detail ?? e?.message ?? String(e)); } } ``` diff --git a/docs/swift-integration.md b/docs/swift-integration.md index 9f3f7f3..4ded0a9 100644 --- a/docs/swift-integration.md +++ b/docs/swift-integration.md @@ -292,10 +292,12 @@ Fields: Swift client methods throw `FormatFailure`. Cases: -- `InvalidInput(message:retryable:)` -- `InvalidDescriptor(message:retryable:)` -- `ResolutionFailed(message:retryable:)` -- `Internal(message:retryable:)` +- `InvalidInput(detail:retryable:)` +- `InvalidDescriptor(detail:retryable:)` +- `ResolutionFailed(detail:retryable:)` +- `Internal(detail:retryable:)` + +`ClearSigningClient` extends `FormatFailure` with `var message: String` and `var retryable: Bool` accessors that work across every variant. Example: @@ -305,10 +307,10 @@ do { // handle outcome } catch let failure as FormatFailure { switch failure { - case .InvalidInput(let message, _): - showBlockingError(message) - case .ResolutionFailed(let message, let retryable): - showResolutionError(message: message, retryable: retryable) + case .InvalidInput(let detail, _): + showBlockingError(detail) + case .ResolutionFailed(let detail, let retryable): + showResolutionError(message: detail, retryable: retryable) default: showBlockingError(failure.message) } From ad68915d0ed618b7425450d5e8e8b408ff137b6d Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Thu, 28 May 2026 11:39:16 +0200 Subject: [PATCH 10/10] Update wallet FormatFailure.Internal constructor for detail rename MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `coerceFailure` was still passing the `message:` argument label, which would break the Wallet iOS build after the FormatFailure variant field rename. Swift only flagged this once the renamed binding regenerated locally — the iOS app is not on CI. Co-Authored-By: Claude Opus 4.7 (1M context) --- wallet/Wallet/Services/ClearSigningService.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/wallet/Wallet/Services/ClearSigningService.swift b/wallet/Wallet/Services/ClearSigningService.swift index 77fa1c8..d8e39dd 100644 --- a/wallet/Wallet/Services/ClearSigningService.swift +++ b/wallet/Wallet/Services/ClearSigningService.swift @@ -215,7 +215,7 @@ struct ClearSigningService { if let failure = error as? FormatFailure { return failure } - return .Internal(message: error.localizedDescription, retryable: false) + return .Internal(detail: error.localizedDescription, retryable: false) } private static func describe(_ outcome: FormatOutcome) -> String {