diff --git a/src/chain_parsers/visualsign-ethereum/src/abi_decoder.rs b/src/chain_parsers/visualsign-ethereum/src/abi_decoder.rs index aa0a963a..b7bfdb90 100644 --- a/src/chain_parsers/visualsign-ethereum/src/abi_decoder.rs +++ b/src/chain_parsers/visualsign-ethereum/src/abi_decoder.rs @@ -45,18 +45,26 @@ fn decode_solidity_value(ty: &str, data: &[u8], offset: &mut usize) -> String { } else if ty == "address[]" { // Dynamic address arrays - offset points to location of array if *offset + 32 <= data.len() { - let array_offset = U256::from_be_bytes(data[*offset..*offset + 32].try_into().unwrap_or([0; 32])); + let array_offset = + U256::from_be_bytes(data[*offset..*offset + 32].try_into().unwrap_or([0; 32])); *offset += 32; // Read array length at the offset let array_offset_usize = array_offset.try_into().unwrap_or(0usize); if array_offset_usize + 32 <= data.len() { - let array_len_val = U256::from_be_bytes(data[array_offset_usize..array_offset_usize + 32].try_into().unwrap_or([0; 32])); + let array_len_val = U256::from_be_bytes( + data[array_offset_usize..array_offset_usize + 32] + .try_into() + .unwrap_or([0; 32]), + ); let array_len: usize = array_len_val.try_into().unwrap_or(0); let mut addresses = Vec::new(); for i in 0..array_len { - let addr_offset_val: usize = (U256::from(array_offset_usize) + U256::from(32) + U256::from(i * 32)).try_into().unwrap_or(0); + let addr_offset_val: usize = + (U256::from(array_offset_usize) + U256::from(32) + U256::from(i * 32)) + .try_into() + .unwrap_or(0); if addr_offset_val + 32 <= data.len() { let addr_bytes = &data[addr_offset_val + 12..addr_offset_val + 32]; // Take last 20 bytes addresses.push(format!("0x{}", hex::encode(addr_bytes))); @@ -73,7 +81,8 @@ fn decode_solidity_value(ty: &str, data: &[u8], offset: &mut usize) -> String { } else if ty.ends_with("[]") { // Other dynamic arrays - just show offset for now if *offset + 32 <= data.len() { - let array_offset_val = U256::from_be_bytes(data[*offset..*offset + 32].try_into().unwrap_or([0; 32])); + let array_offset_val = + U256::from_be_bytes(data[*offset..*offset + 32].try_into().unwrap_or([0; 32])); *offset += 32; return format!("(dynamic array at offset {})", array_offset_val); } @@ -102,9 +111,7 @@ impl AbiDecoder { /// Finds a function by its 4-byte selector fn find_function_by_selector(&self, selector: &[u8; 4]) -> Option<&Function> { - self.abi - .functions() - .find(|f| &f.selector() == selector) + self.abi.functions().find(|f| &f.selector() == selector) } /// Decodes a function call from calldata diff --git a/src/chain_parsers/visualsign-ethereum/src/abi_registry.rs b/src/chain_parsers/visualsign-ethereum/src/abi_registry.rs index c794caeb..b7c73c5e 100644 --- a/src/chain_parsers/visualsign-ethereum/src/abi_registry.rs +++ b/src/chain_parsers/visualsign-ethereum/src/abi_registry.rs @@ -68,7 +68,11 @@ impl AbiRegistry { /// const ABI_JSON: &str = include_str!("abi.json"); /// registry.register_abi("MyContract", ABI_JSON)?; /// ``` - pub fn register_abi(&mut self, name: &str, abi_json: &str) -> Result<(), Box> { + pub fn register_abi( + &mut self, + name: &str, + abi_json: &str, + ) -> Result<(), Box> { let abi = serde_json::from_str::(abi_json)?; Arc::get_mut(&mut self.abis) .expect("ABI map should be mutable") @@ -155,7 +159,9 @@ mod tests { #[test] fn test_register_and_retrieve_abi() { let mut registry = AbiRegistry::new(); - registry.register_abi("TestToken", TEST_ABI).expect("Failed to register ABI"); + registry + .register_abi("TestToken", TEST_ABI) + .expect("Failed to register ABI"); let abi = registry.get_abi("TestToken"); assert!(abi.is_some()); @@ -171,7 +177,9 @@ mod tests { #[test] fn test_address_mapping() { let mut registry = AbiRegistry::new(); - registry.register_abi("TestToken", TEST_ABI).expect("Failed to register ABI"); + registry + .register_abi("TestToken", TEST_ABI) + .expect("Failed to register ABI"); let addr = "0x1234567890123456789012345678901234567890" .parse::
() @@ -196,7 +204,9 @@ mod tests { #[test] fn test_different_chains_separate() { let mut registry = AbiRegistry::new(); - registry.register_abi("TestToken", TEST_ABI).expect("Failed to register ABI"); + registry + .register_abi("TestToken", TEST_ABI) + .expect("Failed to register ABI"); let addr = "0x1234567890123456789012345678901234567890" .parse::
() @@ -214,8 +224,12 @@ mod tests { #[test] fn test_list_abis() { let mut registry = AbiRegistry::new(); - registry.register_abi("TokenA", TEST_ABI).expect("Failed to register"); - registry.register_abi("TokenB", TEST_ABI).expect("Failed to register"); + registry + .register_abi("TokenA", TEST_ABI) + .expect("Failed to register"); + registry + .register_abi("TokenB", TEST_ABI) + .expect("Failed to register"); let abis = registry.list_abis(); assert_eq!(abis.len(), 2); diff --git a/src/chain_parsers/visualsign-ethereum/src/contracts/core/dynamic_abi.rs b/src/chain_parsers/visualsign-ethereum/src/contracts/core/dynamic_abi.rs index d27e5b1a..ce375cfc 100644 --- a/src/chain_parsers/visualsign-ethereum/src/contracts/core/dynamic_abi.rs +++ b/src/chain_parsers/visualsign-ethereum/src/contracts/core/dynamic_abi.rs @@ -34,9 +34,7 @@ impl CalldataVisualizer for DynamicAbiVisualizer { chain_id: u64, registry: Option<&ContractRegistry>, ) -> Option { - self.decoder - .visualize(calldata, chain_id, registry) - .ok() + self.decoder.visualize(calldata, chain_id, registry).ok() } } diff --git a/src/chain_parsers/visualsign-ethereum/src/embedded_abis.rs b/src/chain_parsers/visualsign-ethereum/src/embedded_abis.rs index 1c535e7d..871e3df3 100644 --- a/src/chain_parsers/visualsign-ethereum/src/embedded_abis.rs +++ b/src/chain_parsers/visualsign-ethereum/src/embedded_abis.rs @@ -175,7 +175,8 @@ mod tests { #[test] fn test_parse_abi_address_mapping_valid() { - let result = parse_abi_address_mapping("TestToken:0x1234567890123456789012345678901234567890"); + let result = + parse_abi_address_mapping("TestToken:0x1234567890123456789012345678901234567890"); assert!(result.is_some()); let (name, _addr) = result.unwrap(); assert_eq!(name, "TestToken"); @@ -198,7 +199,9 @@ mod tests { let mut registry = AbiRegistry::new(); register_embedded_abi(&mut registry, "TestToken", TEST_ABI).unwrap(); - let address: Address = "0x1234567890123456789012345678901234567890".parse().unwrap(); + let address: Address = "0x1234567890123456789012345678901234567890" + .parse() + .unwrap(); map_abi_address(&mut registry, 1, address, "TestToken"); // Verify it was mapped @@ -234,8 +237,12 @@ mod tests { register_embedded_abi(&mut registry, "ExtendedToken", MULTI_ABI).unwrap(); // Map addresses on different chains - let addr1: Address = "0x1111111111111111111111111111111111111111".parse().unwrap(); - let addr2: Address = "0x2222222222222222222222222222222222222222".parse().unwrap(); + let addr1: Address = "0x1111111111111111111111111111111111111111" + .parse() + .unwrap(); + let addr2: Address = "0x2222222222222222222222222222222222222222" + .parse() + .unwrap(); map_abi_address(&mut registry, 1, addr1, "SimpleToken"); map_abi_address(&mut registry, 1, addr2, "ExtendedToken"); @@ -254,7 +261,9 @@ mod tests { assert_ne!(abi1_on_mainnet, abi2_on_mainnet); // Verify unmapped addresses return None - let unmapped: Address = "0x9999999999999999999999999999999999999999".parse().unwrap(); + let unmapped: Address = "0x9999999999999999999999999999999999999999" + .parse() + .unwrap(); assert!(registry.get_abi_for_address(1, unmapped).is_none()); } @@ -264,7 +273,7 @@ mod tests { let mapping_strs = vec![ "Token1:0x1111111111111111111111111111111111111111", "Token2:0x2222222222222222222222222222222222222222", - "InvalidFormat", // Invalid mapping + "InvalidFormat", // Invalid mapping "Token3:0x3333333333333333333333333333333333333333", ]; diff --git a/src/chain_parsers/visualsign-ethereum/src/grpc_abi.rs b/src/chain_parsers/visualsign-ethereum/src/grpc_abi.rs index 54dd1197..1d6b43dd 100644 --- a/src/chain_parsers/visualsign-ethereum/src/grpc_abi.rs +++ b/src/chain_parsers/visualsign-ethereum/src/grpc_abi.rs @@ -4,7 +4,7 @@ //! using optional secp256k1 signatures. use crate::abi_registry::AbiRegistry; -use crate::embedded_abis::{register_embedded_abi, AbiEmbeddingError}; +use crate::embedded_abis::{AbiEmbeddingError, register_embedded_abi}; /// Error type for gRPC ABI operations #[derive(Debug, thiserror::Error)] @@ -133,7 +133,12 @@ mod tests { let registry = result.unwrap(); // Verify ABI was registered - assert!(registry.list_abis().iter().any(|name| *name == "wallet_provided")); + assert!( + registry + .list_abis() + .iter() + .any(|name| *name == "wallet_provided") + ); } #[test] diff --git a/src/chain_parsers/visualsign-ethereum/src/lib.rs b/src/chain_parsers/visualsign-ethereum/src/lib.rs index 53747153..8d83daac 100644 --- a/src/chain_parsers/visualsign-ethereum/src/lib.rs +++ b/src/chain_parsers/visualsign-ethereum/src/lib.rs @@ -331,9 +331,10 @@ fn convert_to_visual_sign_payload( let chain_id = transaction.chain_id(); // Try to extract AbiRegistry from options - let abi_registry = options.abi_registry.as_ref().and_then(|any_reg| { - any_reg.downcast_ref::() - }); + let abi_registry = options + .abi_registry + .as_ref() + .and_then(|any_reg| any_reg.downcast_ref::()); let chain_name = chains::get_chain_name(chain_id); @@ -450,6 +451,45 @@ fn convert_to_visual_sign_payload( input_fields.push(field); } } + // Check if this is an Aave V3 Pool contract and visualize it + else if contract_type + == crate::protocols::aave::config::AaveV3PoolContract::short_type_id() + { + if let Some(field) = (protocols::aave::PoolVisualizer::new()) + .visualize_pool_operation(input, chain_id_val, Some(registry)) + { + input_fields.push(field); + } + } + } + + // Check for Aave governance contracts by address + if let Some(to_addr) = transaction.to() { + // Check if this is the AAVE token (delegation) + if let Some(aave_token) = + protocols::aave::config::AaveV3Config::aave_token_address() + { + if to_addr == aave_token { + if let Some(field) = (protocols::aave::AaveTokenVisualizer) + .visualize_governance(input, chain_id_val, Some(registry)) + { + input_fields.push(field); + } + } + } + + // Check if this is a VotingMachine contract + if let Some(voting_machine) = + protocols::aave::config::AaveV3Config::voting_machine_address(chain_id_val) + { + if to_addr == voting_machine { + if let Some(field) = (protocols::aave::VotingMachineVisualizer) + .visualize_vote(input, chain_id_val, Some(registry)) + { + input_fields.push(field); + } + } + } } } } @@ -679,6 +719,7 @@ mod tests { decode_transfers: false, transaction_name: Some("Custom Transaction Title".to_string()), metadata: None, + abi_registry: None, }; let payload = transaction_to_visual_sign(tx, options).unwrap(); @@ -905,6 +946,7 @@ mod tests { decode_transfers: true, transaction_name: Some("Test Transaction".to_string()), metadata: None, + abi_registry: None, } ), Ok(SignablePayload::new( diff --git a/src/chain_parsers/visualsign-ethereum/src/protocols/aave/config.rs b/src/chain_parsers/visualsign-ethereum/src/protocols/aave/config.rs new file mode 100644 index 00000000..787cfa53 --- /dev/null +++ b/src/chain_parsers/visualsign-ethereum/src/protocols/aave/config.rs @@ -0,0 +1,382 @@ +use crate::registry::{ContractRegistry, ContractType}; +use crate::token_metadata::{ErcStandard, TokenMetadata}; +use alloy_primitives::Address; + +/// Aave v3 Pool contract type identifier +pub struct AaveV3PoolContract; + +impl ContractType for AaveV3PoolContract { + fn short_type_id() -> &'static str { + "aave_v3_pool" + } +} + +/// Configuration for Aave v3 protocol contracts +/// +/// Source: https://docs.aave.com/developers/deployed-contracts/v3-mainnet +pub struct AaveV3Config; + +impl AaveV3Config { + /// Returns the Aave v3 Pool contract address for the given chain + pub fn pool_address(chain_id: u64) -> Option
{ + match chain_id { + // Ethereum Mainnet + 1 => Some( + "0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2" + .parse() + .unwrap(), + ), + // Polygon + 137 => Some( + "0x794a61358D6845594F94dc1DB02A252b5b4814aD" + .parse() + .unwrap(), + ), + // Arbitrum + 42161 => Some( + "0x794a61358D6845594F94dc1DB02A252b5b4814aD" + .parse() + .unwrap(), + ), + // Optimism + 10 => Some( + "0x794a61358D6845594F94dc1DB02A252b5b4814aD" + .parse() + .unwrap(), + ), + // Base + 8453 => Some( + "0xA238Dd80C259a72e81d7e4664a9801593F98d1c5" + .parse() + .unwrap(), + ), + // Avalanche + 43114 => Some( + "0x794a61358D6845594F94dc1DB02A252b5b4814aD" + .parse() + .unwrap(), + ), + // Gnosis + 100 => Some( + "0xb50201558B00496A145fE76f7424749556E326D8" + .parse() + .unwrap(), + ), + // BNB Chain + 56 => Some( + "0x6807dc923806fE8Fd134338EABCA509979a7e0cB" + .parse() + .unwrap(), + ), + _ => None, + } + } + + /// Returns the AAVE governance token address + /// + /// The AAVE token only exists on Ethereum mainnet. + /// Source: https://github.com/bgd-labs/aave-governance-v3 + pub fn aave_token_address() -> Option
{ + Some( + "0x7Fc66500c84A76Ad7e9c93437bFc5Ac33E2DDaE9" + .parse() + .unwrap(), + ) + } + + /// Returns the VotingMachine contract address for the given chain + /// + /// VotingMachine contracts enable cross-chain voting using storage proofs. + /// Source: https://github.com/bgd-labs/aave-governance-v3 + pub fn voting_machine_address(chain_id: u64) -> Option
{ + match chain_id { + 1 => Some( + "0x617332a777780F546261247F621051d0b98975Eb" + .parse() + .unwrap(), + ), + 137 => Some( + "0xc8a2ADC4261c6b669CdFf69E717E77C9cFeB420d" + .parse() + .unwrap(), + ), + 43114 => Some( + "0x9b6f5ef589A3DD08670Dd146C11C4Fb33E04494F" + .parse() + .unwrap(), + ), + _ => None, + } + } + + /// Returns the list of chain IDs where Aave v3 is deployed + pub fn supported_chains() -> &'static [u64] { + &[ + 1, // Ethereum Mainnet + 137, // Polygon + 42161, // Arbitrum + 10, // Optimism + 8453, // Base + 43114, // Avalanche + 100, // Gnosis + 56, // BNB Chain + ] + } + + /// Registers Aave v3 protocol contracts in the registry + pub fn register_contracts(registry: &mut ContractRegistry) { + for &chain_id in Self::supported_chains() { + if let Some(pool_address) = Self::pool_address(chain_id) { + registry + .register_contract_typed::(chain_id, vec![pool_address]); + } + } + + // Register common tokens used with Aave + Self::register_common_tokens(registry); + } + + /// Registers common tokens used in Aave transactions + /// + /// This registers tokens like USDC, USDT, DAI, and WETH across multiple chains + /// so they can be resolved by symbol during transaction visualization. + pub fn register_common_tokens(registry: &mut ContractRegistry) { + // Ethereum Mainnet tokens + registry.register_token( + 1, + TokenMetadata { + symbol: "USDC".to_string(), + name: "USD Coin".to_string(), + erc_standard: ErcStandard::Erc20, + contract_address: "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48".to_string(), + decimals: 6, + }, + ); + + registry.register_token( + 1, + TokenMetadata { + symbol: "USDT".to_string(), + name: "Tether USD".to_string(), + erc_standard: ErcStandard::Erc20, + contract_address: "0xdac17f958d2ee523a2206206994597c13d831ec7".to_string(), + decimals: 6, + }, + ); + + registry.register_token( + 1, + TokenMetadata { + symbol: "DAI".to_string(), + name: "Dai Stablecoin".to_string(), + erc_standard: ErcStandard::Erc20, + contract_address: "0x6b175474e89094c44da98b954eedeac495271d0f".to_string(), + decimals: 18, + }, + ); + + registry.register_token( + 1, + TokenMetadata { + symbol: "WETH".to_string(), + name: "Wrapped Ether".to_string(), + erc_standard: ErcStandard::Erc20, + contract_address: "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2".to_string(), + decimals: 18, + }, + ); + + // Base tokens + registry.register_token( + 8453, + TokenMetadata { + symbol: "USDC".to_string(), + name: "USD Coin".to_string(), + erc_standard: ErcStandard::Erc20, + contract_address: "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913".to_string(), + decimals: 6, + }, + ); + + registry.register_token( + 8453, + TokenMetadata { + symbol: "WETH".to_string(), + name: "Wrapped Ether".to_string(), + erc_standard: ErcStandard::Erc20, + contract_address: "0x4200000000000000000000000000000000000006".to_string(), + decimals: 18, + }, + ); + + // Polygon tokens + registry.register_token( + 137, + TokenMetadata { + symbol: "USDC".to_string(), + name: "USD Coin".to_string(), + erc_standard: ErcStandard::Erc20, + contract_address: "0x2791bca1f2de4661ed88a30c99a7a9449aa84174".to_string(), + decimals: 6, + }, + ); + + registry.register_token( + 137, + TokenMetadata { + symbol: "USDT".to_string(), + name: "Tether USD".to_string(), + erc_standard: ErcStandard::Erc20, + contract_address: "0xc2132d05d31c914a87c6611c10748aeb04b58e8f".to_string(), + decimals: 6, + }, + ); + + registry.register_token( + 137, + TokenMetadata { + symbol: "DAI".to_string(), + name: "Dai Stablecoin".to_string(), + erc_standard: ErcStandard::Erc20, + contract_address: "0x8f3cf7ad23cd3cadbd9735aff958023239c6a063".to_string(), + decimals: 18, + }, + ); + + registry.register_token( + 137, + TokenMetadata { + symbol: "WETH".to_string(), + name: "Wrapped Ether".to_string(), + erc_standard: ErcStandard::Erc20, + contract_address: "0x7ceb23fd6bc0add59e62ac25578270cff1b9f619".to_string(), + decimals: 18, + }, + ); + + // Arbitrum tokens + registry.register_token( + 42161, + TokenMetadata { + symbol: "USDC".to_string(), + name: "USD Coin".to_string(), + erc_standard: ErcStandard::Erc20, + contract_address: "0xaf88d065e77c8cc2239327c5edb3a432268e5831".to_string(), + decimals: 6, + }, + ); + + registry.register_token( + 42161, + TokenMetadata { + symbol: "USDT".to_string(), + name: "Tether USD".to_string(), + erc_standard: ErcStandard::Erc20, + contract_address: "0xfd086bc7cd5c481dcc9c85ebe478a1c0b69fcbb9".to_string(), + decimals: 6, + }, + ); + + registry.register_token( + 42161, + TokenMetadata { + symbol: "DAI".to_string(), + name: "Dai Stablecoin".to_string(), + erc_standard: ErcStandard::Erc20, + contract_address: "0xda10009cbd5d07dd0cecc66161fc93d7c9000da1".to_string(), + decimals: 18, + }, + ); + + registry.register_token( + 42161, + TokenMetadata { + symbol: "WETH".to_string(), + name: "Wrapped Ether".to_string(), + erc_standard: ErcStandard::Erc20, + contract_address: "0x82af49447d8a07e3bd95bd0d56f35241523fbab1".to_string(), + decimals: 18, + }, + ); + + // Optimism tokens + registry.register_token( + 10, + TokenMetadata { + symbol: "USDC".to_string(), + name: "USD Coin".to_string(), + erc_standard: ErcStandard::Erc20, + contract_address: "0x0b2c639c533813f4aa9d7837caf62653d097ff85".to_string(), + decimals: 6, + }, + ); + + registry.register_token( + 10, + TokenMetadata { + symbol: "USDT".to_string(), + name: "Tether USD".to_string(), + erc_standard: ErcStandard::Erc20, + contract_address: "0x94b008aa00579c1307b0ef2c499ad98a8ce58e58".to_string(), + decimals: 6, + }, + ); + + registry.register_token( + 10, + TokenMetadata { + symbol: "DAI".to_string(), + name: "Dai Stablecoin".to_string(), + erc_standard: ErcStandard::Erc20, + contract_address: "0xda10009cbd5d07dd0cecc66161fc93d7c9000da1".to_string(), + decimals: 18, + }, + ); + + registry.register_token( + 10, + TokenMetadata { + symbol: "WETH".to_string(), + name: "Wrapped Ether".to_string(), + erc_standard: ErcStandard::Erc20, + contract_address: "0x4200000000000000000000000000000000000006".to_string(), + decimals: 18, + }, + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_pool_addresses() { + // Ethereum mainnet + let eth_pool = AaveV3Config::pool_address(1).unwrap(); + assert_eq!( + format!("{:?}", eth_pool).to_lowercase(), + "0x87870bca3f3fd6335c3f4ce8392d69350b4fa4e2" + ); + + // Base + let base_pool = AaveV3Config::pool_address(8453).unwrap(); + assert_eq!( + format!("{:?}", base_pool).to_lowercase(), + "0xa238dd80c259a72e81d7e4664a9801593f98d1c5" + ); + } + + #[test] + fn test_unsupported_chain() { + assert!(AaveV3Config::pool_address(999999).is_none()); + } + + #[test] + fn test_supported_chains() { + let chains = AaveV3Config::supported_chains(); + assert!(chains.contains(&1)); // Ethereum + assert!(chains.contains(&8453)); // Base + assert!(chains.len() >= 8); + } +} diff --git a/src/chain_parsers/visualsign-ethereum/src/protocols/aave/contracts/aave_token.rs b/src/chain_parsers/visualsign-ethereum/src/protocols/aave/contracts/aave_token.rs new file mode 100644 index 00000000..b1ea6ed9 --- /dev/null +++ b/src/chain_parsers/visualsign-ethereum/src/protocols/aave/contracts/aave_token.rs @@ -0,0 +1,247 @@ +use alloy_sol_types::{SolCall as _, sol}; +use visualsign::{ + AnnotatedPayloadField, SignablePayloadField, SignablePayloadFieldCommon, + SignablePayloadFieldListLayout, SignablePayloadFieldPreviewLayout, SignablePayloadFieldTextV2, +}; + +use crate::registry::ContractRegistry; + +// Aave Governance Token interface definitions +// +// Official Documentation: +// - Technical Reference: https://docs.aave.com/governance +// - Contract Source: https://github.com/bgd-labs/aave-governance-v3 +sol! { + interface IAaveToken { + function delegate(address delegatee) external; + function delegateByType(address delegatee, uint8 delegationType) external; + } +} + +pub struct AaveTokenVisualizer; + +impl AaveTokenVisualizer { + pub fn visualize_governance( + &self, + input: &[u8], + _chain_id: u64, + _registry: Option<&ContractRegistry>, + ) -> Option { + if input.len() < 4 { + return None; + } + + // Try delegate + if let Ok(call) = IAaveToken::delegateCall::abi_decode(input) { + return Self::decode_delegate(&call); + } + + // Try delegateByType + if let Ok(call) = IAaveToken::delegateByTypeCall::abi_decode(input) { + return Self::decode_delegate_by_type(&call); + } + + None + } + + fn decode_delegate(call: &IAaveToken::delegateCall) -> Option { + let delegatee_str = format!("{:?}", call.delegatee); + let summary = format!("Delegate all governance power to {}", delegatee_str); + + let fields = vec![ + AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: delegatee_str.clone(), + label: "Delegatee".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: delegatee_str, + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: "Voting + Proposition".to_string(), + label: "Powers Delegated".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: "Voting + Proposition".to_string(), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + ]; + + Some(SignablePayloadField::PreviewLayout { + common: SignablePayloadFieldCommon { + fallback_text: summary.clone(), + label: "Aave Governance".to_string(), + }, + preview_layout: SignablePayloadFieldPreviewLayout { + title: Some(SignablePayloadFieldTextV2 { + text: "Aave Governance Delegation".to_string(), + }), + subtitle: Some(SignablePayloadFieldTextV2 { text: summary }), + condensed: None, + expanded: Some(SignablePayloadFieldListLayout { fields }), + }, + }) + } + + fn decode_delegate_by_type( + call: &IAaveToken::delegateByTypeCall, + ) -> Option { + let delegatee_str = format!("{:?}", call.delegatee); + let power_type = match call.delegationType { + 0 => "Voting Power", + 1 => "Proposition Power", + _ => "Unknown Power", + }; + + let summary = format!("Delegate {} to {}", power_type, delegatee_str); + + let fields = vec![ + AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: delegatee_str.clone(), + label: "Delegatee".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: delegatee_str, + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: power_type.to_string(), + label: "Power Type".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: power_type.to_string(), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + ]; + + Some(SignablePayloadField::PreviewLayout { + common: SignablePayloadFieldCommon { + fallback_text: summary.clone(), + label: "Aave Governance".to_string(), + }, + preview_layout: SignablePayloadFieldPreviewLayout { + title: Some(SignablePayloadFieldTextV2 { + text: "Aave Governance Delegation".to_string(), + }), + subtitle: Some(SignablePayloadFieldTextV2 { text: summary }), + condensed: None, + expanded: Some(SignablePayloadFieldListLayout { fields }), + }, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use alloy_primitives::address; + + #[test] + fn test_decode_delegate() { + let call = IAaveToken::delegateCall { + delegatee: address!("0742d35Cc6634C0532925a3b844Bc9e7595f0bEb"), + }; + + let input = IAaveToken::delegateCall::abi_encode(&call); + let result = AaveTokenVisualizer.visualize_governance(&input, 1, None); + + assert!(result.is_some(), "Should decode delegate successfully"); + + if let Some(SignablePayloadField::PreviewLayout { + common, + preview_layout, + }) = result + { + assert_eq!(common.label, "Aave Governance"); + assert!( + common + .fallback_text + .to_lowercase() + .contains("0742d35cc6634c0532925a3b844bc9e7595f0beb") + ); + assert!(preview_layout.subtitle.is_some()); + } else { + panic!("Expected PreviewLayout"); + } + } + + #[test] + fn test_decode_delegate_by_type_voting() { + let call = IAaveToken::delegateByTypeCall { + delegatee: address!("0742d35Cc6634C0532925a3b844Bc9e7595f0bEb"), + delegationType: 0, // Voting power + }; + + let input = IAaveToken::delegateByTypeCall::abi_encode(&call); + let result = AaveTokenVisualizer.visualize_governance(&input, 1, None); + + assert!( + result.is_some(), + "Should decode delegateByType successfully" + ); + + if let Some(SignablePayloadField::PreviewLayout { + common, + preview_layout, + }) = result + { + assert_eq!(common.label, "Aave Governance"); + assert!(common.fallback_text.contains("Voting Power")); + assert!(preview_layout.subtitle.is_some()); + } else { + panic!("Expected PreviewLayout"); + } + } + + #[test] + fn test_decode_delegate_by_type_proposition() { + let call = IAaveToken::delegateByTypeCall { + delegatee: address!("0742d35Cc6634C0532925a3b844Bc9e7595f0bEb"), + delegationType: 1, // Proposition power + }; + + let input = IAaveToken::delegateByTypeCall::abi_encode(&call); + let result = AaveTokenVisualizer.visualize_governance(&input, 1, None); + + assert!( + result.is_some(), + "Should decode delegateByType successfully" + ); + + if let Some(SignablePayloadField::PreviewLayout { common, .. }) = result { + assert!(common.fallback_text.contains("Proposition Power")); + } else { + panic!("Expected PreviewLayout"); + } + } + + #[test] + fn test_invalid_input() { + let result = AaveTokenVisualizer.visualize_governance(&[], 1, None); + assert!(result.is_none(), "Should return None for empty input"); + + let invalid = vec![0xff, 0xff, 0xff, 0xff]; + let result = AaveTokenVisualizer.visualize_governance(&invalid, 1, None); + assert!(result.is_none(), "Should return None for invalid selector"); + } +} diff --git a/src/chain_parsers/visualsign-ethereum/src/protocols/aave/contracts/l2_pool.rs b/src/chain_parsers/visualsign-ethereum/src/protocols/aave/contracts/l2_pool.rs new file mode 100644 index 00000000..047f1e20 --- /dev/null +++ b/src/chain_parsers/visualsign-ethereum/src/protocols/aave/contracts/l2_pool.rs @@ -0,0 +1,1249 @@ +use alloy_primitives::Address; +use alloy_sol_types::{SolCall as _, sol}; +use chrono::{TimeZone, Utc}; +use visualsign::{ + AnnotatedPayloadField, SignablePayloadField, SignablePayloadFieldCommon, + SignablePayloadFieldListLayout, SignablePayloadFieldPreviewLayout, SignablePayloadFieldTextV2, +}; + +use crate::registry::ContractRegistry; + +// Aave v3 L2Pool interface - Layer 2 optimized version +// +// L2Pool uses packed bytes32 parameters to save gas costs on L2 networks. +// Deployed on: Arbitrum, Optimism, Polygon +// +// Encoding format: +// - Bits 0-15: Asset ID (uint16) - reserve ID from Aave protocol +// - Bits 16-143: Amount (uint128) - transaction amount +// - Bits 144-151: Interest Rate Mode (uint8) - only for borrow/repay (2 = Variable) +// +// Source: https://github.com/aave-dao/aave-v3-origin/blob/main/src/contracts/interfaces/IL2Pool.sol +sol! { + interface IL2Pool { + function supply(bytes32 args) external; + function withdraw(bytes32 args) external returns (uint256); + function borrow(bytes32 args) external; + function repay(bytes32 args) external returns (uint256); + function repayWithATokens(bytes32 args) external returns (uint256); + function liquidationCall(bytes32 args1, bytes32 args2) external; + function setUserUseReserveAsCollateral(bytes32 args) external; + + function supplyWithPermit(bytes32 args, bytes32 r, bytes32 s) external; + function repayWithPermit(bytes32 args, bytes32 r, bytes32 s) external returns (uint256); + } +} + +pub struct L2PoolVisualizer {} + +impl L2PoolVisualizer { + pub fn new() -> Self { + Self {} + } + + /// Visualizes Aave v3 L2Pool operations + /// + /// # Arguments + /// * `input` - The calldata bytes + /// * `chain_id` - The chain ID for registry lookups + /// * `registry` - Optional registry for resolving token symbols + pub fn visualize_l2pool_operation( + &self, + input: &[u8], + chain_id: u64, + registry: Option<&ContractRegistry>, + ) -> Option { + if input.len() < 4 { + return None; + } + + let selector = &input[0..4]; + + match selector { + _ if selector == IL2Pool::supplyCall::SELECTOR => { + Self::decode_l2_supply(input, chain_id, registry) + } + _ if selector == IL2Pool::withdrawCall::SELECTOR => { + Self::decode_l2_withdraw(input, chain_id, registry) + } + _ if selector == IL2Pool::borrowCall::SELECTOR => { + Self::decode_l2_borrow(input, chain_id, registry) + } + _ if selector == IL2Pool::repayCall::SELECTOR => { + Self::decode_l2_repay(input, chain_id, registry) + } + _ if selector == IL2Pool::repayWithATokensCall::SELECTOR => { + Self::decode_l2_repay_with_atokens(input, chain_id, registry) + } + _ if selector == IL2Pool::liquidationCallCall::SELECTOR => { + Self::decode_l2_liquidation_call(input, chain_id, registry) + } + _ if selector == IL2Pool::setUserUseReserveAsCollateralCall::SELECTOR => { + Self::decode_l2_set_user_use_reserve_as_collateral(input, chain_id, registry) + } + _ if selector == IL2Pool::supplyWithPermitCall::SELECTOR => { + Self::decode_l2_supply_with_permit(input, chain_id, registry) + } + _ if selector == IL2Pool::repayWithPermitCall::SELECTOR => { + Self::decode_l2_repay_with_permit(input, chain_id, registry) + } + _ => None, + } + } + + fn split_bytes32(args: [u8; 32]) -> (u128, u128) { + let mut lower_bytes = [0u8; 16]; + lower_bytes.copy_from_slice(&args[16..32]); + let lower = u128::from_be_bytes(lower_bytes); + + let mut upper_bytes = [0u8; 16]; + upper_bytes.copy_from_slice(&args[0..16]); + let upper = u128::from_be_bytes(upper_bytes); + + (lower, upper) + } + + fn decode_supply_params(args: [u8; 32]) -> (u16, u128, u16) { + let (lower, upper) = Self::split_bytes32(args); + + let asset_id = (lower & 0xFFFF) as u16; + let amount = (lower >> 16) as u128; + let referral_code = ((upper >> 16) & 0xFFFF) as u16; + + (asset_id, amount, referral_code) + } + + fn decode_withdraw_params(args: [u8; 32]) -> (u16, u128) { + let (lower, _upper) = Self::split_bytes32(args); + + let asset_id = (lower & 0xFFFF) as u16; + let amount = (lower >> 16) as u128; + + (asset_id, amount) + } + + fn decode_borrow_params(args: [u8; 32]) -> (u16, u128, u8, u16) { + let (lower, upper) = Self::split_bytes32(args); + + let asset_id = (lower & 0xFFFF) as u16; + let amount = (lower >> 16) as u128; + let interest_rate_mode = ((upper >> 16) & 0xFF) as u8; + let referral_code = ((upper >> 24) & 0xFFFF) as u16; + + (asset_id, amount, interest_rate_mode, referral_code) + } + + fn decode_repay_params(args: [u8; 32]) -> (u16, u128, u8) { + let (lower, upper) = Self::split_bytes32(args); + + let asset_id = (lower & 0xFFFF) as u16; + let amount = (lower >> 16) as u128; + let interest_rate_mode = ((upper >> 16) & 0xFF) as u8; + + (asset_id, amount, interest_rate_mode) + } + + fn decode_set_user_use_reserve_as_collateral_params(args: [u8; 32]) -> (u16, bool) { + let (lower, _upper) = Self::split_bytes32(args); + + let asset_id = (lower & 0xFFFF) as u16; + let use_as_collateral = ((lower >> 16) & 0x1) == 1; + + (asset_id, use_as_collateral) + } + + fn extract_supply_permit_deadline(args: [u8; 32]) -> u32 { + let mut deadline_bytes = [0u8; 4]; + deadline_bytes.copy_from_slice(&args[8..12]); + u32::from_be_bytes(deadline_bytes) + } + + fn extract_repay_permit_deadline(args: [u8; 32]) -> u32 { + let mut deadline_bytes = [0u8; 4]; + deadline_bytes.copy_from_slice(&args[9..13]); + u32::from_be_bytes(deadline_bytes) + } + + fn decode_liquidation_call_params( + args1: [u8; 32], + args2: [u8; 32], + ) -> (u16, u16, Address, u128, bool) { + let (lower1, _upper1) = Self::split_bytes32(args1); + let (lower2, upper2) = Self::split_bytes32(args2); + + let collateral_asset_id = (lower1 & 0xFFFF) as u16; + let debt_asset_id = ((lower1 >> 16) & 0xFFFF) as u16; + + let mut user_addr_bytes = [0u8; 20]; + user_addr_bytes.copy_from_slice(&args1[8..28]); + let user = Address::from_slice(&user_addr_bytes); + + let debt_to_cover = lower2; + let receive_atoken = ((upper2 >> 0) & 0x1) == 1; + + ( + collateral_asset_id, + debt_asset_id, + user, + debt_to_cover, + receive_atoken, + ) + } + + /// Maps L2Pool asset IDs to contract addresses for supported chains + fn get_asset_from_id( + asset_id: u16, + chain_id: u64, + _registry: Option<&ContractRegistry>, + ) -> Option
{ + if chain_id == 42161 { + return match asset_id { + 0 => Some("0x82aF49447D8a07e3bd95BD0d56f35241523fBab1".parse().ok()?), // WETH + 1 => Some("0x2f2a2543B76A4166549F7aaB2e75Bef0aefC5B0f".parse().ok()?), // WBTC + 2 => Some("0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9".parse().ok()?), // USDT + 3 => Some("0xFF970A61A04b1cA14834A43f5dE4533eBDDB5CC8".parse().ok()?), // USDC.e (bridged) + 4 => Some("0xDA10009cBd5D07dd0CeCc66161FC93D7c9000da1".parse().ok()?), // DAI + 5 => Some("0xf97f4df75117a78c1A5a0DBb814Af92458539FB4".parse().ok()?), // LINK + 6 => Some("0xFa7F8980b0f1E64A2062791cc3b0871572f1F7f0".parse().ok()?), // UNI + 7 => Some("0x912CE59144191C1204E64559FE8253a0e49E6548".parse().ok()?), // ARB + 8 => Some("0x3082CC23568eA640225c2467653dB90e9250AaA0".parse().ok()?), // RDNT + 9 => Some("0x6694340fc020c5E6B96567843da2df01b2CE1eb6".parse().ok()?), // STG + 10 => Some("0x17FC002b466eEc40DaE837Fc4bE5c67993ddBd6F".parse().ok()?), // FRAX + 11 => Some("0xd22a58f79e9481D1a88e00c343885A588b34b68B".parse().ok()?), // EURS + 12 => Some("0xaf88d065e77c8cC2239327C5EDb3A432268e5831".parse().ok()?), // USDC (native) + 13 => Some("0x93b346b6BC2548dA6A1E7d98E9a421B42541425b".parse().ok()?), // LUSD + 14 => Some("0x5979D7b546E38E414F7E9822514be443A4800529".parse().ok()?), // wstETH + 15 => Some("0x35751007a407ca6FEFfE80b3cB397736D2cf4dbe".parse().ok()?), // weETH + 16 => Some("0x1a7e4e63778B4f12a199C062f3eFdD288afCBce8".parse().ok()?), // agEUR + 17 => Some("0xaf88d065e77c8cC2239327C5EDb3A432268e5831".parse().ok()?), // MAI + _ => None, + }; + } + + if chain_id == 10 { + return match asset_id { + 0 => Some("0x4200000000000000000000000000000000000006".parse().ok()?), // WETH + 1 => Some("0x68f180fcCe6836688e9084f035309E29Bf0A2095".parse().ok()?), // WBTC + 2 => Some("0x94b008aA00579c1307B0EF2c499aD98a8ce58e58".parse().ok()?), // USDT + 3 => Some("0x7F5c764cBc14f9669B88837ca1490cCa17c31607".parse().ok()?), // USDC.e (bridged) + 4 => Some("0xDA10009cBd5D07dd0CeCc66161FC93D7c9000da1".parse().ok()?), // DAI + 5 => Some("0x350a791Bfc2C21F9Ed5d10980Dad2e2638ffa7f6".parse().ok()?), // LINK + 6 => Some("0x6fd9d7AD17242c41f7131d257212c54A0e816691".parse().ok()?), // UNI + 7 => Some("0x76FB31fb4af56892A25e32cFC43De717950c9278".parse().ok()?), // AAVE + 8 => Some("0x9Bcef72be871e61ED4fBbc7630889beE758eb81D".parse().ok()?), // rETH + 9 => Some("0x1F32b1c2345538c0c6f582fCB022739c4A194Ebb".parse().ok()?), // wstETH + 10 => Some("0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85".parse().ok()?), // USDC (native) + 11 => Some("0x8700dAec35aF8Ff88c16BdF0418774CB3D7599B4".parse().ok()?), // SNX + _ => None, + }; + } + + if chain_id == 137 { + return match asset_id { + 0 => Some("0x7ceB23fD6bC0adD59E62ac25578270cFf1b9f619".parse().ok()?), // WETH + 1 => Some("0x1BFD67037B42Cf73acF2047067bd4F2C47D9BfD6".parse().ok()?), // WBTC + 2 => Some("0xc2132D05D31c914a87C6611C10748AEb04B58e8F".parse().ok()?), // USDT + 3 => Some("0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174".parse().ok()?), // USDC.e (bridged) + 4 => Some("0x8f3Cf7ad23Cd3CaDbD9735AFf958023239c6A063".parse().ok()?), // DAI + 5 => Some("0x53E0bca35eC356BD5ddDFebbD1Fc0fD03FaBad39".parse().ok()?), // LINK + 6 => Some("0xb33EaAd8d922B1083446DC23f610c2567fB5180f".parse().ok()?), // UNI + 7 => Some("0xD6DF932A45C0f255f85145f286eA0b292B21C90B".parse().ok()?), // AAVE + 8 => Some("0x0d500B1d8E8eF31E21C99d1Db9A6444d3ADf1270".parse().ok()?), // WMATIC + 9 => Some("0x385Eeac5cB85A38A9a07A70c73e0a3271CfB54A7".parse().ok()?), // GHST + 10 => Some("0x172370d5Cd63279eFa6d502DAB29171933a610AF".parse().ok()?), // CRV + 11 => Some("0x0b3F868E0BE5597D5DB7fEB59E1CADBb0fdDa50a".parse().ok()?), // SUSHI + 12 => Some("0x3A58a54C066FdC0f2D55FC9C89F0415C92eBf3C4".parse().ok()?), // stMATIC + 13 => Some("0x03b54A6e9a984069379fae1a4fC4dBAE93B3bCCD".parse().ok()?), // wstETH + 14 => Some("0xfa68FB4628DFF1028CFEc22b4162FCcd0d45efb6".parse().ok()?), // MaticX + 15 => Some("0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359".parse().ok()?), // USDC (native) + _ => None, + }; + } + + None + } + + /// Decodes L2Pool supply operation + fn decode_l2_supply( + input: &[u8], + chain_id: u64, + registry: Option<&ContractRegistry>, + ) -> Option { + let call = IL2Pool::supplyCall::abi_decode(input).ok()?; + + let (asset_id, amount, _referral_code) = Self::decode_supply_params(call.args.0); + let asset_address = Self::get_asset_from_id(asset_id, chain_id, registry); + + let (amount_str, token_symbol) = if let Some(addr) = asset_address { + let symbol = registry + .and_then(|r| r.get_token_symbol(chain_id, addr)) + .unwrap_or_else(|| format!("{:?}", addr)); + + let formatted = registry + .and_then(|r| r.format_token_amount(chain_id, addr, amount)) + .map(|(amt, sym)| (amt, sym)) + .unwrap_or_else(|| (amount.to_string(), symbol.clone())); + + formatted + } else { + (amount.to_string(), format!("Asset#{}", asset_id)) + }; + + let summary = format!("Supply {} {}", amount_str, token_symbol); + + let fields = vec![ + AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: format!("Asset ID: {}", asset_id), + label: "Asset".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: format!("{} (ID: {})", token_symbol, asset_id), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: format!("{} {}", amount_str, token_symbol), + label: "Amount".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: format!("{} {} (raw: {})", amount_str, token_symbol, amount), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + ]; + + Some(SignablePayloadField::PreviewLayout { + common: SignablePayloadFieldCommon { + fallback_text: summary.clone(), + label: "Aave L2 Supply".to_string(), + }, + preview_layout: SignablePayloadFieldPreviewLayout { + title: Some(SignablePayloadFieldTextV2 { + text: "Aave v3 L2 Supply".to_string(), + }), + subtitle: Some(SignablePayloadFieldTextV2 { text: summary }), + condensed: None, + expanded: Some(SignablePayloadFieldListLayout { fields }), + }, + }) + } + + /// Decodes L2Pool withdraw operation + fn decode_l2_withdraw( + input: &[u8], + chain_id: u64, + registry: Option<&ContractRegistry>, + ) -> Option { + let call = IL2Pool::withdrawCall::abi_decode(input).ok()?; + + let (asset_id, amount) = Self::decode_withdraw_params(call.args.0); + let asset_address = Self::get_asset_from_id(asset_id, chain_id, registry); + + let (amount_str, token_symbol) = if let Some(addr) = asset_address { + let symbol = registry + .and_then(|r| r.get_token_symbol(chain_id, addr)) + .unwrap_or_else(|| format!("{:?}", addr)); + + let formatted = registry + .and_then(|r| r.format_token_amount(chain_id, addr, amount)) + .map(|(amt, sym)| (amt, sym)) + .unwrap_or_else(|| (amount.to_string(), symbol.clone())); + + formatted + } else { + (amount.to_string(), format!("Asset#{}", asset_id)) + }; + + let is_max = amount == u128::MAX; + let amount_display = if is_max { + "Maximum available".to_string() + } else { + amount_str.clone() + }; + + let summary = format!("Withdraw {} {}", amount_display, token_symbol); + + let fields = vec![ + AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: format!("Asset ID: {}", asset_id), + label: "Asset".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: format!("{} (ID: {})", token_symbol, asset_id), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: format!("{} {}", amount_display, token_symbol), + label: "Amount".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: if is_max { + format!("Maximum available (type(uint128).max)") + } else { + format!("{} {} (raw: {})", amount_str, token_symbol, amount) + }, + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + ]; + + Some(SignablePayloadField::PreviewLayout { + common: SignablePayloadFieldCommon { + fallback_text: summary.clone(), + label: "Aave L2 Withdraw".to_string(), + }, + preview_layout: SignablePayloadFieldPreviewLayout { + title: Some(SignablePayloadFieldTextV2 { + text: "Aave v3 L2 Withdraw".to_string(), + }), + subtitle: Some(SignablePayloadFieldTextV2 { text: summary }), + condensed: None, + expanded: Some(SignablePayloadFieldListLayout { fields }), + }, + }) + } + + /// Decodes L2Pool borrow operation + fn decode_l2_borrow( + input: &[u8], + chain_id: u64, + registry: Option<&ContractRegistry>, + ) -> Option { + let call = IL2Pool::borrowCall::abi_decode(input).ok()?; + + let (asset_id, amount, interest_rate_mode, _referral_code) = + Self::decode_borrow_params(call.args.0); + + let asset_address = Self::get_asset_from_id(asset_id, chain_id, registry); + + let (amount_str, token_symbol) = if let Some(addr) = asset_address { + let symbol = registry + .and_then(|r| r.get_token_symbol(chain_id, addr)) + .unwrap_or_else(|| format!("{:?}", addr)); + + let formatted = registry + .and_then(|r| r.format_token_amount(chain_id, addr, amount)) + .map(|(amt, sym)| (amt, sym)) + .unwrap_or_else(|| (amount.to_string(), symbol.clone())); + + formatted + } else { + (amount.to_string(), format!("Asset#{}", asset_id)) + }; + + let rate_mode = match interest_rate_mode { + 2 => "Variable", + 1 => "Stable (Deprecated)", + mode => { + return Some(SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: format!("Unknown rate mode: {}", mode), + label: "Error".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: format!("Invalid interest rate mode: {}", mode), + }, + }); + } + }; + + let summary = format!( + "Borrow {} {} at {} rate", + amount_str, token_symbol, rate_mode + ); + + let fields = vec![ + AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: format!("Asset ID: {}", asset_id), + label: "Asset".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: format!("{} (ID: {})", token_symbol, asset_id), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: format!("{} {}", amount_str, token_symbol), + label: "Amount".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: format!("{} {} (raw: {})", amount_str, token_symbol, amount), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: rate_mode.to_string(), + label: "Interest Rate Mode".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: format!("{} ({})", rate_mode, interest_rate_mode), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + ]; + + Some(SignablePayloadField::PreviewLayout { + common: SignablePayloadFieldCommon { + fallback_text: summary.clone(), + label: "Aave L2 Borrow".to_string(), + }, + preview_layout: SignablePayloadFieldPreviewLayout { + title: Some(SignablePayloadFieldTextV2 { + text: "Aave v3 L2 Borrow".to_string(), + }), + subtitle: Some(SignablePayloadFieldTextV2 { text: summary }), + condensed: None, + expanded: Some(SignablePayloadFieldListLayout { fields }), + }, + }) + } + + /// Decodes L2Pool repay operation + fn decode_l2_repay( + input: &[u8], + chain_id: u64, + registry: Option<&ContractRegistry>, + ) -> Option { + let call = IL2Pool::repayCall::abi_decode(input).ok()?; + + let (asset_id, amount, interest_rate_mode) = Self::decode_repay_params(call.args.0); + let asset_address = Self::get_asset_from_id(asset_id, chain_id, registry); + + let (amount_str, token_symbol) = if let Some(addr) = asset_address { + let symbol = registry + .and_then(|r| r.get_token_symbol(chain_id, addr)) + .unwrap_or_else(|| format!("{:?}", addr)); + + let formatted = registry + .and_then(|r| r.format_token_amount(chain_id, addr, amount)) + .map(|(amt, sym)| (amt, sym)) + .unwrap_or_else(|| (amount.to_string(), symbol.clone())); + + formatted + } else { + (amount.to_string(), format!("Asset#{}", asset_id)) + }; + + let is_max = amount == u128::MAX; + let amount_display = if is_max { + "Full debt".to_string() + } else { + amount_str.clone() + }; + + let rate_mode = match interest_rate_mode { + 2 => "Variable", + 1 => "Stable (Deprecated)", + mode => { + return Some(SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: format!("Unknown rate mode: {}", mode), + label: "Error".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: format!("Invalid interest rate mode: {}", mode), + }, + }); + } + }; + + let summary = format!( + "Repay {} {} at {} rate", + amount_display, token_symbol, rate_mode + ); + + let fields = vec![ + AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: format!("Asset ID: {}", asset_id), + label: "Asset".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: format!("{} (ID: {})", token_symbol, asset_id), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: format!("{} {}", amount_display, token_symbol), + label: "Amount".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: if is_max { + format!("Full debt (type(uint128).max)") + } else { + format!("{} {} (raw: {})", amount_str, token_symbol, amount) + }, + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: rate_mode.to_string(), + label: "Interest Rate Mode".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: format!("{} ({})", rate_mode, interest_rate_mode), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + ]; + + Some(SignablePayloadField::PreviewLayout { + common: SignablePayloadFieldCommon { + fallback_text: summary.clone(), + label: "Aave L2 Repay".to_string(), + }, + preview_layout: SignablePayloadFieldPreviewLayout { + title: Some(SignablePayloadFieldTextV2 { + text: "Aave v3 L2 Repay".to_string(), + }), + subtitle: Some(SignablePayloadFieldTextV2 { text: summary }), + condensed: None, + expanded: Some(SignablePayloadFieldListLayout { fields }), + }, + }) + } + + /// Decodes L2Pool repayWithATokens operation + fn decode_l2_repay_with_atokens( + input: &[u8], + chain_id: u64, + registry: Option<&ContractRegistry>, + ) -> Option { + let call = IL2Pool::repayWithATokensCall::abi_decode(input).ok()?; + + let (asset_id, amount, interest_rate_mode) = Self::decode_repay_params(call.args.0); + let asset_address = Self::get_asset_from_id(asset_id, chain_id, registry); + + let (amount_str, token_symbol) = if let Some(addr) = asset_address { + let symbol = registry + .and_then(|r| r.get_token_symbol(chain_id, addr)) + .unwrap_or_else(|| format!("{:?}", addr)); + + let formatted = registry + .and_then(|r| r.format_token_amount(chain_id, addr, amount)) + .map(|(amt, sym)| (amt, sym)) + .unwrap_or_else(|| (amount.to_string(), symbol.clone())); + + formatted + } else { + (amount.to_string(), format!("Asset#{}", asset_id)) + }; + + let is_max = amount == u128::MAX; + let amount_display = if is_max { + "Full balance".to_string() + } else { + amount_str.clone() + }; + + let rate_mode = match interest_rate_mode { + 2 => "Variable", + 1 => "Stable (Deprecated)", + mode => { + return Some(SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: format!("Unknown rate mode: {}", mode), + label: "Error".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: format!("Invalid interest rate mode: {}", mode), + }, + }); + } + }; + + let summary = format!( + "Repay {} {} using aTokens at {} rate", + amount_display, token_symbol, rate_mode + ); + + let fields = vec![ + AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: format!("Asset ID: {}", asset_id), + label: "Asset".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: format!("{} (ID: {})", token_symbol, asset_id), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: format!("{} {}", amount_display, token_symbol), + label: "Amount".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: if is_max { + format!("Full balance (type(uint128).max)") + } else { + format!("{} {} (raw: {})", amount_str, token_symbol, amount) + }, + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: rate_mode.to_string(), + label: "Interest Rate Mode".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: format!("{} ({})", rate_mode, interest_rate_mode), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: "Using aTokens".to_string(), + label: "Repayment Method".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: "Repaying with aTokens (no transfer required)".to_string(), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + ]; + + Some(SignablePayloadField::PreviewLayout { + common: SignablePayloadFieldCommon { + fallback_text: summary.clone(), + label: "Aave L2 Repay with aTokens".to_string(), + }, + preview_layout: SignablePayloadFieldPreviewLayout { + title: Some(SignablePayloadFieldTextV2 { + text: "Aave v3 L2 Repay with aTokens".to_string(), + }), + subtitle: Some(SignablePayloadFieldTextV2 { text: summary }), + condensed: None, + expanded: Some(SignablePayloadFieldListLayout { fields }), + }, + }) + } + + /// Decodes L2Pool liquidation call operation + fn decode_l2_liquidation_call( + input: &[u8], + chain_id: u64, + registry: Option<&ContractRegistry>, + ) -> Option { + let call = IL2Pool::liquidationCallCall::abi_decode(input).ok()?; + + let (collateral_asset_id, debt_asset_id, user, debt_to_cover, receive_atoken) = + Self::decode_liquidation_call_params(call.args1.0, call.args2.0); + + let collateral_address = Self::get_asset_from_id(collateral_asset_id, chain_id, registry); + let debt_address = Self::get_asset_from_id(debt_asset_id, chain_id, registry); + + let (debt_amount_str, debt_symbol) = if let Some(addr) = debt_address { + let symbol = registry + .and_then(|r| r.get_token_symbol(chain_id, addr)) + .unwrap_or_else(|| format!("{:?}", addr)); + + let formatted = registry + .and_then(|r| r.format_token_amount(chain_id, addr, debt_to_cover)) + .map(|(amt, sym)| (amt, sym)) + .unwrap_or_else(|| (debt_to_cover.to_string(), symbol.clone())); + + formatted + } else { + ( + debt_to_cover.to_string(), + format!("Asset#{}", debt_asset_id), + ) + }; + + let collateral_symbol = if let Some(addr) = collateral_address { + registry + .and_then(|r| r.get_token_symbol(chain_id, addr)) + .unwrap_or_else(|| format!("{:?}", addr)) + } else { + format!("Asset#{}", collateral_asset_id) + }; + + let is_max = debt_to_cover == u128::MAX; + let amount_display = if is_max { + "Full debt".to_string() + } else { + debt_amount_str.clone() + }; + + let summary = format!( + "Liquidate {} {} debt, seize {} collateral{}", + amount_display, + debt_symbol, + collateral_symbol, + if receive_atoken { + " (receive aTokens)" + } else { + "" + } + ); + + let fields = vec![ + AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: format!("{:?}", user), + label: "User".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: format!("{:?}", user), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: format!("Asset ID: {}", debt_asset_id), + label: "Debt Asset".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: format!("{} (ID: {})", debt_symbol, debt_asset_id), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: format!("{} {}", amount_display, debt_symbol), + label: "Debt to Cover".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: if is_max { + format!("Full debt (type(uint128).max)") + } else { + format!( + "{} {} (raw: {})", + debt_amount_str, debt_symbol, debt_to_cover + ) + }, + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: format!("Asset ID: {}", collateral_asset_id), + label: "Collateral Asset".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: format!("{} (ID: {})", collateral_symbol, collateral_asset_id), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: if receive_atoken { + "Receive aTokens" + } else { + "Receive underlying" + } + .to_string(), + label: "Liquidation Bonus".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: if receive_atoken { + "Receive aTokens (no transfer)" + } else { + "Receive underlying asset" + } + .to_string(), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + ]; + + Some(SignablePayloadField::PreviewLayout { + common: SignablePayloadFieldCommon { + fallback_text: summary.clone(), + label: "Aave L2 Liquidation".to_string(), + }, + preview_layout: SignablePayloadFieldPreviewLayout { + title: Some(SignablePayloadFieldTextV2 { + text: "Aave v3 L2 Liquidation Call".to_string(), + }), + subtitle: Some(SignablePayloadFieldTextV2 { text: summary }), + condensed: None, + expanded: Some(SignablePayloadFieldListLayout { fields }), + }, + }) + } + + /// Decodes L2Pool setUserUseReserveAsCollateral operation + fn decode_l2_set_user_use_reserve_as_collateral( + input: &[u8], + chain_id: u64, + registry: Option<&ContractRegistry>, + ) -> Option { + let call = IL2Pool::setUserUseReserveAsCollateralCall::abi_decode(input).ok()?; + + let (asset_id, use_as_collateral) = + Self::decode_set_user_use_reserve_as_collateral_params(call.args.0); + + let asset_address = Self::get_asset_from_id(asset_id, chain_id, registry); + + let token_symbol = if let Some(addr) = asset_address { + registry + .and_then(|r| r.get_token_symbol(chain_id, addr)) + .unwrap_or_else(|| format!("{:?}", addr)) + } else { + format!("Asset#{}", asset_id) + }; + + let action = if use_as_collateral { + "Enable" + } else { + "Disable" + }; + + let summary = format!("{} {} as collateral", action, token_symbol); + + let fields = vec![ + AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: format!("Asset ID: {}", asset_id), + label: "Asset".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: format!("{} (ID: {})", token_symbol, asset_id), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: action.to_string(), + label: "Action".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: if use_as_collateral { + "Enable as collateral (allows borrowing)" + } else { + "Disable as collateral (reduces borrowing power)" + } + .to_string(), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + ]; + + Some(SignablePayloadField::PreviewLayout { + common: SignablePayloadFieldCommon { + fallback_text: summary.clone(), + label: "Aave L2 Collateral Setting".to_string(), + }, + preview_layout: SignablePayloadFieldPreviewLayout { + title: Some(SignablePayloadFieldTextV2 { + text: "Aave v3 L2 Set Collateral".to_string(), + }), + subtitle: Some(SignablePayloadFieldTextV2 { text: summary }), + condensed: None, + expanded: Some(SignablePayloadFieldListLayout { fields }), + }, + }) + } + + /// Decodes L2Pool supplyWithPermit operation + fn decode_l2_supply_with_permit( + input: &[u8], + chain_id: u64, + registry: Option<&ContractRegistry>, + ) -> Option { + let call = IL2Pool::supplyWithPermitCall::abi_decode(input).ok()?; + + let (asset_id, amount, _referral_code) = Self::decode_supply_params(call.args.0); + let deadline = Self::extract_supply_permit_deadline(call.args.0); + + let asset_address = Self::get_asset_from_id(asset_id, chain_id, registry); + + let (amount_str, token_symbol) = if let Some(addr) = asset_address { + let symbol = registry + .and_then(|r| r.get_token_symbol(chain_id, addr)) + .unwrap_or_else(|| format!("{:?}", addr)); + + let formatted = registry + .and_then(|r| r.format_token_amount(chain_id, addr, amount)) + .map(|(amt, sym)| (amt, sym)) + .unwrap_or_else(|| (amount.to_string(), symbol.clone())); + + formatted + } else { + (amount.to_string(), format!("Asset#{}", asset_id)) + }; + + let deadline_str = if deadline == u32::MAX { + "never".to_string() + } else { + let dt = Utc.timestamp_opt(deadline as i64, 0).unwrap(); + dt.format("%Y-%m-%d %H:%M UTC").to_string() + }; + + let summary = format!( + "Supply {} {} with permit (expires: {})", + amount_str, token_symbol, deadline_str + ); + + let fields = vec![ + AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: format!("Asset ID: {}", asset_id), + label: "Asset".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: format!("{} (ID: {})", token_symbol, asset_id), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: format!("{} {}", amount_str, token_symbol), + label: "Amount".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: format!("{} {} (raw: {})", amount_str, token_symbol, amount), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: "ERC-2612 Permit".to_string(), + label: "Authorization".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: "Using gasless ERC-2612 permit signature".to_string(), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + ]; + + Some(SignablePayloadField::PreviewLayout { + common: SignablePayloadFieldCommon { + fallback_text: summary.clone(), + label: "Aave L2 Supply with Permit".to_string(), + }, + preview_layout: SignablePayloadFieldPreviewLayout { + title: Some(SignablePayloadFieldTextV2 { + text: "Aave v3 L2 Supply with Permit".to_string(), + }), + subtitle: Some(SignablePayloadFieldTextV2 { text: summary }), + condensed: None, + expanded: Some(SignablePayloadFieldListLayout { fields }), + }, + }) + } + + /// Decodes L2Pool repayWithPermit operation + fn decode_l2_repay_with_permit( + input: &[u8], + chain_id: u64, + registry: Option<&ContractRegistry>, + ) -> Option { + let call = IL2Pool::repayWithPermitCall::abi_decode(input).ok()?; + + let (asset_id, amount, interest_rate_mode) = Self::decode_repay_params(call.args.0); + let deadline = Self::extract_repay_permit_deadline(call.args.0); + + let asset_address = Self::get_asset_from_id(asset_id, chain_id, registry); + + let (amount_str, token_symbol) = if let Some(addr) = asset_address { + let symbol = registry + .and_then(|r| r.get_token_symbol(chain_id, addr)) + .unwrap_or_else(|| format!("{:?}", addr)); + + let formatted = registry + .and_then(|r| r.format_token_amount(chain_id, addr, amount)) + .map(|(amt, sym)| (amt, sym)) + .unwrap_or_else(|| (amount.to_string(), symbol.clone())); + + formatted + } else { + (amount.to_string(), format!("Asset#{}", asset_id)) + }; + + let is_max = amount == u128::MAX; + let amount_display = if is_max { + "Full balance".to_string() + } else { + amount_str.clone() + }; + + let rate_mode = match interest_rate_mode { + 2 => "Variable", + 1 => "Stable (Deprecated)", + mode => { + return Some(SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: format!("Unknown rate mode: {}", mode), + label: "Error".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: format!("Invalid interest rate mode: {}", mode), + }, + }); + } + }; + + let deadline_str = if deadline == u32::MAX { + "never".to_string() + } else { + let dt = Utc.timestamp_opt(deadline as i64, 0).unwrap(); + dt.format("%Y-%m-%d %H:%M UTC").to_string() + }; + + let summary = format!( + "Repay {} {} at {} rate with permit (expires: {})", + amount_display, token_symbol, rate_mode, deadline_str + ); + + let fields = vec![ + AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: format!("Asset ID: {}", asset_id), + label: "Asset".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: format!("{} (ID: {})", token_symbol, asset_id), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: format!("{} {}", amount_display, token_symbol), + label: "Amount".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: if is_max { + format!("Full balance (type(uint128).max)") + } else { + format!("{} {} (raw: {})", amount_str, token_symbol, amount) + }, + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: rate_mode.to_string(), + label: "Interest Rate Mode".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: format!("{} ({})", rate_mode, interest_rate_mode), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: "ERC-2612 Permit".to_string(), + label: "Authorization".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: "Using gasless ERC-2612 permit signature".to_string(), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + ]; + + Some(SignablePayloadField::PreviewLayout { + common: SignablePayloadFieldCommon { + fallback_text: summary.clone(), + label: "Aave L2 Repay with Permit".to_string(), + }, + preview_layout: SignablePayloadFieldPreviewLayout { + title: Some(SignablePayloadFieldTextV2 { + text: "Aave v3 L2 Repay with Permit".to_string(), + }), + subtitle: Some(SignablePayloadFieldTextV2 { text: summary }), + condensed: None, + expanded: Some(SignablePayloadFieldListLayout { fields }), + }, + }) + } +} diff --git a/src/chain_parsers/visualsign-ethereum/src/protocols/aave/contracts/mod.rs b/src/chain_parsers/visualsign-ethereum/src/protocols/aave/contracts/mod.rs new file mode 100644 index 00000000..a5d3b6fa --- /dev/null +++ b/src/chain_parsers/visualsign-ethereum/src/protocols/aave/contracts/mod.rs @@ -0,0 +1,9 @@ +pub mod aave_token; +pub mod l2_pool; +pub mod pool; +pub mod voting_machine; + +pub use aave_token::AaveTokenVisualizer; +pub use l2_pool::L2PoolVisualizer; +pub use pool::{PoolContractVisualizer, PoolVisualizer}; +pub use voting_machine::VotingMachineVisualizer; diff --git a/src/chain_parsers/visualsign-ethereum/src/protocols/aave/contracts/pool.rs b/src/chain_parsers/visualsign-ethereum/src/protocols/aave/contracts/pool.rs new file mode 100644 index 00000000..2e0366bb --- /dev/null +++ b/src/chain_parsers/visualsign-ethereum/src/protocols/aave/contracts/pool.rs @@ -0,0 +1,2061 @@ +use alloy_primitives::{Address, U256}; +use alloy_sol_types::{SolCall as _, sol}; +use visualsign::{ + AnnotatedPayloadField, SignablePayloadField, SignablePayloadFieldCommon, + SignablePayloadFieldListLayout, SignablePayloadFieldPreviewLayout, SignablePayloadFieldTextV2, +}; + +use super::l2_pool::{IL2Pool, L2PoolVisualizer}; +use crate::context::VisualizerContext; +use crate::protocols::aave::config::AaveV3PoolContract; +use crate::registry::{ContractRegistry, ContractType}; + +// Aave v3 Pool interface definitions +// +// Official Documentation: +// - Technical Reference: https://docs.aave.com/developers/core-contracts/pool +// - Contract Source: https://github.com/aave-dao/aave-v3-origin +// +// The Pool contract is the main entry point for Aave v3 interactions. +// It supports lending, borrowing, repayment, and liquidations. +sol! { + interface IPool { + function supply( + address asset, + uint256 amount, + address onBehalfOf, + uint16 referralCode + ) external; + + function withdraw( + address asset, + uint256 amount, + address to + ) external returns (uint256); + + function borrow( + address asset, + uint256 amount, + uint256 interestRateMode, + uint16 referralCode, + address onBehalfOf + ) external; + + function repay( + address asset, + uint256 amount, + uint256 interestRateMode, + address onBehalfOf + ) external returns (uint256); + + function liquidationCall( + address collateralAsset, + address debtAsset, + address user, + uint256 debtToCover, + bool receiveAToken + ) external; + + function supplyWithPermit( + address asset, + uint256 amount, + address onBehalfOf, + uint16 referralCode, + uint256 deadline, + uint8 permitV, + bytes32 permitR, + bytes32 permitS + ) external; + + function repayWithPermit( + address asset, + uint256 amount, + uint256 interestRateMode, + address onBehalfOf, + uint256 deadline, + uint8 permitV, + bytes32 permitR, + bytes32 permitS + ) external returns (uint256); + + function repayWithATokens( + address asset, + uint256 amount, + uint256 interestRateMode + ) external returns (uint256); + + function setUserUseReserveAsCollateral( + address asset, + bool useAsCollateral + ) external; + + function flashLoan( + address receiverAddress, + address[] calldata assets, + uint256[] calldata amounts, + uint256[] calldata interestRateModes, + address onBehalfOf, + bytes calldata params, + uint16 referralCode + ) external; + + function flashLoanSimple( + address receiverAddress, + address asset, + uint256 amount, + bytes calldata params, + uint16 referralCode + ) external; + } +} + +/// Visualizer for Aave v3 Pool contract +pub struct PoolVisualizer { + l2_pool_visualizer: L2PoolVisualizer, +} + +impl PoolVisualizer { + pub fn new() -> Self { + Self { + l2_pool_visualizer: L2PoolVisualizer::new(), + } + } + + /// Visualizes Aave v3 Pool operations + /// + /// # Arguments + /// * `input` - The calldata bytes + /// * `chain_id` - The chain ID for registry lookups + /// * `registry` - Optional registry for resolving token symbols + pub fn visualize_pool_operation( + &self, + input: &[u8], + chain_id: u64, + registry: Option<&ContractRegistry>, + ) -> Option { + if input.len() < 4 { + return None; + } + + let selector = &input[0..4]; + + // Route to specific decoders based on function selector + match selector { + // Standard Pool functions (Ethereum mainnet, Base, etc.) + + // supply(address,uint256,address,uint16) + _ if selector == IPool::supplyCall::SELECTOR => { + Self::decode_supply(input, chain_id, registry) + } + // withdraw(address,uint256,address) + _ if selector == IPool::withdrawCall::SELECTOR => { + Self::decode_withdraw(input, chain_id, registry) + } + // borrow(address,uint256,uint256,uint16,address) + _ if selector == IPool::borrowCall::SELECTOR => { + Self::decode_borrow(input, chain_id, registry) + } + // repay(address,uint256,uint256,address) + _ if selector == IPool::repayCall::SELECTOR => { + Self::decode_repay(input, chain_id, registry) + } + // liquidationCall(address,address,address,uint256,bool) + _ if selector == IPool::liquidationCallCall::SELECTOR => { + Self::decode_liquidation_call(input, chain_id, registry) + } + // supplyWithPermit(address,uint256,address,uint16,uint256,uint8,bytes32,bytes32) + _ if selector == IPool::supplyWithPermitCall::SELECTOR => { + Self::decode_supply_with_permit(input, chain_id, registry) + } + // repayWithPermit(address,uint256,uint256,address,uint256,uint8,bytes32,bytes32) + _ if selector == IPool::repayWithPermitCall::SELECTOR => { + Self::decode_repay_with_permit(input, chain_id, registry) + } + // repayWithATokens(address,uint256,uint256) + _ if selector == IPool::repayWithATokensCall::SELECTOR => { + Self::decode_repay_with_atokens(input, chain_id, registry) + } + // setUserUseReserveAsCollateral(address,bool) + _ if selector == IPool::setUserUseReserveAsCollateralCall::SELECTOR => { + Self::decode_set_user_use_reserve_as_collateral(input, chain_id, registry) + } + // flashLoan(address,address[],uint256[],uint256[],address,bytes,uint16) + _ if selector == IPool::flashLoanCall::SELECTOR => { + Self::decode_flash_loan(input, chain_id, registry) + } + // flashLoanSimple(address,address,uint256,bytes,uint16) + _ if selector == IPool::flashLoanSimpleCall::SELECTOR => { + Self::decode_flash_loan_simple(input, chain_id, registry) + } + + // L2Pool functions - delegate to L2PoolVisualizer + + // supply(bytes32) + _ if selector == IL2Pool::supplyCall::SELECTOR => self + .l2_pool_visualizer + .visualize_l2pool_operation(input, chain_id, registry), + // withdraw(bytes32) + _ if selector == IL2Pool::withdrawCall::SELECTOR => self + .l2_pool_visualizer + .visualize_l2pool_operation(input, chain_id, registry), + // borrow(bytes32) + _ if selector == IL2Pool::borrowCall::SELECTOR => self + .l2_pool_visualizer + .visualize_l2pool_operation(input, chain_id, registry), + // repay(bytes32) + _ if selector == IL2Pool::repayCall::SELECTOR => self + .l2_pool_visualizer + .visualize_l2pool_operation(input, chain_id, registry), + // repayWithATokens(bytes32) + _ if selector == IL2Pool::repayWithATokensCall::SELECTOR => self + .l2_pool_visualizer + .visualize_l2pool_operation(input, chain_id, registry), + // liquidationCall(bytes32, bytes32) + _ if selector == IL2Pool::liquidationCallCall::SELECTOR => self + .l2_pool_visualizer + .visualize_l2pool_operation(input, chain_id, registry), + // setUserUseReserveAsCollateral(bytes32) + _ if selector == IL2Pool::setUserUseReserveAsCollateralCall::SELECTOR => self + .l2_pool_visualizer + .visualize_l2pool_operation(input, chain_id, registry), + // supplyWithPermit(bytes32,bytes32,bytes32) + _ if selector == IL2Pool::supplyWithPermitCall::SELECTOR => self + .l2_pool_visualizer + .visualize_l2pool_operation(input, chain_id, registry), + // repayWithPermit(bytes32,bytes32,bytes32) + _ if selector == IL2Pool::repayWithPermitCall::SELECTOR => self + .l2_pool_visualizer + .visualize_l2pool_operation(input, chain_id, registry), + + _ => None, + } + } + + /// Decodes supply operation + fn decode_supply( + input: &[u8], + chain_id: u64, + registry: Option<&ContractRegistry>, + ) -> Option { + let call = IPool::supplyCall::abi_decode(input).ok()?; + + let token_symbol = registry + .and_then(|r| r.get_token_symbol(chain_id, call.asset)) + .unwrap_or_else(|| format!("{:?}", call.asset)); + + let amount_u128: u128 = call.amount.to_string().parse().unwrap_or(0); + let (amount_str, _) = registry + .and_then(|r| r.format_token_amount(chain_id, call.asset, amount_u128)) + .unwrap_or_else(|| (call.amount.to_string(), token_symbol.clone())); + + let behalf_text = if call.onBehalfOf != Address::ZERO { + format!(" on behalf of {:?}", call.onBehalfOf) + } else { + String::new() + }; + + let summary = format!("Supply {} {}{}", amount_str, token_symbol, behalf_text); + + let fields = vec![ + AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: format!("{} ({:?})", token_symbol, call.asset), + label: "Asset".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: format!("{} ({:?})", token_symbol, call.asset), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: format!("{} {}", amount_str, token_symbol), + label: "Amount".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: format!("{} {} (raw: {})", amount_str, token_symbol, call.amount), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: format!("{:?}", call.onBehalfOf), + label: "On Behalf Of".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: format!("{:?}", call.onBehalfOf), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + ]; + + Some(SignablePayloadField::PreviewLayout { + common: SignablePayloadFieldCommon { + fallback_text: summary.clone(), + label: "Aave Supply".to_string(), + }, + preview_layout: SignablePayloadFieldPreviewLayout { + title: Some(SignablePayloadFieldTextV2 { + text: "Aave v3 Supply".to_string(), + }), + subtitle: Some(SignablePayloadFieldTextV2 { text: summary }), + condensed: None, + expanded: Some(SignablePayloadFieldListLayout { fields }), + }, + }) + } + + /// Decodes withdraw operation + fn decode_withdraw( + input: &[u8], + chain_id: u64, + registry: Option<&ContractRegistry>, + ) -> Option { + let call = IPool::withdrawCall::abi_decode(input).ok()?; + + // Resolve token symbol + let token_symbol = registry + .and_then(|r| r.get_token_symbol(chain_id, call.asset)) + .unwrap_or_else(|| format!("{:?}", call.asset)); + + // Check if withdrawing max amount + let is_max = call.amount == U256::MAX; + let amount_display = if is_max { + "Maximum".to_string() + } else { + let amount_u128: u128 = call.amount.to_string().parse().unwrap_or(0); + let (amount_str, _) = registry + .and_then(|r| r.format_token_amount(chain_id, call.asset, amount_u128)) + .unwrap_or_else(|| (call.amount.to_string(), token_symbol.clone())); + amount_str + }; + + let summary = format!( + "Withdraw {} {} to {:?}", + amount_display, token_symbol, call.to + ); + + // Create detailed parameter fields + let fields = vec![ + AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: format!("{} ({:?})", token_symbol, call.asset), + label: "Asset".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: format!("{} ({:?})", token_symbol, call.asset), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: if is_max { + "Maximum (type(uint256).max)".to_string() + } else { + format!("{} {}", amount_display, token_symbol) + }, + label: "Amount".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: if is_max { + format!("Maximum ({})", call.amount) + } else { + format!("{} {} (raw: {})", amount_display, token_symbol, call.amount) + }, + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: format!("{:?}", call.to), + label: "To".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: format!("{:?}", call.to), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + ]; + + Some(SignablePayloadField::PreviewLayout { + common: SignablePayloadFieldCommon { + fallback_text: summary.clone(), + label: "Aave Withdraw".to_string(), + }, + preview_layout: SignablePayloadFieldPreviewLayout { + title: Some(SignablePayloadFieldTextV2 { + text: "Aave v3 Withdraw".to_string(), + }), + subtitle: Some(SignablePayloadFieldTextV2 { text: summary }), + condensed: None, + expanded: Some(SignablePayloadFieldListLayout { fields }), + }, + }) + } + + /// Decodes borrow operation + fn decode_borrow( + input: &[u8], + chain_id: u64, + registry: Option<&ContractRegistry>, + ) -> Option { + let call = IPool::borrowCall::abi_decode(input).ok()?; + + // Resolve token symbol + let token_symbol = registry + .and_then(|r| r.get_token_symbol(chain_id, call.asset)) + .unwrap_or_else(|| format!("{:?}", call.asset)); + + // Format amount + let amount_u128: u128 = call.amount.to_string().parse().unwrap_or(0); + let (amount_str, _) = registry + .and_then(|r| r.format_token_amount(chain_id, call.asset, amount_u128)) + .unwrap_or_else(|| (call.amount.to_string(), token_symbol.clone())); + + // Interest rate mode (2 = Variable, 1 = Deprecated Stable) + let rate_mode_str = call.interestRateMode.to_string(); + let rate_mode = match rate_mode_str.as_str() { + "1" => "Stable (Deprecated)", + "2" => "Variable", + _ => &rate_mode_str, + }; + + let behalf_text = if call.onBehalfOf != Address::ZERO { + format!(" on behalf of {:?}", call.onBehalfOf) + } else { + String::new() + }; + + let summary = format!( + "Borrow {} {} at {} rate{}", + amount_str, token_symbol, rate_mode, behalf_text + ); + + // Create detailed parameter fields + let fields = vec![ + AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: format!("{} ({:?})", token_symbol, call.asset), + label: "Asset".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: format!("{} ({:?})", token_symbol, call.asset), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: format!("{} {}", amount_str, token_symbol), + label: "Amount".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: format!("{} {} (raw: {})", amount_str, token_symbol, call.amount), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: rate_mode.to_string(), + label: "Interest Rate Mode".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: format!("{} ({})", rate_mode, call.interestRateMode), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: format!("{:?}", call.onBehalfOf), + label: "On Behalf Of".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: format!("{:?}", call.onBehalfOf), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + ]; + + Some(SignablePayloadField::PreviewLayout { + common: SignablePayloadFieldCommon { + fallback_text: summary.clone(), + label: "Aave Borrow".to_string(), + }, + preview_layout: SignablePayloadFieldPreviewLayout { + title: Some(SignablePayloadFieldTextV2 { + text: "Aave v3 Borrow".to_string(), + }), + subtitle: Some(SignablePayloadFieldTextV2 { text: summary }), + condensed: None, + expanded: Some(SignablePayloadFieldListLayout { fields }), + }, + }) + } + + /// Decodes repay operation + fn decode_repay( + input: &[u8], + chain_id: u64, + registry: Option<&ContractRegistry>, + ) -> Option { + let call = IPool::repayCall::abi_decode(input).ok()?; + + // Resolve token symbol + let token_symbol = registry + .and_then(|r| r.get_token_symbol(chain_id, call.asset)) + .unwrap_or_else(|| format!("{:?}", call.asset)); + + // Check if repaying max amount + let is_max = call.amount == U256::MAX; + let amount_display = if is_max { + "Full debt".to_string() + } else { + let amount_u128: u128 = call.amount.to_string().parse().unwrap_or(0); + let (amount_str, _) = registry + .and_then(|r| r.format_token_amount(chain_id, call.asset, amount_u128)) + .unwrap_or_else(|| (call.amount.to_string(), token_symbol.clone())); + format!("{} {}", amount_str, token_symbol) + }; + + // Interest rate mode + let rate_mode_str = call.interestRateMode.to_string(); + let rate_mode = match rate_mode_str.as_str() { + "1" => "Stable (Deprecated)", + "2" => "Variable", + _ => &rate_mode_str, + }; + + let behalf_text = if call.onBehalfOf != Address::ZERO { + format!(" on behalf of {:?}", call.onBehalfOf) + } else { + String::new() + }; + + let summary = format!( + "Repay {} at {} rate{}", + amount_display, rate_mode, behalf_text + ); + + // Create detailed parameter fields + let fields = vec![ + AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: format!("{} ({:?})", token_symbol, call.asset), + label: "Asset".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: format!("{} ({:?})", token_symbol, call.asset), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: amount_display.clone(), + label: "Amount".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: if is_max { + format!("Full debt ({})", call.amount) + } else { + format!("{} (raw: {})", amount_display, call.amount) + }, + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: rate_mode.to_string(), + label: "Interest Rate Mode".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: format!("{} ({})", rate_mode, call.interestRateMode), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: format!("{:?}", call.onBehalfOf), + label: "On Behalf Of".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: format!("{:?}", call.onBehalfOf), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + ]; + + Some(SignablePayloadField::PreviewLayout { + common: SignablePayloadFieldCommon { + fallback_text: summary.clone(), + label: "Aave Repay".to_string(), + }, + preview_layout: SignablePayloadFieldPreviewLayout { + title: Some(SignablePayloadFieldTextV2 { + text: "Aave v3 Repay".to_string(), + }), + subtitle: Some(SignablePayloadFieldTextV2 { text: summary }), + condensed: None, + expanded: Some(SignablePayloadFieldListLayout { fields }), + }, + }) + } + + /// Decodes liquidation call operation + fn decode_liquidation_call( + input: &[u8], + chain_id: u64, + registry: Option<&ContractRegistry>, + ) -> Option { + let call = IPool::liquidationCallCall::abi_decode(input).ok()?; + + // Resolve token symbols + let collateral_symbol = registry + .and_then(|r| r.get_token_symbol(chain_id, call.collateralAsset)) + .unwrap_or_else(|| format!("{:?}", call.collateralAsset)); + let debt_symbol = registry + .and_then(|r| r.get_token_symbol(chain_id, call.debtAsset)) + .unwrap_or_else(|| format!("{:?}", call.debtAsset)); + + // Format debt to cover + let debt_u128: u128 = call.debtToCover.to_string().parse().unwrap_or(0); + let (debt_str, _) = registry + .and_then(|r| r.format_token_amount(chain_id, call.debtAsset, debt_u128)) + .unwrap_or_else(|| (call.debtToCover.to_string(), debt_symbol.clone())); + + let receive_type = if call.receiveAToken { + format!("a{}", collateral_symbol) + } else { + collateral_symbol.clone() + }; + + let summary = format!( + "Liquidate {:?}: Cover {} {} debt, receive {}", + call.user, debt_str, debt_symbol, receive_type + ); + + // Create detailed parameter fields + let fields = vec![ + AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: format!("{:?}", call.user), + label: "User Being Liquidated".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: format!("{:?}", call.user), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: format!("{} {}", debt_str, debt_symbol), + label: "Debt to Cover".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: format!( + "{} {} ({:?}, raw: {})", + debt_str, debt_symbol, call.debtAsset, call.debtToCover + ), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: collateral_symbol.clone(), + label: "Collateral Asset".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: format!("{} ({:?})", collateral_symbol, call.collateralAsset), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: receive_type.clone(), + label: "Receive As".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: if call.receiveAToken { + format!("{} (aToken)", receive_type) + } else { + format!("{} (underlying)", receive_type) + }, + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + ]; + + Some(SignablePayloadField::PreviewLayout { + common: SignablePayloadFieldCommon { + fallback_text: summary.clone(), + label: "Aave Liquidation".to_string(), + }, + preview_layout: SignablePayloadFieldPreviewLayout { + title: Some(SignablePayloadFieldTextV2 { + text: "Aave v3 Liquidation Call".to_string(), + }), + subtitle: Some(SignablePayloadFieldTextV2 { text: summary }), + condensed: None, + expanded: Some(SignablePayloadFieldListLayout { fields }), + }, + }) + } + + /// Decodes supplyWithPermit operation + fn decode_supply_with_permit( + input: &[u8], + chain_id: u64, + registry: Option<&ContractRegistry>, + ) -> Option { + let call = IPool::supplyWithPermitCall::abi_decode(input).ok()?; + + let token_symbol = registry + .and_then(|r| r.get_token_symbol(chain_id, call.asset)) + .unwrap_or_else(|| format!("{:?}", call.asset)); + + let amount_u128: u128 = call.amount.to_string().parse().unwrap_or(0); + let (amount_str, _) = registry + .and_then(|r| r.format_token_amount(chain_id, call.asset, amount_u128)) + .unwrap_or_else(|| (call.amount.to_string(), token_symbol.clone())); + + let behalf_text = if call.onBehalfOf != Address::ZERO { + format!(" on behalf of {:?}", call.onBehalfOf) + } else { + String::new() + }; + + let summary = format!( + "Supply {} {} with permit{}", + amount_str, token_symbol, behalf_text + ); + + let fields = vec![ + AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: format!("{} ({:?})", token_symbol, call.asset), + label: "Asset".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: format!("{} ({:?})", token_symbol, call.asset), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: format!("{} {}", amount_str, token_symbol), + label: "Amount".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: format!("{} {} (raw: {})", amount_str, token_symbol, call.amount), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: format!("{:?}", call.onBehalfOf), + label: "On Behalf Of".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: format!("{:?}", call.onBehalfOf), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: "ERC-2612 Permit".to_string(), + label: "Authorization".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: "Using gasless ERC-2612 permit signature".to_string(), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + ]; + + Some(SignablePayloadField::PreviewLayout { + common: SignablePayloadFieldCommon { + fallback_text: summary.clone(), + label: "Aave Supply with Permit".to_string(), + }, + preview_layout: SignablePayloadFieldPreviewLayout { + title: Some(SignablePayloadFieldTextV2 { + text: "Aave v3 Supply with Permit".to_string(), + }), + subtitle: Some(SignablePayloadFieldTextV2 { text: summary }), + condensed: None, + expanded: Some(SignablePayloadFieldListLayout { fields }), + }, + }) + } + + /// Decodes repayWithPermit operation + fn decode_repay_with_permit( + input: &[u8], + chain_id: u64, + registry: Option<&ContractRegistry>, + ) -> Option { + let call = IPool::repayWithPermitCall::abi_decode(input).ok()?; + + let token_symbol = registry + .and_then(|r| r.get_token_symbol(chain_id, call.asset)) + .unwrap_or_else(|| format!("{:?}", call.asset)); + + let is_max = call.amount == U256::MAX; + let amount_display = if is_max { + "Full debt".to_string() + } else { + let amount_u128: u128 = call.amount.to_string().parse().unwrap_or(0); + let (amount_str, _) = registry + .and_then(|r| r.format_token_amount(chain_id, call.asset, amount_u128)) + .unwrap_or_else(|| (call.amount.to_string(), token_symbol.clone())); + format!("{} {}", amount_str, token_symbol) + }; + + let rate_mode_str = call.interestRateMode.to_string(); + let rate_mode = match rate_mode_str.as_str() { + "1" => "Stable (Deprecated)", + "2" => "Variable", + _ => &rate_mode_str, + }; + + let behalf_text = if call.onBehalfOf != Address::ZERO { + format!(" on behalf of {:?}", call.onBehalfOf) + } else { + String::new() + }; + + let summary = format!( + "Repay {} at {} rate with permit{}", + amount_display, rate_mode, behalf_text + ); + + let fields = vec![ + AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: format!("{} ({:?})", token_symbol, call.asset), + label: "Asset".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: format!("{} ({:?})", token_symbol, call.asset), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: amount_display.clone(), + label: "Amount".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: if is_max { + format!("Full debt ({})", call.amount) + } else { + format!("{} (raw: {})", amount_display, call.amount) + }, + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: rate_mode.to_string(), + label: "Interest Rate Mode".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: format!("{} ({})", rate_mode, call.interestRateMode), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: format!("{:?}", call.onBehalfOf), + label: "On Behalf Of".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: format!("{:?}", call.onBehalfOf), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: "ERC-2612 Permit".to_string(), + label: "Authorization".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: "Using gasless ERC-2612 permit signature".to_string(), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + ]; + + Some(SignablePayloadField::PreviewLayout { + common: SignablePayloadFieldCommon { + fallback_text: summary.clone(), + label: "Aave Repay with Permit".to_string(), + }, + preview_layout: SignablePayloadFieldPreviewLayout { + title: Some(SignablePayloadFieldTextV2 { + text: "Aave v3 Repay with Permit".to_string(), + }), + subtitle: Some(SignablePayloadFieldTextV2 { text: summary }), + condensed: None, + expanded: Some(SignablePayloadFieldListLayout { fields }), + }, + }) + } + + /// Decodes repayWithATokens operation + fn decode_repay_with_atokens( + input: &[u8], + chain_id: u64, + registry: Option<&ContractRegistry>, + ) -> Option { + let call = IPool::repayWithATokensCall::abi_decode(input).ok()?; + + let token_symbol = registry + .and_then(|r| r.get_token_symbol(chain_id, call.asset)) + .unwrap_or_else(|| format!("{:?}", call.asset)); + + let is_max = call.amount == U256::MAX; + let amount_display = if is_max { + "Full debt".to_string() + } else { + let amount_u128: u128 = call.amount.to_string().parse().unwrap_or(0); + let (amount_str, _) = registry + .and_then(|r| r.format_token_amount(chain_id, call.asset, amount_u128)) + .unwrap_or_else(|| (call.amount.to_string(), token_symbol.clone())); + format!("{} {}", amount_str, token_symbol) + }; + + let rate_mode_str = call.interestRateMode.to_string(); + let rate_mode = match rate_mode_str.as_str() { + "1" => "Stable (Deprecated)", + "2" => "Variable", + _ => &rate_mode_str, + }; + + let summary = format!( + "Repay {} using aTokens at {} rate", + amount_display, rate_mode + ); + + let fields = vec![ + AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: format!("{} ({:?})", token_symbol, call.asset), + label: "Asset".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: format!("{} ({:?})", token_symbol, call.asset), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: amount_display.clone(), + label: "Amount".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: if is_max { + format!("Full debt ({})", call.amount) + } else { + format!("{} (raw: {})", amount_display, call.amount) + }, + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: rate_mode.to_string(), + label: "Interest Rate Mode".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: format!("{} ({})", rate_mode, call.interestRateMode), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: "Using aTokens".to_string(), + label: "Repayment Method".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: "Repaying with aTokens (no transfer required)".to_string(), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + ]; + + Some(SignablePayloadField::PreviewLayout { + common: SignablePayloadFieldCommon { + fallback_text: summary.clone(), + label: "Aave Repay with aTokens".to_string(), + }, + preview_layout: SignablePayloadFieldPreviewLayout { + title: Some(SignablePayloadFieldTextV2 { + text: "Aave v3 Repay with aTokens".to_string(), + }), + subtitle: Some(SignablePayloadFieldTextV2 { text: summary }), + condensed: None, + expanded: Some(SignablePayloadFieldListLayout { fields }), + }, + }) + } + + /// Decodes setUserUseReserveAsCollateral operation + fn decode_set_user_use_reserve_as_collateral( + input: &[u8], + chain_id: u64, + registry: Option<&ContractRegistry>, + ) -> Option { + let call = IPool::setUserUseReserveAsCollateralCall::abi_decode(input).ok()?; + + let token_symbol = registry + .and_then(|r| r.get_token_symbol(chain_id, call.asset)) + .unwrap_or_else(|| format!("{:?}", call.asset)); + + let action = if call.useAsCollateral { + "Enable" + } else { + "Disable" + }; + + let summary = format!("{} {} as collateral", action, token_symbol); + + let fields = vec![ + AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: format!("{} ({:?})", token_symbol, call.asset), + label: "Asset".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: format!("{} ({:?})", token_symbol, call.asset), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: action.to_string(), + label: "Action".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: if call.useAsCollateral { + "Enable as collateral (allows borrowing)" + } else { + "Disable as collateral (reduces borrowing power)" + } + .to_string(), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + ]; + + Some(SignablePayloadField::PreviewLayout { + common: SignablePayloadFieldCommon { + fallback_text: summary.clone(), + label: "Aave Collateral Setting".to_string(), + }, + preview_layout: SignablePayloadFieldPreviewLayout { + title: Some(SignablePayloadFieldTextV2 { + text: "Aave v3 Set Collateral".to_string(), + }), + subtitle: Some(SignablePayloadFieldTextV2 { text: summary }), + condensed: None, + expanded: Some(SignablePayloadFieldListLayout { fields }), + }, + }) + } + + fn decode_flash_loan( + input: &[u8], + chain_id: u64, + registry: Option<&ContractRegistry>, + ) -> Option { + let call = IPool::flashLoanCall::abi_decode(input).ok()?; + + let assets_count = call.assets.len(); + let assets_text = if assets_count == 1 { + let asset = call.assets[0]; + let token_symbol = registry + .and_then(|r| r.get_token_symbol(chain_id, asset)) + .unwrap_or_else(|| format!("{:?}", asset)); + + let amount_u128: u128 = call.amounts[0].to_string().parse().unwrap_or(0); + let (amount_str, _) = registry + .and_then(|r| r.format_token_amount(chain_id, asset, amount_u128)) + .unwrap_or_else(|| (call.amounts[0].to_string(), token_symbol.clone())); + + format!("{} {}", amount_str, token_symbol) + } else { + format!("{} assets", assets_count) + }; + + let summary = format!("Flash loan {}", assets_text); + + let mut fields = vec![]; + + for (i, (asset, amount)) in call.assets.iter().zip(call.amounts.iter()).enumerate() { + let token_symbol = registry + .and_then(|r| r.get_token_symbol(chain_id, *asset)) + .unwrap_or_else(|| format!("{:?}", asset)); + + let amount_u128: u128 = amount.to_string().parse().unwrap_or(0); + let (amount_str, _) = registry + .and_then(|r| r.format_token_amount(chain_id, *asset, amount_u128)) + .unwrap_or_else(|| (amount.to_string(), token_symbol.clone())); + + fields.push(AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: format!("{} {}", amount_str, token_symbol), + label: format!("Asset {}", i + 1), + }, + text_v2: SignablePayloadFieldTextV2 { + text: format!("{} {} ({:?})", amount_str, token_symbol, asset), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }); + } + + fields.push(AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: format!("{:?}", call.receiverAddress), + label: "Receiver".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: format!("{:?}", call.receiverAddress), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }); + + fields.push(AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: format!("{:?}", call.onBehalfOf), + label: "On Behalf Of".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: format!("{:?}", call.onBehalfOf), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }); + + Some(SignablePayloadField::PreviewLayout { + common: SignablePayloadFieldCommon { + fallback_text: summary.clone(), + label: "Aave Flash Loan".to_string(), + }, + preview_layout: SignablePayloadFieldPreviewLayout { + title: Some(SignablePayloadFieldTextV2 { + text: "Aave v3 Flash Loan".to_string(), + }), + subtitle: Some(SignablePayloadFieldTextV2 { text: summary }), + condensed: None, + expanded: Some(SignablePayloadFieldListLayout { fields }), + }, + }) + } + + fn decode_flash_loan_simple( + input: &[u8], + chain_id: u64, + registry: Option<&ContractRegistry>, + ) -> Option { + let call = IPool::flashLoanSimpleCall::abi_decode(input).ok()?; + + let token_symbol = registry + .and_then(|r| r.get_token_symbol(chain_id, call.asset)) + .unwrap_or_else(|| format!("{:?}", call.asset)); + + let amount_u128: u128 = call.amount.to_string().parse().unwrap_or(0); + let (amount_str, _) = registry + .and_then(|r| r.format_token_amount(chain_id, call.asset, amount_u128)) + .unwrap_or_else(|| (call.amount.to_string(), token_symbol.clone())); + + let summary = format!("Flash loan {} {}", amount_str, token_symbol); + + let fields = vec![ + AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: format!("{} ({:?})", token_symbol, call.asset), + label: "Asset".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: format!("{} ({:?})", token_symbol, call.asset), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: format!("{} {}", amount_str, token_symbol), + label: "Amount".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: format!("{} {} (raw: {})", amount_str, token_symbol, call.amount), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: format!("{:?}", call.receiverAddress), + label: "Receiver".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: format!("{:?}", call.receiverAddress), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + ]; + + Some(SignablePayloadField::PreviewLayout { + common: SignablePayloadFieldCommon { + fallback_text: summary.clone(), + label: "Aave Flash Loan".to_string(), + }, + preview_layout: SignablePayloadFieldPreviewLayout { + title: Some(SignablePayloadFieldTextV2 { + text: "Aave v3 Simple Flash Loan".to_string(), + }), + subtitle: Some(SignablePayloadFieldTextV2 { text: summary }), + condensed: None, + expanded: Some(SignablePayloadFieldListLayout { fields }), + }, + }) + } +} + +/// ContractVisualizer implementation for Aave v3 Pool +pub struct PoolContractVisualizer { + inner: PoolVisualizer, +} + +impl PoolContractVisualizer { + pub fn new() -> Self { + Self { + inner: PoolVisualizer::new(), + } + } +} + +impl Default for PoolContractVisualizer { + fn default() -> Self { + Self::new() + } +} + +impl crate::visualizer::ContractVisualizer for PoolContractVisualizer { + fn contract_type(&self) -> &str { + AaveV3PoolContract::short_type_id() + } + + fn visualize( + &self, + context: &VisualizerContext, + ) -> Result>, visualsign::vsptrait::VisualSignError> { + let contract_registry = ContractRegistry::with_default_protocols(); + + if let Some(field) = self.inner.visualize_pool_operation( + &context.calldata, + context.chain_id, + Some(&contract_registry), + ) { + let annotated = AnnotatedPayloadField { + signable_payload_field: field, + static_annotation: None, + dynamic_annotation: None, + }; + + Ok(Some(vec![annotated])) + } else { + Ok(None) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use alloy_primitives::{FixedBytes, address}; + + #[test] + fn test_decode_supply() { + let call = IPool::supplyCall { + asset: address!("A0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"), // USDC + amount: U256::from(1000000000u64), // 1000 USDC (6 decimals) + onBehalfOf: address!("0742d35Cc6634C0532925a3b844Bc9e7595f0bEb"), + referralCode: 0, + }; + + let input = IPool::supplyCall::abi_encode(&call); + let result = PoolVisualizer::new().visualize_pool_operation(&input, 1, None); + + assert!(result.is_some(), "Should decode supply successfully"); + + if let Some(SignablePayloadField::PreviewLayout { + common, + preview_layout, + }) = result + { + assert_eq!(common.label, "Aave Supply"); + assert!(common.fallback_text.contains("Supply")); + + assert!(preview_layout.expanded.is_some()); + if let Some(expanded) = preview_layout.expanded { + assert_eq!(expanded.fields.len(), 3, "Should have 3 parameter fields"); + } + } else { + panic!("Expected PreviewLayout"); + } + } + + #[test] + fn test_decode_borrow() { + let call = IPool::borrowCall { + asset: address!("A0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"), + amount: U256::from(500000000u64), + interestRateMode: U256::from(2), // Variable + referralCode: 0, + onBehalfOf: address!("0742d35Cc6634C0532925a3b844Bc9e7595f0bEb"), + }; + + let input = IPool::borrowCall::abi_encode(&call); + let result = PoolVisualizer::new().visualize_pool_operation(&input, 1, None); + + assert!(result.is_some(), "Should decode borrow successfully"); + + if let Some(SignablePayloadField::PreviewLayout { common, .. }) = result { + assert_eq!(common.label, "Aave Borrow"); + assert!(common.fallback_text.contains("Borrow")); + assert!(common.fallback_text.contains("Variable")); + } + } + + #[test] + fn test_decode_withdraw_max() { + let call = IPool::withdrawCall { + asset: address!("A0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"), + amount: U256::MAX, // Withdraw all + to: address!("0742d35Cc6634C0532925a3b844Bc9e7595f0bEb"), + }; + + let input = IPool::withdrawCall::abi_encode(&call); + let result = PoolVisualizer::new().visualize_pool_operation(&input, 1, None); + + assert!(result.is_some(), "Should decode withdraw successfully"); + + if let Some(SignablePayloadField::PreviewLayout { common, .. }) = result { + assert!( + common.fallback_text.contains("Maximum"), + "Should indicate max withdrawal" + ); + } + } + + #[test] + fn test_decode_repay_full_debt() { + let call = IPool::repayCall { + asset: address!("A0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"), + amount: U256::MAX, // Repay full debt + interestRateMode: U256::from(2), + onBehalfOf: address!("0742d35Cc6634C0532925a3b844Bc9e7595f0bEb"), + }; + + let input = IPool::repayCall::abi_encode(&call); + let result = PoolVisualizer::new().visualize_pool_operation(&input, 1, None); + + assert!(result.is_some(), "Should decode repay successfully"); + + if let Some(SignablePayloadField::PreviewLayout { common, .. }) = result { + assert!( + common.fallback_text.contains("Full debt"), + "Should indicate full debt repayment" + ); + } + } + + #[test] + fn test_real_aave_supply_110k_usdt() { + // Real transaction: 0x394da4860478e24eaf99007a617f2009ed6a4c2f3a9ac43cf4da1e8ad1db2400 + // Just the calldata! + let input_hex = "617ba037000000000000000000000000dac17f958d2ee523a2206206994597c13d831ec7000000000000000000000000000000000000000000000000000000199c82cc00000000000000000000000000b6559478b59836376da9937c4c697ddb21779e490000000000000000000000000000000000000000000000000000000000000000"; + let input = hex::decode(input_hex).unwrap(); + + let registry = ContractRegistry::with_default_protocols(); + let result = PoolVisualizer::new() + .visualize_pool_operation(&input, 1, Some(®istry)) + .expect("Should decode successfully"); + + if let SignablePayloadField::PreviewLayout { + common, + preview_layout, + } = result + { + println!("\n=== Real Aave Supply Transaction ==="); + println!("Label: {}", common.label); + println!("Summary: {}", common.fallback_text); + + if let Some(title) = &preview_layout.title { + println!("Title: {}", title.text); + } + if let Some(subtitle) = &preview_layout.subtitle { + println!("Subtitle: {}", subtitle.text); + } + + if let Some(expanded) = &preview_layout.expanded { + println!("\nDetailed Parameters:"); + for field in &expanded.fields { + match &field.signable_payload_field { + SignablePayloadField::TextV2 { common, text_v2 } => { + println!(" {}: {}", common.label, text_v2.text); + } + _ => {} + } + } + } + println!("=== End ===\n"); + + // Assertions + assert_eq!(common.label, "Aave Supply"); + assert!(common.fallback_text.contains("USDT")); + assert!(common.fallback_text.contains("110000")); + } else { + panic!("Expected PreviewLayout"); + } + } + + #[test] + fn test_decode_l2_liquidation_call() { + let mut args1_bytes = [0u8; 32]; + let mut args2_bytes = [0u8; 32]; + + let collateral_asset_id: u16 = 12; + let debt_asset_id: u16 = 0; + let user_addr = address!("0742d35Cc6634C0532925a3b844Bc9e7595f0bEb"); + let debt_to_cover: u128 = 1000000; + let receive_atoken = true; + + args1_bytes[30..32].copy_from_slice(&collateral_asset_id.to_be_bytes()); + args1_bytes[28..30].copy_from_slice(&debt_asset_id.to_be_bytes()); + args1_bytes[8..28].copy_from_slice(user_addr.as_slice()); + + args2_bytes[16..32].copy_from_slice(&debt_to_cover.to_be_bytes()); + if receive_atoken { + args2_bytes[15] = 1; + } + + let call = IL2Pool::liquidationCallCall { + args1: FixedBytes::from(args1_bytes), + args2: FixedBytes::from(args2_bytes), + }; + + let input = IL2Pool::liquidationCallCall::abi_encode(&call); + let result = PoolVisualizer::new().visualize_pool_operation(&input, 42161, None); + + assert!( + result.is_some(), + "Should decode L2 liquidation call successfully" + ); + + if let Some(SignablePayloadField::PreviewLayout { common, .. }) = result { + assert_eq!(common.label, "Aave L2 Liquidation"); + assert!(common.fallback_text.contains("Liquidate")); + assert!(common.fallback_text.contains("debt")); + } else { + panic!("Expected PreviewLayout"); + } + } + + #[test] + fn test_real_arbitrum_liquidation_standard_pool() { + // Real Arbitrum liquidation transaction + // Tx uses STANDARD Pool liquidationCall(address,address,address,uint256,bool) + // NOT the L2Pool liquidationCall(bytes32,bytes32) + // + // Details: + // - collateralAsset: 0xfd086bc7cd5c481dcc9c85ebe478a1c0b69fcbb9 (USDT on Arbitrum) + // - debtAsset: 0xfd086bc7cd5c481dcc9c85ebe478a1c0b69fcbb9 (USDT) + // - borrower: 0x6cd6f60cf17566f145713b0f909fc5ce6ef5eb75 + // - debtToCover: 8165790531 (8.165 USDT, 6 decimals) + // - receiveAToken: false + + let input_hex = "00a718a9000000000000000000000000fd086bc7cd5c481dcc9c85ebe478a1c0b69fcbb9000000000000000000000000fd086bc7cd5c481dcc9c85ebe478a1c0b69fcbb90000000000000000000000006cd6f60cf17566f145713b0f909fc5ce6ef5eb7500000000000000000000000000000000000000000000000000000001e6b813430000000000000000000000000000000000000000000000000000000000000000"; + let input = hex::decode(input_hex).unwrap(); + + let result = PoolVisualizer::new().visualize_pool_operation(&input, 42161, None); + + assert!( + result.is_some(), + "Should decode real liquidation transaction" + ); + + if let Some(SignablePayloadField::PreviewLayout { common, .. }) = result { + assert_eq!(common.label, "Aave Liquidation"); + assert!(common.fallback_text.contains("Liquidate")); + } else { + panic!("Expected PreviewLayout"); + } + } + + #[test] + fn test_decode_l2_set_user_use_reserve_as_collateral() { + let mut args_bytes = [0u8; 32]; + + let asset_id: u16 = 12; + let use_as_collateral = true; + + args_bytes[30..32].copy_from_slice(&asset_id.to_be_bytes()); + if use_as_collateral { + args_bytes[29] = 1; + } + + let call = IL2Pool::setUserUseReserveAsCollateralCall { + args: FixedBytes::from(args_bytes), + }; + + let input = IL2Pool::setUserUseReserveAsCollateralCall::abi_encode(&call); + let result = PoolVisualizer::new().visualize_pool_operation(&input, 42161, None); + + assert!( + result.is_some(), + "Should decode L2 set collateral successfully" + ); + + if let Some(SignablePayloadField::PreviewLayout { common, .. }) = result { + assert_eq!(common.label, "Aave L2 Collateral Setting"); + assert!(common.fallback_text.contains("Enable")); + assert!(common.fallback_text.contains("collateral")); + } else { + panic!("Expected PreviewLayout"); + } + } + + #[test] + fn test_decode_repay_with_atokens() { + let call = IPool::repayWithATokensCall { + asset: address!("A0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"), // USDC + amount: U256::from(500000000u64), // 500 USDC + interestRateMode: U256::from(2), // Variable + }; + + let input = IPool::repayWithATokensCall::abi_encode(&call); + let result = PoolVisualizer::new().visualize_pool_operation(&input, 1, None); + + assert!( + result.is_some(), + "Should decode repayWithATokens successfully" + ); + + if let Some(SignablePayloadField::PreviewLayout { common, .. }) = result { + assert_eq!(common.label, "Aave Repay with aTokens"); + assert!(common.fallback_text.contains("Repay")); + assert!(common.fallback_text.contains("aTokens")); + assert!(common.fallback_text.contains("Variable")); + } else { + panic!("Expected PreviewLayout"); + } + } + + #[test] + fn test_decode_repay_with_atokens_max() { + let call = IPool::repayWithATokensCall { + asset: address!("A0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"), + amount: U256::MAX, // Full debt repayment + interestRateMode: U256::from(2), + }; + + let input = IPool::repayWithATokensCall::abi_encode(&call); + let result = PoolVisualizer::new().visualize_pool_operation(&input, 1, None); + + assert!(result.is_some()); + + if let Some(SignablePayloadField::PreviewLayout { common, .. }) = result { + assert!(common.fallback_text.contains("Full debt")); + } else { + panic!("Expected PreviewLayout"); + } + } + + #[test] + fn test_decode_set_user_use_reserve_as_collateral_enable() { + let call = IPool::setUserUseReserveAsCollateralCall { + asset: address!("A0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"), // USDC + useAsCollateral: true, + }; + + let input = IPool::setUserUseReserveAsCollateralCall::abi_encode(&call); + let result = PoolVisualizer::new().visualize_pool_operation(&input, 1, None); + + assert!( + result.is_some(), + "Should decode setUserUseReserveAsCollateral successfully" + ); + + if let Some(SignablePayloadField::PreviewLayout { common, .. }) = result { + assert_eq!(common.label, "Aave Collateral Setting"); + assert!(common.fallback_text.contains("Enable")); + assert!(common.fallback_text.contains("collateral")); + } else { + panic!("Expected PreviewLayout"); + } + } + + #[test] + fn test_decode_set_user_use_reserve_as_collateral_disable() { + let call = IPool::setUserUseReserveAsCollateralCall { + asset: address!("A0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"), + useAsCollateral: false, + }; + + let input = IPool::setUserUseReserveAsCollateralCall::abi_encode(&call); + let result = PoolVisualizer::new().visualize_pool_operation(&input, 1, None); + + assert!(result.is_some()); + + if let Some(SignablePayloadField::PreviewLayout { common, .. }) = result { + assert!(common.fallback_text.contains("Disable")); + assert!(common.fallback_text.contains("collateral")); + } else { + panic!("Expected PreviewLayout"); + } + } + + #[test] + fn test_decode_supply_with_permit() { + let call = IPool::supplyWithPermitCall { + asset: address!("A0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"), // USDC + amount: U256::from(1000000000u64), // 1000 USDC + onBehalfOf: address!("0742d35Cc6634C0532925a3b844Bc9e7595f0bEb"), + referralCode: 0, + deadline: U256::from(1700000000u64), + permitV: 27, + permitR: FixedBytes::from([1u8; 32]), + permitS: FixedBytes::from([2u8; 32]), + }; + + let input = IPool::supplyWithPermitCall::abi_encode(&call); + let result = PoolVisualizer::new().visualize_pool_operation(&input, 1, None); + + assert!( + result.is_some(), + "Should decode supplyWithPermit successfully" + ); + + if let Some(SignablePayloadField::PreviewLayout { + common, + preview_layout, + }) = result + { + assert_eq!(common.label, "Aave Supply with Permit"); + assert!(common.fallback_text.contains("Supply")); + assert!(common.fallback_text.contains("permit")); + + // Verify permit field is present + if let Some(expanded) = preview_layout.expanded { + let has_permit_field = expanded.fields.iter().any(|f| { + if let SignablePayloadField::TextV2 { common, text_v2 } = + &f.signable_payload_field + { + common.label == "Authorization" && text_v2.text.contains("ERC-2612") + } else { + false + } + }); + assert!(has_permit_field, "Should have ERC-2612 permit field"); + } + } else { + panic!("Expected PreviewLayout"); + } + } + + #[test] + fn test_decode_repay_with_permit() { + let call = IPool::repayWithPermitCall { + asset: address!("A0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"), // USDC + amount: U256::from(500000000u64), // 500 USDC + interestRateMode: U256::from(2), // Variable + onBehalfOf: address!("0742d35Cc6634C0532925a3b844Bc9e7595f0bEb"), + deadline: U256::from(1700000000u64), + permitV: 27, + permitR: FixedBytes::from([1u8; 32]), + permitS: FixedBytes::from([2u8; 32]), + }; + + let input = IPool::repayWithPermitCall::abi_encode(&call); + let result = PoolVisualizer::new().visualize_pool_operation(&input, 1, None); + + assert!( + result.is_some(), + "Should decode repayWithPermit successfully" + ); + + if let Some(SignablePayloadField::PreviewLayout { + common, + preview_layout, + }) = result + { + assert_eq!(common.label, "Aave Repay with Permit"); + assert!(common.fallback_text.contains("Repay")); + assert!(common.fallback_text.contains("permit")); + assert!(common.fallback_text.contains("Variable")); + + // Verify permit field is present + if let Some(expanded) = preview_layout.expanded { + let has_permit_field = expanded.fields.iter().any(|f| { + if let SignablePayloadField::TextV2 { common, text_v2 } = + &f.signable_payload_field + { + common.label == "Authorization" && text_v2.text.contains("ERC-2612") + } else { + false + } + }); + assert!(has_permit_field, "Should have ERC-2612 permit field"); + } + } else { + panic!("Expected PreviewLayout"); + } + } + + #[test] + fn test_decode_l2_supply_with_permit() { + let mut args_bytes = [0u8; 32]; + + let asset_id: u16 = 12; // USDC on Arbitrum + let amount: u128 = 1000000000; // 1000 USDC + let referral_code: u16 = 0; + let deadline: u32 = 1700000000; + let permit_v: u8 = 27; + + // Pack according to IL2Pool.sol comment: + // | 0-padding | permitV | shortenedDeadline | referralCode | shortenedAmount | assetId | + // | 48 bits | 8 bits | 32 bits | 16 bits | 128 bits | 16 bits | + args_bytes[30..32].copy_from_slice(&asset_id.to_be_bytes()); + args_bytes[14..30].copy_from_slice(&amount.to_be_bytes()); + args_bytes[12..14].copy_from_slice(&referral_code.to_be_bytes()); + args_bytes[8..12].copy_from_slice(&deadline.to_be_bytes()); + args_bytes[7] = permit_v; + + let call = IL2Pool::supplyWithPermitCall { + args: FixedBytes::from(args_bytes), + r: FixedBytes::from([1u8; 32]), + s: FixedBytes::from([2u8; 32]), + }; + + let input = IL2Pool::supplyWithPermitCall::abi_encode(&call); + let result = PoolVisualizer::new().visualize_pool_operation(&input, 42161, None); + + if result.is_none() { + eprintln!( + "Input bytes (first 20): {:?}", + &input[0..20.min(input.len())] + ); + eprintln!("Input length: {}", input.len()); + } + + assert!( + result.is_some(), + "Should decode L2 supplyWithPermit successfully" + ); + + if let Some(SignablePayloadField::PreviewLayout { + common, + preview_layout, + }) = result + { + assert_eq!(common.label, "Aave L2 Supply with Permit"); + assert!(common.fallback_text.contains("Supply")); + assert!(common.fallback_text.contains("permit")); + + // Verify permit field is present + if let Some(expanded) = preview_layout.expanded { + let has_permit_field = expanded.fields.iter().any(|f| { + if let SignablePayloadField::TextV2 { common, text_v2 } = + &f.signable_payload_field + { + common.label == "Authorization" && text_v2.text.contains("ERC-2612") + } else { + false + } + }); + assert!(has_permit_field, "Should have ERC-2612 permit field"); + } + } else { + panic!("Expected PreviewLayout"); + } + } + + #[test] + fn test_decode_l2_repay_with_permit() { + let mut args_bytes = [0u8; 32]; + + let asset_id: u16 = 0; // WETH on Arbitrum + let amount: u128 = 500000000000000000; // 0.5 ETH + let interest_rate_mode: u8 = 2; // Variable + let deadline: u32 = 1700000000; + let permit_v: u8 = 27; + + // Pack according to IL2Pool.sol comment: + // | 0-padding | permitV | shortenedDeadline | shortenedInterestRateMode | shortenedAmount | assetId | + // | 64 bits | 8 bits | 32 bits | 8 bits | 128 bits | 16 bits | + args_bytes[30..32].copy_from_slice(&asset_id.to_be_bytes()); + args_bytes[14..30].copy_from_slice(&amount.to_be_bytes()); + args_bytes[13] = interest_rate_mode; + args_bytes[9..13].copy_from_slice(&deadline.to_be_bytes()); + args_bytes[8] = permit_v; + + let call = IL2Pool::repayWithPermitCall { + args: FixedBytes::from(args_bytes), + r: FixedBytes::from([1u8; 32]), + s: FixedBytes::from([2u8; 32]), + }; + + let input = IL2Pool::repayWithPermitCall::abi_encode(&call); + let result = PoolVisualizer::new().visualize_pool_operation(&input, 42161, None); + + assert!( + result.is_some(), + "Should decode L2 repayWithPermit successfully" + ); + + if let Some(SignablePayloadField::PreviewLayout { + common, + preview_layout, + }) = result + { + assert_eq!(common.label, "Aave L2 Repay with Permit"); + assert!(common.fallback_text.contains("Repay")); + assert!(common.fallback_text.contains("permit")); + assert!(common.fallback_text.contains("Variable")); + + // Verify permit field is present + if let Some(expanded) = preview_layout.expanded { + let has_permit_field = expanded.fields.iter().any(|f| { + if let SignablePayloadField::TextV2 { common, text_v2 } = + &f.signable_payload_field + { + common.label == "Authorization" && text_v2.text.contains("ERC-2612") + } else { + false + } + }); + assert!(has_permit_field, "Should have ERC-2612 permit field"); + } + } else { + panic!("Expected PreviewLayout"); + } + } + + #[test] + fn test_visualize_empty_input() { + let result = PoolVisualizer::new().visualize_pool_operation(&[], 1, None); + assert_eq!(result, None, "Empty input should return None"); + } + + #[test] + fn test_visualize_too_short_input() { + // Input shorter than function selector (4 bytes) + let result = PoolVisualizer::new().visualize_pool_operation(&[0x01, 0x02, 0x03], 1, None); + assert_eq!(result, None, "Too-short input should return None"); + } + + #[test] + fn test_visualize_invalid_function_selector() { + // Valid length but unrecognized function selector + let mut invalid_input = vec![0xff, 0xff, 0xff, 0xff]; // Invalid selector + invalid_input.extend_from_slice(&[0u8; 128]); // Add some data + + let result = PoolVisualizer::new().visualize_pool_operation(&invalid_input, 1, None); + assert_eq!( + result, None, + "Unrecognized function selector should return None" + ); + } + + #[test] + fn test_decode_borrow_invalid_interest_rate_mode() { + let call = IPool::borrowCall { + asset: address!("A0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"), + amount: U256::from(1000000u64), + interestRateMode: U256::from(999), // Invalid + referralCode: 0, + onBehalfOf: address!("0742d35Cc6634C0532925a3b844Bc9e7595f0bEb"), + }; + + let input = IPool::borrowCall::abi_encode(&call); + let result = PoolVisualizer::new().visualize_pool_operation(&input, 1, None); + + assert!(result.is_some()); + if let Some(SignablePayloadField::PreviewLayout { common, .. }) = result { + assert!( + common.fallback_text.contains("Unknown") || common.fallback_text.contains("999") + ); + } + } + + #[test] + fn test_supply_without_registry_shows_raw_address() { + let call = IPool::supplyCall { + asset: address!("A0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"), + amount: U256::from(1000000000u64), + onBehalfOf: address!("0742d35Cc6634C0532925a3b844Bc9e7595f0bEb"), + referralCode: 0, + }; + + let input = IPool::supplyCall::abi_encode(&call); + let result = PoolVisualizer::new().visualize_pool_operation(&input, 1, None); + + assert!(result.is_some()); + if let Some(SignablePayloadField::PreviewLayout { common, .. }) = result { + assert!( + common.fallback_text.contains("0xA0b86991") + || common.fallback_text.contains("0xa0b86991") + ); + } + } + + #[test] + fn test_supply_with_registry_shows_token_symbol() { + let call = IPool::supplyCall { + asset: address!("A0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"), + amount: U256::from(1000000000u64), + onBehalfOf: address!("0742d35Cc6634C0532925a3b844Bc9e7595f0bEb"), + referralCode: 0, + }; + + let input = IPool::supplyCall::abi_encode(&call); + let registry = ContractRegistry::with_default_protocols(); + let result = PoolVisualizer::new().visualize_pool_operation(&input, 1, Some(®istry)); + + assert!(result.is_some()); + if let Some(SignablePayloadField::PreviewLayout { common, .. }) = result { + assert!(common.fallback_text.contains("USDC")); + } + } + + #[test] + fn test_real_mainnet_borrow_transaction() { + let input_hex = "a415bcad000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48000000000000000000000000000000000000000000000000000000003b9aca0000000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000000000000000000000000000b6559478b59836376da9937c4c697ddb21779e49"; + let input = hex::decode(input_hex).unwrap(); + + let registry = ContractRegistry::with_default_protocols(); + let result = PoolVisualizer::new().visualize_pool_operation(&input, 1, Some(®istry)); + + assert!(result.is_some()); + if let Some(SignablePayloadField::PreviewLayout { common, .. }) = result { + assert_eq!(common.label, "Aave Borrow"); + assert!(common.fallback_text.contains("USDC")); + assert!(common.fallback_text.contains("Variable")); + } + } +} diff --git a/src/chain_parsers/visualsign-ethereum/src/protocols/aave/contracts/voting_machine.rs b/src/chain_parsers/visualsign-ethereum/src/protocols/aave/contracts/voting_machine.rs new file mode 100644 index 00000000..77b912c4 --- /dev/null +++ b/src/chain_parsers/visualsign-ethereum/src/protocols/aave/contracts/voting_machine.rs @@ -0,0 +1,301 @@ +use alloy_sol_types::{SolCall as _, sol}; +use visualsign::{ + AnnotatedPayloadField, SignablePayloadField, SignablePayloadFieldCommon, + SignablePayloadFieldListLayout, SignablePayloadFieldPreviewLayout, SignablePayloadFieldTextV2, +}; + +use crate::registry::ContractRegistry; + +// Aave Governance VotingMachine interface definitions +// +// Official Documentation: +// - Technical Reference: https://docs.aave.com/governance/master/aave-governance-v3 +// - Contract Source: https://github.com/bgd-labs/aave-governance-v3 +sol! { + interface IVotingMachine { + function submitVote(uint256 proposalId, bool support) external; + function submitVoteAsRepresentative( + uint256 proposalId, + bool support, + address[] calldata votingTokens + ) external; + } +} + +pub struct VotingMachineVisualizer; + +impl VotingMachineVisualizer { + pub fn visualize_vote( + &self, + input: &[u8], + _chain_id: u64, + _registry: Option<&ContractRegistry>, + ) -> Option { + if input.len() < 4 { + return None; + } + + // Try submitVote + if let Ok(call) = IVotingMachine::submitVoteCall::abi_decode(input) { + return Self::decode_submit_vote(&call); + } + + // Try submitVoteAsRepresentative + if let Ok(call) = IVotingMachine::submitVoteAsRepresentativeCall::abi_decode(input) { + return Self::decode_submit_vote_as_representative(&call); + } + + None + } + + fn decode_submit_vote(call: &IVotingMachine::submitVoteCall) -> Option { + let vote_direction = if call.support { "For" } else { "Against" }; + let proposal_id = call.proposalId.to_string(); + let summary = format!("Vote {} on proposal #{}", vote_direction, proposal_id); + + let fields = vec![ + AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: proposal_id.clone(), + label: "Proposal ID".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { text: proposal_id }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: vote_direction.to_string(), + label: "Vote".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: vote_direction.to_string(), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + ]; + + Some(SignablePayloadField::PreviewLayout { + common: SignablePayloadFieldCommon { + fallback_text: summary.clone(), + label: "Aave Governance".to_string(), + }, + preview_layout: SignablePayloadFieldPreviewLayout { + title: Some(SignablePayloadFieldTextV2 { + text: "Aave Governance Vote".to_string(), + }), + subtitle: Some(SignablePayloadFieldTextV2 { text: summary }), + condensed: None, + expanded: Some(SignablePayloadFieldListLayout { fields }), + }, + }) + } + + fn decode_submit_vote_as_representative( + call: &IVotingMachine::submitVoteAsRepresentativeCall, + ) -> Option { + let vote_direction = if call.support { "For" } else { "Against" }; + let proposal_id = call.proposalId.to_string(); + let num_tokens = call.votingTokens.len(); + + let summary = format!( + "Vote {} on proposal #{} (as representative with {} token{})", + vote_direction, + proposal_id, + num_tokens, + if num_tokens == 1 { "" } else { "s" } + ); + + let mut fields = vec![ + AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: proposal_id.clone(), + label: "Proposal ID".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { text: proposal_id }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: vote_direction.to_string(), + label: "Vote".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: vote_direction.to_string(), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: num_tokens.to_string(), + label: "Voting Tokens".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: num_tokens.to_string(), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + ]; + + for (i, token) in call.votingTokens.iter().enumerate() { + let token_str = format!("{:?}", token); + fields.push(AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: token_str.clone(), + label: format!("Token {}", i + 1), + }, + text_v2: SignablePayloadFieldTextV2 { text: token_str }, + }, + static_annotation: None, + dynamic_annotation: None, + }); + } + + Some(SignablePayloadField::PreviewLayout { + common: SignablePayloadFieldCommon { + fallback_text: summary.clone(), + label: "Aave Governance".to_string(), + }, + preview_layout: SignablePayloadFieldPreviewLayout { + title: Some(SignablePayloadFieldTextV2 { + text: "Aave Governance Vote".to_string(), + }), + subtitle: Some(SignablePayloadFieldTextV2 { text: summary }), + condensed: None, + expanded: Some(SignablePayloadFieldListLayout { fields }), + }, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use alloy_primitives::{U256, address}; + + #[test] + fn test_decode_submit_vote_for() { + let call = IVotingMachine::submitVoteCall { + proposalId: U256::from(123), + support: true, + }; + + let input = IVotingMachine::submitVoteCall::abi_encode(&call); + let result = VotingMachineVisualizer.visualize_vote(&input, 1, None); + + assert!(result.is_some(), "Should decode submitVote successfully"); + + if let Some(SignablePayloadField::PreviewLayout { + common, + preview_layout, + }) = result + { + assert_eq!(common.label, "Aave Governance"); + assert!(common.fallback_text.contains("For")); + assert!(common.fallback_text.contains("123")); + assert!(preview_layout.subtitle.is_some()); + } else { + panic!("Expected PreviewLayout"); + } + } + + #[test] + fn test_decode_submit_vote_against() { + let call = IVotingMachine::submitVoteCall { + proposalId: U256::from(456), + support: false, + }; + + let input = IVotingMachine::submitVoteCall::abi_encode(&call); + let result = VotingMachineVisualizer.visualize_vote(&input, 137, None); + + assert!(result.is_some(), "Should decode submitVote successfully"); + + if let Some(SignablePayloadField::PreviewLayout { common, .. }) = result { + assert!(common.fallback_text.contains("Against")); + assert!(common.fallback_text.contains("456")); + } else { + panic!("Expected PreviewLayout"); + } + } + + #[test] + fn test_decode_submit_vote_as_representative() { + let call = IVotingMachine::submitVoteAsRepresentativeCall { + proposalId: U256::from(789), + support: true, + votingTokens: vec![ + address!("7Fc66500c84A76Ad7e9c93437bFc5Ac33E2DDaE9"), + address!("A0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"), + ], + }; + + let input = IVotingMachine::submitVoteAsRepresentativeCall::abi_encode(&call); + let result = VotingMachineVisualizer.visualize_vote(&input, 43114, None); + + assert!( + result.is_some(), + "Should decode submitVoteAsRepresentative successfully" + ); + + if let Some(SignablePayloadField::PreviewLayout { + common, + preview_layout, + }) = result + { + assert_eq!(common.label, "Aave Governance"); + assert!(common.fallback_text.contains("For")); + assert!(common.fallback_text.contains("789")); + assert!(common.fallback_text.contains("2 tokens")); + assert!(preview_layout.subtitle.is_some()); + } else { + panic!("Expected PreviewLayout"); + } + } + + #[test] + fn test_decode_submit_vote_as_representative_single_token() { + let call = IVotingMachine::submitVoteAsRepresentativeCall { + proposalId: U256::from(100), + support: false, + votingTokens: vec![address!("7Fc66500c84A76Ad7e9c93437bFc5Ac33E2DDaE9")], + }; + + let input = IVotingMachine::submitVoteAsRepresentativeCall::abi_encode(&call); + let result = VotingMachineVisualizer.visualize_vote(&input, 1, None); + + assert!(result.is_some(), "Should decode successfully"); + + if let Some(SignablePayloadField::PreviewLayout { common, .. }) = result { + assert!(common.fallback_text.contains("1 token")); + assert!(!common.fallback_text.contains("tokens")); + } else { + panic!("Expected PreviewLayout"); + } + } + + #[test] + fn test_invalid_input() { + let result = VotingMachineVisualizer.visualize_vote(&[], 1, None); + assert!(result.is_none(), "Should return None for empty input"); + + let invalid = vec![0xff, 0xff, 0xff, 0xff]; + let result = VotingMachineVisualizer.visualize_vote(&invalid, 1, None); + assert!(result.is_none(), "Should return None for invalid selector"); + } +} diff --git a/src/chain_parsers/visualsign-ethereum/src/protocols/aave/mod.rs b/src/chain_parsers/visualsign-ethereum/src/protocols/aave/mod.rs new file mode 100644 index 00000000..7ab48331 --- /dev/null +++ b/src/chain_parsers/visualsign-ethereum/src/protocols/aave/mod.rs @@ -0,0 +1,82 @@ +//! Aave v3 Protocol Decoders +//! +//! This module provides transaction decoders for Aave v3 protocol operations. +//! +//! # Supported Operations +//! - `supply`: Supply assets to Aave to earn interest +//! - `withdraw`: Withdraw supplied assets from Aave +//! - `borrow`: Borrow assets against collateral +//! - `repay`: Repay borrowed assets +//! - `liquidationCall`: Liquidate undercollateralized positions +//! +//! # Example +//! ```rust,ignore +//! use crate::protocols::aave::contracts::PoolVisualizer; +//! +//! let visualizer = PoolVisualizer {}; +//! let result = visualizer.visualize_pool_operation(calldata, chain_id, Some(®istry)); +//! ``` + +pub mod config; +pub mod contracts; + +use crate::registry::ContractRegistry; +use crate::visualizer::EthereumVisualizerRegistryBuilder; + +pub use config::{AaveV3Config, AaveV3PoolContract}; +pub use contracts::{ + AaveTokenVisualizer, PoolContractVisualizer, PoolVisualizer, VotingMachineVisualizer, +}; + +/// Registers all Aave v3 protocol contracts and visualizers +/// +/// This function: +/// 1. Registers contract addresses in the ContractRegistry for address-to-type lookup +/// 2. Registers visualizers in the EthereumVisualizerRegistryBuilder for transaction visualization +/// +/// # Arguments +/// * `contract_reg` - The contract registry to register addresses +/// * `visualizer_reg` - The visualizer registry to register visualizers +pub fn register( + contract_reg: &mut ContractRegistry, + visualizer_reg: &mut EthereumVisualizerRegistryBuilder, +) { + // Register Pool contracts on all supported chains + AaveV3Config::register_contracts(contract_reg); + + // Register visualizers + visualizer_reg.register(Box::new(PoolContractVisualizer::new())); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::registry::ContractType; + use alloy_primitives::Address; + + #[test] + fn test_register_aave_contracts() { + let mut contract_reg = ContractRegistry::new(); + let mut visualizer_reg = EthereumVisualizerRegistryBuilder::new(); + + register(&mut contract_reg, &mut visualizer_reg); + + // Verify Pool is registered on Ethereum mainnet + let eth_pool: Address = "0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2" + .parse() + .unwrap(); + let contract_type = contract_reg + .get_contract_type(1, eth_pool) + .expect("Pool should be registered on Ethereum mainnet"); + assert_eq!(contract_type, AaveV3PoolContract::short_type_id()); + + // Verify Pool is registered on Base + let base_pool: Address = "0xA238Dd80C259a72e81d7e4664a9801593F98d1c5" + .parse() + .unwrap(); + let contract_type = contract_reg + .get_contract_type(8453, base_pool) + .expect("Pool should be registered on Base"); + assert_eq!(contract_type, AaveV3PoolContract::short_type_id()); + } +} diff --git a/src/chain_parsers/visualsign-ethereum/src/protocols/mod.rs b/src/chain_parsers/visualsign-ethereum/src/protocols/mod.rs index 0bc7b8cb..760c5e22 100644 --- a/src/chain_parsers/visualsign-ethereum/src/protocols/mod.rs +++ b/src/chain_parsers/visualsign-ethereum/src/protocols/mod.rs @@ -1,3 +1,4 @@ +pub mod aave; pub mod morpho; pub mod uniswap; @@ -13,6 +14,9 @@ pub fn register_all( contract_reg: &mut ContractRegistry, visualizer_reg: &mut EthereumVisualizerRegistryBuilder, ) { + // Register Aave protocol + aave::register(contract_reg, visualizer_reg); + // Register Morpho protocol morpho::register(contract_reg, visualizer_reg); diff --git a/src/chain_parsers/visualsign-ethereum/tests/fixtures/aave_supply.expected b/src/chain_parsers/visualsign-ethereum/tests/fixtures/aave_supply.expected new file mode 100644 index 00000000..36e40c28 --- /dev/null +++ b/src/chain_parsers/visualsign-ethereum/tests/fixtures/aave_supply.expected @@ -0,0 +1 @@ +{"Fields":[{"FallbackText":"Ethereum Mainnet","Label":"Network","TextV2":{"Text":"Ethereum Mainnet"},"Type":"text_v2"},{"AddressV2":{"Address":"0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2","AssetLabel":"Test Asset","Name":"To"},"FallbackText":"0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2","Label":"To","Type":"address_v2"},{"AmountV2":{"Abbreviation":"ETH","Amount":"0"},"FallbackText":"0 ETH","Label":"Value","Type":"amount_v2"},{"FallbackText":"300000","Label":"Gas Limit","TextV2":{"Text":"300000"},"Type":"text_v2"},{"FallbackText":"1.109 gwei","Label":"Gas Price","TextV2":{"Text":"1.109 gwei"},"Type":"text_v2"},{"FallbackText":"1 gwei","Label":"Max Priority Fee Per Gas","TextV2":{"Text":"1 gwei"},"Type":"text_v2"},{"FallbackText":"9","Label":"Nonce","TextV2":{"Text":"9"},"Type":"text_v2"},{"FallbackText":"Supply 1000.000000 USDT on behalf of 0xec79237c9ea485d855298f0a70c48748f86d4b50","Label":"Aave Supply","PreviewLayout":{"Expanded":{"Fields":[{"FallbackText":"USDT (0xdac17f958d2ee523a2206206994597c13d831ec7)","Label":"Asset","TextV2":{"Text":"USDT (0xdac17f958d2ee523a2206206994597c13d831ec7)"},"Type":"text_v2"},{"FallbackText":"1000.000000 USDT","Label":"Amount","TextV2":{"Text":"1000.000000 USDT (raw: 1000000000)"},"Type":"text_v2"},{"FallbackText":"0xec79237c9ea485d855298f0a70c48748f86d4b50","Label":"On Behalf Of","TextV2":{"Text":"0xec79237c9ea485d855298f0a70c48748f86d4b50"},"Type":"text_v2"}]},"Subtitle":{"Text":"Supply 1000.000000 USDT on behalf of 0xec79237c9ea485d855298f0a70c48748f86d4b50"},"Title":{"Text":"Aave v3 Supply"}},"Type":"preview_layout"}],"PayloadType":"EthereumTx","Title":"Ethereum Transaction","Version":"0"} diff --git a/src/chain_parsers/visualsign-ethereum/tests/fixtures/aave_supply.input b/src/chain_parsers/visualsign-ethereum/tests/fixtures/aave_supply.input new file mode 100644 index 00000000..8027e756 --- /dev/null +++ b/src/chain_parsers/visualsign-ethereum/tests/fixtures/aave_supply.input @@ -0,0 +1 @@ +0x02f8ad0109843b9aca00844219ff40830493e09487870bca3f3fd6335c3f4ce8392d69350b4fa4e280b884617ba037000000000000000000000000dac17f958d2ee523a2206206994597c13d831ec7000000000000000000000000000000000000000000000000000000003b9aca00000000000000000000000000ec79237c9ea485d855298f0a70c48748f86d4b500000000000000000000000000000000000000000000000000000000000000000c0 diff --git a/src/chain_parsers/visualsign-ethereum/tests/lib_test.rs b/src/chain_parsers/visualsign-ethereum/tests/lib_test.rs index f2e17de6..8e1bbaa8 100644 --- a/src/chain_parsers/visualsign-ethereum/tests/lib_test.rs +++ b/src/chain_parsers/visualsign-ethereum/tests/lib_test.rs @@ -12,7 +12,7 @@ fn fixture_path(name: &str) -> PathBuf { path } -static FIXTURES: [&str; 3] = ["1559", "legacy", "v2swap"]; +static FIXTURES: [&str; 4] = ["1559", "legacy", "v2swap", "aave_supply"]; #[test] fn test_with_fixtures() { @@ -34,6 +34,7 @@ fn test_with_fixtures() { decode_transfers: true, transaction_name: None, metadata: None, + abi_registry: None, }; let result = transaction_string_to_visual_sign(transaction_hex, options); @@ -78,6 +79,7 @@ fn test_ethereum_charset_validation() { decode_transfers: true, transaction_name: None, metadata: None, + abi_registry: None, }; let result = transaction_string_to_visual_sign(transaction_hex, options); diff --git a/src/chain_parsers/visualsign-solana/src/core/visualsign.rs b/src/chain_parsers/visualsign-solana/src/core/visualsign.rs index 259a5971..9212bc2a 100644 --- a/src/chain_parsers/visualsign-solana/src/core/visualsign.rs +++ b/src/chain_parsers/visualsign-solana/src/core/visualsign.rs @@ -372,6 +372,7 @@ mod tests { metadata: None, decode_transfers: true, transaction_name: Some("Solana Transaction".to_string()), + abi_registry: None, }, ); @@ -454,6 +455,7 @@ mod tests { metadata: None, decode_transfers: true, transaction_name: Some("V0 Transaction".to_string()), + abi_registry: None, }, ); @@ -620,6 +622,7 @@ mod tests { metadata: None, decode_transfers: true, transaction_name: Some("Legacy Transfer Test".to_string()), + abi_registry: None, }, ); @@ -663,6 +666,7 @@ mod tests { metadata: None, decode_transfers: true, transaction_name: Some("V0 Transfer Test".to_string()), + abi_registry: None, }, ); @@ -789,6 +793,7 @@ mod tests { metadata: None, decode_transfers: true, transaction_name: Some("Manual V0 Transfer Test".to_string()), + abi_registry: None, }, ); @@ -939,6 +944,7 @@ mod tests { metadata: None, decode_transfers: true, transaction_name: Some("TokenKeg Test".to_string()), + abi_registry: None, }, ); diff --git a/src/chain_parsers/visualsign-solana/src/lib.rs b/src/chain_parsers/visualsign-solana/src/lib.rs index 1ae51ab3..2aeea883 100644 --- a/src/chain_parsers/visualsign-solana/src/lib.rs +++ b/src/chain_parsers/visualsign-solana/src/lib.rs @@ -32,6 +32,7 @@ mod tests { metadata: None, decode_transfers: true, transaction_name: Some(description.to_string()), + abi_registry: None, }, ) .unwrap_or_else(|e| panic!("Failed to convert {description} to payload: {e:?}")); @@ -88,6 +89,7 @@ mod tests { metadata: None, decode_transfers: true, transaction_name: Some("Unicode Escape Test".to_string()), + abi_registry: None, }, ) .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 50c9adf5..2b268973 100644 --- a/src/chain_parsers/visualsign-solana/src/utils/mod.rs +++ b/src/chain_parsers/visualsign-solana/src/utils/mod.rs @@ -143,6 +143,7 @@ pub mod test_utils { metadata: None, decode_transfers: true, transaction_name: None, + abi_registry: None, }, ) .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 9a7c592d..3353b2e2 100644 --- a/src/chain_parsers/visualsign-sui/src/utils/test_helpers.rs +++ b/src/chain_parsers/visualsign-sui/src/utils/test_helpers.rs @@ -72,6 +72,7 @@ pub fn payload_from_b64(data: &str) -> SignablePayload { decode_transfers: true, transaction_name: None, metadata: None, + abi_registry: None, }, ) .expect("Failed to visualize tx commands") @@ -85,6 +86,7 @@ pub fn payload_from_b64_with_context(data: &str, context: &str) -> SignablePaylo decode_transfers: true, transaction_name: None, metadata: None, + abi_registry: None, }, ) { Ok(payload) => payload, diff --git a/src/parser/cli/src/cli.rs b/src/parser/cli/src/cli.rs index 82a151a6..aa51885f 100644 --- a/src/parser/cli/src/cli.rs +++ b/src/parser/cli/src/cli.rs @@ -2,11 +2,11 @@ use crate::chains; use chains::parse_chain; use clap::Parser; use parser_app::registry::create_registry; +use std::sync::Arc; use visualsign::vsptrait::VisualSignOptions; use visualsign::{SignablePayload, SignablePayloadField}; -use visualsign_ethereum::embedded_abis::load_and_map_abi; use visualsign_ethereum::abi_registry::AbiRegistry; -use std::sync::Arc; +use visualsign_ethereum::embedded_abis::load_and_map_abi; #[derive(Parser, Debug)] #[command(name = "visualsign-parser")] @@ -247,10 +247,14 @@ fn build_abi_registry_from_mappings(abi_json_mappings: &[String]) -> (AbiRegistr match parse_abi_file_mapping(mapping) { Some((abi_name, file_path, address_str)) => { let chain_id = 1u64; // TODO: Make chain_id configurable - match load_and_map_abi(&mut registry, &abi_name, &file_path, chain_id, &address_str) { + match load_and_map_abi(&mut registry, &abi_name, &file_path, chain_id, &address_str) + { Ok(()) => { valid_count += 1; - eprintln!(" Loaded ABI '{}' from {} and mapped to {}", abi_name, file_path, address_str); + eprintln!( + " Loaded ABI '{}' from {} and mapped to {}", + abi_name, file_path, address_str + ); } Err(e) => { eprintln!(" Warning: Failed to load/map ABI '{}': {}", abi_name, e); @@ -283,7 +287,11 @@ fn parse_and_display( if !abi_json_mappings.is_empty() { eprintln!("Registering custom ABIs:"); let (registry, valid_count) = build_abi_registry_from_mappings(abi_json_mappings); - eprintln!("Successfully registered {}/{} ABI mappings\n", valid_count, abi_json_mappings.len()); + eprintln!( + "Successfully registered {}/{} ABI mappings\n", + valid_count, + abi_json_mappings.len() + ); options.abi_registry = Some(Arc::new(registry)); } diff --git a/src/test_decoder.rs b/src/test_decoder.rs new file mode 100644 index 00000000..670aa3cf --- /dev/null +++ b/src/test_decoder.rs @@ -0,0 +1,140 @@ +/// Quick Decoder Testing Tool +/// +/// This file lets you test any Ethereum contract decoder by just changing the calldata. +/// No need to modify tests or recompile the whole project. + +use alloy_primitives::hex; +use visualsign_ethereum::protocols::aave::contracts::PoolVisualizer; +use visualsign_ethereum::protocols::morpho::contracts::BundlerVisualizer; +use visualsign_ethereum::protocols::uniswap::contracts::UniversalRouterVisualizer; +use visualsign_ethereum::registry::ContractRegistry; +use visualsign::{SignablePayloadField}; + +fn main() { + println!("\n╔════════════════════════════════════════════════════════════╗"); + println!("║ 🧪 Ethereum Contract Decoder Tester ║"); + println!("╚════════════════════════════════════════════════════════════╝\n"); + + // ═══════════════════════════════════════════════════════════ + // 🔧 CONFIGURATION - CHANGE THESE VALUES TO TEST + // ═══════════════════════════════════════════════════════════ + + // STEP 1: Choose which protocol to test (uncomment one) + let protocol = "aave"; // Aave v3 + // let protocol = "morpho"; // Morpho + // let protocol = "uniswap"; // Uniswap Universal Router + + // STEP 2: Paste your calldata here (with or without 0x prefix) + let calldata_hex = "617ba037000000000000000000000000dac17f958d2ee523a2206206994597c13d831ec7000000000000000000000000000000000000000000000000000000199c82cc00000000000000000000000000b6559478b59836376da9937c4c697ddb21779e490000000000000000000000000000000000000000000000000000000000000000"; + + // STEP 3: Set the chain ID (1 = Ethereum, 137 = Polygon, etc.) + let chain_id = 1; + + // ═══════════════════════════════════════════════════════════ + // 🚀 EXECUTION - No need to change anything below + // ═══════════════════════════════════════════════════════════ + + // Decode hex + let calldata = match hex::decode(calldata_hex.trim_start_matches("0x")) { + Ok(data) => data, + Err(e) => { + eprintln!("❌ Failed to decode hex: {}", e); + eprintln!(" Make sure your calldata is valid hex!"); + return; + } + }; + + println!("📊 Test Configuration:"); + println!(" Protocol: {}", protocol.to_uppercase()); + println!(" Chain ID: {}", chain_id); + println!(" Calldata length: {} bytes", calldata.len()); + println!(" Function selector: 0x{}\n", hex::encode(&calldata[0..4])); + + // Create registry with token metadata + let registry = ContractRegistry::with_default_protocols(); + + // Route to the appropriate visualizer + let result = match protocol { + "aave" => { + println!("🏦 Testing with Aave v3 Pool decoder...\n"); + PoolVisualizer {}.visualize_pool_operation(&calldata, chain_id, Some(®istry)) + } + "morpho" => { + println!("🦋 Testing with Morpho Bundler decoder...\n"); + BundlerVisualizer {}.visualize_multicall(&calldata, chain_id, Some(®istry)) + } + "uniswap" => { + println!("🦄 Testing with Uniswap Universal Router decoder...\n"); + UniversalRouterVisualizer {}.visualize_tx_commands(&calldata, chain_id, Some(®istry)) + } + _ => { + eprintln!("❌ Unknown protocol: {}", protocol); + eprintln!(" Valid options: aave, morpho, uniswap"); + return; + } + }; + + // Display results + match result { + Some(SignablePayloadField::PreviewLayout { common, preview_layout }) => { + println!("╔════════════════════════════════════════════════════════════╗"); + println!("║ ✅ DECODE SUCCESS! ║"); + println!("╚════════════════════════════════════════════════════════════╝\n"); + + println!("📋 Label: {}", common.label); + println!("📝 Summary: {}\n", common.fallback_text); + + if let Some(title) = &preview_layout.title { + println!("🏷️ Title: {}", title.text); + } + + if let Some(subtitle) = &preview_layout.subtitle { + println!("📄 Subtitle: {}\n", subtitle.text); + } + + if let Some(expanded) = &preview_layout.expanded { + println!("╔════════════════════════════════════════════════════════════╗"); + println!("║ 📊 Detailed Parameters ║"); + println!("╚════════════════════════════════════════════════════════════╝"); + + for (i, field) in expanded.fields.iter().enumerate() { + match &field.signable_payload_field { + SignablePayloadField::TextV2 { common, text_v2 } => { + println!(" {}. {}: {}", i + 1, common.label, text_v2.text); + } + SignablePayloadField::PreviewLayout { common, .. } => { + println!(" {}. {} (nested)", i + 1, common.label); + } + _ => { + println!(" {}. {} ({})", i + 1, + match &field.signable_payload_field { + SignablePayloadField::TextV2 { common, .. } => &common.label, + SignablePayloadField::AmountV2 { common, .. } => &common.label, + SignablePayloadField::AddressV2 { common, .. } => &common.label, + _ => "Unknown" + }, + "other type" + ); + } + } + } + println!(); + } + + println!("╔════════════════════════════════════════════════════════════╗"); + println!("║ 🎉 Test Complete! ║"); + println!("╚════════════════════════════════════════════════════════════╝\n"); + } + Some(_) => { + println!("⚠️ Decoded but got unexpected format (not PreviewLayout)"); + } + None => { + println!("❌ Failed to decode transaction"); + println!(" Possible reasons:"); + println!(" - Wrong protocol selected"); + println!(" - Invalid function selector"); + println!(" - Malformed calldata"); + println!(" - Unsupported function\n"); + } + } +} diff --git a/src/visualsign/src/vsptrait.rs b/src/visualsign/src/vsptrait.rs index 1ddbf31f..8d90f8bb 100644 --- a/src/visualsign/src/vsptrait.rs +++ b/src/visualsign/src/vsptrait.rs @@ -1,6 +1,6 @@ +use std::any::Any; use std::fmt::Debug; use std::sync::Arc; -use std::any::Any; use crate::SignablePayload; @@ -284,6 +284,7 @@ mod tests { decode_transfers: true, transaction_name: Some("Custom Transaction".to_string()), metadata: None, + abi_registry: None, }; let result = converter.to_visual_sign_payload(transaction, options);