From 1a4c6898e6c26568bc57122119013968a6d04c38 Mon Sep 17 00:00:00 2001 From: Prasanna Gautam Date: Wed, 24 Sep 2025 18:33:18 +0000 Subject: [PATCH 1/2] initial partial transaction support --- src/Cargo.lock | 3 +- .../visualsign-ethereum/Cargo.toml | 1 - .../visualsign-ethereum/src/lib.rs | 59 ++- .../src/partial_transaction.rs | 364 ++++++++++++++++++ .../visualsign-ethereum/tests/lib_test.rs | 60 +++ .../visualsign-solana/src/core/visualsign.rs | 5 + .../visualsign-solana/src/lib.rs | 1 + .../visualsign-solana/src/utils/mod.rs | 1 + .../visualsign-sui/src/utils/test_helpers.rs | 2 + src/parser/app/src/routes/parse.rs | 1 + src/parser/cli/Cargo.toml | 3 +- src/parser/cli/src/cli.rs | 125 +++++- src/visualsign/src/vsptrait.rs | 2 + 13 files changed, 605 insertions(+), 22 deletions(-) create mode 100644 src/chain_parsers/visualsign-ethereum/src/partial_transaction.rs diff --git a/src/Cargo.lock b/src/Cargo.lock index c5273e36..d32f7622 100644 --- a/src/Cargo.lock +++ b/src/Cargo.lock @@ -5466,6 +5466,7 @@ dependencies = [ "bs58 0.5.1", "clap", "generated", + "hex", "parser_app", "serde", "serde_json", @@ -5476,6 +5477,7 @@ dependencies = [ "tracing-log 0.2.0", "tracing-subscriber", "visualsign", + "visualsign-ethereum", "visualsign-solana", "visualsign-unspecified", ] @@ -11200,7 +11202,6 @@ dependencies = [ "alloy-primitives", "alloy-rlp", "base64 0.22.1", - "hex", "log", "serde", "serde_json", diff --git a/src/chain_parsers/visualsign-ethereum/Cargo.toml b/src/chain_parsers/visualsign-ethereum/Cargo.toml index 5a9e57b9..3ea96ee8 100644 --- a/src/chain_parsers/visualsign-ethereum/Cargo.toml +++ b/src/chain_parsers/visualsign-ethereum/Cargo.toml @@ -9,7 +9,6 @@ alloy-primitives = "1.0.20" alloy-eips = "1.0.20" alloy-rlp = "0.3.12" base64 = "0.22.1" -hex = "0.4.3" log = "0.4" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" diff --git a/src/chain_parsers/visualsign-ethereum/src/lib.rs b/src/chain_parsers/visualsign-ethereum/src/lib.rs index ba627e60..9de62f52 100644 --- a/src/chain_parsers/visualsign-ethereum/src/lib.rs +++ b/src/chain_parsers/visualsign-ethereum/src/lib.rs @@ -1,5 +1,5 @@ use alloy_consensus::{Transaction as _, TxType, TypedTransaction}; -use alloy_primitives::{U256, utils::format_units}; +use alloy_primitives::{U256, hex, utils::format_units}; use alloy_rlp::{Buf, Decodable}; use base64::{Engine as _, engine::general_purpose::STANDARD as b64}; use visualsign::{ @@ -12,6 +12,7 @@ use visualsign::{ }; pub mod chains; +pub mod partial_transaction; #[derive(Debug, Eq, PartialEq, thiserror::Error)] pub enum EthereumParserError { @@ -93,7 +94,16 @@ impl VisualSignConverter for EthereumVisualSignConve } } -impl VisualSignConverterFromString for EthereumVisualSignConverter {} +impl VisualSignConverterFromString for EthereumVisualSignConverter { + fn to_visual_sign_payload_from_string( + &self, + transaction_data: &str, + options: VisualSignOptions, + ) -> Result { + // Use our enhanced function that handles partial parsing + transaction_string_to_visual_sign(transaction_data, options) + } +} fn decode_transaction_bytes(mut buf: &[u8]) -> Result { let tx = if buf.is_empty() { Err(EthereumParserError::FailedToDecodeTransaction( @@ -273,12 +283,49 @@ pub fn transaction_to_visual_sign( converter.to_visual_sign_payload(wrapper, options) } -pub fn transaction_string_to_visual_sign( +/// Internal function for standard transaction parsing (no partial parsing) +fn standard_transaction_parsing( transaction_data: &str, options: VisualSignOptions, ) -> Result { + let transaction = EthereumTransactionWrapper::from_string(transaction_data) + .map_err(VisualSignError::ParseError)?; let converter = EthereumVisualSignConverter; - converter.to_visual_sign_payload_from_string(transaction_data, options) + converter.to_visual_sign_payload(transaction, options) +} + +pub fn transaction_string_to_visual_sign( + transaction_data: &str, + options: VisualSignOptions, +) -> Result { + if options.partial_parsing { + // If partial parsing is enabled, try partial first, then fall back to standard + match partial_transaction_string_to_visual_sign(transaction_data, options.clone()) { + Ok(payload) => Ok(payload), + Err(_) => { + // Fall back to standard parsing + standard_transaction_parsing(transaction_data, options) + } + } + } else { + // Regular parsing - completely unaffected + standard_transaction_parsing(transaction_data, options) + } +} + +/// Public API to convert a partial transaction string to a VisualSign payload +/// This function attempts to decode incomplete transaction data gracefully +pub fn partial_transaction_string_to_visual_sign( + raw_transaction: &str, + options: VisualSignOptions, +) -> Result { + match partial_transaction::decode_partial_transaction_from_hex(raw_transaction) { + Ok(partial_tx) => Ok(partial_tx.to_visual_sign_payload(options)), + Err(e) => Err(VisualSignError::DecodeError(format!( + "Failed to decode as partial transaction: {}", + e + ))), + } } #[cfg(test)] @@ -445,6 +492,7 @@ mod tests { let options = VisualSignOptions { decode_transfers: false, transaction_name: Some("Custom Transaction Title".to_string()), + partial_parsing: false, }; let payload = transaction_to_visual_sign(tx, options).unwrap(); @@ -471,7 +519,7 @@ mod tests { assert_eq!( EthereumTransactionWrapper::from_string("0x123"), Err(TransactionParseError::DecodeError( - "Failed to decode transaction: Failed to decode hex: Odd number of digits" + "Failed to decode transaction: Failed to decode hex: odd number of digits" .to_string() )), ); @@ -670,6 +718,7 @@ mod tests { VisualSignOptions { decode_transfers: true, transaction_name: Some("Test Transaction".to_string()), + partial_parsing: false, } ), Ok(SignablePayload::new( diff --git a/src/chain_parsers/visualsign-ethereum/src/partial_transaction.rs b/src/chain_parsers/visualsign-ethereum/src/partial_transaction.rs new file mode 100644 index 00000000..2379f2a2 --- /dev/null +++ b/src/chain_parsers/visualsign-ethereum/src/partial_transaction.rs @@ -0,0 +1,364 @@ +// Partial Transaction Decoder for Ethereum +// This module provides functionality to decode partial transactions where some fields may be missing + +use alloy_primitives::{Address, Bytes, U256, hex}; +use alloy_rlp::{Decodable, Encodable, RlpDecodable, RlpEncodable}; +use visualsign::{ + SignablePayload, SignablePayloadField, SignablePayloadFieldCommon, SignablePayloadFieldTextV2, + vsptrait::VisualSignOptions, +}; + +/// A partial Ethereum transaction that follows the RLP structure from the fixture +/// RLP structure: [chain_id, nonce, gas_price, gas_tip, gas_limit, to, value, data, access_list] +#[derive(Debug, Clone, PartialEq, RlpEncodable, RlpDecodable)] +pub struct PartialEthereumTransaction { + pub chain_id: U256, // 0x11 (17) + pub nonce: U256, // 0x (empty = 0) + pub gas_price: U256, // 0x03 (3) + pub gas_tip: U256, // 0x14 (20) + pub gas_limit: U256, // 0x5208 (21000) + pub to: Address, // 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 + pub value: U256, // 0x01 (1 wei, but represents 1 ETH in context) + pub data: Bytes, // 0x (empty) + pub access_list: Vec, // [] (empty list) +} + +impl Default for PartialEthereumTransaction { + fn default() -> Self { + Self { + chain_id: U256::from(1u64), // Default to Ethereum mainnet + nonce: U256::ZERO, + gas_price: U256::ZERO, + gas_tip: U256::ZERO, + gas_limit: U256::from(21000u64), // Standard transfer gas limit + to: Address::ZERO, + value: U256::ZERO, + data: Bytes::new(), + access_list: Vec::new(), + } + } +} + +impl PartialEthereumTransaction { + /// Create a new partial transaction with default values + pub fn new() -> Self { + Self::default() + } + + /// Create from the fixture data + pub fn from_fixture() -> Self { + Self { + chain_id: U256::from(17u64), // 0x11 + nonce: U256::ZERO, // 0x (empty) + gas_price: U256::from(3u64), // 0x03 + gas_tip: U256::from(20u64), // 0x14 + gas_limit: U256::from(21000u64), // 0x5208 + to: Address::from_slice( + &hex::decode("0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266").unwrap(), + ), + value: U256::from(1u64), // 0x01 (represents 1 ETH in context) + data: Bytes::new(), // 0x (empty) + access_list: Vec::new(), // [] (empty) + } + } + + /// Decode from RLP bytes using alloy-rlp's automatic decoding + pub fn decode_partial(buf: &[u8]) -> Result> { + if buf.is_empty() { + return Err("Cannot decode transaction from empty data".into()); + } + + // Use alloy-rlp's automatic decoding with the RlpDecodable derive + let mut buf_slice = buf; + match Self::decode(&mut buf_slice) { + Ok(tx) => Ok(tx), + Err(e) => Err(format!("Failed to decode RLP: {}", e).into()), + } + } + + /// Encode to RLP bytes using alloy-rlp's automatic encoding + pub fn encode_partial(&self) -> Vec { + let mut buffer = Vec::new(); + self.encode(&mut buffer); + buffer + } + /// Convert to visual sign format + pub fn to_visual_sign_payload(&self, options: VisualSignOptions) -> SignablePayload { + let mut fields = Vec::new(); + + // Network field + let chain_id_u64: u64 = self.chain_id.to(); + let chain_name = match chain_id_u64 { + 1 => "Ethereum Mainnet".to_string(), + 11155111 => "Sepolia Testnet".to_string(), + 5 => "Goerli Testnet".to_string(), + 17 => "Custom Chain ID 17".to_string(), // From our fixture + 137 => "Polygon Mainnet".to_string(), + _ => format!("Chain ID: {}", chain_id_u64), + }; + + fields.push(SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: chain_name.clone(), + label: "Network".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { text: chain_name }, + }); + + // Transaction type field + fields.push(SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: "Partial Transaction".to_string(), + label: "Transaction Type".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: "Partial Transaction".to_string(), + }, + }); + + // To address + fields.push(SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: self.to.to_string(), + label: "To Address".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: self.to.to_string(), + }, + }); + + // Value - use alloy's format_units to properly format the value + let value_text = format_value_with_unit(self.value); + + fields.push(SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: value_text.clone(), + label: "Value".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { text: value_text }, + }); + + // Nonce + fields.push(SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: self.nonce.to_string(), + label: "Nonce".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: self.nonce.to_string(), + }, + }); + + // Gas limit + fields.push(SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: self.gas_limit.to_string(), + label: "Gas Limit".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: self.gas_limit.to_string(), + }, + }); + + // Gas price + let gas_price_text = format!("{} wei", self.gas_price); + fields.push(SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: gas_price_text.clone(), + label: "Gas Price".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: gas_price_text, + }, + }); + + // Gas tip (EIP-1559) + let gas_tip_text = format!("{} wei", self.gas_tip); + fields.push(SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: gas_tip_text.clone(), + label: "Gas Tip".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { text: gas_tip_text }, + }); + + // Input data + if !self.data.is_empty() { + fields.push(SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: format!("0x{}", hex::encode(&self.data)), + label: "Input Data".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: format!("0x{}", hex::encode(&self.data)), + }, + }); + } + + let title = options + .transaction_name + .unwrap_or_else(|| "Partial Ethereum Transaction".to_string()); + + SignablePayload::new( + 0, + title, + Some("Partial transaction decoded using alloy-rlp".to_string()), + fields, + "EthereumTx".to_string(), + ) + } +} + +// Helper function to format value with appropriate unit using alloy's format_units +fn format_value_with_unit(wei: U256) -> String { + use alloy_primitives::utils::format_units; + + // For very small values (< 1000 wei), show as wei + if wei < U256::from(1000u64) { + format!("{} wei", wei) + } else { + // For larger values, show as ETH using alloy's format_units + let formatted = format_units(wei, 18).unwrap_or_else(|_| wei.to_string()); + + // Trim trailing zeros + let trimmed = if formatted.contains('.') { + formatted + .trim_end_matches('0') + .trim_end_matches('.') + .to_string() + } else { + formatted + }; + + format!("{} ETH", trimmed) + } +} + +/// Decode partial transaction from hex string using Alloy's robust parsing +pub fn decode_partial_transaction_from_hex( + hex_data: &str, +) -> Result> { + let clean_hex = hex_data.strip_prefix("0x").unwrap_or(hex_data); + + // Handle empty hex by erroring instead of making up values + if clean_hex.is_empty() { + return Err("Cannot decode transaction from empty hex string".into()); + } + + let bytes = hex::decode(clean_hex)?; + PartialEthereumTransaction::decode_partial(&bytes) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_fixture_transaction() { + let fixture_tx = PartialEthereumTransaction::from_fixture(); + + assert_eq!(fixture_tx.chain_id, U256::from(17u64)); + assert_eq!(fixture_tx.nonce, U256::ZERO); + assert_eq!(fixture_tx.gas_price, U256::from(3u64)); + assert_eq!(fixture_tx.gas_tip, U256::from(20u64)); + assert_eq!(fixture_tx.gas_limit, U256::from(21000u64)); + assert_eq!( + fixture_tx.to, + Address::from_slice( + &hex::decode("0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266").unwrap() + ) + ); + assert_eq!(fixture_tx.value, U256::from(1u64)); // Represents 1 ETH in context + assert_eq!(fixture_tx.data, Bytes::new()); + assert_eq!(fixture_tx.access_list, Vec::::new()); + } + + #[test] + fn test_encode_decode_roundtrip() { + let original_tx = PartialEthereumTransaction::from_fixture(); + + // Encode to RLP + let encoded = original_tx.encode_partial(); + + // Decode back from RLP + let decoded_tx = PartialEthereumTransaction::decode_partial(&encoded).unwrap(); + + // Should be identical + assert_eq!(original_tx, decoded_tx); + } + + #[test] + fn test_decode_from_hex_fixture() { + let hex_data = "df1180031482520894f39Fd6e51aad88F6F4ce6aB8827279cffFb922660180c0"; + + let decoded_tx = decode_partial_transaction_from_hex(hex_data).unwrap(); + let fixture_tx = PartialEthereumTransaction::from_fixture(); + + assert_eq!(decoded_tx, fixture_tx); + } + + #[test] + fn test_visual_sign_payload() { + let fixture_tx = PartialEthereumTransaction::from_fixture(); + + let options = VisualSignOptions { + decode_transfers: true, + transaction_name: Some("Test Fixture Transaction".to_string()), + partial_parsing: true, + }; + + let payload = fixture_tx.to_visual_sign_payload(options); + assert_eq!(payload.title, "Test Fixture Transaction"); + + // Helper function to find field by label + let find_field_text = |label: &str| -> Option { + payload.fields.iter().find_map(|f| match f { + SignablePayloadField::TextV2 { common, text_v2 } if common.label == label => { + Some(text_v2.text.clone()) + } + SignablePayloadField::Text { common, text } if common.label == label => { + Some(text.text.clone()) + } + _ => None, + }) + }; + + // Check the fields match our fixture + assert_eq!( + find_field_text("Network"), + Some("Custom Chain ID 17".to_string()) + ); + // Address comparison should be case-insensitive + let to_address = find_field_text("To Address").unwrap(); + assert_eq!( + to_address.to_lowercase(), + "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266" + ); + assert_eq!(find_field_text("Value"), Some("1 wei".to_string())); // Using alloy format_units + assert_eq!(find_field_text("Nonce"), Some("0".to_string())); + assert_eq!(find_field_text("Gas Limit"), Some("21000".to_string())); + } + + #[test] + fn test_alloy_rlp_pattern() { + // Test the pattern described in the user request + let my_tx = PartialEthereumTransaction { + chain_id: U256::from(17u64), + nonce: U256::ZERO, + gas_price: U256::from(3u64), + gas_tip: U256::from(20u64), + gas_limit: U256::from(21000u64), + to: Address::from_slice( + &hex::decode("0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266").unwrap(), + ), + value: U256::from(1u64), + data: Bytes::new(), + access_list: Vec::new(), + }; + + let mut buffer = Vec::::new(); + let _encoded_size = my_tx.encode(&mut buffer); + let decoded = PartialEthereumTransaction::decode(&mut buffer.as_slice()).unwrap(); + assert_eq!(my_tx, decoded); + } +} diff --git a/src/chain_parsers/visualsign-ethereum/tests/lib_test.rs b/src/chain_parsers/visualsign-ethereum/tests/lib_test.rs index 915a0c00..d6228efb 100644 --- a/src/chain_parsers/visualsign-ethereum/tests/lib_test.rs +++ b/src/chain_parsers/visualsign-ethereum/tests/lib_test.rs @@ -1,6 +1,8 @@ +use alloy_primitives::hex; use std::fs; use std::path::PathBuf; use visualsign::vsptrait::VisualSignOptions; +use visualsign_ethereum::partial_transaction::PartialEthereumTransaction; use visualsign_ethereum::transaction_string_to_visual_sign; // Helper function to get fixture path @@ -33,6 +35,7 @@ fn test_with_fixtures() { let options = VisualSignOptions { decode_transfers: true, transaction_name: None, + partial_parsing: true, }; let result = transaction_string_to_visual_sign(transaction_hex, options); @@ -82,6 +85,7 @@ fn test_ethereum_charset_validation() { let options = VisualSignOptions { decode_transfers: true, transaction_name: None, + partial_parsing: false, }; let result = transaction_string_to_visual_sign(transaction_hex, options); @@ -138,3 +142,59 @@ fn test_ethereum_charset_validation() { } } } + +#[test] +fn test_empty_data_errors() { + // Test that empty data returns an error instead of made-up values + let empty_result = PartialEthereumTransaction::decode_partial(&[]); + assert!( + empty_result.is_err(), + "Empty data should return error, not made-up values" + ); + + // Test that minimal invalid data returns error instead of made-up values + let minimal_data = vec![0x01, 0x02]; + let minimal_result = PartialEthereumTransaction::decode_partial(&minimal_data); + assert!( + minimal_result.is_err(), + "Minimal invalid data should return error, not made-up values" + ); + + // Test that completely invalid data returns error + let invalid_data = vec![0xff; 10]; // All 0xff bytes + let invalid_result = PartialEthereumTransaction::decode_partial(&invalid_data); + assert!( + invalid_result.is_err(), + "Invalid data should return error, not made-up values" + ); + + println!("✅ All empty data tests passed - decoder properly errors on invalid input"); +} + +#[test] +fn test_original_partial_transaction() { + // Test the original failing transaction + let hex_data = "0xdf1180031482520894f39Fd6e51aad88F6F4ce6aB8827279cffFb922660180c0"; + let clean_hex = hex_data.strip_prefix("0x").unwrap(); + let bytes = hex::decode(clean_hex).expect("Valid hex"); + + println!("Testing transaction: {}", hex_data); + println!("Bytes: {:?}", bytes); + println!("First byte: 0x{:02x} ({})", bytes[0], bytes[0]); + println!("Length: {}", bytes.len()); + + let result = PartialEthereumTransaction::decode_partial(&bytes); + println!("Decode result: {:?}", result); + + // This should succeed, not error + assert!( + result.is_ok(), + "Original partial transaction should decode successfully" + ); + + // Also test the hex function directly + use visualsign_ethereum::partial_transaction::decode_partial_transaction_from_hex; + let hex_result = decode_partial_transaction_from_hex(hex_data); + println!("Hex decode result: {:?}", hex_result); + assert!(hex_result.is_ok(), "Hex decode should also work"); +} diff --git a/src/chain_parsers/visualsign-solana/src/core/visualsign.rs b/src/chain_parsers/visualsign-solana/src/core/visualsign.rs index 14a58508..82bc80c8 100644 --- a/src/chain_parsers/visualsign-solana/src/core/visualsign.rs +++ b/src/chain_parsers/visualsign-solana/src/core/visualsign.rs @@ -387,6 +387,7 @@ mod tests { VisualSignOptions { decode_transfers: true, transaction_name: Some("Solana Transaction".to_string()), + partial_parsing: false, }, ); @@ -468,6 +469,7 @@ mod tests { VisualSignOptions { decode_transfers: true, transaction_name: Some("V0 Transaction".to_string()), + partial_parsing: false, }, ); @@ -628,6 +630,7 @@ mod tests { VisualSignOptions { decode_transfers: true, transaction_name: Some("Legacy Transfer Test".to_string()), + partial_parsing: false, }, ); @@ -670,6 +673,7 @@ mod tests { VisualSignOptions { decode_transfers: true, transaction_name: Some("V0 Transfer Test".to_string()), + partial_parsing: false, }, ); @@ -796,6 +800,7 @@ mod tests { VisualSignOptions { decode_transfers: true, transaction_name: Some("Manual V0 Transfer Test".to_string()), + partial_parsing: false, }, ); diff --git a/src/chain_parsers/visualsign-solana/src/lib.rs b/src/chain_parsers/visualsign-solana/src/lib.rs index 58b846ba..b8ffb29f 100644 --- a/src/chain_parsers/visualsign-solana/src/lib.rs +++ b/src/chain_parsers/visualsign-solana/src/lib.rs @@ -26,6 +26,7 @@ mod tests { VisualSignOptions { decode_transfers: true, transaction_name: Some("Unicode Escape Test".to_string()), + partial_parsing: false, }, ) .expect("Should convert to payload successfully"); diff --git a/src/chain_parsers/visualsign-solana/src/utils/mod.rs b/src/chain_parsers/visualsign-solana/src/utils/mod.rs index e3216828..5cbfd2e0 100644 --- a/src/chain_parsers/visualsign-solana/src/utils/mod.rs +++ b/src/chain_parsers/visualsign-solana/src/utils/mod.rs @@ -142,6 +142,7 @@ pub mod test_utils { VisualSignOptions { decode_transfers: true, transaction_name: None, + partial_parsing: false, }, ) .expect("Failed to visualize tx commands") diff --git a/src/chain_parsers/visualsign-sui/src/utils/test_helpers.rs b/src/chain_parsers/visualsign-sui/src/utils/test_helpers.rs index a67e7d90..465a2059 100644 --- a/src/chain_parsers/visualsign-sui/src/utils/test_helpers.rs +++ b/src/chain_parsers/visualsign-sui/src/utils/test_helpers.rs @@ -71,6 +71,7 @@ pub fn payload_from_b64(data: &str) -> SignablePayload { VisualSignOptions { decode_transfers: true, transaction_name: None, + partial_parsing: false, }, ) .expect("Failed to visualize tx commands") @@ -83,6 +84,7 @@ pub fn payload_from_b64_with_context(data: &str, context: &str) -> SignablePaylo VisualSignOptions { decode_transfers: true, transaction_name: None, + partial_parsing: false, }, ) { Ok(payload) => payload, diff --git a/src/parser/app/src/routes/parse.rs b/src/parser/app/src/routes/parse.rs index c3b01b0f..cce08ab2 100644 --- a/src/parser/app/src/routes/parse.rs +++ b/src/parser/app/src/routes/parse.rs @@ -31,6 +31,7 @@ pub fn parse( let options = VisualSignOptions { decode_transfers: true, transaction_name: None, + partial_parsing: false, }; let registry = create_registry(); let proto_chain = ProtoChain::from_i32(parse_request.chain) diff --git a/src/parser/cli/Cargo.toml b/src/parser/cli/Cargo.toml index 2e8b915a..3139b1b7 100644 --- a/src/parser/cli/Cargo.toml +++ b/src/parser/cli/Cargo.toml @@ -14,7 +14,7 @@ parser_app = { path = "../app" } borsh = { version = "1.5.7", features = ["std"], default-features = false } generated = { path = "../../generated" } visualsign = { workspace = true } -#visualsign-ethereum = { path = "../../chain_parsers/visualsign-ethereum" } +visualsign-ethereum = { path = "../../chain_parsers/visualsign-ethereum" } visualsign-solana = { path = "../../chain_parsers/visualsign-solana" } visualsign-unspecified = { path = "../../chain_parsers/visualsign-unspecified" } @@ -27,3 +27,4 @@ clap = { version = "4.0", features = ["derive"] } bs58 = { version = "0.5.1", default-features = false } sha2 = { version = "0.10.8", default-features = false } similar = "2.7.0" +hex = { workspace = true } diff --git a/src/parser/cli/src/cli.rs b/src/parser/cli/src/cli.rs index 1da32cfb..487f5aa2 100644 --- a/src/parser/cli/src/cli.rs +++ b/src/parser/cli/src/cli.rs @@ -4,28 +4,101 @@ use clap::{Arg, Command}; use parser_app::registry::create_registry; use visualsign::vsptrait::VisualSignOptions; -fn parse_and_display(chain: &str, raw_tx: &str, options: VisualSignOptions, output_format: &str) { +fn parse_and_display( + chain: &str, + raw_tx: &str, + options: VisualSignOptions, + output_format: &str, + allow_partial: bool, + debug_mode: bool, +) { let registry_chain = parse_chain(chain); + // Debug output - show raw transaction details + if debug_mode { + println!("=== DEBUG MODE ==="); + println!("Raw transaction: {}", raw_tx); + println!("Chain: {}", chain); + println!("Allow partial: {}", allow_partial); + println!("Output format: {}", output_format); + + // Hex analysis + if let Ok(bytes) = hex::decode(raw_tx.strip_prefix("0x").unwrap_or(raw_tx)) { + println!("Hex bytes length: {}", bytes.len()); + println!( + "Raw bytes: [{}]", + bytes + .iter() + .map(|b| format!("{:02x}", b)) + .collect::>() + .join(", ") + ); + + if !bytes.is_empty() { + println!("First byte: 0x{:02x} ({})", bytes[0], bytes[0]); + // Known transaction type tags + match bytes[0] { + 0x00 => println!(" -> EIP-2930 transaction"), + 0x01 => println!(" -> EIP-2930 transaction"), + 0x02 => println!(" -> EIP-1559 transaction"), + 0x03 => println!(" -> EIP-4844 blob transaction"), + b if b <= 0x7f => println!(" -> EIP-2718 typed transaction (type {})", b), + b if b >= 0xc0 => println!(" -> RLP list"), + _ => println!(" -> Unknown format"), + } + } + } else { + println!("Failed to decode hex"); + } + println!("=================="); + println!(); + } + let registry = create_registry(); let signable_payload_str = registry.convert_transaction(®istry_chain, raw_tx, options); match signable_payload_str { - Ok(payload) => match output_format { - "json" => { - if let Ok(json_output) = serde_json::to_string_pretty(&payload) { - println!("{json_output}"); - } else { - eprintln!("Error: Failed to serialize output as JSON"); + Ok(payload) => { + if debug_mode { + println!("=== PARSED PAYLOAD DEBUG ==="); + println!("Title: {}", payload.title); + println!("Subtitle: {:?}", payload.subtitle); + println!("Version: {}", payload.version); + println!("Payload Type: {}", payload.payload_type); + println!("Number of fields: {}", payload.fields.len()); + for (i, field) in payload.fields.iter().enumerate() { + println!( + "Field {}: {} = {}", + i + 1, + field.label(), + field.fallback_text() + ); } + println!("============================"); + println!(); } - "text" => { - println!("{payload:#?}"); - } - _ => { - eprintln!("Error: Unsupported output format '{output_format}'"); + + match output_format { + "json" => { + if let Ok(json_output) = serde_json::to_string_pretty(&payload) { + println!("{json_output}"); + } else { + eprintln!("Error: Failed to serialize output as JSON"); + } + } + "text" => { + println!("{payload:#?}"); + } + _ => { + eprintln!("Error: Unsupported output format '{output_format}'"); + } } - }, + } Err(err) => { + if debug_mode { + println!("=== PARSING ERROR DEBUG ==="); + println!("Error type: {:?}", err); + println!("==========================="); + } eprintln!("Error: {err:?}"); } } @@ -72,6 +145,20 @@ impl Cli { .value_parser(["text", "json"]) .default_value("text"), ) + .arg( + Arg::new("partial") + .short('p') + .long("partial") + .help("Allow parsing of partial/incomplete transactions") + .action(clap::ArgAction::SetTrue), + ) + .arg( + Arg::new("debug") + .short('d') + .long("debug") + .help("Show low-level debug information including raw hex analysis") + .action(clap::ArgAction::SetTrue), + ) .get_matches(); let chain = matches @@ -83,12 +170,22 @@ impl Cli { let output_format = matches .get_one::("output") .expect("Output format has default value"); + let allow_partial = matches.get_flag("partial"); + let debug_mode = matches.get_flag("debug"); let options = VisualSignOptions { decode_transfers: true, transaction_name: None, + partial_parsing: allow_partial, }; - parse_and_display(chain, raw_tx, options, output_format); + parse_and_display( + chain, + raw_tx, + options, + output_format, + allow_partial, + debug_mode, + ); } } diff --git a/src/visualsign/src/vsptrait.rs b/src/visualsign/src/vsptrait.rs index 97a7bc79..467be7ac 100644 --- a/src/visualsign/src/vsptrait.rs +++ b/src/visualsign/src/vsptrait.rs @@ -8,6 +8,7 @@ pub use crate::errors::{TransactionParseError, VisualSignError}; pub struct VisualSignOptions { pub decode_transfers: bool, pub transaction_name: Option, + pub partial_parsing: bool, // Add more options as needed - we can extend this struct later } @@ -256,6 +257,7 @@ mod tests { let options = VisualSignOptions { decode_transfers: true, transaction_name: Some("Custom Transaction".to_string()), + partial_parsing: false, }; let result = converter.to_visual_sign_payload(transaction, options); From bf8af4d4718c867d95df69f44621be0e97c146ef Mon Sep 17 00:00:00 2001 From: Prasanna Gautam Date: Wed, 24 Sep 2025 22:12:35 +0000 Subject: [PATCH 2/2] Fallback to custom processing if EIP-7702 --- .../visualsign-ethereum/src/lib.rs | 16 +- .../src/partial_transaction.rs | 487 +++++++++++++----- .../visualsign-ethereum/tests/lib_test.rs | 64 ++- 3 files changed, 412 insertions(+), 155 deletions(-) diff --git a/src/chain_parsers/visualsign-ethereum/src/lib.rs b/src/chain_parsers/visualsign-ethereum/src/lib.rs index 9de62f52..cdc2c625 100644 --- a/src/chain_parsers/visualsign-ethereum/src/lib.rs +++ b/src/chain_parsers/visualsign-ethereum/src/lib.rs @@ -299,8 +299,8 @@ pub fn transaction_string_to_visual_sign( options: VisualSignOptions, ) -> Result { if options.partial_parsing { - // If partial parsing is enabled, try partial first, then fall back to standard - match partial_transaction_string_to_visual_sign(transaction_data, options.clone()) { + // If partial parsing is enabled, try EIP-7702 first, then fall back to standard + match eip7702_transaction_string_to_visual_sign(transaction_data, options.clone()) { Ok(payload) => Ok(payload), Err(_) => { // Fall back to standard parsing @@ -313,16 +313,16 @@ pub fn transaction_string_to_visual_sign( } } -/// Public API to convert a partial transaction string to a VisualSign payload -/// This function attempts to decode incomplete transaction data gracefully -pub fn partial_transaction_string_to_visual_sign( +/// Public API to convert an EIP-7702 transaction string to a VisualSign payload +/// This function decodes EIP-7702 transaction data using Alloy's TxEip7702 +pub fn eip7702_transaction_string_to_visual_sign( raw_transaction: &str, options: VisualSignOptions, ) -> Result { - match partial_transaction::decode_partial_transaction_from_hex(raw_transaction) { - Ok(partial_tx) => Ok(partial_tx.to_visual_sign_payload(options)), + match partial_transaction::decode_eip7702_transaction_from_hex(raw_transaction) { + Ok(eip7702_tx) => Ok(eip7702_tx.to_visual_sign_payload(options)), Err(e) => Err(VisualSignError::DecodeError(format!( - "Failed to decode as partial transaction: {}", + "Failed to decode as EIP-7702 transaction: {}", e ))), } diff --git a/src/chain_parsers/visualsign-ethereum/src/partial_transaction.rs b/src/chain_parsers/visualsign-ethereum/src/partial_transaction.rs index 2379f2a2..62d3defe 100644 --- a/src/chain_parsers/visualsign-ethereum/src/partial_transaction.rs +++ b/src/chain_parsers/visualsign-ethereum/src/partial_transaction.rs @@ -1,85 +1,100 @@ -// Partial Transaction Decoder for Ethereum -// This module provides functionality to decode partial transactions where some fields may be missing +// EIP-7702 Transaction Decoder for Ethereum +// This module provides functionality to decode EIP-7702 transactions using Alloy's TxEip7702 -use alloy_primitives::{Address, Bytes, U256, hex}; -use alloy_rlp::{Decodable, Encodable, RlpDecodable, RlpEncodable}; +use alloy_consensus::TxEip7702; +use alloy_primitives::{Address, Bytes, ChainId, U256, hex}; +use alloy_rlp::{Decodable, Encodable}; use visualsign::{ SignablePayload, SignablePayloadField, SignablePayloadFieldCommon, SignablePayloadFieldTextV2, vsptrait::VisualSignOptions, }; -/// A partial Ethereum transaction that follows the RLP structure from the fixture -/// RLP structure: [chain_id, nonce, gas_price, gas_tip, gas_limit, to, value, data, access_list] -#[derive(Debug, Clone, PartialEq, RlpEncodable, RlpDecodable)] -pub struct PartialEthereumTransaction { - pub chain_id: U256, // 0x11 (17) - pub nonce: U256, // 0x (empty = 0) - pub gas_price: U256, // 0x03 (3) - pub gas_tip: U256, // 0x14 (20) - pub gas_limit: U256, // 0x5208 (21000) - pub to: Address, // 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 - pub value: U256, // 0x01 (1 wei, but represents 1 ETH in context) - pub data: Bytes, // 0x (empty) - pub access_list: Vec, // [] (empty list) +/// Parsing modes for transaction decoding +#[derive(Debug, Clone, PartialEq)] +pub enum TransactionParsingMode { + /// Standard EIP-7702 transaction format + Eip7702, + /// Custom partial transaction format (catch all for non-standard cases for now) + CustomPartial, } -impl Default for PartialEthereumTransaction { +/// A wrapper around Alloy's TxEip7702 for transaction handling +/// Uses Alloy's built-in RLP encoding/decoding and transaction structure +/// Can represent both EIP-7702 transactions and legacy partial transactions converted to EIP-7702 format +#[derive(Debug, Clone, PartialEq)] +pub struct Eip7702TransactionWrapper { + pub inner: TxEip7702, + pub parsing_mode: TransactionParsingMode, +} + +impl Default for Eip7702TransactionWrapper { fn default() -> Self { Self { - chain_id: U256::from(1u64), // Default to Ethereum mainnet - nonce: U256::ZERO, - gas_price: U256::ZERO, - gas_tip: U256::ZERO, - gas_limit: U256::from(21000u64), // Standard transfer gas limit - to: Address::ZERO, - value: U256::ZERO, - data: Bytes::new(), - access_list: Vec::new(), + inner: TxEip7702 { + chain_id: ChainId::from(1u64), + nonce: 0, + gas_limit: 0, + max_fee_per_gas: 0, + max_priority_fee_per_gas: 0, + to: Address::ZERO, + value: U256::ZERO, + input: Bytes::new(), + access_list: Default::default(), + authorization_list: Default::default(), + }, + parsing_mode: TransactionParsingMode::Eip7702, } } } -impl PartialEthereumTransaction { - /// Create a new partial transaction with default values +impl Eip7702TransactionWrapper { + /// Create a new EIP-7702 transaction with default values pub fn new() -> Self { Self::default() } - /// Create from the fixture data - pub fn from_fixture() -> Self { + /// Create from a TxEip7702 instance (defaults to EIP-7702 parsing mode) + pub fn from_inner(inner: TxEip7702) -> Self { Self { - chain_id: U256::from(17u64), // 0x11 - nonce: U256::ZERO, // 0x (empty) - gas_price: U256::from(3u64), // 0x03 - gas_tip: U256::from(20u64), // 0x14 - gas_limit: U256::from(21000u64), // 0x5208 - to: Address::from_slice( - &hex::decode("0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266").unwrap(), - ), - value: U256::from(1u64), // 0x01 (represents 1 ETH in context) - data: Bytes::new(), // 0x (empty) - access_list: Vec::new(), // [] (empty) + inner, + parsing_mode: TransactionParsingMode::Eip7702, } } - /// Decode from RLP bytes using alloy-rlp's automatic decoding + /// Create from a TxEip7702 instance with specific parsing mode + pub fn from_inner_with_mode(inner: TxEip7702, parsing_mode: TransactionParsingMode) -> Self { + Self { + inner, + parsing_mode, + } + } + + /// Create from RLP bytes + pub fn from_bytes(bytes: &[u8]) -> Result> { + Self::decode_partial(bytes) + } + + /// Decode from RLP bytes using TxEip7702's built-in decoding pub fn decode_partial(buf: &[u8]) -> Result> { if buf.is_empty() { return Err("Cannot decode transaction from empty data".into()); } - // Use alloy-rlp's automatic decoding with the RlpDecodable derive + // Use TxEip7702's built-in RLP decoding let mut buf_slice = buf; - match Self::decode(&mut buf_slice) { - Ok(tx) => Ok(tx), + match TxEip7702::decode(&mut buf_slice) { + Ok(tx) => Ok(Self { + inner: tx, + parsing_mode: TransactionParsingMode::Eip7702, + }), Err(e) => Err(format!("Failed to decode RLP: {}", e).into()), } } - /// Encode to RLP bytes using alloy-rlp's automatic encoding + /// Encode to RLP bytes using TxEip7702's built-in encoding pub fn encode_partial(&self) -> Vec { let mut buffer = Vec::new(); - self.encode(&mut buffer); + self.inner.encode(&mut buffer); buffer } /// Convert to visual sign format @@ -87,7 +102,7 @@ impl PartialEthereumTransaction { let mut fields = Vec::new(); // Network field - let chain_id_u64: u64 = self.chain_id.to(); + let chain_id_u64: u64 = self.inner.chain_id.into(); let chain_name = match chain_id_u64 { 1 => "Ethereum Mainnet".to_string(), 11155111 => "Sepolia Testnet".to_string(), @@ -105,30 +120,35 @@ impl PartialEthereumTransaction { text_v2: SignablePayloadFieldTextV2 { text: chain_name }, }); - // Transaction type field + // Transaction type field - indicate parsing mode + let tx_type_text = match self.parsing_mode { + TransactionParsingMode::Eip7702 => "EIP-7702 Transaction".to_string(), + TransactionParsingMode::CustomPartial => { + "Custom Partial Transaction (converted to EIP-7702)".to_string() + } + }; + fields.push(SignablePayloadField::TextV2 { common: SignablePayloadFieldCommon { - fallback_text: "Partial Transaction".to_string(), + fallback_text: tx_type_text.clone(), label: "Transaction Type".to_string(), }, - text_v2: SignablePayloadFieldTextV2 { - text: "Partial Transaction".to_string(), - }, + text_v2: SignablePayloadFieldTextV2 { text: tx_type_text }, }); // To address fields.push(SignablePayloadField::TextV2 { common: SignablePayloadFieldCommon { - fallback_text: self.to.to_string(), + fallback_text: self.inner.to.to_string(), label: "To Address".to_string(), }, text_v2: SignablePayloadFieldTextV2 { - text: self.to.to_string(), + text: self.inner.to.to_string(), }, }); // Value - use alloy's format_units to properly format the value - let value_text = format_value_with_unit(self.value); + let value_text = format_value_with_unit(self.inner.value); fields.push(SignablePayloadField::TextV2 { common: SignablePayloadFieldCommon { @@ -141,68 +161,82 @@ impl PartialEthereumTransaction { // Nonce fields.push(SignablePayloadField::TextV2 { common: SignablePayloadFieldCommon { - fallback_text: self.nonce.to_string(), + fallback_text: self.inner.nonce.to_string(), label: "Nonce".to_string(), }, text_v2: SignablePayloadFieldTextV2 { - text: self.nonce.to_string(), + text: self.inner.nonce.to_string(), }, }); // Gas limit fields.push(SignablePayloadField::TextV2 { common: SignablePayloadFieldCommon { - fallback_text: self.gas_limit.to_string(), + fallback_text: self.inner.gas_limit.to_string(), label: "Gas Limit".to_string(), }, text_v2: SignablePayloadFieldTextV2 { - text: self.gas_limit.to_string(), + text: self.inner.gas_limit.to_string(), }, }); - // Gas price - let gas_price_text = format!("{} wei", self.gas_price); + // Max fee per gas (EIP-1559) + let max_fee_text = format!("{} wei", self.inner.max_fee_per_gas); fields.push(SignablePayloadField::TextV2 { common: SignablePayloadFieldCommon { - fallback_text: gas_price_text.clone(), - label: "Gas Price".to_string(), - }, - text_v2: SignablePayloadFieldTextV2 { - text: gas_price_text, + fallback_text: max_fee_text.clone(), + label: "Max Fee Per Gas".to_string(), }, + text_v2: SignablePayloadFieldTextV2 { text: max_fee_text }, }); - // Gas tip (EIP-1559) - let gas_tip_text = format!("{} wei", self.gas_tip); + // Max priority fee per gas (EIP-1559) + let max_priority_fee_text = format!("{} wei", self.inner.max_priority_fee_per_gas); fields.push(SignablePayloadField::TextV2 { common: SignablePayloadFieldCommon { - fallback_text: gas_tip_text.clone(), - label: "Gas Tip".to_string(), + fallback_text: max_priority_fee_text.clone(), + label: "Max Priority Fee Per Gas".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: max_priority_fee_text, }, - text_v2: SignablePayloadFieldTextV2 { text: gas_tip_text }, }); // Input data - if !self.data.is_empty() { + if !self.inner.input.is_empty() { fields.push(SignablePayloadField::TextV2 { common: SignablePayloadFieldCommon { - fallback_text: format!("0x{}", hex::encode(&self.data)), + fallback_text: format!("0x{}", hex::encode(&self.inner.input)), label: "Input Data".to_string(), }, text_v2: SignablePayloadFieldTextV2 { - text: format!("0x{}", hex::encode(&self.data)), + text: format!("0x{}", hex::encode(&self.inner.input)), }, }); } - let title = options - .transaction_name - .unwrap_or_else(|| "Partial Ethereum Transaction".to_string()); + let default_title = match self.parsing_mode { + TransactionParsingMode::Eip7702 => "EIP-7702 Ethereum Transaction".to_string(), + TransactionParsingMode::CustomPartial => { + "Custom Partial Ethereum Transaction".to_string() + } + }; + + let title = options.transaction_name.unwrap_or(default_title); + + let description = match self.parsing_mode { + TransactionParsingMode::Eip7702 => { + "EIP-7702 transaction decoded using Alloy".to_string() + } + TransactionParsingMode::CustomPartial => { + "Custom partial transaction converted to EIP-7702 format".to_string() + } + }; SignablePayload::new( 0, title, - Some("Partial transaction decoded using alloy-rlp".to_string()), + Some(description), fields, "EthereumTx".to_string(), ) @@ -234,10 +268,46 @@ fn format_value_with_unit(wei: U256) -> String { } } -/// Decode partial transaction from hex string using Alloy's robust parsing -pub fn decode_partial_transaction_from_hex( +/// Legacy structure for the original partial transaction format +/// This matches the old RLP structure: [chain_id, nonce, gas_price, gas_tip, gas_limit, to, value, data, access_list] +#[derive(Debug, Clone, PartialEq)] +struct LegacyPartialTransaction { + pub chain_id: U256, + pub nonce: U256, + pub gas_price: U256, + pub gas_tip: U256, + pub gas_limit: U256, + pub to: Address, + pub value: U256, + pub data: Bytes, + pub access_list: Vec, +} + +impl LegacyPartialTransaction { + fn to_eip7702_wrapper(&self) -> Eip7702TransactionWrapper { + Eip7702TransactionWrapper { + inner: TxEip7702 { + chain_id: ChainId::from(self.chain_id.to::()), + nonce: self.nonce.to::(), + gas_limit: self.gas_limit.to::(), + // Convert legacy gas_price + gas_tip to EIP-1559 format + max_fee_per_gas: (self.gas_price + self.gas_tip).to::(), + max_priority_fee_per_gas: self.gas_tip.to::(), + to: self.to, + value: self.value, + input: self.data.clone(), + access_list: Default::default(), + authorization_list: Default::default(), + }, + parsing_mode: TransactionParsingMode::CustomPartial, + } + } +} + +/// Decode transaction from hex string - tries EIP-7702 first, then falls back to legacy format +pub fn decode_eip7702_transaction_from_hex( hex_data: &str, -) -> Result> { +) -> Result> { let clean_hex = hex_data.strip_prefix("0x").unwrap_or(hex_data); // Handle empty hex by erroring instead of making up values @@ -246,60 +316,133 @@ pub fn decode_partial_transaction_from_hex( } let bytes = hex::decode(clean_hex)?; - PartialEthereumTransaction::decode_partial(&bytes) + + // First try to decode as EIP-7702 + match Eip7702TransactionWrapper::decode_partial(&bytes) { + Ok(tx) => Ok(tx), + Err(_) => { + // Fall back to legacy format + decode_legacy_partial_transaction(&bytes) + } + } +} + +/// Decode legacy partial transaction format and convert to EIP-7702 wrapper +fn decode_legacy_partial_transaction( + bytes: &[u8], +) -> Result> { + use alloy_rlp::{Decodable, Header}; + + // Try to decode as the old format [chain_id, nonce, gas_price, gas_tip, gas_limit, to, value, data, access_list] + let mut buf = bytes; + + // Decode the RLP header first to ensure it's a list + let header = Header::decode(&mut buf)?; + if !header.list { + return Err("Expected RLP list".into()); + } + + // Manually decode each field in sequence + let chain_id = U256::decode(&mut buf)?; + let nonce = U256::decode(&mut buf)?; + let gas_price = U256::decode(&mut buf)?; + let gas_tip = U256::decode(&mut buf)?; + let gas_limit = U256::decode(&mut buf)?; + let to = Address::decode(&mut buf)?; + let value = U256::decode(&mut buf)?; + let data = Bytes::decode(&mut buf)?; + let access_list = Vec::::decode(&mut buf)?; + + let legacy_tx = LegacyPartialTransaction { + chain_id, + nonce, + gas_price, + gas_tip, + gas_limit, + to, + value, + data, + access_list, + }; + + Ok(legacy_tx.to_eip7702_wrapper()) } #[cfg(test)] mod tests { use super::*; + /// Create fixture data for testing - this is test-specific and doesn't belong in the main impl + fn create_fixture_transaction() -> Eip7702TransactionWrapper { + Eip7702TransactionWrapper { + inner: TxEip7702 { + chain_id: ChainId::from(17u64), // 0x11 + nonce: 0, // 0x (empty) + max_fee_per_gas: 23_000_000_000u128, // gas_price (3) + gas_tip (20) = 23 gwei + max_priority_fee_per_gas: 20_000_000_000u128, // gas_tip (20) = 20 gwei + gas_limit: 21000, // 0x5208 + to: Address::from_slice( + &hex::decode("f39Fd6e51aad88F6F4ce6aB8827279cffFb92266").unwrap(), + ), + value: U256::from(1u64), // 0x01 (represents 1 ETH in context) + input: Bytes::new(), // 0x (empty) + access_list: Default::default(), // [] (empty) + authorization_list: Default::default(), // [] (empty) + }, + parsing_mode: TransactionParsingMode::Eip7702, + } + } + #[test] fn test_fixture_transaction() { - let fixture_tx = PartialEthereumTransaction::from_fixture(); + let fixture_tx = create_fixture_transaction(); - assert_eq!(fixture_tx.chain_id, U256::from(17u64)); - assert_eq!(fixture_tx.nonce, U256::ZERO); - assert_eq!(fixture_tx.gas_price, U256::from(3u64)); - assert_eq!(fixture_tx.gas_tip, U256::from(20u64)); - assert_eq!(fixture_tx.gas_limit, U256::from(21000u64)); + assert_eq!(fixture_tx.inner.chain_id, ChainId::from(17u64)); + assert_eq!(fixture_tx.inner.nonce, 0); + assert_eq!(fixture_tx.inner.max_fee_per_gas, 23_000_000_000u128); assert_eq!( - fixture_tx.to, - Address::from_slice( - &hex::decode("0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266").unwrap() - ) + fixture_tx.inner.max_priority_fee_per_gas, + 20_000_000_000u128 ); - assert_eq!(fixture_tx.value, U256::from(1u64)); // Represents 1 ETH in context - assert_eq!(fixture_tx.data, Bytes::new()); - assert_eq!(fixture_tx.access_list, Vec::::new()); + assert_eq!(fixture_tx.inner.gas_limit, 21000); + assert_eq!( + fixture_tx.inner.to, + Address::from_slice(&hex::decode("f39Fd6e51aad88F6F4ce6aB8827279cffFb92266").unwrap()) + ); + assert_eq!(fixture_tx.inner.value, U256::from(1u64)); // Represents 1 ETH in context + assert_eq!(fixture_tx.inner.input, Bytes::new()); + assert!(fixture_tx.inner.access_list.is_empty()); + assert!(fixture_tx.inner.authorization_list.is_empty()); } #[test] fn test_encode_decode_roundtrip() { - let original_tx = PartialEthereumTransaction::from_fixture(); + let original_tx = create_fixture_transaction(); // Encode to RLP let encoded = original_tx.encode_partial(); // Decode back from RLP - let decoded_tx = PartialEthereumTransaction::decode_partial(&encoded).unwrap(); + let decoded_tx = Eip7702TransactionWrapper::decode_partial(&encoded).unwrap(); // Should be identical assert_eq!(original_tx, decoded_tx); } #[test] - fn test_decode_from_hex_fixture() { - let hex_data = "df1180031482520894f39Fd6e51aad88F6F4ce6aB8827279cffFb922660180c0"; - - let decoded_tx = decode_partial_transaction_from_hex(hex_data).unwrap(); - let fixture_tx = PartialEthereumTransaction::from_fixture(); - - assert_eq!(decoded_tx, fixture_tx); + fn test_encode_decode_with_hex() { + // Test the encode/decode cycle with hex encoding + let fixture_tx = create_fixture_transaction(); + let encoded = fixture_tx.encode_partial(); + let hex_str = hex::encode(&encoded); + + let decoded_tx = decode_eip7702_transaction_from_hex(&hex_str).unwrap(); + assert_eq!(fixture_tx, decoded_tx); } #[test] fn test_visual_sign_payload() { - let fixture_tx = PartialEthereumTransaction::from_fixture(); + let fixture_tx = create_fixture_transaction(); let options = VisualSignOptions { decode_transfers: true, @@ -328,6 +471,10 @@ mod tests { find_field_text("Network"), Some("Custom Chain ID 17".to_string()) ); + assert_eq!( + find_field_text("Transaction Type"), + Some("EIP-7702 Transaction".to_string()) + ); // Address comparison should be case-insensitive let to_address = find_field_text("To Address").unwrap(); assert_eq!( @@ -337,28 +484,122 @@ mod tests { assert_eq!(find_field_text("Value"), Some("1 wei".to_string())); // Using alloy format_units assert_eq!(find_field_text("Nonce"), Some("0".to_string())); assert_eq!(find_field_text("Gas Limit"), Some("21000".to_string())); + assert_eq!( + find_field_text("Max Fee Per Gas"), + Some("23000000000 wei".to_string()) + ); + assert_eq!( + find_field_text("Max Priority Fee Per Gas"), + Some("20000000000 wei".to_string()) + ); } #[test] - fn test_alloy_rlp_pattern() { - // Test the pattern described in the user request - let my_tx = PartialEthereumTransaction { - chain_id: U256::from(17u64), - nonce: U256::ZERO, - gas_price: U256::from(3u64), - gas_tip: U256::from(20u64), - gas_limit: U256::from(21000u64), - to: Address::from_slice( - &hex::decode("0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266").unwrap(), - ), - value: U256::from(1u64), - data: Bytes::new(), - access_list: Vec::new(), + fn test_new_constructors() { + // Test default constructor + let default_tx = Eip7702TransactionWrapper::new(); + assert_eq!(default_tx.inner.chain_id, ChainId::from(1u64)); + assert_eq!(default_tx.inner.nonce, 0); + assert_eq!(default_tx.inner.gas_limit, 0); + assert_eq!(default_tx.inner.max_fee_per_gas, 0); + assert_eq!(default_tx.inner.max_priority_fee_per_gas, 0); + assert_eq!(default_tx.inner.to, Address::ZERO); + assert_eq!(default_tx.inner.value, U256::ZERO); + + // Test from_inner constructor + let test_inner = TxEip7702 { + chain_id: ChainId::from(42u64), + nonce: 5, + gas_limit: 30000, + max_fee_per_gas: 50_000_000_000u128, + max_priority_fee_per_gas: 10_000_000_000u128, + to: Address::from([1u8; 20]), + value: U256::from(1000u64), + input: Bytes::from(vec![0x12, 0x34]), + access_list: Default::default(), + authorization_list: Default::default(), + }; + let from_inner_tx = Eip7702TransactionWrapper::from_inner(test_inner.clone()); + assert_eq!(from_inner_tx.inner, test_inner); + + // Test from_bytes constructor + let encoded = from_inner_tx.encode_partial(); + let from_bytes_tx = Eip7702TransactionWrapper::from_bytes(&encoded).unwrap(); + assert_eq!(from_bytes_tx, from_inner_tx); + } + + #[test] + fn test_eip7702_rlp_pattern() { + // Test the EIP-7702 transaction encoding/decoding pattern + let my_tx = Eip7702TransactionWrapper { + inner: TxEip7702 { + chain_id: ChainId::from(17u64), + nonce: 0, + max_fee_per_gas: 23_000_000_000u128, + max_priority_fee_per_gas: 20_000_000_000u128, + gas_limit: 21000, + to: Address::from_slice( + &hex::decode("f39Fd6e51aad88F6F4ce6aB8827279cffFb92266").unwrap(), + ), + value: U256::from(1u64), + input: Bytes::new(), + access_list: Default::default(), + authorization_list: Default::default(), + }, + parsing_mode: TransactionParsingMode::Eip7702, }; let mut buffer = Vec::::new(); - let _encoded_size = my_tx.encode(&mut buffer); - let decoded = PartialEthereumTransaction::decode(&mut buffer.as_slice()).unwrap(); + my_tx.inner.encode(&mut buffer); + let decoded_inner = TxEip7702::decode(&mut buffer.as_slice()).unwrap(); + let decoded = Eip7702TransactionWrapper { + inner: decoded_inner, + parsing_mode: TransactionParsingMode::Eip7702, + }; assert_eq!(my_tx, decoded); } + + #[test] + fn test_legacy_partial_transaction_fallback() { + // Test that the original hex fixture now works with legacy fallback + let legacy_hex = "0xdf11800314825208941d0dd7a303374bd5f8c57bd8d16e52316d3bfe740180c0"; + + // This should now work with the legacy fallback + let result = decode_eip7702_transaction_from_hex(legacy_hex); + assert!( + result.is_ok(), + "Legacy transaction should decode successfully with fallback" + ); + + let decoded_tx = result.unwrap(); + assert_eq!( + decoded_tx.parsing_mode, + TransactionParsingMode::CustomPartial + ); + + // Verify the transaction type in visual sign payload + let options = VisualSignOptions { + decode_transfers: true, + transaction_name: None, + partial_parsing: true, + }; + + let payload = decoded_tx.to_visual_sign_payload(options); + + // Should indicate it was converted from custom partial format + let find_field_text = |label: &str| -> Option { + payload.fields.iter().find_map(|f| match f { + SignablePayloadField::TextV2 { common, text_v2 } if common.label == label => { + Some(text_v2.text.clone()) + } + _ => None, + }) + }; + + assert_eq!( + find_field_text("Transaction Type"), + Some("Custom Partial Transaction (converted to EIP-7702)".to_string()) + ); + assert_eq!(payload.title, "Custom Partial Ethereum Transaction"); + } } diff --git a/src/chain_parsers/visualsign-ethereum/tests/lib_test.rs b/src/chain_parsers/visualsign-ethereum/tests/lib_test.rs index d6228efb..9671402d 100644 --- a/src/chain_parsers/visualsign-ethereum/tests/lib_test.rs +++ b/src/chain_parsers/visualsign-ethereum/tests/lib_test.rs @@ -2,7 +2,7 @@ use alloy_primitives::hex; use std::fs; use std::path::PathBuf; use visualsign::vsptrait::VisualSignOptions; -use visualsign_ethereum::partial_transaction::PartialEthereumTransaction; +use visualsign_ethereum::partial_transaction::Eip7702TransactionWrapper; use visualsign_ethereum::transaction_string_to_visual_sign; // Helper function to get fixture path @@ -146,7 +146,7 @@ fn test_ethereum_charset_validation() { #[test] fn test_empty_data_errors() { // Test that empty data returns an error instead of made-up values - let empty_result = PartialEthereumTransaction::decode_partial(&[]); + let empty_result = Eip7702TransactionWrapper::decode_partial(&[]); assert!( empty_result.is_err(), "Empty data should return error, not made-up values" @@ -154,7 +154,7 @@ fn test_empty_data_errors() { // Test that minimal invalid data returns error instead of made-up values let minimal_data = vec![0x01, 0x02]; - let minimal_result = PartialEthereumTransaction::decode_partial(&minimal_data); + let minimal_result = Eip7702TransactionWrapper::decode_partial(&minimal_data); assert!( minimal_result.is_err(), "Minimal invalid data should return error, not made-up values" @@ -162,7 +162,7 @@ fn test_empty_data_errors() { // Test that completely invalid data returns error let invalid_data = vec![0xff; 10]; // All 0xff bytes - let invalid_result = PartialEthereumTransaction::decode_partial(&invalid_data); + let invalid_result = Eip7702TransactionWrapper::decode_partial(&invalid_data); assert!( invalid_result.is_err(), "Invalid data should return error, not made-up values" @@ -172,29 +172,45 @@ fn test_empty_data_errors() { } #[test] -fn test_original_partial_transaction() { - // Test the original failing transaction - let hex_data = "0xdf1180031482520894f39Fd6e51aad88F6F4ce6aB8827279cffFb922660180c0"; - let clean_hex = hex_data.strip_prefix("0x").unwrap(); - let bytes = hex::decode(clean_hex).expect("Valid hex"); - - println!("Testing transaction: {}", hex_data); - println!("Bytes: {:?}", bytes); - println!("First byte: 0x{:02x} ({})", bytes[0], bytes[0]); - println!("Length: {}", bytes.len()); - - let result = PartialEthereumTransaction::decode_partial(&bytes); +fn test_eip7702_transaction_roundtrip() { + // Test that we can create, encode, and decode an EIP-7702 transaction + use visualsign_ethereum::partial_transaction::Eip7702TransactionWrapper; + use alloy_consensus::TxEip7702; + use alloy_primitives::{ChainId, Address, U256, Bytes}; + + // Create a valid EIP-7702 transaction + let original_tx = Eip7702TransactionWrapper::from_inner(TxEip7702 { + chain_id: ChainId::from(17u64), + nonce: 0, + max_fee_per_gas: 23_000_000_000u128, + max_priority_fee_per_gas: 20_000_000_000u128, + gas_limit: 21000, + to: Address::from_slice(&hex::decode("f39Fd6e51aad88F6F4ce6aB8827279cffFb92266").unwrap()), + value: U256::from(1u64), + input: Bytes::new(), + access_list: Default::default(), + authorization_list: Default::default(), + }); + + // Encode it + let encoded = original_tx.encode_partial(); + let hex_data = format!("0x{}", hex::encode(&encoded)); + + println!("Testing EIP-7702 transaction: {}", hex_data); + println!("Encoded length: {}", encoded.len()); + + // Decode it back + let result = Eip7702TransactionWrapper::decode_partial(&encoded); println!("Decode result: {:?}", result); - - // This should succeed, not error - assert!( - result.is_ok(), - "Original partial transaction should decode successfully" - ); + assert!(result.is_ok(), "EIP-7702 transaction should decode successfully"); // Also test the hex function directly - use visualsign_ethereum::partial_transaction::decode_partial_transaction_from_hex; - let hex_result = decode_partial_transaction_from_hex(hex_data); + use visualsign_ethereum::partial_transaction::decode_eip7702_transaction_from_hex; + let hex_result = decode_eip7702_transaction_from_hex(&hex_data); println!("Hex decode result: {:?}", hex_result); assert!(hex_result.is_ok(), "Hex decode should also work"); + + // Verify they're equal + assert_eq!(original_tx, result.unwrap()); + assert_eq!(original_tx, hex_result.unwrap()); }