diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000..90fe9b3f --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "src/chain_parsers/visualsign-ethereum/static/eip7730"] + path = src/chain_parsers/visualsign-ethereum/static/eip7730 + url = https://github.com/LedgerHQ/clear-signing-erc7730-registry diff --git a/src/Cargo.lock b/src/Cargo.lock index c774f570..081ef423 100644 --- a/src/Cargo.lock +++ b/src/Cargo.lock @@ -4543,7 +4543,7 @@ version = "0.1.0" source = "git+https://github.com/MystenLabs/sui?tag=mainnet-v1.52.2#7f45ba185ff0773331d256469c49aefb82542102" dependencies = [ "once_cell", - "phf", + "phf 0.11.3", "serde", ] @@ -5299,7 +5299,7 @@ dependencies = [ "serde", "serde_json", "sha2 0.10.9", - "visualsign", + "visualsign 0.1.0", "visualsign-ethereum", "visualsign-solana", "visualsign-sui", @@ -5319,7 +5319,7 @@ dependencies = [ "serde", "serde_json", "sha2 0.10.9", - "visualsign", + "visualsign 0.1.0", "visualsign-solana", "visualsign-unspecified", ] @@ -5464,8 +5464,29 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" dependencies = [ - "phf_macros", - "phf_shared", + "phf_macros 0.11.3", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7" +dependencies = [ + "phf_macros 0.12.1", + "phf_shared 0.12.1", + "serde", +] + +[[package]] +name = "phf_codegen" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efbdcb6f01d193b17f0b9c3360fa7e0e620991b193ff08702f78b3ce365d7e61" +dependencies = [ + "phf_generator 0.12.1", + "phf_shared 0.12.1", ] [[package]] @@ -5474,18 +5495,41 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" dependencies = [ - "phf_shared", + "phf_shared 0.11.3", "rand 0.8.5", ] +[[package]] +name = "phf_generator" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cbb1126afed61dd6368748dae63b1ee7dc480191c6262a3b4ff1e29d86a6c5b" +dependencies = [ + "fastrand", + "phf_shared 0.12.1", +] + [[package]] name = "phf_macros" version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" dependencies = [ - "phf_generator", - "phf_shared", + "phf_generator 0.11.3", + "phf_shared 0.11.3", + "proc-macro2", + "quote", + "syn 2.0.104", +] + +[[package]] +name = "phf_macros" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d713258393a82f091ead52047ca779d37e5766226d009de21696c4e667044368" +dependencies = [ + "phf_generator 0.12.1", + "phf_shared 0.12.1", "proc-macro2", "quote", "syn 2.0.104", @@ -5500,6 +5544,15 @@ dependencies = [ "siphasher 1.0.1", ] +[[package]] +name = "phf_shared" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981" +dependencies = [ + "siphasher 1.0.1", +] + [[package]] name = "pin-project" version = "1.1.10" @@ -10777,6 +10830,34 @@ dependencies = [ "thiserror 2.0.12", ] +[[package]] +name = "visualsign" +version = "0.1.0" +source = "git+ssh://git@github.com/anchorageoss/visualsign-parser.git?branch=main#db12a545d43535a491ac06db37e009ca6344300b" +dependencies = [ + "base64 0.22.1", + "pretty_assertions", + "serde", + "serde_json", + "thiserror 2.0.12", +] + +[[package]] +name = "visualsign-erc7730-adapter" +version = "0.1.0" +source = "git+ssh://git@github.com/anchorageoss/visualsign-erc-7730-adapter.git?branch=main#7d375c2390f653bffa4df5eec410a2954b98e4f6" +dependencies = [ + "alloy-consensus", + "alloy-primitives", + "alloy-rlp", + "anyhow", + "clap", + "hex", + "serde", + "serde_json", + "visualsign 0.1.0 (git+ssh://git@github.com/anchorageoss/visualsign-parser.git?branch=main)", +] + [[package]] name = "visualsign-ethereum" version = "0.1.0" @@ -10788,10 +10869,14 @@ dependencies = [ "base64 0.22.1", "hex", "log", + "phf 0.12.1", + "phf_codegen", "serde", "serde_json", + "tempfile", "thiserror 2.0.12", - "visualsign", + "visualsign 0.1.0", + "visualsign-erc7730-adapter", ] [[package]] @@ -10810,7 +10895,7 @@ dependencies = [ "spl-associated-token-account 6.0.0", "spl-stake-pool", "spl-token 7.0.0", - "visualsign", + "visualsign 0.1.0", ] [[package]] @@ -10826,14 +10911,14 @@ dependencies = [ "sui-json", "sui-json-rpc-types", "sui-types", - "visualsign", + "visualsign 0.1.0", ] [[package]] name = "visualsign-unspecified" version = "0.1.0" dependencies = [ - "visualsign", + "visualsign 0.1.0", ] [[package]] diff --git a/src/Makefile b/src/Makefile index c7f29f74..2734096f 100644 --- a/src/Makefile +++ b/src/Makefile @@ -53,3 +53,6 @@ parser_enclave: .PHONY: parser_host parser_host: cargo run --bin parser_host -- --host-ip $(PARSER_HOST) --host-port $(PARSER_PORT) --metrics --metrics-port $(PARSER_METRICS_PORT) --usock $(PARSER_OUTER_SOCKET_PATH) + +update_submodules: + git submodule update --init --recursive diff --git a/src/chain_parsers/visualsign-ethereum/Cargo.toml b/src/chain_parsers/visualsign-ethereum/Cargo.toml index 5a9e57b9..60b5b480 100644 --- a/src/chain_parsers/visualsign-ethereum/Cargo.toml +++ b/src/chain_parsers/visualsign-ethereum/Cargo.toml @@ -15,3 +15,15 @@ serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" thiserror = "2.0.12" visualsign = { workspace = true } +visualsign-erc7730-adapter = { git = "ssh://git@github.com/anchorageoss/visualsign-erc-7730-adapter.git", branch = "main" } +phf = { version = "0.12.1", features = ["macros"] } + +[build-dependencies] +phf_codegen = "0.12.1" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +visualsign-erc7730-adapter = { git = "ssh://git@github.com/anchorageoss/visualsign-erc-7730-adapter.git", branch = "main" } +alloy-primitives = "1.0.20" + +[dev-dependencies] +tempfile = "3" diff --git a/src/chain_parsers/visualsign-ethereum/build.rs b/src/chain_parsers/visualsign-ethereum/build.rs new file mode 100644 index 00000000..f22023d9 --- /dev/null +++ b/src/chain_parsers/visualsign-ethereum/build.rs @@ -0,0 +1,25 @@ +use std::{env, fs, io::Write, path::PathBuf}; + +mod build_gen { + include!(concat!(env!("CARGO_MANIFEST_DIR"), "/build_src/gen.rs")); +} + +fn main() { + // Directory containing the JSON registry specs + let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap()); + let registry_dir = manifest_dir.join("static/eip7730/registry"); + println!("cargo:rerun-if-changed={}", registry_dir.display()); + // Also rerun if the generator itself changes + let gen_src = manifest_dir.join("build_src/gen.rs"); + println!("cargo:rerun-if-changed={}", gen_src.display()); + + // Collect entries and generate Rust code + let entries = build_gen::collect_entries(®istry_dir); + let generated = build_gen::generate_registry_rs(&entries); + + // Write to OUT_DIR + let out_dir = PathBuf::from(env::var("OUT_DIR").unwrap()); + let dest_path = out_dir.join("erc7730_registry_gen.rs"); + let mut file = fs::File::create(&dest_path).unwrap(); + writeln!(file, "{generated}").unwrap(); +} diff --git a/src/chain_parsers/visualsign-ethereum/build_src/gen.rs b/src/chain_parsers/visualsign-ethereum/build_src/gen.rs new file mode 100644 index 00000000..91777a43 --- /dev/null +++ b/src/chain_parsers/visualsign-ethereum/build_src/gen.rs @@ -0,0 +1,291 @@ +use alloy_primitives::keccak256; +use serde::Deserialize; +use std::collections::HashMap; +use std::path::Path; + +#[derive(Debug, Clone)] +pub struct RegistryEntry { + pub selector: String, + pub format_id: Option, + pub source_file: String, + pub fields: Vec, +} + +#[derive(Debug, Clone)] +pub struct SimpleField { + pub label: String, + pub path: String, + pub format: Option, + pub params: Option>, +} + +/// Recursively visit a directory and invoke callback with (path, contents) for each UTF-8 file. +fn visit_dir(dir: &std::path::Path, cb: &mut F) { + if let Ok(read_dir) = std::fs::read_dir(dir) { + for entry in read_dir.flatten() { + let path = entry.path(); + if path.is_dir() { + visit_dir(&path, cb); + } else if let Ok(bytes) = std::fs::read(&path) { + if let Ok(s) = String::from_utf8(bytes) { + cb(&path, &s); + } + } + } + } +} + +fn escape(s: &str) -> String { + s.replace('"', "\\\"") +} + +/// Normalize a format key into a 4-byte calldata selector (0xXXXXXXXX) +/// Accepted inputs: +/// - Already a selector: "0x0123abcd" (case-insensitive) +/// - Function signature: "transfer(address,uint256)" -> keccak256 and take first 4 bytes +/// Any other form (e.g., EIP-712 primary type like "mint") returns None. +pub fn normalize_selector(key: &str) -> Option { + let k = key.trim(); + // Already a 4-byte selector + if k.len() == 10 && k.starts_with("0x") && k.chars().skip(2).all(|c| c.is_ascii_hexdigit()) { + return Some(k.to_ascii_lowercase()); + } + // Function signature form: name(args) + if let (Some(_l), Some(r)) = (k.find('('), k.rfind(')')) { + if r > 0 { + let sig = &k[..=r]; // include ')' + // Remove any internal whitespace to be safe + let cleaned: String = sig.chars().filter(|c| !c.is_whitespace()).collect(); + let digest = keccak256(cleaned.as_bytes()); + let selector = &digest.as_slice()[..4]; + return Some(format!( + "0x{:02x}{:02x}{:02x}{:02x}", + selector[0], selector[1], selector[2], selector[3] + )); + } + } + None +} + +/// Read the ERC-7730 registry under the given directory and collect display entries +/// for calldata (selector-keyed) formats. Primary parsing uses the adapter; if that +/// fails, a lightweight fallback extracts display.formats[*].fields labels/paths. +pub fn collect_entries(registry_dir: &Path) -> Vec { + let mut entries: Vec = Vec::new(); + + visit_dir(registry_dir, &mut |path, contents| { + if let Some(ext) = path.extension().and_then(|e| e.to_str()) { + if ext != "json" { + return; + } + } else { + return; + } + + // Primary parse via adapter + let mut parsed_any = false; + if let Ok(spec) = visualsign_erc7730_adapter::types::ERC7730::from_json(contents) { + if let Some(display) = spec.display { + for (key, format) in display.formats.into_iter() { + if let Some(selector) = normalize_selector(&key) { + let fields: Vec<_> = format + .fields + .into_iter() + .map(|f| SimpleField { + label: f.label, + path: f.path, + format: None, // The adapter doesn't provide format info, will extract from raw JSON instead + params: None, + }) + .collect(); + entries.push(RegistryEntry { + selector, + format_id: format.id, + source_file: path + .strip_prefix(registry_dir) + .unwrap_or(path) + .to_string_lossy() + .to_string(), + fields, + }); + parsed_any = true; + } + } + } + } + if !parsed_any { + // Fallback lightweight parse of display.formats[*].fields + #[derive(Deserialize)] + struct FbField { + label: Option, + path: Option, + #[serde(rename = "$ref")] + r#ref: Option, + format: Option, + params: Option>, + } + #[derive(Deserialize)] + struct FbFormat { + #[serde(rename = "$id")] + id: Option, + fields: Option>, + } + #[derive(Deserialize)] + struct FbDefinition { + label: Option, + format: Option, + params: Option>, + } + #[derive(Deserialize)] + struct FbDisplay { + formats: HashMap, + definitions: Option>, + } + #[derive(Deserialize)] + struct FbSpec { + display: Option, + } + if let Ok(fb) = serde_json::from_str::(contents) { + if let Some(display) = fb.display { + let defs = display.definitions.unwrap_or_default(); + for (key, fmt) in display.formats.into_iter() { + if let Some(selector) = normalize_selector(&key) { + let fields: Vec<_> = fmt + .fields + .unwrap_or_default() + .into_iter() + .map(|f| { + // derive label: explicit label, else from $ref -> definitions + let label = if let Some(lbl) = f.label { + lbl + } else if let Some(ref r) = f.r#ref { + let key = r.rsplit('.').next().unwrap_or(r); + defs.get(key) + .and_then(|d| d.label.clone()) + .unwrap_or_default() + } else { + String::new() + }; + + // derive format: explicit format, else from $ref -> definitions + let format = if let Some(fmt) = f.format { + Some(fmt) + } else if let Some(ref r) = f.r#ref { + let key = r.rsplit('.').next().unwrap_or(r); + defs.get(key).and_then(|d| d.format.clone()) + } else { + None + }; + + // derive params: explicit params, else from $ref -> definitions + let params = if let Some(p) = f.params { + Some(p) + } else if let Some(ref r) = f.r#ref { + let key = r.rsplit('.').next().unwrap_or(r); + defs.get(key).and_then(|d| d.params.clone()) + } else { + None + }; + + SimpleField { + label, + path: f.path.unwrap_or_default(), + format, + params, + } + }) + .collect(); + entries.push(RegistryEntry { + selector, + format_id: fmt.id, + source_file: path + .strip_prefix(registry_dir) + .unwrap_or(path) + .to_string_lossy() + .to_string(), + fields, + }); + } + } + } + } + } + }); + + entries +} + +/// Generate the Rust source for the registry map used at runtime. +pub fn generate_registry_rs(entries: &[RegistryEntry]) -> String { + // De-duplicate selectors grouping indexes + let mut selector_map: HashMap> = HashMap::new(); + for (idx, e) in entries.iter().enumerate() { + selector_map + .entry(e.selector.clone()) + .or_default() + .push(idx); + } + + let mut out = String::new(); + out.push_str("// @generated automatically by build.rs; DO NOT EDIT\n\n"); + out.push_str( + "#[derive(Debug)] pub struct GenField { pub label: &'static str, pub path: &'static str, pub format: Option<&'static str> }\n", + ); + out.push_str("#[derive(Debug)] pub struct GenFormat { pub source_file: &'static str, pub selector: &'static str, pub format_id: Option<&'static str>, pub fields: &'static [GenField] }\n\n"); + + // Emit fields and formats as separate static arrays for reuse + for (i, entry) in entries.iter().enumerate() { + out.push_str(&format!( + "static FIELDS_{i}: [GenField; {}] = [", + entry.fields.len() + )); + for f in &entry.fields { + let format_str = f + .format + .as_ref() + .map(|s| format!("Some(\"{}\")", escape(s))) + .unwrap_or_else(|| "None".to_string()); + out.push_str(&format!( + "GenField {{ label: \"{}\", path: \"{}\", format: {} }},", + escape(&f.label), + escape(&f.path), + format_str + )); + } + out.push_str("];\n\n"); + let format_id = entry + .format_id + .as_ref() + .map(|s| format!("Some(\"{}\")", escape(s))) + .unwrap_or_else(|| "None".to_string()); + out.push_str(&format!( + "static FORMAT_{i}: GenFormat = GenFormat {{ source_file: \"{}\", selector: \"{}\", format_id: {format_id}, fields: &FIELDS_{i} }};\n\n", + escape(&entry.source_file), + escape(&entry.selector) + )); + } + + // Build per-selector format slices + let mut grouped: Vec<(&String, &Vec)> = selector_map.iter().collect(); + grouped.sort_by(|a, b| a.0.cmp(b.0)); + for (idx, (_sel, list)) in grouped.iter().enumerate() { + out.push_str(&format!( + "static FORMATS_FOR_{idx}: [&GenFormat; {}] = [", + list.len() + )); + for fi in *list { + out.push_str(&format!("&FORMAT_{fi},")); + } + out.push_str("];\n\n"); + } + + // phf map: selector -> slice of &GenFormat + out.push_str( + "pub static SELECTOR_MAP: phf::Map<&'static str, &'static [&'static GenFormat]> = phf::phf_map! {\n", + ); + for (idx, (sel, _)) in grouped.iter().enumerate() { + out.push_str(&format!(" \"{}\" => &FORMATS_FOR_{idx},\n", escape(sel))); + } + out.push_str("};\n"); + out +} diff --git a/src/chain_parsers/visualsign-ethereum/src/lib.rs b/src/chain_parsers/visualsign-ethereum/src/lib.rs index ba627e60..cddd50bf 100644 --- a/src/chain_parsers/visualsign-ethereum/src/lib.rs +++ b/src/chain_parsers/visualsign-ethereum/src/lib.rs @@ -12,6 +12,8 @@ use visualsign::{ }; pub mod chains; +pub mod provider; +pub mod registry; #[derive(Debug, Eq, PartialEq, thiserror::Error)] pub enum EthereumParserError { @@ -246,15 +248,20 @@ fn convert_to_visual_sign_payload( // Add contract call data if present let input = transaction.input(); if !input.is_empty() { - fields.push(SignablePayloadField::TextV2 { - common: SignablePayloadFieldCommon { - fallback_text: format!("0x{}", hex::encode(input)), - label: "Input Data".to_string(), - }, - text_v2: SignablePayloadFieldTextV2 { - text: format!("0x{}", hex::encode(input)), - }, - }); + let address = transaction.to().or_else(|| None); + if let Some(field) = registry::try_visualize_commands(chain_id, address, &input) { + fields.push(field); + } else { + fields.push(SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: format!("0x{}", hex::encode(input)), + label: "Input Data".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: format!("0x{}", hex::encode(input)), + }, + }); + } } let title = options diff --git a/src/chain_parsers/visualsign-ethereum/src/provider/eip7730.rs b/src/chain_parsers/visualsign-ethereum/src/provider/eip7730.rs new file mode 100644 index 00000000..75b3af4e --- /dev/null +++ b/src/chain_parsers/visualsign-ethereum/src/provider/eip7730.rs @@ -0,0 +1,36 @@ +use crate::registry::{CommandVisualizer, VisualizerContext, decode_calldata}; +use visualsign::{ + AnnotatedPayloadField, SignablePayloadField, SignablePayloadFieldCommon, + SignablePayloadFieldListLayout, +}; + +pub struct Eip7730TxVisualizer; + +impl CommandVisualizer for Eip7730TxVisualizer { + fn visualize_tx_commands(&self, context: &VisualizerContext) -> Option { + let decoded = decode_calldata(context.calldata)?; + if decoded.is_empty() { + return None; + } + + // Create one item per decoded field, using the actual field types and values + let items: Vec = decoded + .into_iter() + .map(|field| AnnotatedPayloadField { + signable_payload_field: field, + static_annotation: None, + dynamic_annotation: None, + }) + .collect(); + + let summary_text = format!("Decoded {} field(s)", items.len()); + + Some(SignablePayloadField::ListLayout { + common: SignablePayloadFieldCommon { + fallback_text: summary_text, + label: "Decoded Input".to_string(), + }, + list_layout: SignablePayloadFieldListLayout { fields: items }, + }) + } +} diff --git a/src/chain_parsers/visualsign-ethereum/src/provider/mod.rs b/src/chain_parsers/visualsign-ethereum/src/provider/mod.rs new file mode 100644 index 00000000..34706124 --- /dev/null +++ b/src/chain_parsers/visualsign-ethereum/src/provider/mod.rs @@ -0,0 +1,2 @@ +pub mod eip7730; + diff --git a/src/chain_parsers/visualsign-ethereum/src/registry.rs b/src/chain_parsers/visualsign-ethereum/src/registry.rs new file mode 100644 index 00000000..c0dcb70f --- /dev/null +++ b/src/chain_parsers/visualsign-ethereum/src/registry.rs @@ -0,0 +1,350 @@ +include!(concat!(env!("OUT_DIR"), "/erc7730_registry_gen.rs")); +use crate::provider::eip7730::Eip7730TxVisualizer; +use alloy_primitives::Address; +use std::{ + collections::HashMap, + sync::{Arc, Once}, +}; +use visualsign::{ + SignablePayloadField, SignablePayloadFieldAddressV2, SignablePayloadFieldAmountV2, + SignablePayloadFieldCommon, SignablePayloadFieldNumber, SignablePayloadFieldTextV2, +}; + +/// Context passed to visualizers for higher-level command rendering +#[derive(Debug)] +pub struct VisualizerContext<'a> { + pub chain_id: Option, + pub to: Option
, + pub calldata: &'a [u8], +} + +/// Trait for contract-specific visualizers. Implementations should attempt to produce +/// a higher-level SignablePayloadField (e.g. a PreviewLayout summarizing commands) or return None. +pub trait CommandVisualizer: Send + Sync + 'static { + fn visualize_tx_commands(&self, context: &VisualizerContext) -> Option; +} + +type DynVisualizer = Arc; +static INIT: Once = Once::new(); +// Top-level map: chain_id (Some or None for chain-agnostic) -> address -> visualizer +static mut COMMAND_REGISTRY_PTR: *mut HashMap, HashMap> = + std::ptr::null_mut(); + +#[inline] +fn ensure_init() { + INIT.call_once(|| unsafe { + let boxed: Box, HashMap>> = + Box::new(HashMap::new()); + COMMAND_REGISTRY_PTR = Box::into_raw(boxed); + }); +} + +/// Register a visualizer for (chain_id,address). Use chain_id None for chain-agnostic fallback. +pub fn register_visualizer(chain_id: Option, address: Address, visualizer: DynVisualizer) { + ensure_init(); + unsafe { + let top = &mut *COMMAND_REGISTRY_PTR; + top.entry(chain_id) + .or_insert_with(HashMap::new) + .insert(address, visualizer); + } +} + +/// Lookup a visualizer. Attempts exact (chain_id,address) then (None,address). +pub fn get_visualizer(chain_id: Option, address: Address) -> Option { + ensure_init(); + unsafe { + if COMMAND_REGISTRY_PTR.is_null() { + return None; + } + let top = &*COMMAND_REGISTRY_PTR; + if let Some(m) = top.get(&chain_id) { + if let Some(v) = m.get(&address) { + return Some(v.clone()); + } + } + // Fallback to chain-agnostic (None) + if let Some(m_any) = top.get(&None) { + if let Some(v) = m_any.get(&address) { + return Some(v.clone()); + } + } + None + } +} + +/// Convenience: try to visualize using any registered visualizer; returns the produced field or None. +pub fn try_visualize_commands( + chain_id: Option, + to: Option
, + calldata: &[u8], +) -> Option { + // Prefer a specifically registered visualizer if available + if let Some(to_addr) = to { + if let Some(v) = get_visualizer(chain_id, to_addr) { + return v.visualize_tx_commands(&VisualizerContext { + chain_id, + to, + calldata, + }); + } + } + // Fallback to generic EIP-7730 visualizer (selector-based) if none registered + let generic = Eip7730TxVisualizer; + generic.visualize_tx_commands(&VisualizerContext { + chain_id, + to, + calldata, + }) +} + +/// Given calldata bytes, attempt to produce SignablePayloadFields using the registry. +/// Extracts values from calldata based on field paths and creates appropriate field types +/// based on field format metadata. Falls back to TextV2 when format is not defined. +pub fn decode_calldata(calldata: &[u8]) -> Option> { + if calldata.len() < 4 { + return None; + } + let selector_hex = format!( + "0x{:08x}", + u32::from_be_bytes([calldata[0], calldata[1], calldata[2], calldata[3]]) + ); + let formats = SELECTOR_MAP.get(&*selector_hex)?; + let format = formats.first()?; + let mut fields = Vec::new(); + + // Skip the 4-byte selector to get to the actual parameters + let params_data = &calldata[4..]; + + for (field_index, f) in format.fields.iter().enumerate() { + let label = f.label.to_string(); + + // Extract value from calldata based on field path + let extracted_value = extract_value_from_calldata(params_data, &f.path, field_index); + + // Create appropriate field based on format + let field = create_typed_field(&label, &f.path, &extracted_value, f.format.as_deref()); + fields.push(field); + } + Some(fields) +} + +/// Extract value from calldata based on the field path +/// For now, this is a simple implementation that extracts ABI-encoded parameters +fn extract_value_from_calldata(params_data: &[u8], path: &str, field_index: usize) -> String { + // Simple extraction: each parameter is 32 bytes in standard ABI encoding + let start_offset = field_index * 32; + + if start_offset + 32 <= params_data.len() { + let param_bytes = ¶ms_data[start_offset..start_offset + 32]; + + // For paths that suggest an address, format as hex address + if path.contains("address") || path.contains("to") || path.contains("recipient") { + // Take last 20 bytes for address (addresses are right-padded in 32-byte words) + let addr_bytes = ¶m_bytes[12..32]; + format!("0x{}", hex::encode(addr_bytes)) + } else if path.contains("amount") || path.contains("value") || path.contains("tokenId") { + // Decode as uint256 + let mut value = alloy_primitives::U256::ZERO; + for (i, &byte) in param_bytes.iter().enumerate() { + value = value + + alloy_primitives::U256::from(byte) + * alloy_primitives::U256::from(256) + .pow(alloy_primitives::U256::from(31 - i)); + } + value.to_string() + } else { + // Default: show as hex for debugging + format!("0x{}", hex::encode(param_bytes)) + } + } else { + // Not enough data, return placeholder + format!("0x{}", hex::encode(&[0u8; 32])) + } +} + +/// Create appropriate SignablePayloadField based on format type +fn create_typed_field( + label: &str, + _path: &str, + value: &str, + format: Option<&str>, +) -> SignablePayloadField { + let common = SignablePayloadFieldCommon { + fallback_text: value.to_string(), + label: label.to_string(), + }; + + match format { + Some("addressName") | Some("address") => SignablePayloadField::AddressV2 { + common, + address_v2: SignablePayloadFieldAddressV2 { + address: value.to_string(), + name: String::new(), + memo: None, + asset_label: String::new(), + badge_text: None, + }, + }, + Some("tokenAmount") | Some("amount") => SignablePayloadField::AmountV2 { + common, + amount_v2: SignablePayloadFieldAmountV2 { + amount: value.to_string(), + abbreviation: None, + }, + }, + Some("number") => SignablePayloadField::Number { + common, + number: SignablePayloadFieldNumber { + number: value.to_string(), + }, + }, + _ => { + // Default to TextV2 when format is not defined or unknown + SignablePayloadField::TextV2 { + common, + text_v2: SignablePayloadFieldTextV2 { + text: value.to_string(), + }, + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use hex::FromHex; + + // Helper to build calldata from selector hex string like "0x04e45aaf" + fn calldata_from_selector(selector: &str) -> Vec { + let clean = selector.trim_start_matches("0x"); + let bytes = <[u8; 4]>::from_hex(clean).unwrap(); + bytes.to_vec() // no args appended for these tests + } + + #[test] + fn registry_is_populated() { + assert!( + SELECTOR_MAP.entries().len() > 0, + "Registry map should not be empty" + ); + } + + #[test] + fn known_uniswap_selector_present() { + // From calldata-UniswapV3Router02.json: selector 0x04e45aaf (exactInputSingle) + let selector = "0x04e45aaf"; + let formats = SELECTOR_MAP.get(selector).expect("selector present"); + assert!( + !formats.is_empty(), + "Registered formats list should not be empty" + ); + let first = formats[0]; + assert!( + first.fields.iter().any(|f| f.label == "Send"), + "Expected a field with label 'Send'" + ); + } + + #[test] + fn decode_calldata_returns_fields_for_known_selector() { + let selector = "0x04e45aaf"; // exactInputSingle + let calldata = calldata_from_selector(selector); + let fields = decode_calldata(&calldata).expect("Should decode fields"); + // Expect at least the number of fields defined in spec (we know some exist) + assert!( + fields.len() >= 3, + "Expected at least 3 fields, got {}", + fields.len() + ); + // Ensure labels preserved + let labels: Vec<_> = fields.iter().map(|f| f.label().clone()).collect(); + assert!( + labels.iter().any(|l| l == "Send"), + "Missing 'Send' label in decoded fields: {:?}", + labels + ); + } + + #[test] + fn decode_calldata_unknown_selector_returns_none() { + // Random selector unlikely to exist + let calldata = calldata_from_selector("0xdeadbeef"); + assert!(decode_calldata(&calldata).is_none()); + } + + #[test] + fn decode_calldata_short_input_returns_none() { + assert!(decode_calldata(&[0x01, 0x02, 0x03]).is_none()); + } + + #[test] + fn decode_calldata_with_additional_arguments() { + // Use known selector and append arbitrary bytes simulating encoded params + let selector = "0x04e45aaf"; // exactInputSingle + let mut calldata = calldata_from_selector(selector); + // Append 32 bytes (typical ABI word) of zeros + calldata.extend_from_slice(&[0u8; 32]); + let fields = decode_calldata(&calldata).expect("Should decode with extra args"); + assert!( + fields.len() >= 3, + "Expected at least 3 fields with args present" + ); + } + + #[test] + fn decode_calldata_is_deterministic() { + let selector = "0x04e45aaf"; + let calldata = calldata_from_selector(selector); + let a = decode_calldata(&calldata).unwrap(); + let b = decode_calldata(&calldata).unwrap(); + assert_eq!(a, b, "Decoding same calldata should be deterministic"); + } + + #[test] + fn first_format_field_count_matches_decoded_count_for_all_selectors() { + // For every selector ensure decode returns exactly the number of fields in the first format + // (current implementation uses the first format only) + for (selector, formats) in SELECTOR_MAP.entries() { + if formats.is_empty() { + continue; + } + let first = formats[0]; + // Build calldata bytes + let mut raw = Vec::new(); + let hex = selector.trim_start_matches("0x"); + let bytes = <[u8; 4]>::from_hex(hex).expect("valid selector hex"); + raw.extend_from_slice(&bytes); + let decoded = decode_calldata(&raw).expect("should decode"); + assert_eq!( + decoded.len(), + first.fields.len(), + "selector {selector} field count mismatch" + ); + } + } + + #[test] + fn decoded_fields_have_non_empty_labels_and_fallback_text() { + // Sample up to first 25 selectors to keep test lean + for (i, (selector, _)) in SELECTOR_MAP.entries().enumerate() { + if i >= 25 { + break; + } + let mut raw = Vec::new(); + let hex = selector.trim_start_matches("0x"); + let bytes = <[u8; 4]>::from_hex(hex).expect("valid selector hex"); + raw.extend_from_slice(&bytes); + let decoded = decode_calldata(&raw).expect("decode"); + for f in decoded { + let label_empty = f.label().is_empty(); + let fb_empty = f.fallback_text().is_empty(); + assert!( + !(label_empty && fb_empty), + "selector {selector} has both empty label and fallback_text" + ); + } + } + } +} diff --git a/src/chain_parsers/visualsign-ethereum/static/eip7730 b/src/chain_parsers/visualsign-ethereum/static/eip7730 new file mode 160000 index 00000000..c0405bd6 --- /dev/null +++ b/src/chain_parsers/visualsign-ethereum/static/eip7730 @@ -0,0 +1 @@ +Subproject commit c0405bd62f4c75031027df58e75e7521e0c266ec diff --git a/src/chain_parsers/visualsign-ethereum/tests/build_gen.rs b/src/chain_parsers/visualsign-ethereum/tests/build_gen.rs new file mode 100644 index 00000000..e69914eb --- /dev/null +++ b/src/chain_parsers/visualsign-ethereum/tests/build_gen.rs @@ -0,0 +1,65 @@ +// Bring in the same generator module for tests +mod build_gen { + include!(concat!(env!("CARGO_MANIFEST_DIR"), "/build_src/gen.rs")); +} + +use std::fs; +use std::io::Write; + +#[test] +fn normalize_selector_works() { + assert_eq!( + build_gen::normalize_selector("0xdeadBEEF"), + Some("0xdeadbeef".to_string()) + ); + // keccak256("transfer(address,uint256)")[:4] = a9059cbb + assert_eq!( + build_gen::normalize_selector("transfer(address,uint256)"), + Some("0xa9059cbb".to_string()) + ); +} + +#[test] +fn collect_entries_parses_fallback_json() { + let tmp = tempfile::tempdir().unwrap(); + let reg_dir = tmp.path().to_path_buf(); + let json = r#" + { + "display": { + "formats": { + "0x12345678": { + "$id": "test-format", + "fields": [ + {"label": "Field A", "path": "data.a"}, + {"label": "Field B", "path": "data.b"} + ] + } + } + } + }"#; + let file_path = reg_dir.join("foo.json"); + let mut f = fs::File::create(&file_path).unwrap(); + write!(f, "{json}").unwrap(); + + let entries = build_gen::collect_entries(®_dir); + assert_eq!(entries.len(), 1); + let e = &entries[0]; + assert_eq!(e.selector, "0x12345678"); + assert_eq!(e.fields.len(), 2); + assert_eq!(e.fields[0].label, "Field A"); + assert_eq!(e.fields[0].path, "data.a"); + + let generated = build_gen::generate_registry_rs(&entries); + assert!(generated.contains("phf::phf_map!")); + // Guard: ensure static array declarations end with semicolons + assert!(generated.contains("static FIELDS_0: [GenField; 2] = [")); + assert!( + generated.contains("];\n\nstatic FORMAT_0:"), + "FIELDS array must be closed with ]; followed by next item" + ); + assert!(generated.contains("static FORMATS_FOR_0: [&GenFormat; 1] = [")); + assert!( + generated.contains("];\n\npub static SELECTOR_MAP"), + "FORMATS_FOR array must be closed with ]; before map" + ); +} diff --git a/src/chain_parsers/visualsign-ethereum/tests/fixtures/eip7730/aave_deposit.expected b/src/chain_parsers/visualsign-ethereum/tests/fixtures/eip7730/aave_deposit.expected new file mode 100644 index 00000000..ead0614a --- /dev/null +++ b/src/chain_parsers/visualsign-ethereum/tests/fixtures/eip7730/aave_deposit.expected @@ -0,0 +1,94 @@ +SignablePayload { + fields: [ + TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: "Ethereum Mainnet", + label: "Network", + }, + text_v2: SignablePayloadFieldTextV2 { + text: "Ethereum Mainnet", + }, + }, + TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: "0x7d2768dE32b0b80b7a3454c06BdAc94A69DDc7A9", + label: "To", + }, + text_v2: SignablePayloadFieldTextV2 { + text: "0x7d2768dE32b0b80b7a3454c06BdAc94A69DDc7A9", + }, + }, + TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: "0 ETH", + label: "Value", + }, + text_v2: SignablePayloadFieldTextV2 { + text: "0 ETH", + }, + }, + TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: "318162", + label: "Gas Limit", + }, + text_v2: SignablePayloadFieldTextV2 { + text: "318162", + }, + }, + TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: "5", + label: "Nonce", + }, + text_v2: SignablePayloadFieldTextV2 { + text: "5", + }, + }, + ListLayout { + common: SignablePayloadFieldCommon { + fallback_text: "Decoded 2 field(s)", + label: "Decoded Input", + }, + list_layout: SignablePayloadFieldListLayout { + fields: [ + AnnotatedPayloadField { + signable_payload_field: AmountV2 { + common: SignablePayloadFieldCommon { + fallback_text: "196268403159008932410419402999721616371951519129", + label: "Amount to supply", + }, + amount_v2: SignablePayloadFieldAmountV2 { + amount: "196268403159008932410419402999721616371951519129", + abbreviation: None, + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + AnnotatedPayloadField { + signable_payload_field: AddressV2 { + common: SignablePayloadFieldCommon { + fallback_text: "0x00000000000000000000000000000000000000000000000000000000000003e8", + label: "Collateral recipient", + }, + address_v2: SignablePayloadFieldAddressV2 { + address: "0x00000000000000000000000000000000000000000000000000000000000003e8", + name: "", + memo: None, + asset_label: "", + badge_text: None, + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + ], + }, + }, + ], + payload_type: "EthereumTx", + subtitle: None, + title: "Ethereum Transaction", + version: "0", +} diff --git a/src/chain_parsers/visualsign-ethereum/tests/fixtures/eip7730/aave_deposit.input b/src/chain_parsers/visualsign-ethereum/tests/fixtures/eip7730/aave_deposit.input new file mode 100644 index 00000000..1cb3b850 --- /dev/null +++ b/src/chain_parsers/visualsign-ethereum/tests/fixtures/eip7730/aave_deposit.input @@ -0,0 +1 @@ +0x02f8ad0105843b9aca0084607128e58304dad2947d2768de32b0b80b7a3454c06bdac94a69ddc7a980b884e8eda9df0000000000000000000000002260fac5e5542a773aa44fbcfedf7c193bc2c59900000000000000000000000000000000000000000000000000000000000003e8000000000000000000000000f68a392712174eecc6e4d00d7cf444dd5203aa5c0000000000000000000000000000000000000000000000000000000000000000c0 diff --git a/src/chain_parsers/visualsign-ethereum/tests/fixtures/eip7730/paraswap_simpleSwap.expected b/src/chain_parsers/visualsign-ethereum/tests/fixtures/eip7730/paraswap_simpleSwap.expected new file mode 100644 index 00000000..90f70777 --- /dev/null +++ b/src/chain_parsers/visualsign-ethereum/tests/fixtures/eip7730/paraswap_simpleSwap.expected @@ -0,0 +1,117 @@ +SignablePayload { + fields: [ + TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: "BNB Smart Chain Mainnet", + label: "Network", + }, + text_v2: SignablePayloadFieldTextV2 { + text: "BNB Smart Chain Mainnet", + }, + }, + TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: "0xDEF171Fe48CF0115B1d80b88dc8eAB59176FEe57", + label: "To", + }, + text_v2: SignablePayloadFieldTextV2 { + text: "0xDEF171Fe48CF0115B1d80b88dc8eAB59176FEe57", + }, + }, + TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: "0 ETH", + label: "Value", + }, + text_v2: SignablePayloadFieldTextV2 { + text: "0 ETH", + }, + }, + TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: "2000000", + label: "Gas Limit", + }, + text_v2: SignablePayloadFieldTextV2 { + text: "2000000", + }, + }, + TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: "0.00000000010000001 ETH", + label: "Gas Price", + }, + text_v2: SignablePayloadFieldTextV2 { + text: "0.00000000010000001 ETH", + }, + }, + TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: "133706", + label: "Nonce", + }, + text_v2: SignablePayloadFieldTextV2 { + text: "133706", + }, + }, + ListLayout { + common: SignablePayloadFieldCommon { + fallback_text: "Decoded 3 field(s)", + label: "Decoded Input", + }, + list_layout: SignablePayloadFieldListLayout { + fields: [ + AnnotatedPayloadField { + signable_payload_field: AmountV2 { + common: SignablePayloadFieldCommon { + fallback_text: "0x0000000000000000000000000000000000000000000000000000000000000020", + label: "Amount to Send", + }, + amount_v2: SignablePayloadFieldAmountV2 { + amount: "0x0000000000000000000000000000000000000000000000000000000000000020", + abbreviation: None, + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + AnnotatedPayloadField { + signable_payload_field: AmountV2 { + common: SignablePayloadFieldCommon { + fallback_text: "0x55d398326f99059ff775485246999027b3197955", + label: "Minimum to Receive", + }, + amount_v2: SignablePayloadFieldAmountV2 { + amount: "0x55d398326f99059ff775485246999027b3197955", + abbreviation: None, + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + AnnotatedPayloadField { + signable_payload_field: AddressV2 { + common: SignablePayloadFieldCommon { + fallback_text: "0x0000000000000000000000008ac76a51cc950d9822d68b83fe1ad97b32cd580d", + label: "Beneficiary", + }, + address_v2: SignablePayloadFieldAddressV2 { + address: "0x0000000000000000000000008ac76a51cc950d9822d68b83fe1ad97b32cd580d", + name: "", + memo: None, + asset_label: "", + badge_text: None, + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + ], + }, + }, + ], + payload_type: "EthereumTx", + subtitle: None, + title: "Ethereum Transaction", + version: "0", +} diff --git a/src/chain_parsers/visualsign-ethereum/tests/fixtures/eip7730/paraswap_simpleSwap.input b/src/chain_parsers/visualsign-ethereum/tests/fixtures/eip7730/paraswap_simpleSwap.input new file mode 100644 index 00000000..70eb06c8 --- /dev/null +++ b/src/chain_parsers/visualsign-ethereum/tests/fixtures/eip7730/paraswap_simpleSwap.input @@ -0,0 +1 @@ +0xf9044d83020a4a8405f5e10a831e848094def171fe48cf0115b1d80b88dc8eab59176fee5780b9042454e3f31b000000000000000000000000000000000000000000000000000000000000002000000000000000000000000055d398326f99059ff775485246999027b31979550000000000000000000000008ac76a51cc950d9822d68b83fe1ad97b32cd580d0000000000000000000000000000000000000000000000008c1b4f22ab2660000000000000000000000000000000000000000000000000008c0f197817a61de00000000000000000000000000000000000000000000000008c12af73929ecf9d00000000000000000000000000000000000000000000000000000000000001e00000000000000000000000000000000000000000000000000000000000000220000000000000000000000000000000000000000000000000000000000000034000000000000000000000000000000000000000000000000000000000000003a000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000003e00000000000000000000000000000000000000000000000000000000068b33493305cf445500e4e35b0ee1d30964bb46a00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000053e693c6c7ffc4446c53b205cf513105bf140d7b00000000000000000000000000000000000000000000000000000000000000e491a32b6900000000000000000000000055d398326f99059ff775485246999027b31979550000000000000000000000000000000000000000000000008c1b4f22ab2660000000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000001000000000000000000004deeec6557348085aa57c72514d67070dc863c0a5a8c000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000e4000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000388080 diff --git a/src/chain_parsers/visualsign-ethereum/tests/lib_test.rs b/src/chain_parsers/visualsign-ethereum/tests/lib_test.rs index 2c448416..595de2b2 100644 --- a/src/chain_parsers/visualsign-ethereum/tests/lib_test.rs +++ b/src/chain_parsers/visualsign-ethereum/tests/lib_test.rs @@ -12,14 +12,10 @@ fn fixture_path(name: &str) -> PathBuf { path } -static FIXTURES: [&str; 2] = ["1559", "legacy"]; +fn test_fixture_dir(path: &str, fixtures: &[&str]) { + let fixtures_dir = fixture_path(path); -#[test] -fn test_with_fixtures() { - // Get paths for all test cases - let fixtures_dir = fixture_path(""); - - for test_name in FIXTURES { + for test_name in fixtures { let input_path = fixtures_dir.join(format!("{}.input", test_name)); // Read input file contents @@ -62,3 +58,9 @@ fn test_with_fixtures() { ); } } + +#[test] +fn test_with_fixtures() { + test_fixture_dir("", &["1559", "legacy"]); + test_fixture_dir("eip7730", &["aave_deposit", "paraswap_simpleSwap"]); +}