diff --git a/android/clear-signing/build.gradle b/android/clear-signing/build.gradle index fe01610..b0d8818 100644 --- a/android/clear-signing/build.gradle +++ b/android/clear-signing/build.gradle @@ -51,17 +51,10 @@ afterEvaluate { from components.release 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') - } + // 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' } } } 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/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..c51ba00 --- /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().is_multiple_of(2) { + 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/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/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..668aba7 --- /dev/null +++ b/crates/clear-signing/src/resolver/standard_token.rs @@ -0,0 +1,589 @@ +//! Synthesize ERC-7730 descriptors on-the-fly for standard ERC-20 functions +//! when the wallet supplies token metadata. +//! +//! Edge cases handled here: +//! - `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. +//! +//! 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; + +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, SenderAddress, + 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, + /// Approve amount only — adds `threshold = uint256.max` + `message = "Unlimited"` + /// so the engine collapses infinite-approval calls. + TokenAmountUnlimited, +} + +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::TokenAmountUnlimited, + }, +]; + +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, + 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()), + label: spec.label.to_string(), + value: None, + format: Some(format), + params, + separator: None, + visible: VisibleRule::Always, + } +} + +/// 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: None, + 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, + } +} + +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()), + message: Some("Unlimited".to_string()), + ..empty_format_params() + } +} + +fn address_name_params(sender_path: &str) -> FormatParams { + FormatParams { + sender_address: Some(SenderAddress::Single(sender_path.to_string())), + ..empty_format_params() + } +} + +#[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")); + } + + /// Only `approve.amount` carries `threshold` + `message`. The engine + /// collapses the 2^256-1 "infinite approval" pattern to "Unlimited {ticker}". + #[test] + 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)", + ), + ( + [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!( + params.threshold.is_none(), + "{format_key} amount should have no threshold, got {:?}", + params.threshold + ); + assert!( + params.message.is_none(), + "{format_key} amount should have no message, got {:?}", + params.message + ); + } + } + + /// 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" + ); + } + } + + /// 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_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 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()); + 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 f4b008e..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, }) }) @@ -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)?; } @@ -481,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, } } @@ -1150,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}" 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/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/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..ef4ab88 --- /dev/null +++ b/crates/clear-signing/tests/standard_token.rs @@ -0,0 +1,828 @@ +//! 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 +} + +/// 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)); + out.extend_from_slice(&uint_word(amount)); + 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( + "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" + ); +} + +// --------------------------------------------------------------------------- +// 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_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 + // 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" + ); +} 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..6578e5e --- /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().is_multiple_of(2) { + 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 + ); + } + } +} 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) } diff --git a/wallet/Wallet.xcodeproj/project.pbxproj b/wallet/Wallet.xcodeproj/project.pbxproj index 714074b..826b577 100644 --- a/wallet/Wallet.xcodeproj/project.pbxproj +++ b/wallet/Wallet.xcodeproj/project.pbxproj @@ -48,6 +48,10 @@ 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 */; }; + E3000001000000000004 /* known-contracts.json in Resources */ = {isa = PBXBuildFile; fileRef = E3000001000000000012 /* known-contracts.json */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -80,6 +84,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 +141,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 +180,7 @@ F1000001000000000012 /* MetadataCache.swift */, F1000001000000000013 /* AlchemyClient.swift */, F1000001000000000014 /* ENSResolver.swift */, + E3000001000000000011 /* SeedContractStore.swift */, E2000001000000000012 /* tokens.json */, ); path = Services; @@ -292,6 +308,7 @@ files = ( A3000001000000000001 /* Assets.xcassets in Resources */, E2000001000000000002 /* tokens.json in Resources */, + E3000001000000000003 /* known-contracts.json in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -299,6 +316,7 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + E3000001000000000004 /* known-contracts.json in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -324,6 +342,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 +362,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/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 { 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..e4345c2 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,26 @@ 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 — 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? { diff --git a/wallet/WalletTests/WalletMetadataProviderTests.swift b/wallet/WalletTests/WalletMetadataProviderTests.swift index 4868c41..79a0ea6 100644 --- a/wallet/WalletTests/WalletMetadataProviderTests.swift +++ b/wallet/WalletTests/WalletMetadataProviderTests.swift @@ -246,6 +246,186 @@ 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" + ) + } + + /// 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)), + 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))")