diff --git a/src/Cargo.lock b/src/Cargo.lock index 875ef7d1..5e092d84 100644 --- a/src/Cargo.lock +++ b/src/Cargo.lock @@ -12007,8 +12007,10 @@ dependencies = [ "base64 0.22.1", "bincode", "borsh 1.5.7", + "bs58 0.5.1", "hex", "jupiter-swap-api-client", + "serde", "serde_json", "solana-program", "solana-sdk", diff --git a/src/chain_parsers/visualsign-solana/Cargo.toml b/src/chain_parsers/visualsign-solana/Cargo.toml index 5f023d7d..26001084 100644 --- a/src/chain_parsers/visualsign-solana/Cargo.toml +++ b/src/chain_parsers/visualsign-solana/Cargo.toml @@ -21,6 +21,8 @@ spl-stake-pool = "2.0.2" solana-system-interface = "1.0" [dev-dependencies] +serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" jupiter-swap-api-client = "0.2.0" base64 = "0.22.1" +bs58 = "0.5" diff --git a/src/chain_parsers/visualsign-solana/src/presets/jupiter_swap/mod.rs b/src/chain_parsers/visualsign-solana/src/presets/jupiter_swap/mod.rs index 69383312..5fa3904d 100644 --- a/src/chain_parsers/visualsign-solana/src/presets/jupiter_swap/mod.rs +++ b/src/chain_parsers/visualsign-solana/src/presets/jupiter_swap/mod.rs @@ -201,7 +201,7 @@ fn parse_route_instruction( JupiterSwapInstruction::parse_amounts_and_slippage_from_data(data)?; let in_token = accounts.first().map(|addr| get_token_info(addr, in_amount)); - let out_token = accounts.get(1).map(|addr| get_token_info(addr, out_amount)); + let out_token = accounts.get(5).map(|addr| get_token_info(addr, out_amount)); Ok(JupiterSwapInstruction::Route { in_token, @@ -219,7 +219,7 @@ fn parse_exact_out_route_instruction( JupiterSwapInstruction::parse_amounts_and_slippage_from_data(data)?; let in_token = accounts.first().map(|addr| get_token_info(addr, in_amount)); - let out_token = accounts.get(1).map(|addr| get_token_info(addr, out_amount)); + let out_token = accounts.get(5).map(|addr| get_token_info(addr, out_amount)); Ok(JupiterSwapInstruction::ExactOutRoute { in_token, @@ -237,7 +237,7 @@ fn parse_shared_accounts_route_instruction( JupiterSwapInstruction::parse_amounts_and_slippage_from_data(data)?; let in_token = accounts.first().map(|addr| get_token_info(addr, in_amount)); - let out_token = accounts.get(1).map(|addr| get_token_info(addr, out_amount)); + let out_token = accounts.get(5).map(|addr| get_token_info(addr, out_amount)); Ok(JupiterSwapInstruction::SharedAccountsRoute { in_token, @@ -349,8 +349,7 @@ fn create_jupiter_swap_expanded_fields( .map_err(|e| VisualSignError::ConversionError(e.to_string()))?, create_text_field("Input Token Name", &token.name) .map_err(|e| VisualSignError::ConversionError(e.to_string()))?, - create_text_field("Input Token Address", &token.address) - .map_err(|e| VisualSignError::ConversionError(e.to_string()))?, + // TODO: Add back Input Token Address ]); } @@ -759,4 +758,5 @@ mod tests { ); println!("✅ Platform Fee field present in expanded fields"); } + mod fixture_test; } diff --git a/src/chain_parsers/visualsign-solana/src/presets/jupiter_swap/tests/fixture_test.rs b/src/chain_parsers/visualsign-solana/src/presets/jupiter_swap/tests/fixture_test.rs new file mode 100644 index 00000000..f91645c0 --- /dev/null +++ b/src/chain_parsers/visualsign-solana/src/presets/jupiter_swap/tests/fixture_test.rs @@ -0,0 +1,236 @@ +// Fixture-based tests for Jupiter Swap instruction parsing +// See /src/chain_parsers/visualsign-solana/TESTING.md for documentation +// +// To add these tests to the existing tests module in mod.rs, add this line at the end +// of the existing `mod tests` block (before the closing brace): +// +// mod fixture_tests; +// +// This file will then be compiled as `tests::fixture_tests` + +use super::*; +use solana_sdk::{ + instruction::{AccountMeta, Instruction}, + pubkey::Pubkey, +}; +use std::str::FromStr; +use visualsign::SignablePayloadField; + +#[derive(Debug, serde::Deserialize)] +struct TestFixture { + description: String, + source: String, + signature: String, + cluster: String, + #[serde(default)] + full_transaction_note: Option, + #[allow(dead_code)] + instruction_index: usize, + instruction_data: String, + program_id: String, + accounts: Vec, + expected_fields: serde_json::Map, +} + +#[derive(Debug, serde::Deserialize)] +struct TestAccount { + pubkey: String, + signer: bool, + writable: bool, + #[allow(dead_code)] + description: String, +} + +fn load_fixture(name: &str) -> TestFixture { + let fixture_path = format!( + "{}/tests/fixtures/jupiter_swap/{}.json", + env!("CARGO_MANIFEST_DIR"), + name + ); + let fixture_content = std::fs::read_to_string(&fixture_path) + .unwrap_or_else(|e| panic!("Failed to read fixture {fixture_path}: {e}")); + serde_json::from_str(&fixture_content) + .unwrap_or_else(|e| panic!("Failed to parse fixture {fixture_path}: {e}")) +} + +fn create_instruction_from_fixture(fixture: &TestFixture) -> Instruction { + let program_id = Pubkey::from_str(&fixture.program_id).unwrap(); + let accounts: Vec = fixture + .accounts + .iter() + .map(|acc| { + let pubkey = Pubkey::from_str(&acc.pubkey).unwrap(); + AccountMeta { + pubkey, + is_signer: acc.signer, + is_writable: acc.writable, + } + }) + .collect(); + + // Instruction data from JSON RPC responses is base58 encoded + let data = bs58::decode(&fixture.instruction_data) + .into_vec() + .expect("Failed to decode base58 instruction data"); + + Instruction { + program_id, + accounts, + data, + } +} + +#[test] +fn test_route_real_transaction() { + use crate::core::VisualizerContext; + use solana_parser::solana::structs::SolanaAccount; + + let fixture: TestFixture = load_fixture("sample_route"); + println!("\n=== Testing Real Transaction ==="); + println!("Description: {}", fixture.description); + println!("Source: {}", fixture.source); + println!("Signature: {}", fixture.signature); + println!("Cluster: {}", fixture.cluster); + if let Some(note) = &fixture.full_transaction_note { + println!("Transaction Context: {note}"); + } + println!(); + + let instruction = create_instruction_from_fixture(&fixture); + let instructions = vec![instruction.clone()]; + + // Create a context - using index 0 since we only loaded the one relevant instruction + // In reality, the fixture.instruction_index would be used with all transaction instructions + let sender = SolanaAccount { + account_key: fixture.accounts.first().unwrap().pubkey.clone(), + signer: false, + writable: false, + }; + let context = VisualizerContext::new(&sender, 0, &instructions); + + // Visualize + let visualizer = super::JupiterSwapVisualizer; + let result = visualizer + .visualize_tx_commands(&context) + .expect("Failed to visualize instruction"); + + // Extract the preview layout + if let SignablePayloadField::PreviewLayout { + common, + preview_layout, + } = result.signable_payload_field + { + println!("\n=== Extracted Fields ==="); + println!("Label: {}", common.label); + if let Some(title) = &preview_layout.title { + println!("Title: {}", title.text); + } + + if let Some(expanded) = &preview_layout.expanded { + println!("\nExpanded Fields:"); + for field in &expanded.fields { + match &field.signable_payload_field { + SignablePayloadField::TextV2 { common, text_v2 } => { + println!(" {}: {}", common.label, text_v2.text); + } + SignablePayloadField::Number { common, number } => { + println!(" {}: {}", common.label, number.number); + } + SignablePayloadField::AmountV2 { common, amount_v2 } => { + println!(" {}: {}", common.label, amount_v2.amount); + } + _ => {} + } + } + } + + // Validate against expected fields + println!("\n=== Validation ==="); + for (key, expected_value) in &fixture.expected_fields { + let expected_str = expected_value + .as_str() + .unwrap_or_else(|| panic!("Expected field '{key}' is not a string")); + + if let Some(expanded) = &preview_layout.expanded { + let found = + expanded + .fields + .iter() + .any(|field| match &field.signable_payload_field { + SignablePayloadField::TextV2 { common, text_v2 } => { + let label_normalized = + common.label.to_lowercase().replace(" ", "_"); + let key_normalized = key.to_lowercase(); + let label_matches = label_normalized == key_normalized; + let value_matches = text_v2.text == expected_str; + + if label_matches { + if value_matches { + println!("✓ {key}: {expected_str} (matches)"); + } else { + println!( + "✗ {}: expected '{}', got '{}'", + key, expected_str, text_v2.text + ); + } + return value_matches; + } + false + } + SignablePayloadField::Number { common, number } => { + let label_normalized = + common.label.to_lowercase().replace(" ", "_"); + let key_normalized = key.to_lowercase(); + let label_matches = label_normalized == key_normalized; + let value_matches = number.number == expected_str; + + if label_matches { + if value_matches { + println!("✓ {key}: {expected_str} (matches)"); + } else { + println!( + "✗ {}: expected '{}', got '{}'", + key, expected_str, number.number + ); + } + return value_matches; + } + false + } + SignablePayloadField::AmountV2 { common, amount_v2 } => { + let label_normalized = + common.label.to_lowercase().replace(" ", "_"); + let key_normalized = key.to_lowercase(); + let label_matches = label_normalized == key_normalized; + let value_matches = amount_v2.amount == expected_str; + + if label_matches { + if value_matches { + println!("✓ {key}: {expected_str} (matches)"); + } else { + println!( + "✗ {}: expected '{}', got '{}'", + key, expected_str, amount_v2.amount + ); + } + return value_matches; + } + false + } + _ => false, + }); + + if !found { + println!("✗ {key}: field not found in output"); + } + + assert!( + found, + "Expected field '{key}' with value '{expected_str}' not found in visualization" + ); + } + } + } else { + panic!("Expected PreviewLayout field type"); + } +} diff --git a/src/chain_parsers/visualsign-solana/tests/fixtures/jupiter_swap/sample_route.json b/src/chain_parsers/visualsign-solana/tests/fixtures/jupiter_swap/sample_route.json new file mode 100644 index 00000000..bd2d4a24 --- /dev/null +++ b/src/chain_parsers/visualsign-solana/tests/fixtures/jupiter_swap/sample_route.json @@ -0,0 +1,170 @@ +{ + "description": "Jupiter Route swap - WSOL to USELESS swap transaction", + "source": "https://solscan.io/tx/441ttot8CzpgsiRHvAHnNTCBwbSnPuhuy43pCjzZU9BKwBuJeW8f4TMU7FYLeqBst6WJeMEHprdQxr4thxqZSxRs", + "signature": "441ttot8CzpgsiRHvAHnNTCBwbSnPuhuy43pCjzZU9BKwBuJeW8f4TMU7FYLeqBst6WJeMEHprdQxr4thxqZSxRs", + "cluster": "mainnet-beta", + "full_transaction_note": "This transaction has 6 instructions: [0-3] Token account setup, [4] Jupiter Route swap, [5] Close account. We're testing instruction [4].", + "instruction_index": 4, + "instruction_data": "8gKxDaCUhQVtYEpLAEXpKEYshjkYgM8mjv4s27FCdzQSyLQUbmy", + "program_id": "JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4", + "accounts": [ + { + "pubkey": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + "signer": false, + "writable": false, + "description": "Token program" + }, + { + "pubkey": "B7hSadyLX8YhNT8RDcK8RbnR3KAfX4HbWvV89XmeqitA", + "signer": true, + "writable": true, + "description": "User wallet" + }, + { + "pubkey": "3c5JEJ3un3HZAtWvZ77nhNGxDGqmWM7uZ1cx4bGDsKE8", + "signer": false, + "writable": true, + "description": "User source token account" + }, + { + "pubkey": "FAXnNWMXbadmfMTfWtEu3WDymtRwsxYLGdbKoJbfLKsK", + "signer": false, + "writable": true, + "description": "User destination token account" + }, + { + "pubkey": "JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4", + "signer": false, + "writable": false, + "description": "Jupiter program" + }, + { + "pubkey": "Dz9mQ9NzkBcCsuGPFJ3r1bS4wgqKMHBPiVuniW8Mbonk", + "signer": false, + "writable": false, + "description": "Output token mint (USELESS) - position 5" + }, + { + "pubkey": "JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4", + "signer": false, + "writable": false, + "description": "Jupiter program" + }, + { + "pubkey": "D8cy77BBepLMngZx6ZukaTff5hCt1HrWyKk3Hnd9oitf", + "signer": false, + "writable": false, + "description": "Event authority" + }, + { + "pubkey": "JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4", + "signer": false, + "writable": false, + "description": "Jupiter program" + }, + { + "pubkey": "whirLbMiicVdio4qvUfM5KAg6Ct8VwpYzGff3uctyCc", + "signer": false, + "writable": false, + "description": "Whirlpool program" + }, + { + "pubkey": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + "signer": false, + "writable": false, + "description": "Token program" + }, + { + "pubkey": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + "signer": false, + "writable": false, + "description": "Token program" + }, + { + "pubkey": "MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr", + "signer": false, + "writable": false, + "description": "Memo program" + }, + { + "pubkey": "B7hSadyLX8YhNT8RDcK8RbnR3KAfX4HbWvV89XmeqitA", + "signer": true, + "writable": false, + "description": "User wallet" + }, + { + "pubkey": "CCifuNxNLBZV9ch94GQ4QgdrmbBdG13xDfReomzqvhMC", + "signer": false, + "writable": true, + "description": "Account 14" + }, + { + "pubkey": "So11111111111111111111111111111111111111112", + "signer": false, + "writable": false, + "description": "Input token mint (WSOL) - position 15" + }, + { + "pubkey": "Dz9mQ9NzkBcCsuGPFJ3r1bS4wgqKMHBPiVuniW8Mbonk", + "signer": false, + "writable": false, + "description": "Output token mint (USELESS) - position 16 (duplicate)" + }, + { + "pubkey": "3c5JEJ3un3HZAtWvZ77nhNGxDGqmWM7uZ1cx4bGDsKE8", + "signer": false, + "writable": true, + "description": "User source token account" + }, + { + "pubkey": "4iBF54K8AY5dbwFZL8JBktqDrRvAnEkBs42aSRDYB14p", + "signer": false, + "writable": true, + "description": "Account 18" + }, + { + "pubkey": "FAXnNWMXbadmfMTfWtEu3WDymtRwsxYLGdbKoJbfLKsK", + "signer": false, + "writable": true, + "description": "User destination token account" + }, + { + "pubkey": "3YYrbiosq79pthXhFV8MwXThXWgb4Wn85fMiqPoo8dq4", + "signer": false, + "writable": true, + "description": "Account 20" + }, + { + "pubkey": "FdV51SZanY1thtYtseN5enYiASp78r7gtEcTo3xxg1eS", + "signer": false, + "writable": true, + "description": "Account 21" + }, + { + "pubkey": "4ThAi4HvBnXpgjBs5BDUQHosHjqMc1G6b9L88Djq1Ue8", + "signer": false, + "writable": true, + "description": "Account 22" + }, + { + "pubkey": "EHmLbEKVdix9brJJ13ey5o3VHo2f8w8Rr5NEwhBtHrrT", + "signer": false, + "writable": true, + "description": "Account 23" + }, + { + "pubkey": "68sWwn3c2exPMnAGe2STD3LFAc97bVBvGaGCHSEctK1", + "signer": false, + "writable": true, + "description": "Account 24" + } + ], + "expected_fields": { + "program_id": "JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4", + "input_amount": "2000000", + "quoted_output_amount": "1550653", + "output_token_address": "Dz9mQ9NzkBcCsuGPFJ3r1bS4wgqKMHBPiVuniW8Mbonk", + "slippage": "50", + "raw_data": "e517cb977ae3ad2a010000002f010064000180841e00000000003da9170000000000320000" + } +}