diff --git a/src/Cargo.lock b/src/Cargo.lock index 1cf43345..750113be 100644 --- a/src/Cargo.lock +++ b/src/Cargo.lock @@ -11948,8 +11948,10 @@ dependencies = [ "hex", "log", "num_enum 0.7.5", + "phf", "serde", "serde_json", + "sha2 0.10.9", "thiserror 2.0.17", "visualsign", ] diff --git a/src/chain_parsers/visualsign-ethereum/ARCHITECTURE.md b/src/chain_parsers/visualsign-ethereum/ARCHITECTURE.md new file mode 100644 index 00000000..9611c216 --- /dev/null +++ b/src/chain_parsers/visualsign-ethereum/ARCHITECTURE.md @@ -0,0 +1,420 @@ +# VisualSign Ethereum Module Architecture + +## Overview + +The visualsign-ethereum module provides transaction visualization for Ethereum and EVM-compatible chains. It follows a layered architecture that separates generic contract standards from protocol-specific implementations. + +## Directory Structure + +``` +src/ +├── lib.rs - Main entry point, transaction parsing +├── chains.rs - Chain ID to name mappings +├── context.rs - VisualizerContext for transaction context +├── fmt.rs - Formatting utilities (ether, gwei, etc) +├── registry.rs - ContractRegistry for address-to-type mapping +├── token_metadata.rs - Canonical wallet token format +├── visualizer.rs - VisualizerRegistry and builder pattern +│ +├── contracts/ - Generic contract standards +│ ├── mod.rs - Re-exports all contract modules +│ └── core/ - Core contract standards +│ ├── mod.rs +│ ├── erc20.rs - ERC20 token standard visualizer +│ └── fallback.rs - Catch-all hex visualizer for unknown contracts +│ +└── protocols/ - Protocol-specific implementations + ├── mod.rs - register_all() function + └── uniswap/ - Uniswap DEX protocol + ├── mod.rs - Protocol registration + ├── config.rs - Contract addresses and chain deployments + └── contracts/ - Uniswap-specific contract visualizers + ├── mod.rs + └── universal_router.rs - Universal Router (V2/V3/V4) visualizer +``` + +## Key Concepts + +### Contracts vs Protocols + +**Contracts** (`src/contracts/`): +- Generic, cross-protocol contract standards +- Implemented by many different projects +- Examples: ERC20, ERC721, ERC1155 +- Organized by category: + - **core/** - Fundamental token standards (ERC20, ERC721) + - **staking/** - Generic staking patterns (future) + - **governance/** - Generic governance patterns (future) + +**Protocols** (`src/protocols/`): +- Specific DeFi/Web3 protocols with custom business logic +- Each protocol is a collection of related contracts +- Examples: Uniswap, Aave, Compound +- Each protocol contains: + - **config.rs** - Contract addresses, chain deployments, metadata + - **contracts/** - Protocol-specific contract visualizers + - **mod.rs** - Registration function + +### Example: Uniswap Protocol + +``` +protocols/uniswap/ +├── config.rs # Addresses for all chains (Mainnet, Arbitrum, etc) +├── contracts/ +│ ├── universal_router.rs # Handles Universal Router calls +│ ├── v3_router.rs # (future) V3-specific router +│ └── v2_router.rs # (future) V2-specific router +└── mod.rs # register() function +``` + +The `config.rs` file defines: +- **Contract type markers** (type-safe unit structs implementing `ContractType`) +- Contract addresses per chain +- Helper methods to query deployments + +## Type-Safe Contract Identifiers + +The module uses the `ContractType` trait to ensure compile-time uniqueness of contract types: + +```rust +/// Define a contract type marker (in protocols/uniswap/config.rs) +pub struct UniswapUniversalRouter; +impl ContractType for UniswapUniversalRouter {} + +// If someone copies this and forgets to rename: +pub struct UniswapUniversalRouter; // ❌ Compile error: duplicate type! +``` + +**Benefits:** +- ✅ Compile-time uniqueness - can't have duplicate type names +- ✅ No manual string maintenance +- ✅ Type-safe at API boundaries +- ✅ Automatic type ID generation from type name + +## Registration System + +The module uses a dual-registry pattern: + +### 1. ContractRegistry (Address → Type) +Maps `(chain_id, address)` to contract type string: +```rust +// Type-safe registration (preferred) +registry.register_contract_typed::(1, vec![address]); + +// String-based registration (backward compatibility) +registry.register_contract(1, "CustomContract", vec![address]); +``` + +### 2. EthereumVisualizerRegistry (Type → Visualizer) +Maps contract type to visualizer implementation: +```rust +// Example: "UniswapUniversalRouter" → UniswapUniversalRouterVisualizer +visualizer_reg.register(Box::new(UniswapUniversalRouterVisualizer::new())); +``` + +### Registration Flow + +```rust +// protocols/uniswap/mod.rs +pub fn register( + contract_reg: &mut ContractRegistry, + visualizer_reg: &mut EthereumVisualizerRegistryBuilder, +) { + use config::UniswapUniversalRouter; + + let address = UniswapConfig::universal_router_address(); + + // 1. Register Universal Router on all supported chains (type-safe) + for &chain_id in UniswapConfig::universal_router_chains() { + contract_reg.register_contract_typed::( + chain_id, + vec![address], + ); + } + + // 2. Register visualizers (future) + // visualizer_reg.register(Box::new(UniswapUniversalRouterVisualizer::new())); +} + +// protocols/mod.rs +pub fn register_all( + contract_reg: &mut ContractRegistry, + visualizer_reg: &mut EthereumVisualizerRegistryBuilder, +) { + uniswap::register(contract_reg, visualizer_reg); + // Future: aave::register(contract_reg, visualizer_reg); + // Future: compound::register(contract_reg, visualizer_reg); +} +``` + +## Visualization Pipeline + +1. **Transaction Parsing** ([lib.rs:89](src/lib.rs#L89)) + - Parse RLP-encoded transaction + - Extract chain_id, to, value, input data + +2. **Contract Type Lookup** ([lib.rs:198](src/lib.rs#L198)) + - Query `ContractRegistry` with (chain_id, to_address) + - Get contract type string (e.g., "Uniswap_UniversalRouter") + +3. **Visualizer Dispatch** (future enhancement) + - Query `EthereumVisualizerRegistry` with contract type + - Invoke visualizer's `visualize()` method + +4. **Fallback Visualization** ([lib.rs:389](src/lib.rs#L389)) + - If no specific visualizer handles the call + - Use `FallbackVisualizer` to display raw hex + +## Scope and Limitations + +### Calldata Decoding vs Transaction Simulation + +This module **decodes transaction calldata** to show user intent. It does **not simulate transaction execution** to show results or state changes. + +#### What We Can Decode (Calldata Analysis): +✅ Function calls and parameters (e.g., `execute(commands, inputs, deadline)`) +✅ **Outgoing amounts** - Exact amounts user is sending (e.g., "240 SETH", "60 SETH") +✅ **Minimum expected outputs** - Slippage protection (e.g., ">=0.0035 WETH") +✅ Token symbols from registry (e.g., "SETH", "WETH" instead of addresses) +✅ Pool fee tiers (e.g., "0.3% fee" indicates which V3 pool tier) +✅ Recipients and addresses for transfers and payments +✅ Deadline timestamps +✅ Command sequences showing transaction flow (e.g., swap → pay fee → unwrap) + +**Example output:** +``` +Command 1: Swap 240 SETH for >=0.00357 WETH via V3 (0.3% fee) +Command 2: Swap 60 SETH for >=0.000895 WETH via V3 (1% fee) +Command 3: Pay 0.25% of WETH to 0x000000fee13a103a10d593b9ae06b3e05f2e7e1c +Command 4: Unwrap >=0.00446920 WETH to ETH +``` + +#### What Requires Simulation (Out of Scope): + +❌ **Actual received amounts** - Exact output after execution (vs minimum expected) + - We show: ">=0.00357 WETH" (from calldata) + - Simulation shows: "0.003573913782539750 WETH received" (actual result) + - Requires: EVM execution to compute exact amounts after slippage + +❌ **Pool address resolution** - Which specific pool contract handles each swap + - We show: "via V3 (0.3% fee)" (fee tier from calldata) + - Simulation shows: "via pool 0xd6e420f6...34cd" (actual pool address) + - Requires: RPC queries to find pools for token pairs + fee tier + +❌ **Balance changes in external contracts** - State deltas in pools, routers, etc. + - We show: User intent (swap X for Y, pay fee, unwrap) + - Simulation shows: "Pool 0xd6e420f6: WETH -0.0036, SETH +240" + - Requires: State tracking during execution for all touched contracts + +❌ **Multi-hop routing** - Intermediate tokens in complex swap paths + - Current: Single-hop decoding (token A → token B) + - Future enhancement: Parse multi-hop paths from calldata (no simulation needed) + +❌ **Gas estimation** - Actual gas consumed + - Requires: EVM execution + +**Why these are out of scope:** + +1. **Architectural separation**: Visualizers decode calldata (signing time), not execution results (runtime) +2. **No RPC dependency**: This module is pure calldata → human-readable transformation +3. **Deterministic behavior**: Decoding doesn't depend on chain state or external data +4. **Performance**: No network calls or heavy computation required + +**Tools that provide simulation:** +- [Tenderly](https://tenderly.co) - Full EVM simulation with state tracking +- [Foundry's cast](https://book.getfoundry.sh/cast/) - Local simulation +- Block explorers with internal transaction tracing + +This module's goal is to make **what the user is signing** clear, not to predict execution outcomes. + +## Adding New Protocols + +To add a new protocol (e.g., Aave): + +1. **Create protocol directory**: + ```bash + mkdir -p src/protocols/aave/contracts + ``` + +2. **Create config.rs with type-safe contract markers**: + ```rust + // src/protocols/aave/config.rs + use alloy_primitives::Address; + use crate::registry::ContractType; + + /// Contract type marker for Aave Lending Pool + #[derive(Debug, Clone, Copy)] + pub struct AaveLendingPool; + impl ContractType for AaveLendingPool {} + + /// Aave protocol configuration + pub struct AaveConfig; + + impl AaveConfig { + pub fn lending_pool_address() -> Address { + "0x7d2768dE32b0b80b7a3454c06BdAc94A69DDc7A9".parse().unwrap() + } + + pub fn lending_pool_chains() -> &'static [u64] { + &[1, 137, 42161, 10, 8453] // Mainnet, Polygon, Arbitrum, etc. + } + } + ``` + +3. **Create contract visualizers**: + ```rust + // src/protocols/aave/contracts/lending_pool.rs + pub struct AaveLendingPoolVisualizer {} + ``` + +4. **Create registration function**: + ```rust + // src/protocols/aave/mod.rs + pub fn register( + contract_reg: &mut ContractRegistry, + visualizer_reg: &mut EthereumVisualizerRegistryBuilder, + ) { + use config::AaveLendingPool; + + let address = AaveConfig::lending_pool_address(); + + // Register using type-safe method + for &chain_id in AaveConfig::lending_pool_chains() { + contract_reg.register_contract_typed::( + chain_id, + vec![address], + ); + } + + // Register visualizers (future) + // visualizer_reg.register(Box::new(AaveLendingPoolVisualizer::new())); + } + ``` + +5. **Register in protocols/mod.rs**: + ```rust + pub mod aave; + + pub fn register_all(...) { + uniswap::register(contract_reg, visualizer_reg); + aave::register(contract_reg, visualizer_reg); + } + ``` + +## Fallback Mechanism + +The `FallbackVisualizer` ([contracts/core/fallback.rs](src/contracts/core/fallback.rs)) provides a catch-all for unknown contract calls: + +- Returns raw calldata as hex: `0x1234567890abcdef` +- Label: "Contract Call Data" +- Similar to Solana's unknown program handler + +This ensures all transactions can be visualized, even without specific protocol support. + +## Configuration Pattern + +Each protocol uses a simple configuration struct with static methods: + +```rust +use alloy_primitives::Address; +use crate::registry::ContractType; + +/// Contract type marker (compile-time unique) +#[derive(Debug, Clone, Copy)] +pub struct UniswapUniversalRouter; +impl ContractType for UniswapUniversalRouter {} + +/// Protocol configuration +pub struct UniswapConfig; + +impl UniswapConfig { + /// Returns the Universal Router address (same across chains) + pub fn universal_router_address() -> Address { + "0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD".parse().unwrap() + } + + /// Returns supported chain IDs + pub fn universal_router_chains() -> &'static [u64] { + &[1, 10, 137, 8453, 42161] + } +} +``` + +Benefits: +- ✅ Single source of truth for contract addresses +- ✅ Easy to add new chains +- ✅ Compile-time type safety with `ContractType` trait +- ✅ Simple, stateless design +- ✅ Easy to test + +## Future Enhancements + +### 1. Visualizer Trait Implementation +Currently, protocol visualizers (like `UniswapV4Visualizer`) use ad-hoc methods. They should implement the `ContractVisualizer` trait: + +```rust +impl ContractVisualizer for UniswapUniversalRouterVisualizer { + fn contract_type(&self) -> &str { + UNISWAP_UNIVERSAL_ROUTER + } + + fn visualize(&self, context: &VisualizerContext) + -> Result>, VisualSignError> + { + // Decode and visualize Universal Router calls + } +} +``` + +### 2. Registry Architecture Refactor +See [lib.rs:116-164](src/lib.rs#L116) for detailed TODO about moving registries from converter ownership to context-based passing. + +### 3. Protocol Version Support +Each protocol should support multiple versions: +``` +protocols/uniswap/contracts/ +├── v2_router.rs +├── v3_router.rs +└── universal_router.rs +``` + +### 4. Cross-Protocol Standards +Some patterns span multiple protocols: +``` +contracts/ +├── core/ # ERC standards +├── staking/ # Generic staking (not protocol-specific) +└── governance/ # Generic governance contracts +``` + +## Testing + +Each module should include tests: + +- **Config tests**: Verify addresses are registered correctly +- **Visualizer tests**: Test calldata decoding and field generation +- **Integration tests**: End-to-end transaction visualization + +Example from [protocols/uniswap/mod.rs](src/protocols/uniswap/mod.rs#L48): +```rust +#[test] +fn test_register_uniswap_contracts() { + let mut contract_reg = ContractRegistry::new(); + let mut visualizer_reg = EthereumVisualizerRegistryBuilder::new(); + + register(&mut contract_reg, &mut visualizer_reg); + + let addr = "0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD".parse().unwrap(); + + for chain_id in [1, 10, 137, 8453, 42161] { + let contract_type = contract_reg.get_contract_type(chain_id, addr); + assert_eq!(contract_type.unwrap(), UNISWAP_UNIVERSAL_ROUTER); + } +} +``` + +## References + +- [CLAUDE.md](CLAUDE.md) - Development guidelines and best practices +- [visualsign crate](../../visualsign/) - Field builders and core types +- [Registry TODO](src/lib.rs#L116) - Future registry architecture improvements diff --git a/src/chain_parsers/visualsign-ethereum/CLAUDE.md b/src/chain_parsers/visualsign-ethereum/CLAUDE.md new file mode 100644 index 00000000..82b4272c --- /dev/null +++ b/src/chain_parsers/visualsign-ethereum/CLAUDE.md @@ -0,0 +1,330 @@ +# VisualSign Ethereum Module Guidelines + +## Field Builders + +The `visualsign` crate provides field builder functions that reduce boilerplate when creating payload fields. Always use these rather than constructing field structs directly. + +### Available Functions + +Import from `visualsign::field_builders`: + +#### `create_text_field(label: &str, text: &str) -> Result` +Creates a TextV2 field. Use for simple text display (network names, addresses, etc). + +```rust +use visualsign::field_builders::create_text_field; + +let field = create_text_field("Network", "Ethereum Mainnet")?; +``` + +#### `create_amount_field(label: &str, amount: &str, abbreviation: &str) -> Result` +Creates an AmountV2 field with token symbol. Validates that amount is a proper signed decimal number. + +```rust +use visualsign::field_builders::create_amount_field; + +let field = create_amount_field("Value", "1.5", "USDC")?; +``` + +#### `create_number_field(label: &str, number: &str, unit: &str) -> Result` +Creates a Number field with optional unit. Similar to amount but without requiring a symbol. + +```rust +use visualsign::field_builders::create_number_field; + +let field = create_number_field("Gas Limit", "21000", "units")?; +``` + +#### `create_address_field(label: &str, address: &str, name: Option<&str>, memo: Option<&str>, asset_label: Option<&str>, badge_text: Option<&str>) -> Result` +Creates an AddressV2 field with optional metadata. + +```rust +use visualsign::field_builders::create_address_field; + +let field = create_address_field( + "To", + "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", + Some("Vitalik"), + None, + Some("ETH"), + Some("Founder"), +)?; +``` + +#### `create_raw_data_field(data: &[u8], optional_fallback_string: Option) -> Result` +Creates a TextV2 field for raw bytes. Displays as hex by default. + +```rust +use visualsign::field_builders::create_raw_data_field; + +let field = create_raw_data_field(b"calldata", None)?; +``` + +### Number Validation + +All amount and number fields validate the input using a regex pattern: +- Valid: `123`, `123.45`, `-123.45`, `+678.90`, `0`, `0.0` +- Invalid: `-.45`, `123.`, `abc`, `12.3.4`, `--1` + +## Token Metadata + +The `token_metadata` module provides canonical wallet format for token data: + +```rust +use crate::token_metadata::{ChainMetadata, TokenMetadata, ErcStandard, parse_network_id}; + +// Parse network identifier to chain ID +let chain_id = parse_network_id("ETHEREUM_MAINNET")?; // Returns 1 + +// Create token metadata +let token = TokenMetadata { + symbol: "USDC".to_string(), + name: "USD Coin".to_string(), + erc_standard: ErcStandard::Erc20, + contract_address: "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48".to_string(), + decimals: 6, +}; + +// Hash protobuf bytes +let hash = compute_metadata_hash(protobuf_bytes); +``` + +### Supported Networks + +- `ETHEREUM_MAINNET` → chain_id: 1 +- `POLYGON_MAINNET` → chain_id: 137 +- `ARBITRUM_MAINNET` → chain_id: 42161 +- `OPTIMISM_MAINNET` → chain_id: 10 +- `BASE_MAINNET` → chain_id: 8453 + +## Registry + +The `ContractRegistry` maps `(chain_id, Address) -> TokenMetadata` for efficient lookups: + +```rust +use crate::registry::ContractRegistry; + +let mut registry = ContractRegistry::new(); + +// Register token with metadata +registry.register_token(1, token_metadata); + +// Get token symbol +let symbol = registry.get_token_symbol(1, address); + +// Format token amount with proper decimals +let formatted = registry.format_token_amount(1, address, raw_amount); + +// Load from wallet metadata +registry.load_chain_metadata(&chain_metadata)?; +``` + +## Context and Visualization + +The `VisualizerContext` provides execution context for transaction visualization: + +```rust +use crate::context::VisualizerContext; + +let context = VisualizerContext::new( + chain_id, + sender_address, + contract_address, + calldata, + registry, + visualizers, +); + +// Create nested call context +let nested = context.for_nested_call(nested_contract, nested_calldata); +``` + +## Best Practices + +1. **Always use field builders** - Don't construct SignablePayloadField structs directly +2. **Handle errors** - All field builders return `Result` types +3. **Prefer canonical types** - Use `TokenMetadata` from `token_metadata` module +4. **Use registry for lookups** - Don't duplicate token metadata storage +5. **Network ID mapping** - Always use `parse_network_id()` to convert string IDs to chain IDs +6. **Validate amounts** - Field builders validate number formats automatically +7. **Chain ID + Address as key** - Always use (chain_id, Address) tuple for token lookups + +## Module Structure + +``` +src/ +├── lib.rs - Main entry point, re-exports +├── chains.rs - Chain name mappings +├── context.rs - VisualizerContext for transaction context +├── contracts/ - Contract-specific visualizers (ERC20, Uniswap, etc) +├── fmt.rs - Formatting utilities (ether, gwei, etc) +├── protocols/ - Protocol-specific handlers +├── registry.rs - ContractRegistry for metadata lookup +├── token_metadata.rs - Canonical wallet token format +└── visualizer.rs - VisualizerRegistry and builder +``` + +## Milestone 1.1 - Token and Contract Registry + +- `TokenMetadata`: canonical wallet token format with symbol, name, erc_standard, contract_address, decimals +- `ChainMetadata`: grouping of tokens by network, sent from wallets as protobuf +- `parse_network_id()`: maps network identifiers to chain IDs +- `compute_metadata_hash()`: SHA256 hashing of protobuf metadata bytes +- `ContractRegistry`: (chain_id, Address) → TokenMetadata mapping for efficient lookups +- Field builders from visualsign: reusable field construction utilities + +## Common Patterns + +### Creating transaction fields + +```rust +use visualsign::field_builders::*; + +let mut fields = vec![ + create_text_field("Network", "Ethereum Mainnet")?, + create_address_field("To", "0x...", None, None, None, None)?, + create_amount_field("Value", "1.5", "ETH")?, + create_number_field("Gas Limit", "21000", "")?, +]; +``` + +### Formatting token amounts + +```rust +use crate::registry::ContractRegistry; + +if let Some((formatted, symbol)) = registry.format_token_amount(chain_id, token_address, raw_amount) { + let field = create_amount_field("Amount", &formatted, &symbol)?; + // Use field... +} +``` + +### Loading wallet metadata + +```rust +use crate::registry::ContractRegistry; + +let mut registry = ContractRegistry::new(); +registry.load_chain_metadata(&wallet_metadata)?; + +// Now all tokens from wallet are indexed by (chain_id, address) +``` + +## Solidity Protocol Decoders + +All protocol decoders (Uniswap, future Aave, etc.) follow a clean, repeatable pattern using the `sol!` macro from alloy. + +### Decoder Pattern + +Every decoder has 4 steps: + +1. **Define struct with sol!** - Type-safe parameter structure +2. **Decode or handle error** - Use `StructName::abi_decode(bytes)` +3. **Resolve tokens from registry** - Get symbols and format amounts +4. **Return TextV2 field** - Human-readable summary + +### Example: Simple Decoder + +```rust +fn decode_operation( + bytes: &[u8], + chain_id: u64, + registry: Option<&ContractRegistry>, +) -> SignablePayloadField { + // Step 1: Decode parameters + let params = match OperationParams::abi_decode(bytes) { + Ok(p) => p, + Err(_) => { + return SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: format!("Operation: 0x{}", hex::encode(bytes)), + label: "Operation".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: "Failed to decode parameters".to_string(), + }, + }; + } + }; + + // Step 2: Resolve token symbols via registry + let token_symbol = registry + .and_then(|r| r.get_token_symbol(chain_id, params.token)) + .unwrap_or_else(|| format!("{:?}", params.token)); + + // Step 3: Format amount with decimals + let (amount_str, _) = registry + .and_then(|r| { + let amount: u128 = params.amount.to_string().parse().ok()?; + r.format_token_amount(chain_id, params.token, amount) + }) + .unwrap_or_else(|| (params.amount.to_string(), token_symbol.clone())); + + // Step 4: Create human-readable summary + let text = format!("Operation with {} {}", amount_str, token_symbol); + + SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: text.clone(), + label: "Operation".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { text }, + } +} +``` + +### Defining Parameter Structs + +Use the `sol!` macro to define all parameters: + +```rust +sol! { + struct SwapParams { + address tokenIn; + address tokenOut; + uint256 amountIn; + uint256 minAmountOut; + } + + struct TransferParams { + address to; + uint256 amount; + bytes data; + } +} +``` + +**Benefits:** +- Automatic type-safe ABI decoding +- No manual byte parsing needed +- Compile-time correctness + +### Reusable Address Utilities + +For canonical contracts like WETH: + +```rust +use crate::utils::address_utils::WellKnownAddresses; + +let weth = WellKnownAddresses::weth(chain_id)?; // Get WETH for this chain +let usdc = WellKnownAddresses::usdc(chain_id)?; // Get USDC for this chain +let permit2 = WellKnownAddresses::permit2(); // Same on all chains +``` + +### No ASCII Restrictions + +Always use ASCII for terminal compatibility: +- Use `>=` instead of `≥` +- Use `<=` instead of `≤` +- Use `->` instead of `→` + +### Adding New Protocols + +To add Aave, Curve, or any other protocol: + +1. Create `src/protocols/aave/` directory +2. Define Aave function structs with `sol!` +3. Create decoder functions (20-40 lines each) +4. Add to main visualizer registry + +See `DECODER_GUIDE.md` for complete examples. diff --git a/src/chain_parsers/visualsign-ethereum/Cargo.toml b/src/chain_parsers/visualsign-ethereum/Cargo.toml index 6f0718ae..282126c3 100644 --- a/src/chain_parsers/visualsign-ethereum/Cargo.toml +++ b/src/chain_parsers/visualsign-ethereum/Cargo.toml @@ -14,7 +14,9 @@ chrono = { version = "0.4", features = ["std", "clock"] } hex = "0.4.3" log = "0.4" num_enum = "0.7.2" +phf = { version = "0.11", features = ["macros"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" +sha2 = "0.10" thiserror = "2.0.12" visualsign = { workspace = true } diff --git a/src/chain_parsers/visualsign-ethereum/DECODER_GUIDE.md b/src/chain_parsers/visualsign-ethereum/DECODER_GUIDE.md new file mode 100644 index 00000000..4b3debff --- /dev/null +++ b/src/chain_parsers/visualsign-ethereum/DECODER_GUIDE.md @@ -0,0 +1,344 @@ +# Solidity Protocol Decoder Implementation Guide + +This guide shows how to add clean, maintainable decoders for any Solidity-based protocol (Uniswap, Aave, Curve, etc.) using the patterns established in this codebase. + +## The Pattern: Simple, Repeatable, and Type-Safe + +Every decoder follows this simple pattern: + +```rust +/// Decodes OPERATION command parameters +fn decode_operation( + bytes: &[u8], + chain_id: u64, + registry: Option<&ContractRegistry>, +) -> SignablePayloadField { + // 1. Decode the struct using sol! macro + let params = match OperationParams::abi_decode(bytes) { + Ok(p) => p, + Err(_) => { + // Return error field if decoding fails + return SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: format!("Operation: 0x{}", hex::encode(bytes)), + label: "Operation".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: "Failed to decode parameters".to_string(), + }, + }; + } + }; + + // 2. Extract data from params and resolve tokens via registry + let token_symbol = registry + .and_then(|r| r.get_token_symbol(chain_id, params.token)) + .unwrap_or_else(|| format!("{:?}", params.token)); + + let amount_u128 = params.amount.to_string().parse().unwrap_or(0); + let (amount_str, _) = registry + .and_then(|r| r.format_token_amount(chain_id, params.token, amount_u128)) + .unwrap_or_else(|| (params.amount.to_string(), token_symbol.clone())); + + // 3. Create human-readable text summary + let text = format!("Perform operation with {} {}", amount_str, token_symbol); + + // 4. Return as TextV2 field + SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: text.clone(), + label: "Operation".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { text }, + } +} +``` + +## Step-by-Step Implementation + +### Step 1: Define Struct Parameters with sol! Macro + +In your main decoder file, define all the parameter structs using the `sol!` macro: + +```rust +sol! { + struct SwapParams { + address tokenIn; + address tokenOut; + uint256 amountIn; + uint256 minAmountOut; + } + + struct ApproveLendParams { + address token; + address lendingPool; + uint256 amount; + } +} +``` + +**Why?** The `sol!` macro from alloy automatically generates: +- Type-safe `abi_decode()` function +- Proper ABI encoding/decoding +- Clean field access without manual byte parsing + +### Step 2: Add Decoder Function + +Create a `decode_*` function for each operation type. Keep it focused: + +```rust +fn decode_swap( + bytes: &[u8], + chain_id: u64, + registry: Option<&ContractRegistry>, +) -> SignablePayloadField { + // Decode or return error + let params = match SwapParams::abi_decode(bytes) { + Ok(p) => p, + Err(_) => return error_field("Swap"), + }; + + // Get token symbols from registry + let token_in = registry + .and_then(|r| r.get_token_symbol(chain_id, params.tokenIn)) + .unwrap_or_else(|| format!("{:?}", params.tokenIn)); + + let token_out = registry + .and_then(|r| r.get_token_symbol(chain_id, params.tokenOut)) + .unwrap_or_else(|| format!("{:?}", params.tokenOut)); + + // Format amounts using registry decimals + let (amount_in_str, _) = registry + .and_then(|r| { + let amount: u128 = params.amountIn.to_string().parse().ok()?; + r.format_token_amount(chain_id, params.tokenIn, amount) + }) + .unwrap_or_else(|| (params.amountIn.to_string(), token_in.clone())); + + let text = format!("Swap {} {} for {} {}", + amount_in_str, token_in, params.minAmountOut, token_out + ); + + text_field("Swap", text) +} +``` + +### Step 3: Add to Match Statement + +In your main decoder function, add each operation to the match statement: + +```rust +match operation_type { + OperationType::Swap => Self::decode_swap(bytes, chain_id, registry), + OperationType::ApproveLend => Self::decode_approve_lend(bytes, chain_id, registry), + _ => unimplemented_field(operation_type), +} +``` + +### Step 4: Leverage Registry for Token Resolution + +The `ContractRegistry` is your key to clean code. Use these methods: + +```rust +// Get token symbol +let symbol = registry.get_token_symbol(chain_id, address); + +// Format amount with decimals +let (formatted, symbol) = registry.format_token_amount( + chain_id, + token_address, + raw_amount // u128 +); +``` + +## Real-World Examples from Uniswap Router + +### Simple Decoder: Wrap ETH + +```rust +fn decode_wrap_eth( + bytes: &[u8], + _chain_id: u64, + _registry: Option<&ContractRegistry>, +) -> SignablePayloadField { + let params = match WrapEthParams::abi_decode(bytes) { + Ok(p) => p, + Err(_) => { + return SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: format!("Wrap ETH: 0x{}", hex::encode(bytes)), + label: "Wrap ETH".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: "Failed to decode parameters".to_string(), + }, + }; + } + }; + + let amount_str = params.amountMin.to_string(); + let text = format!("Wrap {} ETH to WETH", amount_str); + + SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: text.clone(), + label: "Wrap ETH".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { text }, + } +} +``` + +### Complex Decoder: V3 Swap Exact In + +```rust +fn decode_v3_swap_exact_in( + bytes: &[u8], + chain_id: u64, + registry: Option<&ContractRegistry>, +) -> SignablePayloadField { + // Decode parameters + let params = match V3SwapExactInputParams::abi_decode(bytes) { + Ok(p) => p, + Err(_) => return error_field("V3 Swap Exact In"), + }; + + // Parse V3 path (address[20] + fee[3bytes] + address[20] + ...) + if params.path.0.len() < 43 { + return invalid_path_field(); + } + + let path_bytes = ¶ms.path.0; + let token_in = Address::from_slice(&path_bytes[0..20]); + let fee = u32::from_be_bytes([0, path_bytes[20], path_bytes[21], path_bytes[22]]); + let token_out = Address::from_slice(&path_bytes[23..43]); + + // Resolve tokens + let token_in_symbol = registry + .and_then(|r| r.get_token_symbol(chain_id, token_in)) + .unwrap_or_else(|| format!("{:?}", token_in)); + + let token_out_symbol = registry + .and_then(|r| r.get_token_symbol(chain_id, token_out)) + .unwrap_or_else(|| format!("{:?}", token_out)); + + // Format amounts + let (amount_in_str, _) = registry + .and_then(|r| { + let amount: u128 = params.amountIn.to_string().parse().ok()?; + r.format_token_amount(chain_id, token_in, amount) + }) + .unwrap_or_else(|| (params.amountIn.to_string(), token_in_symbol.clone())); + + let (amount_out_str, _) = registry + .and_then(|r| { + let amount: u128 = params.amountOutMinimum.to_string().parse().ok()?; + r.format_token_amount(chain_id, token_out, amount) + }) + .unwrap_or_else(|| (params.amountOutMinimum.to_string(), token_out_symbol.clone())); + + let fee_pct = fee as f64 / 10000.0; + let text = format!( + "Swap {} {} for >={} {} via V3 ({}% fee)", + amount_in_str, token_in_symbol, amount_out_str, token_out_symbol, fee_pct + ); + + SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: text.clone(), + label: "V3 Swap Exact In".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { text }, + } +} +``` + +## Key Principles + +### 1. Type Safety First +Use the `sol!` macro to generate type-safe decoders. Avoid manual byte parsing. + +### 2. Registry as Single Source of Truth +All token symbols and decimals come from `ContractRegistry`. This ensures consistency and allows wallets to customize metadata. + +### 3. Graceful Error Handling +Always handle decode failures by returning a TextV2 field with the hex input. This gives users visibility into what failed. + +### 4. Clean, Human-Readable Output +Format amounts with proper decimals and symbols. Make the transaction intent clear. + +### 5. No ASCII Characters in Strings +Use `>=` and `<=` instead of non-ASCII characters like `≥` and `≤` for terminal compatibility. + +## Reusable Utilities + +### WellKnownAddresses + +For contracts like WETH that don't need registry lookups: + +```rust +use crate::utils::address_utils::WellKnownAddresses; + +let weth_address = WellKnownAddresses::weth(chain_id)?; +let permit2_address = WellKnownAddresses::permit2(); +``` + +### Error Fields + +Create consistent error fields: + +```rust +SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: format!("{}: 0x{}", operation_name, hex::encode(bytes)), + label: operation_name.to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: "Failed to decode parameters".to_string(), + }, +} +``` + +## Adding Support for Aave + +When you're ready to add Aave support, follow this pattern: + +```rust +// 1. Define Aave structs using sol! +sol! { + struct DepositParams { + address asset; + uint256 amount; + address onBehalfOf; + } + + struct BorrowParams { + address asset; + uint256 amount; + uint256 interestRateMode; + address onBehalfOf; + } +} + +// 2. Create decoder functions (same pattern as Uniswap) +fn decode_deposit(bytes: &[u8], chain_id: u64, registry: Option<&ContractRegistry>) -> SignablePayloadField { + // ... follows the same pattern ... +} + +// 3. Add to main match statement +match aave_operation { + AaveOp::Deposit => decode_deposit(bytes, chain_id, registry), + AaveOp::Borrow => decode_borrow(bytes, chain_id, registry), + // ... +} +``` + +## Summary + +The pattern is simple and scales: +1. Define structs with `sol!` +2. Create decoder function (20-40 lines) +3. Add to match statement +4. Test with real transaction data + +This approach has been used successfully for Uniswap's 19 command types. It will work for any Solidity protocol. diff --git a/src/chain_parsers/visualsign-ethereum/WORKSHOP.md b/src/chain_parsers/visualsign-ethereum/WORKSHOP.md new file mode 100644 index 00000000..0465645b --- /dev/null +++ b/src/chain_parsers/visualsign-ethereum/WORKSHOP.md @@ -0,0 +1,161 @@ +# Extending VisualSign: A Guide to Implementing Custom Protocol Decoders + +## 1. Introduction + +Welcome! This workshop will guide you through the process of adding a new protocol decoder to the VisualSign parser. VisualSign's power comes from its ability to translate complex, hexadecimal transaction data into a human-readable format. By the end of this session, you will have the knowledge and tools to extend VisualSign to support any EVM-based protocol. + +Our case study will be the **Morpho Bundler**, a contract that batches multiple operations into a single transaction. + +## 2. Before You Code: The Art of the "One-Shot" Spec + +The most common pitfall in software development is a poorly defined task. A vague request leads to a cycle of exploration, rework, and refinement. To avoid this, we advocate for creating a "one-shot" specification before writing a single line of code. + +The goal of a "one-shot" spec is to provide a developer with all the necessary information to complete a feature correctly in a single pass. It acts as a mission brief, a blueprint, and a definition of done, all in one. + +### Anatomy of a "One-Shot" Spec + +A perfect spec for a new protocol decoder should contain these seven elements: + +1. **High-Level Goal**: A single, clear sentence defining the objective. +2. **Project Context & Precedent**: Point to existing code that should be used as a template. This ensures architectural consistency. +3. **Target Contract & Transaction**: Provide the specific contract address, the relevant Solidity ABI, and a real-world raw transaction hex to serve as the primary test case. +4. **Core Logic Requirements**: Break down the decoding steps. How is the transaction identified? How are nested data structures handled? +5. **Detailed Output Specification**: A visual mock-up of the desired final output. This is the most critical part, as it removes all ambiguity about what "done" looks like. +6. **File Structure & Naming**: Specify the exact directory and file names to be created. +7. **Documentation Requirements**: State the need for documentation, like an `IMPLEMENTATION_STATUS.md` file, and provide a template. + +## 3. Case Study: The Morpho Bundler Mission Brief + +Here is the "one-shot" spec we could have used to implement the Morpho decoder. Each protocol may have unique quirks but if you're using something without too much assembly and quirks, this might work most of the way to get you started. + +--- + +**Subject: Implement VisualSign Decoder for Morpho BundlerV3** + +**1. High-Level Goal** +Create a visualizer that decodes a `multicall` to the Morpho Bundler, displaying each of the nested operations in a human-readable format. + +**2. Project Context & Precedent** +Please follow the existing architectural patterns outlined in `src/chain_parsers/visualsign-ethereum/DECODER_GUIDE.md`. The implementation should mirror the structure of the Uniswap protocol found in `src/chain_parsers/visualsign-ethereum/src/protocols/uniswap/`. + +**3. Target Contract & Transaction** +* **Contract Address**: `0x6566194141eefa99Af43Bb5Aa71460Ca2Dc90245` +* **Example Raw Transaction**: `0x02f9...da44c0` Full unsigned hex +* **Relevant ABIs**: + ```solidity + // For use with the sol! macro + struct Call { address target; bytes data; /* ... */ } + function multicall(Call[] calldata calls) external; + // Nested call ABIs + function permit(address owner, address spender, uint256 value, uint256 deadline, bytes signature) external; + function erc20TransferFrom(address token, address from, uint256 amount) external; + function erc4626Deposit(address vault, uint256 assets, uint256 minShares, address receiver) external; + ``` + +**4. Core Logic Requirements** +* The visualizer should trigger for transactions sent to the BundlerV3 address with the `multicall` selector (`0x374f435d`). +* The main `visualize_multicall` function should decode the `Call[]` array. +* It should then loop through each `Call` and use a `match` statement on the first 4 bytes of `call.data` (the selector) to delegate to a specific decoding function. +* Use the `ContractRegistry` to resolve token addresses to symbols and format amounts using the correct decimals. + +**5. Detailed Output Specification** +The final output should be structured as follows, using `ListLayout` and `PreviewLayout`: +``` +Morpho Bundler + Title: Morpho Bundler Multicall + Detail: 3 operation(s) + 📖 Expanded View: + ├─ Permit: Permit 1.000000 USDC to 0x4a6c... (expires: ...) + ├─ Transfer From: Transfer 1.000000 USDC from 0x4a6c... + └─ Vault Deposit: Deposit 1000000 assets into 0xbeef... vault +``` +*Note: The expanded view for each item should show all parameters, and if a token symbol is not found, display only the address.* + +**6. File Structure & Naming** +Create the following structure: +`src/chain_parsers/visualsign-ethereum/src/protocols/morpho/` +├── `mod.rs` +├── `config.rs` +└── `contracts/` + ├── `mod.rs` + └── `bundler.rs` + +**7. Documentation** +After implementation, create an `IMPLEMENTATION_STATUS.md` file inside `protocols/morpho/`, following the format of `protocols/uniswap/IMPLEMENTATION_STATUS.md`. + +--- + +## 4. Step-by-Step Implementation Guide + +Now, let's walk through how to turn that spec into working code. + +### Step 1: Set Up the File Structure + +As specified, create the directory and empty files. This provides the skeleton for our module. + +```sh +mkdir -p src/chain_parsers/visualsign-ethereum/src/protocols/morpho/contracts +touch src/chain_parsers/visualsign-ethereum/src/protocols/morpho/{mod.rs,config.rs} +touch src/chain_parsers/visualsign-ethereum/src/protocols/morpho/contracts/{mod.rs,bundler.rs} +``` + +### Step 2: Configure the Protocol (`config.rs`) + +Define a new `struct` for the contract and implement the `ContractType` trait. Then, create a config struct to hold the address and registration logic. + +```rust +// In src/chain_parsers/visualsign-ethereum/src/protocols/morpho/config.rs + +// 1. Define the contract type +pub struct Bundler3Contract; +impl ContractType for Bundler3Contract { /* ... */ } + +// 2. Define the config +pub struct MorphoConfig { /* ... */ } +impl MorphoConfig { + // 3. Add address and registration logic + pub fn bundler3_address() -> Address { ... } + pub fn register_contracts(registry: &mut ContractRegistry) { ... } +} +``` + +### Step 3: Implement the Core Logic (`contracts/bundler.rs`) + +This is where the main decoding happens. + +1. **Define ABIs with `sol!`**: Use the `sol!` macro from `alloy-sol-types` to create Rust types from the Solidity interfaces in the spec. +2. **Create the Visualizer Struct**: `pub struct BundlerVisualizer;` +3. **Implement the Main Entry Point**: Create `visualize_multicall`, which decodes the outer `multicall` and gets the array of `Call` structs. +4. **Implement the Dispatcher**: Create a `decode_nested_call` helper. This function takes a `Call` struct, `match`es on its `selector`, and calls the appropriate decoder for the nested operation. +5. **Implement Individual Decoders**: For each nested operation (`permit`, `transfer`, `deposit`), create a function that decodes its specific parameters, queries the `ContractRegistry` for token info, and formats the data into a `SignablePayloadField` (like `TextV2` or `PreviewLayout`). + +### Step 4: Integrate the Protocol + +Now, plug the new module into the application. + +1. **`src/chain_parsers/visualsign-ethereum/src/protocols/mod.rs`**: + * Declare the new module: `pub mod morpho;` + * Call its registration function: `morpho::register(registry);` +2. **`src/chain_parsers/visualsign-ethereum/src/lib.rs`**: + * In the main `visualize_ethereum_transaction` function, add logic to detect if the transaction is for the Morpho Bundler. If it is, instantiate `BundlerVisualizer` and call `visualize_multicall`. + +### Step 5: Test Your Implementation + +Use the raw transaction from the spec to create tests. + +* **Unit Tests**: Write tests for each individual decoder function (e.g., `test_decode_permit`). +* **Integration Test**: Write a test for the top-level `visualize_multicall` function using the full, real-world transaction data. This ensures all parts work together correctly. +* Run tests with `cargo test -p visualsign-ethereum`. + +### Step 6: Document Your Work + +Finally, create the `IMPLEMENTATION_STATUS.md` file as requested. This document is vital for future maintainers. It should clearly state: +* What is implemented (✅). +* What is not yet implemented (⏳). +* A guide on how to add new commands, so the next developer can follow your pattern. + +## 5. Conclusion + +By following this structured approach—starting with a detailed spec and moving through implementation, integration, testing, and documentation—you can efficiently and accurately extend VisualSign with new protocol decoders. This process not only ensures correctness but also promotes a clean, maintainable, and consistent codebase. + +Happy coding! diff --git a/src/chain_parsers/visualsign-ethereum/src/context.rs b/src/chain_parsers/visualsign-ethereum/src/context.rs new file mode 100644 index 00000000..d0e2bdfa --- /dev/null +++ b/src/chain_parsers/visualsign-ethereum/src/context.rs @@ -0,0 +1,261 @@ +use alloy_primitives::Address; +use std::sync::Arc; + +/// Registry for managing contract ABIs and metadata +pub trait ContractRegistry: Send + Sync { + /// Format a token amount using the registry's token information + fn format_token_amount(&self, amount: u128, decimals: u8) -> String; +} + +/// Registry for managing contract visualizers +pub trait VisualizerRegistry: Send + Sync {} + +/// Arguments for creating a new VisualizerContext +/// This is safer than making a new() with many arguments directly +/// which clippy doesn't like and is bug prone to missing fields or mixing them +pub struct VisualizerContextParams { + pub chain_id: u64, + pub sender: Address, + pub current_contract: Address, + pub calldata: Vec, + pub registry: Arc, + pub visualizers: Arc, +} + +/// Context for visualizing Ethereum transactions and calls +#[derive(Clone)] +pub struct VisualizerContext { + /// The blockchain chain ID (e.g., 1 for Ethereum mainnet) + pub chain_id: u64, + /// The sender of the transaction + pub sender: Address, + /// The current contract being visualized + pub current_contract: Address, + /// The depth of nested calls (0 for top-level) + pub call_depth: usize, + /// The raw calldata for the current call, shared via Arc + pub calldata: Arc<[u8]>, + /// Registry containing contract ABI and metadata + pub registry: Arc, + /// Registry containing contract visualizers + pub visualizers: Arc, +} + +impl VisualizerContext { + /// Creates a new, top-level visualizer context + pub fn new(params: VisualizerContextParams) -> Self { + Self { + chain_id: params.chain_id, + sender: params.sender, + current_contract: params.current_contract, + call_depth: 0, // Set defaults inside the constructor + calldata: Arc::from(params.calldata), + registry: params.registry, + visualizers: params.visualizers, + } + } + + /// Creates a child context for a nested call with incremented call_depth + pub fn for_nested_call( + &self, + current_contract: Address, + calldata: Vec, // Still takes a Vec, as it's new data + ) -> Self { + Self { + chain_id: self.chain_id, + sender: self.sender, + current_contract, + call_depth: self.call_depth + 1, + calldata: Arc::from(calldata), // Convert to Arc + registry: self.registry.clone(), + visualizers: self.visualizers.clone(), + } + } + + /// Helper method to format token amounts using the registry + pub fn format_token_amount(&self, amount: u128, decimals: u8) -> String { + self.registry.format_token_amount(amount, decimals) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Mock implementation of ContractRegistry for testing + struct MockContractRegistry; + + impl ContractRegistry for MockContractRegistry { + fn format_token_amount(&self, amount: u128, decimals: u8) -> String { + // Use Alloy's format_units utility + alloy_primitives::utils::format_units(amount, decimals) + .unwrap_or_else(|_| amount.to_string()) + } + } + + /// Mock implementation of VisualizerRegistry for testing + struct MockVisualizerRegistry; + + impl VisualizerRegistry for MockVisualizerRegistry {} + + #[test] + fn test_visualizer_context_creation() { + let registry = Arc::new(MockContractRegistry); + let visualizers = Arc::new(MockVisualizerRegistry); + let sender = "0x1234567890123456789012345678901234567890" + .parse() + .unwrap(); + let contract = "0xabcdefabcdefabcdefabcdefabcdefabcdefabce" + .parse() + .unwrap(); + let calldata = vec![0x12, 0x34, 0x56, 0x78]; + + let params = VisualizerContextParams { + chain_id: 1, + sender, + current_contract: contract, + calldata: calldata.clone(), + registry: registry.clone(), + visualizers: visualizers.clone(), + }; + let context = VisualizerContext::new(params); + + assert_eq!(context.chain_id, 1); + assert_eq!(context.call_depth, 0); + assert_eq!(context.sender, sender); + assert_eq!(context.current_contract, contract); + assert_eq!(context.calldata.len(), 4); + assert_eq!(context.calldata.as_ref(), calldata.as_slice()); + } + + #[test] + fn test_visualizer_context_clone() { + let registry = Arc::new(MockContractRegistry); + let visualizers = Arc::new(MockVisualizerRegistry); + let sender = "0x1234567890123456789012345678901234567890" + .parse() + .unwrap(); + let contract = "0xabcdefabcdefabcdefabcdefabcdefabcdefabce" + .parse() + .unwrap(); + let calldata = vec![0x12, 0x34, 0x56, 0x78]; + + let params = VisualizerContextParams { + chain_id: 1, + sender, + current_contract: contract, + calldata: calldata.clone(), + registry: registry.clone(), + visualizers: visualizers.clone(), + }; + let context = VisualizerContext::new(params); + + let cloned = context.clone(); + + assert_eq!(cloned.chain_id, context.chain_id); + assert_eq!(cloned.call_depth, context.call_depth); + assert_eq!(cloned.sender, context.sender); + assert_eq!(cloned.current_contract, context.current_contract); + + // Test that the Arcs point to the same data and the data is correct + assert_eq!(cloned.calldata, context.calldata); + assert_eq!(cloned.calldata.as_ref(), calldata.as_slice()); + // Test that cloning the Arc was cheap (pointer comparison) + assert!(Arc::ptr_eq(&cloned.calldata, &context.calldata)); + assert!(Arc::ptr_eq(&cloned.registry, &context.registry)); + } + + #[test] + fn test_for_nested_call() { + let registry = Arc::new(MockContractRegistry); + let visualizers = Arc::new(MockVisualizerRegistry); + let sender = "0x1234567890123456789012345678901234567890" + .parse() + .unwrap(); + let contract1 = "0xabcdefabcdefabcdefabcdefabcdefabcdefabce" + .parse() + .unwrap(); + let contract2 = "0xfedcbafedcbafedcbafedcbafedcbafedcbafeda" + .parse() + .unwrap(); + let calldata1 = vec![0x12, 0x34, 0x56, 0x78]; + let calldata2 = vec![0xaa, 0xbb, 0xcc, 0xdd]; + let params = VisualizerContextParams { + chain_id: 1, + sender, + current_contract: contract1, + calldata: calldata1.clone(), + registry: registry.clone(), + visualizers: visualizers.clone(), + }; + let context = VisualizerContext::new(params); + + let nested = context.for_nested_call(contract2, calldata2.clone()); + + assert_eq!(nested.chain_id, context.chain_id); + assert_eq!(nested.sender, context.sender); + assert_eq!(nested.current_contract, contract2); + assert_eq!(nested.call_depth, 1); + assert_eq!(nested.calldata.as_ref(), calldata2.as_slice()); + } + + #[test] + fn test_format_token_amount() { + let registry = Arc::new(MockContractRegistry); + let visualizers = Arc::new(MockVisualizerRegistry); + + let params = VisualizerContextParams { + chain_id: 1, + sender: Address::ZERO, + current_contract: Address::ZERO, + calldata: vec![], + registry: registry.clone(), + visualizers: visualizers.clone(), + }; + let context = VisualizerContext::new(params); + + // Test with 18 decimals (like ETH/USDC) + assert_eq!( + context.format_token_amount(1000000000000000000, 18), + "1.000000000000000000" + ); + assert_eq!( + context.format_token_amount(1500000000000000000, 18), + "1.500000000000000000" + ); + + // Test with 6 decimals (like USDT) + assert_eq!(context.format_token_amount(1000000, 6), "1.000000"); + assert_eq!(context.format_token_amount(1500000, 6), "1.500000"); + } + + #[test] + fn test_nested_call_increments_depth() { + let registry = Arc::new(MockContractRegistry); + let visualizers = Arc::new(MockVisualizerRegistry); + let contract1 = "0xabcdefabcdefabcdefabcdefabcdefabcdefabce" + .parse() + .unwrap(); + let contract2 = "0xfedcbafedcbafedcbafedcbafedcbafedcbafeda" + .parse() + .unwrap(); + let contract3 = "0x1111111111111111111111111111111111111111" + .parse() + .unwrap(); + let params = VisualizerContextParams { + chain_id: 1, + sender: Address::ZERO, + current_contract: contract1, + calldata: vec![], + registry: registry.clone(), + visualizers: visualizers.clone(), + }; + let context = VisualizerContext::new(params); + + let nested1 = context.for_nested_call(contract2, vec![]); + assert_eq!(nested1.call_depth, 1); + + let nested2 = nested1.for_nested_call(contract3, vec![]); + assert_eq!(nested2.call_depth, 2); + } +} diff --git a/src/chain_parsers/visualsign-ethereum/src/contracts/erc20.rs b/src/chain_parsers/visualsign-ethereum/src/contracts/core/erc20.rs similarity index 100% rename from src/chain_parsers/visualsign-ethereum/src/contracts/erc20.rs rename to src/chain_parsers/visualsign-ethereum/src/contracts/core/erc20.rs diff --git a/src/chain_parsers/visualsign-ethereum/src/contracts/core/erc721.rs b/src/chain_parsers/visualsign-ethereum/src/contracts/core/erc721.rs new file mode 100644 index 00000000..f4a9a56f --- /dev/null +++ b/src/chain_parsers/visualsign-ethereum/src/contracts/core/erc721.rs @@ -0,0 +1,72 @@ +//! ERC-721 NFT Standard Visualizer +//! +//! Provides visualization for common ERC-721 functions. +//! +//! Reference: + +#![allow(unused_imports)] + +use alloy_sol_types::{SolCall, sol}; +use visualsign::{SignablePayloadField, SignablePayloadFieldCommon, SignablePayloadFieldTextV2}; + +// ERC-721 interface +sol! { + interface IERC721 { + function balanceOf(address owner) external view returns (uint256 balance); + function ownerOf(uint256 tokenId) external view returns (address owner); + function safeTransferFrom(address from, address to, uint256 tokenId) external; + function safeTransferFrom(address from, address to, uint256 tokenId, bytes calldata data) external; + function transferFrom(address from, address to, uint256 tokenId) external; + function approve(address to, uint256 tokenId) external; + function setApprovalForAll(address operator, bool approved) external; + function getApproved(uint256 tokenId) external view returns (address operator); + function isApprovedForAll(address owner, address operator) external view returns (bool); + } +} + +/// Visualizer for ERC-721 NFT contract calls +pub struct ERC721Visualizer; + +impl ERC721Visualizer { + /// Attempts to decode and visualize ERC-721 function calls + /// + /// # Arguments + /// * `input` - The calldata bytes + /// + /// # Returns + /// * `Some(field)` if a recognized ERC-721 function is found + /// * `None` if the input doesn't match any ERC-721 function + pub fn visualize_tx_commands(&self, input: &[u8]) -> Option { + if input.len() < 4 { + return None; + } + + // TODO: Implement ERC-721 function decoding + // - transferFrom(address,address,uint256) + // - safeTransferFrom variants + // - approve(address,uint256) + // - setApprovalForAll(address,bool) + // + // For now, return None to use fallback visualizer + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_visualize_empty_input() { + let visualizer = ERC721Visualizer; + assert_eq!(visualizer.visualize_tx_commands(&[]), None); + } + + #[test] + fn test_visualize_too_short() { + let visualizer = ERC721Visualizer; + assert_eq!(visualizer.visualize_tx_commands(&[0x01, 0x02]), None); + } + + // TODO: Add tests for each ERC-721 function once implemented +} diff --git a/src/chain_parsers/visualsign-ethereum/src/contracts/core/fallback.rs b/src/chain_parsers/visualsign-ethereum/src/contracts/core/fallback.rs new file mode 100644 index 00000000..ad896966 --- /dev/null +++ b/src/chain_parsers/visualsign-ethereum/src/contracts/core/fallback.rs @@ -0,0 +1,93 @@ +//! Fallback visualizer for unknown/unhandled contract calls +//! +//! This visualizer acts as a catch-all for contract calls that don't have +//! specific visualizers. It displays the raw calldata as hex. + +use visualsign::{SignablePayloadField, SignablePayloadFieldCommon, SignablePayloadFieldTextV2}; + +/// Fallback visualizer that displays raw hex data for unknown contracts +pub struct FallbackVisualizer; + +impl FallbackVisualizer { + /// Creates a new fallback visualizer + pub fn new() -> Self { + Self + } + + /// Visualizes unknown contract calldata as hex + /// + /// # Arguments + /// * `input` - The raw calldata bytes + /// + /// # Returns + /// A SignablePayloadField containing the hex-encoded calldata + pub fn visualize_hex(&self, input: &[u8]) -> SignablePayloadField { + let hex_data = if input.is_empty() { + "0x".to_string() + } else { + format!("0x{}", hex::encode(input)) + }; + + SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: hex_data.clone(), + label: "Contract Call Data".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { text: hex_data }, + } + } +} + +impl Default for FallbackVisualizer { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_visualize_empty_input() { + let visualizer = FallbackVisualizer::new(); + let field = visualizer.visualize_hex(&[]); + + match field { + SignablePayloadField::TextV2 { text_v2, .. } => { + assert_eq!(text_v2.text, "0x"); + } + _ => panic!("Expected TextV2 field"), + } + } + + #[test] + fn test_visualize_hex_data() { + let visualizer = FallbackVisualizer::new(); + let input = vec![0x12, 0x34, 0x56, 0x78, 0xab, 0xcd, 0xef]; + let field = visualizer.visualize_hex(&input); + + match field { + SignablePayloadField::TextV2 { text_v2, common } => { + assert_eq!(text_v2.text, "0x12345678abcdef"); + assert_eq!(common.label, "Contract Call Data"); + } + _ => panic!("Expected TextV2 field"), + } + } + + #[test] + fn test_visualize_function_selector() { + let visualizer = FallbackVisualizer::new(); + // Simulate a function call with 4-byte selector + let input = vec![0xa9, 0x05, 0x9c, 0xbb]; + let field = visualizer.visualize_hex(&input); + + match field { + SignablePayloadField::TextV2 { text_v2, .. } => { + assert_eq!(text_v2.text, "0xa9059cbb"); + } + _ => panic!("Expected TextV2 field"), + } + } +} diff --git a/src/chain_parsers/visualsign-ethereum/src/contracts/core/mod.rs b/src/chain_parsers/visualsign-ethereum/src/contracts/core/mod.rs new file mode 100644 index 00000000..ce148a45 --- /dev/null +++ b/src/chain_parsers/visualsign-ethereum/src/contracts/core/mod.rs @@ -0,0 +1,9 @@ +//! Core contract standards (ERC20, ERC721, etc.) + +pub mod erc20; +pub mod erc721; +pub mod fallback; + +pub use erc20::ERC20Visualizer; +pub use erc721::ERC721Visualizer; +pub use fallback::FallbackVisualizer; diff --git a/src/chain_parsers/visualsign-ethereum/src/contracts/mod.rs b/src/chain_parsers/visualsign-ethereum/src/contracts/mod.rs index ba5f478e..f326cb4f 100644 --- a/src/chain_parsers/visualsign-ethereum/src/contracts/mod.rs +++ b/src/chain_parsers/visualsign-ethereum/src/contracts/mod.rs @@ -1,2 +1,10 @@ -pub mod erc20; -pub mod uniswap; +//! Generic contract standards +//! +//! This module contains generic contract standards that are used across +//! multiple protocols (e.g., ERC20, ERC721, ERC1155). +//! +//! Protocol-specific contracts are located in the `protocols` module. + +pub mod core; + +pub use core::*; diff --git a/src/chain_parsers/visualsign-ethereum/src/contracts/uniswap.rs b/src/chain_parsers/visualsign-ethereum/src/contracts/uniswap.rs deleted file mode 100644 index 74a64992..00000000 --- a/src/chain_parsers/visualsign-ethereum/src/contracts/uniswap.rs +++ /dev/null @@ -1,494 +0,0 @@ -use alloy_sol_types::{SolCall as _, sol}; -use chrono::{TimeZone, Utc}; -use num_enum::TryFromPrimitive; -use visualsign::{SignablePayloadField, SignablePayloadFieldCommon, SignablePayloadFieldTextV2}; - -// From: https://github.com/Uniswap/universal-router/blob/main/contracts/interfaces/IUniversalRouter.sol -sol! { - interface IUniversalRouter { - /// @notice Executes encoded commands along with provided inputs. Reverts if deadline has expired. - /// @param commands A set of concatenated commands, each 1 byte in length - /// @param inputs An array of byte strings containing abi encoded inputs for each command - /// @param deadline The deadline by which the transaction must be executed - function execute(bytes calldata commands, bytes[] calldata inputs, uint256 deadline) external payable; - } -} - -// From: https://github.com/Uniswap/universal-router/blob/main/contracts/libraries/Commands.sol -#[derive(Copy, Clone, Debug, Eq, PartialEq, TryFromPrimitive)] -#[repr(u8)] -pub enum Command { - V3SwapExactIn = 0x00, - V3SwapExactOut = 0x01, - Permit2TransferFrom = 0x02, - Permit2PermitBatch = 0x03, - Sweep = 0x04, - Transfer = 0x05, - PayPortion = 0x06, - - V2SwapExactIn = 0x08, - V2SwapExactOut = 0x09, - Permit2Permit = 0x0a, - WrapEth = 0x0b, - UnwrapWeth = 0x0c, - Permit2TransferFromBatch = 0x0d, - BalanceCheckErc20 = 0x0e, - - V4Swap = 0x10, - V3PositionManagerPermit = 0x11, - V3PositionManagerCall = 0x12, - V4InitializePool = 0x13, - V4PositionManagerCall = 0x14, - - ExecuteSubPlan = 0x21, -} - -fn map_commands(raw: &[u8]) -> Vec { - let mut out = Vec::with_capacity(raw.len()); - for &b in raw { - if let Ok(cmd) = Command::try_from(b) { - out.push(cmd); - } - } - out -} - -pub struct UniswapV4Visualizer {} - -impl UniswapV4Visualizer { - pub fn visualize_tx_commands(&self, input: &[u8]) -> Option { - if input.len() < 4 { - return None; - } - if let Ok(call) = IUniversalRouter::executeCall::abi_decode(input) { - let deadline_val: i64 = match call.deadline.try_into() { - Ok(val) => val, - Err(_) => return None, - }; - let deadline = if deadline_val > 0 { - Utc.timestamp_opt(deadline_val, 0) - .single() - .map(|dt| dt.to_string()) - } else { - None - }; - let mapped = map_commands(&call.commands.0); - let mut detail_fields = Vec::new(); - - for (i, cmd) in mapped.iter().enumerate() { - let input_hex = call - .inputs - .get(i) - .map(|b| format!("0x{}", hex::encode(&b.0))) - .unwrap_or_else(|| "None".to_string()); // TODO: decode into readable values - - detail_fields.push(SignablePayloadField::PreviewLayout { - common: SignablePayloadFieldCommon { - fallback_text: format!("{cmd:?} input: {input_hex}"), - label: format!("Command {}", i + 1), - }, - preview_layout: visualsign::SignablePayloadFieldPreviewLayout { - title: Some(visualsign::SignablePayloadFieldTextV2 { - text: format!("{cmd:?}"), - }), - subtitle: Some(visualsign::SignablePayloadFieldTextV2 { - text: format!("Input: {input_hex}"), - }), - condensed: None, - expanded: None, - }, - }); - } - - // Deadline field (optional) - if let Some(dl) = &deadline { - detail_fields.push(SignablePayloadField::TextV2 { - common: SignablePayloadFieldCommon { - fallback_text: dl.clone(), - label: "Deadline".to_string(), - }, - text_v2: SignablePayloadFieldTextV2 { text: dl.clone() }, - }); - } - - return Some(SignablePayloadField::PreviewLayout { - common: SignablePayloadFieldCommon { - fallback_text: if let Some(dl) = &deadline { - format!( - "Universal Router Execute: {} commands ({:?}), deadline {}", - mapped.len(), - mapped, - dl - ) - } else { - format!( - "Universal Router Execute: {} commands ({:?})", - mapped.len(), - mapped - ) - }, - label: "Universal Router".to_string(), - }, - preview_layout: visualsign::SignablePayloadFieldPreviewLayout { - title: Some(visualsign::SignablePayloadFieldTextV2 { - text: "Universal Router Execute".to_string(), - }), - subtitle: if let Some(dl) = &deadline { - Some(visualsign::SignablePayloadFieldTextV2 { - text: format!("{} commands, deadline {}", mapped.len(), dl), - }) - } else { - Some(visualsign::SignablePayloadFieldTextV2 { - text: format!("{} commands", mapped.len()), - }) - }, - condensed: None, - expanded: Some(visualsign::SignablePayloadFieldListLayout { - fields: detail_fields - .into_iter() - .map(|f| visualsign::AnnotatedPayloadField { - signable_payload_field: f, - static_annotation: None, - dynamic_annotation: None, - }) - .collect(), - }), - }, - }); - } - None - } -} - -#[cfg(test)] -mod tests { - use super::*; - use alloy_primitives::{Bytes, U256}; - use visualsign::{ - AnnotatedPayloadField, SignablePayloadField, SignablePayloadFieldCommon, - SignablePayloadFieldListLayout, SignablePayloadFieldPreviewLayout, - SignablePayloadFieldTextV2, - }; - - fn encode_execute_call(commands: &[u8], inputs: Vec>, deadline: u64) -> Vec { - let inputs_bytes = inputs.into_iter().map(Bytes::from).collect::>(); - IUniversalRouter::executeCall { - commands: Bytes::from(commands.to_vec()), - inputs: inputs_bytes, - deadline: U256::from(deadline), - } - .abi_encode() - } - - #[test] - fn test_visualize_tx_commands_empty_input() { - assert_eq!(UniswapV4Visualizer {}.visualize_tx_commands(&[]), None); - assert_eq!( - UniswapV4Visualizer {}.visualize_tx_commands(&[0x01, 0x02, 0x03]), - None - ); - } - - #[test] - fn test_visualize_tx_commands_invalid_deadline() { - // deadline is not convertible to i64 (u64::MAX) - let input = encode_execute_call(&[0x00], vec![vec![0x01, 0x02]], u64::MAX); - assert_eq!(UniswapV4Visualizer {}.visualize_tx_commands(&input), None); - } - - #[test] - fn test_visualize_tx_commands_single_command_with_deadline() { - let commands = vec![Command::V3SwapExactIn as u8]; - let inputs = vec![vec![0xde, 0xad, 0xbe, 0xef]]; - let deadline = 1_700_000_000u64; // 2023-11-13T12:26:40Z - let input = encode_execute_call(&commands, inputs.clone(), deadline); - - // Build expected field - let dt = chrono::Utc.timestamp_opt(deadline as i64, 0).unwrap(); - let deadline_str = dt.to_string(); - - assert_eq!( - UniswapV4Visualizer {} - .visualize_tx_commands(&input) - .unwrap(), - SignablePayloadField::PreviewLayout { - common: SignablePayloadFieldCommon { - fallback_text: format!( - "Universal Router Execute: 1 commands ([V3SwapExactIn]), deadline {deadline_str}" - ), - label: "Universal Router".to_string(), - }, - preview_layout: SignablePayloadFieldPreviewLayout { - title: Some(SignablePayloadFieldTextV2 { - text: "Universal Router Execute".to_string(), - }), - subtitle: Some(SignablePayloadFieldTextV2 { - text: format!("1 commands, deadline {deadline_str}"), - }), - condensed: None, - expanded: Some(SignablePayloadFieldListLayout { - fields: vec![ - AnnotatedPayloadField { - signable_payload_field: SignablePayloadField::PreviewLayout { - common: SignablePayloadFieldCommon { - fallback_text: "V3SwapExactIn input: 0xdeadbeef" - .to_string(), - label: "Command 1".to_string(), - }, - preview_layout: SignablePayloadFieldPreviewLayout { - title: Some(SignablePayloadFieldTextV2 { - text: "V3SwapExactIn".to_string(), - }), - subtitle: Some(SignablePayloadFieldTextV2 { - text: "Input: 0xdeadbeef".to_string(), - }), - condensed: None, - expanded: None, - }, - }, - static_annotation: None, - dynamic_annotation: None, - }, - AnnotatedPayloadField { - signable_payload_field: SignablePayloadField::TextV2 { - common: SignablePayloadFieldCommon { - fallback_text: deadline_str.clone(), - label: "Deadline".to_string(), - }, - text_v2: SignablePayloadFieldTextV2 { - text: deadline_str.clone(), - }, - }, - static_annotation: None, - dynamic_annotation: None, - }, - ], - }), - }, - } - ); - } - - #[test] - fn test_visualize_tx_commands_multiple_commands_no_deadline() { - let commands = vec![ - Command::V3SwapExactIn as u8, - Command::Transfer as u8, - Command::WrapEth as u8, - ]; - let inputs = vec![vec![0x01, 0x02], vec![0x03, 0x04, 0x05], vec![0x06]]; - let deadline = 0u64; - let input = encode_execute_call(&commands, inputs.clone(), deadline); - - assert_eq!( - UniswapV4Visualizer {} - .visualize_tx_commands(&input) - .unwrap(), - SignablePayloadField::PreviewLayout { - common: SignablePayloadFieldCommon { - fallback_text: - "Universal Router Execute: 3 commands ([V3SwapExactIn, Transfer, WrapEth])" - .to_string(), - label: "Universal Router".to_string(), - }, - preview_layout: SignablePayloadFieldPreviewLayout { - title: Some(SignablePayloadFieldTextV2 { - text: "Universal Router Execute".to_string(), - }), - subtitle: Some(SignablePayloadFieldTextV2 { - text: "3 commands".to_string(), - }), - condensed: None, - expanded: Some(SignablePayloadFieldListLayout { - fields: vec![ - AnnotatedPayloadField { - signable_payload_field: SignablePayloadField::PreviewLayout { - common: SignablePayloadFieldCommon { - fallback_text: "V3SwapExactIn input: 0x0102".to_string(), - label: "Command 1".to_string(), - }, - preview_layout: SignablePayloadFieldPreviewLayout { - title: Some(SignablePayloadFieldTextV2 { - text: "V3SwapExactIn".to_string(), - }), - subtitle: Some(SignablePayloadFieldTextV2 { - text: "Input: 0x0102".to_string(), - }), - condensed: None, - expanded: None, - }, - }, - static_annotation: None, - dynamic_annotation: None, - }, - AnnotatedPayloadField { - signable_payload_field: SignablePayloadField::PreviewLayout { - common: SignablePayloadFieldCommon { - fallback_text: "Transfer input: 0x030405".to_string(), - label: "Command 2".to_string(), - }, - preview_layout: SignablePayloadFieldPreviewLayout { - title: Some(SignablePayloadFieldTextV2 { - text: "Transfer".to_string(), - }), - subtitle: Some(SignablePayloadFieldTextV2 { - text: "Input: 0x030405".to_string(), - }), - condensed: None, - expanded: None, - }, - }, - static_annotation: None, - dynamic_annotation: None, - }, - AnnotatedPayloadField { - signable_payload_field: SignablePayloadField::PreviewLayout { - common: SignablePayloadFieldCommon { - fallback_text: "WrapEth input: 0x06".to_string(), - label: "Command 3".to_string(), - }, - preview_layout: SignablePayloadFieldPreviewLayout { - title: Some(SignablePayloadFieldTextV2 { - text: "WrapEth".to_string(), - }), - subtitle: Some(SignablePayloadFieldTextV2 { - text: "Input: 0x06".to_string(), - }), - condensed: None, - expanded: None, - }, - }, - static_annotation: None, - dynamic_annotation: None, - }, - ], - }), - }, - } - ); - } - - #[test] - fn test_visualize_tx_commands_command_without_input() { - // Only one command, but no input for it - let commands = vec![Command::Sweep as u8]; - let inputs = vec![]; // No input - let deadline = 1_700_000_000u64; - let input = encode_execute_call(&commands, inputs.clone(), deadline); - - let dt = chrono::Utc.timestamp_opt(deadline as i64, 0).unwrap(); - let deadline_str = dt.to_string(); - - assert_eq!( - UniswapV4Visualizer {} - .visualize_tx_commands(&input) - .unwrap(), - SignablePayloadField::PreviewLayout { - common: SignablePayloadFieldCommon { - fallback_text: format!( - "Universal Router Execute: 1 commands ([Sweep]), deadline {deadline_str}", - ), - label: "Universal Router".to_string(), - }, - preview_layout: SignablePayloadFieldPreviewLayout { - title: Some(SignablePayloadFieldTextV2 { - text: "Universal Router Execute".to_string(), - }), - subtitle: Some(SignablePayloadFieldTextV2 { - text: format!("1 commands, deadline {deadline_str}"), - }), - condensed: None, - expanded: Some(SignablePayloadFieldListLayout { - fields: vec![ - AnnotatedPayloadField { - signable_payload_field: SignablePayloadField::PreviewLayout { - common: SignablePayloadFieldCommon { - fallback_text: "Sweep input: None".to_string(), - label: "Command 1".to_string(), - }, - preview_layout: SignablePayloadFieldPreviewLayout { - title: Some(SignablePayloadFieldTextV2 { - text: "Sweep".to_string(), - }), - subtitle: Some(SignablePayloadFieldTextV2 { - text: "Input: None".to_string(), - }), - condensed: None, - expanded: None, - }, - }, - static_annotation: None, - dynamic_annotation: None, - }, - AnnotatedPayloadField { - signable_payload_field: SignablePayloadField::TextV2 { - common: SignablePayloadFieldCommon { - fallback_text: deadline_str.clone(), - label: "Deadline".to_string(), - }, - text_v2: SignablePayloadFieldTextV2 { - text: deadline_str.clone(), - }, - }, - static_annotation: None, - dynamic_annotation: None, - }, - ], - }), - }, - } - ); - } - - #[test] - fn test_visualize_tx_commands_unrecognized_command() { - // 0xff is not a valid Command, so it should be skipped - let commands = vec![0xff, Command::Transfer as u8]; - let inputs = vec![vec![0x01], vec![0x02]]; - let deadline = 0u64; - let input = encode_execute_call(&commands, inputs.clone(), deadline); - - assert_eq!( - UniswapV4Visualizer {} - .visualize_tx_commands(&input) - .unwrap(), - SignablePayloadField::PreviewLayout { - common: SignablePayloadFieldCommon { - fallback_text: "Universal Router Execute: 1 commands ([Transfer])".to_string(), - label: "Universal Router".to_string(), - }, - preview_layout: SignablePayloadFieldPreviewLayout { - title: Some(SignablePayloadFieldTextV2 { - text: "Universal Router Execute".to_string(), - }), - subtitle: Some(SignablePayloadFieldTextV2 { - text: "1 commands".to_string(), - }), - condensed: None, - expanded: Some(SignablePayloadFieldListLayout { - fields: vec![AnnotatedPayloadField { - signable_payload_field: SignablePayloadField::PreviewLayout { - common: SignablePayloadFieldCommon { - fallback_text: "Transfer input: 0x01".to_string(), - label: "Command 1".to_string(), - }, - preview_layout: SignablePayloadFieldPreviewLayout { - title: Some(SignablePayloadFieldTextV2 { - text: "Transfer".to_string(), - }), - subtitle: Some(SignablePayloadFieldTextV2 { - text: "Input: 0x01".to_string(), - }), - condensed: None, - expanded: None, - }, - }, - static_annotation: None, - dynamic_annotation: None, - }], - }), - }, - } - ); - } -} diff --git a/src/chain_parsers/visualsign-ethereum/src/lib.rs b/src/chain_parsers/visualsign-ethereum/src/lib.rs index 0e6452ca..f799c22d 100644 --- a/src/chain_parsers/visualsign-ethereum/src/lib.rs +++ b/src/chain_parsers/visualsign-ethereum/src/lib.rs @@ -1,4 +1,5 @@ use crate::fmt::{format_ether, format_gwei}; +use crate::registry::ContractType; use alloy_consensus::{Transaction as _, TxType, TypedTransaction}; use alloy_rlp::{Buf, Decodable}; use base64::{Engine as _, engine::general_purpose::STANDARD as b64}; @@ -13,8 +14,14 @@ use visualsign::{ }; pub mod chains; +pub mod context; pub mod contracts; pub mod fmt; +pub mod protocols; +pub mod registry; +pub mod token_metadata; +pub mod utils; +pub mod visualizer; #[derive(Debug, Eq, PartialEq, thiserror::Error)] pub enum EthereumParserError { @@ -107,7 +114,98 @@ impl EthereumTransactionWrapper { } /// Converter that knows how to format Ethereum transactions for VisualSign -pub struct EthereumVisualSignConverter; +/// +/// # TODO: Registry Architecture Refactor +/// +/// The current design has a fundamental issue: the registry is owned by the converter, +/// but it should be context-based and layered with provenance tracking. +/// +/// ## Current Problems: +/// 1. Registry is static per converter instance - can't change per transaction +/// 2. No way to merge built-in parser registry with wallet-provided ChainMetadata +/// 3. No provenance tracking - caller can't tell if data came from built-in or wallet +/// 4. Registry is created at converter initialization, not passed per-request +/// +/// ## Proper Architecture: +/// +/// ```rust +/// // Registry with source tracking +/// pub struct RegistrySource { +/// source: RegistrySourceType, // Builtin | Wallet +/// registry: ContractRegistry, +/// } +/// +/// pub enum RegistrySourceType { +/// Builtin, // Parser's known contracts/tokens +/// Wallet, // From ChainMetadata +/// } +/// +/// // Layered lookup with provenance +/// pub struct RegistryLayers { +/// layers: Vec, // Lookup order matters +/// } +/// +/// // Pass via context or options, not owned by converter +/// pub struct VisualSignOptions { +/// registries: Option, +/// // ... other fields +/// } +/// ``` +/// +/// ## Benefits of Refactor: +/// - Wallets can provide ChainMetadata that gets merged transparently +/// - Different transactions can use different registry combinations +/// - Caller knows if token/contract info came from built-in or wallet source +/// - Registry flows through VisualizerContext, enabling protocol-specific lookups +/// +/// ## Migration Path: +/// 1. Create RegistryLayers and RegistrySource types +/// 2. Add optional registries field to VisualSignOptions +/// 3. Update to_visual_sign_payload to accept options-based registries +/// 4. Deprecate converter-owned registry field +/// 5. Update all protocol visualizers to use context-based registry +pub struct EthereumVisualSignConverter { + registry: registry::ContractRegistry, + visualizer_registry: visualizer::EthereumVisualizerRegistry, +} + +impl EthereumVisualSignConverter { + /// Creates a new converter with custom registries + pub fn with_registries( + registry: registry::ContractRegistry, + visualizer_registry: visualizer::EthereumVisualizerRegistry, + ) -> Self { + Self { + registry, + visualizer_registry, + } + } + + /// Creates a new converter with a custom contract registry and default visualizer registry + pub fn with_registry(registry: registry::ContractRegistry) -> Self { + Self { + registry, + visualizer_registry: + visualizer::EthereumVisualizerRegistryBuilder::with_default_protocols().build(), + } + } + + /// Creates a new converter with a default registry including all known protocols + pub fn new() -> Self { + let visualizer_registry = + visualizer::EthereumVisualizerRegistryBuilder::with_default_protocols().build(); + Self { + registry: registry::ContractRegistry::with_default_protocols(), + visualizer_registry, + } + } +} + +impl Default for EthereumVisualSignConverter { + fn default() -> Self { + Self::new() + } +} impl VisualSignConverter for EthereumVisualSignConverter { fn to_visual_sign_payload( @@ -116,12 +214,27 @@ impl VisualSignConverter for EthereumVisualSignConve options: VisualSignOptions, ) -> Result { let transaction = transaction_wrapper.inner().clone(); + + // Debug trace: Log registry usage for contract/token lookups (future enhancement) + if let Some(to) = transaction.to() { + if let Some(chain_id) = transaction.chain_id() { + let _contract_type = self.registry.get_contract_type(chain_id, to); + let _token_symbol = self.registry.get_token_symbol(chain_id, to); + // TODO: Use contract_type and token_symbol to enhance visualization + } + } + let is_supported = match transaction.tx_type() { TxType::Eip2930 | TxType::Eip4844 | TxType::Eip7702 => false, TxType::Legacy | TxType::Eip1559 => true, }; if is_supported { - return Ok(convert_to_visual_sign_payload(transaction, options)); + return Ok(convert_to_visual_sign_payload( + transaction, + options, + &self.registry, + &self.visualizer_registry, + )); } Err(VisualSignError::DecodeError(format!( "Unsupported transaction type: {}", @@ -206,6 +319,8 @@ fn decode_transaction( fn convert_to_visual_sign_payload( transaction: TypedTransaction, options: VisualSignOptions, + registry: ®istry::ContractRegistry, + visualizer_registry: &visualizer::EthereumVisualizerRegistry, ) -> SignablePayload { // Extract chain ID to determine the network let chain_id = transaction.chain_id(); @@ -288,28 +403,60 @@ fn convert_to_visual_sign_payload( let input = transaction.input(); if !input.is_empty() { let mut input_fields: Vec = Vec::new(); - if options.decode_transfers { - if let Some(field) = (contracts::erc20::ERC20Visualizer {}).visualize_tx_commands(input) + + // Try to visualize using the registered visualizers + let chain_id_val = chain_id.unwrap_or(1); + if let Some(to_address) = transaction.to() { + if let Some(contract_type) = registry.get_contract_type(chain_id_val, to_address) { + if visualizer_registry.get(&contract_type).is_some() { + // Check if this is a Morpho Bundler3 contract and visualize it + if contract_type + == crate::protocols::morpho::config::Bundler3Contract::short_type_id() + { + if let Some(field) = (protocols::morpho::BundlerVisualizer {}) + .visualize_multicall(input, chain_id_val, Some(registry)) + { + input_fields.push(field); + } + } + // Check if this is a Universal Router contract and visualize it + else if contract_type + == crate::protocols::uniswap::config::UniswapUniversalRouter::short_type_id( + ) + { + if let Some(field) = (protocols::uniswap::UniversalRouterVisualizer {}) + .visualize_tx_commands(input, chain_id_val, Some(registry)) + { + input_fields.push(field); + } + } + // Check if this is a Permit2 contract and visualize it + else if contract_type + == crate::protocols::uniswap::config::Permit2Contract::short_type_id() + { + if let Some(field) = (protocols::uniswap::Permit2Visualizer) + .visualize_tx_commands(input, chain_id_val, Some(registry)) + { + input_fields.push(field); + } + } + } + } + } + + // Fallback: Try ERC20 if decode_transfers is enabled + if input_fields.is_empty() && options.decode_transfers { + if let Some(field) = (contracts::core::ERC20Visualizer {}).visualize_tx_commands(input) { input_fields.push(field); } } - if let Some(field) = - (contracts::uniswap::UniswapV4Visualizer {}).visualize_tx_commands(input) - { - input_fields.push(field); - } + + // Last resort: Use fallback visualizer for unknown contract calls if input_fields.is_empty() { - input_fields.push(SignablePayloadField::TextV2 { - common: SignablePayloadFieldCommon { - fallback_text: format!("0x{}", hex::encode(input)), - label: "Input Data".to_string(), - }, - text_v2: SignablePayloadFieldTextV2 { - text: format!("0x{}", hex::encode(input)), - }, - }); + input_fields.push(contracts::core::FallbackVisualizer::new().visualize_hex(input)); } + fields.append(&mut input_fields); } @@ -325,7 +472,7 @@ pub fn transaction_to_visual_sign( options: VisualSignOptions, ) -> Result { let wrapper = EthereumTransactionWrapper::new(transaction); - let converter = EthereumVisualSignConverter; + let converter = EthereumVisualSignConverter::new(); converter.to_visual_sign_payload(wrapper, options) } @@ -333,7 +480,7 @@ pub fn transaction_string_to_visual_sign( transaction_data: &str, options: VisualSignOptions, ) -> Result { - let converter = EthereumVisualSignConverter; + let converter = EthereumVisualSignConverter::new(); converter.to_visual_sign_payload_from_string(transaction_data, options) } @@ -475,12 +622,17 @@ mod tests { let options = VisualSignOptions::default(); let payload = transaction_to_visual_sign(tx, options).unwrap(); - // Check that input data field is present - assert!(payload.fields.iter().any(|f| f.label() == "Input Data")); + // Check that contract call data field is present (FallbackVisualizer) + assert!( + payload + .fields + .iter() + .any(|f| f.label() == "Contract Call Data") + ); let input_field = payload .fields .iter() - .find(|f| f.label() == "Input Data") + .find(|f| f.label() == "Contract Call Data") .unwrap(); if let SignablePayloadField::TextV2 { text_v2, .. } = input_field { assert_eq!(text_v2.text, "0x12345678"); diff --git a/src/chain_parsers/visualsign-ethereum/src/protocols/mod.rs b/src/chain_parsers/visualsign-ethereum/src/protocols/mod.rs new file mode 100644 index 00000000..c9b2ce1f --- /dev/null +++ b/src/chain_parsers/visualsign-ethereum/src/protocols/mod.rs @@ -0,0 +1,25 @@ +pub mod morpho; +pub mod safe; +pub mod uniswap; + +use crate::registry::ContractRegistry; +use crate::visualizer::EthereumVisualizerRegistryBuilder; + +/// Registers all available protocol contracts and visualizers +/// +/// # Arguments +/// * `contract_reg` - The contract registry to register addresses +/// * `visualizer_reg` - The visualizer registry to register visualizers +pub fn register_all( + contract_reg: &mut ContractRegistry, + visualizer_reg: &mut EthereumVisualizerRegistryBuilder, +) { + // Register Morpho protocol + morpho::register(contract_reg, visualizer_reg); + + // Register Safe protocol + safe::register(contract_reg, visualizer_reg); + + // Register Uniswap protocol + uniswap::register(contract_reg, visualizer_reg); +} diff --git a/src/chain_parsers/visualsign-ethereum/src/protocols/morpho/IMPLEMENTATION_STATUS.md b/src/chain_parsers/visualsign-ethereum/src/protocols/morpho/IMPLEMENTATION_STATUS.md new file mode 100644 index 00000000..bc49c4ee --- /dev/null +++ b/src/chain_parsers/visualsign-ethereum/src/protocols/morpho/IMPLEMENTATION_STATUS.md @@ -0,0 +1,159 @@ +# Morpho Bundler - Implementation Status + +## Overview + +This document outlines the implementation status of the Morpho Bundler `multicall` command visualization. Based on the `BundlerV3` contract, we catalog: + +- ✅ Implemented commands +- ⏳ Commands needing implementation +- 📋 Known special cases and encoding requirements + +## Reference + +- **Contract**: [BundlerV3.sol on GitHub](https://github.com/morpho-org/morpho-blue-bundlers/blob/main/src/BundlerV3.sol) +- **Configuration**: `src/protocols/morpho/config.rs` +- **Implementation**: `src/protocols/morpho/contracts/bundler.rs` +- **Tests**: All tests passing (5/5 ✓) + +--- + +## Implemented Commands (✅) + +The `multicall` function takes an array of `Call` structs. The action to be performed is determined by the `selector` field within each `Call` struct. + +### 0xd505accf - `permit(address owner, address spender, uint256 value, uint256 deadline, bytes signature)` + +**Status**: ✅ Fully Implemented +**Visualization**: Shows token, amount, spender, and expiration. +**Special Case**: The `signature` is a dynamic `bytes` array. The decoder handles this by reading the offset and length. + +### 0xd96ca0b9 - `erc20TransferFrom(address token, address from, uint256 amount)` + +**Status**: ✅ Fully Implemented +**Visualization**: Shows token, amount, and the source address. +**Notes**: This is a wrapper for a standard `transferFrom` call, executed by the bundler contract. + +### 0x6ef5eeae - `erc4626Deposit(address vault, uint256 assets, uint256 minShares, address receiver)` + +**Status**: ✅ Fully Implemented +**Visualization**: Shows vault address, assets deposited, minimum shares expected, and the receiver. +**Notes**: Resolves vault symbol if available in the `ContractRegistry`. + +--- + +## Commands Requiring Implementation (⏳) + +The following operations are part of the Morpho protocol but are not yet implemented in the visualizer. + +### Bundler Actions + +- ⏳ `erc4626Redeem` +- ⏳ `erc4626Withdraw` +- ⏳ `wethWithdraw` +- ⏳ `wethWithdrawTo` +- ⏳ `transfer` +- ⏳ `pull` + +### Morpho Blue Actions + +- ⏳ `blueSupply` +- ⏳ `blueWithdraw` +- ⏳ `blueBorrow` +- ⏳ `blueRepay` +- ⏳ `blueAddCollateral` + +--- + +## Implementation Priority Matrix + +### Tier 1 (High Priority - Core Functionality) + +- [ ] `blueSupply` - Essential for interacting with Morpho Blue markets. +- [ ] `blueBorrow` - Core user action on Morpho Blue. +- [ ] `blueRepay` - Completes the borrowing lifecycle. +- [ ] `blueWithdraw` - Allows users to retrieve their supplied assets. + +### Tier 2 (Medium Priority - Vault and Collateral) + +- [ ] `erc4626Redeem` / `erc4626Withdraw` - Common vault interactions. +- [ ] `blueAddCollateral` - Important for managing loan health. + +### Tier 3 (Lower Priority - Utility Functions) + +- [ ] `wethWithdraw` / `wethWithdrawTo` - WETH handling. +- [ ] `transfer` / `pull` - Basic token movements. + +--- + +## Key Technical Findings + +### `sol!` Macro for Decoding + +The implementation relies heavily on the `alloy-sol-types` `sol!` macro for generating type-safe Rust structs from Solidity definitions. This simplifies decoding and reduces boilerplate. + +### Nested Dynamic Calls + +The core of the bundler is the `multicall(Call[] calldata calls)` function. The visualizer must first decode this outer call, then iterate through the `calls` array. For each `Call` in the array, it must: + +1. Read the `selector`. +2. Match the `selector` to a known function. +3. Decode the `data` field using the corresponding function's ABI. + +### Contract Registry for Context + +The `ContractRegistry` is crucial for providing context, such as token symbols and decimals. All visualizers should query the registry to enrich the output. + +--- + +## How to Add a New Command + +To add support for a new command (e.g., `blueSupply`): + +1. **`contracts/bundler.rs`**: + + - Add the function signature and any required structs to the `sol!` macro block. + ```rust + // ... existing sol! macro + function blueSupply(address market, uint256 assets, address onBehalf, bytes data) external; + // ... + ``` + - Add a new `const` for the selector. + ```rust + // ... + const BLUE_SUPPLY_SELECTOR: [u8; 4] = selector!("blueSupply(address,uint256,address,bytes)"); + // ... + ``` + - Add a new match arm in `decode_nested_call`. + ```rust + // ... + match selector { + // ... + BLUE_SUPPLY_SELECTOR => self.decode_blue_supply(registry, &call.data), + _ => Ok(unhandled_field(call)), + } + // ... + ``` + - Implement the `decode_blue_supply` function. This function should decode the parameters and return a `SignablePayloadField`. + ```rust + fn decode_blue_supply(...) -> Result { + // 1. Decode data using sol! struct: blueSupplyCall::decode_single(&data, true)? + // 2. Look up token symbols in registry. + // 3. Format amounts. + // 4. Return a TextV2 or PreviewLayout field. + } + ``` + +2. **`tests` in `contracts/bundler.rs`**: + + - Add a unit test for the new `decode_blue_supply` function with sample data. + - If possible, add the new command to the `test_visualize_multicall_real_transaction` test or create a new integration test. + +3. **`IMPLEMENTATION_STATUS.md` (This file)**: + - Move the command from the "⏳" section to the "✅" section. + - Update the status and add implementation notes. + +--- + +_Document Version 1.0_ +_Last Updated: 2025-11-16_ +_Status: Initial implementation with three core bundler commands._ diff --git a/src/chain_parsers/visualsign-ethereum/src/protocols/morpho/config.rs b/src/chain_parsers/visualsign-ethereum/src/protocols/morpho/config.rs new file mode 100644 index 00000000..f2a96b62 --- /dev/null +++ b/src/chain_parsers/visualsign-ethereum/src/protocols/morpho/config.rs @@ -0,0 +1,66 @@ +use crate::registry::{ContractRegistry, ContractType}; +use alloy_primitives::Address; + +/// Morpho Bundler3 contract type identifier +pub struct Bundler3Contract; + +impl ContractType for Bundler3Contract { + fn short_type_id() -> &'static str { + "morpho_bundler3" + } +} + +/// Configuration for Morpho protocol contracts +pub struct MorphoConfig; + +impl MorphoConfig { + /// Returns the Bundler3 contract address (same on all chains) + /// Source: https://docs.morpho.org/contracts/addresses + pub fn bundler3_address() -> Address { + "0x6566194141eefa99Af43Bb5Aa71460Ca2Dc90245" + .parse() + .unwrap() + } + + /// Returns the list of chain IDs where Bundler3 is deployed + pub fn bundler3_chains() -> &'static [u64] { + &[ + 1, // Ethereum Mainnet + 10, // Optimism + 8453, // Base + 42161, // Arbitrum One + ] + } + + /// Registers Morpho protocol contracts in the registry + pub fn register_contracts(registry: &mut ContractRegistry) { + let bundler3_address = Self::bundler3_address(); + + for &chain_id in Self::bundler3_chains() { + registry.register_contract_typed::(chain_id, vec![bundler3_address]); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_bundler3_address() { + let addr = MorphoConfig::bundler3_address(); + assert_eq!( + format!("{:?}", addr).to_lowercase(), + "0x6566194141eefa99af43bb5aa71460ca2dc90245" + ); + } + + #[test] + fn test_bundler3_chains() { + let chains = MorphoConfig::bundler3_chains(); + assert!(chains.contains(&1)); // Ethereum + assert!(chains.contains(&10)); // Optimism + assert!(chains.contains(&8453)); // Base + assert!(chains.contains(&42161)); // Arbitrum + } +} diff --git a/src/chain_parsers/visualsign-ethereum/src/protocols/morpho/contracts/bundler.rs b/src/chain_parsers/visualsign-ethereum/src/protocols/morpho/contracts/bundler.rs new file mode 100644 index 00000000..45673f67 --- /dev/null +++ b/src/chain_parsers/visualsign-ethereum/src/protocols/morpho/contracts/bundler.rs @@ -0,0 +1,698 @@ +use alloy_primitives::{Address, Bytes, U256}; +use alloy_sol_types::{SolCall as _, SolValue as _, sol}; +use chrono::TimeZone; +use visualsign::{ + AnnotatedPayloadField, SignablePayloadField, SignablePayloadFieldCommon, + SignablePayloadFieldListLayout, SignablePayloadFieldPreviewLayout, SignablePayloadFieldTextV2, +}; + +use crate::context::VisualizerContext; +use crate::protocols::morpho::config::Bundler3Contract; +use crate::registry::{ContractRegistry, ContractType}; + +// Morpho Bundler3 interface definitions +// +// Official Documentation: +// - Technical Reference: https://docs.morpho.org/contracts/bundler +// - Contract Source: https://github.com/morpho-org/morpho-blue-bundlers +// +// The Bundler3 contract allows batching multiple operations into a single transaction. +sol! { + /// @notice Struct containing all the data needed to make a call. + struct Call { + address to; + bytes data; + uint256 value; + bool skipRevert; + bytes32 callbackHash; + } + + interface IBundler3 { + /// @notice Executes multiple calls in sequence + function multicall(Call[] calldata) external payable; + } + + // Common ERC-20 operations used in Bundler calls + interface IERC20 { + /// @notice ERC-2612 permit function + function permit( + address owner, + address spender, + uint256 value, + uint256 deadline, + uint8 v, + bytes32 r, + bytes32 s + ) external; + } + + // Bundler-specific wrapper functions + /// @notice Transfer tokens from user to Bundler + struct Erc20TransferFromParams { + address token; + address from; + uint256 amount; + } + + /// @notice Deposit into ERC-4626 vault + struct Erc4626DepositParams { + address vault; + uint256 assets; + uint256 minShares; + address receiver; + } +} + +/// Visualizer for Morpho Bundler3 contract +pub struct BundlerVisualizer {} + +impl BundlerVisualizer { + /// Visualizes Morpho Bundler3 multicall operations + /// + /// # Arguments + /// * `input` - The calldata bytes + /// * `chain_id` - The chain ID for registry lookups + /// * `registry` - Optional registry for resolving token symbols + pub fn visualize_multicall( + &self, + input: &[u8], + chain_id: u64, + registry: Option<&ContractRegistry>, + ) -> Option { + if input.len() < 4 { + return None; + } + + // Try decoding the multicall + let call = match IBundler3::multicallCall::abi_decode(input) { + Ok(c) => c, + Err(_) => return None, + }; + + let calls = &call.0; + let mut detail_fields = Vec::new(); + + for morpho_call in calls.iter() { + // Decode the nested call data + let nested_field = Self::decode_nested_call( + &morpho_call.to, + &morpho_call.data, + &morpho_call.value, + chain_id, + registry, + ); + + detail_fields.push(nested_field); + } + + Some(SignablePayloadField::PreviewLayout { + common: SignablePayloadFieldCommon { + fallback_text: format!("Morpho Bundler: {} operations", calls.len()), + label: "Morpho Bundler".to_string(), + }, + preview_layout: SignablePayloadFieldPreviewLayout { + title: Some(SignablePayloadFieldTextV2 { + text: "Morpho Bundler Multicall".to_string(), + }), + subtitle: Some(SignablePayloadFieldTextV2 { + text: format!("{} operation(s)", calls.len()), + }), + condensed: None, + expanded: Some(SignablePayloadFieldListLayout { + fields: detail_fields + .into_iter() + .map(|f| AnnotatedPayloadField { + signable_payload_field: f, + static_annotation: None, + dynamic_annotation: None, + }) + .collect(), + }), + }, + }) + } + + /// Decodes a nested call within the multicall + fn decode_nested_call( + to: &Address, + data: &Bytes, + _value: &U256, + chain_id: u64, + registry: Option<&ContractRegistry>, + ) -> SignablePayloadField { + if data.len() < 4 { + return Self::unknown_call_field(to, data); + } + + let selector = &data[0..4]; + + // Check known function selectors + match selector { + // permit(address,address,uint256,uint256,uint8,bytes32,bytes32) + [0xd5, 0x05, 0xac, 0xcf] => Self::decode_permit(&data[4..], to, chain_id, registry), + // erc20TransferFrom(address,address,uint256) + [0xd9, 0x6c, 0xa0, 0xb9] => { + Self::decode_erc20_transfer_from(&data[4..], chain_id, registry) + } + // erc4626Deposit(address,uint256,uint256,address) + [0x6e, 0xf5, 0xee, 0xae] => { + Self::decode_erc4626_deposit(&data[4..], chain_id, registry) + } + _ => Self::unknown_call_field(to, data), + } + } + + /// Decodes ERC-2612 permit operation + fn decode_permit( + bytes: &[u8], + token_address: &Address, + chain_id: u64, + registry: Option<&ContractRegistry>, + ) -> SignablePayloadField { + // Manual decode: owner (32) | spender (32) | value (32) | deadline (32) | v (32) | r (32) | s (32) + if bytes.len() < 224 { + return SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: "Permit: Invalid data".to_string(), + label: "Permit".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: format!("Data too short: {} bytes", bytes.len()), + }, + }; + } + + let owner = Address::from_slice(&bytes[12..32]); + let spender = Address::from_slice(&bytes[44..64]); + let value = U256::from_be_slice(&bytes[64..96]); + let deadline = U256::from_be_slice(&bytes[96..128]); + + let token_symbol = registry + .and_then(|r| r.get_token_symbol(chain_id, *token_address)) + .unwrap_or_else(|| format!("{:?}", token_address)); + + let value_u128: u128 = value.to_string().parse().unwrap_or(0); + let (amount_str, _) = registry + .and_then(|r| r.format_token_amount(chain_id, *token_address, value_u128)) + .unwrap_or_else(|| (value.to_string(), token_symbol.clone())); + + // Check if value is unlimited + let is_unlimited = value_u128 == u128::MAX + || value.to_string() + == "115792089237316195423570985008687907853269984665640564039457584007913129639935"; + + let display_amount = if is_unlimited { + "Unlimited".to_string() + } else { + amount_str.clone() + }; + + let deadline_str = if deadline == U256::MAX { + "No expiry".to_string() + } else { + let deadline_u64: u64 = deadline.to_string().parse().unwrap_or(0); + let dt = chrono::Utc.timestamp_opt(deadline_u64 as i64, 0).unwrap(); + dt.format("%Y-%m-%d %H:%M UTC").to_string() + }; + + let summary = format!( + "Permit {} {} to {:?} (expires: {})", + display_amount, token_symbol, spender, deadline_str + ); + + // Create detailed parameter fields for debugging + let param_fields = vec![ + AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: format!("{:?}", token_address), + label: "Token".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: format!("{} ({:?})", token_symbol, token_address), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: format!("{:?}", owner), + label: "Owner".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: format!("{:?}", owner), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: format!("{:?}", spender), + label: "Spender".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: format!("{:?}", spender), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: value.to_string(), + label: "Value".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: if is_unlimited { + format!("{} (unlimited)", value) + } else { + format!("{} {} (raw: {})", amount_str, token_symbol, value) + }, + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: deadline.to_string(), + label: "Deadline".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: format!("{} ({})", deadline, deadline_str), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + ]; + + SignablePayloadField::PreviewLayout { + common: SignablePayloadFieldCommon { + fallback_text: summary.clone(), + label: "Permit".to_string(), + }, + preview_layout: visualsign::SignablePayloadFieldPreviewLayout { + title: Some(visualsign::SignablePayloadFieldTextV2 { + text: "ERC-2612 Permit".to_string(), + }), + subtitle: Some(visualsign::SignablePayloadFieldTextV2 { text: summary }), + condensed: None, + expanded: Some(visualsign::SignablePayloadFieldListLayout { + fields: param_fields, + }), + }, + } + } + + /// Decodes erc20TransferFrom operation + fn decode_erc20_transfer_from( + bytes: &[u8], + chain_id: u64, + registry: Option<&ContractRegistry>, + ) -> SignablePayloadField { + let params = match Erc20TransferFromParams::abi_decode(bytes) { + Ok(p) => p, + Err(_) => { + return SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: format!("ERC20 Transfer From: 0x{}", hex::encode(bytes)), + label: "ERC20 Transfer From".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: "Failed to decode parameters".to_string(), + }, + }; + } + }; + + let token_symbol = registry + .and_then(|r| r.get_token_symbol(chain_id, params.token)) + .unwrap_or_else(|| format!("{:?}", params.token)); + + let amount_u128: u128 = params.amount.to_string().parse().unwrap_or(0); + let (amount_str, _) = registry + .and_then(|r| r.format_token_amount(chain_id, params.token, amount_u128)) + .unwrap_or_else(|| (params.amount.to_string(), token_symbol.clone())); + + let summary = format!( + "Transfer {} {} from {:?}", + amount_str, token_symbol, params.from + ); + + // Create detailed parameter fields for debugging + let param_fields = vec![ + AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: format!("{:?}", params.token), + label: "Token".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: format!("{} ({:?})", token_symbol, params.token), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: format!("{:?}", params.from), + label: "From".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: format!("{:?}", params.from), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: params.amount.to_string(), + label: "Amount".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: format!("{} {} (raw: {})", amount_str, token_symbol, params.amount), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + ]; + + SignablePayloadField::PreviewLayout { + common: SignablePayloadFieldCommon { + fallback_text: summary.clone(), + label: "Transfer From".to_string(), + }, + preview_layout: visualsign::SignablePayloadFieldPreviewLayout { + title: Some(visualsign::SignablePayloadFieldTextV2 { + text: "ERC20 Transfer From".to_string(), + }), + subtitle: Some(visualsign::SignablePayloadFieldTextV2 { text: summary }), + condensed: None, + expanded: Some(visualsign::SignablePayloadFieldListLayout { + fields: param_fields, + }), + }, + } + } + + /// Decodes erc4626Deposit operation + fn decode_erc4626_deposit( + bytes: &[u8], + chain_id: u64, + registry: Option<&ContractRegistry>, + ) -> SignablePayloadField { + let params = match Erc4626DepositParams::abi_decode(bytes) { + Ok(p) => p, + Err(_) => { + return SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: format!("ERC4626 Deposit: 0x{}", hex::encode(bytes)), + label: "ERC4626 Deposit".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: "Failed to decode parameters".to_string(), + }, + }; + } + }; + + // Try to get vault info from registry + let vault_symbol = registry.and_then(|r| r.get_token_symbol(chain_id, params.vault)); + + let assets_u128: u128 = params.assets.to_string().parse().unwrap_or(0); + let min_shares_u128: u128 = params.minShares.to_string().parse().unwrap_or(0); + + // Format the deposit summary + let vault_display = vault_symbol + .as_ref() + .map(|s| format!("{} vault", s)) + .unwrap_or_else(|| format!("vault {:?}", params.vault)); + + let summary = format!( + "Deposit {} assets into {} (min {} shares) for {:?}", + assets_u128, vault_display, min_shares_u128, params.receiver + ); + + // Format vault display for expanded view + let vault_text = if let Some(symbol) = &vault_symbol { + format!("{} ({:?})", symbol, params.vault) + } else { + format!("{:?}", params.vault) + }; + + // Create detailed parameter fields for debugging + let param_fields = vec![ + AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: format!("{:?}", params.vault), + label: "Vault".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { text: vault_text }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: params.assets.to_string(), + label: "Assets".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: params.assets.to_string(), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: params.minShares.to_string(), + label: "Min Shares".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: params.minShares.to_string(), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: format!("{:?}", params.receiver), + label: "Receiver".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: format!("{:?}", params.receiver), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + ]; + + SignablePayloadField::PreviewLayout { + common: SignablePayloadFieldCommon { + fallback_text: summary.clone(), + label: "Vault Deposit".to_string(), + }, + preview_layout: visualsign::SignablePayloadFieldPreviewLayout { + title: Some(visualsign::SignablePayloadFieldTextV2 { + text: "ERC4626 Vault Deposit".to_string(), + }), + subtitle: Some(visualsign::SignablePayloadFieldTextV2 { text: summary }), + condensed: None, + expanded: Some(visualsign::SignablePayloadFieldListLayout { + fields: param_fields, + }), + }, + } + } + + /// Creates a field for unknown calls + fn unknown_call_field(to: &Address, data: &Bytes) -> SignablePayloadField { + let selector = if data.len() >= 4 { + format!("0x{}", hex::encode(&data[0..4])) + } else { + "Unknown".to_string() + }; + + SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: format!("Call to {:?}", to), + label: "Unknown Call".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: format!("To: {:?}, Selector: {}", to, selector), + }, + } + } +} + +/// ContractVisualizer implementation for Morpho Bundler3 +pub struct BundlerContractVisualizer { + inner: BundlerVisualizer, +} + +impl BundlerContractVisualizer { + pub fn new() -> Self { + Self { + inner: BundlerVisualizer {}, + } + } +} + +impl Default for BundlerContractVisualizer { + fn default() -> Self { + Self::new() + } +} + +impl crate::visualizer::ContractVisualizer for BundlerContractVisualizer { + fn contract_type(&self) -> &str { + Bundler3Contract::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_multicall( + &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::*; + + #[test] + fn test_visualize_multicall_real_transaction() { + // Real Morpho transaction calldata + let input_hex = "374f435d00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000003000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000002200000000000000000000000000000000000000000000000000000000000000360000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb4800000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000e4d505accf000000000000000000000000078473fc814d2581c0e9b06efb2443ea503421cb0000000000000000000000004a6c312ec70e8747a587ee860a0353cd42be0ae000000000000000000000000000000000000000000000000000000000000f42400000000000000000000000000000000000000000000000000000000068f67d97000000000000000000000000000000000000000000000000000000000000001b5c10d948b0e33626f5f196df389c9f8b95c85a66065bc16c5a23a5ba9dde396941a237ed342773264d7a1694bcce90bf5538ae75eab39edd0ebcb1077442df9f000000000000000000000000000000000000000000000000000000000000000000000000000000004a6c312ec70e8747a587ee860a0353cd42be0ae000000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000064d96ca0b9000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb480000000000000000000000004a6c312ec70e8747a587ee860a0353cd42be0ae000000000000000000000000000000000000000000000000000000000000f4240000000000000000000000000000000000000000000000000000000000000000000000000000000004a6c312ec70e8747a587ee860a0353cd42be0ae000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000846ef5eeae000000000000000000000000beef01735c132ada46aa9aa4c54623caa92a64cb00000000000000000000000000000000000000000000000000000000000f42400000000000000000000000000000000000000000000000000003ece3bf77e9a9000000000000000000000000078473fc814d2581c0e9b06efb2443ea503421cb0000000000000000000000000000000000000000000000000000000068f661a72222da44"; + let input = hex::decode(input_hex).unwrap(); + + let registry = ContractRegistry::with_default_protocols(); + let result = BundlerVisualizer {}.visualize_multicall(&input, 1, Some(®istry)); + + assert!( + result.is_some(), + "Should successfully decode Morpho multicall" + ); + + let field = result.unwrap(); + if let SignablePayloadField::PreviewLayout { + common, + preview_layout, + } = field + { + assert!( + common.fallback_text.contains("3 operations"), + "Expected 3 operations, got: {}", + common.fallback_text + ); + + assert!( + preview_layout.expanded.is_some(), + "Expected expanded section" + ); + + if let Some(list_layout) = preview_layout.expanded { + assert_eq!(list_layout.fields.len(), 3, "Expected 3 decoded operations"); + + // Verify we have the expected operation types + println!("\n=== Decoded Morpho Transaction ==="); + for (i, field) in list_layout.fields.iter().enumerate() { + match &field.signable_payload_field { + SignablePayloadField::TextV2 { common, .. } => { + println!("Operation {}: {}", i + 1, common.label); + println!(" {}", common.fallback_text); + } + _ => {} + } + } + println!("=== End ===\n"); + } + } else { + panic!("Expected PreviewLayout"); + } + } + + #[test] + fn test_decode_permit() { + // Minimal permit parameters + let mut bytes = vec![0u8; 224]; + + // Owner (address at offset 12-32) + let owner = + Address::from_slice(&hex::decode("078473fc814d2581c0e9b06efb2443ea503421cb").unwrap()); + bytes[12..32].copy_from_slice(owner.as_slice()); + + // Spender + let spender = + Address::from_slice(&hex::decode("4a6c312ec70e8747a587ee860a0353cd42be0ae0").unwrap()); + bytes[44..64].copy_from_slice(spender.as_slice()); + + // Value (1000000 = 1 USDC with 6 decimals) + let value = U256::from(1000000u64); + bytes[64..96].copy_from_slice(&value.to_be_bytes::<32>()); + + // Deadline (some future timestamp) + let deadline = U256::from(1758288535u64); + bytes[96..128].copy_from_slice(&deadline.to_be_bytes::<32>()); + + let token_address: Address = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" + .parse() + .unwrap(); // USDC + + let registry = ContractRegistry::with_default_protocols(); + let result = BundlerVisualizer::decode_permit(&bytes, &token_address, 1, Some(®istry)); + + match result { + SignablePayloadField::PreviewLayout { + common, + preview_layout, + } => { + assert_eq!(common.label, "Permit"); + assert!(common.fallback_text.contains("USDC")); + + // Verify expanded view has parameters + assert!(preview_layout.expanded.is_some()); + if let Some(expanded) = preview_layout.expanded { + assert_eq!(expanded.fields.len(), 5, "Should have 5 parameter fields"); + } + } + _ => panic!("Expected PreviewLayout field"), + } + } +} diff --git a/src/chain_parsers/visualsign-ethereum/src/protocols/morpho/contracts/mod.rs b/src/chain_parsers/visualsign-ethereum/src/protocols/morpho/contracts/mod.rs new file mode 100644 index 00000000..899cdc31 --- /dev/null +++ b/src/chain_parsers/visualsign-ethereum/src/protocols/morpho/contracts/mod.rs @@ -0,0 +1,3 @@ +pub mod bundler; + +pub use bundler::{BundlerContractVisualizer, BundlerVisualizer}; diff --git a/src/chain_parsers/visualsign-ethereum/src/protocols/morpho/mod.rs b/src/chain_parsers/visualsign-ethereum/src/protocols/morpho/mod.rs new file mode 100644 index 00000000..809d8f18 --- /dev/null +++ b/src/chain_parsers/visualsign-ethereum/src/protocols/morpho/mod.rs @@ -0,0 +1,66 @@ +//! Morpho protocol implementation +//! +//! This module contains contract visualizers, configuration, and registration +//! logic for the Morpho lending protocol. +//! +//! Morpho is a decentralized lending protocol that optimizes interest rates +//! through peer-to-peer matching while maintaining liquidity pool fallbacks. + +pub mod config; +pub mod contracts; + +use crate::registry::ContractRegistry; +use crate::visualizer::EthereumVisualizerRegistryBuilder; + +pub use config::{Bundler3Contract, MorphoConfig}; +pub use contracts::{BundlerContractVisualizer, BundlerVisualizer}; + +/// Registers all Morpho 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 Bundler3 contract on all supported chains + MorphoConfig::register_contracts(contract_reg); + + // Register visualizers + visualizer_reg.register(Box::new(BundlerContractVisualizer::new())); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::registry::ContractType; + use alloy_primitives::Address; + + #[test] + fn test_register_morpho_contracts() { + let mut contract_reg = ContractRegistry::new(); + let mut visualizer_reg = EthereumVisualizerRegistryBuilder::new(); + + register(&mut contract_reg, &mut visualizer_reg); + + let bundler3_address: Address = "0x6566194141eefa99Af43Bb5Aa71460Ca2Dc90245" + .parse() + .unwrap(); + + // Verify Bundler3 is registered on all supported chains + for chain_id in [1, 10, 8453, 42161] { + let contract_type = contract_reg + .get_contract_type(chain_id, bundler3_address) + .expect(&format!( + "Bundler3 should be registered on chain {}", + chain_id + )); + assert_eq!(contract_type, Bundler3Contract::short_type_id()); + } + } +} diff --git a/src/chain_parsers/visualsign-ethereum/src/protocols/safe/config.rs b/src/chain_parsers/visualsign-ethereum/src/protocols/safe/config.rs new file mode 100644 index 00000000..16da5ecd --- /dev/null +++ b/src/chain_parsers/visualsign-ethereum/src/protocols/safe/config.rs @@ -0,0 +1,62 @@ +use crate::registry::ContractType; +use alloy_primitives::Address; + +/// Type marker for Safe wallet contracts +/// All Safe wallets (regardless of version or deployment address) share this type +pub struct SafeWallet; + +impl ContractType for SafeWallet { + fn short_type_id() -> &'static str { + "SafeWallet" + } +} + +pub struct SafeConfig; + +impl SafeConfig { + /// Safe v1.3.0 singleton address (used on most chains) + pub fn safe_v1_3_0_address() -> Address { + "0xd9Db270c1B5E3Bd161E8c8503c55cEABeE709552" + .parse() + .expect("Valid address") + } + + /// Safe v1.4.1 singleton address + pub fn safe_v1_4_1_address() -> Address { + "0x41675C099F32341bf84BFc5382aF534df5C7461a" + .parse() + .expect("Valid address") + } + + /// Chains where Safe is deployed + pub fn safe_chains() -> &'static [u64] { + &[ + 1, // Ethereum + 10, // Optimism + 137, // Polygon + 8453, // Base + 42161, // Arbitrum + ] + } + + /// Register Safe singleton contracts + pub fn register_contracts(registry: &mut crate::registry::ContractRegistry) { + let v1_3_0 = Self::safe_v1_3_0_address(); + let v1_4_1 = Self::safe_v1_4_1_address(); + + for &chain_id in Self::safe_chains() { + // Register both versions as SafeWallet type + registry.register_contract_typed::(chain_id, vec![v1_3_0]); + registry.register_contract_typed::(chain_id, vec![v1_4_1]); + } + } + + /// Register a specific Safe instance (for dynamically deployed wallets) + pub fn register_safe_instance( + registry: &mut crate::registry::ContractRegistry, + chain_id: u64, + address: Address, + ) { + registry.register_contract_typed::(chain_id, vec![address]); + } +} diff --git a/src/chain_parsers/visualsign-ethereum/src/protocols/safe/contracts/mod.rs b/src/chain_parsers/visualsign-ethereum/src/protocols/safe/contracts/mod.rs new file mode 100644 index 00000000..3b94f699 --- /dev/null +++ b/src/chain_parsers/visualsign-ethereum/src/protocols/safe/contracts/mod.rs @@ -0,0 +1,3 @@ +pub mod safe; + +pub use safe::SafeWalletVisualizer; diff --git a/src/chain_parsers/visualsign-ethereum/src/protocols/safe/contracts/safe.rs b/src/chain_parsers/visualsign-ethereum/src/protocols/safe/contracts/safe.rs new file mode 100644 index 00000000..0ed54e84 --- /dev/null +++ b/src/chain_parsers/visualsign-ethereum/src/protocols/safe/contracts/safe.rs @@ -0,0 +1,282 @@ +use super::super::config::SafeWallet; +use crate::context::VisualizerContext; +use crate::fmt::format_ether; +use crate::registry::ContractType; +use crate::visualizer::ContractVisualizer; +use alloy_primitives::{Address, U256}; +use alloy_sol_types::{SolCall, sol}; +use visualsign::AnnotatedPayloadField; +use visualsign::field_builders::{ + create_address_field, create_amount_field, create_number_field, create_preview_layout, + create_text_field, +}; +use visualsign::vsptrait::VisualSignError; + +// Define Safe wallet operations using sol! macro for type-safe ABI decoding +// The macro automatically generates SolCall trait implementations with SELECTOR constants +sol! { + interface IGnosisSafe { + function execTransaction( + address to, + uint256 value, + bytes calldata data, + uint8 operation, + uint256 safeTxGas, + uint256 baseGas, + uint256 gasPrice, + address gasToken, + address refundReceiver, + bytes calldata signatures + ) external payable returns (bool success); + + function addOwnerWithThreshold(address owner, uint256 _threshold) external; + function removeOwner(address prevOwner, address owner, uint256 _threshold) external; + function swapOwner(address prevOwner, address oldOwner, address newOwner) external; + function changeThreshold(uint256 _threshold) external; + } +} + + +// Safe wallet visualizer that uses auto-visualization +pub struct SafeWalletVisualizer; + +impl SafeWalletVisualizer { + pub fn new() -> Self { + SafeWalletVisualizer + } + + fn decode_add_owner(&self, params_bytes: &[u8]) -> Option { + let call = IGnosisSafe::addOwnerWithThresholdCall::abi_decode(params_bytes).ok()?; + let fallback = format!( + "Add owner {:?} with threshold {}", + call.owner, call._threshold + ); + + let fields = vec![ + create_address_field( + "New Owner", + &format!("{:?}", call.owner), + None, + None, + None, + None, + ) + .ok()?, + create_number_field( + "New Threshold", + &call._threshold.to_string(), + "signatures required", + ) + .ok()?, + ]; + + Some(create_preview_layout("Safe: Add Owner", fallback, fields)) + } + + fn decode_remove_owner(&self, params_bytes: &[u8]) -> Option { + let call = IGnosisSafe::removeOwnerCall::abi_decode(params_bytes).ok()?; + let fallback = format!( + "Remove owner {:?} with new threshold {}", + call.owner, call._threshold + ); + + let fields = vec![ + create_address_field( + "Previous Owner", + &format!("{:?}", call.prevOwner), + None, + Some("Required for linked list ordering"), + None, + None, + ) + .ok()?, + create_address_field( + "Owner to Remove", + &format!("{:?}", call.owner), + None, + None, + None, + Some("Will be removed"), + ) + .ok()?, + create_number_field( + "New Threshold", + &call._threshold.to_string(), + "signatures required", + ) + .ok()?, + ]; + + Some(create_preview_layout( + "Safe: Remove Owner", + fallback, + fields, + )) + } + + fn decode_swap_owner(&self, params_bytes: &[u8]) -> Option { + let call = IGnosisSafe::swapOwnerCall::abi_decode(params_bytes).ok()?; + let fallback = format!("Swap owner {:?} with {:?}", call.oldOwner, call.newOwner); + + let fields = vec![ + create_address_field( + "Previous Owner", + &format!("{:?}", call.prevOwner), + None, + Some("Required for linked list ordering"), + None, + None, + ) + .ok()?, + create_address_field( + "Old Owner", + &format!("{:?}", call.oldOwner), + None, + None, + None, + Some("Will be removed"), + ) + .ok()?, + create_address_field( + "New Owner", + &format!("{:?}", call.newOwner), + None, + None, + None, + Some("Will be added"), + ) + .ok()?, + ]; + + Some(create_preview_layout("Safe: Swap Owner", fallback, fields)) + } + + fn decode_change_threshold(&self, params_bytes: &[u8]) -> Option { + let call = IGnosisSafe::changeThresholdCall::abi_decode(params_bytes).ok()?; + let fallback = format!("Change threshold to {}", call._threshold); + + let mut fields = vec![ + create_number_field( + "New Threshold", + &call._threshold.to_string(), + "signatures required", + ) + .ok()?, + ]; + + if call._threshold == U256::from(1) { + fields.push( + create_text_field( + "Warning", + "Setting threshold to 1 allows single signature control", + ) + .ok()?, + ); + } + + Some(create_preview_layout( + "Safe: Change Threshold", + fallback, + fields, + )) + } + + fn decode_exec_transaction(&self, params_bytes: &[u8]) -> Option { + let call = IGnosisSafe::execTransactionCall::abi_decode(params_bytes).ok()?; + + let mut fields = vec![ + create_address_field("Target", &format!("{:?}", call.to), None, None, None, None) + .ok()?, + ]; + + if call.value > U256::ZERO { + let value_str = format_ether(call.value); + fields.push(create_amount_field("Value", &value_str, "ETH").ok()?); + } + + let operation_text = match call.operation { + 0 => "Call", + 1 => "DelegateCall", + _ => "Unknown", + }; + fields.push(create_text_field("Operation Type", operation_text).ok()?); + + if call.gasToken != Address::ZERO { + fields.push(create_text_field("Gas Token", &format!("{:?}", call.gasToken)).ok()?); + } + + if !call.signatures.is_empty() { + let signature_count = call.signatures.len() / 65; + fields.push( + create_number_field("Signatures", &signature_count.to_string(), "provided").ok()?, + ); + } + + if !call.data.is_empty() { + fields.push(create_text_field("Data", &format!("{} bytes", call.data.len())).ok()?); + } + + let fallback = if call.value > U256::ZERO && call.data.is_empty() { + format!( + "Send {} ETH to {:?}", + format_ether(call.value), + call.to + ) + } else if call.value > U256::ZERO { + format!( + "Execute transaction with {} ETH to {:?}", + format_ether(call.value), + call.to + ) + } else { + format!("Execute transaction to {:?}", call.to) + }; + + Some(create_preview_layout( + "Safe: Execute Transaction", + fallback, + fields, + )) + } +} + +// Function selectors are automatically generated by the sol! macro +// They're derived as the first 4 bytes of keccak256(function_signature) +// We extract them from the SolCall trait implementations + +impl ContractVisualizer for SafeWalletVisualizer { + fn contract_type(&self) -> &str { + SafeWallet::short_type_id() + } + + fn visualize( + &self, + context: &VisualizerContext, + ) -> Result>, VisualSignError> { + if context.calldata.len() < 4 { + return Ok(None); + } + + let selector: [u8; 4] = match context.calldata[0..4].try_into() { + Ok(s) => s, + Err(_) => return Ok(None), + }; + let params_bytes = &context.calldata[4..]; + + // Match against function selectors from sol! macro generated SolCall implementations + let field = match selector { + IGnosisSafe::addOwnerWithThresholdCall::SELECTOR => self.decode_add_owner(params_bytes), + IGnosisSafe::removeOwnerCall::SELECTOR => self.decode_remove_owner(params_bytes), + IGnosisSafe::swapOwnerCall::SELECTOR => self.decode_swap_owner(params_bytes), + IGnosisSafe::changeThresholdCall::SELECTOR => { + self.decode_change_threshold(params_bytes) + } + IGnosisSafe::execTransactionCall::SELECTOR => { + self.decode_exec_transaction(params_bytes) + } + _ => None, + }; + + Ok(field.map(|f| vec![f])) + } +} diff --git a/src/chain_parsers/visualsign-ethereum/src/protocols/safe/mod.rs b/src/chain_parsers/visualsign-ethereum/src/protocols/safe/mod.rs new file mode 100644 index 00000000..d65d6d45 --- /dev/null +++ b/src/chain_parsers/visualsign-ethereum/src/protocols/safe/mod.rs @@ -0,0 +1,19 @@ +pub mod config; +pub mod contracts; + +use crate::registry::ContractRegistry; +use crate::visualizer::EthereumVisualizerRegistryBuilder; +use config::SafeConfig; +use contracts::SafeWalletVisualizer; + +/// Register Safe protocol contracts and visualizers +pub fn register( + contract_reg: &mut ContractRegistry, + visualizer_reg: &mut EthereumVisualizerRegistryBuilder, +) { + // Register known Safe deployment addresses + SafeConfig::register_contracts(contract_reg); + + // Register the visualizer for all Safe wallets + visualizer_reg.register(Box::new(SafeWalletVisualizer::new())); +} diff --git a/src/chain_parsers/visualsign-ethereum/src/protocols/uniswap/IMPLEMENTATION_STATUS.md b/src/chain_parsers/visualsign-ethereum/src/protocols/uniswap/IMPLEMENTATION_STATUS.md new file mode 100644 index 00000000..fd0b7114 --- /dev/null +++ b/src/chain_parsers/visualsign-ethereum/src/protocols/uniswap/IMPLEMENTATION_STATUS.md @@ -0,0 +1,393 @@ +# Uniswap Universal Router - Implementation Status + +## Overview + +This document outlines the implementation status of Uniswap Universal Router command visualization. Based on analysis of the Dispatcher.sol contract (v67553d8b067249dd7841d9d1b0eb2997b19d4bf9), we catalog: +- ✅ Implemented commands +- ⏳ Commands needing implementation +- 📋 Known special cases and encoding requirements + +## Reference +- **Contract**: https://github.com/Uniswap/universal-router/blob/67553d8b067249dd7841d9d1b0eb2997b19d4bf9/contracts/base/Dispatcher.sol +- **Configuration**: src/protocols/uniswap/config.rs +- **Implementation**: src/protocols/uniswap/contracts/universal_router.rs +- **Tests**: All tests passing (97/97 ✓) + +--- + +## Implemented Commands (✅) + +### 0x00 - V3_SWAP_EXACT_IN +**Status**: ✅ Fully Implemented +**Parameters**: `(address recipient, uint256 amountIn, uint256 amountOutMin, bytes path, bool payerIsUser)` +**Visualization**: Shows swap route with amounts and payer info +**Special Case**: Path is a packed bytes structure (custom V3 pool encoding) + +### 0x01 - V3_SWAP_EXACT_OUT +**Status**: ✅ Fully Implemented +**Parameters**: `(address recipient, uint256 amountOut, uint256 amountInMax, bytes path, bool payerIsUser)` +**Visualization**: Similar to V3_SWAP_EXACT_IN but inverted amounts +**Special Case**: Same path encoding as V3_SWAP_EXACT_IN + +### 0x02 - PERMIT2_TRANSFER_FROM +**Status**: ✅ Fully Implemented +**Parameters**: `(address token, address to, uint160 amount)` +**Visualization**: "Transfer {amount} {symbol} from permit2" +**Notes**: Simple 3-parameter operation, straightforward decoding + +### 0x04 - SWEEP +**Status**: ✅ Fully Implemented +**Parameters**: `(address token, address recipient, uint160 amountMin)` +**Visualization**: Shows token sweep to recipient address +**Special Case**: Uses `amountMin` (uint160) instead of full uint256 + +### 0x05 - TRANSFER +**Status**: ✅ Fully Implemented +**Parameters**: `(address token, address recipient, uint256 value)` +**Visualization**: Direct token transfer with amount +**Notes**: Simple payment operation + +### 0x06 - PAY_PORTION +**Status**: ✅ Fully Implemented +**Parameters**: `(address token, address recipient, uint256 bips)` +**Visualization**: Shows percentage (bips = basis points, 1 bip = 0.01%) +**Special Case**: BIPS conversion (divide by 10000 for percentage) + +### 0x0A - PERMIT2_PERMIT +**Status**: ✅ Fully Implemented & FIXED (Correct byte offsets discovered & verified) +**Parameters**: `(PermitSingle permitSingle, bytes signature)` + - `PermitSingle` struct contains: + - `PermitDetails details` (4 slots = 128 bytes): + - `address token` (bytes 12-31, Slot 0) + - `uint160 amount` (bytes 44-63, Slot 1) + - `uint48 expiration` (bytes 90-95, Slot 2 - right-aligned at end) + - `uint48 nonce` (bytes 96-101, Slot 3) + - `address spender` (bytes 140-159, Slot 4 - left-padded) + - `uint256 sigDeadline` (bytes 160-191, Slot 5) +**Visualization**: Expanded layout showing Token, Amount, Spender, Expires, Sig Deadline + - Condensed: Shows "Unlimited Amount" when amount = 0xfff... (max uint160) + - Expanded: Shows exact numeric value for transparency +**Special Case**: Uses nested structs; PermitSingle occupies exactly 6 slots (192 bytes) +**Encoding Note**: Assembly extraction at `inputs.offset` with `inputs.toBytes(6)` for first 6 slots +**Fix Details** (this PR): + - Discovered correct EVM slot byte layout through transaction analysis + - Implemented custom Solidity struct decoder for non-standard encoding + - Fixed offsets for expiration (was reading wrong bytes), spender (was showing zeros) + - Added "Unlimited Amount" display for max approvals + - Comprehensive test coverage: 6 new tests covering decoder, visualization, integration, and edge cases +**Verification**: All values now correctly match Tenderly traces ✓ + - Token: 0x72b658Bd674f9c2B4954682f517c17D14476e417 ✓ + - Amount: 1461501637330902918203684832716283019655932542975 (0xfff...) ✓ + - Spender: 0x3fc91a3afd70395cd496c647d5a6cc9d4b2b7fad ✓ + - Expires: 2025-12-15 18:44 UTC (1765824281) ✓ + - Sig Deadline: 2025-11-15 19:14 UTC (1763234081) ✓ + +### 0x0B - WRAP_ETH +**Status**: ✅ Fully Implemented +**Parameters**: `(address recipient, uint256 amount)` +**Visualization**: "Wrap {amount} ETH to WETH" +**Notes**: Simple WETH wrapping operation + +### 0x0C - UNWRAP_WETH +**Status**: ✅ Fully Implemented +**Parameters**: `(address recipient, uint256 amountMin)` +**Visualization**: "Unwrap {amount} WETH to ETH" +**Special Case**: Uses minimum amount instead of exact amount + +--- + +## Commands Requiring Implementation (⏳) + +### 0x03 - PERMIT2_PERMIT_BATCH +**Status**: ⏳ Not Yet Implemented +**Parameters**: `(IAllowanceTransfer.PermitBatch permitBatch, bytes data)` +**PermitBatch Structure**: +```solidity +struct PermitBatch { + TokenPermissions[] tokens; // Dynamic array of token permissions + address spender; + uint256 deadline; +} + +struct TokenPermissions { + address token; + uint160 amount; +} +``` +**Implementation Challenge**: +- Dynamic array decoding (unlike PermitSingle which is fixed-size) +- Variable number of token permissions +**Recommended Visualization**: +- Title: "Permit2 Batch Permit" +- Show spender, deadline +- Expanded list of token permissions + +### 0x08 - V2_SWAP_EXACT_IN +**Status**: ⏳ Not Yet Implemented +**Parameters**: `(address recipient, uint256 amountIn, uint256 amountOutMin, address[] path, bool payerIsUser)` +**Implementation Challenge**: +- Dynamic array of addresses (swap path) +- Need to decode array length and extract addresses +**Decoding Pattern** (from Solidity): +```solidity +path = inputs.toAddressArray(); +``` +**Recommended Visualization**: +- Show start/end token +- Display full path with arrows (token1 → token2 → token3) +- Show amounts and payer + +### 0x09 - V2_SWAP_EXACT_OUT +**Status**: ⏳ Not Yet Implemented +**Parameters**: `(address recipient, uint256 amountOut, uint256 amountInMax, address[] path, bool payerIsUser)` +**Implementation Challenge**: Same as V2_SWAP_EXACT_IN +**Difference**: Output amount fixed, input is maximum + +### 0x0D - PERMIT2_TRANSFER_FROM_BATCH +**Status**: ⏳ Not Yet Implemented +**Parameters**: `(IAllowanceTransfer.AllowanceTransferDetails[] batchDetails)` +**Structure**: +```solidity +struct AllowanceTransferDetails { + address from; + address to; + uint160 amount; + address token; +} +``` +**Implementation Challenge**: +- Dynamic array of structs +- Variable number of transfers +**Recommended Visualization**: +- Title: "Permit2 Batch Transfer" +- Expanded list showing each transfer (from → to, amount, token) + +### 0x0E - BALANCE_CHECK_ERC20 +**Status**: ⏳ Not Yet Implemented +**Parameters**: `(address owner, address token, uint256 minBalance)` +**Special Case - CRITICAL**: +- Unlike other commands that revert on failure, this returns encoded error +- Returns `(bool success, bytes memory output)` where: + - On success: `output` is empty + - On failure: `output` contains error selector `0x7f7a0d94` (BalanceCheckFailed) +- Should NOT be visualized as a normal command execution +**Recommended Visualization**: +- "Balance Check: {token} balance >= {minBalance}" +- Show as verification step, not state-changing operation +**Implementation Note**: May need special handling in the UI layer + +--- + +## V4-Specific Commands (⏳) + +### 0x10 - V4_SWAP +**Status**: ⏳ Not Yet Implemented +**Parameters**: Raw calldata passed to `V4SwapRouter._executeActions()` +**Implementation Challenge**: +- Entirely custom V4 swap encoding +- Requires understanding V4 hook system +- Complex nested parameters +**Placeholder**: Currently shows raw hex + +### 0x13 - V4_INITIALIZE_POOL +**Status**: ⏳ Not Yet Implemented +**Parameters**: `(PoolKey poolKey, uint160 sqrtPriceX96)` +**PoolKey Structure**: +```solidity +struct PoolKey { + Currency currency0; // 160 bits + Currency currency1; // 160 bits + uint24 fee; // 24 bits + int24 tickSpacing; // 24 bits + IHooks hooks; // 160 bits + bytes32 salt; // 256 bits (optional) +} +``` +**Implementation Challenge**: Complex struct with custom types (Currency) +**Recommended Visualization**: +- "Initialize V4 Pool" +- Show: currency0 ↔ currency1, fee, sqrtPriceX96 +- Display implied starting price + +--- + +## Position Manager Commands (⏳) + +### 0x11 - V3_POSITION_MANAGER_PERMIT +**Status**: ⏳ Partial - Shows raw hex +**Type**: Raw call forwarding +**Implementation Challenge**: +- Requires parsing V3 PositionManager ABI +- Multiple function signatures possible +- Recommendation: Forward to V3 PositionManager visualizer if available + +### 0x12 - V3_POSITION_MANAGER_CALL +**Status**: ⏳ Partial - Shows raw hex +**Type**: Raw call forwarding +**Implementation Challenge**: Same as 0x11 +**Special Case**: Calldata passed directly to PositionManager + +### 0x14 - V4_POSITION_MANAGER_CALL +**Status**: ⏳ Partial - Shows raw hex +**Type**: Raw call with ETH value forwarding +**Special Case**: Contract balance (from previous WETH unwrap) sent to PositionManager +**Implementation Challenge**: +- Need to track ETH balance state across command sequence +- Complex for transaction analysis + +--- + +## Sub-execution Commands + +### 0x21 - EXECUTE_SUB_PLAN +**Status**: ⏳ Not Yet Implemented +**Parameters**: `(bytes commands, bytes[] inputs)` +**Type**: Recursive command execution +**Implementation Challenge**: +- Requires recursive parsing of commands/inputs +- May have arbitrary nesting depth +- Visualization challenge: How to represent nested command trees +**Recommendation for UI**: +- Collapsible tree view +- Show nesting level +- Display number of sub-commands + +--- + +## Bridge Commands + +### 0x40 - ACROSS_V4_DEPOSIT_V3 +**Status**: ⏳ Not Yet Implemented (Rare/Special) +**Type**: Cross-protocol bridge deposit +**Implementation Challenge**: +- Highly specialized cross-chain operation +- May require chain-specific context +- Rarely seen in typical routing + +--- + +## Implementation Priority Matrix + +### Tier 1 (High Priority - Common in Real Transactions) +- [ ] V2_SWAP_EXACT_IN (0x08) - Very common for liquidity pairs +- [ ] V2_SWAP_EXACT_OUT (0x09) - Common complement to 0x08 +- [ ] PERMIT2_TRANSFER_FROM_BATCH (0x0D) - Multi-token operations +- [ ] EXECUTE_SUB_PLAN (0x21) - Complex routes often nested + +### Tier 2 (Medium Priority - V4 Support) +- [ ] V4_SWAP (0x10) +- [ ] V4_INITIALIZE_POOL (0x13) +- [ ] V4_POSITION_MANAGER_CALL (0x14) + +### Tier 3 (Lower Priority - Specialized Cases) +- [ ] PERMIT2_PERMIT_BATCH (0x03) - Less common than single permits +- [ ] BALANCE_CHECK_ERC20 (0x0E) - Safety check, not core operation +- [ ] V3_POSITION_MANAGER_PERMIT (0x11) - Position management +- [ ] V3_POSITION_MANAGER_CALL (0x12) - Position management +- [ ] ACROSS_V4_DEPOSIT_V3 (0x40) - Bridge operations (rare) + +--- + +## Key Technical Findings + +### Assembly-Based Encoding +The Solidity contract uses low-level assembly for calldata decoding (not standard ABI): +- `inputs.offset` - Direct pointer to calldata memory +- `inputs.toBytes(N)` - Extract N slots starting from offset +- `inputs.toAddressArray()` - Extract address array with length prefix + +### Recipient Mapping +All recipient addresses are processed through a `map()` function: +- Constants: `MSG_SENDER` (0) → msg.sender +- Constants: `ADDRESS_THIS` (1) → address(this) +- Normal addresses passed through unchanged + +### Payer Determination +Commands with `payerIsUser` boolean flag: +- `true` → msg.sender pays (user initiated) +- `false` → contract pays (router provides liquidity) + +### Special Timestamp Formatting +- Timestamps should show as ISO format (YYYY-MM-DD HH:MM UTC) +- `type(uint48).max` or `type(uint256).max` should display as "never" + +--- + +## Testing Strategy + +### Current Test Coverage +- Basic parameter validation (empty/short inputs) +- Real transaction test: Uniswap swap with deadline and multiple commands +- Registry token symbol resolution + +### Recommended Additional Tests +For each new command implementation: +1. Empty/invalid input handling +2. Boundary conditions (max/min values) +3. Real-world transaction example +4. Token symbol resolution via registry +5. Timestamp formatting edge cases + +### Known Test Transaction Sources +- Tenderly.co traces for reference +- Etherscan decoded transactions for validation +- Uniswap Router Web Interface transaction logs + +--- + +## Type System Notes + +### Solidity uint160 (20 bytes) +- Represents both addresses and amounts +- When used for amounts: max value is ~1.46e48 (not practical for most tokens) +- Primarily used for permit2 approval amounts + +### Dynamic Arrays in ABI Encoding +- Prefixed with 32-byte offset (relative to struct start) +- Followed by 32-byte length +- Followed by concatenated elements +- Example: `bytes path` encoding is `offset || length || data` + +### Nested Struct Encoding +- Structs encoded inline (no offsets) when part of fixed-size encoding +- Dynamic types inside structs require offsets +- PermitSingle (fixed 6 slots) encoded inline, but requires special handling for assembly extraction + +--- + +## Documentation References + +### Useful Links +- [Uniswap V3 Swap Router Docs](https://docs.uniswap.org/contracts/v3/technical-reference#SwapRouter02) +- [Uniswap V4 Documentation](https://docs.uniswap.org/contracts/v4/overview) +- [Permit2 Specification](https://github.com/Uniswap/permit2) +- [Universal Router Deployment Addresses](https://github.com/Uniswap/universal-router/tree/main/deploy-addresses) + +--- + +## Next Steps + +1. **✅ COMPLETED**: PERMIT2_PERMIT (0x0A) - Full byte offset fix with "Unlimited Amount" display +2. **Tier 1**: Implement V2 swaps (0x08, 0x09) - Very common in real transactions +3. **Tier 1**: Implement batch operations (0x03, 0x0D) - Multi-token operations +4. **Tier 2**: Implement V4 commands (0x10, 0x13) - V4 support +5. **Tier 2**: Sub-plan and specialized commands (0x21, 0x11-0x12, 0x14) + +--- + +## Completed Implementation Summary + +### Permit2 Permit (0x0A) - Full Fix ✅ (This PR) +**Problem Solved**: Spender address showing all zeros, timestamps showing epoch 0 +**Root Cause**: Incorrect byte offsets due to misunderstanding of Solidity struct packing and EVM slot alignment +**Solution**: +- Analyzed actual transaction bytes to discover correct layout +- Implemented custom decoder bypassing standard ABI +- Added dual-mode display: "Unlimited Amount" (condensed) + exact value (expanded) +**Quality**: 6 new tests, all 97 tests passing, verified against Tenderly traces + +--- + +*Document Version 2.0* +*Last Updated: 2024-11-16* +*Status: PERMIT2_PERMIT fully implemented and fixed; other commands pending* diff --git a/src/chain_parsers/visualsign-ethereum/src/protocols/uniswap/config.rs b/src/chain_parsers/visualsign-ethereum/src/protocols/uniswap/config.rs new file mode 100644 index 00000000..af38ef2a --- /dev/null +++ b/src/chain_parsers/visualsign-ethereum/src/protocols/uniswap/config.rs @@ -0,0 +1,282 @@ +//! Uniswap protocol configuration +//! +//! Contains contract addresses, chain deployments, and protocol metadata. +//! +//! # Deployment Addresses +//! +//! Official Uniswap Universal Router deployments are documented at: +//! +//! +//! Each network has a JSON file (e.g., mainnet.json, optimism.json) containing: +//! - `UniversalRouterV1`: Legacy V1 router +//! - `UniversalRouterV1_2_V2Support`: V1.2 with V2 support (0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD) +//! - `UniversalRouterV2`: Latest V2 router +//! +//! Currently, only V1.2 is implemented. Future versions should be added as separate +//! contract type markers below. + +use crate::registry::{ContractRegistry, ContractType}; +use crate::token_metadata::{ErcStandard, TokenMetadata}; +use alloy_primitives::Address; + +/// Contract type marker for Uniswap Universal Router V1.2 +/// +/// This is the V1.2 router with V2 support, deployed at 0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD +/// across multiple chains (Mainnet, Optimism, Polygon, Base, Arbitrum). +/// +/// Reference: +#[derive(Debug, Clone, Copy)] +pub struct UniswapUniversalRouter; + +impl ContractType for UniswapUniversalRouter {} + +/// Contract type marker for Permit2 +/// +/// Permit2 is a token approval contract that unifies the approval experience across all applications. +/// It is deployed at the same address (0x000000000022D473030F116dDEE9F6B43aC78BA3) on all chains. +/// +/// Reference: +#[derive(Debug, Clone, Copy)] +pub struct Permit2Contract; + +impl ContractType for Permit2Contract {} + +// TODO: Add contract type markers for other Universal Router versions +// +// /// Universal Router V1 (legacy) - 0xEf1c6E67703c7BD7107eed8303Fbe6EC2554BF6B +// #[derive(Debug, Clone, Copy)] +// pub struct UniswapUniversalRouterV1; +// impl ContractType for UniswapUniversalRouterV1 {} +// +// /// Universal Router V2 (latest) - 0x66a9893cc07d91d95644aedd05d03f95e1dba8af +// #[derive(Debug, Clone, Copy)] +// pub struct UniswapUniversalRouterV2; +// impl ContractType for UniswapUniversalRouterV2 {} + +// TODO: Add V4 PoolManager contract type +// +// V4 requires the PoolManager contract for liquidity pool management. +// Deployments: +// +// /// Uniswap V4 PoolManager +// #[derive(Debug, Clone, Copy)] +// pub struct UniswapV4PoolManager; +// impl ContractType for UniswapV4PoolManager {} + +/// Uniswap protocol configuration +pub struct UniswapConfig; + +impl UniswapConfig { + /// Returns the Universal Router V1.2 address + /// + /// This is the `UniversalRouterV1_2_V2Support` address from Uniswap's deployment files. + /// It is deployed at the same address across multiple chains. + /// + /// Source: + pub fn universal_router_address() -> Address { + "0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD" + .parse() + .expect("Valid Universal Router address") + } + + /// Returns the chain IDs where Universal Router V1.2 is deployed + /// + /// Supported chains: + /// - 1 = Ethereum Mainnet + /// - 10 = Optimism + /// - 137 = Polygon + /// - 8453 = Base + /// - 42161 = Arbitrum One + /// + /// Note: Other chains may be supported. See deployment files: + /// + pub fn universal_router_chains() -> &'static [u64] { + &[1, 10, 137, 8453, 42161] + } + + /// Returns the Permit2 contract address + /// + /// Permit2 is deployed at the same address across all chains. + /// + /// Source: + pub fn permit2_address() -> Address { + crate::utils::address_utils::WellKnownAddresses::permit2() + } + + // TODO: Add methods for other Universal Router versions + // + // Source: https://github.com/Uniswap/universal-router/tree/main/deploy-addresses + // + // pub fn universal_router_v1_address() -> Address { + // "0xEf1c6E67703c7BD7107eed8303Fbe6EC2554BF6B".parse().unwrap() + // } + // pub fn universal_router_v1_chains() -> &'static [u64] { ... } + // + // pub fn universal_router_v2_address() -> Address { + // "0x66a9893cc07d91d95644aedd05d03f95e1dba8af".parse().unwrap() + // } + // pub fn universal_router_v2_chains() -> &'static [u64] { ... } + + // TODO: Add methods for V4 PoolManager + // + // Source: https://docs.uniswap.org/contracts/v4/deployments + // + // pub fn v4_pool_manager_address() -> Address { ... } + // pub fn v4_pool_manager_chains() -> &'static [u64] { ... } + + /// Returns the WETH address for a given chain + /// + /// WETH (Wrapped ETH) addresses vary by chain. This method returns the canonical + /// WETH address for supported chains. + pub fn weth_address(chain_id: u64) -> Option
{ + let addr_str = match chain_id { + 1 => "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2", // Ethereum Mainnet + 10 => "0x4200000000000000000000000000000000000006", // Optimism + 137 => "0x7ceb23fd6bc0add59e62ac25578270cff1b9f619", // Polygon + 8453 => "0x4200000000000000000000000000000000000006", // Base + 42161 => "0x82af49447d8a07e3bd95bd0d56f35241523fbab1", // Arbitrum + _ => return None, + }; + addr_str.parse().ok() + } + + /// Registers common tokens used in Uniswap transactions + /// + /// This registers tokens like WETH across multiple chains so they can be + /// resolved by symbol during transaction visualization. + pub fn register_common_tokens(registry: &mut ContractRegistry) { + // WETH on Ethereum Mainnet (WETH9 contract) + registry.register_token( + 1, + TokenMetadata { + symbol: "WETH".to_string(), + name: "WETH9".to_string(), + erc_standard: ErcStandard::Erc20, + contract_address: "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2".to_string(), + decimals: 18, + }, + ); + + // WETH on Optimism + registry.register_token( + 10, + TokenMetadata { + symbol: "WETH".to_string(), + name: "WETH9".to_string(), + erc_standard: ErcStandard::Erc20, + contract_address: "0x4200000000000000000000000000000000000006".to_string(), + decimals: 18, + }, + ); + + // WETH on Polygon + registry.register_token( + 137, + TokenMetadata { + symbol: "WETH".to_string(), + name: "WETH9".to_string(), + erc_standard: ErcStandard::Erc20, + contract_address: "0x7ceb23fd6bc0add59e62ac25578270cff1b9f619".to_string(), + decimals: 18, + }, + ); + + // WETH on Base + registry.register_token( + 8453, + TokenMetadata { + symbol: "WETH".to_string(), + name: "WETH9".to_string(), + erc_standard: ErcStandard::Erc20, + contract_address: "0x4200000000000000000000000000000000000006".to_string(), + decimals: 18, + }, + ); + + // WETH on Arbitrum + registry.register_token( + 42161, + TokenMetadata { + symbol: "WETH".to_string(), + name: "WETH9".to_string(), + erc_standard: ErcStandard::Erc20, + contract_address: "0x82af49447d8a07e3bd95bd0d56f35241523fbab1".to_string(), + decimals: 18, + }, + ); + + // Add common tokens on Ethereum Mainnet + // USDC + 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, + }, + ); + + // USDT + 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, + }, + ); + + // DAI + 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, + }, + ); + + // SETH (Sonne Ethereum - or other SETH variant) + registry.register_token( + 1, + TokenMetadata { + symbol: "SETH".to_string(), + name: "SETH".to_string(), + erc_standard: ErcStandard::Erc20, + contract_address: "0xe71bdfe1df69284f00ee185cf0d95d0c7680c0d4".to_string(), + decimals: 18, + }, + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_universal_router_address() { + let expected: Address = "0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD" + .parse() + .unwrap(); + assert_eq!(UniswapConfig::universal_router_address(), expected); + } + + #[test] + fn test_universal_router_chains() { + let chains = UniswapConfig::universal_router_chains(); + assert_eq!(chains, &[1, 10, 137, 8453, 42161]); + } + + #[test] + fn test_contract_type_id() { + let type_id = UniswapUniversalRouter::short_type_id(); + assert_eq!(type_id, "UniswapUniversalRouter"); + } +} diff --git a/src/chain_parsers/visualsign-ethereum/src/protocols/uniswap/contracts/mod.rs b/src/chain_parsers/visualsign-ethereum/src/protocols/uniswap/contracts/mod.rs new file mode 100644 index 00000000..55ab80d2 --- /dev/null +++ b/src/chain_parsers/visualsign-ethereum/src/protocols/uniswap/contracts/mod.rs @@ -0,0 +1,9 @@ +//! Uniswap protocol contract visualizers + +pub mod permit2; +pub mod universal_router; +pub mod v4_pool; + +pub use permit2::{Permit2ContractVisualizer, Permit2Visualizer}; +pub use universal_router::{UniversalRouterContractVisualizer, UniversalRouterVisualizer}; +pub use v4_pool::V4PoolManagerVisualizer; diff --git a/src/chain_parsers/visualsign-ethereum/src/protocols/uniswap/contracts/permit2.rs b/src/chain_parsers/visualsign-ethereum/src/protocols/uniswap/contracts/permit2.rs new file mode 100644 index 00000000..1bb6ca1b --- /dev/null +++ b/src/chain_parsers/visualsign-ethereum/src/protocols/uniswap/contracts/permit2.rs @@ -0,0 +1,280 @@ +//! Permit2 Contract Visualizer +//! +//! Permit2 is Uniswap's token approval system that allows signature-based approvals +//! and transfers, improving UX by batching operations. +//! +//! Reference: + +#![allow(unused_imports)] + +use alloy_primitives::Address; +use alloy_sol_types::{SolCall, sol}; +use chrono::{TimeZone, Utc}; +use visualsign::{SignablePayloadField, SignablePayloadFieldCommon, SignablePayloadFieldTextV2}; + +use crate::registry::{ContractRegistry, ContractType}; + +// Permit2 interface (simplified) +sol! { + interface IPermit2 { + function approve(address token, address spender, uint160 amount, uint48 expiration) external; + function permit(address owner, PermitSingle calldata permitSingle, bytes calldata signature) external; + function transferFrom(address from, address to, uint160 amount, address token) external; + } + + struct PermitSingle { + PermitDetails details; + address spender; + uint256 sigDeadline; + } + + struct PermitDetails { + address token; + uint160 amount; + uint48 expiration; + uint48 nonce; + } +} + +/// Visualizer for Permit2 contract calls +/// +/// Permit2 address: 0x000000000022D473030F116dDEE9F6B43aC78BA3 +/// (deployed at the same address across all chains) +pub struct Permit2Visualizer; + +impl Permit2Visualizer { + /// Attempts to decode and visualize Permit2 function calls + /// + /// # Arguments + /// * `input` - The calldata bytes + /// * `chain_id` - The chain ID for token lookups + /// * `registry` - Optional contract registry for token metadata + /// + /// # Returns + /// * `Some(field)` if a recognized Permit2 function is found + /// * `None` if the input doesn't match any Permit2 function + pub fn visualize_tx_commands( + &self, + input: &[u8], + chain_id: u64, + registry: Option<&ContractRegistry>, + ) -> Option { + if input.len() < 4 { + return None; + } + + // Try to decode as approve + if let Ok(call) = IPermit2::approveCall::abi_decode(input) { + return Some(Self::decode_approve(call, chain_id, registry)); + } + + // Try to decode as permit + if let Ok(call) = IPermit2::permitCall::abi_decode(input) { + return Some(Self::decode_permit(call, chain_id, registry)); + } + + // Try to decode as transferFrom + if let Ok(call) = IPermit2::transferFromCall::abi_decode(input) { + return Some(Self::decode_transfer_from(call, chain_id, registry)); + } + + None + } + + /// Decodes approve function call + fn decode_approve( + call: IPermit2::approveCall, + chain_id: u64, + registry: Option<&ContractRegistry>, + ) -> SignablePayloadField { + let token_symbol = registry + .and_then(|r| r.get_token_symbol(chain_id, call.token)) + .unwrap_or_else(|| format!("{:?}", call.token)); + + // Format amount with proper decimals + 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.token, amount_u128)) + .unwrap_or_else(|| (call.amount.to_string(), token_symbol.clone())); + + // Format expiration timestamp + let expiration_u64: u64 = call.expiration.to_string().parse().unwrap_or(0); + let expiration_str = if expiration_u64 == u64::MAX { + "never".to_string() + } else { + let dt = Utc.timestamp_opt(expiration_u64 as i64, 0).unwrap(); + dt.format("%Y-%m-%d %H:%M UTC").to_string() + }; + + let text = format!( + "Approve {} {} {} to spend {} (expires: {})", + call.spender, amount_str, token_symbol, token_symbol, expiration_str + ); + + SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: text.clone(), + label: "Permit2 Approve".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { text }, + } + } + + /// Decodes permit function call + fn decode_permit( + call: IPermit2::permitCall, + chain_id: u64, + registry: Option<&ContractRegistry>, + ) -> SignablePayloadField { + let token = call.permitSingle.details.token; + let token_symbol = registry + .and_then(|r| r.get_token_symbol(chain_id, token)) + .unwrap_or_else(|| format!("{:?}", token)); + + // Format amount with proper decimals + let amount_u128: u128 = call + .permitSingle + .details + .amount + .to_string() + .parse() + .unwrap_or(0); + let (amount_str, _) = registry + .and_then(|r| r.format_token_amount(chain_id, token, amount_u128)) + .unwrap_or_else(|| { + ( + call.permitSingle.details.amount.to_string(), + token_symbol.clone(), + ) + }); + + // Format expiration timestamp + let expiration_u64: u64 = call + .permitSingle + .details + .expiration + .to_string() + .parse() + .unwrap_or(0); + let expiration_str = if expiration_u64 == u64::MAX { + "never".to_string() + } else { + let dt = Utc.timestamp_opt(expiration_u64 as i64, 0).unwrap(); + dt.format("%Y-%m-%d %H:%M UTC").to_string() + }; + + let text = format!( + "Permit {} to spend {} {} from {} (expires: {})", + call.permitSingle.spender, amount_str, token_symbol, call.owner, expiration_str + ); + + SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: text.clone(), + label: "Permit2 Permit".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { text }, + } + } + + /// Decodes transferFrom function call + fn decode_transfer_from( + call: IPermit2::transferFromCall, + chain_id: u64, + registry: Option<&ContractRegistry>, + ) -> SignablePayloadField { + let token_symbol = registry + .and_then(|r| r.get_token_symbol(chain_id, call.token)) + .unwrap_or_else(|| format!("{:?}", call.token)); + + // Format amount with proper decimals + 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.token, amount_u128)) + .unwrap_or_else(|| (call.amount.to_string(), token_symbol.clone())); + + let text = format!( + "Transfer {} {} from {} to {}", + amount_str, token_symbol, call.from, call.to + ); + + SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: text.clone(), + label: "Permit2 Transfer".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { text }, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_visualize_empty_input() { + let visualizer = Permit2Visualizer; + assert_eq!(visualizer.visualize_tx_commands(&[], 1, None), None); + } + + #[test] + fn test_visualize_too_short() { + let visualizer = Permit2Visualizer; + assert_eq!( + visualizer.visualize_tx_commands(&[0x01, 0x02], 1, None), + None + ); + } + + // TODO: Add tests for Permit2 functions once implemented +} + +/// ContractVisualizer implementation for Permit2 +pub struct Permit2ContractVisualizer { + inner: Permit2Visualizer, +} + +impl Permit2ContractVisualizer { + pub fn new() -> Self { + Self { + inner: Permit2Visualizer, + } + } +} + +impl Default for Permit2ContractVisualizer { + fn default() -> Self { + Self::new() + } +} + +impl crate::visualizer::ContractVisualizer for Permit2ContractVisualizer { + fn contract_type(&self) -> &str { + crate::protocols::uniswap::config::Permit2Contract::short_type_id() + } + + fn visualize( + &self, + context: &crate::context::VisualizerContext, + ) -> Result>, visualsign::vsptrait::VisualSignError> + { + let contract_registry = crate::registry::ContractRegistry::with_default_protocols(); + + if let Some(field) = self.inner.visualize_tx_commands( + &context.calldata, + context.chain_id, + Some(&contract_registry), + ) { + let annotated = visualsign::AnnotatedPayloadField { + signable_payload_field: field, + static_annotation: None, + dynamic_annotation: None, + }; + + Ok(Some(vec![annotated])) + } else { + Ok(None) + } + } +} diff --git a/src/chain_parsers/visualsign-ethereum/src/protocols/uniswap/contracts/universal_router.rs b/src/chain_parsers/visualsign-ethereum/src/protocols/uniswap/contracts/universal_router.rs new file mode 100644 index 00000000..a216e9c8 --- /dev/null +++ b/src/chain_parsers/visualsign-ethereum/src/protocols/uniswap/contracts/universal_router.rs @@ -0,0 +1,2378 @@ +use alloy_primitives::{Address, Bytes, U256}; +use alloy_sol_types::{SolCall as _, SolType, SolValue, sol}; +use chrono::{TimeZone, Utc}; +use num_enum::TryFromPrimitive; +use visualsign::{SignablePayloadField, SignablePayloadFieldCommon, SignablePayloadFieldTextV2}; + +use crate::registry::{ContractRegistry, ContractType}; + +// Uniswap Universal Router interface definitions +// +// Official Documentation: +// - Technical Reference: https://docs.uniswap.org/contracts/universal-router/technical-reference +// - Contract Source: https://github.com/Uniswap/universal-router/blob/main/contracts/interfaces/IUniversalRouter.sol +// +// The Universal Router supports function overloading with two execute variants: +// 1. execute(bytes,bytes[],uint256) - with deadline parameter for time-bound execution +// 2. execute(bytes,bytes[]) - without deadline for flexible execution +// +// Each function gets a unique 4-byte selector based on its signature. +sol! { + interface IUniversalRouter { + /// @notice Executes encoded commands along with provided inputs. Reverts if deadline has expired. + /// @param commands A set of concatenated commands, each 1 byte in length + /// @param inputs An array of byte strings containing abi encoded inputs for each command + /// @param deadline The deadline by which the transaction must be executed + function execute(bytes calldata commands, bytes[] calldata inputs, uint256 deadline) external payable; + + /// @notice Executes encoded commands along with provided inputs (no deadline check) + /// @param commands A set of concatenated commands, each 1 byte in length + /// @param inputs An array of byte strings containing abi encoded inputs for each command + function execute(bytes calldata commands, bytes[] calldata inputs) external payable; + } +} + +// Command parameter structures +// +// These structs define the ABI-encoded parameters for each command type. +// Reference: https://docs.uniswap.org/contracts/universal-router/technical-reference +// Source: https://github.com/Uniswap/universal-router/blob/main/contracts/modules/uniswap/v3/V3SwapRouter.sol +sol! { + /// Parameters for V3_SWAP_EXACT_IN command + struct V3SwapExactInputParams { + address recipient; + uint256 amountIn; + uint256 amountOutMinimum; + bytes path; + bool payerIsUser; + } + + /// Parameters for V3_SWAP_EXACT_OUT command + struct V3SwapExactOutputParams { + address recipient; + uint256 amountOut; + uint256 amountInMaximum; + bytes path; + bool payerIsUser; + } + + /// Parameters for PAY_PORTION command + struct PayPortionParams { + address token; + address recipient; + uint256 bips; + } + + /// Parameters for UNWRAP_WETH command + struct UnwrapWethParams { + address recipient; + uint256 amountMinimum; + } + + /// Parameters for V2_SWAP_EXACT_IN command + /// Source: https://github.com/Uniswap/universal-router/blob/main/contracts/modules/uniswap/v2/V2SwapRouter.sol + /// function v2SwapExactInput(address recipient, uint256 amountIn, uint256 amountOutMinimum, address[] calldata path, address payer) + struct V2SwapExactInputParams { + address recipient; + uint256 amountIn; + uint256 amountOutMinimum; + address[] path; + address payer; + } + + /// Parameters for V2_SWAP_EXACT_OUT command + struct V2SwapExactOutputParams { + uint256 amountOut; + uint256 amountInMaximum; + address[] path; + address recipient; + } + + /// Parameters for WRAP_ETH command + struct WrapEthParams { + uint256 amountMin; + } + + /// Parameters for SWEEP command + struct SweepParams { + address token; + uint256 amountMinimum; + address recipient; + } + + /// Parameters for TRANSFER command + struct TransferParams { + address from; + address to; + uint160 amount; + } + + /// Parameters for PERMIT2_TRANSFER_FROM command + struct Permit2TransferFromParams { + address from; + address to; + uint160 amount; + address token; + } + + /// Parameters for PERMIT2_PERMIT command + struct PermitDetails { + address token; + uint160 amount; + uint48 expiration; + uint48 nonce; + } + + struct PermitSingle { + PermitDetails details; + address spender; + uint256 sigDeadline; + } + + struct Permit2PermitParams { + PermitSingle permitSingle; + bytes signature; + } +} + +// Command IDs for Universal Router +// +// Reference: https://docs.uniswap.org/contracts/universal-router/technical-reference +// Source: https://github.com/Uniswap/universal-router/blob/main/contracts/libraries/Commands.sol +// +// Commands are encoded as single bytes and define the operation to execute. +// The Universal Router processes these commands sequentially. +#[derive(Copy, Clone, Debug, Eq, PartialEq, TryFromPrimitive)] +#[repr(u8)] +pub enum Command { + V3SwapExactIn = 0x00, + V3SwapExactOut = 0x01, + Permit2TransferFrom = 0x02, + Permit2PermitBatch = 0x03, + Sweep = 0x04, + Transfer = 0x05, + PayPortion = 0x06, + + V2SwapExactIn = 0x08, + V2SwapExactOut = 0x09, + Permit2Permit = 0x0a, + WrapEth = 0x0b, + UnwrapWeth = 0x0c, + Permit2TransferFromBatch = 0x0d, + BalanceCheckErc20 = 0x0e, + + V4Swap = 0x10, + V3PositionManagerPermit = 0x11, + V3PositionManagerCall = 0x12, + V4InitializePool = 0x13, + V4PositionManagerCall = 0x14, + + ExecuteSubPlan = 0x21, +} + +fn map_commands(raw: &[u8]) -> Vec { + let mut out = Vec::with_capacity(raw.len()); + for &b in raw { + if let Ok(cmd) = Command::try_from(b) { + out.push(cmd); + } + } + out +} + +/// Visualizer for Uniswap Universal Router +/// +/// Handles the `execute` function from IUniversalRouter interface: +/// +pub struct UniversalRouterVisualizer {} + +impl UniversalRouterVisualizer { + /// Visualizes Uniswap Universal Router Execute commands + /// + /// # Arguments + /// * `input` - The calldata bytes + /// * `chain_id` - The chain ID for registry lookups + /// * `registry` - Optional registry for resolving token symbols + pub fn visualize_tx_commands( + &self, + input: &[u8], + chain_id: u64, + registry: Option<&ContractRegistry>, + ) -> Option { + if input.len() < 4 { + return None; + } + + // Try decoding with deadline first (3-parameter version) + if let Ok(call) = IUniversalRouter::execute_0Call::abi_decode(input) { + let deadline_val: i64 = match call.deadline.try_into() { + Ok(val) => val, + Err(_) => return None, + }; + let deadline = if deadline_val > 0 { + Utc.timestamp_opt(deadline_val, 0) + .single() + .map(|dt| dt.to_string()) + } else { + None + }; + return Self::visualize_commands( + &call.commands.0, + &call.inputs, + deadline, + chain_id, + registry, + ); + } + + // Try decoding without deadline (2-parameter version) + if let Ok(call) = IUniversalRouter::execute_1Call::abi_decode(input) { + return Self::visualize_commands( + &call.commands.0, + &call.inputs, + None, + chain_id, + registry, + ); + } + + None + } + + /// Helper function to visualize commands (shared by both execute variants) + fn visualize_commands( + commands: &[u8], + inputs: &[alloy_primitives::Bytes], + deadline: Option, + chain_id: u64, + registry: Option<&ContractRegistry>, + ) -> Option { + let mapped = map_commands(commands); + let mut detail_fields = Vec::new(); + + for (i, cmd) in mapped.iter().enumerate() { + let input_bytes = inputs.get(i).map(|b| &b.0[..]); + + // Decode command-specific parameters + let field = if let Some(bytes) = input_bytes { + match cmd { + Command::V3SwapExactIn => { + Self::decode_v3_swap_exact_in(bytes, chain_id, registry) + } + Command::V3SwapExactOut => { + Self::decode_v3_swap_exact_out(bytes, chain_id, registry) + } + Command::V2SwapExactIn => { + Self::decode_v2_swap_exact_in(bytes, chain_id, registry) + } + Command::V2SwapExactOut => { + Self::decode_v2_swap_exact_out(bytes, chain_id, registry) + } + Command::PayPortion => Self::decode_pay_portion(bytes, chain_id, registry), + Command::WrapEth => Self::decode_wrap_eth(bytes, chain_id, registry), + Command::UnwrapWeth => Self::decode_unwrap_weth(bytes, chain_id, registry), + Command::Sweep => Self::decode_sweep(bytes, chain_id, registry), + Command::Transfer => Self::decode_transfer(bytes, chain_id, registry), + Command::Permit2TransferFrom => { + Self::decode_permit2_transfer_from(bytes, chain_id, registry) + } + Command::Permit2Permit => { + Self::decode_permit2_permit(bytes, chain_id, registry) + } + _ => { + // For unimplemented commands, show hex + let input_hex = format!("0x{}", hex::encode(bytes)); + SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: format!("{cmd:?} input: {input_hex}"), + label: format!("{:?}", cmd), + }, + text_v2: SignablePayloadFieldTextV2 { + text: format!("Input: {input_hex}"), + }, + } + } + } + } else { + SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: format!("{cmd:?} input: None"), + label: format!("{:?}", cmd), + }, + text_v2: SignablePayloadFieldTextV2 { + text: "Input: None".to_string(), + }, + } + }; + + // Wrap the field in a PreviewLayout for consistency + let label = format!("Command {}", i + 1); + let wrapped_field = match field { + SignablePayloadField::TextV2 { common, text_v2 } => { + SignablePayloadField::PreviewLayout { + common: SignablePayloadFieldCommon { + fallback_text: common.fallback_text, + label, + }, + preview_layout: visualsign::SignablePayloadFieldPreviewLayout { + title: Some(visualsign::SignablePayloadFieldTextV2 { + text: common.label, + }), + subtitle: Some(text_v2), + condensed: None, + expanded: None, + }, + } + } + _ => field, + }; + + detail_fields.push(wrapped_field); + } + + // Deadline field (optional) + if let Some(dl) = &deadline { + detail_fields.push(SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: dl.clone(), + label: "Deadline".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { text: dl.clone() }, + }); + } + + Some(SignablePayloadField::PreviewLayout { + common: SignablePayloadFieldCommon { + fallback_text: if let Some(dl) = &deadline { + format!( + "Uniswap Universal Router Execute: {} commands ({:?}), deadline {}", + mapped.len(), + mapped, + dl + ) + } else { + format!( + "Uniswap Universal Router Execute: {} commands ({:?})", + mapped.len(), + mapped + ) + }, + label: "Universal Router".to_string(), + }, + preview_layout: visualsign::SignablePayloadFieldPreviewLayout { + title: Some(visualsign::SignablePayloadFieldTextV2 { + text: "Uniswap Universal Router Execute".to_string(), + }), + subtitle: if let Some(dl) = &deadline { + Some(visualsign::SignablePayloadFieldTextV2 { + text: format!("{} commands, deadline {}", mapped.len(), dl), + }) + } else { + Some(visualsign::SignablePayloadFieldTextV2 { + text: format!("{} commands", mapped.len()), + }) + }, + condensed: None, + expanded: Some(visualsign::SignablePayloadFieldListLayout { + fields: detail_fields + .into_iter() + .map(|f| visualsign::AnnotatedPayloadField { + signable_payload_field: f, + static_annotation: None, + dynamic_annotation: None, + }) + .collect(), + }), + }, + }) + } + + /// Decodes V3_SWAP_EXACT_IN command parameters + /// Uses abi_decode_params for proper ABI decoding of raw calldata bytes + fn decode_v3_swap_exact_in( + bytes: &[u8], + chain_id: u64, + registry: Option<&ContractRegistry>, + ) -> SignablePayloadField { + // Define the parameter types for V3SwapExactIn + // (address recipient, uint256 amountIn, uint256 amountOutMinimum, bytes path, bool payerIsUser) + type V3SwapParams = (Address, U256, U256, Bytes, bool); + + // Decode the ABI-encoded parameters + let params = match V3SwapParams::abi_decode_params(bytes) { + Ok(p) => p, + Err(_) => { + return SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: format!("V3 Swap Exact In: 0x{}", hex::encode(bytes)), + label: "V3 Swap Exact In".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: "Failed to decode parameters".to_string(), + }, + }; + } + }; + + let (_recipient, amount_in, amount_out_min, path, _payer_is_user) = params; + + // Validate path length (minimum 43 bytes for single hop: token + fee + token) + if path.len() < 43 { + return SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: "V3 Swap Exact In: Invalid path".to_string(), + label: "V3 Swap Exact In".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: format!("Path length: {} bytes (expected >=43)", path.len()), + }, + }; + } + + // Extract token addresses and fee from path + let token_in = Address::from_slice(&path[0..20]); + let fee = u32::from_be_bytes([0, path[20], path[21], path[22]]); + let token_out = Address::from_slice(&path[23..43]); + + // Resolve token symbols + let token_in_symbol = registry + .and_then(|r| r.get_token_symbol(chain_id, token_in)) + .unwrap_or_else(|| format!("{:?}", token_in)); + let token_out_symbol = registry + .and_then(|r| r.get_token_symbol(chain_id, token_out)) + .unwrap_or_else(|| format!("{:?}", token_out)); + + // Format amounts + let amount_in_u128: u128 = amount_in.to_string().parse().unwrap_or(0); + let amount_out_min_u128: u128 = amount_out_min.to_string().parse().unwrap_or(0); + + let (amount_in_str, _) = registry + .and_then(|r| r.format_token_amount(chain_id, token_in, amount_in_u128)) + .unwrap_or_else(|| (amount_in.to_string(), token_in_symbol.clone())); + + let (amount_out_min_str, _) = registry + .and_then(|r| r.format_token_amount(chain_id, token_out, amount_out_min_u128)) + .unwrap_or_else(|| (amount_out_min.to_string(), token_out_symbol.clone())); + + // Calculate fee percentage + let fee_pct = fee as f64 / 10000.0; + let text = format!( + "Swap {} {} for >={} {} via V3 ({}% fee)", + amount_in_str, token_in_symbol, amount_out_min_str, token_out_symbol, fee_pct + ); + + // Create individual parameter fields + let fields = vec![ + visualsign::AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: token_in_symbol.clone(), + label: "Input Token".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: token_in_symbol.clone(), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + visualsign::AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: amount_in_str.clone(), + label: "Input Amount".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: amount_in_str.clone(), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + visualsign::AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: token_out_symbol.clone(), + label: "Output Token".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: token_out_symbol.clone(), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + visualsign::AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: format!(">={}", amount_out_min_str), + label: "Minimum Output".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: format!(">={}", amount_out_min_str), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + visualsign::AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: format!("{}%", fee_pct), + label: "Fee Tier".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: format!("{}%", fee_pct), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + ]; + + SignablePayloadField::PreviewLayout { + common: SignablePayloadFieldCommon { + fallback_text: text.clone(), + label: "V3 Swap Exact In".to_string(), + }, + preview_layout: visualsign::SignablePayloadFieldPreviewLayout { + title: Some(visualsign::SignablePayloadFieldTextV2 { + text: "V3 Swap Exact In".to_string(), + }), + subtitle: Some(visualsign::SignablePayloadFieldTextV2 { text }), + condensed: None, + expanded: Some(visualsign::SignablePayloadFieldListLayout { fields }), + }, + } + } + + /// Decodes PAY_PORTION command parameters + fn decode_pay_portion( + bytes: &[u8], + chain_id: u64, + registry: Option<&ContractRegistry>, + ) -> SignablePayloadField { + let params = match ::abi_decode(bytes) { + Ok(p) => p, + Err(_) => { + return SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: format!("Pay Portion: 0x{}", hex::encode(bytes)), + label: "Pay Portion".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: "Failed to decode parameters".to_string(), + }, + }; + } + }; + + let token_symbol = registry + .and_then(|r| r.get_token_symbol(chain_id, params.token)) + .unwrap_or_else(|| format!("{:?}", params.token)); + + // Convert bips to percentage (10000 bips = 100%) + let bips_value: u128 = params.bips.to_string().parse().unwrap_or(0); + let bips_pct = (bips_value as f64) / 100.0; + let percentage_str = if bips_pct >= 1.0 { + format!("{:.2}%", bips_pct) + } else { + format!("{:.4}%", bips_pct) + }; + + // Create individual parameter fields + let fields = vec![ + visualsign::AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: token_symbol.clone(), + label: "Token".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: token_symbol.clone(), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + visualsign::AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: percentage_str.clone(), + label: "Percentage".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: percentage_str.clone(), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + visualsign::AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: format!("{:?}", params.recipient), + label: "Recipient".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: format!("{:?}", params.recipient), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + ]; + + let text = format!( + "Pay {} of {} to {}", + percentage_str, token_symbol, params.recipient + ); + + SignablePayloadField::PreviewLayout { + common: SignablePayloadFieldCommon { + fallback_text: text.clone(), + label: "Pay Portion".to_string(), + }, + preview_layout: visualsign::SignablePayloadFieldPreviewLayout { + title: Some(visualsign::SignablePayloadFieldTextV2 { + text: "Pay Portion".to_string(), + }), + subtitle: Some(visualsign::SignablePayloadFieldTextV2 { text }), + condensed: None, + expanded: Some(visualsign::SignablePayloadFieldListLayout { fields }), + }, + } + } + + /// Decodes UNWRAP_WETH command parameters + fn decode_unwrap_weth( + bytes: &[u8], + chain_id: u64, + registry: Option<&ContractRegistry>, + ) -> SignablePayloadField { + let params = match ::abi_decode(bytes) { + Ok(p) => p, + Err(_) => { + return SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: format!("Unwrap WETH: 0x{}", hex::encode(bytes)), + label: "Unwrap WETH".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: "Failed to decode parameters".to_string(), + }, + }; + } + }; + + // Get WETH address for this chain and format the amount + // WETH is registered in the token registry via UniswapConfig::register_common_tokens + let amount_min_str = + crate::protocols::uniswap::config::UniswapConfig::weth_address(chain_id) + .and_then(|weth_addr| { + let amount_min_u128: u128 = + params.amountMinimum.to_string().parse().unwrap_or(0); + registry + .and_then(|r| r.format_token_amount(chain_id, weth_addr, amount_min_u128)) + }) + .map(|(amt, _)| amt) + .unwrap_or_else(|| params.amountMinimum.to_string()); + + // Create individual parameter fields + let fields = vec![ + visualsign::AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: amount_min_str.clone(), + label: "Minimum Amount".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: format!(">={} WETH", amount_min_str), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + visualsign::AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: format!("{:?}", params.recipient), + label: "Recipient".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: format!("{:?}", params.recipient), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + ]; + + let text = format!( + "Unwrap >={} WETH to ETH for {}", + amount_min_str, params.recipient + ); + + SignablePayloadField::PreviewLayout { + common: SignablePayloadFieldCommon { + fallback_text: text.clone(), + label: "Unwrap WETH".to_string(), + }, + preview_layout: visualsign::SignablePayloadFieldPreviewLayout { + title: Some(visualsign::SignablePayloadFieldTextV2 { + text: "Unwrap WETH".to_string(), + }), + subtitle: Some(visualsign::SignablePayloadFieldTextV2 { text }), + condensed: None, + expanded: Some(visualsign::SignablePayloadFieldListLayout { fields }), + }, + } + } + + /// Decodes V3_SWAP_EXACT_OUT command parameters + /// Uses abi_decode_params for proper ABI decoding of raw calldata bytes + fn decode_v3_swap_exact_out( + bytes: &[u8], + chain_id: u64, + registry: Option<&ContractRegistry>, + ) -> SignablePayloadField { + // Define the parameter types for V3SwapExactOut + // (address recipient, uint256 amountOut, uint256 amountInMaximum, bytes path, bool payerIsUser) + type V3SwapOutParams = (Address, U256, U256, Bytes, bool); + + // Decode the ABI-encoded parameters + let params = match V3SwapOutParams::abi_decode_params(bytes) { + Ok(p) => p, + Err(_) => { + return SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: format!("V3 Swap Exact Out: 0x{}", hex::encode(bytes)), + label: "V3 Swap Exact Out".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: "Failed to decode parameters".to_string(), + }, + }; + } + }; + + let (_recipient, amount_out, amount_in_max, path, _payer_is_user) = params; + + // Validate path length (minimum 43 bytes for single hop: token + fee + token) + if path.len() < 43 { + return SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: "V3 Swap Exact Out: Invalid path".to_string(), + label: "V3 Swap Exact Out".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: format!("Path length: {} bytes (expected >=43)", path.len()), + }, + }; + } + + // Extract token addresses and fee from path + let token_in = Address::from_slice(&path[0..20]); + let fee = u32::from_be_bytes([0, path[20], path[21], path[22]]); + let token_out = Address::from_slice(&path[23..43]); + + // Resolve token symbols + let token_in_symbol = registry + .and_then(|r| r.get_token_symbol(chain_id, token_in)) + .unwrap_or_else(|| format!("{:?}", token_in)); + let token_out_symbol = registry + .and_then(|r| r.get_token_symbol(chain_id, token_out)) + .unwrap_or_else(|| format!("{:?}", token_out)); + + // Convert amounts to u128 for formatting + let amount_out_u128: u128 = amount_out.to_string().parse().unwrap_or(0); + let amount_in_max_u128: u128 = amount_in_max.to_string().parse().unwrap_or(0); + + // Format amounts with token decimals + let (amount_out_str, _) = registry + .and_then(|r| r.format_token_amount(chain_id, token_out, amount_out_u128)) + .unwrap_or_else(|| (amount_out.to_string(), token_out_symbol.clone())); + + let (amount_in_max_str, _) = registry + .and_then(|r| r.format_token_amount(chain_id, token_in, amount_in_max_u128)) + .unwrap_or_else(|| (amount_in_max.to_string(), token_in_symbol.clone())); + + // Calculate fee percentage + let fee_pct = fee as f64 / 10000.0; + let text = format!( + "Swap <={} {} for {} {} via V3 ({}% fee)", + amount_in_max_str, token_in_symbol, amount_out_str, token_out_symbol, fee_pct + ); + + // Create individual parameter fields + let fields = vec![ + visualsign::AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: token_in_symbol.clone(), + label: "Input Token".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: token_in_symbol.clone(), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + visualsign::AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: format!("<={}", amount_in_max_str), + label: "Maximum Input".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: format!("<={}", amount_in_max_str), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + visualsign::AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: token_out_symbol.clone(), + label: "Output Token".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: token_out_symbol.clone(), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + visualsign::AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: amount_out_str.clone(), + label: "Output Amount".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: amount_out_str.clone(), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + visualsign::AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: format!("{}%", fee_pct), + label: "Fee Tier".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: format!("{}%", fee_pct), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + ]; + + SignablePayloadField::PreviewLayout { + common: SignablePayloadFieldCommon { + fallback_text: text.clone(), + label: "V3 Swap Exact Out".to_string(), + }, + preview_layout: visualsign::SignablePayloadFieldPreviewLayout { + title: Some(visualsign::SignablePayloadFieldTextV2 { + text: "V3 Swap Exact Out".to_string(), + }), + subtitle: Some(visualsign::SignablePayloadFieldTextV2 { text }), + condensed: None, + expanded: Some(visualsign::SignablePayloadFieldListLayout { fields }), + }, + } + } + + /// Decodes V2_SWAP_EXACT_IN command parameters + /// (address recipient, uint256 amountIn, uint256 amountOutMinimum, address[] path, address payerIsUser) + fn decode_v2_swap_exact_in( + bytes: &[u8], + chain_id: u64, + registry: Option<&ContractRegistry>, + ) -> SignablePayloadField { + use alloy_sol_types::sol_data; + + type V2SwapParams = ( + sol_data::Address, + sol_data::Uint<256>, + sol_data::Uint<256>, + sol_data::Array, + sol_data::Address, + ); + + let params = match V2SwapParams::abi_decode_params(bytes) { + Ok(p) => p, + Err(_) => { + return SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: format!("V2 Swap Exact In: 0x{}", hex::encode(bytes)), + label: "V2 Swap Exact In".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: "Failed to decode parameters".to_string(), + }, + }; + } + }; + + let (_recipient, amount_in, amount_out_minimum, path_array, _payer) = params; + let path = path_array.as_slice(); + + if path.is_empty() { + return SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: "V2 Swap Exact In: Empty path".to_string(), + label: "V2 Swap Exact In".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: "Swap path is empty".to_string(), + }, + }; + } + + let token_in = path[0]; + let token_out = path[path.len() - 1]; + + let token_in_symbol = registry + .and_then(|r| r.get_token_symbol(chain_id, token_in)) + .unwrap_or_else(|| format!("{:?}", token_in)); + let token_out_symbol = registry + .and_then(|r| r.get_token_symbol(chain_id, token_out)) + .unwrap_or_else(|| format!("{:?}", token_out)); + + let amount_in_u128: u128 = amount_in.to_string().parse().unwrap_or(0); + let amount_out_min_u128: u128 = amount_out_minimum.to_string().parse().unwrap_or(0); + + let (amount_in_str, _) = registry + .and_then(|r| r.format_token_amount(chain_id, token_in, amount_in_u128)) + .unwrap_or_else(|| (amount_in.to_string(), token_in_symbol.clone())); + + let (amount_out_min_str, _) = registry + .and_then(|r| r.format_token_amount(chain_id, token_out, amount_out_min_u128)) + .unwrap_or_else(|| (amount_out_minimum.to_string(), token_out_symbol.clone())); + + let hops = path.len() - 1; + let text = format!( + "Swap {} {} for >={} {} via V2 ({} hops)", + amount_in_str, token_in_symbol, amount_out_min_str, token_out_symbol, hops + ); + + // Create individual parameter fields + let fields = vec![ + visualsign::AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: token_in_symbol.clone(), + label: "Input Token".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: token_in_symbol.clone(), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + visualsign::AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: amount_in_str.clone(), + label: "Input Amount".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: amount_in_str.clone(), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + visualsign::AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: token_out_symbol.clone(), + label: "Output Token".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: token_out_symbol.clone(), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + visualsign::AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: format!(">={}", amount_out_min_str), + label: "Minimum Output".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: format!(">={}", amount_out_min_str), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + visualsign::AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: hops.to_string(), + label: "Hops".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: hops.to_string(), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + ]; + + SignablePayloadField::PreviewLayout { + common: SignablePayloadFieldCommon { + fallback_text: text.clone(), + label: "V2 Swap Exact In".to_string(), + }, + preview_layout: visualsign::SignablePayloadFieldPreviewLayout { + title: Some(visualsign::SignablePayloadFieldTextV2 { + text: "V2 Swap Exact In".to_string(), + }), + subtitle: Some(visualsign::SignablePayloadFieldTextV2 { text }), + condensed: None, + expanded: Some(visualsign::SignablePayloadFieldListLayout { fields }), + }, + } + } + + /// Decodes V2_SWAP_EXACT_OUT command parameters + /// (uint256 amountOut, uint256 amountInMaximum, address[] path, address recipient) + fn decode_v2_swap_exact_out( + bytes: &[u8], + chain_id: u64, + registry: Option<&ContractRegistry>, + ) -> SignablePayloadField { + use alloy_sol_types::sol_data; + + type V2SwapOutParams = ( + sol_data::Uint<256>, + sol_data::Uint<256>, + sol_data::Array, + sol_data::Address, + ); + + let params = match V2SwapOutParams::abi_decode_params(bytes) { + Ok(p) => p, + Err(_) => { + return SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: format!("V2 Swap Exact Out: 0x{}", hex::encode(bytes)), + label: "V2 Swap Exact Out".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: "Failed to decode parameters".to_string(), + }, + }; + } + }; + + let (amount_out, amount_in_maximum, path_array, _recipient) = params; + let path = path_array.as_slice(); + + if path.is_empty() { + return SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: "V2 Swap Exact Out: Empty path".to_string(), + label: "V2 Swap Exact Out".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: "Swap path is empty".to_string(), + }, + }; + } + + let token_in = path[0]; + let token_out = path[path.len() - 1]; + + let token_in_symbol = registry + .and_then(|r| r.get_token_symbol(chain_id, token_in)) + .unwrap_or_else(|| format!("{:?}", token_in)); + let token_out_symbol = registry + .and_then(|r| r.get_token_symbol(chain_id, token_out)) + .unwrap_or_else(|| format!("{:?}", token_out)); + + let amount_out_u128: u128 = amount_out.to_string().parse().unwrap_or(0); + let amount_in_max_u128: u128 = amount_in_maximum.to_string().parse().unwrap_or(0); + + let (amount_out_str, _) = registry + .and_then(|r| r.format_token_amount(chain_id, token_out, amount_out_u128)) + .unwrap_or_else(|| (amount_out.to_string(), token_out_symbol.clone())); + + let (amount_in_max_str, _) = registry + .and_then(|r| r.format_token_amount(chain_id, token_in, amount_in_max_u128)) + .unwrap_or_else(|| (amount_in_maximum.to_string(), token_in_symbol.clone())); + + let hops = path.len() - 1; + let text = format!( + "Swap <={} {} for {} {} via V2 ({} hops)", + amount_in_max_str, token_in_symbol, amount_out_str, token_out_symbol, hops + ); + + // Create individual parameter fields + let fields = vec![ + visualsign::AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: token_in_symbol.clone(), + label: "Input Token".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: token_in_symbol.clone(), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + visualsign::AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: format!("<={}", amount_in_max_str), + label: "Maximum Input".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: format!("<={}", amount_in_max_str), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + visualsign::AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: token_out_symbol.clone(), + label: "Output Token".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: token_out_symbol.clone(), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + visualsign::AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: amount_out_str.clone(), + label: "Output Amount".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: amount_out_str.clone(), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + visualsign::AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: hops.to_string(), + label: "Hops".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: hops.to_string(), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + ]; + + SignablePayloadField::PreviewLayout { + common: SignablePayloadFieldCommon { + fallback_text: text.clone(), + label: "V2 Swap Exact Out".to_string(), + }, + preview_layout: visualsign::SignablePayloadFieldPreviewLayout { + title: Some(visualsign::SignablePayloadFieldTextV2 { + text: "V2 Swap Exact Out".to_string(), + }), + subtitle: Some(visualsign::SignablePayloadFieldTextV2 { text }), + condensed: None, + expanded: Some(visualsign::SignablePayloadFieldListLayout { fields }), + }, + } + } + + /// Decodes WRAP_ETH command parameters + fn decode_wrap_eth( + bytes: &[u8], + _chain_id: u64, + _registry: Option<&ContractRegistry>, + ) -> SignablePayloadField { + let params = match ::abi_decode(bytes) { + Ok(p) => p, + Err(_) => { + return SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: format!("Wrap ETH: 0x{}", hex::encode(bytes)), + label: "Wrap ETH".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: "Failed to decode parameters".to_string(), + }, + }; + } + }; + + let amount_min_str = params.amountMin.to_string(); + let text = format!("Wrap {} ETH to WETH", amount_min_str); + + SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: text.clone(), + label: "Wrap ETH".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { text }, + } + } + + /// Decodes SWEEP command parameters + fn decode_sweep( + bytes: &[u8], + chain_id: u64, + registry: Option<&ContractRegistry>, + ) -> SignablePayloadField { + let params = match ::abi_decode(bytes) { + Ok(p) => p, + Err(_) => { + return SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: format!("Sweep: 0x{}", hex::encode(bytes)), + label: "Sweep".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: "Failed to decode parameters".to_string(), + }, + }; + } + }; + + let token_symbol = registry + .and_then(|r| r.get_token_symbol(chain_id, params.token)) + .unwrap_or_else(|| format!("{:?}", params.token)); + + let text = format!( + "Sweep >={} {} to {:?}", + params.amountMinimum, token_symbol, params.recipient + ); + + SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: text.clone(), + label: "Sweep".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { text }, + } + } + + /// Decodes TRANSFER command parameters + fn decode_transfer( + bytes: &[u8], + _chain_id: u64, + _registry: Option<&ContractRegistry>, + ) -> SignablePayloadField { + let params = match ::abi_decode(bytes) { + Ok(p) => p, + Err(_) => { + return SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: format!("Transfer: 0x{}", hex::encode(bytes)), + label: "Transfer".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: "Failed to decode parameters".to_string(), + }, + }; + } + }; + + let text = format!( + "Transfer {} tokens from {:?} to {:?}", + params.amount, params.from, params.to + ); + + SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: text.clone(), + label: "Transfer".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { text }, + } + } + + /// Decodes PERMIT2_TRANSFER_FROM command parameters + fn decode_permit2_transfer_from( + bytes: &[u8], + chain_id: u64, + registry: Option<&ContractRegistry>, + ) -> SignablePayloadField { + let params = match ::abi_decode(bytes) { + Ok(p) => p, + Err(_) => { + return SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: format!("Permit2 Transfer From: 0x{}", hex::encode(bytes)), + label: "Permit2 Transfer From".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: "Failed to decode parameters".to_string(), + }, + }; + } + }; + + let token_symbol = registry + .and_then(|r| r.get_token_symbol(chain_id, params.token)) + .unwrap_or_else(|| format!("{:?}", params.token)); + + // Format amount with proper decimals + let amount_u128: u128 = params.amount.to_string().parse().unwrap_or(0); + let (amount_str, _) = registry + .and_then(|r| r.format_token_amount(chain_id, params.token, amount_u128)) + .unwrap_or_else(|| (params.amount.to_string(), token_symbol.clone())); + + // Create individual parameter fields + let fields = vec![ + visualsign::AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: token_symbol.clone(), + label: "Token".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: token_symbol.clone(), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + visualsign::AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: amount_str.clone(), + label: "Amount".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: amount_str.clone(), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + visualsign::AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: format!("{:?}", params.from), + label: "From".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: format!("{:?}", params.from), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + visualsign::AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: format!("{:?}", params.to), + label: "To".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: format!("{:?}", params.to), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + ]; + + let summary = format!( + "Transfer {} {} from {} to {}", + amount_str, token_symbol, params.from, params.to + ); + + SignablePayloadField::PreviewLayout { + common: SignablePayloadFieldCommon { + fallback_text: summary.clone(), + label: "Permit2 Transfer From".to_string(), + }, + preview_layout: visualsign::SignablePayloadFieldPreviewLayout { + title: Some(visualsign::SignablePayloadFieldTextV2 { + text: "Permit2 Transfer From".to_string(), + }), + subtitle: Some(visualsign::SignablePayloadFieldTextV2 { text: summary }), + condensed: None, + expanded: Some(visualsign::SignablePayloadFieldListLayout { fields }), + }, + } + } + + /// Decodes PERMIT2_PERMIT (0x0a) command parameters + /// The Uniswap Universal Router uses custom encoding (not standard ABI) for Permit2 commands: + /// - Slots 0-5 (192 bytes): Raw PermitSingle struct data (inline, no ABI offsets) + /// - Slots 6+: ABI-encoded bytes signature + fn decode_permit2_permit( + bytes: &[u8], + chain_id: u64, + registry: Option<&ContractRegistry>, + ) -> SignablePayloadField { + // Try standard ABI decoding first + let decode_result = ::abi_decode(bytes); + + let params = match decode_result { + Ok(p) => p, + Err(err) => { + // Try custom encoding layout + match Self::decode_custom_permit2_params(bytes) { + Ok(p) => p, + Err(_) => { + // Both attempts failed, show diagnostic info + return Self::show_decode_error(bytes, &err); + } + } + } + }; + + let token = params.permitSingle.details.token; + let token_symbol = registry + .and_then(|r| r.get_token_symbol(chain_id, token)) + .unwrap_or_else(|| format!("{:?}", token)); + + // Format amount with proper decimals + // Check if amount is unlimited (all 0xfff... = max uint160 or max uint256) + let amount_str_val = params.permitSingle.details.amount.to_string(); + let is_unlimited = amount_str_val == "1461501637330902918203684832716283019655932542975" || // MAX_UINT160 + amount_str_val == "115792089237316195423570985008687907853269984665640564039457584007913129639935"; // MAX_UINT256 + + let amount_u128: u128 = amount_str_val.parse().unwrap_or(0); + let (amount_str, _) = registry + .and_then(|r| r.format_token_amount(chain_id, token, amount_u128)) + .unwrap_or_else(|| (amount_str_val.clone(), token_symbol.clone())); + + // For condensed display, use "Unlimited Amount" if max value + let display_amount_str = if is_unlimited { + "Unlimited Amount".to_string() + } else { + amount_str.clone() + }; + + // Format expiration timestamp + let expiration_u64: u64 = params + .permitSingle + .details + .expiration + .to_string() + .parse() + .unwrap_or(0); + let expiration_str = if expiration_u64 == u64::MAX { + "never".to_string() + } else { + let dt = Utc.timestamp_opt(expiration_u64 as i64, 0).unwrap(); + dt.format("%Y-%m-%d %H:%M UTC").to_string() + }; + + // Format sig deadline timestamp + let sig_deadline_u64: u64 = params + .permitSingle + .sigDeadline + .to_string() + .parse() + .unwrap_or(0); + let sig_deadline_str = if sig_deadline_u64 == u64::MAX { + "never".to_string() + } else { + let dt = Utc.timestamp_opt(sig_deadline_u64 as i64, 0).unwrap(); + dt.format("%Y-%m-%d %H:%M UTC").to_string() + }; + + // Create individual parameter fields + let fields = vec![ + visualsign::AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: token_symbol.clone(), + label: "Token".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: token_symbol.clone(), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + visualsign::AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: amount_str.clone(), + label: "Amount".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: amount_str.clone(), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + visualsign::AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: format!("{:?}", params.permitSingle.spender), + label: "Spender".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: format!("{:?}", params.permitSingle.spender), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + visualsign::AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: expiration_str.clone(), + label: "Expires".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: expiration_str.clone(), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + visualsign::AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: sig_deadline_str.clone(), + label: "Sig Deadline".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: sig_deadline_str.clone(), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + ]; + + let summary = format!( + "Permit {} to spend {} of {}", + params.permitSingle.spender, display_amount_str, token_symbol + ); + + // NOTE: The parameter encoding for PERMIT2_PERMIT command in Universal Router needs verification + // The current decoding may not match the actual encoding used by the router + // Values should be compared against Tenderly/Etherscan traces for accuracy + + SignablePayloadField::PreviewLayout { + common: SignablePayloadFieldCommon { + fallback_text: summary.clone(), + label: "Permit2 Permit".to_string(), + }, + preview_layout: visualsign::SignablePayloadFieldPreviewLayout { + title: Some(visualsign::SignablePayloadFieldTextV2 { + text: "Permit2 Permit".to_string(), + }), + subtitle: Some(visualsign::SignablePayloadFieldTextV2 { text: summary }), + condensed: None, + expanded: Some(visualsign::SignablePayloadFieldListLayout { fields }), + }, + } + } + + /// Decodes custom Permit2 parameter layout used by Uniswap router + /// The Universal Router uses a custom encoding for Permit2 commands: + /// Slots 0-5 (192 bytes): Raw PermitSingle structure (inline, no ABI offsets) + /// Slots 6+: ABI-encoded bytes signature + /// + /// Byte Layout (discovered through transaction analysis): + /// Slot 0 (0-31): token (address, left-padded with 12 bytes zero padding) + /// Slot 1 (32-63): amount (uint160, left-padded with 12 bytes zero padding) + /// Slot 2 (64-95): padding (28 bytes) + expiration (6 bytes, right-aligned) + /// Slot 3 (96-127): nonce/reserved (all zeros in observed transaction) + /// Slot 4 (128-159): spender (address, left-padded with 12 bytes zero padding) + /// Slot 5 (160-191): sigDeadline (uint256, left-padded, value in last bytes) + fn decode_custom_permit2_params( + bytes: &[u8], + ) -> Result> { + if bytes.len() < 192 { + return Err("bytes too short for PermitSingle (need 192 bytes minimum)".into()); + } + + let permit_single_bytes = &bytes[0..192]; + + // Extract token (address) from bytes 12-31 (left-padded in Slot 0) + let token = Address::from_slice(&permit_single_bytes[12..32]); + + // Extract amount (uint160) from bytes 44-63 (left-padded in Slot 1) + let amount_hex = hex::encode(&permit_single_bytes[44..64]); + let amount = alloy_primitives::Uint::<160, 3>::from_str_radix(&amount_hex, 16) + .map_err(|_| "Failed to parse amount")?; + + // Extract expiration (uint48) from bytes 90-95 (right-aligned in Slot 2) + let expiration_hex = hex::encode(&permit_single_bytes[90..96]); + let expiration = alloy_primitives::Uint::<48, 1>::from_str_radix(&expiration_hex, 16) + .map_err(|_| "Failed to parse expiration")?; + + // Extract nonce (uint48) from bytes 96-101 (Slot 3, appears to be unused/zero) + let nonce_hex = hex::encode(&permit_single_bytes[96..102]); + let nonce = alloy_primitives::Uint::<48, 1>::from_str_radix(&nonce_hex, 16) + .map_err(|_| "Failed to parse nonce")?; + + // Extract spender (address) from bytes 140-159 (left-padded in Slot 4) + let spender = Address::from_slice(&permit_single_bytes[140..160]); + + // Extract sigDeadline (uint256) from bytes 160-191 (all of Slot 5) + let sig_deadline_hex = hex::encode(&permit_single_bytes[160..192]); + let sig_deadline = alloy_primitives::U256::from_str_radix(&sig_deadline_hex, 16) + .map_err(|_| "Failed to parse sigDeadline")?; + + // Extract signature bytes starting at offset 192 (slot 6+) + // These should be ABI-encoded as bytes: offset (32) | length (32) | data (variable) + let signature = alloy_primitives::Bytes::default(); // Placeholder + + Ok(Permit2PermitParams { + permitSingle: PermitSingle { + details: PermitDetails { + token, + amount, + expiration, + nonce, + }, + spender, + sigDeadline: sig_deadline, + }, + signature, + }) + } + + /// Helper function to display decoding error with raw hex slots + fn show_decode_error(bytes: &[u8], err: &dyn std::fmt::Display) -> SignablePayloadField { + let hex_data = format!("0x{}", hex::encode(bytes)); + let chunk_size = 32; + let mut fields = vec![]; + + for (i, chunk) in bytes.chunks(chunk_size).enumerate() { + let chunk_hex = format!("0x{}", hex::encode(chunk)); + fields.push(visualsign::AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: chunk_hex.clone(), + label: format!("Slot {}", i), + }, + text_v2: SignablePayloadFieldTextV2 { text: chunk_hex }, + }, + static_annotation: None, + dynamic_annotation: None, + }); + } + + SignablePayloadField::PreviewLayout { + common: SignablePayloadFieldCommon { + fallback_text: hex_data.clone(), + label: "Permit2 Permit".to_string(), + }, + preview_layout: visualsign::SignablePayloadFieldPreviewLayout { + title: Some(visualsign::SignablePayloadFieldTextV2 { + text: "Permit2 Permit (Failed to Decode)".to_string(), + }), + subtitle: Some(visualsign::SignablePayloadFieldTextV2 { + text: format!("Error: {}, Length: {} bytes", err, bytes.len()), + }), + condensed: None, + expanded: Some(visualsign::SignablePayloadFieldListLayout { fields }), + }, + } + } +} + +/// ContractVisualizer implementation for Uniswap Universal Router +pub struct UniversalRouterContractVisualizer { + inner: UniversalRouterVisualizer, +} + +impl UniversalRouterContractVisualizer { + pub fn new() -> Self { + Self { + inner: UniversalRouterVisualizer {}, + } + } +} + +impl Default for UniversalRouterContractVisualizer { + fn default() -> Self { + Self::new() + } +} + +impl crate::visualizer::ContractVisualizer for UniversalRouterContractVisualizer { + fn contract_type(&self) -> &str { + crate::protocols::uniswap::config::UniswapUniversalRouter::short_type_id() + } + + fn visualize( + &self, + context: &crate::context::VisualizerContext, + ) -> Result>, visualsign::vsptrait::VisualSignError> + { + let contract_registry = crate::registry::ContractRegistry::with_default_protocols(); + + if let Some(field) = self.inner.visualize_tx_commands( + &context.calldata, + context.chain_id, + Some(&contract_registry), + ) { + let annotated = visualsign::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::{Bytes, U256}; + use visualsign::{ + AnnotatedPayloadField, SignablePayloadField, SignablePayloadFieldCommon, + SignablePayloadFieldListLayout, SignablePayloadFieldPreviewLayout, + SignablePayloadFieldTextV2, + }; + + fn encode_execute_call(commands: &[u8], inputs: Vec>, deadline: u64) -> Vec { + let inputs_bytes = inputs.into_iter().map(Bytes::from).collect::>(); + IUniversalRouter::execute_0Call { + commands: Bytes::from(commands.to_vec()), + inputs: inputs_bytes, + deadline: U256::from(deadline), + } + .abi_encode() + } + + #[test] + fn test_visualize_tx_commands_empty_input() { + assert_eq!( + UniversalRouterVisualizer {}.visualize_tx_commands(&[], 1, None), + None + ); + assert_eq!( + UniversalRouterVisualizer {}.visualize_tx_commands(&[0x01, 0x02, 0x03], 1, None), + None + ); + } + + #[test] + fn test_visualize_tx_commands_invalid_deadline() { + // deadline is not convertible to i64 (u64::MAX) + let input = encode_execute_call(&[0x00], vec![vec![0x01, 0x02]], u64::MAX); + assert_eq!( + UniversalRouterVisualizer {}.visualize_tx_commands(&input, 1, None), + None + ); + } + + #[test] + fn test_visualize_tx_commands_single_command_with_deadline() { + let commands = vec![Command::V3SwapExactIn as u8]; + let inputs = vec![vec![0xde, 0xad, 0xbe, 0xef]]; + let deadline = 1_700_000_000u64; // 2023-11-13T12:26:40Z + let input = encode_execute_call(&commands, inputs.clone(), deadline); + + // Build expected field + let dt = chrono::Utc.timestamp_opt(deadline as i64, 0).unwrap(); + let deadline_str = dt.to_string(); + + assert_eq!( + UniversalRouterVisualizer {} + .visualize_tx_commands(&input, 1, None) + .unwrap(), + SignablePayloadField::PreviewLayout { + common: SignablePayloadFieldCommon { + fallback_text: format!( + "Uniswap Universal Router Execute: 1 commands ([V3SwapExactIn]), deadline {deadline_str}" + ), + label: "Universal Router".to_string(), + }, + preview_layout: SignablePayloadFieldPreviewLayout { + title: Some(SignablePayloadFieldTextV2 { + text: "Uniswap Universal Router Execute".to_string(), + }), + subtitle: Some(SignablePayloadFieldTextV2 { + text: format!("1 commands, deadline {deadline_str}"), + }), + condensed: None, + expanded: Some(SignablePayloadFieldListLayout { + fields: vec![ + AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::PreviewLayout { + common: SignablePayloadFieldCommon { + fallback_text: "V3 Swap Exact In: 0xdeadbeef".to_string(), + label: "Command 1".to_string(), + }, + preview_layout: SignablePayloadFieldPreviewLayout { + title: Some(SignablePayloadFieldTextV2 { + text: "V3 Swap Exact In".to_string(), + }), + subtitle: Some(SignablePayloadFieldTextV2 { + text: "Failed to decode parameters".to_string(), + }), + condensed: None, + expanded: None, + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: deadline_str.clone(), + label: "Deadline".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: deadline_str.clone(), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + ], + }), + }, + } + ); + } + + #[test] + fn test_visualize_tx_commands_multiple_commands_no_deadline() { + let commands = vec![ + Command::V3SwapExactIn as u8, + Command::Transfer as u8, + Command::WrapEth as u8, + ]; + let inputs = vec![vec![0x01, 0x02], vec![0x03, 0x04, 0x05], vec![0x06]]; + let deadline = 0u64; + let input = encode_execute_call(&commands, inputs.clone(), deadline); + + assert_eq!( + UniversalRouterVisualizer {} + .visualize_tx_commands(&input, 1, None) + .unwrap(), + SignablePayloadField::PreviewLayout { + common: SignablePayloadFieldCommon { + fallback_text: + "Uniswap Universal Router Execute: 3 commands ([V3SwapExactIn, Transfer, WrapEth])" + .to_string(), + label: "Universal Router".to_string(), + }, + preview_layout: SignablePayloadFieldPreviewLayout { + title: Some(SignablePayloadFieldTextV2 { + text: "Uniswap Universal Router Execute".to_string(), + }), + subtitle: Some(SignablePayloadFieldTextV2 { + text: "3 commands".to_string(), + }), + condensed: None, + expanded: Some(SignablePayloadFieldListLayout { + fields: vec![ + AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::PreviewLayout { + common: SignablePayloadFieldCommon { + fallback_text: "V3 Swap Exact In: 0x0102".to_string(), + label: "Command 1".to_string(), + }, + preview_layout: SignablePayloadFieldPreviewLayout { + title: Some(SignablePayloadFieldTextV2 { + text: "V3 Swap Exact In".to_string(), + }), + subtitle: Some(SignablePayloadFieldTextV2 { + text: "Failed to decode parameters".to_string(), + }), + condensed: None, + expanded: None, + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::PreviewLayout { + common: SignablePayloadFieldCommon { + fallback_text: "Transfer: 0x030405".to_string(), + label: "Command 2".to_string(), + }, + preview_layout: SignablePayloadFieldPreviewLayout { + title: Some(SignablePayloadFieldTextV2 { + text: "Transfer".to_string(), + }), + subtitle: Some(SignablePayloadFieldTextV2 { + text: "Failed to decode parameters".to_string(), + }), + condensed: None, + expanded: None, + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::PreviewLayout { + common: SignablePayloadFieldCommon { + fallback_text: "Wrap ETH: 0x06".to_string(), + label: "Command 3".to_string(), + }, + preview_layout: SignablePayloadFieldPreviewLayout { + title: Some(SignablePayloadFieldTextV2 { + text: "Wrap ETH".to_string(), + }), + subtitle: Some(SignablePayloadFieldTextV2 { + text: "Failed to decode parameters".to_string(), + }), + condensed: None, + expanded: None, + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + ], + }), + }, + } + ); + } + + #[test] + fn test_visualize_tx_commands_command_without_input() { + // Only one command, but no input for it + let commands = vec![Command::Sweep as u8]; + let inputs = vec![]; // No input + let deadline = 1_700_000_000u64; + let input = encode_execute_call(&commands, inputs.clone(), deadline); + + let dt = chrono::Utc.timestamp_opt(deadline as i64, 0).unwrap(); + let deadline_str = dt.to_string(); + + assert_eq!( + UniversalRouterVisualizer {} + .visualize_tx_commands(&input, 1, None) + .unwrap(), + SignablePayloadField::PreviewLayout { + common: SignablePayloadFieldCommon { + fallback_text: format!( + "Uniswap Universal Router Execute: 1 commands ([Sweep]), deadline {deadline_str}", + ), + label: "Universal Router".to_string(), + }, + preview_layout: SignablePayloadFieldPreviewLayout { + title: Some(SignablePayloadFieldTextV2 { + text: "Uniswap Universal Router Execute".to_string(), + }), + subtitle: Some(SignablePayloadFieldTextV2 { + text: format!("1 commands, deadline {deadline_str}"), + }), + condensed: None, + expanded: Some(SignablePayloadFieldListLayout { + fields: vec![ + AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::PreviewLayout { + common: SignablePayloadFieldCommon { + fallback_text: "Sweep input: None".to_string(), + label: "Command 1".to_string(), + }, + preview_layout: SignablePayloadFieldPreviewLayout { + title: Some(SignablePayloadFieldTextV2 { + text: "Sweep".to_string(), + }), + subtitle: Some(SignablePayloadFieldTextV2 { + text: "Input: None".to_string(), + }), + condensed: None, + expanded: None, + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::TextV2 { + common: SignablePayloadFieldCommon { + fallback_text: deadline_str.clone(), + label: "Deadline".to_string(), + }, + text_v2: SignablePayloadFieldTextV2 { + text: deadline_str.clone(), + }, + }, + static_annotation: None, + dynamic_annotation: None, + }, + ], + }), + }, + } + ); + } + + #[test] + fn test_visualize_tx_commands_real_transaction() { + // Real transaction from Etherscan with 4 commands: + // 1. V3SwapExactIn (0x00) + // 2. V3SwapExactIn (0x00) + // 3. PayPortion (0x06) + // 4. UnwrapWeth (0x0c) + let registry = crate::registry::ContractRegistry::with_default_protocols(); + + // Transaction input data (execute function call) + let input_hex = "3593564c000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000a0000000000000000000000000000000000000000000000000000000006918f83f00000000000000000000000000000000000000000000000000000000000000040000060c000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000001a000000000000000000000000000000000000000000000000000000000000002c000000000000000000000000000000000000000000000000000000000000003400000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000d02ab486cedc00000000000000000000000000000000000000000000000000000000cb274a57755e600000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000002be71bdfe1df69284f00ee185cf0d95d0c7680c0d4000bb8c02aaa39b223fe8d0a0e5c4f27ead9083c756cc20000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000340aad21b3b70000000000000000000000000000000000000000000000000000000032e42284d704100000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000002be71bdfe1df69284f00ee185cf0d95d0c7680c0d4002710c02aaa39b223fe8d0a0e5c4f27ead9083c756cc20000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000060000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2000000000000000000000000000000fee13a103a10d593b9ae06b3e05f2e7e1c000000000000000000000000000000000000000000000000000000000000001900000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000fe0b6cdc4c628c0"; + let input = hex::decode(input_hex).unwrap(); + + let result = UniversalRouterVisualizer {}.visualize_tx_commands(&input, 1, Some(®istry)); + assert!(result.is_some(), "Should decode transaction successfully"); + + // Verify the result contains decoded information + let field = result.unwrap(); + if let SignablePayloadField::PreviewLayout { + common, + preview_layout, + } = field + { + // Check that the fallback text mentions 4 commands + assert!( + common.fallback_text.contains("4 commands"), + "Expected '4 commands' in: {}", + common.fallback_text + ); + + // Check that expanded section exists + assert!( + preview_layout.expanded.is_some(), + "Expected expanded section" + ); + + if let Some(list_layout) = preview_layout.expanded { + // Should have 5 fields: 4 commands + 1 deadline + assert_eq!( + list_layout.fields.len(), + 5, + "Expected 5 fields (4 commands + deadline)" + ); + + // Print decoded commands to verify they're human-readable + println!("\n=== Decoded Transaction ==="); + println!("Fallback text: {}", common.fallback_text); + for (i, annotated_field) in list_layout.fields.iter().enumerate() { + match &annotated_field.signable_payload_field { + SignablePayloadField::PreviewLayout { + common: field_common, + preview_layout: field_preview, + } => { + println!("\nCommand {}: {}", i + 1, field_common.label); + if let Some(title) = &field_preview.title { + println!(" Title: {}", title.text); + } + if let Some(subtitle) = &field_preview.subtitle { + println!(" Detail: {}", subtitle.text); + + // Verify that decoded commands contain tokens, amounts, or decode failures + if i < 2 { + // First two are swaps - should mention WETH, address, or decode failure + assert!( + subtitle.text.contains("WETH") + || subtitle.text.contains("0x") + || subtitle.text.contains("Failed to decode"), + "Swap command should mention WETH, token address, or decode failure" + ); + } + } + } + SignablePayloadField::TextV2 { + common: field_common, + text_v2, + } => { + println!("\n{}: {}", field_common.label, text_v2.text); + } + _ => {} + } + } + println!("\n=== End Decoded Transaction ===\n"); + } + } else { + panic!("Expected PreviewLayout, got different field type"); + } + } + + #[test] + fn test_visualize_tx_commands_unrecognized_command() { + // 0xff is not a valid Command, so it should be skipped + let commands = vec![0xff, Command::Transfer as u8]; + let inputs = vec![vec![0x01], vec![0x02]]; + let deadline = 0u64; + let input = encode_execute_call(&commands, inputs.clone(), deadline); + + assert_eq!( + UniversalRouterVisualizer {} + .visualize_tx_commands(&input, 1, None) + .unwrap(), + SignablePayloadField::PreviewLayout { + common: SignablePayloadFieldCommon { + fallback_text: "Uniswap Universal Router Execute: 1 commands ([Transfer])" + .to_string(), + label: "Universal Router".to_string(), + }, + preview_layout: SignablePayloadFieldPreviewLayout { + title: Some(SignablePayloadFieldTextV2 { + text: "Uniswap Universal Router Execute".to_string(), + }), + subtitle: Some(SignablePayloadFieldTextV2 { + text: "1 commands".to_string(), + }), + condensed: None, + expanded: Some(SignablePayloadFieldListLayout { + fields: vec![AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::PreviewLayout { + common: SignablePayloadFieldCommon { + fallback_text: "Transfer: 0x01".to_string(), + label: "Command 1".to_string(), + }, + preview_layout: SignablePayloadFieldPreviewLayout { + title: Some(SignablePayloadFieldTextV2 { + text: "Transfer".to_string(), + }), + subtitle: Some(SignablePayloadFieldTextV2 { + text: "Failed to decode parameters".to_string(), + }), + condensed: None, + expanded: None, + }, + }, + static_annotation: None, + dynamic_annotation: None, + }], + }), + }, + } + ); + } + + #[test] + fn test_decode_permit2_permit_custom_decoder() { + // Unit test for the custom Permit2 Permit decoder + // This tests the byte-level decoding without going through ABI + + // Construct a minimal PermitSingle structure (192 bytes) + let mut permit_single = vec![0u8; 192]; + + // Set token at bytes 12-31 (Slot 0, left-padded address) + let token_bytes = hex::decode("72b658bd674f9c2b4954682f517c17d14476e417").unwrap(); + permit_single[0..12].fill(0); // Clear padding + permit_single[12..32].copy_from_slice(&token_bytes); + + // Set amount at bytes 44-63 (Slot 1, max uint160, left-padded) + let amount_bytes = hex::decode("ffffffffffffffffffffffffffffffffffffffff").unwrap(); + permit_single[32..44].fill(0); // Clear padding for slot 1 + permit_single[44..64].copy_from_slice(&amount_bytes); + + // Set expiration at bytes 90-95 (Slot 2, 1765824281 = 0x69405719) + permit_single[90..96].copy_from_slice(&[0u8, 0, 0x69, 0x40, 0x57, 0x19]); + + // Set spender at bytes 140-159 (Slot 4, left-padded address) + let spender_bytes = hex::decode("3fc91a3afd70395cd496c647d5a6cc9d4b2b7fad").unwrap(); + permit_single[128..140].fill(0); // Clear padding for slot 4 + permit_single[140..160].copy_from_slice(&spender_bytes); + + // Set sigDeadline at bytes 160-191 (Slot 5, 1763234081 = 0x6918d121) + permit_single[160..188].copy_from_slice(&[0u8; 28]); + permit_single[188..192].copy_from_slice(&[0x69, 0x18, 0xd1, 0x21]); + + let result = UniversalRouterVisualizer::decode_custom_permit2_params(&permit_single); + assert!( + result.is_ok(), + "Should decode custom permit2 params successfully" + ); + + let params = result.unwrap(); + + // Verify token + let expected_token: Address = "0x72b658bd674f9c2b4954682f517c17d14476e417" + .parse() + .unwrap(); + assert_eq!(params.permitSingle.details.token, expected_token); + + // Verify amount (max uint160) + let expected_amount = alloy_primitives::Uint::<160, 3>::from_str_radix( + "ffffffffffffffffffffffffffffffffffffffff", + 16, + ) + .unwrap(); + assert_eq!(params.permitSingle.details.amount, expected_amount); + + // Verify expiration + let expected_expiration = alloy_primitives::Uint::<48, 1>::from(1765824281u64); + assert_eq!(params.permitSingle.details.expiration, expected_expiration); + + // Verify spender + let expected_spender: Address = "0x3fc91a3afd70395cd496c647d5a6cc9d4b2b7fad" + .parse() + .unwrap(); + assert_eq!(params.permitSingle.spender, expected_spender); + + // Verify sigDeadline + let expected_sig_deadline = alloy_primitives::U256::from(1763234081u64); + assert_eq!(params.permitSingle.sigDeadline, expected_sig_deadline); + } + + #[test] + fn test_decode_permit2_permit_field_visualization() { + // Unit test for Permit2 Permit field visualization + let registry = ContractRegistry::with_default_protocols(); + + // Construct the same PermitSingle structure + let mut permit_single = vec![0u8; 192]; + + let token_bytes = hex::decode("72b658bd674f9c2b4954682f517c17d14476e417").unwrap(); + permit_single[0..12].fill(0); + permit_single[12..32].copy_from_slice(&token_bytes); + + let amount_bytes = hex::decode("ffffffffffffffffffffffffffffffffffffffff").unwrap(); + permit_single[32..44].fill(0); + permit_single[44..64].copy_from_slice(&amount_bytes); + + permit_single[90..96].copy_from_slice(&[0u8, 0, 0x69, 0x40, 0x57, 0x19]); + + let spender_bytes = hex::decode("3fc91a3afd70395cd496c647d5a6cc9d4b2b7fad").unwrap(); + permit_single[128..140].fill(0); + permit_single[140..160].copy_from_slice(&spender_bytes); + + permit_single[160..188].copy_from_slice(&[0u8; 28]); + permit_single[188..192].copy_from_slice(&[0x69, 0x18, 0xd1, 0x21]); + + let field = + UniversalRouterVisualizer::decode_permit2_permit(&permit_single, 1, Some(®istry)); + + // Verify the field is a PreviewLayout + match field { + SignablePayloadField::PreviewLayout { common, .. } => { + // Check the label + assert_eq!(common.label, "Permit2 Permit"); + } + _ => panic!("Expected PreviewLayout, got different field type"), + } + } + + #[test] + fn test_permit2_permit_integration_with_fixture_transaction() { + // Integration test using the actual transaction fixture provided by the user + // The user provided a full EIP-1559 transaction, but we can only test with the calldata + let registry = ContractRegistry::with_default_protocols(); + + // Extract just the execute() calldata from the transaction data + let input_hex = "3593564c000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000a0000000000000000000000000000000000000000000000000000000006918f83f00000000000000000000000000000000000000000000000000000000000000040a08060c00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000032000000000000000000000000000000000000000000000000000000000000003a0000000000000000000000000000000000000000000000000000000000000016000000000000000000000000072b658bd674f9c2b4954682f517c17d14476e417000000000000000000000000ffffffffffffffffffffffffffffffffffffffff000000000000000000000000000000000000000000000000000000006940571900000000000000000000000000000000000000000000000000000000000000000000000000000000000000003fc91a3afd70395cd496c647d5a6cc9d4b2b7fad000000000000000000000000000000000000000000000000000000006918d12100000000000000000000000000000000000000000000000000000000000000e000000000000000000000000000000000000000000000000000000000000000412eb0933411b0970637515316fb50511bea7908d3f85808074ceed3bf881562bc06da5178104470e54fb5be96075169b30799c30f30975317ae14113ffdb84bc81c000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000285aaa58c1a1a183d0000000000000000000000000000000000000000000000000009cf200e607a0800000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000200000000000000000000000072b658bd674f9c2b4954682f517c17d14476e417000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc20000000000000000000000000000000000000000000000000000000000000060000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2000000000000000000000000000000fee13a103a10d593b9ae06b3e05f2e7e1c000000000000000000000000000000000000000000000000000000000000001900000000000000000000000000000000000000000000000000000000000000400000000000000000000000008419e7eda8577dfc49591a49cad965a0fc6716cf0000000000000000000000000000000000000000000000000009c8d8ef9ef49bc0"; + let input = hex::decode(input_hex).unwrap(); + + let result = UniversalRouterVisualizer {}.visualize_tx_commands(&input, 1, Some(®istry)); + assert!(result.is_some(), "Should decode transaction successfully"); + + let field = result.unwrap(); + + // Verify the main transaction field + match field { + SignablePayloadField::PreviewLayout { common, .. } => { + // Check that it mentions commands + assert!( + common.fallback_text.contains("commands"), + "Expected 'commands' in fallback text: {}", + common.fallback_text + ); + } + _ => panic!("Expected PreviewLayout for main field"), + } + } + + #[test] + fn test_permit2_permit_timestamp_boundaries() { + // Test edge cases for timestamp handling + let registry = ContractRegistry::with_default_protocols(); + let mut permit_single = vec![0u8; 192]; + + let token_bytes = hex::decode("72b658bd674f9c2b4954682f517c17d14476e417").unwrap(); + permit_single[0..12].fill(0); + permit_single[12..32].copy_from_slice(&token_bytes); + + let amount_bytes = hex::decode("ffffffffffffffffffffffffffffffffffffffff").unwrap(); + permit_single[32..44].fill(0); + permit_single[44..64].copy_from_slice(&amount_bytes); + + let spender_bytes = hex::decode("3fc91a3afd70395cd496c647d5a6cc9d4b2b7fad").unwrap(); + permit_single[128..140].fill(0); + permit_single[140..160].copy_from_slice(&spender_bytes); + + // Test with a future timestamp (year 2030) + // 1893456000 = Friday, January 1, 2030 2:40:00 AM + permit_single[90..96].copy_from_slice(&[0u8, 0, 0x70, 0x94, 0x4b, 0x80]); + permit_single[160..192].copy_from_slice(&[0u8; 32]); + + let field = + UniversalRouterVisualizer::decode_permit2_permit(&permit_single, 1, Some(®istry)); + + match field { + SignablePayloadField::PreviewLayout { preview_layout, .. } => { + if let Some(expanded) = &preview_layout.expanded { + for f in &expanded.fields { + if let SignablePayloadField::PreviewLayout { + common, + preview_layout: inner_preview, + } = &f.signable_payload_field + { + if common.label.contains("Expires") { + if let Some(subtitle) = &inner_preview.subtitle { + // Should show a valid date in 2030 + assert!(subtitle.text.contains("2030")); + } + } + } + } + } + } + _ => {} + } + } + + #[test] + fn test_permit2_permit_invalid_input_too_short() { + // Test that short input is properly rejected + let short_input = vec![0u8; 100]; // Too short + let result = UniversalRouterVisualizer::decode_custom_permit2_params(&short_input); + assert!( + result.is_err(), + "Should reject input shorter than 192 bytes" + ); + } + + #[test] + fn test_permit2_permit_empty_input() { + // Test that empty input is properly rejected + let empty_input = vec![]; + let result = UniversalRouterVisualizer::decode_custom_permit2_params(&empty_input); + assert!(result.is_err(), "Should reject empty input"); + } +} diff --git a/src/chain_parsers/visualsign-ethereum/src/protocols/uniswap/contracts/v4_pool.rs b/src/chain_parsers/visualsign-ethereum/src/protocols/uniswap/contracts/v4_pool.rs new file mode 100644 index 00000000..096fbe18 --- /dev/null +++ b/src/chain_parsers/visualsign-ethereum/src/protocols/uniswap/contracts/v4_pool.rs @@ -0,0 +1,94 @@ +//! Uniswap V4 Pool Manager Visualizer +//! +//! Visualizes interactions with the Uniswap V4 PoolManager contract. +//! +//! Reference: +//! Deployments: + +#![allow(unused_imports)] + +use alloy_sol_types::{SolCall, sol}; +use visualsign::{SignablePayloadField, SignablePayloadFieldCommon, SignablePayloadFieldTextV2}; + +// Simplified V4 PoolManager interface +sol! { + interface IPoolManager { + function initialize(PoolKey memory key, uint160 sqrtPriceX96, bytes calldata hookData) external returns (int24 tick); + function modifyLiquidity(PoolKey memory key, ModifyLiquidityParams memory params, bytes calldata hookData) external returns (BalanceDelta callerDelta, BalanceDelta feesAccrued); + function swap(PoolKey memory key, SwapParams memory params, bytes calldata hookData) external returns (BalanceDelta); + function donate(PoolKey memory key, uint256 amount0, uint256 amount1, bytes calldata hookData) external returns (BalanceDelta); + } + + struct PoolKey { + address currency0; + address currency1; + uint24 fee; + int24 tickSpacing; + address hooks; + } + + struct ModifyLiquidityParams { + int24 tickLower; + int24 tickUpper; + int256 liquidityDelta; + bytes32 salt; + } + + struct SwapParams { + bool zeroForOne; + int256 amountSpecified; + uint160 sqrtPriceLimitX96; + } + + struct BalanceDelta { + int128 amount0; + int128 amount1; + } +} + +/// Visualizer for Uniswap V4 PoolManager contract calls +pub struct V4PoolManagerVisualizer; + +impl V4PoolManagerVisualizer { + /// Attempts to decode and visualize V4 PoolManager function calls + /// + /// # Arguments + /// * `input` - The calldata bytes + /// + /// # Returns + /// * `Some(field)` if a recognized V4 function is found + /// * `None` if the input doesn't match any V4 function + pub fn visualize_tx_commands(&self, input: &[u8]) -> Option { + if input.len() < 4 { + return None; + } + + // TODO: Implement V4 PoolManager function decoding + // - initialize(PoolKey,uint160,bytes) + // - modifyLiquidity(PoolKey,ModifyLiquidityParams,bytes) + // - swap(PoolKey,SwapParams,bytes) + // - donate(PoolKey,uint256,uint256,bytes) + // + // For now, return None to use fallback visualizer + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_visualize_empty_input() { + let visualizer = V4PoolManagerVisualizer; + assert_eq!(visualizer.visualize_tx_commands(&[]), None); + } + + #[test] + fn test_visualize_too_short() { + let visualizer = V4PoolManagerVisualizer; + assert_eq!(visualizer.visualize_tx_commands(&[0x01, 0x02]), None); + } + + // TODO: Add tests for V4 PoolManager functions once implemented +} diff --git a/src/chain_parsers/visualsign-ethereum/src/protocols/uniswap/mod.rs b/src/chain_parsers/visualsign-ethereum/src/protocols/uniswap/mod.rs new file mode 100644 index 00000000..501836f0 --- /dev/null +++ b/src/chain_parsers/visualsign-ethereum/src/protocols/uniswap/mod.rs @@ -0,0 +1,83 @@ +//! Uniswap protocol implementation +//! +//! This module contains contract visualizers, configuration, and registration +//! logic for the Uniswap decentralized exchange protocol. + +pub mod config; +pub mod contracts; + +use crate::registry::ContractRegistry; +use crate::visualizer::EthereumVisualizerRegistryBuilder; + +pub use config::UniswapConfig; +pub use contracts::{ + Permit2ContractVisualizer, Permit2Visualizer, UniversalRouterContractVisualizer, + UniversalRouterVisualizer, V4PoolManagerVisualizer, +}; + +/// Registers all Uniswap 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, +) { + use config::{Permit2Contract, UniswapUniversalRouter}; + + let ur_address = UniswapConfig::universal_router_address(); + + // Register Universal Router on all supported chains + for &chain_id in UniswapConfig::universal_router_chains() { + contract_reg.register_contract_typed::(chain_id, vec![ur_address]); + } + + // Register Permit2 (same address on all chains) + let permit2_address = UniswapConfig::permit2_address(); + for &chain_id in UniswapConfig::universal_router_chains() { + contract_reg.register_contract_typed::(chain_id, vec![permit2_address]); + } + + // Register common tokens (WETH, USDC, USDT, DAI, etc.) + UniswapConfig::register_common_tokens(contract_reg); + + // Register visualizers + visualizer_reg.register(Box::new(UniversalRouterContractVisualizer::new())); + visualizer_reg.register(Box::new(Permit2ContractVisualizer::new())); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::protocols::uniswap::config::UniswapUniversalRouter; + use crate::registry::ContractType; + use alloy_primitives::Address; + + #[test] + fn test_register_uniswap_contracts() { + let mut contract_reg = ContractRegistry::new(); + let mut visualizer_reg = EthereumVisualizerRegistryBuilder::new(); + + register(&mut contract_reg, &mut visualizer_reg); + + let universal_router_address: Address = "0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD" + .parse() + .unwrap(); + + // Verify Universal Router is registered on all supported chains + for chain_id in [1, 10, 137, 8453, 42161] { + let contract_type = contract_reg + .get_contract_type(chain_id, universal_router_address) + .expect(&format!( + "Universal Router should be registered on chain {}", + chain_id + )); + assert_eq!(contract_type, UniswapUniversalRouter::short_type_id()); + } + } +} diff --git a/src/chain_parsers/visualsign-ethereum/src/registry.rs b/src/chain_parsers/visualsign-ethereum/src/registry.rs index e69de29b..0dfadd5e 100644 --- a/src/chain_parsers/visualsign-ethereum/src/registry.rs +++ b/src/chain_parsers/visualsign-ethereum/src/registry.rs @@ -0,0 +1,581 @@ +use crate::token_metadata::{ChainMetadata, TokenMetadata, parse_network_id}; +use alloy_primitives::{Address, utils::format_units}; +use std::collections::HashMap; + +/// Type alias for chain ID to avoid depending on external chain types +pub type ChainId = u64; + +/// Trait for contract type markers +/// +/// Implement this trait on unit structs to create compile-time unique contract type identifiers. +/// The type name is automatically used as the contract type string. +/// +/// # Example +/// ```rust +/// pub struct UniswapUniversalRouter; +/// impl ContractType for UniswapUniversalRouter {} +/// +/// // The type_id is automatically "UniswapUniversalRouter" +/// ``` +/// +/// # Compile-time Uniqueness +/// Because Rust doesn't allow duplicate type names in the same scope, this provides +/// compile-time guarantees that contract types are unique. If someone copies a protocol +/// directory and forgets to rename the type, the code won't compile. +pub trait ContractType: 'static { + /// Returns the unique identifier for this contract type + /// + /// By default, uses the Rust type name. Can be overridden for custom strings. + fn type_id() -> &'static str { + std::any::type_name::() + } + + /// Returns a shortened type ID without module path + /// + /// Strips the module path to get just the struct name. + /// Example: "visualsign_ethereum::protocols::uniswap::UniswapUniversalRouter" -> "UniswapUniversalRouter" + fn short_type_id() -> &'static str { + let full_name = Self::type_id(); + full_name.rsplit("::").next().unwrap_or(full_name) + } +} + +/// Registry for managing Ethereum contract types and token metadata +/// +/// Maintains two types of mappings: +/// 1. Contract type registry: Maps (chain_id, address) to contract type (e.g., "UniswapV3Router") +/// 2. Token metadata registry: Maps (chain_id, token_address) to token information +/// +/// # TODO +/// Extract a ChainRegistry trait that all chains can implement for handling token metadata, +/// contract types, and other chain-specific information. This will allow Solana, Tron, Sui, +/// and other chains to use the same interface pattern. +pub struct ContractRegistry { + /// Maps (chain_id, address) to contract type + address_to_type: HashMap<(ChainId, Address), String>, + /// Maps (chain_id, contract_type) to list of addresses + type_to_addresses: HashMap<(ChainId, String), Vec
>, + /// Maps (chain_id, token_address) to token metadata + token_metadata: HashMap<(ChainId, Address), TokenMetadata>, +} + +impl ContractRegistry { + /// Creates a new empty registry + pub fn new() -> Self { + Self { + address_to_type: HashMap::new(), + type_to_addresses: HashMap::new(), + token_metadata: HashMap::new(), + } + } + + /// Creates a new registry with default protocols registered + /// + /// This is the recommended way to create a ContractRegistry with + /// built-in support for known protocols like Uniswap, Aave, etc. + pub fn with_default_protocols() -> Self { + let mut registry = Self::new(); + let mut visualizer_builder = crate::visualizer::EthereumVisualizerRegistryBuilder::new(); + crate::protocols::register_all(&mut registry, &mut visualizer_builder); + registry + } + + /// Registers a contract type on a specific chain (type-safe version) + /// + /// This is the preferred method for registering contracts. It uses the ContractType + /// trait to ensure compile-time uniqueness of contract type identifiers. + /// + /// # Arguments + /// * `chain_id` - The chain ID (1 for Ethereum, 137 for Polygon, etc.) + /// * `addresses` - List of contract addresses on this chain + /// + /// # Example + /// ```rust + /// pub struct UniswapUniversalRouter; + /// impl ContractType for UniswapUniversalRouter {} + /// + /// registry.register_contract_typed::(1, vec![address]); + /// ``` + pub fn register_contract_typed( + &mut self, + chain_id: ChainId, + addresses: Vec
, + ) { + let contract_type_str = T::short_type_id().to_string(); + + for address in &addresses { + self.address_to_type + .insert((chain_id, *address), contract_type_str.clone()); + } + + self.type_to_addresses + .insert((chain_id, contract_type_str), addresses); + } + + /// Registers a contract type on a specific chain (string version) + /// + /// This method is kept for backward compatibility and dynamic registration. + /// Prefer `register_contract_typed` for compile-time safety. + /// + /// # Arguments + /// * `chain_id` - The chain ID (1 for Ethereum, 137 for Polygon, etc.) + /// * `contract_type` - The contract type identifier (e.g., "UniswapV3Router", "Aave") + /// * `addresses` - List of contract addresses on this chain + pub fn register_contract( + &mut self, + chain_id: ChainId, + contract_type: impl Into, + addresses: Vec
, + ) { + let contract_type_str = contract_type.into(); + + for address in &addresses { + self.address_to_type + .insert((chain_id, *address), contract_type_str.clone()); + } + + self.type_to_addresses + .insert((chain_id, contract_type_str), addresses); + } + + /// Registers token metadata for a specific token + /// + /// # Arguments + /// * `chain_id` - The chain ID + /// * `metadata` - The TokenMetadata containing all token information + pub fn register_token(&mut self, chain_id: ChainId, metadata: TokenMetadata) { + let address: Address = metadata + .contract_address + .parse() + .expect("Invalid contract address"); + self.token_metadata.insert((chain_id, address), metadata); + } + + /// Gets the contract type for a specific address on a chain + /// + /// # Arguments + /// * `chain_id` - The chain ID + /// * `address` - The contract address + /// + /// # Returns + /// `Some(contract_type)` if the address is registered, `None` otherwise + pub fn get_contract_type(&self, chain_id: ChainId, address: Address) -> Option { + self.address_to_type.get(&(chain_id, address)).cloned() + } + + /// Gets the symbol for a specific token on a chain + /// + /// # Arguments + /// * `chain_id` - The chain ID + /// * `token` - The token's contract address + /// + /// # Returns + /// `Some(symbol)` if the token is registered, `None` otherwise + pub fn get_token_symbol(&self, chain_id: ChainId, token: Address) -> Option { + self.token_metadata + .get(&(chain_id, token)) + .map(|m| m.symbol.clone()) + } + + /// Formats a raw token amount with the proper number of decimal places + /// + /// This method: + /// 1. Looks up the token metadata for the given address + /// 2. Uses Alloy's format_units to convert raw amount to decimal representation + /// 3. Returns (formatted_amount, symbol) tuple + /// + /// # Arguments + /// * `chain_id` - The chain ID + /// * `token` - The token's contract address + /// * `raw_amount` - The raw amount in the token's smallest units + /// + /// # Returns + /// `Some((formatted_amount, symbol))` if token is registered and format succeeds + /// `None` if token is not registered + /// + /// # Examples + /// ```ignore + /// // USDC with 6 decimals + /// registry.format_token_amount(1, usdc_addr, 1_500_000); + /// // Returns: Some(("1.5", "USDC")) + /// + /// // WETH with 18 decimals + /// registry.format_token_amount(1, weth_addr, 1_000_000_000_000_000_000); + /// // Returns: Some(("1", "WETH")) + /// ``` + pub fn format_token_amount( + &self, + chain_id: ChainId, + token: Address, + raw_amount: u128, + ) -> Option<(String, String)> { + let metadata = self.token_metadata.get(&(chain_id, token))?; + + // Use Alloy's format_units to format the amount + let formatted = format_units(raw_amount, metadata.decimals).ok()?; + + Some((formatted, metadata.symbol.clone())) + } + + /// Loads token metadata from wallet ChainMetadata structure + /// + /// This method parses network_id to determine the chain ID and registers + /// all tokens from the metadata's assets collection. + /// + /// # Arguments + /// * `chain_metadata` - Reference to ChainMetadata containing token information + /// + /// # Returns + /// `Ok(())` on success, `Err(String)` if network_id is unknown + pub fn load_chain_metadata(&mut self, chain_metadata: &ChainMetadata) -> Result<(), String> { + let chain_id = parse_network_id(&chain_metadata.network_id).map_err(|e| e.to_string())?; + + for (_symbol, token_metadata) in &chain_metadata.assets { + self.register_token(chain_id, token_metadata.clone()); + } + Ok(()) + } +} + +impl Default for ContractRegistry { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::token_metadata::ErcStandard; + + fn usdc_address() -> Address { + "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48" + .parse() + .unwrap() + } + + fn weth_address() -> Address { + "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2" + .parse() + .unwrap() + } + + fn dai_address() -> Address { + "0x6b175474e89094c44da98b954eedeac495271d0f" + .parse() + .unwrap() + } + + fn create_token_metadata( + symbol: &str, + name: &str, + address: &str, + decimals: u8, + ) -> TokenMetadata { + TokenMetadata { + symbol: symbol.to_string(), + name: name.to_string(), + erc_standard: ErcStandard::Erc20, + contract_address: address.to_string(), + decimals, + } + } + + #[test] + fn test_registry_new() { + let registry = ContractRegistry::new(); + assert_eq!(registry.address_to_type.len(), 0); + assert_eq!(registry.type_to_addresses.len(), 0); + assert_eq!(registry.token_metadata.len(), 0); + } + + #[test] + fn test_register_contract() { + let mut registry = ContractRegistry::new(); + let addresses = vec![ + "0x68b3465833fb72B5A828cCEEaAF60b9Ab78ad723" + .parse() + .unwrap(), + "0xE592427A0AEce92De3Edee1F18E0157C05861564" + .parse() + .unwrap(), + ]; + + registry.register_contract(1, "UniswapV3Router", addresses.clone()); + + assert_eq!(registry.address_to_type.len(), 2); + assert_eq!(registry.type_to_addresses.len(), 1); + + for addr in &addresses { + assert_eq!( + registry.get_contract_type(1, *addr), + Some("UniswapV3Router".to_string()) + ); + } + } + + #[test] + fn test_register_token() { + let mut registry = ContractRegistry::new(); + let usdc = create_token_metadata( + "USDC", + "USD Coin", + "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", + 6, + ); + registry.register_token(1, usdc); + + assert_eq!(registry.token_metadata.len(), 1); + assert_eq!( + registry.get_token_symbol(1, usdc_address()), + Some("USDC".to_string()) + ); + } + + #[test] + fn test_format_token_amount_6_decimals() { + let mut registry = ContractRegistry::new(); + let usdc = create_token_metadata( + "USDC", + "USD Coin", + "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", + 6, + ); + registry.register_token(1, usdc); + + // Test: 1.5 USDC = 1_500_000 in raw units + let result = registry.format_token_amount(1, usdc_address(), 1_500_000); + assert_eq!(result, Some(("1.500000".to_string(), "USDC".to_string()))); + } + + #[test] + fn test_format_token_amount_18_decimals() { + let mut registry = ContractRegistry::new(); + let weth = create_token_metadata( + "WETH", + "Wrapped Ether", + "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2", + 18, + ); + registry.register_token(1, weth); + + // Test: 1 WETH = 1_000_000_000_000_000_000 in raw units + let result = registry.format_token_amount(1, weth_address(), 1_000_000_000_000_000_000); + assert_eq!( + result, + Some(("1.000000000000000000".to_string(), "WETH".to_string())) + ); + } + + #[test] + fn test_format_token_amount_with_trailing_zeros() { + let mut registry = ContractRegistry::new(); + let usdc = create_token_metadata( + "USDC", + "USD Coin", + "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", + 6, + ); + registry.register_token(1, usdc); + + // Test: 1 USDC = 1_000_000 in raw units + let result = registry.format_token_amount(1, usdc_address(), 1_000_000); + assert_eq!(result, Some(("1.000000".to_string(), "USDC".to_string()))); + } + + #[test] + fn test_format_token_amount_multiple_decimals() { + let mut registry = ContractRegistry::new(); + let usdc = create_token_metadata( + "USDC", + "USD Coin", + "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", + 6, + ); + registry.register_token(1, usdc); + + // Test: 12.345678 USDC (should trim to 6 decimals: 12.345678) + let result = registry.format_token_amount(1, usdc_address(), 12_345_678); + assert_eq!(result, Some(("12.345678".to_string(), "USDC".to_string()))); + } + + #[test] + fn test_format_token_amount_unknown_token() { + let registry = ContractRegistry::new(); + + // Test: Unknown token returns None + let result = registry.format_token_amount(1, usdc_address(), 1_000_000); + assert_eq!(result, None); + } + + #[test] + fn test_format_token_amount_zero_amount() { + let mut registry = ContractRegistry::new(); + let usdc = create_token_metadata( + "USDC", + "USD Coin", + "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", + 6, + ); + registry.register_token(1, usdc); + + // Test: 0 USDC + let result = registry.format_token_amount(1, usdc_address(), 0); + assert_eq!(result, Some(("0.000000".to_string(), "USDC".to_string()))); + } + + #[test] + fn test_load_chain_metadata() { + let mut registry = ContractRegistry::new(); + + let mut assets = HashMap::new(); + assets.insert( + "USDC".to_string(), + create_token_metadata( + "USDC", + "USD Coin", + "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", + 6, + ), + ); + assets.insert( + "DAI".to_string(), + create_token_metadata( + "DAI", + "Dai Stablecoin", + "0x6b175474e89094c44da98b954eedeac495271d0f", + 18, + ), + ); + + let metadata = ChainMetadata { + network_id: "ETHEREUM_MAINNET".to_string(), + assets, + }; + + registry.load_chain_metadata(&metadata).unwrap(); + + assert_eq!(registry.token_metadata.len(), 2); + assert_eq!( + registry.get_token_symbol(1, usdc_address()), + Some("USDC".to_string()) + ); + assert_eq!( + registry.get_token_symbol(1, dai_address()), + Some("DAI".to_string()) + ); + } + + #[test] + fn test_get_contract_type_not_found() { + let registry = ContractRegistry::new(); + + let result = registry.get_contract_type(1, usdc_address()); + assert_eq!(result, None); + } + + #[test] + fn test_get_token_symbol_not_found() { + let registry = ContractRegistry::new(); + + let result = registry.get_token_symbol(1, usdc_address()); + assert_eq!(result, None); + } + + #[test] + fn test_register_multiple_tokens() { + let mut registry = ContractRegistry::new(); + + registry.register_token( + 1, + create_token_metadata( + "USDC", + "USD Coin", + "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", + 6, + ), + ); + registry.register_token( + 1, + create_token_metadata( + "WETH", + "Wrapped Ether", + "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2", + 18, + ), + ); + registry.register_token( + 1, + create_token_metadata( + "DAI", + "Dai Stablecoin", + "0x6b175474e89094c44da98b954eedeac495271d0f", + 18, + ), + ); + + assert_eq!(registry.token_metadata.len(), 3); + + // Verify each token was registered correctly + let usdc_result = registry.format_token_amount(1, usdc_address(), 1_500_000); + assert_eq!( + usdc_result, + Some(("1.500000".to_string(), "USDC".to_string())) + ); + + let weth_result = + registry.format_token_amount(1, weth_address(), 2_000_000_000_000_000_000); + assert_eq!( + weth_result, + Some(("2.000000000000000000".to_string(), "WETH".to_string())) + ); + + let dai_result = registry.format_token_amount(1, dai_address(), 3_500_000_000_000_000_000); + assert_eq!( + dai_result, + Some(("3.500000000000000000".to_string(), "DAI".to_string())) + ); + } + + #[test] + fn test_same_token_different_chains() { + let mut registry = ContractRegistry::new(); + + // Register USDC on Ethereum (chain 1) + registry.register_token( + 1, + create_token_metadata( + "USDC", + "USD Coin", + "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", + 6, + ), + ); + + // Register USDC on Polygon (chain 137) with different address + registry.register_token( + 137, + create_token_metadata( + "USDC", + "USD Coin", + "0x2791bca1f2de4661ed88a30c99a7a9449aa84174", + 6, + ), + ); + + let eth_result = registry.format_token_amount(1, usdc_address(), 1_000_000); + assert_eq!( + eth_result, + Some(("1.000000".to_string(), "USDC".to_string())) + ); + + let poly_usdc = "0x2791bca1f2de4661ed88a30c99a7a9449aa84174" + .parse() + .unwrap(); + let poly_result = registry.format_token_amount(137, poly_usdc, 1_000_000); + assert_eq!( + poly_result, + Some(("1.000000".to_string(), "USDC".to_string())) + ); + } +} diff --git a/src/chain_parsers/visualsign-ethereum/src/token_metadata.rs b/src/chain_parsers/visualsign-ethereum/src/token_metadata.rs new file mode 100644 index 00000000..44388b0f --- /dev/null +++ b/src/chain_parsers/visualsign-ethereum/src/token_metadata.rs @@ -0,0 +1,285 @@ +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::collections::HashMap; + +/// Standard for ERC token types +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum ErcStandard { + /// ERC20 fungible token standard + #[serde(rename = "ERC20")] + Erc20, + /// ERC721 non-fungible token standard + #[serde(rename = "ERC721")] + Erc721, + /// ERC1155 multi-token standard + #[serde(rename = "ERC1155")] + Erc1155, +} + +impl std::fmt::Display for ErcStandard { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ErcStandard::Erc20 => write!(f, "ERC20"), + ErcStandard::Erc721 => write!(f, "ERC721"), + ErcStandard::Erc1155 => write!(f, "ERC1155"), + } + } +} + +/// Information about a token asset +/// +/// This represents a single token in the blockchain, with its metadata. +/// Used in both the Anchorage format (gRPC ChainMetadata) and internally +/// by the ContractRegistry. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct TokenMetadata { + /// Token symbol (e.g., "USDC", "WETH") + pub symbol: String, + /// Token name (e.g., "USD Coin") + pub name: String, + /// ERC standard this token implements + pub erc_standard: ErcStandard, + /// Contract address of the token + pub contract_address: String, + /// Number of decimal places for token amounts + pub decimals: u8, +} + +/// Chain metadata representing network and token information +/// +/// This is the canonical format for wallets to send token metadata. +/// Network ID is sent as a string (e.g., "ETHEREUM_MAINNET") and is converted +/// to a numeric chain ID by parse_network_id(). +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ChainMetadata { + /// Network identifier as string (e.g., "ETHEREUM_MAINNET") + pub network_id: String, + /// Map of token symbol to token metadata + pub assets: HashMap, +} + +/// Error type for token metadata operations +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TokenMetadataError { + /// Unknown network ID + UnknownNetworkId(String), + /// Hash computation error + HashError(String), +} + +impl std::fmt::Display for TokenMetadataError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + TokenMetadataError::UnknownNetworkId(id) => write!(f, "Unknown network ID: {}", id), + TokenMetadataError::HashError(msg) => write!(f, "Hash error: {}", msg), + } + } +} + +impl std::error::Error for TokenMetadataError {} + +/// Parses a network ID string to its corresponding chain ID +/// +/// # Arguments +/// * `network_id` - The network identifier string (e.g., "ETHEREUM_MAINNET") +/// +/// # Returns +/// `Ok(chain_id)` for known networks, `Err(TokenMetadataError)` otherwise +/// +/// # Supported Networks +/// - "ETHEREUM_MAINNET" -> 1 +/// - "POLYGON_MAINNET" -> 137 +/// - "ARBITRUM_MAINNET" -> 42161 +/// - "OPTIMISM_MAINNET" -> 10 +/// - "BASE_MAINNET" -> 8453 +/// +/// # Examples +/// ``` +/// use visualsign_ethereum::token_metadata::parse_network_id; +/// +/// assert_eq!(parse_network_id("ETHEREUM_MAINNET"), Ok(1)); +/// assert_eq!(parse_network_id("POLYGON_MAINNET"), Ok(137)); +/// ``` +pub fn parse_network_id(network_id: &str) -> Result { + match network_id { + "ETHEREUM_MAINNET" => Ok(1), + "POLYGON_MAINNET" => Ok(137), + "ARBITRUM_MAINNET" => Ok(42161), + "OPTIMISM_MAINNET" => Ok(10), + "BASE_MAINNET" => Ok(8453), + _ => Err(TokenMetadataError::UnknownNetworkId(network_id.to_string())), + } +} + +/// Computes a deterministic SHA256 hash of protobuf bytes +/// +/// This function takes the raw protobuf bytes directly (as received from gRPC) +/// and computes a SHA256 hash. The same bytes will always produce the same hash, +/// making this deterministic without needing to reserialize. +/// +/// # Arguments +/// * `protobuf_bytes` - The raw protobuf bytes representing ChainMetadata +/// +/// # Returns +/// A hex-encoded SHA256 hash string +/// +/// # Examples +/// ``` +/// use visualsign_ethereum::token_metadata::compute_metadata_hash; +/// +/// let bytes = b"example protobuf bytes"; +/// let hash1 = compute_metadata_hash(bytes); +/// let hash2 = compute_metadata_hash(bytes); +/// assert_eq!(hash1, hash2); // Same bytes = same hash +/// ``` +pub fn compute_metadata_hash(protobuf_bytes: &[u8]) -> String { + let mut hasher = Sha256::new(); + hasher.update(protobuf_bytes); + let hash = hasher.finalize(); + format!("{:x}", hash) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_network_id_ethereum() { + assert_eq!(parse_network_id("ETHEREUM_MAINNET"), Ok(1)); + } + + #[test] + fn test_parse_network_id_polygon() { + assert_eq!(parse_network_id("POLYGON_MAINNET"), Ok(137)); + } + + #[test] + fn test_parse_network_id_arbitrum() { + assert_eq!(parse_network_id("ARBITRUM_MAINNET"), Ok(42161)); + } + + #[test] + fn test_parse_network_id_optimism() { + assert_eq!(parse_network_id("OPTIMISM_MAINNET"), Ok(10)); + } + + #[test] + fn test_parse_network_id_base() { + assert_eq!(parse_network_id("BASE_MAINNET"), Ok(8453)); + } + + #[test] + fn test_parse_network_id_unknown() { + let result = parse_network_id("UNKNOWN_NETWORK"); + assert!(result.is_err()); + assert_eq!( + result, + Err(TokenMetadataError::UnknownNetworkId( + "UNKNOWN_NETWORK".to_string() + )) + ); + } + + #[test] + fn test_parse_network_id_empty() { + let result = parse_network_id(""); + assert!(result.is_err()); + } + + #[test] + fn test_compute_metadata_hash_deterministic() { + let bytes = b"example protobuf bytes"; + let hash1 = compute_metadata_hash(bytes); + let hash2 = compute_metadata_hash(bytes); + assert_eq!(hash1, hash2); + } + + #[test] + fn test_compute_metadata_hash_different_bytes() { + let bytes1 = b"protobuf bytes 1"; + let bytes2 = b"protobuf bytes 2"; + + let hash1 = compute_metadata_hash(bytes1); + let hash2 = compute_metadata_hash(bytes2); + + assert_ne!(hash1, hash2); + } + + #[test] + fn test_compute_metadata_hash_format() { + let bytes = b"example protobuf bytes"; + let hash = compute_metadata_hash(bytes); + + // SHA256 produces 256 bits = 32 bytes = 64 hex characters + assert_eq!(hash.len(), 64); + // Verify it's valid hex + assert!(hash.chars().all(|c| c.is_ascii_hexdigit())); + } + + #[test] + fn test_compute_metadata_hash_empty_bytes() { + let bytes = b""; + let hash = compute_metadata_hash(bytes); + + // Empty bytes should still produce valid SHA256 hash + assert_eq!(hash.len(), 64); + assert!(hash.chars().all(|c| c.is_ascii_hexdigit())); + } + + #[test] + fn test_token_metadata_serialization() { + let token = TokenMetadata { + symbol: "USDC".to_string(), + name: "USD Coin".to_string(), + erc_standard: ErcStandard::Erc20, + contract_address: "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48".to_string(), + decimals: 6, + }; + + let json = serde_json::to_string(&token).expect("Failed to serialize"); + let deserialized: TokenMetadata = + serde_json::from_str(&json).expect("Failed to deserialize"); + + assert_eq!(token, deserialized); + } + + #[test] + fn test_chain_metadata_serialization() { + let mut metadata = ChainMetadata { + network_id: "ETHEREUM_MAINNET".to_string(), + assets: HashMap::new(), + }; + + let usdc = TokenMetadata { + symbol: "USDC".to_string(), + name: "USD Coin".to_string(), + erc_standard: ErcStandard::Erc20, + contract_address: "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48".to_string(), + decimals: 6, + }; + + metadata.assets.insert("USDC".to_string(), usdc); + + let json = serde_json::to_string(&metadata).expect("Failed to serialize"); + let deserialized: ChainMetadata = + serde_json::from_str(&json).expect("Failed to deserialize"); + + assert_eq!(metadata, deserialized); + } + + #[test] + fn test_erc_standard_display() { + assert_eq!(ErcStandard::Erc20.to_string(), "ERC20"); + assert_eq!(ErcStandard::Erc721.to_string(), "ERC721"); + assert_eq!(ErcStandard::Erc1155.to_string(), "ERC1155"); + } + + #[test] + fn test_erc_standard_serialization() { + let erc20 = ErcStandard::Erc20; + let json = serde_json::to_string(&erc20).expect("Failed to serialize"); + let deserialized: ErcStandard = serde_json::from_str(&json).expect("Failed to deserialize"); + assert_eq!(erc20, deserialized); + } +} diff --git a/src/chain_parsers/visualsign-ethereum/src/utils/address_utils.rs b/src/chain_parsers/visualsign-ethereum/src/utils/address_utils.rs new file mode 100644 index 00000000..d8ca4daf --- /dev/null +++ b/src/chain_parsers/visualsign-ethereum/src/utils/address_utils.rs @@ -0,0 +1,84 @@ +//! Ethereum address utilities and well-known contract addresses +//! +//! This module provides canonical addresses for contracts like WETH and USDC +//! that may not be in the registry. For most tokens, prefer using the registry. +//! +//! # Example +//! +//! ```rust,ignore +//! use visualsign_ethereum::utils::address_utils::WellKnownAddresses; +//! +//! let weth = WellKnownAddresses::weth(1)?; // Ethereum mainnet WETH +//! ``` + +use alloy_primitives::Address; +use std::collections::HashMap; + +/// Well-known contract addresses by token name and chain ID +/// +/// These are contracts that may not be in a custom registry but are canonical +/// across all chains (e.g., WETH, USDC). For protocol-specific tokens, prefer +/// using the ContractRegistry instead. +pub struct WellKnownAddresses; + +impl WellKnownAddresses { + /// Get WETH address for a chain + pub fn weth(chain_id: u64) -> Option
{ + WETH_ADDRESSES + .get(&chain_id) + .and_then(|addr_str| addr_str.parse().ok()) + } + + /// Get USDC address for a chain + pub fn usdc(chain_id: u64) -> Option
{ + USDC_ADDRESSES + .get(&chain_id) + .and_then(|addr_str| addr_str.parse().ok()) + } + + /// Get Permit2 address (same on all chains) + pub fn permit2() -> Address { + // Permit2 is deployed at the same address on all chains + "0x000000000022d473030f116ddee9f6b43ac78ba3" + .parse() + .expect("Valid PERMIT2 address") + } + + /// Get all addresses for a token across all chains + pub fn all_addresses(token: &str) -> HashMap { + let mut map = HashMap::new(); + match token { + "WETH" => { + for (&chain_id, &addr) in WETH_ADDRESSES.entries() { + map.insert(chain_id, addr.to_string()); + } + } + "USDC" => { + for (&chain_id, &addr) in USDC_ADDRESSES.entries() { + map.insert(chain_id, addr.to_string()); + } + } + _ => {} + } + map + } +} + +// WETH addresses by chain ID +// Sourced from official Uniswap documentation and chain explorers +pub static WETH_ADDRESSES: phf::Map = phf::phf_map! { + 1u64 => "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2", // Ethereum Mainnet + 10u64 => "0x4200000000000000000000000000000000000006", // Optimism + 137u64 => "0x7ceb23fd6bc0add59e62ac25578270cff1b9f619", // Polygon + 8453u64 => "0x4200000000000000000000000000000000000006", // Base + 42161u64 => "0x82af49447d8a07e3bd95bd0d56f35241523fbab1", // Arbitrum +}; + +// USDC addresses by chain ID (using the canonical USDC Bridge) +pub static USDC_ADDRESSES: phf::Map = phf::phf_map! { + 1u64 => "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", // Ethereum Mainnet + 10u64 => "0x0b2c639c533813f4aa9d7837caf62653d097ff85", // Optimism + 137u64 => "0x2791bca1f2de4661ed88a30c99a7a9449aa84174", // Polygon + 8453u64 => "0x833589fcd6edb6e08f4c7c32d4f71b1566469c3d", // Base + 42161u64 => "0xff970a61a04b1ca14834a43f5de4533ebddb5f86", // Arbitrum +}; diff --git a/src/chain_parsers/visualsign-ethereum/src/utils/mod.rs b/src/chain_parsers/visualsign-ethereum/src/utils/mod.rs new file mode 100644 index 00000000..eb64f58b --- /dev/null +++ b/src/chain_parsers/visualsign-ethereum/src/utils/mod.rs @@ -0,0 +1,9 @@ +//! Reusable Ethereum decoder utilities for DApp protocols +//! +//! This module provides shared utilities for decoding Solidity contract calls and creating +//! visualizations. These utilities are designed to be reusable across any DApp that uses +//! Solidity contracts, making it easy to add support for new protocols (e.g., Aave, Curve, etc). + +pub mod address_utils; + +pub use address_utils::*; diff --git a/src/chain_parsers/visualsign-ethereum/src/visualizer.rs b/src/chain_parsers/visualsign-ethereum/src/visualizer.rs new file mode 100644 index 00000000..e2baf53b --- /dev/null +++ b/src/chain_parsers/visualsign-ethereum/src/visualizer.rs @@ -0,0 +1,236 @@ +use crate::context::VisualizerContext; +use std::collections::HashMap; +use visualsign::AnnotatedPayloadField; +use visualsign::vsptrait::VisualSignError; + +/// Trait for visualizing specific contract types +/// We're using Arc so that visualizers can be shared across threads +/// (we don't have guarantee it's only going to be one thread in tokio) +pub trait ContractVisualizer: Send + Sync { + /// Returns the contract type this visualizer handles + fn contract_type(&self) -> &str; + + /// Visualizes a call to this contract type + /// + /// # Arguments + /// * `context` - The visualizer context containing transaction information + /// + /// # Returns + /// * `Ok(Some(fields))` - Successfully visualized into annotated fields + /// * `Ok(None)` - This visualizer cannot handle this call + /// * `Err(error)` - Error during visualization + /// + /// # TODO + /// Return hashed data of chain metadata as part of the response + fn visualize( + &self, + context: &VisualizerContext, + ) -> Result>, VisualSignError>; +} + +/// Registry for managing Ethereum contract visualizers (Immutable) +/// +/// This registry is designed to be built once and shared immutably (e.g., in an Arc). +/// Use `EthereumVisualizerRegistryBuilder` to construct a registry. +pub struct EthereumVisualizerRegistry { + visualizers: HashMap>, +} + +impl EthereumVisualizerRegistry { + /// Retrieves a visualizer by contract type + /// + /// # Arguments + /// * `contract_type` - The contract type to look up + /// + /// # Returns + /// * `Some(&dyn ContractVisualizer)` - The visualizer if found + /// * `None` - No visualizer registered for this type + pub fn get(&self, contract_type: &str) -> Option<&dyn ContractVisualizer> { + self.visualizers.get(contract_type).map(Box::as_ref) + } +} + +// Implement VisualizerRegistry trait for EthereumVisualizerRegistry +impl crate::context::VisualizerRegistry for EthereumVisualizerRegistry {} + +/// Builder for creating a new EthereumVisualizerRegistry (Mutable) +/// +/// This builder is used during the setup phase to register visualizers. +/// Once all visualizers are registered, call `build()` to create an immutable registry. +#[derive(Default)] +pub struct EthereumVisualizerRegistryBuilder { + visualizers: HashMap>, +} + +impl EthereumVisualizerRegistryBuilder { + /// Creates a new empty builder + pub fn new() -> Self { + Self { + visualizers: HashMap::new(), + } + } + + /// Creates a new builder pre-populated with default protocols + pub fn with_default_protocols() -> Self { + let mut builder = Self::new(); + let mut contract_reg = crate::registry::ContractRegistry::new(); + crate::protocols::register_all(&mut contract_reg, &mut builder); + builder + } + + /// Registers a visualizer for a specific contract type + /// + /// # Arguments + /// * `visualizer` - The visualizer to register + /// + /// # Returns + /// * `None` - If this is a new registration + /// * `Some(old_visualizer)` - If an existing visualizer was replaced + pub fn register( + &mut self, + visualizer: Box, + ) -> Option> { + let contract_type = visualizer.contract_type().to_string(); + self.visualizers.insert(contract_type, visualizer) + } + + /// Consumes the builder and returns the immutable registry + pub fn build(self) -> EthereumVisualizerRegistry { + EthereumVisualizerRegistry { + visualizers: self.visualizers, + } + } +} + +impl Default for EthereumVisualizerRegistry { + fn default() -> Self { + EthereumVisualizerRegistryBuilder::default().build() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Mock visualizer for testing + struct MockVisualizer { + contract_type: String, + } + + impl ContractVisualizer for MockVisualizer { + fn contract_type(&self) -> &str { + &self.contract_type + } + + fn visualize( + &self, + _context: &VisualizerContext, + ) -> Result>, VisualSignError> { + Ok(Some(vec![])) + } + } + + #[test] + fn test_builder_new() { + let builder = EthereumVisualizerRegistryBuilder::new(); + assert_eq!(builder.visualizers.len(), 0); + } + + #[test] + fn test_builder_register() { + let mut builder = EthereumVisualizerRegistryBuilder::new(); + let visualizer = Box::new(MockVisualizer { + contract_type: "TestToken".to_string(), + }); + + let old = builder.register(visualizer); + assert!(old.is_none()); + assert_eq!(builder.visualizers.len(), 1); + } + + #[test] + fn test_builder_register_returns_old() { + let mut builder = EthereumVisualizerRegistryBuilder::new(); + + let visualizer1 = Box::new(MockVisualizer { + contract_type: "Token".to_string(), + }); + let old1 = builder.register(visualizer1); + assert!(old1.is_none()); + + let visualizer2 = Box::new(MockVisualizer { + contract_type: "Token".to_string(), + }); + let old2 = builder.register(visualizer2); + assert!(old2.is_some()); + assert_eq!(old2.unwrap().contract_type(), "Token"); + } + + #[test] + fn test_builder_build() { + let mut builder = EthereumVisualizerRegistryBuilder::new(); + let visualizer = Box::new(MockVisualizer { + contract_type: "ERC20".to_string(), + }); + builder.register(visualizer); + + let registry = builder.build(); + assert!(registry.get("ERC20").is_some()); + assert_eq!(registry.get("ERC20").unwrap().contract_type(), "ERC20"); + } + + #[test] + fn test_registry_get_not_found() { + let registry = EthereumVisualizerRegistry::default(); + assert!(registry.get("NonExistent").is_none()); + } + + #[test] + fn test_registry_multiple_visualizers() { + let mut builder = EthereumVisualizerRegistryBuilder::new(); + + let erc20 = Box::new(MockVisualizer { + contract_type: "ERC20".to_string(), + }); + let uniswap = Box::new(MockVisualizer { + contract_type: "UniswapV3".to_string(), + }); + let aave = Box::new(MockVisualizer { + contract_type: "Aave".to_string(), + }); + + builder.register(erc20); + builder.register(uniswap); + builder.register(aave); + + let registry = builder.build(); + assert!(registry.get("ERC20").is_some()); + assert!(registry.get("UniswapV3").is_some()); + assert!(registry.get("Aave").is_some()); + assert!(registry.get("Unknown").is_none()); + } + + #[test] + fn test_builder_default() { + let builder = EthereumVisualizerRegistryBuilder::default(); + let registry = builder.build(); + // Default creates empty registry (no default protocols registered in tests) + assert!(registry.get("ERC20").is_none()); + } + + #[test] + fn test_registry_default() { + let registry = EthereumVisualizerRegistry::default(); + // Default calls builder default and builds empty registry + assert!(registry.get("ERC20").is_none()); + } + + #[test] + fn test_builder_with_default_protocols() { + let builder = EthereumVisualizerRegistryBuilder::with_default_protocols(); + let registry = builder.build(); + // Even though with_default_protocols is called, no protocols are registered + // because crate::protocols::register_all is a placeholder + assert!(registry.get("ERC20").is_none()); + } +} diff --git a/src/chain_parsers/visualsign-ethereum/tests/fixtures/1559.expected b/src/chain_parsers/visualsign-ethereum/tests/fixtures/1559.expected index 3312c174..2bcf4072 100644 --- a/src/chain_parsers/visualsign-ethereum/tests/fixtures/1559.expected +++ b/src/chain_parsers/visualsign-ethereum/tests/fixtures/1559.expected @@ -1 +1 @@ -{"Fields":[{"FallbackText":"Ethereum Mainnet","Label":"Network","TextV2":{"Text":"Ethereum Mainnet"},"Type":"text_v2"},{"AddressV2":{"Address":"0x66a9893cC07D91D95644AEDD05D03f95e1dBA8Af","AssetLabel":"Test Asset","Name":"To"},"FallbackText":"0x66a9893cC07D91D95644AEDD05D03f95e1dBA8Af","Label":"To","Type":"address_v2"},{"AmountV2":{"Abbreviation":"ETH","Amount":"0.005"},"FallbackText":"0.005 ETH","Label":"Value","Type":"amount_v2"},{"FallbackText":"262716","Label":"Gas Limit","TextV2":{"Text":"262716"},"Type":"text_v2"},{"FallbackText":"1.767030437 gwei","Label":"Gas Price","TextV2":{"Text":"1.767030437 gwei"},"Type":"text_v2"},{"FallbackText":"1.264743777 gwei","Label":"Max Priority Fee Per Gas","TextV2":{"Text":"1.264743777 gwei"},"Type":"text_v2"},{"FallbackText":"562","Label":"Nonce","TextV2":{"Text":"562"},"Type":"text_v2"},{"FallbackText":"Universal Router Execute: 4 commands ([WrapEth, V2SwapExactIn, PayPortion, Sweep]), deadline 2025-07-24 21:15:28 UTC","Label":"Universal Router","PreviewLayout":{"Expanded":{"Fields":[{"FallbackText":"WrapEth input: 0x00000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000011c37937e08000","Label":"Command 1","PreviewLayout":{"Subtitle":{"Text":"Input: 0x00000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000011c37937e08000"},"Title":{"Text":"WrapEth"}},"Type":"preview_layout"},{"FallbackText":"V2SwapExactIn input: 0x00000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000011c37937e08000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2000000000000000000000000b1137b9ce6db98312bc9dcb3a8a41eb3d212776f","Label":"Command 2","PreviewLayout":{"Subtitle":{"Text":"Input: 0x00000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000011c37937e08000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2000000000000000000000000b1137b9ce6db98312bc9dcb3a8a41eb3d212776f"},"Title":{"Text":"V2SwapExactIn"}},"Type":"preview_layout"},{"FallbackText":"PayPortion input: 0x000000000000000000000000b1137b9ce6db98312bc9dcb3a8a41eb3d212776f000000000000000000000000000000fee13a103a10d593b9ae06b3e05f2e7e1c0000000000000000000000000000000000000000000000000000000000000019","Label":"Command 3","PreviewLayout":{"Subtitle":{"Text":"Input: 0x000000000000000000000000b1137b9ce6db98312bc9dcb3a8a41eb3d212776f000000000000000000000000000000fee13a103a10d593b9ae06b3e05f2e7e1c0000000000000000000000000000000000000000000000000000000000000019"},"Title":{"Text":"PayPortion"}},"Type":"preview_layout"},{"FallbackText":"Sweep input: 0x000000000000000000000000b1137b9ce6db98312bc9dcb3a8a41eb3d212776f0000000000000000000000006b95d095598e1a080cb62e8ccd99dd64853f1b9900000000000000000000000000000000000000000000000000000e2ab638514b","Label":"Command 4","PreviewLayout":{"Subtitle":{"Text":"Input: 0x000000000000000000000000b1137b9ce6db98312bc9dcb3a8a41eb3d212776f0000000000000000000000006b95d095598e1a080cb62e8ccd99dd64853f1b9900000000000000000000000000000000000000000000000000000e2ab638514b"},"Title":{"Text":"Sweep"}},"Type":"preview_layout"},{"FallbackText":"2025-07-24 21:15:28 UTC","Label":"Deadline","TextV2":{"Text":"2025-07-24 21:15:28 UTC"},"Type":"text_v2"}]},"Subtitle":{"Text":"4 commands, deadline 2025-07-24 21:15:28 UTC"},"Title":{"Text":"Universal Router Execute"}},"Type":"preview_layout"}],"PayloadType":"EthereumTx","Title":"Ethereum Transaction","Version":"0"} +{"Fields":[{"FallbackText":"Ethereum Mainnet","Label":"Network","TextV2":{"Text":"Ethereum Mainnet"},"Type":"text_v2"},{"AddressV2":{"Address":"0x66a9893cC07D91D95644AEDD05D03f95e1dBA8Af","AssetLabel":"Test Asset","Name":"To"},"FallbackText":"0x66a9893cC07D91D95644AEDD05D03f95e1dBA8Af","Label":"To","Type":"address_v2"},{"AmountV2":{"Abbreviation":"ETH","Amount":"0.005"},"FallbackText":"0.005 ETH","Label":"Value","Type":"amount_v2"},{"FallbackText":"262716","Label":"Gas Limit","TextV2":{"Text":"262716"},"Type":"text_v2"},{"FallbackText":"1.767030437 gwei","Label":"Gas Price","TextV2":{"Text":"1.767030437 gwei"},"Type":"text_v2"},{"FallbackText":"1.264743777 gwei","Label":"Max Priority Fee Per Gas","TextV2":{"Text":"1.264743777 gwei"},"Type":"text_v2"},{"FallbackText":"562","Label":"Nonce","TextV2":{"Text":"562"},"Type":"text_v2"},{"FallbackText":"0x3593564c000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000a0000000000000000000000000000000000000000000000000000000006882a27000000000000000000000000000000000000000000000000000000000000000040b080604000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000000e000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000280000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000011c37937e08000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000011c37937e08000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2000000000000000000000000b1137b9ce6db98312bc9dcb3a8a41eb3d212776f0000000000000000000000000000000000000000000000000000000000000060000000000000000000000000b1137b9ce6db98312bc9dcb3a8a41eb3d212776f000000000000000000000000000000fee13a103a10d593b9ae06b3e05f2e7e1c00000000000000000000000000000000000000000000000000000000000000190000000000000000000000000000000000000000000000000000000000000060000000000000000000000000b1137b9ce6db98312bc9dcb3a8a41eb3d212776f0000000000000000000000006b95d095598e1a080cb62e8ccd99dd64853f1b9900000000000000000000000000000000000000000000000000000e2ab638514b0b","Label":"Contract Call Data","TextV2":{"Text":"0x3593564c000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000a0000000000000000000000000000000000000000000000000000000006882a27000000000000000000000000000000000000000000000000000000000000000040b080604000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000000e000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000280000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000011c37937e08000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000011c37937e08000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2000000000000000000000000b1137b9ce6db98312bc9dcb3a8a41eb3d212776f0000000000000000000000000000000000000000000000000000000000000060000000000000000000000000b1137b9ce6db98312bc9dcb3a8a41eb3d212776f000000000000000000000000000000fee13a103a10d593b9ae06b3e05f2e7e1c00000000000000000000000000000000000000000000000000000000000000190000000000000000000000000000000000000000000000000000000000000060000000000000000000000000b1137b9ce6db98312bc9dcb3a8a41eb3d212776f0000000000000000000000006b95d095598e1a080cb62e8ccd99dd64853f1b9900000000000000000000000000000000000000000000000000000e2ab638514b0b"},"Type":"text_v2"}],"PayloadType":"EthereumTx","Title":"Ethereum Transaction","Version":"0"} diff --git a/src/chain_parsers/visualsign-ethereum/tests/fixtures/legacy.expected b/src/chain_parsers/visualsign-ethereum/tests/fixtures/legacy.expected index d157e1df..49e78166 100644 --- a/src/chain_parsers/visualsign-ethereum/tests/fixtures/legacy.expected +++ b/src/chain_parsers/visualsign-ethereum/tests/fixtures/legacy.expected @@ -1 +1 @@ -{"Fields":[{"FallbackText":"Unknown Network","Label":"Network","TextV2":{"Text":"Unknown Network"},"Type":"text_v2"},{"AddressV2":{"Address":"0x2910543Af39abA0Cd09dBb2D50200b3E800A63D2","AssetLabel":"Test Asset","Name":"To"},"FallbackText":"0x2910543Af39abA0Cd09dBb2D50200b3E800A63D2","Label":"To","Type":"address_v2"},{"AmountV2":{"Abbreviation":"ETH","Amount":"5909.9"},"FallbackText":"5909.9 ETH","Label":"Value","Type":"amount_v2"},{"FallbackText":"50000","Label":"Gas Limit","TextV2":{"Text":"50000"},"Type":"text_v2"},{"FallbackText":"1171.602790622 gwei","Label":"Gas Price","TextV2":{"Text":"1171.602790622 gwei"},"Type":"text_v2"},{"FallbackText":"0","Label":"Nonce","TextV2":{"Text":"0"},"Type":"text_v2"},{"FallbackText":"0x454e354d5154544630","Label":"Input Data","TextV2":{"Text":"0x454e354d5154544630"},"Type":"text_v2"}],"PayloadType":"EthereumTx","Title":"Ethereum Transaction","Version":"0"} +{"Fields":[{"FallbackText":"Unknown Network","Label":"Network","TextV2":{"Text":"Unknown Network"},"Type":"text_v2"},{"AddressV2":{"Address":"0x2910543Af39abA0Cd09dBb2D50200b3E800A63D2","AssetLabel":"Test Asset","Name":"To"},"FallbackText":"0x2910543Af39abA0Cd09dBb2D50200b3E800A63D2","Label":"To","Type":"address_v2"},{"AmountV2":{"Abbreviation":"ETH","Amount":"5909.9"},"FallbackText":"5909.9 ETH","Label":"Value","Type":"amount_v2"},{"FallbackText":"50000","Label":"Gas Limit","TextV2":{"Text":"50000"},"Type":"text_v2"},{"FallbackText":"1171.602790622 gwei","Label":"Gas Price","TextV2":{"Text":"1171.602790622 gwei"},"Type":"text_v2"},{"FallbackText":"0","Label":"Nonce","TextV2":{"Text":"0"},"Type":"text_v2"},{"FallbackText":"0x454e354d5154544630","Label":"Contract Call Data","TextV2":{"Text":"0x454e354d5154544630"},"Type":"text_v2"}],"PayloadType":"EthereumTx","Title":"Ethereum Transaction","Version":"0"} diff --git a/src/chain_parsers/visualsign-ethereum/tests/fixtures/v2swap.expected b/src/chain_parsers/visualsign-ethereum/tests/fixtures/v2swap.expected new file mode 100644 index 00000000..12731183 --- /dev/null +++ b/src/chain_parsers/visualsign-ethereum/tests/fixtures/v2swap.expected @@ -0,0 +1 @@ +{"Fields":[{"FallbackText":"Ethereum Mainnet","Label":"Network","TextV2":{"Text":"Ethereum Mainnet"},"Type":"text_v2"},{"AddressV2":{"Address":"0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD","AssetLabel":"Test Asset","Name":"To"},"FallbackText":"0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD","Label":"To","Type":"address_v2"},{"AmountV2":{"Abbreviation":"ETH","Amount":"0"},"FallbackText":"0 ETH","Label":"Value","Type":"amount_v2"},{"FallbackText":"283399","Label":"Gas Limit","TextV2":{"Text":"283399"},"Type":"text_v2"},{"FallbackText":"2.081928163 gwei","Label":"Gas Price","TextV2":{"Text":"2.081928163 gwei"},"Type":"text_v2"},{"FallbackText":"2 gwei","Label":"Max Priority Fee Per Gas","TextV2":{"Text":"2 gwei"},"Type":"text_v2"},{"FallbackText":"183","Label":"Nonce","TextV2":{"Text":"183"},"Type":"text_v2"},{"FallbackText":"Uniswap Universal Router Execute: 4 commands ([Permit2Permit, V2SwapExactIn, PayPortion, UnwrapWeth])","Label":"Universal Router","PreviewLayout":{"Expanded":{"Fields":[{"FallbackText":"Permit 0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD to spend Unlimited Amount of 0x72b658bd674f9c2b4954682f517c17d14476e417","Label":"Permit2 Permit","PreviewLayout":{"Expanded":{"Fields":[{"FallbackText":"0x72b658bd674f9c2b4954682f517c17d14476e417","Label":"Token","TextV2":{"Text":"0x72b658bd674f9c2b4954682f517c17d14476e417"},"Type":"text_v2"},{"FallbackText":"1461501637330902918203684832716283019655932542975","Label":"Amount","TextV2":{"Text":"1461501637330902918203684832716283019655932542975"},"Type":"text_v2"},{"FallbackText":"0x3fc91a3afd70395cd496c647d5a6cc9d4b2b7fad","Label":"Spender","TextV2":{"Text":"0x3fc91a3afd70395cd496c647d5a6cc9d4b2b7fad"},"Type":"text_v2"},{"FallbackText":"2025-12-15 18:44 UTC","Label":"Expires","TextV2":{"Text":"2025-12-15 18:44 UTC"},"Type":"text_v2"},{"FallbackText":"2025-11-15 19:14 UTC","Label":"Sig Deadline","TextV2":{"Text":"2025-11-15 19:14 UTC"},"Type":"text_v2"}]},"Subtitle":{"Text":"Permit 0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD to spend Unlimited Amount of 0x72b658bd674f9c2b4954682f517c17d14476e417"},"Title":{"Text":"Permit2 Permit"}},"Type":"preview_layout"},{"FallbackText":"Swap 46525180921656252477 0x72b658bd674f9c2b4954682f517c17d14476e417 for >=0.002761011377502728 WETH via V2 (1 hops)","Label":"V2 Swap Exact In","PreviewLayout":{"Expanded":{"Fields":[{"FallbackText":"0x72b658bd674f9c2b4954682f517c17d14476e417","Label":"Input Token","TextV2":{"Text":"0x72b658bd674f9c2b4954682f517c17d14476e417"},"Type":"text_v2"},{"FallbackText":"46525180921656252477","Label":"Input Amount","TextV2":{"Text":"46525180921656252477"},"Type":"text_v2"},{"FallbackText":"WETH","Label":"Output Token","TextV2":{"Text":"WETH"},"Type":"text_v2"},{"FallbackText":">=0.002761011377502728","Label":"Minimum Output","TextV2":{"Text":">=0.002761011377502728"},"Type":"text_v2"},{"FallbackText":"1","Label":"Hops","TextV2":{"Text":"1"},"Type":"text_v2"}]},"Subtitle":{"Text":"Swap 46525180921656252477 0x72b658bd674f9c2b4954682f517c17d14476e417 for >=0.002761011377502728 WETH via V2 (1 hops)"},"Title":{"Text":"V2 Swap Exact In"}},"Type":"preview_layout"},{"FallbackText":"Pay 0.2500% of WETH to 0x000000fee13a103A10D593b9AE06b3e05F2E7E1c","Label":"Pay Portion","PreviewLayout":{"Expanded":{"Fields":[{"FallbackText":"WETH","Label":"Token","TextV2":{"Text":"WETH"},"Type":"text_v2"},{"FallbackText":"0.2500%","Label":"Percentage","TextV2":{"Text":"0.2500%"},"Type":"text_v2"},{"FallbackText":"0x000000fee13a103a10d593b9ae06b3e05f2e7e1c","Label":"Recipient","TextV2":{"Text":"0x000000fee13a103a10d593b9ae06b3e05f2e7e1c"},"Type":"text_v2"}]},"Subtitle":{"Text":"Pay 0.2500% of WETH to 0x000000fee13a103A10D593b9AE06b3e05F2E7E1c"},"Title":{"Text":"Pay Portion"}},"Type":"preview_layout"},{"FallbackText":"Unwrap >=0.002754108849058971 WETH to ETH for 0x8419e7Eda8577Dfc49591a49CAd965a0Fc6716cF","Label":"Unwrap WETH","PreviewLayout":{"Expanded":{"Fields":[{"FallbackText":"0.002754108849058971","Label":"Minimum Amount","TextV2":{"Text":">=0.002754108849058971 WETH"},"Type":"text_v2"},{"FallbackText":"0x8419e7eda8577dfc49591a49cad965a0fc6716cf","Label":"Recipient","TextV2":{"Text":"0x8419e7eda8577dfc49591a49cad965a0fc6716cf"},"Type":"text_v2"}]},"Subtitle":{"Text":"Unwrap >=0.002754108849058971 WETH to ETH for 0x8419e7Eda8577Dfc49591a49CAd965a0Fc6716cF"},"Title":{"Text":"Unwrap WETH"}},"Type":"preview_layout"}]},"Subtitle":{"Text":"4 commands"},"Title":{"Text":"Uniswap Universal Router Execute"}},"Type":"preview_layout"}],"PayloadType":"EthereumTx","Title":"Ethereum Transaction","Version":"0"} diff --git a/src/chain_parsers/visualsign-ethereum/tests/fixtures/v2swap.input b/src/chain_parsers/visualsign-ethereum/tests/fixtures/v2swap.input new file mode 100644 index 00000000..876491e6 --- /dev/null +++ b/src/chain_parsers/visualsign-ethereum/tests/fixtures/v2swap.input @@ -0,0 +1 @@ +0x02f904cf0181b78477359400847c17b3e383045307943fc91a3afd70395cd496c647d5a6cc9d4b2b7fad80b904a424856bc30000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000000040a08060c00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000032000000000000000000000000000000000000000000000000000000000000003a0000000000000000000000000000000000000000000000000000000000000016000000000000000000000000072b658bd674f9c2b4954682f517c17d14476e417000000000000000000000000ffffffffffffffffffffffffffffffffffffffff000000000000000000000000000000000000000000000000000000006940571900000000000000000000000000000000000000000000000000000000000000000000000000000000000000003fc91a3afd70395cd496c647d5a6cc9d4b2b7fad000000000000000000000000000000000000000000000000000000006918d12100000000000000000000000000000000000000000000000000000000000000e000000000000000000000000000000000000000000000000000000000000000412eb0933411b0970637515316fb50511bea7908d3f85808074ceed3bf881562bc06da5178104470e54fb5be96075169b30799c30f30975317ae14113ffdb84bc81c000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000285aaa58c1a1a183d0000000000000000000000000000000000000000000000000009cf200e607a0800000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000200000000000000000000000072b658bd674f9c2b4954682f517c17d14476e417000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc20000000000000000000000000000000000000000000000000000000000000060000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2000000000000000000000000000000fee13a103a10d593b9ae06b3e05f2e7e1c000000000000000000000000000000000000000000000000000000000000001900000000000000000000000000000000000000000000000000000000000000400000000000000000000000008419e7eda8577dfc49591a49cad965a0fc6716cf0000000000000000000000000000000000000000000000000009c8d8ef9ef49bc0 diff --git a/src/chain_parsers/visualsign-ethereum/tests/lib_test.rs b/src/chain_parsers/visualsign-ethereum/tests/lib_test.rs index a45a227b..d4dd9046 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; 2] = ["1559", "legacy"]; +static FIXTURES: [&str; 3] = ["1559", "legacy", "v2swap"]; #[test] fn test_with_fixtures() { diff --git a/src/parser/app/src/registry.rs b/src/parser/app/src/registry.rs index 9f8551d9..7b112c08 100644 --- a/src/parser/app/src/registry.rs +++ b/src/parser/app/src/registry.rs @@ -7,9 +7,11 @@ #[must_use] pub fn create_registry() -> visualsign::registry::TransactionConverterRegistry { let mut registry = visualsign::registry::TransactionConverterRegistry::new(); + // TODO: Create a ChainRegistry trait that all chains can implement for token metadata, + // contract types, etc. Currently only Ethereum has a ContractRegistry. registry.register::( visualsign::registry::Chain::Ethereum, - visualsign_ethereum::EthereumVisualSignConverter, + visualsign_ethereum::EthereumVisualSignConverter::new(), ); registry.register::( visualsign::registry::Chain::Solana, diff --git a/src/visualsign/src/field_builders.rs b/src/visualsign/src/field_builders.rs index d42c31cd..0fd889fe 100644 --- a/src/visualsign/src/field_builders.rs +++ b/src/visualsign/src/field_builders.rs @@ -1,8 +1,8 @@ use crate::errors; use crate::{ AnnotatedPayloadField, SignablePayloadField, SignablePayloadFieldAddressV2, - SignablePayloadFieldAmountV2, SignablePayloadFieldCommon, SignablePayloadFieldNumber, - SignablePayloadFieldTextV2, + SignablePayloadFieldAmountV2, SignablePayloadFieldCommon, SignablePayloadFieldListLayout, + SignablePayloadFieldNumber, SignablePayloadFieldPreviewLayout, SignablePayloadFieldTextV2, }; use regex::Regex; @@ -175,6 +175,42 @@ pub fn create_raw_data_field( }) } +/// Wrap a SignablePayloadField in an AnnotatedPayloadField with no annotations +pub fn annotate_field(field: SignablePayloadField) -> AnnotatedPayloadField { + AnnotatedPayloadField { + signable_payload_field: field, + static_annotation: None, + dynamic_annotation: None, + } +} + +/// Create a preview layout field with title, subtitle (fallback text), and expanded fields +/// This is useful for operation summaries that show a collapsible preview +pub fn create_preview_layout( + title: &str, + subtitle: String, + fields: Vec, +) -> AnnotatedPayloadField { + AnnotatedPayloadField { + signable_payload_field: SignablePayloadField::PreviewLayout { + common: SignablePayloadFieldCommon { + fallback_text: subtitle.clone(), + label: title.to_string(), + }, + preview_layout: SignablePayloadFieldPreviewLayout { + title: Some(SignablePayloadFieldTextV2 { + text: title.to_string(), + }), + subtitle: Some(SignablePayloadFieldTextV2 { text: subtitle }), + condensed: None, + expanded: Some(SignablePayloadFieldListLayout { fields }), + }, + }, + static_annotation: None, + dynamic_annotation: None, + } +} + #[cfg(test)] mod tests { use super::*;